From ffa7a06b87a5cfe90c3b292d7425b8496cf872f4 Mon Sep 17 00:00:00 2001 From: ItsNik Date: Tue, 16 Dec 2025 18:18:55 +0100 Subject: [PATCH 01/12] Review of Changes since latest main update (wish me luck) (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Refactor: Improve JSON column handling and table creation This commit improves the handling of JSON columns within the sqlite-wrapper. It refactors the table creation process to accept a `jsonConfig` option, enabling automatic JSON serialization/deserialization for specified columns. - Adds `jsonConfig` to the `createTable` options, allowing specification of JSON columns. - Updates `transformRowToDb` to automatically serialize JSON columns before insertion/update. - Improves logging for debugging purposes. - Updates the example in the README to reflect the changes. * 📝 Update README and package version Updates the README with more detailed features, switches to MPL-2.0 license and bumps package version to 1.2.7. * ✨ Feature: Implement Plugin Support and Theme Enhancements This commit introduces plugin support to DockStat, allowing for extending functionality through external plugins. It also enhances theme capabilities and improves code organization. - Implemented PluginHandler for managing plugins, including registration, updates, and deletion. - Added database table for storing plugin metadata and configurations. - Enhanced ThemeHandler to provide access to the theme table. - Updated dependencies and scripts for building plugins. - Moved and renamed existing plugin handler to prevent naming collisions The `pluginHandler` now handles registration, updates, and deletions of plugins. It also creates a plugin table, for storing information about plugins, such as version number, the plugin component, and plugin settings. BREAKING CHANGE: The original plugin handler was renamed to prevent naming collisions when new packages are built. * feat(core): Plugin handler and runtime This commit introduces a comprehensive plugin handling system, enabling dynamic loading, management, and execution of plugins within the dockstat application. The key features include: - Plugin Registry: The core functionality is encapsulated in the PluginHandler class, responsible for registering, loading, updating, and deleting plugins. - Plugin API: introduces typings for plugin metadata, API, config and actions. - Dynamic Execution: Support for executing plugin actions and routes, allowing for dynamic interaction with plugins. BREAKING CHANGE: The plugin backend api has been changed * feat(lockfile): Remove bun.lock file This commit removes the `bun.lock` file. This file is no longer needed and is being removed to cleanup the codebase. The `bun.lock` file tracked the versions of dependencies used in the project. This is being moved to another file. * feat(sqlite-wrapper): Enhance create table and add metadata handling This commit introduces several enhancements to the create table functionality, including comprehensive type-safe column definitions using TableSchema, support for table constraints (PRIMARY KEY, UNIQUE, CHECK, FOREIGN KEY), and the ability to add table comments as metadata. Additionally, the commit includes bug fixes and improvements. - Implemented comprehensive type-safe column definitions using TableSchema - Added support for table constraints (PRIMARY KEY, UNIQUE, CHECK, FOREIGN KEY) - Added ability to add table comments as metadata - Improved error handling and validation for column definitions - Fixed issue with AUTOINCREMENT usage validation - Added utility functions for building SQL column definitions and table constraints * feat(dockstore): Initial commit for dockstore app This commit adds the basic scaffolding for the dockstore app. It also includes the installation of js-yaml and associated types and the sqlite-wrapper for local plugin data persistence. Some files from DockStacks were removed and the app code was added Body: - Created a simple index.ts file for the dockstore app. - Added a .gitignore file. - Added placeholder dependencies. - Updated package.json with updated dependencies and metadata for local plugin data persistence. - Removed files related to DockStacks templates and themes. - Added dockstacks plugin scaffolding * feat(dockstacks): Remove writeStackToDisk function call The `writeStackToDisk` function call was removed from the insertStack action. This function was responsible for writing the stack configuration to disk, but this functionality is no longer required. * feat(docknode): Implement Dockstack deployment endpoint This commit introduces the dockstack deployment endpoint, enabling dynamic deployment of Docker Compose stacks via API calls. - Adds .gitignore for ignoring node_modules, build artifacts, logs and other non-essential files. - Adds README.md for basic usage - Implements the `/dockstack/deploy/:id/:name` endpoint to handle stack deployments. - Includes file handling functions to write YAML configuration files. - Introduces a logger for the dockstack module for better traceability and error handling. - Includes a sample Elysia app with the dockstack endpoint integrated. - Adds a tsconfig.json file with compiler options configured for the project. * feat(docknode): Refactor dockstack deployment and add auth plugin This commit refactors the dockstack deployment process, introduces an authentication plugin, and includes several improvements to logging and configuration. The following changes were made: - Replaced the deprecated `bun.lock` file with `package.json` dependencies - Implemented a new Docker Compose file writing mechanism with variable parsing. - Added an authentication plugin to secure API endpoints. - Introduced more detailed logging for debugging and operational insights. - Add the ability to delete Dockstacks - Add Openapi integration * feat(logger): Refactor logger to support hierarchical logging This commit refactors the logger to support hierarchical logging using a Logger class. The Logger class allows for the creation of child loggers with a parent-child relationship, enabling more granular control over logging output. The logging format is updated to include the logger's prefix and a hierarchy of parent prefixes. * feat(dockstore): Implement plugin architecture and initial plugins This commit introduces a plugin architecture for DockStore, allowing for extensible functionality through plugins, themes, and stacks. It includes initial implementations for processing and building plugins, themes, and stacks, generating a manifest file, and updating the README with plugin details. The following changes were made: - Added .gitignore entry for .dist directory - Updated README to include DockStore description and plugin information - Implemented plugin architecture in index.ts to process plugins, themes, and stacks - Added utility functions for manifest processing and file system operations - Introduced a build process for plugins using Bun.build - Created new components including the actions and supporting files for the docknode-plugin and dockstacks plugin - Added a manifest.json file to showcase sample plugin metadata - Updated package.json with new dependencies - Created a util function for rendering markdown from manifest.json * fix(dockstat): update dependencies and add elysia-react-router This commit updates the dependencies in the dockstat package.json file. It also adds the elysia-react-router dependency. * feat(docknode): Implement docknode application This commit introduces the core functionality of the docknode application. It includes: - Dockerfile and .dockerignore for containerization - Environment type definitions - Updated package.json with build script and dependencies - Builder script for production builds - Authentication model and adapter - Dockstack handler with instance UUID generation - Removed the old auth handler (replaced with elysia-basic-auth) - Updated index.ts to include the auth plugin - Includes a basic test call This commit lays the foundation for deploying and managing docker stacks with authentication. * feat(bun.lock): Add docknode app and update dependencies This commit adds the docknode application to the bun.lock file, including its dependencies and development dependencies. It also updates various dependencies for other applications and includes an override for elysia to version 1.4.12. Finally, it updates the typings for the plugin action handler. * feat(dockstat): Refactor DockStat API with Elysia and backend routes This commit introduces Elysia as the backend framework for the DockStat API and includes restructuring the server for better modularity and scalability. The following changes were made: - Integrated Elysia for API routing and middleware management. - Created adapter routes using Elysia to handle Docker host configurations. - Refactored the .server/index.ts to prepare for Elysia integration. - Updated dependencies for elysia and react-router. - Minor edits to docstrings and README.md This change sets the foundation for future API enhancements and plugin support. * feat(docknode): Refactor docknode application This commit includes several changes to the docknode application: - Updates .dockerignore to exclude unnecessary files and directories. - Modifies the Dockerfile to improve build process and use bun. - Updates environment type definitions and builders. - Updates dockstack handler and its dependencies. - Removes unused or deprecated files related to archive data, front end, and docker setup The changes aim to improve code organization, reduce Docker image size, improve handling environment variables and auth Fixes: None * ✨ Refactor: Modernize DockStat app and server This commit introduces significant changes to modernize the DockStat application, focusing on server-side logic, API structure, and component architecture. - 🗑️ Removed outdated server-side logic and legacy routing mechanisms. - ✨ Introduced an Elysia-based API layer with OpenAPI documentation. - 📝 Implemented API handlers and consolidated data handling. - ♻️ Refactored server-side code for improved modularity and maintainability. - 🔥 Removed deprecated UI components and streamlined styling. - 🚀 Implemented updated dependency structure - ✅ Simplified routing setup These changes enhance the application's performance, scalability, and maintainability. * 🐛 Fix: Correctly derive file and line number in logger This commit fixes an issue where the file and line number reported by the logger were incorrect in certain environments. The fix involves: - Allowing the `DOCKSTAT_LOGGER_FULL_FILE_PATH` environment variable to influence how the caller information is derived. - Using the correct depth when extracting the line number from the stack trace. - Adding support for logger chaining using "@" as a separator This ensures that the logger reports the correct file and line number, even when source maps are used or when the code is run in a production environment. Also incremented the package version to 1.0.1. * ✨ Initial commit: React Router × ElysiaJS template This commit introduces a new template for building React Router v7 applications with an ElysiaJS backend. The template includes: - Dockerfile and `.dockerignore` for containerization - `.gitignore` for excluding files - README.md with project overview and instructions - ElysiaJS server setup with WebSocket example - React Router setup with example routes - Tailwind CSS configuration - Type safety and build configurations This template aims to provide a modern and efficient full-stack development experience with type safety and speed. * 📝 Update template package details and installation instructions This commit updates the package name, description, keywords, and files included in the template package. It also corrects the installation instruction in the README file to point to the correct GitHub repository URL. Finally, it adds a thank you message with a link to the repository upon installation. * ⬆️ Bump version to 1.0.2 Removes a misleading note about WebSocket support. * ``` 🐛 Fix(dockstat): Implement config updates and API request IDs This commit addresses several issues and introduces new features: - **elysia:** Updates the elysia version in docknode's package.json - **Database Updates:** Implements database config updates for dockstat, allowing modifications to the DockStatConfigTable and returning a comprehensive response including update status and the new configuration. - **API Request IDs:** Adds request IDs to API requests, improving traceability and debugging. - **Database Initialization:** Provides a more robust database initialization process with comprehensive default settings. - **Logger Context:** Adds request IDs to logger calls for better context. ``` * ✨ API: Enhance API Functionality and Structure This commit introduces several enhancements to the API, including: - Adds API documentation through OpenAPI and Scalar. - Implements request logging for debugging and monitoring. - Introduces a new endpoint to retrieve DockStat configuration. - Improves the way RequestIDs are handled and passed between the App and API - Removes `welcome.tsx` These changes aim to provide a more robust and well-documented API for DockStat. - Introduces Elysia Plugins and Handlers for enhanced organization * feat(plugin-handler): Implement PluginHandler class and update dependencies This commit introduces the `PluginHandler` class in `@dockstat/plugin-handler` and updates the dependencies to use workspace references. The `PluginHandler` class is responsible for managing plugins and interacting with the database and logger. It initializes a new `Logger` instance and uses `DB` and `QueryBuilder` classes from `@dockstat/sqlite-wrapper`. The `@dockstat/plugin-handler` package's dependencies are updated to use `workspace:*` for `@dockstat/logger` and `@dockstat/sqlite-wrapper`. This ensures that the package uses the latest versions of these dependencies from the workspace. The `bun.lock` file is updated to reflect the changes in the package dependencies. Additionally, the `@dockstat/typings` package is updated to export plugin-related types. * ✨ Implement Plugin Handler This commit introduces a PluginHandler class to manage and interact with plugins. The handler provides functionality for saving, deleting, loading, and unloading plugins from a database. It also includes a mechanism for triggering actions on loaded plugins based on Docker events. Key changes: - Created `PluginHandler` class with methods for managing plugins. - Implemented database interaction for plugin persistence. - Added event triggering mechanism to invoke plugin actions. - Introduced `definePlugin` helper function. - Updated typings to reflect new plugin structure * 🔥 Remove code: Delete unused plugin types The plugin types were moved to the plugins.ts file. This commit removes the duplicated types from the _types.ts file. * ✨ Refactor: Enhance plugin handler and SQLite wrapper This commit refactors the plugin handler and SQLite wrapper to improve functionality and maintainability. - Updates .gitignore to include .test-setup and exclude backup files. - Modifies PluginHandler to include tags, function column handling, and event triggering. - Deletes unused builder.ts file. - Updates test plugin example. - Enhances SQLite wrapper for better JSON and function column management. - Updates typings for plugins to include ID and refined table definitions. These changes enhance the plugin system's flexibility and robustness. * ✨ Add module and JSON parsing to query builder This commit introduces functionality for parsing and transforming JSON and MODULE columns in the query builder. It enhances data handling for both fetching (transformRowFromDb, transformRowsFromDb) and inserting/updating (transformRowToDb) operations. - Added logic to parse JSON columns when fetching data from the database, handling potential parsing errors gracefully. - Implemented support for MODULE columns, utilizing Bun.Transpiler to transform and compile function strings into object URLs for dynamic importing. - Updated the transformRowToDb method to serialize JSON columns before insertion/update. - Added comprehensive logging for debugging and monitoring the transformation processes. - Modified types to work with both JSON and Transpiler * ✨ feat: Introduce Plugin System with Database Integration This commit introduces a plugin system with database integration, allowing for dynamic loading and unloading of plugins. - Implemented a new `PluginHandler` class in `@dockstat/plugin-handler` to manage plugin loading, unloading, and event triggering. - Added a new API endpoint `/plugins` in `apps/dockstat/app/.server/api/plugins/index.ts` to fetch all plugins from the database. - Modified the database schema to include a `plugins` table with necessary metadata like name, version, manifest, author, and event hooks. - Updated `DockStatDB` to include a reference to the `pluginTable` QueryBuilder. - Modified `sqlite-wrapper` to include module support. * ``` feat(dockstat): Upgrade Bun runtime and enhance plugin handling This commit upgrades the Bun runtime from 1.2.20 to 1.3.1. It also includes enhancements to plugin handling within DockStat, including adding routes for plugin status, installation, deletion, and custom routes. API documentation has been kept up to date through scalar openapi for consistency ``` * ✨ Feature: Enhance plugin system and API with new functionalities and structure Adds plugin management API endpoints for installing, deleting, and activating plugins. Introduces bundling for dockstore plugins and improves logging for API requests. Adds DockStore route to React Router. - Implements plugin bundling via `bun` to `apps/dockstore` - Enhances API for plugin management: - Adds routes in Elysia for retrieving, installing, deleting, and activating plugins. - Adds proxy route - Adds handlers for saving, deleting, loading, and handling routes for plugins. - Adds manifest URL installation for plugins. - Improves API logging for requests and responses. - Adds new `DockStore` route to `app/routes.ts` - Adds `tags` and `detail` to the `database` route - Enhances DB with default config This commit restructures the plugin system, provides functionalities to manage plugins, improve the plugin loading/installation experience, API route management, and enhances logging for API requests. * feat(deps): Update dependencies and add bunfig.toml files This commit updates dependencies in `package.json` and `bun.lock` to their latest versions. It also adds `bunfig.toml` files to various packages and apps to configure bun with `linker = "isolated"` The `install-deps.sh` script was removed. * ✨ feat: Add UI package with shared components This commit introduces a new `ui` package that contains reusable UI components built with React, TypeScript, and Vite. This package will be used across multiple applications within the monorepo to maintain a consistent look and feel. The following components are included: - Badge - Button - Card - Checkbox - CheckboxGroup - Divider - HoverBubble - Input - Link - Modal - Table - Toggle These components provide basic building blocks for creating user interfaces, including styling with tailwindcss. 🔥 Remove CI/CD pipelines and images This commit removes the old CI/CD pipelines. It also removes images from the .github directory that are no longer needed. * ✨ feat: Theme provider and component styling updates Adds a theme provider using tailwind's @theme directive Updates component styles to use theme variables for consistent theming. Updates dependencies. Removes react logo from packages/ui/src/assets * 🚀 Bump @biomejs/biome to v2.3.3 This commit updates the `@biomejs/biome` dependency from version 1.9.4 to 2.3.3. This includes updating the `bun.lock` and `package.json` files to reflect the new version. A new `biome.json` file has been added to the `packages/ui` directory with linting, formatting, and assist configurations. The `eslint.config.js` file has been removed from the `packages/ui` directory. A new `DockStat2-06.png` file has been added to the `packages/ui/public` directory. Several component updates and new components has been added to the `packages/ui/src/components` directory. A new `Intro.stories.tsx` file has been added to the `packages/ui/src/stories` directory. A new `Slider.stories.tsx` file has been added to the `packages/ui/src/stories` directory. A new `Welcome.stories.tsx` file has been added to the `packages/ui/src/stories` directory. The `tailwind.css` file has been updated with slider styling and animations. Several welcome components have been added to the `packages/ui/src/welcome` directory. * ✨ Improve intro screen animation and add message prop The intro screen animation has been tweaked to improve the floating stripes and logo appearance. Also added a message property to the Intro screen to show a message in the Card. * 🚀 Implement Plugin Store UI with UI Package This commit introduces a Plugin Store UI in the dockstat app, leveraging components from the `@dockstat/ui` package. It also fixes an issue where the default config was not correctly written to the DB. - Updates the app's styling to use Tailwind CSS from the `@dockstat/ui` package, and removes unused CSS. - Implements DockStore component that fetches repos and installed plugins from the API to display plugin installation options. - Adds basic utility for pulling repository data from Github - Includes new dev tool configs * ✨ Implement Extension Browser and related actions This commit introduces the Extension Browser, allowing users to browse, install, and uninstall extensions. It includes: - New server actions for handling extension installation and uninstallation. - New server loaders for fetching extension data. - API endpoints for retrieving remote plugin manifests. - Parsers for different repository types (currently only GitHub). - UI components for displaying and interacting with extensions. - Updated the DockStore route to use the new Extension Browser. This also introduces a new react component @dockstat/ui ExtensionBrowser, and small changes to the db typings. * ✨ Implement repo manifest endpoint and plugin fetching This commit introduces a new `/repo/manifest` endpoint to fetch a repository's manifest file (manifest.yml) and updates the `/plugin/manifest` endpoint to accept a plugin name parameter. It also introduces the logic to retrieve and display a plugins metadata from various repositories in the Dockstore route. The following changes were made: - Added `getRepoManifest` function to fetch and parse the repository manifest file. - Modified the `/extensions/plugin/manifest` endpoint to expect a plugin name in the params. - Implemented API calls and logic to fetch and display plugin metadata in the Dockstore route, dynamically retrieving plugins from configured repositories. - Fixed a schema error in the plugin get all endpoint. * ✨ Feat: Reworked Plugin System and added frontend support This commit introduces several significant changes to the plugin system: - **Frontend Integration:** A new `/extensions` route is added to display available and installed plugins. This includes loaders and actions for fetching and managing plugins. - **Manifest Handling:** Modified how plugin manifests are fetched and processed. Removed unused code for remote plugin manifests. The system now focuses on retrieving a repository manifest containing a list of plugins. - **Elysia Plugins:** Added `@elysiajs/server-timing` for improved performance monitoring. - **Type Updates:** Changed the type definition for PluginMeta to allow for better data structure. - **Repo Manifest Type:** Added a type for the manifest repo to allow for more information. - **Dockstore:** Removed old dockstore code. * feat(dockstore): Improve plugin management and add schema validation This commit introduces several enhancements to the plugin management system in Dockstore, including: - Adds schema validation for plugin metadata using AJV to ensure data integrity and consistency. - Implements concurrent building of plugins with progress UI and enhanced error handling. - Introduces schema generation for plugin metadata to improve type safety and documentation. - Refactors and improves Extension Browser UI to display remote plugins and display associated metadata. - Updates plugin manifest format. These changes enhance the developer experience and improve the reliability of Dockstore's plugin system. * feat(extensions): Add plugin proxy and repo type to plugin metadata Adds a proxy endpoint for plugins to allow fetching manifest files from different sources. Also adds `repoType` to plugin metadata to specify the type of repository the plugin is hosted on. - Added a new proxy endpoint for plugins - Added `repoType` to plugin metadata schema - Updated plugin metadata in manifest files to include `repoType` - Updated plugin metadata type to include `repoType` - Updated UI components to display plugins and install them - Updated modal component to prevent background scroll when modal is open and to close on Escape key press * feat(extensions): Implement extension proxy and manifest fetching This commit introduces the following changes: - Implemented an extension proxy that redirects to GitHub raw content for plugin manifests. - Added a utility function to build GitHub raw content URLs. - Updated the extension API to use the proxy. - Updated the manifest files to use `.yml` extension. The extension proxy allows fetching plugin manifests directly from GitHub repositories, enabling dynamic plugin loading and updates. The manifest fetching functionality is updated to support the new proxy. * Adding Plugin bundle.js * feat(app): Refactor React Router integration and enhance plugin management This commit introduces significant changes to improve the integration between React Router and Elysia, as well as enhance the plugin management process. It includes a new API convention document, proxy actions and loaders, refactored extension actions and loaders, and various enhancements to API endpoints. The key changes include: - Introduction of an API convention document (`README.md`) that explains the integration pattern between Elysia, Eden Treaty, and React Router. - Implementation of proxy actions and loaders (`proxy.ts`) to handle API requests through React Router. - Refactoring of extension actions and loaders to improve code organization and maintainability. - Addition of a server entry point (`index.ts`) to explicitly start the Elysia server. - Enhancements to the plugin management process, including the ability to install plugins from URLs and proxy plugin API requests. - Update dependencies. - Update biome config. * feat(api): Enhanced logging and request ID handling This commit introduces several improvements to the DockStat API: - Added a global derive function in `DockStatElysiaHandlers` to extract the `x-dockstatapi-requestid` from headers and make it available globally. - Replaced `Elogger` with `ElysiaLogger` in `apps/dockstat/app/.server/api/handlers.ts` and `apps/dockstat/app/.server/api/plugins/index.ts` for more consistent logging. - Updated logging messages to include the request ID for easier debugging. - Added a new `ElysiaLogger` in `apps/dockstat/app/.server/api/logger.ts` to centralize logging for Elysia-related events. - Added `x-dockstatapi-requestid` header generation for treaty for easier end-to-end debugging These changes improve the API's observability and make it easier to track requests. * fix(dockstore): Remove sourcemap due to the insane size increase * feat(plugin-handler): Implement plugin installation and validation This commit introduces several changes to improve plugin installation and validation: - Adds error handling for plugin saving, returning a success/failure message. - Updates the plugin installation endpoint to accept a stringified JSON object for the plugin data. - Modifies the DB schema to store the plugin as a module instead of JSON, allowing for executable code. - Implements better validation of JSON parse errors in the backend for more comprehensive error handling. - Updates the UI to correctly format the plugin object before sending it to the backend. These changes enhance the stability and usability of the plugin installation process. * feat(api): Add metrics endpoint and plugin management UI This commit introduces a metrics endpoint for monitoring API usage and adds a basic UI for managing plugins. The following changes were made: - Added a metrics endpoint () that exposes Prometheus-formatted metrics. - Implemented middleware to track request metrics such as total requests, requests by method/path/status, request durations, and errors. - Collected database metrics such as size, page count, and table row counts. - Included process metrics such as memory usage and uptime. - Added a plugins route that currently allows to install, update and delete plugins via a manifest link. The following new files were added: - : Contains helper functions for collecting and formatting metrics. - : Implements the metrics endpoint. - : data loader for plugins page - : UI for plugin management. - : Prometheus configuration file for scraping metrics. * feat(api): Refactor API endpoints for clarity and metrics This commit refactors the API endpoints to improve code clarity and integrates metrics tracking for better performance monitoring. The changes include: - Updated imports and file formatting for consistency. - Added metrics middleware to track request durations, methods, paths, and status codes. - Enhanced error handling and logging for API requests. - Improved code readability and maintainability. * feat(plugins): Implement plugin list UI and navigation This commit introduces a new UI for displaying and managing plugins. It includes: - A new `PluginsPage` component in `@dockstat/ui` that displays a list of installed plugins, active repositories, and loaded plugins. - A `Navbar` component in `@dockstat/ui` for navigating between different sections of the app (Extensions and Plugins). - Updates to the `plugins` route in `apps/dockstat` to use the new `PluginsPage` component. - An overview card for plugin counts - A repo list displaying active repositories - Plugin cards displaying information about the plugin The commit also includes changes to the plugin loading logic to fetch dockstat config. The UI is built using components from `@dockstat/ui` such as `Card`, `Badge`, `LinkWithIcon` and `Table`. The overall experience is improved with a new logo. The commit also enhances the Table component with search, sorting, and filtering functionality. * feat(docker-client): Implement worker-based architecture and UI enhancements This commit introduces a worker-based architecture for the docker-client to improve performance and stability. It includes UI enhancements for extension browsing, such as a table view for plugins. The following changes were made: - Implemented a worker-based architecture for the docker-client. - Added a manager class to handle worker creation, initialization, and communication. - Created a new worker file to handle DockerClient instances in a separate thread. - Modified the DockerClient class to be compatible with the worker environment. - Added types for worker requests and responses. - Added utility functions for mapping container information and stats. - Added retry logic for Docker API operations. - Enhanced the UI for extension browsing with a table view for plugins. - Updated dependencies in package.json and bun.lock. The new architecture provides the following benefits: - Improved performance by offloading Docker API calls to separate threads. - Increased stability by isolating DockerClient instances from the main thread. - Enhanced scalability by allowing multiple workers to handle Docker API calls concurrently. The UI enhancements provide the following benefits: - Improved usability by allowing users to view plugin information in a table format. - Enhanced discoverability by making it easier to browse and filter plugins. * ``` feat(docker): Implement Docker Client Management API This commit introduces a new API endpoint for managing Docker clients. It allows registering, deleting, and listing Docker clients. It includes the ability to fetch host information for registered clients. The changes include: - Creation of a DockerClientManager class to handle Docker client registration, deletion, and listing. - Creation of `/api/docker` routes for managing Docker clients. - Modification DockerEventEmitter and StreamManager to adopt new Logger Chain - Integration of the DockerClientManager with an SQLite database for persistent storage. - Use DockerAdapterOptionsSchema for validation ``` * feat(api): Add endpoint to create Docker hosts Adds a POST endpoint `/api/docker/add` to allow creating new Docker host configurations. The endpoint expects a JSON payload containing clientId, hostname, name, secure, and port. This allows for dynamic creation of new Docker host connections. * feat(metrics): Implement persistent Prometheus metrics and refactor formatting This commit introduces persistent metrics storage using SQLite, allowing for tracking of total metrics across server sessions. It also refactors the Prometheus metrics formatting to improve readability and maintainability. The following changes were made: - Implemented SQLite-backed persistent metrics, including initialization, saving, and updating. - Added `DockStatDB` initialization to ensure DB is available. - Refactored metrics collection to track both session and persistent metrics. - Added error tracking. - Created `prometheus.ts` to handle prometheus metrics formatting. - Updated Prometheus metrics formatting to use a structured approach with MetricFamily and MetricSample interfaces. - Summarize durations to include p50, p95 and p99 values. - Separated Prometheus formatting from metrics collection. - Add support for labels. * feat(api): Implement docker host management and container retrieval This commit introduces several new features and improvements to the Docker API, focusing on host management and container retrieval: - Added endpoint for getting all containers for a specific client - Added endpoint for updating a host - Modified the register client endpoint to allow nullable options - Added a status endpoint for the docker API - Fixed a bug in the error handling where the error message was not correctly displayed - Fixed the table name in the metrics helper - Changed the way the origin port is handled in the treaty module - Removed the oldHost parameter from the updateHost method in the docker client - Refactored the removeHost method in the docker client to use the hostId instead of the host object - Refactored the host table name in the host handler - Updated the docker client options schema These changes provide more comprehensive control over Docker clients and their associated resources, enabling better monitoring and management capabilities. * feat(api): Implement docker status endpoint This commit introduces a new endpoint `/docker/status` that retrieves and returns the status of the Docker pool. It also adds an `initialized` property to the `WorkerMetrics` interface and sets it in the `DockerClientManager`. The docker client init function is now called upon initialisation. This helps ensure that the API can accurately report the health and state of the Docker environment. The commit includes: - Implementation of the `/docker/status` endpoint in the `dockstat` app. - Addition of the `initialized` property to the `WorkerMetrics` interface in the `docker-client` package. - Setting `initialized` value. - Calling init function. * feat(docker): Enhance host management and monitoring This commit introduces several enhancements to host management and monitoring within the Docker integration. The changes include: - **API Endpoint for Monitoring Control:** Added new API endpoints to start and stop monitoring for specific clients. - **Improved Host Fetching with Error Handling:** The API now returns hosts per client, including error messages if fetching hosts for a client fails. - **Host Update Functionality:** Implemented a mechanism to update existing Docker hosts, including updating the database record and restarting streams. - **Enhanced Error Logging:** Improved error logging throughout the application, providing more context for debugging. - **Worker Error Tracking:** The `DockerClientManager` now tracks worker errors, including the last error message and the number of errors encountered. This helps in diagnosing and resolving issues with worker processes. - **Docker status:** added more data to the return of the /status endpoint - **Worker Initialization Timeout:** Added a 30-second timeout for worker initialization. If a worker fails to initialize within this time, an error is reported and the worker is terminated. These changes improve the reliability and usability of the Docker integration, providing better insights into the status of Docker hosts and containers. * fix(api): Formatting and cleanup of API endpoints This commit addresses several formatting issues and performs general cleanup across various API endpoint files. Changes include: - Standardizing import statements. - Consistent use of semicolons and trailing commas. - Improving code readability and consistency by applying biome formatting. - Fixed type import for app load context and entrycontext in entry.server.tsx - Added format script to package.json * feat(plugin-handler): Add Docker client events and trigger hooks for plugins This commit introduces the ability for plugins to subscribe to Docker client events. It includes: - Added a `dockerClientEvents` property to the `Plugin` interface to allow plugins to define event handlers. - Added a `triggerDockerClientHooks` method to the `PluginHandler` class to trigger event handlers in loaded plugins. - Passed a `triggerHook` function to the DockerClientManager to trigger the hooks on the PluginHandler - Added formatting scripts to the docker-client and plugin-handler packages This allows plugins to react to Docker client events and perform custom actions based on those events. * feat(docker-client): Refactor event handling and update dependencies This commit refactors the event handling mechanism in the docker-client package by: - Removing the DockerEventEmitter class. - Introducing a proxyEvent function that posts events to the parent thread. - Updating dependencies, specifically referencing logger with workspace:*. These changes improve performance and simplify event handling within the docker-client package. * feat(core): Add plugin events and worker event proxy This commit introduces a new feature that allows plugins to register event handlers for Docker client events. This is achieved by: - Adding a new `events` property to the `Plugin` interface in `@dockstat/typings`. - Updating the `DockerClientManager` in `@dockstat/docker-client` to listen for events from workers and trigger the corresponding plugin event handlers. - Adding a new `proxyEvent` function in `@dockstat/docker-client` to proxy events from the worker to the main process. - Adding a new `@dockstat/utils` package to provide utility functions for building messages. - Upgrading bun-types to 1.3.3 The new `events` property in the `Plugin` interface allows plugins to specify a set of event handlers that will be called when the corresponding Docker client events are triggered. The `DockerClientManager` has been updated to listen for events from workers. When an event is received, the `DockerClientManager` will iterate over the registered plugins and call the corresponding event handler, if one exists. The `proxyEvent` function is used to proxy events from the worker to the main process. This function takes the event type, the event context, and an optional additional context as arguments. It then posts a message to the main process containing the event data. The `@dockstat/utils` package provides utility functions for building messages. This package is used by the `proxyEvent` function to build the message that is posted to the main process. * feat(docker-client): Refactor DockerClientManager for improved worker management and plugin event handling This commit introduces significant refactoring to the DockerClientManager, focusing on enhanced worker management, plugin event handling, and overall code structure. The key changes include: - **Migration to Worker Pool Pattern:** The DockerClientManager now manages a pool of workers for executing Docker client operations, improving concurrency and resource utilization. - **Mixin-Based Architecture:** The DockerClientManager is now structured using mixins to separate concerns such as host management, container management, image management, and monitoring, resulting in a more modular and maintainable codebase. - **Event-Driven Architecture:** Improved event handling using plugins by proxying the event through the in the Docker Client, using a typed message builder - **Improved Client Lifecycle Management:** Enhanced worker lifecycle management, including worker creation, initialization, error handling, and cleanup. - **Centralized worker logic in ** This file is the only entrypoint for Workers - **Expose on endpoint of route.** This method is called directly, instead of fetching all clients and then fetching each client - **Refactor API endpoint to accept a query parameter.** This allows retrieving the stored clients and merging them with the initialized ones - **Refactor API endpoint to use the function.** - **Enhance Worker Error Handling:** Improved worker error handling with detailed error logging and automatic worker termination upon critical errors. The diff includes the following major components: - : New directory containing the core logic of the DockerClientManager, split into mixins for different functionalities. - : New file containing the entry point for worker processes. - : Updated exports to reflect the new directory structure. - : Update API endpoints - : Import from the new path - : Caching Docker client event handlers. - : Added a method to build the message data when an event is dispatched These changes improve the scalability, maintainability, and reliability of the DockerClientManager, making it better suited for handling a large number of Docker clients and hosts. * feat(docker-client): Implement Docker host and container monitoring This commit introduces a comprehensive monitoring system for Docker hosts and containers. It includes health checks, event monitoring, and metric collection, leveraging separate monitors for each aspect. The changes include: - Refactored `MonitoringManager` into seperate monitors for healthchecks, container events and metrics. - Implemented `HealthCheckMonitor` to periodically ping Docker hosts and emit `host:health:changed` events. - Implemented `ContainerEventMonitor` to monitor container state changes (created, started, stopped, removed) and emit corresponding events. - Implemented `HostMetricsMonitor` to collect host-level metrics (CPU, memory, OS, Docker version) and emit `host:metrics` events. - Implemented `DockerEventStreamManager` which streams docker events from the docker daemon and emits container events in almost real time (without polling). - Added retry logic to handle temporary connection issues with Docker daemons. - Added various logging improvements and fixes. The new monitoring system provides a foundation for real-time insights into the health and performance of Docker environments. * feat(docker-client): Improve Monitoring and Logging This commit introduces several enhancements to the docker-client package, focusing on improved monitoring capabilities and more detailed logging. The changes include: - Enhanced logging within the MonitoringManager and related classes by passing logger parents for better context. - Added debug logging for container mapping. - Corrected typo in proxy logger. - Improved retry logic in the `withRetry` function. - Added a base utils logger These changes provide more visibility into the monitoring process and aid in debugging potential issues. * feat(dockstore): Plugin Architecture Refactor and New DockMon Plugin This commit introduces a major refactor of the DockStore plugin architecture to improve maintainability and add new features. This includes: - Improved progress UI with clear task status and error highlighting. - Concurrent plugin building for faster build times. - Error tracking and detailed summaries for debugging. - Enhanced validation and schema generation. - Introduction of a new DockMon plugin for metrics and monitoring. - Improved error handling - Implemented UI clearing The architecture is now more resilient and easier to extend, paving the way for new plugins and features. The new DockMon plugin introduces a default plugin that collects container and host metrics and stores them in the DockStat database. This change required a migration to: - Chalk (for progress output colors) - Errors list (for error tracking) * feat(plugin): Implement DockMon plugin and save default plugins during startup This commit introduces the DockMon plugin, a default plugin for DockStat that enables monitoring and metrics pages. This plugin listens for and events and saves the data in a table in the main DockStat DB. Additionally, this commit includes changes to save default plugins during startup. The function scans the directory for plugins and saves them to the database. The following changes were made: - Created the DockMon plugin in . - Implemented the function in . - Modified the method in to include the in the host object. - Updated dependencies in various files. - Fixed a bug in where the was not being passed to the event. - Added logging for validation of plugins. - Changed Plugin Column from column.module() to column.text() The commit also includes: - Fixed a bug with missing handler logs - Changed imports to not fail the build on the first attempt - Fixed a bug where reqID was not always shown - Reduced logs in production and added truncate to reduce load for parsing and debugging - Added ignore messages and the option to disable individual loggers These changes enable DockStat to collect and store monitoring and metrics data from Docker hosts and containers, and provide a mechanism for extending DockStat with plugins. * feat(dockstat): enhance database and plugin handling This commit introduces several enhancements to the database layer and plugin handling within DockStat, including: - Implemented WAL mode and caching for SQLite to improve performance and reliability. - Added database revisioning to ensure smooth migrations and prevent data corruption during updates. - Introduced BOOLEAN parser to improve bool conversion - Improved Plugin Handler to correctly load and assign IDs to plugins. - Fixed a bug in the DockMon plugin that prevented host metrics from being properly saved. - Updated the docker-client manager to use `PluginHandler` when applying the events to the workers This commit enhances the overall stability and performance of DockStat, while also providing a more robust and flexible plugin system. * Add standalone DockStat API app and update client integration (#11) * feat(api): Implement component and page API endpoints This commit introduces API endpoints for managing components and pages. - Added `/api/components` endpoint to retrieve component JavaScript and library information. - Added `/api/pages` endpoint to retrieve all pages. - Implemented PageHandler class to manage page data and interact with the database. - Implemented ComponentLibrary class to manage registered components and interact with the database. - Created database tables for components and pages using `@dockstat/sqlite-wrapper`. * feat(api): Initial DockStat API setup This commit introduces the initial setup for the DockStat API. It includes: - API project setup with basic files (.gitignore, README.md, package.json) - Core API functionality (database interaction, docker client management, plugin handling) - API routes for DB configuration, Docker management, metrics, and plugins - ElysiaJS integration for API server and plugin management - Middleware for metrics collection - Default plugins for DockStat core functionality Diff: This commit includes a significant amount of new code, laying the foundation for the DockStat API. It introduces the basic structure, dependencies, and initial functionality for interacting with the database, managing Docker clients, handling plugins, and exposing API endpoints. * feat(api): Refactor and Enhance API Structure This commit introduces several key changes to enhance the API's structure, error handling, and plugin management. - Updates package dependencies, including elysia to version 1.4.12, and adds @elysiajs/cors. - Modifies elysia-plugins.ts to enable server timing based on the DOCKSTATAPI_SHOW_TRACES environment variable. - Adds a new error handling middleware in onError.ts to provide structured error responses for validation, parsing, and not found errors. - Refactors index.ts to include cors middleware and the new errorHandler. - Updates database and docker models to improve type safety and consistency. - Modifies plugin utilities, logger and saveDefaultPlugins, to enhance logging and plugin bundling. - Removes deprecated code and proxies to improve React Router integration. - Adds Onboarding route and moves files around The main goal of this commit is to improve the API's reliability, maintainability, and developer experience. * chore(dockstat): Untrack bundled output and update gitignore The '.bundled/index.js' file has been removed from version control, and the '.gitignore' file updated to explicitly ignore the entire '.bundled' directory. This ensures that generated build artifacts are not committed to the repository. * refactor(api, docker-client): improve configuration, plugin loading, and client data handling Refine API server timing enablement logic to correctly interpret `DOCKSTATAPI_SHOW_TRACES` environment variable, allowing explicit disabling with "false". Enhance default plugin loading by using `node:url` and `node:path` for robust directory resolution, and a more specific glob pattern to find `index.ts/js` files. Overhaul `DockerClientManagerCore.getAllClients` to accurately merge live worker data with stored client information, ensuring consistent `initialized` status and reliable client naming. * refactor(api): Enhance plugin management, bundling, and add hook discovery - **Plugin Handling Improvements**: - Refactored `PluginHandler.loadPlugins` and `PluginHandler.loadPlugin` for more robust error reporting and immediate activation of newly saved plugins. - Adjusted plugin bundling with `Bun.build` to use ESM format, disable aggressive minification, and include a banner for `import.meta.require` compatibility. - Corrected the default directory path for loading default plugins. - Modified temporary file creation for bundled plugins to utilize a dedicated subdirectory. - Optimized `savePlugin` logging and refined the `saveDefaultPlugins` `id` increment logic. - Enhanced `PluginHandler.getStatus` to exclude plugin source code, reducing API response payload size. - **API Enhancements**: - Introduced a new GET `/plugins/hooks` endpoint to provide a list of available event hooks from currently loaded plugins. - **Docker Client Manager Refinements**: - Improved `maxWorkers` environment variable parsing to correctly handle `0` or empty string values, ensuring proper numeric assignment. - Streamlined event handler initialization to consistently use the PluginHandler for hook discovery. - Added debug logging within `triggerHooks` for better visibility into event processing. - **Code Quality & Maintenance**: - Replaced nullish coalescing `??` with logical OR `||` in specific environment variable parsing for broader falsy value handling. - Implemented `type` imports for `Logger` and `QueryBuilder` for improved type safety and potential tree-shaking benefits. - Updated `.gitignore` to properly ignore new `.bundle` build artifacts and SQLite journal files. - Updated `bun.lock` to reflect dependency changes. * feat(plugin-api): Implement action-based plugin API routes Reworked plugin API routing to be declarative and action-based. This centralizes route handling within the `PluginHandler` and provides a clearer interface for plugins. * **Plugin API Rework**: * Removed direct `Elysia` route instances from plugins (`routes?: AnyElysia` in `Plugin` interface). * Plugins now define `apiRoutes` and an `actions` map in their `PluginConfig`. API routes link paths and methods to specific actions defined in the plugin's `actions`. * The `PluginHandler`'s `handleRoute` method was refactored to directly interpret `PluginConfig.apiRoutes` and execute the corresponding actions. * **DockMon Plugin Enhancement**: * Added a new `getSavedMetrics` action to the `DockMon` plugin, allowing retrieval of all saved metrics. * Integrated `getSavedMetrics` with the `/all` API route for `DockMon`. * Updated `host:metrics` event handler to use the provided `logger` instead of `console.info`. * **Docker Client Refactor**: * Removed the unused `clientId` property from `DockerClient`. * Changed monitoring manager initialization failures from a `warning` to an `error` event. * Improved error logging in `withRetry` utility to handle non-Error objects gracefully. * Added type assertions for `additionalCtx` in `DockerClientManagerCore.handleEvent` to address typing issues. * **Plugin Loading & Stability**: * Ensured `pluginHandlerFactory.loadAllPlugins()` is properly awaited in `saveDefaultPlugins` for correct asynchronous behavior. * Removed `this.loadPlugins(success)` from `installPlugin`, as `loadAllPlugins` now manages the loading process. * **Typing Updates**: Updated `PluginRoute` and `PluginConfig` types to align with the new action-based architecture. * chore(lint, workflows): Added new turbo based lint workflow * ci(lint): Configure GitHub Actions to use Biome's GitHub reporter * ci(lint): Install Bun dependencies for lint workflow * ci(lint): Automatically fix lint issues and commit changes * Lint * chore(ci): Update lint workflow's commit author and message format * feat(plugins): Implement generic parser, route discovery, and refactor API handling - Refactor plugin API route handling (`/:id/routes/*`) to support all HTTP methods. - Introduce a generic `Parser` configuration for plugin tables, enabling custom transformations for JSON, module code, and boolean column types. - Centralize plugin action execution into a new `triggerRouteAction` method for better modularity. - Add a new `/plugins/routes` endpoint to allow discovery of all active plugin API routes. - Streamline the `PluginActionContext` to simplify the interface for plugin action handlers. * feat(dockmon): Implement action chaining and specialized metric retrieval - Renamed `getSavedMetrics` to `getAllMetrics` for improved clarity. - Added `getContainerMetrics` and `getHostMetrics` actions to allow retrieval of metrics based on their type. - Introduced a new API route `/test` that demonstrates chaining multiple plugin actions (`test1`, `test2`) together. - Refactored the plugin configuration by moving `jsonColumns` to a more generic `parser.JSON` property, enabling future expansion of parser types. - Updated the `Parser` interface in `@sqlite-wrapper` to make `JSON`, `BOOLEAN`, and `MODULE` properties optional. * chore(ci): Lint * ci(lint): Run lint workflow only on push * chore(dockstat): Remove generated bundle file This file appears to be a generated bundle output that was mistakenly committed. Generated files should not be part of the source control to prevent unnecessary diffs and ensure clean builds. * feat(readme): Introduce comprehensive project documentation and guides This commit significantly revamps the project's `README.md` to provide a detailed overview and essential guides for both new users and contributors. Key updates include: - Expanded project overview, core features, and technology stack. - New dedicated sections covering repository structure, developer getting started, plugin system architecture, stack management, multi-node adapters, and development workflow guidelines. - Clear articulation of known limitations, a high-level roadmap, contributing guidelines, and documentation resources. - Integration of technology badges for quick reference. - Addition of `DockStat2-04.png` as a visual asset, likely for workflow illustration. - Updated `@dockstat/typings` package metadata (added `private` flag, `author` info) and bumped the version to `1.1.0`. - Removed `tsconfig.tsbuildinfo` from `packages/typings`. * docs(github): Add DockStat2-04 screenshot * chore(lint): Allow empty commits in lint workflow * chore(ci): Lint * refactor(monorepo): Modernize JS/TS, enhance types, and cleanup code Code Modernization: - Replaced string concatenations with template literals for enhanced readability. - Standardized strict equality (===) and optional chaining (?.). - Adopted `Number.isFinite()`, `Number.isNaN()`, and explicit radix in `Number.parseInt()`. Type Safety Enhancements: - Upgraded `any` to `unknown` in various TypeScript type definitions. - Improved mixin types and added Biome ignore comments for intentional `any` usage. Dependency & Code Cleanup: - Removed numerous unused imports and variables across `apps/api`, `apps/docknode`, `apps/dockstat`, `apps/dockstore`, and various `packages/`. - Deleted outdated example files from `packages/docker-client/examples/`. - Eliminated redundant code elements, such as empty JSX fragments in React components. Tooling: - Updated `biome.json` to refine file inclusion/exclusion for linting and formatting. * chore(ci): Lint * feat(outline-sync): Re-implement and simplify sync logic This commit completely re-implements the `outline-sync` package, moving from a complex, multi-collection, file-based configuration system to a simpler, CLI-driven approach. Key changes include: * **Simplified Architecture**: The entire core logic (e.g., `lib/config`, `lib/init`, `lib/outlineApi`, `lib/syncEngine`, `lib/types`) has been replaced with a new, more modular `src/` structure. * **New CLI Commands**: Introduces `sync`, `watch`, and `ci` commands for one-time pull, bidirectional watching, and CI/CD integration, respectively, powered by `commander`. * **Direct Configuration**: Configuration is now primarily driven by CLI options (`--url`, `--token`, `--output`) and environment variables, reducing reliance on local config files. * **Package Renaming**: The package name has been updated from `@dockstat/outline-sync` to `outline-sync`. * **Dependency Updates**: Removed `@dockstat/logger` and `typescript` peer dependency. Added `commander`, `chokidar`, `front-matter`, and `yaml` for the new implementation, along with updated `bun-types` and `@types/node`. * **License Change**: Switched the package license from MPL-2.0 to MIT. * **Updated README**: The documentation has been completely rewritten to reflect the new features and usage. * **Bundling**: The CLI is now bundled into `dist/index.js` for simpler distribution and execution. This refactor aims to make `outline-sync` easier to use, understand, and integrate into various workflows, especially for CI/CD environments. * feat(outline-sync): Introduce config file support and init command Introduces a flexible configuration system for `outline-sync`, allowing settings to be defined via: - A dedicated `outline-sync.config.json` file (recommended) - Environment variables - CLI arguments Adds a new `outline-sync init` command to easily generate a sample `outline-sync.config.json` file. This streamlines initial setup and encourages best practices for configuration. Detailed changes: - **Configuration Precedence**: CLI arguments now take precedence over config file settings, which take precedence over environment variables. - **`init` Command**: New command to scaffold `outline-sync.config.json`. - **Enhanced `customPaths`**: `customPaths` in the config file now support paths relative to the current working directory (e.g., `../../README.md`). - **Improved CLI Options**: `sync`, `watch`, and `ci` commands now accept a `--config ` option. - **Package Name Update**: Renamed package from `outline-sync` to `@dockstat/outline-sync` in `package.json`. - **Extensive Documentation**: README updated to reflect all new configuration methods, custom path examples, document ID discovery, and CI/CD best practices for config files. This enhancement significantly improves the usability and configurability of the `outline-sync` tool. * refactor(docs): clear local content and enhance Outline sync Removed all existing documentation files and sync configurations from `apps/docs`. This prepares the directory for a fresh documentation structure, likely to be managed more actively via the Outline sync tool. The `@dockstat/outline-sync` package has been updated (v1.2.1) with significant enhancements: - **Collection Filtering**: Introduced `includeCollections` and `excludeCollections` options, allowing users to specify which Outline collections should be synchronized. This can be configured via CLI (`--include`, `--exclude`) or in `outline-sync.config.json`. - **Improved Package Management**: The `package.json` now includes a `files` field for better control over published assets. - **Internal Refinements**: Updated `fs` and `path` imports for Bun compatibility and `Bun.write` to `writeFile`. - **Documentation Update**: The package's `README.md` has been revised to reflect the new features and installation methods. A new `apps/docs/outline-sync.config.json` is introduced to serve as the initial configuration for the restructured documentation setup. * feat(docs): Automate Outline documentation sync with hierarchical structure Implement a new GitHub Actions workflow (`docs-sync.yaml`) to automatically synchronize documentation from Outline. The workflow runs on pushes to `main`, a 6-hour schedule, and manual dispatch. Enhance `@dockstat/outline-sync` to support hierarchical document syncing, mirroring Outline's parent-child structure in the local filesystem. Each document is now stored in a `README.md` within a directory representing its title. Update `apps/docs/outline-sync.config.json` to store documents directly in the `apps/docs` root and to filter for the "DockStat" collection only. Bump `@dockstat/outline-sync` version to `1.2.3` and add a `prepublishOnly` script for build automation. * chore(ci): Lint --------- Co-authored-by: actions user Co-authored-by: actions-user * chore(ci): Lint * Merge current dev branch into Review (#12) * feat(api): Implement component and page API endpoints This commit introduces API endpoints for managing components and pages. - Added `/api/components` endpoint to retrieve component JavaScript and library information. - Added `/api/pages` endpoint to retrieve all pages. - Implemented PageHandler class to manage page data and interact with the database. - Implemented ComponentLibrary class to manage registered components and interact with the database. - Created database tables for components and pages using `@dockstat/sqlite-wrapper`. * feat(api): Initial DockStat API setup This commit introduces the initial setup for the DockStat API. It includes: - API project setup with basic files (.gitignore, README.md, package.json) - Core API functionality (database interaction, docker client management, plugin handling) - API routes for DB configuration, Docker management, metrics, and plugins - ElysiaJS integration for API server and plugin management - Middleware for metrics collection - Default plugins for DockStat core functionality Diff: This commit includes a significant amount of new code, laying the foundation for the DockStat API. It introduces the basic structure, dependencies, and initial functionality for interacting with the database, managing Docker clients, handling plugins, and exposing API endpoints. * feat(api): Refactor and Enhance API Structure This commit introduces several key changes to enhance the API's structure, error handling, and plugin management. - Updates package dependencies, including elysia to version 1.4.12, and adds @elysiajs/cors. - Modifies elysia-plugins.ts to enable server timing based on the DOCKSTATAPI_SHOW_TRACES environment variable. - Adds a new error handling middleware in onError.ts to provide structured error responses for validation, parsing, and not found errors. - Refactors index.ts to include cors middleware and the new errorHandler. - Updates database and docker models to improve type safety and consistency. - Modifies plugin utilities, logger and saveDefaultPlugins, to enhance logging and plugin bundling. - Removes deprecated code and proxies to improve React Router integration. - Adds Onboarding route and moves files around The main goal of this commit is to improve the API's reliability, maintainability, and developer experience. * chore(dockstat): Untrack bundled output and update gitignore The '.bundled/index.js' file has been removed from version control, and the '.gitignore' file updated to explicitly ignore the entire '.bundled' directory. This ensures that generated build artifacts are not committed to the repository. * refactor(api, docker-client): improve configuration, plugin loading, and client data handling Refine API server timing enablement logic to correctly interpret `DOCKSTATAPI_SHOW_TRACES` environment variable, allowing explicit disabling with "false". Enhance default plugin loading by using `node:url` and `node:path` for robust directory resolution, and a more specific glob pattern to find `index.ts/js` files. Overhaul `DockerClientManagerCore.getAllClients` to accurately merge live worker data with stored client information, ensuring consistent `initialized` status and reliable client naming. * refactor(api): Enhance plugin management, bundling, and add hook discovery - **Plugin Handling Improvements**: - Refactored `PluginHandler.loadPlugins` and `PluginHandler.loadPlugin` for more robust error reporting and immediate activation of newly saved plugins. - Adjusted plugin bundling with `Bun.build` to use ESM format, disable aggressive minification, and include a banner for `import.meta.require` compatibility. - Corrected the default directory path for loading default plugins. - Modified temporary file creation for bundled plugins to utilize a dedicated subdirectory. - Optimized `savePlugin` logging and refined the `saveDefaultPlugins` `id` increment logic. - Enhanced `PluginHandler.getStatus` to exclude plugin source code, reducing API response payload size. - **API Enhancements**: - Introduced a new GET `/plugins/hooks` endpoint to provide a list of available event hooks from currently loaded plugins. - **Docker Client Manager Refinements**: - Improved `maxWorkers` environment variable parsing to correctly handle `0` or empty string values, ensuring proper numeric assignment. - Streamlined event handler initialization to consistently use the PluginHandler for hook discovery. - Added debug logging within `triggerHooks` for better visibility into event processing. - **Code Quality & Maintenance**: - Replaced nullish coalescing `??` with logical OR `||` in specific environment variable parsing for broader falsy value handling. - Implemented `type` imports for `Logger` and `QueryBuilder` for improved type safety and potential tree-shaking benefits. - Updated `.gitignore` to properly ignore new `.bundle` build artifacts and SQLite journal files. - Updated `bun.lock` to reflect dependency changes. * feat(plugin-api): Implement action-based plugin API routes Reworked plugin API routing to be declarative and action-based. This centralizes route handling within the `PluginHandler` and provides a clearer interface for plugins. * **Plugin API Rework**: * Removed direct `Elysia` route instances from plugins (`routes?: AnyElysia` in `Plugin` interface). * Plugins now define `apiRoutes` and an `actions` map in their `PluginConfig`. API routes link paths and methods to specific actions defined in the plugin's `actions`. * The `PluginHandler`'s `handleRoute` method was refactored to directly interpret `PluginConfig.apiRoutes` and execute the corresponding actions. * **DockMon Plugin Enhancement**: * Added a new `getSavedMetrics` action to the `DockMon` plugin, allowing retrieval of all saved metrics. * Integrated `getSavedMetrics` with the `/all` API route for `DockMon`. * Updated `host:metrics` event handler to use the provided `logger` instead of `console.info`. * **Docker Client Refactor**: * Removed the unused `clientId` property from `DockerClient`. * Changed monitoring manager initialization failures from a `warning` to an `error` event. * Improved error logging in `withRetry` utility to handle non-Error objects gracefully. * Added type assertions for `additionalCtx` in `DockerClientManagerCore.handleEvent` to address typing issues. * **Plugin Loading & Stability**: * Ensured `pluginHandlerFactory.loadAllPlugins()` is properly awaited in `saveDefaultPlugins` for correct asynchronous behavior. * Removed `this.loadPlugins(success)` from `installPlugin`, as `loadAllPlugins` now manages the loading process. * **Typing Updates**: Updated `PluginRoute` and `PluginConfig` types to align with the new action-based architecture. * chore(lint, workflows): Added new turbo based lint workflow * ci(lint): Configure GitHub Actions to use Biome's GitHub reporter * ci(lint): Install Bun dependencies for lint workflow * ci(lint): Automatically fix lint issues and commit changes * Lint * chore(ci): Update lint workflow's commit author and message format * feat(plugins): Implement generic parser, route discovery, and refactor API handling - Refactor plugin API route handling (`/:id/routes/*`) to support all HTTP methods. - Introduce a generic `Parser` configuration for plugin tables, enabling custom transformations for JSON, module code, and boolean column types. - Centralize plugin action execution into a new `triggerRouteAction` method for better modularity. - Add a new `/plugins/routes` endpoint to allow discovery of all active plugin API routes. - Streamline the `PluginActionContext` to simplify the interface for plugin action handlers. * feat(dockmon): Implement action chaining and specialized metric retrieval - Renamed `getSavedMetrics` to `getAllMetrics` for improved clarity. - Added `getContainerMetrics` and `getHostMetrics` actions to allow retrieval of metrics based on their type. - Introduced a new API route `/test` that demonstrates chaining multiple plugin actions (`test1`, `test2`) together. - Refactored the plugin configuration by moving `jsonColumns` to a more generic `parser.JSON` property, enabling future expansion of parser types. - Updated the `Parser` interface in `@sqlite-wrapper` to make `JSON`, `BOOLEAN`, and `MODULE` properties optional. * chore(ci): Lint * ci(lint): Run lint workflow only on push * chore(dockstat): Remove generated bundle file This file appears to be a generated bundle output that was mistakenly committed. Generated files should not be part of the source control to prevent unnecessary diffs and ensure clean builds. * feat(readme): Introduce comprehensive project documentation and guides This commit significantly revamps the project's `README.md` to provide a detailed overview and essential guides for both new users and contributors. Key updates include: - Expanded project overview, core features, and technology stack. - New dedicated sections covering repository structure, developer getting started, plugin system architecture, stack management, multi-node adapters, and development workflow guidelines. - Clear articulation of known limitations, a high-level roadmap, contributing guidelines, and documentation resources. - Integration of technology badges for quick reference. - Addition of `DockStat2-04.png` as a visual asset, likely for workflow illustration. - Updated `@dockstat/typings` package metadata (added `private` flag, `author` info) and bumped the version to `1.1.0`. - Removed `tsconfig.tsbuildinfo` from `packages/typings`. * docs(github): Add DockStat2-04 screenshot * chore(lint): Allow empty commits in lint workflow * chore(ci): Lint * refactor(monorepo): Modernize JS/TS, enhance types, and cleanup code Code Modernization: - Replaced string concatenations with template literals for enhanced readability. - Standardized strict equality (===) and optional chaining (?.). - Adopted `Number.isFinite()`, `Number.isNaN()`, and explicit radix in `Number.parseInt()`. Type Safety Enhancements: - Upgraded `any` to `unknown` in various TypeScript type definitions. - Improved mixin types and added Biome ignore comments for intentional `any` usage. Dependency & Code Cleanup: - Removed numerous unused imports and variables across `apps/api`, `apps/docknode`, `apps/dockstat`, `apps/dockstore`, and various `packages/`. - Deleted outdated example files from `packages/docker-client/examples/`. - Eliminated redundant code elements, such as empty JSX fragments in React components. Tooling: - Updated `biome.json` to refine file inclusion/exclusion for linting and formatting. * chore(ci): Lint * feat(outline-sync): Re-implement and simplify sync logic This commit completely re-implements the `outline-sync` package, moving from a complex, multi-collection, file-based configuration system to a simpler, CLI-driven approach. Key changes include: * **Simplified Architecture**: The entire core logic (e.g., `lib/config`, `lib/init`, `lib/outlineApi`, `lib/syncEngine`, `lib/types`) has been replaced with a new, more modular `src/` structure. * **New CLI Commands**: Introduces `sync`, `watch`, and `ci` commands for one-time pull, bidirectional watching, and CI/CD integration, respectively, powered by `commander`. * **Direct Configuration**: Configuration is now primarily driven by CLI options (`--url`, `--token`, `--output`) and environment variables, reducing reliance on local config files. * **Package Renaming**: The package name has been updated from `@dockstat/outline-sync` to `outline-sync`. * **Dependency Updates**: Removed `@dockstat/logger` and `typescript` peer dependency. Added `commander`, `chokidar`, `front-matter`, and `yaml` for the new implementation, along with updated `bun-types` and `@types/node`. * **License Change**: Switched the package license from MPL-2.0 to MIT. * **Updated README**: The documentation has been completely rewritten to reflect the new features and usage. * **Bundling**: The CLI is now bundled into `dist/index.js` for simpler distribution and execution. This refactor aims to make `outline-sync` easier to use, understand, and integrate into various workflows, especially for CI/CD environments. * feat(outline-sync): Introduce config file support and init command Introduces a flexible configuration system for `outline-sync`, allowing settings to be defined via: - A dedicated `outline-sync.config.json` file (recommended) - Environment variables - CLI arguments Adds a new `outline-sync init` command to easily generate a sample `outline-sync.config.json` file. This streamlines initial setup and encourages best practices for configuration. Detailed changes: - **Configuration Precedence**: CLI arguments now take precedence over config file settings, which take precedence over environment variables. - **`init` Command**: New command to scaffold `outline-sync.config.json`. - **Enhanced `customPaths`**: `customPaths` in the config file now support paths relative to the current working directory (e.g., `../../README.md`). - **Improved CLI Options**: `sync`, `watch`, and `ci` commands now accept a `--config ` option. - **Package Name Update**: Renamed package from `outline-sync` to `@dockstat/outline-sync` in `package.json`. - **Extensive Documentation**: README updated to reflect all new configuration methods, custom path examples, document ID discovery, and CI/CD best practices for config files. This enhancement significantly improves the usability and configurability of the `outline-sync` tool. * refactor(docs): clear local content and enhance Outline sync Removed all existing documentation files and sync configurations from `apps/docs`. This prepares the directory for a fresh documentation structure, likely to be managed more actively via the Outline sync tool. The `@dockstat/outline-sync` package has been updated (v1.2.1) with significant enhancements: - **Collection Filtering**: Introduced `includeCollections` and `excludeCollections` options, allowing users to specify which Outline collections should be synchronized. This can be configured via CLI (`--include`, `--exclude`) or in `outline-sync.config.json`. - **Improved Package Management**: The `package.json` now includes a `files` field for better control over published assets. - **Internal Refinements**: Updated `fs` and `path` imports for Bun compatibility and `Bun.write` to `writeFile`. - **Documentation Update**: The package's `README.md` has been revised to reflect the new features and installation methods. A new `apps/docs/outline-sync.config.json` is introduced to serve as the initial configuration for the restructured documentation setup. * feat(docs): Automate Outline documentation sync with hierarchical structure Implement a new GitHub Actions workflow (`docs-sync.yaml`) to automatically synchronize documentation from Outline. The workflow runs on pushes to `main`, a 6-hour schedule, and manual dispatch. Enhance `@dockstat/outline-sync` to support hierarchical document syncing, mirroring Outline's parent-child structure in the local filesystem. Each document is now stored in a `README.md` within a directory representing its title. Update `apps/docs/outline-sync.config.json` to store documents directly in the `apps/docs` root and to filter for the "DockStat" collection only. Bump `@dockstat/outline-sync` version to `1.2.3` and add a `prepublishOnly` script for build automation. * chore(ci): Lint * refactor(db): externalize default application configuration Moves the initial DockStat application configuration from 'index.ts' into a new 'defaults.ts' file. This improves modularity and separation of concerns, making the default configuration easier to manage and update. Also updates 'package.json' and 'tsconfig.json' to properly include the new defaults module for distribution and compilation. * docs(dockstat): Add initial comprehensive project documentation Introduces a wide range of documentation for the DockStat project, covering various aspects of its components and lifecycle. This commit includes: - Current DockStatAPI reference. - Architecture overview for DockStat. - Archive section for older versions, including DockStatAPI v1 and v3 documentation (API reference, authentication, background tasks, contributing, database, plugin development, Docker events endpoint, stacks, usage, web sockets). - Contribution guidelines for DockStat, DockStatAPI, and DockStacks. - Maintaining functions documentation, covering dependency graphs, unused dependency removal, automated testing, JavaScript minification, credits generation, and environment file creation. - Notifications service details, including configuration and custom notification guides. - Technical documentation for public packages: `@dockstat/outline-sync`, `@dockstat/sqlite-wrapper`, `@dockstat/theme-handler`. - Documentation for the private package: `@dockstat/db-features`. * chore(ci): Lint * chore(ci): Lint --------- Co-authored-by: actions user Co-authored-by: actions-user * chore(ci): Lint * Repair issues that occured in the review brach (#16) * feat(api): Implement component and page API endpoints This commit introduces API endpoints for managing components and pages. - Added `/api/components` endpoint to retrieve component JavaScript and library information. - Added `/api/pages` endpoint to retrieve all pages. - Implemented PageHandler class to manage page data and interact with the database. - Implemented ComponentLibrary class to manage registered components and interact with the database. - Created database tables for components and pages using `@dockstat/sqlite-wrapper`. * feat(api): Initial DockStat API setup This commit introduces the initial setup for the DockStat API. It includes: - API project setup with basic files (.gitignore, README.md, package.json) - Core API functionality (database interaction, docker client management, plugin handling) - API routes for DB configuration, Docker management, metrics, and plugins - ElysiaJS integration for API server and plugin management - Middleware for metrics collection - Default plugins for DockStat core functionality Diff: This commit includes a significant amount of new code, laying the foundation for the DockStat API. It introduces the basic structure, dependencies, and initial functionality for interacting with the database, managing Docker clients, handling plugins, and exposing API endpoints. * feat(api): Refactor and Enhance API Structure This commit introduces several key changes to enhance the API's structure, error handling, and plugin management. - Updates package dependencies, including elysia to version 1.4.12, and adds @elysiajs/cors. - Modifies elysia-plugins.ts to enable server timing based on the DOCKSTATAPI_SHOW_TRACES environment variable. - Adds a new error handling middleware in onError.ts to provide structured error responses for validation, parsing, and not found errors. - Refactors index.ts to include cors middleware and the new errorHandler. - Updates database and docker models to improve type safety and consistency. - Modifies plugin utilities, logger and saveDefaultPlugins, to enhance logging and plugin bundling. - Removes deprecated code and proxies to improve React Router integration. - Adds Onboarding route and moves files around The main goal of this commit is to improve the API's reliability, maintainability, and developer experience. * chore(dockstat): Untrack bundled output and update gitignore The '.bundled/index.js' file has been removed from version control, and the '.gitignore' file updated to explicitly ignore the entire '.bundled' directory. This ensures that generated build artifacts are not committed to the repository. * refactor(api, docker-client): improve configuration, plugin loading, and client data handling Refine API server timing enablement logic to correctly interpret `DOCKSTATAPI_SHOW_TRACES` environment variable, allowing explicit disabling with "false". Enhance default plugin loading by using `node:url` and `node:path` for robust directory resolution, and a more specific glob pattern to find `index.ts/js` files. Overhaul `DockerClientManagerCore.getAllClients` to accurately merge live worker data with stored client information, ensuring consistent `initialized` status and reliable client naming. * refactor(api): Enhance plugin management, bundling, and add hook discovery - **Plugin Handling Improvements**: - Refactored `PluginHandler.loadPlugins` and `PluginHandler.loadPlugin` for more robust error reporting and immediate activation of newly saved plugins. - Adjusted plugin bundling with `Bun.build` to use ESM format, disable aggressive minification, and include a banner for `import.meta.require` compatibility. - Corrected the default directory path for loading default plugins. - Modified temporary file creation for bundled plugins to utilize a dedicated subdirectory. - Optimized `savePlugin` logging and refined the `saveDefaultPlugins` `id` increment logic. - Enhanced `PluginHandler.getStatus` to exclude plugin source code, reducing API response payload size. - **API Enhancements**: - Introduced a new GET `/plugins/hooks` endpoint to provide a list of available event hooks from currently loaded plugins. - **Docker Client Manager Refinements**: - Improved `maxWorkers` environment variable parsing to correctly handle `0` or empty string values, ensuring proper numeric assignment. - Streamlined event handler initialization to consistently use the PluginHandler for hook discovery. - Added debug logging within `triggerHooks` for better visibility into event processing. - **Code Quality & Maintenance**: - Replaced nullish coalescing `??` with logical OR `||` in specific environment variable parsing for broader falsy value handling. - Implemented `type` imports for `Logger` and `QueryBuilder` for improved type safety and potential tree-shaking benefits. - Updated `.gitignore` to properly ignore new `.bundle` build artifacts and SQLite journal files. - Updated `bun.lock` to reflect dependency changes. * feat(plugin-api): Implement action-based plugin API routes Reworked plugin API routing to be declarative and action-based. This centralizes route handling within the `PluginHandler` and provides a clearer interface for plugins. * **Plugin API Rework**: * Removed direct `Elysia` route instances from plugins (`routes?: AnyElysia` in `Plugin` interface). * Plugins now define `apiRoutes` and an `actions` map in their `PluginConfig`. API routes link paths and methods to specific actions defined in the plugin's `actions`. * The `PluginHandler`'s `handleRoute` method was refactored to directly interpret `PluginConfig.apiRoutes` and execute the corresponding actions. * **DockMon Plugin Enhancement**: * Added a new `getSavedMetrics` action to the `DockMon` plugin, allowing retrieval of all saved metrics. * Integrated `getSavedMetrics` with the `/all` API route for `DockMon`. * Updated `host:metrics` event handler to use the provided `logger` instead of `console.info`. * **Docker Client Refactor**: * Removed the unused `clientId` property from `DockerClient`. * Changed monitoring manager initialization failures from a `warning` to an `error` event. * Improved error logging in `withRetry` utility to handle non-Error objects gracefully. * Added type assertions for `additionalCtx` in `DockerClientManagerCore.handleEvent` to address typing issues. * **Plugin Loading & Stability**: * Ensured `pluginHandlerFactory.loadAllPlugins()` is properly awaited in `saveDefaultPlugins` for correct asynchronous behavior. * Removed `this.loadPlugins(success)` from `installPlugin`, as `loadAllPlugins` now manages the loading process. * **Typing Updates**: Updated `PluginRoute` and `PluginConfig` types to align with the new action-based architecture. * chore(lint, workflows): Added new turbo based lint workflow * ci(lint): Configure GitHub Actions to use Biome's GitHub reporter * ci(lint): Install Bun dependencies for lint workflow * ci(lint): Automatically fix lint issues and commit changes * Lint * chore(ci): Update lint workflow's commit author and message format * feat(plugins): Implement generic parser, route discovery, and refactor API handling - Refactor plugin API route handling (`/:id/routes/*`) to support all HTTP methods. - Introduce a generic `Parser` configuration for plugin tables, enabling custom transformations for JSON, module code, and boolean column types. - Centralize plugin action execution into a new `triggerRouteAction` method for better modularity. - Add a new `/plugins/routes` endpoint to allow discovery of all active plugin API routes. - Streamline the `PluginActionContext` to simplify the interface for plugin action handlers. * feat(dockmon): Implement action chaining and specialized metric retrieval - Renamed `getSavedMetrics` to `getAllMetrics` for improved clarity. - Added `getContainerMetrics` and `getHostMetrics` actions to allow retrieval of metrics based on their type. - Introduced a new API route `/test` that demonstrates chaining multiple plugin actions (`test1`, `test2`) together. - Refactored the plugin configuration by moving `jsonColumns` to a more generic `parser.JSON` property, enabling future expansion of parser types. - Updated the `Parser` interface in `@sqlite-wrapper` to make `JSON`, `BOOLEAN`, and `MODULE` properties optional. * chore(ci): Lint * ci(lint): Run lint workflow only on push * chore(dockstat): Remove generated bundle file This file appears to be a generated bundle output that was mistakenly committed. Generated files should not be part of the source control to prevent unnecessary diffs and ensure clean builds. * feat(readme): Introduce comprehensive project documentation and guides This commit significantly revamps the project's `README.md` to provide a detailed overview and essential guides for both new users and contributors. Key updates include: - Expanded project overview, core features, and technology stack. - New dedicated sections covering repository structure, developer getting started, plugin system architecture, stack management, multi-node adapters, and development workflow guidelines. - Clear articulation of known limitations, a high-level roadmap, contributing guidelines, and documentation resources. - Integration of technology badges for quick reference. - Addition of `DockStat2-04.png` as a visual asset, likely for workflow illustration. - Updated `@dockstat/typings` package metadata (added `private` flag, `author` info) and bumped the version to `1.1.0`. - Removed `tsconfig.tsbuildinfo` from `packages/typings`. * docs(github): Add DockStat2-04 screenshot * chore(lint): Allow empty commits in lint workflow * chore(ci): Lint * refactor(monorepo): Modernize JS/TS, enhance types, and cleanup code Code Modernization: - Replaced string concatenations with template literals for enhanced readability. - Standardized strict equality (===) and optional chaining (?.). - Adopted `Number.isFinite()`, `Number.isNaN()`, and explicit radix in `Number.parseInt()`. Type Safety Enhancements: - Upgraded `any` to `unknown` in various TypeScript type definitions. - Improved mixin types and added Biome ignore comments for intentional `any` usage. Dependency & Code Cleanup: - Removed numerous unused imports and variables across `apps/api`, `apps/docknode`, `apps/dockstat`, `apps/dockstore`, and various `packages/`. - Deleted outdated example files from `packages/docker-client/examples/`. - Eliminated redundant code elements, such as empty JSX fragments in React components. Tooling: - Updated `biome.json` to refine file inclusion/exclusion for linting and formatting. * chore(ci): Lint * feat(outline-sync): Re-implement and simplify sync logic This commit completely re-implements the `outline-sync` package, moving from a complex, multi-collection, file-based configuration system to a simpler, CLI-driven approach. Key changes include: * **Simplified Architecture**: The entire core logic (e.g., `lib/config`, `lib/init`, `lib/outlineApi`, `lib/syncEngine`, `lib/types`) has been replaced with a new, more modular `src/` structure. * **New CLI Commands**: Introduces `sync`, `watch`, and `ci` commands for one-time pull, bidirectional watching, and CI/CD integration, respectively, powered by `commander`. * **Direct Configuration**: Configuration is now primarily driven by CLI options (`--url`, `--token`, `--output`) and environment variables, reducing reliance on local config files. * **Package Renaming**: The package name has been updated from `@dockstat/outline-sync` to `outline-sync`. * **Dependency Updates**: Removed `@dockstat/logger` and `typescript` peer dependency. Added `commander`, `chokidar`, `front-matter`, and `yaml` for the new implementation, along with updated `bun-types` and `@types/node`. * **License Change**: Switched the package license from MPL-2.0 to MIT. * **Updated README**: The documentation has been completely rewritten to reflect the new features and usage. * **Bundling**: The CLI is now bundled into `dist/index.js` for simpler distribution and execution. This refactor aims to make `outline-sync` easier to use, understand, and integrate into various workflows, especially for CI/CD environments. * feat(outline-sync): Introduce config file support and init command Introduces a flexible configuration system for `outline-sync`, allowing settings to be defined via: - A dedicated `outline-sync.config.json` file (recommended) - Environment variables - CLI arguments Adds a new `outline-sync init` command to easily generate a sample `outline-sync.config.json` file. This streamlines initial setup and encourages best practices for configuration. Detailed changes: - **Configuration Precedence**: CLI arguments now take precedence over config file settings, which take precedence over environment variables. - **`init` Command**: New command to scaffold `outline-sync.config.json`. - **Enhanced `customPaths`**: `customPaths` in the config file now support paths relative to the current working directory (e.g., `../../README.md`). - **Improved CLI Options**: `sync`, `watch`, and `ci` commands now accept a `--config ` option. - **Package Name Update**: Renamed package from `outline-sync` to `@dockstat/outline-sync` in `package.json`. - **Extensive Documentation**: README updated to reflect all new configuration methods, custom path examples, document ID discovery, and CI/CD best practices for config files. This enhancement significantly improves the usability and configurability of the `outline-sync` tool. * refactor(docs): clear local content and enhance Outline sync Removed all existing documentation files and sync configurations from `apps/docs`. This prepares the directory for a fresh documentation structure, likely to be managed more actively via the Outline sync tool. The `@dockstat/outline-sync` package has been updated (v1.2.1) with significant enhancements: - **Collection Filtering**: Introduced `includeCollections` and `excludeCollections` options, allowing users to specify which Outline collections should be synchronized. This can be configured via CLI (`--include`, `--exclude`) or in `outline-sync.config.json`. - **Improved Package Management**: The `package.json` now includes a `files` field for better control over published assets. - **Internal Refinements**: Updated `fs` and `path` imports for Bun compatibility and `Bun.write` to `writeFile`. - **Documentation Update**: The package's `README.md` has been revised to reflect the new features and installation methods. A new `apps/docs/outline-sync.config.json` is introduced to serve as the initial configuration for the restructured documentation setup. * feat(docs): Automate Outline documentation sync with hierarchical structure Implement a new GitHub Actions workflow (`docs-sync.yaml`) to automatically synchronize documentation from Outline. The workflow runs on pushes to `main`, a 6-hour schedule, and manual dispatch. Enhance `@dockstat/outline-sync` to support hierarchical document syncing, mirroring Outline's parent-child structure in the local filesystem. Each document is now stored in a `README.md` within a directory representing its title. Update `apps/docs/outline-sync.config.json` to store documents directly in the `apps/docs` root and to filter for the "DockStat" collection only. Bump `@dockstat/outline-sync` version to `1.2.3` and add a `prepublishOnly` script for build automation. * chore(ci): Lint * refactor(db): externalize default application configuration Moves the initial DockStat application configuration from 'index.ts' into a new 'defaults.ts' file. This improves modularity and separation of concerns, making the default configuration easier to manage and update. Also updates 'package.json' and 'tsconfig.json' to properly include the new defaults module for distribution and compilation. * docs(dockstat): Add initial comprehensive project documentation Introduces a wide range of documentation for the DockStat project, covering various aspects of its components and lifecycle. This commit includes: - Current DockStatAPI reference. - Architecture overview for DockStat. - Archive section for older versions, including DockStatAPI v1 and v3 documentation (API reference, authentication, background tasks, contributing, database, plugin development, Docker events endpoint, stacks, usage, web sockets). - Contribution guidelines for DockStat, DockStatAPI, and DockStacks. - Maintaining functions documentation, covering dependency graphs, unused dependency removal, automated testing, JavaScript minification, credits generation, and environment file creation. - Notifications service details, including configuration and custom notification guides. - Technical documentation for public packages: `@dockstat/outline-sync`, `@dockstat/sqlite-wrapper`, `@dockstat/theme-handler`. - Documentation for the private package: `@dockstat/db-features`. * chore(ci): Lint * chore(ci): Lint * ci(lint): Add [skip ci] to empty commit message --------- Co-authored-by: actions user Co-authored-by: actions-user * refactor(project): Enhance code quality, type safety, and accessibility Enable Biome's assist mode for the `lint:fix:all` command in `package.json`. Standardize Node.js built-in module imports with `node:` prefix and remove unused type imports across the `outline-sync` package. Improve type safety by adding explicit type annotations to `getConfig` parameters and applying type assertions in the `Table` component's sorting logic. Introduce `title` elements to various SVG icons in the `react-router-elysia` package for better accessibility. Optimize the `Onboarding` component in the `ui` package by leveraging `useCallback` hooks for function stability and preventing duplicate keydown events using `e.repeat`. Update `max-w` utility class for improved responsiveness in `react-router-elysia/app/welcome/welcome.tsx`. Align type definitions in `outline-sync/src/types.ts` by removing trailing semicolons. * chore(ci) [skip ci]: Lint * chore(deps): Rename outline-sync to @dockstat/outline-sync and update dependencies Renamed the local `outline-sync` package to `@dockstat/outline-sync` and bumped its version from `1.2.0` to `1.2.3`. This commit also includes numerous dependency updates across the project: - Upgraded `esbuild` to `0.27.1` (from `0.25.12`). - Updated `@eslint/*` packages, `@typescript-eslint/*` packages, and `eslint` to their latest patch/minor versions. - Updated `@grpc/grpc-js` to `1.14.3`. - Upgraded `@joshwooding/vite-plugin-react-docgen-typescript` to `0.6.3`. - Updated `@react-router/*` packages to `7.10.1`. - Upgraded `@rolldown/pluginutils` to `1.0.0-beta.53` and `rollup` and its platform-specific binaries to `4.53.5`. - Updated `@storybook/*` packages to `10.1.9`. - Upgraded `@tailwindcss/*` packages and `tailwindcss` to `4.1.18`. - Updated `@types/bun`, `bun-types`, `@types/node` (and nested `@types/node`) to newer versions. - Updated `vite` to `7.3.0` and `@vitejs/plugin-react` to `5.1.2`. - Upgraded `react` and `react-dom` to `19.2.3`. - Other various package updates like `baseline-browser-mapping`, `caniuse-lite`, `electron-to-chromium`, `enhanced-resolve`, `fast-copy`, `glob`, `jackspeak`, `path-scurry`, `semver`, `send`, `serve-static`, `storybook`, `turbo`. - Added new dependencies: `@isaacs/balanced-match`, `@isaacs/brace-expansion`, `bundle-name`, `default-browser`, `default-browser-id`, `define-lazy-prop`, `is-docker`, `is-inside-container`, `is-wsl`, `open`, `run-applescript`, `wsl-utils`. - Removed `@pkgjs/parseargs` and `graphemer`. * ci(lint-workflow): Adjust placement of [skip ci] in internal commit message --------- Co-authored-by: actions user Co-authored-by: actions-user Co-authored-by: actions user --- .bun-version | 2 +- .github/DockStat2-02.png | Bin 82847 -> 0 bytes .github/workflows/docs-sync.yaml | 24 + .github/workflows/docs-sync.yml | 68 - .github/workflows/lint.yaml | 23 + .github/workflows/lint.yml | 65 - .github/workflows/pr-validation.yml | 406 - .gitignore | 6 + README.md | 245 +- apps/api/.gitignore | 42 + apps/api/README.md | 15 + apps/api/package.json | 28 + apps/api/src/database/index.ts | 19 + apps/api/src/database/utils.ts | 22 + apps/api/src/docker/index.ts | 10 + apps/api/src/elysia-plugins.ts | 29 + apps/api/src/handlers/onError.ts | 92 + apps/api/src/index.ts | 19 + apps/api/src/logger.ts | 5 + apps/api/src/middleware/metrics/helper.ts | 709 + apps/api/src/middleware/metrics/index.ts | 1 + apps/api/src/middleware/metrics/prometheus.ts | 42 + apps/api/src/models/database.ts | 24 + apps/api/src/models/docker.ts | 111 + apps/api/src/models/metrics.ts | 11 + apps/api/src/models/plugins.ts | 14 + .../plugins/default-plugins/dockmon/index.ts | 33 + .../default-plugins/dockmon/src/actions.ts | 16 + .../default-plugins/dockmon/src/config.ts | 33 + .../default-plugins/dockmon/src/types.ts | 16 + .../dockmon/src/utils/mapTo.ts | 14 + apps/api/src/plugins/index.ts | 13 + apps/api/src/plugins/utils/logger.ts | 3 + .../src/plugins/utils/saveDefaultPlugins.ts | 84 + apps/api/src/routes/db.ts | 45 + apps/api/src/routes/docker/client.ts | 51 + apps/api/src/routes/docker/container.ts | 13 + apps/api/src/routes/docker/hosts.ts | 37 + apps/api/src/routes/docker/index.ts | 40 + apps/api/src/routes/docker/manager.ts | 40 + apps/api/src/routes/metrics/prometheus.ts | 27 + apps/api/src/routes/plugins/index.ts | 43 + apps/api/tsconfig.json | 105 + apps/docknode/.dockerignore | 7 + apps/docknode/.dockstacks_instance_uuid.txt | 1 + apps/docknode/.gitignore | 42 + apps/docknode/README.md | 15 + apps/docknode/bunfig.toml | 2 + apps/docknode/dockerfile | 8 + apps/docknode/environment.d.ts | 9 + apps/docknode/package.json | 21 + apps/docknode/src/builder.ts | 14 + apps/docknode/src/handlers/auth/auth.model.ts | 10 + .../src/handlers/auth/elysia-adapter.ts | 0 apps/docknode/src/handlers/dockstack/index.ts | 65 + .../src/handlers/dockstack/src/deployStack.ts | 39 + .../src/handlers/dockstack/src/types.ts | 50 + .../handlers/dockstack/src/utils/constants.ts | 3 + .../dockstack/src/utils/createResponses.ts | 95 + .../dockstack/src/utils/getDefaultAuthKey.ts | 68 + .../handlers/dockstack/src/utils/includes.ts | 179 + .../dockstack/src/utils/instanceUUID.ts | 39 + .../dockstack/src/utils/instanceUUIDtypes.ts | 29 + .../handlers/dockstack/src/utils/loggers.ts | 3 + .../handlers/dockstack/src/utils/responses.ts | 18 + apps/docknode/src/index.ts | 33 + .../src/tests/basic-api-requests.test.ts | 17 + apps/docknode/tsconfig.json | 105 + apps/dockstat/.gitignore | 5 +- apps/dockstat/app/.server/index.ts | 153 +- .../app/.server/src/adapters/handler.ts | 106 - .../app/.server/src/plugins/handler.ts | 41 - .../app/.server/src/theme/defaultTheme.ts | 22 - .../app/.server/src/theme/themeHandler.ts | 92 - apps/dockstat/app/.server/src/utils.ts | 120 - apps/dockstat/app/api.ts | 4 + apps/dockstat/app/app.css | 50 +- .../app/components/ui/AdapterCard.tsx | 43 - apps/dockstat/app/components/ui/Badge.tsx | 39 - apps/dockstat/app/components/ui/Button.tsx | 54 - apps/dockstat/app/components/ui/Card.tsx | 34 - apps/dockstat/app/components/ui/Input.tsx | 47 - apps/dockstat/app/components/ui/Modal.tsx | 200 - apps/dockstat/app/components/ui/Nav.tsx | 147 - apps/dockstat/app/components/ui/NavCards.tsx | 42 - apps/dockstat/app/entry.client.tsx | 7 +- apps/dockstat/app/entry.server.tsx | 60 +- apps/dockstat/app/root.tsx | 75 +- apps/dockstat/app/routes.ts | 9 +- apps/dockstat/app/routes/_index.tsx | 57 - apps/dockstat/app/routes/adapters.tsx | 19 - .../app/routes/api.v1.adapter.docker.tsx | 54 - apps/dockstat/app/routes/api.v1.adapter.tsx | 42 - apps/dockstat/app/routes/api.v1.themes.tsx | 7 - apps/dockstat/app/routes/index.tsx | 38 + apps/dockstat/app/routes/onboarding.tsx | 13 + apps/dockstat/app/routes/test.tsx | 700 - apps/dockstat/app/utils/NavItem.tsx | 98 - apps/dockstat/app/utils/cn.ts | 3 - apps/dockstat/bunfig.toml | 2 +- apps/dockstat/package.json | 75 +- .../public/_assets/DockStat-Text-Dark.png | Bin 82847 -> 0 bytes .../public/_assets/DockStat-Text-Ligh.png | Bin 79885 -> 0 bytes apps/dockstat/react-router.config.ts | 5 +- apps/dockstat/server.ts | 19 - apps/dockstat/tsconfig.json | 20 +- apps/dockstat/turbo.json | 6 + apps/dockstat/vite.config.ts | 29 +- apps/dockstore/.gitignore | 35 + .../.schemas/plugin-meta.schema.json | 49 + apps/dockstore/CONTRIBUTE.md | 73 - apps/dockstore/Index.json | 98 - apps/dockstore/README.md | 62 +- apps/dockstore/bun.lock | 29 - apps/dockstore/bundler.ts | 313 + apps/dockstore/bunfig.toml | 2 + apps/dockstore/index.ts | 4 - apps/dockstore/manifest.yml | 17 + apps/dockstore/package.json | 28 +- apps/dockstore/schemas/themeOptions.json | 34 - apps/dockstore/src/.utils/render.ts | 55 + .../plugins/docknode-plugin/bundle/index.js | 20 + .../docknode-plugin/bundle/index.js.map | 11 + .../content/plugins/docknode-plugin/index.ts | 14 + .../plugins/docknode-plugin/manifest.yml | 16 + .../plugins/docknode-plugin/src/actions.ts | 37 + .../plugins/docknode-plugin/src/config.ts | 25 + .../plugins/docknode-plugin/src/meta.ts | 17 + .../plugins/docknode-plugin/src/types.ts | 14 + .../plugins/dockstacks/bundle/index.js | 17354 ++++++++++++++++ .../plugins/dockstacks/bundle/index.js.map | 206 + .../src/content/plugins/dockstacks/index.ts | 11 + .../content/plugins/dockstacks/manifest.yml | 16 + .../content/plugins/dockstacks/src/elyisa.ts | 5 + .../content/plugins/dockstacks/src/meta.ts | 18 + .../templates/adguardhome/adguard-home.svg | 1 - .../templates/adguardhome/template.json | 20 - .../templates/bookstack/bookstack.svg | 1 - .../templates/bookstack/template.json | 17 - apps/dockstore/templates/gitea/gitea.svg | 1 - apps/dockstore/templates/gitea/template.json | 18 - apps/dockstore/templates/grafana/grafana.svg | 1 - .../dockstore/templates/grafana/template.json | 17 - .../dockstore/templates/heimdall/heimdall.svg | 1 - .../templates/heimdall/template.json | 17 - .../templates/home-assistant/template.json | 22 - .../nginx-proxy-manager.svg | 1 - .../nginx-proxy-manager/template.json | 17 - .../dockstore/templates/outline/template.json | 53 - apps/dockstore/templates/pihole/template.json | 21 - apps/dockstore/templates/qcdn/template.json | 30 - apps/dockstore/templates/tianji/template.json | 18 - apps/dockstore/templates/tianji/tianji.svg | 1 - .../templates/uptime-kuma/template.json | 17 - .../templates/uptime-kuma/uptime-kuma.svg | 1 - apps/dockstore/themes/beach/options.json | 9 - apps/dockstore/themes/beach/theme.css | 24 - apps/dockstore/themes/cyberpunk/options.json | 9 - apps/dockstore/themes/cyberpunk/theme.css | 24 - apps/dockstore/themes/default/options.json | 6 - apps/dockstore/themes/default/theme.css | 24 - apps/dockstore/themes/dracula/options.json | 9 - apps/dockstore/themes/dracula/theme.css | 24 - .../dockstore/themes/forest-dusk/options.json | 9 - apps/dockstore/themes/forest-dusk/theme.css | 24 - apps/dockstore/themes/forest/options.json | 9 - apps/dockstore/themes/forest/theme.css | 24 - apps/dockstore/themes/lavender/options.json | 9 - apps/dockstore/themes/lavender/theme.css | 24 - .../themes/midnight-blue/options.json | 9 - apps/dockstore/themes/midnight-blue/theme.css | 24 - .../themes/midnight-purple/options.json | 9 - .../themes/midnight-purple/theme.css | 24 - apps/dockstore/themes/mint/options.json | 9 - apps/dockstore/themes/mint/theme.css | 24 - .../themes/morning-mist/options.json | 9 - apps/dockstore/themes/morning-mist/theme.css | 24 - .../themes/obsidian-red/options.json | 9 - apps/dockstore/themes/obsidian-red/theme.css | 24 - apps/dockstore/themes/ocean/options.json | 9 - apps/dockstore/themes/ocean/theme.css | 24 - apps/dockstore/themes/retro-neon/options.json | 9 - apps/dockstore/themes/retro-neon/theme.css | 23 - apps/dockstore/themes/steel-blue/options.json | 9 - apps/dockstore/themes/steel-blue/theme.css | 24 - apps/dockstore/tsconfig.json | 32 +- ...8f-f103-480b-9f50-8f53f515cab9.config.json | 5 - ...48f-f103-480b-9f50-8f53f515cab9.pages.json | 201 - apps/docs/README.md | 316 - apps/docs/archive/README.md | 1 - apps/docs/archive/dockstat-v1/README.md | 8 - .../dockstat-v1/customization/README.md | 240 - .../customization/themes/README.md | 94 - .../dockstat-v1/installation/README.md | 258 - apps/docs/archive/dockstat-v2/README.md | 22 - apps/docs/archive/dockstatapi-v2/README.md | 65 - .../dockstatapi-v2/developing/README.md | 81 - apps/docs/dockstat/README.md | 30 - .../api-reference/README.md | 9 + apps/docs/dockstat/architecture/README.md | 17 + apps/docs/dockstat/archive/README.md | 10 + .../archive/contribute/README.md | 9 + .../archive/dockstatapi-v1/README.md | 9 + .../backend-api-reference/README.md | 9 + .../dockstatapi-v1/integrations/README.md | 9 + .../archive/dockstatapi-v3/README.md | 27 +- .../dockstatapi-v3/authentication/README.md | 9 + .../dockstatapi-v3/background-tasks/README.md | 9 + .../dockstatapi-v3/contributing/README.md | 9 + .../archive/dockstatapi-v3/database/README.md | 9 + .../plugin-development/README.md | 9 + .../docker-events-endpoint/README.md | 9 + .../archive/dockstatapi-v3/stacks/README.md | 9 + .../dockstatapi-v3/stacks/usage/README.md | 9 + .../dockstatapi-v3/web-sockets/README.md | 9 + .../maintaining-functions/README.md | 9 + .../notifications/README.md | 9 + .../README.md | 9 + .../README.md | 9 + .../README.md | 9 + .../@dockstat-theme-handler/flow/README.md | 135 + apps/docs/dockstat/packages/README.md | 17 +- .../README.md.outline-sync.bak.1756056848814 | 3 - .../README.md.outline-sync.bak.1756059516863 | 3 - .../README.md | 9 + .../README.md.outline-sync.bak.1756038409651 | 2536 --- .../README.md.outline-sync.bak.1756056849218 | 2536 --- apps/docs/outline-sync.config.json | 6 + biome.json | 41 +- bun.lock | 1314 +- ...ompose-dev.yaml => docker-compose.dev.yaml | 23 +- dockstat-dev.prometheus.yml | 11 + package.json | 38 +- packages/db/build.ts | 14 +- packages/db/bunfig.toml | 2 + packages/db/defaults.ts | 31 + packages/db/index.ts | 192 +- packages/db/package.json | 10 +- packages/db/test.ts | 315 +- packages/db/tsconfig.json | 2 +- packages/docker-client/build.ts | 15 +- packages/docker-client/bunfig.toml | 2 + .../docker-client/examples/all-stats-demo.ts | 377 - .../docker-client/examples/basic-usage.ts | 379 - .../examples/websocket-integration.ts | 652 - packages/docker-client/index.ts | 21 +- packages/docker-client/package.json | 19 +- packages/docker-client/src/_worker.index.ts | 270 + packages/docker-client/src/docker-client.ts | 1121 +- .../docker-client/src/events/docker-events.ts | 175 - .../src/events/workerEventProxy.ts | 25 + .../docker-client/src/hosts-handler/index.ts | 60 +- packages/docker-client/src/manager/_mixin.ts | 38 + .../docker-client/src/manager/containers.ts | 194 + packages/docker-client/src/manager/core.ts | 669 + packages/docker-client/src/manager/hosts.ts | 89 + packages/docker-client/src/manager/images.ts | 18 + packages/docker-client/src/manager/index.ts | 22 + .../docker-client/src/manager/monitoring.ts | 48 + .../docker-client/src/manager/networks.ts | 10 + packages/docker-client/src/manager/system.ts | 31 + packages/docker-client/src/manager/types.ts | 114 + packages/docker-client/src/manager/volumes.ts | 10 + .../src/monitoring/MonitoringManager.ts | 158 + .../src/monitoring/monitoring-manager.ts | 315 +- .../monitors/ContainerEventMonitor.ts | 141 + .../monitors/DockerEventStreamManager.ts | 199 + .../monitoring/monitors/HealthCheckMonitor.ts | 96 + .../monitoring/monitors/HostMetricsMonitor.ts | 113 + .../src/monitoring/utils/containerMapper.ts | 53 + .../src/monitoring/utils/index.ts | 3 + .../src/monitoring/utils/retry.ts | 26 + .../src/stream/stream-manager.ts | 348 +- packages/docker-client/src/types.ts | 104 + .../docker-client/src/utils/docker-helpers.ts | 339 +- packages/docker-client/src/utils/mapper.ts | 91 + packages/docker-client/src/utils/retry.ts | 23 + packages/docker-client/test.ts | 255 +- packages/docker-client/tsconfig.json | 3 +- packages/logger/bunfig.toml | 2 + packages/logger/index.ts | 187 +- packages/logger/package.json | 6 +- packages/logger/test.ts | 12 +- packages/outline-sync/LICENSE | 373 - packages/outline-sync/README.md | 534 +- packages/outline-sync/bin/cli.ts | 160 - packages/outline-sync/build.ts | 25 - packages/outline-sync/lib/config.ts | 138 - packages/outline-sync/lib/init.ts | 239 - packages/outline-sync/lib/outlineApi.ts | 162 - packages/outline-sync/lib/syncEngine.ts | 414 - packages/outline-sync/lib/types.ts | 153 - packages/outline-sync/lib/utils.ts | 120 - packages/outline-sync/package.json | 60 +- packages/outline-sync/src/client.ts | 63 + packages/outline-sync/src/index.ts | 146 + packages/outline-sync/src/sync.ts | 304 + packages/outline-sync/src/types.ts | 36 + packages/outline-sync/tsconfig.json | 18 +- packages/plugin-handler/.gitignore | 34 + packages/plugin-handler/README.md | 1 + packages/plugin-handler/bunfig.toml | 2 + packages/plugin-handler/package.json | 25 + packages/plugin-handler/src/index.ts | 403 + packages/plugin-handler/tsconfig.json | 29 + packages/react-router-elysia/.dockerignore | 4 + packages/react-router-elysia/.gitignore | 7 + packages/react-router-elysia/Dockerfile | 22 + packages/react-router-elysia/README.md | 31 + .../react-router-elysia/app/.server/elysia.ts | 52 + .../react-router-elysia/app/.server/treaty.ts | 27 + .../app/.server/websocket.ts | 15 + packages/react-router-elysia/app/app.css | 16 + packages/react-router-elysia/app/root.tsx | 66 + packages/react-router-elysia/app/routes.ts | 7 + .../react-router-elysia/app/routes/api.tsx | 33 + .../app/routes/example.tsx | 312 + .../react-router-elysia/app/routes/home.tsx | 12 + .../app/welcome/elysia_v.webp | Bin 0 -> 38316 bytes .../app/welcome/logo-dark.svg | 23 + .../app/welcome/logo-light.svg | 23 + .../app/welcome/welcome.tsx | 118 + packages/react-router-elysia/bunfig.toml | 2 + packages/react-router-elysia/package.json | 63 + packages/react-router-elysia/posts.json | 12 + .../react-router-elysia/public/favicon.ico | Bin 0 -> 15086 bytes .../react-router.config.ts | 7 + packages/react-router-elysia/tsconfig.json | 22 + packages/react-router-elysia/vite.config.ts | 8 + packages/sqlite-wrapper/README.md | 56 +- packages/sqlite-wrapper/bunfig.toml | 2 + packages/sqlite-wrapper/index.ts | 295 +- packages/sqlite-wrapper/package.json | 12 +- packages/sqlite-wrapper/query-builder/base.ts | 196 +- .../sqlite-wrapper/query-builder/delete.ts | 250 +- .../sqlite-wrapper/query-builder/index.ts | 212 +- .../sqlite-wrapper/query-builder/insert.ts | 166 +- .../sqlite-wrapper/query-builder/select.ts | 116 +- .../sqlite-wrapper/query-builder/update.ts | 206 +- .../sqlite-wrapper/query-builder/where.ts | 186 +- packages/sqlite-wrapper/test.ts | 782 +- packages/sqlite-wrapper/types.ts | 288 +- packages/typings/bunfig.toml | 2 + packages/typings/package.json | 22 +- packages/typings/src/adapter.ts | 47 +- packages/typings/src/archive/database.ts | 59 - .../typings/src/archive/docker-compose.ts | 522 - packages/typings/src/archive/docker.ts | 41 - packages/typings/src/archive/dockerode.ts | 162 - packages/typings/src/archive/dockstacks.ts | 23 - .../typings/src/archive/frontend/handles.ts | 6 - .../typings/src/archive/frontend/loaders.ts | 10 - packages/typings/src/archive/index.ts | 42 - packages/typings/src/archive/misc.ts | 5 - packages/typings/src/archive/plugin.ts | 50 - packages/typings/src/archive/websocket.ts | 52 - packages/typings/src/database.ts | 4 +- packages/typings/src/docker-client-worker.ts | 15 + packages/typings/src/docker-client.ts | 506 +- .../typings/src/docker-monitoring-manager.ts | 16 + packages/typings/src/events.ts | 27 + packages/typings/src/helpers.ts | 3 + packages/typings/src/index.ts | 17 +- packages/typings/src/plugin-base.ts | 50 + packages/typings/src/plugins.ts | 42 +- packages/typings/src/theme-components.ts | 8 +- packages/typings/src/themes.ts | 9 +- packages/typings/src/typebox/_schemas.ts | 14 + packages/typings/src/typebox/_types.ts | 33 + packages/typings/src/typebox/db.ts | 133 + packages/typings/src/typebox/dockstore.ts | 6 + packages/typings/src/typebox/plugins.ts | 77 + packages/ui/.gitignore | 27 + packages/ui/.storybook/main.ts | 19 + packages/ui/.storybook/preview.ts | 26 + packages/ui/README.md | 73 + packages/ui/index.html | 13 + packages/ui/package.json | 57 + .../ui/public}/DockStat2-06.png | Bin packages/ui/public/vite.svg | 1 + packages/ui/src/App.css | 250 + packages/ui/src/App.tsx | 5 + packages/ui/src/components/Badge/Badge.tsx | 80 + packages/ui/src/components/Button/Button.tsx | 90 + packages/ui/src/components/Card/Card.tsx | 64 + packages/ui/src/components/Card/CardBody.tsx | 10 + .../ui/src/components/Card/CardFooter.tsx | 21 + .../ui/src/components/Card/CardHeader.tsx | 16 + .../ui/src/components/Divider/Divider.tsx | 52 + .../ui/src/components/Extensions/Browser.tsx | 57 + .../ui/src/components/Extensions/Repo.tsx | 164 + .../components/Extensions/slides/Plugin.tsx | 322 + .../components/Extensions/slides/Stack.tsx | 0 .../components/Extensions/slides/Theme.tsx | 0 .../src/components/Extensions/table/index.tsx | 0 packages/ui/src/components/Forms/Checkbox.tsx | 143 + .../ui/src/components/Forms/CheckboxGroup.tsx | 49 + packages/ui/src/components/Forms/Input.tsx | 68 + packages/ui/src/components/Forms/Toggle.tsx | 64 + .../components/HoverBubble/HoverBubble.tsx | 68 + packages/ui/src/components/Link/Link.tsx | 35 + packages/ui/src/components/Modal/Modal.tsx | 99 + .../ui/src/components/Navbar/DockStat2-06.png | Bin packages/ui/src/components/Navbar/Navbar.tsx | 66 + .../ui/src/components/Plugins/Overview.tsx | 37 + packages/ui/src/components/Plugins/Plugin.tsx | 65 + .../ui/src/components/Plugins/Plugins.tsx | 55 + .../ui/src/components/Plugins/RepoList.tsx | 27 + packages/ui/src/components/Slider/Slider.tsx | 118 + packages/ui/src/components/Table/Table.tsx | 251 + packages/ui/src/components/index.ts | 61 + packages/ui/src/stories/Badge.stories.tsx | 47 + packages/ui/src/stories/Button.stories.tsx | 52 + packages/ui/src/stories/Card.stories.tsx | 53 + packages/ui/src/stories/Checkbox.stories.tsx | 165 + .../ui/src/stories/CheckboxGroup.stories.tsx | 78 + packages/ui/src/stories/Divider.stories.tsx | 52 + .../ui/src/stories/HoverBubble.stories.tsx | 62 + packages/ui/src/stories/Input.stories.tsx | 23 + packages/ui/src/stories/Intro.stories.tsx | 25 + packages/ui/src/stories/Link.stories.tsx | 45 + packages/ui/src/stories/Modal.stories.tsx | 83 + packages/ui/src/stories/Navbar.stories.tsx | 17 + packages/ui/src/stories/Slider.stories.tsx | 28 + packages/ui/src/stories/Table.stories.tsx | 78 + packages/ui/src/stories/Toggle.stories.tsx | 67 + packages/ui/src/stories/Welcome.stories.tsx | 41 + .../ui/src/stories/assets/accessibility.png | Bin 0 -> 42336 bytes .../ui/src/stories/assets/accessibility.svg | 1 + .../ui/src/stories/assets/addon-library.png | Bin 0 -> 467366 bytes packages/ui/src/stories/assets/assets.png | Bin 0 -> 3899 bytes .../src/stories/assets/avif-test-image.avif | Bin 0 -> 829 bytes packages/ui/src/stories/assets/context.png | Bin 0 -> 6119 bytes packages/ui/src/stories/assets/discord.svg | 1 + packages/ui/src/stories/assets/docs.png | Bin 0 -> 27875 bytes .../ui/src/stories/assets/figma-plugin.png | Bin 0 -> 44246 bytes packages/ui/src/stories/assets/github.svg | 1 + packages/ui/src/stories/assets/share.png | Bin 0 -> 40767 bytes packages/ui/src/stories/assets/styling.png | Bin 0 -> 7237 bytes packages/ui/src/stories/assets/testing.png | Bin 0 -> 49313 bytes packages/ui/src/stories/assets/theming.png | Bin 0 -> 44374 bytes packages/ui/src/stories/assets/tutorials.svg | 1 + packages/ui/src/stories/assets/youtube.svg | 1 + packages/ui/src/themes/light.css | 1 + packages/ui/src/themes/themes.json | 8 + packages/ui/src/utils/repoIcons.tsx | 17 + packages/ui/src/welcome/CONSTS.tsx | 105 + packages/ui/src/welcome/Intro.tsx | 50 + packages/ui/src/welcome/Onboarding.tsx | 89 + packages/ui/src/welcome/OnboardingFooter.tsx | 54 + packages/ui/src/welcome/OnboardingHeader.tsx | 31 + packages/ui/src/welcome/OnboardingSlide.tsx | 36 + packages/ui/src/welcome/ProgressDots.tsx | 23 + packages/ui/src/welcome/SlideBullet.tsx | 32 + packages/ui/src/welcome/SlideContent.tsx | 32 + packages/ui/src/welcome/SlideHeader.tsx | 26 + packages/ui/src/welcome/types.ts | 15 + packages/ui/tsconfig.app.json | 28 + packages/ui/tsconfig.json | 11 + packages/ui/tsconfig.node.json | 26 + packages/ui/vite.config.ts | 7 + packages/utils/.gitignore | 34 + packages/utils/README.md | 15 + packages/utils/bunfig.toml | 2 + packages/utils/package.json | 14 + packages/utils/src/http/RequestId.ts | 43 + packages/utils/src/http/_logger.ts | 5 + packages/utils/src/index.ts | 13 + packages/utils/src/worker/_logger.ts | 5 + packages/utils/src/worker/buildMessage.ts | 22 + packages/utils/tsconfig.json | 26 + tsconfig.base.json | 27 +- turbo.json | 28 +- 473 files changed, 35443 insertions(+), 18979 deletions(-) delete mode 100644 .github/DockStat2-02.png create mode 100644 .github/workflows/docs-sync.yaml delete mode 100644 .github/workflows/docs-sync.yml create mode 100644 .github/workflows/lint.yaml delete mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/pr-validation.yml create mode 100644 apps/api/.gitignore create mode 100644 apps/api/README.md create mode 100644 apps/api/package.json create mode 100644 apps/api/src/database/index.ts create mode 100644 apps/api/src/database/utils.ts create mode 100644 apps/api/src/docker/index.ts create mode 100644 apps/api/src/elysia-plugins.ts create mode 100644 apps/api/src/handlers/onError.ts create mode 100644 apps/api/src/index.ts create mode 100644 apps/api/src/logger.ts create mode 100644 apps/api/src/middleware/metrics/helper.ts create mode 100644 apps/api/src/middleware/metrics/index.ts create mode 100644 apps/api/src/middleware/metrics/prometheus.ts create mode 100644 apps/api/src/models/database.ts create mode 100644 apps/api/src/models/docker.ts create mode 100644 apps/api/src/models/metrics.ts create mode 100644 apps/api/src/models/plugins.ts create mode 100644 apps/api/src/plugins/default-plugins/dockmon/index.ts create mode 100644 apps/api/src/plugins/default-plugins/dockmon/src/actions.ts create mode 100644 apps/api/src/plugins/default-plugins/dockmon/src/config.ts create mode 100644 apps/api/src/plugins/default-plugins/dockmon/src/types.ts create mode 100644 apps/api/src/plugins/default-plugins/dockmon/src/utils/mapTo.ts create mode 100644 apps/api/src/plugins/index.ts create mode 100644 apps/api/src/plugins/utils/logger.ts create mode 100644 apps/api/src/plugins/utils/saveDefaultPlugins.ts create mode 100644 apps/api/src/routes/db.ts create mode 100644 apps/api/src/routes/docker/client.ts create mode 100644 apps/api/src/routes/docker/container.ts create mode 100644 apps/api/src/routes/docker/hosts.ts create mode 100644 apps/api/src/routes/docker/index.ts create mode 100644 apps/api/src/routes/docker/manager.ts create mode 100644 apps/api/src/routes/metrics/prometheus.ts create mode 100644 apps/api/src/routes/plugins/index.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/docknode/.dockerignore create mode 100644 apps/docknode/.dockstacks_instance_uuid.txt create mode 100644 apps/docknode/.gitignore create mode 100644 apps/docknode/README.md create mode 100644 apps/docknode/bunfig.toml create mode 100644 apps/docknode/dockerfile create mode 100644 apps/docknode/environment.d.ts create mode 100644 apps/docknode/package.json create mode 100644 apps/docknode/src/builder.ts create mode 100644 apps/docknode/src/handlers/auth/auth.model.ts rename test.ts => apps/docknode/src/handlers/auth/elysia-adapter.ts (100%) create mode 100644 apps/docknode/src/handlers/dockstack/index.ts create mode 100644 apps/docknode/src/handlers/dockstack/src/deployStack.ts create mode 100644 apps/docknode/src/handlers/dockstack/src/types.ts create mode 100644 apps/docknode/src/handlers/dockstack/src/utils/constants.ts create mode 100644 apps/docknode/src/handlers/dockstack/src/utils/createResponses.ts create mode 100644 apps/docknode/src/handlers/dockstack/src/utils/getDefaultAuthKey.ts create mode 100644 apps/docknode/src/handlers/dockstack/src/utils/includes.ts create mode 100644 apps/docknode/src/handlers/dockstack/src/utils/instanceUUID.ts create mode 100644 apps/docknode/src/handlers/dockstack/src/utils/instanceUUIDtypes.ts create mode 100644 apps/docknode/src/handlers/dockstack/src/utils/loggers.ts create mode 100644 apps/docknode/src/handlers/dockstack/src/utils/responses.ts create mode 100644 apps/docknode/src/index.ts create mode 100644 apps/docknode/src/tests/basic-api-requests.test.ts create mode 100644 apps/docknode/tsconfig.json delete mode 100644 apps/dockstat/app/.server/src/adapters/handler.ts delete mode 100644 apps/dockstat/app/.server/src/plugins/handler.ts delete mode 100644 apps/dockstat/app/.server/src/theme/defaultTheme.ts delete mode 100644 apps/dockstat/app/.server/src/theme/themeHandler.ts delete mode 100644 apps/dockstat/app/.server/src/utils.ts create mode 100644 apps/dockstat/app/api.ts delete mode 100644 apps/dockstat/app/components/ui/AdapterCard.tsx delete mode 100644 apps/dockstat/app/components/ui/Badge.tsx delete mode 100644 apps/dockstat/app/components/ui/Button.tsx delete mode 100644 apps/dockstat/app/components/ui/Card.tsx delete mode 100644 apps/dockstat/app/components/ui/Input.tsx delete mode 100644 apps/dockstat/app/components/ui/Modal.tsx delete mode 100644 apps/dockstat/app/components/ui/Nav.tsx delete mode 100644 apps/dockstat/app/components/ui/NavCards.tsx delete mode 100644 apps/dockstat/app/routes/_index.tsx delete mode 100644 apps/dockstat/app/routes/adapters.tsx delete mode 100644 apps/dockstat/app/routes/api.v1.adapter.docker.tsx delete mode 100644 apps/dockstat/app/routes/api.v1.adapter.tsx delete mode 100644 apps/dockstat/app/routes/api.v1.themes.tsx create mode 100644 apps/dockstat/app/routes/index.tsx create mode 100644 apps/dockstat/app/routes/onboarding.tsx delete mode 100644 apps/dockstat/app/routes/test.tsx delete mode 100644 apps/dockstat/app/utils/NavItem.tsx delete mode 100644 apps/dockstat/app/utils/cn.ts delete mode 100644 apps/dockstat/public/_assets/DockStat-Text-Dark.png delete mode 100644 apps/dockstat/public/_assets/DockStat-Text-Ligh.png delete mode 100644 apps/dockstat/server.ts create mode 100644 apps/dockstat/turbo.json create mode 100644 apps/dockstore/.gitignore create mode 100644 apps/dockstore/.schemas/plugin-meta.schema.json delete mode 100644 apps/dockstore/CONTRIBUTE.md delete mode 100644 apps/dockstore/Index.json delete mode 100644 apps/dockstore/bun.lock create mode 100644 apps/dockstore/bundler.ts create mode 100644 apps/dockstore/bunfig.toml delete mode 100644 apps/dockstore/index.ts create mode 100644 apps/dockstore/manifest.yml delete mode 100644 apps/dockstore/schemas/themeOptions.json create mode 100644 apps/dockstore/src/.utils/render.ts create mode 100644 apps/dockstore/src/content/plugins/docknode-plugin/bundle/index.js create mode 100644 apps/dockstore/src/content/plugins/docknode-plugin/bundle/index.js.map create mode 100644 apps/dockstore/src/content/plugins/docknode-plugin/index.ts create mode 100644 apps/dockstore/src/content/plugins/docknode-plugin/manifest.yml create mode 100644 apps/dockstore/src/content/plugins/docknode-plugin/src/actions.ts create mode 100644 apps/dockstore/src/content/plugins/docknode-plugin/src/config.ts create mode 100644 apps/dockstore/src/content/plugins/docknode-plugin/src/meta.ts create mode 100644 apps/dockstore/src/content/plugins/docknode-plugin/src/types.ts create mode 100644 apps/dockstore/src/content/plugins/dockstacks/bundle/index.js create mode 100644 apps/dockstore/src/content/plugins/dockstacks/bundle/index.js.map create mode 100644 apps/dockstore/src/content/plugins/dockstacks/index.ts create mode 100644 apps/dockstore/src/content/plugins/dockstacks/manifest.yml create mode 100644 apps/dockstore/src/content/plugins/dockstacks/src/elyisa.ts create mode 100644 apps/dockstore/src/content/plugins/dockstacks/src/meta.ts delete mode 100644 apps/dockstore/templates/adguardhome/adguard-home.svg delete mode 100644 apps/dockstore/templates/adguardhome/template.json delete mode 100644 apps/dockstore/templates/bookstack/bookstack.svg delete mode 100644 apps/dockstore/templates/bookstack/template.json delete mode 100644 apps/dockstore/templates/gitea/gitea.svg delete mode 100644 apps/dockstore/templates/gitea/template.json delete mode 100644 apps/dockstore/templates/grafana/grafana.svg delete mode 100644 apps/dockstore/templates/grafana/template.json delete mode 100644 apps/dockstore/templates/heimdall/heimdall.svg delete mode 100644 apps/dockstore/templates/heimdall/template.json delete mode 100644 apps/dockstore/templates/home-assistant/template.json delete mode 100644 apps/dockstore/templates/nginx-proxy-manager/nginx-proxy-manager.svg delete mode 100644 apps/dockstore/templates/nginx-proxy-manager/template.json delete mode 100644 apps/dockstore/templates/outline/template.json delete mode 100644 apps/dockstore/templates/pihole/template.json delete mode 100644 apps/dockstore/templates/qcdn/template.json delete mode 100644 apps/dockstore/templates/tianji/template.json delete mode 100644 apps/dockstore/templates/tianji/tianji.svg delete mode 100644 apps/dockstore/templates/uptime-kuma/template.json delete mode 100644 apps/dockstore/templates/uptime-kuma/uptime-kuma.svg delete mode 100644 apps/dockstore/themes/beach/options.json delete mode 100644 apps/dockstore/themes/beach/theme.css delete mode 100644 apps/dockstore/themes/cyberpunk/options.json delete mode 100644 apps/dockstore/themes/cyberpunk/theme.css delete mode 100644 apps/dockstore/themes/default/options.json delete mode 100644 apps/dockstore/themes/default/theme.css delete mode 100644 apps/dockstore/themes/dracula/options.json delete mode 100644 apps/dockstore/themes/dracula/theme.css delete mode 100644 apps/dockstore/themes/forest-dusk/options.json delete mode 100644 apps/dockstore/themes/forest-dusk/theme.css delete mode 100644 apps/dockstore/themes/forest/options.json delete mode 100644 apps/dockstore/themes/forest/theme.css delete mode 100644 apps/dockstore/themes/lavender/options.json delete mode 100644 apps/dockstore/themes/lavender/theme.css delete mode 100644 apps/dockstore/themes/midnight-blue/options.json delete mode 100644 apps/dockstore/themes/midnight-blue/theme.css delete mode 100644 apps/dockstore/themes/midnight-purple/options.json delete mode 100644 apps/dockstore/themes/midnight-purple/theme.css delete mode 100644 apps/dockstore/themes/mint/options.json delete mode 100644 apps/dockstore/themes/mint/theme.css delete mode 100644 apps/dockstore/themes/morning-mist/options.json delete mode 100644 apps/dockstore/themes/morning-mist/theme.css delete mode 100644 apps/dockstore/themes/obsidian-red/options.json delete mode 100644 apps/dockstore/themes/obsidian-red/theme.css delete mode 100644 apps/dockstore/themes/ocean/options.json delete mode 100644 apps/dockstore/themes/ocean/theme.css delete mode 100644 apps/dockstore/themes/retro-neon/options.json delete mode 100644 apps/dockstore/themes/retro-neon/theme.css delete mode 100644 apps/dockstore/themes/steel-blue/options.json delete mode 100644 apps/dockstore/themes/steel-blue/theme.css delete mode 100644 apps/docs/.outline-sync/b4a5e48f-f103-480b-9f50-8f53f515cab9.config.json delete mode 100644 apps/docs/.outline-sync/b4a5e48f-f103-480b-9f50-8f53f515cab9.pages.json delete mode 100644 apps/docs/README.md delete mode 100644 apps/docs/archive/README.md delete mode 100644 apps/docs/archive/dockstat-v1/README.md delete mode 100644 apps/docs/archive/dockstat-v1/customization/README.md delete mode 100644 apps/docs/archive/dockstat-v1/customization/themes/README.md delete mode 100644 apps/docs/archive/dockstat-v1/installation/README.md delete mode 100644 apps/docs/archive/dockstat-v2/README.md delete mode 100644 apps/docs/archive/dockstatapi-v2/README.md delete mode 100644 apps/docs/archive/dockstatapi-v2/developing/README.md delete mode 100644 apps/docs/dockstat/README.md rename apps/docs/{archive/dockstatapi-v2 => dockstat}/api-reference/README.md (97%) create mode 100644 apps/docs/dockstat/architecture/README.md create mode 100644 apps/docs/dockstat/archive/README.md rename apps/docs/{ => dockstat}/archive/contribute/README.md (96%) rename apps/docs/{ => dockstat}/archive/dockstatapi-v1/README.md (56%) rename apps/docs/{ => dockstat}/archive/dockstatapi-v1/backend-api-reference/README.md (96%) rename apps/docs/{ => dockstat}/archive/dockstatapi-v1/integrations/README.md (89%) rename apps/docs/{ => dockstat}/archive/dockstatapi-v3/README.md (66%) rename apps/docs/{ => dockstat}/archive/dockstatapi-v3/authentication/README.md (85%) rename apps/docs/{ => dockstat}/archive/dockstatapi-v3/background-tasks/README.md (72%) rename apps/docs/{ => dockstat}/archive/dockstatapi-v3/contributing/README.md (91%) rename apps/docs/{ => dockstat}/archive/dockstatapi-v3/database/README.md (91%) rename apps/docs/{ => dockstat}/archive/dockstatapi-v3/plugin-development/README.md (95%) rename apps/docs/{ => dockstat}/archive/dockstatapi-v3/plugin-development/docker-events-endpoint/README.md (97%) rename apps/docs/{ => dockstat}/archive/dockstatapi-v3/stacks/README.md (80%) rename apps/docs/{ => dockstat}/archive/dockstatapi-v3/stacks/usage/README.md (93%) rename apps/docs/{ => dockstat}/archive/dockstatapi-v3/web-sockets/README.md (90%) rename apps/docs/{archive/dockstatapi-v2 => dockstat}/maintaining-functions/README.md (97%) rename apps/docs/{archive/dockstatapi-v2 => dockstat}/notifications/README.md (94%) rename apps/docs/dockstat/packages/{dockstat-outline-sync => @dockstat-outline-sync}/README.md (98%) rename apps/docs/dockstat/packages/{dockstat-sqlite-wrapper => @dockstat-sqlite-wrapper}/README.md (99%) rename apps/docs/dockstat/packages/{dockstat-theme-handler => @dockstat-theme-handler}/README.md (98%) create mode 100644 apps/docs/dockstat/packages/@dockstat-theme-handler/flow/README.md delete mode 100644 apps/docs/dockstat/packages/README.md.outline-sync.bak.1756056848814 delete mode 100644 apps/docs/dockstat/packages/README.md.outline-sync.bak.1756059516863 rename apps/docs/dockstat/packages/{private-dockstat-db-features => [private]-@dockstat-db-features}/README.md (98%) delete mode 100644 apps/docs/dockstat/packages/dockstat-sqlite-wrapper/README.md.outline-sync.bak.1756038409651 delete mode 100644 apps/docs/dockstat/packages/dockstat-sqlite-wrapper/README.md.outline-sync.bak.1756056849218 create mode 100644 apps/docs/outline-sync.config.json rename docker-compose-dev.yaml => docker-compose.dev.yaml (63%) create mode 100644 dockstat-dev.prometheus.yml create mode 100644 packages/db/bunfig.toml create mode 100644 packages/db/defaults.ts create mode 100644 packages/docker-client/bunfig.toml delete mode 100644 packages/docker-client/examples/all-stats-demo.ts delete mode 100644 packages/docker-client/examples/basic-usage.ts delete mode 100644 packages/docker-client/examples/websocket-integration.ts create mode 100644 packages/docker-client/src/_worker.index.ts delete mode 100644 packages/docker-client/src/events/docker-events.ts create mode 100644 packages/docker-client/src/events/workerEventProxy.ts create mode 100644 packages/docker-client/src/manager/_mixin.ts create mode 100644 packages/docker-client/src/manager/containers.ts create mode 100644 packages/docker-client/src/manager/core.ts create mode 100644 packages/docker-client/src/manager/hosts.ts create mode 100644 packages/docker-client/src/manager/images.ts create mode 100644 packages/docker-client/src/manager/index.ts create mode 100644 packages/docker-client/src/manager/monitoring.ts create mode 100644 packages/docker-client/src/manager/networks.ts create mode 100644 packages/docker-client/src/manager/system.ts create mode 100644 packages/docker-client/src/manager/types.ts create mode 100644 packages/docker-client/src/manager/volumes.ts create mode 100644 packages/docker-client/src/monitoring/MonitoringManager.ts create mode 100644 packages/docker-client/src/monitoring/monitors/ContainerEventMonitor.ts create mode 100644 packages/docker-client/src/monitoring/monitors/DockerEventStreamManager.ts create mode 100644 packages/docker-client/src/monitoring/monitors/HealthCheckMonitor.ts create mode 100644 packages/docker-client/src/monitoring/monitors/HostMetricsMonitor.ts create mode 100644 packages/docker-client/src/monitoring/utils/containerMapper.ts create mode 100644 packages/docker-client/src/monitoring/utils/index.ts create mode 100644 packages/docker-client/src/monitoring/utils/retry.ts create mode 100644 packages/docker-client/src/types.ts create mode 100644 packages/docker-client/src/utils/mapper.ts create mode 100644 packages/docker-client/src/utils/retry.ts create mode 100644 packages/logger/bunfig.toml delete mode 100644 packages/outline-sync/LICENSE delete mode 100644 packages/outline-sync/bin/cli.ts delete mode 100644 packages/outline-sync/build.ts delete mode 100644 packages/outline-sync/lib/config.ts delete mode 100644 packages/outline-sync/lib/init.ts delete mode 100644 packages/outline-sync/lib/outlineApi.ts delete mode 100644 packages/outline-sync/lib/syncEngine.ts delete mode 100644 packages/outline-sync/lib/types.ts delete mode 100644 packages/outline-sync/lib/utils.ts create mode 100644 packages/outline-sync/src/client.ts create mode 100644 packages/outline-sync/src/index.ts create mode 100644 packages/outline-sync/src/sync.ts create mode 100644 packages/outline-sync/src/types.ts create mode 100644 packages/plugin-handler/.gitignore create mode 100644 packages/plugin-handler/README.md create mode 100644 packages/plugin-handler/bunfig.toml create mode 100644 packages/plugin-handler/package.json create mode 100644 packages/plugin-handler/src/index.ts create mode 100644 packages/plugin-handler/tsconfig.json create mode 100644 packages/react-router-elysia/.dockerignore create mode 100644 packages/react-router-elysia/.gitignore create mode 100644 packages/react-router-elysia/Dockerfile create mode 100644 packages/react-router-elysia/README.md create mode 100644 packages/react-router-elysia/app/.server/elysia.ts create mode 100644 packages/react-router-elysia/app/.server/treaty.ts create mode 100644 packages/react-router-elysia/app/.server/websocket.ts create mode 100644 packages/react-router-elysia/app/app.css create mode 100644 packages/react-router-elysia/app/root.tsx create mode 100644 packages/react-router-elysia/app/routes.ts create mode 100644 packages/react-router-elysia/app/routes/api.tsx create mode 100644 packages/react-router-elysia/app/routes/example.tsx create mode 100644 packages/react-router-elysia/app/routes/home.tsx create mode 100644 packages/react-router-elysia/app/welcome/elysia_v.webp create mode 100644 packages/react-router-elysia/app/welcome/logo-dark.svg create mode 100644 packages/react-router-elysia/app/welcome/logo-light.svg create mode 100644 packages/react-router-elysia/app/welcome/welcome.tsx create mode 100644 packages/react-router-elysia/bunfig.toml create mode 100644 packages/react-router-elysia/package.json create mode 100644 packages/react-router-elysia/posts.json create mode 100644 packages/react-router-elysia/public/favicon.ico create mode 100644 packages/react-router-elysia/react-router.config.ts create mode 100644 packages/react-router-elysia/tsconfig.json create mode 100644 packages/react-router-elysia/vite.config.ts create mode 100644 packages/sqlite-wrapper/bunfig.toml create mode 100644 packages/typings/bunfig.toml delete mode 100644 packages/typings/src/archive/database.ts delete mode 100644 packages/typings/src/archive/docker-compose.ts delete mode 100644 packages/typings/src/archive/docker.ts delete mode 100644 packages/typings/src/archive/dockerode.ts delete mode 100644 packages/typings/src/archive/dockstacks.ts delete mode 100644 packages/typings/src/archive/frontend/handles.ts delete mode 100644 packages/typings/src/archive/frontend/loaders.ts delete mode 100644 packages/typings/src/archive/index.ts delete mode 100644 packages/typings/src/archive/misc.ts delete mode 100644 packages/typings/src/archive/plugin.ts delete mode 100644 packages/typings/src/archive/websocket.ts create mode 100644 packages/typings/src/docker-client-worker.ts create mode 100644 packages/typings/src/docker-monitoring-manager.ts create mode 100644 packages/typings/src/events.ts create mode 100644 packages/typings/src/helpers.ts create mode 100644 packages/typings/src/plugin-base.ts create mode 100644 packages/typings/src/typebox/_schemas.ts create mode 100644 packages/typings/src/typebox/_types.ts create mode 100644 packages/typings/src/typebox/db.ts create mode 100644 packages/typings/src/typebox/dockstore.ts create mode 100644 packages/typings/src/typebox/plugins.ts create mode 100644 packages/ui/.gitignore create mode 100644 packages/ui/.storybook/main.ts create mode 100644 packages/ui/.storybook/preview.ts create mode 100644 packages/ui/README.md create mode 100644 packages/ui/index.html create mode 100644 packages/ui/package.json rename {.github => packages/ui/public}/DockStat2-06.png (100%) create mode 100644 packages/ui/public/vite.svg create mode 100644 packages/ui/src/App.css create mode 100644 packages/ui/src/App.tsx create mode 100644 packages/ui/src/components/Badge/Badge.tsx create mode 100644 packages/ui/src/components/Button/Button.tsx create mode 100644 packages/ui/src/components/Card/Card.tsx create mode 100644 packages/ui/src/components/Card/CardBody.tsx create mode 100644 packages/ui/src/components/Card/CardFooter.tsx create mode 100644 packages/ui/src/components/Card/CardHeader.tsx create mode 100644 packages/ui/src/components/Divider/Divider.tsx create mode 100644 packages/ui/src/components/Extensions/Browser.tsx create mode 100644 packages/ui/src/components/Extensions/Repo.tsx create mode 100644 packages/ui/src/components/Extensions/slides/Plugin.tsx create mode 100644 packages/ui/src/components/Extensions/slides/Stack.tsx create mode 100644 packages/ui/src/components/Extensions/slides/Theme.tsx create mode 100644 packages/ui/src/components/Extensions/table/index.tsx create mode 100644 packages/ui/src/components/Forms/Checkbox.tsx create mode 100644 packages/ui/src/components/Forms/CheckboxGroup.tsx create mode 100644 packages/ui/src/components/Forms/Input.tsx create mode 100644 packages/ui/src/components/Forms/Toggle.tsx create mode 100644 packages/ui/src/components/HoverBubble/HoverBubble.tsx create mode 100644 packages/ui/src/components/Link/Link.tsx create mode 100644 packages/ui/src/components/Modal/Modal.tsx rename apps/dockstat/public/_assets/DockStat-Logo.png => packages/ui/src/components/Navbar/DockStat2-06.png (100%) create mode 100644 packages/ui/src/components/Navbar/Navbar.tsx create mode 100644 packages/ui/src/components/Plugins/Overview.tsx create mode 100644 packages/ui/src/components/Plugins/Plugin.tsx create mode 100644 packages/ui/src/components/Plugins/Plugins.tsx create mode 100644 packages/ui/src/components/Plugins/RepoList.tsx create mode 100644 packages/ui/src/components/Slider/Slider.tsx create mode 100644 packages/ui/src/components/Table/Table.tsx create mode 100644 packages/ui/src/components/index.ts create mode 100644 packages/ui/src/stories/Badge.stories.tsx create mode 100644 packages/ui/src/stories/Button.stories.tsx create mode 100644 packages/ui/src/stories/Card.stories.tsx create mode 100644 packages/ui/src/stories/Checkbox.stories.tsx create mode 100644 packages/ui/src/stories/CheckboxGroup.stories.tsx create mode 100644 packages/ui/src/stories/Divider.stories.tsx create mode 100644 packages/ui/src/stories/HoverBubble.stories.tsx create mode 100644 packages/ui/src/stories/Input.stories.tsx create mode 100644 packages/ui/src/stories/Intro.stories.tsx create mode 100644 packages/ui/src/stories/Link.stories.tsx create mode 100644 packages/ui/src/stories/Modal.stories.tsx create mode 100644 packages/ui/src/stories/Navbar.stories.tsx create mode 100644 packages/ui/src/stories/Slider.stories.tsx create mode 100644 packages/ui/src/stories/Table.stories.tsx create mode 100644 packages/ui/src/stories/Toggle.stories.tsx create mode 100644 packages/ui/src/stories/Welcome.stories.tsx create mode 100644 packages/ui/src/stories/assets/accessibility.png create mode 100644 packages/ui/src/stories/assets/accessibility.svg create mode 100644 packages/ui/src/stories/assets/addon-library.png create mode 100644 packages/ui/src/stories/assets/assets.png create mode 100644 packages/ui/src/stories/assets/avif-test-image.avif create mode 100644 packages/ui/src/stories/assets/context.png create mode 100644 packages/ui/src/stories/assets/discord.svg create mode 100644 packages/ui/src/stories/assets/docs.png create mode 100644 packages/ui/src/stories/assets/figma-plugin.png create mode 100644 packages/ui/src/stories/assets/github.svg create mode 100644 packages/ui/src/stories/assets/share.png create mode 100644 packages/ui/src/stories/assets/styling.png create mode 100644 packages/ui/src/stories/assets/testing.png create mode 100644 packages/ui/src/stories/assets/theming.png create mode 100644 packages/ui/src/stories/assets/tutorials.svg create mode 100644 packages/ui/src/stories/assets/youtube.svg create mode 100644 packages/ui/src/themes/light.css create mode 100644 packages/ui/src/themes/themes.json create mode 100644 packages/ui/src/utils/repoIcons.tsx create mode 100644 packages/ui/src/welcome/CONSTS.tsx create mode 100644 packages/ui/src/welcome/Intro.tsx create mode 100644 packages/ui/src/welcome/Onboarding.tsx create mode 100644 packages/ui/src/welcome/OnboardingFooter.tsx create mode 100644 packages/ui/src/welcome/OnboardingHeader.tsx create mode 100644 packages/ui/src/welcome/OnboardingSlide.tsx create mode 100644 packages/ui/src/welcome/ProgressDots.tsx create mode 100644 packages/ui/src/welcome/SlideBullet.tsx create mode 100644 packages/ui/src/welcome/SlideContent.tsx create mode 100644 packages/ui/src/welcome/SlideHeader.tsx create mode 100644 packages/ui/src/welcome/types.ts create mode 100644 packages/ui/tsconfig.app.json create mode 100644 packages/ui/tsconfig.json create mode 100644 packages/ui/tsconfig.node.json create mode 100644 packages/ui/vite.config.ts create mode 100644 packages/utils/.gitignore create mode 100644 packages/utils/README.md create mode 100644 packages/utils/bunfig.toml create mode 100644 packages/utils/package.json create mode 100644 packages/utils/src/http/RequestId.ts create mode 100644 packages/utils/src/http/_logger.ts create mode 100644 packages/utils/src/index.ts create mode 100644 packages/utils/src/worker/_logger.ts create mode 100644 packages/utils/src/worker/buildMessage.ts create mode 100644 packages/utils/tsconfig.json diff --git a/.bun-version b/.bun-version index 53fc08d6..3a3cd8cc 100644 --- a/.bun-version +++ b/.bun-version @@ -1 +1 @@ -1.2.20 +1.3.1 diff --git a/.github/DockStat2-02.png b/.github/DockStat2-02.png deleted file mode 100644 index 00ac779a6dcb303ce5c690aa079dedc175e5c8f3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 82847 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

TLgl9u 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 AX#fBK diff --git a/.github/workflows/docs-sync.yaml b/.github/workflows/docs-sync.yaml new file mode 100644 index 00000000..ad45589f --- /dev/null +++ b/.github/workflows/docs-sync.yaml @@ -0,0 +1,24 @@ +name: Outline Sync + +on: + push: + branches: [main] + schedule: + - cron: "0 */6 * * *" # Every 6 hours + workflow_dispatch: + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + - run: bun install -g @dockstat/outline-sync + - run: | + cd ./apps/docs + outline-sync ci + env: + OUTLINE_TOKEN: ${{ secrets.OUTLINE_TOKEN }} + - uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "docs: sync from Outline" diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml deleted file mode 100644 index 6c142b06..00000000 --- a/.github/workflows/docs-sync.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Docs Sync - -on: - push: - branches: [main] - paths: - - "apps/docs/dockstat/**/*.md" - schedule: - # Run every 6 hours - - cron: "0 */6 * * *" - workflow_dispatch: # Allow manual triggering - -permissions: - contents: write - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - sync-docs: - name: Sync Documentation - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version-file: ".bun-version" - - - name: Sync docs - env: - OUTLINE_COLLECTION_ID: ${{ secrets.OUTLINE_COLLECTION_ID }} - OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }} - run: | - bash sync.sh - git add . - - - name: Check for changes - id: check_changes - run: | - if git diff --quiet && git diff --staged --quiet; then - echo "No changes detected" - echo "changes=false" >> $GITHUB_OUTPUT - else - echo "Changes detected" - echo "changes=true" >> $GITHUB_OUTPUT - fi - - - name: Commit and push changes - if: steps.check_changes.outputs.changes == 'true' - run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - git add . - git commit -m "docs: sync documentation [skip ci]" - git push - - - name: Check sync status - run: | - if [ $? -eq 0 ]; then - echo "✅ Docs sync completed successfully" - else - echo "❌ Docs sync failed" - exit 1 - fi diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 00000000..d30ff682 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,23 @@ +name: Lint + Format +on: [push] + +permissions: + contents: write + +jobs: + lint: + name: Lint All packages and Apps + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: | + bun ci + bun run lint:fix:all + - name: Commit changes + if: always() + run: | + git config --global user.name 'actions-user' + git config --global user.email 'its4nik@users.noreply.github.com' + git commit --allow-empty -am "chore(ci): Lint [skip ci]" + git push diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 2c728016..00000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Lint - -on: - pull_request: - push: - branches: - - main - -permissions: - contents: write - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - lint: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version-file: ".bun-version" - - - name: Cache Bun dependencies - uses: actions/cache@v4 - with: - path: | - ~/.bun/install/cache - node_modules - key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun- - - - name: Install dependencies - run: | - bun install - - - name: Prettify code - run: | - bun run lint:gh - - - name: Check for changes - id: check_changes - run: | - if git diff --quiet && git diff --staged --quiet; then - echo "No changes detected" - echo "changes=false" >> $GITHUB_OUTPUT - else - echo "Changes detected" - echo "changes=true" >> $GITHUB_OUTPUT - fi - - - name: Commit and push changes - if: steps.check_changes.outputs.changes == 'true' - run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - git add . - git commit -m "chore(lint): Linting fixes [skip ci]" - git push diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml deleted file mode 100644 index 48eddf86..00000000 --- a/.github/workflows/pr-validation.yml +++ /dev/null @@ -1,406 +0,0 @@ -name: PR Validation - -on: - pull_request: - branches: [main, dev] - types: [opened, synchronize, reopened, ready_for_review] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - NODE_VERSION: "18" - -jobs: - changes: - name: Detect Changes - runs-on: ubuntu-latest - outputs: - apps: ${{ steps.filter.outputs.apps }} - packages: ${{ steps.filter.outputs.packages }} - docs: ${{ steps.filter.outputs.docs }} - dockstat: ${{ steps.filter.outputs.dockstat }} - dockstore: ${{ steps.filter.outputs.dockstore }} - sqlite-wrapper: ${{ steps.filter.outputs.sqlite-wrapper }} - db: ${{ steps.filter.outputs.db }} - typings: ${{ steps.filter.outputs.typings }} - ci: ${{ steps.filter.outputs.ci }} - steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v2 - id: filter - with: - filters: | - apps: - - 'apps/**' - packages: - - 'packages/**' - docs: - - 'apps/docs/**/*.md' - dockstat: - - 'apps/dockstat/**' - dockstore: - - 'apps/dockstore/**' - sqlite-wrapper: - - 'packages/sqlite-wrapper/**' - db: - - 'packages/db/**' - typings: - - 'packages/typings/**' - ci: - - '.github/**' - - 'package.json' - - 'turbo.json' - - 'tsconfig.base.json' - - lint: - name: Lint - runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.apps == 'true' || needs.changes.outputs.packages == 'true' || needs.changes.outputs.ci == 'true' - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v1 - with: - bun-version: ${{ env.BUN_VERSION }} - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Run linter - run: bun run lint - - - name: Check formatting - run: bun run lint:check - - typecheck: - name: Type Check - runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.apps == 'true' || needs.changes.outputs.packages == 'true' || needs.changes.outputs.ci == 'true' - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v1 - with: - bun-version: ${{ env.BUN_VERSION }} - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Type check - run: bun run check-types - - test-packages: - name: Test Packages - runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.packages == 'true' - strategy: - matrix: - package: [sqlite-wrapper, db, typings] - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v1 - with: - bun-version: ${{ env.BUN_VERSION }} - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Build package - run: bun run build --filter=@dockstat/${{ matrix.package }} - - - name: Test package - run: bun run test --filter=@dockstat/${{ matrix.package }} - - test-sqlite-wrapper: - name: Test SQLite Wrapper - runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.sqlite-wrapper == 'true' - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v1 - with: - bun-version: ${{ env.BUN_VERSION }} - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Build SQLite Wrapper - run: bun run build --filter=@dockstat/sqlite-wrapper - - - name: Test SQLite Wrapper - run: bun run test --filter=@dockstat/sqlite-wrapper - - - name: Test package installation - run: | - cd packages/sqlite-wrapper - bun pack --dry-run - - test-apps: - name: Test Apps - runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.apps == 'true' - strategy: - matrix: - app: [dockstat, dockstore, docs] - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v1 - with: - bun-version: ${{ env.BUN_VERSION }} - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Build dependencies - run: bun run build --filter=@dockstat/${{ matrix.app }}^... - - - name: Build app - run: bun run build --filter=${{ matrix.app }} - - - name: Test app - run: bun run test --filter=${{ matrix.app }} - - test-dockstat: - name: Test Dockstat with Docker - runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.dockstat == 'true' - services: - docker: - image: docker:dind - options: --privileged - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Docker - run: | - # Start some test containers for Dockstat to monitor - docker run -d --name test-nginx nginx:alpine - docker run -d --name test-redis redis:alpine - docker ps - - - name: Setup Bun - uses: oven-sh/setup-bun@v1 - with: - bun-version: ${{ env.BUN_VERSION }} - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Build Dockstat - run: bun run build --filter=dockstat - - - name: Test Dockstat - run: bun run test --filter=dockstat - - - name: Integration test with Docker - run: | - # Run integration tests that interact with Docker - cd apps/dockstat - bun run test:integration - - - name: Cleanup - run: | - docker stop test-nginx test-redis || true - docker rm test-nginx test-redis || true - - build-check: - name: Build Check - runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.apps == 'true' || needs.changes.outputs.packages == 'true' - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v1 - with: - bun-version: ${{ env.BUN_VERSION }} - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Build all packages - run: bun run build --filter=./packages/* - - - name: Build all apps - run: bun run build --filter=./apps/* - - security: - name: Security Scan - runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.apps == 'true' || needs.changes.outputs.packages == 'true' || needs.changes.outputs.ci == 'true' - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v1 - with: - bun-version: ${{ env.BUN_VERSION }} - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Run security audit - run: bun audit - - - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master - with: - scan-type: "fs" - scan-ref: "." - format: "sarif" - output: "trivy-results.sarif" - - - name: Upload Trivy scan results - uses: github/codeql-action/upload-sarif@v2 - if: always() - with: - sarif_file: "trivy-results.sarif" - - package-size: - name: Package Size Check - runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.packages == 'true' - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v1 - with: - bun-version: ${{ env.BUN_VERSION }} - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Build packages - run: bun run build --filter=./packages/* - - - name: Check package sizes - run: | - for package in packages/*/; do - if [ -f "$package/package.json" ]; then - echo "Checking size for $package" - cd "$package" - size=$(bun pack --dry-run 2>&1 | grep "package size" | awk '{print $3}' || echo "unknown") - echo "Package size: $size" - cd - > /dev/null - fi - done - - docs-build: - name: Docs Build - runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.docs == 'true' - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v1 - with: - bun-version: ${{ env.BUN_VERSION }} - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Build docs - run: bun run build --filter=docs - - - name: Check for broken links - run: | - cd apps/docs - # Add link checking here if available - echo "Docs built successfully" - - pr-validation-summary: - name: PR Validation Summary - runs-on: ubuntu-latest - needs: - [ - lint, - typecheck, - test-packages, - test-apps, - build-check, - security, - docs-build, - ] - if: always() - steps: - - name: Check all jobs - run: | - echo "Lint: ${{ needs.lint.result }}" - echo "TypeCheck: ${{ needs.typecheck.result }}" - echo "Test Packages: ${{ needs.test-packages.result }}" - echo "Test Apps: ${{ needs.test-apps.result }}" - echo "Build Check: ${{ needs.build-check.result }}" - echo "Security: ${{ needs.security.result }}" - echo "Docs Build: ${{ needs.docs-build.result }}" - - if [[ "${{ needs.lint.result }}" == "failure" || - "${{ needs.typecheck.result }}" == "failure" || - "${{ needs.test-packages.result }}" == "failure" || - "${{ needs.test-apps.result }}" == "failure" || - "${{ needs.build-check.result }}" == "failure" || - "${{ needs.security.result }}" == "failure" || - "${{ needs.docs-build.result }}" == "failure" ]]; then - echo "❌ PR validation failed" - exit 1 - else - echo "✅ PR validation passed" - fi - - - name: Comment PR - uses: actions/github-script@v6 - if: always() - with: - script: | - const status = '${{ job.status }}' === 'success' ? '✅ Passed' : '❌ Failed'; - const body = ` - ## PR Validation Results ${status} - - | Check | Status | - |-------|--------| - | Lint | ${{ needs.lint.result == 'success' && '✅' || needs.lint.result == 'failure' && '❌' || '⏭️' }} | - | Type Check | ${{ needs.typecheck.result == 'success' && '✅' || needs.typecheck.result == 'failure' && '❌' || '⏭️' }} | - | Package Tests | ${{ needs.test-packages.result == 'success' && '✅' || needs.test-packages.result == 'failure' && '❌' || '⏭️' }} | - | App Tests | ${{ needs.test-apps.result == 'success' && '✅' || needs.test-apps.result == 'failure' && '❌' || '⏭️' }} | - | Build Check | ${{ needs.build-check.result == 'success' && '✅' || needs.build-check.result == 'failure' && '❌' || '⏭️' }} | - | Security Scan | ${{ needs.security.result == 'success' && '✅' || needs.security.result == 'failure' && '❌' || '⏭️' }} | - | Docs Build | ${{ needs.docs-build.result == 'success' && '✅' || needs.docs-build.result == 'failure' && '❌' || '⏭️' }} | - `; - - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: body - }); diff --git a/.gitignore b/.gitignore index ab011c03..31a3b632 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ .gitai.json backup \!archive + +.test-setup + # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # Dependencies @@ -47,3 +50,6 @@ yarn-error.log* *.pem *.sqlite + +*storybook.log +storybook-static diff --git a/README.md b/README.md index b3bdaa70..03c374e2 100644 --- a/README.md +++ b/README.md @@ -3,55 +3,230 @@

- Wiki + Wiki · Getting Started · Features · Architecture & Apps · Development

--- -## 📖 Description +# DockStat -DockStat aims to combine the best of **Portainer**, **Grafana**, and **Dockge** into a single, unified platform for **container administration** and **monitoring**. -The goal: the ultimate, extensible solution for managing and observing your containerized environments. +| ![TailwindCSS](https://img.shields.io/badge/tailwindcss-%2338B2AC.svg?style=for-the-badge&logo=tailwind-css&logoColor=white) | ![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white) | ![Biome](https://img.shields.io/badge/biome-%2360A5FA.svg?style=for-the-badge&logo=biome&logoColor=white) | ![SQLite](https://img.shields.io/badge/sqlite-%2307405e.svg?style=for-the-badge&logo=sqlite&logoColor=white) | +| ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| ![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white) | ![React Router](https://img.shields.io/badge/React_Router-CA4245?style=for-the-badge&logo=react-router&logoColor=white) | ![Prometheus](https://img.shields.io/badge/Prometheus-E6522C?style=for-the-badge&logo=Prometheus&logoColor=white) | ![Turborepo](https://img.shields.io/badge/Turborepo-%230F0813.svg?style=for-the-badge&logo=Turborepo&logoColor=white) | -#### ⚠️ Disclaimer +DockStat is an extensible container administration and monitoring platform that aims to combine the best ideas from tools like Portainer, Grafana and Dockge into a single, modular solution. It focuses on real-world manageability, deep observability and a runtime plugin-first architecture so the platform can evolve with your stack. -DockStat is currently in **Pre-Alpha**. -Expect **breaking changes, missing features, and no guarantees** at this stage. +> ⚠️ Disclaimer +> DockStat is currently in **Pre-Alpha**. Expect breaking changes, missing features and instability. Use for testing and evaluation only unless you know it's suitable for your environment. --- -## ✨ Features +## 📖 Project overview -DockStat is built with extensibility in mind, powered by a **runtime-compatible plugin system**. +DockStat's goal is to be the unified UI and runtime for managing containerized infrastructures (Docker for now, with intentions to expand). It provides: -- **Plugin System** - - Frontend components - - Backend services - - Combined (full-stack) plugins -- **Theming Support** - - Custom CSS variables for deep theming -- **Stacks** - - Automatic node provisioning - - (e.g. Hetzner Cloud API plugin) - - Prebuilt Docker Compose templates - - _(future: Kubernetes support)_ - - Whole-stack monitoring - - Stack-level plugin support (e.g. Traefik, Caddy) -- **Monitoring** - - Table-based views - - Graph-based insights - - Network-based visualization with [sigma.js](https://sigmajs.org) / [reagraph](https://www.npmjs.com/package/reagraph) -- **Custom Dashboards** - - Extend with plugins (e.g. Home Assistant-style widgets) -- **Multi-Node Monitoring** - - Group nodes using **adapters** - - Adapters configure monitoring options (e.g. Docker Client can track multiple hosts with its own config) +- A responsive web UI for container & stack lifecycle operations. +- A monitoring layer with table and graph views and network visualizations. +- A runtime plugin system that supports frontend components, backend services and full-stack plugins. +- Stack and template management (prebuilt Docker Compose templates, stack-level plugins). +- Multi-node monitoring via adapters that let a single DockStat instance manage multiple Docker hosts. --- -## 🛠 Tech Stack +## ✨ Key features -**Core**: React Router v7 • React • Bun +- Runtime-compatible plugin system: + - Frontend UI components (widgets, pages) + - Backend services (collectors, adapters) + - Full-stack plugins (both frontend and backend) +- Theming: + - CSS variable driven theming for deep customization +- Stacks & Templates: + - Prebuilt Docker Compose templates (DockStore) + - Stack-level plugin hooks (Traefik/Caddy examples) + - Automatic node provisioning (e.g. Hetzner Cloud plugin) + - Future: Kubernetes support roadmap +- Monitoring & Visualization: + - Table-based and graph-based metrics + - Network visualization with sigma.js / reagraph + - Whole-stack monitoring (containers, services, networks) +- Custom Dashboards: + - Home-Assistant style widget system extendable via plugins +- Multi-Node Monitoring: + - Adapters abstract different backends (local Docker, remote Docker API, SSH, etc.) + - Per-adapter configuration and credentials +- Extensible datastore: + - Lightweight persistence (bun:sqlite used in current prototypes) for local storage and historic metrics -- **Frontend**: TailwindCSS • GSAP • lucide-react -- **Backend**: Dockerode • bun:sqlite • @dockstat/\* +--- + +## 🛠 Tech stack + +- Core: React, React Router v7, Bun +- Frontend: TypeScript, TailwindCSS, GSAP, lucide-react +- Backend / Integrations: ElysiaJS, Dockerode, bun:sqlite, @dockstat/\* packages (internal libs) +- Monorepo layout: apps/, packages/ (typings, db, sql-wrapper, plugins, ...) + +--- + +## 📁 Repo structure (high level) + +- apps/ + - dockstat — main frontend / UI app (React Router SPA) + - dockstore — community hub for templates, themes & plugins + - docs — documentation and Outline wiki sync helpers +- packages/ (internal packages / libraries used by apps) +- .github/ (assets such as logos and CI configs) + +See apps/README.md for per-app details. + +--- + +## 🚀 Getting started (developer / local) + +Requirements + +- Bun (used for development and scripts) +- Node ecosystem tools (if you prefer npm/pnpm for some tasks) +- Docker (for testing container interactions) +- Optional: access to cloud provider API keys (for provisioning plugins) + +Quick start (from Repo root) + +1. Install dependencies: + +```bash +bun install +``` + +2. Start dockstat in development: + +```bash +cd ./apps/dockstat +bun run dev +``` + +--- + +## 🧩 Plugin system (overview) + +DockStat is designed to grow through plugins. Plugins can register UI components, provide backend services (collectors, adapters, provisioning modules), or both. + +Plugin types + +- Frontend plugin: Registers routes, pages or widgets in the UI. Usually provides a manifest and runtime hooks. +- Backend plugin: Runs a process/service that collects metrics, talks to external APIs or adds adapters for new node types. +- Full-stack plugin: Includes both frontend and backend parts and ships as a single distributable. + +Concepts + +- Manifest: A plugin manifest describes name, version, provided capabilities, and entry points. +- Lifecycle: Plugins are discovered at runtime and may be started/stopped without recompiling the host (subject to host safety). + +Developer notes + +- Keep plugin APIs minimal; prefer well-documented typed contracts. + +--- + +## 🔌 Stacks & DockStore + +DockStore is the ecosystem hub for templates, themes and plugins: + +- Pre-built Docker Compose templates for common apps (AdGuard, Grafana, Home Assistant, etc.) +- Themes and UI tweaks +- Plugin marketplace / registry for community contributions + +--- + +## ⚙️ Multi-node & adapters + +DockStat separates collection and aggregation from visualization via adapters: + +- Docker client adapters (can manage multiple hosts) +- Cloud provisioning adapters (Hetzner, others planned) +- Custom adapters can be written as backend plugins to bring new node types under management + +Adapters expose: + +- Node discovery & registration +- Metrics collection configuration +- Connection and credential management + +--- + +## ✅ Development workflow & guidelines + +- Each app/package should have its own README and local dev scripts. +- Use TypeScript and JSDoc for public APIs. +- Follow repository linting and formatting rules (see root scripts and package.json). +- CI should run type checks, linters and tests on PRs. + +Common scripts + +- bun run dev — start development servers +- bun run build — build production bundles +- biome — linting and type checks (project specific) + +Adding an app + +- Create directory in apps/ +- Add package.json with scripts (dev/build/lint/check-types) +- Add tsconfig.json extending root config +- Update root workspace config if needed + +--- + +## 🧪 Known limitations & stability + +- Pre-Alpha: expect breaking API and UX changes. +- Limited production hardening; use only for testing. +- Some features (Kubernetes support, advanced alerting, long-term metrics storage) are planned but not yet implemented. + +--- + +## 📦 Roadmap (high level) + +Short term + +- Stabilize plugin runtime API +- Improve adapter management and credentials handling +- Expand DockStore templates and build a simple web marketplace + +Medium term + +- Advanced dashboards and widget editor +- Built-in alerting and notification center +- Optional integration with time-series DB for long-term metrics + +Long term + +- Kubernetes management support +- Multi-tenant and RBAC features +- Rich plugin ecosystem and marketplace + +--- + +## 🤝 Contributing + +Contributions, ideas and bug reports are welcome. + +- Check the Wiki for developer docs and architecture notes +- Open issues for bugs or feature requests +- Submit PRs with small, focused changes and good descriptions +- If you're adding a plugin or template, prefer adding it to apps/dockstore for discoverability + +--- + +## 📚 Documentation & Support + +Start with the repository Wiki and the Outline docs: + +- Wiki: https://outline.itsnik.de/s/9d88c471-373e-4ef2-a955-b1058eb7dc99 +- apps/README.md and apps/dockstat/README.md for per-app instructions + +--- + +## 📝 License + +> [Mozilla Public License Version 2.0](https://www.mozilla.org/en-US/MPL/2.0/) diff --git a/apps/api/.gitignore b/apps/api/.gitignore new file mode 100644 index 00000000..87e56100 --- /dev/null +++ b/apps/api/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +**/*.trace +**/*.zip +**/*.tar.gz +**/*.tgz +**/*.log +package-lock.json +**/*.bun \ No newline at end of file diff --git a/apps/api/README.md b/apps/api/README.md new file mode 100644 index 00000000..688c87e6 --- /dev/null +++ b/apps/api/README.md @@ -0,0 +1,15 @@ +# Elysia with Bun runtime + +## Getting Started +To get started with this template, simply paste this command into your terminal: +```bash +bun create elysia ./elysia-example +``` + +## Development +To start the development server run: +```bash +bun run dev +``` + +Open http://localhost:3000/ with your browser to see the result. \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 00000000..cb7ecc17 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,28 @@ +{ + "name": "@dockstat/api", + "version": "1.0.0", + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "dev": "bun run --watch src/index.ts" + }, + "dependencies": { + "@dockstat/db": "workspace:*", + "@dockstat/docker-client": "workspace:*", + "@dockstat/logger": "workspace:*", + "@dockstat/plugin-handler": "workspace:*", + "@dockstat/sqlite-wrapper": "workspace:*", + "@dockstat/typings": "workspace:*", + "@dockstat/ui": "workspace:*", + "@dockstat/utils": "workspace:*", + "@elysiajs/cors": "^1.4.0", + "@elysiajs/openapi": "^1.4.11", + "@elysiajs/server-timing": "^1.4.0", + "elysia": "1.4.12" + }, + "devDependencies": { + "bun-types": "latest" + }, + "module": "src/index.ts" +} diff --git a/apps/api/src/database/index.ts b/apps/api/src/database/index.ts new file mode 100644 index 00000000..bb291440 --- /dev/null +++ b/apps/api/src/database/index.ts @@ -0,0 +1,19 @@ +import DBFactory from "@dockstat/db" +import type DB from "@dockstat/sqlite-wrapper" +import type { QueryBuilder } from "@dockstat/sqlite-wrapper" +import type { DockStatConfigTableType } from "@dockstat/typings/types" +import BaseLogger from "../logger" + +const InitialDockStatDB = new DBFactory("DB", BaseLogger.getParentsForLoggerChaining()) + +export const DockStatDB: { + _sqliteWrapper: DB + _dbPath: string + configTable: QueryBuilder + metricsTable: unknown +} = { + _sqliteWrapper: InitialDockStatDB.getDB(), + _dbPath: InitialDockStatDB.getDatabasePath(), + configTable: InitialDockStatDB.getConfigTable(), + metricsTable: InitialDockStatDB.getMetricsTable(), +} diff --git a/apps/api/src/database/utils.ts b/apps/api/src/database/utils.ts new file mode 100644 index 00000000..419dbc9d --- /dev/null +++ b/apps/api/src/database/utils.ts @@ -0,0 +1,22 @@ +import type { DatabaseModel } from "../models/database" +import { DockStatDB } from "." + +export function updateConfig(config: DatabaseModel.updateBody) { + const updateRes = DockStatDB.configTable + .where({ + id: 0, + }) + .update(config) + const newConfig = DockStatDB.configTable + .select(["*"]) + .where({ + id: 0, + }) + .get() + return { + message: "Updated config successfully", + code: 200, + update_response: updateRes, + new_config: newConfig, + } +} diff --git a/apps/api/src/docker/index.ts b/apps/api/src/docker/index.ts new file mode 100644 index 00000000..514d4ad7 --- /dev/null +++ b/apps/api/src/docker/index.ts @@ -0,0 +1,10 @@ +import DockerClientManager from "@dockstat/docker-client/manager" +import { DockStatDB } from "../database" +import BaseLogger from "../logger" +import PluginHandler from "../plugins" + +const DCM = new DockerClientManager(DockStatDB._sqliteWrapper, PluginHandler, BaseLogger, { + maxWorkers: Number(Bun.env.DOCKSTAT_MAX_WORKERS) || 200, +}) + +export default DCM diff --git a/apps/api/src/elysia-plugins.ts b/apps/api/src/elysia-plugins.ts new file mode 100644 index 00000000..b9872667 --- /dev/null +++ b/apps/api/src/elysia-plugins.ts @@ -0,0 +1,29 @@ +import { openapi } from "@elysiajs/openapi" +import { serverTiming } from "@elysiajs/server-timing" +import Elysia from "elysia" + +const DockStatElysiaPlugins = new Elysia() + .use( + openapi({ + path: "/docs", + provider: "scalar", + }) + ) + .use( + serverTiming({ + enabled: Bun.env.DOCKSTATAPI_SHOW_TRACES !== "false", + trace: { + afterHandle: true, + beforeHandle: true, + error: true, + handle: true, + mapResponse: true, + parse: true, + request: true, + total: true, + transform: true, + }, + }) + ) + +export default DockStatElysiaPlugins diff --git a/apps/api/src/handlers/onError.ts b/apps/api/src/handlers/onError.ts new file mode 100644 index 00000000..6419b685 --- /dev/null +++ b/apps/api/src/handlers/onError.ts @@ -0,0 +1,92 @@ +import Elysia, { type ValidationError } from "elysia" + +export const errorHandler = new Elysia().onError(({ code, error, set, request }) => { + const path = new URL(request.url).pathname + const timestamp = new Date().toISOString() + + // Handle validation errors + if (code === "VALIDATION") { + const validationError = error as ValidationError + + // Check if it's a response validation error (server-side) + if (validationError.type === "response") { + set.status = 500 + + return { + error: "Response validation failed", + message: validationError.message, + path, + timestamp, + ...(process.env.NODE_ENV === "development" && { + detail: validationError.all, + }), + } + } + + // Request validation errors (client-side) + set.status = 400 + + // In production, hide validation details for security + if (process.env.NODE_ENV === "production") { + return { + error: "Validation failed", + message: validationError.message, + path, + timestamp, + } + } + + // In development, show details + return { + error: "Validation failed", + message: validationError.message, + path, + timestamp, + detail: validationError.all, + } + } + + // Handle parser errors (malformed JSON, etc.) + if (code === "PARSE") { + set.status = 400 + return { + error: "Parse error", + message: "Invalid request format", + path, + timestamp, + } + } + + // Handle not found errors + if (code === "NOT_FOUND") { + set.status = 404 + return { + error: "Not found", + message: `Cannot ${request.method} ${path}`, + path, + timestamp, + } + } + + // Handle internal server errors + if (code === "INTERNAL_SERVER_ERROR") { + set.status = 500 + return { + error: "Internal server error", + message: "An unexpected error occurred", + path, + timestamp, + } + } + + // Handle unknown errors + set.status = 500 + console.error("Unhandled error:", { code, error, path, timestamp }) + + return { + error: "Unknown error", + message: "An unexpected error occurred", + path, + timestamp, + } +}) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 00000000..2c22acee --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,19 @@ +import { cors } from "@elysiajs/cors" +import Elysia from "elysia" +import DockStatElysiaPlugins from "./elysia-plugins" +import { errorHandler } from "./handlers/onError" +import DBRoutes from "./routes/db" +import DockerRoutes from "./routes/docker" +import PrometheusMetricsRoute from "./routes/metrics/prometheus" +import PluginRoutes from "./routes/plugins" + +export const DockStatAPI = new Elysia({ prefix: "/api/v2" }) + .use(cors()) + .use(DockStatElysiaPlugins) + .use(errorHandler) + .use(DBRoutes) + .use(PrometheusMetricsRoute) + .use(DockerRoutes) + .use(PluginRoutes) + +export type TreatyType = typeof DockStatAPI diff --git a/apps/api/src/logger.ts b/apps/api/src/logger.ts new file mode 100644 index 00000000..c46b7bcd --- /dev/null +++ b/apps/api/src/logger.ts @@ -0,0 +1,5 @@ +import Logger from "@dockstat/logger" + +const BaseLogger = new Logger("DockStatAPI") + +export default BaseLogger diff --git a/apps/api/src/middleware/metrics/helper.ts b/apps/api/src/middleware/metrics/helper.ts new file mode 100644 index 00000000..3748059c --- /dev/null +++ b/apps/api/src/middleware/metrics/helper.ts @@ -0,0 +1,709 @@ +import type { Database } from "bun:sqlite" +import type { Elysia } from "elysia" +import { DockStatDB } from "../../database" +import BaseLogger from "../../logger" +import { type MetricFamily, renderPrometheusMetrics } from "./prometheus" + +/** + * In-memory metrics (current server session) + */ +interface RequestMetrics { + totalRequests: number + requestsByMethod: Map + requestsByPath: Map + requestsByStatus: Map + requestDurations: number[] + errors: number +} + +/** + * Serializable version for JSON columns (no Map keys) + */ +interface SerializableRequestMetrics { + totalRequests: number + requestsByMethod: Record + requestsByPath: Record + requestsByStatus: Record + requestDurations: number[] + errors: number +} + +interface MetricsRow extends SerializableRequestMetrics, Record { + id: number +} + +const MetricsTable = DockStatDB._sqliteWrapper.table("metrics", { + JSON: ["requestsByMethod", "requestsByPath", "requestsByStatus", "requestDurations"], +}) + +const logger = BaseLogger.spawn("Metrics") + +/** + * Helpers to create empty metrics + */ +function createEmptyRequestMetrics(): RequestMetrics { + return { + totalRequests: 0, + requestsByMethod: new Map(), + requestsByPath: new Map(), + requestsByStatus: new Map(), + requestDurations: [], + errors: 0, + } +} + +function createEmptySerializableMetrics(): SerializableRequestMetrics { + return { + totalRequests: 0, + requestsByMethod: {}, + requestsByPath: {}, + requestsByStatus: {}, + requestDurations: [], + errors: 0, + } +} + +/** + * Global in-memory metrics (current server session) + */ +const metrics: RequestMetrics = createEmptyRequestMetrics() + +/** + * Persisted metrics (backed by DB) + */ +let persistedMetrics: SerializableRequestMetrics = createEmptySerializableMetrics() +let persistedMetricsId: number | null = null + +/** + * Initialize/load the single metrics row from DB (or create it) + */ +function initPersistedMetrics() { + if (persistedMetricsId !== null) return + + try { + const existing = MetricsTable.all()[0] + + if (existing) { + persistedMetricsId = existing.id + persistedMetrics = { + totalRequests: existing.totalRequests ?? 0, + requestsByMethod: existing.requestsByMethod ?? {}, + requestsByPath: existing.requestsByPath ?? {}, + requestsByStatus: existing.requestsByStatus ?? {}, + requestDurations: existing.requestDurations ?? [], + errors: existing.errors ?? 0, + } + } else { + const empty = createEmptySerializableMetrics() + const result = MetricsTable.insert({ + totalRequests: empty.totalRequests, + requestsByMethod: empty.requestsByMethod, + requestsByPath: empty.requestsByPath, + requestsByStatus: empty.requestsByStatus, + requestDurations: empty.requestDurations, + errors: empty.errors, + }) + persistedMetrics = empty + const id = result.insertId + persistedMetricsId = Number(id ?? 1) + } + } catch (err) { + console.error("Failed to init persisted metrics:", err) + // Fall back to in-memory only + persistedMetricsId = null + } +} + +/** + * Persist current `persistedMetrics` into DB + */ +function savePersistedMetrics() { + if (persistedMetricsId === null) { + initPersistedMetrics() + if (persistedMetricsId === null) return + } + + try { + MetricsTable.where({ + id: persistedMetricsId, + }).update({ + totalRequests: persistedMetrics.totalRequests, + requestsByMethod: persistedMetrics.requestsByMethod, + requestsByPath: persistedMetrics.requestsByPath, + requestsByStatus: persistedMetrics.requestsByStatus, + requestDurations: persistedMetrics.requestDurations, + errors: persistedMetrics.errors, + }) + } catch (err) { + console.error("Failed to save persisted metrics:", err) + } +} + +/** + * Helpers for updating persisted metrics safely + */ +function incPersisted(obj: Record, key: string) { + obj[key] = (obj[key] ?? 0) + 1 +} + +function trackDuration(durations: number[], duration: number, max = 1000) { + durations.push(duration) + if (durations.length > max) durations.shift() +} + +export const metricsMiddleware = (app: Elysia) => { + // Ensure persisted metrics row is ready + initPersistedMetrics() + + return app + .state("startTime", 0) + .onBeforeHandle( + { + as: "global", + }, + ({ store, request }) => { + store.startTime = performance.now() + logger.debug( + `Started performance tracking`, + request.headers.get("x-dockstatapi-requestid") ?? undefined + ) + } + ) + .onAfterHandle( + { + as: "global", + }, + ({ request, responseValue, store }) => { + const duration = performance.now() - (store.startTime || 0) + const method = request.method + const path = new URL(request.url).pathname + + logger.debug( + `[${method}] Took ${Math.round(duration)}ms on ${path}`, + request.headers.get("x-dockstatapi-requestid") ?? undefined + ) + + if (path === "/api/metrics") { + logger.debug( + `Skipped path: ${path}`, + request.headers.get("x-dockstatapi-requestid") ?? undefined + ) + } else { + // ---- SESSION METRICS ---- + metrics.totalRequests++ + + metrics.requestsByMethod.set(method, (metrics.requestsByMethod.get(method) || 0) + 1) + + metrics.requestsByPath.set(path, (metrics.requestsByPath.get(path) || 0) + 1) + + const status = + ( + responseValue as { + status?: number + } + )?.status || 200 + metrics.requestsByStatus.set(status, (metrics.requestsByStatus.get(status) || 0) + 1) + + trackDuration(metrics.requestDurations, duration) + + // ---- PERSISTED / TOTAL METRICS ---- + persistedMetrics.totalRequests++ + + incPersisted(persistedMetrics.requestsByMethod, method) + incPersisted(persistedMetrics.requestsByPath, path) + incPersisted(persistedMetrics.requestsByStatus, String(status)) + trackDuration(persistedMetrics.requestDurations, duration) + + // Persist to DB + savePersistedMetrics() + } + + logger.debug("Tracked metrics", request.headers.get("x-dockstatapi-requestid") ?? undefined) + } + ) + .onError( + { + as: "global", + }, + ({ store, request }) => { + const duration = performance.now() - (store.startTime || 0) + + // Session metrics + metrics.errors++ + trackDuration(metrics.requestDurations, duration) + + // Persisted metrics + persistedMetrics.errors++ + trackDuration(persistedMetrics.requestDurations, duration) + savePersistedMetrics() + + logger.debug("Tracked Error", request.headers.get("x-dockstatapi-requestid") ?? undefined) + } + ) +} + +// Database metrics collector (unchanged) +function getDatabaseMetrics(db: Database) { + const dbMetrics = { + size: 0, + pageCount: 0, + pageSize: 0, + tableCount: 0, + tables: [] as Array<{ + name: string + rowCount: number + }>, + } + + try { + const sizeQuery = db.query( + "SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()" + ) + const sizeResult: { + size?: number + } = sizeQuery.get() as { + size?: number + } + dbMetrics.size = sizeResult?.size || 0 + + const pageCountQuery = db.query("PRAGMA page_count") + const pageCountResult = pageCountQuery.get() as { + page_count?: number + } + dbMetrics.pageCount = pageCountResult?.page_count || 0 + + const pageSizeQuery = db.query("PRAGMA page_size") + const pageSizeResult = pageSizeQuery.get() as { + page_size?: number + } + dbMetrics.pageSize = pageSizeResult?.page_size || 0 + + const tablesQuery = db.query( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" + ) + const tables = tablesQuery.all() as Array<{ + name: string + }> + dbMetrics.tableCount = tables.length + + for (const table of tables) { + try { + const countQuery = db.query(`SELECT COUNT(*) as count FROM "${table.name}"`) + const countResult = countQuery.get() as { + count?: number + } + dbMetrics.tables.push({ + name: table.name, + rowCount: countResult?.count || 0, + }) + } catch (_) { + // Skip tables we can't read + } + } + } catch (error) { + console.error("Error collecting database metrics:", error) + } + + return dbMetrics +} + +/** + * Helper to compute summary stats from a duration array + */ +function summarizeDurations(durations: number[]) { + if (!durations.length) return null + + const sorted = [...durations].sort((a, b) => a - b) + const sum = sorted.reduce((a, b) => a + b, 0) + const avg = sum / sorted.length + const p50 = sorted[Math.floor(sorted.length * 0.5)] + const p95 = sorted[Math.floor(sorted.length * 0.95)] + const p99 = sorted[Math.floor(sorted.length * 0.99)] + + return { + sorted, + sum, + avg, + p50, + p95, + p99, + } +} + +export function formatPrometheusMetrics(db: Database) { + initPersistedMetrics() + + const timestamp = Date.now() + const families: MetricFamily[] = [] + + families.push({ + name: "http_requests_total", + help: "Total number of HTTP requests", + type: "counter", + samples: [ + { + labels: { + scope: "session", + }, + value: metrics.totalRequests, + timestamp, + }, + { + labels: { + scope: "total", + }, + value: persistedMetrics.totalRequests, + timestamp, + }, + ], + }) + + // --- REPLACEMENT for http_requests_by_method_total block --- + families.push({ + name: "http_requests_by_method_total", + help: "HTTP requests by method", + type: "counter", + samples: [ + // session + ...Array.from(metrics.requestsByMethod.entries()).map(([method, count]) => ({ + labels: { + method, + scope: "session", + }, + value: count, + timestamp, + })), + // total (persisted) + ...Object.entries(persistedMetrics.requestsByMethod).map(([method, count]) => ({ + labels: { + method, + scope: "total", + }, + value: count, + timestamp, + })), + ], + }) + + families.push({ + name: "http_requests_by_path_total", + help: "HTTP requests by path", + type: "counter", + samples: [ + ...Array.from(metrics.requestsByPath.entries()).map(([path, count]) => ({ + labels: { + path, + scope: "session", + }, + value: count, + timestamp, + })), + ...Object.entries(persistedMetrics.requestsByPath).map(([path, count]) => ({ + labels: { + path, + scope: "total", + }, + value: count, + timestamp, + })), + ], + }) + + families.push({ + name: "http_requests_by_status_total", + help: "HTTP requests by status code", + type: "counter", + samples: [ + ...Array.from(metrics.requestsByStatus.entries()).map(([status, count]) => ({ + labels: { + status: String(status), + scope: "session", + }, + value: count, + timestamp, + })), + ...Object.entries(persistedMetrics.requestsByStatus).map(([status, count]) => ({ + labels: { + status, + scope: "total", + }, + value: count, + timestamp, + })), + ], + }) + + families.push({ + name: "http_request_errors_total", + help: "Total number of HTTP errors", + type: "counter", + samples: [ + { + labels: { + scope: "session", + }, + value: metrics.errors, + timestamp, + }, + { + labels: { + scope: "total", + }, + value: persistedMetrics.errors, + timestamp, + }, + ], + }) + + // ---------- DURATION METRICS ---------- + + const sessionSummary = summarizeDurations(metrics.requestDurations) + const totalSummary = summarizeDurations(persistedMetrics.requestDurations) + + if (sessionSummary) { + families.push({ + name: "http_request_duration_ms", + help: "HTTP request duration in milliseconds", + type: "summary", + samples: [ + { + labels: { + quantile: "0.5", + scope: "session", + }, + value: sessionSummary.p50.toFixed(2), + timestamp, + }, + { + labels: { + quantile: "0.95", + scope: "session", + }, + value: sessionSummary.p95.toFixed(2), + timestamp, + }, + { + labels: { + quantile: "0.99", + scope: "session", + }, + value: sessionSummary.p99.toFixed(2), + timestamp, + }, + ], + }) + + families.push({ + name: "http_request_duration_ms_sum", + help: "Total HTTP request duration in milliseconds", + type: "summary", + samples: [ + { + labels: { + scope: "session", + }, + value: sessionSummary.sum.toFixed(2), + timestamp, + }, + ], + }) + + families.push({ + name: "http_request_duration_ms_count", + help: "HTTP request duration sample count", + type: "summary", + samples: [ + { + labels: { + scope: "session", + }, + value: sessionSummary.sorted.length, + timestamp, + }, + ], + }) + } + + if (totalSummary) { + families.push({ + name: "http_request_duration_ms", + help: "HTTP request duration in milliseconds", + type: "summary", + samples: [ + { + labels: { + quantile: "0.5", + scope: "total", + }, + value: totalSummary.p50.toFixed(2), + timestamp, + }, + { + labels: { + quantile: "0.95", + scope: "total", + }, + value: totalSummary.p95.toFixed(2), + timestamp, + }, + { + labels: { + quantile: "0.99", + scope: "total", + }, + value: totalSummary.p99.toFixed(2), + timestamp, + }, + ], + }) + + families.push({ + name: "http_request_duration_ms_sum", + help: "Total HTTP request duration in milliseconds", + type: "summary", + samples: [ + { + labels: { + scope: "total", + }, + value: totalSummary.sum.toFixed(2), + timestamp, + }, + ], + }) + + families.push({ + name: "http_request_duration_ms_count", + help: "HTTP request duration sample count", + type: "summary", + samples: [ + { + labels: { + scope: "total", + }, + value: totalSummary.sorted.length, + timestamp, + }, + ], + }) + } + + // ---------- DATABASE METRICS ---------- + + const dbMetrics = getDatabaseMetrics(db) + + families.push({ + name: "database_size_bytes", + help: "Database file size in bytes", + type: "gauge", + samples: [ + { + value: dbMetrics.size, + timestamp, + }, + ], + }) + + families.push({ + name: "database_page_count", + help: "Total number of database pages", + type: "gauge", + samples: [ + { + value: dbMetrics.pageCount, + timestamp, + }, + ], + }) + + families.push({ + name: "database_page_size_bytes", + help: "Database page size in bytes", + type: "gauge", + samples: [ + { + value: dbMetrics.pageSize, + timestamp, + }, + ], + }) + + families.push({ + name: "database_table_count", + help: "Total number of tables", + type: "gauge", + samples: [ + { + value: dbMetrics.tableCount, + timestamp, + }, + ], + }) + + families.push({ + name: "database_table_rows", + help: "Number of rows per table", + type: "gauge", + samples: dbMetrics.tables.map((table) => ({ + labels: { + table: table.name, + }, + value: table.rowCount, + timestamp, + })), + }) + + // ---------- PROCESS METRICS ---------- + + const memUsage = process.memoryUsage() + + families.push({ + name: "process_memory_rss_bytes", + help: "Process resident memory in bytes", + type: "gauge", + samples: [ + { + value: memUsage.rss, + timestamp, + }, + ], + }) + + families.push({ + name: "process_memory_heap_used_bytes", + help: "Process heap memory used in bytes", + type: "gauge", + samples: [ + { + value: memUsage.heapUsed, + timestamp, + }, + ], + }) + + families.push({ + name: "process_memory_heap_total_bytes", + help: "Process heap memory total in bytes", + type: "gauge", + samples: [ + { + value: memUsage.heapTotal, + timestamp, + }, + ], + }) + + families.push({ + name: "process_uptime_seconds", + help: "Process uptime in seconds", + type: "counter", + samples: [ + { + value: Number(Math.floor(process.uptime()).toFixed(2)), + timestamp, + }, + ], + }) + + return renderPrometheusMetrics(families, timestamp) +} diff --git a/apps/api/src/middleware/metrics/index.ts b/apps/api/src/middleware/metrics/index.ts new file mode 100644 index 00000000..c20cede8 --- /dev/null +++ b/apps/api/src/middleware/metrics/index.ts @@ -0,0 +1 @@ +export { formatPrometheusMetrics, metricsMiddleware } from "./helper" diff --git a/apps/api/src/middleware/metrics/prometheus.ts b/apps/api/src/middleware/metrics/prometheus.ts new file mode 100644 index 00000000..33dfd98d --- /dev/null +++ b/apps/api/src/middleware/metrics/prometheus.ts @@ -0,0 +1,42 @@ +export type MetricType = "counter" | "gauge" | "summary" + +export interface MetricSample { + labels?: Record + value: number | string + timestamp?: number +} + +export interface MetricFamily { + name: string + help: string + type: MetricType + samples: MetricSample[] +} + +function formatLabels(labels?: Record): string { + if (!labels || Object.keys(labels).length === 0) return "" + const parts = Object.entries(labels).map(([key, value]) => `${key}="${String(value)}"`) + return `{${parts.join(",")}}` +} + +export function renderPrometheusMetrics( + families: MetricFamily[], + defaultTimestamp = Date.now() +): string { + const lines: string[] = [] + + families.forEach((family, index) => { + if (index > 0) lines.push("") // blank line between metric families + + lines.push(`# HELP ${family.name} ${family.help}`) + lines.push(`# TYPE ${family.name} ${family.type}`) + + for (const sample of family.samples) { + const labels = formatLabels(sample.labels) + const ts = sample.timestamp ?? defaultTimestamp + lines.push(`${family.name}${labels} ${sample.value} ${ts}`) + } + }) + + return lines.join("\n") +} diff --git a/apps/api/src/models/database.ts b/apps/api/src/models/database.ts new file mode 100644 index 00000000..8bf2dde4 --- /dev/null +++ b/apps/api/src/models/database.ts @@ -0,0 +1,24 @@ +import { DockStatConfigTable, UpdateDockStatConfigTableResponse } from "@dockstat/typings/schemas" +import type { DockStatConfigTableType } from "@dockstat/typings/types" +import { t } from "elysia" + +export namespace DatabaseModel { + export const updateBody = DockStatConfigTable + export type updateBody = DockStatConfigTableType + + export const updateRes = UpdateDockStatConfigTableResponse + export type updateRes = typeof UpdateDockStatConfigTableResponse.static + + export const configRes = DockStatConfigTable + + export const error = t.Literal("Error while opening Database") + export const updateError = t.Object({ + message: t.Literal("Error while updating Database"), + error: t.Unknown(), + }) + + export const configResponses = t.Object({ + 200: configRes, + 400: error, + }) +} diff --git a/apps/api/src/models/docker.ts b/apps/api/src/models/docker.ts new file mode 100644 index 00000000..677bc6b9 --- /dev/null +++ b/apps/api/src/models/docker.ts @@ -0,0 +1,111 @@ +import { DockerAdapterOptionsSchema } from "@dockstat/typings" +import { t } from "elysia" + +export namespace DockerModel { + export const status = t.Object({ + hosts: t.Array(t.Object({ name: t.String(), id: t.Integer(), clientId: t.Integer() })), + totalWorkers: t.Integer(), + activeWorkers: t.Integer(), + totalHosts: t.Integer(), + totalClients: t.Integer(), + averageHostsPerWorker: t.Integer(), + workers: t.Array( + t.Object({ + workerId: t.Number(), + clientId: t.Number(), + clientName: t.String(), + hostsManaged: t.Number(), + activeStreams: t.Number(), + isMonitoring: t.Boolean(), + initialized: t.Boolean(), + memoryUsage: t.Object({ + rss: t.Number(), + heapTotal: t.Number(), + heapUsed: t.Number(), + external: t.Number(), + }), + uptime: t.Number(), + }) + ), + }) + export const error = t.Object({ + message: t.String(), + error: t.Unknown(), + }) + + export const poolStatus = t.Object({ + totalWorkers: t.Integer(), + activeWorkers: t.Integer(), + totalHosts: t.Integer(), + totalClients: t.Integer(), + averageHostsPerWorker: t.Integer(), + workers: t.Array( + t.Object({ + workerId: t.Number(), + clientId: t.Number(), + clientName: t.String(), + hostsManaged: t.Number(), + activeStreams: t.Number(), + isMonitoring: t.Boolean(), + initialized: t.Boolean(), + memoryUsage: t.Object({ + rss: t.Number(), + heapTotal: t.Number(), + heapUsed: t.Number(), + external: t.Number(), + }), + uptime: t.Number(), + }) + ), + }) + + export const initAllClientsRes = t.Array( + t.Object({ + id: t.Number(), + name: t.String(), + initialized: t.MaybeEmpty(t.Boolean()), + }) + ) + + export const registerClientBody = t.Object({ + clientName: t.String(), + options: t.Nullable(DockerAdapterOptionsSchema), + }) + export const registerClientError = t.Object({ + success: t.Boolean(), + error: t.Unknown(), + message: t.String(), + }) + export const registerClientSuccess = t.Object({ + success: t.Boolean(), + message: t.String(), + clientId: t.Number(), + }) + + export const allHosts = t.Array( + t.Object({ + name: t.String(), + id: t.Number(), + clientId: t.Number(), + }) + ) + + export const addHostBody = t.Object({ + clientId: t.Number(), + hostname: t.String(), + name: t.String(), + secure: t.Boolean(), + port: t.Number(), + }) + + export const updateBody = t.Object({ + clientId: t.Number(), + host: t.Object({ + id: t.Number(), + host: t.String(), + name: t.String(), + secure: t.Boolean(), + port: t.Number(), + }), + }) +} diff --git a/apps/api/src/models/metrics.ts b/apps/api/src/models/metrics.ts new file mode 100644 index 00000000..971803b5 --- /dev/null +++ b/apps/api/src/models/metrics.ts @@ -0,0 +1,11 @@ +import { t } from "elysia" + +export namespace MetricsModel { + export const prometheusRes = t.String() + export type prometheusRes = string + + export const prometheusError = t.Object({ + error: t.Unknown(), + message: t.Literal("Could not get Prometheus metrics!"), + }) +} diff --git a/apps/api/src/models/plugins.ts b/apps/api/src/models/plugins.ts new file mode 100644 index 00000000..b2a43181 --- /dev/null +++ b/apps/api/src/models/plugins.ts @@ -0,0 +1,14 @@ +import { DBPluginShema } from "@dockstat/typings/schemas" +import { t } from "elysia" + +export namespace PluginModel { + export const installPluginBody = DBPluginShema + + export const activatePluginBody = t.Array(t.Number()) + export const activatePluginRes = t.Object({ + errors: t.Array(t.Number()), + successes: t.Array(t.Number()), + }) + + export const deletePluginBody = t.Object({ pluginId: t.Number() }) +} diff --git a/apps/api/src/plugins/default-plugins/dockmon/index.ts b/apps/api/src/plugins/default-plugins/dockmon/index.ts new file mode 100644 index 00000000..b27521ec --- /dev/null +++ b/apps/api/src/plugins/default-plugins/dockmon/index.ts @@ -0,0 +1,33 @@ +import type { Plugin, PluginMetaType } from "@dockstat/typings/types" +import { config } from "./src/config" +import type { DockMonTable } from "./src/types" +import { mapFromHostMetricHookToDb } from "./src/utils/mapTo" + +export const meta: PluginMetaType = { + name: "DockMon", + repoType: "default", + repository: "its4nik/dockstat:dev/apps/dockstore", + version: "0.1.0", + author: { + license: "MIT", + name: "Its4Nik", + email: "dockstat@itsnik.de", + website: "https://itsnik.de", + }, + description: + "This is a default Plugin for DockStat, this Plugin enables the Monitoring and Metrics Pages. Monitoring and Metrics collection is still handled by DockStat, this plugin just listens for 'host:metrics' and 'container:metrics' and saves the data in a Table of the main DockStat DB.", + manifest: "src/content/plugins/dockmon/manifest.yml", +} + +const DockMon: Plugin = { + ...meta, + config: config, + events: { + "host:metrics": (ctx, { table, logger }) => { + logger.info("Saving Host metrics to DB") + table.insert(mapFromHostMetricHookToDb(ctx)) + }, + }, +} + +export default DockMon diff --git a/apps/api/src/plugins/default-plugins/dockmon/src/actions.ts b/apps/api/src/plugins/default-plugins/dockmon/src/actions.ts new file mode 100644 index 00000000..b67ee218 --- /dev/null +++ b/apps/api/src/plugins/default-plugins/dockmon/src/actions.ts @@ -0,0 +1,16 @@ +import type { PluginActions } from "@dockstat/typings" +import type { DockMonTable } from "./types" + +const DockMonActions: PluginActions = { + getAllMetrics: ({ table }) => table?.select(["*"]).all(), + getContainerMetrics: ({ table }) => table?.select(["*"]).where({ type: "CONTAINER" }).all(), + getHostMetrics: ({ table }) => table?.select(["*"]).where({ type: "HOST" }).all(), + test1: () => { + return "Test1" + }, + test2: ({ previousAction }) => { + return `${previousAction} - Test2` + }, +} + +export default DockMonActions diff --git a/apps/api/src/plugins/default-plugins/dockmon/src/config.ts b/apps/api/src/plugins/default-plugins/dockmon/src/config.ts new file mode 100644 index 00000000..4b516a9a --- /dev/null +++ b/apps/api/src/plugins/default-plugins/dockmon/src/config.ts @@ -0,0 +1,33 @@ +import { column } from "@dockstat/sqlite-wrapper" +import type { PluginConfig } from "@dockstat/typings" +import DockMonActions from "./actions" +import type { DockMonTable } from "./types" + +export const config: PluginConfig = { + actions: DockMonActions, + apiRoutes: { + "/all": { + actions: ["getAllMetrics"], + method: "GET", + }, + "/test": { + actions: ["test1", "test2"], + method: "GET", + }, + }, + table: { + columns: { + id: column.id(), + type: column.enum(["CONTAINER", "HOST"]), + host_id: column.integer(), + docker_client_id: column.integer(), + container_id: column.text({ notNull: false }), + data: column.json(), + stored_on: column.createdAt(), + }, + parser: { + JSON: ["data"], + }, + name: "dockmon", + }, +} diff --git a/apps/api/src/plugins/default-plugins/dockmon/src/types.ts b/apps/api/src/plugins/default-plugins/dockmon/src/types.ts new file mode 100644 index 00000000..ab4f914a --- /dev/null +++ b/apps/api/src/plugins/default-plugins/dockmon/src/types.ts @@ -0,0 +1,16 @@ +import type { ContainerStatsInfo, HostMetrics } from "@dockstat/typings" + +export type DockMonTable = { + id: number + type: "CONTAINER" | "HOST" + host_id: number + docker_client_id: number + container_id: string | null + data: DBMetrics + stored_on: number +} + +export type DBMetrics = { + host_metrics?: HostMetrics + container_metrics?: ContainerStatsInfo +} diff --git a/apps/api/src/plugins/default-plugins/dockmon/src/utils/mapTo.ts b/apps/api/src/plugins/default-plugins/dockmon/src/utils/mapTo.ts new file mode 100644 index 00000000..b4f8fec7 --- /dev/null +++ b/apps/api/src/plugins/default-plugins/dockmon/src/utils/mapTo.ts @@ -0,0 +1,14 @@ +import type { EVENTS } from "@dockstat/typings" +import type { DockMonTable } from "../types" + +export function mapFromHostMetricHookToDb( + hostMetricEvent: Parameters[0] +): Omit, "stored_on"> { + return { + container_id: null, + docker_client_id: hostMetricEvent.docker_client_id, + host_id: hostMetricEvent.hostId, + type: "HOST", + data: { host_metrics: hostMetricEvent.metrics }, + } +} diff --git a/apps/api/src/plugins/index.ts b/apps/api/src/plugins/index.ts new file mode 100644 index 00000000..90c6d768 --- /dev/null +++ b/apps/api/src/plugins/index.ts @@ -0,0 +1,13 @@ +import PluginHandlerFactory from "@dockstat/plugin-handler" +import { DockStatDB } from "../database" +import BaseLogger from "../logger" +import { saveDefaultPlugins } from "./utils/saveDefaultPlugins" + +const PluginHandler = new PluginHandlerFactory( + DockStatDB._sqliteWrapper, + BaseLogger.getParentsForLoggerChaining() +) + +await saveDefaultPlugins(PluginHandler) + +export default PluginHandler diff --git a/apps/api/src/plugins/utils/logger.ts b/apps/api/src/plugins/utils/logger.ts new file mode 100644 index 00000000..5e947418 --- /dev/null +++ b/apps/api/src/plugins/utils/logger.ts @@ -0,0 +1,3 @@ +import BaseLogger from "../../logger" + +export const logger = BaseLogger.spawn("Utils", ["Plugins"]) diff --git a/apps/api/src/plugins/utils/saveDefaultPlugins.ts b/apps/api/src/plugins/utils/saveDefaultPlugins.ts new file mode 100644 index 00000000..42220152 --- /dev/null +++ b/apps/api/src/plugins/utils/saveDefaultPlugins.ts @@ -0,0 +1,84 @@ +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import type PluginHandlerFactory from "@dockstat/plugin-handler" +import type { DBPluginShemaT, PluginMetaType } from "@dockstat/typings/types" +import { Glob } from "bun" +import { logger } from "./logger" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +let id = 1 + +const defaultPluginDir = + Bun.env.DOCKSTATAPI_DEFAULT_PLUGIN_DIR ?? join(__dirname, "../default-plugins") + +const pattern = join(defaultPluginDir, "**/index.{ts,js}") + +export async function saveDefaultPlugins(pluginHandlerFactory: PluginHandlerFactory) { + logger.info("Saving default plugins") + const plugins = new Glob(pattern) + const pluginPaths = Array.from(plugins.scanSync({ absolute: true })) + + logger.debug(`Found ${pluginPaths.length} default plugins in ${defaultPluginDir}`) + + for (const p of pluginPaths) { + logger.debug(`Processing plugin ${p}`) + + const { meta } = (await import(/* @vite-ignore */ p)) as { + meta: PluginMetaType + } + + const build = await Bun.build({ + entrypoints: [p], + minify: false, + splitting: false, + banner: ` +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); +Object.defineProperty(import.meta, 'require', { + value: require, + enumerable: true, +});`, + outdir: "./.bundle", + target: "bun", + format: "esm", + }) + + const js = await build.outputs[0].text() + + saveDefaultPlugin(pluginHandlerFactory, meta, js) + } + + await pluginHandlerFactory.loadAllPlugins() +} + +function saveDefaultPlugin(pluginHandler: PluginHandlerFactory, meta: PluginMetaType, js: string) { + const pPlug: DBPluginShemaT = { + id: id, + author: meta.author, + description: meta.description, + manifest: meta.manifest, + name: meta.name, + plugin: js, + repoType: "default", + repository: "Default", + version: meta.version, + tags: [...(meta.tags ?? []), "default"], + } + + logger.debug(`Saving default plugin ${pPlug.name} (${pPlug.repoType})`) + + const saveRes = pluginHandler.savePlugin(pPlug, true) + + //if (saveRes.success) { + // pluginHandler.loadPlugin(id) + //} + + if (saveRes.success) { + logger.info(`Default plugin ${meta.name} saved successfully`) + } else { + logger.error(`Failed to save default plugin ${meta.name}: ${saveRes.error}`) + } + + id++ +} diff --git a/apps/api/src/routes/db.ts b/apps/api/src/routes/db.ts new file mode 100644 index 00000000..ca42054e --- /dev/null +++ b/apps/api/src/routes/db.ts @@ -0,0 +1,45 @@ +import Elysia from "elysia" +import { DockStatDB } from "../database" +import { updateConfig } from "../database/utils" +import { DatabaseModel } from "../models/database" + +//const logger = BaseLogger.spawn("Test") + +const DBRoutes = new Elysia({ + name: "DatabaseElysiaInstance", + prefix: "/db", + detail: { + tags: ["DB"], + }, +}) + .post( + "config", + ({ body, status }) => { + try { + const res = updateConfig(body) + return status(200, res) + } catch (error) { + return status(400, { + message: "Error while updating Database", + error: error, + }) + } + }, + { + body: DatabaseModel.updateBody, + response: { + 200: DatabaseModel.updateRes, + 400: DatabaseModel.updateError, + }, + } + ) + .get("config", ({ status }) => { + try { + const res = DockStatDB.configTable.select(["*"]).all()[0] + return status(200, res) + } catch (_error) { + return status(400, "Error while opening Database") + } + }) + +export default DBRoutes diff --git a/apps/api/src/routes/docker/client.ts b/apps/api/src/routes/docker/client.ts new file mode 100644 index 00000000..85193124 --- /dev/null +++ b/apps/api/src/routes/docker/client.ts @@ -0,0 +1,51 @@ +import Elysia, { t } from "elysia" +import DCM from "../../docker" +import { DockerModel } from "../../models/docker" + +export const DockerClientElysia = new Elysia({ + prefix: "/client", + detail: { + tags: ["Docker Client Management"], + }, +}) + .post( + "/register", + async ({ status, body }) => { + const res = await DCM.registerClient(body.clientName, body.options || undefined) + if (!res.success) { + return status(400, { + error: res.error, + message: res.message, + success: res.success, + }) + } + return status(200, { + clientId: Number(res.clientId), + message: res.message, + success: res.success, + }) + }, + { + body: DockerModel.registerClientBody, + response: { + 200: DockerModel.registerClientSuccess, + 400: DockerModel.registerClientError, + }, + } + ) + .delete("/delete", ({ body }) => DCM.removeClient(body.clientId), { + body: t.Object({ clientId: t.Number() }), + }) + .get("/all/:stored", ({ params }) => DCM.getAllClients(params.stored), { + params: t.Object({ stored: t.Boolean({ default: false }) }), + }) + .post("/monitoring/:clientId/start", ({ params }) => DCM.startMonitoring(params.clientId), { + params: t.Object({ + clientId: t.Number(), + }), + }) + .post("/monitoring/:clientId/stop", ({ params }) => DCM.stopMonitoring(params.clientId), { + params: t.Object({ + clientId: t.Number(), + }), + }) diff --git a/apps/api/src/routes/docker/container.ts b/apps/api/src/routes/docker/container.ts new file mode 100644 index 00000000..3b27c19c --- /dev/null +++ b/apps/api/src/routes/docker/container.ts @@ -0,0 +1,13 @@ +import Elysia, { t } from "elysia" +import DCM from "../../docker" + +export const DockerContainerElysia = new Elysia({ + prefix: "/containers", + detail: { + tags: ["Docker Containers"], + }, +}).get("/all/:clientId", async ({ params: { clientId } }) => await DCM.getAllContainers(clientId), { + params: t.Object({ + clientId: t.Number(), + }), +}) diff --git a/apps/api/src/routes/docker/hosts.ts b/apps/api/src/routes/docker/hosts.ts new file mode 100644 index 00000000..6b25ddbf --- /dev/null +++ b/apps/api/src/routes/docker/hosts.ts @@ -0,0 +1,37 @@ +import Elysia, { t } from "elysia" +import DCM from "../../docker" +import { DockerModel } from "../../models/docker" + +export const DockerHostElysia = new Elysia({ + prefix: "/hosts", + detail: { + tags: ["Docker Host Management"], + }, +}) + .get("/", async ({ status }) => status(200, await DCM.getAllHosts()), { + response: { 200: DockerModel.allHosts }, + }) + .get("/:clientId", async ({ params: { clientId } }) => await DCM.getAllHostMetrics(clientId), { + params: t.Object({ + clientId: t.Number(), + }), + }) + .post( + "/add", + async ({ body }) => + await DCM.addHost(body.clientId, body.hostname, body.name, body.secure, body.port), + { + body: DockerModel.addHostBody, + } + ) + .post( + "/update", + async ({ body: { clientId, host } }) => + await DCM.updateHost(clientId, { + ...host, + docker_client_id: clientId, + }), + { + body: DockerModel.updateBody, + } + ) diff --git a/apps/api/src/routes/docker/index.ts b/apps/api/src/routes/docker/index.ts new file mode 100644 index 00000000..8c95551a --- /dev/null +++ b/apps/api/src/routes/docker/index.ts @@ -0,0 +1,40 @@ +import Elysia from "elysia" +import DCM from "../../docker" +import { DockerModel } from "../../models/docker" +import { DockerClientElysia } from "./client" +import { DockerContainerElysia } from "./container" +import { DockerHostElysia } from "./hosts" +import { DockerManager } from "./manager" + +const DockerRoutes = new Elysia({ + prefix: "/docker", + detail: { + tags: ["Docker"], + }, +}) + .get( + "/status", + async ({ status }) => { + try { + const res = await DCM.getStatus() + return status(200, res) + } catch (error) { + return status(400, { + error: error, + message: "Could not get Docker Status", + }) + } + }, + { + response: { + 200: DockerModel.status, + 400: DockerModel.error, + }, + } + ) + .use(DockerManager) + .use(DockerClientElysia) + .use(DockerHostElysia) + .use(DockerContainerElysia) + +export default DockerRoutes diff --git a/apps/api/src/routes/docker/manager.ts b/apps/api/src/routes/docker/manager.ts new file mode 100644 index 00000000..8d3b9479 --- /dev/null +++ b/apps/api/src/routes/docker/manager.ts @@ -0,0 +1,40 @@ +import Elysia from "elysia" +import DCM from "../../docker" +import { DockerModel } from "../../models/docker" + +export const DockerManager = new Elysia({ + prefix: "/manager", + detail: { + tags: ["Docker Manager"], + }, +}) + .get( + "/pool-stats", + async ({ status }) => { + try { + const res = await DCM.getPoolMetrics() + return status(200, res) + } catch (error) { + return status(400, { + error: error, + message: "Could not get Pool Stats", + }) + } + }, + { + response: { 200: DockerModel.poolStatus, 400: DockerModel.error }, + } + ) + .post( + "/init-all-clients", + ({ status }) => { + const allClients = DCM.getAllClients() + for (const c of allClients) { + DCM.init(c.id) + } + return status(200, DCM.getAllClients(true)) + }, + { + response: { 200: DockerModel.initAllClientsRes }, + } + ) diff --git a/apps/api/src/routes/metrics/prometheus.ts b/apps/api/src/routes/metrics/prometheus.ts new file mode 100644 index 00000000..af807a5f --- /dev/null +++ b/apps/api/src/routes/metrics/prometheus.ts @@ -0,0 +1,27 @@ +import Elysia from "elysia" +import { DockStatDB } from "../../database" +import { formatPrometheusMetrics } from "../../middleware/metrics" +import { MetricsModel } from "../../models/metrics" + +const PrometheusMetricsRoute = new Elysia({ prefix: "/metrics" }).get( + "/", + ({ status }) => { + try { + const res = formatPrometheusMetrics(DockStatDB._sqliteWrapper.getDb()) + return status(200, res) + } catch (error) { + return status(400, { + error: error, + message: "Could not get Prometheus metrics!", + }) + } + }, + { + response: { + 200: MetricsModel.prometheusRes, + 400: MetricsModel.prometheusError, + }, + } +) + +export default PrometheusMetricsRoute diff --git a/apps/api/src/routes/plugins/index.ts b/apps/api/src/routes/plugins/index.ts new file mode 100644 index 00000000..759bb529 --- /dev/null +++ b/apps/api/src/routes/plugins/index.ts @@ -0,0 +1,43 @@ +import Elysia from "elysia" +import { PluginModel } from "../../models/plugins" +import PluginHandler from "../../plugins" + +const PluginRoutes = new Elysia({ + prefix: "/plugins", + detail: { + tags: ["Plugins"], + }, +}) + .get("/all", () => PluginHandler.getAll()) + .get("/hooks", () => { + return Array.from(PluginHandler.getHookHandlers()) + }) + .get("/status", () => PluginHandler.getStatus()) + .post("/install", ({ body }) => PluginHandler.savePlugin(body), { + body: PluginModel.installPluginBody, + }) + .post( + "/activate", + async ({ body, status }) => status(200, await PluginHandler.loadPlugins(body)), + { + body: PluginModel.activatePluginBody, + responses: { 200: PluginModel.activatePluginRes }, + } + ) + .post("/delete", ({ body }) => PluginHandler.deletePlugin(body.pluginId), { + body: PluginModel.deletePluginBody, + }) + .get("/routes", () => PluginHandler.getAllPluginRoutes()) + .all( + "/:id/routes/*", + async ({ request, params }) => + PluginHandler.handleRoute(Number(params.id), params["*"], request), + { + detail: { + tags: ["Plugin Routes"], + description: + "This route proxies all Plugin-API requests to the specified Plugin's Elysia Instance", + }, + } + ) +export default PluginRoutes diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 00000000..d11415c4 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,105 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "ES2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "ES2022" /* Specify what module code is generated. */, + // "rootDir": "./", /* Specify the root folder within your source files. */ + "moduleResolution": "bundler" /* Specify how TypeScript looks up a file from a given module specifier. */, + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + "types": [ + "bun-types" + ] /* Specify type package names to be included without being referenced in a source file. */, + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + + /* Type Checking */ + "strict": true /* Enable all strict type-checking options. */, + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} diff --git a/apps/docknode/.dockerignore b/apps/docknode/.dockerignore new file mode 100644 index 00000000..29889732 --- /dev/null +++ b/apps/docknode/.dockerignore @@ -0,0 +1,7 @@ +.gitignore +.dockstacks_instance_uuid.txt +dockerfile +.env +.prod.env +.dev.envw +node_modules \ No newline at end of file diff --git a/apps/docknode/.dockstacks_instance_uuid.txt b/apps/docknode/.dockstacks_instance_uuid.txt new file mode 100644 index 00000000..8d613fb5 --- /dev/null +++ b/apps/docknode/.dockstacks_instance_uuid.txt @@ -0,0 +1 @@ +34ca4e88-4ffc-40b5-b72e-592854728b5a \ No newline at end of file diff --git a/apps/docknode/.gitignore b/apps/docknode/.gitignore new file mode 100644 index 00000000..87e56100 --- /dev/null +++ b/apps/docknode/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +**/*.trace +**/*.zip +**/*.tar.gz +**/*.tgz +**/*.log +package-lock.json +**/*.bun \ No newline at end of file diff --git a/apps/docknode/README.md b/apps/docknode/README.md new file mode 100644 index 00000000..688c87e6 --- /dev/null +++ b/apps/docknode/README.md @@ -0,0 +1,15 @@ +# Elysia with Bun runtime + +## Getting Started +To get started with this template, simply paste this command into your terminal: +```bash +bun create elysia ./elysia-example +``` + +## Development +To start the development server run: +```bash +bun run dev +``` + +Open http://localhost:3000/ with your browser to see the result. \ No newline at end of file diff --git a/apps/docknode/bunfig.toml b/apps/docknode/bunfig.toml new file mode 100644 index 00000000..641e878f --- /dev/null +++ b/apps/docknode/bunfig.toml @@ -0,0 +1,2 @@ +[install] +linker = "isolated" diff --git a/apps/docknode/dockerfile b/apps/docknode/dockerfile new file mode 100644 index 00000000..19a12e6f --- /dev/null +++ b/apps/docknode/dockerfile @@ -0,0 +1,8 @@ +FROM oven/bun:1.3 as BASE + +FROM BASE AS BUNINSTALL +WORKDIR "/tmp/docknode-installer" +COPY . /tmp + +RUN bun install +RUN bun run build \ No newline at end of file diff --git a/apps/docknode/environment.d.ts b/apps/docknode/environment.d.ts new file mode 100644 index 00000000..0241a94b --- /dev/null +++ b/apps/docknode/environment.d.ts @@ -0,0 +1,9 @@ +declare module "bun" { + interface Env { + DOCKNODE_DOCKSTACK_AUTH_PSK?: string + DOCKNODE_DOCKSTACK_DEV_AUTH?: string + DOCKNODE_DOCKSTACK_AUTH_PRIORITY: string + PORT?: string + NODE_ENV: "development" | "production" | "test" + } +} diff --git a/apps/docknode/package.json b/apps/docknode/package.json new file mode 100644 index 00000000..700a9a62 --- /dev/null +++ b/apps/docknode/package.json @@ -0,0 +1,21 @@ +{ + "name": "docknode", + "version": "1.0.50", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "install": "bunx turbo install", + "dev": "bun run --watch src/index.ts", + "build": "bun run src/build.ts" + }, + "dependencies": { + "@dockstat/logger": "workspace:*", + "@elysiajs/openapi": "^1.4.0", + "@tqman/nice-logger": "^1.1.1", + "elysia": "1.4.12", + "elysia-basic-auth": "^1.0.7" + }, + "devDependencies": { + "bun-types": "latest" + }, + "module": "src/index.js" +} diff --git a/apps/docknode/src/builder.ts b/apps/docknode/src/builder.ts new file mode 100644 index 00000000..a6d42b31 --- /dev/null +++ b/apps/docknode/src/builder.ts @@ -0,0 +1,14 @@ +import { build } from "bun" + +await build({ + sourcemap: "linked", + compile: false, + entrypoints: ["./index.ts"], + minify: { + identifiers: false, + syntax: true, + whitespace: true, + keepNames: false, + }, + outdir: "./dist", +}) diff --git a/apps/docknode/src/handlers/auth/auth.model.ts b/apps/docknode/src/handlers/auth/auth.model.ts new file mode 100644 index 00000000..70e4ac9b --- /dev/null +++ b/apps/docknode/src/handlers/auth/auth.model.ts @@ -0,0 +1,10 @@ +import Elysia, { t } from "elysia" + +const AuthObject = t.Object({ + handler: t.String(), + key: t.String(), +}) + +export const authModel = new Elysia().model({ auth: AuthObject }) + +export type AuthObjectType = typeof AuthObject.static diff --git a/test.ts b/apps/docknode/src/handlers/auth/elysia-adapter.ts similarity index 100% rename from test.ts rename to apps/docknode/src/handlers/auth/elysia-adapter.ts diff --git a/apps/docknode/src/handlers/dockstack/index.ts b/apps/docknode/src/handlers/dockstack/index.ts new file mode 100644 index 00000000..02c73182 --- /dev/null +++ b/apps/docknode/src/handlers/dockstack/index.ts @@ -0,0 +1,65 @@ +import { rm } from "node:fs/promises" +import Logger from "@dockstat/logger" +import openapi from "@elysiajs/openapi" +import Elysia, { t } from "elysia" +import { getStackDir, writeDockerCompose } from "./src/deployStack" + +export const dockStackLogger = new Logger("DockStack", ["DockNode"]) + +export const DockStackHandler = new Elysia({ prefix: "/dockstack" }) + .use( + openapi({ + path: "/docs", + provider: "scalar", + }) + ) + .post( + "/deploy", + async ({ body }) => { + try { + await writeDockerCompose(body.id, body.name, body.vars, body.data) + return new Response( + JSON.stringify({ + status: "200", + message: `Succesfully deployed ${body.name}`, + }) + ) + } catch (error) { + return new Response( + JSON.stringify({ + status: "400", + message: "An error occured while writing the Docker Compose file!", + error: `${error}`, + }) + ) + } + }, + { + body: t.Object({ + id: t.Number(), + name: t.String(), + data: t.String(), + vars: t.Record(t.String(), t.String()), + }), + } + ) + .delete( + "/delete", + async ({ body }) => { + try { + await rm(getStackDir(body.id, body.name), { recursive: true }) + } catch (error) { + if ((error as { code: string }).code === "ENOENT") { + console.log("Directory doesn't exist") + } else { + throw error + } + } + }, + { + body: t.Object({ + id: t.Number(), + name: t.String(), + }), + } + ) diff --git a/apps/docknode/src/handlers/dockstack/src/deployStack.ts b/apps/docknode/src/handlers/dockstack/src/deployStack.ts new file mode 100644 index 00000000..3f800717 --- /dev/null +++ b/apps/docknode/src/handlers/dockstack/src/deployStack.ts @@ -0,0 +1,39 @@ +import { dockStackLogger } from ".." + +export const STACK_DIR = "./stacks" as const + +export async function writeDockerCompose( + stackId: number, + stackName: string, + vars: Record, + compose: string +) { + try { + dockStackLogger.info(`Writing Docker Compose of ${stackName}`) + const stackDir = getStackDir(stackId, stackName) + + const YamlDump = Bun.YAML.parse(parseVars(vars, compose)) as string + + return await Bun.write(`${stackDir}/docker-compose.yaml`, YamlDump) + } catch (error) { + throw new Error(`Could not write Docker Compose file ${error}`) + } +} + +function parseVars(deployConfig: Record, data: string) { + data.replace(/{{(.*?)}}/g, (_, key) => deployConfig[key.trim()] ?? "") + + const matches = data.search(/{{(.*?)}}/g) + + if (matches <= 0) { + throw new Error("Not all variables were provided values in the deploy config") + } + + return data +} + +export function getStackDir(stackId: number, stackName: string) { + const pId = Number(stackId) + const pSN = String(stackName).replaceAll("../", "").replaceAll("..\\", "") + return `./stacks/stackId${pId}-stackName${pSN}` +} diff --git a/apps/docknode/src/handlers/dockstack/src/types.ts b/apps/docknode/src/handlers/dockstack/src/types.ts new file mode 100644 index 00000000..1d63e9f6 --- /dev/null +++ b/apps/docknode/src/handlers/dockstack/src/types.ts @@ -0,0 +1,50 @@ +export interface BaseResponse { + success: boolean + message: string + timestamp: string +} + +export interface SuccessResponse extends BaseResponse { + success: true + data?: T +} + +export interface ErrorResponse extends BaseResponse { + success: false + error: { + code: string + details?: string + } +} + +export const ErrorCodes = { + // Authentication errors + UNAUTHORIZED: "UNAUTHORIZED", + INVALID_TOKEN: "INVALID_TOKEN", + MISSING_CREDENTIALS: "MISSING_CREDENTIALS", + INVALID_CREDENTIALS: "INVALID_CREDENTIALS", + + // Validation errors + VALIDATION_ERROR: "VALIDATION_ERROR", + + // Server errors + INTERNAL_SERVER_ERROR: "INTERNAL_SERVER_ERROR", + SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE", + + // Client errors + BAD_REQUEST: "BAD_REQUEST", + NOT_FOUND: "NOT_FOUND", + FORBIDDEN: "FORBIDDEN", +} as const + +// HTTP Status Codes +export const HttpStatus = { + OK: 200, + CREATED: 201, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + INTERNAL_SERVER_ERROR: 500, + SERVICE_UNAVAILABLE: 503, +} as const diff --git a/apps/docknode/src/handlers/dockstack/src/utils/constants.ts b/apps/docknode/src/handlers/dockstack/src/utils/constants.ts new file mode 100644 index 00000000..17619b2e --- /dev/null +++ b/apps/docknode/src/handlers/dockstack/src/utils/constants.ts @@ -0,0 +1,3 @@ +// src/constants.ts +export const INSTANCE_UUID_PATH = "./.dockstacks_instance_uuid.txt" +export const INSTANCE_UUID_BUN_FILE = Bun.file(INSTANCE_UUID_PATH) diff --git a/apps/docknode/src/handlers/dockstack/src/utils/createResponses.ts b/apps/docknode/src/handlers/dockstack/src/utils/createResponses.ts new file mode 100644 index 00000000..691dbf1b --- /dev/null +++ b/apps/docknode/src/handlers/dockstack/src/utils/createResponses.ts @@ -0,0 +1,95 @@ +import type { ErrorResponse, SuccessResponse } from "../types" +import type { InstanceUUIDResult } from "./instanceUUIDtypes" + +const INSTANCE_UUID_PATH = "./.dockstacks_instance_uuid.txt" +const INSTANCE_UUID_BUN_FILE = Bun.file(INSTANCE_UUID_PATH) + +/** + * Creates a standardized success response object + * @template T - The type of the data payload + * @param {string} message - A descriptive message about the successful operation + * @param {T} [data] - Optional data payload to be included in the response + * @returns {SuccessResponse} A success response object with the message, data, and timestamp + */ +export const createSuccessResponse = (message: string, data?: T): SuccessResponse => ({ + success: true, + message, + data, + timestamp: new Date().toISOString(), +}) + +/** + * Creates a standardized error response object + * @param {string} message - A descriptive error message + * @param {string} code - An error code identifying the type of error + * @param {string} [details] - Optional additional details about the error + * @returns {ErrorResponse} An error response object with the message, code, details, and timestamp + */ +export const createErrorResponse = ( + message: string, + code: string, + details?: string +): ErrorResponse => ({ + success: false, + message, + timestamp: new Date().toISOString(), + error: { + code, + details, + }, +}) + +/** + * Creates a new UUID for the instance and saves it to a file + * @returns {Promise<{ + * message: string, + * data?: { uuid: string }, + * code: { success: boolean, file?: boolean } + * }>} Object containing the operation result + */ +export async function createInstanceUUID() { + if (await INSTANCE_UUID_BUN_FILE.exists()) { + return { + message: "UUID File already exists. Please call 'getInstanceUUID()'", + code: { success: false, file: true }, + } + } + const local = { uuid: crypto.randomUUID() } as const + await INSTANCE_UUID_BUN_FILE.write(local.uuid) + return { + message: "UUID created succesfully.", + data: { + uuid: local.uuid, + }, + code: { + success: true, + }, + } +} + +/** + * Retrieves the instance UUID from the file system + * @returns {Promise<{ + * message: string, + * uuid?: string, + * code: { success: boolean, file?: boolean }, + * data?: null + * }>} Object containing the UUID if successful, or error information if not + */ +export async function getInstanceUUID(): Promise { + if (!(await INSTANCE_UUID_BUN_FILE.exists())) { + return { + message: "UUID File does not exist. Please class 'createInstanceUUID()'", + code: { file: false, success: false }, + data: null, + } + } + const data = await INSTANCE_UUID_BUN_FILE.text() + return { + message: "UUID file read succesfully.", + data: { + uuid: data, + }, + code: { success: true }, + } +} diff --git a/apps/docknode/src/handlers/dockstack/src/utils/getDefaultAuthKey.ts b/apps/docknode/src/handlers/dockstack/src/utils/getDefaultAuthKey.ts new file mode 100644 index 00000000..d14a8b7e --- /dev/null +++ b/apps/docknode/src/handlers/dockstack/src/utils/getDefaultAuthKey.ts @@ -0,0 +1,68 @@ +import { getInstanceUUID } from "./createResponses" +import { type InstanceUUIDSuccess, isInstanceUUID } from "./instanceUUIDtypes" +import { dockNodeAuthHandlerLogger } from "./loggers" + +/** + * Get the default auth key (instance UUID). + * - If file exists, returns it. + * - If file missing, creates it and returns the new uuid. + * - Throws on unrecoverable errors. + */ + +type DockStackAuthVariations = "DevEnv" | "ProdEnv" | "InstanceUUID" + +function isDockStackPriorityList(r: unknown): r is DockStackAuthVariations { + return Boolean( + Array.isArray(String(r).split(",")) && + (r as DockStackAuthVariations[]).find((k) => k === "DevEnv") && + (r as DockStackAuthVariations[]).find((k) => k === "InstanceUUID") && + (r as DockStackAuthVariations[]).find((k) => k === "ProdEnv") + ) +} + +export async function getDefaultAuthKey(): Promise { + const DockStackProdAuthKey = Bun.env.DOCKNODE_DOCKSTACK_AUTH_PSK + const DockStackDevAuthKey = Bun.env.DOCKNODE_DOCKSTACK_DEV_AUTH + const DockStackAuthPriority = Bun.env.DOCKNODE_DOCKSTACK_AUTH_PRIORITY + const InstanceUUID = await getInstanceUUID() + + // Priority List + let AuthPriority: DockStackAuthVariations[] = ["DevEnv", "ProdEnv", "InstanceUUID"] + + dockNodeAuthHandlerLogger.debug(`Default Auth Priority: ${AuthPriority}`) + + if (DockStackAuthPriority && isDockStackPriorityList(DockStackAuthPriority)) { + dockNodeAuthHandlerLogger.debug(`Found environment overwrite: ${DockStackAuthPriority}`) + AuthPriority = DockStackAuthPriority.split(",") as DockStackAuthVariations[] + } + + // Parsing Key Types + dockNodeAuthHandlerLogger.info("Assembling DockStack Auth Variations") + const KeyRecord: Record = {} as typeof KeyRecord + + if (DockStackProdAuthKey) { + dockNodeAuthHandlerLogger.info("Has DockStackProdAuthKey") + KeyRecord.DevEnv = DockStackProdAuthKey + } + + if (DockStackDevAuthKey) { + dockNodeAuthHandlerLogger.info("Has DockStackDevAuthKey") + KeyRecord.ProdEnv = DockStackDevAuthKey + } + + if (isInstanceUUID(InstanceUUID)) { + dockNodeAuthHandlerLogger.info("Has a valid Instance UUID") + KeyRecord.InstanceUUID = (InstanceUUID as InstanceUUIDSuccess).data.uuid + } + dockNodeAuthHandlerLogger.debug(`InstanceUUIDResponse: ${JSON.stringify(InstanceUUID)}`) + + for (const key of AuthPriority) { + if (KeyRecord[key]) { + return KeyRecord[key] + } + } + + dockNodeAuthHandlerLogger.error(`No Default Auth Key found in: ${JSON.stringify(KeyRecord)}`) + + throw new Error("No Default Auth Key found") +} diff --git a/apps/docknode/src/handlers/dockstack/src/utils/includes.ts b/apps/docknode/src/handlers/dockstack/src/utils/includes.ts new file mode 100644 index 00000000..94e4695e --- /dev/null +++ b/apps/docknode/src/handlers/dockstack/src/utils/includes.ts @@ -0,0 +1,179 @@ +import { dockNodeAuthHandlerLogger } from "./loggers" + +/** Cache compiled regexes for performance */ +const compiledRegexCache = new Map() + +function escapeRegExp(s: string) { + return s.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&") +} + +function globToRegExpStr(glob: string) { + let tGlob = glob + + // Normalize: keep leading slash if provided. If pattern starts with '*' + // (e.g. "*/docs" or "**/docs") we will treat that as "match anywhere" + // and convert to leading '/**/...'. + if (!tGlob.startsWith("/")) { + if (tGlob.startsWith("*") || tGlob.startsWith("?")) { + // convert leading '*' or '**' into '/**' (match across directories) + // e.g. "*/docs" -> "/**/docs" + tGlob = tGlob.replace(/^\*+/, "**") // normalize multiple * to ** + if (!tGlob.startsWith("**")) { + // if it started with '?', leave it; we'll prefix '/**' anyway + tGlob = `**${tGlob}` + } + tGlob = `/${tGlob}` // ensure it begins with slash for regex generation + } else { + // typical case: "docs" -> "/docs" + tGlob = `/${tGlob}` + } + } + + // Escape then replace special tokens + let re = "" + for (let i = 0; i < tGlob.length; i++) { + const ch = tGlob[i] + + // handle ** + if (ch === "*" && tGlob[i + 1] === "*") { + // consume the second * + i++ + // replace with '.*' (can match slashes) + re += ".*" + continue + } + if (ch === "*") { + // single * should not match slash + re += "[^/]*" + continue + } + if (ch === "?") { + re += "." + continue + } + re += escapeRegExp(ch) + } + + // Anchor to full path to avoid accidental partial matches + return `^${re}$` +} + +/** + * Route-safe glob matcher with detailed logging and caching. + * + * - route: full URL or path (e.g. "http://localhost:4000/api/x" or "/api/x") + * - patterns: string or array of strings (supports negation with leading '!') + * + * Logging: for each pattern we log the normalized pattern, produced regex, + * and the match result. Final decision is also logged. + */ +export function globMatch(route: string, patterns: string | string[]) { + try { + dockNodeAuthHandlerLogger.debug( + `globMatch called — raw route=${route} patterns=${JSON.stringify(patterns)}` + ) + + if (!route) { + dockNodeAuthHandlerLogger.debug("globMatch: empty route -> false") + return false + } + + // Normalize route to pathname (strip query/hash). Keep leading slash. + let pathname = route + try { + // If route is a full URL, extract pathname; otherwise treat as path + const u = new URL(route, "http://localhost") + pathname = u.pathname + } catch (_) { + // route was probably already a pathname like "/api/x" — keep as-is + } + + dockNodeAuthHandlerLogger.debug(`globMatch: normalized pathname=${pathname}`) + + const list = Array.isArray(patterns) ? patterns : [patterns] + let matched = false + + for (const raw of list) { + if (!raw) { + dockNodeAuthHandlerLogger.debug("globMatch: skipping empty pattern") + continue + } + + const negated = raw.startsWith("!") + const body = negated ? raw.slice(1) : raw + const pat = body.trim() + + // Normalize pattern for consistent logging and caching + let normalizedPattern = pat + if (!normalizedPattern.startsWith("/")) { + // If the pattern starts with '*' or '?', treat as "match anywhere" -> prefix '/**' + if (/^[*?]/.test(normalizedPattern)) { + normalizedPattern = normalizedPattern.replace(/^\*+/, "**") + normalizedPattern = `/${normalizedPattern}` + } else { + normalizedPattern = `/${normalizedPattern}` + } + } + + // Build or retrieve compiled regex + const cacheKey = `${negated ? "!" : ""}${normalizedPattern}` + let rx = compiledRegexCache.get(cacheKey) + let regexStr = "" + + if (!rx) { + try { + regexStr = globToRegExpStr(normalizedPattern) + rx = new RegExp(regexStr) + compiledRegexCache.set(cacheKey, rx) + } catch (e) { + dockNodeAuthHandlerLogger.error( + `globMatch: failed to compile pattern=${raw} normalized=${normalizedPattern} -> ${String( + e + )}` + ) + // Skip invalid pattern + continue + } + } else { + // Build regexStr for logging from the stored RegExp, if needed + regexStr = rx.source + } + + const doesMatch = rx.test(pathname) + dockNodeAuthHandlerLogger.debug( + `globMatch: pattern='${raw}' normalized='${normalizedPattern}' negated=${negated} regex='${regexStr}' test=${doesMatch}` + ) + + // Apply pattern precedence: later patterns override earlier ones + if (negated) { + if (doesMatch) { + dockNodeAuthHandlerLogger.debug( + `globMatch: negation matched -> setting matched=false (pattern ${raw})` + ) + matched = false + } else { + dockNodeAuthHandlerLogger.debug( + `globMatch: negation did not match -> keep matched=${matched}` + ) + } + } else { + if (doesMatch) { + dockNodeAuthHandlerLogger.debug( + `globMatch: positive pattern matched -> setting matched=true (pattern ${raw})` + ) + matched = true + } else { + dockNodeAuthHandlerLogger.debug( + `globMatch: positive pattern did not match -> keep matched=${matched}` + ) + } + } + } + + dockNodeAuthHandlerLogger.debug(`globMatch result for ${pathname}: ${matched}`) + return matched + } catch (err) { + dockNodeAuthHandlerLogger.error(`globMatch unexpected error: ${String(err)}`) + return false + } +} diff --git a/apps/docknode/src/handlers/dockstack/src/utils/instanceUUID.ts b/apps/docknode/src/handlers/dockstack/src/utils/instanceUUID.ts new file mode 100644 index 00000000..00cecf21 --- /dev/null +++ b/apps/docknode/src/handlers/dockstack/src/utils/instanceUUID.ts @@ -0,0 +1,39 @@ +import { INSTANCE_UUID_BUN_FILE } from "./constants" +import type { InstanceUUIDResult } from "./instanceUUIDtypes" + +export async function createInstanceUUID(): Promise { + if (await INSTANCE_UUID_BUN_FILE.exists()) { + return { + message: "UUID file already exists. Please call 'getInstanceUUID()'.", + data: null, + code: { success: false, file: true }, + } + } + + const uuid = crypto.randomUUID() + await INSTANCE_UUID_BUN_FILE.write(uuid) + + return { + message: "UUID created successfully.", + data: { uuid }, + code: { success: true }, + } +} + +export async function getInstanceUUID(): Promise { + if (!(await INSTANCE_UUID_BUN_FILE.exists())) { + return { + message: "UUID file does not exist. Please call 'createInstanceUUID()'.", + data: null, + code: { success: false, file: false }, + } + } + + const text = await INSTANCE_UUID_BUN_FILE.text() + + return { + message: "UUID file read successfully.", + data: { uuid: text }, + code: { success: true }, + } +} diff --git a/apps/docknode/src/handlers/dockstack/src/utils/instanceUUIDtypes.ts b/apps/docknode/src/handlers/dockstack/src/utils/instanceUUIDtypes.ts new file mode 100644 index 00000000..3127ca94 --- /dev/null +++ b/apps/docknode/src/handlers/dockstack/src/utils/instanceUUIDtypes.ts @@ -0,0 +1,29 @@ +export type InstanceUUIDSuccess = { + message: string + data: { uuid: string } + code: { success: true; file?: boolean } +} + +export type InstanceUUIDFailure = { + message: string + data: null + code: { success: false; file: boolean } +} + +export type InstanceUUIDResult = InstanceUUIDSuccess | InstanceUUIDFailure + +export type EnvKey = { + success: boolean + data: string | null +} + +/** Type guard */ +export function isInstanceUUID(r: unknown): r is InstanceUUIDSuccess { + const pR = r as InstanceUUIDResult + + if (pR.code.success) { + return true + } + + return false +} diff --git a/apps/docknode/src/handlers/dockstack/src/utils/loggers.ts b/apps/docknode/src/handlers/dockstack/src/utils/loggers.ts new file mode 100644 index 00000000..5dee23f3 --- /dev/null +++ b/apps/docknode/src/handlers/dockstack/src/utils/loggers.ts @@ -0,0 +1,3 @@ +import Logger from "@dockstat/logger" + +export const dockNodeAuthHandlerLogger = new Logger("Auth", ["DockStack", "DockNode"]) diff --git a/apps/docknode/src/handlers/dockstack/src/utils/responses.ts b/apps/docknode/src/handlers/dockstack/src/utils/responses.ts new file mode 100644 index 00000000..afca8d04 --- /dev/null +++ b/apps/docknode/src/handlers/dockstack/src/utils/responses.ts @@ -0,0 +1,18 @@ +// src/responses.ts +export async function createSuccessResponse(message: string, data?: T) { + return { + success: true, + message, + data, + timestamp: new Date().toISOString(), + } as const +} + +export async function createErrorResponse(message: string, code: string, details?: string) { + return { + success: false, + message, + timestamp: new Date().toISOString(), + error: { code, details }, + } as const +} diff --git a/apps/docknode/src/index.ts b/apps/docknode/src/index.ts new file mode 100644 index 00000000..909ae845 --- /dev/null +++ b/apps/docknode/src/index.ts @@ -0,0 +1,33 @@ +import Logger from "@dockstat/logger" + +const dockNodeLogger = new Logger("DockNode", []) +export const dockNodeLoggerParents: string[] = dockNodeLogger.getParents() + +import { openapi } from "@elysiajs/openapi" +import { logger } from "@tqman/nice-logger" +import Elysia from "elysia" +import { DockStackHandler } from "./handlers/dockstack" + +new Elysia({ prefix: "/api" }) + .use( + openapi({ + path: "/docs", + provider: "scalar", + }) + ) + .use( + logger({ + mode: "combined", // "live" or "combined" (default: "combined") + withTimestamp: true, // optional (default: false) + withBanner: true, + enabled: true, + }) + ) + .get("/status", { status: 200, message: "alive" }) + .use(DockStackHandler) + .get("/deimudda", () => "sex", { + requireAuth: true, + }) + .listen(4000) + +dockNodeLogger.info("Server listening on http://localhost:4000") diff --git a/apps/docknode/src/tests/basic-api-requests.test.ts b/apps/docknode/src/tests/basic-api-requests.test.ts new file mode 100644 index 00000000..5e4ce7db --- /dev/null +++ b/apps/docknode/src/tests/basic-api-requests.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test" + +await import("../index") + +test("Basic API Status Test", async () => { + const res = (await (await fetch("http://localhost:4000/api/status")).json()) as { status: number } + + expect(res.status).toBe(200) +}) + +test("Basic Unauthorized Call", async () => { + const _res = await ( + await fetch("http://localhost:4000/api/dockstack/deploy", { + method: "POST", + }) + ).json() +}) diff --git a/apps/docknode/tsconfig.json b/apps/docknode/tsconfig.json new file mode 100644 index 00000000..a376c1e5 --- /dev/null +++ b/apps/docknode/tsconfig.json @@ -0,0 +1,105 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "esnext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "ES2022" /* Specify what module code is generated. */, + // "rootDir": "./", /* Specify the root folder within your source files. */ + "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + "types": [ + "bun-types" + ] /* Specify type package names to be included without being referenced in a source file. */, + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + + /* Type Checking */ + "strict": true /* Enable all strict type-checking options. */, + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} diff --git a/apps/dockstat/.gitignore b/apps/dockstat/.gitignore index 9b7c041f..bac257c5 100644 --- a/apps/dockstat/.gitignore +++ b/apps/dockstat/.gitignore @@ -1,6 +1,9 @@ .DS_Store +.env /node_modules/ - # React Router /.react-router/ /build/ +./dockstat.sqlite-wal +./dockstat.sqlite-shm +./.bundle diff --git a/apps/dockstat/app/.server/index.ts b/apps/dockstat/app/.server/index.ts index 51e1bbaa..bf7c83d5 100644 --- a/apps/dockstat/app/.server/index.ts +++ b/apps/dockstat/app/.server/index.ts @@ -1,151 +1,8 @@ -import DBFactory from '@dockstat/db' -import type sqliteWrapper from '@dockstat/sqlite-wrapper'; -import type { QueryBuilder } from '@dockstat/sqlite-wrapper'; -import type { DATABASE } from '@dockstat/typings'; -import { createLogger } from '@dockstat/logger' -import { startUp } from './src/utils'; -import AdapterHandler from './src/adapters/handler'; -import ThemeHandler from './src/theme/themeHandler'; +import { DockStatAPI, type TreatyType } from "@dockstat/api" +import { treaty } from "@elysiajs/eden" -class ServerInstance { - private logger: { - error: (msg: string) => void; - warn: (msg: string) => void; - info: (msg: string) => void; - debug: (msg: string) => void; - } - private AdapterHandler!: AdapterHandler; - private DB!: DBFactory; - private DBWrapper!: sqliteWrapper; - private config_table!: QueryBuilder; - private themeHandler!: ThemeHandler; +const API = DockStatAPI - constructor(name = 'DockStatAPI'){ - this.logger = createLogger(`${name}`); - this.logger.debug("Initialized Server Logger"); - this.logger.info("Starting DockStat Server... Please stand by."); +export const ServerAPI = treaty("http://localhost:3000").api.v2 - startUp({ - "Setup Database": { - steps: [ - () => { - this.logger.debug("Step: Instantiating DBFactory"); - this.DB = new DBFactory(); - this.logger.info("DBFactory instance created"); - }, - () => { - this.logger.debug("Step: Getting DB Wrapper from DBFactory"); - this.DBWrapper = this.DB.getDB(); - this.logger.info(`DBWrapper assigned: ${!!this.DBWrapper}`); - }, - () => { - // Final verification step: ensure both are assigned - const okDB = !!this.DB; - const okWrapper = !!this.DBWrapper; - - this.logger.debug(`Verifying DB and DBWrapper: DB=${okDB}, Wrapper=${okWrapper}`); - - if (!okDB || !okWrapper) { - const msg = - `Database initialization failed: DB=${okDB}, okWrapper=${okWrapper}`; - this.logger.error(msg); - throw new Error(msg); - } - - this.logger.info('Database initialized successfully.'); - }, - () => { - this.logger.debug("Step: Getting config table from DB"); - this.config_table = this.DB.getConfigTable(); - this.logger.info("Config table assigned"); - } - ] - }, - "Setup Adapter Handler": { - steps: [ - () => { - this.logger.debug("Step: Instantiating AdapterHandler"); - this.AdapterHandler = new AdapterHandler(this.DB.getDB()); - this.logger.info("AdapterHandler instance created"); - }, - ] - }, - "Init Docker Adapters": { - asyncSteps: [async () => { - this.logger.info("Async Step: Initializing Docker Adapters"); - try { - await this.AdapterHandler.initDockerAdapters(); - this.logger.info("Docker Adapters initialized"); - } catch (err) { - this.logger.error(`Error initializing Docker Adapters: ${err}`); - throw err; - } - }] - }, - "Init Docker Clients": { - steps: [ - () => { - this.logger.info("Getting Docker Adapters"); - let c = 0; - const DA = this.AdapterHandler.getDockerAdapters(); - this.logger.debug(`Found ${Object.keys(DA).length} Docker Adapters`); - for (const client of Object.values(DA)) { - c = c++; - this.logger.info(`initializing Nr ${c}`); - try { - client.init(); - this.logger.debug(`Docker client ${c} initialized`); - } catch (err) { - this.logger.error(`Error initializing Docker client ${c}: ${err}`); - } - } - } - ] - }, - "Setup Theme Handler": { - steps: [ - () => { - this.logger.info("Setting up Theme Handler"); - try { - this.themeHandler = new ThemeHandler(this.DB.getDB()); - this.logger.debug("ThemeHandler instance created"); - } catch (err) { - this.logger.error(`Error setting up ThemeHandler: ${err}`); - throw err; - } - } - ] - } - }); - } - - getLogger(){ - return this.logger; - } - - getDB(){ - this.logger.debug("Getting Database Object"); - const dbObj = { - DB: this.DB, - DBWrapper: this.DBWrapper, - tables: { - config: this.config_table, - adapter: this.AdapterHandler.getAdapterTable() - } - }; - this.logger.info("Database object returned"); - return dbObj; - } - - getThemeHandler(){ - this.logger.debug("Getting ThemeHandler instance"); - return this.themeHandler; - } - - public getAdapterHandler(){ - this.logger.debug("Getting AdapterHandler instance"); - return this.AdapterHandler; - } -} - -export default new ServerInstance(); +API.listen({ port: 3000, reusePort: true }) diff --git a/apps/dockstat/app/.server/src/adapters/handler.ts b/apps/dockstat/app/.server/src/adapters/handler.ts deleted file mode 100644 index 9be2eaf2..00000000 --- a/apps/dockstat/app/.server/src/adapters/handler.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type DB from "@dockstat/sqlite-wrapper" -import type { DOCKER, ADAPTER ,DockerAdapterOptions } from "@dockstat/typings"; -import DockerClient from "@dockstat/docker-client"; -import { column, type QueryBuilder } from "@dockstat/sqlite-wrapper"; -import { createLogger } from "@dockstat/logger"; - -class AdapterHandler { - private db: DB; - private adapterConfigTable: QueryBuilder; - private dockerAdapters: Record = {}; - private logger = createLogger("AdapterHandler") - - constructor(db: DB) { - this.logger.info("Initializing AdapterHandler with DB instance"); - this.db = db; - this.db.createTable("adapters", { - id: column.id(), - name: column.text({ notNull: true }), - type: column.enum(["docker"]), - config: column.json({ notNull: true }), - },{ifNotExists:true}) - - this.adapterConfigTable = db.table("adapters", {jsonColumns: ["config"]}); - this.logger.debug("Adapter config table created and assigned"); - } - - getAdapterTable(){ - this.logger.debug("Returning adapterConfigTable"); - return this.adapterConfigTable - } - - registerDockerAdapter(name: string, config: DOCKER.DockerAdapterOptions = {} ) { - this.logger.info(`Registering Docker Adapter: ${name}`); - this.logger.debug(`Config: ${JSON.stringify(config)}`); - try { - this.adapterConfigTable.insert({ - name: name, - type: "docker", - config: config - }); - this.logger.info(`Docker Adapter "${name}" registered successfully`); - } catch (err) { - this.logger.error(`Failed to register Docker Adapter "${name}": ${err}`); - throw err; - } - } - - unregisterDockerAdapter(name: string){ - this.logger.debug(`Unregistering Docker Adapter: ${name}`); - try { - this.adapterConfigTable.where({name: name}).delete(); - this.logger.info(`Docker Adapter "${name}" unregistered successfully`); - } catch (err) { - this.logger.error(`Failed to unregister Docker Adapter "${name}": ${err}`); - throw err; - } - } - - async initDockerAdapters(){ - this.logger.info("Initializing Docker adapters"); - const DockerAdapters = this.adapterConfigTable.where({type: "docker"}).select(["*"]).all() - this.logger.debug(`Found ${DockerAdapters.length} Docker adapters in table`); - let count = 0; - const rawEntries = Object.keys(this.dockerAdapters); - const parsedEntries = rawEntries.map((id) => Number(id)); - - for(const adapter of DockerAdapters){ - this.logger.debug(`Processing adapter ID: ${adapter.id}`); - if(parsedEntries.includes(adapter.id)){ - this.logger.info(`Docker Adapter ${adapter.id} already initialized, testing reachability`); - const docker = this.dockerAdapters[adapter.id]; - - try { - const result = await docker.checkAllHostsHealth(); - if(result){ - this.logger.info(`Docker Adapter ${adapter.id} is reachable --- ${JSON.stringify(result)}`); - continue; - } - this.logger.warn(`Docker Adapter ${adapter.id} is not reachable, recreating`); - delete this.dockerAdapters[adapter.id]; - } catch (err) { - this.logger.error(`Error checking health for Docker Adapter ${adapter.id}: ${err}`); - delete this.dockerAdapters[adapter.id]; - } - } - this.logger.info( - `Initializing Docker Adapter ${adapter.id} --- ${count}/${DockerAdapters.length}` - ) - try { - this.dockerAdapters[adapter.id] = new DockerClient(adapter.id,this.db, adapter.config as DockerAdapterOptions); - this.logger.info(`Initialized Docker Adapter ${adapter.id} --- ${count}/${DockerAdapters.length}`); - } catch (err) { - this.logger.error(`Failed to initialize Docker Adapter ${adapter.id}: ${err}`); - } - count++; - } - this.logger.debug(`Initialized ${count} Docker Adapters`); - } - - getDockerAdapters(){ - this.logger.debug("Returning dockerAdapters map"); - return this.dockerAdapters; - } -} - -export default AdapterHandler diff --git a/apps/dockstat/app/.server/src/plugins/handler.ts b/apps/dockstat/app/.server/src/plugins/handler.ts deleted file mode 100644 index 3e769928..00000000 --- a/apps/dockstat/app/.server/src/plugins/handler.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type DB from "@dockstat/sqlite-wrapper"; -import type { PLUGIN } from "@dockstat/typings" -import { column, type QueryBuilder } from "@dockstat/sqlite-wrapper"; -import { createLogger } from "@dockstat/logger"; - -export class PluginHandler { - private DB: DB - private table: QueryBuilder - private logger = createLogger("PluginHandler") - - constructor(DB: DB) { - this.DB = DB - - this.table = this.DB.createTable("plugins", { - id: column.id(), - name: column.text({ notNull: true }), - version: column.text({ notNull: true, check: "GLOB '[0-9]*.[0-9]*.[0-9]*'" }), - type: column.enum(["component", "backend", "multi"]), - component: column.blob({ notNull: false }), - backend: column.blob({ notNull: false }) - }) - } - - getPluginBlobs(id: number) { - const plugin = this.table.select(["backend", "component"]).where({ id: id }).first() - const tmp = new Map - - if (!plugin) { - throw new Error(`Plugin with id ${id} not found`) - } - - plugin.backend && tmp.set("backend", plugin.backend) - plugin.component && tmp.set('component', plugin.component) - - return tmp - } - - getSavedPlugins() { - return this.table.select(["*"]).all() - } -} diff --git a/apps/dockstat/app/.server/src/theme/defaultTheme.ts b/apps/dockstat/app/.server/src/theme/defaultTheme.ts deleted file mode 100644 index 06de27a8..00000000 --- a/apps/dockstat/app/.server/src/theme/defaultTheme.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { THEME } from "@dockstat/typings"; - -export const DockStat_dark: THEME.ThemeTable = { - creator: "https://github.com/Its4Nik", - license: "MIT", - name: "DockStat Dark", - vars: { - "primary-text": "white", - "secondary-text": "gray", - "tertiary-text": "lightgray", - "accent": "#007bff", - "border-color": "#333", - "border-width": "1px" - }, - config: { - bg: { - useEffect: true, - colors: [], - effect: "aurora" - } - } -} as const; diff --git a/apps/dockstat/app/.server/src/theme/themeHandler.ts b/apps/dockstat/app/.server/src/theme/themeHandler.ts deleted file mode 100644 index 3f9eab49..00000000 --- a/apps/dockstat/app/.server/src/theme/themeHandler.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { createLogger } from "@dockstat/logger"; -import type DB from "@dockstat/sqlite-wrapper"; -import type { THEME } from "@dockstat/typings"; -import { column, type QueryBuilder } from "@dockstat/sqlite-wrapper"; -import { injectVariables } from "../utils"; - -class ThemeHandler { - private DB: DB; - private logger = createLogger("ThemeHandler") - private themes: QueryBuilder; - - constructor(db: DB){ - this.logger.info("Setting up ThemeHandler") - this.DB = db - - this.logger.debug("Creating 'themes' table in DB"); - this.DB.createTable("themes",{ - name: column.text({notNull: true, primaryKey: true, unique: true}), - creator: column.text({notNull: true}), - license: column.text({notNull: true}), - vars: column.json({notNull: true}), - config: column.json({notNull: true}) - },{ifNotExists: true}) - - this.themes = this.DB.table("themes", {jsonColumns: ["vars", "config"]}) - this.logger.debug("Theme table created and assigned"); - - this.logger.info("ThemeHandler setup complete"); - } - - getAll(){ - return this.themes.select(["*"]).all() - } - - registerTheme(theme: THEME.ThemeTable){ - this.logger.info(`Registering theme: ${theme.name}`); - this.logger.debug(`Theme details: ${JSON.stringify(theme)}`); - try { - const result = this.themes.insertOrReplace(theme) - this.logger.info(`Theme "${theme.name}" registered/updated`); - return result; - } catch (err) { - this.logger.error(`Failed to register theme "${theme.name}": ${err}`); - throw err; - } - } - - unregisterTheme(themeName: string){ - this.logger.info(`Unregistering theme: ${themeName}`); - try { - const result = this.themes.where({name: themeName}).delete() - this.logger.info(`Theme "${themeName}" unregistered`); - return result; - } catch (err) { - this.logger.error(`Failed to unregister theme "${themeName}": ${err}`); - throw err; - } - } - - getThemeByName(themeName: string){ - this.logger.debug(`Getting theme by name: ${themeName}`); - return this.themes.where({name: themeName}).first() - } - - getThemeByCreator(themeCreator: string){ - this.logger.debug(`Getting themes by creator: ${themeCreator}`); - return this.themes.select(["*"]).where({creator: themeCreator}).all() - } - - activateTheme(themeName: string, docRoot: HTMLElement){ - this.logger.info(`Activating theme: ${themeName}`); - const theme = this.getThemeByName(themeName) - - if(!theme){ - this.logger.error(`No theme found for name: ${themeName}`); - return { success: false }; - } - - try { - this.logger.debug(`Injecting theme variables for "${themeName}"`); - injectVariables(theme.vars, docRoot) - this.logger.info(`Theme "${themeName}" activated`); - } catch (error: unknown){ - this.logger.error(`Error injecting theme variables: ${error as string}`); - throw new Error(error as string) - } - - return { success: true } - } -} - -export default ThemeHandler diff --git a/apps/dockstat/app/.server/src/utils.ts b/apps/dockstat/app/.server/src/utils.ts deleted file mode 100644 index fd13d0a0..00000000 --- a/apps/dockstat/app/.server/src/utils.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type DockerClient from "@dockstat/docker-client" -import { createLogger } from "@dockstat/logger" -import type { DB_target_host } from "@dockstat/typings" -import { clientLogger as logger } from "~/root" - -export async function startUp( - toRun: Record< - string, - { - steps?: Array<() => void> - asyncSteps?: Array<() => Promise> - } - > -) { - const startUpLogger = createLogger("DockStat-Init") - const entries = Object.entries(toRun) - - startUpLogger.info("=".repeat(20)) - startUpLogger.info("/".repeat(10)) - startUpLogger.info(`🚀 Running ${entries.length} steps`) - startUpLogger.info( - `⌛ Async Steps: ${entries.filter(([, t]) => (t.asyncSteps?.length ?? 0) > 0).length}` - ) - startUpLogger.info( - `⌛ Sync Steps: ${entries.filter(([, t]) => (t.steps?.length ?? 0) > 0).length}` - ) - - for (const [taskName, tasks] of entries) { - startUpLogger.info("=".repeat(20)) - startUpLogger.info("/".repeat(10)) - startUpLogger.info(`▶️ Running Step: ${taskName}`) - - // Run sync steps - if (tasks.steps?.length) { - for (const [i, step] of tasks.steps.entries()) { - startUpLogger.debug(`Starting sync step ${i + 1} for ${taskName}`); - try { - step() - startUpLogger.info(`✅ Step ${i + 1}/${tasks.steps.length} completed`) - } catch (err) { - startUpLogger.error( - `❌ Step ${i + 1} failed in ${taskName}: ${(err as Error).message}` - ) - startUpLogger.debug(`Error stack: ${(err as Error).stack}`); - } - } - } - - // Run async steps - if (tasks.asyncSteps?.length) { - for (const [i, step] of tasks.asyncSteps.entries()) { - startUpLogger.debug(`Starting async step ${i + 1} for ${taskName}`); - try { - await step() - startUpLogger.info( - `✅ Async Step ${i + 1}/${tasks.asyncSteps.length} completed` - ) - } catch (err) { - startUpLogger.error( - `❌ Async Step ${i + 1} failed in ${taskName}: ${(err as Error).message}` - ) - startUpLogger.debug(`Error stack: ${(err as Error).stack}`); - } - } - } - - startUpLogger.info(`✔️ Finished ${taskName}`) - } - - startUpLogger.info("🎉 All tasks complete!") -} - -export function injectVariables(variables: Record, docRoot: HTMLElement): void { - logger.info(`Injecting variables : ${Object.keys(variables).join(", ")}`) - logger.debug(`Variables: ${JSON.stringify(variables)}`); - for (const [key, value] of Object.entries(variables)) { - logger.debug(`Injecting variable: ${key} with value: ${value}`); - try { - injectSingleCSSVar(key,value, docRoot) - logger.info(`Injected variable: ${key}`); - } catch (err) { - logger.error(`Failed to inject variable ${key}: ${err}`); - } - } -} - -function injectSingleCSSVar(variable: string, value: string, docRoot: HTMLElement): string { - logger.info(`Injecting ${variable} with value: ${value}`) - const startsWithHyphen = variable.startsWith("--") - logger.debug(`${startsWithHyphen ? "Parsed variable with Hyphen" : "Parsed variable without Hyphens, added \"--\""}`) - const cssVar = startsWithHyphen ? variable : `--${variable}`; - try { - docRoot.style.setProperty(cssVar, value); - logger.debug(`Set CSS variable ${cssVar} on docRoot`); - } catch (err) { - logger.error(`Failed to set CSS variable ${cssVar}: ${err}`); - } - return value; -} - -export function getHostObjectFromForm(form: FormData, setId = false){ - const host: DB_target_host = { - id: setId ? Number.parseInt(String(form.get('id'))) : 0, - host: String(form.get('host')), - port: Number.parseInt(String(form.get('port'))), - secure: Boolean(String(form.get('secure'))), - name: String(form.get('name')), - } - - return host -} - -export function getDockerClientFromAdapterID(DOA: [string, DockerClient][], form: FormData){ - const aID = String(form.get('adapterID')) - const AOBJ = DOA.find(([id]) => id === aID) - if(!AOBJ) {throw new Error(`Adapter ID ${aID} not found`)} - const [id, client] = AOBJ - - return {id: id, client: client} -} diff --git a/apps/dockstat/app/api.ts b/apps/dockstat/app/api.ts new file mode 100644 index 00000000..b5af3a61 --- /dev/null +++ b/apps/dockstat/app/api.ts @@ -0,0 +1,4 @@ +import type { TreatyType } from "@dockstat/api" +import { treaty } from "@elysiajs/eden" + +export const ClientAPI = treaty("http://localhost:3000").api.v2 diff --git a/apps/dockstat/app/app.css b/apps/dockstat/app/app.css index 0086b3ea..9b06746f 100644 --- a/apps/dockstat/app/app.css +++ b/apps/dockstat/app/app.css @@ -1,49 +1,5 @@ +@import "@dockstat/ui/css"; @import "tailwindcss"; -@theme { - --color-navcard-from: #20063B; - --color-navcard-to: #424B54; - - --color-navcard-views-from: #231942; - --color-navcard-views-to: #5E548E; - - --color-navcard-dockstore-from: #9F86C0 ; - --color-navcard-dockstore-to: #5E548E; - - --color-navcard-stacks-from: #353A47; - --color-navcard-stacks-to: #BEA7E5; - - --color-main-bg: #0b1220; /* deep navy */ - --color-surface: #0f1724; /* slightly lighter surface */ - --color-card-bg: #111827; /* card base */ - --color-border: rgba(255,255,255,0.06); - --color-shadow: 0 10px 30px rgba(2,6,23,0.6); - --color-glow: rgba(124,92,255,0.14); - - /* accents & text */ - --color-accent: #818CF8FF; /* violet accent */ - --color-accent-2: #60a5fa; /* secondary blue */ - --color-text-primary: #e6eef8; /* main text */ - --color-text-secondary: #bfc8ff; /* secondary text */ - --color-muted: #9aa4bf; /* muted text */ - - /* card text (explicit) */ - --color-card-text: #F8FAFC; - - --animate-fade-in-scale: fade-in-scale 0.3s ease-out; - - @keyframes fade-in-scale { - 0% { - opacity: 0; - transform: scale(0.95); - } - 98% { - opacity: 1; - transform: scale(1.2); - } - 100% { - opacity: 1; - transform: scale(1); - } - } -} +/* DO NOT REMOVE THE FOLLOWING LINE */ +@source "../../../packages/ui/src/**/*.{js,jsx,ts,tsx}"; diff --git a/apps/dockstat/app/components/ui/AdapterCard.tsx b/apps/dockstat/app/components/ui/AdapterCard.tsx deleted file mode 100644 index 658ccf50..00000000 --- a/apps/dockstat/app/components/ui/AdapterCard.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { AdapterTable } from "@dockstat/typings"; -import { Card, CardBody, CardHeader } from "./Card"; - -type AdapterCardProps = { - adapters: AdapterTable[] -} - -export default function AdapterCard({ adapters }: AdapterCardProps) { - const Adapters = { - docker: adapters.filter((adapter) => adapter.type === "docker") - } as const - - return ( - <> - {Adapters.docker.map((adapter) => { - return ( - - - {adapter.name} - {adapter.type} - - - - - - - - - - -
- Setting - - Value -
- {adapter.config.defaultTimeout} -
-
-
- ) - })} - - ) -} diff --git a/apps/dockstat/app/components/ui/Badge.tsx b/apps/dockstat/app/components/ui/Badge.tsx deleted file mode 100644 index 66cf2fbc..00000000 --- a/apps/dockstat/app/components/ui/Badge.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import type * as React from 'react'; -import { cn } from '~/utils/cn'; - -type Variant = 'neutral' | 'success' | 'warning' | 'danger'; -type Size = 'sm' | 'md'; - -export type BadgeProps = React.HTMLAttributes & { - variant?: Variant; - size?: Size; - icon?: React.ReactNode; - label?: string; // used for aria-label if children are not descriptive -}; - -const variantMap: Record = { - neutral: 'bg-gray-100 text-gray-800', - success: 'bg-green-100 text-green-800', - warning: 'bg-yellow-100 text-yellow-800', - danger: 'bg-red-100 text-red-800', -}; - -const sizeMap: Record = { - sm: 'text-xs px-2 py-0.5 rounded', - md: 'text-sm px-2.5 py-0.5 rounded-md', -}; - -export const Badge: React.FC = ({ variant = 'neutral', size = 'md', icon, children, className, label, ...rest }) => { - const hasText = typeof children === 'string' && children.trim().length > 0; - return ( - - {icon ? {icon} : null} - {children} - - ); -}; diff --git a/apps/dockstat/app/components/ui/Button.tsx b/apps/dockstat/app/components/ui/Button.tsx deleted file mode 100644 index 32a5cee2..00000000 --- a/apps/dockstat/app/components/ui/Button.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import * as React from 'react'; -import { cn } from '~/utils/cn'; - -type Variant = 'primary' | 'secondary' | 'ghost' | 'destructive'; -type Size = 'xs' | 'sm' | 'md' | 'lg'; - -export type ButtonProps = React.ButtonHTMLAttributes & { - variant?: Variant; - size?: Size; - loading?: boolean; - leftIcon?: React.ReactNode; - rightIcon?: React.ReactNode; -}; - -const base = - 'inline-flex hover:cursor-pointer items-center justify-center rounded-md font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none'; - -const variantClasses: Record = { - primary: 'bg-accent text-white hover:bg-accent/50 focus-visible:ring-accent', - secondary: 'bg-muted border border-border text-gray-800 hover:bg-gray-50 focus-visible:ring-sky-500', - ghost: 'bg-transparent hover:border-accent text-text-primary hover:bg-card-bg focus-visible:ring-accent border border-border', - destructive: 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500', -}; - -const sizeClasses: Record = { - xs: "px-1 py-1 text-xs", - sm: 'px-2.5 py-1.5 text-sm', - md: 'px-4 py-2 text-sm', - lg: 'px-6 py-3 text-base', -}; - -export const Button = React.forwardRef( - ({ variant = 'primary', size = 'md', loading = false, leftIcon, rightIcon, children, className, ...rest }, ref) => { - const isAriaPressed = (rest as any)['aria-pressed'] !== undefined; - return ( - - ); - } -); - -Button.displayName = 'Button' diff --git a/apps/dockstat/app/components/ui/Card.tsx b/apps/dockstat/app/components/ui/Card.tsx deleted file mode 100644 index 0ddb1b7f..00000000 --- a/apps/dockstat/app/components/ui/Card.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type * as React from 'react'; -import { cn } from '~/utils/cn'; - -export const Card: React.FC> = ({ className, children, ...rest }) => { - return ( -
- {children} -
- ); -}; - -export const CardHeader: React.FC> = ({ className, children, ...rest }) => ( -
- {children} -
-); - -export const CardBody: React.FC> = ({ className, children, ...rest }) => ( -
- {children} -
-); - -export const CardFooter: React.FC> = ({ className, children, ...rest }) => ( -
- {children} -
-); - -Card.displayName = "Card" diff --git a/apps/dockstat/app/components/ui/Input.tsx b/apps/dockstat/app/components/ui/Input.tsx deleted file mode 100644 index 60af8625..00000000 --- a/apps/dockstat/app/components/ui/Input.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import * as React from 'react'; -import { cn } from '~/utils/cn'; - -export type InputProps = React.InputHTMLAttributes & { - label?: string; - id?: string; - error?: string | null; - hint?: string | null; -}; - -export const Input = React.forwardRef(({ label, id, error, hint, className, ...rest }, ref) => { - const inputId = id || `input-${Math.random().toString(36).slice(2, 9)}`; - const hintId = hint ? `${inputId}-hint` : undefined; - const errorId = error ? `${inputId}-error` : undefined; - - return ( -
- {label ? ( - - ) : null} - - {error ? ( - - ) : hint ? ( -

- {hint} -

- ) : null} -
- ); -}); - -Input.displayName = 'Input'; diff --git a/apps/dockstat/app/components/ui/Modal.tsx b/apps/dockstat/app/components/ui/Modal.tsx deleted file mode 100644 index b9fc487b..00000000 --- a/apps/dockstat/app/components/ui/Modal.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { gsap } from "gsap"; -import { Button } from "./Button"; -import { X } from "lucide-react"; -import { useGSAP } from "@gsap/react"; - -gsap.registerPlugin(useGSAP) - -type ModalProps = { - open: boolean; - onClose?: () => void; - title?: string; - children?: React.ReactNode; - className?: string; - dismissible?: boolean; // allows click outside + ESC to close -}; - -export default function Modal({ - open, - onClose, - title, - children, - className = "", - dismissible = true, -}: ModalProps) { - const mountedRef = useRef(false); - const panelRef = useRef(null); - const backdropRef = useRef(null); - const lastActiveRef = useRef(null); - - // Visible controls whether the modal is rendered (so we can animate out) - const [isVisible, setIsVisible] = useState(open); - - // Ensure we don't render on SSR - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); - - // When `open` becomes false, run exit animation and only after it completes remove from DOM. - useGSAP(() => { - if (open) { - // start render+enter - setIsVisible(true); - - // kill any existing tweens to avoid conflicts - gsap.killTweensOf([panelRef.current, backdropRef.current]); - - // small timeout to ensure DOM nodes exist - requestAnimationFrame(() => { - if (!panelRef.current || !backdropRef.current) return; - - // set initial state then animate in - gsap.set([backdropRef.current, panelRef.current], { clearProps: "all" }); - gsap.fromTo( - backdropRef.current, - { autoAlpha: 0 }, - { autoAlpha: 1, duration: 0.32, ease: "power3.inOut" } - ); - gsap.fromTo( - panelRef.current, - { y: 14, autoAlpha: 0, scale: 0.985 }, - { y: 0, autoAlpha: 1, scale: 1, duration: 0.36, ease: "power3.inOut" } - ); - }); - } else if (!open && isVisible) { - // exit animation: animate then hide - gsap.killTweensOf([panelRef.current, backdropRef.current]); - const tl = gsap.timeline({ - defaults: { ease: "bounce.inOut" }, - }); - if (backdropRef.current) { - tl.to(backdropRef.current, { autoAlpha: 0, duration: 0.22 }, 0); - } - if (panelRef.current) { - tl.to( - panelRef.current, - { y: 12, scale: 0.99, autoAlpha: 0, duration: 0.22 }, - 0 - ); - } - tl.call(() => { - setIsVisible(false); - }); - } - }, [open]); - - // Focus handling, scroll lock, and keyboard trap while modal is visible. - useEffect(() => { - if (!isVisible) return; - - lastActiveRef.current = document.activeElement as HTMLElement | null; - - // focus first focusable element inside modal, or the container - setTimeout(() => { - const modalEl = panelRef.current; - if (!modalEl) return; - const first = modalEl.querySelector( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' - ); - (first ?? modalEl).focus(); - }, 0); - - // lock scroll - const prevOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - - const handleKey = (e: KeyboardEvent) => { - if (!dismissible) return; - if (e.key === "Escape") { - onClose?.(); - } else if (e.key === "Tab") { - const modalEl = panelRef.current; - if (!modalEl) return; - const focusables = Array.from( - modalEl.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' - ) - ).filter((el) => !el.hasAttribute("disabled")); - if (focusables.length === 0) { - e.preventDefault(); - return; - } - const first = focusables[0]; - const last = focusables[focusables.length - 1]; - if (!e.shiftKey && document.activeElement === last) { - e.preventDefault(); - first.focus(); - } else if (e.shiftKey && document.activeElement === first) { - e.preventDefault(); - last.focus(); - } - } - }; - - document.addEventListener("keydown", handleKey); - return () => { - // cleanup only after exit animation finished (because isVisible remains true until then) - document.body.style.overflow = prevOverflow; - document.removeEventListener("keydown", handleKey); - lastActiveRef.current?.focus?.(); - }; - }, [isVisible, dismissible, onClose]); - - if (!mountedRef.current) return null; - if (typeof document === "undefined") return null; - if (!isVisible) return null; - - const overlay = ( -
- {/* backdrop (lower z) */} -
dismissible && onClose?.()} - // start hidden to avoid flash until gsap runs - style={{ opacity: 0 }} - /> - - {/* modal panel (above backdrop) */} -
e.stopPropagation()} - className={`relative z-50 max-w-3xl w-fit mx-4 sm:mx-6 rounded-lg p-6 shadow-xl bg-main-bg/90 border border-border ${className}`} - style={{ opacity: 0 }} - > - {/* header */} -
- {title ?

{title}

:
} -
- -
-
- - {/* body */} -
{children}
-
-
- ); - - return createPortal(overlay, document.body); -} diff --git a/apps/dockstat/app/components/ui/Nav.tsx b/apps/dockstat/app/components/ui/Nav.tsx deleted file mode 100644 index aa964576..00000000 --- a/apps/dockstat/app/components/ui/Nav.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import React, { useEffect, useRef, useState } from "react"; -import gsap from "gsap"; -import { useGSAP } from "@gsap/react"; -import { Button } from "./Button"; -import { Book, Bubbles, Github, Navigation, Table } from "lucide-react"; -import { Link } from "react-router"; -import Modal from "./Modal"; -import { Card, CardBody } from "./Card"; -import NavCards from "./NavCards"; -import { NavItems } from "~/utils/NavItem"; - -gsap.registerPlugin(useGSAP) - -/** - * Navbar behavior: - * - at top of page: centered, slightly narrower bar (brand on left, links on right) - * - scrolled away from top: shrinks into a floating pill at top-left - * - * TailwindCSS required. - */ - -export default function Nav() { - const navRef = useRef(null); - const [scrolled, setScrolled] = useState(false); - const [isModalOpen, setIsModalOpen] = useState(false) - - // toggle scrolled state - useEffect(() => { - const onScroll = () => { - // threshold to treat as "not at top" - setScrolled(window.scrollY > 10); - }; - onScroll(); - window.addEventListener("scroll", onScroll, { passive: true }); - return () => window.removeEventListener("scroll", onScroll); - }, []); - - // optional GSAP tween for extra polish (falls back to Tailwind transitions) - useGSAP(() => { - if (!navRef.current) return; - const el = navRef.current; - - if (scrolled) { - // small pill at top-left - gsap.to(el, { - duration: 0.32, - top: 16, - left: 16, - width: 48, - height: "16vh", - borderRadius: 999, - ease: "power2.out", - }); - } else { - // centered, slightly narrower top bar - // NOTE: left and width are set so the bar is centered and not full-width - gsap.to(el, { - duration: 0.32, - top: 8, - left: "5%", - width: "90%", - height: 80, - borderRadius: 24, - ease: "power2.out", - }); - } - }, [scrolled]); - - const navBaseClass = - "bg-main-bg/40 hover:shadow-glow shadow-2xl border border-border hover:border-accent backdrop-blur-md rounded-full shadow-md flex items-center transition-all duration-300 ease-in-out"; - - return ( - <> - setIsModalOpen(false)} title="Navigation"> - - -
- {/* the visible bar / pill */} - -
- - ); -} diff --git a/apps/dockstat/app/components/ui/NavCards.tsx b/apps/dockstat/app/components/ui/NavCards.tsx deleted file mode 100644 index 417b929f..00000000 --- a/apps/dockstat/app/components/ui/NavCards.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Link } from "react-router"; -import { Card, CardBody, CardHeader } from "./Card"; - -export type NavCard = { - label: string; - bgColorFrom: string; - bgColorTo: string - textColor: string; - links: { - label: string; - ariaLabel: string; - href: string; - icon?: React.ReactNode; - }[]; -}; - -type NavCardsProps = { - cards: NavCard[]; -}; - -export default function NavCards({ cards }: NavCardsProps) { - return ( -
- {cards.map(({ label, links }) => ( - - - {label} - - - {links.map(({ label, href, icon, ariaLabel }) => ( - - {icon &&
{icon}
} - {label} - - ))} -
-
- )) - } -
- ); -} diff --git a/apps/dockstat/app/entry.client.tsx b/apps/dockstat/app/entry.client.tsx index 9be3baf7..0c35fca5 100644 --- a/apps/dockstat/app/entry.client.tsx +++ b/apps/dockstat/app/entry.client.tsx @@ -1,7 +1,6 @@ -import { createLogger } from '@dockstat/logger' -import { StrictMode,startTransition } from 'react' -import { hydrateRoot } from 'react-dom/client' -import { HydratedRouter } from 'react-router/dom' +import { StrictMode, startTransition } from "react" +import { hydrateRoot } from "react-dom/client" +import { HydratedRouter } from "react-router/dom" startTransition(() => { hydrateRoot( diff --git a/apps/dockstat/app/entry.server.tsx b/apps/dockstat/app/entry.server.tsx index 9faa3035..33f9ff32 100644 --- a/apps/dockstat/app/entry.server.tsx +++ b/apps/dockstat/app/entry.server.tsx @@ -1,42 +1,59 @@ -import { PassThrough } from 'node:stream' -import { createReadableStreamFromReadable } from '@react-router/node' -import { isbot } from 'isbot' -import type { RenderToPipeableStreamOptions } from 'react-dom/server' -import { renderToPipeableStream } from 'react-dom/server' -import type { AppLoadContext, EntryContext } from 'react-router' -import { ServerRouter } from 'react-router' +import { PassThrough } from "node:stream" +import { createReadableStreamFromReadable } from "@react-router/node" +import { isbot } from "isbot" +import type { RenderToPipeableStreamOptions } from "react-dom/server" +import { renderToPipeableStream } from "react-dom/server" +import type { AppLoadContext, EntryContext } from "react-router" +import { ServerRouter } from "react-router" + +// Start server: +import "./.server" export const streamTimeout = 5_000 -export default function handleRequest( +export default async function handleRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, - routerContext: EntryContext - //loadContext: AppLoadContext, + routerContext: EntryContext, + _loadContext: AppLoadContext // If you have middleware enabled: - // loadContext: unstable_RouterContextProvider + // loadContext: RouterContextProvider ) { return new Promise((resolve, reject) => { let shellRendered = false - const userAgent = request.headers.get('user-agent') + const userAgent = request.headers.get("user-agent") // Ensure requests from bots and SPA Mode renders wait for all content to load before responding // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation const readyOption: keyof RenderToPipeableStreamOptions = - (userAgent && isbot(userAgent)) || routerContext.isSpaMode - ? 'onAllReady' - : 'onShellReady' + (userAgent && isbot(userAgent)) || routerContext.isSpaMode ? "onAllReady" : "onShellReady" + + // Abort the rendering stream after the `streamTimeout` so it has time to + // flush down the rejected boundaries + let timeoutId: ReturnType | undefined = setTimeout( + () => abort(), + streamTimeout + 1000 + ) const { pipe, abort } = renderToPipeableStream( , { [readyOption]() { shellRendered = true - const body = new PassThrough() + const body = new PassThrough({ + final(callback) { + // Clear the timeout to prevent retaining the closure and memory leak + clearTimeout(timeoutId) + timeoutId = undefined + callback() + }, + }) const stream = createReadableStreamFromReadable(body) - responseHeaders.set('Content-Type', 'text/html') + responseHeaders.set("Content-Type", "text/html") + + pipe(body) resolve( new Response(stream, { @@ -44,15 +61,12 @@ export default function handleRequest( status: responseStatusCode, }) ) - - pipe(body) }, onShellError(error: unknown) { reject(error) }, onError(error: unknown) { - // biome-ignore lint/style/noParameterAssign: Default from RR; will be rewritten as soon as the WebServer is Bun Compatible - responseStatusCode = 500 + //responseStatusCode = 500; // Log streaming rendering errors from inside the shell. Don't log // errors encountered during initial shell rendering since they'll // reject and get logged in handleDocumentRequest. @@ -62,9 +76,5 @@ export default function handleRequest( }, } ) - - // Abort the rendering stream after the `streamTimeout` so it has time to - // flush down the rejected boundaries - setTimeout(abort, streamTimeout + 1000) }) } diff --git a/apps/dockstat/app/root.tsx b/apps/dockstat/app/root.tsx index d5c2b744..7c5e4099 100644 --- a/apps/dockstat/app/root.tsx +++ b/apps/dockstat/app/root.tsx @@ -1,21 +1,40 @@ -import './app.css' import { + isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration, - isRouteErrorResponse, - useLoaderData, -} from 'react-router' -import type { Route } from './+types/root' -import { createLogger } from '@dockstat/logger'; -import { Card, CardBody, CardFooter, CardHeader } from './components/ui/Card'; -import Nav from './components/ui/Nav'; -export { loader, action } from "react-router-theme"; -export const clientLogger = createLogger("DockStat-Client") + useLocation, + useNavigation, +} from "react-router" +import { Toaster } from "sonner" + +import type { Route } from "./+types/root" +import "./app.css" +import "@dockstat/ui/css" +import { Navbar } from "@dockstat/ui" + +export const links: Route.LinksFunction = () => [ + { + rel: "preconnect", + href: "https://fonts.googleapis.com", + }, + { + rel: "preconnect", + href: "https://fonts.gstatic.com", + crossOrigin: "anonymous", + }, + { + rel: "stylesheet", + href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", + }, +] export function Layout({ children }: { children: React.ReactNode }) { + const navigation = useNavigation() + const location = useLocation().pathname + const isNavigating = navigation.state !== "idle" return ( @@ -24,9 +43,9 @@ export function Layout({ children }: { children: React.ReactNode }) { - -