diff --git a/.ai-workflow/manifest.json b/.ai-workflow/manifest.json new file mode 100644 index 00000000..99291355 --- /dev/null +++ b/.ai-workflow/manifest.json @@ -0,0 +1,223 @@ +{ + "version": 1, + "snapshot_of": "backend-php", + "projects": { + "backend-php": { + "display_name": "Copenhagen Truck Wash API", + "relative_root": ".", + "commands": { + "setup": { + "default": "./scripts/setup.sh", + "win32": "powershell -ExecutionPolicy Bypass -File .\\scripts\\setup.ps1" + }, + "run": { + "default": "docker compose up -d traefik redis mysql-debug php1 caddy" + }, + "test_unit": { + "default": "docker compose exec -T php1 sh -lc \"cd /var/www/html && composer test:unit\"" + }, + "test_e2e": { + "default": "" + }, + "test_full": { + "default": "" + }, + "debug": { + "default": "docker compose logs -f --tail=200 php1" + } + }, + "codex_environment": { + "name": "api", + "actions": [ + { + "name": "Start API", + "icon": "run", + "command_id": "run" + }, + { + "name": "Stop API", + "icon": "run", + "literal_command": { + "default": "docker compose down" + } + }, + { + "name": "PHP logs", + "icon": "debug", + "command_id": "debug" + }, + { + "name": "PHP unit tests", + "icon": "test", + "command_id": "test_unit" + }, + { + "name": "AI workflow check", + "icon": "debug", + "literal_command": { + "default": "node scripts/sync-ai-workflow.mjs --check" + } + } + ] + }, + "generated_content": { + "aiassistant_tests_lines": [ + "# Backend PHP Testing Rules", + "", + "These rules apply to `services/nginx/app/tests` and any backend change that needs verification.", + "", + "1. Add or update tests for every new feature, bug fix, API contract change, or search or authentication workflow change.", + "2. Run backend verification in the `php1` container. Do not use host-side PHP for the supported workflow.", + "3. Use `docker compose exec -T php1 sh -lc \"cd /var/www/html && composer test:unit\"` for unit coverage.", + "4. Use `docker compose exec -T php1 sh -lc \"cd /var/www/html && composer test:integration\"` when code depends on Redis, MySQL, or environment-backed configuration.", + "5. Use `docker compose exec -T php1 sh -lc \"cd /var/www/html && composer test:api\"` when route behavior, envelopes, or request parsing changes.", + "6. Keep tests deterministic: no live third-party calls, no shared Redis keys, no broad database cleanup, and no sleeps unless time behavior is the thing under test.", + "7. Prefer narrow fixtures, explicit cleanup, and behavior-level assertions over implementation checks.", + "8. When public routes, schemas, or permissions change, update `openapi.yaml` together with the tests.", + "9. Run the narrowest relevant suite first, then the broader suite that matches the risk before you finish the task.", + "", + "Canonical workflow reference: `.ai-workflow/workflow.md`." + ], + "aiassistant_routes_lines": [ + "# Backend Route Rules", + "", + "These rules apply to files under `services/nginx/app/routes` and the classes they call.", + "", + "1. Keep route handlers thin: validate input, enforce permissions, call domain code, and write the response.", + "2. Default to protected endpoints. Use the existing authentication and permission helpers instead of ad hoc access checks.", + "3. Add or update backend tests in the `php1` container whenever route behavior changes.", + "4. Update `openapi.yaml` whenever paths, parameters, request bodies, response envelopes, or permissions change.", + "5. Prefer deterministic route tests and avoid live external integrations in route coverage.", + "6. Use concise API errors and keep sensitive implementation details out of the response body.", + "7. When a change touches department, order, or subuser authorization, cover both the allow path and the deny path.", + "", + "Canonical workflow reference: `.ai-workflow/workflow.md`." + ], + "junie_lines": [ + "# Copenhagen Truck Wash API Development Guidelines", + "", + "This file is generated from the canonical AI workflow and is the supported Junie-facing reference for the backend repository.", + "", + "## Build And Run", + "", + "- Project root: `services/nginx/app` is the effective PHP application root.", + "- Setup: use `./scripts/setup.sh` on POSIX or `powershell -ExecutionPolicy Bypass -File .\\scripts\\setup.ps1` on Windows.", + "- Start local API stack: `docker compose up -d traefik redis mysql-debug php1 caddy`.", + "- Tail logs with `docker compose logs -f --tail=200 php1`.", + "", + "## Testing", + "", + "- Supported backend validation runs in `php1`.", + "- Unit tests: `docker compose exec -T php1 sh -lc \"cd /var/www/html && composer test:unit\"`.", + "- Integration tests: `docker compose exec -T php1 sh -lc \"cd /var/www/html && composer test:integration\"`.", + "- API tests: `docker compose exec -T php1 sh -lc \"cd /var/www/html && composer test:api\"`.", + "- Prefer the narrowest suite that proves the change, then run the broader suite that matches the risk.", + "", + "## Workflow Notes", + "", + "- Generated assistant metadata is checked with `node scripts/sync-ai-workflow.mjs --check`.", + "- Route and schema changes require matching updates to `openapi.yaml`.", + "- Runtime OpenAI product behavior is out of scope for this workflow bundle unless a task explicitly changes product code.", + "", + "Canonical workflow reference: `.ai-workflow/workflow.md`." + ] + } + } + }, + "assistants": { + "codex": { + "description": "Codex environment files and actions." + }, + "aiassistant": { + "description": "Project-specific AI Assistant rules." + }, + "junie": { + "description": "Project-specific Junie guidance." + }, + "copilot": { + "description": "Standardized Copilot task dispatch workflow." + } + }, + "commands": { + "setup": { + "description": "Install dependencies and prepare the supported local environment." + }, + "run": { + "description": "Start the primary local development entrypoint for the project." + }, + "test_unit": { + "description": "Run the project's narrow unit-style verification command." + }, + "test_e2e": { + "description": "Run the project's targeted browser or end-to-end verification command." + }, + "test_full": { + "description": "Run the broader high-confidence verification command when the project defines one." + }, + "debug": { + "description": "Run the supported debug entrypoint." + } + }, + "generated_outputs": [ + { + "id": "backend_codex_environment", + "template": "codex_project_environment", + "project": "backend-php", + "path": ".codex/environments/environment.toml" + }, + { + "id": "backend_aiassistant_tests", + "template": "aiassistant_backend_tests_rule", + "project": "backend-php", + "path": ".aiassistant/rules/Creating and maintaining tests.md" + }, + { + "id": "backend_aiassistant_routes", + "template": "aiassistant_backend_routes_rule", + "project": "backend-php", + "path": ".aiassistant/rules/Creating and securing routes.md" + }, + { + "id": "backend_junie_guidelines", + "template": "junie_backend_guidelines", + "project": "backend-php", + "path": ".junie/guidelines.md" + }, + { + "id": "backend_copilot_workflow", + "template": "copilot_dispatcher_workflow", + "project": "backend-php", + "path": ".github/workflows/copilot.yml" + } + ], + "sync_targets": { + "backend-php": { + "source_root": ".", + "destination": "C:\\Users\\2jepp\\PhpstormProjects\\api", + "supported_metadata_dirs": [ + ".codex", + ".aiassistant", + ".junie", + ".github", + ".ai-workflow", + "scripts" + ] + } + }, + "copilot": { + "workflow_name": "Copilot Task Dispatcher", + "input_description": "The task description for Copilot", + "issue_label": "copilot-task", + "title_prefix": "Copilot Task:", + "body_lines": [ + "Assigned to Copilot by @{{ACTOR}}.", + "", + "Task", + "{{TASK}}", + "", + "AI workflow notes", + "- Generated assistant metadata is synchronized from the canonical AI workflow bundle.", + "- Run `node scripts/sync-ai-workflow.mjs --check` if assistant metadata changed." + ] + } +} diff --git a/.ai-workflow/workflow.md b/.ai-workflow/workflow.md new file mode 100644 index 00000000..5c90a6d0 --- /dev/null +++ b/.ai-workflow/workflow.md @@ -0,0 +1,74 @@ + + +# Developer AI Workflow + +This directory is the canonical source of truth for the repository's developer-facing AI workflow. + +## Goals + +- Keep Codex, AI Assistant, Junie, and Copilot aligned from one maintained source. +- Make assistant metadata deterministic so the generated files can be rewritten safely and checked in CI. +- Preserve the current mirror-repo workflow for `backend-php` and `front-end-vue`. +- Keep all changes in this workflow scoped to developer tooling and documentation. Runtime OpenAI features stay out of scope. + +## Supported Assistants + +- `Codex`: local environments and actions under `.codex/environments`. +- `AI Assistant`: generated guidance under `.aiassistant/rules`. +- `Junie`: generated project guidance under `.junie/guidelines.md`. +- `Copilot`: standardized issue-dispatch workflow content under `.github/workflows/copilot.yml`. + +## Global Rules + +1. Change only `.ai-workflow/workflow.md` and `.ai-workflow/manifest.json` when updating the developer AI workflow. +2. Regenerate all derived files with `node scripts/sync-ai-workflow.mjs --write`. +3. Validate drift with `node scripts/sync-ai-workflow.mjs --check`. +4. Generated assistant files are not hand-edited. +5. Unsupported surfaces stay unsupported until they have a real owner and a real config. +6. `front-end-vue/.ai/mcp/mcp.json` is intentionally unsupported and should not be recreated until there is an actual MCP integration to maintain. + +## Command Matrix + +### `backend-php` + +- Setup: use the existing setup scripts in `backend-php/scripts`. +- Run: start the API stack with `traefik`, `redis`, `mysql-debug`, `php1`, and `caddy`. +- Debug: tail `php1` logs. +- PHP verification: always run backend validation in the `php1` container. +- Unit tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:unit"`. +- Integration tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:integration"`. +- API tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:api"`. + +### `front-end-vue` + +- Setup: `npm ci` and `npx playwright install`. +- Run: `npm run dev`. +- Unit tests: `npm run test:unit` with Vitest. +- Browser smoke: `npm run test:e2e:smoke`. +- Browser full suite: `npm run test:e2e:ci`. +- Debug: `npx playwright test --debug`. +- Browser validation is Playwright-first. WebdriverIO and Appium are not part of the supported workflow. + +### `automation` + +- Setup: `npm ci` and `npx playwright install`. +- Run: `npx playwright test --ui`. +- Browser tests: `npx playwright test`. +- Debug: `npx playwright test --debug`. + +## Mirror Repos And Sync + +- `backend-php` mirrors to `C:\Users\2jepp\PhpstormProjects\api`. +- `front-end-vue` mirrors to `C:\Users\2jepp\WebstormProjects\pleno-vue`. +- Supported metadata directories that must stay mirrored are `.codex`, `.aiassistant`, `.junie`, `.github`, generated `.ai-workflow`, and `scripts/sync-ai-workflow.mjs`. +- Cache and build directories remain excluded from the watcher. +- The mirror repos receive generated snapshots of `.ai-workflow` and `scripts/sync-ai-workflow.mjs` so their local CI can run `--check` without depending on the combined workspace root. + +## Generated Outputs + +- Root Codex environment for combined backend and frontend entrypoints. +- Backend and frontend Codex environments. +- Backend and frontend AI Assistant guidance. +- Backend and frontend Junie guidance. +- Backend Copilot dispatcher workflow. +- Backend and frontend snapshot copies of `.ai-workflow` plus `scripts/sync-ai-workflow.mjs` for mirrored repositories. diff --git a/.aiassistant/rules/Creating and maintaining tests.md b/.aiassistant/rules/Creating and maintaining tests.md index d8a49b30..436ef950 100644 --- a/.aiassistant/rules/Creating and maintaining tests.md +++ b/.aiassistant/rules/Creating and maintaining tests.md @@ -2,261 +2,20 @@ apply: always --- -### Creating and maintaining tests — Rules (Project‑specific) + -These rules apply to all tests under `services/nginx/app/tests` for the Copenhagen Truck Wash API. They codify how to create, run, and maintain the lightweight CLI PHP tests used in this repository. +# Backend PHP Testing Rules -1. Scope and philosophy - - Prefer fast, deterministic CLI scripts over framework‑based tests. - - Keep unit‑style tests isolated from I/O (no DB/Redis/files/network). Use test doubles and explicit `require_once` for only the code exercised. - - Use integration tests sparingly and only when configuration/globals are required. Run them in Docker with `USE_ENV=true`. +These rules apply to `services/nginx/app/tests` and any backend change that needs verification. -2. Location and naming - - Place tests in `services/nginx/app/tests//YourTest.php` where `` reflects the feature area (e.g., `subusers`, `orders`, `redis`). - - File names must end with `Test.php` (e.g., `SelfservePermissionInitTest.php`). - - Keep one coherent scenario per file. If a file grows beyond ~150 lines or mixes unrelated scenarios, split it. - -3. Execution modes - - Unit‑style (preferred): run on host with PHP CLI. - - Command: `php services/nginx/app/tests//Test.php` - - Integration‑style (needs env/globals): run inside Docker (php1) where `USE_ENV=true` and env vars are provided by `docker-compose.yml`. - - Start minimal stack: `docker compose up -d traefik redis php1 caddy` - - Command: `docker compose exec -T php1 php /var/www/html/tests//Test.php` - -4. Bootstrapping and includes - - At the top of every test, define `WD` if not already defined: - ```php - if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); } - ``` - - For unit‑style tests: include only the files you exercise via explicit `require_once WD . '/path/to/file.php';`. - - Do NOT include `config.php` for unit‑style tests; it throws if `USE_ENV` is not set. - - For integration‑style tests that need globals (`$CONFIG_DB`, `$REDIS_CONFIG`, etc.): `require_once WD . '/config.php';` and run the test inside `php1`. - -5. Output and exit codes - - Use simple helpers for human‑readable output: - ```php - function ok($message){ echo "\n\033[32m✔ $message\033[0m\n"; } - function fail($message){ echo "\n\033[31m✖ $message\033[0m\n"; } - ``` - - Prefer collecting failures and exiting non‑zero when any assertion fails: - ```php - $failed = 0; - // ... on failure set $failed = 1; - exit($failed); - ``` - - End with a concise completion line, e.g., `echo "\nTest completed.\n";`. - -6. Determinism, time, and randomness - - Avoid `sleep()` and real time dependencies. If time is relevant, pass it as a parameter or stub the time source. - - Do not use random values unless strictly necessary; if used, seed explicitly (`mt_srand(1234)`) and document it. - -7. External I/O and side effects - - Never call external services (Stripe, e‑conomic, MinIO, Slack, WordPress) from tests. Stub or override code paths as shown in `tests/subusers/SelfservePermissionInitTest.php`. - - Do not modify files under the repository during tests. If temporary files are required, use `sys_get_temp_dir()` and delete them before exit. - -8. Redis and database (integration‑only) - - Run inside Docker (`php1`) so `config.php` can populate `$REDIS_CONFIG` and `$CONFIG_DB`. - - Use unique, namespaced Redis keys for test data (e.g., `test::`). Clean them up at the end. Do NOT call `FLUSHALL`. - - Prefer fakes/stubs over real DB writes. If DB writes are unavoidable, scope them to clearly identifiable rows and delete them before exit. - -9. Performance budgets - - Each unit‑style test script should complete in < 100 ms on a typical dev machine. - - Each integration‑style test should complete in < 2 s and must avoid N+1 loops or heavy queries. - -10. When to add or update tests - - New route/feature: add at least one unit‑style test for core logic. If behavior depends on configuration/globals, add a minimal integration test. - - Bug fix: first reproduce with a failing test; then fix and ensure the test passes. - - Refactor: keep behavior‑preserving tests green. If public behavior changes intentionally, update both tests and `openapi.yaml` accordingly. - -11. Maintenance expectations - - Do not weaken or remove assertions to “make tests pass”. Investigate and fix root causes. - - Keep tests small and readable. Extract tiny helpers within the test file rather than introducing new shared libraries. - - Match the project code style and line endings (see `.editorconfig`: UTF‑8, CRLF, 4‑space indent, class brace `next_line`). - -12. Templates - - Unit‑style template: - ```php - set($key, 'pong'); - $val = $r->get($key); - if ($val === 'pong') { ok('Redis set/get works for namespaced key'); } else { fail('Unexpected value from Redis'); $failed = 1; } - } finally { - // Best‑effort cleanup - try { $r->del($key); } catch (\Throwable $e) { /* ignore */ } - } - - echo "\nExampleRedisIntegrationTest completed.\n"; - exit($failed); - ``` - -13. Quick reference - - Run unit‑style test on host: `php services/nginx/app/tests//Test.php` - - Run integration‑style test in Docker: `docker compose exec -T php1 php /var/www/html/tests//Test.php` - - Minimal stack for integration: `docker compose up -d traefik redis php1 caddy` - -For broader build, configuration, and existing examples, see `.junie/guidelines.md` (Testing section). - ---- -apply: always ---- - -### Creating and maintaining tests — Rules (Project‑specific) - -These rules apply to all tests under `services/nginx/app/tests` for the Copenhagen Truck Wash API. They codify how to create, run, and maintain the lightweight CLI PHP tests used in this repository. - -1. Scope and philosophy - - Prefer fast, deterministic CLI scripts over framework‑based tests. - - Keep unit‑style tests isolated from I/O (no DB/Redis/files/network). Use test doubles and explicit `require_once` for only the code exercised. - - Use integration tests sparingly and only when configuration/globals are required. Run them in Docker with `USE_ENV=true`. - -2. Location and naming - - Place tests in `services/nginx/app/tests//YourTest.php` where `` reflects the feature area (e.g., `subusers`, `orders`, `redis`). - - File names must end with `Test.php` (e.g., `SelfservePermissionInitTest.php`). - - Keep one coherent scenario per file. If a file grows beyond ~150 lines or mixes unrelated scenarios, split it. - -3. Execution modes - - Unit‑style (preferred): run on host with PHP CLI. - - Command: `php services/nginx/app/tests//Test.php` - - Integration‑style (needs env/globals): run inside Docker (php1) where `USE_ENV=true` and env vars are provided by `docker-compose.yml`. - - Start minimal stack: `docker compose up -d traefik redis php1 caddy` - - Command: `docker compose exec -T php1 php /var/www/html/tests//Test.php` - -4. Bootstrapping and includes - - At the top of every test, define `WD` if not already defined: - ```php - if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); } - ``` - - For unit‑style tests: include only the files you exercise via explicit `require_once WD . '/path/to/file.php';`. - - Do NOT include `config.php` for unit‑style tests; it throws if `USE_ENV` is not set. - - For integration‑style tests that need globals (`$CONFIG_DB`, `$REDIS_CONFIG`, etc.): `require_once WD . '/config.php';` and run the test inside `php1`. - -5. Output and exit codes - - Use simple helpers for human‑readable output: - ```php - function ok($message){ echo "\n\033[32m✔ $message\033[0m\n"; } - function fail($message){ echo "\n\033[31m✖ $message\033[0m\n"; } - ``` - - Prefer collecting failures and exiting non‑zero when any assertion fails: - ```php - $failed = 0; - // ... on failure set $failed = 1; - exit($failed); - ``` - - End with a concise completion line, e.g., `echo "\nTest completed.\n";`. - -6. Determinism, time, and randomness - - Avoid `sleep()` and real time dependencies. If time is relevant, pass it as a parameter or stub the time source. - - Do not use random values unless strictly necessary; if used, seed explicitly (`mt_srand(1234)`) and document it. - -7. External I/O and side effects - - Never call external services (Stripe, e‑conomic, MinIO, Slack, WordPress) from tests. Stub or override code paths as shown in `tests/subusers/SelfservePermissionInitTest.php`. - - Do not modify files under the repository during tests. If temporary files are required, use `sys_get_temp_dir()` and delete them before exit. - -8. Redis and database (integration‑only) - - Run inside Docker (`php1`) so `config.php` can populate `$REDIS_CONFIG` and `$CONFIG_DB`. - - Use unique, namespaced Redis keys for test data (e.g., `test::`). Clean them up at the end. Do NOT call `FLUSHALL`. - - Prefer fakes/stubs over real DB writes. If DB writes are unavoidable, scope them to clearly identifiable rows and delete them before exit. - -9. Performance budgets - - Each unit‑style test script should complete in < 100 ms on a typical dev machine. - - Each integration‑style test should complete in < 2 s and must avoid N+1 loops or heavy queries. - -10. When to add or update tests -- New route/feature: add at least one unit‑style test for core logic. If behavior depends on configuration/globals, add a minimal integration test. -- Bug fix: first reproduce with a failing test; then fix and ensure the test passes. -- Refactor: keep behavior‑preserving tests green. If public behavior changes intentionally, update both tests and `openapi.yaml` accordingly. - -11. Maintenance expectations -- Do not weaken or remove assertions to “make tests pass”. Investigate and fix root causes. -- Keep tests small and readable. Extract tiny helpers within the test file rather than introducing new shared libraries. -- Match the project code style and line endings (see `.editorconfig`: UTF‑8, CRLF, 4‑space indent, class brace `next_line`). - -12. Templates -- Unit‑style template: - ```php - set($key, 'pong'); - $val = $r->get($key); - if ($val === 'pong') { ok('Redis set/get works for namespaced key'); } else { fail('Unexpected value from Redis'); $failed = 1; } - } finally { - // Best‑effort cleanup - try { $r->del($key); } catch (\Throwable $e) { /* ignore */ } - } - - echo "\nExampleRedisIntegrationTest completed.\n"; - exit($failed); - ``` - -13. Quick reference -- Run unit‑style test on host: `php services/nginx/app/tests//Test.php` -- Run integration‑style test in Docker: `docker compose exec -T php1 php /var/www/html/tests//Test.php` -- Minimal stack for integration: `docker compose up -d traefik redis php1 caddy` - -For broader build, configuration, and existing examples, see `.junie/guidelines.md` (Testing section). +1. Add or update tests for every new feature, bug fix, API contract change, or search or authentication workflow change. +2. Run backend verification in the `php1` container. Do not use host-side PHP for the supported workflow. +3. Use `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:unit"` for unit coverage. +4. Use `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:integration"` when code depends on Redis, MySQL, or environment-backed configuration. +5. Use `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:api"` when route behavior, envelopes, or request parsing changes. +6. Keep tests deterministic: no live third-party calls, no shared Redis keys, no broad database cleanup, and no sleeps unless time behavior is the thing under test. +7. Prefer narrow fixtures, explicit cleanup, and behavior-level assertions over implementation checks. +8. When public routes, schemas, or permissions change, update `openapi.yaml` together with the tests. +9. Run the narrowest relevant suite first, then the broader suite that matches the risk before you finish the task. +Canonical workflow reference: `.ai-workflow/workflow.md`. diff --git a/.aiassistant/rules/Creating and securing routes.md b/.aiassistant/rules/Creating and securing routes.md index 75c22fcb..552d7e88 100644 --- a/.aiassistant/rules/Creating and securing routes.md +++ b/.aiassistant/rules/Creating and securing routes.md @@ -2,172 +2,18 @@ apply: always --- -### Creating and securing routes — Rules (Project‑specific) + -These rules define how to add HTTP routes to the Copenhagen Truck Wash API and how to secure them consistently. They reflect current patterns in `services/nginx/app/routes` and the helper APIs in `traits\route_t`. +# Backend Route Rules -1. Location, naming, and structure - - Place route classes under `services/nginx/app/routes`. - - File names should describe the domain and end with `Route.php`, e.g., `BookingsRoute.php`, `ModuleSelfServeRoute.php`. - - Namespace must be `routes` and each file must define one class that uses `traits\route_t` and implements `run(): void`. - ```php - get('/example', function () { - global $response; - $response->success(['message' => 'Hello World!']); - }); - ``` - -3. Route paths and local routing - - Caddy serves the app directly; Traefik adds an external `/api` prefix for local access. - - Direct path in the app: `/foo/bar`. - - Local access paths (both work): - - `http://localhost/foo/bar` (direct) - - `http://localhost/api/foo/bar` (Traefik strip‑prefix `/api` → still routes to `/foo/bar` in the app) - - Keep paths meaningful and resource‑oriented. Prefer plural nouns for collections and subpaths for actions when unavoidable (e.g., `/modules/self-serve/lane/status`). - -4. Responses and status codes - - Use `$response->success($payload, $status = 200)` for successful results and `$response->error($messageOrPayload, $status)` for failures. - - Typical statuses: - - 200 OK for successful reads/updates; 201 Created when new resources are created. - - 400 Bad Request for validation errors. - - 401 Unauthorized when authentication is missing/invalid. - - 403 Forbidden when authenticated but lacking permissions. - - Return concise, non‑sensitive error messages. Internal details go to logs (see `objects\logs_o`). - -5. Input handling and validation (route_t helpers) - - Read parameters via: - - `getParameter($name)` or `getParametersAsArray()` (preferred; integrates with `classes\response` parsing) - - `fromRequest($name)` (checks JSON body, then `$_POST`, then query) - - `fromQuery($name)` and `fromRoute($segmentName)` when relevant - - Validate early and fail fast using helpers: - - Presence: `requireParameters(['a','b'])`, `isParametersSet(['a','b'])` - - Types: `requireType($val, type_string()|type_int()|TYPE_BOOL()|TYPE_ARRAY())`, `requireTypeIn($val, [...])` - - Ranges/lengths: `requireMinValue($n, $min)`, `requireMaxValue($n, $max)`, `requireMaxLength($name, $len)`, `requireParameterIntPositive($n, $name)` - - Formats/enums: `requireDateFormat($date, FORMAT_DATE())`, `requireInArray($val, $allowed)` - -6. Authentication and authorization (core rules) - - Default posture: endpoints are protected and require authentication unless explicitly public. - - To check authentication without a specific permission: `isAuthenticated()`. - - Enforce permissions inside handlers using: - - `requirePermission('some_permission')` — throws 401/403 as appropriate. - - `hasPermission('some_permission', $customerNumber = null)` — boolean check (do not throw). - - Register permissions for discoverability/administration by supplying the third `$permissions` argument when declaring the route. Keys are permission names; values are human‑readable descriptions. You should still call `requirePermission()` inside the handler to actually enforce. - ```php - $this->get('/modules/self-serve/lane/status', function () { - global $response; - self::requirePermission('modules_selfserve_lane_status_view'); - // ... - $response->success(['status' => 'OK']); - }, [ - 'modules_selfserve_lane_status_view' => 'View self-serve lane status', - ]); - ``` - -7. Subusers, permission nodes, and customer context - - When a permission corresponds to a subuser permission node, define it with `definePermission($perm, subusers_permission_node_key::CASE)` to keep mapping explicit. - - Subuser contexts may target a specific customer via the `X-Customer-Number` header or a `customer_number` parameter; `route_t` exposes this as response meta (`target_customer_number`) when applicable. - - Use `allowOwnOrDepartmentAccess($ownPerm, $deptPerm, $targetCustomerNumber, $departmentId, $ownGuard = null, $denyMessage = null)` for the common pattern: allow the principal to act on their own customer scope or fall back to a department/admin permission. - - Use `isOwnCustomerContext($targetCustomerNumber)` and `resolveEffectiveCustomerNumber()` when building conditional logic. - -8. Department, order, and special access helpers - - Department access: `requireDepartmentAccess($departmentId, $permissionSuffix = null)`, `hasDepartmentAccess(...)`. - - Order access: `requireOrderAccess($orderId, $permissionSuffix = null)` (note: indirect access rules may evolve; keep logic minimal in routes). - - Plate scanners (API key based): protect endpoints with `requirePlateScannerAuth()`. - - Bot protection (public forms): use `requireRecaptcha()` and expect `g_recaptcha_response` in the request. - -9. Public endpoints - - Keep public endpoints extremely limited. If an endpoint must be public, you must: - - Validate all inputs thoroughly using the helpers above. - - Add rate‑limit or abuse‑mitigation where relevant (Redis is available as `classes\redis` for counters/locks; coordinate design with maintainers before introducing new limits). - - Prefer `requireRecaptcha()` for anonymous form submissions. - -10. Side effects and idempotency - - For POST/PUT/PATCH with side effects, make operations idempotent when feasible (e.g., by honoring a client idempotency key header). If you add such behavior, document it in the route’s docblock and in `openapi.yaml`. - - Wrap multi‑step operations with proper validation and permission checks before any external calls (Stripe, e‑conomic, etc.). Do not leak third‑party error payloads directly; map to concise API errors and log details. - -11. Logging and observability - - On auth/permission denials, the helpers already log to `logs_o` with structured messages. Avoid duplicating logs for the same event. - - For unexpected states you handle gracefully, add targeted logs via `objects\logs_o` with a clear category/key. - -12. OpenAPI contract synchronization - - Every public surface change must be reflected in `openapi.yaml` at repo root. Keep paths, methods, parameters, request/response schemas, and error statuses up to date. - - Use the same path strings as in the route declaration (remember Traefik’s `/api` is stripped before hitting the app). - - If you add or change permissions that users must hold, document them in the endpoint description. - -13. Example: secure command endpoint - ```php - post('/modules/self-serve/lane/command', function () { - global $response; - - // AuthZ - self::requirePermission('modules_selfserve_lane_command_execute'); - - // Validate input - self::requireParameters(['lane_id', 'command']); - $laneId = (int) self::getParameter('lane_id'); - self::requireType($laneId, self::type_int()); - self::requireMinValue($laneId, 1); - - $cmd = (string) self::getParameter('command'); - self::requireType($cmd, self::type_string()); - self::requireInArray($cmd, ['START','STOP','RESERVE','RELEASE','RESET']); - - // Business logic (call into modules/classes) - // ... - - $response->success(['ok' => true]); - }, [ - 'modules_selfserve_lane_command_execute' => 'Execute self‑serve lane commands', - ]); - } - } - ``` - -14. Quick reference (local) - - Bring up minimal stack: `docker compose up -d traefik redis php1 caddy` - - Access an app route locally: - - `http://localhost/` or `http://localhost/api/` - - Tests are CLI scripts under `services/nginx/app/tests`. Prefer unit‑style tests for core logic; see “Creating and maintaining tests — Rules”. - -15. Code style and hygiene - - Follow `.editorconfig` (UTF‑8, CRLF, 4‑space indents). - - Keep route files cohesive; avoid adding unrelated endpoints to the same class. If a class exceeds ~200–300 lines or mixes domains, split it. - - Do not perform heavy bootstrap in route files. Delegate to classes/modules and keep handlers thin: validate → authorize → invoke → respond. +1. Keep route handlers thin: validate input, enforce permissions, call domain code, and write the response. +2. Default to protected endpoints. Use the existing authentication and permission helpers instead of ad hoc access checks. +3. Add or update backend tests in the `php1` container whenever route behavior changes. +4. Update `openapi.yaml` whenever paths, parameters, request bodies, response envelopes, or permissions change. +5. Prefer deterministic route tests and avoid live external integrations in route coverage. +6. Use concise API errors and keep sensitive implementation details out of the response body. +7. When a change touches department, order, or subuser authorization, cover both the allow path and the deny path. +Canonical workflow reference: `.ai-workflow/workflow.md`. diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml index 04c9e65f..c2678940 100644 --- a/.codex/environments/environment.toml +++ b/.codex/environments/environment.toml @@ -3,31 +3,46 @@ version = 1 name = "api" [setup] -script = "./scripts/setup.sh" +script = ''' +./scripts/setup.sh +''' [setup.win32] -script = "powershell -ExecutionPolicy Bypass -File .\\scripts\\setup.ps1" +script = ''' +powershell -ExecutionPolicy Bypass -File .\scripts\setup.ps1 +''' [[actions]] name = "Start API" icon = "run" -command = "powershell -ExecutionPolicy Bypass -File .\\scripts\\run.ps1 -Action start" -platform = "win32" +command = ''' +docker compose up -d traefik redis mysql-debug php1 caddy +''' [[actions]] name = "Stop API" icon = "run" -command = "powershell -ExecutionPolicy Bypass -File .\\scripts\\run.ps1 -Action stop" -platform = "win32" +command = ''' +docker compose down +''' [[actions]] name = "PHP logs" icon = "debug" -command = "powershell -ExecutionPolicy Bypass -File .\\scripts\\run.ps1 -Action logs -Service php1" -platform = "win32" +command = ''' +docker compose logs -f --tail=200 php1 +''' [[actions]] -name = "Unit tests" +name = "PHP unit tests" icon = "test" -command = "powershell -ExecutionPolicy Bypass -File .\\scripts\\run.ps1 -Action test" -platform = "win32" +command = ''' +docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:unit" +''' + +[[actions]] +name = "AI workflow check" +icon = "debug" +command = ''' +node scripts/sync-ai-workflow.mjs --check +''' diff --git a/.github/workflows/copilot.yml b/.github/workflows/copilot.yml index 3b6c2355..6177bd06 100644 --- a/.github/workflows/copilot.yml +++ b/.github/workflows/copilot.yml @@ -14,7 +14,7 @@ jobs: issues: write steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Create GitHub Issue for Copilot env: @@ -22,7 +22,18 @@ jobs: TASK: ${{ github.event.inputs.task }} ACTOR: ${{ github.actor }} run: | + BODY="$(cat < -#### Scope -This document captures project‑specific knowledge for building, configuring, testing, and extending the API. It assumes an advanced developer familiar with Docker, PHP 8.2, and HTTP APIs. +# Copenhagen Truck Wash API Development Guidelines ---- +This file is generated from the canonical AI workflow and is the supported Junie-facing reference for the backend repository. -### Build and Configuration +## Build And Run -- Stack overview (local): - - Reverse proxy/router: Traefik 2.x (`docker-compose.yml` service `traefik`). - - Web server: Caddy (`caddy`) serving the PHP app from `services/nginx/app` and proxied by Traefik. - - PHP runtime: Multiple PHP‑FPM containers (`php1`..`php5`), sharing the bind‑mounted app directory. - - Redis: `redis` for caching/queues/locks. +- Project root: `services/nginx/app` is the effective PHP application root. +- Setup: use `./scripts/setup.sh` on POSIX or `powershell -ExecutionPolicy Bypass -File .\scripts\setup.ps1` on Windows. +- Start local API stack: `docker compose up -d traefik redis mysql-debug php1 caddy`. +- Tail logs with `docker compose logs -f --tail=200 php1`. -- App location: `services/nginx/app` is the effective application root (many scripts/tests derive `WD` to point here). +## Testing -- Composer and dependencies: - - `services/php/Dockerfile` installs Composer and PHP extensions. - - `services/php/docker-entrypoint.sh` performs a guarded Composer install on `php1` at container start when `AUTO_COMPOSER_INSTALL=true` and `composer.json` is present. - - App dependencies live under `services/nginx/app/composer.json` (note: very light, primarily runtime libs; dev tool `rector/rector`). +- Supported backend validation runs in `php1`. +- Unit tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:unit"`. +- Integration tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:integration"`. +- API tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:api"`. +- Prefer the narrowest suite that proves the change, then run the broader suite that matches the risk. -- Configuration source of truth during containerized runs is environment variables consumed by `services/nginx/app/config.php`. - - `config.php` requires `USE_ENV=true`; otherwise it throws an exception. Many CLI scripts/tests bypass `config.php` entirely to remain env‑agnostic. +## Workflow Notes -- Local run targets and routing: - - Traefik exposes: - - `http://localhost` → routes to Caddy → app (HTTP only for dev). - - `https://localhost` → also mapped, using Traefik’s default/self‑signed dev cert. - - `http(s)://localhost/api/*` → Traefik strip‑prefix middleware forwards to Caddy; the app sees paths without the `/api` prefix. - - For production‑like HTTPS with real certs, see `README.md` for `LETSENCRYPT_PATH` mounting strategy (only needed if you want the exact `api.truckwash.dk` TLS behavior locally). +- Generated assistant metadata is checked with `node scripts/sync-ai-workflow.mjs --check`. +- Route and schema changes require matching updates to `openapi.yaml`. +- Runtime OpenAI product behavior is out of scope for this workflow bundle unless a task explicitly changes product code. -- Minimal bring‑up for local development: - - Prerequisites: Docker Desktop 4.x+. - - First run will build PHP images and start dependent services. Composer install runs automatically on `php1`. - - Recommended minimal set: - - `docker compose up -d traefik redis php1 caddy` - - Full set (scale out PHP or add observability as needed): - - `docker compose up -d` (starts `traefik`, `redis`, `caddy`, `php1`..`php5`, and other declared services). - - Logs: - - `docker compose logs -f caddy` - - `docker compose logs -f php1` - - `docker compose logs -f traefik` - -- Security note: `docker-compose.yml` currently embeds sensitive env values (DB, API tokens). Treat the file as secret in private repos; never re‑publish as is. Prefer `.env` overrides and secrets providers for wider teams. - ---- - -### Testing - -The repository does not use PHPUnit for the app. Instead, tests are lightweight CLI scripts under `services/nginx/app/tests`. Conventions: - -- Test style - - Self‑contained procedural PHP scripts intended to be executed with `php`. No framework required. - - Many tests define the `WD` constant to the app root and then `require_once` specific class/trait/interface files they exercise. - - Integration‑style scripts that need configuration will rely on `services/nginx/app/config.php` and so must run within a properly provisioned environment (Docker containers with `USE_ENV=true`). - - Fast unit‑style scripts should avoid `config.php` and any I/O; they manually include only what’s needed and/or use test doubles. - -- Running tests from host (fastest path) - - Prereq: PHP CLI available on host. Verified with: - - `php -v` → observed on our env: `PHP 8.2.30 (cli)` - - Example: an existing, fully self‑contained test validating subuser permission wiring: - - Command: - - `php services/nginx/app/tests/subusers/SelfservePermissionInitTest.php` - - Verified output (captured): - - - ``` - ✔ SELFSERVE_ADD is granted as expected - ✔ SELFSERVE_LIST is not granted as expected - ✔ SELFSERVE_EDIT is not granted as expected - ✔ SELFSERVEDELETE is not granted as expected - SelfservePermissionInitTest completed. - ``` - -- Running tests inside Docker (when host PHP is unavailable or when env is required) - - Ensure containers are up: `docker compose up -d traefik redis php1 caddy` - - Execute a test within `php1`: - - `docker compose exec -T php1 php /var/www/html/tests/subusers/SelfservePermissionInitTest.php` - - For integration tests that depend on `config.php` (e.g., Redis/DB), environment variables are preconfigured in `docker-compose.yml` for the PHP services. Running inside `php1` will satisfy `USE_ENV=true`. - -- Adding a new test - - Place the file under `services/nginx/app/tests//YourTest.php`. - - At top of the file, define `WD` if not already defined: - ```php - if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); } - ``` - - Prefer self‑contained tests that avoid I/O. If you need to touch internal classes without Composer autoloading, include files directly, mirroring existing tests. - - If the test must hit Redis/DB or rely on globals from `config.php`, run it inside a PHP container (`php1`) with `USE_ENV=true`. - -- Demonstration: creating and running a minimal test - - We created a temporary, env‑agnostic test at `services/nginx/app/tests/examples/HelloWorldTest.php` with these semantics: - - Define `WD`, perform trivial assertions, and print success markers. - - Command executed and verified output: - - Command: - - `php services/nginx/app/tests/examples/HelloWorldTest.php` - - Output (captured): - - - ``` - ✔ Basic arithmetic works (2 + 2 = 4) - ✔ WD points to the application root: C:\\Users\\2jepp\\PhpstormProjects\\api\\services\\nginx\\app - HelloWorldTest completed. - ``` - - The example file was removed afterwards to keep the repository unchanged. You can replicate by creating a similar file and removing it after execution. - ---- - -### Additional Development Information - -- Routing and HTTP surface - - Routes live under `services/nginx/app/routes`. Example route `exampleRoute.php` exposes `GET /example` returning `{"message":"Hello World!"}`. Local access paths (with Traefik): - - `http://localhost/example` (direct) - - `http://localhost/api/example` (Traefik strip‑prefix `/api` → still routes to `/example` in the app). - - The OpenAPI contract is at the repo root `openapi.yaml` (large, authoritative). Keep it synchronized with implemented routes and payloads. - -- Module layout and traits - - Domain modules live in `services/nginx/app/modules/*` and are heavily trait‑based. Tests often pull in precise files from here to avoid full app bootstrap. - - Example: subusers module (`modules/subusers/...`) provides permission node containers and helpers. The test `tests/subusers/SelfservePermissionInitTest.php` demonstrates overriding DB‑backed methods to inject permissions for fast, deterministic checks. - -- Config and globals - - `services/nginx/app/config.php` populates globals like `$CONFIG_DB`, `$REDIS_CONFIG`, etc., but only when `USE_ENV=true`. If you see tests throwing “Environment variables are not set”, run them inside Docker or set required envs for host PHP. - -- Code style - - `.editorconfig` at repo root configures formatting. Key PHP rules: - - Encoding: UTF‑8 with CRLF line endings. - - Indent: 4 spaces; continuation indent 4. - - Class brace style: next_line; function/method blank lines: 1. - - Import sorting: alphabetic; various alignment toggles are disabled. - - Match existing patterns (procedural scripts for tests; namespacing in app code; traits for cross‑cutting concerns). Avoid adding new frameworks for tests unless explicitly requested. - -- Composer / Autoload - - There is no Composer autoload configured for the app code in `composer.json`; tests include files directly. If you introduce autoloading, coordinate with Docker entrypoint behaviors and ensure zero‑downtime for existing scripts. - -- Performance & reliability hints for tests - - Prefer small, deterministic CLI scripts; inject dependencies and override I/O methods (as shown in `SelfservePermissionInitTest.php`). - - Avoid hitting external services (Stripe, e‑conomic, MinIO) from tests; instead, stub/override or provide fakes. - ---- - -### Quick Reference - -- Bring up minimal dev stack: - - `docker compose up -d traefik redis php1 caddy` - -- Run a fast, self‑contained test (host PHP): - - `php services/nginx/app/tests/subusers/SelfservePermissionInitTest.php` - -- Run a test inside Docker (env‑backed): - - `docker compose exec -T php1 php /var/www/html/tests/subusers/SelfservePermissionInitTest.php` +Canonical workflow reference: `.ai-workflow/workflow.md`. diff --git a/README.md b/README.md index 3b1ecfec..db36d3d2 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,32 @@ The API is accessible at: - `https://localhost` (using Traefik default cert) - `http(s)://localhost/api/` (proxied with `/api` prefix stripped) +### Test Gateway Container +To run a real PHP edge agent as a disposable Dockerized test gateway against the local stack, first create an install token from the edge gateway admin UI, then start the helper: + +```powershell +.\scripts\test-gateway.ps1 start --install-token +``` + +```bash +./scripts/test-gateway.sh start --install-token +``` + +The helper will: +- start the local compose dependencies if needed +- claim a gateway through `http://localhost/api/edge-agent/claim` +- write the generated config to `.tmp/test-gateway/test-gateway.json` +- build `services/edge-agent/Dockerfile.test-gateway` +- run the PHP agent container on the local compose network + +Useful follow-up commands: + +```powershell +.\scripts\test-gateway.ps1 logs +.\scripts\test-gateway.ps1 status +.\scripts\test-gateway.ps1 stop +``` + To start all services including multiple PHP workers and development tools (Jaeger, Portainer): ```powershell docker compose --profile dev up -d @@ -74,6 +100,25 @@ $env:RUN_INTEGRATION_TESTS='1' composer test:integration ``` +### Public Staging Edge-Gateway Smoke +`api.truckwash.io:4433` is the public staging ingress. For Edge Gateways v2, the router must serve the canonical artifacts from `services/nginx/app/resources/edge-gateway-agent`, not from the legacy `dist/agent.mjs` output or a separate runtime mount. + +To verify the public staging stack after a deploy, use a real installer token and run: + +```powershell +node .\scripts\staging-edge-gateway-smoke.mjs --install-token +``` + +```bash +node ./scripts/staging-edge-gateway-smoke.mjs --install-token +``` + +The smoke check fails unless all of these return `200` from the public domain: +- `/ping` +- `/edge-agent/artifacts/agent.php` +- `/edge-agent/artifacts/truckwash-edge-agent.service` +- `/edge-agent/install.sh?token=` + ### API Test Suite The `Api` suite exercises real HTTP endpoints instead of calling route handlers in-process. diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 1e415a4b..6cc669c2 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -101,7 +101,6 @@ services: EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} volumes: - ./services/nginx/app:/var/www/html - - ./services/edge-agent/dist:/services/edge-agent/dist:ro - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro - ./services/php/logs:/var/log/php @@ -123,7 +122,6 @@ services: EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} volumes: - ./services/nginx/app:/var/www/html - - ./services/edge-agent/dist:/services/edge-agent/dist:ro - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro n8n: diff --git a/docker-compose.yml b/docker-compose.yml index ccbcc3d2..52a27f92 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,16 +80,6 @@ services: retries: 10 start_period: 20s - edge-broker: - build: - context: . - dockerfile: services/edge-broker/Dockerfile - container_name: edge-broker - environment: - EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} - ports: - - "4300:4300" - caddy: image: caddy:2.7.6-alpine container_name: caddy @@ -159,7 +149,7 @@ services: depends_on: - php-staging volumes: - - ./services/nginx/staging:/var/www/html + - ./services/nginx/app:/var/www/html - ./services/caddy/Caddyfile-staging:/etc/caddy/Caddyfile:ro - ./services/caddy/logs-staging:/var/log/caddy labels: @@ -206,17 +196,13 @@ services: container_name: php1 depends_on: - redis - - edge-broker command: ["php-fpm"] env_file: - .env environment: AUTO_COMPOSER_INSTALL: "true" - EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300} - EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} volumes: - ./services/nginx/app:/var/www/html - - ./services/edge-agent/dist:/services/edge-agent/dist:ro - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro - ./services/php/logs:/var/log/php @@ -227,17 +213,13 @@ services: container_name: php2 depends_on: - redis - - edge-broker command: ["php-fpm"] env_file: - .env environment: AUTO_COMPOSER_INSTALL: "false" - EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300} - EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} volumes: - ./services/nginx/app:/var/www/html - - ./services/edge-agent/dist:/services/edge-agent/dist:ro - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro - ./services/php/logs:/var/log/php @@ -248,17 +230,13 @@ services: container_name: php3 depends_on: - redis - - edge-broker command: ["php-fpm"] env_file: - .env environment: AUTO_COMPOSER_INSTALL: "false" - EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300} - EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} volumes: - ./services/nginx/app:/var/www/html - - ./services/edge-agent/dist:/services/edge-agent/dist:ro - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro - ./services/php/logs:/var/log/php @@ -269,17 +247,13 @@ services: container_name: php4 depends_on: - redis - - edge-broker command: ["php-fpm"] env_file: - .env environment: AUTO_COMPOSER_INSTALL: "false" - EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300} - EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} volumes: - ./services/nginx/app:/var/www/html - - ./services/edge-agent/dist:/services/edge-agent/dist:ro - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro - ./services/php/logs:/var/log/php @@ -290,17 +264,13 @@ services: container_name: php5 depends_on: - redis - - edge-broker command: ["php-fpm"] env_file: - .env environment: AUTO_COMPOSER_INSTALL: "false" - EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300} - EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} volumes: - ./services/nginx/app:/var/www/html - - ./services/edge-agent/dist:/services/edge-agent/dist:ro - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro - ./services/php/logs:/var/log/php @@ -311,17 +281,13 @@ services: container_name: php-staging depends_on: - redis-staging - - edge-broker command: ["php-fpm"] env_file: - .env.staging environment: AUTO_COMPOSER_INSTALL: "false" - EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300} - EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} volumes: - - ./services/nginx/staging:/var/www/html - - ./services/edge-agent/dist:/services/edge-agent/dist:ro + - ./services/nginx/app:/var/www/html - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro - ./services/php/logs-staging:/var/log/php @@ -332,17 +298,13 @@ services: container_name: php-cron depends_on: - redis - - edge-broker command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"] env_file: - .env environment: AUTO_COMPOSER_INSTALL: "false" - EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300} - EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} volumes: - ./services/nginx/app:/var/www/html - - ./services/edge-agent/dist:/services/edge-agent/dist:ro - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro - ./services/php/logs:/var/log/php portainer: diff --git a/openapi.yaml b/openapi.yaml index f5f0122a..fc90718f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2195,6 +2195,17 @@ paths: two_factor_enabled: type: boolean description: Indicates if 2FA is enabled for this account + runtime_config: + type: object + properties: + economic: + type: object + properties: + transaction_draft_customer_number: + type: integer + nullable: true + additionalProperties: false + additionalProperties: true '400': $ref: '#/components/responses/BadRequest' '401': @@ -12031,12 +12042,13 @@ components: type: object properties: module: { type: string, enum: [economic] } - variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber] } + variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber, transactionDraftCustomerNumber] } type: { type: string, enum: [string, int] } value: oneOf: - type: string - type: integer + nullable: true required: [module, variable, type, value] RecaptchaConfigEntry: diff --git a/scripts/staging-edge-gateway-smoke.mjs b/scripts/staging-edge-gateway-smoke.mjs new file mode 100644 index 00000000..313231e8 --- /dev/null +++ b/scripts/staging-edge-gateway-smoke.mjs @@ -0,0 +1,138 @@ +import process from "node:process"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +export const DEFAULT_STAGING_BASE_URL = "https://api.truckwash.io:4433"; + +export function normalizeBaseUrl(url) { + return String(url || "").trim().replace(/\/+$/, ""); +} + +export function parseArgs(argv = process.argv.slice(2)) { + const options = { + baseUrl: DEFAULT_STAGING_BASE_URL, + installToken: "", + help: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const next = argv[index + 1]; + + switch (arg) { + case "--base-url": + options.baseUrl = String(next || "").trim() || DEFAULT_STAGING_BASE_URL; + index += 1; + break; + case "--install-token": + options.installToken = String(next || "").trim(); + index += 1; + break; + case "--help": + case "-h": + options.help = true; + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + return options; +} + +export function buildChecks(baseUrl, installToken) { + const normalizedBaseUrl = normalizeBaseUrl(baseUrl); + + return [ + { + name: "Ping", + url: `${normalizedBaseUrl}/ping`, + }, + { + name: "Agent PHP artifact", + url: `${normalizedBaseUrl}/edge-agent/artifacts/agent.php`, + }, + { + name: "Service unit artifact", + url: `${normalizedBaseUrl}/edge-agent/artifacts/truckwash-edge-agent.service`, + }, + { + name: "Installer script", + url: `${normalizedBaseUrl}/edge-agent/install.sh?token=${encodeURIComponent(installToken)}`, + }, + ]; +} + +function printUsage() { + process.stdout.write(`Usage: + node scripts/staging-edge-gateway-smoke.mjs --install-token [--base-url ] + +Options: + --install-token Real edge-gateway install token used to validate install.sh. + --base-url Public staging base URL. Default: ${DEFAULT_STAGING_BASE_URL} + --help Show this help text. +`); +} + +function previewBody(body, limit = 200) { + const normalized = String(body || "").replace(/\s+/g, " ").trim(); + if (normalized.length <= limit) { + return normalized; + } + return `${normalized.slice(0, limit)}...`; +} + +export async function runSmoke({ baseUrl, installToken }) { + if (String(installToken || "").trim() === "") { + throw new Error("Missing required --install-token value."); + } + + const checks = buildChecks(baseUrl, installToken); + const results = []; + + for (const check of checks) { + process.stdout.write(`[staging-smoke] GET ${check.url}\n`); + const response = await fetch(check.url); + const body = await response.text(); + const result = { + ...check, + status: response.status, + ok: response.ok, + bodyPreview: previewBody(body), + }; + results.push(result); + + if (!response.ok) { + throw new Error( + `${check.name} failed with HTTP ${response.status} at ${check.url}. ` + + `Body preview: ${result.bodyPreview || ""}` + ); + } + } + + return results; +} + +async function main() { + const options = parseArgs(); + + if (options.help) { + printUsage(); + return; + } + + const results = await runSmoke(options); + for (const result of results) { + process.stdout.write(`[staging-smoke] OK ${result.status} ${result.name}\n`); + } +} + +const isDirectExecution = process.argv[1] + && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; + +if (isDirectExecution) { + main().catch((error) => { + process.stderr.write(`[staging-smoke] ERROR: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/staging-edge-gateway-smoke.test.mjs b/scripts/staging-edge-gateway-smoke.test.mjs new file mode 100644 index 00000000..9fecc3f4 --- /dev/null +++ b/scripts/staging-edge-gateway-smoke.test.mjs @@ -0,0 +1,37 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + DEFAULT_STAGING_BASE_URL, + buildChecks, + normalizeBaseUrl, + parseArgs, +} from "./staging-edge-gateway-smoke.mjs"; + +test("normalizeBaseUrl strips trailing slashes", () => { + assert.equal(normalizeBaseUrl("https://api.truckwash.io:4433///"), DEFAULT_STAGING_BASE_URL); +}); + +test("parseArgs accepts base URL and install token", () => { + const options = parseArgs([ + "--base-url", + "https://staging.example.test/", + "--install-token", + "token-123", + ]); + + assert.equal(options.baseUrl, "https://staging.example.test/"); + assert.equal(options.installToken, "token-123"); + assert.equal(options.help, false); +}); + +test("buildChecks targets the public staging endpoints", () => { + const checks = buildChecks(DEFAULT_STAGING_BASE_URL, "abc 123"); + + assert.deepEqual(checks.map((check) => check.url), [ + "https://api.truckwash.io:4433/ping", + "https://api.truckwash.io:4433/edge-agent/artifacts/agent.php", + "https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-agent.service", + "https://api.truckwash.io:4433/edge-agent/install.sh?token=abc%20123", + ]); +}); diff --git a/scripts/sync-ai-workflow.mjs b/scripts/sync-ai-workflow.mjs new file mode 100644 index 00000000..08c86e87 --- /dev/null +++ b/scripts/sync-ai-workflow.mjs @@ -0,0 +1,414 @@ +#!/usr/bin/env node + +import fs from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const scriptSource = await fs.readFile(__filename, "utf8"); + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const workspaceRoot = path.resolve(args.root ?? path.join(__dirname, "..")); + const workflowPath = path.join(workspaceRoot, ".ai-workflow", "workflow.md"); + const manifestPath = path.join(workspaceRoot, ".ai-workflow", "manifest.json"); + + const workflow = await fs.readFile(workflowPath, "utf8"); + const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); + + validateManifest(manifest); + + const outputs = buildOutputs({ + manifest, + workflow, + scriptSource, + }); + + if (args.mode === "check") { + const drifted = await findDriftedOutputs(workspaceRoot, outputs); + if (drifted.length > 0) { + console.error("AI workflow outputs are out of sync:"); + for (const drift of drifted) { + console.error(`- ${drift}`); + } + process.exit(1); + } + + console.log("AI workflow outputs are in sync."); + return; + } + + const changed = await writeOutputs(workspaceRoot, outputs); + if (changed.length === 0) { + console.log("AI workflow outputs are already up to date."); + return; + } + + console.log("Updated AI workflow outputs:"); + for (const changedPath of changed) { + console.log(`- ${changedPath}`); + } +} + +function parseArgs(argv) { + const args = { + mode: null, + root: null, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === "--write") { + args.mode = "write"; + continue; + } + + if (arg === "--check") { + args.mode = "check"; + continue; + } + + if (arg === "--root") { + args.root = argv[index + 1]; + index += 1; + continue; + } + + throw new Error(`Unsupported argument: ${arg}`); + } + + if (!args.mode) { + throw new Error("Expected --write or --check."); + } + + return args; +} + +function validateManifest(manifest) { + const requiredKeys = ["projects", "assistants", "commands", "generated_outputs", "sync_targets"]; + for (const key of requiredKeys) { + if (!(key in manifest)) { + throw new Error(`Manifest is missing required key: ${key}`); + } + } + + const seenPaths = new Set(); + for (const output of manifest.generated_outputs) { + if (!output.path || !output.template) { + throw new Error("Each generated output must define path and template."); + } + + if (seenPaths.has(output.path)) { + throw new Error(`Duplicate generated output path: ${output.path}`); + } + + seenPaths.add(output.path); + } +} + +function buildOutputs(context) { + const outputs = []; + for (const output of context.manifest.generated_outputs) { + outputs.push({ + path: normalizeRelativePath(output.path), + content: renderTemplate(output, context), + }); + } + return outputs; +} + +function renderTemplate(output, context) { + const templateMap = { + codex_workspace_environment: () => renderWorkspaceCodexEnvironment(context.manifest), + codex_project_environment: () => renderProjectCodexEnvironment(context.manifest, output.project), + aiassistant_backend_tests_rule: () => + renderAiAssistantRule(context.manifest.projects["backend-php"].generated_content.aiassistant_tests_lines), + aiassistant_backend_routes_rule: () => + renderAiAssistantRule(context.manifest.projects["backend-php"].generated_content.aiassistant_routes_lines), + aiassistant_frontend_tests_rule: () => + renderAiAssistantRule(context.manifest.projects["front-end-vue"].generated_content.aiassistant_tests_lines), + junie_backend_guidelines: () => + renderJunieGuidelines(context.manifest.projects["backend-php"].generated_content.junie_lines), + junie_frontend_guidelines: () => + renderJunieGuidelines(context.manifest.projects["front-end-vue"].generated_content.junie_lines), + copilot_dispatcher_workflow: () => renderCopilotWorkflow(context.manifest.copilot), + workflow_snapshot: () => renderWorkflowSnapshot(context.workflow, output.project), + project_snapshot_manifest: () => renderProjectSnapshotManifest(context.manifest, output.project), + project_sync_script: () => context.scriptSource, + }; + + const renderer = templateMap[output.template]; + if (!renderer) { + throw new Error(`Unsupported template: ${output.template}`); + } + + return ensureTrailingNewline(renderer()); +} + +function renderWorkspaceCodexEnvironment(manifest) { + return renderCodexEnvironment({ + name: manifest.workspace.name, + setup: manifest.workspace.codex_environment.setup, + actions: manifest.workspace.codex_environment.actions, + manifest, + workspaceScoped: true, + }); +} + +function renderProjectCodexEnvironment(manifest, projectKey) { + const project = manifest.projects[projectKey]; + return renderCodexEnvironment({ + name: project.codex_environment.name, + setup: project.commands.setup, + actions: project.codex_environment.actions, + manifest, + projectKey, + workspaceScoped: false, + }); +} + +function renderCodexEnvironment({ name, setup, actions, manifest, projectKey = null, workspaceScoped }) { + const lines = [ + "# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY", + "version = 1", + `name = "${name}"`, + "", + ]; + + appendTomlCommandBlock(lines, "setup", setup); + + for (const action of actions) { + lines.push(""); + lines.push("[[actions]]"); + lines.push(`name = "${escapeTomlString(action.name)}"`); + lines.push(`icon = "${escapeTomlString(action.icon)}"`); + lines.push(`command = ${renderTomlMultiline(resolveActionCommand(action, manifest, projectKey, workspaceScoped))}`); + + if (action.platform) { + lines.push(`platform = "${escapeTomlString(action.platform)}"`); + } + } + + return lines.join("\n"); +} + +function appendTomlCommandBlock(lines, key, commandSpec = {}) { + lines.push(`[${key}]`); + lines.push(`script = ${renderTomlMultiline(commandSpec.default ?? "")}`); + + if (commandSpec.win32 !== undefined) { + lines.push(""); + lines.push(`[${key}.win32]`); + lines.push(`script = ${renderTomlMultiline(commandSpec.win32)}`); + } +} + +function resolveActionCommand(action, manifest, projectKey, workspaceScoped) { + if (action.literal_command) { + return action.literal_command.default ?? ""; + } + + if (!action.command_id) { + throw new Error(`Action ${action.name} is missing command_id or literal_command.`); + } + + const resolvedProjectKey = action.project ?? projectKey; + if (!resolvedProjectKey) { + throw new Error(`Action ${action.name} does not resolve to a project.`); + } + + const project = manifest.projects[resolvedProjectKey]; + const command = project.commands[action.command_id]?.default ?? ""; + + if (!workspaceScoped) { + return command; + } + + return joinCommands([`cd ${project.relative_root}`, command]); +} + +function renderAiAssistantRule(lines) { + return [ + "---", + "apply: always", + "---", + "", + "", + "", + ...lines, + ].join("\n"); +} + +function renderJunieGuidelines(lines) { + return [ + "", + "", + ...lines, + ].join("\n"); +} + +function renderCopilotWorkflow(copilot) { + const body = copilot.body_lines + .map((line) => line.replaceAll("{{ACTOR}}", "$ACTOR").replaceAll("{{TASK}}", "$TASK")) + .join("\n"); + + return [ + `name: ${copilot.workflow_name}`, + "on:", + " workflow_dispatch:", + " inputs:", + " task:", + ` description: '${copilot.input_description}'`, + " required: true", + " type: string", + "", + "jobs:", + " assign-task:", + " runs-on: ubuntu-latest", + " permissions:", + " issues: write", + " steps:", + " - name: Checkout repository", + " uses: actions/checkout@v4", + "", + " - name: Create GitHub Issue for Copilot", + " env:", + " GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}", + " TASK: ${{ github.event.inputs.task }}", + " ACTOR: ${{ github.actor }}", + " run: |", + " BODY=\"$(cat < ` ${line}`), + " EOF", + " )\"", + " gh issue create \\", + ` --title "${copilot.title_prefix} $TASK" \\`, + " --body \"$BODY\" \\", + ` --label "${copilot.issue_label}"`, + ].join("\n"); +} + +function renderWorkflowSnapshot(workflow, projectKey) { + return [ + ``, + "", + workflow.trimEnd(), + ].join("\n"); +} + +function renderProjectSnapshotManifest(manifest, projectKey) { + const snapshotManifest = buildProjectSnapshotManifest(manifest, projectKey); + return `${JSON.stringify(snapshotManifest, null, 2)}\n`; +} + +function buildProjectSnapshotManifest(manifest, projectKey) { + const project = JSON.parse(JSON.stringify(manifest.projects[projectKey])); + const prefix = `${manifest.projects[projectKey].relative_root}/`; + + project.relative_root = "."; + + const snapshotOutputs = manifest.generated_outputs + .filter((output) => output.project === projectKey) + .filter((output) => output.template !== "workflow_snapshot") + .filter((output) => output.template !== "project_snapshot_manifest") + .filter((output) => output.template !== "project_sync_script") + .map((output) => ({ + ...output, + path: normalizeRelativePath(output.path.slice(prefix.length)), + })); + + const snapshotManifest = { + "version": manifest.version, + "snapshot_of": projectKey, + "projects": { + [projectKey]: project, + }, + "assistants": manifest.assistants, + "commands": manifest.commands, + "generated_outputs": snapshotOutputs, + "sync_targets": { + [projectKey]: { + "source_root": ".", + "destination": manifest.sync_targets[projectKey]?.destination ?? "", + "supported_metadata_dirs": manifest.sync_targets[projectKey]?.supported_metadata_dirs ?? [], + }, + }, + }; + + if (projectKey === "backend-php" && manifest.copilot) { + snapshotManifest.copilot = manifest.copilot; + } + + if (projectKey === "front-end-vue" && manifest.unsupported) { + snapshotManifest.unsupported = manifest.unsupported; + } + + return snapshotManifest; +} + +async function findDriftedOutputs(workspaceRoot, outputs) { + const drifted = []; + for (const output of outputs) { + const absolutePath = path.join(workspaceRoot, output.path); + const currentContent = await readIfExists(absolutePath); + if (currentContent !== output.content) { + drifted.push(output.path); + } + } + return drifted; +} + +async function writeOutputs(workspaceRoot, outputs) { + const changed = []; + for (const output of outputs) { + const absolutePath = path.join(workspaceRoot, output.path); + const currentContent = await readIfExists(absolutePath); + if (currentContent === output.content) { + continue; + } + + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, output.content, "utf8"); + changed.push(output.path); + } + return changed; +} + +async function readIfExists(filePath) { + try { + return await fs.readFile(filePath, "utf8"); + } catch (error) { + if (error && error.code === "ENOENT") { + return null; + } + throw error; + } +} + +function joinCommands(commands) { + return commands.filter((command) => command && command.trim() !== "").join("\n"); +} + +function renderTomlMultiline(value) { + return `'''\n${value ?? ""}\n'''`; +} + +function escapeTomlString(value) { + return String(value).replaceAll("\\", "\\\\").replaceAll("\"", "\\\""); +} + +function normalizeRelativePath(filePath) { + return filePath.replaceAll("\\", "/"); +} + +function ensureTrailingNewline(value) { + return value.endsWith("\n") ? value : `${value}\n`; +} + +main().catch((error) => { + console.error(error.message); + process.exit(1); +}); diff --git a/scripts/test-gateway.mjs b/scripts/test-gateway.mjs new file mode 100644 index 00000000..6a70bf61 --- /dev/null +++ b/scripts/test-gateway.mjs @@ -0,0 +1,480 @@ +import { execFile as execFileCallback, spawn as spawnCallback } from "node:child_process"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFile = promisify(execFileCallback); + +export const DEFAULT_CONTAINER_NAME = "truckwash-test-gateway"; +export const DEFAULT_IMAGE_TAG = "truckwash-edge-agent:test-gateway"; +export const DEFAULT_CONFIG_FILE_NAME = "test-gateway.json"; +export const DEFAULT_HOST_API_URL = "http://localhost/api"; +export const DEFAULT_CONTAINER_API_URL = "http://caddy"; +export const DEFAULT_CONTAINER_BROKER_URL = "http://edge-broker:4300"; +export const DEFAULT_INSTALL_DIR = "/opt/truckwash-edge-agent"; +export const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15; +export const DEFAULT_INSTALLED_VERSION = "php-agent-v1"; +const DEFAULT_COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "caddy"]; + +function printUsage() { + process.stdout.write(`Usage: + node scripts/test-gateway.mjs start [--install-token ] [--container-name ] [--hostname ] + node scripts/test-gateway.mjs stop [--container-name ] + node scripts/test-gateway.mjs logs [--container-name ] [--tail ] + node scripts/test-gateway.mjs status [--container-name ] + +Options: + --install-token Claim a new edge gateway before starting the container. + --container-name Docker container name. Default: ${DEFAULT_CONTAINER_NAME} + --hostname Gateway hostname reported during claim and Docker run. + --host-api-url Host-reachable API URL for claim/health checks. Default: ${DEFAULT_HOST_API_URL} + --api-url Container-internal API URL. Default: ${DEFAULT_CONTAINER_API_URL} + --broker-url Container-internal broker URL. Default: ${DEFAULT_CONTAINER_BROKER_URL} + --config-dir Directory for generated config. Default: backend-php/.tmp/test-gateway + --image-tag Docker image tag. Default: ${DEFAULT_IMAGE_TAG} + --heartbeat-seconds Agent heartbeat interval. Default: ${DEFAULT_HEARTBEAT_INTERVAL_SECONDS} + --tail Log lines for the logs action. Default: 200 + --skip-compose-up Do not start the local Docker Compose stack before start. + --skip-build Do not rebuild the test gateway image before start. +`); +} + +export function resolveComposeProjectName(rootDir, env = process.env) { + const explicit = String(env.COMPOSE_PROJECT_NAME || "").trim(); + if (explicit !== "") { + return explicit; + } + + return path.basename(rootDir); +} + +export function resolveComposeNetworkName(rootDir, env = process.env) { + return `${resolveComposeProjectName(rootDir, env)}_default`; +} + +export function resolveConfigDirectory(rootDir, explicitDir = null) { + if (explicitDir) { + return path.resolve(rootDir, explicitDir); + } + + return path.join(rootDir, ".tmp", "test-gateway"); +} + +export function shouldClaimGateway(existingConfig = {}, installToken = "") { + if (String(installToken || "").trim() !== "") { + return true; + } + + return !(existingConfig.gatewayId && existingConfig.agentToken); +} + +export function buildGatewayConfig({ + existingConfig = {}, + claim = null, + apiUrl = DEFAULT_CONTAINER_API_URL, + brokerUrl = DEFAULT_CONTAINER_BROKER_URL, + hostname = DEFAULT_CONTAINER_NAME, + heartbeatIntervalSeconds = DEFAULT_HEARTBEAT_INTERVAL_SECONDS, +} = {}) { + const installedVersion = String(existingConfig.installedVersion || DEFAULT_INSTALLED_VERSION); + const targetVersion = String(existingConfig.targetVersion || installedVersion); + + return { + apiUrl, + brokerUrl, + gatewayId: claim?.gateway?.id ?? existingConfig.gatewayId ?? null, + agentToken: claim?.agent_token ?? existingConfig.agentToken ?? null, + hostname, + releaseChannel: claim?.release_channel ?? existingConfig.releaseChannel ?? "stable", + installedVersion, + targetVersion, + installDir: DEFAULT_INSTALL_DIR, + agentPath: `${DEFAULT_INSTALL_DIR}/agent.php`, + serviceUnitPath: `${DEFAULT_INSTALL_DIR}/truckwash-edge-agent.service`, + serviceName: String(existingConfig.serviceName || hostname || DEFAULT_CONTAINER_NAME), + restartMode: "spawn", + heartbeatIntervalSeconds, + commandPollTimeoutSeconds: Number(existingConfig.commandPollTimeoutSeconds || 20), + shellActionPollTimeoutSeconds: Number(existingConfig.shellActionPollTimeoutSeconds || 20), + commandPollRetryDelayMs: Number(existingConfig.commandPollRetryDelayMs || 1000), + shellActionPollRetryDelayMs: Number(existingConfig.shellActionPollRetryDelayMs || 1000), + brokerReconnectDelayMs: Number(existingConfig.brokerReconnectDelayMs || 1500), + }; +} + +export function parseArgs(argv = process.argv.slice(2)) { + const knownActions = new Set(["start", "stop", "logs", "status"]); + const [first = "", ...remaining] = argv; + const action = knownActions.has(first) ? first : "start"; + const rest = knownActions.has(first) ? remaining : argv; + const options = { + action, + containerName: DEFAULT_CONTAINER_NAME, + imageTag: DEFAULT_IMAGE_TAG, + hostApiUrl: DEFAULT_HOST_API_URL, + apiUrl: DEFAULT_CONTAINER_API_URL, + brokerUrl: DEFAULT_CONTAINER_BROKER_URL, + hostname: DEFAULT_CONTAINER_NAME, + configDir: null, + installToken: "", + heartbeatSeconds: DEFAULT_HEARTBEAT_INTERVAL_SECONDS, + tail: "200", + skipComposeUp: false, + skipBuild: false, + }; + + for (let index = 0; index < rest.length; index += 1) { + const arg = rest[index]; + const next = rest[index + 1]; + + switch (arg) { + case "--container-name": + options.containerName = String(next || "").trim() || DEFAULT_CONTAINER_NAME; + options.hostname = options.containerName; + index += 1; + break; + case "--hostname": + options.hostname = String(next || "").trim() || options.hostname; + index += 1; + break; + case "--image-tag": + options.imageTag = String(next || "").trim() || DEFAULT_IMAGE_TAG; + index += 1; + break; + case "--host-api-url": + options.hostApiUrl = String(next || "").trim() || DEFAULT_HOST_API_URL; + index += 1; + break; + case "--api-url": + options.apiUrl = String(next || "").trim() || DEFAULT_CONTAINER_API_URL; + index += 1; + break; + case "--broker-url": + options.brokerUrl = String(next || "").trim() || DEFAULT_CONTAINER_BROKER_URL; + index += 1; + break; + case "--config-dir": + options.configDir = String(next || "").trim() || null; + index += 1; + break; + case "--install-token": + options.installToken = String(next || "").trim(); + index += 1; + break; + case "--heartbeat-seconds": + options.heartbeatSeconds = Number(next || DEFAULT_HEARTBEAT_INTERVAL_SECONDS); + index += 1; + break; + case "--tail": + options.tail = String(next || "200").trim() || "200"; + index += 1; + break; + case "--skip-compose-up": + options.skipComposeUp = true; + break; + case "--skip-build": + options.skipBuild = true; + break; + case "--help": + case "-h": + options.help = true; + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + return options; +} + +async function runCommand(command, args, { cwd, allowFailure = false, stdio = "pipe" } = {}) { + if (stdio === "inherit") { + await new Promise((resolve, reject) => { + const child = spawnCallback(command, args, { + cwd, + stdio: "inherit", + windowsHide: true, + }); + + child.on("exit", (code) => { + if (code === 0 || allowFailure) { + resolve(); + return; + } + reject(new Error(`${command} ${args.join(" ")} failed with exit code ${code}`)); + }); + child.on("error", reject); + }); + return { stdout: "", stderr: "" }; + } + + try { + return await execFile(command, args, { + cwd, + windowsHide: true, + encoding: "utf8", + }); + } catch (error) { + if (allowFailure) { + return { + stdout: error.stdout || "", + stderr: error.stderr || "", + code: error.code || 1, + }; + } + throw error; + } +} + +async function ensureComposeServices(rootDir, skipComposeUp) { + if (skipComposeUp) { + return; + } + + await runCommand("docker", ["compose", "up", "-d", ...DEFAULT_COMPOSE_SERVICES], { + cwd: rootDir, + stdio: "inherit", + }); +} + +function normalizeBaseUrl(url) { + return String(url || "").replace(/\/+$/, ""); +} + +async function waitForApiReady(hostApiUrl, attempts = 60) { + const baseUrl = normalizeBaseUrl(hostApiUrl); + let lastError = null; + + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const response = await fetch(`${baseUrl}/ping`); + if (response.ok) { + return; + } + lastError = new Error(`HTTP ${response.status}`); + } catch (error) { + lastError = error; + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + throw new Error(`API did not become ready at ${baseUrl}/ping: ${lastError instanceof Error ? lastError.message : String(lastError)}`); +} + +async function claimGateway(hostApiUrl, installToken, hostname) { + const response = await fetch(`${normalizeBaseUrl(hostApiUrl)}/edge-agent/claim`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + token: installToken, + hostname, + installed_version: DEFAULT_INSTALLED_VERSION, + metadata: { + source: "docker-test-gateway-script", + }, + }), + }); + + const json = await response.json(); + if (!response.ok) { + throw new Error(json?.data?.message || json?.message || `HTTP ${response.status}`); + } + + return json.data ?? json; +} + +async function readConfig(configFilePath) { + try { + const raw = await fs.readFile(configFilePath, "utf8"); + return JSON.parse(raw); + } catch (error) { + if (error?.code === "ENOENT") { + return {}; + } + throw error; + } +} + +async function writeConfig(configFilePath, config) { + await fs.mkdir(path.dirname(configFilePath), { recursive: true }); + await fs.writeFile(configFilePath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); +} + +function toDockerMountPath(hostPath) { + return hostPath.replace(/\\/g, "/"); +} + +async function ensureImage(rootDir, imageTag, skipBuild) { + if (skipBuild) { + return; + } + + await runCommand( + "docker", + ["build", "-t", imageTag, "-f", "services/edge-agent/Dockerfile.test-gateway", "."], + { + cwd: rootDir, + stdio: "inherit", + } + ); +} + +async function removeContainer(containerName) { + await runCommand("docker", ["rm", "-f", containerName], { allowFailure: true }); +} + +async function startContainer({ + rootDir, + imageTag, + containerName, + hostname, + configDir, +}) { + const networkName = resolveComposeNetworkName(rootDir); + const mountedConfigDir = toDockerMountPath(configDir); + + await removeContainer(containerName); + await runCommand( + "docker", + [ + "run", + "-d", + "--name", + containerName, + "--hostname", + hostname, + "--restart", + "unless-stopped", + "--network", + networkName, + "-v", + `${mountedConfigDir}:/config`, + imageTag, + "--config", + `/config/${DEFAULT_CONFIG_FILE_NAME}`, + ], + { + cwd: rootDir, + stdio: "inherit", + } + ); +} + +async function printStatus({ imageTag, configDir, containerName }) { + const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME); + const config = await readConfig(configFilePath); + const inspection = await runCommand("docker", ["inspect", containerName], { + allowFailure: true, + }); + + let container = null; + if (inspection.stdout && String(inspection.stdout).trim() !== "") { + try { + const decoded = JSON.parse(inspection.stdout); + container = Array.isArray(decoded) ? decoded[0] ?? null : null; + } catch { + container = null; + } + } + + process.stdout.write(`${JSON.stringify({ + containerName, + configFilePath, + gatewayId: config.gatewayId ?? null, + installDir: config.installDir ?? null, + agentPath: config.agentPath ?? null, + serviceUnitPath: config.serviceUnitPath ?? null, + containerStatus: container?.State?.Status ?? "missing", + running: Boolean(container?.State?.Running), + image: container?.Config?.Image ?? imageTag, + }, null, 2)}\n`); +} + +async function main() { + const options = parseArgs(); + if (options.help) { + printUsage(); + return; + } + + const scriptPath = fileURLToPath(import.meta.url); + const rootDir = path.resolve(path.dirname(scriptPath), ".."); + const configDir = resolveConfigDirectory(rootDir, options.configDir); + const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME); + + switch (options.action) { + case "start": { + await ensureComposeServices(rootDir, options.skipComposeUp); + await waitForApiReady(options.hostApiUrl); + await ensureImage(rootDir, options.imageTag, options.skipBuild); + + const existingConfig = await readConfig(configFilePath); + let claim = null; + if (shouldClaimGateway(existingConfig, options.installToken)) { + if (String(options.installToken || "").trim() === "") { + throw new Error(`An install token is required to create the first test gateway config at ${configFilePath}`); + } + claim = await claimGateway(options.hostApiUrl, options.installToken, options.hostname); + } + + const config = buildGatewayConfig({ + existingConfig, + claim, + apiUrl: options.apiUrl, + brokerUrl: options.brokerUrl, + hostname: options.hostname, + heartbeatIntervalSeconds: Number.isFinite(options.heartbeatSeconds) && options.heartbeatSeconds > 0 + ? Math.round(options.heartbeatSeconds) + : DEFAULT_HEARTBEAT_INTERVAL_SECONDS, + }); + + await writeConfig(configFilePath, config); + await startContainer({ + rootDir, + imageTag: options.imageTag, + containerName: options.containerName, + hostname: options.hostname, + configDir, + }); + + process.stdout.write(`Test gateway container started. +Container: ${options.containerName} +Config: ${configFilePath} +Gateway ID: ${config.gatewayId ?? "unclaimed"} +`); + return; + } + case "stop": + await removeContainer(options.containerName); + process.stdout.write(`Removed container ${options.containerName}\n`); + return; + case "logs": + await runCommand("docker", ["logs", "-f", "--tail", options.tail, options.containerName], { + stdio: "inherit", + }); + return; + case "status": + await printStatus({ + imageTag: options.imageTag, + configDir, + containerName: options.containerName, + }); + return; + default: + throw new Error(`Unsupported action: ${options.action}`); + } +} + +const currentFilePath = fileURLToPath(import.meta.url); +const invokedScript = process.argv[1] ? path.resolve(process.argv[1]) : ""; +if (invokedScript === path.resolve(currentFilePath)) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/test-gateway.ps1 b/scripts/test-gateway.ps1 new file mode 100644 index 00000000..ebddf878 --- /dev/null +++ b/scripts/test-gateway.ps1 @@ -0,0 +1,6 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +& node (Join-Path $scriptDir "test-gateway.mjs") @args +exit $LASTEXITCODE diff --git a/scripts/test-gateway.sh b/scripts/test-gateway.sh new file mode 100644 index 00000000..a0f06518 --- /dev/null +++ b/scripts/test-gateway.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +node "$SCRIPT_DIR/test-gateway.mjs" "$@" diff --git a/scripts/test-gateway.test.mjs b/scripts/test-gateway.test.mjs new file mode 100644 index 00000000..fe59583b --- /dev/null +++ b/scripts/test-gateway.test.mjs @@ -0,0 +1,91 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + DEFAULT_CONTAINER_NAME, + DEFAULT_CONTAINER_API_URL, + DEFAULT_CONTAINER_BROKER_URL, + DEFAULT_HEARTBEAT_INTERVAL_SECONDS, + buildGatewayConfig, + parseArgs, + resolveComposeNetworkName, + resolveComposeProjectName, + resolveConfigDirectory, + shouldClaimGateway, +} from "./test-gateway.mjs"; + +test("resolveComposeProjectName prefers COMPOSE_PROJECT_NAME", () => { + assert.equal( + resolveComposeProjectName("C:/Users/test/backend-php", { COMPOSE_PROJECT_NAME: "custom-stack" }), + "custom-stack" + ); +}); + +test("resolveComposeProjectName falls back to backend directory name", () => { + assert.equal(resolveComposeProjectName("C:/Users/test/backend-php", {}), "backend-php"); +}); + +test("resolveComposeNetworkName derives the default compose network", () => { + assert.equal( + resolveComposeNetworkName("C:/Users/test/backend-php", { COMPOSE_PROJECT_NAME: "custom-stack" }), + "custom-stack_default" + ); +}); + +test("resolveConfigDirectory uses the default temp folder when none is provided", () => { + assert.equal( + resolveConfigDirectory("C:/Users/test/backend-php"), + "C:\\Users\\test\\backend-php\\.tmp\\test-gateway" + ); +}); + +test("shouldClaimGateway requires a token when no credentials are present", () => { + assert.equal(shouldClaimGateway({}, ""), true); + assert.equal(shouldClaimGateway({ gatewayId: 12, agentToken: "secret" }, ""), false); + assert.equal(shouldClaimGateway({ gatewayId: 12, agentToken: "secret" }, "fresh-token"), true); +}); + +test("parseArgs accepts help without an explicit action", () => { + const options = parseArgs(["--help"]); + + assert.equal(options.action, "start"); + assert.equal(options.help, true); +}); + +test("buildGatewayConfig applies defaults for a dockerized gateway", () => { + const config = buildGatewayConfig({}); + + assert.equal(config.apiUrl, DEFAULT_CONTAINER_API_URL); + assert.equal(config.brokerUrl, DEFAULT_CONTAINER_BROKER_URL); + assert.equal(config.hostname, DEFAULT_CONTAINER_NAME); + assert.equal(config.restartMode, "spawn"); + assert.equal(config.heartbeatIntervalSeconds, DEFAULT_HEARTBEAT_INTERVAL_SECONDS); + assert.equal(config.installDir, "/opt/truckwash-edge-agent"); + assert.equal(config.agentPath, "/opt/truckwash-edge-agent/agent.php"); + assert.equal(config.serviceUnitPath, "/opt/truckwash-edge-agent/truckwash-edge-agent.service"); + assert.equal(config.serviceName, DEFAULT_CONTAINER_NAME); +}); + +test("buildGatewayConfig folds claim response into the generated config", () => { + const config = buildGatewayConfig({ + existingConfig: { + installedVersion: "0.9.0", + targetVersion: "0.9.1", + }, + hostname: "gw-docker-1", + claim: { + gateway: { id: 44 }, + agent_token: "agent-secret", + release_channel: "stable", + }, + heartbeatIntervalSeconds: 7, + }); + + assert.equal(config.gatewayId, 44); + assert.equal(config.agentToken, "agent-secret"); + assert.equal(config.releaseChannel, "stable"); + assert.equal(config.hostname, "gw-docker-1"); + assert.equal(config.installedVersion, "0.9.0"); + assert.equal(config.targetVersion, "0.9.1"); + assert.equal(config.heartbeatIntervalSeconds, 7); +}); diff --git a/services/edge-agent/Dockerfile.test-gateway b/services/edge-agent/Dockerfile.test-gateway new file mode 100644 index 00000000..2f81fc4d --- /dev/null +++ b/services/edge-agent/Dockerfile.test-gateway @@ -0,0 +1,16 @@ +FROM php:8.2-cli + +WORKDIR /opt/truckwash-edge-agent + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + libcurl4-openssl-dev \ + ca-certificates; \ + docker-php-ext-install curl; \ + rm -rf /var/lib/apt/lists/* + +COPY services/nginx/app/resources/edge-gateway-agent/ ./ + +ENTRYPOINT ["php", "/opt/truckwash-edge-agent/agent.php"] +CMD ["--config", "/config/test-gateway.json"] diff --git a/services/edge-agent/dist/agent.mjs b/services/edge-agent/dist/agent.mjs index 690b7a71..cb3cd5cf 100644 --- a/services/edge-agent/dist/agent.mjs +++ b/services/edge-agent/dist/agent.mjs @@ -137,7 +137,6 @@ function buildTransportHeartbeatState(brokerState = {}) { status: "ONLINE", metadata: { command_transport: brokerConnected ? "BROKER_FAST_PATH" : "API_POLLING", - shell_transport: "API_POLLING", broker_connected: brokerConnected, broker_url: brokerState.url || null, broker_last_error: brokerState.lastError || null, @@ -1763,20 +1762,21 @@ export async function startAgent({ config.configPath = configPath; const intervalMs = Number(config.heartbeatIntervalSeconds || 15) * 1000; const commandPollTimeoutSeconds = Number(config.commandPollTimeoutSeconds || 20); - const shellActionPollTimeoutSeconds = Number(config.shellActionPollTimeoutSeconds || 20); const commandPollRetryDelayMs = Number(config.commandPollRetryDelayMs || 1000); - const shellActionPollRetryDelayMs = Number(config.shellActionPollRetryDelayMs || 1000); const brokerReconnectDelayMs = Number(config.brokerReconnectDelayMs || DEFAULT_BROKER_RECONNECT_DELAY_MS); let stopped = false; let cpuSnapshot = null; let lastHeartbeatLatencyMs = null; - const shellEventPublisher = createShellEventPublisher(config, fetchImpl); + void createShellBridgeImpl; let brokerBridge = null; - const shell = createShellBridgeImpl((message) => { - shellEventPublisher.publish(message); - brokerBridge?.send(message); - }); + const shell = { + open: async () => {}, + input: () => {}, + resize: () => {}, + close: () => {}, + dispose: () => {}, + }; brokerBridge = createBrokerBridge({ config, shell, @@ -1841,32 +1841,8 @@ export async function startAgent({ } }; - const runShellActionPollLoop = async () => { - while (!stopped) { - try { - const action = await pollShellActionJob(config, fetchImpl, shellActionPollTimeoutSeconds); - if (stopped) { - break; - } - - if (!action) { - continue; - } - - await processPolledShellAction(config, action, shell, fetchImpl); - } catch { - if (stopped) { - break; - } - - await new Promise((resolve) => setTimeout(resolve, shellActionPollRetryDelayMs)); - } - } - }; - await sendTransportHeartbeat(); const commandPollPromise = runCommandPollLoop(); - const shellActionPollPromise = runShellActionPollLoop(); const timer = setInterval(() => { sendTransportHeartbeat().catch(() => {}); @@ -1878,8 +1854,7 @@ export async function startAgent({ clearInterval(timer); brokerBridge?.stop(); shell.dispose(); - await shellEventPublisher.drain().catch(() => {}); - await Promise.allSettled([commandPollPromise, shellActionPollPromise]); + await Promise.allSettled([commandPollPromise]); }; return { diff --git a/services/nginx/app/build/logs/api-server.err.log b/services/nginx/app/build/logs/api-server.err.log index 196b5efa..569fad1f 100644 --- a/services/nginx/app/build/logs/api-server.err.log +++ b/services/nginx/app/build/logs/api-server.err.log @@ -6347,3 +6347,3505 @@ [Tue Apr 14 18:11:32 2026] 127.0.0.1:60036 Accepted [Tue Apr 14 18:11:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) [Tue Apr 14 18:11:33 2026] 127.0.0.1:60036 Closing +[Mon Apr 20 10:36:46 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Mon Apr 20 10:36:46 2026] 127.0.0.1:51922 Accepted +[Mon Apr 20 10:36:48 2026] 127.0.0.1:51922 Closing +[Mon Apr 20 10:36:48 2026] 127.0.0.1:51924 Accepted +[Mon Apr 20 10:36:50 2026] 127.0.0.1:51924 Closing +[Mon Apr 20 10:36:52 2026] 127.0.0.1:51928 Accepted +[Mon Apr 20 10:36:53 2026] 127.0.0.1:51928 Closing +[Mon Apr 20 10:36:53 2026] 127.0.0.1:51930 Accepted +[Mon Apr 20 10:36:54 2026] 127.0.0.1:51930 Closing +[Mon Apr 20 10:36:57 2026] 127.0.0.1:52668 Accepted +[Mon Apr 20 10:36:58 2026] 127.0.0.1:52668 Closing +[Mon Apr 20 10:37:01 2026] 127.0.0.1:52684 Accepted +[Mon Apr 20 10:37:02 2026] 127.0.0.1:52684 Closing +[Mon Apr 20 10:37:03 2026] 127.0.0.1:52694 Accepted +[Mon Apr 20 10:37:04 2026] 127.0.0.1:52694 Closing +[Mon Apr 20 10:37:04 2026] 127.0.0.1:52698 Accepted +[Mon Apr 20 10:37:05 2026] 127.0.0.1:52698 Closing +[Mon Apr 20 10:37:05 2026] 127.0.0.1:39668 Accepted +[Mon Apr 20 10:37:06 2026] 127.0.0.1:39668 Closing +[Mon Apr 20 10:37:08 2026] 127.0.0.1:39672 Accepted +[Mon Apr 20 10:37:10 2026] 127.0.0.1:39672 Closing +[Mon Apr 20 10:37:10 2026] 127.0.0.1:39682 Accepted +[Mon Apr 20 10:37:11 2026] 127.0.0.1:39682 Closing +[Mon Apr 20 10:37:11 2026] 127.0.0.1:39686 Accepted +[Mon Apr 20 10:37:12 2026] 127.0.0.1:39686 Closing +[Mon Apr 20 10:37:14 2026] 127.0.0.1:39698 Accepted +[Mon Apr 20 10:37:15 2026] 127.0.0.1:39698 Closing +[Mon Apr 20 10:37:16 2026] 127.0.0.1:41538 Accepted +[Mon Apr 20 10:37:19 2026] 127.0.0.1:41538 Closing +[Mon Apr 20 10:37:26 2026] 127.0.0.1:52864 Accepted +[Mon Apr 20 10:37:30 2026] 127.0.0.1:52864 Closing +[Mon Apr 20 10:37:34 2026] 127.0.0.1:60392 Accepted +[Mon Apr 20 10:37:34 2026] 127.0.0.1:60392 Closing +[Mon Apr 20 10:37:34 2026] 127.0.0.1:60398 Accepted +[Mon Apr 20 10:37:36 2026] 127.0.0.1:60398 Closing +[Mon Apr 20 10:37:39 2026] 127.0.0.1:60400 Accepted +[Mon Apr 20 10:37:43 2026] 127.0.0.1:60400 Closing +[Mon Apr 20 10:37:47 2026] 127.0.0.1:44546 Accepted +[Mon Apr 20 10:37:48 2026] 127.0.0.1:44546 Closing +[Mon Apr 20 10:37:48 2026] 127.0.0.1:44556 Accepted +[Mon Apr 20 10:37:50 2026] 127.0.0.1:44556 Closing +[Mon Apr 20 10:37:50 2026] 127.0.0.1:44564 Accepted +[Mon Apr 20 10:37:51 2026] 127.0.0.1:44564 Closing +[Mon Apr 20 10:37:51 2026] 127.0.0.1:44578 Accepted +[Mon Apr 20 10:37:53 2026] 127.0.0.1:44578 Closing +[Mon Apr 20 10:37:57 2026] 127.0.0.1:53556 Accepted +[Mon Apr 20 10:38:00 2026] 127.0.0.1:53556 Closing +[Mon Apr 20 10:38:07 2026] 127.0.0.1:36794 Accepted +[Mon Apr 20 10:38:10 2026] 127.0.0.1:36794 Closing +[Mon Apr 20 10:38:10 2026] 127.0.0.1:36804 Accepted +[Mon Apr 20 10:38:12 2026] 127.0.0.1:36804 Closing +[Mon Apr 20 10:38:20 2026] 127.0.0.1:32954 Accepted +[Mon Apr 20 10:38:22 2026] 127.0.0.1:32954 Closing +[Mon Apr 20 10:38:22 2026] 127.0.0.1:32956 Accepted +[Mon Apr 20 10:38:24 2026] 127.0.0.1:32956 Closing +[Mon Apr 20 10:38:24 2026] 127.0.0.1:51972 Accepted +[Mon Apr 20 10:38:25 2026] 127.0.0.1:51972 Closing +[Mon Apr 20 10:38:25 2026] 127.0.0.1:51988 Accepted +[Mon Apr 20 10:38:27 2026] 127.0.0.1:51988 Closing +[Mon Apr 20 10:38:27 2026] 127.0.0.1:51998 Accepted +[Mon Apr 20 10:38:27 2026] 127.0.0.1:51998 Closing +[Mon Apr 20 10:38:27 2026] 127.0.0.1:52012 Accepted +[Mon Apr 20 10:38:29 2026] 127.0.0.1:52012 Closing +[Mon Apr 20 10:38:34 2026] 127.0.0.1:40234 Accepted +[Mon Apr 20 10:38:36 2026] 127.0.0.1:40234 Closing +[Mon Apr 20 10:38:36 2026] 127.0.0.1:40250 Accepted +[Mon Apr 20 10:38:37 2026] 127.0.0.1:40250 Closing +[Mon Apr 20 10:38:40 2026] 127.0.0.1:40256 Accepted +[Mon Apr 20 10:38:44 2026] 127.0.0.1:40256 Closing +[Mon Apr 20 10:38:51 2026] 127.0.0.1:50848 Accepted +[Mon Apr 20 10:38:53 2026] 127.0.0.1:50848 Closing +[Mon Apr 20 10:38:53 2026] 127.0.0.1:50856 Accepted +[Mon Apr 20 10:38:55 2026] 127.0.0.1:50856 Closing +[Mon Apr 20 10:38:55 2026] 127.0.0.1:50870 Accepted +[Mon Apr 20 10:38:55 2026] 127.0.0.1:50870 Closing +[Mon Apr 20 10:38:55 2026] 127.0.0.1:50872 Accepted +[Mon Apr 20 10:38:57 2026] 127.0.0.1:50872 Closing +[Mon Apr 20 10:38:57 2026] 127.0.0.1:50874 Accepted +[Mon Apr 20 10:38:59 2026] 127.0.0.1:50874 Closing +[Mon Apr 20 10:38:59 2026] 127.0.0.1:50888 Accepted +[Mon Apr 20 10:39:01 2026] 127.0.0.1:50888 Closing +[Mon Apr 20 10:39:06 2026] 127.0.0.1:44640 Accepted +[Mon Apr 20 10:39:07 2026] 127.0.0.1:44640 Closing +[Mon Apr 20 10:39:10 2026] 127.0.0.1:42148 Accepted +[Mon Apr 20 10:39:12 2026] 127.0.0.1:42148 Closing +[Mon Apr 20 10:39:12 2026] 127.0.0.1:42156 Accepted +[Mon Apr 20 10:39:13 2026] 127.0.0.1:42156 Closing +[Mon Apr 20 10:39:18 2026] 127.0.0.1:42164 Accepted +[Mon Apr 20 10:39:21 2026] 127.0.0.1:42164 Closing +[Mon Apr 20 10:39:24 2026] 127.0.0.1:37084 Accepted +[Mon Apr 20 10:39:26 2026] 127.0.0.1:37084 Closing +[Mon Apr 20 10:39:26 2026] 127.0.0.1:37094 Accepted +[Mon Apr 20 10:39:27 2026] 127.0.0.1:37094 Closing +[Mon Apr 20 10:39:29 2026] 127.0.0.1:43890 Accepted +[Mon Apr 20 10:39:31 2026] 127.0.0.1:43890 Closing +[Mon Apr 20 10:39:34 2026] 127.0.0.1:43906 Accepted +[Mon Apr 20 10:39:36 2026] 127.0.0.1:43906 Closing +[Mon Apr 20 10:39:36 2026] 127.0.0.1:43910 Accepted +[Mon Apr 20 10:39:38 2026] 127.0.0.1:43910 Closing +[Mon Apr 20 10:39:38 2026] 127.0.0.1:43914 Accepted +[Mon Apr 20 10:39:39 2026] 127.0.0.1:43914 Closing +[Mon Apr 20 10:39:39 2026] 127.0.0.1:37710 Accepted +[Mon Apr 20 10:39:40 2026] 127.0.0.1:37710 Closing +[Mon Apr 20 10:39:43 2026] 127.0.0.1:37720 Accepted +[Mon Apr 20 10:39:45 2026] 127.0.0.1:37720 Closing +[Mon Apr 20 10:39:51 2026] 127.0.0.1:49542 Accepted +[Mon Apr 20 10:39:53 2026] 127.0.0.1:49542 Closing +[Mon Apr 20 10:39:56 2026] 127.0.0.1:49554 Accepted +[Mon Apr 20 10:39:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:39:56 2026] 127.0.0.1:49554 Closing +[Mon Apr 20 10:39:56 2026] 127.0.0.1:39002 Accepted +[Mon Apr 20 10:39:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:39:57 2026] 127.0.0.1:39002 Closing +[Mon Apr 20 10:39:57 2026] 127.0.0.1:39018 Accepted +[Mon Apr 20 10:39:58 2026] 127.0.0.1:39018 Closing +[Mon Apr 20 10:39:58 2026] 127.0.0.1:39020 Accepted +[Mon Apr 20 10:40:00 2026] 127.0.0.1:39020 Closing +[Mon Apr 20 10:40:21 2026] 127.0.0.1:37312 Accepted +[Mon Apr 20 10:40:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:40:22 2026] 127.0.0.1:37312 Closing +[Mon Apr 20 10:40:22 2026] 127.0.0.1:37316 Accepted +[Mon Apr 20 10:40:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:40:23 2026] 127.0.0.1:37316 Closing +[Mon Apr 20 10:40:23 2026] 127.0.0.1:37332 Accepted +[Mon Apr 20 10:40:25 2026] 127.0.0.1:37332 Closing +[Mon Apr 20 10:40:27 2026] 127.0.0.1:59592 Accepted +[Mon Apr 20 10:40:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:40:28 2026] 127.0.0.1:59592 Closing +[Mon Apr 20 10:40:28 2026] 127.0.0.1:59606 Accepted +[Mon Apr 20 10:40:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:40:29 2026] 127.0.0.1:59606 Closing +[Mon Apr 20 10:40:29 2026] 127.0.0.1:59614 Accepted +[Mon Apr 20 10:40:31 2026] 127.0.0.1:59614 Closing +[Mon Apr 20 10:40:31 2026] 127.0.0.1:59622 Accepted +[Mon Apr 20 10:40:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:40:32 2026] 127.0.0.1:59622 Closing +[Mon Apr 20 10:40:32 2026] 127.0.0.1:59638 Accepted +[Mon Apr 20 10:40:33 2026] 127.0.0.1:59638 Closing +[Mon Apr 20 10:40:33 2026] 127.0.0.1:59644 Accepted +[Mon Apr 20 10:40:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:40:34 2026] 127.0.0.1:59644 Closing +[Mon Apr 20 10:40:34 2026] 127.0.0.1:59646 Accepted +[Mon Apr 20 10:40:35 2026] 127.0.0.1:59646 Closing +[Mon Apr 20 10:40:35 2026] 127.0.0.1:47540 Accepted +[Mon Apr 20 10:40:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:40:36 2026] 127.0.0.1:47540 Closing +[Mon Apr 20 10:40:36 2026] 127.0.0.1:47546 Accepted +[Mon Apr 20 10:40:37 2026] 127.0.0.1:47546 Closing +[Mon Apr 20 10:41:12 2026] 127.0.0.1:59176 Accepted +[Mon Apr 20 10:41:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:13 2026] 127.0.0.1:59176 Closing +[Mon Apr 20 10:41:13 2026] 127.0.0.1:59184 Accepted +[Mon Apr 20 10:41:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:14 2026] 127.0.0.1:59184 Closing +[Mon Apr 20 10:41:14 2026] 127.0.0.1:60322 Accepted +[Mon Apr 20 10:41:15 2026] 127.0.0.1:60322 Closing +[Mon Apr 20 10:41:18 2026] 127.0.0.1:60334 Accepted +[Mon Apr 20 10:41:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:18 2026] 127.0.0.1:60334 Closing +[Mon Apr 20 10:41:19 2026] 127.0.0.1:60348 Accepted +[Mon Apr 20 10:41:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:19 2026] 127.0.0.1:60348 Closing +[Mon Apr 20 10:41:19 2026] 127.0.0.1:60362 Accepted +[Mon Apr 20 10:41:20 2026] 127.0.0.1:60362 Closing +[Mon Apr 20 10:41:20 2026] 127.0.0.1:60364 Accepted +[Mon Apr 20 10:41:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:20 2026] 127.0.0.1:60364 Closing +[Mon Apr 20 10:41:20 2026] 127.0.0.1:60374 Accepted +[Mon Apr 20 10:41:22 2026] 127.0.0.1:60374 Closing +[Mon Apr 20 10:41:22 2026] 127.0.0.1:60388 Accepted +[Mon Apr 20 10:41:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:22 2026] 127.0.0.1:60388 Closing +[Mon Apr 20 10:41:22 2026] 127.0.0.1:58906 Accepted +[Mon Apr 20 10:41:24 2026] 127.0.0.1:58906 Closing +[Mon Apr 20 10:41:24 2026] 127.0.0.1:58912 Accepted +[Mon Apr 20 10:41:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:24 2026] 127.0.0.1:58912 Closing +[Mon Apr 20 10:41:24 2026] 127.0.0.1:58922 Accepted +[Mon Apr 20 10:41:26 2026] 127.0.0.1:58922 Closing +[Mon Apr 20 10:41:28 2026] 127.0.0.1:58938 Accepted +[Mon Apr 20 10:41:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:28 2026] 127.0.0.1:58938 Closing +[Mon Apr 20 10:41:29 2026] 127.0.0.1:58952 Accepted +[Mon Apr 20 10:41:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:30 2026] 127.0.0.1:58952 Closing +[Mon Apr 20 10:41:30 2026] 127.0.0.1:58956 Accepted +[Mon Apr 20 10:41:32 2026] 127.0.0.1:58956 Closing +[Mon Apr 20 10:41:37 2026] 127.0.0.1:51662 Accepted +[Mon Apr 20 10:41:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:38 2026] 127.0.0.1:51662 Closing +[Mon Apr 20 10:41:38 2026] 127.0.0.1:51672 Accepted +[Mon Apr 20 10:41:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:39 2026] 127.0.0.1:51672 Closing +[Mon Apr 20 10:41:39 2026] 127.0.0.1:51680 Accepted +[Mon Apr 20 10:41:41 2026] 127.0.0.1:51680 Closing +[Mon Apr 20 10:41:46 2026] 127.0.0.1:37358 Accepted +[Mon Apr 20 10:41:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:46 2026] 127.0.0.1:37358 Closing +[Mon Apr 20 10:41:47 2026] 127.0.0.1:37368 Accepted +[Mon Apr 20 10:41:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:41:47 2026] 127.0.0.1:37368 Closing +[Mon Apr 20 10:41:47 2026] 127.0.0.1:37378 Accepted +[Mon Apr 20 10:41:48 2026] 127.0.0.1:37378 Closing +[Mon Apr 20 10:42:06 2026] 127.0.0.1:52434 Accepted +[Mon Apr 20 10:42:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:06 2026] 127.0.0.1:52434 Closing +[Mon Apr 20 10:42:06 2026] 127.0.0.1:52448 Accepted +[Mon Apr 20 10:42:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:07 2026] 127.0.0.1:52448 Closing +[Mon Apr 20 10:42:07 2026] 127.0.0.1:52456 Accepted +[Mon Apr 20 10:42:08 2026] 127.0.0.1:52456 Closing +[Mon Apr 20 10:42:10 2026] 127.0.0.1:52462 Accepted +[Mon Apr 20 10:42:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:11 2026] 127.0.0.1:52462 Closing +[Mon Apr 20 10:42:11 2026] 127.0.0.1:48668 Accepted +[Mon Apr 20 10:42:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:11 2026] 127.0.0.1:48668 Closing +[Mon Apr 20 10:42:11 2026] 127.0.0.1:48684 Accepted +[Mon Apr 20 10:42:12 2026] 127.0.0.1:48684 Closing +[Mon Apr 20 10:42:12 2026] 127.0.0.1:48686 Accepted +[Mon Apr 20 10:42:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:13 2026] 127.0.0.1:48686 Closing +[Mon Apr 20 10:42:13 2026] 127.0.0.1:48702 Accepted +[Mon Apr 20 10:42:14 2026] 127.0.0.1:48702 Closing +[Mon Apr 20 10:42:16 2026] 127.0.0.1:48716 Accepted +[Mon Apr 20 10:42:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:15 2026] 127.0.0.1:48716 Closing +[Mon Apr 20 10:42:16 2026] 127.0.0.1:48728 Accepted +[Mon Apr 20 10:42:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:16 2026] 127.0.0.1:48728 Closing +[Mon Apr 20 10:42:16 2026] 127.0.0.1:48736 Accepted +[Mon Apr 20 10:42:17 2026] 127.0.0.1:48736 Closing +[Mon Apr 20 10:42:19 2026] 127.0.0.1:32960 Accepted +[Mon Apr 20 10:42:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:20 2026] 127.0.0.1:32960 Closing +[Mon Apr 20 10:42:20 2026] 127.0.0.1:32966 Accepted +[Mon Apr 20 10:42:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:21 2026] 127.0.0.1:32966 Closing +[Mon Apr 20 10:42:21 2026] 127.0.0.1:32968 Accepted +[Mon Apr 20 10:42:23 2026] 127.0.0.1:32968 Closing +[Mon Apr 20 10:42:25 2026] 127.0.0.1:32982 Accepted +[Mon Apr 20 10:42:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:26 2026] 127.0.0.1:32982 Closing +[Mon Apr 20 10:42:26 2026] 127.0.0.1:32998 Accepted +[Mon Apr 20 10:42:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:26 2026] 127.0.0.1:32998 Closing +[Mon Apr 20 10:42:26 2026] 127.0.0.1:33014 Accepted +[Mon Apr 20 10:42:27 2026] 127.0.0.1:33014 Closing +[Mon Apr 20 10:42:27 2026] 127.0.0.1:33018 Accepted +[Mon Apr 20 10:42:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:28 2026] 127.0.0.1:33018 Closing +[Mon Apr 20 10:42:28 2026] 127.0.0.1:33028 Accepted +[Mon Apr 20 10:42:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:28 2026] 127.0.0.1:33028 Closing +[Mon Apr 20 10:42:28 2026] 127.0.0.1:33036 Accepted +[Mon Apr 20 10:42:29 2026] 127.0.0.1:33036 Closing +[Mon Apr 20 10:42:29 2026] 127.0.0.1:44814 Accepted +[Mon Apr 20 10:42:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:30 2026] 127.0.0.1:44814 Closing +[Mon Apr 20 10:42:30 2026] 127.0.0.1:44818 Accepted +[Mon Apr 20 10:42:31 2026] 127.0.0.1:44818 Closing +[Mon Apr 20 10:42:33 2026] 127.0.0.1:44832 Accepted +[Mon Apr 20 10:42:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:34 2026] 127.0.0.1:44832 Closing +[Mon Apr 20 10:42:34 2026] 127.0.0.1:44836 Accepted +[Mon Apr 20 10:42:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:34 2026] 127.0.0.1:44836 Closing +[Mon Apr 20 10:42:34 2026] 127.0.0.1:44840 Accepted +[Mon Apr 20 10:42:36 2026] 127.0.0.1:44840 Closing +[Mon Apr 20 10:42:36 2026] 127.0.0.1:44848 Accepted +[Mon Apr 20 10:42:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:37 2026] 127.0.0.1:44848 Closing +[Mon Apr 20 10:42:37 2026] 127.0.0.1:44858 Accepted +[Mon Apr 20 10:42:38 2026] 127.0.0.1:44858 Closing +[Mon Apr 20 10:42:38 2026] 127.0.0.1:44866 Accepted +[Mon Apr 20 10:42:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:38 2026] 127.0.0.1:44866 Closing +[Mon Apr 20 10:42:38 2026] 127.0.0.1:44876 Accepted +[Mon Apr 20 10:42:39 2026] 127.0.0.1:44876 Closing +[Mon Apr 20 10:42:41 2026] 127.0.0.1:52734 Accepted +[Mon Apr 20 10:42:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:42 2026] 127.0.0.1:52734 Closing +[Mon Apr 20 10:42:42 2026] 127.0.0.1:52744 Accepted +[Mon Apr 20 10:42:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:42 2026] 127.0.0.1:52744 Closing +[Mon Apr 20 10:42:42 2026] 127.0.0.1:52756 Accepted +[Mon Apr 20 10:42:43 2026] 127.0.0.1:52756 Closing +[Mon Apr 20 10:42:43 2026] 127.0.0.1:52772 Accepted +[Mon Apr 20 10:42:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:44 2026] 127.0.0.1:52772 Closing +[Mon Apr 20 10:42:45 2026] 127.0.0.1:52784 Accepted +[Mon Apr 20 10:42:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:44 2026] 127.0.0.1:52784 Closing +[Mon Apr 20 10:42:44 2026] 127.0.0.1:52792 Accepted +[Mon Apr 20 10:42:47 2026] 127.0.0.1:52792 Closing +[Mon Apr 20 10:42:53 2026] 127.0.0.1:40736 Accepted +[Mon Apr 20 10:42:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:53 2026] 127.0.0.1:40736 Closing +[Mon Apr 20 10:42:54 2026] 127.0.0.1:40744 Accepted +[Mon Apr 20 10:42:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:42:55 2026] 127.0.0.1:40744 Closing +[Mon Apr 20 10:42:55 2026] 127.0.0.1:40750 Accepted +[Mon Apr 20 10:42:59 2026] 127.0.0.1:40750 Closing +[Mon Apr 20 10:43:05 2026] 127.0.0.1:41804 Accepted +[Mon Apr 20 10:43:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:05 2026] 127.0.0.1:41804 Closing +[Mon Apr 20 10:43:05 2026] 127.0.0.1:41806 Accepted +[Mon Apr 20 10:43:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:06 2026] 127.0.0.1:41806 Closing +[Mon Apr 20 10:43:06 2026] 127.0.0.1:41822 Accepted +[Mon Apr 20 10:43:06 2026] 127.0.0.1:41822 Closing +[Mon Apr 20 10:43:07 2026] 127.0.0.1:41832 Accepted +[Mon Apr 20 10:43:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:07 2026] 127.0.0.1:41832 Closing +[Mon Apr 20 10:43:07 2026] 127.0.0.1:41840 Accepted +[Mon Apr 20 10:43:10 2026] 127.0.0.1:41840 Closing +[Mon Apr 20 10:43:12 2026] 127.0.0.1:54998 Accepted +[Mon Apr 20 10:43:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:13 2026] 127.0.0.1:54998 Closing +[Mon Apr 20 10:43:13 2026] 127.0.0.1:55014 Accepted +[Mon Apr 20 10:43:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:13 2026] 127.0.0.1:55014 Closing +[Mon Apr 20 10:43:13 2026] 127.0.0.1:55020 Accepted +[Mon Apr 20 10:43:17 2026] 127.0.0.1:55020 Closing +[Mon Apr 20 10:43:21 2026] 127.0.0.1:59684 Accepted +[Mon Apr 20 10:43:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:21 2026] 127.0.0.1:59684 Closing +[Mon Apr 20 10:43:22 2026] 127.0.0.1:59692 Accepted +[Mon Apr 20 10:43:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:22 2026] 127.0.0.1:59692 Closing +[Mon Apr 20 10:43:22 2026] 127.0.0.1:59706 Accepted +[Mon Apr 20 10:43:24 2026] 127.0.0.1:59706 Closing +[Mon Apr 20 10:43:24 2026] 127.0.0.1:59712 Accepted +[Mon Apr 20 10:43:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:24 2026] 127.0.0.1:59712 Closing +[Mon Apr 20 10:43:24 2026] 127.0.0.1:59726 Accepted +[Mon Apr 20 10:43:26 2026] 127.0.0.1:59726 Closing +[Mon Apr 20 10:43:26 2026] 127.0.0.1:59736 Accepted +[Mon Apr 20 10:43:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:26 2026] 127.0.0.1:59736 Closing +[Mon Apr 20 10:43:26 2026] 127.0.0.1:59740 Accepted +[Mon Apr 20 10:43:28 2026] 127.0.0.1:59740 Closing +[Mon Apr 20 10:43:28 2026] 127.0.0.1:47088 Accepted +[Mon Apr 20 10:43:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:29 2026] 127.0.0.1:47088 Closing +[Mon Apr 20 10:43:29 2026] 127.0.0.1:47102 Accepted +[Mon Apr 20 10:43:30 2026] 127.0.0.1:47102 Closing +[Mon Apr 20 10:43:33 2026] 127.0.0.1:47112 Accepted +[Mon Apr 20 10:43:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:34 2026] 127.0.0.1:47112 Closing +[Mon Apr 20 10:43:35 2026] 127.0.0.1:47128 Accepted +[Mon Apr 20 10:43:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:35 2026] 127.0.0.1:47128 Closing +[Mon Apr 20 10:43:35 2026] 127.0.0.1:47130 Accepted +[Mon Apr 20 10:43:40 2026] 127.0.0.1:47130 Closing +[Mon Apr 20 10:43:45 2026] 127.0.0.1:42296 Accepted +[Mon Apr 20 10:43:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:45 2026] 127.0.0.1:42296 Closing +[Mon Apr 20 10:43:46 2026] 127.0.0.1:49210 Accepted +[Mon Apr 20 10:43:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:47 2026] 127.0.0.1:49210 Closing +[Mon Apr 20 10:43:47 2026] 127.0.0.1:49220 Accepted +[Mon Apr 20 10:43:50 2026] 127.0.0.1:49220 Closing +[Mon Apr 20 10:43:50 2026] 127.0.0.1:49230 Accepted +[Mon Apr 20 10:43:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:43:51 2026] 127.0.0.1:49230 Closing +[Mon Apr 20 10:43:51 2026] 127.0.0.1:49246 Accepted +[Mon Apr 20 10:43:54 2026] 127.0.0.1:49246 Closing +[Mon Apr 20 10:44:00 2026] 127.0.0.1:51194 Accepted +[Mon Apr 20 10:44:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:01 2026] 127.0.0.1:51194 Closing +[Mon Apr 20 10:44:01 2026] 127.0.0.1:51208 Accepted +[Mon Apr 20 10:44:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:02 2026] 127.0.0.1:51208 Closing +[Mon Apr 20 10:44:02 2026] 127.0.0.1:51218 Accepted +[Mon Apr 20 10:44:04 2026] 127.0.0.1:51218 Closing +[Mon Apr 20 10:44:04 2026] 127.0.0.1:51232 Accepted +[Mon Apr 20 10:44:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:05 2026] 127.0.0.1:51232 Closing +[Mon Apr 20 10:44:05 2026] 127.0.0.1:44314 Accepted +[Mon Apr 20 10:44:07 2026] 127.0.0.1:44314 Closing +[Mon Apr 20 10:44:07 2026] 127.0.0.1:44326 Accepted +[Mon Apr 20 10:44:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:07 2026] 127.0.0.1:44326 Closing +[Mon Apr 20 10:44:07 2026] 127.0.0.1:44336 Accepted +[Mon Apr 20 10:44:09 2026] 127.0.0.1:44336 Closing +[Mon Apr 20 10:44:09 2026] 127.0.0.1:44352 Accepted +[Mon Apr 20 10:44:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:10 2026] 127.0.0.1:44352 Closing +[Mon Apr 20 10:44:10 2026] 127.0.0.1:44364 Accepted +[Mon Apr 20 10:44:10 2026] 127.0.0.1:44364 Closing +[Mon Apr 20 10:44:10 2026] 127.0.0.1:44366 Accepted +[Mon Apr 20 10:44:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:11 2026] 127.0.0.1:44366 Closing +[Mon Apr 20 10:44:11 2026] 127.0.0.1:44374 Accepted +[Mon Apr 20 10:44:13 2026] 127.0.0.1:44374 Closing +[Mon Apr 20 10:44:13 2026] 127.0.0.1:44390 Accepted +[Mon Apr 20 10:44:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:14 2026] 127.0.0.1:44390 Closing +[Mon Apr 20 10:44:14 2026] 127.0.0.1:46196 Accepted +[Mon Apr 20 10:44:16 2026] 127.0.0.1:46196 Closing +[Mon Apr 20 10:44:21 2026] 127.0.0.1:46212 Accepted +[Mon Apr 20 10:44:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:22 2026] 127.0.0.1:46212 Closing +[Mon Apr 20 10:44:22 2026] 127.0.0.1:46216 Accepted +[Mon Apr 20 10:44:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:23 2026] 127.0.0.1:46216 Closing +[Mon Apr 20 10:44:23 2026] 127.0.0.1:46220 Accepted +[Mon Apr 20 10:44:25 2026] 127.0.0.1:46220 Closing +[Mon Apr 20 10:44:25 2026] 127.0.0.1:56900 Accepted +[Mon Apr 20 10:44:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:25 2026] 127.0.0.1:56900 Closing +[Mon Apr 20 10:44:25 2026] 127.0.0.1:56916 Accepted +[Mon Apr 20 10:44:26 2026] 127.0.0.1:56916 Closing +[Mon Apr 20 10:44:29 2026] 127.0.0.1:56928 Accepted +[Mon Apr 20 10:44:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:29 2026] 127.0.0.1:56928 Closing +[Mon Apr 20 10:44:30 2026] 127.0.0.1:56940 Accepted +[Mon Apr 20 10:44:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:31 2026] 127.0.0.1:56940 Closing +[Mon Apr 20 10:44:31 2026] 127.0.0.1:56954 Accepted +[Mon Apr 20 10:44:35 2026] 127.0.0.1:56954 Closing +[Mon Apr 20 10:44:40 2026] 127.0.0.1:53172 Accepted +[Mon Apr 20 10:44:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:40 2026] 127.0.0.1:53172 Closing +[Mon Apr 20 10:44:41 2026] 127.0.0.1:53178 Accepted +[Mon Apr 20 10:44:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:42 2026] 127.0.0.1:53178 Closing +[Mon Apr 20 10:44:42 2026] 127.0.0.1:53186 Accepted +[Mon Apr 20 10:44:45 2026] 127.0.0.1:53186 Closing +[Mon Apr 20 10:44:45 2026] 127.0.0.1:42460 Accepted +[Mon Apr 20 10:44:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:45 2026] 127.0.0.1:42460 Closing +[Mon Apr 20 10:44:45 2026] 127.0.0.1:42472 Accepted +[Mon Apr 20 10:44:47 2026] 127.0.0.1:42472 Closing +[Mon Apr 20 10:44:47 2026] 127.0.0.1:42478 Accepted +[Mon Apr 20 10:44:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:47 2026] 127.0.0.1:42478 Closing +[Mon Apr 20 10:44:47 2026] 127.0.0.1:42494 Accepted +[Mon Apr 20 10:44:49 2026] 127.0.0.1:42494 Closing +[Mon Apr 20 10:44:49 2026] 127.0.0.1:42510 Accepted +[Mon Apr 20 10:44:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:50 2026] 127.0.0.1:42510 Closing +[Mon Apr 20 10:44:50 2026] 127.0.0.1:42526 Accepted +[Mon Apr 20 10:44:51 2026] 127.0.0.1:42526 Closing +[Mon Apr 20 10:44:51 2026] 127.0.0.1:42538 Accepted +[Mon Apr 20 10:44:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:52 2026] 127.0.0.1:42538 Closing +[Mon Apr 20 10:44:52 2026] 127.0.0.1:42540 Accepted +[Mon Apr 20 10:44:54 2026] 127.0.0.1:42540 Closing +[Mon Apr 20 10:44:54 2026] 127.0.0.1:48174 Accepted +[Mon Apr 20 10:44:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:44:54 2026] 127.0.0.1:48174 Closing +[Mon Apr 20 10:44:54 2026] 127.0.0.1:48190 Accepted +[Mon Apr 20 10:44:56 2026] 127.0.0.1:48190 Closing +[Mon Apr 20 10:45:01 2026] 127.0.0.1:48202 Accepted +[Mon Apr 20 10:45:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:45:01 2026] 127.0.0.1:48202 Closing +[Mon Apr 20 10:45:02 2026] 127.0.0.1:48206 Accepted +[Mon Apr 20 10:45:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:45:02 2026] 127.0.0.1:48206 Closing +[Mon Apr 20 10:45:02 2026] 127.0.0.1:57254 Accepted +[Mon Apr 20 10:45:04 2026] 127.0.0.1:57254 Closing +[Mon Apr 20 10:45:07 2026] 127.0.0.1:57264 Accepted +[Mon Apr 20 10:45:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:45:07 2026] 127.0.0.1:57264 Closing +[Mon Apr 20 10:45:07 2026] 127.0.0.1:57280 Accepted +[Mon Apr 20 10:45:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:45:07 2026] 127.0.0.1:57280 Closing +[Mon Apr 20 10:45:07 2026] 127.0.0.1:57292 Accepted +[Mon Apr 20 10:45:09 2026] 127.0.0.1:57292 Closing +[Mon Apr 20 10:45:09 2026] 127.0.0.1:57308 Accepted +[Mon Apr 20 10:45:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:45:09 2026] 127.0.0.1:57308 Closing +[Mon Apr 20 10:45:09 2026] 127.0.0.1:57316 Accepted +[Mon Apr 20 10:45:10 2026] 127.0.0.1:57316 Closing +[Mon Apr 20 10:45:16 2026] 127.0.0.1:54576 Accepted +[Mon Apr 20 10:45:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:45:17 2026] 127.0.0.1:54576 Closing +[Mon Apr 20 10:45:19 2026] 127.0.0.1:54578 Accepted +[Mon Apr 20 10:45:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:45:20 2026] 127.0.0.1:54578 Closing +[Mon Apr 20 10:45:20 2026] 127.0.0.1:54584 Accepted +[Mon Apr 20 10:45:23 2026] 127.0.0.1:54584 Closing +[Mon Apr 20 10:45:28 2026] 127.0.0.1:33398 Accepted +[Mon Apr 20 10:45:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:45:29 2026] 127.0.0.1:33398 Closing +[Mon Apr 20 10:45:29 2026] 127.0.0.1:33400 Accepted +[Mon Apr 20 10:45:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:45:30 2026] 127.0.0.1:33400 Closing +[Mon Apr 20 10:45:30 2026] 127.0.0.1:33408 Accepted +[Mon Apr 20 10:45:32 2026] 127.0.0.1:33408 Closing +[Mon Apr 20 10:45:32 2026] 127.0.0.1:45670 Accepted +[Mon Apr 20 10:45:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Mon Apr 20 10:45:32 2026] 127.0.0.1:45670 Closing +[Mon Apr 20 10:45:32 2026] 127.0.0.1:45684 Accepted +[Mon Apr 20 10:45:34 2026] 127.0.0.1:45684 Closing +[Tue Apr 21 08:04:06 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 08:04:06 2026] 127.0.0.1:43372 Accepted +[Tue Apr 21 08:04:07 2026] 127.0.0.1:43372 Closing +[Tue Apr 21 08:04:09 2026] 127.0.0.1:33384 Accepted +[Tue Apr 21 08:04:12 2026] 127.0.0.1:33384 Closing +[Tue Apr 21 08:04:20 2026] 127.0.0.1:43330 Accepted +[Tue Apr 21 08:04:24 2026] 127.0.0.1:43330 Closing +[Tue Apr 21 08:04:29 2026] 127.0.0.1:54202 Accepted +[Tue Apr 21 08:04:30 2026] 127.0.0.1:54202 Closing +[Tue Apr 21 08:04:30 2026] 127.0.0.1:54210 Accepted +[Tue Apr 21 08:04:32 2026] 127.0.0.1:54210 Closing +[Tue Apr 21 08:04:33 2026] 127.0.0.1:54224 Accepted +[Tue Apr 21 08:04:38 2026] 127.0.0.1:54224 Closing +[Tue Apr 21 08:04:42 2026] 127.0.0.1:53938 Accepted +[Tue Apr 21 08:04:43 2026] 127.0.0.1:53938 Closing +[Tue Apr 21 08:04:43 2026] 127.0.0.1:53946 Accepted +[Tue Apr 21 08:04:44 2026] 127.0.0.1:53946 Closing +[Tue Apr 21 08:04:44 2026] 127.0.0.1:53954 Accepted +[Tue Apr 21 08:04:46 2026] 127.0.0.1:53954 Closing +[Tue Apr 21 08:04:46 2026] 127.0.0.1:55356 Accepted +[Tue Apr 21 08:04:47 2026] 127.0.0.1:55356 Closing +[Tue Apr 21 08:04:52 2026] 127.0.0.1:55362 Accepted +[Tue Apr 21 08:04:56 2026] 127.0.0.1:55362 Closing +[Tue Apr 21 08:05:01 2026] 127.0.0.1:49600 Accepted +[Tue Apr 21 08:05:05 2026] 127.0.0.1:49600 Closing +[Tue Apr 21 08:05:05 2026] 127.0.0.1:33086 Accepted +[Tue Apr 21 08:05:08 2026] 127.0.0.1:33086 Closing +[Tue Apr 21 08:05:15 2026] 127.0.0.1:48110 Accepted +[Tue Apr 21 08:05:18 2026] 127.0.0.1:48110 Closing +[Tue Apr 21 08:05:18 2026] 127.0.0.1:48122 Accepted +[Tue Apr 21 08:05:19 2026] 127.0.0.1:48122 Closing +[Tue Apr 21 08:05:19 2026] 127.0.0.1:48138 Accepted +[Tue Apr 21 08:05:21 2026] 127.0.0.1:48138 Closing +[Tue Apr 21 08:05:21 2026] 127.0.0.1:48152 Accepted +[Tue Apr 21 08:05:24 2026] 127.0.0.1:48152 Closing +[Tue Apr 21 08:05:24 2026] 127.0.0.1:48154 Accepted +[Tue Apr 21 08:05:26 2026] 127.0.0.1:48154 Closing +[Tue Apr 21 08:05:26 2026] 127.0.0.1:58460 Accepted +[Tue Apr 21 08:05:28 2026] 127.0.0.1:58460 Closing +[Tue Apr 21 08:05:31 2026] 127.0.0.1:58462 Accepted +[Tue Apr 21 08:05:33 2026] 127.0.0.1:58462 Closing +[Tue Apr 21 08:05:33 2026] 127.0.0.1:40606 Accepted +[Tue Apr 21 08:05:34 2026] 127.0.0.1:40606 Closing +[Tue Apr 21 08:05:37 2026] 127.0.0.1:40616 Accepted +[Tue Apr 21 08:05:42 2026] 127.0.0.1:40616 Closing +[Tue Apr 21 08:05:49 2026] 127.0.0.1:58290 Accepted +[Tue Apr 21 08:05:51 2026] 127.0.0.1:58290 Closing +[Tue Apr 21 08:05:51 2026] 127.0.0.1:58298 Accepted +[Tue Apr 21 08:05:53 2026] 127.0.0.1:58298 Closing +[Tue Apr 21 08:05:53 2026] 127.0.0.1:57162 Accepted +[Tue Apr 21 08:05:55 2026] 127.0.0.1:57162 Closing +[Tue Apr 21 08:05:55 2026] 127.0.0.1:57178 Accepted +[Tue Apr 21 08:05:55 2026] 127.0.0.1:57178 Closing +[Tue Apr 21 08:05:55 2026] 127.0.0.1:57188 Accepted +[Tue Apr 21 08:05:57 2026] 127.0.0.1:57188 Closing +[Tue Apr 21 08:05:57 2026] 127.0.0.1:57192 Accepted +[Tue Apr 21 08:05:59 2026] 127.0.0.1:57192 Closing +[Tue Apr 21 08:06:05 2026] 127.0.0.1:38046 Accepted +[Tue Apr 21 08:06:06 2026] 127.0.0.1:38046 Closing +[Tue Apr 21 08:06:09 2026] 127.0.0.1:38056 Accepted +[Tue Apr 21 08:06:11 2026] 127.0.0.1:38056 Closing +[Tue Apr 21 08:06:11 2026] 127.0.0.1:50508 Accepted +[Tue Apr 21 08:06:13 2026] 127.0.0.1:50508 Closing +[Tue Apr 21 08:06:18 2026] 127.0.0.1:50514 Accepted +[Tue Apr 21 08:06:21 2026] 127.0.0.1:50514 Closing +[Tue Apr 21 08:06:24 2026] 127.0.0.1:37216 Accepted +[Tue Apr 21 08:06:26 2026] 127.0.0.1:37216 Closing +[Tue Apr 21 08:06:26 2026] 127.0.0.1:37228 Accepted +[Tue Apr 21 08:06:27 2026] 127.0.0.1:37228 Closing +[Tue Apr 21 08:08:54 2026] 127.0.0.1:48696 Accepted +[Tue Apr 21 08:08:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:08:55 2026] 127.0.0.1:48696 Closing +[Tue Apr 21 08:08:56 2026] 127.0.0.1:48712 Accepted +[Tue Apr 21 08:08:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:08:57 2026] 127.0.0.1:48712 Closing +[Tue Apr 21 08:08:57 2026] 127.0.0.1:47894 Accepted +[Tue Apr 21 08:09:00 2026] 127.0.0.1:47894 Closing +[Tue Apr 21 08:09:07 2026] 127.0.0.1:37224 Accepted +[Tue Apr 21 08:09:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:07 2026] 127.0.0.1:37224 Closing +[Tue Apr 21 08:09:08 2026] 127.0.0.1:37236 Accepted +[Tue Apr 21 08:09:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:09 2026] 127.0.0.1:37236 Closing +[Tue Apr 21 08:09:09 2026] 127.0.0.1:37246 Accepted +[Tue Apr 21 08:09:11 2026] 127.0.0.1:37246 Closing +[Tue Apr 21 08:09:16 2026] 127.0.0.1:58096 Accepted +[Tue Apr 21 08:09:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:16 2026] 127.0.0.1:58096 Closing +[Tue Apr 21 08:09:16 2026] 127.0.0.1:58110 Accepted +[Tue Apr 21 08:09:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:17 2026] 127.0.0.1:58110 Closing +[Tue Apr 21 08:09:17 2026] 127.0.0.1:58112 Accepted +[Tue Apr 21 08:09:18 2026] 127.0.0.1:58112 Closing +[Tue Apr 21 08:09:18 2026] 127.0.0.1:58126 Accepted +[Tue Apr 21 08:09:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:19 2026] 127.0.0.1:58126 Closing +[Tue Apr 21 08:09:19 2026] 127.0.0.1:58134 Accepted +[Tue Apr 21 08:09:21 2026] 127.0.0.1:58134 Closing +[Tue Apr 21 08:09:23 2026] 127.0.0.1:58138 Accepted +[Tue Apr 21 08:09:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:24 2026] 127.0.0.1:58138 Closing +[Tue Apr 21 08:09:24 2026] 127.0.0.1:49622 Accepted +[Tue Apr 21 08:09:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:25 2026] 127.0.0.1:49622 Closing +[Tue Apr 21 08:09:25 2026] 127.0.0.1:49628 Accepted +[Tue Apr 21 08:09:29 2026] 127.0.0.1:49628 Closing +[Tue Apr 21 08:09:33 2026] 127.0.0.1:49630 Accepted +[Tue Apr 21 08:09:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:33 2026] 127.0.0.1:49630 Closing +[Tue Apr 21 08:09:34 2026] 127.0.0.1:49644 Accepted +[Tue Apr 21 08:09:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:34 2026] 127.0.0.1:49644 Closing +[Tue Apr 21 08:09:34 2026] 127.0.0.1:49652 Accepted +[Tue Apr 21 08:09:35 2026] 127.0.0.1:49652 Closing +[Tue Apr 21 08:09:35 2026] 127.0.0.1:49728 Accepted +[Tue Apr 21 08:09:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:36 2026] 127.0.0.1:49728 Closing +[Tue Apr 21 08:09:36 2026] 127.0.0.1:49736 Accepted +[Tue Apr 21 08:09:37 2026] 127.0.0.1:49736 Closing +[Tue Apr 21 08:09:37 2026] 127.0.0.1:49738 Accepted +[Tue Apr 21 08:09:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:37 2026] 127.0.0.1:49738 Closing +[Tue Apr 21 08:09:37 2026] 127.0.0.1:49740 Accepted +[Tue Apr 21 08:09:39 2026] 127.0.0.1:49740 Closing +[Tue Apr 21 08:09:39 2026] 127.0.0.1:49746 Accepted +[Tue Apr 21 08:09:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:37 2026] 127.0.0.1:49746 Closing +[Tue Apr 21 08:09:37 2026] 127.0.0.1:49760 Accepted +[Tue Apr 21 08:09:39 2026] 127.0.0.1:49760 Closing +[Tue Apr 21 08:09:42 2026] 127.0.0.1:38138 Accepted +[Tue Apr 21 08:09:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:43 2026] 127.0.0.1:38138 Closing +[Tue Apr 21 08:09:44 2026] 127.0.0.1:38140 Accepted +[Tue Apr 21 08:09:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:44 2026] 127.0.0.1:38140 Closing +[Tue Apr 21 08:09:44 2026] 127.0.0.1:38144 Accepted +[Tue Apr 21 08:09:49 2026] 127.0.0.1:38144 Closing +[Tue Apr 21 08:09:55 2026] 127.0.0.1:43494 Accepted +[Tue Apr 21 08:09:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:56 2026] 127.0.0.1:43494 Closing +[Tue Apr 21 08:09:57 2026] 127.0.0.1:43504 Accepted +[Tue Apr 21 08:09:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:09:57 2026] 127.0.0.1:43504 Closing +[Tue Apr 21 08:09:57 2026] 127.0.0.1:43508 Accepted +[Tue Apr 21 08:10:01 2026] 127.0.0.1:43508 Closing +[Tue Apr 21 08:10:01 2026] 127.0.0.1:43520 Accepted +[Tue Apr 21 08:10:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:01 2026] 127.0.0.1:43520 Closing +[Tue Apr 21 08:10:01 2026] 127.0.0.1:43536 Accepted +[Tue Apr 21 08:10:04 2026] 127.0.0.1:43536 Closing +[Tue Apr 21 08:10:08 2026] 127.0.0.1:56320 Accepted +[Tue Apr 21 08:10:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:09 2026] 127.0.0.1:56320 Closing +[Tue Apr 21 08:10:10 2026] 127.0.0.1:32938 Accepted +[Tue Apr 21 08:10:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:10 2026] 127.0.0.1:32938 Closing +[Tue Apr 21 08:10:10 2026] 127.0.0.1:32950 Accepted +[Tue Apr 21 08:10:12 2026] 127.0.0.1:32950 Closing +[Tue Apr 21 08:10:19 2026] 127.0.0.1:32952 Accepted +[Tue Apr 21 08:10:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:19 2026] 127.0.0.1:32952 Closing +[Tue Apr 21 08:10:20 2026] 127.0.0.1:56604 Accepted +[Tue Apr 21 08:10:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:20 2026] 127.0.0.1:56604 Closing +[Tue Apr 21 08:10:20 2026] 127.0.0.1:56610 Accepted +[Tue Apr 21 08:10:23 2026] 127.0.0.1:56610 Closing +[Tue Apr 21 08:10:28 2026] 127.0.0.1:56626 Accepted +[Tue Apr 21 08:10:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:28 2026] 127.0.0.1:56626 Closing +[Tue Apr 21 08:10:29 2026] 127.0.0.1:56632 Accepted +[Tue Apr 21 08:10:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:29 2026] 127.0.0.1:56632 Closing +[Tue Apr 21 08:10:29 2026] 127.0.0.1:56636 Accepted +[Tue Apr 21 08:10:32 2026] 127.0.0.1:56636 Closing +[Tue Apr 21 08:10:32 2026] 127.0.0.1:51558 Accepted +[Tue Apr 21 08:10:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:32 2026] 127.0.0.1:51558 Closing +[Tue Apr 21 08:10:32 2026] 127.0.0.1:51570 Accepted +[Tue Apr 21 08:10:34 2026] 127.0.0.1:51570 Closing +[Tue Apr 21 08:10:34 2026] 127.0.0.1:51582 Accepted +[Tue Apr 21 08:10:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:34 2026] 127.0.0.1:51582 Closing +[Tue Apr 21 08:10:34 2026] 127.0.0.1:51586 Accepted +[Tue Apr 21 08:10:34 2026] 127.0.0.1:51586 Closing +[Tue Apr 21 08:10:34 2026] 127.0.0.1:51590 Accepted +[Tue Apr 21 08:10:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:35 2026] 127.0.0.1:51590 Closing +[Tue Apr 21 08:10:35 2026] 127.0.0.1:51598 Accepted +[Tue Apr 21 08:10:37 2026] 127.0.0.1:51598 Closing +[Tue Apr 21 08:10:37 2026] 127.0.0.1:51612 Accepted +[Tue Apr 21 08:10:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:37 2026] 127.0.0.1:51612 Closing +[Tue Apr 21 08:10:37 2026] 127.0.0.1:51628 Accepted +[Tue Apr 21 08:10:40 2026] 127.0.0.1:51628 Closing +[Tue Apr 21 08:10:40 2026] 127.0.0.1:45048 Accepted +[Tue Apr 21 08:10:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:40 2026] 127.0.0.1:45048 Closing +[Tue Apr 21 08:10:40 2026] 127.0.0.1:45054 Accepted +[Tue Apr 21 08:10:42 2026] 127.0.0.1:45054 Closing +[Tue Apr 21 08:10:47 2026] 127.0.0.1:45062 Accepted +[Tue Apr 21 08:10:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:48 2026] 127.0.0.1:45062 Closing +[Tue Apr 21 08:10:48 2026] 127.0.0.1:48950 Accepted +[Tue Apr 21 08:10:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:48 2026] 127.0.0.1:48950 Closing +[Tue Apr 21 08:10:48 2026] 127.0.0.1:48958 Accepted +[Tue Apr 21 08:10:50 2026] 127.0.0.1:48958 Closing +[Tue Apr 21 08:10:50 2026] 127.0.0.1:48966 Accepted +[Tue Apr 21 08:10:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:51 2026] 127.0.0.1:48966 Closing +[Tue Apr 21 08:10:51 2026] 127.0.0.1:48982 Accepted +[Tue Apr 21 08:10:52 2026] 127.0.0.1:48982 Closing +[Tue Apr 21 08:10:54 2026] 127.0.0.1:48988 Accepted +[Tue Apr 21 08:10:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:55 2026] 127.0.0.1:48988 Closing +[Tue Apr 21 08:10:56 2026] 127.0.0.1:48994 Accepted +[Tue Apr 21 08:10:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:10:56 2026] 127.0.0.1:48994 Closing +[Tue Apr 21 08:10:56 2026] 127.0.0.1:48996 Accepted +[Tue Apr 21 08:11:01 2026] 127.0.0.1:48996 Closing +[Tue Apr 21 08:11:05 2026] 127.0.0.1:35086 Accepted +[Tue Apr 21 08:11:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:05 2026] 127.0.0.1:35086 Closing +[Tue Apr 21 08:11:06 2026] 127.0.0.1:52666 Accepted +[Tue Apr 21 08:11:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:06 2026] 127.0.0.1:52666 Closing +[Tue Apr 21 08:11:06 2026] 127.0.0.1:52670 Accepted +[Tue Apr 21 08:11:09 2026] 127.0.0.1:52670 Closing +[Tue Apr 21 08:11:09 2026] 127.0.0.1:52686 Accepted +[Tue Apr 21 08:11:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:09 2026] 127.0.0.1:52686 Closing +[Tue Apr 21 08:11:09 2026] 127.0.0.1:52698 Accepted +[Tue Apr 21 08:11:11 2026] 127.0.0.1:52698 Closing +[Tue Apr 21 08:11:11 2026] 127.0.0.1:52706 Accepted +[Tue Apr 21 08:11:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:12 2026] 127.0.0.1:52706 Closing +[Tue Apr 21 08:11:12 2026] 127.0.0.1:52708 Accepted +[Tue Apr 21 08:11:14 2026] 127.0.0.1:52708 Closing +[Tue Apr 21 08:11:14 2026] 127.0.0.1:52716 Accepted +[Tue Apr 21 08:11:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:14 2026] 127.0.0.1:52716 Closing +[Tue Apr 21 08:11:14 2026] 127.0.0.1:52722 Accepted +[Tue Apr 21 08:11:17 2026] 127.0.0.1:52722 Closing +[Tue Apr 21 08:11:17 2026] 127.0.0.1:40638 Accepted +[Tue Apr 21 08:11:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:17 2026] 127.0.0.1:40638 Closing +[Tue Apr 21 08:11:17 2026] 127.0.0.1:40644 Accepted +[Tue Apr 21 08:11:20 2026] 127.0.0.1:40644 Closing +[Tue Apr 21 08:11:20 2026] 127.0.0.1:40652 Accepted +[Tue Apr 21 08:11:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:20 2026] 127.0.0.1:40652 Closing +[Tue Apr 21 08:11:20 2026] 127.0.0.1:40658 Accepted +[Tue Apr 21 08:11:23 2026] 127.0.0.1:40658 Closing +[Tue Apr 21 08:11:28 2026] 127.0.0.1:57136 Accepted +[Tue Apr 21 08:11:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:28 2026] 127.0.0.1:57136 Closing +[Tue Apr 21 08:11:28 2026] 127.0.0.1:57142 Accepted +[Tue Apr 21 08:11:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:29 2026] 127.0.0.1:57142 Closing +[Tue Apr 21 08:11:29 2026] 127.0.0.1:57148 Accepted +[Tue Apr 21 08:11:31 2026] 127.0.0.1:57148 Closing +[Tue Apr 21 08:11:31 2026] 127.0.0.1:57164 Accepted +[Tue Apr 21 08:11:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:31 2026] 127.0.0.1:57164 Closing +[Tue Apr 21 08:11:32 2026] 127.0.0.1:57170 Accepted +[Tue Apr 21 08:11:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:33 2026] 127.0.0.1:57170 Closing +[Tue Apr 21 08:11:33 2026] 127.0.0.1:57172 Accepted +[Tue Apr 21 08:11:35 2026] 127.0.0.1:57172 Closing +[Tue Apr 21 08:11:35 2026] 127.0.0.1:46746 Accepted +[Tue Apr 21 08:11:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:35 2026] 127.0.0.1:46746 Closing +[Tue Apr 21 08:11:35 2026] 127.0.0.1:46756 Accepted +[Tue Apr 21 08:11:37 2026] 127.0.0.1:46756 Closing +[Tue Apr 21 08:11:42 2026] 127.0.0.1:46764 Accepted +[Tue Apr 21 08:11:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:42 2026] 127.0.0.1:46764 Closing +[Tue Apr 21 08:11:43 2026] 127.0.0.1:46768 Accepted +[Tue Apr 21 08:11:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:43 2026] 127.0.0.1:46768 Closing +[Tue Apr 21 08:11:43 2026] 127.0.0.1:41290 Accepted +[Tue Apr 21 08:11:46 2026] 127.0.0.1:41290 Closing +[Tue Apr 21 08:11:51 2026] 127.0.0.1:41292 Accepted +[Tue Apr 21 08:11:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:51 2026] 127.0.0.1:41292 Closing +[Tue Apr 21 08:11:52 2026] 127.0.0.1:41294 Accepted +[Tue Apr 21 08:11:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:52 2026] 127.0.0.1:41294 Closing +[Tue Apr 21 08:11:52 2026] 127.0.0.1:41306 Accepted +[Tue Apr 21 08:11:54 2026] 127.0.0.1:41306 Closing +[Tue Apr 21 08:11:54 2026] 127.0.0.1:59510 Accepted +[Tue Apr 21 08:11:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:11:54 2026] 127.0.0.1:59510 Closing +[Tue Apr 21 08:11:54 2026] 127.0.0.1:59520 Accepted +[Tue Apr 21 08:11:56 2026] 127.0.0.1:59520 Closing +[Tue Apr 21 08:12:26 2026] 127.0.0.1:55620 Accepted +[Tue Apr 21 08:12:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:12:26 2026] 127.0.0.1:55620 Closing +[Tue Apr 21 08:12:25 2026] 127.0.0.1:55636 Accepted +[Tue Apr 21 08:12:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:12:26 2026] 127.0.0.1:55636 Closing +[Tue Apr 21 08:12:26 2026] 127.0.0.1:55642 Accepted +[Tue Apr 21 08:12:29 2026] 127.0.0.1:55642 Closing +[Tue Apr 21 08:12:36 2026] 127.0.0.1:58532 Accepted +[Tue Apr 21 08:12:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:12:36 2026] 127.0.0.1:58532 Closing +[Tue Apr 21 08:12:37 2026] 127.0.0.1:58540 Accepted +[Tue Apr 21 08:12:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:12:37 2026] 127.0.0.1:58540 Closing +[Tue Apr 21 08:12:37 2026] 127.0.0.1:58550 Accepted +[Tue Apr 21 08:12:41 2026] 127.0.0.1:58550 Closing +[Tue Apr 21 08:12:47 2026] 127.0.0.1:50978 Accepted +[Tue Apr 21 08:12:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:12:47 2026] 127.0.0.1:50978 Closing +[Tue Apr 21 08:12:47 2026] 127.0.0.1:50988 Accepted +[Tue Apr 21 08:12:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:12:48 2026] 127.0.0.1:50988 Closing +[Tue Apr 21 08:12:48 2026] 127.0.0.1:51000 Accepted +[Tue Apr 21 08:12:48 2026] 127.0.0.1:51000 Closing +[Tue Apr 21 08:12:48 2026] 127.0.0.1:51002 Accepted +[Tue Apr 21 08:12:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:12:49 2026] 127.0.0.1:51002 Closing +[Tue Apr 21 08:12:49 2026] 127.0.0.1:51010 Accepted +[Tue Apr 21 08:12:51 2026] 127.0.0.1:51010 Closing +[Tue Apr 21 08:12:53 2026] 127.0.0.1:40380 Accepted +[Tue Apr 21 08:12:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:12:53 2026] 127.0.0.1:40380 Closing +[Tue Apr 21 08:12:54 2026] 127.0.0.1:40390 Accepted +[Tue Apr 21 08:12:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:12:52 2026] 127.0.0.1:40390 Closing +[Tue Apr 21 08:12:52 2026] 127.0.0.1:40402 Accepted +[Tue Apr 21 08:12:57 2026] 127.0.0.1:40402 Closing +[Tue Apr 21 08:13:00 2026] 127.0.0.1:38826 Accepted +[Tue Apr 21 08:13:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:01 2026] 127.0.0.1:38826 Closing +[Tue Apr 21 08:13:02 2026] 127.0.0.1:38834 Accepted +[Tue Apr 21 08:13:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:02 2026] 127.0.0.1:38834 Closing +[Tue Apr 21 08:13:02 2026] 127.0.0.1:38850 Accepted +[Tue Apr 21 08:13:04 2026] 127.0.0.1:38850 Closing +[Tue Apr 21 08:13:04 2026] 127.0.0.1:38852 Accepted +[Tue Apr 21 08:13:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:05 2026] 127.0.0.1:38852 Closing +[Tue Apr 21 08:13:05 2026] 127.0.0.1:38864 Accepted +[Tue Apr 21 08:13:06 2026] 127.0.0.1:38864 Closing +[Tue Apr 21 08:13:06 2026] 127.0.0.1:38874 Accepted +[Tue Apr 21 08:13:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:07 2026] 127.0.0.1:38874 Closing +[Tue Apr 21 08:13:07 2026] 127.0.0.1:38878 Accepted +[Tue Apr 21 08:13:08 2026] 127.0.0.1:38878 Closing +[Tue Apr 21 08:13:09 2026] 127.0.0.1:58064 Accepted +[Tue Apr 21 08:13:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:09 2026] 127.0.0.1:58064 Closing +[Tue Apr 21 08:13:09 2026] 127.0.0.1:58072 Accepted +[Tue Apr 21 08:13:12 2026] 127.0.0.1:58072 Closing +[Tue Apr 21 08:13:16 2026] 127.0.0.1:58080 Accepted +[Tue Apr 21 08:13:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:18 2026] 127.0.0.1:58080 Closing +[Tue Apr 21 08:13:19 2026] 127.0.0.1:55852 Accepted +[Tue Apr 21 08:13:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:19 2026] 127.0.0.1:55852 Closing +[Tue Apr 21 08:13:19 2026] 127.0.0.1:55856 Accepted +[Tue Apr 21 08:13:21 2026] 127.0.0.1:55856 Closing +[Tue Apr 21 08:13:28 2026] 127.0.0.1:50716 Accepted +[Tue Apr 21 08:13:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:28 2026] 127.0.0.1:50716 Closing +[Tue Apr 21 08:13:29 2026] 127.0.0.1:50730 Accepted +[Tue Apr 21 08:13:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:29 2026] 127.0.0.1:50730 Closing +[Tue Apr 21 08:13:29 2026] 127.0.0.1:50740 Accepted +[Tue Apr 21 08:13:33 2026] 127.0.0.1:50740 Closing +[Tue Apr 21 08:13:33 2026] 127.0.0.1:50742 Accepted +[Tue Apr 21 08:13:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:33 2026] 127.0.0.1:50742 Closing +[Tue Apr 21 08:13:33 2026] 127.0.0.1:50746 Accepted +[Tue Apr 21 08:13:36 2026] 127.0.0.1:50746 Closing +[Tue Apr 21 08:13:42 2026] 127.0.0.1:47172 Accepted +[Tue Apr 21 08:13:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:43 2026] 127.0.0.1:47172 Closing +[Tue Apr 21 08:13:44 2026] 127.0.0.1:47186 Accepted +[Tue Apr 21 08:13:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:44 2026] 127.0.0.1:47186 Closing +[Tue Apr 21 08:13:44 2026] 127.0.0.1:47202 Accepted +[Tue Apr 21 08:13:47 2026] 127.0.0.1:47202 Closing +[Tue Apr 21 08:13:51 2026] 127.0.0.1:53600 Accepted +[Tue Apr 21 08:13:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:51 2026] 127.0.0.1:53600 Closing +[Tue Apr 21 08:13:52 2026] 127.0.0.1:53616 Accepted +[Tue Apr 21 08:13:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:13:53 2026] 127.0.0.1:53616 Closing +[Tue Apr 21 08:13:53 2026] 127.0.0.1:53896 Accepted +[Tue Apr 21 08:13:55 2026] 127.0.0.1:53896 Closing +[Tue Apr 21 08:14:00 2026] 127.0.0.1:53900 Accepted +[Tue Apr 21 08:14:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:00 2026] 127.0.0.1:53900 Closing +[Tue Apr 21 08:14:01 2026] 127.0.0.1:53916 Accepted +[Tue Apr 21 08:14:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:01 2026] 127.0.0.1:53916 Closing +[Tue Apr 21 08:14:01 2026] 127.0.0.1:53930 Accepted +[Tue Apr 21 08:14:04 2026] 127.0.0.1:53930 Closing +[Tue Apr 21 08:14:04 2026] 127.0.0.1:53274 Accepted +[Tue Apr 21 08:14:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:04 2026] 127.0.0.1:53274 Closing +[Tue Apr 21 08:14:04 2026] 127.0.0.1:53290 Accepted +[Tue Apr 21 08:14:06 2026] 127.0.0.1:53290 Closing +[Tue Apr 21 08:14:06 2026] 127.0.0.1:53300 Accepted +[Tue Apr 21 08:14:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:07 2026] 127.0.0.1:53300 Closing +[Tue Apr 21 08:14:07 2026] 127.0.0.1:53316 Accepted +[Tue Apr 21 08:14:09 2026] 127.0.0.1:53316 Closing +[Tue Apr 21 08:14:09 2026] 127.0.0.1:53326 Accepted +[Tue Apr 21 08:14:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:09 2026] 127.0.0.1:53326 Closing +[Tue Apr 21 08:14:09 2026] 127.0.0.1:53330 Accepted +[Tue Apr 21 08:14:11 2026] 127.0.0.1:53330 Closing +[Tue Apr 21 08:14:11 2026] 127.0.0.1:53336 Accepted +[Tue Apr 21 08:14:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:11 2026] 127.0.0.1:53336 Closing +[Tue Apr 21 08:14:11 2026] 127.0.0.1:53338 Accepted +[Tue Apr 21 08:14:13 2026] 127.0.0.1:53338 Closing +[Tue Apr 21 08:14:13 2026] 127.0.0.1:37034 Accepted +[Tue Apr 21 08:14:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:14 2026] 127.0.0.1:37034 Closing +[Tue Apr 21 08:14:14 2026] 127.0.0.1:37050 Accepted +[Tue Apr 21 08:14:16 2026] 127.0.0.1:37050 Closing +[Tue Apr 21 08:14:19 2026] 127.0.0.1:37054 Accepted +[Tue Apr 21 08:14:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:19 2026] 127.0.0.1:37054 Closing +[Tue Apr 21 08:14:19 2026] 127.0.0.1:37058 Accepted +[Tue Apr 21 08:14:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:20 2026] 127.0.0.1:37058 Closing +[Tue Apr 21 08:14:20 2026] 127.0.0.1:37068 Accepted +[Tue Apr 21 08:14:21 2026] 127.0.0.1:37068 Closing +[Tue Apr 21 08:14:21 2026] 127.0.0.1:38562 Accepted +[Tue Apr 21 08:14:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:21 2026] 127.0.0.1:38562 Closing +[Tue Apr 21 08:14:21 2026] 127.0.0.1:38578 Accepted +[Tue Apr 21 08:14:23 2026] 127.0.0.1:38578 Closing +[Tue Apr 21 08:14:25 2026] 127.0.0.1:38584 Accepted +[Tue Apr 21 08:14:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:25 2026] 127.0.0.1:38584 Closing +[Tue Apr 21 08:14:26 2026] 127.0.0.1:38600 Accepted +[Tue Apr 21 08:14:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:27 2026] 127.0.0.1:38600 Closing +[Tue Apr 21 08:14:27 2026] 127.0.0.1:38608 Accepted +[Tue Apr 21 08:14:31 2026] 127.0.0.1:38608 Closing +[Tue Apr 21 08:14:37 2026] 127.0.0.1:38676 Accepted +[Tue Apr 21 08:14:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:37 2026] 127.0.0.1:38676 Closing +[Tue Apr 21 08:14:38 2026] 127.0.0.1:38678 Accepted +[Tue Apr 21 08:14:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:39 2026] 127.0.0.1:38678 Closing +[Tue Apr 21 08:14:39 2026] 127.0.0.1:38690 Accepted +[Tue Apr 21 08:14:41 2026] 127.0.0.1:38690 Closing +[Tue Apr 21 08:14:41 2026] 127.0.0.1:36158 Accepted +[Tue Apr 21 08:14:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:42 2026] 127.0.0.1:36158 Closing +[Tue Apr 21 08:14:42 2026] 127.0.0.1:36174 Accepted +[Tue Apr 21 08:14:44 2026] 127.0.0.1:36174 Closing +[Tue Apr 21 08:14:44 2026] 127.0.0.1:36190 Accepted +[Tue Apr 21 08:14:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:44 2026] 127.0.0.1:36190 Closing +[Tue Apr 21 08:14:44 2026] 127.0.0.1:36198 Accepted +[Tue Apr 21 08:14:44 2026] 127.0.0.1:36198 Closing +[Tue Apr 21 08:14:44 2026] 127.0.0.1:36210 Accepted +[Tue Apr 21 08:14:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:44 2026] 127.0.0.1:36210 Closing +[Tue Apr 21 08:14:44 2026] 127.0.0.1:36218 Accepted +[Tue Apr 21 08:14:46 2026] 127.0.0.1:36218 Closing +[Tue Apr 21 08:14:46 2026] 127.0.0.1:36234 Accepted +[Tue Apr 21 08:14:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:46 2026] 127.0.0.1:36234 Closing +[Tue Apr 21 08:14:46 2026] 127.0.0.1:36250 Accepted +[Tue Apr 21 08:14:48 2026] 127.0.0.1:36250 Closing +[Tue Apr 21 08:14:48 2026] 127.0.0.1:54868 Accepted +[Tue Apr 21 08:14:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:49 2026] 127.0.0.1:54868 Closing +[Tue Apr 21 08:14:49 2026] 127.0.0.1:54872 Accepted +[Tue Apr 21 08:14:51 2026] 127.0.0.1:54872 Closing +[Tue Apr 21 08:14:56 2026] 127.0.0.1:54874 Accepted +[Tue Apr 21 08:14:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:56 2026] 127.0.0.1:54874 Closing +[Tue Apr 21 08:14:56 2026] 127.0.0.1:54882 Accepted +[Tue Apr 21 08:14:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:14:57 2026] 127.0.0.1:54882 Closing +[Tue Apr 21 08:14:57 2026] 127.0.0.1:54890 Accepted +[Tue Apr 21 08:14:59 2026] 127.0.0.1:54890 Closing +[Tue Apr 21 08:15:01 2026] 127.0.0.1:32860 Accepted +[Tue Apr 21 08:15:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:15:02 2026] 127.0.0.1:32860 Closing +[Tue Apr 21 08:15:03 2026] 127.0.0.1:32876 Accepted +[Tue Apr 21 08:15:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:15:03 2026] 127.0.0.1:32876 Closing +[Tue Apr 21 08:15:03 2026] 127.0.0.1:32884 Accepted +[Tue Apr 21 08:15:05 2026] 127.0.0.1:32884 Closing +[Tue Apr 21 08:15:05 2026] 127.0.0.1:32898 Accepted +[Tue Apr 21 08:15:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:15:06 2026] 127.0.0.1:32898 Closing +[Tue Apr 21 08:15:06 2026] 127.0.0.1:32910 Accepted +[Tue Apr 21 08:15:07 2026] 127.0.0.1:32910 Closing +[Tue Apr 21 08:15:12 2026] 127.0.0.1:58180 Accepted +[Tue Apr 21 08:15:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:15:12 2026] 127.0.0.1:58180 Closing +[Tue Apr 21 08:15:13 2026] 127.0.0.1:58188 Accepted +[Tue Apr 21 08:15:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:15:12 2026] 127.0.0.1:58188 Closing +[Tue Apr 21 08:15:12 2026] 127.0.0.1:58196 Accepted +[Tue Apr 21 08:15:14 2026] 127.0.0.1:58196 Closing +[Tue Apr 21 08:15:19 2026] 127.0.0.1:40902 Accepted +[Tue Apr 21 08:15:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:15:20 2026] 127.0.0.1:40902 Closing +[Tue Apr 21 08:15:20 2026] 127.0.0.1:40910 Accepted +[Tue Apr 21 08:15:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:15:20 2026] 127.0.0.1:40910 Closing +[Tue Apr 21 08:15:20 2026] 127.0.0.1:40920 Accepted +[Tue Apr 21 08:15:22 2026] 127.0.0.1:40920 Closing +[Tue Apr 21 08:15:22 2026] 127.0.0.1:40934 Accepted +[Tue Apr 21 08:15:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:15:22 2026] 127.0.0.1:40934 Closing +[Tue Apr 21 08:15:22 2026] 127.0.0.1:40948 Accepted +[Tue Apr 21 08:15:23 2026] 127.0.0.1:40948 Closing +[Tue Apr 21 08:18:01 2026] 127.0.0.1:58648 Accepted +[Tue Apr 21 08:18:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:02 2026] 127.0.0.1:58648 Closing +[Tue Apr 21 08:18:03 2026] 127.0.0.1:49812 Accepted +[Tue Apr 21 08:18:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:04 2026] 127.0.0.1:49812 Closing +[Tue Apr 21 08:18:04 2026] 127.0.0.1:49820 Accepted +[Tue Apr 21 08:18:08 2026] 127.0.0.1:49820 Closing +[Tue Apr 21 08:18:15 2026] 127.0.0.1:59650 Accepted +[Tue Apr 21 08:18:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:15 2026] 127.0.0.1:59650 Closing +[Tue Apr 21 08:18:16 2026] 127.0.0.1:59662 Accepted +[Tue Apr 21 08:18:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:16 2026] 127.0.0.1:59662 Closing +[Tue Apr 21 08:18:16 2026] 127.0.0.1:59676 Accepted +[Tue Apr 21 08:18:21 2026] 127.0.0.1:59676 Closing +[Tue Apr 21 08:18:26 2026] 127.0.0.1:33508 Accepted +[Tue Apr 21 08:18:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:27 2026] 127.0.0.1:33508 Closing +[Tue Apr 21 08:18:27 2026] 127.0.0.1:33514 Accepted +[Tue Apr 21 08:18:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:27 2026] 127.0.0.1:33514 Closing +[Tue Apr 21 08:18:27 2026] 127.0.0.1:33524 Accepted +[Tue Apr 21 08:18:28 2026] 127.0.0.1:33524 Closing +[Tue Apr 21 08:18:28 2026] 127.0.0.1:33528 Accepted +[Tue Apr 21 08:18:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:26 2026] 127.0.0.1:33528 Closing +[Tue Apr 21 08:18:26 2026] 127.0.0.1:33534 Accepted +[Tue Apr 21 08:18:28 2026] 127.0.0.1:33534 Closing +[Tue Apr 21 08:18:31 2026] 127.0.0.1:43534 Accepted +[Tue Apr 21 08:18:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:31 2026] 127.0.0.1:43534 Closing +[Tue Apr 21 08:18:32 2026] 127.0.0.1:43542 Accepted +[Tue Apr 21 08:18:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:32 2026] 127.0.0.1:43542 Closing +[Tue Apr 21 08:18:32 2026] 127.0.0.1:43546 Accepted +[Tue Apr 21 08:18:36 2026] 127.0.0.1:43546 Closing +[Tue Apr 21 08:18:40 2026] 127.0.0.1:43560 Accepted +[Tue Apr 21 08:18:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:40 2026] 127.0.0.1:43560 Closing +[Tue Apr 21 08:18:41 2026] 127.0.0.1:40356 Accepted +[Tue Apr 21 08:18:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:41 2026] 127.0.0.1:40356 Closing +[Tue Apr 21 08:18:41 2026] 127.0.0.1:40370 Accepted +[Tue Apr 21 08:18:43 2026] 127.0.0.1:40370 Closing +[Tue Apr 21 08:18:43 2026] 127.0.0.1:40386 Accepted +[Tue Apr 21 08:18:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:43 2026] 127.0.0.1:40386 Closing +[Tue Apr 21 08:18:43 2026] 127.0.0.1:40402 Accepted +[Tue Apr 21 08:18:45 2026] 127.0.0.1:40402 Closing +[Tue Apr 21 08:18:45 2026] 127.0.0.1:40416 Accepted +[Tue Apr 21 08:18:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:45 2026] 127.0.0.1:40416 Closing +[Tue Apr 21 08:18:45 2026] 127.0.0.1:40428 Accepted +[Tue Apr 21 08:18:47 2026] 127.0.0.1:40428 Closing +[Tue Apr 21 08:18:47 2026] 127.0.0.1:40434 Accepted +[Tue Apr 21 08:18:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:47 2026] 127.0.0.1:40434 Closing +[Tue Apr 21 08:18:47 2026] 127.0.0.1:40444 Accepted +[Tue Apr 21 08:18:49 2026] 127.0.0.1:40444 Closing +[Tue Apr 21 08:18:52 2026] 127.0.0.1:40618 Accepted +[Tue Apr 21 08:18:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:53 2026] 127.0.0.1:40618 Closing +[Tue Apr 21 08:18:54 2026] 127.0.0.1:40632 Accepted +[Tue Apr 21 08:18:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:18:54 2026] 127.0.0.1:40632 Closing +[Tue Apr 21 08:18:54 2026] 127.0.0.1:40634 Accepted +[Tue Apr 21 08:18:57 2026] 127.0.0.1:40634 Closing +[Tue Apr 21 08:19:03 2026] 127.0.0.1:53008 Accepted +[Tue Apr 21 08:19:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:03 2026] 127.0.0.1:53008 Closing +[Tue Apr 21 08:19:04 2026] 127.0.0.1:53016 Accepted +[Tue Apr 21 08:19:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:05 2026] 127.0.0.1:53016 Closing +[Tue Apr 21 08:19:05 2026] 127.0.0.1:53030 Accepted +[Tue Apr 21 08:19:08 2026] 127.0.0.1:53030 Closing +[Tue Apr 21 08:19:08 2026] 127.0.0.1:55468 Accepted +[Tue Apr 21 08:19:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:09 2026] 127.0.0.1:55468 Closing +[Tue Apr 21 08:19:09 2026] 127.0.0.1:55478 Accepted +[Tue Apr 21 08:19:12 2026] 127.0.0.1:55478 Closing +[Tue Apr 21 08:19:18 2026] 127.0.0.1:39746 Accepted +[Tue Apr 21 08:19:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:18 2026] 127.0.0.1:39746 Closing +[Tue Apr 21 08:19:19 2026] 127.0.0.1:39760 Accepted +[Tue Apr 21 08:19:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:20 2026] 127.0.0.1:39760 Closing +[Tue Apr 21 08:19:20 2026] 127.0.0.1:39774 Accepted +[Tue Apr 21 08:19:22 2026] 127.0.0.1:39774 Closing +[Tue Apr 21 08:19:26 2026] 127.0.0.1:58184 Accepted +[Tue Apr 21 08:19:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:27 2026] 127.0.0.1:58184 Closing +[Tue Apr 21 08:19:27 2026] 127.0.0.1:58196 Accepted +[Tue Apr 21 08:19:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:28 2026] 127.0.0.1:58196 Closing +[Tue Apr 21 08:19:28 2026] 127.0.0.1:58210 Accepted +[Tue Apr 21 08:19:30 2026] 127.0.0.1:58210 Closing +[Tue Apr 21 08:19:35 2026] 127.0.0.1:58212 Accepted +[Tue Apr 21 08:19:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:35 2026] 127.0.0.1:58212 Closing +[Tue Apr 21 08:19:36 2026] 127.0.0.1:58216 Accepted +[Tue Apr 21 08:19:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:36 2026] 127.0.0.1:58216 Closing +[Tue Apr 21 08:19:36 2026] 127.0.0.1:34522 Accepted +[Tue Apr 21 08:19:38 2026] 127.0.0.1:34522 Closing +[Tue Apr 21 08:19:38 2026] 127.0.0.1:34530 Accepted +[Tue Apr 21 08:19:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:39 2026] 127.0.0.1:34530 Closing +[Tue Apr 21 08:19:39 2026] 127.0.0.1:34542 Accepted +[Tue Apr 21 08:19:41 2026] 127.0.0.1:34542 Closing +[Tue Apr 21 08:19:41 2026] 127.0.0.1:34544 Accepted +[Tue Apr 21 08:19:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:41 2026] 127.0.0.1:34544 Closing +[Tue Apr 21 08:19:41 2026] 127.0.0.1:34560 Accepted +[Tue Apr 21 08:19:43 2026] 127.0.0.1:34560 Closing +[Tue Apr 21 08:19:43 2026] 127.0.0.1:34572 Accepted +[Tue Apr 21 08:19:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:44 2026] 127.0.0.1:34572 Closing +[Tue Apr 21 08:19:44 2026] 127.0.0.1:34586 Accepted +[Tue Apr 21 08:19:46 2026] 127.0.0.1:34586 Closing +[Tue Apr 21 08:19:46 2026] 127.0.0.1:34588 Accepted +[Tue Apr 21 08:19:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:46 2026] 127.0.0.1:34588 Closing +[Tue Apr 21 08:19:46 2026] 127.0.0.1:53338 Accepted +[Tue Apr 21 08:19:48 2026] 127.0.0.1:53338 Closing +[Tue Apr 21 08:19:48 2026] 127.0.0.1:53354 Accepted +[Tue Apr 21 08:19:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:49 2026] 127.0.0.1:53354 Closing +[Tue Apr 21 08:19:49 2026] 127.0.0.1:53366 Accepted +[Tue Apr 21 08:19:50 2026] 127.0.0.1:53366 Closing +[Tue Apr 21 08:19:53 2026] 127.0.0.1:53380 Accepted +[Tue Apr 21 08:19:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:54 2026] 127.0.0.1:53380 Closing +[Tue Apr 21 08:19:54 2026] 127.0.0.1:38838 Accepted +[Tue Apr 21 08:19:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:54 2026] 127.0.0.1:38838 Closing +[Tue Apr 21 08:19:54 2026] 127.0.0.1:38846 Accepted +[Tue Apr 21 08:19:56 2026] 127.0.0.1:38846 Closing +[Tue Apr 21 08:19:56 2026] 127.0.0.1:38860 Accepted +[Tue Apr 21 08:19:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:19:56 2026] 127.0.0.1:38860 Closing +[Tue Apr 21 08:19:56 2026] 127.0.0.1:38874 Accepted +[Tue Apr 21 08:19:58 2026] 127.0.0.1:38874 Closing +[Tue Apr 21 08:20:00 2026] 127.0.0.1:38880 Accepted +[Tue Apr 21 08:20:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:01 2026] 127.0.0.1:38880 Closing +[Tue Apr 21 08:20:02 2026] 127.0.0.1:38886 Accepted +[Tue Apr 21 08:20:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:02 2026] 127.0.0.1:38886 Closing +[Tue Apr 21 08:20:02 2026] 127.0.0.1:38902 Accepted +[Tue Apr 21 08:20:07 2026] 127.0.0.1:38902 Closing +[Tue Apr 21 08:20:13 2026] 127.0.0.1:37734 Accepted +[Tue Apr 21 08:20:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:13 2026] 127.0.0.1:37734 Closing +[Tue Apr 21 08:20:14 2026] 127.0.0.1:55220 Accepted +[Tue Apr 21 08:20:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:15 2026] 127.0.0.1:55220 Closing +[Tue Apr 21 08:20:15 2026] 127.0.0.1:55228 Accepted +[Tue Apr 21 08:20:18 2026] 127.0.0.1:55228 Closing +[Tue Apr 21 08:20:18 2026] 127.0.0.1:55244 Accepted +[Tue Apr 21 08:20:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:18 2026] 127.0.0.1:55244 Closing +[Tue Apr 21 08:20:18 2026] 127.0.0.1:55258 Accepted +[Tue Apr 21 08:20:18 2026] 127.0.0.1:55258 Closing +[Tue Apr 21 08:20:18 2026] 127.0.0.1:55272 Accepted +[Tue Apr 21 08:20:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:18 2026] 127.0.0.1:55272 Closing +[Tue Apr 21 08:20:18 2026] 127.0.0.1:55276 Accepted +[Tue Apr 21 08:20:20 2026] 127.0.0.1:55276 Closing +[Tue Apr 21 08:20:20 2026] 127.0.0.1:55286 Accepted +[Tue Apr 21 08:20:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:21 2026] 127.0.0.1:55286 Closing +[Tue Apr 21 08:20:21 2026] 127.0.0.1:55300 Accepted +[Tue Apr 21 08:20:23 2026] 127.0.0.1:55300 Closing +[Tue Apr 21 08:20:23 2026] 127.0.0.1:35860 Accepted +[Tue Apr 21 08:20:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:23 2026] 127.0.0.1:35860 Closing +[Tue Apr 21 08:20:23 2026] 127.0.0.1:35870 Accepted +[Tue Apr 21 08:20:25 2026] 127.0.0.1:35870 Closing +[Tue Apr 21 08:20:25 2026] 127.0.0.1:35880 Accepted +[Tue Apr 21 08:20:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:25 2026] 127.0.0.1:35880 Closing +[Tue Apr 21 08:20:25 2026] 127.0.0.1:35888 Accepted +[Tue Apr 21 08:20:27 2026] 127.0.0.1:35888 Closing +[Tue Apr 21 08:20:32 2026] 127.0.0.1:39720 Accepted +[Tue Apr 21 08:20:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:32 2026] 127.0.0.1:39720 Closing +[Tue Apr 21 08:20:33 2026] 127.0.0.1:39732 Accepted +[Tue Apr 21 08:20:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:33 2026] 127.0.0.1:39732 Closing +[Tue Apr 21 08:20:33 2026] 127.0.0.1:39744 Accepted +[Tue Apr 21 08:20:35 2026] 127.0.0.1:39744 Closing +[Tue Apr 21 08:20:37 2026] 127.0.0.1:39750 Accepted +[Tue Apr 21 08:20:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:38 2026] 127.0.0.1:39750 Closing +[Tue Apr 21 08:20:38 2026] 127.0.0.1:39762 Accepted +[Tue Apr 21 08:20:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:39 2026] 127.0.0.1:39762 Closing +[Tue Apr 21 08:20:39 2026] 127.0.0.1:39764 Accepted +[Tue Apr 21 08:20:40 2026] 127.0.0.1:39764 Closing +[Tue Apr 21 08:20:40 2026] 127.0.0.1:39780 Accepted +[Tue Apr 21 08:20:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:41 2026] 127.0.0.1:39780 Closing +[Tue Apr 21 08:20:41 2026] 127.0.0.1:39786 Accepted +[Tue Apr 21 08:20:42 2026] 127.0.0.1:39786 Closing +[Tue Apr 21 08:20:45 2026] 127.0.0.1:50508 Accepted +[Tue Apr 21 08:20:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:46 2026] 127.0.0.1:50508 Closing +[Tue Apr 21 08:20:47 2026] 127.0.0.1:50520 Accepted +[Tue Apr 21 08:20:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:47 2026] 127.0.0.1:50520 Closing +[Tue Apr 21 08:20:47 2026] 127.0.0.1:50526 Accepted +[Tue Apr 21 08:20:50 2026] 127.0.0.1:50526 Closing +[Tue Apr 21 08:20:55 2026] 127.0.0.1:46300 Accepted +[Tue Apr 21 08:20:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:55 2026] 127.0.0.1:46300 Closing +[Tue Apr 21 08:20:56 2026] 127.0.0.1:46314 Accepted +[Tue Apr 21 08:20:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:56 2026] 127.0.0.1:46314 Closing +[Tue Apr 21 08:20:56 2026] 127.0.0.1:46316 Accepted +[Tue Apr 21 08:20:58 2026] 127.0.0.1:46316 Closing +[Tue Apr 21 08:20:58 2026] 127.0.0.1:46324 Accepted +[Tue Apr 21 08:20:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:20:59 2026] 127.0.0.1:46324 Closing +[Tue Apr 21 08:20:59 2026] 127.0.0.1:46334 Accepted +[Tue Apr 21 08:21:00 2026] 127.0.0.1:46334 Closing +[Tue Apr 21 08:21:38 2026] 127.0.0.1:55200 Accepted +[Tue Apr 21 08:21:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:21:39 2026] 127.0.0.1:55200 Closing +[Tue Apr 21 08:21:40 2026] 127.0.0.1:55204 Accepted +[Tue Apr 21 08:21:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:21:41 2026] 127.0.0.1:55204 Closing +[Tue Apr 21 08:21:41 2026] 127.0.0.1:55206 Accepted +[Tue Apr 21 08:21:42 2026] 127.0.0.1:55206 Closing +[Tue Apr 21 08:21:49 2026] 127.0.0.1:36834 Accepted +[Tue Apr 21 08:21:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:21:49 2026] 127.0.0.1:36834 Closing +[Tue Apr 21 08:21:50 2026] 127.0.0.1:36850 Accepted +[Tue Apr 21 08:21:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:21:51 2026] 127.0.0.1:36850 Closing +[Tue Apr 21 08:21:51 2026] 127.0.0.1:36860 Accepted +[Tue Apr 21 08:21:55 2026] 127.0.0.1:36860 Closing +[Tue Apr 21 08:22:00 2026] 127.0.0.1:32944 Accepted +[Tue Apr 21 08:22:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:01 2026] 127.0.0.1:32944 Closing +[Tue Apr 21 08:22:01 2026] 127.0.0.1:32950 Accepted +[Tue Apr 21 08:22:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:01 2026] 127.0.0.1:32950 Closing +[Tue Apr 21 08:22:01 2026] 127.0.0.1:32952 Accepted +[Tue Apr 21 08:22:02 2026] 127.0.0.1:32952 Closing +[Tue Apr 21 08:22:02 2026] 127.0.0.1:32962 Accepted +[Tue Apr 21 08:22:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:03 2026] 127.0.0.1:32962 Closing +[Tue Apr 21 08:22:03 2026] 127.0.0.1:32976 Accepted +[Tue Apr 21 08:22:06 2026] 127.0.0.1:32976 Closing +[Tue Apr 21 08:22:08 2026] 127.0.0.1:42952 Accepted +[Tue Apr 21 08:22:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:09 2026] 127.0.0.1:42952 Closing +[Tue Apr 21 08:22:09 2026] 127.0.0.1:42956 Accepted +[Tue Apr 21 08:22:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:10 2026] 127.0.0.1:42956 Closing +[Tue Apr 21 08:22:10 2026] 127.0.0.1:42968 Accepted +[Tue Apr 21 08:22:12 2026] 127.0.0.1:42968 Closing +[Tue Apr 21 08:22:16 2026] 127.0.0.1:51880 Accepted +[Tue Apr 21 08:22:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:16 2026] 127.0.0.1:51880 Closing +[Tue Apr 21 08:22:17 2026] 127.0.0.1:51888 Accepted +[Tue Apr 21 08:22:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:17 2026] 127.0.0.1:51888 Closing +[Tue Apr 21 08:22:17 2026] 127.0.0.1:51896 Accepted +[Tue Apr 21 08:22:19 2026] 127.0.0.1:51896 Closing +[Tue Apr 21 08:22:19 2026] 127.0.0.1:51902 Accepted +[Tue Apr 21 08:22:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:20 2026] 127.0.0.1:51902 Closing +[Tue Apr 21 08:22:20 2026] 127.0.0.1:51912 Accepted +[Tue Apr 21 08:22:21 2026] 127.0.0.1:51912 Closing +[Tue Apr 21 08:22:21 2026] 127.0.0.1:51924 Accepted +[Tue Apr 21 08:22:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:22 2026] 127.0.0.1:51924 Closing +[Tue Apr 21 08:22:22 2026] 127.0.0.1:51928 Accepted +[Tue Apr 21 08:22:24 2026] 127.0.0.1:51928 Closing +[Tue Apr 21 08:22:24 2026] 127.0.0.1:36168 Accepted +[Tue Apr 21 08:22:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:24 2026] 127.0.0.1:36168 Closing +[Tue Apr 21 08:22:24 2026] 127.0.0.1:36178 Accepted +[Tue Apr 21 08:22:26 2026] 127.0.0.1:36178 Closing +[Tue Apr 21 08:22:29 2026] 127.0.0.1:36182 Accepted +[Tue Apr 21 08:22:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:30 2026] 127.0.0.1:36182 Closing +[Tue Apr 21 08:22:31 2026] 127.0.0.1:36196 Accepted +[Tue Apr 21 08:22:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:31 2026] 127.0.0.1:36196 Closing +[Tue Apr 21 08:22:31 2026] 127.0.0.1:36204 Accepted +[Tue Apr 21 08:22:36 2026] 127.0.0.1:36204 Closing +[Tue Apr 21 08:22:40 2026] 127.0.0.1:45340 Accepted +[Tue Apr 21 08:22:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:40 2026] 127.0.0.1:45340 Closing +[Tue Apr 21 08:22:41 2026] 127.0.0.1:50354 Accepted +[Tue Apr 21 08:22:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:42 2026] 127.0.0.1:50354 Closing +[Tue Apr 21 08:22:42 2026] 127.0.0.1:50366 Accepted +[Tue Apr 21 08:22:46 2026] 127.0.0.1:50366 Closing +[Tue Apr 21 08:22:46 2026] 127.0.0.1:50374 Accepted +[Tue Apr 21 08:22:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:46 2026] 127.0.0.1:50374 Closing +[Tue Apr 21 08:22:46 2026] 127.0.0.1:50386 Accepted +[Tue Apr 21 08:22:49 2026] 127.0.0.1:50386 Closing +[Tue Apr 21 08:22:56 2026] 127.0.0.1:50472 Accepted +[Tue Apr 21 08:22:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:57 2026] 127.0.0.1:50472 Closing +[Tue Apr 21 08:22:58 2026] 127.0.0.1:50484 Accepted +[Tue Apr 21 08:22:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:22:59 2026] 127.0.0.1:50484 Closing +[Tue Apr 21 08:22:59 2026] 127.0.0.1:50488 Accepted +[Tue Apr 21 08:23:02 2026] 127.0.0.1:50488 Closing +[Tue Apr 21 08:23:07 2026] 127.0.0.1:53868 Accepted +[Tue Apr 21 08:23:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:08 2026] 127.0.0.1:53868 Closing +[Tue Apr 21 08:23:09 2026] 127.0.0.1:32884 Accepted +[Tue Apr 21 08:23:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:10 2026] 127.0.0.1:32884 Closing +[Tue Apr 21 08:23:10 2026] 127.0.0.1:32900 Accepted +[Tue Apr 21 08:23:12 2026] 127.0.0.1:32900 Closing +[Tue Apr 21 08:23:17 2026] 127.0.0.1:32902 Accepted +[Tue Apr 21 08:23:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:18 2026] 127.0.0.1:32902 Closing +[Tue Apr 21 08:23:19 2026] 127.0.0.1:40398 Accepted +[Tue Apr 21 08:23:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:19 2026] 127.0.0.1:40398 Closing +[Tue Apr 21 08:23:19 2026] 127.0.0.1:40408 Accepted +[Tue Apr 21 08:23:22 2026] 127.0.0.1:40408 Closing +[Tue Apr 21 08:23:22 2026] 127.0.0.1:40424 Accepted +[Tue Apr 21 08:23:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:22 2026] 127.0.0.1:40424 Closing +[Tue Apr 21 08:23:22 2026] 127.0.0.1:40432 Accepted +[Tue Apr 21 08:23:24 2026] 127.0.0.1:40432 Closing +[Tue Apr 21 08:23:24 2026] 127.0.0.1:40442 Accepted +[Tue Apr 21 08:23:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:25 2026] 127.0.0.1:40442 Closing +[Tue Apr 21 08:23:25 2026] 127.0.0.1:40446 Accepted +[Tue Apr 21 08:23:27 2026] 127.0.0.1:40446 Closing +[Tue Apr 21 08:23:27 2026] 127.0.0.1:40456 Accepted +[Tue Apr 21 08:23:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:28 2026] 127.0.0.1:40456 Closing +[Tue Apr 21 08:23:28 2026] 127.0.0.1:40460 Accepted +[Tue Apr 21 08:23:30 2026] 127.0.0.1:40460 Closing +[Tue Apr 21 08:23:30 2026] 127.0.0.1:52170 Accepted +[Tue Apr 21 08:23:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:30 2026] 127.0.0.1:52170 Closing +[Tue Apr 21 08:23:30 2026] 127.0.0.1:52172 Accepted +[Tue Apr 21 08:23:32 2026] 127.0.0.1:52172 Closing +[Tue Apr 21 08:23:32 2026] 127.0.0.1:52186 Accepted +[Tue Apr 21 08:23:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:33 2026] 127.0.0.1:52186 Closing +[Tue Apr 21 08:23:33 2026] 127.0.0.1:52196 Accepted +[Tue Apr 21 08:23:33 2026] 127.0.0.1:52196 Closing +[Tue Apr 21 08:23:38 2026] 127.0.0.1:52664 Accepted +[Tue Apr 21 08:23:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:38 2026] 127.0.0.1:52664 Closing +[Tue Apr 21 08:23:39 2026] 127.0.0.1:52678 Accepted +[Tue Apr 21 08:23:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:39 2026] 127.0.0.1:52678 Closing +[Tue Apr 21 08:23:39 2026] 127.0.0.1:52686 Accepted +[Tue Apr 21 08:23:41 2026] 127.0.0.1:52686 Closing +[Tue Apr 21 08:23:41 2026] 127.0.0.1:52702 Accepted +[Tue Apr 21 08:23:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:42 2026] 127.0.0.1:52702 Closing +[Tue Apr 21 08:23:42 2026] 127.0.0.1:52708 Accepted +[Tue Apr 21 08:23:43 2026] 127.0.0.1:52708 Closing +[Tue Apr 21 08:23:45 2026] 127.0.0.1:52714 Accepted +[Tue Apr 21 08:23:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:46 2026] 127.0.0.1:52714 Closing +[Tue Apr 21 08:23:47 2026] 127.0.0.1:33682 Accepted +[Tue Apr 21 08:23:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:47 2026] 127.0.0.1:33682 Closing +[Tue Apr 21 08:23:47 2026] 127.0.0.1:33696 Accepted +[Tue Apr 21 08:23:52 2026] 127.0.0.1:33696 Closing +[Tue Apr 21 08:23:58 2026] 127.0.0.1:42506 Accepted +[Tue Apr 21 08:23:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:23:59 2026] 127.0.0.1:42506 Closing +[Tue Apr 21 08:23:59 2026] 127.0.0.1:42522 Accepted +[Tue Apr 21 08:23:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:00 2026] 127.0.0.1:42522 Closing +[Tue Apr 21 08:24:00 2026] 127.0.0.1:42526 Accepted +[Tue Apr 21 08:24:00 2026] 127.0.0.1:42526 Closing +[Tue Apr 21 08:24:00 2026] 127.0.0.1:42540 Accepted +[Tue Apr 21 08:24:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:01 2026] 127.0.0.1:42540 Closing +[Tue Apr 21 08:24:01 2026] 127.0.0.1:42552 Accepted +[Tue Apr 21 08:24:03 2026] 127.0.0.1:42552 Closing +[Tue Apr 21 08:24:03 2026] 127.0.0.1:42560 Accepted +[Tue Apr 21 08:24:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:03 2026] 127.0.0.1:42560 Closing +[Tue Apr 21 08:24:03 2026] 127.0.0.1:42572 Accepted +[Tue Apr 21 08:24:05 2026] 127.0.0.1:42572 Closing +[Tue Apr 21 08:24:05 2026] 127.0.0.1:41178 Accepted +[Tue Apr 21 08:24:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:05 2026] 127.0.0.1:41178 Closing +[Tue Apr 21 08:24:05 2026] 127.0.0.1:41182 Accepted +[Tue Apr 21 08:24:08 2026] 127.0.0.1:41182 Closing +[Tue Apr 21 08:24:08 2026] 127.0.0.1:41188 Accepted +[Tue Apr 21 08:24:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:08 2026] 127.0.0.1:41188 Closing +[Tue Apr 21 08:24:08 2026] 127.0.0.1:41198 Accepted +[Tue Apr 21 08:24:10 2026] 127.0.0.1:41198 Closing +[Tue Apr 21 08:24:10 2026] 127.0.0.1:41202 Accepted +[Tue Apr 21 08:24:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:11 2026] 127.0.0.1:41202 Closing +[Tue Apr 21 08:24:11 2026] 127.0.0.1:41204 Accepted +[Tue Apr 21 08:24:13 2026] 127.0.0.1:41204 Closing +[Tue Apr 21 08:24:18 2026] 127.0.0.1:54386 Accepted +[Tue Apr 21 08:24:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:18 2026] 127.0.0.1:54386 Closing +[Tue Apr 21 08:24:19 2026] 127.0.0.1:54398 Accepted +[Tue Apr 21 08:24:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:19 2026] 127.0.0.1:54398 Closing +[Tue Apr 21 08:24:19 2026] 127.0.0.1:54410 Accepted +[Tue Apr 21 08:24:21 2026] 127.0.0.1:54410 Closing +[Tue Apr 21 08:24:24 2026] 127.0.0.1:54418 Accepted +[Tue Apr 21 08:24:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:24 2026] 127.0.0.1:54418 Closing +[Tue Apr 21 08:24:25 2026] 127.0.0.1:57742 Accepted +[Tue Apr 21 08:24:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:25 2026] 127.0.0.1:57742 Closing +[Tue Apr 21 08:24:25 2026] 127.0.0.1:57758 Accepted +[Tue Apr 21 08:24:27 2026] 127.0.0.1:57758 Closing +[Tue Apr 21 08:24:27 2026] 127.0.0.1:57770 Accepted +[Tue Apr 21 08:24:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:27 2026] 127.0.0.1:57770 Closing +[Tue Apr 21 08:24:27 2026] 127.0.0.1:57782 Accepted +[Tue Apr 21 08:24:29 2026] 127.0.0.1:57782 Closing +[Tue Apr 21 08:24:32 2026] 127.0.0.1:57784 Accepted +[Tue Apr 21 08:24:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:32 2026] 127.0.0.1:57784 Closing +[Tue Apr 21 08:24:33 2026] 127.0.0.1:50000 Accepted +[Tue Apr 21 08:24:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:34 2026] 127.0.0.1:50000 Closing +[Tue Apr 21 08:24:34 2026] 127.0.0.1:50006 Accepted +[Tue Apr 21 08:24:41 2026] 127.0.0.1:50006 Closing +[Tue Apr 21 08:24:47 2026] 127.0.0.1:58318 Accepted +[Tue Apr 21 08:24:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:53 2026] 127.0.0.1:58318 Closing +[Tue Apr 21 08:24:53 2026] 127.0.0.1:47462 Accepted +[Tue Apr 21 08:24:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:24:57 2026] 127.0.0.1:47462 Closing +[Tue Apr 21 08:24:57 2026] 127.0.0.1:47478 Accepted +[Tue Apr 21 08:25:03 2026] 127.0.0.1:47478 Closing +[Tue Apr 21 08:25:03 2026] 127.0.0.1:52486 Accepted +[Tue Apr 21 08:25:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:25:03 2026] 127.0.0.1:52486 Closing +[Tue Apr 21 08:25:03 2026] 127.0.0.1:52500 Accepted +[Tue Apr 21 08:25:05 2026] 127.0.0.1:52500 Closing +[Tue Apr 21 08:27:29 2026] 127.0.0.1:35928 Accepted +[Tue Apr 21 08:27:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:27:30 2026] 127.0.0.1:35928 Closing +[Tue Apr 21 08:27:32 2026] 127.0.0.1:35940 Accepted +[Tue Apr 21 08:27:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:27:32 2026] 127.0.0.1:35940 Closing +[Tue Apr 21 08:27:32 2026] 127.0.0.1:35946 Accepted +[Tue Apr 21 08:27:36 2026] 127.0.0.1:35946 Closing +[Tue Apr 21 08:27:43 2026] 127.0.0.1:39466 Accepted +[Tue Apr 21 08:27:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:27:44 2026] 127.0.0.1:39466 Closing +[Tue Apr 21 08:27:42 2026] 127.0.0.1:39478 Accepted +[Tue Apr 21 08:27:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:27:43 2026] 127.0.0.1:39478 Closing +[Tue Apr 21 08:27:43 2026] 127.0.0.1:39490 Accepted +[Tue Apr 21 08:27:47 2026] 127.0.0.1:39490 Closing +[Tue Apr 21 08:27:53 2026] 127.0.0.1:59140 Accepted +[Tue Apr 21 08:27:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:27:53 2026] 127.0.0.1:59140 Closing +[Tue Apr 21 08:27:53 2026] 127.0.0.1:59154 Accepted +[Tue Apr 21 08:27:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:27:54 2026] 127.0.0.1:59154 Closing +[Tue Apr 21 08:27:54 2026] 127.0.0.1:59170 Accepted +[Tue Apr 21 08:27:54 2026] 127.0.0.1:59170 Closing +[Tue Apr 21 08:27:54 2026] 127.0.0.1:59186 Accepted +[Tue Apr 21 08:27:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:27:55 2026] 127.0.0.1:59186 Closing +[Tue Apr 21 08:27:55 2026] 127.0.0.1:59202 Accepted +[Tue Apr 21 08:27:58 2026] 127.0.0.1:59202 Closing +[Tue Apr 21 08:28:00 2026] 127.0.0.1:53748 Accepted +[Tue Apr 21 08:28:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:01 2026] 127.0.0.1:53748 Closing +[Tue Apr 21 08:28:01 2026] 127.0.0.1:53754 Accepted +[Tue Apr 21 08:28:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:01 2026] 127.0.0.1:53754 Closing +[Tue Apr 21 08:28:01 2026] 127.0.0.1:53768 Accepted +[Tue Apr 21 08:28:06 2026] 127.0.0.1:53768 Closing +[Tue Apr 21 08:28:09 2026] 127.0.0.1:36940 Accepted +[Tue Apr 21 08:28:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:10 2026] 127.0.0.1:36940 Closing +[Tue Apr 21 08:28:10 2026] 127.0.0.1:36946 Accepted +[Tue Apr 21 08:28:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:11 2026] 127.0.0.1:36946 Closing +[Tue Apr 21 08:28:11 2026] 127.0.0.1:36948 Accepted +[Tue Apr 21 08:28:10 2026] 127.0.0.1:36948 Closing +[Tue Apr 21 08:28:10 2026] 127.0.0.1:36950 Accepted +[Tue Apr 21 08:28:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:11 2026] 127.0.0.1:36950 Closing +[Tue Apr 21 08:28:11 2026] 127.0.0.1:36962 Accepted +[Tue Apr 21 08:28:13 2026] 127.0.0.1:36962 Closing +[Tue Apr 21 08:28:13 2026] 127.0.0.1:36976 Accepted +[Tue Apr 21 08:28:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:13 2026] 127.0.0.1:36976 Closing +[Tue Apr 21 08:28:13 2026] 127.0.0.1:36986 Accepted +[Tue Apr 21 08:28:16 2026] 127.0.0.1:36986 Closing +[Tue Apr 21 08:28:16 2026] 127.0.0.1:33170 Accepted +[Tue Apr 21 08:28:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:17 2026] 127.0.0.1:33170 Closing +[Tue Apr 21 08:28:17 2026] 127.0.0.1:33182 Accepted +[Tue Apr 21 08:28:18 2026] 127.0.0.1:33182 Closing +[Tue Apr 21 08:28:22 2026] 127.0.0.1:33198 Accepted +[Tue Apr 21 08:28:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:22 2026] 127.0.0.1:33198 Closing +[Tue Apr 21 08:28:23 2026] 127.0.0.1:33202 Accepted +[Tue Apr 21 08:28:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:25 2026] 127.0.0.1:33202 Closing +[Tue Apr 21 08:28:25 2026] 127.0.0.1:33532 Accepted +[Tue Apr 21 08:28:30 2026] 127.0.0.1:33532 Closing +[Tue Apr 21 08:28:36 2026] 127.0.0.1:56376 Accepted +[Tue Apr 21 08:28:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:37 2026] 127.0.0.1:56376 Closing +[Tue Apr 21 08:28:38 2026] 127.0.0.1:56392 Accepted +[Tue Apr 21 08:28:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:38 2026] 127.0.0.1:56392 Closing +[Tue Apr 21 08:28:38 2026] 127.0.0.1:56396 Accepted +[Tue Apr 21 08:28:41 2026] 127.0.0.1:56396 Closing +[Tue Apr 21 08:28:41 2026] 127.0.0.1:56408 Accepted +[Tue Apr 21 08:28:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:42 2026] 127.0.0.1:56408 Closing +[Tue Apr 21 08:28:42 2026] 127.0.0.1:49498 Accepted +[Tue Apr 21 08:28:45 2026] 127.0.0.1:49498 Closing +[Tue Apr 21 08:28:51 2026] 127.0.0.1:49512 Accepted +[Tue Apr 21 08:28:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:28:52 2026] 127.0.0.1:49512 Closing +[Tue Apr 21 08:28:59 2026] 127.0.0.1:49834 Accepted +[Tue Apr 21 08:28:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:00 2026] 127.0.0.1:49834 Closing +[Tue Apr 21 08:29:05 2026] 127.0.0.1:44346 Accepted +[Tue Apr 21 08:29:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:06 2026] 127.0.0.1:44346 Closing +[Tue Apr 21 08:29:07 2026] 127.0.0.1:44352 Accepted +[Tue Apr 21 08:29:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:07 2026] 127.0.0.1:44352 Closing +[Tue Apr 21 08:29:07 2026] 127.0.0.1:44358 Accepted +[Tue Apr 21 08:29:08 2026] 127.0.0.1:44358 Closing +[Tue Apr 21 08:29:08 2026] 127.0.0.1:44368 Accepted +[Tue Apr 21 08:29:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:08 2026] 127.0.0.1:44368 Closing +[Tue Apr 21 08:29:08 2026] 127.0.0.1:44380 Accepted +[Tue Apr 21 08:29:10 2026] 127.0.0.1:44380 Closing +[Tue Apr 21 08:29:10 2026] 127.0.0.1:54756 Accepted +[Tue Apr 21 08:29:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:11 2026] 127.0.0.1:54756 Closing +[Tue Apr 21 08:29:11 2026] 127.0.0.1:54768 Accepted +[Tue Apr 21 08:29:13 2026] 127.0.0.1:54768 Closing +[Tue Apr 21 08:29:13 2026] 127.0.0.1:54774 Accepted +[Tue Apr 21 08:29:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:14 2026] 127.0.0.1:54774 Closing +[Tue Apr 21 08:29:14 2026] 127.0.0.1:54782 Accepted +[Tue Apr 21 08:29:16 2026] 127.0.0.1:54782 Closing +[Tue Apr 21 08:29:16 2026] 127.0.0.1:54796 Accepted +[Tue Apr 21 08:29:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:17 2026] 127.0.0.1:54796 Closing +[Tue Apr 21 08:29:17 2026] 127.0.0.1:54812 Accepted +[Tue Apr 21 08:29:19 2026] 127.0.0.1:54812 Closing +[Tue Apr 21 08:29:19 2026] 127.0.0.1:54820 Accepted +[Tue Apr 21 08:29:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:19 2026] 127.0.0.1:54820 Closing +[Tue Apr 21 08:29:19 2026] 127.0.0.1:54632 Accepted +[Tue Apr 21 08:29:22 2026] 127.0.0.1:54632 Closing +[Tue Apr 21 08:29:27 2026] 127.0.0.1:54648 Accepted +[Tue Apr 21 08:29:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:27 2026] 127.0.0.1:54648 Closing +[Tue Apr 21 08:29:28 2026] 127.0.0.1:54660 Accepted +[Tue Apr 21 08:29:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:28 2026] 127.0.0.1:54660 Closing +[Tue Apr 21 08:29:28 2026] 127.0.0.1:54664 Accepted +[Tue Apr 21 08:29:30 2026] 127.0.0.1:54664 Closing +[Tue Apr 21 08:29:30 2026] 127.0.0.1:53886 Accepted +[Tue Apr 21 08:29:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:31 2026] 127.0.0.1:53886 Closing +[Tue Apr 21 08:29:31 2026] 127.0.0.1:53898 Accepted +[Tue Apr 21 08:29:32 2026] 127.0.0.1:53898 Closing +[Tue Apr 21 08:29:35 2026] 127.0.0.1:53912 Accepted +[Tue Apr 21 08:29:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:33 2026] 127.0.0.1:53912 Closing +[Tue Apr 21 08:29:34 2026] 127.0.0.1:53924 Accepted +[Tue Apr 21 08:29:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:34 2026] 127.0.0.1:53924 Closing +[Tue Apr 21 08:29:34 2026] 127.0.0.1:53928 Accepted +[Tue Apr 21 08:29:39 2026] 127.0.0.1:53928 Closing +[Tue Apr 21 08:29:45 2026] 127.0.0.1:46778 Accepted +[Tue Apr 21 08:29:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:46 2026] 127.0.0.1:46778 Closing +[Tue Apr 21 08:29:47 2026] 127.0.0.1:42228 Accepted +[Tue Apr 21 08:29:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:47 2026] 127.0.0.1:42228 Closing +[Tue Apr 21 08:29:47 2026] 127.0.0.1:42240 Accepted +[Tue Apr 21 08:29:50 2026] 127.0.0.1:42240 Closing +[Tue Apr 21 08:29:50 2026] 127.0.0.1:42244 Accepted +[Tue Apr 21 08:29:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:51 2026] 127.0.0.1:42244 Closing +[Tue Apr 21 08:29:51 2026] 127.0.0.1:42256 Accepted +[Tue Apr 21 08:29:53 2026] 127.0.0.1:42256 Closing +[Tue Apr 21 08:29:53 2026] 127.0.0.1:42264 Accepted +[Tue Apr 21 08:29:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:53 2026] 127.0.0.1:42264 Closing +[Tue Apr 21 08:29:53 2026] 127.0.0.1:42274 Accepted +[Tue Apr 21 08:29:55 2026] 127.0.0.1:42274 Closing +[Tue Apr 21 08:29:55 2026] 127.0.0.1:42282 Accepted +[Tue Apr 21 08:29:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:56 2026] 127.0.0.1:42282 Closing +[Tue Apr 21 08:29:56 2026] 127.0.0.1:42288 Accepted +[Tue Apr 21 08:29:58 2026] 127.0.0.1:42288 Closing +[Tue Apr 21 08:29:58 2026] 127.0.0.1:57346 Accepted +[Tue Apr 21 08:29:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:29:59 2026] 127.0.0.1:57346 Closing +[Tue Apr 21 08:29:59 2026] 127.0.0.1:57354 Accepted +[Tue Apr 21 08:30:01 2026] 127.0.0.1:57354 Closing +[Tue Apr 21 08:30:01 2026] 127.0.0.1:57358 Accepted +[Tue Apr 21 08:30:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:30:01 2026] 127.0.0.1:57358 Closing +[Tue Apr 21 08:30:01 2026] 127.0.0.1:57374 Accepted +[Tue Apr 21 08:30:01 2026] 127.0.0.1:57374 Closing +[Tue Apr 21 08:30:06 2026] 127.0.0.1:57612 Accepted +[Tue Apr 21 08:30:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:30:07 2026] 127.0.0.1:57612 Closing +[Tue Apr 21 08:30:07 2026] 127.0.0.1:57626 Accepted +[Tue Apr 21 08:30:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:30:08 2026] 127.0.0.1:57626 Closing +[Tue Apr 21 08:30:08 2026] 127.0.0.1:57636 Accepted +[Tue Apr 21 08:30:10 2026] 127.0.0.1:57636 Closing +[Tue Apr 21 08:30:12 2026] 127.0.0.1:57646 Accepted +[Tue Apr 21 08:30:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:30:13 2026] 127.0.0.1:57646 Closing +[Tue Apr 21 08:30:14 2026] 127.0.0.1:57660 Accepted +[Tue Apr 21 08:30:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:30:14 2026] 127.0.0.1:57660 Closing +[Tue Apr 21 08:30:14 2026] 127.0.0.1:57674 Accepted +[Tue Apr 21 08:30:16 2026] 127.0.0.1:57674 Closing +[Tue Apr 21 08:30:16 2026] 127.0.0.1:37684 Accepted +[Tue Apr 21 08:30:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:30:16 2026] 127.0.0.1:37684 Closing +[Tue Apr 21 08:30:16 2026] 127.0.0.1:37688 Accepted +[Tue Apr 21 08:30:17 2026] 127.0.0.1:37688 Closing +[Tue Apr 21 08:30:22 2026] 127.0.0.1:37694 Accepted +[Tue Apr 21 08:30:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:30:23 2026] 127.0.0.1:37694 Closing +[Tue Apr 21 08:30:24 2026] 127.0.0.1:37710 Accepted +[Tue Apr 21 08:30:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:30:25 2026] 127.0.0.1:37710 Closing +[Tue Apr 21 08:30:25 2026] 127.0.0.1:44306 Accepted +[Tue Apr 21 08:30:28 2026] 127.0.0.1:44306 Closing +[Tue Apr 21 08:30:33 2026] 127.0.0.1:48456 Accepted +[Tue Apr 21 08:30:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:30:33 2026] 127.0.0.1:48456 Closing +[Tue Apr 21 08:30:34 2026] 127.0.0.1:48462 Accepted +[Tue Apr 21 08:30:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:30:35 2026] 127.0.0.1:48462 Closing +[Tue Apr 21 08:30:35 2026] 127.0.0.1:48468 Accepted +[Tue Apr 21 08:30:37 2026] 127.0.0.1:48468 Closing +[Tue Apr 21 08:30:37 2026] 127.0.0.1:48474 Accepted +[Tue Apr 21 08:30:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:30:38 2026] 127.0.0.1:48474 Closing +[Tue Apr 21 08:30:38 2026] 127.0.0.1:48476 Accepted +[Tue Apr 21 08:30:39 2026] 127.0.0.1:48476 Closing +[Tue Apr 21 08:32:20 2026] 127.0.0.1:42026 Accepted +[Tue Apr 21 08:32:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:32:20 2026] 127.0.0.1:42026 Closing +[Tue Apr 21 08:32:21 2026] 127.0.0.1:42042 Accepted +[Tue Apr 21 08:32:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:32:22 2026] 127.0.0.1:42042 Closing +[Tue Apr 21 08:32:22 2026] 127.0.0.1:42050 Accepted +[Tue Apr 21 08:32:25 2026] 127.0.0.1:42050 Closing +[Tue Apr 21 08:32:33 2026] 127.0.0.1:56782 Accepted +[Tue Apr 21 08:32:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:32:34 2026] 127.0.0.1:56782 Closing +[Tue Apr 21 08:32:36 2026] 127.0.0.1:56796 Accepted +[Tue Apr 21 08:32:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:32:37 2026] 127.0.0.1:56796 Closing +[Tue Apr 21 08:32:37 2026] 127.0.0.1:56806 Accepted +[Tue Apr 21 08:32:42 2026] 127.0.0.1:56806 Closing +[Tue Apr 21 08:32:48 2026] 127.0.0.1:40416 Accepted +[Tue Apr 21 08:32:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:32:47 2026] 127.0.0.1:40416 Closing +[Tue Apr 21 08:32:47 2026] 127.0.0.1:40432 Accepted +[Tue Apr 21 08:32:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:32:47 2026] 127.0.0.1:40432 Closing +[Tue Apr 21 08:32:47 2026] 127.0.0.1:40446 Accepted +[Tue Apr 21 08:32:48 2026] 127.0.0.1:40446 Closing +[Tue Apr 21 08:32:49 2026] 127.0.0.1:40454 Accepted +[Tue Apr 21 08:32:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:32:49 2026] 127.0.0.1:40454 Closing +[Tue Apr 21 08:32:49 2026] 127.0.0.1:40458 Accepted +[Tue Apr 21 08:32:52 2026] 127.0.0.1:40458 Closing +[Tue Apr 21 08:32:54 2026] 127.0.0.1:48762 Accepted +[Tue Apr 21 08:32:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:32:55 2026] 127.0.0.1:48762 Closing +[Tue Apr 21 08:32:56 2026] 127.0.0.1:48778 Accepted +[Tue Apr 21 08:32:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:32:57 2026] 127.0.0.1:48778 Closing +[Tue Apr 21 08:32:57 2026] 127.0.0.1:48794 Accepted +[Tue Apr 21 08:33:02 2026] 127.0.0.1:48794 Closing +[Tue Apr 21 08:33:06 2026] 127.0.0.1:33506 Accepted +[Tue Apr 21 08:33:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:07 2026] 127.0.0.1:33506 Closing +[Tue Apr 21 08:33:07 2026] 127.0.0.1:33510 Accepted +[Tue Apr 21 08:33:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:08 2026] 127.0.0.1:33510 Closing +[Tue Apr 21 08:33:08 2026] 127.0.0.1:33524 Accepted +[Tue Apr 21 08:33:11 2026] 127.0.0.1:33524 Closing +[Tue Apr 21 08:33:11 2026] 127.0.0.1:52916 Accepted +[Tue Apr 21 08:33:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:12 2026] 127.0.0.1:52916 Closing +[Tue Apr 21 08:33:12 2026] 127.0.0.1:52920 Accepted +[Tue Apr 21 08:33:13 2026] 127.0.0.1:52920 Closing +[Tue Apr 21 08:33:13 2026] 127.0.0.1:52934 Accepted +[Tue Apr 21 08:33:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:15 2026] 127.0.0.1:52934 Closing +[Tue Apr 21 08:33:15 2026] 127.0.0.1:52936 Accepted +[Tue Apr 21 08:33:14 2026] 127.0.0.1:52936 Closing +[Tue Apr 21 08:33:14 2026] 127.0.0.1:52946 Accepted +[Tue Apr 21 08:33:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:15 2026] 127.0.0.1:52946 Closing +[Tue Apr 21 08:33:15 2026] 127.0.0.1:52950 Accepted +[Tue Apr 21 08:33:17 2026] 127.0.0.1:52950 Closing +[Tue Apr 21 08:33:21 2026] 127.0.0.1:38616 Accepted +[Tue Apr 21 08:33:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:22 2026] 127.0.0.1:38616 Closing +[Tue Apr 21 08:33:23 2026] 127.0.0.1:38630 Accepted +[Tue Apr 21 08:33:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:23 2026] 127.0.0.1:38630 Closing +[Tue Apr 21 08:33:23 2026] 127.0.0.1:38636 Accepted +[Tue Apr 21 08:33:29 2026] 127.0.0.1:38636 Closing +[Tue Apr 21 08:33:36 2026] 127.0.0.1:43520 Accepted +[Tue Apr 21 08:33:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:37 2026] 127.0.0.1:43520 Closing +[Tue Apr 21 08:33:38 2026] 127.0.0.1:43534 Accepted +[Tue Apr 21 08:33:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:38 2026] 127.0.0.1:43534 Closing +[Tue Apr 21 08:33:38 2026] 127.0.0.1:54204 Accepted +[Tue Apr 21 08:33:43 2026] 127.0.0.1:54204 Closing +[Tue Apr 21 08:33:43 2026] 127.0.0.1:54220 Accepted +[Tue Apr 21 08:33:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:43 2026] 127.0.0.1:54220 Closing +[Tue Apr 21 08:33:43 2026] 127.0.0.1:54234 Accepted +[Tue Apr 21 08:33:44 2026] 127.0.0.1:54234 Closing +[Tue Apr 21 08:33:50 2026] 127.0.0.1:43308 Accepted +[Tue Apr 21 08:33:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:51 2026] 127.0.0.1:43308 Closing +[Tue Apr 21 08:33:58 2026] 127.0.0.1:37758 Accepted +[Tue Apr 21 08:33:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:33:59 2026] 127.0.0.1:37758 Closing +[Tue Apr 21 08:34:04 2026] 127.0.0.1:37766 Accepted +[Tue Apr 21 08:34:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:05 2026] 127.0.0.1:37766 Closing +[Tue Apr 21 08:34:06 2026] 127.0.0.1:35176 Accepted +[Tue Apr 21 08:34:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:06 2026] 127.0.0.1:35176 Closing +[Tue Apr 21 08:34:06 2026] 127.0.0.1:35188 Accepted +[Tue Apr 21 08:34:09 2026] 127.0.0.1:35188 Closing +[Tue Apr 21 08:34:09 2026] 127.0.0.1:35204 Accepted +[Tue Apr 21 08:34:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:10 2026] 127.0.0.1:35204 Closing +[Tue Apr 21 08:34:10 2026] 127.0.0.1:35220 Accepted +[Tue Apr 21 08:34:10 2026] 127.0.0.1:35220 Closing +[Tue Apr 21 08:34:10 2026] 127.0.0.1:35236 Accepted +[Tue Apr 21 08:34:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:10 2026] 127.0.0.1:35236 Closing +[Tue Apr 21 08:34:10 2026] 127.0.0.1:35242 Accepted +[Tue Apr 21 08:34:13 2026] 127.0.0.1:35242 Closing +[Tue Apr 21 08:34:13 2026] 127.0.0.1:35258 Accepted +[Tue Apr 21 08:34:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:13 2026] 127.0.0.1:35258 Closing +[Tue Apr 21 08:34:13 2026] 127.0.0.1:35262 Accepted +[Tue Apr 21 08:34:15 2026] 127.0.0.1:35262 Closing +[Tue Apr 21 08:34:15 2026] 127.0.0.1:37582 Accepted +[Tue Apr 21 08:34:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:16 2026] 127.0.0.1:37582 Closing +[Tue Apr 21 08:34:16 2026] 127.0.0.1:37588 Accepted +[Tue Apr 21 08:34:18 2026] 127.0.0.1:37588 Closing +[Tue Apr 21 08:34:18 2026] 127.0.0.1:37604 Accepted +[Tue Apr 21 08:34:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:18 2026] 127.0.0.1:37604 Closing +[Tue Apr 21 08:34:18 2026] 127.0.0.1:37614 Accepted +[Tue Apr 21 08:34:20 2026] 127.0.0.1:37614 Closing +[Tue Apr 21 08:34:25 2026] 127.0.0.1:59704 Accepted +[Tue Apr 21 08:34:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:28 2026] 127.0.0.1:59704 Closing +[Tue Apr 21 08:34:28 2026] 127.0.0.1:59712 Accepted +[Tue Apr 21 08:34:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:29 2026] 127.0.0.1:59712 Closing +[Tue Apr 21 08:34:29 2026] 127.0.0.1:59720 Accepted +[Tue Apr 21 08:34:31 2026] 127.0.0.1:59720 Closing +[Tue Apr 21 08:34:31 2026] 127.0.0.1:59722 Accepted +[Tue Apr 21 08:34:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:31 2026] 127.0.0.1:59722 Closing +[Tue Apr 21 08:34:31 2026] 127.0.0.1:59728 Accepted +[Tue Apr 21 08:34:32 2026] 127.0.0.1:59728 Closing +[Tue Apr 21 08:34:35 2026] 127.0.0.1:55356 Accepted +[Tue Apr 21 08:34:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:36 2026] 127.0.0.1:55356 Closing +[Tue Apr 21 08:34:37 2026] 127.0.0.1:55366 Accepted +[Tue Apr 21 08:34:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:37 2026] 127.0.0.1:55366 Closing +[Tue Apr 21 08:34:37 2026] 127.0.0.1:55368 Accepted +[Tue Apr 21 08:34:40 2026] 127.0.0.1:55368 Closing +[Tue Apr 21 08:34:46 2026] 127.0.0.1:38064 Accepted +[Tue Apr 21 08:34:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:47 2026] 127.0.0.1:38064 Closing +[Tue Apr 21 08:34:48 2026] 127.0.0.1:38070 Accepted +[Tue Apr 21 08:34:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:49 2026] 127.0.0.1:38070 Closing +[Tue Apr 21 08:34:49 2026] 127.0.0.1:38074 Accepted +[Tue Apr 21 08:34:51 2026] 127.0.0.1:38074 Closing +[Tue Apr 21 08:34:51 2026] 127.0.0.1:58306 Accepted +[Tue Apr 21 08:34:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:52 2026] 127.0.0.1:58306 Closing +[Tue Apr 21 08:34:52 2026] 127.0.0.1:58318 Accepted +[Tue Apr 21 08:34:55 2026] 127.0.0.1:58318 Closing +[Tue Apr 21 08:34:55 2026] 127.0.0.1:58330 Accepted +[Tue Apr 21 08:34:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:34:56 2026] 127.0.0.1:58330 Closing +[Tue Apr 21 08:34:56 2026] 127.0.0.1:58346 Accepted +[Tue Apr 21 08:34:59 2026] 127.0.0.1:58346 Closing +[Tue Apr 21 08:34:59 2026] 127.0.0.1:58358 Accepted +[Tue Apr 21 08:34:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:01 2026] 127.0.0.1:58358 Closing +[Tue Apr 21 08:35:01 2026] 127.0.0.1:57072 Accepted +[Tue Apr 21 08:35:04 2026] 127.0.0.1:57072 Closing +[Tue Apr 21 08:35:04 2026] 127.0.0.1:57088 Accepted +[Tue Apr 21 08:35:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:05 2026] 127.0.0.1:57088 Closing +[Tue Apr 21 08:35:05 2026] 127.0.0.1:57100 Accepted +[Tue Apr 21 08:35:05 2026] 127.0.0.1:57100 Closing +[Tue Apr 21 08:35:05 2026] 127.0.0.1:57108 Accepted +[Tue Apr 21 08:35:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:06 2026] 127.0.0.1:57108 Closing +[Tue Apr 21 08:35:06 2026] 127.0.0.1:57124 Accepted +[Tue Apr 21 08:35:09 2026] 127.0.0.1:57124 Closing +[Tue Apr 21 08:35:15 2026] 127.0.0.1:45818 Accepted +[Tue Apr 21 08:35:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:15 2026] 127.0.0.1:45818 Closing +[Tue Apr 21 08:35:15 2026] 127.0.0.1:45830 Accepted +[Tue Apr 21 08:35:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:16 2026] 127.0.0.1:45830 Closing +[Tue Apr 21 08:35:16 2026] 127.0.0.1:45832 Accepted +[Tue Apr 21 08:35:18 2026] 127.0.0.1:45832 Closing +[Tue Apr 21 08:35:20 2026] 127.0.0.1:59276 Accepted +[Tue Apr 21 08:35:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:21 2026] 127.0.0.1:59276 Closing +[Tue Apr 21 08:35:22 2026] 127.0.0.1:59284 Accepted +[Tue Apr 21 08:35:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:22 2026] 127.0.0.1:59284 Closing +[Tue Apr 21 08:35:22 2026] 127.0.0.1:59300 Accepted +[Tue Apr 21 08:35:24 2026] 127.0.0.1:59300 Closing +[Tue Apr 21 08:35:24 2026] 127.0.0.1:59308 Accepted +[Tue Apr 21 08:35:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:25 2026] 127.0.0.1:59308 Closing +[Tue Apr 21 08:35:25 2026] 127.0.0.1:59310 Accepted +[Tue Apr 21 08:35:26 2026] 127.0.0.1:59310 Closing +[Tue Apr 21 08:35:32 2026] 127.0.0.1:37456 Accepted +[Tue Apr 21 08:35:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:32 2026] 127.0.0.1:37456 Closing +[Tue Apr 21 08:35:34 2026] 127.0.0.1:37470 Accepted +[Tue Apr 21 08:35:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:32 2026] 127.0.0.1:37470 Closing +[Tue Apr 21 08:35:32 2026] 127.0.0.1:37486 Accepted +[Tue Apr 21 08:35:35 2026] 127.0.0.1:37486 Closing +[Tue Apr 21 08:35:40 2026] 127.0.0.1:42040 Accepted +[Tue Apr 21 08:35:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:40 2026] 127.0.0.1:42040 Closing +[Tue Apr 21 08:35:40 2026] 127.0.0.1:42042 Accepted +[Tue Apr 21 08:35:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:41 2026] 127.0.0.1:42042 Closing +[Tue Apr 21 08:35:41 2026] 127.0.0.1:42056 Accepted +[Tue Apr 21 08:35:42 2026] 127.0.0.1:42056 Closing +[Tue Apr 21 08:35:42 2026] 127.0.0.1:42064 Accepted +[Tue Apr 21 08:35:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:35:43 2026] 127.0.0.1:42064 Closing +[Tue Apr 21 08:35:43 2026] 127.0.0.1:42080 Accepted +[Tue Apr 21 08:35:45 2026] 127.0.0.1:42080 Closing +[Tue Apr 21 08:36:32 2026] 127.0.0.1:56310 Accepted +[Tue Apr 21 08:36:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:36:33 2026] 127.0.0.1:56310 Closing +[Tue Apr 21 08:36:34 2026] 127.0.0.1:56316 Accepted +[Tue Apr 21 08:36:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:36:35 2026] 127.0.0.1:56316 Closing +[Tue Apr 21 08:36:35 2026] 127.0.0.1:56330 Accepted +[Tue Apr 21 08:36:38 2026] 127.0.0.1:56330 Closing +[Tue Apr 21 08:36:45 2026] 127.0.0.1:54024 Accepted +[Tue Apr 21 08:36:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:36:46 2026] 127.0.0.1:54024 Closing +[Tue Apr 21 08:36:47 2026] 127.0.0.1:54032 Accepted +[Tue Apr 21 08:36:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:36:47 2026] 127.0.0.1:54032 Closing +[Tue Apr 21 08:36:47 2026] 127.0.0.1:54038 Accepted +[Tue Apr 21 08:36:52 2026] 127.0.0.1:54038 Closing +[Tue Apr 21 08:36:55 2026] 127.0.0.1:58872 Accepted +[Tue Apr 21 08:36:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:36:55 2026] 127.0.0.1:58872 Closing +[Tue Apr 21 08:36:55 2026] 127.0.0.1:58874 Accepted +[Tue Apr 21 08:36:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:36:56 2026] 127.0.0.1:58874 Closing +[Tue Apr 21 08:36:56 2026] 127.0.0.1:58876 Accepted +[Tue Apr 21 08:36:57 2026] 127.0.0.1:58876 Closing +[Tue Apr 21 08:36:57 2026] 127.0.0.1:58888 Accepted +[Tue Apr 21 08:36:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:36:57 2026] 127.0.0.1:58888 Closing +[Tue Apr 21 08:36:57 2026] 127.0.0.1:48330 Accepted +[Tue Apr 21 08:37:00 2026] 127.0.0.1:48330 Closing +[Tue Apr 21 08:37:02 2026] 127.0.0.1:48340 Accepted +[Tue Apr 21 08:37:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:03 2026] 127.0.0.1:48340 Closing +[Tue Apr 21 08:37:03 2026] 127.0.0.1:48346 Accepted +[Tue Apr 21 08:37:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:04 2026] 127.0.0.1:48346 Closing +[Tue Apr 21 08:37:04 2026] 127.0.0.1:48352 Accepted +[Tue Apr 21 08:37:08 2026] 127.0.0.1:48352 Closing +[Tue Apr 21 08:37:12 2026] 127.0.0.1:51998 Accepted +[Tue Apr 21 08:37:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:13 2026] 127.0.0.1:51998 Closing +[Tue Apr 21 08:37:13 2026] 127.0.0.1:52004 Accepted +[Tue Apr 21 08:37:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:14 2026] 127.0.0.1:52004 Closing +[Tue Apr 21 08:37:14 2026] 127.0.0.1:52016 Accepted +[Tue Apr 21 08:37:16 2026] 127.0.0.1:52016 Closing +[Tue Apr 21 08:37:16 2026] 127.0.0.1:52024 Accepted +[Tue Apr 21 08:37:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:16 2026] 127.0.0.1:52024 Closing +[Tue Apr 21 08:37:16 2026] 127.0.0.1:52028 Accepted +[Tue Apr 21 08:37:18 2026] 127.0.0.1:52028 Closing +[Tue Apr 21 08:37:18 2026] 127.0.0.1:55842 Accepted +[Tue Apr 21 08:37:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:19 2026] 127.0.0.1:55842 Closing +[Tue Apr 21 08:37:19 2026] 127.0.0.1:55858 Accepted +[Tue Apr 21 08:37:20 2026] 127.0.0.1:55858 Closing +[Tue Apr 21 08:37:21 2026] 127.0.0.1:55866 Accepted +[Tue Apr 21 08:37:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:21 2026] 127.0.0.1:55866 Closing +[Tue Apr 21 08:37:21 2026] 127.0.0.1:55878 Accepted +[Tue Apr 21 08:37:23 2026] 127.0.0.1:55878 Closing +[Tue Apr 21 08:37:24 2026] 127.0.0.1:55890 Accepted +[Tue Apr 21 08:37:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:25 2026] 127.0.0.1:55890 Closing +[Tue Apr 21 08:37:26 2026] 127.0.0.1:52118 Accepted +[Tue Apr 21 08:37:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:26 2026] 127.0.0.1:52118 Closing +[Tue Apr 21 08:37:26 2026] 127.0.0.1:52132 Accepted +[Tue Apr 21 08:37:31 2026] 127.0.0.1:52132 Closing +[Tue Apr 21 08:37:37 2026] 127.0.0.1:44942 Accepted +[Tue Apr 21 08:37:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:38 2026] 127.0.0.1:44942 Closing +[Tue Apr 21 08:37:39 2026] 127.0.0.1:44958 Accepted +[Tue Apr 21 08:37:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:39 2026] 127.0.0.1:44958 Closing +[Tue Apr 21 08:37:39 2026] 127.0.0.1:44974 Accepted +[Tue Apr 21 08:37:43 2026] 127.0.0.1:44974 Closing +[Tue Apr 21 08:37:43 2026] 127.0.0.1:44988 Accepted +[Tue Apr 21 08:37:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:44 2026] 127.0.0.1:44988 Closing +[Tue Apr 21 08:37:44 2026] 127.0.0.1:44992 Accepted +[Tue Apr 21 08:37:47 2026] 127.0.0.1:44992 Closing +[Tue Apr 21 08:37:51 2026] 127.0.0.1:44304 Accepted +[Tue Apr 21 08:37:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:37:52 2026] 127.0.0.1:44304 Closing +[Tue Apr 21 08:37:59 2026] 127.0.0.1:46586 Accepted +[Tue Apr 21 08:37:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:00 2026] 127.0.0.1:46586 Closing +[Tue Apr 21 08:38:06 2026] 127.0.0.1:50020 Accepted +[Tue Apr 21 08:38:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:06 2026] 127.0.0.1:50020 Closing +[Tue Apr 21 08:38:07 2026] 127.0.0.1:50034 Accepted +[Tue Apr 21 08:38:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:07 2026] 127.0.0.1:50034 Closing +[Tue Apr 21 08:38:07 2026] 127.0.0.1:50036 Accepted +[Tue Apr 21 08:38:10 2026] 127.0.0.1:50036 Closing +[Tue Apr 21 08:38:10 2026] 127.0.0.1:50044 Accepted +[Tue Apr 21 08:38:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:10 2026] 127.0.0.1:50044 Closing +[Tue Apr 21 08:38:10 2026] 127.0.0.1:50054 Accepted +[Tue Apr 21 08:38:12 2026] 127.0.0.1:50054 Closing +[Tue Apr 21 08:38:12 2026] 127.0.0.1:41470 Accepted +[Tue Apr 21 08:38:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:13 2026] 127.0.0.1:41470 Closing +[Tue Apr 21 08:38:13 2026] 127.0.0.1:41472 Accepted +[Tue Apr 21 08:38:15 2026] 127.0.0.1:41472 Closing +[Tue Apr 21 08:38:15 2026] 127.0.0.1:41482 Accepted +[Tue Apr 21 08:38:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:15 2026] 127.0.0.1:41482 Closing +[Tue Apr 21 08:38:15 2026] 127.0.0.1:41494 Accepted +[Tue Apr 21 08:38:18 2026] 127.0.0.1:41494 Closing +[Tue Apr 21 08:38:18 2026] 127.0.0.1:41508 Accepted +[Tue Apr 21 08:38:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:15 2026] 127.0.0.1:41508 Closing +[Tue Apr 21 08:38:15 2026] 127.0.0.1:41522 Accepted +[Tue Apr 21 08:38:18 2026] 127.0.0.1:41522 Closing +[Tue Apr 21 08:38:18 2026] 127.0.0.1:41532 Accepted +[Tue Apr 21 08:38:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:18 2026] 127.0.0.1:41532 Closing +[Tue Apr 21 08:38:18 2026] 127.0.0.1:41548 Accepted +[Tue Apr 21 08:38:20 2026] 127.0.0.1:41548 Closing +[Tue Apr 21 08:38:26 2026] 127.0.0.1:50308 Accepted +[Tue Apr 21 08:38:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:26 2026] 127.0.0.1:50308 Closing +[Tue Apr 21 08:38:26 2026] 127.0.0.1:50310 Accepted +[Tue Apr 21 08:38:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:27 2026] 127.0.0.1:50310 Closing +[Tue Apr 21 08:38:27 2026] 127.0.0.1:50324 Accepted +[Tue Apr 21 08:38:29 2026] 127.0.0.1:50324 Closing +[Tue Apr 21 08:38:29 2026] 127.0.0.1:50336 Accepted +[Tue Apr 21 08:38:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:29 2026] 127.0.0.1:50336 Closing +[Tue Apr 21 08:38:29 2026] 127.0.0.1:50338 Accepted +[Tue Apr 21 08:38:30 2026] 127.0.0.1:50338 Closing +[Tue Apr 21 08:38:33 2026] 127.0.0.1:36982 Accepted +[Tue Apr 21 08:38:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:34 2026] 127.0.0.1:36982 Closing +[Tue Apr 21 08:38:35 2026] 127.0.0.1:36996 Accepted +[Tue Apr 21 08:38:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:35 2026] 127.0.0.1:36996 Closing +[Tue Apr 21 08:38:35 2026] 127.0.0.1:36998 Accepted +[Tue Apr 21 08:38:41 2026] 127.0.0.1:36998 Closing +[Tue Apr 21 08:38:44 2026] 127.0.0.1:54474 Accepted +[Tue Apr 21 08:38:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:45 2026] 127.0.0.1:54474 Closing +[Tue Apr 21 08:38:46 2026] 127.0.0.1:54482 Accepted +[Tue Apr 21 08:38:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:47 2026] 127.0.0.1:54482 Closing +[Tue Apr 21 08:38:47 2026] 127.0.0.1:48460 Accepted +[Tue Apr 21 08:38:50 2026] 127.0.0.1:48460 Closing +[Tue Apr 21 08:38:50 2026] 127.0.0.1:48462 Accepted +[Tue Apr 21 08:38:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:50 2026] 127.0.0.1:48462 Closing +[Tue Apr 21 08:38:50 2026] 127.0.0.1:48472 Accepted +[Tue Apr 21 08:38:52 2026] 127.0.0.1:48472 Closing +[Tue Apr 21 08:38:52 2026] 127.0.0.1:48484 Accepted +[Tue Apr 21 08:38:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:53 2026] 127.0.0.1:48484 Closing +[Tue Apr 21 08:38:53 2026] 127.0.0.1:48486 Accepted +[Tue Apr 21 08:38:56 2026] 127.0.0.1:48486 Closing +[Tue Apr 21 08:38:56 2026] 127.0.0.1:48498 Accepted +[Tue Apr 21 08:38:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:38:57 2026] 127.0.0.1:48498 Closing +[Tue Apr 21 08:38:57 2026] 127.0.0.1:47426 Accepted +[Tue Apr 21 08:38:59 2026] 127.0.0.1:47426 Closing +[Tue Apr 21 08:38:59 2026] 127.0.0.1:47438 Accepted +[Tue Apr 21 08:38:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:00 2026] 127.0.0.1:47438 Closing +[Tue Apr 21 08:39:00 2026] 127.0.0.1:47444 Accepted +[Tue Apr 21 08:39:02 2026] 127.0.0.1:47444 Closing +[Tue Apr 21 08:39:02 2026] 127.0.0.1:47456 Accepted +[Tue Apr 21 08:39:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:03 2026] 127.0.0.1:47456 Closing +[Tue Apr 21 08:39:03 2026] 127.0.0.1:47458 Accepted +[Tue Apr 21 08:39:06 2026] 127.0.0.1:47458 Closing +[Tue Apr 21 08:39:11 2026] 127.0.0.1:57526 Accepted +[Tue Apr 21 08:39:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:11 2026] 127.0.0.1:57526 Closing +[Tue Apr 21 08:39:12 2026] 127.0.0.1:57528 Accepted +[Tue Apr 21 08:39:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:12 2026] 127.0.0.1:57528 Closing +[Tue Apr 21 08:39:12 2026] 127.0.0.1:57540 Accepted +[Tue Apr 21 08:39:11 2026] 127.0.0.1:57540 Closing +[Tue Apr 21 08:39:14 2026] 127.0.0.1:57542 Accepted +[Tue Apr 21 08:39:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:14 2026] 127.0.0.1:57542 Closing +[Tue Apr 21 08:39:15 2026] 127.0.0.1:39198 Accepted +[Tue Apr 21 08:39:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:16 2026] 127.0.0.1:39198 Closing +[Tue Apr 21 08:39:16 2026] 127.0.0.1:39206 Accepted +[Tue Apr 21 08:39:17 2026] 127.0.0.1:39206 Closing +[Tue Apr 21 08:39:17 2026] 127.0.0.1:39220 Accepted +[Tue Apr 21 08:39:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:18 2026] 127.0.0.1:39220 Closing +[Tue Apr 21 08:39:18 2026] 127.0.0.1:39222 Accepted +[Tue Apr 21 08:39:19 2026] 127.0.0.1:39222 Closing +[Tue Apr 21 08:39:24 2026] 127.0.0.1:38660 Accepted +[Tue Apr 21 08:39:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:25 2026] 127.0.0.1:38660 Closing +[Tue Apr 21 08:39:26 2026] 127.0.0.1:38672 Accepted +[Tue Apr 21 08:39:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:26 2026] 127.0.0.1:38672 Closing +[Tue Apr 21 08:39:26 2026] 127.0.0.1:38684 Accepted +[Tue Apr 21 08:39:29 2026] 127.0.0.1:38684 Closing +[Tue Apr 21 08:39:34 2026] 127.0.0.1:42206 Accepted +[Tue Apr 21 08:39:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:35 2026] 127.0.0.1:42206 Closing +[Tue Apr 21 08:39:35 2026] 127.0.0.1:42214 Accepted +[Tue Apr 21 08:39:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:36 2026] 127.0.0.1:42214 Closing +[Tue Apr 21 08:39:36 2026] 127.0.0.1:42220 Accepted +[Tue Apr 21 08:39:38 2026] 127.0.0.1:42220 Closing +[Tue Apr 21 08:39:38 2026] 127.0.0.1:42222 Accepted +[Tue Apr 21 08:39:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:39:38 2026] 127.0.0.1:42222 Closing +[Tue Apr 21 08:39:38 2026] 127.0.0.1:42238 Accepted +[Tue Apr 21 08:39:40 2026] 127.0.0.1:42238 Closing +[Tue Apr 21 08:41:52 2026] 127.0.0.1:33936 Accepted +[Tue Apr 21 08:41:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:41:53 2026] 127.0.0.1:33936 Closing +[Tue Apr 21 08:41:55 2026] 127.0.0.1:33942 Accepted +[Tue Apr 21 08:41:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:41:55 2026] 127.0.0.1:33942 Closing +[Tue Apr 21 08:41:55 2026] 127.0.0.1:33954 Accepted +[Tue Apr 21 08:41:56 2026] 127.0.0.1:33954 Closing +[Tue Apr 21 08:42:04 2026] 127.0.0.1:38466 Accepted +[Tue Apr 21 08:42:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:04 2026] 127.0.0.1:38466 Closing +[Tue Apr 21 08:42:05 2026] 127.0.0.1:38482 Accepted +[Tue Apr 21 08:42:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:06 2026] 127.0.0.1:38482 Closing +[Tue Apr 21 08:42:06 2026] 127.0.0.1:38494 Accepted +[Tue Apr 21 08:42:10 2026] 127.0.0.1:38494 Closing +[Tue Apr 21 08:42:16 2026] 127.0.0.1:60048 Accepted +[Tue Apr 21 08:42:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:16 2026] 127.0.0.1:60048 Closing +[Tue Apr 21 08:42:16 2026] 127.0.0.1:60060 Accepted +[Tue Apr 21 08:42:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:17 2026] 127.0.0.1:60060 Closing +[Tue Apr 21 08:42:17 2026] 127.0.0.1:60076 Accepted +[Tue Apr 21 08:42:17 2026] 127.0.0.1:60076 Closing +[Tue Apr 21 08:42:17 2026] 127.0.0.1:60082 Accepted +[Tue Apr 21 08:42:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:18 2026] 127.0.0.1:60082 Closing +[Tue Apr 21 08:42:18 2026] 127.0.0.1:35288 Accepted +[Tue Apr 21 08:42:20 2026] 127.0.0.1:35288 Closing +[Tue Apr 21 08:42:23 2026] 127.0.0.1:35290 Accepted +[Tue Apr 21 08:42:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:23 2026] 127.0.0.1:35290 Closing +[Tue Apr 21 08:42:21 2026] 127.0.0.1:35292 Accepted +[Tue Apr 21 08:42:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:22 2026] 127.0.0.1:35292 Closing +[Tue Apr 21 08:42:22 2026] 127.0.0.1:35298 Accepted +[Tue Apr 21 08:42:26 2026] 127.0.0.1:35298 Closing +[Tue Apr 21 08:42:30 2026] 127.0.0.1:41052 Accepted +[Tue Apr 21 08:42:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:30 2026] 127.0.0.1:41052 Closing +[Tue Apr 21 08:42:31 2026] 127.0.0.1:41064 Accepted +[Tue Apr 21 08:42:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:31 2026] 127.0.0.1:41064 Closing +[Tue Apr 21 08:42:31 2026] 127.0.0.1:41076 Accepted +[Tue Apr 21 08:42:33 2026] 127.0.0.1:41076 Closing +[Tue Apr 21 08:42:33 2026] 127.0.0.1:41090 Accepted +[Tue Apr 21 08:42:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:33 2026] 127.0.0.1:41090 Closing +[Tue Apr 21 08:42:33 2026] 127.0.0.1:41098 Accepted +[Tue Apr 21 08:42:34 2026] 127.0.0.1:41098 Closing +[Tue Apr 21 08:42:34 2026] 127.0.0.1:41106 Accepted +[Tue Apr 21 08:42:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:35 2026] 127.0.0.1:41106 Closing +[Tue Apr 21 08:42:35 2026] 127.0.0.1:58110 Accepted +[Tue Apr 21 08:42:37 2026] 127.0.0.1:58110 Closing +[Tue Apr 21 08:42:37 2026] 127.0.0.1:58114 Accepted +[Tue Apr 21 08:42:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:37 2026] 127.0.0.1:58114 Closing +[Tue Apr 21 08:42:37 2026] 127.0.0.1:58124 Accepted +[Tue Apr 21 08:42:38 2026] 127.0.0.1:58124 Closing +[Tue Apr 21 08:42:42 2026] 127.0.0.1:58130 Accepted +[Tue Apr 21 08:42:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:43 2026] 127.0.0.1:58130 Closing +[Tue Apr 21 08:42:44 2026] 127.0.0.1:58132 Accepted +[Tue Apr 21 08:42:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:44 2026] 127.0.0.1:58132 Closing +[Tue Apr 21 08:42:44 2026] 127.0.0.1:58140 Accepted +[Tue Apr 21 08:42:48 2026] 127.0.0.1:58140 Closing +[Tue Apr 21 08:42:52 2026] 127.0.0.1:55692 Accepted +[Tue Apr 21 08:42:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:53 2026] 127.0.0.1:55692 Closing +[Tue Apr 21 08:42:54 2026] 127.0.0.1:42170 Accepted +[Tue Apr 21 08:42:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:54 2026] 127.0.0.1:42170 Closing +[Tue Apr 21 08:42:54 2026] 127.0.0.1:42178 Accepted +[Tue Apr 21 08:42:58 2026] 127.0.0.1:42178 Closing +[Tue Apr 21 08:42:58 2026] 127.0.0.1:42190 Accepted +[Tue Apr 21 08:42:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:42:59 2026] 127.0.0.1:42190 Closing +[Tue Apr 21 08:42:59 2026] 127.0.0.1:42204 Accepted +[Tue Apr 21 08:43:02 2026] 127.0.0.1:42204 Closing +[Tue Apr 21 08:43:08 2026] 127.0.0.1:60494 Accepted +[Tue Apr 21 08:43:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:09 2026] 127.0.0.1:60494 Closing +[Tue Apr 21 08:43:10 2026] 127.0.0.1:60504 Accepted +[Tue Apr 21 08:43:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:11 2026] 127.0.0.1:60504 Closing +[Tue Apr 21 08:43:11 2026] 127.0.0.1:60508 Accepted +[Tue Apr 21 08:43:14 2026] 127.0.0.1:60508 Closing +[Tue Apr 21 08:43:18 2026] 127.0.0.1:50612 Accepted +[Tue Apr 21 08:43:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:19 2026] 127.0.0.1:50612 Closing +[Tue Apr 21 08:43:20 2026] 127.0.0.1:43492 Accepted +[Tue Apr 21 08:43:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:20 2026] 127.0.0.1:43492 Closing +[Tue Apr 21 08:43:20 2026] 127.0.0.1:43494 Accepted +[Tue Apr 21 08:43:23 2026] 127.0.0.1:43494 Closing +[Tue Apr 21 08:43:29 2026] 127.0.0.1:43498 Accepted +[Tue Apr 21 08:43:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:29 2026] 127.0.0.1:43498 Closing +[Tue Apr 21 08:43:30 2026] 127.0.0.1:52050 Accepted +[Tue Apr 21 08:43:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:31 2026] 127.0.0.1:52050 Closing +[Tue Apr 21 08:43:31 2026] 127.0.0.1:52064 Accepted +[Tue Apr 21 08:43:36 2026] 127.0.0.1:52064 Closing +[Tue Apr 21 08:43:36 2026] 127.0.0.1:52078 Accepted +[Tue Apr 21 08:43:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:38 2026] 127.0.0.1:52078 Closing +[Tue Apr 21 08:43:38 2026] 127.0.0.1:52090 Accepted +[Tue Apr 21 08:43:42 2026] 127.0.0.1:52090 Closing +[Tue Apr 21 08:43:42 2026] 127.0.0.1:34256 Accepted +[Tue Apr 21 08:43:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:44 2026] 127.0.0.1:34256 Closing +[Tue Apr 21 08:43:44 2026] 127.0.0.1:34258 Accepted +[Tue Apr 21 08:43:44 2026] 127.0.0.1:34258 Closing +[Tue Apr 21 08:43:44 2026] 127.0.0.1:34264 Accepted +[Tue Apr 21 08:43:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:45 2026] 127.0.0.1:34264 Closing +[Tue Apr 21 08:43:45 2026] 127.0.0.1:34266 Accepted +[Tue Apr 21 08:43:47 2026] 127.0.0.1:34266 Closing +[Tue Apr 21 08:43:47 2026] 127.0.0.1:56920 Accepted +[Tue Apr 21 08:43:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:48 2026] 127.0.0.1:56920 Closing +[Tue Apr 21 08:43:48 2026] 127.0.0.1:56932 Accepted +[Tue Apr 21 08:43:50 2026] 127.0.0.1:56932 Closing +[Tue Apr 21 08:43:50 2026] 127.0.0.1:56940 Accepted +[Tue Apr 21 08:43:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:51 2026] 127.0.0.1:56940 Closing +[Tue Apr 21 08:43:51 2026] 127.0.0.1:56946 Accepted +[Tue Apr 21 08:43:53 2026] 127.0.0.1:56946 Closing +[Tue Apr 21 08:43:58 2026] 127.0.0.1:47898 Accepted +[Tue Apr 21 08:43:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:43:59 2026] 127.0.0.1:47898 Closing +[Tue Apr 21 08:43:59 2026] 127.0.0.1:47912 Accepted +[Tue Apr 21 08:43:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:00 2026] 127.0.0.1:47912 Closing +[Tue Apr 21 08:44:00 2026] 127.0.0.1:47916 Accepted +[Tue Apr 21 08:44:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:02 2026] 127.0.0.1:47916 Closing +[Tue Apr 21 08:44:02 2026] 127.0.0.1:47920 Accepted +[Tue Apr 21 08:44:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:02 2026] 127.0.0.1:47920 Closing +[Tue Apr 21 08:44:02 2026] 127.0.0.1:47922 Accepted +[Tue Apr 21 08:44:03 2026] 127.0.0.1:47922 Closing +[Tue Apr 21 08:44:03 2026] 127.0.0.1:47928 Accepted +[Tue Apr 21 08:44:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:05 2026] 127.0.0.1:47928 Closing +[Tue Apr 21 08:44:05 2026] 127.0.0.1:47940 Accepted +[Tue Apr 21 08:44:05 2026] 127.0.0.1:47940 Closing +[Tue Apr 21 08:44:05 2026] 127.0.0.1:47956 Accepted +[Tue Apr 21 08:44:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:09 2026] 127.0.0.1:47956 Closing +[Tue Apr 21 08:44:09 2026] 127.0.0.1:53268 Accepted +[Tue Apr 21 08:44:09 2026] 127.0.0.1:53268 Closing +[Tue Apr 21 08:44:10 2026] 127.0.0.1:53274 Accepted +[Tue Apr 21 08:44:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:11 2026] 127.0.0.1:53274 Closing +[Tue Apr 21 08:44:11 2026] 127.0.0.1:53276 Accepted +[Tue Apr 21 08:44:13 2026] 127.0.0.1:53276 Closing +[Tue Apr 21 08:44:13 2026] 127.0.0.1:53280 Accepted +[Tue Apr 21 08:44:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:14 2026] 127.0.0.1:53280 Closing +[Tue Apr 21 08:44:15 2026] 127.0.0.1:45126 Accepted +[Tue Apr 21 08:44:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:16 2026] 127.0.0.1:45126 Closing +[Tue Apr 21 08:44:16 2026] 127.0.0.1:45136 Accepted +[Tue Apr 21 08:44:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:21 2026] 127.0.0.1:45136 Closing +[Tue Apr 21 08:44:21 2026] 127.0.0.1:45146 Accepted +[Tue Apr 21 08:44:21 2026] 127.0.0.1:45146 Closing +[Tue Apr 21 08:44:22 2026] 127.0.0.1:45152 Accepted +[Tue Apr 21 08:44:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:22 2026] 127.0.0.1:45152 Closing +[Tue Apr 21 08:44:22 2026] 127.0.0.1:45168 Accepted +[Tue Apr 21 08:44:25 2026] 127.0.0.1:45168 Closing +[Tue Apr 21 08:44:25 2026] 127.0.0.1:39192 Accepted +[Tue Apr 21 08:44:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:26 2026] 127.0.0.1:39192 Closing +[Tue Apr 21 08:44:26 2026] 127.0.0.1:39208 Accepted +[Tue Apr 21 08:44:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:28 2026] 127.0.0.1:39208 Closing +[Tue Apr 21 08:44:28 2026] 127.0.0.1:39220 Accepted +[Tue Apr 21 08:44:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:28 2026] 127.0.0.1:39220 Closing +[Tue Apr 21 08:44:28 2026] 127.0.0.1:39226 Accepted +[Tue Apr 21 08:44:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:29 2026] 127.0.0.1:39226 Closing +[Tue Apr 21 08:44:29 2026] 127.0.0.1:39234 Accepted +[Tue Apr 21 08:44:29 2026] 127.0.0.1:39234 Closing +[Tue Apr 21 08:44:29 2026] 127.0.0.1:39240 Accepted +[Tue Apr 21 08:44:31 2026] 127.0.0.1:39240 Closing +[Tue Apr 21 08:44:31 2026] 127.0.0.1:39252 Accepted +[Tue Apr 21 08:44:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:32 2026] 127.0.0.1:39252 Closing +[Tue Apr 21 08:44:32 2026] 127.0.0.1:39254 Accepted +[Tue Apr 21 08:44:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:33 2026] 127.0.0.1:39254 Closing +[Tue Apr 21 08:44:33 2026] 127.0.0.1:39256 Accepted +[Tue Apr 21 08:44:33 2026] 127.0.0.1:39260 Accepted +[Tue Apr 21 08:44:33 2026] 127.0.0.1:39256 Closing +[Tue Apr 21 08:44:35 2026] 127.0.0.1:39260 Closing +[Tue Apr 21 08:44:35 2026] 127.0.0.1:39274 Accepted +[Tue Apr 21 08:44:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:38 2026] 127.0.0.1:39274 Closing +[Tue Apr 21 08:44:38 2026] 127.0.0.1:53182 Accepted +[Tue Apr 21 08:44:39 2026] 127.0.0.1:53182 Closing +[Tue Apr 21 08:44:39 2026] 127.0.0.1:53196 Accepted +[Tue Apr 21 08:44:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:38 2026] 127.0.0.1:53196 Closing +[Tue Apr 21 08:44:38 2026] 127.0.0.1:53200 Accepted +[Tue Apr 21 08:44:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:39 2026] 127.0.0.1:53200 Closing +[Tue Apr 21 08:44:39 2026] 127.0.0.1:53210 Accepted +[Tue Apr 21 08:44:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:40 2026] 127.0.0.1:53210 Closing +[Tue Apr 21 08:44:40 2026] 127.0.0.1:53218 Accepted +[Tue Apr 21 08:44:40 2026] 127.0.0.1:53218 Closing +[Tue Apr 21 08:44:40 2026] 127.0.0.1:53228 Accepted +[Tue Apr 21 08:44:43 2026] 127.0.0.1:53228 Closing +[Tue Apr 21 08:44:43 2026] 127.0.0.1:53238 Accepted +[Tue Apr 21 08:44:47 2026] 127.0.0.1:53238 Closing +[Tue Apr 21 08:44:48 2026] 127.0.0.1:58846 Accepted +[Tue Apr 21 08:44:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:48 2026] 127.0.0.1:58846 Closing +[Tue Apr 21 08:44:49 2026] 127.0.0.1:58848 Accepted +[Tue Apr 21 08:44:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:49 2026] 127.0.0.1:58848 Closing +[Tue Apr 21 08:44:49 2026] 127.0.0.1:58852 Accepted +[Tue Apr 21 08:44:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:51 2026] 127.0.0.1:58852 Closing +[Tue Apr 21 08:44:51 2026] 127.0.0.1:58866 Accepted +[Tue Apr 21 08:44:52 2026] 127.0.0.1:58866 Closing +[Tue Apr 21 08:44:53 2026] 127.0.0.1:45250 Accepted +[Tue Apr 21 08:44:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:53 2026] 127.0.0.1:45250 Closing +[Tue Apr 21 08:44:53 2026] 127.0.0.1:45264 Accepted +[Tue Apr 21 08:44:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:55 2026] 127.0.0.1:45264 Closing +[Tue Apr 21 08:44:55 2026] 127.0.0.1:45272 Accepted +[Tue Apr 21 08:44:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:55 2026] 127.0.0.1:45272 Closing +[Tue Apr 21 08:44:55 2026] 127.0.0.1:45276 Accepted +[Tue Apr 21 08:44:56 2026] 127.0.0.1:45276 Closing +[Tue Apr 21 08:44:56 2026] 127.0.0.1:45288 Accepted +[Tue Apr 21 08:44:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:58 2026] 127.0.0.1:45288 Closing +[Tue Apr 21 08:44:58 2026] 127.0.0.1:45300 Accepted +[Tue Apr 21 08:44:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:44:58 2026] 127.0.0.1:45300 Closing +[Tue Apr 21 08:44:58 2026] 127.0.0.1:45316 Accepted +[Tue Apr 21 08:44:59 2026] 127.0.0.1:45316 Closing +[Tue Apr 21 08:44:59 2026] 127.0.0.1:45318 Accepted +[Tue Apr 21 08:44:59 2026] 127.0.0.1:45326 Accepted +[Tue Apr 21 08:45:01 2026] 127.0.0.1:45318 Closing +[Tue Apr 21 08:45:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:02 2026] 127.0.0.1:45326 Closing +[Tue Apr 21 08:45:02 2026] 127.0.0.1:45338 Accepted +[Tue Apr 21 08:45:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:03 2026] 127.0.0.1:45338 Closing +[Tue Apr 21 08:45:03 2026] 127.0.0.1:35790 Accepted +[Tue Apr 21 08:45:03 2026] 127.0.0.1:35790 Closing +[Tue Apr 21 08:45:03 2026] 127.0.0.1:35804 Accepted +[Tue Apr 21 08:45:03 2026] 127.0.0.1:35812 Accepted +[Tue Apr 21 08:45:05 2026] 127.0.0.1:35804 Closing +[Tue Apr 21 08:45:07 2026] 127.0.0.1:35812 Closing +[Tue Apr 21 08:45:08 2026] 127.0.0.1:35820 Accepted +[Tue Apr 21 08:45:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:08 2026] 127.0.0.1:35820 Closing +[Tue Apr 21 08:45:08 2026] 127.0.0.1:35822 Accepted +[Tue Apr 21 08:45:09 2026] 127.0.0.1:35822 Closing +[Tue Apr 21 08:45:09 2026] 127.0.0.1:42350 Accepted +[Tue Apr 21 08:45:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:10 2026] 127.0.0.1:42350 Closing +[Tue Apr 21 08:45:10 2026] 127.0.0.1:42358 Accepted +[Tue Apr 21 08:45:11 2026] 127.0.0.1:42358 Closing +[Tue Apr 21 08:45:11 2026] 127.0.0.1:42374 Accepted +[Tue Apr 21 08:45:14 2026] 127.0.0.1:42374 Closing +[Tue Apr 21 08:45:14 2026] 127.0.0.1:42380 Accepted +[Tue Apr 21 08:45:19 2026] 127.0.0.1:42380 Closing +[Tue Apr 21 08:45:19 2026] 127.0.0.1:57698 Accepted +[Tue Apr 21 08:45:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:20 2026] 127.0.0.1:57698 Closing +[Tue Apr 21 08:45:20 2026] 127.0.0.1:57710 Accepted +[Tue Apr 21 08:45:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:21 2026] 127.0.0.1:57710 Closing +[Tue Apr 21 08:45:21 2026] 127.0.0.1:57722 Accepted +[Tue Apr 21 08:45:22 2026] 127.0.0.1:57722 Closing +[Tue Apr 21 08:45:22 2026] 127.0.0.1:57736 Accepted +[Tue Apr 21 08:45:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:23 2026] 127.0.0.1:57736 Closing +[Tue Apr 21 08:45:23 2026] 127.0.0.1:57738 Accepted +[Tue Apr 21 08:45:24 2026] 127.0.0.1:57738 Closing +[Tue Apr 21 08:45:25 2026] 127.0.0.1:57742 Accepted +[Tue Apr 21 08:45:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:26 2026] 127.0.0.1:57742 Closing +[Tue Apr 21 08:45:27 2026] 127.0.0.1:57752 Accepted +[Tue Apr 21 08:45:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:28 2026] 127.0.0.1:57752 Closing +[Tue Apr 21 08:45:28 2026] 127.0.0.1:57762 Accepted +[Tue Apr 21 08:45:32 2026] 127.0.0.1:57762 Closing +[Tue Apr 21 08:45:32 2026] 127.0.0.1:41970 Accepted +[Tue Apr 21 08:45:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:32 2026] 127.0.0.1:41970 Closing +[Tue Apr 21 08:45:32 2026] 127.0.0.1:41986 Accepted +[Tue Apr 21 08:45:32 2026] 127.0.0.1:41986 Closing +[Tue Apr 21 08:45:39 2026] 127.0.0.1:55186 Accepted +[Tue Apr 21 08:45:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:39 2026] 127.0.0.1:55186 Closing +[Tue Apr 21 08:45:40 2026] 127.0.0.1:55192 Accepted +[Tue Apr 21 08:45:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:41 2026] 127.0.0.1:55192 Closing +[Tue Apr 21 08:45:41 2026] 127.0.0.1:55202 Accepted +[Tue Apr 21 08:45:44 2026] 127.0.0.1:55202 Closing +[Tue Apr 21 08:45:51 2026] 127.0.0.1:39182 Accepted +[Tue Apr 21 08:45:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:52 2026] 127.0.0.1:39182 Closing +[Tue Apr 21 08:45:53 2026] 127.0.0.1:39194 Accepted +[Tue Apr 21 08:45:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:45:53 2026] 127.0.0.1:39194 Closing +[Tue Apr 21 08:45:53 2026] 127.0.0.1:39202 Accepted +[Tue Apr 21 08:45:55 2026] 127.0.0.1:39202 Closing +[Tue Apr 21 08:46:01 2026] 127.0.0.1:42394 Accepted +[Tue Apr 21 08:46:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:02 2026] 127.0.0.1:42394 Closing +[Tue Apr 21 08:46:00 2026] 127.0.0.1:42402 Accepted +[Tue Apr 21 08:46:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:00 2026] 127.0.0.1:42402 Closing +[Tue Apr 21 08:46:00 2026] 127.0.0.1:42410 Accepted +[Tue Apr 21 08:46:03 2026] 127.0.0.1:42410 Closing +[Tue Apr 21 08:46:03 2026] 127.0.0.1:42424 Accepted +[Tue Apr 21 08:46:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:03 2026] 127.0.0.1:42424 Closing +[Tue Apr 21 08:46:03 2026] 127.0.0.1:42438 Accepted +[Tue Apr 21 08:46:05 2026] 127.0.0.1:42438 Closing +[Tue Apr 21 08:46:05 2026] 127.0.0.1:56980 Accepted +[Tue Apr 21 08:46:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:05 2026] 127.0.0.1:56980 Closing +[Tue Apr 21 08:46:05 2026] 127.0.0.1:56990 Accepted +[Tue Apr 21 08:46:08 2026] 127.0.0.1:56990 Closing +[Tue Apr 21 08:46:08 2026] 127.0.0.1:56994 Accepted +[Tue Apr 21 08:46:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:08 2026] 127.0.0.1:56994 Closing +[Tue Apr 21 08:46:08 2026] 127.0.0.1:57008 Accepted +[Tue Apr 21 08:46:10 2026] 127.0.0.1:57008 Closing +[Tue Apr 21 08:46:10 2026] 127.0.0.1:57014 Accepted +[Tue Apr 21 08:46:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:11 2026] 127.0.0.1:57014 Closing +[Tue Apr 21 08:46:11 2026] 127.0.0.1:57018 Accepted +[Tue Apr 21 08:46:13 2026] 127.0.0.1:57018 Closing +[Tue Apr 21 08:46:13 2026] 127.0.0.1:56756 Accepted +[Tue Apr 21 08:46:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:14 2026] 127.0.0.1:56756 Closing +[Tue Apr 21 08:46:14 2026] 127.0.0.1:56768 Accepted +[Tue Apr 21 08:46:16 2026] 127.0.0.1:56768 Closing +[Tue Apr 21 08:46:21 2026] 127.0.0.1:56782 Accepted +[Tue Apr 21 08:46:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:21 2026] 127.0.0.1:56782 Closing +[Tue Apr 21 08:46:22 2026] 127.0.0.1:56792 Accepted +[Tue Apr 21 08:46:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:22 2026] 127.0.0.1:56792 Closing +[Tue Apr 21 08:46:22 2026] 127.0.0.1:56794 Accepted +[Tue Apr 21 08:46:24 2026] 127.0.0.1:56794 Closing +[Tue Apr 21 08:46:24 2026] 127.0.0.1:55956 Accepted +[Tue Apr 21 08:46:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:24 2026] 127.0.0.1:55956 Closing +[Tue Apr 21 08:46:24 2026] 127.0.0.1:55970 Accepted +[Tue Apr 21 08:46:26 2026] 127.0.0.1:55970 Closing +[Tue Apr 21 08:46:28 2026] 127.0.0.1:55980 Accepted +[Tue Apr 21 08:46:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:29 2026] 127.0.0.1:55980 Closing +[Tue Apr 21 08:46:27 2026] 127.0.0.1:55992 Accepted +[Tue Apr 21 08:46:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:27 2026] 127.0.0.1:55992 Closing +[Tue Apr 21 08:46:27 2026] 127.0.0.1:56000 Accepted +[Tue Apr 21 08:46:32 2026] 127.0.0.1:56000 Closing +[Tue Apr 21 08:46:38 2026] 127.0.0.1:49798 Accepted +[Tue Apr 21 08:46:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:39 2026] 127.0.0.1:49798 Closing +[Tue Apr 21 08:46:39 2026] 127.0.0.1:49812 Accepted +[Tue Apr 21 08:46:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:40 2026] 127.0.0.1:49812 Closing +[Tue Apr 21 08:46:40 2026] 127.0.0.1:49826 Accepted +[Tue Apr 21 08:46:42 2026] 127.0.0.1:49826 Closing +[Tue Apr 21 08:46:42 2026] 127.0.0.1:42242 Accepted +[Tue Apr 21 08:46:42 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:43 2026] 127.0.0.1:42242 Closing +[Tue Apr 21 08:46:43 2026] 127.0.0.1:42258 Accepted +[Tue Apr 21 08:46:45 2026] 127.0.0.1:42258 Closing +[Tue Apr 21 08:46:45 2026] 127.0.0.1:42270 Accepted +[Tue Apr 21 08:46:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:45 2026] 127.0.0.1:42270 Closing +[Tue Apr 21 08:46:45 2026] 127.0.0.1:42284 Accepted +[Tue Apr 21 08:46:47 2026] 127.0.0.1:42284 Closing +[Tue Apr 21 08:46:47 2026] 127.0.0.1:42294 Accepted +[Tue Apr 21 08:46:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:47 2026] 127.0.0.1:42294 Closing +[Tue Apr 21 08:46:47 2026] 127.0.0.1:42296 Accepted +[Tue Apr 21 08:46:49 2026] 127.0.0.1:42296 Closing +[Tue Apr 21 08:46:49 2026] 127.0.0.1:42312 Accepted +[Tue Apr 21 08:46:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:50 2026] 127.0.0.1:42312 Closing +[Tue Apr 21 08:46:50 2026] 127.0.0.1:42318 Accepted +[Tue Apr 21 08:46:52 2026] 127.0.0.1:42318 Closing +[Tue Apr 21 08:46:52 2026] 127.0.0.1:52466 Accepted +[Tue Apr 21 08:46:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:52 2026] 127.0.0.1:52466 Closing +[Tue Apr 21 08:46:52 2026] 127.0.0.1:52480 Accepted +[Tue Apr 21 08:46:55 2026] 127.0.0.1:52480 Closing +[Tue Apr 21 08:46:57 2026] 127.0.0.1:52492 Accepted +[Tue Apr 21 08:46:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:58 2026] 127.0.0.1:52492 Closing +[Tue Apr 21 08:46:58 2026] 127.0.0.1:58754 Accepted +[Tue Apr 21 08:46:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:46:59 2026] 127.0.0.1:58754 Closing +[Tue Apr 21 08:46:59 2026] 127.0.0.1:58758 Accepted +[Tue Apr 21 08:47:01 2026] 127.0.0.1:58758 Closing +[Tue Apr 21 08:47:03 2026] 127.0.0.1:58760 Accepted +[Tue Apr 21 08:47:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:47:04 2026] 127.0.0.1:58760 Closing +[Tue Apr 21 08:47:04 2026] 127.0.0.1:58770 Accepted +[Tue Apr 21 08:47:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:47:05 2026] 127.0.0.1:58770 Closing +[Tue Apr 21 08:47:05 2026] 127.0.0.1:58778 Accepted +[Tue Apr 21 08:47:06 2026] 127.0.0.1:58778 Closing +[Tue Apr 21 08:47:06 2026] 127.0.0.1:58788 Accepted +[Tue Apr 21 08:47:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:47:07 2026] 127.0.0.1:58788 Closing +[Tue Apr 21 08:47:07 2026] 127.0.0.1:58800 Accepted +[Tue Apr 21 08:47:08 2026] 127.0.0.1:58800 Closing +[Tue Apr 21 08:47:13 2026] 127.0.0.1:56006 Accepted +[Tue Apr 21 08:47:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:47:13 2026] 127.0.0.1:56006 Closing +[Tue Apr 21 08:47:14 2026] 127.0.0.1:56012 Accepted +[Tue Apr 21 08:47:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:47:14 2026] 127.0.0.1:56012 Closing +[Tue Apr 21 08:47:14 2026] 127.0.0.1:56018 Accepted +[Tue Apr 21 08:47:17 2026] 127.0.0.1:56018 Closing +[Tue Apr 21 08:47:22 2026] 127.0.0.1:41548 Accepted +[Tue Apr 21 08:47:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:47:22 2026] 127.0.0.1:41548 Closing +[Tue Apr 21 08:47:23 2026] 127.0.0.1:41556 Accepted +[Tue Apr 21 08:47:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:47:23 2026] 127.0.0.1:41556 Closing +[Tue Apr 21 08:47:23 2026] 127.0.0.1:41572 Accepted +[Tue Apr 21 08:47:22 2026] 127.0.0.1:41572 Closing +[Tue Apr 21 08:47:22 2026] 127.0.0.1:41578 Accepted +[Tue Apr 21 08:47:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:47:22 2026] 127.0.0.1:41578 Closing +[Tue Apr 21 08:47:22 2026] 127.0.0.1:41580 Accepted +[Tue Apr 21 08:47:24 2026] 127.0.0.1:41580 Closing +[Tue Apr 21 08:52:59 2026] 127.0.0.1:56616 Accepted +[Tue Apr 21 08:52:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:00 2026] 127.0.0.1:56616 Closing +[Tue Apr 21 08:53:02 2026] 127.0.0.1:56628 Accepted +[Tue Apr 21 08:53:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:02 2026] 127.0.0.1:56628 Closing +[Tue Apr 21 08:53:02 2026] 127.0.0.1:38048 Accepted +[Tue Apr 21 08:53:06 2026] 127.0.0.1:38048 Closing +[Tue Apr 21 08:53:13 2026] 127.0.0.1:37494 Accepted +[Tue Apr 21 08:53:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:14 2026] 127.0.0.1:37494 Closing +[Tue Apr 21 08:53:14 2026] 127.0.0.1:37508 Accepted +[Tue Apr 21 08:53:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:15 2026] 127.0.0.1:37508 Closing +[Tue Apr 21 08:53:15 2026] 127.0.0.1:37516 Accepted +[Tue Apr 21 08:53:19 2026] 127.0.0.1:37516 Closing +[Tue Apr 21 08:53:24 2026] 127.0.0.1:46902 Accepted +[Tue Apr 21 08:53:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:25 2026] 127.0.0.1:46902 Closing +[Tue Apr 21 08:53:25 2026] 127.0.0.1:46916 Accepted +[Tue Apr 21 08:53:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:25 2026] 127.0.0.1:46916 Closing +[Tue Apr 21 08:53:25 2026] 127.0.0.1:46920 Accepted +[Tue Apr 21 08:53:26 2026] 127.0.0.1:46920 Closing +[Tue Apr 21 08:53:26 2026] 127.0.0.1:46932 Accepted +[Tue Apr 21 08:53:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:27 2026] 127.0.0.1:46932 Closing +[Tue Apr 21 08:53:27 2026] 127.0.0.1:46938 Accepted +[Tue Apr 21 08:53:29 2026] 127.0.0.1:46938 Closing +[Tue Apr 21 08:53:32 2026] 127.0.0.1:48406 Accepted +[Tue Apr 21 08:53:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:32 2026] 127.0.0.1:48406 Closing +[Tue Apr 21 08:53:33 2026] 127.0.0.1:48418 Accepted +[Tue Apr 21 08:53:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:33 2026] 127.0.0.1:48418 Closing +[Tue Apr 21 08:53:33 2026] 127.0.0.1:48432 Accepted +[Tue Apr 21 08:53:38 2026] 127.0.0.1:48432 Closing +[Tue Apr 21 08:53:44 2026] 127.0.0.1:55198 Accepted +[Tue Apr 21 08:53:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:44 2026] 127.0.0.1:55198 Closing +[Tue Apr 21 08:53:45 2026] 127.0.0.1:55210 Accepted +[Tue Apr 21 08:53:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:46 2026] 127.0.0.1:55210 Closing +[Tue Apr 21 08:53:46 2026] 127.0.0.1:55216 Accepted +[Tue Apr 21 08:53:45 2026] 127.0.0.1:55216 Closing +[Tue Apr 21 08:53:45 2026] 127.0.0.1:55230 Accepted +[Tue Apr 21 08:53:45 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:45 2026] 127.0.0.1:55230 Closing +[Tue Apr 21 08:53:45 2026] 127.0.0.1:55242 Accepted +[Tue Apr 21 08:53:47 2026] 127.0.0.1:55242 Closing +[Tue Apr 21 08:53:47 2026] 127.0.0.1:40070 Accepted +[Tue Apr 21 08:53:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:47 2026] 127.0.0.1:40070 Closing +[Tue Apr 21 08:53:47 2026] 127.0.0.1:40086 Accepted +[Tue Apr 21 08:53:49 2026] 127.0.0.1:40086 Closing +[Tue Apr 21 08:53:49 2026] 127.0.0.1:40090 Accepted +[Tue Apr 21 08:53:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:50 2026] 127.0.0.1:40090 Closing +[Tue Apr 21 08:53:50 2026] 127.0.0.1:40094 Accepted +[Tue Apr 21 08:53:52 2026] 127.0.0.1:40094 Closing +[Tue Apr 21 08:53:55 2026] 127.0.0.1:40100 Accepted +[Tue Apr 21 08:53:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:56 2026] 127.0.0.1:40100 Closing +[Tue Apr 21 08:53:57 2026] 127.0.0.1:47014 Accepted +[Tue Apr 21 08:53:57 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:53:58 2026] 127.0.0.1:47014 Closing +[Tue Apr 21 08:53:58 2026] 127.0.0.1:47018 Accepted +[Tue Apr 21 08:54:03 2026] 127.0.0.1:47018 Closing +[Tue Apr 21 08:54:09 2026] 127.0.0.1:47342 Accepted +[Tue Apr 21 08:54:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:10 2026] 127.0.0.1:47342 Closing +[Tue Apr 21 08:54:11 2026] 127.0.0.1:47348 Accepted +[Tue Apr 21 08:54:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:11 2026] 127.0.0.1:47348 Closing +[Tue Apr 21 08:54:11 2026] 127.0.0.1:47352 Accepted +[Tue Apr 21 08:54:12 2026] 127.0.0.1:47352 Closing +[Tue Apr 21 08:54:12 2026] 127.0.0.1:47354 Accepted +[Tue Apr 21 08:54:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:13 2026] 127.0.0.1:47354 Closing +[Tue Apr 21 08:54:13 2026] 127.0.0.1:47358 Accepted +[Tue Apr 21 08:54:16 2026] 127.0.0.1:47358 Closing +[Tue Apr 21 08:54:22 2026] 127.0.0.1:42382 Accepted +[Tue Apr 21 08:54:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:23 2026] 127.0.0.1:42382 Closing +[Tue Apr 21 08:54:30 2026] 127.0.0.1:44134 Accepted +[Tue Apr 21 08:54:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:31 2026] 127.0.0.1:44134 Closing +[Tue Apr 21 08:54:36 2026] 127.0.0.1:48842 Accepted +[Tue Apr 21 08:54:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:37 2026] 127.0.0.1:48842 Closing +[Tue Apr 21 08:54:38 2026] 127.0.0.1:48848 Accepted +[Tue Apr 21 08:54:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:38 2026] 127.0.0.1:48848 Closing +[Tue Apr 21 08:54:38 2026] 127.0.0.1:48860 Accepted +[Tue Apr 21 08:54:38 2026] 127.0.0.1:48860 Closing +[Tue Apr 21 08:54:38 2026] 127.0.0.1:48866 Accepted +[Tue Apr 21 08:54:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:39 2026] 127.0.0.1:48866 Closing +[Tue Apr 21 08:54:39 2026] 127.0.0.1:48878 Accepted +[Tue Apr 21 08:54:40 2026] 127.0.0.1:48878 Closing +[Tue Apr 21 08:54:40 2026] 127.0.0.1:48886 Accepted +[Tue Apr 21 08:54:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:41 2026] 127.0.0.1:48886 Closing +[Tue Apr 21 08:54:41 2026] 127.0.0.1:48890 Accepted +[Tue Apr 21 08:54:43 2026] 127.0.0.1:48890 Closing +[Tue Apr 21 08:54:43 2026] 127.0.0.1:43942 Accepted +[Tue Apr 21 08:54:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:45 2026] 127.0.0.1:43942 Closing +[Tue Apr 21 08:54:45 2026] 127.0.0.1:43952 Accepted +[Tue Apr 21 08:54:48 2026] 127.0.0.1:43952 Closing +[Tue Apr 21 08:54:48 2026] 127.0.0.1:43962 Accepted +[Tue Apr 21 08:54:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:50 2026] 127.0.0.1:43962 Closing +[Tue Apr 21 08:54:50 2026] 127.0.0.1:43974 Accepted +[Tue Apr 21 08:54:52 2026] 127.0.0.1:43974 Closing +[Tue Apr 21 08:54:52 2026] 127.0.0.1:50514 Accepted +[Tue Apr 21 08:54:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:54:53 2026] 127.0.0.1:50514 Closing +[Tue Apr 21 08:54:53 2026] 127.0.0.1:50524 Accepted +[Tue Apr 21 08:54:56 2026] 127.0.0.1:50524 Closing +[Tue Apr 21 08:55:01 2026] 127.0.0.1:50536 Accepted +[Tue Apr 21 08:55:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:03 2026] 127.0.0.1:50536 Closing +[Tue Apr 21 08:55:03 2026] 127.0.0.1:56012 Accepted +[Tue Apr 21 08:55:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:04 2026] 127.0.0.1:56012 Closing +[Tue Apr 21 08:55:04 2026] 127.0.0.1:56014 Accepted +[Tue Apr 21 08:55:06 2026] 127.0.0.1:56014 Closing +[Tue Apr 21 08:55:06 2026] 127.0.0.1:56024 Accepted +[Tue Apr 21 08:55:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:06 2026] 127.0.0.1:56024 Closing +[Tue Apr 21 08:55:06 2026] 127.0.0.1:56032 Accepted +[Tue Apr 21 08:55:05 2026] 127.0.0.1:56032 Closing +[Tue Apr 21 08:55:07 2026] 127.0.0.1:56040 Accepted +[Tue Apr 21 08:55:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:08 2026] 127.0.0.1:56040 Closing +[Tue Apr 21 08:55:09 2026] 127.0.0.1:41424 Accepted +[Tue Apr 21 08:55:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:10 2026] 127.0.0.1:41424 Closing +[Tue Apr 21 08:55:10 2026] 127.0.0.1:41440 Accepted +[Tue Apr 21 08:55:14 2026] 127.0.0.1:41440 Closing +[Tue Apr 21 08:55:21 2026] 127.0.0.1:36150 Accepted +[Tue Apr 21 08:55:21 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:22 2026] 127.0.0.1:36150 Closing +[Tue Apr 21 08:55:22 2026] 127.0.0.1:36162 Accepted +[Tue Apr 21 08:55:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:23 2026] 127.0.0.1:36162 Closing +[Tue Apr 21 08:55:23 2026] 127.0.0.1:36176 Accepted +[Tue Apr 21 08:55:26 2026] 127.0.0.1:36176 Closing +[Tue Apr 21 08:55:26 2026] 127.0.0.1:36184 Accepted +[Tue Apr 21 08:55:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:27 2026] 127.0.0.1:36184 Closing +[Tue Apr 21 08:55:27 2026] 127.0.0.1:36192 Accepted +[Tue Apr 21 08:55:30 2026] 127.0.0.1:36192 Closing +[Tue Apr 21 08:55:30 2026] 127.0.0.1:59846 Accepted +[Tue Apr 21 08:55:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:31 2026] 127.0.0.1:59846 Closing +[Tue Apr 21 08:55:31 2026] 127.0.0.1:59848 Accepted +[Tue Apr 21 08:55:34 2026] 127.0.0.1:59848 Closing +[Tue Apr 21 08:55:34 2026] 127.0.0.1:59852 Accepted +[Tue Apr 21 08:55:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:35 2026] 127.0.0.1:59852 Closing +[Tue Apr 21 08:55:35 2026] 127.0.0.1:59854 Accepted +[Tue Apr 21 08:55:35 2026] 127.0.0.1:59854 Closing +[Tue Apr 21 08:55:35 2026] 127.0.0.1:59858 Accepted +[Tue Apr 21 08:55:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:35 2026] 127.0.0.1:59858 Closing +[Tue Apr 21 08:55:35 2026] 127.0.0.1:59870 Accepted +[Tue Apr 21 08:55:38 2026] 127.0.0.1:59870 Closing +[Tue Apr 21 08:55:38 2026] 127.0.0.1:56116 Accepted +[Tue Apr 21 08:55:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:38 2026] 127.0.0.1:56116 Closing +[Tue Apr 21 08:55:38 2026] 127.0.0.1:56132 Accepted +[Tue Apr 21 08:55:41 2026] 127.0.0.1:56132 Closing +[Tue Apr 21 08:55:46 2026] 127.0.0.1:41646 Accepted +[Tue Apr 21 08:55:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:46 2026] 127.0.0.1:41646 Closing +[Tue Apr 21 08:55:47 2026] 127.0.0.1:41658 Accepted +[Tue Apr 21 08:55:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:47 2026] 127.0.0.1:41658 Closing +[Tue Apr 21 08:55:47 2026] 127.0.0.1:41660 Accepted +[Tue Apr 21 08:55:49 2026] 127.0.0.1:41660 Closing +[Tue Apr 21 08:55:52 2026] 127.0.0.1:41670 Accepted +[Tue Apr 21 08:55:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:52 2026] 127.0.0.1:41670 Closing +[Tue Apr 21 08:55:53 2026] 127.0.0.1:41686 Accepted +[Tue Apr 21 08:55:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:54 2026] 127.0.0.1:41686 Closing +[Tue Apr 21 08:55:54 2026] 127.0.0.1:41690 Accepted +[Tue Apr 21 08:55:56 2026] 127.0.0.1:41690 Closing +[Tue Apr 21 08:55:56 2026] 127.0.0.1:41692 Accepted +[Tue Apr 21 08:55:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:55:56 2026] 127.0.0.1:41692 Closing +[Tue Apr 21 08:55:56 2026] 127.0.0.1:42780 Accepted +[Tue Apr 21 08:55:57 2026] 127.0.0.1:42780 Closing +[Tue Apr 21 08:56:00 2026] 127.0.0.1:42782 Accepted +[Tue Apr 21 08:56:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:56:00 2026] 127.0.0.1:42782 Closing +[Tue Apr 21 08:56:01 2026] 127.0.0.1:42784 Accepted +[Tue Apr 21 08:56:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:56:01 2026] 127.0.0.1:42784 Closing +[Tue Apr 21 08:56:01 2026] 127.0.0.1:42794 Accepted +[Tue Apr 21 08:56:04 2026] 127.0.0.1:42794 Closing +[Tue Apr 21 08:56:09 2026] 127.0.0.1:50462 Accepted +[Tue Apr 21 08:56:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:56:09 2026] 127.0.0.1:50462 Closing +[Tue Apr 21 08:56:10 2026] 127.0.0.1:50476 Accepted +[Tue Apr 21 08:56:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:56:10 2026] 127.0.0.1:50476 Closing +[Tue Apr 21 08:56:10 2026] 127.0.0.1:50482 Accepted +[Tue Apr 21 08:56:12 2026] 127.0.0.1:50482 Closing +[Tue Apr 21 08:56:12 2026] 127.0.0.1:50494 Accepted +[Tue Apr 21 08:56:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:56:12 2026] 127.0.0.1:50494 Closing +[Tue Apr 21 08:56:12 2026] 127.0.0.1:50506 Accepted +[Tue Apr 21 08:56:14 2026] 127.0.0.1:50506 Closing +[Tue Apr 21 08:57:18 2026] 127.0.0.1:60268 Accepted +[Tue Apr 21 08:57:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:57:18 2026] 127.0.0.1:60268 Closing +[Tue Apr 21 08:57:20 2026] 127.0.0.1:44562 Accepted +[Tue Apr 21 08:57:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:57:20 2026] 127.0.0.1:44562 Closing +[Tue Apr 21 08:57:20 2026] 127.0.0.1:44572 Accepted +[Tue Apr 21 08:57:23 2026] 127.0.0.1:44572 Closing +[Tue Apr 21 08:57:27 2026] 127.0.0.1:45256 Accepted +[Tue Apr 21 08:57:27 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:57:28 2026] 127.0.0.1:45256 Closing +[Tue Apr 21 08:57:29 2026] 127.0.0.1:45272 Accepted +[Tue Apr 21 08:57:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:57:29 2026] 127.0.0.1:45272 Closing +[Tue Apr 21 08:57:29 2026] 127.0.0.1:45276 Accepted +[Tue Apr 21 08:57:32 2026] 127.0.0.1:45276 Closing +[Tue Apr 21 08:59:20 2026] 127.0.0.1:42994 Accepted +[Tue Apr 21 08:59:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:59:20 2026] 127.0.0.1:42994 Closing +[Tue Apr 21 08:59:22 2026] 127.0.0.1:43004 Accepted +[Tue Apr 21 08:59:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:59:22 2026] 127.0.0.1:43004 Closing +[Tue Apr 21 08:59:22 2026] 127.0.0.1:43020 Accepted +[Tue Apr 21 08:59:26 2026] 127.0.0.1:43020 Closing +[Tue Apr 21 08:59:33 2026] 127.0.0.1:57628 Accepted +[Tue Apr 21 08:59:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:59:33 2026] 127.0.0.1:57628 Closing +[Tue Apr 21 08:59:34 2026] 127.0.0.1:58824 Accepted +[Tue Apr 21 08:59:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 08:59:34 2026] 127.0.0.1:58824 Closing +[Tue Apr 21 08:59:34 2026] 127.0.0.1:58826 Accepted +[Tue Apr 21 08:59:37 2026] 127.0.0.1:58826 Closing +[Tue Apr 21 09:00:26 2026] 127.0.0.1:33708 Accepted +[Tue Apr 21 09:00:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:00:26 2026] 127.0.0.1:33708 Closing +[Tue Apr 21 09:00:28 2026] 127.0.0.1:33724 Accepted +[Tue Apr 21 09:00:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:00:28 2026] 127.0.0.1:33724 Closing +[Tue Apr 21 09:00:28 2026] 127.0.0.1:33728 Accepted +[Tue Apr 21 09:00:31 2026] 127.0.0.1:33728 Closing +[Tue Apr 21 09:00:36 2026] 127.0.0.1:47244 Accepted +[Tue Apr 21 09:00:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:00:36 2026] 127.0.0.1:47244 Closing +[Tue Apr 21 09:00:37 2026] 127.0.0.1:44858 Accepted +[Tue Apr 21 09:00:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:00:38 2026] 127.0.0.1:44858 Closing +[Tue Apr 21 09:00:38 2026] 127.0.0.1:44874 Accepted +[Tue Apr 21 09:00:41 2026] 127.0.0.1:44874 Closing +[Tue Apr 21 09:05:04 2026] 127.0.0.1:34782 Accepted +[Tue Apr 21 09:05:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:05:05 2026] 127.0.0.1:34782 Closing +[Tue Apr 21 09:05:06 2026] 127.0.0.1:34794 Accepted +[Tue Apr 21 09:05:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:05:07 2026] 127.0.0.1:34794 Closing +[Tue Apr 21 09:05:07 2026] 127.0.0.1:34798 Accepted +[Tue Apr 21 09:05:07 2026] 127.0.0.1:34798 Closing +[Tue Apr 21 09:05:14 2026] 127.0.0.1:58062 Accepted +[Tue Apr 21 09:05:14 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:05:15 2026] 127.0.0.1:58062 Closing +[Tue Apr 21 09:05:16 2026] 127.0.0.1:58074 Accepted +[Tue Apr 21 09:05:16 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:05:16 2026] 127.0.0.1:58074 Closing +[Tue Apr 21 09:05:16 2026] 127.0.0.1:58078 Accepted +[Tue Apr 21 09:05:19 2026] 127.0.0.1:58078 Closing +[Tue Apr 21 09:06:31 2026] 127.0.0.1:43082 Accepted +[Tue Apr 21 09:06:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:06:32 2026] 127.0.0.1:43082 Closing +[Tue Apr 21 09:06:33 2026] 127.0.0.1:43086 Accepted +[Tue Apr 21 09:06:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:06:34 2026] 127.0.0.1:43086 Closing +[Tue Apr 21 09:06:34 2026] 127.0.0.1:43090 Accepted +[Tue Apr 21 09:06:37 2026] 127.0.0.1:43090 Closing +[Tue Apr 21 09:06:44 2026] 127.0.0.1:52170 Accepted +[Tue Apr 21 09:06:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:06:45 2026] 127.0.0.1:52170 Closing +[Tue Apr 21 09:06:46 2026] 127.0.0.1:52184 Accepted +[Tue Apr 21 09:06:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 09:06:46 2026] 127.0.0.1:52184 Closing +[Tue Apr 21 09:06:46 2026] 127.0.0.1:52190 Accepted +[Tue Apr 21 09:06:50 2026] 127.0.0.1:52190 Closing +[Tue Apr 21 09:09:15 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:09:15 2026] 127.0.0.1:37124 Accepted +[Tue Apr 21 09:09:16 2026] 127.0.0.1:37124 Closing +[Tue Apr 21 09:09:17 2026] 127.0.0.1:37132 Accepted +[Tue Apr 21 09:09:21 2026] 127.0.0.1:37132 Closing +[Tue Apr 21 09:09:29 2026] 127.0.0.1:39856 Accepted +[Tue Apr 21 09:09:32 2026] 127.0.0.1:39856 Closing +[Tue Apr 21 09:14:34 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:14:34 2026] 127.0.0.1:42430 Accepted +[Tue Apr 21 09:14:36 2026] 127.0.0.1:42430 Closing +[Tue Apr 21 09:14:37 2026] 127.0.0.1:42440 Accepted +[Tue Apr 21 09:14:39 2026] 127.0.0.1:42440 Closing +[Tue Apr 21 09:14:47 2026] 127.0.0.1:52492 Accepted +[Tue Apr 21 09:14:50 2026] 127.0.0.1:52492 Closing +[Tue Apr 21 09:20:28 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:20:28 2026] 127.0.0.1:36122 Accepted +[Tue Apr 21 09:20:30 2026] 127.0.0.1:36122 Closing +[Tue Apr 21 09:20:31 2026] 127.0.0.1:48862 Accepted +[Tue Apr 21 09:20:35 2026] 127.0.0.1:48862 Closing +[Tue Apr 21 09:20:40 2026] 127.0.0.1:41728 Accepted +[Tue Apr 21 09:20:43 2026] 127.0.0.1:41728 Closing +[Tue Apr 21 09:26:08 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:26:09 2026] 127.0.0.1:53492 Accepted +[Tue Apr 21 09:26:10 2026] 127.0.0.1:53492 Closing +[Tue Apr 21 09:26:12 2026] 127.0.0.1:53508 Accepted +[Tue Apr 21 09:26:16 2026] 127.0.0.1:53508 Closing +[Tue Apr 21 09:26:23 2026] 127.0.0.1:38754 Accepted +[Tue Apr 21 09:26:26 2026] 127.0.0.1:38754 Closing +[Tue Apr 21 09:29:08 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:29:08 2026] 127.0.0.1:42454 Accepted +[Tue Apr 21 09:29:09 2026] 127.0.0.1:42454 Closing +[Tue Apr 21 09:29:10 2026] 127.0.0.1:42464 Accepted +[Tue Apr 21 09:29:11 2026] 127.0.0.1:42464 Closing +[Tue Apr 21 09:29:19 2026] 127.0.0.1:49926 Accepted +[Tue Apr 21 09:29:22 2026] 127.0.0.1:49926 Closing +[Tue Apr 21 09:30:49 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:30:49 2026] 127.0.0.1:55198 Accepted +[Tue Apr 21 09:30:52 2026] 127.0.0.1:55198 Closing +[Tue Apr 21 09:30:53 2026] 127.0.0.1:55200 Accepted +[Tue Apr 21 09:30:58 2026] 127.0.0.1:55200 Closing +[Tue Apr 21 09:31:03 2026] 127.0.0.1:34268 Accepted +[Tue Apr 21 09:31:06 2026] 127.0.0.1:34268 Closing +[Tue Apr 21 09:33:17 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:33:17 2026] 127.0.0.1:59300 Accepted +[Tue Apr 21 09:33:19 2026] 127.0.0.1:59300 Closing +[Tue Apr 21 09:33:18 2026] 127.0.0.1:59302 Accepted +[Tue Apr 21 09:33:21 2026] 127.0.0.1:59302 Closing +[Tue Apr 21 09:33:29 2026] 127.0.0.1:45640 Accepted +[Tue Apr 21 09:33:32 2026] 127.0.0.1:45640 Closing +[Tue Apr 21 09:34:33 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:34:33 2026] 127.0.0.1:46450 Accepted +[Tue Apr 21 09:34:35 2026] 127.0.0.1:46450 Closing +[Tue Apr 21 09:34:36 2026] 127.0.0.1:46576 Accepted +[Tue Apr 21 09:34:40 2026] 127.0.0.1:46576 Closing +[Tue Apr 21 09:34:46 2026] 127.0.0.1:37792 Accepted +[Tue Apr 21 09:34:49 2026] 127.0.0.1:37792 Closing +[Tue Apr 21 09:36:48 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:36:48 2026] 127.0.0.1:35382 Accepted +[Tue Apr 21 09:36:49 2026] 127.0.0.1:35382 Closing +[Tue Apr 21 09:36:50 2026] 127.0.0.1:35406 Accepted +[Tue Apr 21 09:36:56 2026] 127.0.0.1:35406 Closing +[Tue Apr 21 09:37:00 2026] 127.0.0.1:50920 Accepted +[Tue Apr 21 09:37:06 2026] 127.0.0.1:50920 Closing +[Tue Apr 21 09:38:42 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:38:42 2026] 127.0.0.1:39810 Accepted +[Tue Apr 21 09:38:44 2026] 127.0.0.1:39810 Closing +[Tue Apr 21 09:39:47 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:39:47 2026] 127.0.0.1:40678 Accepted +[Tue Apr 21 09:39:49 2026] 127.0.0.1:40678 Closing +[Tue Apr 21 09:40:49 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:40:49 2026] 127.0.0.1:38806 Accepted +[Tue Apr 21 09:40:50 2026] 127.0.0.1:38806 Closing +[Tue Apr 21 09:42:47 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:42:48 2026] 127.0.0.1:47514 Accepted +[Tue Apr 21 09:42:49 2026] 127.0.0.1:47514 Closing +[Tue Apr 21 09:42:50 2026] 127.0.0.1:47528 Accepted +[Tue Apr 21 09:42:58 2026] 127.0.0.1:47528 Closing +[Tue Apr 21 09:43:06 2026] 127.0.0.1:52840 Accepted +[Tue Apr 21 09:43:16 2026] 127.0.0.1:52840 Closing +[Tue Apr 21 09:45:09 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:45:09 2026] 127.0.0.1:53508 Accepted +[Tue Apr 21 09:45:08 2026] 127.0.0.1:53508 Closing +[Tue Apr 21 09:45:09 2026] 127.0.0.1:52992 Accepted +[Tue Apr 21 09:45:15 2026] 127.0.0.1:52992 Closing +[Tue Apr 21 09:45:24 2026] 127.0.0.1:47224 Accepted +[Tue Apr 21 09:45:32 2026] 127.0.0.1:47224 Closing +[Tue Apr 21 09:47:02 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:47:02 2026] 127.0.0.1:48818 Accepted +[Tue Apr 21 09:47:03 2026] 127.0.0.1:48818 Closing +[Tue Apr 21 09:47:05 2026] 127.0.0.1:48828 Accepted +[Tue Apr 21 09:47:10 2026] 127.0.0.1:48828 Closing +[Tue Apr 21 09:47:18 2026] 127.0.0.1:34784 Accepted +[Tue Apr 21 09:47:23 2026] 127.0.0.1:34784 Closing +[Tue Apr 21 09:50:11 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:50:11 2026] 127.0.0.1:46780 Accepted +[Tue Apr 21 09:50:12 2026] 127.0.0.1:46780 Closing +[Tue Apr 21 09:50:13 2026] 127.0.0.1:46794 Accepted +[Tue Apr 21 09:50:18 2026] 127.0.0.1:46794 Closing +[Tue Apr 21 09:50:19 2026] 127.0.0.1:43952 Accepted +[Tue Apr 21 09:50:23 2026] 127.0.0.1:43952 Closing +[Tue Apr 21 09:50:30 2026] 127.0.0.1:55486 Accepted +[Tue Apr 21 09:50:34 2026] 127.0.0.1:55486 Closing +[Tue Apr 21 09:50:34 2026] 127.0.0.1:55496 Accepted +[Tue Apr 21 09:50:35 2026] 127.0.0.1:55496 Closing +[Tue Apr 21 09:53:07 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:53:07 2026] 127.0.0.1:44662 Accepted +[Tue Apr 21 09:53:08 2026] 127.0.0.1:44662 Closing +[Tue Apr 21 09:53:10 2026] 127.0.0.1:44678 Accepted +[Tue Apr 21 09:53:15 2026] 127.0.0.1:44678 Closing +[Tue Apr 21 09:53:15 2026] 127.0.0.1:52068 Accepted +[Tue Apr 21 09:53:23 2026] 127.0.0.1:52068 Closing +[Tue Apr 21 09:53:31 2026] 127.0.0.1:53252 Accepted +[Tue Apr 21 09:53:36 2026] 127.0.0.1:53252 Closing +[Tue Apr 21 09:53:36 2026] 127.0.0.1:53262 Accepted +[Tue Apr 21 09:53:42 2026] 127.0.0.1:53262 Closing +[Tue Apr 21 09:54:57 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 09:54:58 2026] 127.0.0.1:57352 Accepted +[Tue Apr 21 09:54:58 2026] 127.0.0.1:57352 Closing +[Tue Apr 21 09:55:00 2026] 127.0.0.1:57358 Accepted +[Tue Apr 21 09:55:12 2026] 127.0.0.1:57358 Closing +[Tue Apr 21 09:55:13 2026] 127.0.0.1:55050 Accepted +[Tue Apr 21 09:55:36 2026] 127.0.0.1:55050 Closing +[Tue Apr 21 09:55:45 2026] 127.0.0.1:36622 Accepted +[Tue Apr 21 09:56:02 2026] 127.0.0.1:36622 Closing +[Tue Apr 21 09:56:02 2026] 127.0.0.1:60100 Accepted +[Tue Apr 21 09:56:09 2026] 127.0.0.1:60100 Closing +[Tue Apr 21 10:00:16 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 10:00:16 2026] 127.0.0.1:59888 Accepted +[Tue Apr 21 10:00:23 2026] 127.0.0.1:59888 Closing +[Tue Apr 21 10:00:25 2026] 127.0.0.1:36474 Accepted +[Tue Apr 21 10:00:40 2026] 127.0.0.1:36474 Closing +[Tue Apr 21 10:00:41 2026] 127.0.0.1:41064 Accepted +[Tue Apr 21 10:01:10 2026] 127.0.0.1:41064 Closing +[Tue Apr 21 10:01:19 2026] 127.0.0.1:49872 Accepted +[Tue Apr 21 10:01:37 2026] 127.0.0.1:49872 Closing +[Tue Apr 21 10:01:37 2026] 127.0.0.1:59594 Accepted +[Tue Apr 21 10:01:49 2026] 127.0.0.1:59594 Closing +[Tue Apr 21 10:04:37 2026] PHP 8.2.30 Development Server (http://127.0.0.1:18080) started +[Tue Apr 21 10:04:37 2026] 127.0.0.1:35028 Accepted +[Tue Apr 21 10:04:43 2026] 127.0.0.1:35028 Closing +[Tue Apr 21 10:04:45 2026] 127.0.0.1:48198 Accepted +[Tue Apr 21 10:04:58 2026] 127.0.0.1:48198 Closing +[Tue Apr 21 10:04:59 2026] 127.0.0.1:60746 Accepted +[Tue Apr 21 10:05:22 2026] 127.0.0.1:60746 Closing +[Tue Apr 21 10:05:34 2026] 127.0.0.1:36188 Accepted +[Tue Apr 21 10:05:50 2026] 127.0.0.1:36188 Closing +[Tue Apr 21 10:05:50 2026] 127.0.0.1:55030 Accepted +[Tue Apr 21 10:06:17 2026] 127.0.0.1:55030 Closing +[Tue Apr 21 10:07:03 2026] 127.0.0.1:32996 Accepted +[Tue Apr 21 10:07:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:07:07 2026] 127.0.0.1:32996 Closing +[Tue Apr 21 10:07:09 2026] 127.0.0.1:36834 Accepted +[Tue Apr 21 10:07:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:07:11 2026] 127.0.0.1:36834 Closing +[Tue Apr 21 10:07:11 2026] 127.0.0.1:36846 Accepted +[Tue Apr 21 10:07:18 2026] 127.0.0.1:36846 Closing +[Tue Apr 21 10:07:25 2026] 127.0.0.1:36098 Accepted +[Tue Apr 21 10:07:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:07:28 2026] 127.0.0.1:36098 Closing +[Tue Apr 21 10:07:29 2026] 127.0.0.1:36104 Accepted +[Tue Apr 21 10:07:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:07:32 2026] 127.0.0.1:36104 Closing +[Tue Apr 21 10:07:32 2026] 127.0.0.1:36106 Accepted +[Tue Apr 21 10:07:46 2026] 127.0.0.1:36106 Closing +[Tue Apr 21 10:07:53 2026] 127.0.0.1:52604 Accepted +[Tue Apr 21 10:07:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:07:56 2026] 127.0.0.1:52604 Closing +[Tue Apr 21 10:07:56 2026] 127.0.0.1:52620 Accepted +[Tue Apr 21 10:07:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:07:58 2026] 127.0.0.1:52620 Closing +[Tue Apr 21 10:07:58 2026] 127.0.0.1:52630 Accepted +[Tue Apr 21 10:08:03 2026] 127.0.0.1:52630 Closing +[Tue Apr 21 10:08:03 2026] 127.0.0.1:38358 Accepted +[Tue Apr 21 10:08:03 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:08:06 2026] 127.0.0.1:38358 Closing +[Tue Apr 21 10:08:06 2026] 127.0.0.1:38374 Accepted +[Tue Apr 21 10:08:12 2026] 127.0.0.1:38374 Closing +[Tue Apr 21 10:08:15 2026] 127.0.0.1:46864 Accepted +[Tue Apr 21 10:08:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:08:17 2026] 127.0.0.1:46864 Closing +[Tue Apr 21 10:08:18 2026] 127.0.0.1:46878 Accepted +[Tue Apr 21 10:08:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:08:20 2026] 127.0.0.1:46878 Closing +[Tue Apr 21 10:08:20 2026] 127.0.0.1:46880 Accepted +[Tue Apr 21 10:08:25 2026] 127.0.0.1:46880 Closing +[Tue Apr 21 10:08:29 2026] 127.0.0.1:57052 Accepted +[Tue Apr 21 10:08:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:08:32 2026] 127.0.0.1:57052 Closing +[Tue Apr 21 10:08:33 2026] 127.0.0.1:57058 Accepted +[Tue Apr 21 10:08:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:08:35 2026] 127.0.0.1:57058 Closing +[Tue Apr 21 10:08:35 2026] 127.0.0.1:57066 Accepted +[Tue Apr 21 10:08:40 2026] 127.0.0.1:57066 Closing +[Tue Apr 21 10:08:40 2026] 127.0.0.1:36648 Accepted +[Tue Apr 21 10:08:40 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:08:43 2026] 127.0.0.1:36648 Closing +[Tue Apr 21 10:08:43 2026] 127.0.0.1:36656 Accepted +[Tue Apr 21 10:08:47 2026] 127.0.0.1:36656 Closing +[Tue Apr 21 10:08:47 2026] 127.0.0.1:36666 Accepted +[Tue Apr 21 10:08:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:08:47 2026] 127.0.0.1:36666 Closing +[Tue Apr 21 10:08:47 2026] 127.0.0.1:47790 Accepted +[Tue Apr 21 10:08:52 2026] 127.0.0.1:47790 Closing +[Tue Apr 21 10:08:52 2026] 127.0.0.1:47804 Accepted +[Tue Apr 21 10:08:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:08:55 2026] 127.0.0.1:47804 Closing +[Tue Apr 21 10:08:55 2026] 127.0.0.1:47808 Accepted +[Tue Apr 21 10:08:58 2026] 127.0.0.1:47808 Closing +[Tue Apr 21 10:09:02 2026] 127.0.0.1:54830 Accepted +[Tue Apr 21 10:09:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:09:04 2026] 127.0.0.1:54830 Closing +[Tue Apr 21 10:09:05 2026] 127.0.0.1:54844 Accepted +[Tue Apr 21 10:09:05 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:09:07 2026] 127.0.0.1:54844 Closing +[Tue Apr 21 10:09:07 2026] 127.0.0.1:43012 Accepted +[Tue Apr 21 10:09:14 2026] 127.0.0.1:43012 Closing +[Tue Apr 21 10:09:19 2026] 127.0.0.1:39520 Accepted +[Tue Apr 21 10:09:19 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:09:22 2026] 127.0.0.1:39520 Closing +[Tue Apr 21 10:09:23 2026] 127.0.0.1:39522 Accepted +[Tue Apr 21 10:09:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:09:26 2026] 127.0.0.1:39522 Closing +[Tue Apr 21 10:09:26 2026] 127.0.0.1:38236 Accepted +[Tue Apr 21 10:09:34 2026] 127.0.0.1:38236 Closing +[Tue Apr 21 10:09:34 2026] 127.0.0.1:41048 Accepted +[Tue Apr 21 10:09:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:09:37 2026] 127.0.0.1:41048 Closing +[Tue Apr 21 10:09:37 2026] 127.0.0.1:41064 Accepted +[Tue Apr 21 10:09:40 2026] 127.0.0.1:41064 Closing +[Tue Apr 21 10:09:48 2026] 127.0.0.1:52060 Accepted +[Tue Apr 21 10:09:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:09:50 2026] 127.0.0.1:52060 Closing +[Tue Apr 21 10:09:51 2026] 127.0.0.1:56828 Accepted +[Tue Apr 21 10:09:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:09:54 2026] 127.0.0.1:56828 Closing +[Tue Apr 21 10:09:54 2026] 127.0.0.1:56842 Accepted +[Tue Apr 21 10:10:09 2026] 127.0.0.1:56842 Closing +[Tue Apr 21 10:10:09 2026] 127.0.0.1:58918 Accepted +[Tue Apr 21 10:10:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:10:14 2026] 127.0.0.1:58918 Closing +[Tue Apr 21 10:10:14 2026] 127.0.0.1:58920 Accepted +[Tue Apr 21 10:10:36 2026] 127.0.0.1:58920 Closing +[Tue Apr 21 10:10:43 2026] 127.0.0.1:46102 Accepted +[Tue Apr 21 10:10:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:10:48 2026] 127.0.0.1:46102 Closing +[Tue Apr 21 10:10:49 2026] 127.0.0.1:60944 Accepted +[Tue Apr 21 10:10:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:10:51 2026] 127.0.0.1:60944 Closing +[Tue Apr 21 10:10:51 2026] 127.0.0.1:60954 Accepted +[Tue Apr 21 10:11:07 2026] 127.0.0.1:60954 Closing +[Tue Apr 21 10:11:07 2026] 127.0.0.1:47990 Accepted +[Tue Apr 21 10:11:07 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:11:12 2026] 127.0.0.1:47990 Closing +[Tue Apr 21 10:11:12 2026] 127.0.0.1:48000 Accepted +[Tue Apr 21 10:11:33 2026] 127.0.0.1:48000 Closing +[Tue Apr 21 10:11:38 2026] 127.0.0.1:46856 Accepted +[Tue Apr 21 10:11:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:11:43 2026] 127.0.0.1:46856 Closing +[Tue Apr 21 10:11:44 2026] 127.0.0.1:51184 Accepted +[Tue Apr 21 10:11:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:11:47 2026] 127.0.0.1:51184 Closing +[Tue Apr 21 10:11:47 2026] 127.0.0.1:51196 Accepted +[Tue Apr 21 10:11:54 2026] 127.0.0.1:51196 Closing +[Tue Apr 21 10:11:54 2026] 127.0.0.1:48146 Accepted +[Tue Apr 21 10:11:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:11:58 2026] 127.0.0.1:48146 Closing +[Tue Apr 21 10:11:58 2026] 127.0.0.1:48156 Accepted +[Tue Apr 21 10:12:00 2026] 127.0.0.1:48156 Closing +[Tue Apr 21 10:12:00 2026] 127.0.0.1:50554 Accepted +[Tue Apr 21 10:12:00 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:04 2026] 127.0.0.1:50554 Closing +[Tue Apr 21 10:12:04 2026] 127.0.0.1:50562 Accepted +[Tue Apr 21 10:12:09 2026] 127.0.0.1:50562 Closing +[Tue Apr 21 10:12:09 2026] 127.0.0.1:33538 Accepted +[Tue Apr 21 10:12:09 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:12 2026] 127.0.0.1:33538 Closing +[Tue Apr 21 10:12:12 2026] 127.0.0.1:33548 Accepted +[Tue Apr 21 10:12:17 2026] 127.0.0.1:33548 Closing +[Tue Apr 21 10:12:17 2026] 127.0.0.1:33550 Accepted +[Tue Apr 21 10:12:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:19 2026] 127.0.0.1:33550 Closing +[Tue Apr 21 10:12:19 2026] 127.0.0.1:41146 Accepted +[Tue Apr 21 10:12:22 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:24 2026] 127.0.0.1:41146 Closing +[Tue Apr 21 10:12:24 2026] 127.0.0.1:41154 Accepted +[Tue Apr 21 10:12:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:24 2026] 127.0.0.1:41154 Closing +[Tue Apr 21 10:12:24 2026] 127.0.0.1:41170 Accepted +[Tue Apr 21 10:12:26 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:27 2026] 127.0.0.1:41170 Closing +[Tue Apr 21 10:12:27 2026] 127.0.0.1:57128 Accepted +[Tue Apr 21 10:12:27 2026] 127.0.0.1:57132 Accepted +[Tue Apr 21 10:12:31 2026] 127.0.0.1:57128 Closing +[Tue Apr 21 10:12:36 2026] 127.0.0.1:57132 Closing +[Tue Apr 21 10:12:36 2026] 127.0.0.1:57134 Accepted +[Tue Apr 21 10:12:41 2026] 127.0.0.1:57134 Closing +[Tue Apr 21 10:12:41 2026] 127.0.0.1:57154 Accepted +[Tue Apr 21 10:12:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:43 2026] 127.0.0.1:57154 Closing +[Tue Apr 21 10:12:44 2026] 127.0.0.1:57158 Accepted +[Tue Apr 21 10:12:44 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:46 2026] 127.0.0.1:57158 Closing +[Tue Apr 21 10:12:46 2026] 127.0.0.1:41420 Accepted +[Tue Apr 21 10:12:49 2026] 127.0.0.1:41420 Closing +[Tue Apr 21 10:12:49 2026] 127.0.0.1:41436 Accepted +[Tue Apr 21 10:12:49 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:50 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:51 2026] 127.0.0.1:41436 Closing +[Tue Apr 21 10:12:51 2026] 127.0.0.1:41438 Accepted +[Tue Apr 21 10:12:51 2026] 127.0.0.1:41452 Accepted +[Tue Apr 21 10:12:53 2026] 127.0.0.1:41438 Closing +[Tue Apr 21 10:12:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:54 2026] 127.0.0.1:41452 Closing +[Tue Apr 21 10:12:54 2026] 127.0.0.1:41464 Accepted +[Tue Apr 21 10:12:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:12:56 2026] 127.0.0.1:41464 Closing +[Tue Apr 21 10:12:56 2026] 127.0.0.1:52728 Accepted +[Tue Apr 21 10:12:56 2026] 127.0.0.1:52736 Accepted +[Tue Apr 21 10:12:58 2026] 127.0.0.1:52728 Closing +[Tue Apr 21 10:12:59 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:13:12 2026] 127.0.0.1:52736 Closing +[Tue Apr 21 10:13:12 2026] 127.0.0.1:52744 Accepted +[Tue Apr 21 10:13:15 2026] 127.0.0.1:52744 Closing +[Tue Apr 21 10:13:15 2026] 127.0.0.1:38398 Accepted +[Tue Apr 21 10:13:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:13:20 2026] 127.0.0.1:38398 Closing +[Tue Apr 21 10:13:20 2026] 127.0.0.1:38412 Accepted +[Tue Apr 21 10:13:23 2026] 127.0.0.1:38412 Closing +[Tue Apr 21 10:13:23 2026] 127.0.0.1:35616 Accepted +[Tue Apr 21 10:13:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:13:25 2026] 127.0.0.1:35616 Closing +[Tue Apr 21 10:13:25 2026] 127.0.0.1:35626 Accepted +[Tue Apr 21 10:13:28 2026] 127.0.0.1:35626 Closing +[Tue Apr 21 10:13:28 2026] 127.0.0.1:35642 Accepted +[Tue Apr 21 10:13:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:13:28 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:13:31 2026] 127.0.0.1:35642 Closing +[Tue Apr 21 10:13:31 2026] 127.0.0.1:35646 Accepted +[Tue Apr 21 10:13:34 2026] 127.0.0.1:35646 Closing +[Tue Apr 21 10:13:34 2026] 127.0.0.1:52314 Accepted +[Tue Apr 21 10:13:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:13:39 2026] 127.0.0.1:52314 Closing +[Tue Apr 21 10:13:39 2026] 127.0.0.1:52326 Accepted +[Tue Apr 21 10:13:41 2026] 127.0.0.1:52326 Closing +[Tue Apr 21 10:13:41 2026] 127.0.0.1:49312 Accepted +[Tue Apr 21 10:13:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:13:48 2026] 127.0.0.1:49312 Closing +[Tue Apr 21 10:13:48 2026] 127.0.0.1:49322 Accepted +[Tue Apr 21 10:13:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:13:47 2026] 127.0.0.1:49322 Closing +[Tue Apr 21 10:13:47 2026] 127.0.0.1:49326 Accepted +[Tue Apr 21 10:13:48 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:13:50 2026] 127.0.0.1:49326 Closing +[Tue Apr 21 10:13:50 2026] 127.0.0.1:46478 Accepted +[Tue Apr 21 10:13:50 2026] 127.0.0.1:46494 Accepted +[Tue Apr 21 10:13:53 2026] 127.0.0.1:46478 Closing +[Tue Apr 21 10:13:58 2026] 127.0.0.1:46494 Closing +[Tue Apr 21 10:13:58 2026] 127.0.0.1:46510 Accepted +[Tue Apr 21 10:13:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:14:06 2026] 127.0.0.1:46510 Closing +[Tue Apr 21 10:14:06 2026] 127.0.0.1:36746 Accepted +[Tue Apr 21 10:14:08 2026] 127.0.0.1:36746 Closing +[Tue Apr 21 10:14:08 2026] 127.0.0.1:44590 Accepted +[Tue Apr 21 10:14:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:14:12 2026] 127.0.0.1:44590 Closing +[Tue Apr 21 10:14:12 2026] 127.0.0.1:44594 Accepted +[Tue Apr 21 10:14:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:14:14 2026] 127.0.0.1:44594 Closing +[Tue Apr 21 10:14:14 2026] 127.0.0.1:44610 Accepted +[Tue Apr 21 10:14:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:14:16 2026] 127.0.0.1:44610 Closing +[Tue Apr 21 10:14:16 2026] 127.0.0.1:44616 Accepted +[Tue Apr 21 10:14:16 2026] 127.0.0.1:44628 Accepted +[Tue Apr 21 10:14:15 2026] 127.0.0.1:44616 Closing +[Tue Apr 21 10:14:20 2026] 127.0.0.1:44628 Closing +[Tue Apr 21 10:14:20 2026] 127.0.0.1:33896 Accepted +[Tue Apr 21 10:14:20 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:14:24 2026] 127.0.0.1:33896 Closing +[Tue Apr 21 10:14:24 2026] 127.0.0.1:33904 Accepted +[Tue Apr 21 10:14:24 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:14:27 2026] 127.0.0.1:33904 Closing +[Tue Apr 21 10:14:27 2026] 127.0.0.1:57434 Accepted +[Tue Apr 21 10:14:27 2026] 127.0.0.1:57442 Accepted +[Tue Apr 21 10:14:29 2026] 127.0.0.1:57434 Closing +[Tue Apr 21 10:14:33 2026] 127.0.0.1:57442 Closing +[Tue Apr 21 10:14:33 2026] 127.0.0.1:57448 Accepted +[Tue Apr 21 10:14:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:14:37 2026] 127.0.0.1:57448 Closing +[Tue Apr 21 10:14:37 2026] 127.0.0.1:57460 Accepted +[Tue Apr 21 10:14:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:14:40 2026] 127.0.0.1:57460 Closing +[Tue Apr 21 10:14:40 2026] 127.0.0.1:49038 Accepted +[Tue Apr 21 10:14:40 2026] 127.0.0.1:49052 Accepted +[Tue Apr 21 10:14:43 2026] 127.0.0.1:49038 Closing +[Tue Apr 21 10:14:46 2026] 127.0.0.1:49052 Closing +[Tue Apr 21 10:14:46 2026] 127.0.0.1:49062 Accepted +[Tue Apr 21 10:14:51 2026] 127.0.0.1:49062 Closing +[Tue Apr 21 10:14:51 2026] 127.0.0.1:43262 Accepted +[Tue Apr 21 10:14:51 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:14:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:14:55 2026] 127.0.0.1:43262 Closing +[Tue Apr 21 10:14:55 2026] 127.0.0.1:43272 Accepted +[Tue Apr 21 10:14:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:14:58 2026] 127.0.0.1:43272 Closing +[Tue Apr 21 10:14:58 2026] 127.0.0.1:43288 Accepted +[Tue Apr 21 10:14:58 2026] 127.0.0.1:43294 Accepted +[Tue Apr 21 10:15:01 2026] 127.0.0.1:43288 Closing +[Tue Apr 21 10:15:05 2026] 127.0.0.1:43294 Closing +[Tue Apr 21 10:15:05 2026] 127.0.0.1:43310 Accepted +[Tue Apr 21 10:15:10 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:15:08 2026] 127.0.0.1:43310 Closing +[Tue Apr 21 10:15:08 2026] 127.0.0.1:40804 Accepted +[Tue Apr 21 10:15:11 2026] 127.0.0.1:40804 Closing +[Tue Apr 21 10:15:11 2026] 127.0.0.1:32910 Accepted +[Tue Apr 21 10:15:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:15:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:15:14 2026] 127.0.0.1:32910 Closing +[Tue Apr 21 10:15:14 2026] 127.0.0.1:32912 Accepted +[Tue Apr 21 10:15:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:15:17 2026] 127.0.0.1:32912 Closing +[Tue Apr 21 10:15:17 2026] 127.0.0.1:32928 Accepted +[Tue Apr 21 10:15:17 2026] 127.0.0.1:32936 Accepted +[Tue Apr 21 10:15:20 2026] 127.0.0.1:32928 Closing +[Tue Apr 21 10:15:29 2026] 127.0.0.1:32936 Closing +[Tue Apr 21 10:15:29 2026] 127.0.0.1:43988 Accepted +[Tue Apr 21 10:15:34 2026] 127.0.0.1:43988 Closing +[Tue Apr 21 10:15:34 2026] 127.0.0.1:60440 Accepted +[Tue Apr 21 10:15:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:15:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:15:37 2026] 127.0.0.1:60440 Closing +[Tue Apr 21 10:15:37 2026] 127.0.0.1:60456 Accepted +[Tue Apr 21 10:15:37 2026] 127.0.0.1:60464 Accepted +[Tue Apr 21 10:15:37 2026] 127.0.0.1:60456 Closing +[Tue Apr 21 10:15:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:15:41 2026] 127.0.0.1:60464 Closing +[Tue Apr 21 10:15:41 2026] 127.0.0.1:43070 Accepted +[Tue Apr 21 10:15:45 2026] 127.0.0.1:43070 Closing +[Tue Apr 21 10:15:45 2026] 127.0.0.1:43076 Accepted +[Tue Apr 21 10:15:47 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:15:54 2026] 127.0.0.1:43076 Closing +[Tue Apr 21 10:15:54 2026] 127.0.0.1:46048 Accepted +[Tue Apr 21 10:15:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:15:57 2026] 127.0.0.1:46048 Closing +[Tue Apr 21 10:15:57 2026] 127.0.0.1:46060 Accepted +[Tue Apr 21 10:15:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:16:00 2026] 127.0.0.1:46060 Closing +[Tue Apr 21 10:16:00 2026] 127.0.0.1:58510 Accepted +[Tue Apr 21 10:16:02 2026] 127.0.0.1:58510 Closing +[Tue Apr 21 10:16:02 2026] 127.0.0.1:58526 Accepted +[Tue Apr 21 10:16:02 2026] 127.0.0.1:58528 Accepted +[Tue Apr 21 10:16:06 2026] 127.0.0.1:58526 Closing +[Tue Apr 21 10:16:12 2026] 127.0.0.1:58528 Closing +[Tue Apr 21 10:16:13 2026] 127.0.0.1:48308 Accepted +[Tue Apr 21 10:16:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:16:16 2026] 127.0.0.1:48308 Closing +[Tue Apr 21 10:16:17 2026] 127.0.0.1:48318 Accepted +[Tue Apr 21 10:16:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:16:18 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:16:20 2026] 127.0.0.1:48318 Closing +[Tue Apr 21 10:16:20 2026] 127.0.0.1:48322 Accepted +[Tue Apr 21 10:16:20 2026] 127.0.0.1:48336 Accepted +[Tue Apr 21 10:16:22 2026] 127.0.0.1:48322 Closing +[Tue Apr 21 10:16:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:16:34 2026] 127.0.0.1:48336 Closing +[Tue Apr 21 10:16:34 2026] 127.0.0.1:48348 Accepted +[Tue Apr 21 10:16:34 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:16:38 2026] 127.0.0.1:48348 Closing +[Tue Apr 21 10:16:38 2026] 127.0.0.1:38626 Accepted +[Tue Apr 21 10:16:38 2026] 127.0.0.1:38638 Accepted +[Tue Apr 21 10:16:41 2026] 127.0.0.1:38626 Closing +[Tue Apr 21 10:16:45 2026] 127.0.0.1:38638 Closing +[Tue Apr 21 10:16:45 2026] 127.0.0.1:37094 Accepted +[Tue Apr 21 10:16:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:17:05 2026] 127.0.0.1:37094 Closing +[Tue Apr 21 10:17:05 2026] 127.0.0.1:37106 Accepted +[Tue Apr 21 10:17:09 2026] 127.0.0.1:37106 Closing +[Tue Apr 21 10:17:09 2026] 127.0.0.1:51204 Accepted +[Tue Apr 21 10:17:12 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:17:13 2026] 127.0.0.1:51204 Closing +[Tue Apr 21 10:17:13 2026] 127.0.0.1:51210 Accepted +[Tue Apr 21 10:17:14 2026] 127.0.0.1:51210 Closing +[Tue Apr 21 10:17:15 2026] 127.0.0.1:51222 Accepted +[Tue Apr 21 10:17:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:17:17 2026] 127.0.0.1:51222 Closing +[Tue Apr 21 10:17:17 2026] 127.0.0.1:51224 Accepted +[Tue Apr 21 10:17:29 2026] 127.0.0.1:51224 Closing +[Tue Apr 21 10:17:29 2026] 127.0.0.1:51922 Accepted +[Tue Apr 21 10:17:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:17:34 2026] 127.0.0.1:51922 Closing +[Tue Apr 21 10:17:34 2026] 127.0.0.1:51936 Accepted +[Tue Apr 21 10:17:56 2026] 127.0.0.1:51936 Closing +[Tue Apr 21 10:18:02 2026] 127.0.0.1:42234 Accepted +[Tue Apr 21 10:18:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:18:07 2026] 127.0.0.1:42234 Closing +[Tue Apr 21 10:18:07 2026] 127.0.0.1:42250 Accepted +[Tue Apr 21 10:18:08 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:18:11 2026] 127.0.0.1:42250 Closing +[Tue Apr 21 10:18:11 2026] 127.0.0.1:42258 Accepted +[Tue Apr 21 10:18:17 2026] 127.0.0.1:42258 Closing +[Tue Apr 21 10:18:17 2026] 127.0.0.1:34972 Accepted +[Tue Apr 21 10:18:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:18:20 2026] 127.0.0.1:34972 Closing +[Tue Apr 21 10:18:20 2026] 127.0.0.1:34982 Accepted +[Tue Apr 21 10:18:23 2026] 127.0.0.1:34982 Closing +[Tue Apr 21 10:18:23 2026] 127.0.0.1:55706 Accepted +[Tue Apr 21 10:18:23 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:18:25 2026] 127.0.0.1:55706 Closing +[Tue Apr 21 10:18:25 2026] 127.0.0.1:55708 Accepted +[Tue Apr 21 10:18:30 2026] 127.0.0.1:55708 Closing +[Tue Apr 21 10:18:30 2026] 127.0.0.1:33844 Accepted +[Tue Apr 21 10:18:30 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:18:32 2026] 127.0.0.1:33844 Closing +[Tue Apr 21 10:18:32 2026] 127.0.0.1:33856 Accepted +[Tue Apr 21 10:18:37 2026] 127.0.0.1:33856 Closing +[Tue Apr 21 10:18:37 2026] 127.0.0.1:33858 Accepted +[Tue Apr 21 10:18:37 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:18:39 2026] 127.0.0.1:33858 Closing +[Tue Apr 21 10:18:39 2026] 127.0.0.1:44958 Accepted +[Tue Apr 21 10:18:43 2026] 127.0.0.1:44958 Closing +[Tue Apr 21 10:18:43 2026] 127.0.0.1:44972 Accepted +[Tue Apr 21 10:18:43 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:18:46 2026] 127.0.0.1:44972 Closing +[Tue Apr 21 10:18:46 2026] 127.0.0.1:44984 Accepted +[Tue Apr 21 10:18:47 2026] 127.0.0.1:44984 Closing +[Tue Apr 21 10:18:52 2026] 127.0.0.1:38568 Accepted +[Tue Apr 21 10:18:53 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:18:55 2026] 127.0.0.1:38568 Closing +[Tue Apr 21 10:18:55 2026] 127.0.0.1:38582 Accepted +[Tue Apr 21 10:18:55 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:18:57 2026] 127.0.0.1:38582 Closing +[Tue Apr 21 10:18:57 2026] 127.0.0.1:53292 Accepted +[Tue Apr 21 10:19:02 2026] 127.0.0.1:53292 Closing +[Tue Apr 21 10:19:02 2026] 127.0.0.1:53302 Accepted +[Tue Apr 21 10:19:02 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:19:04 2026] 127.0.0.1:53302 Closing +[Tue Apr 21 10:19:04 2026] 127.0.0.1:53318 Accepted +[Tue Apr 21 10:19:08 2026] 127.0.0.1:53318 Closing +[Tue Apr 21 10:19:11 2026] 127.0.0.1:33594 Accepted +[Tue Apr 21 10:19:11 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:19:13 2026] 127.0.0.1:33594 Closing +[Tue Apr 21 10:19:14 2026] 127.0.0.1:33606 Accepted +[Tue Apr 21 10:19:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:19:14 2026] 127.0.0.1:33606 Closing +[Tue Apr 21 10:19:14 2026] 127.0.0.1:53236 Accepted +[Tue Apr 21 10:19:22 2026] 127.0.0.1:53236 Closing +[Tue Apr 21 10:19:29 2026] 127.0.0.1:54442 Accepted +[Tue Apr 21 10:19:29 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:19:32 2026] 127.0.0.1:54442 Closing +[Tue Apr 21 10:19:33 2026] 127.0.0.1:54450 Accepted +[Tue Apr 21 10:19:33 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:19:35 2026] 127.0.0.1:54450 Closing +[Tue Apr 21 10:19:35 2026] 127.0.0.1:37744 Accepted +[Tue Apr 21 10:19:41 2026] 127.0.0.1:37744 Closing +[Tue Apr 21 10:19:41 2026] 127.0.0.1:37748 Accepted +[Tue Apr 21 10:19:41 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:19:41 2026] 127.0.0.1:37748 Closing +[Tue Apr 21 10:19:41 2026] 127.0.0.1:37760 Accepted +[Tue Apr 21 10:19:46 2026] 127.0.0.1:37760 Closing +[Tue Apr 21 10:19:46 2026] 127.0.0.1:45680 Accepted +[Tue Apr 21 10:19:46 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:19:49 2026] 127.0.0.1:45680 Closing +[Tue Apr 21 10:19:49 2026] 127.0.0.1:45682 Accepted +[Tue Apr 21 10:19:54 2026] 127.0.0.1:45682 Closing +[Tue Apr 21 10:19:54 2026] 127.0.0.1:49242 Accepted +[Tue Apr 21 10:19:54 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:19:56 2026] 127.0.0.1:49242 Closing +[Tue Apr 21 10:19:56 2026] 127.0.0.1:49254 Accepted +[Tue Apr 21 10:19:58 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:20:01 2026] 127.0.0.1:49254 Closing +[Tue Apr 21 10:20:01 2026] 127.0.0.1:49256 Accepted +[Tue Apr 21 10:20:01 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:20:04 2026] 127.0.0.1:49256 Closing +[Tue Apr 21 10:20:04 2026] 127.0.0.1:38612 Accepted +[Tue Apr 21 10:20:06 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:20:10 2026] 127.0.0.1:38612 Closing +[Tue Apr 21 10:20:10 2026] 127.0.0.1:38616 Accepted +[Tue Apr 21 10:20:10 2026] 127.0.0.1:38624 Accepted +[Tue Apr 21 10:20:10 2026] 127.0.0.1:38616 Closing +[Tue Apr 21 10:20:15 2026] 127.0.0.1:38624 Closing +[Tue Apr 21 10:20:15 2026] 127.0.0.1:36188 Accepted +[Tue Apr 21 10:20:15 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:20:20 2026] 127.0.0.1:36188 Closing +[Tue Apr 21 10:20:20 2026] 127.0.0.1:36202 Accepted +[Tue Apr 21 10:20:23 2026] 127.0.0.1:36202 Closing +[Tue Apr 21 10:20:23 2026] 127.0.0.1:53424 Accepted +[Tue Apr 21 10:20:25 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:20:29 2026] 127.0.0.1:53424 Closing +[Tue Apr 21 10:20:29 2026] 127.0.0.1:53434 Accepted +[Tue Apr 21 10:20:34 2026] 127.0.0.1:53434 Closing +[Tue Apr 21 10:20:34 2026] 127.0.0.1:34112 Accepted +[Tue Apr 21 10:20:35 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:20:36 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:20:37 2026] 127.0.0.1:34112 Closing +[Tue Apr 21 10:20:37 2026] 127.0.0.1:34118 Accepted +[Tue Apr 21 10:20:37 2026] 127.0.0.1:34126 Accepted +[Tue Apr 21 10:20:37 2026] 127.0.0.1:34118 Closing +[Tue Apr 21 10:20:38 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:20:42 2026] 127.0.0.1:34126 Closing +[Tue Apr 21 10:20:42 2026] 127.0.0.1:46576 Accepted +[Tue Apr 21 10:20:45 2026] 127.0.0.1:46576 Closing +[Tue Apr 21 10:20:45 2026] 127.0.0.1:46586 Accepted +[Tue Apr 21 10:20:50 2026] 127.0.0.1:46586 Closing +[Tue Apr 21 10:20:52 2026] 127.0.0.1:59508 Accepted +[Tue Apr 21 10:20:52 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:20:55 2026] 127.0.0.1:59508 Closing +[Tue Apr 21 10:20:56 2026] 127.0.0.1:50654 Accepted +[Tue Apr 21 10:20:56 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:20:59 2026] 127.0.0.1:50654 Closing +[Tue Apr 21 10:20:59 2026] 127.0.0.1:50666 Accepted +[Tue Apr 21 10:21:04 2026] 127.0.0.1:50666 Closing +[Tue Apr 21 10:21:04 2026] 127.0.0.1:50682 Accepted +[Tue Apr 21 10:21:04 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:21:04 2026] 127.0.0.1:50682 Closing +[Tue Apr 21 10:21:04 2026] 127.0.0.1:60888 Accepted +[Tue Apr 21 10:21:08 2026] 127.0.0.1:60888 Closing +[Tue Apr 21 10:21:13 2026] 127.0.0.1:58594 Accepted +[Tue Apr 21 10:21:13 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:21:16 2026] 127.0.0.1:58594 Closing +[Tue Apr 21 10:21:17 2026] 127.0.0.1:58606 Accepted +[Tue Apr 21 10:21:17 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:21:19 2026] 127.0.0.1:58606 Closing +[Tue Apr 21 10:21:19 2026] 127.0.0.1:58612 Accepted +[Tue Apr 21 10:21:26 2026] 127.0.0.1:58612 Closing +[Tue Apr 21 10:21:31 2026] 127.0.0.1:58168 Accepted +[Tue Apr 21 10:21:31 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:21:31 2026] 127.0.0.1:58168 Closing +[Tue Apr 21 10:21:32 2026] 127.0.0.1:40120 Accepted +[Tue Apr 21 10:21:32 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:21:35 2026] 127.0.0.1:40120 Closing +[Tue Apr 21 10:21:35 2026] 127.0.0.1:40132 Accepted +[Tue Apr 21 10:21:39 2026] 127.0.0.1:40132 Closing +[Tue Apr 21 10:21:39 2026] 127.0.0.1:40134 Accepted +[Tue Apr 21 10:21:39 2026] Failed to listen on 127.0.0.1:18080 (reason: Address already in use) +[Tue Apr 21 10:21:42 2026] 127.0.0.1:40134 Closing +[Tue Apr 21 10:21:42 2026] 127.0.0.1:60180 Accepted +[Tue Apr 21 10:21:46 2026] 127.0.0.1:60180 Closing diff --git a/services/nginx/app/classes/economic.php b/services/nginx/app/classes/economic.php index 743e8eb2..314ad730 100644 --- a/services/nginx/app/classes/economic.php +++ b/services/nginx/app/classes/economic.php @@ -29,6 +29,8 @@ use interfaces\economic_i; class economic implements economic_i { + public const DRAFT_CUSTOMER_EXPORT_BLOCKED_MESSAGE = 'Transactions for the configured draft customer cannot be exported to e-conomic.'; + /** * Configuration of the economic module * @var economic_c @@ -120,6 +122,37 @@ class economic implements economic_i return new $this->helpers->economic_tasks(); } + public function getTransactionDraftCustomerNumber(): ?int + { + $value = $this->config->transaction_draft_customer_number->getVariableValue(); + if ($value === null || $value === '') { + return null; + } + + $customer_number = (int)$value; + return $customer_number > 0 ? $customer_number : null; + } + + public function isDraftCustomerNumber(?int $customer_number): bool + { + $configured_customer_number = $this->getTransactionDraftCustomerNumber(); + if ($configured_customer_number === null || $customer_number === null) { + return false; + } + + return $configured_customer_number === (int)$customer_number; + } + + /** + * @throws \Exception + */ + public function assertCustomerNumberIsNotDraft(?int $customer_number): void + { + if ($this->isDraftCustomerNumber($customer_number)) { + throw new \Exception(self::DRAFT_CUSTOMER_EXPORT_BLOCKED_MESSAGE); + } + } + /** * Create a customer in e-conomic and return the raw upstream payload. */ diff --git a/services/nginx/app/classes/economic_transfer_executor.php b/services/nginx/app/classes/economic_transfer_executor.php index f819ef42..3b1d75c3 100644 --- a/services/nginx/app/classes/economic_transfer_executor.php +++ b/services/nginx/app/classes/economic_transfer_executor.php @@ -23,6 +23,7 @@ class economic_transfer_executor */ public function exportOrderDraftInvoice(int $order_id, int $user_id = 0): array { + $economic = new economic(); $order = (new orders_o())->getOrderById($order_id); if (!$order->exists()) { throw new Exception('Order not found'); @@ -44,6 +45,7 @@ class economic_transfer_executor if (!$customer->exists()) { throw new Exception('Customer not found'); } + $economic->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value()); $customer_economic = $customer->getCustomerEcocomicData()->economic_customer; $economic_invoice_draft = new economic_invoice_draft_mo(); @@ -128,6 +130,7 @@ class economic_transfer_executor */ public function exportOrderInvoice(int $order_id, int $user_id = 0): array { + $economic = new economic(); $order = (new orders_o())->getOrderById($order_id); if (!$order->exists()) { throw new Exception('Order not found'); @@ -137,6 +140,7 @@ class economic_transfer_executor if (!$customer->exists()) { throw new Exception('Customer not found'); } + $economic->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value()); $economic_module_orders = (new economic_module_orders())->getByOrderId($order_id); if ($economic_module_orders->economic_invoice_draft_id->value() === 0) { @@ -189,6 +193,7 @@ class economic_transfer_executor { $collected_order_invoices = (new collected_order_invoices_o())->select($collected_invoice_id); $collected_order_invoices->requireSelected(); + (new economic())->assertCustomerNumberIsNotDraft((int)$collected_order_invoices->customer_number->value()); if ($collected_order_invoices->external_id->value() === null) { if (!$send_as_is) { diff --git a/services/nginx/app/classes/edge_gateway_agent_artifact_locator.php b/services/nginx/app/classes/edge_gateway_agent_artifact_locator.php new file mode 100644 index 00000000..21a8de68 --- /dev/null +++ b/services/nginx/app/classes/edge_gateway_agent_artifact_locator.php @@ -0,0 +1,145 @@ + + */ + public static function candidatePaths( + string $fileName, + ?string $basePath = null, + ?string $mountedArtifactDirectory = null, + ?string $bakedArtifactDirectory = null + ): array + { + $basePath = self::normalizePath($basePath ?? WD); + $candidateDirectories = []; + + $configuredDirectory = trim((string)(getenv('EDGE_AGENT_ARTIFACT_DIR') ?: '')); + if ($configuredDirectory !== '') { + $candidateDirectories[] = self::normalizePath($configuredDirectory); + } + + $candidateDirectories[] = self::routerArtifactDirectory($basePath); + + $mountedArtifactDirectory = $mountedArtifactDirectory ?? self::mountedArtifactDirectory(); + if ($mountedArtifactDirectory !== null) { + $candidateDirectories[] = self::normalizePath($mountedArtifactDirectory); + } + + $bakedArtifactDirectory = $bakedArtifactDirectory ?? self::bakedArtifactDirectory(); + if ($bakedArtifactDirectory !== null) { + $candidateDirectories[] = self::normalizePath($bakedArtifactDirectory); + } + + $candidateDirectories[] = self::normalizePath(dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'); + $candidateDirectories[] = self::normalizePath(dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'); + $candidateDirectories[] = self::normalizePath(dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'); + + $paths = []; + foreach (array_values(array_unique($candidateDirectories)) as $directory) { + $paths[] = rtrim($directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $fileName; + } + + return array_values(array_unique($paths)); + } + + /** + * @throws Exception + */ + public static function resolve( + string $fileName, + ?string $basePath = null, + ?string $mountedArtifactDirectory = null, + ?string $bakedArtifactDirectory = null + ): string + { + $candidatePaths = self::candidatePaths($fileName, $basePath, $mountedArtifactDirectory, $bakedArtifactDirectory); + foreach ($candidatePaths as $path) { + if (is_file($path)) { + return $path; + } + } + + $message = 'Missing edge agent artifact: ' . $fileName . '.'; + $message .= ' Checked paths: ' . implode(', ', $candidatePaths) . '.'; + $message .= ' Deploy the router-owned artifacts under ' . self::routerArtifactDirectory($basePath) . '.'; + + $legacyNodeArtifact = self::legacyNodeArtifactPath($basePath); + if ($legacyNodeArtifact !== null) { + $message .= ' Legacy Node dist artifact found at ' . $legacyNodeArtifact . '.'; + $message .= ' Remove /services/edge-agent/dist and deploy the router resources instead of depending on the legacy mount.'; + } + + throw new Exception($message); + } + + private static function normalizePath(string $path): string + { + $normalized = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path); + if (DIRECTORY_SEPARATOR === '/') { + $normalized = preg_replace('#/+#', '/', $normalized) ?: $normalized; + } + + $trimmed = rtrim($normalized, DIRECTORY_SEPARATOR); + if ($trimmed === '' && str_starts_with($normalized, DIRECTORY_SEPARATOR)) { + return DIRECTORY_SEPARATOR; + } + + return $trimmed; + } + + private static function routerArtifactDirectory(string $basePath): string + { + return self::normalizePath($basePath . DIRECTORY_SEPARATOR . self::ROUTER_ARTIFACT_DIRECTORY); + } + + private static function mountedArtifactDirectory(): ?string + { + if (DIRECTORY_SEPARATOR !== '/') { + return null; + } + + return self::DEFAULT_MOUNTED_ARTIFACT_DIRECTORY; + } + + private static function bakedArtifactDirectory(): ?string + { + if (DIRECTORY_SEPARATOR !== '/') { + return null; + } + + return self::DEFAULT_BAKED_ARTIFACT_DIRECTORY; + } + + private static function legacyNodeArtifactPath(?string $basePath = null): ?string + { + $basePath = self::normalizePath($basePath ?? WD); + $candidateDirectories = [ + dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'dist', + dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'dist', + dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'dist', + ]; + + if (DIRECTORY_SEPARATOR === '/') { + $candidateDirectories[] = '/services/edge-agent/dist'; + } + + foreach (array_values(array_unique(array_map(static fn(string $directory): string => self::normalizePath($directory), $candidateDirectories))) as $directory) { + $path = rtrim($directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'agent.mjs'; + if (is_file($path)) { + return $path; + } + } + + return null; + } +} diff --git a/services/nginx/app/classes/edge_gateway_install_service.php b/services/nginx/app/classes/edge_gateway_install_service.php new file mode 100644 index 00000000..608ffed4 --- /dev/null +++ b/services/nginx/app/classes/edge_gateway_install_service.php @@ -0,0 +1,72 @@ + 'application/x-httpd-php; charset=utf-8', + 'truckwash-edge-agent.service' => 'text/plain; charset=utf-8', + ]; + + public function __construct(private readonly ?edge_gateway_manager $manager = null) + { + edge_gateway_schema_bootstrap::ensureTables(); + } + + public function buildInstallScript(string $plainToken): string + { + return $this->manager()->buildInstallScript($plainToken); + } + + /** + * @throws Exception + */ + public function verifyInstallToken(string $plainToken): array + { + return $this->manager()->verifyInstallToken($plainToken); + } + + /** + * @throws Exception + */ + public function readArtifact(string $fileName): string + { + $path = $this->artifactPath($fileName); + $contents = file_get_contents($path); + if ($contents === false) { + throw new Exception('Unable to read edge agent artifact'); + } + + return $contents; + } + + public function contentType(string $fileName): string + { + return self::ARTIFACTS[$fileName] ?? 'application/octet-stream'; + } + + public function buildArtifactUrl(string $fileName): string + { + return rtrim($this->manager()->getApiBaseUrl(), '/') . '/edge-agent/artifacts/' . $fileName; + } + + /** + * @throws Exception + */ + public function artifactPath(string $fileName): string + { + if (!array_key_exists($fileName, self::ARTIFACTS)) { + throw new Exception('Unknown edge agent artifact'); + } + + return edge_gateway_agent_artifact_locator::resolve($fileName); + } + + private function manager(): edge_gateway_manager + { + return $this->manager ?? new edge_gateway_manager(); + } +} diff --git a/services/nginx/app/classes/edge_gateway_manager.php b/services/nginx/app/classes/edge_gateway_manager.php index a9edf9a3..97724907 100644 --- a/services/nginx/app/classes/edge_gateway_manager.php +++ b/services/nginx/app/classes/edge_gateway_manager.php @@ -10,10 +10,6 @@ use objects\edge_gateway_claim_tokens_o; use objects\edge_gateway_command_jobs_o; use objects\edge_gateway_device_inventory_o; use objects\edge_gateway_relay_bindings_o; -use objects\edge_gateway_shell_action_jobs_o; -use objects\edge_gateway_shell_events_o; -use objects\edge_gateway_shell_sessions_o; -use objects\edge_gateway_update_jobs_o; use objects\edge_gateways_o; class edge_gateway_manager @@ -34,7 +30,6 @@ class edge_gateway_manager public const DEFAULT_RELEASE_CHANNEL = 'stable'; public const DEFAULT_AGENT_SERVICE_NAME = 'truckwash-edge-agent.service'; public const INSTALL_TOKEN_TTL_SECONDS = 1800; - public const SHELL_SESSION_TTL_SECONDS = 900; public const HEARTBEAT_DEGRADED_AFTER_SECONDS = 60; public const HEARTBEAT_OFFLINE_AFTER_SECONDS = 300; public const COMMAND_WAIT_TIMEOUT_SECONDS = 10; @@ -42,15 +37,11 @@ class edge_gateway_manager public const COMMAND_POLL_INTERVAL_MICROSECONDS = 250000; public const COMMAND_DISPATCH_STALE_AFTER_SECONDS = 30; public const COMMAND_EXPIRES_AFTER_SECONDS = 90; - public const SHELL_ACTION_POLL_TIMEOUT_SECONDS = 20; - public const SHELL_ACTION_STALE_AFTER_SECONDS = 30; - public const SHELL_ACTION_EXPIRES_AFTER_SECONDS = 90; - public const SHELL_EVENT_POLL_TIMEOUT_SECONDS = 5; - public const SHELL_EVENT_POLL_LIMIT = 200; public const BROKER_PRESENCE_TTL_SECONDS = 90; public const BROKER_CONNECTIVITY_DEGRADED_AFTER_SECONDS = 45; public const BROKER_HTTP_TIMEOUT_SECONDS = 5; public const DEVICE_FRESHNESS_DEGRADED_AFTER_SECONDS = 180; + public const CREDENTIAL_FRESH_AFTER_SECONDS = 2592000; public function __construct() { @@ -120,11 +111,15 @@ class edge_gateway_manager 'last_seen_ip' => $this->remoteIp(), 'discovery_status' => 'PENDING', 'is_primary' => 1, - 'metadata_json' => $metadata, + 'metadata_json' => array_merge($metadata, [ + 'credentials_rotated_at' => $this->now(), + 'agent_runtime' => 'php-cli', + ]), ]); $claimToken->used_at->set($this->now()); $gateway->select($gatewayId); + $this->setGatewayPrimaryState($gateway, true); $this->writeAudit( $gatewayId, @@ -138,31 +133,11 @@ class edge_gateway_manager 'gateway' => $this->getGateway($gatewayId), 'agent_token' => $agentToken, 'heartbeat_url' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/heartbeat', + 'commands_poll_url' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/commands/poll', + 'operations_poll_url' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/operations/next', + 'operation_events_url_template' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/operations/{operationId}/events', + 'operation_complete_url_template' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/operations/{operationId}/complete', 'release_channel' => (string)$gateway->release_channel->value(), - 'broker_url' => $this->buildBrokerPublicUrl(), - ]; - } - - /** - * @throws Exception - */ - public function rotateGatewayCredentials(int $gatewayId, ?int $userId = null): array - { - $gateway = $this->requireGateway($gatewayId); - $token = bin2hex(random_bytes(32)); - $gateway->agent_token_hash->set($this->hashToken($token)); - - $this->writeAudit( - $gatewayId, - (int)$gateway->department_id->value(), - 'GATEWAY_CREDENTIALS_ROTATED', - $userId, - [] - ); - - return [ - 'gateway' => $this->getGateway($gatewayId), - 'agent_token' => $token, ]; } @@ -198,8 +173,6 @@ class edge_gateway_manager $this->syncDeviceInventory($gatewayId, $payload['inventory']); } - $this->applyHeartbeatUpdateLifecycle($gateway, $payload); - return $this->getGateway($gatewayId); } @@ -207,7 +180,7 @@ class edge_gateway_manager * @return array> * @throws Exception */ - public function listGateways(?int $departmentId = null): array + public function listGateways(?int $departmentId = null, bool $includeDetail = true): array { $gatewayObject = new edge_gateways_o(); $rows = $departmentId === null @@ -216,32 +189,213 @@ class edge_gateway_manager $gateways = []; foreach ($rows as $row) { - $gateways[] = $this->getGateway((int)$row['id']); + $gateway = $this->requireGateway((int)$row['id']); + $gateways[] = $this->buildGatewayPayload($gateway, $includeDetail); } usort($gateways, static fn(array $a, array $b): int => ($a['department_id'] <=> $b['department_id']) ?: ($a['id'] <=> $b['id'])); return $gateways; } + /** + * @param array> $gateways + * @return array + * @throws Exception + */ + public function buildFleetUsageStatistics(?int $departmentId = null, array $gateways = []): array + { + $fleet = $gateways !== [] ? $gateways : $this->listGateways($departmentId, false); + $gatewayIds = array_values(array_filter(array_map( + static fn(array $gateway): int => (int)($gateway['id'] ?? 0), + $fleet + ))); + + return self::summarizeFleetUsage( + $fleet, + $this->aggregateInventoryUsage($gatewayIds), + $this->aggregateBindingUsage($gatewayIds) + ); + } + + /** + * @param array> $gateways + * @return array + */ + public static function summarizeFleetUsage(array $gateways, array $inventoryUsage = [], array $bindingUsage = []): array + { + $inventory = array_merge(self::emptyInventoryUsage(), $inventoryUsage); + $bindings = array_merge(self::emptyBindingUsage(), $bindingUsage); + $totalGateways = count($gateways); + $departmentIds = []; + $gatewayOnline = 0; + $gatewayOffline = 0; + $gatewayDegraded = 0; + $gatewayDrifted = 0; + $brokerConnected = 0; + $activeOperations = 0; + $pendingOperations = 0; + $inProgressOperations = 0; + $operationBacklog = 0; + $commandBacklog = 0; + $latencyValues = []; + $cpuValues = []; + $memoryValues = []; + $diskValues = []; + + foreach ($gateways as $gateway) { + $departmentId = (int)($gateway['department_id'] ?? 0); + if ($departmentId > 0) { + $departmentIds[$departmentId] = true; + } + + $status = strtoupper((string)($gateway['status'] ?? self::STATUS_OFFLINE)); + if ($status === self::STATUS_ONLINE) { + $gatewayOnline += 1; + } elseif ($status === self::STATUS_DEGRADED) { + $gatewayDegraded += 1; + } else { + $gatewayOffline += 1; + } + + if (!empty($gateway['version_drift']['is_drifted'])) { + $gatewayDrifted += 1; + } + + if (!empty($gateway['channel_status']['broker']['connected'])) { + $brokerConnected += 1; + } + + if (!empty($gateway['active_operation'])) { + $activeOperations += 1; + } + + $recentSummary = isset($gateway['recent_operations_summary']) && is_array($gateway['recent_operations_summary']) + ? (array)$gateway['recent_operations_summary'] + : []; + $pendingOperations += (int)($recentSummary['pending'] ?? 0); + $inProgressOperations += (int)($recentSummary['in_progress'] ?? 0); + + $backlog = isset($gateway['backlog_depth']) && is_array($gateway['backlog_depth']) + ? (array)$gateway['backlog_depth'] + : []; + $operationBacklog += (int)($backlog['operations'] ?? 0); + $commandBacklog += (int)($backlog['commands'] ?? 0); + + $metrics = isset($gateway['metadata']['system_metrics']) && is_array($gateway['metadata']['system_metrics']) + ? (array)$gateway['metadata']['system_metrics'] + : []; + self::appendNumericMetric($latencyValues, $metrics['latency_ms'] ?? null); + self::appendNumericMetric($cpuValues, $metrics['cpu_usage_pct'] ?? null); + self::appendNumericMetric($memoryValues, $metrics['memory_usage_pct'] ?? null); + self::appendNumericMetric($diskValues, $metrics['disk_usage_pct'] ?? null); + } + + return [ + 'gateways' => [ + 'total' => $totalGateways, + 'departments' => count($departmentIds), + 'online' => $gatewayOnline, + 'offline' => $gatewayOffline, + 'degraded' => $gatewayDegraded, + 'drifted' => $gatewayDrifted, + 'broker_connected' => $brokerConnected, + ], + 'inventory' => $inventory, + 'bindings' => $bindings, + 'operations' => [ + 'active' => $activeOperations, + 'pending' => $pendingOperations, + 'in_progress' => $inProgressOperations, + 'backlog' => $operationBacklog, + ], + 'commands' => [ + 'backlog' => $commandBacklog, + ], + 'system' => [ + 'latency_ms_avg' => self::averageMetric($latencyValues), + 'cpu_usage_pct_avg' => self::averageMetric($cpuValues), + 'memory_usage_pct_avg' => self::averageMetric($memoryValues), + 'disk_usage_pct_avg' => self::averageMetric($diskValues), + ], + ]; + } + /** * @throws Exception */ public function getGateway(int $gatewayId): array { $gateway = $this->requireGateway($gatewayId); + return $this->buildGatewayPayload($gateway, true); + } + + /** + * @throws Exception + */ + private function buildGatewayPayload(edge_gateways_o $gateway, bool $includeDetail): array + { $data = $gateway->asArray(); + $operations = new edge_gateway_operation_service($this); $data['metadata']['broker_presence'] = $this->readBrokerPresence((int)$gateway->id); - $data['inventory'] = $this->listInventory($gatewayId); - $data['bindings'] = $this->listBindings($gatewayId); - $data['recent_commands'] = $this->listRecentObjects(new edge_gateway_command_jobs_o(), ['gateway_id' => $gatewayId, 'deleted_at' => null]); - $data['recent_updates'] = $this->listRecentObjects(new edge_gateway_update_jobs_o(), ['gateway_id' => $gatewayId, 'deleted_at' => null]); - $data['recent_shell_sessions'] = $this->listRecentObjects(new edge_gateway_shell_sessions_o(), ['gateway_id' => $gatewayId, 'deleted_at' => null]); - $data['audit_logs'] = $this->listRecentObjects(new edge_gateway_audit_logs_o(), ['gateway_id' => $gatewayId]); + $gatewayId = (int)$gateway->id; + if ($includeDetail) { + $data['inventory'] = $this->listInventory($gatewayId); + $data['bindings'] = $this->listBindings($gatewayId); + $data['recent_commands'] = $this->listRecentObjects(new edge_gateway_command_jobs_o(), ['gateway_id' => $gatewayId, 'deleted_at' => null]); + $data['audit_logs'] = $this->listRecentObjects(new edge_gateway_audit_logs_o(), ['gateway_id' => $gatewayId]); + $data['operations'] = $operations->listOperations($gatewayId, 12, true); + } else { + $data['inventory'] = []; + $data['bindings'] = []; + $data['recent_commands'] = []; + $data['audit_logs'] = []; + $data['operations'] = $operations->listOperations($gatewayId, 5, false); + } + $data['active_operation'] = $operations->getActiveOperation($gatewayId, false); + $data['recent_operations_summary'] = $operations->buildRecentOperationsSummary($gatewayId); $data['department_transport_mode'] = $this->getDepartmentTransportMode((int)$gateway->department_id->value()); $data['operational_snapshot'] = $this->buildGatewayOperationalSnapshot((int)$gateway->id); return self::deriveGatewayRuntimeState($data); } + /** + * @throws Exception + */ + public function updateGatewayMetadata(int $gatewayId, array $payload, ?int $userId = null): array + { + $gateway = $this->requireGateway($gatewayId); + $previousLabel = (string)$gateway->label->value(); + $previousIsPrimary = (bool)$gateway->is_primary->value(); + $label = trim((string)($payload['label'] ?? $previousLabel)); + if ($label === '') { + throw new Exception('Gateway label is required'); + } + + $isPrimary = (bool)($payload['is_primary'] ?? $previousIsPrimary); + $gateway->label->set($label); + + if ($isPrimary) { + $this->setGatewayPrimaryState($gateway, true); + } elseif ($isPrimary !== $previousIsPrimary) { + $this->setGatewayPrimaryState($gateway, false); + } + + $this->writeAudit( + $gatewayId, + (int)$gateway->department_id->value(), + 'GATEWAY_METADATA_UPDATED', + $userId, + [ + 'label' => $label, + 'previous_label' => $previousLabel, + 'is_primary' => $isPrimary, + 'previous_is_primary' => $previousIsPrimary, + ] + ); + + return $this->getGateway($gatewayId); + } + /** * @throws Exception */ @@ -362,91 +516,6 @@ class edge_gateway_manager return $this->getGateway($gatewayId); } - /** - * @throws Exception - */ - public function queueUpdate(int $gatewayId, string $targetVersion, string $releaseChannel, ?int $userId = null): array - { - $gateway = $this->requireGateway($gatewayId); - $gateway->target_version->set($targetVersion); - - $jobObject = new edge_gateway_update_jobs_o(); - $jobId = $jobObject->add_object([ - 'gateway_id' => $gatewayId, - 'command_job_id' => null, - 'target_version' => $targetVersion, - 'release_channel' => $releaseChannel, - 'status' => 'PENDING', - 'requested_by' => $userId, - 'requested_at' => $this->now(), - 'delivery_json' => $this->buildDeliveryMetadata([ - 'preferred_channel' => $this->resolveGatewayPreferredCommandChannel($gateway), - ], self::COMMAND_EXPIRES_AFTER_SECONDS), - 'result_json' => [], - ]); - $jobObject->select($jobId); - - $command = $this->createCommandJob( - $gatewayId, - 'RUN_UPDATE', - $this->buildUpdateCommandPayload($targetVersion, $releaseChannel), - $userId, - [ - 'preferred_channel' => $this->resolveGatewayPreferredCommandChannel($gateway), - ] - ); - $jobObject->command_job_id->set((int)$command->id); - - $this->writeAudit( - $gatewayId, - (int)$gateway->department_id->value(), - 'UPDATE_QUEUED', - $userId, - ['target_version' => $targetVersion, 'release_channel' => $releaseChannel] - ); - - return $jobObject->asArray(); - } - - /** - * @throws Exception - */ - public function queueUninstall(int $gatewayId, ?int $userId = null): array - { - $gateway = $this->requireDispatchableGateway($gatewayId); - $metadata = (array)($gateway->metadata_json->value() ?? []); - $metadata['pending_uninstall'] = [ - 'requested_at' => $this->now(), - 'requested_by' => $userId, - ]; - $gateway->metadata_json->set($metadata); - - $job = $this->createCommandJob( - $gatewayId, - 'UNINSTALL_AGENT', - [ - 'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME, - ], - $userId, - [ - 'preferred_channel' => $this->resolveGatewayPreferredCommandChannel($gateway), - ] - ); - - $this->writeAudit( - $gatewayId, - (int)$gateway->department_id->value(), - 'GATEWAY_UNINSTALL_QUEUED', - $userId, - ['command_job_id' => (int)$job->id] - ); - - return [ - 'gateway' => $this->getGateway($gatewayId), - 'job' => $job->asArray(), - ]; - } - /** * @throws Exception */ @@ -455,6 +524,12 @@ class edge_gateway_manager $gateway = $this->requireGateway($gatewayId); $departmentId = (int)$gateway->department_id->value(); $label = (string)$gateway->label->value(); + if ((bool)$gateway->is_primary->value()) { + $replacement = $this->findAlternateGatewayForDepartment($departmentId, $gatewayId); + if ($replacement !== null) { + $replacement->is_primary->set(true); + } + } $this->softDeleteGatewayRelations($gatewayId); $gateway->deleted_at->set($this->now()); @@ -539,374 +614,6 @@ class edge_gateway_manager ]; } - /** - * @throws Exception - */ - public function createShellSession(int $gatewayId, string $reason, ?int $userId = null): array - { - return $this->createShellSessionRequest($gatewayId, $reason, [], $userId); - } - - /** - * @throws Exception - */ - public function createShellSessionRequest(int $gatewayId, string $reason, array $options = [], ?int $userId = null): array - { - $gateway = $this->requireGateway($gatewayId); - $gatewayStatus = self::resolveGatewayStatus( - $gateway->status->value() === null ? null : (string)$gateway->status->value(), - $gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value() - ); - $sessionToken = bin2hex(random_bytes(32)); - $metadata = [ - 'ttl_seconds' => self::SHELL_SESSION_TTL_SECONDS, - 'transport' => self::DELIVERY_CHANNEL_API, - 'transport_path' => self::DELIVERY_CHANNEL_API, - 'reconnect_state' => 'PENDING', - 'degraded' => $gatewayStatus !== self::STATUS_ONLINE, - 'gateway_status' => $gatewayStatus, - ]; - - $cols = isset($options['cols']) ? (int)$options['cols'] : null; - $rows = isset($options['rows']) ? (int)$options['rows'] : null; - - $sessionObject = new edge_gateway_shell_sessions_o(); - $sessionId = $sessionObject->add_object([ - 'gateway_id' => $gatewayId, - 'reason' => $reason, - 'approval_status' => 'APPROVED', - 'session_token_hash' => $this->hashToken($sessionToken), - 'requested_by' => $userId, - 'approved_by' => $userId, - 'approved_at' => $this->now(), - 'expires_at' => $this->formatDateTime(time() + self::SHELL_SESSION_TTL_SECONDS), - 'metadata_json' => $metadata, - ]); - $sessionObject->select($sessionId); - $this->createShellActionJob( - (int)$sessionObject->id, - $gatewayId, - 'OPEN', - array_filter([ - 'reason' => $reason, - 'cols' => $cols, - 'rows' => $rows, - ], static fn(mixed $value): bool => $value !== null && $value !== ''), - $userId, - [ - 'preferred_channel' => self::DELIVERY_CHANNEL_API, - ] - ); - - $this->writeAudit( - $gatewayId, - (int)$gateway->department_id->value(), - 'ROOT_SHELL_APPROVED', - $userId, - ['reason' => $reason, 'session_id' => $sessionId] - ); - - return [ - 'session' => $sessionObject->asArray(), - 'session_token' => $sessionToken, - 'transport' => self::DELIVERY_CHANNEL_API, - ]; - } - - /** - * @throws Exception - */ - public function validateShellSessionToken(string $plainToken): array - { - $tokenHash = $this->hashToken($plainToken); - $rows = (new edge_gateway_shell_sessions_o())->getFieldsWhere([ - 'session_token_hash' => $tokenHash, - 'deleted_at' => null, - ], ['id']); - - if ($rows === []) { - throw new Exception('Invalid shell session token'); - } - - $session = (new edge_gateway_shell_sessions_o())->select((int)$rows[0]['id']); - if (!$session->exists()) { - throw new Exception('Shell session not found'); - } - if (strtotime((string)$session->expires_at->value()) < time()) { - throw new Exception('Shell session has expired'); - } - - return $session->asArray(); - } - - /** - * @throws Exception - */ - public function closeShellSession(string $plainToken, string $transcript, string $closedReason): array - { - $sessionData = $this->validateShellSessionToken($plainToken); - $session = (new edge_gateway_shell_sessions_o())->select((int)$sessionData['id']); - $session->closed_at->set($this->now()); - $session->transcript_text->set($transcript); - $metadata = (array)($session->metadata_json->value() ?? []); - $metadata['closed_reason'] = $closedReason; - $metadata['reconnect_state'] = 'CLOSED'; - $metadata['transport_state'] = 'DEGRADED'; - $session->metadata_json->set($metadata); - - $this->writeAudit( - (int)$session->gateway_id->value(), - null, - 'ROOT_SHELL_CLOSED', - null, - ['session_id' => (int)$session->id, 'closed_reason' => $closedReason] - ); - - return $session->asArray(); - } - - /** - * @throws Exception - */ - public function getShellSession(int $gatewayId, int $sessionId): array - { - return $this->requireShellSessionForGateway($gatewayId, $sessionId)->asArray(); - } - - /** - * @return array - * @throws Exception - */ - public function pollShellSessionEvents( - int $gatewayId, - int $sessionId, - int $afterId = 0, - int $waitSeconds = self::SHELL_EVENT_POLL_TIMEOUT_SECONDS - ): array { - $session = $this->requireShellSessionForGateway($gatewayId, $sessionId); - $deadline = microtime(true) + max(0, $waitSeconds); - $cursor = max(0, $afterId); - - do { - $session = (new edge_gateway_shell_sessions_o())->select($sessionId); - $events = $this->listShellEvents($sessionId, $cursor, self::SHELL_EVENT_POLL_LIMIT); - if ($events !== [] || $session->closed_at->value() !== null) { - return [ - 'session' => $session->asArray(), - 'events' => $events, - ]; - } - - if (microtime(true) >= $deadline) { - break; - } - - usleep(self::COMMAND_POLL_INTERVAL_MICROSECONDS); - } while (true); - - return [ - 'session' => $session->asArray(), - 'events' => [], - ]; - } - - /** - * @throws Exception - */ - public function enqueueShellInput(int $gatewayId, int $sessionId, string $data, ?int $userId = null): array - { - $session = $this->requireActiveShellSessionForGateway($gatewayId, $sessionId); - $this->createShellActionJob( - $sessionId, - $gatewayId, - 'INPUT', - ['data' => $data], - $userId, - ['preferred_channel' => self::DELIVERY_CHANNEL_API] - ); - - return $session->asArray(); - } - - /** - * @throws Exception - */ - public function enqueueShellResize(int $gatewayId, int $sessionId, int $cols, int $rows, ?int $userId = null): array - { - $session = $this->requireActiveShellSessionForGateway($gatewayId, $sessionId); - $this->createShellActionJob( - $sessionId, - $gatewayId, - 'RESIZE', - [ - 'cols' => max(1, $cols), - 'rows' => max(1, $rows), - ], - $userId, - ['preferred_channel' => self::DELIVERY_CHANNEL_API] - ); - - return $session->asArray(); - } - - /** - * @throws Exception - */ - public function requestShellSessionClose(int $gatewayId, int $sessionId, ?int $userId = null): array - { - $session = $this->requireShellSessionForGateway($gatewayId, $sessionId); - $metadata = (array)($session->metadata_json->value() ?? []); - $metadata['close_requested_by'] = $userId; - $metadata['reconnect_state'] = 'CLOSING'; - $session->metadata_json->set($metadata); - - $wasClosed = $session->closed_at->value() !== null; - if (!$wasClosed) { - $session->approval_status->set('CLOSED'); - $session->closed_at->set($this->now()); - $this->writeAudit( - (int)$session->gateway_id->value(), - null, - 'ROOT_SHELL_CLOSED', - $userId, - ['session_id' => (int)$session->id, 'closed_reason' => 'browser_requested'] - ); - } - - $this->cancelPendingShellActionJobs($sessionId); - $this->createShellActionJob( - $sessionId, - $gatewayId, - 'CLOSE', - ['reason' => 'browser_requested'], - $userId, - ['preferred_channel' => self::DELIVERY_CHANNEL_API] - ); - $this->appendShellEvent( - (int)$session->gateway_id->value(), - (int)$session->id, - 'CLOSED', - ['reason' => 'browser_requested'] - ); - - return $session->asArray(); - } - - /** - * @throws Exception - */ - public function pollShellAction( - int $gatewayId, - string $plainToken, - int $waitSeconds = self::SHELL_ACTION_POLL_TIMEOUT_SECONDS - ): ?array { - $gateway = $this->authenticateGateway($gatewayId, $plainToken); - $deadline = microtime(true) + max(0, $waitSeconds); - - do { - $job = $this->claimNextShellActionJob($gateway); - if ($job !== null) { - return $this->formatAgentShellAction($job); - } - - if (microtime(true) >= $deadline) { - break; - } - - usleep(self::COMMAND_POLL_INTERVAL_MICROSECONDS); - } while (true); - - return null; - } - - /** - * @throws Exception - */ - public function submitShellActionResult( - int $gatewayId, - int $actionId, - string $plainToken, - bool $ok, - ?string $error = null - ): array { - $gateway = $this->authenticateGateway($gatewayId, $plainToken); - $job = (new edge_gateway_shell_action_jobs_o())->select($actionId); - if (!$job->exists()) { - throw new Exception('Edge gateway shell action job not found'); - } - if ((int)$job->gateway_id->value() !== (int)$gateway->id) { - throw new Exception('Edge gateway shell action job does not belong to this gateway'); - } - - $status = (string)$job->status->value(); - if (in_array($status, ['COMPLETED', 'FAILED'], true)) { - return [ - 'acknowledged' => true, - 'job' => $job->asArray(), - ]; - } - - $resolvedError = $ok ? null : (trim((string)$error) !== '' ? trim((string)$error) : 'Edge gateway shell action failed'); - $job->status->set($ok ? 'COMPLETED' : 'FAILED'); - $job->completed_at->set($this->now()); - $job->error_message->set($resolvedError); - $job->delivery_json->set($this->buildDeliveryMetadata( - array_merge( - (array)($job->delivery_json->value() ?? []), - [ - 'delivery_channel' => ((array)($job->delivery_json->value() ?? []))['delivery_channel'] ?? self::DELIVERY_CHANNEL_API, - 'last_dispatch_error' => $resolvedError, - ] - ), - self::SHELL_ACTION_EXPIRES_AFTER_SECONDS - )); - - if ((string)$job->action_type->value() === 'OPEN') { - $session = $this->requireShellSessionForGateway((int)$gateway->id, (int)$job->session_id->value()); - $metadata = (array)($session->metadata_json->value() ?? []); - $metadata['reconnect_state'] = $ok ? 'CONNECTED' : 'FAILED'; - $metadata['last_dispatch_error'] = $resolvedError; - $session->metadata_json->set($metadata); - } - - return [ - 'acknowledged' => true, - 'job' => $job->asArray(), - ]; - } - - /** - * @param array> $events - * @return array - * @throws Exception - */ - public function recordShellSessionEvents( - int $gatewayId, - int $sessionId, - string $plainToken, - array $events - ): array { - $gateway = $this->authenticateGateway($gatewayId, $plainToken); - $session = $this->requireShellSessionForGateway((int)$gateway->id, $sessionId); - $recordedEvents = []; - - foreach ($events as $event) { - if (!is_array($event)) { - continue; - } - - $recorded = $this->applyShellSessionEvent($session, $event); - if ($recorded !== null) { - $recordedEvents[] = $recorded; - } - } - - return [ - 'session' => $session->asArray(), - 'events' => $recordedEvents, - ]; - } - /** * @throws Exception */ @@ -1028,7 +735,7 @@ class edge_gateway_manager public function buildInstallCommand(string $plainToken): string { - return 'curl -fsSL ' . escapeshellarg($this->buildInstallScriptUrl($plainToken)) . ' | sudo bash'; + return 'curl -fsSL "' . $this->buildInstallScriptUrl($plainToken) . '" | sudo bash'; } public function buildInstallTokenVerifyUrl(string $plainToken): string @@ -1045,72 +752,353 @@ class edge_gateway_manager { $configJson = json_encode([ 'apiUrl' => $this->getApiBaseUrl(), - 'brokerUrl' => $this->buildBrokerPublicUrl(), 'installToken' => $plainToken, 'gatewayId' => null, 'agentToken' => null, 'installDir' => '/opt/truckwash-edge-agent', 'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME, 'heartbeatIntervalSeconds' => 15, - 'commandPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS, - 'shellActionPollTimeoutSeconds' => self::SHELL_ACTION_POLL_TIMEOUT_SECONDS, - 'updateVerificationTimeoutSeconds' => 45, + 'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS, ], JSON_UNESCAPED_SLASHES); $script = <<<'BASH' #!/usr/bin/env bash -set -euo pipefail -curl -fsSL "__VERIFY_URL__" >/dev/null +set -Eeuo pipefail +CURRENT_STEP="Preparing installer" +CURRENT_METHOD="" +CURRENT_URL="" +INSTALL_STARTED_AT="$(date +%s)" +log_info() { + printf '[truckwash-edge-agent] %s\n' "$1" +} +log_error() { + printf '[truckwash-edge-agent] ERROR: %s\n' "$1" >&2 +} +on_error() { + local exit_code=$? + log_error "Installer failed during step: ${CURRENT_STEP:-unknown}" + if [ -n "${CURRENT_METHOD:-}" ] && [ -n "${CURRENT_URL:-}" ]; then + log_error "Last request: ${CURRENT_METHOD} ${CURRENT_URL}" + fi + exit "$exit_code" +} +trap on_error ERR +run_step() { + local description="$1" + shift + CURRENT_STEP="$description" + CURRENT_METHOD="" + CURRENT_URL="" + log_info "$description" + "$@" +} +fetch_http() { + local description="$1" + local url="$2" + local output_path="${3:-}" + local body_path="$output_path" + local headers_path + local status="" + local curl_exit=0 + local preview="" + local cleanup_body=0 + + if [ -z "$body_path" ]; then + body_path="$(mktemp)" + cleanup_body=1 + fi + headers_path="$(mktemp)" + + CURRENT_STEP="$description" + CURRENT_METHOD="GET" + CURRENT_URL="$url" + log_info "${description}: GET ${url}" + + set +e + status="$(curl -sS -L -D "$headers_path" -o "$body_path" -w '%{http_code}' "$url")" + curl_exit=$? + set -e + + if [ "$curl_exit" -ne 0 ]; then + log_error "${description} request failed before a successful HTTP response was received." + log_error "Request: GET ${url}" + log_error "curl exit code: ${curl_exit}" + if [ -s "$headers_path" ]; then + log_error "Response headers:" + sed 's/^/[truckwash-edge-agent] /' "$headers_path" >&2 + fi + if [ -s "$body_path" ]; then + preview="$(head -c 400 "$body_path" || true)" + if [ -n "$preview" ]; then + log_error "Response body preview (first 400 bytes):" + printf '%s\n' "$preview" | sed 's/^/[truckwash-edge-agent] /' >&2 + fi + fi + [ "$cleanup_body" -eq 1 ] && rm -f "$body_path" + rm -f "$headers_path" + return "$curl_exit" + fi + + if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then + log_error "${description} returned HTTP ${status}." + log_error "Request: GET ${url}" + if [ -s "$headers_path" ]; then + log_error "Response headers:" + sed 's/^/[truckwash-edge-agent] /' "$headers_path" >&2 + fi + if [ -s "$body_path" ]; then + preview="$(head -c 400 "$body_path" || true)" + if [ -n "$preview" ]; then + log_error "Response body preview (first 400 bytes):" + printf '%s\n' "$preview" | sed 's/^/[truckwash-edge-agent] /' >&2 + fi + fi + [ "$cleanup_body" -eq 1 ] && rm -f "$body_path" + rm -f "$headers_path" + return 1 + fi + + [ "$cleanup_body" -eq 1 ] && rm -f "$body_path" + rm -f "$headers_path" +} +config_has_claimed_gateway() { + local config_path="$1" + php -r ' + $path = $argv[1]; + if (!is_file($path)) { + exit(1); + } + $decoded = json_decode((string)file_get_contents($path), true); + if (!is_array($decoded)) { + exit(1); + } + $gatewayId = isset($decoded["gatewayId"]) ? (int)$decoded["gatewayId"] : 0; + $agentToken = isset($decoded["agentToken"]) ? trim((string)$decoded["agentToken"]) : ""; + exit($gatewayId > 0 && $agentToken !== "" ? 0 : 1); + ' "$config_path" +} +read_config_value() { + local config_path="$1" + local key="$2" + php -r ' + $path = $argv[1]; + $key = $argv[2]; + if (!is_file($path)) { + exit(0); + } + $decoded = json_decode((string)file_get_contents($path), true); + if (!is_array($decoded) || !array_key_exists($key, $decoded) || $decoded[$key] === null) { + exit(0); + } + $value = $decoded[$key]; + if (is_array($value) || is_object($value)) { + echo json_encode($value, JSON_UNESCAPED_SLASHES); + exit(0); + } + echo (string)$value; + ' "$config_path" "$key" +} +merge_agent_config() { + local template_path="$1" + local config_path="$2" + php -r ' + $templatePath = $argv[1]; + $configPath = $argv[2]; + $template = json_decode((string)file_get_contents($templatePath), true); + if (!is_array($template)) { + fwrite(STDERR, "Invalid edge agent config template.\n"); + exit(1); + } + $existing = []; + if (is_file($configPath)) { + $decoded = json_decode((string)file_get_contents($configPath), true); + if (is_array($decoded)) { + $existing = $decoded; + } + } + foreach (["gatewayId", "agentToken", "agentInstanceId", "installedVersion", "targetVersion", "lastStagedUpdate"] as $key) { + if (array_key_exists($key, $existing) && $existing[$key] !== null && $existing[$key] !== "") { + $template[$key] = $existing[$key]; + } + } + file_put_contents($configPath, json_encode($template, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + ' "$template_path" "$config_path" +} +heartbeat_marker_is_fresh() { + local heartbeat_path="$1" + local minimum_epoch="$2" + if [ ! -f "$heartbeat_path" ]; then + return 1 + fi + local modified_epoch + modified_epoch="$(stat -c %Y "$heartbeat_path" 2>/dev/null || echo 0)" + [ "${modified_epoch:-0}" -ge "$minimum_epoch" ] +} +print_service_diagnostics() { + log_error "truckwash-edge-agent.service did not complete installation verification." + log_error "systemctl status --no-pager truckwash-edge-agent.service" + systemctl status --no-pager truckwash-edge-agent.service || true + log_error "journalctl -u truckwash-edge-agent.service -n 40 --no-pager" + journalctl -u truckwash-edge-agent.service -n 40 --no-pager || true +} +wait_for_gateway_claim() { + local config_path="$1" + local heartbeat_path="$2" + local minimum_epoch="$3" + local timeout_seconds="${4:-30}" + local elapsed=0 + while [ "$elapsed" -lt "$timeout_seconds" ]; do + if config_has_claimed_gateway "$config_path" && heartbeat_marker_is_fresh "$heartbeat_path" "$minimum_epoch"; then + return 0 + fi + sleep 1 + elapsed=$((elapsed + 1)) + done + log_error "Gateway claim did not complete within ${timeout_seconds}s." + print_service_diagnostics + return 1 +} +wait_for_post_restart_heartbeat() { + local heartbeat_path="$1" + local minimum_epoch="$2" + local timeout_seconds="${3:-30}" + local elapsed=0 + while [ "$elapsed" -lt "$timeout_seconds" ]; do + if heartbeat_marker_is_fresh "$heartbeat_path" "$minimum_epoch"; then + return 0 + fi + sleep 1 + elapsed=$((elapsed + 1)) + done + log_error "Gateway heartbeat was not observed within ${timeout_seconds}s after reinstall." + print_service_diagnostics + return 1 +} + +fetch_http "Verify install token" "__VERIFY_URL__" INSTALL_DIR=/opt/truckwash-edge-agent -mkdir -p "$INSTALL_DIR" +CONFIG_PATH="$INSTALL_DIR/config.json" +CONFIG_TEMPLATE_PATH="$INSTALL_DIR/config.template.json" +HEARTBEAT_MARKER_PATH="$INSTALL_DIR/runtime/last-heartbeat-ok.txt" +REUSE_EXISTING_CREDENTIALS=0 +run_step "Creating install directory" mkdir -p "$INSTALL_DIR" export DEBIAN_FRONTEND=noninteractive -apt-get update -apt-get install -y curl ca-certificates nodejs npm python3 make g++ -curl -fsSL "__PACKAGE_URL__" -o "$INSTALL_DIR/package.json" -curl -fsSL "__AGENT_URL__" -o "$INSTALL_DIR/agent.mjs" -cat > "$INSTALL_DIR/config.json" <<'EOF_JSON' +run_step "Updating package lists" apt-get update +run_step "Installing required packages" apt-get install -y curl ca-certificates php-cli php-curl php-mbstring +fetch_http "Download PHP edge agent" "__AGENT_URL__" "$INSTALL_DIR/agent.php" +fetch_http "Download systemd service unit" "__SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-agent.service" +if config_has_claimed_gateway "$CONFIG_PATH"; then + REUSE_EXISTING_CREDENTIALS=1 + log_info "Existing claimed gateway detected; reinstall will reuse saved gateway credentials." +fi +cat > "$CONFIG_TEMPLATE_PATH" <<'EOF_JSON' __CONFIG_JSON__ EOF_JSON -cd "$INSTALL_DIR" -npm install --omit=dev -cat >/etc/systemd/system/truckwash-edge-agent.service <<'EOF' -[Unit] -Description=TruckWash Edge Agent -After=network.target - -[Service] -Type=simple -WorkingDirectory=/opt/truckwash-edge-agent -ExecStart=/usr/bin/node /opt/truckwash-edge-agent/agent.mjs --config /opt/truckwash-edge-agent/config.json -Restart=always -RestartSec=5 -User=root - -[Install] -WantedBy=multi-user.target -EOF -systemctl daemon-reload -systemctl enable --now truckwash-edge-agent.service +run_step "Writing agent config" merge_agent_config "$CONFIG_TEMPLATE_PATH" "$CONFIG_PATH" +rm -f "$CONFIG_TEMPLATE_PATH" +run_step "Installing systemd service definition" install -m 0644 "$INSTALL_DIR/truckwash-edge-agent.service" "/etc/systemd/system/truckwash-edge-agent.service" +run_step "Setting PHP agent permissions" chmod 0755 "$INSTALL_DIR/agent.php" +run_step "Reloading systemd" systemctl daemon-reload +run_step "Enabling truckwash-edge-agent.service" systemctl enable truckwash-edge-agent.service +run_step "Restarting truckwash-edge-agent.service" systemctl restart truckwash-edge-agent.service +run_step "Verifying truckwash-edge-agent.service is active" systemctl is-active --quiet truckwash-edge-agent.service +if [ "$REUSE_EXISTING_CREDENTIALS" -eq 1 ]; then + run_step "Waiting for post-reinstall heartbeat" wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 30 + log_info "Reinstall reused gateway $(read_config_value "$CONFIG_PATH" gatewayId)." +else + run_step "Waiting for gateway claim" wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 30 + log_info "Gateway claim completed for gateway $(read_config_value "$CONFIG_PATH" gatewayId)." +fi echo 'TruckWash edge agent installed.' BASH; return strtr($script, [ '__VERIFY_URL__' => $this->buildInstallTokenVerifyUrl($plainToken), - '__PACKAGE_URL__' => $this->buildAgentArtifactUrl('package.json'), - '__AGENT_URL__' => $this->buildAgentArtifactUrl('agent.mjs'), + '__AGENT_URL__' => $this->buildAgentArtifactUrl('agent.php'), + '__SERVICE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_AGENT_SERVICE_NAME), '__CONFIG_JSON__' => (string)$configJson, ]); } + /** + * @throws Exception + */ + public function buildUpdateOperationRequest(string $targetVersion, string $releaseChannel): array + { + return array_merge( + $this->buildUpdateCommandPayload($targetVersion, $releaseChannel), + [ + 'target_version' => $targetVersion, + 'release_channel' => $releaseChannel, + ] + ); + } + + /** + * @throws Exception + */ + public function rotateGatewayCredentials(int $gatewayId, ?int $userId = null): array + { + $gateway = $this->requireGateway($gatewayId); + $newToken = bin2hex(random_bytes(32)); + $gateway->agent_token_hash->set($this->hashToken($newToken)); + + $metadata = (array)($gateway->metadata_json->value() ?? []); + $metadata['credentials_rotated_at'] = $this->now(); + $metadata['credential_rotation_requested_by'] = $userId; + $gateway->metadata_json->set($metadata); + + $payload = [ + 'apiUrl' => $this->getApiBaseUrl(), + 'gatewayId' => (int)$gateway->id, + 'agentToken' => $newToken, + 'installDir' => '/opt/truckwash-edge-agent', + 'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME, + 'heartbeatIntervalSeconds' => 15, + 'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS, + 'installedVersion' => $gateway->installed_version->value() === null ? null : (string)$gateway->installed_version->value(), + ]; + + $this->writeAudit( + (int)$gateway->id, + (int)$gateway->department_id->value(), + 'GATEWAY_CREDENTIALS_ROTATED', + $userId, + ['service_name' => self::DEFAULT_AGENT_SERVICE_NAME] + ); + + return [ + 'gateway_id' => (int)$gateway->id, + 'rotated_at' => (string)$metadata['credentials_rotated_at'], + 'agent_token' => $newToken, + 'config' => $payload, + 'config_json' => json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), + 'restart_instructions' => [ + 'sudo systemctl restart ' . self::DEFAULT_AGENT_SERVICE_NAME, + 'sudo systemctl status ' . self::DEFAULT_AGENT_SERVICE_NAME . ' --no-pager', + ], + ]; + } + + public function syncGatewayInventory(int $gatewayId, array $inventory): void + { + $this->syncDeviceInventory($gatewayId, $inventory); + } + + public function logGatewayAudit(?int $gatewayId, ?int $departmentId, string $action, ?int $userId, array $context): void + { + $this->writeAudit($gatewayId, $departmentId, $action, $userId, $context); + } + private function buildUpdateCommandPayload(string $targetVersion, string $releaseChannel): array { return [ 'targetVersion' => $targetVersion, 'releaseChannel' => $releaseChannel, - 'artifactUrl' => $this->buildAgentArtifactUrl('agent.mjs'), - 'artifactSha256' => $this->buildAgentArtifactSha256('agent.mjs'), - 'packageUrl' => $this->buildAgentArtifactUrl('package.json'), - 'packageSha256' => $this->buildAgentArtifactSha256('package.json'), + 'artifactUrl' => $this->buildAgentArtifactUrl('agent.php'), + 'artifactSha256' => $this->buildAgentArtifactSha256('agent.php'), + 'serviceUnitUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_AGENT_SERVICE_NAME), + 'serviceUnitSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_AGENT_SERVICE_NAME), 'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME, ]; } @@ -1122,7 +1110,7 @@ BASH; private function buildAgentArtifactPath(string $fileName): string { - return dirname(WD, 3) . '/services/edge-agent/dist/' . $fileName; + return edge_gateway_agent_artifact_locator::resolve($fileName); } /** @@ -1271,10 +1259,7 @@ BASH; 'edge_gateway_device_inventory', 'edge_gateway_relay_bindings', 'edge_gateway_command_jobs', - 'edge_gateway_update_jobs', - 'edge_gateway_shell_sessions', - 'edge_gateway_shell_action_jobs', - 'edge_gateway_shell_events', + 'edge_gateway_operations', ]; $pdo = db::getPDO(); $deletedAt = $this->now(); @@ -1376,6 +1361,74 @@ BASH; return $gateway; } + /** + * @throws Exception + */ + private function setGatewayPrimaryState(edge_gateways_o $gateway, bool $isPrimary): void + { + if ($isPrimary) { + $statement = db::getPDO()->prepare( + "UPDATE edge_gateways + SET is_primary = CASE WHEN id = :gateway_id THEN 1 ELSE 0 END + WHERE department_id = :department_id + AND deleted_at IS NULL" + ); + $statement->execute([ + ':gateway_id' => (int)$gateway->id, + ':department_id' => (int)$gateway->department_id->value(), + ]); + $gateway->is_primary->set(true); + return; + } + + $replacement = $this->findAlternateGatewayForDepartment( + (int)$gateway->department_id->value(), + (int)$gateway->id + ); + if ($replacement === null) { + throw new Exception('Department must retain a primary gateway'); + } + + $replacement->is_primary->set(true); + $gateway->is_primary->set(false); + } + + private function findAlternateGatewayForDepartment(int $departmentId, int $excludedGatewayId): ?edge_gateways_o + { + $rows = (new edge_gateways_o())->getFieldsWhere([ + 'department_id' => $departmentId, + 'deleted_at' => null, + ], ['id']); + + $gatewayIds = array_values(array_filter( + array_map(static fn(array $row): int => (int)$row['id'], $rows), + static fn(int $gatewayId): bool => $gatewayId !== $excludedGatewayId + )); + + if ($gatewayIds === []) { + return null; + } + + $gateways = array_map(static fn(int $id): edge_gateways_o => (new edge_gateways_o())->select($id), $gatewayIds); + usort($gateways, static function (edge_gateways_o $a, edge_gateways_o $b): int { + $aStatus = self::resolveGatewayStatus( + $a->status->value() === null ? null : (string)$a->status->value(), + $a->last_heartbeat_at->value() === null ? null : (string)$a->last_heartbeat_at->value() + ); + $bStatus = self::resolveGatewayStatus( + $b->status->value() === null ? null : (string)$b->status->value(), + $b->last_heartbeat_at->value() === null ? null : (string)$b->last_heartbeat_at->value() + ); + + return ((int)$b->is_primary->value() <=> (int)$a->is_primary->value()) + ?: (self::statusPriority($bStatus) <=> self::statusPriority($aStatus)) + ?: (self::heartbeatTimestamp($b->last_heartbeat_at->value() === null ? null : (string)$b->last_heartbeat_at->value()) + <=> self::heartbeatTimestamp($a->last_heartbeat_at->value() === null ? null : (string)$a->last_heartbeat_at->value())); + }); + + return $gateways[0] ?? null; + } + private function createCommandJob( int $gatewayId, string $commandType, @@ -1401,281 +1454,6 @@ BASH; return $jobObject->select($jobId); } - private function createShellActionJob( - int $sessionId, - int $gatewayId, - string $actionType, - array $payload, - ?int $userId, - array $delivery = [] - ): edge_gateway_shell_action_jobs_o { - $deliveryMetadata = $this->buildDeliveryMetadata($delivery, self::SHELL_ACTION_EXPIRES_AFTER_SECONDS); - $jobObject = new edge_gateway_shell_action_jobs_o(); - $jobId = $jobObject->add_object([ - 'gateway_id' => $gatewayId, - 'session_id' => $sessionId, - 'action_type' => strtoupper($actionType), - 'status' => 'PENDING', - 'payload_json' => $payload, - 'delivery_json' => $deliveryMetadata, - 'requested_by' => $userId, - 'requested_at' => $this->now(), - ]); - - return $jobObject->select($jobId); - } - - private function claimNextShellActionJob(edge_gateways_o $gateway): ?edge_gateway_shell_action_jobs_o - { - $pdo = db::getPDO(); - $pdo->beginTransaction(); - - try { - $statement = $pdo->prepare( - 'SELECT id - FROM edge_gateway_shell_action_jobs - WHERE gateway_id = :gateway_id - AND deleted_at IS NULL - AND ( - status = :pending_status_match - OR ( - status = :dispatching_status_match - AND COALESCE(updated_at, created_at, requested_at) <= :stale_before - ) - ) - ORDER BY CASE WHEN status = :pending_status_order THEN 0 ELSE 1 END, requested_at ASC, id ASC - LIMIT 1 - FOR UPDATE' - ); - $statement->execute([ - ':gateway_id' => (int)$gateway->id, - ':pending_status_match' => 'PENDING', - ':dispatching_status_match' => 'DISPATCHING', - ':pending_status_order' => 'PENDING', - ':stale_before' => $this->formatDateTime(time() - self::SHELL_ACTION_STALE_AFTER_SECONDS), - ]); - - $row = $statement->fetch(); - if (!is_array($row) || !isset($row['id'])) { - $pdo->commit(); - return null; - } - - $update = $pdo->prepare( - 'UPDATE edge_gateway_shell_action_jobs - SET status = :status, - delivery_json = JSON_SET( - COALESCE(delivery_json, JSON_OBJECT()), - \'$.delivery_channel\', :delivery_channel, - \'$.attempt_count\', COALESCE(JSON_EXTRACT(COALESCE(delivery_json, JSON_OBJECT()), \'$.attempt_count\'), 0) + 1, - \'$.last_dispatch_error\', CAST(NULL AS JSON) - ), - error_message = NULL, - completed_at = NULL - WHERE id = :id' - ); - $update->execute([ - ':status' => 'DISPATCHING', - ':delivery_channel' => json_encode(self::DELIVERY_CHANNEL_API, JSON_UNESCAPED_UNICODE), - ':id' => (int)$row['id'], - ]); - - $pdo->commit(); - } catch (\Throwable $throwable) { - if ($pdo->inTransaction()) { - $pdo->rollBack(); - } - throw $throwable; - } - - return (new edge_gateway_shell_action_jobs_o())->select((int)$row['id']); - } - - private function formatAgentShellAction(edge_gateway_shell_action_jobs_o $job): array - { - $session = $this->requireShellSessionForGateway((int)$job->gateway_id->value(), (int)$job->session_id->value()); - - return [ - 'id' => (int)$job->id, - 'gateway_id' => (int)$job->gateway_id->value(), - 'session_id' => (int)$job->session_id->value(), - 'action_type' => (string)$job->action_type->value(), - 'actionType' => (string)$job->action_type->value(), - 'payload' => array_merge([ - 'sessionId' => (int)$session->id, - 'reason' => (string)$session->reason->value(), - 'expiresAt' => (string)$session->expires_at->value(), - ], (array)($job->payload_json->value() ?? [])), - 'requested_at' => (string)$job->requested_at->value(), - ]; - } - - private function applyShellSessionEvent(edge_gateway_shell_sessions_o $session, array $event): ?array - { - $type = strtoupper(trim((string)($event['type'] ?? $event['event_type'] ?? ''))); - if ($type === '') { - return null; - } - - $payload = isset($event['payload']) && is_array($event['payload']) ? (array)$event['payload'] : []; - if (array_key_exists('data', $event) && !array_key_exists('data', $payload)) { - $payload['data'] = (string)$event['data']; - } - if (array_key_exists('code', $event) && !array_key_exists('code', $payload)) { - $payload['code'] = (int)$event['code']; - } - if (array_key_exists('reason', $event) && !array_key_exists('reason', $payload)) { - $payload['reason'] = (string)$event['reason']; - } - - if ($type === 'OPENED') { - if ($session->opened_at->value() === null) { - $session->opened_at->set($this->now()); - } - $session->approval_status->set('OPEN'); - $metadata = (array)($session->metadata_json->value() ?? []); - $metadata['reconnect_state'] = 'CONNECTED'; - $metadata['transport_state'] = 'LIVE'; - $metadata['last_dispatch_error'] = null; - $session->metadata_json->set($metadata); - return $this->appendShellEvent((int)$session->gateway_id->value(), (int)$session->id, $type, $payload); - } - - if ($type === 'OUTPUT') { - $data = (string)($payload['data'] ?? ''); - if ($data === '') { - return null; - } - - $session->transcript_text->set((string)($session->transcript_text->value() ?? '') . $data); - return $this->appendShellEvent( - (int)$session->gateway_id->value(), - (int)$session->id, - $type, - ['data' => $data] - ); - } - - if ($type === 'CLOSED') { - $metadata = (array)($session->metadata_json->value() ?? []); - $closedReason = trim((string)($payload['reason'] ?? 'agent_closed')); - $metadata['closed_reason'] = $closedReason !== '' ? $closedReason : 'agent_closed'; - $metadata['reconnect_state'] = 'CLOSED'; - $metadata['transport_state'] = 'DEGRADED'; - if (array_key_exists('code', $payload)) { - $metadata['exit_code'] = (int)$payload['code']; - } - $session->metadata_json->set($metadata); - - $wasClosed = $session->closed_at->value() !== null; - $session->approval_status->set('CLOSED'); - if (!$wasClosed) { - $session->closed_at->set($this->now()); - $this->writeAudit( - (int)$session->gateway_id->value(), - null, - 'ROOT_SHELL_CLOSED', - null, - ['session_id' => (int)$session->id, 'closed_reason' => $metadata['closed_reason']] - ); - } - - $this->cancelPendingShellActionJobs((int)$session->id); - return $this->appendShellEvent((int)$session->gateway_id->value(), (int)$session->id, $type, $payload); - } - - return $this->appendShellEvent((int)$session->gateway_id->value(), (int)$session->id, $type, $payload); - } - - private function appendShellEvent(int $gatewayId, int $sessionId, string $eventType, array $payload): array - { - $eventObject = new edge_gateway_shell_events_o(); - $eventId = $eventObject->add_object([ - 'gateway_id' => $gatewayId, - 'session_id' => $sessionId, - 'event_type' => strtoupper($eventType), - 'payload_json' => $payload, - ]); - - return $eventObject->select($eventId)->asArray(); - } - - /** - * @return array> - */ - private function listShellEvents(int $sessionId, int $afterId = 0, int $limit = self::SHELL_EVENT_POLL_LIMIT): array - { - $safeAfterId = max(0, $afterId); - $safeLimit = max(1, min(self::SHELL_EVENT_POLL_LIMIT, $limit)); - $statement = db::getPDO()->prepare( - 'SELECT id - FROM edge_gateway_shell_events - WHERE session_id = :session_id - AND deleted_at IS NULL - AND id > :after_id - ORDER BY id ASC - LIMIT ' . $safeLimit - ); - $statement->execute([ - ':session_id' => $sessionId, - ':after_id' => $safeAfterId, - ]); - - $events = []; - while (($row = $statement->fetch()) && is_array($row) && isset($row['id'])) { - $events[] = (new edge_gateway_shell_events_o())->select((int)$row['id'])->asArray(); - } - - return $events; - } - - /** - * @throws Exception - */ - private function requireShellSessionForGateway(int $gatewayId, int $sessionId): edge_gateway_shell_sessions_o - { - $session = (new edge_gateway_shell_sessions_o())->select($sessionId); - if (!$session->exists() || $session->deleted_at->value() !== null) { - throw new Exception('Edge gateway shell session not found'); - } - if ((int)$session->gateway_id->value() !== $gatewayId) { - throw new Exception('Edge gateway shell session does not belong to this gateway'); - } - - return $session; - } - - /** - * @throws Exception - */ - private function requireActiveShellSessionForGateway(int $gatewayId, int $sessionId): edge_gateway_shell_sessions_o - { - $session = $this->requireShellSessionForGateway($gatewayId, $sessionId); - if ($session->closed_at->value() !== null) { - throw new Exception('Edge gateway shell session is already closed'); - } - if (strtotime((string)$session->expires_at->value()) < time()) { - throw new Exception('Edge gateway shell session has expired'); - } - - return $session; - } - - private function cancelPendingShellActionJobs(int $sessionId): void - { - $statement = db::getPDO()->prepare( - 'UPDATE edge_gateway_shell_action_jobs - SET deleted_at = :deleted_at - WHERE session_id = :session_id - AND deleted_at IS NULL - AND status IN (\'PENDING\', \'DISPATCHING\')' - ); - $statement->execute([ - ':deleted_at' => $this->now(), - ':session_id' => $sessionId, - ]); - } - /** * @throws Exception */ @@ -1786,10 +1564,7 @@ BASH; throw $throwable; } - $job = (new edge_gateway_command_jobs_o())->select((int)$row['id']); - $this->markLinkedUpdateJobStarted((int)$job->id); - - return $job; + return (new edge_gateway_command_jobs_o())->select((int)$row['id']); } private function formatAgentCommandJob(edge_gateway_command_jobs_o $job, edge_gateways_o $gateway): array @@ -1856,9 +1631,8 @@ BASH; array $payload, ?string $errorMessage ): void { - $commandType = (string)$job->command_type->value(); - - if ($commandType === 'DISCOVER_SHELLY') { + unset($errorMessage); + if ((string)$job->command_type->value() === 'DISCOVER_SHELLY') { if ($ok) { $inventory = isset($payload['inventory']) && is_array($payload['inventory']) ? $payload['inventory'] : []; $this->syncDeviceInventory((int)$gateway->id, $inventory); @@ -1866,173 +1640,7 @@ BASH; } else { $gateway->discovery_status->set('FAILED'); } - return; } - - if ($commandType === 'RUN_UPDATE') { - if ($ok && !empty($payload['verification_pending'])) { - $this->markLinkedUpdateJobVerifying((int)$job->id, $payload); - } else { - $this->finalizeLinkedUpdateJob((int)$job->id, $ok, $payload, $errorMessage); - } - return; - } - - if ($commandType === 'UNINSTALL_AGENT') { - $metadata = (array)($gateway->metadata_json->value() ?? []); - $metadata['pending_uninstall'] = null; - $metadata['last_uninstall'] = [ - 'ok' => $ok, - 'completed_at' => $this->now(), - 'error' => $ok ? null : $errorMessage, - ]; - $gateway->metadata_json->set($metadata); - - $this->writeAudit( - (int)$gateway->id, - (int)$gateway->department_id->value(), - $ok ? 'GATEWAY_UNINSTALL_COMPLETED' : 'GATEWAY_UNINSTALL_FAILED', - null, - [ - 'command_job_id' => (int)$job->id, - 'error' => $ok ? null : $errorMessage, - ] - ); - } - } - - private function markLinkedUpdateJobStarted(int $commandJobId): void - { - $updateJob = $this->findLinkedUpdateJobByCommandId($commandJobId); - if ($updateJob === null) { - return; - } - - if ($updateJob->started_at->value() === null) { - $updateJob->started_at->set($this->now()); - } - - if ((string)$updateJob->status->value() === 'PENDING') { - $updateJob->status->set('DISPATCHING'); - } - } - - private function markLinkedUpdateJobVerifying(int $commandJobId, array $payload): void - { - $updateJob = $this->findLinkedUpdateJobByCommandId($commandJobId); - if ($updateJob === null) { - return; - } - - if ($updateJob->started_at->value() === null) { - $updateJob->started_at->set($this->now()); - } - - $updateJob->status->set('VERIFYING'); - $updateJob->result_json->set($payload); - } - - private function finalizeLinkedUpdateJob( - int $commandJobId, - bool $ok, - array $payload, - ?string $errorMessage, - ?string $finalStatus = null - ): void - { - $updateJob = $this->findLinkedUpdateJobByCommandId($commandJobId); - if ($updateJob === null) { - return; - } - - if ($updateJob->started_at->value() === null) { - $updateJob->started_at->set($this->now()); - } - - $updateJob->status->set($finalStatus ?? ($ok ? 'COMPLETED' : 'FAILED')); - $updateJob->completed_at->set($this->now()); - $updateJob->result_json->set($ok - ? $payload - : ['error' => $errorMessage ?: 'Edge gateway update failed']); - } - - private function findLinkedUpdateJobByCommandId(int $commandJobId): ?edge_gateway_update_jobs_o - { - $rows = (new edge_gateway_update_jobs_o())->getFieldsWhere([ - 'command_job_id' => $commandJobId, - 'deleted_at' => null, - ], ['id']); - - if ($rows === []) { - return null; - } - - return (new edge_gateway_update_jobs_o())->select((int)$rows[0]['id']); - } - - private function findLatestActiveUpdateJobForGateway(int $gatewayId, ?string $targetVersion = null): ?edge_gateway_update_jobs_o - { - $statement = db::getPDO()->prepare( - 'SELECT id - FROM edge_gateway_update_jobs - WHERE gateway_id = :gateway_id - AND deleted_at IS NULL - AND status NOT IN (\'COMPLETED\', \'FAILED\', \'ROLLED_BACK\')' - . ($targetVersion !== null && trim($targetVersion) !== '' ? ' AND target_version = :target_version' : '') - . ' ORDER BY requested_at DESC, id DESC - LIMIT 1' - ); - - $parameters = [ - ':gateway_id' => $gatewayId, - ]; - if ($targetVersion !== null && trim($targetVersion) !== '') { - $parameters[':target_version'] = trim($targetVersion); - } - - $statement->execute($parameters); - $row = $statement->fetch(); - if (!is_array($row) || !isset($row['id'])) { - return null; - } - - return (new edge_gateway_update_jobs_o())->select((int)$row['id']); - } - - private function applyHeartbeatUpdateLifecycle(edge_gateways_o $gateway, array $payload): void - { - $metadata = isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []; - $lastUpdate = isset($metadata['last_update']) && is_array($metadata['last_update']) - ? (array)$metadata['last_update'] - : []; - - if ($lastUpdate === []) { - return; - } - - $state = strtoupper(trim((string)($lastUpdate['state'] ?? ''))); - if (!in_array($state, ['COMPLETED', 'FAILED', 'ROLLED_BACK'], true)) { - return; - } - - $targetVersion = trim((string)($lastUpdate['target_version'] ?? '')); - $updateJob = $this->findLatestActiveUpdateJobForGateway((int)$gateway->id, $targetVersion !== '' ? $targetVersion : null); - if ($updateJob === null) { - return; - } - - if ($updateJob->started_at->value() === null) { - $updateJob->started_at->set($this->now()); - } - - $result = array_merge($lastUpdate, [ - 'heartbeat_installed_version' => $payload['installed_version'] ?? $gateway->installed_version->value(), - 'heartbeat_target_version' => $payload['target_version'] ?? $gateway->target_version->value(), - ]); - - $updateJob->status->set($state); - $updateJob->completed_at->set($this->now()); - $updateJob->result_json->set($result); } /** @@ -2220,11 +1828,11 @@ BASH; $counts = [ 'command_backlog' => 0, - 'shell_backlog' => 0, - 'update_backlog' => 0, + 'operation_backlog' => 0, 'last_successful_command_at' => null, 'last_successful_discovery_at' => null, - 'last_successful_shell_at' => null, + 'last_successful_operation_at' => null, + 'last_successful_update_at' => null, ]; $countQueries = [ @@ -2233,16 +1841,11 @@ BASH; WHERE gateway_id = :gateway_id AND deleted_at IS NULL AND status IN ('PENDING', 'DISPATCHING')", - 'shell_backlog' => "SELECT COUNT(*) AS c - FROM edge_gateway_shell_action_jobs + 'operation_backlog' => "SELECT COUNT(*) AS c + FROM edge_gateway_operations WHERE gateway_id = :gateway_id AND deleted_at IS NULL - AND status IN ('PENDING', 'DISPATCHING')", - 'update_backlog' => "SELECT COUNT(*) AS c - FROM edge_gateway_update_jobs - WHERE gateway_id = :gateway_id - AND deleted_at IS NULL - AND status IN ('PENDING', 'DISPATCHING', 'VERIFYING')", + AND status IN ('PENDING', 'IN_PROGRESS')", ]; foreach ($countQueries as $key => $sql) { @@ -2268,11 +1871,20 @@ BASH; AND command_type = 'DISCOVER_SHELLY' ORDER BY completed_at DESC, id DESC LIMIT 1", - 'last_successful_shell_at' => "SELECT COALESCE(opened_at, approved_at, created_at) AS ts - FROM edge_gateway_shell_sessions + 'last_successful_operation_at' => "SELECT completed_at AS ts + FROM edge_gateway_operations WHERE gateway_id = :gateway_id AND deleted_at IS NULL - ORDER BY COALESCE(opened_at, approved_at, created_at) DESC, id DESC + AND status = 'COMPLETED' + ORDER BY completed_at DESC, id DESC + LIMIT 1", + 'last_successful_update_at' => "SELECT completed_at AS ts + FROM edge_gateway_operations + WHERE gateway_id = :gateway_id + AND deleted_at IS NULL + AND status = 'COMPLETED' + AND type = 'UPDATE' + ORDER BY completed_at DESC, id DESC LIMIT 1", ]; @@ -2734,6 +2346,121 @@ BASH; return $decoded; } + /** + * @param array $gatewayIds + * @return array + */ + private function aggregateInventoryUsage(array $gatewayIds): array + { + if ($gatewayIds === []) { + return self::emptyInventoryUsage(); + } + + $placeholders = implode(', ', array_fill(0, count($gatewayIds), '?')); + $statement = db::getPDO()->prepare( + "SELECT + COUNT(*) AS total, + SUM(CASE WHEN online = 1 THEN 1 ELSE 0 END) AS online_total, + SUM(CASE WHEN online = 0 THEN 1 ELSE 0 END) AS offline_total + FROM edge_gateway_device_inventory + WHERE deleted_at IS NULL + AND gateway_id IN ($placeholders)" + ); + $statement->execute($gatewayIds); + $row = $statement->fetch(); + + return [ + 'total' => (int)($row['total'] ?? 0), + 'online' => (int)($row['online_total'] ?? 0), + 'offline' => (int)($row['offline_total'] ?? 0), + ]; + } + + /** + * @param array $gatewayIds + * @return array + */ + private function aggregateBindingUsage(array $gatewayIds): array + { + if ($gatewayIds === []) { + return self::emptyBindingUsage(); + } + + $placeholders = implode(', ', array_fill(0, count($gatewayIds), '?')); + $statement = db::getPDO()->prepare( + "SELECT + COUNT(*) AS total, + SUM(CASE WHEN fallback_mode <> ? THEN 1 ELSE 0 END) AS fallback_overrides, + SUM(CASE WHEN fallback_mode = ? THEN 1 ELSE 0 END) AS cloud_only_total, + SUM(CASE WHEN fallback_mode = ? THEN 1 ELSE 0 END) AS local_only_total + FROM edge_gateway_relay_bindings + WHERE deleted_at IS NULL + AND gateway_id IN ($placeholders)" + ); + $statement->execute(array_merge([ + self::RELAY_FALLBACK_PREFER_LOCAL, + self::RELAY_FALLBACK_CLOUD_ONLY, + self::RELAY_FALLBACK_LOCAL_ONLY, + ], $gatewayIds)); + $row = $statement->fetch(); + + return [ + 'total' => (int)($row['total'] ?? 0), + 'fallback_overrides' => (int)($row['fallback_overrides'] ?? 0), + 'cloud_only' => (int)($row['cloud_only_total'] ?? 0), + 'local_only' => (int)($row['local_only_total'] ?? 0), + ]; + } + + /** + * @return array + */ + private static function emptyInventoryUsage(): array + { + return [ + 'total' => 0, + 'online' => 0, + 'offline' => 0, + ]; + } + + /** + * @return array + */ + private static function emptyBindingUsage(): array + { + return [ + 'total' => 0, + 'fallback_overrides' => 0, + 'cloud_only' => 0, + 'local_only' => 0, + ]; + } + + /** + * @param array $values + */ + private static function appendNumericMetric(array &$values, mixed $value): void + { + if (!is_int($value) && !is_float($value) && !(is_string($value) && is_numeric($value))) { + return; + } + + $values[] = (float)$value; + } + + /** + * @param array $values + */ + private static function averageMetric(array $values): ?int + { + if ($values === []) { + return null; + } + + return (int)round(array_sum($values) / count($values)); + } + public static function deriveGatewayRuntimeState(array $gateway, ?int $now = null): array { $effectiveStatus = self::resolveGatewayStatus( @@ -2766,13 +2493,15 @@ BASH; ?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), null); $gateway['last_successful_discovery_at'] = $gateway['operational_snapshot']['last_successful_discovery_at'] ?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), 'DISCOVER_SHELLY'); - $gateway['last_successful_shell_at'] = $gateway['operational_snapshot']['last_successful_shell_at'] - ?? self::findLatestShellTimestamp((array)($gateway['recent_shell_sessions'] ?? [])); + $gateway['last_successful_operation_at'] = $gateway['operational_snapshot']['last_successful_operation_at'] ?? null; $gateway['backlog_depth'] = [ 'commands' => (int)($gateway['operational_snapshot']['command_backlog'] ?? 0), - 'shell_actions' => (int)($gateway['operational_snapshot']['shell_backlog'] ?? 0), - 'updates' => (int)($gateway['operational_snapshot']['update_backlog'] ?? 0), + 'operations' => (int)($gateway['operational_snapshot']['operation_backlog'] ?? 0), ]; + $gateway['version_drift'] = self::buildVersionDriftSummary($gateway); + $gateway['credential_freshness'] = self::buildCredentialFreshnessSummary($gateway, $now); + $gateway['diagnostics'] = self::buildGatewayDiagnostics($gateway, $effectiveStatus, $now); + $gateway['error_state'] = self::primaryGatewayErrorState($gateway['diagnostics'], $gateway); return $gateway; } @@ -2803,14 +2532,6 @@ BASH; 'last_success_at' => $operational['last_successful_command_at'] ?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), null), ], - 'shell' => [ - 'preferred' => self::DELIVERY_CHANNEL_API, - 'active' => self::DELIVERY_CHANNEL_API, - 'state' => $effectiveStatus, - 'backlog_depth' => (int)($operational['shell_backlog'] ?? 0), - 'last_success_at' => $operational['last_successful_shell_at'] - ?? self::findLatestShellTimestamp((array)($gateway['recent_shell_sessions'] ?? [])), - ], 'broker' => [ 'connected' => $brokerConnected, 'state' => !$brokerConnected @@ -2970,6 +2691,118 @@ BASH; ]; } + private static function buildVersionDriftSummary(array $gateway): array + { + $installed = isset($gateway['installed_version']) ? trim((string)$gateway['installed_version']) : ''; + $target = isset($gateway['target_version']) ? trim((string)$gateway['target_version']) : ''; + $isDrifted = $installed !== '' && $target !== '' && $installed !== $target; + + return [ + 'installed_version' => $installed !== '' ? $installed : null, + 'target_version' => $target !== '' ? $target : null, + 'release_channel' => isset($gateway['release_channel']) ? (string)$gateway['release_channel'] : self::DEFAULT_RELEASE_CHANNEL, + 'is_drifted' => $isDrifted, + 'status' => $isDrifted ? 'UPDATE_AVAILABLE' : (($installed === '' || $target === '') ? 'UNKNOWN' : 'IN_SYNC'), + 'last_successful_update_at' => $gateway['operational_snapshot']['last_successful_update_at'] ?? null, + ]; + } + + private static function buildCredentialFreshnessSummary(array $gateway, ?int $now = null): array + { + $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; + $rotatedAt = isset($metadata['credentials_rotated_at']) ? (string)$metadata['credentials_rotated_at'] : null; + $ageSeconds = self::heartbeatAgeSeconds($rotatedAt, $now); + + return [ + 'rotated_at' => $rotatedAt, + 'age_days' => $ageSeconds === null ? null : (int)floor($ageSeconds / 86400), + 'state' => $rotatedAt === null + ? 'UNKNOWN' + : ($ageSeconds !== null && $ageSeconds <= self::CREDENTIAL_FRESH_AFTER_SECONDS ? 'FRESH' : 'STALE'), + ]; + } + + private static function buildGatewayDiagnostics(array $gateway, string $effectiveStatus, ?int $now = null): array + { + $diagnostics = []; + $heartbeatAge = self::heartbeatAgeSeconds( + isset($gateway['last_heartbeat_at']) ? (string)$gateway['last_heartbeat_at'] : null, + $now + ); + + if ($effectiveStatus === self::STATUS_OFFLINE) { + $diagnostics[] = [ + 'code' => edge_gateway_operation_service::ERROR_OFFLINE, + 'severity' => 'danger', + 'message' => 'Gateway heartbeat has expired and the gateway is offline.', + 'recommended_action' => 'restart_agent', + ]; + } elseif ($effectiveStatus === self::STATUS_DEGRADED || ($heartbeatAge !== null && $heartbeatAge >= self::HEARTBEAT_DEGRADED_AFTER_SECONDS)) { + $diagnostics[] = [ + 'code' => edge_gateway_operation_service::ERROR_STALE_HEARTBEAT, + 'severity' => 'warning', + 'message' => 'Gateway heartbeat is stale and control traffic may degrade.', + 'recommended_action' => 'inspect_connectivity', + ]; + } + + $activeOperation = isset($gateway['active_operation']) && is_array($gateway['active_operation']) + ? (array)$gateway['active_operation'] + : null; + if ($activeOperation !== null && !empty($activeOperation['started_at'])) { + $startedAt = strtotime((string)$activeOperation['started_at']); + if ($startedAt !== false && (($now ?? time()) - $startedAt) >= edge_gateway_operation_service::OPERATION_TIMEOUT_SECONDS) { + $diagnostics[] = [ + 'code' => edge_gateway_operation_service::ERROR_OPERATION_TIMEOUT, + 'severity' => 'warning', + 'message' => 'The active gateway operation has exceeded the expected timeout.', + 'recommended_action' => 'retry_operation', + ]; + } + } + + if (!empty($gateway['version_drift']['is_drifted'])) { + $diagnostics[] = [ + 'code' => 'EDGE_GATEWAY_VERSION_DRIFT', + 'severity' => 'info', + 'message' => 'Installed gateway version differs from the target version.', + 'recommended_action' => 'queue_update', + ]; + } + + if (($gateway['credential_freshness']['state'] ?? 'UNKNOWN') === 'STALE') { + $diagnostics[] = [ + 'code' => 'EDGE_GATEWAY_CREDENTIALS_STALE', + 'severity' => 'warning', + 'message' => 'Gateway credentials have not been rotated recently.', + 'recommended_action' => 'rotate_credentials', + ]; + } + + return $diagnostics; + } + + private static function primaryGatewayErrorState(array $diagnostics, array $gateway): ?array + { + if ($diagnostics !== []) { + return [ + 'code' => (string)$diagnostics[0]['code'], + 'message' => (string)$diagnostics[0]['message'], + 'recommended_action' => $diagnostics[0]['recommended_action'] ?? null, + ]; + } + + if (!empty($gateway['active_operation']['error_code']) || !empty($gateway['active_operation']['error_message'])) { + return [ + 'code' => $gateway['active_operation']['error_code'] ?? 'EDGE_GATEWAY_OPERATION_FAILED', + 'message' => $gateway['active_operation']['error_message'] ?? 'Gateway operation failed', + 'recommended_action' => 'retry_operation', + ]; + } + + return null; + } + private static function findLatestCompletionTimestamp(array $jobs, ?string $commandType = null): ?string { foreach ($jobs as $job) { diff --git a/services/nginx/app/classes/edge_gateway_operation_exception.php b/services/nginx/app/classes/edge_gateway_operation_exception.php new file mode 100644 index 00000000..c673bef3 --- /dev/null +++ b/services/nginx/app/classes/edge_gateway_operation_exception.php @@ -0,0 +1,16 @@ +> + * @throws Exception + */ + public function listOperations(int $gatewayId, int $limit = 20, bool $includeEvents = true): array + { + $this->requireGateway($gatewayId); + $this->failTimedOutOperations($gatewayId); + $rows = (new edge_gateway_operations_o())->getFieldsWhere([ + 'gateway_id' => $gatewayId, + 'deleted_at' => null, + ], ['id']); + + $ids = array_map(static fn(array $row): int => (int)$row['id'], $rows); + rsort($ids); + $ids = array_slice($ids, 0, max(1, $limit)); + + $operations = []; + foreach ($ids as $id) { + $operations[] = $this->serializeOperation((new edge_gateway_operations_o())->select($id), $includeEvents); + } + + return $operations; + } + + /** + * @return array> + * @throws Exception + */ + public function listOperationEvents(int $gatewayId, int $operationId, int $limit = 100): array + { + $this->requireOperation($gatewayId, $operationId); + + $rows = (new edge_gateway_operation_events_o())->getFieldsWhere([ + 'gateway_id' => $gatewayId, + 'operation_id' => $operationId, + ], ['id']); + + $ids = array_map(static fn(array $row): int => (int)$row['id'], $rows); + sort($ids); + $ids = array_slice($ids, max(0, count($ids) - max(1, $limit))); + + $events = []; + foreach ($ids as $id) { + $events[] = (new edge_gateway_operation_events_o())->select($id)->asArray(); + } + + return $events; + } + + /** + * @throws Exception + */ + public function getActiveOperation(int $gatewayId, bool $includeEvents = true): ?array + { + $this->requireGateway($gatewayId); + $this->failTimedOutOperations($gatewayId); + + $statement = db::getPDO()->prepare( + "SELECT id + FROM edge_gateway_operations + WHERE gateway_id = :gateway_id + AND deleted_at IS NULL + AND status IN ('PENDING', 'IN_PROGRESS') + ORDER BY FIELD(status, 'IN_PROGRESS', 'PENDING'), id ASC + LIMIT 1" + ); + $statement->execute([':gateway_id' => $gatewayId]); + $row = $statement->fetch(); + + if (!is_array($row) || empty($row['id'])) { + return null; + } + + return $this->serializeOperation((new edge_gateway_operations_o())->select((int)$row['id']), $includeEvents); + } + + /** + * @throws Exception + */ + public function buildRecentOperationsSummary(int $gatewayId): array + { + $operations = $this->listOperations($gatewayId, 25, false); + $summary = [ + 'total' => count($operations), + 'pending' => 0, + 'in_progress' => 0, + 'completed' => 0, + 'failed' => 0, + 'latest_completed_at' => null, + 'latest_failed_at' => null, + 'latest_type' => $operations[0]['type'] ?? null, + 'latest_status' => $operations[0]['status'] ?? null, + ]; + + foreach ($operations as $operation) { + $status = (string)($operation['status'] ?? self::STATUS_PENDING); + if ($status === self::STATUS_PENDING) { + $summary['pending'] += 1; + } elseif ($status === self::STATUS_IN_PROGRESS) { + $summary['in_progress'] += 1; + } elseif ($status === self::STATUS_COMPLETED) { + $summary['completed'] += 1; + $summary['latest_completed_at'] ??= $operation['completed_at'] ?? null; + } elseif ($status === self::STATUS_FAILED) { + $summary['failed'] += 1; + $summary['latest_failed_at'] ??= $operation['completed_at'] ?? null; + } + } + + return $summary; + } + + /** + * @throws Exception + */ + public function queueOperation(int $gatewayId, string $type, array $request = [], ?int $requestedBy = null): array + { + $gateway = $this->requireGateway($gatewayId); + $type = self::normalizeOperationType($type); + $request = $this->validateOperationRequest($gateway, $type, $request); + + if ($this->getActiveOperation($gatewayId) !== null) { + throw new edge_gateway_operation_exception( + 'Another gateway operation is already active', + self::ERROR_CONFLICT, + 409 + ); + } + + if ($type === self::TYPE_DISCOVERY) { + $gateway->discovery_status->set('PENDING'); + } elseif ($type === self::TYPE_UPDATE) { + $targetVersion = trim((string)($request['target_version'] ?? '')); + if ($targetVersion !== '') { + $gateway->target_version->set($targetVersion); + } + } + + $summary = [ + 'label' => match ($type) { + self::TYPE_DISCOVERY => 'Discovery queued', + self::TYPE_UPDATE => 'Update queued', + self::TYPE_UNINSTALL => 'Uninstall queued', + }, + 'progress' => 0, + 'retryable' => true, + ]; + + $operationId = (new edge_gateway_operations_o())->add_object([ + 'gateway_id' => $gatewayId, + 'type' => $type, + 'operation_type' => $type, + 'status' => self::STATUS_PENDING, + 'request_json' => $request, + 'summary_json' => $summary, + 'result_json' => [], + 'error_code' => null, + 'error_message' => null, + 'correlation_id' => bin2hex(random_bytes(16)), + 'agent_instance_id' => null, + 'lease_expires_at' => null, + 'last_progress_at' => null, + 'attempt_count' => 0, + 'requested_by' => $requestedBy, + 'requested_at' => $this->now(), + 'started_at' => null, + 'completed_at' => null, + ]); + + $this->appendEventRecord( + $gatewayId, + $operationId, + self::LEVEL_INFO, + 'OPERATION_QUEUED', + 'Operation queued for gateway execution', + ['type' => $type, 'request' => $request] + ); + + $this->manager()->logGatewayAudit( + $gatewayId, + (int)$gateway->department_id->value(), + 'GATEWAY_OPERATION_QUEUED', + $requestedBy, + ['operation_id' => $operationId, 'type' => $type] + ); + + return $this->serializeOperation((new edge_gateway_operations_o())->select($operationId), true); + } + + /** + * @throws Exception + */ + public function queueDiscoveryOperation(int $gatewayId, ?int $requestedBy = null): array + { + return $this->queueOperation($gatewayId, self::TYPE_DISCOVERY, [], $requestedBy); + } + + /** + * @throws Exception + */ + public function rotateCredentials(int $gatewayId, ?int $userId = null): array + { + return $this->manager()->rotateGatewayCredentials($gatewayId, $userId); + } + + /** + * @throws Exception + */ + public function claimNextOperation( + int $gatewayId, + string $plainToken, + int $waitSeconds = edge_gateway_manager::COMMAND_POLL_TIMEOUT_SECONDS, + ?string $agentInstanceId = null + ): ?array { + try { + $this->manager()->authenticateGateway($gatewayId, $plainToken); + } catch (Exception) { + throw new edge_gateway_operation_exception('Invalid edge gateway token', self::ERROR_INVALID_TOKEN, 401); + } + + $deadline = microtime(true) + max(0, $waitSeconds); + do { + $this->failTimedOutOperations($gatewayId); + $operation = $this->claimPendingOperation($gatewayId, $this->normalizeAgentInstanceId($agentInstanceId, $gatewayId)); + if ($operation !== null) { + return $operation; + } + + if (microtime(true) >= $deadline) { + break; + } + + usleep(self::POLL_INTERVAL_MICROSECONDS); + } while (true); + + return null; + } + + /** + * @throws Exception + */ + public function appendAgentOperationEvent(int $gatewayId, int $operationId, string $plainToken, array $payload): array + { + try { + $this->manager()->authenticateGateway($gatewayId, $plainToken); + } catch (Exception) { + throw new edge_gateway_operation_exception('Invalid edge gateway token', self::ERROR_INVALID_TOKEN, 401); + } + + $operation = $this->requireOperation($gatewayId, $operationId); + if (in_array((string)$operation->status->value(), [self::STATUS_COMPLETED, self::STATUS_FAILED], true)) { + return $this->serializeOperation($operation, true); + } + + $level = strtoupper(trim((string)($payload['level'] ?? self::LEVEL_INFO))); + if (!in_array($level, [self::LEVEL_INFO, self::LEVEL_WARNING, self::LEVEL_ERROR], true)) { + $level = self::LEVEL_INFO; + } + + $message = trim((string)($payload['message'] ?? 'Operation event received')); + if ($message === '') { + $message = 'Operation event received'; + } + + $code = isset($payload['code']) ? trim((string)$payload['code']) : null; + $context = isset($payload['context']) && is_array($payload['context']) ? (array)$payload['context'] : []; + $this->appendEventRecord($gatewayId, $operationId, $level, $code, $message, $context); + + $summary = (array)($operation->summary_json->value() ?? []); + $summary['last_event_at'] = $this->now(); + $summary['last_event_message'] = $message; + if (isset($context['progress'])) { + $summary['progress'] = max(0, min(100, (int)$context['progress'])); + } + if (isset($context['label']) && trim((string)$context['label']) !== '') { + $summary['label'] = trim((string)$context['label']); + } + $operation->summary_json->set($summary); + $this->refreshOperationLease($operation); + + return $this->serializeOperation($operation, true); + } + + /** + * @throws Exception + */ + public function completeAgentOperation(int $gatewayId, int $operationId, string $plainToken, array $payload): array + { + try { + $this->manager()->authenticateGateway($gatewayId, $plainToken); + } catch (Exception) { + throw new edge_gateway_operation_exception('Invalid edge gateway token', self::ERROR_INVALID_TOKEN, 401); + } + + $operation = $this->requireOperation($gatewayId, $operationId); + if (in_array((string)$operation->status->value(), [self::STATUS_COMPLETED, self::STATUS_FAILED], true)) { + return $this->serializeOperation($operation, true); + } + + $ok = (bool)($payload['ok'] ?? false); + $result = isset($payload['result']) && is_array($payload['result']) ? (array)$payload['result'] : []; + $errorMessage = trim((string)($payload['error_message'] ?? $payload['error'] ?? '')); + $errorCode = trim((string)($payload['error_code'] ?? '')); + if (!$ok && $errorCode === '') { + $errorCode = $this->classifyCompletionError($gatewayId, $errorMessage); + } + if (!$ok && $errorMessage === '') { + $errorMessage = 'Gateway operation failed'; + } + + $operation->status->set($ok ? self::STATUS_COMPLETED : self::STATUS_FAILED); + $operation->result_json->set($result); + $operation->error_code->set($ok ? null : $errorCode); + $operation->error_message->set($ok ? null : $errorMessage); + $operation->completed_at->set($this->now()); + $operation->lease_expires_at->set(null); + $operation->last_progress_at->set($this->now()); + + $summary = (array)($operation->summary_json->value() ?? []); + $summary['label'] = $ok ? 'Completed' : 'Failed'; + $summary['progress'] = 100; + $summary['retryable'] = !$ok && $errorCode !== self::ERROR_UNSUPPORTED_VERSION; + $operation->summary_json->set($summary); + + $this->appendEventRecord( + $gatewayId, + $operationId, + $ok ? self::LEVEL_INFO : self::LEVEL_ERROR, + $ok ? 'OPERATION_COMPLETED' : $errorCode, + $ok ? 'Operation completed successfully' : $errorMessage, + $result + ); + + $this->applyCompletionSideEffects($gatewayId, $operation, $ok, $result, $errorCode, $errorMessage); + + return $this->serializeOperation($operation, true); + } + + private static function normalizeOperationType(string $type): string + { + $normalized = strtoupper(trim($type)); + if (in_array($normalized, [self::TYPE_DISCOVERY, self::TYPE_UPDATE, self::TYPE_UNINSTALL], true)) { + return $normalized; + } + + throw new edge_gateway_operation_exception( + 'Unsupported gateway operation type', + self::ERROR_VALIDATION, + 422 + ); + } + + /** + * @throws Exception + */ + private function validateOperationRequest(edge_gateways_o $gateway, string $type, array $request): array + { + if ($type === self::TYPE_DISCOVERY) { + return $request; + } + + if ($type === self::TYPE_UPDATE) { + $targetVersion = trim((string)($request['target_version'] ?? '')); + if ($targetVersion === '') { + throw new edge_gateway_operation_exception( + 'Update operations require target_version', + self::ERROR_VALIDATION, + 422 + ); + } + + $releaseChannel = trim((string)($request['release_channel'] ?? $gateway->release_channel->value() ?? edge_gateway_manager::DEFAULT_RELEASE_CHANNEL)); + if ($releaseChannel === '') { + $releaseChannel = edge_gateway_manager::DEFAULT_RELEASE_CHANNEL; + } + + return array_merge( + $this->manager()->buildUpdateOperationRequest($targetVersion, $releaseChannel), + $request, + [ + 'target_version' => $targetVersion, + 'release_channel' => $releaseChannel, + ] + ); + } + + if ($type === self::TYPE_UNINSTALL) { + return array_merge([ + 'service_name' => edge_gateway_manager::DEFAULT_AGENT_SERVICE_NAME, + 'install_dir' => '/opt/truckwash-edge-agent', + ], $request); + } + + return $request; + } + + /** + * @throws Exception + */ + private function claimPendingOperation(int $gatewayId, string $agentInstanceId): ?array + { + $pdo = db::getPDO(); + $pdo->beginTransaction(); + + try { + $statement = $pdo->prepare( + "SELECT id + FROM edge_gateway_operations + WHERE gateway_id = :gateway_id + AND deleted_at IS NULL + AND status = 'PENDING' + ORDER BY id ASC + LIMIT 1 + FOR UPDATE" + ); + $statement->execute([':gateway_id' => $gatewayId]); + $row = $statement->fetch(); + + if (!is_array($row) || empty($row['id'])) { + $pdo->commit(); + return null; + } + + $operation = (new edge_gateway_operations_o())->select((int)$row['id']); + $operation->status->set(self::STATUS_IN_PROGRESS); + $operation->started_at->set($this->now()); + $operation->agent_instance_id->set($agentInstanceId); + $operation->last_progress_at->set($this->now()); + $operation->lease_expires_at->set($this->leaseExpiry()); + $operation->attempt_count->set(((int)($operation->attempt_count->value() ?? 0)) + 1); + $summary = (array)($operation->summary_json->value() ?? []); + $summary['label'] = 'Gateway is processing the operation'; + $summary['progress'] = max(5, (int)($summary['progress'] ?? 0)); + $summary['claimed_by'] = $agentInstanceId; + $operation->summary_json->set($summary); + $pdo->commit(); + + $this->appendEventRecord( + $gatewayId, + (int)$operation->id, + self::LEVEL_INFO, + 'OPERATION_STARTED', + 'Gateway started processing the operation', + [ + 'type' => (string)$operation->type->value(), + 'agent_instance_id' => $agentInstanceId, + 'attempt_count' => (int)($operation->attempt_count->value() ?? 1), + ] + ); + + return $this->serializeOperation($operation, true); + } catch (\Throwable $throwable) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $throwable; + } + } + + /** + * @throws Exception + */ + private function failTimedOutOperations(int $gatewayId): void + { + $rows = (new edge_gateway_operations_o())->getFieldsWhere([ + 'gateway_id' => $gatewayId, + 'status' => self::STATUS_IN_PROGRESS, + 'deleted_at' => null, + ], ['id']); + + $now = time(); + foreach ($rows as $row) { + $operation = (new edge_gateway_operations_o())->select((int)$row['id']); + $startedAt = $operation->started_at->value() === null ? null : strtotime((string)$operation->started_at->value()); + $leaseExpiresAt = $operation->lease_expires_at->value() === null ? null : strtotime((string)$operation->lease_expires_at->value()); + $timedOut = $startedAt !== false + && $startedAt !== null + && ($now - $startedAt) >= self::OPERATION_TIMEOUT_SECONDS; + $leaseExpired = $leaseExpiresAt !== false + && $leaseExpiresAt !== null + && $leaseExpiresAt <= $now; + + if (!$timedOut && !$leaseExpired) { + continue; + } + + $errorMessage = $leaseExpired + ? 'Gateway stopped reporting operation progress before the lease expired' + : 'Gateway operation timed out'; + $summaryLabel = $leaseExpired ? 'Lease expired' : 'Timed out'; + $operation->status->set(self::STATUS_FAILED); + $operation->completed_at->set($this->now()); + $operation->error_code->set(self::ERROR_OPERATION_TIMEOUT); + $operation->error_message->set($errorMessage); + $operation->lease_expires_at->set(null); + $summary = (array)($operation->summary_json->value() ?? []); + $summary['label'] = $summaryLabel; + $summary['retryable'] = true; + $operation->summary_json->set($summary); + + $this->appendEventRecord( + $gatewayId, + (int)$operation->id, + self::LEVEL_ERROR, + self::ERROR_OPERATION_TIMEOUT, + $errorMessage, + [ + 'agent_instance_id' => $operation->agent_instance_id->value(), + 'last_progress_at' => $operation->last_progress_at->value(), + ] + ); + } + } + + /** + * @throws Exception + */ + private function applyCompletionSideEffects( + int $gatewayId, + edge_gateway_operations_o $operation, + bool $ok, + array $result, + string $errorCode, + string $errorMessage + ): void { + $gateway = $this->requireGateway($gatewayId); + $metadata = (array)($gateway->metadata_json->value() ?? []); + $type = (string)$operation->type->value(); + $now = $this->now(); + + $metadata['last_operation'] = [ + 'id' => (int)$operation->id, + 'type' => $type, + 'status' => $ok ? self::STATUS_COMPLETED : self::STATUS_FAILED, + 'completed_at' => $now, + 'error_code' => $ok ? null : $errorCode, + ]; + + if ($type === self::TYPE_DISCOVERY) { + if ($ok) { + $inventory = isset($result['inventory']) && is_array($result['inventory']) ? (array)$result['inventory'] : []; + $this->manager()->syncGatewayInventory($gatewayId, $inventory); + $gateway->discovery_status->set('READY'); + $metadata['last_discovery_completed_at'] = $now; + $metadata['last_discovery_error'] = null; + } else { + $gateway->discovery_status->set('FAILED'); + $metadata['last_discovery_error'] = [ + 'code' => $errorCode, + 'message' => $errorMessage, + 'at' => $now, + ]; + } + } elseif ($type === self::TYPE_UPDATE) { + if ($ok) { + $installedVersion = trim((string)($result['installed_version'] ?? $result['target_version'] ?? $operation->request_json->value()['target_version'] ?? '')); + if ($installedVersion !== '') { + $gateway->installed_version->set($installedVersion); + $gateway->target_version->set($installedVersion); + } + $metadata['last_update_completed_at'] = $now; + $metadata['last_update_error'] = null; + } else { + $metadata['last_update_error'] = [ + 'code' => $errorCode, + 'message' => $errorMessage, + 'at' => $now, + ]; + } + } elseif ($type === self::TYPE_UNINSTALL) { + if ($ok) { + $gateway->status->set(edge_gateway_manager::STATUS_OFFLINE); + $metadata['uninstalled_at'] = $now; + $metadata['uninstall_error'] = null; + } else { + $metadata['uninstall_error'] = [ + 'code' => $errorCode, + 'message' => $errorMessage, + 'at' => $now, + ]; + } + } + + $gateway->metadata_json->set($metadata); + + $this->manager()->logGatewayAudit( + $gatewayId, + (int)$gateway->department_id->value(), + $ok ? 'GATEWAY_OPERATION_COMPLETED' : 'GATEWAY_OPERATION_FAILED', + null, + [ + 'operation_id' => (int)$operation->id, + 'type' => $type, + 'status' => $ok ? self::STATUS_COMPLETED : self::STATUS_FAILED, + 'error_code' => $ok ? null : $errorCode, + ] + ); + } + + /** + * @throws Exception + */ + private function serializeOperation(edge_gateway_operations_o $operation, bool $includeEvents = true): array + { + $operationArray = $operation->asArray(); + if ($includeEvents) { + $operationArray['events'] = $this->listOperationEvents( + (int)$operation->gateway_id->value(), + (int)$operation->id, + 20 + ); + } + + return $operationArray; + } + + /** + * @throws Exception + */ + private function requireGateway(int $gatewayId): edge_gateways_o + { + $gateway = (new edge_gateways_o())->select($gatewayId); + if (!$gateway->exists() || $gateway->deleted_at->value() !== null) { + throw new Exception('Edge gateway not found'); + } + + return $gateway; + } + + /** + * @throws Exception + */ + private function requireOperation(int $gatewayId, int $operationId): edge_gateway_operations_o + { + $operation = (new edge_gateway_operations_o())->select($operationId); + if (!$operation->exists() || $operation->deleted_at->value() !== null) { + throw new Exception('Edge gateway operation not found'); + } + if ((int)$operation->gateway_id->value() !== $gatewayId) { + throw new Exception('Edge gateway operation does not belong to this gateway'); + } + + return $operation; + } + + private function appendEventRecord( + int $gatewayId, + int $operationId, + string $level, + ?string $code, + string $message, + array $context + ): array { + $eventId = (new edge_gateway_operation_events_o())->add_object([ + 'operation_id' => $operationId, + 'gateway_id' => $gatewayId, + 'level' => $level, + 'code' => $code, + 'message' => $message, + 'context_json' => $context, + ]); + + return (new edge_gateway_operation_events_o())->select($eventId)->asArray(); + } + + private function refreshOperationLease(edge_gateway_operations_o $operation): void + { + $operation->last_progress_at->set($this->now()); + $operation->lease_expires_at->set($this->leaseExpiry()); + } + + /** + * @throws Exception + */ + private function classifyCompletionError(int $gatewayId, string $errorMessage): string + { + $gateway = $this->requireGateway($gatewayId); + $gatewayStatus = edge_gateway_manager::resolveGatewayStatus( + (string)$gateway->status->value(), + $gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value() + ); + + if ($gatewayStatus === edge_gateway_manager::STATUS_OFFLINE) { + return self::ERROR_OFFLINE; + } + if ($gatewayStatus === edge_gateway_manager::STATUS_DEGRADED) { + return self::ERROR_STALE_HEARTBEAT; + } + + $normalizedMessage = strtolower(trim($errorMessage)); + if (str_contains($normalizedMessage, 'version')) { + return self::ERROR_UNSUPPORTED_VERSION; + } + if (str_contains($normalizedMessage, 'validation')) { + return self::ERROR_VALIDATION; + } + + return self::ERROR_VALIDATION; + } + + private function manager(): edge_gateway_manager + { + return $this->manager ?? new edge_gateway_manager(); + } + + private function now(): string + { + return date('Y-m-d H:i:s'); + } + + private function leaseExpiry(): string + { + return date('Y-m-d H:i:s', time() + self::OPERATION_LEASE_SECONDS); + } + + private function normalizeAgentInstanceId(?string $agentInstanceId, int $gatewayId): string + { + $candidate = trim((string)$agentInstanceId); + if ($candidate === '') { + return 'gateway-' . $gatewayId; + } + + return substr($candidate, 0, 128); + } +} diff --git a/services/nginx/app/classes/edge_gateway_registry_service.php b/services/nginx/app/classes/edge_gateway_registry_service.php new file mode 100644 index 00000000..f57f8720 --- /dev/null +++ b/services/nginx/app/classes/edge_gateway_registry_service.php @@ -0,0 +1,92 @@ +manager()->createInstallToken($departmentId, $label, $createdBy); + } + + /** + * @throws Exception + */ + public function verifyInstallToken(string $plainToken): array + { + return $this->manager()->verifyInstallToken($plainToken); + } + + /** + * @throws Exception + */ + public function claimGateway( + string $token, + string $hostname, + ?string $installedVersion = null, + array $metadata = [] + ): array { + $payload = $this->manager()->claimGateway($token, $hostname, $installedVersion, $metadata); + unset($payload['broker_url']); + + return $payload; + } + + /** + * @throws Exception + */ + public function recordHeartbeat(int $gatewayId, string $plainToken, array $payload): array + { + return $this->manager()->recordHeartbeat($gatewayId, $plainToken, $payload); + } + + /** + * @throws Exception + */ + public function updateGatewayMetadata(int $gatewayId, array $payload, ?int $userId = null): array + { + return $this->manager()->updateGatewayMetadata($gatewayId, $payload, $userId); + } + + /** + * @return array> + * @throws Exception + */ + public function setRelayBindings(int $gatewayId, array $bindings, ?int $userId = null): array + { + return $this->manager()->setRelayBindings($gatewayId, $bindings, $userId); + } + + /** + * @throws Exception + */ + public function deleteGateway(int $gatewayId, ?int $userId = null): array + { + return $this->manager()->deleteGateway($gatewayId, $userId); + } + + /** + * @throws Exception + */ + public function setDepartmentTransportMode(int $departmentId, string $transportMode, ?int $userId = null): array + { + return $this->manager()->setDepartmentTransportMode($departmentId, $transportMode, $userId); + } + + public function getDepartmentTransportMode(int $departmentId): string + { + return $this->manager()->getDepartmentTransportMode($departmentId); + } + + private function manager(): edge_gateway_manager + { + return $this->manager ?? new edge_gateway_manager(); + } +} diff --git a/services/nginx/app/classes/edge_gateway_schema_bootstrap.php b/services/nginx/app/classes/edge_gateway_schema_bootstrap.php index 5f4d580e..cfc57424 100644 --- a/services/nginx/app/classes/edge_gateway_schema_bootstrap.php +++ b/services/nginx/app/classes/edge_gateway_schema_bootstrap.php @@ -89,6 +89,7 @@ class edge_gateway_schema_bootstrap device_id VARCHAR(255) NOT NULL, local_ip VARCHAR(64) NULL, channel INT NOT NULL DEFAULT 0, + fallback_mode VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL', binding_source VARCHAR(64) NOT NULL DEFAULT 'MANUAL', approved_by INT NULL, approved_at DATETIME NULL, @@ -123,81 +124,47 @@ class edge_gateway_schema_bootstrap INDEX idx_edge_gateway_command_type (command_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", - "CREATE TABLE IF NOT EXISTS edge_gateway_update_jobs ( + "CREATE TABLE IF NOT EXISTS edge_gateway_operations ( id INT AUTO_INCREMENT PRIMARY KEY, gateway_id INT NOT NULL, - command_job_id INT NULL, - target_version VARCHAR(64) NOT NULL, - release_channel VARCHAR(32) NOT NULL DEFAULT 'stable', + type VARCHAR(32) NOT NULL, + operation_type VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY', status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + request_json JSON NULL, + summary_json JSON NULL, + result_json JSON NULL, + error_code VARCHAR(128) NULL, + error_message TEXT NULL, + correlation_id VARCHAR(128) NOT NULL, + agent_instance_id VARCHAR(128) NULL, + lease_expires_at DATETIME NULL, + last_progress_at DATETIME NULL, + attempt_count INT NOT NULL DEFAULT 0, requested_by INT NULL, requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, started_at DATETIME NULL, completed_at DATETIME NULL, - delivery_json JSON NULL, - result_json JSON NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL DEFAULT NULL, - INDEX idx_edge_gateway_update_gateway (gateway_id), - INDEX idx_edge_gateway_update_command (command_job_id), - INDEX idx_edge_gateway_update_status (status) + UNIQUE KEY uniq_edge_gateway_operation_correlation (correlation_id), + INDEX idx_edge_gateway_operations_gateway (gateway_id), + INDEX idx_edge_gateway_operations_status (status), + INDEX idx_edge_gateway_operations_type (type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", - "CREATE TABLE IF NOT EXISTS edge_gateway_shell_sessions ( + "CREATE TABLE IF NOT EXISTS edge_gateway_operation_events ( id INT AUTO_INCREMENT PRIMARY KEY, + operation_id INT NOT NULL, gateway_id INT NOT NULL, - reason TEXT NOT NULL, - approval_status VARCHAR(32) NOT NULL DEFAULT 'APPROVED', - session_token_hash VARCHAR(255) NOT NULL, - requested_by INT NULL, - approved_by INT NULL, - approved_at DATETIME NULL, - expires_at DATETIME NOT NULL, - opened_at DATETIME NULL, - closed_at DATETIME NULL, - transcript_text LONGTEXT NULL, - metadata_json JSON NULL, + level VARCHAR(16) NOT NULL DEFAULT 'INFO', + code VARCHAR(128) NULL, + message TEXT NOT NULL, + context_json JSON NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL DEFAULT NULL, - INDEX idx_edge_gateway_shell_gateway (gateway_id), - INDEX idx_edge_gateway_shell_expires (expires_at), - INDEX idx_edge_gateway_shell_status (approval_status) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", - - "CREATE TABLE IF NOT EXISTS edge_gateway_shell_action_jobs ( - id INT AUTO_INCREMENT PRIMARY KEY, - gateway_id INT NOT NULL, - session_id INT NOT NULL, - action_type VARCHAR(32) NOT NULL, - status VARCHAR(32) NOT NULL DEFAULT 'PENDING', - payload_json JSON NULL, - delivery_json JSON NULL, - requested_by INT NULL, - requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - completed_at DATETIME NULL, - error_message TEXT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL DEFAULT NULL, - INDEX idx_edge_gateway_shell_action_gateway (gateway_id), - INDEX idx_edge_gateway_shell_action_session (session_id), - INDEX idx_edge_gateway_shell_action_status (status), - INDEX idx_edge_gateway_shell_action_type (action_type) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", - - "CREATE TABLE IF NOT EXISTS edge_gateway_shell_events ( - id INT AUTO_INCREMENT PRIMARY KEY, - gateway_id INT NOT NULL, - session_id INT NOT NULL, - event_type VARCHAR(32) NOT NULL, - payload_json JSON NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL DEFAULT NULL, - INDEX idx_edge_gateway_shell_event_gateway (gateway_id), - INDEX idx_edge_gateway_shell_event_session (session_id), - INDEX idx_edge_gateway_shell_event_type (event_type) + INDEX idx_edge_gateway_operation_events_operation (operation_id), + INDEX idx_edge_gateway_operation_events_gateway (gateway_id), + INDEX idx_edge_gateway_operation_events_level (level) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", "CREATE TABLE IF NOT EXISTS edge_gateway_audit_logs ( @@ -220,38 +187,58 @@ class edge_gateway_schema_bootstrap $db->query($sql); } - if (!self::tableHasColumn('edge_gateway_update_jobs', 'command_job_id')) { - $db->query( - "ALTER TABLE edge_gateway_update_jobs - ADD COLUMN command_job_id INT NULL AFTER gateway_id, - ADD INDEX idx_edge_gateway_update_command (command_job_id)" - ); - } + self::ensureColumn('edge_gateway_command_jobs', 'delivery_json', 'JSON NULL AFTER response_json'); - if (!self::tableHasColumn('edge_gateway_command_jobs', 'delivery_json')) { - $db->query( - "ALTER TABLE edge_gateway_command_jobs - ADD COLUMN delivery_json JSON NULL AFTER response_json" - ); - } + self::ensureColumn('edge_gateway_relay_bindings', 'fallback_mode', "VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL' AFTER channel"); - if (!self::tableHasColumn('edge_gateway_shell_action_jobs', 'delivery_json')) { - $db->query( - "ALTER TABLE edge_gateway_shell_action_jobs - ADD COLUMN delivery_json JSON NULL AFTER payload_json" - ); - } + self::ensureColumn('edge_gateway_operations', 'type', "VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER gateway_id"); + self::ensureColumn('edge_gateway_operations', 'operation_type', "VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER type"); + self::ensureColumn('edge_gateway_operations', 'summary_json', 'JSON NULL AFTER request_json'); + self::ensureColumn('edge_gateway_operations', 'result_json', 'JSON NULL AFTER summary_json'); + self::ensureColumn('edge_gateway_operations', 'error_code', 'VARCHAR(128) NULL AFTER result_json'); + self::ensureColumn('edge_gateway_operations', 'error_message', 'TEXT NULL AFTER error_code'); + self::ensureColumn('edge_gateway_operations', 'correlation_id', 'VARCHAR(128) NULL AFTER error_message'); + self::ensureColumn('edge_gateway_operations', 'agent_instance_id', 'VARCHAR(128) NULL AFTER correlation_id'); + self::ensureColumn('edge_gateway_operations', 'lease_expires_at', 'DATETIME NULL AFTER agent_instance_id'); + self::ensureColumn('edge_gateway_operations', 'last_progress_at', 'DATETIME NULL AFTER lease_expires_at'); + self::ensureColumn('edge_gateway_operations', 'attempt_count', 'INT NOT NULL DEFAULT 0 AFTER last_progress_at'); + self::ensureColumn('edge_gateway_operations', 'requested_by', 'INT NULL AFTER attempt_count'); + self::ensureColumn('edge_gateway_operations', 'requested_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER requested_by'); + self::ensureColumn('edge_gateway_operations', 'started_at', 'DATETIME NULL AFTER requested_at'); + self::ensureColumn('edge_gateway_operations', 'completed_at', 'DATETIME NULL AFTER started_at'); + self::ensureColumn('edge_gateway_operations', 'updated_at', 'TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP AFTER created_at'); + self::ensureColumn('edge_gateway_operations', 'deleted_at', 'TIMESTAMP NULL DEFAULT NULL AFTER updated_at'); - if (!self::tableHasColumn('edge_gateway_update_jobs', 'delivery_json')) { - $db->query( - "ALTER TABLE edge_gateway_update_jobs - ADD COLUMN delivery_json JSON NULL AFTER completed_at" - ); - } + self::ensureColumn('edge_gateway_operation_events', 'code', 'VARCHAR(128) NULL AFTER level'); + self::ensureColumn('edge_gateway_operation_events', 'context_json', 'JSON NULL AFTER message'); + + self::ensureColumn('edge_gateway_audit_logs', 'actor_type', "VARCHAR(32) NOT NULL DEFAULT 'USER' AFTER actor_user_id"); + self::ensureColumn('edge_gateway_audit_logs', 'severity', "VARCHAR(16) NOT NULL DEFAULT 'INFO' AFTER actor_type"); + self::ensureColumn('edge_gateway_audit_logs', 'context_json', 'JSON NULL AFTER severity'); + + self::syncOperationTypeColumns(); self::$initialized = true; } + private static function ensureColumn(string $table, string $column, string $definition): void + { + global $db; + + if (self::tableHasColumn($table, $column)) { + return; + } + + if (!preg_match('/^[A-Za-z0-9_]+$/', $table) || !preg_match('/^[A-Za-z0-9_]+$/', $column)) { + throw new \RuntimeException('Invalid schema bootstrap identifier'); + } + + $db->query( + "ALTER TABLE `$table` + ADD COLUMN `$column` $definition" + ); + } + private static function tableHasColumn(string $table, string $column): bool { global $db; @@ -275,4 +262,30 @@ class edge_gateway_schema_bootstrap $row = $result->fetch_assoc(); return ((int)($row['c'] ?? 0)) > 0; } + + private static function syncOperationTypeColumns(): void + { + global $db; + + if (!self::tableHasColumn('edge_gateway_operations', 'type') + || !self::tableHasColumn('edge_gateway_operations', 'operation_type')) { + return; + } + + $db->query( + "UPDATE edge_gateway_operations + SET type = operation_type + WHERE operation_type IS NOT NULL + AND operation_type <> '' + AND (type IS NULL OR type = '' OR type <> operation_type)" + ); + + $db->query( + "UPDATE edge_gateway_operations + SET operation_type = type + WHERE type IS NOT NULL + AND type <> '' + AND (operation_type IS NULL OR operation_type = '')" + ); + } } diff --git a/services/nginx/app/classes/edge_gateway_view_service.php b/services/nginx/app/classes/edge_gateway_view_service.php new file mode 100644 index 00000000..a7c02b0c --- /dev/null +++ b/services/nginx/app/classes/edge_gateway_view_service.php @@ -0,0 +1,45 @@ +> + * @throws Exception + */ + public function listGateways(?int $departmentId = null, bool $includeDetail = true): array + { + return $this->manager()->listGateways($departmentId, $includeDetail); + } + + /** + * @param array> $gateways + * @return array + * @throws Exception + */ + public function buildFleetUsageStatistics(?int $departmentId = null, array $gateways = []): array + { + return $this->manager()->buildFleetUsageStatistics($departmentId, $gateways); + } + + /** + * @throws Exception + */ + public function getGateway(int $gatewayId): array + { + return $this->manager()->getGateway($gatewayId); + } + + private function manager(): edge_gateway_manager + { + return $this->manager ?? new edge_gateway_manager(); + } +} diff --git a/services/nginx/app/classes/email.php b/services/nginx/app/classes/email.php index a67d8d6a..53e1e0b1 100644 --- a/services/nginx/app/classes/email.php +++ b/services/nginx/app/classes/email.php @@ -39,6 +39,8 @@ use Psr\Http\Client\ClientExceptionInterface; #[AllowDynamicProperties] class email implements email_i { + public static array $fake_deliveries = []; + /** * Configuration for the email service * @var email_c @@ -125,6 +127,17 @@ use Psr\Http\Client\ClientExceptionInterface; */ private function sendEmailMailerSend(string $to, string $recipient_name, string $subject, string $message, string $html = null, string $references = null, array $attachments = []): void { + if (self::isFakeDeliveryEnabled()) { + self::$fake_deliveries[] = [ + 'to' => $to, + 'recipient_name' => $recipient_name, + 'subject' => $subject, + 'message' => $message, + 'html' => $html, + ]; + return; + } + // Check if the email is blacklisted $blacklisted_emails = [ 'invoice.dk@freja.com', // TODO: Make this dynamic. @@ -209,6 +222,16 @@ use Psr\Http\Client\ClientExceptionInterface; $this->sendEmailMailerSend($to, $recipient_name, 'Betalingslink for bestilling #' . $order_id, '', $html); } + public static function resetFakeDeliveries(): void + { + self::$fake_deliveries = []; + } + + private static function isFakeDeliveryEnabled(): bool + { + return getenv('EMAIL_FAKE_MODE') === '1'; + } + /** * Send a booking confirmation email * @throws MailerSendException @@ -488,4 +511,4 @@ use Psr\Http\Client\ClientExceptionInterface; $this->attachments ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/classes/order_bookings_counts_cache.php b/services/nginx/app/classes/order_bookings_counts_cache.php new file mode 100644 index 00000000..dacdc693 --- /dev/null +++ b/services/nginx/app/classes/order_bookings_counts_cache.php @@ -0,0 +1,201 @@ + max(0, (int)$counts['past']), + 'current' => max(0, (int)$counts['current']), + 'future' => max(0, (int)$counts['future']), + ]; + } + + private static function normalizeValue(mixed $value): mixed + { + if (!is_array($value)) { + return self::normalizeScalar($value); + } + + $normalized = array_map([self::class, 'normalizeValue'], $value); + + if (array_is_list($normalized)) { + if (self::isScalarList($normalized)) { + sort($normalized); + } + return $normalized; + } + + ksort($normalized); + return $normalized; + } + + private static function normalizeScalar(mixed $value): mixed + { + if (!is_string($value)) { + return $value; + } + + if (preg_match('/^-?\d+$/', $value) === 1) { + return (int)$value; + } + + if (is_numeric($value)) { + return (float)$value; + } + + return $value; + } + + private static function isScalarList(array $value): bool + { + foreach ($value as $item) { + if (is_array($item) || is_object($item)) { + return false; + } + } + + return true; + } + + private static function clearPattern(string $pattern): void + { + try { + $client = self::redisClient(); + if ($client === null) { + return; + } + $client->clear_keys($pattern); + } catch (Throwable) { + // Cache invalidation must never break request flow. + } + } + + private static function redisSetEx(string $key, string $value, int $ttl): void + { + try { + $client = self::redisClient(); + if ($client === null) { + return; + } + $client->setEx($key, $value, $ttl); + } catch (Throwable) { + // Best-effort cache write. + } + } + + private static function redisGet(string $key): ?string + { + try { + $client = self::redisClient(); + if ($client === null) { + return null; + } + $value = $client->get($key); + return is_string($value) ? $value : null; + } catch (Throwable) { + return null; + } + } + + private static function redisClient(): ?object + { + if (self::$adapter !== null) { + return self::$adapter; + } + + try { + if (defined('redis')) { + $instance = constant('redis'); + if (is_object($instance)) { + return $instance; + } + } + + return (new redis())->connect(); + } catch (Throwable) { + return null; + } + } +} diff --git a/services/nginx/app/classes/order_bookings_list_cache.php b/services/nginx/app/classes/order_bookings_list_cache.php new file mode 100644 index 00000000..caa5986f --- /dev/null +++ b/services/nginx/app/classes/order_bookings_list_cache.php @@ -0,0 +1,190 @@ +clear_keys($pattern); + } catch (Throwable) { + // Cache invalidation must never break request flow. + } + } + + private static function redisSetEx(string $key, string $value, int $ttl): void + { + try { + $client = self::redisClient(); + if ($client === null) { + return; + } + $client->setEx($key, $value, $ttl); + } catch (Throwable) { + // Best-effort cache write. + } + } + + private static function redisGet(string $key): ?string + { + try { + $client = self::redisClient(); + if ($client === null) { + return null; + } + $value = $client->get($key); + return is_string($value) ? $value : null; + } catch (Throwable) { + return null; + } + } + + private static function redisClient(): ?object + { + if (self::$adapter !== null) { + return self::$adapter; + } + + try { + if (defined('redis')) { + $instance = constant('redis'); + if (is_object($instance)) { + return $instance; + } + } + + return (new redis())->connect(); + } catch (Throwable) { + return null; + } + } +} diff --git a/services/nginx/app/classes/response.php b/services/nginx/app/classes/response.php index fd87b3e8..c4ecbb91 100644 --- a/services/nginx/app/classes/response.php +++ b/services/nginx/app/classes/response.php @@ -89,6 +89,16 @@ class response implements response_i return $this->data; } + public function get_meta(): array + { + return $this->meta; + } + + public function get_includes(): array + { + return $this->includes; + } + #[NoReturn] public function not_found(): void { $this->error('Not found', 404); diff --git a/services/nginx/app/classes/stripe.php b/services/nginx/app/classes/stripe.php index 634360d4..1c8b360e 100644 --- a/services/nginx/app/classes/stripe.php +++ b/services/nginx/app/classes/stripe.php @@ -10,6 +10,7 @@ require_once WD . '/modules/stripe/endpoints/stripe_endpoint_prices.php'; require_once WD . '/modules/stripe/endpoints/stripe_endpoint_invoice.php'; require_once WD . '/modules/stripe/endpoints/stripe_endpoint_readers.php'; require_once WD . '/modules/stripe/endpoints/stripe_endpoint_payment_intents.php'; +require_once WD . '/classes/stripe_fake_http_client.php'; // Require all helper classes @@ -29,6 +30,7 @@ use stripe\endpoints\stripe_endpoint_prices; use stripe\endpoints\stripe_endpoint_product; use stripe\endpoints\stripe_endpoint_readers; use stripe\stripe_c; +use Stripe\ApiRequestor; use Stripe\StripeClient; /** @@ -104,6 +106,13 @@ class stripe implements stripe_i { // Get the stripe client if (!isset($this->client)) { + if (self::isFakeModeEnabled()) { + ApiRequestor::setHttpClient(new stripe_fake_http_client()); + $this->client = new StripeClient([ + 'api_key' => 'sk_test_fake' + ]); + return $this->client; + } // Require the module to be enabled self::requireModuleEnabled(); // Require the secret key to be set @@ -118,6 +127,11 @@ class stripe implements stripe_i return $this->client; } + private static function isFakeModeEnabled(): bool + { + return getenv('STRIPE_FAKE_MODE') === '1'; + } + /** * @inheritDoc * @throws Exception @@ -153,4 +167,4 @@ class stripe implements stripe_i throw new Exception('Invalid publishable key'); } } -} \ No newline at end of file +} diff --git a/services/nginx/app/classes/stripe_fake_http_client.php b/services/nginx/app/classes/stripe_fake_http_client.php new file mode 100644 index 00000000..183469da --- /dev/null +++ b/services/nginx/app/classes/stripe_fake_http_client.php @@ -0,0 +1,268 @@ + 1, + 'next_price' => 1, + 'next_invoice' => 1, + 'next_invoice_item' => 1, + 'customers' => [], + 'prices' => [], + 'products' => [], + 'invoice_items' => [], + 'invoices' => [], + ]; + + public static function resetStore(): void + { + self::writeStore(self::DEFAULT_STATE); + } + + public static function setInvoiceState(string $invoiceId, array $attributes): void + { + $store = self::readStore(); + $invoice = $store['invoices'][$invoiceId] ?? null; + if (!$invoice) { + return; + } + + $store['invoices'][$invoiceId] = [ + ...$invoice, + ...$attributes, + ]; + self::writeStore($store); + } + + public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1') + { + $path = (string)parse_url((string)$absUrl, PHP_URL_PATH); + $store = self::readStore(); + + if ($method === 'post' && $path === '/v1/customers') { + $customerId = sprintf('cus_fake_%06d', (int)$store['next_customer']); + $store['next_customer']++; + $customer = [ + 'id' => $customerId, + 'object' => 'customer', + 'email' => (string)($params['email'] ?? ''), + ]; + $store['customers'][$customerId] = $customer; + self::writeStore($store); + + return $this->jsonResponse($customer); + } + + if ($method === 'get' && preg_match('#^/v1/customers/(?P[^/]+)$#', $path, $matches)) { + $customer = $store['customers'][$matches['id']] ?? null; + if (!$customer) { + return $this->errorResponse(404, 'resource_missing', 'No such customer.'); + } + + return $this->jsonResponse($customer); + } + + if ($method === 'get' && preg_match('#^/v1/products/(?P[^/]+)$#', $path, $matches)) { + $product = $store['products'][$matches['id']] ?? null; + if (!$product) { + return $this->errorResponse(404, 'resource_missing', 'No such product.'); + } + + return $this->jsonResponse($product); + } + + if ($method === 'post' && $path === '/v1/products') { + $productId = (string)($params['id'] ?? sprintf('prod_fake_%06d', count($store['products']) + 1)); + $product = [ + 'id' => $productId, + 'object' => 'product', + 'name' => (string)($params['name'] ?? 'Fake Product'), + ]; + $store['products'][$productId] = $product; + self::writeStore($store); + + return $this->jsonResponse($product); + } + + if ($method === 'post' && $path === '/v1/prices') { + $priceId = sprintf('price_fake_%06d', (int)$store['next_price']); + $store['next_price']++; + $price = [ + 'id' => $priceId, + 'object' => 'price', + 'product' => (string)($params['product'] ?? ''), + 'unit_amount' => (int)($params['unit_amount'] ?? 0), + 'currency' => strtolower((string)($params['currency'] ?? 'dkk')), + ]; + $store['prices'][$priceId] = $price; + self::writeStore($store); + + return $this->jsonResponse($price); + } + + if ($method === 'post' && $path === '/v1/invoices') { + $invoiceId = sprintf('in_fake_%06d', (int)$store['next_invoice']); + $store['next_invoice']++; + $invoice = [ + 'id' => $invoiceId, + 'object' => 'invoice', + 'customer' => (string)($params['customer'] ?? ''), + 'status' => 'draft', + 'paid' => false, + 'amount_due' => 0, + 'amount_paid' => 0, + 'collection_method' => (string)($params['collection_method'] ?? 'send_invoice'), + 'hosted_invoice_url' => sprintf('https://stripe.test/invoices/%s', $invoiceId), + 'metadata' => is_array($params['metadata'] ?? null) ? $params['metadata'] : [], + 'lines' => [], + ]; + $store['invoices'][$invoiceId] = $invoice; + self::writeStore($store); + + return $this->jsonResponse($invoice); + } + + if ($method === 'post' && $path === '/v1/invoiceitems') { + $invoiceId = (string)($params['invoice'] ?? ''); + $invoice = $store['invoices'][$invoiceId] ?? null; + if (!$invoice) { + return $this->errorResponse(404, 'resource_missing', 'No such invoice.'); + } + + $invoiceItemId = sprintf('ii_fake_%06d', (int)$store['next_invoice_item']); + $store['next_invoice_item']++; + $priceId = (string)($params['price'] ?? ''); + $price = $store['prices'][$priceId] ?? ['unit_amount' => 0]; + $invoiceItem = [ + 'id' => $invoiceItemId, + 'object' => 'invoiceitem', + 'invoice' => $invoiceId, + 'customer' => (string)($params['customer'] ?? ''), + 'price' => $priceId, + 'amount' => (int)($price['unit_amount'] ?? 0), + ]; + + $store['invoice_items'][$invoiceItemId] = $invoiceItem; + $store['invoices'][$invoiceId]['lines'][] = $invoiceItemId; + $store['invoices'][$invoiceId]['amount_due'] += (int)($price['unit_amount'] ?? 0); + self::writeStore($store); + + return $this->jsonResponse($invoiceItem); + } + + if ($method === 'post' && preg_match('#^/v1/invoices/(?P[^/]+)/finalize$#', $path, $matches)) { + $invoiceId = $matches['id']; + $invoice = $store['invoices'][$invoiceId] ?? null; + if (!$invoice) { + return $this->errorResponse(404, 'resource_missing', 'No such invoice.'); + } + + $invoice['status'] = 'open'; + $store['invoices'][$invoiceId] = $invoice; + self::writeStore($store); + + return $this->jsonResponse($invoice); + } + + if ($method === 'post' && preg_match('#^/v1/invoices/(?P[^/]+)/void$#', $path, $matches)) { + $invoiceId = $matches['id']; + $invoice = $store['invoices'][$invoiceId] ?? null; + if (!$invoice) { + return $this->errorResponse(404, 'resource_missing', 'No such invoice.'); + } + + $invoice['status'] = 'void'; + $invoice['paid'] = false; + $store['invoices'][$invoiceId] = $invoice; + self::writeStore($store); + + return $this->jsonResponse($invoice); + } + + if ($method === 'get' && preg_match('#^/v1/invoices/(?P[^/]+)$#', $path, $matches)) { + $invoice = $store['invoices'][$matches['id']] ?? null; + if (!$invoice) { + return $this->errorResponse(404, 'resource_missing', 'No such invoice.'); + } + + return $this->jsonResponse($invoice); + } + + return $this->errorResponse(404, 'resource_missing', 'Unsupported fake Stripe request: ' . $method . ' ' . $path); + } + + private function jsonResponse(array $payload, int $status = 200): array + { + return [ + json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + $status, + [ + 'Content-Type' => 'application/json', + 'Request-Id' => 'req_fake_stripe', + ], + ]; + } + + private function errorResponse(int $status, string $code, string $message): array + { + return [ + json_encode([ + 'error' => [ + 'type' => 'invalid_request_error', + 'code' => $code, + 'message' => $message, + ], + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + $status, + [ + 'Content-Type' => 'application/json', + 'Request-Id' => 'req_fake_stripe_error', + ], + ]; + } + + private static function readStore(): array + { + $path = self::storePath(); + if (!is_file($path)) { + return self::DEFAULT_STATE; + } + + $json = file_get_contents($path); + if ($json === false || trim($json) === '') { + return self::DEFAULT_STATE; + } + + $decoded = json_decode($json, true); + if (!is_array($decoded)) { + return self::DEFAULT_STATE; + } + + return [ + ...self::DEFAULT_STATE, + ...$decoded, + ]; + } + + private static function writeStore(array $store): void + { + file_put_contents( + self::storePath(), + json_encode($store, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) + ); + } + + private static function storePath(): string + { + $path = trim((string)getenv('STRIPE_FAKE_STORE_PATH')); + if ($path !== '') { + return $path; + } + + return sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'truckwash-stripe-fake-store.json'; + } +} diff --git a/services/nginx/app/classes/workfeed_shift_time_resolver.php b/services/nginx/app/classes/workfeed_shift_time_resolver.php new file mode 100644 index 00000000..64983ff0 --- /dev/null +++ b/services/nginx/app/classes/workfeed_shift_time_resolver.php @@ -0,0 +1,258 @@ +getTimestamp() <= $actual_start->getTimestamp()) { + return null; + } + + return [ + 'actualStart' => $actual_start, + 'scheduledEnd' => $scheduled_end, + 'actualEnd' => $actual_end, + 'hasApproval' => self::hasShiftApproval($record), + ]; + } + + public static function calculateOvertimeHoursInRange(mixed $record_value, DateTime $range_start, DateTime $range_end_exclusive): float + { + $timing = self::resolveShiftTiming($record_value); + if ($timing === null) { + return 0.0; + } + + $scheduled_end_ts = $timing['scheduledEnd']->getTimestamp(); + $actual_end_ts = $timing['actualEnd']->getTimestamp(); + if ($actual_end_ts <= $scheduled_end_ts) { + return 0.0; + } + + $overtime_start_ts = max($scheduled_end_ts, $range_start->getTimestamp()); + $overtime_end_ts = min($actual_end_ts, $range_end_exclusive->getTimestamp()); + if ($overtime_end_ts <= $overtime_start_ts) { + return 0.0; + } + + return round(($overtime_end_ts - $overtime_start_ts) / 3600, 2); + } + + /** + * @return array + */ + private static function normalizeRecord(mixed $record): array + { + if (is_array($record)) { + return $record; + } + if (is_object($record)) { + return get_object_vars($record); + } + + return []; + } + + private static function getNestedRecordValue(array $record, string $path): mixed + { + $segments = explode('.', $path); + $current = $record; + + foreach ($segments as $segment) { + if (is_array($current)) { + if (!array_key_exists($segment, $current)) { + return null; + } + $current = $current[$segment]; + continue; + } + + if (is_object($current)) { + if (!property_exists($current, $segment)) { + return null; + } + $current = $current->$segment; + continue; + } + + return null; + } + + return $current; + } + + /** + * @param array $paths + */ + private static function firstDateTimeFromPaths(array $record, array $paths): ?DateTime + { + foreach ($paths as $path) { + $parsed = self::parseDateTimeValue(self::getNestedRecordValue($record, $path)); + if ($parsed !== null) { + return $parsed; + } + } + + return null; + } + + private static function parseDateTimeValue(mixed $value): ?DateTime + { + if (is_string($value)) { + $normalized = trim($value); + if ($normalized === '') { + return null; + } + + try { + return new DateTime($normalized); + } catch (Exception) { + if (!is_numeric($normalized)) { + return null; + } + $value = (float)$normalized; + } + } + + if (is_int($value) || is_float($value)) { + if (!is_finite((float)$value)) { + return null; + } + + $timestamp = (float)$value; + if ($timestamp > 9999999999) { + $timestamp /= 1000; + } + + try { + $date = new DateTime('@' . (string)(int)round($timestamp)); + $date->setTimezone(new DateTimeZone('UTC')); + return $date; + } catch (Exception) { + return null; + } + } + + $record = self::normalizeRecord($value); + foreach (['seconds', '_seconds', 'epochSeconds', 'timestamp'] as $key) { + if (!array_key_exists($key, $record)) { + continue; + } + + $parsed = self::parseDateTimeValue($record[$key]); + if ($parsed !== null) { + return $parsed; + } + } + + return null; + } + + private static function hasShiftApproval(array $record): bool + { + if (!array_key_exists('approval', $record)) { + return false; + } + + $approval = $record['approval']; + if ($approval === null) { + return false; + } + if (is_array($approval)) { + return $approval !== []; + } + if (is_object($approval)) { + return get_object_vars($approval) !== []; + } + + return true; + } + + private static function resolveActualEnd( + array $record, + DateTime $actual_start, + DateTime $scheduled_end, + DateTime $saved_actual_end, + ?DateTime $actual_only_end + ): DateTime { + if (self::hasShiftApproval($record) || $actual_only_end !== null) { + return $saved_actual_end; + } + + $update_time = self::parseDateTimeValue($record['updateTime'] ?? null); + if ($update_time === null) { + return $saved_actual_end; + } + + $shift_start_ts = $actual_start->getTimestamp(); + $scheduled_end_ts = $scheduled_end->getTimestamp(); + $saved_actual_end_ts = $saved_actual_end->getTimestamp(); + $update_ts = $update_time->getTimestamp(); + $fallback_base_ts = max($saved_actual_end_ts, $scheduled_end_ts); + + if ($update_ts <= $fallback_base_ts) { + return $saved_actual_end; + } + + // Only use updateTime as an overtime hint when no explicit actual end was saved. + if (($update_ts - $scheduled_end_ts) > self::MAX_UNAPPROVED_EXTENSION_SECONDS) { + return $saved_actual_end; + } + if (($update_ts - $shift_start_ts) > self::MAX_UNAPPROVED_SHIFT_SPAN_SECONDS) { + return $saved_actual_end; + } + + return $update_time; + } +} diff --git a/services/nginx/app/modules/economic/config/economic_transaction_draft_customer_number_c.php b/services/nginx/app/modules/economic/config/economic_transaction_draft_customer_number_c.php new file mode 100644 index 00000000..4f204c74 --- /dev/null +++ b/services/nginx/app/modules/economic/config/economic_transaction_draft_customer_number_c.php @@ -0,0 +1,28 @@ +invoice_layout = new economic_invoice_layout_c(); $this->payment_terms = new economic_payment_terms_c(); $this->admin_fee_monthly = new economic_admin_fee_monthly_c(); $this->admin_fee_order = new economic_admin_fee_order_c(); $this->fee_product_id = new economic_fee_product_id_c(); + $this->transaction_draft_customer_number = new economic_transaction_draft_customer_number_c(); } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/stripe/endpoints/stripe_endpoint_invoice.php b/services/nginx/app/modules/stripe/endpoints/stripe_endpoint_invoice.php index 6b914a21..7040693c 100644 --- a/services/nginx/app/modules/stripe/endpoints/stripe_endpoint_invoice.php +++ b/services/nginx/app/modules/stripe/endpoints/stripe_endpoint_invoice.php @@ -26,6 +26,14 @@ class stripe_endpoint_invoice return self::getClient()->invoices->retrieve($id); } + /** + * @throws ApiErrorException + */ + public function void(string $id): Invoice + { + return self::getClient()->invoices->voidInvoice($id); + } + /** * @throws ApiErrorException * @throws Exception @@ -53,13 +61,47 @@ class stripe_endpoint_invoice return $this->create( $line_items, $stripe_customer_id, - [ - 'order_id' => $order_id, - 'customer_id' => $stripe_customer_id - ] + $this->buildOrderMetadata($order, $stripe_customer_id) ); } + private function buildOrderMetadata(orders_o $order, string $stripe_customer_id): array + { + $metadata = [ + 'order_id' => (string)$order->id, + 'customer_id' => (string)$order->customer_id->value(), + 'stripe_customer_id' => $stripe_customer_id, + 'department_id' => (string)$order->department_id->value(), + ]; + + $reference = trim((string)$order->reference->value()); + if ($reference !== '') { + $metadata['reference'] = $reference; + } + + $po = trim((string)$order->po->value()); + if ($po !== '') { + $metadata['po'] = $po; + } + + $reg1 = trim((string)$order->reg_1->value()); + if ($reg1 !== '') { + $metadata['reg_1'] = $reg1; + } + + $reg2 = trim((string)$order->reg_2->value()); + if ($reg2 !== '') { + $metadata['reg_2'] = $reg2; + } + + $reg3 = trim((string)$order->reg_3->value()); + if ($reg3 !== '') { + $metadata['reg_3'] = $reg3; + } + + return $metadata; + } + /** * Create a new payment link * @param stripe_line_items $line_items @@ -96,4 +138,4 @@ class stripe_endpoint_invoice return $invoice->finalizeInvoice(); } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/collected_order_invoices_o.php b/services/nginx/app/objects/collected_order_invoices_o.php index 758b5ef1..f9f88707 100644 --- a/services/nginx/app/objects/collected_order_invoices_o.php +++ b/services/nginx/app/objects/collected_order_invoices_o.php @@ -426,6 +426,7 @@ class collected_order_invoices_o extends db if (empty($this->customer_number->value())) { throw new Exception('Customer number is not set'); } + (new economic())->assertCustomerNumberIsNotDraft((int)$this->customer_number->value()); if (!$ignore_closed) { // Require the invoice collection to be open self::requireOpen(); @@ -479,6 +480,7 @@ class collected_order_invoices_o extends db { // Require the invoice collection to be selected self::requireSelected(); + (new economic())->assertCustomerNumberIsNotDraft((int)$this->customer_number->value()); // Check if the invoice draft already exists if (self::isDraftExisting() || self::isBooked()) { throw new Exception('Invoice draft already exists, or invoice collection is already booked'); @@ -1487,4 +1489,4 @@ class collected_order_invoices_o extends db { $this->objectChanged(); } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/edge_gateway_operation_events_o.php b/services/nginx/app/objects/edge_gateway_operation_events_o.php new file mode 100644 index 00000000..e1a684b8 --- /dev/null +++ b/services/nginx/app/objects/edge_gateway_operation_events_o.php @@ -0,0 +1,58 @@ +setTable('edge_gateway_operation_events'); + } + + public function getObjectProperties(): void + { + $this->operation_id = new object_property($this->table, $this->id, 'operation_id', 'int', false); + $this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false); + $this->level = new object_property($this->table, $this->id, 'level', 'string', false); + $this->code = new object_property($this->table, $this->id, 'code', 'string', false); + $this->message = new object_property($this->table, $this->id, 'message', 'text', false); + $this->context_json = new object_property($this->table, $this->id, 'context_json', 'json', false); + $this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false); + } + + public function objectChanged(): void + { + } + + public function asArray(): array + { + $this->requireSelected(); + + return [ + 'id' => (int)$this->id, + 'operation_id' => (int)$this->operation_id->value(), + 'gateway_id' => (int)$this->gateway_id->value(), + 'level' => (string)$this->level->value(), + 'code' => $this->code->value() === null ? null : (string)$this->code->value(), + 'message' => (string)$this->message->value(), + 'context' => (array)($this->context_json->value() ?? []), + 'created_at' => (string)$this->created_at->value(), + ]; + } +} diff --git a/services/nginx/app/objects/edge_gateway_update_jobs_o.php b/services/nginx/app/objects/edge_gateway_operations_o.php similarity index 50% rename from services/nginx/app/objects/edge_gateway_update_jobs_o.php rename to services/nginx/app/objects/edge_gateway_operations_o.php index eb99ea9a..15295868 100644 --- a/services/nginx/app/objects/edge_gateway_update_jobs_o.php +++ b/services/nginx/app/objects/edge_gateway_operations_o.php @@ -7,21 +7,28 @@ use classes\edge_gateway_schema_bootstrap; use classes\object_property; use traits\db_object_t; -class edge_gateway_update_jobs_o extends db +class edge_gateway_operations_o extends db { use db_object_t; public object_property $gateway_id; - public object_property $command_job_id; - public object_property $target_version; - public object_property $release_channel; + public object_property $type; + public object_property $operation_type; public object_property $status; + public object_property $request_json; + public object_property $summary_json; + public object_property $result_json; + public object_property $error_code; + public object_property $error_message; + public object_property $correlation_id; + public object_property $agent_instance_id; + public object_property $lease_expires_at; + public object_property $last_progress_at; + public object_property $attempt_count; public object_property $requested_by; public object_property $requested_at; public object_property $started_at; public object_property $completed_at; - public object_property $delivery_json; - public object_property $result_json; public object_property $created_at; public object_property $updated_at; public object_property $deleted_at; @@ -29,22 +36,29 @@ class edge_gateway_update_jobs_o extends db public function structure(): void { edge_gateway_schema_bootstrap::ensureTables(); - $this->setTable('edge_gateway_update_jobs'); + $this->setTable('edge_gateway_operations'); } public function getObjectProperties(): void { $this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false); - $this->command_job_id = new object_property($this->table, $this->id, 'command_job_id', 'int', false); - $this->target_version = new object_property($this->table, $this->id, 'target_version', 'string', false); - $this->release_channel = new object_property($this->table, $this->id, 'release_channel', 'string', false); + $this->type = new object_property($this->table, $this->id, 'type', 'string', false); + $this->operation_type = new object_property($this->table, $this->id, 'operation_type', 'string', false); $this->status = new object_property($this->table, $this->id, 'status', 'string', false); + $this->request_json = new object_property($this->table, $this->id, 'request_json', 'json', false); + $this->summary_json = new object_property($this->table, $this->id, 'summary_json', 'json', false); + $this->result_json = new object_property($this->table, $this->id, 'result_json', 'json', false); + $this->error_code = new object_property($this->table, $this->id, 'error_code', 'string', false); + $this->error_message = new object_property($this->table, $this->id, 'error_message', 'text', false); + $this->correlation_id = new object_property($this->table, $this->id, 'correlation_id', 'string', false); + $this->agent_instance_id = new object_property($this->table, $this->id, 'agent_instance_id', 'string', false); + $this->lease_expires_at = new object_property($this->table, $this->id, 'lease_expires_at', 'string', false); + $this->last_progress_at = new object_property($this->table, $this->id, 'last_progress_at', 'string', false); + $this->attempt_count = new object_property($this->table, $this->id, 'attempt_count', 'int', false); $this->requested_by = new object_property($this->table, $this->id, 'requested_by', 'int', false); $this->requested_at = new object_property($this->table, $this->id, 'requested_at', 'string', false); $this->started_at = new object_property($this->table, $this->id, 'started_at', 'string', false); $this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'string', false); - $this->delivery_json = new object_property($this->table, $this->id, 'delivery_json', 'json', false); - $this->result_json = new object_property($this->table, $this->id, 'result_json', 'json', false); $this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false); $this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false); $this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false); @@ -61,16 +75,22 @@ class edge_gateway_update_jobs_o extends db return [ 'id' => (int)$this->id, 'gateway_id' => (int)$this->gateway_id->value(), - 'command_job_id' => $this->command_job_id->value() === null ? null : (int)$this->command_job_id->value(), - 'target_version' => (string)$this->target_version->value(), - 'release_channel' => (string)$this->release_channel->value(), + 'type' => (string)($this->type->value() ?? $this->operation_type->value() ?? ''), 'status' => (string)$this->status->value(), + 'request' => (array)($this->request_json->value() ?? []), + 'summary' => (array)($this->summary_json->value() ?? []), + 'result' => (array)($this->result_json->value() ?? []), + 'error_code' => $this->error_code->value() === null ? null : (string)$this->error_code->value(), + 'error_message' => $this->error_message->value() === null ? null : (string)$this->error_message->value(), + 'correlation_id' => (string)$this->correlation_id->value(), + 'agent_instance_id' => $this->agent_instance_id->value() === null ? null : (string)$this->agent_instance_id->value(), + 'lease_expires_at' => $this->lease_expires_at->value() === null ? null : (string)$this->lease_expires_at->value(), + 'last_progress_at' => $this->last_progress_at->value() === null ? null : (string)$this->last_progress_at->value(), + 'attempt_count' => (int)($this->attempt_count->value() ?? 0), 'requested_by' => $this->requested_by->value() === null ? null : (int)$this->requested_by->value(), 'requested_at' => (string)$this->requested_at->value(), 'started_at' => $this->started_at->value() === null ? null : (string)$this->started_at->value(), 'completed_at' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(), - 'delivery' => (array)($this->delivery_json->value() ?? []), - 'result' => (array)($this->result_json->value() ?? []), 'created_at' => (string)$this->created_at->value(), 'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(), ]; diff --git a/services/nginx/app/objects/edge_gateway_shell_action_jobs_o.php b/services/nginx/app/objects/edge_gateway_shell_action_jobs_o.php deleted file mode 100644 index 0f6ef8c5..00000000 --- a/services/nginx/app/objects/edge_gateway_shell_action_jobs_o.php +++ /dev/null @@ -1,75 +0,0 @@ -setTable('edge_gateway_shell_action_jobs'); - } - - public function getObjectProperties(): void - { - $this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false); - $this->session_id = new object_property($this->table, $this->id, 'session_id', 'int', false); - $this->action_type = new object_property($this->table, $this->id, 'action_type', 'string', false); - $this->status = new object_property($this->table, $this->id, 'status', 'string', false); - $this->payload_json = new object_property($this->table, $this->id, 'payload_json', 'json', false); - $this->delivery_json = new object_property($this->table, $this->id, 'delivery_json', 'json', false); - $this->requested_by = new object_property($this->table, $this->id, 'requested_by', 'int', false); - $this->requested_at = new object_property($this->table, $this->id, 'requested_at', 'string', false); - $this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'string', false); - $this->error_message = new object_property($this->table, $this->id, 'error_message', 'text', false); - $this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false); - $this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false); - $this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false); - } - - public function objectChanged(): void - { - } - - public function asArray(): array - { - $this->requireSelected(); - - return [ - 'id' => (int)$this->id, - 'gateway_id' => (int)$this->gateway_id->value(), - 'session_id' => (int)$this->session_id->value(), - 'action_type' => (string)$this->action_type->value(), - 'status' => (string)$this->status->value(), - 'payload' => (array)($this->payload_json->value() ?? []), - 'delivery' => (array)($this->delivery_json->value() ?? []), - 'requested_by' => $this->requested_by->value() === null ? null : (int)$this->requested_by->value(), - 'requested_at' => (string)$this->requested_at->value(), - 'completed_at' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(), - 'error_message' => $this->error_message->value() === null ? null : (string)$this->error_message->value(), - 'created_at' => (string)$this->created_at->value(), - 'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(), - ]; - } -} diff --git a/services/nginx/app/objects/edge_gateway_shell_events_o.php b/services/nginx/app/objects/edge_gateway_shell_events_o.php deleted file mode 100644 index e1b6d7f9..00000000 --- a/services/nginx/app/objects/edge_gateway_shell_events_o.php +++ /dev/null @@ -1,54 +0,0 @@ -setTable('edge_gateway_shell_events'); - } - - public function getObjectProperties(): void - { - $this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false); - $this->session_id = new object_property($this->table, $this->id, 'session_id', 'int', false); - $this->event_type = new object_property($this->table, $this->id, 'event_type', 'string', false); - $this->payload_json = new object_property($this->table, $this->id, 'payload_json', 'json', false); - $this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false); - $this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false); - } - - public function objectChanged(): void - { - } - - public function asArray(): array - { - $this->requireSelected(); - - return [ - 'id' => (int)$this->id, - 'gateway_id' => (int)$this->gateway_id->value(), - 'session_id' => (int)$this->session_id->value(), - 'event_type' => (string)$this->event_type->value(), - 'payload' => (array)($this->payload_json->value() ?? []), - 'created_at' => (string)$this->created_at->value(), - ]; - } -} diff --git a/services/nginx/app/objects/edge_gateway_shell_sessions_o.php b/services/nginx/app/objects/edge_gateway_shell_sessions_o.php deleted file mode 100644 index 7080e2a6..00000000 --- a/services/nginx/app/objects/edge_gateway_shell_sessions_o.php +++ /dev/null @@ -1,80 +0,0 @@ -setTable('edge_gateway_shell_sessions'); - } - - public function getObjectProperties(): void - { - $this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false); - $this->reason = new object_property($this->table, $this->id, 'reason', 'text', false); - $this->approval_status = new object_property($this->table, $this->id, 'approval_status', 'string', false); - $this->session_token_hash = new object_property($this->table, $this->id, 'session_token_hash', 'string', false); - $this->requested_by = new object_property($this->table, $this->id, 'requested_by', 'int', false); - $this->approved_by = new object_property($this->table, $this->id, 'approved_by', 'int', false); - $this->approved_at = new object_property($this->table, $this->id, 'approved_at', 'string', false); - $this->expires_at = new object_property($this->table, $this->id, 'expires_at', 'string', false); - $this->opened_at = new object_property($this->table, $this->id, 'opened_at', 'string', false); - $this->closed_at = new object_property($this->table, $this->id, 'closed_at', 'string', false); - $this->transcript_text = new object_property($this->table, $this->id, 'transcript_text', 'text', false); - $this->metadata_json = new object_property($this->table, $this->id, 'metadata_json', 'json', false); - $this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false); - $this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false); - $this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false); - } - - public function objectChanged(): void - { - } - - public function asArray(): array - { - $this->requireSelected(); - - return [ - 'id' => (int)$this->id, - 'gateway_id' => (int)$this->gateway_id->value(), - 'reason' => (string)$this->reason->value(), - 'approval_status' => (string)$this->approval_status->value(), - 'requested_by' => $this->requested_by->value() === null ? null : (int)$this->requested_by->value(), - 'approved_by' => $this->approved_by->value() === null ? null : (int)$this->approved_by->value(), - 'approved_at' => $this->approved_at->value() === null ? null : (string)$this->approved_at->value(), - 'expires_at' => (string)$this->expires_at->value(), - 'opened_at' => $this->opened_at->value() === null ? null : (string)$this->opened_at->value(), - 'closed_at' => $this->closed_at->value() === null ? null : (string)$this->closed_at->value(), - 'transcript_text' => $this->transcript_text->value() === null ? null : (string)$this->transcript_text->value(), - 'metadata' => (array)($this->metadata_json->value() ?? []), - 'created_at' => (string)$this->created_at->value(), - 'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(), - ]; - } -} diff --git a/services/nginx/app/objects/order_bookings_o.php b/services/nginx/app/objects/order_bookings_o.php index 875c7be1..b5a15ec7 100644 --- a/services/nginx/app/objects/order_bookings_o.php +++ b/services/nginx/app/objects/order_bookings_o.php @@ -7,6 +7,8 @@ use classes\db; use classes\email; use classes\gatewayapi; use classes\object_property; +use classes\order_bookings_counts_cache; +use classes\order_bookings_list_cache; use classes\pdf_generator; use classes\slack; use Exception; @@ -290,7 +292,12 @@ class order_bookings_o extends db public function objectChanged(): void { - //TODO: Add cache invalidation + try { + order_bookings_list_cache::clearAll(); + order_bookings_counts_cache::clearAll(); + } catch (\Throwable) { + // Cache invalidation must never break order-booking writes. + } } @@ -569,4 +576,68 @@ class order_bookings_o extends db return count($order_ids); } + /** + * @param array|null $departmentIds + * @return array{past:int,current:int,future:int} + */ + public function getPendingBookingCounts(?array $departmentIds = null, ?int $customerNumber = null, ?\DateTimeImmutable $reference = null): array + { + global /** @var db $db */ + $db; + + $normalizedDepartmentIds = []; + if (is_array($departmentIds)) { + foreach ($departmentIds as $departmentId) { + $normalizedDepartmentId = (int)$departmentId; + if ($normalizedDepartmentId > 0) { + $normalizedDepartmentIds[] = $normalizedDepartmentId; + } + } + $normalizedDepartmentIds = array_values(array_unique($normalizedDepartmentIds)); + if ($normalizedDepartmentIds === []) { + return [ + 'past' => 0, + 'current' => 0, + 'future' => 0, + ]; + } + } + + $now = $reference ?? new \DateTimeImmutable('now'); + $todayStart = $now->setTime(0, 0, 0); + $todayEnd = $now->setTime(23, 59, 59); + + $todayStartSql = $db->escape_string($todayStart->format('Y-m-d H:i:s')); + $todayEndSql = $db->escape_string($todayEnd->format('Y-m-d H:i:s')); + + $whereClauses = [ + '`deleted_at` IS NULL', + "(`order_id` IS NULL OR `order_id` = 0 OR TRIM(CAST(`order_id` AS CHAR)) = '')", + ]; + + if ($normalizedDepartmentIds !== []) { + $whereClauses[] = '`department` IN (' . implode(', ', array_map('intval', $normalizedDepartmentIds)) . ')'; + } + + if ($customerNumber !== null && $customerNumber > 0) { + $whereClauses[] = '`customer_number` = ' . (int)$customerNumber; + } + + $sql = "SELECT + COALESCE(SUM(CASE WHEN `datetime` < '{$todayStartSql}' THEN 1 ELSE 0 END), 0) AS `past`, + COALESCE(SUM(CASE WHEN `datetime` >= '{$todayStartSql}' AND `datetime` <= '{$todayEndSql}' THEN 1 ELSE 0 END), 0) AS `current`, + COALESCE(SUM(CASE WHEN `datetime` > '{$todayEndSql}' THEN 1 ELSE 0 END), 0) AS `future` + FROM `order_bookings` + WHERE " . implode(' AND ', $whereClauses); + + $result = $db->query($sql); + $row = $db->fetch_assoc($result); + + return [ + 'past' => max(0, (int)($row['past'] ?? 0)), + 'current' => max(0, (int)($row['current'] ?? 0)), + 'future' => max(0, (int)($row['future'] ?? 0)), + ]; + } + } diff --git a/services/nginx/app/objects/orders_o.php b/services/nginx/app/objects/orders_o.php index ee50e526..a3eab301 100644 --- a/services/nginx/app/objects/orders_o.php +++ b/services/nginx/app/objects/orders_o.php @@ -318,6 +318,16 @@ class orders_o extends db self::objectChanged(); } + /** + * @throws Exception + */ + public function clearStripeInvoicing(): void + { + self::requireSelected(); + $this->stripe_module_orders->clear(); + self::objectChanged(); + } + public function addArray(array $order_array): orders_o { global $db, $response; @@ -375,10 +385,11 @@ class orders_o extends db * @param int|null $invoiceCollectionId The invoice collection id, if not set, the default invoice collection id will be used. * @throws Exception If the order is not selected */ - public function assignToInvoiceCollection(int $invoiceCollectionId = null): void + public function assignToInvoiceCollection(int $invoiceCollectionId = null, bool $notifyChanges = true): void { // If the invoice collection id is not set, get the default invoice collection id self::requireSelected(); + $previousInvoiceCollectionId = (int)$this->invoice_collection_id->value(); // Get the customer $customer = new users_o(); $customer_id = (int)$this->customer_id->value(); @@ -391,7 +402,16 @@ class orders_o extends db $invoiceCollectionId = $invoiceCollectionId ?? $customer->getNewOrderInvoiceCollectionId(); // Assign the order to the invoice collection $this->invoice_collection_id->set($invoiceCollectionId); - self::objectChanged(); + if ($previousInvoiceCollectionId > 0 && $previousInvoiceCollectionId !== (int)$invoiceCollectionId) { + $previousCollection = new collected_order_invoices_o(); + $previousCollection->select($previousInvoiceCollectionId); + if ($previousCollection->exists()) { + $previousCollection->objectChanged(); + } + } + if ($notifyChanges) { + self::objectChanged(); + } } /** @@ -1378,14 +1398,91 @@ class orders_o extends db public function hasWashCertificateAttached(): bool { self::requireSelected(); - // Check if the order has a wash certificate attached - $attachments = $this->listAttachments(); - foreach ( $attachments as $attachment ) { - if ($attachment->isWashCertificate()) { - return true; // Wash certificate found + return $this->listWashCertificateAttachmentIds() !== []; + } + + /** + * @return int[] + * @throws Exception + */ + protected function listWashCertificateAttachmentIds(): array + { + self::requireSelected(); + + global $db; + + $rawObjectType = trim((string)$this->table, '`'); + $objectTypes = array_values(array_unique([ + $db->escape_string($rawObjectType), + $db->escape_string('`' . $rawObjectType . '`'), + ])); + $quotedObjectTypes = "'" . implode("','", $objectTypes) . "'"; + $objectId = (int)$this->id; + $sql = "SELECT id, content + FROM object_attachments + WHERE object_type IN ($quotedObjectTypes) + AND object_id = $objectId + AND deleted_at IS NULL"; + $result = $db->query($sql); + if (!$result) { + return []; + } + + $attachmentIds = []; + while ($row = $db->fetch_assoc($result)) { + $content = json_decode((string)($row['content'] ?? ''), true); + $other = is_array($content) ? ($content['other'] ?? null) : null; + if (is_string($other) && strtolower($other) === 'wash_certificate') { + $attachmentIds[] = (int)($row['id'] ?? 0); } } - return false; // No wash certificate product found in the order items + + return array_values(array_filter($attachmentIds, static fn(int $id): bool => $id > 0)); + } + + /** + * @throws Exception + */ + public function regenerateAttachedWashCertificate(): bool + { + self::requireSelected(); + if (!$this->hasWashCertificateAttached()) { + return false; + } + + $this->removeAttachedWashCertificates(); + $this->generateWashCertificate( + $this->getSafetySealValue(), + $this->resolveWashCertificateOperator(), + $this->resolveWashCertificateDate() + ); + + return $this->hasWashCertificateAttached(); + } + + /** + * @throws Exception + */ + protected function removeAttachedWashCertificates(): int + { + self::requireSelected(); + + $attachmentIds = $this->listWashCertificateAttachmentIds(); + if ($attachmentIds === []) { + return 0; + } + + global $db; + + $escapedIds = array_map(static fn(int $id): int => (int)$id, $attachmentIds); + $idList = implode(',', $escapedIds); + $sql = "UPDATE object_attachments + SET deleted_at = NOW() + WHERE id IN ($idList) + AND deleted_at IS NULL"; + $db->query($sql); + + return count($escapedIds); } /** @@ -1439,6 +1536,42 @@ class orders_o extends db return self::normalizeSafetySealValue($this->safety_seal->value()); } + /** + * @throws Exception + */ + public function resolveWashCertificateOperator(): ?string + { + self::requireSelected(); + + $cashierId = (int)$this->cashier_id->value(); + if ($cashierId <= 0) { + return null; + } + + $cashier = (new users_o())->select($cashierId); + if (!$cashier->exists()) { + return null; + } + + $displayName = trim((string)$cashier->display_name->value()); + return $displayName === '' ? null : $displayName; + } + + /** + * @throws Exception + */ + public function resolveWashCertificateDate(): string + { + self::requireSelected(); + + $candidate = $this->completed_at->value() ?: $this->created_at->value(); + if (is_string($candidate) && trim($candidate) !== '') { + return $candidate; + } + + return date('Y-m-d H:i:s'); + } + /** * @throws Exception */ @@ -1482,17 +1615,27 @@ class orders_o extends db if (!$department->exists()) { throw new Exception('Department not found'); } - // Get branding for the department - $branding = (new branding_o())->select((int)$department->branding->value()); - if (!$branding->exists()) { - throw new Exception('Branding not found'); + // Branding is optional; fall back to the department values when it is not configured. + $branding = null; + $brandingId = (int)($department->branding->value() ?? 0); + if ($brandingId > 0) { + $selectedBranding = (new branding_o())->select($brandingId); + if ($selectedBranding->exists()) { + $branding = $selectedBranding; + } } // Get the customer for the order $customer = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value()); $department_array = $department->asArray(); - $department_array['branding'] = $branding->asArray(); - $customer_array = $customer->asArray(); $order_array = $this->asArray(); + $branding_array = $branding?->asArray() ?? []; + $customer_number = (int)$customer->customer_number->value(); + $customer_name = trim((string)$customer->display_name->value()); + if ($customer_name === '') { + $customer_name = (string)($customer_number > 0 ? $customer_number : $this->customer_id->value()); + } + + $customer_address = '-'; // Format the date as 17:35 02-12-2025 $date = ($date instanceof DateTime ? $date : ($date !== null ? new DateTime($date) : new DateTime())); $date_formatted = ($date instanceof DateTime ? $date->format('d-m-Y') : date('d-m-Y')); @@ -1526,10 +1669,10 @@ class orders_o extends db 'time' => $time_formatted, 'carried_out_by' => ($operator ?? null), 'department_id' => $department->id, - 'department_name' => $department_array['branding']['name'] ?: $department_array['name'], - 'department_address' => $department_array['branding']['address'] ?: $department_array['description'], - 'customer_name' => $customer->getCustomerName($customer_array['customer_number']), - 'customer_address' => $customer->getCustomerEcocomicData($customer_array['customer_number'])->economic_customer->address ?: '-', + 'department_name' => ($branding_array['name'] ?? null) ?: $department_array['name'], + 'department_address' => ($branding_array['address'] ?? null) ?: $department_array['description'], + 'customer_name' => $customer_name, + 'customer_address' => $customer_address, 'wash_type' => 'ORDER_WASH' ]) ->getHtml() diff --git a/services/nginx/app/objects/stripe_module_orders_o.php b/services/nginx/app/objects/stripe_module_orders_o.php index 28c03626..813fe25e 100644 --- a/services/nginx/app/objects/stripe_module_orders_o.php +++ b/services/nginx/app/objects/stripe_module_orders_o.php @@ -93,4 +93,19 @@ class stripe_module_orders_o extends db return (new stripe())->invoice->retrieve($this->invoice_id->value()); } -} \ No newline at end of file + public function clear(): void + { + if (!$this->exists()) { + return; + } + + $this->delete(); + } + + public function deleteForOrder(int $order_id): void + { + $record = (new self())->select($order_id); + $record->clear(); + } + +} diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 7a963d64..d38afb22 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -2195,6 +2195,17 @@ paths: two_factor_enabled: type: boolean description: Indicates if 2FA is enabled for this account + runtime_config: + type: object + properties: + economic: + type: object + properties: + transaction_draft_customer_number: + type: integer + nullable: true + additionalProperties: false + additionalProperties: true '400': $ref: '#/components/responses/BadRequest' '401': @@ -12041,12 +12052,13 @@ components: type: object properties: module: { type: string, enum: [economic] } - variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber] } + variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber, transactionDraftCustomerNumber] } type: { type: string, enum: [string, int] } value: oneOf: - type: string - type: integer + nullable: true required: [module, variable, type, value] RecaptchaConfigEntry: diff --git a/services/nginx/app/resources/edge-gateway-agent/agent.php b/services/nginx/app/resources/edge-gateway-agent/agent.php new file mode 100644 index 00000000..fac5e6a7 --- /dev/null +++ b/services/nginx/app/resources/edge-gateway-agent/agent.php @@ -0,0 +1,770 @@ +#!/usr/bin/env php +data) ? $this->data[$key] : $default; + } + + public function set(string $key, mixed $value): void + { + $this->data[$key] = $value; + } + + public function save(): void + { + file_put_contents($this->path, json_encode($this->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + } +} + +final class HttpJsonClient +{ + public function __construct(private readonly string $baseUrl) + { + } + + public function post(string $path, array $payload, int $timeoutSeconds = 30): ?array + { + $response = $this->requestJson( + 'POST', + rtrim($this->baseUrl, '/') . '/' . ltrim($path, '/'), + $payload, + $timeoutSeconds + ); + + return $response['decoded']; + } + + public function getJson(string $url, int $timeoutSeconds = 10): array + { + $response = $this->requestJson('GET', $url, null, $timeoutSeconds); + return $response['decoded'] ?? []; + } + + public function download(string $url, int $timeoutSeconds = 60): string + { + $response = $this->requestRaw('GET', $url, null, $timeoutSeconds, ['Accept: */*']); + return $response['body']; + } + + private function requestJson(string $method, string $url, ?array $payload, int $timeoutSeconds): array + { + $headers = ['Accept: application/json']; + if ($payload !== null) { + $headers[] = 'Content-Type: application/json'; + } + + $response = $this->requestRaw($method, $url, $payload, $timeoutSeconds, $headers); + $decoded = json_decode($response['body'], true); + if (!is_array($decoded)) { + throw new RuntimeException('Invalid JSON response from ' . $method . ' ' . $url); + } + + $response['decoded'] = $decoded; + return $response; + } + + private function requestRaw( + string $method, + string $url, + ?array $payload, + int $timeoutSeconds, + array $headers + ): array { + $responseHeaders = []; + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $timeoutSeconds, + CURLOPT_CONNECTTIMEOUT => min(10, $timeoutSeconds), + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_HEADERFUNCTION => static function ($curl, string $headerLine) use (&$responseHeaders): int { + $trimmed = trim($headerLine); + if ($trimmed !== '') { + $responseHeaders[] = $trimmed; + } + return strlen($headerLine); + }, + ]); + + if ($payload !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_SLASHES)); + } + + $raw = curl_exec($ch); + $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($raw === false) { + throw new RuntimeException(sprintf('%s %s failed: %s', $method, $url, $error !== '' ? $error : 'unknown curl error')); + } + + if ($status >= 400) { + $preview = substr(preg_replace('/\s+/', ' ', (string)$raw) ?? '', 0, 240); + $headerPreview = implode('; ', array_slice($responseHeaders, 0, 8)); + throw new RuntimeException(sprintf( + '%s %s returned HTTP %d. Headers: %s. Body preview: %s', + $method, + $url, + $status, + $headerPreview !== '' ? $headerPreview : 'none', + $preview !== '' ? $preview : 'empty' + )); + } + + return [ + 'status' => $status, + 'body' => (string)$raw, + 'headers' => $responseHeaders, + ]; + } +} + +final class Logger +{ + public function __construct(private readonly string $logFile) + { + } + + public function info(string $message): void + { + $this->write('INFO', $message); + } + + public function warning(string $message): void + { + $this->write('WARNING', $message); + } + + public function error(string $message): void + { + $this->write('ERROR', $message); + } + + private function write(string $level, string $message): void + { + $line = '[' . date('c') . "] {$level} {$message}\n"; + file_put_contents($this->logFile, $line, FILE_APPEND); + fwrite($level === 'ERROR' ? STDERR : STDOUT, $line); + } +} + +final class TruckwashEdgeAgent +{ + private AgentConfig $config; + private HttpJsonClient $http; + private Logger $logger; + private string $installDir; + private string $runtimeDir; + private string $statePath; + private string $lastOperationSnapshotPath; + private string $lastHeartbeatMarkerPath; + private int $lastHeartbeatAt = 0; + private string $agentInstanceId; + + public function __construct(string $configPath) + { + $this->config = AgentConfig::load($configPath); + $this->http = new HttpJsonClient((string)$this->config->get('apiUrl')); + $this->installDir = rtrim((string)$this->config->get('installDir', dirname($configPath)), DIRECTORY_SEPARATOR); + $this->runtimeDir = $this->installDir . DIRECTORY_SEPARATOR . 'runtime'; + @mkdir($this->runtimeDir, 0777, true); + $this->logger = new Logger($this->runtimeDir . DIRECTORY_SEPARATOR . 'agent.log'); + $this->statePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'current-operation.json'; + $this->lastOperationSnapshotPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-operation.json'; + $this->lastHeartbeatMarkerPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-heartbeat-ok.txt'; + $this->agentInstanceId = $this->ensureAgentInstanceId(); + } + + public function run(): void + { + $this->logger->info('Truckwash PHP edge agent starting.'); + $this->recoverPreviousOperationState(); + + while (true) { + try { + $this->ensureClaimed(); + $this->heartbeat(); + $processedManagementOperation = $this->processManagementOperation(); + if (!$processedManagementOperation) { + $this->processCommandQueue(); + } + } catch (Throwable $throwable) { + $this->logger->error($throwable->getMessage()); + sleep(2); + } + + usleep(250000); + } + } + + private function ensureClaimed(): void + { + if ($this->config->get('gatewayId') && $this->config->get('agentToken')) { + return; + } + + $response = $this->http->post('/edge-agent/claim', [ + 'token' => (string)$this->config->get('installToken'), + 'hostname' => gethostname() ?: 'truckwash-edge', + 'installed_version' => (string)$this->config->get('installedVersion', 'php-agent-v1'), + 'metadata' => [ + 'runtime' => 'php-cli', + 'php_version' => PHP_VERSION, + 'agent_instance_id' => $this->agentInstanceId, + ], + ]); + + $payload = $response['data'] ?? []; + $gateway = $payload['gateway'] ?? []; + $installedVersion = (string)$this->config->get('installedVersion', 'php-agent-v1'); + $this->config->set('gatewayId', $gateway['id'] ?? null); + $this->config->set('agentToken', $payload['agent_token'] ?? null); + $this->config->set('installedVersion', $installedVersion); + $this->config->set('targetVersion', (string)($gateway['target_version'] ?? $installedVersion)); + $this->config->set('agentInstanceId', $this->agentInstanceId); + $this->config->save(); + $this->logger->info('Claimed gateway ' . (string)($gateway['id'] ?? 'unknown') . '.'); + } + + private function heartbeat(bool $force = false, array $metadata = []): void + { + $interval = (int)$this->config->get('heartbeatIntervalSeconds', 15); + if (!$force && (time() - $this->lastHeartbeatAt) < max(5, $interval)) { + return; + } + + $gatewayId = (int)$this->config->get('gatewayId'); + if ($gatewayId <= 0) { + return; + } + + $operationState = $this->readOperationState(); + $this->http->post('/edge-agent/gateways/' . $gatewayId . '/heartbeat', [ + 'agent_token' => (string)$this->config->get('agentToken'), + 'status' => 'ONLINE', + 'hostname' => gethostname() ?: 'truckwash-edge', + 'installed_version' => (string)$this->config->get('installedVersion', 'php-agent-v1'), + 'target_version' => (string)$this->config->get('targetVersion', $this->config->get('installedVersion', 'php-agent-v1')), + 'metadata' => array_merge([ + 'agent_instance_id' => $this->agentInstanceId, + 'current_operation' => $operationState, + 'system_metrics' => [ + 'memory_usage_bytes' => memory_get_usage(true), + 'memory_peak_bytes' => memory_get_peak_usage(true), + 'load_average' => function_exists('sys_getloadavg') ? sys_getloadavg() : [], + ], + ], $metadata), + ]); + + $this->lastHeartbeatAt = time(); + file_put_contents($this->lastHeartbeatMarkerPath, json_encode([ + 'gateway_id' => $gatewayId, + 'at' => date('c'), + 'agent_instance_id' => $this->agentInstanceId, + ], JSON_UNESCAPED_SLASHES) . PHP_EOL); + } + + private function processCommandQueue(): void + { + $gatewayId = (int)$this->config->get('gatewayId'); + $response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/poll', [ + 'agent_token' => (string)$this->config->get('agentToken'), + 'wait_seconds' => (int)$this->config->get('operationPollTimeoutSeconds', 20), + ]); + $command = $response['data'] ?? null; + if (!is_array($command) || empty($command['id'])) { + return; + } + + $jobId = (int)$command['id']; + $type = (string)($command['command_type'] ?? $command['commandType'] ?? ''); + $request = is_array($command['payload'] ?? null) ? (array)$command['payload'] : []; + + try { + $result = match ($type) { + 'DISCOVER_SHELLY' => $this->runDiscovery(), + 'GET_RELAY_STATUS' => $this->readRelayStatus($request), + 'SET_RELAY_STATE' => $this->switchRelay($request), + default => throw new RuntimeException('Unsupported command type: ' . $type), + }; + + $this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/' . $jobId . '/result', [ + 'agent_token' => (string)$this->config->get('agentToken'), + 'ok' => true, + 'result' => $result, + ]); + } catch (Throwable $throwable) { + $this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/' . $jobId . '/result', [ + 'agent_token' => (string)$this->config->get('agentToken'), + 'ok' => false, + 'error' => $throwable->getMessage(), + ]); + } + } + + private function processManagementOperation(): bool + { + $gatewayId = (int)$this->config->get('gatewayId'); + $response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/next', [ + 'agent_token' => (string)$this->config->get('agentToken'), + 'wait_seconds' => (int)$this->config->get('operationPollTimeoutSeconds', 20), + 'agent_instance_id' => $this->agentInstanceId, + ]); + $operation = $response['data'] ?? null; + if (!is_array($operation) || empty($operation['id'])) { + return false; + } + + $operationId = (int)$operation['id']; + $type = (string)($operation['type'] ?? ''); + $request = is_array($operation['request'] ?? null) ? (array)$operation['request'] : []; + $this->persistOperationState([ + 'gateway_id' => $gatewayId, + 'operation_id' => $operationId, + 'type' => $type, + 'status' => 'IN_PROGRESS', + 'stage' => 'claimed', + 'progress' => 5, + 'agent_instance_id' => $this->agentInstanceId, + 'started_at' => date('c'), + 'request' => $request, + ]); + + $this->emitOperationProgress( + $gatewayId, + $operationId, + 'OPERATION_AGENT_STARTED', + 'PHP edge agent started processing the operation', + 10, + ['type' => $type, 'agent_instance_id' => $this->agentInstanceId], + 'starting' + ); + + try { + $result = match ($type) { + 'DISCOVERY' => $this->runManagementDiscovery($gatewayId, $operationId, $request), + 'UPDATE' => $this->runUpdate($gatewayId, $operationId, $request), + 'UNINSTALL' => $this->runUninstall($gatewayId, $operationId, $request), + default => throw new RuntimeException('Unsupported operation type: ' . $type), + }; + + $this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/complete', [ + 'agent_token' => (string)$this->config->get('agentToken'), + 'ok' => true, + 'result' => $result, + ]); + $this->snapshotOperationState('COMPLETED', ['result' => $result]); + $this->clearOperationState(); + } catch (Throwable $throwable) { + $errorCode = $this->classifyManagementError($throwable, $type); + $this->postOperationEvent($gatewayId, $operationId, [ + 'level' => 'ERROR', + 'code' => $errorCode, + 'message' => $throwable->getMessage(), + 'context' => ['type' => $type, 'progress' => 100, 'agent_instance_id' => $this->agentInstanceId], + ]); + + $this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/complete', [ + 'agent_token' => (string)$this->config->get('agentToken'), + 'ok' => false, + 'error_code' => $errorCode, + 'error_message' => $throwable->getMessage(), + ]); + $this->snapshotOperationState('FAILED', [ + 'error_code' => $errorCode, + 'error_message' => $throwable->getMessage(), + ]); + $this->clearOperationState(); + } + + return true; + } + + private function postOperationEvent(int $gatewayId, int $operationId, array $payload): void + { + $this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/events', [ + 'agent_token' => (string)$this->config->get('agentToken'), + 'level' => (string)($payload['level'] ?? 'INFO'), + 'code' => $payload['code'] ?? null, + 'message' => (string)($payload['message'] ?? 'Operation event'), + 'context' => is_array($payload['context'] ?? null) ? (array)$payload['context'] : [], + ]); + } + + private function emitOperationProgress( + int $gatewayId, + int $operationId, + string $code, + string $message, + int $progress, + array $context = [], + ?string $stage = null + ): void { + $operationState = $this->readOperationState(); + if ($operationState !== null) { + $operationState['progress'] = max(0, min(100, $progress)); + $operationState['stage'] = $stage ?? ($operationState['stage'] ?? 'running'); + $operationState['updated_at'] = date('c'); + $this->persistOperationState($operationState); + } + + $this->heartbeat(true, [ + 'current_operation' => $this->readOperationState(), + ]); + + $this->postOperationEvent($gatewayId, $operationId, [ + 'level' => 'INFO', + 'code' => $code, + 'message' => $message, + 'context' => array_merge($context, [ + 'progress' => max(0, min(100, $progress)), + 'agent_instance_id' => $this->agentInstanceId, + ]), + ]); + } + + private function runDiscovery(): array + { + return ['inventory' => []]; + } + + private function runManagementDiscovery(int $gatewayId, int $operationId, array $request): array + { + $this->emitOperationProgress($gatewayId, $operationId, 'DISCOVERY_COLLECTING', 'Collecting local gateway inventory', 35, [], 'discovering'); + $hostname = gethostname() ?: 'truckwash-edge'; + $fallbackInventory = [[ + 'device_id' => 'gateway-runtime-' . substr(sha1($hostname), 0, 10), + 'local_ip' => gethostbyname($hostname), + 'model' => 'TruckWash Edge Gateway', + 'channel_count' => 1, + 'online' => true, + 'capabilities' => [ + 'local_discovery' => true, + 'relay_commands' => true, + 'gateway_management_v2' => true, + ], + 'metadata' => [ + 'hostname' => $hostname, + 'runtime' => 'php-cli', + 'php_version' => PHP_VERSION, + ], + ]]; + + $inventory = isset($request['inventory']) && is_array($request['inventory']) && $request['inventory'] !== [] + ? (array)$request['inventory'] + : $fallbackInventory; + $this->emitOperationProgress($gatewayId, $operationId, 'DISCOVERY_COMPLETED', 'Inventory collected', 90, [ + 'device_count' => count($inventory), + ], 'finishing'); + + return ['inventory' => $inventory]; + } + + private function runUpdate(int $gatewayId, int $operationId, array $request): array + { + $targetVersion = trim((string)($request['target_version'] ?? $request['targetVersion'] ?? '')); + if ($targetVersion === '') { + throw new RuntimeException('Update request is missing target version'); + } + + $agentArtifactUrl = trim((string)($request['artifactUrl'] ?? '')); + $serviceUnitUrl = trim((string)($request['serviceUnitUrl'] ?? '')); + $agentArtifactSha = trim((string)($request['artifactSha256'] ?? '')); + $serviceUnitSha = trim((string)($request['serviceUnitSha256'] ?? '')); + + $this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_VALIDATED', 'Validated update request', 20, [ + 'target_version' => $targetVersion, + ], 'validating'); + + $changedFiles = []; + if ($agentArtifactUrl !== '') { + $this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_DOWNLOAD_AGENT', 'Downloading PHP edge agent artifact', 40, [ + 'url' => $agentArtifactUrl, + ], 'downloading-agent'); + $changedFiles[] = $this->downloadToFileAtomic( + $agentArtifactUrl, + $this->installDir . DIRECTORY_SEPARATOR . 'agent.php', + $agentArtifactSha + ); + } + + if ($serviceUnitUrl !== '') { + $this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_DOWNLOAD_SERVICE', 'Downloading service unit', 65, [ + 'url' => $serviceUnitUrl, + ], 'downloading-service'); + $changedFiles[] = $this->downloadToFileAtomic( + $serviceUnitUrl, + $this->installDir . DIRECTORY_SEPARATOR . 'truckwash-edge-agent.service', + $serviceUnitSha + ); + } + + $this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_WRITE_CONFIG', 'Writing staged update metadata', 85, [], 'writing-config'); + $this->config->set('installedVersion', $targetVersion); + $this->config->set('targetVersion', $targetVersion); + $this->config->set('lastStagedUpdate', [ + 'target_version' => $targetVersion, + 'staged_at' => date('c'), + 'changed_files' => $changedFiles, + ]); + $this->config->save(); + + $this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_STAGED', 'Artifacts staged successfully', 95, [ + 'changed_files' => $changedFiles, + ], 'staged'); + + return [ + 'installed_version' => $targetVersion, + 'target_version' => $targetVersion, + 'restart_required' => true, + 'service_name' => (string)$this->config->get('serviceName', 'truckwash-edge-agent.service'), + 'changed_files' => $changedFiles, + 'agent_instance_id' => $this->agentInstanceId, + ]; + } + + private function runUninstall(int $gatewayId, int $operationId, array $request): array + { + $this->emitOperationProgress($gatewayId, $operationId, 'UNINSTALL_PREPARE', 'Preparing uninstall manifest', 40, [], 'preparing-uninstall'); + $manifestPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'uninstall-plan.json'; + $manifest = [ + 'service_name' => (string)($request['service_name'] ?? $this->config->get('serviceName', 'truckwash-edge-agent.service')), + 'install_dir' => (string)($request['install_dir'] ?? $this->installDir), + 'generated_at' => date('c'), + 'agent_instance_id' => $this->agentInstanceId, + ]; + file_put_contents($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + file_put_contents($this->runtimeDir . DIRECTORY_SEPARATOR . 'uninstall.flag', date('c') . PHP_EOL); + $this->config->set('pendingUninstall', $manifest); + $this->config->save(); + + $this->emitOperationProgress($gatewayId, $operationId, 'UNINSTALL_READY', 'Gateway marked for controlled uninstall', 90, [ + 'manifest_path' => $manifestPath, + ], 'uninstall-ready'); + + return [ + 'uninstalled' => true, + 'service_name' => $manifest['service_name'], + 'install_dir' => $manifest['install_dir'], + 'manual_cleanup_required' => true, + 'cleanup_manifest_path' => $manifestPath, + ]; + } + + private function readRelayStatus(array $request): array + { + $localIp = (string)($request['localIp'] ?? $request['local_ip'] ?? ''); + $channel = (int)($request['channel'] ?? 0); + return $this->fetchShellyState($localIp, $channel); + } + + private function switchRelay(array $request): array + { + $localIp = (string)($request['localIp'] ?? $request['local_ip'] ?? ''); + $channel = (int)($request['channel'] ?? 0); + $on = (bool)($request['on'] ?? false); + + $rpcUrl = sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s', $localIp, $channel, $on ? 'true' : 'false'); + try { + $this->http->getJson($rpcUrl, 8); + } catch (Throwable) { + $legacyUrl = sprintf('http://%s/relay/%d?turn=%s', $localIp, $channel, $on ? 'on' : 'off'); + $this->http->getJson($legacyUrl, 8); + } + + return $this->fetchShellyState($localIp, $channel); + } + + private function downloadToFileAtomic(string $url, string $path, string $expectedSha256 = ''): array + { + $directory = dirname($path); + if (!is_dir($directory)) { + @mkdir($directory, 0777, true); + } + + $temporaryPath = $path . '.download'; + $backupPath = $path . '.bak'; + $raw = $this->http->download($url, 60); + file_put_contents($temporaryPath, $raw); + + if ($expectedSha256 !== '') { + $actualSha = hash_file('sha256', $temporaryPath); + if (!is_string($actualSha) || !hash_equals(strtolower($expectedSha256), strtolower($actualSha))) { + @unlink($temporaryPath); + throw new RuntimeException('Artifact checksum mismatch for ' . basename($path)); + } + } + + if (is_file($backupPath)) { + @unlink($backupPath); + } + + if (is_file($path) && !@rename($path, $backupPath)) { + @unlink($temporaryPath); + throw new RuntimeException('Could not create backup for ' . $path); + } + + if (!@rename($temporaryPath, $path)) { + if (is_file($backupPath)) { + @rename($backupPath, $path); + } + @unlink($temporaryPath); + throw new RuntimeException('Could not promote downloaded artifact for ' . $path); + } + + return [ + 'path' => $path, + 'url' => $url, + 'backup_path' => is_file($backupPath) ? $backupPath : null, + 'sha256' => hash_file('sha256', $path), + ]; + } + + private function classifyManagementError(Throwable $throwable, string $type): string + { + $message = strtolower(trim($throwable->getMessage())); + if (str_contains($message, 'token') || str_contains($message, 'credential')) { + return 'EDGE_GATEWAY_INVALID_TOKEN'; + } + if (str_contains($message, 'timeout') || str_contains($message, 'http 5')) { + return 'EDGE_GATEWAY_OPERATION_TIMEOUT'; + } + if ($type === 'UPDATE' && (str_contains($message, 'version') || str_contains($message, 'checksum'))) { + return 'EDGE_GATEWAY_UNSUPPORTED_VERSION'; + } + + return 'EDGE_GATEWAY_VALIDATION_FAILED'; + } + + private function fetchShellyState(string $localIp, int $channel): array + { + if ($localIp === '') { + throw new RuntimeException('Missing Shelly IP address'); + } + + try { + $payload = $this->http->getJson(sprintf('http://%s/rpc/Switch.GetStatus?id=%d', $localIp, $channel), 8); + return [ + 'online' => true, + 'on' => (bool)($payload['output'] ?? false), + 'output' => (bool)($payload['output'] ?? false), + 'raw' => $payload, + ]; + } catch (Throwable) { + $payload = $this->http->getJson(sprintf('http://%s/relay/%d', $localIp, $channel), 8); + return [ + 'online' => true, + 'on' => (bool)($payload['ison'] ?? false), + 'output' => (bool)($payload['ison'] ?? false), + 'raw' => $payload, + ]; + } + } + + private function ensureAgentInstanceId(): string + { + $configured = trim((string)$this->config->get('agentInstanceId', '')); + if ($configured !== '') { + return substr($configured, 0, 128); + } + + $generated = sprintf( + '%s-%s', + preg_replace('/[^A-Za-z0-9\-]+/', '-', gethostname() ?: 'truckwash-edge') ?: 'truckwash-edge', + substr(bin2hex(random_bytes(6)), 0, 12) + ); + $this->config->set('agentInstanceId', $generated); + $this->config->save(); + return $generated; + } + + private function recoverPreviousOperationState(): void + { + $state = $this->readOperationState(); + if ($state === null) { + return; + } + + $this->logger->warning(sprintf( + 'Recovered previous unfinished operation snapshot: operation %s (%s) last stage %s.', + (string)($state['operation_id'] ?? 'unknown'), + (string)($state['type'] ?? 'unknown'), + (string)($state['stage'] ?? 'unknown') + )); + } + + private function persistOperationState(array $state): void + { + file_put_contents($this->statePath, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + } + + private function snapshotOperationState(string $status, array $extra = []): void + { + $state = $this->readOperationState() ?? []; + $state['status'] = $status; + $state['finished_at'] = date('c'); + $state = array_merge($state, $extra); + file_put_contents($this->lastOperationSnapshotPath, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + } + + private function clearOperationState(): void + { + if (is_file($this->statePath)) { + @unlink($this->statePath); + } + } + + private function readOperationState(): ?array + { + if (!is_file($this->statePath)) { + return null; + } + + $decoded = json_decode((string)file_get_contents($this->statePath), true); + return is_array($decoded) ? $decoded : null; + } +} + +$configPath = null; +foreach ($argv as $index => $argument) { + if ($argument === '--config' && isset($argv[$index + 1])) { + $configPath = $argv[$index + 1]; + break; + } +} + +if (!is_string($configPath) || trim($configPath) === '') { + fwrite(STDERR, "Usage: agent.php --config /path/to/config.json\n"); + exit(1); +} + +(new TruckwashEdgeAgent($configPath))->run(); diff --git a/services/nginx/app/resources/edge-gateway-agent/truckwash-edge-agent.service b/services/nginx/app/resources/edge-gateway-agent/truckwash-edge-agent.service new file mode 100644 index 00000000..f09efba8 --- /dev/null +++ b/services/nginx/app/resources/edge-gateway-agent/truckwash-edge-agent.service @@ -0,0 +1,14 @@ +[Unit] +Description=TruckWash Edge Agent +After=network.target + +[Service] +Type=simple +WorkingDirectory=/opt/truckwash-edge-agent +ExecStart=/usr/bin/php /opt/truckwash-edge-agent/agent.php --config /opt/truckwash-edge-agent/config.json +Restart=always +RestartSec=5 +User=root + +[Install] +WantedBy=multi-user.target diff --git a/services/nginx/app/routes/authRoute.php b/services/nginx/app/routes/authRoute.php index cff66068..ec534bd0 100644 --- a/services/nginx/app/routes/authRoute.php +++ b/services/nginx/app/routes/authRoute.php @@ -116,6 +116,12 @@ class authRoute try { $cached = redis->get_auth_session($token); if (is_array($cached)) { + $cached = $this->appendRuntimeConfig($cached); + try { + redis->cache_auth_session($token, $cached, 60); + } catch (\Throwable $e) { + // Best-effort caching only + } $response->success($cached); } } catch (\Throwable $e) { @@ -131,6 +137,7 @@ class authRoute $user_data = $user->includeIncludes(['economicCustomer', 'permissions'])->asArray(); $user_data['two_factor_enabled'] = $user->isTwoFactorEnabled(); + $user_data = $this->appendRuntimeConfig($user_data); // Cache the session payload briefly to reduce DB load on hot paths try { @@ -810,4 +817,18 @@ class authRoute (new logs_o())->add('auth', 'global', 0, 0, $action, $message); } + + private function appendRuntimeConfig(array $payload): array + { + $payload['runtime_config'] = array_replace_recursive( + is_array($payload['runtime_config'] ?? null) ? $payload['runtime_config'] : [], + [ + 'economic' => [ + 'transaction_draft_customer_number' => (new economic())->getTransactionDraftCustomerNumber(), + ], + ] + ); + + return $payload; + } } diff --git a/services/nginx/app/routes/departmentDailyReportsRoute.php b/services/nginx/app/routes/departmentDailyReportsRoute.php index 65378546..bf2cef3f 100644 --- a/services/nginx/app/routes/departmentDailyReportsRoute.php +++ b/services/nginx/app/routes/departmentDailyReportsRoute.php @@ -5,6 +5,7 @@ namespace routes; use classes\authentication; use classes\department_outside_hours_statistics_service; use classes\workfeed; +use classes\workfeed_shift_time_resolver; use customers\economicCustomers; use DateInterval; use DateTime; @@ -1654,53 +1655,7 @@ class departmentDailyReportsRoute private function calculateShiftOvertimeHoursInRange(array $record, DateTime $range_start, DateTime $range_end_exclusive): float { - $shift_start = $this->firstShiftDateTimeFromPaths($record, [ - 'actualStart', - 'actualStartTime', - 'clockIn', - 'clockInTime', - 'start', - 'startTime', - 'from', - 'approval.originalStart', - ]); - - $scheduled_end = $this->lastShiftDateTimeFromPaths($record, [ - 'end', - 'endTime', - 'to', - ]); - - $effective_end = $this->lastShiftDateTimeFromPaths($record, [ - 'actualEnd', - 'actualEndTime', - 'clockOut', - 'clockOutTime', - 'end', - 'endTime', - 'to', - 'approval.originalEnd', - 'approval.end', - ]); - - if ($shift_start === null || $scheduled_end === null || $effective_end === null) { - return 0.0; - } - - $effective_end = $this->resolveEffectiveShiftEnd($record, $shift_start, $effective_end); - $scheduled_end_ts = $scheduled_end->getTimestamp(); - $effective_end_ts = $effective_end->getTimestamp(); - if ($effective_end_ts <= $scheduled_end_ts) { - return 0.0; - } - - $overtime_start_ts = max($scheduled_end_ts, $range_start->getTimestamp()); - $overtime_end_ts = min($effective_end_ts, $range_end_exclusive->getTimestamp()); - if ($overtime_end_ts <= $overtime_start_ts) { - return 0.0; - } - - return round(($overtime_end_ts - $overtime_start_ts) / 3600, 2); + return workfeed_shift_time_resolver::calculateOvertimeHoursInRange($record, $range_start, $range_end_exclusive); } /** diff --git a/services/nginx/app/routes/economicInvoiceRoute.php b/services/nginx/app/routes/economicInvoiceRoute.php index ea6a4beb..4997e818 100644 --- a/services/nginx/app/routes/economicInvoiceRoute.php +++ b/services/nginx/app/routes/economicInvoiceRoute.php @@ -3,6 +3,7 @@ namespace routes; use classes\authentication; +use classes\economic; use classes\economic_transfer_queue; use classes\response; use classes\router; @@ -45,6 +46,11 @@ class economicInvoiceRoute if (!$order->exists()) { $response->error('Order not found', 404); } + try { + $this->assertOrderCanBeExportedToEconomic((int)$order_id); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 400); + } if (!$this->isEconomicTransferQueueAvailable()) { try { @@ -158,6 +164,11 @@ class economicInvoiceRoute if (!$customer->exists()) { $response->error('Customer not found', 404); } + try { + (new economic())->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value()); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 400); + } $economic_module_orders = (new economic_module_orders())->getByOrderId((int)$order_id); if ($economic_module_orders->economic_invoice_draft_id->value() === 0) { $response->error('No economic invoice draft found', 404); @@ -365,6 +376,7 @@ class economicInvoiceRoute if (!$customer->exists()) { throw new \Exception('Customer not found'); } + (new economic())->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value()); $customer_economic = $customer->getCustomerEcocomicData()->economic_customer; $economic_invoice_draft = new economic_invoice_draft_mo(); @@ -449,6 +461,7 @@ class economicInvoiceRoute if (!$customer->exists()) { throw new \Exception('Customer not found'); } + (new economic())->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value()); $economic_module_orders = (new economic_module_orders())->getByOrderId($order_id); if ($economic_module_orders->economic_invoice_draft_id->value() === 0) { @@ -622,4 +635,17 @@ class economicInvoiceRoute $response->error('Economic transfer queue is unavailable in this deployment', 503); } } + + /** + * @throws \Exception + */ + private function assertOrderCanBeExportedToEconomic(int $order_id): void + { + $customer = (new orders_o())->getCustomerByOrderId($order_id); + if (!$customer->exists()) { + throw new \Exception('Customer not found'); + } + + (new economic())->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value()); + } } diff --git a/services/nginx/app/routes/edgeGatewaysRoute.php b/services/nginx/app/routes/edgeGatewaysRoute.php index 4d405b6a..084345df 100644 --- a/services/nginx/app/routes/edgeGatewaysRoute.php +++ b/services/nginx/app/routes/edgeGatewaysRoute.php @@ -3,9 +3,14 @@ namespace routes; use classes\authentication; +use classes\edge_gateway_install_service; use classes\edge_gateway_manager; +use classes\edge_gateway_operation_exception; +use classes\edge_gateway_operation_service; +use classes\edge_gateway_registry_service; +use classes\edge_gateway_view_service; use classes\response; -use objects\logs_o; +use Exception; use traits\route_t; class edgeGatewaysRoute @@ -14,244 +19,223 @@ class edgeGatewaysRoute public function run(): void { - $this->get('/edge-gateways', function () { - global /** @var response $response */ $response; - $this->requirePermission('modules_shelly_config'); - - $departmentId = self::isParametersSet(['department_id']) ? (int)self::getParameter('department_id') : null; - if ($departmentId !== null && $departmentId > 0) { - $this->requireDepartmentAccess((int)$departmentId); - } - - $response->success((new edge_gateway_manager())->listGateways($departmentId)); - }, [ + $this->get('/edge-gateways', fn() => $this->handleListGateways(), [ 'modules_shelly_config' => 'Manage department edge gateways for local Shelly control', ]); - - $this->get('/edge-gateways/{id}', function () { - global /** @var response $response */ $response; - $this->requirePermission('modules_shelly_config'); - - $gatewayId = (int)$this->fromRoute('id'); - self::requireParameterIntPositive($gatewayId, 'id'); - $gateway = (new edge_gateway_manager())->getGateway($gatewayId); - $this->requireDepartmentAccess((int)$gateway['department_id']); - $response->success($gateway); - }, [ + $this->get('/edge-gateways/{id}', fn() => $this->handleGatewayDetail(), [ 'modules_shelly_config' => 'View department edge gateway detail', ]); - - $this->post('/edge-gateways/install-token', function () { - global /** @var response $response */ $response; - $this->requirePermission('modules_shelly_config'); - - self::requireParameters(['department_id']); - $departmentId = (int)self::getParameter('department_id'); - self::requireParameterIntPositive($departmentId, 'department_id'); - $this->requireDepartmentAccess($departmentId); - - $user = (new authentication())->get_user(); - $response->success( - (new edge_gateway_manager())->createInstallToken( - $departmentId, - self::isParametersSet(['label']) ? (string)self::getParameter('label') : null, - $user ? (int)$user->id : null - ), - 201 - ); - }, [ + $this->put('/edge-gateways/{id}', fn() => $this->handleGatewayUpdate(), [ + 'modules_shelly_config' => 'Update edge gateway metadata and primary assignment', + ]); + $this->get('/edge-gateways/{id}/operations', fn() => $this->handleGatewayOperationsList(), [ + 'modules_shelly_config' => 'List edge gateway operations', + ]); + $this->post('/edge-gateways/{id}/operations', fn() => $this->handleGatewayOperationCreate(), [ + 'modules_shelly_config' => 'Queue an edge gateway operation', + ]); + $this->get('/edge-gateways/{id}/operations/{operationId}/events', fn() => $this->handleGatewayOperationEvents(), [ + 'modules_shelly_config' => 'List edge gateway operation events', + ]); + $this->post('/edge-gateways/{id}/rotate-credentials', fn() => $this->handleGatewayCredentialRotate(), [ + 'modules_shelly_config' => 'Rotate edge gateway credentials', + ]); + $this->post('/edge-gateways/install-token', fn() => $this->handleInstallTokenCreate(), [ 'modules_shelly_config' => 'Create a one-time Raspberry Pi edge gateway installer token', ]); - - $this->post('/edge-gateways/{id}/discovery', function () { - global /** @var response $response */ $response; - $this->requirePermission('modules_shelly_config'); - - $gatewayId = (int)$this->fromRoute('id'); - self::requireParameterIntPositive($gatewayId, 'id'); - $manager = new edge_gateway_manager(); - $gateway = $manager->getGateway($gatewayId); - $this->requireDepartmentAccess((int)$gateway['department_id']); - - $user = (new authentication())->get_user(); - $response->success($manager->queueDiscovery($gatewayId, $user ? (int)$user->id : null)); - }, [ - 'modules_shelly_config' => 'Trigger a Shelly LAN discovery job on an edge gateway', + $this->post('/edge-gateways/{id}/discovery', fn() => $this->handleGatewayDiscovery(), [ + 'modules_shelly_config' => 'Queue Shelly discovery through the local edge gateway', ]); - - $this->put('/edge-gateways/{id}/bindings', function () { - global /** @var response $response */ $response; - $this->requirePermission('modules_shelly_config'); - self::requireParameters(['bindings']); - self::requireType(self::getParameter('bindings'), self::TYPE_ARRAY()); - - $gatewayId = (int)$this->fromRoute('id'); - self::requireParameterIntPositive($gatewayId, 'id'); - $manager = new edge_gateway_manager(); - $gateway = $manager->getGateway($gatewayId); - $this->requireDepartmentAccess((int)$gateway['department_id']); - - $user = (new authentication())->get_user(); - $response->success($manager->setRelayBindings( - $gatewayId, - (array)self::getParameter('bindings'), - $user ? (int)$user->id : null - )); - }, [ + $this->put('/edge-gateways/{id}/bindings', fn() => $this->handleBindingsUpdate(), [ 'modules_shelly_config' => 'Approve or override relay bindings for an edge gateway', ]); - - $this->post('/edge-gateways/{id}/update-jobs', function () { - global /** @var response $response */ $response; - $this->requirePermission('modules_shelly_config'); - self::requireParameters(['target_version']); - - $gatewayId = (int)$this->fromRoute('id'); - self::requireParameterIntPositive($gatewayId, 'id'); - $manager = new edge_gateway_manager(); - $gateway = $manager->getGateway($gatewayId); - $this->requireDepartmentAccess((int)$gateway['department_id']); - - $user = (new authentication())->get_user(); - $response->success($manager->queueUpdate( - $gatewayId, - (string)self::getParameter('target_version'), - self::isParametersSet(['release_channel']) ? (string)self::getParameter('release_channel') : edge_gateway_manager::DEFAULT_RELEASE_CHANNEL, - $user ? (int)$user->id : null - ), 201); - }, [ - 'modules_shelly_config' => 'Queue an automatic edge gateway update', - ]); - - $this->post('/edge-gateways/{id}/uninstall', function () { - global /** @var response $response */ $response; - $this->requirePermission('modules_shelly_config'); - - $gatewayId = (int)$this->fromRoute('id'); - self::requireParameterIntPositive($gatewayId, 'id'); - $manager = new edge_gateway_manager(); - $gateway = $manager->getGateway($gatewayId); - $this->requireDepartmentAccess((int)$gateway['department_id']); - - $user = (new authentication())->get_user(); - $response->success($manager->queueUninstall($gatewayId, $user ? (int)$user->id : null), 202); - }, [ - 'modules_shelly_config' => 'Queue an edge gateway uninstall on the Raspberry Pi', - ]); - - $this->post('/edge-gateways/{id}/shell-sessions', function () { - global /** @var response $response */ $response; - $this->requirePermission('modules_shelly_config'); - self::requireParameters(['reason']); - - $gatewayId = (int)$this->fromRoute('id'); - self::requireParameterIntPositive($gatewayId, 'id'); - $manager = new edge_gateway_manager(); - $gateway = $manager->getGateway($gatewayId); - $this->requireDepartmentAccess((int)$gateway['department_id']); - self::requireMinLength('reason', 5); - - $user = (new authentication())->get_user(); - $response->success($manager->createShellSessionRequest( - $gatewayId, - (string)self::getParameter('reason'), - [ - 'cols' => self::isParametersSet(['cols']) ? (int)self::getParameter('cols') : null, - 'rows' => self::isParametersSet(['rows']) ? (int)self::getParameter('rows') : null, - ], - $user ? (int)$user->id : null - ), 201); - }, [ - 'modules_shelly_config' => 'Approve a break-glass root shell on an edge gateway', - ]); - - $this->get('/edge-gateways/{id}/shell-sessions/{sessionId}/events', fn() => $this->handleShellSessionEvents(), [ - 'modules_shelly_config' => 'Poll root shell events for an edge gateway', - ]); - - $this->post('/edge-gateways/{id}/shell-sessions/{sessionId}/input', fn() => $this->handleShellSessionInput(), [ - 'modules_shelly_config' => 'Send terminal input to an edge gateway root shell', - ]); - - $this->post('/edge-gateways/{id}/shell-sessions/{sessionId}/resize', fn() => $this->handleShellSessionResize(), [ - 'modules_shelly_config' => 'Resize an edge gateway root shell terminal', - ]); - - $this->post('/edge-gateways/{id}/shell-sessions/{sessionId}/close', fn() => $this->handleShellSessionClose(), [ - 'modules_shelly_config' => 'Close an approved edge gateway root shell session', - ]); - - $this->post('/edge-gateways/{id}/rotate-credentials', function () { - global /** @var response $response */ $response; - $this->requirePermission('modules_shelly_config'); - - $gatewayId = (int)$this->fromRoute('id'); - self::requireParameterIntPositive($gatewayId, 'id'); - $manager = new edge_gateway_manager(); - $gateway = $manager->getGateway($gatewayId); - $this->requireDepartmentAccess((int)$gateway['department_id']); - - $user = (new authentication())->get_user(); - $response->success($manager->rotateGatewayCredentials($gatewayId, $user ? (int)$user->id : null)); - }, [ - 'modules_shelly_config' => 'Rotate edge gateway agent credentials', - ]); - - $this->delete('/edge-gateways/{id}', function () { - global /** @var response $response */ $response; - $this->requirePermission('modules_shelly_config'); - - $gatewayId = (int)$this->fromRoute('id'); - self::requireParameterIntPositive($gatewayId, 'id'); - $manager = new edge_gateway_manager(); - $gateway = $manager->getGateway($gatewayId); - $this->requireDepartmentAccess((int)$gateway['department_id']); - - $user = (new authentication())->get_user(); - $response->success($manager->deleteGateway($gatewayId, $user ? (int)$user->id : null)); - }, [ + $this->delete('/edge-gateways/{id}', fn() => $this->handleGatewayDelete(), [ 'modules_shelly_config' => 'Delete an edge gateway registration', ]); - - $this->post('/departments/{id}/gateway-cutover', function () { - global /** @var response $response */ $response; - $this->requirePermission('modules_shelly_config'); - - $departmentId = (int)$this->fromRoute('id'); - self::requireParameterIntPositive($departmentId, 'id'); - $this->requireDepartmentAccess($departmentId); - self::requireParameters(['transport_mode']); - - $user = (new authentication())->get_user(); - $response->success((new edge_gateway_manager())->setDepartmentTransportMode( - $departmentId, - (string)self::getParameter('transport_mode'), - $user ? (int)$user->id : null - )); - }, [ + $this->post('/departments/{id}/gateway-cutover', fn() => $this->handleDepartmentCutover(), [ 'modules_shelly_config' => 'Cut a department over from Shelly cloud to local edge gateways', ]); $this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify()); $this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript()); - $this->get('/edge-agent/artifacts/agent.mjs', fn() => $this->renderAgentArtifact('agent.mjs', 'application/javascript; charset=utf-8')); - $this->get('/edge-agent/artifacts/package.json', fn() => $this->renderAgentArtifact('package.json', 'application/json; charset=utf-8')); + $this->get('/edge-agent/artifacts/agent.php', fn() => $this->renderArtifact('agent.php')); + $this->get('/edge-agent/artifacts/truckwash-edge-agent.service', fn() => $this->renderArtifact('truckwash-edge-agent.service')); $this->post('/edge-agent/claim', fn() => $this->handleAgentClaim()); $this->post('/edge-agent/gateways/{id}/heartbeat', fn() => $this->handleAgentHeartbeat()); + $this->post('/edge-agent/gateways/{id}/operations/next', fn() => $this->handleAgentOperationNext()); + $this->post('/edge-agent/gateways/{id}/operations/{operationId}/events', fn() => $this->handleAgentOperationEvent()); + $this->post('/edge-agent/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleAgentOperationComplete()); $this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll()); $this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult()); - $this->post('/edge-agent/gateways/{id}/shell-actions/poll', fn() => $this->handleAgentShellActionPoll()); - $this->post('/edge-agent/gateways/{id}/shell-actions/{actionId}/result', fn() => $this->handleAgentShellActionResult()); - $this->post('/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events', fn() => $this->handleAgentShellEvents()); - $this->post('/edge-agent/internal/gateways/{id}/validate', fn() => $this->handleBrokerGatewayValidate()); - $this->post('/edge-agent/internal/shell-sessions/validate', fn() => $this->handleBrokerShellValidate()); - $this->post('/edge-agent/internal/gateways/{id}/presence', fn() => $this->handleBrokerGatewayPresence()); - $this->post('/edge-agent/internal/shell-sessions/close', fn() => $this->handleBrokerShellSessionClose()); + $this->post('/edge-agent/gateways/{id}/presence', fn() => $this->handleAgentPresence()); + } + + private function handleListGateways(): void + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + + $departmentId = self::isParametersSet(['department_id']) ? (int)self::getParameter('department_id') : null; + $view = trim((string)$this->fromQuery('view')); + if ($departmentId !== null && $departmentId > 0) { + $this->requireDepartmentAccess($departmentId); + } + + $gateways = $this->views()->listGateways($departmentId, $view !== 'summary'); + $response->add_meta('fleet_usage', $this->views()->buildFleetUsageStatistics($departmentId, $gateways)); + $response->success($gateways); + } + + private function handleGatewayDetail(): void + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + $response->success($this->requireGatewayAccess((int)$this->fromRoute('id'))); + } + + private function handleGatewayUpdate(): void + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + self::requireParameters(['label', 'is_primary']); + self::requireType(self::getParameter('label'), self::TYPE_STRING()); + self::requireType(self::getParameter('is_primary'), self::TYPE_BOOL()); + + $gatewayId = (int)$this->fromRoute('id'); + $this->requireGatewayAccess($gatewayId); + + $response->success($this->registry()->updateGatewayMetadata($gatewayId, [ + 'label' => (string)self::getParameter('label'), + 'is_primary' => (bool)self::getParameter('is_primary'), + ], $this->actorUserId())); + } + + private function handleGatewayOperationsList(): void + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + $gatewayId = (int)$this->fromRoute('id'); + $this->requireGatewayAccess($gatewayId); + $response->success($this->operations()->listOperations($gatewayId)); + } + + private function handleGatewayOperationCreate(): void + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + self::requireParameters(['type', 'request']); + self::requireType(self::getParameter('type'), self::TYPE_STRING()); + self::requireType(self::getParameter('request'), self::TYPE_ARRAY()); + + $gatewayId = (int)$this->fromRoute('id'); + $this->requireGatewayAccess($gatewayId); + + try { + $operation = $this->operations()->queueOperation( + $gatewayId, + (string)self::getParameter('type'), + (array)self::getParameter('request'), + $this->actorUserId() + ); + $response->success([ + 'operation' => $operation, + 'gateway' => $this->views()->getGateway($gatewayId), + ], 201); + } catch (edge_gateway_operation_exception $exception) { + $response->error([ + 'message' => $exception->getMessage(), + 'error_code' => $exception->errorCode, + ], $exception->status); + } + } + + private function handleGatewayOperationEvents(): void + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + $gatewayId = (int)$this->fromRoute('id'); + $operationId = (int)$this->fromRoute('operationId'); + self::requireParameterIntPositive($operationId, 'operationId'); + $this->requireGatewayAccess($gatewayId); + $response->success($this->operations()->listOperationEvents($gatewayId, $operationId)); + } + + private function handleGatewayCredentialRotate(): void + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + $gatewayId = (int)$this->fromRoute('id'); + $this->requireGatewayAccess($gatewayId); + $response->success($this->operations()->rotateCredentials($gatewayId, $this->actorUserId())); + } + + private function handleInstallTokenCreate(): void + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + self::requireParameters(['department_id']); + + $departmentId = (int)self::getParameter('department_id'); + self::requireParameterIntPositive($departmentId, 'department_id'); + $this->requireDepartmentAccess($departmentId); + + $response->success( + $this->registry()->createInstallToken( + $departmentId, + self::isParametersSet(['label']) ? (string)self::getParameter('label') : null, + $this->actorUserId() + ), + 201 + ); + } + + private function handleGatewayDiscovery(): void + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + + $gatewayId = (int)$this->fromRoute('id'); + $this->requireGatewayAccess($gatewayId); + + $this->operations()->queueDiscoveryOperation($gatewayId, $this->actorUserId()); + $response->success($this->views()->getGateway($gatewayId)); + } + + private function handleBindingsUpdate(): void + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + self::requireParameters(['bindings']); + self::requireType(self::getParameter('bindings'), self::TYPE_ARRAY()); + + $gatewayId = (int)$this->fromRoute('id'); + $this->requireGatewayAccess($gatewayId); + $this->registry()->setRelayBindings($gatewayId, (array)self::getParameter('bindings'), $this->actorUserId()); + $response->success($this->views()->getGateway($gatewayId)); + } + + private function handleGatewayDelete(): void + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + $gatewayId = (int)$this->fromRoute('id'); + $this->requireGatewayAccess($gatewayId); + $response->success($this->registry()->deleteGateway($gatewayId, $this->actorUserId())); + } + + private function handleDepartmentCutover(): void + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + self::requireParameters(['transport_mode']); + $departmentId = (int)$this->fromRoute('id'); + self::requireParameterIntPositive($departmentId, 'id'); + $this->requireDepartmentAccess($departmentId); + $response->success($this->registry()->setDepartmentTransportMode($departmentId, (string)self::getParameter('transport_mode'), $this->actorUserId())); } private function renderInstallScript(): void { - $manager = new edge_gateway_manager(); $token = trim((string)$this->fromQuery('token')); if ($token === '') { http_response_code(400); @@ -260,7 +244,7 @@ class edgeGatewaysRoute } header('Content-Type: text/x-shellscript; charset=utf-8'); - echo $manager->buildInstallScript($token); + echo $this->install()->buildInstallScript($token); exit; } @@ -272,21 +256,20 @@ class edgeGatewaysRoute $response->error('Missing token', 400); } - $response->success((new edge_gateway_manager())->verifyInstallToken($token)); + $response->success($this->install()->verifyInstallToken($token)); } - private function renderAgentArtifact(string $fileName, string $contentType): void + private function renderArtifact(string $fileName): void { - $artifactPath = dirname(WD, 3) . '/services/edge-agent/dist/' . $fileName; - if (!is_file($artifactPath)) { + try { + header('Content-Type: ' . $this->install()->contentType($fileName)); + echo $this->install()->readArtifact($fileName); + exit; + } catch (Exception $exception) { http_response_code(404); - echo 'Missing edge agent artifact'; + echo $exception->getMessage(); exit; } - - header('Content-Type: ' . $contentType); - echo file_get_contents($artifactPath); - exit; } private function handleAgentClaim(): void @@ -294,40 +277,103 @@ class edgeGatewaysRoute global /** @var response $response */ $response; self::requireParameters(['token']); $payload = self::getParametersAsArray(); - $response->success((new edge_gateway_manager())->claimGateway( - (string)$payload['token'], - trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')), - isset($payload['installed_version']) ? (string)$payload['installed_version'] : null, - (array)($payload['metadata'] ?? []) - ), 201); + + $response->success( + $this->registry()->claimGateway( + (string)$payload['token'], + trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')), + isset($payload['installed_version']) ? (string)$payload['installed_version'] : null, + isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : [] + ), + 201 + ); } private function handleAgentHeartbeat(): void { global /** @var response $response */ $response; $gatewayId = (int)$this->fromRoute('id'); - self::requireParameterIntPositive($gatewayId, 'id'); $payload = self::getParametersAsArray(); - $token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token'))); - if ($token === '') { - $response->error('Missing edge gateway agent token', 401); + $response->success($this->registry()->recordHeartbeat($gatewayId, $this->requireAgentToken($payload), $payload)); + } + + private function handleAgentOperationNext(): void + { + global /** @var response $response */ $response; + $gatewayId = (int)$this->fromRoute('id'); + $payload = self::getParametersAsArray(); + + try { + $response->success($this->operations()->claimNextOperation( + $gatewayId, + $this->requireAgentToken($payload), + isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : edge_gateway_manager::COMMAND_POLL_TIMEOUT_SECONDS, + isset($payload['agent_instance_id']) ? (string)$payload['agent_instance_id'] : null + )); + } catch (edge_gateway_operation_exception $exception) { + $response->error([ + 'message' => $exception->getMessage(), + 'error_code' => $exception->errorCode, + ], $exception->status); + } + } + + private function handleAgentOperationEvent(): void + { + global /** @var response $response */ $response; + $gatewayId = (int)$this->fromRoute('id'); + $operationId = (int)$this->fromRoute('operationId'); + self::requireParameterIntPositive($operationId, 'operationId'); + $payload = self::getParametersAsArray(); + + try { + $response->success($this->operations()->appendAgentOperationEvent( + $gatewayId, + $operationId, + $this->requireAgentToken($payload), + $payload + )); + } catch (edge_gateway_operation_exception $exception) { + $response->error([ + 'message' => $exception->getMessage(), + 'error_code' => $exception->errorCode, + ], $exception->status); + } + } + + private function handleAgentOperationComplete(): void + { + global /** @var response $response */ $response; + $gatewayId = (int)$this->fromRoute('id'); + $operationId = (int)$this->fromRoute('operationId'); + self::requireParameterIntPositive($operationId, 'operationId'); + $payload = self::getParametersAsArray(); + + try { + $response->success($this->operations()->completeAgentOperation( + $gatewayId, + $operationId, + $this->requireAgentToken($payload), + $payload + )); + } catch (edge_gateway_operation_exception $exception) { + $response->error([ + 'message' => $exception->getMessage(), + 'error_code' => $exception->errorCode, + ], $exception->status); } - $response->success((new edge_gateway_manager())->recordHeartbeat($gatewayId, $token, $payload)); } private function handleAgentCommandPoll(): void { global /** @var response $response */ $response; $gatewayId = (int)$this->fromRoute('id'); - self::requireParameterIntPositive($gatewayId, 'id'); $payload = self::getParametersAsArray(); - $token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token'))); - if ($token === '') { - $response->error('Missing edge gateway agent token', 401); - } - - $waitSeconds = isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : edge_gateway_manager::COMMAND_POLL_TIMEOUT_SECONDS; - $response->success((new edge_gateway_manager())->pollCommand($gatewayId, $token, $waitSeconds)); + $response->success($this->manager()->pollCommand( + $gatewayId, + $this->requireAgentToken($payload), + isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : edge_gateway_manager::COMMAND_POLL_TIMEOUT_SECONDS + )); } private function handleAgentCommandResult(): void @@ -335,256 +381,83 @@ class edgeGatewaysRoute global /** @var response $response */ $response; $gatewayId = (int)$this->fromRoute('id'); $jobId = (int)$this->fromRoute('jobId'); - self::requireParameterIntPositive($gatewayId, 'id'); self::requireParameterIntPositive($jobId, 'jobId'); $payload = self::getParametersAsArray(); - $token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token'))); - if ($token === '') { - $response->error('Missing edge gateway agent token', 401); - } - - $response->success((new edge_gateway_manager())->submitCommandResult( + $response->success($this->manager()->submitCommandResult( $gatewayId, $jobId, - $token, + $this->requireAgentToken($payload), (bool)($payload['ok'] ?? false), - isset($payload['payload']) && is_array($payload['payload']) ? (array)$payload['payload'] : [], + isset($payload['result']) && is_array($payload['result']) ? (array)$payload['result'] : [], isset($payload['error']) ? (string)$payload['error'] : null )); } - private function handleShellSessionEvents(): void - { - global /** @var response $response */ $response; - $this->requirePermission('modules_shelly_config'); - - $gatewayId = (int)$this->fromRoute('id'); - $sessionId = (int)$this->fromRoute('sessionId'); - self::requireParameterIntPositive($gatewayId, 'id'); - self::requireParameterIntPositive($sessionId, 'sessionId'); - - $manager = new edge_gateway_manager(); - $gateway = $manager->getGateway($gatewayId); - $this->requireDepartmentAccess((int)$gateway['department_id']); - - $afterId = max(0, (int)$this->fromQuery('after_id')); - $waitSeconds = (int)$this->fromQuery('wait_seconds'); - if ($waitSeconds <= 0) { - $waitSeconds = edge_gateway_manager::SHELL_EVENT_POLL_TIMEOUT_SECONDS; - } - - $response->success($manager->pollShellSessionEvents($gatewayId, $sessionId, $afterId, $waitSeconds)); - } - - private function handleShellSessionInput(): void - { - global /** @var response $response */ $response; - $this->requirePermission('modules_shelly_config'); - self::requireParameters(['data']); - - $gatewayId = (int)$this->fromRoute('id'); - $sessionId = (int)$this->fromRoute('sessionId'); - self::requireParameterIntPositive($gatewayId, 'id'); - self::requireParameterIntPositive($sessionId, 'sessionId'); - - $manager = new edge_gateway_manager(); - $gateway = $manager->getGateway($gatewayId); - $this->requireDepartmentAccess((int)$gateway['department_id']); - - $user = (new authentication())->get_user(); - $response->success($manager->enqueueShellInput( - $gatewayId, - $sessionId, - (string)self::getParameter('data'), - $user ? (int)$user->id : null - )); - } - - private function handleShellSessionResize(): void - { - global /** @var response $response */ $response; - $this->requirePermission('modules_shelly_config'); - self::requireParameters(['cols', 'rows']); - - $gatewayId = (int)$this->fromRoute('id'); - $sessionId = (int)$this->fromRoute('sessionId'); - self::requireParameterIntPositive($gatewayId, 'id'); - self::requireParameterIntPositive($sessionId, 'sessionId'); - - $manager = new edge_gateway_manager(); - $gateway = $manager->getGateway($gatewayId); - $this->requireDepartmentAccess((int)$gateway['department_id']); - - $user = (new authentication())->get_user(); - $response->success($manager->enqueueShellResize( - $gatewayId, - $sessionId, - (int)self::getParameter('cols'), - (int)self::getParameter('rows'), - $user ? (int)$user->id : null - )); - } - - private function handleShellSessionClose(): void - { - global /** @var response $response */ $response; - $this->requirePermission('modules_shelly_config'); - - $gatewayId = (int)$this->fromRoute('id'); - $sessionId = (int)$this->fromRoute('sessionId'); - self::requireParameterIntPositive($gatewayId, 'id'); - self::requireParameterIntPositive($sessionId, 'sessionId'); - - $manager = new edge_gateway_manager(); - $gateway = $manager->getGateway($gatewayId); - $this->requireDepartmentAccess((int)$gateway['department_id']); - - $user = (new authentication())->get_user(); - $response->success($manager->requestShellSessionClose( - $gatewayId, - $sessionId, - $user ? (int)$user->id : null - )); - } - - private function handleAgentShellActionPoll(): void + private function handleAgentPresence(): void { global /** @var response $response */ $response; $gatewayId = (int)$this->fromRoute('id'); - self::requireParameterIntPositive($gatewayId, 'id'); - $payload = self::getParametersAsArray(); - $token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token'))); - if ($token === '') { - $response->error('Missing edge gateway agent token', 401); - } - $waitSeconds = isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : edge_gateway_manager::SHELL_ACTION_POLL_TIMEOUT_SECONDS; - $response->success((new edge_gateway_manager())->pollShellAction($gatewayId, $token, $waitSeconds)); - } + $this->requireAgentToken($payload); - private function handleAgentShellActionResult(): void - { - global /** @var response $response */ $response; - $gatewayId = (int)$this->fromRoute('id'); - $actionId = (int)$this->fromRoute('actionId'); - self::requireParameterIntPositive($gatewayId, 'id'); - self::requireParameterIntPositive($actionId, 'actionId'); - - $payload = self::getParametersAsArray(); - $token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token'))); - if ($token === '') { - $response->error('Missing edge gateway agent token', 401); - } - - $response->success((new edge_gateway_manager())->submitShellActionResult( + $response->success($this->manager()->recordBrokerPresence( $gatewayId, - $actionId, - $token, - (bool)($payload['ok'] ?? false), - isset($payload['error']) ? (string)$payload['error'] : null - )); - } - - private function handleAgentShellEvents(): void - { - global /** @var response $response */ $response; - $gatewayId = (int)$this->fromRoute('id'); - $sessionId = (int)$this->fromRoute('sessionId'); - self::requireParameterIntPositive($gatewayId, 'id'); - self::requireParameterIntPositive($sessionId, 'sessionId'); - - $payload = self::getParametersAsArray(); - $token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token'))); - if ($token === '') { - $response->error('Missing edge gateway agent token', 401); - } - - $events = isset($payload['events']) && is_array($payload['events']) - ? (array)$payload['events'] - : (isset($payload['event']) && is_array($payload['event']) ? [(array)$payload['event']] : []); - - $response->success((new edge_gateway_manager())->recordShellSessionEvents( - $gatewayId, - $sessionId, - $token, - $events - )); - } - - private function handleBrokerGatewayValidate(): void - { - global /** @var response $response */ $response; - $this->requireBrokerSharedSecret(); - - $gatewayId = (int)$this->fromRoute('id'); - self::requireParameterIntPositive($gatewayId, 'id'); - $payload = self::getParametersAsArray(); - self::requireParameters(['token']); - - $response->success((new edge_gateway_manager())->validateBrokerAgentConnection( - $gatewayId, - (string)$payload['token'] - )); - } - - private function handleBrokerShellValidate(): void - { - global /** @var response $response */ $response; - $this->requireBrokerSharedSecret(); - - $payload = self::getParametersAsArray(); - self::requireParameters(['token']); - - $response->success((new edge_gateway_manager())->validateShellSessionToken((string)$payload['token'])); - } - - private function handleBrokerGatewayPresence(): void - { - global /** @var response $response */ $response; - $this->requireBrokerSharedSecret(); - - $gatewayId = (int)$this->fromRoute('id'); - self::requireParameterIntPositive($gatewayId, 'id'); - $payload = self::getParametersAsArray(); - self::requireParameters(['status']); - - $response->success((new edge_gateway_manager())->recordBrokerPresence( - $gatewayId, - (string)$payload['status'], + isset($payload['status']) ? (string)$payload['status'] : 'disconnected', isset($payload['connection_id']) ? (string)$payload['connection_id'] : null, isset($payload['reason']) ? (string)$payload['reason'] : null, isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : [] )); } - private function handleBrokerShellSessionClose(): void + private function requireGatewayAccess(int $gatewayId): array { - global /** @var response $response */ $response; - $this->requireBrokerSharedSecret(); - - $payload = self::getParametersAsArray(); - self::requireParameters(['token']); - - $response->success((new edge_gateway_manager())->closeShellSession( - (string)$payload['token'], - isset($payload['transcript']) ? (string)$payload['transcript'] : '', - isset($payload['reason']) ? (string)$payload['reason'] : 'broker_closed' - )); + self::requireParameterIntPositive($gatewayId, 'id'); + $gateway = $this->views()->getGateway($gatewayId); + $this->requireDepartmentAccess((int)$gateway['department_id']); + return $gateway; } - private function requireBrokerSharedSecret(): void + private function requireAgentToken(array $payload): string { global /** @var response $response */ $response; - $expected = trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: '')); - if ($expected === '') { - $response->error('Edge broker secret is not configured', 503); + $token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token'))); + if ($token === '') { + $response->error('Missing edge gateway agent token', 401); } - $provided = trim((string)($_SERVER['HTTP_X_EDGE_BROKER_SECRET'] ?? $this->fromRequest('broker_secret'))); - if ($provided === '' || !hash_equals($expected, $provided)) { - $response->error('Forbidden', 403); - } + return $token; + } + + private function actorUserId(): ?int + { + $user = (new authentication())->get_user(); + return $user ? (int)$user->id : null; + } + + private function views(): edge_gateway_view_service + { + return new edge_gateway_view_service(); + } + + private function registry(): edge_gateway_registry_service + { + return new edge_gateway_registry_service(); + } + + private function manager(): edge_gateway_manager + { + return new edge_gateway_manager(); + } + + private function operations(): edge_gateway_operation_service + { + return new edge_gateway_operation_service($this->manager()); + } + + private function install(): edge_gateway_install_service + { + return new edge_gateway_install_service(); } } diff --git a/services/nginx/app/routes/moduleStripeRoute.php b/services/nginx/app/routes/moduleStripeRoute.php index 5098930c..7c1c1e5f 100644 --- a/services/nginx/app/routes/moduleStripeRoute.php +++ b/services/nginx/app/routes/moduleStripeRoute.php @@ -101,6 +101,30 @@ class moduleStripeRoute // Check if the order exists $order = (new orders_o())->select((int)self::fromRequest('order_id')); $order->requireSelected(); + if ($order->stripe_module_orders->exists()) { + $existingInvoice = []; + $shouldBlockInvoiceCreation = true; + try { + $existingInvoice = $order->stripe_module_orders->asArray(); + $retrievedInvoice = $order->stripe_module_orders->retrievePaymentLink(); + $existingStatus = (string)($retrievedInvoice->status ?? ''); + $isTerminalInvoiceState = (bool)($retrievedInvoice->paid ?? false) === true + || in_array($existingStatus, ['paid', 'void', 'uncollectible', 'deleted'], true); + $shouldBlockInvoiceCreation = !$isTerminalInvoiceState; + } catch (\Throwable) { + $shouldBlockInvoiceCreation = false; + } + + if ($shouldBlockInvoiceCreation) { + $response->error([ + 'message' => 'A Stripe payment link is already active for this order.', + 'code' => 'stripe_invoice_exists', + 'stripeModuleOrders' => $existingInvoice, + ], 409); + } + + $order->clearStripeInvoicing(); + } // Log the action (new logs_o())->add('modules_stripe', 'global', 1, $user->id, 'MODULES_STRIPE', 'User sent an invoice'); // Create a customer account, if it doesn't exist @@ -141,6 +165,56 @@ class moduleStripeRoute ] ); + /** Modules > Stripe > Cancel Invoice */ + $this->delete('/modules/stripe/invoice', function () { + global $response; + self::requirePermission('modules_stripe_invoice_send'); + $user = (new authentication())->get_user(); + if (!$user) { + (new logs_o())->add('modules_stripe', 'global', 0, 0, 'MODULES_STRIPE', 'User tried to cancel a Stripe invoice without a valid session'); + $response->error('Invalid session', 400); + } + + self::requireParameters(['order_id']); + self::requireType((int)self::fromRequest('order_id'), self::TYPE_INT()); + self::requireMinLength('order_id', 1); + self::requireMaxLength('order_id', 255); + + $order = (new orders_o())->select((int)self::fromRequest('order_id')); + $order->requireSelected(); + + if (!$order->stripe_module_orders->exists()) { + $response->success([ + 'stripeModuleOrders' => [], + ]); + } + + $invoice = $order->stripe_module_orders->retrievePaymentLink(); + if ((bool)($invoice->paid ?? false) === true) { + $response->error([ + 'message' => 'A paid Stripe payment link cannot be cancelled.', + 'code' => 'stripe_invoice_paid', + 'stripeModuleOrders' => $order->stripe_module_orders->asArray(), + ], 409); + } + + $invoiceStatus = (string)($invoice->status ?? ''); + if ($invoiceStatus !== 'void' && $invoiceStatus !== 'uncollectible' && $invoiceStatus !== 'deleted') { + (new stripe())->invoice->void((string)$order->stripe_module_orders->invoice_id->value()); + } + + $order->clearStripeInvoicing(); + (new logs_o())->add('modules_stripe', 'global', 1, $user->id, 'MODULES_STRIPE', 'User cancelled a Stripe invoice'); + + $response->success([ + 'stripeModuleOrders' => [], + ]); + }, + [ + 'modules_stripe_invoice_send' => 'Send invoice' + ] + ); + self::get('/modules/stripe/terminal/readers', function () { global $response; self::requirePermission('modules_stripe_terminal_readers_list'); diff --git a/services/nginx/app/routes/moduleWeatherAPIRoute.php b/services/nginx/app/routes/moduleWeatherAPIRoute.php index 055190ee..e61a5cd9 100644 --- a/services/nginx/app/routes/moduleWeatherAPIRoute.php +++ b/services/nginx/app/routes/moduleWeatherAPIRoute.php @@ -7,6 +7,7 @@ use classes\response; use classes\router; use classes\weatherapi; use classes\workfeed; +use classes\workfeed_shift_time_resolver; use DateInterval; use DateTime; use DateTimeZone; @@ -1634,7 +1635,12 @@ class moduleWeatherAPIRoute return $update_time; } - private function calculateWorkfeedEmployeeHoursForHour(array $shifts, string|array $workfeed_department_ids, DateTime $slot_start): float + private function calculateWorkfeedEmployeeHoursForHour( + array $shifts, + string|array $workfeed_department_ids, + DateTime $slot_start, + ?DateTime $occurred_until = null + ): float { $department_id_values = is_array($workfeed_department_ids) ? $workfeed_department_ids : [$workfeed_department_ids]; $department_id_lookup = []; @@ -1652,6 +1658,7 @@ class moduleWeatherAPIRoute $slot_end->add(new DateInterval('PT1H')); $slot_start_ts = $slot_start->getTimestamp(); $slot_end_ts = $slot_end->getTimestamp(); + $occurred_until_ts = ($occurred_until ?? new DateTime())->getTimestamp(); $hours = 0.0; foreach ($shifts as $shift) { @@ -1660,36 +1667,13 @@ class moduleWeatherAPIRoute continue; } - $record = self::normalizeWorkfeedRecord($shift); - $shift_start = self::firstShiftDateTimeFromPaths($record, [ - 'actualStart', - 'actualStartTime', - 'clockIn', - 'clockInTime', - 'start', - 'startTime', - 'from', - 'approval.originalStart', - ]); - $shift_end = self::lastShiftDateTimeFromPaths($record, [ - 'actualEnd', - 'actualEndTime', - 'clockOut', - 'clockOutTime', - 'end', - 'endTime', - 'to', - 'approval.originalEnd', - 'approval.end', - ]); - if ($shift_start === null || $shift_end === null) { + $timing = workfeed_shift_time_resolver::resolveShiftTiming($shift); + if ($timing === null) { continue; } - $shift_end = self::resolveEffectiveShiftEnd($record, $shift_start, $shift_end); - - $shift_start_ts = $shift_start->getTimestamp(); - $shift_end_ts = $shift_end->getTimestamp(); + $shift_start_ts = $timing['actualStart']->getTimestamp(); + $shift_end_ts = min($timing['actualEnd']->getTimestamp(), $occurred_until_ts); if ($shift_end_ts <= $shift_start_ts) { continue; } diff --git a/services/nginx/app/routes/orderBookingRoute.php b/services/nginx/app/routes/orderBookingRoute.php index adebd58d..c7b672cc 100644 --- a/services/nginx/app/routes/orderBookingRoute.php +++ b/services/nginx/app/routes/orderBookingRoute.php @@ -3,6 +3,8 @@ namespace routes; use classes\authentication; +use classes\order_bookings_counts_cache; +use classes\order_bookings_list_cache; use classes\redis; use Exception; use modules\subusers\helpers\subusers_permission_node_key; @@ -132,17 +134,41 @@ class orderBookingRoute */ $object = new order_bookings_o(); // New object for listing $effectiveCustomer = self::resolveEffectiveCustomerNumber(); - $response->success($object->listObjectsWithPaginationIfSet( + $forcedFilters = $object->forceRestrictFilters([ + ...($has_permission_other && $user !== false ? [ + 'department' => $user->getGroup()->getDepartments() + ] : []), + ...(!$has_permission_other && $effectiveCustomer !== null ? [ + 'customer_number' => [(int)$effectiveCustomer] + ] : []) + ]); + + $cacheTtl = order_bookings_list_cache::getTtl(); + $cacheKey = $cacheTtl > 0 ? $this->getOrderBookingsListCacheKey($object, $forcedFilters) : null; + if ($cacheKey !== null) { + $cachedPayload = order_bookings_list_cache::getPayload($cacheKey); + if ($cachedPayload !== null) { + $response->rawJson($cachedPayload); + } + } + + $result = $object->listObjectsWithPaginationIfSet( function ($booking) { return (new order_bookings_o())->select((int)$booking['id'])->asArray(); }, - $object->forceRestrictFilters([ - ...($has_permission_other && $user !== false ? [ - 'department' => $user->getGroup()->getDepartments() - ] : []), - ...(!$has_permission_other && $effectiveCustomer !== null ? [ - 'customer_number' => [(int)$effectiveCustomer] - ] : []) - ]) - )); + $forcedFilters + ); + + $payload = [ + 'success' => true, + 'data' => $result, + 'meta' => $response->get_meta(), + 'includes' => $response->get_includes(), + ]; + + if ($cacheKey !== null) { + order_bookings_list_cache::storePayload($cacheKey, $payload, $cacheTtl); + } + + $response->rawJson($payload); }, [ 'list_own_bookings' => 'Permission to list own order bookings.', @@ -150,6 +176,77 @@ class orderBookingRoute ] ); + $this->get('/order-bookings/counts', function () { + global $response; + + $auth = new authentication(); + $user = $auth->get_user(); + + $permission_own = self::definePermission('list_own_bookings', subusers_permission_node_key::BOOKINGS_LIST); + $permission_other = self::definePermission('list_bookings'); + $hasPermissionOwn = self::hasPermission($permission_own); + $hasPermissionOther = self::hasPermission($permission_other); + + if (!$hasPermissionOwn && !$hasPermissionOther) { + $this->emitForbidden([$permission_own, $permission_other]); + } + + $requestedDepartmentId = $this->getTargetCountDepartmentId(); + if ($hasPermissionOther && $requestedDepartmentId !== null) { + self::requireDepartmentAccess((string)$requestedDepartmentId); + } + + $effectiveCustomer = null; + if (!$hasPermissionOther) { + $effectiveCustomer = self::resolveEffectiveCustomerNumber(); + if ($effectiveCustomer === null || $effectiveCustomer < 1) { + $this->emitForbidden([$permission_other]); + } + } + + $departmentIds = null; + if ($requestedDepartmentId !== null) { + $departmentIds = [$requestedDepartmentId]; + } elseif ($hasPermissionOther && $user !== false) { + $departmentIds = array_map('intval', $user->getGroup()->getDepartments()); + if ($departmentIds === []) { + $response->success([ + 'past' => 0, + 'current' => 0, + 'future' => 0, + ]); + } + } + + $cacheTtl = order_bookings_counts_cache::getTtl(); + $cacheKey = $cacheTtl > 0 + ? $this->getOrderBookingsCountsCacheKey($departmentIds, $effectiveCustomer) + : null; + + if ($cacheKey !== null) { + $cachedCounts = order_bookings_counts_cache::getCounts($cacheKey); + if ($cachedCounts !== null) { + $response->success($cachedCounts); + } + } + + $counts = (new order_bookings_o())->getPendingBookingCounts( + $departmentIds, + $effectiveCustomer + ); + + if ($cacheKey !== null) { + order_bookings_counts_cache::storeCounts($cacheKey, $counts, $cacheTtl); + } + + $response->success($counts); + }, + [ + 'list_own_bookings' => 'Permission to list own order booking counts.', + 'list_bookings' => 'Permission to list department order booking counts.', + ] + ); + $this->put('/order-bookings', function () { // Require the user to be logged in global $response; @@ -300,6 +397,83 @@ class orderBookingRoute } + private function getOrderBookingsListCacheKey(order_bookings_o $object, string $forcedFilters): string + { + global $response; + + $page = (int)($response->getRequestParameter('page') ?? 0); + if ($page < 1) { + $page = 1; + } + + $limit = (int)($response->getRequestParameter('limit') ?? 0); + if ($limit < 1) { + $limit = 1000; + } + + return order_bookings_list_cache::buildKey([ + 'route' => '/order-bookings', + 'page' => $page, + 'limit' => $limit, + 'search' => (string)($response->getRequestParameter('search') ?? ''), + 'order' => $this->normalizeOrderBookingsListCacheOrder((string)($response->getRequestParameter('order') ?? 'id:ASC')), + 'filters' => $object->filter_string_to_array($forcedFilters), + ]); + } + + private function getOrderBookingsCountsCacheKey(?array $departmentIds, ?int $customerNumber): string + { + return order_bookings_counts_cache::buildKey([ + 'route' => '/order-bookings/counts', + 'day' => (new \DateTimeImmutable('now'))->format('Y-m-d'), + 'department_ids' => $departmentIds ?? [], + 'customer_number' => $customerNumber, + ]); + } + + private function normalizeOrderBookingsListCacheOrder(string $order): array + { + $normalized = trim($order); + if ($normalized === '') { + $normalized = 'id:ASC'; + } + + $parts = array_pad(explode(':', $normalized, 2), 2, 'ASC'); + $field = trim((string)$parts[0]); + $direction = strtoupper(trim((string)$parts[1])); + + if ($field === '') { + $field = 'id'; + } + if ($direction === '') { + $direction = 'ASC'; + } + + return [$field => $direction]; + } + + private function getTargetCountDepartmentId(): ?int + { + global $response; + + $parameter = 'department'; + $rawValue = $this->fromRequest($parameter); + if ($rawValue === null || $rawValue === '') { + return null; + } + + if (!is_numeric($rawValue)) { + $response->error('Invalid department', 400); + } + + $value = (int)$rawValue; + if ($value < 1 || $value > 999999999) { + $response->error('Invalid department', 400); + } + + return $value; + } + /** * @throws Exception */ diff --git a/services/nginx/app/routes/orderInvoicesRoute.php b/services/nginx/app/routes/orderInvoicesRoute.php index 54c295f5..7e71cbd5 100644 --- a/services/nginx/app/routes/orderInvoicesRoute.php +++ b/services/nginx/app/routes/orderInvoicesRoute.php @@ -557,6 +557,11 @@ class orderInvoicesRoute $collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id')); $collected_order_invoices->requireSelected(); + try { + $this->assertCollectedInvoiceCanBeExportedToEconomic($collected_order_invoices); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 400); + } if ($collected_order_invoices->booked_invoice_id->value() !== null) { $response->error('Invoice has already been booked', 400); @@ -992,6 +997,11 @@ class orderInvoicesRoute // Validate the ID against the database $collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id')); $collected_order_invoices->requireSelected(); + try { + $this->assertCollectedInvoiceCanBeExportedToEconomic($collected_order_invoices); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 400); + } // Require the external ID to be set if ($collected_order_invoices->external_id->value() === null) { $response->error('Transaction has not been created in Stripe', 400); @@ -1782,6 +1792,8 @@ class orderInvoicesRoute */ private function exportCollectedInvoiceSynchronously(collected_order_invoices_o $collected_order_invoices, bool $send_as_is): array { + $this->assertCollectedInvoiceCanBeExportedToEconomic($collected_order_invoices); + if ($collected_order_invoices->external_id->value() === null) { if (!$send_as_is) { $customer_fixed_pricing_o = new customer_fixed_pricing_o(); @@ -2023,6 +2035,15 @@ class orderInvoicesRoute }, $jobs)); } + /** + * @throws Exception + */ + private function assertCollectedInvoiceCanBeExportedToEconomic(collected_order_invoices_o $collected_order_invoices): void + { + $collected_order_invoices->requireSelected(); + (new economic())->assertCustomerNumberIsNotDraft((int)$collected_order_invoices->customer_number->value()); + } + /** * @param $collected_order_invoice * @param users_o $users diff --git a/services/nginx/app/routes/ordersRoute.php b/services/nginx/app/routes/ordersRoute.php index b49df00d..cf02a459 100644 --- a/services/nginx/app/routes/ordersRoute.php +++ b/services/nginx/app/routes/ordersRoute.php @@ -6,6 +6,7 @@ use attachments\helpers\attachment_content; use classes\attachment_store; use classes\attachments; use classes\authentication; +use classes\economic; use classes\orders_input_normalizer; use classes\response; use classes\stripe; @@ -986,19 +987,26 @@ class ordersRoute break; } }; + $shouldRefreshAttachedWashCertificate = false; // PO if (isset($data['po'])) { $order->po->set((string)$data['po']); } // Registration numbers if (isset($data['reg_1'])) { - $order->reg_1->set($this->normalizeRegistrationNumberOrError($data['reg_1'])); + $normalizedReg1 = $this->normalizeRegistrationNumberOrError($data['reg_1']); + $shouldRefreshAttachedWashCertificate = true; + $order->reg_1->set($normalizedReg1); } if (isset($data['reg_2'])) { - $order->reg_2->set($this->normalizeRegistrationNumberOrError($data['reg_2'])); + $normalizedReg2 = $this->normalizeRegistrationNumberOrError($data['reg_2']); + $shouldRefreshAttachedWashCertificate = true; + $order->reg_2->set($normalizedReg2); } if (isset($data['reg_3'])) { - $order->reg_3->set($this->normalizeRegistrationNumberOrError($data['reg_3'])); + $normalizedReg3 = $this->normalizeRegistrationNumberOrError($data['reg_3']); + $shouldRefreshAttachedWashCertificate = true; + $order->reg_3->set($normalizedReg3); } // Reference if (isset($data['reference'])) { @@ -1009,7 +1017,12 @@ class ordersRoute $order->notes->set((string)$data['notes']); } if (array_key_exists('safety_seal', $data)) { - $order->setSafetySealValue($data['safety_seal']); + $normalizedSafetySeal = orders_o::normalizeSafetySealValue($data['safety_seal']); + $shouldRefreshAttachedWashCertificate = true; + $order->setSafetySealValue($normalizedSafetySeal); + } + if ($shouldRefreshAttachedWashCertificate) { + $order->regenerateAttachedWashCertificate(); } // Register the change $order->objectChanged(); @@ -1019,12 +1032,22 @@ class ordersRoute // Admin/department path (requires edit_order) self::requirePermission($permission_other); /** Departmental access */ + $originalCustomerNumber = (int)$order->customer_id->value(); + $newCustomerNumber = $originalCustomerNumber; + $shouldAutoReassignInvoiceCollection = false; + $shouldRefreshAttachedWashCertificate = false; // If the customer ID is set, validate it if (isset($data['customer_id'])) { if (!(new users_o())->getUserByCustomerNumber((int)$data['customer_id'])->exists() || empty($data['customer_id'])) { $response->error('Customer not found or invalid', 400); } - $order->customer_id->set((int)$data['customer_id']); + $newCustomerNumber = (int)$data['customer_id']; + $shouldRefreshAttachedWashCertificate = true; + $shouldAutoReassignInvoiceCollection = $this->shouldAutoReassignInvoiceCollectionForDraftTransition( + $originalCustomerNumber, + $newCustomerNumber + ); + $order->customer_id->set($newCustomerNumber); } // If the reference is set, validate it if (isset($data['reference'])) { @@ -1036,22 +1059,30 @@ class ordersRoute } // If the registration number is set, validate it if (isset($data['reg_1'])) { - $order->reg_1->set($this->normalizeRegistrationNumberOrError($data['reg_1'])); + $normalizedReg1 = $this->normalizeRegistrationNumberOrError($data['reg_1']); + $shouldRefreshAttachedWashCertificate = true; + $order->reg_1->set($normalizedReg1); } // If the registration number 2 is set, validate it if (isset($data['reg_2'])) { - $order->reg_2->set($this->normalizeRegistrationNumberOrError($data['reg_2'])); + $normalizedReg2 = $this->normalizeRegistrationNumberOrError($data['reg_2']); + $shouldRefreshAttachedWashCertificate = true; + $order->reg_2->set($normalizedReg2); } // If the registration number 3 is set, validate it if (isset($data['reg_3'])) { - $order->reg_3->set($this->normalizeRegistrationNumberOrError($data['reg_3'])); + $normalizedReg3 = $this->normalizeRegistrationNumberOrError($data['reg_3']); + $shouldRefreshAttachedWashCertificate = true; + $order->reg_3->set($normalizedReg3); } // If the PO is set, validate it if (isset($data['po'])) { $order->po->set((string)$data['po']); } if (array_key_exists('safety_seal', $data)) { - $order->setSafetySealValue($data['safety_seal']); + $normalizedSafetySeal = orders_o::normalizeSafetySealValue($data['safety_seal']); + $shouldRefreshAttachedWashCertificate = true; + $order->setSafetySealValue($normalizedSafetySeal); } // If the lane is set, validate it if (isset($data['lane'])) { @@ -1069,7 +1100,7 @@ class ordersRoute $order->booking_id->set((int)$data['booking_id']); } // Check if the invoice collection is set - if (isset($data['invoice_collection_id'])) { + if (isset($data['invoice_collection_id']) && !$shouldAutoReassignInvoiceCollection) { $order->invoice_collection_id->set((int)$data['invoice_collection_id']); } // Check if the wash_id is set @@ -1094,6 +1125,12 @@ class ordersRoute $response->error($e->getMessage(), 400); } } + if ($shouldAutoReassignInvoiceCollection) { + $order->assignToInvoiceCollection(null, false); + } + if ($shouldRefreshAttachedWashCertificate) { + $order->regenerateAttachedWashCertificate(); + } // Void any cached key for the order $order->objectChanged(); // Log the incident @@ -1149,6 +1186,17 @@ class ordersRoute } } + private function shouldAutoReassignInvoiceCollectionForDraftTransition(int $originalCustomerNumber, int $newCustomerNumber): bool + { + if ($originalCustomerNumber <= 0 || $newCustomerNumber <= 0 || $originalCustomerNumber === $newCustomerNumber) { + return false; + } + + $economic = new economic(); + return $economic->isDraftCustomerNumber($originalCustomerNumber) + || $economic->isDraftCustomerNumber($newCustomerNumber); + } + /** * @param array> $orders * @return array> diff --git a/services/nginx/app/tests/Api/AuthApiTest.php b/services/nginx/app/tests/Api/AuthApiTest.php index 769f890d..dd131a13 100644 --- a/services/nginx/app/tests/Api/AuthApiTest.php +++ b/services/nginx/app/tests/Api/AuthApiTest.php @@ -76,6 +76,7 @@ it('returns the cached auth session payload for a valid token', function (): voi 'two_factor_enabled' => false, ] ); + api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', '445566', 'int'); $response = api_client()->get('/auth/session', $session['headers']); @@ -89,7 +90,31 @@ it('returns the cached auth session payload for a valid token', function (): voi ->toHaveKey('customer_number', $session['user']['customer_number']) ->toHaveKey('two_factor_enabled', false) ->and($response->data()['permissions']) - ->toContain('list_departments'); + ->toContain('list_departments') + ->and($response->data()['runtime_config']['economic']['transaction_draft_customer_number'] ?? null) + ->toBe(445566); +}); + +it('includes economic runtime config for uncached auth sessions', function (): void { + api_test_covers('GET /auth/session', 'happy'); + + api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', '556677', 'int'); + $session = api_fixtures()->createUserSession(['list_departments'], [ + 'display_name' => 'Fresh Session User', + ]); + + $response = api_client()->get('/auth/session', $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()) + ->toBeArray() + ->toHaveKey('customer_number', $session['user']['customer_number']) + ->and($response->data()['runtime_config']['economic']['transaction_draft_customer_number'] ?? null) + ->toBe(556677); }); it('rejects invalid auth session tokens', function (): void { diff --git a/services/nginx/app/tests/Api/EconomicDraftCustomerApiTest.php b/services/nginx/app/tests/Api/EconomicDraftCustomerApiTest.php new file mode 100644 index 00000000..cafe763f --- /dev/null +++ b/services/nginx/app/tests/Api/EconomicDraftCustomerApiTest.php @@ -0,0 +1,155 @@ +createUserSession(['economic_config']); + + $response = api_client()->get('/economic/config', $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $entry = economic_draft_customer_find_config_entry((array)$response->data(), 'transactionDraftCustomerNumber'); + + expect($entry) + ->not->toBeNull() + ->and($entry['module'] ?? null)->toBe('economic') + ->and($entry['type'] ?? null)->toBe('int') + ->and(is_int($entry['value'] ?? null) || ($entry['value'] ?? null) === null)->toBeTrue(); +}); + +it('round-trips the draft customer config value through economic config updates', function (): void { + api_test_covers('POST /economic/config', 'happy'); + + $session = api_fixtures()->createUserSession(['economic_config']); + + api_client()->post('/economic/config', [ + 'variable' => 'transactionDraftCustomerNumber', + 'value' => 667788, + ], $session['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $configuredResponse = api_client()->get('/economic/config?variable=transactionDraftCustomerNumber', $session['headers']); + $configuredResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $configuredEntry = economic_draft_customer_find_config_entry((array)$configuredResponse->data(), 'transactionDraftCustomerNumber'); + expect($configuredEntry) + ->not->toBeNull() + ->and($configuredEntry['value'] ?? null)->toBe(667788); + + api_client()->post('/economic/config', [ + 'variable' => 'transactionDraftCustomerNumber', + 'value' => null, + ], $session['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $clearedResponse = api_client()->get('/economic/config?variable=transactionDraftCustomerNumber', $session['headers']); + $clearedResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $clearedEntry = economic_draft_customer_find_config_entry((array)$clearedResponse->data(), 'transactionDraftCustomerNumber'); + expect($clearedEntry)->toBeArray(); + expect(array_key_exists('value', $clearedEntry))->toBeTrue(); + expect($clearedEntry['value'])->toBeNull(); +}); + +it('rejects order draft exports for the configured draft customer', function (): void { + api_test_covers('POST /economic/invoice/draft/export', 'failure'); + + $draftCustomer = api_fixtures()->createUser(['display_name' => 'Draft Export Customer']); + $department = api_fixtures()->createDepartment(); + $cashier = api_fixtures()->createUser(['display_name' => 'Draft Export Cashier']); + $order = api_fixtures()->createOrder([ + 'customer_id' => $draftCustomer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => 'BLOCK-DRAFT', + 'reg_1' => 'DRFT123', + ]); + + api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int'); + $session = api_fixtures()->createUserSession(['economic_invoice_draft_export']); + + api_client()->post('/economic/invoice/draft/export', [ + 'order_id' => $order['id'], + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage(ECONOMIC_DRAFT_CUSTOMER_BLOCKED_MESSAGE); +}); + +it('rejects booked invoice exports for the configured draft customer', function (): void { + api_test_covers('POST /economic/invoice/export', 'failure'); + + $draftCustomer = api_fixtures()->createUser(['display_name' => 'Booked Export Customer']); + $department = api_fixtures()->createDepartment(); + $cashier = api_fixtures()->createUser(['display_name' => 'Booked Export Cashier']); + $order = api_fixtures()->createOrder([ + 'customer_id' => $draftCustomer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => 'BLOCK-INVOICE', + 'reg_1' => 'INV1234', + ]); + + api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int'); + $session = api_fixtures()->createUserSession(['economic_invoice_export']); + + api_client()->post('/economic/invoice/export', [ + 'order_id' => $order['id'], + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage(ECONOMIC_DRAFT_CUSTOMER_BLOCKED_MESSAGE); +}); + +it('rejects collected invoice exports for the configured draft customer', function (): void { + api_test_covers('POST /collected-invoices/economic', 'failure'); + + $draftCustomer = api_fixtures()->createUser(['display_name' => 'Collected Export Customer']); + $invoiceCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $draftCustomer['customer_number'], + ]); + + api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int'); + $session = api_fixtures()->createUserSession(['add_collected_invoice_economic']); + + api_client()->post('/collected-invoices/economic', [ + 'id' => $invoiceCollection['id'], + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage(ECONOMIC_DRAFT_CUSTOMER_BLOCKED_MESSAGE); +}); diff --git a/services/nginx/app/tests/Api/OrdersApiTest.php b/services/nginx/app/tests/Api/OrdersApiTest.php index 75d97162..27bb0873 100644 --- a/services/nginx/app/tests/Api/OrdersApiTest.php +++ b/services/nginx/app/tests/Api/OrdersApiTest.php @@ -4,6 +4,39 @@ declare(strict_types=1); usesApiSuite(); +function activeWashCertificateAttachmentIdsForOrder(int $orderId): array +{ + $statement = api_test_runtime()->db()->prepare( + 'SELECT id, content + FROM object_attachments + WHERE object_type IN (?, ?) + AND object_id = ? + AND deleted_at IS NULL + ORDER BY id ASC' + ); + expect($statement)->not->toBeFalse(); + + $objectType = 'orders'; + $backtickedObjectType = '`orders`'; + $statement->bind_param('ssi', $objectType, $backtickedObjectType, $orderId); + $statement->execute(); + + $result = $statement->get_result(); + $attachmentIds = []; + while ($row = $result->fetch_assoc()) { + $content = json_decode((string)($row['content'] ?? ''), true); + $other = is_array($content) ? ($content['other'] ?? null) : null; + if (is_string($other) && strtolower($other) === 'wash_certificate') { + $attachmentIds[] = (int)($row['id'] ?? 0); + } + } + + $result->free(); + $statement->close(); + + return array_values(array_filter($attachmentIds, static fn(int $id): bool => $id > 0)); +} + it('lists orders for an admin-scoped user and limits the results to the permitted departments', function (): void { api_test_covers('GET /orders', 'happy'); @@ -297,6 +330,190 @@ it('updates orders through the primary endpoint', function (): void { expect((int)($row['include_in_invoice'] ?? 1))->toBe(0); }); +it('reassigns invoice collections when changing an order across the draft customer boundary', function (): void { + api_test_covers('PUT /orders', 'happy'); + + $draftCustomer = api_fixtures()->createUser(['display_name' => 'Draft Customer']); + $regularCustomer = api_fixtures()->createUser(['display_name' => 'Regular Customer']); + $department = api_fixtures()->createDepartment(['name' => 'Draft Boundary Department']); + $cashier = api_fixtures()->createUser(['display_name' => 'Draft Boundary Cashier']); + + api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int'); + + $order = api_fixtures()->createOrder([ + 'customer_id' => $regularCustomer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => 'DRAFT-BOUNDARY', + 'reg_1' => 'DRAFT123', + ]); + $session = api_fixtures()->createUserSession(['edit_order']); + + api_client()->put('/orders', [ + 'id' => $order['id'], + 'customer_id' => $draftCustomer['customer_number'], + ], $session['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess() + ->assertMessage('Order updated successfully'); + + $draftRow = api_fixtures()->fetchRowById('orders', (int)$order['id']); + expect($draftRow)->not->toBeNull(); + $draftInvoiceCollectionId = (int)($draftRow['invoice_collection_id'] ?? 0); + expect($draftInvoiceCollectionId)->toBeGreaterThan(0); + + $draftCollectionRow = api_fixtures()->fetchRowById('collected_order_invoices', $draftInvoiceCollectionId); + expect($draftCollectionRow)->not->toBeNull(); + expect((int)($draftCollectionRow['customer_number'] ?? 0))->toBe((int)$draftCustomer['customer_number']); + + api_client()->put('/orders', [ + 'id' => $order['id'], + 'customer_id' => $regularCustomer['customer_number'], + ], $session['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess() + ->assertMessage('Order updated successfully'); + + $regularRow = api_fixtures()->fetchRowById('orders', (int)$order['id']); + expect($regularRow)->not->toBeNull(); + $regularInvoiceCollectionId = (int)($regularRow['invoice_collection_id'] ?? 0); + expect($regularInvoiceCollectionId)->toBeGreaterThan(0); + + $regularCollectionRow = api_fixtures()->fetchRowById('collected_order_invoices', $regularInvoiceCollectionId); + expect($regularCollectionRow)->not->toBeNull(); + expect((int)($regularCollectionRow['customer_number'] ?? 0))->toBe((int)$regularCustomer['customer_number']); +}); + +it('regenerates attached wash certificates when certificate metadata changes through the primary endpoint', function (): void { + api_test_covers('PUT /orders', 'happy'); + + $department = api_fixtures()->createDepartment(['name' => 'Wash Certificate Department']); + $customer = api_fixtures()->createUser(['display_name' => 'Original Certificate Customer']); + $updatedCustomer = api_fixtures()->createUser(['display_name' => 'Updated Certificate Customer']); + $cashier = api_fixtures()->createUser(['display_name' => 'Certificate Cashier']); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => 'CERT-REF', + 'reg_1' => 'CERT123', + 'reg_2' => 'TRAIL1', + 'safety_seal' => 'SEAL-OLD', + 'created_at' => '2026-04-12 10:15:00', + ]); + $session = api_fixtures()->createUserSession([ + 'edit_order', + 'add_order_attachments', + 'department_access_' . $department['id'], + ]); + + api_client()->post('/orders/attachments/upload', [ + 'order_id' => $order['id'], + 'base64_file' => base64_encode('wash-certificate'), + 'file_name' => 'wash_certificate', + ], $session['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $initialAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']); + expect($initialAttachmentIds)->toHaveCount(1); + + $initialAttachmentId = $initialAttachmentIds[0]; + $initialAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $initialAttachmentId); + expect($initialAttachmentRow)->not->toBeNull(); + $initialDocument = (json_decode((string)($initialAttachmentRow['content'] ?? ''), true) ?? [])['document'] ?? null; + + api_client()->put('/orders', [ + 'id' => $order['id'], + 'customer_id' => $updatedCustomer['customer_number'], + 'reg_2' => ' new-trail-55 ', + 'safety_seal' => 'SEAL-NEW', + ], $session['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess() + ->assertMessage('Order updated successfully'); + + $orderRow = api_fixtures()->fetchRowById('orders', (int)$order['id']); + expect($orderRow)->not->toBeNull(); + expect((int)($orderRow['customer_id'] ?? 0))->toBe((int)$updatedCustomer['customer_number']); + expect($orderRow['reg_2'] ?? null)->toBe('NEWTRAIL55'); + expect($orderRow['safety_seal'] ?? null)->toBe('SEAL-NEW'); + + $updatedAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']); + expect($updatedAttachmentIds)->toHaveCount(1); + expect($updatedAttachmentIds)->not->toContain($initialAttachmentId); + + $updatedAttachmentId = $updatedAttachmentIds[0]; + $updatedAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $updatedAttachmentId); + $deletedAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $initialAttachmentId); + expect($updatedAttachmentRow)->not->toBeNull(); + expect($deletedAttachmentRow)->not->toBeNull(); + expect($deletedAttachmentRow['deleted_at'] ?? null)->not->toBeNull(); + expect((json_decode((string)($updatedAttachmentRow['content'] ?? ''), true) ?? [])['document'] ?? null)->not->toBe($initialDocument); +}); + +it('regenerates attached wash certificates for legacy field-value updates through the alias endpoint', function (): void { + api_test_covers('PUT /order', 'happy'); + + $department = api_fixtures()->createDepartment(['name' => 'Alias Wash Certificate Department']); + $customer = api_fixtures()->createUser(['display_name' => 'Alias Certificate Customer']); + $cashier = api_fixtures()->createUser(['display_name' => 'Alias Certificate Cashier']); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => 'ALIAS-CERT', + 'reg_1' => 'ALIAS123', + 'safety_seal' => 'ALIAS-SEAL', + ]); + $session = api_fixtures()->createUserSession([ + 'edit_order', + 'add_order_attachments', + 'department_access_' . $department['id'], + ]); + + api_client()->post('/orders/attachments/upload', [ + 'order_id' => $order['id'], + 'base64_file' => base64_encode('wash-certificate'), + 'file_name' => 'wash_certificate', + ], $session['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $initialAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']); + expect($initialAttachmentIds)->toHaveCount(1); + + $initialAttachmentId = $initialAttachmentIds[0]; + + api_client()->put('/order', [ + 'id' => $order['id'], + 'field' => 'reg_1', + 'value' => ' zz-88 11 ', + ], $session['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess() + ->assertMessage('Order updated successfully'); + + $orderRow = api_fixtures()->fetchRowById('orders', (int)$order['id']); + expect($orderRow)->not->toBeNull(); + expect($orderRow['reg_1'] ?? null)->toBe('ZZ8811'); + + $updatedAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']); + expect($updatedAttachmentIds)->toHaveCount(1); + expect($updatedAttachmentIds)->not->toContain($initialAttachmentId); + + $deletedAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $initialAttachmentId); + + expect($deletedAttachmentRow)->not->toBeNull(); + expect($deletedAttachmentRow['deleted_at'] ?? null)->not->toBeNull(); +}); + it('supports legacy field-value metadata updates through the primary endpoint', function (): void { api_test_covers('PUT /orders', 'happy'); diff --git a/services/nginx/app/tests/Api/StripeApiTest.php b/services/nginx/app/tests/Api/StripeApiTest.php index 77b2fa4a..84991bca 100644 --- a/services/nginx/app/tests/Api/StripeApiTest.php +++ b/services/nginx/app/tests/Api/StripeApiTest.php @@ -2,8 +2,26 @@ declare(strict_types=1); +use classes\email; +use classes\stripe; +use classes\stripe_fake_http_client; + +putenv('STRIPE_FAKE_MODE=1'); +putenv('STRIPE_FAKE_STORE_PATH=' . sys_get_temp_dir() . '/truckwash-stripe-api-tests.json'); +putenv('EMAIL_FAKE_MODE=1'); + usesApiSuite(); +beforeEach(function (): void { + stripe_fake_http_client::resetStore(); + email::resetFakeDeliveries(); + + if (api_tests_enabled()) { + api_test_runtime()->db()->query("DELETE FROM stripe_module_orders WHERE invoice_id LIKE 'in_fake_%'"); + api_test_runtime()->db()->query("DELETE FROM stripe_module_customers WHERE customer_id LIKE 'cus_fake_%' OR email LIKE 'stripe-%@example.com'"); + } +}); + it('returns a setup required error when department terminal readers are requested without terminal setup', function (): void { $department = api_fixtures()->createDepartment([ 'name' => 'Stripe Setup Pending Department', @@ -29,6 +47,248 @@ it('returns a setup required error when department terminal readers are requeste ->toHaveKey('code', 'stripe_terminal_setup_required'); }); +it('sends a Stripe invoice by email and persists the hosted invoice association', function (): void { + $department = api_fixtures()->createDepartment([ + 'name' => 'Stripe Email Payments Department', + ]); + $customer = api_fixtures()->createUser([ + 'display_name' => 'Stripe Hosted Invoice Customer', + ]); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'reference' => 'STRIPE-EMAIL-SEND', + 'reg_1' => 'EMAIL01', + ]); + $session = api_fixtures()->createUserSession([ + 'modules_stripe_invoice_send', + ]); + $emailAddress = sprintf('stripe-email-%d@example.com', (int)$order['id']); + + $response = api_client()->post('/modules/stripe/invoice', [ + 'email' => $emailAddress, + 'order_id' => $order['id'], + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(true); + + expect($response->data()) + ->toBeArray() + ->toHaveKey('id') + ->toHaveKey('hosted_invoice_url') + ->toHaveKey('metadata'); + + expect($response->data()['metadata'] ?? []) + ->toMatchArray([ + 'order_id' => (string)$order['id'], + 'customer_id' => (string)$customer['customer_number'], + 'department_id' => (string)$department['id'], + 'reference' => 'STRIPE-EMAIL-SEND', + 'reg_1' => 'EMAIL01', + ]) + ->and(($response->data()['metadata']['stripe_customer_id'] ?? null)) + ->toBe((string)($response->data()['customer'] ?? '')); + + $retrievedInvoice = (new stripe())->invoice->retrieve((string)$response->data()['id']); + $retrievedMetadata = json_decode(json_encode($retrievedInvoice->metadata), true); + if (!is_array($retrievedMetadata)) { + $retrievedMetadata = []; + } + + expect($retrievedMetadata) + ->toMatchArray([ + 'order_id' => (string)$order['id'], + 'customer_id' => (string)$customer['customer_number'], + 'department_id' => (string)$department['id'], + 'reference' => 'STRIPE-EMAIL-SEND', + 'reg_1' => 'EMAIL01', + ]) + ->and(($retrievedMetadata['stripe_customer_id'] ?? null)) + ->toBe((string)($response->data()['customer'] ?? '')); + + $stored = api_test_runtime()->queryOne( + 'SELECT invoice_id, customer_id, url FROM stripe_module_orders WHERE id = ' . (int)$order['id'] + ); + + expect($stored) + ->not->toBeNull() + ->and($stored['invoice_id'] ?? null)->toBe((string)$response->data()['id']) + ->and($stored['customer_id'] ?? null)->not->toBe('') + ->and($stored['url'] ?? null)->toBe((string)$response->data()['hosted_invoice_url']); +}); + +it('returns a conflict when a Stripe hosted invoice is already active for the order', function (): void { + $department = api_fixtures()->createDepartment([ + 'name' => 'Stripe Email Guard Department', + ]); + $customer = api_fixtures()->createUser([ + 'display_name' => 'Stripe Duplicate Guard Customer', + ]); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'reference' => 'STRIPE-EMAIL-GUARD', + 'reg_1' => 'EMAIL02', + ]); + $session = api_fixtures()->createUserSession([ + 'modules_stripe_invoice_send', + ]); + $emailAddress = sprintf('stripe-duplicate-%d@example.com', (int)$order['id']); + + api_client()->post('/modules/stripe/invoice', [ + 'email' => $emailAddress, + 'order_id' => $order['id'], + ], $session['headers'])->assertStatus(200); + + $response = api_client()->post('/modules/stripe/invoice', [ + 'email' => $emailAddress, + 'order_id' => $order['id'], + ], $session['headers']); + + $response + ->assertStatus(409) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('A Stripe payment link is already active for this order.'); + + expect($response->data()) + ->toBeArray() + ->toHaveKey('code', 'stripe_invoice_exists'); +}); + +it('allows sending a new Stripe hosted invoice when the existing association is already terminal', function (): void { + $department = api_fixtures()->createDepartment([ + 'name' => 'Stripe Email Terminal Guard Department', + ]); + $customer = api_fixtures()->createUser([ + 'display_name' => 'Stripe Terminal Guard Customer', + ]); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'reference' => 'STRIPE-EMAIL-TERMINAL', + 'reg_1' => 'EMAIL05', + ]); + $session = api_fixtures()->createUserSession([ + 'modules_stripe_invoice_send', + ]); + $emailAddress = sprintf('stripe-terminal-%d@example.com', (int)$order['id']); + + $firstResponse = api_client()->post('/modules/stripe/invoice', [ + 'email' => $emailAddress, + 'order_id' => $order['id'], + ], $session['headers']); + + $firstInvoiceId = (string)($firstResponse->data()['id'] ?? ''); + stripe_fake_http_client::setInvoiceState($firstInvoiceId, [ + 'status' => 'void', + 'paid' => false, + ]); + + $secondResponse = api_client()->post('/modules/stripe/invoice', [ + 'email' => $emailAddress, + 'order_id' => $order['id'], + ], $session['headers']); + + $secondResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(true); + + expect((string)($secondResponse->data()['id'] ?? '')) + ->not->toBe('') + ->not->toBe($firstInvoiceId); +}); + +it('voids an unpaid Stripe hosted invoice and clears the local order association', function (): void { + $department = api_fixtures()->createDepartment([ + 'name' => 'Stripe Email Cancel Department', + ]); + $customer = api_fixtures()->createUser([ + 'display_name' => 'Stripe Cancel Customer', + ]); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'reference' => 'STRIPE-EMAIL-CANCEL', + 'reg_1' => 'EMAIL03', + ]); + $session = api_fixtures()->createUserSession([ + 'modules_stripe_invoice_send', + ]); + $emailAddress = sprintf('stripe-cancel-%d@example.com', (int)$order['id']); + + $sendResponse = api_client()->post('/modules/stripe/invoice', [ + 'email' => $emailAddress, + 'order_id' => $order['id'], + ], $session['headers']); + $invoiceId = (string)($sendResponse->data()['id'] ?? ''); + + $response = api_client()->delete('/modules/stripe/invoice', [ + 'order_id' => $order['id'], + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(true); + + expect(api_test_runtime()->queryOne( + 'SELECT invoice_id FROM stripe_module_orders WHERE id = ' . (int)$order['id'] + ))->toBeNull(); + + expect((new stripe())->invoice->retrieve($invoiceId)->status)->toBe('void'); +}); + +it('refuses to cancel a paid Stripe hosted invoice', function (): void { + $department = api_fixtures()->createDepartment([ + 'name' => 'Stripe Email Paid Department', + ]); + $customer = api_fixtures()->createUser([ + 'display_name' => 'Stripe Paid Customer', + ]); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'reference' => 'STRIPE-EMAIL-PAID', + 'reg_1' => 'EMAIL04', + ]); + $session = api_fixtures()->createUserSession([ + 'modules_stripe_invoice_send', + ]); + $emailAddress = sprintf('stripe-paid-%d@example.com', (int)$order['id']); + + $sendResponse = api_client()->post('/modules/stripe/invoice', [ + 'email' => $emailAddress, + 'order_id' => $order['id'], + ], $session['headers']); + $invoiceId = (string)($sendResponse->data()['id'] ?? ''); + + stripe_fake_http_client::setInvoiceState($invoiceId, [ + 'status' => 'paid', + 'paid' => true, + 'amount_due' => 0, + 'amount_paid' => 1000, + ]); + + $response = api_client()->delete('/modules/stripe/invoice', [ + 'order_id' => $order['id'], + ], $session['headers']); + + $response + ->assertStatus(409) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('A paid Stripe payment link cannot be cancelled.'); + + expect(api_test_runtime()->queryOne( + 'SELECT invoice_id FROM stripe_module_orders WHERE id = ' . (int)$order['id'] + ))->not->toBeNull(); +}); + it('returns a setup required error when creating a payment intent for a department without terminal setup', function (): void { $department = api_fixtures()->createDepartment([ 'name' => 'Stripe Payment Intent Pending Department', diff --git a/services/nginx/app/tests/Support/bootstrap.php b/services/nginx/app/tests/Support/bootstrap.php index 10c7120c..e386b7bb 100644 --- a/services/nginx/app/tests/Support/bootstrap.php +++ b/services/nginx/app/tests/Support/bootstrap.php @@ -18,6 +18,60 @@ function app_require(string $relative): void require_once app_path($relative); } +spl_autoload_register(function (string $class): void { + $class = ltrim($class, '\\'); + if ($class === '') { + return; + } + + $parts = explode('\\', $class); + $top = strtolower($parts[0] ?? ''); + $relative = implode(DIRECTORY_SEPARATOR, array_slice($parts, 1)); + + if ($relative === '') { + return; + } + + $base = app_path(); + $candidates = []; + + if (in_array($top, ['classes', 'interfaces', 'traits', 'objects', 'routes', 'statistics'], true)) { + $candidates[] = $base . DIRECTORY_SEPARATOR . $top . DIRECTORY_SEPARATOR . $relative; + } elseif ($top === 'modules') { + $candidates[] = $base . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . $relative; + } else { + $candidates[] = $base . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $class); + $modules_root = $base . DIRECTORY_SEPARATOR . 'modules'; + if (is_dir($modules_root)) { + $module_dirs = array_filter(scandir($modules_root) ?: [], static function (string $entry) use ($modules_root): bool { + return $entry !== '.' && $entry !== '..' && is_dir($modules_root . DIRECTORY_SEPARATOR . $entry); + }); + foreach ($module_dirs as $module_dir) { + $candidates[] = $modules_root . DIRECTORY_SEPARATOR . $module_dir . DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $class); + } + } + } + + foreach ($candidates as $path) { + foreach (['', '_t', '_o', '_s', '_i', '_c', '_m'] as $suffix) { + $file = $path . $suffix . '.php'; + if (!is_file($file)) { + continue; + } + + require_once $file; + if ( + class_exists($class, false) + || interface_exists($class, false) + || trait_exists($class, false) + || (function_exists('enum_exists') && enum_exists($class, false)) + ) { + return; + } + } + } +}); + function integration_enabled(): bool { return getenv('RUN_INTEGRATION_TESTS') === '1'; diff --git a/services/nginx/app/tests/Unit/Bookings/OrderBookingsCountsCacheTest.php b/services/nginx/app/tests/Unit/Bookings/OrderBookingsCountsCacheTest.php new file mode 100644 index 00000000..e0011ad9 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bookings/OrderBookingsCountsCacheTest.php @@ -0,0 +1,119 @@ + */ + public array $store = []; + + public function get(string $key): ?string + { + return $this->store[$key] ?? null; + } + + public function setEx(string $key, string $value, int $ttl): void + { + $this->store[$key] = $value; + } + + public function clear_keys(string $pattern): void + { + $regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/'; + foreach (array_keys($this->store) as $key) { + if (preg_match($regex, $key) === 1) { + unset($this->store[$key]); + } + } + } + } +} + +beforeEach(function (): void { + $this->oldTtl = getenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL'); + $this->redis = new OrderBookingsCountsCacheRedisFake(); + order_bookings_counts_cache::setAdapterForTests($this->redis); +}); + +afterEach(function (): void { + order_bookings_counts_cache::setAdapterForTests(null); + + if ($this->oldTtl === false) { + putenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL'); + return; + } + + putenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL=' . $this->oldTtl); +}); + +it('uses sane ttl defaults and clamps negative ttl to zero', function (): void { + putenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL'); + expect(order_bookings_counts_cache::getTtl())->toBe(30); + + putenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL=20'); + expect(order_bookings_counts_cache::getTtl())->toBe(20); + + putenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL=-1'); + expect(order_bookings_counts_cache::getTtl())->toBe(0); +}); + +it('builds deterministic keys for equivalent count contexts', function (): void { + $keyA = order_bookings_counts_cache::buildKey([ + 'route' => '/order-bookings/counts', + 'day' => '2026-04-21', + 'department_ids' => ['3', '1'], + 'customer_number' => null, + ]); + + $keyB = order_bookings_counts_cache::buildKey([ + 'customer_number' => null, + 'route' => '/order-bookings/counts', + 'department_ids' => [1, 3], + 'day' => '2026-04-21', + ]); + + expect($keyA)->toBe($keyB); + expect($keyA)->toStartWith(order_bookings_counts_cache::PREFIX); +}); + +it('stores and retrieves normalized booking counts', function (): void { + $key = order_bookings_counts_cache::buildKey([ + 'route' => '/order-bookings/counts', + 'day' => '2026-04-21', + 'department_ids' => [12], + ]); + + order_bookings_counts_cache::storeCounts($key, [ + 'past' => '2', + 'current' => 3, + 'future' => 1, + ], 60); + + expect(order_bookings_counts_cache::getCounts($key))->toEqual([ + 'past' => 2, + 'current' => 3, + 'future' => 1, + ]); +}); + +it('ignores malformed payloads and clears order-bookings count caches', function (): void { + $validKey = order_bookings_counts_cache::buildKey([ + 'route' => '/order-bookings/counts', + 'day' => '2026-04-21', + ]); + + $this->redis->store[$validKey] = '{"past":1,"current":2,"future":3}'; + $this->redis->store[order_bookings_counts_cache::PREFIX . 'broken'] = '{"count":5}'; + $this->redis->store['other:key'] = '{"keep":true}'; + + expect(order_bookings_counts_cache::getCounts(order_bookings_counts_cache::PREFIX . 'broken'))->toBeNull(); + + order_bookings_counts_cache::clearAll(); + + expect($this->redis->store)->toHaveKey('other:key'); + expect($this->redis->store)->not->toHaveKey($validKey); + expect($this->redis->store)->not->toHaveKey(order_bookings_counts_cache::PREFIX . 'broken'); +}); diff --git a/services/nginx/app/tests/Unit/Bookings/OrderBookingsCountsRouteWiringTest.php b/services/nginx/app/tests/Unit/Bookings/OrderBookingsCountsRouteWiringTest.php new file mode 100644 index 00000000..870628ad --- /dev/null +++ b/services/nginx/app/tests/Unit/Bookings/OrderBookingsCountsRouteWiringTest.php @@ -0,0 +1,23 @@ +toBeTrue(); + expect(is_file($objectFile))->toBeTrue(); + + $routeCode = preg_replace('/\s+/', ' ', (string)file_get_contents($routeFile)); + $objectCode = preg_replace('/\s+/', ' ', (string)file_get_contents($objectFile)); + + expect($routeCode)->toContain('use classes\order_bookings_counts_cache;'); + expect($routeCode)->toContain("\$this->get('/order-bookings/counts', function () {"); + expect($routeCode)->toContain('$cacheTtl = order_bookings_counts_cache::getTtl();'); + expect($routeCode)->toContain('$cacheKey = $cacheTtl > 0 ? $this->getOrderBookingsCountsCacheKey('); + expect($routeCode)->toContain('$cachedCounts = order_bookings_counts_cache::getCounts($cacheKey);'); + expect($routeCode)->toContain('order_bookings_counts_cache::storeCounts($cacheKey, $counts, $cacheTtl);'); + expect($routeCode)->toContain('getPendingBookingCounts('); + + expect($objectCode)->toContain('use classes\order_bookings_counts_cache;'); + expect($objectCode)->toContain('order_bookings_counts_cache::clearAll();'); +}); diff --git a/services/nginx/app/tests/Unit/Bookings/OrderBookingsListCacheTest.php b/services/nginx/app/tests/Unit/Bookings/OrderBookingsListCacheTest.php new file mode 100644 index 00000000..28310400 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bookings/OrderBookingsListCacheTest.php @@ -0,0 +1,143 @@ + */ + public array $store = []; + + public function reset(): void + { + $this->store = []; + } + + public function get(string $key): ?string + { + return $this->store[$key] ?? null; + } + + public function setEx(string $key, string $value, int $ttl): void + { + $this->store[$key] = $value; + } + + public function clear_keys(string $pattern): void + { + $regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/'; + foreach (array_keys($this->store) as $key) { + if (preg_match($regex, $key) === 1) { + unset($this->store[$key]); + } + } + } + } +} + +beforeEach(function (): void { + $this->oldTtl = getenv('ORDER_BOOKINGS_LIST_CACHE_TTL'); + $this->redis = new OrderBookingsListCacheRedisFake(); + order_bookings_list_cache::setAdapterForTests($this->redis); +}); + +afterEach(function (): void { + order_bookings_list_cache::setAdapterForTests(null); + + if ($this->oldTtl === false) { + putenv('ORDER_BOOKINGS_LIST_CACHE_TTL'); + return; + } + + putenv('ORDER_BOOKINGS_LIST_CACHE_TTL=' . $this->oldTtl); +}); + +it('uses sane ttl defaults and clamps negative ttl to zero', function (): void { + putenv('ORDER_BOOKINGS_LIST_CACHE_TTL'); + expect(order_bookings_list_cache::getTtl())->toBe(30); + + putenv('ORDER_BOOKINGS_LIST_CACHE_TTL=45'); + expect(order_bookings_list_cache::getTtl())->toBe(45); + + putenv('ORDER_BOOKINGS_LIST_CACHE_TTL=-10'); + expect(order_bookings_list_cache::getTtl())->toBe(0); +}); + +it('builds deterministic keys for equivalent contexts', function (): void { + $keyA = order_bookings_list_cache::buildKey([ + 'route' => '/order-bookings', + 'page' => 1, + 'limit' => 100, + 'search' => '', + 'order' => ['datetime' => 'DESC'], + 'filters' => [ + 'customer_number' => '42', + 'department' => ['3', '1'], + 'order_id' => null, + ], + ]); + + $keyB = order_bookings_list_cache::buildKey([ + 'search' => '', + 'route' => '/order-bookings', + 'limit' => 100, + 'filters' => [ + 'order_id' => null, + 'department' => [1, 3], + 'customer_number' => 42, + ], + 'order' => ['datetime' => 'DESC'], + 'page' => 1, + ]); + + expect($keyA)->toBe($keyB); + expect($keyA)->toStartWith(order_bookings_list_cache::PREFIX); +}); + +it('stores and retrieves full order-bookings payloads', function (): void { + $key = order_bookings_list_cache::buildKey([ + 'route' => '/order-bookings', + 'page' => 1, + 'limit' => 100, + ]); + + $payload = [ + 'success' => true, + 'data' => [ + ['id' => 123], + ], + 'meta' => [ + 'pagination' => [ + 'page' => 1, + 'per_page' => 100, + 'total' => 1, + ], + ], + 'includes' => [], + ]; + + order_bookings_list_cache::storePayload($key, $payload, 60); + + expect(order_bookings_list_cache::getPayload($key))->toBe($payload); +}); + +it('ignores malformed cached payloads and clears order-bookings list caches', function (): void { + $validKey = order_bookings_list_cache::buildKey([ + 'route' => '/order-bookings', + 'page' => 1, + ]); + + $this->redis->store[$validKey] = '{"success":true,"data":[]}'; + $this->redis->store[order_bookings_list_cache::PREFIX . 'broken'] = '{"not":"a payload"}'; + $this->redis->store['other:cache:key'] = '{"leave":"me"}'; + + expect(order_bookings_list_cache::getPayload(order_bookings_list_cache::PREFIX . 'broken'))->toBeNull(); + + order_bookings_list_cache::clearAll(); + + expect($this->redis->store)->toHaveKey('other:cache:key'); + expect($this->redis->store)->not->toHaveKey($validKey); + expect($this->redis->store)->not->toHaveKey(order_bookings_list_cache::PREFIX . 'broken'); +}); diff --git a/services/nginx/app/tests/Unit/Bookings/OrderBookingsRouteCacheWiringTest.php b/services/nginx/app/tests/Unit/Bookings/OrderBookingsRouteCacheWiringTest.php new file mode 100644 index 00000000..a2bf9631 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bookings/OrderBookingsRouteCacheWiringTest.php @@ -0,0 +1,24 @@ +toBeTrue(); + expect(is_file($objectFile))->toBeTrue(); + + $routeCode = preg_replace('/\s+/', ' ', (string)file_get_contents($routeFile)); + $objectCode = preg_replace('/\s+/', ' ', (string)file_get_contents($objectFile)); + + expect($routeCode)->toContain('use classes\order_bookings_list_cache;'); + expect($routeCode)->toContain('$cacheTtl = order_bookings_list_cache::getTtl();'); + expect($routeCode)->toContain('$cacheKey = $cacheTtl > 0 ? $this->getOrderBookingsListCacheKey($object, $forcedFilters) : null;'); + expect($routeCode)->toContain('$cachedPayload = order_bookings_list_cache::getPayload($cacheKey);'); + expect($routeCode)->toContain('$response->rawJson($cachedPayload);'); + expect($routeCode)->toContain('order_bookings_list_cache::storePayload($cacheKey, $payload, $cacheTtl);'); + expect($routeCode)->toContain('$response->rawJson($payload);'); + expect($routeCode)->toContain('private function getOrderBookingsListCacheKey(order_bookings_o $object, string $forcedFilters): string'); + + expect($objectCode)->toContain('use classes\order_bookings_list_cache;'); + expect($objectCode)->toContain('order_bookings_list_cache::clearAll();'); +}); diff --git a/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewRouteTest.php b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewRouteTest.php index 3785df0a..49af59ec 100644 --- a/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewRouteTest.php +++ b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsOverviewRouteTest.php @@ -217,8 +217,8 @@ it('builds the overview payload from batched repository data with deterministic (object)[ 'departmentID' => 'dep_1', 'start' => '2026-03-23T09:00:00+00:00', - 'end' => '2026-03-23T17:00:00+00:00', - 'approval' => (object)['originalEnd' => '2026-03-23T17:30:00+00:00'], + 'end' => '2026-03-23T17:30:00+00:00', + 'approval' => (object)['originalEnd' => '2026-03-23T17:00:00+00:00'], ], (object)[ 'departmentID' => 'dep_2', @@ -299,13 +299,44 @@ it('limits overtime counting to the selected reporting range', function (): void $hours = department_daily_reports_route_invoke_private($route, 'calculateShiftOvertimeHoursInRange', [[ 'start' => '2026-03-22T20:00:00+00:00', - 'end' => '2026-03-22T23:45:00+00:00', - 'approval' => (object)['originalEnd' => '2026-03-23T00:30:00+00:00'], + 'end' => '2026-03-23T00:30:00+00:00', + 'approval' => (object)['originalEnd' => '2026-03-22T23:45:00+00:00'], ], new \DateTime('2026-03-23T00:00:00+00:00'), new \DateTime('2026-03-24T00:00:00+00:00')]); expect($hours)->toBe(0.5); }); +it('does not count negative approved overtime when a saved end shortens the shift', function (): void { + $route = new DepartmentDailyReportsOverviewRouteDouble(); + $route->repository = new FakeDailyReportRepository(); + $route->complaints_repository = new FakeDailyReportComplaintsRepository(); + $route->outside_hours_service = new FakeOutsideHoursStatisticsService(); + + $hours = department_daily_reports_route_invoke_private($route, 'calculateShiftOvertimeHoursInRange', [[ + 'start' => '2026-03-23T10:00:00+00:00', + 'end' => '2026-03-23T17:00:00+00:00', + 'approval' => (object)['originalEnd' => '2026-03-23T18:00:00+00:00'], + ], new \DateTime('2026-03-23T00:00:00+00:00'), new \DateTime('2026-03-24T00:00:00+00:00')]); + + expect($hours)->toBe(0.0); +}); + +it('ignores late unapproved administrative edits when calculating overtime', function (): void { + $route = new DepartmentDailyReportsOverviewRouteDouble(); + $route->repository = new FakeDailyReportRepository(); + $route->complaints_repository = new FakeDailyReportComplaintsRepository(); + $route->outside_hours_service = new FakeOutsideHoursStatisticsService(); + + $hours = department_daily_reports_route_invoke_private($route, 'calculateShiftOvertimeHoursInRange', [[ + 'start' => '2026-03-23T09:00:00+00:00', + 'end' => '2026-03-23T17:00:00+00:00', + 'approval' => null, + 'updateTime' => '2026-03-24T02:30:00+00:00', + ], new \DateTime('2026-03-23T00:00:00+00:00'), new \DateTime('2026-03-24T00:00:00+00:00')]); + + expect($hours)->toBe(0.0); +}); + it('wires the overview route to batched repository methods and overview path', function (): void { $routeContent = (string)file_get_contents(app_path('routes/departmentDailyReportsRoute.php')); $objectContent = (string)file_get_contents(app_path('objects/department_daily_reports_o.php')); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicDraftCustomerOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicDraftCustomerOpenApiSpecTest.php new file mode 100644 index 00000000..3286e138 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicDraftCustomerOpenApiSpecTest.php @@ -0,0 +1,39 @@ +markTestSkipped('One or more tracked openapi.yaml copies are not available in this runtime environment.'); + } + + $content = file_get_contents($candidate); + if ($content === false) { + test()->markTestSkipped('Failed to read one or more tracked openapi.yaml copies.'); + } + + $specs[$candidate] = $content; + } + + return $specs; +} + +it('documents transaction draft customer config and auth runtime fields in all tracked openapi copies', function (): void { + foreach (economic_draft_customer_openapi_specs_or_skip() as $path => $content) { + expect($content, $path)->toContain('/auth/session:'); + expect($content, $path)->toContain('transaction_draft_customer_number:'); + expect($content, $path)->toContain('EconomicConfigEntry:'); + expect($content, $path)->toContain('transactionDraftCustomerNumber'); + expect($content, $path)->toContain('nullable: true'); + } +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayArtifactLocatorTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayArtifactLocatorTest.php new file mode 100644 index 00000000..f3596ea7 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayArtifactLocatorTest.php @@ -0,0 +1,176 @@ + $value) { + if ($value === false) { + putenv($key); + continue; + } + putenv($key . '=' . $value); + } + } +} + +function remove_edge_gateway_temp_path(string $path): void +{ + if (is_file($path)) { + unlink($path); + return; + } + + if (is_dir($path)) { + rmdir($path); + } +} + +it('resolves edge-agent artifacts from a supported runtime layout', function (): void { + $path = edge_gateway_agent_artifact_locator::resolve('agent.php', app_path()); + + expect(str_replace('\\', '/', $path))->toEndWith('/resources/edge-gateway-agent/agent.php'); + expect(is_file($path))->toBeTrue(); +}); + +it('prioritizes router resources before mounted and baked-in artifact directories', function (): void { + with_edge_gateway_artifact_env(['EDGE_AGENT_ARTIFACT_DIR' => null], function (): void { + $candidatePaths = array_map( + static fn(string $path): string => str_replace('\\', '/', $path), + edge_gateway_agent_artifact_locator::candidatePaths( + 'agent.php', + '/var/www/html', + '/services/edge-agent/php-agent', + '/opt/truckwash-edge-agent-artifacts' + ) + ); + + expect(array_slice($candidatePaths, 0, 3))->toBe([ + '/var/www/html/resources/edge-gateway-agent/agent.php', + '/services/edge-agent/php-agent/agent.php', + '/opt/truckwash-edge-agent-artifacts/agent.php', + ]); + expect($candidatePaths)->toContain('/var/edge-agent/php-agent/agent.php'); + expect($candidatePaths)->toContain('/var/www/edge-agent/php-agent/agent.php'); + }); +}); + +it('reads install artifacts through the shared locator', function (): void { + $service = new EdgeGatewayInstallServiceHarness(); + $contents = $service->readArtifact('truckwash-edge-agent.service'); + + expect($contents)->toContain('ExecStart=/usr/bin/php /opt/truckwash-edge-agent/agent.php'); +}); + +it('builds update payloads with checksums from resolved artifact paths', function (): void { + $manager = new EdgeGatewayManagerArtifactHarness(); + $payload = $manager->buildUpdateOperationRequest('2.0.0', 'stable'); + + expect($payload['artifactSha256'])->toBe(hash_file('sha256', app_path('resources/edge-gateway-agent/agent.php'))); + expect($payload['serviceUnitSha256'])->toBe(hash_file('sha256', app_path('resources/edge-gateway-agent/truckwash-edge-agent.service'))); +}); + +it('falls back to baked-in artifacts when the mount path is absent', function (): void { + $tempRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'edge-gateway-baked-' . bin2hex(random_bytes(6)); + $appPath = $tempRoot . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'nginx' . DIRECTORY_SEPARATOR . 'app'; + $missingMountPath = $tempRoot . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'; + $bakedPath = $tempRoot . DIRECTORY_SEPARATOR . 'baked-artifacts'; + $bakedAgentPath = $bakedPath . DIRECTORY_SEPARATOR . 'agent.php'; + + @mkdir($appPath, 0777, true); + @mkdir($bakedPath, 0777, true); + file_put_contents($bakedAgentPath, "toBe(str_replace('\\', '/', $bakedAgentPath)); + } finally { + remove_edge_gateway_temp_path($bakedAgentPath); + foreach ([ + $bakedPath, + $appPath, + dirname($appPath), + dirname(dirname($appPath)), + dirname($missingMountPath), + dirname(dirname($missingMountPath)), + $tempRoot, + ] as $directory) { + remove_edge_gateway_temp_path($directory); + } + } +}); + +it('explains the legacy dist mount mismatch when php artifacts are unavailable', function (): void { + $tempRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'edge-gateway-artifacts-' . bin2hex(random_bytes(6)); + $appPath = $tempRoot . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'nginx' . DIRECTORY_SEPARATOR . 'app'; + $legacyDistPath = $tempRoot . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'dist'; + + @mkdir($appPath, 0777, true); + @mkdir($legacyDistPath, 0777, true); + file_put_contents($legacyDistPath . DIRECTORY_SEPARATOR . 'agent.mjs', '// legacy node artifact'); + + try { + edge_gateway_agent_artifact_locator::resolve('agent.php', $appPath); + test()->fail('Expected artifact resolution to fail when php-agent artifacts are missing.'); + } catch (Exception $exception) { + expect($exception->getMessage())->toContain('Missing edge agent artifact: agent.php.'); + expect($exception->getMessage())->toContain('Deploy the router-owned artifacts under'); + expect($exception->getMessage())->toContain('Legacy Node dist artifact found at'); + expect($exception->getMessage())->toContain('Remove /services/edge-agent/dist and deploy the router resources instead of depending on the legacy mount.'); + } finally { + remove_edge_gateway_temp_path($legacyDistPath . DIRECTORY_SEPARATOR . 'agent.mjs'); + foreach ([ + $legacyDistPath, + dirname($legacyDistPath), + $appPath, + dirname($appPath), + dirname(dirname($appPath)), + $tempRoot, + ] as $directory) { + remove_edge_gateway_temp_path($directory); + } + } +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayFleetUsageStatisticsTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayFleetUsageStatisticsTest.php new file mode 100644 index 00000000..f1b9424d --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayFleetUsageStatisticsTest.php @@ -0,0 +1,91 @@ + 701, + 'department_id' => 1, + 'status' => edge_gateway_manager::STATUS_ONLINE, + 'version_drift' => ['is_drifted' => true], + 'channel_status' => ['broker' => ['connected' => true]], + 'active_operation' => ['id' => 91, 'type' => 'DISCOVERY'], + 'recent_operations_summary' => ['pending' => 1, 'in_progress' => 1], + 'backlog_depth' => ['operations' => 2, 'commands' => 3], + 'metadata' => [ + 'system_metrics' => [ + 'latency_ms' => 184, + 'cpu_usage_pct' => 27, + 'memory_usage_pct' => 61, + 'disk_usage_pct' => 58, + ], + ], + ], + [ + 'id' => 702, + 'department_id' => 2, + 'status' => edge_gateway_manager::STATUS_OFFLINE, + 'version_drift' => ['is_drifted' => false], + 'channel_status' => ['broker' => ['connected' => false]], + 'active_operation' => null, + 'recent_operations_summary' => ['pending' => 0, 'in_progress' => 0], + 'backlog_depth' => ['operations' => 0, 'commands' => 1], + 'metadata' => [ + 'system_metrics' => [ + 'latency_ms' => 412, + 'cpu_usage_pct' => 9, + 'memory_usage_pct' => 42, + 'disk_usage_pct' => 76, + ], + ], + ], + ], [ + 'total' => 2, + 'online' => 1, + 'offline' => 1, + ], [ + 'total' => 3, + 'fallback_overrides' => 2, + 'cloud_only' => 1, + 'local_only' => 1, + ]); + + expect($summary['gateways'])->toBe([ + 'total' => 2, + 'departments' => 2, + 'online' => 1, + 'offline' => 1, + 'degraded' => 0, + 'drifted' => 1, + 'broker_connected' => 1, + ]); + expect($summary['inventory'])->toBe([ + 'total' => 2, + 'online' => 1, + 'offline' => 1, + ]); + expect($summary['bindings'])->toBe([ + 'total' => 3, + 'fallback_overrides' => 2, + 'cloud_only' => 1, + 'local_only' => 1, + ]); + expect($summary['operations'])->toBe([ + 'active' => 1, + 'pending' => 1, + 'in_progress' => 1, + 'backlog' => 2, + ]); + expect($summary['commands'])->toBe([ + 'backlog' => 4, + ]); + expect($summary['system'])->toBe([ + 'latency_ms_avg' => 298, + 'cpu_usage_pct_avg' => 18, + 'memory_usage_pct_avg' => 52, + 'disk_usage_pct_avg' => 67, + ]); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerCommandQueueTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerCommandQueueTest.php index 16b98dee..a4e18df7 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerCommandQueueTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerCommandQueueTest.php @@ -4,50 +4,45 @@ app_require('classes/edge_gateway_manager.php'); use classes\edge_gateway_manager; -it('queues admin commands, exposes agent poll/result handlers, and keeps heartbeats from mutating discovery state', function (): void { - $source = file_get_contents(app_path('classes/edge_gateway_manager.php')); +it('keeps relay dispatch and discovery queueing on the edge gateway manager', function (): void { + $managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php')); + $operationServiceSource = file_get_contents(app_path('classes/edge_gateway_operation_service.php')); - expect($source)->not->toBeFalse(); - expect($source)->toContain('public function pollCommand'); - expect($source)->toContain('public function submitCommandResult'); - expect($source)->toContain('private function waitForCommandResult'); - expect($source)->toContain('private function claimNextCommandJob'); - expect($source)->toContain("COALESCE(updated_at, created_at, requested_at) <= :stale_before"); - expect($source)->toContain("throw new Exception('Edge gateway command timed out');"); - expect($source)->toContain("throw new Exception('Gateway agent is offline');"); - expect($source)->toContain("\$gateway->discovery_status->set('PENDING');"); - expect($source)->toContain("\$gateway->discovery_status->set('READY');"); - expect($source)->toContain("\$gateway->discovery_status->set('FAILED');"); - expect($source)->toContain("\$this->buildUpdateCommandPayload(\$targetVersion, \$releaseChannel)"); - expect($source)->toContain("'UNINSTALL_AGENT'"); - expect($source)->toContain("public function queueUninstall"); - expect($source)->toContain("public function deleteGateway"); - expect($source)->toContain("private function softDeleteGatewayRelations"); - expect($source)->toContain("\$updateJob->status->set('DISPATCHING');"); - expect($source)->toContain("\$updateJob->status->set('VERIFYING');"); - expect($source)->toContain("\$updateJob->status->set(\$finalStatus ?? (\$ok ? 'COMPLETED' : 'FAILED'));"); - expect($source)->toContain("\$this->applyHeartbeatUpdateLifecycle(\$gateway, \$payload);"); - expect($source)->not->toContain("\$gateway->discovery_status->set((string)(\$payload['discovery_status'] ?? \$gateway->discovery_status->value()));"); + expect($managerSource)->not->toBeFalse(); + expect($managerSource)->toContain('public function queueDiscovery'); + expect($managerSource)->toContain('public function dispatchRelayStatus'); + expect($managerSource)->toContain('public function dispatchRelaySwitch'); + expect($managerSource)->toContain('private function createCommandJob'); + expect($managerSource)->toContain('public function buildUpdateOperationRequest'); + expect($managerSource)->toContain('public function rotateGatewayCredentials'); + expect($operationServiceSource)->toContain('public function queueOperation'); + expect($operationServiceSource)->toContain('public function claimNextOperation'); + expect($operationServiceSource)->toContain('public function completeAgentOperation'); + expect($operationServiceSource)->toContain('public const OPERATION_LEASE_SECONDS = 45;'); + expect($operationServiceSource)->toContain('private function refreshOperationLease'); + expect($operationServiceSource)->toContain('agent_instance_id'); + expect($operationServiceSource)->toContain('lease_expires_at'); + expect($operationServiceSource)->toContain("'operation_type' => \$type"); + expect($managerSource)->toContain("command_type"); }); -it('defines the dispatchable gateway guard and api-polled shell queue on the loaded manager class', function (): void { - $reflection = new \ReflectionClass(edge_gateway_manager::class); +it('loads relay command helpers on the manager and gateway operations on the dedicated service', function (): void { + $reflection = new ReflectionClass(edge_gateway_manager::class); + $operationServiceReflection = new ReflectionClass(\classes\edge_gateway_operation_service::class); - expect($reflection->hasMethod('requireDispatchableGateway'))->toBeTrue(); - expect($reflection->getMethod('requireDispatchableGateway')->isPrivate())->toBeTrue(); expect($reflection->hasMethod('queueDiscovery'))->toBeTrue(); - expect($reflection->hasMethod('queueUpdate'))->toBeTrue(); - expect($reflection->hasMethod('queueUninstall'))->toBeTrue(); - expect($reflection->hasMethod('deleteGateway'))->toBeTrue(); - expect($reflection->hasMethod('buildUpdateCommandPayload'))->toBeTrue(); - expect($reflection->hasMethod('applyHeartbeatUpdateLifecycle'))->toBeTrue(); + expect($reflection->hasMethod('pollCommand'))->toBeTrue(); + expect($reflection->hasMethod('submitCommandResult'))->toBeTrue(); expect($reflection->hasMethod('dispatchRelayStatus'))->toBeTrue(); expect($reflection->hasMethod('dispatchRelaySwitch'))->toBeTrue(); - expect($reflection->hasMethod('pollShellAction'))->toBeTrue(); - expect($reflection->hasMethod('submitShellActionResult'))->toBeTrue(); - expect($reflection->hasMethod('recordShellSessionEvents'))->toBeTrue(); - expect($reflection->hasMethod('pollShellSessionEvents'))->toBeTrue(); - expect($reflection->hasMethod('enqueueShellInput'))->toBeTrue(); - expect($reflection->hasMethod('enqueueShellResize'))->toBeTrue(); - expect($reflection->hasMethod('requestShellSessionClose'))->toBeTrue(); + expect($reflection->hasMethod('claimNextCommandJob'))->toBeTrue(); + expect($reflection->getMethod('claimNextCommandJob')->isPrivate())->toBeTrue(); + expect($reflection->hasMethod('syncDeviceInventory'))->toBeTrue(); + expect($reflection->getMethod('syncDeviceInventory')->isPrivate())->toBeTrue(); + expect($reflection->hasMethod('syncGatewayInventory'))->toBeTrue(); + expect($operationServiceReflection->hasMethod('queueOperation'))->toBeTrue(); + expect($operationServiceReflection->hasMethod('listOperations'))->toBeTrue(); + expect($operationServiceReflection->hasMethod('claimNextOperation'))->toBeTrue(); + expect($operationServiceReflection->hasMethod('appendAgentOperationEvent'))->toBeTrue(); + expect($operationServiceReflection->hasMethod('completeAgentOperation'))->toBeTrue(); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerHeartbeatStatusTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerHeartbeatStatusTest.php index 5c1dd5ee..f8757055 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerHeartbeatStatusTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerHeartbeatStatusTest.php @@ -68,7 +68,7 @@ it('merges incoming heartbeat metadata with existing gateway metadata', function expect($source)->toContain("\$gateway->metadata_json->set(array_merge(\$existingMetadata, (array)(\$payload['metadata'] ?? [])));"); }); -it('derives relay fallback and transport health details for degraded hybrid control planes', function (): void { +it('derives relay fallback and transport health details without shell or update runtime state', function (): void { $gateway = edge_gateway_manager::deriveGatewayRuntimeState([ 'status' => edge_gateway_manager::STATUS_ONLINE, 'last_heartbeat_at' => '2026-04-08 10:04:30', @@ -82,8 +82,13 @@ it('derives relay fallback and transport health details for degraded hybrid cont ], 'operational_snapshot' => [ 'command_backlog' => 2, - 'shell_backlog' => 1, - 'update_backlog' => 1, + 'operation_backlog' => 1, + ], + 'active_operation' => [ + 'id' => 91, + 'type' => 'DISCOVERY', + 'status' => 'IN_PROGRESS', + 'started_at' => '2026-04-08 09:30:00', ], 'bindings' => [ [ @@ -113,15 +118,11 @@ it('derives relay fallback and transport health details for degraded hybrid cont 'completed_at' => '2026-04-08 10:02:00', ], ], - 'recent_shell_sessions' => [ - [ - 'opened_at' => '2026-04-08 10:03:00', - ], - ], ], strtotime('2026-04-08 10:05:00')); expect($gateway['channel_status']['command']['active'])->toBe(edge_gateway_manager::DELIVERY_CHANNEL_API); expect($gateway['channel_status']['broker']['state'])->toBe(edge_gateway_manager::STATUS_OFFLINE); + expect($gateway['channel_status'])->not->toHaveKey('shell'); expect($gateway['relay_health'][0]['execution_path'])->toBe('cloud'); expect($gateway['relay_health'][0]['reason'])->toBe('device_stale'); expect($gateway['relay_health'][0]['recommended_action'])->toBe('retry_discovery'); @@ -132,10 +133,10 @@ it('derives relay fallback and transport health details for degraded hybrid cont expect($gateway['transport_health']['status'])->toBe(edge_gateway_manager::STATUS_DEGRADED); expect($gateway['transport_health']['recommended_action'])->toBe('retry_discovery'); expect($gateway['last_successful_discovery_at'])->toBe('2026-04-08 10:02:00'); - expect($gateway['last_successful_shell_at'])->toBe('2026-04-08 10:03:00'); + expect($gateway['diagnostics'])->not->toBeEmpty(); + expect($gateway['error_state']['code'])->toBe('EDGE_GATEWAY_OPERATION_TIMEOUT'); expect($gateway['backlog_depth'])->toBe([ 'commands' => 2, - 'shell_actions' => 1, - 'updates' => 1, + 'operations' => 1, ]); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php index bc098449..23f8b9b8 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php @@ -15,7 +15,6 @@ function with_edge_gateway_server_state(array $server, callable $callback): void { $originalServer = $_SERVER; $originalPublicApiUrl = getenv('EDGE_PUBLIC_API_URL'); - $originalPublicBrokerUrl = getenv('EDGE_PUBLIC_BROKER_URL'); $_SERVER = $server; @@ -28,15 +27,10 @@ function with_edge_gateway_server_state(array $server, callable $callback): void } else { putenv('EDGE_PUBLIC_API_URL=' . $originalPublicApiUrl); } - if ($originalPublicBrokerUrl === false) { - putenv('EDGE_PUBLIC_BROKER_URL'); - } else { - putenv('EDGE_PUBLIC_BROKER_URL=' . $originalPublicBrokerUrl); - } } } -it('builds install script urls with forwarded https scheme when proxied', function (): void { +it('builds install script urls with the PHP agent artifacts and forwarded https scheme', function (): void { with_edge_gateway_server_state([ 'HTTP_HOST' => 'api.truckwash.io:4433', 'HTTP_X_FORWARDED_PROTO' => 'https', @@ -48,18 +42,17 @@ it('builds install script urls with forwarded https scheme when proxied', functi expect($manager->buildInstallTokenVerifyUrl('abc123'))->toBe('https://api.truckwash.io:4433/edge-agent/install-token/verify?token=abc123'); expect($manager->buildInstallScriptUrl('abc123'))->toBe('https://api.truckwash.io:4433/edge-agent/install.sh?token=abc123'); expect($manager->buildInstallCommand('abc123'))->toContain("https://api.truckwash.io:4433/edge-agent/install.sh?token=abc123"); - expect($script)->toContain('curl -fsSL "https://api.truckwash.io:4433/edge-agent/install-token/verify?token=abc123" >/dev/null'); - expect($script)->toContain('mkdir -p "$INSTALL_DIR"'); - expect($script)->toContain('curl -fsSL "https://api.truckwash.io:4433/edge-agent/artifacts/package.json" -o "$INSTALL_DIR/package.json"'); - expect($script)->toContain('curl -fsSL "https://api.truckwash.io:4433/edge-agent/artifacts/agent.mjs" -o "$INSTALL_DIR/agent.mjs"'); + expect($script)->toContain('fetch_http "Verify install token" "https://api.truckwash.io:4433/edge-agent/install-token/verify?token=abc123"'); + expect($script)->toContain('fetch_http "Download PHP edge agent" "https://api.truckwash.io:4433/edge-agent/artifacts/agent.php" "$INSTALL_DIR/agent.php"'); + expect($script)->toContain('fetch_http "Download systemd service unit" "https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-agent.service" "$INSTALL_DIR/truckwash-edge-agent.service"'); + expect($script)->toContain('Installer failed during step: ${CURRENT_STEP:-unknown}'); + expect($script)->toContain('Last request: ${CURRENT_METHOD} ${CURRENT_URL}'); + expect($script)->toContain('Response body preview (first 400 bytes):'); expect($script)->toContain('"apiUrl":"https://api.truckwash.io:4433"'); - expect($script)->toContain('"installDir":"/opt/truckwash-edge-agent"'); expect($script)->toContain('"serviceName":"truckwash-edge-agent.service"'); - expect($script)->toContain('"commandPollTimeoutSeconds":20'); - expect($script)->toContain('"shellActionPollTimeoutSeconds":20'); - expect($script)->toContain('"updateVerificationTimeoutSeconds":45'); - expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4300"'); - expect($script)->not->toContain('Undefined variable $INSTALL_DIR'); + expect($script)->toContain('"operationPollTimeoutSeconds":20'); + expect($script)->not->toContain('"shellActionPollTimeoutSeconds"'); + expect($script)->not->toContain('"brokerUrl"'); }); }); @@ -75,47 +68,19 @@ it('appends forwarded ports when the forwarded host omits them', function (): vo }); }); -it('infers https for the staging api host when only the https port is present', function (): void { - with_edge_gateway_server_state([ - 'HTTP_HOST' => 'api.truckwash.io:4433', - 'SERVER_PORT' => '4433', - ], function (): void { - $manager = new EdgeGatewayManagerUrlHarness(); - $script = $manager->buildInstallScript('port-only'); - - expect($manager->getApiBaseUrl())->toBe('https://api.truckwash.io:4433'); - expect($script)->toContain('"apiUrl":"https://api.truckwash.io:4433"'); - expect($script)->toContain('"serviceName":"truckwash-edge-agent.service"'); - expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4300"'); - }); -}); - it('prefers EDGE_PUBLIC_API_URL when explicitly configured', function (): void { with_edge_gateway_server_state([ 'HTTP_HOST' => 'localhost', 'HTTP_X_FORWARDED_PROTO' => 'http', ], function (): void { putenv('EDGE_PUBLIC_API_URL=https://edge.example.test/api'); - putenv('EDGE_PUBLIC_BROKER_URL=https://broker.edge.example.test'); $manager = new EdgeGatewayManagerUrlHarness(); + $script = $manager->buildInstallScript('token-1'); expect($manager->getApiBaseUrl())->toBe('https://edge.example.test/api'); expect($manager->buildInstallScriptUrl('token-1'))->toBe('https://edge.example.test/api/edge-agent/install.sh?token=token-1'); - expect($manager->buildInstallScript('token-1'))->toContain('"brokerUrl":"https://broker.edge.example.test"'); - }); -}); - -it('includes node-pty build prerequisites in the generated install script', function (): void { - with_edge_gateway_server_state([ - 'HTTP_HOST' => 'api.truckwash.io:4433', - 'HTTP_X_FORWARDED_PROTO' => 'https', - ], function (): void { - $manager = new EdgeGatewayManagerUrlHarness(); - $script = $manager->buildInstallScript('pty-token'); - - expect($script)->toMatch('/set -euo pipefail\s+curl -fsSL "https:\/\/api\.truckwash\.io:4433\/edge-agent\/install-token\/verify\?token=pty-token" >\/dev\/null\s+INSTALL_DIR=\/opt\/truckwash-edge-agent/'); - expect($script)->toContain('apt-get install -y curl ca-certificates nodejs npm python3 make g++'); - expect($script)->toContain('npm install --omit=dev'); + expect($script)->toContain('"apiUrl":"https://edge.example.test/api"'); + expect($script)->not->toContain('"brokerUrl"'); }); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayMetadataUpdateContractTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayMetadataUpdateContractTest.php new file mode 100644 index 00000000..524b78cb --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayMetadataUpdateContractTest.php @@ -0,0 +1,29 @@ +not->toBeFalse(); + expect($routeSource)->toContain("put('/edge-gateways/{id}'"); + expect($routeSource)->toContain("self::requireParameters(['label', 'is_primary']);"); + expect($routeSource)->toContain("self::requireType(self::getParameter('label'), self::TYPE_STRING());"); + expect($routeSource)->toContain("self::requireType(self::getParameter('is_primary'), self::TYPE_BOOL());"); + expect($routeSource)->toContain('updateGatewayMetadata('); +}); + +it('reassigns department primary gateways through dedicated manager helpers', function (): void { + $source = file_get_contents(app_path('classes/edge_gateway_manager.php')); + $reflection = new ReflectionClass(edge_gateway_manager::class); + + expect($source)->not->toBeFalse(); + expect($source)->toContain("\$this->setGatewayPrimaryState(\$gateway, true);"); + expect($source)->toContain("\$replacement = \$this->findAlternateGatewayForDepartment("); + expect($source)->toContain("throw new Exception('Department must retain a primary gateway');"); + expect($reflection->hasMethod('updateGatewayMetadata'))->toBeTrue(); + expect($reflection->hasMethod('setGatewayPrimaryState'))->toBeTrue(); + expect($reflection->hasMethod('findAlternateGatewayForDepartment'))->toBeTrue(); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayOperationLegacySchemaCompatibilityTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayOperationLegacySchemaCompatibilityTest.php new file mode 100644 index 00000000..20791da4 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayOperationLegacySchemaCompatibilityTest.php @@ -0,0 +1,17 @@ +not->toBeFalse(); + expect($operationServiceContent)->not->toBeFalse(); + expect($operationObjectContent)->not->toBeFalse(); + + expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'operation_type', \"VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER type\")"); + expect($bootstrapContent)->toContain('private static function syncOperationTypeColumns(): void'); + expect($bootstrapContent)->toContain('SET type = operation_type'); + expect($operationServiceContent)->toContain("'operation_type' => \$type"); + expect($operationObjectContent)->toContain("new object_property(\$this->table, \$this->id, 'operation_type', 'string', false)"); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php index 5fc80e32..ff6cd6cd 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php @@ -1,6 +1,6 @@ not->toBeFalse(); @@ -9,42 +9,32 @@ it('registers the edge gateway management REST endpoints', function (): void { expect($route)->toContain("'/edge-gateways/install-token'"); expect($route)->toContain("'/edge-gateways/{id}/discovery'"); expect($route)->toContain("'/edge-gateways/{id}/bindings'"); - expect($route)->toContain("'/edge-gateways/{id}/update-jobs'"); - expect($route)->toContain("'/edge-gateways/{id}/uninstall'"); - expect($route)->toContain("'/edge-gateways/{id}/shell-sessions'"); - expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/events'"); - expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/input'"); - expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/resize'"); - expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/close'"); + expect($route)->toContain("'/edge-gateways/{id}/operations'"); + expect($route)->toContain("'/edge-gateways/{id}/operations/{operationId}/events'"); expect($route)->toContain("'/edge-gateways/{id}/rotate-credentials'"); - expect($route)->toContain("'/edge-gateways/{id}'"); expect($route)->toContain("'/departments/{id}/gateway-cutover'"); + expect($route)->toContain("add_meta('fleet_usage'"); + expect($route)->not->toContain("'/edge-gateways/{id}/shell-sessions'"); + expect($route)->not->toContain('private function requirePermission'); + expect($route)->not->toContain('private function requireDepartmentAccess'); }); -it('registers public installer, claim, heartbeat, command polling, and shell polling endpoints', function (): void { +it('registers PHP edge agent routes for operations and legacy relay command polling', function (): void { $route = file_get_contents(app_path('routes/edgeGatewaysRoute.php')); expect($route)->toContain("'/edge-agent/install-token/verify'"); expect($route)->toContain("'/edge-agent/install.sh'"); - expect($route)->toContain("'/edge-agent/artifacts/agent.mjs'"); - expect($route)->toContain("'/edge-agent/artifacts/package.json'"); + expect($route)->toContain("'/edge-agent/artifacts/agent.php'"); + expect($route)->toContain("'/edge-agent/artifacts/truckwash-edge-agent.service'"); expect($route)->toContain("'/edge-agent/claim'"); expect($route)->toContain("'/edge-agent/gateways/{id}/heartbeat'"); + expect($route)->toContain("'/edge-agent/gateways/{id}/operations/next'"); + expect($route)->toContain("'/edge-agent/gateways/{id}/operations/{operationId}/events'"); + expect($route)->toContain("'/edge-agent/gateways/{id}/operations/{operationId}/complete'"); expect($route)->toContain("'/edge-agent/gateways/{id}/commands/poll'"); expect($route)->toContain("'/edge-agent/gateways/{id}/commands/{jobId}/result'"); - expect($route)->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'"); - expect($route)->toContain("'/edge-agent/gateways/{id}/shell-actions/{actionId}/result'"); - expect($route)->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'"); -}); - -it('registers authenticated internal broker validation and presence endpoints', function (): void { - $route = file_get_contents(app_path('routes/edgeGatewaysRoute.php')); - - expect($route)->not->toBeFalse(); - expect($route)->toContain("'/edge-agent/internal/gateways/{id}/validate'"); - expect($route)->toContain("'/edge-agent/internal/shell-sessions/validate'"); - expect($route)->toContain("'/edge-agent/internal/gateways/{id}/presence'"); - expect($route)->toContain("'/edge-agent/internal/shell-sessions/close'"); - expect($route)->toContain('HTTP_X_EDGE_BROKER_SECRET'); - expect($route)->toContain('EDGE_BROKER_SHARED_SECRET'); + expect($route)->toContain("'/edge-agent/gateways/{id}/presence'"); + expect($route)->toContain('echo $exception->getMessage()'); + expect($route)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'"); + expect($route)->not->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'"); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewaySchemaBootstrapTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewaySchemaBootstrapTest.php index a6634a23..58c02c00 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewaySchemaBootstrapTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewaySchemaBootstrapTest.php @@ -1,6 +1,6 @@ not->toBeFalse(); @@ -9,24 +9,38 @@ it('defines the edge gateway runtime schema bootstrap tables', function (): void expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_device_inventory'); expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_relay_bindings'); expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_command_jobs'); - expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_update_jobs'); - expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_shell_sessions'); - expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_shell_action_jobs'); - expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_shell_events'); + expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_operations'); + expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_operation_events'); expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_audit_logs'); + expect($bootstrapContent)->not->toContain('edge_gateway_shell_sessions'); + expect($bootstrapContent)->not->toContain('edge_gateway_shell_action_jobs'); + expect($bootstrapContent)->not->toContain('edge_gateway_shell_events'); }); -it('stores edge gateway heartbeats, bindings, shell transcripts, and shell polling queues', function (): void { +it('stores operation metadata and event timelines for management workflows', function (): void { $bootstrapContent = file_get_contents(app_path('classes/edge_gateway_schema_bootstrap.php')); - expect($bootstrapContent)->toContain('last_heartbeat_at DATETIME NULL'); - expect($bootstrapContent)->toContain('command_job_id INT NULL'); - expect($bootstrapContent)->toContain('channel INT NOT NULL DEFAULT 0'); - expect($bootstrapContent)->toContain('transcript_text LONGTEXT NULL'); - expect($bootstrapContent)->toContain('action_type VARCHAR(32) NOT NULL'); - expect($bootstrapContent)->toContain('payload_json JSON NULL'); - expect($bootstrapContent)->toContain('event_type VARCHAR(32) NOT NULL'); + expect($bootstrapContent)->toContain('command_type VARCHAR(64) NOT NULL'); + expect($bootstrapContent)->toContain('delivery_json JSON NULL'); + expect($bootstrapContent)->toContain("fallback_mode VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL'"); + expect($bootstrapContent)->toContain('type VARCHAR(32) NOT NULL'); + expect($bootstrapContent)->toContain("operation_type VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY'"); + expect($bootstrapContent)->toContain('agent_instance_id VARCHAR(128) NULL'); + expect($bootstrapContent)->toContain('lease_expires_at DATETIME NULL'); + expect($bootstrapContent)->toContain('last_progress_at DATETIME NULL'); + expect($bootstrapContent)->toContain('attempt_count INT NOT NULL DEFAULT 0'); + expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'type', \"VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER gateway_id\")"); + expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'operation_type', \"VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER type\")"); + expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'summary_json', 'JSON NULL AFTER request_json')"); + expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'agent_instance_id', 'VARCHAR(128) NULL AFTER correlation_id')"); + expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'lease_expires_at', 'DATETIME NULL AFTER agent_instance_id')"); + expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'last_progress_at', 'DATETIME NULL AFTER lease_expires_at')"); + expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'attempt_count', 'INT NOT NULL DEFAULT 0 AFTER last_progress_at')"); + expect($bootstrapContent)->toContain('private static function syncOperationTypeColumns(): void'); + expect($bootstrapContent)->toContain('SET type = operation_type'); + expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operation_events', 'context_json', 'JSON NULL AFTER message')"); + expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_audit_logs', 'context_json', 'JSON NULL AFTER severity')"); + expect($bootstrapContent)->toContain('summary_json JSON NULL'); expect($bootstrapContent)->toContain('context_json JSON NULL'); - expect(substr_count($bootstrapContent, 'delivery_json JSON NULL'))->toBeGreaterThanOrEqual(3); - expect($bootstrapContent)->toContain('ADD COLUMN delivery_json JSON NULL'); + expect($bootstrapContent)->not->toContain('session_token_hash CHAR(64) NOT NULL'); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayShellPollingTransportTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayShellPollingTransportTest.php index 0ff713c8..579db874 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayShellPollingTransportTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayShellPollingTransportTest.php @@ -1,36 +1,15 @@ not->toBeFalse(); expect($routeSource)->not->toBeFalse(); - expect($managerSource)->toContain("'transport' => self::DELIVERY_CHANNEL_API"); - expect($managerSource)->toContain("'transport_path' => self::DELIVERY_CHANNEL_API"); - expect($managerSource)->toContain("'reconnect_state' => 'PENDING'"); - expect($managerSource)->toContain("'preferred_channel' => self::DELIVERY_CHANNEL_API"); - expect($managerSource)->toContain('private function createShellActionJob'); - expect($managerSource)->toContain('private function claimNextShellActionJob'); - expect($managerSource)->toContain('private function appendShellEvent'); - expect($managerSource)->toContain('private function listShellEvents'); - expect($managerSource)->toContain('public function pollShellAction'); - expect($managerSource)->toContain('public function submitShellActionResult'); - expect($managerSource)->toContain('public function recordShellSessionEvents'); - expect($managerSource)->not->toContain('buildBrowserShellWsUrl'); - expect($managerSource)->not->toContain('edge_broker_client'); - - expect($routeSource)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/events'"); - expect($routeSource)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/input'"); - expect($routeSource)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/resize'"); - expect($routeSource)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/close'"); - expect($routeSource)->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'"); - expect($routeSource)->toContain("'/edge-agent/gateways/{id}/shell-actions/{actionId}/result'"); - expect($routeSource)->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'"); - expect($routeSource)->toContain("'/edge-agent/internal/gateways/{id}/validate'"); - expect($routeSource)->toContain("'/edge-agent/internal/shell-sessions/validate'"); - expect($routeSource)->toContain("'/edge-agent/internal/gateways/{id}/presence'"); - expect($routeSource)->toContain("'/edge-agent/internal/shell-sessions/close'"); - expect($routeSource)->toContain('requireBrokerSharedSecret'); + expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/events'"); + expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/input'"); + expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/resize'"); + expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/close'"); + expect($routeSource)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'"); + expect($routeSource)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/{actionId}/result'"); + expect($routeSource)->not->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'"); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php index c665ce98..d47e4ab9 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php @@ -1,22 +1,43 @@ not->toBeFalse(); - expect($source)->toContain("'artifactUrl' => \$this->buildAgentArtifactUrl('agent.mjs')"); - expect($source)->toContain("'artifactSha256' => \$this->buildAgentArtifactSha256('agent.mjs')"); - expect($source)->toContain("'packageUrl' => \$this->buildAgentArtifactUrl('package.json')"); - expect($source)->toContain("'packageSha256' => \$this->buildAgentArtifactSha256('package.json')"); - expect($source)->toContain("'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME"); + expect($managerSource)->not->toBeFalse(); + expect($managerSource)->toContain("'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS"); + expect($managerSource)->toContain('fetch_http "Verify install token" "__VERIFY_URL__"'); + expect($managerSource)->toContain('fetch_http "Download PHP edge agent" "__AGENT_URL__" "$INSTALL_DIR/agent.php"'); + expect($managerSource)->toContain('fetch_http "Download systemd service unit" "__SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-agent.service"'); + expect($managerSource)->toContain('log_error "Request: GET ${url}"'); + expect($managerSource)->toContain('log_error "Response body preview (first 400 bytes):"'); + expect($managerSource)->toContain('apt-get install -y curl ca-certificates php-cli php-curl php-mbstring'); + expect($managerSource)->toContain('Existing claimed gateway detected; reinstall will reuse saved gateway credentials.'); + expect($managerSource)->toContain('run_step "Writing agent config" merge_agent_config "$CONFIG_TEMPLATE_PATH" "$CONFIG_PATH"'); + expect($managerSource)->toContain('systemctl enable truckwash-edge-agent.service'); + expect($managerSource)->toContain('systemctl restart truckwash-edge-agent.service'); + expect($managerSource)->toContain('systemctl is-active --quiet truckwash-edge-agent.service'); + expect($managerSource)->toContain('run_step "Waiting for gateway claim" wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 30'); + expect($managerSource)->toContain('run_step "Waiting for post-reinstall heartbeat" wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 30'); + expect($managerSource)->toContain('journalctl -u truckwash-edge-agent.service -n 40 --no-pager || true'); + expect($managerSource)->not->toContain('agent.mjs'); + expect($managerSource)->not->toContain('"brokerUrl"'); + expect($serviceSource)->toContain('ExecStart=/usr/bin/php /opt/truckwash-edge-agent/agent.php --config /opt/truckwash-edge-agent/config.json'); + expect($serviceSource)->not->toContain('node /opt/truckwash-edge-agent/agent.mjs'); }); -it('finalizes active update jobs from heartbeat metadata when the agent reports completion or rollback', function (): void { - $source = file_get_contents(app_path('classes/edge_gateway_manager.php')); +it('exposes update payload, credential rotation, and operation endpoints without shell transport wiring', function (): void { + $managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php')); + $routeSource = file_get_contents(app_path('routes/edgeGatewaysRoute.php')); + $agentSource = file_get_contents(app_path('resources/edge-gateway-agent/agent.php')); - expect($source)->toContain("\$state = strtoupper(trim((string)(\$lastUpdate['state'] ?? '')));"); - expect($source)->toContain("if (!in_array(\$state, ['COMPLETED', 'FAILED', 'ROLLED_BACK'], true)) {"); - expect($source)->toContain("\$updateJob->status->set(\$state);"); - expect($source)->toContain("'heartbeat_installed_version' => \$payload['installed_version'] ?? \$gateway->installed_version->value()"); - expect($source)->toContain("'heartbeat_target_version' => \$payload['target_version'] ?? \$gateway->target_version->value()"); + expect($managerSource)->toContain('public function buildUpdateOperationRequest'); + expect($managerSource)->toContain('public function rotateGatewayCredentials'); + expect($routeSource)->toContain("'/edge-gateways/{id}/rotate-credentials'"); + expect($routeSource)->toContain("'/edge-agent/gateways/{id}/operations/next'"); + expect($agentSource)->toContain("/operations/next"); + expect($agentSource)->toContain("/operations/' . \$operationId . '/complete"); + expect($agentSource)->toContain('last-heartbeat-ok.txt'); + expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions'"); + expect($routeSource)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'"); }); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php index 0badf84b..94861641 100644 --- a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php @@ -108,7 +108,7 @@ it('calculates workfeed employee hours across multiple departments for one hour expect($hours)->toBe(2.0); }); -it('counts overtime minutes when a shift carries an extended approval end timestamp', function (): void { +it('counts overtime minutes when a saved shift end extends past the approved original end', function (): void { $route = new moduleWeatherAPIRoute(); $slot = new DateTime('2026-03-23T18:00:00+00:00'); @@ -116,9 +116,9 @@ it('counts overtime minutes when a shift carries an extended approval end timest (object)[ 'departmentID' => 'dep_1', 'start' => '2026-03-23T10:00:00+00:00', - 'end' => '2026-03-23T18:00:00+00:00', + 'end' => '2026-03-23T18:17:00+00:00', 'approval' => (object)[ - 'originalEnd' => '2026-03-23T18:17:00+00:00', + 'originalEnd' => '2026-03-23T18:00:00+00:00', ], ], ]; @@ -128,6 +128,26 @@ it('counts overtime minutes when a shift carries an extended approval end timest expect($hours)->toBe(0.28); }); +it('does not count removed approved time when a saved shift end is shortened', function (): void { + $route = new moduleWeatherAPIRoute(); + $slot = new DateTime('2026-03-23T17:00:00+00:00'); + + $shifts = [ + (object)[ + 'departmentID' => 'dep_1', + 'start' => '2026-03-23T10:00:00+00:00', + 'end' => '2026-03-23T17:00:00+00:00', + 'approval' => (object)[ + 'originalEnd' => '2026-03-23T18:00:00+00:00', + ], + ], + ]; + + $hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot]); + + expect($hours)->toBe(0.0); +}); + it('counts unapproved overtime from a bounded shift updateTime fallback', function (): void { $route = new moduleWeatherAPIRoute(); $slot = new DateTime('2026-03-23T17:00:00+00:00'); @@ -166,6 +186,27 @@ it('does not treat late unapproved administrative edits as overtime', function ( expect($hours)->toBe(0.0); }); +it('caps current-slot hours to elapsed minutes and zeroes future slots', function (): void { + $route = new moduleWeatherAPIRoute(); + $currentSlot = new DateTime('2026-03-24T13:00:00+00:00'); + $futureSlot = new DateTime('2026-03-24T14:00:00+00:00'); + $occurredUntil = new DateTime('2026-03-24T13:15:00+00:00'); + + $shifts = [ + (object)[ + 'departmentID' => 'dep_1', + 'start' => '2026-03-24T13:00:00+00:00', + 'end' => '2026-03-24T15:00:00+00:00', + ], + ]; + + $currentHours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $currentSlot, $occurredUntil]); + $futureHours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $futureSlot, $occurredUntil]); + + expect($currentHours)->toBe(0.25); + expect($futureHours)->toBe(0.0); +}); + it('normalizes department id input from scalar csv and nested array values', function (): void { $route = new moduleWeatherAPIRoute(); diff --git a/services/nginx/app/traits/module_config_t.php b/services/nginx/app/traits/module_config_t.php index a5468aff..104d01b4 100644 --- a/services/nginx/app/traits/module_config_t.php +++ b/services/nginx/app/traits/module_config_t.php @@ -37,7 +37,7 @@ trait module_config_t { // Check if the request has a variable name and value global $response; - if ($response->getRequestParameter('variable') === null || $response->getRequestParameter('value') === null) { + if ($response->getRequestParameter('variable') === null || !$response->isRequestParameterSet('value')) { $response->error('Variable and value not set', 400); } // Get the variable name and value @@ -144,6 +144,9 @@ trait module_config_t { // If the variable is an int, convert it to an int if ($type == 'integer' || $type == 'int') { + if ($value === null || $value === '') { + return null; + } return (integer)$value; } // If the variable is a boolean, convert it to a boolean diff --git a/services/nginx/app/traits/module_config_variable_t.php b/services/nginx/app/traits/module_config_variable_t.php index b5337eb4..740dbaec 100644 --- a/services/nginx/app/traits/module_config_variable_t.php +++ b/services/nginx/app/traits/module_config_variable_t.php @@ -171,6 +171,14 @@ trait module_config_variable */ function validateVariableValue(mixed $value): bool { + if ($value === null) { + return !$this->config_variable_required; + } + + if ($value === '' && !$this->config_variable_required && $this->config_variable_type === 'int') { + return true; + } + // Check if the value is empty and the variable is required if ($this->config_variable_required && empty($value)) { // If the value is empty and the type is not a boolean, return false diff --git a/services/php/logs-staging/error.log b/services/php/logs-staging/error.log index 4182a4c6..4c73b4d1 100644 --- a/services/php/logs-staging/error.log +++ b/services/php/logs-staging/error.log @@ -4,3 +4,4 @@ [24-Mar-2026 10:12:39 UTC] PHP Warning: PHP Startup: Unable to load dynamic library 'elastic_apm.so' (tried: /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so: cannot open shared object file: No such file or directory), /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so: cannot open shared object file: No such file or directory)) in Unknown on line 0 [01-Apr-2026 10:49:51 UTC] PHP Warning: PHP Startup: Unable to load dynamic library 'elastic_apm.so' (tried: /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so: cannot open shared object file: No such file or directory), /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so: cannot open shared object file: No such file or directory)) in Unknown on line 0 [07-Apr-2026 14:28:09 UTC] PHP Warning: PHP Startup: Unable to load dynamic library 'elastic_apm.so' (tried: /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so: cannot open shared object file: No such file or directory), /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so: cannot open shared object file: No such file or directory)) in Unknown on line 0 +[20-Apr-2026 12:45:29 UTC] PHP Parse error: syntax error, unexpected end of file in Command line code on line 1