[
  {
    "path": ".cargo/config.toml",
    "content": "[target.aarch64-apple-darwin]\nrustflags = [\"-C\", \"link-args=-Wl,-headerpad_max_install_names\"]\n\n[target.x86_64-apple-darwin]\nrustflags = [\"-C\", \"link-args=-Wl,-headerpad_max_install_names\"]\n"
  },
  {
    "path": ".github/GIT.md",
    "content": ""
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: Rust\n\n# on:\n#   push:\n#     branches: [\"main\"]\n#   pull_request:\n#     branches: [\"main\"]\n\nenv:\n  CARGO_TERM_COLOR: always\n  BINARY_NAME: server\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: \"20.x\"\n\n      - name: Install system dependencies\n        run: sudo apt-get update && sudo apt-get install -y libglib2.0-dev pkg-config libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev\n\n      - name: Install npm dependencies\n        run: npm install\n\n      - name: Build\n        run: cargo build --release --verbose\n        working-directory: ./apps/server\n\n      - name: Upload artifact\n        uses: actions/upload-artifact@v4\n        with:\n          name: ${{ env.BINARY_NAME }}\n          path: target/release/${{ env.BINARY_NAME }}\n"
  },
  {
    "path": ".github/workflows/release-macos.yml",
    "content": "name: Release macOS\n\non:\n  push:\n    tags: ['v*']\n  workflow_dispatch:\n\njobs:\n  build:\n    runs-on: macos-14\n    timeout-minutes: 60\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n          cache: 'npm'\n\n      - uses: dtolnay/rust-toolchain@stable\n        with:\n          targets: aarch64-apple-darwin\n\n      - uses: Swatinem/rust-cache@v2\n        with:\n          workspaces: |\n            .\n            apps/desktop/src-tauri\n\n      - name: Install GStreamer & GLib\n        run: brew install gstreamer glib pkg-config\n\n      - name: Import code signing certificate\n        env:\n          APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}\n          APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}\n          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}\n        run: |\n          KEYCHAIN_PATH=\"$RUNNER_TEMP/build.keychain-db\"\n          CERT_PATH=\"$RUNNER_TEMP/cert.p12\"\n\n          security create-keychain -p \"$KEYCHAIN_PASSWORD\" \"$KEYCHAIN_PATH\"\n          security set-keychain-settings -lut 21600 \"$KEYCHAIN_PATH\"\n          security unlock-keychain -p \"$KEYCHAIN_PASSWORD\" \"$KEYCHAIN_PATH\"\n\n          echo -n \"$APPLE_CERTIFICATE\" | base64 --decode -o \"$CERT_PATH\"\n          security import \"$CERT_PATH\" -P \"$APPLE_CERTIFICATE_PASSWORD\" \\\n            -A -t cert -f pkcs12 -k \"$KEYCHAIN_PATH\"\n          security set-key-partition-list -S apple-tool:,apple:,codesign: \\\n            -s -k \"$KEYCHAIN_PASSWORD\" \"$KEYCHAIN_PATH\"\n\n          security list-keychain -d user -s \"$KEYCHAIN_PATH\" login.keychain\n          security default-keychain -s \"$KEYCHAIN_PATH\"\n\n          security find-identity -v -p codesigning \"$KEYCHAIN_PATH\"\n\n      - name: Install npm dependencies\n        run: npm ci\n\n      - name: Build, sign and notarize\n        env:\n          APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}\n          APPLE_ID: ${{ secrets.APPLE_ID }}\n          APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}\n          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}\n          APPLE_ENTITLEMENTS_PATH: ${{ github.workspace }}/apps/desktop/src-tauri/entitlements.plist\n          VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}\n          VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}\n          VITE_CAPSULE_URL: ${{ secrets.VITE_CAPSULE_URL }}\n        working-directory: apps/desktop\n        run: npm run tauri -- build --target aarch64-apple-darwin\n\n      - name: Notarize and staple DMG\n        env:\n          APPLE_ID: ${{ secrets.APPLE_ID }}\n          APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}\n          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}\n        run: |\n          set -e\n          DMG=$(ls target/aarch64-apple-darwin/release/bundle/dmg/*.dmg | head -1)\n          echo \"==> Submitting DMG for notarization: $DMG\"\n          xcrun notarytool submit \"$DMG\" \\\n            --apple-id \"$APPLE_ID\" \\\n            --password \"$APPLE_PASSWORD\" \\\n            --team-id \"$APPLE_TEAM_ID\" \\\n            --wait\n          echo \"==> Stapling DMG\"\n          xcrun stapler staple \"$DMG\"\n\n      - name: Verify signature and notarization\n        run: |\n          set -e\n          BUNDLE_DIR=\"target/aarch64-apple-darwin/release/bundle\"\n          APP=$(ls -d \"$BUNDLE_DIR\"/macos/*.app | head -1)\n          DMG=$(ls \"$BUNDLE_DIR\"/dmg/*.dmg | head -1)\n\n          echo \"==> App: $APP\"\n          echo \"==> DMG: $DMG\"\n\n          codesign -dv --verbose=4 \"$APP\" 2>&1 | grep -E 'Authority|TeamIdentifier|Runtime|Identifier'\n          codesign --verify --deep --strict --verbose=2 \"$APP\"\n          spctl -a -vv -t exec \"$APP\"\n          xcrun stapler validate \"$DMG\"\n          spctl -a -vv -t install \"$DMG\"\n\n      - name: Upload DMG artifact\n        uses: actions/upload-artifact@v4\n        with:\n          name: vessel-macos-arm64\n          path: target/aarch64-apple-darwin/release/bundle/dmg/*.dmg\n          if-no-files-found: error\n\n      - name: Publish to GitHub Release\n        if: startsWith(github.ref, 'refs/tags/')\n        uses: softprops/action-gh-release@v2\n        with:\n          files: target/aarch64-apple-darwin/release/bundle/dmg/*.dmg\n          draft: true\n          generate_release_notes: true\n\n      - name: Cleanup keychain\n        if: always()\n        run: security delete-keychain \"$RUNNER_TEMP/build.keychain-db\" || true\n"
  },
  {
    "path": ".gitignore",
    "content": "/node_modules\ndocs/.vitepress/dist\ndocs/.vitepress/cache\n/target\n.env\ndatabase.db\ndatabase.db-*\n.DS_Store\n*.log.*\n*.log\n/storage\n/config.toml\n/.venv\n/recordings\n/packages/*/node_modules\n/packages/*/dist\n/packages/*/**/node_modules\n/apps/*/**/node_modules\n/supabase"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"semi\": true,\n  \"singleQuote\": false,\n  \"jsxSingleQuote\": true,\n  \"tabWidth\": 2,\n  \"trailingComma\": \"all\",\n  \"arrowParens\": \"always\"\n}\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our\ncommunity a harassment-free experience for everyone, regardless of age, body\nsize, visible or invisible disability, ethnicity, sex characteristics, gender\nidentity and expression, level of experience, education, socio-economic status,\nnationality, personal appearance, race, religion, or sexual identity\nand orientation.\n\nWe pledge to act and interact in ways that contribute to an open, welcoming,\ndiverse, inclusive, and healthy community.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment for our\ncommunity include:\n\n- Demonstrating empathy and kindness toward other people\n- Being respectful of differing opinions, viewpoints, and experiences\n- Giving and gracefully accepting constructive feedback\n- Accepting responsibility and apologizing to those affected by our mistakes,\n  and learning from the experience\n- Focusing on what is best not just for us as individuals, but for the\n  overall community\n\nExamples of unacceptable behavior include:\n\n- The use of sexualized language or imagery, and sexual attention or\n  advances of any kind\n- Trolling, insulting or derogatory comments, and personal or political attacks\n- Public or private harassment\n- Publishing others' private information, such as a physical or email\n  address, without their explicit permission\n- Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Enforcement Responsibilities\n\nCommunity leaders are responsible for clarifying and enforcing our standards of\nacceptable behavior and will take appropriate and fair corrective action in\nresponse to any behavior that they deem inappropriate, threatening, offensive,\nor harmful.\n\nCommunity leaders have the right and responsibility to remove, edit, or reject\ncomments, commits, code, wiki edits, issues, and other contributions that are\nnot aligned to this Code of Conduct, and will communicate reasons for moderation\ndecisions when appropriate.\n\n## Scope\n\nThis Code of Conduct applies within all community spaces, and also applies when\nan individual is officially representing the community in public spaces.\nExamples of representing our community include using an official e-mail address,\nposting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported to the community leaders responsible for enforcement at\n3457xc@gmail.com.\nAll complaints will be reviewed and investigated promptly and fairly.\n\nAll community leaders are obligated to respect the privacy and security of the\nreporter of any incident.\n\n## Enforcement Guidelines\n\nCommunity leaders will follow these Community Impact Guidelines in determining\nthe consequences for any action they deem in violation of this Code of Conduct:\n\n### 1. Correction\n\n**Community Impact**: Use of inappropriate language or other behavior deemed\nunprofessional or unwelcome in the community.\n\n**Consequence**: A private, written warning from community leaders, providing\nclarity around the nature of the violation and an explanation of why the\nbehavior was inappropriate. A public apology may be requested.\n\n### 2. Warning\n\n**Community Impact**: A violation through a single incident or series\nof actions.\n\n**Consequence**: A warning with consequences for continued behavior. No\ninteraction with the people involved, including unsolicited interaction with\nthose enforcing the Code of Conduct, for a specified period of time. This\nincludes avoiding interactions in community spaces as well as external channels\nlike social media. Violating these terms may lead to a temporary or\npermanent ban.\n\n### 3. Temporary Ban\n\n**Community Impact**: A serious violation of community standards, including\nsustained inappropriate behavior.\n\n**Consequence**: A temporary ban from any sort of interaction or public\ncommunication with the community for a specified period of time. No public or\nprivate interaction with the people involved, including unsolicited interaction\nwith those enforcing the Code of Conduct, is allowed during this period.\nViolating these terms may lead to a permanent ban.\n\n### 4. Permanent Ban\n\n**Community Impact**: Demonstrating a pattern of violation of community\nstandards, including sustained inappropriate behavior, harassment of an\nindividual, or aggression toward or disparagement of classes of individuals.\n\n**Consequence**: A permanent ban from any sort of public interaction within\nthe community.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage],\nversion 2.0, available at\nhttps://www.contributor-covenant.org/version/2/0/code_of_conduct.html.\n\nCommunity Impact Guidelines were inspired by [Mozilla's code of conduct\nenforcement ladder](https://github.com/mozilla/diversity).\n\n[homepage]: https://www.contributor-covenant.org\n\nFor answers to common questions about this code of conduct, see the FAQ at\nhttps://www.contributor-covenant.org/faq. Translations are available at\nhttps://www.contributor-covenant.org/translations.\n"
  },
  {
    "path": "CODE_RULE.md",
    "content": "# Coding Rules\n\nThis document establishes the official coding standards for all mission-critical software. Adherence to these rules is mandatory to ensure maximum safety, reliability, and security. The guiding principle is to produce code that is robust, predictable, and resilient under all operating conditions by managing the entire development lifecycle, not just the code itself.\n\n## ## Core Principles\n\n### 1. Fail-Safe Principle\n\nAll systems must be designed to default to a safe state in the event of any failure. The software must anticipate failures and handle them gracefully without compromising the integrity of the mission or system.\n\n- **R1.1: No Silent Failures.** All fallible operations **must** return an explicit result type (e.g., `Result<T, E>` or a `Promise<T>`). The consuming code **must** exhaustively handle both success and failure states. Linter rules must be configured to fail the build on any unhandled error or result.\n- **R1.2: Graceful Degradation.** In the event of a non-critical component failure, the system must continue to operate, albeit in a degraded mode. The software must be able to isolate faults and prevent them from cascading.\n- **R1.3: Explicit Resource Management.** Leverage language features for automatic resource cleanup where available (e.g., RAII). For resources not managed automatically, cleanup **must** be guaranteed using deterministic patterns like `try...finally` blocks.\n\n### 2. Deterministic Behavior\n\nThe software's output must depend solely on its inputs, and its execution time must be predictable and bounded. Non-deterministic behavior introduces unacceptable risk.\n\n- **R2.1: Minimize and Isolate Dynamic Allocation.** After an initial setup phase, dynamic heap allocation should be avoided in performance-critical loops. Where unavoidable, use patterns like object pooling and pre-allocated buffers with fixed capacity to control memory usage.\n- **R2.2: Forbid Recursion.** Recursive function calls are prohibited. All recursive algorithms **must** be implemented iteratively to prevent stack overflow errors.\n- **R2.3: Avoid Floating-Point Ambiguity.** Never compare floating-point numbers for exact equality. Comparisons **must** be made within a defined tolerance (`epsilon`).\n- **R2.4: Use Fixed-Size Data Structures.** All data structures, including arrays and buffers, **must** have a fixed, statically defined maximum size to prevent buffer overflows.\n- **R2.5: Use Named Constants over Magic Numbers.** Do not use unexplained numeric literals. Use the language's constant features (`const`, `static`, `enum`) to define all such values.\n\n### 3. Security by Design\n\nSecurity is not an afterthought; it is an integral part of the design process. The system must be architected to be secure from the ground up.\n\n- **R3.1: Default to Secure.** The default state of any system parameter **must** be the most secure state. Permissions should be denied by default and only explicitly granted.\n- **R3.2: Validate All External Inputs.** All data originating from outside a trusted boundary is considered hostile. It **must** be validated at runtime before use, as compile-time type safety is insufficient for external data.\n- **R3.3: Principle of Least Privilege.** Each software module **must** only be granted the permissions and access rights essential for its designated task.\n- **R3.4: Keep it Simple.** Code complexity is the enemy of security. Avoid complex control flows and deep nesting. Prefer simple, linear logic that is easy to inspect and verify.\n- **R3.5: Ensure Data Integrity.** Use mechanisms like CRCs or checksums to verify the integrity of critical data, especially during transmission or storage.\n\n### 4. Concurrency and Real-Time Behavior\n\nSystems with multiple threads or processes must behave predictably and meet their operational deadlines without fail.\n\n- **R4.1: Avoid Shared Mutable State.** The modification of shared data by multiple threads should be forbidden or strictly controlled. Use language-enforced safety mechanisms where available, and prefer message-passing architectures over shared memory.\n- **R4.2: Bounded Execution Time.** All tasks and functions **must** have a provable worst-case execution time (WCET) to ensure the system can meet its real-time deadlines.\n- **R4.3: Handle Interrupts with Extreme Care.** Interrupt Service Routines (ISRs) **must** be as short and simple as possible. Forbid any non-deterministic operations within an ISR.\n\n### 5. Verification and Validation (V&V)\n\nIt is not enough for code to be well-written; it must be proven to meet its requirements correctly.\n\n- **R5.1: Traceability to Requirements.** Every line of code **must** trace back to a specific, documented requirement. No code should exist that does not fulfill a requirement.\n- **R5.2: Mandatory Peer Reviews.** All code **must** be reviewed by at least one other qualified engineer before being integrated. This is a critical step for quality assurance.\n- **R5.3: Strive for 100% Test Coverage.** All code **must** be unit-tested, with the goal of achieving 100% statement and branch coverage to ensure every execution path is verified.\n\n### 6. Toolchain and Dependency Integrity\n\nThe reliability of the final product depends on the reliability of the tools and libraries used to create it.\n\n- **R6.1: Vet All Third-Party Code.** Any external library or open-source component **must** undergo a rigorous vetting process for security and reliability before it can be used.\n- **R6.2: Lock Compiler and Dependency Versions.** The exact versions of the compiler, libraries, and all other build tools **must** be specified and locked using the language's standard lock file mechanism (e.g., `Cargo.lock`, `package-lock.json`).\n\n---\n\n## Minor Rule\n\n### m1. Everything is Strict\n\nA strict development environment minimizes ambiguity and catches errors at the earliest possible stage.\n\n- **R_m1.1: Zero Tolerance for Warnings.** All available compiler and linter checks **must** be enabled at their strictest levels. Any build that produces a warning is considered a failed build and **must** be corrected.\n- **R_m1.2: Enforce a Single Coding Standard.** All code **must** be formatted using the standard automated tool for the language (e.g., `rustfmt`, `prettier`). The formatter **must** be run as a pre-commit hook.\n- **R_m1.3: Use Comprehensive Static Analysis.** All code **must** pass a comprehensive suite of static analysis tools (e.g., `Clippy`, `ESLint`) without errors before being committed. The linter's ruleset **must** be configured for maximum strictness.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# CONTRIBUTING\n\n...\n\n## Projects Structure\n\n`/apps/client` Frontend code with Vite.  \n`/apps/server` Rust server that connects the physical device to the front end.  \n`/apps/sentinel-go` Audio transfer test code written in Golang.  \n`/apps/landing` Landing page code https://vessel.cartesiancs.com\n\n`/docs` Document code https://vessel.cartesiancs.com/docs  \n`/tests` Test code.\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[workspace]\nmembers = [\n    \"apps/server\",\n    \"apps/desktop/src-tauri\",\n    \"apps/capsule\",\n]\nresolver = \"2\"\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM rust:latest AS builder\n\nRUN apt-get update && apt-get install -y cmake build-essential libglib2.0-dev pkg-config musl-tools libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev python3-dev\n\nRUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \\\n    apt-get install -y nodejs\n\nWORKDIR /app\n\nCOPY Cargo.toml Cargo.lock ./\nCOPY package*.json ./\n\nRUN npm install\n\nCOPY ./apps ./apps\n\nWORKDIR /app/apps/client\n\nRUN npm install\n\nWORKDIR /app/apps/server\n\nRUN cargo build --release\n\nWORKDIR /app/target/release\n\nCMD [\"./server\"]"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "<p align='center'>\n<img src='.github/icon.png' width='140' />\n<h1 align='center'>Vessel</h1>\n\n<p align='center'>\n<a href=\"https://github.com/cartesiancs/vessel/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/cartesiancs/vessel?style=for-the-badge\" /></a>\n<a href=\"https://github.com/cartesiancs/vessel/stargazers\"><img src=\"https://img.shields.io/github/stars/cartesiancs/vessel?style=for-the-badge\" /></a>\n<a href=\"https://github.com/cartesiancs/vessel/issues\"><img src=\"https://img.shields.io/github/issues/cartesiancs/vessel?style=for-the-badge\" /></a>\n<a href=\"https://vessel.cartesiancs.com/\"><img src=\"https://img.shields.io/badge/Website-Live-2563eb?style=for-the-badge\" /></a>\n<a href=\"https://vessel.cartesiancs.com/docs/introduction\"><img src=\"https://img.shields.io/badge/Docs-Ready-7c3aed?style=for-the-badge\" /></a>\n</p>\n\n<p align='center'>\n<a href=\"https://vessel.cartesiancs.com/\">Visit Website</a> · <a href=\"https://github.com/cartesiancs/vessel/issues\">Report Bugs</a> · <a href=\"https://vessel.cartesiancs.com/docs/introduction\">Docs</a> · <a href=\"https://vsl.cartesiancs.com/\">App</a>\n</p>\n\n## About The Project\n\n![banner](./.github/banner.png)\n\nVessel is the **C2 (Command & Control) software** for connecting, monitoring, and orchestrating arrays of physical sensors via an intuitive, visual flow-based interface.\n\nThis project is to build a \"proactive security system\". To achieve this, the following three functions are necessary:\n\n1. **Connect** to Physical Device\n2. **Detect** Threats\n3. **Control** and Respond\n\nThis project solves the problems with existing **home security systems**. Current systems fail to protect against burglaries, trespassing, theft and even war.\n\nSo we plan to open-source the technology used in existing defense systems.\n\nThis system allows you to analyze video and audio sources with AI/ML technology.\n\nAnd automate actions through Flow-based operations. The Flow provides the flexibility to select multiple AI models and connect them directly to stream sources.\n\nWhen everything is implemented, individuals will be able to protect themselves from any threats.\n\n> [!NOTE]\n> 🚧 <strong>This project is under active development.</strong> Some features may be unstable or subject to change without notice.\n\n## Features\n\n- Connect all sensers (MQTT, RTP, RTSP, HTTP, ...)\n- RTP Audio & Video Streaming\n- RTSP Video Streaming\n- Real-time streaming support\n- Flow Visual Logic\n- Custom Script Language Support\n- Pub/Sub MQTT with Flow\n- Map based UI\n- Home Assistant Integration\n- ROS2 Integration\n- External access support\n- Capsule (Zero-Knowledge LLM Call) security.\n- Local-first design, Offline-first design.\n\n## Develop\n\nGet your local copy up and running.\n\n#### Prerequisites\n\n- [Rust](https://www.rust-lang.org/) & Cargo\n- [Node.js](https://nodejs.org/en/) (v18+) and npm\n- [gstreamer](https://gstreamer.freedesktop.org/documentation/rust/git/docs/gstreamer/index.html)\n- [mosquitto (MQTT)](https://mosquitto.org/) (additional)\n\n### Option1. Run normally\n\n##### 1. Server Setup\n\n```bash\n# 1. Clone the repository\ngit clone https://github.com/cartesiancs/vessel.git\ncd vessel/apps/server\n\n# 2. Copy and configure environment variables\ncp .env.example .env\n# nano .env (Modify if needed)\n\n# 3. Run database migrations\ndiesel setup\ndiesel migration run\n\n# 4. Run the server\ncargo run\n```\n\n##### 2. Client Setup\n\n```bash\n# 1. Install dependencies\nnpm install\n\n# 2. Run the development server\nnpm run client\n```\n\n### Option2. Run Docker\n\n```bash\ndocker build -t server .\n\ndocker run -p 0.0.0.0:6174:6174 server:latest\n```\n\n### Option3. Desktop (Tauri)\n\n```bash\nnpm run desktop\n```\n\nBuilds the Rust server sidecar in release mode, starts the Vite dev server for the client, and launches the Tauri shell. For a packaged build use `npm run desktop:build`.\n\n## Compile\n\nThis command compiles the entire project, including both the server and the client, into a single executable file.\n\n```bash\nnpm run build\n```\n\nThe compiled binary, named 'server', will be located in the target/release directory.\n\n> To run the server executable, you must have a .env file in the same directory (target/release).\n\n## Principles\n\n1. Local-first\n2. Offline-first\n3. Ultimate control rests with the user\n\n## Troubleshooting\n\n> A more detailed troubleshooting guide will be available soon.\n\n## Roadmap\n\nPlease visit our Roadmap page below:\n\n[Roadmap Page >](https://vessel.cartesiancs.com/roadmap)\n\n## Contributing\n\nContributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated.\n\nPlease refer to our [CONTRIBUTING.md](CONTRIBUTING.md) for details.\n\n## Contributors\n\n <a href = \"https://github.com/cartesiancs/vessel/graphs/contributors\">\n   <img src = \"https://contrib.rocks/image?repo=cartesiancs/vessel\"/>\n </a>\n\n## License\n\nDistributed under the Apache-2.0 License. See [LICENSE](LICENSE) for more information.\n\n## Disclaimer\n\nThis project is intended for academic and research purposes only. It is designed to facilitate the connection and control of physical devices. All responsibility for its use lies with the user.\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security\n\nPlease report security issues to `3457xc@gmail.com`.\n\nThis email is the maintainer's personal and public email, allowing for immediate responses to security issues. Alternatively, you may also send a message via LinkedIn DM. My LinkedIn profile is: [in/huhhyeongjun](https://www.linkedin.com/in/huhhyeongjun/)\n"
  },
  {
    "path": "apps/capsule/Cargo.toml",
    "content": "[package]\nname = \"capsule\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n# Web Framework\ntokio = { version = \"1\", features = [\"full\"] }\naxum = { version = \"0.7\", features = [\"json\"] }\ntower-http = { version = \"0.5\", features = [\"cors\", \"trace\", \"limit\"] }\ntower = \"0.5\"\n\n# HTTP types\nhttp = \"1\"\n\n# Serialization\nserde = { version = \"1\", features = [\"derive\"] }\nserde_json = \"1\"\n\n# Cryptography\nchacha20poly1305 = \"0.10\"\nx25519-dalek = { version = \"2\", features = [\"static_secrets\"] }\nrand = \"0.8\"\nzeroize = { version = \"1\", features = [\"derive\"] }\nbase64 = \"0.22\"\nhkdf = \"0.12\"\nsha2 = \"0.10\"\n\n# OpenAI\nreqwest = { version = \"0.12\", features = [\"json\", \"stream\"] }\nasync-stream = \"0.3\"\nfutures-util = \"0.3\"\ntokio-stream = \"0.1\"\n\n# JWT & Auth\njsonwebtoken = \"9\"\nasync-trait = \"0.1\"\nchrono = { version = \"0.4\", features = [\"serde\"] }\naxum-extra = { version = \"0.9\", features = [\"typed-header\"] }\n\n# Utilities\nanyhow = \"1\"\nthiserror = \"1\"\ntracing = \"0.1\"\ntracing-subscriber = { version = \"0.3\", features = [\"env-filter\"] }\ndotenvy = \"0.15\"\n\n[[bin]]\nname = \"capsule\"\npath = \"src/main.rs\"\n"
  },
  {
    "path": "apps/capsule/Dockerfile",
    "content": "# Build stage\nFROM rust:1.75-alpine AS builder\n\n# 빌드 의존성 설치\nRUN apk add --no-cache musl-dev pkgconfig openssl-dev\n\nWORKDIR /app\n\n# 의존성 캐싱을 위해 Cargo 파일 먼저 복사\nCOPY Cargo.toml Cargo.lock* ./\n\n# 더미 소스로 의존성 빌드 (캐싱)\nRUN mkdir src && \\\n    echo \"fn main() {}\" > src/main.rs && \\\n    cargo build --release && \\\n    rm -rf src\n\n# 실제 소스 복사 및 빌드\nCOPY src ./src\nRUN touch src/main.rs && cargo build --release\n\n# Runtime stage\nFROM alpine:3.19\n\n# 보안: non-root 사용자 생성\nRUN addgroup -g 1001 capsule && \\\n    adduser -u 1001 -G capsule -s /bin/sh -D capsule\n\n# 런타임 의존성\nRUN apk add --no-cache ca-certificates\n\nWORKDIR /app\n\n# 빌드된 바이너리 복사\nCOPY --from=builder /app/target/release/capsule .\n\n# 소유권 변경\nRUN chown -R capsule:capsule /app\n\n# non-root로 실행\nUSER capsule\n\n# 포트 노출\nEXPOSE 3000\n\n# 헬스체크\nHEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\\n    CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1\n\n# 환경 변수\nENV RUST_LOG=info\nENV PORT=3000\n\n# 실행\nCMD [\"./capsule\"]\n"
  },
  {
    "path": "apps/capsule/docker-compose.yml",
    "content": "version: '3.8'\n\nservices:\n  capsule:\n    build:\n      context: .\n      dockerfile: Dockerfile\n    ports:\n      - \"3000:3000\"\n    environment:\n      - RUST_LOG=info\n      - PORT=3000\n      - OPENAI_API_KEY=${OPENAI_API_KEY}\n      - OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o}\n    deploy:\n      resources:\n        limits:\n          memory: 256M\n          cpus: '0.5'\n    # 보안 설정\n    security_opt:\n      - no-new-privileges:true\n    # 읽기 전용 파일시스템 (민감 데이터 디스크 저장 방지)\n    read_only: true\n    # 임시 파일용 tmpfs\n    tmpfs:\n      - /tmp\n    # 재시작 정책\n    restart: unless-stopped\n    # 헬스체크\n    healthcheck:\n      test: [\"CMD\", \"wget\", \"--no-verbose\", \"--tries=1\", \"--spider\", \"http://localhost:3000/health\"]\n      interval: 30s\n      timeout: 3s\n      retries: 3\n      start_period: 5s\n"
  },
  {
    "path": "apps/capsule/src/api/auth.rs",
    "content": "//! Authentication extractor for protected endpoints\n//!\n//! Extracts and validates JWT tokens from Authorization header.\n//! Returns AuthUser with user_id for downstream handlers.\n\nuse axum::{\n    extract::FromRequestParts,\n    http::{request::Parts, StatusCode},\n    response::{IntoResponse, Response},\n    Json, RequestPartsExt,\n};\nuse axum_extra::{\n    headers::{authorization::Bearer, Authorization},\n    TypedHeader,\n};\nuse std::sync::Arc;\n\nuse crate::services::{JwtError, JwtValidator, SupabaseClaims};\n\n/// Authenticated user information\n///\n/// Extracted from validated JWT token.\n/// Available in handlers as an extractor parameter.\n#[derive(Debug, Clone)]\npub struct AuthUser {\n    /// User ID (UUID) from JWT sub claim\n    pub user_id: String,\n\n    /// User email (optional)\n    pub email: Option<String>,\n\n    /// Full JWT claims for additional data\n    pub claims: SupabaseClaims,\n}\n\n/// Authentication errors\n#[derive(Debug)]\npub enum AuthError {\n    /// No Authorization header present\n    MissingToken,\n\n    /// Token format is invalid\n    InvalidToken(String),\n\n    /// Token has expired\n    Expired,\n}\n\nimpl IntoResponse for AuthError {\n    fn into_response(self) -> Response {\n        let (status, message) = match &self {\n            AuthError::MissingToken => (StatusCode::UNAUTHORIZED, \"Authorization token required\"),\n            AuthError::InvalidToken(msg) => {\n                tracing::debug!(\"Invalid token: {}\", msg);\n                (StatusCode::UNAUTHORIZED, \"Invalid authorization token\")\n            }\n            AuthError::Expired => (StatusCode::UNAUTHORIZED, \"Token expired\"),\n        };\n\n        (status, Json(serde_json::json!({ \"error\": message }))).into_response()\n    }\n}\n\n#[async_trait::async_trait]\nimpl<S> FromRequestParts<S> for AuthUser\nwhere\n    S: Send + Sync,\n{\n    type Rejection = AuthError;\n\n    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {\n        // Extract Bearer token from Authorization header\n        let TypedHeader(Authorization(bearer)) = parts\n            .extract::<TypedHeader<Authorization<Bearer>>>()\n            .await\n            .map_err(|_| AuthError::MissingToken)?;\n\n        // Get JWT validator from extensions (set by middleware layer)\n        let jwt_validator = parts\n            .extensions\n            .get::<Arc<JwtValidator>>()\n            .ok_or_else(|| AuthError::InvalidToken(\"JWT validator not configured\".to_string()))?;\n\n        // Validate token (async - fetches JWKS if needed)\n        let claims = jwt_validator\n            .validate(bearer.token())\n            .await\n            .map_err(|e| match e {\n                JwtError::Expired => AuthError::Expired,\n                _ => AuthError::InvalidToken(e.to_string()),\n            })?;\n\n        tracing::debug!(user_id = %claims.sub, \"User authenticated\");\n\n        Ok(AuthUser {\n            user_id: claims.sub.clone(),\n            email: claims.email.clone(),\n            claims,\n        })\n    }\n}\n"
  },
  {
    "path": "apps/capsule/src/api/chat.rs",
    "content": "//! Chat API handlers with authentication and usage tracking\n//!\n//! # Security Data Flow\n//! 1. Extract and validate JWT token (AuthUser extractor)\n//! 2. Check subscription status (billing_subscriptions table)\n//! 3. Check rate limits (enclave_usage table)\n//! 4. Process request (decrypt image if present)\n//! 5. Track usage (fire-and-forget)\n//! 6. Return response\n\nuse axum::{\n    extract::State,\n    response::sse::{Event, Sse},\n    Json,\n};\nuse futures_util::{stream::Stream, StreamExt};\nuse std::sync::Arc;\n\nuse crate::api::AuthUser;\nuse crate::error::CapsuleError;\nuse crate::types::{ChatRequest, ChatResponse};\nuse crate::AppState;\n\n/// POST /api/chat\n///\n/// Receives encrypted image and message, returns AI response.\n///\n/// # Authentication\n/// Requires valid Supabase JWT token in Authorization header.\n///\n/// # Subscription\n/// Only users with active Pro subscription can access this endpoint.\n///\n/// # Security Data Flow\n/// 1. `EncryptedImage` received (safe to log)\n/// 2. Decrypted to `DecryptedImage` (memory only)\n/// 3. OpenAI API call (using decrypted image)\n/// 4. `DecryptedImage` auto-dropped → memory zeroized\n/// 5. Response returned\npub async fn chat_handler(\n    State(state): State<Arc<AppState>>,\n    auth: AuthUser,\n    Json(req): Json<ChatRequest>,\n) -> Result<Json<ChatResponse>, CapsuleError> {\n    // 1. Check subscription and rate limits\n    let rate_status = state\n        .usage_tracker\n        .check_rate_limit(&auth.user_id)\n        .await\n        .map_err(|e| CapsuleError::Internal(e.to_string()))?;\n\n    // 2. Block non-subscribers\n    if !rate_status.subscribed {\n        tracing::info!(user_id = %auth.user_id, \"Subscription required\");\n        return Err(CapsuleError::SubscriptionRequired);\n    }\n\n    // 3. Check rate limit\n    if !rate_status.allowed {\n        tracing::info!(\n            user_id = %auth.user_id,\n            reason = ?rate_status.reason,\n            \"Rate limited\"\n        );\n        return Err(CapsuleError::RateLimited(\n            rate_status.reason.unwrap_or_else(|| \"Rate limit exceeded\".to_string()),\n        ));\n    }\n\n    // 3.5. Validate history\n    req.validate_history()?;\n\n    tracing::info!(\n        user_id = %auth.user_id,\n        message_len = req.message.len(),\n        has_image = req.encrypted_image.is_some(),\n        history_len = req.history.as_ref().map(|h| h.len()).unwrap_or(0),\n        has_system_prompt = req.system_prompt.is_some(),\n        \"Chat request\"\n    );\n\n    let is_image_request = req.encrypted_image.is_some();\n    let history_slice = req.history.as_deref();\n    let system_prompt = req.system_prompt.as_deref();\n    let tools = req.tools.as_ref();\n    let tool_choice = req.tool_choice.as_ref();\n\n    // 4. Process request\n    let result = if let Some(encrypted_image) = req.encrypted_image {\n        let decrypted = state.key_manager.decrypt(&encrypted_image).await?;\n\n        tracing::debug!(\"Image decrypted: {} bytes\", decrypted.len());\n\n        state\n            .openai\n            .analyze_image(&req.message, decrypted, system_prompt, history_slice, tools, tool_choice)\n            .await\n            .map_err(|e| CapsuleError::OpenAIError(e.to_string()))?\n    } else {\n        state\n            .openai\n            .chat(&req.message, system_prompt, history_slice, tools, tool_choice)\n            .await\n            .map_err(|e| CapsuleError::OpenAIError(e.to_string()))?\n    };\n\n    tracing::info!(\n        user_id = %auth.user_id,\n        response_len = result.content.len(),\n        has_tool_calls = result.tool_calls.is_some(),\n        input_tokens = result.usage.prompt_tokens,\n        output_tokens = result.usage.completion_tokens,\n        \"Chat response generated\"\n    );\n\n    // 5. Track usage (fire-and-forget)\n    let tracker = state.usage_tracker.clone();\n    let user_id = auth.user_id.clone();\n    let input_tokens = result.usage.prompt_tokens;\n    let output_tokens = result.usage.completion_tokens;\n    tokio::spawn(async move {\n        if let Err(e) = tracker.track_request(&user_id, input_tokens, output_tokens, is_image_request).await {\n            tracing::warn!(error = %e, \"Failed to track usage\");\n        }\n    });\n\n    Ok(Json(ChatResponse {\n        response: result.content,\n        tool_calls: result.tool_calls,\n    }))\n}\n\n/// POST /api/chat/stream\n///\n/// Returns streaming response via Server-Sent Events.\n///\n/// # Authentication\n/// Requires valid Supabase JWT token in Authorization header.\n///\n/// # Subscription\n/// Only users with active Pro subscription can access this endpoint.\npub async fn chat_stream_handler(\n    State(state): State<Arc<AppState>>,\n    auth: AuthUser,\n    Json(req): Json<ChatRequest>,\n) -> Result<Sse<impl Stream<Item = Result<Event, CapsuleError>>>, CapsuleError> {\n    // 1. Check subscription and rate limits\n    let rate_status = state\n        .usage_tracker\n        .check_rate_limit(&auth.user_id)\n        .await\n        .map_err(|e| CapsuleError::Internal(e.to_string()))?;\n\n    // 2. Block non-subscribers\n    if !rate_status.subscribed {\n        tracing::info!(user_id = %auth.user_id, \"Subscription required\");\n        return Err(CapsuleError::SubscriptionRequired);\n    }\n\n    // 3. Check rate limit\n    if !rate_status.allowed {\n        tracing::info!(\n            user_id = %auth.user_id,\n            reason = ?rate_status.reason,\n            \"Rate limited\"\n        );\n        return Err(CapsuleError::RateLimited(\n            rate_status.reason.unwrap_or_else(|| \"Rate limit exceeded\".to_string()),\n        ));\n    }\n\n    // 3.5. Validate history\n    req.validate_history()?;\n\n    tracing::info!(\n        user_id = %auth.user_id,\n        message_len = req.message.len(),\n        has_image = req.encrypted_image.is_some(),\n        history_len = req.history.as_ref().map(|h| h.len()).unwrap_or(0),\n        has_system_prompt = req.system_prompt.is_some(),\n        \"Stream chat request\"\n    );\n\n    let is_image_request = req.encrypted_image.is_some();\n    let history_slice = req.history.as_deref();\n    let system_prompt = req.system_prompt.as_deref();\n    let tools = req.tools.as_ref();\n    let tool_choice = req.tool_choice.as_ref();\n\n    // 4. Process request\n    let (stream, usage) = if let Some(encrypted_image) = req.encrypted_image {\n        let decrypted = state.key_manager.decrypt(&encrypted_image).await?;\n        tracing::debug!(\"Image decrypted for streaming: {} bytes\", decrypted.len());\n\n        state\n            .openai\n            .analyze_image_stream(&req.message, decrypted, system_prompt, history_slice, tools, tool_choice)\n            .await\n            .map_err(|e| CapsuleError::OpenAIError(e.to_string()))?\n    } else {\n        state\n            .openai\n            .chat_stream(&req.message, system_prompt, history_slice, tools, tool_choice)\n            .await\n            .map_err(|e| CapsuleError::OpenAIError(e.to_string()))?\n    };\n\n    // 5. Track usage after stream completes (fire-and-forget)\n    let tracker = state.usage_tracker.clone();\n    let user_id = auth.user_id.clone();\n\n    // Wrap the stream to track usage when it completes\n    let wrapped_stream = async_stream::stream! {\n        let mut inner = std::pin::pin!(stream);\n        while let Some(item) = inner.next().await {\n            yield item;\n        }\n\n        // Stream completed, now track usage\n        let (input_tokens, output_tokens) = {\n            let guard = usage.lock().unwrap();\n            (guard.prompt_tokens, guard.completion_tokens)\n        };\n\n        tracing::info!(\n            user_id = %user_id,\n            input_tokens = input_tokens,\n            output_tokens = output_tokens,\n            \"Stream completed, tracking usage\"\n        );\n\n        let tracker_clone = tracker.clone();\n        let user_id_clone = user_id.clone();\n        tokio::spawn(async move {\n            if let Err(e) = tracker_clone.track_request(&user_id_clone, input_tokens, output_tokens, is_image_request).await {\n                tracing::warn!(error = %e, \"Failed to track usage\");\n            }\n        });\n    };\n\n    Ok(Sse::new(wrapped_stream))\n}\n"
  },
  {
    "path": "apps/capsule/src/api/key.rs",
    "content": "use axum::{extract::State, Json};\nuse std::sync::Arc;\n\nuse crate::types::PublicKeyResponse;\nuse crate::AppState;\n\n/// GET /api/public-key\n///\n/// 서버의 공개키를 반환합니다.\n/// 클라이언트는 이 키로 이미지를 암호화합니다.\n///\n/// # Response\n/// ```json\n/// {\n///   \"public_key\": \"base64_encoded_public_key\",\n///   \"key_id\": \"key_identifier\",\n///   \"expires_at\": \"2024-01-01T00:00:00Z\"\n/// }\n/// ```\npub async fn public_key_handler(State(state): State<Arc<AppState>>) -> Json<PublicKeyResponse> {\n    let (public_key, key_id, expires_at) = state.key_manager.current_public_key().await;\n\n    tracing::info!(key_id = %key_id, \"Public key requested\");\n\n    Json(PublicKeyResponse {\n        public_key,\n        key_id,\n        expires_at,\n    })\n}\n"
  },
  {
    "path": "apps/capsule/src/api/mod.rs",
    "content": "mod auth;\nmod chat;\nmod key;\nmod routes;\n\npub use auth::AuthUser;\npub use chat::{chat_handler, chat_stream_handler};\npub use key::public_key_handler;\npub use routes::{create_router, RouterConfig};\n"
  },
  {
    "path": "apps/capsule/src/api/routes.rs",
    "content": "use axum::{\n    routing::{get, post},\n    Extension, Router,\n};\nuse std::sync::Arc;\nuse tower_http::{\n    cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer},\n    limit::RequestBodyLimitLayer,\n    trace::TraceLayer,\n};\n\nuse crate::api::{chat_handler, chat_stream_handler, public_key_handler};\nuse crate::services::JwtValidator;\nuse crate::AppState;\n\n/// Max request body size: 100MB (for encrypted images)\nconst MAX_BODY_SIZE: usize = 100 * 1024 * 1024;\n\n/// Router configuration\npub struct RouterConfig {\n    /// Allowed CORS origins\n    pub allowed_origins: Vec<String>,\n}\n\n/// Create API router\npub fn create_router(\n    state: Arc<AppState>,\n    config: &RouterConfig,\n    jwt_validator: Arc<JwtValidator>,\n) -> Router {\n    // CORS configuration\n    let cors = build_cors_layer(config);\n\n    Router::new()\n        // Health check (public)\n        .route(\"/health\", get(health_handler))\n        // Public Key endpoint (public)\n        .route(\"/api/public-key\", get(public_key_handler))\n        // Chat endpoints (authenticated)\n        .route(\"/api/chat\", post(chat_handler))\n        .route(\"/api/chat/stream\", post(chat_stream_handler))\n        // Middleware\n        .layer(TraceLayer::new_for_http())\n        .layer(cors)\n        .layer(RequestBodyLimitLayer::new(MAX_BODY_SIZE))\n        // JWT validator extension for auth extractor\n        .layer(Extension(jwt_validator))\n        // State\n        .with_state(state)\n}\n\n/// Build CORS layer\nfn build_cors_layer(config: &RouterConfig) -> CorsLayer {\n    let allowed_origins = &config.allowed_origins;\n\n    if allowed_origins.is_empty() || allowed_origins.iter().any(|o| o == \"*\") {\n        // Development: Allow all origins (with warning)\n        tracing::warn!(\"CORS: Allowing all origins (development mode)\");\n        CorsLayer::new()\n            .allow_origin(AllowOrigin::any())\n            .allow_methods(AllowMethods::list([\n                http::Method::GET,\n                http::Method::POST,\n                http::Method::OPTIONS,\n            ]))\n            .allow_headers(AllowHeaders::list([\n                http::header::CONTENT_TYPE,\n                http::header::AUTHORIZATION,\n            ]))\n    } else {\n        // Production: Allow only specified origins\n        tracing::info!(\"CORS: Allowing origins: {:?}\", allowed_origins);\n        let origins: Vec<_> = allowed_origins\n            .iter()\n            .filter_map(|o| o.parse().ok())\n            .collect();\n\n        CorsLayer::new()\n            .allow_origin(origins)\n            .allow_methods(AllowMethods::list([\n                http::Method::GET,\n                http::Method::POST,\n                http::Method::OPTIONS,\n            ]))\n            .allow_headers(AllowHeaders::list([\n                http::header::CONTENT_TYPE,\n                http::header::AUTHORIZATION,\n            ]))\n    }\n}\n\n/// Health check handler\nasync fn health_handler() -> &'static str {\n    \"OK\"\n}\n"
  },
  {
    "path": "apps/capsule/src/config.rs",
    "content": "use zeroize::Zeroizing;\n\nuse crate::error::CapsuleError;\n\n/// Application configuration\npub struct Config {\n    /// Server port\n    pub port: u16,\n    /// OpenAI API key (protected with Zeroizing)\n    pub openai_api_key: Zeroizing<String>,\n    /// OpenAI model (default: gpt-4o)\n    pub openai_model: String,\n    /// Allowed CORS origins\n    pub allowed_origins: Vec<String>,\n    /// Supabase project URL\n    pub supabase_url: String,\n    /// Supabase service role key (protected with Zeroizing)\n    pub supabase_service_key: Zeroizing<String>,\n    /// Key rotation interval in seconds (default: 86400 = 24 hours)\n    pub key_rotation_interval_secs: u64,\n    /// Grace period for previous key in seconds (default: 300 = 5 minutes)\n    pub key_grace_period_secs: u64,\n}\n\nimpl Config {\n    /// Load configuration from environment variables\n    ///\n    /// # Security\n    /// - API keys are protected with `Zeroizing<String>`\n    /// - Sensitive environment variables are removed after loading\n    pub fn from_env() -> Result<Self, CapsuleError> {\n        dotenvy::dotenv().ok();\n\n        // Load OpenAI API key\n        let openai_api_key = std::env::var(\"OPENAI_API_KEY\").map_err(|_| {\n            CapsuleError::ConfigError(\"OPENAI_API_KEY environment variable is required\".to_string())\n        })?;\n\n        // Load Supabase configuration\n        let supabase_url = std::env::var(\"SUPABASE_URL\").map_err(|_| {\n            CapsuleError::ConfigError(\"SUPABASE_URL environment variable is required\".to_string())\n        })?;\n\n        let supabase_service_key = std::env::var(\"SUPABASE_SERVICE_KEY\").map_err(|_| {\n            CapsuleError::ConfigError(\n                \"SUPABASE_SERVICE_KEY environment variable is required\".to_string(),\n            )\n        })?;\n\n        // Security: Remove sensitive environment variables\n        std::env::remove_var(\"OPENAI_API_KEY\");\n        std::env::remove_var(\"SUPABASE_SERVICE_KEY\");\n        tracing::debug!(\"Removed sensitive environment variables\");\n\n        let port = std::env::var(\"PORT\")\n            .unwrap_or_else(|_| \"3000\".to_string())\n            .parse()\n            .unwrap_or(3000);\n\n        let openai_model = std::env::var(\"OPENAI_MODEL\").unwrap_or_else(|_| \"gpt-4o\".to_string());\n\n        // CORS allowed origins (comma-separated)\n        let allowed_origins = std::env::var(\"ALLOWED_ORIGINS\")\n            .unwrap_or_else(|_| \"*\".to_string())\n            .split(',')\n            .map(|s| s.trim().to_string())\n            .filter(|s| !s.is_empty())\n            .collect();\n\n        let key_rotation_interval_secs = std::env::var(\"KEY_ROTATION_INTERVAL_SECS\")\n            .unwrap_or_else(|_| \"86400\".to_string())\n            .parse()\n            .unwrap_or(86400);\n\n        let key_grace_period_secs = std::env::var(\"KEY_GRACE_PERIOD_SECS\")\n            .unwrap_or_else(|_| \"300\".to_string())\n            .parse()\n            .unwrap_or(300);\n\n        Ok(Self {\n            port,\n            openai_api_key: Zeroizing::new(openai_api_key),\n            openai_model,\n            allowed_origins,\n            supabase_url,\n            supabase_service_key: Zeroizing::new(supabase_service_key),\n            key_rotation_interval_secs,\n            key_grace_period_secs,\n        })\n    }\n}\n"
  },
  {
    "path": "apps/capsule/src/crypto/encryption.rs",
    "content": "use base64::{engine::general_purpose::STANDARD, Engine};\nuse chacha20poly1305::{\n    aead::{Aead, KeyInit},\n    XChaCha20Poly1305, XNonce,\n};\nuse hkdf::Hkdf;\nuse sha2::Sha256;\nuse x25519_dalek::{PublicKey, StaticSecret};\nuse zeroize::Zeroize;\n\nuse crate::error::CapsuleError;\nuse crate::types::{DecryptedImage, EncryptedImage};\n\n/// HKDF에 사용할 salt와 info\nconst HKDF_SALT: &[u8] = b\"vessel-capsule-v1-salt\";\nconst HKDF_INFO: &[u8] = b\"vessel-capsule-v1-key\";\n\n/// 특정 비밀키로 암호화된 이미지 복호화\n///\n/// # 보안\n/// - Shared secret은 함수 종료 시 zeroize됨\n/// - 반환되는 `DecryptedImage`는 Drop 시 자동 zeroize\n///\n/// # 암호화 스킴\n/// 1. X25519 ECDH로 shared secret 계산\n/// 2. HKDF-SHA256으로 대칭키 유도\n/// 3. XChaCha20-Poly1305로 복호화 (AEAD)\npub(crate) fn decrypt_with_secret(\n    server_secret: &StaticSecret,\n    encrypted: &EncryptedImage,\n) -> Result<DecryptedImage, CapsuleError> {\n    // 1. Base64 디코딩\n    let ephemeral_pk_bytes = STANDARD\n        .decode(&encrypted.ephemeral_public_key)\n        .map_err(|_| CapsuleError::InvalidPublicKey)?;\n\n    let nonce_bytes = STANDARD\n        .decode(&encrypted.nonce)\n        .map_err(|_| CapsuleError::InvalidNonce)?;\n\n    let ciphertext = STANDARD\n        .decode(&encrypted.ciphertext)\n        .map_err(|_| CapsuleError::InvalidCiphertext)?;\n\n    // 2. 공개키 파싱 (32 bytes)\n    let ephemeral_pk: [u8; 32] = ephemeral_pk_bytes\n        .try_into()\n        .map_err(|_| CapsuleError::InvalidPublicKey)?;\n    let client_public = PublicKey::from(ephemeral_pk);\n\n    // 3. Shared Secret 계산 (X25519 ECDH)\n    let mut shared_secret = server_secret.diffie_hellman(&client_public);\n\n    // 4. HKDF로 대칭키 유도 (32 bytes for XChaCha20)\n    let mut symmetric_key = [0u8; 32];\n    let hkdf = Hkdf::<Sha256>::new(Some(HKDF_SALT), shared_secret.as_bytes());\n    hkdf.expand(HKDF_INFO, &mut symmetric_key)\n        .map_err(|_| CapsuleError::CipherInitFailed)?;\n\n    // Shared secret 즉시 클리어\n    shared_secret.zeroize();\n\n    // 5. Nonce 파싱 (24 bytes for XChaCha20)\n    let nonce: [u8; 24] = nonce_bytes\n        .try_into()\n        .map_err(|_| CapsuleError::InvalidNonce)?;\n\n    // 6. XChaCha20-Poly1305로 복호화\n    let cipher =\n        XChaCha20Poly1305::new_from_slice(&symmetric_key).map_err(|_| CapsuleError::CipherInitFailed)?;\n\n    // 대칭키 즉시 클리어\n    symmetric_key.zeroize();\n\n    let plaintext = cipher\n        .decrypt(XNonce::from_slice(&nonce), ciphertext.as_ref())\n        .map_err(|_| CapsuleError::DecryptionFailed)?;\n\n    tracing::debug!(\"Successfully decrypted {} bytes\", plaintext.len());\n\n    Ok(DecryptedImage::new(plaintext))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_decrypt_invalid_public_key() {\n        let secret = StaticSecret::random_from_rng(rand::thread_rng());\n\n        let encrypted = EncryptedImage {\n            ephemeral_public_key: \"invalid\".to_string(),\n            nonce: STANDARD.encode([0u8; 24]),\n            ciphertext: STANDARD.encode(vec![0u8; 32]),\n            key_id: None,\n        };\n\n        let result = decrypt_with_secret(&secret, &encrypted);\n        assert!(matches!(result, Err(CapsuleError::InvalidPublicKey)));\n    }\n\n    #[test]\n    fn test_decrypt_invalid_nonce() {\n        let secret = StaticSecret::random_from_rng(rand::thread_rng());\n\n        let encrypted = EncryptedImage {\n            ephemeral_public_key: STANDARD.encode([0u8; 32]),\n            nonce: \"invalid\".to_string(),\n            ciphertext: STANDARD.encode(vec![0u8; 32]),\n            key_id: None,\n        };\n\n        let result = decrypt_with_secret(&secret, &encrypted);\n        assert!(matches!(result, Err(CapsuleError::InvalidNonce)));\n    }\n}\n"
  },
  {
    "path": "apps/capsule/src/crypto/keypair.rs",
    "content": "use base64::{engine::general_purpose::STANDARD, Engine};\nuse std::sync::Arc;\nuse std::time::Duration;\nuse tokio::sync::RwLock;\nuse x25519_dalek::{PublicKey, StaticSecret};\n\nuse crate::crypto::decrypt_with_secret;\nuse crate::error::CapsuleError;\nuse crate::types::{DecryptedImage, EncryptedImage};\n\n/// 버전이 지정된 키 쌍\nstruct VersionedKey {\n    /// 키 고유 식별자 (public key base64의 앞 16자)\n    key_id: String,\n    /// Private Key\n    secret: StaticSecret,\n    /// Public Key\n    public: PublicKey,\n    /// 생성 시각\n    created_at: std::time::Instant,\n}\n\nimpl VersionedKey {\n    fn new() -> Self {\n        let secret = StaticSecret::random_from_rng(rand::thread_rng());\n        let public = PublicKey::from(&secret);\n        let key_id = STANDARD.encode(public.as_bytes())[..16].to_string();\n\n        tracing::info!(key_id = %key_id, \"New versioned key pair generated\");\n\n        Self {\n            key_id,\n            secret,\n            public,\n            created_at: std::time::Instant::now(),\n        }\n    }\n\n    fn public_key_base64(&self) -> String {\n        STANDARD.encode(self.public.as_bytes())\n    }\n}\n\n/// 현재/이전 키 슬롯\nstruct KeySlots {\n    /// 현재 활성 키 (새 클라이언트에게 배포)\n    current: VersionedKey,\n    /// 이전 키 (grace period 동안 유지)\n    previous: Option<VersionedKey>,\n}\n\n/// 서버 키 쌍 관리자 (로테이션 지원)\n///\n/// # 보안 특성\n/// - Private Key는 절대 외부로 노출되지 않음\n/// - 복호화는 `decrypt()` 메서드를 통해서만 가능\n/// - 키 로테이션으로 침해 범위를 로테이션 주기로 한정\n/// - Drop 시 모든 Private Key 자동 zeroize (StaticSecret 내장)\n///\n/// # Two-Slot Key Model\n/// - `current`: 새 클라이언트에게 배포, 새 요청 복호화\n/// - `previous`: grace period 동안 유지, 이전 키를 캐싱한 클라이언트 지원\npub struct KeyManager {\n    keys: Arc<RwLock<KeySlots>>,\n    rotation_interval: Duration,\n    grace_period: Duration,\n}\n\nimpl KeyManager {\n    /// 새 KeyManager 생성 (로테이션 설정 포함)\n    ///\n    /// # 보안\n    /// - 키는 메모리에만 존재\n    /// - 디스크에 저장되지 않음\n    /// - 컨테이너 재시작 시 새 키 생성\n    pub fn new(rotation_interval: Duration, grace_period: Duration) -> Self {\n        let current = VersionedKey::new();\n        tracing::info!(\n            key_id = %current.key_id,\n            rotation_interval_secs = rotation_interval.as_secs(),\n            grace_period_secs = grace_period.as_secs(),\n            \"KeyManager initialized\"\n        );\n\n        Self {\n            keys: Arc::new(RwLock::new(KeySlots {\n                current,\n                previous: None,\n            })),\n            rotation_interval,\n            grace_period,\n        }\n    }\n\n    /// 현재 공개키 정보 반환 (key_id, expires_at 포함)\n    pub async fn current_public_key(&self) -> (String, String, String) {\n        let keys = self.keys.read().await;\n        let elapsed = keys.current.created_at.elapsed();\n        let remaining = self.rotation_interval.saturating_sub(elapsed);\n        let expiry = chrono::Utc::now()\n            + chrono::Duration::from_std(remaining).unwrap_or(chrono::Duration::zero());\n        (\n            keys.current.public_key_base64(),\n            keys.current.key_id.clone(),\n            expiry.to_rfc3339(),\n        )\n    }\n\n    /// 복호화 수행 (key_id로 적절한 키 선택)\n    ///\n    /// # 키 선택 로직\n    /// - `key_id` 있으면: 매칭되는 키로 복호화\n    /// - `key_id` 없으면 (하위 호환): current → previous 순서로 시도\n    pub async fn decrypt(&self, encrypted: &EncryptedImage) -> Result<DecryptedImage, CapsuleError> {\n        let keys = self.keys.read().await;\n\n        match &encrypted.key_id {\n            Some(kid) => {\n                // key_id가 명시된 경우: 정확히 매칭되는 키 사용\n                if kid == &keys.current.key_id {\n                    decrypt_with_secret(&keys.current.secret, encrypted)\n                } else if let Some(prev) = &keys.previous {\n                    if kid == &prev.key_id {\n                        tracing::debug!(key_id = %kid, \"Decrypting with previous key\");\n                        decrypt_with_secret(&prev.secret, encrypted)\n                    } else {\n                        tracing::warn!(\n                            requested_key_id = %kid,\n                            current_key_id = %keys.current.key_id,\n                            \"Unknown key_id requested\"\n                        );\n                        Err(CapsuleError::DecryptionFailed)\n                    }\n                } else {\n                    tracing::warn!(\n                        requested_key_id = %kid,\n                        current_key_id = %keys.current.key_id,\n                        \"Unknown key_id requested (no previous key)\"\n                    );\n                    Err(CapsuleError::DecryptionFailed)\n                }\n            }\n            None => {\n                // key_id가 없는 경우 (하위 호환): current → previous 순서로 시도\n                match decrypt_with_secret(&keys.current.secret, encrypted) {\n                    Ok(result) => Ok(result),\n                    Err(_) => {\n                        if let Some(prev) = &keys.previous {\n                            tracing::debug!(\"Retrying decryption with previous key (no key_id)\");\n                            decrypt_with_secret(&prev.secret, encrypted)\n                        } else {\n                            Err(CapsuleError::DecryptionFailed)\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    /// 키 로테이션: current → previous, 새 키 → current\n    async fn rotate(&self) {\n        let mut keys = self.keys.write().await;\n        let new_key = VersionedKey::new();\n\n        tracing::info!(\n            new_key_id = %new_key.key_id,\n            old_key_id = %keys.current.key_id,\n            \"Key rotation: new key generated\"\n        );\n\n        let old_current = std::mem::replace(&mut keys.current, new_key);\n        // 이전 previous가 있으면 여기서 drop → StaticSecret zeroize\n        keys.previous = Some(old_current);\n    }\n\n    /// 이전 키 제거 (grace period 만료 후 호출)\n    async fn clear_previous(&self) {\n        let mut keys = self.keys.write().await;\n        if let Some(prev) = keys.previous.take() {\n            tracing::info!(\n                key_id = %prev.key_id,\n                \"Previous key cleared (grace period expired)\"\n            );\n            // prev drop → StaticSecret zeroize\n        }\n    }\n\n    /// 백그라운드 로테이션 태스크 시작\n    pub fn start_rotation_task(self: &Arc<Self>) -> tokio::task::JoinHandle<()> {\n        let manager = Arc::clone(self);\n        let rotation_interval = self.rotation_interval;\n        let grace_period = self.grace_period;\n\n        tokio::spawn(async move {\n            loop {\n                tokio::time::sleep(rotation_interval).await;\n\n                tracing::info!(\"Key rotation triggered\");\n                manager.rotate().await;\n\n                // Grace period 후 이전 키 제거\n                let manager_clone = Arc::clone(&manager);\n                tokio::spawn(async move {\n                    tokio::time::sleep(grace_period).await;\n                    manager_clone.clear_previous().await;\n                });\n            }\n        })\n    }\n}\n\nimpl Drop for KeyManager {\n    fn drop(&mut self) {\n        tracing::info!(\"KeyManager dropped, all secret keys will be zeroized\");\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::types::EncryptedImage;\n    use base64::{engine::general_purpose::STANDARD, Engine};\n    use chacha20poly1305::{\n        aead::{Aead, KeyInit},\n        XChaCha20Poly1305, XNonce,\n    };\n    use hkdf::Hkdf;\n    use sha2::Sha256;\n\n    /// 테스트용: 특정 공개키로 이미지를 암호화\n    fn encrypt_for_key(public_key: &PublicKey, data: &[u8]) -> (EncryptedImage, String) {\n        let ephemeral_secret = StaticSecret::random_from_rng(rand::thread_rng());\n        let ephemeral_public = PublicKey::from(&ephemeral_secret);\n\n        let shared_secret = ephemeral_secret.diffie_hellman(public_key);\n\n        let mut symmetric_key = [0u8; 32];\n        let hkdf = Hkdf::<Sha256>::new(\n            Some(b\"vessel-capsule-v1-salt\"),\n            shared_secret.as_bytes(),\n        );\n        hkdf.expand(b\"vessel-capsule-v1-key\", &mut symmetric_key)\n            .unwrap();\n\n        let cipher = XChaCha20Poly1305::new_from_slice(&symmetric_key).unwrap();\n        let nonce_bytes: [u8; 24] = rand::random();\n        let ciphertext = cipher\n            .encrypt(XNonce::from_slice(&nonce_bytes), data)\n            .unwrap();\n\n        let key_id = STANDARD.encode(public_key.as_bytes())[..16].to_string();\n\n        let encrypted = EncryptedImage {\n            ephemeral_public_key: STANDARD.encode(ephemeral_public.as_bytes()),\n            nonce: STANDARD.encode(nonce_bytes),\n            ciphertext: STANDARD.encode(&ciphertext),\n            key_id: Some(key_id.clone()),\n        };\n\n        (encrypted, key_id)\n    }\n\n    #[tokio::test]\n    async fn test_rotation_creates_new_key() {\n        let km = Arc::new(KeyManager::new(\n            Duration::from_secs(3600),\n            Duration::from_secs(60),\n        ));\n        let (_, id1, _) = km.current_public_key().await;\n        km.rotate().await;\n        let (_, id2, _) = km.current_public_key().await;\n        assert_ne!(id1, id2);\n    }\n\n    #[tokio::test]\n    async fn test_decrypt_with_current_key() {\n        let km = Arc::new(KeyManager::new(\n            Duration::from_secs(3600),\n            Duration::from_secs(60),\n        ));\n\n        let keys = km.keys.read().await;\n        let public = keys.current.public;\n        drop(keys);\n\n        let (encrypted, _) = encrypt_for_key(&public, b\"test image data\");\n        let decrypted = km.decrypt(&encrypted).await.unwrap();\n        assert_eq!(decrypted.len(), b\"test image data\".len());\n    }\n\n    #[tokio::test]\n    async fn test_previous_key_available_after_rotation() {\n        let km = Arc::new(KeyManager::new(\n            Duration::from_secs(3600),\n            Duration::from_secs(60),\n        ));\n\n        // 현재 키로 암호화\n        let keys = km.keys.read().await;\n        let old_public = keys.current.public;\n        drop(keys);\n\n        let (encrypted, _) = encrypt_for_key(&old_public, b\"old key data\");\n\n        // 로테이션\n        km.rotate().await;\n\n        // 이전 키로 복호화 성공해야 함\n        let decrypted = km.decrypt(&encrypted).await.unwrap();\n        assert_eq!(decrypted.len(), b\"old key data\".len());\n    }\n\n    #[tokio::test]\n    async fn test_previous_key_cleared_after_grace_period() {\n        let km = Arc::new(KeyManager::new(\n            Duration::from_secs(3600),\n            Duration::from_secs(60),\n        ));\n\n        // 현재 키로 암호화\n        let keys = km.keys.read().await;\n        let old_public = keys.current.public;\n        drop(keys);\n\n        let (encrypted, _) = encrypt_for_key(&old_public, b\"expired data\");\n\n        // 로테이션 + 이전 키 제거\n        km.rotate().await;\n        km.clear_previous().await;\n\n        // 이전 키가 없으므로 복호화 실패해야 함\n        let result = km.decrypt(&encrypted).await;\n        assert!(result.is_err());\n    }\n\n    #[tokio::test]\n    async fn test_no_key_id_fallback() {\n        let km = Arc::new(KeyManager::new(\n            Duration::from_secs(3600),\n            Duration::from_secs(60),\n        ));\n\n        // 현재 키로 암호화 (key_id 없이)\n        let keys = km.keys.read().await;\n        let old_public = keys.current.public;\n        drop(keys);\n\n        let (mut encrypted, _) = encrypt_for_key(&old_public, b\"no key id data\");\n        encrypted.key_id = None; // key_id 제거 (하위 호환 시뮬레이션)\n\n        // 로테이션 후에도 fallback으로 복호화 성공\n        km.rotate().await;\n        let decrypted = km.decrypt(&encrypted).await.unwrap();\n        assert_eq!(decrypted.len(), b\"no key id data\".len());\n    }\n}\n"
  },
  {
    "path": "apps/capsule/src/crypto/mod.rs",
    "content": "mod keypair;\nmod encryption;\n\npub use keypair::KeyManager;\npub(crate) use encryption::decrypt_with_secret;\n"
  },
  {
    "path": "apps/capsule/src/error.rs",
    "content": "use axum::{\n    http::StatusCode,\n    response::{IntoResponse, Response},\n    Json,\n};\nuse serde_json::json;\nuse thiserror::Error;\n\n/// Capsule error types\n#[derive(Error, Debug)]\npub enum CapsuleError {\n    #[error(\"Invalid public key format\")]\n    InvalidPublicKey,\n\n    #[error(\"Invalid nonce format\")]\n    InvalidNonce,\n\n    #[error(\"Invalid ciphertext format\")]\n    InvalidCiphertext,\n\n    #[error(\"Cipher initialization failed\")]\n    CipherInitFailed,\n\n    #[error(\"Decryption failed - invalid ciphertext or key\")]\n    DecryptionFailed,\n\n    #[error(\"OpenAI API error: {0}\")]\n    OpenAIError(String),\n\n    #[error(\"Configuration error: {0}\")]\n    ConfigError(String),\n\n    #[error(\"Internal error: {0}\")]\n    Internal(String),\n\n    #[error(\"Rate limit exceeded: {0}\")]\n    RateLimited(String),\n\n    #[error(\"Subscription required\")]\n    SubscriptionRequired,\n\n    #[error(\"History validation failed: {0}\")]\n    HistoryTooLarge(String),\n}\n\nimpl IntoResponse for CapsuleError {\n    fn into_response(self) -> Response {\n        let (status, error_message) = match &self {\n            CapsuleError::InvalidPublicKey\n            | CapsuleError::InvalidNonce\n            | CapsuleError::InvalidCiphertext => (StatusCode::BAD_REQUEST, self.to_string()),\n\n            CapsuleError::DecryptionFailed => {\n                (StatusCode::BAD_REQUEST, \"Decryption failed\".to_string())\n            }\n\n            CapsuleError::CipherInitFailed | CapsuleError::Internal(_) => {\n                // Internal errors don't expose details\n                tracing::error!(\"Internal error: {}\", self);\n                (\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    \"Internal server error\".to_string(),\n                )\n            }\n\n            CapsuleError::OpenAIError(msg) => {\n                tracing::error!(\"OpenAI error: {}\", msg);\n                (StatusCode::BAD_GATEWAY, \"AI service error\".to_string())\n            }\n\n            CapsuleError::ConfigError(msg) => {\n                tracing::error!(\"Config error: {}\", msg);\n                (\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    \"Configuration error\".to_string(),\n                )\n            }\n\n            CapsuleError::RateLimited(reason) => {\n                (StatusCode::TOO_MANY_REQUESTS, reason.clone())\n            }\n\n            CapsuleError::SubscriptionRequired => {\n                (\n                    StatusCode::FORBIDDEN,\n                    \"Pro subscription required\".to_string(),\n                )\n            }\n\n            CapsuleError::HistoryTooLarge(msg) => {\n                (StatusCode::BAD_REQUEST, msg.clone())\n            }\n        };\n\n        let body = Json(json!({\n            \"error\": error_message\n        }));\n\n        (status, body).into_response()\n    }\n}\n\nimpl From<anyhow::Error> for CapsuleError {\n    fn from(err: anyhow::Error) -> Self {\n        CapsuleError::Internal(err.to_string())\n    }\n}\n"
  },
  {
    "path": "apps/capsule/src/main.rs",
    "content": "mod api;\nmod config;\nmod crypto;\nmod error;\nmod services;\nmod types;\n\nuse std::sync::Arc;\nuse std::time::Duration;\n\nuse config::Config;\nuse crypto::KeyManager;\nuse services::{JwtValidator, OpenAIService, UsageTracker};\n\n/// Application state\n///\n/// # Security\n/// - `KeyManager` holds private keys internally with rotation support\n/// - Private keys are never exposed externally\n/// - Keys are rotated periodically to limit blast radius\n/// - JWT validator uses Zeroizing for secret storage\n/// - Usage tracker uses service key for Supabase API calls\npub struct AppState {\n    /// Key manager (encryption/decryption with rotation)\n    pub key_manager: Arc<KeyManager>,\n    /// OpenAI service\n    pub openai: OpenAIService,\n    /// JWT validator for Supabase tokens\n    pub jwt_validator: Arc<JwtValidator>,\n    /// Usage tracker for rate limiting and billing\n    pub usage_tracker: UsageTracker,\n}\n\n#[tokio::main]\nasync fn main() -> anyhow::Result<()> {\n    // Initialize logging\n    tracing_subscriber::fmt()\n        .with_env_filter(\n            tracing_subscriber::EnvFilter::from_default_env()\n                .add_directive(\"capsule=debug\".parse().unwrap()),\n        )\n        .init();\n\n    tracing::info!(\"Starting Capsule server...\");\n\n    // Load configuration (API keys removed from env after loading)\n    let config = Config::from_env()?;\n    tracing::info!(\"Configuration loaded (port: {})\", config.port);\n    tracing::info!(\"CORS allowed origins: {:?}\", config.allowed_origins);\n    tracing::info!(\"Supabase URL: {}\", config.supabase_url);\n\n    // Router configuration\n    let app_config = api::RouterConfig {\n        allowed_origins: config.allowed_origins.clone(),\n    };\n\n    // Create JWT validator (uses JWKS from Supabase for ES256 validation)\n    let jwt_validator = Arc::new(JwtValidator::new(config.supabase_url.clone()));\n\n    // Create usage tracker\n    let usage_tracker = UsageTracker::new(\n        config.supabase_url.clone(),\n        config.supabase_service_key,\n    );\n\n    // Create KeyManager with rotation configuration\n    let key_manager = Arc::new(KeyManager::new(\n        Duration::from_secs(config.key_rotation_interval_secs),\n        Duration::from_secs(config.key_grace_period_secs),\n    ));\n\n    // Start background key rotation task\n    let _rotation_handle = key_manager.start_rotation_task();\n\n    // Create application state\n    let openai_model = config.openai_model.clone();\n    let state = Arc::new(AppState {\n        key_manager: Arc::clone(&key_manager),\n        openai: OpenAIService::new(config.openai_api_key).with_model(openai_model),\n        jwt_validator: jwt_validator.clone(),\n        usage_tracker,\n    });\n\n    // Create router\n    let app = api::create_router(state, &app_config, jwt_validator);\n\n    // Start server\n    let addr = format!(\"0.0.0.0:{}\", config.port);\n    let listener = tokio::net::TcpListener::bind(&addr).await?;\n\n    tracing::info!(\"Capsule server listening on {}\", addr);\n    tracing::info!(\"Security: Private keys exist only in memory\");\n    tracing::info!(\"Security: Key rotation enabled (interval: {}s, grace: {}s)\",\n        config.key_rotation_interval_secs, config.key_grace_period_secs);\n    tracing::info!(\"Security: Decrypted images are zeroized after use\");\n    tracing::info!(\"Security: API keys protected with Zeroizing<String>\");\n    tracing::info!(\"Security: JWT authentication required for chat endpoints\");\n    tracing::info!(\"Security: Subscription verification via Supabase\");\n\n    axum::serve(listener, app).await?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "apps/capsule/src/services/jwt.rs",
    "content": "//! JWT validation service for Supabase tokens\n//!\n//! Validates Supabase JWT tokens using ES256 algorithm with JWKS.\n//! Extracts user_id (sub claim) for authentication.\n\nuse jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};\nuse serde::{Deserialize, Serialize};\nuse std::sync::Arc;\nuse thiserror::Error;\nuse tokio::sync::RwLock;\n\n/// JWT validation errors\n#[derive(Error, Debug)]\npub enum JwtError {\n    #[error(\"Invalid token format\")]\n    InvalidFormat,\n\n    #[error(\"Token expired\")]\n    Expired,\n\n    #[error(\"Invalid signature\")]\n    InvalidSignature,\n\n    #[error(\"Invalid audience\")]\n    InvalidAudience,\n\n    #[error(\"JWKS fetch failed: {0}\")]\n    JwksFetchFailed(String),\n\n    #[error(\"No matching key found\")]\n    NoMatchingKey,\n\n    #[error(\"Validation failed: {0}\")]\n    ValidationFailed(String),\n}\n\n/// JWKS response from Supabase\n#[derive(Debug, Deserialize)]\nstruct JwksResponse {\n    keys: Vec<Jwk>,\n}\n\n/// JSON Web Key\n#[derive(Debug, Clone, Deserialize)]\nstruct Jwk {\n    kty: String,\n    #[serde(default)]\n    kid: Option<String>,\n    #[serde(default)]\n    alg: Option<String>,\n    /// EC curve (for ES256)\n    #[serde(default)]\n    crv: Option<String>,\n    /// EC x coordinate (base64url)\n    #[serde(default)]\n    x: Option<String>,\n    /// EC y coordinate (base64url)\n    #[serde(default)]\n    y: Option<String>,\n}\n\n/// Supabase JWT claims\n///\n/// Contains the standard claims from Supabase auth tokens.\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct SupabaseClaims {\n    /// Subject - User ID (UUID)\n    pub sub: String,\n\n    /// Audience - Should be \"authenticated\"\n    pub aud: String,\n\n    /// User role\n    pub role: String,\n\n    /// Expiration timestamp\n    pub exp: usize,\n\n    /// Issued at timestamp\n    pub iat: usize,\n\n    /// User email (optional)\n    pub email: Option<String>,\n}\n\n/// Cached JWKS data\nstruct CachedJwks {\n    keys: Vec<Jwk>,\n    fetched_at: std::time::Instant,\n}\n\n/// JWT validator for Supabase tokens\n///\n/// # Security\n/// - Uses ES256 algorithm with JWKS public keys\n/// - Validates audience claim to ensure token is for authenticated users\n/// - Validates expiration to prevent replay attacks\n/// - JWKS is cached for 1 hour to reduce network calls\npub struct JwtValidator {\n    /// Supabase URL for JWKS endpoint\n    supabase_url: String,\n    /// HTTP client\n    client: reqwest::Client,\n    /// Cached JWKS\n    jwks_cache: Arc<RwLock<Option<CachedJwks>>>,\n}\n\nimpl JwtValidator {\n    /// Create a new JWT validator\n    ///\n    /// # Arguments\n    /// * `supabase_url` - Supabase project URL\n    pub fn new(supabase_url: String) -> Self {\n        Self {\n            supabase_url,\n            client: reqwest::Client::new(),\n            jwks_cache: Arc::new(RwLock::new(None)),\n        }\n    }\n\n    /// Fetch JWKS from Supabase (with caching)\n    async fn get_jwks(&self) -> Result<Vec<Jwk>, JwtError> {\n        // Check cache\n        {\n            let cache = self.jwks_cache.read().await;\n            if let Some(cached) = cache.as_ref() {\n                // Cache valid for 1 hour\n                if cached.fetched_at.elapsed() < std::time::Duration::from_secs(3600) {\n                    return Ok(cached.keys.clone());\n                }\n            }\n        }\n\n        // Fetch from Supabase\n        let jwks_url = format!(\"{}/auth/v1/.well-known/jwks.json\", self.supabase_url);\n        tracing::debug!(\"Fetching JWKS from: {}\", jwks_url);\n\n        let response = self\n            .client\n            .get(&jwks_url)\n            .send()\n            .await\n            .map_err(|e| JwtError::JwksFetchFailed(e.to_string()))?;\n\n        if !response.status().is_success() {\n            return Err(JwtError::JwksFetchFailed(format!(\n                \"HTTP {}\",\n                response.status()\n            )));\n        }\n\n        let jwks: JwksResponse = response\n            .json()\n            .await\n            .map_err(|e| JwtError::JwksFetchFailed(e.to_string()))?;\n\n        tracing::debug!(\"Fetched {} keys from JWKS\", jwks.keys.len());\n\n        // Update cache\n        {\n            let mut cache = self.jwks_cache.write().await;\n            *cache = Some(CachedJwks {\n                keys: jwks.keys.clone(),\n                fetched_at: std::time::Instant::now(),\n            });\n        }\n\n        Ok(jwks.keys)\n    }\n\n    /// Validate a JWT token and extract claims\n    ///\n    /// # Arguments\n    /// * `token` - Bearer token string (without \"Bearer \" prefix)\n    ///\n    /// # Returns\n    /// * `Ok(SupabaseClaims)` - Validated claims including user_id\n    /// * `Err(JwtError)` - Validation failure reason\n    ///\n    /// # Security\n    /// - Validates ES256 signature against JWKS public key\n    /// - Checks \"authenticated\" audience claim\n    /// - Verifies token has not expired\n    pub async fn validate(&self, token: &str) -> Result<SupabaseClaims, JwtError> {\n        // Decode header to get kid\n        let header = decode_header(token).map_err(|e| JwtError::InvalidFormat)?;\n        tracing::debug!(\"JWT header: alg={:?}, kid={:?}\", header.alg, header.kid);\n\n        // Fetch JWKS\n        let keys = self.get_jwks().await?;\n\n        // Find matching key\n        let jwk = if let Some(kid) = &header.kid {\n            keys.iter().find(|k| k.kid.as_ref() == Some(kid))\n        } else {\n            // Use first ES256 key if no kid\n            keys.iter()\n                .find(|k| k.alg.as_deref() == Some(\"ES256\") || k.crv.as_deref() == Some(\"P-256\"))\n        }\n        .ok_or(JwtError::NoMatchingKey)?;\n\n        // Build decoding key from JWK\n        let decoding_key = self.jwk_to_decoding_key(jwk)?;\n\n        // Validate token\n        let mut validation = Validation::new(Algorithm::ES256);\n        validation.set_audience(&[\"authenticated\"]);\n\n        let token_data =\n            decode::<SupabaseClaims>(token, &decoding_key, &validation).map_err(|e| {\n                match e.kind() {\n                    jsonwebtoken::errors::ErrorKind::ExpiredSignature => JwtError::Expired,\n                    jsonwebtoken::errors::ErrorKind::InvalidSignature => JwtError::InvalidSignature,\n                    jsonwebtoken::errors::ErrorKind::InvalidAudience => JwtError::InvalidAudience,\n                    _ => JwtError::ValidationFailed(e.to_string()),\n                }\n            })?;\n\n        Ok(token_data.claims)\n    }\n\n    /// Convert JWK to DecodingKey\n    fn jwk_to_decoding_key(&self, jwk: &Jwk) -> Result<DecodingKey, JwtError> {\n        if jwk.kty != \"EC\" {\n            return Err(JwtError::ValidationFailed(format!(\n                \"Unsupported key type: {}\",\n                jwk.kty\n            )));\n        }\n\n        let x = jwk\n            .x\n            .as_ref()\n            .ok_or_else(|| JwtError::ValidationFailed(\"Missing x coordinate\".to_string()))?;\n        let y = jwk\n            .y\n            .as_ref()\n            .ok_or_else(|| JwtError::ValidationFailed(\"Missing y coordinate\".to_string()))?;\n\n        // Build JWK JSON for jsonwebtoken\n        let jwk_json = serde_json::json!({\n            \"kty\": \"EC\",\n            \"crv\": \"P-256\",\n            \"x\": x,\n            \"y\": y\n        });\n\n        DecodingKey::from_jwk(\n            &serde_json::from_value(jwk_json)\n                .map_err(|e| JwtError::ValidationFailed(format!(\"Invalid JWK format: {}\", e)))?,\n        )\n        .map_err(|e| JwtError::ValidationFailed(format!(\"Failed to create decoding key: {}\", e)))\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[tokio::test]\n    async fn test_invalid_token() {\n        let validator = JwtValidator::new(\"https://example.supabase.co\".to_string());\n        let result = validator.validate(\"invalid-token\").await;\n        assert!(result.is_err());\n    }\n}\n"
  },
  {
    "path": "apps/capsule/src/services/mod.rs",
    "content": "mod jwt;\nmod openai;\nmod usage;\n\npub use jwt::{JwtError, JwtValidator, SupabaseClaims};\npub use openai::{ChatResult, OpenAIService, TokenUsage};\npub use usage::{RateLimitStatus, UsageTracker};\n"
  },
  {
    "path": "apps/capsule/src/services/openai.rs",
    "content": "use axum::response::sse::Event;\nuse futures_util::{Stream, StreamExt};\nuse reqwest::Client;\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\nuse std::pin::Pin;\nuse zeroize::{Zeroize, Zeroizing};\n\nuse crate::error::CapsuleError;\nuse crate::types::{DecryptedImage, HistoryMessage};\n\nconst OPENAI_API_URL: &str = \"https://api.openai.com/v1/chat/completions\";\n\n/// OpenAI API 서비스\n///\n/// # 보안\n/// - `api_key`는 `Zeroizing<String>`으로 보호됨\n/// - Drop 시 자동으로 메모리 클리어\npub struct OpenAIService {\n    client: Client,\n    api_key: Zeroizing<String>,\n    model: String,\n}\n\nimpl OpenAIService {\n    /// 새 OpenAI 서비스 생성\n    pub fn new(api_key: Zeroizing<String>) -> Self {\n        Self {\n            client: Client::new(),\n            api_key,\n            model: \"gpt-4o\".to_string(),\n        }\n    }\n\n    /// 모델 설정\n    pub fn with_model(mut self, model: String) -> Self {\n        self.model = model;\n        self\n    }\n\n    /// 메시지 배열 빌드: system_prompt → history → current_message\n    fn build_messages(\n        system_prompt: Option<&str>,\n        history: Option<&[HistoryMessage]>,\n        current_message: Message,\n    ) -> Vec<Message> {\n        let mut messages = Vec::new();\n\n        if let Some(prompt) = system_prompt {\n            messages.push(Message {\n                role: \"system\".to_string(),\n                content: Some(MessageContent::Text(prompt.to_string())),\n                tool_call_id: None,\n                tool_calls: None,\n            });\n        }\n\n        if let Some(history) = history {\n            for msg in history {\n                let mut m = Message {\n                    role: msg.role.clone(),\n                    content: Some(MessageContent::Text(msg.content.clone())),\n                    tool_call_id: None,\n                    tool_calls: None,\n                };\n                if let Some(ref tc_id) = msg.tool_call_id {\n                    m.tool_call_id = Some(tc_id.clone());\n                }\n                if let Some(ref tc) = msg.tool_calls {\n                    m.tool_calls = Some(tc.clone());\n                    if msg.content.is_empty() {\n                        m.content = None;\n                    }\n                }\n                messages.push(m);\n            }\n        }\n\n        messages.push(current_message);\n        messages\n    }\n\n    /// 텍스트 전용 채팅\n    pub async fn chat(\n        &self,\n        message: &str,\n        system_prompt: Option<&str>,\n        history: Option<&[HistoryMessage]>,\n        tools: Option<&serde_json::Value>,\n        tool_choice: Option<&serde_json::Value>,\n    ) -> Result<ChatResult, anyhow::Error> {\n        let current = Message {\n            role: \"user\".to_string(),\n            content: Some(MessageContent::Text(message.to_string())),\n            tool_call_id: None,\n            tool_calls: None,\n        };\n\n        let request_body = ChatRequest {\n            model: self.model.clone(),\n            messages: Self::build_messages(system_prompt, history, current),\n            max_tokens: Some(4096),\n            stream: false,\n            stream_options: None,\n            tools: tools.cloned(),\n            tool_choice: tool_choice.cloned(),\n        };\n\n        let response = self\n            .client\n            .post(OPENAI_API_URL)\n            .header(\"Authorization\", format!(\"Bearer {}\", &*self.api_key))\n            .header(\"Content-Type\", \"application/json\")\n            .json(&request_body)\n            .send()\n            .await?;\n\n        if !response.status().is_success() {\n            let error_text = response.text().await?;\n            return Err(anyhow::anyhow!(\"OpenAI API error: {}\", error_text));\n        }\n\n        let response_body: ChatResponse = response.json().await?;\n\n        let choice = response_body.choices.first();\n        Ok(ChatResult {\n            content: choice\n                .and_then(|c| c.message.content.clone())\n                .unwrap_or_default(),\n            tool_calls: choice.and_then(|c| c.message.tool_calls.clone()),\n            usage: response_body.usage.into(),\n        })\n    }\n\n    /// 이미지 분석 요청\n    ///\n    /// # 보안\n    /// - `decrypted_image`는 소유권이 이전됨\n    /// - 함수 종료 시 자동으로 drop → zeroize\n    /// - base64 문자열도 사용 후 명시적 zeroize\n    pub async fn analyze_image(\n        &self,\n        message: &str,\n        decrypted_image: DecryptedImage,\n        system_prompt: Option<&str>,\n        history: Option<&[HistoryMessage]>,\n        tools: Option<&serde_json::Value>,\n        tool_choice: Option<&serde_json::Value>,\n    ) -> Result<ChatResult, anyhow::Error> {\n        let mut image_base64 = decrypted_image.to_base64();\n        drop(decrypted_image);\n\n        let current = Message {\n            role: \"user\".to_string(),\n            content: Some(MessageContent::MultiPart(vec![\n                ContentPart::Text {\n                    text: message.to_string(),\n                },\n                ContentPart::ImageUrl {\n                    image_url: ImageUrl {\n                        url: format!(\"data:image/jpeg;base64,{}\", image_base64),\n                    },\n                },\n            ])),\n            tool_call_id: None,\n            tool_calls: None,\n        };\n\n        let request_body = ChatRequest {\n            model: self.model.clone(),\n            messages: Self::build_messages(system_prompt, history, current),\n            max_tokens: Some(4096),\n            stream: false,\n            stream_options: None,\n            tools: tools.cloned(),\n            tool_choice: tool_choice.cloned(),\n        };\n\n        let response = self\n            .client\n            .post(OPENAI_API_URL)\n            .header(\"Authorization\", format!(\"Bearer {}\", &*self.api_key))\n            .header(\"Content-Type\", \"application/json\")\n            .json(&request_body)\n            .send()\n            .await;\n\n        image_base64.zeroize();\n\n        let response = response?;\n\n        if !response.status().is_success() {\n            let error_text = response.text().await?;\n            return Err(anyhow::anyhow!(\"OpenAI API error: {}\", error_text));\n        }\n\n        let response_body: ChatResponse = response.json().await?;\n\n        let choice = response_body.choices.first();\n        Ok(ChatResult {\n            content: choice\n                .and_then(|c| c.message.content.clone())\n                .unwrap_or_default(),\n            tool_calls: choice.and_then(|c| c.message.tool_calls.clone()),\n            usage: response_body.usage.into(),\n        })\n    }\n\n    /// 텍스트 전용 스트리밍 채팅\n    ///\n    /// Returns a stream of SSE events and a shared usage tracker.\n    /// The usage will be populated when the stream completes.\n    pub async fn chat_stream(\n        &self,\n        message: &str,\n        system_prompt: Option<&str>,\n        history: Option<&[HistoryMessage]>,\n        tools: Option<&serde_json::Value>,\n        tool_choice: Option<&serde_json::Value>,\n    ) -> Result<(Pin<Box<dyn Stream<Item = Result<Event, CapsuleError>> + Send>>, std::sync::Arc<std::sync::Mutex<TokenUsage>>), anyhow::Error>\n    {\n        let current = Message {\n            role: \"user\".to_string(),\n            content: Some(MessageContent::Text(message.to_string())),\n            tool_call_id: None,\n            tool_calls: None,\n        };\n\n        let request_body = ChatRequest {\n            model: self.model.clone(),\n            messages: Self::build_messages(system_prompt, history, current),\n            max_tokens: Some(4096),\n            stream: true,\n            stream_options: Some(StreamOptions { include_usage: true }),\n            tools: tools.cloned(),\n            tool_choice: tool_choice.cloned(),\n        };\n\n        let response = self\n            .client\n            .post(OPENAI_API_URL)\n            .header(\"Authorization\", format!(\"Bearer {}\", &*self.api_key))\n            .header(\"Content-Type\", \"application/json\")\n            .json(&request_body)\n            .send()\n            .await?;\n\n        if !response.status().is_success() {\n            let error_text = response.text().await?;\n            return Err(anyhow::anyhow!(\"OpenAI API error: {}\", error_text));\n        }\n\n        let usage = std::sync::Arc::new(std::sync::Mutex::new(TokenUsage::default()));\n        let stream = Box::pin(Self::process_stream_with_usage(response, usage.clone()));\n        Ok((stream, usage))\n    }\n\n    /// 이미지 분석 스트리밍 요청\n    ///\n    /// Returns a stream of SSE events and a shared usage tracker.\n    /// The usage will be populated when the stream completes.\n    pub async fn analyze_image_stream(\n        &self,\n        message: &str,\n        decrypted_image: DecryptedImage,\n        system_prompt: Option<&str>,\n        history: Option<&[HistoryMessage]>,\n        tools: Option<&serde_json::Value>,\n        tool_choice: Option<&serde_json::Value>,\n    ) -> Result<(Pin<Box<dyn Stream<Item = Result<Event, CapsuleError>> + Send>>, std::sync::Arc<std::sync::Mutex<TokenUsage>>), anyhow::Error>\n    {\n        let mut image_base64 = decrypted_image.to_base64();\n        drop(decrypted_image);\n\n        let current = Message {\n            role: \"user\".to_string(),\n            content: Some(MessageContent::MultiPart(vec![\n                ContentPart::Text {\n                    text: message.to_string(),\n                },\n                ContentPart::ImageUrl {\n                    image_url: ImageUrl {\n                        url: format!(\"data:image/jpeg;base64,{}\", image_base64),\n                    },\n                },\n            ])),\n            tool_call_id: None,\n            tool_calls: None,\n        };\n\n        let request_body = ChatRequest {\n            model: self.model.clone(),\n            messages: Self::build_messages(system_prompt, history, current),\n            max_tokens: Some(4096),\n            stream: true,\n            stream_options: Some(StreamOptions { include_usage: true }),\n            tools: tools.cloned(),\n            tool_choice: tool_choice.cloned(),\n        };\n\n        let response = self\n            .client\n            .post(OPENAI_API_URL)\n            .header(\"Authorization\", format!(\"Bearer {}\", &*self.api_key))\n            .header(\"Content-Type\", \"application/json\")\n            .json(&request_body)\n            .send()\n            .await;\n\n        image_base64.zeroize();\n\n        let response = response?;\n\n        if !response.status().is_success() {\n            let error_text = response.text().await?;\n            return Err(anyhow::anyhow!(\"OpenAI API error: {}\", error_text));\n        }\n\n        let usage = std::sync::Arc::new(std::sync::Mutex::new(TokenUsage::default()));\n        let stream = Box::pin(Self::process_stream_with_usage(response, usage.clone()));\n        Ok((stream, usage))\n    }\n\n    /// SSE 스트림 처리 (with usage tracking and tool call accumulation)\n    fn process_stream_with_usage(\n        response: reqwest::Response,\n        usage: std::sync::Arc<std::sync::Mutex<TokenUsage>>,\n    ) -> impl Stream<Item = Result<Event, CapsuleError>> {\n        async_stream::stream! {\n            let mut stream = response.bytes_stream();\n            let mut tool_call_buffer: HashMap<u32, AccumulatedToolCall> = HashMap::new();\n\n            while let Some(chunk) = stream.next().await {\n                match chunk {\n                    Ok(bytes) => {\n                        let text = String::from_utf8_lossy(&bytes);\n\n                        for line in text.lines() {\n                            if line.starts_with(\"data: \") {\n                                let data = &line[6..];\n\n                                if data == \"[DONE]\" {\n                                    // Emit accumulated tool calls before [DONE] if any\n                                    if !tool_call_buffer.is_empty() {\n                                        let mut entries: Vec<_> = tool_call_buffer.drain().collect();\n                                        entries.sort_by_key(|(idx, _)| *idx);\n                                        let tool_calls: Vec<serde_json::Value> = entries\n                                            .into_iter()\n                                            .map(|(_, tc)| tc.to_json())\n                                            .collect();\n                                        if let Ok(json) = serde_json::to_string(&tool_calls) {\n                                            yield Ok(Event::default().data(format!(\"[TOOL_CALLS]{}\", json)));\n                                        }\n                                    }\n                                    yield Ok(Event::default().data(\"[DONE]\"));\n                                    return;\n                                }\n\n                                if let Ok(chunk) = serde_json::from_str::<StreamChunk>(data) {\n                                    if let Some(u) = chunk.usage {\n                                        if let Ok(mut usage_guard) = usage.lock() {\n                                            usage_guard.prompt_tokens = u.prompt_tokens;\n                                            usage_guard.completion_tokens = u.completion_tokens;\n                                            usage_guard.total_tokens = u.total_tokens;\n                                        }\n                                    }\n\n                                    if let Some(choice) = chunk.choices.first() {\n                                        // Stream text content\n                                        if let Some(content) = &choice.delta.content {\n                                            yield Ok(Event::default().data(content.clone()));\n                                        }\n\n                                        // Accumulate tool calls by index\n                                        if let Some(ref tcs) = choice.delta.tool_calls {\n                                            for tc in tcs {\n                                                let entry = tool_call_buffer\n                                                    .entry(tc.index)\n                                                    .or_insert_with(AccumulatedToolCall::default);\n\n                                                if let Some(ref id) = tc.id {\n                                                    entry.id = id.clone();\n                                                }\n                                                if let Some(ref func) = tc.function {\n                                                    if let Some(ref name) = func.name {\n                                                        entry.function_name = name.clone();\n                                                    }\n                                                    if let Some(ref args) = func.arguments {\n                                                        entry.arguments.push_str(args);\n                                                    }\n                                                }\n                                            }\n                                        }\n\n                                        // Emit tool calls when finish_reason is \"tool_calls\"\n                                        if choice.finish_reason.as_deref() == Some(\"tool_calls\") && !tool_call_buffer.is_empty() {\n                                            let mut entries: Vec<_> = tool_call_buffer.drain().collect();\n                                            entries.sort_by_key(|(idx, _)| *idx);\n                                            let tool_calls: Vec<serde_json::Value> = entries\n                                                .into_iter()\n                                                .map(|(_, tc)| tc.to_json())\n                                                .collect();\n                                            if let Ok(json) = serde_json::to_string(&tool_calls) {\n                                                yield Ok(Event::default().data(format!(\"[TOOL_CALLS]{}\", json)));\n                                            }\n                                        }\n                                    }\n                                }\n                            }\n                        }\n                    }\n                    Err(e) => {\n                        yield Err(CapsuleError::OpenAIError(e.to_string()));\n                        return;\n                    }\n                }\n            }\n        }\n    }\n}\n\n// -- Accumulated tool call buffer --\n\n#[derive(Default)]\nstruct AccumulatedToolCall {\n    id: String,\n    function_name: String,\n    arguments: String,\n}\n\nimpl AccumulatedToolCall {\n    fn to_json(&self) -> serde_json::Value {\n        serde_json::json!({\n            \"id\": self.id,\n            \"type\": \"function\",\n            \"function\": {\n                \"name\": self.function_name,\n                \"arguments\": self.arguments,\n            }\n        })\n    }\n}\n\n// -- OpenAI API request/response types --\n\n#[derive(Serialize)]\nstruct ChatRequest {\n    model: String,\n    messages: Vec<Message>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    max_tokens: Option<u32>,\n    stream: bool,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    stream_options: Option<StreamOptions>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    tools: Option<serde_json::Value>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    tool_choice: Option<serde_json::Value>,\n}\n\n#[derive(Serialize)]\nstruct StreamOptions {\n    include_usage: bool,\n}\n\n#[derive(Serialize)]\nstruct Message {\n    role: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    content: Option<MessageContent>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    tool_call_id: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    tool_calls: Option<serde_json::Value>,\n}\n\n#[derive(Serialize)]\n#[serde(untagged)]\nenum MessageContent {\n    Text(String),\n    MultiPart(Vec<ContentPart>),\n}\n\n#[derive(Serialize)]\n#[serde(tag = \"type\")]\nenum ContentPart {\n    #[serde(rename = \"text\")]\n    Text { text: String },\n    #[serde(rename = \"image_url\")]\n    ImageUrl { image_url: ImageUrl },\n}\n\n#[derive(Serialize)]\nstruct ImageUrl {\n    url: String,\n}\n\n/// Token usage information from OpenAI API\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct TokenUsage {\n    pub prompt_tokens: i32,\n    pub completion_tokens: i32,\n    pub total_tokens: i32,\n}\n\n/// Result of a chat request including content and token usage\n#[derive(Debug, Clone)]\npub struct ChatResult {\n    pub content: String,\n    pub tool_calls: Option<serde_json::Value>,\n    pub usage: TokenUsage,\n}\n\n#[derive(Deserialize)]\nstruct ChatResponse {\n    choices: Vec<Choice>,\n    usage: Option<Usage>,\n}\n\n#[derive(Deserialize)]\nstruct Usage {\n    prompt_tokens: i32,\n    completion_tokens: i32,\n    total_tokens: i32,\n}\n\nimpl From<Option<Usage>> for TokenUsage {\n    fn from(usage: Option<Usage>) -> Self {\n        match usage {\n            Some(u) => TokenUsage {\n                prompt_tokens: u.prompt_tokens,\n                completion_tokens: u.completion_tokens,\n                total_tokens: u.total_tokens,\n            },\n            None => TokenUsage::default(),\n        }\n    }\n}\n\n#[derive(Deserialize)]\nstruct Choice {\n    message: ResponseMessage,\n}\n\n#[derive(Deserialize)]\nstruct ResponseMessage {\n    content: Option<String>,\n    tool_calls: Option<serde_json::Value>,\n}\n\n#[derive(Deserialize)]\nstruct StreamChunk {\n    choices: Vec<StreamChoice>,\n    usage: Option<Usage>,\n}\n\n#[derive(Deserialize)]\nstruct StreamChoice {\n    delta: Delta,\n    #[serde(default)]\n    finish_reason: Option<String>,\n}\n\n#[derive(Deserialize)]\nstruct Delta {\n    content: Option<String>,\n    #[serde(default)]\n    tool_calls: Option<Vec<StreamToolCall>>,\n}\n\n#[derive(Deserialize)]\nstruct StreamToolCall {\n    index: u32,\n    id: Option<String>,\n    #[serde(default)]\n    function: Option<StreamToolCallFunction>,\n}\n\n#[derive(Deserialize, Default)]\nstruct StreamToolCallFunction {\n    name: Option<String>,\n    arguments: Option<String>,\n}\n"
  },
  {
    "path": "apps/capsule/src/services/usage.rs",
    "content": "//! Usage tracking service for rate limiting and billing\n//!\n//! Communicates with Supabase to:\n//! 1. Check subscription status (billing_subscriptions table)\n//! 2. Check rate limits (enclave_rate_limits table)\n//! 3. Track usage (enclave_usage table)\n\nuse reqwest::Client;\nuse serde::Deserialize;\nuse zeroize::Zeroizing;\n\n/// Rate limit check result from Supabase RPC\n#[derive(Debug, Deserialize)]\npub struct RateLimitStatus {\n    /// Whether the request is allowed\n    pub allowed: bool,\n\n    /// Reason for denial (if not allowed)\n    pub reason: Option<String>,\n\n    /// Whether user has active subscription\n    pub subscribed: bool,\n\n    /// Remaining requests for today\n    pub requests_remaining: i32,\n\n    /// Remaining tokens for today\n    pub tokens_remaining: i32,\n}\n\n/// Usage tracking service\n///\n/// # Security\n/// - Service key is stored in `Zeroizing<String>`\n/// - All requests use HTTPS to Supabase\n/// - Fire-and-forget usage tracking doesn't block responses\n#[derive(Clone)]\npub struct UsageTracker {\n    client: Client,\n    supabase_url: String,\n    service_key: Zeroizing<String>,\n}\n\nimpl UsageTracker {\n    /// Create a new usage tracker\n    ///\n    /// # Arguments\n    /// * `supabase_url` - Supabase project URL\n    /// * `service_key` - Supabase service role key (NOT anon key)\n    pub fn new(supabase_url: String, service_key: Zeroizing<String>) -> Self {\n        Self {\n            client: Client::new(),\n            supabase_url,\n            service_key,\n        }\n    }\n\n    /// Check if user can make a request\n    ///\n    /// Calls the `check_rate_limit` RPC function which:\n    /// 1. Verifies active subscription in billing_subscriptions\n    /// 2. Checks daily request/token limits\n    ///\n    /// # Arguments\n    /// * `user_id` - User UUID from JWT claims\n    ///\n    /// # Returns\n    /// * `RateLimitStatus` with allowed flag and remaining quotas\n    pub async fn check_rate_limit(&self, user_id: &str) -> Result<RateLimitStatus, anyhow::Error> {\n        let url = format!(\"{}/rest/v1/rpc/check_rate_limit\", self.supabase_url);\n\n        tracing::debug!(user_id = %user_id, \"Checking rate limit\");\n\n        let response = self\n            .client\n            .post(&url)\n            .header(\"apikey\", self.service_key.as_str())\n            .header(\n                \"Authorization\",\n                format!(\"Bearer {}\", self.service_key.as_str()),\n            )\n            .header(\"Content-Type\", \"application/json\")\n            .json(&serde_json::json!({ \"p_user_id\": user_id }))\n            .send()\n            .await?;\n\n        if !response.status().is_success() {\n            let status_code = response.status();\n            let error_text = response.text().await.unwrap_or_default();\n            tracing::error!(\n                status = %status_code,\n                error = %error_text,\n                \"check_rate_limit RPC failed - check if function exists in Supabase\"\n            );\n            return Err(anyhow::anyhow!(\"Supabase RPC error: {}\", error_text));\n        }\n\n        let status = response.json::<RateLimitStatus>().await?;\n        tracing::debug!(\n            allowed = status.allowed,\n            subscribed = status.subscribed,\n            requests_remaining = status.requests_remaining,\n            \"Rate limit check result\"\n        );\n        Ok(status)\n    }\n\n    /// Track a completed request\n    ///\n    /// Calls the `increment_usage` RPC function to record:\n    /// - Request count\n    /// - Input/output tokens\n    /// - Image request flag\n    ///\n    /// # Note\n    /// This is designed to be called in a fire-and-forget manner\n    /// using `tokio::spawn` to not block the response.\n    ///\n    /// # Arguments\n    /// * `user_id` - User UUID from JWT claims\n    /// * `input_tokens` - Number of input tokens used\n    /// * `output_tokens` - Number of output tokens generated\n    /// * `is_image_request` - Whether this was an image analysis request\n    pub async fn track_request(\n        &self,\n        user_id: &str,\n        input_tokens: i32,\n        output_tokens: i32,\n        is_image_request: bool,\n    ) -> Result<(), anyhow::Error> {\n        let today = chrono::Utc::now().format(\"%Y-%m-%d\").to_string();\n        let url = format!(\"{}/rest/v1/rpc/increment_usage\", self.supabase_url);\n\n        tracing::debug!(\n            user_id = %user_id,\n            date = %today,\n            is_image = is_image_request,\n            \"Tracking usage request\"\n        );\n\n        let response = self\n            .client\n            .post(&url)\n            .header(\"apikey\", self.service_key.as_str())\n            .header(\n                \"Authorization\",\n                format!(\"Bearer {}\", self.service_key.as_str()),\n            )\n            .header(\"Content-Type\", \"application/json\")\n            .json(&serde_json::json!({\n                \"p_user_id\": user_id,\n                \"p_date\": today,\n                \"p_request_count\": 1,\n                \"p_input_tokens\": input_tokens,\n                \"p_output_tokens\": output_tokens,\n                \"p_image_requests\": if is_image_request { 1 } else { 0 }\n            }))\n            .send()\n            .await?;\n\n        if !response.status().is_success() {\n            let status = response.status();\n            let error_text = response.text().await.unwrap_or_default();\n            tracing::error!(\n                status = %status,\n                error = %error_text,\n                \"Failed to track usage - check if increment_usage RPC function exists\"\n            );\n            return Err(anyhow::anyhow!(\"Supabase RPC error: {}\", error_text));\n        }\n\n        tracing::info!(user_id = %user_id, \"Usage tracked successfully\");\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "apps/capsule/src/types/decrypted.rs",
    "content": "use base64::{engine::general_purpose::STANDARD, Engine};\nuse zeroize::{Zeroize, ZeroizeOnDrop};\n\n/// 복호화된 이미지 - Capsule 내부에서만 존재\n///\n/// # 보안 특성\n/// - `Debug` 미구현: 로그에 출력 불가\n/// - `Display` 미구현: `println!` 등으로 출력 불가\n/// - `Clone` 미구현: 복사 불가\n/// - `Zeroize` + `ZeroizeOnDrop`: Drop 시 자동 메모리 클리어\n///\n/// # 컴파일 타임 보안\n/// ```compile_fail\n/// let img = DecryptedImage::new(vec![1, 2, 3]);\n/// println!(\"{:?}\", img);  // 컴파일 에러: Debug 미구현\n/// ```\n///\n/// ```compile_fail\n/// let img = DecryptedImage::new(vec![1, 2, 3]);\n/// let copy = img.clone();  // 컴파일 에러: Clone 미구현\n/// ```\n#[derive(Zeroize, ZeroizeOnDrop)]\npub struct DecryptedImage {\n    /// 복호화된 이미지 데이터 (private 필드)\n    data: Vec<u8>,\n}\n\nimpl DecryptedImage {\n    /// 내부에서만 생성 가능 (crate 내부 가시성)\n    pub(crate) fn new(data: Vec<u8>) -> Self {\n        Self { data }\n    }\n\n    /// OpenAI API 호출용 base64 인코딩\n    ///\n    /// # 주의\n    /// 반환된 `String`도 사용 후 명시적으로 zeroize 권장\n    ///\n    /// # Example\n    /// ```\n    /// let mut base64_str = decrypted.to_base64();\n    /// // ... OpenAI API 호출 ...\n    /// base64_str.zeroize();  // 명시적 클리어\n    /// ```\n    pub fn to_base64(&self) -> String {\n        STANDARD.encode(&self.data)\n    }\n\n    /// 이미지 크기 (바이트)\n    pub fn len(&self) -> usize {\n        self.data.len()\n    }\n\n    /// 이미지가 비어있는지 확인\n    pub fn is_empty(&self) -> bool {\n        self.data.is_empty()\n    }\n}\n\n// Debug, Display, Clone을 의도적으로 구현하지 않음\n// 이는 컴파일 타임에 민감 데이터 노출을 방지함\n"
  },
  {
    "path": "apps/capsule/src/types/encrypted.rs",
    "content": "use serde::{Deserialize, Serialize};\n\nuse crate::error::CapsuleError;\n\n/// 최대 히스토리 메시지 수\nconst MAX_HISTORY_MESSAGES: usize = 50;\n\n/// 최대 히스토리 총 문자 수 (~25k 토큰)\nconst MAX_HISTORY_CHARS: usize = 100_000;\n\n/// 허용되는 메시지 역할\nconst ALLOWED_ROLES: &[&str] = &[\"user\", \"assistant\", \"system\", \"tool\"];\n\n/// 클라이언트에서 전송된 암호화된 이미지\n/// 이 타입은 안전하게 로깅 가능 (민감 데이터 없음 - 복호화 불가)\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct EncryptedImage {\n    /// 클라이언트의 임시 공개키 (32 bytes, base64)\n    pub ephemeral_public_key: String,\n    /// Nonce (24 bytes, base64)\n    pub nonce: String,\n    /// 암호화된 이미지 데이터 (base64)\n    pub ciphertext: String,\n    /// 암호화에 사용된 서버 키 ID (하위 호환을 위해 선택사항)\n    #[serde(default)]\n    pub key_id: Option<String>,\n}\n\n/// 대화 히스토리의 단일 메시지\n///\n/// 클라이언트가 관리하며, 서버는 저장하지 않음 (zero-knowledge)\n/// 이미지는 텍스트 요약으로만 포함 가능\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct HistoryMessage {\n    /// 메시지 역할: \"user\", \"assistant\", \"system\", 또는 \"tool\"\n    pub role: String,\n    /// 텍스트 내용\n    pub content: String,\n    /// Tool call ID (role이 \"tool\"일 때 필수)\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub tool_call_id: Option<String>,\n    /// Tool calls (role이 \"assistant\"이고 tool call 응답일 때)\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub tool_calls: Option<serde_json::Value>,\n}\n\n/// 채팅 요청\n#[derive(Debug, Deserialize)]\npub struct ChatRequest {\n    /// 사용자 메시지\n    pub message: String,\n    /// 암호화된 이미지 (선택사항)\n    #[serde(default)]\n    pub encrypted_image: Option<EncryptedImage>,\n    /// 대화 히스토리 (선택사항, 오래된 순서)\n    #[serde(default)]\n    pub history: Option<Vec<HistoryMessage>>,\n    /// 시스템 프롬프트 (선택사항)\n    #[serde(default)]\n    pub system_prompt: Option<String>,\n    /// OpenAI tools 정의 (프론트엔드에서 주입, 그대로 passthrough)\n    #[serde(default)]\n    pub tools: Option<serde_json::Value>,\n    /// OpenAI tool_choice (프론트엔드에서 주입, 그대로 passthrough)\n    /// \"auto\" | \"required\" | \"none\" | {\"type\":\"function\",\"function\":{\"name\":\"...\"}}\n    #[serde(default)]\n    pub tool_choice: Option<serde_json::Value>,\n}\n\nimpl ChatRequest {\n    /// 히스토리 유효성 검사\n    pub fn validate_history(&self) -> Result<(), CapsuleError> {\n        if let Some(history) = &self.history {\n            if history.len() > MAX_HISTORY_MESSAGES {\n                return Err(CapsuleError::HistoryTooLarge(format!(\n                    \"History contains {} messages, maximum is {}\",\n                    history.len(),\n                    MAX_HISTORY_MESSAGES\n                )));\n            }\n\n            let total_chars: usize = history.iter().map(|m| m.content.len()).sum();\n            if total_chars > MAX_HISTORY_CHARS {\n                return Err(CapsuleError::HistoryTooLarge(format!(\n                    \"History total size is {} characters, maximum is {}\",\n                    total_chars, MAX_HISTORY_CHARS\n                )));\n            }\n\n            for msg in history {\n                if !ALLOWED_ROLES.contains(&msg.role.as_str()) {\n                    return Err(CapsuleError::HistoryTooLarge(format!(\n                        \"Invalid role '{}', allowed: {:?}\",\n                        msg.role, ALLOWED_ROLES\n                    )));\n                }\n            }\n        }\n        Ok(())\n    }\n}\n\n/// 채팅 응답\n#[derive(Debug, Serialize)]\npub struct ChatResponse {\n    /// AI 응답 텍스트\n    pub response: String,\n    /// Tool calls (함수 호출 요청, 있을 경우)\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub tool_calls: Option<serde_json::Value>,\n}\n\n/// Public Key 응답\n#[derive(Debug, Serialize)]\npub struct PublicKeyResponse {\n    /// 서버 공개키 (base64)\n    pub public_key: String,\n    /// 키 고유 식별자\n    pub key_id: String,\n    /// 키 만료 예상 시각 (RFC 3339) - 클라이언트 캐시 갱신 힌트\n    pub expires_at: String,\n}\n\n/// 스트리밍 청크\n#[derive(Debug, Serialize)]\npub struct StreamChunk {\n    /// 응답 텍스트 조각\n    pub content: String,\n    /// 완료 여부\n    pub done: bool,\n}\n"
  },
  {
    "path": "apps/capsule/src/types/mod.rs",
    "content": "mod encrypted;\nmod decrypted;\n\npub use encrypted::*;\npub use decrypted::*;\n"
  },
  {
    "path": "apps/capsule/tests/api.test.mjs",
    "content": "/**\n * Capsule API 테스트\n *\n * 사용법:\n *   cd apps/capsule/tests\n *   npm install\n *   npm test                         # 텍스트 전용 테스트\n *   npm test ./path/to/image.jpg     # 이미지 분석 테스트\n */\n\nimport { readFileSync } from 'fs';\nimport { encryptImage } from './crypto.mjs';\n\nconst BASE_URL = process.env.CAPSULE_URL || 'http://localhost:3000';\n\n// 테스트 결과 추적\nconst results = {\n  passed: 0,\n  failed: 0,\n  tests: [],\n};\n\n/** 테스트 실행 헬퍼 */\nasync function test(name, fn) {\n  process.stdout.write(`  ${name}... `);\n  try {\n    await fn();\n    console.log('✓');\n    results.passed++;\n    results.tests.push({ name, status: 'passed' });\n  } catch (error) {\n    console.log('✗');\n    console.log(`    Error: ${error.message}`);\n    results.failed++;\n    results.tests.push({ name, status: 'failed', error: error.message });\n  }\n}\n\n/** 단언 헬퍼 */\nfunction assert(condition, message) {\n  if (!condition) {\n    throw new Error(message || 'Assertion failed');\n  }\n}\n\nfunction assertEqual(actual, expected, message) {\n  if (actual !== expected) {\n    throw new Error(message || `Expected ${expected}, got ${actual}`);\n  }\n}\n\n/** API 호출 헬퍼 */\nasync function api(path, options = {}) {\n  const response = await fetch(`${BASE_URL}${path}`, {\n    headers: { 'Content-Type': 'application/json' },\n    ...options,\n  });\n\n  if (!response.ok) {\n    const error = await response.text();\n    throw new Error(`API Error ${response.status}: ${error}`);\n  }\n\n  const contentType = response.headers.get('content-type');\n  if (contentType && contentType.includes('application/json')) {\n    return response.json();\n  }\n  return response.text();\n}\n\n// ============================================\n// 테스트 케이스\n// ============================================\n\nasync function testHealthCheck() {\n  const response = await fetch(`${BASE_URL}/health`);\n  assertEqual(response.status, 200, 'Health check should return 200');\n  const text = await response.text();\n  assertEqual(text, 'OK', 'Health check should return OK');\n}\n\nasync function testPublicKey() {\n  const data = await api('/api/public-key');\n  assert(data.public_key, 'Response should have public_key');\n  assert(data.public_key.length > 0, 'Public key should not be empty');\n  // Base64 encoded 32 bytes = 44 characters\n  assertEqual(data.public_key.length, 44, 'Public key should be 44 chars (base64 of 32 bytes)');\n  return data.public_key;\n}\n\nasync function testTextChat() {\n  const data = await api('/api/chat', {\n    method: 'POST',\n    body: JSON.stringify({ message: '1+1은?' }),\n  });\n  assert(data.response, 'Response should have response field');\n  assert(data.response.length > 0, 'Response should not be empty');\n}\n\nasync function testTextChatEmptyMessage() {\n  const data = await api('/api/chat', {\n    method: 'POST',\n    body: JSON.stringify({ message: '' }),\n  });\n  // 빈 메시지도 처리되어야 함\n  assert(data.response !== undefined, 'Response should exist');\n}\n\nasync function testImageEncryption(publicKey) {\n  // 테스트용 더미 이미지 데이터 (1x1 JPEG)\n  const dummyJpeg = new Uint8Array([\n    0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01,\n    0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43,\n    0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09,\n    0x09, 0x08, 0x0a, 0x0c, 0x14, 0x0d, 0x0c, 0x0b, 0x0b, 0x0c, 0x19, 0x12,\n    0x13, 0x0f, 0x14, 0x1d, 0x1a, 0x1f, 0x1e, 0x1d, 0x1a, 0x1c, 0x1c, 0x20,\n    0x24, 0x2e, 0x27, 0x20, 0x22, 0x2c, 0x23, 0x1c, 0x1c, 0x28, 0x37, 0x29,\n    0x2c, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1f, 0x27, 0x39, 0x3d, 0x38, 0x32,\n    0x3c, 0x2e, 0x33, 0x34, 0x32, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01,\n    0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00, 0x1f, 0x00, 0x00,\n    0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,\n    0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,\n    0x09, 0x0a, 0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x10, 0x00, 0x02, 0x01, 0x03,\n    0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7d,\n    0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06,\n    0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xa1, 0x08,\n    0x23, 0x42, 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, 0x24, 0x33, 0x62, 0x72,\n    0x82, 0x09, 0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, 0x26, 0x27, 0x28,\n    0x29, 0x2a, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45,\n    0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59,\n    0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75,\n    0x76, 0x77, 0x78, 0x79, 0x7a, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,\n    0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3,\n    0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6,\n    0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9,\n    0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2,\n    0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf1, 0xf2, 0xf3, 0xf4,\n    0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01,\n    0x00, 0x00, 0x3f, 0x00, 0xfb, 0xd5, 0xdb, 0x20, 0xa8, 0xf1, 0x45, 0x10,\n    0xff, 0xd9,\n  ]);\n\n  const encrypted = await encryptImage(dummyJpeg, publicKey);\n\n  assert(encrypted.ephemeral_public_key, 'Should have ephemeral_public_key');\n  assert(encrypted.nonce, 'Should have nonce');\n  assert(encrypted.ciphertext, 'Should have ciphertext');\n\n  assertEqual(encrypted.ephemeral_public_key.length, 44, 'Ephemeral public key should be 44 chars');\n  assertEqual(encrypted.nonce.length, 32, 'Nonce should be 32 chars (24 bytes base64)');\n  assert(encrypted.ciphertext.length > 0, 'Ciphertext should not be empty');\n}\n\nasync function testImageAnalysis(imagePath, publicKey) {\n  // 이미지 로드\n  const imageData = new Uint8Array(readFileSync(imagePath));\n  console.log(`\\n    Image: ${imagePath} (${imageData.length} bytes)`);\n\n  // 이미지 암호화\n  const encrypted = await encryptImage(imageData, publicKey);\n  console.log(`    Encrypted: ${encrypted.ciphertext.length} chars (base64)`);\n\n  // API 호출\n  const data = await api('/api/chat', {\n    method: 'POST',\n    body: JSON.stringify({\n      message: '이 이미지에 무엇이 있는지 간단히 설명해주세요.',\n      encrypted_image: encrypted,\n    }),\n  });\n\n  assert(data.response, 'Response should have response field');\n  assert(data.response.length > 0, 'Response should not be empty');\n  console.log(`    Response: ${data.response.substring(0, 100)}...`);\n}\n\nasync function testInvalidEncryptedImage() {\n  try {\n    await api('/api/chat', {\n      method: 'POST',\n      body: JSON.stringify({\n        message: 'test',\n        encrypted_image: {\n          ephemeral_public_key: 'invalid',\n          nonce: 'invalid',\n          ciphertext: 'invalid',\n        },\n      }),\n    });\n    throw new Error('Should have thrown error for invalid encrypted image');\n  } catch (error) {\n    assert(error.message.includes('API Error'), 'Should return API error');\n  }\n}\n\n// ============================================\n// 메인\n// ============================================\n\nasync function main() {\n  console.log(`\\nCapsule API Tests`);\n  console.log(`Server: ${BASE_URL}`);\n  console.log('─'.repeat(50));\n\n  let publicKey = null;\n\n  // 기본 테스트\n  console.log('\\n[Basic Tests]');\n  await test('Health check', testHealthCheck);\n  await test('Get public key', async () => {\n    publicKey = await testPublicKey();\n  });\n\n  // 텍스트 채팅 테스트\n  console.log('\\n[Text Chat Tests]');\n  await test('Text chat', testTextChat);\n  await test('Empty message', testTextChatEmptyMessage);\n\n  // 암호화 테스트\n  console.log('\\n[Encryption Tests]');\n  await test('Image encryption', () => testImageEncryption(publicKey));\n  await test('Invalid encrypted image', testInvalidEncryptedImage);\n\n  // 이미지 분석 테스트 (이미지 경로가 제공된 경우)\n  const imagePath = process.argv[2];\n  if (imagePath) {\n    console.log('\\n[Image Analysis Tests]');\n    await test('Image analysis', () => testImageAnalysis(imagePath, publicKey));\n  } else {\n    console.log('\\n[Image Analysis Tests]');\n    console.log('  Skipped (no image path provided)');\n    console.log('  Usage: npm test ./path/to/image.jpg');\n  }\n\n  // 결과 출력\n  console.log('\\n' + '─'.repeat(50));\n  console.log(`Results: ${results.passed} passed, ${results.failed} failed`);\n\n  if (results.failed > 0) {\n    console.log('\\nFailed tests:');\n    results.tests\n      .filter((t) => t.status === 'failed')\n      .forEach((t) => console.log(`  - ${t.name}: ${t.error}`));\n    process.exit(1);\n  }\n\n  console.log('\\nAll tests passed! ✓\\n');\n}\n\nmain().catch((error) => {\n  console.error('\\nTest suite failed:', error.message);\n  process.exit(1);\n});\n"
  },
  {
    "path": "apps/capsule/tests/crypto.mjs",
    "content": "/**\n * 클라이언트 측 암호화 유틸리티\n * 서버의 암호화 스킴과 동일하게 구현\n */\n\nimport { x25519 } from '@noble/curves/ed25519';\nimport { xchacha20poly1305 } from '@noble/ciphers/chacha';\nimport { hkdf } from '@noble/hashes/hkdf';\nimport { sha256 } from '@noble/hashes/sha256';\nimport { randomBytes } from '@noble/ciphers/webcrypto';\n\n/** HKDF 상수 (서버와 동일해야 함) */\nconst HKDF_SALT = new TextEncoder().encode('vessel-capsule-v1-salt');\nconst HKDF_INFO = new TextEncoder().encode('vessel-capsule-v1-key');\n\n/** Base64 인코딩 */\nexport function bytesToBase64(bytes) {\n  return Buffer.from(bytes).toString('base64');\n}\n\n/** Base64 디코딩 */\nexport function base64ToBytes(base64) {\n  return new Uint8Array(Buffer.from(base64, 'base64'));\n}\n\n/**\n * 이미지를 서버 공개키로 암호화\n *\n * @param {Uint8Array} imageData - 암호화할 이미지 데이터\n * @param {string} serverPublicKeyBase64 - 서버 공개키 (base64)\n * @returns {Promise<Object>} 암호화된 이미지 데이터\n */\nexport async function encryptImage(imageData, serverPublicKeyBase64) {\n  // 1. 서버 공개키 디코딩\n  const serverPublicKey = base64ToBytes(serverPublicKeyBase64);\n\n  // 2. 클라이언트 임시 키 쌍 생성\n  const ephemeralPrivateKey = randomBytes(32);\n  const ephemeralPublicKey = x25519.getPublicKey(ephemeralPrivateKey);\n\n  // 3. Shared secret 계산 (X25519 ECDH)\n  const sharedSecret = x25519.getSharedSecret(ephemeralPrivateKey, serverPublicKey);\n\n  // 4. HKDF로 대칭키 유도\n  const symmetricKey = hkdf(sha256, sharedSecret, HKDF_SALT, HKDF_INFO, 32);\n\n  // 5. Nonce 생성 (24 bytes)\n  const nonce = randomBytes(24);\n\n  // 6. XChaCha20-Poly1305로 암호화\n  const cipher = xchacha20poly1305(symmetricKey, nonce);\n  const ciphertext = cipher.encrypt(imageData);\n\n  // 민감 데이터 클리어\n  ephemeralPrivateKey.fill(0);\n  sharedSecret.fill(0);\n  symmetricKey.fill(0);\n\n  return {\n    ephemeral_public_key: bytesToBase64(ephemeralPublicKey),\n    nonce: bytesToBase64(nonce),\n    ciphertext: bytesToBase64(ciphertext),\n  };\n}\n"
  },
  {
    "path": "apps/capsule/tests/package.json",
    "content": "{\n  \"name\": \"capsule-tests\",\n  \"type\": \"module\",\n  \"private\": true,\n  \"scripts\": {\n    \"test\": \"node api.test.mjs\",\n    \"test:image\": \"node api.test.mjs\"\n  },\n  \"dependencies\": {\n    \"@noble/ciphers\": \"^0.5.0\",\n    \"@noble/curves\": \"^1.3.0\",\n    \"@noble/hashes\": \"^1.3.3\"\n  }\n}\n"
  },
  {
    "path": "apps/client/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n\nmaindist"
  },
  {
    "path": "apps/client/README.md",
    "content": "# client\n"
  },
  {
    "path": "apps/client/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"new-york\",\n  \"rsc\": false,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"tailwind.config.js\",\n    \"css\": \"src/index.css\",\n    \"baseColor\": \"neutral\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/lib\",\n    \"hooks\": \"@/hooks\"\n  },\n  \"iconLibrary\": \"lucide\"\n}\n"
  },
  {
    "path": "apps/client/eslint.config.js",
    "content": "import js from \"@eslint/js\";\nimport globals from \"globals\";\nimport reactHooks from \"eslint-plugin-react-hooks\";\nimport reactRefresh from \"eslint-plugin-react-refresh\";\nimport tseslint from \"typescript-eslint\";\n\nexport default tseslint.config(\n  { ignores: [\"dist\"] },\n  {\n    extends: [js.configs.recommended, ...tseslint.configs.recommended],\n    files: [\"**/*.{ts,tsx}\"],\n    languageOptions: {\n      ecmaVersion: 2020,\n      globals: globals.browser,\n    },\n    plugins: {\n      \"react-hooks\": reactHooks,\n      \"react-refresh\": reactRefresh,\n    },\n    rules: {\n      ...reactHooks.configs.recommended.rules,\n      \"react-refresh/only-export-components\": [\n        \"warn\",\n        { allowConstantExport: true },\n      ],\n    },\n  },\n);\n"
  },
  {
    "path": "apps/client/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\" class=\"dark\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Vessel</title>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "apps/client/package.json",
    "content": "{\n  \"name\": \"client\",\n  \"private\": true,\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"main\": \"maindist/main.js\",\n  \"scripts\": {\n    \"dev\": \"concurrently \\\"npm run dev:vite\\\"\",\n    \"dev:vite\": \"vite --host\",\n    \"build:main\": \"tsc -p ./configs/electron\",\n    \"build:app\": \"tsc -b && vite build\",\n    \"build:vite\": \"tsc -b && vite build\",\n    \"build\": \"vite build\",\n    \"lint\": \"eslint .\",\n    \"preview\": \"vite preview\",\n    \"test\": \"vitest run\",\n    \"test:watch\": \"vitest\"\n  },\n  \"dependencies\": {\n    \"@vessel/capsule-client\": \"file:../../packages/capsule-client\",\n    \"@codemirror/lang-json\": \"^6.0.2\",\n    \"@emotion/react\": \"^11.14.0\",\n    \"@monaco-editor/react\": \"^4.7.0\",\n    \"@radix-ui/react-alert-dialog\": \"^1.1.14\",\n    \"@radix-ui/react-avatar\": \"^1.1.10\",\n    \"@radix-ui/react-context-menu\": \"^2.2.16\",\n    \"@radix-ui/react-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.1.15\",\n    \"@radix-ui/react-label\": \"^2.1.7\",\n    \"@radix-ui/react-navigation-menu\": \"^1.2.14\",\n    \"@radix-ui/react-popover\": \"^1.1.15\",\n    \"@radix-ui/react-scroll-area\": \"^1.2.10\",\n    \"@radix-ui/react-select\": \"^2.2.5\",\n    \"@radix-ui/react-separator\": \"^1.1.7\",\n    \"@radix-ui/react-slot\": \"^1.2.3\",\n    \"@radix-ui/react-switch\": \"^1.2.5\",\n    \"@radix-ui/react-tabs\": \"^1.1.13\",\n    \"@radix-ui/react-tooltip\": \"^1.2.7\",\n    \"@shadcn/ui\": \"^0.0.4\",\n    \"@supabase/supabase-js\": \"^2.49.4\",\n    \"@tailwindcss/postcss\": \"^4.1.8\",\n    \"@tauri-apps/api\": \"^2.2.0\",\n    \"@tauri-apps/plugin-shell\": \"^2.3.4\",\n    \"@uiw/react-codemirror\": \"^4.24.2\",\n    \"autoprefixer\": \"^10.4.21\",\n    \"axios\": \"^1.11.0\",\n    \"clsx\": \"^2.1.1\",\n    \"cmdk\": \"^1.1.1\",\n    \"d3\": \"^7.9.0\",\n    \"electron\": \"^35.0.0\",\n    \"js-cookie\": \"^3.0.5\",\n    \"leaflet\": \"^1.9.4\",\n    \"maplibre-gl\": \"^5.6.2\",\n    \"next-themes\": \"^0.4.6\",\n    \"postcss\": \"^8.5.4\",\n    \"react-hook-form\": \"^7.62.0\",\n    \"react-icons\": \"^5.5.0\",\n    \"react-leaflet\": \"^5.0.0\",\n    \"react-map-gl\": \"^8.0.4\",\n    \"react-resizable-panels\": \"^3.0.5\",\n    \"sonner\": \"^2.0.5\",\n    \"vite-plugin-dts\": \"^4.5.3\",\n    \"zustand\": \"^5.0.5\"\n  },\n  \"devDependencies\": {\n    \"vitest\": \"^3.0.0\",\n    \"@eslint/js\": \"^9.21.0\",\n    \"@types/d3\": \"^7.4.3\",\n    \"@types/js-cookie\": \"^3.0.6\",\n    \"@types/leaflet\": \"^1.9.20\",\n    \"wait-on\": \"^8.0.3\"\n  }\n}\n"
  },
  {
    "path": "apps/client/src/App.tsx",
    "content": "import { AuthPage } from \"./pages/auth\";\n\nimport { createBrowserRouter, RouterProvider } from \"react-router\";\nimport {\n  DashboardSwipeLayout,\n  DashboardSwipeRoutePlaceholder,\n} from \"./features/dashboard-swipe/DashboardSwipeLayout\";\nimport { DevicePage } from \"./pages/devices\";\nimport { FlowPage } from \"./pages/flow\";\nimport { AuthInterceptor } from \"./features/auth/AuthInterceptor\";\nimport { NotFound } from \"./pages/notfound\";\nimport LandingPage from \"./pages/landing\";\nimport { MapPage } from \"./pages/map\";\nimport { SetupPage } from \"./pages/setup\";\nimport { CodePage } from \"./pages/code\";\nimport { AuthenticatedLayout } from \"./widgets/auth/AuthenticatedLayout\";\nimport { TopBarWrapper } from \"./widgets/auth/TopBarWrapper\";\nimport { useDesktopSidecar } from \"./hooks/useDesktopSidecar\";\nimport { usePreventBackNavigation } from \"./hooks/usePreventBackNavigation\";\nimport { SettingsPage } from \"./pages/settings\";\nimport { AccountSettingsPage } from \"./pages/settings/account\";\nimport { ServicesSettingsPage } from \"./pages/settings/services\";\nimport { UsersSettingsPage } from \"./pages/settings/users\";\nimport { NetworksSettingsPage } from \"./pages/settings/networks\";\nimport { IntegrationSettingsPage } from \"./pages/settings/integration\";\nimport { LogSettingsPage } from \"./pages/settings/log\";\nimport { ConfigSettingsPage } from \"./pages/settings/config\";\nimport { RecordingsPage } from \"./pages/recordings\";\nimport { DesktopSettingsPage } from \"./pages/desktop-settings\";\n\nconst router = createBrowserRouter([\n  {\n    path: \"/\",\n    element: <LandingPage />,\n  },\n  {\n    path: \"/auth\",\n    element: (\n      <TopBarWrapper hide={true}>\n        <AuthPage />\n      </TopBarWrapper>\n    ),\n  },\n  {\n    element: <AuthenticatedLayout />,\n    children: [\n      {\n        element: (\n          <AuthInterceptor>\n            <DashboardSwipeLayout />\n          </AuthInterceptor>\n        ),\n        children: [\n          {\n            path: \"/dashboard\",\n            element: <DashboardSwipeRoutePlaceholder />,\n          },\n          {\n            path: \"/dynamic-dashboard/new\",\n            element: <DashboardSwipeRoutePlaceholder />,\n          },\n          {\n            path: \"/dynamic-dashboard/:dashboardId\",\n            element: <DashboardSwipeRoutePlaceholder />,\n          },\n          {\n            path: \"/dynamic-dashboard\",\n            element: <DashboardSwipeRoutePlaceholder />,\n          },\n        ],\n      },\n      {\n        path: \"/devices\",\n        element: (\n          <AuthInterceptor>\n            <DevicePage />\n          </AuthInterceptor>\n        ),\n      },\n      {\n        path: \"/flow\",\n        element: (\n          <AuthInterceptor>\n            <FlowPage />\n          </AuthInterceptor>\n        ),\n      },\n      {\n        path: \"/map\",\n        element: (\n          <AuthInterceptor>\n            <MapPage />\n          </AuthInterceptor>\n        ),\n      },\n      {\n        path: \"/setup\",\n        element: (\n          <AuthInterceptor>\n            <SetupPage />\n          </AuthInterceptor>\n        ),\n      },\n      {\n        path: \"/settings\",\n        element: (\n          <AuthInterceptor>\n            <SettingsPage />\n          </AuthInterceptor>\n        ),\n      },\n      {\n        path: \"/settings/account\",\n        element: (\n          <AuthInterceptor>\n            <AccountSettingsPage />\n          </AuthInterceptor>\n        ),\n      },\n      {\n        path: \"/settings/services\",\n        element: (\n          <AuthInterceptor>\n            <ServicesSettingsPage />\n          </AuthInterceptor>\n        ),\n      },\n      {\n        path: \"/settings/users\",\n        element: (\n          <AuthInterceptor>\n            <UsersSettingsPage />\n          </AuthInterceptor>\n        ),\n      },\n      {\n        path: \"/settings/networks\",\n        element: (\n          <AuthInterceptor>\n            <NetworksSettingsPage />\n          </AuthInterceptor>\n        ),\n      },\n      {\n        path: \"/settings/integration\",\n        element: (\n          <AuthInterceptor>\n            <IntegrationSettingsPage />\n          </AuthInterceptor>\n        ),\n      },\n      {\n        path: \"/settings/log\",\n        element: (\n          <AuthInterceptor>\n            <LogSettingsPage />\n          </AuthInterceptor>\n        ),\n      },\n      {\n        path: \"/settings/config\",\n        element: (\n          <AuthInterceptor>\n            <ConfigSettingsPage />\n          </AuthInterceptor>\n        ),\n      },\n      {\n        path: \"/code\",\n        element: (\n          <AuthInterceptor>\n            <CodePage />\n          </AuthInterceptor>\n        ),\n      },\n      {\n        path: \"/recordings\",\n        element: (\n          <AuthInterceptor>\n            <RecordingsPage />\n          </AuthInterceptor>\n        ),\n      },\n    ],\n  },\n  {\n    path: \"*\",\n    element: (\n      <TopBarWrapper>\n        <NotFound />\n      </TopBarWrapper>\n    ),\n  },\n]);\n\nconst isDesktopSettingsWindow = (): boolean => {\n  if (typeof window === \"undefined\") return false;\n  try {\n    const params = new URLSearchParams(window.location.search);\n    if (params.get(\"view\") === \"desktop_settings\") return true;\n    if (params.get(\"desktop_settings\") === \"1\") return true;\n    if (window.location.hash.includes(\"desktop_settings\")) return true;\n    return false;\n  } catch {\n    return false;\n  }\n};\n\nfunction App() {\n  useDesktopSidecar();\n  usePreventBackNavigation();\n\n  if (isDesktopSettingsWindow()) {\n    return <DesktopSettingsPage />;\n  }\n\n  return (\n    <>\n      <RouterProvider router={router} />\n    </>\n  );\n}\n\nexport default App;\n"
  },
  {
    "path": "apps/client/src/app/pageWrapper/page-wrapper.tsx",
    "content": "import { isElectron } from \"@/lib/electron\";\nimport type { PropsWithChildren } from \"react\";\n\nexport function PageWrapper(props: PropsWithChildren) {\n  if (isElectron()) {\n    return (\n      <div className='relative overflow-scroll h-[calc(100%_-_34px)] top-[34px]'>\n        {props.children}\n      </div>\n    );\n  } else {\n    return (\n      <div className='relative overflow-scroll h-full'>{props.children}</div>\n    );\n  }\n}\n"
  },
  {
    "path": "apps/client/src/app/providers/theme-provider.tsx",
    "content": "import { createContext, useContext, useEffect, useState } from \"react\";\n\ntype Theme = \"dark\" | \"light\" | \"system\";\n\ntype ThemeProviderProps = {\n  children: React.ReactNode;\n  defaultTheme?: Theme;\n  storageKey?: string;\n};\n\ntype ThemeProviderState = {\n  theme: Theme;\n  setTheme: (theme: Theme) => void;\n};\n\nconst initialState: ThemeProviderState = {\n  theme: \"system\",\n  setTheme: () => null,\n};\n\nconst ThemeProviderContext = createContext<ThemeProviderState>(initialState);\n\nexport function ThemeProvider({\n  children,\n  defaultTheme = \"system\",\n  storageKey = \"vite-ui-theme\",\n  ...props\n}: ThemeProviderProps) {\n  const [theme, setTheme] = useState<Theme>(\n    () => (localStorage.getItem(storageKey) as Theme) || defaultTheme\n  );\n\n  useEffect(() => {\n    const root = window.document.documentElement;\n\n    root.classList.remove(\"light\", \"dark\");\n\n    if (theme === \"system\") {\n      const systemTheme = window.matchMedia(\"(prefers-color-scheme: dark)\")\n        .matches\n        ? \"dark\"\n        : \"light\";\n\n      root.classList.add(systemTheme);\n      return;\n    }\n\n    root.classList.add(theme);\n  }, [theme]);\n\n  const value = {\n    theme,\n    setTheme: (theme: Theme) => {\n      localStorage.setItem(storageKey, theme);\n      setTheme(theme);\n    },\n  };\n\n  return (\n    <ThemeProviderContext.Provider {...props} value={value}>\n      {children}\n    </ThemeProviderContext.Provider>\n  );\n}\n\nexport const useTheme = () => {\n  const context = useContext(ThemeProviderContext);\n\n  if (context === undefined)\n    throw new Error(\"useTheme must be used within a ThemeProvider\");\n\n  return context;\n};\n"
  },
  {
    "path": "apps/client/src/components/icon/Logo.tsx",
    "content": "export function VesselLogo() {\n  return (\n    <svg\n      width='285'\n      height='133'\n      viewBox='0 0 285 133'\n      fill='none'\n      xmlns='http://www.w3.org/2000/svg'\n    >\n      <path\n        d='M168.023 87.8333L161.544 87.2898L155.965 65.3658L138.509 58.843L137.429 54.4945L139.589 54.8568L139.949 52.139L135.99 50.3271L134.37 52.5014L135.27 58.2994H131.671L129.151 36.5567L139.229 36.1943V34.5636L128.251 34.2012L127.891 28.5844L133.47 28.7656L133.29 27.316L127.351 26.5913L126.992 22.6051L137.429 23.5111L137.069 21.6992L126.812 20.9744L126.272 18.4378L128.971 17.8942V15.5387L123.032 14.6328V8.47234H125.552V7.20401H123.032V0.5H121.953L120.693 15.5387L118.893 16.0823V17.8942L120.693 18.9813V20.9744H111.695V22.4239L121.953 23.1487V34.2012H107.736V35.6508L123.032 36.7379L121.953 46.8845L119.793 59.2054L115.114 65.3658L107.196 66.8154L96.398 65.3658L94.5984 72.251L92.6189 65.3658L83.0809 66.8154L67.6042 65.9094L65.6246 70.6203H59.8658L57.8862 76.2372L45.109 74.9689L18.6546 77.6867L17.7548 82.76H0.838379L6.41719 93.0878L136.889 118.998L247.386 131.5L261.423 114.831L282.838 99.7918L216.972 94.8997H173.242L171.622 91.0947L168.563 90.1888L168.023 87.8333Z'\n        fill='#EEEEF5'\n        stroke='#EEEEF5'\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/components/ui/alert-dialog.tsx",
    "content": "import * as React from \"react\";\nimport * as AlertDialogPrimitive from \"@radix-ui/react-alert-dialog\";\n\nimport { cn } from \"@/lib/utils\";\nimport { buttonVariants } from \"@/components/ui/button\";\n\nfunction AlertDialog({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {\n  return <AlertDialogPrimitive.Root data-slot='alert-dialog' {...props} />;\n}\n\nfunction AlertDialogTrigger({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {\n  return (\n    <AlertDialogPrimitive.Trigger data-slot='alert-dialog-trigger' {...props} />\n  );\n}\n\nfunction AlertDialogPortal({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {\n  return (\n    <AlertDialogPrimitive.Portal data-slot='alert-dialog-portal' {...props} />\n  );\n}\n\nfunction AlertDialogOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {\n  return (\n    <AlertDialogPrimitive.Overlay\n      data-slot='alert-dialog-overlay'\n      className={cn(\n        \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {\n  return (\n    <AlertDialogPortal>\n      <AlertDialogOverlay />\n      <AlertDialogPrimitive.Content\n        data-slot='alert-dialog-content'\n        className={cn(\n          \"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg\",\n          className,\n        )}\n        {...props}\n      />\n    </AlertDialogPortal>\n  );\n}\n\nfunction AlertDialogHeader({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='alert-dialog-header'\n      className={cn(\"flex flex-col gap-2 text-center sm:text-left\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogFooter({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='alert-dialog-footer'\n      className={cn(\n        \"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {\n  return (\n    <AlertDialogPrimitive.Title\n      data-slot='alert-dialog-title'\n      className={cn(\"text-lg font-semibold\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {\n  return (\n    <AlertDialogPrimitive.Description\n      data-slot='alert-dialog-description'\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogAction({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {\n  return (\n    <AlertDialogPrimitive.Action\n      className={cn(buttonVariants(), className)}\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogCancel({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {\n  return (\n    <AlertDialogPrimitive.Cancel\n      className={cn(buttonVariants({ variant: \"outline\" }), className)}\n      {...props}\n    />\n  );\n}\n\nexport {\n  AlertDialog,\n  AlertDialogPortal,\n  AlertDialogOverlay,\n  AlertDialogTrigger,\n  AlertDialogContent,\n  AlertDialogHeader,\n  AlertDialogFooter,\n  AlertDialogTitle,\n  AlertDialogDescription,\n  AlertDialogAction,\n  AlertDialogCancel,\n};\n"
  },
  {
    "path": "apps/client/src/components/ui/alert.tsx",
    "content": "import * as React from \"react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst alertVariants = cva(\n  \"relative w-full border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-background text-foreground\",\n        destructive:\n          \"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  },\n);\n\nconst Alert = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>\n>(({ className, variant, ...props }, ref) => (\n  <div\n    ref={ref}\n    role='alert'\n    className={cn(alertVariants({ variant }), className)}\n    {...props}\n  />\n));\nAlert.displayName = \"Alert\";\n\nconst AlertTitle = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLHeadingElement>\n>(({ className, ...props }, ref) => (\n  <h5\n    ref={ref}\n    className={cn(\"mb-1 font-medium leading-none tracking-tight\", className)}\n    {...props}\n  />\n));\nAlertTitle.displayName = \"AlertTitle\";\n\nconst AlertDescription = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"text-sm [&_p]:leading-relaxed\", className)}\n    {...props}\n  />\n));\nAlertDescription.displayName = \"AlertDescription\";\n\nexport { Alert, AlertTitle, AlertDescription };\n"
  },
  {
    "path": "apps/client/src/components/ui/avatar.tsx",
    "content": "import * as React from \"react\"\nimport * as AvatarPrimitive from \"@radix-ui/react-avatar\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Avatar({\n  className,\n  ...props\n}: React.ComponentProps<typeof AvatarPrimitive.Root>) {\n  return (\n    <AvatarPrimitive.Root\n      data-slot=\"avatar\"\n      className={cn(\n        \"relative flex size-8 shrink-0 overflow-hidden rounded-full\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AvatarImage({\n  className,\n  ...props\n}: React.ComponentProps<typeof AvatarPrimitive.Image>) {\n  return (\n    <AvatarPrimitive.Image\n      data-slot=\"avatar-image\"\n      className={cn(\"aspect-square size-full\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction AvatarFallback({\n  className,\n  ...props\n}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {\n  return (\n    <AvatarPrimitive.Fallback\n      data-slot=\"avatar-fallback\"\n      className={cn(\n        \"bg-muted flex size-full items-center justify-center rounded-full\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Avatar, AvatarImage, AvatarFallback }\n"
  },
  {
    "path": "apps/client/src/components/ui/badge.tsx",
    "content": "import * as React from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst badgeVariants = cva(\n  \"inline-flex items-center justify-center border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90\",\n        secondary:\n          \"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90\",\n        destructive:\n          \"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60\",\n        outline:\n          \"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  },\n);\n\nfunction Badge({\n  className,\n  variant,\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"span\"> &\n  VariantProps<typeof badgeVariants> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot : \"span\";\n\n  return (\n    <Comp\n      data-slot='badge'\n      className={cn(badgeVariants({ variant }), className)}\n      {...props}\n    />\n  );\n}\n\nexport { Badge, badgeVariants };\n"
  },
  {
    "path": "apps/client/src/components/ui/breadcrumb.tsx",
    "content": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { ChevronRight, MoreHorizontal } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Breadcrumb({ ...props }: React.ComponentProps<\"nav\">) {\n  return <nav aria-label=\"breadcrumb\" data-slot=\"breadcrumb\" {...props} />\n}\n\nfunction BreadcrumbList({ className, ...props }: React.ComponentProps<\"ol\">) {\n  return (\n    <ol\n      data-slot=\"breadcrumb-list\"\n      className={cn(\n        \"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction BreadcrumbItem({ className, ...props }: React.ComponentProps<\"li\">) {\n  return (\n    <li\n      data-slot=\"breadcrumb-item\"\n      className={cn(\"inline-flex items-center gap-1.5\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction BreadcrumbLink({\n  asChild,\n  className,\n  ...props\n}: React.ComponentProps<\"a\"> & {\n  asChild?: boolean\n}) {\n  const Comp = asChild ? Slot : \"a\"\n\n  return (\n    <Comp\n      data-slot=\"breadcrumb-link\"\n      className={cn(\"hover:text-foreground transition-colors\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction BreadcrumbPage({ className, ...props }: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"breadcrumb-page\"\n      role=\"link\"\n      aria-disabled=\"true\"\n      aria-current=\"page\"\n      className={cn(\"text-foreground font-normal\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction BreadcrumbSeparator({\n  children,\n  className,\n  ...props\n}: React.ComponentProps<\"li\">) {\n  return (\n    <li\n      data-slot=\"breadcrumb-separator\"\n      role=\"presentation\"\n      aria-hidden=\"true\"\n      className={cn(\"[&>svg]:size-3.5\", className)}\n      {...props}\n    >\n      {children ?? <ChevronRight />}\n    </li>\n  )\n}\n\nfunction BreadcrumbEllipsis({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"breadcrumb-ellipsis\"\n      role=\"presentation\"\n      aria-hidden=\"true\"\n      className={cn(\"flex size-9 items-center justify-center\", className)}\n      {...props}\n    >\n      <MoreHorizontal className=\"size-4\" />\n      <span className=\"sr-only\">More</span>\n    </span>\n  )\n}\n\nexport {\n  Breadcrumb,\n  BreadcrumbList,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n  BreadcrumbEllipsis,\n}\n"
  },
  {
    "path": "apps/client/src/components/ui/button.tsx",
    "content": "import * as React from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst buttonVariants = cva(\n  \"inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90\",\n        destructive:\n          \"!bg-red-600 !text-white hover:!bg-red-700 shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20\",\n        outline:\n          \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50\",\n        secondary:\n          \"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80\",\n        ghost:\n          \"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50\",\n        link: \"text-primary underline-offset-4 hover:underline\",\n      },\n      size: {\n        default: \"h-9 px-4 py-2 has-[>svg]:px-3\",\n        sm: \"h-8 gap-1.5 px-3 has-[>svg]:px-2.5\",\n        lg: \"h-10 px-6 has-[>svg]:px-4\",\n        icon: \"size-9\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  },\n);\n\nfunction Button({\n  className,\n  variant,\n  size,\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"button\"> &\n  VariantProps<typeof buttonVariants> & {\n    asChild?: boolean;\n  }) {\n  const Comp = asChild ? Slot : \"button\";\n\n  return (\n    <Comp\n      data-slot='button'\n      className={cn(buttonVariants({ variant, size, className }))}\n      {...props}\n    />\n  );\n}\n\nexport { Button, buttonVariants };\n"
  },
  {
    "path": "apps/client/src/components/ui/card.tsx",
    "content": "import * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Card({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='card'\n      className={cn(\n        \"bg-card text-card-foreground flex flex-col gap-6 border py-6 shadow-sm\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CardHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='card-header'\n      className={cn(\n        \"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CardTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='card-title'\n      className={cn(\"leading-none font-semibold\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction CardDescription({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='card-description'\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction CardAction({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='card-action'\n      className={cn(\n        \"col-start-2 row-span-2 row-start-1 self-start justify-self-end\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CardContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='card-content'\n      className={cn(\"px-6\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction CardFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='card-footer'\n      className={cn(\"flex items-center px-6 [.border-t]:pt-6\", className)}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Card,\n  CardHeader,\n  CardFooter,\n  CardTitle,\n  CardAction,\n  CardDescription,\n  CardContent,\n};\n"
  },
  {
    "path": "apps/client/src/components/ui/command.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { type DialogProps } from \"@radix-ui/react-dialog\";\nimport { Command as CommandPrimitive } from \"cmdk\";\nimport { Search } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Dialog, DialogContent } from \"@/components/ui/dialog\";\n\nconst Command = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive\n    ref={ref}\n    className={cn(\n      \"flex h-full w-full flex-col overflow-hidden bg-popover text-popover-foreground\",\n      className,\n    )}\n    {...props}\n  />\n));\nCommand.displayName = CommandPrimitive.displayName;\n\nconst CommandDialog = ({ children, ...props }: DialogProps) => {\n  return (\n    <Dialog {...props}>\n      <DialogContent className='overflow-hidden p-0'>\n        <Command className='[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5'>\n          {children}\n        </Command>\n      </DialogContent>\n    </Dialog>\n  );\n};\n\nconst CommandInput = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Input>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>\n>(({ className, ...props }, ref) => (\n  <div className='flex items-center border-b px-3' cmdk-input-wrapper=''>\n    <Search className='mr-2 h-4 w-4 shrink-0 opacity-50' />\n    <CommandPrimitive.Input\n      ref={ref}\n      className={cn(\n        \"flex h-10 w-full bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50\",\n        className,\n      )}\n      {...props}\n    />\n  </div>\n));\n\nCommandInput.displayName = CommandPrimitive.Input.displayName;\n\nconst CommandList = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.List\n    ref={ref}\n    className={cn(\"max-h-[300px] overflow-y-auto overflow-x-hidden\", className)}\n    {...props}\n  />\n));\n\nCommandList.displayName = CommandPrimitive.List.displayName;\n\nconst CommandEmpty = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Empty>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>\n>((props, ref) => (\n  <CommandPrimitive.Empty\n    ref={ref}\n    className='py-6 text-center text-sm'\n    {...props}\n  />\n));\n\nCommandEmpty.displayName = CommandPrimitive.Empty.displayName;\n\nconst CommandGroup = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Group>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Group\n    ref={ref}\n    className={cn(\n      \"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground\",\n      className,\n    )}\n    {...props}\n  />\n));\n\nCommandGroup.displayName = CommandPrimitive.Group.displayName;\n\nconst CommandSeparator = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Separator\n    ref={ref}\n    className={cn(\"-mx-1 h-px bg-border\", className)}\n    {...props}\n  />\n));\nCommandSeparator.displayName = CommandPrimitive.Separator.displayName;\n\nconst CommandItem = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Item\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default gap-2 select-none items-center px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n      className,\n    )}\n    {...props}\n  />\n));\n\nCommandItem.displayName = CommandPrimitive.Item.displayName;\n\nconst CommandShortcut = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLSpanElement>) => {\n  return (\n    <span\n      className={cn(\n        \"ml-auto text-xs tracking-widest text-muted-foreground\",\n        className,\n      )}\n      {...props}\n    />\n  );\n};\nCommandShortcut.displayName = \"CommandShortcut\";\n\nexport {\n  Command,\n  CommandDialog,\n  CommandInput,\n  CommandList,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandShortcut,\n  CommandSeparator,\n};\n"
  },
  {
    "path": "apps/client/src/components/ui/context-menu.tsx",
    "content": "import * as React from \"react\"\nimport * as ContextMenuPrimitive from \"@radix-ui/react-context-menu\"\nimport { Check, ChevronRight, Circle } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst ContextMenu = ContextMenuPrimitive.Root\n\nconst ContextMenuTrigger = ContextMenuPrimitive.Trigger\n\nconst ContextMenuGroup = ContextMenuPrimitive.Group\n\nconst ContextMenuPortal = ContextMenuPrimitive.Portal\n\nconst ContextMenuSub = ContextMenuPrimitive.Sub\n\nconst ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup\n\nconst ContextMenuSubTrigger = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {\n    inset?: boolean\n  }\n>(({ className, inset, children, ...props }, ref) => (\n  <ContextMenuPrimitive.SubTrigger\n    ref={ref}\n    className={cn(\n      \"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground\",\n      inset && \"pl-8\",\n      className\n    )}\n    {...props}\n  >\n    {children}\n    <ChevronRight className=\"ml-auto h-4 w-4\" />\n  </ContextMenuPrimitive.SubTrigger>\n))\nContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName\n\nconst ContextMenuSubContent = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.SubContent>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>\n>(({ className, ...props }, ref) => (\n  <ContextMenuPrimitive.SubContent\n    ref={ref}\n    className={cn(\n      \"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]\",\n      className\n    )}\n    {...props}\n  />\n))\nContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName\n\nconst ContextMenuContent = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <ContextMenuPrimitive.Portal>\n    <ContextMenuPrimitive.Content\n      ref={ref}\n      className={cn(\n        \"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]\",\n        className\n      )}\n      {...props}\n    />\n  </ContextMenuPrimitive.Portal>\n))\nContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName\n\nconst ContextMenuItem = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {\n    inset?: boolean\n  }\n>(({ className, inset, ...props }, ref) => (\n  <ContextMenuPrimitive.Item\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      inset && \"pl-8\",\n      className\n    )}\n    {...props}\n  />\n))\nContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName\n\nconst ContextMenuCheckboxItem = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>\n>(({ className, children, checked, ...props }, ref) => (\n  <ContextMenuPrimitive.CheckboxItem\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      className\n    )}\n    checked={checked}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <ContextMenuPrimitive.ItemIndicator>\n        <Check className=\"h-4 w-4\" />\n      </ContextMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </ContextMenuPrimitive.CheckboxItem>\n))\nContextMenuCheckboxItem.displayName =\n  ContextMenuPrimitive.CheckboxItem.displayName\n\nconst ContextMenuRadioItem = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>\n>(({ className, children, ...props }, ref) => (\n  <ContextMenuPrimitive.RadioItem\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      className\n    )}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <ContextMenuPrimitive.ItemIndicator>\n        <Circle className=\"h-4 w-4 fill-current\" />\n      </ContextMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </ContextMenuPrimitive.RadioItem>\n))\nContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName\n\nconst ContextMenuLabel = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {\n    inset?: boolean\n  }\n>(({ className, inset, ...props }, ref) => (\n  <ContextMenuPrimitive.Label\n    ref={ref}\n    className={cn(\n      \"px-2 py-1.5 text-sm font-semibold text-foreground\",\n      inset && \"pl-8\",\n      className\n    )}\n    {...props}\n  />\n))\nContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName\n\nconst ContextMenuSeparator = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <ContextMenuPrimitive.Separator\n    ref={ref}\n    className={cn(\"-mx-1 my-1 h-px bg-border\", className)}\n    {...props}\n  />\n))\nContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName\n\nconst ContextMenuShortcut = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLSpanElement>) => {\n  return (\n    <span\n      className={cn(\n        \"ml-auto text-xs tracking-widest text-muted-foreground\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\nContextMenuShortcut.displayName = \"ContextMenuShortcut\"\n\nexport {\n  ContextMenu,\n  ContextMenuTrigger,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuCheckboxItem,\n  ContextMenuRadioItem,\n  ContextMenuLabel,\n  ContextMenuSeparator,\n  ContextMenuShortcut,\n  ContextMenuGroup,\n  ContextMenuPortal,\n  ContextMenuSub,\n  ContextMenuSubContent,\n  ContextMenuSubTrigger,\n  ContextMenuRadioGroup,\n}\n"
  },
  {
    "path": "apps/client/src/components/ui/dialog.tsx",
    "content": "import * as React from \"react\";\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\";\nimport { XIcon } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Dialog({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Root>) {\n  return <DialogPrimitive.Root data-slot='dialog' {...props} />;\n}\n\nfunction DialogTrigger({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {\n  return <DialogPrimitive.Trigger data-slot='dialog-trigger' {...props} />;\n}\n\nfunction DialogPortal({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Portal>) {\n  return <DialogPrimitive.Portal data-slot='dialog-portal' {...props} />;\n}\n\nfunction DialogClose({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Close>) {\n  return <DialogPrimitive.Close data-slot='dialog-close' {...props} />;\n}\n\nfunction DialogOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {\n  return (\n    <DialogPrimitive.Overlay\n      data-slot='dialog-overlay'\n      className={cn(\n        \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction DialogContent({\n  className,\n  children,\n  showCloseButton = true,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Content> & {\n  showCloseButton?: boolean;\n}) {\n  return (\n    <DialogPortal data-slot='dialog-portal'>\n      <DialogOverlay />\n      <DialogPrimitive.Content\n        data-slot='dialog-content'\n        className={cn(\n          \"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4  border p-6 shadow-lg duration-200 sm:max-w-lg\",\n          className,\n        )}\n        {...props}\n      >\n        {children}\n        {showCloseButton && (\n          <DialogPrimitive.Close\n            data-slot='dialog-close'\n            className=\"ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n          >\n            <XIcon />\n            <span className='sr-only'>Close</span>\n          </DialogPrimitive.Close>\n        )}\n      </DialogPrimitive.Content>\n    </DialogPortal>\n  );\n}\n\nfunction DialogHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='dialog-header'\n      className={cn(\"flex flex-col gap-2 text-center sm:text-left\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction DialogFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='dialog-footer'\n      className={cn(\n        \"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction DialogTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Title>) {\n  return (\n    <DialogPrimitive.Title\n      data-slot='dialog-title'\n      className={cn(\"text-lg leading-none font-semibold\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction DialogDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Description>) {\n  return (\n    <DialogPrimitive.Description\n      data-slot='dialog-description'\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogOverlay,\n  DialogPortal,\n  DialogTitle,\n  DialogTrigger,\n};\n"
  },
  {
    "path": "apps/client/src/components/ui/dropdown-menu.tsx",
    "content": "import * as React from \"react\";\nimport * as DropdownMenuPrimitive from \"@radix-ui/react-dropdown-menu\";\nimport { CheckIcon, ChevronRightIcon, CircleIcon } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction DropdownMenu({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {\n  return <DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />;\n}\n\nfunction DropdownMenuPortal({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {\n  return (\n    <DropdownMenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} />\n  );\n}\n\nfunction DropdownMenuTrigger({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {\n  return (\n    <DropdownMenuPrimitive.Trigger\n      data-slot='dropdown-menu-trigger'\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuContent({\n  className,\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {\n  return (\n    <DropdownMenuPrimitive.Portal>\n      <DropdownMenuPrimitive.Content\n        data-slot='dropdown-menu-content'\n        sideOffset={sideOffset}\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto border p-1 shadow-md\",\n          className,\n        )}\n        {...props}\n      />\n    </DropdownMenuPrimitive.Portal>\n  );\n}\n\nfunction DropdownMenuGroup({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {\n  return (\n    <DropdownMenuPrimitive.Group data-slot='dropdown-menu-group' {...props} />\n  );\n}\n\nfunction DropdownMenuItem({\n  className,\n  inset,\n  variant = \"default\",\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {\n  inset?: boolean;\n  variant?: \"default\" | \"destructive\";\n}) {\n  return (\n    <DropdownMenuPrimitive.Item\n      data-slot='dropdown-menu-item'\n      data-inset={inset}\n      data-variant={variant}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuCheckboxItem({\n  className,\n  children,\n  checked,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {\n  return (\n    <DropdownMenuPrimitive.CheckboxItem\n      data-slot='dropdown-menu-checkbox-item'\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className,\n      )}\n      checked={checked}\n      {...props}\n    >\n      <span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>\n        <DropdownMenuPrimitive.ItemIndicator>\n          <CheckIcon className='size-4' />\n        </DropdownMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </DropdownMenuPrimitive.CheckboxItem>\n  );\n}\n\nfunction DropdownMenuRadioGroup({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {\n  return (\n    <DropdownMenuPrimitive.RadioGroup\n      data-slot='dropdown-menu-radio-group'\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuRadioItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {\n  return (\n    <DropdownMenuPrimitive.RadioItem\n      data-slot='dropdown-menu-radio-item'\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className,\n      )}\n      {...props}\n    >\n      <span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>\n        <DropdownMenuPrimitive.ItemIndicator>\n          <CircleIcon className='size-2 fill-current' />\n        </DropdownMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </DropdownMenuPrimitive.RadioItem>\n  );\n}\n\nfunction DropdownMenuLabel({\n  className,\n  inset,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {\n  inset?: boolean;\n}) {\n  return (\n    <DropdownMenuPrimitive.Label\n      data-slot='dropdown-menu-label'\n      data-inset={inset}\n      className={cn(\n        \"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {\n  return (\n    <DropdownMenuPrimitive.Separator\n      data-slot='dropdown-menu-separator'\n      className={cn(\"bg-border -mx-1 my-1 h-px\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuShortcut({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot='dropdown-menu-shortcut'\n      className={cn(\n        \"text-muted-foreground ml-auto text-xs tracking-widest\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuSub({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {\n  return <DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />;\n}\n\nfunction DropdownMenuSubTrigger({\n  className,\n  inset,\n  children,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {\n  inset?: boolean;\n}) {\n  return (\n    <DropdownMenuPrimitive.SubTrigger\n      data-slot='dropdown-menu-sub-trigger'\n      data-inset={inset}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n      <ChevronRightIcon className='ml-auto size-4' />\n    </DropdownMenuPrimitive.SubTrigger>\n  );\n}\n\nfunction DropdownMenuSubContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {\n  return (\n    <DropdownMenuPrimitive.SubContent\n      data-slot='dropdown-menu-sub-content'\n      className={cn(\n        \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport {\n  DropdownMenu,\n  DropdownMenuPortal,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuLabel,\n  DropdownMenuItem,\n  DropdownMenuCheckboxItem,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuSub,\n  DropdownMenuSubTrigger,\n  DropdownMenuSubContent,\n};\n"
  },
  {
    "path": "apps/client/src/components/ui/input.tsx",
    "content": "import * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Input({ className, type, ...props }: React.ComponentProps<\"input\">) {\n  return (\n    <input\n      type={type}\n      data-slot='input'\n      className={cn(\n        \"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0  border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n        \"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]\",\n        \"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Input };\n"
  },
  {
    "path": "apps/client/src/components/ui/label.tsx",
    "content": "import * as React from \"react\"\nimport * as LabelPrimitive from \"@radix-ui/react-label\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Label({\n  className,\n  ...props\n}: React.ComponentProps<typeof LabelPrimitive.Root>) {\n  return (\n    <LabelPrimitive.Root\n      data-slot=\"label\"\n      className={cn(\n        \"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Label }\n"
  },
  {
    "path": "apps/client/src/components/ui/navigation-menu.tsx",
    "content": "import * as React from \"react\"\nimport * as NavigationMenuPrimitive from \"@radix-ui/react-navigation-menu\"\nimport { cva } from \"class-variance-authority\"\nimport { ChevronDownIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction NavigationMenu({\n  className,\n  children,\n  viewport = true,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {\n  viewport?: boolean\n}) {\n  return (\n    <NavigationMenuPrimitive.Root\n      data-slot=\"navigation-menu\"\n      data-viewport={viewport}\n      className={cn(\n        \"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      {viewport && <NavigationMenuViewport />}\n    </NavigationMenuPrimitive.Root>\n  )\n}\n\nfunction NavigationMenuList({\n  className,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {\n  return (\n    <NavigationMenuPrimitive.List\n      data-slot=\"navigation-menu-list\"\n      className={cn(\n        \"group flex flex-1 list-none items-center justify-center gap-1\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction NavigationMenuItem({\n  className,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {\n  return (\n    <NavigationMenuPrimitive.Item\n      data-slot=\"navigation-menu-item\"\n      className={cn(\"relative\", className)}\n      {...props}\n    />\n  )\n}\n\nconst navigationMenuTriggerStyle = cva(\n  \"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1\"\n)\n\nfunction NavigationMenuTrigger({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {\n  return (\n    <NavigationMenuPrimitive.Trigger\n      data-slot=\"navigation-menu-trigger\"\n      className={cn(navigationMenuTriggerStyle(), \"group\", className)}\n      {...props}\n    >\n      {children}{\" \"}\n      <ChevronDownIcon\n        className=\"relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180\"\n        aria-hidden=\"true\"\n      />\n    </NavigationMenuPrimitive.Trigger>\n  )\n}\n\nfunction NavigationMenuContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {\n  return (\n    <NavigationMenuPrimitive.Content\n      data-slot=\"navigation-menu-content\"\n      className={cn(\n        \"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto\",\n        \"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction NavigationMenuViewport({\n  className,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {\n  return (\n    <div\n      className={cn(\n        \"absolute top-full left-0 isolate z-50 flex justify-center\"\n      )}\n    >\n      <NavigationMenuPrimitive.Viewport\n        data-slot=\"navigation-menu-viewport\"\n        className={cn(\n          \"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]\",\n          className\n        )}\n        {...props}\n      />\n    </div>\n  )\n}\n\nfunction NavigationMenuLink({\n  className,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {\n  return (\n    <NavigationMenuPrimitive.Link\n      data-slot=\"navigation-menu-link\"\n      className={cn(\n        \"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction NavigationMenuIndicator({\n  className,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {\n  return (\n    <NavigationMenuPrimitive.Indicator\n      data-slot=\"navigation-menu-indicator\"\n      className={cn(\n        \"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden\",\n        className\n      )}\n      {...props}\n    >\n      <div className=\"bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md\" />\n    </NavigationMenuPrimitive.Indicator>\n  )\n}\n\nexport {\n  NavigationMenu,\n  NavigationMenuList,\n  NavigationMenuItem,\n  NavigationMenuContent,\n  NavigationMenuTrigger,\n  NavigationMenuLink,\n  NavigationMenuIndicator,\n  NavigationMenuViewport,\n  navigationMenuTriggerStyle,\n}\n"
  },
  {
    "path": "apps/client/src/components/ui/popover.tsx",
    "content": "import * as React from \"react\"\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Popover = PopoverPrimitive.Root\n\nconst PopoverTrigger = PopoverPrimitive.Trigger\n\nconst PopoverAnchor = PopoverPrimitive.Anchor\n\ntype PopoverContentProps = React.ComponentPropsWithoutRef<\n  typeof PopoverPrimitive.Content\n> & { container?: HTMLElement | null }\n\nconst PopoverContent = React.forwardRef<\n  React.ElementRef<typeof PopoverPrimitive.Content>,\n  PopoverContentProps\n>(({ className, align = \"center\", sideOffset = 4, container, ...props }, ref) => (\n  <PopoverPrimitive.Portal container={container || undefined}>\n    <PopoverPrimitive.Content\n      ref={ref}\n      align={align}\n      sideOffset={sideOffset}\n      className={cn(\n        \"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]\",\n        className\n      )}\n      {...props}\n    />\n  </PopoverPrimitive.Portal>\n))\nPopoverContent.displayName = PopoverPrimitive.Content.displayName\n\nexport { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }\n"
  },
  {
    "path": "apps/client/src/components/ui/resizable.tsx",
    "content": "import { GripVertical } from \"lucide-react\"\nimport * as ResizablePrimitive from \"react-resizable-panels\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst ResizablePanelGroup = ({\n  className,\n  ...props\n}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (\n  <ResizablePrimitive.PanelGroup\n    className={cn(\n      \"flex h-full w-full data-[panel-group-direction=vertical]:flex-col\",\n      className\n    )}\n    {...props}\n  />\n)\n\nconst ResizablePanel = ResizablePrimitive.Panel\n\nconst ResizableHandle = ({\n  withHandle,\n  className,\n  ...props\n}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {\n  withHandle?: boolean\n}) => (\n  <ResizablePrimitive.PanelResizeHandle\n    className={cn(\n      \"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90\",\n      className\n    )}\n    {...props}\n  >\n    {withHandle && (\n      <div className=\"z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border\">\n        <GripVertical className=\"h-2.5 w-2.5\" />\n      </div>\n    )}\n  </ResizablePrimitive.PanelResizeHandle>\n)\n\nexport { ResizablePanelGroup, ResizablePanel, ResizableHandle }\n"
  },
  {
    "path": "apps/client/src/components/ui/scroll-area.tsx",
    "content": "import * as React from \"react\"\nimport * as ScrollAreaPrimitive from \"@radix-ui/react-scroll-area\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst ScrollArea = React.forwardRef<\n  React.ElementRef<typeof ScrollAreaPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>\n>(({ className, children, ...props }, ref) => (\n  <ScrollAreaPrimitive.Root\n    ref={ref}\n    className={cn(\"relative overflow-hidden\", className)}\n    {...props}\n  >\n    <ScrollAreaPrimitive.Viewport className=\"h-full w-full rounded-[inherit]\">\n      {children}\n    </ScrollAreaPrimitive.Viewport>\n    <ScrollBar />\n    <ScrollAreaPrimitive.Corner />\n  </ScrollAreaPrimitive.Root>\n))\nScrollArea.displayName = ScrollAreaPrimitive.Root.displayName\n\nconst ScrollBar = React.forwardRef<\n  React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,\n  React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>\n>(({ className, orientation = \"vertical\", ...props }, ref) => (\n  <ScrollAreaPrimitive.ScrollAreaScrollbar\n    ref={ref}\n    orientation={orientation}\n    className={cn(\n      \"flex touch-none select-none transition-colors\",\n      orientation === \"vertical\" &&\n        \"h-full w-2.5 border-l border-l-transparent p-[1px]\",\n      orientation === \"horizontal\" &&\n        \"h-2.5 flex-col border-t border-t-transparent p-[1px]\",\n      className\n    )}\n    {...props}\n  >\n    <ScrollAreaPrimitive.ScrollAreaThumb className=\"relative flex-1 rounded-full bg-border\" />\n  </ScrollAreaPrimitive.ScrollAreaScrollbar>\n))\nScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName\n\nexport { ScrollArea, ScrollBar }\n"
  },
  {
    "path": "apps/client/src/components/ui/select.tsx",
    "content": "import * as React from \"react\";\nimport * as SelectPrimitive from \"@radix-ui/react-select\";\nimport { CheckIcon, ChevronDownIcon, ChevronUpIcon } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Select({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Root>) {\n  return <SelectPrimitive.Root data-slot='select' {...props} />;\n}\n\nfunction SelectGroup({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Group>) {\n  return <SelectPrimitive.Group data-slot='select-group' {...props} />;\n}\n\nfunction SelectValue({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Value>) {\n  return <SelectPrimitive.Value data-slot='select-value' {...props} />;\n}\n\nfunction SelectTrigger({\n  className,\n  size = \"default\",\n  children,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {\n  size?: \"sm\" | \"default\";\n}) {\n  return (\n    <SelectPrimitive.Trigger\n      data-slot='select-trigger'\n      data-size={size}\n      className={cn(\n        \"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n      <SelectPrimitive.Icon asChild>\n        <ChevronDownIcon className='size-4 opacity-50' />\n      </SelectPrimitive.Icon>\n    </SelectPrimitive.Trigger>\n  );\n}\n\nfunction SelectContent({\n  className,\n  children,\n  position = \"popper\",\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Content>) {\n  return (\n    <SelectPrimitive.Portal>\n      <SelectPrimitive.Content\n        data-slot='select-content'\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto border shadow-md\",\n          position === \"popper\" &&\n            \"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1\",\n          className,\n        )}\n        position={position}\n        {...props}\n      >\n        <SelectScrollUpButton />\n        <SelectPrimitive.Viewport\n          className={cn(\n            \"p-1\",\n            position === \"popper\" &&\n              \"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1\",\n          )}\n        >\n          {children}\n        </SelectPrimitive.Viewport>\n        <SelectScrollDownButton />\n      </SelectPrimitive.Content>\n    </SelectPrimitive.Portal>\n  );\n}\n\nfunction SelectLabel({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Label>) {\n  return (\n    <SelectPrimitive.Label\n      data-slot='select-label'\n      className={cn(\"text-muted-foreground px-2 py-1.5 text-xs\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SelectItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Item>) {\n  return (\n    <SelectPrimitive.Item\n      data-slot='select-item'\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2\",\n        className,\n      )}\n      {...props}\n    >\n      <span className='absolute right-2 flex size-3.5 items-center justify-center'>\n        <SelectPrimitive.ItemIndicator>\n          <CheckIcon className='size-4' />\n        </SelectPrimitive.ItemIndicator>\n      </span>\n      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n    </SelectPrimitive.Item>\n  );\n}\n\nfunction SelectSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Separator>) {\n  return (\n    <SelectPrimitive.Separator\n      data-slot='select-separator'\n      className={cn(\"bg-border pointer-events-none -mx-1 my-1 h-px\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SelectScrollUpButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {\n  return (\n    <SelectPrimitive.ScrollUpButton\n      data-slot='select-scroll-up-button'\n      className={cn(\n        \"flex cursor-default items-center justify-center py-1\",\n        className,\n      )}\n      {...props}\n    >\n      <ChevronUpIcon className='size-4' />\n    </SelectPrimitive.ScrollUpButton>\n  );\n}\n\nfunction SelectScrollDownButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {\n  return (\n    <SelectPrimitive.ScrollDownButton\n      data-slot='select-scroll-down-button'\n      className={cn(\n        \"flex cursor-default items-center justify-center py-1\",\n        className,\n      )}\n      {...props}\n    >\n      <ChevronDownIcon className='size-4' />\n    </SelectPrimitive.ScrollDownButton>\n  );\n}\n\nexport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectLabel,\n  SelectScrollDownButton,\n  SelectScrollUpButton,\n  SelectSeparator,\n  SelectTrigger,\n  SelectValue,\n};\n"
  },
  {
    "path": "apps/client/src/components/ui/separator.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SeparatorPrimitive from \"@radix-ui/react-separator\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Separator({\n  className,\n  orientation = \"horizontal\",\n  decorative = true,\n  ...props\n}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {\n  return (\n    <SeparatorPrimitive.Root\n      data-slot=\"separator\"\n      decorative={decorative}\n      orientation={orientation}\n      className={cn(\n        \"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Separator }\n"
  },
  {
    "path": "apps/client/src/components/ui/sheet.tsx",
    "content": "import * as React from \"react\";\nimport * as SheetPrimitive from \"@radix-ui/react-dialog\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {\n  return <SheetPrimitive.Root data-slot='sheet' {...props} />;\n}\n\nfunction SheetTrigger({\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {\n  return <SheetPrimitive.Trigger data-slot='sheet-trigger' {...props} />;\n}\n\nfunction SheetClose({\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Close>) {\n  return <SheetPrimitive.Close data-slot='sheet-close' {...props} />;\n}\n\nfunction SheetPortal({\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Portal>) {\n  return <SheetPrimitive.Portal data-slot='sheet-portal' {...props} />;\n}\n\nfunction SheetOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {\n  return (\n    <SheetPrimitive.Overlay\n      data-slot='sheet-overlay'\n      className={cn(\n        \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SheetContent({\n  className,\n  children,\n  side = \"right\",\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Content> & {\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n}) {\n  return (\n    <SheetPortal>\n      <SheetOverlay />\n      <SheetPrimitive.Content\n        data-slot='sheet-content'\n        className={cn(\n          \"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-[999] flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 pt-12\",\n          side === \"right\" &&\n            \"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm\",\n          side === \"left\" &&\n            \"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm\",\n          side === \"top\" &&\n            \"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b\",\n          side === \"bottom\" &&\n            \"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t\",\n          className,\n        )}\n        {...props}\n      >\n        {children}\n        <SheetPrimitive.Close className='ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none'>\n          <span className='sr-only'>Close</span>\n        </SheetPrimitive.Close>\n      </SheetPrimitive.Content>\n    </SheetPortal>\n  );\n}\n\nfunction SheetHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='sheet-header'\n      className={cn(\"flex flex-col gap-1.5 p-4\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SheetFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='sheet-footer'\n      className={cn(\"mt-auto flex flex-col gap-2 p-4\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SheetTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Title>) {\n  return (\n    <SheetPrimitive.Title\n      data-slot='sheet-title'\n      className={cn(\"text-foreground font-semibold\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SheetDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Description>) {\n  return (\n    <SheetPrimitive.Description\n      data-slot='sheet-description'\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Sheet,\n  SheetTrigger,\n  SheetClose,\n  SheetContent,\n  SheetHeader,\n  SheetFooter,\n  SheetTitle,\n  SheetDescription,\n};\n"
  },
  {
    "path": "apps/client/src/components/ui/sidebar.tsx",
    "content": "import * as React from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva, VariantProps } from \"class-variance-authority\";\nimport { PanelLeftIcon } from \"lucide-react\";\n\nimport { useIsMobile } from \"@/hooks/use-mobile\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetHeader,\n  SheetTitle,\n} from \"@/components/ui/sheet\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\nconst SIDEBAR_COOKIE_NAME = \"sidebar_state\";\nconst SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;\nconst SIDEBAR_WIDTH = \"16rem\";\nconst SIDEBAR_WIDTH_MOBILE = \"18rem\";\nconst SIDEBAR_WIDTH_ICON = \"3rem\";\nconst SIDEBAR_KEYBOARD_SHORTCUT = \"b\";\n\ntype SidebarContextProps = {\n  state: \"expanded\" | \"collapsed\";\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  openMobile: boolean;\n  setOpenMobile: (open: boolean) => void;\n  isMobile: boolean;\n  toggleSidebar: () => void;\n};\n\nconst SidebarContext = React.createContext<SidebarContextProps | null>(null);\n\nfunction useSidebar() {\n  const context = React.useContext(SidebarContext);\n  if (!context) {\n    throw new Error(\"useSidebar must be used within a SidebarProvider.\");\n  }\n\n  return context;\n}\n\nfunction SidebarProvider({\n  defaultOpen = true,\n  open: openProp,\n  onOpenChange: setOpenProp,\n  className,\n  style,\n  children,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  defaultOpen?: boolean;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}) {\n  const isMobile = useIsMobile();\n  const [openMobile, setOpenMobile] = React.useState(false);\n\n  // This is the internal state of the sidebar.\n  // We use openProp and setOpenProp for control from outside the component.\n  const [_open, _setOpen] = React.useState(defaultOpen);\n  const open = openProp ?? _open;\n  const setOpen = React.useCallback(\n    (value: boolean | ((value: boolean) => boolean)) => {\n      const openState = typeof value === \"function\" ? value(open) : value;\n      if (setOpenProp) {\n        setOpenProp(openState);\n      } else {\n        _setOpen(openState);\n      }\n\n      // This sets the cookie to keep the sidebar state.\n      document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;\n    },\n    [setOpenProp, open],\n  );\n\n  // Helper to toggle the sidebar.\n  const toggleSidebar = React.useCallback(() => {\n    return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);\n  }, [isMobile, setOpen, setOpenMobile]);\n\n  // Adds a keyboard shortcut to toggle the sidebar.\n  React.useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (\n        event.key === SIDEBAR_KEYBOARD_SHORTCUT &&\n        (event.metaKey || event.ctrlKey)\n      ) {\n        event.preventDefault();\n        toggleSidebar();\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => window.removeEventListener(\"keydown\", handleKeyDown);\n  }, [toggleSidebar]);\n\n  // We add a state so that we can do data-state=\"expanded\" or \"collapsed\".\n  // This makes it easier to style the sidebar with Tailwind classes.\n  const state = open ? \"expanded\" : \"collapsed\";\n\n  const contextValue = React.useMemo<SidebarContextProps>(\n    () => ({\n      state,\n      open,\n      setOpen,\n      isMobile,\n      openMobile,\n      setOpenMobile,\n      toggleSidebar,\n    }),\n    [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],\n  );\n\n  return (\n    <SidebarContext.Provider value={contextValue}>\n      <TooltipProvider delayDuration={0}>\n        <div\n          data-slot='sidebar-wrapper'\n          style={\n            {\n              \"--sidebar-width\": SIDEBAR_WIDTH,\n              \"--sidebar-width-icon\": SIDEBAR_WIDTH_ICON,\n              ...style,\n            } as React.CSSProperties\n          }\n          className={cn(\n            \"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full\",\n            className,\n          )}\n          {...props}\n        >\n          {children}\n        </div>\n      </TooltipProvider>\n    </SidebarContext.Provider>\n  );\n}\n\nfunction Sidebar({\n  side = \"left\",\n  variant = \"sidebar\",\n  collapsible = \"offcanvas\",\n  className,\n  children,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  side?: \"left\" | \"right\";\n  variant?: \"sidebar\" | \"floating\" | \"inset\";\n  collapsible?: \"offcanvas\" | \"icon\" | \"none\";\n}) {\n  const { isMobile, state, openMobile, setOpenMobile } = useSidebar();\n\n  if (collapsible === \"none\") {\n    return (\n      <div\n        data-slot='sidebar'\n        className={cn(\n          \"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col\",\n          className,\n        )}\n        {...props}\n      >\n        {children}\n      </div>\n    );\n  }\n\n  if (isMobile) {\n    return (\n      <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>\n        <SheetContent\n          data-sidebar='sidebar'\n          data-slot='sidebar'\n          data-mobile='true'\n          className='bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden'\n          style={\n            {\n              \"--sidebar-width\": SIDEBAR_WIDTH_MOBILE,\n            } as React.CSSProperties\n          }\n          side={side}\n        >\n          <SheetHeader className='sr-only'>\n            <SheetTitle>Sidebar</SheetTitle>\n            <SheetDescription>Displays the mobile sidebar.</SheetDescription>\n          </SheetHeader>\n          <div className='flex h-full w-full flex-col'>{children}</div>\n        </SheetContent>\n      </Sheet>\n    );\n  }\n\n  return (\n    <div\n      className='group peer text-sidebar-foreground hidden md:block'\n      data-state={state}\n      data-collapsible={state === \"collapsed\" ? collapsible : \"\"}\n      data-variant={variant}\n      data-side={side}\n      data-slot='sidebar'\n    >\n      {/* This is what handles the sidebar gap on desktop */}\n      <div\n        data-slot='sidebar-gap'\n        className={cn(\n          \"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear\",\n          \"group-data-[collapsible=offcanvas]:w-0\",\n          \"group-data-[side=right]:rotate-180\",\n          variant === \"floating\" || variant === \"inset\"\n            ? \"group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]\"\n            : \"group-data-[collapsible=icon]:w-(--sidebar-width-icon)\",\n        )}\n      />\n      <div\n        data-slot='sidebar-container'\n        className={cn(\n          \"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex\",\n          side === \"left\"\n            ? \"left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]\"\n            : \"right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]\",\n          // Adjust the padding for floating and inset variants.\n          variant === \"floating\" || variant === \"inset\"\n            ? \"p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]\"\n            : \"group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l\",\n          className,\n        )}\n        {...props}\n      >\n        <div\n          data-sidebar='sidebar'\n          data-slot='sidebar-inner'\n          className='bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-[calc(100% - 20px)] w-full flex-col group-data-[variant=floating]: group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm'\n        >\n          {children}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction SidebarTrigger({\n  className,\n  onClick,\n  ...props\n}: React.ComponentProps<typeof Button>) {\n  const { toggleSidebar } = useSidebar();\n\n  return (\n    <Button\n      data-sidebar='trigger'\n      data-slot='sidebar-trigger'\n      variant='ghost'\n      size='icon'\n      className={cn(\"size-7\", className)}\n      onClick={(event) => {\n        onClick?.(event);\n        toggleSidebar();\n      }}\n      {...props}\n    >\n      <PanelLeftIcon />\n      <span className='sr-only'>Toggle Sidebar</span>\n    </Button>\n  );\n}\n\nfunction SidebarRail({ className, ...props }: React.ComponentProps<\"button\">) {\n  const { toggleSidebar } = useSidebar();\n\n  return (\n    <button\n      data-sidebar='rail'\n      data-slot='sidebar-rail'\n      aria-label='Toggle Sidebar'\n      tabIndex={-1}\n      onClick={toggleSidebar}\n      title='Toggle Sidebar'\n      className={cn(\n        \"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex\",\n        \"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize\",\n        \"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize\",\n        \"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full\",\n        \"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2\",\n        \"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarInset({ className, ...props }: React.ComponentProps<\"main\">) {\n  return (\n    <main\n      data-slot='sidebar-inset'\n      className={cn(\n        \"bg-background relative flex w-full flex-1 flex-col\",\n        \"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarInput({\n  className,\n  ...props\n}: React.ComponentProps<typeof Input>) {\n  return (\n    <Input\n      data-slot='sidebar-input'\n      data-sidebar='input'\n      className={cn(\"bg-background h-8 w-full shadow-none\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='sidebar-header'\n      data-sidebar='header'\n      className={cn(\"bg-background flex flex-col gap-2 p-2\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='sidebar-footer'\n      data-sidebar='footer'\n      className={cn(\"flex flex-col gap-2 p-2 bg-background\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof Separator>) {\n  return (\n    <Separator\n      data-slot='sidebar-separator'\n      data-sidebar='separator'\n      className={cn(\"bg-sidebar-border mx-2 w-auto\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='sidebar-content'\n      data-sidebar='content'\n      className={cn(\n        \"bg-background flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='sidebar-group'\n      data-sidebar='group'\n      className={cn(\"relative flex w-full min-w-0 flex-col p-2\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarGroupLabel({\n  className,\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"div\"> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot : \"div\";\n\n  return (\n    <Comp\n      data-slot='sidebar-group-label'\n      data-sidebar='group-label'\n      className={cn(\n        \"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center  px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0\",\n        \"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarGroupAction({\n  className,\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"button\"> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot : \"button\";\n\n  return (\n    <Comp\n      data-slot='sidebar-group-action'\n      data-sidebar='group-action'\n      className={cn(\n        \"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center  p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0\",\n        // Increases the hit area of the button on mobile.\n        \"after:absolute after:-inset-2 md:after:hidden\",\n        \"group-data-[collapsible=icon]:hidden\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarGroupContent({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='sidebar-group-content'\n      data-sidebar='group-content'\n      className={cn(\"w-full text-sm\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenu({ className, ...props }: React.ComponentProps<\"ul\">) {\n  return (\n    <ul\n      data-slot='sidebar-menu'\n      data-sidebar='menu'\n      className={cn(\n        \"flex w-full min-w-0 flex-col gap-1 bg-background\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuItem({ className, ...props }: React.ComponentProps<\"li\">) {\n  return (\n    <li\n      data-slot='sidebar-menu-item'\n      data-sidebar='menu-item'\n      className={cn(\"group/menu-item relative bg-background\", className)}\n      {...props}\n    />\n  );\n}\n\nconst sidebarMenuButtonVariants = cva(\n  \"peer/menu-button flex w-full items-center gap-2 overflow-hidden  p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0\",\n  {\n    variants: {\n      variant: {\n        default: \"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground\",\n        outline:\n          \"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]\",\n      },\n      size: {\n        default: \"h-8 text-sm\",\n        sm: \"h-7 text-xs\",\n        lg: \"h-12 text-sm group-data-[collapsible=icon]:p-0!\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  },\n);\n\nfunction SidebarMenuButton({\n  asChild = false,\n  isActive = false,\n  variant = \"default\",\n  size = \"default\",\n  tooltip,\n  className,\n  ...props\n}: React.ComponentProps<\"button\"> & {\n  asChild?: boolean;\n  isActive?: boolean;\n  tooltip?: string | React.ComponentProps<typeof TooltipContent>;\n} & VariantProps<typeof sidebarMenuButtonVariants>) {\n  const Comp = asChild ? Slot : \"button\";\n  const { isMobile, state } = useSidebar();\n\n  const button = (\n    <Comp\n      data-slot='sidebar-menu-button'\n      data-sidebar='menu-button'\n      data-size={size}\n      data-active={isActive}\n      className={cn(sidebarMenuButtonVariants({ variant, size }), className)}\n      {...props}\n    />\n  );\n\n  if (!tooltip) {\n    return button;\n  }\n\n  if (typeof tooltip === \"string\") {\n    tooltip = {\n      children: tooltip,\n    };\n  }\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>{button}</TooltipTrigger>\n      <TooltipContent\n        side='right'\n        align='center'\n        hidden={state !== \"collapsed\" || isMobile}\n        {...tooltip}\n      />\n    </Tooltip>\n  );\n}\n\nfunction SidebarMenuAction({\n  className,\n  asChild = false,\n  showOnHover = false,\n  ...props\n}: React.ComponentProps<\"button\"> & {\n  asChild?: boolean;\n  showOnHover?: boolean;\n}) {\n  const Comp = asChild ? Slot : \"button\";\n\n  return (\n    <Comp\n      data-slot='sidebar-menu-action'\n      data-sidebar='menu-action'\n      className={cn(\n        \"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center  p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0\",\n        // Increases the hit area of the button on mobile.\n        \"after:absolute after:-inset-2 md:after:hidden\",\n        \"peer-data-[size=sm]/menu-button:top-1\",\n        \"peer-data-[size=default]/menu-button:top-1.5\",\n        \"peer-data-[size=lg]/menu-button:top-2.5\",\n        \"group-data-[collapsible=icon]:hidden\",\n        showOnHover &&\n          \"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuBadge({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='sidebar-menu-badge'\n      data-sidebar='menu-badge'\n      className={cn(\n        \"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center  px-1 text-xs font-medium tabular-nums select-none\",\n        \"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground\",\n        \"peer-data-[size=sm]/menu-button:top-1\",\n        \"peer-data-[size=default]/menu-button:top-1.5\",\n        \"peer-data-[size=lg]/menu-button:top-2.5\",\n        \"group-data-[collapsible=icon]:hidden\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuSkeleton({\n  className,\n  showIcon = false,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  showIcon?: boolean;\n}) {\n  // Random width between 50 to 90%.\n  const width = React.useMemo(() => {\n    return `${Math.floor(Math.random() * 40) + 50}%`;\n  }, []);\n\n  return (\n    <div\n      data-slot='sidebar-menu-skeleton'\n      data-sidebar='menu-skeleton'\n      className={cn(\"flex h-8 items-center gap-2  px-2\", className)}\n      {...props}\n    >\n      {showIcon && (\n        <Skeleton className='size-4 ' data-sidebar='menu-skeleton-icon' />\n      )}\n      <Skeleton\n        className='h-4 max-w-(--skeleton-width) flex-1'\n        data-sidebar='menu-skeleton-text'\n        style={\n          {\n            \"--skeleton-width\": width,\n          } as React.CSSProperties\n        }\n      />\n    </div>\n  );\n}\n\nfunction SidebarMenuSub({ className, ...props }: React.ComponentProps<\"ul\">) {\n  return (\n    <ul\n      data-slot='sidebar-menu-sub'\n      data-sidebar='menu-sub'\n      className={cn(\n        \"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5\",\n        \"group-data-[collapsible=icon]:hidden\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuSubItem({\n  className,\n  ...props\n}: React.ComponentProps<\"li\">) {\n  return (\n    <li\n      data-slot='sidebar-menu-sub-item'\n      data-sidebar='menu-sub-item'\n      className={cn(\"group/menu-sub-item relative\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuSubButton({\n  asChild = false,\n  size = \"md\",\n  isActive = false,\n  className,\n  ...props\n}: React.ComponentProps<\"a\"> & {\n  asChild?: boolean;\n  size?: \"sm\" | \"md\";\n  isActive?: boolean;\n}) {\n  const Comp = asChild ? Slot : \"a\";\n\n  return (\n    <Comp\n      data-slot='sidebar-menu-sub-button'\n      data-sidebar='menu-sub-button'\n      data-size={size}\n      data-active={isActive}\n      className={cn(\n        \"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden  px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0\",\n        \"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground\",\n        size === \"sm\" && \"text-xs\",\n        size === \"md\" && \"text-sm\",\n        \"group-data-[collapsible=icon]:hidden\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Sidebar,\n  SidebarContent,\n  SidebarFooter,\n  SidebarGroup,\n  SidebarGroupAction,\n  SidebarGroupContent,\n  SidebarGroupLabel,\n  SidebarHeader,\n  SidebarInput,\n  SidebarInset,\n  SidebarMenu,\n  SidebarMenuAction,\n  SidebarMenuBadge,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  SidebarMenuSkeleton,\n  SidebarMenuSub,\n  SidebarMenuSubButton,\n  SidebarMenuSubItem,\n  SidebarProvider,\n  SidebarRail,\n  SidebarSeparator,\n  SidebarTrigger,\n  useSidebar,\n};\n"
  },
  {
    "path": "apps/client/src/components/ui/skeleton.tsx",
    "content": "import { cn } from \"@/lib/utils\"\n\nfunction Skeleton({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"skeleton\"\n      className={cn(\"bg-accent animate-pulse rounded-md\", className)}\n      {...props}\n    />\n  )\n}\n\nexport { Skeleton }\n"
  },
  {
    "path": "apps/client/src/components/ui/sonner.tsx",
    "content": "import { useTheme } from \"next-themes\"\nimport { Toaster as Sonner, ToasterProps } from \"sonner\"\n\nconst Toaster = ({ ...props }: ToasterProps) => {\n  const { theme = \"system\" } = useTheme()\n\n  return (\n    <Sonner\n      theme={theme as ToasterProps[\"theme\"]}\n      className=\"toaster group\"\n      style={\n        {\n          \"--normal-bg\": \"var(--popover)\",\n          \"--normal-text\": \"var(--popover-foreground)\",\n          \"--normal-border\": \"var(--border)\",\n          \"--border-radius\": \"0\",\n        } as React.CSSProperties\n      }\n      {...props}\n    />\n  )\n}\n\nexport { Toaster }\n"
  },
  {
    "path": "apps/client/src/components/ui/switch.tsx",
    "content": "import * as React from \"react\"\nimport * as SwitchPrimitive from \"@radix-ui/react-switch\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Switch({\n  className,\n  ...props\n}: React.ComponentProps<typeof SwitchPrimitive.Root>) {\n  return (\n    <SwitchPrimitive.Root\n      data-slot=\"switch\"\n      className={cn(\n        \"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    >\n      <SwitchPrimitive.Thumb\n        data-slot=\"switch-thumb\"\n        className={cn(\n          \"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0\"\n        )}\n      />\n    </SwitchPrimitive.Root>\n  )\n}\n\nexport { Switch }\n"
  },
  {
    "path": "apps/client/src/components/ui/table.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Table({ className, ...props }: React.ComponentProps<\"table\">) {\n  return (\n    <div\n      data-slot=\"table-container\"\n      className=\"relative w-full overflow-x-auto\"\n    >\n      <table\n        data-slot=\"table\"\n        className={cn(\"w-full caption-bottom text-sm\", className)}\n        {...props}\n      />\n    </div>\n  )\n}\n\nfunction TableHeader({ className, ...props }: React.ComponentProps<\"thead\">) {\n  return (\n    <thead\n      data-slot=\"table-header\"\n      className={cn(\"[&_tr]:border-b\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction TableBody({ className, ...props }: React.ComponentProps<\"tbody\">) {\n  return (\n    <tbody\n      data-slot=\"table-body\"\n      className={cn(\"[&_tr:last-child]:border-0\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction TableFooter({ className, ...props }: React.ComponentProps<\"tfoot\">) {\n  return (\n    <tfoot\n      data-slot=\"table-footer\"\n      className={cn(\n        \"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction TableRow({ className, ...props }: React.ComponentProps<\"tr\">) {\n  return (\n    <tr\n      data-slot=\"table-row\"\n      className={cn(\n        \"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction TableHead({ className, ...props }: React.ComponentProps<\"th\">) {\n  return (\n    <th\n      data-slot=\"table-head\"\n      className={cn(\n        \"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction TableCell({ className, ...props }: React.ComponentProps<\"td\">) {\n  return (\n    <td\n      data-slot=\"table-cell\"\n      className={cn(\n        \"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction TableCaption({\n  className,\n  ...props\n}: React.ComponentProps<\"caption\">) {\n  return (\n    <caption\n      data-slot=\"table-caption\"\n      className={cn(\"text-muted-foreground mt-4 text-sm\", className)}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Table,\n  TableHeader,\n  TableBody,\n  TableFooter,\n  TableHead,\n  TableRow,\n  TableCell,\n  TableCaption,\n}\n"
  },
  {
    "path": "apps/client/src/components/ui/tabs.tsx",
    "content": "import * as React from \"react\";\nimport * as TabsPrimitive from \"@radix-ui/react-tabs\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst Tabs = TabsPrimitive.Root;\n\nconst TabsList = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.List\n    ref={ref}\n    className={cn(\n      \"inline-flex h-9 items-center justify-center bg-muted p-1 text-muted-foreground\",\n      className,\n    )}\n    {...props}\n  />\n));\nTabsList.displayName = TabsPrimitive.List.displayName;\n\nconst TabsTrigger = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Trigger\n    ref={ref}\n    className={cn(\n      \"inline-flex items-center justify-center whitespace-nowrap  px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow\",\n      className,\n    )}\n    {...props}\n  />\n));\nTabsTrigger.displayName = TabsPrimitive.Trigger.displayName;\n\nconst TabsContent = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Content\n    ref={ref}\n    className={cn(\n      \"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n      className,\n    )}\n    {...props}\n  />\n));\nTabsContent.displayName = TabsPrimitive.Content.displayName;\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent };\n"
  },
  {
    "path": "apps/client/src/components/ui/textarea.tsx",
    "content": "import * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Textarea({ className, ...props }: React.ComponentProps<\"textarea\">) {\n  return (\n    <textarea\n      data-slot='textarea'\n      className={cn(\n        \"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Textarea };\n"
  },
  {
    "path": "apps/client/src/components/ui/tooltip.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as TooltipPrimitive from \"@radix-ui/react-tooltip\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction TooltipProvider({\n  delayDuration = 0,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {\n  return (\n    <TooltipPrimitive.Provider\n      data-slot=\"tooltip-provider\"\n      delayDuration={delayDuration}\n      {...props}\n    />\n  )\n}\n\nfunction Tooltip({\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Root>) {\n  return (\n    <TooltipProvider>\n      <TooltipPrimitive.Root data-slot=\"tooltip\" {...props} />\n    </TooltipProvider>\n  )\n}\n\nfunction TooltipTrigger({\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {\n  return <TooltipPrimitive.Trigger data-slot=\"tooltip-trigger\" {...props} />\n}\n\nfunction TooltipContent({\n  className,\n  sideOffset = 0,\n  children,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Content>) {\n  return (\n    <TooltipPrimitive.Portal>\n      <TooltipPrimitive.Content\n        data-slot=\"tooltip-content\"\n        sideOffset={sideOffset}\n        className={cn(\n          \"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n        <TooltipPrimitive.Arrow className=\"bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]\" />\n      </TooltipPrimitive.Content>\n    </TooltipPrimitive.Portal>\n  )\n}\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }\n"
  },
  {
    "path": "apps/client/src/contexts/SupabaseAuthContext.tsx",
    "content": "import {\n  createContext,\n  useContext,\n  useEffect,\n  useState,\n  useCallback,\n  type ReactNode,\n} from \"react\";\nimport type { Session } from \"@supabase/supabase-js\";\nimport { supabase } from \"@/lib/supabase\";\n\nconst isTauri = (): boolean => {\n  return typeof window !== \"undefined\" && \"__TAURI_INTERNALS__\" in window;\n};\n\ntype SupabaseAuthContextType = {\n  session: Session | null;\n  accessToken: string | null;\n  isLoading: boolean;\n  signInWithGoogle: () => Promise<void>;\n  signOut: () => Promise<void>;\n};\n\nconst SupabaseAuthContext = createContext<SupabaseAuthContextType | null>(null);\n\nexport function SupabaseAuthProvider({ children }: { children: ReactNode }) {\n  const [session, setSession] = useState<Session | null>(null);\n  const [isLoading, setIsLoading] = useState(true);\n\n  const handleDeepLink = useCallback(async (urls: string[]) => {\n    for (const url of urls) {\n      if (url.startsWith(\"vessel://auth/callback\")) {\n        const hashIndex = url.indexOf(\"#\");\n        if (hashIndex === -1) continue;\n\n        const hash = url.slice(hashIndex + 1);\n        const params = new URLSearchParams(hash);\n        const accessToken = params.get(\"access_token\");\n        const refreshToken = params.get(\"refresh_token\");\n\n        if (accessToken && refreshToken) {\n          const { data, error } = await supabase.auth.setSession({\n            access_token: accessToken,\n            refresh_token: refreshToken,\n          });\n\n          if (error) {\n            console.error(\"Failed to set session:\", error);\n          } else if (data.session) {\n            setSession(data.session);\n          }\n        }\n      }\n    }\n  }, []);\n\n  useEffect(() => {\n    supabase.auth.getSession().then(({ data: { session } }) => {\n      setSession(session);\n      setIsLoading(false);\n    });\n\n    const {\n      data: { subscription },\n    } = supabase.auth.onAuthStateChange((_event, session) => {\n      setSession(session);\n    });\n\n    return () => subscription.unsubscribe();\n  }, []);\n\n  useEffect(() => {\n    let unlisten: (() => void) | undefined;\n    let mounted = true;\n\n    if (isTauri()) {\n      import(\"@tauri-apps/api/event\")\n        .then(({ listen }) => {\n          if (!mounted) return;\n          listen<string[]>(\"deep-link-opened\", (event) => {\n            handleDeepLink(event.payload);\n          })\n            .then((fn) => {\n              if (mounted) {\n                unlisten = fn;\n              } else {\n                fn();\n              }\n            })\n            .catch((err) => {\n              console.error(\"Failed to listen for deep link:\", err);\n            });\n        })\n        .catch((err) => {\n          console.error(\"Failed to import @tauri-apps/api/event:\", err);\n        });\n    }\n\n    return () => {\n      mounted = false;\n      if (unlisten) {\n        unlisten();\n      }\n    };\n  }, [handleDeepLink]);\n\n  const signInWithGoogle = useCallback(async () => {\n    if (isTauri()) {\n      // Tauri: use deep link and open in external browser\n      const { data, error } = await supabase.auth.signInWithOAuth({\n        provider: \"google\",\n        options: {\n          redirectTo: \"vessel://auth/callback\",\n          skipBrowserRedirect: true,\n        },\n      });\n\n      if (error) {\n        console.error(\"OAuth error:\", error);\n        return;\n      }\n\n      if (data.url) {\n        try {\n          const { open } = await import(\"@tauri-apps/plugin-shell\");\n          await open(data.url);\n        } catch (err) {\n          console.error(\"Failed to open URL:\", err);\n        }\n      }\n    } else {\n      // Web browser: redirect in same window, Supabase handles the callback automatically\n      const { error } = await supabase.auth.signInWithOAuth({\n        provider: \"google\",\n        options: {\n          redirectTo: `${window.location.origin}/settings`,\n        },\n      });\n\n      if (error) {\n        console.error(\"OAuth error:\", error);\n      }\n    }\n  }, []);\n\n  const signOut = useCallback(async () => {\n    const { error } = await supabase.auth.signOut();\n    if (error) {\n      console.error(\"Sign out error:\", error);\n    } else {\n      setSession(null);\n    }\n  }, []);\n\n  return (\n    <SupabaseAuthContext.Provider\n      value={{\n        session,\n        accessToken: session?.access_token ?? null,\n        isLoading,\n        signInWithGoogle,\n        signOut,\n      }}\n    >\n      {children}\n    </SupabaseAuthContext.Provider>\n  );\n}\n\nexport function useSupabaseAuth() {\n  const context = useContext(SupabaseAuthContext);\n  if (!context) {\n    throw new Error(\n      \"useSupabaseAuth must be used within a SupabaseAuthProvider\",\n    );\n  }\n  return context;\n}\n"
  },
  {
    "path": "apps/client/src/entities/configurations/api.ts",
    "content": "import { apiClient } from \"@/shared/api\";\nimport type { SystemConfiguration, SystemConfigurationPayload } from \"./types\";\n\nexport const getConfigs = () =>\n  apiClient.get<SystemConfiguration[]>(\"/configurations\");\n\nexport const createConfig = (data: SystemConfigurationPayload) =>\n  apiClient.post<SystemConfiguration>(\"/configurations\", data);\n\nexport const updateConfig = (id: number, data: SystemConfigurationPayload) =>\n  apiClient.put<SystemConfiguration>(`/configurations/${id}`, data);\n\nexport const deleteConfig = (id: number) =>\n  apiClient.delete(`/configurations/${id}`);\n"
  },
  {
    "path": "apps/client/src/entities/configurations/codeService.ts",
    "content": "import type { SystemConfiguration } from \"./types\";\n\nexport const CODE_SERVICE_CONFIG_KEY = \"code_service_enabled\";\n\nexport function getCodeServiceEnabled(\n  configurations: SystemConfiguration[],\n): boolean {\n  const row = configurations.find((c) => c.key === CODE_SERVICE_CONFIG_KEY);\n  return row !== undefined && row.enabled === 1;\n}\n"
  },
  {
    "path": "apps/client/src/entities/configurations/store.ts",
    "content": "import { create } from \"zustand\";\nimport * as api from \"./api\";\nimport type { SystemConfiguration, SystemConfigurationPayload } from \"./types\";\n\ninterface ConfigState {\n  configurations: SystemConfiguration[];\n  isLoading: boolean;\n  error: string | null;\n  fetchConfigs: () => Promise<void>;\n  createConfig: (data: SystemConfigurationPayload) => Promise<void>;\n  updateConfig: (id: number, data: SystemConfigurationPayload) => Promise<void>;\n  deleteConfig: (id: number) => Promise<void>;\n}\n\nexport const useConfigStore = create<ConfigState>((set, get) => ({\n  configurations: [],\n  isLoading: false,\n  error: null,\n  fetchConfigs: async () => {\n    set({ isLoading: true, error: null });\n    try {\n      const response = await api.getConfigs();\n      set({ configurations: response.data, isLoading: false });\n    } catch (err) {\n      const errorMsg = err instanceof Error ? err.message : String(err);\n      set({\n        error: \"Failed to fetch configurations: \" + errorMsg,\n        isLoading: false,\n      });\n    }\n  },\n  createConfig: async (data) => {\n    await api.createConfig(data);\n    await get().fetchConfigs();\n  },\n  updateConfig: async (id, data) => {\n    await api.updateConfig(id, data);\n    await get().fetchConfigs();\n  },\n  deleteConfig: async (id) => {\n    await api.deleteConfig(id);\n    await get().fetchConfigs();\n  },\n}));\n"
  },
  {
    "path": "apps/client/src/entities/configurations/types.ts",
    "content": "export interface SystemConfiguration {\n  id: number;\n  key: string;\n  value: string;\n  enabled: number;\n  description: string | null;\n  created_at: string;\n  updated_at: string;\n}\n\nexport type SystemConfigurationPayload = Omit<\n  SystemConfiguration,\n  \"id\" | \"created_at\" | \"updated_at\"\n>;\n"
  },
  {
    "path": "apps/client/src/entities/custom-nodes/api.ts",
    "content": "import { apiClient } from \"@/shared/api\";\nimport { CustomNodeDynamicData, CustomNodeFromApi } from \"./types\";\n\nexport const getAllCustomNodes = async (): Promise<CustomNodeFromApi[]> => {\n  const response = await apiClient.get(\"/custom-nodes\");\n  return response.data;\n};\n\nexport const createCustomNode = async (node: {\n  node_type: string;\n  data: CustomNodeDynamicData;\n}): Promise<CustomNodeFromApi> => {\n  const response = await apiClient.post(\"/custom-nodes\", node);\n  return response.data;\n};\n\nexport const updateCustomNode = async (\n  node_type: string,\n  data: CustomNodeDynamicData,\n): Promise<CustomNodeFromApi> => {\n  const response = await apiClient.put(`/custom-nodes/${node_type}`, { data });\n  return response.data;\n};\n\nexport const deleteCustomNode = async (node_type: string): Promise<void> => {\n  await apiClient.delete(`/custom-nodes/${node_type}`);\n};\n"
  },
  {
    "path": "apps/client/src/entities/custom-nodes/presets.ts",
    "content": "export interface PresetConnector {\n  id: string;\n  name: string;\n  type: \"in\" | \"out\";\n}\n\nexport type PresetCategory =\n  | \"Math\"\n  | \"String\"\n  | \"Array\"\n  | \"Random\"\n  | \"Data Utility\"\n  | \"Counter\";\n\nexport interface RhaiPreset {\n  nodeId: string;\n  displayName: string;\n  description: string;\n  category: PresetCategory;\n  connectors: PresetConnector[];\n  scriptPath: string;\n}\n\nexport function presetToApiPayload(preset: RhaiPreset) {\n  return {\n    node_type: preset.nodeId,\n    data: {\n      connectors: preset.connectors,\n      data: { path: preset.scriptPath },\n      dataType: { path: \"FIXED_STRING\" },\n      nodeType: preset.nodeId,\n    },\n  };\n}\n\nexport const RHAI_PRESETS: RhaiPreset[] = [\n  // ============ Math ============\n  {\n    nodeId: \"_math_abs\",\n    displayName: \"Absolute Value\",\n    description: \"Returns the absolute value of a number\",\n    category: \"Math\",\n    connectors: [\n      { id: \"id\", name: \"number\", type: \"in\" },\n      { id: \"id\", name: \"number\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/math_abs.rhai\",\n  },\n  {\n    nodeId: \"_math_clamp\",\n    displayName: \"Clamp\",\n    description: \"Clamps a number between min and max\",\n    category: \"Math\",\n    connectors: [\n      { id: \"id\", name: \"number\", type: \"in\" },\n      { id: \"id\", name: \"min\", type: \"in\" },\n      { id: \"id\", name: \"max\", type: \"in\" },\n      { id: \"id\", name: \"number\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/math_clamp.rhai\",\n  },\n  {\n    nodeId: \"_math_round\",\n    displayName: \"Round\",\n    description: \"Rounds a number to the nearest integer\",\n    category: \"Math\",\n    connectors: [\n      { id: \"id\", name: \"number\", type: \"in\" },\n      { id: \"id\", name: \"number\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/math_round.rhai\",\n  },\n  {\n    nodeId: \"_math_power\",\n    displayName: \"Power\",\n    description: \"Raises base to the power of exponent\",\n    category: \"Math\",\n    connectors: [\n      { id: \"id\", name: \"base\", type: \"in\" },\n      { id: \"id\", name: \"exponent\", type: \"in\" },\n      { id: \"id\", name: \"number\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/math_power.rhai\",\n  },\n  {\n    nodeId: \"_math_sqrt\",\n    displayName: \"Square Root\",\n    description: \"Calculates the square root of a number\",\n    category: \"Math\",\n    connectors: [\n      { id: \"id\", name: \"number\", type: \"in\" },\n      { id: \"id\", name: \"number\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/math_sqrt.rhai\",\n  },\n  {\n    nodeId: \"_math_min\",\n    displayName: \"Minimum\",\n    description: \"Returns the smaller of two numbers\",\n    category: \"Math\",\n    connectors: [\n      { id: \"id\", name: \"a\", type: \"in\" },\n      { id: \"id\", name: \"b\", type: \"in\" },\n      { id: \"id\", name: \"number\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/math_min.rhai\",\n  },\n  {\n    nodeId: \"_math_max\",\n    displayName: \"Maximum\",\n    description: \"Returns the larger of two numbers\",\n    category: \"Math\",\n    connectors: [\n      { id: \"id\", name: \"a\", type: \"in\" },\n      { id: \"id\", name: \"b\", type: \"in\" },\n      { id: \"id\", name: \"number\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/math_max.rhai\",\n  },\n  {\n    nodeId: \"_math_map_range\",\n    displayName: \"Map Range\",\n    description: \"Maps a value from one range to another\",\n    category: \"Math\",\n    connectors: [\n      { id: \"id\", name: \"value\", type: \"in\" },\n      { id: \"id\", name: \"in_min\", type: \"in\" },\n      { id: \"id\", name: \"in_max\", type: \"in\" },\n      { id: \"id\", name: \"out_min\", type: \"in\" },\n      { id: \"id\", name: \"out_max\", type: \"in\" },\n      { id: \"id\", name: \"number\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/math_map_range.rhai\",\n  },\n\n  // ============ String ============\n  {\n    nodeId: \"_string_concat\",\n    displayName: \"Concatenate\",\n    description: \"Joins two strings together\",\n    category: \"String\",\n    connectors: [\n      { id: \"id\", name: \"a\", type: \"in\" },\n      { id: \"id\", name: \"b\", type: \"in\" },\n      { id: \"id\", name: \"string\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/string_concat.rhai\",\n  },\n  {\n    nodeId: \"_string_upper\",\n    displayName: \"To Uppercase\",\n    description: \"Converts string to uppercase\",\n    category: \"String\",\n    connectors: [\n      { id: \"id\", name: \"string\", type: \"in\" },\n      { id: \"id\", name: \"string\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/string_upper.rhai\",\n  },\n  {\n    nodeId: \"_string_lower\",\n    displayName: \"To Lowercase\",\n    description: \"Converts string to lowercase\",\n    category: \"String\",\n    connectors: [\n      { id: \"id\", name: \"string\", type: \"in\" },\n      { id: \"id\", name: \"string\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/string_lower.rhai\",\n  },\n  {\n    nodeId: \"_string_trim\",\n    displayName: \"Trim\",\n    description: \"Removes whitespace from both ends\",\n    category: \"String\",\n    connectors: [\n      { id: \"id\", name: \"string\", type: \"in\" },\n      { id: \"id\", name: \"string\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/string_trim.rhai\",\n  },\n  {\n    nodeId: \"_string_length\",\n    displayName: \"String Length\",\n    description: \"Returns the length of a string\",\n    category: \"String\",\n    connectors: [\n      { id: \"id\", name: \"string\", type: \"in\" },\n      { id: \"id\", name: \"number\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/string_length.rhai\",\n  },\n  {\n    nodeId: \"_string_contains\",\n    displayName: \"Contains\",\n    description: \"Checks if string contains a substring\",\n    category: \"String\",\n    connectors: [\n      { id: \"id\", name: \"string\", type: \"in\" },\n      { id: \"id\", name: \"search\", type: \"in\" },\n      { id: \"id\", name: \"bool\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/string_contains.rhai\",\n  },\n  {\n    nodeId: \"_string_replace\",\n    displayName: \"Replace\",\n    description: \"Replaces occurrences in a string\",\n    category: \"String\",\n    connectors: [\n      { id: \"id\", name: \"string\", type: \"in\" },\n      { id: \"id\", name: \"from\", type: \"in\" },\n      { id: \"id\", name: \"to\", type: \"in\" },\n      { id: \"id\", name: \"string\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/string_replace.rhai\",\n  },\n  {\n    nodeId: \"_string_split\",\n    displayName: \"Split\",\n    description: \"Splits string by delimiter into array\",\n    category: \"String\",\n    connectors: [\n      { id: \"id\", name: \"string\", type: \"in\" },\n      { id: \"id\", name: \"delimiter\", type: \"in\" },\n      { id: \"id\", name: \"array\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/string_split.rhai\",\n  },\n  {\n    nodeId: \"_string_substring\",\n    displayName: \"Substring\",\n    description: \"Extracts a portion of a string\",\n    category: \"String\",\n    connectors: [\n      { id: \"id\", name: \"string\", type: \"in\" },\n      { id: \"id\", name: \"start\", type: \"in\" },\n      { id: \"id\", name: \"length\", type: \"in\" },\n      { id: \"id\", name: \"string\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/string_substring.rhai\",\n  },\n  {\n    nodeId: \"_string_reverse\",\n    displayName: \"Reverse String\",\n    description: \"Reverses a string\",\n    category: \"String\",\n    connectors: [\n      { id: \"id\", name: \"string\", type: \"in\" },\n      { id: \"id\", name: \"string\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/string_reverse.rhai\",\n  },\n\n  // ============ Array ============\n  {\n    nodeId: \"_array_length\",\n    displayName: \"Array Length\",\n    description: \"Returns the length of an array\",\n    category: \"Array\",\n    connectors: [\n      { id: \"id\", name: \"array\", type: \"in\" },\n      { id: \"id\", name: \"number\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/array_length.rhai\",\n  },\n  {\n    nodeId: \"_array_push\",\n    displayName: \"Array Push\",\n    description: \"Appends an item to an array\",\n    category: \"Array\",\n    connectors: [\n      { id: \"id\", name: \"array\", type: \"in\" },\n      { id: \"id\", name: \"item\", type: \"in\" },\n      { id: \"id\", name: \"array\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/array_push.rhai\",\n  },\n  {\n    nodeId: \"_array_reverse\",\n    displayName: \"Array Reverse\",\n    description: \"Reverses an array in place\",\n    category: \"Array\",\n    connectors: [\n      { id: \"id\", name: \"array\", type: \"in\" },\n      { id: \"id\", name: \"array\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/array_reverse.rhai\",\n  },\n  {\n    nodeId: \"_array_join\",\n    displayName: \"Array Join\",\n    description: \"Joins array elements into a string\",\n    category: \"Array\",\n    connectors: [\n      { id: \"id\", name: \"array\", type: \"in\" },\n      { id: \"id\", name: \"delimiter\", type: \"in\" },\n      { id: \"id\", name: \"string\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/array_join.rhai\",\n  },\n  {\n    nodeId: \"_array_sort\",\n    displayName: \"Array Sort\",\n    description: \"Sorts an array in ascending order\",\n    category: \"Array\",\n    connectors: [\n      { id: \"id\", name: \"array\", type: \"in\" },\n      { id: \"id\", name: \"array\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/array_sort.rhai\",\n  },\n  {\n    nodeId: \"_array_sum\",\n    displayName: \"Array Sum\",\n    description: \"Sums all numeric elements in an array\",\n    category: \"Array\",\n    connectors: [\n      { id: \"id\", name: \"array\", type: \"in\" },\n      { id: \"id\", name: \"number\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/array_sum.rhai\",\n  },\n  {\n    nodeId: \"_array_average\",\n    displayName: \"Array Average\",\n    description: \"Calculates the average of array elements\",\n    category: \"Array\",\n    connectors: [\n      { id: \"id\", name: \"array\", type: \"in\" },\n      { id: \"id\", name: \"number\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/array_average.rhai\",\n  },\n\n  // ============ Random ============\n  {\n    nodeId: \"_random_number\",\n    displayName: \"Random Number\",\n    description: \"Generates a random number (input + random float)\",\n    category: \"Random\",\n    connectors: [{ id: \"id\", name: \"number\", type: \"out\" }],\n    scriptPath: \"{:code}/add_node.rhai\",\n  },\n  {\n    nodeId: \"_random_range\",\n    displayName: \"Random Range\",\n    description: \"Generates random number within a range\",\n    category: \"Random\",\n    connectors: [\n      { id: \"id\", name: \"min\", type: \"in\" },\n      { id: \"id\", name: \"max\", type: \"in\" },\n      { id: \"id\", name: \"number\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/random_range.rhai\",\n  },\n  {\n    nodeId: \"_random_bool\",\n    displayName: \"Random Boolean\",\n    description: \"Generates a random true/false value\",\n    category: \"Random\",\n    connectors: [{ id: \"id\", name: \"bool\", type: \"out\" }],\n    scriptPath: \"{:code}/random_bool.rhai\",\n  },\n  {\n    nodeId: \"_random_choice\",\n    displayName: \"Random Choice\",\n    description: \"Picks a random element from an array\",\n    category: \"Random\",\n    connectors: [\n      { id: \"id\", name: \"array\", type: \"in\" },\n      { id: \"id\", name: \"value\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/random_choice.rhai\",\n  },\n\n  // ============ Data Utility ============\n  {\n    nodeId: \"_passthrough\",\n    displayName: \"Passthrough\",\n    description: \"Passes input directly to output unchanged\",\n    category: \"Data Utility\",\n    connectors: [\n      { id: \"id\", name: \"value\", type: \"in\" },\n      { id: \"id\", name: \"value\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/passthrough.rhai\",\n  },\n  {\n    nodeId: \"_timestamp\",\n    displayName: \"Timestamp\",\n    description: \"Outputs the current epoch timestamp in milliseconds\",\n    category: \"Data Utility\",\n    connectors: [{ id: \"id\", name: \"number\", type: \"out\" }],\n    scriptPath: \"{:code}/timestamp.rhai\",\n  },\n  {\n    nodeId: \"_template\",\n    displayName: \"Template\",\n    description: \"Replaces {value} placeholder in a template string\",\n    category: \"Data Utility\",\n    connectors: [\n      { id: \"id\", name: \"template\", type: \"in\" },\n      { id: \"id\", name: \"value\", type: \"in\" },\n      { id: \"id\", name: \"string\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/template.rhai\",\n  },\n  {\n    nodeId: \"_to_json_string\",\n    displayName: \"To JSON String\",\n    description: \"Converts a value to its string representation\",\n    category: \"Data Utility\",\n    connectors: [\n      { id: \"id\", name: \"value\", type: \"in\" },\n      { id: \"id\", name: \"string\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/to_json_string.rhai\",\n  },\n  {\n    nodeId: \"_parse_json\",\n    displayName: \"Parse JSON\",\n    description: \"Parses a JSON string into an object\",\n    category: \"Data Utility\",\n    connectors: [\n      { id: \"id\", name: \"string\", type: \"in\" },\n      { id: \"id\", name: \"value\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/parse_json.rhai\",\n  },\n\n  // ============ Counter ============\n  {\n    nodeId: \"_counter\",\n    displayName: \"Counter\",\n    description: \"Increments current value by step\",\n    category: \"Counter\",\n    connectors: [\n      { id: \"id\", name: \"current\", type: \"in\" },\n      { id: \"id\", name: \"step\", type: \"in\" },\n      { id: \"id\", name: \"number\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/counter.rhai\",\n  },\n  {\n    nodeId: \"_accumulator\",\n    displayName: \"Accumulator\",\n    description: \"Adds value to a running total\",\n    category: \"Counter\",\n    connectors: [\n      { id: \"id\", name: \"total\", type: \"in\" },\n      { id: \"id\", name: \"value\", type: \"in\" },\n      { id: \"id\", name: \"number\", type: \"out\" },\n    ],\n    scriptPath: \"{:code}/accumulator.rhai\",\n  },\n];\n\nexport function getPresetCategories(): PresetCategory[] {\n  const categories = new Set<PresetCategory>();\n  for (const preset of RHAI_PRESETS) {\n    categories.add(preset.category);\n  }\n  return Array.from(categories);\n}\n\nexport function getPresetsByCategory(category: PresetCategory): RhaiPreset[] {\n  return RHAI_PRESETS.filter((p) => p.category === category);\n}\n"
  },
  {
    "path": "apps/client/src/entities/custom-nodes/store.ts",
    "content": "import { create } from \"zustand\";\nimport { CustomNodeState } from \"./types\";\nimport * as api from \"./api\";\n\n// const parseNodeData = (nodeFromApi: CustomNodeFromApi): CustomNodeFromApi => {\n//   try {\n//     return {\n//       ...nodeFromApi,\n//       data: nodeFromApi.data,\n//     };\n//   } catch (error) {\n//     console.error(\n//       `Failed to parse data for node ${nodeFromApi.node_type}`,\n//       error,\n//     );\n//     // Return a default/error state if parsing fails\n//     return {\n//       node_type: nodeFromApi.node_type,\n//       data: {},\n//     };\n//   }\n// };\n\nexport const useCustomNodeStore = create<CustomNodeState>((set) => ({\n  nodes: [],\n  isLoading: false,\n  error: null,\n  fetchAllNodes: async () => {\n    set({ isLoading: true, error: null });\n    try {\n      const nodesFromApi = await api.getAllCustomNodes();\n      set({ nodes: nodesFromApi, isLoading: false });\n    } catch {\n      set({ error: \"Failed to fetch nodes.\", isLoading: false });\n    }\n  },\n  createNode: async (node) => {\n    set({ isLoading: true, error: null });\n    try {\n      const newNodeFromApi = await api.createCustomNode(node);\n      set((state) => ({\n        nodes: [...state.nodes, newNodeFromApi],\n        isLoading: false,\n      }));\n    } catch {\n      set({ error: \"Failed to create node.\", isLoading: false });\n    }\n  },\n  updateNode: async (node_type, data) => {\n    set({ isLoading: true, error: null });\n    try {\n      const updatedNodeFromApi = await api.updateCustomNode(node_type, data);\n      set((state) => ({\n        nodes: state.nodes.map((n) =>\n          n.node_type === node_type ? updatedNodeFromApi : n,\n        ),\n        isLoading: false,\n      }));\n    } catch {\n      set({ error: \"Failed to update node.\", isLoading: false });\n    }\n  },\n  deleteNode: async (node_type) => {\n    set({ isLoading: true, error: null });\n    try {\n      await api.deleteCustomNode(node_type);\n      set((state) => ({\n        nodes: state.nodes.filter((n) => n.node_type !== node_type),\n        isLoading: false,\n      }));\n    } catch {\n      set({ error: \"Failed to delete node.\", isLoading: false });\n    }\n  },\n}));\n"
  },
  {
    "path": "apps/client/src/entities/custom-nodes/types.ts",
    "content": "export interface CustomNodeFromApi {\n  node_type: string;\n  data: CustomNodeDynamicData;\n}\n\nexport type CustomNodeDynamicData = Record<string, unknown>;\n\nexport interface CustomNodeData {\n  name: string;\n  description: string;\n  category: string;\n  script_path: string;\n  connectors: Connector[];\n  dataType?: Record<string, string>;\n}\n\nexport type CustomNode = CustomNodeFromApi;\n\nexport interface Connector {\n  type: \"in\" | \"out\";\n  name: string;\n  label: string;\n}\n\nexport interface CustomNodeState {\n  nodes: CustomNode[];\n  isLoading: boolean;\n  error: string | null;\n  fetchAllNodes: () => Promise<void>;\n  createNode: (node: {\n    node_type: string;\n    data: CustomNodeDynamicData;\n  }) => Promise<void>;\n  updateNode: (node_type: string, data: CustomNodeDynamicData) => Promise<void>;\n  deleteNode: (node_type: string) => Promise<void>;\n}\n"
  },
  {
    "path": "apps/client/src/entities/device/api.ts",
    "content": "import { apiClient } from \"@/shared/api\";\nimport type { Device, DevicePayload, DeviceWithEntity } from \"./types\";\n\nexport const getDevices = () => apiClient.get<Device[]>(\"/devices\");\nexport const getDeviceById = (pk_id: number) =>\n  apiClient.get<DeviceWithEntity>(\"/devices/id/\" + pk_id);\n\nexport const createDevice = (data: DevicePayload) =>\n  apiClient.post<Device>(\"/devices\", data);\nexport const updateDevice = (id: number, data: DevicePayload) =>\n  apiClient.put<Device>(`/devices/${id}`, data);\nexport const deleteDevice = (id: number) => apiClient.delete(`/devices/${id}`);\n"
  },
  {
    "path": "apps/client/src/entities/device/store.ts",
    "content": "import { create } from \"zustand\";\nimport * as api from \"./api\";\nimport type { Device, DevicePayload } from \"./types\";\n\ninterface DeviceState {\n  devices: Device[];\n  selectedDevice: Device | null;\n  isLoading: boolean;\n  error: string | null;\n  fetchDevices: () => Promise<void>;\n  createDevice: (data: DevicePayload) => Promise<void>;\n  updateDevice: (id: number, data: DevicePayload) => Promise<void>;\n  deleteDevice: (id: number) => Promise<void>;\n  selectDevice: (device: Device | null) => void;\n}\n\nexport const useDeviceStore = create<DeviceState>((set, get) => ({\n  devices: [],\n  selectedDevice: null,\n  isLoading: false,\n  error: null,\n  selectDevice: (device) => set({ selectedDevice: device }),\n  fetchDevices: async () => {\n    set({ isLoading: true, error: null });\n    try {\n      const response = await api.getDevices();\n      set({ devices: response.data, isLoading: false });\n    } catch (err) {\n      set({ error: \"Failed to fetch devices\", isLoading: false });\n      console.error(err);\n    }\n  },\n  createDevice: async (data) => {\n    await api.createDevice(data);\n    await get().fetchDevices();\n  },\n  updateDevice: async (id, data) => {\n    await api.updateDevice(id, data);\n    await get().fetchDevices();\n  },\n  deleteDevice: async (id) => {\n    await api.deleteDevice(id);\n    if (get().selectedDevice?.id === id) {\n      set({ selectedDevice: null });\n    }\n    await get().fetchDevices();\n  },\n}));\n"
  },
  {
    "path": "apps/client/src/entities/device/types.ts",
    "content": "import { EntityAll } from \"../entity/types\";\n\nexport interface Device {\n  id: number;\n  device_id: string;\n  name: string | null;\n  manufacturer: string | null;\n  model: string | null;\n}\n\nexport interface DeviceWithEntity {\n  id: number;\n  device_id: string;\n  name: string | null;\n  manufacturer: string | null;\n  model: string | null;\n  entities: EntityAll[];\n}\n\nexport type DevicePayload = Omit<Device, \"id\">;\n"
  },
  {
    "path": "apps/client/src/entities/device-token/api.ts",
    "content": "import { apiClient } from \"@/shared/api\";\nimport type { DeviceToken, IssuedTokenResponse } from \"./types\";\n\nexport const issueDeviceToken = (deviceId: number) =>\n  apiClient.post<IssuedTokenResponse>(`/devices/${deviceId}/token`);\n\nexport const getDeviceTokenInfo = (deviceId: number) =>\n  apiClient.get<DeviceToken | { message: string }>(\n    `/devices/${deviceId}/token`,\n  );\n\nexport const revokeDeviceToken = (deviceId: number) =>\n  apiClient.delete(`/devices/${deviceId}/token`);\n"
  },
  {
    "path": "apps/client/src/entities/device-token/store.ts",
    "content": "import { create } from \"zustand\";\nimport * as api from \"./api\";\nimport type { DeviceToken } from \"./types\";\n\ninterface DeviceTokenState {\n  tokenInfo: DeviceToken | null;\n  newToken: string | null;\n  isLoading: boolean;\n  error: string | null;\n  fetchTokenInfo: (deviceId: number) => Promise<void>;\n  issueToken: (deviceId: number) => Promise<void>;\n  revokeToken: (deviceId: number) => Promise<void>;\n  clearNewToken: () => void;\n}\n\nexport const useDeviceTokenStore = create<DeviceTokenState>((set, get) => ({\n  tokenInfo: null,\n  newToken: null,\n  isLoading: false,\n  error: null,\n\n  fetchTokenInfo: async (deviceId) => {\n    set({ isLoading: true, error: null });\n    try {\n      const response = await api.getDeviceTokenInfo(deviceId);\n      if (response.data && \"id\" in response.data) {\n        set({ tokenInfo: response.data, isLoading: false });\n      } else {\n        set({ tokenInfo: null, isLoading: false });\n      }\n    } catch (err) {\n      console.error(\"Failed to fetch token info:\", err);\n      set({ tokenInfo: null, isLoading: false });\n    }\n  },\n  issueToken: async (deviceId) => {\n    set({ isLoading: true, error: null });\n    try {\n      const response = await api.issueDeviceToken(deviceId);\n      set({ newToken: response.data.token, isLoading: false });\n      await get().fetchTokenInfo(deviceId);\n    } catch (err) {\n      const errorMsg = err instanceof Error ? err.message : String(err);\n      set({ error: \"Failed to issue token: \" + errorMsg, isLoading: false });\n    }\n  },\n  revokeToken: async (deviceId) => {\n    set({ isLoading: true, error: null });\n    try {\n      await api.revokeDeviceToken(deviceId);\n      set({ tokenInfo: null, isLoading: false });\n    } catch (err) {\n      const errorMsg = err instanceof Error ? err.message : String(err);\n      set({ error: \"Failed to revoke token: \" + errorMsg, isLoading: false });\n    }\n  },\n  clearNewToken: () => {\n    set({ newToken: null });\n  },\n}));\n"
  },
  {
    "path": "apps/client/src/entities/device-token/types.ts",
    "content": "export interface DeviceToken {\n  id: number;\n  device_id: number;\n  expires_at: string | null;\n  last_used_at: string | null;\n  created_at: string;\n}\n\nexport interface IssuedTokenResponse {\n  message: string;\n  token: string;\n}\n"
  },
  {
    "path": "apps/client/src/entities/dynamic-dashboard/api.ts",
    "content": "import { apiClient } from \"@/shared/api\";\n\nexport type DynamicDashboardDto = {\n  id: string;\n  name: string;\n  layout: unknown;\n  created_at: string;\n  updated_at: string;\n};\n\nexport const listDashboards = () =>\n  apiClient.get<DynamicDashboardDto[]>(\"/dynamic-dashboards\");\n\nexport const getDashboard = (id: string) =>\n  apiClient.get<DynamicDashboardDto>(`/dynamic-dashboards/${id}`);\n\nexport const createDashboard = (payload: {\n  name: string;\n  layout: unknown;\n}) => apiClient.post<DynamicDashboardDto>(\"/dynamic-dashboards\", payload);\n\nexport const updateDashboard = (\n  id: string,\n  payload: { name?: string; layout?: unknown },\n) => apiClient.put<DynamicDashboardDto>(`/dynamic-dashboards/${id}`, payload);\n\nexport const deleteDashboard = (id: string) =>\n  apiClient.delete(`/dynamic-dashboards/${id}`);\n"
  },
  {
    "path": "apps/client/src/entities/dynamic-dashboard/interaction.ts",
    "content": "/** Contract version for dashboard → flow WebSocket payloads (listener id + broadcast bus). */\nexport const DASHBOARD_COMPONENT_EVENT_VERSION = 2 as const;\n\nexport const MAX_LISTENER_ID_LENGTH = 128;\n\n/** Match server: alphanumeric, underscore, hyphen only. */\nexport function isValidListenerId(id: string): boolean {\n  const t = id.trim();\n  if (!t || t.length > MAX_LISTENER_ID_LENGTH) return false;\n  return /^[a-zA-Z0-9_-]+$/.test(t);\n}\n\nexport type DashboardComponentType = \"button\" | string;\n\nexport type DashboardComponentAction = \"click\" | \"change\" | \"submit\" | string;\n\n/**\n * Client → server: user interaction; server publishes to `dashboard_ui` for\n * `DASHBOARD_EVENT_LISTENER` nodes in **running** flows.\n */\nexport type DashboardComponentEventPayload = {\n  event_version: typeof DASHBOARD_COMPONENT_EVENT_VERSION;\n  event_id: string;\n  occurred_at: string;\n  listener_id: string;\n  dashboard_id: string;\n  group_id: string;\n  item_id: string;\n  component_type: DashboardComponentType;\n  action: DashboardComponentAction;\n  value?: unknown;\n  source_session_id: string;\n  cooldown_ms?: number;\n};\n"
  },
  {
    "path": "apps/client/src/entities/dynamic-dashboard/layoutResolve.ts",
    "content": "import type { DashboardGroup, DashboardItem } from \"./store\";\n\n/** Clamp top-left grid position to group bounds (matches store behavior). */\nexport function clampItemPosition(\n  group: DashboardGroup,\n  size: { w: number; h: number },\n  position: { x: number; y: number },\n): { x: number; y: number } {\n  return {\n    x: Math.min(Math.max(0, position.x), Math.max(0, group.cols - size.w)),\n    y: Math.min(Math.max(0, position.y), Math.max(0, group.rows - size.h)),\n  };\n}\n\nexport function itemsCollide(\n  a: Pick<DashboardItem, \"position\" | \"size\">,\n  b: Pick<DashboardItem, \"position\" | \"size\">,\n): boolean {\n  return (\n    a.position.x < b.position.x + b.size.w &&\n    a.position.x + a.size.w > b.position.x &&\n    a.position.y < b.position.y + b.size.h &&\n    a.position.y + a.size.h > b.position.y\n  );\n}\n\n/**\n * Pointer/grid intent → clamped position if it does not overlap other items.\n * Returns null if the move is blocked by collision (keep previous preview).\n */\nexport function resolveItemPositionOrNull(\n  group: DashboardGroup,\n  movingItem: DashboardItem,\n  desiredPosition: { x: number; y: number },\n): { x: number; y: number } | null {\n  const nextPos = clampItemPosition(group, movingItem.size, desiredPosition);\n  const candidate = { ...movingItem, position: nextPos };\n  const collides = group.items.some(\n    (other) => other.id !== movingItem.id && itemsCollide(candidate, other),\n  );\n  if (collides) return null;\n  return nextPos;\n}\n"
  },
  {
    "path": "apps/client/src/entities/dynamic-dashboard/store.ts",
    "content": "import { create } from \"zustand\";\nimport * as api from \"./api\";\nimport { clampItemPosition, itemsCollide } from \"./layoutResolve\";\n\nexport type DashboardItemType =\n  | \"entity-card\"\n  | \"entity-text\"\n  | \"media\"\n  | \"button\"\n  | \"map\"\n  | \"flow\";\n\nexport type DashboardItemDataMap = {\n  \"entity-card\": Record<string, never>;\n  \"entity-text\": Record<string, never>;\n  media: Record<string, never>;\n  button: {\n    /** Matches `listenerId` on flow `DASHBOARD_EVENT_LISTENER` node. */\n    listener_id?: string;\n    debounce_ms?: number;\n    cooldown_ms?: number;\n    /** @deprecated Ignored; use `listener_id`. */\n    action?: string;\n  };\n  map: {\n    layerId?: number;\n    center?: [number, number];\n    zoom?: number;\n  };\n  flow: {\n    flowId?: number;\n    autoRun?: boolean;\n  };\n};\n\nexport type DashboardItem<T extends DashboardItemType = DashboardItemType> = {\n  id: string;\n  type: T;\n  label?: string;\n  refId?: string;\n  position: { x: number; y: number };\n  size: { w: number; h: number };\n  minSize: { w: number; h: number };\n  data?: DashboardItemDataMap[T];\n};\n\nexport type DashboardGroup = {\n  id: string;\n  title: string;\n  cols: number;\n  rows: number;\n  items: DashboardItem[];\n};\n\nexport type DynamicDashboard = {\n  id: string;\n  name: string;\n  groups: DashboardGroup[];\n};\n\ntype LayoutPayload = {\n  position?: { x: number; y: number };\n  size?: { w: number; h: number };\n};\n\ntype CreateItemPayload = {\n  type: DashboardItemType;\n  label?: string;\n  refId?: string;\n  data?: DashboardItemDataMap[DashboardItemType];\n  size?: { w: number; h: number };\n  minSize?: { w: number; h: number };\n};\n\nexport interface DynamicDashboardState {\n  dashboards: DynamicDashboard[];\n  activeDashboardId?: string;\n  isLoading: boolean;\n  hasLoaded: boolean;\n  error?: string | null;\n  loadDashboards: () => Promise<void>;\n  createDashboard: (name?: string) => Promise<string | null>;\n  cloneDashboard: (dashboardId: string) => Promise<string | null>;\n  deleteDashboard: (dashboardId: string) => Promise<void>;\n  setActiveDashboard: (dashboardId?: string) => void;\n  updateDashboardMeta: (\n    dashboardId: string,\n    data: Partial<Pick<DynamicDashboard, \"name\">>,\n  ) => void;\n  addItem: (\n    dashboardId: string,\n    groupId: string,\n    payload: CreateItemPayload,\n  ) => string | null;\n  updateItemLayout: (\n    dashboardId: string,\n    groupId: string,\n    itemId: string,\n    payload: LayoutPayload,\n  ) => boolean;\n  updateItemData: (\n    dashboardId: string,\n    groupId: string,\n    itemId: string,\n    payload: Partial<\n      Pick<DashboardItem, \"label\" | \"refId\" | \"data\" | \"minSize\" | \"type\">\n    >,\n  ) => void;\n  deleteItem: (dashboardId: string, groupId: string, itemId: string) => void;\n}\n\nconst saveTimers = new Map<string, ReturnType<typeof setTimeout>>();\n\nconst DEFAULT_ITEM_SIZES: Record<DashboardItemType, { w: number; h: number }> =\n  {\n    \"entity-card\": { w: 4, h: 3 },\n    \"entity-text\": { w: 3, h: 2 },\n    media: { w: 6, h: 4 },\n    button: { w: 2, h: 2 },\n    map: { w: 8, h: 6 },\n    flow: { w: 6, h: 4 },\n  };\n\nconst DEFAULT_ITEM_DATA = {\n  \"entity-card\": {},\n  \"entity-text\": {},\n  media: {},\n  button: {},\n  map: {},\n  flow: {},\n} satisfies Record<DashboardItemType, DashboardItemDataMap[DashboardItemType]>;\n\nconst createId = () =>\n  typeof crypto !== \"undefined\" && \"randomUUID\" in crypto\n    ? crypto.randomUUID()\n    : Math.random().toString(36).slice(2);\n\nconst clampSizeToGroup = (\n  group: DashboardGroup,\n  size: { w: number; h: number },\n  minSize: { w: number; h: number },\n) => {\n  return {\n    w: Math.min(group.cols, Math.max(size.w, minSize.w)),\n    h: Math.min(group.rows, Math.max(size.h, minSize.h)),\n  };\n};\n\nconst findOpenSlot = (\n  group: DashboardGroup,\n  size: { w: number; h: number },\n): { x: number; y: number } => {\n  for (let y = 0; y <= group.rows - size.h; y++) {\n    for (let x = 0; x <= group.cols - size.w; x++) {\n      const candidate: DashboardItem = {\n        id: \"preview\",\n        type: \"entity-card\",\n        position: { x, y },\n        size,\n        minSize: size,\n      };\n\n      const collides = group.items.some((item) => itemsCollide(candidate, item));\n      if (!collides) {\n        return { x, y };\n      }\n    }\n  }\n\n  return { x: 0, y: 0 };\n};\n\n/** Merges multiple layout groups into one canvas (first group's grid size; items from other groups re-placed). */\nconst mergeGroupsIntoOne = (groups: DashboardGroup[]): DashboardGroup => {\n  if (groups.length === 0) {\n    return createDefaultGroup(\"Main\");\n  }\n  if (groups.length === 1) {\n    return groups[0];\n  }\n\n  const primary: DashboardGroup = {\n    ...groups[0],\n    items: groups[0].items.map((item) => ({ ...item })),\n  };\n\n  const seenIds = new Set(primary.items.map((i) => i.id));\n\n  for (let gi = 1; gi < groups.length; gi++) {\n    for (const item of groups[gi].items) {\n      let id = item.id;\n      if (seenIds.has(id)) {\n        id = createId();\n      }\n      seenIds.add(id);\n\n      const size = clampSizeToGroup(primary, item.size, item.minSize);\n      const position = findOpenSlot(primary, size);\n      const clampedPos = clampItemPosition(primary, size, position);\n      const nextItem: DashboardItem = {\n        ...item,\n        id,\n        size,\n        position: clampedPos,\n      };\n      primary.items = [...primary.items, nextItem];\n    }\n  }\n\n  return primary;\n};\n\nconst createDefaultGroup = (title: string): DashboardGroup => ({\n  id: createId(),\n  title,\n  cols: 16,\n  rows: 12,\n  items: [],\n});\n\nconst createDefaultDashboard = (name: string): DynamicDashboard => ({\n  id: createId(),\n  name,\n  groups: [createDefaultGroup(\"Main Group\")],\n});\n\nexport const useDynamicDashboardStore = create<DynamicDashboardState>()(\n  (set, get) => {\n    const scheduleSync = (dashboardId: string, includeName?: boolean) => {\n      const dashboard = get().dashboards.find((d) => d.id === dashboardId);\n      if (!dashboard) return;\n\n      if (saveTimers.has(dashboardId)) {\n        clearTimeout(saveTimers.get(dashboardId));\n      }\n\n      const layoutPayload = { groups: dashboard.groups };\n\n      const timer = setTimeout(async () => {\n        try {\n          await api.updateDashboard(dashboardId, {\n            layout: layoutPayload,\n            name: includeName ? dashboard.name : undefined,\n          });\n        } catch (err) {\n          console.error(\"Failed to sync dashboard layout\", err);\n        }\n      }, 500);\n\n      saveTimers.set(dashboardId, timer);\n    };\n\n    const isDashboardGroup = (value: unknown): value is DashboardGroup => {\n      if (!value || typeof value !== \"object\") return false;\n      const candidate = value as Partial<DashboardGroup>;\n      return (\n        typeof candidate.id === \"string\" &&\n        typeof candidate.title === \"string\" &&\n        typeof candidate.cols === \"number\" &&\n        typeof candidate.rows === \"number\" &&\n        Array.isArray(candidate.items)\n      );\n    };\n\n    const parseLayoutGroups = (layout: unknown): DashboardGroup[] => {\n      if (\n        layout &&\n        typeof layout === \"object\" &&\n        Array.isArray((layout as { groups?: unknown }).groups)\n      ) {\n        const rawGroups = (layout as { groups: unknown }).groups as unknown[];\n        return rawGroups.filter(isDashboardGroup) as DashboardGroup[];\n      }\n      return [];\n    };\n\n    const mapDtoToDashboard = (\n      dto: api.DynamicDashboardDto,\n    ): DynamicDashboard => {\n      const raw = parseLayoutGroups(dto.layout);\n      let groups: DashboardGroup[];\n      if (raw.length === 0) {\n        groups = [createDefaultGroup(\"Main\")];\n      } else if (raw.length === 1) {\n        groups = raw;\n      } else {\n        groups = [mergeGroupsIntoOne(raw)];\n      }\n\n      return {\n        id: dto.id,\n        name: dto.name,\n        groups,\n      };\n    };\n\n    return {\n      dashboards: [],\n      activeDashboardId: undefined,\n      isLoading: false,\n      hasLoaded: false,\n      error: null,\n      loadDashboards: async () => {\n        set({ isLoading: true, error: null });\n        try {\n          const res = await api.listDashboards();\n          const dashboards = res.data.map(mapDtoToDashboard);\n          set((state) => ({\n            dashboards,\n            hasLoaded: true,\n            isLoading: false,\n            activeDashboardId: state.activeDashboardId || dashboards[0]?.id,\n          }));\n        } catch (err) {\n          console.error(\"Failed to load dashboards\", err);\n          set({ error: \"Failed to load dashboards\", isLoading: false, hasLoaded: true });\n        }\n      },\n      createDashboard: async (name) => {\n        const newDashboard = createDefaultDashboard(\n          name || `Dynamic Dashboard ${get().dashboards.length + 1}`,\n        );\n        try {\n          const res = await api.createDashboard({\n            name: newDashboard.name,\n            layout: { groups: newDashboard.groups },\n          });\n          const created = mapDtoToDashboard(res.data);\n          set((state) => ({\n            dashboards: [...state.dashboards, created],\n            activeDashboardId: created.id,\n          }));\n          return created.id;\n        } catch (err) {\n          console.error(\"Failed to create dashboard\", err);\n          set({ error: \"Failed to create dashboard\" });\n          return null;\n        }\n      },\n      cloneDashboard: async (dashboardId) => {\n        const target = get().dashboards.find((d) => d.id === dashboardId);\n        if (!target) {\n          return null;\n        }\n\n        const clonedGroups = target.groups.map((g) => ({\n          ...g,\n          id: createId(),\n          items: g.items.map((i) => ({ ...i, id: createId() })),\n        }));\n\n        const merged =\n          clonedGroups.length <= 1\n            ? clonedGroups[0] ?? createDefaultGroup(\"Main\")\n            : mergeGroupsIntoOne(clonedGroups);\n\n        const cloned: DynamicDashboard = {\n          ...target,\n          id: createId(),\n          name: `${target.name} (copy)`,\n          groups: [merged],\n        };\n\n        try {\n          const res = await api.createDashboard({\n            name: cloned.name,\n            layout: { groups: cloned.groups },\n          });\n          const created = mapDtoToDashboard(res.data);\n          set((state) => ({\n            dashboards: [...state.dashboards, created],\n            activeDashboardId: created.id,\n          }));\n          return created.id;\n        } catch (err) {\n          console.error(\"Failed to clone dashboard\", err);\n          set({ error: \"Failed to clone dashboard\" });\n          return null;\n        }\n      },\n      deleteDashboard: async (dashboardId) => {\n        try {\n          await api.deleteDashboard(dashboardId);\n        } catch (err) {\n          console.error(\"Failed to delete dashboard\", err);\n          set({ error: \"Failed to delete dashboard\" });\n        }\n\n        set((state) => {\n          const filtered = state.dashboards.filter((d) => d.id !== dashboardId);\n          const activeDashboardId =\n            state.activeDashboardId === dashboardId\n              ? filtered[0]?.id\n              : state.activeDashboardId;\n          return { dashboards: filtered, activeDashboardId };\n        });\n      },\n      setActiveDashboard: (dashboardId) => {\n        set({ activeDashboardId: dashboardId });\n      },\n      updateDashboardMeta: (dashboardId, data) => {\n        set((state) => ({\n          dashboards: state.dashboards.map((d) =>\n            d.id === dashboardId ? { ...d, ...data } : d,\n          ),\n        }));\n        scheduleSync(dashboardId, true);\n      },\n      addItem: (dashboardId, groupId, payload) => {\n        let createdId: string | null = null;\n\n        set((state) => {\n          const dashboards = state.dashboards.map((d) => {\n            if (d.id !== dashboardId) {\n              return d;\n            }\n\n            const groups = d.groups.map((g) => {\n              if (g.id !== groupId) {\n                return g;\n              }\n\n              const baseSize =\n                payload.size || DEFAULT_ITEM_SIZES[payload.type] || { w: 3, h: 2 };\n              const minSize =\n                payload.minSize || DEFAULT_ITEM_SIZES[payload.type] || baseSize;\n              const size = clampSizeToGroup(g, baseSize, minSize);\n              const position = findOpenSlot(g, size);\n              const data = payload.data ?? { ...DEFAULT_ITEM_DATA[payload.type] };\n              const newItem: DashboardItem = {\n                id: createId(),\n                type: payload.type,\n                label: payload.label,\n                refId: payload.refId,\n                position,\n                size,\n                minSize,\n                data,\n              };\n\n              createdId = newItem.id;\n              return { ...g, items: [...g.items, newItem] };\n            });\n\n            return { ...d, groups };\n          });\n\n          return { ...state, dashboards };\n        });\n        scheduleSync(dashboardId);\n\n        return createdId;\n      },\n      updateItemLayout: (dashboardId, groupId, itemId, payload) => {\n        let updated = false;\n\n        set((state) => {\n          const dashboards = state.dashboards.map((d) => {\n            if (d.id !== dashboardId) {\n              return d;\n            }\n\n            const groups = d.groups.map((g) => {\n              if (g.id !== groupId) {\n                return g;\n              }\n\n              const items = g.items.map((item) => {\n                if (item.id !== itemId) {\n                  return item;\n                }\n\n                const nextSize = payload.size\n                  ? clampSizeToGroup(g, payload.size, item.minSize)\n                  : item.size;\n                const nextPos = clampItemPosition(\n                  g,\n                  nextSize,\n                  payload.position || item.position,\n                );\n\n                const candidate = { ...item, size: nextSize, position: nextPos };\n                const collision = g.items.some(\n                  (other) =>\n                    other.id !== itemId && itemsCollide(candidate, other),\n                );\n\n                if (collision) {\n                  return item;\n                }\n\n                updated = true;\n                return candidate;\n              });\n\n              return { ...g, items };\n            });\n\n            return { ...d, groups };\n          });\n\n          return updated ? { ...state, dashboards } : state;\n        });\n\n        if (updated) {\n          scheduleSync(dashboardId);\n        }\n\n        return updated;\n      },\n      updateItemData: (dashboardId, groupId, itemId, payload) => {\n        set((state) => ({\n          dashboards: state.dashboards.map((d) => {\n            if (d.id !== dashboardId) {\n              return d;\n            }\n\n            const groups = d.groups.map((g) => {\n              if (g.id !== groupId) {\n                return g;\n              }\n\n              const items = g.items.map((item) =>\n                item.id === itemId ? { ...item, ...payload } : item,\n              );\n\n              return { ...g, items };\n            });\n\n            return { ...d, groups };\n          }),\n        }));\n        scheduleSync(dashboardId);\n      },\n      deleteItem: (dashboardId, groupId, itemId) => {\n        set((state) => ({\n          dashboards: state.dashboards.map((d) => {\n            if (d.id !== dashboardId) {\n              return d;\n            }\n\n            const groups = d.groups.map((g) =>\n              g.id === groupId\n                ? { ...g, items: g.items.filter((item) => item.id !== itemId) }\n                : g,\n            );\n\n            return { ...d, groups };\n          }),\n        }));\n        scheduleSync(dashboardId);\n      },\n    };\n  },\n);\n"
  },
  {
    "path": "apps/client/src/entities/entity/api.ts",
    "content": "import { apiClient } from \"@/shared/api\";\nimport type { Entity, EntityAll, EntityPayload, State } from \"./types\";\n\nexport const getEntities = () => apiClient.get<Entity[]>(\"/entities\");\nexport const getAllEntities = () => apiClient.get<EntityAll[]>(\"/entities/all\");\nexport const getEntityHistory = (entityId: string) =>\n  apiClient.get<State[]>(\n    `/entities/${encodeURIComponent(entityId)}/history`,\n  );\nexport const getEntitiesFilter = (entityType?: string) => {\n  return apiClient.get<Entity[]>(\"/entities\", {\n    params: {\n      entity_type: entityType,\n    },\n  });\n};\nexport const getAllEntitiesFilter = (entityType?: string) => {\n  return apiClient.get<EntityAll[]>(\"/entities/all\", {\n    params: {\n      entity_type: entityType,\n    },\n  });\n};\n\nexport const createEntity = (data: EntityPayload) =>\n  apiClient.post<Entity>(\"/entities\", data);\n\nexport const updateEntity = (id: number, data: EntityPayload) =>\n  apiClient.put<Entity>(`/entities/${id}`, data);\n\nexport const deleteEntity = (id: number) => apiClient.delete(`/entities/${id}`);\n"
  },
  {
    "path": "apps/client/src/entities/entity/store.ts",
    "content": "import { create } from \"zustand\";\nimport * as api from \"./api\";\nimport type { Entity, EntityPayload } from \"./types\";\n\ninterface EntityState {\n  entities: Entity[];\n  isLoading: boolean;\n  error: string | null;\n  autoOpenEntityId: number | null;\n  fetchEntities: () => Promise<void>;\n  createEntity: (data: EntityPayload) => Promise<void>;\n  updateEntity: (id: number, data: EntityPayload) => Promise<void>;\n  deleteEntity: (id: number) => Promise<void>;\n  setAutoOpenEntityId: (id: number | null) => void;\n}\n\nexport const useEntityStore = create<EntityState>((set, get) => ({\n  entities: [],\n  isLoading: false,\n  error: null,\n  autoOpenEntityId: null,\n  setAutoOpenEntityId: (id) => set({ autoOpenEntityId: id }),\n  fetchEntities: async () => {\n    set({ isLoading: true, error: null });\n    try {\n      const response = await api.getEntities();\n      set({ entities: response.data, isLoading: false });\n    } catch (err) {\n      set({ error: \"Failed to fetch entities\" + err, isLoading: false });\n    }\n  },\n  createEntity: async (data) => {\n    await api.createEntity(data);\n    await get().fetchEntities();\n  },\n  updateEntity: async (id, data) => {\n    await api.updateEntity(id, data);\n    await get().fetchEntities();\n  },\n  deleteEntity: async (id) => {\n    await api.deleteEntity(id);\n    await get().fetchEntities();\n  },\n}));\n"
  },
  {
    "path": "apps/client/src/entities/entity/types.ts",
    "content": "export interface Entity {\n  id: number;\n  entity_id: string;\n  device_id: number | null;\n  friendly_name: string | null;\n  platform: string | null;\n  configuration: string | null;\n  entity_type: string | null;\n}\n\ninterface DynamicConfig {\n  [key: string]: unknown;\n}\n\nexport interface State {\n  state_id: number;\n  metadata_id: number;\n  state: string;\n  attributes: string | null;\n  last_changed: string;\n  last_updated: string;\n  created: string;\n}\n\nexport interface EntityAll {\n  id: number;\n  entity_id: string;\n  device_id: number;\n  friendly_name: string;\n  platform: string;\n  configuration: DynamicConfig | null;\n  state: State | null;\n  entity_type: string | null;\n}\n\nexport type EntityPayload = Omit<Entity, \"id\">;\n"
  },
  {
    "path": "apps/client/src/entities/file/api.ts",
    "content": "import { DirEntry } from \"./types\";\nimport { apiClient } from \"@/shared/api\";\n\nexport const getDirectoryListing = async (\n  path: string,\n): Promise<DirEntry[]> => {\n  const response = await apiClient.get(`/storage/${path}`);\n\n  const entries: { path: string; entries: { name: string; isDir: boolean }[] } =\n    response.data;\n\n  return entries.entries.map((entry) => ({\n    ...entry,\n    path: path ? `${path}/${entry.name}` : entry.name,\n  }));\n};\n\nexport const getFileContent = async (path: string): Promise<string> => {\n  const response = await apiClient.get(`/storage/${path}`);\n  return response.data;\n};\n\nexport const updateFileContent = async (\n  path: string,\n  content: string,\n): Promise<void> => {\n  await apiClient.put(`/storage/${path}`, { content });\n};\n\nexport const createNewFolder = async (path: string): Promise<void> => {\n  await apiClient.post(`/storage/mkdir/${path}`);\n};\n\nexport const createNewFile = async (path: string): Promise<void> => {\n  await apiClient.put(`/storage/${path}`, { content: \"\" });\n};\nexport const renameEntry = async (\n  oldPath: string,\n  newPath: string,\n): Promise<void> => {\n  await apiClient.post(`/storage/rename/${oldPath}`, { to: newPath });\n};\n\nexport const deleteEntry = async (path: string): Promise<void> => {\n  await apiClient.delete(`/storage/${path}`);\n};\n"
  },
  {
    "path": "apps/client/src/entities/file/store.ts",
    "content": "import { create } from \"zustand\";\nimport { IdeState } from \"./types\";\nimport { getFileContent, updateFileContent } from \"./api\";\nimport { toast } from \"sonner\";\n\nexport interface FileTreeState {\n  treeVersion: number;\n  refreshFileTree: () => void;\n}\n\nexport const useFileTreeStore = create<FileTreeState>((set) => ({\n  treeVersion: 0,\n  refreshFileTree: () =>\n    set((state) => ({ treeVersion: state.treeVersion + 1 })),\n}));\n\nexport const useIdeStore = create<IdeState>((set, get) => ({\n  activeFilePath: null,\n  activeFileContent: \"\",\n  isDirty: false,\n  isLoading: false,\n  error: null,\n\n  openFile: async (path) => {\n    if (get().isDirty) {\n      if (\n        !window.confirm(\n          \"You have unsaved changes. Are you sure you want to open a new file?\",\n        )\n      ) {\n        return;\n      }\n    }\n    set({ isLoading: true, error: null });\n    try {\n      const content = await getFileContent(path);\n      set({\n        activeFilePath: path,\n        activeFileContent: content,\n        isDirty: false,\n        isLoading: false,\n      });\n    } catch (err) {\n      const errorMsg = \"Failed to load file content.\" + err;\n      set({ error: errorMsg, isLoading: false });\n      toast.error(errorMsg);\n    }\n  },\n\n  updateActiveFileContent: (content) => {\n    set({ activeFileContent: content, isDirty: true });\n  },\n\n  saveActiveFile: async () => {\n    const { activeFilePath, activeFileContent } = get();\n    if (!activeFilePath) return;\n\n    set({ isLoading: true });\n    try {\n      await updateFileContent(activeFilePath, activeFileContent);\n      set({ isDirty: false, isLoading: false });\n      toast.success(\"File saved successfully!\");\n    } catch (err) {\n      const errorMsg = \"Failed to save file.\" + err;\n      set({ error: errorMsg, isLoading: false });\n      toast.error(errorMsg);\n    }\n  },\n\n  closeFile: () => {\n    if (get().isDirty) {\n      if (\n        !window.confirm(\n          \"You have unsaved changes. Are you sure you want to close?\",\n        )\n      ) {\n        return;\n      }\n    }\n    set({ activeFilePath: null, activeFileContent: \"\", isDirty: false });\n  },\n}));\n"
  },
  {
    "path": "apps/client/src/entities/file/types.ts",
    "content": "export interface DirEntry {\n  name: string;\n  isDir: boolean;\n  path: string;\n}\n\nexport interface IdeState {\n  activeFilePath: string | null;\n  activeFileContent: string;\n  isDirty: boolean;\n  isLoading: boolean;\n  error: string | null;\n  openFile: (path: string) => Promise<void>;\n  updateActiveFileContent: (content: string) => void;\n  saveActiveFile: () => Promise<void>;\n  closeFile: () => void;\n}\n"
  },
  {
    "path": "apps/client/src/entities/flow/api.ts",
    "content": "import { apiClient } from \"@/shared/api\";\nimport { Flow, FlowPayload, FlowVersion, FlowVersionPayload } from \"./types\";\n\nexport const getFlows = async (): Promise<Flow[]> => {\n  const { data } = await apiClient.get<Flow[]>(\"/flows\");\n  return data;\n};\n\nexport const createFlow = async (payload: FlowPayload): Promise<Flow> => {\n  const { data } = await apiClient.post<Flow>(\"/flows\", payload);\n  return data;\n};\n\nexport const deleteFlow = async (id: number): Promise<Flow> => {\n  const { data } = await apiClient.delete<Flow>(`/flows/${id}`);\n  return data;\n};\n\nexport const saveFlowVersion = async (\n  flowId: number,\n  payload: FlowVersionPayload,\n): Promise<FlowVersion> => {\n  const { data } = await apiClient.post<FlowVersion>(\n    `/flows/${flowId}/versions`,\n    payload,\n  );\n  return data;\n};\n\nexport const getFlowVersions = async (\n  flowId: number,\n): Promise<FlowVersion[]> => {\n  const { data } = await apiClient.get<FlowVersion[]>(\n    `/flows/${flowId}/versions`,\n  );\n  return data;\n};\n"
  },
  {
    "path": "apps/client/src/entities/flow/store.ts",
    "content": "import { create } from \"zustand\";\nimport { DataNodeType, Edge, Node } from \"@/features/flow/flowTypes\";\nimport {\n  getFlows,\n  getFlowVersions,\n  saveFlowVersion,\n  createFlow,\n} from \"@/entities/flow/api\";\nimport { Flow } from \"@/entities/flow/types\";\n\ninterface FlowState {\n  flows: Flow[];\n  currentFlowId: number | null;\n  isLoading: boolean;\n  error: string | null;\n  nodes: Node[];\n  edges: Edge[];\n  refresh: number;\n  hasUnsavedChanges: boolean;\n  fetchFlows: () => Promise<void>;\n  setCurrentFlowId: (flowId: number | null) => void;\n  setNodes: (nodes: Node[]) => void;\n  setEdges: (edges: Edge[]) => void;\n  updateNode: (nodeId: string, data: DataNodeType) => void;\n  addRefresh: () => void;\n  markDirty: () => void;\n  markSaved: () => void;\n  createNewFlow: (name: string) => Promise<void>;\n  saveGraph: (comment?: string) => Promise<void>;\n  loadGraph: () => Promise<void>;\n  resetFlowState: () => void;\n}\n\nexport const useFlowStore = create<FlowState>((set, get) => ({\n  flows: [],\n  currentFlowId: null,\n  nodes: [],\n  edges: [],\n  isLoading: false,\n  error: null,\n  refresh: 1,\n  hasUnsavedChanges: false,\n\n  fetchFlows: async () => {\n    set({ isLoading: true, error: null });\n    try {\n      const flows = await getFlows();\n      set({ flows, isLoading: false });\n    } catch (err) {\n      const error =\n        err instanceof Error ? err.message : \"Failed to fetch flows\";\n      set({ isLoading: false, error });\n    }\n  },\n\n  setCurrentFlowId: (flowId: number | null) => {\n    set({\n      currentFlowId: flowId,\n      nodes: [],\n      edges: [],\n      hasUnsavedChanges: false,\n    });\n    if (flowId) {\n      get().loadGraph();\n    }\n  },\n\n  setNodes: (nodes) => set({ nodes, hasUnsavedChanges: true }),\n  setEdges: (edges) => set({ edges, hasUnsavedChanges: true }),\n\n  updateNode: (nodeId: string, data: DataNodeType) => {\n    set((state) => ({\n      nodes: state.nodes.map((node) =>\n        node.id === nodeId\n          ? { ...node, data: { ...node.data, ...data } }\n          : node,\n      ),\n      hasUnsavedChanges: true,\n    }));\n  },\n\n  addRefresh: () => {\n    set((state) => ({ refresh: state.refresh + 1 }));\n  },\n\n  markDirty: () => set({ hasUnsavedChanges: true }),\n  markSaved: () => set({ hasUnsavedChanges: false }),\n\n  createNewFlow: async (name: string) => {\n    set({ isLoading: true, error: null });\n    try {\n      const newFlow = await createFlow({ name, enabled: 1 });\n      set((state) => ({\n        flows: [...state.flows, newFlow],\n        currentFlowId: newFlow.id,\n        nodes: [],\n        edges: [],\n        isLoading: false,\n        hasUnsavedChanges: false,\n      }));\n    } catch (err) {\n      const error =\n        err instanceof Error ? err.message : \"Failed to create flow\";\n      set({ isLoading: false, error });\n    }\n  },\n\n  saveGraph: async (comment?: string) => {\n    const { nodes, edges, currentFlowId } = get();\n    if (!currentFlowId) {\n      set({ error: \"No flow selected\" });\n      return;\n    }\n    set({ isLoading: true, error: null });\n    try {\n      const graphData = { nodes, edges };\n      const graph_json = JSON.stringify(graphData, null, 2);\n      await saveFlowVersion(currentFlowId, { graph_json, comment });\n      set({ isLoading: false, hasUnsavedChanges: false });\n    } catch (err) {\n      const error = err instanceof Error ? err.message : \"Failed to save graph\";\n      set({ isLoading: false, error });\n      console.error(error);\n    }\n  },\n\n  loadGraph: async () => {\n    const { currentFlowId } = get();\n    if (!currentFlowId) return;\n    set({ isLoading: true, error: null });\n    try {\n      const versions = await getFlowVersions(currentFlowId);\n      if (versions.length > 0) {\n        const latestVersion = versions.sort((a, b) => b.version - a.version)[0];\n        const graphData = JSON.parse(latestVersion.graph_json);\n        if (graphData.nodes && graphData.edges) {\n          set({\n            nodes: graphData.nodes,\n            edges: graphData.edges,\n            isLoading: false,\n            hasUnsavedChanges: false,\n          });\n        }\n      } else {\n        set({\n          isLoading: false,\n          nodes: [],\n          edges: [],\n          hasUnsavedChanges: false,\n        });\n      }\n    } catch (err) {\n      const error = err instanceof Error ? err.message : \"Failed to load graph\";\n      set({ isLoading: false, error });\n      console.error(error);\n    }\n  },\n  resetFlowState: () =>\n    set({\n      nodes: [],\n      edges: [],\n      currentFlowId: null,\n      hasUnsavedChanges: false,\n    }),\n}));\n"
  },
  {
    "path": "apps/client/src/entities/flow/types.ts",
    "content": "export interface Flow {\n  id: number;\n  name: string;\n  description: string | null;\n  enabled: number;\n  created_at: string;\n  updated_at: string;\n}\n\nexport interface FlowPayload {\n  name: string;\n  description?: string;\n  enabled?: number;\n}\n\nexport interface FlowVersion {\n  id: number;\n  flow_id: number;\n  version: number;\n  graph_json: string;\n  comment: string | null;\n  created_at: string;\n}\n\nexport interface FlowVersionPayload {\n  graph_json: string;\n  comment?: string;\n}\n"
  },
  {
    "path": "apps/client/src/entities/ha/api.ts",
    "content": "import { apiClient } from \"@/shared/api\";\n\nimport { HaState } from \"./types\";\n\nexport const fetchAllHaStates = async (): Promise<HaState[]> => {\n  try {\n    const response = await apiClient.get<HaState[]>(\"/ha/states\");\n    return response.data;\n  } catch (error) {\n    console.error(\"Failed to fetch Home Assistant states:\", error);\n    throw new Error(\"Failed to fetch HA states.\");\n  }\n};\n"
  },
  {
    "path": "apps/client/src/entities/ha/store.ts",
    "content": "import { create } from \"zustand\";\nimport { HaStateStore } from \"./types\";\nimport { fetchAllHaStates } from \"./api\";\n\nexport const useHaStore = create<HaStateStore>((set) => ({\n  states: [],\n  status: \"idle\",\n  error: null,\n\n  fetchStates: async () => {\n    set({ status: \"loading\", error: null });\n    try {\n      const data = await fetchAllHaStates();\n      set({ states: data, status: \"succeeded\" });\n    } catch (error) {\n      set({\n        status: \"failed\",\n        error: \"Failed to load states from server.\" + error,\n      });\n    }\n  },\n}));\n"
  },
  {
    "path": "apps/client/src/entities/ha/types.ts",
    "content": "type JsonPrimitive = string | number | boolean | null;\nexport type JsonObject = { [key: string]: JsonValue };\ntype JsonArray = JsonValue[];\nexport type JsonValue = JsonPrimitive | JsonObject | JsonArray;\n\nexport interface HaState {\n  entity_id: string;\n  state: string;\n  attributes: JsonObject;\n  last_changed: string;\n  last_updated: string;\n  context: JsonObject;\n}\n\nexport interface HaStateStore {\n  states: HaState[];\n  status: \"idle\" | \"loading\" | \"succeeded\" | \"failed\";\n  error: string | null;\n  fetchStates: () => Promise<void>;\n}\n"
  },
  {
    "path": "apps/client/src/entities/integrations/api.ts",
    "content": "import { apiClient } from \"@/shared/api\";\nimport type { IntegrationStatus, IntegrationRegisterPayload } from \"./types\";\n\nexport const getIntegrationStatus = () =>\n  apiClient.get<IntegrationStatus>(\"/integrations/status\");\n\nexport const registerIntegration = (data: IntegrationRegisterPayload) =>\n  apiClient.post(\"/integrations/register\", data);\n\nexport const deleteIntegration = (integrationId: string) =>\n  apiClient.delete(`/integrations/${integrationId}`);\n"
  },
  {
    "path": "apps/client/src/entities/integrations/store.ts",
    "content": "import { create } from \"zustand\";\nimport * as api from \"./api\";\n\ninterface IntegrationState {\n  isHaConnected: boolean;\n  isRos2Connected: boolean;\n  isSdrConnected: boolean;\n  isLoading: boolean;\n  error: string | null;\n  fetchStatus: () => Promise<void>;\n  registerIntegration: (\n    id: string,\n    config: Record<string, string>,\n  ) => Promise<void>;\n  deleteIntegration: (id: string) => Promise<void>;\n}\n\nexport const useIntegrationStore = create<IntegrationState>((set, get) => ({\n  isHaConnected: false,\n  isRos2Connected: false,\n  isSdrConnected: false,\n  isLoading: false,\n  error: null,\n  fetchStatus: async () => {\n    set({ isLoading: true, error: null });\n    try {\n      const response = await api.getIntegrationStatus();\n      set({\n        isHaConnected: response.data.home_assistant.connected,\n        isRos2Connected: response.data.ros2.connected,\n        isSdrConnected: response.data.sdr.connected,\n        isLoading: false,\n      });\n    } catch (err) {\n      const errorMsg = err instanceof Error ? err.message : String(err);\n      set({\n        error: \"Failed to fetch integration status: \" + errorMsg,\n        isLoading: false,\n      });\n    }\n  },\n  registerIntegration: async (id, config) => {\n    await api.registerIntegration({ integration_id: id, config });\n    await get().fetchStatus();\n  },\n  deleteIntegration: async (id) => {\n    await api.deleteIntegration(id);\n    await get().fetchStatus();\n  },\n}));\n"
  },
  {
    "path": "apps/client/src/entities/integrations/types.ts",
    "content": "export interface IntegrationStatus {\n  home_assistant: { connected: boolean };\n  ros2: { connected: boolean };\n  sdr: { connected: boolean };\n}\n\nexport interface IntegrationRegisterPayload {\n  integration_id: string;\n  config: Record<string, string>;\n}\n"
  },
  {
    "path": "apps/client/src/entities/log/api.ts",
    "content": "import { apiClient } from \"@/shared/api\";\nimport { LogContentResponse, LogFileListResponse } from \"./types\";\n\nexport const getLatestLog = async (): Promise<LogContentResponse> => {\n  const { data } = await apiClient.get<LogContentResponse>(\"/logs/latest\");\n  return data;\n};\n\nexport const getLogFileList = async (): Promise<LogFileListResponse> => {\n  const { data } = await apiClient.get<LogFileListResponse>(\"/logs\");\n  return data;\n};\n\nexport const getLogByFilename = async (\n  filename: string,\n): Promise<LogContentResponse> => {\n  const { data } = await apiClient.get<LogContentResponse>(`/logs/${filename}`);\n  return data;\n};\n"
  },
  {
    "path": "apps/client/src/entities/log/types.ts",
    "content": "export type LogContentResponse = {\n  filename: string;\n  logs: string;\n};\n\nexport type LogFileListResponse = {\n  files: string[];\n};\n"
  },
  {
    "path": "apps/client/src/entities/map/api.ts",
    "content": "import { apiClient } from \"@/shared/api\";\nimport {\n  MapLayer,\n  LayerWithFeatures,\n  LayerPayload,\n  FeaturePayload,\n  MapFeature,\n  FeatureWithVertices,\n  UpdateFeaturePayload,\n} from \"./types\";\n\nexport const getAllLayers = async (): Promise<MapLayer[]> => {\n  const response = await apiClient.get<MapLayer[]>(\"/map/layers\");\n  return response.data;\n};\n\nexport const getLayerWithFeatures = async (\n  layerId: number,\n): Promise<LayerWithFeatures> => {\n  const response = await apiClient.get<LayerWithFeatures>(\n    `/map/layers/${layerId}`,\n  );\n  return response.data;\n};\n\nexport const createLayer = async (payload: LayerPayload): Promise<MapLayer> => {\n  const response = await apiClient.post<MapLayer>(\"/map/layers\", payload);\n  return response.data;\n};\n\nexport const updateLayer = async (\n  layerId: number,\n  payload: LayerPayload,\n): Promise<MapLayer> => {\n  const response = await apiClient.put<MapLayer>(\n    `/map/layers/${layerId}`,\n    payload,\n  );\n  return response.data;\n};\n\nexport const deleteLayer = async (\n  layerId: number,\n): Promise<{ message: string }> => {\n  const response = await apiClient.delete(`/map/layers/${layerId}`);\n  return response.data;\n};\n\nexport const createFeature = async (\n  payload: FeaturePayload,\n): Promise<MapFeature> => {\n  const response = await apiClient.post<MapFeature>(\"/map/features\", payload);\n  return response.data;\n};\n\nexport const getFeatureWithVertices = async (\n  featureId: number,\n): Promise<FeatureWithVertices> => {\n  const response = await apiClient.get<FeatureWithVertices>(\n    `/map/features/${featureId}`,\n  );\n  return response.data;\n};\n\nexport const updateFeature = async (\n  featureId: number,\n  payload: UpdateFeaturePayload,\n): Promise<MapFeature> => {\n  const response = await apiClient.put<MapFeature>(\n    `/map/features/${featureId}`,\n    payload,\n  );\n  return response.data;\n};\n\nexport const deleteFeature = async (\n  featureId: number,\n): Promise<{ message: string }> => {\n  const response = await apiClient.delete(`/map/features/${featureId}`);\n  return response.data;\n};\n"
  },
  {
    "path": "apps/client/src/entities/map/store.ts",
    "content": "import { create } from \"zustand\";\nimport * as mapApi from \"./api\";\nimport {\n  MapDataState,\n  MapInteractionState,\n  FeaturePayload,\n  TileMapType,\n} from \"./types\";\n\nexport const useMapDataStore = create<MapDataState>((set, get) => ({\n  layer: null,\n  isLoading: false,\n  error: null,\n  layers: [],\n  activeLayerId: null,\n  fetchAllLayers: async () => {\n    try {\n      const layers = await mapApi.getAllLayers();\n      set({ layers });\n      if (layers.length > 0 && !get().activeLayerId) {\n        get().setActiveLayer(layers[0].id);\n      }\n    } catch (err) {\n      set({ error: \"Failed to fetch layers\" + err });\n    }\n  },\n  setActiveLayer: async (layerId) => {\n    set({ isLoading: true, activeLayerId: layerId });\n    try {\n      const layerData = await mapApi.getLayerWithFeatures(layerId);\n      set({ layer: layerData, isLoading: false });\n    } catch (err) {\n      set({\n        error: \"Failed to fetch active layer data\" + err,\n        isLoading: false,\n      });\n    }\n  },\n  addLayer: async (payload) => {\n    try {\n      await mapApi.createLayer(payload);\n      await get().fetchAllLayers(); // refresh list\n    } catch (err) {\n      set({ error: \"Failed to create layer\" + err });\n    }\n  },\n  removeLayer: async (layerId) => {\n    try {\n      await mapApi.deleteLayer(layerId);\n      set((state) => ({\n        layers: state.layers.filter((l) => l.id !== layerId),\n      }));\n      if (get().activeLayerId === layerId) {\n        set({ layer: null, activeLayerId: null });\n        const remainingLayers = get().layers;\n        if (remainingLayers.length > 0) {\n          get().setActiveLayer(remainingLayers[0].id);\n        }\n      }\n    } catch (err) {\n      set({ error: \"Failed to delete layer\" + err });\n    }\n  },\n  editLayer: async (layerId, payload) => {\n    try {\n      await mapApi.updateLayer(layerId, payload);\n      await get().fetchAllLayers();\n    } catch (err) {\n      set({ error: \"Failed to update layer\" + err });\n    }\n  },\n  fetchLayerData: async (layerId) => {\n    set({ isLoading: true, error: null });\n    try {\n      const layerData = await mapApi.getLayerWithFeatures(layerId);\n      set({ layer: layerData, isLoading: false });\n    } catch (err) {\n      set({ error: \"Failed to fetch map data\" + err, isLoading: false });\n    }\n  },\n  addFeature: async (payload: FeaturePayload) => {\n    try {\n      const newFeature = await mapApi.createFeature(payload);\n      const featureWithVertices = await mapApi.getFeatureWithVertices(\n        newFeature.id,\n      );\n      const currentLayer = get().layer;\n      if (currentLayer) {\n        set({\n          layer: {\n            ...currentLayer,\n            features: [...currentLayer.features, featureWithVertices],\n          },\n        });\n      }\n    } catch (err) {\n      set({ error: \"Failed to add feature\" + err });\n    }\n  },\n  removeFeature: async (featureId) => {\n    try {\n      await mapApi.deleteFeature(featureId);\n      const currentLayer = get().layer;\n      if (currentLayer) {\n        set({\n          layer: {\n            ...currentLayer,\n            features: currentLayer.features.filter((f) => f.id !== featureId),\n          },\n        });\n        useMapInteractionStore.getState().setSelectedFeature(null);\n      }\n    } catch (err) {\n      set({ error: \"Failed to remove feature\" + err });\n    }\n  },\n  updateFeature: async (featureId, payload) => {\n    try {\n      const updatedFeatureData = await mapApi.updateFeature(featureId, payload);\n      const featureWithVertices = await mapApi.getFeatureWithVertices(\n        updatedFeatureData.id,\n      );\n      const currentLayer = get().layer;\n      if (currentLayer) {\n        set({\n          layer: {\n            ...currentLayer,\n            features: currentLayer.features.map((f) =>\n              f.id === featureId ? featureWithVertices : f,\n            ),\n          },\n        });\n        useMapInteractionStore\n          .getState()\n          .setSelectedFeature(featureWithVertices);\n      }\n    } catch (err) {\n      set({ error: \"Failed to update feature\" + err });\n    }\n  },\n}));\n\nconst getStoredTileMapType = (): TileMapType => {\n  const stored = localStorage.getItem(\"mapTileType\");\n  return stored === \"satellite\" || stored === \"dark\" ? stored : \"dark\";\n};\n\nexport const useMapInteractionStore = create<MapInteractionState>(\n  (set, get) => ({\n    selectedFeature: null,\n    setSelectedFeature: (feature) => set({ selectedFeature: feature }), // deselect entity when selecting a feature\n\n    drawingMode: null,\n    currentVertices: [],\n    setDrawingMode: (mode) => {\n      get().clearDrawing();\n      set({ drawingMode: mode });\n    },\n    addVertex: (vertex) => {\n      set((state) => ({\n        currentVertices: [...state.currentVertices, vertex],\n      }));\n    },\n    clearDrawing: () => {\n      set({ currentVertices: [], drawingMode: null });\n    },\n\n    tileMapType: getStoredTileMapType(),\n    setTileMapType: (type) => {\n      localStorage.setItem(\"mapTileType\", type);\n      set({ tileMapType: type });\n    },\n  }),\n);\n"
  },
  {
    "path": "apps/client/src/entities/map/types.ts",
    "content": "import { LatLng } from \"leaflet\";\n\nexport interface MapVertex {\n  id: number;\n  feature_id: number;\n  latitude: number;\n  longitude: number;\n  altitude?: number;\n  sequence: number;\n}\n\nexport interface MapFeature {\n  id: number;\n  layer_id: number;\n  feature_type: \"POINT\" | \"LINE\" | \"POLYGON\";\n  name?: string;\n  style_properties?: string;\n  created_by_user_id: number;\n  created_at: string;\n  updated_at: string;\n}\n\nexport interface FeatureWithVertices extends MapFeature {\n  vertices: MapVertex[];\n}\n\nexport interface MapLayer {\n  id: number;\n  name: string;\n  description?: string;\n  owner_user_id: number;\n  is_visible: number;\n  created_at: string;\n  updated_at: string;\n}\n\nexport interface LayerWithFeatures extends MapLayer {\n  features: FeatureWithVertices[];\n}\n\nexport interface LayerPayload {\n  name: string;\n  description?: string;\n  is_visible?: boolean;\n}\n\nexport interface VertexPayload {\n  latitude: number;\n  longitude: number;\n  altitude?: number;\n}\n\nexport interface FeaturePayload {\n  layer_id: number;\n  feature_type: \"POINT\" | \"LINE\" | \"POLYGON\";\n  name?: string;\n  style_properties?: string;\n  vertices: VertexPayload[];\n}\n\nexport interface UpdateFeaturePayload {\n  name?: string;\n  style_properties?: string;\n  vertices?: VertexPayload[];\n}\n\nexport type DrawingMode = \"POINT\" | \"LINE\" | \"POLYGON\" | null;\n\n// 타일맵 타입 정의\nexport type TileMapType = \"dark\" | \"satellite\";\n\nexport interface TileMapConfig {\n  type: TileMapType;\n  url: string;\n  attribution: string;\n  maxNativeZoom: number;\n}\n\nexport const TILE_MAPS: Record<TileMapType, TileMapConfig> = {\n  dark: {\n    type: \"dark\",\n    url: \"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png\",\n    attribution:\n      '&copy; <a href=\"http://osm.org/copyright\">OpenStreetMap</a> contributors',\n    maxNativeZoom: 20,\n  },\n  satellite: {\n    type: \"satellite\",\n    url: \"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}\",\n    attribution: \"&copy; Esri\",\n    maxNativeZoom: 18,\n  },\n};\n\nexport interface MapInteractionState {\n  selectedFeature: FeatureWithVertices | null;\n  setSelectedFeature: (feature: FeatureWithVertices | null) => void;\n  drawingMode: DrawingMode;\n  currentVertices: LatLng[];\n  setDrawingMode: (mode: DrawingMode) => void;\n  addVertex: (vertex: LatLng) => void;\n  clearDrawing: () => void;\n  tileMapType: TileMapType;\n  setTileMapType: (type: TileMapType) => void;\n}\n\nexport interface MapDataState {\n  layer: LayerWithFeatures | null;\n  isLoading: boolean;\n  error: string | null;\n  layers: MapLayer[];\n  activeLayerId: number | null;\n  fetchAllLayers: () => Promise<void>;\n  setActiveLayer: (layerId: number) => Promise<void>;\n  addLayer: (payload: LayerPayload) => Promise<void>;\n  removeLayer: (layerId: number) => Promise<void>;\n  editLayer: (layerId: number, payload: LayerPayload) => Promise<void>;\n  fetchLayerData: (layerId: number) => Promise<void>;\n  addFeature: (payload: FeaturePayload) => Promise<void>;\n  removeFeature: (featureId: number) => Promise<void>;\n  updateFeature: (\n    featureId: number,\n    payload: UpdateFeaturePayload,\n  ) => Promise<void>;\n}\n"
  },
  {
    "path": "apps/client/src/entities/permission/api.ts",
    "content": "import { apiClient } from \"@/shared/api\";\nimport { Permission } from \"./types\";\n\nexport const getPermissions = () => apiClient.get<Permission[]>(\"/permissions\");\n"
  },
  {
    "path": "apps/client/src/entities/permission/store.ts",
    "content": "import { create } from \"zustand\";\nimport { getPermissions } from \"./api\";\nimport { Permission } from \"./types\";\n\ntype PermissionState = {\n  permissions: Permission[];\n  isLoading: boolean;\n  error: string | null;\n  hasFetched: boolean;\n  fetchPermissions: () => Promise<void>;\n};\n\nexport const usePermissionStore = create<PermissionState>((set, get) => ({\n  permissions: [],\n  isLoading: false,\n  error: null,\n  hasFetched: false,\n  fetchPermissions: async () => {\n    if (get().hasFetched || get().isLoading) return;\n\n    set({ isLoading: true, error: null });\n    try {\n      const response = await getPermissions();\n      set({\n        permissions: response.data,\n        isLoading: false,\n        hasFetched: true,\n      });\n    } catch (error) {\n      set({ error: \"Failed to fetch permissions.\", isLoading: false });\n      console.error(error);\n    }\n  },\n}));\n"
  },
  {
    "path": "apps/client/src/entities/permission/types.ts",
    "content": "export type Permission = {\n  id: number;\n  name: string;\n  description: string | null;\n};\n"
  },
  {
    "path": "apps/client/src/entities/recording/api.ts",
    "content": "import { apiClient } from \"@/shared/api\";\nimport {\n  Recording,\n  StartRecordingRequest,\n  StartRecordingResponse,\n  ActiveRecordingInfo,\n  TopicRecordingStatus,\n} from \"./types\";\n\nexport const getRecordings = async (): Promise<Recording[]> => {\n  const { data } = await apiClient.get<Recording[]>(\"/recordings\");\n  return data;\n};\n\nexport const getRecordingsByTopic = async (\n  topic: string\n): Promise<Recording[]> => {\n  const { data } = await apiClient.get<Recording[]>(\"/recordings\", {\n    params: { topic },\n  });\n  return data;\n};\n\nexport const getRecordingsByStatus = async (\n  status: string\n): Promise<Recording[]> => {\n  const { data } = await apiClient.get<Recording[]>(\"/recordings\", {\n    params: { status },\n  });\n  return data;\n};\n\nexport const getRecording = async (id: number): Promise<Recording> => {\n  const { data } = await apiClient.get<Recording>(`/recordings/${id}`);\n  return data;\n};\n\nexport const startRecording = async (\n  payload: StartRecordingRequest\n): Promise<StartRecordingResponse> => {\n  const { data } = await apiClient.post<StartRecordingResponse>(\n    \"/recordings\",\n    payload\n  );\n  return data;\n};\n\nexport const stopRecording = async (id: number): Promise<void> => {\n  await apiClient.post(`/recordings/${id}/stop`);\n};\n\nexport const deleteRecording = async (id: number): Promise<void> => {\n  await apiClient.delete(`/recordings/${id}`);\n};\n\nexport const getActiveRecordings = async (): Promise<ActiveRecordingInfo[]> => {\n  const { data } = await apiClient.get<ActiveRecordingInfo[]>(\n    \"/recordings/active\"\n  );\n  return data;\n};\n\nexport const isTopicRecording = async (\n  topic: string\n): Promise<TopicRecordingStatus> => {\n  const encodedTopic = encodeURIComponent(topic);\n  const { data } = await apiClient.get<TopicRecordingStatus>(\n    `/recordings/active/${encodedTopic}`\n  );\n  return data;\n};\n\nexport const getRecordingStreamUrl = (\n  id: number,\n  serverUrl: string,\n  token: string\n): string => {\n  try {\n    const baseUrl = new URL(serverUrl);\n    if (baseUrl.protocol !== \"http:\" && baseUrl.protocol !== \"https:\") {\n      return \"\";\n    }\n\n    const streamUrl = new URL(`/api/recordings/${id}/stream`, baseUrl.origin);\n    streamUrl.searchParams.set(\"token\", token);\n    return streamUrl.toString();\n  } catch {\n    return \"\";\n  }\n};\n"
  },
  {
    "path": "apps/client/src/entities/recording/index.ts",
    "content": "export * from \"./types\";\nexport * from \"./api\";\nexport { useRecordingStore } from \"./store\";\n"
  },
  {
    "path": "apps/client/src/entities/recording/store.ts",
    "content": "import { create } from \"zustand\";\nimport * as api from \"./api\";\nimport type { Recording, ActiveRecordingInfo } from \"./types\";\n\ninterface RecordingState {\n  recordings: Recording[];\n  activeRecordings: ActiveRecordingInfo[];\n  isLoading: boolean;\n  error: string | null;\n  fetchRecordings: () => Promise<void>;\n  fetchActiveRecordings: () => Promise<void>;\n  startRecording: (topic: string) => Promise<number>;\n  stopRecording: (id: number) => Promise<void>;\n  deleteRecording: (id: number) => Promise<void>;\n  isRecording: (topic: string) => boolean;\n  getActiveRecordingId: (topic: string) => number | null;\n}\n\nexport const useRecordingStore = create<RecordingState>((set, get) => ({\n  recordings: [],\n  activeRecordings: [],\n  isLoading: false,\n  error: null,\n\n  fetchRecordings: async () => {\n    set({ isLoading: true, error: null });\n    try {\n      const recordings = await api.getRecordings();\n      set({ recordings, isLoading: false });\n    } catch (err) {\n      set({ error: \"Failed to fetch recordings\", isLoading: false });\n      console.error(err);\n    }\n  },\n\n  fetchActiveRecordings: async () => {\n    try {\n      const activeRecordings = await api.getActiveRecordings();\n      set({ activeRecordings });\n    } catch (err) {\n      console.error(\"Failed to fetch active recordings\", err);\n    }\n  },\n\n  startRecording: async (topic: string) => {\n    try {\n      const response = await api.startRecording({ topic });\n      await get().fetchActiveRecordings();\n      await get().fetchRecordings();\n      return response.id;\n    } catch (err) {\n      console.error(\"Failed to start recording\", err);\n      throw err;\n    }\n  },\n\n  stopRecording: async (id: number) => {\n    try {\n      await api.stopRecording(id);\n      await get().fetchActiveRecordings();\n      // Wait a bit for the recording to be finalized\n      setTimeout(() => get().fetchRecordings(), 1000);\n    } catch (err) {\n      console.error(\"Failed to stop recording\", err);\n      throw err;\n    }\n  },\n\n  deleteRecording: async (id: number) => {\n    try {\n      await api.deleteRecording(id);\n      await get().fetchRecordings();\n    } catch (err) {\n      console.error(\"Failed to delete recording\", err);\n      throw err;\n    }\n  },\n\n  isRecording: (topic: string) => {\n    return get().activeRecordings.some((r) => r.topic === topic);\n  },\n\n  getActiveRecordingId: (topic: string) => {\n    const active = get().activeRecordings.find((r) => r.topic === topic);\n    return active?.recording_id ?? null;\n  },\n}));\n"
  },
  {
    "path": "apps/client/src/entities/recording/types.ts",
    "content": "export interface Recording {\n  id: number;\n  stream_ssrc: number;\n  topic: string;\n  device_id: string;\n  media_type: \"audio\" | \"video\";\n  filename: string;\n  file_path: string;\n  file_size: number;\n  duration_ms: number;\n  status: RecordingStatus;\n  started_at: string;\n  ended_at: string | null;\n  created_by_user_id: number | null;\n}\n\nexport type RecordingStatus = \"recording\" | \"completed\" | \"failed\";\n\nexport interface StartRecordingRequest {\n  topic: string;\n}\n\nexport interface StartRecordingResponse {\n  id: number;\n  topic: string;\n  status: string;\n}\n\nexport interface ActiveRecordingInfo {\n  topic: string;\n  recording_id: number;\n}\n\nexport interface TopicRecordingStatus {\n  is_recording: boolean;\n  recording_id: number | null;\n}\n"
  },
  {
    "path": "apps/client/src/entities/role/api.ts",
    "content": "import { apiClient } from \"@/shared/api\";\nimport { Role, CreateRolePayload, UpdateRolePayload } from \"./types\";\n\nexport const getRoles = () => apiClient.get<Role[]>(\"/roles\");\nexport const createRole = (data: CreateRolePayload) =>\n  apiClient.post<Role>(\"/roles\", data);\nexport const updateRole = (id: number, data: UpdateRolePayload) =>\n  apiClient.put<Role>(`/roles/${id}`, data);\nexport const deleteRole = (id: number) => apiClient.delete(`/roles/${id}`);\n"
  },
  {
    "path": "apps/client/src/entities/role/store.ts",
    "content": "import { create } from \"zustand\";\nimport { getRoles, createRole, updateRole, deleteRole } from \"./api\";\nimport { Role, CreateRolePayload, UpdateRolePayload } from \"./types\";\n\ntype RoleState = {\n  roles: Role[];\n  isLoading: boolean;\n  error: string | null;\n  fetchRoles: () => Promise<void>;\n  addRole: (data: CreateRolePayload) => Promise<void>;\n  editRole: (id: number, data: UpdateRolePayload) => Promise<void>;\n  removeRole: (id: number) => Promise<void>;\n};\n\nexport const useRoleStore = create<RoleState>((set) => ({\n  roles: [],\n  isLoading: false,\n  error: null,\n  fetchRoles: async () => {\n    set({ isLoading: true, error: null });\n    try {\n      const response = await getRoles();\n      set({ roles: response.data, isLoading: false });\n    } catch (error) {\n      set({ error: \"Failed to fetch roles.\", isLoading: false });\n      console.error(error);\n    }\n  },\n  addRole: async (data) => {\n    try {\n      await createRole(data);\n      await useRoleStore.getState().fetchRoles();\n    } catch (error) {\n      const errorMessage = \"Failed to add role.\";\n      set({ error: errorMessage });\n      console.error(error);\n      throw new Error(errorMessage);\n    }\n  },\n  editRole: async (id, data) => {\n    try {\n      await updateRole(id, data);\n      await useRoleStore.getState().fetchRoles();\n    } catch (error) {\n      const errorMessage = \"Failed to update role.\";\n      set({ error: errorMessage });\n      console.error(error);\n      throw new Error(errorMessage);\n    }\n  },\n  removeRole: async (id) => {\n    try {\n      await deleteRole(id);\n      set((state) => ({\n        roles: state.roles.filter((role) => role.id !== id),\n      }));\n    } catch (error) {\n      const errorMessage = \"Failed to delete role.\";\n      set({ error: errorMessage });\n      console.error(error);\n      throw new Error(errorMessage);\n    }\n  },\n}));\n"
  },
  {
    "path": "apps/client/src/entities/role/types.ts",
    "content": "import { Permission } from \"@/entities/permission/types\";\n\nexport type Role = {\n  id: number;\n  name: string;\n  description: string | null;\n  permissions?: Permission[];\n};\n\nexport type CreateRolePayload = {\n  name: string;\n  description?: string;\n  permission_ids: number[];\n};\n\nexport type UpdateRolePayload = Partial<CreateRolePayload>;\n"
  },
  {
    "path": "apps/client/src/entities/stat/api.ts",
    "content": "import { apiClient } from \"@/shared/api\";\nimport { Stat } from \"./types\";\n\nexport const getStats = async (): Promise<Stat> => {\n  const { data } = await apiClient.get<Stat>(\"/stat\");\n  return data;\n};\n"
  },
  {
    "path": "apps/client/src/entities/stat/store.ts",
    "content": "import { create } from \"zustand\";\nimport * as api from \"./api\";\nimport type { Stat } from \"./types\";\n\ninterface StatState {\n  stat: Stat;\n  isLoading: boolean;\n  error: string | null;\n  fetchStat: () => Promise<void>;\n}\n\nexport const useStatStore = create<StatState>((set) => ({\n  stat: {\n    count: {\n      devices: 0,\n      entities: 0,\n    },\n  },\n  isLoading: false,\n  error: null,\n  fetchStat: async () => {\n    set({ isLoading: true, error: null });\n    try {\n      const response = await api.getStats();\n      set({ stat: response, isLoading: false });\n    } catch (err) {\n      set({ error: \"Failed to fetch entities\" + err, isLoading: false });\n    }\n  },\n}));\n"
  },
  {
    "path": "apps/client/src/entities/stat/types.ts",
    "content": "export interface Stat {\n  count: {\n    devices: number;\n    entities: number;\n  };\n}\n"
  },
  {
    "path": "apps/client/src/entities/tunnel/api.ts",
    "content": "import { apiClient } from \"@/shared/api\";\nimport type {\n  StartTunnelRequest,\n  StartTunnelResponse,\n  TunnelStatus,\n} from \"./types\";\n\nexport const getStatus = () => apiClient.get<TunnelStatus>(\"/tunnel/status\");\n\nexport const startTunnel = (payload: StartTunnelRequest) =>\n  apiClient.post<StartTunnelResponse>(\"/tunnel/start\", payload);\n\nexport const stopTunnel = () => apiClient.post(\"/tunnel/stop\");\n"
  },
  {
    "path": "apps/client/src/entities/tunnel/store.ts",
    "content": "import { create } from \"zustand\";\nimport * as api from \"./api\";\nimport type { TunnelStatus } from \"./types\";\n\ntype TunnelState = {\n  status: TunnelStatus;\n  isLoading: boolean;\n  error: string | null;\n  refresh: () => Promise<void>;\n  start: (server: string, target: string, accessToken?: string) => Promise<void>;\n  stop: () => Promise<void>;\n};\n\nconst emptyStatus: TunnelStatus = {\n  active: false,\n  session_id: null,\n  server: null,\n  target: null,\n};\n\nexport const useTunnelStore = create<TunnelState>((set, get) => ({\n  status: emptyStatus,\n  isLoading: false,\n  error: null,\n  refresh: async () => {\n    set({ isLoading: true, error: null });\n    try {\n      const res = await api.getStatus();\n      set({ status: res.data, isLoading: false });\n    } catch (err) {\n      const message = err instanceof Error ? err.message : String(err);\n      set({ error: message, isLoading: false, status: emptyStatus });\n    }\n  },\n  start: async (server, target, accessToken) => {\n    set({ isLoading: true, error: null });\n    try {\n      const res = await api.startTunnel({ server, target, access_token: accessToken });\n      set({\n        status: {\n          active: true,\n          session_id: res.data.session_id,\n          server,\n          target,\n        },\n        isLoading: false,\n      });\n    } catch (err) {\n      const message = err instanceof Error ? err.message : String(err);\n      set({ error: message, isLoading: false });\n      throw err;\n    }\n  },\n  stop: async () => {\n    set({ isLoading: true, error: null });\n    try {\n      await api.stopTunnel();\n      set({ status: emptyStatus, isLoading: false });\n    } catch (err) {\n      const message = err instanceof Error ? err.message : String(err);\n      set({ error: message, isLoading: false });\n      throw err;\n    }\n  },\n}));\n"
  },
  {
    "path": "apps/client/src/entities/tunnel/types.ts",
    "content": "export type TunnelStatus = {\n  active: boolean;\n  session_id?: string | null;\n  server?: string | null;\n  target?: string | null;\n};\n\nexport type StartTunnelRequest = {\n  server: string;\n  target: string;\n  access_token?: string;\n};\n\nexport type StartTunnelResponse = {\n  session_id: string;\n};\n"
  },
  {
    "path": "apps/client/src/entities/user/api.ts",
    "content": "import { apiClient } from \"@/shared/api\";\nimport { User, CreateUserPayload, UpdateUserPayload } from \"./types\";\n\nexport const getUsers = () => apiClient.get<User[]>(\"/users\");\nexport const createUser = (data: CreateUserPayload) =>\n  apiClient.post<User>(\"/users\", data);\nexport const updateUser = (id: number, data: UpdateUserPayload) =>\n  apiClient.put<User>(`/users/${id}`, data);\nexport const deleteUser = (id: number) => apiClient.delete(`/users/${id}`);\n\nexport const assignRoleToUser = (userId: number, roleId: number) =>\n  apiClient.post(`/users/${userId}/roles`, { role_id: roleId });\n\nexport const revokeRoleFromUser = (userId: number, roleId: number) =>\n  apiClient.delete(`/users/${userId}/roles/${roleId}`);\n"
  },
  {
    "path": "apps/client/src/entities/user/store.ts",
    "content": "import { create } from \"zustand\";\nimport { getUsers, createUser, updateUser, deleteUser } from \"./api\";\nimport { User, CreateUserPayload, UpdateUserPayload } from \"./types\";\n\ntype UserState = {\n  users: User[];\n  isLoading: boolean;\n  error: string | null;\n  fetchUsers: () => Promise<void>;\n  addUser: (data: CreateUserPayload) => Promise<void>;\n  editUser: (id: number, data: UpdateUserPayload) => Promise<void>;\n  removeUser: (id: number) => Promise<void>;\n};\n\nexport const useUserStore = create<UserState>((set) => ({\n  users: [],\n  isLoading: false,\n  error: null,\n  fetchUsers: async () => {\n    set({ isLoading: true, error: null });\n    try {\n      const response = await getUsers();\n      set({ users: response.data, isLoading: false });\n    } catch (error) {\n      set({ error: \"Failed to fetch users.\", isLoading: false });\n      console.error(error);\n    }\n  },\n  addUser: async (data) => {\n    try {\n      await createUser(data);\n      // After adding, refresh the list\n      await useUserStore.getState().fetchUsers();\n    } catch (error) {\n      set({ error: \"Failed to add user.\" });\n      console.error(error);\n      throw error; // re-throw to be caught in the component\n    }\n  },\n  editUser: async (id, data) => {\n    try {\n      await updateUser(id, data);\n      await useUserStore.getState().fetchUsers();\n    } catch (error) {\n      set({ error: \"Failed to update user.\" });\n      console.error(error);\n      throw error;\n    }\n  },\n  removeUser: async (id) => {\n    try {\n      await deleteUser(id);\n      set((state) => ({\n        users: state.users.filter((user) => user.id !== id),\n      }));\n    } catch (error) {\n      set({ error: \"Failed to delete user.\" });\n      console.error(error);\n      throw error;\n    }\n  },\n}));\n"
  },
  {
    "path": "apps/client/src/entities/user/types.ts",
    "content": "import { Role } from \"../role/types\";\n\nexport type User = {\n  id: number;\n  username: string;\n  email: string;\n  created_at: string;\n  updated_at: string;\n  roles: Role[];\n};\n\nexport type CreateUserPayload = {\n  username: string;\n  email: string;\n  password: string;\n};\n\nexport type UpdateUserPayload = {\n  username?: string;\n  email?: string;\n  password?: string;\n};\n"
  },
  {
    "path": "apps/client/src/features/account-switcher/index.tsx",
    "content": "import * as React from \"react\";\nimport { Check, ChevronsUpDown } from \"lucide-react\";\n\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n  SidebarMenu,\n  SidebarMenuButton,\n  SidebarMenuItem,\n} from \"@/components/ui/sidebar\";\nimport { VesselLogo } from \"@/components/icon/Logo\";\n\nexport function AccountSwitcher({\n  versions,\n  defaultVersion,\n}: {\n  versions: string[];\n  defaultVersion: string;\n}) {\n  const [selectedVersion, setSelectedVersion] = React.useState(defaultVersion);\n\n  return (\n    <SidebarMenu>\n      <SidebarMenuItem>\n        <DropdownMenu>\n          <DropdownMenuTrigger asChild>\n            <SidebarMenuButton\n              size='lg'\n              className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'\n            >\n              <div className='bg-background text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center'>\n                <VesselLogo />\n              </div>\n              <div className='flex flex-col gap-0.5 leading-none'>\n                <span className='font-medium'>Server</span>\n                <span className=''>@{selectedVersion}</span>\n              </div>\n              <ChevronsUpDown className='ml-auto' />\n            </SidebarMenuButton>\n          </DropdownMenuTrigger>\n          <DropdownMenuContent\n            className='w-(--radix-dropdown-menu-trigger-width)'\n            align='start'\n          >\n            {versions.map((version) => (\n              <DropdownMenuItem\n                key={version}\n                onSelect={() => setSelectedVersion(version)}\n              >\n                @{version}{\" \"}\n                {version === selectedVersion && <Check className='ml-auto' />}\n              </DropdownMenuItem>\n            ))}\n          </DropdownMenuContent>\n        </DropdownMenu>\n      </SidebarMenuItem>\n    </SidebarMenu>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/auth/AuthInterceptor.tsx",
    "content": "import { useEffect } from \"react\";\nimport { Navigate, useLocation } from \"react-router\";\nimport { parseJwt } from \"@/lib/jwt\";\nimport { storage } from \"@/lib/storage\";\nimport { isDemoMode } from \"@/shared/demo\";\n\nexport function AuthInterceptor({ children }: { children: React.ReactNode }) {\n  const location = useLocation();\n  const token = storage.getToken();\n\n  useEffect(() => {\n    if (isDemoMode) return;\n\n    const currentToken = storage.getToken();\n    if (!currentToken) {\n      window.location.href = \"/auth\";\n    } else {\n      const parse = parseJwt(currentToken);\n      if (!parse?.exp) {\n        window.location.href = \"/auth\";\n        return;\n      }\n\n      const now = new Date();\n      const exp = new Date(parse.exp * 1000);\n\n      if (now.getTime() >= exp.getTime()) {\n        window.location.href = \"/auth\";\n      }\n    }\n  }, [location]);\n\n  if (!token && !isDemoMode) {\n    return <Navigate to='/auth' replace />;\n  }\n\n  return <>{children}</>;\n}\n"
  },
  {
    "path": "apps/client/src/features/auth/DefaultAdminPasswordDialog.tsx",
    "content": "import { useState } from \"react\";\nimport { toast } from \"sonner\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n  authenticateWithPassword,\n  fetchAdminUser,\n  updateUserPassword,\n} from \"./api\";\n\ntype DefaultAdminPasswordDialogProps = {\n  open: boolean;\n  serverUrl: string;\n  onSuccess: (token: string) => void;\n};\n\nexport function DefaultAdminPasswordDialog({\n  open,\n  serverUrl,\n  onSuccess,\n}: DefaultAdminPasswordDialogProps) {\n  const [newPassword, setNewPassword] = useState(\"\");\n  const [confirmNewPassword, setConfirmNewPassword] = useState(\"\");\n  const [isUpdatingPassword, setIsUpdatingPassword] = useState(false);\n\n  const handlePasswordChange = async () => {\n    if (!newPassword || !confirmNewPassword) {\n      toast.error(\"Please fill in both password fields.\");\n      return;\n    }\n\n    if (newPassword !== confirmNewPassword) {\n      toast.error(\"Passwords do not match.\");\n      return;\n    }\n\n    if (newPassword === \"admin\") {\n      toast.error(\"Please choose a password other than the default.\");\n      return;\n    }\n\n    if (!serverUrl) {\n      toast.error(\"Missing server URL. Please reconnect to the server first.\");\n      return;\n    }\n\n    setIsUpdatingPassword(true);\n\n    try {\n      const adminUser = await fetchAdminUser();\n\n      await updateUserPassword(adminUser.id, newPassword);\n\n      const refreshedToken = await authenticateWithPassword(serverUrl, {\n        id: adminUser.username,\n        password: newPassword,\n      });\n\n      setNewPassword(\"\");\n      setConfirmNewPassword(\"\");\n      toast.success(\"Password updated. Logging you in with the new password.\");\n      onSuccess(refreshedToken);\n    } catch (error) {\n      toast.error(\n        error instanceof Error\n          ? error.message\n          : \"Failed to update the admin password.\",\n      );\n    } finally {\n      setIsUpdatingPassword(false);\n    }\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={() => {}}>\n      <DialogContent\n        showCloseButton={false}\n        onInteractOutside={(event) => event.preventDefault()}\n        onEscapeKeyDown={(event) => event.preventDefault()}\n      >\n        <DialogHeader>\n          <DialogTitle>Update Admin Password</DialogTitle>\n          <DialogDescription>\n            Default admin credentials were used. Please set a new password to\n            continue.\n          </DialogDescription>\n        </DialogHeader>\n        <div className='grid gap-4 py-4'>\n          <div className='grid gap-2'>\n            <Label htmlFor='new-password'>New password</Label>\n            <Input\n              id='new-password'\n              type='password'\n              autoComplete='new-password'\n              value={newPassword}\n              onChange={(e) => setNewPassword(e.target.value)}\n              disabled={isUpdatingPassword}\n            />\n          </div>\n          <div className='grid gap-2'>\n            <Label htmlFor='confirm-password'>Confirm new password</Label>\n            <Input\n              id='confirm-password'\n              type='password'\n              autoComplete='new-password'\n              value={confirmNewPassword}\n              onChange={(e) => setConfirmNewPassword(e.target.value)}\n              disabled={isUpdatingPassword}\n            />\n          </div>\n        </div>\n        <DialogFooter>\n          <Button\n            type='button'\n            className='w-full'\n            onClick={handlePasswordChange}\n            disabled={isUpdatingPassword}\n          >\n            {isUpdatingPassword ? \"Updating...\" : \"Update password\"}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/auth/api.ts",
    "content": "import { apiClient } from \"@/shared/api\";\nimport { User } from \"@/entities/user/types\";\n\nexport type AuthCredentials = {\n  id: string;\n  password: string;\n};\n\nexport const authenticateWithPassword = async (\n  baseUrl: string,\n  credentials: AuthCredentials,\n): Promise<string> => {\n  const normalizedBaseUrl = baseUrl.replace(/\\/$/, \"\");\n\n  const response = await fetch(`${normalizedBaseUrl}/api/auth`, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n    },\n    body: JSON.stringify(credentials),\n  });\n\n  if (!response.ok) {\n    throw new Error(\"Wrong ID or password.\");\n  }\n\n  const data = await response.json();\n\n  if (data.token === \"none\" || !data.token) {\n    throw new Error(\"Authentication failed. Please check your credentials.\");\n  }\n\n  return data.token as string;\n};\n\nexport const fetchAdminUser = async (): Promise<User> => {\n  const usersResponse = await apiClient.get<User[]>(\"/users\");\n  const adminUser = usersResponse.data.find((user) => user.username === \"admin\");\n\n  if (!adminUser) {\n    throw new Error(\"Admin user not found on the server.\");\n  }\n\n  return adminUser;\n};\n\nexport const updateUserPassword = async (\n  userId: number,\n  newPassword: string,\n): Promise<void> => {\n  await apiClient.put(`/users/${userId}`, {\n    password: newPassword,\n  });\n};\n"
  },
  {
    "path": "apps/client/src/features/auth/hook.ts",
    "content": "import { useCallback } from \"react\";\nimport { useNavigate } from \"react-router\";\nimport { storage } from \"@/lib/storage\";\n\nexport const useLogout = () => {\n  const navigate = useNavigate();\n\n  const logout = useCallback(() => {\n    storage.removeToken();\n    storage.removeServerUrl();\n    navigate(\"/auth\", { replace: true });\n  }, [navigate]);\n\n  return { logout };\n};\n"
  },
  {
    "path": "apps/client/src/features/auth/index.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { toast } from \"sonner\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { useNavigate } from \"react-router\";\nimport { ArrowRight, Loader2, Trash2 } from \"lucide-react\";\nimport { DEMO_SERVER_URL, DEMO_TOKEN, isDemoMode } from \"@/shared/demo\";\nimport { DefaultAdminPasswordDialog } from \"./DefaultAdminPasswordDialog\";\nimport { authenticateWithPassword } from \"./api\";\nimport { storage } from \"@/lib/storage\";\nimport { parseJwt } from \"@/lib/jwt\";\nimport {\n  ensureSidecarRunning,\n  getDesktopServerUrl,\n  isTauri,\n  openDesktopSettings,\n} from \"@/shared/desktop\";\n\nconst MAX_RECENT_URLS = 5;\n\nfunction sanitizeServerUrl(rawUrl: string): string | null {\n  const trimmed = rawUrl.trim().replace(/\\/$/, \"\");\n\n  try {\n    const parsed = new URL(trimmed);\n    if (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") {\n      return null;\n    }\n    return parsed.origin;\n  } catch {\n    return null;\n  }\n}\n\nexport function LoginForm({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  const [url, setUrl] = useState(\"\");\n  const [id, setId] = useState(\"\");\n  const [password, setPassword] = useState(\"\");\n  const [showAuthFields, setShowAuthFields] = useState(false);\n  const [isLoading, setIsLoading] = useState(false);\n  const [recentUrls, setRecentUrls] = useState<string[]>([]);\n  const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);\n  const [serverUrlForDialog, setServerUrlForDialog] = useState(\"\");\n  const [isTauriClient] = useState<boolean>(() => isTauri());\n  const [isResolvingDesktop, setIsResolvingDesktop] = useState<boolean>(() =>\n    isTauri(),\n  );\n  const [desktopError, setDesktopError] = useState<string | null>(null);\n  const navigate = useNavigate();\n\n  const connectToServer = async (targetUrl: string) => {\n    if (isDemoMode) {\n      storage.setServerUrl(DEMO_SERVER_URL);\n      storage.setToken(DEMO_TOKEN);\n      toast.success(\"Demo mode enabled. Loading demo dashboard...\");\n      navigate(\"/dashboard\");\n      return;\n    }\n\n    if (!targetUrl) {\n      toast.error(\"Server URL cannot be empty.\");\n      return;\n    }\n    setIsLoading(true);\n    const processedUrl = targetUrl.replace(/\\/$/, \"\");\n\n    try {\n      const response = await fetch(`${processedUrl}/info`, {\n        method: \"GET\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      });\n\n      if (!response.ok) {\n        throw new Error(\"Failed.\");\n      }\n\n      const data = await response.json();\n\n      if (\n        data.code === 200 &&\n        data.id === \"vessel-server\" &&\n        data.status === \"success\"\n      ) {\n        toast.success(\"Connected to server successfully.\");\n\n        const updatedUrls = [\n          processedUrl,\n          ...recentUrls.filter((u) => u !== processedUrl),\n        ].slice(0, MAX_RECENT_URLS);\n\n        setRecentUrls(updatedUrls);\n        storage.setRecentUrls(updatedUrls);\n        storage.setServerUrl(processedUrl);\n\n        setServerUrlForDialog(processedUrl);\n        setShowAuthFields(true);\n      } else {\n        throw new Error(\"Failed to connect to the server.\");\n      }\n    } catch (error) {\n      toast.error(\n        error instanceof Error\n          ? error.message\n          : \"Failed to connect to the server.\",\n      );\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  const removeRecentUrl = (toRemove: string) => {\n    const updated = recentUrls.filter((u) => u !== toRemove);\n    setRecentUrls(updated);\n    storage.setRecentUrls(updated);\n    const normalized = url.replace(/\\/$/, \"\");\n    if (normalized === toRemove) {\n      setUrl(\"\");\n    }\n  };\n\n  const handleConnect = async (e: React.FormEvent) => {\n    e.preventDefault();\n    await connectToServer(url);\n  };\n\n  const handleAuth = async (e: React.FormEvent) => {\n    e.preventDefault();\n    setIsLoading(true);\n    const processedUrl = sanitizeServerUrl(url);\n\n    if (!processedUrl) {\n      toast.error(\"Please enter a valid server URL using http or https.\");\n      setIsLoading(false);\n      return;\n    }\n\n    try {\n      const token = await authenticateWithPassword(processedUrl, {\n        id,\n        password,\n      });\n\n      storage.setToken(token);\n      storage.setServerUrl(processedUrl);\n\n      setServerUrlForDialog(processedUrl);\n\n      if (id === \"admin\" && password === \"admin\") {\n        setIsPasswordDialogOpen(true);\n        toast.message(\"Default admin password detected\", {\n          description: \"Please change the password before continuing.\",\n        });\n        return;\n      }\n\n      toast.success(\"Successfully authenticated.\");\n      navigate(\"/dashboard\");\n    } catch (error) {\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to authenticate.\",\n      );\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  const bootstrapDesktop = async () => {\n    setDesktopError(null);\n    setIsResolvingDesktop(true);\n    try {\n      // Make sure the sidecar is running, then ask Rust for its base URL.\n      await ensureSidecarRunning();\n      let baseUrl: string | null = null;\n      for (let attempt = 0; attempt < 5; attempt++) {\n        baseUrl = await getDesktopServerUrl();\n        if (baseUrl) break;\n        await new Promise((r) => setTimeout(r, 250));\n      }\n      if (!baseUrl) {\n        baseUrl = storage.getServerUrl();\n      }\n      if (!baseUrl) {\n        setDesktopError(\n          \"Could not reach the local Vessel server. Open the tray menu \\u2192 Server Settings\\u2026 to configure it.\",\n        );\n        return;\n      }\n      storage.setServerUrl(baseUrl);\n      setUrl(baseUrl);\n      setShowAuthFields(true);\n    } finally {\n      setIsResolvingDesktop(false);\n    }\n  };\n\n  useEffect(() => {\n    if (isDemoMode) {\n      storage.setServerUrl(DEMO_SERVER_URL);\n      storage.setToken(DEMO_TOKEN);\n      toast.message(\"Demo mode active\", {\n        description: \"Using mock data without a backend.\",\n      });\n      navigate(\"/dashboard\");\n      return;\n    }\n\n    const token = storage.getToken();\n    const serverUrl = storage.getServerUrl();\n\n    if (token && serverUrl) {\n      const parsed = parseJwt(token);\n      if (!parsed?.exp) {\n        storage.removeToken();\n      } else {\n        const now = new Date();\n        const exp = new Date(parsed.exp * 1000);\n        if (now.getTime() >= exp.getTime()) {\n          storage.removeToken();\n        } else {\n          navigate(\"/dashboard\");\n          return;\n        }\n      }\n    }\n\n    if (isTauriClient) {\n      void bootstrapDesktop();\n      return;\n    }\n\n    const storedUrls = storage.getRecentUrls();\n    if (storedUrls.length > 0) {\n      setRecentUrls(storedUrls);\n    }\n  }, [navigate, isTauriClient]);\n\n  return (\n    <div className={cn(\"flex flex-col gap-6\", className)} {...props}>\n      <form onSubmit={showAuthFields ? handleAuth : handleConnect}>\n        <div className='flex flex-col gap-6'>\n          <div className='flex flex-col items-center gap-2'>\n            <a\n              href='#'\n              className='flex flex-col items-center gap-2 font-medium'\n            >\n              <div className='flex size-12 items-center justify-center rounded-md'>\n                <img src='/icon.png' />\n              </div>\n            </a>\n            {/* <h1 className='text-xl font-bold'>Vessel</h1>\n            <div className='text-center text-sm'>\n              Physical Device Orchestration Platform\n            </div> */}\n          </div>\n          <div className='flex flex-col gap-6'>\n            {isTauriClient && isResolvingDesktop ? (\n              <div className='flex flex-col items-center gap-2 text-sm text-muted-foreground'>\n                <Loader2 className='size-5 animate-spin' />\n                Connecting to local server...\n              </div>\n            ) : isTauriClient && desktopError ? (\n              <div className='flex flex-col gap-3'>\n                <p className='text-sm text-red-300'>{desktopError}</p>\n                <div className='flex gap-2'>\n                  <Button\n                    type='button'\n                    variant='outline'\n                    className='flex-1'\n                    onClick={() => void bootstrapDesktop()}\n                  >\n                    Retry\n                  </Button>\n                  <Button\n                    type='button'\n                    variant='secondary'\n                    className='flex-1'\n                    onClick={() => void openDesktopSettings()}\n                  >\n                    Server Settings\n                  </Button>\n                </div>\n              </div>\n            ) : !showAuthFields ? (\n              <div className='grid gap-3'>\n                {/* <Label htmlFor='server-url'>Server</Label> */}\n                <div className='flex w-full gap-2'>\n                  <Input\n                    id='server-url'\n                    type='text'\n                    placeholder='https://your-server.com'\n                    required\n                    value={url}\n                    onChange={(e) => setUrl(e.target.value)}\n                    disabled={isLoading}\n                    className='flex-1'\n                  />\n                  <Button\n                    type='submit'\n                    size='icon'\n                    disabled={isLoading}\n                    aria-label='Connect'\n                    className='shrink-0'\n                  >\n                    {isLoading ? (\n                      <Loader2 className='size-4 animate-spin' />\n                    ) : (\n                      <ArrowRight className='size-4' />\n                    )}\n                  </Button>\n                </div>\n              </div>\n            ) : (\n              <>\n                <div className='grid gap-3'>\n                  <Label htmlFor='id'>ID</Label>\n                  <Input\n                    id='id'\n                    type='text'\n                    placeholder='admin'\n                    required\n                    value={id}\n                    onChange={(e) => setId(e.target.value)}\n                    disabled={isLoading}\n                  />\n                </div>\n                <div className='grid gap-3'>\n                  <Label htmlFor='password'>Password</Label>\n                  <Input\n                    id='password'\n                    type='password'\n                    required\n                    value={password}\n                    onChange={(e) => setPassword(e.target.value)}\n                    disabled={isLoading}\n                  />\n                </div>\n              </>\n            )}\n            {!(isTauriClient && (isResolvingDesktop || desktopError)) &&\n              showAuthFields && (\n                <Button type='submit' className='w-full' disabled={isLoading}>\n                  {isLoading ? \"Processing...\" : \"Auth\"}\n                </Button>\n              )}\n          </div>\n\n          {!isTauriClient && recentUrls.length > 0 && !showAuthFields && (\n            <div className='flex w-full flex-col gap-2 pt-4'>\n              {recentUrls.map((recentUrl) => (\n                <div key={recentUrl} className='flex w-full gap-2'>\n                  <Button\n                    type='button'\n                    variant='outline'\n                    onClick={async () => {\n                      setUrl(recentUrl);\n                      await connectToServer(recentUrl);\n                    }}\n                    className='min-w-0 flex-1 justify-start truncate text-left text-xs'\n                    size='sm'\n                  >\n                    {recentUrl}\n                  </Button>\n                  <Button\n                    type='button'\n                    variant='link'\n                    size='icon'\n                    className='size-8 shrink-0 text-muted-foreground'\n                    onClick={() => removeRecentUrl(recentUrl)}\n                    aria-label={`Remove ${recentUrl} from recent servers`}\n                  >\n                    <Trash2 className='size-4' />\n                  </Button>\n                </div>\n              ))}\n            </div>\n          )}\n        </div>\n      </form>\n      {/* <div className='text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4'>\n        This software is managed as an open source and can be found on this{\" \"}\n        <a\n          href='https://github.com/cartesiancs/vessel'\n          target='_blank'\n          rel='noopener noreferrer'\n        >\n          GitHub\n        </a>\n        .\n      </div> */}\n      <DefaultAdminPasswordDialog\n        open={isPasswordDialogOpen}\n        serverUrl={serverUrlForDialog}\n        onSuccess={(refreshedToken) => {\n          storage.setToken(refreshedToken);\n          if (serverUrlForDialog) {\n            storage.setServerUrl(serverUrlForDialog);\n          }\n          setIsPasswordDialogOpen(false);\n          toast.success(\n            \"Password updated. Logging you in with the new password.\",\n          );\n          navigate(\"/dashboard\");\n        }}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/code/CreateItemDialog.tsx",
    "content": "import React, { useState } from \"react\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogFooter,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\n\ninterface CreateItemDialogProps {\n  isOpen: boolean;\n  onClose: () => void;\n  itemType: \"file\" | \"folder\";\n  onCreate: (name: string) => Promise<void>;\n}\n\nexport const CreateItemDialog: React.FC<CreateItemDialogProps> = ({\n  isOpen,\n  onClose,\n  itemType,\n  onCreate,\n}) => {\n  const [name, setName] = useState(\"\");\n  const [isLoading, setIsLoading] = useState(false);\n\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n    if (!name.trim()) return;\n\n    setIsLoading(true);\n    await onCreate(name.trim());\n    setIsLoading(false);\n    onClose();\n    setName(\"\");\n  };\n\n  return (\n    <Dialog open={isOpen} onOpenChange={onClose}>\n      <DialogContent>\n        <DialogHeader>\n          <DialogTitle>Create New {itemType}</DialogTitle>\n        </DialogHeader>\n        <form onSubmit={handleSubmit}>\n          <Input\n            value={name}\n            onChange={(e) => setName(e.target.value)}\n            placeholder={`Enter ${itemType} name...`}\n            className='my-4'\n            autoFocus\n          />\n          <DialogFooter>\n            <Button\n              type='button'\n              variant='ghost'\n              onClick={onClose}\n              disabled={isLoading}\n            >\n              Cancel\n            </Button>\n            <Button type='submit' disabled={isLoading || !name.trim()}>\n              {isLoading ? \"Creating...\" : \"Create\"}\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/client/src/features/code/FileEditor.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { useIdeStore } from \"@/entities/file/store\";\nimport Editor, { loader } from \"@monaco-editor/react\";\nimport { Loader2 } from \"lucide-react\";\nimport * as monaco from \"monaco-editor\";\n\nloader.config({ monaco });\n\nconst getLanguageFromPath = (path: string) => {\n  const extension = path.split(\".\").pop()?.toLowerCase();\n  switch (extension) {\n    case \"js\":\n      return \"javascript\";\n    case \"ts\":\n      return \"typescript\";\n    case \"json\":\n      return \"json\";\n    case \"css\":\n      return \"css\";\n    case \"html\":\n      return \"html\";\n    case \"md\":\n      return \"markdown\";\n    case \"py\":\n      return \"python\";\n    case \"rs\":\n      return \"rust\";\n    case \"go\":\n      return \"go\";\n    case \"java\":\n      return \"java\";\n    default:\n      return \"plaintext\";\n  }\n};\n\nexport const FileEditor = () => {\n  const {\n    activeFilePath,\n    activeFileContent,\n    updateActiveFileContent,\n    saveActiveFile,\n    isDirty,\n    isLoading,\n  } = useIdeStore();\n\n  if (!activeFilePath) {\n    return (\n      <div className='flex items-center justify-center h-full bg-[#1e1e1e] text-muted-foreground'>\n        Select a file to begin editing.\n      </div>\n    );\n  }\n\n  const handleEditorChange = (value: string | undefined) => {\n    updateActiveFileContent(value || \"\");\n  };\n\n  return (\n    <div className='flex flex-col h-full bg-[#1e1e1e]'>\n      <div className='flex items-center justify-between p-2 border-b border-neutral-700 bg-[#252526]'>\n        <h2 className='text-sm font-medium text-gray-300'>\n          {activeFilePath}{\" \"}\n          {isDirty && <span className='text-yellow-400'>*</span>}\n        </h2>\n        <Button\n          onClick={saveActiveFile}\n          disabled={!isDirty || isLoading}\n          size='sm'\n        >\n          {isLoading ? \"Saving...\" : \"Save\"}\n        </Button>\n      </div>\n      <div className='flex-grow w-full h-full'>\n        <Editor\n          path={activeFilePath}\n          language={getLanguageFromPath(activeFilePath)}\n          value={activeFileContent}\n          onChange={handleEditorChange}\n          theme='vs-dark'\n          loading={<Loader2 className='w-8 h-8 mx-auto mt-10 animate-spin' />}\n          options={{\n            minimap: { enabled: true },\n            fontSize: 14,\n            wordWrap: \"on\",\n            scrollBeyondLastLine: false,\n          }}\n        />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/client/src/features/code/FileTree.tsx",
    "content": "import React, { useState, useEffect, useRef } from \"react\";\nimport {\n  ChevronRight,\n  Folder,\n  File as FileIcon,\n  Loader2,\n  FolderPlus,\n  FilePlus,\n  Trash2,\n  FilePenLine,\n} from \"lucide-react\";\nimport {\n  getDirectoryListing,\n  createNewFile,\n  createNewFolder,\n  renameEntry,\n  deleteEntry,\n} from \"@/entities/file/api\";\nimport { toast } from \"sonner\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n  ContextMenu,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuTrigger,\n} from \"@/components/ui/context-menu\";\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { useFileTreeStore, useIdeStore } from \"@/entities/file/store\";\nimport { DirEntry } from \"@/entities/file/types\";\nimport { CreateItemDialog } from \"./CreateItemDialog\";\n\ninterface TreeNodeProps {\n  entry: DirEntry;\n  onDeleteRequest: (entry: DirEntry) => void;\n}\n\nconst TreeNode: React.FC<TreeNodeProps> = ({ entry, onDeleteRequest }) => {\n  const [isOpen, setIsOpen] = useState(false);\n  const [children, setChildren] = useState<DirEntry[]>([]);\n  const [isLoading, setIsLoading] = useState(false);\n  const [isRenaming, setIsRenaming] = useState(false);\n  const [newName, setNewName] = useState(entry.name);\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  const openFile = useIdeStore((state) => state.openFile);\n  const { treeVersion, refreshFileTree } = useFileTreeStore();\n\n  useEffect(() => {\n    if (isRenaming) {\n      inputRef.current?.focus();\n      inputRef.current?.select();\n    }\n  }, [isRenaming]);\n\n  const fetchChildren = async () => {\n    setIsLoading(true);\n    try {\n      const childEntries = await getDirectoryListing(entry.path);\n      setChildren(childEntries);\n    } catch {\n      toast.error(`Failed to load directory: ${entry.name}`);\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  const handleToggle = async () => {\n    if (isRenaming) return;\n    if (!entry.isDir) {\n      openFile(entry.path);\n      return;\n    }\n    if (isOpen) {\n      setIsOpen(false);\n    } else {\n      setIsOpen(true);\n      if (children.length === 0) {\n        await fetchChildren();\n      }\n    }\n  };\n\n  useEffect(() => {\n    if (isOpen && entry.isDir) {\n      fetchChildren();\n    }\n  }, [treeVersion, isOpen, entry.isDir, entry.path]);\n\n  const handleRename = async () => {\n    if (newName === entry.name || !newName.trim()) {\n      setIsRenaming(false);\n      setNewName(entry.name);\n      return;\n    }\n    try {\n      const parentPath = entry.path.substring(\n        0,\n        entry.path.lastIndexOf(\"/\") + 1,\n      );\n      const newPath = `${parentPath}${newName}`;\n      await renameEntry(entry.path, newPath);\n      toast.success(`Renamed to '${newName}'`);\n      refreshFileTree();\n    } catch (error) {\n      toast.error(`Failed to rename: ${error}`);\n      setNewName(entry.name);\n    } finally {\n      setIsRenaming(false);\n    }\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === \"Enter\") {\n      handleRename();\n    } else if (e.key === \"Escape\") {\n      setIsRenaming(false);\n      setNewName(entry.name);\n    }\n  };\n\n  const Icon = entry.isDir ? Folder : FileIcon;\n\n  return (\n    <div className='text-sm'>\n      <ContextMenu>\n        <ContextMenuTrigger>\n          <div\n            className='flex items-center p-1 cursor-pointer hover:bg-muted'\n            onClick={handleToggle}\n          >\n            {entry.isDir ? (\n              <ChevronRight\n                className={`w-4 h-4 mr-0 transition-transform ${\n                  isOpen ? \"rotate-90\" : \"\"\n                }`}\n              />\n            ) : (\n              <div className='w-4 mr-1' />\n            )}\n            <Icon className='w-4 h-4 mr-2' />\n            {isRenaming ? (\n              <Input\n                ref={inputRef}\n                value={newName}\n                onChange={(e) => setNewName(e.target.value)}\n                onBlur={handleRename}\n                onKeyDown={handleKeyDown}\n                className='h-6 px-1 text-sm'\n                onClick={(e) => e.stopPropagation()}\n              />\n            ) : (\n              <span>{entry.name}</span>\n            )}\n            {isLoading && <Loader2 className='w-4 h-4 ml-auto animate-spin' />}\n          </div>\n        </ContextMenuTrigger>\n        <ContextMenuContent>\n          <ContextMenuItem onSelect={() => setIsRenaming(true)}>\n            <FilePenLine className='w-4 h-4 mr-2' />\n            Rename\n          </ContextMenuItem>\n          <ContextMenuItem\n            onSelect={() => onDeleteRequest(entry)}\n            className='text-red-500 focus:text-red-500'\n          >\n            <Trash2 className='w-4 h-4 mr-2' />\n            Delete\n          </ContextMenuItem>\n        </ContextMenuContent>\n      </ContextMenu>\n\n      {isOpen && (\n        <div className='pl-4 border-l border-muted-foreground/20'>\n          {children.map((child) => (\n            <TreeNode\n              key={child.path}\n              entry={child}\n              onDeleteRequest={onDeleteRequest}\n            />\n          ))}\n        </div>\n      )}\n    </div>\n  );\n};\n\ninterface ConfirmDeleteDialogProps {\n  item: DirEntry | null;\n  onClose: () => void;\n  onConfirm: (item: DirEntry) => Promise<void>;\n}\n\nconst ConfirmDeleteDialog: React.FC<ConfirmDeleteDialogProps> = ({\n  item,\n  onClose,\n  onConfirm,\n}) => {\n  if (!item) return null;\n\n  return (\n    <AlertDialog open={!!item} onOpenChange={onClose}>\n      <AlertDialogContent>\n        <AlertDialogHeader>\n          <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>\n          <AlertDialogDescription>\n            This action cannot be undone. This will permanently delete the{\" \"}\n            {item.isDir ? \"folder\" : \"file\"}\n            <strong className='mx-1'>{item.name}</strong>\n            and all its contents.\n          </AlertDialogDescription>\n        </AlertDialogHeader>\n        <AlertDialogFooter>\n          <AlertDialogCancel onClick={onClose}>Cancel</AlertDialogCancel>\n          <AlertDialogAction onClick={() => onConfirm(item)}>\n            Delete\n          </AlertDialogAction>\n        </AlertDialogFooter>\n      </AlertDialogContent>\n    </AlertDialog>\n  );\n};\n\nexport const FileTree = () => {\n  const [rootEntries, setRootEntries] = useState<DirEntry[]>([]);\n  const [isLoading, setIsLoading] = useState(true);\n  const [dialogOpen, setDialogOpen] = useState<\"file\" | \"folder\" | null>(null);\n  const [deleteTarget, setDeleteTarget] = useState<DirEntry | null>(null);\n\n  const { treeVersion, refreshFileTree } = useFileTreeStore();\n\n  const fetchRoot = async () => {\n    setIsLoading(true);\n    try {\n      const entries = await getDirectoryListing(\"\");\n      setRootEntries(entries);\n    } catch {\n      toast.error(\"Failed to load root directory.\");\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  useEffect(() => {\n    fetchRoot();\n  }, [treeVersion]);\n\n  const handleCreate = async (name: string, type: \"file\" | \"folder\") => {\n    try {\n      if (type === \"file\") {\n        await createNewFile(name);\n      } else {\n        await createNewFolder(name);\n      }\n      toast.success(`${type} '${name}' created successfully.`);\n      refreshFileTree();\n    } catch {\n      toast.error(`Failed to create ${type}.`);\n    }\n  };\n\n  const handleDelete = async (item: DirEntry) => {\n    try {\n      await deleteEntry(item.path);\n      toast.success(`'${item.name}' deleted successfully.`);\n      refreshFileTree();\n    } catch (error) {\n      toast.error(`Failed to delete '${item.name}': ${error}`);\n    } finally {\n      setDeleteTarget(null);\n    }\n  };\n\n  return (\n    <div className='p-2'>\n      <div className='flex items-center justify-between mb-2'>\n        <p className='font-bold text-sm ps-2'>Project</p>\n        <div className='flex items-center gap-1'>\n          <Button\n            variant='ghost'\n            size='icon'\n            onClick={() => setDialogOpen(\"folder\")}\n          >\n            <FolderPlus className='w-4 h-4' />\n          </Button>\n          <Button\n            variant='ghost'\n            size='icon'\n            onClick={() => setDialogOpen(\"file\")}\n          >\n            <FilePlus className='w-4 h-4' />\n          </Button>\n        </div>\n      </div>\n\n      {isLoading ? (\n        <div className='p-4 text-sm'>Loading project...</div>\n      ) : (\n        rootEntries.map((entry) => (\n          <TreeNode\n            key={entry.path}\n            entry={entry}\n            onDeleteRequest={setDeleteTarget}\n          />\n        ))\n      )}\n\n      <CreateItemDialog\n        isOpen={dialogOpen !== null}\n        onClose={() => setDialogOpen(null)}\n        itemType={dialogOpen || \"file\"}\n        onCreate={(name) => handleCreate(name, dialogOpen!)}\n      />\n\n      <ConfirmDeleteDialog\n        item={deleteTarget}\n        onClose={() => setDeleteTarget(null)}\n        onConfirm={handleDelete}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/client/src/features/configurations/ConfigurationActionButton.tsx",
    "content": "import { useState } from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { MoreHorizontal, Pencil, Trash2 } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { useConfigStore } from \"@/entities/configurations/store\";\nimport {\n  SystemConfiguration,\n  SystemConfigurationPayload,\n} from \"@/entities/configurations/types\";\n\ninterface Props {\n  config: SystemConfiguration;\n}\n\nexport function ConfigurationActionButton({ config }: Props) {\n  const [isEditOpen, setIsEditOpen] = useState(false);\n  const [isDeleteOpen, setIsDeleteOpen] = useState(false);\n  const { updateConfig, deleteConfig } = useConfigStore();\n\n  const { register, handleSubmit, control } =\n    useForm<SystemConfigurationPayload>({\n      defaultValues: {\n        key: config.key,\n        value: config.value,\n        description: config.description ?? \"\",\n        enabled: config.enabled,\n      },\n    });\n\n  const onUpdate = async (data: SystemConfigurationPayload) => {\n    await updateConfig(config.id, data);\n    setIsEditOpen(false);\n  };\n\n  const onDelete = async () => {\n    await deleteConfig(config.id);\n    setIsDeleteOpen(false);\n  };\n\n  return (\n    <>\n      {/* Edit Dialog */}\n      <Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>\n        <DropdownMenu>\n          <DropdownMenuTrigger asChild>\n            <Button variant='ghost' className='h-8 w-8 p-0'>\n              <span className='sr-only'>Open menu</span>\n              <MoreHorizontal className='h-4 w-4' />\n            </Button>\n          </DropdownMenuTrigger>\n          <DropdownMenuContent align='end'>\n            <DialogTrigger asChild>\n              <DropdownMenuItem>\n                <Pencil className='mr-2 h-4 w-4' />\n                <span>Edit</span>\n              </DropdownMenuItem>\n            </DialogTrigger>\n            <DropdownMenuItem\n              onClick={() => setIsDeleteOpen(true)}\n              className='text-red-500 focus:text-red-500'\n            >\n              <Trash2 className='mr-2 h-4 w-4 text-red-500' />\n              <span>Delete</span>\n            </DropdownMenuItem>\n          </DropdownMenuContent>\n        </DropdownMenu>\n\n        <DialogContent>\n          <DialogHeader>\n            <DialogTitle>Edit: {config.key}</DialogTitle>\n          </DialogHeader>\n          <form onSubmit={handleSubmit(onUpdate)} className='space-y-4 py-2'>\n            <div className='space-y-2'>\n              <Label htmlFor='key'>Key</Label>\n              <Input id='key' {...register(\"key\", { required: true })} />\n            </div>\n            <div className='space-y-2'>\n              <Label htmlFor='value'>Value</Label>\n              <Textarea\n                id='value'\n                {...register(\"value\", { required: true })}\n                className='break-all'\n              />\n            </div>\n            <div className='space-y-2'>\n              <Label htmlFor='description'>Description</Label>\n              <Input id='description' {...register(\"description\")} />\n            </div>\n            <div className='flex items-center space-x-2'>\n              <Controller\n                name='enabled'\n                control={control}\n                render={({ field }) => (\n                  <Switch\n                    id='enabled'\n                    checked={field.value === 1}\n                    onCheckedChange={(checked) =>\n                      field.onChange(checked ? 1 : 0)\n                    }\n                  />\n                )}\n              />\n              <Label htmlFor='enabled'>Enabled</Label>\n            </div>\n            <DialogFooter>\n              <Button\n                type='button'\n                variant='outline'\n                onClick={() => setIsEditOpen(false)}\n              >\n                Cancel\n              </Button>\n              <Button type='submit'>Save Changes</Button>\n            </DialogFooter>\n          </form>\n        </DialogContent>\n      </Dialog>\n\n      {/* Delete Alert Dialog */}\n      <AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>\n            <AlertDialogDescription>\n              This action cannot be undone. This will permanently delete the\n              <span className='font-bold'> {config.key} </span>\n              configuration.\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel>Cancel</AlertDialogCancel>\n            <AlertDialogAction onClick={onDelete}>Continue</AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/configurations/ConfigurationCreate.tsx",
    "content": "import { Controller, useForm } from \"react-hook-form\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { useConfigStore } from \"@/entities/configurations/store\";\nimport { SystemConfigurationPayload } from \"@/entities/configurations/types\";\n\nexport function ConfigurationCreate({\n  isOpen,\n  onOpenChange,\n}: {\n  isOpen: boolean;\n  onOpenChange: (open: boolean) => void;\n}) {\n  const createConfig = useConfigStore((state) => state.createConfig);\n  const { register, handleSubmit, reset, control } =\n    useForm<SystemConfigurationPayload>({\n      defaultValues: {\n        key: \"\",\n        value: \"\",\n        description: \"\",\n        enabled: 1,\n      },\n    });\n\n  const onSubmit = async (data: SystemConfigurationPayload) => {\n    await createConfig(data);\n    onOpenChange(false);\n    reset();\n  };\n\n  const handleOpenChange = (open: boolean) => {\n    onOpenChange(open);\n  };\n\n  return (\n    <Dialog open={isOpen} onOpenChange={handleOpenChange}>\n      <DialogContent>\n        <DialogHeader>\n          <DialogTitle>Add New System Configuration</DialogTitle>\n        </DialogHeader>\n        <form onSubmit={handleSubmit(onSubmit)} className='space-y-4 py-2'>\n          <div className='space-y-2'>\n            <Label htmlFor='key'>Key</Label>\n            <Input id='key' {...register(\"key\", { required: true })} />\n          </div>\n          <div className='space-y-2'>\n            <Label htmlFor='value'>Value</Label>\n            <Textarea\n              id='value'\n              {...register(\"value\", { required: true })}\n              className='break-all'\n            />\n          </div>\n          <div className='space-y-2'>\n            <Label htmlFor='description'>Description</Label>\n            <Input id='description' {...register(\"description\")} />\n          </div>\n          <div className='flex items-center space-x-2'>\n            <Controller\n              name='enabled'\n              control={control}\n              render={({ field }) => (\n                <Switch\n                  id='enabled'\n                  checked={field.value === 1}\n                  onCheckedChange={(checked) => field.onChange(checked ? 1 : 0)}\n                />\n              )}\n            />\n            <Label htmlFor='enabled'>Enabled</Label>\n          </div>\n          <DialogFooter>\n            <Button\n              type='button'\n              variant='outline'\n              onClick={() => onOpenChange(false)}\n            >\n              Cancel\n            </Button>\n            <Button type='submit'>Save</Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/configurations/ConfigurationCreateButton.tsx",
    "content": "import { useState } from \"react\";\nimport { ConfigurationCreate } from \"./ConfigurationCreate\";\nimport { Button } from \"@/components/ui/button\";\nimport { PlusCircle } from \"lucide-react\";\n\nexport function ConfigurationCreateButton() {\n  const [isOpen, setIsOpen] = useState(false);\n\n  return (\n    <>\n      <Button size={\"sm\"} variant='outline' onClick={() => setIsOpen(true)}>\n        <PlusCircle className='mr-2 h-4 w-4' />\n        Add Configuration\n      </Button>\n      <ConfigurationCreate isOpen={isOpen} onOpenChange={setIsOpen} />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/darkmode/mode-toggle.tsx",
    "content": "import { Moon, Sun } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { useTheme } from \"@/app/providers/theme-provider\";\n\nexport function ModeToggle() {\n  const { setTheme } = useTheme();\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <Button variant=\"outline\" size=\"icon\">\n          <Sun className=\"h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90\" />\n          <Moon className=\"absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0\" />\n          <span className=\"sr-only\">Toggle theme</span>\n        </Button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\">\n        <DropdownMenuItem onClick={() => setTheme(\"light\")}>\n          Light\n        </DropdownMenuItem>\n        <DropdownMenuItem onClick={() => setTheme(\"dark\")}>\n          Dark\n        </DropdownMenuItem>\n        <DropdownMenuItem onClick={() => setTheme(\"system\")}>\n          System\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/dashboard-swipe/DashboardSwipeHeader.tsx",
    "content": "import {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport {\n  Breadcrumb,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbList,\n  BreadcrumbSeparator,\n} from \"@/components/ui/breadcrumb\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { SidebarTrigger } from \"@/components/ui/sidebar\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { useDynamicDashboardStore } from \"@/entities/dynamic-dashboard/store\";\nimport { useIntegrationStore } from \"@/entities/integrations/store\";\nimport { MoreVertical, Pencil, Plus, Trash2 } from \"lucide-react\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport {\n  useLocation,\n  useNavigate,\n  useSearchParams,\n} from \"react-router\";\n\nexport function DashboardSwipeHeader() {\n  const location = useLocation();\n  const navigate = useNavigate();\n  const [searchParams, setSearchParams] = useSearchParams();\n\n  const {\n    dashboards,\n    setActiveDashboard: setActiveDashboardStore,\n    deleteDashboard,\n    updateDashboardMeta,\n  } = useDynamicDashboardStore();\n  const setActiveDashboard = setActiveDashboardStore;\n\n  const { isHaConnected, isRos2Connected, isSdrConnected } =\n    useIntegrationStore();\n\n  const integrationDashboards = useMemo(() => {\n    return [\n      { id: \"main\", name: \"Dashboard\" },\n      isHaConnected && { id: \"ha\", name: \"Home Assistant\" },\n      isRos2Connected && { id: \"ros2\", name: \"ROS2\" },\n      isSdrConnected && { id: \"sdr\", name: \"RTL-SDR\" },\n    ].filter(Boolean) as { id: string; name: string }[];\n  }, [isHaConnected, isRos2Connected, isSdrConnected]);\n\n  const initialView = searchParams.get(\"view\") || \"main\";\n  const [selectedDashboard, setSelectedDashboard] = useState(initialView);\n\n  useEffect(() => {\n    const view = searchParams.get(\"view\");\n    if (view && view !== selectedDashboard) {\n      setSelectedDashboard(view);\n    }\n  }, [searchParams, selectedDashboard]);\n\n  const pathname = location.pathname;\n  const n = dashboards.length;\n\n  const isNewDynamicPanel =\n    pathname === \"/dynamic-dashboard/new\" ||\n    (pathname === \"/dynamic-dashboard\" && n === 0);\n\n  const isMainDashboardRoute = pathname === \"/dashboard\";\n\n  const dynamicHeaderDashboardId = useMemo(() => {\n    if (isNewDynamicPanel || isMainDashboardRoute) return null;\n    if (pathname === \"/dynamic-dashboard\" && n > 0) {\n      return dashboards[0]?.id ?? null;\n    }\n    const m = pathname.match(/^\\/dynamic-dashboard\\/([^/]+)$/);\n    if (!m || m[1] === \"new\") return null;\n    return m[1];\n  }, [isNewDynamicPanel, isMainDashboardRoute, pathname, n, dashboards]);\n\n  const currentDynamicDashboard = useMemo(\n    () => dashboards.find((d) => d.id === dynamicHeaderDashboardId),\n    [dashboards, dynamicHeaderDashboardId],\n  );\n\n  const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);\n  const [renameOpen, setRenameOpen] = useState(false);\n  const [renameDraft, setRenameDraft] = useState(\"\");\n\n  const handleAddDashboard = () => {\n    navigate(\"/dynamic-dashboard\");\n  };\n\n  const handleSelectIntegration = (value: string) => {\n    setSelectedDashboard(value);\n    setSearchParams({ view: value }, { replace: true });\n  };\n\n  const handleSelectDynamicDashboard = (id: string) => {\n    setActiveDashboard(id);\n    navigate(`/dynamic-dashboard/${id}`);\n  };\n\n  const handleDeleteDashboard = async () => {\n    if (!currentDynamicDashboard) {\n      setDeleteConfirmOpen(false);\n      return;\n    }\n\n    const remaining = dashboards.filter(\n      (d) => d.id !== currentDynamicDashboard.id,\n    );\n    await deleteDashboard(currentDynamicDashboard.id);\n    setDeleteConfirmOpen(false);\n\n    const nextId = remaining[0]?.id;\n    if (nextId) {\n      navigate(`/dynamic-dashboard/${nextId}`);\n    } else {\n      navigate(\"/dynamic-dashboard/new\");\n    }\n  };\n\n  const openRenameDialog = () => {\n    if (currentDynamicDashboard) {\n      setRenameDraft(currentDynamicDashboard.name);\n      setRenameOpen(true);\n    }\n  };\n\n  const handleRenameSave = () => {\n    const name = renameDraft.trim();\n    if (!name || !currentDynamicDashboard) return;\n    updateDashboardMeta(currentDynamicDashboard.id, { name });\n    setRenameOpen(false);\n  };\n\n  const showDynamicChrome =\n    !isMainDashboardRoute &&\n    !isNewDynamicPanel &&\n    dynamicHeaderDashboardId != null;\n\n  return (\n    <>\n      <header className='flex h-12 shrink-0 items-center gap-2 border-b px-4'>\n        <SidebarTrigger className='-ml-1' />\n        <Separator\n          orientation='vertical'\n          className='mr-2 data-[orientation=vertical]:h-4'\n        />\n        {isMainDashboardRoute && (\n          <Breadcrumb>\n            <BreadcrumbList>\n              <BreadcrumbItem className='hidden md:block'>\n                <BreadcrumbLink href='#'>/</BreadcrumbLink>\n              </BreadcrumbItem>\n              <BreadcrumbSeparator className='hidden md:block' />\n              <BreadcrumbItem>\n                <div className='flex items-center gap-2'>\n                  <Select\n                    value={selectedDashboard}\n                    onValueChange={handleSelectIntegration}\n                  >\n                    <SelectTrigger\n                      size='sm'\n                      className='w-[180px] focus:ring-0'\n                    >\n                      <SelectValue placeholder='Select a dashboard' />\n                    </SelectTrigger>\n                    <SelectContent>\n                      {integrationDashboards.map((dashboard) => (\n                        <SelectItem key={dashboard.id} value={dashboard.id}>\n                          {dashboard.name}\n                        </SelectItem>\n                      ))}\n                    </SelectContent>\n                  </Select>\n                  <Button\n                    variant='ghost'\n                    size='icon'\n                    onClick={handleAddDashboard}\n                  >\n                    <Plus className='h-4 w-4' />\n                  </Button>\n                </div>\n              </BreadcrumbItem>\n            </BreadcrumbList>\n          </Breadcrumb>\n        )}\n        {isNewDynamicPanel && (\n          <span className='text-sm font-medium text-muted-foreground'>\n            New canvas\n          </span>\n        )}\n        {showDynamicChrome && (\n          <>\n            <Breadcrumb>\n              <BreadcrumbList>\n                <BreadcrumbItem className='hidden md:block'>\n                  <BreadcrumbLink href='#'>/</BreadcrumbLink>\n                </BreadcrumbItem>\n                <BreadcrumbSeparator className='hidden md:block' />\n                <BreadcrumbItem>\n                  <div className='flex items-center gap-2'>\n                    <Select\n                      value={dynamicHeaderDashboardId}\n                      onValueChange={handleSelectDynamicDashboard}\n                    >\n                      <SelectTrigger size='sm' className='w-[200px] focus:ring-0'>\n                        <SelectValue placeholder='Select dashboard' />\n                      </SelectTrigger>\n                      <SelectContent>\n                        {dashboards.map((dashboard) => (\n                          <SelectItem key={dashboard.id} value={dashboard.id}>\n                            {dashboard.name}\n                          </SelectItem>\n                        ))}\n                      </SelectContent>\n                    </Select>\n                    {currentDynamicDashboard && (\n                      <DropdownMenu>\n                        <DropdownMenuTrigger asChild>\n                          <Button\n                            variant='ghost'\n                            size='icon'\n                            aria-label='Dashboard actions'\n                          >\n                            <MoreVertical className='h-4 w-4' />\n                          </Button>\n                        </DropdownMenuTrigger>\n                        <DropdownMenuContent align='start'>\n                          <DropdownMenuItem onSelect={openRenameDialog}>\n                            <Pencil className='h-4 w-4' />\n                            Rename\n                          </DropdownMenuItem>\n                          <DropdownMenuItem\n                            variant='destructive'\n                            onSelect={() => setDeleteConfirmOpen(true)}\n                          >\n                            <Trash2 className='h-4 w-4' />\n                            Delete\n                          </DropdownMenuItem>\n                        </DropdownMenuContent>\n                      </DropdownMenu>\n                    )}\n                  </div>\n                </BreadcrumbItem>\n              </BreadcrumbList>\n            </Breadcrumb>\n          </>\n        )}\n      </header>\n\n      <Dialog open={renameOpen} onOpenChange={setRenameOpen}>\n        <DialogContent>\n          <DialogHeader>\n            <DialogTitle>Rename dashboard</DialogTitle>\n          </DialogHeader>\n          <div className='space-y-2 py-2'>\n            <Label htmlFor='dashboard-rename-swipe'>Name</Label>\n            <Input\n              id='dashboard-rename-swipe'\n              value={renameDraft}\n              onChange={(e) => setRenameDraft(e.target.value)}\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\") {\n                  e.preventDefault();\n                  handleRenameSave();\n                }\n              }}\n            />\n          </div>\n          <DialogFooter>\n            <Button variant='outline' onClick={() => setRenameOpen(false)}>\n              Cancel\n            </Button>\n            <Button onClick={handleRenameSave} disabled={!renameDraft.trim()}>\n              Save\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n\n      <AlertDialog\n        open={deleteConfirmOpen}\n        onOpenChange={(open) => setDeleteConfirmOpen(open)}\n      >\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>Delete dashboard?</AlertDialogTitle>\n            <AlertDialogDescription>\n              This action cannot be undone. The dashboard layout will be\n              removed.\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel onClick={() => setDeleteConfirmOpen(false)}>\n              Cancel\n            </AlertDialogCancel>\n            <AlertDialogAction onClick={handleDeleteDashboard}>\n              Delete\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/dashboard-swipe/DashboardSwipeLayout.tsx",
    "content": "import { Footer } from \"@/features/footer\";\nimport { AppSidebar } from \"@/features/sidebar\";\nimport { WebRTCProvider } from \"@/features/rtc/WebRTCProvider\";\nimport {\n  SidebarInset,\n  SidebarProvider,\n} from \"@/components/ui/sidebar\";\nimport {\n  DashboardMainPanel,\n  type DashboardMainPanelContentView,\n} from \"@/pages/dashboard\";\nimport {\n  DynamicDashboardMainPanel,\n  NewDynamicDashboardPanel,\n} from \"@/pages/dynamic-dashboard\";\nimport type { DynamicDashboard } from \"@/entities/dynamic-dashboard/store\";\nimport { useDynamicDashboardStore } from \"@/entities/dynamic-dashboard/store\";\nimport { useIntegrationStore } from \"@/entities/integrations/store\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { Outlet, useLocation, useNavigate } from \"react-router\";\nimport { DashboardSwipeHeader } from \"./DashboardSwipeHeader\";\n\n/** Child route element so nested paths match without rendering extra UI. */\nexport function DashboardSwipeRoutePlaceholder() {\n  return null;\n}\n\nfunction maxPanelIndex(\n  integrationCount: number,\n  dashboards: DynamicDashboard[],\n): number {\n  return integrationCount + dashboards.length + 1;\n}\n\nfunction panelIndexFromPath(\n  pathname: string,\n  search: string,\n  dashboards: DynamicDashboard[],\n  activeIntegrations: DashboardMainPanelContentView[],\n): number {\n  const n = dashboards.length;\n  const k = activeIntegrations.length;\n  const lastIdx = maxPanelIndex(k, dashboards);\n\n  if (pathname === \"/dashboard\") {\n    const view = new URLSearchParams(search).get(\"view\") || \"main\";\n    if (view === \"main\") {\n      return 0;\n    }\n    const i = activeIntegrations.indexOf(view as DashboardMainPanelContentView);\n    if (i >= 0) {\n      return 1 + i;\n    }\n    return 0;\n  }\n  if (pathname === \"/dynamic-dashboard/new\") {\n    return lastIdx;\n  }\n  if (pathname === \"/dynamic-dashboard\") {\n    return n > 0 ? k + 1 : lastIdx;\n  }\n  const prefix = \"/dynamic-dashboard/\";\n  if (pathname.startsWith(prefix)) {\n    const rest = pathname.slice(prefix.length);\n    if (rest === \"new\") {\n      return lastIdx;\n    }\n    const i = dashboards.findIndex((d) => d.id === rest);\n    if (i >= 0) {\n      return k + 1 + i;\n    }\n  }\n  return 0;\n}\n\nconst INTEGRATION_VIEW_IDS: DashboardMainPanelContentView[] = [\n  \"ha\",\n  \"ros2\",\n  \"sdr\",\n];\n\nexport function DashboardSwipeLayout() {\n  const location = useLocation();\n  const navigate = useNavigate();\n  const scrollRef = useRef<HTMLDivElement | null>(null);\n  const syncingRef = useRef(false);\n  const scrollEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const [containerWidth, setContainerWidth] = useState(0);\n\n  const dashboards = useDynamicDashboardStore((s) => s.dashboards);\n  const setActiveDashboard = useDynamicDashboardStore(\n    (s) => s.setActiveDashboard,\n  );\n  const loadDashboards = useDynamicDashboardStore((s) => s.loadDashboards);\n  const hasLoaded = useDynamicDashboardStore((s) => s.hasLoaded);\n  const isLoading = useDynamicDashboardStore((s) => s.isLoading);\n\n  const { isHaConnected, isRos2Connected, isSdrConnected, fetchStatus } =\n    useIntegrationStore();\n\n  const activeIntegrations = useMemo(() => {\n    const flags = [isHaConnected, isRos2Connected, isSdrConnected];\n    return INTEGRATION_VIEW_IDS.filter((_, i) => flags[i]);\n  }, [isHaConnected, isRos2Connected, isSdrConnected]);\n\n  const k = activeIntegrations.length;\n  const n = dashboards.length;\n  const lastIdx = maxPanelIndex(k, dashboards);\n\n  const panelIndex = useMemo(\n    () =>\n      panelIndexFromPath(\n        location.pathname,\n        location.search,\n        dashboards,\n        activeIntegrations,\n      ),\n    [location.pathname, location.search, dashboards, activeIntegrations],\n  );\n\n  useEffect(() => {\n    fetchStatus();\n  }, [fetchStatus]);\n\n  useEffect(() => {\n    if (!hasLoaded && !isLoading) {\n      loadDashboards();\n    }\n  }, [hasLoaded, isLoading, loadDashboards]);\n\n  useEffect(() => {\n    if (location.pathname !== \"/dashboard\") {\n      return;\n    }\n    const view = new URLSearchParams(location.search).get(\"view\") || \"main\";\n    if (view === \"main\") {\n      return;\n    }\n    const validIntegration = activeIntegrations.includes(\n      view as DashboardMainPanelContentView,\n    );\n    if (!validIntegration) {\n      navigate({ pathname: \"/dashboard\", search: \"view=main\" }, { replace: true });\n    }\n  }, [\n    location.pathname,\n    location.search,\n    activeIntegrations,\n    navigate,\n  ]);\n\n  useEffect(() => {\n    if (!hasLoaded || isLoading) {\n      return;\n    }\n    const m = location.pathname.match(/^\\/dynamic-dashboard\\/([^/]+)$/);\n    if (!m || m[1] === \"new\") {\n      return;\n    }\n    const exists = dashboards.some((d) => d.id === m[1]);\n    if (!exists) {\n      if (dashboards.length > 0) {\n        navigate(`/dynamic-dashboard/${dashboards[0].id}`, { replace: true });\n      } else {\n        navigate(\"/dynamic-dashboard/new\", { replace: true });\n      }\n    }\n  }, [dashboards, hasLoaded, isLoading, location.pathname, navigate]);\n\n  useEffect(() => {\n    const m = location.pathname.match(/^\\/dynamic-dashboard\\/([^/]+)$/);\n    if (m && m[1] !== \"new\") {\n      setActiveDashboard(m[1]);\n    }\n  }, [location.pathname, setActiveDashboard]);\n\n  useEffect(() => {\n    const el = scrollRef.current;\n    if (!el) return;\n\n    const ro = new ResizeObserver((entries) => {\n      const w = entries[0]?.contentRect.width;\n      if (w && w > 0) {\n        setContainerWidth(w);\n      }\n    });\n    ro.observe(el);\n    setContainerWidth(el.clientWidth);\n    return () => ro.disconnect();\n  }, []);\n\n  useEffect(() => {\n    const el = scrollRef.current;\n    if (!el || containerWidth < 1) return;\n\n    syncingRef.current = true;\n    const target = panelIndex * containerWidth;\n    el.scrollTo({ left: target, behavior: \"auto\" });\n\n    const t = window.requestAnimationFrame(() => {\n      syncingRef.current = false;\n    });\n    return () => window.cancelAnimationFrame(t);\n  }, [panelIndex, containerWidth, location.pathname, location.search]);\n\n  const resolveScrollToUrl = useCallback(() => {\n    const el = scrollRef.current;\n    if (!el || containerWidth < 1 || syncingRef.current) return;\n\n    const idx = Math.round(el.scrollLeft / containerWidth);\n    const clamped = Math.max(0, Math.min(lastIdx, idx));\n\n    if (clamped === 0) {\n      const viewParam = new URLSearchParams(location.search).get(\"view\");\n      const isMainView = viewParam === \"main\" || viewParam === null;\n      if (location.pathname !== \"/dashboard\" || !isMainView) {\n        navigate({ pathname: \"/dashboard\", search: \"view=main\" }, { replace: true });\n      }\n      return;\n    }\n\n    if (clamped >= 1 && clamped <= k) {\n      const view = activeIntegrations[clamped - 1];\n      const params = new URLSearchParams(location.search);\n      if (\n        location.pathname !== \"/dashboard\" ||\n        params.get(\"view\") !== view\n      ) {\n        navigate(\n          { pathname: \"/dashboard\", search: `view=${view}` },\n          { replace: true },\n        );\n      }\n      return;\n    }\n\n    if (clamped === lastIdx) {\n      if (location.pathname !== \"/dynamic-dashboard/new\") {\n        navigate(\"/dynamic-dashboard/new\", { replace: true });\n      }\n      return;\n    }\n\n    if (clamped >= k + 1 && clamped <= k + n) {\n      const id = dashboards[clamped - k - 1]?.id;\n      if (id && location.pathname !== `/dynamic-dashboard/${id}`) {\n        navigate(`/dynamic-dashboard/${id}`, { replace: true });\n      }\n    }\n  }, [\n    activeIntegrations,\n    containerWidth,\n    dashboards,\n    k,\n    lastIdx,\n    location.pathname,\n    location.search,\n    n,\n    navigate,\n  ]);\n\n  useEffect(() => {\n    const el = scrollRef.current;\n    if (!el) return;\n\n    const onScroll = () => {\n      if (syncingRef.current) return;\n      if (scrollEndTimerRef.current) {\n        clearTimeout(scrollEndTimerRef.current);\n      }\n      scrollEndTimerRef.current = setTimeout(() => {\n        resolveScrollToUrl();\n      }, 40);\n    };\n\n    el.addEventListener(\"scroll\", onScroll, { passive: true });\n    return () => {\n      el.removeEventListener(\"scroll\", onScroll);\n      if (scrollEndTimerRef.current) {\n        clearTimeout(scrollEndTimerRef.current);\n      }\n    };\n  }, [resolveScrollToUrl]);\n\n  return (\n    <WebRTCProvider>\n      <SidebarProvider>\n        <AppSidebar />\n        <SidebarInset className='flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden'>\n          <DashboardSwipeHeader />\n          <div\n            ref={scrollRef}\n            className='grid min-h-0 w-full max-w-full flex-1 basis-0 grid-flow-col auto-cols-[100%] grid-rows-[minmax(0,1fr)] snap-x snap-mandatory overflow-x-auto overflow-y-hidden overscroll-x-contain [scroll-behavior:auto]'\n            style={{ WebkitOverflowScrolling: \"touch\" }}\n          >\n            <section\n              className='box-border flex min-h-0 max-w-full min-w-0 snap-center snap-always flex-col overflow-hidden'\n              aria-label='Main dashboard'\n            >\n              <DashboardMainPanel contentView='main' />\n            </section>\n            {activeIntegrations.map((view) => (\n              <section\n                key={view}\n                className='box-border flex min-h-0 max-w-full min-w-0 snap-center snap-always flex-col overflow-hidden'\n                aria-label={`Dashboard ${view}`}\n              >\n                <DashboardMainPanel contentView={view} />\n              </section>\n            ))}\n            {dashboards.map((d) => (\n              <section\n                key={d.id}\n                className='box-border flex min-h-0 max-w-full min-w-0 snap-center snap-always flex-col overflow-hidden'\n                aria-label={`Dynamic dashboard ${d.name}`}\n              >\n                <DynamicDashboardMainPanel dashboardId={d.id} />\n              </section>\n            ))}\n            <section\n              className='box-border flex min-h-0 max-w-full min-w-0 snap-center snap-always flex-col overflow-hidden'\n              aria-label='New dynamic dashboard'\n            >\n              <NewDynamicDashboardPanel />\n            </section>\n          </div>\n          <Outlet />\n          <Footer />\n        </SidebarInset>\n      </SidebarProvider>\n    </WebRTCProvider>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/device/DeviceCreateButton.tsx",
    "content": "import { useState } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { PlusCircle } from \"lucide-react\";\nimport { DevicePayload } from \"@/entities/device/types\";\nimport { useDeviceStore } from \"@/entities/device/store\";\n\nexport function DeviceCreateButton() {\n  const [isOpen, setIsOpen] = useState(false);\n  const createDevice = useDeviceStore((state) => state.createDevice);\n  const { register, handleSubmit, reset } = useForm<DevicePayload>();\n\n  const onSubmit = async (data: DevicePayload) => {\n    await createDevice(data);\n    reset();\n    setIsOpen(false);\n  };\n\n  return (\n    <Dialog open={isOpen} onOpenChange={setIsOpen}>\n      <DialogTrigger asChild>\n        <Button size='sm'>\n          <PlusCircle className='h-4 w-4 mr-2' />\n          New Device\n        </Button>\n      </DialogTrigger>\n      <DialogContent>\n        <DialogHeader>\n          <DialogTitle>Create New Device</DialogTitle>\n        </DialogHeader>\n        <form onSubmit={handleSubmit(onSubmit)} className='space-y-4 py-2'>\n          <div className='space-y-2'>\n            <Label htmlFor='device_id'>Device ID</Label>\n            <Input\n              id='device_id'\n              {...register(\"device_id\", { required: true })}\n            />\n          </div>\n          <div className='space-y-2'>\n            <Label htmlFor='name'>Name</Label>\n            <Input id='name' {...register(\"name\")} />\n          </div>\n          <div className='space-y-2'>\n            <Label htmlFor='manufacturer'>Manufacturer (optional)</Label>\n            <Input id='manufacturer' {...register(\"manufacturer\")} />\n          </div>\n          <div className='space-y-2'>\n            <Label htmlFor='model'>Model (optional)</Label>\n            <Input id='model' {...register(\"model\")} />\n          </div>\n          <DialogFooter>\n            <Button\n              type='button'\n              variant='outline'\n              onClick={() => setIsOpen(false)}\n            >\n              Cancel\n            </Button>\n            <Button type='submit'>Save</Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/device/DeviceDeleteButton.tsx",
    "content": "import { useState } from \"react\";\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\nimport { Trash2 } from \"lucide-react\";\nimport { useDeviceStore } from \"@/entities/device/store\";\nimport { DropdownMenuItem } from \"@/components/ui/dropdown-menu\";\nimport { Button } from \"@/components/ui/button\";\n\ninterface Props {\n  deviceId: number;\n}\n\nexport function DeviceDeleteButton({ deviceId }: Props) {\n  const [isOpen, setIsOpen] = useState(false);\n  const deleteDevice = useDeviceStore((state) => state.deleteDevice);\n\n  const handleDelete = async () => {\n    await deleteDevice(deviceId);\n    setIsOpen(false);\n  };\n\n  return (\n    <AlertDialog open={isOpen} onOpenChange={setIsOpen}>\n      <AlertDialogTrigger asChild>\n        <DropdownMenuItem\n          onSelect={(e) => e.preventDefault()}\n          className='text-red-500 focus:text-red-500'\n        >\n          <Trash2 className='mr-2 h-4 w-4 text-red-500 focus:text-red-500' />\n          <span>Delete</span>\n        </DropdownMenuItem>\n      </AlertDialogTrigger>\n      <AlertDialogContent>\n        <AlertDialogHeader>\n          <AlertDialogTitle>Are you sure?</AlertDialogTitle>\n          <AlertDialogDescription>\n            This will permanently delete the device and all its entities.\n          </AlertDialogDescription>\n        </AlertDialogHeader>\n        <AlertDialogFooter>\n          <AlertDialogCancel>Cancel</AlertDialogCancel>\n          <AlertDialogAction asChild>\n            <Button variant={\"destructive\"} onClick={handleDelete}>\n              Delete\n            </Button>\n          </AlertDialogAction>\n        </AlertDialogFooter>\n      </AlertDialogContent>\n    </AlertDialog>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/device/DeviceKeyButton.tsx",
    "content": "import { useState } from \"react\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Key } from \"lucide-react\";\nimport { DropdownMenuItem } from \"@/components/ui/dropdown-menu\";\nimport { DeviceTokenManager } from \"@/features/device-token/DeviceTokenManager\";\n\ninterface Props {\n  deviceId: number;\n}\n\nexport function DeviceKeyButton({ deviceId }: Props) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  return (\n    <Dialog open={isOpen} onOpenChange={setIsOpen}>\n      <DialogTrigger asChild>\n        <DropdownMenuItem onSelect={(e) => e.preventDefault()}>\n          <Key className='mr-2 h-4 w-4' />\n          <span>Key</span>\n        </DropdownMenuItem>\n      </DialogTrigger>\n      <DialogContent>\n        <DialogHeader>\n          <DialogTitle>Access Token</DialogTitle>\n          <DialogDescription>\n            Manage the permanent access token for this device.\n          </DialogDescription>\n        </DialogHeader>\n        <DeviceTokenManager deviceId={deviceId} />\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/device/DeviceUpdateButton.tsx",
    "content": "import { useState } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Pencil } from \"lucide-react\";\nimport { useDeviceStore } from \"@/entities/device/store\";\nimport { Device, DevicePayload } from \"@/entities/device/types\";\nimport { DropdownMenuItem } from \"@/components/ui/dropdown-menu\";\n\ninterface Props {\n  device: Device;\n}\n\nexport function DeviceUpdateButton({ device }: Props) {\n  const [isOpen, setIsOpen] = useState(false);\n  const updateDevice = useDeviceStore((state) => state.updateDevice);\n  const { register, handleSubmit } = useForm<DevicePayload>({\n    defaultValues: {\n      device_id: device.device_id,\n      name: device.name ?? \"\",\n      manufacturer: device.manufacturer ?? \"\",\n      model: device.model ?? \"\",\n    },\n  });\n\n  const onSubmit = async (data: DevicePayload) => {\n    await updateDevice(device.id, data);\n    setIsOpen(false);\n  };\n\n  return (\n    <Dialog open={isOpen} onOpenChange={setIsOpen}>\n      <DialogTrigger asChild>\n        <DropdownMenuItem onSelect={(e) => e.preventDefault()}>\n          <Pencil className='mr-2 h-4 w-4' />\n          <span>Edit</span>\n        </DropdownMenuItem>\n      </DialogTrigger>\n      <DialogContent>\n        <DialogHeader>\n          <DialogTitle>Edit Device</DialogTitle>\n        </DialogHeader>\n        <form onSubmit={handleSubmit(onSubmit)} className='space-y-4 py-2'>\n          <div className='space-y-2'>\n            <Label htmlFor='device_id_edit'>Device ID</Label>\n            <Input\n              id='device_id_edit'\n              {...register(\"device_id\", { required: true })}\n            />\n          </div>\n          <div className='space-y-2'>\n            <Label htmlFor='name_edit'>Name</Label>\n            <Input id='name_edit' {...register(\"name\")} />\n          </div>\n          <div className='space-y-2'>\n            <Label htmlFor='manufacturer_edit'>Manufacturer</Label>\n            <Input id='manufacturer_edit' {...register(\"manufacturer\")} />\n          </div>\n          <div className='space-y-2'>\n            <Label htmlFor='model_edit'>Model</Label>\n            <Input id='model_edit' {...register(\"model\")} />\n          </div>\n          <DialogFooter>\n            <Button\n              type='button'\n              variant='outline'\n              onClick={() => setIsOpen(false)}\n            >\n              Cancel\n            </Button>\n            <Button type='submit'>Save Changes</Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/device-token/DeviceTokenManager.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { Copy, Check, Key, AlertTriangle, RefreshCw } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogDescription,\n  DialogFooter,\n} from \"@/components/ui/dialog\";\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { useDeviceTokenStore } from \"@/entities/device-token/store\";\n\ninterface Props {\n  deviceId: number;\n}\n\nexport function DeviceTokenManager({ deviceId }: Props) {\n  const {\n    tokenInfo,\n    newToken,\n    isLoading,\n    fetchTokenInfo,\n    issueToken,\n    revokeToken,\n    clearNewToken,\n  } = useDeviceTokenStore();\n  const [isCopied, setIsCopied] = useState(false);\n\n  useEffect(() => {\n    if (deviceId) {\n      fetchTokenInfo(deviceId);\n    }\n  }, [deviceId, fetchTokenInfo]);\n\n  const handleCopy = () => {\n    if (!newToken) return;\n    navigator.clipboard.writeText(newToken);\n    setIsCopied(true);\n    setTimeout(() => setIsCopied(false), 2000);\n  };\n\n  return (\n    <>\n      <div className='space-y-4'>\n        {tokenInfo ? (\n          <div className='flex items-center justify-between p-3 border'>\n            <div>\n              <p className='font-semibold'>Token is active</p>\n              <p className='text-sm text-muted-foreground'>\n                Created at: {new Date(tokenInfo.created_at).toLocaleString()}\n              </p>\n            </div>\n            <div className='flex items-center gap-2'>\n              <Button\n                variant='outline'\n                size='sm'\n                onClick={() => issueToken(deviceId)}\n                disabled={isLoading}\n              >\n                <RefreshCw className='mr-2 h-4 w-4' />\n                Re-issue\n              </Button>\n              <AlertDialog>\n                <AlertDialogTrigger asChild>\n                  <Button\n                    variant='destructive'\n                    size='sm'\n                    disabled={isLoading}\n                  >\n                    Revoke\n                  </Button>\n                </AlertDialogTrigger>\n                <AlertDialogContent>\n                  <AlertDialogHeader>\n                    <AlertDialogTitle>Are you sure?</AlertDialogTitle>\n                    <AlertDialogDescription>\n                      This will permanently revoke the access token. The\n                      device will no longer be able to connect.\n                    </AlertDialogDescription>\n                  </AlertDialogHeader>\n                  <AlertDialogFooter>\n                    <AlertDialogCancel>Cancel</AlertDialogCancel>\n                    <AlertDialogAction onClick={() => revokeToken(deviceId)}>\n                      Continue\n                    </AlertDialogAction>\n                  </AlertDialogFooter>\n                </AlertDialogContent>\n              </AlertDialog>\n            </div>\n          </div>\n        ) : (\n          <div className='flex items-center justify-between p-3 border bg-muted/50'>\n            <div>\n              <p className='font-semibold text-muted-foreground'>\n                No active token\n              </p>\n              <p className='text-sm text-muted-foreground'>\n                Issue a new token to allow this device to connect.\n              </p>\n            </div>\n            <Button onClick={() => issueToken(deviceId)} disabled={isLoading}>\n              <Key className='mr-2 h-4 w-4' />\n              Issue Token\n            </Button>\n          </div>\n        )}\n      </div>\n\n      <Dialog open={!!newToken} onOpenChange={() => clearNewToken()}>\n        <DialogContent>\n          <DialogHeader>\n            <DialogTitle>New Access Token Generated</DialogTitle>\n            <DialogDescription>\n              <Badge variant='destructive' className='my-2'>\n                <AlertTriangle className='mr-2 h-4 w-4' />\n                Store this token securely. It will not be shown again.\n              </Badge>\n            </DialogDescription>\n          </DialogHeader>\n          <div className='relative my-4'>\n            <pre className='p-4 bg-muted rounded-md font-mono text-sm break-all'>\n              {newToken}\n            </pre>\n            <Button\n              size='icon'\n              variant='ghost'\n              className='absolute top-2 right-2 h-8 w-8'\n              onClick={handleCopy}\n            >\n              {isCopied ? (\n                <Check className='h-4 w-4 text-green-500' />\n              ) : (\n                <Copy className='h-4 w-4' />\n              )}\n            </Button>\n          </div>\n          <DialogFooter>\n            <Button onClick={() => clearNewToken()}>\n              I have copied the token\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/dynamic-dashboard/GroupCanvas.tsx",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport type { PointerEvent as ReactPointerEvent } from \"react\";\nimport { toast } from \"sonner\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card } from \"@/components/ui/card\";\nimport { resolveItemPositionOrNull } from \"@/entities/dynamic-dashboard/layoutResolve\";\nimport {\n  DashboardGroup,\n  DashboardItem,\n  useDynamicDashboardStore,\n} from \"@/entities/dynamic-dashboard/store\";\nimport { EntityAll } from \"@/entities/entity/types\";\nimport { StreamState } from \"@/features/entity/useEntitiesData\";\nimport { EntityCard } from \"@/features/entity/Card\";\nimport { StreamReceiver } from \"@/features/rtc/StreamReceiver\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Plus, X } from \"lucide-react\";\nimport { useSidebar } from \"@/components/ui/sidebar\";\nimport { useFlowStore } from \"@/entities/flow/store\";\nimport { useMapDataStore } from \"@/entities/map/store\";\nimport { MapPanel } from \"./panels/MapPanel\";\nimport { FlowPanel } from \"./panels/FlowPanel\";\nimport { useWebSocket } from \"@/features/ws/WebSocketProvider\";\nimport { getFlowRunSessionId } from \"@/features/ws/ws\";\nimport { createDashboardEventDispatcher } from \"./events/dispatcher\";\nimport { isValidListenerId } from \"@/entities/dynamic-dashboard/interaction\";\n\nconst isMapItem = (\n  candidate: DashboardItem,\n): candidate is DashboardItem<\"map\"> => candidate.type === \"map\";\n\nconst isFlowItem = (\n  candidate: DashboardItem,\n): candidate is DashboardItem<\"flow\"> => candidate.type === \"flow\";\n\nconst isButtonItem = (\n  candidate: DashboardItem,\n): candidate is DashboardItem<\"button\"> => candidate.type === \"button\";\n\nconst MOBILE_ROW_UNIT_PX = 28;\n\n/** Viewport Y (px): show delete drop target while dragging above this line. */\nconst DRAG_DELETE_SHOW_MAX_CLIENT_Y = 100;\n\n/** Fixed delete chip below app header (~h-12). */\nconst DRAG_DELETE_TARGET_TOP = \"3.5rem\";\n\n/** Spring for smooth magnet-snap motion — near-critical damping for minimal overshoot. */\nconst DRAG_SPRING_STIFFNESS = 400;\nconst DRAG_SPRING_DAMPING = 40;\n\ntype Vec2 = { x: number; y: number };\n\ntype DragSpringSim = {\n  px: number;\n  py: number;\n  vx: number;\n  vy: number;\n};\n\n/** Clamp span so position + span stays within the grid (CSS grid 1×1…N tracks). */\nfunction clampGridSpan(position: number, span: number, limit: number): number {\n  const maxSpan = Math.max(1, limit - position);\n  return Math.max(1, Math.min(span, maxSpan));\n}\n\n/** Session data for an active drag (stable across preview updates → effect does not rebind on each cell). */\ntype DragSessionState = {\n  itemId: string;\n  startX: number;\n  startY: number;\n  origin: { x: number; y: number };\n};\n\n/** Which edge/corner is being dragged for frame resize. */\ntype ResizeEdge = \"n\" | \"s\" | \"e\" | \"w\" | \"ne\" | \"nw\" | \"se\" | \"sw\";\n\ntype ResizeState = {\n  itemId: string;\n  startX: number;\n  startY: number;\n  originSize: { w: number; h: number };\n  originPos: { x: number; y: number };\n  minSize: { w: number; h: number };\n  edge: ResizeEdge;\n};\n\nconst FRAME_STRIP_CLASS =\n  \"absolute z-[25] touch-none border-0 bg-transparent p-0\";\n\nfunction resizeCursorForEdge(edge: ResizeEdge): string {\n  switch (edge) {\n    case \"n\":\n    case \"s\":\n      return \"ns-resize\";\n    case \"e\":\n    case \"w\":\n      return \"ew-resize\";\n    case \"ne\":\n    case \"sw\":\n      return \"nesw-resize\";\n    case \"nw\":\n    case \"se\":\n      return \"nwse-resize\";\n    default:\n      return \"default\";\n  }\n}\n\nfunction startResize(\n  event: ReactPointerEvent<Element>,\n  item: DashboardItem,\n  edge: ResizeEdge,\n  clearDrag: () => void,\n  setResizing: (v: ResizeState | null) => void,\n) {\n  event.preventDefault();\n  event.stopPropagation();\n  clearDrag();\n  setResizing({\n    itemId: item.id,\n    startX: event.clientX,\n    startY: event.clientY,\n    originSize: { ...item.size },\n    originPos: { ...item.position },\n    minSize: { ...item.minSize },\n    edge,\n  });\n}\n\ntype GroupCanvasProps = {\n  dashboardId: string;\n  group: DashboardGroup;\n  entities: EntityAll[];\n  streamsState: StreamState[];\n};\n\nexport function GroupCanvas({\n  dashboardId,\n  group,\n  entities,\n  streamsState,\n}: GroupCanvasProps) {\n  const { isMobile } = useSidebar();\n  const addItem = useDynamicDashboardStore((state) => state.addItem);\n  const updateItemLayout = useDynamicDashboardStore(\n    (state) => state.updateItemLayout,\n  );\n  const updateItemData = useDynamicDashboardStore(\n    (state) => state.updateItemData,\n  );\n  const deleteItem = useDynamicDashboardStore((state) => state.deleteItem);\n  const { layers, fetchAllLayers } = useMapDataStore();\n  const { flows, fetchFlows } = useFlowStore();\n  const { wsManager } = useWebSocket();\n\n  const dashboardDispatcher = useMemo(\n    () =>\n      createDashboardEventDispatcher({\n        send: (msg) => wsManager?.send(msg),\n        isConnected: () => wsManager?.isConnected() ?? false,\n      }),\n    [wsManager],\n  );\n\n  const [dragSession, setDragSession] = useState<DragSessionState | null>(null);\n  const dragPreviewRef = useRef<{ x: number; y: number } | null>(null);\n  const dragSpringTargetRef = useRef<Vec2>({ x: 0, y: 0 });\n  const [resizing, setResizing] = useState<ResizeState | null>(null);\n  const [dragNearTopForDelete, setDragNearTopForDelete] = useState(false);\n  const [dragOverDeleteTarget, setDragOverDeleteTarget] = useState(false);\n  const containerRef = useRef<HTMLDivElement | null>(null);\n  const deleteDropTargetRef = useRef<HTMLDivElement | null>(null);\n  const [editingButtonItem, setEditingButtonItem] =\n    useState<DashboardItem<\"button\"> | null>(null);\n\n  const dragSpringSimRef = useRef<DragSpringSim>({\n    px: 0,\n    py: 0,\n    vx: 0,\n    vy: 0,\n  });\n  const dragSpringRafRef = useRef<number | null>(null);\n  const dragSpringLastTRef = useRef<number | null>(null);\n  const [dragSpringVisual, setDragSpringVisual] = useState<Vec2>({\n    x: 0,\n    y: 0,\n  });\n  const resetDragSpring = useCallback(() => {\n    if (dragSpringRafRef.current != null) {\n      cancelAnimationFrame(dragSpringRafRef.current);\n      dragSpringRafRef.current = null;\n    }\n    dragSpringLastTRef.current = null;\n    dragSpringSimRef.current = { px: 0, py: 0, vx: 0, vy: 0 };\n    setDragSpringVisual({ x: 0, y: 0 });\n  }, []);\n\n  const ensureDragSpringLoop = useCallback(() => {\n    if (dragSpringRafRef.current != null) return;\n    dragSpringLastTRef.current = performance.now();\n\n    const tick = (now: number) => {\n      const last = dragSpringLastTRef.current ?? now;\n      const dt = Math.min((now - last) / 1000, 0.064);\n      dragSpringLastTRef.current = now;\n\n      const s = dragSpringSimRef.current;\n      const t = dragSpringTargetRef.current;\n      const k = DRAG_SPRING_STIFFNESS;\n      const c = DRAG_SPRING_DAMPING;\n      const errX = s.px - t.x;\n      const errY = s.py - t.y;\n      const ax = -k * errX - c * s.vx;\n      const ay = -k * errY - c * s.vy;\n      s.vx += ax * dt;\n      s.vy += ay * dt;\n      s.px += s.vx * dt;\n      s.py += s.vy * dt;\n\n      setDragSpringVisual({ x: s.px, y: s.py });\n\n      const settled =\n        Math.abs(errX) < 0.4 &&\n        Math.abs(errY) < 0.4 &&\n        Math.abs(s.vx) < 8 &&\n        Math.abs(s.vy) < 8;\n\n      if (settled) {\n        s.px = t.x;\n        s.py = t.y;\n        s.vx = s.vy = 0;\n        setDragSpringVisual({ x: t.x, y: t.y });\n        dragSpringRafRef.current = null;\n        dragSpringLastTRef.current = null;\n        return;\n      }\n\n      dragSpringRafRef.current = requestAnimationFrame(tick);\n    };\n\n    dragSpringRafRef.current = requestAnimationFrame(tick);\n  }, []);\n\n  const clearDrag = useCallback(() => {\n    resetDragSpring();\n    dragSpringTargetRef.current = { x: 0, y: 0 };\n    setDragSession(null);\n    dragPreviewRef.current = null;\n    setDragNearTopForDelete(false);\n    setDragOverDeleteTarget(false);\n  }, [resetDragSpring]);\n\n  useEffect(() => {\n    return () => {\n      if (dragSpringRafRef.current != null) {\n        cancelAnimationFrame(dragSpringRafRef.current);\n      }\n    };\n  }, []);\n\n  const cols = Math.max(1, group.cols);\n  const rows = Math.max(1, group.rows);\n\n  const findEntityByRef = (refId?: string) => {\n    if (!refId) return undefined;\n    return (\n      entities.find((e) => e.entity_id === refId) ||\n      entities.find((e) => String(e.id) === refId)\n    );\n  };\n\n  useEffect(() => {\n    if (!dragSession) {\n      setDragNearTopForDelete(false);\n      setDragOverDeleteTarget(false);\n    }\n  }, [dragSession]);\n\n  useEffect(() => {\n    if (layers.length === 0) {\n      fetchAllLayers().catch((err) => {\n        console.error(\"Failed to load layers for dashboard\", err);\n      });\n    }\n    if (flows.length === 0) {\n      fetchFlows().catch((err) => {\n        console.error(\"Failed to load flows for dashboard\", err);\n      });\n    }\n  }, [fetchAllLayers, fetchFlows, flows.length, layers.length]);\n\n  useEffect(() => {\n    if (!dragSession) {\n      return;\n    }\n\n    const handleMove = (event: PointerEvent) => {\n      const nearTop = event.clientY < DRAG_DELETE_SHOW_MAX_CLIENT_Y;\n      setDragNearTopForDelete((prev) => (prev === nearTop ? prev : nearTop));\n\n      let overDelete = false;\n      const targetEl = deleteDropTargetRef.current;\n      if (targetEl && nearTop) {\n        const tr = targetEl.getBoundingClientRect();\n        overDelete =\n          event.clientX >= tr.left &&\n          event.clientX <= tr.right &&\n          event.clientY >= tr.top &&\n          event.clientY <= tr.bottom;\n      }\n      setDragOverDeleteTarget((prev) =>\n        prev === overDelete ? prev : overDelete,\n      );\n\n      if (overDelete) {\n        return;\n      }\n\n      const el = containerRef.current;\n      if (!el) return;\n      const rect = el.getBoundingClientRect();\n      const cellW = rect.width / cols;\n      const cellH = rect.height / rows;\n      if (cellW < 1e-6 || cellH < 1e-6) return;\n\n      const dx = Math.round((event.clientX - dragSession.startX) / cellW);\n      const dy = Math.round((event.clientY - dragSession.startY) / cellH);\n\n      const desired = {\n        x: dragSession.origin.x + dx,\n        y: dragSession.origin.y + dy,\n      };\n\n      const dash = useDynamicDashboardStore\n        .getState()\n        .dashboards.find((d) => d.id === dashboardId);\n      const g = dash?.groups.find((gr) => gr.id === group.id);\n      const movingItem = g?.items.find((it) => it.id === dragSession.itemId);\n      if (!g || !movingItem) return;\n\n      const resolved = resolveItemPositionOrNull(g, movingItem, desired);\n      if (!resolved) {\n        return;\n      }\n\n      const prev = dragPreviewRef.current ?? dragSession.origin;\n      if (prev.x === resolved.x && prev.y === resolved.y) {\n        return;\n      }\n\n      dragPreviewRef.current = resolved;\n      dragSpringTargetRef.current = {\n        x: (resolved.x - dragSession.origin.x) * cellW,\n        y: (resolved.y - dragSession.origin.y) * cellH,\n      };\n      ensureDragSpringLoop();\n    };\n\n    const handleUp = (event: PointerEvent) => {\n      const session = dragSession;\n      const finalPreview = dragPreviewRef.current;\n\n      const targetEl = deleteDropTargetRef.current;\n      let droppedOnDelete = false;\n      if (targetEl && session) {\n        const tr = targetEl.getBoundingClientRect();\n        droppedOnDelete =\n          event.clientX >= tr.left &&\n          event.clientX <= tr.right &&\n          event.clientY >= tr.top &&\n          event.clientY <= tr.bottom;\n      }\n\n      if (droppedOnDelete && session) {\n        deleteItem(dashboardId, group.id, session.itemId);\n        clearDrag();\n        return;\n      }\n\n      if (session && finalPreview) {\n        const stored = useDynamicDashboardStore\n          .getState()\n          .dashboards.find((d) => d.id === dashboardId)\n          ?.groups.find((gr) => gr.id === group.id)\n          ?.items.find((it) => it.id === session.itemId);\n        if (\n          stored &&\n          (stored.position.x !== finalPreview.x ||\n            stored.position.y !== finalPreview.y)\n        ) {\n          updateItemLayout(dashboardId, group.id, session.itemId, {\n            position: finalPreview,\n          });\n        }\n      }\n\n      clearDrag();\n    };\n\n    window.addEventListener(\"pointermove\", handleMove);\n    window.addEventListener(\"pointerup\", handleUp);\n    window.addEventListener(\"pointercancel\", handleUp);\n\n    return () => {\n      window.removeEventListener(\"pointermove\", handleMove);\n      window.removeEventListener(\"pointerup\", handleUp);\n      window.removeEventListener(\"pointercancel\", handleUp);\n    };\n  }, [\n    clearDrag,\n    cols,\n    dashboardId,\n    deleteItem,\n    dragSession,\n    ensureDragSpringLoop,\n    group.id,\n    rows,\n    updateItemLayout,\n  ]);\n\n  useEffect(() => {\n    if (!resizing) {\n      return;\n    }\n\n    const handleMove = (event: PointerEvent) => {\n      const el = containerRef.current;\n      if (!el) return;\n      const rect = el.getBoundingClientRect();\n      const cellW = rect.width / cols;\n      const cellH = rect.height / rows;\n      if (cellW < 1e-6 || cellH < 1e-6) return;\n\n      const dw = Math.round((event.clientX - resizing.startX) / cellW);\n      const dh = Math.round((event.clientY - resizing.startY) / cellH);\n\n      const { originPos, originSize, minSize, edge } = resizing;\n      let nextX = originPos.x;\n      let nextY = originPos.y;\n      let nextW = originSize.w;\n      let nextH = originSize.h;\n\n      const maxGrowW = cols - originPos.x;\n      const maxGrowH = rows - originPos.y;\n      const rightEdge = originPos.x + originSize.w;\n      const bottomEdge = originPos.y + originSize.h;\n\n      switch (edge) {\n        case \"se\": {\n          nextW = Math.max(minSize.w, Math.min(maxGrowW, originSize.w + dw));\n          nextH = Math.max(minSize.h, Math.min(maxGrowH, originSize.h + dh));\n          break;\n        }\n        case \"e\": {\n          nextW = Math.max(minSize.w, Math.min(maxGrowW, originSize.w + dw));\n          break;\n        }\n        case \"s\": {\n          nextH = Math.max(minSize.h, Math.min(maxGrowH, originSize.h + dh));\n          break;\n        }\n        case \"w\": {\n          let x = originPos.x + dw;\n          x = Math.max(0, x);\n          x = Math.min(x, rightEdge - minSize.w);\n          nextW = rightEdge - x;\n          nextX = x;\n          break;\n        }\n        case \"n\": {\n          let y = originPos.y + dh;\n          y = Math.max(0, y);\n          y = Math.min(y, bottomEdge - minSize.h);\n          nextH = bottomEdge - y;\n          nextY = y;\n          break;\n        }\n        case \"ne\": {\n          nextW = Math.max(minSize.w, Math.min(maxGrowW, originSize.w + dw));\n          let y = originPos.y + dh;\n          y = Math.max(0, y);\n          y = Math.min(y, bottomEdge - minSize.h);\n          nextH = bottomEdge - y;\n          nextY = y;\n          break;\n        }\n        case \"nw\": {\n          let x = originPos.x + dw;\n          x = Math.max(0, x);\n          x = Math.min(x, rightEdge - minSize.w);\n          nextW = rightEdge - x;\n          nextX = x;\n          let y = originPos.y + dh;\n          y = Math.max(0, y);\n          y = Math.min(y, bottomEdge - minSize.h);\n          nextH = bottomEdge - y;\n          nextY = y;\n          break;\n        }\n        case \"sw\": {\n          let x = originPos.x + dw;\n          x = Math.max(0, x);\n          x = Math.min(x, rightEdge - minSize.w);\n          nextW = rightEdge - x;\n          nextX = x;\n          nextH = Math.max(minSize.h, Math.min(maxGrowH, originSize.h + dh));\n          break;\n        }\n        default:\n          break;\n      }\n\n      updateItemLayout(dashboardId, group.id, resizing.itemId, {\n        position: { x: nextX, y: nextY },\n        size: { w: nextW, h: nextH },\n      });\n    };\n\n    const handleUp = () => {\n      setResizing(null);\n    };\n\n    window.addEventListener(\"pointermove\", handleMove);\n    window.addEventListener(\"pointerup\", handleUp);\n    window.addEventListener(\"pointercancel\", handleUp);\n\n    return () => {\n      window.removeEventListener(\"pointermove\", handleMove);\n      window.removeEventListener(\"pointerup\", handleUp);\n      window.removeEventListener(\"pointercancel\", handleUp);\n    };\n  }, [cols, dashboardId, group.id, resizing, rows, updateItemLayout]);\n\n  const handleAddEntityCard = (entityId?: string) => {\n    if (!entityId) return;\n    addItem(dashboardId, group.id, {\n      type: \"entity-card\",\n      refId: entityId,\n      label: \"Entity\",\n    });\n  };\n\n  // const handleAddEntityText = (entityId?: string) => {\n  //   if (!entityId) return;\n  //   addItem(dashboardId, group.id, {\n  //     type: \"entity-text\",\n  //     refId: entityId,\n  //     label: \"Entity Text\",\n  //   });\n  // };\n\n  // const handleAddMedia = (entityId?: string) => {\n  //   if (!entityId) return;\n  //   addItem(dashboardId, group.id, {\n  //     type: \"media\",\n  //     refId: entityId,\n  //     label: \"Media\",\n  //   });\n  // };\n\n  const handleAddButton = () => {\n    addItem(dashboardId, group.id, {\n      type: \"button\",\n      label: \"Action\",\n      data: {},\n    });\n  };\n\n  const handleAddMapPanel = (layerId: number) => {\n    addItem(dashboardId, group.id, {\n      type: \"map\",\n      label: \"Map\",\n      data: { layerId },\n    });\n  };\n\n  const handleAddFlowPanel = (flowId: number) => {\n    addItem(dashboardId, group.id, {\n      type: \"flow\",\n      label: \"Flow\",\n      data: { flowId },\n    });\n  };\n\n  const renderItem = (item: DashboardItem) => {\n    if (item.type === \"entity-card\") {\n      const entity = findEntityByRef(item.refId);\n      if (!entity) {\n        return <Placeholder text='Entity not found' />;\n      }\n      return <EntityCard item={entity} streamsState={streamsState} />;\n    }\n\n    if (item.type === \"entity-text\") {\n      const entity = findEntityByRef(item.refId);\n      if (!entity) {\n        return <Placeholder text='Entity not found' />;\n      }\n      return (\n        <div className='flex h-full flex-col justify-between'>\n          <div>\n            <p className='text-sm text-muted-foreground'>\n              {entity.friendly_name}\n            </p>\n            <p className='text-3xl font-semibold'>\n              {entity.state?.state ?? \"N/A\"}\n            </p>\n          </div>\n          <p className='text-xs text-muted-foreground'>{entity.platform}</p>\n        </div>\n      );\n    }\n\n    if (item.type === \"media\") {\n      const entity = findEntityByRef(item.refId);\n      const topic =\n        (entity?.configuration?.state_topic as string) ||\n        (entity?.configuration?.rtsp_url as string);\n\n      if (!topic) {\n        return <Placeholder text='No stream topic' />;\n      }\n\n      return (\n        <div className='h-full w-full'>\n          <StreamReceiver topic={topic} streamType='video' />\n        </div>\n      );\n    }\n\n    if (isButtonItem(item)) {\n      const listenerId = item.data?.listener_id?.trim() ?? \"\";\n      const handleButtonClick = () => {\n        if (!listenerId || !isValidListenerId(listenerId)) {\n          toast.message(\"Configure the button\", {\n            description: \"Set a listener id (letters, numbers, _ and - only).\",\n          });\n          return;\n        }\n        dashboardDispatcher.dispatch({\n          dashboard_id: dashboardId,\n          group_id: group.id,\n          item_id: item.id,\n          listener_id: listenerId,\n          component_type: \"button\",\n          action: \"click\",\n          source_session_id: getFlowRunSessionId(),\n          debounce_ms: item.data?.debounce_ms,\n          cooldown_ms: item.data?.cooldown_ms,\n        });\n      };\n\n      return (\n        <div className='pointer-events-auto flex h-full min-h-0 w-full flex-col gap-2'>\n          <div className='flex min-h-0 flex-1 items-stretch justify-center'>\n            <Button\n              className='h-full w-full'\n              variant='outline'\n              type='button'\n              onClick={handleButtonClick}\n            >\n              {item.label || \"Action\"}\n            </Button>\n          </div>\n        </div>\n      );\n    }\n\n    if (isMapItem(item)) {\n      return (\n        <div className='pointer-events-auto flex h-full min-h-0 w-full flex-col'>\n          <MapPanel\n            data={item.data}\n            onLayerChange={(layerId) =>\n              updateItemData(dashboardId, group.id, item.id, {\n                data: { ...(item.data ?? {}), layerId },\n              })\n            }\n          />\n        </div>\n      );\n    }\n\n    if (isFlowItem(item)) {\n      return (\n        <div className='pointer-events-auto flex h-full min-h-0 w-full flex-col'>\n          <FlowPanel\n            data={item.data}\n            onFlowChange={(flowId) =>\n              updateItemData(dashboardId, group.id, item.id, {\n                data: { ...(item.data ?? {}), flowId },\n              })\n            }\n          />\n        </div>\n      );\n    }\n\n    return <Placeholder text='Unknown item' />;\n  };\n\n  const groupToolbar = (\n    <div className='mb-3 flex flex-wrap items-center justify-between gap-2'>\n      <div className='flex items-center gap-2 px-4'></div>\n      <div className='flex flex-wrap items-center gap-2 px-4'>\n        <DropdownMenu>\n          <DropdownMenuTrigger asChild>\n            <Button variant='outline' size='sm'>\n              <Plus className='h-4 w-4 mr-1' />\n              Add Item\n            </Button>\n          </DropdownMenuTrigger>\n          <DropdownMenuContent align='end'>\n            <DropdownMenuSub>\n              <DropdownMenuSubTrigger>Add Entity</DropdownMenuSubTrigger>\n              <DropdownMenuSubContent>\n                {entities.map((entity) => (\n                  <DropdownMenuItem\n                    key={entity.id}\n                    onClick={() => handleAddEntityCard(entity.entity_id)}\n                  >\n                    {entity.friendly_name}\n                  </DropdownMenuItem>\n                ))}\n              </DropdownMenuSubContent>\n            </DropdownMenuSub>\n            <DropdownMenuItem onClick={handleAddButton}>\n              Add Button\n            </DropdownMenuItem>\n            <DropdownMenuSub>\n              <DropdownMenuSubTrigger disabled={layers.length === 0}>\n                Add Map\n              </DropdownMenuSubTrigger>\n              <DropdownMenuSubContent>\n                {layers.map((layer) => (\n                  <DropdownMenuItem\n                    key={layer.id}\n                    onClick={() => handleAddMapPanel(layer.id)}\n                  >\n                    {layer.name}\n                  </DropdownMenuItem>\n                ))}\n              </DropdownMenuSubContent>\n            </DropdownMenuSub>\n            <DropdownMenuSub>\n              <DropdownMenuSubTrigger disabled={flows.length === 0}>\n                Add Flow\n              </DropdownMenuSubTrigger>\n              <DropdownMenuSubContent>\n                {flows.map((flow) => (\n                  <DropdownMenuItem\n                    key={flow.id}\n                    onClick={() => handleAddFlowPanel(flow.id)}\n                  >\n                    {flow.name}\n                  </DropdownMenuItem>\n                ))}\n              </DropdownMenuSubContent>\n            </DropdownMenuSub>\n          </DropdownMenuContent>\n        </DropdownMenu>\n      </div>\n    </div>\n  );\n\n  if (isMobile) {\n    return (\n      <Card className='flex flex-col border-0 py-4'>\n        {groupToolbar}\n        <div className='flex flex-col gap-3 px-4'>\n          {group.items.map((item) => (\n            <div\n              key={item.id}\n              className='relative overflow-hidden rounded-lg border bg-card shadow-sm'\n              style={{\n                minHeight: Math.max(120, item.size.h * MOBILE_ROW_UNIT_PX),\n              }}\n            >\n              <div className='flex h-full min-h-0 flex-col p-2'>\n                {renderItem(item)}\n              </div>\n            </div>\n          ))}\n        </div>\n      </Card>\n    );\n  }\n\n  return (\n    <Card className='flex h-full min-h-0 flex-col border-0 py-4'>\n      {groupToolbar}\n\n      <div className='flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden'>\n        <div\n          ref={containerRef}\n          className='grid h-full min-h-0 w-full min-w-0 flex-1 overflow-hidden bg-background'\n          style={{\n            gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,\n            gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,\n            backgroundImage:\n              \"linear-gradient(to right, rgba(0,0,0,0.08) 1px, transparent 1px), linear-gradient(to bottom, rgba(0,0,0,0.08) 1px, transparent 1px)\",\n            backgroundSize: `calc(100% / ${cols}) calc(100% / ${rows})`,\n          }}\n        >\n          {group.items.map((item) => {\n            const isMoveDragging = dragSession?.itemId === item.id;\n            const displayPos = isMoveDragging\n              ? dragSession.origin\n              : item.position;\n            const spanW = clampGridSpan(displayPos.x, item.size.w, cols);\n            const spanH = clampGridSpan(displayPos.y, item.size.h, rows);\n            const activeResizeEdge =\n              resizing?.itemId === item.id ? resizing.edge : null;\n            const springFollows = isMoveDragging;\n            return (\n              <div\n                key={item.id}\n                className='relative min-h-0 min-w-0 overflow-hidden bg-card shadow-sm'\n                style={{\n                  gridColumn: `${displayPos.x + 1} / span ${spanW}`,\n                  gridRow: `${displayPos.y + 1} / span ${spanH}`,\n                  cursor: activeResizeEdge\n                    ? resizeCursorForEdge(activeResizeEdge)\n                    : \"default\",\n                  transform: springFollows\n                    ? `translate3d(${dragSpringVisual.x}px, ${dragSpringVisual.y}px, 0)`\n                    : undefined,\n                  zIndex: springFollows ? 80 : undefined,\n                  willChange: springFollows ? \"transform\" : undefined,\n                }}\n              >\n                <div className='group/movehit pointer-events-auto absolute inset-x-0 top-2 z-[36] h-3'>\n                  <button\n                    type='button'\n                    aria-label={`Move ${item.label || item.type}`}\n                    className='pointer-events-none absolute left-1/2 top-1 z-[37] h-2 w-14 shrink-0 -translate-x-1/2 cursor-grab touch-none rounded-full bg-white opacity-0 shadow-sm transition-opacity duration-200 group-hover/movehit:pointer-events-auto group-hover/movehit:opacity-100 active:cursor-grabbing'\n                    onDoubleClick={(event) => {\n                      event.preventDefault();\n                      event.stopPropagation();\n                      if (isButtonItem(item)) {\n                        setEditingButtonItem(item);\n                      }\n                    }}\n                    onPointerDown={(event) => {\n                      event.preventDefault();\n                      event.stopPropagation();\n                      setResizing(null);\n                      resetDragSpring();\n                      setDragNearTopForDelete(false);\n                      setDragOverDeleteTarget(false);\n                      const origin = { ...item.position };\n                      dragPreviewRef.current = origin;\n                      dragSpringTargetRef.current = { x: 0, y: 0 };\n                      setDragSession({\n                        itemId: item.id,\n                        startX: event.clientX,\n                        startY: event.clientY,\n                        origin,\n                      });\n                    }}\n                  />\n                </div>\n\n                <button\n                  type='button'\n                  aria-label='Resize from top edge'\n                  className={`${FRAME_STRIP_CLASS} left-2 right-2 top-0 h-2 cursor-ns-resize`}\n                  onPointerDown={(e) =>\n                    startResize(e, item, \"n\", clearDrag, setResizing)\n                  }\n                />\n                <button\n                  type='button'\n                  aria-label='Resize from bottom edge'\n                  className={`${FRAME_STRIP_CLASS} bottom-0 left-2 right-2 h-2 cursor-ns-resize`}\n                  onPointerDown={(e) =>\n                    startResize(e, item, \"s\", clearDrag, setResizing)\n                  }\n                />\n                <button\n                  type='button'\n                  aria-label='Resize from left edge'\n                  className={`${FRAME_STRIP_CLASS} bottom-2 left-0 top-2 w-2 cursor-ew-resize`}\n                  onPointerDown={(e) =>\n                    startResize(e, item, \"w\", clearDrag, setResizing)\n                  }\n                />\n                <button\n                  type='button'\n                  aria-label='Resize from right edge'\n                  className={`${FRAME_STRIP_CLASS} bottom-2 right-0 top-2 w-2 cursor-ew-resize`}\n                  onPointerDown={(e) =>\n                    startResize(e, item, \"e\", clearDrag, setResizing)\n                  }\n                />\n                <button\n                  type='button'\n                  aria-label='Resize from top-left'\n                  className={`${FRAME_STRIP_CLASS} left-0 top-0 h-3 w-3 cursor-nwse-resize`}\n                  onPointerDown={(e) =>\n                    startResize(e, item, \"nw\", clearDrag, setResizing)\n                  }\n                />\n                <button\n                  type='button'\n                  aria-label='Resize from top-right'\n                  className={`${FRAME_STRIP_CLASS} right-0 top-0 h-3 w-3 cursor-nesw-resize`}\n                  onPointerDown={(e) =>\n                    startResize(e, item, \"ne\", clearDrag, setResizing)\n                  }\n                />\n                <button\n                  type='button'\n                  aria-label='Resize from bottom-left'\n                  className={`${FRAME_STRIP_CLASS} bottom-0 left-0 h-3 w-3 cursor-nesw-resize`}\n                  onPointerDown={(e) =>\n                    startResize(e, item, \"sw\", clearDrag, setResizing)\n                  }\n                />\n                <button\n                  type='button'\n                  aria-label='Resize from bottom-right'\n                  className={`${FRAME_STRIP_CLASS} bottom-0 right-0 h-3 w-3 cursor-nwse-resize`}\n                  onPointerDown={(e) =>\n                    startResize(e, item, \"se\", clearDrag, setResizing)\n                  }\n                />\n\n                <div className='relative z-0 flex h-full min-h-0 flex-col p-2'>\n                  {renderItem(item)}\n                </div>\n              </div>\n            );\n          })}\n        </div>\n      </div>\n\n      {dragSession && dragNearTopForDelete ? (\n        <div\n          ref={deleteDropTargetRef}\n          className={`pointer-events-none fixed left-1/2 z-[300] flex h-14 w-14 -translate-x-1/2 items-center justify-center rounded-full bg-dark transition-[transform,box-shadow] ${\n            dragOverDeleteTarget ? \"scale-110\" : \"scale-100\"\n          } `}\n          style={{ top: DRAG_DELETE_TARGET_TOP }}\n          aria-hidden\n        >\n          <X className='h-7 w-7 text-white' strokeWidth={2.25} />\n        </div>\n      ) : null}\n\n      <Dialog\n        open={editingButtonItem !== null}\n        onOpenChange={(open) => {\n          if (!open) setEditingButtonItem(null);\n        }}\n      >\n        <DialogContent className='sm:max-w-md'>\n          <DialogHeader>\n            <DialogTitle>Edit Button</DialogTitle>\n          </DialogHeader>\n          {editingButtonItem && (\n            <ButtonConfigEditor\n              item={editingButtonItem}\n              onSave={(data) => {\n                updateItemData(dashboardId, group.id, editingButtonItem.id, {\n                  data,\n                  label: data.label,\n                });\n                setEditingButtonItem(null);\n              }}\n            />\n          )}\n        </DialogContent>\n      </Dialog>\n    </Card>\n  );\n}\n\ntype ButtonConfigEditorProps = {\n  item: DashboardItem<\"button\">;\n  onSave: (data: { listener_id?: string; label?: string }) => void;\n};\n\nfunction ButtonConfigEditor({ item, onSave }: ButtonConfigEditorProps) {\n  const [listenerId, setListenerId] = useState(item.data?.listener_id ?? \"\");\n  const [label, setLabel] = useState(item.label ?? \"\");\n\n  const handleSave = () => {\n    const trimmedId = listenerId.trim();\n    if (trimmedId && !isValidListenerId(trimmedId)) {\n      toast.error(\"Invalid listener ID\", {\n        description: \"Use only letters, numbers, _ and -\",\n      });\n      return;\n    }\n    onSave({\n      listener_id: trimmedId || undefined,\n      label: label.trim() || undefined,\n    });\n  };\n\n  return (\n    <div className='flex flex-col gap-4'>\n      <div className='grid gap-2'>\n        <Label htmlFor='button-label'>Label</Label>\n        <Input\n          id='button-label'\n          value={label}\n          placeholder='Button text'\n          onChange={(e) => setLabel(e.target.value)}\n        />\n      </div>\n      <div className='grid gap-2'>\n        <Label htmlFor='listener-id'>Listener ID</Label>\n        <Input\n          id='listener-id'\n          className='font-mono'\n          value={listenerId}\n          placeholder='e.g. lights-toggle'\n          onChange={(e) => setListenerId(e.target.value)}\n        />\n      </div>\n      <Button onClick={handleSave}>Save</Button>\n    </div>\n  );\n}\n\nfunction Placeholder({ text }: { text: string }) {\n  return (\n    <div className='flex h-full items-center justify-center border border-dashed text-xs text-muted-foreground'>\n      {text}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/dynamic-dashboard/events/dispatcher.test.ts",
    "content": "import { describe, it, expect, vi, beforeEach, afterEach } from \"vitest\";\nimport { createDashboardEventDispatcher } from \"./dispatcher\";\nimport { DASHBOARD_COMPONENT_EVENT_VERSION } from \"@/entities/dynamic-dashboard/interaction\";\n\ndescribe(\"createDashboardEventDispatcher\", () => {\n  const baseCtx = {\n    dashboard_id: \"d1\",\n    group_id: \"g1\",\n    item_id: \"i1\",\n    listener_id: \"btn-a\",\n    component_type: \"button\" as const,\n    action: \"click\" as const,\n    source_session_id: \"sess-1\",\n  };\n\n  beforeEach(() => {\n    vi.useFakeTimers({ toFake: [\"Date\"] });\n  });\n\n  afterEach(() => {\n    vi.useRealTimers();\n  });\n\n  it(\"sends dashboard_component_event v2 with listener_id\", () => {\n    const send = vi.fn();\n    const d = createDashboardEventDispatcher({\n      send,\n      isConnected: () => true,\n    });\n    d.dispatch(baseCtx);\n    expect(send).toHaveBeenCalledTimes(1);\n    const msg = send.mock.calls[0][0] as {\n      type: string;\n      payload: {\n        event_version: number;\n        listener_id: string;\n      };\n    };\n    expect(msg.type).toBe(\"dashboard_component_event\");\n    expect(msg.payload.event_version).toBe(DASHBOARD_COMPONENT_EVENT_VERSION);\n    expect(msg.payload.listener_id).toBe(\"btn-a\");\n  });\n\n  it(\"does not send when disconnected\", () => {\n    const send = vi.fn();\n    const d = createDashboardEventDispatcher({\n      send,\n      isConnected: () => false,\n    });\n    d.dispatch(baseCtx);\n    expect(send).not.toHaveBeenCalled();\n  });\n\n  it(\"enforces cooldown between same listener+item+action\", () => {\n    const send = vi.fn();\n    const d = createDashboardEventDispatcher({\n      send,\n      isConnected: () => true,\n    });\n    const t0 = new Date(\"2024-01-01T00:00:00.000Z\").getTime();\n    vi.setSystemTime(t0);\n    d.dispatch({ ...baseCtx, cooldown_ms: 500 });\n    d.dispatch({ ...baseCtx, cooldown_ms: 500 });\n    expect(send).toHaveBeenCalledTimes(1);\n    vi.setSystemTime(t0 + 600);\n    d.dispatch({ ...baseCtx, cooldown_ms: 500 });\n    expect(send).toHaveBeenCalledTimes(2);\n  });\n});\n"
  },
  {
    "path": "apps/client/src/features/dynamic-dashboard/events/dispatcher.ts",
    "content": "import {\n  DASHBOARD_COMPONENT_EVENT_VERSION,\n  type DashboardComponentEventPayload,\n  type DashboardComponentAction,\n  type DashboardComponentType,\n} from \"@/entities/dynamic-dashboard/interaction\";\nimport type { WebSocketMessage } from \"@/features/ws/ws\";\n\nexport const DEFAULT_DASHBOARD_COOLDOWN_MS = 320;\nexport const MIN_DASHBOARD_COOLDOWN_MS = 100;\n\nconst createEventId = () =>\n  typeof crypto !== \"undefined\" && \"randomUUID\" in crypto\n    ? crypto.randomUUID()\n    : `evt_${Date.now()}_${Math.random().toString(36).slice(2)}`;\n\nexport type DashboardEventDispatchContext = {\n  dashboard_id: string;\n  group_id: string;\n  item_id: string;\n  listener_id: string;\n  component_type: DashboardComponentType;\n  action: DashboardComponentAction;\n  value?: unknown;\n  source_session_id: string;\n  debounce_ms?: number;\n  cooldown_ms?: number;\n};\n\nexport type DashboardEventDispatcherDeps = {\n  send: (msg: WebSocketMessage) => void;\n  isConnected: () => boolean;\n};\n\n/**\n * Coalesces rapid events (debounce) and enforces cooldown between sends.\n */\nexport function createDashboardEventDispatcher(deps: DashboardEventDispatcherDeps) {\n  const lastSentByKey = new Map<string, number>();\n  const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();\n\n  const cooldownMsFor = (ctx: DashboardEventDispatchContext) =>\n    Math.max(\n      MIN_DASHBOARD_COOLDOWN_MS,\n      ctx.cooldown_ms ?? DEFAULT_DASHBOARD_COOLDOWN_MS,\n    );\n\n  const debounceFor = (ctx: DashboardEventDispatchContext) =>\n    Math.max(0, ctx.debounce_ms ?? 0);\n\n  function buildPayload(\n    ctx: DashboardEventDispatchContext,\n  ): DashboardComponentEventPayload {\n    return {\n      event_version: DASHBOARD_COMPONENT_EVENT_VERSION,\n      event_id: createEventId(),\n      occurred_at: new Date().toISOString(),\n      listener_id: ctx.listener_id.trim(),\n      dashboard_id: ctx.dashboard_id,\n      group_id: ctx.group_id,\n      item_id: ctx.item_id,\n      component_type: ctx.component_type,\n      action: ctx.action,\n      value: ctx.value,\n      source_session_id: ctx.source_session_id,\n      cooldown_ms: ctx.cooldown_ms,\n    };\n  }\n\n  function sendNow(ctx: DashboardEventDispatchContext) {\n    if (!deps.isConnected()) {\n      return;\n    }\n    const key = `${ctx.listener_id}:${ctx.item_id}:${ctx.action}`;\n    const now = Date.now();\n    const last = lastSentByKey.get(key) ?? 0;\n    if (now - last < cooldownMsFor(ctx)) {\n      return;\n    }\n    lastSentByKey.set(key, now);\n\n    const payload = buildPayload(ctx);\n    deps.send({\n      type: \"dashboard_component_event\",\n      payload,\n    } as WebSocketMessage);\n  }\n\n  return {\n    dispatch(ctx: DashboardEventDispatchContext) {\n      const ms = debounceFor(ctx);\n      const key = `${ctx.listener_id}:${ctx.item_id}:${ctx.action}`;\n      if (ms <= 0) {\n        sendNow(ctx);\n        return;\n      }\n      const existing = debounceTimers.get(key);\n      if (existing) {\n        clearTimeout(existing);\n      }\n      debounceTimers.set(\n        key,\n        setTimeout(() => {\n          debounceTimers.delete(key);\n          sendNow(ctx);\n        }, ms),\n      );\n    },\n    _reset() {\n      lastSentByKey.clear();\n      for (const t of debounceTimers.values()) {\n        clearTimeout(t);\n      }\n      debounceTimers.clear();\n    },\n  };\n}\n\nexport type DashboardEventDispatcher = ReturnType<\n  typeof createDashboardEventDispatcher\n>;\n"
  },
  {
    "path": "apps/client/src/features/dynamic-dashboard/panels/ButtonPanel.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport type { DashboardItemDataMap } from \"@/entities/dynamic-dashboard/store\";\nimport { isValidListenerId } from \"@/entities/dynamic-dashboard/interaction\";\n\ntype ButtonPanelProps = {\n  data?: DashboardItemDataMap[\"button\"];\n  onDataChange?: (next: NonNullable<DashboardItemDataMap[\"button\"]>) => void;\n  disabled?: boolean;\n};\n\nexport function ButtonPanel({\n  data,\n  onDataChange,\n  disabled,\n}: ButtonPanelProps) {\n  const [listenerId, setListenerId] = useState(data?.listener_id ?? \"\");\n\n  useEffect(() => {\n    setListenerId(data?.listener_id ?? \"\");\n  }, [data?.listener_id]);\n\n  const commit = (raw: string) => {\n    const trimmed = raw.trim();\n    if (!trimmed) {\n      onDataChange?.({ ...data, listener_id: undefined });\n      return;\n    }\n    if (!isValidListenerId(trimmed)) {\n      return;\n    }\n    onDataChange?.({\n      ...data,\n      listener_id: trimmed,\n    });\n  };\n\n  return (\n    <div\n      className='flex flex-col gap-2 rounded-md border border-dashed bg-muted/30 p-2 text-xs'\n      onPointerDown={(e) => e.stopPropagation()}\n    >\n      <div className='grid gap-1'>\n        <Label className='text-[10px] uppercase'>Listener id</Label>\n        <Input\n          className='h-8 font-mono text-xs'\n          disabled={disabled}\n          value={listenerId}\n          placeholder='e.g. lights-toggle'\n          onChange={(e) => setListenerId(e.target.value)}\n          onBlur={() => commit(listenerId)}\n          onKeyDown={(e) => {\n            if (e.key === \"Enter\") {\n              (e.target as HTMLInputElement).blur();\n            }\n          }}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/dynamic-dashboard/panels/FlowPanel.tsx",
    "content": "import { useEffect, useMemo, useState } from \"react\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { useFlowStore } from \"@/entities/flow/store\";\nimport {\n  useWebSocket,\n  useWebSocketMessage,\n} from \"@/features/ws/WebSocketProvider\";\nimport { getFlowRunSessionId, WebSocketMessage } from \"@/features/ws/ws\";\nimport { DashboardItemDataMap } from \"@/entities/dynamic-dashboard/store\";\nimport { Play, Square } from \"lucide-react\";\n\ntype FlowPanelProps = {\n  data?: DashboardItemDataMap[\"flow\"];\n  onFlowChange?: (flowId?: number) => void;\n};\n\ntype FlowStatusPayload = {\n  id: number;\n  name: string;\n  is_running: boolean;\n}[];\n\nexport function FlowPanel({ data, onFlowChange }: FlowPanelProps) {\n  const { flows, fetchFlows } = useFlowStore();\n  const { wsManager } = useWebSocket();\n  const [selectedFlowId, setSelectedFlowId] = useState<number | undefined>(\n    data?.flowId,\n  );\n  const [isRunning, setIsRunning] = useState(false);\n\n  useEffect(() => {\n    fetchFlows().catch((err) => {\n      console.error(\"Failed to fetch flows for flow panel\", err);\n    });\n  }, [fetchFlows]);\n\n  useEffect(() => {\n    if (typeof data?.flowId === \"number\") {\n      setSelectedFlowId(data.flowId);\n    }\n    setIsRunning(data?.autoRun ?? false);\n  }, [data?.autoRun, data?.flowId]);\n\n  const selectedFlowName = useMemo(() => {\n    if (typeof selectedFlowId !== \"number\") return \"No flow selected\";\n    return flows.find((f) => f.id === selectedFlowId)?.name ?? \"Unknown flow\";\n  }, [flows, selectedFlowId]);\n\n  const requestStatus = (flowId?: number) => {\n    if (!wsManager || typeof flowId !== \"number\") {\n      return;\n    }\n    wsManager.send({\n      type: \"get_all_flows\",\n      payload: {},\n    });\n  };\n\n  useWebSocketMessage((msg: WebSocketMessage) => {\n    if (msg.type !== \"get_all_flows_response\" || !msg.payload) return;\n    const payload = msg.payload as FlowStatusPayload;\n    if (!Array.isArray(payload)) return;\n\n    const match = payload.find((flow) => flow.id === selectedFlowId);\n    if (match) {\n      setIsRunning(Boolean(match.is_running));\n    }\n  });\n\n  useEffect(() => {\n    requestStatus(selectedFlowId);\n  }, [selectedFlowId]);\n\n  const handleSelect = (value: string) => {\n    const nextId = Number(value);\n    const validId = Number.isFinite(nextId) ? nextId : undefined;\n    setSelectedFlowId(validId);\n    onFlowChange?.(validId);\n    requestStatus(validId);\n  };\n\n  const handleRun = () => {\n    if (!wsManager || typeof selectedFlowId !== \"number\") {\n      return;\n    }\n    wsManager.send({\n      type: \"compute_flow\",\n      payload: {\n        flow_id: selectedFlowId,\n        run_context: { session_id: getFlowRunSessionId() },\n      },\n    });\n    requestStatus(selectedFlowId);\n  };\n\n  const handleStop = () => {\n    if (!wsManager || typeof selectedFlowId !== \"number\") {\n      return;\n    }\n    wsManager.send({\n      type: \"stop_flow\",\n      payload: { flow_id: selectedFlowId },\n    });\n    requestStatus(selectedFlowId);\n  };\n\n  return (\n    <div className='flex h-full w-full flex-col gap-3'>\n      <div className='flex items-center justify-between gap-2 text-xs'>\n        <Select\n          value={\n            typeof selectedFlowId === \"number\"\n              ? String(selectedFlowId)\n              : undefined\n          }\n          onValueChange={handleSelect}\n        >\n          <SelectTrigger className='h-8 w-[180px]'>\n            <SelectValue placeholder='Select flow' />\n          </SelectTrigger>\n          <SelectContent>\n            {flows.map((flow) => (\n              <SelectItem key={flow.id} value={String(flow.id)}>\n                {flow.name}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n        <Badge variant={isRunning ? \"default\" : \"outline\"}>\n          {isRunning ? \"Running\" : \"Stopped\"}\n        </Badge>\n      </div>\n      <div className='flex flex-1 flex-col justify-between border border-dashed p-3 text-sm'>\n        <div className='space-y-1'>\n          <p className='text-xs text-muted-foreground'>Selected flow</p>\n          <p className='font-semibold'>{selectedFlowName}</p>\n        </div>\n        <div className='flex items-center justify-end gap-2'>\n          <Button\n            size='sm'\n            variant='outline'\n            onClick={handleStop}\n            disabled={!selectedFlowId}\n          >\n            <Square className='mr-1 h-3 w-3' />\n            Stop\n          </Button>\n          <Button size='sm' onClick={handleRun} disabled={!selectedFlowId}>\n            <Play className='mr-1 h-3 w-3' />\n            Run\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/dynamic-dashboard/panels/MapPanel.tsx",
    "content": "import { useEffect, useMemo, useState } from \"react\";\nimport { MapContainer, TileLayer, useMap } from \"react-leaflet\";\nimport \"leaflet/dist/leaflet.css\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { useMapDataStore, useMapInteractionStore } from \"@/entities/map/store\";\nimport { FeatureRenderer } from \"@/features/map-draw/FeatureRenderer\";\nimport { DashboardItemDataMap } from \"@/entities/dynamic-dashboard/store\";\n\ntype MapPanelProps = {\n  data?: DashboardItemDataMap[\"map\"];\n  onLayerChange?: (layerId?: number) => void;\n};\n\nconst FALLBACK_CENTER: [number, number] = [37.5665, 126.978];\nconst FALLBACK_ZOOM = 5;\n\nexport function MapPanel({ data, onLayerChange }: MapPanelProps) {\n  const { layers, layer, activeLayerId, fetchAllLayers, setActiveLayer } =\n    useMapDataStore();\n  const clearSelection = useMapInteractionStore(\n    (state) => state.setSelectedFeature,\n  );\n  const [localLayerId, setLocalLayerId] = useState<number | undefined>(\n    data?.layerId,\n  );\n\n  useEffect(() => {\n    fetchAllLayers().catch((err) => {\n      console.error(\"Failed to fetch layers for map panel\", err);\n    });\n  }, [fetchAllLayers]);\n\n  useEffect(() => {\n    if (typeof data?.layerId === \"number\") {\n      setLocalLayerId(data.layerId);\n    }\n  }, [data?.layerId]);\n\n  useEffect(() => {\n    if (typeof localLayerId !== \"number\" || activeLayerId === localLayerId) {\n      return;\n    }\n\n    setActiveLayer(localLayerId).catch((err) => {\n      console.error(\"Failed to activate layer in map panel\", err);\n    });\n    clearSelection(null);\n  }, [activeLayerId, clearSelection, localLayerId, setActiveLayer]);\n\n  const viewCenter = useMemo<[number, number]>(() => {\n    if (data?.center) return data.center;\n    const positions =\n      layer?.features.flatMap((feature) =>\n        feature.vertices.map(\n          (v) => [v.latitude, v.longitude] as [number, number],\n        ),\n      ) || [];\n\n    if (positions.length === 0) {\n      return FALLBACK_CENTER;\n    }\n\n    const [latSum, lngSum] = positions.reduce(\n      (acc, [lat, lng]) => [acc[0] + lat, acc[1] + lng],\n      [0, 0],\n    );\n    return [latSum / positions.length, lngSum / positions.length];\n  }, [data?.center, layer?.features]);\n\n  const zoom = data?.zoom ?? FALLBACK_ZOOM;\n\n  const handleLayerChange = (value: string) => {\n    const nextId = Number(value);\n    const validId = Number.isFinite(nextId) ? nextId : undefined;\n    setLocalLayerId(validId);\n    onLayerChange?.(validId);\n  };\n\n  return (\n    <div className='flex h-full w-full flex-col gap-2'>\n      <div className='flex items-center justify-between gap-2 text-xs'>\n        <Select\n          value={\n            typeof localLayerId === \"number\" ? String(localLayerId) : undefined\n          }\n          onValueChange={handleLayerChange}\n        >\n          <SelectTrigger className='h-8 w-[180px]'>\n            <SelectValue placeholder='Select layer' />\n          </SelectTrigger>\n          <SelectContent>\n            {layers.map((l) => (\n              <SelectItem key={l.id} value={String(l.id)}>\n                {l.name}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n        <Badge variant='outline'>{layer?.features.length ?? 0} features</Badge>\n      </div>\n      <div className='relative h-full w-full overflow-hidden bg-muted/50'>\n        {typeof localLayerId !== \"number\" ? (\n          <div className='flex h-full items-center justify-center text-xs text-muted-foreground'>\n            Select a layer to preview the map.\n          </div>\n        ) : (\n          <MapContainer\n            center={viewCenter}\n            zoom={zoom}\n            maxZoom={22}\n            scrollWheelZoom\n            zoomControl={false}\n            className='h-full w-full'\n          >\n            <TileLayer\n              attribution='&copy; OpenStreetMap contributors'\n              url='https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'\n              maxNativeZoom={20}\n              maxZoom={22}\n            />\n            {layer?.features.map((feature) => (\n              <FeatureRenderer\n                key={`feature-${feature.id}`}\n                feature={feature}\n              />\n            ))}\n            <ResizeInvalidator />\n          </MapContainer>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction ResizeInvalidator() {\n  const map = useMap();\n\n  useEffect(() => {\n    const container = map.getContainer();\n    const handle = () => map.invalidateSize();\n    handle();\n\n    const observer = new ResizeObserver(() => handle());\n    observer.observe(container);\n\n    return () => observer.disconnect();\n  }, [map]);\n\n  return null;\n}\n"
  },
  {
    "path": "apps/client/src/features/entity/AllEntities.tsx",
    "content": "import { Fragment } from \"react\";\n// import { EntityAll } from \"@/entities/entity/types\";\nimport { EntityCard } from \"./Card\";\nimport {\n  // StreamState,\n  useEntitiesData,\n} from \"@/features/entity/useEntitiesData\";\n\nexport function AllEntities() {\n  const { entities, streamsState } = useEntitiesData();\n\n  return (\n    <>\n      <div className='grid grid-cols-1 gap-4 px-0 sm:grid-cols-2 lg:grid-cols-4 lg:px-6'>\n        {entities.map((item) => (\n          <Fragment key={item.id}>\n            <EntityCard item={item} streamsState={streamsState} />\n          </Fragment>\n        ))}\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/entity/AnalyzeMenuItem.tsx",
    "content": "import { useState } from \"react\";\nimport { Eye, Loader2 } from \"lucide-react\";\nimport { DropdownMenuItem } from \"@/components/ui/dropdown-menu\";\nimport { useWebRTC } from \"../rtc/WebRTCProvider\";\nimport { captureFrameFromStream } from \"../rtc/captureFrame\";\nimport { useChatStore } from \"../llm-chat/store\";\nimport { cn } from \"@/lib/utils\";\n\ninterface AnalyzeMenuItemProps {\n  topic: string;\n  className?: string;\n}\n\nexport function AnalyzeMenuItem({ topic, className }: AnalyzeMenuItemProps) {\n  const { streams } = useWebRTC();\n  const { setPendingImage, openPanel } = useChatStore();\n  const [isCapturing, setIsCapturing] = useState(false);\n\n  const streamInfo = streams.get(topic);\n  const hasStream = !!streamInfo?.stream;\n\n  const handleClick = async () => {\n    if (!streamInfo?.stream) return;\n\n    setIsCapturing(true);\n    try {\n      const frameFile = await captureFrameFromStream(streamInfo.stream);\n      setPendingImage(frameFile);\n      openPanel();\n    } catch (error) {\n      console.error(\"Frame capture failed:\", error);\n    } finally {\n      setIsCapturing(false);\n    }\n  };\n\n  return (\n    <DropdownMenuItem\n      onClick={handleClick}\n      disabled={!hasStream || isCapturing}\n      className={cn(\"flex items-center gap-2\", className)}\n    >\n      {isCapturing ? (\n        <>\n          <Loader2 className='h-4 w-4 animate-spin' />\n          <span>Capturing...</span>\n        </>\n      ) : (\n        <>\n          <Eye className='h-4 w-4' />\n          <span>Analyze Frame</span>\n        </>\n      )}\n    </DropdownMenuItem>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/entity/Card.tsx",
    "content": "import { useState } from \"react\";\nimport { EntityAll } from \"@/entities/entity/types\";\nimport {\n  Card,\n  CardHeader,\n  CardDescription,\n  CardTitle,\n  CardFooter,\n} from \"@/components/ui/card\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Button } from \"@/components/ui/button\";\nimport { MoreVertical } from \"lucide-react\";\nimport { formatSimpleDateTime } from \"@/lib/time\";\nimport { StreamReceiver } from \"../rtc/StreamReceiver\";\nimport { RecordingMenuItem } from \"../recording/RecordingButton\";\nimport { AnalyzeMenuItem } from \"./AnalyzeMenuItem\";\nimport { StateHistorySheet } from \"./StateHistorySheet\";\nimport { useNavigate } from \"react-router\";\n\ntype StreamState = {\n  topic: string;\n  is_online: boolean;\n};\n\nconst isEnabledStream = (topic: string, streams?: StreamState[]) => {\n  if (!streams) {\n    return false;\n  }\n\n  const index = streams.findIndex((item) => item.topic == topic);\n  if (index == -1) {\n    return false;\n  }\n\n  return streams[index].is_online;\n};\n\nexport function EntityCard({\n  item,\n  streamsState,\n}: {\n  item: EntityAll;\n  streamsState?: StreamState[];\n}) {\n  const navigate = useNavigate();\n\n  if (item.platform === \"sdr\") {\n    return (\n      <Card key={item.id}>\n        <CardHeader className='px-4'>\n          <CardDescription>{item.friendly_name}</CardDescription>\n          <CardTitle className='text-2xl font-semibold tabular-nums'>\n            RTL-SDR\n          </CardTitle>\n        </CardHeader>\n        <CardFooter className='flex-row items-center justify-between px-4 text-sm'>\n          <Button\n            size='sm'\n            variant='outline'\n            className='w-full'\n            onClick={() => navigate(\"/dashboard?view=sdr\")}\n          >\n            Open Panel\n          </Button>\n        </CardFooter>\n      </Card>\n    );\n  }\n  if (\n    item.platform == \"UDP\" &&\n    item.configuration &&\n    item.entity_type == \"AUDIO\"\n  ) {\n    return (\n      <Card key={item.id}>\n        <CardHeader className='px-4'>\n          <CardDescription>{item.friendly_name}</CardDescription>\n          <CardTitle className='text-2xl font-semibold tabular-nums'>\n            {item.configuration && (\n              <StreamReceiver\n                topic={item.configuration.state_topic as string}\n                streamType='audio'\n              />\n            )}\n          </CardTitle>\n        </CardHeader>\n        <CardFooter className='flex-row items-center justify-between px-4 text-sm'>\n          <div className='flex flex-col gap-1'>\n            <div className='font-medium'>{item.platform}</div>\n            <div className='text-muted-foreground'>\n              {isEnabledStream(\n                item.configuration.state_topic as string,\n                streamsState,\n              ) ? (\n                <Online />\n              ) : (\n                <Offline />\n              )}\n            </div>\n          </div>\n          <DropdownMenu>\n            <DropdownMenuTrigger asChild>\n              <Button variant='ghost' size='icon' className='h-8 w-8'>\n                <MoreVertical className='h-4 w-4' />\n              </Button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align='end'>\n              <RecordingMenuItem\n                topic={item.configuration.state_topic as string}\n              />\n            </DropdownMenuContent>\n          </DropdownMenu>\n        </CardFooter>\n      </Card>\n    );\n  }\n\n  if (\n    item.platform == \"RTSP\" &&\n    item.configuration &&\n    item.entity_type == \"VIDEO\"\n  ) {\n    return (\n      <Card key={item.id}>\n        <CardHeader className='px-4'>\n          <CardDescription>{item.friendly_name}</CardDescription>\n          <CardTitle className='text-2xl font-semibold tabular-nums'>\n            {item.configuration && (\n              <StreamReceiver\n                topic={item.configuration.rtsp_url as string}\n                streamType='video'\n              />\n            )}\n          </CardTitle>\n        </CardHeader>\n        <CardFooter className='flex-row items-center justify-between px-4 text-sm'>\n          <div className='flex flex-col gap-1'>\n            <div className='font-medium'>{item.platform}</div>\n            <div className='text-muted-foreground'>\n              {isEnabledStream(\n                item.configuration.rtsp_url as string,\n                streamsState,\n              ) ? (\n                <Online />\n              ) : (\n                <Offline />\n              )}\n            </div>\n          </div>\n          <DropdownMenu>\n            <DropdownMenuTrigger asChild>\n              <Button variant='ghost' size='icon' className='h-8 w-8'>\n                <MoreVertical className='h-4 w-4' />\n              </Button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align='end'>\n              <RecordingMenuItem\n                topic={item.configuration.rtsp_url as string}\n              />\n              <AnalyzeMenuItem topic={item.configuration.rtsp_url as string} />\n            </DropdownMenuContent>\n          </DropdownMenu>\n        </CardFooter>\n      </Card>\n    );\n  }\n\n  if (\n    item.platform == \"UDP\" &&\n    item.configuration &&\n    item.entity_type == \"VIDEO\"\n  ) {\n    return (\n      <Card key={item.id}>\n        <CardHeader className='px-4'>\n          <CardDescription>{item.friendly_name}</CardDescription>\n          <CardTitle className='text-2xl font-semibold tabular-nums'>\n            {item.configuration && (\n              <StreamReceiver\n                topic={item.configuration.state_topic as string}\n                streamType='video'\n              />\n            )}\n          </CardTitle>\n        </CardHeader>\n        <CardFooter className='flex-row items-center justify-between px-4 text-sm'>\n          <div className='flex flex-col gap-1'>\n            <div className='font-medium'>{item.platform}</div>\n            <div className='text-muted-foreground'>\n              {isEnabledStream(\n                item.configuration.state_topic as string,\n                streamsState,\n              ) ? (\n                <Online />\n              ) : (\n                <Offline />\n              )}\n            </div>\n          </div>\n          <DropdownMenu>\n            <DropdownMenuTrigger asChild>\n              <Button variant='ghost' size='icon' className='h-8 w-8'>\n                <MoreVertical className='h-4 w-4' />\n              </Button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align='end'>\n              <RecordingMenuItem\n                topic={item.configuration.state_topic as string}\n              />\n              <AnalyzeMenuItem\n                topic={item.configuration.state_topic as string}\n              />\n            </DropdownMenuContent>\n          </DropdownMenu>\n        </CardFooter>\n      </Card>\n    );\n  }\n\n  return <DefaultEntityCard item={item} />;\n}\n\nfunction DefaultEntityCard({ item }: { item: EntityAll }) {\n  const [historyOpen, setHistoryOpen] = useState(false);\n\n  return (\n    <>\n      <Card key={item.id}>\n        <CardHeader className='px-4'>\n          <CardDescription>{item.friendly_name}</CardDescription>\n          <button\n            type='button'\n            onClick={() => setHistoryOpen(true)}\n            className='block w-full min-w-0 max-w-full text-left cursor-pointer hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded'\n          >\n            <CardTitle className='text-2xl font-semibold font-mono tabular-nums truncate'>\n              {item.state?.state || \"N/A\"}\n            </CardTitle>\n          </button>\n        </CardHeader>\n        <CardFooter className='flex-col items-start gap-1 px-4 text-sm'>\n          <div className='font-medium'>{item.platform}</div>\n          <div className='text-muted-foreground'>\n            {formatSimpleDateTime(item.state?.last_updated || \"\")}\n          </div>\n        </CardFooter>\n      </Card>\n      <StateHistorySheet\n        entityId={item.entity_id}\n        entityName={item.friendly_name}\n        open={historyOpen}\n        onOpenChange={setHistoryOpen}\n      />\n    </>\n  );\n}\n\nfunction Dot({ color }: { color: string }) {\n  return <span className={`w-2 h-2 ${color} rounded`}></span>;\n}\n\nfunction Offline() {\n  return (\n    <span className='flex flex-row text-gray-600 justify-center items-center gap-1'>\n      <Dot color='bg-gray-600' /> Offline\n    </span>\n  );\n}\n\nfunction Online() {\n  return (\n    <span className='flex flex-row text-emerald-500 justify-center items-center gap-1 '>\n      <Dot color='bg-emerald-500' /> Online\n    </span>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/entity/EntityCreateButton.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { AlertCircleIcon, PlusCircle } from \"lucide-react\";\nimport { useDeviceStore } from \"@/entities/device/store\";\nimport { useEntityStore } from \"@/entities/entity/store\";\nimport { EntityPayload } from \"@/entities/entity/types\";\nimport {\n  Select,\n  SelectContent,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { JsonCodeEditor } from \"../json/JsonEditor\";\nimport { EntitySelectTypes } from \"./SelectTypes\";\nimport { EntitySelectPlatforms } from \"./SelectPlatforms\";\nimport { toast } from \"sonner\";\nimport { Alert, AlertTitle, AlertDescription } from \"@/components/ui/alert\";\n\nexport function EntityCreateButton() {\n  const [isOpen, setIsOpen] = useState(false);\n  const [jsonError, setJsonError] = useState<string | null>(null);\n\n  const createEntity = useEntityStore((state) => state.createEntity);\n  const selectedDevice = useDeviceStore((state) => state.selectedDevice);\n  const { register, handleSubmit, reset, watch, control, setValue } =\n    useForm<EntityPayload>();\n\n  const entityId = watch(\"entity_id\");\n\n  useEffect(() => {\n    if (isOpen && selectedDevice) {\n      reset({\n        entity_id: `${selectedDevice.device_id}-`,\n        friendly_name: \"\",\n        platform: \"\",\n        configuration: \"\",\n        entity_type: \"\",\n      });\n    }\n  }, [isOpen, selectedDevice, reset]);\n\n  useEffect(() => {\n    if (entityId) {\n      const friendlyName = entityId\n        .split(\"-\")\n        .filter(Boolean)\n        .map((word) => word.charAt(0).toUpperCase() + word.slice(1))\n        .join(\" \");\n      setValue(\"friendly_name\", friendlyName);\n    }\n  }, [entityId, setValue]);\n\n  useEffect(() => {\n    if (entityId && entityId.includes(\" \")) {\n      setValue(\"entity_id\", entityId.replace(/ /g, \"-\"));\n    }\n  }, [entityId, setValue]);\n\n  const onSubmit = async (data: EntityPayload) => {\n    if (!selectedDevice) return;\n    let configPayload: string | null = null;\n\n    try {\n      if (data.configuration && data.configuration.trim() !== \"\") {\n        configPayload = JSON.parse(data.configuration);\n      }\n    } catch (error) {\n      console.error(\"Invalid JSON format:\", error);\n      toast(\"Invalid JSON format. Please check the syntax.\");\n      setJsonError(\"Invalid JSON format. Please check the syntax.\");\n\n      return;\n    }\n\n    const payload = {\n      ...data,\n      device_id: selectedDevice.id,\n      configuration: configPayload,\n    };\n\n    await createEntity(payload);\n    setIsOpen(false);\n  };\n\n  if (!selectedDevice) return null;\n\n  return (\n    <Dialog open={isOpen} onOpenChange={setIsOpen}>\n      <DialogTrigger asChild>\n        <Button size='sm'>\n          <PlusCircle className='h-4 w-4 mr-2' />\n          New Entity\n        </Button>\n      </DialogTrigger>\n      <DialogContent>\n        <DialogHeader>\n          <DialogTitle>Create New Entity for {selectedDevice.name}</DialogTitle>\n        </DialogHeader>\n        <form onSubmit={handleSubmit(onSubmit)} className='space-y-4 py-2'>\n          <div className='space-y-2'>\n            <Label htmlFor='entity_id_create'>Entity ID</Label>\n            <Input\n              id='entity_id_create'\n              {...register(\"entity_id\", { required: true })}\n            />\n          </div>\n          <div className='space-y-2'>\n            <Label htmlFor='friendly_name_create'>Friendly Name</Label>\n            <Input id='friendly_name_create' {...register(\"friendly_name\")} />\n          </div>\n          <div className='space-y-2'>\n            <Label htmlFor='platform_update'>Platform (Protocol)</Label>\n            <Controller\n              control={control}\n              name='platform'\n              render={({ field }) => (\n                <Select\n                  onValueChange={field.onChange}\n                  defaultValue={field.value ?? \"\"}\n                >\n                  <SelectTrigger id='platform_update' className='w-full'>\n                    <SelectValue placeholder='Select a platform' />\n                  </SelectTrigger>\n                  <SelectContent className='w-full'>\n                    <EntitySelectPlatforms />\n                  </SelectContent>\n                </Select>\n              )}\n            />\n          </div>\n          <div className='space-y-2'>\n            <Label htmlFor='entity_type'>Type</Label>\n            <Controller\n              control={control}\n              name='entity_type'\n              render={({ field }) => (\n                <Select\n                  onValueChange={field.onChange}\n                  defaultValue={field.value ?? \"\"}\n                >\n                  <SelectTrigger id='entity_type' className='w-full'>\n                    <SelectValue placeholder='Select a type' />\n                  </SelectTrigger>\n                  <SelectContent className='w-full'>\n                    <EntitySelectTypes />\n                  </SelectContent>\n                </Select>\n              )}\n            />\n          </div>\n\n          <div className='space-y-2'>\n            <Label htmlFor='configuration_update'>Configuration (JSON)</Label>\n            <Controller\n              control={control}\n              name='configuration'\n              render={({ field }) => (\n                <JsonCodeEditor\n                  value={field.value ?? \"\"}\n                  onChange={field.onChange}\n                />\n              )}\n            />\n            {jsonError && (\n              <Alert variant='destructive'>\n                <AlertCircleIcon className='w-5' />\n                <AlertTitle>Error</AlertTitle>\n                <AlertDescription>\n                  <p>{jsonError}</p>\n                </AlertDescription>\n              </Alert>\n            )}\n          </div>\n\n          <DialogFooter>\n            <Button\n              type='button'\n              variant='outline'\n              onClick={() => setIsOpen(false)}\n            >\n              Cancel\n            </Button>\n            <Button type='submit'>Save</Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/entity/EntityDeleteButton.tsx",
    "content": "import { useState } from \"react\";\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\nimport { Trash2 } from \"lucide-react\";\nimport { useEntityStore } from \"@/entities/entity/store\";\nimport { DropdownMenuItem } from \"@/components/ui/dropdown-menu\";\n\ninterface Props {\n  entityId: number;\n}\n\nexport function EntityDeleteButton({ entityId }: Props) {\n  const [isOpen, setIsOpen] = useState(false);\n  const deleteEntity = useEntityStore((state) => state.deleteEntity);\n\n  const handleDelete = async () => {\n    await deleteEntity(entityId);\n    setIsOpen(false);\n  };\n\n  return (\n    <AlertDialog open={isOpen} onOpenChange={setIsOpen}>\n      <AlertDialogTrigger asChild>\n        <DropdownMenuItem\n          onSelect={(e) => e.preventDefault()}\n          className='text-red-500 focus:text-red-500'\n        >\n          <Trash2 className='mr-2 h-4 w-4 text-red-500 focus:text-red-500' />\n          <span>Delete</span>\n        </DropdownMenuItem>\n      </AlertDialogTrigger>\n      <AlertDialogContent>\n        <AlertDialogHeader>\n          <AlertDialogTitle>Are you sure?</AlertDialogTitle>\n          <AlertDialogDescription>\n            This action cannot be undone. This will permanently delete the\n            entity.\n          </AlertDialogDescription>\n        </AlertDialogHeader>\n        <AlertDialogFooter>\n          <AlertDialogCancel>Cancel</AlertDialogCancel>\n          <AlertDialogAction\n            onClick={handleDelete}\n            className='bg-red-600 hover:bg-red-700 text-white'\n          >\n            Delete\n          </AlertDialogAction>\n        </AlertDialogFooter>\n      </AlertDialogContent>\n    </AlertDialog>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/entity/EntityUpdateButton.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { useForm, Controller } from \"react-hook-form\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { AlertCircleIcon, Pencil } from \"lucide-react\";\nimport { useEntityStore } from \"@/entities/entity/store\";\nimport { Entity, EntityPayload } from \"@/entities/entity/types\";\nimport { DropdownMenuItem } from \"@/components/ui/dropdown-menu\";\nimport { JsonCodeEditor } from \"../json/JsonEditor\";\nimport { EntitySelectTypes } from \"./SelectTypes\";\nimport { EntitySelectPlatforms } from \"./SelectPlatforms\";\nimport { toast } from \"sonner\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\n\ninterface Props {\n  entity: Entity;\n  defaultOpen?: boolean;\n}\n\ntype EntityFormValues = Omit<EntityPayload, \"configuration\"> & {\n  configuration: string;\n};\n\nexport function EntityUpdateButton({ entity, defaultOpen = false }: Props) {\n  const [isOpen, setIsOpen] = useState(defaultOpen);\n  const [jsonError, setJsonError] = useState<string | null>(null);\n  const updateEntity = useEntityStore((state) => state.updateEntity);\n  const setAutoOpenEntityId = useEntityStore((state) => state.setAutoOpenEntityId);\n  const { register, handleSubmit, control, watch, setValue } =\n    useForm<EntityFormValues>({\n      defaultValues: {\n        entity_id: entity.entity_id,\n        device_id: entity.device_id,\n        friendly_name: entity.friendly_name ?? \"\",\n        platform: entity.platform ?? \"\",\n        entity_type: entity.entity_type ?? \"\",\n        configuration: entity.configuration\n          ? JSON.stringify(entity.configuration, null, 2)\n          : \"\",\n      },\n    });\n\n  const entityId = watch(\"entity_id\");\n\n  useEffect(() => {\n    if (entityId && entityId.includes(\" \")) {\n      setValue(\"entity_id\", entityId.replace(/ /g, \"-\"));\n    }\n  }, [entityId, setValue]);\n\n  const onSubmit = async (data: EntityFormValues) => {\n    setJsonError(null);\n    let configPayload: string | null = null;\n\n    try {\n      if (data.configuration && data.configuration.trim() !== \"\") {\n        configPayload = JSON.parse(data.configuration);\n      }\n    } catch (error) {\n      console.error(\"Invalid JSON format:\", error);\n      toast(\"Invalid JSON format. Please check the syntax.\");\n      setJsonError(\"Invalid JSON format. Please check the syntax.\");\n      return;\n    }\n\n    const finalPayload: EntityPayload = {\n      ...data,\n      configuration: configPayload,\n    };\n\n    await updateEntity(entity.id, finalPayload);\n    setIsOpen(false);\n    if (defaultOpen) {\n      setAutoOpenEntityId(null);\n    }\n  };\n\n  const handleOpenChange = (open: boolean) => {\n    setIsOpen(open);\n    if (!open && defaultOpen) {\n      setAutoOpenEntityId(null);\n    }\n  };\n\n  return (\n    <Dialog open={isOpen} onOpenChange={handleOpenChange}>\n      {!defaultOpen && (\n        <DialogTrigger asChild>\n          <DropdownMenuItem onSelect={(e) => e.preventDefault()}>\n            <Pencil className='mr-2 h-4 w-4' />\n            <span>Edit</span>\n          </DropdownMenuItem>\n        </DialogTrigger>\n      )}\n      <DialogContent>\n        <DialogHeader>\n          <DialogTitle>Edit Entity</DialogTitle>\n        </DialogHeader>\n        <form onSubmit={handleSubmit(onSubmit)} className='space-y-4 py-2'>\n          <div className='space-y-2'>\n            <Label htmlFor='entity_id_update'>Entity ID</Label>\n            <Input\n              id='entity_id_update'\n              {...register(\"entity_id\", { required: true })}\n            />\n          </div>\n          <div className='space-y-2'>\n            <Label htmlFor='friendly_name_update'>Friendly Name</Label>\n            <Input id='friendly_name_update' {...register(\"friendly_name\")} />\n          </div>\n          <div className='space-y-2'>\n            <Label htmlFor='platform_update'>Platform (Protocol)</Label>\n            <Controller\n              control={control}\n              name='platform'\n              render={({ field }) => (\n                <Select\n                  onValueChange={field.onChange}\n                  defaultValue={field.value ?? \"\"}\n                >\n                  <SelectTrigger id='platform_update' className='w-full'>\n                    <SelectValue placeholder='Select a platform' />\n                  </SelectTrigger>\n                  <SelectContent className='w-full'>\n                    <EntitySelectPlatforms />\n                  </SelectContent>\n                </Select>\n              )}\n            />\n          </div>\n\n          <div className='space-y-2'>\n            <Label htmlFor='entity_type_update'>Type</Label>\n            <Controller\n              control={control}\n              name='entity_type'\n              render={({ field }) => (\n                <Select\n                  onValueChange={field.onChange}\n                  defaultValue={field.value ?? \"\"}\n                >\n                  <SelectTrigger id='entity_type_update' className='w-full'>\n                    <SelectValue placeholder='Select a type' />\n                  </SelectTrigger>\n                  <SelectContent className='w-full'>\n                    <EntitySelectTypes />\n                  </SelectContent>\n                </Select>\n              )}\n            />\n          </div>\n\n          <div className='space-y-2'>\n            <Label htmlFor='configuration_update'>Configuration (JSON)</Label>\n            <Controller\n              control={control}\n              name='configuration'\n              render={({ field }) => (\n                <JsonCodeEditor value={field.value} onChange={field.onChange} />\n              )}\n            />\n            {jsonError && (\n              <Alert variant='destructive'>\n                <AlertCircleIcon className='w-5' />\n                <AlertTitle>Error</AlertTitle>\n                <AlertDescription>\n                  <p>{jsonError}</p>\n                </AlertDescription>\n              </Alert>\n            )}\n          </div>\n          <DialogFooter>\n            <Button\n              type='button'\n              variant='outline'\n              onClick={() => setIsOpen(false)}\n            >\n              Cancel\n            </Button>\n            <Button type='submit'>Save Changes</Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/entity/SelectPlatforms.tsx",
    "content": "import { SelectItem } from \"@/components/ui/select\";\n\nexport function EntitySelectPlatforms() {\n  return (\n    <>\n      <SelectItem value='MQTT'>MQTT</SelectItem>\n      <SelectItem value='UDP'>RTP over UDP</SelectItem>\n      <SelectItem value='RTSP'>RTSP</SelectItem>\n      <SelectItem value='HTTP'>HTTP</SelectItem>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/entity/SelectTypes.tsx",
    "content": "import { SelectItem } from \"@/components/ui/select\";\nimport { Baseline, Database, Locate, Play } from \"lucide-react\";\n\nexport function EntitySelectTypes() {\n  return (\n    <>\n      <SelectItem value='NONE'>\n        <Database /> NONE\n      </SelectItem>\n      <SelectItem value='AUDIO'>\n        <Play /> AUDIO\n      </SelectItem>\n      <SelectItem value='GPS'>\n        <Locate /> GPS\n      </SelectItem>\n      <SelectItem value='TEXT'>\n        <Baseline /> TEXT\n      </SelectItem>\n      <SelectItem value='VIDEO'>\n        <Play /> VIDEO\n      </SelectItem>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/entity/StateHistorySheet.tsx",
    "content": "import { useEffect, useMemo, useRef, useState } from \"react\";\nimport { scaleTime } from \"d3-scale\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetHeader,\n  SheetTitle,\n} from \"@/components/ui/sheet\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { formatSimpleDateTime } from \"@/lib/time\";\nimport * as api from \"@/entities/entity/api\";\nimport type { State } from \"@/entities/entity/types\";\n\ntype Props = {\n  entityId: string;\n  entityName: string;\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n};\n\nconst STRIP_WIDTH = 360;\nconst STRIP_HEIGHT = 80;\nconst STRIP_PADDING_X = 16;\nconst AXIS_Y = STRIP_HEIGHT - 24;\n\nexport function StateHistorySheet({\n  entityId,\n  entityName,\n  open,\n  onOpenChange,\n}: Props) {\n  const [history, setHistory] = useState<State[] | null>(null);\n  const [loading, setLoading] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const [now, setNow] = useState<Date>(() => new Date());\n  const requestId = useRef(0);\n\n  useEffect(() => {\n    if (!open) {\n      return;\n    }\n    const id = ++requestId.current;\n    setLoading(true);\n    setError(null);\n    setNow(new Date());\n\n    api\n      .getEntityHistory(entityId)\n      .then((res) => {\n        if (id !== requestId.current) return;\n        setHistory(res.data);\n        setLoading(false);\n      })\n      .catch((err) => {\n        if (id !== requestId.current) return;\n        console.error(\"Failed to fetch entity history:\", err);\n        setError(\"Failed to load history.\");\n        setLoading(false);\n      });\n  }, [open, entityId]);\n\n  const sortedAsc = useMemo(() => {\n    if (!history) return [];\n    return [...history].sort(\n      (a, b) =>\n        new Date(a.last_updated).getTime() -\n        new Date(b.last_updated).getTime(),\n    );\n  }, [history]);\n\n  const sortedDesc = useMemo(() => [...sortedAsc].reverse(), [sortedAsc]);\n\n  const firstRecord = sortedAsc[0];\n  const lastRecord = sortedAsc[sortedAsc.length - 1];\n\n  const xScale = useMemo(() => {\n    if (!firstRecord) return null;\n    const start = new Date(firstRecord.last_updated);\n    const end = now > new Date(lastRecord.last_updated) ? now : new Date(lastRecord.last_updated);\n    return scaleTime()\n      .domain([start, end])\n      .range([STRIP_PADDING_X, STRIP_WIDTH - STRIP_PADDING_X]);\n  }, [firstRecord, lastRecord, now]);\n\n  return (\n    <Sheet open={open} onOpenChange={onOpenChange}>\n      <SheetContent side='right' className='w-full sm:max-w-md flex flex-col'>\n        <SheetHeader>\n          <SheetTitle className='truncate'>{entityName || entityId}</SheetTitle>\n          <SheetDescription className='truncate'>\n            <span className='font-mono text-xs'>{entityId}</span>\n          </SheetDescription>\n        </SheetHeader>\n\n        <div className='flex flex-col gap-4 px-4 pb-4 flex-1 min-h-0'>\n          {loading && (\n            <div className='flex flex-col gap-2'>\n              <Skeleton className='h-20 w-full' />\n              <Skeleton className='h-6 w-full' />\n              <Skeleton className='h-6 w-full' />\n              <Skeleton className='h-6 w-full' />\n            </div>\n          )}\n\n          {!loading && error && (\n            <div className='text-sm text-destructive'>{error}</div>\n          )}\n\n          {!loading && !error && sortedAsc.length === 0 && (\n            <div className='text-sm text-muted-foreground'>\n              No state history recorded yet.\n            </div>\n          )}\n\n          {!loading && !error && sortedAsc.length > 0 && xScale && (\n            <>\n              <div className='grid grid-cols-3 gap-2 text-xs'>\n                <SummaryCell\n                  label='First'\n                  value={formatSimpleDateTime(firstRecord!.last_updated)}\n                />\n                <SummaryCell\n                  label='Last'\n                  value={formatSimpleDateTime(lastRecord!.last_updated)}\n                />\n                <SummaryCell\n                  label='Changes'\n                  value={`${sortedAsc.length}`}\n                />\n              </div>\n\n              <TooltipProvider delayDuration={100}>\n                <svg\n                  width='100%'\n                  viewBox={`0 0 ${STRIP_WIDTH} ${STRIP_HEIGHT}`}\n                  className='border rounded bg-muted/30'\n                >\n                  <line\n                    x1={STRIP_PADDING_X}\n                    x2={STRIP_WIDTH - STRIP_PADDING_X}\n                    y1={AXIS_Y}\n                    y2={AXIS_Y}\n                    className='stroke-muted-foreground/40'\n                    strokeWidth={1}\n                  />\n                  <text\n                    x={STRIP_PADDING_X}\n                    y={AXIS_Y + 16}\n                    className='fill-muted-foreground'\n                    fontSize={10}\n                  >\n                    first\n                  </text>\n                  <text\n                    x={STRIP_WIDTH - STRIP_PADDING_X}\n                    y={AXIS_Y + 16}\n                    textAnchor='end'\n                    className='fill-muted-foreground'\n                    fontSize={10}\n                  >\n                    now\n                  </text>\n                  {sortedAsc.map((record) => {\n                    const cx = xScale(new Date(record.last_updated));\n                    return (\n                      <Tooltip key={record.state_id}>\n                        <TooltipTrigger asChild>\n                          <circle\n                            cx={cx}\n                            cy={AXIS_Y}\n                            r={4}\n                            className='fill-primary cursor-pointer hover:r-6'\n                          />\n                        </TooltipTrigger>\n                        <TooltipContent side='top' className='z-1000'>\n                          <div className='text-xs'>\n                            <div className='font-mono'>\n                              {formatSimpleDateTime(record.last_updated)}\n                            </div>\n                            <div className='font-semibold'>\n                              {record.state ?? \"—\"}\n                            </div>\n                          </div>\n                        </TooltipContent>\n                      </Tooltip>\n                    );\n                  })}\n                  {(() => {\n                    const lastX = xScale(new Date(lastRecord!.last_updated));\n                    const nowX = xScale(now);\n                    if (nowX > lastX + 6) {\n                      return (\n                        <circle\n                          cx={nowX}\n                          cy={AXIS_Y}\n                          r={3}\n                          className='fill-muted-foreground/60'\n                        />\n                      );\n                    }\n                    return null;\n                  })()}\n                </svg>\n              </TooltipProvider>\n\n              <ScrollArea className='flex-1 min-h-0 border rounded'>\n                <ul className='divide-y'>\n                  {sortedDesc.map((record) => (\n                    <li\n                      key={record.state_id}\n                      className='flex items-center justify-between gap-3 px-3 py-2 text-sm'\n                    >\n                      <span className='text-muted-foreground font-mono text-xs whitespace-nowrap'>\n                        {formatSimpleDateTime(record.last_updated)}\n                      </span>\n                      <span className='font-mono font-medium truncate text-right'>\n                        {record.state ?? \"—\"}\n                      </span>\n                    </li>\n                  ))}\n                </ul>\n              </ScrollArea>\n            </>\n          )}\n        </div>\n      </SheetContent>\n    </Sheet>\n  );\n}\n\nfunction SummaryCell({ label, value }: { label: string; value: string }) {\n  return (\n    <div className='flex flex-col gap-0.5 rounded border px-2 py-1.5'>\n      <span className='text-[10px] uppercase tracking-wide text-muted-foreground'>\n        {label}\n      </span>\n      <span className='font-mono text-xs truncate'>{value}</span>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/entity/useEntitiesData.ts",
    "content": "import { useCallback, useEffect, useState } from \"react\";\nimport * as api from \"@/entities/entity/api\";\nimport { EntityAll, State } from \"@/entities/entity/types\";\nimport { useWebSocket, useWebSocketMessage } from \"../ws/WebSocketProvider\";\nimport { WebSocketMessage } from \"../ws/ws\";\n\nexport type StreamState = {\n  topic: string;\n  is_online: boolean;\n};\n\nexport type ChangeStatePayload = {\n  entity_id: string;\n  state: State;\n};\n\nexport function useEntitiesData() {\n  const [entities, setEntities] = useState<EntityAll[]>([]);\n  const [streamsState, setStreamsState] = useState<StreamState[]>([]);\n  const { wsManager, isConnected } = useWebSocket();\n\n  const getAllEntities = useCallback(async () => {\n    try {\n      const response = await api.getAllEntities();\n      setEntities(response.data);\n    } catch (error) {\n      console.error(\"Failed to fetch all entities:\", error);\n    }\n  }, []);\n\n  const handleMessage = useCallback(\n    (msg: WebSocketMessage) => {\n      try {\n        if (msg.type === \"stream_state\") {\n          const loads = msg.payload as StreamState[];\n          setStreamsState(loads);\n        }\n\n        if (msg.type == \"change_state\") {\n          const index = entities.findIndex((item) => {\n            return (\n              item.entity_id == (msg.payload as ChangeStatePayload).entity_id\n            );\n          });\n\n          if (index == -1) {\n            return false;\n          }\n\n          if (entities[index].state) {\n            entities[index].state = (msg.payload as ChangeStatePayload).state;\n\n            setEntities([...entities]);\n          }\n        }\n      } catch (err) {\n        console.error(\"Error handling signaling message:\", err);\n      }\n    },\n    [entities],\n  );\n\n  useEffect(() => {\n    if (wsManager && isConnected) {\n      wsManager.send({\n        type: \"get_all_stream_state\",\n        payload: {},\n      });\n\n      const interval = setInterval(() => {\n        wsManager.send({\n          type: \"get_all_stream_state\",\n          payload: {},\n        });\n      }, 5000);\n\n      return () => clearInterval(interval);\n    }\n  }, [wsManager, isConnected]);\n\n  useWebSocketMessage(handleMessage);\n\n  useEffect(() => {\n    getAllEntities();\n  }, [getAllEntities]);\n\n  return { entities, streamsState, refresh: getAllEntities };\n}\n"
  },
  {
    "path": "apps/client/src/features/error/index.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\n\nexport function ErrorRender() {\n  return (\n    <div className='bg-background flex h-[calc(100%_-_34px)] items-center justify-center p-6 md:p-10'>\n      <div className='flex w-full max-w-lg flex-col items-center text-center md:flex-row md:items-center md:gap-8 md:text-left'>\n        <h1 className='text-9xl font-extrabold tracking-tighter text-blue-500'>\n          ERR\n        </h1>\n        <div className='mt-4 md:mt-0'>\n          <h2 className='text-2xl mb-2 font-semibold text-foreground'>\n            An error has occurred. Please try again.\n          </h2>\n          <Button onClick={() => (location.href = \"/\")}>Go To Main</Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/flow/AddCustomNode.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n  DialogFooter,\n} from \"@/components/ui/dialog\";\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n  Blocks,\n  Edit,\n  PlusCircle,\n  Trash2,\n  Loader2,\n  Download,\n} from \"lucide-react\";\nimport { useCustomNodeStore } from \"@/entities/custom-nodes/store\";\nimport {\n  CustomNode,\n  CustomNodeDynamicData,\n  CustomNodeFromApi,\n} from \"@/entities/custom-nodes/types\";\nimport {\n  RHAI_PRESETS,\n  getPresetCategories,\n  getPresetsByCategory,\n  presetToApiPayload,\n  type RhaiPreset,\n  type PresetCategory,\n} from \"@/entities/custom-nodes/presets\";\nimport { toast } from \"sonner\";\nimport { JsonCodeEditor } from \"../json/JsonEditor\";\n\nfunction CustomNodeForm({\n  onSubmit,\n  onCancel,\n  initialNode,\n}: {\n  onSubmit: (node: CustomNodeFromApi) => void;\n  onCancel: () => void;\n  initialNode?: CustomNode | null | undefined;\n}) {\n  const [nodeType, setNodeType] = useState(initialNode?.node_type || \"_\");\n  const [data, setData] = useState(JSON.stringify(initialNode?.data) || \"\");\n  const isEditing = !!initialNode;\n\n  useEffect(() => {\n    try {\n      if (initialNode?.data) {\n        setData(JSON.stringify(initialNode?.data));\n      }\n    } catch {\n      console.log(\"\");\n    }\n  }, [initialNode]);\n\n  const handleSubmit = (e: React.FormEvent) => {\n    e.preventDefault();\n    try {\n      onSubmit({\n        node_type: nodeType,\n        data: JSON.parse(data) as CustomNodeDynamicData,\n      });\n    } catch {\n      toast(\"Invalid JSON in data field.\");\n    }\n  };\n\n  const handleNodeTypeChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    let value = e.target.value;\n    if (!value.startsWith(\"_\")) {\n      value = `_${value}`;\n    }\n    setNodeType(value);\n  };\n\n  return (\n    <form onSubmit={handleSubmit} className='space-y-4 py-4'>\n      <div>\n        <Label htmlFor='node_type'>Node Type (ID)</Label>\n        <Input\n          id='node_type'\n          value={nodeType}\n          onChange={handleNodeTypeChange}\n          required\n          disabled={isEditing}\n          placeholder='e.g., custom-adder'\n        />\n      </div>\n      <div>\n        <Label htmlFor='data'>Data (JSON)</Label>\n\n        <JsonCodeEditor value={data as string} onChange={(e) => setData(e)} />\n      </div>\n      <DialogFooter>\n        <Button type='button' variant='ghost' onClick={onCancel}>\n          Cancel\n        </Button>\n        <Button type='submit'>\n          {isEditing ? \"Save Changes\" : \"Create Node\"}\n        </Button>\n      </DialogFooter>\n    </form>\n  );\n}\n\nexport function AddCustomNode() {\n  const {\n    nodes,\n    fetchAllNodes,\n    createNode,\n    updateNode,\n    deleteNode,\n    isLoading,\n  } = useCustomNodeStore();\n  const [isDialogOpen, setDialogOpen] = useState(false);\n  const [view, setView] = useState<\"list\" | \"form\" | \"presets\">(\"list\");\n  const [editingNode, setEditingNode] = useState<CustomNode | null>(null);\n  const [deletingNode, setDeletingNode] = useState<CustomNode | null>(null);\n  const [selectedCategory, setSelectedCategory] =\n    useState<PresetCategory | null>(null);\n\n  useEffect(() => {\n    fetchAllNodes();\n  }, [isDialogOpen, fetchAllNodes]);\n\n  const handleAddNew = () => {\n    setEditingNode(null);\n    setView(\"form\");\n  };\n\n  const handleEdit = (node: CustomNode) => {\n    setEditingNode(node);\n    setView(\"form\");\n  };\n\n  const handleBackToList = () => {\n    setEditingNode(null);\n    setSelectedCategory(null);\n    setView(\"list\");\n  };\n\n  const handleAddFromPreset = () => {\n    setSelectedCategory(null);\n    setView(\"presets\");\n  };\n\n  const handleRegisterPreset = async (preset: RhaiPreset) => {\n    const alreadyExists = nodes.some((n) => n.node_type === preset.nodeId);\n    if (alreadyExists) {\n      toast(`Node \"${preset.displayName}\" is already registered.`);\n      return;\n    }\n    const payload = presetToApiPayload(preset);\n    await createNode(payload);\n    toast(`Preset \"${preset.displayName}\" registered successfully.`);\n  };\n\n  const handleRegisterAll = async () => {\n    const presetsToRegister = (\n      selectedCategory ? getPresetsByCategory(selectedCategory) : RHAI_PRESETS\n    ).filter((preset) => !nodes.some((n) => n.node_type === preset.nodeId));\n\n    if (presetsToRegister.length === 0) {\n      toast(\"All presets are already registered.\");\n      return;\n    }\n\n    for (const preset of presetsToRegister) {\n      const payload = presetToApiPayload(preset);\n      await createNode(payload);\n    }\n    toast(`${presetsToRegister.length} presets registered successfully.`);\n  };\n\n  const handleSubmit = async (nodeData: CustomNodeFromApi) => {\n    if (editingNode) {\n      await updateNode(editingNode.node_type, nodeData.data);\n    } else {\n      await createNode(nodeData);\n    }\n    handleBackToList();\n  };\n\n  const handleDeleteConfirm = async () => {\n    if (deletingNode) {\n      await deleteNode(deletingNode.node_type);\n      setDeletingNode(null);\n    }\n  };\n\n  return (\n    <>\n      <Dialog open={isDialogOpen} onOpenChange={setDialogOpen}>\n        <DialogTrigger asChild>\n          <Button size={\"sm\"} variant={\"secondary\"}>\n            <Blocks className='h-4 w-4' />\n          </Button>\n        </DialogTrigger>\n        <DialogContent className='min-w-[80vw] h-[80vh] flex flex-col'>\n          <DialogHeader>\n            <DialogTitle>Custom Node Registry</DialogTitle>\n            <DialogDescription>\n              Manage custom script nodes available in the flow editor.\n            </DialogDescription>\n          </DialogHeader>\n\n          {view === \"list\" && (\n            <div className='flex-grow overflow-y-auto pr-2'>\n              <div className='flex justify-end mb-4 gap-2'>\n                <Button\n                  size='sm'\n                  variant={\"outline\"}\n                  onClick={handleAddFromPreset}\n                >\n                  <Download className='mr-2 h-4 w-4' /> Add from Preset\n                </Button>\n                <Button size='sm' variant={\"outline\"}>\n                  <PlusCircle className='mr-2 h-4 w-4' /> Select dir\n                </Button>\n                <Button size='sm' onClick={handleAddNew}>\n                  <PlusCircle className='mr-2 h-4 w-4' /> Add New Node\n                </Button>\n              </div>\n              <div className='space-y-2'>\n                {isLoading && !nodes.length ? (\n                  <div className='flex justify-center items-center h-40'>\n                    <Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />\n                  </div>\n                ) : (\n                  nodes.map((node) => (\n                    <div\n                      key={node.node_type}\n                      className='flex items-center justify-between p-3 border'\n                    >\n                      <div>\n                        <p className='font-semibold'>{node.node_type}</p>\n                        <p className='text-sm text-muted-foreground truncate max-w-xs'>\n                          {JSON.stringify(node)}\n                        </p>\n                      </div>\n                      <div className='flex items-center gap-2'>\n                        <Button\n                          variant='outline'\n                          size='icon'\n                          onClick={() => handleEdit(node)}\n                        >\n                          <Edit className='h-4 w-4' />\n                        </Button>\n                        <Button\n                          variant='destructive'\n                          size='icon'\n                          onClick={() => setDeletingNode(node)}\n                        >\n                          <Trash2 className='h-4 w-4' />\n                        </Button>\n                      </div>\n                    </div>\n                  ))\n                )}\n                {!isLoading && !nodes.length && (\n                  <div className='text-center text-muted-foreground py-10'>\n                    No custom nodes found.\n                  </div>\n                )}\n              </div>\n            </div>\n          )}\n\n          {view === \"form\" && (\n            <CustomNodeForm\n              onSubmit={handleSubmit}\n              onCancel={handleBackToList}\n              initialNode={editingNode}\n            />\n          )}\n\n          {view === \"presets\" && (\n            <div className='flex-grow overflow-y-auto pr-2'>\n              <div className='flex justify-between items-center mb-4'>\n                <Button size='sm' variant='ghost' onClick={handleBackToList}>\n                  Back to List\n                </Button>\n                <Button\n                  size='sm'\n                  variant='outline'\n                  onClick={handleRegisterAll}\n                  disabled={isLoading}\n                >\n                  <Download className='mr-2 h-4 w-4' />\n                  {selectedCategory\n                    ? `Register All ${selectedCategory}`\n                    : \"Register All\"}\n                </Button>\n              </div>\n\n              <div className='flex flex-wrap gap-2 mb-4'>\n                {getPresetCategories().map((cat) => (\n                  <Badge\n                    key={cat}\n                    variant={selectedCategory === cat ? \"default\" : \"outline\"}\n                    className='cursor-pointer'\n                    onClick={() =>\n                      setSelectedCategory(selectedCategory === cat ? null : cat)\n                    }\n                  >\n                    {cat}\n                  </Badge>\n                ))}\n              </div>\n\n              <div className='space-y-2'>\n                {(selectedCategory\n                  ? getPresetsByCategory(selectedCategory)\n                  : RHAI_PRESETS\n                ).map((preset) => {\n                  const isRegistered = nodes.some(\n                    (n) => n.node_type === preset.nodeId,\n                  );\n                  return (\n                    <div\n                      key={preset.nodeId}\n                      className='flex items-center justify-between p-3 border rounded-md'\n                    >\n                      <div className='flex-1'>\n                        <div className='flex items-center gap-2'>\n                          <p className='font-semibold text-sm'>\n                            {preset.displayName}\n                          </p>\n                          <Badge variant='secondary' className='text-xs'>\n                            {preset.category}\n                          </Badge>\n                          {isRegistered && (\n                            <Badge\n                              variant='outline'\n                              className='text-xs text-green-500'\n                            >\n                              Registered\n                            </Badge>\n                          )}\n                        </div>\n                        <p className='text-xs text-muted-foreground mt-1'>\n                          {preset.description}\n                        </p>\n                        <p className='text-xs text-muted-foreground'>\n                          In:{\" \"}\n                          {preset.connectors\n                            .filter((c) => c.type === \"in\")\n                            .map((c) => c.name)\n                            .join(\", \") || \"none\"}{\" \"}\n                          | Out:{\" \"}\n                          {preset.connectors\n                            .filter((c) => c.type === \"out\")\n                            .map((c) => c.name)\n                            .join(\", \")}\n                        </p>\n                      </div>\n                      <Button\n                        size='sm'\n                        variant={isRegistered ? \"outline\" : \"default\"}\n                        disabled={isRegistered || isLoading}\n                        onClick={() => handleRegisterPreset(preset)}\n                      >\n                        {isRegistered ? \"Added\" : \"Register\"}\n                      </Button>\n                    </div>\n                  );\n                })}\n              </div>\n            </div>\n          )}\n        </DialogContent>\n      </Dialog>\n\n      <AlertDialog\n        open={!!deletingNode}\n        onOpenChange={() => setDeletingNode(null)}\n      >\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>Are you sure?</AlertDialogTitle>\n            <AlertDialogDescription>\n              This action cannot be undone. This will permanently delete the\n              <span className='font-semibold mx-1'>\n                {deletingNode?.node_type}\n              </span>\n              node.\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel>Cancel</AlertDialogCancel>\n            <AlertDialogAction onClick={handleDeleteConfirm}>\n              Continue\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/flow/Flow.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { Graph } from \"./Graph\";\nimport { Button } from \"@/components/ui/button\";\n\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n  DialogClose,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { MoreHorizontal, Plus, Trash2 } from \"lucide-react\";\nimport { File } from \"lucide-react\";\nimport { deleteFlow } from \"@/entities/flow/api\";\nimport { Flow } from \"@/entities/flow/types\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n  AlertDialogHeader,\n  AlertDialogFooter,\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { useFlowStore } from \"@/entities/flow/store\";\nimport { RunFlowButton } from \"./RunFlow\";\nimport { FlowLog } from \"../flow-log/FlowLog\";\n\nexport default function FlowPage() {\n  const {\n    nodes,\n    edges,\n    setNodes,\n    setEdges,\n    flows,\n    fetchFlows,\n    currentFlowId,\n    resetFlowState,\n  } = useFlowStore();\n\n  // const saveAndRunFlow = async () => {\n  //   await saveGraph();\n  //   await fetchFlows();\n  // };\n\n  useEffect(() => {\n    fetchFlows();\n\n    return () => {\n      resetFlowState();\n    };\n  }, [fetchFlows, resetFlowState]);\n\n  useEffect(() => {\n    console.log(\"Fetched flows from API:\", flows);\n  }, [flows]);\n\n  return (\n    <div className='flex flex-col h-[calc(100vh-3rem)] w-full bg-background'>\n      <div className='flex-1 flex flex-col relative overflow-hidden'>\n        {currentFlowId ? (\n          <>\n            <div\n              className='flex-1 relative min-h-0'\n              data-allow-backspace='true'\n            >\n              <Graph\n                nodes={nodes}\n                edges={edges}\n                onNodesChange={setNodes}\n                onEdgesChange={setEdges}\n              />\n            </div>\n            <FlowLog />\n          </>\n        ) : (\n          <div\n            style={{\n              display: \"flex\",\n              justifyContent: \"center\",\n              alignItems: \"center\",\n              height: \"calc(100% - 34px)\",\n              color: \"#888\",\n            }}\n          >\n            <h2>Select or create a flow to begin.</h2>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nexport function FlowHeader() {\n  const { error } = useFlowStore();\n\n  // const [saveComment, setSaveComment] = useState(\"\");\n\n  // const handleSave = () => {\n  //   saveGraph(saveComment || undefined);\n  //   setSaveComment(\"\");\n  // };\n\n  return (\n    <header\n      style={{\n        display: \"flex\",\n        gap: \"1rem\",\n        alignItems: \"center\",\n      }}\n    >\n      <RunFlowButton />\n      {/* <Dialog onOpenChange={(open) => !open && setSaveComment(\"\")}>\n        <DialogTrigger asChild>\n          <Button\n            size={\"sm\"}\n            variant={\"outline\"}\n            disabled={!currentFlowId || isLoading}\n          >\n            {isLoading ? \"Saving...\" : \"Save Current Flow\"}\n          </Button>\n        </DialogTrigger>\n        <DialogContent className='sm:max-w-[425px]'>\n          <DialogHeader>\n            <DialogTitle>Save Flow Version</DialogTitle>\n            <DialogDescription>\n              Enter an optional comment for this version of the flow.\n            </DialogDescription>\n          </DialogHeader>\n          <div className='grid gap-4 py-4'>\n            <div className='grid grid-cols-4 items-center gap-4'>\n              <Label htmlFor='comment' className='text-right'>\n                Comment\n              </Label>\n              <Input\n                id='comment'\n                value={saveComment}\n                onChange={(e) => setSaveComment(e.target.value)}\n                className='col-span-3'\n                placeholder='e.g., Initial setup'\n              />\n            </div>\n          </div>\n          <DialogFooter>\n            <DialogClose asChild>\n              <Button type='button' onClick={handleSave}>\n                Save Version\n              </Button>\n            </DialogClose>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog> */}\n\n      {error && (\n        <p style={{ color: \"red\", marginLeft: \"auto\" }}>Error: {error}</p>\n      )}\n    </header>\n  );\n}\n\nexport function FlowSidebar() {\n  const {\n    flows,\n    currentFlowId,\n    createNewFlow,\n    setCurrentFlowId,\n    fetchFlows,\n    hasUnsavedChanges,\n  } = useFlowStore();\n  const [newFlowName, setNewFlowName] = useState(\"\");\n  const [flowToDelete, setFlowToDelete] = useState<Flow | null>(null);\n  const [pendingFlowId, setPendingFlowId] = useState<number | null>(null);\n  const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false);\n\n  const unsavedMessage =\n    \"You have unsaved changes in this flow. Switch flows without saving?\";\n\n  const handleCreateFlow = async () => {\n    if (newFlowName) {\n      await createNewFlow(newFlowName);\n      await fetchFlows();\n      setNewFlowName(\"\");\n    }\n  };\n\n  const handleFlowSelect = (flowId: number) => {\n    if (hasUnsavedChanges && flowId !== currentFlowId) {\n      setPendingFlowId(flowId);\n      setShowUnsavedPrompt(true);\n      return;\n    }\n    setCurrentFlowId(flowId);\n  };\n\n  const handleConfirmSwitch = () => {\n    if (pendingFlowId !== null) {\n      setCurrentFlowId(pendingFlowId);\n    }\n    setPendingFlowId(null);\n    setShowUnsavedPrompt(false);\n  };\n\n  const handleCancelSwitch = () => {\n    setPendingFlowId(null);\n    setShowUnsavedPrompt(false);\n  };\n\n  const handleDeleteConfirm = async () => {\n    if (flowToDelete) {\n      await deleteFlow(flowToDelete.id);\n      await fetchFlows();\n      setFlowToDelete(null);\n    }\n  };\n\n  return (\n    <aside className='w-86 border-r bg-card text-card-foreground p-4 overflow-hidden h-full'>\n      <div className='flex flex-col h-full'>\n        <div className='flex items-center justify-between mb-4 shrink-0'>\n          <h2 className='mb-1 text-md font-semibold tracking-tight'>Flows</h2>\n          <div className='flex items-center'>\n            <Dialog onOpenChange={(open) => !open && setNewFlowName(\"\")}>\n              <DialogTrigger asChild>\n                <Button variant='ghost' size='icon'>\n                  <Plus className='h-4 w-4 ' />\n                </Button>\n              </DialogTrigger>\n              <DialogContent className='sm:max-w-[425px]'>\n                <DialogHeader>\n                  <DialogTitle>Create New Flow</DialogTitle>\n                  <DialogDescription>\n                    Enter a name for your new flow. Click create when you're\n                    done.\n                  </DialogDescription>\n                </DialogHeader>\n                <div className='grid gap-4 py-4'>\n                  <div className='grid grid-cols-4 items-center gap-4'>\n                    <Label htmlFor='name' className='text-right'>\n                      Name\n                    </Label>\n                    <Input\n                      id='name'\n                      value={newFlowName}\n                      onChange={(e) => setNewFlowName(e.target.value)}\n                      className='col-span-3'\n                      placeholder='My Awesome Flow'\n                    />\n                  </div>\n                </div>\n                <DialogFooter>\n                  <DialogClose asChild>\n                    <Button type='button' onClick={handleCreateFlow}>\n                      Create\n                    </Button>\n                  </DialogClose>\n                </DialogFooter>\n              </DialogContent>\n            </Dialog>\n          </div>\n        </div>\n        <div className='flex-1 overflow-y-auto min-h-0 -mr-4 pr-4'>\n          <div className='flex flex-col gap-1'>\n            {flows.map((flow) => (\n              <div\n                key={flow.id}\n                className='flex w-full items-center group rounded-md gap-2'\n              >\n                <Button\n                  variant='ghost'\n                  onClick={() => handleFlowSelect(flow.id)}\n                  className={` justify-start flex-grow hover:bg-transparent ${\n                    currentFlowId === flow.id\n                      ? \"bg-accent text-accent-foreground\"\n                      : \"\"\n                  }`}\n                >\n                  <File className='mr-0 h-4 w-4' />\n                  <span className='truncate'>{flow.name}</span>\n                </Button>\n                <DropdownMenu>\n                  <DropdownMenuTrigger asChild>\n                    <Button\n                      variant='ghost'\n                      size='icon'\n                      className='h-8 w-8 opacity-0 group-hover:opacity-100 focus:opacity-100'\n                    >\n                      <MoreHorizontal className='h-4 w-4 ' />\n                    </Button>\n                  </DropdownMenuTrigger>\n                  <DropdownMenuContent>\n                    <DropdownMenuItem\n                      onSelect={() => setFlowToDelete(flow)}\n                      className='text-red-600 hover:!text-red-600 focus:text-red-600'\n                    >\n                      <Trash2 className='text-red-600 hover:!text-red-600 mr-2 h-4 w-4' />\n                      Delete\n                    </DropdownMenuItem>\n                  </DropdownMenuContent>\n                </DropdownMenu>\n              </div>\n            ))}\n          </div>\n        </div>\n      </div>\n\n      <AlertDialog\n        open={!!flowToDelete}\n        onOpenChange={(open) => !open && setFlowToDelete(null)}\n      >\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>\n            <AlertDialogDescription>\n              This action cannot be undone. This will permanently delete the \"\n              {flowToDelete?.name}\" flow.\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel onClick={() => setFlowToDelete(null)}>\n              Cancel\n            </AlertDialogCancel>\n            <AlertDialogAction asChild>\n              <Button variant={\"destructive\"} onClick={handleDeleteConfirm}>\n                Continue\n              </Button>\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n\n      <AlertDialog open={showUnsavedPrompt} onOpenChange={setShowUnsavedPrompt}>\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>Unsaved changes</AlertDialogTitle>\n            <AlertDialogDescription>\n              {unsavedMessage} Unsaved changes will be lost.\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel onClick={handleCancelSwitch}>\n              Stay on this flow\n            </AlertDialogCancel>\n            <AlertDialogAction onClick={handleConfirmSwitch}>\n              Switch anyway\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </aside>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/flow/Graph.tsx",
    "content": "import { useState, useRef, useEffect, useCallback, useMemo } from \"react\";\nimport * as d3 from \"d3\";\nimport { Plus, Minus, Lock, LockOpen, SquarePlus } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  GraphProps,\n  Node,\n  Edge,\n  Connector,\n  NodeRenderer,\n  NodeTypes,\n} from \"./flowTypes\";\n\n// import { renderButtonNode } from \"./nodes/ButtonNode\";\nimport { renderTitleNode } from \"./nodes/TitleNode\";\nimport { Options } from \"./Options\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuPortal,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { renderProcessingNode } from \"./nodes/ProcessingNode\";\nimport { getCustomNode, getDefalutNode } from \"./flowUtils\";\nimport { renderVarNode } from \"./nodes/VarNode\";\nimport { renderCalcNode } from \"./nodes/CalcNode\";\nimport { renderHttpNode } from \"./nodes/HttpNode\";\nimport { formatConstantCase } from \"@/lib/string\";\nimport { renderLogicNode } from \"./nodes/LogicNode\";\nimport { renderIntervalNode } from \"./nodes/IntervalNode\";\nimport { renderMQTTNode } from \"./nodes/MQTTNode\";\nimport { renderButtonNode } from \"./nodes/ButtonNode\";\nimport { zoomIdentity } from \"d3-zoom\";\nimport { AddCustomNode } from \"./AddCustomNode\";\nimport { useCustomNodeStore } from \"@/entities/custom-nodes/store\";\nimport { useIntegrationStore } from \"@/entities/integrations/store\";\nimport { SelectedItemActions } from \"./SelectedItemActions\";\n\ntype NodeGroup = {\n  label: string;\n  nodes: string[];\n};\n\nconst nodeColor = \"#2a2c36\";\nconst nodeHoverColor = \"#444754\";\n\nconst nodeLightColor = \"#d1d4e3\";\nconst highlightColor = \"#1976d2\";\n\nconst ros2NodeKey = [\"EXT_ROS2_WEBSOCKET_ON\", \"EXT_ROS2_WEBSOCKET_SEND\"];\n\nexport function Graph({\n  nodes,\n  edges,\n  width = \"100%\",\n  height = \"100%\",\n  gridSize = 20,\n  gridColor = \"#2f2f38\",\n  edgeColor = \"#666\",\n  edgeWidth = 2,\n  onNodesChange,\n  onEdgesChange,\n}: GraphProps) {\n  const [selectedConnector, setSelectedConnector] = useState<string | null>(\n    null,\n  );\n  const [locked, setLocked] = useState(false);\n  const [selectedElement, setSelectedElement] = useState<{\n    type: \"node\" | \"edge\";\n    id: string;\n  } | null>(null);\n  const [open, setOpen] = useState(false);\n  const [openedNode, setOpenedNode] = useState<Node | null>(null);\n  const { nodes: customNodes } = useCustomNodeStore();\n\n  const svgRef = useRef<SVGSVGElement>(null);\n  const gRef = useRef<SVGGElement>(null);\n  const zoomRef = useRef<d3.ZoomBehavior<SVGSVGElement, unknown> | null>(null);\n  const transformRef = useRef<d3.ZoomTransform>(zoomIdentity);\n\n  const edgesRef = useRef(edges);\n  const nodesRef = useRef(nodes);\n\n  const { isRos2Connected, fetchStatus } = useIntegrationStore();\n\n  useEffect(() => {\n    fetchStatus();\n  }, [fetchStatus]);\n\n  const [nodeRenderers, setNodeRenderers] = useState<\n    Record<string, NodeRenderer>\n  >({});\n\n  const nodeGroups: NodeGroup[] = useMemo(() => {\n    const baseGroups: NodeGroup[] = [\n      {\n        label: \"Default\",\n        nodes: [\"START\", \"LOG_MESSAGE\", \"SHOW_TOAST\"],\n      },\n      {\n        label: \"Data\",\n        nodes: [\n          \"SET_VARIABLE\",\n          \"SET_VARIABLE_WITH_EXEC\",\n          \"TYPE_CONVERTER\",\n          \"RTP_STREAM_IN\",\n          \"DECODE_OPUS\",\n          \"GST_DECODER\",\n        ],\n      },\n      {\n        label: \"Logic\",\n        nodes: [\n          \"CALCULATION\",\n          \"LOGIC_OPERATOR\",\n          \"INTERVAL\",\n          \"BRANCH\",\n          \"JSON_SELECTOR\",\n          \"JSON_MODIFY\",\n        ],\n      },\n      {\n        label: \"Communication\",\n        nodes: [\n          \"HTTP_REQUEST\",\n          \"MQTT_PUBLISH\",\n          \"MQTT_SUBSCRIBE\",\n          \"DASHBOARD_EVENT_LISTENER\",\n          \"WEBSOCKET_SEND\",\n          \"WEBSOCKET_ON\",\n        ],\n      },\n      {\n        label: \"AI/ML\",\n        nodes: [\"YOLO_DETECT\"],\n      },\n    ];\n\n    if (isRos2Connected) {\n      baseGroups.push({\n        label: \"ROS2\",\n        nodes: ros2NodeKey,\n      });\n    }\n\n    const customNodeTypes = customNodes.map((item) => {\n      try {\n        return item.node_type;\n      } catch {\n        return \"\";\n      }\n    });\n\n    if (customNodeTypes.length > 0) {\n      baseGroups.push({\n        label: \"Custom Node\",\n        nodes: customNodeTypes,\n      });\n    }\n\n    return baseGroups;\n  }, [customNodes, isRos2Connected]);\n\n  const handleClickOption = (node: Node) => {\n    setOpenedNode(node);\n    setOpen(true);\n  };\n\n  const handleOpenOptions = (isOpen: boolean) => {\n    setOpen(isOpen);\n  };\n\n  useEffect(() => {\n    const baseRenderers: Record<string, NodeRenderer> = {\n      START: (g, d) => renderTitleNode(g, d),\n      SET_VARIABLE: (g, d) => renderVarNode(g, d, () => handleClickOption(d)),\n      SET_VARIABLE_WITH_EXEC: (g, d) =>\n        renderVarNode(g, d, () => handleClickOption(d)),\n      CONDITION: (g, d) => renderProcessingNode(g, d),\n      LOG_MESSAGE: (g, d) => renderProcessingNode(g, d),\n      SHOW_TOAST: (g, d) =>\n        renderButtonNode(g, d, () => handleClickOption(d)),\n      CALCULATION: (g, d) => renderCalcNode(g, d, () => handleClickOption(d)),\n      HTTP_REQUEST: (g, d) => renderHttpNode(g, d, () => handleClickOption(d)),\n      INTERVAL: (g, d) => renderIntervalNode(g, d, () => handleClickOption(d)),\n      LOGIC_OPERATOR: (g, d) =>\n        renderLogicNode(g, d, () => handleClickOption(d)),\n      MQTT_PUBLISH: (g, d) => renderMQTTNode(g, d, () => handleClickOption(d)),\n      MQTT_SUBSCRIBE: (g, d) =>\n        renderMQTTNode(g, d, () => handleClickOption(d)),\n      DASHBOARD_EVENT_LISTENER: (g, d) =>\n        renderMQTTNode(g, d, () => handleClickOption(d)),\n      TYPE_CONVERTER: (g, d) =>\n        renderButtonNode(g, d, () => handleClickOption(d)),\n      RTP_STREAM_IN: (g, d) =>\n        renderButtonNode(g, d, () => handleClickOption(d)),\n      DECODE_OPUS: (g, d) => renderProcessingNode(g, d),\n      DECODE_H264: (g, d) => renderProcessingNode(g, d),\n      BRANCH: (g, d) => renderProcessingNode(g, d),\n      JSON_SELECTOR: (g, d) =>\n        renderButtonNode(g, d, () => handleClickOption(d)),\n      JSON_MODIFY: (g, d) => renderButtonNode(g, d, () => handleClickOption(d)),\n      YOLO_DETECT: (g, d) => renderButtonNode(g, d, () => handleClickOption(d)),\n      GST_DECODER: (g, d) => renderButtonNode(g, d, () => handleClickOption(d)),\n      WEBSOCKET_SEND: (g, d) =>\n        renderButtonNode(g, d, () => handleClickOption(d)),\n      WEBSOCKET_ON: (g, d) =>\n        renderButtonNode(g, d, () => handleClickOption(d)),\n    };\n\n    if (isRos2Connected) {\n      ros2NodeKey.forEach((element) => {\n        baseRenderers[element] = (g, d) =>\n          renderButtonNode(g, d, () => handleClickOption(d));\n      });\n    }\n\n    const customNodeRenderers = customNodes.reduce((acc, customNode) => {\n      acc[customNode.node_type] = (g, d) =>\n        renderButtonNode(g, d, () => handleClickOption(d));\n      return acc;\n    }, {} as Record<string, NodeRenderer>);\n\n    setNodeRenderers({\n      ...baseRenderers,\n      ...customNodeRenderers,\n    });\n  }, [customNodes, isRos2Connected]);\n\n  useEffect(() => {\n    edgesRef.current = edges;\n    nodesRef.current = nodes;\n  }, [edges, nodes]);\n\n  const dragLineRef = useRef<d3.Selection<\n    SVGLineElement,\n    unknown,\n    null,\n    undefined\n  > | null>(null);\n  const dragSourceRef = useRef<string | null>(null);\n  const isDraggingRef = useRef(false);\n\n  const handleAddNode = useCallback(\n    (type: NodeTypes) => {\n      if (!onNodesChange || !svgRef.current) return;\n\n      const currentTransform = transformRef.current;\n\n      const svgRect = svgRef.current.getBoundingClientRect();\n      const viewCenterX = svgRect.width / 2;\n      const viewCenterY = svgRect.height / 2;\n\n      const [worldX, worldY] = currentTransform.invert([\n        viewCenterX,\n        viewCenterY,\n      ]);\n\n      const id = `${type}-${Date.now()}`;\n      const isCustomNode = type[0] == \"_\";\n      if (isCustomNode) {\n        console.log(type);\n        const customNode = getCustomNode(\n          customNodes,\n          type,\n          id,\n          worldX,\n          worldY,\n        ) as unknown as Node;\n\n        if (!customNode) {\n          return false;\n        }\n\n        onNodesChange([\n          ...nodesRef.current,\n          {\n            ...(customNode as Node),\n          },\n        ]);\n\n        return false;\n      }\n\n      const value = getDefalutNode(type, id, worldX, worldY);\n\n      onNodesChange([\n        ...nodesRef.current,\n        {\n          ...value,\n        },\n      ]);\n    },\n    [onNodesChange, customNodes],\n  );\n\n  useEffect(() => {\n    if (!svgRef.current) return;\n    const svg = d3.select<SVGSVGElement, unknown>(svgRef.current);\n    if (!svg.select(\"defs\").size()) {\n      svg\n        .append(\"defs\")\n        .append(\"pattern\")\n        .attr(\"id\", \"grid\")\n        .attr(\"width\", gridSize)\n        .attr(\"height\", gridSize)\n        .attr(\"patternUnits\", \"userSpaceOnUse\")\n        .append(\"circle\")\n        .attr(\"cx\", gridSize / 2)\n        .attr(\"cy\", gridSize / 2)\n        .attr(\"r\", 1)\n        .attr(\"fill\", gridColor);\n    }\n\n    const zoom = d3\n      .zoom<SVGSVGElement, unknown>()\n      .scaleExtent([0.2, 2])\n      .on(\"zoom\", (e) => {\n        transformRef.current = e.transform;\n\n        if (!locked)\n          d3.select<SVGGElement, unknown>(gRef.current!).attr(\n            \"transform\",\n            e.transform,\n          );\n      });\n    zoomRef.current = zoom;\n    svg.call(zoom);\n\n    svg.on(\"click.clearSelection\", () => {\n      setSelectedElement(null);\n    });\n\n    return () => {\n      svg.on(\"click.clearSelection\", null);\n    };\n  }, [gridSize, gridColor, locked]);\n\n  const deleteSelectedNode = useCallback(() => {\n    if (!selectedElement || selectedElement.type !== \"node\") return;\n\n    const removedNode = nodesRef.current.find(\n      (n) => n.id === selectedElement.id,\n    );\n    const removedConnIds = removedNode\n      ? removedNode.connectors.map((c) => c.id)\n      : [];\n\n    onNodesChange?.(\n      nodesRef.current.filter((n) => n.id !== selectedElement.id),\n    );\n    onEdgesChange?.(\n      edgesRef.current.filter(\n        (ed) =>\n          !removedConnIds.includes(ed.source) &&\n          !removedConnIds.includes(ed.target),\n      ),\n    );\n\n    setSelectedElement(null);\n  }, [selectedElement, onNodesChange, onEdgesChange]);\n\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key !== \"Backspace\") return;\n      if (selectedElement?.type === \"node\") {\n        deleteSelectedNode();\n      } else if (selectedElement?.type === \"edge\") {\n        onEdgesChange?.(\n          edgesRef.current.filter((ed) => ed.id !== selectedElement.id),\n        );\n        setSelectedElement(null);\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => {\n      window.removeEventListener(\"keydown\", handleKeyDown);\n    };\n  }, [selectedElement, onEdgesChange, deleteSelectedNode]);\n\n  useEffect(() => {\n    const g = d3.select(gRef.current);\n\n    g.selectAll<SVGRectElement, unknown>(\"rect.grid-bg\")\n      .data([null])\n      .join(\"rect\")\n      .attr(\"class\", \"grid-bg\")\n      .attr(\"x\", -5000)\n      .attr(\"y\", -5000)\n      .attr(\"width\", 10000)\n      .attr(\"height\", 10000)\n      .attr(\"fill\", \"url(#grid)\")\n      .attr(\"pointer-events\", \"none\");\n\n    const nodeSel = g\n      .selectAll<SVGGElement, Node>(\"g.node\")\n      .data(nodes, (d) => d.id);\n\n    nodeSel.exit().remove();\n\n    const nodeEnter = nodeSel\n      .enter()\n      .append(\"g\")\n      .attr(\"class\", \"node\")\n      .attr(\"cursor\", \"pointer\");\n\n    nodeEnter\n      .append(\"rect\")\n      .attr(\"class\", \"node-body\")\n      .attr(\"width\", (d) => d.width)\n      .attr(\"height\", (d) => d.height)\n      .attr(\"fill\", \"#07070f\")\n      .attr(\"stroke\", nodeColor)\n      .attr(\"stroke-width\", 1);\n\n    nodeEnter\n      .append(\"rect\")\n      .attr(\"class\", \"node-header\")\n      .attr(\"width\", (d) => d.width)\n      .attr(\"height\", 10)\n      .attr(\"fill\", nodeColor);\n\n    nodeEnter\n      .append(\"text\")\n      .attr(\"x\", (d) => d.width / 2)\n      .attr(\"y\", 2)\n      .attr(\"text-anchor\", \"middle\")\n      .attr(\"dominant-baseline\", \"hanging\")\n      .attr(\"fill\", nodeLightColor)\n      .attr(\"font-size\", 8)\n      .attr(\"font-weight\", 800)\n      .text((d) => {\n        if (d.nodeType === \"DASHBOARD_EVENT_LISTENER\") {\n          return \"Dashboard\";\n        }\n        return d.nodeType || \"\";\n      });\n\n    const nodesMerged = nodeEnter.merge(nodeSel);\n\n    nodesMerged\n      .attr(\n        \"transform\",\n        (d) => `translate(${d.x - d.width / 2},${d.y - d.height / 2})`,\n      )\n      .select<SVGRectElement>(\"rect\")\n      .attr(\"stroke\", (d) =>\n        selectedElement?.type === \"node\" && selectedElement.id === d.id\n          ? highlightColor\n          : nodeColor,\n      );\n\n    nodesMerged\n      .select<SVGRectElement>(\".node-body\")\n      .attr(\"stroke\", (d) =>\n        selectedElement?.type === \"node\" && selectedElement.id === d.id\n          ? highlightColor\n          : nodeColor,\n      );\n\n    nodesMerged\n      .on(\"mouseover\", function (event, d) {\n        if (selectedElement?.type === \"node\" && selectedElement.id === d.id)\n          return;\n        d3.select(this)\n          .select(\".node-body\")\n          .transition()\n          .duration(100)\n          .attr(\"stroke\", nodeHoverColor);\n\n        d3.select(this)\n          .select(\".node-header\")\n          .transition()\n          .duration(100)\n          .attr(\"fill\", nodeHoverColor);\n      })\n      .on(\"mouseout\", function (event, d) {\n        if (selectedElement?.type === \"node\" && selectedElement.id === d.id)\n          return;\n        d3.select(this)\n          .select(\".node-body\")\n          .transition()\n          .duration(100)\n          .attr(\"stroke\", nodeColor);\n\n        d3.select(this)\n          .select(\".node-header\")\n          .transition()\n          .duration(100)\n          .attr(\"fill\", nodeColor);\n      });\n\n    nodesMerged\n      .select<SVGRectElement>(\".node-header\")\n      .attr(\"fill\", (d) =>\n        selectedElement?.type === \"node\" && selectedElement.id === d.id\n          ? highlightColor\n          : nodeColor,\n      );\n\n    nodesMerged.each(function (d) {\n      const g = d3.select<SVGGElement, Node>(this);\n      //console.log(\"Rendering nodes:\");\n      g.selectAll(\".node-content\").remove();\n      const renderer = d.nodeType ? nodeRenderers[d.nodeType] : null;\n      if (renderer) renderer(g, d);\n    });\n\n    const drag = d3.drag<SVGGElement, Node>().on(\"drag\", (e, d) => {\n      if (locked || isDraggingRef.current) return;\n      const updated = nodesRef.current.map((n) =>\n        n.id === d.id ? { ...n, x: e.x, y: e.y } : n,\n      );\n      onNodesChange?.(updated);\n    });\n\n    nodesMerged.on(\"click\", (e, d) => {\n      e.stopPropagation();\n      setSelectedElement({ type: \"node\", id: d.id });\n    });\n\n    nodesMerged.call(drag);\n\n    const connSel = nodesMerged\n      .selectAll<SVGGElement, Connector>(\"g.connector\")\n      .data(\n        (d) => d.connectors,\n        (c) => c.id,\n      );\n\n    connSel.exit().remove();\n\n    const connEnter = connSel.enter().append(\"g\").attr(\"class\", \"connector\");\n\n    connEnter.append(\"circle\").attr(\"r\", 3);\n\n    connEnter\n      .append(\"text\")\n      .attr(\"font-size\", 8)\n      .attr(\"dy\", 4)\n      .attr(\"text-anchor\", (c) => (c.type === \"in\" ? \"end\" : \"start\"))\n      .attr(\"dx\", (c) => (c.type === \"in\" ? -6 : 6))\n      .attr(\"fill\", nodeLightColor)\n      .text((c) => c.name);\n\n    connEnter\n      .merge(connSel)\n      .attr(\"data-connector-id\", (c) => c.id)\n      .attr(\"data-connector-type\", (c) => c.type)\n      .attr(\"transform\", (c, i, els) => {\n        const parentEl = els[i].parentNode as SVGGElement;\n        const parent = d3.select<SVGGElement, Node>(parentEl).datum();\n        const list = parent.connectors.filter((x) => x.type === c.type);\n        const idx = list.findIndex((x) => x.id === c.id);\n        const h = parent.height;\n        const spacing = list.length > 0 ? h / list.length : 0;\n        const dx = c.type === \"in\" ? 0 : parent.width;\n        const dy = spacing * idx + spacing / 2;\n        return `translate(${dx},${dy})`;\n      })\n      .select(\"circle\")\n      .attr(\"fill\", (c) =>\n        selectedConnector === c.id ? highlightColor : nodeLightColor,\n      );\n\n    const edgeSel = g\n      .selectAll<SVGPathElement, Edge>(\"path.edge\")\n      .data(edges, (d) => d.id);\n\n    edgeSel.exit().remove();\n\n    const edgeEnter = edgeSel\n      .enter()\n      .append(\"path\")\n      .attr(\"class\", \"edge\")\n      .attr(\"fill\", \"none\")\n      .style(\"cursor\", \"pointer\")\n      .attr(\"stroke\", edgeColor)\n      .attr(\"stroke-width\", edgeWidth);\n\n    edgeEnter\n      .merge(edgeSel)\n      .attr(\"stroke\", (d) =>\n        selectedElement?.type === \"edge\" && selectedElement.id === d.id\n          ? highlightColor\n          : edgeColor,\n      )\n      .attr(\"d\", (d) => {\n        const srcNode = nodes.find((n) =>\n          n.connectors.some((c) => c.id === d.source),\n        )!;\n        const srcConn = srcNode.connectors.find((c) => c.id === d.source)!;\n        const srcW = srcNode.width;\n        const srcH = srcNode.height;\n        const srcDx = srcConn.type === \"in\" ? 0 : srcW;\n        const srcList = srcNode.connectors.filter(\n          (c) => c.type === srcConn.type,\n        );\n        const srcIdx = srcList.findIndex((c) => c.id === srcConn.id);\n        const srcSpacing = srcList.length > 0 ? srcH / srcList.length : 0;\n        const srcDy = srcSpacing * srcIdx + srcSpacing / 2;\n        const x1 = srcNode.x - srcW / 2 + srcDx;\n        const y1 = srcNode.y - srcH / 2 + srcDy;\n\n        const tgtNode = nodes.find((n) =>\n          n.connectors.some((c) => c.id === d.target),\n        )!;\n        const tgtConn = tgtNode.connectors.find((c) => c.id === d.target)!;\n        const tgtW = tgtNode.width;\n        const tgtH = tgtNode.height;\n        const tgtDx = tgtConn.type === \"in\" ? 0 : tgtW;\n        const tgtList = tgtNode.connectors.filter(\n          (c) => c.type === tgtConn.type,\n        );\n        const tgtIdx = tgtList.findIndex((c) => c.id === tgtConn.id);\n        const tgtSpacing = tgtList.length > 0 ? tgtH / tgtList.length : 0;\n        const tgtDy = tgtSpacing * tgtIdx + tgtSpacing / 2;\n        const x2 = tgtNode.x - tgtW / 2 + tgtDx;\n        const y2 = tgtNode.y - tgtH / 2 + tgtDy;\n\n        const mx = (x1 + x2) / 2;\n        return `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`;\n      })\n      .on(\"click\", (e, d) => {\n        e.stopPropagation();\n        setSelectedElement({ type: \"edge\", id: d.id });\n      });\n\n    g.selectAll<SVGPathElement, Edge>(\".edge-hit\").remove();\n\n    g.selectAll<SVGPathElement, Edge>(\"path.edge\")\n      .style(\"pointer-events\", \"none\")\n      .style(\"transition\", \"stroke 0.2s ease\")\n      .each(function (d) {\n        const original = this as SVGPathElement;\n        const hit = original.cloneNode(false) as SVGPathElement;\n        hit.setAttribute(\"class\", \"edge-hit\");\n        hit.setAttribute(\"fill\", \"none\");\n        hit.setAttribute(\"stroke\", \"transparent\");\n        hit.setAttribute(\"stroke-width\", \"12\");\n        hit.style.pointerEvents = \"stroke\";\n        hit.addEventListener(\"click\", (e) => {\n          e.stopPropagation();\n          setSelectedElement({ type: \"edge\", id: d.id });\n        });\n\n        hit.addEventListener(\"mouseover\", () => {\n          hit.setAttribute(\"stroke\", \"#26282b90\");\n        });\n        hit.addEventListener(\"mouseout\", () => {\n          hit.setAttribute(\"stroke\", \"transparent\");\n        });\n        original.parentNode!.insertBefore(hit, original);\n      });\n\n    let magnetTarget: { id: string; x: number; y: number } | null = null;\n    const magnetRadius = 10;\n\n    connEnter.on(\"mousedown\", (e, c) => {\n      e.stopPropagation();\n      if (c.type !== \"out\") return;\n      setSelectedConnector(c.id);\n      dragSourceRef.current = c.id;\n      isDraggingRef.current = true;\n      const gNode = gRef.current;\n      if (!gNode) return;\n      const [x, y] = d3.pointer(e, gNode);\n      dragLineRef.current = d3\n        .select(gNode)\n        .append(\"line\")\n        .attr(\"class\", \"drag-temp\")\n        .attr(\"stroke\", highlightColor)\n        .attr(\"stroke-width\", edgeWidth)\n        .attr(\"pointer-events\", \"none\")\n        .attr(\"x1\", x)\n        .attr(\"y1\", y)\n        .attr(\"x2\", x)\n        .attr(\"y2\", y);\n\n      d3.select(svgRef.current)\n        .on(\"mousemove.dragline\", (ev) => {\n          if (!isDraggingRef.current || !dragLineRef.current) return;\n          const [mx, my] = d3.pointer(ev, gNode);\n          magnetTarget = null;\n          nodesRef.current.forEach((node) => {\n            node.connectors.forEach((connItem) => {\n              if (connItem.type !== \"in\") return;\n              const w = node.width;\n              const h = node.height;\n              const dx = connItem.type === \"in\" ? 0 : w;\n              const list = node.connectors.filter(\n                (x) => x.type === connItem.type,\n              );\n              const idx = list.findIndex((x) => x.id === connItem.id);\n              const spacing = list.length > 0 ? h / list.length : 0;\n              const dy = spacing * idx + spacing / 2;\n              const cx = node.x - w / 2 + dx;\n              const cy = node.y - h / 2 + dy;\n              const dist = Math.hypot(mx - cx, my - cy);\n              if (dist < magnetRadius) {\n                magnetTarget = { id: connItem.id, x: cx, y: cy };\n              }\n            });\n          });\n\n          if (magnetTarget) {\n            const magnetH = magnetTarget as { x: number; y: number };\n            dragLineRef.current.attr(\"x2\", magnetH.x).attr(\"y2\", magnetH.y);\n          } else {\n            dragLineRef.current.attr(\"x2\", mx).attr(\"y2\", my);\n          }\n        })\n        .on(\"mouseup.dragline\", (ev) => {\n          d3.select(svgRef.current).on(\".dragline\", null);\n          dragLineRef.current?.remove();\n          dragLineRef.current = null;\n          isDraggingRef.current = false;\n          setSelectedConnector(null);\n          let targetId: string | null = null;\n          if (magnetTarget) {\n            targetId = magnetTarget.id;\n          } else {\n            const targetEl = (ev.target as HTMLElement).closest(\n              \"g.connector\",\n            ) as SVGGElement | null;\n            if (targetEl) {\n              const type = targetEl.getAttribute(\"data-connector-type\");\n              const id = targetEl.getAttribute(\"data-connector-id\");\n              if (type === \"in\" && id) {\n                targetId = id;\n              }\n            }\n          }\n          if (targetId && dragSourceRef.current) {\n            const isExistConnection =\n              edgesRef.current.filter((item) => {\n                return (\n                  item.source == dragSourceRef.current &&\n                  item.target == targetId\n                );\n              }).length > 0;\n\n            if (isExistConnection == false) {\n              onEdgesChange?.([\n                ...edgesRef.current,\n                {\n                  id: `${dragSourceRef.current}-${targetId}-${Date.now()}`,\n                  source: dragSourceRef.current,\n                  target: targetId,\n                },\n              ]);\n            }\n          }\n          dragSourceRef.current = null;\n          magnetTarget = null;\n        });\n    });\n\n    nodesMerged.raise();\n  }, [\n    nodes,\n    edges,\n    locked,\n    selectedConnector,\n    selectedElement,\n    edgeColor,\n    edgeWidth,\n    onNodesChange,\n    onEdgesChange,\n  ]);\n\n  const handleZoom = useCallback((k: number) => {\n    if (!zoomRef.current) return;\n    if (!svgRef.current) return;\n\n    const scaleBy = zoomRef.current.scaleBy;\n\n    d3.select<SVGSVGElement, unknown>(svgRef.current)\n      .transition()\n      .duration(200)\n      .call(scaleBy, k);\n  }, []);\n\n  return (\n    <div\n      style={{\n        width,\n        height,\n        position: \"relative\",\n      }}\n    >\n      <svg ref={svgRef} width='100%' height='100%'>\n        <g ref={gRef} />\n      </svg>\n      <SelectedItemActions\n        selectedElement={selectedElement}\n        onDeleteNode={deleteSelectedNode}\n      />\n      <div\n        style={{\n          position: \"absolute\",\n          top: 32,\n          right: 16,\n          display: \"flex\",\n          flexDirection: \"column\",\n          gap: 8,\n        }}\n      >\n        <DropdownMenu>\n          <DropdownMenuTrigger asChild>\n            <Button size={\"sm\"} variant={\"secondary\"}>\n              <SquarePlus size={18} />\n            </Button>\n          </DropdownMenuTrigger>\n          <DropdownMenuContent>\n            {nodeGroups.map((group) => (\n              <DropdownMenuSub key={group.label}>\n                <DropdownMenuSubTrigger>\n                  <span>{group.label}</span>\n                </DropdownMenuSubTrigger>\n                <DropdownMenuPortal>\n                  <DropdownMenuSubContent>\n                    {group.nodes.map((nodeType) => (\n                      <DropdownMenuItem\n                        key={nodeType}\n                        onClick={() => handleAddNode(nodeType as NodeTypes)}\n                      >\n                        {formatConstantCase(nodeType)}\n                      </DropdownMenuItem>\n                    ))}\n                  </DropdownMenuSubContent>\n                </DropdownMenuPortal>\n              </DropdownMenuSub>\n            ))}\n          </DropdownMenuContent>\n        </DropdownMenu>\n\n        <Button\n          size={\"sm\"}\n          variant={\"secondary\"}\n          onClick={() => handleZoom(1.2)}\n        >\n          <Plus size={18} />\n        </Button>\n        <Button\n          size={\"sm\"}\n          variant={\"secondary\"}\n          onClick={() => handleZoom(0.8)}\n        >\n          <Minus size={18} />\n        </Button>\n        <Button\n          size={\"sm\"}\n          variant={\"secondary\"}\n          onClick={() => setLocked((p) => !p)}\n        >\n          {locked ? <Lock size={18} /> : <LockOpen size={18} />}\n        </Button>\n        <AddCustomNode />\n\n        <Options\n          open={open}\n          selectedNode={openedNode}\n          setOpen={handleOpenOptions}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/flow/Options.tsx",
    "content": "import { useState, useEffect, useCallback } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n} from \"@/components/ui/sheet\";\nimport { DataNodeType, DataNodeTypeType, Node } from \"./flowTypes\";\nimport { useFlowStore } from \"@/entities/flow/store\";\n\ninterface OptionsProps {\n  open: boolean;\n  selectedNode: Node | null;\n  setOpen: (open: boolean) => void;\n}\n\nconst getDefaultValueForType = (type: string) => {\n  switch (type.toUpperCase()) {\n    case \"NUMBER\":\n      return \"0\";\n    case \"BOOLEAN\":\n      return \"false\";\n    case \"JSON\":\n      return \"{}\";\n    case \"STRING\":\n    default:\n      return \"\";\n  }\n};\n\nexport function Options({ open, selectedNode, setOpen }: OptionsProps) {\n  const { updateNode, addRefresh } = useFlowStore();\n\n  const [formData, setFormData] = useState<DataNodeType | object>({});\n  const [formTypeData, setFormTypeData] = useState<DataNodeTypeType>({});\n\n  const resolveDynamicTypes = useCallback(\n    (\n      currentData: DataNodeType | object,\n      baseDataType: DataNodeTypeType | undefined,\n    ) => {\n      if (!baseDataType) return {};\n\n      const resolved: DataNodeTypeType = {};\n      const dependsOnRegex = /^DEPENDS_ON\\[(.*?):(.*?)\\]$/;\n\n      for (const key in baseDataType) {\n        const typeDef = baseDataType[key] as string;\n        const match = typeDef.match(dependsOnRegex);\n\n        if (match) {\n          const controllerField = match[1];\n          const mappingsString = match[2];\n          const controllingValue =\n            currentData[controllerField as keyof typeof currentData];\n\n          const mapping = new Map(\n            mappingsString.split(\",\").map((part) => {\n              const [val, type] = part.split(\">\");\n              return [val, type];\n            }),\n          );\n\n          resolved[key] = mapping.get(controllingValue as string) || \"ANY\";\n        } else {\n          resolved[key] = typeDef;\n        }\n      }\n      return resolved;\n    },\n    [],\n  );\n\n  useEffect(() => {\n    if (selectedNode?.data && selectedNode?.dataType) {\n      setFormData(selectedNode.data);\n      const initialResolvedTypes = resolveDynamicTypes(\n        selectedNode.data,\n        selectedNode.dataType,\n      );\n      setFormTypeData(initialResolvedTypes);\n    } else {\n      setFormData({});\n      setFormTypeData({});\n    }\n  }, [selectedNode, resolveDynamicTypes]);\n\n  const handleInputChange = (key: string, value: string | number) => {\n    const originalValue =\n      selectedNode?.data?.[key as keyof typeof selectedNode.data];\n    let processedValue: string | number = value;\n\n    if (typeof originalValue === \"number\") {\n      processedValue = parseFloat(value as string);\n      if (isNaN(processedValue)) {\n        processedValue = 0;\n      }\n    }\n\n    const updatedFormData = { ...formData, [key]: processedValue };\n    setFormData(updatedFormData);\n\n    const newResolvedTypes = resolveDynamicTypes(\n      updatedFormData,\n      selectedNode?.dataType,\n    );\n    setFormTypeData(newResolvedTypes);\n\n    const dataToReset: Record<string, string | number> = {};\n    for (const field in newResolvedTypes) {\n      if (formTypeData[field] !== newResolvedTypes[field]) {\n        dataToReset[field] = getDefaultValueForType(newResolvedTypes[field]);\n      }\n    }\n\n    if (Object.keys(dataToReset).length > 0) {\n      setFormData((prev) => ({\n        ...prev,\n        ...dataToReset,\n      }));\n    }\n  };\n\n  const handleSubmit = () => {\n    if (selectedNode) {\n      updateNode(selectedNode.id, formData as DataNodeType);\n      setOpen(false);\n      addRefresh();\n    }\n  };\n\n  const renderFormFields = () => {\n    if (!selectedNode || !Object.keys(formData).length) {\n      return (\n        <p className='text-sm text-muted-foreground'>\n          This node has no editable properties.\n        </p>\n      );\n    }\n\n    return Object.entries(formData).map(([key, value]) => {\n      const inputId = `${selectedNode.id}-${key}`;\n      const ifExist = key in formTypeData;\n\n      if (!ifExist) {\n        return null;\n      }\n\n      const selectRegex = /^SELECT\\[(.*)\\]$/;\n      const match = formTypeData[key]?.match(selectRegex);\n\n      if (match && match[1]) {\n        return (\n          <div key={inputId} className='grid grid-cols-4 items-center gap-4'>\n            <Label htmlFor={inputId} className='text-right'>\n              {key}\n            </Label>\n            <Select\n              value={String(value)}\n              onValueChange={(val) => handleInputChange(key, val)}\n            >\n              <SelectTrigger id={inputId} className='col-span-3'>\n                <SelectValue placeholder={`Select ${key}`} />\n              </SelectTrigger>\n              <SelectContent className='z-[9999]'>\n                <>\n                  {match[1].split(\",\").map((item) => (\n                    <SelectItem key={item} value={item}>\n                      {item}\n                    </SelectItem>\n                  ))}\n                </>\n              </SelectContent>\n            </Select>\n          </div>\n        );\n      }\n\n      switch (formTypeData[key]) {\n        case \"NUMBER\":\n          return (\n            <div key={inputId} className='grid grid-cols-4 items-center gap-4'>\n              <Label htmlFor={inputId} className='text-right'>\n                {key}\n              </Label>\n              <Input\n                id={inputId}\n                type='number'\n                value={String(value)}\n                onChange={(e) => handleInputChange(key, e.target.value)}\n                className='col-span-3'\n              />\n            </div>\n          );\n        case \"STRING\":\n        case \"ANY\":\n          return (\n            <div key={inputId} className='grid grid-cols-4 items-center gap-4'>\n              <Label htmlFor={inputId} className='text-right'>\n                {key}\n              </Label>\n              <Input\n                id={inputId}\n                value={String(value)}\n                onChange={(e) => handleInputChange(key, e.target.value)}\n                className='col-span-3'\n              />\n            </div>\n          );\n        case \"JSON\":\n          return (\n            <div key={inputId} className='grid grid-cols-4 items-center gap-4'>\n              <Label htmlFor={inputId} className='text-right'>\n                {key}\n              </Label>\n              <textarea\n                id={inputId}\n                value={String(value)}\n                onChange={(e) => handleInputChange(key, e.target.value)}\n                className='col-span-3 p-2 border rounded-md h-24 font-mono'\n                rows={4}\n              />\n            </div>\n          );\n        case \"BOOLEAN\":\n          return (\n            <div key={inputId} className='grid grid-cols-4 items-center gap-4'>\n              <Label htmlFor={inputId} className='text-right'>\n                {key}\n              </Label>\n              <Select\n                value={String(value)}\n                onValueChange={(val) => handleInputChange(key, val)}\n              >\n                <SelectTrigger id={inputId} className='col-span-3'>\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent className='z-[9999]'>\n                  <SelectItem value='true'>true</SelectItem>\n                  <SelectItem value='false'>false</SelectItem>\n                </SelectContent>\n              </Select>\n            </div>\n          );\n        case \"FIXED_STRING\":\n          return (\n            <div key={inputId} className='grid grid-cols-4 items-center gap-4'>\n              <Label htmlFor={inputId} className='text-right'>\n                {key}\n              </Label>\n              <Input\n                id={inputId}\n                value={String(value)}\n                onChange={(e) => handleInputChange(key, e.target.value)}\n                className='col-span-3'\n                disabled={true}\n              />\n            </div>\n          );\n        default:\n          return null;\n      }\n    });\n  };\n\n  return (\n    <Sheet open={open} onOpenChange={setOpen}>\n      <SheetContent>\n        <SheetHeader>\n          <SheetTitle>Edit: {selectedNode?.title || \"Node\"}</SheetTitle>\n          <SheetDescription>\n            Modify the properties of the selected node here.\n          </SheetDescription>\n        </SheetHeader>\n        <div className='grid gap-4 p-4'>{renderFormFields()}</div>\n        <SheetFooter>\n          <Button onClick={handleSubmit}>Save Changes</Button>\n        </SheetFooter>\n      </SheetContent>\n    </Sheet>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/flow/OptionsVariation.tsx",
    "content": ""
  },
  {
    "path": "apps/client/src/features/flow/RunFlow.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { useFlowStore } from \"@/entities/flow/store\";\nimport { Play, Square } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { useWebSocket, useWebSocketMessage } from \"@/features/ws/WebSocketProvider\";\nimport { useCallback, useEffect, useState } from \"react\";\nimport { getFlowRunSessionId, WebSocketMessage } from \"@/features/ws/ws\";\n\nexport function RunFlowButton() {\n  const { wsManager } = useWebSocket();\n\n  const { currentFlowId, saveGraph } = useFlowStore();\n\n  const [isRun, setIsRun] = useState(false);\n\n  const handleSend = async () => {\n    if (!currentFlowId || !wsManager) {\n      toast.error(\"WebSocket is not connected or no flow is selected.\");\n      return;\n    }\n\n    if (currentFlowId && wsManager) {\n      await saveGraph();\n      wsManager.send({\n        type: \"compute_flow\",\n        payload: {\n          flow_id: currentFlowId,\n          run_context: { session_id: getFlowRunSessionId() },\n        },\n      });\n\n      setTimeout(() => {\n        wsManager.send({\n          type: \"get_all_flows\",\n          payload: {},\n        });\n      }, 500);\n    }\n  };\n\n  const handleSendStop = async () => {\n    if (!currentFlowId || !wsManager) {\n      toast.error(\"WebSocket is not connected or no flow is selected.\");\n      return;\n    }\n\n    if (currentFlowId && wsManager) {\n      await saveGraph();\n      wsManager.send({\n        type: \"stop_flow\",\n        payload: {\n          flow_id: currentFlowId,\n        },\n      });\n\n      setTimeout(() => {\n        wsManager.send({\n          type: \"get_all_flows\",\n          payload: {},\n        });\n      }, 500);\n      setIsRun(false);\n    }\n  };\n\n  const handleMessage = useCallback(\n    (msg: WebSocketMessage) => {\n      try {\n        if (msg.type === \"get_all_flows_response\") {\n          const loads = msg.payload as {\n            id: number;\n            name: string;\n            is_running: boolean;\n          }[];\n          const index = loads.findIndex((item) => {\n            return item.id == currentFlowId;\n          });\n\n          if (index != -1) {\n            setIsRun(!loads[index].is_running);\n          }\n        }\n      } catch (err) {\n        console.error(\"Error handling signaling message:\", err);\n      }\n    },\n    [currentFlowId],\n  );\n\n  useEffect(() => {\n    if (currentFlowId && wsManager) {\n      wsManager.send({\n        type: \"get_all_flows\",\n        payload: {},\n      });\n    }\n  }, [currentFlowId, wsManager]);\n\n  useWebSocketMessage(handleMessage);\n\n  if (!currentFlowId) {\n    return null;\n  }\n\n  return (\n    <>\n      {isRun ? (\n        <Button onClick={handleSend} size={\"icon\"} variant={\"outline\"}>\n          <Play />\n        </Button>\n      ) : (\n        <Button onClick={handleSendStop} size={\"icon\"} variant={\"destructive\"}>\n          <Square />\n        </Button>\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/flow/SelectedItemActions.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { Trash2 } from \"lucide-react\";\n\ntype SelectedElement = {\n  type: \"node\" | \"edge\";\n  id: string;\n} | null;\n\ntype Props = {\n  selectedElement: SelectedElement;\n  onDeleteNode: () => void;\n};\n\n// Floating action bar anchored at the top; avoids overlap with nodes.\nexport function SelectedItemActions({ selectedElement, onDeleteNode }: Props) {\n  if (!selectedElement || selectedElement.type !== \"node\") {\n    return null;\n  }\n\n  return (\n    <div\n      style={{\n        position: \"absolute\",\n        top: 12,\n        left: \"12px\",\n        display: \"flex\",\n        gap: 8,\n        zIndex: 10,\n      }}\n    >\n      <Button\n        size={\"sm\"}\n        variant={\"outline\"}\n        onClick={onDeleteNode}\n        style={{ display: \"flex\", alignItems: \"center\", gap: 6 }}\n      >\n        <Trash2 size={14} />\n      </Button>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/flow/flow-chat/buildSystemPrompt.ts",
    "content": "import type { Node, Edge } from \"@/features/flow/flowTypes\";\nimport type { Flow } from \"@/entities/flow/types\";\nimport { DEFINITION_NODE } from \"@/features/flow/flowNode\";\n\nexport function buildFlowSystemPrompt(\n  currentNodes: Node[],\n  currentEdges: Edge[],\n  availableFlows: Flow[],\n  currentFlowName: string | null,\n): string {\n  const nodeSchemas = Object.entries(DEFINITION_NODE).map(([type, def]) => {\n    const connectors = def.connectors.map(\n      (c: { name: string; type: string }) => `${c.name}(${c.type})`,\n    );\n    const dataFields = def.data\n      ? Object.entries(def.data).map(([k, v]) => `${k}: ${JSON.stringify(v)}`)\n      : [];\n    const dataTypeFields = def.dataType\n      ? Object.entries(def.dataType).map(([k, v]) => `${k}: ${v}`)\n      : [];\n\n    return [\n      `- ${type}`,\n      `  connectors: [${connectors.join(\", \")}]`,\n      dataFields.length > 0\n        ? `  data defaults: {${dataFields.join(\", \")}}`\n        : null,\n      dataTypeFields.length > 0\n        ? `  data types: {${dataTypeFields.join(\", \")}}`\n        : null,\n    ]\n      .filter(Boolean)\n      .join(\"\\n\");\n  });\n\n  const flowsList = availableFlows\n    .map((f) => `- ${f.name} (id: ${f.id})`)\n    .join(\"\\n\");\n\n  const currentGraph =\n    currentNodes.length > 0\n      ? JSON.stringify({ nodes: currentNodes, edges: currentEdges }, null, 2)\n      : \"(empty)\";\n\n  return `You are a flow builder assistant, a visual node-based automation platform.\nYour job is to create or modify flows by calling the provided tools.\n\n## Available Node Types\n\n${nodeSchemas.join(\"\\n\\n\")}\n\n## ID Generation Rules\n\n- Node ID: \\`{NODE_TYPE}-{timestamp}\\` (e.g. \\`INTERVAL-1709876543210\\`)\n  Use Date.now() style timestamps. Each node MUST have a unique ID.\n- Connector ID: \\`{nodeId}-{connectorType}{index}\\` (e.g. \\`INTERVAL-1709876543210-out0\\`)\n  connectorType is \"in\" or \"out\", index is 0-based position in the connectors array.\n\n## Edge Rules\n\n- \\`source\\` must be an \"out\" type connector ID\n- \\`target\\` must be an \"in\" type connector ID\n- Edge ID: \\`{source}-{target}\\`\n\n## Node Position Layout Rules\n\n- Layout nodes left-to-right following data flow direction\n- Start position: (100, 100)\n- Horizontal gap between nodes: 250px\n- Vertical gap between parallel nodes: 150px\n- Node dimensions: width=120, height=50\n\n## Current State\n\nCurrent flow: ${currentFlowName ?? \"(none selected)\"}\n\nRegistered flows:\n${flowsList || \"(none)\"}\n\nCurrent graph:\n${currentGraph}\n\n## Instructions\n\n- Always use tools to modify the flow. Never describe changes in plain text.\n- When generating a complete flow, use the \\`generate_flow\\` tool.\n- When adding to an existing flow, use \\`add_nodes\\` and \\`add_edges\\`.\n- When modifying, use \\`update_node\\` or \\`remove_nodes\\`.\n- Ensure all edges connect valid connector IDs with correct in/out types.\n- After tool execution, briefly summarize what was done.`;\n}\n"
  },
  {
    "path": "apps/client/src/features/flow/flow-chat/executeToolCalls.ts",
    "content": "import type { ToolCallResult } from \"@vessel/capsule-client\";\nimport type { Node, Edge, DataNodeType } from \"@/features/flow/flowTypes\";\n\nexport interface ToolExecutionResult {\n  toolCallId: string;\n  name: string;\n  success: boolean;\n  message: string;\n}\n\ninterface FlowStoreActions {\n  nodes: Node[];\n  edges: Edge[];\n  setNodes: (nodes: Node[]) => void;\n  setEdges: (edges: Edge[]) => void;\n  updateNode: (nodeId: string, data: DataNodeType) => void;\n}\n\nfunction validateEdges(\n  edges: Edge[],\n  allNodes: Node[],\n): { valid: Edge[]; errors: string[] } {\n  const valid: Edge[] = [];\n  const errors: string[] = [];\n\n  const connectorMap = new Map<string, \"in\" | \"out\">();\n  for (const node of allNodes) {\n    for (const c of node.connectors) {\n      connectorMap.set(c.id, c.type as \"in\" | \"out\");\n    }\n  }\n\n  const existingEdgeIds = new Set<string>();\n\n  for (const edge of edges) {\n    const sourceType = connectorMap.get(edge.source);\n    const targetType = connectorMap.get(edge.target);\n\n    if (!sourceType) {\n      errors.push(`Edge ${edge.id}: source connector \"${edge.source}\" not found`);\n      continue;\n    }\n    if (!targetType) {\n      errors.push(`Edge ${edge.id}: target connector \"${edge.target}\" not found`);\n      continue;\n    }\n    if (sourceType !== \"out\") {\n      errors.push(`Edge ${edge.id}: source \"${edge.source}\" is not an \"out\" connector`);\n      continue;\n    }\n    if (targetType !== \"in\") {\n      errors.push(`Edge ${edge.id}: target \"${edge.target}\" is not an \"in\" connector`);\n      continue;\n    }\n\n    const edgeKey = `${edge.source}->${edge.target}`;\n    if (existingEdgeIds.has(edgeKey)) {\n      errors.push(`Edge ${edge.id}: duplicate connection ${edgeKey}`);\n      continue;\n    }\n    existingEdgeIds.add(edgeKey);\n    valid.push(edge);\n  }\n\n  return { valid, errors };\n}\n\nfunction executeGenerateFlow(\n  args: { nodes: Node[]; edges: Edge[] },\n  store: FlowStoreActions,\n): ToolExecutionResult & { toolCallId: string } {\n  const { valid, errors } = validateEdges(args.edges, args.nodes);\n\n  store.setNodes(args.nodes);\n  store.setEdges(valid);\n\n  const msg = errors.length > 0\n    ? `Generated flow with ${args.nodes.length} nodes and ${valid.length} edges. ${errors.length} edges skipped: ${errors.join(\"; \")}`\n    : `Generated flow with ${args.nodes.length} nodes and ${valid.length} edges.`;\n\n  return { toolCallId: \"\", name: \"generate_flow\", success: true, message: msg };\n}\n\nfunction executeAddNodes(\n  args: { nodes: Node[] },\n  store: FlowStoreActions,\n): ToolExecutionResult & { toolCallId: string } {\n  const newNodes = [...store.nodes, ...args.nodes];\n  store.setNodes(newNodes);\n\n  return {\n    toolCallId: \"\",\n    name: \"add_nodes\",\n    success: true,\n    message: `Added ${args.nodes.length} node(s).`,\n  };\n}\n\nfunction executeAddEdges(\n  args: { edges: Edge[] },\n  store: FlowStoreActions,\n): ToolExecutionResult & { toolCallId: string } {\n  const allNodes = store.nodes;\n  const { valid, errors } = validateEdges(args.edges, allNodes);\n\n  // Also check for duplicates with existing edges\n  const existingKeys = new Set(\n    store.edges.map((e) => `${e.source}->${e.target}`),\n  );\n  const actuallyNew = valid.filter((e) => {\n    const key = `${e.source}->${e.target}`;\n    if (existingKeys.has(key)) {\n      errors.push(`Edge ${e.id}: already exists`);\n      return false;\n    }\n    return true;\n  });\n\n  store.setEdges([...store.edges, ...actuallyNew]);\n\n  const msg = errors.length > 0\n    ? `Added ${actuallyNew.length} edge(s). ${errors.length} skipped: ${errors.join(\"; \")}`\n    : `Added ${actuallyNew.length} edge(s).`;\n\n  return { toolCallId: \"\", name: \"add_edges\", success: true, message: msg };\n}\n\nfunction executeRemoveNodes(\n  args: { node_ids: string[] },\n  store: FlowStoreActions,\n): ToolExecutionResult & { toolCallId: string } {\n  const idsToRemove = new Set(args.node_ids);\n  const removedNodes = store.nodes.filter((n) => idsToRemove.has(n.id));\n  const removedConnIds = new Set(\n    removedNodes.flatMap((n) => n.connectors.map((c) => c.id)),\n  );\n\n  store.setNodes(store.nodes.filter((n) => !idsToRemove.has(n.id)));\n  store.setEdges(\n    store.edges.filter(\n      (e) => !removedConnIds.has(e.source) && !removedConnIds.has(e.target),\n    ),\n  );\n\n  return {\n    toolCallId: \"\",\n    name: \"remove_nodes\",\n    success: true,\n    message: `Removed ${removedNodes.length} node(s) and their connected edges.`,\n  };\n}\n\nfunction executeUpdateNode(\n  args: { node_id: string; data: Record<string, unknown> },\n  store: FlowStoreActions,\n): ToolExecutionResult & { toolCallId: string } {\n  const node = store.nodes.find((n) => n.id === args.node_id);\n  if (!node) {\n    return {\n      toolCallId: \"\",\n      name: \"update_node\",\n      success: false,\n      message: `Node \"${args.node_id}\" not found.`,\n    };\n  }\n\n  store.updateNode(args.node_id, args.data as DataNodeType);\n\n  return {\n    toolCallId: \"\",\n    name: \"update_node\",\n    success: true,\n    message: `Updated node \"${args.node_id}\".`,\n  };\n}\n\nexport function executeFlowToolCalls(\n  toolCalls: ToolCallResult[],\n  store: FlowStoreActions,\n): ToolExecutionResult[] {\n  const results: ToolExecutionResult[] = [];\n\n  for (const tc of toolCalls) {\n    let args: Record<string, unknown>;\n    try {\n      args = JSON.parse(tc.function.arguments);\n    } catch {\n      results.push({\n        toolCallId: tc.id,\n        name: tc.function.name,\n        success: false,\n        message: `Failed to parse arguments: invalid JSON`,\n      });\n      continue;\n    }\n\n    let result: ToolExecutionResult;\n\n    switch (tc.function.name) {\n      case \"generate_flow\":\n        result = executeGenerateFlow(\n          args as { nodes: Node[]; edges: Edge[] },\n          store,\n        );\n        break;\n      case \"add_nodes\":\n        result = executeAddNodes(args as { nodes: Node[] }, store);\n        break;\n      case \"add_edges\":\n        result = executeAddEdges(args as { edges: Edge[] }, store);\n        break;\n      case \"remove_nodes\":\n        result = executeRemoveNodes(args as { node_ids: string[] }, store);\n        break;\n      case \"update_node\":\n        result = executeUpdateNode(\n          args as { node_id: string; data: Record<string, unknown> },\n          store,\n        );\n        break;\n      default:\n        result = {\n          toolCallId: tc.id,\n          name: tc.function.name,\n          success: false,\n          message: `Unknown tool: ${tc.function.name}`,\n        };\n    }\n\n    result.toolCallId = tc.id;\n    results.push(result);\n  }\n\n  return results;\n}\n"
  },
  {
    "path": "apps/client/src/features/flow/flow-chat/flowTools.ts",
    "content": "import type { Tool } from \"@vessel/capsule-client\";\n\nexport const FLOW_TOOLS: Tool[] = [\n  {\n    type: \"function\",\n    function: {\n      name: \"generate_flow\",\n      description:\n        \"Generate a complete flow graph by replacing all nodes and edges. Use this when creating a new flow from scratch or completely rebuilding the current flow.\",\n      parameters: {\n        type: \"object\",\n        properties: {\n          nodes: {\n            type: \"array\",\n            description: \"Array of flow nodes\",\n            items: {\n              type: \"object\",\n              properties: {\n                id: { type: \"string\", description: \"Unique node ID (format: {NODE_TYPE}-{timestamp})\" },\n                title: { type: \"string\", description: \"Display title (same as id)\" },\n                x: { type: \"number\", description: \"X position\" },\n                y: { type: \"number\", description: \"Y position\" },\n                width: { type: \"number\", description: \"Node width (default: 120)\" },\n                height: { type: \"number\", description: \"Node height (default: 50)\" },\n                nodeType: { type: \"string\", description: \"Node type (e.g. START, INTERVAL, LOG_MESSAGE)\" },\n                connectors: {\n                  type: \"array\",\n                  items: {\n                    type: \"object\",\n                    properties: {\n                      id: { type: \"string\", description: \"Connector ID (format: {nodeId}-{type}{index})\" },\n                      name: { type: \"string\", description: \"Connector name\" },\n                      type: { type: \"string\", enum: [\"in\", \"out\"], description: \"Connector direction\" },\n                    },\n                    required: [\"id\", \"name\", \"type\"],\n                  },\n                },\n                data: {\n                  type: \"object\",\n                  description: \"Node-specific configuration data\",\n                },\n              },\n              required: [\"id\", \"title\", \"x\", \"y\", \"width\", \"height\", \"nodeType\", \"connectors\"],\n            },\n          },\n          edges: {\n            type: \"array\",\n            description: \"Array of flow edges\",\n            items: {\n              type: \"object\",\n              properties: {\n                id: { type: \"string\", description: \"Edge ID (format: {source}-{target})\" },\n                source: { type: \"string\", description: \"Source connector ID (must be 'out' type)\" },\n                target: { type: \"string\", description: \"Target connector ID (must be 'in' type)\" },\n              },\n              required: [\"id\", \"source\", \"target\"],\n            },\n          },\n        },\n        required: [\"nodes\", \"edges\"],\n      },\n    },\n  },\n  {\n    type: \"function\",\n    function: {\n      name: \"add_nodes\",\n      description: \"Add new nodes to the existing flow without removing existing ones.\",\n      parameters: {\n        type: \"object\",\n        properties: {\n          nodes: {\n            type: \"array\",\n            description: \"Array of nodes to add\",\n            items: {\n              type: \"object\",\n              properties: {\n                id: { type: \"string\" },\n                title: { type: \"string\" },\n                x: { type: \"number\" },\n                y: { type: \"number\" },\n                width: { type: \"number\" },\n                height: { type: \"number\" },\n                nodeType: { type: \"string\" },\n                connectors: {\n                  type: \"array\",\n                  items: {\n                    type: \"object\",\n                    properties: {\n                      id: { type: \"string\" },\n                      name: { type: \"string\" },\n                      type: { type: \"string\", enum: [\"in\", \"out\"] },\n                    },\n                    required: [\"id\", \"name\", \"type\"],\n                  },\n                },\n                data: { type: \"object\" },\n              },\n              required: [\"id\", \"title\", \"x\", \"y\", \"width\", \"height\", \"nodeType\", \"connectors\"],\n            },\n          },\n        },\n        required: [\"nodes\"],\n      },\n    },\n  },\n  {\n    type: \"function\",\n    function: {\n      name: \"add_edges\",\n      description: \"Add new edges (connections) to the existing flow.\",\n      parameters: {\n        type: \"object\",\n        properties: {\n          edges: {\n            type: \"array\",\n            description: \"Array of edges to add\",\n            items: {\n              type: \"object\",\n              properties: {\n                id: { type: \"string\" },\n                source: { type: \"string\", description: \"Source connector ID (out type)\" },\n                target: { type: \"string\", description: \"Target connector ID (in type)\" },\n              },\n              required: [\"id\", \"source\", \"target\"],\n            },\n          },\n        },\n        required: [\"edges\"],\n      },\n    },\n  },\n  {\n    type: \"function\",\n    function: {\n      name: \"remove_nodes\",\n      description: \"Remove nodes from the flow. Connected edges are automatically removed.\",\n      parameters: {\n        type: \"object\",\n        properties: {\n          node_ids: {\n            type: \"array\",\n            items: { type: \"string\" },\n            description: \"Array of node IDs to remove\",\n          },\n        },\n        required: [\"node_ids\"],\n      },\n    },\n  },\n  {\n    type: \"function\",\n    function: {\n      name: \"update_node\",\n      description: \"Update the data (configuration) of an existing node.\",\n      parameters: {\n        type: \"object\",\n        properties: {\n          node_id: { type: \"string\", description: \"ID of the node to update\" },\n          data: {\n            type: \"object\",\n            description: \"New data fields to merge into the node's data\",\n          },\n        },\n        required: [\"node_id\", \"data\"],\n      },\n    },\n  },\n];\n"
  },
  {
    "path": "apps/client/src/features/flow/flow-chat/index.ts",
    "content": "export { buildFlowSystemPrompt } from \"./buildSystemPrompt\";\nexport { FLOW_TOOLS } from \"./flowTools\";\nexport { executeFlowToolCalls } from \"./executeToolCalls\";\nexport type { ToolExecutionResult } from \"./executeToolCalls\";\n"
  },
  {
    "path": "apps/client/src/features/flow/flowNode.ts",
    "content": "import { Node } from \"./flowTypes\";\n\nexport type DefaultValueType = {\n  connectors: Node[\"connectors\"];\n  nodeType: string;\n  data: Node[\"data\"];\n  dataType: Node[\"dataType\"];\n};\n\nexport const DEFINITION_NODE = {\n  START: {\n    connectors: [{ id: `id`, name: \"out\", type: \"out\" }],\n    nodeType: \"START\",\n    data: undefined,\n    dataType: undefined,\n  },\n  SET_VARIABLE: {\n    connectors: [{ id: `id`, name: \"out\", type: \"out\" }],\n    nodeType: \"SET_VARIABLE\",\n    data: {\n      variable: \"\",\n      variableType: \"string\",\n    },\n    dataType: {\n      variable:\n        \"DEPENDS_ON[variableType:string>STRING,number>NUMBER,boolean>BOOLEAN,json>JSON]\",\n\n      variableType: \"SELECT[string,number,boolean,json]\",\n    },\n  },\n  SET_VARIABLE_WITH_EXEC: {\n    connectors: [\n      { id: `id`, name: \"exec\", type: \"in\" },\n      { id: `id`, name: \"out\", type: \"out\" },\n    ],\n    nodeType: \"SET_VARIABLE_WITH_EXEC\",\n    data: {\n      variable: \"\",\n      variableType: \"string\",\n    },\n    dataType: {\n      variable:\n        \"DEPENDS_ON[variableType:string>STRING,number>NUMBER,boolean>BOOLEAN,json>JSON]\",\n\n      variableType: \"SELECT[string,number,boolean,json]\",\n    },\n  },\n  CONDITION: {\n    connectors: [\n      { id: `id`, name: \"input\", type: \"in\" },\n      { id: `id`, name: \"true\", type: \"out\" },\n      { id: `id`, name: \"false\", type: \"out\" },\n    ],\n    nodeType: \"CONDITION\",\n    data: {\n      operator: \"GreaterThan\",\n      operand: 0,\n    },\n    dataType: {\n      operator: \"SELECT[GreaterThan,LessThan,EqualTo]\",\n      operand: \"NUMBER\",\n    },\n  },\n  LOG_MESSAGE: {\n    connectors: [{ id: `id`, name: \"message\", type: \"in\" }],\n    nodeType: \"LOG_MESSAGE\",\n    data: undefined,\n    dataType: undefined,\n  },\n  SHOW_TOAST: {\n    connectors: [\n      { id: `id`, name: \"exec\", type: \"in\" },\n      { id: `id`, name: \"out\", type: \"out\" },\n    ],\n    nodeType: \"SHOW_TOAST\",\n    data: {\n      level: \"info\",\n      title: \"\",\n      message: \"\",\n      durationMs: 4000,\n    },\n    dataType: {\n      level: \"SELECT[info,success,warning,error]\",\n      title: \"STRING\",\n      message: \"STRING\",\n      durationMs: \"NUMBER\",\n    },\n  },\n  CALCULATION: {\n    connectors: [\n      { id: `id`, name: \"a\", type: \"in\" },\n      { id: `id`, name: \"b\", type: \"in\" },\n      { id: `id`, name: \"number\", type: \"out\" },\n    ],\n    nodeType: \"CALCULATION\",\n    data: {\n      operatorCalc: \"+\",\n    },\n    dataType: {\n      operatorCalc: \"SELECT[+,-,/,*,%]\",\n    },\n  },\n  HTTP_REQUEST: {\n    connectors: [\n      { id: `id`, name: \"execution\", type: \"in\" },\n      { id: `id`, name: \"result\", type: \"out\" },\n    ],\n    nodeType: \"HTTP_REQUEST\",\n    data: {\n      url: \"\",\n      httpMethod: \"GET\",\n    },\n    dataType: {\n      url: \"STRING\",\n      httpMethod: \"SELECT[GET,POST,DELETE,PUT]\",\n    },\n  },\n  LOOP: {\n    connectors: [\n      { id: `id`, name: \"start\", type: \"in\" },\n      { id: `id`, name: \"body\", type: \"out\" },\n    ],\n    nodeType: \"LOOP\",\n    data: {\n      iterations: 5,\n    },\n    dataType: {\n      iterations: \"NUMBER\",\n    },\n  },\n  LOGIC_OPERATOR: {\n    connectors: [\n      { id: `id`, name: \"a\", type: \"in\" },\n      { id: `id`, name: \"b\", type: \"in\" },\n      { id: `id`, name: \"bool\", type: \"out\" },\n    ],\n    nodeType: \"LOGIC_OPERATOR\",\n    data: {\n      operator: \"AND\",\n    },\n    dataType: {\n      operator: \"SELECT[AND,OR,XOR,NAND,NOR,XNOR,>,<,==,!=,>=,<=]\",\n    },\n  },\n  INTERVAL: {\n    connectors: [{ id: `id`, name: \"exec\", type: \"out\" }],\n    nodeType: \"INTERVAL\",\n    data: {\n      interval: 10,\n      unit: \"seconds\",\n    },\n    dataType: {\n      interval: \"NUMBER\",\n      unit: \"SELECT[milliseconds,seconds,minutes]\",\n    },\n  },\n  MQTT_PUBLISH: {\n    connectors: [{ id: `id`, name: \"payload\", type: \"in\" }],\n    nodeType: \"MQTT_PUBLISH\",\n    data: {\n      topic: \"default/topic\",\n      qos: 1,\n      retain: false,\n    },\n    dataType: {\n      topic: \"STRING\",\n      qos: \"SELECT[0,1,2]\",\n      retain: \"BOOLEAN\",\n    },\n  },\n  MQTT_SUBSCRIBE: {\n    connectors: [{ id: `id`, name: \"payload\", type: \"out\" }],\n    nodeType: \"MQTT_SUBSCRIBE\",\n    data: {\n      topic: \"default/topic\",\n    },\n    dataType: {\n      topic: \"STRING\",\n    },\n  },\n  DASHBOARD_EVENT_LISTENER: {\n    connectors: [{ id: `id`, name: \"payload\", type: \"out\" }],\n    nodeType: \"DASHBOARD_EVENT_LISTENER\",\n    data: {\n      listenerId: \"my-listener\",\n    },\n    dataType: {\n      listenerId: \"STRING\",\n    },\n  },\n  TYPE_CONVERTER: {\n    connectors: [\n      { id: `id`, name: \"in\", type: \"in\" },\n      { id: `id`, name: \"out\", type: \"out\" },\n    ],\n    nodeType: \"TYPE_CONVERTER\",\n    data: {\n      targetType: \"string\",\n    },\n    dataType: {\n      targetType: \"SELECT[string,number,boolean]\",\n    },\n  },\n  RTP_STREAM_IN: {\n    connectors: [\n      { id: `id`, name: \"payload\", type: \"out\" },\n      { id: `id`, name: \"raw_packet\", type: \"out\" },\n    ],\n    nodeType: \"RTP_STREAM_IN\",\n    data: {\n      topic: \"default/audio\",\n    },\n    dataType: {\n      topic: \"STRING\",\n    },\n  },\n  DECODE_OPUS: {\n    connectors: [\n      { id: `id`, name: \"payload\", type: \"in\" },\n      { id: `id`, name: \"info\", type: \"out\" },\n    ],\n    nodeType: \"DECODE_OPUS\",\n    data: undefined,\n    dataType: undefined,\n  },\n  BRANCH: {\n    connectors: [\n      { id: `id`, name: \"data\", type: \"in\" },\n      { id: `id`, name: \"condition\", type: \"in\" },\n      { id: `id`, name: \"true_output\", type: \"out\" },\n      { id: `id`, name: \"false_output\", type: \"out\" },\n    ],\n    nodeType: \"BRANCH\",\n    data: undefined,\n    dataType: undefined,\n  },\n  JSON_SELECTOR: {\n    connectors: [\n      { id: `id`, name: \"json\", type: \"in\" },\n      { id: `id`, name: \"value\", type: \"out\" },\n    ],\n    nodeType: \"JSON_SELECTOR\",\n    data: {\n      path: \"path.to.value\",\n    },\n    dataType: {\n      path: \"STRING\",\n    },\n  },\n  JSON_MODIFY: {\n    connectors: [\n      { id: `id`, name: \"json\", type: \"in\" },\n      { id: `id`, name: \"data\", type: \"in\" },\n      { id: `id`, name: \"value\", type: \"out\" },\n    ],\n    nodeType: \"JSON_MODIFY\",\n    data: {\n      path: \"path.to.value\",\n    },\n    dataType: {\n      path: \"STRING\",\n    },\n  },\n  DECODE_H264: {\n    connectors: [\n      { id: `id`, name: \"payload\", type: \"in\" },\n      { id: `id`, name: \"frame\", type: \"out\" },\n    ],\n    nodeType: \"DECODE_H264\",\n    data: undefined,\n    dataType: undefined,\n  },\n  YOLO_DETECT: {\n    connectors: [\n      { id: `id`, name: \"frame\", type: \"in\" },\n      { id: `id`, name: \"detections\", type: \"out\" },\n    ],\n    nodeType: \"YOLO_DETECT\",\n    data: {\n      model_path: \"assets/yolov8n.onnx\",\n      labels_path: \"assets/coco.names\",\n      confidence_threshold: 0.5,\n      nms_threshold: 0.4,\n      input_size: 640,\n    },\n    dataType: {\n      model_path: \"STRING\",\n      labels_path: \"STRING\",\n      confidence_threshold: \"NUMBER\",\n      nms_threshold: \"NUMBER\",\n      input_size: \"NUMBER\",\n    },\n  },\n  GST_DECODER: {\n    connectors: [{ id: \"id\", name: \"frame\", type: \"out\" }],\n    nodeType: \"GST_DECODER\",\n    data: {\n      topic: \"go_video_stream_1\",\n    },\n    dataType: {\n      topic: \"STRING\",\n    },\n  },\n  WEBSOCKET_ON: {\n    connectors: [{ id: \"id\", name: \"payload\", type: \"out\" }],\n    nodeType: \"WEBSOCKET_ON\",\n    data: {\n      url: \"ws://localhost:6174\",\n    },\n    dataType: {\n      url: \"STRING\",\n    },\n  },\n  WEBSOCKET_SEND: {\n    connectors: [{ id: \"id\", name: \"payload\", type: \"in\" }],\n    nodeType: \"WEBSOCKET_SEND\",\n    data: {\n      url: \"ws://localhost:6174\",\n    },\n    dataType: {\n      url: \"STRING\",\n    },\n  },\n  EXT_ROS2_WEBSOCKET_ON: {\n    connectors: [{ id: \"id\", name: \"payload\", type: \"out\" }],\n    nodeType: \"WEBSOCKET_ON\",\n    data: {\n      url: \"{:ros2_websocket_url}\",\n    },\n    dataType: {\n      url: \"FIXED_STRING\",\n    },\n  },\n  EXT_ROS2_WEBSOCKET_SEND: {\n    connectors: [{ id: \"id\", name: \"payload\", type: \"in\" }],\n    nodeType: \"WEBSOCKET_SEND\",\n    data: {\n      url: \"{:ros2_websocket_url}\",\n    },\n    dataType: {\n      url: \"FIXED_STRING\",\n    },\n  },\n} as const;\n"
  },
  {
    "path": "apps/client/src/features/flow/flowTypes.ts",
    "content": "import { DEFINITION_NODE } from \"./flowNode\";\n\nexport type Connector = {\n  id: string;\n  name: string;\n  type: \"in\" | \"out\" | \"execution\";\n};\n\nexport type NodeRenderer = (\n  g: d3.Selection<SVGGElement, Node, null, undefined>,\n  d: Node,\n) => void;\n\nexport type NodeTypes = keyof typeof DEFINITION_NODE;\n\nexport type NumberNodeType = {\n  number: number;\n};\n\nexport type TextNodeType = {\n  string: string;\n};\n\nexport type AddNodeType = {\n  a: number;\n  b: number;\n};\n\nexport type SetVariableNodeType = {\n  variable: string | number | boolean;\n  variableType?: \"string\" | \"number\" | \"boolean\";\n};\n\nexport type ConditionNodeType = {\n  operator: \"GreaterThan\" | \"LessThan\" | \"EqualTo\";\n  operand: number;\n};\n\nexport type CalculationNodeType = {\n  operatorCalc: \"+\" | \"-\" | \"/\" | \"*\" | \"%\";\n};\n\nexport type LogicOpetatorNodeType = {\n  operator:\n    | \"AND\"\n    | \"OR\"\n    | \"XOR\"\n    | \"NAND\"\n    | \"XNOR\"\n    | \"NOR\"\n    | \"<\"\n    | \">\"\n    | \"=\"\n    | \"!=\"\n    | \">=\"\n    | \"<=\";\n};\n\nexport type HTTPRequestNodeType = {\n  url: string;\n  httpMethod: \"POST\" | \"GET\" | \"DELETE\" | \"PUT\";\n};\n\nexport type LoopNodeType = {\n  iterations: number;\n};\n\nexport type IntervalNodeType = {\n  interval: number;\n  unit: \"milliseconds\" | \"seconds\" | \"minutes\";\n};\n\nexport type MqttPublishNodeType = {\n  topic: string;\n  qos: 0 | 1 | 2;\n  retain: boolean;\n};\n\nexport type MqttSubscribeNodeType = {\n  topic: string;\n};\n\nexport type TypeConverterNodeType = {\n  targetType: \"string\" | \"number\" | \"boolean\";\n};\n\nexport type RtpStreamInNodeType = {\n  topic: string;\n};\n\nexport type JsonSelectorNodeType = {\n  path: string;\n};\n\nexport type GstDecoderNodeType = {\n  topic: string;\n};\n\nexport type YoloDetectNodeType = {\n  model_path: string;\n  labels_path: string;\n  confidence_threshold: number;\n  nms_threshold: number;\n  input_size: number;\n};\n\nexport type WebSocketOnNodeType = {\n  url: string;\n};\n\nexport type WebSocketSendNodeType = {\n  url: string;\n};\n\ntype Generalize<T> = {\n  -readonly [K in keyof T]: T[K] extends string\n    ? string\n    : T[K] extends number\n    ? number\n    : T[K] extends boolean\n    ? boolean\n    : T[K];\n};\n\ntype AllSpecificDataNodeTypes = {\n  [K in keyof typeof DEFINITION_NODE]: (typeof DEFINITION_NODE)[K][\"data\"];\n};\ntype SpecificDataNodeType =\n  AllSpecificDataNodeTypes[keyof AllSpecificDataNodeTypes];\n\nexport type DataNodeType = Generalize<Exclude<SpecificDataNodeType, undefined>>;\n\nexport type DataNodeTypeType = Record<string, string>;\n\nexport type Node = {\n  id: string;\n  title: string;\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n  connectors: Connector[];\n  nodeType?: string;\n  data?: DataNodeType;\n  dataType?: DataNodeTypeType;\n};\n\nexport type Edge = {\n  id: string;\n  source: string;\n  target: string;\n};\n\nexport interface GraphProps {\n  nodes: Node[];\n  edges: Edge[];\n  width?: string | number;\n  height?: string | number;\n  gridSize?: number;\n  gridColor?: string;\n  edgeColor?: string;\n  edgeWidth?: number;\n  onNodesChange?: (nodes: Node[]) => void;\n  onEdgesChange?: (edges: Edge[]) => void;\n}\n"
  },
  {
    "path": "apps/client/src/features/flow/flowUtils.ts",
    "content": "import { CustomNode, CustomNodeDynamicData } from \"@/entities/custom-nodes/types\";\nimport { DEFINITION_NODE } from \"./flowNode\";\nimport { NodeTypes, Node, DataNodeTypeType } from \"./flowTypes\";\n\nexport function getDefalutValue(type: NodeTypes, id: string) {\n  const value = JSON.parse(JSON.stringify(DEFINITION_NODE[type]));\n\n  for (let index = 0; index < value.connectors.length; index++) {\n    value.connectors[\n      index\n    ].id = `${id}-${value.connectors[index].type}${index}`;\n  }\n\n  return value;\n}\n\nexport function getDefalutNode(\n  type: NodeTypes,\n  id: string,\n  x: number = 100,\n  y: number = 100,\n): Node {\n  const defaultValue = getDefalutValue(type, id);\n\n  return {\n    id: id,\n    title: id,\n    x: x,\n    y: y,\n    width: 120,\n    height: 50,\n    ...defaultValue,\n  };\n}\n\nexport function getCustomValue(\n  customNodes: CustomNode[],\n  type: string,\n  id: string,\n) {\n  const value = customNodes.filter((item) => {\n    return item.node_type == type;\n  });\n\n  if (value) {\n    if (value.length <= 0) {\n      return null;\n    }\n\n    let valueData = value[0].data;\n    if (typeof valueData === \"string\") {\n      try {\n        valueData = JSON.parse(valueData);\n      } catch {\n        return null;\n      }\n    }\n\n    if (!valueData) {\n      return null;\n    }\n\n    // 중첩 구조 처리: {\"data\":{\"connectors\":[...],...},...} → 내부 data 사용\n    const nested = valueData.data as Record<string, unknown> | undefined;\n    if (!valueData.connectors && nested && nested.connectors) {\n      valueData = nested as CustomNodeDynamicData;\n    }\n\n    valueData.nodeType = value[0].node_type;\n\n    if (!valueData.connectors) {\n      valueData.connectors = [];\n    }\n\n    const connectors = valueData.connectors as Array<Record<string, unknown>>;\n    for (let index = 0; index < connectors.length; index++) {\n      connectors[index].id = `${id}-${connectors[index].type}${index}`;\n    }\n\n    return valueData;\n  }\n\n  return null;\n}\n\nexport function getCustomNode(\n  customNodes: CustomNode[],\n  type: NodeTypes,\n  id: string,\n  x: number = 100,\n  y: number = 100,\n) {\n  const defaultValue = getCustomValue(customNodes, type, id);\n\n  if (!defaultValue) {\n    return null;\n  }\n\n  return {\n    id: id,\n    title: id,\n    x: x,\n    y: y,\n    width: 120,\n    height: 50,\n    ...defaultValue,\n  };\n}\n\nexport function getNodeValue(\n  nodes: DataNodeTypeType,\n  type: NodeTypes,\n  id: string,\n) {\n  const value = JSON.parse(JSON.stringify(nodes[type]));\n\n  for (let index = 0; index < value.connectors.length; index++) {\n    value.connectors[\n      index\n    ].id = `${id}-${value.connectors[index].type}${index}`;\n  }\n\n  return value;\n}\n\nexport function getNode(\n  nodes: DataNodeTypeType,\n  type: NodeTypes,\n  id: string,\n  x: number = 100,\n  y: number = 100,\n): Node {\n  const defaultValue = getNodeValue(nodes, type, id);\n\n  return {\n    id: id,\n    title: id,\n    x: x,\n    y: y,\n    width: 120,\n    height: 50,\n    ...defaultValue,\n  };\n}\n"
  },
  {
    "path": "apps/client/src/features/flow/nodes/ButtonNode.tsx",
    "content": "import { Node } from \"../flowTypes\";\n\nexport function renderButtonNode(\n  g: d3.Selection<SVGGElement, Node, null, undefined>,\n  d: Node,\n  onOpen: () => void,\n) {\n  const w = d.width;\n  const h = d.height;\n  const group = g\n    .append(\"g\")\n    .attr(\"class\", \"node-content\")\n    .style(\"cursor\", \"pointer\");\n  group\n    .append(\"rect\")\n    .attr(\"x\", w / 2 - 40)\n    .attr(\"y\", h / 2 - 10)\n    .attr(\"width\", 80)\n    .attr(\"height\", 20)\n    .attr(\"rx\", 4)\n    .attr(\"fill\", \"#2a2c36\");\n  group\n    .append(\"text\")\n    .attr(\"x\", w / 2)\n    .attr(\"y\", h / 2 + 1)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"dominant-baseline\", \"middle\")\n    .attr(\"font-size\", 8)\n    .attr(\"fill\", \"#fff\")\n    .text(\"Change Attr\");\n  group.on(\"click\", (e) => {\n    e.stopPropagation();\n    onOpen();\n  });\n}\n"
  },
  {
    "path": "apps/client/src/features/flow/nodes/CalcNode.tsx",
    "content": "import { CalculationNodeType, Node } from \"../flowTypes\";\n\nexport function renderCalcNode(\n  g: d3.Selection<SVGGElement, Node, null, undefined>,\n  d: Node,\n  onOpen: () => void,\n) {\n  const w = d.width;\n  const h = d.height;\n  const group = g\n    .append(\"g\")\n    .attr(\"class\", \"node-content\")\n    .style(\"cursor\", \"pointer\");\n  group\n    .append(\"rect\")\n    .attr(\"x\", w / 2 - 40)\n    .attr(\"y\", h / 2 - 10)\n    .attr(\"width\", 80)\n    .attr(\"height\", 20)\n    .attr(\"rx\", 4)\n    .attr(\"fill\", \"#2a2c36\");\n  group\n    .append(\"text\")\n    .attr(\"x\", w / 2)\n    .attr(\"y\", h / 2 + 1)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"dominant-baseline\", \"middle\")\n    .attr(\"font-size\", 8)\n    .attr(\"fill\", \"#fff\")\n    .text(`${(d.data as CalculationNodeType)?.operatorCalc ?? \"\"}`);\n  group.on(\"click\", (e) => {\n    e.stopPropagation();\n    onOpen();\n  });\n}\n"
  },
  {
    "path": "apps/client/src/features/flow/nodes/HttpNode.tsx",
    "content": "import { HTTPRequestNodeType, Node } from \"../flowTypes\";\n\nexport function renderHttpNode(\n  g: d3.Selection<SVGGElement, Node, null, undefined>,\n  d: Node,\n  onOpen: () => void,\n) {\n  const w = d.width;\n  const h = d.height;\n  const group = g\n    .append(\"g\")\n    .attr(\"class\", \"node-content\")\n    .style(\"cursor\", \"pointer\");\n  group\n    .append(\"rect\")\n    .attr(\"x\", w / 2 - 40)\n    .attr(\"y\", h / 2 - 10)\n    .attr(\"width\", 80)\n    .attr(\"height\", 20)\n    .attr(\"rx\", 4)\n    .attr(\"fill\", \"#2a2c36\");\n  group\n    .append(\"text\")\n    .attr(\"x\", w / 2)\n    .attr(\"y\", h / 2 + 1)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"dominant-baseline\", \"middle\")\n    .attr(\"font-size\", 8)\n    .attr(\"fill\", \"#fff\")\n    .text(`${(d.data as HTTPRequestNodeType)?.httpMethod ?? \"\"}`);\n  group.on(\"click\", (e) => {\n    e.stopPropagation();\n    onOpen();\n  });\n}\n"
  },
  {
    "path": "apps/client/src/features/flow/nodes/IntervalNode.tsx",
    "content": "import { IntervalNodeType, Node } from \"../flowTypes\";\n\nexport function renderIntervalNode(\n  g: d3.Selection<SVGGElement, Node, null, undefined>,\n  d: Node,\n  onOpen: () => void,\n) {\n  const w = d.width;\n  const h = d.height;\n  const group = g\n    .append(\"g\")\n    .attr(\"class\", \"node-content\")\n    .style(\"cursor\", \"pointer\");\n  group\n    .append(\"rect\")\n    .attr(\"x\", w / 2 - 40)\n    .attr(\"y\", h / 2 - 10)\n    .attr(\"width\", 80)\n    .attr(\"height\", 20)\n    .attr(\"rx\", 4)\n    .attr(\"fill\", \"#2a2c36\");\n  group\n    .append(\"text\")\n    .attr(\"x\", w / 2)\n    .attr(\"y\", h / 2 + 1)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"dominant-baseline\", \"middle\")\n    .attr(\"font-size\", 8)\n    .attr(\"fill\", \"#fff\")\n    .text(`Interval ${(d.data as IntervalNodeType)?.interval ?? \"\"}`);\n  group.on(\"click\", (e) => {\n    e.stopPropagation();\n    onOpen();\n  });\n}\n"
  },
  {
    "path": "apps/client/src/features/flow/nodes/LogicNode.tsx",
    "content": "import { LogicOpetatorNodeType, Node } from \"../flowTypes\";\n\nexport function renderLogicNode(\n  g: d3.Selection<SVGGElement, Node, null, undefined>,\n  d: Node,\n  onOpen: () => void,\n) {\n  const w = d.width;\n  const h = d.height;\n  const group = g\n    .append(\"g\")\n    .attr(\"class\", \"node-content\")\n    .style(\"cursor\", \"pointer\");\n  group\n    .append(\"rect\")\n    .attr(\"x\", w / 2 - 40)\n    .attr(\"y\", h / 2 - 10)\n    .attr(\"width\", 80)\n    .attr(\"height\", 20)\n    .attr(\"rx\", 4)\n    .attr(\"fill\", \"#2a2c36\");\n  group\n    .append(\"text\")\n    .attr(\"x\", w / 2)\n    .attr(\"y\", h / 2 + 1)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"dominant-baseline\", \"middle\")\n    .attr(\"font-size\", 8)\n    .attr(\"fill\", \"#fff\")\n    .text(`${(d.data as LogicOpetatorNodeType)?.operator ?? \"\"}`);\n  group.on(\"click\", (e) => {\n    e.stopPropagation();\n    onOpen();\n  });\n}\n"
  },
  {
    "path": "apps/client/src/features/flow/nodes/LoopNode.tsx",
    "content": "import { LoopNodeType, Node } from \"../flowTypes\";\n\nexport function renderLoopNode(\n  g: d3.Selection<SVGGElement, Node, null, undefined>,\n  d: Node,\n  onOpen: () => void,\n) {\n  const w = d.width;\n  const h = d.height;\n  const group = g\n    .append(\"g\")\n    .attr(\"class\", \"node-content\")\n    .style(\"cursor\", \"pointer\");\n  group\n    .append(\"rect\")\n    .attr(\"x\", w / 2 - 40)\n    .attr(\"y\", h / 2 - 10)\n    .attr(\"width\", 80)\n    .attr(\"height\", 20)\n    .attr(\"rx\", 4)\n    .attr(\"fill\", \"#2a2c36\");\n  group\n    .append(\"text\")\n    .attr(\"x\", w / 2)\n    .attr(\"y\", h / 2 + 1)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"dominant-baseline\", \"middle\")\n    .attr(\"font-size\", 8)\n    .attr(\"fill\", \"#fff\")\n    .text(`Edit Loop ${(d.data as LoopNodeType)?.iterations ?? \"\"}`);\n  group.on(\"click\", (e) => {\n    e.stopPropagation();\n    onOpen();\n  });\n}\n"
  },
  {
    "path": "apps/client/src/features/flow/nodes/MQTTNode.tsx",
    "content": "import { MqttPublishNodeType, Node } from \"../flowTypes\";\n\nfunction mqttLikeCenterLabel(d: Node): string {\n  if (d.nodeType === \"DASHBOARD_EVENT_LISTENER\") {\n    const id = (d.data as { listenerId?: string } | undefined)?.listenerId;\n    return id?.trim() ?? \"\";\n  }\n  return (d.data as MqttPublishNodeType)?.topic ?? \"\";\n}\n\nexport function renderMQTTNode(\n  g: d3.Selection<SVGGElement, Node, null, undefined>,\n  d: Node,\n  onOpen: () => void,\n) {\n  const w = d.width;\n  const h = d.height;\n  const group = g\n    .append(\"g\")\n    .attr(\"class\", \"node-content\")\n    .style(\"cursor\", \"pointer\");\n  group\n    .append(\"rect\")\n    .attr(\"x\", w / 2 - 40)\n    .attr(\"y\", h / 2 - 10)\n    .attr(\"width\", 80)\n    .attr(\"height\", 20)\n    .attr(\"rx\", 4)\n    .attr(\"fill\", \"#2a2c36\");\n  group\n    .append(\"text\")\n    .attr(\"x\", w / 2)\n    .attr(\"y\", h / 2 + 1)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"dominant-baseline\", \"middle\")\n    .attr(\"font-size\", 8)\n    .attr(\"fill\", \"#fff\")\n    .text(mqttLikeCenterLabel(d));\n  group.on(\"click\", (e) => {\n    e.stopPropagation();\n    onOpen();\n  });\n}\n"
  },
  {
    "path": "apps/client/src/features/flow/nodes/NumberNode.tsx",
    "content": "import { Node, NumberNodeType } from \"../flowTypes\";\n\nexport function renderNumberNode(\n  g: d3.Selection<SVGGElement, Node, null, undefined>,\n  d: Node,\n  onOpen: () => void,\n) {\n  const w = d.width;\n  const h = d.height;\n  const group = g\n    .append(\"g\")\n    .attr(\"class\", \"node-content\")\n    .style(\"cursor\", \"pointer\");\n  group\n    .append(\"rect\")\n    .attr(\"x\", w / 2 - 40)\n    .attr(\"y\", h / 2 - 10)\n    .attr(\"width\", 80)\n    .attr(\"height\", 20)\n    .attr(\"rx\", 4)\n    .attr(\"fill\", \"#2a2c36\");\n  group\n    .append(\"text\")\n    .attr(\"x\", w / 2)\n    .attr(\"y\", h / 2 + 1)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"dominant-baseline\", \"middle\")\n    .attr(\"font-size\", 8)\n    .attr(\"fill\", \"#fff\")\n    .text(`Edit Number ${(d.data as unknown as NumberNodeType)?.number ?? 0}`);\n  group.on(\"click\", (e) => {\n    e.stopPropagation();\n    onOpen();\n  });\n}\n"
  },
  {
    "path": "apps/client/src/features/flow/nodes/ProcessingNode.tsx",
    "content": "import { Node } from \"../flowTypes\";\n\nexport function renderProcessingNode(\n  g: d3.Selection<SVGGElement, Node, null, undefined>,\n  d: Node,\n) {\n  const w = d.width;\n  const h = d.height;\n  g.append(\"text\")\n    .attr(\"class\", \"node-content\")\n    .attr(\"x\", w / 2)\n    .attr(\"y\", h / 2 + 4)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"dominant-baseline\", \"middle\")\n    .attr(\"font-size\", 8)\n    .attr(\"fill\", \"#fff\")\n    .text(d.nodeType || \"PROCESSING\");\n}\n"
  },
  {
    "path": "apps/client/src/features/flow/nodes/TitleNode.tsx",
    "content": "import { Node } from \"../flowTypes\";\n\nexport function renderTitleNode(\n  g: d3.Selection<SVGGElement, Node, null, undefined>,\n  d: Node,\n) {\n  const w = d.width;\n  const h = d.height;\n  g.append(\"text\")\n    .attr(\"class\", \"node-content\")\n    .attr(\"x\", w / 2)\n    .attr(\"y\", h / 2 + 4)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"dominant-baseline\", \"middle\")\n    .attr(\"font-size\", 8)\n    .attr(\"fill\", \"#fff\")\n    .text(d.title);\n}\n"
  },
  {
    "path": "apps/client/src/features/flow/nodes/VarNode.tsx",
    "content": "import { Node } from \"../flowTypes\";\n\nexport function renderVarNode(\n  g: d3.Selection<SVGGElement, Node, null, undefined>,\n  d: Node,\n  onOpen: () => void,\n) {\n  const w = d.width;\n  const h = d.height;\n  const group = g\n    .append(\"g\")\n    .attr(\"class\", \"node-content\")\n    .style(\"cursor\", \"pointer\");\n  group\n    .append(\"rect\")\n    .attr(\"x\", w / 2 - 40)\n    .attr(\"y\", h / 2 - 10)\n    .attr(\"width\", 80)\n    .attr(\"height\", 20)\n    .attr(\"rx\", 4)\n    .attr(\"fill\", \"#2a2c36\");\n  group\n    .append(\"text\")\n    .attr(\"x\", w / 2)\n    .attr(\"y\", h / 2 + 1)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"dominant-baseline\", \"middle\")\n    .attr(\"font-size\", 8)\n    .attr(\"fill\", \"#fff\")\n    .text(`Edit Variable`);\n  group.on(\"click\", (e) => {\n    e.stopPropagation();\n    onOpen();\n  });\n}\n"
  },
  {
    "path": "apps/client/src/features/flow-log/FlowLog.tsx",
    "content": "import { useEffect, useState, useRef, useCallback } from \"react\";\nimport { X, ChevronDown, ChevronUp, Terminal } from \"lucide-react\";\nimport { useWebSocketMessage } from \"../ws/WebSocketProvider\";\nimport { WebSocketMessage } from \"../ws/ws\";\n\nexport function FlowLog() {\n  const [logMessages, setLogMessages] = useState<string[]>([]);\n  const [isPanelOpen, setIsPanelOpen] = useState(false);\n  const [isCollapsed, setIsCollapsed] = useState(false);\n  const logContainerRef = useRef<HTMLDivElement>(null);\n\n  const handleMessage = useCallback((msg: WebSocketMessage) => {\n    setIsPanelOpen(true);\n    setIsCollapsed(false);\n    try {\n      if (msg.type === \"log_message\") {\n        const timestamp = new Date().toLocaleTimeString(\"en-US\", {\n          hour12: false,\n        });\n        setLogMessages((prev) => [\n          ...prev,\n          `[${timestamp}] ${JSON.stringify(msg.payload)}`,\n        ]);\n      }\n    } catch (err) {\n      console.error(\"Error handling signaling message:\", err);\n    }\n  }, []);\n\n  useWebSocketMessage(handleMessage);\n\n  useEffect(() => {\n    if (logContainerRef.current) {\n      logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;\n    }\n  }, [logMessages]);\n\n  if (!isPanelOpen) {\n    return null;\n  }\n\n  return (\n    <div\n      className={`absolute bottom-0 left-0 right-0  bg-neutral-900 text-white border-t-2 border-neutral-700 shadow-lg transition-all duration-300 ease-in-out z-10 ${\n        isCollapsed ? \"h-12\" : \"h-64\"\n      }`}\n    >\n      <div\n        className='flex items-center justify-between p-3 bg-neutral-800 cursor-pointer'\n        onClick={() => setIsCollapsed(!isCollapsed)}\n      >\n        <div className='flex items-center gap-2'>\n          <Terminal size={16} className='text-gray-400' />\n          <h3 className='font-semibold text-sm tracking-wider select-none'>\n            LOGS\n          </h3>\n        </div>\n        <div className='flex items-center gap-2'>\n          <button\n            onClick={(e) => {\n              e.stopPropagation();\n              setIsCollapsed(!isCollapsed);\n            }}\n            className='p-1 rounded hover:bg-gray-700'\n            aria-label={isCollapsed ? \"Expand logs\" : \"Collapse logs\"}\n          >\n            {isCollapsed ? <ChevronUp size={16} /> : <ChevronDown size={16} />}\n          </button>\n          <button\n            onClick={(e) => {\n              e.stopPropagation();\n              setIsPanelOpen(false);\n              setLogMessages([]);\n            }}\n            className='p-1 rounded hover:bg-gray-700'\n            aria-label='Close logs'\n          >\n            <X size={16} />\n          </button>\n        </div>\n      </div>\n      {!isCollapsed && (\n        <div\n          ref={logContainerRef}\n          className='h-[calc(100%-3rem)] overflow-y-auto p-4'\n        >\n          <pre className='text-sm font-mono whitespace-pre-wrap break-words'>\n            {logMessages.map((message, index) => (\n              <div key={index} className='text-gray-300'>\n                {message}\n              </div>\n            ))}\n          </pre>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/footer/index.tsx",
    "content": "import { useState, useEffect, useCallback } from \"react\";\nimport { useWebSocket, useWebSocketMessage } from \"../ws/WebSocketProvider\";\nimport { WebSocketMessage } from \"../ws/ws\";\n\nexport function Footer() {\n  const { wsManager } = useWebSocket();\n  const [latency, setLatency] = useState(0);\n\n  useEffect(() => {\n    if (!wsManager) return;\n\n    const intervalId = setInterval(() => {\n      wsManager.send({\n        type: \"ping\",\n        payload: { timestamp: Date.now() },\n      });\n    }, 10200);\n\n    return () => {\n      console.log(\"CLEARING INTERVAL\");\n      clearInterval(intervalId);\n    };\n  }, [wsManager]);\n\n  const handleMessage = useCallback((msg: WebSocketMessage) => {\n    try {\n      if (msg.type === \"pong\") {\n        const latency =\n          Date.now() - (msg.payload as { timestamp: number }).timestamp;\n        setLatency(latency);\n      }\n    } catch (err) {\n      console.error(\"Error handling signaling message:\", err);\n    }\n  }, []);\n\n  useWebSocketMessage(handleMessage);\n\n  return (\n    <div className='flex h-5 w-full bg-background z-[999] fixed border-t bottom-0 px-2 gap-2'>\n      <b className='text-xs text-neutral-600 font-medium'>Vessel 0.1</b>\n      <b className='text-xs text-neutral-600 font-medium'>{latency}ms</b>\n      <b className='text-xs text-neutral-600 font-medium'>\n        <TokenExpiration />\n      </b>\n      <b className='text-xs font-medium'>\n        <WebSocketStatusIndicator />\n      </b>\n    </div>\n  );\n}\n\nimport { WebSocketStatusIndicator } from \"../ws/IsConnected\";\nimport { parseJwt } from \"@/lib/jwt\";\nimport { isDemoMode } from \"@/shared/demo\";\nimport { storage } from \"@/lib/storage\";\n\nconst TokenExpiration: React.FC = () => {\n  const [timeLeft, setTimeLeft] = useState<string>(\"\");\n\n  useEffect(() => {\n    const calculateTimeLeft = () => {\n      const token = storage.getToken();\n\n      if (!token || isDemoMode) {\n        setTimeLeft(\"\");\n        return;\n      }\n\n      const decodedToken = parseJwt(token);\n\n      if (!decodedToken || typeof decodedToken.exp !== \"number\") {\n        setTimeLeft(\"\");\n        return;\n      }\n\n      const expirationTime = decodedToken.exp * 1000;\n      const currentTime = new Date().getTime();\n      const remainingTime = expirationTime - currentTime;\n\n      if (remainingTime <= 0) {\n        setTimeLeft(\"\");\n        return;\n      }\n\n      const hours = Math.floor((remainingTime / (1000 * 60 * 60)) % 24);\n      const minutes = Math.floor((remainingTime / (1000 * 60)) % 60);\n      const seconds = Math.floor((remainingTime / 1000) % 60);\n\n      setTimeLeft(`${hours}h ${minutes}m ${seconds}s left`);\n    };\n\n    calculateTimeLeft();\n    const intervalId = setInterval(calculateTimeLeft, 1000);\n\n    return () => clearInterval(intervalId);\n  }, []);\n\n  return <div>{timeLeft}</div>;\n};\n"
  },
  {
    "path": "apps/client/src/features/gps/parseGps.ts",
    "content": "export const parseGpsState = (\n  state: string | null | undefined,\n): [number, number] | null => {\n  if (!state) return null;\n\n  const latMatch = state.match(/lat=([-\\d.]+)/);\n  const lngMatch = state.match(/lng=([-\\d.]+)/);\n\n  if (latMatch && lngMatch && latMatch[1] && lngMatch[1]) {\n    const lat = parseFloat(latMatch[1]);\n    const lng = parseFloat(lngMatch[1]);\n    if (!isNaN(lat) && !isNaN(lng)) {\n      return [lat, lng];\n    }\n  }\n  return null;\n};\n"
  },
  {
    "path": "apps/client/src/features/ha/HaEntitiesTable.tsx",
    "content": "import {\n  Card,\n  CardContent,\n  CardHeader,\n  CardTitle,\n  CardDescription,\n} from \"@/components/ui/card\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { HaState } from \"@/entities/ha/types\";\n\ninterface HaEntitiesTableProps {\n  states: HaState[];\n}\n\nexport const HaEntitiesTable: React.FC<HaEntitiesTableProps> = ({ states }) => {\n  return (\n    <Card>\n      <CardHeader>\n        <CardTitle>All Entities</CardTitle>\n        <CardDescription>\n          Live states of all entities from your Home Assistant instance.\n        </CardDescription>\n      </CardHeader>\n      <CardContent>\n        <Table>\n          <TableHeader>\n            <TableRow>\n              <TableHead>Entity ID</TableHead>\n              <TableHead>State</TableHead>\n              <TableHead>Attributes</TableHead>\n              <TableHead>Last Updated</TableHead>\n            </TableRow>\n          </TableHeader>\n          <TableBody>\n            {states.map((entity) => (\n              <TableRow key={entity.entity_id}>\n                <TableCell className='font-medium'>\n                  {entity.entity_id}\n                </TableCell>\n                <TableCell>\n                  <Badge\n                    variant={\n                      entity.state === \"on\" || entity.state === \"home\"\n                        ? \"default\"\n                        : \"secondary\"\n                    }\n                  >\n                    {entity.state}\n                  </Badge>\n                </TableCell>\n                <TableCell>\n                  <pre className='text-xs bg-muted p-2 rounded-md overflow-x-auto'>\n                    <code>{JSON.stringify(entity.attributes, null, 2)}</code>\n                  </pre>\n                </TableCell>\n                <TableCell>\n                  {new Date(entity.last_updated).toLocaleString()}\n                </TableCell>\n              </TableRow>\n            ))}\n          </TableBody>\n        </Table>\n      </CardContent>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "apps/client/src/features/ha/HaStatBlock.tsx",
    "content": "import { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { HaState } from \"@/entities/ha/types\";\nimport { Lightbulb, Power, Router, Thermometer } from \"lucide-react\";\nimport React, { useMemo } from \"react\";\n\ninterface HaStatBlockProps {\n  states: HaState[];\n}\n\nconst StatCard = ({\n  title,\n  value,\n  icon: Icon,\n}: {\n  title: string;\n  value: string | number;\n  icon: React.ElementType;\n}) => (\n  <Card>\n    <CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>\n      <CardTitle className='text-sm font-medium'>{title}</CardTitle>\n      <Icon className='h-4 w-4 text-muted-foreground' />\n    </CardHeader>\n    <CardContent>\n      <div className='text-2xl font-bold'>{value}</div>\n    </CardContent>\n  </Card>\n);\n\nexport const HaStatBlock: React.FC<HaStatBlockProps> = ({ states }) => {\n  const stats = useMemo(() => {\n    const totalEntities = states.length;\n    const lightsOn = states.filter(\n      (s) => s.entity_id.startsWith(\"light.\") && s.state === \"on\",\n    ).length;\n    const switchesOn = states.filter(\n      (s) => s.entity_id.startsWith(\"switch.\") && s.state === \"on\",\n    ).length;\n    const availableSensors = states.filter(\n      (s) => s.entity_id.startsWith(\"sensor.\") && s.state !== \"unavailable\",\n    ).length;\n\n    return { totalEntities, lightsOn, switchesOn, availableSensors };\n  }, [states]);\n\n  return (\n    <div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4'>\n      <StatCard\n        title='Total Entities'\n        value={stats.totalEntities}\n        icon={Router}\n      />\n      <StatCard title='Lights On' value={stats.lightsOn} icon={Lightbulb} />\n      <StatCard title='Switches On' value={stats.switchesOn} icon={Power} />\n      <StatCard\n        title='Available Sensors'\n        value={stats.availableSensors}\n        icon={Thermometer}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/client/src/features/ha/index.tsx",
    "content": "import React, { useEffect } from \"react\";\nimport { useHaStore } from \"@/entities/ha/store\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport { Loader2, AlertTriangle } from \"lucide-react\";\nimport { HaStatBlock } from \"./HaStatBlock\";\nimport { HaEntitiesTable } from \"./HaEntitiesTable\";\n\nexport const HaDashboard = () => {\n  const { states, status, error, fetchStates } = useHaStore();\n\n  useEffect(() => {\n    if (status === \"idle\") {\n      fetchStates();\n    }\n  }, [status, fetchStates]);\n\n  if (status === \"loading\" || status === \"idle\") {\n    return (\n      <div className='flex items-center justify-center py-20'>\n        <Loader2 className='h-10 w-10 animate-spin text-muted-foreground' />\n      </div>\n    );\n  }\n\n  if (status === \"failed\") {\n    return (\n      <div className='flex flex-1 flex-col gap-4 w-full'>\n        <Alert variant='destructive' className='m-4 w-auto'>\n          <AlertTriangle className='h-4 w-4' />\n          <AlertTitle>Error</AlertTitle>\n          <AlertDescription>\n            {error || \"An unknown error occurred.\"}\n          </AlertDescription>\n        </Alert>\n      </div>\n    );\n  }\n\n  return (\n    <div className='flex flex-1 flex-col gap-4'>\n      <div className='py-4 md:py-6'>\n        <HaStatBlock states={states} />\n      </div>\n      <div className='py-6'>\n        <HaEntitiesTable states={states} />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/client/src/features/integration/HA.tsx",
    "content": "import React from \"react\";\n\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\n\nimport { StepComponentProps } from \"./types\";\n\nexport const HA_Step1_URL: React.FC<StepComponentProps> = ({\n  value,\n  onChange,\n}) => (\n  <div className='grid gap-4 py-4'>\n    <div className='grid grid-cols-4 items-center gap-4'>\n      <Label htmlFor='ha-url' className='text-right'>\n        HA URL\n      </Label>\n      <Input\n        id='ha-url'\n        placeholder='http://homeassistant.local:8123'\n        className='col-span-3'\n        value={value}\n        onChange={onChange}\n      />\n    </div>\n    <p className='text-sm text-muted-foreground px-4 text-center'>\n      Enter the network address of your Home Assistant instance.\n    </p>\n  </div>\n);\n\nexport const HA_Step2_Token: React.FC<StepComponentProps> = ({\n  value,\n  onChange,\n}) => (\n  <div className='grid gap-4 py-4'>\n    <div className='grid grid-cols-4 items-center gap-4'>\n      <Label htmlFor='ha-token' className='text-right'>\n        Access Token\n      </Label>\n      <Input\n        id='ha-token'\n        type='password'\n        placeholder='Paste your Long-Lived Access Token'\n        className='col-span-3'\n        value={value}\n        onChange={onChange}\n      />\n    </div>\n    <p className='text-sm text-muted-foreground px-4 text-center'>\n      Create and copy a Long-Lived Access Token from your HA profile page.\n    </p>\n  </div>\n);\n"
  },
  {
    "path": "apps/client/src/features/integration/Integration.tsx",
    "content": "import React, { useState, useEffect, useMemo } from \"react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogFooter,\n  DialogDescription,\n} from \"@/components/ui/dialog\";\nimport { Home, Bot, Radio, ArrowRight, CheckCircle, Loader2 } from \"lucide-react\";\nimport { useIntegrationStore } from \"@/entities/integrations/store\";\nimport {\n  StepComponentProps,\n  FinalStepProps,\n  IntegrationId,\n  IntegrationWizardModalProps,\n} from \"./types\";\nimport { HA_Step1_URL, HA_Step2_Token } from \"./HA\";\nimport { ROS2_Step1_Bridge, ROS2_Step2_Address } from \"./ROS\";\nimport { SDR_Step1_Info, SDR_Step2_Host, SDR_Step3_Port } from \"./SDR\";\nimport { useNavigate } from \"react-router\";\nimport { toast } from \"sonner\";\n\nconst FinalStep: React.FC<FinalStepProps> = ({ integrationName }) => (\n  <div className='py-8 text-center flex flex-col items-center justify-center gap-4'>\n    <CheckCircle className='h-16 w-16 text-green-500' />\n    <h3 className='text-2xl font-bold'>Connection Complete!</h3>\n    <p className='text-muted-foreground'>\n      Successfully configured the {integrationName} integration.\n    </p>\n  </div>\n);\n\nconst wizardConfig: {\n  [key in IntegrationId]: {\n    name: string;\n    registrationId: string;\n    steps: {\n      title: string;\n      configKey?: string;\n      component: React.FC<StepComponentProps>;\n    }[];\n    buildConfig: (formData: Record<string, string>) => Record<string, string>;\n  };\n} = {\n  \"home-assistant\": {\n    name: \"Home Assistant\",\n    registrationId: \"home_assistant\",\n    steps: [\n      {\n        title: \"Connect to Home Assistant\",\n        configKey: \"home_assistant_url\",\n        component: HA_Step1_URL,\n      },\n      {\n        title: \"Provide Access Token\",\n        configKey: \"home_assistant_token\",\n        component: HA_Step2_Token,\n      },\n    ],\n    buildConfig: (formData) => ({\n      url: formData[\"home_assistant_url\"] || \"\",\n      token: formData[\"home_assistant_token\"] || \"\",\n    }),\n  },\n  ros2: {\n    name: \"ROS2\",\n    registrationId: \"ros2\",\n    steps: [\n      {\n        title: \"Prerequisites\",\n        component: ROS2_Step1_Bridge,\n      },\n      {\n        title: \"Enter Bridge Address\",\n        configKey: \"ros2_websocket_url\",\n        component: ROS2_Step2_Address,\n      },\n    ],\n    buildConfig: (formData) => ({\n      websocket_url: formData[\"ros2_websocket_url\"] || \"\",\n    }),\n  },\n  sdr: {\n    name: \"RTL-SDR\",\n    registrationId: \"sdr\",\n    steps: [\n      {\n        title: \"RTL-SDR (rtl_tcp) Prerequisites\",\n        component: SDR_Step1_Info,\n      },\n      {\n        title: \"Enter Server Host\",\n        configKey: \"sdr_host\",\n        component: SDR_Step2_Host,\n      },\n      {\n        title: \"Enter Server Port\",\n        configKey: \"sdr_port\",\n        component: SDR_Step3_Port,\n      },\n    ],\n    buildConfig: (formData) => ({\n      host: formData[\"sdr_host\"] || \"\",\n      port: formData[\"sdr_port\"] || \"1234\",\n    }),\n  },\n};\n\nconst IntegrationWizardModal: React.FC<IntegrationWizardModalProps> = ({\n  integrationId,\n  onClose,\n  onComplete,\n}) => {\n  const [currentStep, setCurrentStep] = useState(0);\n  const [formData, setFormData] = useState<{ [key: string]: string }>({});\n  const [isSaving, setIsSaving] = useState(false);\n  const { registerIntegration } = useIntegrationStore();\n\n  const config = wizardConfig[integrationId];\n  const totalSteps = config.steps.length;\n  const isLastStep = currentStep === totalSteps;\n\n  const handleDataChange = (key: string | undefined, value: string) => {\n    if (key) {\n      setFormData((prev) => ({ ...prev, [key]: value }));\n    }\n  };\n\n  const isLastConfigStep = currentStep === totalSteps - 1;\n\n  const handleNext = async () => {\n    if (isLastConfigStep) {\n      // On the last configurable step, register the entire integration\n      setIsSaving(true);\n      try {\n        const integrationConfig = config.buildConfig(formData);\n        await registerIntegration(config.registrationId, integrationConfig);\n        setCurrentStep((prev) => prev + 1);\n      } catch (error) {\n        console.error(\"Failed to register integration:\", error);\n        toast(\"Failed to register integration.\");\n      } finally {\n        setIsSaving(false);\n      }\n    } else {\n      setCurrentStep((prev) => prev + 1);\n    }\n  };\n  const handleBack = () => setCurrentStep((prev) => Math.max(prev - 1, 0));\n\n  const stepInfo = !isLastStep ? config.steps[currentStep] : null;\n  const CurrentStepComponent = isLastStep ? (\n    <FinalStep integrationName={config.name} />\n  ) : (\n    stepInfo &&\n    React.createElement(stepInfo.component, {\n      value: formData[stepInfo.configKey!] || \"\",\n      onChange: (e: React.ChangeEvent<HTMLInputElement>) =>\n        handleDataChange(stepInfo.configKey, e.target.value),\n    })\n  );\n\n  const currentTitle = isLastStep\n    ? \"Setup Complete\"\n    : config.steps[currentStep].title;\n\n  return (\n    <Dialog open={true} onOpenChange={!isSaving ? onClose : undefined}>\n      <DialogContent className='sm:max-w-[425px]'>\n        <DialogHeader>\n          <DialogTitle>{currentTitle}</DialogTitle>\n          {!isLastStep && (\n            <DialogDescription>\n              Step {currentStep + 1} of {totalSteps}\n            </DialogDescription>\n          )}\n        </DialogHeader>\n\n        {CurrentStepComponent}\n\n        <DialogFooter>\n          {currentStep > 0 && !isLastStep && (\n            <Button variant='outline' onClick={handleBack} disabled={isSaving}>\n              Back\n            </Button>\n          )}\n          {isLastStep ? (\n            <Button onClick={onComplete} className='w-full'>\n              Finish\n            </Button>\n          ) : (\n            <Button onClick={handleNext} disabled={isSaving}>\n              {isSaving ? (\n                <Loader2 className='mr-2 h-4 w-4 animate-spin' />\n              ) : (\n                \"Next\"\n              )}\n              {!isSaving && <ArrowRight className='ml-2 h-4 w-4' />}\n            </Button>\n          )}\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n};\n\nconst initialIntegrations: {\n  id: IntegrationId;\n  name: string;\n  description: string;\n  icon: React.ReactNode;\n  status: \"Connected\" | \"Not Connected\";\n}[] = [\n  {\n    id: \"home-assistant\",\n    name: \"Home Assistant\",\n    description:\n      \"Connect and control your Home Assistant devices and entities directly.\",\n    icon: <Home className='h-8 w-8 text-cyan-500' />,\n    status: \"Not Connected\",\n  },\n  {\n    id: \"ros2\",\n    name: \"ROS2\",\n    description:\n      \"Integrate with your ROS2 ecosystem for advanced robotics control and monitoring.\",\n    icon: <Bot className='h-8 w-8 text-green-500' />,\n    status: \"Not Connected\",\n  },\n  {\n    id: \"sdr\",\n    name: \"RTL-SDR\",\n    description:\n      \"Connect to an RTL-SDR receiver via rtl_tcp for software-defined radio control and spectrum monitoring.\",\n    icon: <Radio className='h-8 w-8 text-purple-500' />,\n    status: \"Not Connected\",\n  },\n];\n\nexport function Intergration() {\n  const [selectedIntegration, setSelectedIntegration] =\n    useState<IntegrationId | null>(null);\n  const { isHaConnected, isRos2Connected, isSdrConnected, fetchStatus } =\n    useIntegrationStore();\n  const navigate = useNavigate();\n\n  useEffect(() => {\n    fetchStatus();\n  }, [fetchStatus]);\n\n  const integrations = useMemo(() => {\n    return initialIntegrations.map((int) => {\n      if (int.id === \"home-assistant\") {\n        return {\n          ...int,\n          status: isHaConnected\n            ? (\"Connected\" as const)\n            : (\"Not Connected\" as const),\n        };\n      }\n      if (int.id === \"ros2\") {\n        return {\n          ...int,\n          status: isRos2Connected\n            ? (\"Connected\" as const)\n            : (\"Not Connected\" as const),\n        };\n      }\n      if (int.id === \"sdr\") {\n        return {\n          ...int,\n          status: isSdrConnected\n            ? (\"Connected\" as const)\n            : (\"Not Connected\" as const),\n        };\n      }\n      return int;\n    });\n  }, [isHaConnected, isRos2Connected, isSdrConnected]);\n\n  const handleConnectClick = (integrationId: IntegrationId) => {\n    setSelectedIntegration(integrationId);\n  };\n\n  const handleWizardComplete = () => {\n    fetchStatus();\n    setSelectedIntegration(null);\n  };\n\n  const handleWizardClose = () => {\n    setSelectedIntegration(null);\n  };\n\n  return (\n    <>\n      {integrations.map((integration) => (\n        <div\n          key={integration.id}\n          className='flex items-center gap-4 border p-4'\n        >\n          {integration.icon}\n          <div className='flex-grow'>\n            <h3 className='font-semibold'>{integration.name}</h3>\n            <p className='text-sm text-muted-foreground'>\n              {integration.description}\n            </p>\n          </div>\n          <div className='flex items-center gap-4 ml-auto'>\n            {integration.status === \"Connected\" ? (\n              <Button\n                size='sm'\n                variant='secondary'\n                onClick={() => navigate(`/devices?configure=${integration.id}`)}\n              >\n                Configure\n              </Button>\n            ) : (\n              <Button\n                size='sm'\n                onClick={() => handleConnectClick(integration.id)}\n              >\n                Connect\n              </Button>\n            )}\n          </div>\n        </div>\n      ))}\n\n      {selectedIntegration && (\n        <IntegrationWizardModal\n          integrationId={selectedIntegration}\n          onClose={handleWizardClose}\n          onComplete={handleWizardComplete}\n        />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/integration/ROS.tsx",
    "content": "import React from \"react\";\n\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\n\nimport { StepComponentProps } from \"./types\";\n\nexport const ROS2_Step1_Bridge: React.FC = () => (\n  <div className='py-4 text-center'>\n    <h4 className='font-semibold'>ROS2 Bridge Server</h4>\n    <p className='text-sm text-muted-foreground mt-2'>\n      Ensure that the `rosbridge_server` is running on your ROS2 machine.\n      <br />\n      Example command:\n    </p>\n    <code className='text-xs bg-muted p-2 rounded-md block mt-2'>\n      ros2 launch rosbridge_server rosbridge_websocket_launch.xml\n    </code>\n  </div>\n);\n\nexport const ROS2_Step2_Address: React.FC<StepComponentProps> = ({\n  value,\n  onChange,\n}) => (\n  <div className='grid gap-4 py-4'>\n    <div className='grid grid-cols-4 items-center gap-4'>\n      <Label htmlFor='ros-address' className='text-right'>\n        WebSocket URL\n      </Label>\n      <Input\n        id='ros-address'\n        placeholder='ws://<your_ros_ip>:9090'\n        className='col-span-3'\n        value={value}\n        onChange={onChange}\n      />\n    </div>\n    <p className='text-sm text-muted-foreground px-4 text-center'>\n      Enter the WebSocket address of your rosbridge server.\n    </p>\n  </div>\n);\n"
  },
  {
    "path": "apps/client/src/features/integration/SDR.tsx",
    "content": "import React from \"react\";\n\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\n\nimport { StepComponentProps } from \"./types\";\n\nexport const SDR_Step1_Info: React.FC = () => (\n  <div className='py-4 text-center'>\n    <h4 className='font-semibold'>RTL-SDR (rtl_tcp)</h4>\n    <p className='text-sm text-muted-foreground mt-2'>\n      Ensure that rtl_tcp is running and accessible on the network.\n      <br />\n      The server typically listens on port 1234.\n    </p>\n    <code className='text-xs bg-muted p-2 rounded-md block mt-2'>\n      rtl_tcp -a 0.0.0.0\n    </code>\n  </div>\n);\n\nexport const SDR_Step2_Host: React.FC<StepComponentProps> = ({\n  value,\n  onChange,\n}) => (\n  <div className='grid gap-4 py-4'>\n    <div className='grid grid-cols-4 items-center gap-4'>\n      <Label htmlFor='sdr-host' className='text-right'>\n        Host\n      </Label>\n      <Input\n        id='sdr-host'\n        placeholder='192.168.1.100'\n        className='col-span-3'\n        value={value}\n        onChange={onChange}\n      />\n    </div>\n    <p className='text-sm text-muted-foreground px-4 text-center'>\n      Enter the hostname or IP address of the rtl_tcp server.\n    </p>\n  </div>\n);\n\nexport const SDR_Step3_Port: React.FC<StepComponentProps> = ({\n  value,\n  onChange,\n}) => (\n  <div className='grid gap-4 py-4'>\n    <div className='grid grid-cols-4 items-center gap-4'>\n      <Label htmlFor='sdr-port' className='text-right'>\n        Port\n      </Label>\n      <Input\n        id='sdr-port'\n        placeholder='1234'\n        className='col-span-3'\n        value={value}\n        onChange={onChange}\n      />\n    </div>\n    <p className='text-sm text-muted-foreground px-4 text-center'>\n      Enter the TCP port of the rtl_tcp server (default: 1234).\n    </p>\n  </div>\n);\n"
  },
  {
    "path": "apps/client/src/features/integration/constants.ts",
    "content": "export const INTEGRATION_DEVICE_ID: Record<string, string> = {\n  \"home-assistant\": \"home_assistant\",\n  ros2: \"ros2_bridge\",\n  sdr: \"sdr_server\",\n};\n\nexport const INTEGRATION_ENTITY_ID: Record<string, string> = {\n  \"home-assistant\": \"home_assistant.bridge\",\n  ros2: \"ros2.bridge\",\n  sdr: \"sdr.server\",\n};\n"
  },
  {
    "path": "apps/client/src/features/integration/types.ts",
    "content": "export type IntegrationId = \"home-assistant\" | \"ros2\" | \"sdr\";\n\nexport interface StepComponentProps {\n  value: string;\n  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;\n}\n\nexport interface FinalStepProps {\n  integrationName: string;\n}\n\nexport interface IntegrationWizardModalProps {\n  integrationId: IntegrationId;\n  onClose: () => void;\n  onComplete: () => void;\n}\n"
  },
  {
    "path": "apps/client/src/features/json/JsonEditor.tsx",
    "content": "import CodeMirror, {\n  EditorView,\n  ReactCodeMirrorProps,\n} from \"@uiw/react-codemirror\";\nimport { json } from \"@codemirror/lang-json\";\n\ninterface JsonCodeEditorProps extends ReactCodeMirrorProps {\n  value: string;\n  onChange: (value: string) => void;\n}\n\nexport const JsonCodeEditor: React.FC<JsonCodeEditorProps> = ({\n  value,\n  onChange,\n  ...props\n}) => {\n  return (\n    <div className='rounded-md border'>\n      <CodeMirror\n        value={value}\n        onChange={onChange}\n        height='240px'\n        extensions={[json(), EditorView.lineWrapping]}\n        theme='dark'\n        style={{\n          fontSize: \"0.875rem\",\n          fontFamily: \"var(--font-mono)\",\n        }}\n        {...props}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/client/src/features/llm-chat/ChatInput.tsx",
    "content": "import { useState, useRef, useEffect, useMemo } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { SendIcon, ImageIcon, XIcon } from \"lucide-react\";\n\ninterface ChatInputProps {\n  onSend: (content: string) => void;\n  onSendWithImage: (content: string, image: File) => void;\n  pendingImage: File | null;\n  onImageSelect: (image: File | null) => void;\n  disabled?: boolean;\n}\n\nexport function ChatInput({\n  onSend,\n  onSendWithImage,\n  pendingImage,\n  onImageSelect,\n  disabled,\n}: ChatInputProps) {\n  const [value, setValue] = useState(\"\");\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n  const fileInputRef = useRef<HTMLInputElement>(null);\n\n  // Memoize pendingImage's Object URL (prevent duplicate creation)\n  const imagePreviewUrl = useMemo(() => {\n    if (!pendingImage) return null;\n    return URL.createObjectURL(pendingImage);\n  }, [pendingImage]);\n\n  // cleanup on unmount or when image changes\n  useEffect(() => {\n    return () => {\n      if (imagePreviewUrl) {\n        URL.revokeObjectURL(imagePreviewUrl);\n      }\n    };\n  }, [imagePreviewUrl]);\n\n  const handleSubmit = () => {\n    if (disabled) return;\n\n    if (pendingImage) {\n      // Send with image if image is attached\n      const message = value.trim() || \"Please analyze this image\";\n      onSendWithImage(message, pendingImage);\n      setValue(\"\");\n    } else {\n      // Send text only\n      const trimmed = value.trim();\n      if (!trimmed) return;\n      onSend(trimmed);\n      setValue(\"\");\n    }\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {\n    if (e.key === \"Enter\" && !e.shiftKey) {\n      e.preventDefault();\n      handleSubmit();\n    }\n  };\n\n  const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const file = e.target.files?.[0];\n    if (file) {\n      // Validate image type\n      if (!file.type.startsWith(\"image/\")) {\n        alert(\"Only image files can be uploaded.\");\n        return;\n      }\n      // Size limit (20MB)\n      if (file.size > 20 * 1024 * 1024) {\n        alert(\"Image size must be 20MB or less.\");\n        return;\n      }\n      onImageSelect(file);\n    }\n    // Allow reselecting the same file\n    e.target.value = \"\";\n  };\n\n  const handleRemoveImage = () => {\n    onImageSelect(null);\n  };\n\n  useEffect(() => {\n    if (textareaRef.current) {\n      textareaRef.current.style.height = \"auto\";\n      textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`;\n    }\n  }, [value]);\n\n  const canSubmit = pendingImage || value.trim();\n\n  return (\n    <div className=\"border-t p-4 pb-9\">\n      {/* Image preview */}\n      {imagePreviewUrl && (\n        <div className=\"mb-2 relative inline-block\">\n          <img\n            src={imagePreviewUrl}\n            alt=\"Preview\"\n            className=\"h-20 rounded border object-cover\"\n          />\n          <button\n            type=\"button\"\n            onClick={handleRemoveImage}\n            className=\"absolute -top-2 -right-2 bg-destructive text-destructive-foreground rounded-full p-0.5 hover:bg-destructive/90\"\n          >\n            <XIcon className=\"size-3\" />\n          </button>\n        </div>\n      )}\n\n      <div className=\"flex gap-2\">\n        {/* Image select button */}\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"icon\"\n          onClick={() => fileInputRef.current?.click()}\n          disabled={disabled}\n          title=\"Attach image\"\n        >\n          <ImageIcon className=\"size-4\" />\n        </Button>\n        <input\n          ref={fileInputRef}\n          type=\"file\"\n          accept=\"image/*\"\n          onChange={handleImageSelect}\n          className=\"hidden\"\n        />\n\n        <textarea\n          ref={textareaRef}\n          value={value}\n          onChange={(e) => setValue(e.target.value)}\n          onKeyDown={handleKeyDown}\n          placeholder={\n            pendingImage ? \"Ask about this image...\" : \"Type a message...\"\n          }\n          disabled={disabled}\n          rows={1}\n          className=\"flex-1 resize-none border bg-background px-3 py-2 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring disabled:opacity-50\"\n        />\n\n        <Button\n          onClick={handleSubmit}\n          disabled={disabled || !canSubmit}\n          size=\"icon\"\n          title=\"Send\"\n        >\n          <SendIcon className=\"size-4\" />\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/llm-chat/ChatMessage.tsx",
    "content": "import { cn } from \"@/lib/utils\";\nimport { LoaderCircle } from \"lucide-react\";\nimport type { ChatMessage as ChatMessageType } from \"./types\";\n\ninterface ChatMessageProps {\n  message: ChatMessageType;\n}\n\nexport function ChatMessage({ message }: ChatMessageProps) {\n  if (message.role === \"tool\") return null;\n\n  const isUser = message.role === \"user\";\n\n  return (\n    <div\n      className={cn(\"flex w-full\", isUser ? \"justify-end\" : \"justify-start\")}\n    >\n      <div\n        className={cn(\n          \"max-w-[80%] px-3 py-2 text-sm\",\n          isUser\n            ? \"bg-primary text-primary-foreground\"\n            : \"bg-muted text-foreground\",\n        )}\n      >\n        {/* Display image if present */}\n        {message.image?.previewUrl && (\n          <img\n            src={message.image.previewUrl}\n            alt={message.image.fileName || \"Uploaded image\"}\n            className='max-w-full rounded mb-2'\n          />\n        )}\n\n        {/* Tool call in progress */}\n        {message.isToolCalling && (\n          <div className='mb-2 flex items-center gap-2 text-xs text-muted-foreground px-2 py-1.5 rounded bg-blue-500/10'>\n            <LoaderCircle className='h-3.5 w-3.5 animate-spin text-blue-400' />\n            <span className='text-blue-400'>Generating flow...</span>\n          </div>\n        )}\n\n        {/* Tool call results */}\n        {!message.isToolCalling &&\n          message.toolResults &&\n          message.toolResults.length > 0 && (\n            <div className='mb-2 space-y-1'>\n              {message.toolResults.map((r, i) => (\n                <div\n                  key={i}\n                  className={cn(\n                    \"flex items-center gap-1.5 text-xs px-2 py-1\",\n                    r.success\n                      ? \"bg-green-500/10 text-green-400\"\n                      : \"bg-red-500/10 text-red-400\",\n                  )}\n                >\n                  <span className='font-mono truncate w-50'>\n                    {r.name} {r.message}\n                  </span>\n                </div>\n              ))}\n            </div>\n          )}\n\n        {/* Text content */}\n        <p className='whitespace-pre-wrap break-words'>\n          {message.content}\n          {message.isStreaming && (\n            <span className='animate-pulse ml-0.5'>▊</span>\n          )}\n        </p>\n\n        <span\n          className={cn(\n            \"mt-1 block text-xs opacity-60\",\n            isUser ? \"text-right\" : \"text-left\",\n          )}\n        >\n          {message.timestamp.toLocaleTimeString([], {\n            hour: \"2-digit\",\n            minute: \"2-digit\",\n          })}\n        </span>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/llm-chat/ChatMessages.tsx",
    "content": "import { useEffect, useRef } from \"react\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport { ChatMessage } from \"./ChatMessage\";\nimport type { ChatMessage as ChatMessageType } from \"./types\";\n\ninterface ChatMessagesProps {\n  messages: ChatMessageType[];\n  isLoading?: boolean;\n}\n\nexport function ChatMessages({ messages, isLoading }: ChatMessagesProps) {\n  const bottomRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    bottomRef.current?.scrollIntoView({ behavior: \"smooth\" });\n  }, [messages]);\n\n  return (\n    <ScrollArea className='flex-1 px-4'>\n      <div className='flex flex-col gap-3 py-4'>\n        {messages.length === 0 ? (\n          <div className='flex h-full items-center justify-center text-muted-foreground text-sm'>\n            <p>Type a message to start the conversation.</p>\n          </div>\n        ) : (\n          messages.map((message) => (\n            <ChatMessage key={message.id} message={message} />\n          ))\n        )}\n        {isLoading && (\n          <div className='flex justify-start'>\n            <div className='bg-muted rounded-lg px-3 py-2'>\n              <div className='flex gap-1'>\n                <span className='size-2 animate-bounce rounded-full bg-foreground/40' />\n                <span className='size-2 animate-bounce rounded-full bg-foreground/40 [animation-delay:0.1s]' />\n                <span className='size-2 animate-bounce rounded-full bg-foreground/40 [animation-delay:0.2s]' />\n              </div>\n            </div>\n          </div>\n        )}\n        <div ref={bottomRef} />\n      </div>\n    </ScrollArea>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/llm-chat/ChatPanel.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { XIcon, TrashIcon } from \"lucide-react\";\nimport { isElectron } from \"@/lib/electron\";\nimport { cn } from \"@/lib/utils\";\nimport { useChatStore } from \"./store\";\nimport { ChatMessages } from \"./ChatMessages\";\nimport { ChatInput } from \"./ChatInput\";\n\nconst PANEL_WIDTH = 400;\nconst ELECTRON_TOP_OFFSET = 34;\n\nexport function ChatPanel() {\n  const {\n    isOpen,\n    messages,\n    isLoading,\n    pendingImage,\n    closePanel,\n    sendMessage,\n    sendMessageWithImage,\n    setPendingImage,\n    clearMessages,\n  } = useChatStore();\n\n  const topOffset = isElectron() ? ELECTRON_TOP_OFFSET : 0;\n\n  return (\n    <div\n      className={cn(\n        \"fixed right-0 z-40 flex flex-col border-l bg-background shadow-lg transition-transform duration-300 ease-in-out\",\n        !isOpen && \"translate-x-full\",\n      )}\n      style={{\n        top: topOffset,\n        height: `calc(100vh - ${topOffset}px)`,\n        width: PANEL_WIDTH,\n      }}\n    >\n      {/* Header */}\n      <div className='flex items-center justify-between border-b px-4 h-[48px]'>\n        <div className='flex items-center gap-2'>\n          <h2 className='font-semibold'>Assistant</h2>\n        </div>\n        <div className='flex items-center gap-1'>\n          <Button\n            variant='ghost'\n            size='icon'\n            onClick={clearMessages}\n            disabled={messages.length === 0}\n            title='Clear conversation'\n          >\n            <TrashIcon className='size-4' />\n          </Button>\n          <Button\n            variant='ghost'\n            size='icon'\n            onClick={closePanel}\n            title='Close panel (⌘K)'\n          >\n            <XIcon className='size-4' />\n          </Button>\n        </div>\n      </div>\n\n      {/* Messages */}\n      <ChatMessages messages={messages} isLoading={isLoading} />\n\n      {/* Input */}\n      <ChatInput\n        onSend={sendMessage}\n        onSendWithImage={sendMessageWithImage}\n        pendingImage={pendingImage}\n        onImageSelect={setPendingImage}\n        disabled={isLoading}\n      />\n    </div>\n  );\n}\n\nexport { PANEL_WIDTH };\n"
  },
  {
    "path": "apps/client/src/features/llm-chat/ChatPanelContainer.tsx",
    "content": "import { useIsMobile } from \"@/hooks/use-mobile\";\nimport { useChatStore } from \"./store\";\nimport { useChatKeyboard } from \"./useChatKeyboard\";\nimport { ChatPanel } from \"./ChatPanel\";\nimport { ChatPanelMobile } from \"./ChatPanelMobile\";\n\nexport function ChatPanelContainer() {\n  const isMobile = useIsMobile();\n  const isOpen = useChatStore((s) => s.isOpen);\n\n  // Cmd+K 단축키 등록\n  useChatKeyboard();\n\n  // 모바일에서는 열려있을 때만 Sheet 표시\n  if (isMobile) {\n    return isOpen ? <ChatPanelMobile /> : null;\n  }\n\n  // 데스크톱에서는 항상 패널 렌더링 (열기/닫기 애니메이션 처리)\n  return <ChatPanel />;\n}\n"
  },
  {
    "path": "apps/client/src/features/llm-chat/ChatPanelMobile.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetHeader,\n  SheetTitle,\n} from \"@/components/ui/sheet\";\nimport { TrashIcon, MessageSquareIcon } from \"lucide-react\";\nimport { useChatStore } from \"./store\";\nimport { ChatMessages } from \"./ChatMessages\";\nimport { ChatInput } from \"./ChatInput\";\n\nexport function ChatPanelMobile() {\n  const {\n    isOpen,\n    messages,\n    isLoading,\n    pendingImage,\n    closePanel,\n    sendMessage,\n    sendMessageWithImage,\n    setPendingImage,\n    clearMessages,\n  } = useChatStore();\n\n  return (\n    <Sheet open={isOpen} onOpenChange={(open) => !open && closePanel()}>\n      <SheetContent side=\"right\" className=\"flex flex-col p-0\">\n        <SheetHeader className=\"flex-row items-center justify-between border-b px-4 py-3\">\n          <div className=\"flex items-center gap-2\">\n            <MessageSquareIcon className=\"size-5 text-muted-foreground\" />\n            <SheetTitle>AI Assistant</SheetTitle>\n          </div>\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            onClick={clearMessages}\n            disabled={messages.length === 0}\n            title=\"Clear conversation\"\n          >\n            <TrashIcon className=\"size-4\" />\n          </Button>\n        </SheetHeader>\n\n        <ChatMessages messages={messages} isLoading={isLoading} />\n\n        <ChatInput\n          onSend={sendMessage}\n          onSendWithImage={sendMessageWithImage}\n          pendingImage={pendingImage}\n          onImageSelect={setPendingImage}\n          disabled={isLoading}\n        />\n      </SheetContent>\n    </Sheet>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/llm-chat/index.tsx",
    "content": "export { ChatPanelContainer } from \"./ChatPanelContainer\";\nexport { useChatStore } from \"./store\";\nexport { PANEL_WIDTH } from \"./ChatPanel\";\nexport type { ChatMessage, ChatPanelState, FlowChatContext } from \"./types\";\n"
  },
  {
    "path": "apps/client/src/features/llm-chat/store.ts",
    "content": "import { create } from \"zustand\";\nimport {\n  CapsuleClient,\n  CapsuleAuthError,\n  CapsuleSubscriptionError,\n  CapsuleRateLimitError,\n} from \"@vessel/capsule-client\";\nimport type { HistoryMessage, ToolCallResult } from \"@vessel/capsule-client\";\nimport { supabase } from \"@/lib/supabase\";\nimport { storage } from \"@/lib/storage\";\nimport type { ChatMessage, ChatPanelState } from \"./types\";\n\nfunction generateId(): string {\n  return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;\n}\n\nfunction buildHistory(messages: ChatMessage[]): HistoryMessage[] {\n  return messages\n    .filter((m) => !m.isStreaming && !m.content.startsWith(\"⚠️\"))\n    .map((m) => {\n      const msg: HistoryMessage = { role: m.role, content: m.content };\n      if (m.toolCalls) msg.tool_calls = m.toolCalls;\n      if (m.toolCallId) msg.tool_call_id = m.toolCallId;\n      return msg;\n    });\n}\n\nconst DEFAULT_CAPSULE_URL = import.meta.env.VITE_CAPSULE_URL || \"http://localhost:3000\";\n\nconst capsuleClient = new CapsuleClient({\n  baseUrl: storage.getCapsuleUrl() || DEFAULT_CAPSULE_URL,\n  timeout: 120000,\n  getAccessToken: async () => {\n    const {\n      data: { session },\n    } = await supabase.auth.getSession();\n    return session?.access_token ?? null;\n  },\n});\n\nfunction handleChatError(\n  error: unknown,\n  defaultMsg: string,\n): { errorMessage: string; showInChat: boolean } {\n  let errorMessage = defaultMsg;\n  let showInChat = false;\n\n  if (error instanceof CapsuleAuthError) {\n    errorMessage = \"Please sign in to continue.\";\n    showInChat = true;\n  } else if (error instanceof CapsuleSubscriptionError) {\n    errorMessage =\n      \"Pro subscription required. Upgrade at [vessel.cartesiancs.com/pricing](https://vessel.cartesiancs.com/pricing)\";\n    showInChat = true;\n  } else if (error instanceof CapsuleRateLimitError) {\n    errorMessage = \"Daily usage limit reached. Please try again tomorrow.\";\n    showInChat = true;\n  } else if (error instanceof Error) {\n    errorMessage = error.message;\n    if (errorMessage.includes(\"length limit exceeded\")) {\n      errorMessage = \"Image is too large. Please use an image under 20MB.\";\n    }\n    showInChat = true;\n  }\n\n  return { errorMessage, showInChat };\n}\n\nexport const useChatStore = create<ChatPanelState>((set, get) => ({\n  isOpen: false,\n  messages: [],\n  isLoading: false,\n  error: null,\n  pendingImage: null,\n  flowContext: null,\n\n  openPanel: () => set({ isOpen: true }),\n  closePanel: () => set({ isOpen: false }),\n  togglePanel: () => set((state) => ({ isOpen: !state.isOpen })),\n\n  setFlowContext: (ctx) => set({ flowContext: ctx }),\n\n  sendMessage: async (content: string) => {\n    const userMessage: ChatMessage = {\n      id: generateId(),\n      role: \"user\",\n      content,\n      timestamp: new Date(),\n    };\n\n    const assistantMessage: ChatMessage = {\n      id: generateId(),\n      role: \"assistant\",\n      content: \"\",\n      timestamp: new Date(),\n      isStreaming: true,\n    };\n\n    set((state) => ({\n      messages: [...state.messages, userMessage, assistantMessage],\n      isLoading: true,\n      error: null,\n    }));\n\n    try {\n      const history = buildHistory(get().messages.slice(0, -2));\n      const flowCtx = get().flowContext;\n\n      let receivedToolCalls: ToolCallResult[] | null = null;\n\n      await capsuleClient.chatStream(content, (chunk, done) => {\n        if (done) {\n          set((state) => ({\n            messages: state.messages.map((m) =>\n              m.id === assistantMessage.id ? { ...m, isStreaming: false } : m,\n            ),\n          }));\n        } else {\n          set((state) => ({\n            messages: state.messages.map((m) =>\n              m.id === assistantMessage.id\n                ? { ...m, content: m.content + chunk }\n                : m,\n            ),\n          }));\n        }\n      }, {\n        history,\n        ...(flowCtx && {\n          systemPrompt: flowCtx.buildSystemPrompt(),\n          tools: flowCtx.tools,\n          toolChoice: \"auto\" as const,\n          onToolCall: (toolCalls) => {\n            receivedToolCalls = toolCalls;\n            set((state) => ({\n              messages: state.messages.map((m) =>\n                m.id === assistantMessage.id\n                  ? { ...m, isToolCalling: true, isStreaming: false }\n                  : m,\n              ),\n            }));\n          },\n        }),\n      });\n\n      // Tool call feedback loop\n      if (receivedToolCalls && flowCtx) {\n        const toolResults = flowCtx.executeToolCalls(receivedToolCalls);\n\n        // Store toolCalls on the assistant message for history reconstruction\n        // and add tool result messages to the messages array\n        const toolResultMessages: ChatMessage[] = toolResults.map((r) => ({\n          id: generateId(),\n          role: \"tool\" as const,\n          content: JSON.stringify({ success: r.success, message: r.message }),\n          toolCallId: r.toolCallId,\n          timestamp: new Date(),\n        }));\n\n        const finalMessage: ChatMessage = {\n          id: generateId(),\n          role: \"assistant\",\n          content: \"\",\n          timestamp: new Date(),\n          isStreaming: true,\n        };\n\n        set((state) => ({\n          messages: [\n            ...state.messages.map((m) =>\n              m.id === assistantMessage.id\n                ? { ...m, isToolCalling: false, toolResults, toolCalls: receivedToolCalls ?? undefined }\n                : m,\n            ),\n            ...toolResultMessages,\n            finalMessage,\n          ],\n        }));\n\n        const feedbackHistory = buildHistory(\n          get().messages.slice(0, -1),\n        );\n\n        await capsuleClient.chatStream(\n          \"Based on the tool execution results above, briefly summarize what was done.\",\n          (chunk, done) => {\n            if (done) {\n              set((state) => ({\n                messages: state.messages.map((m) =>\n                  m.id === finalMessage.id ? { ...m, isStreaming: false } : m,\n                ),\n                isLoading: false,\n              }));\n            } else {\n              set((state) => ({\n                messages: state.messages.map((m) =>\n                  m.id === finalMessage.id\n                    ? { ...m, content: m.content + chunk }\n                    : m,\n                ),\n              }));\n            }\n          },\n          {\n            history: feedbackHistory,\n            systemPrompt: flowCtx.buildSystemPrompt(),\n          },\n        );\n      } else {\n        set({ isLoading: false });\n      }\n    } catch (error) {\n      const { errorMessage, showInChat } = handleChatError(error, \"Failed to send message\");\n\n      set({\n        error: showInChat ? null : errorMessage,\n        isLoading: false,\n        messages: get().messages.map((m) =>\n          m.id === assistantMessage.id\n            ? {\n                ...m,\n                content: showInChat ? `⚠️ ${errorMessage}` : m.content,\n                isStreaming: false,\n                isToolCalling: false,\n              }\n            : m,\n        ),\n      });\n    }\n  },\n\n  sendMessageWithImage: async (content: string, image: File) => {\n    const previewUrl = URL.createObjectURL(image);\n\n    const userMessage: ChatMessage = {\n      id: generateId(),\n      role: \"user\",\n      content,\n      timestamp: new Date(),\n      image: {\n        previewUrl,\n        fileName: image.name,\n        size: image.size,\n      },\n    };\n\n    const assistantMessage: ChatMessage = {\n      id: generateId(),\n      role: \"assistant\",\n      content: \"\",\n      timestamp: new Date(),\n      isStreaming: true,\n    };\n\n    set((state) => ({\n      messages: [...state.messages, userMessage, assistantMessage],\n      isLoading: true,\n      error: null,\n      pendingImage: null,\n    }));\n\n    try {\n      const history = buildHistory(get().messages.slice(0, -2));\n      await capsuleClient.analyzeImageStream(\n        { image, message: content },\n        (chunk, done) => {\n          if (done) {\n            set((state) => ({\n              messages: state.messages.map((m) =>\n                m.id === assistantMessage.id ? { ...m, isStreaming: false } : m,\n              ),\n              isLoading: false,\n            }));\n          } else {\n            set((state) => ({\n              messages: state.messages.map((m) =>\n                m.id === assistantMessage.id\n                  ? { ...m, content: m.content + chunk }\n                  : m,\n              ),\n            }));\n          }\n        },\n        { history },\n      );\n    } catch (error) {\n      const { errorMessage, showInChat } = handleChatError(error, \"Failed to analyze image\");\n\n      set((state) => ({\n        error: showInChat ? null : errorMessage,\n        isLoading: false,\n        messages: state.messages.map((m) =>\n          m.id === assistantMessage.id\n            ? { ...m, content: `⚠️ ${errorMessage}`, isStreaming: false }\n            : m,\n        ),\n      }));\n    }\n  },\n\n  setPendingImage: (image: File | null) => {\n    set({ pendingImage: image });\n  },\n\n  clearMessages: () => {\n    const messages = get().messages;\n    messages.forEach((m) => {\n      if (m.image?.previewUrl) {\n        URL.revokeObjectURL(m.image.previewUrl);\n      }\n    });\n    set({ messages: [], error: null });\n  },\n\n  updateCapsuleUrl: (url: string) => {\n    storage.setCapsuleUrl(url);\n    capsuleClient.setBaseUrl(url);\n  },\n}));\n"
  },
  {
    "path": "apps/client/src/features/llm-chat/types.ts",
    "content": "import type { Tool, ToolCallResult } from \"@vessel/capsule-client\";\nimport type { ToolExecutionResult } from \"@/features/flow/flow-chat\";\n\nexport interface ChatMessageImage {\n  /** Object URL for local preview (revoke after use) */\n  previewUrl?: string;\n  /** Image file name */\n  fileName?: string;\n  /** Image size (bytes) */\n  size?: number;\n}\n\nexport interface ChatMessage {\n  id: string;\n  role: \"user\" | \"assistant\" | \"tool\";\n  content: string;\n  timestamp: Date;\n  isStreaming?: boolean;\n  /** Attached image (only exists in user messages) */\n  image?: ChatMessageImage;\n  /** Tool call is currently being executed */\n  isToolCalling?: boolean;\n  /** Raw tool calls from LLM (for history reconstruction) */\n  toolCalls?: ToolCallResult[];\n  /** Tool call ID (for tool result messages in history) */\n  toolCallId?: string;\n  /** Tool call execution results (UI display only) */\n  toolResults?: ToolExecutionResult[];\n}\n\nexport interface FlowChatContext {\n  buildSystemPrompt: () => string;\n  tools: Tool[];\n  executeToolCalls: (toolCalls: ToolCallResult[]) => ToolExecutionResult[];\n}\n\nexport interface ChatPanelState {\n  isOpen: boolean;\n  messages: ChatMessage[];\n  isLoading: boolean;\n  error: string | null;\n  /** Selected image (before sending) */\n  pendingImage: File | null;\n  /** Flow context for tool calling (set when on flow page) */\n  flowContext: FlowChatContext | null;\n\n  openPanel: () => void;\n  closePanel: () => void;\n  togglePanel: () => void;\n  /** Send text message */\n  sendMessage: (content: string) => Promise<void>;\n  /** Send message with image */\n  sendMessageWithImage: (content: string, image: File) => Promise<void>;\n  /** Select image */\n  setPendingImage: (image: File | null) => void;\n  clearMessages: () => void;\n  /** Update the Capsule server URL at runtime */\n  updateCapsuleUrl: (url: string) => void;\n  /** Set flow context for tool calling */\n  setFlowContext: (ctx: FlowChatContext | null) => void;\n}\n"
  },
  {
    "path": "apps/client/src/features/llm-chat/useChatKeyboard.ts",
    "content": "import { useEffect } from \"react\";\nimport { useChatStore } from \"./store\";\n\nconst CHAT_KEYBOARD_SHORTCUT = \"k\";\n\nexport function useChatKeyboard() {\n  const togglePanel = useChatStore((s) => s.togglePanel);\n\n  useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (\n        event.key === CHAT_KEYBOARD_SHORTCUT &&\n        (event.metaKey || event.ctrlKey)\n      ) {\n        event.preventDefault();\n        togglePanel();\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => window.removeEventListener(\"keydown\", handleKeyDown);\n  }, [togglePanel]);\n}\n"
  },
  {
    "path": "apps/client/src/features/log/index.tsx",
    "content": "import {\n  getLatestLog,\n  getLogFileList,\n  getLogByFilename,\n} from \"@/entities/log/api\";\nimport { useEffect, useState } from \"react\";\nimport {\n  ResizablePanelGroup,\n  ResizablePanel,\n  ResizableHandle,\n} from \"@/components/ui/resizable\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport { Terminal } from \"lucide-react\";\n\nexport function Logs() {\n  const [logContent, setLogContent] = useState(\"\");\n  const [logFiles, setLogFiles] = useState<string[]>([]);\n  const [selectedFile, setSelectedFile] = useState(\"\");\n  const [isLoading, setIsLoading] = useState(true);\n  const [error, setError] = useState<string | null>(null);\n\n  useEffect(() => {\n    const fetchInitialData = async () => {\n      try {\n        setError(null);\n        setIsLoading(true);\n\n        const [latestLogData, fileListData] = await Promise.all([\n          getLatestLog(),\n          getLogFileList(),\n        ]);\n\n        setLogContent(latestLogData.logs);\n        setSelectedFile(latestLogData.filename);\n        setLogFiles(fileListData.files);\n      } catch (err) {\n        setError(\"Failed.\");\n        console.error(err);\n      } finally {\n        setIsLoading(false);\n      }\n    };\n\n    fetchInitialData();\n  }, []);\n\n  const handleFileSelect = async (filename: string) => {\n    if (filename === selectedFile || isLoading) return;\n\n    try {\n      setError(null);\n      setIsLoading(true);\n      const data = await getLogByFilename(filename);\n      setLogContent(data.logs);\n      setSelectedFile(data.filename);\n    } catch (err) {\n      setError(`Failed: ${filename}`);\n      console.error(err);\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  return (\n    <ResizablePanelGroup\n      direction='horizontal'\n      className='w-full h-[85vh] border'\n    >\n      <ResizablePanel defaultSize={20} minSize={15}>\n        <div className='flex h-full flex-col p-4'>\n          <h2 className='text-lg font-semibold mb-4'>Logs</h2>\n          <ScrollArea className='flex-grow'>\n            <div className='space-y-1'>\n              {logFiles.map((file) => (\n                <Button\n                  key={file}\n                  variant={selectedFile === file ? \"secondary\" : \"ghost\"}\n                  className='w-full justify-start'\n                  onClick={() => handleFileSelect(file)}\n                  disabled={isLoading}\n                >\n                  {file}\n                </Button>\n              ))}\n            </div>\n          </ScrollArea>\n        </div>\n      </ResizablePanel>\n      <ResizableHandle withHandle />\n      <ResizablePanel defaultSize={80}>\n        <Card className='h-full w-full border-0 rounded-none flex flex-col'>\n          <CardHeader>\n            <CardTitle>{selectedFile || \"Select a log.\"}</CardTitle>\n          </CardHeader>\n          <CardContent className='flex-grow overflow-hidden p-4'>\n            <ScrollArea className='h-full bg-muted/30 dark:bg-muted/50 rounded-md'>\n              <div className='p-4 font-mono text-sm'>\n                {isLoading && (\n                  <div className='space-y-2'>\n                    <Skeleton className='h-4 w-full' />\n                    <Skeleton className='h-4 w-full' />\n                    <Skeleton className='h-4 w-[75%]' />\n                    <Skeleton className='h-4 w-full' />\n                    <Skeleton className='h-4 w-[80%]' />\n                    <Skeleton className='h-4 w-full' />\n                  </div>\n                )}\n                {error && (\n                  <Alert variant='destructive'>\n                    <Terminal className='h-4 w-4' />\n                    <AlertTitle>Error</AlertTitle>\n                    <AlertDescription>{error}</AlertDescription>\n                  </Alert>\n                )}\n                {!isLoading && !error && (\n                  <pre className='whitespace-pre-wrap'>{logContent}</pre>\n                )}\n              </div>\n            </ScrollArea>\n          </CardContent>\n        </Card>\n      </ResizablePanel>\n    </ResizablePanelGroup>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/map/CurrentLocationMarker.tsx",
    "content": "import { useEffect, useRef, useState } from \"react\";\nimport { Circle, CircleMarker } from \"react-leaflet\";\n\ntype Props = {\n  onLocationUpdate?: (coords: [number, number]) => void;\n};\n\nexport function CurrentLocationMarker({ onLocationUpdate }: Props) {\n  const [position, setPosition] = useState<[number, number] | null>(null);\n  const [accuracy, setAccuracy] = useState<number | null>(null);\n  const hasReportedInitialPosition = useRef(false);\n\n  useEffect(() => {\n    if (typeof navigator === \"undefined\" || !navigator.geolocation) return;\n\n    const watchId = navigator.geolocation.watchPosition(\n      (pos) => {\n        const coords: [number, number] = [\n          pos.coords.latitude,\n          pos.coords.longitude,\n        ];\n\n        setPosition(coords);\n        setAccuracy(\n          typeof pos.coords.accuracy === \"number\" ? pos.coords.accuracy : null,\n        );\n\n        if (!hasReportedInitialPosition.current && onLocationUpdate) {\n          onLocationUpdate(coords);\n          hasReportedInitialPosition.current = true;\n        }\n      },\n      (err) => {\n        console.log(\"Error watching location:\", err);\n      },\n      {\n        enableHighAccuracy: true,\n        maximumAge: 1000,\n        timeout: 5000,\n      },\n    );\n\n    return () => {\n      navigator.geolocation.clearWatch(watchId);\n    };\n  }, [onLocationUpdate]);\n\n  const baseRadius = Math.max(accuracy ?? 0, 10);\n  const rippleRadius = baseRadius + 24;\n\n  if (!position) return null;\n\n  return (\n    <>\n      <Circle\n        center={position}\n        radius={rippleRadius}\n        pathOptions={{\n          className: \"current-location-ripple\",\n          color: \"#22c55e\",\n          fillColor: \"#22c55e\",\n          fillOpacity: 0.2,\n          weight: 1,\n          opacity: 0.2,\n        }}\n      />\n      <Circle\n        center={position}\n        radius={baseRadius}\n        pathOptions={{\n          color: \"#22c55e\",\n          fillColor: \"#22c55e\",\n          fillOpacity: 0.12,\n          weight: 1,\n        }}\n      />\n      <CircleMarker\n        center={position}\n        radius={6}\n        pathOptions={{\n          color: \"#15803d\",\n          fillColor: \"#22c55e\",\n          fillOpacity: 0.92,\n          weight: 2,\n        }}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/map/MapViewPersistence.tsx",
    "content": "import { useEffect } from \"react\";\nimport { useMapEvents } from \"react-leaflet\";\n\nconst MAP_VIEW_STORAGE_KEY = \"map:last-view\";\n\nexport type StoredMapView = {\n  lat: number;\n  lng: number;\n  zoom?: number;\n};\n\nexport function getStoredMapView(): StoredMapView | null {\n  try {\n    const storedValue = localStorage.getItem(MAP_VIEW_STORAGE_KEY);\n    if (!storedValue) return null;\n\n    const parsed = JSON.parse(storedValue);\n    if (typeof parsed?.lat !== \"number\" || typeof parsed?.lng !== \"number\") {\n      return null;\n    }\n\n    const zoom = typeof parsed.zoom === \"number\" ? parsed.zoom : undefined;\n\n    return { lat: parsed.lat, lng: parsed.lng, zoom };\n  } catch {\n    return null;\n  }\n}\n\nfunction persistMapView(center: { lat: number; lng: number }, zoom: number) {\n  try {\n    localStorage.setItem(\n      MAP_VIEW_STORAGE_KEY,\n      JSON.stringify({ lat: center.lat, lng: center.lng, zoom }),\n    );\n  } catch {\n    // ignore write errors\n  }\n}\n\nexport function MapLastViewTracker() {\n  const map = useMapEvents({\n    moveend() {\n      persistMapView(map.getCenter(), map.getZoom());\n    },\n  });\n\n  useEffect(() => {\n    persistMapView(map.getCenter(), map.getZoom());\n  }, [map]);\n\n  return null;\n}\n"
  },
  {
    "path": "apps/client/src/features/map/index.tsx",
    "content": "import { useState, useEffect, useCallback } from \"react\";\nimport { Plus, Minus } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { MapContainer, TileLayer, useMap } from \"react-leaflet\";\nimport \"leaflet/dist/leaflet.css\";\nimport L from \"leaflet\";\n\nimport icon from \"leaflet/dist/images/marker-icon.png\";\nimport iconShadow from \"leaflet/dist/images/marker-shadow.png\";\nimport \"./style.css\";\nimport { useMapDataStore, useMapInteractionStore } from \"@/entities/map/store\";\nimport { TILE_MAPS } from \"@/entities/map/types\";\n\nimport { MapEntityRender } from \"../map-entity/render\";\nimport { FeatureDetailsPanel } from \"../map-draw/FeatureDetailsPanel\";\nimport { DrawingPreview } from \"../map-draw/FeatureDrawingPreview\";\nimport { FeatureEditor } from \"../map-draw/FeatureEditor\";\nimport { FeatureRenderer } from \"../map-draw/FeatureRenderer\";\nimport { MapEvents } from \"../map-draw/MapEvents\";\nimport { EntityDetailsPanel } from \"../map-entity/EntityDetailsPanel\";\nimport { useMapEntityStore } from \"../map-entity/store\";\nimport { cn } from \"@/lib/utils\";\nimport { MapLastViewTracker, getStoredMapView } from \"./MapViewPersistence\";\nimport { CurrentLocationMarker } from \"./CurrentLocationMarker\";\n\nconst DefaultIcon = L.icon({\n  iconUrl: icon,\n  shadowUrl: iconShadow,\n  iconAnchor: [12, 41],\n  popupAnchor: [1, -34],\n});\n\nconst failedPosition = [39.8283, -98.5795];\n\nL.Marker.prototype.options.icon = DefaultIcon;\n\nfunction MapResizer({ isSidebarCollapsed }: { isSidebarCollapsed: boolean }) {\n  const map = useMap();\n\n  useEffect(() => {\n    const timer = setTimeout(() => {\n      map.invalidateSize();\n    }, 310);\n\n    return () => {\n      clearTimeout(timer);\n    };\n  }, [isSidebarCollapsed, map]);\n\n  return null;\n}\n\nfunction CustomZoomControl() {\n  const map = useMap();\n\n  return (\n    <div className='absolute bottom-14 right-4 z-[1000] flex flex-col gap-1 bg-background/80 rounded-md shadow-lg backdrop-blur-sm'>\n      <Button\n        size='icon'\n        variant='ghost'\n        className='h-8 w-8 border-1'\n        onClick={() => map.zoomIn()}\n      >\n        <Plus className='h-4 w-4' />\n      </Button>\n      <Button\n        size='icon'\n        variant='ghost'\n        className='h-8 w-8 border-1'\n        onClick={() => map.zoomOut()}\n      >\n        <Minus className='h-4 w-4' />\n      </Button>\n    </div>\n  );\n}\n\nfunction CustomScaleControl() {\n  const map = useMap();\n  const [scale, setScale] = useState({ value: 0, unit: \"m\" });\n\n  const updateScale = useCallback(() => {\n    const y = map.getSize().y / 2;\n    const leftPoint = map.containerPointToLatLng([0, y]);\n    const rightPoint = map.containerPointToLatLng([100, y]);\n    const distance = leftPoint.distanceTo(rightPoint);\n\n    let value: number;\n    let unit: string;\n\n    if (distance >= 1000) {\n      value = Math.round((distance / 1000) * 10) / 10;\n      unit = \"km\";\n    } else {\n      value = Math.round(distance);\n      unit = \"m\";\n    }\n\n    setScale({ value, unit });\n  }, [map]);\n\n  useEffect(() => {\n    updateScale();\n    map.on(\"zoomend\", updateScale);\n    map.on(\"moveend\", updateScale);\n\n    return () => {\n      map.off(\"zoomend\", updateScale);\n      map.off(\"moveend\", updateScale);\n    };\n  }, [map, updateScale]);\n\n  return (\n    <div className='absolute bottom-4 right-4 z-[1000] bg-background/80 rounded-md shadow-lg backdrop-blur-sm px-2 py-1'>\n      <div className='flex items-center gap-2 text-xs'>\n        <div className='w-[100px] h-1 bg-foreground/70 relative'>\n          <div className='absolute left-0 top-[-3px] w-[1px] h-[7px] bg-foreground/70' />\n          <div className='absolute right-0 top-[-3px] w-[1px] h-[7px] bg-foreground/70' />\n        </div>\n        <span className='text-foreground/70 font-mono'>\n          {scale.value} {scale.unit}\n        </span>\n      </div>\n    </div>\n  );\n}\n\nexport function MapView({\n  isSidebarCollapsed,\n}: {\n  isSidebarCollapsed: boolean;\n}) {\n  const [position, setPosition] = useState<[number, number] | null>(null);\n  const [initialZoom, setInitialZoom] = useState(13);\n  const { layer, fetchAllLayers } = useMapDataStore();\n  const { selectedFeature, tileMapType } = useMapInteractionStore();\n  const currentTileMap = TILE_MAPS[tileMapType];\n  const { selectedEntity } = useMapEntityStore();\n\n  const [isFeaturePanelCollapsed, setFeaturePanelCollapsed] = useState(false);\n  const [isEntityPanelCollapsed, setEntityPanelCollapsed] = useState(false);\n\n  useEffect(() => {\n    fetchAllLayers();\n  }, [fetchAllLayers]);\n\n  useEffect(() => {\n    const storedView = getStoredMapView();\n\n    if (storedView) {\n      setPosition([storedView.lat, storedView.lng]);\n      if (typeof storedView.zoom === \"number\") {\n        setInitialZoom(storedView.zoom);\n      }\n    }\n\n    navigator.geolocation.getCurrentPosition(\n      (pos) => {\n        const { latitude, longitude } = pos.coords;\n        setPosition((prev) => prev ?? [latitude, longitude]);\n      },\n      (err) => {\n        console.log(\"Error getting location:\", err);\n        setPosition((prev) => prev ?? (failedPosition as [number, number]));\n      },\n      {\n        enableHighAccuracy: true,\n        timeout: 2000,\n        maximumAge: 0,\n      },\n    );\n  }, []);\n\n  const showPanelContainer = selectedFeature || selectedEntity;\n\n  return (\n    <div className='relative h-full w-full overflow-hidden'>\n      {!position ? (\n        <div className='flex items-center justify-center h-full'>\n          <span>Loading Map...</span>\n        </div>\n      ) : (\n        <MapContainer\n          center={position}\n          zoom={initialZoom}\n          maxZoom={23}\n          scrollWheelZoom={true}\n          zoomControl={false}\n          className='h-full w-full'\n        >\n          <TileLayer\n            key={tileMapType}\n            attribution={currentTileMap.attribution}\n            url={currentTileMap.url}\n            maxNativeZoom={currentTileMap.maxNativeZoom}\n            maxZoom={23}\n          />\n          {layer?.features.map((feature) => (\n            <FeatureRenderer key={`feature-${feature.id}`} feature={feature} />\n          ))}\n          <MapEvents />\n          <DrawingPreview />\n          <FeatureEditor />\n          <MapEntityRender />\n          <CurrentLocationMarker />\n          <MapLastViewTracker />\n          <MapResizer isSidebarCollapsed={isSidebarCollapsed} />\n          <CustomZoomControl />\n          <CustomScaleControl />\n        </MapContainer>\n      )}\n\n      <div\n        className={cn(\n          \"absolute top-[48px] right-0 h-[calc(100%-48px)] p-4 w-[400px] z-[1001]\",\n          \"flex flex-col gap-4 overflow-y-auto transition-transform duration-300 ease-in-out\",\n          \"pointer-events-none\",\n          showPanelContainer ? \"translate-x-0\" : \"translate-x-full\",\n        )}\n      >\n        {selectedFeature && (\n          <div className='pointer-events-auto'>\n            <FeatureDetailsPanel\n              isCollapsed={isFeaturePanelCollapsed}\n              onToggleCollapse={() => setFeaturePanelCollapsed((prev) => !prev)}\n            />\n          </div>\n        )}\n        {selectedEntity && (\n          <div className='pointer-events-auto'>\n            <EntityDetailsPanel\n              isCollapsed={isEntityPanelCollapsed}\n              onToggleCollapse={() => setEntityPanelCollapsed((prev) => !prev)}\n            />\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/map/style.css",
    "content": "/* .leaflet-attribution-flag {\n  display: none !important;\n} */\n\n.leaflet-div-icon.entity-map-marker-icon {\n  background: transparent !important;\n  border: none !important;\n}\n\n.current-location-ripple {\n  animation: current-location-ripple 1000ms ease-out infinite;\n  transform-origin: center;\n  transform-box: fill-box;\n  pointer-events: none;\n}\n\n@keyframes current-location-ripple {\n  0% {\n    transform: scale(1);\n    opacity: 0.35;\n  }\n  70% {\n    transform: scale(2.3);\n    opacity: 0;\n  }\n  100% {\n    transform: scale(2.3);\n    opacity: 0;\n  }\n}\n"
  },
  {
    "path": "apps/client/src/features/map-draw/FeatureDetailsPanel.tsx",
    "content": "import { useState, useEffect, useMemo } from \"react\";\nimport {\n  X,\n  Pencil,\n  Trash2,\n  Save,\n  MapPin,\n  Milestone,\n  Squircle,\n  Maximize,\n  Ruler,\n  Palette,\n  Minus,\n} from \"lucide-react\";\nimport { useMapDataStore, useMapInteractionStore } from \"@/entities/map/store\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { cn } from \"@/lib/utils\";\nimport { MapVertex, UpdateFeaturePayload } from \"@/entities/map/types\";\nimport { calculateFeatureGeometry } from \"@/lib/geometry-precision\";\n\nconst FeatureIcon = ({ type }: { type: string }) => {\n  switch (type) {\n    case \"POINT\":\n      return <MapPin className='h-5 w-5 mr-2 text-muted-foreground' />;\n    case \"LINE\":\n      return <Milestone className='h-5 w-5 mr-2 text-muted-foreground' />;\n    case \"POLYGON\":\n      return <Squircle className='h-5 w-5 mr-2 text-muted-foreground' />;\n    default:\n      return null;\n  }\n};\n\ninterface FeatureDetailsPanelProps {\n  isCollapsed: boolean;\n  onToggleCollapse: () => void;\n}\n\nexport function FeatureDetailsPanel({\n  isCollapsed,\n  onToggleCollapse,\n}: FeatureDetailsPanelProps) {\n  const { selectedFeature, setSelectedFeature } = useMapInteractionStore();\n  const { removeFeature, updateFeature } = useMapDataStore();\n\n  const [isEditing, setIsEditing] = useState(false);\n  const [editableVertices, setEditableVertices] = useState<MapVertex[]>([]);\n  const [isDeleteAlertOpen, setDeleteAlertOpen] = useState(false);\n  const [editableColor, setEditableColor] = useState(\"#6ec7f0\");\n\n  const geometryInfo = useMemo(() => {\n    if (!selectedFeature) return null;\n    return calculateFeatureGeometry(selectedFeature);\n  }, [selectedFeature]);\n\n  const defaultColor = \"#6ec7f0\";\n\n  useEffect(() => {\n    if (selectedFeature) {\n      setIsEditing(false);\n      setEditableVertices(\n        selectedFeature.vertices.sort((a, b) => a.sequence - b.sequence),\n      );\n      try {\n        if (selectedFeature.style_properties) {\n          const styles = JSON.parse(selectedFeature.style_properties);\n          setEditableColor(styles.color || defaultColor);\n        } else {\n          setEditableColor(defaultColor);\n        }\n      } catch {\n        setEditableColor(defaultColor);\n      }\n    } else {\n      setIsEditing(false);\n    }\n  }, [selectedFeature]);\n\n  const handleClose = () => setSelectedFeature(null);\n\n  const handleDelete = () => {\n    if (selectedFeature) {\n      removeFeature(selectedFeature.id);\n      setDeleteAlertOpen(false);\n    }\n  };\n\n  const handleSave = () => {\n    if (!selectedFeature) return;\n    const payload: UpdateFeaturePayload = {\n      vertices: editableVertices.map((v) => ({\n        latitude: v.latitude,\n        longitude: v.longitude,\n      })),\n      style_properties: JSON.stringify({ color: editableColor }),\n    };\n    updateFeature(selectedFeature.id, payload);\n    setIsEditing(false);\n  };\n\n  const handleCancel = () => {\n    setIsEditing(false);\n    if (selectedFeature) {\n      setEditableVertices(\n        selectedFeature.vertices.sort((a, b) => a.sequence - b.sequence),\n      );\n      try {\n        if (selectedFeature.style_properties) {\n          const styles = JSON.parse(selectedFeature.style_properties);\n          setEditableColor(styles.color || defaultColor);\n        } else {\n          setEditableColor(defaultColor);\n        }\n      } catch {\n        setEditableColor(defaultColor);\n      }\n    }\n  };\n\n  const handleToggleCollapse = (e: React.MouseEvent) => {\n    e.stopPropagation();\n    onToggleCollapse();\n  };\n\n  if (!selectedFeature) {\n    return null;\n  }\n\n  return (\n    <>\n      <Card className='w-full flex flex-col max-h-full bg-background'>\n        <CardHeader\n          onClick={isCollapsed ? onToggleCollapse : undefined}\n          className={cn(\"flex-shrink-0\", isCollapsed && \"cursor-pointer\")}\n        >\n          <div className='flex justify-between items-start'>\n            <CardTitle className='flex items-center'>\n              <FeatureIcon type={selectedFeature.feature_type} />\n              Feature #{selectedFeature.id}\n            </CardTitle>\n            <div className='flex items-center'>\n              <Button\n                variant='ghost'\n                size='icon'\n                onClick={handleToggleCollapse}\n              >\n                <Minus className='h-4 w-4' />\n              </Button>\n              <Button variant='ghost' size='icon' onClick={handleClose}>\n                <X className='h-4 w-4' />\n              </Button>\n            </div>\n          </div>\n        </CardHeader>\n        {!isCollapsed && (\n          <>\n            <CardContent className='flex-grow overflow-y-auto space-y-4'>\n              {geometryInfo && (\n                <div>\n                  <h4 className='font-semibold text-sm mb-2'>Geometry</h4>\n                  <ul className='text-sm space-y-2 p-3 bg-muted rounded-md'>\n                    {geometryInfo.location && (\n                      <li className='flex items-center'>\n                        <MapPin className='h-4 w-4 mr-2 text-muted-foreground' />\n                        <strong>Location:</strong>\n                        <span className='ml-2 font-mono'>\n                          {geometryInfo.location}\n                        </span>\n                      </li>\n                    )}\n                    {geometryInfo.length && (\n                      <li className='flex items-center'>\n                        <Ruler className='h-4 w-4 mr-2 text-muted-foreground' />\n                        <strong>Length:</strong>\n                        <span className='ml-2 font-mono'>\n                          {geometryInfo.length}\n                        </span>\n                      </li>\n                    )}\n                    {geometryInfo.area && (\n                      <li className='flex items-center'>\n                        <Maximize className='h-4 w-4 mr-2 text-muted-foreground' />\n                        <strong>Area:</strong>\n                        <span className='ml-2 font-mono'>\n                          {geometryInfo.area}\n                        </span>\n                      </li>\n                    )}\n                  </ul>\n                </div>\n              )}\n\n              <div>\n                <h4 className='font-semibold text-sm mb-2'>Style</h4>\n                <div className='p-3 bg-muted rounded-md'>\n                  <div className='flex items-center justify-between'>\n                    <div className='flex items-center'>\n                      <Palette className='h-4 w-4 mr-2 text-muted-foreground' />\n                      <span className='text-sm font-medium'>Color</span>\n                    </div>\n                    {isEditing ? (\n                      <input\n                        type='color'\n                        value={editableColor}\n                        onChange={(e) => setEditableColor(e.target.value)}\n                        className='w-10 h-8 p-1 bg-transparent border rounded-md cursor-pointer'\n                      />\n                    ) : (\n                      <div\n                        className='w-10 h-8 rounded-md border'\n                        style={{ backgroundColor: editableColor }}\n                      />\n                    )}\n                  </div>\n                </div>\n              </div>\n\n              <div>\n                <h4 className='font-semibold text-sm mb-2'>Vertices</h4>\n                <div className='text-xs p-3 bg-muted rounded-md overflow-auto max-h-60'>\n                  <pre>\n                    {JSON.stringify(\n                      editableVertices.map((v) => ({\n                        lat: v.latitude.toFixed(6),\n                        lng: v.longitude.toFixed(6),\n                      })),\n                      null,\n                      2,\n                    )}\n                  </pre>\n                </div>\n              </div>\n            </CardContent>\n            <CardFooter className='flex-shrink-0 flex justify-end gap-2'>\n              {isEditing ? (\n                <>\n                  <Button variant='outline' onClick={handleCancel}>\n                    Cancel\n                  </Button>\n                  <Button onClick={handleSave}>\n                    <Save className='h-4 w-4 mr-2' />\n                    Save\n                  </Button>\n                </>\n              ) : (\n                <>\n                  <Button\n                    variant='destructive'\n                    onClick={() => setDeleteAlertOpen(true)}\n                  >\n                    <Trash2 className='h-4 w-4 mr-2' />\n                    Delete\n                  </Button>\n                  <Button onClick={() => setIsEditing(true)}>\n                    <Pencil className='h-4 w-4 mr-2' />\n                    Modify\n                  </Button>\n                </>\n              )}\n            </CardFooter>\n          </>\n        )}\n      </Card>\n\n      <AlertDialog open={isDeleteAlertOpen} onOpenChange={setDeleteAlertOpen}>\n        <AlertDialogContent className='z-[999999]'>\n          <AlertDialogHeader>\n            <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>\n            <AlertDialogDescription>\n              This action cannot be undone. This will permanently delete this\n              feature.\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel>Cancel</AlertDialogCancel>\n            <AlertDialogAction onClick={handleDelete}>Delete</AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/map-draw/FeatureDrawingPreview.tsx",
    "content": "import { useMapInteractionStore } from \"@/entities/map/store\";\nimport { Polyline, Polygon } from \"react-leaflet\";\n\nexport function DrawingPreview() {\n  const { drawingMode, currentVertices } = useMapInteractionStore();\n\n  if (currentVertices.length === 0) return null;\n\n  if (drawingMode === \"LINE\") {\n    return (\n      <Polyline positions={currentVertices} color='yellow' dashArray='5, 10' />\n    );\n  }\n\n  if (drawingMode === \"POLYGON\") {\n    return (\n      <Polygon positions={currentVertices} color='yellow' fillOpacity={0.2} />\n    );\n  }\n\n  return null;\n}\n"
  },
  {
    "path": "apps/client/src/features/map-draw/FeatureEditor.tsx",
    "content": "import { Marker } from \"react-leaflet\";\nimport L from \"leaflet\";\nimport { useMapInteractionStore } from \"@/entities/map/store\";\nimport { useMapDataStore } from \"@/entities/map/store\";\nimport { LatLng } from \"leaflet\";\n\nconst DraggableIcon = L.divIcon({\n  html: `<div class=\"w-4 h-4 bg-background border-2 border-blue-100 rounded-full cursor-move shadow-md\"></div>`,\n  className: \"\",\n  iconSize: [16, 16],\n  iconAnchor: [8, 8],\n});\n\nexport function FeatureEditor() {\n  const { selectedFeature } = useMapInteractionStore();\n  const { updateFeature } = useMapDataStore();\n\n  if (!selectedFeature) {\n    return null;\n  }\n\n  const handleDragEnd = (index: number, newPosition: LatLng) => {\n    if (!selectedFeature) return;\n\n    const updatedVertices = selectedFeature.vertices.map((vertex, i) =>\n      i === index\n        ? { ...vertex, latitude: newPosition.lat, longitude: newPosition.lng }\n        : vertex,\n    );\n\n    const payload = {\n      vertices: updatedVertices.map((v) => ({\n        latitude: v.latitude,\n        longitude: v.longitude,\n      })),\n    };\n\n    updateFeature(selectedFeature.id, payload);\n  };\n\n  return (\n    <>\n      {selectedFeature.vertices.map((vertex, index) => (\n        <Marker\n          key={`edit-vertex-${selectedFeature.id}-${vertex.id || index}`}\n          position={[vertex.latitude, vertex.longitude]}\n          icon={DraggableIcon}\n          draggable={true}\n          eventHandlers={{\n            dragend: (e) => {\n              handleDragEnd(index, e.target.getLatLng());\n            },\n          }}\n        />\n      ))}\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/map-draw/FeatureRenderer.tsx",
    "content": "import { CircleMarker, Polygon, Polyline } from \"react-leaflet\";\nimport { LatLngExpression } from \"leaflet\";\nimport { FeatureWithVertices } from \"@/entities/map/types\";\nimport { useMapInteractionStore } from \"@/entities/map/store\";\nimport { useMemo } from \"react\";\n\ninterface FeatureRendererProps {\n  feature: FeatureWithVertices;\n}\n\nexport function FeatureRenderer({ feature }: FeatureRendererProps) {\n  const setSelectedFeature = useMapInteractionStore(\n    (state) => state.setSelectedFeature,\n  );\n  const positions = feature.vertices\n    .sort((a, b) => a.sequence - b.sequence)\n    .map((v) => [v.latitude, v.longitude] as LatLngExpression);\n\n  const featureColor = useMemo(() => {\n    try {\n      if (feature.style_properties) {\n        const styles = JSON.parse(feature.style_properties);\n        return styles.color || \"#6ec7f0\";\n      }\n    } catch (e) {\n      console.error(\"Failed to parse style_properties\", e);\n    }\n    return \"#6ec7f0\";\n  }, [feature.style_properties]);\n\n  if (positions.length === 0) {\n    return null;\n  }\n\n  const eventHandlers = {\n    click: () => {\n      setSelectedFeature(feature);\n    },\n  };\n\n  const pathOptions = { color: featureColor };\n\n  switch (feature.feature_type) {\n    case \"POINT\":\n      return (\n        <CircleMarker\n          center={positions[0]}\n          pathOptions={pathOptions}\n          radius={6}\n          eventHandlers={eventHandlers}\n        />\n      );\n\n    case \"LINE\":\n      return (\n        <Polyline\n          positions={positions}\n          pathOptions={pathOptions}\n          eventHandlers={eventHandlers}\n        />\n      );\n\n    case \"POLYGON\":\n      return (\n        <Polygon\n          positions={positions}\n          pathOptions={pathOptions}\n          eventHandlers={eventHandlers}\n        />\n      );\n\n    default:\n      return null;\n  }\n}\n"
  },
  {
    "path": "apps/client/src/features/map-draw/LayerDialog.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport {\n  DialogHeader,\n  DialogFooter,\n  DialogTrigger,\n  Dialog,\n  DialogContent,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { useMapDataStore } from \"@/entities/map/store\";\nimport { MapLayer, LayerPayload } from \"@/entities/map/types\";\nimport { useState, useEffect, useCallback } from \"react\";\n\ninterface LayerDialogProps {\n  children?: React.ReactNode;\n  layer?: MapLayer;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}\n\nexport function LayerDialog({\n  children,\n  layer,\n  open: openProp,\n  onOpenChange: onOpenChangeProp,\n}: LayerDialogProps) {\n  const [internalOpen, setInternalOpen] = useState(false);\n  const isControlled = openProp !== undefined;\n  const open = isControlled ? openProp : internalOpen;\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!isControlled) setInternalOpen(next);\n      onOpenChangeProp?.(next);\n    },\n    [isControlled, onOpenChangeProp],\n  );\n  const [name, setName] = useState(\"\");\n  const [description, setDescription] = useState(\"\");\n  const { addLayer, editLayer } = useMapDataStore();\n\n  useEffect(() => {\n    if (layer) {\n      setName(layer.name);\n      setDescription(layer.description || \"\");\n    } else {\n      setName(\"\");\n      setDescription(\"\");\n    }\n  }, [layer, open]);\n\n  const handleSubmit = () => {\n    const payload: LayerPayload = { name, description };\n    if (layer) {\n      editLayer(layer.id, payload);\n    } else {\n      addLayer(payload);\n    }\n    setOpen(false);\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      {children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}\n      <DialogContent className='sm:max-w-[425px] z-[999999]'>\n        <DialogHeader>\n          <DialogTitle>{layer ? \"Edit Layer\" : \"Create New Layer\"}</DialogTitle>\n        </DialogHeader>\n        <div className='grid gap-4 py-4'>\n          <div className='grid grid-cols-4 items-center gap-4'>\n            <Label htmlFor='name' className='text-right'>\n              Name\n            </Label>\n            <Input\n              id='name'\n              value={name}\n              onChange={(e) => setName(e.target.value)}\n              className='col-span-3'\n            />\n          </div>\n          <div className='grid grid-cols-4 items-center gap-4'>\n            <Label htmlFor='description' className='text-right'>\n              Description\n            </Label>\n            <Input\n              id='description'\n              value={description}\n              onChange={(e) => setDescription(e.target.value)}\n              className='col-span-3'\n            />\n          </div>\n        </div>\n        <DialogFooter>\n          <Button onClick={handleSubmit}>Save changes</Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/map-draw/LayerSidebar.tsx",
    "content": "import { useState } from \"react\";\nimport { Plus, ChevronLeft, MoreHorizontal } from \"lucide-react\";\n\nimport { LayerDialog } from \"./LayerDialog\";\nimport { useMapDataStore } from \"@/entities/map/store\";\nimport { MapLayer } from \"@/entities/map/types\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\ntype LayerDialogState =\n  | { kind: \"closed\" }\n  | { kind: \"add\" }\n  | { kind: \"edit\"; layer: MapLayer };\n\nexport function LayerSidebar() {\n  const { layers, activeLayerId, setActiveLayer, removeLayer } =\n    useMapDataStore();\n  const [isCollapsed, setIsCollapsed] = useState(false);\n  const [layerToDelete, setLayerToDelete] = useState<number | null>(null);\n  const [layerDialog, setLayerDialog] = useState<LayerDialogState>({\n    kind: \"closed\",\n  });\n\n  const toggleSidebar = () => setIsCollapsed(!isCollapsed);\n\n  const handleDeleteConfirm = () => {\n    if (layerToDelete) {\n      removeLayer(layerToDelete);\n      setLayerToDelete(null);\n    }\n  };\n\n  const selectedLayerName =\n    layers.find((l) => l.id === layerToDelete)?.name || \"\";\n\n  const layerDialogOpen = layerDialog.kind !== \"closed\";\n  const layerDialogLayer =\n    layerDialog.kind === \"edit\" ? layerDialog.layer : undefined;\n\n  return (\n    <>\n      <div\n        className={cn(\n          \"absolute top-[48px] left-0 border-r h-[calc(100%-48px)] w-64 bg-background/80 z-[999] p-4 flex flex-col shadow-lg backdrop-blur-sm transition-all duration-300 ease-in-out overflow-hidden\",\n          isCollapsed && \"w-0 p-0 border-none\",\n        )}\n      >\n        <div className='flex justify-between items-center mb-4'>\n          <h2 className='text-md font-semibold'>Layers</h2>\n          <div className='flex items-center'>\n            <LayerDialog>\n              <Button variant='ghost' size='icon'>\n                <Plus className='h-4 w-4' />\n              </Button>\n            </LayerDialog>\n            <Button variant='ghost' size='icon' onClick={toggleSidebar}>\n              <ChevronLeft className='h-5 w-5' />\n            </Button>\n          </div>\n        </div>\n        <div className='flex-grow overflow-y-auto'>\n          <ul className='space-y-2'>\n            {layers.map((layer) => (\n              <li\n                key={layer.id}\n                onClick={() => setActiveLayer(layer.id)}\n                className={cn(\n                  \"px-2 py-1 cursor-pointer hover:bg-muted flex justify-between items-center gap-2\",\n                  activeLayerId === layer.id && \"bg-muted\",\n                )}\n              >\n                <span className='truncate text-sm min-w-0'>{layer.name}</span>\n                <DropdownMenu>\n                  <DropdownMenuTrigger asChild>\n                    <Button\n                      variant='ghost'\n                      size='icon'\n                      className='shrink-0'\n                      aria-label={`Options for ${layer.name}`}\n                      onClick={(e) => e.stopPropagation()}\n                    >\n                      <MoreHorizontal className='h-4 w-4' />\n                    </Button>\n                  </DropdownMenuTrigger>\n                  <DropdownMenuContent align='end' className='z-[1002]'>\n                    <DropdownMenuItem\n                      onSelect={() => setLayerDialog({ kind: \"edit\", layer })}\n                    >\n                      Edit layer\n                    </DropdownMenuItem>\n                    <DropdownMenuSeparator />\n                    <DropdownMenuItem\n                      variant='destructive'\n                      onSelect={() => setLayerToDelete(layer.id)}\n                    >\n                      Delete layer\n                    </DropdownMenuItem>\n                  </DropdownMenuContent>\n                </DropdownMenu>\n              </li>\n            ))}\n          </ul>\n        </div>\n      </div>\n\n      <LayerDialog\n        layer={layerDialogLayer}\n        open={layerDialogOpen}\n        onOpenChange={(open) => {\n          if (!open) setLayerDialog({ kind: \"closed\" });\n        }}\n      />\n\n      {isCollapsed && (\n        <DropdownMenu>\n          <DropdownMenuTrigger asChild>\n            <Button\n              variant='outline'\n              size='icon'\n              className='absolute top-[56px] left-2 z-[1001] bg-background/80 backdrop-blur-sm'\n              aria-label='Collapsed sidebar options'\n            >\n              <MoreHorizontal className='h-5 w-5' />\n            </Button>\n          </DropdownMenuTrigger>\n          <DropdownMenuContent align='start' className='z-[1002]'>\n            <DropdownMenuItem onSelect={() => toggleSidebar()}>\n              Expand sidebar\n            </DropdownMenuItem>\n          </DropdownMenuContent>\n        </DropdownMenu>\n      )}\n\n      <AlertDialog\n        open={!!layerToDelete}\n        onOpenChange={() => setLayerToDelete(null)}\n      >\n        <AlertDialogContent className='z-[999999]'>\n          <AlertDialogHeader>\n            <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>\n            <AlertDialogDescription>\n              This action cannot be undone. This will permanently delete the '\n              {selectedLayerName}' layer.\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel>Cancel</AlertDialogCancel>\n            <AlertDialogAction onClick={handleDeleteConfirm}>\n              Delete\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/map-draw/MapEvents.tsx",
    "content": "import { useMapInteractionStore, useMapDataStore } from \"@/entities/map/store\";\nimport { useMapEvents } from \"react-leaflet\";\n\nexport function MapEvents() {\n  const { drawingMode, addVertex, setDrawingMode, clearDrawing } =\n    useMapInteractionStore();\n  const { addFeature } = useMapDataStore();\n  const activeLayerId = useMapDataStore((state) => state.layer?.id);\n\n  useMapEvents({\n    click(e) {\n      if (!drawingMode || !activeLayerId) return;\n      if (drawingMode === \"POINT\") {\n        addFeature({\n          layer_id: activeLayerId,\n          feature_type: \"POINT\",\n          vertices: [{ latitude: e.latlng.lat, longitude: e.latlng.lng }],\n        });\n        setDrawingMode(null);\n      } else {\n        addVertex(e.latlng);\n      }\n    },\n    keydown(e) {\n      if (e.originalEvent.key === \"Escape\") clearDrawing();\n    },\n  });\n\n  return null;\n}\n"
  },
  {
    "path": "apps/client/src/features/map-draw/MapToolbar.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { useMapDataStore, useMapInteractionStore } from \"@/entities/map/store\";\nimport { MapPin, Milestone, Squircle, Check, X, Map, Satellite } from \"lucide-react\";\n\nexport function MapToolbar() {\n  const { addFeature } = useMapDataStore();\n  const {\n    drawingMode,\n    setDrawingMode,\n    currentVertices,\n    clearDrawing,\n    tileMapType,\n    setTileMapType,\n  } = useMapInteractionStore();\n  const activeLayerId = useMapDataStore((state) => state.layer?.id);\n\n  const handleFinishDrawing = () => {\n    if (!activeLayerId || !drawingMode || currentVertices.length < 1) return;\n    if (drawingMode !== \"POINT\" && currentVertices.length < 2) return;\n\n    addFeature({\n      layer_id: activeLayerId,\n      feature_type: drawingMode,\n      vertices: currentVertices.map((v) => ({\n        latitude: v.lat,\n        longitude: v.lng,\n      })),\n    });\n    clearDrawing();\n  };\n\n  return (\n    <div className='absolute top-14 right-4 z-[1000] flex flex-col gap-2 p-2 bg-background/80 rounded-md shadow-lg backdrop-blur-sm'>\n      <Button\n        onClick={() => setDrawingMode(\"POINT\")}\n        size='icon'\n        variant={drawingMode === \"POINT\" ? \"secondary\" : \"outline\"}\n        disabled={!activeLayerId}\n      >\n        <MapPin className='h-4 w-4' />\n      </Button>\n      <Button\n        onClick={() => setDrawingMode(\"LINE\")}\n        size='icon'\n        variant={drawingMode === \"LINE\" ? \"secondary\" : \"outline\"}\n        disabled={!activeLayerId}\n      >\n        <Milestone className='h-4 w-4' />\n      </Button>\n      <Button\n        onClick={() => setDrawingMode(\"POLYGON\")}\n        size='icon'\n        variant={drawingMode === \"POLYGON\" ? \"secondary\" : \"outline\"}\n        disabled={!activeLayerId}\n      >\n        <Squircle className='h-4 w-4' />\n      </Button>\n\n      <div className='border-t pt-2 mt-2 flex flex-col gap-2'>\n        <Button\n          onClick={() => setTileMapType(\"dark\")}\n          size='icon'\n          variant={tileMapType === \"dark\" ? \"secondary\" : \"outline\"}\n        >\n          <Map className='h-4 w-4' />\n        </Button>\n        <Button\n          onClick={() => setTileMapType(\"satellite\")}\n          size='icon'\n          variant={tileMapType === \"satellite\" ? \"secondary\" : \"outline\"}\n        >\n          <Satellite className='h-4 w-4' />\n        </Button>\n      </div>\n\n      {currentVertices.length > 0 &&\n        (drawingMode === \"LINE\" || drawingMode === \"POLYGON\") && (\n          <div className='flex flex-col gap-2 pt-2 mt-2 border-t'>\n            <Button onClick={handleFinishDrawing} size='icon' variant='default'>\n              <Check className='h-4 w-4' />\n            </Button>\n            <Button onClick={clearDrawing} size='icon' variant='ghost'>\n              <X className='h-4 w-4' />\n            </Button>\n          </div>\n        )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/map-entity/EntityDetailsPanel.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { X, MapPin, TabletSmartphone, Server, Minus } from \"lucide-react\";\nimport { useCallback, useEffect, useState } from \"react\";\nimport { getDeviceById } from \"@/entities/device/api\";\nimport { EntityAll } from \"@/entities/entity/types\";\nimport { EntityCard } from \"../entity/Card\";\nimport { useWebSocket, useWebSocketMessage } from \"../ws/WebSocketProvider\";\nimport { WebSocketMessage } from \"../ws/ws\";\nimport { ChangeStatePayload, StreamState } from \"../entity/AllEntities\";\nimport * as api from \"../../entities/entity/api\";\nimport { useMapEntityStore } from \"./store\";\nimport { cn } from \"@/lib/utils\";\n\ninterface EntityDetailsPanelProps {\n  isCollapsed: boolean;\n  onToggleCollapse: () => void;\n}\n\nexport function EntityDetailsPanel({\n  isCollapsed,\n  onToggleCollapse,\n}: EntityDetailsPanelProps) {\n  const { selectedEntity, setSelectedEntity } = useMapEntityStore();\n  const [device, setDevice] = useState({\n    name: \"\",\n    model: \"\",\n    manufacturer: \"\",\n  });\n  const [entities, setEntities] = useState<EntityAll[]>([]);\n  const [streamsState, setStreamsState] = useState<StreamState[]>([]);\n  const { wsManager, isConnected } = useWebSocket();\n\n  const getAllEntities = async () => {\n    try {\n      const response = await api.getAllEntities();\n      setEntities(response.data);\n    } catch (error) {\n      console.error(\"Failed to fetch all entities:\", error);\n    }\n  };\n\n  const handleMessage = useCallback(\n    (msg: WebSocketMessage) => {\n      try {\n        if (msg.type === \"stream_state\") {\n          const loads = msg.payload as StreamState[];\n          setStreamsState(loads);\n        }\n\n        if (msg.type == \"change_state\") {\n          const index = entities.findIndex((item) => {\n            return (\n              item.entity_id == (msg.payload as ChangeStatePayload).entity_id\n            );\n          });\n\n          if (index == -1) {\n            return false;\n          }\n\n          if (entities[index].state) {\n            entities[index].state = (msg.payload as ChangeStatePayload).state;\n\n            setEntities([...entities]);\n          }\n        }\n      } catch (err) {\n        console.error(\"Error handling signaling message:\", err);\n      }\n    },\n    [entities],\n  );\n\n  useEffect(() => {\n    if (wsManager && isConnected) {\n      wsManager.send({\n        type: \"get_all_stream_state\",\n        payload: {},\n      });\n\n      setInterval(() => {\n        wsManager.send({\n          type: \"get_all_stream_state\",\n          payload: {},\n        });\n      }, 5000);\n    }\n  }, [wsManager, isConnected]);\n\n  useWebSocketMessage(handleMessage);\n\n  useEffect(() => {\n    getAllEntities();\n  }, []);\n\n  const handleClose = () => {\n    setSelectedEntity(null);\n  };\n\n  const getDevice = async (device_id: number) => {\n    const get = await getDeviceById(device_id);\n\n    setDevice({\n      name: get.data.name as string,\n      model: get.data.model as string,\n      manufacturer: get.data.manufacturer as string,\n    });\n    setEntities(get.data.entities);\n  };\n\n  useEffect(() => {\n    if (selectedEntity) {\n      getDevice(selectedEntity.device_id);\n    }\n  }, [selectedEntity]);\n\n  const handleToggleCollapse = (e: React.MouseEvent) => {\n    e.stopPropagation();\n    onToggleCollapse();\n  };\n\n  if (!selectedEntity) {\n    return null;\n  }\n\n  return (\n    <Card className='w-full flex flex-col max-h-full bg-background'>\n      <CardHeader\n        onClick={isCollapsed ? onToggleCollapse : undefined}\n        className={cn(\"flex-shrink-0\", isCollapsed && \"cursor-pointer\")}\n      >\n        <div className='flex justify-between items-start'>\n          <div>\n            <CardTitle>\n              {selectedEntity.friendly_name || selectedEntity.entity_id}\n            </CardTitle>\n            <CardDescription>{selectedEntity.entity_id}</CardDescription>\n          </div>\n          <div className='flex items-center'>\n            <Button variant='ghost' size='icon' onClick={handleToggleCollapse}>\n              <Minus className='h-4 w-4' />\n            </Button>\n            <Button variant='ghost' size='icon' onClick={handleClose}>\n              <X className='h-4 w-4' />\n            </Button>\n          </div>\n        </div>\n      </CardHeader>\n      {!isCollapsed && (\n        <CardContent className='flex-grow space-y-4 overflow-y-auto'>\n          <div>\n            <h4 className='font-semibold text-sm mb-2'>State</h4>\n            <div className='text-sm p-3 bg-muted rounded-md overflow-scroll'>\n              <p>\n                <strong>State:</strong> {selectedEntity.state?.state || \"N/A\"}\n              </p>\n              <p className='text-xs text-muted-foreground'>\n                <strong>Last Updated:</strong>{\" \"}\n                {selectedEntity.state\n                  ? new Date(selectedEntity.state.last_updated).toLocaleString()\n                  : \"N/A\"}\n              </p>\n            </div>\n          </div>\n\n          <div>\n            <h4 className='font-semibold text-sm mb-2'>Details</h4>\n            <ul className='text-sm space-y-2'>\n              <li className='flex items-center'>\n                <MapPin className='h-4 w-4 mr-2 text-muted-foreground' />\n                <strong>Type:</strong>{\" \"}\n                <span className='ml-2'>\n                  {selectedEntity.entity_type || \"N/A\"}\n                </span>\n              </li>\n              <li className='flex items-center'>\n                <Server className='h-4 w-4 mr-2 text-muted-foreground' />\n                <strong>Platform:</strong>{\" \"}\n                <span className='ml-2'>{selectedEntity.platform || \"N/A\"}</span>\n              </li>\n              <li className='flex items-center'>\n                <TabletSmartphone className='h-4 w-4 mr-2 text-muted-foreground' />\n                <strong>Device ID:</strong>{\" \"}\n                <span className='ml-2'>\n                  {selectedEntity.device_id ?? \"N/A\"}\n                </span>\n              </li>\n            </ul>\n          </div>\n\n          {selectedEntity.configuration && (\n            <div>\n              <h4 className='font-semibold text-sm mb-2'>Configuration</h4>\n              <pre className='text-xs p-3 bg-muted rounded-md overflow-auto'>\n                {JSON.stringify(selectedEntity.configuration, null, 2)}\n              </pre>\n            </div>\n          )}\n\n          <div>\n            <h4 className='font-semibold text-base mb-2'>Device</h4>\n            <ul className='text-sm space-y-2'>\n              <li className='flex items-center'>\n                <strong>Name:</strong>{\" \"}\n                <span className='ml-2'>{device.name || \"N/A\"}</span>\n              </li>\n              <li className='flex items-center'>\n                <strong>Model:</strong>{\" \"}\n                <span className='ml-2'>{device.model || \"N/A\"}</span>\n              </li>\n              <li className='flex items-center'>\n                <strong>Manufacturer:</strong>{\" \"}\n                <span className='ml-2'>{device.manufacturer || \"N/A\"}</span>\n              </li>\n            </ul>\n          </div>\n\n          {entities.map((item) => (\n            <div key={item.entity_id}>\n              <h4 className='font-semibold text-sm mb-2'>\n                Entity: {item.friendly_name}\n              </h4>\n              <EntityCard item={item} streamsState={streamsState} />\n\n              <pre className='text-xs p-3 bg-muted rounded-md overflow-auto mt-2'>\n                {JSON.stringify(item, null, 2)}\n              </pre>\n            </div>\n          ))}\n        </CardContent>\n      )}\n    </Card>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/map-entity/render.tsx",
    "content": "import { getAllEntitiesFilter } from \"@/entities/entity/api\";\nimport { EntityAll } from \"@/entities/entity/types\";\nimport L from \"leaflet\";\nimport { MapPin } from \"lucide-react\";\nimport { useState, useEffect, createElement } from \"react\";\nimport { renderToStaticMarkup } from \"react-dom/server\";\nimport { parseGpsState } from \"../gps/parseGps\";\nimport { useMapEntityStore } from \"./store\";\nimport { Marker } from \"react-leaflet\";\n\nconst MARKER_W = 32;\nconst MARKER_H = 36;\nconst MARKER_SEL_W = 40;\nconst MARKER_SEL_H = 44;\n\nconst PIN_SHADOW =\n  \"filter:drop-shadow(0 1px 2px rgba(0,0,0,0.35)) drop-shadow(0 0 1px rgba(0,0,0,0.25))\";\n\nfunction lucideMapPinMarkup(size: number): string {\n  return renderToStaticMarkup(\n    createElement(MapPin, {\n      size,\n      strokeWidth: 1.5,\n      absoluteStrokeWidth: true,\n      color: \"#ffffff\",\n      fill: \"none\",\n      strokeLinecap: \"round\",\n      strokeLinejoin: \"round\",\n    }),\n  );\n}\n\nfunction entityMarkerHtml(selected: boolean): string {\n  const w = selected ? MARKER_SEL_W : MARKER_W;\n  const h = selected ? MARKER_SEL_H : MARKER_H;\n  const pinSize = selected ? 28 : 22;\n  return `<div style=\"width:${w}px;height:${h}px;display:flex;align-items:flex-end;justify-content:center;pointer-events:none;${PIN_SHADOW}\">${lucideMapPinMarkup(pinSize)}</div>`;\n}\n\nconst entityMarkerIconDefault = L.divIcon({\n  className: \"leaflet-div-icon entity-map-marker-icon\",\n  html: entityMarkerHtml(false),\n  iconSize: [MARKER_W, MARKER_H],\n  iconAnchor: [MARKER_W / 2, MARKER_H],\n});\n\nconst entityMarkerIconSelected = L.divIcon({\n  className: \"leaflet-div-icon entity-map-marker-icon\",\n  html: entityMarkerHtml(true),\n  iconSize: [MARKER_SEL_W, MARKER_SEL_H],\n  iconAnchor: [MARKER_SEL_W / 2, MARKER_SEL_H],\n});\n\nexport function MapEntityRender() {\n  const [entities, setEntities] = useState<EntityAll[]>([]);\n  const setSelectedEntity = useMapEntityStore(\n    (state) => state.setSelectedEntity,\n  );\n  const selectedEntityId = useMapEntityStore(\n    (state) => state.selectedEntity?.id ?? null,\n  );\n\n  const fetchEntities = async () => {\n    try {\n      const response = await getAllEntitiesFilter(\"GPS\");\n      setEntities(response.data);\n    } catch (error) {\n      console.error(\"Failed to fetch entities:\", error);\n    }\n  };\n\n  useEffect(() => {\n    fetchEntities();\n  }, []);\n\n  return (\n    <>\n      {entities.map((entity) => {\n        const markerPosition = parseGpsState(entity.state?.state);\n        if (!markerPosition) return null;\n\n        return (\n          <Marker\n            key={entity.id}\n            position={markerPosition}\n            icon={\n              entity.id === selectedEntityId\n                ? entityMarkerIconSelected\n                : entityMarkerIconDefault\n            }\n            eventHandlers={{\n              click: () => {\n                setSelectedEntity(entity);\n              },\n            }}\n          />\n        );\n      })}\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/map-entity/store.ts",
    "content": "import { create } from \"zustand\";\nimport type { EntityAll } from \"@/entities/entity/types\";\n\ninterface MapEntityState {\n  selectedEntity: EntityAll | null;\n  setSelectedEntity: (entity: EntityAll | null) => void;\n}\n\nexport const useMapEntityStore = create<MapEntityState>((set) => ({\n  selectedEntity: null,\n  setSelectedEntity: (entity) => set({ selectedEntity: entity }),\n}));\n"
  },
  {
    "path": "apps/client/src/features/recording/RecordingButton.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { Circle, Square, Loader2 } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { useRecordingStore } from \"@/entities/recording/store\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { DropdownMenuItem } from \"@/components/ui/dropdown-menu\";\n\ninterface RecordingButtonProps {\n  topic: string;\n  className?: string;\n}\n\nexport function RecordingButton({ topic, className }: RecordingButtonProps) {\n  const {\n    isRecording,\n    getActiveRecordingId,\n    startRecording,\n    stopRecording,\n    fetchActiveRecordings,\n  } = useRecordingStore();\n\n  const [isLoading, setIsLoading] = useState(false);\n  const recording = isRecording(topic);\n  const recordingId = getActiveRecordingId(topic);\n\n  useEffect(() => {\n    fetchActiveRecordings();\n  }, [fetchActiveRecordings]);\n\n  const handleClick = async () => {\n    setIsLoading(true);\n    try {\n      if (recording && recordingId) {\n        await stopRecording(recordingId);\n      } else {\n        await startRecording(topic);\n      }\n    } catch (error) {\n      console.error(\"Recording action failed:\", error);\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <Button\n            variant={recording ? \"destructive\" : \"outline\"}\n            size='icon'\n            onClick={handleClick}\n            disabled={isLoading}\n            className={cn(\"relative h-8 w-8\", className)}\n          >\n            {isLoading ? (\n              <Loader2 className='h-4 w-4 animate-spin' />\n            ) : recording ? (\n              <>\n                <Square className='h-4 w-4' />\n                <span className='absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-red-500 animate-pulse' />\n              </>\n            ) : (\n              <Circle className='h-4 w-4 fill-red-500 text-red-500' />\n            )}\n          </Button>\n        </TooltipTrigger>\n        <TooltipContent>\n          <p>{recording ? \"Stop Recording\" : \"Start Recording\"}</p>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n}\n\nexport function RecordingMenuItem({ topic, className }: RecordingButtonProps) {\n  const {\n    isRecording,\n    getActiveRecordingId,\n    startRecording,\n    stopRecording,\n    fetchActiveRecordings,\n  } = useRecordingStore();\n\n  const [isLoading, setIsLoading] = useState(false);\n  const recording = isRecording(topic);\n  const recordingId = getActiveRecordingId(topic);\n\n  useEffect(() => {\n    fetchActiveRecordings();\n  }, [fetchActiveRecordings]);\n\n  const handleClick = async () => {\n    setIsLoading(true);\n    try {\n      if (recording && recordingId) {\n        await stopRecording(recordingId);\n      } else {\n        await startRecording(topic);\n      }\n    } catch (error) {\n      console.error(\"Recording action failed:\", error);\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  return (\n    <DropdownMenuItem\n      onClick={handleClick}\n      disabled={isLoading}\n      className={cn(\"flex items-center gap-2\", className)}\n    >\n      {isLoading ? (\n        <Loader2 className='h-4 w-4 animate-spin' />\n      ) : recording ? (\n        <>\n          <Square className='h-4 w-4' />\n          <span>Stop Recording</span>\n          <span className='ml-auto h-2 w-2 rounded-full bg-red-500 animate-pulse' />\n        </>\n      ) : (\n        <>\n          <Circle className='h-4 w-4 fill-red-500 text-red-500' />\n          <span>Start Recording</span>\n        </>\n      )}\n    </DropdownMenuItem>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/recording/RecordingsList.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\nimport { Play, Trash2, Loader2, RefreshCw, Square } from \"lucide-react\";\nimport { useRecordingStore } from \"@/entities/recording/store\";\nimport { VideoPlaybackDialog } from \"./VideoPlaybackDialog\";\nimport { formatSimpleDateTime } from \"@/lib/time\";\nimport type { Recording } from \"@/entities/recording/types\";\n\nexport function RecordingsList() {\n  const {\n    recordings,\n    isLoading,\n    fetchRecordings,\n    deleteRecording,\n    stopRecording,\n  } = useRecordingStore();\n\n  const [selectedRecording, setSelectedRecording] = useState<number | null>(\n    null,\n  );\n  const [deletingId, setDeletingId] = useState<number | null>(null);\n\n  useEffect(() => {\n    fetchRecordings();\n  }, [fetchRecordings]);\n\n  const handleDelete = async (id: number) => {\n    setDeletingId(id);\n    try {\n      await deleteRecording(id);\n    } finally {\n      setDeletingId(null);\n    }\n  };\n\n  const handleStopRecording = async (id: number) => {\n    try {\n      await stopRecording(id);\n    } catch (error) {\n      console.error(\"Failed to stop recording:\", error);\n    }\n  };\n\n  const getStatusBadge = (status: string) => {\n    switch (status) {\n      case \"completed\":\n        return <Badge variant='default'>Completed</Badge>;\n      case \"recording\":\n        return (\n          <Badge variant='destructive' className='animate-pulse'>\n            Recording\n          </Badge>\n        );\n      case \"failed\":\n        return <Badge variant='secondary'>Failed</Badge>;\n      default:\n        return <Badge variant='outline'>{status}</Badge>;\n    }\n  };\n\n  return (\n    <Card>\n      <CardHeader className='flex flex-row items-center justify-between'>\n        <div>\n          <CardTitle>Recording History</CardTitle>\n          <CardDescription></CardDescription>\n        </div>\n        <Button\n          variant='outline'\n          size='icon'\n          onClick={() => fetchRecordings()}\n          disabled={isLoading}\n        >\n          <RefreshCw className={`h-4 w-4 ${isLoading ? \"animate-spin\" : \"\"}`} />\n        </Button>\n      </CardHeader>\n      <CardContent>\n        {isLoading && recordings.length === 0 ? (\n          <div className='flex justify-center py-8'>\n            <Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />\n          </div>\n        ) : recordings.length === 0 ? (\n          <div className='text-center py-8 text-muted-foreground'>\n            No recordings yet\n          </div>\n        ) : (\n          <Table>\n            <TableHeader>\n              <TableRow>\n                <TableHead>Topic</TableHead>\n                <TableHead>Type</TableHead>\n                <TableHead>Duration</TableHead>\n                <TableHead>Size</TableHead>\n                <TableHead>Status</TableHead>\n                <TableHead>Recorded At</TableHead>\n                <TableHead className='text-right'>Actions</TableHead>\n              </TableRow>\n            </TableHeader>\n            <TableBody>\n              {recordings.map((recording) => (\n                <RecordingRow\n                  key={recording.id}\n                  recording={recording}\n                  onPlay={() => setSelectedRecording(recording.id)}\n                  onDelete={() => handleDelete(recording.id)}\n                  onStop={() => handleStopRecording(recording.id)}\n                  isDeleting={deletingId === recording.id}\n                  getStatusBadge={getStatusBadge}\n                />\n              ))}\n            </TableBody>\n          </Table>\n        )}\n      </CardContent>\n\n      <VideoPlaybackDialog\n        recordingId={selectedRecording}\n        open={selectedRecording !== null}\n        onClose={() => setSelectedRecording(null)}\n      />\n    </Card>\n  );\n}\n\ninterface RecordingRowProps {\n  recording: Recording;\n  onPlay: () => void;\n  onDelete: () => void;\n  onStop: () => void;\n  isDeleting: boolean;\n  getStatusBadge: (status: string) => React.ReactNode;\n}\n\nfunction RecordingRow({\n  recording,\n  onPlay,\n  onDelete,\n  onStop,\n  isDeleting,\n  getStatusBadge,\n}: RecordingRowProps) {\n  return (\n    <TableRow>\n      <TableCell className='font-medium max-w-[200px] truncate'>\n        {recording.topic}\n      </TableCell>\n      <TableCell>\n        <Badge variant='outline'>{recording.media_type}</Badge>\n      </TableCell>\n      <TableCell>{formatDuration(recording.duration_ms)}</TableCell>\n      <TableCell>{formatFileSize(recording.file_size)}</TableCell>\n      <TableCell>{getStatusBadge(recording.status)}</TableCell>\n      <TableCell>{formatSimpleDateTime(recording.started_at)}</TableCell>\n      <TableCell className='text-right'>\n        <div className='flex gap-2 justify-end'>\n          {recording.status === \"recording\" ? (\n            <Button variant='destructive' size='icon' onClick={onStop}>\n              <Square className='h-4 w-4' />\n            </Button>\n          ) : (\n            <Button\n              variant='ghost'\n              size='icon'\n              onClick={onPlay}\n              disabled={recording.status !== \"completed\"}\n            >\n              <Play className='h-4 w-4' />\n            </Button>\n          )}\n          <AlertDialog>\n            <AlertDialogTrigger asChild>\n              <Button\n                variant='ghost'\n                size='icon'\n                disabled={isDeleting || recording.status === \"recording\"}\n              >\n                {isDeleting ? (\n                  <Loader2 className='h-4 w-4 animate-spin' />\n                ) : (\n                  <Trash2 className='h-4 w-4' />\n                )}\n              </Button>\n            </AlertDialogTrigger>\n            <AlertDialogContent>\n              <AlertDialogHeader>\n                <AlertDialogTitle>Delete Recording</AlertDialogTitle>\n                <AlertDialogDescription>\n                  Are you sure you want to delete this recording? This action\n                  cannot be undone.\n                </AlertDialogDescription>\n              </AlertDialogHeader>\n              <AlertDialogFooter>\n                <AlertDialogCancel>Cancel</AlertDialogCancel>\n                <AlertDialogAction onClick={onDelete}>Delete</AlertDialogAction>\n              </AlertDialogFooter>\n            </AlertDialogContent>\n          </AlertDialog>\n        </div>\n      </TableCell>\n    </TableRow>\n  );\n}\n\nfunction formatDuration(ms: number): string {\n  if (ms === 0) return \"-\";\n  const seconds = Math.floor(ms / 1000);\n  const minutes = Math.floor(seconds / 60);\n  const hours = Math.floor(minutes / 60);\n\n  if (hours > 0) {\n    return `${hours}:${(minutes % 60).toString().padStart(2, \"0\")}:${(seconds % 60).toString().padStart(2, \"0\")}`;\n  }\n  return `${minutes}:${(seconds % 60).toString().padStart(2, \"0\")}`;\n}\n\nfunction formatFileSize(bytes: number): string {\n  if (bytes === 0) return \"-\";\n  const k = 1024;\n  const sizes = [\"B\", \"KB\", \"MB\", \"GB\"];\n  const i = Math.floor(Math.log(bytes) / Math.log(k));\n  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i];\n}\n"
  },
  {
    "path": "apps/client/src/features/recording/VideoPlaybackDialog.tsx",
    "content": "import { useRef, useEffect } from \"react\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { useRecordingStore } from \"@/entities/recording/store\";\nimport { getRecordingStreamUrl } from \"@/entities/recording/api\";\nimport { storage } from \"@/lib/storage\";\nimport { AudioWaveformPlayer } from \"./components/AudioWaveformPlayer\";\nimport { VideoControlBar } from \"./components/VideoControlBar\";\n\ninterface VideoPlaybackDialogProps {\n  recordingId: number | null;\n  open: boolean;\n  onClose: () => void;\n}\n\nexport function VideoPlaybackDialog({\n  recordingId,\n  open,\n  onClose,\n}: VideoPlaybackDialogProps) {\n  const videoRef = useRef<HTMLVideoElement>(null);\n  const { recordings } = useRecordingStore();\n  const recording = recordings.find((r) => r.id === recordingId);\n\n  const serverUrl = storage.getServerUrl() || \"\";\n  const token = storage.getToken() || \"\";\n\n  const streamUrl = recordingId\n    ? getRecordingStreamUrl(recordingId, serverUrl, token)\n    : null;\n\n  const isAudioOnly = recording?.media_type === \"audio\";\n\n  useEffect(() => {\n    if (open && videoRef.current && streamUrl && !isAudioOnly) {\n      videoRef.current.load();\n    }\n  }, [open, streamUrl, isAudioOnly]);\n\n  return (\n    <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>\n      <DialogContent className='max-w-4xl'>\n        <DialogHeader>\n          <DialogTitle className='truncate max-w-sm'>\n            {recording?.topic || \"Recording Playback\"}\n          </DialogTitle>\n        </DialogHeader>\n\n        {streamUrl ? (\n          isAudioOnly ? (\n            <AudioWaveformPlayer\n              src={streamUrl}\n              durationMs={recording?.duration_ms || 0}\n              className='py-2'\n            />\n          ) : (\n            <div className='space-y-2'>\n              <div className='aspect-video bg-black rounded-lg overflow-hidden'>\n                <video\n                  ref={videoRef}\n                  src={streamUrl}\n                  className='w-full h-full'\n                />\n              </div>\n              <VideoControlBar\n                videoRef={videoRef}\n                durationMs={recording?.duration_ms || 0}\n                src={streamUrl}\n              />\n            </div>\n          )\n        ) : (\n          <div className='aspect-video bg-black rounded-lg overflow-hidden'>\n            <div className='w-full h-full flex items-center justify-center text-muted-foreground'>\n              No recording selected\n            </div>\n          </div>\n        )}\n\n        {recording && (\n          <div className='text-sm text-muted-foreground space-y-1'>\n            <p>Duration: {formatDuration(recording.duration_ms)}</p>\n            <p>Size: {formatFileSize(recording.file_size)}</p>\n            <p>Type: {recording.media_type}</p>\n          </div>\n        )}\n      </DialogContent>\n    </Dialog>\n  );\n}\n\nfunction formatDuration(ms: number): string {\n  const seconds = Math.floor(ms / 1000);\n  const minutes = Math.floor(seconds / 60);\n  const hours = Math.floor(minutes / 60);\n\n  if (hours > 0) {\n    return `${hours}:${(minutes % 60).toString().padStart(2, \"0\")}:${(seconds % 60).toString().padStart(2, \"0\")}`;\n  }\n  return `${minutes}:${(seconds % 60).toString().padStart(2, \"0\")}`;\n}\n\nfunction formatFileSize(bytes: number): string {\n  if (bytes === 0) return \"0 B\";\n  const k = 1024;\n  const sizes = [\"B\", \"KB\", \"MB\", \"GB\"];\n  const i = Math.floor(Math.log(bytes) / Math.log(k));\n  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i];\n}\n"
  },
  {
    "path": "apps/client/src/features/recording/components/AudioWaveformPlayer.tsx",
    "content": "import { useRef, useState, useEffect, useCallback } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { useMediaPlayback } from \"../hooks/useMediaPlayback\";\nimport { useAudioWaveform } from \"../hooks/useAudioWaveform\";\nimport { PlaybackControls } from \"./PlaybackControls\";\nimport { WaveformCanvas } from \"./WaveformCanvas\";\nimport { TimeRuler } from \"./TimeRuler\";\n\ninterface AudioWaveformPlayerProps {\n  src: string;\n  durationMs: number;\n  className?: string;\n  autoPlay?: boolean;\n}\n\nexport function AudioWaveformPlayer({\n  src,\n  durationMs,\n  className,\n  autoPlay = false,\n}: AudioWaveformPlayerProps) {\n  const audioRef = useRef<HTMLAudioElement>(null);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [containerWidth, setContainerWidth] = useState(0);\n\n  const { waveformData, isLoading, error } = useAudioWaveform({ src });\n  const playback = useMediaPlayback(audioRef);\n\n  // Handle resize\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) return;\n\n    const updateWidth = () => {\n      setContainerWidth(container.clientWidth);\n    };\n\n    updateWidth();\n\n    const resizeObserver = new ResizeObserver(updateWidth);\n    resizeObserver.observe(container);\n\n    return () => {\n      resizeObserver.disconnect();\n    };\n  }, []);\n\n  // Auto-play on mount if enabled\n  useEffect(() => {\n    if (autoPlay && audioRef.current) {\n      audioRef.current.play().catch(console.error);\n    }\n  }, [autoPlay]);\n\n  const handleSeek = useCallback(\n    (progress: number) => {\n      const effectiveDuration = playback.duration || durationMs;\n      playback.seek(progress * effectiveDuration);\n    },\n    [playback, durationMs]\n  );\n\n  const handleTimeSeek = useCallback(\n    (timeMs: number) => {\n      playback.seek(timeMs);\n    },\n    [playback]\n  );\n\n  const effectiveDuration = playback.duration || durationMs;\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn(\"w-full space-y-3\", className)}\n    >\n      {/* Hidden audio element */}\n      <audio\n        ref={audioRef}\n        src={src}\n        preload='metadata'\n      />\n\n      {/* Playback Controls */}\n      <PlaybackControls\n        isPlaying={playback.isPlaying}\n        currentTimeMs={playback.currentTime}\n        durationMs={effectiveDuration}\n        onPlay={playback.play}\n        onPause={playback.pause}\n        onStop={playback.stop}\n      />\n\n      {/* Waveform */}\n      <div className='bg-black rounded-lg p-2'>\n        {isLoading ? (\n          <Skeleton className='w-full h-24 bg-white/10' />\n        ) : error ? (\n          <div className='w-full h-24 flex items-center justify-center text-white/50 text-sm'>\n            Failed to load waveform\n          </div>\n        ) : (\n          <WaveformCanvas\n            waveformData={waveformData}\n            width={containerWidth - 16}\n            height={96}\n            progress={playback.progress}\n            onSeek={handleSeek}\n          />\n        )}\n      </div>\n\n      {/* Time Ruler */}\n      <TimeRuler\n        durationMs={effectiveDuration}\n        currentTimeMs={playback.currentTime}\n        width={containerWidth}\n        onSeek={handleTimeSeek}\n        className='rounded'\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/recording/components/FrameTimeline.tsx",
    "content": "import { useRef, useState, useCallback } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\ninterface Frame {\n  timeMs: number;\n  dataUrl: string;\n}\n\ninterface FrameTimelineProps {\n  frames: Frame[];\n  isLoading: boolean;\n  progress: number;\n  durationMs: number;\n  onSeek?: (progress: number) => void;\n  className?: string;\n}\n\nexport function FrameTimeline({\n  frames,\n  isLoading,\n  progress,\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  durationMs,\n  onSeek,\n  className,\n}: FrameTimelineProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [isDragging, setIsDragging] = useState(false);\n\n  const calculateProgress = useCallback((clientX: number) => {\n    if (!containerRef.current) return 0;\n    const rect = containerRef.current.getBoundingClientRect();\n    const x = clientX - rect.left;\n    return Math.max(0, Math.min(1, x / rect.width));\n  }, []);\n\n  const handleMouseDown = useCallback(\n    (e: React.MouseEvent) => {\n      if (!onSeek) return;\n      setIsDragging(true);\n      onSeek(calculateProgress(e.clientX));\n    },\n    [onSeek, calculateProgress],\n  );\n\n  const handleMouseMove = useCallback(\n    (e: React.MouseEvent) => {\n      if (!isDragging || !onSeek) return;\n      onSeek(calculateProgress(e.clientX));\n    },\n    [isDragging, onSeek, calculateProgress],\n  );\n\n  const handleMouseUp = useCallback(() => {\n    setIsDragging(false);\n  }, []);\n\n  const handleMouseLeave = useCallback(() => {\n    if (isDragging) {\n      setIsDragging(false);\n    }\n  }, [isDragging]);\n\n  const handleTouchStart = useCallback(\n    (e: React.TouchEvent) => {\n      if (!onSeek) return;\n      setIsDragging(true);\n      onSeek(calculateProgress(e.touches[0].clientX));\n    },\n    [onSeek, calculateProgress],\n  );\n\n  const handleTouchMove = useCallback(\n    (e: React.TouchEvent) => {\n      if (!isDragging || !onSeek) return;\n      onSeek(calculateProgress(e.touches[0].clientX));\n    },\n    [isDragging, onSeek, calculateProgress],\n  );\n\n  const handleTouchEnd = useCallback(() => {\n    setIsDragging(false);\n  }, []);\n\n  if (isLoading) {\n    return (\n      <div className={cn(\"flex gap-1 overflow-hidden\", className)}>\n        {Array.from({ length: 10 }).map((_, i) => (\n          <Skeleton key={i} className='h-12 flex-1 min-w-[40px]' />\n        ))}\n      </div>\n    );\n  }\n\n  if (frames.length === 0) {\n    return (\n      <div\n        className={cn(\n          \"h-12 bg-muted/30 rounded flex items-center justify-center text-muted-foreground text-sm\",\n          className,\n        )}\n      >\n        No frames available\n      </div>\n    );\n  }\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn(\n        \"relative h-12 bg-black/20 rounded overflow-hidden cursor-pointer select-none\",\n        className,\n      )}\n      onMouseDown={handleMouseDown}\n      onMouseMove={handleMouseMove}\n      onMouseUp={handleMouseUp}\n      onMouseLeave={handleMouseLeave}\n      onTouchStart={handleTouchStart}\n      onTouchMove={handleTouchMove}\n      onTouchEnd={handleTouchEnd}\n    >\n      {/* Frame thumbnails */}\n      <div className='flex h-full'>\n        {frames.map((frame, i) => (\n          <div\n            key={i}\n            className='flex-1 min-w-0 h-full border-r border-black/20 last:border-r-0'\n          >\n            <img\n              src={frame.dataUrl}\n              alt={`Frame at ${formatTime(frame.timeMs)}`}\n              className='w-full h-full object-cover'\n              draggable={false}\n            />\n          </div>\n        ))}\n      </div>\n\n      {/* Progress overlay (darkens unplayed portion) */}\n      <div\n        className='absolute top-0 right-0 h-full bg-black/50 pointer-events-none transition-[left] duration-75'\n        style={{ left: `${progress * 100}%` }}\n      />\n\n      {/* Progress handle */}\n      <div\n        className='absolute top-0 h-full w-1 bg-primary pointer-events-none'\n        style={{ left: `calc(${progress * 100}% - 2px)` }}\n      >\n        <div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-primary shadow-md' />\n      </div>\n    </div>\n  );\n}\n\nfunction formatTime(ms: number): string {\n  const totalSeconds = Math.floor(ms / 1000);\n  const minutes = Math.floor(totalSeconds / 60);\n  const seconds = totalSeconds % 60;\n  return `${minutes}:${seconds.toString().padStart(2, \"0\")}`;\n}\n"
  },
  {
    "path": "apps/client/src/features/recording/components/PlaybackControls.tsx",
    "content": "import { Play, Pause, Square } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\ninterface PlaybackControlsProps {\n  isPlaying: boolean;\n  currentTimeMs: number;\n  durationMs: number;\n  onPlay: () => void;\n  onPause: () => void;\n  onStop: () => void;\n  className?: string;\n}\n\nexport function PlaybackControls({\n  isPlaying,\n  currentTimeMs,\n  durationMs,\n  onPlay,\n  onPause,\n  onStop,\n  className,\n}: PlaybackControlsProps) {\n  return (\n    <div className={cn(\"flex items-center gap-2\", className)}>\n      <Button\n        variant='ghost'\n        size='icon'\n        onClick={isPlaying ? onPause : onPlay}\n        className='h-8 w-8'\n      >\n        {isPlaying ? (\n          <Pause className='h-4 w-4' />\n        ) : (\n          <Play className='h-4 w-4' />\n        )}\n      </Button>\n      <Button\n        variant='ghost'\n        size='icon'\n        onClick={onStop}\n        className='h-8 w-8'\n      >\n        <Square className='h-4 w-4' />\n      </Button>\n      <span className='text-sm text-muted-foreground ml-2'>\n        {formatTime(currentTimeMs)} / {formatTime(durationMs)}\n      </span>\n    </div>\n  );\n}\n\nfunction formatTime(ms: number): string {\n  const totalSeconds = Math.floor(ms / 1000);\n  const minutes = Math.floor(totalSeconds / 60);\n  const seconds = totalSeconds % 60;\n  return `${minutes}:${seconds.toString().padStart(2, \"0\")}`;\n}\n"
  },
  {
    "path": "apps/client/src/features/recording/components/TimeRuler.tsx",
    "content": "import { useRef, useCallback, useMemo } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ninterface TimeRulerProps {\n  durationMs: number;\n  currentTimeMs: number;\n  width: number;\n  height?: number;\n  onSeek?: (timeMs: number) => void;\n  className?: string;\n}\n\nexport function TimeRuler({\n  durationMs,\n  currentTimeMs,\n  width,\n  height = 24,\n  onSeek,\n  className,\n}: TimeRulerProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  const handleClick = useCallback(\n    (e: React.MouseEvent<HTMLDivElement>) => {\n      if (!onSeek || !containerRef.current || durationMs <= 0) return;\n\n      const rect = containerRef.current.getBoundingClientRect();\n      const x = e.clientX - rect.left;\n      const progress = Math.max(0, Math.min(1, x / rect.width));\n      onSeek(progress * durationMs);\n    },\n    [onSeek, durationMs]\n  );\n\n  const ticks = useMemo(() => {\n    if (durationMs <= 0 || width <= 0) return [];\n\n    const result: Array<{\n      timeMs: number;\n      x: number;\n      isMajor: boolean;\n      label: string;\n    }> = [];\n\n    // Calculate appropriate tick interval based on duration\n    let majorInterval: number;\n    let minorInterval: number;\n\n    if (durationMs <= 30000) {\n      // <= 30 seconds\n      majorInterval = 10000;\n      minorInterval = 2000;\n    } else if (durationMs <= 120000) {\n      // <= 2 minutes\n      majorInterval = 30000;\n      minorInterval = 10000;\n    } else if (durationMs <= 600000) {\n      // <= 10 minutes\n      majorInterval = 60000;\n      minorInterval = 15000;\n    } else {\n      // > 10 minutes\n      majorInterval = 300000;\n      minorInterval = 60000;\n    }\n\n    for (let t = 0; t <= durationMs; t += minorInterval) {\n      const isMajor = t % majorInterval === 0;\n      const x = (t / durationMs) * width;\n\n      result.push({\n        timeMs: t,\n        x,\n        isMajor,\n        label: isMajor ? formatRulerTime(t) : \"\",\n      });\n    }\n\n    return result;\n  }, [durationMs, width]);\n\n  const progressX = durationMs > 0 ? (currentTimeMs / durationMs) * width : 0;\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn(\n        \"relative bg-muted/30 select-none cursor-pointer\",\n        className\n      )}\n      style={{ height, width: width || \"100%\" }}\n      onClick={handleClick}\n    >\n      {/* Tick marks */}\n      <svg\n        width={width}\n        height={height}\n        className='absolute top-0 left-0'\n      >\n        {ticks.map((tick, i) => (\n          <g key={i}>\n            <line\n              x1={tick.x}\n              y1={0}\n              x2={tick.x}\n              y2={tick.isMajor ? 8 : 4}\n              stroke='currentColor'\n              strokeOpacity={tick.isMajor ? 0.5 : 0.3}\n              strokeWidth={1}\n            />\n            {tick.label && (\n              <text\n                x={tick.x + 2}\n                y={height - 4}\n                fontSize={10}\n                fill='currentColor'\n                fillOpacity={0.6}\n              >\n                {tick.label}\n              </text>\n            )}\n          </g>\n        ))}\n      </svg>\n\n      {/* Current time indicator */}\n      <div\n        className='absolute top-0 w-0.5 h-full bg-primary'\n        style={{ left: progressX }}\n      />\n    </div>\n  );\n}\n\nfunction formatRulerTime(ms: number): string {\n  const totalSeconds = Math.floor(ms / 1000);\n  const minutes = Math.floor(totalSeconds / 60);\n  const seconds = totalSeconds % 60;\n  return `${minutes}:${seconds.toString().padStart(2, \"0\")}`;\n}\n"
  },
  {
    "path": "apps/client/src/features/recording/components/VideoControlBar.tsx",
    "content": "import { useRef, useState, useEffect, useCallback, RefObject } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useMediaPlayback } from \"../hooks/useMediaPlayback\";\nimport { useVideoFrames } from \"../hooks/useVideoFrames\";\nimport { PlaybackControls } from \"./PlaybackControls\";\nimport { FrameTimeline } from \"./FrameTimeline\";\nimport { TimeRuler } from \"./TimeRuler\";\n\ninterface VideoControlBarProps {\n  videoRef: RefObject<HTMLVideoElement | null>;\n  durationMs: number;\n  src: string;\n  className?: string;\n  frameCount?: number;\n}\n\nexport function VideoControlBar({\n  videoRef,\n  durationMs,\n  src,\n  className,\n  frameCount = 10,\n}: VideoControlBarProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [containerWidth, setContainerWidth] = useState(0);\n\n  const playback = useMediaPlayback(videoRef);\n  const { frames, isLoading } = useVideoFrames({\n    src,\n    durationMs,\n    frameCount,\n  });\n\n  // Handle resize\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) return;\n\n    const updateWidth = () => {\n      setContainerWidth(container.clientWidth);\n    };\n\n    updateWidth();\n\n    const resizeObserver = new ResizeObserver(updateWidth);\n    resizeObserver.observe(container);\n\n    return () => {\n      resizeObserver.disconnect();\n    };\n  }, []);\n\n  const handleSeek = useCallback(\n    (progress: number) => {\n      const effectiveDuration = playback.duration || durationMs;\n      playback.seek(progress * effectiveDuration);\n    },\n    [playback, durationMs],\n  );\n\n  const handleTimeSeek = useCallback(\n    (timeMs: number) => {\n      playback.seek(timeMs);\n    },\n    [playback],\n  );\n\n  const effectiveDuration = playback.duration || durationMs;\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn(\"w-full space-y-2 p-2 bg-black/80 rounded-lg\", className)}\n    >\n      {/* Playback Controls */}\n      <PlaybackControls\n        isPlaying={playback.isPlaying}\n        currentTimeMs={playback.currentTime}\n        durationMs={effectiveDuration}\n        onPlay={playback.play}\n        onPause={playback.pause}\n        onStop={playback.stop}\n        className='text-white'\n      />\n\n      {/* Frame Timeline */}\n      <FrameTimeline\n        frames={frames}\n        isLoading={isLoading}\n        progress={playback.progress}\n        durationMs={effectiveDuration}\n        onSeek={handleSeek}\n      />\n\n      {/* Time Ruler */}\n      <TimeRuler\n        durationMs={effectiveDuration}\n        currentTimeMs={playback.currentTime}\n        width={containerWidth - 16}\n        onSeek={handleTimeSeek}\n        className='text-white/80'\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/recording/components/WaveformCanvas.tsx",
    "content": "import { useRef, useEffect, useCallback } from \"react\";\nimport * as d3 from \"d3\";\nimport { cn } from \"@/lib/utils\";\n\ninterface WaveformCanvasProps {\n  waveformData: number[];\n  width: number;\n  height: number;\n  progress: number;\n  playedColor?: string;\n  unplayedColor?: string;\n  rulerColor?: string;\n  onSeek?: (progress: number) => void;\n  className?: string;\n}\n\nexport function WaveformCanvas({\n  waveformData,\n  width,\n  height,\n  progress,\n  playedColor = \"rgba(255, 255, 255, 1)\",\n  unplayedColor = \"rgba(255, 255, 255, 0.3)\",\n  rulerColor = \"#ef4444\",\n  onSeek,\n  className,\n}: WaveformCanvasProps) {\n  const svgRef = useRef<SVGSVGElement>(null);\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  const handleClick = useCallback(\n    (e: React.MouseEvent<HTMLDivElement>) => {\n      if (!onSeek || !containerRef.current) return;\n\n      const rect = containerRef.current.getBoundingClientRect();\n      const x = e.clientX - rect.left;\n      const newProgress = Math.max(0, Math.min(1, x / rect.width));\n      onSeek(newProgress);\n    },\n    [onSeek],\n  );\n\n  useEffect(() => {\n    if (!svgRef.current || !waveformData.length || width <= 0 || height <= 0) {\n      return;\n    }\n\n    const svg = d3.select(svgRef.current);\n    svg.selectAll(\"*\").remove();\n\n    const midY = height / 2;\n    const barWidth = Math.max(1, width / waveformData.length - 1);\n\n    // Create scales\n    const xScale = d3\n      .scaleLinear()\n      .domain([0, waveformData.length - 1])\n      .range([0, width]);\n\n    const yScale = d3\n      .scaleLinear()\n      .domain([0, 1])\n      .range([0, midY - 2]);\n\n    // Define clip path for played portion\n    const clipId = `waveform-clip-${Math.random().toString(36).slice(2)}`;\n    svg\n      .append(\"defs\")\n      .append(\"clipPath\")\n      .attr(\"id\", clipId)\n      .append(\"rect\")\n      .attr(\"x\", 0)\n      .attr(\"y\", 0)\n      .attr(\"width\", progress * width)\n      .attr(\"height\", height);\n\n    // Draw unplayed waveform (background)\n    const unplayedGroup = svg.append(\"g\").attr(\"fill\", unplayedColor);\n\n    waveformData.forEach((value, i) => {\n      const x = xScale(i);\n      const barHeight = yScale(value);\n\n      // Top bar (mirrored)\n      unplayedGroup\n        .append(\"rect\")\n        .attr(\"x\", x)\n        .attr(\"y\", midY - barHeight)\n        .attr(\"width\", barWidth)\n        .attr(\"height\", barHeight)\n        .attr(\"rx\", barWidth / 2);\n\n      // Bottom bar\n      unplayedGroup\n        .append(\"rect\")\n        .attr(\"x\", x)\n        .attr(\"y\", midY)\n        .attr(\"width\", barWidth)\n        .attr(\"height\", barHeight)\n        .attr(\"rx\", barWidth / 2);\n    });\n\n    // Draw played waveform (foreground with clip)\n    const playedGroup = svg\n      .append(\"g\")\n      .attr(\"fill\", playedColor)\n      .attr(\"clip-path\", `url(#${clipId})`);\n\n    waveformData.forEach((value, i) => {\n      const x = xScale(i);\n      const barHeight = yScale(value);\n\n      // Top bar (mirrored)\n      playedGroup\n        .append(\"rect\")\n        .attr(\"x\", x)\n        .attr(\"y\", midY - barHeight)\n        .attr(\"width\", barWidth)\n        .attr(\"height\", barHeight)\n        .attr(\"rx\", barWidth / 2);\n\n      // Bottom bar\n      playedGroup\n        .append(\"rect\")\n        .attr(\"x\", x)\n        .attr(\"y\", midY)\n        .attr(\"width\", barWidth)\n        .attr(\"height\", barHeight)\n        .attr(\"rx\", barWidth / 2);\n    });\n\n    // Draw center line\n    svg\n      .append(\"line\")\n      .attr(\"x1\", 0)\n      .attr(\"y1\", midY)\n      .attr(\"x2\", width)\n      .attr(\"y2\", midY)\n      .attr(\"stroke\", \"currentColor\")\n      .attr(\"stroke-opacity\", 0.1)\n      .attr(\"stroke-width\", 1);\n\n    // Draw red ruler (playhead)\n    const rulerX = progress * width;\n    svg\n      .append(\"line\")\n      .attr(\"x1\", rulerX)\n      .attr(\"y1\", 0)\n      .attr(\"x2\", rulerX)\n      .attr(\"y2\", height)\n      .attr(\"stroke\", rulerColor)\n      .attr(\"stroke-width\", 2);\n\n    // Draw ruler handle (triangle at top)\n    svg\n      .append(\"polygon\")\n      .attr(\"points\", `${rulerX - 6},0 ${rulerX + 6},0 ${rulerX},8`)\n      .attr(\"fill\", rulerColor);\n  }, [\n    waveformData,\n    width,\n    height,\n    progress,\n    playedColor,\n    unplayedColor,\n    rulerColor,\n  ]);\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn(\"relative cursor-pointer\", className)}\n      onClick={handleClick}\n      style={{ width, height }}\n    >\n      <svg\n        ref={svgRef}\n        width={width}\n        height={height}\n        className='absolute top-0 left-0'\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/recording/hooks/useAudioWaveform.ts",
    "content": "import { useState, useEffect, useRef } from \"react\";\n\ninterface UseAudioWaveformOptions {\n  src: string;\n  samples?: number;\n}\n\ninterface UseAudioWaveformReturn {\n  waveformData: number[];\n  isLoading: boolean;\n  error: Error | null;\n}\n\nexport function useAudioWaveform({\n  src,\n  samples = 200,\n}: UseAudioWaveformOptions): UseAudioWaveformReturn {\n  const [waveformData, setWaveformData] = useState<number[]>([]);\n  const [isLoading, setIsLoading] = useState(false);\n  const [error, setError] = useState<Error | null>(null);\n  const audioContextRef = useRef<AudioContext | null>(null);\n\n  useEffect(() => {\n    if (!src) {\n      setWaveformData([]);\n      return;\n    }\n\n    let cancelled = false;\n\n    const generateWaveform = async () => {\n      setIsLoading(true);\n      setError(null);\n\n      try {\n        // Fetch audio data\n        const response = await fetch(src);\n        if (!response.ok) {\n          throw new Error(`Failed to fetch audio: ${response.status}`);\n        }\n        const arrayBuffer = await response.arrayBuffer();\n\n        if (cancelled) return;\n\n        // Create or reuse AudioContext\n        const audioContext =\n          audioContextRef.current ?? new AudioContext();\n        audioContextRef.current = audioContext;\n\n        // Decode audio data\n        const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);\n\n        if (cancelled) return;\n\n        // Get channel data (use first channel or mix stereo)\n        const channelData = audioBuffer.getChannelData(0);\n        const secondChannel =\n          audioBuffer.numberOfChannels > 1\n            ? audioBuffer.getChannelData(1)\n            : null;\n\n        // Downsample by taking peak values in each segment\n        const blockSize = Math.floor(channelData.length / samples);\n        const waveform: number[] = [];\n\n        for (let i = 0; i < samples; i++) {\n          const start = i * blockSize;\n          let max = 0;\n\n          for (let j = start; j < start + blockSize && j < channelData.length; j++) {\n            let sample = Math.abs(channelData[j]);\n            if (secondChannel) {\n              sample = Math.max(sample, Math.abs(secondChannel[j]));\n            }\n            max = Math.max(max, sample);\n          }\n\n          waveform.push(max);\n        }\n\n        // Normalize to 0-1 range\n        const maxVal = Math.max(...waveform, 0.001);\n        const normalized = waveform.map((v) => v / maxVal);\n\n        if (!cancelled) {\n          setWaveformData(normalized);\n        }\n      } catch (err) {\n        if (!cancelled) {\n          setError(err instanceof Error ? err : new Error(String(err)));\n        }\n      } finally {\n        if (!cancelled) {\n          setIsLoading(false);\n        }\n      }\n    };\n\n    generateWaveform();\n\n    return () => {\n      cancelled = true;\n    };\n  }, [src, samples]);\n\n  // Cleanup AudioContext on unmount\n  useEffect(() => {\n    return () => {\n      if (audioContextRef.current) {\n        audioContextRef.current.close().catch(console.error);\n        audioContextRef.current = null;\n      }\n    };\n  }, []);\n\n  return {\n    waveformData,\n    isLoading,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/client/src/features/recording/hooks/useMediaPlayback.ts",
    "content": "import { useState, useEffect, useCallback, RefObject } from \"react\";\n\ninterface UseMediaPlaybackReturn {\n  isPlaying: boolean;\n  currentTime: number; // ms\n  duration: number; // ms\n  progress: number; // 0-1\n  play: () => void;\n  pause: () => void;\n  stop: () => void;\n  seek: (timeMs: number) => void;\n}\n\nexport function useMediaPlayback(\n  mediaRef: RefObject<HTMLMediaElement | null>\n): UseMediaPlaybackReturn {\n  const [isPlaying, setIsPlaying] = useState(false);\n  const [currentTime, setCurrentTime] = useState(0);\n  const [duration, setDuration] = useState(0);\n\n  useEffect(() => {\n    const media = mediaRef.current;\n    if (!media) return;\n\n    const handleTimeUpdate = () => {\n      setCurrentTime(media.currentTime * 1000);\n    };\n\n    const handleDurationChange = () => {\n      setDuration(media.duration * 1000);\n    };\n\n    const handlePlay = () => setIsPlaying(true);\n    const handlePause = () => setIsPlaying(false);\n    const handleEnded = () => {\n      setIsPlaying(false);\n      setCurrentTime(0);\n    };\n\n    const handleLoadedMetadata = () => {\n      setDuration(media.duration * 1000);\n    };\n\n    media.addEventListener(\"timeupdate\", handleTimeUpdate);\n    media.addEventListener(\"durationchange\", handleDurationChange);\n    media.addEventListener(\"loadedmetadata\", handleLoadedMetadata);\n    media.addEventListener(\"play\", handlePlay);\n    media.addEventListener(\"pause\", handlePause);\n    media.addEventListener(\"ended\", handleEnded);\n\n    // Initialize duration if already loaded\n    if (media.duration && !isNaN(media.duration)) {\n      setDuration(media.duration * 1000);\n    }\n\n    return () => {\n      media.removeEventListener(\"timeupdate\", handleTimeUpdate);\n      media.removeEventListener(\"durationchange\", handleDurationChange);\n      media.removeEventListener(\"loadedmetadata\", handleLoadedMetadata);\n      media.removeEventListener(\"play\", handlePlay);\n      media.removeEventListener(\"pause\", handlePause);\n      media.removeEventListener(\"ended\", handleEnded);\n    };\n  }, [mediaRef]);\n\n  const play = useCallback(() => {\n    const media = mediaRef.current;\n    if (media) {\n      media.play().catch((err) => {\n        console.error(\"Error playing media:\", err);\n      });\n    }\n  }, [mediaRef]);\n\n  const pause = useCallback(() => {\n    const media = mediaRef.current;\n    if (media) {\n      media.pause();\n    }\n  }, [mediaRef]);\n\n  const stop = useCallback(() => {\n    const media = mediaRef.current;\n    if (media) {\n      media.pause();\n      media.currentTime = 0;\n      setCurrentTime(0);\n      setIsPlaying(false);\n    }\n  }, [mediaRef]);\n\n  const seek = useCallback(\n    (timeMs: number) => {\n      const media = mediaRef.current;\n      if (media) {\n        const clampedTime = Math.max(0, Math.min(timeMs, duration));\n        media.currentTime = clampedTime / 1000;\n        setCurrentTime(clampedTime);\n      }\n    },\n    [mediaRef, duration]\n  );\n\n  const progress = duration > 0 ? currentTime / duration : 0;\n\n  return {\n    isPlaying,\n    currentTime,\n    duration,\n    progress,\n    play,\n    pause,\n    stop,\n    seek,\n  };\n}\n"
  },
  {
    "path": "apps/client/src/features/recording/hooks/useVideoFrames.ts",
    "content": "import { useState, useEffect, useRef } from \"react\";\n\ninterface Frame {\n  timeMs: number;\n  dataUrl: string;\n}\n\ninterface UseVideoFramesOptions {\n  src: string;\n  durationMs: number;\n  frameCount: number;\n  frameHeight?: number;\n}\n\ninterface UseVideoFramesReturn {\n  frames: Frame[];\n  isLoading: boolean;\n  error: Error | null;\n}\n\nexport function useVideoFrames({\n  src,\n  durationMs,\n  frameCount,\n  frameHeight = 48,\n}: UseVideoFramesOptions): UseVideoFramesReturn {\n  const [frames, setFrames] = useState<Frame[]>([]);\n  const [isLoading, setIsLoading] = useState(false);\n  const [error, setError] = useState<Error | null>(null);\n  const videoRef = useRef<HTMLVideoElement | null>(null);\n\n  useEffect(() => {\n    if (!src || durationMs <= 0 || frameCount <= 0) {\n      setFrames([]);\n      return;\n    }\n\n    let cancelled = false;\n\n    const extractFrames = async () => {\n      setIsLoading(true);\n      setError(null);\n\n      try {\n        // Create offscreen video element\n        const video = document.createElement(\"video\");\n        video.src = src;\n        video.crossOrigin = \"anonymous\";\n        video.preload = \"metadata\";\n        videoRef.current = video;\n\n        // Wait for video metadata to load\n        await new Promise<void>((resolve, reject) => {\n          video.onloadedmetadata = () => resolve();\n          video.onerror = () =>\n            reject(new Error(\"Failed to load video metadata\"));\n          video.load();\n        });\n\n        if (cancelled) return;\n\n        // Create canvas for frame capture\n        const aspectRatio = video.videoWidth / video.videoHeight;\n        const canvas = document.createElement(\"canvas\");\n        canvas.height = frameHeight;\n        canvas.width = Math.round(frameHeight * aspectRatio);\n        const ctx = canvas.getContext(\"2d\");\n\n        if (!ctx) {\n          throw new Error(\"Failed to get canvas context\");\n        }\n\n        const extractedFrames: Frame[] = [];\n        const interval = durationMs / (frameCount + 1);\n\n        for (let i = 1; i <= frameCount; i++) {\n          if (cancelled) break;\n\n          const timeMs = interval * i;\n          video.currentTime = timeMs / 1000;\n\n          // Wait for seek to complete\n          await new Promise<void>((resolve) => {\n            const onSeeked = () => {\n              video.removeEventListener(\"seeked\", onSeeked);\n              resolve();\n            };\n            video.addEventListener(\"seeked\", onSeeked);\n          });\n\n          if (cancelled) break;\n\n          // Capture frame\n          ctx.drawImage(video, 0, 0, canvas.width, canvas.height);\n          const dataUrl = canvas.toDataURL(\"image/jpeg\", 0.6);\n\n          extractedFrames.push({\n            timeMs,\n            dataUrl,\n          });\n        }\n\n        if (!cancelled) {\n          setFrames(extractedFrames);\n        }\n      } catch (err) {\n        if (!cancelled) {\n          setError(err instanceof Error ? err : new Error(String(err)));\n        }\n      } finally {\n        if (!cancelled) {\n          setIsLoading(false);\n        }\n      }\n    };\n\n    extractFrames();\n\n    return () => {\n      cancelled = true;\n      if (videoRef.current) {\n        videoRef.current.src = \"\";\n        videoRef.current = null;\n      }\n    };\n  }, [src, durationMs, frameCount, frameHeight]);\n\n  return {\n    frames,\n    isLoading,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/client/src/features/recording/index.ts",
    "content": "export { RecordingButton } from \"./RecordingButton\";\nexport { RecordingsList } from \"./RecordingsList\";\nexport { VideoPlaybackDialog } from \"./VideoPlaybackDialog\";\n"
  },
  {
    "path": "apps/client/src/features/role/RoleDialogs.tsx",
    "content": "import {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n  CreateRolePayload,\n  Role,\n  UpdateRolePayload,\n} from \"@/entities/role/types\";\nimport { FC, useState } from \"react\";\nimport { RoleForm } from \"./RoleForm\";\nimport { Button } from \"@/components/ui/button\";\nimport { DialogFooter } from \"@/components/ui/dialog\";\nimport { useRoleStore } from \"@/entities/role/store\";\n\n// Add Role Dialog\nexport const AddRoleDialog: FC<{\n  isOpen: boolean;\n  onOpenChange: (open: boolean) => void;\n}> = ({ isOpen, onOpenChange }) => {\n  const [isSubmitting, setIsSubmitting] = useState(false);\n  const addRole = useRoleStore((state) => state.addRole);\n\n  const handleSubmit = async (data: CreateRolePayload | UpdateRolePayload) => {\n    setIsSubmitting(true);\n    try {\n      await addRole(data as CreateRolePayload);\n      onOpenChange(false);\n    } catch (e) {\n      console.error(\"Failed to add role from component\", e);\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  return (\n    <Dialog open={isOpen} onOpenChange={onOpenChange}>\n      <DialogContent>\n        <DialogHeader>\n          <DialogTitle>Add New Role</DialogTitle>\n        </DialogHeader>\n        <RoleForm\n          onSubmit={handleSubmit}\n          onCancel={() => onOpenChange(false)}\n          isSubmitting={isSubmitting}\n        />\n      </DialogContent>\n    </Dialog>\n  );\n};\n\n// Edit Role Dialog\nexport const EditRoleDialog: FC<{\n  role: Role | null;\n  isOpen: boolean;\n  onOpenChange: (open: boolean) => void;\n}> = ({ role, isOpen, onOpenChange }) => {\n  const [isSubmitting, setIsSubmitting] = useState(false);\n  const editRole = useRoleStore((state) => state.editRole);\n\n  if (!role) return null;\n\n  const handleSubmit = async (data: CreateRolePayload | UpdateRolePayload) => {\n    setIsSubmitting(true);\n    try {\n      await editRole(role.id, data as UpdateRolePayload);\n      onOpenChange(false);\n    } catch (e) {\n      console.error(\"Failed to edit role from component\", e);\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  return (\n    <Dialog open={isOpen} onOpenChange={onOpenChange}>\n      <DialogContent>\n        <DialogHeader>\n          <DialogTitle>Edit Role</DialogTitle>\n        </DialogHeader>\n        <RoleForm\n          role={role}\n          onSubmit={handleSubmit}\n          onCancel={() => onOpenChange(false)}\n          isSubmitting={isSubmitting}\n        />\n      </DialogContent>\n    </Dialog>\n  );\n};\n\n// Delete Role Dialog\nexport const DeleteRoleDialog: FC<{\n  roleId: number | null;\n  isOpen: boolean;\n  onOpenChange: (open: boolean) => void;\n}> = ({ roleId, isOpen, onOpenChange }) => {\n  const removeRole = useRoleStore((state) => state.removeRole);\n\n  const handleDelete = async () => {\n    if (roleId) {\n      await removeRole(roleId);\n      onOpenChange(false);\n    }\n  };\n\n  return (\n    <Dialog open={isOpen} onOpenChange={onOpenChange}>\n      <DialogContent>\n        <DialogHeader>\n          <DialogTitle>Are you sure?</DialogTitle>\n          <DialogDescription>\n            This action cannot be undone. This will permanently delete the role.\n          </DialogDescription>\n        </DialogHeader>\n        <DialogFooter>\n          <Button variant='outline' onClick={() => onOpenChange(false)}>\n            Cancel\n          </Button>\n          <Button variant='destructive' onClick={handleDelete}>\n            Delete\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/client/src/features/role/RoleForm.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from \"@/components/ui/command\";\nimport { DialogFooter } from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { usePermissionStore } from \"@/entities/permission/store\";\nimport { Permission } from \"@/entities/permission/types\";\nimport {\n  CreateRolePayload,\n  Role,\n  UpdateRolePayload,\n} from \"@/entities/role/types\";\nimport { cn } from \"@/lib/utils\";\nimport { Check, ChevronsUpDown } from \"lucide-react\";\nimport { FC, useEffect, useState } from \"react\";\n\ninterface RoleFormProps {\n  role?: Role;\n  onSubmit: (data: CreateRolePayload | UpdateRolePayload) => void;\n  onCancel: () => void;\n  isSubmitting: boolean;\n}\n\nexport const RoleForm: FC<RoleFormProps> = ({\n  role,\n  onSubmit,\n  onCancel,\n  isSubmitting,\n}) => {\n  const [name, setName] = useState(role?.name || \"\");\n  const [description, setDescription] = useState(role?.description || \"\");\n  const [selectedPermissions, setSelectedPermissions] = useState<Permission[]>(\n    role?.permissions || [],\n  );\n  const [error, setError] = useState(\"\");\n  const [isPermissionsPopoverOpen, setPermissionsPopoverOpen] = useState(false);\n  const [popoverContainer, setPopoverContainer] = useState<HTMLDivElement | null>(null);\n\n  const { permissions, fetchPermissions } = usePermissionStore();\n\n  useEffect(() => {\n    fetchPermissions();\n  }, [fetchPermissions]);\n\n  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n    if (!name) {\n      setError(\"Please enter a role name.\");\n      return;\n    }\n    setError(\"\");\n    const payload: CreateRolePayload | UpdateRolePayload = {\n      name,\n      description: description || undefined,\n      permission_ids: selectedPermissions.map((p) => p.id),\n    };\n    onSubmit(payload);\n  };\n\n  return (\n    <form onSubmit={handleSubmit}>\n      <div className='grid gap-4 py-4'>\n        <div className='grid grid-cols-4 items-center gap-4'>\n          <Label htmlFor='name' className='text-right'>\n            Role Name\n          </Label>\n          <Input\n            id='name'\n            value={name}\n            onChange={(e) => setName(e.target.value)}\n            className='col-span-3'\n          />\n        </div>\n        <div className='grid grid-cols-4 items-center gap-4'>\n          <Label htmlFor='description' className='text-right'>\n            Description\n          </Label>\n          <Input\n            id='description'\n            value={description}\n            onChange={(e) => setDescription(e.target.value)}\n            className='col-span-3'\n          />\n        </div>\n        <div className='grid grid-cols-4 items-center gap-4'>\n          <Label className='text-right'>Permissions</Label>\n          <div className='col-span-3' ref={(node) => setPopoverContainer(node)}>\n            <Popover\n              open={isPermissionsPopoverOpen}\n              onOpenChange={setPermissionsPopoverOpen}\n            >\n              <PopoverTrigger asChild>\n                <Button variant='outline' role='combobox' className='w-full justify-between'>\n                  {selectedPermissions.length > 0\n                    ? `${selectedPermissions.length} selected`\n                    : \"Select permissions...\"}\n                  <ChevronsUpDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />\n                </Button>\n              </PopoverTrigger>\n              <PopoverContent\n                container={popoverContainer}\n                className='w-[--radix-popover-trigger-width] p-0'\n              >\n                <Command>\n                  <CommandInput placeholder='Search permissions...' />\n                  <CommandList>\n                    <CommandEmpty>No permissions found.</CommandEmpty>\n                    <CommandGroup>\n                      {permissions.map((permission) => (\n                        <CommandItem\n                          key={permission.id}\n                          value={permission.name}\n                          onSelect={() => {\n                            setSelectedPermissions((current) =>\n                              current.some((p) => p.id === permission.id)\n                                ? current.filter((p) => p.id !== permission.id)\n                                : [...current, permission],\n                            );\n                          }}\n                        >\n                          <Check\n                            className={cn(\n                              \"mr-2 h-4 w-4\",\n                              selectedPermissions.some(\n                                (p) => p.id === permission.id,\n                              )\n                                ? \"opacity-100\"\n                                : \"opacity-0\",\n                            )}\n                          />\n                          {permission.name}\n                        </CommandItem>\n                      ))}\n                    </CommandGroup>\n                  </CommandList>\n                </Command>\n              </PopoverContent>\n            </Popover>\n          </div>\n        </div>\n        {error && (\n          <p className='text-sm text-destructive col-span-4 text-center'>\n            {error}\n          </p>\n        )}\n      </div>\n      <DialogFooter>\n        <Button type='button' variant='outline' onClick={onCancel}>\n          Cancel\n        </Button>\n        <Button type='submit' disabled={isSubmitting}>\n          {isSubmitting ? \"Saving...\" : \"Save\"}\n        </Button>\n      </DialogFooter>\n    </form>\n  );\n};\n"
  },
  {
    "path": "apps/client/src/features/ros2/Ros2Dashboard.tsx",
    "content": "import { Link } from \"react-router\";\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { Button } from \"@/components/ui/button\";\n\nexport function Ros2Dashboard() {\n  return (\n    <div className='flex flex-1 flex-col gap-4'>\n      <Card>\n        <CardHeader>\n          <CardTitle>ROS2</CardTitle>\n          <CardDescription>\n            rosbridge WebSocket is connected. Use the flow editor to wire ROS2 events and\n            commands, or adjust the bridge URL in integrations.\n          </CardDescription>\n        </CardHeader>\n        <CardContent className='flex flex-wrap gap-2'>\n          <Button asChild variant='default'>\n            <Link to='/flow'>Open flow</Link>\n          </Button>\n          <Button asChild variant='outline'>\n            <Link to='/settings/integration'>Integrations</Link>\n          </Button>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/rtc/AudioLevelBar.tsx",
    "content": "import { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ntype AudioLevelBarProps = {\n  stream: MediaStream | null;\n  className?: string;\n};\n\nexport function AudioLevelBar({ stream, className }: AudioLevelBarProps) {\n  const [level, setLevel] = useState(0);\n  const rafRef = useRef<number | null>(null);\n  const audioContextRef = useRef<AudioContext | null>(null);\n  const analyserRef = useRef<AnalyserNode | null>(null);\n  const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);\n\n  const stopAnalyser = () => {\n    if (rafRef.current !== null) {\n      cancelAnimationFrame(rafRef.current);\n      rafRef.current = null;\n    }\n    if (sourceRef.current) {\n      try {\n        sourceRef.current.disconnect();\n      } catch (err) {\n        console.error(\"Error disconnecting audio source\", err);\n      }\n      sourceRef.current = null;\n    }\n    if (analyserRef.current) {\n      try {\n        analyserRef.current.disconnect();\n      } catch (err) {\n        console.error(\"Error disconnecting analyser\", err);\n      }\n      analyserRef.current = null;\n    }\n  };\n\n  useEffect(() => {\n    stopAnalyser();\n\n    if (!stream || stream.getAudioTracks().length === 0) {\n      setLevel(0);\n      return;\n    }\n\n    let cancelled = false;\n\n    const setupAnalyser = async () => {\n      const audioContext =\n        audioContextRef.current ??\n        new AudioContext({ latencyHint: \"interactive\" });\n      audioContextRef.current = audioContext;\n\n      if (audioContext.state === \"suspended\") {\n        try {\n          await audioContext.resume();\n        } catch (err) {\n          console.error(\"Unable to resume AudioContext\", err);\n        }\n      }\n\n      const sourceNode = audioContext.createMediaStreamSource(stream);\n      const analyserNode = audioContext.createAnalyser();\n      analyserNode.fftSize = 2048;\n      analyserNode.smoothingTimeConstant = 0.7;\n\n      sourceNode.connect(analyserNode);\n      analyserRef.current = analyserNode;\n      sourceRef.current = sourceNode;\n\n      const buffer = new Float32Array(analyserNode.fftSize);\n\n      const updateLevel = () => {\n        if (cancelled || !analyserRef.current) {\n          return;\n        }\n\n        analyserRef.current.getFloatTimeDomainData(buffer);\n\n        let sumSquares = 0;\n        for (let i = 0; i < buffer.length; i++) {\n          const sample = buffer[i];\n          sumSquares += sample * sample;\n        }\n\n        const rms = Math.sqrt(sumSquares / buffer.length) || 0;\n        const db = 20 * Math.log10(rms || 1e-8);\n        const minDb = -70;\n        const normalized = Math.min(Math.max((db - minDb) / -minDb, 0), 1);\n\n        setLevel(normalized);\n        rafRef.current = requestAnimationFrame(updateLevel);\n      };\n\n      updateLevel();\n    };\n\n    setupAnalyser();\n\n    return () => {\n      cancelled = true;\n      stopAnalyser();\n    };\n  }, [stream]);\n\n  useEffect(() => {\n    return () => {\n      stopAnalyser();\n      if (audioContextRef.current) {\n        audioContextRef.current.close().catch((err) => {\n          console.error(\"Error closing AudioContext\", err);\n        });\n        audioContextRef.current = null;\n      }\n    };\n  }, []);\n\n  return (\n    <div className={cn(\"h-10 w-full overflow-hidden bg-black/50\", className)}>\n      <div\n        className='h-full bg-white transition-[width] duration-75 ease-linear'\n        style={{ width: `${Math.round(level * 100)}%` }}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/rtc/StreamReceiver.tsx",
    "content": "import { useEffect, useRef, useMemo } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { useWebRTC } from \"./WebRTCProvider\";\nimport { WebRTCManager } from \"./rtc\";\nimport { AudioLevelBar } from \"./AudioLevelBar\";\n\ntype StreamReceiverProps = {\n  topic: string;\n  streamType: \"audio\" | \"video\";\n};\n\nexport function StreamReceiver({ topic, streamType }: StreamReceiverProps) {\n  const mediaRef = useRef<HTMLMediaElement>(null);\n  const { rtcManager, streams, audioStreamCount, videoStreamCount } =\n    useWebRTC();\n\n  const streamInfo = useMemo(() => {\n    return streams.get(topic) || null;\n  }, [streams, topic]);\n\n  const stream = streamInfo?.stream || null;\n\n  useEffect(() => {\n    if (stream && mediaRef.current) {\n      mediaRef.current.srcObject = stream;\n      mediaRef.current\n        .play()\n        .catch((e) => console.error(\"Media play failed:\", e));\n    }\n  }, [stream]);\n\n  const handleSubscribe = () => {\n    if (rtcManager) {\n      rtcManager.subscribe(topic, streamType);\n    } else {\n      console.error(\"RTCManager is not ready.\");\n    }\n  };\n\n  const isLimitReached =\n    (streamType === \"audio\" &&\n      audioStreamCount >= WebRTCManager.MAX_AUDIO_STREAMS) ||\n    (streamType === \"video\" &&\n      videoStreamCount >= WebRTCManager.MAX_VIDEO_STREAMS);\n\n  if (!stream) {\n    return (\n      <Button\n        className='w-full'\n        variant={\"outline\"}\n        onClick={handleSubscribe}\n        disabled={isLimitReached}\n      >\n        {streamType === \"audio\" ? \"Play Audio\" : \"Play Video\"}\n        {isLimitReached && \" (Limited)\"}\n      </Button>\n    );\n  }\n\n  if (streamType === \"video\") {\n    return (\n      <video\n        ref={mediaRef as React.RefObject<HTMLVideoElement>}\n        className='w-full bg-black'\n        autoPlay\n        playsInline\n        controls\n        muted\n      />\n    );\n  }\n\n  if (streamType === \"audio\") {\n    return (\n      <div className='flex w-full flex-col gap-3 rounded-md bg-black/60 p-1'>\n        <audio\n          ref={mediaRef as React.RefObject<HTMLAudioElement>}\n          className='w-full hidden'\n          autoPlay\n          playsInline\n          controls\n        />\n        <AudioLevelBar stream={stream} />\n      </div>\n    );\n  }\n\n  return null;\n}\n"
  },
  {
    "path": "apps/client/src/features/rtc/WebRTCProvider.tsx",
    "content": "import {\n  createContext,\n  useContext,\n  useEffect,\n  useRef,\n  useState,\n  ReactNode,\n  useMemo,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport { useWebSocket } from \"../ws/WebSocketProvider\";\nimport { WebRTCManager } from \"./rtc\";\nimport { isDemoMode } from \"@/shared/demo\";\nimport { WebSocketChannel } from \"../ws/ws\";\nimport {\n  ensureIceServers,\n  onCredentialChange,\n  onTurnCredentialError,\n  stopAutoRenewal,\n  DEFAULT_ICE_SERVERS,\n} from \"./turnService\";\n\ntype StreamInfo = {\n  stream: MediaStream;\n  type: \"audio\" | \"video\";\n};\n\ntype WebRTCContextType = {\n  rtcManager: WebRTCManager | null;\n  streams: Map<string, StreamInfo>;\n  audioStreamCount: number;\n  videoStreamCount: number;\n};\n\nconst WebRTCContext = createContext<WebRTCContextType | undefined>(undefined);\n\nexport function useWebRTC() {\n  const context = useContext(WebRTCContext);\n  if (!context) {\n    throw new Error(\"useWebRTC must be used within a WebRTCProvider\");\n  }\n  return context;\n}\n\ntype WebRTCProviderProps = {\n  children: ReactNode;\n};\n\nexport function WebRTCProvider({ children }: WebRTCProviderProps) {\n  const { wsManager, isConnected } = useWebSocket();\n  const [rtcManager, setRtcManager] = useState<WebRTCManager | null>(null);\n  const [streams, setStreams] = useState<Map<string, StreamInfo>>(new Map());\n  const [demoMode] = useState(isDemoMode);\n  const [iceServers, setIceServers] = useState<RTCIceServer[] | null>(null);\n  const lastTurnErrorRef = useRef<string | null>(null);\n\n  // Load ICE servers: try localStorage → server DB → Supabase → STUN fallback\n  useEffect(() => {\n    if (demoMode) {\n      setIceServers(DEFAULT_ICE_SERVERS);\n      return;\n    }\n\n    let cancelled = false;\n\n    ensureIceServers().then((servers) => {\n      if (!cancelled) setIceServers(servers);\n    });\n\n    const unsubscribe = onCredentialChange((servers) => {\n      if (!cancelled) setIceServers(servers);\n    });\n\n    return () => {\n      cancelled = true;\n      unsubscribe();\n      stopAutoRenewal();\n    };\n  }, [demoMode, isConnected]);\n\n  useEffect(() => {\n    if (demoMode) return;\n\n    return onTurnCredentialError((error) => {\n      const fingerprint = `${error.code}:${error.message}:${error.usage?.periodEnd ?? \"\"}`;\n      if (lastTurnErrorRef.current === fingerprint) return;\n\n      lastTurnErrorRef.current = fingerprint;\n\n      if (error.code === \"TURN_QUOTA_EXCEEDED\") {\n        toast.error(\"TURN relay quota exceeded\", {\n          description:\n            error.usage?.periodEnd\n              ? `1 GB monthly limit reached. Quota resets ${new Date(\n                  error.usage.periodEnd,\n                ).toLocaleString()}.`\n              : \"1 GB monthly limit reached for TURN relay traffic.\",\n        });\n        return;\n      }\n\n      if (error.code === \"TURN_USAGE_UNAVAILABLE\") {\n        toast.error(\"TURN usage check unavailable\", {\n          description: \"TURN relay usage could not be verified, so the app is using STUN only.\",\n        });\n      }\n    });\n  }, [demoMode]);\n\n  // Create WebRTCManager when connected and ICE servers are ready\n  useEffect(() => {\n    if (demoMode) {\n      setRtcManager(null);\n      setStreams(new Map());\n      return;\n    }\n\n    if (isConnected && wsManager && iceServers) {\n      if (!(wsManager instanceof WebSocketChannel)) {\n        setRtcManager(null);\n        setStreams(new Map());\n        return;\n      }\n\n      console.log(\n        \"WebSocket connected, initializing WebRTCManager with\",\n        iceServers.length,\n        \"ICE servers:\",\n        iceServers.map((s) => ({\n          urls: s.urls,\n          hasCredentials: !!s.username,\n        })),\n      );\n\n      const onStreamsChanged = (newStreams: Map<string, StreamInfo>) => {\n        setStreams(newStreams);\n      };\n\n      const manager = new WebRTCManager(wsManager, onStreamsChanged, {\n        iceServers,\n      });\n      manager.connect();\n      setRtcManager(manager);\n\n      return () => {\n        manager.close();\n        setRtcManager(null);\n      };\n    }\n  }, [demoMode, isConnected, wsManager, iceServers]);\n\n  const { audioStreamCount, videoStreamCount } = useMemo(() => {\n    const counts = { audioStreamCount: 0, videoStreamCount: 0 };\n    for (const streamInfo of streams.values()) {\n      if (streamInfo.type === \"audio\") {\n        counts.audioStreamCount++;\n      } else {\n        counts.videoStreamCount++;\n      }\n    }\n    return counts;\n  }, [streams]);\n\n  const value = demoMode\n    ? {\n        rtcManager: null,\n        streams: new Map(),\n        audioStreamCount: 0,\n        videoStreamCount: 0,\n      }\n    : {\n        rtcManager,\n        streams,\n        audioStreamCount,\n        videoStreamCount,\n      };\n\n  return (\n    <WebRTCContext.Provider value={value}>{children}</WebRTCContext.Provider>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/rtc/captureFrame.ts",
    "content": "/**\n * Captures the current frame from a live MediaStream as a JPEG File.\n *\n * Creates a temporary offscreen <video> element, draws the current frame\n * to a <canvas>, and exports it as a JPEG blob. The original stream is\n * not affected (MediaStream reference is shared, not cloned).\n */\nexport async function captureFrameFromStream(\n  stream: MediaStream,\n): Promise<File> {\n  return new Promise((resolve, reject) => {\n    const video = document.createElement(\"video\");\n    video.srcObject = stream;\n    video.muted = true;\n    video.playsInline = true;\n\n    const cleanup = () => {\n      video.pause();\n      video.srcObject = null;\n    };\n\n    const timeout = setTimeout(() => {\n      cleanup();\n      reject(new Error(\"Frame capture timed out\"));\n    }, 5000);\n\n    video.onloadeddata = () => {\n      clearTimeout(timeout);\n      try {\n        const canvas = document.createElement(\"canvas\");\n        canvas.width = video.videoWidth;\n        canvas.height = video.videoHeight;\n\n        const ctx = canvas.getContext(\"2d\");\n        if (!ctx) {\n          cleanup();\n          reject(new Error(\"Failed to get canvas context\"));\n          return;\n        }\n\n        ctx.drawImage(video, 0, 0, canvas.width, canvas.height);\n\n        canvas.toBlob(\n          (blob) => {\n            cleanup();\n            if (!blob) {\n              reject(new Error(\"Failed to capture frame\"));\n              return;\n            }\n            const file = new File([blob], `frame-${Date.now()}.jpg`, {\n              type: \"image/jpeg\",\n            });\n            resolve(file);\n          },\n          \"image/jpeg\",\n          0.85,\n        );\n      } catch (err) {\n        cleanup();\n        reject(err);\n      }\n    };\n\n    video.onerror = () => {\n      clearTimeout(timeout);\n      cleanup();\n      reject(new Error(\"Failed to load video stream for capture\"));\n    };\n\n    video.play().catch((err) => {\n      clearTimeout(timeout);\n      cleanup();\n      reject(new Error(`Failed to play video for capture: ${err.message}`));\n    });\n  });\n}\n"
  },
  {
    "path": "apps/client/src/features/rtc/rtc.ts",
    "content": "import { WebSocketChannel, WebSocketMessage } from \"../ws/ws\";\n\nexport interface WebRTCConfig {\n  iceServers: RTCIceServer[];\n}\n\nexport class WebRTCManager {\n  private pc: RTCPeerConnection;\n  private signaling: WebSocketChannel;\n  public streams: Map<string, { stream: MediaStream; type: \"audio\" | \"video\" }>;\n  private onStreamsChanged: (\n    streams: Map<string, { stream: MediaStream; type: \"audio\" | \"video\" }>,\n  ) => void;\n  private pendingTopics: Array<{ topic: string; type: \"audio\" | \"video\" }>;\n  private candidateQueue: RTCIceCandidateInit[] = [];\n\n  public static readonly MAX_AUDIO_STREAMS = 2;\n  public static readonly MAX_VIDEO_STREAMS = 4;\n\n  constructor(\n    signaling: WebSocketChannel,\n    onStreamsChanged: (\n      streams: Map<string, { stream: MediaStream; type: \"audio\" | \"video\" }>,\n    ) => void,\n    config?: WebRTCConfig,\n  ) {\n    this.signaling = signaling;\n    this.streams = new Map();\n    this.pendingTopics = [];\n    this.onStreamsChanged = onStreamsChanged;\n    const iceServers = config?.iceServers ?? [\n      { urls: \"stun:stun.l.google.com:19302\" },\n    ];\n    this.pc = new RTCPeerConnection({ iceServers });\n    this.setupEvents();\n  }\n\n  private setupEvents(): void {\n    this.signaling.addMessageListener(this.handleSignalingMessage);\n\n    this.pc.oniceconnectionstatechange = () => {\n      console.log(\"[WebRTC] ICE connection state:\", this.pc.iceConnectionState);\n    };\n\n    this.pc.onconnectionstatechange = () => {\n      console.log(\"[WebRTC] Connection state:\", this.pc.connectionState);\n    };\n\n    this.pc.onicegatheringstatechange = () => {\n      console.log(\"[WebRTC] ICE gathering state:\", this.pc.iceGatheringState);\n    };\n\n    this.pc.onicecandidate = (event) => {\n      if (event.candidate) {\n        const type = event.candidate.type ?? \"unknown\";\n        console.log(`[WebRTC] Local candidate [${type}]:`, event.candidate.candidate);\n        this.signaling.send({\n          type: \"candidate\",\n          payload: event.candidate.toJSON(),\n        });\n      } else {\n        console.log(\"[WebRTC] ICE gathering complete\");\n      }\n    };\n\n    this.pc.ontrack = (event) => {\n      const pending = this.pendingTopics.shift();\n      if (!pending) {\n        return;\n      }\n      const { topic, type } = pending;\n\n      const track = event.track;\n      const stream = new MediaStream([track]);\n\n      console.log(` \"${topic}\": ${track.kind} : ${stream.id}`);\n\n      if (!this.streams.has(topic)) {\n        this.streams.set(topic, { stream, type });\n        this.onStreamsChanged(new Map(this.streams));\n      }\n    };\n  }\n\n  private handleSignalingMessage = async (msg: WebSocketMessage) => {\n    try {\n      switch (msg.type) {\n        case \"answer\":\n          await this.pc.setRemoteDescription(\n            new RTCSessionDescription(msg.payload as RTCSessionDescriptionInit),\n          );\n          this.processCandidateQueue();\n          break;\n\n        case \"offer\": {\n          await this.pc.setRemoteDescription(\n            new RTCSessionDescription(msg.payload as RTCSessionDescriptionInit),\n          );\n          const answer = await this.pc.createAnswer();\n          await this.pc.setLocalDescription(answer);\n          this.signaling.send({\n            type: \"answer\",\n            payload: this.pc.localDescription as RTCSessionDescriptionInit,\n          });\n          this.processCandidateQueue();\n          break;\n        }\n\n        case \"candidate\":\n          if (msg.payload) {\n            const candidateInit = msg.payload as RTCIceCandidateInit;\n            if (this.pc.remoteDescription) {\n              await this.pc.addIceCandidate(new RTCIceCandidate(candidateInit));\n            } else {\n              this.candidateQueue.push(candidateInit);\n            }\n          }\n          break;\n      }\n    } catch (err) {\n      console.error(\"Error handling signaling message:\", err);\n    }\n  };\n\n  private processCandidateQueue = async () => {\n    while (this.candidateQueue.length > 0) {\n      const candidate = this.candidateQueue.shift();\n      if (candidate) {\n        try {\n          await this.pc.addIceCandidate(candidate);\n        } catch (error) {\n          console.error(\"Error processing queued candidate:\", error);\n        }\n      }\n    }\n  };\n\n  public async connect(): Promise<void> {\n    try {\n      for (let i = 0; i < WebRTCManager.MAX_AUDIO_STREAMS; i++) {\n        this.pc.addTransceiver(\"audio\", { direction: \"recvonly\" });\n      }\n      for (let i = 0; i < WebRTCManager.MAX_VIDEO_STREAMS; i++) {\n        this.pc.addTransceiver(\"video\", { direction: \"recvonly\" });\n      }\n\n      const offer = await this.pc.createOffer();\n      await this.pc.setLocalDescription(offer);\n\n      this.signaling.send({\n        type: \"offer\",\n        payload: this.pc.localDescription as RTCSessionDescriptionInit,\n      });\n    } catch (err) {\n      console.error(\"Error creating initial offer:\", err);\n    }\n  }\n\n  public subscribe(topic: string, streamType: \"audio\" | \"video\"): void {\n    const currentStreams = Array.from(this.streams.values());\n    const audioCount = currentStreams.filter((s) => s.type === \"audio\").length;\n    const videoCount = currentStreams.filter((s) => s.type === \"video\").length;\n\n    if (\n      streamType === \"audio\" &&\n      audioCount >= WebRTCManager.MAX_AUDIO_STREAMS\n    ) {\n      console.warn(\"Max audio streams reached. Cannot subscribe.\");\n      return;\n    }\n\n    if (\n      streamType === \"video\" &&\n      videoCount >= WebRTCManager.MAX_VIDEO_STREAMS\n    ) {\n      console.warn(\"Max video streams reached. Cannot subscribe.\");\n      return;\n    }\n\n    console.log(`Subscribing to topic: ${topic} (${streamType})`);\n    this.pendingTopics.push({ topic, type: streamType });\n\n    this.signaling.send({\n      type: \"subscribe_stream\",\n      payload: { topic },\n    });\n  }\n\n  public close(): void {\n    this.signaling.send({ type: \"hangup\", payload: \"\" });\n\n    this.signaling.removeMessageListener(this.handleSignalingMessage);\n    if (this.pc) {\n      this.pc.close();\n    }\n    this.streams.clear();\n    this.onStreamsChanged(new Map(this.streams));\n  }\n}\n"
  },
  {
    "path": "apps/client/src/features/rtc/turnService.ts",
    "content": "import { supabase } from \"@/lib/supabase\";\nimport { getConfigs, updateConfig } from \"@/entities/configurations/api\";\n\nexport interface TurnUsage {\n  egressBytes: number;\n  ingressBytes: number;\n  totalBytes: number;\n  quotaBytes: number;\n  remainingBytes: number;\n  isQuotaExceeded: boolean;\n  periodStart: string;\n  periodEnd: string;\n}\n\nexport interface TurnCredentialsResponse {\n  iceServers: RTCIceServer[];\n  expiresAt: string;\n  usage: TurnUsage;\n}\n\nexport const DEFAULT_ICE_SERVERS: RTCIceServer[] = [\n  { urls: \"stun:stun.l.google.com:19302\" },\n];\n\nconst TURN_CACHE_KEY = \"vessel_turn_credentials\";\nconst RENEWAL_BUFFER_MS = 5 * 60 * 1000; // 5 minutes before expiry\n\ninterface CachedCredentials {\n  iceServers: RTCIceServer[];\n  expiresAt: string;\n}\n\n/* ── Credential change pub/sub ── */\n\ntype CredentialChangeCallback = (iceServers: RTCIceServer[]) => void;\nconst listeners = new Set<CredentialChangeCallback>();\n\nexport type TurnCredentialErrorCode =\n  | \"TURN_QUOTA_EXCEEDED\"\n  | \"TURN_USAGE_UNAVAILABLE\"\n  | \"TURN_ISSUE_FAILED\";\n\nexport class TurnCredentialError extends Error {\n  code: TurnCredentialErrorCode;\n  status?: number;\n  usage?: TurnUsage;\n\n  constructor(\n    code: TurnCredentialErrorCode,\n    message: string,\n    options: { status?: number; usage?: TurnUsage } = {},\n  ) {\n    super(message);\n    this.name = \"TurnCredentialError\";\n    this.code = code;\n    this.status = options.status;\n    this.usage = options.usage;\n  }\n}\n\nexport function isTurnCredentialError(error: unknown): error is TurnCredentialError {\n  return error instanceof TurnCredentialError;\n}\n\ntype TurnErrorCallback = (error: TurnCredentialError) => void;\nconst errorListeners = new Set<TurnErrorCallback>();\n\nexport function onTurnCredentialError(cb: TurnErrorCallback): () => void {\n  errorListeners.add(cb);\n  return () => {\n    errorListeners.delete(cb);\n  };\n}\n\nfunction notifyTurnCredentialError(error: TurnCredentialError): void {\n  errorListeners.forEach((cb) => cb(error));\n}\n\nfunction getDefaultUsage(raw: Partial<TurnUsage> = {}): TurnUsage {\n  const egressBytes = Number(raw.egressBytes ?? 0);\n  const ingressBytes = Number(raw.ingressBytes ?? 0);\n  const totalBytes = Number(raw.totalBytes ?? egressBytes + ingressBytes);\n  const quotaBytes = Number(raw.quotaBytes ?? 0);\n  const remainingBytes = Number(\n    raw.remainingBytes ?? Math.max(0, quotaBytes - totalBytes),\n  );\n\n  return {\n    egressBytes,\n    ingressBytes,\n    totalBytes,\n    quotaBytes,\n    remainingBytes,\n    isQuotaExceeded: Boolean(\n      raw.isQuotaExceeded ?? (quotaBytes > 0 && totalBytes >= quotaBytes),\n    ),\n    periodStart: raw.periodStart ?? \"\",\n    periodEnd: raw.periodEnd ?? \"\",\n  };\n}\n\nfunction getTurnErrorCode(errorCode?: string): TurnCredentialErrorCode {\n  switch (errorCode) {\n    case \"turn_quota_exceeded\":\n      return \"TURN_QUOTA_EXCEEDED\";\n    case \"turn_usage_unavailable\":\n      return \"TURN_USAGE_UNAVAILABLE\";\n    default:\n      return \"TURN_ISSUE_FAILED\";\n  }\n}\n\nasync function parseInvokeError(error: unknown): Promise<TurnCredentialError> {\n  const context =\n    typeof error === \"object\" && error !== null && \"context\" in error\n      ? Reflect.get(error, \"context\")\n      : null;\n  const status =\n    typeof context === \"object\" && context !== null && \"status\" in context\n      ? Number(Reflect.get(context, \"status\"))\n      : undefined;\n\n  let payload: Record<string, unknown> | null = null;\n  if (\n    typeof context === \"object\" &&\n    context !== null &&\n    \"json\" in context &&\n    typeof Reflect.get(context, \"json\") === \"function\"\n  ) {\n    try {\n      payload = await (context as Response).clone().json();\n    } catch {\n      payload = null;\n    }\n  }\n\n  const rawCode =\n    typeof payload?.error === \"string\"\n      ? payload.error\n      : typeof payload?.code === \"string\"\n        ? payload.code\n        : undefined;\n  const message =\n    typeof payload?.message === \"string\"\n      ? payload.message\n      : typeof payload?.error === \"string\"\n        ? payload.error\n        : error instanceof Error\n          ? error.message\n          : \"Failed to fetch TURN credentials\";\n\n  return new TurnCredentialError(getTurnErrorCode(rawCode), message, {\n    status,\n    usage:\n      payload && typeof payload.usage === \"object\" && payload.usage !== null\n        ? getDefaultUsage(payload.usage as Partial<TurnUsage>)\n        : undefined,\n  });\n}\n\nfunction toTurnCredentialError(error: unknown): TurnCredentialError {\n  if (isTurnCredentialError(error)) return error;\n\n  if (error instanceof Error) {\n    return new TurnCredentialError(\"TURN_ISSUE_FAILED\", error.message);\n  }\n\n  return new TurnCredentialError(\n    \"TURN_ISSUE_FAILED\",\n    \"Failed to fetch TURN credentials\",\n  );\n}\n\nfunction getTurnErrorSummary(error: TurnCredentialError): string | null {\n  if (!error.usage?.quotaBytes) return null;\n\n  const totalGb = (error.usage.totalBytes / 1024 ** 3).toFixed(2);\n  const quotaGb = (error.usage.quotaBytes / 1024 ** 3).toFixed(2);\n  const resetAt = error.usage.periodEnd\n    ? new Date(error.usage.periodEnd).toLocaleString()\n    : null;\n\n  return resetAt\n    ? `${totalGb} GB / ${quotaGb} GB used. Quota resets ${resetAt}.`\n    : `${totalGb} GB / ${quotaGb} GB used this period.`;\n}\n\nexport function onCredentialChange(cb: CredentialChangeCallback): () => void {\n  listeners.add(cb);\n  return () => {\n    listeners.delete(cb);\n  };\n}\n\nexport function notifyListeners(iceServers: RTCIceServer[]): void {\n  listeners.forEach((cb) => cb(iceServers));\n}\n\n/* ── Auto-renewal scheduler ── */\n\nlet renewalTimer: ReturnType<typeof setTimeout> | null = null;\n\nfunction scheduleRenewal(expiresAt: string): void {\n  stopAutoRenewal();\n  const delay = Math.max(\n    new Date(expiresAt).getTime() - Date.now() - RENEWAL_BUFFER_MS,\n    0,\n  );\n\n  renewalTimer = setTimeout(async () => {\n    try {\n      // Try Supabase edge function first (requires Supabase session)\n      const result = await fetchTurnCredentials();\n      await saveTurnConfigToServer({\n        iceServers: result.iceServers,\n        expiresAt: result.expiresAt,\n      });\n      scheduleRenewal(result.expiresAt);\n      notifyListeners(result.iceServers);\n    } catch (err) {\n      const turnError = toTurnCredentialError(err);\n      notifyTurnCredentialError(turnError);\n\n      // Fallback: load from server DB (another session may have renewed)\n      try {\n        const serverConfig = await loadTurnConfigFromServer();\n        if (serverConfig) {\n          cacheCredentials({\n            iceServers: serverConfig.iceServers,\n            expiresAt: serverConfig.expiresAt,\n            usage: getDefaultUsage(),\n          });\n          scheduleRenewal(serverConfig.expiresAt);\n          notifyListeners(serverConfig.iceServers);\n        } else {\n          console.warn(\n            \"TURN credential auto-renewal failed: no valid credentials available\",\n            turnError,\n          );\n        }\n      } catch (err) {\n        console.warn(\"TURN credential auto-renewal failed:\", err);\n      }\n    }\n  }, delay);\n}\n\nexport function stopAutoRenewal(): void {\n  if (renewalTimer) {\n    clearTimeout(renewalTimer);\n    renewalTimer = null;\n  }\n}\n\n/* ── Cache helpers ── */\n\nfunction getCachedCredentials(): CachedCredentials | null {\n  try {\n    const stored = localStorage.getItem(TURN_CACHE_KEY);\n    if (!stored) return null;\n\n    const cached: CachedCredentials = JSON.parse(stored);\n    const expiresAt = new Date(cached.expiresAt).getTime();\n    const now = Date.now();\n\n    if (expiresAt - RENEWAL_BUFFER_MS > now) {\n      return cached;\n    }\n\n    localStorage.removeItem(TURN_CACHE_KEY);\n    return null;\n  } catch {\n    return null;\n  }\n}\n\nfunction cacheCredentials(data: TurnCredentialsResponse): void {\n  try {\n    const cached: CachedCredentials = {\n      iceServers: data.iceServers,\n      expiresAt: data.expiresAt,\n    };\n    localStorage.setItem(TURN_CACHE_KEY, JSON.stringify(cached));\n  } catch {\n    // localStorage unavailable\n  }\n}\n\n/* ── Server DB helpers ── */\n\nconst TURN_CONFIG_KEY = \"turn_server_config\";\n\nasync function loadTurnConfigFromServer(): Promise<CachedCredentials | null> {\n  try {\n    const response = await getConfigs();\n    const entry = response.data.find((c) => c.key === TURN_CONFIG_KEY);\n    if (!entry) return null;\n\n    const parsed = JSON.parse(entry.value);\n    if (!parsed.iceServers?.length || !parsed.expiresAt) return null;\n\n    const expiresAt = new Date(parsed.expiresAt).getTime();\n    if (expiresAt - RENEWAL_BUFFER_MS <= Date.now()) return null;\n\n    return { iceServers: parsed.iceServers, expiresAt: parsed.expiresAt };\n  } catch {\n    return null;\n  }\n}\n\nexport async function saveTurnConfigToServer(\n  data: CachedCredentials,\n): Promise<void> {\n  try {\n    const response = await getConfigs();\n    const entry = response.data.find((c) => c.key === TURN_CONFIG_KEY);\n    if (!entry) return;\n\n    await updateConfig(entry.id, {\n      key: TURN_CONFIG_KEY,\n      value: JSON.stringify({\n        iceServers: data.iceServers,\n        expiresAt: data.expiresAt,\n      }),\n      enabled: 1,\n      description: entry.description,\n    });\n  } catch (err) {\n    console.warn(\"Failed to save TURN config to server:\", err);\n  }\n}\n\n/* ── Public API ── */\n\n/** Synchronously read cached TURN iceServers, fallback to STUN */\nexport function getIceServers(): RTCIceServer[] {\n  const cached = getCachedCredentials();\n  return cached ? cached.iceServers : DEFAULT_ICE_SERVERS;\n}\n\n/** Get current credential info (including expired) for UI display */\nexport function getCredentialInfo(): {\n  iceServers: RTCIceServer[];\n  expiresAt: string;\n  isExpired: boolean;\n} | null {\n  try {\n    const stored = localStorage.getItem(TURN_CACHE_KEY);\n    if (!stored) return null;\n\n    const cached: CachedCredentials = JSON.parse(stored);\n    if (!cached.iceServers?.length || !cached.expiresAt) return null;\n\n    const isExpired =\n      new Date(cached.expiresAt).getTime() - RENEWAL_BUFFER_MS <= Date.now();\n    return {\n      iceServers: cached.iceServers,\n      expiresAt: cached.expiresAt,\n      isExpired,\n    };\n  } catch {\n    return null;\n  }\n}\n\n/** Ensure valid TURN credentials are available (async fetch if needed) */\nexport async function ensureIceServers(): Promise<RTCIceServer[]> {\n  // 1. Check localStorage cache\n  const cached = getCachedCredentials();\n  if (cached) {\n    scheduleRenewal(cached.expiresAt);\n    return cached.iceServers;\n  }\n\n  // 2. Load from server DB\n  const serverConfig = await loadTurnConfigFromServer();\n  if (serverConfig) {\n    cacheCredentials({\n      iceServers: serverConfig.iceServers,\n      expiresAt: serverConfig.expiresAt,\n      usage: getDefaultUsage(),\n    });\n    scheduleRenewal(serverConfig.expiresAt);\n    return serverConfig.iceServers;\n  }\n\n  // 3. Fetch from Supabase edge function + save to server DB\n  try {\n    const result = await fetchTurnCredentials();\n    await saveTurnConfigToServer({\n      iceServers: result.iceServers,\n      expiresAt: result.expiresAt,\n    });\n    scheduleRenewal(result.expiresAt);\n    return result.iceServers;\n  } catch (err) {\n    const turnError = toTurnCredentialError(err);\n    notifyTurnCredentialError(turnError);\n    console.warn(\"Failed to fetch TURN credentials, falling back to STUN:\", turnError);\n    return DEFAULT_ICE_SERVERS;\n  }\n}\n\n/** Fetch fresh TURN credentials from edge function */\nexport async function fetchTurnCredentials(): Promise<TurnCredentialsResponse> {\n  const { data, error } = await supabase.functions.invoke(\"turn-ice\", {\n    body: { strip_port_53: true },\n  });\n\n  if (error) {\n    throw await parseInvokeError(error);\n  }\n\n  if (!data?.iceServers) {\n    throw new TurnCredentialError(\n      getTurnErrorCode(data?.error),\n      data?.message ?? data?.error ?? \"No iceServers in response\",\n      {\n        usage:\n          data?.usage && typeof data.usage === \"object\"\n            ? getDefaultUsage(data.usage as Partial<TurnUsage>)\n            : undefined,\n      },\n    );\n  }\n\n  const response: TurnCredentialsResponse = {\n    iceServers: data.iceServers,\n    expiresAt: data.expiresAt ?? \"\",\n    usage:\n      data.usage && typeof data.usage === \"object\"\n        ? getDefaultUsage(data.usage as Partial<TurnUsage>)\n        : getDefaultUsage(),\n  };\n  cacheCredentials(response);\n  return response;\n}\n\nexport function clearTurnCache(): void {\n  localStorage.removeItem(TURN_CACHE_KEY);\n}\n\nexport function getTurnCredentialErrorSummary(error: TurnCredentialError): string | null {\n  return getTurnErrorSummary(error);\n}\n"
  },
  {
    "path": "apps/client/src/features/sdr/SdrAudioPlayer.tsx",
    "content": "import { useEffect, useRef, useCallback, useState } from \"react\";\nimport { storage } from \"@/lib/storage\";\n\nconst AUDIO_SAMPLE_RATE = 48000;\nconst BUFFER_SIZE = 4096;\nconst WATERFALL_HEIGHT = 200;\nconst FFT_SIZE = 2048;\n\n// SDR-style waterfall color map: black → blue → cyan → green → yellow → red → white\nfunction amplitudeToColor(value: number): [number, number, number] {\n  // value: 0-255\n  const v = value / 255;\n  let r: number, g: number, b: number;\n\n  if (v < 0.2) {\n    // black → dark blue\n    const t = v / 0.2;\n    r = 0;\n    g = 0;\n    b = Math.floor(t * 180);\n  } else if (v < 0.4) {\n    // dark blue → cyan\n    const t = (v - 0.2) / 0.2;\n    r = 0;\n    g = Math.floor(t * 255);\n    b = 180 + Math.floor(t * 75);\n  } else if (v < 0.6) {\n    // cyan → green\n    const t = (v - 0.4) / 0.2;\n    r = 0;\n    g = 255;\n    b = Math.floor(255 * (1 - t));\n  } else if (v < 0.8) {\n    // green → yellow\n    const t = (v - 0.6) / 0.2;\n    r = Math.floor(t * 255);\n    g = 255;\n    b = 0;\n  } else {\n    // yellow → red → white\n    const t = (v - 0.8) / 0.2;\n    r = 255;\n    g = Math.floor(255 * (1 - t * 0.6));\n    b = Math.floor(t * 200);\n  }\n\n  return [r, g, b];\n}\n\ninterface SdrAudioPlayerProps {\n  isPlaying: boolean;\n  onInfo?: (info: { samplerate: number; audio_rate: number }) => void;\n  onError?: (error: string) => void;\n  onDisconnect?: () => void;\n}\n\nexport function SdrAudioPlayer({\n  isPlaying,\n  onInfo,\n  onError,\n  onDisconnect,\n}: SdrAudioPlayerProps) {\n  const wsRef = useRef<WebSocket | null>(null);\n  const audioCtxRef = useRef<AudioContext | null>(null);\n  const analyserRef = useRef<AnalyserNode | null>(null);\n  const bufferQueueRef = useRef<Int16Array[]>([]);\n  const waterfallRef = useRef<HTMLCanvasElement>(null);\n  const animFrameRef = useRef<number>(0);\n  const [level, setLevel] = useState(0);\n\n  const cleanup = useCallback(() => {\n    if (animFrameRef.current) {\n      cancelAnimationFrame(animFrameRef.current);\n      animFrameRef.current = 0;\n    }\n    if (wsRef.current) {\n      wsRef.current.close();\n      wsRef.current = null;\n    }\n    if (audioCtxRef.current) {\n      audioCtxRef.current.close();\n      audioCtxRef.current = null;\n    }\n    analyserRef.current = null;\n    bufferQueueRef.current = [];\n    setLevel(0);\n  }, []);\n\n  useEffect(() => {\n    if (!isPlaying) {\n      cleanup();\n      return;\n    }\n\n    const serverUrl = storage.getServerUrl();\n    if (!serverUrl) {\n      onError?.(\"Server URL not configured\");\n      return;\n    }\n\n    const wsUrl = serverUrl.replace(/^http/, \"ws\") + \"/api/sdr/audio\";\n    const token = storage.getToken();\n    const ws = new WebSocket(\n      token ? `${wsUrl}?token=${encodeURIComponent(token)}` : wsUrl,\n    );\n    ws.binaryType = \"arraybuffer\";\n    wsRef.current = ws;\n\n    const audioCtx = new AudioContext({ sampleRate: AUDIO_SAMPLE_RATE });\n    audioCtxRef.current = audioCtx;\n\n    const analyser = audioCtx.createAnalyser();\n    analyser.fftSize = FFT_SIZE;\n    analyser.smoothingTimeConstant = 0.3;\n    analyserRef.current = analyser;\n\n    const scriptNode = audioCtx.createScriptProcessor(BUFFER_SIZE, 1, 1);\n    scriptNode.onaudioprocess = (e) => {\n      const output = e.outputBuffer.getChannelData(0);\n      const queue = bufferQueueRef.current;\n\n      let written = 0;\n      while (written < output.length && queue.length > 0) {\n        const chunk = queue[0];\n        const remaining = output.length - written;\n        const toWrite = Math.min(remaining, chunk.length);\n\n        for (let i = 0; i < toWrite; i++) {\n          output[written + i] = chunk[i] / 32768.0;\n        }\n        written += toWrite;\n\n        if (toWrite >= chunk.length) {\n          queue.shift();\n        } else {\n          queue[0] = chunk.slice(toWrite);\n        }\n      }\n\n      for (let i = written; i < output.length; i++) {\n        output[i] = 0;\n      }\n    };\n\n    scriptNode.connect(analyser);\n    analyser.connect(audioCtx.destination);\n\n    // Frequency data for waterfall\n    const freqBins = analyser.frequencyBinCount;\n    const dataArray = new Uint8Array(freqBins);\n\n    const drawWaterfall = () => {\n      if (!analyserRef.current) return;\n      analyserRef.current.getByteFrequencyData(dataArray);\n\n      // Calculate level\n      let sum = 0;\n      for (let i = 0; i < dataArray.length; i++) {\n        sum += dataArray[i];\n      }\n      setLevel(sum / dataArray.length / 255);\n\n      // Draw waterfall\n      const canvas = waterfallRef.current;\n      if (canvas) {\n        const ctx = canvas.getContext(\"2d\", { willReadFrequently: true });\n        if (ctx) {\n          const w = canvas.width;\n          const h = canvas.height;\n\n          // Scroll existing content down by 1 pixel\n          const existing = ctx.getImageData(0, 0, w, h - 1);\n          ctx.putImageData(existing, 0, 1);\n\n          // Draw new FFT line at top row\n          const lineData = ctx.createImageData(w, 1);\n          const pixels = lineData.data;\n\n          for (let x = 0; x < w; x++) {\n            // Map canvas x position to frequency bin\n            const binIndex = Math.floor((x / w) * freqBins);\n            const value = dataArray[Math.min(binIndex, freqBins - 1)];\n            const [r, g, b] = amplitudeToColor(value);\n            const idx = x * 4;\n            pixels[idx] = r;\n            pixels[idx + 1] = g;\n            pixels[idx + 2] = b;\n            pixels[idx + 3] = 255;\n          }\n\n          ctx.putImageData(lineData, 0, 0);\n        }\n      }\n\n      animFrameRef.current = requestAnimationFrame(drawWaterfall);\n    };\n    animFrameRef.current = requestAnimationFrame(drawWaterfall);\n\n    ws.onmessage = (event) => {\n      if (typeof event.data === \"string\") {\n        const msg = JSON.parse(event.data);\n        if (msg.type === \"info\") {\n          onInfo?.({\n            samplerate: msg.samplerate,\n            audio_rate: msg.audio_rate,\n          });\n        } else if (msg.error) {\n          onError?.(msg.error);\n        }\n        return;\n      }\n\n      // Binary: int16 PCM samples\n      const pcmData = new Int16Array(event.data);\n      bufferQueueRef.current.push(pcmData);\n\n      // Prevent buffer from growing too large (drop oldest)\n      while (bufferQueueRef.current.length > 20) {\n        bufferQueueRef.current.shift();\n      }\n    };\n\n    ws.onerror = () => {\n      onError?.(\"WebSocket connection error\");\n    };\n\n    ws.onclose = () => {\n      onDisconnect?.();\n    };\n\n    return () => {\n      scriptNode.disconnect();\n      cleanup();\n    };\n  }, [isPlaying, cleanup, onInfo, onError, onDisconnect]);\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      {/* Waterfall display */}\n      <div className=\"relative rounded overflow-hidden border border-border\">\n        <canvas\n          ref={waterfallRef}\n          width={800}\n          height={WATERFALL_HEIGHT}\n          className=\"w-full bg-black\"\n          style={{ height: `${WATERFALL_HEIGHT}px`, imageRendering: \"pixelated\" }}\n        />\n        {/* Frequency axis labels */}\n        <div className=\"absolute bottom-0 left-0 right-0 flex justify-between px-1 py-0.5 text-[10px] text-white/60 bg-black/40\">\n          <span>0 Hz</span>\n          <span>{AUDIO_SAMPLE_RATE / 4 / 1000} kHz</span>\n          <span>{AUDIO_SAMPLE_RATE / 2 / 1000} kHz</span>\n        </div>\n      </div>\n      <div className=\"text-xs text-muted-foreground\">\n        Level: {(level * 100).toFixed(0)}%\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/sdr/SdrDashboard.tsx",
    "content": "import { useCallback, useEffect, useState } from \"react\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Radio, Play, Square, Loader2 } from \"lucide-react\";\nimport { SdrAudioPlayer } from \"./SdrAudioPlayer\";\nimport * as sdrApi from \"./api\";\n\nexport function SdrDashboard() {\n  const [frequencyMhz, setFrequencyMhz] = useState(\"100.0\");\n  const [samplerate, setSamplerate] = useState<number | null>(null);\n  const [isPlaying, setIsPlaying] = useState(false);\n  const [isSettingFreq, setIsSettingFreq] = useState(false);\n  const [streamInfo, setStreamInfo] = useState<{\n    samplerate: number;\n    audio_rate: number;\n  } | null>(null);\n  const [error, setError] = useState<string | null>(null);\n\n  useEffect(() => {\n    sdrApi\n      .getSamplerate()\n      .then((res) => {\n        if (res.data.samplerate) {\n          setSamplerate(res.data.samplerate);\n        }\n      })\n      .catch(() => {});\n  }, []);\n\n  const handleSetFrequency = async () => {\n    const freqHz = parseFloat(frequencyMhz) * 1_000_000;\n    if (isNaN(freqHz) || freqHz <= 0) {\n      setError(\"Invalid frequency\");\n      return;\n    }\n    setIsSettingFreq(true);\n    setError(null);\n    try {\n      await sdrApi.setFrequency(freqHz);\n    } catch (err) {\n      setError(\n        err instanceof Error ? err.message : \"Failed to set frequency\",\n      );\n    } finally {\n      setIsSettingFreq(false);\n    }\n  };\n\n  const handleToggleAudio = () => {\n    setError(null);\n    setIsPlaying((prev) => !prev);\n  };\n\n  const handleInfo = useCallback(\n    (info: { samplerate: number; audio_rate: number }) => {\n      setStreamInfo(info);\n      setSamplerate(info.samplerate);\n    },\n    [],\n  );\n\n  const handleError = useCallback((err: string) => {\n    setError(err);\n    setIsPlaying(false);\n  }, []);\n\n  const handleDisconnect = useCallback(() => {\n    setIsPlaying(false);\n  }, []);\n\n  return (\n    <div className=\"flex flex-1 flex-col gap-4 p-4\">\n      <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n        {/* Frequency Control */}\n        <Card>\n          <CardHeader>\n            <CardDescription>Tuner</CardDescription>\n            <CardTitle className=\"flex items-center gap-2\">\n              <Radio className=\"h-5 w-5 text-purple-500\" />\n              Frequency Control\n            </CardTitle>\n          </CardHeader>\n          <CardContent className=\"flex flex-col gap-4\">\n            <div className=\"flex flex-col gap-2\">\n              <Label htmlFor=\"frequency\">Frequency (MHz)</Label>\n              <div className=\"flex gap-2\">\n                <Input\n                  id=\"frequency\"\n                  type=\"number\"\n                  step=\"0.001\"\n                  value={frequencyMhz}\n                  onChange={(e) => setFrequencyMhz(e.target.value)}\n                  placeholder=\"100.0\"\n                  onKeyDown={(e) => {\n                    if (e.key === \"Enter\") handleSetFrequency();\n                  }}\n                />\n                <Button\n                  onClick={handleSetFrequency}\n                  disabled={isSettingFreq}\n                  className=\"shrink-0\"\n                >\n                  {isSettingFreq ? (\n                    <Loader2 className=\"h-4 w-4 animate-spin\" />\n                  ) : (\n                    \"Set\"\n                  )}\n                </Button>\n              </div>\n            </div>\n          </CardContent>\n        </Card>\n\n        {/* Status */}\n        <Card>\n          <CardHeader>\n            <CardDescription>RTL-SDR</CardDescription>\n            <CardTitle>Status</CardTitle>\n          </CardHeader>\n          <CardContent className=\"flex flex-col gap-3\">\n            <div className=\"flex items-center justify-between\">\n              <span className=\"text-sm text-muted-foreground\">\n                Sample Rate\n              </span>\n              <Badge variant=\"secondary\">\n                {samplerate\n                  ? `${(samplerate / 1_000_000).toFixed(2)} MSps`\n                  : \"N/A\"}\n              </Badge>\n            </div>\n            {streamInfo && (\n              <div className=\"flex items-center justify-between\">\n                <span className=\"text-sm text-muted-foreground\">\n                  Audio Rate\n                </span>\n                <Badge variant=\"secondary\">\n                  {streamInfo.audio_rate / 1000} kHz\n                </Badge>\n              </div>\n            )}\n            <div className=\"flex items-center justify-between\">\n              <span className=\"text-sm text-muted-foreground\">Stream</span>\n              <Badge variant={isPlaying ? \"default\" : \"outline\"}>\n                {isPlaying ? \"Active\" : \"Stopped\"}\n              </Badge>\n            </div>\n          </CardContent>\n        </Card>\n      </div>\n\n      {/* Audio Stream */}\n      <Card>\n        <CardHeader>\n          <CardDescription>FM Audio</CardDescription>\n          <CardTitle className=\"flex items-center justify-between\">\n            <span>Audio Stream</span>\n            <Button\n              size=\"sm\"\n              variant={isPlaying ? \"destructive\" : \"default\"}\n              onClick={handleToggleAudio}\n            >\n              {isPlaying ? (\n                <>\n                  <Square className=\"h-4 w-4 mr-1\" /> Stop\n                </>\n              ) : (\n                <>\n                  <Play className=\"h-4 w-4 mr-1\" /> Start Audio\n                </>\n              )}\n            </Button>\n          </CardTitle>\n        </CardHeader>\n        <CardContent>\n          <SdrAudioPlayer\n            isPlaying={isPlaying}\n            onInfo={handleInfo}\n            onError={handleError}\n            onDisconnect={handleDisconnect}\n          />\n          {error && (\n            <p className=\"text-sm text-destructive mt-2\">{error}</p>\n          )}\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/sdr/api.ts",
    "content": "import { apiClient } from \"@/shared/api\";\n\nexport const setFrequency = (frequency: number) =>\n  apiClient.post(\"/sdr/frequency\", { frequency });\n\nexport const getSamplerate = () =>\n  apiClient.get<{ samplerate: number | null }>(\"/sdr/samplerate\");\n\nexport const startStream = () => apiClient.post(\"/sdr/start\");\n\nexport const stopStream = () => apiClient.post(\"/sdr/stop\");\n"
  },
  {
    "path": "apps/client/src/features/search/search-form.tsx",
    "content": "import { Search } from \"lucide-react\";\n\nimport { Label } from \"@/components/ui/label\";\nimport {\n  SidebarGroup,\n  SidebarGroupContent,\n  SidebarInput,\n} from \"@/components/ui/sidebar\";\n\nexport function SearchForm({ ...props }: React.ComponentProps<\"form\">) {\n  return (\n    <form {...props}>\n      <SidebarGroup className=\"py-0\">\n        <SidebarGroupContent className=\"relative\">\n          <Label htmlFor=\"search\" className=\"sr-only\">\n            Search\n          </Label>\n          <SidebarInput id=\"search\" placeholder=\"Search...\" className=\"pl-8\" />\n          <Search className=\"pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 opacity-50 select-none\" />\n        </SidebarGroupContent>\n      </SidebarGroup>\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/server-resource/resourceUsage.tsx",
    "content": "import {\n  Card,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { useCallback, useEffect, useState } from \"react\";\nimport { useWebSocket, useWebSocketMessage } from \"../ws/WebSocketProvider\";\nimport { WebSocketMessage } from \"../ws/ws\";\n\nexport default function ResourceUsage() {\n  const { wsManager } = useWebSocket();\n  const [info, setInfo] = useState({\n    cpu_usage: 0,\n    memory_usage: 0,\n  });\n\n  useEffect(() => {\n    if (!wsManager) return;\n\n    const requestServerInfo = () => {\n      wsManager.send({\n        type: \"get_server\",\n        payload: {},\n      });\n    };\n\n    requestServerInfo();\n    const intervalId = setInterval(requestServerInfo, 2000);\n\n    return () => {\n      clearInterval(intervalId);\n    };\n  }, [wsManager]);\n\n  const handleMessage = useCallback((msg: WebSocketMessage) => {\n    try {\n      if (msg.type === \"get_server\") {\n        setInfo({\n          ...(msg.payload as { cpu_usage: number; memory_usage: number }),\n        });\n      }\n    } catch (err) {\n      console.error(\"Error handling signaling message:\", err);\n    }\n  }, []);\n\n  useWebSocketMessage(handleMessage);\n\n  return (\n    <>\n      <Card className='@container/card'>\n        <CardHeader>\n          <CardDescription>CPU</CardDescription>\n          <CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>\n            {info.cpu_usage.toFixed(2)}%\n          </CardTitle>\n        </CardHeader>\n        <CardFooter className='flex-col items-start gap-1.5 text-sm'>\n          <div className='line-clamp-1 flex gap-2 font-medium'>Active</div>\n          <div className='text-muted-foreground'>Server CPU Usage %</div>\n        </CardFooter>\n      </Card>\n      <Card className='@container/card'>\n        <CardHeader>\n          <CardDescription>Memory</CardDescription>\n          <CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>\n            {info.memory_usage.toFixed(2)}%\n          </CardTitle>\n        </CardHeader>\n        <CardFooter className='flex-col items-start gap-1.5 text-sm'>\n          <div className='line-clamp-1 flex gap-2 font-medium'>Active</div>\n          <div className='text-muted-foreground'>Server Memory Usage %</div>\n        </CardFooter>\n      </Card>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/setup/index.tsx",
    "content": "import { getDevices } from \"@/entities/device/api\";\nimport { getAllEntities } from \"@/entities/entity/api\";\nimport { getFlows } from \"@/entities/flow/api\";\nimport { getIntegrationStatus } from \"@/entities/integrations/api\";\n\nexport type SetupStep = {\n  id: string;\n  title: string;\n  description: string;\n  isCompleted: boolean;\n  url: string;\n  verifyStatus: () => Promise<boolean>;\n};\n\nexport const initialSetupSteps: SetupStep[] = [\n  {\n    id: \"enable-config\",\n    title: \"Enable MQTT & UDP\",\n    description: \"Enable basic protocols\",\n    isCompleted: false,\n    url: \"/settings/config\",\n    verifyStatus: async () => {\n      try {\n        const status = await getIntegrationStatus();\n        return status.data.home_assistant.connected;\n      } catch {\n        return false;\n      }\n    },\n  },\n  {\n    id: \"add-device\",\n    title: \"Add Device\",\n    description: \"Add a new device.\",\n    isCompleted: false,\n    url: \"/devices\",\n    verifyStatus: async () => {\n      try {\n        const devices = await getDevices();\n        return devices.data.length > 0;\n      } catch {\n        return false;\n      }\n    },\n  },\n  {\n    id: \"add-sensor\",\n    title: \"Add Sensor\",\n    description: \"Add a sensor (entity) for the device.\",\n    isCompleted: false,\n    url: \"/devices\",\n    verifyStatus: async () => {\n      try {\n        const entity = await getAllEntities();\n        return entity.data.length > 0;\n      } catch {\n        return false;\n      }\n    },\n  },\n  {\n    id: \"first-flow\",\n    title: \"Setup a Flow\",\n    description: \"Creates the first flow.\",\n    isCompleted: false,\n    url: \"/flow\",\n    verifyStatus: async () => {\n      try {\n        const flows = await getFlows();\n        return flows.length > 0;\n      } catch {\n        return false;\n      }\n    },\n  },\n];\n"
  },
  {
    "path": "apps/client/src/features/sidebar/footer.tsx",
    "content": "import {\n  ChevronsUpDown,\n  Computer,\n  LogOut,\n  MessageSquareWarning,\n  User,\n} from \"lucide-react\";\n\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n  SidebarMenu,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  useSidebar,\n} from \"@/components/ui/sidebar\";\nimport { useLogout } from \"../auth/hook\";\nimport { useEffect, useState } from \"react\";\nimport { parseJwt } from \"@/lib/jwt\";\nimport { isDemoMode } from \"@/shared/demo\";\nimport { storage } from \"@/lib/storage\";\n\nconst openExternal = (url: string) => {\n  import(\"@tauri-apps/plugin-shell\")\n    .then(({ open }) => open(url))\n    .catch(() => window.open(url, \"_blank\", \"noopener,noreferrer\"));\n};\n\nexport function NavFooter({\n  user,\n}: {\n  user: {\n    name: string;\n    email: string;\n    avatar: string;\n  };\n}) {\n  const { isMobile } = useSidebar();\n  const { logout } = useLogout();\n  const [userId, setUserId] = useState<string>(isDemoMode ? \"demo\" : \"\");\n\n  useEffect(() => {\n    if (isDemoMode) return;\n    const token = storage.getToken();\n    if (token) {\n      const parse = parseJwt(token);\n      if (parse?.sub && typeof parse.sub === \"string\") {\n        setUserId(parse.sub);\n      }\n    }\n  }, []);\n\n  return (\n    <SidebarMenu>\n      <SidebarMenuItem>\n        <DropdownMenu>\n          <DropdownMenuTrigger asChild>\n            <SidebarMenuButton\n              size='lg'\n              className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'\n            >\n              <Avatar className='h-8 w-8 '>\n                <AvatarFallback className=''>\n                  <User />\n                </AvatarFallback>\n              </Avatar>\n              <div className='grid flex-1 text-left text-sm leading-tight'>\n                <span className='truncate font-medium'>{userId}</span>\n                <span className='truncate text-xs'>{user.email}</span>\n              </div>\n              <ChevronsUpDown className='ml-auto size-4' />\n            </SidebarMenuButton>\n          </DropdownMenuTrigger>\n          <DropdownMenuContent\n            className='w-(--radix-dropdown-menu-trigger-width) min-w-56  z-[999999]'\n            side={isMobile ? \"bottom\" : \"right\"}\n            align='end'\n            sideOffset={4}\n          >\n            <DropdownMenuLabel className='p-0 font-normal'>\n              <div className='flex items-center gap-2 px-1 py-1.5 text-left text-sm'>\n                <Avatar className='h-8 w-8 '>\n                  <AvatarImage src={user.avatar} alt={userId} />\n                  <AvatarFallback className=''>\n                    <User />\n                  </AvatarFallback>\n                </Avatar>\n                <div className='grid flex-1 text-left text-sm leading-tight'>\n                  <span className='truncate font-medium'>{userId}</span>\n                  <span className='truncate text-xs'>{user.email}</span>\n                </div>\n              </div>\n            </DropdownMenuLabel>\n            <DropdownMenuSeparator />\n            <DropdownMenuGroup>\n              <DropdownMenuItem\n                onClick={() =>\n                  openExternal(\"https://github.com/cartesiancs/vessel\")\n                }\n              >\n                <Computer />\n                GitHub\n              </DropdownMenuItem>\n              <DropdownMenuItem\n                onClick={() =>\n                  openExternal(\"https://github.com/cartesiancs/vessel/issues\")\n                }\n              >\n                <MessageSquareWarning />\n                Report\n              </DropdownMenuItem>\n            </DropdownMenuGroup>\n            <DropdownMenuSeparator />\n            <DropdownMenuItem onClick={logout}>\n              <LogOut />\n              Log out\n            </DropdownMenuItem>\n          </DropdownMenuContent>\n        </DropdownMenu>\n      </SidebarMenuItem>\n    </SidebarMenu>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/sidebar/index.tsx",
    "content": "import {\n  Sidebar,\n  SidebarContent,\n  SidebarFooter,\n  SidebarGroup,\n  SidebarGroupContent,\n  SidebarGroupLabel,\n  SidebarHeader,\n  SidebarMenu,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  SidebarMenuSub,\n  SidebarMenuSubButton,\n  SidebarMenuSubItem,\n  SidebarRail,\n} from \"@/components/ui/sidebar\";\nimport { AccountSwitcher } from \"../account-switcher\";\nimport { useLocation, useNavigate } from \"react-router\";\nimport {\n  LayoutDashboard,\n  MonitorSmartphone,\n  Workflow,\n  Map,\n  CircleDashed,\n  Code,\n  RadioTower,\n  ChevronDown,\n  ChevronRight,\n  Video,\n} from \"lucide-react\";\nimport { NavFooter } from \"./footer\";\nimport { isElectron } from \"@/lib/electron\";\nimport { useDynamicDashboardStore } from \"@/entities/dynamic-dashboard/store\";\nimport { useIntegrationStore } from \"@/entities/integrations/store\";\nimport { useConfigStore } from \"@/entities/configurations/store\";\nimport { getCodeServiceEnabled } from \"@/entities/configurations/codeService\";\nimport {\n  ComponentProps,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nconst CONTROLS_OPEN_KEY = \"controls-menu-open\";\nconst SIDEBAR_SCROLL_KEY = \"sidebar-scroll-top\";\n\nconst data = {\n  versions: [\"main\"],\n  navMain: [\n    {\n      title: \"Dashboard\",\n      url: \"#\",\n      items: [\n        {\n          title: \"Controls\",\n          url: \"/dashboard\",\n          icon: <LayoutDashboard />,\n        },\n        {\n          title: \"Flow\",\n          url: \"/flow\",\n          icon: <Workflow />,\n        },\n        {\n          title: \"Map\",\n          url: \"/map\",\n          icon: <Map />,\n        },\n      ],\n    },\n    {\n      title: \"Devices\",\n      url: \"#\",\n      items: [\n        {\n          title: \"Devices\",\n          url: \"/devices\",\n          icon: <MonitorSmartphone />,\n        },\n        {\n          title: \"Recordings\",\n          url: \"/recordings\",\n          icon: <Video />,\n        },\n      ],\n    },\n    {\n      title: \"Services\",\n      url: \"#\",\n      items: [\n        {\n          title: \"Code\",\n          url: \"/code\",\n          icon: <Code />,\n        },\n      ],\n    },\n    {\n      title: \"Setting\",\n      url: \"#\",\n      items: [\n        {\n          title: \"Setup\",\n          url: \"/setup\",\n          icon: <CircleDashed />,\n        },\n        {\n          title: \"Settings\",\n          url: \"/settings\",\n          icon: <RadioTower />,\n        },\n      ],\n    },\n  ],\n};\n\nexport function AppSidebar({ ...props }: ComponentProps<typeof Sidebar>) {\n  const location = useLocation();\n  const currentPath = location.pathname;\n  const navigate = useNavigate();\n  const {\n    dashboards,\n    loadDashboards,\n    hasLoaded,\n    isLoading,\n    setActiveDashboard,\n    activeDashboardId,\n  } = useDynamicDashboardStore();\n  const { isHaConnected, isRos2Connected, isSdrConnected, fetchStatus } =\n    useIntegrationStore();\n  const { configurations, fetchConfigs } = useConfigStore();\n\n  const scrollRef = useRef<HTMLDivElement>(null);\n\n  const navMain = useMemo(() => {\n    const codeOn = getCodeServiceEnabled(configurations);\n    return data.navMain\n      .map((group) => {\n        if (group.title !== \"Services\") {\n          return group;\n        }\n        const items = group.items.filter(\n          (item) => item.title !== \"Code\" || codeOn,\n        );\n        if (items.length === 0) {\n          return null;\n        }\n        return { ...group, items };\n      })\n      .filter((g): g is (typeof data.navMain)[number] => g !== null);\n  }, [configurations]);\n\n  const [controlsOpen, setControlsOpen] = useState<boolean>(() => {\n    if (typeof window === \"undefined\") return true;\n    try {\n      const saved = localStorage.getItem(CONTROLS_OPEN_KEY);\n      return saved === null ? true : saved === \"true\";\n    } catch {\n      return true;\n    }\n  });\n\n  const dashboardView = useMemo(() => {\n    const params = new URLSearchParams(location.search);\n    return params.get(\"view\") || \"main\";\n  }, [location.search]);\n\n  const controlViews = useMemo(() => {\n    return [\n      { id: \"main\", name: \"Dashboard\" },\n      isHaConnected && { id: \"ha\", name: \"Home Assistant\" },\n      isRos2Connected && { id: \"ros2\", name: \"ROS2\" },\n      isSdrConnected && { id: \"sdr\", name: \"RTL-SDR\" },\n    ].filter(Boolean) as { id: string; name: string }[];\n  }, [isHaConnected, isRos2Connected, isSdrConnected]);\n\n  const handleScroll = useCallback(() => {\n    const el = scrollRef.current;\n    if (el) {\n      sessionStorage.setItem(SIDEBAR_SCROLL_KEY, String(el.scrollTop));\n    }\n  }, []);\n\n  useEffect(() => {\n    const el = scrollRef.current;\n    if (!el) return;\n    const saved = sessionStorage.getItem(SIDEBAR_SCROLL_KEY);\n    if (saved !== null) {\n      requestAnimationFrame(() => {\n        el.scrollTop = Number(saved);\n      });\n    }\n  }, [currentPath]);\n\n  useEffect(() => {\n    try {\n      localStorage.setItem(CONTROLS_OPEN_KEY, controlsOpen ? \"true\" : \"false\");\n    } catch {\n      // ignore storage write errors\n    }\n  }, [controlsOpen]);\n\n  useEffect(() => {\n    if (!hasLoaded && !isLoading) {\n      loadDashboards();\n    }\n  }, [hasLoaded, isLoading, loadDashboards]);\n\n  useEffect(() => {\n    fetchStatus();\n  }, [fetchStatus]);\n\n  useEffect(() => {\n    void fetchConfigs();\n  }, [fetchConfigs]);\n\n  return (\n    <Sidebar\n      {...props}\n      style={{\n        top: isElectron() ? \"34px\" : \"0\",\n        height: isElectron() ? \"calc(100% - 34px)\" : \"100%\",\n      }}\n    >\n      <SidebarHeader>\n        <AccountSwitcher\n          versions={data.versions}\n          defaultVersion={data.versions[0]}\n        />\n      </SidebarHeader>\n      <SidebarContent ref={scrollRef} onScroll={handleScroll}>\n        {navMain.map((group) => (\n          <SidebarGroup key={group.title}>\n            <SidebarGroupLabel>{group.title}</SidebarGroupLabel>\n            <SidebarGroupContent>\n              <SidebarMenu>\n                {group.items.map((item) => {\n                  const isControls = item.url === \"/dashboard\";\n                  const isActive =\n                    currentPath === item.url ||\n                    (isControls &&\n                      currentPath.startsWith(\"/dynamic-dashboard\"));\n                  return (\n                    <SidebarMenuItem key={item.title}>\n                      <SidebarMenuButton\n                        asChild\n                        isActive={isActive}\n                        onClick={() => {\n                          if (isControls) {\n                            setControlsOpen((prev) => !prev);\n                            navigate(item.url);\n                          } else {\n                            navigate(item.url);\n                          }\n                        }}\n                      >\n                        <span>\n                          {item.icon} {item.title}\n                          {isControls && (\n                            <span className='ml-auto flex items-center'>\n                              {controlsOpen ? (\n                                <ChevronDown className='h-4 w-4 text-muted-foreground' />\n                              ) : (\n                                <ChevronRight className='h-4 w-4 text-muted-foreground' />\n                              )}\n                            </span>\n                          )}\n                        </span>\n                      </SidebarMenuButton>\n\n                      {isControls && controlsOpen && (\n                        <SidebarMenuSub>\n                          {controlViews.map((view) => {\n                            const subActive =\n                              currentPath === \"/dashboard\" &&\n                              dashboardView === view.id;\n                            return (\n                              <SidebarMenuSubItem key={view.id}>\n                                <SidebarMenuSubButton\n                                  isActive={subActive}\n                                  onClick={() => {\n                                    navigate(`/dashboard?view=${view.id}`);\n                                  }}\n                                >\n                                  <span className='truncate'>{view.name}</span>\n                                </SidebarMenuSubButton>\n                              </SidebarMenuSubItem>\n                            );\n                          })}\n                          {dashboards.map((db) => {\n                            const subActive =\n                              currentPath === `/dynamic-dashboard/${db.id}` ||\n                              (currentPath.startsWith(\"/dynamic-dashboard\") &&\n                                activeDashboardId === db.id);\n                            return (\n                              <SidebarMenuSubItem key={db.id}>\n                                <SidebarMenuSubButton\n                                  isActive={subActive}\n                                  onClick={() => {\n                                    setActiveDashboard(db.id);\n                                    navigate(`/dynamic-dashboard/${db.id}`);\n                                  }}\n                                >\n                                  <span className='truncate'>{db.name}</span>\n                                </SidebarMenuSubButton>\n                              </SidebarMenuSubItem>\n                            );\n                          })}\n                        </SidebarMenuSub>\n                      )}\n                    </SidebarMenuItem>\n                  );\n                })}\n              </SidebarMenu>\n            </SidebarGroupContent>\n          </SidebarGroup>\n        ))}\n      </SidebarContent>\n      <SidebarRail />\n      <SidebarFooter>\n        <NavFooter\n          user={{\n            name: \"Manager\",\n            email: \"none\",\n            avatar: \"\",\n          }}\n        />\n      </SidebarFooter>\n    </Sidebar>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/stat/index.tsx",
    "content": "import {\n  Card,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { useStatStore } from \"@/entities/stat/store\";\nimport { useEffect } from \"react\";\n\nexport default function StatBlock() {\n  const { stat, fetchStat } = useStatStore();\n\n  useEffect(() => {\n    fetchStat();\n  }, [fetchStat]);\n\n  return (\n    <>\n      <Card className='@container/card'>\n        <CardHeader>\n          <CardDescription>Devices</CardDescription>\n          <CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>\n            {stat.count.devices}\n          </CardTitle>\n        </CardHeader>\n        <CardFooter className='flex-col items-start gap-1.5 text-sm'>\n          <div className='line-clamp-1 flex gap-2 font-medium'>Active</div>\n          <div className='text-muted-foreground'>\n            Total devices in the system\n          </div>\n        </CardFooter>\n      </Card>\n      <Card className='@container/card'>\n        <CardHeader>\n          <CardDescription>Entities</CardDescription>\n          <CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>\n            {stat.count.entities}\n          </CardTitle>\n        </CardHeader>\n        <CardFooter className='flex-col items-start gap-1.5 text-sm'>\n          <div className='line-clamp-1 flex gap-2 font-medium'>Active</div>\n          <div className='text-muted-foreground'>\n            Total entities across all devices\n          </div>\n        </CardFooter>\n      </Card>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/topbar/index.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { ArrowLeft, ArrowRight, RotateCcw } from \"lucide-react\";\nimport { useLocation, useNavigate } from \"react-router\";\n\nexport function TopBar({ hide = false }: { hide?: boolean }) {\n  const navigate = useNavigate();\n  const location = useLocation();\n  const [canBack, setCanBack] = useState(false);\n  const [canForward, setCanForward] = useState(false);\n\n  useEffect(() => {\n    const idx = window.history.state?.idx ?? 0;\n    const length = window.history.length;\n    setCanBack(idx > 0);\n    setCanForward(idx < length - 2);\n  }, [location]);\n\n  return (\n    <div\n      className=\"\n        fixed top-0 left-0 w-full h-[34px]\n        bg-[oklch(0.145_0_0)] border-b-[0.75px] border-[#3a3f44]\n        flex items-center justify-center z-[999]\n        [-webkit-app-region:drag]\n      \"\n    >\n      {!hide && (\n        <>\n          <button\n            onClick={() => navigate(-1)}\n            disabled={!canBack}\n            className=\"\n          flex items-center justify-center h-full p-1\n          [-webkit-app-region:no-drag]\n          disabled:opacity-50 disabled:cursor-not-allowed\n        \"\n          >\n            <ArrowLeft size={14} />\n          </button>\n          <button\n            onClick={() => {\n              navigate(1);\n            }}\n            disabled={!canForward}\n            className=\"\n          flex items-center justify-center h-full p-1 ml-2\n          [-webkit-app-region:no-drag]\n          disabled:opacity-50 disabled:cursor-not-allowed\n        \"\n          >\n            <ArrowRight size={14} />\n          </button>\n          <span className=\"text-xs text-gray-400 mx-2 mx-[16px]\">Vessel</span>\n          <button\n            onClick={() => {\n              window.location.reload();\n            }}\n            className=\"\n          flex items-center justify-center h-full p-1 ml-2\n          [-webkit-app-region:no-drag]\n          disabled:opacity-50 disabled:cursor-not-allowed\n        \"\n          >\n            <RotateCcw size={14} />\n          </button>\n        </>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/features/topbar/style.css",
    "content": ".top-bar {\n  position: fixed;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 34px;\n  background-color: oklch(0.145 0 0);\n  border-bottom: 0.05rem #3a3f44 solid;\n  -webkit-app-region: drag;\n  display: flex;\n  color: #6c757d;\n  font-size: 12px;\n  font-weight: 700;\n  justify-content: center;\n  align-items: center;\n  z-index: 999;\n}\n"
  },
  {
    "path": "apps/client/src/features/user/UserRoleAssigner.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from \"@/components/ui/command\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { useRoleStore } from \"@/entities/role/store\";\nimport { Role } from \"@/entities/role/types\";\nimport { assignRoleToUser, revokeRoleFromUser } from \"@/entities/user/api\";\nimport { cn } from \"@/lib/utils\";\nimport { Check, ChevronsUpDown } from \"lucide-react\";\nimport { FC, useEffect, useState } from \"react\";\nimport { toast } from \"sonner\";\n\ninterface UserRoleAssignerProps {\n  userId?: number | null;\n  initialRoles: Role[];\n}\n\nexport const UserRoleAssigner: FC<UserRoleAssignerProps> = ({\n  userId,\n  initialRoles,\n}) => {\n  const { roles, fetchRoles } = useRoleStore();\n  const [isOpen, setIsOpen] = useState(false);\n  const [selectedRoles, setSelectedRoles] = useState<Role[]>(initialRoles);\n  const [isSubmitting, setIsSubmitting] = useState(false);\n\n  useEffect(() => {\n    fetchRoles();\n  }, [fetchRoles]);\n\n  useEffect(() => {\n    setSelectedRoles(initialRoles);\n  }, [initialRoles]);\n\n  if (userId == null) {\n    return null;\n  }\n\n  const handleSelect = async (role: Role) => {\n    if (isSubmitting) return;\n\n    const isSelected = selectedRoles.some((r) => r.id === role.id);\n    const optimisticState = isSelected\n      ? selectedRoles.filter((r) => r.id !== role.id)\n      : [...selectedRoles, role];\n\n    setSelectedRoles(optimisticState);\n    setIsSubmitting(true);\n\n    try {\n      if (isSelected) {\n        await revokeRoleFromUser(userId, role.id);\n        toast.success(`Role \"${role.name}\" has been revoked.`);\n      } else {\n        await assignRoleToUser(userId, role.id);\n        toast.success(`Role \"${role.name}\" has been assigned.`);\n      }\n    } catch (error) {\n      console.error(\"Failed to update user role:\", error);\n      toast.error(\"Failed to update role. Please try again.\");\n      setSelectedRoles(selectedRoles);\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  return (\n    <Popover open={isOpen} onOpenChange={setIsOpen}>\n      <PopoverTrigger asChild>\n        <Button\n          variant='outline'\n          role='combobox'\n          aria-expanded={isOpen}\n          className='w-full justify-between col-span-3'\n          disabled={isSubmitting}\n        >\n          {selectedRoles.length > 0\n            ? `${selectedRoles.length} selected`\n            : \"Select roles...\"}\n          <ChevronsUpDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className='w-[--radix-popover-trigger-width] p-0'>\n        <Command>\n          <CommandInput placeholder='Search roles...' />\n          <CommandList>\n            <CommandEmpty>No roles found.</CommandEmpty>\n            <CommandGroup>\n              {roles.map((role) => (\n                <CommandItem\n                  key={role.id}\n                  value={role.name}\n                  onSelect={() => handleSelect(role)}\n                  disabled={isSubmitting}\n                >\n                  <Check\n                    className={cn(\n                      \"mr-2 h-4 w-4\",\n                      selectedRoles.some((r) => r.id === role.id)\n                        ? \"opacity-100\"\n                        : \"opacity-0\",\n                    )}\n                  />\n                  {role.name}\n                </CommandItem>\n              ))}\n            </CommandGroup>\n          </CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/client/src/features/user/userAdd.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { CreateUserPayload, UpdateUserPayload } from \"@/entities/user/types\";\nimport { PlusCircle } from \"lucide-react\";\nimport { FC, useState } from \"react\";\nimport { UserForm } from \"./userForm\";\nimport { useUserStore } from \"@/entities/user/store\";\n\nexport const AddUser: FC = () => {\n  const [isOpen, setIsOpen] = useState(false);\n  const [isSubmitting, setIsSubmitting] = useState(false);\n  const addUser = useUserStore((state) => state.addUser);\n\n  const handleSubmit = async (data: CreateUserPayload | UpdateUserPayload) => {\n    setIsSubmitting(true);\n    try {\n      await addUser(data as CreateUserPayload);\n      setIsOpen(false);\n    } catch (e) {\n      console.error(\"Failed to add user from component\", e);\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  return (\n    <>\n      <Button onClick={() => setIsOpen(true)}>\n        <PlusCircle className='mr-2 h-4 w-4' /> Add User\n      </Button>\n      <Dialog open={isOpen} onOpenChange={setIsOpen}>\n        <DialogHeader className='p-6 pb-0'>\n          <DialogTitle>Add New User</DialogTitle>\n          <DialogDescription>\n            Fill in the details below to create a new user.\n          </DialogDescription>\n        </DialogHeader>\n        <DialogContent>\n          <UserForm\n            onSubmit={handleSubmit}\n            onCancel={() => setIsOpen(false)}\n            isSubmitting={isSubmitting}\n          />\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n};\n\nexport const AddUserDialog: FC<{\n  isOpen: boolean;\n  onOpenChange: (open: boolean) => void;\n}> = ({ isOpen, onOpenChange }) => {\n  const [isSubmitting, setIsSubmitting] = useState(false);\n  const addUser = useUserStore((state) => state.addUser);\n\n  const handleSubmit = async (data: CreateUserPayload | UpdateUserPayload) => {\n    setIsSubmitting(true);\n    try {\n      await addUser(data as CreateUserPayload);\n      onOpenChange(false);\n    } catch (e) {\n      console.error(\"Failed to add user from component\", e);\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  return (\n    <Dialog open={isOpen} onOpenChange={onOpenChange}>\n      <DialogContent>\n        <DialogHeader>\n          <DialogTitle>Add New User</DialogTitle>\n        </DialogHeader>\n        <UserForm\n          onSubmit={handleSubmit}\n          onCancel={() => onOpenChange(false)}\n          isSubmitting={isSubmitting}\n        />\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/client/src/features/user/userDelete.tsx",
    "content": "import {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { DropdownMenuItem } from \"@/components/ui/dropdown-menu\";\nimport { useUserStore } from \"@/entities/user/store\";\nimport { Trash2 } from \"lucide-react\";\nimport { FC, useState } from \"react\";\n\ninterface DeleteUserProps {\n  userId: number;\n}\n\nexport const DeleteUser: FC<DeleteUserProps> = ({ userId }) => {\n  const [isOpen, setIsOpen] = useState(false);\n  const removeUser = useUserStore((state) => state.removeUser);\n\n  const handleConfirm = async () => {\n    try {\n      await removeUser(userId);\n    } catch (e) {\n      console.error(\"Failed to delete user from component\", e);\n    }\n  };\n\n  return (\n    <>\n      <DropdownMenuItem\n        onSelect={() => setIsOpen(true)}\n        className='text-destructive focus:text-destructive'\n      >\n        <Trash2 className='mr-2 h-4 w-4' />\n        <span>Delete</span>\n      </DropdownMenuItem>\n      <AlertDialog open={isOpen} onOpenChange={setIsOpen}>\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>\n            <AlertDialogDescription>\n              This action cannot be undone. This will permanently delete the\n              user.\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel onClick={() => setIsOpen(false)} />\n            <AlertDialogAction\n              className='bg-destructive text-destructive-foreground hover:bg-destructive/90'\n              onClick={() => {\n                handleConfirm();\n                setIsOpen(false);\n              }}\n            />\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </>\n  );\n};\n\nexport const DeleteUserDialog: FC<{\n  userId: number | null;\n  isOpen: boolean;\n  onOpenChange: (open: boolean) => void;\n}> = ({ userId, isOpen, onOpenChange }) => {\n  const removeUser = useUserStore((state) => state.removeUser);\n\n  if (!userId) return null;\n\n  const handleConfirm = async () => {\n    try {\n      await removeUser(userId);\n      onOpenChange(false);\n    } catch (e) {\n      console.error(\"Failed to delete user from component\", e);\n    }\n  };\n\n  return (\n    <AlertDialog open={isOpen} onOpenChange={onOpenChange}>\n      <AlertDialogContent>\n        <AlertDialogHeader>\n          <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>\n          <AlertDialogDescription>\n            This action cannot be undone. This will permanently delete the user.\n          </AlertDialogDescription>\n        </AlertDialogHeader>\n        <AlertDialogFooter>\n          <AlertDialogCancel onClick={() => onOpenChange(false)}>\n            Close\n          </AlertDialogCancel>\n          <AlertDialogAction onClick={handleConfirm}>Delete</AlertDialogAction>\n        </AlertDialogFooter>\n      </AlertDialogContent>\n    </AlertDialog>\n  );\n};\n"
  },
  {
    "path": "apps/client/src/features/user/userEdit.tsx",
    "content": "import {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { useUserStore } from \"@/entities/user/store\";\nimport {\n  User,\n  UpdateUserPayload,\n  CreateUserPayload,\n} from \"@/entities/user/types\";\nimport { Edit } from \"lucide-react\";\nimport { FC, useState } from \"react\";\nimport { UserForm } from \"./userForm\";\nimport { DropdownMenuItem } from \"@/components/ui/dropdown-menu\";\n\ninterface EditUserProps {\n  user: User;\n}\n\nexport const EditUser: FC<EditUserProps> = ({ user }) => {\n  const [isOpen, setIsOpen] = useState(false);\n  const [isSubmitting, setIsSubmitting] = useState(false);\n  const editUser = useUserStore((state) => state.editUser);\n\n  const handleSubmit = async (data: UpdateUserPayload) => {\n    setIsSubmitting(true);\n    try {\n      await editUser(user.id, data);\n      setIsOpen(false);\n    } catch (e) {\n      console.error(\"Failed to edit user from component\", e);\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  return (\n    <>\n      <DropdownMenuItem onSelect={() => setIsOpen(true)}>\n        <Edit className='mr-2 h-4 w-4' />\n        <span>Edit</span>\n      </DropdownMenuItem>\n      <Dialog open={isOpen} onOpenChange={setIsOpen}>\n        <DialogContent>\n          <DialogHeader className='p-6 pb-0'>\n            <DialogTitle>Edit User</DialogTitle>\n            <DialogDescription>Update the user's details.</DialogDescription>\n          </DialogHeader>\n          <UserForm\n            user={user}\n            onSubmit={handleSubmit}\n            onCancel={() => setIsOpen(false)}\n            isSubmitting={isSubmitting}\n          />\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n};\n\nexport const EditUserDialog: FC<{\n  user: User | null;\n  isOpen: boolean;\n  onOpenChange: (open: boolean) => void;\n}> = ({ user, isOpen, onOpenChange }) => {\n  const [isSubmitting, setIsSubmitting] = useState(false);\n  const editUser = useUserStore((state) => state.editUser);\n\n  if (!user) return null;\n\n  const handleSubmit = async (data: CreateUserPayload | UpdateUserPayload) => {\n    setIsSubmitting(true);\n    try {\n      await editUser(user.id, data as UpdateUserPayload);\n      onOpenChange(false);\n    } catch (e) {\n      console.error(\"Failed to edit user from component\", e);\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  return (\n    <Dialog open={isOpen} onOpenChange={onOpenChange}>\n      <DialogContent>\n        <DialogHeader>\n          <DialogTitle>Edit User</DialogTitle>\n        </DialogHeader>\n        <UserForm\n          user={user}\n          onSubmit={handleSubmit}\n          onCancel={() => onOpenChange(false)}\n          isSubmitting={isSubmitting}\n        />\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/client/src/features/user/userForm.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { DialogFooter } from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n  User,\n  CreateUserPayload,\n  UpdateUserPayload,\n} from \"@/entities/user/types\";\nimport { Label } from \"@radix-ui/react-label\";\nimport { FC, useState } from \"react\";\nimport { UserRoleAssigner } from \"./UserRoleAssigner\";\n\ninterface UserFormProps {\n  user?: User;\n  onSubmit: (data: CreateUserPayload | UpdateUserPayload) => void;\n  onCancel: () => void;\n  isSubmitting: boolean;\n}\n\nexport const UserForm: FC<UserFormProps> = ({\n  user,\n  onSubmit,\n  onCancel,\n  isSubmitting,\n}) => {\n  const [username, setUsername] = useState(user?.username || \"\");\n  const [email, setEmail] = useState(user?.email || \"\");\n  const [password, setPassword] = useState(\"\");\n  const [error, setError] = useState(\"\");\n\n  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n    if (!username || !email || (!user && !password)) {\n      setError(\"Please fill in all required fields.\");\n      return;\n    }\n    setError(\"\");\n    const payload: CreateUserPayload | UpdateUserPayload = {\n      username,\n      email,\n      ...(password && { password }),\n    };\n    onSubmit(payload);\n  };\n\n  return (\n    <form onSubmit={handleSubmit}>\n      <div className='grid gap-4 py-4'>\n        <div className='grid grid-cols-4 items-center gap-4'>\n          <Label htmlFor='username' className='text-right'>\n            Username\n          </Label>\n          <Input\n            id='username'\n            value={username}\n            onChange={(e) => setUsername(e.target.value)}\n            className='col-span-3'\n          />\n        </div>\n        <div className='grid grid-cols-4 items-center gap-4'>\n          <Label htmlFor='email' className='text-right'>\n            Email\n          </Label>\n          <Input\n            id='email'\n            type='email'\n            value={email}\n            onChange={(e) => setEmail(e.target.value)}\n            className='col-span-3'\n          />\n        </div>\n        <div className='grid grid-cols-4 items-center gap-4'>\n          <Label htmlFor='password' className='text-right'>\n            Password\n          </Label>\n          <Input\n            id='password'\n            type='password'\n            placeholder={user ? \"Leave blank to keep current password\" : \"\"}\n            value={password}\n            onChange={(e) => setPassword(e.target.value)}\n            className='col-span-3'\n          />\n        </div>\n        {user && (\n          <div className='grid grid-cols-4 items-center gap-4'>\n            <Label htmlFor='password' className='text-right'>\n              Roles\n            </Label>\n            <UserRoleAssigner\n              userId={user?.id || null}\n              initialRoles={user?.roles || []}\n            />\n          </div>\n        )}\n\n        {error && (\n          <p className='text-sm text-destructive col-span-4 text-center'>\n            {error}\n          </p>\n        )}\n      </div>\n      <DialogFooter>\n        <Button type='button' variant='outline' onClick={onCancel}>\n          Cancel\n        </Button>\n        <Button type='submit' disabled={isSubmitting}>\n          {isSubmitting ? \"Saving...\" : \"Save\"}\n        </Button>\n      </DialogFooter>\n    </form>\n  );\n};\n"
  },
  {
    "path": "apps/client/src/features/ws/FlowUiEventBridge.tsx",
    "content": "import { useCallback, useMemo } from \"react\";\nimport { useWebSocketMessage } from \"./WebSocketProvider\";\nimport {\n  getFlowRunSessionId,\n  type FlowUiEventPayload,\n  type WebSocketMessage,\n} from \"./ws\";\nimport { createFlowUiEventRouter, mergeFlowUiAdapters } from \"./flowUiEventRouter\";\nimport { toastFlowUiAdapter } from \"./flowUiAdapters/toastFlowUiAdapter\";\n\n/** Subscribes once app-wide: routes `flow_ui_event` → adapters for this tab's session only. */\nexport function FlowUiEventBridge() {\n  const sessionId = useMemo(() => getFlowRunSessionId(), []);\n\n  const router = useMemo(\n    () =>\n      createFlowUiEventRouter(\n        mergeFlowUiAdapters([\"toast\", toastFlowUiAdapter]),\n      ),\n    [],\n  );\n\n  const handleMessage = useCallback(\n    (msg: WebSocketMessage) => {\n      if (msg.type !== \"flow_ui_event\") return;\n      const payload = msg.payload as FlowUiEventPayload;\n      if (\n        !payload?.target?.session_id ||\n        payload.target.session_id !== sessionId\n      )\n        return;\n      router.dispatch(payload);\n    },\n    [router, sessionId],\n  );\n\n  useWebSocketMessage(handleMessage);\n  return null;\n}\n"
  },
  {
    "path": "apps/client/src/features/ws/IsConnected.tsx",
    "content": "import React from \"react\";\nimport { useWebSocket } from \"./WebSocketProvider\";\n\nfunction Offline() {\n  return (\n    <span className='flex flex-row text-gray-600 justify-center text-xs items-center gap-1 font-light h-[16px]'>\n      <span className='w-2 h-2 bg-gray-600 rounded'></span> Offline\n    </span>\n  );\n}\n\nfunction Online() {\n  return (\n    <span className='flex flex-row text-emerald-500 justify-center text-xs items-center gap-1 font-light h-[16px]'>\n      <span className='w-2 h-2 bg-emerald-600 rounded'></span> Connented\n    </span>\n  );\n}\n\nexport function WebSocketStatusIndicator() {\n  const { isConnected } = useWebSocket();\n\n  if (isConnected) {\n    return <Online />;\n  } else {\n    return <Offline />;\n  }\n}\n"
  },
  {
    "path": "apps/client/src/features/ws/WebSocketProvider.tsx",
    "content": "import React, {\n  createContext,\n  useContext,\n  useEffect,\n  useState,\n  useCallback,\n  ReactNode,\n  useRef,\n} from \"react\";\nimport { WebSocketChannel, WebSocketMessage } from \"../ws/ws\";\nimport { isDemoMode } from \"@/shared/demo\";\nimport { MockWebSocketChannel } from \"./wsMock\";\n\ntype WebSocketManager = WebSocketChannel | MockWebSocketChannel;\n\ninterface WebSocketContextState {\n  wsManager: WebSocketManager;\n  isConnected: boolean;\n  sendMessage: (message: WebSocketMessage) => void;\n}\n\nconst WebSocketContext = createContext<WebSocketContextState | undefined>(\n  undefined,\n);\n\nexport const WebSocketProvider: React.FC<{\n  url: string;\n  children: ReactNode;\n}> = ({ url, children }) => {\n  const managerRef = useRef<WebSocketManager>(\n    isDemoMode ? new MockWebSocketChannel() : new WebSocketChannel(),\n  );\n  const [isConnected, setIsConnected] = useState(\n    managerRef.current.isConnected(),\n  );\n\n  useEffect(() => {\n    const wsManager = managerRef.current;\n\n    wsManager.onopen = () => {\n      console.log(\"Connected to WebSocket server\");\n      setIsConnected(true);\n    };\n\n    wsManager.onclose = () => {\n      console.log(\"Disconnected from WebSocket server\");\n      setIsConnected(false);\n    };\n\n    if (url && !wsManager.isConnected()) {\n      wsManager.connect(url);\n    }\n\n    return () => {\n      wsManager.onopen = null;\n      wsManager.onclose = null;\n      wsManager.close();\n    };\n  }, [url]);\n\n  const sendMessage = useCallback((message: WebSocketMessage) => {\n    managerRef.current.send(message);\n  }, []);\n\n  const value = {\n    wsManager: managerRef.current,\n    isConnected,\n    sendMessage,\n  };\n\n  return (\n    <WebSocketContext.Provider value={value}>\n      {children}\n    </WebSocketContext.Provider>\n  );\n};\n\nexport const useWebSocketMessage = (\n  callback: (msg: WebSocketMessage) => void,\n) => {\n  const { wsManager } = useWebSocket();\n\n  useEffect(() => {\n    if (wsManager && callback) {\n      wsManager.addMessageListener(callback);\n\n      return () => {\n        wsManager.removeMessageListener(callback);\n      };\n    }\n  }, [wsManager, callback]);\n};\n\nexport const useWebSocket = (): WebSocketContextState => {\n  const context = useContext(WebSocketContext);\n  if (context === undefined) {\n    throw new Error(\"useWebSocket must be used within a WebSocketProvider\");\n  }\n  return context;\n};\n"
  },
  {
    "path": "apps/client/src/features/ws/flowUiAdapters/toastFlowUiAdapter.ts",
    "content": "import { toast } from \"sonner\";\nimport type { FlowUiEventPayload, FlowUiEventToastData } from \"@/features/ws/ws\";\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return typeof value === \"object\" && value !== null;\n}\n\nfunction asToastData(raw: unknown): FlowUiEventToastData | null {\n  if (!isRecord(raw)) return null;\n  const message = raw.message;\n  if (typeof message !== \"string\") return null;\n  const level =\n    typeof raw.level === \"string\" ? raw.level.toLowerCase() : \"info\";\n  return {\n    level,\n    title:\n      typeof raw.title === \"string\"\n        ? raw.title\n        : raw.title === null\n          ? null\n          : undefined,\n    message,\n    duration_ms:\n      typeof raw.duration_ms === \"number\" ? raw.duration_ms : undefined,\n  };\n}\n\n/** Maps `flow_ui_event` with kind `toast` to Sonner. */\nexport function toastFlowUiAdapter(payload: FlowUiEventPayload): void {\n  const data = asToastData(payload.event.data);\n  if (!data) return;\n\n  const duration = data.duration_ms ?? 4000;\n  const title = data.title?.trim();\n  const message = data.message;\n\n  const opts = title\n    ? ({ duration, description: message } as const)\n    : ({ duration } as const);\n\n  switch (data.level) {\n    case \"success\":\n      // eslint-disable-next-line @typescript-eslint/no-unused-expressions\n      title\n        ? toast.success(title, opts)\n        : toast.success(message, { duration });\n      break;\n    case \"warning\":\n      // eslint-disable-next-line @typescript-eslint/no-unused-expressions\n      title\n        ? toast.warning(title, opts)\n        : toast.warning(message, { duration });\n      break;\n    case \"error\":\n      // eslint-disable-next-line @typescript-eslint/no-unused-expressions\n      title ? toast.error(title, opts) : toast.error(message, { duration });\n      break;\n    default:\n      // eslint-disable-next-line @typescript-eslint/no-unused-expressions\n      title ? toast(title, opts) : toast(message, { duration });\n  }\n}\n"
  },
  {
    "path": "apps/client/src/features/ws/flowUiEventRouter.test.ts",
    "content": "import { describe, it, expect, vi } from \"vitest\";\nimport { createFlowUiEventRouter, mergeFlowUiAdapters } from \"./flowUiEventRouter\";\nimport type { FlowUiEventPayload } from \"./ws\";\n\ndescribe(\"createFlowUiEventRouter\", () => {\n  it(\"dispatches to the adapter for event.kind\", () => {\n    const custom = vi.fn();\n    const router = createFlowUiEventRouter(\n      mergeFlowUiAdapters([\"custom\", custom]),\n    );\n    const payload = {\n      target: { session_id: \"x\" },\n      event: { kind: \"custom\", data: { n: 1 } },\n    } satisfies FlowUiEventPayload;\n    router.dispatch(payload);\n    expect(custom).toHaveBeenCalledWith(payload);\n  });\n\n  it(\"ignores unknown kinds\", () => {\n    const custom = vi.fn();\n    const router = createFlowUiEventRouter(\n      mergeFlowUiAdapters([\"custom\", custom]),\n    );\n    router.dispatch({\n      target: { session_id: \"x\" },\n      event: { kind: \"other\", data: {} },\n    });\n    expect(custom).not.toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "apps/client/src/features/ws/flowUiEventRouter.ts",
    "content": "import type { FlowUiEventPayload } from \"@/features/ws/ws\";\n\nexport type FlowUiEventAdapter = (payload: FlowUiEventPayload) => void;\n\nconst defaultRegistry = (): Map<string, FlowUiEventAdapter> => new Map();\n\n/**\n * Routes flow-driven UI events by `event.kind`. Register adapters once; unknown kinds are ignored.\n */\nexport function createFlowUiEventRouter(\n  adapters: Map<string, FlowUiEventAdapter>,\n) {\n  return {\n    dispatch(payload: FlowUiEventPayload): void {\n      const kind = payload.event?.kind;\n      if (!kind || typeof kind !== \"string\") return;\n      const adapter = adapters.get(kind);\n      adapter?.(payload);\n    },\n  };\n}\n\n/** @internal */\nexport function mergeFlowUiAdapters(\n  ...entries: Array<[string, FlowUiEventAdapter]>\n): Map<string, FlowUiEventAdapter> {\n  const m = defaultRegistry();\n  for (const [k, v] of entries) {\n    m.set(k, v);\n  }\n  return m;\n}\n"
  },
  {
    "path": "apps/client/src/features/ws/ws.ts",
    "content": "import type { DashboardComponentEventPayload } from \"@/entities/dynamic-dashboard/interaction\";\n\n/** Flow-driven UI events (toast, etc.); client should filter by `target.session_id`. */\nexport type FlowUiEventPayload = {\n  target: { session_id: string };\n  event: { kind: string; data: unknown };\n  meta?: {\n    flow_id?: number;\n    node_id?: string;\n    node_type?: string;\n    /** Optional routing hint for dashboard widgets (set by flow nodes). */\n    dashboard_item_id?: string;\n  };\n};\n\nexport type FlowUiEventToastData = {\n  level: string;\n  title?: string | null;\n  message: string;\n  duration_ms?: number;\n};\n\nexport type WebSocketMessage = {\n  type?:\n    | \"offer\"\n    | \"answer\"\n    | \"candidate\"\n    | \"health_check\"\n    | \"subscribe_stream\"\n    | \"health_check_response\"\n    | \"compute_flow\"\n    | \"log_message\"\n    | \"flow_ui_event\"\n    | \"ping\"\n    | \"pong\"\n    | \"get_all_flows\"\n    | \"get_all_flows_response\"\n    | \"stop_flow\"\n    | \"dashboard_component_event\"\n    | \"get_all_stream_state\"\n    | \"stream_state\"\n    | \"change_state\"\n    | \"hangup\"\n    | \"get_server\";\n  payload:\n    | RTCSessionDescriptionInit\n    | RTCIceCandidate\n    | RTCIceCandidateInit\n    | string\n    | number\n    | object\n    | { topic: string }\n    | { flow_id: number }\n    | { timestamp: number }\n    | { id: number; name: string; is_running: boolean }[]\n    | { cpu_usage: number; memory_usage: number }\n    | FlowUiEventPayload\n    | DashboardComponentEventPayload;\n};\n\nconst FLOW_RUN_SESSION_STORAGE_KEY = \"vessel_flow_run_session_id\";\n\n/** Per browser tab; include in `compute_flow` so server UI events target the initiating session. */\nexport function getFlowRunSessionId(): string {\n  try {\n    let id = sessionStorage.getItem(FLOW_RUN_SESSION_STORAGE_KEY);\n    if (!id) {\n      id =\n        typeof crypto !== \"undefined\" && \"randomUUID\" in crypto\n          ? crypto.randomUUID()\n          : `sess_${Date.now()}_${Math.random().toString(36).slice(2)}`;\n      sessionStorage.setItem(FLOW_RUN_SESSION_STORAGE_KEY, id);\n    }\n    return id;\n  } catch {\n    return `sess_${Date.now()}_${Math.random().toString(36).slice(2)}`;\n  }\n}\n\nexport class WebSocketChannel {\n  private ws: WebSocket | null = null;\n  private messageListeners: Set<(msg: WebSocketMessage) => void> = new Set();\n  public onopen: (() => void) | null = null;\n  public onclose: (() => void) | null = null;\n  public isConnected(): boolean {\n    return this.ws?.readyState === WebSocket.OPEN;\n  }\n\n  connect(url: string): void {\n    if (this.isConnected()) {\n      return;\n    }\n    this.ws = new WebSocket(url);\n    this.ws.onopen = () => this.onopen?.();\n\n    this.ws.onmessage = (event) => {\n      const message = JSON.parse(event.data);\n      this.messageListeners.forEach((listener) => listener(message));\n    };\n\n    this.ws.onerror = (err) => console.error(\"WebSocket Error:\", err);\n    this.ws.onclose = () => this.onclose?.();\n  }\n\n  send(message: WebSocketMessage): void {\n    if (!this.isConnected()) {\n      return;\n    }\n\n    try {\n      if (this.ws) {\n        this.ws.send(JSON.stringify(message));\n      }\n    } catch (error) {\n      console.log(error);\n    }\n  }\n\n  close(): void {\n    if (this.ws) {\n      this.ws.close();\n    }\n  }\n\n  addMessageListener(listener: (msg: WebSocketMessage) => void): void {\n    this.messageListeners.add(listener);\n  }\n\n  removeMessageListener(listener: (msg: WebSocketMessage) => void): void {\n    this.messageListeners.delete(listener);\n  }\n}\n"
  },
  {
    "path": "apps/client/src/features/ws/wsMock.ts",
    "content": "import { WebSocketMessage } from \"./ws\";\n\nexport class MockWebSocketChannel {\n  private connected = false;\n  private messageListeners: Set<(msg: WebSocketMessage) => void> = new Set();\n  private intervals: number[] = [];\n  public onopen: (() => void) | null = null;\n  public onclose: (() => void) | null = null;\n\n  isConnected(): boolean {\n    return this.connected;\n  }\n\n  private emit(message: WebSocketMessage) {\n    this.messageListeners.forEach((listener) => listener(message));\n  }\n\n  connect(): void {\n    if (this.connected) return;\n    this.connected = true;\n    this.onopen?.();\n\n    this.intervals.push(\n      window.setInterval(() => {\n        this.emit({\n          type: \"get_server\",\n          payload: {\n            cpu_usage: 12 + Math.random() * 10,\n            memory_usage: 25 + Math.random() * 5,\n          },\n        });\n      }, 2000),\n    );\n\n    this.intervals.push(\n      window.setInterval(() => {\n        this.emit({\n          type: \"log_message\",\n          payload: { level: \"info\", message: \"Demo log entry\" },\n        });\n      }, 5000),\n    );\n\n    this.emit({\n      type: \"stream_state\",\n      payload: [\n        { topic: \"sensors/temperature\", is_online: true },\n        { topic: \"drone/front-camera\", is_online: true },\n      ],\n    });\n  }\n\n  send(message: WebSocketMessage): void {\n    if (!this.connected) return;\n\n    if (message.type === \"get_all_stream_state\") {\n      this.emit({\n        type: \"stream_state\",\n        payload: [\n          { topic: \"sensors/temperature\", is_online: true },\n          { topic: \"drone/front-camera\", is_online: true },\n        ],\n      });\n    }\n\n    if (message.type === \"ping\") {\n      this.emit({\n        type: \"pong\",\n        payload: {\n          timestamp: (message.payload as { timestamp: number }).timestamp,\n        },\n      });\n    }\n\n    if (message.type === \"get_server\") {\n      this.emit({\n        type: \"get_server\",\n        payload: {\n          cpu_usage: 15 + Math.random() * 5,\n          memory_usage: 30 + Math.random() * 5,\n        },\n      });\n    }\n  }\n\n  close(): void {\n    if (!this.connected) return;\n    this.connected = false;\n    this.intervals.forEach((id) => clearInterval(id));\n    this.intervals = [];\n    this.onclose?.();\n  }\n\n  addMessageListener(listener: (msg: WebSocketMessage) => void): void {\n    this.messageListeners.add(listener);\n  }\n\n  removeMessageListener(listener: (msg: WebSocketMessage) => void): void {\n    this.messageListeners.delete(listener);\n  }\n}\n"
  },
  {
    "path": "apps/client/src/font.css",
    "content": "@font-face {\n  font-family: \"Inter\";\n  src: url(\"/font/Inter.ttf\") format(\"truetype\");\n}\n\n@font-face {\n  font-family: \"RobotoMono\";\n  src: url(\"/font/RobotoMono.ttf\") format(\"truetype\");\n}\n\nhtml,\nbody {\n  font-family: \"Inter\", sans-serif;\n}\n\n.font-mono {\n  font-family: \"RobotoMono\", monospace;\n}\n"
  },
  {
    "path": "apps/client/src/hooks/use-mobile.ts",
    "content": "import * as React from \"react\"\n\nconst MOBILE_BREAKPOINT = 768\n\nexport function useIsMobile() {\n  const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)\n\n  React.useEffect(() => {\n    const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)\n    const onChange = () => {\n      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)\n    }\n    mql.addEventListener(\"change\", onChange)\n    setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)\n    return () => mql.removeEventListener(\"change\", onChange)\n  }, [])\n\n  return !!isMobile\n}\n"
  },
  {
    "path": "apps/client/src/hooks/useDesktopSidecar.ts",
    "content": "import { useEffect } from \"react\";\n\nimport { isDemoMode } from \"@/shared/demo\";\nimport { ensureSidecarRunning, getDesktopServerUrl, isTauri } from \"@/shared/desktop\";\nimport { storage } from \"@/lib/storage\";\n\nexport const useDesktopSidecar = () => {\n  useEffect(() => {\n    if (!isTauri() || isDemoMode) {\n      return;\n    }\n\n    const bootstrap = async () => {\n      await ensureSidecarRunning();\n\n      // On the Tauri client, always default to the local sidecar URL so the\n      // login screen connects to the bundled server instead of any stale\n      // address left over in localStorage from a previous session.\n      const baseUrl = await getDesktopServerUrl();\n      if (baseUrl) {\n        storage.setServerUrl(baseUrl);\n      }\n    };\n\n    void bootstrap();\n  }, []);\n};\n"
  },
  {
    "path": "apps/client/src/hooks/usePreventBackNavigation.ts",
    "content": "import { useEffect } from \"react\";\n\nimport { isTauri } from \"@/shared/desktop\";\n\nconst isBackspaceAllowed = (event: KeyboardEvent) => {\n  const path = event.composedPath();\n\n  for (const el of path) {\n    if (!(el instanceof HTMLElement)) continue;\n\n    const tag = el.tagName;\n    if (tag === \"INPUT\" || tag === \"TEXTAREA\" || el.isContentEditable) {\n      return true;\n    }\n\n    if (el.dataset?.allowBackspace === \"true\") {\n      return true;\n    }\n  }\n\n  return false;\n};\n\nexport const usePreventBackNavigation = () => {\n  useEffect(() => {\n    if (!isTauri()) return;\n\n    const handler = (event: KeyboardEvent) => {\n      if (event.key !== \"Backspace\") return;\n      if (isBackspaceAllowed(event)) return;\n      // Block browser navigation only when not focused on editable/allowed elements.\n      event.preventDefault();\n    };\n\n    window.addEventListener(\"keydown\", handler, { capture: true });\n    return () => window.removeEventListener(\"keydown\", handler, { capture: true });\n  }, []);\n};\n"
  },
  {
    "path": "apps/client/src/index.css",
    "content": "@import \"tailwindcss\";\n@import \"tw-animate-css\";\n\n@tailwind utilities;\n\n@import \"tailwindcss\";\n@import \"tw-animate-css\";\n\n@custom-variant dark (&:is(.dark *));\n\n:root {\n  --background: oklch(1 0 0);\n  --foreground: oklch(0.145 0 0);\n  --card: oklch(1 0 0);\n  --card-foreground: oklch(0.145 0 0);\n  --popover: oklch(1 0 0);\n  --popover-foreground: oklch(0.145 0 0);\n  --primary: oklch(0.205 0 0);\n  --primary-foreground: oklch(0.985 0 0);\n  --secondary: oklch(0.97 0 0);\n  --secondary-foreground: oklch(0.205 0 0);\n  --muted: oklch(0.97 0 0);\n  --muted-foreground: oklch(0.556 0 0);\n  --accent: oklch(0.97 0 0);\n  --accent-foreground: oklch(0.205 0 0);\n  --destructive: oklch(0.577 0.245 27.325);\n  --destructive-foreground: oklch(0.577 0.245 27.325);\n  --border: oklch(0.922 0 0);\n  --input: oklch(0.922 0 0);\n  --ring: oklch(0.708 0 0);\n  --chart-1: oklch(0.646 0.222 41.116);\n  --chart-2: oklch(0.6 0.118 184.704);\n  --chart-3: oklch(0.398 0.07 227.392);\n  --chart-4: oklch(0.828 0.189 84.429);\n  --chart-5: oklch(0.769 0.188 70.08);\n  --radius: 0.625rem;\n  --sidebar: oklch(0.985 0 0);\n  --sidebar-foreground: oklch(0.145 0 0);\n  --sidebar-primary: oklch(0.205 0 0);\n  --sidebar-primary-foreground: oklch(0.985 0 0);\n  --sidebar-accent: oklch(0.97 0 0);\n  --sidebar-accent-foreground: oklch(0.205 0 0);\n  --sidebar-border: oklch(0.922 0 0);\n  --sidebar-ring: oklch(0.708 0 0);\n}\n\n.dark {\n  --background: oklch(0.145 0 0);\n  --foreground: oklch(0.985 0 0);\n  --card: oklch(0.145 0 0);\n  --card-foreground: oklch(0.985 0 0);\n  --popover: oklch(0.145 0 0);\n  --popover-foreground: oklch(0.985 0 0);\n  --primary: oklch(0.985 0 0);\n  --primary-foreground: oklch(0.205 0 0);\n  --secondary: oklch(0.269 0 0);\n  --secondary-foreground: oklch(0.985 0 0);\n  --muted: oklch(0.269 0 0);\n  --muted-foreground: oklch(0.708 0 0);\n  --accent: oklch(0.269 0 0);\n  --accent-foreground: oklch(0.985 0 0);\n  --destructive: oklch(0.396 0.141 25.723);\n  --destructive-foreground: oklch(0.637 0.237 25.331);\n  --border: oklch(0.269 0 0);\n  --input: oklch(0.269 0 0);\n  --ring: oklch(0.439 0 0);\n  --chart-1: oklch(0.488 0.243 264.376);\n  --chart-2: oklch(0.696 0.17 162.48);\n  --chart-3: oklch(0.769 0.188 70.08);\n  --chart-4: oklch(0.627 0.265 303.9);\n  --chart-5: oklch(0.645 0.246 16.439);\n  --sidebar: oklch(0.205 0 0);\n  --sidebar-foreground: oklch(0.985 0 0);\n  --sidebar-primary: oklch(0.488 0.243 264.376);\n  --sidebar-primary-foreground: oklch(0.985 0 0);\n  --sidebar-accent: oklch(0.269 0 0);\n  --sidebar-accent-foreground: oklch(0.985 0 0);\n  --sidebar-border: oklch(0.269 0 0);\n  --sidebar-ring: oklch(0.439 0 0);\n}\n\n@theme inline {\n  --font-sans: \"Inter\", sans-serif;\n  --font-mono: \"RobotoMono\", monospace;\n  --color-background: var(--background);\n  --color-foreground: var(--foreground);\n  --color-card: var(--card);\n  --color-card-foreground: var(--card-foreground);\n  --color-popover: var(--popover);\n  --color-popover-foreground: var(--popover-foreground);\n  --color-primary: var(--primary);\n  --color-primary-foreground: var(--primary-foreground);\n  --color-secondary: var(--secondary);\n  --color-secondary-foreground: var(--secondary-foreground);\n  --color-muted: var(--muted);\n  --color-muted-foreground: var(--muted-foreground);\n  --color-accent: var(--accent);\n  --color-accent-foreground: var(--accent-foreground);\n  --color-destructive: var(--destructive);\n  --color-destructive-foreground: var(--destructive-foreground);\n  --color-border: var(--border);\n  --color-input: var(--input);\n  --color-ring: var(--ring);\n  --color-chart-1: var(--chart-1);\n  --color-chart-2: var(--chart-2);\n  --color-chart-3: var(--chart-3);\n  --color-chart-4: var(--chart-4);\n  --color-chart-5: var(--chart-5);\n  --radius-sm: calc(var(--radius) - 4px);\n  --radius-md: calc(var(--radius) - 2px);\n  --radius-lg: var(--radius);\n  --radius-xl: calc(var(--radius) + 4px);\n  --color-sidebar: var(--sidebar);\n  --color-sidebar-foreground: var(--sidebar-foreground);\n  --color-sidebar-primary: var(--sidebar-primary);\n  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n  --color-sidebar-accent: var(--sidebar-accent);\n  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n  --color-sidebar-border: var(--sidebar-border);\n  --color-sidebar-ring: var(--sidebar-ring);\n}\n\n@layer base {\n  * {\n    @apply border-border outline-ring/50;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n}\nhtml,\nbody,\n#root {\n  width: 100%;\n  height: 100%;\n}\n\n* {\n  -webkit-user-select: none;\n  -moz-user-select: none;\n  -ms-user-select: none;\n  user-select: none;\n}\n\nbody {\n  -ms-overflow-style: none;\n}\n\n::-webkit-scrollbar {\n  display: none;\n}\n"
  },
  {
    "path": "apps/client/src/lib/electron.ts",
    "content": "export const isElectron = (): boolean => {\n  const userAgent = navigator.userAgent.toLowerCase();\n  return userAgent.indexOf(\"electron/\") > -1;\n};\n"
  },
  {
    "path": "apps/client/src/lib/geometry-precision.ts",
    "content": "import { FeatureWithVertices } from \"@/entities/map/types\";\n\ninterface GeometryProperties {\n  location?: string;\n  length?: string;\n  area?: string;\n}\n\nconst WGS84 = {\n  a: 6378137,\n  b: 6356752.314245,\n  f: 1 / 298.257223563,\n};\n\nconst formatNumber = (num: number, digits: number = 2): string => {\n  return num.toLocaleString(undefined, {\n    minimumFractionDigits: digits,\n    maximumFractionDigits: digits,\n  });\n};\n\nconst degToRad = (degrees: number): number => {\n  return degrees * (Math.PI / 180);\n};\n\nfunction vincentyDistance(\n  p1: { lat: number; lon: number },\n  p2: { lat: number; lon: number },\n): number {\n  const { a, b, f } = WGS84;\n  const L = degToRad(p2.lon - p1.lon);\n  const U1 = Math.atan((1 - f) * Math.tan(degToRad(p1.lat)));\n  const U2 = Math.atan((1 - f) * Math.tan(degToRad(p2.lat)));\n  const sinU1 = Math.sin(U1),\n    cosU1 = Math.cos(U1);\n  const sinU2 = Math.sin(U2),\n    cosU2 = Math.cos(U2);\n\n  let lambda = L,\n    lambdaP,\n    iterLimit = 100;\n  let cosSqAlpha, sinSigma, cosSigma, cos2SigmaM, sigma;\n\n  do {\n    const sinLambda = Math.sin(lambda),\n      cosLambda = Math.cos(lambda);\n    sinSigma = Math.sqrt(\n      cosU2 * sinLambda * (cosU2 * sinLambda) +\n        (cosU1 * sinU2 - sinU1 * cosU2 * cosLambda) *\n          (cosU1 * sinU2 - sinU1 * cosU2 * cosLambda),\n    );\n    if (sinSigma === 0) return 0;\n\n    cosSigma = sinU1 * sinU2 + cosU1 * cosU2 * cosLambda;\n    sigma = Math.atan2(sinSigma, cosSigma);\n    const sinAlpha = (cosU1 * cosU2 * sinLambda) / sinSigma;\n    cosSqAlpha = 1 - sinAlpha * sinAlpha;\n    cos2SigmaM =\n      cosSqAlpha === 0 ? 0 : cosSigma - (2 * sinU1 * sinU2) / cosSqAlpha;\n\n    const C = (f / 16) * cosSqAlpha * (4 + f * (4 - 3 * cosSqAlpha));\n    lambdaP = lambda;\n    lambda =\n      L +\n      (1 - C) *\n        f *\n        sinAlpha *\n        (sigma +\n          C *\n            sinSigma *\n            (cos2SigmaM + C * cosSigma * (-1 + 2 * cos2SigmaM * cos2SigmaM)));\n  } while (Math.abs(lambda - lambdaP) > 1e-12 && --iterLimit > 0);\n\n  if (iterLimit === 0) return NaN;\n\n  const uSq = (cosSqAlpha * (a * a - b * b)) / (b * b);\n  const A = 1 + (uSq / 16384) * (4096 + uSq * (-768 + uSq * (320 - 175 * uSq)));\n  const B = (uSq / 1024) * (256 + uSq * (-128 + uSq * (74 - 47 * uSq)));\n  const deltaSigma =\n    B *\n    sinSigma *\n    (cos2SigmaM +\n      (B / 4) *\n        (cosSigma * (-1 + 2 * cos2SigmaM * cos2SigmaM) -\n          (B / 6) *\n            cos2SigmaM *\n            (-3 + 4 * sinSigma * sinSigma) *\n            (-3 + 4 * cos2SigmaM * cos2SigmaM)));\n\n  return b * A * (sigma - deltaSigma);\n}\n\nfunction sphericalTriangleArea(\n  a: number,\n  b: number,\n  c: number,\n  r: number,\n): number {\n  if (a <= 0 || b <= 0 || c <= 0) return 0;\n  const s = (a + b + c) / 2;\n\n  if (s <= a || s <= b || s <= c) return 0;\n\n  const E =\n    4 *\n    Math.atan(\n      Math.sqrt(\n        Math.tan(s / (2 * r)) *\n          Math.tan((s - a) / (2 * r)) *\n          Math.tan((s - b) / (2 * r)) *\n          Math.tan((s - c) / (2 * r)),\n      ),\n    );\n\n  return E * r * r;\n}\n\nexport function calculateFeatureGeometry(\n  feature: FeatureWithVertices,\n): GeometryProperties {\n  const vertices = feature.vertices\n    .sort((a, b) => a.sequence - b.sequence)\n    .map((v) => ({ lat: v.latitude, lon: v.longitude }));\n\n  switch (feature.feature_type) {\n    case \"POINT\": {\n      if (vertices.length === 0) return {};\n      const { lat, lon } = vertices[0];\n      return { location: `${lat.toFixed(6)}, ${lon.toFixed(6)}` };\n    }\n\n    case \"LINE\": {\n      if (vertices.length < 2) return {};\n      let totalLengthMeters = 0;\n      for (let i = 0; i < vertices.length - 1; i++) {\n        totalLengthMeters += vincentyDistance(vertices[i], vertices[i + 1]);\n      }\n\n      if (totalLengthMeters < 1000) {\n        return { length: `${formatNumber(totalLengthMeters, 1)} m` };\n      }\n      return { length: `${formatNumber(totalLengthMeters / 1000, 3)} km` };\n    }\n\n    case \"POLYGON\": {\n      if (vertices.length < 3) return {};\n\n      let totalAreaSqMeters = 0;\n      const origin = vertices[0];\n      const meanRadius = (WGS84.a + WGS84.b) / 2;\n\n      for (let i = 1; i < vertices.length - 1; i++) {\n        const p1 = vertices[i];\n        const p2 = vertices[i + 1];\n\n        const sideA = vincentyDistance(origin, p1);\n        const sideB = vincentyDistance(p1, p2);\n        const sideC = vincentyDistance(p2, origin);\n\n        totalAreaSqMeters += sphericalTriangleArea(\n          sideA,\n          sideB,\n          sideC,\n          meanRadius,\n        );\n      }\n\n      if (totalAreaSqMeters < 10000) {\n        return { area: `${formatNumber(totalAreaSqMeters, 1)} m²` };\n      }\n      return { area: `${formatNumber(totalAreaSqMeters / 1_000_000, 3)} km²` };\n    }\n\n    default:\n      return {};\n  }\n}\n"
  },
  {
    "path": "apps/client/src/lib/geometry.ts",
    "content": "import { FeatureWithVertices } from \"@/entities/map/types\";\n\ninterface GeometryProperties {\n  location?: string;\n  length?: string;\n  area?: string;\n}\n\nconst EARTH_RADIUS_KM = 6371;\n\nconst degToRad = (degrees: number): number => {\n  return degrees * (Math.PI / 180);\n};\n\nconst haversineDistance = (\n  p1: { lat: number; lon: number },\n  p2: { lat: number; lon: number },\n): number => {\n  const dLat = degToRad(p2.lat - p1.lat);\n  const dLon = degToRad(p2.lon - p1.lon);\n\n  const lat1 = degToRad(p1.lat);\n  const lat2 = degToRad(p2.lat);\n\n  const a =\n    Math.sin(dLat / 2) * Math.sin(dLat / 2) +\n    Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2);\n  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));\n\n  return EARTH_RADIUS_KM * c;\n};\n\nconst formatNumber = (num: number, digits: number = 2): string => {\n  return num.toLocaleString(undefined, {\n    minimumFractionDigits: digits,\n    maximumFractionDigits: digits,\n  });\n};\n\nexport function calculateFeatureGeometry(\n  feature: FeatureWithVertices,\n): GeometryProperties {\n  const vertices = feature.vertices\n    .sort((a, b) => a.sequence - b.sequence)\n    .map((v) => ({ lat: v.latitude, lon: v.longitude }));\n\n  switch (feature.feature_type) {\n    case \"POINT\": {\n      if (vertices.length === 0) return {};\n      const { lat, lon } = vertices[0];\n      return {\n        location: `${lat.toFixed(6)}, ${lon.toFixed(6)}`,\n      };\n    }\n\n    case \"LINE\": {\n      if (vertices.length < 2) return {};\n      let totalLengthKm = 0;\n      for (let i = 0; i < vertices.length - 1; i++) {\n        totalLengthKm += haversineDistance(vertices[i], vertices[i + 1]);\n      }\n\n      if (totalLengthKm < 1) {\n        return { length: `${formatNumber(totalLengthKm * 1000, 1)} m` };\n      }\n      return { length: `${formatNumber(totalLengthKm, 2)} km` };\n    }\n\n    case \"POLYGON\": {\n      if (vertices.length < 3) return {};\n\n      const earthRadiusM = EARTH_RADIUS_KM * 1000;\n      let area = 0;\n      const closedVertices = [...vertices, vertices[0]];\n\n      for (let i = 0; i < vertices.length; i++) {\n        const p1 = closedVertices[i];\n        const p2 = closedVertices[i + 1];\n\n        const x1 = degToRad(p1.lon) * earthRadiusM * Math.cos(degToRad(p1.lat));\n        const y1 = degToRad(p1.lat) * earthRadiusM;\n        const x2 = degToRad(p2.lon) * earthRadiusM * Math.cos(degToRad(p2.lat));\n        const y2 = degToRad(p2.lat) * earthRadiusM;\n\n        area += x1 * y2 - x2 * y1;\n      }\n\n      const areaInSqMeters = Math.abs(area / 2);\n\n      if (areaInSqMeters < 10000) {\n        return { area: `${formatNumber(areaInSqMeters, 1)} m²` };\n      }\n      return { area: `${formatNumber(areaInSqMeters / 1_000_000, 3)} km²` };\n    }\n\n    default:\n      return {};\n  }\n}\n"
  },
  {
    "path": "apps/client/src/lib/jwt.ts",
    "content": "export type JwtPayload = {\n  exp?: number;\n  sub?: string;\n  [key: string]: unknown;\n};\n\nexport function parseJwt(token?: string): JwtPayload | null {\n  if (!token || token.split(\".\").length < 2) {\n    return null;\n  }\n\n  try {\n    const base64Url = token.split(\".\")[1];\n    const base64 = base64Url?.replace(/-/g, \"+\").replace(/_/g, \"/\");\n    const jsonPayload = decodeURIComponent(\n      atob(base64 || \"\")\n        .split(\"\")\n        .map(function (c) {\n          return \"%\" + (\"00\" + c.charCodeAt(0).toString(16)).slice(-2);\n        })\n        .join(\"\"),\n    );\n\n    return JSON.parse(jsonPayload);\n  } catch (error) {\n    console.error(\"Invalid token:\", error);\n    return null;\n  }\n}\n"
  },
  {
    "path": "apps/client/src/lib/storage.ts",
    "content": "const TOKEN_KEY = \"vessel_token\";\nconst SERVER_URL_KEY = \"vessel_server_url\";\nconst RECENT_URLS_KEY = \"vessel_recent_server_urls\";\nconst CAPSULE_URL_KEY = \"vessel_capsule_url\";\n\nexport const storage = {\n  getToken: (): string | null => {\n    return localStorage.getItem(TOKEN_KEY);\n  },\n\n  setToken: (token: string): void => {\n    localStorage.setItem(TOKEN_KEY, token);\n  },\n\n  removeToken: (): void => {\n    localStorage.removeItem(TOKEN_KEY);\n  },\n\n  getServerUrl: (): string | null => {\n    return localStorage.getItem(SERVER_URL_KEY);\n  },\n\n  setServerUrl: (url: string): void => {\n    localStorage.setItem(SERVER_URL_KEY, url);\n  },\n\n  removeServerUrl: (): void => {\n    localStorage.removeItem(SERVER_URL_KEY);\n  },\n\n  getRecentUrls: (): string[] => {\n    const stored = localStorage.getItem(RECENT_URLS_KEY);\n    if (!stored) return [];\n    try {\n      const parsed = JSON.parse(stored);\n      return Array.isArray(parsed) ? parsed : [];\n    } catch {\n      return [];\n    }\n  },\n\n  setRecentUrls: (urls: string[]): void => {\n    localStorage.setItem(RECENT_URLS_KEY, JSON.stringify(urls));\n  },\n\n  removeRecentUrls: (): void => {\n    localStorage.removeItem(RECENT_URLS_KEY);\n  },\n\n  getCapsuleUrl: (): string | null => {\n    return localStorage.getItem(CAPSULE_URL_KEY);\n  },\n\n  setCapsuleUrl: (url: string): void => {\n    localStorage.setItem(CAPSULE_URL_KEY, url);\n  },\n\n  removeCapsuleUrl: (): void => {\n    localStorage.removeItem(CAPSULE_URL_KEY);\n  },\n\n  clearAll: (): void => {\n    localStorage.removeItem(TOKEN_KEY);\n    localStorage.removeItem(SERVER_URL_KEY);\n    localStorage.removeItem(CAPSULE_URL_KEY);\n  },\n};\n"
  },
  {
    "path": "apps/client/src/lib/string.ts",
    "content": "export function formatConstantCase(str: string) {\n  return str\n    .split(\"_\")\n    .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())\n    .join(\" \");\n}\n"
  },
  {
    "path": "apps/client/src/lib/supabase.ts",
    "content": "import { createClient } from \"@supabase/supabase-js\";\n\nconst supabaseUrl = import.meta.env.VITE_SUPABASE_URL;\nconst supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;\n\nexport const isSupabaseConfigured = Boolean(supabaseUrl && supabaseAnonKey);\n\nif (!isSupabaseConfigured) {\n  console.warn(\n    \"[supabase] VITE_SUPABASE_URL or VITE_SUPABASE_ANON_KEY is not set. \" +\n      \"Supabase-dependent features (Google login, TURN credentials, edge functions) will be disabled.\",\n  );\n}\n\nexport const supabase = createClient(\n  supabaseUrl || \"https://placeholder.supabase.co\",\n  supabaseAnonKey || \"placeholder-anon-key\",\n);\n"
  },
  {
    "path": "apps/client/src/lib/time.ts",
    "content": "export const formatSimpleDateTime = (isoString: string): string => {\n  if (isoString == \"\") {\n    return \"\";\n  }\n\n  const date = new Date(isoString);\n\n  const year = date.getFullYear();\n  const month = (date.getMonth() + 1).toString().padStart(2, \"0\");\n  const day = date.getDate().toString().padStart(2, \"0\");\n\n  const hours = date.getHours().toString().padStart(2, \"0\");\n  const minutes = date.getMinutes().toString().padStart(2, \"0\");\n  const seconds = date.getSeconds().toString().padStart(2, \"0\");\n\n  return `${year}.${month}.${day} ${hours}:${minutes}:${seconds}`;\n};\n"
  },
  {
    "path": "apps/client/src/lib/utils.ts",
    "content": "import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"
  },
  {
    "path": "apps/client/src/main.tsx",
    "content": "import { StrictMode } from \"react\";\nimport { createRoot } from \"react-dom/client\";\nimport \"./index.css\";\nimport \"./font.css\";\n\nimport App from \"./App.tsx\";\nimport { ThemeProvider } from \"./app/providers/theme-provider.tsx\";\nimport { Toaster } from \"./components/ui/sonner.tsx\";\nimport { SupabaseAuthProvider } from \"./contexts/SupabaseAuthContext.tsx\";\n\ncreateRoot(document.getElementById(\"root\")!).render(\n  <StrictMode>\n    <ThemeProvider defaultTheme='dark' storageKey='vite-ui-theme'>\n      <SupabaseAuthProvider>\n        <App />\n        <Toaster />\n      </SupabaseAuthProvider>\n    </ThemeProvider>\n  </StrictMode>,\n);\n"
  },
  {
    "path": "apps/client/src/pages/auth/index.tsx",
    "content": "import { LoginForm } from \"@/features/auth\";\n\nexport function AuthPage() {\n  return (\n    <div className=\"bg-background flex h-[calc(100%_-_34px)] flex-col items-center justify-center gap-6 p-6 md:p-10\">\n      <div className=\"w-full max-w-sm\">\n        <LoginForm />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/pages/code/index.tsx",
    "content": "import { useEffect } from \"react\";\nimport { Navigate } from \"react-router\";\nimport {\n  Breadcrumb,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbList,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"@/components/ui/breadcrumb\";\nimport {\n  ResizablePanelGroup,\n  ResizablePanel,\n  ResizableHandle,\n} from \"@/components/ui/resizable\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n  SidebarInset,\n  SidebarProvider,\n  SidebarTrigger,\n} from \"@/components/ui/sidebar\";\nimport { FileEditor } from \"@/features/code/FileEditor\";\nimport { FileTree } from \"@/features/code/FileTree\";\nimport { AppSidebar } from \"@/features/sidebar\";\nimport { useConfigStore } from \"@/entities/configurations/store\";\nimport { getCodeServiceEnabled } from \"@/entities/configurations/codeService\";\n\nexport function CodePage() {\n  const { configurations, fetchConfigs, isLoading } = useConfigStore();\n\n  useEffect(() => {\n    void fetchConfigs();\n  }, [fetchConfigs]);\n\n  if (isLoading) {\n    return (\n      <SidebarProvider>\n        <AppSidebar />\n        <SidebarInset>\n          <div className='flex h-full items-center justify-center text-sm text-muted-foreground'>\n            Loading…\n          </div>\n        </SidebarInset>\n      </SidebarProvider>\n    );\n  }\n\n  if (!getCodeServiceEnabled(configurations)) {\n    return <Navigate to='/dashboard' replace />;\n  }\n\n  return (\n    <SidebarProvider>\n      <AppSidebar />\n      <SidebarInset>\n        <header className='flex h-12 shrink-0 items-center gap-2 border-b px-4'>\n          <SidebarTrigger className='-ml-1' />\n          <Separator\n            orientation='vertical'\n            className='mr-2 data-[orientation=vertical]:h-4'\n          />\n          <Breadcrumb>\n            <BreadcrumbList>\n              <BreadcrumbItem className='hidden md:block'>\n                <BreadcrumbLink href='#'>/</BreadcrumbLink>\n              </BreadcrumbItem>\n              <BreadcrumbSeparator className='hidden md:block' />\n              <BreadcrumbItem>\n                <BreadcrumbPage>Code</BreadcrumbPage>\n              </BreadcrumbItem>\n            </BreadcrumbList>\n          </Breadcrumb>\n        </header>\n        <main className='flex-1 overflow-y-auto'>\n          <ResizablePanelGroup direction='horizontal' className='w-full h-full'>\n            <ResizablePanel defaultSize={20} minSize={15}>\n              <div className='h-full overflow-y-auto'>\n                <FileTree />\n              </div>\n            </ResizablePanel>\n            <ResizableHandle withHandle />\n            <ResizablePanel defaultSize={70}>\n              <FileEditor />\n            </ResizablePanel>\n          </ResizablePanelGroup>\n        </main>\n      </SidebarInset>\n    </SidebarProvider>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/pages/dashboard/DashboardMainPanel.tsx",
    "content": "import { AllEntities } from \"@/features/entity/AllEntities\";\nimport StatBlock from \"@/features/stat\";\nimport ResourceUsage from \"@/features/server-resource/resourceUsage\";\nimport { HaDashboard } from \"@/features/ha\";\nimport { SdrDashboard } from \"@/features/sdr/SdrDashboard\";\nimport { Ros2Dashboard } from \"@/features/ros2/Ros2Dashboard\";\n\nexport type DashboardMainPanelContentView = \"main\" | \"ha\" | \"ros2\" | \"sdr\";\n\ntype DashboardMainPanelProps = {\n  contentView?: DashboardMainPanelContentView;\n};\n\nexport function DashboardMainPanel({\n  contentView = \"main\",\n}: DashboardMainPanelProps) {\n  return (\n    <div className='flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto overflow-x-hidden'>\n      <div className='overflow-scroll gap-4 p-4'>\n        <div className='flex flex-1 flex-col'>\n          {contentView === \"main\" && (\n            <div className='@container/main flex flex-1 flex-col gap-2'>\n              <div className='flex flex-col gap-4 py-4 md:gap-6 md:py-6'>\n                <div className='*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4'>\n                  <StatBlock />\n                  <ResourceUsage />\n                </div>\n              </div>\n\n              <div className='flex flex-col gap-4 py-6 md:gap-6 md:py-6'>\n                <AllEntities />\n              </div>\n            </div>\n          )}\n\n          <div className='@container/main flex flex-1 flex-col gap-2'>\n            {contentView === \"ha\" && <HaDashboard />}\n            {contentView === \"ros2\" && <Ros2Dashboard />}\n            {contentView === \"sdr\" && <SdrDashboard />}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/pages/dashboard/index.tsx",
    "content": "export {\n  DashboardMainPanel,\n  type DashboardMainPanelContentView,\n} from \"./DashboardMainPanel\";\n"
  },
  {
    "path": "apps/client/src/pages/desktop-settings/index.tsx",
    "content": "import React, { useEffect, useMemo, useState } from \"react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { storage } from \"@/lib/storage\";\nimport {\n  getServerAddress,\n  type ServerAddress,\n  updateServerAddress,\n} from \"@/shared/desktop\";\n\nconst HOST_PRESETS = [\n  { value: \"0.0.0.0\", label: \"0.0.0.0  (LAN / external access)\" },\n  { value: \"127.0.0.1\", label: \"127.0.0.1  (this machine only)\" },\n  { value: \"__custom__\", label: \"Custom host\\u2026\" },\n];\n\nconst isValidHost = (value: string): boolean => {\n  if (!value) return false;\n  if (value === \"0.0.0.0\" || value === \"127.0.0.1\" || value === \"localhost\") {\n    return true;\n  }\n  const ipv4 =\n    /^(25[0-5]|2[0-4]\\d|[01]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[01]?\\d?\\d)){3}$/;\n  if (ipv4.test(value)) return true;\n  const hostname =\n    /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;\n  return hostname.test(value);\n};\n\nconst closeSelf = async () => {\n  try {\n    const mod = await import(\"@tauri-apps/api/window\");\n    const win = mod.getCurrentWindow();\n    await win.close();\n  } catch (err) {\n    console.warn(\"Unable to close settings window via @tauri-apps/api\", err);\n    try {\n      window.close();\n    } catch {\n      // ignore\n    }\n  }\n};\n\nexport function DesktopSettingsPage(): React.ReactElement {\n  const [hostMode, setHostMode] = useState<string>(\"0.0.0.0\");\n  const [host, setHost] = useState<string>(\"0.0.0.0\");\n  const [port, setPort] = useState<string>(\"6174\");\n  const [loading, setLoading] = useState<boolean>(true);\n  const [saving, setSaving] = useState<boolean>(false);\n  const [error, setError] = useState<string | null>(null);\n\n  useEffect(() => {\n    let cancelled = false;\n    (async () => {\n      const current = await getServerAddress();\n      if (cancelled) return;\n      if (current) {\n        applyAddress(current);\n      }\n      setLoading(false);\n    })();\n    return () => {\n      cancelled = true;\n    };\n  }, []);\n\n  const applyAddress = (addr: ServerAddress) => {\n    const preset = HOST_PRESETS.find(\n      (p) => p.value !== \"__custom__\" && p.value === addr.host,\n    );\n    setHost(addr.host);\n    setPort(String(addr.port));\n    setHostMode(preset ? preset.value : \"__custom__\");\n  };\n\n  const portNumber = useMemo(() => Number.parseInt(port, 10), [port]);\n  const hostValid = isValidHost(host.trim());\n  const portValid =\n    Number.isFinite(portNumber) && portNumber >= 1 && portNumber <= 65535;\n  const canSave = hostValid && portValid && !saving && !loading;\n  const lowPortWarning = portValid && portNumber < 1024;\n  const externalWarning =\n    hostValid && host.trim() !== \"127.0.0.1\" && host.trim() !== \"localhost\";\n\n  const handleHostModeChange = (value: string) => {\n    setHostMode(value);\n    if (value !== \"__custom__\") {\n      setHost(value);\n    }\n  };\n\n  const handleSave = async () => {\n    setError(null);\n    if (!hostValid) {\n      setError(\"Invalid host. Use an IPv4 address or hostname.\");\n      return;\n    }\n    if (!portValid) {\n      setError(\"Port must be an integer between 1 and 65535.\");\n      return;\n    }\n\n    setSaving(true);\n    try {\n      const status = await updateServerAddress(host.trim(), portNumber);\n      if (status?.base_url) {\n        storage.setServerUrl(status.base_url);\n      }\n      await closeSelf();\n    } catch (err) {\n      const message = err instanceof Error ? err.message : String(err);\n      setError(message);\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  return (\n    <div className='min-h-screen bg-background text-foreground'>\n      <div className='flex flex-col gap-5 p-6'>\n        <div>\n          <h1 className='text-lg font-semibold'>Server Address</h1>\n        </div>\n\n        <div className='grid gap-4'>\n          <div className='grid gap-2'>\n            <Label htmlFor='host-preset'>Host</Label>\n            <Select value={hostMode} onValueChange={handleHostModeChange}>\n              <SelectTrigger id='host-preset' className='w-full'>\n                <SelectValue placeholder='Select host' />\n              </SelectTrigger>\n              <SelectContent>\n                {HOST_PRESETS.map((preset) => (\n                  <SelectItem key={preset.value} value={preset.value}>\n                    {preset.label}\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n            {hostMode === \"__custom__\" && (\n              <Input\n                value={host}\n                onChange={(e) => setHost(e.target.value)}\n                placeholder='e.g. 192.168.1.10'\n                aria-invalid={!hostValid}\n                spellCheck={false}\n              />\n            )}\n          </div>\n\n          <div className='grid gap-2'>\n            <Label htmlFor='port'>Port</Label>\n            <Input\n              id='port'\n              type='number'\n              min={1}\n              max={65535}\n              value={port}\n              onChange={(e) => setPort(e.target.value)}\n              aria-invalid={!portValid}\n            />\n          </div>\n        </div>\n\n        {(externalWarning || lowPortWarning) && (\n          <div className='space-y-1 rounded-md border border-yellow-700/50 bg-yellow-900/20 p-3 text-xs text-yellow-200'>\n            {externalWarning && (\n              <p>\n                Binding to <code>{host}</code> exposes the server beyond this\n                machine. Make sure your firewall is configured.\n              </p>\n            )}\n            {lowPortWarning && (\n              <p>\n                Ports below 1024 are privileged and may require additional\n                permissions.\n              </p>\n            )}\n          </div>\n        )}\n\n        {error && (\n          <div className='rounded-md border border-red-700/60 bg-red-900/20 p-3 text-xs text-red-200'>\n            {error}\n          </div>\n        )}\n\n        <div className='flex justify-end gap-2'>\n          <Button\n            variant='ghost'\n            onClick={() => void closeSelf()}\n            disabled={saving}\n          >\n            Cancel\n          </Button>\n          <Button onClick={() => void handleSave()} disabled={!canSave}>\n            {saving ? \"Restarting\\u2026\" : \"Save & Restart\"}\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport default DesktopSettingsPage;\n"
  },
  {
    "path": "apps/client/src/pages/devices/index.tsx",
    "content": "import { useEffect, useRef } from \"react\";\nimport { useSearchParams } from \"react-router\";\nimport {\n  Breadcrumb,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbList,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"@/components/ui/breadcrumb\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n  SidebarInset,\n  SidebarProvider,\n  SidebarTrigger,\n} from \"@/components/ui/sidebar\";\nimport { AppSidebar } from \"@/features/sidebar\";\nimport {\n  INTEGRATION_DEVICE_ID,\n  INTEGRATION_ENTITY_ID,\n} from \"@/features/integration/constants\";\nimport { useDeviceStore } from \"@/entities/device/store\";\nimport { useEntityStore } from \"@/entities/entity/store\";\nimport { DeviceList } from \"@/widgets/device-list/DeviceList\";\nimport { EntityList } from \"@/widgets/entity-list/EntityList\";\n\nexport function DevicePage() {\n  const [searchParams, setSearchParams] = useSearchParams();\n  const configureParam = searchParams.get(\"configure\");\n\n  const { devices, isLoading: devicesLoading, selectDevice } = useDeviceStore();\n  const { entities, isLoading: entitiesLoading, setAutoOpenEntityId } =\n    useEntityStore();\n  const hasAutoConfigured = useRef(false);\n\n  useEffect(() => {\n    if (!configureParam || hasAutoConfigured.current) return;\n    if (devicesLoading || entitiesLoading) return;\n    if (devices.length === 0 || entities.length === 0) return;\n\n    const targetDeviceId = INTEGRATION_DEVICE_ID[configureParam];\n    const targetEntityId = INTEGRATION_ENTITY_ID[configureParam];\n    if (!targetDeviceId) {\n      setSearchParams({}, { replace: true });\n      return;\n    }\n\n    const targetDevice = devices.find((d) => d.device_id === targetDeviceId);\n    if (!targetDevice) {\n      setSearchParams({}, { replace: true });\n      return;\n    }\n\n    selectDevice(targetDevice);\n\n    const targetEntity = targetEntityId\n      ? entities.find((e) => e.entity_id === targetEntityId && e.device_id === targetDevice.id)\n      : entities.find((e) => e.device_id === targetDevice.id);\n    const firstEntity = targetEntity;\n    if (firstEntity) {\n      setAutoOpenEntityId(firstEntity.id);\n    }\n\n    hasAutoConfigured.current = true;\n    setSearchParams({}, { replace: true });\n  }, [\n    configureParam,\n    devices,\n    entities,\n    devicesLoading,\n    entitiesLoading,\n    selectDevice,\n    setAutoOpenEntityId,\n    setSearchParams,\n  ]);\n\n  return (\n    <SidebarProvider>\n      <AppSidebar />\n      <SidebarInset>\n        <header className='flex h-12 shrink-0 items-center gap-2 border-b px-4'>\n          <SidebarTrigger className='-ml-1' />\n          <Separator\n            orientation='vertical'\n            className='mr-2 data-[orientation=vertical]:h-4'\n          />\n          <Breadcrumb>\n            <BreadcrumbList>\n              <BreadcrumbItem className='hidden md:block'>\n                <BreadcrumbLink href='#'>/</BreadcrumbLink>\n              </BreadcrumbItem>\n              <BreadcrumbSeparator className='hidden md:block' />\n              <BreadcrumbItem>\n                <BreadcrumbPage>Devices</BreadcrumbPage>\n              </BreadcrumbItem>\n            </BreadcrumbList>\n          </Breadcrumb>\n        </header>\n        <main className='flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-6'>\n          <div className='grid auto-rows-max items-start gap-4 md:gap-8 lg:grid-cols-2'>\n            <DeviceList />\n            <EntityList />\n          </div>\n        </main>\n      </SidebarInset>\n    </SidebarProvider>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/pages/dynamic-dashboard/DynamicDashboardMainPanel.tsx",
    "content": "import { useMemo } from \"react\";\nimport { useDynamicDashboardStore } from \"@/entities/dynamic-dashboard/store\";\nimport { GroupCanvas } from \"@/features/dynamic-dashboard/GroupCanvas\";\nimport { useEntitiesData } from \"@/features/entity/useEntitiesData\";\n\ntype DynamicDashboardMainPanelProps = {\n  /** Which dashboard this column renders (store order / swipe order). */\n  dashboardId: string;\n};\n\nexport function DynamicDashboardMainPanel({\n  dashboardId,\n}: DynamicDashboardMainPanelProps) {\n  const { dashboards } = useDynamicDashboardStore();\n  const { entities, streamsState } = useEntitiesData();\n\n  const currentDashboard = useMemo(\n    () => dashboards.find((d) => d.id === dashboardId),\n    [dashboards, dashboardId],\n  );\n\n  return (\n    <div className='flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto overflow-x-hidden'>\n      {currentDashboard?.groups[0] ? (\n        <div className='flex min-h-0 flex-1 flex-col'>\n          <div className='flex flex-col md:min-h-0 md:flex-1'>\n            <GroupCanvas\n              dashboardId={currentDashboard.id}\n              group={currentDashboard.groups[0]}\n              entities={entities}\n              streamsState={streamsState}\n            />\n          </div>\n        </div>\n      ) : (\n        <div className='text-sm text-muted-foreground'>\n          No dynamic dashboard found. Create one with the + button or swipe\n          sideways for a new canvas.\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/pages/dynamic-dashboard/NewDynamicDashboardPanel.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { useDynamicDashboardStore } from \"@/entities/dynamic-dashboard/store\";\nimport { Plus } from \"lucide-react\";\nimport { useNavigate } from \"react-router\";\n\nexport function NewDynamicDashboardPanel() {\n  const navigate = useNavigate();\n  const createDashboard = useDynamicDashboardStore((s) => s.createDashboard);\n\n  const handleCreate = async () => {\n    const id = await createDashboard();\n    if (id) {\n      navigate(`/dynamic-dashboard/${id}`, { replace: true });\n    }\n  };\n\n  return (\n    <div className='flex min-h-0 flex-1 flex-col items-center justify-center gap-4 overflow-hidden px-6'>\n      <Button size='lg' onClick={handleCreate} className='shrink-0 gap-2'>\n        <Plus className='h-5 w-5' />\n        Create dynamic dashboard\n      </Button>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/pages/dynamic-dashboard/index.tsx",
    "content": "export { DynamicDashboardMainPanel } from \"./DynamicDashboardMainPanel\";\nexport { NewDynamicDashboardPanel } from \"./NewDynamicDashboardPanel\";\n"
  },
  {
    "path": "apps/client/src/pages/flow/index.tsx",
    "content": "import { useCallback, useEffect, useState } from \"react\";\nimport {\n  Breadcrumb,\n  BreadcrumbList,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbSeparator,\n  BreadcrumbPage,\n} from \"@/components/ui/breadcrumb\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n  SidebarInset,\n  SidebarProvider,\n  SidebarTrigger,\n} from \"@/components/ui/sidebar\";\nimport Flow, { FlowHeader, FlowSidebar } from \"@/features/flow/Flow\";\nimport { AppSidebar } from \"@/features/sidebar\";\nimport { useBeforeUnload, useBlocker } from \"react-router\";\nimport { useFlowStore } from \"@/entities/flow/store\";\nimport { useChatStore } from \"@/features/llm-chat\";\nimport {\n  buildFlowSystemPrompt,\n  FLOW_TOOLS,\n  executeFlowToolCalls,\n} from \"@/features/flow/flow-chat\";\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\n\nconst UNSAVED_FLOW_MESSAGE =\n  \"You have unsaved changes in this flow. Leave without saving?\";\n\nexport function FlowPage() {\n  const { hasUnsavedChanges } = useFlowStore();\n  const blocker = useBlocker(hasUnsavedChanges);\n  const [showLeavePrompt, setShowLeavePrompt] = useState(false);\n\n  useEffect(() => {\n    useChatStore.getState().setFlowContext({\n      buildSystemPrompt: () => {\n        const { nodes, edges, flows, currentFlowId } = useFlowStore.getState();\n        const currentFlow = flows.find((f) => f.id === currentFlowId);\n        return buildFlowSystemPrompt(\n          nodes,\n          edges,\n          flows,\n          currentFlow?.name ?? null,\n        );\n      },\n      tools: FLOW_TOOLS,\n      executeToolCalls: (toolCalls) =>\n        executeFlowToolCalls(toolCalls, useFlowStore.getState()),\n    });\n    return () => {\n      useChatStore.getState().setFlowContext(null);\n    };\n  }, []);\n\n  const handleBeforeUnload = useCallback(\n    (event: BeforeUnloadEvent) => {\n      if (!hasUnsavedChanges) return;\n      event.preventDefault();\n      event.returnValue = UNSAVED_FLOW_MESSAGE;\n      return UNSAVED_FLOW_MESSAGE;\n    },\n    [hasUnsavedChanges],\n  );\n\n  useBeforeUnload(handleBeforeUnload);\n\n  useEffect(() => {\n    if (blocker.state === \"blocked\") {\n      setShowLeavePrompt(true);\n    } else {\n      setShowLeavePrompt(false);\n    }\n  }, [blocker.state]);\n\n  const handleStay = () => {\n    blocker.reset?.();\n    setShowLeavePrompt(false);\n  };\n\n  const handleLeave = () => {\n    blocker.proceed?.();\n    setShowLeavePrompt(false);\n  };\n\n  return (\n    <SidebarProvider className='!h-svh !min-h-0 overflow-hidden'>\n      <AppSidebar />\n      <SidebarInset className='h-full'>\n        <header className='flex h-12 shrink-0 items-center gap-2 border-b px-4 justify-between'>\n          <SidebarTrigger className='-ml-1' />\n          <Separator\n            orientation='vertical'\n            className='mr-2 data-[orientation=vertical]:h-4'\n          />\n          <Breadcrumb>\n            <BreadcrumbList>\n              <BreadcrumbItem className='hidden md:block'>\n                <BreadcrumbLink href='#'>/</BreadcrumbLink>\n              </BreadcrumbItem>\n              <BreadcrumbSeparator className='hidden md:block' />\n              <BreadcrumbItem>\n                <BreadcrumbPage>Flow</BreadcrumbPage>\n              </BreadcrumbItem>\n            </BreadcrumbList>\n          </Breadcrumb>\n\n          <div className='ml-auto'>\n            <FlowHeader />\n          </div>\n        </header>\n        <div className='flex flex-1 flex-row h-[calc(100vh-48px)] overflow-hidden'>\n          <FlowSidebar />\n          <Flow />\n        </div>\n      </SidebarInset>\n\n      <AlertDialog\n        open={showLeavePrompt}\n        onOpenChange={(open) => {\n          if (!open && blocker.state === \"blocked\") {\n            blocker.reset?.();\n          }\n          setShowLeavePrompt(open);\n        }}\n      >\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>Unsaved changes</AlertDialogTitle>\n            <AlertDialogDescription>\n              {UNSAVED_FLOW_MESSAGE} Unsaved changes will be lost if you leave.\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel onClick={handleStay}>\n              Stay on this page\n            </AlertDialogCancel>\n            <AlertDialogAction onClick={handleLeave}>\n              Leave without saving\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </SidebarProvider>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/pages/landing/index.tsx",
    "content": "import {\n  NavigationMenu,\n  NavigationMenuItem,\n  NavigationMenuLink,\n  NavigationMenuList,\n  navigationMenuTriggerStyle,\n} from \"@/components/ui/navigation-menu\";\nimport { Link, useNavigate } from \"react-router\";\nimport { useEffect } from \"react\";\n\nfunction LandingPage() {\n  const navigate = useNavigate();\n\n  useEffect(() => {\n    navigate(\"/auth\");\n  }, []);\n\n  return (\n    <>\n      <main className='flex h-screen w-screen flex-col items-center justify-center bg-background text-foreground'>\n        {/* <div className='flex flex-col items-center gap-y-6'>\n          <h1 className='text-3xl md:text-5xl lg:text-5xl leading-17 font-bold tracking-tight text-center'>\n            Vessel\n          </h1>\n          <div className='flex items-center gap-x-3 pt-2'>\n            <Button\n              variant={\"default\"}\n              onClick={() => (location.href = \"/auth\")}\n            >\n              <LogIn /> Auth\n            </Button>\n            <Button\n              variant='outline'\n              onClick={() =>\n                window.open(\"https://vessel.cartesiancs.com/docs/introduction\")\n              }\n            >\n              <BookText /> Docs\n            </Button>\n            <Button\n              onClick={() =>\n                window.open(\"https://github.com/cartesiancs/vessel\")\n              }\n              variant='outline'\n            >\n              <FaGithub />\n              GitHub\n            </Button>\n          </div>\n        </div> */}\n      </main>\n    </>\n  );\n}\n\nexport function Navbar() {\n  const openInNewTab = (url: string) => {\n    window.open(url, \"_blank\", \"noopener,noreferrer\");\n  };\n\n  return (\n    <header className='absolute flex items-center justify-center top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60'>\n      <div className='container h-14 flex items-center justify-between w-full'>\n        <div className='flex items-center'>\n          <Link\n            to='/'\n            className='mr-6 flex items-center space-x-2 pl-4 sm:pl-8 gap-1'\n          >\n            <img src='/icon.png' alt='Logo' className='w-8' />\n            <span className='hidden font-medium sm:inline-block'>Vessel</span>\n          </Link>\n        </div>\n\n        <NavigationMenu>\n          <NavigationMenuList>\n            <NavigationMenuItem>\n              <NavigationMenuLink\n                className={navigationMenuTriggerStyle()}\n                onClick={() => openInNewTab(\"/docs\")}\n                style={{ cursor: \"pointer\" }}\n              >\n                Docs\n              </NavigationMenuLink>\n            </NavigationMenuItem>\n            <NavigationMenuItem>\n              <NavigationMenuLink\n                className={navigationMenuTriggerStyle()}\n                onClick={() =>\n                  openInNewTab(\"https://github.com/cartesiancs/vessel\")\n                }\n                style={{ cursor: \"pointer\" }}\n              >\n                GitHub\n              </NavigationMenuLink>\n            </NavigationMenuItem>\n            <NavigationMenuItem>\n              <NavigationMenuLink\n                className={navigationMenuTriggerStyle()}\n                onClick={() => openInNewTab(\"/docs\")}\n                style={{ cursor: \"pointer\" }}\n              >\n                Blog\n              </NavigationMenuLink>\n            </NavigationMenuItem>\n          </NavigationMenuList>\n        </NavigationMenu>\n      </div>\n    </header>\n  );\n}\n\nexport default LandingPage;\n"
  },
  {
    "path": "apps/client/src/pages/map/index.tsx",
    "content": "import {\n  Breadcrumb,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbList,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"@/components/ui/breadcrumb\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n  SidebarInset,\n  SidebarProvider,\n  SidebarTrigger,\n  useSidebar,\n} from \"@/components/ui/sidebar\";\nimport { MapView } from \"@/features/map\";\nimport { LayerSidebar } from \"@/features/map-draw/LayerSidebar\";\nimport { MapToolbar } from \"@/features/map-draw/MapToolbar\";\nimport { WebRTCProvider } from \"@/features/rtc/WebRTCProvider\";\nimport { AppSidebar } from \"@/features/sidebar\";\n\nexport function MapLayout() {\n  const { open } = useSidebar();\n\n  return (\n    <SidebarInset>\n      <header className='flex h-12 shrink-0 items-center gap-2 border-b px-4 fixed w-full bg-background/60 backdrop-blur-md z-[999]'>\n        <SidebarTrigger className='-ml-1' />\n        <Separator\n          orientation='vertical'\n          className='mr-2 data-[orientation=vertical]:h-4'\n        />\n        <Breadcrumb>\n          <BreadcrumbList>\n            <BreadcrumbItem className='hidden md:block'>\n              <BreadcrumbLink href='#'>/</BreadcrumbLink>\n            </BreadcrumbItem>\n            <BreadcrumbSeparator className='hidden md:block' />\n            <BreadcrumbItem>\n              <BreadcrumbPage>Map</BreadcrumbPage>\n            </BreadcrumbItem>\n          </BreadcrumbList>\n        </Breadcrumb>\n      </header>\n      <main className='flex-1 p-0 overflow-hidden'>\n        <LayerSidebar />\n        <MapToolbar />\n        <MapView isSidebarCollapsed={open} />\n      </main>\n    </SidebarInset>\n  );\n}\n\nexport function MapPage() {\n  return (\n    <SidebarProvider>\n      <WebRTCProvider>\n        <AppSidebar />\n        <MapLayout />\n      </WebRTCProvider>\n    </SidebarProvider>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/pages/notfound/index.tsx",
    "content": "import { Link } from \"react-router\";\n\nexport function NotFound() {\n  return (\n    <div className='bg-background flex h-[calc(100%_-_34px)] items-center justify-center p-6 md:p-10'>\n      <div className='flex w-full max-w-lg flex-col items-center text-center md:flex-row md:items-center md:gap-8 md:text-left'>\n        <h1 className='text-9xl font-extrabold tracking-tighter text-primary'>\n          404\n        </h1>\n        <div className='mt-4 md:mt-0'>\n          <h2 className='text-2xl font-semibold text-foreground'>\n            Page Not Found\n          </h2>\n          <p className='mt-2 text-base text-muted-foreground'>\n            Sorry, the page you are looking for might not exist or has been\n            moved.\n          </p>\n          <Link\n            to='/dashboard'\n            className='mt-6 inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none'\n          >\n            Go to Dashboard\n          </Link>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/pages/recordings/index.tsx",
    "content": "import {\n  Breadcrumb,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbList,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"@/components/ui/breadcrumb\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n  SidebarInset,\n  SidebarProvider,\n  SidebarTrigger,\n} from \"@/components/ui/sidebar\";\nimport { RecordingsList } from \"@/features/recording\";\nimport { AppSidebar } from \"@/features/sidebar\";\n\nexport function RecordingsPage() {\n  return (\n    <SidebarProvider>\n      <AppSidebar />\n      <SidebarInset>\n        <header className='flex h-12 shrink-0 items-center gap-2 border-b px-4'>\n          <SidebarTrigger className='-ml-1' />\n          <Separator\n            orientation='vertical'\n            className='mr-2 data-[orientation=vertical]:h-4'\n          />\n          <Breadcrumb>\n            <BreadcrumbList>\n              <BreadcrumbItem>\n                <BreadcrumbLink href='#'>/</BreadcrumbLink>\n              </BreadcrumbItem>\n              <BreadcrumbSeparator />\n              <BreadcrumbItem>\n                <BreadcrumbPage>Recordings</BreadcrumbPage>\n              </BreadcrumbItem>\n            </BreadcrumbList>\n          </Breadcrumb>\n        </header>\n        <main className='flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-6'>\n          <RecordingsList />\n        </main>\n      </SidebarInset>\n    </SidebarProvider>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/pages/settings/account.tsx",
    "content": "import { useState } from \"react\";\nimport { Link } from \"react-router\";\nimport { toast } from \"sonner\";\nimport {\n  Breadcrumb,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbList,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"@/components/ui/breadcrumb\";\nimport {\n  SidebarInset,\n  SidebarProvider,\n  SidebarTrigger,\n} from \"@/components/ui/sidebar\";\nimport { AppSidebar } from \"@/features/sidebar\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { useSupabaseAuth } from \"@/contexts/SupabaseAuthContext\";\nimport {\n  fetchTurnCredentials,\n  clearTurnCache,\n  saveTurnConfigToServer,\n  getCredentialInfo,\n  isTurnCredentialError,\n  notifyListeners,\n  type TurnCredentialsResponse,\n  type TurnUsage,\n} from \"@/features/rtc/turnService\";\nimport { useChatStore } from \"@/features/llm-chat/store\";\nimport { storage } from \"@/lib/storage\";\n\nfunction formatTurnQuotaUsage(usage: TurnUsage | null): string | null {\n  if (!usage?.quotaBytes) return null;\n\n  const usedGb = (usage.totalBytes / 1024 ** 3).toFixed(2);\n  const quotaGb = (usage.quotaBytes / 1024 ** 3).toFixed(2);\n  const resetsAt = usage.periodEnd\n    ? new Date(usage.periodEnd).toLocaleString()\n    : null;\n\n  return resetsAt\n    ? `${usedGb} GB / ${quotaGb} GB used. Quota resets ${resetsAt}.`\n    : `${usedGb} GB / ${quotaGb} GB used this period.`;\n}\n\nexport function AccountSettingsPage() {\n  const {\n    session,\n    isLoading: authLoading,\n    signInWithGoogle,\n    signOut,\n  } = useSupabaseAuth();\n\n  const { updateCapsuleUrl } = useChatStore();\n  const [capsuleUrl, setCapsuleUrl] = useState(\n    storage.getCapsuleUrl() ||\n      import.meta.env.VITE_CAPSULE_URL ||\n      \"http://localhost:3000\",\n  );\n  const [capsuleSaved, setCapsuleSaved] = useState(false);\n\n  const handleSaveCapsuleUrl = () => {\n    updateCapsuleUrl(capsuleUrl);\n    setCapsuleSaved(true);\n    toast.success(\"Capsule URL updated\");\n    setTimeout(() => setCapsuleSaved(false), 2000);\n  };\n\n  const [turnLoading, setTurnLoading] = useState(false);\n  const [turnResult, setTurnResult] = useState<TurnCredentialsResponse | null>(\n    null,\n  );\n  const [turnError, setTurnError] = useState<string | null>(null);\n  const [turnErrorUsage, setTurnErrorUsage] = useState<TurnUsage | null>(null);\n\n  const existingCred = getCredentialInfo();\n  const isIssued = turnResult\n    ? !new Date(turnResult.expiresAt).getTime() ||\n      new Date(turnResult.expiresAt).getTime() > Date.now()\n    : existingCred !== null && !existingCred.isExpired;\n  const isExpired = turnResult\n    ? false\n    : existingCred !== null && existingCred.isExpired;\n\n  const handleIssueTurn = async () => {\n    setTurnLoading(true);\n    setTurnError(null);\n    setTurnErrorUsage(null);\n    setTurnResult(null);\n    try {\n      clearTurnCache();\n      const result = await fetchTurnCredentials();\n      await saveTurnConfigToServer({\n        iceServers: result.iceServers,\n        expiresAt: result.expiresAt,\n      });\n      setTurnResult(result);\n      notifyListeners(result.iceServers);\n    } catch (err) {\n      if (isTurnCredentialError(err)) {\n        setTurnError(err.message);\n        setTurnErrorUsage(err.usage ?? null);\n      } else {\n        setTurnError(\n          err instanceof Error\n            ? err.message\n            : \"Failed to issue TURN credentials\",\n        );\n      }\n    } finally {\n      setTurnLoading(false);\n    }\n  };\n\n  return (\n    <SidebarProvider>\n      <AppSidebar />\n      <SidebarInset>\n        <header className='flex h-12 shrink-0 items-center gap-2 border-b px-4'>\n          <SidebarTrigger className='-ml-1' />\n          <Separator\n            orientation='vertical'\n            className='mr-2 data-[orientation=vertical]:h-4'\n          />\n          <Breadcrumb>\n            <BreadcrumbList>\n              <BreadcrumbItem>\n                <BreadcrumbLink asChild>\n                  <Link to='/dashboard'>/</Link>\n                </BreadcrumbLink>\n              </BreadcrumbItem>\n              <BreadcrumbSeparator />\n              <BreadcrumbItem>\n                <BreadcrumbLink asChild>\n                  <Link to='/settings'>Settings</Link>\n                </BreadcrumbLink>\n              </BreadcrumbItem>\n              <BreadcrumbSeparator />\n              <BreadcrumbItem>\n                <BreadcrumbPage>Account</BreadcrumbPage>\n              </BreadcrumbItem>\n            </BreadcrumbList>\n          </Breadcrumb>\n        </header>\n\n        <main className='flex-1 overflow-y-auto p-4 md:p-6 space-y-4'>\n          <div className='flex flex-col'>\n            <h1 className='font-semibold text-lg md:text-2xl'>Account</h1>\n          </div>\n\n          <Card>\n            <CardHeader>\n              <CardTitle>Vessel Cloud</CardTitle>\n              <CardDescription>\n                Sign in to connect with Vessel Cloud tunnel servers\n              </CardDescription>\n            </CardHeader>\n            <CardContent>\n              {session ? (\n                <div className='flex items-center justify-between'>\n                  <div className='flex items-center gap-2'>\n                    <span className='text-sm text-muted-foreground'>\n                      Signed in as\n                    </span>\n                    <span className='font-medium'>{session.user?.email}</span>\n                  </div>\n                  <Button\n                    variant='outline'\n                    onClick={signOut}\n                    disabled={authLoading}\n                  >\n                    Sign Out\n                  </Button>\n                </div>\n              ) : (\n                <Button onClick={signInWithGoogle} disabled={authLoading}>\n                  Sign in with Google\n                </Button>\n              )}\n            </CardContent>\n          </Card>\n\n          <Card>\n            <CardHeader>\n              <CardTitle>Capsule Server</CardTitle>\n              <CardDescription>\n                Configure the Capsule AI server URL for chat and image analysis\n              </CardDescription>\n            </CardHeader>\n            <CardContent className='space-y-3'>\n              <div className='space-y-1'>\n                <Label htmlFor='capsule-url'>Capsule Server URL</Label>\n                <div className='flex gap-2'>\n                  <Input\n                    id='capsule-url'\n                    placeholder='http://localhost:3000'\n                    value={capsuleUrl}\n                    onChange={(e) => setCapsuleUrl(e.target.value)}\n                  />\n                  <Button onClick={handleSaveCapsuleUrl}>\n                    {capsuleSaved ? \"Saved\" : \"Save\"}\n                  </Button>\n                </div>\n              </div>\n              <p className='text-xs text-muted-foreground'>\n                Default:{\" \"}\n                {import.meta.env.VITE_CAPSULE_URL || \"http://localhost:3000\"}\n              </p>\n            </CardContent>\n          </Card>\n\n          {session && (\n            <Card>\n              <CardHeader>\n                <CardTitle>TURN Server</CardTitle>\n                <CardDescription>\n                  Issue Cloudflare TURN credentials for WebRTC relay\n                </CardDescription>\n              </CardHeader>\n              <CardContent className='space-y-3'>\n                <div className='flex items-center gap-3'>\n                  {isExpired ? (\n                    <Button\n                      variant='destructive'\n                      onClick={handleIssueTurn}\n                      disabled={turnLoading}\n                    >\n                      {turnLoading\n                        ? \"Re-issuing...\"\n                        : \"Re-issue TURN Credentials\"}\n                    </Button>\n                  ) : (\n                    <Button\n                      onClick={handleIssueTurn}\n                      disabled={turnLoading || isIssued}\n                    >\n                      {turnLoading ? \"Issuing...\" : \"Issue TURN Credentials\"}\n                    </Button>\n                  )}\n                  {isIssued && (\n                    <Badge variant='outline' className='text-green-600'>\n                      Issued — expires{\" \"}\n                      {new Date(\n                        turnResult?.expiresAt ?? existingCred!.expiresAt,\n                      ).toLocaleTimeString()}\n                    </Badge>\n                  )}\n                  {isExpired && (\n                    <Badge variant='outline' className='text-red-500'>\n                      Expired\n                    </Badge>\n                  )}\n                  {turnError && (\n                    <div className='space-y-1'>\n                      <span className='text-sm text-red-500'>{turnError}</span>\n                      {turnErrorUsage && (\n                        <p className='text-xs text-muted-foreground'>\n                          {formatTurnQuotaUsage(turnErrorUsage)}\n                        </p>\n                      )}\n                    </div>\n                  )}\n                </div>\n                {isIssued && (\n                  <p className='text-xs text-muted-foreground'>\n                    {(turnResult?.iceServers ?? existingCred?.iceServers)\n                      ?.length ?? 0}{\" \"}\n                    ICE server(s) cached. Credentials will be applied\n                    automatically.\n                  </p>\n                )}\n              </CardContent>\n            </Card>\n          )}\n        </main>\n      </SidebarInset>\n    </SidebarProvider>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/pages/settings/config.tsx",
    "content": "import { useEffect } from \"react\";\nimport { Link } from \"react-router\";\nimport {\n  Breadcrumb,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbList,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"@/components/ui/breadcrumb\";\nimport {\n  SidebarInset,\n  SidebarProvider,\n  SidebarTrigger,\n} from \"@/components/ui/sidebar\";\nimport { AppSidebar } from \"@/features/sidebar\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { ConfigurationCreateButton } from \"@/features/configurations/ConfigurationCreateButton\";\nimport { ConfigurationActionButton } from \"@/features/configurations/ConfigurationActionButton\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { useConfigStore } from \"@/entities/configurations/store\";\n\nexport function ConfigSettingsPage() {\n  const { configurations, fetchConfigs, isLoading, error } = useConfigStore();\n\n  useEffect(() => {\n    fetchConfigs();\n  }, [fetchConfigs]);\n\n  return (\n    <SidebarProvider>\n      <AppSidebar />\n      <SidebarInset className='flex-1 min-w-0 h-full flex flex-col'>\n        <header className='flex h-12 shrink-0 items-center gap-2 border-b bg-background px-4'>\n          <SidebarTrigger className='-ml-1' />\n          <Separator\n            orientation='vertical'\n            className='mr-2 data-[orientation=vertical]:h-4'\n          />\n          <Breadcrumb className='hidden md:flex'>\n            <BreadcrumbList>\n              <BreadcrumbItem>\n                <BreadcrumbLink asChild>\n                  <Link to='/dashboard'>/</Link>\n                </BreadcrumbLink>\n              </BreadcrumbItem>\n              <BreadcrumbSeparator />\n              <BreadcrumbItem>\n                <BreadcrumbLink asChild>\n                  <Link to='/settings'>Settings</Link>\n                </BreadcrumbLink>\n              </BreadcrumbItem>\n              <BreadcrumbSeparator />\n              <BreadcrumbItem>\n                <BreadcrumbPage>Config</BreadcrumbPage>\n              </BreadcrumbItem>\n            </BreadcrumbList>\n          </Breadcrumb>\n          <div className='ml-auto'>\n            <ConfigurationCreateButton />\n          </div>\n        </header>\n\n        <main className='flex-1 overflow-y-auto p-4 md:p-6'>\n          <div className='flex flex-col mb-4'>\n            <div className='flex items-center mb-1'>\n              <h1 className='font-semibold text-lg md:text-2xl'>\n                Configurations\n              </h1>\n            </div>\n            <b className='text-neutral-500 font-light text-sm'>\n              If you change the configurations, you must restart the server.\n              **If a problem occurs due to an incorrect value, you will need to\n              activate debug mode to forcibly change the setting.**\n            </b>\n          </div>\n\n          {isLoading && <div>Loading configurations...</div>}\n          {error && <div className='text-red-500'>{error}</div>}\n\n          {!isLoading && !error && (\n            <div className='border shadow-sm  overflow-x-auto'>\n              <Table>\n                <TableHeader>\n                  <TableRow>\n                    <TableHead className='w-[200px] min-w-[200px]'>\n                      Key\n                    </TableHead>\n                    <TableHead className='min-w-[250px]'>Value</TableHead>\n                    <TableHead className='w-[100px] min-w-[100px]'>\n                      Status\n                    </TableHead>\n                    <TableHead className='sticky right-0 bg-background w-[100px] min-w-[100px] text-right'>\n                      Actions\n                    </TableHead>\n                  </TableRow>\n                </TableHeader>\n                <TableBody>\n                  {configurations.map((config) => (\n                    <TableRow key={config.id}>\n                      <TableCell className='font-mono'>{config.key}</TableCell>\n                      <TableCell className='font-mono max-w-xs truncate'>\n                        {config.value}\n                      </TableCell>\n                      <TableCell>\n                        <Badge variant={config.enabled ? \"default\" : \"outline\"}>\n                          {config.enabled ? \"Enabled\" : \"Disabled\"}\n                        </Badge>\n                      </TableCell>\n                      <TableCell className='sticky right-0 bg-background text-right'>\n                        <ConfigurationActionButton config={config} />\n                      </TableCell>\n                    </TableRow>\n                  ))}\n                </TableBody>\n              </Table>\n            </div>\n          )}\n        </main>\n      </SidebarInset>\n    </SidebarProvider>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/pages/settings/index.tsx",
    "content": "import { Link, useNavigate } from \"react-router\";\nimport {\n  Breadcrumb,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbList,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"@/components/ui/breadcrumb\";\nimport {\n  SidebarInset,\n  SidebarProvider,\n  SidebarTrigger,\n} from \"@/components/ui/sidebar\";\nimport { AppSidebar } from \"@/features/sidebar\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n  Blocks,\n  ChevronRight,\n  Cloud,\n  Code,\n  Network,\n  ScrollText,\n  Server,\n  UserCog,\n} from \"lucide-react\";\n\ntype Category = {\n  id: string;\n  title: string;\n  description: string;\n  icon: React.ReactNode;\n  to: string;\n};\n\nconst categories: Category[] = [\n  {\n    id: \"account\",\n    title: \"Account\",\n    description: \"Vessel Cloud, Capsule Server, TURN credentials\",\n    icon: <Cloud className='h-5 w-5' />,\n    to: \"/settings/account\",\n  },\n  {\n    id: \"services\",\n    title: \"Services\",\n    description: \"Code workspace, Tunnel\",\n    icon: <Code className='h-5 w-5' />,\n    to: \"/settings/services\",\n  },\n  {\n    id: \"users\",\n    title: \"Users\",\n    description: \"Manage users and roles\",\n    icon: <UserCog className='h-5 w-5' />,\n    to: \"/settings/users\",\n  },\n  {\n    id: \"networks\",\n    title: \"Networks\",\n    description: \"Network interface status\",\n    icon: <Network className='h-5 w-5' />,\n    to: \"/settings/networks\",\n  },\n  {\n    id: \"integration\",\n    title: \"Integration\",\n    description: \"Home Assistant, ROS2, RTL-SDR\",\n    icon: <Blocks className='h-5 w-5' />,\n    to: \"/settings/integration\",\n  },\n  {\n    id: \"log\",\n    title: \"Log\",\n    description: \"Server logs\",\n    icon: <ScrollText className='h-5 w-5' />,\n    to: \"/settings/log\",\n  },\n  {\n    id: \"config\",\n    title: \"Config\",\n    description: \"System configurations\",\n    icon: <Server className='h-5 w-5' />,\n    to: \"/settings/config\",\n  },\n];\n\nexport function SettingsPage() {\n  const navigate = useNavigate();\n\n  return (\n    <SidebarProvider>\n      <AppSidebar />\n      <SidebarInset>\n        <header className='flex h-12 shrink-0 items-center gap-2 border-b px-4'>\n          <SidebarTrigger className='-ml-1' />\n          <Separator\n            orientation='vertical'\n            className='mr-2 data-[orientation=vertical]:h-4'\n          />\n          <Breadcrumb>\n            <BreadcrumbList>\n              <BreadcrumbItem>\n                <BreadcrumbLink asChild>\n                  <Link to='/dashboard'>/</Link>\n                </BreadcrumbLink>\n              </BreadcrumbItem>\n              <BreadcrumbSeparator />\n              <BreadcrumbItem>\n                <BreadcrumbPage>Settings</BreadcrumbPage>\n              </BreadcrumbItem>\n            </BreadcrumbList>\n          </Breadcrumb>\n        </header>\n\n        <main className='flex-1 overflow-y-auto p-4 md:p-6'>\n          <div className='flex flex-col mb-4'>\n            <h1 className='font-semibold text-lg md:text-2xl'>Settings</h1>\n          </div>\n\n          <div className='flex flex-col border divide-y'>\n            {categories.map((c) => (\n              <button\n                key={c.id}\n                type='button'\n                onClick={() => navigate(c.to)}\n                className='flex items-center gap-3 px-4 py-3 text-left hover:bg-muted/50 transition-colors'\n              >\n                <div className='text-muted-foreground'>{c.icon}</div>\n                <div className='flex flex-col flex-1 min-w-0'>\n                  <span className='font-medium text-sm'>{c.title}</span>\n                  <span className='text-xs text-muted-foreground truncate'>\n                    {c.description}\n                  </span>\n                </div>\n                <ChevronRight className='h-4 w-4 text-muted-foreground shrink-0' />\n              </button>\n            ))}\n          </div>\n        </main>\n      </SidebarInset>\n    </SidebarProvider>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/pages/settings/integration.tsx",
    "content": "import React from \"react\";\nimport { Link } from \"react-router\";\nimport {\n  Breadcrumb,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbList,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"@/components/ui/breadcrumb\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n  SidebarInset,\n  SidebarProvider,\n  SidebarTrigger,\n} from \"@/components/ui/sidebar\";\n\nimport { AppSidebar } from \"@/features/sidebar\";\nimport { Intergration } from \"@/features/integration/Integration\";\n\nexport function IntegrationSettingsPage(): React.ReactElement {\n  return (\n    <SidebarProvider>\n      <AppSidebar />\n      <SidebarInset>\n        <header className='flex h-12 shrink-0 items-center gap-2 border-b px-4'>\n          <SidebarTrigger className='-ml-1' />\n          <Separator\n            orientation='vertical'\n            className='mr-2 data-[orientation=vertical]:h-4'\n          />\n          <Breadcrumb>\n            <BreadcrumbList>\n              <BreadcrumbItem className='hidden md:block'>\n                <BreadcrumbLink asChild>\n                  <Link to='/dashboard'>/</Link>\n                </BreadcrumbLink>\n              </BreadcrumbItem>\n              <BreadcrumbSeparator className='hidden md:block' />\n              <BreadcrumbItem className='hidden md:block'>\n                <BreadcrumbLink asChild>\n                  <Link to='/settings'>Settings</Link>\n                </BreadcrumbLink>\n              </BreadcrumbItem>\n              <BreadcrumbSeparator className='hidden md:block' />\n              <BreadcrumbItem>\n                <BreadcrumbPage>Integration</BreadcrumbPage>\n              </BreadcrumbItem>\n            </BreadcrumbList>\n          </Breadcrumb>\n        </header>\n        <main className='flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-6'>\n          <div className='flex items-center'>\n            <h1 className='text-lg font-semibold md:text-2xl'>Integrations</h1>\n          </div>\n\n          <div className='flex flex-col gap-4'>\n            <Intergration />\n          </div>\n        </main>\n      </SidebarInset>\n    </SidebarProvider>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/pages/settings/log.tsx",
    "content": "import { Link } from \"react-router\";\nimport {\n  Breadcrumb,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbList,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"@/components/ui/breadcrumb\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n  SidebarInset,\n  SidebarProvider,\n  SidebarTrigger,\n} from \"@/components/ui/sidebar\";\nimport { Logs } from \"@/features/log\";\nimport { AppSidebar } from \"@/features/sidebar\";\n\nexport function LogSettingsPage() {\n  return (\n    <SidebarProvider>\n      <AppSidebar />\n      <SidebarInset>\n        <header className='flex h-12 shrink-0 items-center gap-2 border-b px-4 fixed w-full bg-background/60 backdrop-blur-md z-[999999]'>\n          <SidebarTrigger className='-ml-1' />\n          <Separator\n            orientation='vertical'\n            className='mr-2 data-[orientation=vertical]:h-4'\n          />\n          <Breadcrumb>\n            <BreadcrumbList>\n              <BreadcrumbItem className='hidden md:block'>\n                <BreadcrumbLink asChild>\n                  <Link to='/dashboard'>/</Link>\n                </BreadcrumbLink>\n              </BreadcrumbItem>\n              <BreadcrumbSeparator className='hidden md:block' />\n              <BreadcrumbItem className='hidden md:block'>\n                <BreadcrumbLink asChild>\n                  <Link to='/settings'>Settings</Link>\n                </BreadcrumbLink>\n              </BreadcrumbItem>\n              <BreadcrumbSeparator className='hidden md:block' />\n              <BreadcrumbItem>\n                <BreadcrumbPage>Log</BreadcrumbPage>\n              </BreadcrumbItem>\n            </BreadcrumbList>\n          </Breadcrumb>\n        </header>\n        <main className='flex flex-1 flex-col gap-4 mt-10 p-4 md:gap-8 md:p-6'>\n          <Logs />\n        </main>\n      </SidebarInset>\n    </SidebarProvider>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/pages/settings/networks.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { Link } from \"react-router\";\nimport {\n  Breadcrumb,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbList,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"@/components/ui/breadcrumb\";\nimport {\n  SidebarInset,\n  SidebarProvider,\n  SidebarTrigger,\n} from \"@/components/ui/sidebar\";\nimport { AppSidebar } from \"@/features/sidebar\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Globe, Radio, Bluetooth, Zap } from \"lucide-react\";\n\nfunction useOnlineStatus() {\n  const [isOnline, setIsOnline] = useState(navigator.onLine);\n\n  useEffect(() => {\n    const handleOnline = () => setIsOnline(true);\n    const handleOffline = () => setIsOnline(false);\n\n    window.addEventListener(\"online\", handleOnline);\n    window.addEventListener(\"offline\", handleOffline);\n\n    return () => {\n      window.removeEventListener(\"online\", handleOnline);\n      window.removeEventListener(\"offline\", handleOffline);\n    };\n  }, []);\n\n  return isOnline;\n}\n\ninterface NetworkCardProps {\n  icon: React.ReactNode;\n  name: string;\n  status: \"online\" | \"offline\" | \"coming-soon\";\n}\n\nfunction NetworkCard({ icon, name, status }: NetworkCardProps) {\n  return (\n    <div className='flex items-center justify-between border p-4'>\n      <div className='flex items-center gap-3'>\n        <div className='text-muted-foreground'>{icon}</div>\n        <span className='font-medium text-sm'>{name}</span>\n      </div>\n      {status === \"online\" && (\n        <Badge variant='default' className='!bg-green-600 !text-white'>\n          Online\n        </Badge>\n      )}\n      {status === \"offline\" && <Badge variant='destructive'>Offline</Badge>}\n      {status === \"coming-soon\" && <Badge variant='outline'>Coming Soon</Badge>}\n    </div>\n  );\n}\n\nexport function NetworksSettingsPage() {\n  const isOnline = useOnlineStatus();\n\n  return (\n    <SidebarProvider>\n      <AppSidebar />\n      <SidebarInset>\n        <header className='flex h-12 shrink-0 items-center gap-2 border-b px-4'>\n          <SidebarTrigger className='-ml-1' />\n          <Separator\n            orientation='vertical'\n            className='mr-2 data-[orientation=vertical]:h-4'\n          />\n          <Breadcrumb>\n            <BreadcrumbList>\n              <BreadcrumbItem>\n                <BreadcrumbLink asChild>\n                  <Link to='/dashboard'>/</Link>\n                </BreadcrumbLink>\n              </BreadcrumbItem>\n              <BreadcrumbSeparator />\n              <BreadcrumbItem>\n                <BreadcrumbLink asChild>\n                  <Link to='/settings'>Settings</Link>\n                </BreadcrumbLink>\n              </BreadcrumbItem>\n              <BreadcrumbSeparator />\n              <BreadcrumbItem>\n                <BreadcrumbPage>Networks</BreadcrumbPage>\n              </BreadcrumbItem>\n            </BreadcrumbList>\n          </Breadcrumb>\n        </header>\n\n        <main className='flex-1 overflow-y-auto p-4 md:p-6'>\n          <div className='flex flex-col mb-4'>\n            <h1 className='font-semibold text-lg md:text-2xl'>Networks</h1>\n            <p className='text-muted-foreground text-sm'>\n              Monitor network interface status.\n            </p>\n          </div>\n\n          <div className='flex flex-col gap-2'>\n            <NetworkCard\n              icon={<Globe className='h-4 w-4' />}\n              name='Internet'\n              status={isOnline ? \"online\" : \"offline\"}\n            />\n            <NetworkCard\n              icon={<Radio className='h-4 w-4' />}\n              name='LoRa Network'\n              status='coming-soon'\n            />\n            <NetworkCard\n              icon={<Bluetooth className='h-4 w-4' />}\n              name='BLE'\n              status='coming-soon'\n            />\n            <NetworkCard\n              icon={<Zap className='h-4 w-4' />}\n              name='Zigbee'\n              status='coming-soon'\n            />\n          </div>\n        </main>\n      </SidebarInset>\n    </SidebarProvider>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/pages/settings/services.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { Link } from \"react-router\";\nimport { toast } from \"sonner\";\nimport {\n  Breadcrumb,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbList,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"@/components/ui/breadcrumb\";\nimport {\n  SidebarInset,\n  SidebarProvider,\n  SidebarTrigger,\n} from \"@/components/ui/sidebar\";\nimport { AppSidebar } from \"@/features/sidebar\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { useTunnelStore } from \"@/entities/tunnel/store\";\nimport { useSupabaseAuth } from \"@/contexts/SupabaseAuthContext\";\nimport { useConfigStore } from \"@/entities/configurations/store\";\nimport {\n  CODE_SERVICE_CONFIG_KEY,\n  getCodeServiceEnabled,\n} from \"@/entities/configurations/codeService\";\n\nexport function ServicesSettingsPage() {\n  const { status, isLoading, error, refresh, start, stop } = useTunnelStore();\n  const { session } = useSupabaseAuth();\n  const [server, setServer] = useState(\n    \"wss://agent.tunnel.cartesiancs.com/agent\",\n  );\n  const [target, setTarget] = useState(\"http://127.0.0.1:6174\");\n  const [localError, setLocalError] = useState<string | null>(null);\n\n  const {\n    configurations,\n    fetchConfigs,\n    createConfig,\n    updateConfig,\n    isLoading: configsLoading,\n  } = useConfigStore();\n  const codeConfigRow = configurations.find(\n    (c) => c.key === CODE_SERVICE_CONFIG_KEY,\n  );\n  const codeServiceEnabled = getCodeServiceEnabled(configurations);\n\n  useEffect(() => {\n    void fetchConfigs();\n  }, [fetchConfigs]);\n\n  useEffect(() => {\n    refresh();\n  }, [refresh]);\n\n  useEffect(() => {\n    if (status.server) setServer(status.server);\n    if (status.target) setTarget(status.target);\n  }, [status.server, status.target]);\n\n  const handleCodeServiceToggle = async (on: boolean) => {\n    try {\n      if (codeConfigRow) {\n        await updateConfig(codeConfigRow.id, {\n          key: codeConfigRow.key,\n          value: codeConfigRow.value,\n          enabled: on ? 1 : 0,\n          description: codeConfigRow.description,\n        });\n      } else if (on) {\n        await createConfig({\n          key: CODE_SERVICE_CONFIG_KEY,\n          value: \"1\",\n          enabled: 1,\n          description: \"Enable the Code workspace (file browser and editor).\",\n        });\n      }\n      toast.success(on ? \"Code workspace enabled\" : \"Code workspace disabled\");\n    } catch {\n      toast.error(\"Failed to update Code workspace setting\");\n    }\n  };\n\n  const handleToggle = async (checked: boolean) => {\n    setLocalError(null);\n    if (checked) {\n      if (!server || !target) {\n        setLocalError(\"Enter tunnel server URL and target.\");\n        return;\n      }\n      try {\n        await start(server, target, session?.access_token);\n      } catch (err) {\n        setLocalError(\n          err instanceof Error ? err.message : \"Failed to start tunnel.\",\n        );\n      }\n    } else {\n      try {\n        await stop();\n      } catch (err) {\n        setLocalError(\n          err instanceof Error ? err.message : \"Failed to stop tunnel.\",\n        );\n      }\n    }\n  };\n\n  const copySession = async () => {\n    if (status.session_id) {\n      const url = `https://${status.session_id}.tunnel.cartesiancs.com/auth`;\n      await navigator.clipboard.writeText(url);\n      toast.success(\"Tunnel URL copied\", {\n        description: url,\n      });\n    }\n  };\n\n  return (\n    <SidebarProvider>\n      <AppSidebar />\n      <SidebarInset>\n        <header className='flex h-12 shrink-0 items-center gap-2 border-b px-4'>\n          <SidebarTrigger className='-ml-1' />\n          <Separator\n            orientation='vertical'\n            className='mr-2 data-[orientation=vertical]:h-4'\n          />\n          <Breadcrumb>\n            <BreadcrumbList>\n              <BreadcrumbItem>\n                <BreadcrumbLink asChild>\n                  <Link to='/dashboard'>/</Link>\n                </BreadcrumbLink>\n              </BreadcrumbItem>\n              <BreadcrumbSeparator />\n              <BreadcrumbItem>\n                <BreadcrumbLink asChild>\n                  <Link to='/settings'>Settings</Link>\n                </BreadcrumbLink>\n              </BreadcrumbItem>\n              <BreadcrumbSeparator />\n              <BreadcrumbItem>\n                <BreadcrumbPage>Services</BreadcrumbPage>\n              </BreadcrumbItem>\n            </BreadcrumbList>\n          </Breadcrumb>\n        </header>\n\n        <main className='flex-1 overflow-y-auto p-4 md:p-6 space-y-4'>\n          <div className='flex flex-col'>\n            <h1 className='font-semibold text-lg md:text-2xl'>Services</h1>\n          </div>\n\n          <Card>\n            <CardHeader>\n              <CardTitle>Code workspace</CardTitle>\n              <CardDescription></CardDescription>\n            </CardHeader>\n            <CardContent>\n              <div className='flex items-center justify-between border px-3 py-2'>\n                <div className='flex flex-col gap-0.5'>\n                  <span className='font-medium'>Code service</span>\n                </div>\n                <Switch\n                  checked={codeServiceEnabled}\n                  disabled={configsLoading}\n                  onCheckedChange={(v) => void handleCodeServiceToggle(v)}\n                />\n              </div>\n            </CardContent>\n          </Card>\n\n          <Card>\n            <CardHeader>\n              <CardTitle>Tunnel</CardTitle>\n              <CardDescription></CardDescription>\n            </CardHeader>\n            <CardContent className='space-y-4'>\n              <div className='flex items-center justify-between border px-3 py-2'>\n                <div className='flex flex-col'>\n                  <span className='font-medium'>Tunnel Connection</span>\n                </div>\n                <Switch\n                  checked={status.active}\n                  disabled={isLoading}\n                  onCheckedChange={handleToggle}\n                />\n              </div>\n\n              <div className='grid gap-3 md:grid-cols-2'>\n                <div className='space-y-1'>\n                  <Label htmlFor='server'>\n                    Tunnel server URL (wss://…/agent)\n                  </Label>\n                  <Input\n                    id='server'\n                    placeholder='wss://tunnel.example.com/agent'\n                    value={server}\n                    onChange={(e) => setServer(e.target.value)}\n                    disabled={isLoading || status.active}\n                  />\n                </div>\n                <div className='space-y-1'>\n                  <Label htmlFor='target'>Local target URL</Label>\n                  <Input\n                    id='target'\n                    placeholder='http://127.0.0.1:6174'\n                    value={target}\n                    onChange={(e) => setTarget(e.target.value)}\n                    disabled={isLoading || status.active}\n                  />\n                </div>\n              </div>\n\n              <div className='flex items-center gap-3 flex-wrap'>\n                <div className='flex items-center gap-2'>\n                  <span className='text-sm text-muted-foreground'>Session</span>\n                  {status.session_id ? (\n                    <Badge variant='outline' className='font-mono'>\n                      {status.session_id}\n                    </Badge>\n                  ) : (\n                    <Badge variant='secondary'>None</Badge>\n                  )}\n                </div>\n                {status.session_id && (\n                  <div className='flex items-center gap-2'>\n                    <Button size='sm' variant='outline' onClick={copySession}>\n                      Copy tunnel URL\n                    </Button>\n                  </div>\n                )}\n              </div>\n\n              <div className='flex items-center gap-2'>\n                <Button\n                  variant='outline'\n                  size='sm'\n                  onClick={refresh}\n                  disabled={isLoading}\n                >\n                  Refresh status\n                </Button>\n                {(error || localError) && (\n                  <span className='text-sm text-red-500'>\n                    {localError ?? error}\n                  </span>\n                )}\n              </div>\n            </CardContent>\n          </Card>\n        </main>\n      </SidebarInset>\n    </SidebarProvider>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/pages/settings/users.tsx",
    "content": "import { Link } from \"react-router\";\nimport {\n  Breadcrumb,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbList,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"@/components/ui/breadcrumb\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n  SidebarInset,\n  SidebarProvider,\n  SidebarTrigger,\n} from \"@/components/ui/sidebar\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { AppSidebar } from \"@/features/sidebar\";\nimport { RoleTable } from \"@/widgets/role-table/RoleList\";\nimport { UserTable } from \"@/widgets/user-table/UserList\";\n\nexport function UsersSettingsPage() {\n  return (\n    <SidebarProvider>\n      <AppSidebar />\n      <SidebarInset>\n        <header className='flex h-12 shrink-0 items-center gap-2 border-b px-4'>\n          <SidebarTrigger className='-ml-1' />\n          <Separator\n            orientation='vertical'\n            className='mr-2 data-[orientation=vertical]:h-4'\n          />\n          <Breadcrumb>\n            <BreadcrumbList>\n              <BreadcrumbItem className='hidden md:block'>\n                <BreadcrumbLink asChild>\n                  <Link to='/dashboard'>/</Link>\n                </BreadcrumbLink>\n              </BreadcrumbItem>\n              <BreadcrumbSeparator className='hidden md:block' />\n              <BreadcrumbItem className='hidden md:block'>\n                <BreadcrumbLink asChild>\n                  <Link to='/settings'>Settings</Link>\n                </BreadcrumbLink>\n              </BreadcrumbItem>\n              <BreadcrumbSeparator className='hidden md:block' />\n              <BreadcrumbItem>\n                <BreadcrumbPage>Users</BreadcrumbPage>\n              </BreadcrumbItem>\n            </BreadcrumbList>\n          </Breadcrumb>\n        </header>\n        <main className='flex-1 overflow-y-auto p-4 md:p-6'>\n          <Tabs defaultValue='user-list' className='w-full'>\n            <TabsList className='grid w-full grid-cols-2'>\n              <TabsTrigger value='user-list'>Users</TabsTrigger>\n              <TabsTrigger value='role'>Roles</TabsTrigger>\n            </TabsList>\n            <TabsContent value='user-list' className='mt-4'>\n              <UserTable />\n            </TabsContent>\n            <TabsContent value='role' className='mt-4'>\n              <RoleTable />\n            </TabsContent>\n          </Tabs>\n        </main>\n      </SidebarInset>\n    </SidebarProvider>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/pages/setup/index.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport {\n  Breadcrumb,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbList,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"@/components/ui/breadcrumb\";\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n  SidebarInset,\n  SidebarProvider,\n  SidebarTrigger,\n} from \"@/components/ui/sidebar\";\nimport { AppSidebar } from \"@/features/sidebar\";\nimport { ArrowRight, Check, Circle, Loader2 } from \"lucide-react\";\nimport { initialSetupSteps, SetupStep } from \"@/features/setup\";\n\nexport function SetupPage(): React.ReactElement {\n  const [steps, setSteps] = useState<SetupStep[]>(initialSetupSteps);\n  const [isVerifying, setIsVerifying] = useState(true);\n\n  useEffect(() => {\n    const runInitialChecks = async () => {\n      setIsVerifying(true);\n      const updatedSteps = await Promise.all(\n        initialSetupSteps.map(async (step) => ({\n          ...step,\n          isCompleted: await step.verifyStatus(),\n        })),\n      );\n      setSteps(updatedSteps);\n      setIsVerifying(false);\n    };\n\n    runInitialChecks();\n  }, []);\n\n  const handleNavigate = (url: string) => {\n    console.log(`Navigating to ${url}`);\n    window.location.href = url;\n  };\n\n  return (\n    <SidebarProvider>\n      <AppSidebar />\n      <SidebarInset>\n        <header className='flex h-12 shrink-0 items-center gap-2 border-b border-gray-800 px-4'>\n          <SidebarTrigger className='-ml-1' />\n          <Separator\n            orientation='vertical'\n            className='mr-2 border-gray-700 data-[orientation=vertical]:h-4'\n          />\n          <Breadcrumb>\n            <BreadcrumbList>\n              <BreadcrumbItem className='hidden md:block'>\n                <BreadcrumbLink href='#' className='text-gray-400'>\n                  /\n                </BreadcrumbLink>\n              </BreadcrumbItem>\n              <BreadcrumbSeparator className='hidden md:block' />\n              <BreadcrumbItem>\n                <BreadcrumbPage className='text-gray-50'>Setup</BreadcrumbPage>\n              </BreadcrumbItem>\n            </BreadcrumbList>\n          </Breadcrumb>\n        </header>\n        <main className='flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-6'>\n          <div className='flex items-center'>\n            <h1 className='text-lg font-semibold text-gray-50 md:text-2xl'>\n              Setup Guide\n            </h1>\n          </div>\n\n          {isVerifying && (\n            <div className='flex items-center justify-center p-8'>\n              <Loader2 className='h-6 w-6 animate-spin text-gray-400' />\n              <p className='ml-3 text-gray-400'>Verifying setup status...</p>\n            </div>\n          )}\n          {!isVerifying && (\n            <div className='flex flex-col'>\n              {steps.map((step, index) => (\n                <div key={step.id} className='flex gap-4'>\n                  <div className='flex flex-col items-center'>\n                    <div\n                      className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full ${\n                        step.isCompleted\n                          ? \"bg-blue-600 text-white\"\n                          : \"border-2 border-gray-700 bg-gray-900 text-gray-500\"\n                      }`}\n                    >\n                      {step.isCompleted ? (\n                        <Check className='h-5 w-5' />\n                      ) : (\n                        <Circle className='h-3 w-3 fill-current' />\n                      )}\n                    </div>\n                    {index < steps.length - 1 && (\n                      <div className='h-full w-0.5 bg-gray-800' />\n                    )}\n                  </div>\n                  <div className='flex-1 pb-10'>\n                    <div className='flex items-center justify-between'>\n                      <div className='flex flex-col'>\n                        <h3\n                          className={`font-semibold ${\n                            step.isCompleted ? \"text-gray-600\" : \"text-gray-100\"\n                          }`}\n                        >\n                          {step.title}\n                        </h3>\n                        <p\n                          className={`text-sm ${\n                            step.isCompleted\n                              ? \"text-gray-500 line-through\"\n                              : \"text-gray-400\"\n                          }`}\n                        >\n                          {step.description}\n                        </p>\n                      </div>\n                      {step.isCompleted ? (\n                        <div className=''></div>\n                      ) : (\n                        <Button\n                          onClick={() => handleNavigate(step.url)}\n                          size='sm'\n                          variant='secondary'\n                        >\n                          Go to setup\n                          <ArrowRight className='ml-2 h-4 w-4' />\n                        </Button>\n                      )}\n                    </div>\n                  </div>\n                </div>\n              ))}\n            </div>\n          )}\n        </main>\n      </SidebarInset>\n    </SidebarProvider>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/shared/api/index.ts",
    "content": "import axios from \"axios\";\nimport { isDemoMode } from \"../demo\";\nimport { createMockAdapter } from \"../mock/mockAdapter\";\nimport { storage } from \"@/lib/storage\";\n\nexport const apiClient = axios.create({\n  headers: {\n    \"Content-Type\": \"application/json\",\n  },\n});\n\nif (isDemoMode) {\n  apiClient.defaults.adapter = createMockAdapter();\n}\n\napiClient.interceptors.request.use(\n  (config) => {\n    if (!isDemoMode) {\n      const token = storage.getToken();\n      const serverUrl = storage.getServerUrl();\n\n      if (serverUrl) {\n        config.baseURL = `${serverUrl}/api`;\n      }\n\n      if (token) {\n        config.headers.Authorization = `Bearer ${token}`;\n      }\n    }\n    return config;\n  },\n  (error) => {\n    return Promise.reject(error);\n  },\n);\n\napiClient.interceptors.response.use(\n  (response) => {\n    return response;\n  },\n  (error) => {\n    if (!isDemoMode && error.response && error.response.status === 401) {\n      window.location.href = \"/auth\";\n    }\n    return Promise.reject(error);\n  },\n);\n"
  },
  {
    "path": "apps/client/src/shared/demo.ts",
    "content": "const DEMO_HOSTNAME = \"demo.vsl.cartesiancs.com\";\n\nconst isDemoHostname =\n  typeof window !== \"undefined\" &&\n  window.location?.hostname === DEMO_HOSTNAME;\n\nconst isDemoEnv =\n  typeof import.meta !== \"undefined\" &&\n  import.meta.env &&\n  import.meta.env.VITE_DEMO_MODE === \"true\";\n\nexport const isDemoMode = isDemoEnv || isDemoHostname;\n\nexport const DEMO_SERVER_URL = \"https://demo.vessel.local\";\nexport const DEMO_TOKEN = \"demo-token\";\n"
  },
  {
    "path": "apps/client/src/shared/desktop.ts",
    "content": "type InvokeFn = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;\ntype TauriCore = { invoke?: InvokeFn };\ntype TauriNamespace = { invoke?: InvokeFn };\ntype TauriGlobal = {\n  invoke?: InvokeFn;\n  core?: TauriCore;\n  tauri?: TauriNamespace;\n};\n\nconst resolveInvoker = (): InvokeFn | null => {\n  if (typeof window === \"undefined\") {\n    return null;\n  }\n\n  const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;\n\n  // Tauri v2 (withGlobalTauri: true)\n  if (tauri?.core?.invoke && typeof tauri.core.invoke === \"function\") {\n    return tauri.core.invoke.bind(tauri.core) as InvokeFn;\n  }\n\n  // Tauri v1 fallbacks\n  if (tauri?.invoke && typeof tauri.invoke === \"function\") {\n    return tauri.invoke as InvokeFn;\n  }\n\n  if (tauri?.tauri?.invoke && typeof tauri.tauri.invoke === \"function\") {\n    return tauri.tauri.invoke as InvokeFn;\n  }\n\n  return null;\n};\n\n// Loads the official invoke function via dynamic import. Works regardless of\n// whether `withGlobalTauri` is enabled, because Vite bundles @tauri-apps/api.\nlet cachedAsyncInvoke: Promise<InvokeFn | null> | null = null;\nconst loadAsyncInvoke = async (): Promise<InvokeFn | null> => {\n  if (typeof window === \"undefined\") return null;\n  if (!cachedAsyncInvoke) {\n    cachedAsyncInvoke = (async () => {\n      try {\n        const mod = (await import(\"@tauri-apps/api/core\")) as {\n          invoke?: InvokeFn;\n        };\n        return typeof mod.invoke === \"function\" ? mod.invoke : null;\n      } catch {\n        return null;\n      }\n    })();\n  }\n  return cachedAsyncInvoke;\n};\n\nconst getInvoke = async (): Promise<InvokeFn | null> => {\n  const sync = resolveInvoker();\n  if (sync) return sync;\n  return await loadAsyncInvoke();\n};\n\nconst hasTauriUA = () => {\n  if (typeof navigator === \"undefined\" || !navigator.userAgent) return false;\n  return navigator.userAgent.toLowerCase().includes(\"tauri\");\n};\n\nconst hasTauriEnv = () => {\n  try {\n    // Vite sets import.meta.env.TAURI in Tauri builds\n    const env = (import.meta as ImportMeta & { env?: Record<string, unknown> }).env;\n    return Boolean(env?.TAURI);\n  } catch {\n    return false;\n  }\n};\n\nexport const isTauri = (): boolean =>\n  Boolean(resolveInvoker()) || hasTauriUA() || hasTauriEnv();\n\nexport const ensureSidecarRunning = async (): Promise<void> => {\n  const invoke = await getInvoke();\n  if (!invoke) {\n    return;\n  }\n\n  try {\n    await invoke(\"start_sidecar\");\n  } catch (error) {\n    console.warn(\"Failed to start server sidecar via Tauri\", error);\n  }\n};\n\nexport type SidecarStatus = {\n  running: boolean;\n  base_url?: string | null;\n  listen_address: string;\n  working_dir: string;\n};\n\nexport type ServerAddress = {\n  host: string;\n  port: number;\n};\n\nexport const getDesktopServerUrl = async (): Promise<string | null> => {\n  const invoke = await getInvoke();\n  if (!invoke) {\n    return null;\n  }\n\n  try {\n    const result = (await invoke(\"get_sidecar_status\")) as SidecarStatus | null;\n    if (result && typeof result.base_url === \"string\") {\n      return result.base_url;\n    }\n  } catch (error) {\n    console.warn(\"Failed to read sidecar status from Tauri\", error);\n  }\n\n  return null;\n};\n\nexport const getSidecarStatus = async (): Promise<SidecarStatus | null> => {\n  const invoke = await getInvoke();\n  if (!invoke) return null;\n  try {\n    return (await invoke(\"get_sidecar_status\")) as SidecarStatus;\n  } catch (error) {\n    console.warn(\"Failed to read sidecar status\", error);\n    return null;\n  }\n};\n\nexport const getServerAddress = async (): Promise<ServerAddress | null> => {\n  const invoke = await getInvoke();\n  if (!invoke) return null;\n  try {\n    return (await invoke(\"get_server_address\")) as ServerAddress;\n  } catch (error) {\n    console.warn(\"Failed to read server address\", error);\n    return null;\n  }\n};\n\nexport const updateServerAddress = async (\n  host: string,\n  port: number,\n): Promise<SidecarStatus> => {\n  const invoke = await getInvoke();\n  if (!invoke) {\n    throw new Error(\"Tauri runtime not available\");\n  }\n  return (await invoke(\"update_server_address\", { host, port })) as SidecarStatus;\n};\n\nexport const openDesktopSettings = async (): Promise<void> => {\n  const invoke = await getInvoke();\n  if (!invoke) return;\n  try {\n    await invoke(\"open_settings_window\");\n  } catch (error) {\n    console.warn(\"Failed to open settings window\", error);\n  }\n};\n"
  },
  {
    "path": "apps/client/src/shared/mock/mockAdapter.ts",
    "content": "import type {\n  AxiosAdapter,\n  AxiosResponse,\n  InternalAxiosRequestConfig,\n} from \"axios\";\nimport {\n  FeatureWithVertices,\n  MapFeature,\n  MapLayer,\n} from \"@/entities/map/types\";\nimport {\n  SystemConfiguration,\n  SystemConfigurationPayload,\n} from \"@/entities/configurations/types\";\nimport { CODE_SERVICE_CONFIG_KEY } from \"@/entities/configurations/codeService\";\nimport { Device, DevicePayload } from \"@/entities/device/types\";\nimport { EntityAll, EntityPayload } from \"@/entities/entity/types\";\nimport { Role, CreateRolePayload } from \"@/entities/role/types\";\nimport { User, CreateUserPayload, UpdateUserPayload } from \"@/entities/user/types\";\nimport { Flow, FlowPayload } from \"@/entities/flow/types\";\nimport { CustomNodeFromApi } from \"@/entities/custom-nodes/types\";\nimport { createMockDb, MockDatabase } from \"./mockData\";\n\nconst clone = <T>(data: T): T => JSON.parse(JSON.stringify(data));\n\nconst match = (path: string, pattern: RegExp) => {\n  const matchResult = path.match(pattern);\n  return matchResult ? matchResult.slice(1) : null;\n};\n\nconst buildResponse = <T>(\n  config: InternalAxiosRequestConfig,\n  status: number,\n  data: T,\n): AxiosResponse<T> => ({\n  data: clone(data),\n  status,\n  statusText: \"OK\",\n  headers: {},\n  config,\n});\n\nconst normalizePath = (url?: string) => {\n  if (!url) return \"/\";\n  const withoutOrigin = url.replace(/^https?:\\/\\/[^/]+/i, \"\");\n  const strippedApi = withoutOrigin.replace(/^\\/api/i, \"\");\n  return strippedApi.replace(/\\/+$/, \"\") || \"/\";\n};\n\nexport const createMockAdapter = (): AxiosAdapter => {\n  const db: MockDatabase = createMockDb();\n\n  const isMockCodeServiceEnabled = () =>\n    db.configs.some(\n      (c) => c.key === CODE_SERVICE_CONFIG_KEY && c.enabled === 1,\n    );\n\n  const counters = {\n    device: db.devices.length + 1,\n    entity: db.entities.length + 1,\n    flow: db.flows.length + 1,\n    flowVersion: Object.values(db.flowVersions).flat().length + 1,\n    config: db.configs.length + 1,\n    layer: db.mapLayers.length + 1,\n    feature: Object.values(db.mapFeatures).flat().length + 1,\n    vertex: Object.values(db.mapFeatures)\n      .flat()\n      .reduce((acc, f) => acc + f.vertices.length, 1),\n    customNode: db.customNodes.length + 1,\n    role: db.roles.length + 1,\n    user: db.users.length + 1,\n    permission: db.permissions.length + 1,\n    deviceToken: Object.keys(db.deviceTokens).length + 1,\n  };\n\n  const getDirectoryEntries = (path: string) => {\n    const entries = Object.entries(db.storage).filter(([key]) => {\n      const parent =\n        key.lastIndexOf(\"/\") === -1 ? \"\" : key.slice(0, key.lastIndexOf(\"/\"));\n      return parent === path && key !== \"\";\n    });\n\n    return entries.map(([name, value]) => ({\n      name: name.replace(path ? `${path}/` : \"\", \"\"),\n      isDir: value.type === \"dir\",\n    }));\n  };\n\n  const ensureDir = (path: string) => {\n    const segments = path.split(\"/\").filter(Boolean);\n    let current = \"\";\n    segments.forEach((segment) => {\n      current = current ? `${current}/${segment}` : segment;\n      if (!db.storage[current]) {\n        db.storage[current] = { type: \"dir\" };\n      }\n    });\n  };\n\n  const renamePath = (oldPath: string, newPath: string) => {\n    const updates: [string, { type: \"file\" | \"dir\"; content?: string }][] = [];\n    Object.entries(db.storage).forEach(([key, value]) => {\n      if (key === oldPath || key.startsWith(`${oldPath}/`)) {\n        const suffix = key.slice(oldPath.length);\n        updates.push([`${newPath}${suffix}`, value]);\n        delete db.storage[key];\n      }\n    });\n    updates.forEach(([key, value]) => {\n      db.storage[key] = value;\n    });\n  };\n\n  const getDeviceWithEntities = (deviceId: number) => {\n    const device = db.devices.find((d) => d.id === deviceId);\n    if (!device) return null;\n    const entities = db.entities.filter((e) => e.device_id === deviceId);\n    return { ...device, entities } as unknown as DeviceWithEntities;\n  };\n\n  type DeviceWithEntities = MockDatabase[\"devices\"][number] & {\n    entities: MockDatabase[\"entities\"];\n  };\n\n  type RequestBody = Record<string, unknown>;\n  type VertexInput = {\n    latitude: number;\n    longitude: number;\n    altitude?: number;\n  };\n\n  return async (config: InternalAxiosRequestConfig) => {\n    const method = (config.method ?? \"get\").toLowerCase();\n    const path = normalizePath(config.url);\n    const params = (config.params as Record<string, string>) || {};\n    const body = (config.data as RequestBody) || {};\n\n    // Stat\n    if (method === \"get\" && path === \"/stat\") {\n      return buildResponse(config, 200, db.stat);\n    }\n\n    // Configurations\n    if (path === \"/configurations\") {\n      if (method === \"get\") {\n        return buildResponse(config, 200, db.configs);\n      }\n      if (method === \"post\") {\n        const payload = body as SystemConfigurationPayload;\n        const now = new Date().toISOString();\n        const newConfig: SystemConfiguration = {\n          id: counters.config++,\n          key: payload.key ?? \"config_key\",\n          value: payload.value ?? \"\",\n          enabled: payload.enabled ?? 1,\n          description: payload.description ?? null,\n          created_at: now,\n          updated_at: now,\n        };\n        db.configs.push(newConfig);\n        return buildResponse(config, 200, newConfig);\n      }\n    }\n\n    const configMatch = match(path, /^\\/configurations\\/(\\d+)$/);\n    if (configMatch) {\n      const id = Number(configMatch[0]);\n      const idx = db.configs.findIndex((c) => c.id === id);\n      if (idx !== -1) {\n        if (method === \"put\") {\n          db.configs[idx] = {\n            ...db.configs[idx],\n            ...body,\n            updated_at: new Date().toISOString(),\n          };\n          return buildResponse(config, 200, db.configs[idx]);\n        }\n        if (method === \"delete\") {\n          const deleted = db.configs[idx];\n          db.configs.splice(idx, 1);\n          return buildResponse(config, 200, deleted);\n        }\n      }\n    }\n\n    // Devices\n    if (path === \"/devices\" && method === \"get\") {\n      return buildResponse(config, 200, db.devices);\n    }\n    if (path === \"/devices\" && method === \"post\") {\n      const payload = body as DevicePayload;\n      const newDevice: Device = {\n        id: counters.device++,\n        device_id: payload.device_id ?? `device-${Date.now()}`,\n        name: payload.name ?? null,\n        manufacturer: payload.manufacturer ?? null,\n        model: payload.model ?? null,\n      };\n      db.devices.push(newDevice);\n      return buildResponse(config, 200, newDevice);\n    }\n\n    const deviceByIdMatch = match(path, /^\\/devices\\/id\\/(\\d+)$/);\n    if (deviceByIdMatch && method === \"get\") {\n      const id = Number(deviceByIdMatch[0]);\n      const device = getDeviceWithEntities(id);\n      if (device) return buildResponse(config, 200, device);\n    }\n\n    const deviceMatch = match(path, /^\\/devices\\/(\\d+)$/);\n    if (deviceMatch) {\n      const id = Number(deviceMatch[0]);\n      const idx = db.devices.findIndex((d) => d.id === id);\n      if (idx !== -1) {\n        if (method === \"put\") {\n          db.devices[idx] = { ...db.devices[idx], ...body };\n          return buildResponse(config, 200, db.devices[idx]);\n        }\n        if (method === \"delete\") {\n          const deleted = db.devices[idx];\n          db.devices.splice(idx, 1);\n          return buildResponse(config, 200, deleted);\n        }\n      }\n    }\n\n    // Device tokens\n    const tokenMatch = match(path, /^\\/devices\\/(\\d+)\\/token$/);\n    if (tokenMatch) {\n      const deviceId = Number(tokenMatch[0]);\n      if (method === \"post\") {\n        const token = `token-${deviceId}-${Date.now()}`;\n        const tokenObj = {\n          id: counters.deviceToken++,\n          device_id: deviceId,\n          token,\n          expires_at: null,\n          last_used_at: null,\n          created_at: new Date().toISOString(),\n        };\n        db.deviceTokens[deviceId] = tokenObj;\n        return buildResponse(config, 200, {\n          message: \"issued\",\n          token,\n        });\n      }\n      if (method === \"get\") {\n        const tokenInfo = db.deviceTokens[deviceId];\n        return buildResponse(config, 200, tokenInfo ?? { message: \"none\" });\n      }\n      if (method === \"delete\") {\n        delete db.deviceTokens[deviceId];\n        return buildResponse(config, 200, { message: \"revoked\" });\n      }\n    }\n\n    // Entities\n    if (path === \"/entities\" && method === \"get\") {\n      const filtered = params.entity_type\n        ? db.entities.filter((e) => e.entity_type === params.entity_type)\n        : db.entities;\n      return buildResponse(config, 200, filtered);\n    }\n    if (path === \"/entities\" && method === \"post\") {\n      const payload = body as EntityPayload;\n      const newEntity: EntityAll = {\n        id: counters.entity++,\n        entity_id: payload.entity_id ?? `entity-${Date.now()}`,\n        device_id: payload.device_id ?? 0,\n        friendly_name: payload.friendly_name ?? \"\",\n        platform: payload.platform ?? \"\",\n        configuration: (payload.configuration as Record<string, unknown> | null) ?? null,\n        state: null,\n        entity_type: payload.entity_type ?? \"\",\n      };\n      db.entities.push(newEntity);\n      db.stat.count.entities = db.entities.length;\n      return buildResponse(config, 200, newEntity);\n    }\n    const entityMatch = match(path, /^\\/entities\\/(\\d+)$/);\n    if (entityMatch) {\n      const id = Number(entityMatch[0]);\n      const idx = db.entities.findIndex((e) => e.id === id);\n      if (idx !== -1) {\n        if (method === \"put\") {\n          db.entities[idx] = { ...db.entities[idx], ...body };\n          return buildResponse(config, 200, db.entities[idx]);\n        }\n        if (method === \"delete\") {\n          const deleted = db.entities[idx];\n          db.entities.splice(idx, 1);\n          db.stat.count.entities = db.entities.length;\n          return buildResponse(config, 200, deleted);\n        }\n      }\n    }\n    if (path === \"/entities/all\" && method === \"get\") {\n      const filtered = params.entity_type\n        ? db.entities.filter((e) => e.entity_type === params.entity_type)\n        : db.entities;\n      return buildResponse(config, 200, filtered);\n    }\n\n    // Permissions\n    if (path === \"/permissions\" && method === \"get\") {\n      return buildResponse(config, 200, db.permissions);\n    }\n\n    // Roles\n    if (path === \"/roles\") {\n      if (method === \"get\") {\n        return buildResponse(config, 200, db.roles);\n      }\n      if (method === \"post\") {\n        const payload = body as CreateRolePayload;\n        const newRole: Role = {\n          id: counters.role++,\n          name: payload.name ?? `Role ${counters.role}`,\n          description: payload.description ?? null,\n          permissions: db.permissions.filter((p) =>\n            (payload.permission_ids ?? []).includes(p.id),\n          ),\n        };\n        db.roles.push(newRole);\n        return buildResponse(config, 200, newRole);\n      }\n    }\n    const roleMatch = match(path, /^\\/roles\\/(\\d+)$/);\n    if (roleMatch) {\n      const id = Number(roleMatch[0]);\n      const idx = db.roles.findIndex((r) => r.id === id);\n      if (idx !== -1) {\n        if (method === \"put\") {\n          const payload = body as Partial<CreateRolePayload>;\n          db.roles[idx] = {\n            ...db.roles[idx],\n            ...payload,\n            name: payload.name ?? db.roles[idx].name,\n            description: payload.description ?? db.roles[idx].description,\n            permissions: db.permissions.filter((p) =>\n              ((payload.permission_ids as number[] | undefined) ?? []).includes(\n                p.id,\n              ),\n            ),\n          };\n          return buildResponse(config, 200, db.roles[idx]);\n        }\n        if (method === \"delete\") {\n          const deleted = db.roles[idx];\n          db.roles.splice(idx, 1);\n          return buildResponse(config, 200, deleted);\n        }\n      }\n    }\n\n    // Users\n    if (path === \"/users\") {\n      if (method === \"get\") {\n        return buildResponse(config, 200, db.users);\n      }\n      if (method === \"post\") {\n        const payload = body as CreateUserPayload;\n        const newUser: User = {\n          id: counters.user++,\n          username: payload.username ?? `user-${counters.user}`,\n          email: payload.email ?? \"\",\n          roles: [],\n          created_at: new Date().toISOString(),\n          updated_at: new Date().toISOString(),\n        };\n        db.users.push(newUser);\n        return buildResponse(config, 200, newUser);\n      }\n    }\n\n    const userMatch = match(path, /^\\/users\\/(\\d+)$/);\n    if (userMatch) {\n      const id = Number(userMatch[0]);\n      const idx = db.users.findIndex((u) => u.id === id);\n      if (idx !== -1) {\n        if (method === \"put\") {\n          const payload = body as UpdateUserPayload;\n          db.users[idx] = {\n            ...db.users[idx],\n            ...payload,\n            username: payload.username ?? db.users[idx].username,\n            email: payload.email ?? db.users[idx].email,\n            updated_at: new Date().toISOString(),\n          };\n          return buildResponse(config, 200, db.users[idx]);\n        }\n        if (method === \"delete\") {\n          const deleted = db.users[idx];\n          db.users.splice(idx, 1);\n          return buildResponse(config, 200, deleted);\n        }\n      }\n    }\n\n    const userRoleAssign = match(path, /^\\/users\\/(\\d+)\\/roles$/);\n    if (userRoleAssign && method === \"post\") {\n      const id = Number(userRoleAssign[0]);\n      const user = db.users.find((u) => u.id === id);\n      if (user) {\n        const role = db.roles.find(\n          (r) => r.id === (body.role_id as number | undefined),\n        );\n        if (role && !user.roles.find((r) => r.id === role.id)) {\n          user.roles.push(role);\n        }\n        return buildResponse(config, 200, user);\n      }\n    }\n    const userRoleRevoke = match(path, /^\\/users\\/(\\d+)\\/roles\\/(\\d+)$/);\n    if (userRoleRevoke && method === \"delete\") {\n      const userId = Number(userRoleRevoke[0]);\n      const roleId = Number(userRoleRevoke[1]);\n      const user = db.users.find((u) => u.id === userId);\n      if (user) {\n        user.roles = user.roles.filter((r) => r.id !== roleId);\n        return buildResponse(config, 200, user);\n      }\n    }\n\n    // Flows\n    if (path === \"/flows\" && method === \"get\") {\n      return buildResponse(config, 200, db.flows);\n    }\n    if (path === \"/flows\" && method === \"post\") {\n      const payload = body as Partial<FlowPayload>;\n      const newFlow: Flow = {\n        id: counters.flow++,\n        created_at: new Date().toISOString(),\n        updated_at: new Date().toISOString(),\n        enabled: payload.enabled ?? 1,\n        name: payload.name ?? `Flow ${counters.flow}`,\n        description: payload.description ?? null,\n      };\n      db.flows.push(newFlow);\n      db.flowVersions[newFlow.id] = [];\n      return buildResponse(config, 200, newFlow);\n    }\n    const flowMatch = match(path, /^\\/flows\\/(\\d+)$/);\n    if (flowMatch && method === \"delete\") {\n      const id = Number(flowMatch[0]);\n      const idx = db.flows.findIndex((f) => f.id === id);\n      if (idx !== -1) {\n        const removed = db.flows[idx];\n        db.flows.splice(idx, 1);\n        delete db.flowVersions[id];\n        return buildResponse(config, 200, removed);\n      }\n    }\n\n    const flowVersionMatch = match(path, /^\\/flows\\/(\\d+)\\/versions$/);\n    if (flowVersionMatch) {\n      const flowId = Number(flowVersionMatch[0]);\n      if (method === \"get\") {\n        return buildResponse(config, 200, db.flowVersions[flowId] ?? []);\n      }\n      if (method === \"post\") {\n        const versions = db.flowVersions[flowId] ?? [];\n        const versionNumber =\n          versions.length > 0\n            ? Math.max(...versions.map((v) => v.version)) + 1\n            : 1;\n        const newVersion = {\n          id: counters.flowVersion++,\n          flow_id: flowId,\n          version: versionNumber,\n          comment: (body.comment as string | undefined) ?? null,\n          graph_json: (body.graph_json as string | undefined) ?? \"{}\",\n          created_at: new Date().toISOString(),\n        };\n        if (!db.flowVersions[flowId]) {\n          db.flowVersions[flowId] = [];\n        }\n        db.flowVersions[flowId].push(newVersion);\n        return buildResponse(config, 200, newVersion);\n      }\n    }\n\n    // Custom nodes\n    if (path === \"/custom-nodes\") {\n      if (method === \"get\") {\n        return buildResponse(config, 200, db.customNodes);\n      }\n      if (method === \"post\") {\n        const payload = body as Partial<CustomNodeFromApi>;\n        const newNode: CustomNodeFromApi = {\n          node_type: payload.node_type ?? \"demo_node\",\n          data: payload.data ?? {},\n        };\n        db.customNodes.push(newNode);\n        return buildResponse(config, 200, newNode);\n      }\n    }\n    const customNodeMatch = match(path, /^\\/custom-nodes\\/([^/]+)$/);\n    if (customNodeMatch) {\n      const nodeType = customNodeMatch[0];\n      const idx = db.customNodes.findIndex((n) => n.node_type === nodeType);\n      if (idx !== -1) {\n        if (method === \"put\") {\n          db.customNodes[idx] = {\n            ...db.customNodes[idx],\n            data: body.data as Record<string, unknown>,\n          };\n          return buildResponse(config, 200, db.customNodes[idx]);\n        }\n        if (method === \"delete\") {\n          const deleted = db.customNodes[idx];\n          db.customNodes.splice(idx, 1);\n          return buildResponse(config, 200, deleted);\n        }\n      }\n    }\n\n    // Map layers\n    if (path === \"/map/layers\") {\n      if (method === \"get\") {\n        return buildResponse(config, 200, db.mapLayers);\n      }\n      if (method === \"post\") {\n        const isVisible = Boolean(body.is_visible ?? true);\n        const payload = body as Partial<MapLayer>;\n        const newLayer: MapLayer = {\n          id: counters.layer++,\n          owner_user_id: 1,\n          is_visible: isVisible ? 1 : 0,\n          created_at: new Date().toISOString(),\n          updated_at: new Date().toISOString(),\n          name: payload.name ?? `Layer ${counters.layer}`,\n          description: payload.description,\n        };\n        db.mapLayers.push(newLayer);\n        db.mapFeatures[newLayer.id] = [];\n        return buildResponse(config, 200, newLayer);\n      }\n    }\n\n    const mapLayerMatch = match(path, /^\\/map\\/layers\\/(\\d+)$/);\n    if (mapLayerMatch) {\n      const layerId = Number(mapLayerMatch[0]);\n      if (method === \"get\") {\n        const layer = db.mapLayers.find((l) => l.id === layerId);\n        if (layer) {\n          return buildResponse(config, 200, {\n            ...layer,\n            features: db.mapFeatures[layerId] ?? [],\n          });\n        }\n      }\n      if (method === \"put\") {\n        const idx = db.mapLayers.findIndex((l) => l.id === layerId);\n        if (idx !== -1) {\n          const isVisible = Boolean(\n            body.is_visible ?? Boolean(db.mapLayers[idx].is_visible),\n          );\n          const payload = body as Partial<MapLayer>;\n          db.mapLayers[idx] = {\n            ...db.mapLayers[idx],\n            ...payload,\n            name: payload.name ?? db.mapLayers[idx].name,\n            description: payload.description ?? db.mapLayers[idx].description,\n            is_visible: isVisible ? 1 : 0,\n            updated_at: new Date().toISOString(),\n          };\n          return buildResponse(config, 200, db.mapLayers[idx]);\n        }\n      }\n      if (method === \"delete\") {\n        const idx = db.mapLayers.findIndex((l) => l.id === layerId);\n        if (idx !== -1) {\n          const deleted = db.mapLayers[idx];\n          db.mapLayers.splice(idx, 1);\n          delete db.mapFeatures[layerId];\n          return buildResponse(config, 200, { message: \"deleted\", ...deleted });\n        }\n      }\n    }\n\n    // Map features\n    if (path === \"/map/features\" && method === \"post\") {\n      const featureId = counters.feature++;\n      const vertices = (body.vertices as VertexInput[] | undefined ?? []).map(\n        (v, idx: number) => ({\n          id: counters.vertex++,\n          feature_id: featureId,\n          sequence: idx,\n          latitude: v.latitude,\n          longitude: v.longitude,\n          altitude: v.altitude ?? 0,\n        }),\n      );\n      const newFeature: MapFeature = {\n        id: featureId,\n        layer_id: body.layer_id as number,\n        feature_type: body.feature_type as MapFeature[\"feature_type\"],\n        name: (body.name as string | undefined) ?? \"\",\n        style_properties: (body.style_properties as string | undefined) ?? \"\",\n        created_by_user_id: 1,\n        created_at: new Date().toISOString(),\n        updated_at: new Date().toISOString(),\n      };\n      const featureWithVertices: FeatureWithVertices = {\n        ...newFeature,\n        vertices,\n      };\n      if (!db.mapFeatures[newFeature.layer_id]) {\n        db.mapFeatures[newFeature.layer_id] = [];\n      }\n      db.mapFeatures[newFeature.layer_id].push(featureWithVertices);\n      return buildResponse(config, 200, newFeature);\n    }\n\n    const mapFeatureMatch = match(path, /^\\/map\\/features\\/(\\d+)$/);\n    if (mapFeatureMatch) {\n      const featureId = Number(mapFeatureMatch[0]);\n      const layerId = Number(\n        Object.entries(db.mapFeatures).find(([, features]) =>\n          features.some((f) => f.id === featureId),\n        )?.[0] ?? 0,\n      );\n      const features = db.mapFeatures[layerId] ?? [];\n      const idx = features.findIndex((f) => f.id === featureId);\n      if (idx !== -1) {\n        if (method === \"get\") {\n          return buildResponse(config, 200, features[idx]);\n        }\n        if (method === \"put\") {\n          const updated = {\n            ...features[idx],\n            ...body,\n            vertices: body.vertices\n              ? (body.vertices as VertexInput[]).map((v, i: number) => ({\n                  id: counters.vertex++,\n                  feature_id: featureId,\n                  sequence: i,\n                  latitude: v.latitude,\n                  longitude: v.longitude,\n                  altitude: v.altitude ?? 0,\n                }))\n              : features[idx].vertices,\n            updated_at: new Date().toISOString(),\n          };\n          features[idx] = updated;\n          db.mapFeatures[layerId] = features;\n          return buildResponse(config, 200, updated);\n        }\n        if (method === \"delete\") {\n          const deleted = features[idx];\n          features.splice(idx, 1);\n          db.mapFeatures[layerId] = features;\n          return buildResponse(config, 200, { message: \"deleted\", ...deleted });\n        }\n      }\n    }\n\n    // HA\n    if (path === \"/ha/states\" && method === \"get\") {\n      return buildResponse(config, 200, db.haStates);\n    }\n\n    // Logs\n    if (path === \"/logs/latest\" && method === \"get\") {\n      const filename = db.logs.files[0];\n      return buildResponse(config, 200, {\n        filename,\n        logs: db.logs.contents[filename],\n      });\n    }\n    if (path === \"/logs\" && method === \"get\") {\n      return buildResponse(config, 200, { files: db.logs.files });\n    }\n    const logMatch = match(path, /^\\/logs\\/(.+)$/);\n    if (logMatch && method === \"get\") {\n      const filename = logMatch[0];\n      return buildResponse(config, 200, {\n        filename,\n        logs: db.logs.contents[filename] ?? \"\",\n      });\n    }\n\n    // Storage\n    if (path.startsWith(\"/storage\")) {\n      if (!isMockCodeServiceEnabled()) {\n        return buildResponse(config, 403, {\n          error: \"Code workspace is disabled\",\n        });\n      }\n      const relPath = path.replace(/^\\/storage\\/?/, \"\");\n      if (method === \"get\") {\n        if (relPath === \"\" || db.storage[relPath]?.type === \"dir\") {\n          const entries = getDirectoryEntries(relPath);\n          return buildResponse(config, 200, { path: relPath, entries });\n        }\n        const file = db.storage[relPath];\n        if (file?.type === \"file\") {\n          return buildResponse(config, 200, file.content ?? \"\");\n        }\n      }\n      if (method === \"put\") {\n        ensureDir(\n          relPath.includes(\"/\")\n            ? relPath.split(\"/\").slice(0, -1).join(\"/\")\n            : \"\",\n        );\n        const content = (body.content as string | undefined) ?? \"\";\n        db.storage[relPath] = { type: \"file\", content };\n        return buildResponse(config, 200, { message: \"saved\" });\n      }\n      if (method === \"post\" && relPath.startsWith(\"mkdir/\")) {\n        const dir = relPath.replace(/^mkdir\\//, \"\");\n        ensureDir(dir);\n        db.storage[dir] = { type: \"dir\" };\n        return buildResponse(config, 200, { message: \"created\" });\n      }\n      if (method === \"post\" && relPath.startsWith(\"rename/\")) {\n        const oldPath = relPath.replace(/^rename\\//, \"\");\n        const newPath = (body.to as string | undefined) ?? oldPath;\n        renamePath(oldPath, newPath);\n        return buildResponse(config, 200, { message: \"renamed\" });\n      }\n      if (method === \"delete\") {\n        delete db.storage[relPath];\n        Object.keys(db.storage).forEach((key) => {\n          if (key.startsWith(`${relPath}/`)) {\n            delete db.storage[key];\n          }\n        });\n        return buildResponse(config, 200, { message: \"deleted\" });\n      }\n    }\n\n    // Fallback\n    return buildResponse(config, 200, {});\n  };\n};\n"
  },
  {
    "path": "apps/client/src/shared/mock/mockData.ts",
    "content": "import { Device } from \"@/entities/device/types\";\nimport { EntityAll } from \"@/entities/entity/types\";\nimport { Flow, FlowVersion } from \"@/entities/flow/types\";\nimport { HaState } from \"@/entities/ha/types\";\nimport { MapLayer, FeatureWithVertices } from \"@/entities/map/types\";\nimport { Permission } from \"@/entities/permission/types\";\nimport { Role } from \"@/entities/role/types\";\nimport { DeviceToken } from \"@/entities/device-token/types\";\nimport { Stat } from \"@/entities/stat/types\";\nimport { SystemConfiguration } from \"@/entities/configurations/types\";\nimport { User } from \"@/entities/user/types\";\nimport { CustomNode } from \"@/entities/custom-nodes/types\";\n\nexport type MockDatabase = {\n  stat: Stat;\n  devices: Device[];\n  entities: EntityAll[];\n  flows: Flow[];\n  flowVersions: Record<number, FlowVersion[]>;\n  configs: SystemConfiguration[];\n  logs: {\n    files: string[];\n    contents: Record<string, string>;\n  };\n  storage: Record<string, { type: \"file\" | \"dir\"; content?: string }>;\n  mapLayers: MapLayer[];\n  mapFeatures: Record<number, FeatureWithVertices[]>;\n  haStates: HaState[];\n  customNodes: CustomNode[];\n  permissions: Permission[];\n  roles: Role[];\n  users: User[];\n  deviceTokens: Record<number, DeviceToken>;\n};\n\nexport const createMockDb = (): MockDatabase => ({\n  stat: {\n    count: { devices: 2, entities: 3 },\n  },\n  devices: [\n    {\n      id: 1,\n      device_id: \"sensor-hub-1\",\n      name: \"Sensor Hub A\",\n      manufacturer: \"Vessel Labs\",\n      model: \"VH-100\",\n    },\n    {\n      id: 2,\n      device_id: \"drone-1\",\n      name: \"Delivery Drone\",\n      manufacturer: \"Vessel Labs\",\n      model: \"VD-42\",\n    },\n  ],\n  entities: [\n    {\n      id: 1,\n      entity_id: \"sensor-hub-1-temp\",\n      device_id: 1,\n      friendly_name: \"Room Temperature\",\n      platform: \"mqtt\",\n      configuration: { topic: \"sensors/temperature\" },\n      state: {\n        state_id: 1,\n        metadata_id: 1,\n        state: \"24.2\",\n        attributes: JSON.stringify({ unit: \"°C\" }),\n        last_changed: new Date().toISOString(),\n        last_updated: new Date().toISOString(),\n        created: new Date().toISOString(),\n      },\n      entity_type: \"sensor\",\n    },\n    {\n      id: 2,\n      entity_id: \"sensor-hub-1-humidity\",\n      device_id: 1,\n      friendly_name: \"Humidity\",\n      platform: \"mqtt\",\n      configuration: { topic: \"sensors/humidity\" },\n      state: {\n        state_id: 2,\n        metadata_id: 1,\n        state: \"48\",\n        attributes: JSON.stringify({ unit: \"%\" }),\n        last_changed: new Date().toISOString(),\n        last_updated: new Date().toISOString(),\n        created: new Date().toISOString(),\n      },\n      entity_type: \"sensor\",\n    },\n    {\n      id: 3,\n      entity_id: \"drone-1-camera\",\n      device_id: 2,\n      friendly_name: \"Front Camera\",\n      platform: \"rtp\",\n      configuration: { topic: \"drone/front-camera\" },\n      state: null,\n      entity_type: \"camera\",\n    },\n  ],\n  flows: [\n    {\n      id: 1,\n      name: \"Demo Flow\",\n      description: \"Example flow that demonstrates mock data.\",\n      enabled: 1,\n      created_at: new Date().toISOString(),\n      updated_at: new Date().toISOString(),\n    },\n  ],\n  flowVersions: {\n    1: [\n      {\n        id: 1,\n        flow_id: 1,\n        version: 1,\n        graph_json: JSON.stringify({\n          nodes: [\n            {\n              id: \"start\",\n              title: \"Start\",\n              x: 100,\n              y: 100,\n              width: 120,\n              height: 60,\n              connectors: [{ id: \"start-out\", name: \"out\", type: \"out\" }],\n              nodeType: \"START\",\n            },\n            {\n              id: \"log\",\n              title: \"Log Message\",\n              x: 320,\n              y: 100,\n              width: 140,\n              height: 80,\n              connectors: [{ id: \"log-in\", name: \"message\", type: \"in\" }],\n              nodeType: \"LOG_MESSAGE\",\n            },\n          ],\n          edges: [{ id: \"start-log\", source: \"start\", target: \"log\" }],\n        }),\n        comment: \"Initial demo graph\",\n        created_at: new Date().toISOString(),\n      },\n    ],\n  },\n  configs: [\n    {\n      id: 1,\n      key: \"home_assistant_url\",\n      value: \"https://demo-home-assistant.local\",\n      enabled: 1,\n      description: \"Demo HA endpoint\",\n      created_at: new Date().toISOString(),\n      updated_at: new Date().toISOString(),\n    },\n    {\n      id: 2,\n      key: \"home_assistant_token\",\n      value: \"demo-ha-token\",\n      enabled: 1,\n      description: \"Demo HA token\",\n      created_at: new Date().toISOString(),\n      updated_at: new Date().toISOString(),\n    },\n    {\n      id: 3,\n      key: \"ros2_websocket_url\",\n      value: \"ws://demo-ros2.local\",\n      enabled: 1,\n      description: \"Demo ROS2 bridge\",\n      created_at: new Date().toISOString(),\n      updated_at: new Date().toISOString(),\n    },\n    {\n      id: 4,\n      key: \"code_service_enabled\",\n      value: \"1\",\n      enabled: 0,\n      description: \"Code workspace (demo: off by default)\",\n      created_at: new Date().toISOString(),\n      updated_at: new Date().toISOString(),\n    },\n  ],\n  logs: {\n    files: [\"server.log\", \"app.log\"],\n    contents: {\n      \"server.log\":\n        \"[INFO] Server started in demo mode\\n[INFO] All systems nominal\\n\",\n      \"app.log\":\n        \"[INFO] User logged in (demo)\\n[WARN] Using mock dataset only\\n\",\n    },\n  },\n  storage: {\n    \"\": { type: \"dir\" },\n    src: { type: \"dir\" },\n    \"src/index.ts\": {\n      type: \"file\",\n      content:\n        \"console.log('Demo file from mock storage');\\nexport const demo = true;\\n\",\n    },\n    \"README.md\": {\n      type: \"file\",\n      content: \"# Demo Project\\nThis is a mock file system.\\n\",\n    },\n  },\n  mapLayers: [\n    {\n      id: 1,\n      name: \"Demo Layer\",\n      description: \"Sample map data\",\n      owner_user_id: 1,\n      is_visible: 1,\n      created_at: new Date().toISOString(),\n      updated_at: new Date().toISOString(),\n    },\n  ],\n  mapFeatures: {\n    1: [\n      {\n        id: 1,\n        layer_id: 1,\n        feature_type: \"POINT\",\n        name: \"HQ\",\n        style_properties: JSON.stringify({ color: \"blue\" }),\n        created_by_user_id: 1,\n        created_at: new Date().toISOString(),\n        updated_at: new Date().toISOString(),\n        vertices: [\n          {\n            id: 1,\n            feature_id: 1,\n            latitude: 37.7749,\n            longitude: -122.4194,\n            altitude: 0,\n            sequence: 0,\n          },\n        ],\n      },\n    ],\n  },\n  haStates: [\n    {\n      entity_id: \"light.living_room\",\n      state: \"on\",\n      attributes: { friendly_name: \"Living Room Light\", brightness: 200 },\n      last_changed: new Date().toISOString(),\n      last_updated: new Date().toISOString(),\n      context: {},\n    },\n    {\n      entity_id: \"sensor.outdoor_temp\",\n      state: \"18.4\",\n      attributes: { unit_of_measurement: \"°C\" },\n      last_changed: new Date().toISOString(),\n      last_updated: new Date().toISOString(),\n      context: {},\n    },\n  ],\n  customNodes: [\n    {\n      node_type: \"demo_node\",\n      data: {\n        name: \"Demo Node\",\n        description: \"Mock custom node\",\n        category: \"demo\",\n        script_path: \"/path/to/script\",\n        connectors: [],\n      },\n    },\n  ],\n  permissions: [\n    { id: 1, name: \"read\", description: \"Read access\" },\n    { id: 2, name: \"write\", description: \"Write access\" },\n  ],\n  roles: [\n    {\n      id: 1,\n      name: \"Admin\",\n      description: \"Administrator\",\n      permissions: [\n        { id: 1, name: \"read\", description: \"Read access\" },\n        { id: 2, name: \"write\", description: \"Write access\" },\n      ],\n    },\n  ],\n  users: [\n    {\n      id: 1,\n      username: \"demo\",\n      email: \"demo@example.com\",\n      created_at: new Date().toISOString(),\n      updated_at: new Date().toISOString(),\n      roles: [\n        {\n          id: 1,\n          name: \"Admin\",\n          description: \"Administrator\",\n          permissions: [\n            { id: 1, name: \"read\", description: \"Read access\" },\n            { id: 2, name: \"write\", description: \"Write access\" },\n          ],\n        },\n      ],\n    },\n  ],\n  deviceTokens: {},\n});\n"
  },
  {
    "path": "apps/client/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "apps/client/src/widgets/auth/AuthenticatedLayout.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { Outlet, useNavigate } from \"react-router\";\nimport { WebSocketProvider } from \"@/features/ws/WebSocketProvider\";\nimport { FlowUiEventBridge } from \"@/features/ws/FlowUiEventBridge\";\nimport { TopBarWrapper } from \"./TopBarWrapper\";\nimport { isDemoMode } from \"@/shared/demo\";\nimport { storage } from \"@/lib/storage\";\nimport { ChatPanelContainer, useChatStore, PANEL_WIDTH } from \"@/features/llm-chat\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\nimport { useConfigStore } from \"@/entities/configurations/store\";\nimport { isTauri, type SidecarStatus } from \"@/shared/desktop\";\n\nexport function AuthenticatedLayout() {\n  const [wsUrl, setWsUrl] = useState<string | null>(null);\n  const [reloadKey, setReloadKey] = useState(0);\n  const navigate = useNavigate();\n\n  useEffect(() => {\n    if (isDemoMode) {\n      setWsUrl(\"ws://demo.vessel.local/mock\");\n      return;\n    }\n\n    const token = storage.getToken();\n    const serverUrlString = storage.getServerUrl();\n\n    if (!token || !serverUrlString) {\n      navigate(\"/auth\", { replace: true });\n      return;\n    }\n\n    try {\n      const url = new URL(serverUrlString);\n      const host = url.host;\n      const wsProto = url.protocol === \"https:\" ? \"wss\" : \"ws\";\n\n      setWsUrl(`${wsProto}://${host}/signal?token=${token}`);\n    } catch {\n      console.error(\"Invalid server_url in storage:\", serverUrlString);\n      navigate(\"/auth\", { replace: true });\n    }\n  }, [navigate, reloadKey]);\n\n  useEffect(() => {\n    if (!isTauri()) return;\n    let unlisten: (() => void) | null = null;\n    let cancelled = false;\n\n    (async () => {\n      try {\n        const { listen } = await import(\"@tauri-apps/api/event\");\n        const dispose = await listen<SidecarStatus>(\"sidecar-restarted\", (event) => {\n          const next = event.payload?.base_url;\n          if (next) {\n            storage.setServerUrl(next);\n          }\n          setWsUrl(null);\n          setReloadKey((k) => k + 1);\n        });\n        if (cancelled) {\n          dispose();\n        } else {\n          unlisten = dispose;\n        }\n      } catch (err) {\n        console.warn(\"Failed to subscribe to sidecar-restarted\", err);\n      }\n    })();\n\n    return () => {\n      cancelled = true;\n      if (unlisten) unlisten();\n    };\n  }, []);\n\n  useEffect(() => {\n    if (!wsUrl) return;\n    void useConfigStore.getState().fetchConfigs();\n  }, [wsUrl]);\n\n  const isOpen = useChatStore((s) => s.isOpen);\n  const isMobile = useIsMobile();\n\n  if (!wsUrl) {\n    return <div>Loading...</div>;\n  }\n\n  // Disable content push on mobile\n  const chatPanelWidth = isOpen && !isMobile ? PANEL_WIDTH : 0;\n\n  return (\n    <WebSocketProvider url={wsUrl}>\n      <FlowUiEventBridge />\n      <div\n        style={\n          {\n            \"--chat-panel-width\": `${chatPanelWidth}px`,\n          } as React.CSSProperties\n        }\n        className=\"transition-[padding] duration-300 pr-[var(--chat-panel-width)]\"\n      >\n        <TopBarWrapper>\n          <Outlet />\n        </TopBarWrapper>\n      </div>\n      <ChatPanelContainer />\n    </WebSocketProvider>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/widgets/auth/TopBarWrapper.tsx",
    "content": "import { PageWrapper } from \"@/app/pageWrapper/page-wrapper\";\nimport { ErrorRender } from \"@/features/error\";\nimport { TopBar } from \"@/features/topbar\";\nimport { isElectron } from \"@/lib/electron\";\nimport { PropsWithChildren } from \"react\";\nimport { ErrorBoundary } from \"react-error-boundary\";\n\ninterface TopBarWrapType extends PropsWithChildren {\n  hide?: boolean;\n}\n\nexport function TopBarWrapper(props: TopBarWrapType) {\n  if (isElectron()) {\n    return (\n      <>\n        <PageWrapper>\n          <TopBar hide={props.hide} />\n          {props.children}\n        </PageWrapper>\n      </>\n    );\n  } else {\n    return (\n      <ErrorBoundary fallbackRender={ErrorRender} onReset={() => {}}>\n        <PageWrapper>{props.children}</PageWrapper>\n      </ErrorBoundary>\n    );\n  }\n}\n"
  },
  {
    "path": "apps/client/src/widgets/device-list/DeviceList.tsx",
    "content": "import { useEffect } from \"react\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { Circle, CircleCheck, Loader2, MoreHorizontal } from \"lucide-react\";\nimport { useDeviceStore } from \"@/entities/device/store\";\nimport { DeviceDeleteButton } from \"@/features/device/DeviceDeleteButton\";\nimport { DeviceUpdateButton } from \"@/features/device/DeviceUpdateButton\";\nimport { DeviceCreateButton } from \"@/features/device/DeviceCreateButton\";\nimport { DeviceKeyButton } from \"@/features/device/DeviceKeyButton\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Button } from \"@/components/ui/button\";\n\nexport function DeviceList() {\n  const { devices, isLoading, fetchDevices, selectDevice, selectedDevice } =\n    useDeviceStore();\n\n  useEffect(() => {\n    fetchDevices();\n  }, [fetchDevices]);\n\n  if (isLoading) {\n    return (\n      <Card className='flex justify-center items-center min-h-[200px]'>\n        <Loader2 className='h-8 w-8 animate-spin' />\n      </Card>\n    );\n  }\n\n  return (\n    <Card>\n      <CardHeader className='flex flex-row items-start justify-between'>\n        <div>\n          <CardTitle>Devices</CardTitle>\n          <CardDescription>\n            Select a device to view its entities.\n          </CardDescription>\n        </div>\n        <DeviceCreateButton />\n      </CardHeader>\n      <CardContent>\n        <Table>\n          <TableHeader>\n            <TableRow>\n              <TableHead></TableHead>\n\n              <TableHead>ID</TableHead>\n              <TableHead>Name</TableHead>\n              <TableHead className='hidden sm:table-cell'>\n                Manufacturer\n              </TableHead>\n              <TableHead className='sticky right-0 bg-background w-[40px] min-w-[40px] text-right'>\n                Actions\n              </TableHead>\n            </TableRow>\n          </TableHeader>\n          <TableBody>\n            {devices.map((device) => (\n              <TableRow\n                key={device.id}\n                onClick={() => selectDevice(device)}\n                className={`cursor-pointer ${\n                  selectedDevice?.id === device.id ? \"bg-muted/50\" : \"\"\n                }`}\n              >\n                <TableCell className='font-medium'>\n                  {selectedDevice?.id === device.id ? (\n                    <CircleCheck className='text-blue-500' />\n                  ) : (\n                    <Circle className='text-muted/70' />\n                  )}\n                </TableCell>\n                <TableCell className='font-medium'>\n                  {device.device_id}\n                </TableCell>\n                <TableCell>{device.name ?? \"N/A\"}</TableCell>\n                <TableCell className='hidden sm:table-cell'>\n                  {device.manufacturer ?? \"N/A\"}\n                </TableCell>\n                <TableCell\n                  className='sticky right-0 bg-background text-right space-x-1'\n                  onClick={(e) => e.stopPropagation()}\n                >\n                  <DropdownMenu>\n                    <DropdownMenuTrigger asChild>\n                      <Button variant='ghost' className='h-8 w-8 p-0'>\n                        <span className='sr-only'>Open menu</span>\n                        <MoreHorizontal className='h-4 w-4' />\n                      </Button>\n                    </DropdownMenuTrigger>\n                    <DropdownMenuContent align='end'>\n                      <DeviceUpdateButton device={device} />\n                      <DeviceKeyButton deviceId={device.id} />\n                      <DeviceDeleteButton deviceId={device.id} />\n                    </DropdownMenuContent>\n                  </DropdownMenu>\n                </TableCell>\n              </TableRow>\n            ))}\n          </TableBody>\n        </Table>\n      </CardContent>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/widgets/entity-list/EntityList.tsx",
    "content": "import { useEffect } from \"react\";\n\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { Loader2, MoreHorizontal } from \"lucide-react\";\nimport { useDeviceStore } from \"@/entities/device/store\";\nimport { useEntityStore } from \"@/entities/entity/store\";\nimport { EntityCreateButton } from \"@/features/entity/EntityCreateButton\";\nimport { EntityDeleteButton } from \"@/features/entity/EntityDeleteButton\";\nimport { EntityUpdateButton } from \"@/features/entity/EntityUpdateButton\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Button } from \"@/components/ui/button\";\n\nexport function EntityList() {\n  const { selectedDevice } = useDeviceStore();\n  const { entities, isLoading, fetchEntities, autoOpenEntityId } = useEntityStore();\n\n  useEffect(() => {\n    fetchEntities();\n  }, [fetchEntities]);\n\n  const filteredEntities = selectedDevice\n    ? entities.filter((e) => e.device_id === selectedDevice.id)\n    : [];\n\n  const autoOpenEntity = autoOpenEntityId\n    ? filteredEntities.find((e) => e.id === autoOpenEntityId)\n    : null;\n\n  return (\n    <Card>\n      <CardHeader className='flex flex-row items-start justify-between'>\n        <div>\n          <CardTitle>Entities</CardTitle>\n          <CardDescription>\n            {selectedDevice\n              ? `Entities for ${\n                  selectedDevice.name || selectedDevice.device_id\n                }`\n              : \"Select a device to see its entities\"}\n          </CardDescription>\n        </div>\n        <EntityCreateButton />\n      </CardHeader>\n      <CardContent className='min-h-[200px]'>\n        {isLoading && (\n          <div className='flex justify-center items-center h-full'>\n            <Loader2 className='h-8 w-8 animate-spin' />\n          </div>\n        )}\n\n        {!isLoading && !selectedDevice && (\n          <div className='flex items-center justify-center h-full text-center text-muted-foreground'>\n            <p>No device selected.</p>\n          </div>\n        )}\n\n        {!isLoading && selectedDevice && filteredEntities.length === 0 && (\n          <div className='flex items-center justify-center h-full text-center text-muted-foreground'>\n            <p>No entities found for this device.</p>\n          </div>\n        )}\n\n        {!isLoading && selectedDevice && filteredEntities.length > 0 && (\n          <Table>\n            <TableHeader>\n              <TableRow>\n                <TableHead>Entity ID</TableHead>\n                <TableHead>Name</TableHead>\n                <TableHead className='sticky right-0 bg-background w-[40px] min-w-[40px] text-right'>\n                  Actions\n                </TableHead>\n              </TableRow>\n            </TableHeader>\n            <TableBody>\n              {filteredEntities.map((entity) => (\n                <TableRow key={entity.id}>\n                  <TableCell className='font-medium'>\n                    {entity.entity_id}\n                  </TableCell>\n                  <TableCell>{entity.friendly_name ?? \"N/A\"}</TableCell>\n                  <TableCell className='sticky right-0 bg-background text-right'>\n                    <DropdownMenu>\n                      <DropdownMenuTrigger asChild>\n                        <Button variant='ghost' className='h-8 w-8 p-0'>\n                          <span className='sr-only'>Open menu</span>\n                          <MoreHorizontal className='h-4 w-4' />\n                        </Button>\n                      </DropdownMenuTrigger>\n                      <DropdownMenuContent align='end'>\n                        <EntityUpdateButton entity={entity} />\n                        <EntityDeleteButton entityId={entity.id} />\n                      </DropdownMenuContent>\n                    </DropdownMenu>\n                  </TableCell>\n                </TableRow>\n              ))}\n            </TableBody>\n          </Table>\n        )}\n      </CardContent>\n      {autoOpenEntity && (\n        <EntityUpdateButton entity={autoOpenEntity} defaultOpen={true} />\n      )}\n    </Card>\n  );\n}\n"
  },
  {
    "path": "apps/client/src/widgets/role-table/RoleList.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n  Table,\n  TableHeader,\n  TableRow,\n  TableHead,\n  TableBody,\n  TableCell,\n} from \"@/components/ui/table\";\nimport { useRoleStore } from \"@/entities/role/store\";\nimport { Role } from \"@/entities/role/types\";\nimport {\n  AddRoleDialog,\n  DeleteRoleDialog,\n  EditRoleDialog,\n} from \"@/features/role/RoleDialogs\";\nimport { Edit, MoreHorizontal, PlusCircle, Trash2 } from \"lucide-react\";\nimport { FC, useEffect, useState } from \"react\";\nimport { Badge } from \"@/components/ui/badge\";\n\nexport const RoleTable: FC = () => {\n  const { roles, isLoading, error, fetchRoles } = useRoleStore();\n  const [isAddDialogOpen, setAddDialogOpen] = useState(false);\n  const [editingRole, setEditingRole] = useState<Role | null>(null);\n  const [deletingRoleId, setDeletingRoleId] = useState<number | null>(null);\n\n  useEffect(() => {\n    fetchRoles();\n  }, [fetchRoles]);\n\n  return (\n    <>\n      <div className='flex items-center justify-between mb-6'>\n        <div>\n          <h1 className='text-2xl font-bold tracking-tight'>Role Management</h1>\n          <p className='text-muted-foreground'>\n            Manage user roles and their permissions.\n          </p>\n        </div>\n        <Button onClick={() => setAddDialogOpen(true)}>\n          <PlusCircle className='mr-2 h-4 w-4' /> Add Role\n        </Button>\n      </div>\n      {isLoading && <div className='text-center p-8'>Loading roles...</div>}\n      {!isLoading && error && (\n        <div className='text-center p-8 text-destructive'>{error}</div>\n      )}\n      {!isLoading && !error && (\n        <div className=' border'>\n          <Table>\n            <TableHeader>\n              <TableRow>\n                <TableHead>Name</TableHead>\n                <TableHead>Description</TableHead>\n                <TableHead>Permissions</TableHead>\n                <TableHead className='text-right'></TableHead>\n              </TableRow>\n            </TableHeader>\n            <TableBody>\n              {roles.length > 0 ? (\n                roles.map((role) => (\n                  <TableRow key={role.id}>\n                    <TableCell className='font-medium'>{role.name}</TableCell>\n                    <TableCell>{role.description}</TableCell>\n                    <TableCell>\n                      <div className='flex flex-wrap gap-1'>\n                        {(role.permissions ?? []).map((p) => (\n                          <Badge key={p.id} variant='secondary'>\n                            {p.name}\n                          </Badge>\n                        ))}\n                      </div>\n                    </TableCell>\n                    <TableCell className='text-right'>\n                      <DropdownMenu>\n                        <DropdownMenuTrigger asChild>\n                          <Button variant='ghost' className='h-8 w-8 p-0'>\n                            <MoreHorizontal className='h-4 w-4' />\n                          </Button>\n                        </DropdownMenuTrigger>\n                        <DropdownMenuContent>\n                          <DropdownMenuItem\n                            onSelect={() => setEditingRole(role)}\n                          >\n                            <Edit className='mr-2 h-4 w-4' />\n                            <span>Edit</span>\n                          </DropdownMenuItem>\n                          <DropdownMenuItem\n                            onSelect={() => setDeletingRoleId(role.id)}\n                            className='text-red-500 focus:text-red-500'\n                          >\n                            <Trash2 className='mr-2 h-4 w-4 text-red-500 focus:text-red-500' />\n                            <span>Delete</span>\n                          </DropdownMenuItem>\n                        </DropdownMenuContent>\n                      </DropdownMenu>\n                    </TableCell>\n                  </TableRow>\n                ))\n              ) : (\n                <TableRow>\n                  <TableCell colSpan={4} className='h-24 text-center'>\n                    No roles found.\n                  </TableCell>\n                </TableRow>\n              )}\n            </TableBody>\n          </Table>\n        </div>\n      )}\n\n      <AddRoleDialog isOpen={isAddDialogOpen} onOpenChange={setAddDialogOpen} />\n      <EditRoleDialog\n        role={editingRole}\n        isOpen={!!editingRole}\n        onOpenChange={() => setEditingRole(null)}\n      />\n      <DeleteRoleDialog\n        roleId={deletingRoleId}\n        isOpen={!!deletingRoleId}\n        onOpenChange={() => setDeletingRoleId(null)}\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/client/src/widgets/user-table/UserList.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n  Table,\n  TableHeader,\n  TableRow,\n  TableHead,\n  TableBody,\n  TableCell,\n} from \"@/components/ui/table\";\nimport { useUserStore } from \"@/entities/user/store\";\nimport { User } from \"@/entities/user/types\";\nimport { AddUserDialog } from \"@/features/user/userAdd\";\nimport { DeleteUserDialog } from \"@/features/user/userDelete\";\nimport { EditUserDialog } from \"@/features/user/userEdit\";\nimport { Edit, MoreHorizontal, PlusCircle, Trash2 } from \"lucide-react\";\nimport { FC, useEffect, useState } from \"react\";\n\nconst formatDate = (dateString: string | null | undefined): string => {\n  if (!dateString) return \"N/A\";\n  try {\n    const formattedString = dateString.includes(\" \")\n      ? dateString.replace(\" \", \"T\")\n      : dateString;\n    const date = new Date(formattedString);\n    if (isNaN(date.getTime())) {\n      return dateString;\n    }\n    return date.toLocaleString();\n  } catch (error) {\n    console.error(\"Invalid date format:\", error);\n    return dateString;\n  }\n};\n\nexport const UserTable: FC = () => {\n  const { users, isLoading, error, fetchUsers } = useUserStore(\n    (state) => state,\n  );\n  const [isAddDialogOpen, setAddDialogOpen] = useState(false);\n  const [editingUser, setEditingUser] = useState<User | null>(null);\n  const [deletingUserId, setDeletingUserId] = useState<number | null>(null);\n\n  useEffect(() => {\n    fetchUsers();\n  }, [fetchUsers]);\n\n  return (\n    <>\n      <div className='flex items-center justify-between mb-6'>\n        <div>\n          <h1 className='text-2xl font-bold tracking-tight'>User Management</h1>\n          <p className='text-muted-foreground'>\n            A list of all users in the system.\n          </p>\n        </div>\n        <Button onClick={() => setAddDialogOpen(true)}>\n          <PlusCircle className='mr-2 h-4 w-4' /> Add User\n        </Button>\n      </div>\n      {isLoading && <div className='text-center p-8'>Loading users...</div>}\n      {!isLoading && error && (\n        <div className='text-center p-8 text-destructive'>{error}</div>\n      )}\n      {!isLoading && !error && (\n        <div className=' border'>\n          <Table>\n            <TableHeader>\n              <TableRow>\n                <TableHead>Username</TableHead>\n                <TableHead>Email</TableHead>\n                <TableHead>Created At</TableHead>\n                <TableHead>Updated At</TableHead>\n                <TableHead className='text-right'></TableHead>\n              </TableRow>\n            </TableHeader>\n            <TableBody>\n              {users.length > 0 ? (\n                users.map((user) => (\n                  <TableRow key={user.id}>\n                    <TableCell className='font-medium'>\n                      {user.username}\n                    </TableCell>\n                    <TableCell>{user.email}</TableCell>\n                    <TableCell>{formatDate(user.created_at)}</TableCell>\n                    <TableCell>{formatDate(user.updated_at)}</TableCell>\n                    <TableCell className='text-right'>\n                      <DropdownMenu>\n                        <DropdownMenuTrigger asChild>\n                          <Button variant='ghost' className='h-8 w-8 p-0'>\n                            <span className='sr-only'>Open menu</span>\n                            <MoreHorizontal className='h-4 w-4' />\n                          </Button>\n                        </DropdownMenuTrigger>\n                        <DropdownMenuContent>\n                          <DropdownMenuItem\n                            onSelect={() => setEditingUser(user)}\n                          >\n                            <Edit className='mr-2 h-4 w-4' />\n                            <span>Edit</span>\n                          </DropdownMenuItem>\n                          <DropdownMenuItem\n                            onSelect={() => setDeletingUserId(user.id)}\n                            className='text-red-500 focus:text-red-500'\n                          >\n                            <Trash2 className='mr-2 h-4 w-4 text-red-500' />\n                            <span>Delete</span>\n                          </DropdownMenuItem>\n                        </DropdownMenuContent>\n                      </DropdownMenu>\n                    </TableCell>\n                  </TableRow>\n                ))\n              ) : (\n                <TableRow>\n                  <TableCell colSpan={5} className='h-24 text-center'>\n                    No users found.\n                  </TableCell>\n                </TableRow>\n              )}\n            </TableBody>\n          </Table>\n        </div>\n      )}\n\n      <AddUserDialog isOpen={isAddDialogOpen} onOpenChange={setAddDialogOpen} />\n      <EditUserDialog\n        user={editingUser}\n        isOpen={!!editingUser}\n        onOpenChange={() => setEditingUser(null)}\n      />\n      <DeleteUserDialog\n        userId={deletingUserId}\n        isOpen={!!deletingUserId}\n        onOpenChange={() => setDeletingUserId(null)}\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/client/tailwind.config.js",
    "content": "/** @type {import('tailwindcss').Config} */\n\nimport { createThemes } from \"@shadcn/tailwind-config\";\n\nmodule.exports = {\n  presets: [createThemes()],\n  content: [\"./index.html\", \"./src/**/*.{js,ts,jsx,tsx}\"],\n  darkMode: \"class\",\n  theme: {\n    extend: {},\n  },\n  plugins: [],\n};\n"
  },
  {
    "path": "apps/client/tsconfig.app.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"isolatedModules\": false,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noImplicitAny\": true,\n    \"types\": [\"@emotion/react/types/css-prop\", \"d3\"],\n    \"jsxImportSource\": \"@emotion/react\",\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"src/*\"]\n    }\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "apps/client/tsconfig.app.tsbuildinfo",
    "content": "{\"root\":[\"./src/app.tsx\",\"./src/main.tsx\",\"./src/vite-env.d.ts\",\"./src/app/pagewrapper/page-wrapper.tsx\",\"./src/app/providers/theme-provider.tsx\",\"./src/components/icon/logo.tsx\",\"./src/components/ui/alert-dialog.tsx\",\"./src/components/ui/alert.tsx\",\"./src/components/ui/avatar.tsx\",\"./src/components/ui/badge.tsx\",\"./src/components/ui/breadcrumb.tsx\",\"./src/components/ui/button.tsx\",\"./src/components/ui/card.tsx\",\"./src/components/ui/command.tsx\",\"./src/components/ui/context-menu.tsx\",\"./src/components/ui/dialog.tsx\",\"./src/components/ui/dropdown-menu.tsx\",\"./src/components/ui/input.tsx\",\"./src/components/ui/label.tsx\",\"./src/components/ui/navigation-menu.tsx\",\"./src/components/ui/popover.tsx\",\"./src/components/ui/resizable.tsx\",\"./src/components/ui/scroll-area.tsx\",\"./src/components/ui/select.tsx\",\"./src/components/ui/separator.tsx\",\"./src/components/ui/sheet.tsx\",\"./src/components/ui/sidebar.tsx\",\"./src/components/ui/skeleton.tsx\",\"./src/components/ui/sonner.tsx\",\"./src/components/ui/switch.tsx\",\"./src/components/ui/table.tsx\",\"./src/components/ui/tabs.tsx\",\"./src/components/ui/textarea.tsx\",\"./src/components/ui/tooltip.tsx\",\"./src/contexts/supabaseauthcontext.tsx\",\"./src/entities/configurations/api.ts\",\"./src/entities/configurations/codeservice.ts\",\"./src/entities/configurations/store.ts\",\"./src/entities/configurations/types.ts\",\"./src/entities/custom-nodes/api.ts\",\"./src/entities/custom-nodes/presets.ts\",\"./src/entities/custom-nodes/store.ts\",\"./src/entities/custom-nodes/types.ts\",\"./src/entities/device/api.ts\",\"./src/entities/device/store.ts\",\"./src/entities/device/types.ts\",\"./src/entities/device-token/api.ts\",\"./src/entities/device-token/store.ts\",\"./src/entities/device-token/types.ts\",\"./src/entities/dynamic-dashboard/api.ts\",\"./src/entities/dynamic-dashboard/interaction.ts\",\"./src/entities/dynamic-dashboard/layoutresolve.ts\",\"./src/entities/dynamic-dashboard/store.ts\",\"./src/entities/entity/api.ts\",\"./src/entities/entity/store.ts\",\"./src/entities/entity/types.ts\",\"./src/entities/file/api.ts\",\"./src/entities/file/store.ts\",\"./src/entities/file/types.ts\",\"./src/entities/flow/api.ts\",\"./src/entities/flow/store.ts\",\"./src/entities/flow/types.ts\",\"./src/entities/ha/api.ts\",\"./src/entities/ha/store.ts\",\"./src/entities/ha/types.ts\",\"./src/entities/integrations/api.ts\",\"./src/entities/integrations/store.ts\",\"./src/entities/integrations/types.ts\",\"./src/entities/log/api.ts\",\"./src/entities/log/types.ts\",\"./src/entities/map/api.ts\",\"./src/entities/map/store.ts\",\"./src/entities/map/types.ts\",\"./src/entities/permission/api.ts\",\"./src/entities/permission/store.ts\",\"./src/entities/permission/types.ts\",\"./src/entities/recording/api.ts\",\"./src/entities/recording/index.ts\",\"./src/entities/recording/store.ts\",\"./src/entities/recording/types.ts\",\"./src/entities/role/api.ts\",\"./src/entities/role/store.ts\",\"./src/entities/role/types.ts\",\"./src/entities/stat/api.ts\",\"./src/entities/stat/store.ts\",\"./src/entities/stat/types.ts\",\"./src/entities/tunnel/api.ts\",\"./src/entities/tunnel/store.ts\",\"./src/entities/tunnel/types.ts\",\"./src/entities/user/api.ts\",\"./src/entities/user/store.ts\",\"./src/entities/user/types.ts\",\"./src/features/account-switcher/index.tsx\",\"./src/features/auth/authinterceptor.tsx\",\"./src/features/auth/defaultadminpassworddialog.tsx\",\"./src/features/auth/api.ts\",\"./src/features/auth/hook.ts\",\"./src/features/auth/index.tsx\",\"./src/features/code/createitemdialog.tsx\",\"./src/features/code/fileeditor.tsx\",\"./src/features/code/filetree.tsx\",\"./src/features/configurations/configurationactionbutton.tsx\",\"./src/features/configurations/configurationcreate.tsx\",\"./src/features/configurations/configurationcreatebutton.tsx\",\"./src/features/darkmode/mode-toggle.tsx\",\"./src/features/dashboard-swipe/dashboardswipeheader.tsx\",\"./src/features/dashboard-swipe/dashboardswipelayout.tsx\",\"./src/features/device/devicecreatebutton.tsx\",\"./src/features/device/devicedeletebutton.tsx\",\"./src/features/device/devicekeybutton.tsx\",\"./src/features/device/deviceupdatebutton.tsx\",\"./src/features/device-token/devicetokenmanager.tsx\",\"./src/features/dynamic-dashboard/groupcanvas.tsx\",\"./src/features/dynamic-dashboard/events/dispatcher.test.ts\",\"./src/features/dynamic-dashboard/events/dispatcher.ts\",\"./src/features/dynamic-dashboard/panels/buttonpanel.tsx\",\"./src/features/dynamic-dashboard/panels/flowpanel.tsx\",\"./src/features/dynamic-dashboard/panels/mappanel.tsx\",\"./src/features/entity/allentities.tsx\",\"./src/features/entity/analyzemenuitem.tsx\",\"./src/features/entity/card.tsx\",\"./src/features/entity/entitycreatebutton.tsx\",\"./src/features/entity/entitydeletebutton.tsx\",\"./src/features/entity/entityupdatebutton.tsx\",\"./src/features/entity/selectplatforms.tsx\",\"./src/features/entity/selecttypes.tsx\",\"./src/features/entity/statehistorysheet.tsx\",\"./src/features/entity/useentitiesdata.ts\",\"./src/features/error/index.tsx\",\"./src/features/flow/addcustomnode.tsx\",\"./src/features/flow/flow.tsx\",\"./src/features/flow/graph.tsx\",\"./src/features/flow/options.tsx\",\"./src/features/flow/optionsvariation.tsx\",\"./src/features/flow/runflow.tsx\",\"./src/features/flow/selecteditemactions.tsx\",\"./src/features/flow/flownode.ts\",\"./src/features/flow/flowtypes.ts\",\"./src/features/flow/flowutils.ts\",\"./src/features/flow/flow-chat/buildsystemprompt.ts\",\"./src/features/flow/flow-chat/executetoolcalls.ts\",\"./src/features/flow/flow-chat/flowtools.ts\",\"./src/features/flow/flow-chat/index.ts\",\"./src/features/flow/nodes/buttonnode.tsx\",\"./src/features/flow/nodes/calcnode.tsx\",\"./src/features/flow/nodes/httpnode.tsx\",\"./src/features/flow/nodes/intervalnode.tsx\",\"./src/features/flow/nodes/logicnode.tsx\",\"./src/features/flow/nodes/loopnode.tsx\",\"./src/features/flow/nodes/mqttnode.tsx\",\"./src/features/flow/nodes/numbernode.tsx\",\"./src/features/flow/nodes/processingnode.tsx\",\"./src/features/flow/nodes/titlenode.tsx\",\"./src/features/flow/nodes/varnode.tsx\",\"./src/features/flow-log/flowlog.tsx\",\"./src/features/footer/index.tsx\",\"./src/features/gps/parsegps.ts\",\"./src/features/ha/haentitiestable.tsx\",\"./src/features/ha/hastatblock.tsx\",\"./src/features/ha/index.tsx\",\"./src/features/integration/ha.tsx\",\"./src/features/integration/integration.tsx\",\"./src/features/integration/ros.tsx\",\"./src/features/integration/sdr.tsx\",\"./src/features/integration/constants.ts\",\"./src/features/integration/types.ts\",\"./src/features/json/jsoneditor.tsx\",\"./src/features/llm-chat/chatinput.tsx\",\"./src/features/llm-chat/chatmessage.tsx\",\"./src/features/llm-chat/chatmessages.tsx\",\"./src/features/llm-chat/chatpanel.tsx\",\"./src/features/llm-chat/chatpanelcontainer.tsx\",\"./src/features/llm-chat/chatpanelmobile.tsx\",\"./src/features/llm-chat/index.tsx\",\"./src/features/llm-chat/store.ts\",\"./src/features/llm-chat/types.ts\",\"./src/features/llm-chat/usechatkeyboard.ts\",\"./src/features/log/index.tsx\",\"./src/features/map/currentlocationmarker.tsx\",\"./src/features/map/mapviewpersistence.tsx\",\"./src/features/map/index.tsx\",\"./src/features/map-draw/featuredetailspanel.tsx\",\"./src/features/map-draw/featuredrawingpreview.tsx\",\"./src/features/map-draw/featureeditor.tsx\",\"./src/features/map-draw/featurerenderer.tsx\",\"./src/features/map-draw/layerdialog.tsx\",\"./src/features/map-draw/layersidebar.tsx\",\"./src/features/map-draw/mapevents.tsx\",\"./src/features/map-draw/maptoolbar.tsx\",\"./src/features/map-entity/entitydetailspanel.tsx\",\"./src/features/map-entity/render.tsx\",\"./src/features/map-entity/store.ts\",\"./src/features/recording/recordingbutton.tsx\",\"./src/features/recording/recordingslist.tsx\",\"./src/features/recording/videoplaybackdialog.tsx\",\"./src/features/recording/index.ts\",\"./src/features/recording/components/audiowaveformplayer.tsx\",\"./src/features/recording/components/frametimeline.tsx\",\"./src/features/recording/components/playbackcontrols.tsx\",\"./src/features/recording/components/timeruler.tsx\",\"./src/features/recording/components/videocontrolbar.tsx\",\"./src/features/recording/components/waveformcanvas.tsx\",\"./src/features/recording/hooks/useaudiowaveform.ts\",\"./src/features/recording/hooks/usemediaplayback.ts\",\"./src/features/recording/hooks/usevideoframes.ts\",\"./src/features/role/roledialogs.tsx\",\"./src/features/role/roleform.tsx\",\"./src/features/ros2/ros2dashboard.tsx\",\"./src/features/rtc/audiolevelbar.tsx\",\"./src/features/rtc/streamreceiver.tsx\",\"./src/features/rtc/webrtcprovider.tsx\",\"./src/features/rtc/captureframe.ts\",\"./src/features/rtc/rtc.ts\",\"./src/features/rtc/turnservice.ts\",\"./src/features/sdr/sdraudioplayer.tsx\",\"./src/features/sdr/sdrdashboard.tsx\",\"./src/features/sdr/api.ts\",\"./src/features/search/search-form.tsx\",\"./src/features/server-resource/resourceusage.tsx\",\"./src/features/setup/index.tsx\",\"./src/features/sidebar/footer.tsx\",\"./src/features/sidebar/index.tsx\",\"./src/features/stat/index.tsx\",\"./src/features/topbar/index.tsx\",\"./src/features/user/userroleassigner.tsx\",\"./src/features/user/useradd.tsx\",\"./src/features/user/userdelete.tsx\",\"./src/features/user/useredit.tsx\",\"./src/features/user/userform.tsx\",\"./src/features/ws/flowuieventbridge.tsx\",\"./src/features/ws/isconnected.tsx\",\"./src/features/ws/websocketprovider.tsx\",\"./src/features/ws/flowuieventrouter.test.ts\",\"./src/features/ws/flowuieventrouter.ts\",\"./src/features/ws/ws.ts\",\"./src/features/ws/wsmock.ts\",\"./src/features/ws/flowuiadapters/toastflowuiadapter.ts\",\"./src/hooks/use-mobile.ts\",\"./src/hooks/usedesktopsidecar.ts\",\"./src/hooks/usepreventbacknavigation.ts\",\"./src/lib/electron.ts\",\"./src/lib/geometry-precision.ts\",\"./src/lib/geometry.ts\",\"./src/lib/jwt.ts\",\"./src/lib/storage.ts\",\"./src/lib/string.ts\",\"./src/lib/supabase.ts\",\"./src/lib/time.ts\",\"./src/lib/utils.ts\",\"./src/pages/auth/index.tsx\",\"./src/pages/code/index.tsx\",\"./src/pages/dashboard/dashboardmainpanel.tsx\",\"./src/pages/dashboard/index.tsx\",\"./src/pages/desktop-settings/index.tsx\",\"./src/pages/devices/index.tsx\",\"./src/pages/dynamic-dashboard/dynamicdashboardmainpanel.tsx\",\"./src/pages/dynamic-dashboard/newdynamicdashboardpanel.tsx\",\"./src/pages/dynamic-dashboard/index.tsx\",\"./src/pages/flow/index.tsx\",\"./src/pages/landing/index.tsx\",\"./src/pages/map/index.tsx\",\"./src/pages/notfound/index.tsx\",\"./src/pages/recordings/index.tsx\",\"./src/pages/settings/account.tsx\",\"./src/pages/settings/config.tsx\",\"./src/pages/settings/index.tsx\",\"./src/pages/settings/integration.tsx\",\"./src/pages/settings/log.tsx\",\"./src/pages/settings/networks.tsx\",\"./src/pages/settings/services.tsx\",\"./src/pages/settings/users.tsx\",\"./src/pages/setup/index.tsx\",\"./src/shared/demo.ts\",\"./src/shared/desktop.ts\",\"./src/shared/api/index.ts\",\"./src/shared/mock/mockadapter.ts\",\"./src/shared/mock/mockdata.ts\",\"./src/widgets/auth/authenticatedlayout.tsx\",\"./src/widgets/auth/topbarwrapper.tsx\",\"./src/widgets/device-list/devicelist.tsx\",\"./src/widgets/entity-list/entitylist.tsx\",\"./src/widgets/role-table/rolelist.tsx\",\"./src/widgets/user-table/userlist.tsx\"],\"errors\":true,\"version\":\"5.8.3\"}"
  },
  {
    "path": "apps/client/tsconfig.json",
    "content": "{\n  \"files\": [],\n  \"references\": [\n    { \"path\": \"./tsconfig.app.json\" },\n    { \"path\": \"./tsconfig.node.json\" }\n  ],\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"lib\": [\"ES2023\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"isolatedModules\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noImplicitAny\": true,\n\n    \"jsxImportSource\": \"@emotion/react\"\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "apps/client/tsconfig.node.tsbuildinfo",
    "content": "{\"root\":[\"./vite.config.ts\"],\"version\":\"5.8.3\"}"
  },
  {
    "path": "apps/client/vite.config.ts",
    "content": "/// <reference types=\"vitest/config\" />\nimport { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\nimport dts from \"vite-plugin-dts\";\nimport tailwindcss from \"@tailwindcss/vite\";\nimport path from \"path\";\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n  server: {\n    port: 5175,\n  },\n  plugins: [\n    dts({\n      insertTypesEntry: true,\n    }),\n    react(),\n    tailwindcss(),\n  ],\n  resolve: {\n    alias: {\n      \"@\": path.resolve(__dirname, \"./src\"),\n    },\n  },\n  test: {\n    environment: \"node\",\n    include: [\"src/**/*.test.ts\"],\n  },\n});\n"
  },
  {
    "path": "apps/desktop/.gitignore",
    "content": "/node_modules\n/src-tauri/target\n/src-tauri/gen\n/dist\n"
  },
  {
    "path": "apps/desktop/README.md",
    "content": "# Vessel Desktop (Tauri)\n\nThis package wraps the existing client and Rust server into a Tauri desktop build with the server running as a sidecar.\n\n## Development\n\n```bash\nnpm run desktop\n```\n\nThis builds the server in release mode, starts the Vite dev server for the client, and launches Tauri.\n\n## Build\n\n```bash\nnpm run desktop:build\n```\n\nThis compiles the client bundle, builds the server sidecar (`apps/server/target/release/server`), and packages the desktop app.\n\n## Notes\n\n- The sidecar server runs inside the app's writable data directory and will create `config.toml`, `database.db`, and `log/` there if they do not exist.\n- The client is pre-wired to point at the local sidecar (sets `server_url` cookie) when running inside Tauri.\n"
  },
  {
    "path": "apps/desktop/package.json",
    "content": "{\n  \"name\": \"desktop\",\n  \"private\": true,\n  \"version\": \"0.1.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"tauri dev\",\n    \"build\": \"tauri build\",\n    \"tauri\": \"tauri\"\n  },\n  \"devDependencies\": {\n    \"@tauri-apps/cli\": \"^2.0.0\",\n    \"typescript\": \"~5.8.3\"\n  }\n}\n"
  },
  {
    "path": "apps/desktop/src-tauri/Cargo.toml",
    "content": "[package]\nname = \"desktop\"\nversion = \"0.1.0\"\ndescription = \"Vessel desktop shell with Tauri\"\nauthors = [\"Vessel Contributors\"]\nedition = \"2021\"\n\n[dependencies]\nserde = { version = \"1.0\", features = [\"derive\"] }\nserde_json = \"1.0\"\ntauri = { version = \"2\", features = [\"tray-icon\", \"image-png\"] }\ntauri-plugin-deep-link = \"2\"\ntauri-plugin-prevent-default = \"4\"\ntauri-plugin-shell = \"2\"\ntoml = \"0.8\"\n\n[build-dependencies]\ntauri-build = { version = \"2\", features = [] }\n"
  },
  {
    "path": "apps/desktop/src-tauri/build.rs",
    "content": "use std::env;\nuse std::fs;\nuse std::path::PathBuf;\n\nfn main() {\n    let manifest_dir = PathBuf::from(env::var(\"CARGO_MANIFEST_DIR\").expect(\"manifest dir\"));\n    let target_triple = env::var(\"TARGET\").unwrap_or_default();\n    let target_dir = manifest_dir.join(\"../../../target/release\");\n    let base_bin = target_dir.join(\"server\");\n    let suffixed_bin = target_dir.join(format!(\"server-{}\", target_triple));\n\n    if base_bin.exists() && !suffixed_bin.exists() {\n        if let Err(err) = fs::copy(&base_bin, &suffixed_bin) {\n            eprintln!(\"Warning: failed to copy sidecar binary: {}\", err);\n        }\n    }\n\n    tauri_build::build()\n}\n"
  },
  {
    "path": "apps/desktop/src-tauri/capabilities/main.json",
    "content": "{\n  \"$schema\": \"../gen/schemas/desktop-schema.json\",\n  \"identifier\": \"main\",\n  \"description\": \"Main window permissions\",\n  \"windows\": [\"main\", \"settings\"],\n  \"permissions\": [\n    \"core:default\",\n    \"core:event:default\",\n    \"core:window:allow-close\",\n    \"core:window:allow-hide\",\n    \"core:window:allow-show\",\n    \"core:window:allow-set-focus\",\n    \"shell:allow-open\",\n    \"shell:default\",\n    \"deep-link:default\"\n  ]\n}\n"
  },
  {
    "path": "apps/desktop/src-tauri/entitlements.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>com.apple.security.cs.disable-library-validation</key>\n\t<true/>\n\t<key>com.apple.security.cs.allow-dyld-environment-variables</key>\n\t<true/>\n\t<key>com.apple.security.cs.allow-jit</key>\n\t<true/>\n\t<key>com.apple.security.cs.allow-unsigned-executable-memory</key>\n\t<true/>\n\t<key>com.apple.security.network.server</key>\n\t<true/>\n\t<key>com.apple.security.network.client</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "apps/desktop/src-tauri/src/main.rs",
    "content": "#![cfg_attr(not(debug_assertions), windows_subsystem = \"windows\")]\n\nuse std::{\n    fs,\n    fs::OpenOptions,\n    io::Write,\n    path::{Path, PathBuf},\n    sync::{Arc, Mutex},\n    time::{Duration, SystemTime},\n};\n\nuse serde::{Deserialize, Serialize};\nuse tauri::{\n    image::Image,\n    menu::{Menu, MenuItem, PredefinedMenuItem},\n    tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},\n    AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder, Wry,\n};\nuse tauri_plugin_prevent_default::{Builder as PreventBuilder, KeyboardShortcut};\nuse tauri_plugin_shell::process::CommandChild;\nuse tauri_plugin_shell::{process::CommandEvent, ShellExt};\n\nconst DEFAULT_LISTEN: &str = \"0.0.0.0:6174\";\nconst CONFIG_FILE: &str = \"config.toml\";\nconst SETTINGS_QUERY: &str = \"index.html?view=desktop_settings\";\n\n#[derive(Default)]\nstruct SidecarManager {\n    child: Mutex<Option<CommandChild>>,\n    workdir: Mutex<Option<PathBuf>>,\n    restart_lock: Mutex<()>,\n    terminated_flag: Arc<Mutex<bool>>,\n    last_stderr: Arc<Mutex<String>>,\n    status_item: Mutex<Option<MenuItem<Wry>>>,\n    toggle_item: Mutex<Option<MenuItem<Wry>>>,\n}\n\n#[derive(Serialize, Clone)]\nstruct SidecarStatus {\n    running: bool,\n    base_url: Option<String>,\n    listen_address: String,\n    working_dir: String,\n}\n\n#[derive(Serialize, Deserialize, Clone)]\nstruct ServerAddress {\n    host: String,\n    port: u16,\n}\n\nfn ensure_workdir(\n    app: &AppHandle,\n    state: &SidecarManager,\n) -> Result<PathBuf, Box<dyn std::error::Error>> {\n    let mut guard = state.workdir.lock().unwrap();\n    if let Some(existing) = guard.clone() {\n        return Ok(existing);\n    }\n\n    let resolver = app.path();\n    let base = resolver.app_data_dir().unwrap_or(std::env::current_dir()?);\n\n    fs::create_dir_all(&base)?;\n    fs::create_dir_all(base.join(\"log\"))?;\n\n    *guard = Some(base.clone());\n    Ok(base)\n}\n\nfn read_listen_address(workdir: &Path) -> Option<String> {\n    let config_path = workdir.join(CONFIG_FILE);\n    if !config_path.exists() {\n        return None;\n    }\n\n    let content = fs::read_to_string(config_path).ok()?;\n    let parsed: toml::Value = toml::from_str(&content).ok()?;\n    parsed\n        .get(\"listen_address\")\n        .and_then(|v| v.as_str())\n        .map(|s| s.to_string())\n}\n\nfn write_listen_address(workdir: &Path, listen: &str) -> Result<(), String> {\n    let config_path = workdir.join(CONFIG_FILE);\n    let mut doc: toml::Value = if config_path.exists() {\n        let content = fs::read_to_string(&config_path).map_err(|e| e.to_string())?;\n        toml::from_str(&content).map_err(|e| e.to_string())?\n    } else {\n        toml::Value::Table(toml::value::Table::new())\n    };\n\n    if let toml::Value::Table(ref mut table) = doc {\n        table.insert(\n            \"listen_address\".to_string(),\n            toml::Value::String(listen.to_string()),\n        );\n    } else {\n        return Err(\"Unexpected config.toml shape\".into());\n    }\n\n    let serialized = toml::to_string(&doc).map_err(|e| e.to_string())?;\n    let tmp = config_path.with_extension(\"toml.tmp\");\n    fs::write(&tmp, serialized).map_err(|e| e.to_string())?;\n    fs::rename(&tmp, &config_path).map_err(|e| e.to_string())?;\n    Ok(())\n}\n\nfn base_url_from_listen(listen: &str) -> String {\n    let mut value = listen\n        .trim()\n        .trim_start_matches(\"http://\")\n        .trim_start_matches(\"https://\")\n        .to_string();\n\n    if let Some(port) = value.strip_prefix(\"0.0.0.0:\") {\n        value = format!(\"127.0.0.1:{}\", port);\n    }\n\n    if value.starts_with(\"127.0.0.1:\") || value.starts_with(\"localhost:\") {\n        format!(\"http://{}\", value)\n    } else if value.contains(\"://\") {\n        value\n    } else {\n        format!(\"http://{}\", value)\n    }\n}\n\nfn parse_listen(listen: &str) -> ServerAddress {\n    let cleaned = listen\n        .trim()\n        .trim_start_matches(\"http://\")\n        .trim_start_matches(\"https://\")\n        .to_string();\n\n    let (host, port) = match cleaned.rsplit_once(':') {\n        Some((h, p)) => (h.to_string(), p.parse::<u16>().unwrap_or(6174)),\n        None => (cleaned, 6174),\n    };\n\n    ServerAddress { host, port }\n}\n\nfn build_status(state: &SidecarManager, workdir: &Path) -> SidecarStatus {\n    let listen = read_listen_address(workdir).unwrap_or_else(|| DEFAULT_LISTEN.to_string());\n    let base_url = Some(base_url_from_listen(&listen));\n\n    let child_guard = state.child.lock().unwrap();\n    let running = child_guard.is_some();\n\n    SidecarStatus {\n        running,\n        base_url,\n        listen_address: listen,\n        working_dir: workdir.display().to_string(),\n    }\n}\n\nfn write_log(path: &Path, msg: &str) {\n    if let Some(parent) = path.parent() {\n        let _ = fs::create_dir_all(parent);\n    }\n    if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {\n        let _ = writeln!(file, \"[{:?}] {}\", SystemTime::now(), msg);\n    }\n}\n\nfn bytes_to_line(bytes: Vec<u8>) -> String {\n    String::from_utf8_lossy(&bytes).trim_end().to_string()\n}\n\nfn start_server_sidecar(\n    app: &AppHandle,\n    state: &SidecarManager,\n    workdir: &Path,\n) -> Result<(), Box<dyn std::error::Error>> {\n    {\n        let guard = state.child.lock().unwrap();\n        if guard.is_some() {\n            return Ok(());\n        }\n    }\n\n    *state.terminated_flag.lock().unwrap() = false;\n    state.last_stderr.lock().unwrap().clear();\n\n    // Resolve bundled library paths relative to the app bundle\n    let resource_dir = app\n        .path()\n        .resource_dir()\n        .unwrap_or_else(|_| std::env::current_dir().unwrap());\n    let libs_dir = resource_dir.join(\"libs\");\n    let gst_plugins_dir = resource_dir.join(\"gstreamer-1.0\");\n    let gst_helpers_dir = gst_plugins_dir.join(\"helpers\");\n\n    // Build DYLD_FALLBACK_LIBRARY_PATH: bundled libs first, then system fallbacks\n    let mut dyld_paths: Vec<String> = vec![libs_dir.display().to_string()];\n\n    // In development mode, also add Homebrew paths as fallbacks\n    #[cfg(debug_assertions)]\n    {\n        dyld_paths.extend([\n            \"/opt/homebrew/opt/sqlite/lib\".to_string(),\n            \"/opt/homebrew/opt/libiconv/lib\".to_string(),\n            \"/opt/homebrew/lib\".to_string(),\n            \"/usr/local/lib\".to_string(),\n        ]);\n    }\n\n    dyld_paths.extend([\"/usr/local/lib\".to_string(), \"/usr/lib\".to_string()]);\n\n    let mut envs = std::collections::HashMap::new();\n    envs.insert(\n        \"DYLD_FALLBACK_LIBRARY_PATH\".to_string(),\n        dyld_paths.join(\":\"),\n    );\n\n    // GStreamer environment: tell GStreamer where to find plugins and helpers\n    envs.insert(\n        \"GST_PLUGIN_PATH\".to_string(),\n        gst_plugins_dir.display().to_string(),\n    );\n    envs.insert(\n        \"GST_PLUGIN_SYSTEM_PATH\".to_string(),\n        gst_plugins_dir.display().to_string(),\n    );\n    envs.insert(\n        \"GST_PLUGIN_SCANNER\".to_string(),\n        gst_helpers_dir\n            .join(\"gst-plugin-scanner\")\n            .display()\n            .to_string(),\n    );\n    // Write GStreamer registry cache to a writable location\n    envs.insert(\n        \"GST_REGISTRY\".to_string(),\n        workdir.join(\"gst-registry.bin\").display().to_string(),\n    );\n\n    let sidecar_log = workdir.join(\"log/sidecar.log\");\n    write_log(\n        &sidecar_log,\n        &format!(\"Spawning server sidecar in {}\", workdir.display()),\n    );\n\n    let command = app\n        .shell()\n        .sidecar(\"server\")?\n        .current_dir(workdir.to_path_buf())\n        .envs(envs);\n\n    let (mut rx, child) = command.spawn()?;\n    write_log(\n        &sidecar_log,\n        &format!(\"Server sidecar started with pid {:?}\", child.pid()),\n    );\n\n    let sidecar_log2 = sidecar_log.clone();\n    let terminated_flag = state.terminated_flag.clone();\n    let stderr_buf = state.last_stderr.clone();\n    tauri::async_runtime::spawn(async move {\n        while let Some(event) = rx.recv().await {\n            match event {\n                CommandEvent::Terminated(payload) => {\n                    write_log(&sidecar_log2, &format!(\"terminated: {:?}\", payload));\n                    *terminated_flag.lock().unwrap() = true;\n                }\n                CommandEvent::Error(err) => {\n                    write_log(&sidecar_log2, &format!(\"error: {}\", err));\n                }\n                CommandEvent::Stdout(line) => {\n                    write_log(&sidecar_log2, &format!(\"stdout: {}\", bytes_to_line(line)));\n                }\n                CommandEvent::Stderr(line) => {\n                    let text = bytes_to_line(line);\n                    write_log(&sidecar_log2, &format!(\"stderr: {}\", text));\n                    let mut buf = stderr_buf.lock().unwrap();\n                    buf.push_str(&text);\n                    buf.push('\\n');\n                }\n                _ => {}\n            }\n        }\n    });\n\n    *state.child.lock().unwrap() = Some(child);\n    Ok(())\n}\n\nfn stop_sidecar_internal(state: &SidecarManager) -> Result<(), String> {\n    if let Some(child) = state.child.lock().unwrap().take() {\n        child.kill().map_err(|e| e.to_string())?;\n    }\n    Ok(())\n}\n\nfn update_tray_status(app: &AppHandle, status: &SidecarStatus) {\n    let state = app.state::<SidecarManager>();\n    let item = state.status_item.lock().unwrap().clone();\n    if let Some(item) = item {\n        let text = if status.running {\n            format!(\"Server: Running ({})\", status.listen_address)\n        } else {\n            format!(\"Server: Stopped ({})\", status.listen_address)\n        };\n        let _ = item.set_text(text);\n    }\n}\n\nfn update_tray_toggle(app: &AppHandle) {\n    let state = app.state::<SidecarManager>();\n    let item = state.toggle_item.lock().unwrap().clone();\n    if let Some(item) = item {\n        if let Some(window) = app.get_webview_window(\"main\") {\n            let visible = window.is_visible().unwrap_or(true);\n            let _ = item.set_text(if visible { \"Hide Vessel\" } else { \"Show Vessel\" });\n        }\n    }\n}\n\n#[tauri::command]\nfn get_sidecar_status(\n    app: AppHandle,\n    state: State<'_, SidecarManager>,\n) -> Result<SidecarStatus, String> {\n    let workdir = ensure_workdir(&app, &state).map_err(|e| e.to_string())?;\n    let status = build_status(&state, &workdir);\n    update_tray_status(&app, &status);\n    Ok(status)\n}\n\n#[tauri::command]\nfn start_sidecar(\n    app: AppHandle,\n    state: State<'_, SidecarManager>,\n) -> Result<SidecarStatus, String> {\n    let workdir = ensure_workdir(&app, &state).map_err(|e| e.to_string())?;\n    start_server_sidecar(&app, &state, &workdir).map_err(|e| e.to_string())?;\n    let status = build_status(&state, &workdir);\n    update_tray_status(&app, &status);\n    Ok(status)\n}\n\n#[tauri::command]\nfn stop_sidecar(app: AppHandle, state: State<'_, SidecarManager>) -> Result<(), String> {\n    stop_sidecar_internal(&state)?;\n    let workdir = ensure_workdir(&app, &state).map_err(|e| e.to_string())?;\n    let status = build_status(&state, &workdir);\n    update_tray_status(&app, &status);\n    Ok(())\n}\n\n#[tauri::command]\nfn get_server_address(\n    app: AppHandle,\n    state: State<'_, SidecarManager>,\n) -> Result<ServerAddress, String> {\n    let workdir = ensure_workdir(&app, &state).map_err(|e| e.to_string())?;\n    let listen = read_listen_address(&workdir).unwrap_or_else(|| DEFAULT_LISTEN.to_string());\n    Ok(parse_listen(&listen))\n}\n\n#[tauri::command]\nfn update_server_address(\n    app: AppHandle,\n    state: State<'_, SidecarManager>,\n    host: String,\n    port: u16,\n) -> Result<SidecarStatus, String> {\n    let host = host.trim().to_string();\n    if host.is_empty() {\n        return Err(\"Host must not be empty\".into());\n    }\n    if port == 0 {\n        return Err(\"Port must be between 1 and 65535\".into());\n    }\n\n    let _guard = state\n        .restart_lock\n        .lock()\n        .map_err(|_| \"restart lock poisoned\".to_string())?;\n\n    let workdir = ensure_workdir(&app, &state).map_err(|e| e.to_string())?;\n    let previous = read_listen_address(&workdir).unwrap_or_else(|| DEFAULT_LISTEN.to_string());\n    let new_listen = format!(\"{}:{}\", host, port);\n\n    if new_listen == previous {\n        let status = build_status(&state, &workdir);\n        update_tray_status(&app, &status);\n        return Ok(status);\n    }\n\n    write_listen_address(&workdir, &new_listen)?;\n    stop_sidecar_internal(&state)?;\n    // Give the OS a moment to release the previous bind\n    std::thread::sleep(Duration::from_millis(300));\n\n    if let Err(err) = start_server_sidecar(&app, &state, &workdir) {\n        let _ = write_listen_address(&workdir, &previous);\n        let _ = start_server_sidecar(&app, &state, &workdir);\n        let status = build_status(&state, &workdir);\n        update_tray_status(&app, &status);\n        return Err(format!(\"Failed to restart sidecar: {}\", err));\n    }\n\n    // Wait briefly to detect bind failures\n    std::thread::sleep(Duration::from_millis(900));\n\n    let terminated = *state.terminated_flag.lock().unwrap();\n    let stderr_text = state.last_stderr.lock().unwrap().clone();\n    let lower = stderr_text.to_ascii_lowercase();\n    let bind_failed = lower.contains(\"address already in use\")\n        || lower.contains(\"permission denied\")\n        || lower.contains(\"failed to bind\")\n        || lower.contains(\"cannot assign requested address\");\n\n    if terminated || bind_failed {\n        let _ = stop_sidecar_internal(&state);\n        let _ = write_listen_address(&workdir, &previous);\n        let _ = start_server_sidecar(&app, &state, &workdir);\n        let status = build_status(&state, &workdir);\n        update_tray_status(&app, &status);\n        let detail = if bind_failed {\n            stderr_text\n                .lines()\n                .last()\n                .unwrap_or(\"bind failed\")\n                .to_string()\n        } else {\n            \"sidecar terminated\".to_string()\n        };\n        return Err(format!(\"Failed to bind {}: {}\", new_listen, detail));\n    }\n\n    let status = build_status(&state, &workdir);\n    update_tray_status(&app, &status);\n    let _ = app.emit(\"sidecar-restarted\", &status);\n    Ok(status)\n}\n\n#[tauri::command]\nfn open_settings_window(app: AppHandle) -> Result<(), String> {\n    if let Some(existing) = app.get_webview_window(\"settings\") {\n        let _ = existing.show();\n        let _ = existing.set_focus();\n        return Ok(());\n    }\n\n    WebviewWindowBuilder::new(\n        &app,\n        \"settings\",\n        WebviewUrl::App(SETTINGS_QUERY.into()),\n    )\n    .title(\"Vessel Server Settings\")\n    .inner_size(440.0, 320.0)\n    .min_inner_size(440.0, 320.0)\n    .resizable(false)\n    .minimizable(false)\n    .maximizable(false)\n    .build()\n    .map_err(|e| e.to_string())?;\n    Ok(())\n}\n\nfn build_tray(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {\n    let toggle = MenuItem::with_id(app, \"toggle\", \"Hide Vessel\", true, None::<&str>)?;\n    let status = MenuItem::with_id(app, \"status\", \"Server: \\u{2026}\", false, None::<&str>)?;\n    let settings = MenuItem::with_id(\n        app,\n        \"settings\",\n        \"Server Settings\\u{2026}\",\n        true,\n        None::<&str>,\n    )?;\n    let quit = MenuItem::with_id(app, \"quit\", \"Quit Vessel\", true, None::<&str>)?;\n    let sep1 = PredefinedMenuItem::separator(app)?;\n    let sep2 = PredefinedMenuItem::separator(app)?;\n\n    let menu = Menu::with_items(\n        app,\n        &[&toggle, &sep1, &status, &settings, &sep2, &quit],\n    )?;\n\n    {\n        let mgr = app.state::<SidecarManager>();\n        *mgr.status_item.lock().unwrap() = Some(status.clone());\n        *mgr.toggle_item.lock().unwrap() = Some(toggle.clone());\n    }\n\n    let icon_path = app.path().resource_dir().ok().and_then(|d| {\n        let candidate = d.join(\"icons/tray-template.png\");\n        if candidate.exists() {\n            Some(candidate)\n        } else {\n            None\n        }\n    });\n\n    let icon = if let Some(path) = icon_path {\n        Image::from_path(path)?\n    } else {\n        app.default_window_icon()\n            .cloned()\n            .ok_or(\"missing default window icon\")?\n    };\n\n    let _tray = TrayIconBuilder::with_id(\"main\")\n        .icon(icon)\n        .icon_as_template(true)\n        .menu(&menu)\n        .show_menu_on_left_click(true)\n        .on_menu_event(move |app, event| match event.id.as_ref() {\n            \"toggle\" => {\n                if let Some(window) = app.get_webview_window(\"main\") {\n                    let visible = window.is_visible().unwrap_or(false);\n                    if visible {\n                        let _ = window.hide();\n                    } else {\n                        let _ = window.show();\n                        let _ = window.set_focus();\n                    }\n                    update_tray_toggle(app);\n                }\n            }\n            \"settings\" => {\n                let _ = open_settings_window(app.clone());\n            }\n            \"quit\" => {\n                if let Some(child) = app\n                    .state::<SidecarManager>()\n                    .child\n                    .lock()\n                    .unwrap()\n                    .take()\n                {\n                    let _ = child.kill();\n                }\n                app.exit(0);\n            }\n            _ => {}\n        })\n        .on_tray_icon_event(|tray, event| {\n            if let TrayIconEvent::Click {\n                button: MouseButton::Left,\n                button_state: MouseButtonState::Up,\n                ..\n            } = event\n            {\n                update_tray_toggle(tray.app_handle());\n            }\n        })\n        .build(app)?;\n\n    Ok(())\n}\n\nfn main() {\n    // JS snippet that blocks Backspace navigation while allowing edits in inputs/contentEditable.\n    // Runs for every webview we create.\n    const PREVENT_NAV_JS: &str = r#\"\n      const shouldAllowBackspace = (event) => {\n        const path = event.composedPath ? event.composedPath() : [event.target];\n        return path.some((el) => {\n          if (!el || !el.tagName) return false;\n          const tag = el.tagName.toUpperCase();\n          const dataset = el.dataset || {};\n          if (dataset.allowBackspace === 'true') return true;\n          return el.isContentEditable || tag === 'INPUT' || tag === 'TEXTAREA';\n        });\n      };\n\n      const preventBackNavigation = (e) => {\n        if (e.key !== 'Backspace') return;\n        if (shouldAllowBackspace(e)) return;\n        e.preventDefault();\n        e.stopPropagation();\n      };\n\n      window.addEventListener('keydown', preventBackNavigation, { capture: true });\n    \"#;\n\n    tauri::Builder::default()\n        .manage(SidecarManager::default())\n        .plugin(tauri_plugin_deep_link::init())\n        .plugin(tauri_plugin_shell::init())\n        .plugin(\n            PreventBuilder::new()\n                // Block browser back/forward shortcuts\n                .shortcut(KeyboardShortcut::new(\"Alt+Left\"))\n                .shortcut(KeyboardShortcut::new(\"Alt+Right\"))\n                .build(),\n        )\n        .on_page_load(|window, _| {\n            let _ = window.eval(PREVENT_NAV_JS);\n        })\n        .on_window_event(|window, event| {\n            // Hide the main window instead of closing so the tray remains useful.\n            if window.label() == \"main\" {\n                if let tauri::WindowEvent::CloseRequested { api, .. } = event {\n                    api.prevent_close();\n                    let _ = window.hide();\n                    update_tray_toggle(window.app_handle());\n                }\n            }\n        })\n        .setup(|app| {\n            for window in app.webview_windows().values() {\n                let _ = window.eval(PREVENT_NAV_JS);\n            }\n\n            #[cfg(any(target_os = \"macos\", target_os = \"linux\", target_os = \"windows\"))]\n            {\n                use tauri_plugin_deep_link::DeepLinkExt;\n                let app_handle = app.handle().clone();\n                app.deep_link().on_open_url(move |event| {\n                    let urls: Vec<String> = event.urls().iter().map(|u| u.to_string()).collect();\n                    let _ = app_handle.emit(\"deep-link-opened\", urls);\n                });\n            }\n\n            let handle = app.handle().clone();\n            let state = app.state::<SidecarManager>();\n            let workdir = ensure_workdir(&handle, &state)?;\n            start_server_sidecar(&handle, &state, &workdir)?;\n\n            build_tray(&handle)?;\n\n            let status = build_status(&state, &workdir);\n            update_tray_status(&handle, &status);\n            update_tray_toggle(&handle);\n\n            Ok(())\n        })\n        .invoke_handler(tauri::generate_handler![\n            get_sidecar_status,\n            start_sidecar,\n            stop_sidecar,\n            get_server_address,\n            update_server_address,\n            open_settings_window\n        ])\n        .build(tauri::generate_context!())\n        .expect(\"error while building tauri application\")\n        .run(|app, event| {\n            if let tauri::RunEvent::Exit = event {\n                if let Some(child) = app.state::<SidecarManager>().child.lock().unwrap().take() {\n                    let _ = child.kill();\n                }\n            }\n        });\n}\n"
  },
  {
    "path": "apps/desktop/src-tauri/tauri.conf.json",
    "content": "{\n  \"$schema\": \"https://schema.tauri.app/config/2\",\n  \"productName\": \"Vessel\",\n  \"version\": \"0.1.0\",\n  \"identifier\": \"com.vessel.desktop\",\n  \"build\": {\n    \"beforeDevCommand\": \"cd ../.. && mkdir -p target/bundle-staging/libs target/bundle-staging/gstreamer-1.0 && npm run build --workspace=@vessel/capsule-client && npm run build --workspace=client && cargo build --release -p server && HOST=$(rustc -vV | sed -n 's/^host: //p') && BIN=target/release/server && cp \\\"$BIN\\\" \\\"${BIN}-${HOST}\\\" && npm run dev:vite --workspace=client\",\n    \"beforeBuildCommand\": \"cd ../.. && npm run build --workspace=@vessel/capsule-client && npm run build --workspace=client && cargo build --release -p server && bash scripts/bundle-macos-deps.sh && HOST=$(rustc -vV | sed -n 's/^host: //p') && BIN=target/release/server && cp \\\"$BIN\\\" \\\"${BIN}-${HOST}\\\"\",\n    \"devUrl\": \"http://localhost:5175\",\n    \"frontendDist\": \"../../client/dist\"\n  },\n  \"app\": {\n    \"security\": {\n      \"capabilities\": [\"main\"]\n    },\n    \"withGlobalTauri\": true,\n    \"windows\": [\n      {\n        \"label\": \"main\",\n        \"title\": \"Vessel\",\n        \"width\": 1280,\n        \"height\": 800,\n        \"resizable\": true,\n        \"fullscreen\": false\n      }\n    ],\n    \"security\": {\n      \"csp\": null\n    }\n  },\n  \"bundle\": {\n    \"active\": true,\n    \"targets\": [\"app\", \"dmg\"],\n    \"icon\": [\"icons/icon.icns\", \"icons/icon.png\"],\n    \"externalBin\": [\"../../../target/release/server\"],\n    \"resources\": {\n      \"../../../target/bundle-staging/libs\": \"libs\",\n      \"../../../target/bundle-staging/gstreamer-1.0\": \"gstreamer-1.0\",\n      \"icons/tray-template.png\": \"icons/tray-template.png\"\n    },\n    \"macOS\": {\n      \"minimumSystemVersion\": \"11.0\",\n      \"entitlements\": \"entitlements.plist\",\n      \"hardenedRuntime\": true,\n      \"frameworks\": []\n    }\n  },\n  \"plugins\": {\n    \"deep-link\": {\n      \"desktop\": {\n        \"schemes\": [\"vessel\"]\n      }\n    },\n    \"prevent-default\": {\n      \"back\": true,\n      \"backspace\": true,\n      \"alt-left\": true,\n      \"alt-right\": true\n    }\n  }\n}\n"
  },
  {
    "path": "apps/landing/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "apps/landing/README.md",
    "content": "# React + TypeScript + Vite\n\nThis template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.\n\nCurrently, two official plugins are available:\n\n- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh\n- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh\n\n## Expanding the ESLint configuration\n\nIf you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:\n\n```js\nexport default tseslint.config([\n  globalIgnores(['dist']),\n  {\n    files: ['**/*.{ts,tsx}'],\n    extends: [\n      // Other configs...\n\n      // Remove tseslint.configs.recommended and replace with this\n      ...tseslint.configs.recommendedTypeChecked,\n      // Alternatively, use this for stricter rules\n      ...tseslint.configs.strictTypeChecked,\n      // Optionally, add this for stylistic rules\n      ...tseslint.configs.stylisticTypeChecked,\n\n      // Other configs...\n    ],\n    languageOptions: {\n      parserOptions: {\n        project: ['./tsconfig.node.json', './tsconfig.app.json'],\n        tsconfigRootDir: import.meta.dirname,\n      },\n      // other options...\n    },\n  },\n])\n```\n\nYou can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:\n\n```js\n// eslint.config.js\nimport reactX from 'eslint-plugin-react-x'\nimport reactDom from 'eslint-plugin-react-dom'\n\nexport default tseslint.config([\n  globalIgnores(['dist']),\n  {\n    files: ['**/*.{ts,tsx}'],\n    extends: [\n      // Other configs...\n      // Enable lint rules for React\n      reactX.configs['recommended-typescript'],\n      // Enable lint rules for React DOM\n      reactDom.configs.recommended,\n    ],\n    languageOptions: {\n      parserOptions: {\n        project: ['./tsconfig.node.json', './tsconfig.app.json'],\n        tsconfigRootDir: import.meta.dirname,\n      },\n      // other options...\n    },\n  },\n])\n```\n"
  },
  {
    "path": "apps/landing/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"new-york\",\n  \"rsc\": false,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"\",\n    \"css\": \"src/index.css\",\n    \"baseColor\": \"neutral\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/lib\",\n    \"hooks\": \"@/hooks\"\n  },\n  \"iconLibrary\": \"lucide\"\n}"
  },
  {
    "path": "apps/landing/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Vessel: The Open-Source C2 Platform</title>\n    <meta name=\"title\" content=\"Vessel: The Open-Source C2 Platform\">\n    <meta name=\"description\" content=\"Connect, orchestrate, and automate your physical devices. Vessel is an open-source platform for building proactive security and automation flows with a visual editor to protect your home and assets.\">\n    <meta name=\"keywords\" content=\"open source, C2 software, command and control, physical security, home automation, IoT platform, sensor orchestration, device management, visual programming, flow-based, Node-RED alternative, Anduril alternative, DIY security, asset protection\">\n    <meta name=\"author\" content=\"cartesiancs\">\n    <meta name=\"robots\" content=\"index, follow\">\n\n    <meta property=\"og:type\" content=\"website\">\n    <meta property=\"og:url\" content=\"https://vessel.cartesiancs.com/\">\n    <meta property=\"og:title\" content=\"Vessel: The Open-Source C2 Platform for Physical Security & Automation\">\n    <meta property=\"og:description\" content=\"Connect, orchestrate, and automate your physical devices. Vessel is an open-source platform for building proactive security and automation flows with a visual editor to protect your home and assets.\">\n    <meta property=\"og:image\" content=\"https://vessel.cartesiancs.com/vessel-social-card.png\"> \n    <meta property=\"twitter:card\" content=\"summary_large_image\">\n    <meta property=\"twitter:url\" content=\"https://vessel.cartesiancs.com/\">\n    <meta property=\"twitter:title\" content=\"Vessel: The Open-Source C2 Platform for Physical Security & Automation\">\n    <meta property=\"twitter:description\" content=\"Connect, orchestrate, and automate your physical devices. Vessel is an open-source platform for building proactive security and automation flows with a visual editor to protect your home and assets.\">\n    <meta property=\"twitter:image\" content=\"https://vessel.cartesiancs.com/vessel-social-card.png\">\n    <script type=\"text/javascript\">\n      (function(e,c){if(!c.__SV){var l,h;window.mixpanel=c;c._i=[];c.init=function(q,r,f){function t(d,a){var g=a.split(\".\");2==g.length&&(d=d[g[0]],a=g[1]);d[a]=function(){d.push([a].concat(Array.prototype.slice.call(arguments,0)))}}var b=c;\"undefined\"!==typeof f?b=c[f]=[]:f=\"mixpanel\";b.people=b.people||[];b.toString=function(d){var a=\"mixpanel\";\"mixpanel\"!==f&&(a+=\".\"+f);d||(a+=\" (stub)\");return a};b.people.toString=function(){return b.toString(1)+\".people (stub)\"};l=\"disable time_event track track_pageview track_links track_forms track_with_groups add_group set_group remove_group register register_once alias unregister identify name_tag set_config reset opt_in_tracking opt_out_tracking has_opted_in_tracking has_opted_out_tracking clear_opt_in_out_tracking start_batch_senders start_session_recording stop_session_recording people.set people.set_once people.unset people.increment people.append people.union people.track_charge people.clear_charges people.delete_user people.remove\".split(\" \");\n      for(h=0;h<l.length;h++)t(b,l[h]);var n=\"set set_once union unset remove delete\".split(\" \");b.get_group=function(){function d(p){a[p]=function(){b.push([g,[p].concat(Array.prototype.slice.call(arguments,0))])}}for(var a={},g=[\"get_group\"].concat(Array.prototype.slice.call(arguments,0)),m=0;m<n.length;m++)d(n[m]);return a};c._i.push([q,r,f])};c.__SV=1.2;var k=e.createElement(\"script\");k.type=\"text/javascript\";k.async=!0;k.src=\"undefined\"!==typeof MIXPANEL_CUSTOM_LIB_URL?MIXPANEL_CUSTOM_LIB_URL:\"file:\"===\n      e.location.protocol&&\"//cdn.mxpnl.com/libs/mixpanel-2-latest.min.js\".match(/^\\/\\//)?\"https://cdn.mxpnl.com/libs/mixpanel-2-latest.min.js\":\"//cdn.mxpnl.com/libs/mixpanel-2-latest.min.js\";e=e.getElementsByTagName(\"script\")[0];e.parentNode.insertBefore(k,e)}})(document,window.mixpanel||[])\n      var isLocal = location.hostname === \"localhost\" || location.hostname === \"127.0.0.1\";\n      if (!isLocal) {\n        mixpanel.init('368d5a13ee869cacc30b696066ffee40', {\n          autocapture: false,\n          record_sessions_percent: 100,\n        })\n      }\n\n    </script>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "apps/landing/package.json",
    "content": "{\n  \"name\": \"landing\",\n  \"private\": true,\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite --host\",\n    \"build\": \"tsc -b && vite build\",\n    \"lint\": \"eslint .\",\n    \"preview\": \"vite preview\"\n  },\n  \"dependencies\": {\n    \"@radix-ui/react-alert-dialog\": \"^1.1.14\",\n    \"@radix-ui/react-navigation-menu\": \"^1.2.14\",\n    \"@radix-ui/react-slot\": \"^1.2.3\",\n    \"@react-three/drei\": \"^10.7.7\",\n    \"@react-three/fiber\": \"^9.5.0\",\n    \"@react-three/postprocessing\": \"^3.0.4\",\n    \"@supabase/supabase-js\": \"^2.90.1\",\n    \"@theatre/core\": \"^0.7.2\",\n    \"lucide-react\": \"^0.564.0\",\n    \"maath\": \"^0.10.8\",\n    \"next-themes\": \"^0.4.6\",\n    \"recharts\": \"^2.15.4\",\n    \"sonner\": \"^2.0.7\",\n    \"three\": \"^0.183.2\"\n  },\n  \"devDependencies\": {\n    \"@theatre/studio\": \"^0.7.2\",\n    \"@types/three\": \"^0.183.1\"\n  }\n}\n"
  },
  {
    "path": "apps/landing/src/App.tsx",
    "content": "import { createBrowserRouter, RouterProvider } from \"react-router\";\nimport LandingPage from \"./pages/Main\";\nimport RoadmapPage from \"./pages/Roadmap\";\nimport UsecasePage from \"./pages/UseCase\";\nimport { PrivacyPage } from \"./pages/Privacy\";\nimport PrivacyPolicyPage from \"./pages/PrivacyPolicy\";\nimport PricingPage from \"./pages/Pricing\";\nimport LoginPage from \"./pages/Login\";\nimport DashboardPage from \"./pages/Dashboard\";\nimport CheckoutSuccessPage from \"./pages/CheckoutSuccess\";\nimport CapsulePage from \"./pages/Capsule\";\nimport ContactPage from \"./pages/Contact\";\nimport TermsPage from \"./pages/Terms\";\nimport DisclaimerPage from \"./pages/Disclaimer\";\nimport { Toaster } from \"sonner\";\n\nconst router = createBrowserRouter([\n  {\n    path: \"/\",\n    element: <LandingPage />,\n  },\n  {\n    path: \"/roadmap\",\n    element: <RoadmapPage />,\n  },\n  {\n    path: \"/usecase\",\n    element: <UsecasePage />,\n  },\n  {\n    path: \"/privacy\",\n    element: <PrivacyPage />,\n  },\n  {\n    path: \"/privacy-policy\",\n    element: <PrivacyPolicyPage />,\n  },\n  {\n    path: \"/pricing\",\n    element: <PricingPage />,\n  },\n  {\n    path: \"/login\",\n    element: <LoginPage />,\n  },\n  {\n    path: \"/dashboard\",\n    element: <DashboardPage />,\n  },\n  {\n    path: \"/checkout/success\",\n    element: <CheckoutSuccessPage />,\n  },\n  {\n    path: \"/capsule\",\n    element: <CapsulePage />,\n  },\n  {\n    path: \"/contact\",\n    element: <ContactPage />,\n  },\n  {\n    path: \"/terms\",\n    element: <TermsPage />,\n  },\n  {\n    path: \"/disclaimer\",\n    element: <DisclaimerPage />,\n  },\n]);\n\nfunction App() {\n  return (\n    <>\n      <RouterProvider router={router} />\n      <Toaster />\n    </>\n  );\n}\n\nexport default App;\n"
  },
  {
    "path": "apps/landing/src/components/Footer.tsx",
    "content": "const footerNav = {\n  resources: [\n    { name: \"Pricing\", href: \"/pricing\" },\n    { name: \"Roadmap\", href: \"/roadmap\" },\n    { name: \"Usecase\", href: \"/usecase\" },\n    { name: \"Contact\", href: \"/contact\" },\n    { name: \"Privacy\", href: \"/privacy\" },\n    { name: \"Capsule\", href: \"/capsule\" },\n  ],\n  contents: [\n    { name: \"Privacy Policy\", href: \"/privacy-policy\" },\n    { name: \"Terms of Service\", href: \"/terms\" },\n    { name: \"Disclaimer\", href: \"/disclaimer\" },\n    {\n      name: \"Change Log\",\n      href: \"https://github.com/cartesiancs/vessel/releases\",\n    },\n    { name: \"GitHub\", href: \"https://github.com/cartesiancs/vessel\" },\n  ],\n  company: [\n    { name: \"Blog\", href: \"https://blog.cartesiancs.com/\" },\n    { name: \"About\", href: \"https://cartesiancs.com/about\" },\n    { name: \"LinkedIn\", href: \"https://www.linkedin.com/company/cartesiancs\" },\n    { name: \"X\", href: \"https://x.com/cartesiancs\" },\n    {\n      name: \"Instagram\",\n      href: \"https://www.instagram.com/cartesiancs.official/\",\n    },\n    {\n      name: \"Youtube\",\n      href: \"https://www.youtube.com/@cartesiancs\",\n    },\n  ],\n};\n\nexport function Footer() {\n  return (\n    <footer className='bg-background border-t'>\n      <div className='container mx-auto px-4 sm:px-6 lg:px-8 py-12'>\n        <div className='lg:grid lg:grid-cols-4 lg:gap-12'>\n          <div className='space-y-4'>\n            <p className='text-lg font-bold'>Vessel</p>\n            <p className='text-sm text-muted-foreground'>\n              Orchestrate physical devices with a single platform\n            </p>\n          </div>\n\n          <div className='mt-12 grid grid-cols-3 gap-16 sm:grid-cols-3 lg:col-start-3 lg:col-span-3 lg:mt-0'>\n            <div>\n              <h3 className='text-sm font-semibold leading-6 text-foreground'>\n                Resources\n              </h3>\n              <ul role='list' className='mt-4 space-y-1'>\n                {footerNav.resources.map((item) => (\n                  <li key={item.name}>\n                    <a\n                      href={item.href}\n                      className='text-sm leading-6 text-muted-foreground hover:text-foreground'\n                    >\n                      {item.name}\n                    </a>\n                  </li>\n                ))}\n              </ul>\n            </div>\n\n            <div>\n              <h3 className='text-sm font-semibold leading-6 text-foreground'>\n                Contents\n              </h3>\n              <ul role='list' className='mt-4 space-y-1'>\n                {footerNav.contents.map((item) => (\n                  <li key={item.name}>\n                    <a\n                      href={item.href}\n                      className='text-sm leading-6 text-muted-foreground hover:text-foreground'\n                    >\n                      {item.name}\n                    </a>\n                  </li>\n                ))}\n              </ul>\n            </div>\n\n            <div>\n              <h3 className='text-sm font-semibold leading-6 text-foreground'>\n                Company\n              </h3>\n              <ul role='list' className='mt-4 space-y-1'>\n                {footerNav.company.map((item) => (\n                  <li key={item.name}>\n                    <a\n                      href={item.href}\n                      target='_blank'\n                      rel='noopener noreferrer'\n                      className='text-sm leading-6 text-muted-foreground hover:text-foreground'\n                    >\n                      {item.name}\n                    </a>\n                  </li>\n                ))}\n              </ul>\n            </div>\n          </div>\n        </div>\n      </div>\n    </footer>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/Navbar.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport {\n  NavigationMenu,\n  NavigationMenuItem,\n  NavigationMenuLink,\n  NavigationMenuList,\n  navigationMenuTriggerStyle,\n} from \"@/components/ui/navigation-menu\";\nimport { Link, useNavigate } from \"react-router\";\nimport { useAuth } from \"@/contexts/AuthContext\";\n\n\nexport function Navbar() {\n  const navigate = useNavigate();\n  const { user, loading } = useAuth();\n  const openInNewTab = (url: string) => {\n    window.open(url, \"_blank\", \"noopener,noreferrer\");\n  };\n\n  const [scrolled, setScrolled] = useState(false);\n\n  useEffect(() => {\n    const onScroll = () => {\n      setScrolled(window.scrollY > 0);\n    };\n    onScroll();\n    window.addEventListener(\"scroll\", onScroll, { passive: true });\n    return () => window.removeEventListener(\"scroll\", onScroll);\n  }, []);\n\n  return (\n    <header\n      className={`fixed flex items-center justify-center top-0 z-50 w-full bg-background/95 backdrop-blur-lg supports-[backdrop-filter]:bg-background/70 ${\n        scrolled ? \"border-b\" : \"\"\n      }`}\n    >\n      <div className='h-12 flex items-center justify-between w-full'>\n        <div className='flex items-center'>\n          <Link to='/' className='mr-6 flex items-center space-x-2 pl-6 gap-1'>\n            <img src='/icon.png' alt='Logo' className='w-8 invert' />\n            <span className='hidden font-medium text-sm sm:inline-block'>\n              Vessel\n            </span>\n          </Link>\n        </div>\n\n        <NavigationMenu className='pr-2'>\n          <NavigationMenuList>\n            <NavigationMenuItem>\n              <NavigationMenuLink\n                className={`${navigationMenuTriggerStyle()} bg-transparent`}\n                onClick={() => navigate(\"/pricing\")}\n                style={{ cursor: \"pointer\" }}\n              >\n                Pricing\n              </NavigationMenuLink>\n            </NavigationMenuItem>\n            <NavigationMenuItem>\n              <NavigationMenuLink\n                className={`${navigationMenuTriggerStyle()} bg-transparent`}\n                onClick={() => openInNewTab(\"/docs/introduction\")}\n                style={{ cursor: \"pointer\" }}\n              >\n                Docs\n              </NavigationMenuLink>\n            </NavigationMenuItem>\n            <NavigationMenuItem>\n              {!loading && (\n                <NavigationMenuLink\n                  className={`${navigationMenuTriggerStyle()} bg-transparent`}\n                  onClick={() => navigate(user ? \"/dashboard\" : \"/login\")}\n                  style={{ cursor: \"pointer\" }}\n                >\n                  {user ? \"Dashboard\" : \"Sign In\"}\n                </NavigationMenuLink>\n              )}\n            </NavigationMenuItem>\n          </NavigationMenuList>\n        </NavigationMenu>\n      </div>\n    </header>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/UsageCharts.tsx",
    "content": "import {\n  Bar,\n  BarChart,\n  XAxis,\n  YAxis,\n  CartesianGrid,\n} from \"recharts\";\nimport {\n  ChartContainer,\n  ChartTooltip,\n  ChartTooltipContent,\n  ChartLegend,\n  ChartLegendContent,\n  type ChartConfig,\n} from \"@/components/ui/chart\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Loader2 } from \"lucide-react\";\nimport type { DailyUsage } from \"@/hooks/useUsageData\";\n\nfunction formatBytes(bytes: number): string {\n  if (bytes === 0) return \"0 B\";\n  const k = 1024;\n  const sizes = [\"B\", \"KB\", \"MB\", \"GB\"];\n  const i = Math.floor(Math.log(bytes) / Math.log(k));\n  return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;\n}\n\nfunction formatDate(dateStr: string): string {\n  const d = new Date(dateStr);\n  return `${d.getMonth() + 1}/${d.getDate()}`;\n}\n\nconst networkChartConfig = {\n  turnBytes: {\n    label: \"Turn\",\n    color: \"var(--color-chart-1)\",\n  },\n  tunnelBytes: {\n    label: \"Tunnel\",\n    color: \"var(--color-chart-2)\",\n  },\n} satisfies ChartConfig;\n\nconst capsuleRequestsConfig = {\n  capsuleRequests: {\n    label: \"API Requests\",\n    color: \"var(--color-chart-3)\",\n  },\n  capsuleImageRequests: {\n    label: \"Image Requests\",\n    color: \"var(--color-chart-4)\",\n  },\n} satisfies ChartConfig;\n\n\nfunction EmptyState() {\n  return (\n    <div className='flex items-center justify-center h-[200px] text-muted-foreground text-sm'>\n      No usage data\n    </div>\n  );\n}\n\nfunction hasNetworkData(data: DailyUsage[]): boolean {\n  return data.some((d) => d.turnBytes > 0 || d.tunnelBytes > 0);\n}\n\nfunction hasCapsuleRequestData(data: DailyUsage[]): boolean {\n  return data.some((d) => d.capsuleRequests > 0 || d.capsuleImageRequests > 0);\n}\n\n\nexport function UsageCharts({\n  data,\n  loading,\n}: {\n  data: DailyUsage[];\n  loading: boolean;\n}) {\n  if (loading) {\n    return (\n      <div className='flex items-center justify-center py-12'>\n        <Loader2 className='h-6 w-6 animate-spin text-muted-foreground' />\n      </div>\n    );\n  }\n\n  return (\n    <div className='grid min-w-0 gap-6'>\n      {/* Network Usage: Turn + Tunnel */}\n      <Card className='min-w-0 overflow-x-hidden'>\n        <CardHeader>\n          <CardTitle>Network Usage</CardTitle>\n          <CardDescription>\n            Turn & Tunnel bandwidth (last 30 days)\n          </CardDescription>\n        </CardHeader>\n        <CardContent className='min-w-0'>\n          {!hasNetworkData(data) ? (\n            <EmptyState />\n          ) : (\n            <ChartContainer\n              config={networkChartConfig}\n              className='h-[250px] w-full min-w-0 max-w-full'\n            >\n              <BarChart\n                data={data}\n                accessibilityLayer\n                margin={{ top: 8, right: 4, left: 4, bottom: 4 }}\n              >\n                <CartesianGrid vertical={false} />\n                <XAxis\n                  dataKey='date'\n                  tickFormatter={formatDate}\n                  tickLine={false}\n                  axisLine={false}\n                  tickMargin={6}\n                  interval='preserveStartEnd'\n                  minTickGap={8}\n                />\n                <YAxis\n                  tickFormatter={formatBytes}\n                  tickLine={false}\n                  axisLine={false}\n                  width={60}\n                />\n                <ChartTooltip\n                  content={\n                    <ChartTooltipContent\n                      formatter={(value) => formatBytes(value as number)}\n                      labelFormatter={(label) => formatDate(label)}\n                    />\n                  }\n                />\n                <ChartLegend content={<ChartLegendContent />} />\n                <Bar\n                  dataKey='turnBytes'\n                  stackId='network'\n                  fill='var(--color-turnBytes)'\n                  radius={[0, 0, 0, 0]}\n                />\n                <Bar\n                  dataKey='tunnelBytes'\n                  stackId='network'\n                  fill='var(--color-tunnelBytes)'\n                  radius={[4, 4, 0, 0]}\n                />\n              </BarChart>\n            </ChartContainer>\n          )}\n        </CardContent>\n      </Card>\n\n      {/* Capsule Requests */}\n      <Card className='min-w-0 overflow-x-hidden'>\n        <CardHeader>\n          <CardTitle>Capsule Requests</CardTitle>\n          <CardDescription>API & image requests (last 30 days)</CardDescription>\n        </CardHeader>\n        <CardContent className='min-w-0'>\n          {!hasCapsuleRequestData(data) ? (\n            <EmptyState />\n          ) : (\n            <ChartContainer\n              config={capsuleRequestsConfig}\n              className='h-[250px] w-full min-w-0 max-w-full'\n            >\n              <BarChart\n                data={data}\n                accessibilityLayer\n                margin={{ top: 8, right: 4, left: 4, bottom: 4 }}\n              >\n                <CartesianGrid vertical={false} />\n                <XAxis\n                  dataKey='date'\n                  tickFormatter={formatDate}\n                  tickLine={false}\n                  axisLine={false}\n                  tickMargin={6}\n                  interval='preserveStartEnd'\n                  minTickGap={8}\n                />\n                <YAxis tickLine={false} axisLine={false} width={40} />\n                <ChartTooltip\n                  content={\n                    <ChartTooltipContent\n                      labelFormatter={(label) => formatDate(label)}\n                    />\n                  }\n                />\n                <ChartLegend content={<ChartLegendContent />} />\n                <Bar\n                  dataKey='capsuleRequests'\n                  fill='var(--color-capsuleRequests)'\n                  radius={[4, 4, 0, 0]}\n                />\n                <Bar\n                  dataKey='capsuleImageRequests'\n                  fill='var(--color-capsuleImageRequests)'\n                  radius={[4, 4, 0, 0]}\n                />\n              </BarChart>\n            </ChartContainer>\n          )}\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/CapsulePromo.tsx",
    "content": "import { useFadeInOnScroll } from \"@/lib/useFadeInOnScroll\";\nimport { Shield } from \"lucide-react\";\n\nfunction cx(...classes: Array<string | false | null | undefined>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\nexport function CapsulePromoSection() {\n  const { ref: sectionRef, isVisible } = useFadeInOnScroll<HTMLElement>();\n\n  return (\n    <section ref={sectionRef} className='w-full'>\n      <div\n        className={cx(\n          \"container mx-auto max-w-7xl px-5 md:px-10 py-16\",\n          \"transition-all duration-700 ease-out\",\n          isVisible ? \"opacity-100 translate-y-0\" : \"opacity-0 translate-y-6\",\n        )}\n      >\n        <a\n          href='/capsule'\n          className='block rounded-3xl bg-neutral-900 px-10 py-14 md:px-16 md:py-16 transition-colors hover:bg-neutral-800/90 cursor-pointer'\n        >\n          <div className='flex flex-col items-center gap-10 md:flex-row md:items-center md:gap-16'>\n            <div className='flex shrink-0 items-center justify-center'>\n              <Shield\n                className='h-20 w-20 text-white md:h-24 md:w-24'\n                strokeWidth={1.4}\n              />\n            </div>\n\n            <div className='text-base leading-relaxed tracking-tight text-neutral-400 md:text-lg md:leading-relaxed'>\n              Vessel is{\" \"}\n              <span className='font-semibold text-white'>\n                designed to protect your privacy\n              </span>{\" \"}\n              at every step. It encrypts your messages on-device through\n              end-to-end encryption. So it processes your conversations without\n              ever accessing your personal data. And with zero-knowledge\n              architecture, Capsule can deliver powerful AI responses while\n              ensuring your privacy is never compromised.\n            </div>\n          </div>\n        </a>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/Faqs.tsx",
    "content": "import { useState } from \"react\";\nimport { Plus } from \"lucide-react\";\nimport { useFadeInOnScroll } from \"@/lib/useFadeInOnScroll\";\n\ntype FAQItem = {\n  question: string;\n  answer: string;\n};\n\nconst faqs: FAQItem[] = [\n  {\n    question: \"What is Vessel?\",\n    answer:\n      \"Vessel is an open-source “Command & Control (C2)” platform that connects, monitors, and orchestrates physical sensors through a visual, flow-based interface, aiming to enable proactive security (from detection to automated response)\",\n  },\n  {\n    question: \"How does the Flow system work, and what can I automate?\",\n    answer:\n      \"Vessel lets you build automation workflows by visually connecting nodes in a pipeline like Sensor (Input) → Logic (Process) → Device/Action (Output). This enables code-free rules (for example, triggering actions via integrations such as Home Assistant) using a drag-and-drop flow editor.\",\n  },\n  {\n    question: \"Can I use this service without an internet connection?\",\n    answer:\n      \"Yes. It can be used without the internet. It was designed with a local-first, offline-first approach and built to give users complete control.\",\n  },\n  {\n    question: \"How do I install/run Vessel, and is it production-ready?\",\n    answer:\n      \"You can run Vessel locally by starting the Rust-based server and the web client (Node/npm), or use Docker; the repository README provides the step-by-step commands. The project is explicitly described as under active development/POC, and it also states it is intended for academic and research purposes with responsibility for use resting with the user.\",\n  },\n  {\n    question: \"Can I integrate Vessel with my existing smart home setup?\",\n    answer:\n      \"Yes. Vessel supports integration with popular platforms like Home Assistant through its flow-based automation system. You can connect your existing devices and sensors to Vessel and orchestrate them together in a unified pipeline.\",\n  },\n  {\n    question: \"Does Vessel support custom sensors or third-party hardware?\",\n    answer:\n      \"Yes. Vessel is designed to be hardware-agnostic. You can connect custom sensors, microcontrollers, or third-party devices via standard protocols, and the flow editor lets you visually wire them into your automation workflows.\",\n  },\n  {\n    question: \"Can I self-host Vessel on my own server?\",\n    answer:\n      \"Yes. Vessel is fully open-source and built for self-hosting. You can deploy it on mac Mini or any machine—whether it's a Raspberry Pi, a local server, or a cloud VM—giving you complete ownership of your data and infrastructure.\",\n  },\n];\n\nfunction cx(...classes: Array<string | false | null | undefined>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\nexport function FAQSection() {\n  const { ref: sectionRef, isVisible } = useFadeInOnScroll<HTMLElement>();\n  const [activeIndex, setActiveIndex] = useState<number | null>(0);\n\n  return (\n    <section ref={sectionRef} className='h-full w-full bg-black text-white'>\n      <div\n        className={cx(\n          \"container mx-auto flex h-full max-w-7xl flex-col px-5 md:px-10 py-16\",\n          \"transition-all duration-700 ease-out\",\n          isVisible ? \"opacity-100 translate-y-0\" : \"opacity-0 translate-y-6\",\n        )}\n      >\n        <div className='w-full'>\n          {faqs.map((item, idx) => {\n            const isOpen = activeIndex === idx;\n            return (\n              <div key={item.question} className='border-b border-gray-300/40'>\n                <button\n                  type='button'\n                  onClick={() => setActiveIndex(isOpen ? null : idx)}\n                  className={cx(\n                    \"flex w-full items-center justify-between gap-4 py-5 text-left cursor-pointer\",\n                    \"focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-black\",\n                  )}\n                  aria-expanded={isOpen}\n                  aria-controls={`faq-panel-${idx}`}\n                  id={`faq-trigger-${idx}`}\n                >\n                  <div className='min-w-0 text-base font-semibold leading-snug'>\n                    {item.question}\n                  </div>\n\n                  <Plus\n                    className={cx(\n                      \"h-5 w-5 flex-none transition-transform duration-200\",\n                      isOpen ? \"rotate-45\" : \"rotate-0\",\n                    )}\n                    aria-hidden='true'\n                  />\n                </button>\n\n                <div\n                  id={`faq-panel-${idx}`}\n                  role='region'\n                  aria-labelledby={`faq-trigger-${idx}`}\n                  className={cx(\n                    \"overflow-hidden transition-[max-height] duration-300 ease-out\",\n                    isOpen ? \"max-h-48\" : \"max-h-0\",\n                  )}\n                >\n                  <div\n                    className={cx(\n                      \"pb-5 text-sm text-white/70 transition-opacity duration-300 ease-out\",\n                      isOpen ? \"opacity-100\" : \"opacity-0\",\n                    )}\n                  >\n                    {item.answer}\n                  </div>\n                </div>\n              </div>\n            );\n          })}\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/Features.tsx",
    "content": "import { useFadeInOnScroll } from \"@/lib/useFadeInOnScroll\";\n\nconst features = [\n  {\n    number: \"01\",\n    title: \"Flow\",\n    description:\n      \"Orchestrate sensors and automations with a flow-based editor that connects devices, AI models, and actions.\",\n    highlight: \"Orchestrate sensors\",\n    image: \"/images/flow.webp\",\n    alt: \"Flow visual editor\",\n  },\n  {\n    number: \"02\",\n    title: \"Dashboard\",\n    description:\n      \"Track streams and system health in one place with a centralized command view.\",\n    highlight: \"Track streams\",\n    image: \"/images/dashboard.webp\",\n    alt: \"Dashboard monitoring\",\n  },\n  {\n    number: \"03\",\n    title: \"Map\",\n    description:\n      \"Coordinate devices spatially with a map UI for faster decisions and responses.\",\n    highlight: \"Coordinate devices\",\n    image: \"/images/map.webp\",\n    alt: \"Map based interface\",\n  },\n];\n\nfunction HighlightedText({\n  text,\n  highlight,\n  animate,\n  delay,\n}: {\n  text: string;\n  highlight: string;\n  animate: boolean;\n  delay: number;\n}) {\n  const idx = text.indexOf(highlight);\n  if (idx === -1) return <>{text}</>;\n\n  const before = text.slice(0, idx);\n  const match = text.slice(idx, idx + highlight.length);\n  const after = text.slice(idx + highlight.length);\n\n  return (\n    <>\n      {before}\n      <span className='relative inline'>\n        <span\n          className='absolute inset-0 -mx-0.5 bg-white/90 origin-left transition-transform duration-700 ease-out'\n          style={{\n            transform: animate ? \"scaleX(1)\" : \"scaleX(0)\",\n            transitionDelay: `${delay}ms`,\n          }}\n        />\n        <span\n          className='relative transition-colors duration-700'\n          style={{\n            color: animate ? \"#171717\" : undefined,\n            transitionDelay: `${delay}ms`,\n          }}\n        >\n          {match}\n        </span>\n      </span>\n      {after}\n    </>\n  );\n}\n\nexport function FeaturesSection() {\n  const { ref: sectionRef, isVisible } = useFadeInOnScroll<HTMLElement>();\n\n  return (\n    <section ref={sectionRef} className='w-full'>\n      <div\n        className={`container mx-auto max-w-7xl px-5 pt-75 md:px-10 py-32 transition-all duration-700 ease-out ${\n          isVisible ? \"opacity-100 translate-y-0\" : \"opacity-0 translate-y-6\"\n        }`}\n      >\n        {/* Header */}\n        <div className='mb-10 text-left'>\n          <h2 className='text-3xl font-bold tracking-tight md:text-4xl leading-tight'>\n            Physical AI Platform.{\" \"}\n            <span className='text-neutral-500'>Local-First by Design.</span>\n          </h2>\n        </div>\n\n        {/* Feature Cards */}\n        <div className='grid grid-cols-1 md:grid-cols-4 gap-0'>\n          {features.map((feature, index) => (\n            <div\n              key={feature.title}\n              className={`relative border border-dashed border-neutral-700 p-6 flex flex-col transition-all duration-500 ease-out ${\n                index === 0 ? \"md:col-span-2\" : \"\"\n              } ${\n                isVisible\n                  ? \"opacity-100 translate-y-0\"\n                  : \"opacity-0 translate-y-4\"\n              }`}\n              style={{ transitionDelay: `${index * 120}ms` }}\n            >\n              {/* Number Label */}\n              <span className='text-sm text-neutral-500 tracking-widest font-mono'>\n                [ {feature.number} ]\n              </span>\n\n              {/* Image Area */}\n              <div className='flex-1 flex items-center justify-center py-12'>\n                <img\n                  src={feature.image}\n                  alt={feature.alt}\n                  className='h-48 w-full object-cover opacity-60'\n                  loading='lazy'\n                />\n              </div>\n\n              {/* Description */}\n              <p className='text-sm leading-relaxed text-neutral-300'>\n                <HighlightedText\n                  text={feature.description}\n                  highlight={feature.highlight}\n                  animate={isVisible}\n                  delay={300 + index * 1500}\n                />\n              </p>\n            </div>\n          ))}\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/FooterCta.tsx",
    "content": "import { useFadeInOnScroll } from \"@/lib/useFadeInOnScroll\";\n\nfunction cx(...classes: Array<string | false | null | undefined>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\nexport function FooterCtaSection() {\n  const { ref: sectionRef, isVisible } = useFadeInOnScroll<HTMLElement>();\n\n  return (\n    <section ref={sectionRef} className='w-full bg-black text-white'>\n      <div\n        className={cx(\n          \"container mx-auto max-w-6xl px-5 md:px-10 py-24 md:py-32\",\n          \"transition-all duration-700 ease-out\",\n          isVisible ? \"opacity-100 translate-y-0\" : \"opacity-0 translate-y-6\",\n        )}\n      >\n        <div className='mx-auto flex max-w-3xl flex-col items-center text-center'>\n          <h2 className='text-4xl font-semibold tracking-tight md:text-5xl'>\n            <span className='text-white/50'>Built</span>{\" \"}\n            <span className='text-white'>for independence</span>\n          </h2>\n\n          <p className='mt-5 max-w-2xl text-lg leading-relaxed text-white/75 md:text-xl'>\n            Vessel is a proactive security system that enables users to achieve\n            complete control over their physical spaces.\n          </p>\n\n          <div className='mt-10'>\n            <button\n              className={cx(\n                \"inline-flex h-12 items-center justify-center px-6\",\n                \"border border-white/25 bg-transparent text-base font-medium text-white\",\n                \"transition-colors hover:border-white/45 hover:bg-white/5\",\n                \"focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30 cursor-pointer\",\n              )}\n              onClick={() =>\n                window.open(\"https://github.com/cartesiancs/vessel\")\n              }\n            >\n              See GitHub\n            </button>\n          </div>\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/HeroScene/CameraRig.tsx",
    "content": "import { useFrame } from \"@react-three/fiber\";\n\nconst scrollState = { progress: 0 };\n\nfunction smoothstep(t: number): number {\n  return t * t * (3 - 2 * t);\n}\n\nconst START_POS: [number, number, number] = [-1.5, 1, 5.5];\nconst END_POS: [number, number, number] = [-0.4, 0.7, 3.8];\n\nexport function CameraRig() {\n  useFrame(\n    (state) => {\n      const progress = smoothstep(scrollState.progress);\n\n      const x = START_POS[0] + (END_POS[0] - START_POS[0]) * progress;\n      const y = START_POS[1] + (END_POS[1] - START_POS[1]) * progress;\n      const z = START_POS[2] + (END_POS[2] - START_POS[2]) * progress;\n\n      state.camera.position.set(x, y, z);\n      state.camera.lookAt(0, 0, 0);\n      state.camera.updateMatrixWorld(true);\n    },\n    -1,\n  );\n\n  return null;\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/HeroScene/Computers.tsx",
    "content": "import * as THREE from \"three\";\nimport { useMemo, useContext, createContext, useRef } from \"react\";\nimport { useFrame } from \"@react-three/fiber\";\nimport { useGLTF, Merged, useVideoTexture } from \"@react-three/drei\";\n\nconst context = createContext<any>(null);\n\nexport function Instances({ children, ...props }: any) {\n  const { nodes } = useGLTF(\"/glb/Computers.glb\") as any;\n  const instances = useMemo(\n    () => ({\n      Object1: nodes.Object_16,\n      Sphere: nodes.Sphere,\n    }),\n    [nodes],\n  );\n  return (\n    <Merged castShadow receiveShadow meshes={instances} {...props}>\n      {(instances: any) => (\n        <context.Provider value={instances} children={children} />\n      )}\n    </Merged>\n  );\n}\n\nexport function Computers(props: any) {\n  const { nodes: n, materials: m } = useGLTF(\"/glb/Computers.glb\") as any;\n  const instances = useContext(context);\n  return (\n    <group {...props} dispose={null}>\n      <instances.Object1\n        position={[0, 0, 0]}\n        rotation={[0, 0.17, 0]}\n        scale={1.52}\n      />\n      <mesh\n        castShadow\n        receiveShadow\n        geometry={n.Object_18.geometry}\n        material={m.Texture}\n        position={[-0.82, 0, 0.04]}\n        rotation={[0, -0.06, 0]}\n        scale={1.52}\n      />\n      <ScreenVideo\n        frame='Object_206'\n        panel='Object_207'\n        position={[-0.36, 1.53, 0.39]}\n      />\n      <Leds instances={instances} />\n    </group>\n  );\n}\n\nfunction ScreenVideo({ frame, panel, ...props }: any) {\n  const { nodes, materials } = useGLTF(\"/glb/Computers.glb\") as any;\n  const texture = useVideoTexture(\"/video/video.mp4\", {\n    loop: true,\n    muted: true,\n    playsInline: true,\n  });\n\n  const screen = useMemo(() => {\n    const geo = nodes[panel].geometry;\n    geo.computeBoundingBox();\n    const bb = geo.boundingBox!;\n    return {\n      width: bb.max.x - bb.min.x,\n      height: bb.max.y - bb.min.y,\n      center: [\n        (bb.max.x + bb.min.x) / 2,\n        (bb.max.y + bb.min.y) / 2,\n        bb.max.z + 0.005,\n      ] as [number, number, number],\n    };\n  }, [nodes, panel]);\n\n  return (\n    <group {...props}>\n      <mesh\n        castShadow\n        receiveShadow\n        geometry={nodes[frame].geometry}\n        material={materials.Texture}\n      />\n      <mesh position={screen.center}>\n        <planeGeometry args={[screen.width, screen.height]} />\n        <meshBasicMaterial map={texture} toneMapped={false} />\n      </mesh>\n    </group>\n  );\n}\n\nfunction Leds({ instances }: any) {\n  const ref = useRef<THREE.Group>(null!);\n  const { nodes } = useGLTF(\"/glb/Computers.glb\") as any;\n  useMemo(() => {\n    nodes.Sphere.material = new THREE.MeshBasicMaterial();\n    nodes.Sphere.material.toneMapped = false;\n  }, []);\n  useFrame((state) => {\n    ref.current.children.forEach((instance: any) => {\n      const rand = Math.abs(2 + instance.position.x);\n      const t = Math.round(\n        (1 + Math.sin(rand * 10000 + state.clock.elapsedTime * rand)) / 2,\n      );\n      instance.color.setRGB(0, t * 1.1, t);\n    });\n  });\n  return (\n    <group ref={ref}>\n      <instances.Sphere\n        position={[-1.04, 1.1, 0.79]}\n        scale={0.005}\n        color={[1, 2, 1]}\n      />\n      <instances.Sphere\n        position={[-0.04, 1.32, 0.78]}\n        scale={0.005}\n        color={[1, 2, 1]}\n      />\n    </group>\n  );\n}\n\nuseGLTF.preload(\"/glb/Computers.glb\");\n"
  },
  {
    "path": "apps/landing/src/components/sections/HeroScene/HeroScene.tsx",
    "content": "import { Suspense } from \"react\";\nimport { Canvas } from \"@react-three/fiber\";\nimport {\n  BakeShadows,\n  MeshReflectorMaterial,\n  OrbitControls,\n} from \"@react-three/drei\";\nimport { EffectComposer, Bloom } from \"@react-three/postprocessing\";\nimport { Instances, Computers } from \"./Computers\";\n\nexport function HeroSceneSection() {\n  return (\n    <section style={{ height: \"30vh\" }} className='relative bg-black'>\n      <Canvas\n        shadows\n        dpr={[1, 1.5]}\n        camera={{\n          position: [-0.4, 0.7, 3.8],\n          fov: 45,\n          near: 1,\n          far: 20,\n        }}\n        style={{ background: \"#000000\" }}\n      >\n        <Suspense fallback={null}>\n          <color attach='background' args={[\"black\"]} />\n          <hemisphereLight intensity={0.15} groundColor='black' />\n          <spotLight\n            decay={0}\n            position={[10, 20, 10]}\n            angle={0.12}\n            penumbra={1}\n            intensity={1}\n            castShadow\n            shadow-mapSize={1024}\n          />\n\n          <group position={[0, -1, 0]}>\n            <Instances>\n              <Computers scale={0.5} />\n            </Instances>\n\n            <mesh receiveShadow rotation={[-Math.PI / 2, 0, 0]}>\n              <planeGeometry args={[50, 50]} />\n              <MeshReflectorMaterial\n                blur={[300, 30]}\n                resolution={2048}\n                mixBlur={1}\n                mixStrength={180}\n                roughness={1}\n                depthScale={1.2}\n                minDepthThreshold={0.4}\n                maxDepthThreshold={1.4}\n                color='#202020'\n                metalness={0.8}\n              />\n            </mesh>\n\n            <pointLight\n              distance={1}\n              intensity={0.2}\n              position={[-0.15, 0.7, 0]}\n              color='#ffa914'\n            />\n          </group>\n\n          <EffectComposer enableNormalPass={false}>\n            <Bloom\n              luminanceThreshold={0}\n              mipmapBlur\n              luminanceSmoothing={0.0}\n              intensity={2}\n            />\n          </EffectComposer>\n\n          <OrbitControls enableZoom={false} enablePan={false} minPolarAngle={Math.PI / 2} maxPolarAngle={Math.PI / 2} />\n          <BakeShadows />\n        </Suspense>\n      </Canvas>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/HeroScene/SpinningBox.tsx",
    "content": "import { useRef, useState } from \"react\";\nimport { useFrame } from \"@react-three/fiber\";\nimport { useCursor } from \"@react-three/drei\";\nimport type { Mesh } from \"three\";\nimport type { ThreeEvent } from \"@react-three/fiber\";\n\ninterface SpinningBoxProps {\n  scale?: number;\n  position?: [number, number, number];\n  [key: string]: any;\n}\n\nexport function SpinningBox({ scale = 1, ...props }: SpinningBoxProps) {\n  const ref = useRef<Mesh>(null!);\n  const [hovered, hover] = useState(false);\n  const [clicked, click] = useState(false);\n  useCursor(hovered);\n\n  useFrame((_state, delta) => {\n    ref.current.rotation.x = ref.current.rotation.y += delta;\n  });\n\n  return (\n    <mesh\n      {...props}\n      ref={ref}\n      scale={clicked ? scale * 1.4 : scale * 1.2}\n      onClick={(_event: ThreeEvent<MouseEvent>) => click(!clicked)}\n      onPointerOver={(_event: ThreeEvent<PointerEvent>) => hover(true)}\n      onPointerOut={(_event: ThreeEvent<PointerEvent>) => hover(false)}\n    >\n      <boxGeometry />\n      <meshStandardMaterial color={hovered ? \"hotpink\" : \"indianred\"} />\n    </mesh>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/IntegrationImage.tsx",
    "content": "import React from \"react\";\nimport { useFadeInOnScroll } from \"@/lib/useFadeInOnScroll\";\n\nfunction cx(...classes: Array<string | false | null | undefined>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\nconst logos = [\n  { src: \"/logos/ha.webp\", alt: \"HA\" },\n  { src: \"/logos/ros2.webp\", alt: \"Ros2\" },\n  { src: \"/logos/ws.webp\", alt: \"ws\" },\n  { src: \"/logos/mqtt.webp\", alt: \"mqtt\" },\n  { src: \"/logos/http.webp\", alt: \"http\" },\n];\n\ntype Logo = { src: string; alt: string };\n\nfunction LogoMarquee({\n  items,\n  pxPerSecond = 80,\n}: {\n  items: Logo[];\n  pxPerSecond?: number;\n}) {\n  const containerRef = React.useRef<HTMLDivElement>(null);\n  const setRef = React.useRef<HTMLDivElement>(null);\n  const [setWidth, setSetWidth] = React.useState(0);\n  const [repeat, setRepeat] = React.useState(2);\n\n  React.useEffect(() => {\n    const container = containerRef.current;\n    const setEl = setRef.current;\n    if (!container || !setEl) return;\n\n    const measure = () => {\n      const cw = container.clientWidth;\n      const sw = setEl.scrollWidth;\n      if (cw <= 0 || sw <= 0) return;\n\n      setSetWidth(sw);\n\n      const needed = Math.ceil((cw * 2) / sw) + 1;\n      setRepeat(Math.max(2, needed));\n    };\n\n    measure();\n\n    const ro = new ResizeObserver(measure);\n    ro.observe(container);\n    ro.observe(setEl);\n\n    return () => ro.disconnect();\n  }, []);\n\n  const duration = setWidth > 0 ? setWidth / pxPerSecond : 20;\n\n  const trackStyle = {\n    [\"--marquee-distance\" as any]: `${setWidth}px`,\n    [\"--marquee-duration\" as any]: `${duration}s`,\n  };\n\n  return (\n    <div ref={containerRef} className='relative w-full overflow-hidden'>\n      <div className='pointer-events-none absolute inset-y-0 left-0 w-20 bg-gradient-to-r from-black to-transparent md:w-28' />\n      <div className='pointer-events-none absolute inset-y-0 right-0 w-20 bg-gradient-to-l from-black to-transparent md:w-28' />\n\n      <div className='marquee-track flex w-max items-center' style={trackStyle}>\n        <div ref={setRef} className='flex items-center gap-14 pr-14'>\n          {items.map((logo) => (\n            <div\n              key={logo.alt}\n              className='flex h-10 items-center justify-center opacity-80 transition-opacity duration-200 hover:opacity-100 md:h-12'\n            >\n              <img\n                src={logo.src}\n                alt={logo.alt}\n                className='h-full w-auto select-none object-contain'\n                loading='lazy'\n                draggable={false}\n              />\n            </div>\n          ))}\n        </div>\n\n        {Array.from({ length: repeat - 1 }).map((_, i) => (\n          <div\n            key={i}\n            className='flex items-center gap-14 pr-14'\n            aria-hidden='true'\n          >\n            {items.map((logo) => (\n              <div\n                key={`${logo.alt}-${i}`}\n                className='flex h-10 items-center justify-center opacity-80 transition-opacity duration-200 hover:opacity-100 md:h-12'\n              >\n                <img\n                  src={logo.src}\n                  alt=''\n                  className='h-full w-auto select-none object-contain'\n                  loading='lazy'\n                  draggable={false}\n                />\n              </div>\n            ))}\n          </div>\n        ))}\n      </div>\n\n      <style>{`\n        .marquee-track {\n          animation: marquee var(--marquee-duration) linear infinite;\n          will-change: transform;\n        }\n        .marquee-track:hover {\n          animation-play-state: paused;\n        }\n        @keyframes marquee {\n          from {\n            transform: translateX(0);\n          }\n          to {\n            transform: translateX(calc(-1 * var(--marquee-distance)));\n          }\n        }\n        @media (prefers-reduced-motion: reduce) {\n          .marquee-track {\n            animation: none;\n            transform: none;\n          }\n        }\n      `}</style>\n    </div>\n  );\n}\n\nexport function IntegrationSection() {\n  const { ref: sectionRef, isVisible } = useFadeInOnScroll<HTMLElement>();\n\n  return (\n    <section ref={sectionRef} className='w-full bg-black text-white'>\n      <div\n        className={cx(\n          \"container mx-auto max-w-7xl px-5 md:px-10 pt-40 py-10\",\n          \"transition-all duration-700 ease-out\",\n          isVisible ? \"opacity-100 translate-y-0\" : \"opacity-0 translate-y-6\",\n        )}\n      >\n        <div className='mx-auto max-w-4xl text-center'>\n          <h2 className='text-4xl font-semibold tracking-tight md:text-6xl md:leading-[1.05]'>\n            <span className='text-white/70'>Integration</span>\n            <br />\n            <span className='text-white'>with powerful tools</span>\n          </h2>\n        </div>\n\n        <div className='mx-auto mt-14 w-full max-w-5xl'>\n          <div className='relative mx-auto w-full overflow-hidden bg-black py-10'>\n            <LogoMarquee items={logos} pxPerSecond={80} />\n          </div>\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/ListCards.tsx",
    "content": "import { useEffect, useRef, useState } from \"react\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { useFadeInOnScroll } from \"@/lib/useFadeInOnScroll\";\n\ntype Slide = {\n  image: string;\n  alt: string;\n  title: string;\n  caption: string;\n};\n\nconst slides: Slide[] = [\n  {\n    image: \"/images/s1.webp\",\n    alt: \"Smart home dashboard\",\n    title: \"Edge orchestration\",\n    caption: \"Connect every device and keep control at the edge.\",\n  },\n  {\n    image: \"/images/s2.webp\",\n    alt: \"Industrial robot arm\",\n    title: \"Low-latency control\",\n    caption: \"Drive ROS 2 robots with dependable, real-time flows.\",\n  },\n  {\n    image: \"/images/s3.webp\",\n    alt: \"Factory overview\",\n    title: \"Mission control\",\n    caption: \"See every stream and respond instantly from one place.\",\n  },\n  {\n    image: \"/images/s4.webp\",\n    alt: \"Wearable alerting\",\n    title: \"Always-on insight\",\n    caption: \"Surface critical signals wherever your teams are.\",\n  },\n];\n\nconst defaultTrackPadding = \"clamp(1.5rem, 4vw, 3rem)\";\n\nfunction cx(...classes: Array<string | false | null | undefined>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\nexport function ListCardsSection() {\n  const { ref: sectionRef, isVisible } = useFadeInOnScroll<HTMLElement>();\n  const trackRef = useRef<HTMLDivElement>(null);\n  const cardRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const [activeIndex, setActiveIndex] = useState(0);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [paddingInline, setPaddingInline] = useState<{\n    left: string;\n    right: string;\n  }>({\n    left: defaultTrackPadding,\n    right: defaultTrackPadding,\n  });\n\n  const scrollToIndex = (index: number) => {\n    const target = cardRefs.current[index];\n    if (!target) return;\n\n    target.scrollIntoView({\n      behavior: \"smooth\",\n      block: \"nearest\",\n      inline: \"start\",\n    });\n    setActiveIndex(index);\n  };\n\n  useEffect(() => {\n    const track = trackRef.current;\n    if (!track) return;\n\n    const handleScroll = () => {\n      const trackRect = track.getBoundingClientRect();\n      const center = trackRect.left + trackRect.width / 2;\n\n      let closestIndex = activeIndex;\n      let smallestDistance = Number.POSITIVE_INFINITY;\n\n      cardRefs.current.forEach((card, idx) => {\n        if (!card) return;\n        const cardRect = card.getBoundingClientRect();\n        const cardCenter = cardRect.left + cardRect.width / 2;\n        const distance = Math.abs(center - cardCenter);\n        if (distance < smallestDistance) {\n          smallestDistance = distance;\n          closestIndex = idx;\n        }\n      });\n\n      if (closestIndex !== activeIndex) {\n        setActiveIndex(closestIndex);\n      }\n    };\n\n    track.addEventListener(\"scroll\", handleScroll, { passive: true });\n    return () => {\n      track.removeEventListener(\"scroll\", handleScroll);\n    };\n  }, [activeIndex]);\n\n  useEffect(() => {\n    const updatePadding = () => {\n      const container = containerRef.current;\n      if (!container) return;\n\n      const rect = container.getBoundingClientRect();\n      const style = window.getComputedStyle(container);\n      const left = rect.left + (parseFloat(style.paddingLeft ?? \"0\") || 0);\n      const right =\n        window.innerWidth -\n        rect.right +\n        (parseFloat(style.paddingRight ?? \"0\") || 0);\n\n      setPaddingInline({\n        left: `${Math.max(left, 0)}px`,\n        right: `${Math.max(right, 0)}px`,\n      });\n    };\n\n    updatePadding();\n    window.addEventListener(\"resize\", updatePadding);\n    return () => {\n      window.removeEventListener(\"resize\", updatePadding);\n    };\n  }, []);\n\n  return (\n    <section ref={sectionRef} className='w-full text-white'>\n      <div\n        ref={containerRef}\n        className={cx(\n          \"container mx-auto max-w-7xl px-5 md:px-10 py-40\",\n          \"transition-all duration-700 ease-out\",\n          isVisible ? \"opacity-100 translate-y-0\" : \"opacity-0 translate-y-6\",\n        )}\n      >\n        <div className='mb-6 flex items-baseline justify-between gap-6'>\n          <div>\n            <h2 className='text-3xl font-semibold text-neutral-400 md:text-4xl'>\n              Take a closer look.\n            </h2>\n          </div>\n        </div>\n\n        <div className='relative'>\n          <div\n            ref={trackRef}\n            className='relative left-1/2 flex w-screen -translate-x-1/2 snap-x snap-mandatory gap-6 overflow-x-auto pb-8 cursor-grab active:cursor-grabbing'\n            style={{\n              paddingLeft: paddingInline.left,\n              paddingRight: paddingInline.right,\n              scrollPaddingLeft: paddingInline.left,\n              scrollPaddingRight: paddingInline.right,\n            }}\n          >\n            {slides.map((slide, idx) => (\n              <div\n                key={slide.title}\n                ref={(el) => {\n                  cardRefs.current[idx] = el;\n                }}\n                className='relative flex aspect-video min-h-[280px] shrink-0 snap-start basis-[85%] items-end overflow-hidden border border-white/10 bg-gradient-to-b from-white/10 to-white/5 shadow-[0_30px_120px_rgba(0,0,0,0.5)] md:aspect-auto md:min-h-0 md:h-[520px] md:basis-[70%]'\n              >\n                <img\n                  src={slide.image}\n                  alt={slide.alt}\n                  className='absolute inset-0 h-full w-full object-cover'\n                  loading='lazy'\n                />\n              </div>\n            ))}\n          </div>\n\n          <div className='mt-4 hidden justify-end gap-2 md:flex'>\n            <div className='flex gap-2'>\n              <button\n                type='button'\n                onClick={() =>\n                  scrollToIndex(\n                    (activeIndex - 1 + slides.length) % slides.length,\n                  )\n                }\n                className='flex h-9 w-9 items-center justify-center border border-white/10 bg-white/5 text-white transition hover:border-white/20 hover:bg-white/10 cursor-pointer'\n                aria-label='View previous slide'\n              >\n                <ChevronLeft className='h-4 w-4' />\n              </button>\n              <button\n                type='button'\n                onClick={() => scrollToIndex((activeIndex + 1) % slides.length)}\n                className='flex h-9 w-9 items-center justify-center border border-white/10 bg-white/5 text-white transition hover:border-white/20 hover:bg-white/10 cursor-pointer'\n                aria-label='View next slide'\n              >\n                <ChevronRight className='h-4 w-4' />\n              </button>\n            </div>\n          </div>\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/MidCta.tsx",
    "content": "import { useFadeInOnScroll } from \"@/lib/useFadeInOnScroll\";\n\nfunction cx(...classes: Array<string | false | null | undefined>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\nfunction MapPlaneIllustration() {\n  return (\n    <svg\n      viewBox='0 0 900 360'\n      className='h-full w-full'\n      role='img'\n      aria-label='Map with a plane flying along a route'\n    >\n      <defs>\n        <pattern id='grid' width='36' height='36' patternUnits='userSpaceOnUse'>\n          <path\n            d='M 36 0 L 0 0 0 36'\n            fill='none'\n            stroke='rgba(255,255,255,0.08)'\n            strokeWidth='1'\n          />\n        </pattern>\n\n        <linearGradient id='fade' x1='0' y1='0' x2='1' y2='0'>\n          <stop offset='0.55' stopColor='rgba(255,255,255,0.0)' />\n          <stop offset='0.55' stopColor='rgba(255,255,255,0.64)' />\n          <stop offset='0.55' stopColor='rgba(255,255,255,0.64)' />\n          <stop offset='0.55' stopColor='rgba(255,255,255,0.0)' />\n        </linearGradient>\n\n        <style>\n          {`\n            .stroke { stroke: rgba(255,255,255,0.65); }\n            .muted { stroke: rgba(255,255,255,0.28); }\n            .dash  { stroke: rgba(255,255,255,0.55); stroke-dasharray: 8 10; }\n            .thin  { stroke-width: 2; fill: none; }\n            .thick { stroke-width: 3; fill: none; }\n          `}\n        </style>\n\n        <path\n          id='route'\n          d='M120,250\n             C190,170 250,165 305,205\n             C360,250 410,255 465,220\n             C520,185 570,170 625,205\n             C680,240 725,245 790,190'\n        />\n      </defs>\n\n      <rect x='0' y='0' width='900' height='360' fill='url(#grid)' />\n      <rect\n        x='0'\n        y='0'\n        width='900'\n        height='360'\n        fill='url(#fade)'\n        opacity='0.9'\n      />\n\n      <use href='#route' className='dash thick' />\n      <use href='#route' className='stroke thick' opacity='0.15' />\n\n      <g>\n        <circle r='10' fill='rgba(255,255,255,0.10)'>\n          <animateMotion dur='6.5s' repeatCount='indefinite' rotate='auto'>\n            <mpath href='#route' />\n          </animateMotion>\n        </circle>\n\n        <g>\n          <path d='M0,-10 L22,0 L0,10 L4,0 Z' fill='rgba(255,255,255,0.9)' />\n          <path d='M5,-2 L12,-2' stroke='rgba(0,0,0,0.35)' strokeWidth='2' />\n          <animateMotion dur='6.5s' repeatCount='indefinite' rotate='auto'>\n            <mpath href='#route' />\n          </animateMotion>\n        </g>\n      </g>\n    </svg>\n  );\n}\n\nexport function MidCTASection() {\n  const { ref: sectionRef, isVisible } = useFadeInOnScroll<HTMLElement>();\n\n  return (\n    <section\n      ref={sectionRef}\n      className='h-full w-full bg-neutral-950 text-white'\n    >\n      <div\n        className={cx(\n          \"container mx-auto flex h-full max-w-7xl flex-col gap-12 px-5 md:px-10 py-16 md:flex-row md:items-center md:justify-between\",\n          \"transition-all duration-700 ease-out\",\n          isVisible ? \"opacity-100 translate-y-0\" : \"opacity-0 translate-y-6\",\n        )}\n      >\n        <div className='w-full md:w-[46%]'>\n          <h2 className='text-4xl font-semibold tracking-tight md:text-5xl'>\n            Start with a demo\n            <br />\n            <span className='font-serif font-medium'>See it in action.</span>\n          </h2>\n\n          <div className='mt-8 flex flex-wrap items-center gap-4'>\n            <button\n              type='button'\n              className='inline-flex h-11 items-center justify-center bg-white px-5 text-sm font-medium text-black transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 cursor-pointer'\n              onClick={() => window.open(\"https://demo.vsl.cartesiancs.com/\")}\n            >\n              Start for free\n            </button>\n\n            <button\n              type='button'\n              className='inline-flex h-11 items-center justify-center border border-white/30 bg-transparent px-5 text-sm font-medium text-white transition-colors hover:border-white/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 cursor-pointer'\n              onClick={() => (location.href = \"/roadmap\")}\n            >\n              See our roadmap\n            </button>\n          </div>\n        </div>\n\n        <div className='hidden w-full md:block md:w-[54%]'>\n          <div className='relative h-[320px] w-full'>\n            <MapPlaneIllustration />\n          </div>\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/ScrollBox.tsx",
    "content": "import { useState, useEffect, useRef, useCallback } from \"react\";\nimport { Canvas, useFrame } from \"@react-three/fiber\";\nimport type { Mesh } from \"three\";\n\nconst EXTRA_SCROLL_PX = 1000;\n\nfunction RotatingBox({ progress }: { progress: number }) {\n  const meshRef = useRef<Mesh>(null);\n  const targetRotation = useRef({ x: 0, y: 0 });\n\n  targetRotation.current = {\n    x: progress * Math.PI * 2,\n    y: progress * Math.PI * 2,\n  };\n\n  useFrame(() => {\n    if (!meshRef.current) return;\n    const lerpFactor = 0.1;\n    meshRef.current.rotation.x +=\n      (targetRotation.current.x - meshRef.current.rotation.x) * lerpFactor;\n    meshRef.current.rotation.y +=\n      (targetRotation.current.y - meshRef.current.rotation.y) * lerpFactor;\n  });\n\n  return (\n    <mesh ref={meshRef}>\n      <boxGeometry args={[2, 2, 2]} />\n      <meshStandardMaterial color=\"#ffffff\" metalness={0.3} roughness={0.4} />\n    </mesh>\n  );\n}\n\nexport function ScrollBoxSection() {\n  const sectionRef = useRef<HTMLElement>(null);\n  const [scrollProgress, setScrollProgress] = useState(0);\n\n  const handleScroll = useCallback(() => {\n    if (!sectionRef.current) return;\n    const rect = sectionRef.current.getBoundingClientRect();\n    const sectionHeight = rect.height;\n    const viewportHeight = window.innerHeight;\n\n    const scrollableDistance = sectionHeight - viewportHeight;\n    const scrolled = -rect.top;\n    const progress =\n      scrollableDistance > 0 ? scrolled / scrollableDistance : 0;\n\n    setScrollProgress(Math.max(0, Math.min(1, progress)));\n  }, []);\n\n  useEffect(() => {\n    window.addEventListener(\"scroll\", handleScroll, { passive: true });\n    handleScroll();\n    return () => window.removeEventListener(\"scroll\", handleScroll);\n  }, [handleScroll]);\n\n  return (\n    <section\n      ref={sectionRef}\n      style={{ height: `calc(100vh + ${EXTRA_SCROLL_PX}px)` }}\n      className=\"relative bg-black\"\n    >\n      <div className=\"sticky top-0 h-screen flex items-center justify-center\">\n        <Canvas\n          gl={{ alpha: false }}\n          camera={{ position: [0, 0, 5], fov: 50 }}\n          style={{ background: \"#000000\" }}\n        >\n          <ambientLight intensity={0.4} />\n          <directionalLight position={[5, 5, 5]} intensity={1} />\n          <pointLight position={[-5, -5, -5]} intensity={0.5} />\n          <RotatingBox progress={scrollProgress} />\n        </Canvas>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/ScrollTextReveal.tsx",
    "content": "import { useState, useEffect, useRef, useCallback } from \"react\";\n\nconst QUOTE = {\n  text: `\"Physical AI that discovers, and adapts — built for explorers\"`,\n  author: \"H. Jun Huh\",\n  title: \"Founder · cartesiancs\",\n};\n\nfunction Word({ word, progress }: { word: string; progress: number }) {\n  const clamped = Math.max(0, Math.min(1, progress));\n  // Dark theme: reveal from dark gray (0.3) to white (1.0)\n  const lightness = 0.3 + clamped * 0.7;\n  const color = `oklch(${lightness} 0 0)`;\n\n  return (\n    <span\n      style={{\n        color,\n        transition: \"color 0.08s ease-out\",\n      }}\n      className='inline'\n    >\n      {word}{\" \"}\n    </span>\n  );\n}\n\nfunction QuoteSection() {\n  const sectionRef = useRef<HTMLElement>(null);\n  const [scrollProgress, setScrollProgress] = useState(0);\n\n  const handleScroll = useCallback(() => {\n    if (!sectionRef.current) return;\n    const rect = sectionRef.current.getBoundingClientRect();\n    const sectionHeight = rect.height;\n    const viewportHeight = window.innerHeight;\n\n    const scrollableDistance = sectionHeight - viewportHeight;\n    const scrolled = -rect.top;\n    const progress = scrollableDistance > 0 ? scrolled / scrollableDistance : 0;\n\n    setScrollProgress(Math.max(0, Math.min(1, progress)));\n  }, []);\n\n  useEffect(() => {\n    window.addEventListener(\"scroll\", handleScroll, { passive: true });\n    handleScroll();\n    return () => window.removeEventListener(\"scroll\", handleScroll);\n  }, [handleScroll]);\n\n  const words = QUOTE.text.split(\" \");\n\n  return (\n    <section\n      ref={sectionRef}\n      style={{ height: `${Math.max(250, words.length * 18)}vh` }}\n      className='relative'\n    >\n      <div className='sticky top-0 h-screen flex flex-col items-center justify-center px-[8vw]'>\n        <p\n          className='text-center font-bold leading-[1.25] tracking-tight max-w-[820px] m-0'\n          style={{\n            fontSize: \"clamp(1.8rem, 4.2vw, 3.6rem)\",\n            letterSpacing: \"-0.02em\",\n          }}\n        >\n          {words.map((word, i) => {\n            const wordStart = i / words.length;\n            const wordEnd = (i + 1) / words.length;\n            const wordProgress =\n              (scrollProgress - wordStart) / (wordEnd - wordStart);\n            return <Word key={i} word={word} progress={wordProgress} />;\n          })}\n        </p>\n\n        <div\n          className='mt-8 text-center'\n          style={{\n            opacity:\n              scrollProgress > 0.15\n                ? Math.min(1, (scrollProgress - 0.15) * 3)\n                : 0,\n            transform: `translateY(${scrollProgress > 0.15 ? 0 : 8}px)`,\n            transition: \"opacity 0.4s ease-out, transform 0.4s ease-out\",\n          }}\n        >\n          <p className='text-sm font-semibold text-foreground m-0 tracking-wide'>\n            {QUOTE.author}\n          </p>\n          <p className='text-xs font-normal text-neutral-500 mt-1 m-0 tracking-wide'>\n            {QUOTE.title}\n          </p>\n        </div>\n      </div>\n    </section>\n  );\n}\n\nexport function ScrollTextRevealSection() {\n  return (\n    <section className='w-full'>\n      <QuoteSection />\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/SecurityCta.tsx",
    "content": "import { useFadeInOnScroll } from \"@/lib/useFadeInOnScroll\";\n\nfunction cx(...classes: Array<string | false | null | undefined>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\nexport function SecurityCTASection() {\n  const { ref: sectionRef, isVisible } = useFadeInOnScroll<HTMLElement>();\n\n  return (\n    <section ref={sectionRef} className='h-full w-full bg-black text-white'>\n      <div\n        className={cx(\n          \"container mx-auto flex h-full max-w-7xl flex-col gap-12 px-5 md:px-10 py-16 md:flex-row md:items-center md:justify-between\",\n          \"transition-all duration-700 ease-out\",\n          isVisible ? \"opacity-100 translate-y-0\" : \"opacity-0 translate-y-6\",\n        )}\n      >\n        <div className='w-full md:w-[46%]'>\n          <h2 className='text-4xl font-semibold tracking-tight md:text-5xl'>\n            Zero Tracker.\n            <br />\n            <span className='font-serif font-medium'>Absolute Privacy</span>\n          </h2>\n\n          <div className='mt-8 flex flex-wrap items-center gap-4'>\n            <button\n              type='button'\n              className='inline-flex h-11 items-center justify-center bg-white px-5 text-sm font-medium text-black transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 cursor-pointer'\n              onClick={() =>\n                window.open(\"https://github.com/cartesiancs/vessel\")\n              }\n            >\n              How Vessel works\n            </button>\n\n            <button\n              type='button'\n              className='inline-flex h-11 items-center justify-center border border-white/30 bg-transparent px-5 text-sm font-medium text-white transition-colors hover:border-white/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 cursor-pointer'\n              onClick={() => (location.href = \"/privacy\")}\n            >\n              Privacy\n            </button>\n          </div>\n        </div>\n\n        <div className='hidden md:block md:w-[54%]'>\n          <div className='relative h-[320px] w-full'></div>\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/SubheadingSection.tsx",
    "content": "import { useEffect, useRef, useState } from \"react\";\n\nconst TITLE = \"Build your own infrastructure\";\n\nexport function SubheadingSection() {\n  const sectionRef = useRef<HTMLElement | null>(null);\n  const [isVisible, setIsVisible] = useState(false);\n\n  useEffect(() => {\n    const node = sectionRef.current;\n    if (!node) return;\n\n    const observer = new IntersectionObserver(\n      (entries) => {\n        entries.forEach((entry) => {\n          if (entry.isIntersecting) {\n            setIsVisible(true);\n            observer.disconnect();\n          }\n        });\n      },\n      { threshold: 0.35 },\n    );\n\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  return (\n    <section\n      ref={sectionRef}\n      className='flex h-[400px] items-center justify-center px-6'\n    >\n      <h1 className='flex flex-wrap items-center justify-center gap-2 text-center text-neutral-200 text-2xl md:text-[48px] font-semibold leading-tight tracking-tight'>\n        {TITLE.split(\" \").map((word, index) => (\n          <span\n            key={word + index}\n            className={`transform-gpu transition-all duration-700 ease-out ${\n              isVisible\n                ? \"translate-y-0 opacity-100 blur-0\"\n                : \"translate-y-2 opacity-0 blur-sm\"\n            }`}\n            style={{ transitionDelay: `${index * 80}ms` }}\n          >\n            {word}\n          </span>\n        ))}\n      </h1>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/ThreeCards.tsx",
    "content": "import { useFadeInOnScroll } from \"@/lib/useFadeInOnScroll\";\n\ntype Card = {\n  image: string;\n  alt: string;\n  title: string;\n  description: string;\n};\n\nconst cards: Card[] = [\n  {\n    image: \"/images/ha.webp\",\n    alt: \"Device connections\",\n    title: \"Home Assistant.\",\n    description:\n      \"Connect with every supported device. Powered by the world’s most powerful IoT hub.\",\n  },\n  {\n    image: \"/images/robotarm.webp\",\n    alt: \"Threat detection\",\n    title: \"Robot Control.\",\n    description: \"Control robots in real time with ROS 2’s powerful protocols.\",\n  },\n  {\n    image: \"/images/watch.webp\",\n    alt: \"Automated response\",\n    title: \"Fundamental Control.\",\n    description:\n      \"Bring real-time data from WebSocket, MQTT, and HTTP straight into your flow logic.\",\n  },\n];\n\nfunction cx(...classes: Array<string | false | null | undefined>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\nexport function ThreeCardsSection() {\n  const { ref: sectionRef, isVisible } = useFadeInOnScroll<HTMLElement>();\n\n  return (\n    <section ref={sectionRef} className='w-full bg-black text-white'>\n      <div\n        className={cx(\n          \"container mx-auto max-w-7xl px-5 md:px-10 py-30\",\n          \"transition-all duration-700 ease-out\",\n          isVisible ? \"opacity-100 translate-y-0\" : \"opacity-0 translate-y-6\",\n        )}\n      >\n        <div className='grid gap-10 md:grid-cols-3'>\n          {cards.map((c) => (\n            <div key={c.title} className='space-y-6'>\n              <div className='overflow-hidden bg-white/5'>\n                <img\n                  src={c.image}\n                  alt={c.alt}\n                  className='h-[320px] w-full object-cover md:h-[360px]'\n                  loading='lazy'\n                />\n              </div>\n\n              <p className='text-lg leading-snug'>\n                <span className='font-semibold text-white'>{c.title} </span>\n                <span className='text-white/55'>{c.description}</span>\n              </p>\n            </div>\n          ))}\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/Usecase.tsx",
    "content": "import { useCallback, useMemo, useRef, useState } from \"react\";\nimport { useFadeInOnScroll } from \"@/lib/useFadeInOnScroll\";\n\ntype Usecase = {\n  title: string;\n  description: string;\n  image: string;\n  alt: string;\n};\n\nconst usecases: Usecase[] = [\n  {\n    title: \"Realtime Control\",\n    description:\n      \"Coordinate sensors, cameras, and automated responses across large sites without relying on cloud connectivity.\",\n    image: \"/images/house.webp\",\n    alt: \"Perimeter operations map\",\n  },\n  {\n    title: \"Mission Control Watchtower\",\n    description:\n      \"Fuse live feeds, alerts, and playbooks into a single command layer for faster, safer decisions.\",\n    image: \"/images/factory.webp\",\n    alt: \"Mission control dashboard\",\n  },\n  {\n    title: \"CCTV Dashboard\",\n    description:\n      \"RTSP and low-level UDP RTP video stream support. Aggregate multiple media sources for real-time visibility and control.\",\n    image: \"/images/cctv.webp\",\n    alt: \"Mission control dashboard\",\n  },\n];\n\nfunction cx(...classes: Array<string | false | null | undefined>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\nexport function UsecaseSection() {\n  const { ref: sectionRef, isVisible } = useFadeInOnScroll<HTMLElement>();\n  const [activeIndex, setActiveIndex] = useState(0);\n  const tabBarRef = useRef<HTMLDivElement>(null);\n\n  const active = useMemo(() => usecases[activeIndex], [activeIndex]);\n\n  const handleTabClick = useCallback((idx: number, el: HTMLButtonElement) => {\n    setActiveIndex(idx);\n    el.scrollIntoView({ behavior: \"smooth\", inline: \"center\", block: \"nearest\" });\n  }, []);\n\n  return (\n    <section ref={sectionRef} className='w-full'>\n      <div\n        className={cx(\n          \"container mx-auto max-w-7xl px-5 md:px-10 py-30 transition-all duration-700 ease-out\",\n          \"flex flex-col\",\n          isVisible ? \"opacity-100 translate-y-0\" : \"opacity-0 translate-y-6\",\n        )}\n      >\n        <div className='mb-10 text-left'>\n          <h2 className='mt-3 text-3xl font-bold tracking-tight md:text-4xl'>\n            Explore <span className='text-neutral-500'>What You Can Build</span>\n          </h2>\n        </div>\n\n        {/* Image area – crossfade */}\n        <div className='relative w-full overflow-hidden aspect-[16/10] md:aspect-[16/9]'>\n          {usecases.map((u, idx) => (\n            <img\n              key={u.image}\n              src={u.image}\n              alt={u.alt}\n              className={cx(\n                \"absolute inset-0 h-full w-full object-cover transition-opacity duration-700 ease-in-out\",\n                idx === activeIndex ? \"opacity-100\" : \"opacity-0\",\n              )}\n              loading='lazy'\n            />\n          ))}\n          <div\n            className='absolute inset-0 bg-gradient-to-t from-black/40 to-transparent'\n            aria-hidden='true'\n          />\n        </div>\n\n        {/* Tab bar */}\n        <div ref={tabBarRef} className='mt-8 flex gap-4 md:gap-8 md:justify-center overflow-x-auto'>\n          {usecases.map((u, idx) => {\n            const isActive = idx === activeIndex;\n            return (\n              <button\n                key={u.title}\n                type='button'\n                onClick={(e) => handleTabClick(idx, e.currentTarget)}\n                className={cx(\n                  \"pb-2 text-sm md:text-base font-medium transition-all duration-200 cursor-pointer border-b-2 whitespace-nowrap shrink-0\",\n                  isActive\n                    ? \"text-white border-white\"\n                    : \"text-neutral-500 border-transparent hover:text-neutral-300\",\n                )}\n              >\n                {u.title}\n              </button>\n            );\n          })}\n        </div>\n\n        {/* Description */}\n        <p className='mt-6 mx-auto max-w-2xl text-center text-sm md:text-base leading-relaxed text-neutral-400'>\n          {active.description}\n        </p>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/UsecaseAI.tsx",
    "content": "import { useCallback, useMemo, useRef, useState } from \"react\";\nimport { useFadeInOnScroll } from \"@/lib/useFadeInOnScroll\";\n\ntype Usecase = {\n  title: string;\n  description: string;\n  image: string;\n  alt: string;\n};\n\nconst usecases: Usecase[] = [\n  {\n    title: \"Flow Auto Generation\",\n    description:\n      \"Describe what you want in natural language and the AI agent instantly builds a complete automation flow.\",\n    image: \"/images/ai1.webp\",\n    alt: \"AI generating automation flow\",\n  },\n  {\n    title: \"Home Analysis\",\n    description:\n      \"The AI continuously monitors sensor data, cameras, and device states to understand what is happening at home and surface actionable insights in real time.\",\n    image: \"/images/house.webp\",\n    alt: \"AI analyzing home status\",\n  },\n  {\n    title: \"Flow Integration\",\n    description:\n      \"Seamlessly merge AI-generated suggestions into your existing flows. The agent resolves conflicts, optimizes triggers, and keeps everything in sync.\",\n    image: \"/images/factory.webp\",\n    alt: \"AI integrating into existing flows\",\n  },\n  {\n    title: \"Capsule Security\",\n    description:\n      \"Every AI action runs inside an isolated capsule with scoped permissions, ensuring that automation never exceeds the boundaries you define.\",\n    image: \"/images/s1.webp\",\n    alt: \"Capsule-based security layer\",\n  },\n];\n\nfunction cx(...classes: Array<string | false | null | undefined>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\nexport function UsecaseAIAssistantSection() {\n  const { ref: sectionRef, isVisible } = useFadeInOnScroll<HTMLElement>();\n  const [activeIndex, setActiveIndex] = useState(0);\n  const tabBarRef = useRef<HTMLDivElement>(null);\n\n  const active = useMemo(() => usecases[activeIndex], [activeIndex]);\n\n  const handleTabClick = useCallback((idx: number, el: HTMLButtonElement) => {\n    setActiveIndex(idx);\n    el.scrollIntoView({ behavior: \"smooth\", inline: \"center\", block: \"nearest\" });\n  }, []);\n\n  return (\n    <section ref={sectionRef} className='w-full'>\n      <div\n        className={cx(\n          \"container mx-auto max-w-7xl px-5 md:px-10 py-30 transition-all duration-700 ease-out\",\n          \"flex flex-col\",\n          isVisible ? \"opacity-100 translate-y-0\" : \"opacity-0 translate-y-6\",\n        )}\n      >\n        <div className='mb-10 text-left'>\n          <h2 className='mt-3 text-3xl font-bold tracking-tight md:text-4xl'>\n            AI Agent{\" \"}\n            <span className='text-neutral-500'>\n              for understanding the world\n            </span>\n          </h2>\n        </div>\n\n        {/* Image area – crossfade */}\n        <div className='relative w-full overflow-hidden aspect-[16/10] md:aspect-[16/9]'>\n          {usecases.map((u, idx) => (\n            <img\n              key={u.image}\n              src={u.image}\n              alt={u.alt}\n              className={cx(\n                \"absolute inset-0 h-full w-full object-cover transition-opacity duration-700 ease-in-out\",\n                idx === activeIndex ? \"opacity-100\" : \"opacity-0\",\n              )}\n              loading='lazy'\n            />\n          ))}\n          <div\n            className='absolute inset-0 bg-gradient-to-t from-black/40 to-transparent'\n            aria-hidden='true'\n          />\n        </div>\n\n        {/* Tab bar */}\n        <div ref={tabBarRef} className='mt-8 flex gap-4 md:gap-8 md:justify-center overflow-x-auto'>\n          {usecases.map((u, idx) => {\n            const isActive = idx === activeIndex;\n            return (\n              <button\n                key={u.title}\n                type='button'\n                onClick={(e) => handleTabClick(idx, e.currentTarget)}\n                className={cx(\n                  \"pb-2 text-sm md:text-base font-medium transition-all duration-200 cursor-pointer border-b-2 whitespace-nowrap shrink-0\",\n                  isActive\n                    ? \"text-white border-white\"\n                    : \"text-neutral-500 border-transparent hover:text-neutral-300\",\n                )}\n              >\n                {u.title}\n              </button>\n            );\n          })}\n        </div>\n\n        {/* Description */}\n        <p className='mt-6 mx-auto max-w-2xl text-center text-sm md:text-base leading-relaxed text-neutral-400'>\n          {active.description}\n        </p>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/capsule/CapsuleArchitecture.tsx",
    "content": "import { Cpu, Box, Gauge } from \"lucide-react\";\nimport { useFadeInOnScroll } from \"@/lib/useFadeInOnScroll\";\nimport type { ComponentType } from \"react\";\n\nfunction cx(...classes: Array<string | false | null | undefined>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\ntype Card = {\n  icon: ComponentType<{ className?: string; strokeWidth?: number }>;\n  title: string;\n  description: string;\n};\n\nconst cards: Card[] = [\n  {\n    icon: Cpu,\n    title: \"Rust Backend.\",\n    description:\n      \"Built with Rust for memory safety, zero-cost abstractions, and blazing performance. No garbage collector, no runtime overhead.\",\n  },\n  {\n    icon: Box,\n    title: \"Hardened Containers.\",\n    description:\n      \"Alpine Linux, non-root user, read-only filesystem, no shell access. Every layer minimizes the attack surface.\",\n  },\n  {\n    icon: Gauge,\n    title: \"Rate-Limited Access.\",\n    description:\n      \"Subscription tiers with intelligent rate limiting. Fair usage guaranteed, abuse prevented at the infrastructure level.\",\n  },\n];\n\nexport function CapsuleArchitecture() {\n  const { ref: sectionRef, isVisible } = useFadeInOnScroll<HTMLElement>();\n\n  return (\n    <section ref={sectionRef} className='w-full bg-black text-white'>\n      <div\n        className={cx(\n          \"container mx-auto max-w-6xl px-8 py-40\",\n          \"transition-all duration-700 ease-out\",\n          isVisible ? \"opacity-100 translate-y-0\" : \"opacity-0 translate-y-6\",\n        )}\n      >\n        <div className='mb-14 text-left'>\n          <h2 className='text-3xl font-bold tracking-tight md:text-4xl'>\n            Architecture <span className='text-neutral-500'>Built to Last</span>\n          </h2>\n        </div>\n\n        <div className='grid gap-10 md:grid-cols-3'>\n          {cards.map((c) => {\n            const Icon = c.icon;\n            return (\n              <div key={c.title} className='space-y-6'>\n                <div className='flex h-[320px] w-full items-center justify-center overflow-hidden bg-white/5 md:h-[360px]'>\n                  <Icon className='h-20 w-20 text-white/20' strokeWidth={1} />\n                </div>\n\n                <p className='text-lg leading-snug'>\n                  <span className='font-semibold text-white'>{c.title} </span>\n                  <span className='text-white/55'>{c.description}</span>\n                </p>\n              </div>\n            );\n          })}\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/capsule/CapsuleFaq.tsx",
    "content": "import { useState } from \"react\";\nimport { Plus } from \"lucide-react\";\nimport { useFadeInOnScroll } from \"@/lib/useFadeInOnScroll\";\n\nfunction cx(...classes: Array<string | false | null | undefined>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\ntype FAQItem = {\n  question: string;\n  answer: string;\n};\n\nconst faqs: FAQItem[] = [\n  {\n    question: \"What is Capsule?\",\n    answer:\n      \"Capsule is a secure, privacy-first AI chat service. Every conversation is end-to-end encrypted using X25519 ECDH key exchange and XChaCha20-Poly1305 authenticated encryption, ensuring that only you and the AI can access your messages.\",\n  },\n  {\n    question: \"Can Capsule read my messages?\",\n    answer:\n      \"No. Capsule uses a zero-knowledge architecture. Messages are encrypted on your device before transmission. The server processes encrypted payloads and never has access to plaintext content. Private keys are never persisted to disk.\",\n  },\n  {\n    question: \"What encryption does Capsule use?\",\n    answer:\n      \"Capsule uses X25519 Elliptic Curve Diffie-Hellman for key exchange and XChaCha20-Poly1305 for authenticated encryption. Key derivation uses HKDF-SHA256. All keys are automatically zeroized from memory after use.\",\n  },\n  {\n    question: \"Does image analysis compromise my privacy?\",\n    answer:\n      \"No. Images are encrypted client-side before upload using the same X25519 + XChaCha20-Poly1305 scheme. The server decrypts them only for GPT-4o vision analysis, then immediately zeroizes all image data from memory.\",\n  },\n  {\n    question: \"What happens to my data if there is a breach?\",\n    answer:\n      \"Even in a breach scenario, your data remains unreadable. All messages are transmitted encrypted, encryption keys are never persisted on the server, and memory is automatically zeroized after each request.\",\n  },\n  {\n    question: \"How does the subscription work?\",\n    answer:\n      \"Capsule uses JWT-based authentication through Supabase. Subscribe to a Pro plan for full access to encrypted AI chat and image analysis, with rate-limited usage to ensure fair service for all users.\",\n  },\n];\n\nexport function CapsuleFaq() {\n  const { ref: sectionRef, isVisible } = useFadeInOnScroll<HTMLElement>();\n  const [activeIndex, setActiveIndex] = useState<number | null>(0);\n\n  return (\n    <section ref={sectionRef} className='h-full w-full bg-black text-white'>\n      <div\n        className={cx(\n          \"container mx-auto flex h-full max-w-6xl flex-col px-8 md:px-10 py-16\",\n          \"transition-all duration-700 ease-out\",\n          isVisible ? \"opacity-100 translate-y-0\" : \"opacity-0 translate-y-6\",\n        )}\n      >\n        <div className='mb-12 text-left'>\n          <h2 className='text-3xl font-bold tracking-tight md:text-4xl'>\n            Frequently Asked{\" \"}\n            <span className='text-neutral-500'>Questions</span>\n          </h2>\n        </div>\n\n        <div className='w-full'>\n          {faqs.map((item, idx) => {\n            const isOpen = activeIndex === idx;\n            return (\n              <div key={item.question} className='border-b border-gray-300/40'>\n                <button\n                  type='button'\n                  onClick={() => setActiveIndex(isOpen ? null : idx)}\n                  className={cx(\n                    \"flex w-full items-center justify-between gap-4 py-5 text-left cursor-pointer\",\n                    \"focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-black\",\n                  )}\n                  aria-expanded={isOpen}\n                  aria-controls={`capsule-faq-panel-${idx}`}\n                  id={`capsule-faq-trigger-${idx}`}\n                >\n                  <div className='min-w-0 text-base font-semibold leading-snug'>\n                    {item.question}\n                  </div>\n\n                  <Plus\n                    className={cx(\n                      \"h-5 w-5 flex-none transition-transform duration-200\",\n                      isOpen ? \"rotate-45\" : \"rotate-0\",\n                    )}\n                    aria-hidden='true'\n                  />\n                </button>\n\n                <div\n                  id={`capsule-faq-panel-${idx}`}\n                  role='region'\n                  aria-labelledby={`capsule-faq-trigger-${idx}`}\n                  className={cx(\n                    \"overflow-hidden transition-[max-height] duration-300 ease-out\",\n                    isOpen ? \"max-h-48\" : \"max-h-0\",\n                  )}\n                >\n                  <div\n                    className={cx(\n                      \"pb-5 text-sm text-white/70 transition-opacity duration-300 ease-out\",\n                      isOpen ? \"opacity-100\" : \"opacity-0\",\n                    )}\n                  >\n                    {item.answer}\n                  </div>\n                </div>\n              </div>\n            );\n          })}\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/capsule/CapsuleFeatures.tsx",
    "content": "import {\n  Lock,\n  Image,\n  Radio,\n  ShieldCheck,\n  KeyRound,\n  Server,\n} from \"lucide-react\";\nimport { useFadeInOnScroll } from \"@/lib/useFadeInOnScroll\";\nimport type { ComponentType } from \"react\";\n\nfunction cx(...classes: Array<string | false | null | undefined>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\ntype Feature = {\n  icon: ComponentType<{ className?: string; strokeWidth?: number }>;\n  title: string;\n  description: string;\n};\n\nconst features: Feature[] = [\n  {\n    icon: Lock,\n    title: \"End-to-End Encryption\",\n    description:\n      \"Every message encrypted with X25519 ECDH + XChaCha20-Poly1305 before leaving your device. No one but you and the AI can read it.\",\n  },\n  {\n    icon: Image,\n    title: \"Encrypted Image Analysis\",\n    description:\n      \"Send images for AI analysis. Images are encrypted client-side and processed securely via GPT-4o vision, then wiped from memory.\",\n  },\n  {\n    icon: Radio,\n    title: \"Streaming Responses\",\n    description:\n      \"Real-time AI responses delivered via Server-Sent Events. Watch answers appear token-by-token as they are generated.\",\n  },\n  {\n    icon: ShieldCheck,\n    title: \"Memory Protection\",\n    description:\n      \"Automatic zeroization ensures all sensitive data — keys, plaintext, images — is wiped from server memory immediately after use.\",\n  },\n  {\n    icon: KeyRound,\n    title: \"Powerful Authentication\",\n    description:\n      \"Secure session management backed by Supabase authentication with ES256 token validation and audience checks.\",\n  },\n  {\n    icon: Server,\n    title: \"Hardened Deployment\",\n    description:\n      \"Alpine-based containers running as non-root with read-only filesystems. Minimal attack surface by design.\",\n  },\n];\n\nexport function CapsuleFeatures() {\n  const { ref: sectionRef, isVisible } = useFadeInOnScroll<HTMLElement>();\n\n  return (\n    <section\n      id='features'\n      ref={sectionRef}\n      className='w-full bg-black text-white'\n    >\n      <div\n        className={cx(\n          \"container mx-auto max-w-6xl px-8 py-30\",\n          \"transition-all duration-700 ease-out\",\n          isVisible ? \"opacity-100 translate-y-0\" : \"opacity-0 translate-y-6\",\n        )}\n      >\n        <div className='mb-14 text-left'>\n          <h2 className='text-3xl font-bold tracking-tight md:text-4xl'>\n            Built for Security,{\" \"}\n            <span className='text-neutral-500'>from the Ground Up</span>\n          </h2>\n        </div>\n\n        <div className='grid gap-8 md:grid-cols-2 lg:grid-cols-3'>\n          {features.map((feature, index) => {\n            const Icon = feature.icon;\n            return (\n              <div\n                key={feature.title}\n                className={cx(\n                  \"space-y-4 border border-white/10 p-6 transition-all duration-700 ease-out\",\n                  isVisible\n                    ? \"opacity-100 translate-y-0\"\n                    : \"opacity-0 translate-y-4\",\n                )}\n                style={{ transitionDelay: `${index * 100}ms` }}\n              >\n                <div className='flex h-12 w-12 items-center justify-center bg-white/5 border border-white/10'>\n                  <Icon className='h-6 w-6 text-white/80' strokeWidth={1.5} />\n                </div>\n                <h3 className='text-lg font-semibold text-white'>\n                  {feature.title}\n                </h3>\n                <p className='text-sm leading-relaxed text-white/55'>\n                  {feature.description}\n                </p>\n              </div>\n            );\n          })}\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/capsule/CapsuleFooterCta.tsx",
    "content": "import { useFadeInOnScroll } from \"@/lib/useFadeInOnScroll\";\n\nfunction cx(...classes: Array<string | false | null | undefined>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\nexport function CapsuleFooterCta() {\n  const { ref: sectionRef, isVisible } = useFadeInOnScroll<HTMLElement>();\n\n  return (\n    <section ref={sectionRef} className='w-full bg-black text-white'>\n      <div\n        className={cx(\n          \"container mx-auto max-w-6xl px-8 md:px-10 py-24 md:py-32\",\n          \"transition-all duration-700 ease-out\",\n          isVisible ? \"opacity-100 translate-y-0\" : \"opacity-0 translate-y-6\",\n        )}\n      >\n        <div className='mx-auto flex max-w-3xl flex-col items-center text-center'>\n          <h2 className='text-4xl font-semibold tracking-tight md:text-5xl'>\n            <span className='text-white/50'>Built</span>{\" \"}\n            <span className='text-white'>for privacy</span>\n          </h2>\n\n          <p className='mt-5 max-w-2xl text-lg leading-relaxed text-white/75 md:text-xl'>\n            Capsule ensures your AI conversations remain truly private.\n            End-to-end encrypted, zero-knowledge, and built on Rust.\n          </p>\n\n          <div className='mt-10'>\n            <button\n              className={cx(\n                \"inline-flex h-12 items-center justify-center px-6\",\n                \"border border-white/25 bg-transparent text-base font-medium text-white\",\n                \"transition-colors hover:border-white/45 hover:bg-white/5\",\n                \"focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30 cursor-pointer\",\n              )}\n              onClick={() => (location.href = \"/pricing\")}\n            >\n              Get Started\n            </button>\n          </div>\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/capsule/CapsuleHero.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\n\nexport function CapsuleHero() {\n  return (\n    <section className='flex h-[600px] flex-col items-center justify-center px-8 md:px-10'>\n      <div className='flex flex-col items-center gap-y-6'>\n        <h1 className='text-4xl md:text-6xl lg:text-6xl md:leading-17 leading-10 md:font-bold font-semibold tracking-tight text-center'>\n          Capsule\n        </h1>\n        <div className='flex items-center gap-x-4 pt-2'>\n          <Button\n            variant='default'\n            onClick={() => (location.href = \"/pricing\")}\n          >\n            Get Started\n          </Button>\n          <Button\n            variant='outline'\n            onClick={() =>\n              (location.href = \"https://github.com/cartesiancs/vessel\")\n            }\n          >\n            Learn More\n          </Button>\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/capsule/CapsuleHowItWorks.tsx",
    "content": "import { useFadeInOnScroll } from \"@/lib/useFadeInOnScroll\";\nimport EncryptionFlowIllustration from \"./EncryptionFlowIllustration\";\n\nfunction cx(...classes: Array<string | false | null | undefined>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\nconst steps = [\n  {\n    number: \"01\",\n    title: \"Key Exchange\",\n    description:\n      \"X25519 ECDH establishes a shared secret between your device and the server. No keys are ever transmitted in plaintext.\",\n  },\n  {\n    number: \"02\",\n    title: \"Message Sealing\",\n    description:\n      \"XChaCha20-Poly1305 encrypts every message with a unique nonce. Authenticated encryption prevents any tampering.\",\n  },\n  {\n    number: \"03\",\n    title: \"Zero Residue\",\n    description:\n      \"After processing, all plaintext, keys, and decrypted images are automatically zeroized from memory. Nothing persists.\",\n  },\n];\n\nexport function CapsuleHowItWorks() {\n  const { ref: sectionRef, isVisible } = useFadeInOnScroll<HTMLElement>();\n\n  return (\n    <section\n      ref={sectionRef}\n      className='h-full w-full bg-neutral-950 text-white'\n    >\n      <div\n        className={cx(\n          \"container mx-auto flex h-full max-w-6xl flex-col gap-12 px-8 py-30 md:flex-row md:items-center md:justify-between\",\n          \"transition-all duration-700 ease-out\",\n          isVisible ? \"opacity-100 translate-y-0\" : \"opacity-0 translate-y-6\",\n        )}\n      >\n        <div className='w-full md:w-[46%]'>\n          <h2 className='text-4xl font-semibold tracking-tight md:text-5xl'>\n            How Capsule\n            <br />\n            <span className='font-serif font-medium'>Protects You</span>\n          </h2>\n\n          <div className='mt-10 space-y-8'>\n            {steps.map((step) => (\n              <div key={step.number} className='flex gap-4'>\n                <div className='flex h-8 w-8 shrink-0 items-center justify-center border border-white/20 text-sm font-semibold text-white'>\n                  {step.number}\n                </div>\n                <div>\n                  <h3 className='text-base font-semibold'>{step.title}</h3>\n                  <p className='mt-1 text-sm leading-relaxed text-white/55'>\n                    {step.description}\n                  </p>\n                </div>\n              </div>\n            ))}\n          </div>\n        </div>\n\n        <div className='w-full md:w-[54%]'>\n          <div className='relative h-[260px] w-full md:h-[320px]'>\n            <EncryptionFlowIllustration />\n          </div>\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/capsule/CapsuleSecurity.tsx",
    "content": "import { useFadeInOnScroll } from \"@/lib/useFadeInOnScroll\";\nimport { ShieldCheck } from \"lucide-react\";\n\nfunction cx(...classes: Array<string | false | null | undefined>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\nexport function CapsuleSecurity() {\n  const { ref: sectionRef, isVisible } = useFadeInOnScroll<HTMLElement>();\n\n  return (\n    <section ref={sectionRef} className='h-full w-full bg-black text-white'>\n      <div\n        className={cx(\n          \"container mx-auto flex h-full max-w-6xl flex-col gap-12 px-8 py-40 md:flex-row md:items-center md:justify-between\",\n          \"transition-all duration-700 ease-out\",\n          isVisible ? \"opacity-100 translate-y-0\" : \"opacity-0 translate-y-6\",\n        )}\n      >\n        <div className='w-full md:w-[46%]'>\n          <h2 className='text-4xl font-semibold tracking-tight md:text-5xl'>\n            Zero Knowledge\n            <br />\n            Architecture.\n            <br />\n            <span className='font-serif font-medium'>Absolute Encryption</span>\n          </h2>\n\n          <p className='mt-6 text-base leading-relaxed text-white/55'>\n            Capsule cannot read your messages. The server processes encrypted\n            payloads and returns encrypted results. Even in a breach, your data\n            remains unreadable. Private keys exist only in memory and are never\n            persisted to disk.\n          </p>\n\n          <div className='mt-8 flex flex-wrap items-center gap-4'>\n            <button\n              type='button'\n              className='inline-flex h-11 items-center justify-center bg-white px-5 text-sm font-medium text-black transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 cursor-pointer'\n              onClick={() => (location.href = \"/privacy\")}\n            >\n              View Privacy Policy\n            </button>\n\n            <button\n              type='button'\n              className='inline-flex h-11 items-center justify-center border border-white/30 bg-transparent px-5 text-sm font-medium text-white transition-colors hover:border-white/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 cursor-pointer'\n              onClick={() => (location.href = \"/pricing\")}\n            >\n              See Pricing\n            </button>\n          </div>\n        </div>\n\n        <div className='w-full md:w-[54%]'>\n          <div className='relative flex h-[260px] w-full items-center justify-center md:h-[320px]'>\n            {/* Decorative concentric rings */}\n            <div className='absolute h-64 w-64 rounded-full border border-white/5' />\n            <div className='absolute h-48 w-48 rounded-full border border-white/8' />\n            <div className='absolute h-32 w-32 rounded-full border border-white/12' />\n            <ShieldCheck\n              className='relative h-16 w-16 text-white/60'\n              strokeWidth={1.2}\n            />\n          </div>\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/capsule/CapsuleSubheading.tsx",
    "content": "import { useEffect, useRef, useState } from \"react\";\n\nconst TITLE = \"Zero knowledge LLM call\";\n\nexport function CapsuleSubheading() {\n  const sectionRef = useRef<HTMLElement | null>(null);\n  const [isVisible, setIsVisible] = useState(false);\n\n  useEffect(() => {\n    const node = sectionRef.current;\n    if (!node) return;\n\n    const observer = new IntersectionObserver(\n      (entries) => {\n        entries.forEach((entry) => {\n          if (entry.isIntersecting) {\n            setIsVisible(true);\n            observer.disconnect();\n          }\n        });\n      },\n      { threshold: 0.35 },\n    );\n\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  return (\n    <section\n      ref={sectionRef}\n      className='flex h-[400px] items-center justify-center px-6'\n    >\n      <h1 className='flex flex-wrap items-center justify-center gap-2 text-center text-neutral-200 text-[48px] font-semibold leading-tight tracking-tight'>\n        {TITLE.split(\" \").map((word, index) => (\n          <span\n            key={word + index}\n            className={`transform-gpu transition-all duration-700 ease-out ${\n              isVisible\n                ? \"translate-y-0 opacity-100 blur-0\"\n                : \"translate-y-2 opacity-0 blur-sm\"\n            }`}\n            style={{ transitionDelay: `${index * 80}ms` }}\n          >\n            {word}\n          </span>\n        ))}\n      </h1>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/capsule/CapsuleUsecases.tsx",
    "content": "import { useMemo, useState } from \"react\";\nimport { ChevronDown, MessageSquareLock, ScanEye, Users } from \"lucide-react\";\nimport { useFadeInOnScroll } from \"@/lib/useFadeInOnScroll\";\nimport type { ComponentType } from \"react\";\n\nfunction cx(...classes: Array<string | false | null | undefined>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\ntype Usecase = {\n  title: string;\n  description: string;\n  icon: ComponentType<{ className?: string; strokeWidth?: number }>;\n};\n\nconst usecases: Usecase[] = [\n  {\n    title: \"Private AI Assistant\",\n    description:\n      \"Ask sensitive questions — financial, medical, legal — without your data being stored or shared. Your queries stay yours, protected by end-to-end encryption.\",\n    icon: MessageSquareLock,\n  },\n  {\n    title: \"Secure Image Analysis\",\n    description:\n      \"Upload encrypted photos for AI analysis. Medical scans, documents, personal photos — analyzed privately by GPT-4o vision, never stored on the server.\",\n    icon: ScanEye,\n  },\n  {\n    title: \"Team-Safe Communication\",\n    description:\n      \"Subscription-based access with intelligent rate limiting ensures fair usage. JWT authentication keeps every session isolated and accountable.\",\n    icon: Users,\n  },\n];\n\nexport function CapsuleUsecases() {\n  const { ref: sectionRef, isVisible } = useFadeInOnScroll<HTMLElement>();\n  const [activeIndex, setActiveIndex] = useState(0);\n\n  const active = useMemo(() => usecases[activeIndex], [activeIndex]);\n\n  return (\n    <section ref={sectionRef} className='w-full h-[100vh]'>\n      <div\n        className={cx(\n          \"container mx-auto max-w-6xl px-8 md:px-10 py-16 transition-all duration-700 ease-out\",\n          \"h-full flex flex-col\",\n          isVisible ? \"opacity-100 translate-y-0\" : \"opacity-0 translate-y-6\",\n        )}\n      >\n        <div className='mb-12 text-left'>\n          <h2 className='mt-3 text-3xl font-bold tracking-tight md:text-4xl'>\n            Use Cases{\" \"}\n            <span className='text-neutral-500'>for Every Scenario</span>\n          </h2>\n        </div>\n\n        <div className='flex w-full flex-1 flex-col gap-6 lg:flex-row min-h-0'>\n          <div className='w-full lg:w-[30%] h-full min-h-0'>\n            <div className='h-full overflow-auto'>\n              {usecases.map((u, idx) => {\n                const isOpen = idx === activeIndex;\n                return (\n                  <div key={u.title} className='border-b border-gray-500'>\n                    <button\n                      type='button'\n                      onClick={() => setActiveIndex(idx)}\n                      className={cx(\n                        \"flex w-full items-center justify-between gap-4 py-4 text-left cursor-pointer\",\n                        \"focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n                      )}\n                      aria-expanded={isOpen}\n                      aria-controls={`capsule-usecase-panel-${idx}`}\n                      id={`capsule-usecase-trigger-${idx}`}\n                    >\n                      <div className='min-w-0'>\n                        <div className='text-base font-semibold leading-snug'>\n                          {u.title}\n                        </div>\n                      </div>\n\n                      <ChevronDown\n                        className={cx(\n                          \"h-5 w-5 flex-none transition-transform duration-200\",\n                          isOpen ? \"rotate-180\" : \"rotate-0\",\n                        )}\n                        aria-hidden='true'\n                      />\n                    </button>\n\n                    <div\n                      id={`capsule-usecase-panel-${idx}`}\n                      role='region'\n                      aria-labelledby={`capsule-usecase-trigger-${idx}`}\n                      className={cx(\n                        \"overflow-hidden transition-[max-height] duration-300 ease-out\",\n                        isOpen ? \"max-h-40\" : \"max-h-0\",\n                      )}\n                    >\n                      <div\n                        className={cx(\n                          \"pb-4 text-sm text-muted-foreground transition-opacity duration-300 ease-out\",\n                          isOpen ? \"opacity-100\" : \"opacity-0\",\n                        )}\n                      >\n                        {u.description}\n                      </div>\n                    </div>\n                  </div>\n                );\n              })}\n            </div>\n          </div>\n\n          <div className='relative w-full lg:w-[70%] h-full min-h-0'>\n            <div className='absolute inset-0 flex items-center justify-center bg-gradient-to-br from-white/5 to-white/0 border border-white/10'>\n              <active.icon\n                className='h-32 w-32 text-white/20 transition-all duration-500'\n                strokeWidth={1}\n              />\n            </div>\n          </div>\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/components/sections/capsule/EncryptionFlowIllustration.tsx",
    "content": "function EncryptionFlowIllustration() {\n  return (\n    <svg\n      viewBox='0 0 900 360'\n      className='h-full w-full'\n      role='img'\n      aria-label='Encryption flow diagram showing Client to Server to AI'\n    >\n      <defs>\n        {/* Subtle dot grid instead of lines — cleaner, more tactical */}\n        <pattern\n          id='dot-grid'\n          width='24'\n          height='24'\n          patternUnits='userSpaceOnUse'\n        >\n          <circle cx='0.5' cy='0.5' r='0.5' fill='rgba(255,255,255,0.07)' />\n        </pattern>\n\n        {/* Scanline overlay */}\n        <pattern\n          id='scanlines'\n          width='4'\n          height='4'\n          patternUnits='userSpaceOnUse'\n        >\n          <line\n            x1='0'\n            y1='0'\n            x2='4'\n            y2='0'\n            stroke='rgba(255,255,255,0.015)'\n            strokeWidth='1'\n          />\n        </pattern>\n\n        {/* Glow filter for nodes */}\n        <filter id='node-glow' x='-50%' y='-50%' width='200%' height='200%'>\n          <feGaussianBlur in='SourceGraphic' stdDeviation='6' result='blur' />\n          <feComposite in='SourceGraphic' in2='blur' operator='over' />\n        </filter>\n\n        {/* Ping pulse filter */}\n        <filter id='ping-blur' x='-100%' y='-100%' width='300%' height='300%'>\n          <feGaussianBlur stdDeviation='3' />\n        </filter>\n\n        <style>\n          {`\n            @keyframes dash-flow {\n              to { stroke-dashoffset: -36; }\n            }\n            @keyframes pulse-ring {\n              0% { r: 36; opacity: 0.3; }\n              100% { r: 56; opacity: 0; }\n            }\n            @keyframes packet-pulse {\n              0%, 100% { opacity: 0.9; }\n              50% { opacity: 0.4; }\n            }\n            @keyframes status-blink {\n              0%, 90% { opacity: 1; }\n              95% { opacity: 0.2; }\n              100% { opacity: 1; }\n            }\n            .flow-line {\n              stroke: rgba(255,255,255,0.1);\n              stroke-width: 1;\n              fill: none;\n            }\n            .flow-dash {\n              stroke: rgba(255,255,255,0.3);\n              stroke-dasharray: 6 12;\n              stroke-width: 1;\n              fill: none;\n              animation: dash-flow 2s linear infinite;\n            }\n            .node-label {\n              font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;\n              letter-spacing: 0.08em;\n              text-transform: uppercase;\n            }\n            .status-dot {\n              animation: status-blink 3s ease-in-out infinite;\n            }\n          `}\n        </style>\n\n        {/* Main route path */}\n        <path\n          id='main-route'\n          d='M150,180 C260,180 310,120 450,120 C590,120 640,180 750,180'\n        />\n\n        {/* Return route (faint) */}\n        <path\n          id='return-route'\n          d='M750,180 C640,180 590,240 450,240 C310,240 260,180 150,180'\n        />\n      </defs>\n\n      {/* Background layers */}\n      <rect width='900' height='360' fill='url(#dot-grid)' />\n      <rect width='900' height='360' fill='url(#scanlines)' />\n\n      {/* Coordinate markers — subtle tactical framing */}\n      <text\n        x='16'\n        y='20'\n        fill='rgba(255,255,255,0.1)'\n        fontSize='8'\n        fontFamily='monospace'\n      >\n        00:00\n      </text>\n      <text\n        x='860'\n        y='20'\n        fill='rgba(255,255,255,0.1)'\n        fontSize='8'\n        fontFamily='monospace'\n        textAnchor='end'\n      >\n        SEC.FLOW\n      </text>\n      <text\n        x='16'\n        y='352'\n        fill='rgba(255,255,255,0.1)'\n        fontSize='8'\n        fontFamily='monospace'\n      >\n        E2E.ENC\n      </text>\n\n      {/* Corner brackets — military HUD style */}\n      {/* Top-left */}\n      <path\n        d='M8,28 L8,8 L28,8'\n        fill='none'\n        stroke='rgba(255,255,255,0.08)'\n        strokeWidth='1'\n      />\n      {/* Top-right */}\n      <path\n        d='M872,8 L892,8 L892,28'\n        fill='none'\n        stroke='rgba(255,255,255,0.08)'\n        strokeWidth='1'\n      />\n      {/* Bottom-left */}\n      <path\n        d='M8,332 L8,352 L28,352'\n        fill='none'\n        stroke='rgba(255,255,255,0.08)'\n        strokeWidth='1'\n      />\n      {/* Bottom-right */}\n      <path\n        d='M872,352 L892,352 L892,332'\n        fill='none'\n        stroke='rgba(255,255,255,0.08)'\n        strokeWidth='1'\n      />\n\n      {/* Connection lines — forward route */}\n      <use href='#main-route' className='flow-line' />\n      <use href='#main-route' className='flow-dash' />\n\n      {/* Return route — barely visible */}\n      <path\n        d='M750,180 C640,180 590,240 450,240 C310,240 260,180 150,180'\n        stroke='rgba(255,255,255,0.04)'\n        strokeWidth='1'\n        strokeDasharray='2 16'\n        fill='none'\n      />\n\n      {/* Animated packet — forward */}\n      <g>\n        <g style={{ animation: \"packet-pulse 1.5s ease-in-out infinite\" }}>\n          <rect\n            width='12'\n            height='4'\n            rx='1'\n            fill='rgba(255,255,255,0.6)'\n            x='-6'\n            y='-2'\n          >\n            <animateMotion dur='3.5s' repeatCount='indefinite'>\n              <mpath href='#main-route' />\n            </animateMotion>\n          </rect>\n          <rect\n            width='12'\n            height='4'\n            rx='1'\n            fill='rgba(255,255,255,0.15)'\n            x='-6'\n            y='-2'\n            filter='url(#ping-blur)'\n          >\n            <animateMotion dur='3.5s' repeatCount='indefinite'>\n              <mpath href='#main-route' />\n            </animateMotion>\n          </rect>\n        </g>\n      </g>\n\n      {/* Second packet — staggered */}\n      <g opacity='0.4'>\n        <rect width='6' height='2' rx='1' fill='rgba(255,255,255,0.5)'>\n          <animateMotion dur='3.5s' repeatCount='indefinite' begin='1.75s'>\n            <mpath href='#main-route' />\n          </animateMotion>\n        </rect>\n      </g>\n\n      {/* ==================== CLIENT NODE ==================== */}\n      <g transform='translate(150,180)'>\n        {/* Pulse ring */}\n        <circle\n          r='36'\n          fill='none'\n          stroke='rgba(255,255,255,0.15)'\n          strokeWidth='0.5'\n        >\n          <animate\n            attributeName='r'\n            values='36;56'\n            dur='3s'\n            repeatCount='indefinite'\n          />\n          <animate\n            attributeName='opacity'\n            values='0.3;0'\n            dur='3s'\n            repeatCount='indefinite'\n          />\n        </circle>\n\n        {/* Crosshair marks */}\n        <line\n          x1='-44'\n          y1='0'\n          x2='-38'\n          y2='0'\n          stroke='rgba(255,255,255,0.15)'\n          strokeWidth='0.5'\n        />\n        <line\n          x1='38'\n          y1='0'\n          x2='44'\n          y2='0'\n          stroke='rgba(255,255,255,0.15)'\n          strokeWidth='0.5'\n        />\n        <line\n          x1='0'\n          y1='-44'\n          x2='0'\n          y2='-38'\n          stroke='rgba(255,255,255,0.15)'\n          strokeWidth='0.5'\n        />\n        <line\n          x1='0'\n          y1='38'\n          x2='0'\n          y2='44'\n          stroke='rgba(255,255,255,0.15)'\n          strokeWidth='0.5'\n        />\n\n        {/* Main circle */}\n        <circle\n          r='36'\n          fill='rgba(255,255,255,0.03)'\n          stroke='rgba(255,255,255,0.2)'\n          strokeWidth='1'\n        />\n\n        {/* Inner ring */}\n        <circle\n          r='28'\n          fill='none'\n          stroke='rgba(255,255,255,0.06)'\n          strokeWidth='0.5'\n          strokeDasharray='2 4'\n        />\n\n        {/* Label */}\n        <text\n          textAnchor='middle'\n          y='4'\n          fill='rgba(255,255,255,0.85)'\n          fontSize='11'\n          className='node-label'\n        >\n          Client\n        </text>\n\n        {/* Status */}\n        <g transform='translate(0,60)'>\n          <circle\n            cx='-18'\n            cy='0'\n            r='2'\n            fill='rgba(255,255,255,0.5)'\n            className='status-dot'\n          />\n          <text\n            x='-10'\n            y='3'\n            fill='rgba(255,255,255,0.3)'\n            fontSize='9'\n            fontFamily='monospace'\n            letterSpacing='0.05em'\n          >\n            ENCRYPT\n          </text>\n        </g>\n      </g>\n\n      {/* ==================== SERVER NODE ==================== */}\n      <g transform='translate(450,120)'>\n        {/* Pulse ring */}\n        <circle\n          r='36'\n          fill='none'\n          stroke='rgba(255,255,255,0.15)'\n          strokeWidth='0.5'\n        >\n          <animate\n            attributeName='r'\n            values='36;56'\n            dur='3s'\n            begin='1s'\n            repeatCount='indefinite'\n          />\n          <animate\n            attributeName='opacity'\n            values='0.3;0'\n            dur='3s'\n            begin='1s'\n            repeatCount='indefinite'\n          />\n        </circle>\n\n        {/* Crosshair marks */}\n        <line\n          x1='-44'\n          y1='0'\n          x2='-38'\n          y2='0'\n          stroke='rgba(255,255,255,0.15)'\n          strokeWidth='0.5'\n        />\n        <line\n          x1='38'\n          y1='0'\n          x2='44'\n          y2='0'\n          stroke='rgba(255,255,255,0.15)'\n          strokeWidth='0.5'\n        />\n        <line\n          x1='0'\n          y1='-44'\n          x2='0'\n          y2='-38'\n          stroke='rgba(255,255,255,0.15)'\n          strokeWidth='0.5'\n        />\n        <line\n          x1='0'\n          y1='38'\n          x2='0'\n          y2='44'\n          stroke='rgba(255,255,255,0.15)'\n          strokeWidth='0.5'\n        />\n\n        {/* Main circle */}\n        <circle\n          r='36'\n          fill='rgba(255,255,255,0.03)'\n          stroke='rgba(255,255,255,0.25)'\n          strokeWidth='1'\n        />\n\n        {/* Inner ring */}\n        <circle\n          r='28'\n          fill='none'\n          stroke='rgba(255,255,255,0.06)'\n          strokeWidth='0.5'\n          strokeDasharray='2 4'\n        />\n\n        {/* Lock icon — refined */}\n        <rect\n          x='-5'\n          y='-2'\n          width='10'\n          height='8'\n          rx='1.5'\n          fill='rgba(255,255,255,0.15)'\n          stroke='rgba(255,255,255,0.5)'\n          strokeWidth='0.8'\n        />\n        <path\n          d='M-2.5,-2 L-2.5,-6 A2.5,2.5 0 0,1 2.5,-6 L2.5,-2'\n          fill='none'\n          stroke='rgba(255,255,255,0.5)'\n          strokeWidth='0.8'\n        />\n        <circle cx='0' cy='2' r='1' fill='rgba(255,255,255,0.4)' />\n\n        {/* Label */}\n        <text\n          textAnchor='middle'\n          y='18'\n          fill='rgba(255,255,255,0.85)'\n          fontSize='11'\n          className='node-label'\n        >\n          Capsule\n        </text>\n\n        {/* Status */}\n        <g transform='translate(0,60)'>\n          <circle\n            cx='-18'\n            cy='0'\n            r='2'\n            fill='rgba(255,255,255,0.5)'\n            className='status-dot'\n          />\n          <text\n            x='-10'\n            y='3'\n            fill='rgba(255,255,255,0.3)'\n            fontSize='9'\n            fontFamily='monospace'\n            letterSpacing='0.05em'\n          >\n            PROCESS\n          </text>\n        </g>\n      </g>\n\n      {/* ==================== AI NODE ==================== */}\n      <g transform='translate(750,180)'>\n        {/* Pulse ring */}\n        <circle\n          r='36'\n          fill='none'\n          stroke='rgba(255,255,255,0.15)'\n          strokeWidth='0.5'\n        >\n          <animate\n            attributeName='r'\n            values='36;56'\n            dur='3s'\n            begin='2s'\n            repeatCount='indefinite'\n          />\n          <animate\n            attributeName='opacity'\n            values='0.3;0'\n            dur='3s'\n            begin='2s'\n            repeatCount='indefinite'\n          />\n        </circle>\n\n        {/* Crosshair marks */}\n        <line\n          x1='-44'\n          y1='0'\n          x2='-38'\n          y2='0'\n          stroke='rgba(255,255,255,0.15)'\n          strokeWidth='0.5'\n        />\n        <line\n          x1='38'\n          y1='0'\n          x2='44'\n          y2='0'\n          stroke='rgba(255,255,255,0.15)'\n          strokeWidth='0.5'\n        />\n        <line\n          x1='0'\n          y1='-44'\n          x2='0'\n          y2='-38'\n          stroke='rgba(255,255,255,0.15)'\n          strokeWidth='0.5'\n        />\n        <line\n          x1='0'\n          y1='38'\n          x2='0'\n          y2='44'\n          stroke='rgba(255,255,255,0.15)'\n          strokeWidth='0.5'\n        />\n\n        {/* Main circle */}\n        <circle\n          r='36'\n          fill='rgba(255,255,255,0.03)'\n          stroke='rgba(255,255,255,0.2)'\n          strokeWidth='1'\n        />\n\n        {/* Inner ring */}\n        <circle\n          r='28'\n          fill='none'\n          stroke='rgba(255,255,255,0.06)'\n          strokeWidth='0.5'\n          strokeDasharray='2 4'\n        />\n\n        {/* Label */}\n        <text\n          textAnchor='middle'\n          y='4'\n          fill='rgba(255,255,255,0.85)'\n          fontSize='11'\n          className='node-label'\n        >\n          AI\n        </text>\n\n        {/* Status */}\n        <g transform='translate(0,60)'>\n          <circle\n            cx='-20'\n            cy='0'\n            r='2'\n            fill='rgba(255,255,255,0.5)'\n            className='status-dot'\n          />\n          <text\n            x='-12'\n            y='3'\n            fill='rgba(255,255,255,0.3)'\n            fontSize='9'\n            fontFamily='monospace'\n            letterSpacing='0.05em'\n          >\n            RESPOND\n          </text>\n        </g>\n      </g>\n\n      {/* Midpoint labels on path */}\n      <text\n        x='290'\n        y='145'\n        fill='rgba(255,255,255,0.12)'\n        fontSize='7'\n        fontFamily='monospace'\n        letterSpacing='0.1em'\n      >\n        TLS 1.3\n      </text>\n      <text\n        x='590'\n        y='145'\n        fill='rgba(255,255,255,0.12)'\n        fontSize='7'\n        fontFamily='monospace'\n        letterSpacing='0.1em'\n      >\n        AES-256\n      </text>\n    </svg>\n  );\n}\n\nexport default EncryptionFlowIllustration;\n"
  },
  {
    "path": "apps/landing/src/components/ui/alert-dialog.tsx",
    "content": "import * as React from \"react\";\nimport * as AlertDialogPrimitive from \"@radix-ui/react-alert-dialog\";\n\nimport { cn } from \"@/lib/utils\";\nimport { buttonVariants } from \"@/components/ui/button\";\n\nfunction AlertDialog({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {\n  return <AlertDialogPrimitive.Root data-slot=\"alert-dialog\" {...props} />;\n}\n\nfunction AlertDialogTrigger({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {\n  return (\n    <AlertDialogPrimitive.Trigger data-slot=\"alert-dialog-trigger\" {...props} />\n  );\n}\n\nfunction AlertDialogPortal({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {\n  return (\n    <AlertDialogPrimitive.Portal data-slot=\"alert-dialog-portal\" {...props} />\n  );\n}\n\nfunction AlertDialogOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {\n  return (\n    <AlertDialogPrimitive.Overlay\n      data-slot=\"alert-dialog-overlay\"\n      className={cn(\n        \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50\",\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {\n  return (\n    <AlertDialogPortal>\n      <AlertDialogOverlay />\n      <AlertDialogPrimitive.Content\n        data-slot=\"alert-dialog-content\"\n        className={cn(\n          \"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg\",\n          className\n        )}\n        {...props}\n      />\n    </AlertDialogPortal>\n  );\n}\n\nfunction AlertDialogHeader({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-dialog-header\"\n      className={cn(\"flex flex-col gap-2 text-center sm:text-left\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogFooter({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-dialog-footer\"\n      className={cn(\n        \"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\",\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {\n  return (\n    <AlertDialogPrimitive.Title\n      data-slot=\"alert-dialog-title\"\n      className={cn(\"text-lg font-semibold\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {\n  return (\n    <AlertDialogPrimitive.Description\n      data-slot=\"alert-dialog-description\"\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogAction({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {\n  return (\n    <AlertDialogPrimitive.Action\n      className={cn(buttonVariants(), className)}\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogCancel({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {\n  return (\n    <AlertDialogPrimitive.Cancel\n      className={cn(buttonVariants({ variant: \"outline\" }), className)}\n      {...props}\n    />\n  );\n}\n\nexport {\n  AlertDialog,\n  AlertDialogPortal,\n  AlertDialogOverlay,\n  AlertDialogTrigger,\n  AlertDialogContent,\n  AlertDialogHeader,\n  AlertDialogFooter,\n  AlertDialogTitle,\n  AlertDialogDescription,\n  AlertDialogAction,\n  AlertDialogCancel,\n};\n"
  },
  {
    "path": "apps/landing/src/components/ui/button.tsx",
    "content": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst buttonVariants = cva(\n  \"inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90\",\n        destructive:\n          \"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60\",\n        outline:\n          \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50\",\n        secondary:\n          \"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80\",\n        ghost:\n          \"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50\",\n        link: \"text-primary underline-offset-4 hover:underline\",\n      },\n      size: {\n        default: \"h-9 px-4 py-2 has-[>svg]:px-3\",\n        sm: \"h-8 gap-1.5 px-3 has-[>svg]:px-2.5\",\n        lg: \"h-10 px-6 has-[>svg]:px-4\",\n        icon: \"size-9\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n)\n\nfunction Button({\n  className,\n  variant,\n  size,\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"button\"> &\n  VariantProps<typeof buttonVariants> & {\n    asChild?: boolean\n  }) {\n  const Comp = asChild ? Slot : \"button\"\n\n  return (\n    <Comp\n      data-slot=\"button\"\n      className={cn(buttonVariants({ variant, size, className }))}\n      {...props}\n    />\n  )\n}\n\nexport { Button, buttonVariants }\n"
  },
  {
    "path": "apps/landing/src/components/ui/card.tsx",
    "content": "import * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Card({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='card'\n      className={cn(\n        \"bg-card text-card-foreground flex flex-col gap-6 border py-6 shadow-sm\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CardHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='card-header'\n      className={cn(\n        \"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CardTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='card-title'\n      className={cn(\"leading-none font-semibold\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction CardDescription({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='card-description'\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction CardAction({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='card-action'\n      className={cn(\n        \"col-start-2 row-span-2 row-start-1 self-start justify-self-end\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CardContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='card-content'\n      className={cn(\"px-6\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction CardFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot='card-footer'\n      className={cn(\"flex items-center px-6 [.border-t]:pt-6\", className)}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Card,\n  CardHeader,\n  CardFooter,\n  CardTitle,\n  CardAction,\n  CardDescription,\n  CardContent,\n};\n"
  },
  {
    "path": "apps/landing/src/components/ui/chart.tsx",
    "content": "import * as React from \"react\"\nimport * as RechartsPrimitive from \"recharts\"\n\nimport { cn } from \"@/lib/utils\"\n\n// Format: { THEME_NAME: CSS_SELECTOR }\nconst THEMES = { light: \"\", dark: \".dark\" } as const\n\nexport type ChartConfig = {\n  [k in string]: {\n    label?: React.ReactNode\n    icon?: React.ComponentType\n  } & (\n    | { color?: string; theme?: never }\n    | { color?: never; theme: Record<keyof typeof THEMES, string> }\n  )\n}\n\ntype ChartContextProps = {\n  config: ChartConfig\n}\n\nconst ChartContext = React.createContext<ChartContextProps | null>(null)\n\nfunction useChart() {\n  const context = React.useContext(ChartContext)\n\n  if (!context) {\n    throw new Error(\"useChart must be used within a <ChartContainer />\")\n  }\n\n  return context\n}\n\nfunction ChartContainer({\n  id,\n  className,\n  children,\n  config,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  config: ChartConfig\n  children: React.ComponentProps<\n    typeof RechartsPrimitive.ResponsiveContainer\n  >[\"children\"]\n}) {\n  const uniqueId = React.useId()\n  const chartId = `chart-${id || uniqueId.replace(/:/g, \"\")}`\n\n  return (\n    <ChartContext.Provider value={{ config }}>\n      <div\n        data-slot=\"chart\"\n        data-chart={chartId}\n        className={cn(\n          \"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-auto min-h-0 min-w-0 w-full max-w-full justify-center text-xs [&_.recharts-responsive-container]:!w-full [&_.recharts-responsive-container]:min-w-0 [&_.recharts-responsive-container]:max-w-full [&_.recharts-surface]:max-w-full [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden\",\n          className\n        )}\n        {...props}\n      >\n        <ChartStyle id={chartId} config={config} />\n        <RechartsPrimitive.ResponsiveContainer\n          width=\"100%\"\n          height=\"100%\"\n          minWidth={0}\n        >\n          {children}\n        </RechartsPrimitive.ResponsiveContainer>\n      </div>\n    </ChartContext.Provider>\n  )\n}\n\nconst ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {\n  const colorConfig = Object.entries(config).filter(\n    ([, config]) => config.theme || config.color\n  )\n\n  if (!colorConfig.length) {\n    return null\n  }\n\n  return (\n    <style\n      dangerouslySetInnerHTML={{\n        __html: Object.entries(THEMES)\n          .map(\n            ([theme, prefix]) => `\n${prefix} [data-chart=${id}] {\n${colorConfig\n  .map(([key, itemConfig]) => {\n    const color =\n      itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||\n      itemConfig.color\n    return color ? `  --color-${key}: ${color};` : null\n  })\n  .join(\"\\n\")}\n}\n`\n          )\n          .join(\"\\n\"),\n      }}\n    />\n  )\n}\n\nconst ChartTooltip = RechartsPrimitive.Tooltip\n\nfunction ChartTooltipContent({\n  active,\n  payload,\n  className,\n  indicator = \"dot\",\n  hideLabel = false,\n  hideIndicator = false,\n  label,\n  labelFormatter,\n  labelClassName,\n  formatter,\n  color,\n  nameKey,\n  labelKey,\n}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &\n  React.ComponentProps<\"div\"> & {\n    hideLabel?: boolean\n    hideIndicator?: boolean\n    indicator?: \"line\" | \"dot\" | \"dashed\"\n    nameKey?: string\n    labelKey?: string\n  }) {\n  const { config } = useChart()\n\n  const tooltipLabel = React.useMemo(() => {\n    if (hideLabel || !payload?.length) {\n      return null\n    }\n\n    const [item] = payload\n    const key = `${labelKey || item?.dataKey || item?.name || \"value\"}`\n    const itemConfig = getPayloadConfigFromPayload(config, item, key)\n    const value =\n      !labelKey && typeof label === \"string\"\n        ? config[label as keyof typeof config]?.label || label\n        : itemConfig?.label\n\n    if (labelFormatter) {\n      return (\n        <div className={cn(\"font-medium\", labelClassName)}>\n          {labelFormatter(value, payload)}\n        </div>\n      )\n    }\n\n    if (!value) {\n      return null\n    }\n\n    return <div className={cn(\"font-medium\", labelClassName)}>{value}</div>\n  }, [\n    label,\n    labelFormatter,\n    payload,\n    hideLabel,\n    labelClassName,\n    config,\n    labelKey,\n  ])\n\n  if (!active || !payload?.length) {\n    return null\n  }\n\n  const nestLabel = payload.length === 1 && indicator !== \"dot\"\n\n  return (\n    <div\n      className={cn(\n        \"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl\",\n        className\n      )}\n    >\n      {!nestLabel ? tooltipLabel : null}\n      <div className=\"grid gap-1.5\">\n        {payload\n          .filter((item) => item.type !== \"none\")\n          .map((item, index) => {\n            const key = `${nameKey || item.name || item.dataKey || \"value\"}`\n            const itemConfig = getPayloadConfigFromPayload(config, item, key)\n            const indicatorColor = color || item.payload.fill || item.color\n\n            return (\n              <div\n                key={item.dataKey}\n                className={cn(\n                  \"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5\",\n                  indicator === \"dot\" && \"items-center\"\n                )}\n              >\n                {formatter && item?.value !== undefined && item.name ? (\n                  formatter(item.value, item.name, item, index, item.payload)\n                ) : (\n                  <>\n                    {itemConfig?.icon ? (\n                      <itemConfig.icon />\n                    ) : (\n                      !hideIndicator && (\n                        <div\n                          className={cn(\n                            \"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)\",\n                            {\n                              \"h-2.5 w-2.5\": indicator === \"dot\",\n                              \"w-1\": indicator === \"line\",\n                              \"w-0 border-[1.5px] border-dashed bg-transparent\":\n                                indicator === \"dashed\",\n                              \"my-0.5\": nestLabel && indicator === \"dashed\",\n                            }\n                          )}\n                          style={\n                            {\n                              \"--color-bg\": indicatorColor,\n                              \"--color-border\": indicatorColor,\n                            } as React.CSSProperties\n                          }\n                        />\n                      )\n                    )}\n                    <div\n                      className={cn(\n                        \"flex flex-1 justify-between leading-none\",\n                        nestLabel ? \"items-end\" : \"items-center\"\n                      )}\n                    >\n                      <div className=\"grid gap-1.5\">\n                        {nestLabel ? tooltipLabel : null}\n                        <span className=\"text-muted-foreground\">\n                          {itemConfig?.label || item.name}\n                        </span>\n                      </div>\n                      {item.value && (\n                        <span className=\"text-foreground font-mono font-medium tabular-nums\">\n                          {item.value.toLocaleString()}\n                        </span>\n                      )}\n                    </div>\n                  </>\n                )}\n              </div>\n            )\n          })}\n      </div>\n    </div>\n  )\n}\n\nconst ChartLegend = RechartsPrimitive.Legend\n\nfunction ChartLegendContent({\n  className,\n  hideIcon = false,\n  payload,\n  verticalAlign = \"bottom\",\n  nameKey,\n}: React.ComponentProps<\"div\"> &\n  Pick<RechartsPrimitive.LegendProps, \"payload\" | \"verticalAlign\"> & {\n    hideIcon?: boolean\n    nameKey?: string\n  }) {\n  const { config } = useChart()\n\n  if (!payload?.length) {\n    return null\n  }\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-wrap items-center justify-center gap-x-4 gap-y-2\",\n        verticalAlign === \"top\" ? \"pb-3\" : \"pt-3\",\n        className\n      )}\n    >\n      {payload\n        .filter((item) => item.type !== \"none\")\n        .map((item) => {\n          const key = `${nameKey || item.dataKey || \"value\"}`\n          const itemConfig = getPayloadConfigFromPayload(config, item, key)\n\n          return (\n            <div\n              key={item.value}\n              className={cn(\n                \"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3\"\n              )}\n            >\n              {itemConfig?.icon && !hideIcon ? (\n                <itemConfig.icon />\n              ) : (\n                <div\n                  className=\"h-2 w-2 shrink-0 rounded-[2px]\"\n                  style={{\n                    backgroundColor: item.color,\n                  }}\n                />\n              )}\n              {itemConfig?.label}\n            </div>\n          )\n        })}\n    </div>\n  )\n}\n\n// Helper to extract item config from a payload.\nfunction getPayloadConfigFromPayload(\n  config: ChartConfig,\n  payload: unknown,\n  key: string\n) {\n  if (typeof payload !== \"object\" || payload === null) {\n    return undefined\n  }\n\n  const payloadPayload =\n    \"payload\" in payload &&\n    typeof payload.payload === \"object\" &&\n    payload.payload !== null\n      ? payload.payload\n      : undefined\n\n  let configLabelKey: string = key\n\n  if (\n    key in payload &&\n    typeof payload[key as keyof typeof payload] === \"string\"\n  ) {\n    configLabelKey = payload[key as keyof typeof payload] as string\n  } else if (\n    payloadPayload &&\n    key in payloadPayload &&\n    typeof payloadPayload[key as keyof typeof payloadPayload] === \"string\"\n  ) {\n    configLabelKey = payloadPayload[\n      key as keyof typeof payloadPayload\n    ] as string\n  }\n\n  return configLabelKey in config\n    ? config[configLabelKey]\n    : config[key as keyof typeof config]\n}\n\nexport {\n  ChartContainer,\n  ChartTooltip,\n  ChartTooltipContent,\n  ChartLegend,\n  ChartLegendContent,\n  ChartStyle,\n}\n"
  },
  {
    "path": "apps/landing/src/components/ui/navigation-menu.tsx",
    "content": "import * as React from \"react\";\nimport * as NavigationMenuPrimitive from \"@radix-ui/react-navigation-menu\";\nimport { cva } from \"class-variance-authority\";\nimport { ChevronDownIcon } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction NavigationMenu({\n  className,\n  children,\n  viewport = true,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {\n  viewport?: boolean;\n}) {\n  return (\n    <NavigationMenuPrimitive.Root\n      data-slot='navigation-menu'\n      data-viewport={viewport}\n      className={cn(\n        \"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n      {viewport && <NavigationMenuViewport />}\n    </NavigationMenuPrimitive.Root>\n  );\n}\n\nfunction NavigationMenuList({\n  className,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {\n  return (\n    <NavigationMenuPrimitive.List\n      data-slot='navigation-menu-list'\n      className={cn(\n        \"group flex flex-1 list-none items-center justify-center gap-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction NavigationMenuItem({\n  className,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {\n  return (\n    <NavigationMenuPrimitive.Item\n      data-slot='navigation-menu-item'\n      className={cn(\"relative\", className)}\n      {...props}\n    />\n  );\n}\n\nconst navigationMenuTriggerStyle = cva(\n  \"group inline-flex h-9 w-max items-center justify-center bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1\",\n);\n\nfunction NavigationMenuTrigger({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {\n  return (\n    <NavigationMenuPrimitive.Trigger\n      data-slot='navigation-menu-trigger'\n      className={cn(navigationMenuTriggerStyle(), \"group\", className)}\n      {...props}\n    >\n      {children}{\" \"}\n      <ChevronDownIcon\n        className='relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180'\n        aria-hidden='true'\n      />\n    </NavigationMenuPrimitive.Trigger>\n  );\n}\n\nfunction NavigationMenuContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {\n  return (\n    <NavigationMenuPrimitive.Content\n      data-slot='navigation-menu-content'\n      className={cn(\n        \"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto\",\n        \"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction NavigationMenuViewport({\n  className,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {\n  return (\n    <div\n      className={cn(\n        \"absolute top-full left-0 isolate z-50 flex justify-center\",\n      )}\n    >\n      <NavigationMenuPrimitive.Viewport\n        data-slot='navigation-menu-viewport'\n        className={cn(\n          \"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden border shadow md:w-[var(--radix-navigation-menu-viewport-width)]\",\n          className,\n        )}\n        {...props}\n      />\n    </div>\n  );\n}\n\nfunction NavigationMenuLink({\n  className,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {\n  return (\n    <NavigationMenuPrimitive.Link\n      data-slot='navigation-menu-link'\n      className={cn(\n        \"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction NavigationMenuIndicator({\n  className,\n  ...props\n}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {\n  return (\n    <NavigationMenuPrimitive.Indicator\n      data-slot='navigation-menu-indicator'\n      className={cn(\n        \"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden\",\n        className,\n      )}\n      {...props}\n    >\n      <div className='bg-border relative top-[60%] h-2 w-2 rotate-45 shadow-md' />\n    </NavigationMenuPrimitive.Indicator>\n  );\n}\n\nexport {\n  NavigationMenu,\n  NavigationMenuList,\n  NavigationMenuItem,\n  NavigationMenuContent,\n  NavigationMenuTrigger,\n  NavigationMenuLink,\n  NavigationMenuIndicator,\n  NavigationMenuViewport,\n  navigationMenuTriggerStyle,\n};\n"
  },
  {
    "path": "apps/landing/src/components/ui/sonner.tsx",
    "content": "import {\n  CircleCheckIcon,\n  InfoIcon,\n  Loader2Icon,\n  OctagonXIcon,\n  TriangleAlertIcon,\n} from \"lucide-react\"\nimport { useTheme } from \"next-themes\"\nimport { Toaster as Sonner, type ToasterProps } from \"sonner\"\n\nconst Toaster = ({ ...props }: ToasterProps) => {\n  const { theme = \"system\" } = useTheme()\n\n  return (\n    <Sonner\n      theme={theme as ToasterProps[\"theme\"]}\n      className=\"toaster group\"\n      icons={{\n        success: <CircleCheckIcon className=\"size-4\" />,\n        info: <InfoIcon className=\"size-4\" />,\n        warning: <TriangleAlertIcon className=\"size-4\" />,\n        error: <OctagonXIcon className=\"size-4\" />,\n        loading: <Loader2Icon className=\"size-4 animate-spin\" />,\n      }}\n      style={\n        {\n          \"--normal-bg\": \"var(--popover)\",\n          \"--normal-text\": \"var(--popover-foreground)\",\n          \"--normal-border\": \"var(--border)\",\n          \"--border-radius\": \"var(--radius)\",\n        } as React.CSSProperties\n      }\n      {...props}\n    />\n  )\n}\n\nexport { Toaster }\n"
  },
  {
    "path": "apps/landing/src/contexts/AuthContext.tsx",
    "content": "import {\n  createContext,\n  useContext,\n  useEffect,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport type { User, Session } from \"@supabase/supabase-js\";\nimport { supabase } from \"@/lib/supabase\";\n\ninterface AuthContextType {\n  user: User | null;\n  session: Session | null;\n  loading: boolean;\n  signInWithGoogle: () => Promise<void>;\n  signOut: () => Promise<void>;\n}\n\nconst AuthContext = createContext<AuthContextType | undefined>(undefined);\n\nexport function AuthProvider({ children }: { children: ReactNode }) {\n  const [user, setUser] = useState<User | null>(null);\n  const [session, setSession] = useState<Session | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  useEffect(() => {\n    supabase.auth.getSession().then(({ data: { session } }) => {\n      setSession(session);\n      setUser(session?.user ?? null);\n      setLoading(false);\n    });\n\n    const {\n      data: { subscription },\n    } = supabase.auth.onAuthStateChange((_event, session) => {\n      setSession(session);\n      setUser(session?.user ?? null);\n      setLoading(false);\n    });\n\n    return () => subscription.unsubscribe();\n  }, []);\n\n  const signInWithGoogle = async () => {\n    const { error } = await supabase.auth.signInWithOAuth({\n      provider: \"google\",\n      options: {\n        redirectTo: window.location.origin,\n      },\n    });\n    if (error) {\n      console.error(\"Error signing in with Google:\", error.message);\n    }\n  };\n\n  const signOut = async () => {\n    const { error } = await supabase.auth.signOut();\n    if (error) {\n      console.error(\"Error signing out:\", error.message);\n    }\n  };\n\n  return (\n    <AuthContext.Provider\n      value={{ user, session, loading, signInWithGoogle, signOut }}\n    >\n      {children}\n    </AuthContext.Provider>\n  );\n}\n\nexport function useAuth() {\n  const context = useContext(AuthContext);\n  if (context === undefined) {\n    throw new Error(\"useAuth must be used within an AuthProvider\");\n  }\n  return context;\n}\n"
  },
  {
    "path": "apps/landing/src/hooks/useUsageData.ts",
    "content": "import { useEffect, useState } from \"react\";\nimport { supabase } from \"@/lib/supabase\";\nimport type { User } from \"@supabase/supabase-js\";\n\nexport interface DailyUsage {\n  date: string;\n  turnBytes: number;\n  tunnelBytes: number;\n  capsuleRequests: number;\n  capsuleImageRequests: number;\n  capsuleInputTokens: number;\n  capsuleOutputTokens: number;\n}\n\nfunction getThirtyDaysAgo(): string {\n  const d = new Date();\n  d.setDate(d.getDate() - 30);\n  return d.toISOString().split(\"T\")[0];\n}\n\nfunction toDateKey(value: string): string {\n  return value.split(\"T\")[0];\n}\n\nexport function useUsageData(user: User | null) {\n  const [data, setData] = useState<DailyUsage[]>([]);\n  const [loading, setLoading] = useState(true);\n\n  useEffect(() => {\n    if (!user) {\n      setLoading(false);\n      return;\n    }\n\n    const fetchUsage = async () => {\n      setLoading(true);\n      const since = getThirtyDaysAgo();\n\n      const [turnRes, tunnelRes, capsuleRes] = await Promise.all([\n        supabase\n          .from(\"turn_usage_periods\")\n          .select(\"period_start, egress_bytes, ingress_bytes\")\n          .eq(\"user_id\", user.id)\n          .gte(\"period_start\", since),\n        supabase\n          .from(\"tunnel_usage\")\n          .select(\"recorded_at, inbound_bytes, outbound_bytes\")\n          .eq(\"user_id\", user.id)\n          .gte(\"recorded_at\", since),\n        supabase\n          .from(\"enclave_usage\")\n          .select(\"date, request_count, input_tokens, output_tokens, image_requests\")\n          .eq(\"user_id\", user.id)\n          .gte(\"date\", since),\n      ]);\n\n      const map = new Map<string, DailyUsage>();\n\n      const getOrCreate = (date: string): DailyUsage => {\n        let entry = map.get(date);\n        if (!entry) {\n          entry = {\n            date,\n            turnBytes: 0,\n            tunnelBytes: 0,\n            capsuleRequests: 0,\n            capsuleImageRequests: 0,\n            capsuleInputTokens: 0,\n            capsuleOutputTokens: 0,\n          };\n          map.set(date, entry);\n        }\n        return entry;\n      };\n\n      if (turnRes.data) {\n        for (const row of turnRes.data) {\n          const entry = getOrCreate(toDateKey(row.period_start));\n          entry.turnBytes += Number(row.egress_bytes) + Number(row.ingress_bytes);\n        }\n      }\n\n      if (tunnelRes.data) {\n        for (const row of tunnelRes.data) {\n          const entry = getOrCreate(toDateKey(row.recorded_at));\n          entry.tunnelBytes += Number(row.inbound_bytes) + Number(row.outbound_bytes);\n        }\n      }\n\n      if (capsuleRes.data) {\n        for (const row of capsuleRes.data) {\n          const entry = getOrCreate(row.date);\n          entry.capsuleRequests += row.request_count;\n          entry.capsuleImageRequests += row.image_requests;\n          entry.capsuleInputTokens += row.input_tokens;\n          entry.capsuleOutputTokens += row.output_tokens;\n        }\n      }\n\n      // Fill in missing dates so the chart has continuous x-axis\n      const today = new Date();\n      const start = new Date(since);\n      for (let d = new Date(start); d <= today; d.setDate(d.getDate() + 1)) {\n        const key = d.toISOString().split(\"T\")[0];\n        getOrCreate(key);\n      }\n\n      const sorted = Array.from(map.values()).sort((a, b) =>\n        a.date.localeCompare(b.date),\n      );\n\n      setData(sorted);\n      setLoading(false);\n    };\n\n    fetchUsage();\n  }, [user]);\n\n  return { data, loading };\n}\n"
  },
  {
    "path": "apps/landing/src/index.css",
    "content": "@import \"tailwindcss\";\n@import \"tw-animate-css\";\n\n@import \"tailwindcss\";\n@import \"tw-animate-css\";\n\n@tailwind utilities;\n\n@custom-variant dark (&:is(.dark *));\n\n:root {\n  --background: oklch(0 0 0);\n  --foreground: oklch(1 0 0);\n  --card: oklch(0.205 0 0);\n  --card-foreground: oklch(0.985 0 0);\n  --popover: oklch(0.205 0 0);\n  --popover-foreground: oklch(0.985 0 0);\n  --primary: oklch(0.922 0 0);\n  --primary-foreground: oklch(0.205 0 0);\n  --secondary: oklch(0.269 0 0);\n  --secondary-foreground: oklch(0.985 0 0);\n  --muted: oklch(0.269 0 0);\n  --muted-foreground: oklch(0.708 0 0);\n  --accent: oklch(0.269 0 0);\n  --accent-foreground: oklch(0.985 0 0);\n  --destructive: oklch(0.704 0.191 22.216);\n  --destructive-foreground: oklch(0.637 0.237 25.331);\n  --border: oklch(1 0 0 / 10%);\n  --input: oklch(1 0 0 / 15%);\n  --ring: oklch(0.556 0 0);\n  --chart-1: oklch(0.488 0.243 264.376);\n  --chart-2: oklch(0.696 0.17 162.48);\n  --chart-3: oklch(0.769 0.188 70.08);\n  --chart-4: oklch(0.627 0.265 303.9);\n  --chart-5: oklch(0.645 0.246 16.439);\n  --radius: 0.625rem;\n  --sidebar: oklch(0.205 0 0);\n  --sidebar-foreground: oklch(0.985 0 0);\n  --sidebar-primary: oklch(0.488 0.243 264.376);\n  --sidebar-primary-foreground: oklch(0.985 0 0);\n  --sidebar-accent: oklch(0.269 0 0);\n  --sidebar-accent-foreground: oklch(0.985 0 0);\n  --sidebar-border: oklch(1 0 0 / 10%);\n  --sidebar-ring: oklch(0.556 0 0);\n}\n\n.dark {\n  --background: oklch(0.145 0 0);\n  --foreground: oklch(0.985 0 0);\n  --card: oklch(0.205 0 0);\n  --card-foreground: oklch(0.985 0 0);\n  --popover: oklch(0.205 0 0);\n  --popover-foreground: oklch(0.985 0 0);\n  --primary: oklch(0.922 0 0);\n  --primary-foreground: oklch(0.205 0 0);\n  --secondary: oklch(0.269 0 0);\n  --secondary-foreground: oklch(0.985 0 0);\n  --muted: oklch(0.269 0 0);\n  --muted-foreground: oklch(0.708 0 0);\n  --accent: oklch(0.269 0 0);\n  --accent-foreground: oklch(0.985 0 0);\n  --destructive: oklch(0.704 0.191 22.216);\n  --destructive-foreground: oklch(0.637 0.237 25.331);\n  --border: oklch(1 0 0 / 10%);\n  --input: oklch(1 0 0 / 15%);\n  --ring: oklch(0.556 0 0);\n  --chart-1: oklch(0.488 0.243 264.376);\n  --chart-2: oklch(0.696 0.17 162.48);\n  --chart-3: oklch(0.769 0.188 70.08);\n  --chart-4: oklch(0.627 0.265 303.9);\n  --chart-5: oklch(0.645 0.246 16.439);\n  --sidebar: oklch(0.205 0 0);\n  --sidebar-foreground: oklch(0.985 0 0);\n  --sidebar-primary: oklch(0.488 0.243 264.376);\n  --sidebar-primary-foreground: oklch(0.985 0 0);\n  --sidebar-accent: oklch(0.269 0 0);\n  --sidebar-accent-foreground: oklch(0.985 0 0);\n  --sidebar-border: oklch(1 0 0 / 10%);\n  --sidebar-ring: oklch(0.556 0 0);\n}\n\n@theme inline {\n  --color-background: var(--background);\n  --color-foreground: var(--foreground);\n  --color-card: var(--card);\n  --color-card-foreground: var(--card-foreground);\n  --color-popover: var(--popover);\n  --color-popover-foreground: var(--popover-foreground);\n  --color-primary: var(--primary);\n  --color-primary-foreground: var(--primary-foreground);\n  --color-secondary: var(--secondary);\n  --color-secondary-foreground: var(--secondary-foreground);\n  --color-muted: var(--muted);\n  --color-muted-foreground: var(--muted-foreground);\n  --color-accent: var(--accent);\n  --color-accent-foreground: var(--accent-foreground);\n  --color-destructive: var(--destructive);\n  --color-destructive-foreground: var(--destructive-foreground);\n  --color-border: var(--border);\n  --color-input: var(--input);\n  --color-ring: var(--ring);\n  --color-chart-1: var(--chart-1);\n  --color-chart-2: var(--chart-2);\n  --color-chart-3: var(--chart-3);\n  --color-chart-4: var(--chart-4);\n  --color-chart-5: var(--chart-5);\n  --radius-sm: calc(var(--radius) - 4px);\n  --radius-md: calc(var(--radius) - 2px);\n  --radius-lg: var(--radius);\n  --radius-xl: calc(var(--radius) + 4px);\n  --color-sidebar: var(--sidebar);\n  --color-sidebar-foreground: var(--sidebar-foreground);\n  --color-sidebar-primary: var(--sidebar-primary);\n  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n  --color-sidebar-accent: var(--sidebar-accent);\n  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n  --color-sidebar-border: var(--sidebar-border);\n  --color-sidebar-ring: var(--sidebar-ring);\n}\n\n@layer base {\n  * {\n    @apply border-border outline-ring/50;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n}\nhtml,\nbody,\n#root {\n  width: 100%;\n  height: 100%;\n}\n\nbody {\n  -ms-overflow-style: none;\n}\n\n::-webkit-scrollbar {\n  display: none;\n}\n"
  },
  {
    "path": "apps/landing/src/lib/billing.ts",
    "content": "export async function cancelSubscription(accessToken: string) {\n  const response = await fetch(\n    `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/cancel-subscription`,\n    {\n      method: \"POST\",\n      headers: {\n        Authorization: `Bearer ${accessToken}`,\n        \"Content-Type\": \"application/json\",\n        apikey: import.meta.env.VITE_SUPABASE_ANON_KEY,\n      },\n    },\n  );\n\n  const data = await response.json();\n\n  if (!response.ok) {\n    throw new Error(data.error || \"Failed to cancel subscription\");\n  }\n\n  return data;\n}\n"
  },
  {
    "path": "apps/landing/src/lib/supabase.ts",
    "content": "import { createClient } from \"@supabase/supabase-js\";\n\nconst supabaseUrl = import.meta.env.VITE_SUPABASE_URL;\nconst supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;\n\nif (!supabaseUrl || !supabaseAnonKey) {\n  throw new Error(\"Missing Supabase environment variables\");\n}\n\nexport const supabase = createClient(supabaseUrl, supabaseAnonKey);\n"
  },
  {
    "path": "apps/landing/src/lib/theatre.ts",
    "content": "import { getProject } from \"@theatre/core\";\n\nconst project = getProject(\"VesselLanding\");\n\nexport const heroSheet = project.sheet(\"HeroScene\");\n"
  },
  {
    "path": "apps/landing/src/lib/useFadeInOnScroll.ts",
    "content": "import { useEffect, useRef, useState } from \"react\";\n\n/**\n * Returns a ref and visibility flag that turns true once the element enters the viewport.\n */\nexport function useFadeInOnScroll<T extends HTMLElement>() {\n  const ref = useRef<T | null>(null);\n  const [isVisible, setIsVisible] = useState(false);\n\n  useEffect(() => {\n    const node = ref.current;\n    if (!node || isVisible) return;\n\n    const observer = new IntersectionObserver(\n      (entries) => {\n        entries.forEach((entry) => {\n          if (entry.isIntersecting) {\n            setIsVisible(true);\n            observer.unobserve(entry.target);\n          }\n        });\n      },\n      { threshold: 0.2 },\n    );\n\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [isVisible]);\n\n  return { ref, isVisible };\n}\n"
  },
  {
    "path": "apps/landing/src/lib/utils.ts",
    "content": "import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"
  },
  {
    "path": "apps/landing/src/main.tsx",
    "content": "import { StrictMode } from 'react'\nimport { createRoot } from 'react-dom/client'\nimport './index.css'\nimport App from './App.tsx'\nimport { AuthProvider } from './contexts/AuthContext'\n\nasync function init() {\n  if (import.meta.env.DEV) {\n    const { default: studio } = await import(\"@theatre/studio\");\n    studio.initialize();\n  }\n\n  createRoot(document.getElementById('root')!).render(\n    <StrictMode>\n      <AuthProvider>\n        <App />\n      </AuthProvider>\n    </StrictMode>,\n  );\n}\n\ninit();\n"
  },
  {
    "path": "apps/landing/src/pages/Capsule.tsx",
    "content": "import { Navbar } from \"@/components/Navbar\";\nimport { Footer } from \"@/components/Footer\";\nimport { CapsuleHero } from \"@/components/sections/capsule/CapsuleHero\";\nimport { CapsuleSubheading } from \"@/components/sections/capsule/CapsuleSubheading\";\nimport { CapsuleFeatures } from \"@/components/sections/capsule/CapsuleFeatures\";\nimport { CapsuleHowItWorks } from \"@/components/sections/capsule/CapsuleHowItWorks\";\nimport { CapsuleArchitecture } from \"@/components/sections/capsule/CapsuleArchitecture\";\nimport { CapsuleFaq } from \"@/components/sections/capsule/CapsuleFaq\";\nimport { CapsuleFooterCta } from \"@/components/sections/capsule/CapsuleFooterCta\";\n\nfunction CapsulePage() {\n  return (\n    <>\n      <Navbar />\n      <main className='w-screen bg-background text-foreground'>\n        <CapsuleHero />\n        <CapsuleSubheading />\n        <CapsuleFeatures />\n        <CapsuleHowItWorks />\n        <CapsuleArchitecture />\n        <CapsuleFaq />\n        <CapsuleFooterCta />\n      </main>\n      <Footer />\n    </>\n  );\n}\n\nexport default CapsulePage;\n"
  },
  {
    "path": "apps/landing/src/pages/CheckoutSuccess.tsx",
    "content": "import { useEffect, useRef, useState } from \"react\";\nimport { useNavigate, useSearchParams } from \"react-router\";\nimport { Navbar } from \"@/components/Navbar\";\nimport { Footer } from \"@/components/Footer\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { CheckCircle2, Loader2, XCircle } from \"lucide-react\";\nimport { useAuth } from \"@/contexts/AuthContext\";\nimport { supabase } from \"@/lib/supabase\";\n\ntype SubscriptionStatus = \"loading\" | \"active\" | \"pending\" | \"error\";\n\nconst MAX_POLLS = 10;\nconst POLL_INTERVAL = 2000;\n\nexport default function CheckoutSuccessPage() {\n  const navigate = useNavigate();\n  const [searchParams] = useSearchParams();\n  const { user, loading: authLoading } = useAuth();\n  const [status, setStatus] = useState<SubscriptionStatus>(\"loading\");\n\n  const checkoutId = searchParams.get(\"checkout_id\");\n  const pollCountRef = useRef(0);\n  const timeoutRef = useRef<number | null>(null);\n\n  const checkSubscription = async () => {\n    if (!user) return;\n\n    try {\n      const { data, error } = await supabase\n        .from(\"billing_subscriptions\")\n        .select(\"status\")\n        .eq(\"user_id\", user.id)\n        .eq(\"status\", \"active\")\n        .limit(1)\n        .maybeSingle();\n\n      if (error) {\n        console.error(\"Error checking subscription:\", error);\n        setStatus(\"error\");\n        return;\n      }\n\n      if (data) {\n        setStatus(\"active\");\n        return;\n      }\n\n      pollCountRef.current += 1;\n\n      if (pollCountRef.current < MAX_POLLS) {\n        timeoutRef.current = window.setTimeout(\n          checkSubscription,\n          POLL_INTERVAL,\n        );\n      } else {\n        setStatus(\"pending\");\n      }\n    } catch (err) {\n      console.error(\"Unexpected error:\", err);\n      setStatus(\"error\");\n    }\n  };\n\n  useEffect(() => {\n    if (authLoading) return;\n\n    if (!user) {\n      navigate(\"/login\");\n      return;\n    }\n\n    pollCountRef.current = 0;\n    checkSubscription();\n\n    return () => {\n      if (timeoutRef.current) {\n        clearTimeout(timeoutRef.current);\n      }\n    };\n  }, [user, authLoading, navigate]);\n\n  const handleRetry = () => {\n    if (timeoutRef.current) {\n      clearTimeout(timeoutRef.current);\n    }\n    pollCountRef.current = 0;\n    setStatus(\"loading\");\n    checkSubscription();\n  };\n\n  if (authLoading) {\n    return (\n      <div className='min-h-screen flex items-center justify-center bg-background'>\n        <Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />\n      </div>\n    );\n  }\n\n  return (\n    <>\n      <Navbar />\n      <main className='min-h-screen pt-20 px-4'>\n        <div className='max-w-lg mx-auto mt-16'>\n          <Card className='bg-black'>\n            <CardHeader className='text-center'>\n              {status === \"loading\" && (\n                <>\n                  <div className='flex justify-center mb-4'>\n                    <Loader2 className='h-12 w-12 animate-spin text-primary' />\n                  </div>\n                  <CardTitle className='text-2xl'>\n                    Processing your subscription\n                  </CardTitle>\n                  <CardDescription>\n                    Please wait while we confirm your payment...\n                  </CardDescription>\n                </>\n              )}\n\n              {status === \"active\" && (\n                <>\n                  <div className='flex justify-center mb-4'>\n                    <CheckCircle2 className='h-12 w-12 text-green-500' />\n                  </div>\n                  <CardTitle className='text-2xl'>\n                    Subscription activated!\n                  </CardTitle>\n                  <CardDescription>\n                    Thank you for subscribing to Vessel Pro\n                  </CardDescription>\n                </>\n              )}\n\n              {status === \"pending\" && (\n                <>\n                  <div className='flex justify-center mb-4'>\n                    <Loader2 className='h-12 w-12 text-yellow-500' />\n                  </div>\n                  <CardTitle className='text-2xl'>\n                    Payment is being processed\n                  </CardTitle>\n                  <CardDescription>\n                    Your subscription will be activated shortly. You can check\n                    your status in the dashboard.\n                  </CardDescription>\n                </>\n              )}\n\n              {status === \"error\" && (\n                <>\n                  <div className='flex justify-center mb-4'>\n                    <XCircle className='h-12 w-12 text-red-500' />\n                  </div>\n                  <CardTitle className='text-2xl'>\n                    Something went wrong\n                  </CardTitle>\n                  <CardDescription>\n                    We couldn't verify your subscription. Please contact support\n                    if this issue persists.\n                  </CardDescription>\n                </>\n              )}\n            </CardHeader>\n            <CardContent className='space-y-4'>\n              {checkoutId && (\n                <p className='text-xs text-center text-muted-foreground'>\n                  Checkout ID: {checkoutId}\n                </p>\n              )}\n\n              <div className='flex flex-col gap-3'>\n                <Button\n                  onClick={() => navigate(\"/dashboard\")}\n                  className='w-full'\n                >\n                  Go to Dashboard\n                </Button>\n                {(status === \"pending\" || status === \"error\") && (\n                  <Button\n                    variant='outline'\n                    onClick={handleRetry}\n                    className='w-full'\n                  >\n                    Check again\n                  </Button>\n                )}\n              </div>\n            </CardContent>\n          </Card>\n        </div>\n      </main>\n      <Footer />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/pages/Contact.tsx",
    "content": "import { Footer } from \"@/components/Footer\";\nimport { Navbar } from \"@/components/Navbar\";\nimport { ArrowRight } from \"lucide-react\";\n\nconst contactMethods = [\n  {\n    title: \"Email\",\n    value: \"info@cartesiancs.com\",\n    href: \"mailto:info@cartesiancs.com\",\n  },\n  {\n    title: \"LinkedIn\",\n    value: \"company/cartesiancs\",\n    href: \"https://www.linkedin.com/company/cartesiancs\",\n  },\n];\n\nfunction ContactPage() {\n  return (\n    <>\n      <Navbar />\n      <main className='w-screen bg-background text-foreground'>\n        {/* Hero Section */}\n        <section className='flex min-h-[600px] flex-col items-center justify-center px-8 md:px-10'>\n          <div className='flex max-w-3xl flex-col items-center gap-y-6 text-center'>\n            <h1 className='text-4xl md:text-6xl lg:text-6xl md:leading-17 leading-10 md:font-bold font-semibold tracking-tight text-center'>\n              Contact Us\n            </h1>\n          </div>\n        </section>\n\n        <div className='container mx-auto mt-12 mb-30 max-w-6xl px-8 md:px-10'>\n          <div className='space-y-8'>\n            {contactMethods.map((method) => (\n              <div\n                key={method.title}\n                className='flex flex-col gap-2 border-b border-border pb-6'\n              >\n                <p className='text-sm text-muted-foreground'>{method.title}</p>\n                <a\n                  href={method.href}\n                  target='_blank'\n                  rel='noopener noreferrer'\n                  className='group inline-flex w-fit items-center gap-2 text-md tracking-tight transition-opacity md:text-2xl'\n                >\n                  <span className='transition-opacity group-hover:opacity-60'>\n                    {method.value}\n                  </span>\n                  <ArrowRight className='h-4 w-4 translate-x-[-4px] opacity-0 transition-all duration-200 group-hover:translate-x-0 group-hover:opacity-100 md:h-5 md:w-5' />\n                </a>\n              </div>\n            ))}\n          </div>\n        </div>\n      </main>\n      <Footer />\n    </>\n  );\n}\n\nexport default ContactPage;\n"
  },
  {
    "path": "apps/landing/src/pages/Dashboard.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { useNavigate } from \"react-router\";\nimport { useAuth } from \"@/contexts/AuthContext\";\nimport { Navbar } from \"@/components/Navbar\";\nimport { Footer } from \"@/components/Footer\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\nimport { Loader2, CheckCircle2, User } from \"lucide-react\";\nimport { supabase } from \"@/lib/supabase\";\nimport { cancelSubscription } from \"@/lib/billing\";\nimport { toast } from \"sonner\";\nimport { useUsageData } from \"@/hooks/useUsageData\";\nimport { UsageCharts } from \"@/components/UsageCharts\";\n\nexport default function DashboardPage() {\n  const navigate = useNavigate();\n  const { user, loading, signOut } = useAuth();\n  const [signingOut, setSigningOut] = useState(false);\n  const [isSubscribed, setIsSubscribed] = useState(false);\n  const [subscriptionLoading, setSubscriptionLoading] = useState(true);\n  const [avatarError, setAvatarError] = useState(false);\n  const [cancelLoading, setCancelLoading] = useState(false);\n  const [cancelAtPeriodEnd, setCancelAtPeriodEnd] = useState(false);\n  const [currentPeriodEnd, setCurrentPeriodEnd] = useState<string | null>(null);\n  const { data: usageData, loading: usageLoading } = useUsageData(user);\n\n  useEffect(() => {\n    if (!loading && !user) {\n      navigate(\"/login\");\n    }\n  }, [user, loading, navigate]);\n\n  useEffect(() => {\n    const checkSubscription = async () => {\n      if (!user) {\n        setSubscriptionLoading(false);\n        return;\n      }\n\n      try {\n        const { data } = await supabase\n          .from(\"billing_subscriptions\")\n          .select(\"status, cancel_at_period_end, current_period_end\")\n          .eq(\"user_id\", user.id)\n          .eq(\"status\", \"active\")\n          .limit(1)\n          .maybeSingle();\n\n        setIsSubscribed(!!data);\n        if (data) {\n          setCancelAtPeriodEnd(data.cancel_at_period_end ?? false);\n          setCurrentPeriodEnd(data.current_period_end ?? null);\n        }\n      } catch (err) {\n        console.error(\"Error checking subscription:\", err);\n      } finally {\n        setSubscriptionLoading(false);\n      }\n    };\n\n    if (!loading && user) {\n      checkSubscription();\n    }\n  }, [user, loading]);\n\n  const handleSignOut = async () => {\n    setSigningOut(true);\n    await signOut();\n    navigate(\"/\");\n  };\n\n  const handleCancelSubscription = async () => {\n    setCancelLoading(true);\n    try {\n      const {\n        data: { session },\n      } = await supabase.auth.getSession();\n      if (!session?.access_token) {\n        toast.error(\"Please sign in again to cancel your subscription\");\n        return;\n      }\n\n      await cancelSubscription(session.access_token);\n      toast.success(\"Subscription cancelled successfully\");\n      setIsSubscribed(false);\n    } catch (err) {\n      toast.error(\n        err instanceof Error ? err.message : \"Failed to cancel subscription\",\n      );\n    } finally {\n      setCancelLoading(false);\n    }\n  };\n\n  if (loading) {\n    return (\n      <div className='min-h-screen flex items-center justify-center bg-background'>\n        <p className='text-muted-foreground'>Loading...</p>\n      </div>\n    );\n  }\n\n  if (!user) {\n    return null;\n  }\n\n  return (\n    <>\n      <Navbar />\n      <main className='min-h-screen min-w-0 bg-background pt-20 px-4'>\n        <div className='mx-auto max-w-4xl min-w-0'>\n          <div className='flex items-center justify-between mb-8'>\n            <h1 className='text-3xl font-bold'>Dashboard</h1>\n            <Button\n              variant='outline'\n              onClick={handleSignOut}\n              disabled={signingOut}\n            >\n              {signingOut ? (\n                <>\n                  <Loader2 className='mr-2 h-4 w-4 animate-spin' />\n                  Signing out...\n                </>\n              ) : (\n                \"Sign Out\"\n              )}\n            </Button>\n          </div>\n\n          <div className='grid gap-6 md:grid-cols-2'>\n            <Card>\n              <CardHeader>\n                <CardTitle>Profile</CardTitle>\n                <CardDescription>Your account information</CardDescription>\n              </CardHeader>\n              <CardContent className='space-y-4'>\n                {user.user_metadata?.avatar_url && !avatarError ? (\n                  <img\n                    src={user.user_metadata.avatar_url}\n                    alt='Profile'\n                    className='w-16 h-16 rounded-full'\n                    onError={() => setAvatarError(true)}\n                  />\n                ) : (\n                  <div className='w-16 h-16 rounded-full bg-muted flex items-center justify-center'>\n                    <User className='w-8 h-8 text-muted-foreground' />\n                  </div>\n                )}\n                <div>\n                  <p className='text-sm text-muted-foreground'>Name</p>\n                  <p className='font-medium'>\n                    {user.user_metadata?.full_name || \"Not provided\"}\n                  </p>\n                </div>\n                <div>\n                  <p className='text-sm text-muted-foreground'>Email</p>\n                  <p className='font-medium'>{user.email}</p>\n                </div>\n                <div>\n                  <p className='text-sm text-muted-foreground'>User ID</p>\n                  <p className='font-mono text-sm text-muted-foreground'>\n                    {user.id}\n                  </p>\n                </div>\n              </CardContent>\n            </Card>\n\n            <Card>\n              <CardHeader>\n                <CardTitle>Subscription</CardTitle>\n                <CardDescription>Your current plan</CardDescription>\n              </CardHeader>\n              <CardContent>\n                {subscriptionLoading ? (\n                  <div className='flex items-center gap-2 text-muted-foreground'>\n                    <Loader2 className='h-4 w-4 animate-spin' />\n                    <span>Checking subscription...</span>\n                  </div>\n                ) : isSubscribed ? (\n                  <div className='space-y-4'>\n                    <div className='flex items-center gap-3'>\n                      <div>\n                        <p className='font-semibold flex items-center gap-2'>\n                          Pro Plan\n                          {cancelAtPeriodEnd ? (\n                            <span className='text-xs font-normal text-amber-500'>\n                              Cancelling\n                            </span>\n                          ) : (\n                            <CheckCircle2 className='h-4 w-4 text-green-500' />\n                          )}\n                        </p>\n                        <p className='text-sm text-muted-foreground'>\n                          {cancelAtPeriodEnd && currentPeriodEnd\n                            ? `Ends on ${new Date(currentPeriodEnd).toLocaleDateString()}`\n                            : \"Active subscription\"}\n                        </p>\n                      </div>\n                    </div>\n                    {cancelAtPeriodEnd ? (\n                      <p className='text-sm text-amber-500'>\n                        Your subscription will be cancelled at the end of the\n                        current billing period.\n                      </p>\n                    ) : (\n                      <AlertDialog>\n                        <AlertDialogTrigger asChild>\n                          <Button\n                            variant='outline'\n                            className='w-full text-destructive hover:text-destructive'\n                            disabled={cancelLoading}\n                          >\n                            {cancelLoading ? (\n                              <>\n                                <Loader2 className='mr-2 h-4 w-4 animate-spin' />\n                                Cancelling...\n                              </>\n                            ) : (\n                              \"Cancel Subscription\"\n                            )}\n                          </Button>\n                        </AlertDialogTrigger>\n                        <AlertDialogContent>\n                          <AlertDialogHeader>\n                            <AlertDialogTitle>\n                              Cancel Subscription\n                            </AlertDialogTitle>\n                            <AlertDialogDescription>\n                              Are you sure you want to cancel your subscription?\n                              You will lose access to Pro features at the end of\n                              the current billing period.\n                            </AlertDialogDescription>\n                          </AlertDialogHeader>\n                          <AlertDialogFooter>\n                            <AlertDialogCancel>\n                              Keep Subscription\n                            </AlertDialogCancel>\n                            <AlertDialogAction\n                              onClick={handleCancelSubscription}\n                              className='bg-red-500 text-white hover:bg-destructive/90'\n                            >\n                              Yes, Cancel\n                            </AlertDialogAction>\n                          </AlertDialogFooter>\n                        </AlertDialogContent>\n                      </AlertDialog>\n                    )}\n                  </div>\n                ) : (\n                  <div className='space-y-3'>\n                    <p className='text-muted-foreground'>\n                      You are currently on the Free plan.\n                    </p>\n                    <Button\n                      variant='outline'\n                      className='w-full'\n                      onClick={() => navigate(\"/pricing\")}\n                    >\n                      Upgrade to Pro\n                    </Button>\n                  </div>\n                )}\n              </CardContent>\n            </Card>\n\n            <Card>\n              <CardHeader>\n                <CardTitle>Quick Links</CardTitle>\n                <CardDescription>Useful resources</CardDescription>\n              </CardHeader>\n              <CardContent className='space-y-3'>\n                <Button\n                  variant='outline'\n                  className='w-full justify-start'\n                  onClick={() => window.open(\"/docs/introduction\", \"_blank\")}\n                >\n                  Documentation\n                </Button>\n                <Button\n                  variant='outline'\n                  className='w-full justify-start'\n                  onClick={() =>\n                    window.open(\n                      \"https://github.com/cartesiancs/vessel\",\n                      \"_blank\",\n                    )\n                  }\n                >\n                  GitHub Repository\n                </Button>\n                <Button\n                  variant='outline'\n                  className='w-full justify-start'\n                  onClick={() => navigate(\"/pricing\")}\n                >\n                  Pricing Plans\n                </Button>\n              </CardContent>\n            </Card>\n\n            <Card>\n              <CardHeader>\n                <CardTitle>Support & Roadmap</CardTitle>\n                <CardDescription>\n                  Get help and see what's coming next\n                </CardDescription>\n              </CardHeader>\n              <CardContent className='space-y-3'>\n                <Button\n                  variant='outline'\n                  className='w-full justify-start'\n                  onClick={() => navigate(\"/contact\")}\n                >\n                  Contact Support\n                </Button>\n                <Button\n                  variant='outline'\n                  className='w-full justify-start'\n                  onClick={() => navigate(\"/roadmap\")}\n                >\n                  View Product Roadmap\n                </Button>\n              </CardContent>\n            </Card>\n          </div>\n\n          <div className='my-8 min-w-0'>\n            <h2 className='text-2xl font-bold mb-6'>Usage</h2>\n            <UsageCharts data={usageData} loading={usageLoading} />\n          </div>\n        </div>\n      </main>\n      <Footer />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/pages/Disclaimer.tsx",
    "content": "import { Navbar } from \"@/components/Navbar\";\nimport { Footer } from \"@/components/Footer\";\n\nexport default function DisclaimerPage() {\n  return (\n    <>\n      <Navbar />\n      <main className=\"min-h-screen bg-background text-foreground\">\n        <article className=\"container mx-auto max-w-4xl px-4 pt-24 pb-16 md:pt-28 md:pb-24\">\n          <h1 className=\"text-4xl font-extrabold tracking-tight md:text-5xl mb-8\">\n            Disclaimer\n          </h1>\n          <p className=\"text-muted-foreground mb-8\">\n            Last updated: April 11, 2026\n          </p>\n\n          <div className=\"prose prose-invert max-w-none space-y-8\">\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                1. General Disclaimer\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                The information, software, and services provided by Vessel\n                (\"we,\" \"our,\" or \"us\") are offered on an \"as is\" and \"as\n                available\" basis without warranties of any kind, either express\n                or implied. We do not warrant that the Services will be\n                uninterrupted, error-free, or free of harmful components.\n              </p>\n              <p className=\"text-muted-foreground leading-relaxed mt-4\">\n                Your use of the Services is at your sole risk. We expressly\n                disclaim all warranties, including but not limited to implied\n                warranties of merchantability, fitness for a particular purpose,\n                and non-infringement.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                2. Open-Source Software\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                Vessel's core dashboard and orchestration engine are open-source\n                software. Open-source software is provided without any warranty,\n                as stated in the applicable open-source license. You acknowledge\n                that:\n              </p>\n              <ul className=\"list-disc list-inside text-muted-foreground space-y-2 mt-4\">\n                <li>\n                  The software may contain bugs, errors, or vulnerabilities\n                </li>\n                <li>\n                  You are responsible for evaluating the software's suitability\n                  for your intended use\n                </li>\n                <li>\n                  Self-hosted deployments are entirely your responsibility,\n                  including security, maintenance, and compliance\n                </li>\n                <li>\n                  Community contributions are not guaranteed to be reviewed,\n                  tested, or free of defects\n                </li>\n              </ul>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                3. Device and Hardware Interactions\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                Vessel is designed to orchestrate and automate physical devices.\n                You acknowledge and agree that:\n              </p>\n              <ul className=\"list-disc list-inside text-muted-foreground space-y-2 mt-4\">\n                <li>\n                  <strong>Physical Risk:</strong> Controlling physical devices\n                  remotely involves inherent risks. Incorrect configurations,\n                  software errors, or network failures could result in\n                  unintended device behavior.\n                </li>\n                <li>\n                  <strong>No Liability for Damage:</strong> We are not liable for\n                  any damage to property, equipment, or persons resulting from\n                  the use or misuse of Vessel in connection with physical\n                  devices.\n                </li>\n                <li>\n                  <strong>User Responsibility:</strong> You are solely\n                  responsible for ensuring that your device configurations,\n                  automation flows, and security settings are appropriate and\n                  safe for your environment.\n                </li>\n                <li>\n                  <strong>Compliance:</strong> You are responsible for complying\n                  with all applicable laws and regulations governing the\n                  operation of connected devices in your jurisdiction.\n                </li>\n              </ul>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                4. Security Disclaimer\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                While we implement reasonable security measures for our cloud\n                Services, no system is completely secure. We disclaim liability\n                for:\n              </p>\n              <ul className=\"list-disc list-inside text-muted-foreground space-y-2 mt-4\">\n                <li>\n                  Unauthorized access to your account or devices resulting from\n                  compromised credentials or third-party breaches\n                </li>\n                <li>\n                  Security vulnerabilities in self-hosted deployments that are\n                  not maintained or updated\n                </li>\n                <li>\n                  Data loss or interception during transmission over public\n                  networks\n                </li>\n                <li>\n                  Security incidents arising from third-party integrations or\n                  plugins\n                </li>\n              </ul>\n              <p className=\"text-muted-foreground leading-relaxed mt-4\">\n                You are responsible for implementing appropriate security\n                practices, including keeping your software up to date, using\n                strong authentication, and regularly reviewing access controls.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                5. Cloud Services\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                Our cloud Services, including remote tunnel access and\n                TURN/STUN relay, are provided for convenience and may be\n                subject to:\n              </p>\n              <ul className=\"list-disc list-inside text-muted-foreground space-y-2 mt-4\">\n                <li>Scheduled or unscheduled downtime for maintenance</li>\n                <li>\n                  Capacity limitations or throttling during high-demand periods\n                </li>\n                <li>\n                  Service modifications, suspensions, or discontinuations with\n                  reasonable notice\n                </li>\n                <li>\n                  Regional availability restrictions due to infrastructure or\n                  legal requirements\n                </li>\n              </ul>\n              <p className=\"text-muted-foreground leading-relaxed mt-4\">\n                We do not guarantee any specific uptime percentage or service\n                level unless explicitly stated in a separate service level\n                agreement (SLA).\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                6. Third-Party Services\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                The Services may integrate with or rely on third-party services,\n                including but not limited to Google OAuth, Supabase, and Polar.\n                We are not responsible for the availability, accuracy, or\n                reliability of any third-party services. Your use of third-party\n                services is subject to their respective terms and policies.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                7. No Professional Advice\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                Nothing in our Services, documentation, or communications\n                constitutes professional, legal, security, or engineering advice.\n                The information provided is for general informational purposes\n                only. You should consult qualified professionals for advice\n                specific to your situation, particularly regarding physical\n                security systems and device automation.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                8. Limitation of Liability\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                TO THE FULLEST EXTENT PERMITTED BY APPLICABLE LAW, VESSEL SHALL\n                NOT BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,\n                CONSEQUENTIAL, OR EXEMPLARY DAMAGES, INCLUDING BUT NOT LIMITED\n                TO DAMAGES FOR LOSS OF PROFITS, GOODWILL, DATA, OR OTHER\n                INTANGIBLE LOSSES, RESULTING FROM:\n              </p>\n              <ul className=\"list-disc list-inside text-muted-foreground space-y-2 mt-4\">\n                <li>Your use or inability to use the Services</li>\n                <li>\n                  Any unauthorized access to or alteration of your data or\n                  devices\n                </li>\n                <li>\n                  Any interruption, suspension, or termination of the Services\n                </li>\n                <li>\n                  Any bugs, errors, or inaccuracies in the software or\n                  documentation\n                </li>\n                <li>\n                  Any damage to physical property or personal injury resulting\n                  from the use of the Services with physical devices\n                </li>\n              </ul>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                9. Indemnification\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                You agree to indemnify and hold harmless Vessel, its affiliates,\n                and their respective officers, directors, employees, and agents\n                from any claims, damages, losses, liabilities, and expenses\n                (including attorneys' fees) arising from your use of the\n                Services, your violation of these terms, or your infringement of\n                any third-party rights.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                10. Changes to This Disclaimer\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                We reserve the right to update or modify this Disclaimer at any\n                time. Changes will be effective upon posting to our website. Your\n                continued use of the Services after any changes constitutes\n                acceptance of the updated Disclaimer.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">11. Contact Us</h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                If you have questions or concerns about this Disclaimer, please\n                contact us at:\n              </p>\n              <p className=\"text-muted-foreground leading-relaxed mt-4\">\n                <strong>Email:</strong>{\" \"}\n                <a\n                  href=\"mailto:info@cartesiancs.com\"\n                  className=\"text-primary hover:underline\"\n                >\n                  info@cartesiancs.com\n                </a>\n              </p>\n              <p className=\"text-muted-foreground leading-relaxed mt-2\">\n                <strong>GitHub:</strong>{\" \"}\n                <a\n                  href=\"https://github.com/cartesiancs/vessel\"\n                  className=\"text-primary hover:underline\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                >\n                  github.com/cartesiancs/vessel\n                </a>\n              </p>\n            </section>\n          </div>\n        </article>\n      </main>\n      <Footer />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/pages/Login.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { useNavigate } from \"react-router\";\nimport { useAuth } from \"@/contexts/AuthContext\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { FcGoogle } from \"react-icons/fc\";\nimport { Loader2 } from \"lucide-react\";\n\nexport default function LoginPage() {\n  const navigate = useNavigate();\n  const { user, loading, signInWithGoogle } = useAuth();\n  const [signingIn, setSigningIn] = useState(false);\n\n  useEffect(() => {\n    if (!loading && user) {\n      navigate(\"/dashboard\");\n    }\n  }, [user, loading, navigate]);\n\n  const handleSignIn = async () => {\n    setSigningIn(true);\n    await signInWithGoogle();\n  };\n\n  if (loading) {\n    return (\n      <div className='flex min-h-dvh items-center justify-center bg-background pt-[env(safe-area-inset-top,0px)] pb-[env(safe-area-inset-bottom,0px)]'>\n        <p className='text-muted-foreground'>Loading...</p>\n      </div>\n    );\n  }\n\n  return (\n    <div className='flex min-h-dvh items-center justify-center bg-background px-4 pt-[env(safe-area-inset-top,0px)] pb-[env(safe-area-inset-bottom,0px)]'>\n      <Card className='w-full max-w-md bg-black py-10'>\n        <CardHeader className='text-center'>\n          <div className='flex justify-center mb-4'>\n            <img src='/icon.png' alt='Logo' className='w-12 invert' />\n          </div>\n          <CardTitle className='text-2xl'>Welcome to Vessel</CardTitle>\n        </CardHeader>\n        <CardContent>\n          <Button\n            variant='outline'\n            className='w-full'\n            size='lg'\n            onClick={handleSignIn}\n            disabled={signingIn}\n          >\n            {signingIn ? (\n              <>\n                <Loader2 className='mr-2 h-5 w-5 animate-spin' />\n                Signing in...\n              </>\n            ) : (\n              <>\n                <FcGoogle className='mr-2 h-5 w-5' />\n                Continue with Google\n              </>\n            )}\n          </Button>\n          <p className='text-center text-sm text-muted-foreground mt-6'>\n            By signing in, you agree to our{\" \"}\n            <a\n              href='/privacy-policy'\n              className='underline hover:text-foreground'\n            >\n              Privacy Policy\n            </a>\n          </p>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/pages/Main.tsx",
    "content": "import { useLayoutEffect, useState } from \"react\";\nimport { Navbar } from \"@/components/Navbar\";\nimport { Footer } from \"@/components/Footer\";\nimport { FeaturesSection } from \"@/components/sections/Features\";\nimport { UsecaseSection } from \"@/components/sections/Usecase\";\nimport { Button } from \"@/components/ui/button\";\nimport { BookText } from \"lucide-react\";\nimport { FaGithub } from \"react-icons/fa\";\nimport { MidCTASection } from \"@/components/sections/MidCta\";\nimport { FAQSection } from \"@/components/sections/Faqs\";\nimport { FooterCtaSection } from \"@/components/sections/FooterCta\";\nimport { ThreeCardsSection } from \"@/components/sections/ThreeCards\";\nimport { SecurityCTASection } from \"@/components/sections/SecurityCta\";\nimport { IntegrationSection } from \"@/components/sections/IntegrationImage\";\nimport { ListCardsSection } from \"@/components/sections/ListCards\";\nimport { CapsulePromoSection } from \"@/components/sections/CapsulePromo\";\nimport { ScrollTextRevealSection } from \"@/components/sections/ScrollTextReveal\";\nimport { HeroSceneSection } from \"@/components/sections/HeroScene/HeroScene\";\nimport { UsecaseAIAssistantSection } from \"@/components/sections/UsecaseAI\";\nimport { cn } from \"@/lib/utils\";\n\n// const HERO_BG_SRC = \"/videos/back.webp\";\n\n/** Matches previous Tailwind inset-10 / md:16 / lg:24 / xl:28 (px). */\nfunction maxHeroInsetForWidth(width: number): number {\n  if (width >= 1280) return 112;\n  if (width >= 1024) return 96;\n  if (width >= 768) return 64;\n  return 40;\n}\n\n/** Scroll this fraction of viewport height to go from max inset to 0. */\nconst HERO_INSET_SCROLL_RANGE = 0.72;\n\n/** Below Tailwind `md` — no hero background media (save bandwidth & battery). */\nconst MOBILE_BG_OFF_MEDIA = \"(max-width: 767px)\";\n\nfunction LandingPage() {\n  const [heroBgReady] = useState(false);\n  const [heroBgFailed] = useState(false);\n  const [heroInsetPx, setHeroInsetPx] = useState(40);\n  const [isMobileViewport, setIsMobileViewport] = useState(() =>\n    typeof window !== \"undefined\"\n      ? window.matchMedia(MOBILE_BG_OFF_MEDIA).matches\n      : false,\n  );\n\n  useLayoutEffect(() => {\n    const mq = window.matchMedia(MOBILE_BG_OFF_MEDIA);\n    let rafId = 0;\n\n    const updateHeroInset = () => {\n      const maxInset = maxHeroInsetForWidth(window.innerWidth);\n      const range = Math.max(1, window.innerHeight * HERO_INSET_SCROLL_RANGE);\n      const t = Math.min(1, Math.max(0, window.scrollY / range));\n      setHeroInsetPx(maxInset * (1 - t));\n    };\n\n    const onScrollOrResize = () => {\n      cancelAnimationFrame(rafId);\n      rafId = requestAnimationFrame(updateHeroInset);\n    };\n\n    const detachScroll = () => {\n      cancelAnimationFrame(rafId);\n      window.removeEventListener(\"scroll\", onScrollOrResize);\n      window.removeEventListener(\"resize\", onScrollOrResize);\n    };\n\n    const syncMobileAndScroll = () => {\n      const mobile = mq.matches;\n      setIsMobileViewport(mobile);\n      detachScroll();\n      if (!mobile) {\n        updateHeroInset();\n        window.addEventListener(\"scroll\", onScrollOrResize, { passive: true });\n        window.addEventListener(\"resize\", onScrollOrResize);\n      }\n    };\n\n    mq.addEventListener(\"change\", syncMobileAndScroll);\n    syncMobileAndScroll();\n\n    return () => {\n      mq.removeEventListener(\"change\", syncMobileAndScroll);\n      detachScroll();\n    };\n  }, []);\n\n  return (\n    <>\n      <Navbar />\n      <main className='w-screen bg-background text-foreground'>\n        <section className='relative flex h-svh flex-col items-center justify-center overflow-hidden bg-black px-5 md:px-10'>\n          {!heroBgFailed && !isMobileViewport && (\n            <div\n              className={cn(\n                \"pointer-events-none absolute transition-opacity duration-[1400ms] ease-out\",\n                heroBgReady ? \"opacity-100\" : \"opacity-0\",\n              )}\n              style={{\n                top: heroInsetPx,\n                right: heroInsetPx,\n                bottom: heroInsetPx,\n                left: heroInsetPx,\n              }}\n            >\n              <div className='relative h-full w-full overflow-hidden'>\n                {/* <img\n                  src={HERO_BG_SRC}\n                  alt=''\n                  aria-hidden\n                  loading='eager'\n                  fetchPriority='high'\n                  decoding='async'\n                  onLoad={() => {\n                    requestAnimationFrame(() => setHeroBgReady(true));\n                  }}\n                  onError={() => setHeroBgFailed(true)}\n                  className='h-full w-full object-cover brightness-[0.92]'\n                /> */}\n                <div className='absolute inset-0 bg-black/35' aria-hidden />\n              </div>\n            </div>\n          )}\n          <div className='relative z-10 flex flex-col items-start gap-y-6'>\n            <h1 className='self-center text-4xl md:text-6xl lg:text-6xl md:leading-17 leading-10 md:font-bold font-semibold tracking-tight text-center'>\n              Personal C2 platform <br /> for Physical AI\n            </h1>\n            <div className='flex items-center gap-x-4 pt-2 self-center'>\n              <Button\n                variant='default'\n                onClick={() => window.open(\"/docs/introduction\")}\n              >\n                <BookText /> Docs\n              </Button>\n              <Button\n                onClick={() =>\n                  window.open(\"https://github.com/cartesiancs/vessel\")\n                }\n                variant='outline'\n              >\n                <FaGithub />\n                GitHub\n              </Button>\n            </div>\n          </div>\n        </section>\n\n        {/* <SubheadingSection /> */}\n        <FeaturesSection />\n        <ScrollTextRevealSection />\n        <UsecaseSection />\n        <ListCardsSection />\n        <MidCTASection />\n        <IntegrationSection />\n        <ThreeCardsSection />\n        <UsecaseAIAssistantSection />\n        <SecurityCTASection />\n        <CapsulePromoSection />\n        <FAQSection />\n        <FooterCtaSection />\n      </main>\n      <Footer />\n      <HeroSceneSection />\n    </>\n  );\n}\n\nexport default LandingPage;\n"
  },
  {
    "path": "apps/landing/src/pages/Pricing.tsx",
    "content": "import { useState, useEffect } from \"react\";\nimport { useNavigate } from \"react-router\";\nimport { Navbar } from \"@/components/Navbar\";\nimport { Footer } from \"@/components/Footer\";\nimport {\n  Card,\n  CardContent,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Button } from \"@/components/ui/button\";\nimport { Check, Loader2 } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { useAuth } from \"@/contexts/AuthContext\";\nimport { supabase } from \"@/lib/supabase\";\n\nconst proFeatures = [\n  \"Remote Tunnel\",\n  \"Secure Option\",\n  \"Streaming Server (10GB)\",\n  \"Custom Flow\",\n  \"Advanced data sources\",\n  \"1:1 support\",\n  \"Priority access to new features\",\n];\n\nconst freeFeatures = [\n  \"Self Host\",\n  \"Core Flow\",\n  \"Realtime device orchestration\",\n  \"Community support\",\n  \"GitHub updates\",\n];\n\nconst enterpriseHighlights = [\n  \"Scope and pricing aligned to your requirements\",\n  \"Security and infrastructure architecture designed together\",\n  \"Dedicated onboarding and ongoing partnership\",\n];\n\nfunction PricingPage() {\n  const navigate = useNavigate();\n  const { user, loading: authLoading } = useAuth();\n  const [checkoutLoading, setCheckoutLoading] = useState(false);\n  const [isSubscribed, setIsSubscribed] = useState(false);\n\n  useEffect(() => {\n    const checkSubscription = async () => {\n      if (!user) return;\n\n      try {\n        const { data } = await supabase\n          .from(\"billing_subscriptions\")\n          .select(\"status\")\n          .eq(\"user_id\", user.id)\n          .eq(\"status\", \"active\")\n          .limit(1)\n          .maybeSingle();\n\n        setIsSubscribed(!!data);\n      } catch (err) {\n        console.error(\"Error checking subscription:\", err);\n      }\n    };\n\n    if (!authLoading) {\n      checkSubscription();\n    }\n  }, [user, authLoading]);\n\n  const handleProCheckout = async () => {\n    if (authLoading) return;\n\n    if (!user) {\n      toast.error(\"Please sign in to subscribe\");\n      navigate(\"/login\");\n      return;\n    }\n\n    const productId = import.meta.env.VITE_POLAR_PRO_PRODUCT_ID;\n    if (!productId) {\n      toast.error(\"Product configuration is missing\");\n      return;\n    }\n\n    setCheckoutLoading(true);\n\n    try {\n      const { data, error } = await supabase.functions.invoke(\n        \"create-checkout-session\",\n        {\n          body: { productId },\n        }\n      );\n\n      if (error) {\n        toast.error(error.message || \"Failed to create checkout session\");\n        return;\n      }\n\n      const url = data?.url as string | undefined;\n      if (!url) {\n        toast.error(\"Failed to get checkout URL\");\n        return;\n      }\n\n      window.location.assign(url);\n    } catch (err) {\n      toast.error(\"An unexpected error occurred\");\n      console.error(\"Checkout error:\", err);\n    } finally {\n      setCheckoutLoading(false);\n    }\n  };\n\n  return (\n    <>\n      <Navbar />\n      <main className=\"min-h-screen bg-background text-foreground\">\n        <section className=\"container mx-auto max-w-5xl px-4 pt-24 pb-16 md:pt-28 md:pb-24\">\n          <div className=\"text-center space-y-4\">\n            <h1 className=\"text-4xl font-extrabold tracking-tight md:text-5xl\">\n              Pricing\n            </h1>\n            <p className=\"text-lg text-muted-foreground max-w-3xl mx-auto\">\n              Start Vessel with transparent pricing and tailored support for any\n              team size.\n            </p>\n          </div>\n\n          <div className=\"mt-12 grid gap-6 md:mt-16 md:grid-cols-3\">\n            <Card className=\"h-full\">\n              <CardHeader className=\"pb-0 space-y-4\">\n                <CardTitle className=\"text-xl font-semibold\">Free</CardTitle>\n                <div className=\"space-y-1\">\n                  <div className=\"text-5xl font-extrabold leading-none\">$0</div>\n                  <p className=\"text-sm text-muted-foreground\">Per month</p>\n                </div>\n              </CardHeader>\n              <CardContent className=\"flex-1 space-y-5\">\n                <ul className=\"space-y-3 text-base\">\n                  {freeFeatures.map((feature) => (\n                    <li key={feature} className=\"flex items-center gap-3\">\n                      <span className=\"flex h-5 w-5 items-center justify-center text-primary\">\n                        <Check className=\"h-4 w-4\" />\n                      </span>\n                      <span className=\"text-base text-muted-foreground\">\n                        {feature}\n                      </span>\n                    </li>\n                  ))}\n                </ul>\n              </CardContent>\n              <CardFooter className=\"mt-auto\">\n                <Button\n                  className=\"h-11 w-full text-base\"\n                  variant=\"outline\"\n                  onClick={() =>\n                    window.open(\n                      \"https://github.com/cartesiancs/vessel\",\n                      \"_blank\"\n                    )\n                  }\n                >\n                  Start for free\n                </Button>\n              </CardFooter>\n            </Card>\n\n            <Card className=\"border-primary/15 shadow-lg shadow-primary/5 h-full\">\n              <CardHeader className=\"pb-0 space-y-4\">\n                <CardTitle className=\"text-xl font-semibold\">Pro</CardTitle>\n                <div className=\"space-y-1\">\n                  <div className=\"text-5xl font-extrabold leading-none\">\n                    $21\n                  </div>\n                  <p className=\"text-sm text-muted-foreground\">Per month</p>\n                </div>\n              </CardHeader>\n              <CardContent className=\"flex-1 space-y-5\">\n                <ul className=\"space-y-3 text-base\">\n                  {proFeatures.map((feature) => (\n                    <li key={feature} className=\"flex items-center gap-3\">\n                      <span className=\"flex h-5 w-5 items-center justify-center text-primary\">\n                        <Check className=\"h-4 w-4\" />\n                      </span>\n                      <span className=\"text-base text-muted-foreground\">\n                        {feature}\n                      </span>\n                    </li>\n                  ))}\n                </ul>\n              </CardContent>\n              <CardFooter className=\"mt-auto\">\n                <Button\n                  className=\"h-11 w-full text-base\"\n                  onClick={handleProCheckout}\n                  disabled={checkoutLoading || authLoading || isSubscribed}\n                >\n                  {checkoutLoading ? (\n                    <>\n                      <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                      Redirecting...\n                    </>\n                  ) : isSubscribed ? (\n                    \"Already subscribed\"\n                  ) : (\n                    \"Continue with Pro\"\n                  )}\n                </Button>\n              </CardFooter>\n            </Card>\n\n            <Card className=\"border-primary/15 shadow-lg shadow-primary/5 h-full\">\n              <CardHeader className=\"pb-0 space-y-4\">\n                <CardTitle className=\"text-xl font-semibold\">\n                  Enterprise\n                </CardTitle>\n                <div className=\"space-y-1\">\n                  <div className=\"text-5xl font-extrabold leading-none\">\n                    Custom\n                  </div>\n                  <p className=\"text-sm text-muted-foreground\">Per month</p>\n                </div>\n              </CardHeader>\n              <CardContent className=\"flex-1 space-y-5\">\n                <ul className=\"space-y-3 text-base text-white\">\n                  {enterpriseHighlights.map((item) => (\n                    <li key={item} className=\"flex items-center gap-3\">\n                      <span className=\"flex h-5 w-5 items-center justify-center text-primary\">\n                        <Check className=\"h-4 w-4\" />\n                      </span>\n                      <span className=\"text-base text-muted-foreground\">\n                        {item}\n                      </span>\n                    </li>\n                  ))}\n                </ul>\n              </CardContent>\n              <CardFooter className=\"mt-auto\">\n                <Button\n                  className=\"h-11 w-full text-base\"\n                  variant=\"ghost\"\n                  onClick={() => {\n                    window.location.href = \"mailto:info@cartesiancs.com\";\n                  }}\n                >\n                  Contact Us\n                </Button>\n              </CardFooter>\n            </Card>\n          </div>\n        </section>\n      </main>\n      <Footer />\n    </>\n  );\n}\n\nexport default PricingPage;\n"
  },
  {
    "path": "apps/landing/src/pages/Privacy.tsx",
    "content": "import { Navbar } from \"@/components/Navbar\";\nimport { Footer } from \"@/components/Footer\";\nimport { Button } from \"@/components/ui/button\";\nimport { SecurityCTASection } from \"@/components/sections/SecurityCta\";\nimport { useFadeInOnScroll } from \"@/lib/useFadeInOnScroll\";\nimport {\n  EyeOff,\n  Code,\n  HardDrive,\n  Lock,\n  UserCheck,\n  BookOpen,\n  Check,\n  X,\n} from \"lucide-react\";\nimport { FaGithub } from \"react-icons/fa\";\n\nfunction cx(...classes: Array<string | false | null | undefined>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\nconst privacyPrinciples = [\n  {\n    icon: EyeOff,\n    title: \"No Tracking\",\n    description:\n      \"Vessel contains zero analytics trackers, no telemetry beacons, and no fingerprinting. Your usage patterns are never observed or recorded.\",\n  },\n  {\n    icon: Code,\n    title: \"Open Source\",\n    description:\n      \"Every line of the Vessel core is publicly auditable on GitHub. You never have to trust a black box — verify the code yourself.\",\n  },\n  {\n    icon: HardDrive,\n    title: \"Local-First\",\n    description:\n      \"All processing happens on your own hardware. Sensor data, video streams, and automation logic stay on your network by default.\",\n  },\n  {\n    icon: Lock,\n    title: \"Encrypted by Design\",\n    description:\n      \"Connections between your devices and Vessel use industry-standard encryption. When you opt into cloud services, data is encrypted in transit and at rest.\",\n  },\n  {\n    icon: UserCheck,\n    title: \"You Own Your Data\",\n    description:\n      \"There is no data harvesting, no profiling, and no selling of information to third parties. Your operational data is yours alone.\",\n  },\n  {\n    icon: BookOpen,\n    title: \"Full Transparency\",\n    description:\n      \"Our privacy policy is written in plain language. We disclose every piece of data we collect and exactly how it is used.\",\n  },\n];\n\nconst cloudCollected = [\n  {\n    label: \"Account information\",\n    detail: \"Name and email via Google OAuth for authentication.\",\n  },\n  {\n    label: \"Payment data\",\n    detail: \"Processed by Polar. We never store full card numbers.\",\n  },\n  {\n    label: \"Tunnel usage metadata\",\n    detail: \"Connection logs and usage statistics for service delivery.\",\n  },\n  {\n    label: \"Device connection metadata\",\n    detail: \"Device identifiers and connection data for tunnel routing.\",\n  },\n  {\n    label: \"Server logs\",\n    detail: \"IP addresses and access times for security and troubleshooting.\",\n  },\n];\n\nconst neverCollected = [\n  \"Your sensor data or video streams\",\n  \"The content of your automation flows\",\n  \"Your device configurations or credentials\",\n  \"Location data from your devices\",\n  \"Behavioral analytics or usage profiling\",\n  \"Data from your self-hosted deployments\",\n];\n\nexport function PrivacyPage() {\n  const { ref: principlesRef, isVisible: principlesVisible } =\n    useFadeInOnScroll<HTMLElement>();\n  const { ref: cloudRef, isVisible: cloudVisible } =\n    useFadeInOnScroll<HTMLElement>();\n\n  return (\n    <>\n      <Navbar />\n      <main className='w-screen bg-background text-foreground'>\n        {/* Hero Section */}\n        <section className='flex min-h-[600px] flex-col items-center justify-center px-8 md:px-10'>\n          <div className='flex max-w-3xl flex-col items-center gap-y-6 text-center'>\n            <h1 className='text-4xl md:text-6xl lg:text-6xl md:leading-17 leading-10 md:font-bold font-semibold tracking-tight text-center'>\n              Absolute Privacy\n            </h1>\n            <div className='flex items-center gap-x-4 pt-2'>\n              <Button\n                variant='default'\n                onClick={() => (location.href = \"/privacy-policy\")}\n              >\n                Read Privacy Policy\n              </Button>\n              <Button\n                variant='outline'\n                onClick={() =>\n                  window.open(\"https://github.com/cartesiancs/vessel\")\n                }\n              >\n                <FaGithub />\n                View Source Code\n              </Button>\n            </div>\n          </div>\n        </section>\n\n        {/* Privacy Principles */}\n        <section ref={principlesRef} className='w-full bg-black text-white'>\n          <div\n            className={cx(\n              \"container mx-auto max-w-6xl px-8 py-40 md:px-10\",\n              \"transition-all duration-700 ease-out\",\n              principlesVisible\n                ? \"opacity-100 translate-y-0\"\n                : \"opacity-0 translate-y-6\",\n            )}\n          >\n            <div className='mb-14 text-left'>\n              <h2 className='text-3xl font-bold tracking-tight md:text-4xl'>\n                Privacy by Principle,{\" \"}\n                <span className='text-neutral-500'>not by Promise</span>\n              </h2>\n            </div>\n            <div className='grid gap-8 md:grid-cols-2 lg:grid-cols-3'>\n              {privacyPrinciples.map((principle, index) => {\n                const Icon = principle.icon;\n                return (\n                  <div\n                    key={principle.title}\n                    className={cx(\n                      \"space-y-4 border border-white/10 p-6 transition-all duration-700 ease-out\",\n                      principlesVisible\n                        ? \"opacity-100 translate-y-0\"\n                        : \"opacity-0 translate-y-4\",\n                    )}\n                    style={{ transitionDelay: `${index * 100}ms` }}\n                  >\n                    <div className='flex h-12 w-12 items-center justify-center border border-white/10 bg-white/5'>\n                      <Icon\n                        className='h-6 w-6 text-white/80'\n                        strokeWidth={1.5}\n                      />\n                    </div>\n                    <h3 className='text-lg font-semibold text-white'>\n                      {principle.title}\n                    </h3>\n                    <p className='text-sm leading-relaxed text-white/55'>\n                      {principle.description}\n                    </p>\n                  </div>\n                );\n              })}\n            </div>\n          </div>\n        </section>\n\n        {/* Cloud Services Privacy */}\n        <section ref={cloudRef} className='w-full bg-black text-white'>\n          <div\n            className={cx(\n              \"container mx-auto max-w-6xl px-8 py-40 md:px-10\",\n              \"transition-all duration-700 ease-out\",\n              cloudVisible\n                ? \"opacity-100 translate-y-0\"\n                : \"opacity-0 translate-y-6\",\n            )}\n          >\n            <div className='mb-14 text-left'>\n              <h2 className='text-3xl font-bold tracking-tight md:text-4xl'>\n                When You Use Our Cloud,{\" \"}\n                <span className='text-neutral-500'>here is what happens.</span>\n              </h2>\n              <p className='mt-4 max-w-2xl text-base leading-relaxed text-white/55'>\n                If you choose to use Vessel's cloud services such as Remote\n                Tunnel, we collect only what is strictly necessary to provide\n                the service. Here is a complete breakdown.\n              </p>\n            </div>\n\n            <div className='grid gap-8 md:grid-cols-2'>\n              <div className='space-y-6 border border-white/10 p-8'>\n                <h3 className='text-xl font-semibold text-white'>\n                  What We Collect\n                </h3>\n                <ul className='space-y-4'>\n                  {cloudCollected.map((item) => (\n                    <li key={item.label} className='flex items-start gap-3'>\n                      <Check className='mt-0.5 h-5 w-5 shrink-0 text-white/40' />\n                      <div>\n                        <span className='text-sm font-medium text-white'>\n                          {item.label}\n                        </span>\n                        <p className='mt-1 text-sm text-white/45'>\n                          {item.detail}\n                        </p>\n                      </div>\n                    </li>\n                  ))}\n                </ul>\n              </div>\n\n              <div className='space-y-6 border border-white/10 p-8'>\n                <h3 className='text-xl font-semibold text-white'>\n                  What We Never Collect\n                </h3>\n                <ul className='space-y-4'>\n                  {neverCollected.map((item) => (\n                    <li key={item} className='flex items-start gap-3'>\n                      <X className='mt-0.5 h-5 w-5 shrink-0 text-white/40' />\n                      <span className='text-sm text-white/55'>{item}</span>\n                    </li>\n                  ))}\n                </ul>\n              </div>\n            </div>\n          </div>\n        </section>\n\n        <SecurityCTASection />\n      </main>\n      <Footer />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/pages/PrivacyPolicy.tsx",
    "content": "import { Navbar } from \"@/components/Navbar\";\nimport { Footer } from \"@/components/Footer\";\n\nexport default function PrivacyPolicyPage() {\n  return (\n    <>\n      <Navbar />\n      <main className=\"min-h-screen bg-background text-foreground\">\n        <article className=\"container mx-auto max-w-4xl px-4 pt-24 pb-16 md:pt-28 md:pb-24\">\n          <h1 className=\"text-4xl font-extrabold tracking-tight md:text-5xl mb-8\">\n            Privacy Policy\n          </h1>\n          <p className=\"text-muted-foreground mb-8\">\n            Last updated: January 23, 2026\n          </p>\n\n          <div className=\"prose prose-invert max-w-none space-y-8\">\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">1. Introduction</h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                Vessel (\"we,\" \"our,\" or \"us\") is committed to protecting your\n                privacy. This Privacy Policy explains how we collect, use,\n                disclose, and safeguard your information when you use our\n                services, including our cloud-based remote tunnel service and\n                related offerings.\n              </p>\n              <p className=\"text-muted-foreground leading-relaxed mt-4\">\n                Our core dashboard and orchestration software is open source and\n                available on GitHub. This Privacy Policy applies specifically to\n                the cloud services we provide, including account management,\n                remote tunnel access, and subscription billing.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                2. Information We Collect\n              </h2>\n\n              <h3 className=\"text-xl font-semibold mb-3 mt-6\">\n                2.1 Information You Provide\n              </h3>\n              <ul className=\"list-disc list-inside text-muted-foreground space-y-2\">\n                <li>\n                  <strong>Account Information:</strong> When you create an\n                  account, we collect your name, email address, and\n                  authentication credentials through Google OAuth.\n                </li>\n                <li>\n                  <strong>Payment Information:</strong> When you subscribe to\n                  our paid services, payment processing is handled by our\n                  third-party payment processor (Polar). We do not store your\n                  full credit card numbers.\n                </li>\n                <li>\n                  <strong>Support Communications:</strong> If you contact us for\n                  support, we collect the information you provide in your\n                  communications.\n                </li>\n              </ul>\n\n              <h3 className=\"text-xl font-semibold mb-3 mt-6\">\n                2.2 Information Collected Automatically\n              </h3>\n              <ul className=\"list-disc list-inside text-muted-foreground space-y-2\">\n                <li>\n                  <strong>Usage Data:</strong> We collect information about how\n                  you interact with our services, including connection logs,\n                  tunnel usage statistics, and feature usage.\n                </li>\n                <li>\n                  <strong>Device Information:</strong> We may collect\n                  information about the devices you use to access our services,\n                  including device identifiers and connection metadata.\n                </li>\n                <li>\n                  <strong>Log Data:</strong> Our servers automatically record\n                  information including IP addresses, access times, and request\n                  details for security and troubleshooting purposes.\n                </li>\n              </ul>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                3. How We Use Your Information\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed mb-4\">\n                We use the information we collect to:\n              </p>\n              <ul className=\"list-disc list-inside text-muted-foreground space-y-2\">\n                <li>Provide, maintain, and improve our services</li>\n                <li>Process transactions and manage your subscription</li>\n                <li>\n                  Send you technical notices, updates, and support messages\n                </li>\n                <li>Respond to your comments, questions, and requests</li>\n                <li>\n                  Monitor and analyze trends, usage, and activities in\n                  connection with our services\n                </li>\n                <li>\n                  Detect, investigate, and prevent fraudulent transactions and\n                  other illegal activities\n                </li>\n                <li>Protect the rights and safety of Vessel and our users</li>\n              </ul>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                4. Data Sharing and Disclosure\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed mb-4\">\n                We do not sell your personal information. We may share your\n                information in the following circumstances:\n              </p>\n              <ul className=\"list-disc list-inside text-muted-foreground space-y-2\">\n                <li>\n                  <strong>Service Providers:</strong> We share information with\n                  third-party vendors who perform services on our behalf,\n                  including payment processing (Polar), authentication\n                  (Supabase), and cloud infrastructure providers.\n                </li>\n                <li>\n                  <strong>Legal Requirements:</strong> We may disclose\n                  information if required by law, regulation, or legal process.\n                </li>\n                <li>\n                  <strong>Business Transfers:</strong> In connection with any\n                  merger, sale of company assets, or acquisition, your\n                  information may be transferred.\n                </li>\n                <li>\n                  <strong>With Your Consent:</strong> We may share information\n                  with your consent or at your direction.\n                </li>\n              </ul>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                5. Open Source Software\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                The Vessel dashboard and core orchestration software are open\n                source projects maintained on GitHub. When you self-host Vessel,\n                no data is transmitted to us unless you explicitly connect to\n                our cloud services. The open source software operates entirely\n                on your own infrastructure.\n              </p>\n              <p className=\"text-muted-foreground leading-relaxed mt-4\">\n                This Privacy Policy applies only to data collected through our\n                cloud services (such as Remote Tunnel, account management, and\n                subscription services) and our website.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">6. Data Security</h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                We implement appropriate technical and organizational measures\n                to protect your personal information against unauthorized\n                access, alteration, disclosure, or destruction. These measures\n                include encryption in transit and at rest, access controls, and\n                regular security assessments.\n              </p>\n              <p className=\"text-muted-foreground leading-relaxed mt-4\">\n                However, no method of transmission over the Internet or\n                electronic storage is 100% secure. While we strive to protect\n                your personal information, we cannot guarantee its absolute\n                security.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">7. Data Retention</h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                We retain your personal information for as long as your account\n                is active or as needed to provide you services. We will retain\n                and use your information as necessary to comply with our legal\n                obligations, resolve disputes, and enforce our agreements.\n              </p>\n              <p className=\"text-muted-foreground leading-relaxed mt-4\">\n                You may request deletion of your account and associated data by\n                contacting us. Upon deletion, we will remove your personal\n                information from our active systems, though some information may\n                be retained in backups for a limited period.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">8. Your Rights</h2>\n              <p className=\"text-muted-foreground leading-relaxed mb-4\">\n                Depending on your location, you may have certain rights\n                regarding your personal information:\n              </p>\n              <ul className=\"list-disc list-inside text-muted-foreground space-y-2\">\n                <li>\n                  <strong>Access:</strong> You can request a copy of your\n                  personal information.\n                </li>\n                <li>\n                  <strong>Correction:</strong> You can request that we correct\n                  inaccurate information.\n                </li>\n                <li>\n                  <strong>Deletion:</strong> You can request that we delete your\n                  personal information.\n                </li>\n                <li>\n                  <strong>Portability:</strong> You can request a copy of your\n                  data in a portable format.\n                </li>\n                <li>\n                  <strong>Opt-out:</strong> You can opt out of certain data\n                  processing activities.\n                </li>\n              </ul>\n              <p className=\"text-muted-foreground leading-relaxed mt-4\">\n                To exercise these rights, please contact us at the email address\n                provided below.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                9. California Privacy Rights\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                If you are a California resident, you have additional rights\n                under the California Consumer Privacy Act (CCPA). These include\n                the right to know what personal information we collect, the\n                right to delete your personal information, and the right to\n                opt-out of the sale of personal information. We do not sell\n                personal information.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                10. Children's Privacy\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                Our services are not directed to children under the age of 13.\n                We do not knowingly collect personal information from children\n                under 13. If we learn that we have collected personal\n                information from a child under 13, we will take steps to delete\n                such information promptly.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                11. International Data Transfers\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                Your information may be transferred to and processed in\n                countries other than your country of residence. These countries\n                may have data protection laws that are different from the laws\n                of your country. We take appropriate safeguards to ensure that\n                your personal information remains protected in accordance with\n                this Privacy Policy.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                12. Changes to This Policy\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                We may update this Privacy Policy from time to time. If we make\n                material changes, we will notify you by updating the date at the\n                top of this policy and, in some cases, we may provide additional\n                notice (such as adding a statement to our website or sending you\n                a notification).\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">13. Contact Us</h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                If you have any questions about this Privacy Policy or our\n                privacy practices, please contact us at:\n              </p>\n              <p className=\"text-muted-foreground leading-relaxed mt-4\">\n                <strong>Email:</strong>{\" \"}\n                <a\n                  href=\"mailto:info@cartesiancs.com\"\n                  className=\"text-primary hover:underline\"\n                >\n                  info@cartesiancs.com\n                </a>\n              </p>\n              <p className=\"text-muted-foreground leading-relaxed mt-2\">\n                <strong>GitHub:</strong>{\" \"}\n                <a\n                  href=\"https://github.com/cartesiancs/vessel\"\n                  className=\"text-primary hover:underline\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                >\n                  github.com/cartesiancs/vessel\n                </a>\n              </p>\n            </section>\n          </div>\n        </article>\n      </main>\n      <Footer />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/pages/Roadmap.tsx",
    "content": "import { Navbar } from \"@/components/Navbar\";\nimport { CheckCircle, Circle, Rocket } from \"lucide-react\";\nimport {\n  Card,\n  CardContent,\n  CardHeader,\n  CardTitle,\n  CardDescription,\n} from \"@/components/ui/card\";\nimport { Footer } from \"@/components/Footer\";\n\nconst roadmapData = [\n  {\n    status: \"completed\",\n    title: \"Phase 1: Prototype\",\n    description:\n      \"Defined core features and finalized the technology stack. Completed user experience (UX) design.\",\n    icon: <CheckCircle className='h-6 w-6 text-green-500' />,\n  },\n  {\n    status: \"in-progress\",\n    title: \"Phase 2: Core Feature Development\",\n    description:\n      \"Developing key features, including video & audio hardware integration, MQTT connection, UDP, RTSP, real-time communication with WebRTC, a flow-based visual scripting tool, and map navigation, along with other minor functionalities.\",\n    icon: <Rocket className='h-6 w-6 text-blue-500 animate-pulse' />,\n  },\n  {\n    status: \"pending\",\n    title: \"Phase 3: More Advanced Features\",\n    description:\n      \"Refining the product with more advanced features, including motion detection, enhanced security, LoRa communication, Agentic AI integration, map-based decision-making, audio analysis, and a comprehensive history feature.\",\n    icon: <Circle className='h-6 w-6 text-gray-400' />,\n  },\n  {\n    status: \"pending\",\n    title: \"Phase 4: Minimum of C2 Software\",\n    description:\n      \"An integrated C2 (Command and Control) platform for spatial analysis, decision-making, threat detection, tracking, and third-party software integration.\",\n    icon: <Circle className='h-6 w-6 text-gray-400' />,\n  },\n  {\n    status: \"pending\",\n    title: \"Phase 5: Launch of Watchtower\",\n    description:\n      \"Announcing a new, all-in-one hardware solution for instrumentation, security, and detection, featuring a full suite of integrated sensors including a 360° camera, audio, GPS, thermal imaging, and various environmental sensors.\",\n    icon: <Circle className='h-6 w-6 text-gray-400' />,\n  },\n  {\n    status: \"pending\",\n    title: \"Phase 6: Launch of Self-Operating Drone\",\n    description: \"Launching a drone that can contain multiple payloads.\",\n    icon: <Circle className='h-6 w-6 text-gray-400' />,\n  },\n];\n\nfunction RoadmapPage() {\n  return (\n    <>\n      <Navbar />\n      <main className='container mx-auto max-w-3xl px-4 py-12 md:py-16'>\n        <div className='text-center mb-12'>\n          <h1 className='text-4xl font-extrabold tracking-tight lg:text-5xl pt-16'>\n            Project Roadmap\n          </h1>\n          <p className='mt-4 text-lg text-muted-foreground'>\n            Follow our journey and see what's next on our agenda.\n          </p>\n        </div>\n\n        <div className='relative'>\n          <div className='absolute left-3 top-3 h-full w-0.5 bg-border -z-10'></div>\n\n          <div className='space-y-10'>\n            {roadmapData.map((item, index) => (\n              <div key={index} className='flex items-start space-x-6'>\n                <div className='flex-shrink-0 mt-1.5 bg-background'>\n                  {item.icon}\n                </div>\n                <Card\n                  className={`w-full ${\n                    item.status === \"in-progress\"\n                      ? \"border-blue-500 shadow-lg\"\n                      : \"\"\n                  }`}\n                >\n                  <CardHeader>\n                    <div className='flex justify-between items-center'>\n                      <CardTitle>{item.title}</CardTitle>\n                      <span\n                        className={`px-3 py-1 text-xs font-semibold ${\n                          item.status === \"completed\"\n                            ? \"bg-green-100 text-green-800\"\n                            : item.status === \"in-progress\"\n                            ? \"bg-blue-100 text-blue-800\"\n                            : \"bg-gray-100 text-gray-800\"\n                        }`}\n                      >\n                        {item.status === \"completed\"\n                          ? \"Completed\"\n                          : item.status === \"in-progress\"\n                          ? \"In Progress\"\n                          : \"Planned\"}\n                      </span>\n                    </div>\n                  </CardHeader>\n                  <CardContent>\n                    <CardDescription>{item.description}</CardDescription>\n                  </CardContent>\n                </Card>\n              </div>\n            ))}\n          </div>\n        </div>\n      </main>\n      <Footer />\n    </>\n  );\n}\n\nexport default RoadmapPage;\n"
  },
  {
    "path": "apps/landing/src/pages/Terms.tsx",
    "content": "import { Navbar } from \"@/components/Navbar\";\nimport { Footer } from \"@/components/Footer\";\n\nexport default function TermsPage() {\n  return (\n    <>\n      <Navbar />\n      <main className=\"min-h-screen bg-background text-foreground\">\n        <article className=\"container mx-auto max-w-4xl px-4 pt-24 pb-16 md:pt-28 md:pb-24\">\n          <h1 className=\"text-4xl font-extrabold tracking-tight md:text-5xl mb-8\">\n            Terms of Service\n          </h1>\n          <p className=\"text-muted-foreground mb-8\">\n            Last updated: April 11, 2026\n          </p>\n\n          <div className=\"prose prose-invert max-w-none space-y-8\">\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">1. Acceptance of Terms</h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                By accessing or using Vessel's services, website, cloud\n                platform, or any related software (collectively, the \"Services\"),\n                you agree to be bound by these Terms of Service (\"Terms\"). If you\n                do not agree to these Terms, you may not use the Services.\n              </p>\n              <p className=\"text-muted-foreground leading-relaxed mt-4\">\n                We may update these Terms from time to time. Continued use of the\n                Services after any changes constitutes your acceptance of the\n                revised Terms.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">2. Description of Services</h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                Vessel is an open-source command-and-control (C2) platform for\n                orchestrating and automating physical devices. The Services\n                include:\n              </p>\n              <ul className=\"list-disc list-inside text-muted-foreground space-y-2 mt-4\">\n                <li>\n                  <strong>Open-Source Software:</strong> The Vessel dashboard and\n                  core orchestration engine, available under the applicable\n                  open-source license on GitHub.\n                </li>\n                <li>\n                  <strong>Cloud Services:</strong> Managed remote tunnel access,\n                  TURN/STUN relay services, Capsule API endpoints, account\n                  management, and subscription billing.\n                </li>\n                <li>\n                  <strong>Documentation:</strong> Technical documentation, guides,\n                  and resources provided on our website.\n                </li>\n              </ul>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">3. Account Registration</h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                To access certain features of the Services, you must create an\n                account. You agree to:\n              </p>\n              <ul className=\"list-disc list-inside text-muted-foreground space-y-2 mt-4\">\n                <li>Provide accurate and complete registration information</li>\n                <li>Maintain the security of your account credentials</li>\n                <li>\n                  Promptly update your information if it changes\n                </li>\n                <li>\n                  Accept responsibility for all activities that occur under your\n                  account\n                </li>\n              </ul>\n              <p className=\"text-muted-foreground leading-relaxed mt-4\">\n                We reserve the right to suspend or terminate accounts that violate\n                these Terms or that we reasonably believe are being used for\n                unauthorized purposes.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">4. Acceptable Use</h2>\n              <p className=\"text-muted-foreground leading-relaxed mb-4\">\n                You agree not to use the Services to:\n              </p>\n              <ul className=\"list-disc list-inside text-muted-foreground space-y-2\">\n                <li>\n                  Violate any applicable laws, regulations, or third-party rights\n                </li>\n                <li>\n                  Interfere with or disrupt the integrity or performance of the\n                  Services\n                </li>\n                <li>\n                  Attempt to gain unauthorized access to any systems, networks,\n                  or data\n                </li>\n                <li>\n                  Distribute malware, viruses, or any other harmful software\n                </li>\n                <li>\n                  Engage in any activity that could damage, disable, or impair\n                  the Services\n                </li>\n                <li>\n                  Use the Services for any illegal surveillance or unauthorized\n                  monitoring activities\n                </li>\n                <li>\n                  Resell, redistribute, or sublicense the cloud Services without\n                  our prior written consent\n                </li>\n              </ul>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                5. Subscription and Payments\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                Certain features of the Services require a paid subscription.\n                By subscribing, you agree to the following:\n              </p>\n              <ul className=\"list-disc list-inside text-muted-foreground space-y-2 mt-4\">\n                <li>\n                  <strong>Billing:</strong> Subscriptions are billed on a\n                  recurring basis (monthly or annually) at the prices listed on\n                  our pricing page at the time of purchase.\n                </li>\n                <li>\n                  <strong>Payment Processing:</strong> Payments are processed by\n                  our third-party payment provider (Polar). Your use of payment\n                  services is subject to their terms.\n                </li>\n                <li>\n                  <strong>Cancellation:</strong> You may cancel your subscription\n                  at any time through your dashboard. Upon cancellation, you will\n                  retain access until the end of your current billing period.\n                </li>\n                <li>\n                  <strong>Refunds:</strong> Subscription fees are generally\n                  non-refundable. Refunds may be granted at our sole discretion\n                  on a case-by-case basis.\n                </li>\n                <li>\n                  <strong>Price Changes:</strong> We reserve the right to change\n                  our prices. We will provide advance notice of any price changes.\n                </li>\n              </ul>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                6. Intellectual Property\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                The Vessel open-source software is licensed under the terms of\n                its applicable open-source license, as specified in the project\n                repository. All other aspects of the Services — including but\n                not limited to the website design, branding, logos, cloud\n                infrastructure, and proprietary APIs — are owned by Vessel and\n                protected by intellectual property laws.\n              </p>\n              <p className=\"text-muted-foreground leading-relaxed mt-4\">\n                You retain ownership of any data or content you transmit through\n                the Services. By using the Services, you grant us a limited\n                license to process your data solely to provide and improve the\n                Services.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                7. Open-Source Components\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                Vessel's core software is open source. Your use of the\n                open-source components is governed by the applicable open-source\n                license. In the event of a conflict between these Terms and the\n                open-source license, the open-source license will prevail with\n                respect to the open-source components.\n              </p>\n              <p className=\"text-muted-foreground leading-relaxed mt-4\">\n                Self-hosted instances of Vessel operate independently, and these\n                Terms apply only to the extent that you connect to or use our\n                cloud Services.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                8. Service Availability\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                We strive to maintain high availability of our cloud Services but\n                do not guarantee uninterrupted or error-free operation. We may\n                suspend or discontinue any part of the Services at any time for\n                maintenance, updates, or other reasons. We will make reasonable\n                efforts to provide advance notice of planned downtime.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                9. Limitation of Liability\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                TO THE MAXIMUM EXTENT PERMITTED BY LAW, VESSEL AND ITS\n                AFFILIATES, OFFICERS, DIRECTORS, EMPLOYEES, AND AGENTS SHALL NOT\n                BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL,\n                OR PUNITIVE DAMAGES ARISING OUT OF OR RELATED TO YOUR USE OF THE\n                SERVICES, REGARDLESS OF THE CAUSE OF ACTION OR THE THEORY OF\n                LIABILITY.\n              </p>\n              <p className=\"text-muted-foreground leading-relaxed mt-4\">\n                OUR TOTAL AGGREGATE LIABILITY FOR ALL CLAIMS RELATED TO THE\n                SERVICES SHALL NOT EXCEED THE AMOUNT YOU PAID US IN THE TWELVE\n                (12) MONTHS PRECEDING THE CLAIM.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                10. Indemnification\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                You agree to indemnify, defend, and hold harmless Vessel and its\n                affiliates from and against any claims, liabilities, damages,\n                losses, and expenses (including reasonable attorneys' fees)\n                arising out of or related to your use of the Services, your\n                violation of these Terms, or your violation of any rights of a\n                third party.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">11. Termination</h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                We may terminate or suspend your access to the Services at any\n                time, with or without cause, and with or without notice. Upon\n                termination, your right to use the Services will immediately\n                cease. Provisions of these Terms that by their nature should\n                survive termination will survive, including but not limited to\n                ownership provisions, warranty disclaimers, indemnity, and\n                limitations of liability.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">\n                12. Governing Law\n              </h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                These Terms are governed by and construed in accordance with the\n                laws of the Republic of Korea, without regard to its conflict of\n                law principles. Any disputes arising under these Terms shall be\n                subject to the exclusive jurisdiction of the courts located in\n                the Republic of Korea.\n              </p>\n            </section>\n\n            <section>\n              <h2 className=\"text-2xl font-bold mb-4\">13. Contact Us</h2>\n              <p className=\"text-muted-foreground leading-relaxed\">\n                If you have any questions about these Terms, please contact us\n                at:\n              </p>\n              <p className=\"text-muted-foreground leading-relaxed mt-4\">\n                <strong>Email:</strong>{\" \"}\n                <a\n                  href=\"mailto:info@cartesiancs.com\"\n                  className=\"text-primary hover:underline\"\n                >\n                  info@cartesiancs.com\n                </a>\n              </p>\n              <p className=\"text-muted-foreground leading-relaxed mt-2\">\n                <strong>GitHub:</strong>{\" \"}\n                <a\n                  href=\"https://github.com/cartesiancs/vessel\"\n                  className=\"text-primary hover:underline\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                >\n                  github.com/cartesiancs/vessel\n                </a>\n              </p>\n            </section>\n          </div>\n        </article>\n      </main>\n      <Footer />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/landing/src/pages/UseCase.tsx",
    "content": "import { Circle } from \"lucide-react\";\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { Footer } from \"@/components/Footer\";\nimport { Navbar } from \"@/components/Navbar\";\n\nconst useCases = [\n  {\n    icon: Circle,\n    title: \"Automation with Flow\",\n    description:\n      \"Anyone can easily control the space using a visual editing tool.\",\n    image: \"/images/flow.png\",\n  },\n  {\n    icon: Circle,\n    title: \"Space Control\",\n    description:\n      \"Control your surroundings and protect your assets through an intuitive, at-a-glance map interface.\",\n    image: \"/images/map.png\",\n  },\n  {\n    icon: Circle,\n    title: \"Threat Detection (Coming Soon)\",\n    description:\n      \"Detect threats like burglars and porch pirates, and you can sound an alarm or respond automatically through Flow.\",\n  },\n  {\n    icon: Circle,\n    title: \"Local Mode (Coming Soon)\",\n    description:\n      \"Install the Vessel server on your laptop to use it on the go or in different network environments.\",\n  },\n];\n\nfunction UsecasePage() {\n  return (\n    <>\n      <Navbar />\n      <main className='container mx-auto max-w-5xl px-4 py-12 md:py-16'>\n        <div className='text-center mb-16'>\n          <h1 className='text-4xl font-extrabold tracking-tight lg:text-5xl pt-16'>\n            Use Cases\n          </h1>\n          <p className='mt-4 text-lg text-muted-foreground'>\n            See how our product can be utilized in various scenarios.\n          </p>\n        </div>\n\n        <div className='grid grid-cols-1 gap-8 md:grid-cols-2'>\n          {useCases.map((useCase, index) => (\n            <Card\n              key={index}\n              className='flex flex-col overflow-hidden transition-transform transform hover:-translate-y-1'\n            >\n              <CardHeader>\n                <div className='flex items-center gap-4'>\n                  <div className='flex h-12 w-12 items-center justify-center bg-primary/10'>\n                    <useCase.icon className='h-6 w-6 text-primary' />\n                  </div>\n                  <CardTitle className='text-lg'>{useCase.title}</CardTitle>\n                </div>\n              </CardHeader>\n              <CardContent className='flex flex-col flex-grow'>\n                {useCase.image && (\n                  <div className='w-full h-48 mb-4 overflow-hidden'>\n                    <img\n                      src={useCase.image}\n                      alt={`${useCase.title} image`}\n                      className='w-full h-full object-cover'\n                      loading='lazy'\n                    />\n                  </div>\n                )}\n\n                <p className='text-muted-foreground'>{useCase.description}</p>\n              </CardContent>\n            </Card>\n          ))}\n        </div>\n      </main>\n      <Footer />\n    </>\n  );\n}\n\nexport default UsecasePage;\n"
  },
  {
    "path": "apps/landing/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n\ninterface ImportMetaEnv {\n  readonly VITE_SUPABASE_URL: string;\n  readonly VITE_SUPABASE_ANON_KEY: string;\n  readonly VITE_POLAR_PRO_PRODUCT_ID: string;\n}\n\ninterface ImportMeta {\n  readonly env: ImportMetaEnv;\n}\n"
  },
  {
    "path": "apps/landing/tsconfig.app.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.app.tsbuildinfo\",\n    \"target\": \"ES2022\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    },\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"erasableSyntaxOnly\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "apps/landing/tsconfig.json",
    "content": "{\n  \"files\": [],\n  \"references\": [\n    { \"path\": \"./tsconfig.app.json\" },\n    { \"path\": \"./tsconfig.node.json\" }\n  ],\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  }\n}\n"
  },
  {
    "path": "apps/landing/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\",\n    \"target\": \"ES2023\",\n    \"lib\": [\"ES2023\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"erasableSyntaxOnly\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "apps/landing/vite.config.ts",
    "content": "import { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\nimport path from \"path\";\nimport tailwindcss from \"@tailwindcss/vite\";\n\n// https://vite.dev/config/\nexport default defineConfig({\n  server: {\n    port: 5174,\n  },\n  plugins: [react(), tailwindcss()],\n  resolve: {\n    alias: {\n      \"@\": path.resolve(__dirname, \"./src\"),\n    },\n  },\n});\n"
  },
  {
    "path": "apps/server/Cargo.toml",
    "content": "[package]\nname = \"server\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\ntokio = { version = \"1\", features = [\"full\"] }\ntokio-util = { version = \"0.7\", features = [\"io\"] }\naxum = { version = \"0.7\", features = [\"ws\"] }\nserde = { version = \"1\", features = [\"derive\"] }\nserde_json = \"1\"\nanyhow = \"1\"\ntracing = \"0.1\"\ntracing-subscriber = { version = \"0.3\", features = [\"env-filter\"] }\nfutures-util = \"0.3\"\ntokio-stream = \"0.1\"\nurl = \"2\"\nrumqttd = \"0.19.0\"\ntoml = \"0.9.5\"\nrumqttc = \"0.24.0\"\nwebrtc = \"0.13.0\"\ninterceptor = \"0.14.0\"\nclap = { version = \"4.5.4\", features = [\"derive\"] } \ntower-http = { version = \"0.5.2\", features = [\"fs\", \"cors\"] }\nbytes = \"1.10.1\"\njsonwebtoken = \"9\"\naxum-extra = { version = \"0.9\", features = [\"typed-header\"] }\nchrono = { version = \"0.4.41\", features = [\"serde\"] }\nasync-trait = \"0.1.88\"\ndotenvy = \"0.15.7\"\ndiesel = { version = \"2.2.0\", features = [\"sqlite\", \"chrono\", \"r2d2\", \"returning_clauses_for_sqlite_3_35\"] }\nlibsqlite3-sys = { version = \"*\", features = [\"bundled\"] }\ndiesel_migrations = \"2.2.0\"\nbcrypt = \"0.17.0\"\nr2d2 = \"0.8\"\ndashmap = \"6.1.0\"\nrtp = \"0.13.0\"\nrand = \"0.9.2\"\nbase64 = \"0.22.1\"\ntracing-appender = \"0.2.3\"\nrust-embed = \"8\"\nmime_guess = \"2.0\"\nsysinfo = \"0.36.1\"\nreqwest = { version = \"0.12.23\", features = [\"json\"]}\ngstreamer = \"0.24\"\ngstreamer-app = \"0.24\"\ncrossbeam-channel = \"0.5.15\"\nort = \"=2.0.0-rc.10\"\nndarray = \"0.15\"\nimage = \"0.25\"\nonce_cell = \"1.19\"\nopus = \"0.3\"\ntract-onnx = \"0.22\"\nlazy_static = \"1.5.0\"\ncolored = \"3.0.0\"\nlocal-ip-address = \"0.6.5\"\nuuid = \"1.18.1\"\nrhai = { version = \"1\", features = [\"serde\"] }\nrhai-rand = \"0.1\"\nconfig = { version =  \"0.15.15\", features = [\"toml\"] }\ntokio-tungstenite = { version = \"0.27\", features = [\"rustls-tls-native-roots\"] }\ntungstenite = { version = \"0.27\", features = [\"rustls-tls-native-roots\"] }\nhttparse = \"1.9\"\nhttp = \"1\"\n\n\n[[bin]]\nname = \"server\"\npath = \"src/main.rs\"\n\n\n\n[dev-dependencies]\ntokio-test = \"0.4\"\ntempfile = \"3\"\n\n[target.x86_64-unknown-linux-musl]\nlinker = \"musl-gcc\"\n\n[target.x86_64-pc-windows-gnu]\nlinker = 'x86_64-w64-mingw32-gcc'\n"
  },
  {
    "path": "apps/server/build.rs",
    "content": "use std::env;\nuse std::process::Command;\n\nfn main() -> Result<(), Box<dyn std::error::Error>> {\n    if let Ok(profile) = env::var(\"PROFILE\") {\n        if profile == \"release\" {\n            let client_dir = std::path::Path::new(\"../../apps/client\");\n\n            println!(\"cargo:warning=Running 'npm run build' in ../../apps/client...\");\n            let output = Command::new(\"npm\")\n                .arg(\"run\")\n                .arg(\"build\")\n                .current_dir(&client_dir)\n                .output()?;\n\n            if !output.status.success() {\n                panic!(\n                    \"npm run build failed: {}\",\n                    String::from_utf8_lossy(&output.stderr)\n                );\n            }\n        }\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "apps/server/diesel.toml",
    "content": "# For documentation on how to configure this file,\n# see https://diesel.rs/guides/configuring-diesel-cli\n\n\n[print_schema]\nfile = \"src/db/schema.rs\"\ncustom_type_derives = [\"diesel::query_builder::QueryId\", \"Clone\"]\n\n[migrations_directory]\ndir = \"/Users/huhhyeongjun/Documents/GitHub/vessel/apps/server/migrations\"\n"
  },
  {
    "path": "apps/server/migrations/.keep",
    "content": ""
  },
  {
    "path": "apps/server/migrations/2025-08-07-152325_users/down.sql",
    "content": "-- This file should undo anything in `up.sql`\nDROP TABLE users;\n"
  },
  {
    "path": "apps/server/migrations/2025-08-07-152325_users/up.sql",
    "content": "CREATE TABLE users (\n    id INTEGER PRIMARY KEY NOT NULL,\n    username VARCHAR(255) NOT NULL UNIQUE,\n    email VARCHAR(255) NOT NULL UNIQUE,\n    password_hash VARCHAR(255) NOT NULL,\n    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE TABLE devices (\n    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n    device_id VARCHAR(255) NOT NULL UNIQUE, \n    name VARCHAR(255),\n    manufacturer VARCHAR(255),\n    model VARCHAR(255)\n);\n\nCREATE TABLE device_tokens (\n    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n    device_id INTEGER NOT NULL UNIQUE,\n    token_hash VARCHAR(255) NOT NULL UNIQUE,\n    expires_at DATETIME,\n    last_used_at DATETIME,\n    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    FOREIGN KEY(device_id) REFERENCES devices(id) ON DELETE CASCADE\n);\n\nCREATE TABLE entities (\n    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n    entity_id VARCHAR(255) NOT NULL UNIQUE,\n    device_id INTEGER,\n    friendly_name VARCHAR(255),\n    platform VARCHAR(255),\n    entity_type VARCHAR(255),\n    FOREIGN KEY(device_id) REFERENCES devices(id) ON DELETE CASCADE\n);\n\nCREATE TABLE states_meta (\n    metadata_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n    entity_id VARCHAR(255) NOT NULL UNIQUE\n);\n\nCREATE TABLE states (\n    state_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n    metadata_id INTEGER,\n    state VARCHAR(255),\n    attributes TEXT,\n    last_changed DATETIME,\n    last_updated DATETIME,\n    created DATETIME,\n    FOREIGN KEY(metadata_id) REFERENCES states_meta(metadata_id)\n);\n\nCREATE TABLE events (\n    event_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n    event_type VARCHAR(64),\n    event_data TEXT,\n    origin VARCHAR(32),\n    time_fired DATETIME\n);\n\nCREATE TABLE entities_configurations (\n    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n    entity_id INTEGER NOT NULL UNIQUE,\n    configuration TEXT NOT NULL,\n    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    FOREIGN KEY(entity_id) REFERENCES entities(id) ON DELETE CASCADE\n);\n\nCREATE TABLE system_configurations (\n    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n    key VARCHAR(255) NOT NULL UNIQUE,\n    value TEXT NOT NULL,\n    enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)),\n    description TEXT,\n    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE TABLE flows (\n    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n    name VARCHAR(255) NOT NULL,\n    description TEXT,\n    enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)),\n    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE TABLE flow_versions (\n    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n    flow_id INTEGER NOT NULL,\n    version INTEGER NOT NULL,\n    graph_json TEXT NOT NULL,\n    comment TEXT,\n    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    FOREIGN KEY(flow_id) REFERENCES flows(id) ON DELETE CASCADE,\n    UNIQUE(flow_id, version)\n);"
  },
  {
    "path": "apps/server/migrations/2025-08-22-092047_map/down.sql",
    "content": "-- This file should undo anything in `up.sql`\nDROP TABLE map_layers;\nDROP TABLE map_features;\nDROP TABLE map_vertices;\n"
  },
  {
    "path": "apps/server/migrations/2025-08-22-092047_map/up.sql",
    "content": "\nCREATE TABLE map_layers (\n    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n    name VARCHAR(255) NOT NULL,\n    description TEXT,\n    owner_user_id INTEGER NOT NULL,\n    is_visible INTEGER NOT NULL DEFAULT 1 CHECK(is_visible IN (0, 1)), \n    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    FOREIGN KEY(owner_user_id) REFERENCES users(id) ON DELETE CASCADE\n);\n\n\nCREATE TABLE map_features (\n    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n    layer_id INTEGER NOT NULL,\n    feature_type VARCHAR(10) NOT NULL CHECK(feature_type IN ('POINT', 'LINE', 'POLYGON')), \n    name VARCHAR(255),\n    style_properties TEXT,\n    created_by_user_id INTEGER NOT NULL,\n    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    FOREIGN KEY(layer_id) REFERENCES map_layers(id) ON DELETE CASCADE,\n    FOREIGN KEY(created_by_user_id) REFERENCES users(id) ON DELETE SET NULL\n);\n\n\nCREATE TABLE map_vertices (\n    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n    feature_id INTEGER NOT NULL,\n    latitude REAL NOT NULL,\n    longitude REAL NOT NULL,\n    altitude REAL,\n    sequence INTEGER NOT NULL,\n    FOREIGN KEY(feature_id) REFERENCES map_features(id) ON DELETE CASCADE\n);"
  },
  {
    "path": "apps/server/migrations/2025-09-08-073323_create_rbac_tables/down.sql",
    "content": "DROP TABLE user_roles;\nDROP TABLE role_permissions;\nDROP TABLE permissions;\nDROP TABLE roles;"
  },
  {
    "path": "apps/server/migrations/2025-09-08-073323_create_rbac_tables/up.sql",
    "content": "CREATE TABLE roles (\n    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n    name VARCHAR(255) NOT NULL UNIQUE, -- ex: 'admin', 'editor', 'viewer'\n    description TEXT\n);\n\nCREATE TABLE permissions (\n    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n    name VARCHAR(255) NOT NULL UNIQUE, -- ex: 'users:create', 'devices:edit', 'flows:view'\n    description TEXT\n);\n\nCREATE TABLE role_permissions (\n    role_id INTEGER NOT NULL,\n    permission_id INTEGER NOT NULL,\n    PRIMARY KEY (role_id, permission_id),\n    FOREIGN KEY(role_id) REFERENCES roles(id) ON DELETE CASCADE,\n    FOREIGN KEY(permission_id) REFERENCES permissions(id) ON DELETE CASCADE\n);\n\nCREATE TABLE user_roles (\n    user_id INTEGER NOT NULL,\n    role_id INTEGER NOT NULL,\n    PRIMARY KEY (user_id, role_id),\n    FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,\n    FOREIGN KEY(role_id) REFERENCES roles(id) ON DELETE CASCADE\n);"
  },
  {
    "path": "apps/server/migrations/2025-09-11-151252_custom_node/down.sql",
    "content": "-- This file should undo anything in `up.sql`\nDROP TABLE custom_nodes;\n"
  },
  {
    "path": "apps/server/migrations/2025-09-11-151252_custom_node/up.sql",
    "content": "CREATE TABLE custom_nodes (\n  node_type TEXT PRIMARY KEY NOT NULL,\n  data TEXT NOT NULL\n);"
  },
  {
    "path": "apps/server/migrations/2025-09-19-060609_create_streams/down.sql",
    "content": "-- This file should undo anything in `up.sql`\nDROP TABLE streams;\n"
  },
  {
    "path": "apps/server/migrations/2025-09-19-060609_create_streams/up.sql",
    "content": "-- Your SQL goes here\nCREATE TABLE streams (\n    ssrc INTEGER PRIMARY KEY NOT NULL,\n    topic TEXT NOT NULL,\n    device_id TEXT NOT NULL,\n    media_type TEXT NOT NULL,\n    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE(topic, device_id)\n);"
  },
  {
    "path": "apps/server/migrations/2026-01-12-023507_dyndashboard/down.sql",
    "content": "-- This file should undo anything in `up.sql`\nDROP TABLE IF EXISTS dynamic_dashboards;\n"
  },
  {
    "path": "apps/server/migrations/2026-01-12-023507_dyndashboard/up.sql",
    "content": "-- Your SQL goes here\nCREATE TABLE dynamic_dashboards (\n    id TEXT PRIMARY KEY NOT NULL,\n    name TEXT NOT NULL,\n    layout TEXT NOT NULL,\n    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n"
  },
  {
    "path": "apps/server/migrations/2026-01-31-000001_create_recordings/down.sql",
    "content": "DROP INDEX IF EXISTS idx_recordings_started_at;\nDROP INDEX IF EXISTS idx_recordings_status;\nDROP INDEX IF EXISTS idx_recordings_topic;\nDROP TABLE recordings;\n"
  },
  {
    "path": "apps/server/migrations/2026-01-31-000001_create_recordings/up.sql",
    "content": "CREATE TABLE recordings (\n    id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,\n    stream_ssrc INTEGER NOT NULL,\n    topic TEXT NOT NULL,\n    device_id TEXT NOT NULL,\n    media_type TEXT NOT NULL,\n    filename TEXT NOT NULL,\n    file_path TEXT NOT NULL,\n    file_size INTEGER NOT NULL DEFAULT 0,\n    duration_ms INTEGER NOT NULL DEFAULT 0,\n    status TEXT NOT NULL DEFAULT 'recording',\n    started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    ended_at TIMESTAMP,\n    created_by_user_id INTEGER\n);\n\nCREATE INDEX idx_recordings_topic ON recordings(topic);\nCREATE INDEX idx_recordings_status ON recordings(status);\nCREATE INDEX idx_recordings_started_at ON recordings(started_at);\n"
  },
  {
    "path": "apps/server/src/broker_mqtt.rs",
    "content": "use std::sync::Arc;\n\nuse crate::{\n    db,\n    state::{AppState, MqttMessage, Protocol},\n};\nuse anyhow::Result;\nuse rumqttc::{AsyncClient, Event, EventLoop, Incoming, QoS};\nuse serde_json::json;\nuse tokio::sync::broadcast;\nuse tracing::{error, info, warn};\n\npub async fn start_event_loop(\n    client: AsyncClient,\n    mut eventloop: EventLoop,\n    mqtt_tx: broadcast::Sender<MqttMessage>,\n    state: Arc<AppState>,\n) -> Result<()> {\n    client.subscribe(\"#\", QoS::AtMostOnce).await?;\n\n    info!(\"Internal MQTT client connected and subscribed to all topics.\");\n\n    loop {\n        match eventloop.poll().await {\n            Ok(Event::Incoming(Incoming::Publish(p))) => {\n                let topic_str = p.topic.clone();\n                let payload_str = match std::str::from_utf8(&p.payload) {\n                    Ok(v) => v,\n                    Err(_) => {\n                        warn!(\n                            \"Received non-UTF8 payload on topic '{}'. Skipping.\",\n                            &topic_str\n                        );\n                        continue;\n                    }\n                };\n                let msg = MqttMessage {\n                    topic: p.topic,\n                    bytes: p.payload.to_vec().into(),\n                };\n\n                let topic_map = state.topic_map.read().await;\n                let matched_mapping = topic_map\n                    .iter()\n                    .find(|m| m.protocol == Protocol::MQTT && m.topic == topic_str);\n\n                if let Some(mapping) = matched_mapping {\n                    let pool = state.pool.clone();\n                    let entity_id = mapping.entity_id.clone();\n                    let state_value = payload_str.to_string();\n\n                    if let Ok(s) =\n                        db::repository::set_entity_state(&pool, &entity_id, &state_value, None)\n                    {\n                        let ws_message = json!({\n                            \"type\": \"change_state\",\n                            \"payload\": {\n                                \"entity_id\": entity_id,\n                                \"state\": s.clone()\n                            }\n                        });\n                        if let Ok(payload_str) = serde_json::to_string(&ws_message) {\n                            if state.broadcast_tx.send(payload_str).is_err() {\n                                error!(\"Failed to send health check response.\");\n                            }\n                        }\n                    } else {\n                        error!(\"Failed to set entity state\");\n                    }\n                }\n\n                if mqtt_tx.send(msg).is_err() {\n                    warn!(\"mqtt::event_loop ▶ receiver lagging, mqtt message dropped\");\n                }\n            }\n            Ok(Event::Incoming(Incoming::Disconnect)) => {\n                warn!(\"MQTT client disconnected, stopping event loop.\");\n                break;\n            }\n            Err(e) => {\n                error!(\"MQTT event loop error: {}\", e);\n                tokio::time::sleep(std::time::Duration::from_secs(1)).await;\n            }\n            _ => {}\n        }\n    }\n    Ok(())\n}\n"
  },
  {
    "path": "apps/server/src/config.rs",
    "content": "use config::{Config, ConfigError, File};\nuse rand::{distr::Alphanumeric, Rng};\nuse serde::{Deserialize, Serialize};\nuse std::{env, fs, path::Path};\n\nconst CONFIG_FILE: &str = \"config.toml\";\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Settings {\n    pub jwt_secret: String,\n    pub listen_address: String,\n    pub database_url: String,\n}\n\nimpl Settings {\n    pub fn new() -> Result<Self, ConfigError> {\n        if cfg!(debug_assertions) {\n            println!(\"INFO: Running in DEV mode. Loading from .env file.\");\n            dotenvy::dotenv().expect(\".env file not found in DEV mode\");\n\n            let s = Config::builder()\n                .add_source(config::Environment::default().separator(\"__\"))\n                .build()?;\n            s.try_deserialize()\n        } else {\n            println!(\"INFO: Running in PROD mode. Loading from {}.\", CONFIG_FILE);\n            if Path::new(CONFIG_FILE).exists() {\n                let s = Config::builder()\n                    .add_source(File::with_name(CONFIG_FILE))\n                    .build()?;\n                s.try_deserialize()\n            } else {\n                println!(\n                    \"INFO: {} not found. Generating and using a new one.\",\n                    CONFIG_FILE\n                );\n                let secret: String = rand::rng()\n                    .sample_iter(&Alphanumeric)\n                    .take(32)\n                    .map(char::from)\n                    .collect();\n\n                let default_settings = Settings {\n                    jwt_secret: secret,\n                    listen_address: \"0.0.0.0:6174\".to_string(),\n                    database_url: \"database.db\".to_string(),\n                };\n\n                let toml_content = toml::to_string(&default_settings).unwrap();\n                fs::write(CONFIG_FILE, toml_content).expect(\"Failed to write config file\");\n\n                Ok(default_settings)\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "apps/server/src/db/conn.rs",
    "content": "use diesel::r2d2::{self, ConnectionManager, CustomizeConnection};\nuse diesel::sqlite::SqliteConnection;\nuse diesel::connection::SimpleConnection;\n\nuse crate::state::DbPool;\n\n#[derive(Debug)]\nstruct SqliteConnectionCustomizer;\n\nimpl CustomizeConnection<SqliteConnection, diesel::r2d2::Error> for SqliteConnectionCustomizer {\n    fn on_acquire(&self, conn: &mut SqliteConnection) -> Result<(), diesel::r2d2::Error> {\n        conn.batch_execute(\"PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;\")\n            .map_err(|e| diesel::r2d2::Error::QueryError(e))?;\n        Ok(())\n    }\n}\n\npub fn establish_connection(database_url: &str) -> DbPool {\n    let manager = ConnectionManager::<SqliteConnection>::new(database_url);\n    r2d2::Pool::builder()\n        .connection_customizer(Box::new(SqliteConnectionCustomizer))\n        .build(manager)\n        .expect(\"Failed to create pool.\")\n}\n"
  },
  {
    "path": "apps/server/src/db/mod.rs",
    "content": "pub mod conn;\npub mod models;\npub mod repository;\npub mod schema;\n"
  },
  {
    "path": "apps/server/src/db/models.rs",
    "content": "use crate::db::schema::{\n    device_tokens, devices, entities, entities_configurations, events, flow_versions, flows,\n    map_features, map_layers, map_vertices, recordings, states, states_meta, streams,\n    system_configurations, users,\n};\nuse chrono::NaiveDateTime;\nuse diesel::prelude::*;\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\n\n#[derive(Queryable, Selectable, Identifiable, Serialize)]\n#[diesel(table_name = crate::db::schema::users)]\n#[diesel(check_for_backend(diesel::sqlite::Sqlite))]\npub struct User {\n    pub id: i32,\n    pub username: String,\n    pub email: String,\n    #[serde(skip_serializing)]\n    pub password_hash: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n}\n\n#[derive(Serialize, Clone)]\npub struct UserWithRoles {\n    pub id: i32,\n    pub username: String,\n    pub email: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n    pub roles: Vec<Role>,\n}\n\n#[derive(Insertable)]\n#[diesel(table_name = users)]\npub struct NewUser<'a> {\n    pub username: &'a str,\n    pub email: &'a str,\n    pub password_hash: &'a str,\n}\n\n#[derive(AsChangeset, Deserialize, Default)]\n#[diesel(table_name = users)]\npub struct UpdateUser {\n    pub username: Option<String>,\n    pub email: Option<String>,\n    pub password_hash: Option<String>,\n}\n\n#[derive(Queryable, Selectable, Identifiable, Serialize)]\n#[diesel(table_name = devices)]\npub struct Device {\n    pub id: i32,\n    pub device_id: String,\n    pub name: Option<String>,\n    pub manufacturer: Option<String>,\n    pub model: Option<String>,\n}\n\n#[derive(Insertable)]\n#[diesel(table_name = devices)]\npub struct NewDevice<'a> {\n    pub device_id: &'a str,\n    pub name: Option<&'a str>,\n    pub manufacturer: Option<&'a str>,\n    pub model: Option<&'a str>,\n}\n\n#[derive(Queryable, Selectable, Identifiable, Associations, Serialize, Deserialize, Clone)]\n#[diesel(table_name = entities)]\n#[diesel(belongs_to(Device))]\npub struct Entity {\n    pub id: i32,\n    pub entity_id: String,\n    pub device_id: Option<i32>,\n    pub friendly_name: Option<String>,\n    pub platform: Option<String>,\n    pub entity_type: Option<String>,\n}\n\n#[derive(Insertable)]\n#[diesel(table_name = entities)]\npub struct NewEntity<'a> {\n    pub entity_id: &'a str,\n    pub device_id: Option<i32>,\n    pub friendly_name: Option<&'a str>,\n    pub platform: Option<&'a str>,\n    pub entity_type: Option<&'a str>,\n}\n\n#[derive(Queryable, Selectable, Identifiable, Associations, Serialize, Deserialize, Clone)]\n#[diesel(table_name = entities_configurations)]\n#[diesel(belongs_to(Entity))]\n#[diesel(primary_key(id))]\npub struct EntityConfiguration {\n    pub id: i32,\n    pub entity_id: i32,\n    pub configuration: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n}\n\n#[derive(Insertable)]\n#[diesel(table_name = entities_configurations)]\npub struct NewEntityConfiguration<'a> {\n    pub entity_id: i32,\n    pub configuration: &'a str,\n}\n\n#[derive(Serialize, Deserialize, Clone)]\npub struct EntityWithConfig {\n    #[serde(flatten)]\n    pub entity: Entity,\n    pub configuration: Option<Value>,\n}\n\n#[derive(Serialize, Deserialize)]\npub struct EntityWithStateAndConfig {\n    #[serde(flatten)]\n    pub entity: Entity,\n    pub configuration: Option<Value>,\n    pub state: Option<State>,\n}\n\n#[derive(Queryable, Selectable, Identifiable, Serialize)]\n#[diesel(table_name = states_meta)]\n#[diesel(primary_key(metadata_id))]\npub struct StatesMeta {\n    pub metadata_id: i32,\n    pub entity_id: String,\n}\n\n#[derive(Insertable)]\n#[diesel(table_name = states_meta)]\npub struct NewStatesMeta<'a> {\n    pub entity_id: &'a str,\n}\n\n#[derive(Queryable, Selectable, Identifiable, Associations, Serialize, Deserialize, Clone)]\n#[diesel(table_name = states)]\n#[diesel(primary_key(state_id))]\n#[diesel(belongs_to(StatesMeta, foreign_key = metadata_id))]\npub struct State {\n    pub state_id: i32,\n    pub metadata_id: Option<i32>,\n    pub state: Option<String>,\n    pub attributes: Option<String>,\n    pub last_changed: Option<NaiveDateTime>,\n    pub last_updated: Option<NaiveDateTime>,\n    pub created: Option<NaiveDateTime>,\n}\n\n#[derive(Insertable)]\n#[diesel(table_name = states)]\npub struct NewState<'a> {\n    pub metadata_id: Option<i32>,\n    pub state: Option<&'a str>,\n    pub attributes: Option<&'a str>,\n    pub last_changed: Option<NaiveDateTime>,\n    pub last_updated: Option<NaiveDateTime>,\n    pub created: Option<NaiveDateTime>,\n}\n\n#[derive(Queryable, Selectable, Identifiable, Serialize)]\n#[diesel(table_name = events)]\n#[diesel(primary_key(event_id))]\npub struct Event {\n    pub event_id: i32,\n    pub event_type: Option<String>,\n    pub event_data: Option<String>,\n    pub origin: Option<String>,\n    pub time_fired: Option<NaiveDateTime>,\n}\n\n#[derive(Insertable)]\n#[diesel(table_name = events)]\npub struct NewEvent<'a> {\n    pub event_type: Option<&'a str>,\n    pub event_data: Option<&'a str>,\n    pub origin: Option<&'a str>,\n    pub time_fired: Option<NaiveDateTime>,\n}\n\n#[derive(Queryable, Selectable, Identifiable, Serialize, Clone, Debug)]\n#[diesel(table_name = system_configurations)]\npub struct SystemConfiguration {\n    pub id: i32,\n    pub key: String,\n    pub value: String,\n    pub enabled: i32,\n    pub description: Option<String>,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n}\n\n#[derive(Insertable, AsChangeset)]\n#[diesel(table_name = system_configurations)]\npub struct NewSystemConfiguration<'a> {\n    pub key: &'a str,\n    pub value: &'a str,\n    pub enabled: Option<i32>,\n    pub description: Option<&'a str>,\n}\n\n#[derive(Queryable, Identifiable, Serialize, Clone, Selectable)]\n#[diesel(table_name = crate::db::schema::dynamic_dashboards)]\n#[diesel(primary_key(id))]\npub struct DynamicDashboard {\n    pub id: String,\n    pub name: String,\n    pub layout: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n}\n\n#[derive(Insertable)]\n#[diesel(table_name = crate::db::schema::dynamic_dashboards)]\npub struct NewDynamicDashboard<'a> {\n    pub id: &'a str,\n    pub name: &'a str,\n    pub layout: &'a str,\n}\n\n#[derive(AsChangeset)]\n#[diesel(table_name = crate::db::schema::dynamic_dashboards)]\npub struct UpdateDynamicDashboard<'a> {\n    pub name: Option<&'a str>,\n    pub layout: Option<&'a str>,\n    pub updated_at: Option<NaiveDateTime>,\n}\n\n#[derive(Queryable, Selectable, Identifiable, Associations, Serialize, Clone)]\n#[diesel(table_name = device_tokens)]\n#[diesel(belongs_to(Device))]\npub struct DeviceToken {\n    pub id: i32,\n    pub device_id: i32,\n    #[serde(skip_serializing)]\n    pub token_hash: String,\n    pub expires_at: Option<NaiveDateTime>,\n    pub last_used_at: Option<NaiveDateTime>,\n    pub created_at: NaiveDateTime,\n}\n\n#[derive(Insertable)]\n#[diesel(table_name = device_tokens)]\npub struct NewDeviceToken<'a> {\n    pub device_id: i32,\n    pub token_hash: &'a str,\n}\n\n#[derive(Queryable, Selectable, Identifiable, Serialize, Clone)]\n#[diesel(table_name = flows)]\npub struct Flow {\n    pub id: i32,\n    pub name: String,\n    pub description: Option<String>,\n    pub enabled: i32,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n}\n\n#[derive(Insertable, AsChangeset)]\n#[diesel(table_name = flows)]\npub struct NewFlow<'a> {\n    pub name: &'a str,\n    pub description: Option<&'a str>,\n    pub enabled: Option<i32>,\n}\n\n#[derive(Queryable, Selectable, Identifiable, Associations, Serialize, Clone)]\n#[diesel(table_name = flow_versions)]\n#[diesel(belongs_to(Flow))]\npub struct FlowVersion {\n    pub id: i32,\n    pub flow_id: i32,\n    pub version: i32,\n    pub graph_json: String,\n    pub comment: Option<String>,\n    pub created_at: NaiveDateTime,\n}\n\n#[derive(Insertable)]\n#[diesel(table_name = flow_versions)]\npub struct NewFlowVersion<'a> {\n    pub flow_id: i32,\n    pub version: i32,\n    pub graph_json: &'a str,\n    pub comment: Option<&'a str>,\n}\n\n#[derive(Queryable, Selectable, Identifiable, Associations, Serialize, Clone)]\n#[diesel(table_name = crate::db::schema::map_layers)]\n#[diesel(belongs_to(User, foreign_key = owner_user_id))]\n#[diesel(check_for_backend(diesel::sqlite::Sqlite))]\npub struct MapLayer {\n    pub id: i32,\n    pub name: String,\n    pub description: Option<String>,\n    pub owner_user_id: i32,\n    pub is_visible: i32,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n}\n\n#[derive(Insertable)]\n#[diesel(table_name = map_layers)]\npub struct NewMapLayer<'a> {\n    pub name: &'a str,\n    pub description: Option<&'a str>,\n    pub owner_user_id: i32,\n    pub is_visible: Option<i32>,\n}\n\n#[derive(AsChangeset, Deserialize)]\n#[diesel(table_name = map_layers)]\npub struct UpdateMapLayer {\n    pub name: Option<String>,\n    pub description: Option<String>,\n    pub is_visible: Option<i32>,\n}\n\n#[derive(Queryable, Selectable, Identifiable, Associations, Serialize, Clone)]\n#[diesel(table_name = crate::db::schema::map_features)]\n#[diesel(belongs_to(MapLayer, foreign_key = layer_id))]\n#[diesel(belongs_to(User, foreign_key = created_by_user_id))]\n#[diesel(check_for_backend(diesel::sqlite::Sqlite))]\npub struct MapFeature {\n    pub id: i32,\n    pub layer_id: i32,\n    pub feature_type: String,\n    pub name: Option<String>,\n    pub style_properties: Option<String>,\n    pub created_by_user_id: i32,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n}\n\n#[derive(Insertable)]\n#[diesel(table_name = map_features)]\npub struct NewMapFeature<'a> {\n    pub layer_id: i32,\n    pub feature_type: &'a str,\n    pub name: Option<&'a str>,\n    pub style_properties: Option<&'a str>,\n    pub created_by_user_id: i32,\n}\n\n#[derive(AsChangeset, Deserialize, Default)]\n#[diesel(table_name = map_features)]\npub struct UpdateMapFeature {\n    pub name: Option<String>,\n    pub style_properties: Option<String>,\n}\n\nimpl UpdateMapFeature {\n    pub fn has_changes(&self) -> bool {\n        self.name.is_some() || self.style_properties.is_some()\n    }\n}\n\n#[derive(Queryable, Selectable, Identifiable, Associations, Serialize, Deserialize, Clone)]\n#[diesel(table_name = crate::db::schema::map_vertices)]\n#[diesel(belongs_to(MapFeature, foreign_key = feature_id))]\n#[diesel(check_for_backend(diesel::sqlite::Sqlite))]\npub struct MapVertex {\n    pub id: i32,\n    pub feature_id: i32,\n    pub latitude: f32,\n    pub longitude: f32,\n    pub altitude: Option<f32>,\n    pub sequence: i32,\n}\n\n#[derive(Insertable, Clone)]\n#[diesel(table_name = map_vertices)]\npub struct NewMapVertex {\n    pub feature_id: i32,\n    pub latitude: f32,\n    pub longitude: f32,\n    pub altitude: Option<f32>,\n    pub sequence: i32,\n}\n\n#[derive(Serialize, Clone)]\npub struct FeatureWithVertices {\n    #[serde(flatten)]\n    pub feature: MapFeature,\n    pub vertices: Vec<MapVertex>,\n}\n\n#[derive(Serialize, Clone)]\npub struct LayerWithFeatures {\n    #[serde(flatten)]\n    pub layer: MapLayer,\n    pub features: Vec<FeatureWithVertices>,\n}\n\n#[derive(Queryable, Selectable, Identifiable, Serialize, Deserialize, Clone)]\n#[diesel(table_name = crate::db::schema::roles)]\n#[diesel(check_for_backend(diesel::sqlite::Sqlite))]\npub struct Role {\n    pub id: i32,\n    pub name: String,\n    pub description: Option<String>,\n}\n\n#[derive(Insertable, AsChangeset, Deserialize)]\n#[diesel(table_name = crate::db::schema::roles)]\npub struct NewRole<'a> {\n    pub name: &'a str,\n    pub description: Option<&'a str>,\n}\n\n#[derive(Queryable, Selectable, Identifiable, Serialize, Deserialize, Clone)]\n#[diesel(table_name = crate::db::schema::permissions)]\n#[diesel(check_for_backend(diesel::sqlite::Sqlite))]\npub struct Permission {\n    pub id: i32,\n    pub name: String,\n    pub description: Option<String>,\n}\n\n#[derive(Insertable, Deserialize)]\n#[diesel(table_name = crate::db::schema::permissions)]\npub struct NewPermission<'a> {\n    pub name: &'a str,\n    pub description: Option<&'a str>,\n}\n\n#[derive(Queryable, Selectable, Identifiable, Associations, Serialize, Deserialize, Clone)]\n#[diesel(table_name = crate::db::schema::user_roles)]\n#[diesel(belongs_to(User))]\n#[diesel(belongs_to(Role))]\n#[diesel(primary_key(user_id, role_id))]\n#[diesel(check_for_backend(diesel::sqlite::Sqlite))]\npub struct UserRole {\n    pub user_id: i32,\n    pub role_id: i32,\n}\n\n#[derive(Insertable)]\n#[diesel(table_name = crate::db::schema::user_roles)]\npub struct NewUserRole {\n    pub user_id: i32,\n    pub role_id: i32,\n}\n\n#[derive(Queryable, Selectable, Identifiable, Associations, Serialize, Deserialize, Clone)]\n#[diesel(table_name = crate::db::schema::role_permissions)]\n#[diesel(belongs_to(Role))]\n#[diesel(belongs_to(Permission))]\n#[diesel(primary_key(role_id, permission_id))]\n#[diesel(check_for_backend(diesel::sqlite::Sqlite))]\npub struct RolePermission {\n    pub role_id: i32,\n    pub permission_id: i32,\n}\n\n#[derive(Insertable)]\n#[diesel(table_name = crate::db::schema::role_permissions)]\npub struct NewRolePermission {\n    pub role_id: i32,\n    pub permission_id: i32,\n}\n\n#[derive(Serialize, Clone)]\npub struct RoleWithPermissions {\n    pub id: i32,\n    pub name: String,\n    pub description: Option<String>,\n    pub permissions: Vec<Permission>,\n}\n\n#[derive(Queryable, Selectable, Identifiable, Serialize, Deserialize, Clone)]\n#[diesel(table_name = crate::db::schema::custom_nodes)]\n#[diesel(primary_key(node_type))]\n#[diesel(check_for_backend(diesel::sqlite::Sqlite))]\npub struct CustomNode {\n    pub node_type: String,\n    pub data: String,\n}\n\n#[derive(Serialize, Deserialize, Clone)]\npub struct CustomNodeResult {\n    pub node_type: String,\n    pub data: Option<Value>,\n}\n\n#[derive(Insertable, Deserialize)]\n#[diesel(table_name = crate::db::schema::custom_nodes)]\npub struct NewCustomNode<'a> {\n    pub node_type: &'a str,\n    pub data: &'a str,\n}\n\n#[derive(AsChangeset, Deserialize)]\n#[diesel(table_name = crate::db::schema::custom_nodes)]\npub struct UpdateCustomNode {\n    pub data: Option<String>,\n}\n\n#[derive(Queryable, Selectable, Insertable, AsChangeset, Serialize, Deserialize, Debug)]\n#[diesel(table_name = streams)]\n#[diesel(primary_key(ssrc))]\npub struct Stream {\n    pub ssrc: i32,\n    pub topic: String,\n    pub device_id: String,\n    pub media_type: String,\n    pub created_at: NaiveDateTime,\n}\n\n#[derive(Insertable)]\n#[diesel(table_name = streams)]\npub struct NewStream<'a> {\n    pub ssrc: i32,\n    pub topic: &'a str,\n    pub device_id: &'a str,\n    pub media_type: &'a str,\n}\n\n#[derive(Queryable, Selectable, Identifiable, Serialize, Clone)]\n#[diesel(table_name = recordings)]\npub struct Recording {\n    pub id: i32,\n    pub stream_ssrc: i32,\n    pub topic: String,\n    pub device_id: String,\n    pub media_type: String,\n    pub filename: String,\n    pub file_path: String,\n    pub file_size: i32,\n    pub duration_ms: i32,\n    pub status: String,\n    pub started_at: NaiveDateTime,\n    pub ended_at: Option<NaiveDateTime>,\n    pub created_by_user_id: Option<i32>,\n}\n\n#[derive(Insertable)]\n#[diesel(table_name = recordings)]\npub struct NewRecording<'a> {\n    pub stream_ssrc: i32,\n    pub topic: &'a str,\n    pub device_id: &'a str,\n    pub media_type: &'a str,\n    pub filename: &'a str,\n    pub file_path: &'a str,\n    pub status: &'a str,\n    pub created_by_user_id: Option<i32>,\n}\n\n#[derive(AsChangeset, Default)]\n#[diesel(table_name = recordings)]\npub struct UpdateRecording {\n    pub file_size: Option<i32>,\n    pub duration_ms: Option<i32>,\n    pub status: Option<String>,\n    pub ended_at: Option<NaiveDateTime>,\n}\n"
  },
  {
    "path": "apps/server/src/db/repository/dashboards.rs",
    "content": "use crate::{\n    db::models::{DynamicDashboard, NewDynamicDashboard, UpdateDynamicDashboard},\n    db::schema::dynamic_dashboards::dsl::*,\n    state::DbPool,\n};\nuse chrono::Utc;\nuse diesel::{prelude::*, OptionalExtension};\nuse serde_json::Value;\nuse uuid::Uuid;\n\npub fn list_dynamic_dashboards(pool: &DbPool) -> Result<Vec<DynamicDashboard>, anyhow::Error> {\n    let mut conn = pool.get()?;\n    let dashboards = dynamic_dashboards\n        .select(DynamicDashboard::as_select())\n        .order_by(created_at.asc())\n        .load::<DynamicDashboard>(&mut conn)?;\n    Ok(dashboards)\n}\n\npub fn get_dynamic_dashboard(\n    pool: &DbPool,\n    target_id: &str,\n) -> Result<Option<DynamicDashboard>, anyhow::Error> {\n    let mut conn = pool.get()?;\n    let dashboard = dynamic_dashboards\n        .filter(id.eq(target_id))\n        .select(DynamicDashboard::as_select())\n        .first::<DynamicDashboard>(&mut conn)\n        .optional()?;\n    Ok(dashboard)\n}\n\npub fn create_dynamic_dashboard(\n    pool: &DbPool,\n    name_value: &str,\n    layout_json: &Value,\n) -> Result<DynamicDashboard, anyhow::Error> {\n    let mut conn = pool.get()?;\n    let layout_str = serde_json::to_string(layout_json)?;\n    let new_dashboard = NewDynamicDashboard {\n        id: &Uuid::new_v4().to_string(),\n        name: name_value,\n        layout: &layout_str,\n    };\n\n    let created = diesel::insert_into(dynamic_dashboards)\n        .values(&new_dashboard)\n        .get_result::<DynamicDashboard>(&mut conn)?;\n\n    Ok(created)\n}\n\npub fn update_dynamic_dashboard(\n    pool: &DbPool,\n    target_id: &str,\n    name_value: Option<&str>,\n    layout_json: Option<&Value>,\n) -> Result<Option<DynamicDashboard>, anyhow::Error> {\n    let mut conn = pool.get()?;\n    let layout_str = layout_json.map(|v| serde_json::to_string(v)).transpose()?;\n\n    let changes = UpdateDynamicDashboard {\n        name: name_value,\n        layout: layout_str.as_deref(),\n        updated_at: Some(Utc::now().naive_utc()),\n    };\n\n    let updated = diesel::update(dynamic_dashboards.filter(id.eq(target_id)))\n        .set(changes)\n        .get_result::<DynamicDashboard>(&mut conn)\n        .optional()?;\n\n    Ok(updated)\n}\n\npub fn delete_dynamic_dashboard(pool: &DbPool, target_id: &str) -> Result<usize, anyhow::Error> {\n    let mut conn = pool.get()?;\n    let deleted = diesel::delete(dynamic_dashboards.filter(id.eq(target_id))).execute(&mut conn)?;\n    Ok(deleted)\n}\n"
  },
  {
    "path": "apps/server/src/db/repository/mod.rs",
    "content": "pub mod dashboards;\npub mod rbac;\npub mod recordings;\npub mod streams;\n\nuse std::collections::HashMap;\n\nuse chrono::Utc;\nuse diesel::GroupedBy;\nuse diesel::{\n    dsl::max, BelongingToDsl, Connection, ExpressionMethods, JoinOnDsl, OptionalExtension,\n    QueryDsl, QueryResult, RunQueryDsl, SelectableHelper,\n};\nuse serde_json::Value;\n\nuse crate::db::models::CustomNodeResult;\nuse crate::db::models::{\n    CustomNode, FeatureWithVertices, LayerWithFeatures, MapFeature, MapLayer, MapVertex,\n    NewCustomNode, NewMapFeature, NewMapLayer, NewMapVertex, Role, UpdateCustomNode,\n    UpdateMapFeature, UpdateMapLayer, UserRole, UserWithRoles,\n};\nuse crate::{\n    db::models::{\n        Device, DeviceToken, Entity, EntityConfiguration, EntityWithConfig,\n        EntityWithStateAndConfig, Flow, FlowVersion, NewDevice, NewDeviceToken, NewEntity,\n        NewEntityConfiguration, NewFlow, NewFlowVersion, NewState, NewStatesMeta,\n        NewSystemConfiguration, NewUser, State, StatesMeta, SystemConfiguration, UpdateUser, User,\n    },\n    state::DbPool,\n};\n\npub fn get_user_by_name(pool: &DbPool, target_username: &str) -> Result<User, anyhow::Error> {\n    use crate::db::schema::users::dsl::*;\n\n    let mut conn = pool.get()?;\n\n    let user = users\n        .filter(username.eq(target_username))\n        .select(User::as_select())\n        .first(&mut conn)?;\n\n    Ok(user)\n}\n\npub fn get_all_users(pool: &DbPool) -> Result<Vec<UserWithRoles>, anyhow::Error> {\n    let mut conn = pool.get()?;\n\n    let users_list = crate::db::schema::users::table.load::<User>(&mut conn)?;\n    let roles_list = crate::db::schema::roles::table.load::<Role>(&mut conn)?;\n\n    let user_roles_list = UserRole::belonging_to(&users_list).load::<UserRole>(&mut conn)?;\n\n    let roles_map: HashMap<i32, Role> = roles_list.into_iter().map(|r| (r.id, r)).collect();\n    let user_roles_grouped = user_roles_list.grouped_by(&users_list);\n    let result = users_list\n        .into_iter()\n        .zip(user_roles_grouped)\n        .map(|(user, user_role_associations)| {\n            let roles_for_user = user_role_associations\n                .iter()\n                .filter_map(|ur| roles_map.get(&ur.role_id).cloned())\n                .collect::<Vec<Role>>();\n\n            UserWithRoles {\n                id: user.id,\n                username: user.username,\n                email: user.email,\n                created_at: user.created_at,\n                updated_at: user.updated_at,\n                roles: roles_for_user,\n            }\n        })\n        .collect();\n\n    Ok(result)\n}\n\npub fn create_user(pool: &DbPool, new_user: NewUser) -> Result<User, anyhow::Error> {\n    use crate::db::schema::users::dsl::*;\n\n    let mut conn = pool.get()?;\n    let user = diesel::insert_into(users)\n        .values(&new_user)\n        .get_result(&mut conn)?;\n\n    Ok(user)\n}\n\npub fn get_user_by_id(pool: &DbPool, user_id: i32) -> Result<User, anyhow::Error> {\n    use crate::db::schema::users::dsl::*;\n\n    let mut conn = pool.get()?;\n    let user = users\n        .find(user_id)\n        .select(User::as_select())\n        .first(&mut conn)?;\n\n    Ok(user)\n}\n\npub fn update_user(\n    pool: &DbPool,\n    user_id: i32,\n    user_data: &UpdateUser,\n) -> Result<User, anyhow::Error> {\n    use crate::db::schema::users::dsl::*;\n\n    let mut conn = pool.get()?;\n    let user = diesel::update(users.find(user_id))\n        .set(user_data)\n        .get_result(&mut conn)?;\n\n    Ok(user)\n}\n\npub fn delete_user(pool: &DbPool, user_id: i32) -> Result<usize, anyhow::Error> {\n    use crate::db::schema::users::dsl::*;\n\n    let mut conn = pool.get()?;\n    let num_deleted = diesel::delete(users.find(user_id)).execute(&mut conn)?;\n\n    Ok(num_deleted)\n}\n\npub fn create_device(pool: &DbPool, new_device: NewDevice) -> Result<Device, anyhow::Error> {\n    use crate::db::schema::devices::dsl::*;\n    let mut conn = pool.get()?;\n    let device = diesel::insert_into(devices)\n        .values(&new_device)\n        .get_result(&mut conn)?;\n\n    Ok(device)\n}\n\npub fn get_all_devices(pool: &DbPool) -> Result<Vec<Device>, anyhow::Error> {\n    use crate::db::schema::devices::dsl::*;\n    let mut conn = pool.get()?;\n    let all_devices = devices\n        .select(Device::as_select())\n        .load::<Device>(&mut conn)?;\n    Ok(all_devices)\n}\n\npub fn update_device(\n    pool: &DbPool,\n    target_id: i32,\n    updated_device: &NewDevice,\n) -> Result<Device, anyhow::Error> {\n    use crate::db::schema::devices::dsl::*;\n    let mut conn = pool.get()?;\n    let device = diesel::update(devices.find(target_id))\n        .set((\n            device_id.eq(&updated_device.device_id),\n            name.eq(&updated_device.name),\n            manufacturer.eq(&updated_device.manufacturer),\n            model.eq(&updated_device.model),\n        ))\n        .get_result(&mut conn)?;\n    Ok(device)\n}\n\npub fn delete_device(pool: &DbPool, target_id: i32) -> Result<usize, anyhow::Error> {\n    use crate::db::schema::devices::dsl::*;\n    let mut conn = pool.get()?;\n    let num_deleted = diesel::delete(devices.find(target_id)).execute(&mut conn)?;\n    Ok(num_deleted)\n}\n\npub fn create_entity(pool: &DbPool, new_entity: NewEntity) -> Result<Entity, anyhow::Error> {\n    use crate::db::schema::entities::dsl::*;\n    let mut conn = pool.get()?;\n    let entity = diesel::insert_into(entities)\n        .values(&new_entity)\n        .get_result(&mut conn)?;\n    Ok(entity)\n}\n\npub fn get_all_entities(pool: &DbPool) -> Result<Vec<Entity>, anyhow::Error> {\n    use crate::db::schema::entities::dsl::*;\n    let mut conn = pool.get()?;\n    let all_entities = entities\n        .select(Entity::as_select())\n        .load::<Entity>(&mut conn)?;\n    Ok(all_entities)\n}\n\npub fn update_entity(\n    pool: &DbPool,\n    target_id: i32,\n    updated_entity: &NewEntity,\n) -> Result<Entity, anyhow::Error> {\n    use crate::db::schema::entities::dsl::*;\n    let mut conn = pool.get()?;\n    let entity = diesel::update(entities.find(target_id))\n        .set((\n            entity_id.eq(&updated_entity.entity_id),\n            device_id.eq(updated_entity.device_id),\n            friendly_name.eq(&updated_entity.friendly_name),\n            platform.eq(&updated_entity.platform),\n        ))\n        .get_result(&mut conn)?;\n    Ok(entity)\n}\n\npub fn create_entity_with_config(\n    pool: &DbPool,\n    new_entity: NewEntity,\n    config_str: &str,\n) -> Result<(Entity, Option<EntityConfiguration>), anyhow::Error> {\n    use crate::db::schema::entities;\n    use crate::db::schema::entities_configurations;\n\n    let mut conn = pool.get()?;\n\n    conn.transaction(|conn| {\n        let entity: Entity = diesel::insert_into(entities::table)\n            .values(&new_entity)\n            .get_result(conn)?;\n\n        if !config_str.is_empty() {\n            let new_config = NewEntityConfiguration {\n                entity_id: entity.id,\n                configuration: config_str,\n            };\n\n            let config: EntityConfiguration = diesel::insert_into(entities_configurations::table)\n                .values(&new_config)\n                .get_result(conn)?;\n\n            Ok((entity, Some(config)))\n        } else {\n            Ok((entity, None))\n        }\n    })\n}\n\npub fn get_all_entities_with_configs(\n    pool: &DbPool,\n) -> Result<Vec<EntityWithConfig>, anyhow::Error> {\n    use crate::db::schema::entities::dsl as e;\n    use crate::db::schema::entities_configurations::dsl as ec;\n\n    let mut conn = pool.get()?;\n\n    let results = e::entities\n        .left_join(ec::entities_configurations.on(e::id.eq(ec::entity_id)))\n        .load::<(Entity, Option<EntityConfiguration>)>(&mut conn)?;\n\n    let entities_with_configs = results\n        .into_iter()\n        .map(|(entity, config_opt)| {\n            let configuration = config_opt\n                .map(|c| serde_json::from_str(&c.configuration).unwrap_or(serde_json::Value::Null))\n                .filter(|v| !v.is_null());\n            EntityWithConfig {\n                entity,\n                configuration,\n            }\n        })\n        .collect();\n\n    Ok(entities_with_configs)\n}\n\npub fn get_all_entities_with_configs_filter(\n    pool: &DbPool,\n    entity_type_filter: Option<String>,\n) -> Result<Vec<EntityWithConfig>, anyhow::Error> {\n    use crate::db::schema::entities::dsl as e;\n    use crate::db::schema::entities_configurations::dsl as ec;\n\n    let mut conn = pool.get()?;\n\n    let mut query = e::entities\n        .left_join(ec::entities_configurations.on(e::id.eq(ec::entity_id)))\n        .into_boxed();\n\n    if let Some(e_type) = entity_type_filter {\n        query = query.filter(e::entity_type.eq(e_type));\n    }\n\n    let results = query.load::<(Entity, Option<EntityConfiguration>)>(&mut conn)?;\n\n    let entities_with_configs = results\n        .into_iter()\n        .map(|(entity, config_opt)| {\n            let configuration = config_opt\n                .map(|c| serde_json::from_str(&c.configuration).unwrap_or(serde_json::Value::Null))\n                .filter(|v| !v.is_null());\n            EntityWithConfig {\n                entity,\n                configuration,\n            }\n        })\n        .collect();\n\n    Ok(entities_with_configs)\n}\n\npub fn get_entities_by_device_id(\n    pool: &DbPool,\n    target_device_id: i32,\n) -> Result<Vec<EntityWithStateAndConfig>, anyhow::Error> {\n    use crate::db::schema::entities::dsl as e;\n    use crate::db::schema::entities_configurations::dsl as ec;\n    use crate::db::schema::states::dsl as s;\n    use crate::db::schema::states_meta::dsl as sm;\n\n    let mut conn = pool.get()?;\n\n    let entities_and_configs = e::entities\n        .filter(e::device_id.eq(target_device_id))\n        .left_join(ec::entities_configurations.on(e::id.eq(ec::entity_id)))\n        .load::<(Entity, Option<EntityConfiguration>)>(&mut conn)?;\n\n    if entities_and_configs.is_empty() {\n        return Ok(Vec::new());\n    }\n\n    let entity_ids: Vec<String> = entities_and_configs\n        .iter()\n        .map(|(entity, _)| entity.entity_id.clone())\n        .collect();\n\n    let states_meta_map: std::collections::HashMap<String, i32> = sm::states_meta\n        .filter(sm::entity_id.eq_any(&entity_ids))\n        .load::<StatesMeta>(&mut conn)?\n        .into_iter()\n        .map(|meta| (meta.entity_id, meta.metadata_id))\n        .collect();\n\n    let metadata_ids: Vec<i32> = states_meta_map.values().cloned().collect();\n\n    let latest_states: std::collections::HashMap<i32, State> = if !metadata_ids.is_empty() {\n        let relevant_states = s::states\n            .filter(s::metadata_id.eq_any(metadata_ids))\n            .order(s::last_updated.desc())\n            .load::<State>(&mut conn)?;\n\n        relevant_states\n            .into_iter()\n            .fold(std::collections::HashMap::new(), |mut acc, state| {\n                if let Some(mid) = state.metadata_id {\n                    acc.entry(mid).or_insert(state);\n                }\n                acc\n            })\n    } else {\n        std::collections::HashMap::new()\n    };\n\n    let result = entities_and_configs\n        .into_iter()\n        .map(|(entity, config_opt)| {\n            let configuration = config_opt\n                .map(|c| serde_json::from_str(&c.configuration).unwrap_or(serde_json::Value::Null))\n                .filter(|v| !v.is_null());\n\n            let state = states_meta_map\n                .get(&entity.entity_id)\n                .and_then(|metadata_id| latest_states.get(metadata_id).cloned());\n\n            EntityWithStateAndConfig {\n                entity,\n                configuration,\n                state,\n            }\n        })\n        .collect();\n\n    Ok(result)\n}\n\npub fn update_entity_with_config(\n    pool: &DbPool,\n    target_id: i32,\n    updated_entity: &NewEntity,\n    config_str: &str,\n) -> Result<(Entity, Option<EntityConfiguration>), anyhow::Error> {\n    use crate::db::schema::entities;\n    use crate::db::schema::entities_configurations;\n\n    let mut conn = pool.get()?;\n\n    conn.transaction(|conn| {\n        let entity: Entity = diesel::update(entities::table.find(target_id))\n            .set((\n                entities::entity_id.eq(&updated_entity.entity_id),\n                entities::device_id.eq(updated_entity.device_id),\n                entities::friendly_name.eq(&updated_entity.friendly_name),\n                entities::platform.eq(&updated_entity.platform),\n                entities::entity_type.eq(&updated_entity.entity_type),\n            ))\n            .get_result(conn)?;\n\n        use crate::db::schema::entities_configurations::dsl::*;\n        if !config_str.is_empty() {\n            let new_config = NewEntityConfiguration {\n                entity_id: entity.id,\n                configuration: config_str,\n            };\n\n            let config: EntityConfiguration = diesel::insert_into(entities_configurations)\n                .values(&new_config)\n                .on_conflict(entity_id)\n                .do_update()\n                .set(configuration.eq(config_str))\n                .get_result(conn)?;\n\n            Ok((entity, Some(config)))\n        } else {\n            diesel::delete(entities_configurations.filter(entity_id.eq(target_id)))\n                .execute(conn)?;\n            Ok((entity, None))\n        }\n    })\n}\n\npub fn delete_entity(pool: &DbPool, target_id: i32) -> Result<usize, anyhow::Error> {\n    use crate::db::schema::entities::dsl::*;\n    let mut conn = pool.get()?;\n    let num_deleted = diesel::delete(entities.find(target_id)).execute(&mut conn)?;\n    Ok(num_deleted)\n}\n\npub fn create_system_config(\n    pool: &DbPool,\n    new_config: NewSystemConfiguration,\n) -> Result<SystemConfiguration, anyhow::Error> {\n    use crate::db::schema::system_configurations::dsl::*;\n    let mut conn = pool.get()?;\n    let config = diesel::insert_into(system_configurations)\n        .values(&new_config)\n        .on_conflict(key)\n        .do_update()\n        .set(&new_config)\n        .get_result(&mut conn)?;\n    Ok(config)\n}\n\npub fn get_all_system_configs(pool: &DbPool) -> Result<Vec<SystemConfiguration>, anyhow::Error> {\n    use crate::db::schema::system_configurations::dsl::*;\n    let mut conn = pool.get()?;\n    let configs = system_configurations\n        .select(SystemConfiguration::as_select())\n        .load::<SystemConfiguration>(&mut conn)?;\n    Ok(configs)\n}\n\n/// `system_configurations` key for the Code workspace (file browser / storage API).\npub const CODE_SERVICE_CONFIG_KEY: &str = \"code_service_enabled\";\n\npub fn is_code_service_enabled(pool: &DbPool) -> Result<bool, anyhow::Error> {\n    use crate::db::schema::system_configurations::dsl::{key as key_col, system_configurations};\n    let mut conn = pool.get()?;\n    let config = system_configurations\n        .filter(key_col.eq(CODE_SERVICE_CONFIG_KEY))\n        .select(SystemConfiguration::as_select())\n        .first::<SystemConfiguration>(&mut conn)\n        .optional()?;\n    Ok(config.is_some_and(|c| c.enabled == 1))\n}\n\npub fn update_system_config(\n    pool: &DbPool,\n    target_id: i32,\n    updated_config: &NewSystemConfiguration,\n) -> Result<SystemConfiguration, anyhow::Error> {\n    use crate::db::schema::system_configurations::dsl::*;\n    let mut conn = pool.get()?;\n    let config = diesel::update(system_configurations.find(target_id))\n        .set(updated_config)\n        .get_result(&mut conn)?;\n    Ok(config)\n}\n\npub fn delete_system_config(pool: &DbPool, target_id: i32) -> Result<usize, anyhow::Error> {\n    use crate::db::schema::system_configurations::dsl::*;\n    let mut conn = pool.get()?;\n    let num_deleted = diesel::delete(system_configurations.find(target_id)).execute(&mut conn)?;\n    Ok(num_deleted)\n}\n\npub fn create_or_replace_device_token(\n    pool: &DbPool,\n    target_device_id: i32,\n    hashed_token: &str,\n) -> Result<DeviceToken, anyhow::Error> {\n    use crate::db::schema::device_tokens::dsl::*;\n    let mut conn = pool.get()?;\n\n    let new_token = NewDeviceToken {\n        device_id: target_device_id,\n        token_hash: hashed_token,\n    };\n\n    let token = diesel::insert_into(device_tokens)\n        .values(&new_token)\n        .on_conflict(device_id)\n        .do_update()\n        .set(token_hash.eq(hashed_token))\n        .get_result(&mut conn)?;\n\n    Ok(token)\n}\n\npub fn get_token_info_for_device(\n    pool: &DbPool,\n    target_device_id: i32,\n) -> Result<Option<DeviceToken>, anyhow::Error> {\n    use crate::db::schema::device_tokens::dsl::*;\n    let mut conn = pool.get()?;\n    let token_info = device_tokens\n        .filter(device_id.eq(target_device_id))\n        .select(DeviceToken::as_select())\n        .first::<DeviceToken>(&mut conn)\n        .optional()?;\n    Ok(token_info)\n}\n\npub fn delete_token_for_device(\n    pool: &DbPool,\n    target_device_id: i32,\n) -> Result<usize, anyhow::Error> {\n    use crate::db::schema::device_tokens::dsl::*;\n    let mut conn = pool.get()?;\n    let num_deleted =\n        diesel::delete(device_tokens.filter(device_id.eq(target_device_id))).execute(&mut conn)?;\n    Ok(num_deleted)\n}\n\npub fn get_device_by_device_id(\n    pool: &DbPool,\n    target_device_id: &str,\n) -> Result<Device, anyhow::Error> {\n    use crate::db::schema::devices::dsl::*;\n    let mut conn = pool.get()?;\n    let device_result = devices\n        .filter(device_id.eq(target_device_id))\n        .select(Device::as_select())\n        .first::<Device>(&mut conn)?;\n    Ok(device_result)\n}\n\npub fn get_device_by_id(pool: &DbPool, target_id: i32) -> Result<Device, anyhow::Error> {\n    use crate::db::schema::devices::dsl::*;\n    let mut conn = pool.get()?;\n    let device_result = devices\n        .filter(id.eq(target_id))\n        .select(Device::as_select())\n        .first::<Device>(&mut conn)?;\n    Ok(device_result)\n}\n\npub fn create_flow(pool: &DbPool, new_flow: NewFlow) -> Result<Flow, anyhow::Error> {\n    use crate::db::schema::flows::dsl::*;\n    let mut conn = pool.get()?;\n\n    let version_insert = diesel::insert_into(flows)\n        .values(&new_flow)\n        .returning(Flow::as_returning())\n        .get_result(&mut conn)?;\n\n    Ok(version_insert)\n}\n\npub fn get_all_flows(pool: &DbPool) -> Result<Vec<Flow>, anyhow::Error> {\n    use crate::db::schema::flows::dsl::*;\n    let mut conn = pool.get()?;\n    let all_flows = flows.select(Flow::as_select()).load::<Flow>(&mut conn)?;\n    Ok(all_flows)\n}\n\npub fn update_flow(\n    pool: &DbPool,\n    target_id: i32,\n    updated_flow: &NewFlow,\n) -> Result<usize, anyhow::Error> {\n    use crate::db::schema::flows::dsl::*;\n    let mut conn = pool.get()?;\n    let num_updated = diesel::update(flows.find(target_id))\n        .set(updated_flow)\n        .execute(&mut conn)?;\n    Ok(num_updated)\n}\n\npub fn delete_flow(pool: &DbPool, target_id: i32) -> Result<usize, anyhow::Error> {\n    use crate::db::schema::flows::dsl::*;\n    let mut conn = pool.get()?;\n    let num_deleted = diesel::delete(flows.find(target_id)).execute(&mut conn)?;\n    Ok(num_deleted)\n}\n\npub fn create_flow_version(\n    pool: &DbPool,\n    new_version_data: NewFlowVersion,\n) -> Result<FlowVersion, anyhow::Error> {\n    use crate::db::schema::flow_versions::dsl::*;\n    let mut conn = pool.get()?;\n    let version_insert = diesel::insert_into(flow_versions)\n        .values(&new_version_data)\n        .get_result(&mut conn)?;\n    Ok(version_insert)\n}\n\npub fn overwrite_flow_version(\n    pool: &DbPool,\n    target_flow_id: i32,\n    new_version_data: NewFlowVersion,\n) -> Result<FlowVersion, anyhow::Error> {\n    use crate::db::schema::flow_versions;\n    use crate::db::schema::flow_versions::dsl::*;\n    let mut conn = pool.get()?;\n    conn.transaction(|conn| {\n        diesel::delete(flow_versions.filter(flow_id.eq(target_flow_id))).execute(conn)?;\n        let version_insert = diesel::insert_into(flow_versions::table)\n            .values(&new_version_data)\n            .get_result(conn)?;\n        Ok(version_insert)\n    })\n}\n\npub fn get_latest_version_number(\n    pool: &DbPool,\n    target_flow_id: i32,\n) -> Result<Option<i32>, anyhow::Error> {\n    use crate::db::schema::flow_versions::dsl::*;\n    let mut conn = pool.get()?;\n    let max_version = flow_versions\n        .filter(flow_id.eq(target_flow_id))\n        .select(max(version))\n        .first::<Option<i32>>(&mut conn)?;\n    Ok(max_version)\n}\n\npub fn get_versions_for_flow(\n    pool: &DbPool,\n    target_flow_id: i32,\n) -> Result<Vec<FlowVersion>, anyhow::Error> {\n    use crate::db::schema::flow_versions::dsl::*;\n    let mut conn = pool.get()?;\n    let versions = flow_versions\n        .filter(flow_id.eq(target_flow_id))\n        .order(version.desc())\n        .select(FlowVersion::as_select())\n        .load::<FlowVersion>(&mut conn)?;\n    Ok(versions)\n}\n\npub fn get_entity_by_entity_id(\n    pool: &DbPool,\n    target_entity_id: &str,\n) -> Result<Entity, anyhow::Error> {\n    use crate::db::schema::entities::dsl::*;\n\n    let mut conn = pool.get()?;\n\n    let entity = entities\n        .filter(entity_id.eq(target_entity_id))\n        .select(Entity::as_select())\n        .first(&mut conn)?;\n\n    Ok(entity)\n}\n\npub fn set_entity_state(\n    pool: &DbPool,\n    target_entity_id: &str,\n    new_state_value: &str,\n    new_attributes: Option<&Value>,\n) -> Result<State, anyhow::Error> {\n    let mut conn = pool.get()?;\n\n    conn.transaction(|conn| {\n        let meta: StatesMeta = {\n            use crate::db::schema::states_meta::dsl::*;\n            let existing_meta = states_meta\n                .filter(entity_id.eq(target_entity_id))\n                .first::<StatesMeta>(conn)\n                .optional()?;\n\n            if let Some(meta) = existing_meta {\n                meta\n            } else {\n                use crate::db::schema::entities::dsl::{entities, entity_id as entity_id_col}; // 'self::' 제거\n                entities\n                    .filter(entity_id_col.eq(target_entity_id))\n                    .select(crate::db::schema::entities::id)\n                    .first::<i32>(conn)\n                    .map_err(|_| {\n                        anyhow::anyhow!(\"Entity with id '{}' not found\", target_entity_id)\n                    })?;\n\n                let new_meta = NewStatesMeta {\n                    entity_id: target_entity_id,\n                };\n                diesel::insert_into(states_meta)\n                    .values(&new_meta)\n                    .get_result(conn)?\n            }\n        };\n\n        use crate::db::schema::states::dsl::*;\n        let last_state_record: Option<State> = states\n            .filter(metadata_id.eq(meta.metadata_id))\n            .order(last_updated.desc())\n            .first::<State>(conn)\n            .optional()?;\n\n        let now_utc = Utc::now().naive_utc();\n\n        let last_changed_time = if let Some(ref last_state) = last_state_record {\n            if last_state.state.as_deref() != Some(new_state_value) {\n                Some(now_utc)\n            } else {\n                last_state.last_changed\n            }\n        } else {\n            Some(now_utc)\n        };\n\n        let attributes_string = new_attributes\n            .map(|v| serde_json::to_string(v))\n            .transpose()?;\n\n        let new_state = NewState {\n            metadata_id: Some(meta.metadata_id),\n            state: Some(new_state_value),\n            attributes: attributes_string.as_deref(),\n            last_changed: last_changed_time,\n            last_updated: Some(now_utc),\n            created: Some(now_utc),\n        };\n\n        diesel::insert_into(states)\n            .values(&new_state)\n            .get_result(conn)\n            .map_err(anyhow::Error::from)\n    })\n}\n\npub fn get_entity_state_history(\n    pool: &DbPool,\n    target_entity_id: &str,\n) -> Result<Vec<State>, anyhow::Error> {\n    use crate::db::schema::states::dsl as s;\n    use crate::db::schema::states_meta::dsl as sm;\n\n    let mut conn = pool.get()?;\n\n    let metadata_id_opt: Option<i32> = sm::states_meta\n        .filter(sm::entity_id.eq(target_entity_id))\n        .select(sm::metadata_id)\n        .first::<i32>(&mut conn)\n        .optional()?;\n\n    let Some(meta_id) = metadata_id_opt else {\n        return Ok(Vec::new());\n    };\n\n    let history = s::states\n        .filter(s::metadata_id.eq(meta_id))\n        .order(s::last_updated.asc())\n        .load::<State>(&mut conn)?;\n\n    Ok(history)\n}\n\npub fn get_all_entities_with_states_and_configs(\n    pool: &DbPool,\n) -> Result<Vec<EntityWithStateAndConfig>, anyhow::Error> {\n    use crate::db::schema::entities::dsl as e;\n    use crate::db::schema::entities_configurations::dsl as ec;\n    use crate::db::schema::states::dsl as s;\n    use crate::db::schema::states_meta::dsl as sm;\n\n    let mut conn = pool.get()?;\n\n    let entities_and_configs = e::entities\n        .left_join(ec::entities_configurations.on(e::id.eq(ec::entity_id)))\n        .load::<(Entity, Option<EntityConfiguration>)>(&mut conn)?;\n\n    let states_meta_map: std::collections::HashMap<String, i32> = sm::states_meta\n        .load::<StatesMeta>(&mut conn)?\n        .into_iter()\n        .map(|meta| (meta.entity_id, meta.metadata_id))\n        .collect();\n\n    let all_states = s::states\n        .order(s::last_updated.desc())\n        .load::<State>(&mut conn)?;\n\n    let latest_states: std::collections::HashMap<i32, State> =\n        all_states\n            .into_iter()\n            .fold(std::collections::HashMap::new(), |mut acc, state| {\n                if let Some(mid) = state.metadata_id {\n                    acc.entry(mid).or_insert(state);\n                }\n                acc\n            });\n\n    let result = entities_and_configs\n        .into_iter()\n        .map(|(entity, config_opt)| {\n            let configuration = config_opt\n                .map(|c| serde_json::from_str(&c.configuration).unwrap_or(serde_json::Value::Null))\n                .filter(|v| !v.is_null());\n\n            let state = states_meta_map\n                .get(&entity.entity_id)\n                .and_then(|metadata_id| latest_states.get(metadata_id).cloned());\n\n            EntityWithStateAndConfig {\n                entity,\n                configuration,\n                state,\n            }\n        })\n        .collect();\n\n    Ok(result)\n}\n\npub fn get_all_entities_with_states_and_configs_filter(\n    pool: &DbPool,\n    entity_type_filter: Option<String>,\n) -> Result<Vec<EntityWithStateAndConfig>, anyhow::Error> {\n    use crate::db::schema::entities::dsl as e;\n    use crate::db::schema::entities_configurations::dsl as ec;\n    use crate::db::schema::states::dsl as s;\n    use crate::db::schema::states_meta::dsl as sm;\n\n    let mut conn = pool.get()?;\n\n    let mut entities_query = e::entities\n        .left_join(ec::entities_configurations.on(e::id.eq(ec::entity_id)))\n        .into_boxed();\n\n    if let Some(e_type) = entity_type_filter {\n        entities_query = entities_query.filter(e::entity_type.eq(e_type));\n    }\n\n    let entities_and_configs =\n        entities_query.load::<(Entity, Option<EntityConfiguration>)>(&mut conn)?;\n\n    let states_meta_map: std::collections::HashMap<String, i32> = sm::states_meta\n        .load::<StatesMeta>(&mut conn)?\n        .into_iter()\n        .map(|meta| (meta.entity_id, meta.metadata_id))\n        .collect();\n\n    let all_states = s::states\n        .order(s::last_updated.desc())\n        .load::<State>(&mut conn)?;\n\n    let latest_states: std::collections::HashMap<i32, State> =\n        all_states\n            .into_iter()\n            .fold(std::collections::HashMap::new(), |mut acc, state| {\n                if let Some(mid) = state.metadata_id {\n                    acc.entry(mid).or_insert(state);\n                }\n                acc\n            });\n\n    let result = entities_and_configs\n        .into_iter()\n        .map(|(entity, config_opt)| {\n            let configuration = config_opt\n                .map(|c| serde_json::from_str(&c.configuration).unwrap_or(serde_json::Value::Null))\n                .filter(|v| !v.is_null());\n\n            let state = states_meta_map\n                .get(&entity.entity_id)\n                .and_then(|metadata_id| latest_states.get(metadata_id).cloned());\n\n            EntityWithStateAndConfig {\n                entity,\n                configuration,\n                state,\n            }\n        })\n        .collect();\n\n    Ok(result)\n}\n\npub fn get_entity_with_config_by_entity_id(\n    pool: &DbPool,\n    target_entity_id: &str,\n) -> Result<Option<EntityWithConfig>, anyhow::Error> {\n    use crate::db::schema::entities::dsl as e;\n    use crate::db::schema::entities_configurations::dsl as ec;\n\n    let mut conn = pool.get()?;\n\n    let result = e::entities\n        .filter(e::entity_id.eq(target_entity_id))\n        .left_join(ec::entities_configurations.on(e::id.eq(ec::entity_id)))\n        .first::<(Entity, Option<EntityConfiguration>)>(&mut conn)\n        .optional()?;\n\n    Ok(result.map(|(entity, config_opt)| {\n        let configuration = config_opt\n            .map(|c| serde_json::from_str(&c.configuration).unwrap_or(serde_json::Value::Null))\n            .filter(|v| !v.is_null());\n        EntityWithConfig {\n            entity,\n            configuration,\n        }\n    }))\n}\n\npub fn upsert_device(\n    pool: &DbPool,\n    target_device_id: &str,\n    target_name: Option<&str>,\n    target_manufacturer: Option<&str>,\n    target_model: Option<&str>,\n) -> Result<Device, anyhow::Error> {\n    use crate::db::schema::devices::dsl::*;\n\n    let mut conn = pool.get()?;\n\n    let existing = devices\n        .filter(device_id.eq(target_device_id))\n        .select(Device::as_select())\n        .first::<Device>(&mut conn)\n        .optional()?;\n\n    if let Some(existing_device) = existing {\n        let updated = diesel::update(devices.find(existing_device.id))\n            .set((\n                name.eq(target_name),\n                manufacturer.eq(target_manufacturer),\n                model.eq(target_model),\n            ))\n            .get_result(&mut conn)?;\n        Ok(updated)\n    } else {\n        let new_device = NewDevice {\n            device_id: target_device_id,\n            name: target_name,\n            manufacturer: target_manufacturer,\n            model: target_model,\n        };\n        let created = diesel::insert_into(devices)\n            .values(&new_device)\n            .get_result(&mut conn)?;\n        Ok(created)\n    }\n}\n\npub fn upsert_entity_with_config(\n    pool: &DbPool,\n    target_entity_id: &str,\n    target_device_id: Option<i32>,\n    target_friendly_name: Option<&str>,\n    target_platform: Option<&str>,\n    target_entity_type: Option<&str>,\n    config_str: &str,\n) -> Result<(Entity, Option<EntityConfiguration>), anyhow::Error> {\n    use crate::db::schema::entities;\n    use crate::db::schema::entities_configurations;\n\n    let mut conn = pool.get()?;\n\n    let existing = entities::table\n        .filter(entities::entity_id.eq(target_entity_id))\n        .select(Entity::as_select())\n        .first::<Entity>(&mut conn)\n        .optional()?;\n\n    conn.transaction(|conn| {\n        let entity: Entity = if let Some(existing_entity) = existing {\n            diesel::update(entities::table.find(existing_entity.id))\n                .set((\n                    entities::device_id.eq(target_device_id),\n                    entities::friendly_name.eq(target_friendly_name),\n                    entities::platform.eq(target_platform),\n                    entities::entity_type.eq(target_entity_type),\n                ))\n                .get_result(conn)?\n        } else {\n            let new_entity = NewEntity {\n                entity_id: target_entity_id,\n                device_id: target_device_id,\n                friendly_name: target_friendly_name,\n                platform: target_platform,\n                entity_type: target_entity_type,\n            };\n            diesel::insert_into(entities::table)\n                .values(&new_entity)\n                .get_result(conn)?\n        };\n\n        if !config_str.is_empty() {\n            let new_config = NewEntityConfiguration {\n                entity_id: entity.id,\n                configuration: config_str,\n            };\n\n            let config: EntityConfiguration =\n                diesel::insert_into(entities_configurations::table)\n                    .values(&new_config)\n                    .on_conflict(entities_configurations::entity_id)\n                    .do_update()\n                    .set(entities_configurations::configuration.eq(config_str))\n                    .get_result(conn)?;\n\n            Ok((entity, Some(config)))\n        } else {\n            Ok((entity, None))\n        }\n    })\n}\n\npub fn delete_device_by_device_id(\n    pool: &DbPool,\n    target_device_id: &str,\n) -> Result<usize, anyhow::Error> {\n    use crate::db::schema::devices::dsl::*;\n    let mut conn = pool.get()?;\n    let num_deleted =\n        diesel::delete(devices.filter(device_id.eq(target_device_id))).execute(&mut conn)?;\n    Ok(num_deleted)\n}\n\npub fn create_map_layer(pool: &DbPool, new_layer: NewMapLayer) -> Result<MapLayer, anyhow::Error> {\n    use crate::db::schema::map_layers::dsl::*;\n\n    let mut conn = pool.get()?;\n    let layer = diesel::insert_into(map_layers)\n        .values(&new_layer)\n        .get_result(&mut conn)?;\n    Ok(layer)\n}\n\npub fn get_all_map_layers(pool: &DbPool) -> Result<Vec<MapLayer>, anyhow::Error> {\n    use crate::db::schema::map_layers::dsl::*;\n\n    let mut conn = pool.get()?;\n    let layers = map_layers.select(MapLayer::as_select()).load(&mut conn)?;\n    Ok(layers)\n}\n\npub fn get_map_layer_with_features(\n    pool: &DbPool,\n    target_layer_id: i32,\n) -> Result<LayerWithFeatures, anyhow::Error> {\n    use crate::db::schema::{map_features, map_layers, map_vertices};\n\n    let mut conn = pool.get()?;\n\n    let layer: MapLayer = map_layers::table.find(target_layer_id).first(&mut conn)?;\n\n    let features: Vec<MapFeature> = MapFeature::belonging_to(&layer)\n        .select(MapFeature::as_select())\n        .load(&mut conn)?;\n\n    let vertices: Vec<MapVertex> = MapVertex::belonging_to(&features)\n        .select(MapVertex::as_select())\n        .load(&mut conn)?;\n\n    let features_with_vertices = features\n        .into_iter()\n        .map(|f| {\n            let feature_vertices = vertices\n                .iter()\n                .filter(|v| v.feature_id == f.id)\n                .cloned()\n                .collect();\n            FeatureWithVertices {\n                feature: f,\n                vertices: feature_vertices,\n            }\n        })\n        .collect();\n\n    Ok(LayerWithFeatures {\n        layer,\n        features: features_with_vertices,\n    })\n}\n\npub fn update_map_layer(\n    pool: &DbPool,\n    target_layer_id: i32,\n    update_data: &UpdateMapLayer,\n) -> Result<MapLayer, anyhow::Error> {\n    use crate::db::schema::map_layers::dsl::*;\n\n    let mut conn = pool.get()?;\n    let layer = diesel::update(map_layers.find(target_layer_id))\n        .set(update_data)\n        .get_result(&mut conn)?;\n    Ok(layer)\n}\n\npub fn delete_map_layer(pool: &DbPool, target_layer_id: i32) -> Result<usize, anyhow::Error> {\n    use crate::db::schema::{map_features, map_layers, map_vertices};\n\n    let mut conn = pool.get()?;\n    conn.transaction::<_, anyhow::Error, _>(|conn| {\n        let features_to_delete: Vec<MapFeature> = map_features::table\n            .filter(map_features::layer_id.eq(target_layer_id))\n            .load(conn)?;\n\n        if !features_to_delete.is_empty() {\n            let feature_ids: Vec<i32> = features_to_delete.iter().map(|f| f.id).collect();\n\n            diesel::delete(\n                map_vertices::table.filter(map_vertices::feature_id.eq_any(feature_ids)),\n            )\n            .execute(conn)?;\n\n            diesel::delete(map_features::table.filter(map_features::layer_id.eq(target_layer_id)))\n                .execute(conn)?;\n        }\n\n        let num_deleted = diesel::delete(map_layers::table.find(target_layer_id)).execute(conn)?;\n        Ok(num_deleted)\n    })\n}\n\npub fn create_map_feature(\n    pool: &DbPool,\n    new_feature: NewMapFeature,\n    vertices_data: Vec<NewMapVertex>,\n) -> Result<MapFeature, anyhow::Error> {\n    use crate::db::schema::{map_features, map_vertices};\n\n    let mut conn = pool.get()?;\n\n    let feature = conn.transaction::<_, diesel::result::Error, _>(|conn| {\n        let feature: MapFeature = diesel::insert_into(map_features::table)\n            .values(&new_feature)\n            .get_result(conn)?;\n\n        let vertices_with_id: Vec<NewMapVertex> = vertices_data\n            .into_iter()\n            .map(|mut v| {\n                v.feature_id = feature.id;\n                v\n            })\n            .collect();\n\n        diesel::insert_into(map_vertices::table)\n            .values(&vertices_with_id)\n            .execute(conn)?;\n\n        Ok(feature)\n    })?;\n\n    Ok(feature)\n}\n\npub fn get_map_feature_with_vertices(\n    pool: &DbPool,\n    target_feature_id: i32,\n) -> Result<FeatureWithVertices, anyhow::Error> {\n    use crate::db::schema::{map_features, map_vertices};\n    let mut conn = pool.get()?;\n\n    let feature: MapFeature = map_features::table\n        .find(target_feature_id)\n        .first(&mut conn)?;\n    let vertices: Vec<MapVertex> = MapVertex::belonging_to(&feature).load(&mut conn)?;\n\n    Ok(FeatureWithVertices { feature, vertices })\n}\n\npub fn update_map_feature(\n    pool: &DbPool,\n    target_feature_id: i32,\n    feature_data: &UpdateMapFeature,\n    new_vertices_data: Option<Vec<NewMapVertex>>,\n) -> Result<MapFeature, anyhow::Error> {\n    use crate::db::schema::{map_features, map_vertices};\n\n    let mut conn = pool.get()?;\n\n    conn.transaction::<_, anyhow::Error, _>(|conn| {\n        if feature_data.has_changes() {\n            diesel::update(map_features::table.find(target_feature_id))\n                .set(feature_data)\n                .execute(conn)?;\n        }\n\n        if let Some(vertices_data) = new_vertices_data {\n            diesel::delete(\n                map_vertices::table.filter(map_vertices::feature_id.eq(target_feature_id)),\n            )\n            .execute(conn)?;\n\n            let vertices_with_id: Vec<NewMapVertex> = vertices_data\n                .into_iter()\n                .map(|mut v| {\n                    v.feature_id = target_feature_id;\n                    v\n                })\n                .collect();\n\n            diesel::insert_into(map_vertices::table)\n                .values(&vertices_with_id)\n                .execute(conn)?;\n        }\n\n        let result = map_features::table.find(target_feature_id).first(conn)?;\n        Ok(result)\n    })\n    .map_err(Into::into)\n}\n\npub fn delete_map_feature(pool: &DbPool, target_feature_id: i32) -> Result<usize, anyhow::Error> {\n    use crate::db::schema::{map_features, map_vertices};\n\n    let mut conn = pool.get()?;\n    conn.transaction::<_, anyhow::Error, _>(|conn| {\n        diesel::delete(map_vertices::table.filter(map_vertices::feature_id.eq(target_feature_id)))\n            .execute(conn)?;\n\n        let num_deleted =\n            diesel::delete(map_features::table.find(target_feature_id)).execute(conn)?;\n        Ok(num_deleted)\n    })\n}\n\npub fn create_custom_node(\n    pool: &DbPool,\n    new_node: &NewCustomNode,\n) -> Result<CustomNode, anyhow::Error> {\n    use crate::db::schema::custom_nodes::dsl::*;\n    let mut conn = pool.get()?;\n    let node = diesel::insert_into(custom_nodes)\n        .values(new_node)\n        .get_result(&mut conn)?;\n    Ok(node)\n}\n\npub fn get_all_custom_nodes(pool: &DbPool) -> Result<Vec<CustomNodeResult>, anyhow::Error> {\n    use crate::db::schema::custom_nodes::dsl::*;\n    let mut conn = pool.get()?;\n    let nodes = custom_nodes\n        .select(CustomNode::as_select())\n        .load::<CustomNode>(&mut conn)?;\n\n    let nodes_map: Vec<CustomNodeResult> = nodes\n        .into_iter()\n        .map(|(node)| {\n            let configuration_data = serde_json::from_str::<serde_json::Value>(&node.data)\n                .ok()\n                .filter(|v| !v.is_null());\n            CustomNodeResult {\n                node_type: node.node_type,\n                data: configuration_data,\n            }\n        })\n        .collect();\n\n    Ok(nodes_map)\n}\n\npub fn get_custom_node(pool: &DbPool, target_node_type: &str) -> Result<CustomNode, anyhow::Error> {\n    use crate::db::schema::custom_nodes::dsl::*;\n    let mut conn = pool.get()?;\n    let node = custom_nodes\n        .filter(node_type.eq(target_node_type))\n        .first::<CustomNode>(&mut conn)?;\n    Ok(node)\n}\n\npub fn update_custom_node(\n    pool: &DbPool,\n    target_node_type: &str,\n    updated_data: &UpdateCustomNode,\n) -> Result<CustomNodeResult, anyhow::Error> {\n    use crate::db::schema::custom_nodes::dsl::*;\n    let mut conn = pool.get()?;\n    let node: CustomNode = diesel::update(custom_nodes.find(target_node_type))\n        .set(updated_data)\n        .get_result(&mut conn)?;\n\n    let configuration_data = if node.data.is_empty() {\n        None\n    } else {\n        serde_json::from_str::<Value>(&node.data)\n            .ok()\n            .filter(|v| v.is_object())\n    };\n\n    let result = CustomNodeResult {\n        node_type: node.node_type,\n        data: configuration_data,\n    };\n\n    Ok(result)\n}\n\npub fn delete_custom_node(pool: &DbPool, target_node_type: &str) -> Result<(), anyhow::Error> {\n    use crate::db::schema::custom_nodes::dsl::*;\n    let mut conn = pool.get()?;\n    diesel::delete(custom_nodes.find(target_node_type)).execute(&mut conn)?;\n    Ok(())\n}\n"
  },
  {
    "path": "apps/server/src/db/repository/rbac.rs",
    "content": "use std::collections::HashMap;\n\nuse crate::{\n    db::models::{\n        NewPermission, NewRole, NewRolePermission, NewUserRole, Permission, Role, RolePermission,\n        RoleWithPermissions,\n    },\n    state::DbPool,\n};\nuse diesel::prelude::*;\n\npub fn create_role_with_permissions(\n    pool: &DbPool,\n    name: &str,\n    description: Option<&str>,\n    permission_ids: &[i32],\n) -> Result<Role, anyhow::Error> {\n    use crate::db::schema::{role_permissions, roles};\n\n    let mut conn = pool.get()?;\n\n    conn.transaction(|conn| {\n        let new_role = NewRole { name, description };\n\n        let role: Role = diesel::insert_into(roles::table)\n            .values(&new_role)\n            .get_result(conn)?;\n\n        if !permission_ids.is_empty() {\n            let new_permissions: Vec<NewRolePermission> = permission_ids\n                .iter()\n                .map(|pid| NewRolePermission {\n                    role_id: role.id,\n                    permission_id: *pid,\n                })\n                .collect();\n\n            diesel::insert_into(role_permissions::table)\n                .values(&new_permissions)\n                .execute(conn)?;\n        }\n\n        Ok(role)\n    })\n}\n\npub fn update_role_with_permissions(\n    pool: &DbPool,\n    role_id_to_update: i32,\n    name: &str,\n    description: Option<&str>,\n    permission_ids: &[i32],\n) -> Result<Role, anyhow::Error> {\n    use crate::db::schema::{role_permissions, roles};\n\n    let mut conn = pool.get()?;\n\n    conn.transaction(|conn| {\n        let role_data = NewRole { name, description };\n        let updated_role = diesel::update(roles::table.find(role_id_to_update))\n            .set(&role_data)\n            .get_result::<Role>(conn)?;\n\n        diesel::delete(\n            role_permissions::table.filter(role_permissions::role_id.eq(role_id_to_update)),\n        )\n        .execute(conn)?;\n\n        if !permission_ids.is_empty() {\n            let new_permissions: Vec<NewRolePermission> = permission_ids\n                .iter()\n                .map(|pid| NewRolePermission {\n                    role_id: role_id_to_update,\n                    permission_id: *pid,\n                })\n                .collect();\n\n            diesel::insert_into(role_permissions::table)\n                .values(&new_permissions)\n                .execute(conn)?;\n        }\n\n        Ok(updated_role)\n    })\n}\n\npub fn get_all_roles(pool: &DbPool) -> Result<Vec<Role>, anyhow::Error> {\n    use crate::db::schema::roles::dsl::*;\n    let mut conn = pool.get()?;\n    let all_roles = roles.select(Role::as_select()).load(&mut conn)?;\n    Ok(all_roles)\n}\n\npub fn delete_role(pool: &DbPool, role_id: i32) -> Result<usize, anyhow::Error> {\n    use crate::db::schema::roles::dsl::*;\n    let mut conn = pool.get()?;\n    let num_deleted = diesel::delete(roles.find(role_id)).execute(&mut conn)?;\n    Ok(num_deleted)\n}\n\npub fn create_permission(\n    pool: &DbPool,\n    new_permission: NewPermission,\n) -> Result<Permission, anyhow::Error> {\n    use crate::db::schema::permissions::dsl::*;\n    let mut conn = pool.get()?;\n    let permission = diesel::insert_into(permissions)\n        .values(&new_permission)\n        .get_result(&mut conn)?;\n    Ok(permission)\n}\n\npub fn get_all_permissions(pool: &DbPool) -> Result<Vec<Permission>, anyhow::Error> {\n    use crate::db::schema::permissions::dsl::*;\n    let mut conn = pool.get()?;\n    let all_permissions = permissions\n        .select(Permission::as_select())\n        .load(&mut conn)?;\n    Ok(all_permissions)\n}\n\npub fn assign_role_to_user(\n    pool: &DbPool,\n    target_user_id: i32,\n    target_role_id: i32,\n) -> Result<(), anyhow::Error> {\n    use crate::db::schema::user_roles::dsl::*;\n    let mut conn = pool.get()?;\n    let new_user_role = NewUserRole {\n        user_id: target_user_id,\n        role_id: target_role_id,\n    };\n    diesel::insert_into(user_roles)\n        .values(&new_user_role)\n        .execute(&mut conn)?;\n    Ok(())\n}\n\npub fn remove_role_from_user(\n    pool: &DbPool,\n    target_user_id: i32,\n    target_role_id: i32,\n) -> Result<usize, anyhow::Error> {\n    use crate::db::schema::user_roles::dsl::*;\n    let mut conn = pool.get()?;\n    let num_deleted = diesel::delete(\n        user_roles.filter(user_id.eq(target_user_id).and(role_id.eq(target_role_id))),\n    )\n    .execute(&mut conn)?;\n    Ok(num_deleted)\n}\n\npub fn get_user_roles(pool: &DbPool, target_user_id: i32) -> Result<Vec<Role>, anyhow::Error> {\n    use crate::db::schema::{roles, user_roles};\n    let mut conn = pool.get()?;\n    let user_assigned_roles = user_roles::table\n        .inner_join(roles::table)\n        .filter(user_roles::user_id.eq(target_user_id))\n        .select(Role::as_select())\n        .load(&mut conn)?;\n    Ok(user_assigned_roles)\n}\n\npub fn grant_permission_to_role(\n    pool: &DbPool,\n    target_role_id: i32,\n    target_permission_id: i32,\n) -> Result<(), anyhow::Error> {\n    use crate::db::schema::role_permissions::dsl::*;\n    let mut conn = pool.get()?;\n    let new_role_permission = NewRolePermission {\n        role_id: target_role_id,\n        permission_id: target_permission_id,\n    };\n\n    diesel::insert_into(role_permissions)\n        .values(&new_role_permission)\n        .execute(&mut conn)?;\n    Ok(())\n}\n\npub fn revoke_permission_from_role(\n    pool: &DbPool,\n    target_role_id: i32,\n    target_permission_id: i32,\n) -> Result<usize, anyhow::Error> {\n    use crate::db::schema::role_permissions::dsl::*;\n    let mut conn = pool.get()?;\n    let num_deleted = diesel::delete(\n        role_permissions.filter(\n            role_id\n                .eq(target_role_id)\n                .and(permission_id.eq(target_permission_id)),\n        ),\n    )\n    .execute(&mut conn)?;\n    Ok(num_deleted)\n}\n\npub fn get_role_permissions(\n    pool: &DbPool,\n    target_role_id: i32,\n) -> Result<Vec<Permission>, anyhow::Error> {\n    use crate::db::schema::{permissions, role_permissions};\n    let mut conn = pool.get()?;\n    let role_granted_permissions = role_permissions::table\n        .inner_join(permissions::table)\n        .filter(role_permissions::role_id.eq(target_role_id))\n        .select(Permission::as_select())\n        .load(&mut conn)?;\n    Ok(role_granted_permissions)\n}\n\npub fn get_all_roles_with_permissions(\n    pool: &DbPool,\n) -> Result<Vec<RoleWithPermissions>, anyhow::Error> {\n    let mut conn = pool.get()?;\n\n    let roles_list = crate::db::schema::roles::table.load::<Role>(&mut conn)?;\n\n    let permissions_list = crate::db::schema::permissions::table.load::<Permission>(&mut conn)?;\n\n    let role_permissions_list =\n        RolePermission::belonging_to(&roles_list).load::<RolePermission>(&mut conn)?;\n\n    let permissions_map: HashMap<i32, Permission> =\n        permissions_list.into_iter().map(|p| (p.id, p)).collect();\n\n    let results = roles_list\n        .into_iter()\n        .map(|role| {\n            let permissions_for_role = role_permissions_list\n                .iter()\n                .filter(|rp| rp.role_id == role.id)\n                .filter_map(|rp| permissions_map.get(&rp.permission_id).cloned())\n                .collect();\n\n            RoleWithPermissions {\n                id: role.id,\n                name: role.name.clone(),\n                description: role.description.clone(),\n                permissions: permissions_for_role,\n            }\n        })\n        .collect();\n\n    Ok(results)\n}\n"
  },
  {
    "path": "apps/server/src/db/repository/recordings.rs",
    "content": "use crate::db::models::{NewRecording, Recording, UpdateRecording};\nuse crate::state::DbPool;\nuse diesel::prelude::*;\n\npub fn create_recording(\n    pool: &DbPool,\n    new_recording: NewRecording,\n) -> Result<Recording, anyhow::Error> {\n    use crate::db::schema::recordings::dsl::*;\n\n    let mut conn = pool.get()?;\n    let recording = diesel::insert_into(recordings)\n        .values(&new_recording)\n        .get_result(&mut conn)?;\n\n    Ok(recording)\n}\n\npub fn get_all_recordings(pool: &DbPool) -> Result<Vec<Recording>, anyhow::Error> {\n    use crate::db::schema::recordings::dsl::*;\n\n    let mut conn = pool.get()?;\n    let all_recordings = recordings\n        .order(started_at.desc())\n        .select(Recording::as_select())\n        .load(&mut conn)?;\n\n    Ok(all_recordings)\n}\n\npub fn get_recordings_by_status(\n    pool: &DbPool,\n    target_status: &str,\n) -> Result<Vec<Recording>, anyhow::Error> {\n    use crate::db::schema::recordings::dsl::*;\n\n    let mut conn = pool.get()?;\n    let filtered_recordings = recordings\n        .filter(status.eq(target_status))\n        .order(started_at.desc())\n        .select(Recording::as_select())\n        .load(&mut conn)?;\n\n    Ok(filtered_recordings)\n}\n\npub fn get_recordings_by_topic(\n    pool: &DbPool,\n    target_topic: &str,\n) -> Result<Vec<Recording>, anyhow::Error> {\n    use crate::db::schema::recordings::dsl::*;\n\n    let mut conn = pool.get()?;\n    let filtered_recordings = recordings\n        .filter(topic.eq(target_topic))\n        .order(started_at.desc())\n        .select(Recording::as_select())\n        .load(&mut conn)?;\n\n    Ok(filtered_recordings)\n}\n\npub fn get_active_recording_by_topic(\n    pool: &DbPool,\n    target_topic: &str,\n) -> Result<Option<Recording>, anyhow::Error> {\n    use crate::db::schema::recordings::dsl::*;\n\n    let mut conn = pool.get()?;\n    let recording = recordings\n        .filter(topic.eq(target_topic))\n        .filter(status.eq(\"recording\"))\n        .first::<Recording>(&mut conn)\n        .optional()?;\n\n    Ok(recording)\n}\n\npub fn get_recording_by_id(pool: &DbPool, recording_id: i32) -> Result<Recording, anyhow::Error> {\n    use crate::db::schema::recordings::dsl::*;\n\n    let mut conn = pool.get()?;\n    let recording = recordings\n        .find(recording_id)\n        .select(Recording::as_select())\n        .first(&mut conn)?;\n\n    Ok(recording)\n}\n\npub fn update_recording(\n    pool: &DbPool,\n    recording_id: i32,\n    update_data: &UpdateRecording,\n) -> Result<Recording, anyhow::Error> {\n    use crate::db::schema::recordings::dsl::*;\n\n    let mut conn = pool.get()?;\n    let recording = diesel::update(recordings.find(recording_id))\n        .set(update_data)\n        .get_result(&mut conn)?;\n\n    Ok(recording)\n}\n\npub fn delete_recording(pool: &DbPool, recording_id: i32) -> Result<usize, anyhow::Error> {\n    use crate::db::schema::recordings::dsl::*;\n\n    let mut conn = pool.get()?;\n    let num_deleted = diesel::delete(recordings.find(recording_id)).execute(&mut conn)?;\n\n    Ok(num_deleted)\n}\n\n/// Mark all recordings with status=\"recording\" as \"abandoned\"\n/// Called on server startup to cleanup orphaned recordings from crashes\npub fn mark_orphaned_as_abandoned(pool: &DbPool) -> Result<usize, anyhow::Error> {\n    use crate::db::schema::recordings::dsl::*;\n\n    let mut conn = pool.get()?;\n    let num_updated = diesel::update(recordings.filter(status.eq(\"recording\")))\n        .set((\n            status.eq(\"abandoned\"),\n            ended_at.eq(diesel::dsl::now),\n        ))\n        .execute(&mut conn)?;\n\n    Ok(num_updated)\n}\n"
  },
  {
    "path": "apps/server/src/db/repository/streams.rs",
    "content": "use std::collections::HashMap;\n\nuse crate::{\n    db::{\n        models::{\n            NewPermission, NewRole, NewRolePermission, NewStream, NewUserRole, Permission, Role,\n            RolePermission, RoleWithPermissions, Stream,\n        },\n        schema::streams,\n    },\n    state::DbPool,\n};\nuse diesel::prelude::*;\n\npub fn get_all_streams(pool: &DbPool) -> Result<Vec<Stream>, anyhow::Error> {\n    use crate::db::schema::streams::dsl::*;\n\n    let mut conn = pool.get()?;\n    let all_streams = streams.select(Stream::as_select()).load(&mut conn)?;\n    Ok(all_streams)\n}\n\npub fn upsert_stream(pool: &DbPool, new_stream: &NewStream) -> Result<Stream, anyhow::Error> {\n    use crate::db::schema::streams;\n\n    let mut conn = pool.get()?;\n\n    let stream = diesel::insert_into(streams::table)\n        .values(new_stream)\n        .on_conflict((streams::topic, streams::device_id))\n        .do_update()\n        .set(streams::ssrc.eq(new_stream.ssrc))\n        .get_result(&mut conn)?;\n\n    Ok(stream)\n}\n\npub fn delete_stream(pool: &DbPool, target_ssrc: i32) -> Result<usize, anyhow::Error> {\n    use crate::db::schema::streams::dsl::*;\n\n    let mut conn = pool.get()?;\n    let deleted = diesel::delete(streams.filter(ssrc.eq(target_ssrc))).execute(&mut conn)?;\n    Ok(deleted)\n}\n"
  },
  {
    "path": "apps/server/src/db/schema.rs",
    "content": "// @generated automatically by Diesel CLI.\n\ndiesel::table! {\n    custom_nodes (node_type) {\n        node_type -> Text,\n        data -> Text,\n    }\n}\n\ndiesel::table! {\n    device_tokens (id) {\n        id -> Integer,\n        device_id -> Integer,\n        token_hash -> Text,\n        expires_at -> Nullable<Timestamp>,\n        last_used_at -> Nullable<Timestamp>,\n        created_at -> Timestamp,\n    }\n}\n\ndiesel::table! {\n    devices (id) {\n        id -> Integer,\n        device_id -> Text,\n        name -> Nullable<Text>,\n        manufacturer -> Nullable<Text>,\n        model -> Nullable<Text>,\n    }\n}\n\ndiesel::table! {\n    dynamic_dashboards (id) {\n        id -> Text,\n        name -> Text,\n        layout -> Text,\n        created_at -> Timestamp,\n        updated_at -> Timestamp,\n    }\n}\n\ndiesel::table! {\n    entities (id) {\n        id -> Integer,\n        entity_id -> Text,\n        device_id -> Nullable<Integer>,\n        friendly_name -> Nullable<Text>,\n        platform -> Nullable<Text>,\n        entity_type -> Nullable<Text>,\n    }\n}\n\ndiesel::table! {\n    entities_configurations (id) {\n        id -> Integer,\n        entity_id -> Integer,\n        configuration -> Text,\n        created_at -> Timestamp,\n        updated_at -> Timestamp,\n    }\n}\n\ndiesel::table! {\n    events (event_id) {\n        event_id -> Integer,\n        event_type -> Nullable<Text>,\n        event_data -> Nullable<Text>,\n        origin -> Nullable<Text>,\n        time_fired -> Nullable<Timestamp>,\n    }\n}\n\ndiesel::table! {\n    flow_versions (id) {\n        id -> Integer,\n        flow_id -> Integer,\n        version -> Integer,\n        graph_json -> Text,\n        comment -> Nullable<Text>,\n        created_at -> Timestamp,\n    }\n}\n\ndiesel::table! {\n    flows (id) {\n        id -> Integer,\n        name -> Text,\n        description -> Nullable<Text>,\n        enabled -> Integer,\n        created_at -> Timestamp,\n        updated_at -> Timestamp,\n    }\n}\n\ndiesel::table! {\n    map_features (id) {\n        id -> Integer,\n        layer_id -> Integer,\n        feature_type -> Text,\n        name -> Nullable<Text>,\n        style_properties -> Nullable<Text>,\n        created_by_user_id -> Integer,\n        created_at -> Timestamp,\n        updated_at -> Timestamp,\n    }\n}\n\ndiesel::table! {\n    map_layers (id) {\n        id -> Integer,\n        name -> Text,\n        description -> Nullable<Text>,\n        owner_user_id -> Integer,\n        is_visible -> Integer,\n        created_at -> Timestamp,\n        updated_at -> Timestamp,\n    }\n}\n\ndiesel::table! {\n    map_vertices (id) {\n        id -> Integer,\n        feature_id -> Integer,\n        latitude -> Float,\n        longitude -> Float,\n        altitude -> Nullable<Float>,\n        sequence -> Integer,\n    }\n}\n\ndiesel::table! {\n    permissions (id) {\n        id -> Integer,\n        name -> Text,\n        description -> Nullable<Text>,\n    }\n}\n\ndiesel::table! {\n    role_permissions (role_id, permission_id) {\n        role_id -> Integer,\n        permission_id -> Integer,\n    }\n}\n\ndiesel::table! {\n    roles (id) {\n        id -> Integer,\n        name -> Text,\n        description -> Nullable<Text>,\n    }\n}\n\ndiesel::table! {\n    states (state_id) {\n        state_id -> Integer,\n        metadata_id -> Nullable<Integer>,\n        state -> Nullable<Text>,\n        attributes -> Nullable<Text>,\n        last_changed -> Nullable<Timestamp>,\n        last_updated -> Nullable<Timestamp>,\n        created -> Nullable<Timestamp>,\n    }\n}\n\ndiesel::table! {\n    states_meta (metadata_id) {\n        metadata_id -> Integer,\n        entity_id -> Text,\n    }\n}\n\ndiesel::table! {\n    streams (ssrc) {\n        ssrc -> Integer,\n        topic -> Text,\n        device_id -> Text,\n        media_type -> Text,\n        created_at -> Timestamp,\n    }\n}\n\ndiesel::table! {\n    recordings (id) {\n        id -> Integer,\n        stream_ssrc -> Integer,\n        topic -> Text,\n        device_id -> Text,\n        media_type -> Text,\n        filename -> Text,\n        file_path -> Text,\n        file_size -> Integer,\n        duration_ms -> Integer,\n        status -> Text,\n        started_at -> Timestamp,\n        ended_at -> Nullable<Timestamp>,\n        created_by_user_id -> Nullable<Integer>,\n    }\n}\n\ndiesel::table! {\n    system_configurations (id) {\n        id -> Integer,\n        key -> Text,\n        value -> Text,\n        enabled -> Integer,\n        description -> Nullable<Text>,\n        created_at -> Timestamp,\n        updated_at -> Timestamp,\n    }\n}\n\ndiesel::table! {\n    user_roles (user_id, role_id) {\n        user_id -> Integer,\n        role_id -> Integer,\n    }\n}\n\ndiesel::table! {\n    users (id) {\n        id -> Integer,\n        username -> Text,\n        email -> Text,\n        password_hash -> Text,\n        created_at -> Timestamp,\n        updated_at -> Timestamp,\n    }\n}\n\ndiesel::joinable!(device_tokens -> devices (device_id));\ndiesel::joinable!(entities -> devices (device_id));\ndiesel::joinable!(entities_configurations -> entities (entity_id));\ndiesel::joinable!(flow_versions -> flows (flow_id));\ndiesel::joinable!(map_features -> map_layers (layer_id));\ndiesel::joinable!(map_features -> users (created_by_user_id));\ndiesel::joinable!(map_layers -> users (owner_user_id));\ndiesel::joinable!(map_vertices -> map_features (feature_id));\ndiesel::joinable!(role_permissions -> permissions (permission_id));\ndiesel::joinable!(role_permissions -> roles (role_id));\ndiesel::joinable!(states -> states_meta (metadata_id));\ndiesel::joinable!(user_roles -> roles (role_id));\ndiesel::joinable!(user_roles -> users (user_id));\n\ndiesel::allow_tables_to_appear_in_same_query!(\n    custom_nodes,\n    device_tokens,\n    devices,\n    dynamic_dashboards,\n    entities,\n    entities_configurations,\n    events,\n    flow_versions,\n    flows,\n    map_features,\n    map_layers,\n    map_vertices,\n    permissions,\n    recordings,\n    role_permissions,\n    roles,\n    states,\n    states_meta,\n    streams,\n    system_configurations,\n    user_roles,\n    users,\n);\n"
  },
  {
    "path": "apps/server/src/error.rs",
    "content": "use axum::{\n    http::StatusCode,\n    response::{IntoResponse, Response},\n};\n\npub struct AppError(anyhow::Error);\n\nimpl IntoResponse for AppError {\n    fn into_response(self) -> Response {\n        tracing::error!(\"Application error: {:?}\", self.0);\n\n        (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            format!(\"Something went wrong: {}\", self.0),\n        )\n            .into_response()\n    }\n}\n\nimpl<E> From<E> for AppError\nwhere\n    E: Into<anyhow::Error>,\n{\n    fn from(err: E) -> Self {\n        Self(err.into())\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/binary_store.rs",
    "content": "use dashmap::DashMap;\nuse std::sync::Arc;\nuse std::time::{Duration, Instant};\nuse uuid::Uuid;\n\n#[derive(Clone)]\npub struct BinaryStore {\n    store: Arc<DashMap<Uuid, (Instant, Vec<u8>)>>,\n}\n\nimpl BinaryStore {\n    pub fn new() -> Self {\n        let store = Arc::new(DashMap::new());\n        let store_clone = store.clone();\n\n        tokio::spawn(async move {\n            let mut interval = tokio::time::interval(Duration::from_secs(60));\n            loop {\n                interval.tick().await;\n                let cutoff = Instant::now() - Duration::from_secs(60);\n                store_clone.retain(|_, (timestamp, _)| *timestamp > cutoff);\n            }\n        });\n\n        Self { store }\n    }\n\n    pub fn insert(&self, data: Vec<u8>) -> Uuid {\n        let id = Uuid::new_v4();\n        self.store.insert(id, (Instant::now(), data));\n        id\n    }\n\n    pub fn remove(&self, id: &Uuid) -> Option<Vec<u8>> {\n        self.store.remove(id).map(|(_, (_, data))| data)\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/engine.rs",
    "content": "use anyhow::{anyhow, Result};\nuse rumqttc::AsyncClient;\nuse serde::Deserialize;\nuse serde_json::{json, Value};\nuse std::collections::HashMap;\nuse std::collections::VecDeque;\nuse std::sync::Arc;\nuse std::time::Duration;\nuse tokio::sync::{broadcast, mpsc, watch, Mutex};\nuse tokio::task::JoinHandle;\nuse tokio::time;\nuse tracing::{error, info};\n\nuse crate::db::models::SystemConfiguration;\nuse crate::flow::nodes::branch::BranchNode;\nuse crate::flow::nodes::custom_node::CustomNode;\nuse crate::flow::nodes::dashboard_event_listener::DashboardEventListenerNode;\nuse crate::flow::nodes::decode_h264::DecodeH264Node;\nuse crate::flow::nodes::decode_opus::DecodeOpusNode;\nuse crate::flow::nodes::gst_decoder::GstDecoderNode;\nuse crate::flow::nodes::json_modify::JsonModifyNode;\nuse crate::flow::nodes::json_selector::JsonSelectorNode;\nuse crate::flow::nodes::mqtt_publish::MqttPublishNode;\nuse crate::flow::nodes::mqtt_subscribe::MqttSubscribeNode;\nuse crate::flow::nodes::rtp_stream_in::RtpStreamInNode;\nuse crate::flow::nodes::set_variable_with_exec::SetVariableWithExecNode;\nuse crate::flow::nodes::type_converter::TypeConverterNode;\nuse crate::flow::nodes::websocket_on::WebSocketOnNode;\nuse crate::flow::nodes::websocket_send::WebSocketSendNode;\nuse crate::flow::nodes::yolo_detect::YoloDetectNode;\nuse crate::flow::nodes::{\n    calc::CalcNode, http::HttpNode, interval::IntervalNode, log_message::LogMessageNode,\n    logic_operator::LogicOpetatorNode, set_variable::SetVariableNode, show_toast::ShowToastNode,\n    start::StartNode, ExecutableNode,\n};\nuse crate::flow::types::{FlowRunContext, Graph, Node};\nuse crate::flow::BinaryStore;\nuse crate::state::{DashboardUiEvent, MqttMessage, StreamManager};\n\npub struct ExecutionContext {\n    variables: HashMap<String, Value>,\n    mqtt_client: Option<AsyncClient>,\n    broadcast_tx: broadcast::Sender<String>,\n    flow_id: i32,\n    run_context: Option<FlowRunContext>,\n}\n\nimpl ExecutionContext {\n    pub fn new(\n        mqtt_client: Option<AsyncClient>,\n        broadcast_tx: broadcast::Sender<String>,\n        flow_id: i32,\n        run_context: Option<FlowRunContext>,\n    ) -> Self {\n        Self {\n            variables: HashMap::new(),\n            mqtt_client,\n            broadcast_tx,\n            flow_id,\n            run_context,\n        }\n    }\n\n    pub fn set_variable(&mut self, name: &str, value: Value) {\n        self.variables.insert(name.to_string(), value);\n    }\n\n    pub fn get_variable(&self, name: &str) -> Option<&Value> {\n        self.variables.get(name)\n    }\n\n    pub fn get_broadcast(&self) -> broadcast::Sender<String> {\n        self.broadcast_tx.clone()\n    }\n\n    /// Broadcast a UI event to clients; only the tab that started the run (matching `session_id`) should react.\n    pub fn emit_flow_ui_event(\n        &self,\n        node_id: &str,\n        node_type: &str,\n        event_kind: &str,\n        event_data: Value,\n    ) {\n        let Some(ref rc) = self.run_context else {\n            return;\n        };\n        let ws_message = json!({\n            \"type\": \"flow_ui_event\",\n            \"payload\": {\n                \"target\": { \"session_id\": rc.session_id },\n                \"event\": { \"kind\": event_kind, \"data\": event_data },\n                \"meta\": {\n                    \"flow_id\": self.flow_id,\n                    \"node_id\": node_id,\n                    \"node_type\": node_type,\n                }\n            }\n        });\n        if let Ok(payload_str) = serde_json::to_string(&ws_message) {\n            if self.broadcast_tx.send(payload_str).is_err() {\n                error!(\"Failed to broadcast flow_ui_event\");\n            }\n        }\n    }\n\n    pub fn mqtt_client(&self) -> &Option<AsyncClient> {\n        &self.mqtt_client\n    }\n}\n\npub struct FlowController {\n    shutdown_tx: watch::Sender<bool>,\n}\n\nimpl FlowController {\n    pub fn stop(&self) {\n        self.shutdown_tx.send(true).ok();\n    }\n}\n\n#[derive(Debug)]\npub struct TriggerCommand {\n    pub node_id: String,\n    pub inputs: HashMap<String, Value>,\n}\n\npub struct FlowEngine {\n    pub nodes: HashMap<String, Node>,\n    data_flow_graph: HashMap<String, Vec<(String, String, String)>>,\n    expected_input_counts: HashMap<String, usize>,\n    mqtt_tx: Option<broadcast::Sender<MqttMessage>>,\n    dashboard_ui_tx: Option<broadcast::Sender<DashboardUiEvent>>,\n    stream_manager: StreamManager,\n    binary_store: BinaryStore,\n    system_configs: Vec<SystemConfiguration>,\n}\n\nimpl FlowEngine {\n    pub fn new(\n        graph: Graph,\n        mqtt_tx: Option<broadcast::Sender<MqttMessage>>,\n        dashboard_ui_tx: Option<broadcast::Sender<DashboardUiEvent>>,\n        stream_manager: StreamManager,\n        binary_store: BinaryStore,\n        system_configs: Vec<SystemConfiguration>,\n    ) -> Result<Self> {\n        let nodes_map: HashMap<String, Node> =\n            graph.nodes.into_iter().map(|n| (n.id.clone(), n)).collect();\n\n        let mut connector_to_node_map = HashMap::new();\n        let mut connector_name_map = HashMap::new();\n        for node in nodes_map.values() {\n            for connector in &node.connectors {\n                connector_to_node_map.insert(connector.id.clone(), node.id.clone());\n                connector_name_map.insert(connector.id.clone(), connector.name.clone());\n            }\n        }\n\n        let mut data_flow_graph: HashMap<String, Vec<(String, String, String)>> = HashMap::new();\n        let mut expected_input_counts: HashMap<String, usize> = HashMap::new();\n\n        for edge in &graph.edges {\n            let source_node_id = connector_to_node_map\n                .get(&edge.source)\n                .ok_or_else(|| anyhow!(\"Node for source connector '{}' not found\", edge.source))?;\n            let target_node_id = connector_to_node_map\n                .get(&edge.target)\n                .ok_or_else(|| anyhow!(\"Node for target connector '{}' not found\", edge.target))?;\n\n            let source_connector_name = connector_name_map\n                .get(&edge.source)\n                .ok_or_else(|| anyhow!(\"Name for source connector '{}' not found\", edge.source))?;\n            let target_connector_name = connector_name_map\n                .get(&edge.target)\n                .ok_or_else(|| anyhow!(\"Name for target connector '{}' not found\", edge.target))?;\n\n            data_flow_graph\n                .entry(source_node_id.clone())\n                .or_default()\n                .push((\n                    source_connector_name.clone(),\n                    target_node_id.clone(),\n                    target_connector_name.clone(),\n                ));\n\n            *expected_input_counts\n                .entry(target_node_id.clone())\n                .or_insert(0) += 1;\n        }\n\n        Ok(Self {\n            nodes: nodes_map,\n            data_flow_graph,\n            expected_input_counts,\n            mqtt_tx,\n            dashboard_ui_tx,\n            stream_manager,\n            binary_store,\n            system_configs,\n        })\n    }\n\n    pub fn get_node_by_id(&self, node_id: &str) -> Option<&Node> {\n        self.nodes.get(node_id)\n    }\n\n    pub fn get_start_nodes(&self) -> Vec<String> {\n        self.nodes\n            .keys()\n            .filter(|node_id| *self.expected_input_counts.get(*node_id).unwrap_or(&0) == 0)\n            .cloned()\n            .collect()\n    }\n\n    pub fn get_source_node_ids(&self) -> Vec<String> {\n        self.nodes\n            .iter()\n            .filter(|(id, _)| *self.expected_input_counts.get(*id).unwrap_or(&0) == 0)\n            .map(|(id, _)| id.clone())\n            .collect()\n    }\n\n    pub fn get_node_instance(\n        &self,\n        node_id: &str,\n        source_node_ids: Vec<String>,\n    ) -> Result<Box<dyn ExecutableNode>> {\n        let node = self\n            .nodes\n            .get(node_id)\n            .ok_or_else(|| anyhow!(\"Node not found: '{}'\", node_id))?;\n        match node.node_type.as_str() {\n            \"START\" => Ok(Box::new(StartNode)),\n            \"SET_VARIABLE\" => Ok(Box::new(SetVariableNode::new(&node.data)?)),\n            \"SET_VARIABLE_WITH_EXEC\" => Ok(Box::new(SetVariableWithExecNode::new(&node.data)?)),\n            \"LOG_MESSAGE\" => Ok(Box::new(LogMessageNode)),\n            \"SHOW_TOAST\" => Ok(Box::new(ShowToastNode::new(&node.data, node.id.clone())?)),\n            \"CALCULATION\" => Ok(Box::new(CalcNode::new(&node.data)?)),\n            \"HTTP_REQUEST\" => Ok(Box::new(HttpNode::new(&node.data)?)),\n            \"LOGIC_OPERATOR\" => Ok(Box::new(LogicOpetatorNode::new(&node.data)?)),\n            \"INTERVAL\" => Ok(Box::new(IntervalNode::new(&node.data)?)),\n            \"MQTT_PUBLISH\" => Ok(Box::new(MqttPublishNode::new(&node.data)?)),\n            \"MQTT_SUBSCRIBE\" => {\n                let rx = self\n                    .mqtt_tx\n                    .as_ref()\n                    .ok_or_else(|| {\n                        anyhow!(\"MQTT client is not configured for MQTT_SUBSCRIBE node\")\n                    })?\n                    .subscribe();\n                Ok(Box::new(MqttSubscribeNode::new(\n                    &node.data,\n                    rx,\n                    source_node_ids,\n                )?))\n            }\n            \"DASHBOARD_EVENT_LISTENER\" => {\n                let rx = self\n                    .dashboard_ui_tx\n                    .as_ref()\n                    .ok_or_else(|| {\n                        anyhow!(\"Dashboard UI bus is not configured for DASHBOARD_EVENT_LISTENER\")\n                    })?\n                    .subscribe();\n                Ok(Box::new(DashboardEventListenerNode::new(\n                    &node.data,\n                    rx,\n                )?))\n            }\n            \"TYPE_CONVERTER\" => Ok(Box::new(TypeConverterNode::new(&node.data)?)),\n            \"RTP_STREAM_IN\" => Ok(Box::new(RtpStreamInNode::new(\n                &node.data,\n                self.stream_manager.clone(),\n                source_node_ids,\n            )?)),\n            \"DECODE_OPUS\" => Ok(Box::new(DecodeOpusNode::new()?)),\n            \"BRANCH\" => Ok(Box::new(BranchNode)),\n            \"JSON_SELECTOR\" => Ok(Box::new(JsonSelectorNode::new(&node.data)?)),\n            \"JSON_MODIFY\" => Ok(Box::new(JsonModifyNode::new(&node.data)?)),\n            \"DECODE_H264\" => Ok(Box::new(DecodeH264Node::new()?)),\n            \"YOLO_DETECT\" => Ok(Box::new(YoloDetectNode::new(\n                &node.data,\n                self.binary_store.clone(),\n            )?)),\n            \"GST_DECODER\" => Ok(Box::new(GstDecoderNode::new(\n                &node.data,\n                self.stream_manager.clone(),\n                self.binary_store.clone(),\n            )?)),\n\n            \"WEBSOCKET_ON\" => Ok(Box::new(WebSocketOnNode::new(\n                &node.data,\n                self.system_configs.clone(),\n            )?)),\n            \"WEBSOCKET_SEND\" => Ok(Box::new(WebSocketSendNode::new(\n                &node.data,\n                self.system_configs.clone(),\n            )?)),\n            s if s.starts_with('_') => Ok(Box::new(CustomNode::new(&node)?)),\n            _ => Err(anyhow!(\n                \"Unknown or unimplemented node type: {}\",\n                node.node_type\n            )),\n        }\n    }\n\n    pub async fn start(\n        self: Arc<Self>,\n        broadcast_tx: broadcast::Sender<String>,\n        mqtt_client: Option<AsyncClient>,\n        flow_id: i32,\n        run_context: Option<FlowRunContext>,\n    ) -> (\n        FlowController,\n        JoinHandle<Result<()>>,\n        mpsc::Sender<TriggerCommand>,\n    ) {\n        let (shutdown_tx, mut shutdown_rx) = watch::channel(false);\n        let (trigger_tx, mut trigger_rx) = mpsc::channel::<TriggerCommand>(100);\n        let execution_queue = Arc::new(Mutex::new(VecDeque::new()));\n        let node_input_data =\n            Arc::new(Mutex::new(HashMap::<String, HashMap<String, Value>>::new()));\n        let ws_message = json!({\n            \"type\": \"log_message\",\n            \"payload\": \"Executing flow...\"\n        });\n        if let Ok(payload_str) = serde_json::to_string(&ws_message) {\n            if broadcast_tx.clone().send(payload_str).is_err() {\n                error!(\"Failed to send health check response.\");\n            }\n        }\n        {\n            let mut queue = execution_queue.lock().await;\n            for node_id in self.get_source_node_ids() {\n                if let Ok(instance) = self.get_node_instance(&node_id, vec![]) {\n                    if !instance.is_trigger() {\n                        queue.push_back(node_id.clone());\n                    }\n                }\n            }\n        }\n        let engine_clone = Arc::clone(&self);\n        let handle = tokio::spawn(async move {\n            let self_ = engine_clone;\n            let mut context = ExecutionContext::new(mqtt_client, broadcast_tx, flow_id, run_context);\n            info!(\"Flow Engine task started. Entering main execution loop...\");\n            loop {\n                if *shutdown_rx.borrow() {\n                    break;\n                }\n                let maybe_node_id = execution_queue.lock().await.pop_front();\n                if let Some(node_id) = maybe_node_id {\n                    let node_instance = match self_.get_node_instance(&node_id, vec![]) {\n                        Ok(instance) => instance,\n                        Err(e) => {\n                            error!(\"Failed to create instance for node {}: {}\", node_id, e);\n                            continue;\n                        }\n                    };\n                    let inputs = node_input_data\n                        .lock()\n                        .await\n                        .remove(&node_id)\n                        .unwrap_or_default();\n                    match node_instance.execute(&mut context, inputs).await {\n                        Ok(result) => {\n                            for trigger in result.triggers {\n                                let mut queue = execution_queue.lock().await;\n                                node_input_data\n                                    .lock()\n                                    .await\n                                    .entry(trigger.node_id.clone())\n                                    .or_default()\n                                    .extend(trigger.inputs);\n                                queue.push_back(trigger.node_id);\n                            }\n                            if let Some(connections) = self_.data_flow_graph.get(&node_id) {\n                                for (source_connector, target_node_id, target_connector) in\n                                    connections\n                                {\n                                    if let Some(output_value) = result.outputs.get(source_connector)\n                                    {\n                                        let mut data_map = node_input_data.lock().await;\n                                        let target_inputs =\n                                            data_map.entry(target_node_id.clone()).or_default();\n                                        target_inputs\n                                            .insert(target_connector.clone(), output_value.clone());\n                                        let expected_count = *self_\n                                            .expected_input_counts\n                                            .get(target_node_id)\n                                            .unwrap_or(&0);\n                                        if target_inputs.len() >= expected_count {\n                                            execution_queue\n                                                .lock()\n                                                .await\n                                                .push_back(target_node_id.clone());\n                                        }\n                                    }\n                                }\n                            }\n                        }\n                        Err(e) => {\n                            error!(\"Error executing node {}: {}\", node_id, e);\n                        }\n                    }\n                } else {\n                    tokio::select! {\n                        biased;\n                        _ = shutdown_rx.changed() => {\n                            if *shutdown_rx.borrow() {\n                                break;\n                            }\n                        },\n                        Some(command) = trigger_rx.recv() => {\n                            let mut data_map = node_input_data.lock().await;\n                            data_map.entry(command.node_id.clone()).or_default().extend(command.inputs);\n                            execution_queue.lock().await.push_back(command.node_id);\n                        },\n                        else => {\n                            break;\n                        }\n                    }\n                }\n            }\n            info!(\"Flow execution loop stopped.\");\n            Ok(())\n        });\n        (FlowController { shutdown_tx }, handle, trigger_tx)\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/manager_state.rs",
    "content": "use crate::{\n    db::{self, models::SystemConfiguration},\n    flow::{\n        engine::{FlowController, FlowEngine},\n        types::{FlowRunContext, Graph},\n        BinaryStore,\n    },\n    state::{DashboardUiEvent, DbPool, MqttMessage, StreamManager},\n};\nuse anyhow::Result;\nuse chrono::Utc;\nuse rumqttc::AsyncClient;\nuse std::{collections::HashMap, sync::Arc};\nuse tokio::{\n    sync::{broadcast, mpsc},\n    task::JoinHandle,\n};\nuse tracing::{error, info, warn};\n\npub enum FlowManagerCommand {\n    StartFlow {\n        flow_id: i32,\n        graph: Graph,\n        broadcast_tx: broadcast::Sender<String>,\n        run_context: Option<FlowRunContext>,\n    },\n    StopFlow {\n        flow_id: i32,\n    },\n    GetAllFlows {\n        responder: tokio::sync::oneshot::Sender<Vec<(i32, bool)>>,\n        pool: DbPool,\n    },\n}\n\nstruct ActiveFlow {\n    controller: FlowController,\n    _handle: JoinHandle<Result<()>>,\n    trigger_tasks: Vec<JoinHandle<()>>,\n}\n\npub struct FlowManagerActor {\n    receiver: mpsc::Receiver<FlowManagerCommand>,\n    active_flows: HashMap<i32, ActiveFlow>,\n    mqtt_client: Option<AsyncClient>,\n    mqtt_tx: broadcast::Sender<MqttMessage>,\n    dashboard_ui_tx: broadcast::Sender<DashboardUiEvent>,\n    stream_manager: StreamManager,\n    binary_store: BinaryStore,\n    system_configs: Vec<SystemConfiguration>,\n    pool: DbPool,\n}\n\nimpl FlowManagerActor {\n    pub fn new(\n        receiver: mpsc::Receiver<FlowManagerCommand>,\n        mqtt_client: Option<AsyncClient>,\n        mqtt_tx: broadcast::Sender<MqttMessage>,\n        dashboard_ui_tx: broadcast::Sender<DashboardUiEvent>,\n        stream_manager: StreamManager,\n        system_configs: Vec<SystemConfiguration>,\n        pool: DbPool,\n    ) -> Self {\n        Self {\n            receiver,\n            active_flows: HashMap::new(),\n            mqtt_client,\n            mqtt_tx,\n            dashboard_ui_tx,\n            stream_manager,\n            binary_store: BinaryStore::new(),\n            system_configs,\n            pool,\n        }\n    }\n\n    pub async fn run(&mut self) {\n        while let Some(cmd) = self.receiver.recv().await {\n            match cmd {\n                FlowManagerCommand::StartFlow {\n                    flow_id,\n                    graph,\n                    broadcast_tx,\n                    run_context,\n                } => {\n                    if self.active_flows.contains_key(&flow_id) {\n                        error!(\"Flow with ID {} is already running.\", flow_id);\n                        continue;\n                    }\n                    info!(\"Starting flow execution for flow_id: {}\", flow_id);\n\n                    // Enrich system_configs with entity-based integration configs\n                    let mut enriched_configs = self.system_configs.clone();\n                    enrich_configs_from_entities(&self.pool, &mut enriched_configs);\n\n                    match FlowEngine::new(\n                        graph,\n                        Some(self.mqtt_tx.clone()),\n                        Some(self.dashboard_ui_tx.clone()),\n                        self.stream_manager.clone(),\n                        self.binary_store.clone(),\n                        enriched_configs,\n                    ) {\n                        Ok(engine) => {\n                            let engine = Arc::new(engine);\n                            let source_node_ids = engine.get_source_node_ids();\n                            let (controller, handle, trigger_tx) = engine\n                                .clone()\n                                .start(\n                                    broadcast_tx,\n                                    self.mqtt_client.clone(),\n                                    flow_id,\n                                    run_context,\n                                )\n                                .await;\n                            let mut trigger_tasks = Vec::new();\n                            for node_id in engine.nodes.keys() {\n                                if let Ok(mut node_instance) =\n                                    engine.get_node_instance(node_id, source_node_ids.clone())\n                                {\n                                    if node_instance.is_trigger() {\n                                        match node_instance\n                                            .start_trigger(node_id.clone(), trigger_tx.clone())\n                                        {\n                                            Ok(task_handle) => trigger_tasks.push(task_handle),\n                                            Err(e) => error!(\n                                                \"Failed to start trigger for node {}: {}\",\n                                                node_id, e\n                                            ),\n                                        }\n                                    }\n                                }\n                            }\n                            self.active_flows.insert(\n                                flow_id,\n                                ActiveFlow {\n                                    controller,\n                                    _handle: handle,\n                                    trigger_tasks,\n                                },\n                            );\n                        }\n                        Err(e) => {\n                            error!(\"Failed to create flow engine for flow {}: {}\", flow_id, e);\n                        }\n                    }\n                }\n                FlowManagerCommand::StopFlow { flow_id } => {\n                    if let Some(active_flow) = self.active_flows.remove(&flow_id) {\n                        info!(\"Stop signal sent to flow_id: {}\", flow_id);\n                        active_flow.controller.stop();\n                        for task in active_flow.trigger_tasks {\n                            task.abort();\n                        }\n                    }\n                }\n                FlowManagerCommand::GetAllFlows { responder, pool } => {\n                    let all_db_flows = db::repository::get_all_flows(&pool).unwrap_or_default();\n                    let status: Vec<(i32, bool)> = all_db_flows\n                        .into_iter()\n                        .map(|f| (f.id, self.active_flows.contains_key(&f.id)))\n                        .collect();\n                    let _ = responder.send(status);\n                }\n            }\n        }\n    }\n}\n\n/// Enrich system_configs with values from entity-based integration configurations.\n/// This allows flow nodes using placeholders like `{:ros2_websocket_url}` to resolve\n/// from entity configurations when they are not present in system_configurations.\nfn enrich_configs_from_entities(pool: &DbPool, configs: &mut Vec<SystemConfiguration>) {\n    let now = Utc::now().naive_utc();\n\n    // Home Assistant bridge entity → home_assistant_url, home_assistant_token\n    if let Ok(Some(ha_entity)) =\n        db::repository::get_entity_with_config_by_entity_id(pool, \"home_assistant.bridge\")\n    {\n        if let Some(config) = ha_entity.configuration {\n            if let Some(url) = config.get(\"url\").and_then(|v| v.as_str()) {\n                if !configs.iter().any(|c| c.key == \"home_assistant_url\") {\n                    configs.push(SystemConfiguration {\n                        id: 0,\n                        key: \"home_assistant_url\".to_string(),\n                        value: url.to_string(),\n                        enabled: 1,\n                        description: Some(\"Auto-enriched from entity config\".to_string()),\n                        created_at: now,\n                        updated_at: now,\n                    });\n                }\n            }\n            if let Some(token) = config.get(\"token\").and_then(|v| v.as_str()) {\n                if !configs.iter().any(|c| c.key == \"home_assistant_token\") {\n                    configs.push(SystemConfiguration {\n                        id: 0,\n                        key: \"home_assistant_token\".to_string(),\n                        value: token.to_string(),\n                        enabled: 1,\n                        description: Some(\"Auto-enriched from entity config\".to_string()),\n                        created_at: now,\n                        updated_at: now,\n                    });\n                }\n            }\n        }\n    } else {\n        warn!(\"Could not look up home_assistant.bridge entity for config enrichment\");\n    }\n\n    // ROS2 bridge entity → ros2_websocket_url\n    if let Ok(Some(ros2_entity)) =\n        db::repository::get_entity_with_config_by_entity_id(pool, \"ros2.bridge\")\n    {\n        if let Some(config) = ros2_entity.configuration {\n            if let Some(ws_url) = config.get(\"websocket_url\").and_then(|v| v.as_str()) {\n                if !configs.iter().any(|c| c.key == \"ros2_websocket_url\") {\n                    configs.push(SystemConfiguration {\n                        id: 0,\n                        key: \"ros2_websocket_url\".to_string(),\n                        value: ws_url.to_string(),\n                        enabled: 1,\n                        description: Some(\"Auto-enriched from entity config\".to_string()),\n                        created_at: now,\n                        updated_at: now,\n                    });\n                }\n            }\n        }\n    } else {\n        warn!(\"Could not look up ros2.bridge entity for config enrichment\");\n    }\n\n    // RTL-SDR server entity → sdr_host, sdr_port\n    if let Ok(Some(sdr_entity)) =\n        db::repository::get_entity_with_config_by_entity_id(pool, \"sdr.server\")\n    {\n        if let Some(config) = sdr_entity.configuration {\n            if let Some(host) = config.get(\"host\").and_then(|v| v.as_str()) {\n                if !configs.iter().any(|c| c.key == \"sdr_host\") {\n                    configs.push(SystemConfiguration {\n                        id: 0,\n                        key: \"sdr_host\".to_string(),\n                        value: host.to_string(),\n                        enabled: 1,\n                        description: Some(\"Auto-enriched from entity config\".to_string()),\n                        created_at: now,\n                        updated_at: now,\n                    });\n                }\n            }\n            if let Some(port) = config.get(\"port\").and_then(|v| v.as_str()) {\n                if !configs.iter().any(|c| c.key == \"sdr_port\") {\n                    configs.push(SystemConfiguration {\n                        id: 0,\n                        key: \"sdr_port\".to_string(),\n                        value: port.to_string(),\n                        enabled: 1,\n                        description: Some(\"Auto-enriched from entity config\".to_string()),\n                        created_at: now,\n                        updated_at: now,\n                    });\n                }\n            }\n        }\n    } else {\n        warn!(\"Could not look up sdr.server entity for config enrichment\");\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/mod.rs",
    "content": "pub mod binary_store;\npub mod engine;\npub mod manager_state;\npub mod nodes;\npub mod types;\n\npub use binary_store::BinaryStore;\n"
  },
  {
    "path": "apps/server/src/flow/nodes/branch.rs",
    "content": "use crate::flow::engine::ExecutionContext;\nuse anyhow::{anyhow, Result};\nuse async_trait::async_trait;\nuse serde_json::{json, Value};\nuse std::collections::HashMap;\nuse tracing::error;\n\nuse super::{ExecutableNode, ExecutionResult};\n\npub struct BranchNode;\n\nimpl BranchNode {\n    pub fn new() -> Result<Self> {\n        Ok(Self)\n    }\n}\n\n#[async_trait]\nimpl ExecutableNode for BranchNode {\n    async fn execute(\n        &self,\n        context: &mut ExecutionContext,\n        inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        let result: Result<ExecutionResult> = (|| {\n            let data = inputs\n                .get(\"data\")\n                .ok_or_else(|| anyhow!(\"'data' input is missing for BRANCH node\"))?;\n\n            let condition = inputs\n                .get(\"condition\")\n                .and_then(|v| v.as_bool())\n                .ok_or_else(|| anyhow!(\"'condition' input must be a boolean for BRANCH node\"))?;\n\n            let mut outputs = HashMap::new();\n\n            if condition {\n                outputs.insert(\"true_output\".to_string(), data.clone());\n            } else {\n                outputs.insert(\"false_output\".to_string(), data.clone());\n            }\n\n            Ok(ExecutionResult {\n                outputs,\n                ..Default::default()\n            })\n        })();\n\n        match result {\n            Ok(execution_result) => Ok(execution_result),\n            Err(e) => {\n                let ws_message = json!({\n                    \"type\": \"log_message\",\n                    \"payload\": e.to_string()\n                });\n\n                if let Ok(payload_str) = serde_json::to_string(&ws_message) {\n                    if context.get_broadcast().send(payload_str).is_err() {\n                        error!(\"Failed to send error message over websocket.\");\n                    }\n                }\n                Err(e)\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/calc.rs",
    "content": "use crate::flow::engine::ExecutionContext;\nuse anyhow::{anyhow, Result};\nuse async_trait::async_trait;\nuse serde::Deserialize;\nuse serde_json::Value;\nuse std::collections::HashMap;\nuse tokio::sync::broadcast;\n\nuse super::{ExecutableNode, ExecutionResult};\n\n#[derive(Deserialize, Debug, Clone)]\n#[serde(rename_all = \"camelCase\")]\nstruct CalcNodeData {\n    operator_calc: String,\n}\n\npub struct CalcNode {\n    data: CalcNodeData,\n}\n\nimpl CalcNode {\n    pub fn new(node_data: &Value) -> Result<Self> {\n        let data: CalcNodeData = serde_json::from_value(node_data.clone())?;\n        Ok(Self { data })\n    }\n}\n\n#[async_trait]\nimpl ExecutableNode for CalcNode {\n    async fn execute(\n        &self,\n        _context: &mut ExecutionContext,\n        inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        let a = inputs\n            .get(\"a\")\n            .and_then(Value::as_f64)\n            .ok_or_else(|| anyhow!(\"Input 'a' is not a valid number\"))?;\n        let b = inputs\n            .get(\"b\")\n            .and_then(Value::as_f64)\n            .ok_or_else(|| anyhow!(\"Input 'b' is not a valid number\"))?;\n        let operator_calc = Value::from(self.data.operator_calc.clone());\n        let result;\n\n        if operator_calc == \"+\" {\n            println!(\"Adding numbers: a = {}, b = {}\", a, b);\n            result = a + b;\n        } else if operator_calc == \"-\" {\n            println!(\"Adding numbers: a = {}, b = {}\", a, b);\n            result = a - b;\n        } else if operator_calc == \"/\" {\n            println!(\"Adding numbers: a = {}, b = {}\", a, b);\n            result = a / b;\n        } else if operator_calc == \"*\" {\n            println!(\"Adding numbers: a = {}, b = {}\", a, b);\n            result = a * b;\n        } else {\n            println!(\"Adding numbers: a = {}, b = {}\", a, b);\n            result = a % b;\n        }\n\n        let mut outputs = HashMap::new();\n        outputs.insert(\"number\".to_string(), Value::from(result));\n\n        Ok(ExecutionResult {\n            outputs,\n            ..Default::default()\n        })\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/custom_node.rs",
    "content": "use crate::flow::engine::ExecutionContext;\nuse crate::flow::types::{ExecutionResult, Node};\nuse anyhow::{anyhow, Result};\nuse async_trait::async_trait;\nuse rhai::packages::Package;\nuse rhai::{Dynamic, Engine, Scope};\nuse rhai_rand::RandomPackage;\nuse serde_json::Value;\nuse std::collections::HashMap;\nuse std::time::{SystemTime, UNIX_EPOCH};\nuse tokio::task;\nuse tracing::{error, info};\n\nuse super::ExecutableNode;\n\npub struct CustomNode {\n    node: Node,\n}\n\nimpl CustomNode {\n    pub fn new(node: &Node) -> Result<Self> {\n        let node = node.clone();\n        Ok(Self { node })\n    }\n}\n\n#[async_trait]\nimpl ExecutableNode for CustomNode {\n    async fn execute(\n        &self,\n        _context: &mut ExecutionContext,\n        inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        let path_value = self\n            .node\n            .data\n            .get(\"path\")\n            .ok_or_else(|| anyhow!(\"'path' not found in node data\"))?;\n        let original_path = path_value\n            .as_str()\n            .ok_or_else(|| anyhow!(\"'path' must be a string\"))?;\n\n        let script_path = original_path.replace(\"{:code}\", \"./storage\");\n\n        let code = match tokio::fs::read_to_string(&script_path).await {\n            Ok(c) => c,\n            Err(e) => {\n                let err_msg = format!(\"Failed to read script at '{}': {}\", script_path, e);\n                error!(\"{}\", err_msg);\n                return Err(anyhow!(err_msg));\n            }\n        };\n\n        let blocking_task = task::spawn_blocking(move || {\n            let mut engine = Engine::new();\n            engine.register_global_module(RandomPackage::new().as_shared_module());\n            engine.register_fn(\"epoch_ms\", || -> i64 {\n                SystemTime::now()\n                    .duration_since(UNIX_EPOCH)\n                    .unwrap()\n                    .as_millis() as i64\n            });\n\n            let inputs_dynamic: Dynamic = rhai::serde::to_dynamic(&inputs)\n                .map_err(|e| anyhow!(\"Input conversion failed: {}\", e))?;\n\n            let ast = engine\n                .compile(&code)\n                .map_err(|e| anyhow!(\"Script compilation failed: {}\", e))?;\n            let mut scope = Scope::new();\n            let result: Dynamic = engine\n                .call_fn(&mut scope, &ast, \"main\", (inputs_dynamic,))\n                .map_err(|e| anyhow!(\"Script execution failed: {}\", e))?;\n\n            let result_value: Value = rhai::serde::from_dynamic(&result)\n                .map_err(|e| anyhow!(\"Result conversion failed: {}\", e))?;\n            let outputs: HashMap<String, Value> = match result_value {\n                Value::Object(map) => map.into_iter().collect(),\n                _ => return Err(anyhow!(\"Script must return an object map\")),\n            };\n            Ok(outputs)\n        });\n\n        match blocking_task.await {\n            Ok(Ok(outputs)) => {\n                info!(\"Script executed successfully\");\n                Ok(ExecutionResult {\n                    outputs,\n                    ..Default::default()\n                })\n            }\n            Ok(Err(e)) => {\n                error!(\"Error in script execution: {}\", e);\n                Err(e)\n            }\n            Err(e) => {\n                error!(\"Failed to run script blocking task: {}\", e);\n                Err(anyhow!(\"Task join error: {}\", e))\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/dashboard_event_listener.rs",
    "content": "use anyhow::Result;\nuse async_trait::async_trait;\nuse serde::Deserialize;\nuse serde_json::{json, Value};\nuse std::collections::HashMap;\nuse tokio::{\n    sync::{broadcast, mpsc},\n    task::JoinHandle,\n};\nuse tracing::warn;\n\nuse super::{ExecutableNode, ExecutionResult};\nuse crate::flow::engine::{ExecutionContext, TriggerCommand};\nuse crate::state::DashboardUiEvent;\n\n#[derive(Deserialize, Debug, Clone)]\n#[serde(rename_all = \"camelCase\")]\npub struct DashboardEventListenerNodeData {\n    pub listener_id: String,\n}\n\npub struct DashboardEventListenerNode {\n    data: DashboardEventListenerNodeData,\n    dashboard_rx: broadcast::Receiver<DashboardUiEvent>,\n}\n\nimpl DashboardEventListenerNode {\n    pub fn new(\n        node_data: &Value,\n        dashboard_rx: broadcast::Receiver<DashboardUiEvent>,\n    ) -> Result<Self> {\n        let data: DashboardEventListenerNodeData = serde_json::from_value(node_data.clone())?;\n        Ok(Self { data, dashboard_rx })\n    }\n}\n\n#[async_trait]\nimpl ExecutableNode for DashboardEventListenerNode {\n    async fn execute(\n        &self,\n        _context: &mut ExecutionContext,\n        inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        let mut outputs = HashMap::new();\n        if let Some(payload) = inputs.get(\"payload\") {\n            outputs.insert(\"payload\".to_string(), payload.clone());\n        }\n        Ok(ExecutionResult {\n            outputs,\n            ..Default::default()\n        })\n    }\n\n    fn is_trigger(&self) -> bool {\n        true\n    }\n\n    fn start_trigger(\n        &self,\n        node_id: String,\n        trigger_tx: mpsc::Sender<TriggerCommand>,\n    ) -> Result<JoinHandle<()>> {\n        let target_id = self.data.listener_id.trim().to_string();\n        let mut rx = self.dashboard_rx.resubscribe();\n\n        let handle = tokio::spawn(async move {\n            loop {\n                match rx.recv().await {\n                    Ok(ev) => {\n                        if ev.listener_id != target_id {\n                            continue;\n                        }\n                        let payload = serde_json::to_value(&ev).unwrap_or(json!({}));\n                        let mut inputs = HashMap::new();\n                        inputs.insert(\"payload\".to_string(), payload);\n\n                        let cmd = TriggerCommand {\n                            node_id: node_id.clone(),\n                            inputs,\n                        };\n\n                        if trigger_tx.send(cmd).await.is_err() {\n                            break;\n                        }\n                    }\n                    Err(broadcast::error::RecvError::Lagged(skipped)) => {\n                        warn!(\n                            \"DASHBOARD_EVENT_LISTENER lagged, skipped {} messages\",\n                            skipped\n                        );\n                        continue;\n                    }\n                    Err(_) => break,\n                }\n            }\n        });\n        Ok(handle)\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/decode_h264.rs",
    "content": "use anyhow::{anyhow, Result};\nuse async_trait::async_trait;\nuse gstreamer::prelude::*;\nuse gstreamer::{self as gst, glib};\nuse gstreamer_app::{AppSink, AppSrc};\nuse serde_json::{json, Value};\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tokio::sync::{mpsc, Mutex};\nuse tracing::{error, info, warn};\n\nuse super::{ExecutableNode, ExecutionResult};\nuse crate::flow::engine::ExecutionContext;\n\npub struct DecodeH264Node {\n    appsrc: AppSrc,\n    frame_rx: Arc<Mutex<mpsc::Receiver<Vec<u8>>>>,\n    pipeline: gst::Pipeline,\n}\n\nimpl DecodeH264Node {\n    pub fn new() -> Result<Self> {\n        gst::init()?;\n\n        let pipeline_str = \"appsrc name=mysource ! rtph264depay ! h264parse config-interval=-1 ! appsink name=mysink\";\n        let pipeline = gst::parse::launch(pipeline_str)?\n            .downcast::<gst::Pipeline>()\n            .map_err(|_| anyhow!(\"Failed to downcast GStreamer pipeline\"))?;\n\n        let appsrc = pipeline\n            .by_name(\"mysource\")\n            .ok_or_else(|| anyhow!(\"Failed to get 'appsrc' from pipeline\"))?\n            .downcast::<AppSrc>()\n            .map_err(|_| anyhow!(\"Failed to downcast 'appsrc' element\"))?;\n\n        appsrc.set_property(\"is-live\", true);\n        appsrc.set_property(\"format\", gst::Format::Time);\n        appsrc.set_property(\"do-timestamp\", true);\n\n        let caps = gst::Caps::builder(\"application/x-rtp\")\n            .field(\"media\", \"video\")\n            .field(\"clock-rate\", 90000)\n            .field(\"encoding-name\", \"H264\")\n            .build();\n        appsrc.set_caps(Some(&caps));\n\n        let appsink = pipeline\n            .by_name(\"mysink\")\n            .ok_or_else(|| anyhow!(\"Failed to get 'appsink' from pipeline\"))?\n            .downcast::<AppSink>()\n            .map_err(|_| anyhow!(\"Failed to downcast 'appsink' element\"))?;\n\n        let (frame_tx, frame_rx) = mpsc::channel::<Vec<u8>>(5);\n\n        appsink.set_callbacks(\n            gstreamer_app::AppSinkCallbacks::builder()\n                .new_sample(move |sink| {\n                    let sample = sink.pull_sample().map_err(|_| gst::FlowError::Eos)?;\n                    let buffer = sample.buffer().ok_or(gst::FlowError::Error)?;\n                    let map = buffer.map_readable().map_err(|_| gst::FlowError::Error)?;\n\n                    if let Err(e) = frame_tx.try_send(map.as_slice().to_vec()) {\n                        warn!(\"Failed to send frame from GStreamer thread, channel might be full or closed: {}\", e);\n                    }\n\n                    Ok(gst::FlowSuccess::Ok)\n                })\n                .build(),\n        );\n\n        let bus = pipeline\n            .bus()\n            .ok_or_else(|| anyhow!(\"Failed to get GStreamer bus from pipeline\"))?;\n\n        bus.add_watch(move |_, msg| {\n            match msg.view() {\n                gst::MessageView::Error(err) => {\n                    error!(\n                        \"GStreamer pipeline error: {}, Debug: {:?}\",\n                        err.error(),\n                        err.debug()\n                    );\n                }\n                gst::MessageView::Eos(_) => {\n                    info!(\"GStreamer pipeline reached End-Of-Stream.\");\n                }\n                _ => {}\n            }\n            glib::ControlFlow::Continue\n        })?;\n\n        pipeline.set_state(gst::State::Playing)?;\n\n        // FIX: 파이프라인의 상태가 'Playing'으로 완전히 전환될 때까지 최대 5초간 기다립니다.\n        pipeline\n            .state(gst::ClockTime::from_seconds(5))\n            .0\n            .map_err(|err| anyhow!(\"Failed to set pipeline to Playing: {}\", err))?;\n\n        Ok(Self {\n            appsrc,\n            frame_rx: Arc::new(Mutex::new(frame_rx)),\n            pipeline,\n        })\n    }\n}\n\n#[async_trait]\nimpl ExecutableNode for DecodeH264Node {\n    async fn execute(\n        &self,\n        _context: &mut ExecutionContext,\n        inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        let encoded_packet = inputs\n            .get(\"payload\")\n            .and_then(|v| v.as_str())\n            .ok_or_else(|| anyhow!(\"'payload' input missing or not a string\"))?;\n\n        let rtp_packet_data = base64::decode(encoded_packet)?;\n\n        let buffer = gst::Buffer::from_slice(rtp_packet_data);\n        self.appsrc\n            .push_buffer(buffer)\n            .map_err(|e| anyhow!(\"Failed to push buffer to GStreamer appsrc: {}\", e))?;\n\n        let mut frame_rx = self.frame_rx.lock().await;\n\n        match frame_rx.try_recv() {\n            Ok(frame_data) => {\n                let frame_base64 = base64::encode(&frame_data);\n                let mut outputs = HashMap::new();\n                outputs.insert(\"frame\".to_string(), json!(frame_base64));\n                println!(\"send\");\n                Ok(ExecutionResult {\n                    outputs,\n                    ..Default::default()\n                })\n            }\n            Err(mpsc::error::TryRecvError::Empty) => Ok(ExecutionResult::default()),\n            Err(e) => Err(anyhow!(\"MPSC channel error in DecodeH264Node: {}\", e)),\n        }\n    }\n}\n\nimpl Drop for DecodeH264Node {\n    fn drop(&mut self) {\n        let _ = self.pipeline.set_state(gst::State::Null);\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/decode_opus.rs",
    "content": "use anyhow::{anyhow, Result};\nuse async_trait::async_trait;\nuse opus;\nuse serde_json::{json, Value};\nuse std::collections::HashMap;\n\nuse super::{ExecutableNode, ExecutionResult};\nuse crate::flow::engine::ExecutionContext;\n\npub struct DecodeOpusNode;\n\nimpl DecodeOpusNode {\n    pub fn new() -> Result<Self> {\n        Ok(Self)\n    }\n}\n\n#[async_trait]\nimpl ExecutableNode for DecodeOpusNode {\n    async fn execute(\n        &self,\n        _context: &mut ExecutionContext,\n        inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        let encoded_payload = inputs\n            .get(\"payload\")\n            .and_then(|v| v.as_str())\n            .ok_or_else(|| anyhow!(\"'payload' input missing or not a string\"))?;\n\n        let opus_data = base64::decode(encoded_payload)?;\n\n        let mut decoder =\n            opus::Decoder::new(48000, opus::Channels::Mono).map_err(|e| anyhow!(e))?;\n\n        let mut pcm_output = vec![0i16; 5760];\n        let decoded_samples = decoder.decode(&opus_data, &mut pcm_output, false)?;\n        let pcm_slice = &pcm_output[..decoded_samples];\n\n        let audio_info = if pcm_slice.is_empty() {\n            json!({\n                \"decibels\": -120.0,\n                \"duration_ms\": 0,\n                \"sample_rate\": 48000,\n                \"channels\": 1\n            })\n        } else {\n            let pcm_bytes: Vec<u8> = pcm_slice\n                .iter()\n                .flat_map(|&sample| sample.to_le_bytes())\n                .collect();\n            let pcm_payload_base64 = base64::encode(&pcm_bytes);\n\n            let sum_of_squares = pcm_slice.iter().map(|&s| (s as f64).powi(2)).sum::<f64>();\n            let rms = (sum_of_squares / pcm_slice.len() as f64).sqrt();\n\n            let dbfs = if rms > 0.0 {\n                20.0 * rms.log10() - 20.0 * (i16::MAX as f64).log10()\n            } else {\n                -120.0\n            };\n\n            let duration_ms = (pcm_slice.len() as f64 * 1000.0) / 48000.0;\n\n            json!({\n                \"decibels\": dbfs,\n                \"duration_ms\": duration_ms,\n                \"sample_rate\": 48000,\n                \"channels\": 1\n            })\n        };\n\n        let mut outputs = HashMap::new();\n        outputs.insert(\"info\".to_string(), audio_info);\n\n        Ok(ExecutionResult {\n            outputs,\n            ..Default::default()\n        })\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/gst_decoder.rs",
    "content": "use anyhow::{anyhow, Result};\nuse async_trait::async_trait;\nuse gstreamer::prelude::*;\nuse gstreamer::{self as gst, glib};\nuse gstreamer_app::{AppSink, AppSrc};\nuse serde::Deserialize;\nuse serde_json::{json, Value};\nuse std::collections::HashMap;\nuse tokio::sync::mpsc;\nuse tokio::task::JoinHandle;\nuse tracing::{error, info, warn};\nuse webrtc::util::Marshal;\n\nuse super::{ExecutableNode, ExecutionResult};\nuse crate::flow::BinaryStore;\nuse crate::{\n    flow::engine::{ExecutionContext, TriggerCommand},\n    state::StreamManager,\n};\n\n#[derive(Deserialize, Debug, Clone)]\npub struct GstDecoderData {\n    topic: String,\n}\n\npub struct GstDecoderNode {\n    data: GstDecoderData,\n    stream_manager: StreamManager,\n    binary_store: BinaryStore,\n}\n\nimpl GstDecoderNode {\n    pub fn new(\n        node_data: &Value,\n        stream_manager: StreamManager,\n        binary_store: BinaryStore,\n    ) -> Result<Self> {\n        let data: GstDecoderData = serde_json::from_value(node_data.clone())?;\n        Ok(Self {\n            data,\n            stream_manager,\n            binary_store,\n        })\n    }\n}\n\n#[async_trait]\nimpl ExecutableNode for GstDecoderNode {\n    async fn execute(\n        &self,\n        _context: &mut ExecutionContext,\n        inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        Ok(ExecutionResult {\n            outputs: inputs,\n            ..Default::default()\n        })\n    }\n\n    fn is_trigger(&self) -> bool {\n        true\n    }\n\n    fn start_trigger(\n        &self,\n        node_id: String,\n        trigger_tx: mpsc::Sender<TriggerCommand>,\n    ) -> Result<JoinHandle<()>> {\n        gst::init()?;\n        let stream_info = self\n            .stream_manager\n            .get_by_topic(&self.data.topic)\n            .ok_or_else(|| anyhow!(\"Stream with topic '{}' not found\", self.data.topic))?;\n\n        let pipeline_str = \"appsrc name=mysource ! rtph264depay ! vtdec ! videoconvert ! video/x-raw,format=RGB ! appsink name=mysink\";\n        let pipeline = gst::parse::launch(pipeline_str)?\n            .downcast::<gst::Pipeline>()\n            .map_err(|_| anyhow!(\"Failed to downcast GStreamer pipeline\"))?;\n\n        let appsrc = pipeline\n            .by_name(\"mysource\")\n            .ok_or_else(|| anyhow!(\"Failed to get 'appsrc' from pipeline\"))?\n            .downcast::<AppSrc>()\n            .map_err(|_| anyhow!(\"Failed to downcast 'appsrc' element\"))?;\n\n        appsrc.set_property(\"is-live\", true);\n        appsrc.set_property(\"format\", gst::Format::Time);\n        appsrc.set_property(\"do-timestamp\", true);\n        let caps = gst::Caps::builder(\"application/x-rtp\")\n            .field(\"media\", \"video\")\n            .field(\"clock-rate\", 90000)\n            .field(\"encoding-name\", \"H264\")\n            .build();\n        appsrc.set_caps(Some(&caps));\n\n        let appsink = pipeline\n            .by_name(\"mysink\")\n            .ok_or_else(|| anyhow!(\"Failed to get 'appsink' from pipeline\"))?\n            .downcast::<AppSink>()\n            .map_err(|_| anyhow!(\"Failed to downcast 'appsink' element\"))?;\n\n        let bus = pipeline.bus().unwrap();\n        bus.add_watch(move |_, msg| {\n            match msg.view() {\n                gst::MessageView::Error(err) => {\n                    error!(\"GStreamer bus error: {}, {:?}\", err.error(), err.debug());\n                }\n                gst::MessageView::Eos(_) => info!(\"GStreamer bus EOS.\"),\n                _ => {}\n            }\n            glib::ControlFlow::Continue\n        })?;\n\n        pipeline.set_state(gst::State::Playing)?;\n        pipeline\n            .state(gst::ClockTime::from_seconds(5))\n            .0\n            .map_err(|err| anyhow!(\"Failed to set pipeline to Playing: {}\", err))?;\n\n        info!(\"GStreamer pipeline started for topic '{}'\", self.data.topic);\n\n        let binary_store = self.binary_store.clone();\n\n        let handle = tokio::spawn(async move {\n            let mut packet_rx = stream_info.packet_tx.subscribe();\n\n            appsink.set_callbacks(\n                gstreamer_app::AppSinkCallbacks::builder()\n                    .new_sample(move |sink| {\n                        let sample = sink.pull_sample().map_err(|_| gst::FlowError::Eos)?;\n                        let buffer = sample.buffer().ok_or(gst::FlowError::Error)?;\n                        let caps = sample.caps().ok_or(gst::FlowError::Error)?;\n                        let s = caps.structure(0).ok_or(gst::FlowError::Error)?;\n                        let width =\n                            s.get::<i32>(\"width\").map_err(|_| gst::FlowError::Error)? as u32;\n                        let height =\n                            s.get::<i32>(\"height\").map_err(|_| gst::FlowError::Error)? as u32;\n\n                        let map = buffer.map_readable().map_err(|_| gst::FlowError::Error)?;\n                        let frame_bytes = map.as_slice().to_vec();\n\n                        let frame_id = binary_store.insert(frame_bytes);\n\n                        let frame_output = json!({\n                               \"type\": \"binary_pointer\",\n                            \"id\": frame_id.to_string(),\n                            \"width\": width,\n                            \"height\": height,\n                        });\n\n                        let mut inputs = HashMap::new();\n                        inputs.insert(\"frame\".to_string(), frame_output);\n\n                        let cmd = TriggerCommand {\n                            node_id: node_id.clone(),\n                            inputs,\n                        };\n\n                        if trigger_tx.try_send(cmd).is_err() {\n                            // This warning is expected if YOLO is slower than the video FPS.\n                        }\n\n                        Ok(gst::FlowSuccess::Ok)\n                    })\n                    .build(),\n            );\n\n            loop {\n                match packet_rx.recv().await {\n                    Ok(packet) => {\n                        let header_bytes = packet.header.marshal().unwrap();\n                        let mut raw_packet_bytes =\n                            Vec::with_capacity(header_bytes.len() + packet.payload.len());\n                        raw_packet_bytes.extend_from_slice(&header_bytes);\n                        raw_packet_bytes.extend_from_slice(&packet.payload);\n\n                        let buffer = gst::Buffer::from_slice(raw_packet_bytes);\n                        if appsrc.push_buffer(buffer).is_err() {\n                            error!(\"Failed to push buffer to appsrc, pipeline might have closed.\");\n                            break;\n                        }\n                    }\n                    Err(e) => {\n                        warn!(\"RTP packet receiver lagged or closed: {}\", e);\n                    }\n                }\n            }\n\n            let _ = pipeline.set_state(gst::State::Null);\n        });\n\n        Ok(handle)\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/http.rs",
    "content": "use crate::flow::engine::ExecutionContext;\nuse anyhow::{anyhow, Result};\nuse async_trait::async_trait;\nuse reqwest::Client;\nuse serde::Deserialize;\nuse serde_json::Value;\nuse std::collections::HashMap;\nuse tokio::sync::broadcast;\n\nuse super::{ExecutableNode, ExecutionResult};\n\n#[derive(Deserialize, Debug, Clone)]\n#[serde(rename_all = \"camelCase\")]\nstruct HttpNodeData {\n    url: String,\n    http_method: String,\n}\n\npub struct HttpNode {\n    data: HttpNodeData,\n}\n\nimpl HttpNode {\n    pub fn new(node_data: &Value) -> Result<Self> {\n        let data: HttpNodeData = serde_json::from_value(node_data.clone())?;\n        Ok(Self { data })\n    }\n}\n\n#[async_trait]\nimpl ExecutableNode for HttpNode {\n    async fn execute(\n        &self,\n        _context: &mut ExecutionContext,\n        inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        let client = Client::new();\n        let url = &self.data.url;\n        let method = self.data.http_method.to_uppercase();\n\n        let response = match method.as_str() {\n            \"GET\" => client.get(url).send().await?,\n            \"POST\" => {\n                let _body = inputs.get(\"body\").cloned().unwrap_or(Value::Null);\n                client.post(url).send().await?\n            }\n            \"PUT\" => {\n                let _body = inputs.get(\"body\").cloned().unwrap_or(Value::Null);\n                client.put(url).send().await?\n            }\n            \"DELETE\" => client.delete(url).send().await?,\n            _ => {\n                return Err(anyhow!(\n                    \"Unsupported HTTP method: {}\",\n                    self.data.http_method\n                ));\n            }\n        };\n\n        let result_body = if response.status().is_success() {\n            response.text().await?\n        } else {\n            return Err(anyhow!(\n                \"HTTP request failed with status: {}\",\n                response.status()\n            ));\n        };\n\n        let mut outputs = HashMap::new();\n        outputs.insert(\"result\".to_string(), Value::from(result_body));\n\n        Ok(ExecutionResult {\n            outputs,\n            ..Default::default()\n        })\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/interval.rs",
    "content": "use crate::flow::engine::{ExecutionContext, TriggerCommand};\nuse anyhow::{anyhow, Result};\nuse async_trait::async_trait;\nuse serde::Deserialize;\nuse serde_json::Value;\nuse std::{collections::HashMap, time::Duration};\nuse tokio::{sync::mpsc, task::JoinHandle, time};\n\nuse super::{ExecutableNode, ExecutionResult};\n\n#[derive(Deserialize, Debug, Clone)]\nstruct IntervalNodeData {\n    interval: u64,\n    unit: String,\n}\n\npub struct IntervalNode {\n    data: IntervalNodeData,\n}\n\nimpl IntervalNode {\n    pub fn new(node_data: &Value) -> Result<Self> {\n        let data: IntervalNodeData = serde_json::from_value(node_data.clone())?;\n        Ok(Self { data })\n    }\n\n    pub fn get_duration(&self) -> Result<Duration> {\n        match self.data.unit.as_str() {\n            \"milliseconds\" => Ok(Duration::from_millis(self.data.interval)),\n            \"seconds\" => Ok(Duration::from_secs(self.data.interval)),\n            \"minutes\" => Ok(Duration::from_secs(self.data.interval * 60)),\n            _ => Err(anyhow!(\"Unsupported time unit: {}\", self.data.unit)),\n        }\n    }\n}\n\n#[async_trait]\nimpl ExecutableNode for IntervalNode {\n    async fn execute(\n        &self,\n        _context: &mut ExecutionContext,\n        _inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        let mut outputs = HashMap::new();\n        outputs.insert(\"exec\".to_string(), Value::Null);\n        Ok(ExecutionResult {\n            outputs,\n            ..Default::default()\n        })\n    }\n\n    fn is_trigger(&self) -> bool {\n        true\n    }\n\n    fn start_trigger(\n        &self,\n        node_id: String,\n        trigger_tx: mpsc::Sender<TriggerCommand>,\n    ) -> Result<JoinHandle<()>> {\n        let duration = self.get_duration()?;\n        let handle = tokio::spawn(async move {\n            let mut interval = time::interval(duration);\n            loop {\n                interval.tick().await;\n                let cmd = TriggerCommand {\n                    node_id: node_id.clone(),\n                    inputs: HashMap::new(),\n                };\n                if trigger_tx.send(cmd).await.is_err() {\n                    break;\n                }\n            }\n        });\n        Ok(handle)\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/json_modify.rs",
    "content": "use crate::flow::engine::ExecutionContext;\nuse anyhow::{anyhow, Result};\nuse async_trait::async_trait;\nuse serde::Deserialize;\nuse serde_json::Value;\nuse std::collections::HashMap;\n\nuse super::{ExecutableNode, ExecutionResult};\n\n#[derive(Deserialize, Debug)]\nstruct JsonModifyData {\n    path: String,\n}\n\npub struct JsonModifyNode {\n    data: JsonModifyData,\n}\n\nimpl JsonModifyNode {\n    pub fn new(node_data: &Value) -> Result<Self> {\n        let data: JsonModifyData = serde_json::from_value(node_data.clone())?;\n        Ok(Self { data })\n    }\n}\n\n#[async_trait]\nimpl ExecutableNode for JsonModifyNode {\n    async fn execute(\n        &self,\n        _context: &mut ExecutionContext,\n        inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        let mut json_input = inputs\n            .get(\"json\")\n            .cloned()\n            .ok_or_else(|| anyhow!(\"'json' input is missing for JSON_Modify node\"))?;\n\n        let data_input = inputs\n            .get(\"data\")\n            .cloned()\n            .ok_or_else(|| anyhow!(\"'data' input is missing for JSON_Set node\"))?;\n\n        let json_pointer_path = format!(\"/{}\", self.data.path.replace('.', \"/\"));\n\n        let target = json_input\n            .pointer_mut(&json_pointer_path)\n            .ok_or_else(|| anyhow!(\"Path '{}' not found in the input json\", self.data.path))?;\n\n        *target = data_input;\n\n        let mut outputs = HashMap::new();\n        outputs.insert(\"value\".to_string(), json_input);\n\n        Ok(ExecutionResult {\n            outputs,\n            ..Default::default()\n        })\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/json_selector.rs",
    "content": "use crate::flow::engine::ExecutionContext;\nuse anyhow::{anyhow, Result};\nuse async_trait::async_trait;\nuse serde::Deserialize;\nuse serde_json::Value;\nuse std::collections::HashMap;\n\nuse super::{ExecutableNode, ExecutionResult};\n\n#[derive(Deserialize, Debug)]\nstruct JsonSelectorData {\n    path: String,\n}\n\npub struct JsonSelectorNode {\n    data: JsonSelectorData,\n}\n\nimpl JsonSelectorNode {\n    pub fn new(node_data: &Value) -> Result<Self> {\n        let data: JsonSelectorData = serde_json::from_value(node_data.clone())?;\n        Ok(Self { data })\n    }\n}\n\n#[async_trait]\nimpl ExecutableNode for JsonSelectorNode {\n    async fn execute(\n        &self,\n        _context: &mut ExecutionContext,\n        inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        let json_input = inputs\n            .get(\"json\")\n            .ok_or_else(|| anyhow!(\"'json' input is missing for JSON_SELECTOR node\"))?;\n\n        let json_pointer_path = format!(\"/{}\", self.data.path.replace('.', \"/\"));\n\n        let selected_value = json_input\n            .pointer(&json_pointer_path)\n            .cloned()\n            .unwrap_or(Value::Null);\n\n        let mut outputs = HashMap::new();\n        outputs.insert(\"value\".to_string(), selected_value);\n\n        Ok(ExecutionResult {\n            outputs,\n            ..Default::default()\n        })\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/log_message.rs",
    "content": "use crate::flow::engine::ExecutionContext;\nuse anyhow::Result;\nuse async_trait::async_trait;\nuse serde_json::{json, Value};\nuse std::collections::HashMap;\nuse tokio::sync::broadcast;\nuse tracing::error;\n\nuse super::{ExecutableNode, ExecutionResult};\n\npub struct LogMessageNode;\n\n#[async_trait]\nimpl ExecutableNode for LogMessageNode {\n    async fn execute(\n        &self,\n        context: &mut ExecutionContext,\n        inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        println!(\"[LOG]: {:?}\", inputs);\n\n        let ws_message = json!({\n            \"type\": \"log_message\",\n            \"payload\": inputs.clone()\n        });\n        if let Ok(payload_str) = serde_json::to_string(&ws_message) {\n            if context.get_broadcast().send(payload_str).is_err() {\n                error!(\"Failed to send health check response.\");\n            }\n        }\n        Ok(ExecutionResult::default())\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/logic_operator.rs",
    "content": "use crate::flow::engine::ExecutionContext;\nuse anyhow::{anyhow, Result};\nuse async_trait::async_trait;\nuse serde::Deserialize;\nuse serde_json::{json, Value};\nuse std::collections::HashMap;\nuse tokio::sync::broadcast;\n\nuse super::{ExecutableNode, ExecutionResult};\n\n#[derive(Deserialize, Debug, Clone)]\n#[serde(rename_all = \"camelCase\")]\nstruct LogicOpetatorNodeData {\n    operator: String,\n}\n\npub struct LogicOpetatorNode {\n    data: LogicOpetatorNodeData,\n}\n\nimpl LogicOpetatorNode {\n    pub fn new(node_data: &Value) -> Result<Self> {\n        let data: LogicOpetatorNodeData = serde_json::from_value(node_data.clone())?;\n        Ok(Self { data })\n    }\n\n    async fn get_input_as_bool(\n        &self,\n        key: &str,\n        inputs: &HashMap<String, Value>,\n        broadcast_tx: &broadcast::Sender<String>,\n    ) -> Result<bool> {\n        let input_val = inputs\n            .get(key)\n            .ok_or_else(|| anyhow!(\"Input '{}' is missing\", key))?;\n\n        if let Some(b) = input_val.as_bool() {\n            return Ok(b);\n        }\n        if let Some(n) = input_val.as_f64() {\n            return Ok(n != 0.0);\n        }\n\n        let err = anyhow!(\"Input '{}' is not a valid boolean or number\", key);\n        if let Ok(payload_str) =\n            serde_json::to_string(&json!({ \"type\": \"log_message\", \"payload\": err.to_string() }))\n        {\n            let _ = broadcast_tx.send(payload_str);\n        }\n        Err(err)\n    }\n\n    async fn get_input_as_f64(\n        &self,\n        key: &str,\n        inputs: &HashMap<String, Value>,\n        broadcast_tx: &broadcast::Sender<String>,\n    ) -> Result<f64> {\n        if let Some(n) = inputs.get(key).and_then(Value::as_f64) {\n            return Ok(n);\n        }\n\n        let err = anyhow!(\"Input '{}' is not a valid number for comparison\", key);\n        if let Ok(payload_str) =\n            serde_json::to_string(&json!({ \"type\": \"log_message\", \"payload\": err.to_string() }))\n        {\n            let _ = broadcast_tx.send(payload_str);\n        }\n        Err(err)\n    }\n}\n\n#[async_trait]\nimpl ExecutableNode for LogicOpetatorNode {\n    async fn execute(\n        &self,\n        context: &mut ExecutionContext,\n        inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        let op = self.data.operator.as_str();\n\n        let result_bool = match op {\n            \"AND\" | \"OR\" | \"XOR\" | \"NAND\" | \"NOR\" | \"XNOR\" => {\n                let a = self\n                    .get_input_as_bool(\"a\", &inputs, &context.get_broadcast())\n                    .await?;\n                let b = self\n                    .get_input_as_bool(\"b\", &inputs, &context.get_broadcast())\n                    .await?;\n\n                match op {\n                    \"AND\" => a && b,\n                    \"OR\" => a || b,\n                    \"XOR\" => a ^ b,\n                    \"NAND\" => !(a && b),\n                    \"NOR\" => !(a || b),\n                    \"XNOR\" => !(a ^ b),\n                    _ => unreachable!(),\n                }\n            }\n            \">\" | \"<\" | \"==\" | \"!=\" | \">=\" | \"<=\" => {\n                let a = self\n                    .get_input_as_f64(\"a\", &inputs, &context.get_broadcast())\n                    .await?;\n                let b = self\n                    .get_input_as_f64(\"b\", &inputs, &context.get_broadcast())\n                    .await?;\n\n                match op {\n                    \">\" => a > b,\n                    \"<\" => a < b,\n                    \"==\" => a == b,\n                    \"!=\" => a != b,\n                    \">=\" => a >= b,\n                    \"<=\" => a <= b,\n                    _ => unreachable!(),\n                }\n            }\n            _ => {\n                let err = anyhow!(\"Unsupported operator: {}\", op);\n                if let Ok(payload_str) = serde_json::to_string(\n                    &json!({ \"type\": \"log_message\", \"payload\": err.to_string() }),\n                ) {\n                    let _ = context.get_broadcast().send(payload_str);\n                }\n                return Err(err);\n            }\n        };\n\n        let mut outputs = HashMap::new();\n        outputs.insert(\"bool\".to_string(), Value::from(result_bool));\n        Ok(ExecutionResult {\n            outputs,\n            ..Default::default()\n        })\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/mod.rs",
    "content": "use anyhow::{anyhow, Result};\nuse async_trait::async_trait;\nuse serde_json::Value;\nuse std::collections::HashMap;\nuse tokio::{\n    sync::{broadcast, mpsc},\n    task::JoinHandle,\n};\n\nuse crate::flow::{\n    engine::{ExecutionContext, TriggerCommand},\n    types::ExecutionResult,\n};\n\n#[async_trait]\npub trait ExecutableNode: Send + Sync {\n    async fn execute(\n        &self,\n        context: &mut ExecutionContext,\n        inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult>;\n\n    fn is_trigger(&self) -> bool {\n        false\n    }\n\n    fn start_trigger(\n        &self,\n        _node_id: String,\n        _trigger_tx: mpsc::Sender<TriggerCommand>,\n    ) -> Result<JoinHandle<()>> {\n        Err(anyhow!(\"Node is not a trigger\"))\n    }\n}\n\npub mod branch;\npub mod calc;\npub mod custom_node;\npub mod dashboard_event_listener;\npub mod decode_h264;\npub mod decode_opus;\npub mod gst_decoder;\npub mod http;\npub mod interval;\npub mod json_modify;\npub mod json_selector;\npub mod log_message;\npub mod logic_operator;\npub mod mqtt_publish;\npub mod mqtt_subscribe;\npub mod rtp_stream_in;\npub mod set_variable;\npub mod set_variable_with_exec;\npub mod show_toast;\npub mod start;\npub mod type_converter;\npub mod websocket_on;\npub mod websocket_send;\npub mod yolo_detect;\n"
  },
  {
    "path": "apps/server/src/flow/nodes/mqtt_publish.rs",
    "content": "use anyhow::{anyhow, Result};\nuse async_trait::async_trait;\nuse rumqttc::QoS;\nuse serde::Deserialize;\nuse serde_json::{json, to_string, Value};\nuse std::collections::HashMap;\nuse tokio::sync::broadcast;\nuse tracing::{error, warn};\n\nuse super::ExecutableNode;\nuse crate::flow::{engine::ExecutionContext, types::ExecutionResult};\n\n#[derive(Deserialize, Debug, Clone)]\n#[serde(rename_all = \"camelCase\")]\nstruct MqttPublishNodeData {\n    topic: String,\n    qos: Option<u8>,\n    retain: Option<bool>,\n}\n\npub struct MqttPublishNode {\n    data: MqttPublishNodeData,\n}\n\nimpl MqttPublishNode {\n    pub fn new(node_data: &Value) -> Result<Self> {\n        let data: MqttPublishNodeData = serde_json::from_value(node_data.clone())?;\n        Ok(Self { data })\n    }\n}\n\n#[async_trait]\nimpl ExecutableNode for MqttPublishNode {\n    async fn execute(\n        &self,\n        context: &mut ExecutionContext,\n        inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        let Some(payload) = inputs.get(\"payload\") else {\n            return Err(anyhow!(\"'payload' input is missing for MQTT_PUBLISH node\"));\n        };\n\n        let payload_bytes = match payload {\n            Value::String(s) => s.clone().into_bytes(),\n            _ => to_string(payload)?.into_bytes(),\n        };\n\n        let qos = match self.data.qos.unwrap_or(1) {\n            0 => QoS::AtMostOnce,\n            1 => QoS::AtLeastOnce,\n            2 => QoS::ExactlyOnce,\n            _ => return Err(anyhow!(\"Invalid QoS value. Must be 0, 1, or 2.\")),\n        };\n\n        let retain = self.data.retain.unwrap_or(false);\n\n        if let Some(client) = context.mqtt_client() {\n            match client\n                .publish(&self.data.topic, qos, retain, payload_bytes)\n                .await\n            {\n                Ok(_) => (),\n                Err(e) => return Err(anyhow!(\"Failed to publish MQTT message: {}\", e)),\n            }\n        } else {\n            warn!(\"MQTT client is not available, cannot publish message.\");\n        }\n\n        let ws_message = json!({\n            \"type\": \"log_message\",\n            \"payload\": \"Publish MQTT Topic\"\n        });\n        if let Ok(payload_str) = serde_json::to_string(&ws_message) {\n            if context.get_broadcast().send(payload_str).is_err() {\n                error!(\"Failed to send health check response.\");\n            }\n        }\n\n        Ok(ExecutionResult::default())\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/mqtt_subscribe.rs",
    "content": "use anyhow::Result;\nuse async_trait::async_trait;\nuse serde::Deserialize;\nuse serde_json::Value;\nuse std::collections::HashMap;\nuse tokio::{\n    sync::{broadcast, mpsc},\n    task::JoinHandle,\n};\nuse tracing::warn;\n\nuse super::{ExecutableNode, ExecutionResult};\nuse crate::flow::engine::{ExecutionContext, TriggerCommand};\nuse crate::state::MqttMessage;\n\n#[derive(Deserialize, Debug, Clone)]\n#[serde(rename_all = \"camelCase\")]\npub struct MqttSubscribeNodeData {\n    pub topic: String,\n}\n\npub struct MqttSubscribeNode {\n    pub data: MqttSubscribeNodeData,\n    mqtt_rx: broadcast::Receiver<MqttMessage>,\n    source_node_ids: Vec<String>,\n}\n\nimpl MqttSubscribeNode {\n    pub fn new(\n        node_data: &Value,\n        mqtt_rx: broadcast::Receiver<MqttMessage>,\n        source_node_ids: Vec<String>,\n    ) -> Result<Self> {\n        let data: MqttSubscribeNodeData = serde_json::from_value(node_data.clone())?;\n        Ok(Self {\n            data,\n            mqtt_rx,\n            source_node_ids,\n        })\n    }\n}\n\n#[async_trait]\nimpl ExecutableNode for MqttSubscribeNode {\n    async fn execute(\n        &self,\n        _context: &mut ExecutionContext,\n        inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        let mut outputs = HashMap::new();\n        if let Some(payload) = inputs.get(\"payload\") {\n            outputs.insert(\"payload\".to_string(), payload.clone());\n        }\n        Ok(ExecutionResult {\n            outputs,\n            ..Default::default()\n        })\n    }\n\n    fn is_trigger(&self) -> bool {\n        true\n    }\n\n    fn start_trigger(\n        &self,\n        node_id: String,\n        trigger_tx: mpsc::Sender<TriggerCommand>,\n    ) -> Result<JoinHandle<()>> {\n        let mut rx = self.mqtt_rx.resubscribe();\n        let topic_to_match = self.data.topic.clone();\n        let source_ids = self.source_node_ids.clone();\n\n        let handle = tokio::spawn(async move {\n            loop {\n                match rx.recv().await {\n                    Ok(msg) => {\n                        if msg.topic == topic_to_match {\n                            for source_id in &source_ids {\n                                if *source_id != node_id {\n                                    let cmd = TriggerCommand {\n                                        node_id: source_id.clone(),\n                                        inputs: HashMap::new(),\n                                    };\n                                    if trigger_tx.send(cmd).await.is_err() {\n                                        return;\n                                    }\n                                }\n                            }\n\n                            let payload_value = match serde_json::from_slice(&msg.bytes) {\n                                Ok(p) => p,\n                                Err(_) => {\n                                    Value::String(String::from_utf8_lossy(&msg.bytes).to_string())\n                                }\n                            };\n                            let mut inputs = HashMap::new();\n                            inputs.insert(\"payload\".to_string(), payload_value);\n\n                            let cmd = TriggerCommand {\n                                node_id: node_id.clone(),\n                                inputs,\n                            };\n\n                            if trigger_tx.send(cmd).await.is_err() {\n                                break;\n                            }\n                        }\n                    }\n                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {\n                        warn!(\"MQTT trigger lagged. Some messages may have been missed.\");\n                        continue;\n                    }\n                    Err(_) => {\n                        break;\n                    }\n                }\n            }\n        });\n        Ok(handle)\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/rtp_stream_in.rs",
    "content": "use anyhow::{anyhow, Result};\nuse async_trait::async_trait;\nuse serde::Deserialize;\nuse serde_json::{json, Value};\nuse std::collections::HashMap;\nuse tokio::{sync::mpsc, task::JoinHandle};\nuse tracing::warn;\nuse webrtc::util::Marshal;\n\nuse super::{ExecutableNode, ExecutionResult};\nuse crate::{\n    flow::engine::{ExecutionContext, TriggerCommand},\n    state::StreamManager,\n};\n\n#[derive(Deserialize, Debug, Clone)]\npub struct RtpStreamInData {\n    topic: String,\n}\n\npub struct RtpStreamInNode {\n    data: RtpStreamInData,\n    stream_manager: StreamManager,\n    source_node_ids: Vec<String>,\n}\n\nimpl RtpStreamInNode {\n    pub fn new(\n        node_data: &Value,\n        stream_manager: StreamManager,\n        source_node_ids: Vec<String>,\n    ) -> Result<Self> {\n        let data: RtpStreamInData = serde_json::from_value(node_data.clone())?;\n        Ok(Self {\n            data,\n            stream_manager,\n            source_node_ids,\n        })\n    }\n}\n\n#[async_trait]\nimpl ExecutableNode for RtpStreamInNode {\n    async fn execute(\n        &self,\n        _context: &mut ExecutionContext,\n        inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        let mut outputs = HashMap::new();\n        if let Some(payload) = inputs.get(\"payload\") {\n            outputs.insert(\"payload\".to_string(), payload.clone());\n        }\n        if let Some(raw_packet) = inputs.get(\"raw_packet\") {\n            outputs.insert(\"raw_packet\".to_string(), raw_packet.clone());\n        }\n        Ok(ExecutionResult {\n            outputs,\n            ..Default::default()\n        })\n    }\n\n    fn is_trigger(&self) -> bool {\n        true\n    }\n\n    fn start_trigger(\n        &self,\n        node_id: String,\n        trigger_tx: mpsc::Sender<TriggerCommand>,\n    ) -> Result<JoinHandle<()>> {\n        let stream_info = self\n            .stream_manager\n            .get_by_topic(&self.data.topic)\n            .ok_or_else(|| anyhow!(\"Stream with topic '{}' not found\", self.data.topic))?;\n\n        let mut packet_rx = stream_info.packet_tx.subscribe();\n        let source_ids = self.source_node_ids.clone();\n\n        let handle = tokio::spawn(async move {\n            loop {\n                match packet_rx.recv().await {\n                    Ok(packet) => {\n                        for source_id in &source_ids {\n                            if *source_id != node_id {\n                                let cmd = TriggerCommand {\n                                    node_id: source_id.clone(),\n                                    inputs: HashMap::new(),\n                                };\n                                if trigger_tx.send(cmd).await.is_err() {\n                                    return;\n                                }\n                            }\n                        }\n\n                        let payload_b64 = base64::encode(&packet.payload);\n\n                        let header_bytes = match packet.header.marshal() {\n                            Ok(bytes) => bytes,\n                            Err(_) => continue,\n                        };\n                        let mut raw_packet_bytes =\n                            Vec::with_capacity(header_bytes.len() + packet.payload.len());\n                        raw_packet_bytes.extend_from_slice(&header_bytes);\n                        raw_packet_bytes.extend_from_slice(&packet.payload);\n                        let raw_packet_b64 = base64::encode(&raw_packet_bytes);\n\n                        let mut inputs = HashMap::new();\n                        inputs.insert(\"payload\".to_string(), json!(payload_b64));\n                        inputs.insert(\"raw_packet\".to_string(), json!(raw_packet_b64));\n\n                        let cmd = TriggerCommand {\n                            node_id: node_id.clone(),\n                            inputs,\n                        };\n\n                        if trigger_tx.send(cmd).await.is_err() {\n                            break;\n                        }\n                    }\n                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {\n                        warn!(\"RTP trigger lagged. Some packets may have been missed.\");\n                        continue;\n                    }\n                    Err(_) => {\n                        break;\n                    }\n                }\n            }\n        });\n        Ok(handle)\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/set_variable.rs",
    "content": "use anyhow::{anyhow, Result};\nuse async_trait::async_trait;\nuse serde::Deserialize;\nuse serde_json::{json, Value};\nuse std::collections::HashMap;\nuse tokio::sync::broadcast;\nuse tracing::log::error;\n\nuse super::{ExecutableNode, ExecutionContext, ExecutionResult};\n\n#[derive(Deserialize, Debug, Clone)]\n#[serde(rename_all = \"camelCase\")]\nstruct SetVariableNodeData {\n    variable: String,\n    variable_type: Option<String>,\n}\n\npub struct SetVariableNode {\n    data: SetVariableNodeData,\n}\n\nimpl SetVariableNode {\n    pub fn new(node_data: &Value) -> Result<Self> {\n        let data: SetVariableNodeData = serde_json::from_value(node_data.clone())?;\n        Ok(Self { data })\n    }\n}\n\n#[async_trait]\nimpl ExecutableNode for SetVariableNode {\n    async fn execute(\n        &self,\n        context: &mut ExecutionContext,\n        _inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        let mut outputs = HashMap::new();\n\n        let value = match self.data.variable_type.as_deref() {\n            Some(\"number\") => {\n                if let Ok(num) = self.data.variable.parse::<i64>() {\n                    json!(num)\n                } else if let Ok(num) = self.data.variable.parse::<f64>() {\n                    json!(num)\n                } else {\n                    let error_message =\n                        format!(\"Failed to parse '{}' as a number.\", self.data.variable);\n                    let ws_message = json!({\n                        \"type\": \"log_message\",\n                        \"payload\": error_message.clone()\n                    });\n                    if let Ok(payload_str) = serde_json::to_string(&ws_message) {\n                        if context.get_broadcast().send(payload_str).is_err() {\n                            error!(\"Failed to send websocket log message.\");\n                        }\n                    }\n\n                    return Err(anyhow!(error_message));\n                }\n            }\n            Some(\"boolean\") => match self.data.variable.parse::<bool>() {\n                Ok(b) => json!(b),\n                Err(_) => {\n                    let error_message =\n                        format!(\"Failed to parse '{}' as a boolean.\", self.data.variable);\n                    let ws_message = json!({\n                        \"type\": \"log_message\",\n                        \"payload\": error_message.clone(),\n                    });\n                    if let Ok(payload_str) = serde_json::to_string(&ws_message) {\n                        if context.get_broadcast().send(payload_str).is_err() {\n                            error!(\"Failed to send websocket log message.\");\n                        }\n                    }\n                    return Err(anyhow!(error_message));\n                }\n            },\n            Some(\"string\") => {\n                json!(self.data.variable)\n            }\n            Some(\"json\") | _ => match serde_json::from_str::<Value>(&self.data.variable) {\n                Ok(json_value) => json_value,\n                Err(_) => {\n                    let error_message =\n                        format!(\"Failed to parse '{}' as JSON.\", self.data.variable);\n                    let ws_message = json!({\n                        \"type\": \"log_message\",\n                        \"payload\": error_message.clone(),\n                    });\n                    if let Ok(payload_str) = serde_json::to_string(&ws_message) {\n                        if context.get_broadcast().send(payload_str).is_err() {\n                            error!(\"Failed to send websocket log message.\");\n                        }\n                    }\n                    return Err(anyhow!(error_message));\n                }\n            },\n        };\n\n        outputs.insert(\"out\".to_string(), value);\n\n        Ok(ExecutionResult {\n            outputs,\n            ..Default::default()\n        })\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/set_variable_with_exec.rs",
    "content": "use anyhow::{anyhow, Result};\nuse async_trait::async_trait;\nuse serde::Deserialize;\nuse serde_json::{json, Value};\nuse std::collections::HashMap;\nuse tokio::sync::broadcast;\nuse tracing::log::error;\n\nuse super::{ExecutableNode, ExecutionContext, ExecutionResult};\n\n#[derive(Deserialize, Debug, Clone)]\n#[serde(rename_all = \"camelCase\")]\nstruct SetVariableNodeData {\n    variable: String,\n    variable_type: Option<String>,\n}\n\npub struct SetVariableWithExecNode {\n    data: SetVariableNodeData,\n}\n\nimpl SetVariableWithExecNode {\n    pub fn new(node_data: &Value) -> Result<Self> {\n        let data: SetVariableNodeData = serde_json::from_value(node_data.clone())?;\n        Ok(Self { data })\n    }\n}\n\n#[async_trait]\nimpl ExecutableNode for SetVariableWithExecNode {\n    async fn execute(\n        &self,\n        context: &mut ExecutionContext,\n        inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        let Some(execution) = inputs.get(\"exec\") else {\n            if let Ok(payload_str) = serde_json::to_string(&json!({\n                \"type\": \"log_message\",\n                \"payload\": \"'execution' input is missing for WEBSOCKET_SEND node\"\n            })) {\n                if context.get_broadcast().send(payload_str).is_err() {\n                    error!(\"Failed to send health check response.\");\n                }\n            }\n\n            return Err(anyhow!(\"'exec' input is missing for WEBSOCKET_SEND node\"));\n        };\n\n        let mut outputs = HashMap::new();\n\n        let value = match self.data.variable_type.as_deref() {\n            Some(\"number\") => {\n                if let Ok(num) = self.data.variable.parse::<i64>() {\n                    json!(num)\n                } else if let Ok(num) = self.data.variable.parse::<f64>() {\n                    json!(num)\n                } else {\n                    let error_message =\n                        format!(\"Failed to parse '{}' as a number.\", self.data.variable);\n                    let ws_message = json!({\n                        \"type\": \"log_message\",\n                        \"payload\": error_message.clone()\n                    });\n                    if let Ok(payload_str) = serde_json::to_string(&ws_message) {\n                        if context.get_broadcast().send(payload_str).is_err() {\n                            error!(\"Failed to send websocket log message.\");\n                        }\n                    }\n\n                    return Err(anyhow!(error_message));\n                }\n            }\n            Some(\"boolean\") => match self.data.variable.parse::<bool>() {\n                Ok(b) => json!(b),\n                Err(_) => {\n                    let error_message =\n                        format!(\"Failed to parse '{}' as a boolean.\", self.data.variable);\n                    let ws_message = json!({\n                        \"type\": \"log_message\",\n                        \"payload\": error_message.clone(),\n                    });\n                    if let Ok(payload_str) = serde_json::to_string(&ws_message) {\n                        if context.get_broadcast().send(payload_str).is_err() {\n                            error!(\"Failed to send websocket log message.\");\n                        }\n                    }\n                    return Err(anyhow!(error_message));\n                }\n            },\n            Some(\"string\") => {\n                json!(self.data.variable)\n            }\n            Some(\"json\") | _ => match serde_json::from_str::<Value>(&self.data.variable) {\n                Ok(json_value) => json_value,\n                Err(_) => {\n                    let error_message =\n                        format!(\"Failed to parse '{}' as JSON.\", self.data.variable);\n                    let ws_message = json!({\n                        \"type\": \"log_message\",\n                        \"payload\": error_message.clone(),\n                    });\n                    if let Ok(payload_str) = serde_json::to_string(&ws_message) {\n                        if context.get_broadcast().send(payload_str).is_err() {\n                            error!(\"Failed to send websocket log message.\");\n                        }\n                    }\n                    return Err(anyhow!(error_message));\n                }\n            },\n        };\n\n        outputs.insert(\"out\".to_string(), value);\n\n        Ok(ExecutionResult {\n            outputs,\n            ..Default::default()\n        })\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/show_toast.rs",
    "content": "use crate::flow::engine::ExecutionContext;\nuse anyhow::{anyhow, Result};\nuse async_trait::async_trait;\nuse serde::Deserialize;\nuse serde_json::{json, Value};\nuse std::collections::HashMap;\n\nuse super::{ExecutableNode, ExecutionResult};\n\n#[derive(Deserialize, Debug, Clone)]\n#[serde(rename_all = \"camelCase\")]\nstruct ShowToastData {\n    #[serde(default = \"default_level\")]\n    level: String,\n    title: Option<String>,\n    message: String,\n    #[serde(default = \"default_duration_ms\")]\n    duration_ms: u64,\n}\n\nfn default_level() -> String {\n    \"info\".to_string()\n}\n\nfn default_duration_ms() -> u64 {\n    4000\n}\n\npub struct ShowToastNode {\n    data: ShowToastData,\n    node_id: String,\n}\n\nimpl ShowToastNode {\n    pub fn new(node_data: &Value, node_id: String) -> Result<Self> {\n        let data: ShowToastData = serde_json::from_value(node_data.clone())?;\n        Ok(Self { data, node_id })\n    }\n}\n\n#[async_trait]\nimpl ExecutableNode for ShowToastNode {\n    async fn execute(\n        &self,\n        context: &mut ExecutionContext,\n        inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        let Some(exec_val) = inputs.get(\"exec\") else {\n            return Err(anyhow!(\"'exec' input is missing for SHOW_TOAST node\"));\n        };\n\n        let level_lc = self.data.level.to_lowercase();\n        let level = match level_lc.as_str() {\n            \"info\" | \"success\" | \"warning\" | \"error\" => level_lc,\n            _ => \"info\".to_string(),\n        };\n\n        let event_data = json!({\n            \"level\": level,\n            \"title\": self.data.title,\n            \"message\": self.data.message,\n            \"duration_ms\": self.data.duration_ms,\n        });\n        context.emit_flow_ui_event(&self.node_id, \"SHOW_TOAST\", \"toast\", event_data);\n\n        let mut outputs = HashMap::new();\n        outputs.insert(\"out\".to_string(), exec_val.clone());\n\n        Ok(ExecutionResult {\n            outputs,\n            ..Default::default()\n        })\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/start.rs",
    "content": "use super::{ExecutableNode, ExecutionContext, ExecutionResult};\nuse anyhow::Result;\nuse async_trait::async_trait;\nuse serde_json::Value;\nuse std::collections::HashMap;\nuse tokio::sync::broadcast;\n\npub struct StartNode;\n\n#[async_trait]\nimpl ExecutableNode for StartNode {\n    async fn execute(\n        &self,\n        _context: &mut ExecutionContext,\n        _inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        let mut outputs = HashMap::new();\n        outputs.insert(\"out\".to_string(), Value::Null);\n        Ok(ExecutionResult {\n            outputs,\n            ..Default::default()\n        })\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/type_converter.rs",
    "content": "use super::{ExecutableNode, ExecutionResult};\nuse crate::flow::engine::ExecutionContext;\nuse anyhow::{anyhow, Result};\nuse async_trait::async_trait;\nuse serde::Deserialize;\nuse serde_json::{Number, Value};\nuse std::collections::HashMap;\nuse tokio::sync::broadcast;\n\n#[derive(Deserialize, Debug, Clone)]\n#[serde(rename_all = \"camelCase\")]\nstruct TypeConverterNodeData {\n    target_type: String,\n}\n\npub struct TypeConverterNode {\n    data: TypeConverterNodeData,\n}\n\nimpl TypeConverterNode {\n    pub fn new(node_data: &Value) -> Result<Self> {\n        let data: TypeConverterNodeData = serde_json::from_value(node_data.clone())?;\n        Ok(Self { data })\n    }\n}\n\n#[async_trait]\nimpl ExecutableNode for TypeConverterNode {\n    async fn execute(\n        &self,\n        _context: &mut ExecutionContext,\n        inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        let input_value = inputs\n            .get(\"in\")\n            .ok_or_else(|| anyhow!(\"'in' input is missing for TypeConverterNode\"))?;\n\n        let converted_value = match self.data.target_type.as_str() {\n            \"string\" => {\n                let s = match input_value {\n                    Value::String(s) => s.clone(),\n                    Value::Number(n) => n.to_string(),\n                    Value::Bool(b) => b.to_string(),\n                    Value::Null => \"null\".to_string(),\n                    _ => serde_json::to_string(input_value)?,\n                };\n                Value::String(s.replace('\\\"', \"\"))\n            }\n            \"number\" => {\n                let num = match input_value {\n                    Value::Number(n) => n.as_f64().unwrap_or(0.0),\n                    Value::String(s) => s\n                        .parse::<f64>()\n                        .map_err(|_| anyhow!(\"Failed to parse string '{}' to number\", s))?,\n                    Value::Bool(b) => {\n                        if *b {\n                            1.0\n                        } else {\n                            0.0\n                        }\n                    }\n                    _ => return Err(anyhow!(\"Cannot convert type to a number\")),\n                };\n                Value::Number(\n                    Number::from_f64(num)\n                        .ok_or_else(|| anyhow!(\"Invalid f64 value for conversion\"))?,\n                )\n            }\n            \"boolean\" => {\n                let b = match input_value {\n                    Value::Bool(b) => *b,\n                    Value::String(s) => s.to_lowercase() == \"true\",\n                    Value::Number(n) => n.as_f64().unwrap_or(0.0) != 0.0,\n                    _ => false,\n                };\n                Value::Bool(b)\n            }\n            _ => {\n                return Err(anyhow!(\n                    \"Unsupported target type: {}\",\n                    self.data.target_type\n                ))\n            }\n        };\n\n        let mut outputs = HashMap::new();\n        outputs.insert(\"out\".to_string(), converted_value);\n        Ok(ExecutionResult {\n            outputs,\n            ..Default::default()\n        })\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/websocket_on.rs",
    "content": "use anyhow::{anyhow, Result};\nuse async_trait::async_trait;\nuse futures_util::stream::StreamExt;\nuse serde::Deserialize;\nuse serde_json::Value;\nuse std::collections::HashMap;\nuse tokio::{\n    net::TcpStream,\n    sync::mpsc,\n    task::JoinHandle,\n    time::{sleep, Duration},\n};\nuse tokio_tungstenite::{\n    connect_async, tungstenite::protocol::Message, MaybeTlsStream, WebSocketStream,\n};\nuse tracing::{error, info, warn};\nuse url::Url;\n\nuse super::ExecutableNode;\nuse crate::{\n    db::models::SystemConfiguration,\n    flow::{\n        engine::{ExecutionContext, TriggerCommand},\n        types::ExecutionResult,\n    },\n    utils::system_configs::replace_config_placeholders,\n};\n\n#[derive(Deserialize, Debug, Clone)]\n#[serde(rename_all = \"camelCase\")]\npub struct WebSocketOnNodeData {\n    pub url: String,\n}\n\npub struct WebSocketOnNode {\n    data: WebSocketOnNodeData,\n    system_configs: Vec<SystemConfiguration>,\n}\n\nimpl WebSocketOnNode {\n    pub fn new(node_data: &Value, system_configs: Vec<SystemConfiguration>) -> Result<Self> {\n        let data: WebSocketOnNodeData = serde_json::from_value(node_data.clone())?;\n        Ok(Self {\n            data,\n            system_configs,\n        })\n    }\n}\n\nasync fn handle_connection(\n    mut ws_stream: WebSocketStream<MaybeTlsStream<TcpStream>>,\n    trigger_tx: &mpsc::Sender<TriggerCommand>,\n    node_id: &str,\n) {\n    while let Some(msg) = ws_stream.next().await {\n        println!(\"rsv\");\n\n        match msg {\n            Ok(Message::Text(text)) => {\n                let payload_value = match serde_json::from_str(&text) {\n                    Ok(p) => p,\n                    Err(_) => Value::String(text.to_string()),\n                };\n                let mut inputs = HashMap::new();\n                inputs.insert(\"payload\".to_string(), payload_value);\n                let cmd = TriggerCommand {\n                    node_id: node_id.to_string(),\n                    inputs,\n                };\n                if trigger_tx.send(cmd).await.is_err() {\n                    error!(\"Trigger channel closed. Stopping WebSocket listener.\");\n                    break;\n                }\n            }\n            Ok(Message::Binary(_)) => {\n                warn!(\"Received binary message, which is not currently supported. Ignoring.\");\n            }\n            Ok(Message::Ping(_)) => {}\n            Ok(Message::Pong(_)) => {}\n            Ok(Message::Close(_)) => {\n                info!(\"WebSocket connection closed by peer.\");\n                break;\n            }\n            Ok(Message::Frame(_)) => {\n                warn!(\"Received raw frame, which is not currently supported. Ignoring.\");\n            }\n            Err(e) => {\n                error!(\"Error reading from WebSocket: {}\", e);\n                break;\n            }\n        }\n    }\n}\n\n#[async_trait]\nimpl ExecutableNode for WebSocketOnNode {\n    async fn execute(\n        &self,\n        _context: &mut ExecutionContext,\n        inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        let mut outputs = HashMap::new();\n        if let Some(payload) = inputs.get(\"payload\") {\n            outputs.insert(\"payload\".to_string(), payload.clone());\n        }\n        Ok(ExecutionResult {\n            outputs,\n            ..Default::default()\n        })\n    }\n\n    fn is_trigger(&self) -> bool {\n        true\n    }\n\n    fn start_trigger(\n        &self,\n        node_id: String,\n        trigger_tx: mpsc::Sender<TriggerCommand>,\n    ) -> Result<JoinHandle<()>> {\n        let url_str: String = self.data.url.clone();\n        let result_url: String = replace_config_placeholders(&url_str, &self.system_configs);\n\n        let handle = tokio::spawn(async move {\n            loop {\n                if let Err(e) = Url::parse(&result_url) {\n                    error!(\n                        \"Invalid WebSocket URL '{}': {}. Aborting trigger.\",\n                        result_url, e\n                    );\n                    break;\n                }\n\n                info!(\"Connecting to WebSocket at {}\", result_url);\n                match connect_async(&result_url).await {\n                    Ok((ws_stream, _)) => {\n                        info!(\"Successfully connected to WebSocket: {}\", result_url);\n                        handle_connection(ws_stream, &trigger_tx, &node_id).await;\n                        warn!(\"WebSocket connection lost. Reconnecting in 5 seconds...\");\n                    }\n                    Err(e) => {\n                        error!(\n                            \"Failed to connect to WebSocket '{}': {}. Retrying in 5 seconds...\",\n                            result_url, e\n                        );\n                    }\n                }\n                sleep(Duration::from_secs(5)).await;\n            }\n        });\n\n        Ok(handle)\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/websocket_send.rs",
    "content": "use anyhow::{anyhow, Result};\nuse async_trait::async_trait;\nuse futures_util::{SinkExt, StreamExt};\nuse serde::Deserialize;\nuse serde_json::{json, to_string, Value};\nuse std::collections::HashMap;\nuse tokio_tungstenite::{connect_async, tungstenite::protocol::Message};\nuse tracing::{error, info};\n\nuse super::ExecutableNode;\nuse crate::{\n    db::models::SystemConfiguration,\n    flow::{engine::ExecutionContext, types::ExecutionResult},\n    utils::system_configs::replace_config_placeholders,\n};\n\n#[derive(Deserialize, Debug, Clone)]\n#[serde(rename_all = \"camelCase\")]\nstruct WebSocketSendNodeData {\n    url: String,\n}\n\npub struct WebSocketSendNode {\n    data: WebSocketSendNodeData,\n    system_configs: Vec<SystemConfiguration>,\n}\n\nimpl WebSocketSendNode {\n    pub fn new(node_data: &Value, system_configs: Vec<SystemConfiguration>) -> Result<Self> {\n        let data: WebSocketSendNodeData = serde_json::from_value(node_data.clone())?;\n        Ok(Self {\n            data,\n            system_configs,\n        })\n    }\n}\n\n#[async_trait]\nimpl ExecutableNode for WebSocketSendNode {\n    async fn execute(\n        &self,\n        context: &mut ExecutionContext,\n        inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        let url_str = &self.data.url;\n        let result_url = replace_config_placeholders(url_str, &self.system_configs);\n\n        let Some(payload) = inputs.get(\"payload\") else {\n            if let Ok(payload_str) = serde_json::to_string(&json!({\n                \"type\": \"log_message\",\n                \"payload\": \"'payload' input is missing for WEBSOCKET_SEND node\"\n            })) {\n                if context.get_broadcast().send(payload_str).is_err() {\n                    error!(\"Failed to send health check response.\");\n                }\n            }\n\n            return Err(anyhow!(\n                \"'payload' input is missing for WEBSOCKET_SEND node\"\n            ));\n        };\n\n        let payload_str = match payload {\n            Value::String(s) => s.clone(),\n            _ => to_string(payload)?,\n        };\n\n        info!(\n            \"Connecting to WebSocket at {} to send message\",\n            result_url.clone()\n        );\n        let (ws_stream, _) = connect_async(result_url)\n            .await\n            .map_err(|e| anyhow!(\"Failed to connect to WebSocket: {}\", e))?;\n\n        let (mut write, _) = ws_stream.split();\n\n        info!(\"Sending message to WebSocket\");\n        write\n            .send(Message::Text(payload_str.into()))\n            .await\n            .map_err(|e| anyhow!(\"Failed to send message via WebSocket: {}\", e))?;\n\n        info!(\"Message sent, closing WebSocket connection.\");\n        write\n            .close()\n            .await\n            .map_err(|e| anyhow!(\"Failed to close WebSocket connection: {}\", e))?;\n\n        Ok(ExecutionResult::default())\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/nodes/yolo_detect.rs",
    "content": "use anyhow::{anyhow, Result};\nuse async_trait::async_trait;\nuse image::{imageops, RgbImage};\nuse serde::Deserialize;\nuse serde_json::{json, Value};\nuse std::collections::HashMap;\nuse std::fs::File;\nuse std::io::Read;\nuse tract_ndarray::s;\nuse tract_onnx::{prelude::*, tract_core::downcast_rs::Downcast};\nuse uuid::Uuid;\n\nuse super::{ExecutableNode, ExecutionResult};\nuse crate::flow::{engine::ExecutionContext, BinaryStore};\n\ntype Model = TypedRunnableModel<Graph<TypedFact, Box<dyn TypedOp>>>;\n\n#[derive(Deserialize, Debug, Clone)]\nstruct YoloDetectData {\n    model_path: String,\n    labels_path: String,\n    confidence_threshold: f32,\n    nms_threshold: f32,\n    input_size: u32,\n}\n\n#[derive(Debug, Clone)]\nstruct BBox {\n    x1: f32,\n    y1: f32,\n    x2: f32,\n    y2: f32,\n    confidence: f32,\n    class_id: usize,\n}\n\npub struct YoloDetectNode {\n    data: YoloDetectData,\n    model: Model,\n    labels: Vec<String>,\n    binary_store: BinaryStore,\n}\n\nfn nms(boxes: &mut Vec<BBox>, threshold: f32) {\n    if boxes.is_empty() {\n        return;\n    }\n    boxes.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap());\n    let mut i = 0;\n    while i < boxes.len() {\n        let mut j = i + 1;\n        while j < boxes.len() {\n            let area_i = (boxes[i].x2 - boxes[i].x1) * (boxes[i].y2 - boxes[i].y1);\n            let area_j = (boxes[j].x2 - boxes[j].x1) * (boxes[j].y2 - boxes[j].y1);\n            let inter_x1 = boxes[i].x1.max(boxes[j].x1);\n            let inter_y1 = boxes[i].y1.max(boxes[j].y1);\n            let inter_x2 = boxes[i].x2.min(boxes[j].x2);\n            let inter_y2 = boxes[i].y2.min(boxes[j].y2);\n            let inter_w = (inter_x2 - inter_x1).max(0.0);\n            let inter_h = (inter_y2 - inter_y1).max(0.0);\n            let intersection = inter_w * inter_h;\n            let union = area_i + area_j - intersection;\n            let iou = intersection / union;\n            if iou > threshold {\n                boxes.remove(j);\n            } else {\n                j += 1;\n            }\n        }\n        i += 1;\n    }\n}\n\nimpl YoloDetectNode {\n    pub fn new(node_data: &Value, binary_store: BinaryStore) -> Result<Self> {\n        let data: YoloDetectData = serde_json::from_value(node_data.clone())?;\n        let model_input_size = data.input_size as i64;\n        let model = tract_onnx::onnx()\n            .model_for_path(&data.model_path)?\n            .with_input_fact(\n                0,\n                InferenceFact::dt_shape(\n                    f32::datum_type(),\n                    tvec!(1, 3, model_input_size, model_input_size),\n                ),\n            )?\n            .into_optimized()?\n            .into_runnable()?;\n        let mut labels_file = File::open(&data.labels_path)?;\n        let mut labels_content = String::new();\n        labels_file.read_to_string(&mut labels_content)?;\n        let labels: Vec<String> = labels_content.lines().map(String::from).collect();\n        Ok(Self {\n            data,\n            model,\n            labels,\n            binary_store,\n        })\n    }\n}\n\n#[async_trait]\nimpl ExecutableNode for YoloDetectNode {\n    async fn execute(\n        &self,\n        _: &mut ExecutionContext,\n        inputs: HashMap<String, Value>,\n    ) -> Result<ExecutionResult> {\n        let frame_value = inputs\n            .get(\"frame\")\n            .ok_or_else(|| anyhow!(\"'frame' input missing\"))?;\n        if frame_value.get(\"type\").and_then(|v| v.as_str()) != Some(\"binary_pointer\") {\n            return Err(anyhow!(\"Expected 'binary_pointer' but got something else\"));\n        }\n\n        let frame_id_str = frame_value\n            .get(\"id\")\n            .and_then(|v| v.as_str())\n            .ok_or_else(|| anyhow!(\"'id' missing in binary_pointer\"))?;\n        let frame_id = Uuid::parse_str(frame_id_str)?;\n        let width = frame_value\n            .get(\"width\")\n            .and_then(|v| v.as_u64())\n            .ok_or_else(|| anyhow!(\"'width' missing\"))? as u32;\n        let height = frame_value\n            .get(\"height\")\n            .and_then(|v| v.as_u64())\n            .ok_or_else(|| anyhow!(\"'height' missing\"))? as u32;\n        let rgb_data = self\n            .binary_store\n            .remove(&frame_id)\n            .ok_or_else(|| anyhow!(\"Frame with id '{}' not found or already consumed\", frame_id))?;\n\n        let img = RgbImage::from_raw(width, height, rgb_data)\n            .ok_or_else(|| anyhow!(\"Failed to create RgbImage from raw data\"))?;\n\n        let input_size = self.data.input_size;\n        let resized_img =\n            imageops::resize(&img, input_size, input_size, imageops::FilterType::Triangle);\n        let tensor: Tensor = tract_ndarray::Array4::from_shape_fn(\n            (1, 3, input_size as usize, input_size as usize),\n            |(_, c, y, x)| resized_img.get_pixel(x as u32, y as u32)[c] as f32 / 255.0,\n        )\n        .into();\n\n        let result = self.model.run(tvec!(tensor.into()))?;\n        let output = result[0].to_array_view::<f32>()?;\n        let mut bboxes = Vec::new();\n        let outputs = output.slice(s![0, .., ..]).permuted_axes([1, 0]);\n\n        for row in outputs.rows() {\n            let (class_id, confidence) = row.slice(s![4..]).iter().enumerate().fold(\n                (0, 0.0),\n                |(idx_max, val_max), (idx, &val)| {\n                    if val > val_max {\n                        (idx, val)\n                    } else {\n                        (idx_max, val_max)\n                    }\n                },\n            );\n            if confidence < self.data.confidence_threshold {\n                continue;\n            }\n            let xc = row[0];\n            let yc = row[1];\n            let w = row[2];\n            let h = row[3];\n            let x1 = (xc - w / 2.0) / input_size as f32 * width as f32;\n            let y1 = (yc - h / 2.0) / input_size as f32 * height as f32;\n            let x2 = (xc + w / 2.0) / input_size as f32 * width as f32;\n            let y2 = (yc + h / 2.0) / input_size as f32 * height as f32;\n            bboxes.push(BBox {\n                x1,\n                y1,\n                x2,\n                y2,\n                confidence,\n                class_id,\n            });\n        }\n        nms(&mut bboxes, self.data.nms_threshold);\n        let detections: Vec<Value> = bboxes.into_iter().map(|b| { json!({ \"class\": self.labels.get(b.class_id).map_or(\"unknown\", |s| s.as_str()), \"confidence\": b.confidence, \"box\": { \"x1\": b.x1, \"y1\": b.y1, \"x2\": b.x2, \"y2\": b.y2 } }) }).collect();\n\n        let mut outputs_map = HashMap::new();\n        outputs_map.insert(\"detections\".to_string(), json!(detections));\n        Ok(ExecutionResult {\n            outputs: outputs_map,\n            ..Default::default()\n        })\n    }\n}\n"
  },
  {
    "path": "apps/server/src/flow/types.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse std::collections::HashMap;\n\n/// Identifies the browser tab / client session that started a flow run (WebSocket UX targeting).\n#[derive(Debug, Clone)]\npub struct FlowRunContext {\n    pub session_id: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Trigger {\n    pub node_id: String,\n    pub inputs: HashMap<String, Value>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct ExecutionResult {\n    pub outputs: HashMap<String, Value>,\n    #[serde(default)]\n    pub triggers: Vec<Trigger>,\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct Graph {\n    pub nodes: Vec<Node>,\n    pub edges: Vec<Edge>,\n}\n\n#[derive(Debug, Clone, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct Node {\n    pub id: String,\n    pub node_type: String,\n    #[serde(default)]\n    pub data: Value,\n    #[serde(default)]\n    pub connectors: Vec<Connector>,\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct Edge {\n    pub source: String,\n    pub target: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]\npub enum ConnectorKind {\n    #[serde(rename = \"in\")]\n    In,\n    #[serde(rename = \"out\")]\n    Out,\n}\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct Connector {\n    pub id: String,\n    pub name: String,\n    #[serde(rename = \"type\")]\n    pub kind: ConnectorKind,\n}\n"
  },
  {
    "path": "apps/server/src/handler/auth.rs",
    "content": "use anyhow::anyhow;\nuse async_trait::async_trait;\nuse axum::{\n    extract::{FromRef, FromRequestParts, State},\n    http::{request::Parts, StatusCode},\n    response::{IntoResponse, Response},\n    Json, RequestPartsExt,\n};\nuse axum_extra::{\n    headers::{authorization::Bearer, Authorization},\n    TypedHeader,\n};\nuse jsonwebtoken::{decode, DecodingKey, Validation};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\nuse tracing::info;\n\nuse crate::{\n    db::{\n        self,\n        models::{Device, User},\n        repository::get_user_by_name,\n    },\n    error::AppError,\n    utils::hash::verify_password,\n    AppState,\n};\n\n#[derive(Debug, Serialize)]\npub struct ErrorResponse {\n    pub status: &'static str,\n    pub message: String,\n}\n\n#[derive(Deserialize)]\npub struct AuthPayload {\n    id: String,\n    password: String,\n}\n\n#[derive(Debug)]\npub enum AuthError {\n    WrongCredentials,\n    MissingCredentials,\n    TokenCreation,\n    InvalidToken,\n    UserNotFound,\n}\n\nimpl IntoResponse for AuthError {\n    fn into_response(self) -> Response {\n        let (status, error_message) = match self {\n            AuthError::WrongCredentials => (StatusCode::UNAUTHORIZED, \"Wrong credentials\"),\n            AuthError::MissingCredentials => (StatusCode::BAD_REQUEST, \"Missing credentials\"),\n            AuthError::TokenCreation => (StatusCode::INTERNAL_SERVER_ERROR, \"Token creation error\"),\n            AuthError::InvalidToken => (StatusCode::UNAUTHORIZED, \"Invalid token\"),\n            AuthError::UserNotFound => (StatusCode::UNAUTHORIZED, \"User not found\"),\n        };\n        let body = Json(json!({\n            \"status\": \"error\",\n            \"message\": error_message,\n        }));\n        (status, body).into_response()\n    }\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct Claims {\n    pub sub: String,\n    pub exp: usize,\n}\n\npub struct JwtAuth(pub Claims);\n\n#[async_trait]\nimpl<S> FromRequestParts<S> for JwtAuth\nwhere\n    S: Send + Sync,\n    Arc<AppState>: FromRef<S>,\n{\n    type Rejection = AuthError;\n\n    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {\n        let state = Arc::<AppState>::from_ref(state);\n\n        let token = if let Some(token) = get_token_from_header(parts).await {\n            token\n        } else {\n            get_token_from_query(parts)\n                .await\n                .ok_or(AuthError::MissingCredentials)?\n        };\n\n        let decoding_key = DecodingKey::from_secret(state.jwt_secret.as_ref());\n        let validation = Validation::default();\n\n        let decoded = decode::<Claims>(&token, &decoding_key, &validation)\n            .map_err(|_| AuthError::InvalidToken)?;\n\n        Ok(JwtAuth(decoded.claims))\n    }\n}\n\npub struct AuthUser(pub User);\n\n#[async_trait]\nimpl<S> FromRequestParts<S> for AuthUser\nwhere\n    S: Send + Sync,\n    Arc<AppState>: FromRef<S>,\n{\n    type Rejection = AuthError;\n\n    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {\n        let claims = JwtAuth::from_request_parts(parts, state).await?.0;\n\n        let state = Arc::<AppState>::from_ref(state);\n\n        let user =\n            get_user_by_name(&state.pool, &claims.sub).map_err(|_| AuthError::UserNotFound)?;\n\n        Ok(AuthUser(user))\n    }\n}\n\nasync fn get_token_from_header(parts: &mut Parts) -> Option<String> {\n    parts\n        .extract::<TypedHeader<Authorization<Bearer>>>()\n        .await\n        .map(|TypedHeader(Authorization(bearer))| bearer.token().to_string())\n        .ok()\n}\n\nasync fn get_token_from_query(parts: &mut Parts) -> Option<String> {\n    #[derive(Deserialize)]\n    struct QueryParams {\n        token: String,\n    }\n\n    parts\n        .extract::<axum::extract::Query<QueryParams>>()\n        .await\n        .map(|query| query.0.token)\n        .ok()\n}\n\npub async fn auth_with_password(\n    State(state): State<Arc<AppState>>,\n    Json(payload): Json<AuthPayload>,\n) -> Result<Json<serde_json::Value>, AuthError> {\n    match get_user_by_name(&state.pool, &payload.id) {\n        Ok(user) => {\n            let login_attempt_password = &payload.password.clone();\n            let stored_hash = &user.password_hash;\n            info!(\"auth_with_password\");\n\n            match verify_password(login_attempt_password, stored_hash) {\n                Ok(is_valid) => {\n                    if is_valid {\n                        let claims = Claims {\n                            sub: payload.id,\n                            exp: (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp()\n                                as usize,\n                        };\n\n                        let token = jsonwebtoken::encode(\n                            &jsonwebtoken::Header::default(),\n                            &claims,\n                            &jsonwebtoken::EncodingKey::from_secret(state.jwt_secret.as_ref()),\n                        )\n                        .map_err(|_| AuthError::TokenCreation)?;\n\n                        info!(\"Auth Success {}\", claims.sub);\n\n                        Ok(Json(json!({ \"token\": token })))\n                    } else {\n                        Err(AuthError::WrongCredentials)\n                    }\n                }\n                Err(_) => Err(AuthError::WrongCredentials),\n            }\n        }\n        Err(_) => Err(AuthError::UserNotFound),\n    }\n}\n\npub struct DeviceTokenAuth {\n    pub device: Device,\n}\n\n#[async_trait]\nimpl FromRequestParts<Arc<AppState>> for DeviceTokenAuth {\n    type Rejection = AppError;\n\n    async fn from_request_parts(\n        parts: &mut Parts,\n        state: &Arc<AppState>,\n    ) -> Result<Self, Self::Rejection> {\n        let TypedHeader(Authorization(bearer)) =\n            TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state)\n                .await\n                .map_err(|_| anyhow!(\"Missing or invalid authorization header\"))?;\n\n        let device_id_header = parts\n            .headers\n            .get(\"X-Device-Id\")\n            .and_then(|value| value.to_str().ok())\n            .ok_or_else(|| anyhow!(\"Missing X-Device-Id header\"))?;\n\n        let pool = state.pool.clone();\n        let device_id_clone = device_id_header.to_string();\n        let token_clone = bearer.token().to_string();\n\n        let device = tokio::task::spawn_blocking(move || {\n            let device = db::repository::get_device_by_device_id(&pool, &device_id_clone)?;\n\n            let token_info = db::repository::get_token_info_for_device(&pool, device.id)?\n                .ok_or_else(|| anyhow!(\"No active token for this device\"))?;\n\n            let token_valid = verify_password(&token_clone, &token_info.token_hash)?;\n\n            if token_valid {\n                Ok(device)\n            } else {\n                Err(anyhow!(\"Invalid token\"))\n            }\n        })\n        .await\n        .map_err(|e| anyhow!(\"Task execution error: {}\", e))??;\n\n        Ok(DeviceTokenAuth { device })\n    }\n}\n"
  },
  {
    "path": "apps/server/src/handler/configurations.rs",
    "content": "use crate::{\n    db::{\n        self,\n        models::{NewSystemConfiguration, SystemConfiguration},\n    },\n    error::AppError,\n    handler::auth::AuthUser,\n    state::AppState,\n};\nuse axum::{\n    extract::{Path, State},\n    Json,\n};\nuse serde::Deserialize;\nuse serde_json::{json, Value};\nuse std::sync::Arc;\n\n#[derive(Deserialize)]\npub struct SystemConfigPayload {\n    pub key: String,\n    pub value: String,\n    pub enabled: Option<i32>,\n    pub description: Option<String>,\n}\n\npub async fn create_config(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Json(payload): Json<SystemConfigPayload>,\n) -> Result<Json<SystemConfiguration>, AppError> {\n    let new_config = NewSystemConfiguration {\n        key: &payload.key,\n        value: &payload.value,\n        enabled: payload.enabled,\n        description: payload.description.as_deref(),\n    };\n    let config = db::repository::create_system_config(&state.pool, new_config)?;\n    Ok(Json(config))\n}\n\npub async fn get_configs(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n) -> Result<Json<Vec<SystemConfiguration>>, AppError> {\n    let configs = db::repository::get_all_system_configs(&state.pool)?;\n    Ok(Json(configs))\n}\n\npub async fn update_config(\n    State(state): State<Arc<AppState>>,\n    Path(id): Path<i32>,\n    AuthUser(_user): AuthUser,\n    Json(payload): Json<SystemConfigPayload>,\n) -> Result<Json<SystemConfiguration>, AppError> {\n    let updated_config = NewSystemConfiguration {\n        key: &payload.key,\n        value: &payload.value,\n        enabled: payload.enabled,\n        description: payload.description.as_deref(),\n    };\n    let config = db::repository::update_system_config(&state.pool, id, &updated_config)?;\n    Ok(Json(config))\n}\n\npub async fn delete_config(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(id): Path<i32>,\n) -> Result<Json<Value>, AppError> {\n    db::repository::delete_system_config(&state.pool, id)?;\n    Ok(Json(\n        json!({ \"status\": \"success\", \"message\": \"System configuration deleted\" }),\n    ))\n}\n"
  },
  {
    "path": "apps/server/src/handler/custom_nodes.rs",
    "content": "use crate::{\n    db::{\n        self,\n        models::{CustomNodeResult, NewCustomNode, UpdateCustomNode},\n    },\n    error::AppError,\n    state::AppState,\n};\nuse axum::{\n    extract::{Path, State},\n    http::StatusCode,\n    Json,\n};\nuse serde::Deserialize;\nuse serde_json::Value;\nuse std::sync::Arc;\n\n#[derive(Deserialize)]\npub struct CreateCustomNodePayload {\n    pub node_type: String,\n    pub data: Option<Value>,\n}\n\npub async fn create_custom_node_handler(\n    State(state): State<Arc<AppState>>,\n    Json(payload): Json<CreateCustomNodePayload>,\n) -> Result<(StatusCode, Json<db::models::CustomNode>), AppError> {\n    let data_str = payload\n        .data\n        .map(|v| {\n            if v.is_null() || v.as_object().map_or(false, |m| m.is_empty()) {\n                String::new()\n            } else {\n                serde_json::to_string(&v).unwrap_or_default()\n            }\n        })\n        .unwrap_or_default();\n\n    let new_node = NewCustomNode {\n        node_type: &payload.node_type,\n        data: &data_str,\n    };\n\n    let node = db::repository::create_custom_node(&state.pool, &new_node)?;\n    Ok((StatusCode::CREATED, Json(node)))\n}\n\npub async fn get_all_custom_nodes_handler(\n    State(state): State<Arc<AppState>>,\n) -> Result<Json<Vec<CustomNodeResult>>, AppError> {\n    let nodes = db::repository::get_all_custom_nodes(&state.pool)?;\n    Ok(Json(nodes))\n}\n\npub async fn get_custom_node_handler(\n    State(state): State<Arc<AppState>>,\n    Path(node_type): Path<String>,\n) -> Result<Json<db::models::CustomNode>, AppError> {\n    let node = db::repository::get_custom_node(&state.pool, &node_type)?;\n    Ok(Json(node))\n}\n\n#[derive(Deserialize)]\npub struct UpdateCustomNodePayload {\n    pub data: Option<Value>,\n}\n\npub async fn update_custom_node_handler(\n    State(state): State<Arc<AppState>>,\n    Path(node_type): Path<String>,\n    Json(payload): Json<UpdateCustomNodePayload>,\n) -> Result<Json<CustomNodeResult>, AppError> {\n    let data_str = payload\n        .data\n        .map(|v| {\n            if v.is_null() || v.as_object().map_or(false, |m| m.is_empty()) {\n                String::new()\n            } else {\n                v.to_string()\n            }\n        })\n        .unwrap_or_default();\n\n    let update_data = UpdateCustomNode {\n        data: Some(data_str),\n    };\n\n    let updated_node = db::repository::update_custom_node(&state.pool, &node_type, &update_data)?;\n    Ok(Json(updated_node))\n}\n\npub async fn delete_custom_node_handler(\n    State(state): State<Arc<AppState>>,\n    Path(node_type): Path<String>,\n) -> Result<StatusCode, AppError> {\n    db::repository::delete_custom_node(&state.pool, &node_type)?;\n    Ok(StatusCode::NO_CONTENT)\n}\n"
  },
  {
    "path": "apps/server/src/handler/dashboards.rs",
    "content": "use std::sync::Arc;\n\nuse axum::{\n    extract::{Path, State},\n    http::StatusCode,\n    response::IntoResponse,\n    Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\n\nuse crate::{db, error::AppError, state::AppState};\n\n#[derive(Serialize)]\npub struct DynamicDashboardResponse {\n    pub id: String,\n    pub name: String,\n    pub layout: Value,\n    pub created_at: String,\n    pub updated_at: String,\n}\n\nimpl From<db::models::DynamicDashboard> for DynamicDashboardResponse {\n    fn from(model: db::models::DynamicDashboard) -> Self {\n        let layout_value: Value =\n            serde_json::from_str(&model.layout).unwrap_or_else(|_| Value::Null);\n\n        DynamicDashboardResponse {\n            id: model.id,\n            name: model.name,\n            layout: layout_value,\n            created_at: model.created_at.to_string(),\n            updated_at: model.updated_at.to_string(),\n        }\n    }\n}\n\n#[derive(Deserialize)]\npub struct CreateDashboardPayload {\n    pub name: String,\n    pub layout: Value,\n}\n\n#[derive(Deserialize)]\npub struct UpdateDashboardPayload {\n    pub name: Option<String>,\n    pub layout: Option<Value>,\n}\n\npub async fn list_dashboards(\n    State(state): State<Arc<AppState>>,\n) -> Result<Json<Vec<DynamicDashboardResponse>>, AppError> {\n    let dashboards = db::repository::dashboards::list_dynamic_dashboards(&state.pool)?\n        .into_iter()\n        .map(DynamicDashboardResponse::from)\n        .collect();\n\n    Ok(Json(dashboards))\n}\n\npub async fn create_dashboard(\n    State(state): State<Arc<AppState>>,\n    Json(payload): Json<CreateDashboardPayload>,\n) -> Result<(StatusCode, Json<DynamicDashboardResponse>), AppError> {\n    let dashboard = db::repository::dashboards::create_dynamic_dashboard(\n        &state.pool,\n        &payload.name,\n        &payload.layout,\n    )?;\n\n    Ok((StatusCode::CREATED, Json(dashboard.into())))\n}\n\npub async fn get_dashboard(\n    State(state): State<Arc<AppState>>,\n    Path(id): Path<String>,\n) -> Result<impl IntoResponse, AppError> {\n    let dashboard = db::repository::dashboards::get_dynamic_dashboard(&state.pool, &id)?;\n\n    match dashboard {\n        Some(d) => Ok(Json(DynamicDashboardResponse::from(d)).into_response()),\n        None => Ok((StatusCode::NOT_FOUND, \"Dashboard not found\").into_response()),\n    }\n}\n\npub async fn update_dashboard(\n    State(state): State<Arc<AppState>>,\n    Path(id): Path<String>,\n    Json(payload): Json<UpdateDashboardPayload>,\n) -> Result<impl IntoResponse, AppError> {\n    let dashboard = db::repository::dashboards::update_dynamic_dashboard(\n        &state.pool,\n        &id,\n        payload.name.as_deref(),\n        payload.layout.as_ref(),\n    )?;\n\n    match dashboard {\n        Some(d) => Ok(Json(DynamicDashboardResponse::from(d)).into_response()),\n        None => Ok((StatusCode::NOT_FOUND, \"Dashboard not found\").into_response()),\n    }\n}\n\npub async fn delete_dashboard(\n    State(state): State<Arc<AppState>>,\n    Path(id): Path<String>,\n) -> Result<impl IntoResponse, AppError> {\n    let deleted = db::repository::dashboards::delete_dynamic_dashboard(&state.pool, &id)?;\n\n    if deleted == 0 {\n        return Ok((StatusCode::NOT_FOUND, \"Dashboard not found\").into_response());\n    }\n\n    Ok(StatusCode::NO_CONTENT.into_response())\n}\n"
  },
  {
    "path": "apps/server/src/handler/device_tokens.rs",
    "content": "use crate::{db, error::AppError, handler::auth::AuthUser, utils::hash, state::AppState};\nuse axum::{\n    extract::{Path, State},\n    Json,\n};\nuse base64::{engine::general_purpose, Engine as _};\nuse rand::RngCore;\nuse serde_json::{json, Value};\nuse std::sync::Arc;\n\nfn generate_api_key() -> String {\n    let mut key = [0u8; 32];\n    rand::rng().fill_bytes(&mut key);\n    general_purpose::URL_SAFE_NO_PAD.encode(key)\n}\n\npub async fn issue_token(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(id): Path<i32>,\n) -> Result<Json<Value>, AppError> {\n    let raw_token = generate_api_key();\n\n    let token_hash = hash::hash_password(&raw_token)?;\n\n    db::repository::create_or_replace_device_token(&state.pool, id, &token_hash)?;\n\n    Ok(Json(json!({\n        \"message\": \"New device token generated successfully. Store this token securely, it will not be shown again.\",\n        \"token\": raw_token\n    })))\n}\n\npub async fn get_token_info(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(id): Path<i32>,\n) -> Result<Json<Value>, AppError> {\n    let token_info = db::repository::get_token_info_for_device(&state.pool, id)?;\n\n    match token_info {\n        Some(info) => Ok(Json(serde_json::to_value(info)?)),\n        None => Ok(Json(\n            json!({ \"message\": \"No token found for this device.\" }),\n        )),\n    }\n}\n\npub async fn revoke_token(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(id): Path<i32>,\n) -> Result<Json<Value>, AppError> {\n    db::repository::delete_token_for_device(&state.pool, id)?;\n    Ok(Json(\n        json!({ \"status\": \"success\", \"message\": \"Device token has been revoked.\" }),\n    ))\n}\n"
  },
  {
    "path": "apps/server/src/handler/devices.rs",
    "content": "use crate::{\n    db::{\n        self,\n        models::{Device, Entity, EntityWithStateAndConfig, NewDevice},\n    },\n    error::AppError,\n    handler::auth::AuthUser,\n    state::AppState,\n};\nuse axum::{\n    extract::{Path, State},\n    Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::{json, Value};\nuse std::sync::Arc;\n\n#[derive(Deserialize)]\npub struct DevicePayload {\n    pub device_id: String,\n    pub name: Option<String>,\n    pub manufacturer: Option<String>,\n    pub model: Option<String>,\n}\n\n#[derive(Serialize)]\npub struct DeviceWithEntities {\n    #[serde(flatten)]\n    device: Device,\n    entities: Vec<EntityWithStateAndConfig>,\n}\n\npub async fn create_device(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Json(payload): Json<DevicePayload>,\n) -> Result<Json<crate::db::models::Device>, AppError> {\n    let new_device = NewDevice {\n        device_id: &payload.device_id,\n        name: payload.name.as_deref(),\n        manufacturer: payload.manufacturer.as_deref(),\n        model: payload.model.as_deref(),\n    };\n    let device = db::repository::create_device(&state.pool, new_device)?;\n    Ok(Json(device))\n}\n\npub async fn get_devices(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n) -> Result<Json<Vec<crate::db::models::Device>>, AppError> {\n    let devices = db::repository::get_all_devices(&state.pool)?;\n    Ok(Json(devices))\n}\n\npub async fn get_device(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(device_pk_id): Path<i32>,\n) -> Result<Json<DeviceWithEntities>, AppError> {\n    let device = db::repository::get_device_by_id(&state.pool, device_pk_id)?;\n    let entities = db::repository::get_entities_by_device_id(&state.pool, device.id)?;\n\n    let response = DeviceWithEntities { device, entities };\n\n    Ok(Json(response))\n}\n\npub async fn update_device(\n    State(state): State<Arc<AppState>>,\n    Path(id): Path<i32>,\n    AuthUser(_user): AuthUser,\n    Json(payload): Json<DevicePayload>,\n) -> Result<Json<crate::db::models::Device>, AppError> {\n    let updated_device = NewDevice {\n        device_id: &payload.device_id,\n        name: payload.name.as_deref(),\n        manufacturer: payload.manufacturer.as_deref(),\n        model: payload.model.as_deref(),\n    };\n    let device = db::repository::update_device(&state.pool, id, &updated_device)?;\n    Ok(Json(device))\n}\n\npub async fn delete_device(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(id): Path<i32>,\n) -> Result<Json<Value>, AppError> {\n    db::repository::delete_device(&state.pool, id)?;\n    Ok(Json(\n        json!({ \"status\": \"success\", \"message\": \"Device deleted\" }),\n    ))\n}\n"
  },
  {
    "path": "apps/server/src/handler/entities.rs",
    "content": "use crate::{\n    db::{\n        self,\n        models::{EntityWithConfig, EntityWithStateAndConfig, NewEntity, State as EntityState},\n    },\n    error::AppError,\n    handler::auth::AuthUser,\n    utils::entity_map::remap_topics,\n    state::AppState,\n};\nuse axum::{\n    extract::{Path, Query, State},\n    Json,\n};\nuse serde::Deserialize;\nuse serde_json::{json, Value};\nuse std::sync::Arc;\n\n#[derive(Deserialize)]\npub struct EntityPayload {\n    pub entity_id: String,\n    pub device_id: Option<i32>,\n    pub friendly_name: Option<String>,\n    pub platform: Option<String>,\n    pub entity_type: Option<String>,\n    pub configuration: Option<Value>,\n}\n\n#[derive(Deserialize)]\npub struct GetEntitiesParams {\n    pub entity_type: Option<String>,\n}\n\npub async fn create_entity(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Json(payload): Json<EntityPayload>,\n) -> Result<Json<EntityWithConfig>, AppError> {\n    let new_entity = NewEntity {\n        entity_id: &payload.entity_id,\n        device_id: payload.device_id,\n        friendly_name: payload.friendly_name.as_deref(),\n        platform: payload.platform.as_deref(),\n        entity_type: payload.entity_type.as_deref(),\n    };\n\n    let config_str = payload\n        .configuration\n        .map(|v| {\n            if v.is_null() || v.as_object().map_or(false, |m| m.is_empty()) {\n                String::new()\n            } else {\n                v.to_string()\n            }\n        })\n        .unwrap_or_default();\n\n    let (entity, config_opt) =\n        db::repository::create_entity_with_config(&state.pool, new_entity, &config_str)?;\n\n    let response = EntityWithConfig {\n        entity,\n        configuration: config_opt\n            .map(|c| serde_json::from_str(&c.configuration).unwrap_or_default()),\n    };\n\n    remap_topics(State(state)).await?;\n\n    Ok(Json(response))\n}\n\npub async fn get_entities(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Query(params): Query<GetEntitiesParams>,\n) -> Result<Json<Vec<EntityWithConfig>>, AppError> {\n    let entities =\n        db::repository::get_all_entities_with_configs_filter(&state.pool, params.entity_type)?;\n    Ok(Json(entities))\n}\n\npub async fn get_entities_with_states(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Query(params): Query<GetEntitiesParams>,\n) -> Result<Json<Vec<EntityWithStateAndConfig>>, AppError> {\n    let entities = db::repository::get_all_entities_with_states_and_configs_filter(\n        &state.pool,\n        params.entity_type,\n    )?;\n    Ok(Json(entities))\n}\n\npub async fn update_entity(\n    State(state): State<Arc<AppState>>,\n    Path(id): Path<i32>,\n    AuthUser(_user): AuthUser,\n    Json(payload): Json<EntityPayload>,\n) -> Result<Json<EntityWithConfig>, AppError> {\n    let updated_entity = NewEntity {\n        entity_id: &payload.entity_id,\n        device_id: payload.device_id,\n        friendly_name: payload.friendly_name.as_deref(),\n        platform: payload.platform.as_deref(),\n        entity_type: payload.entity_type.as_deref(),\n    };\n\n    let config_str = payload\n        .configuration\n        .map(|v| {\n            if v.is_null() || v.as_object().map_or(false, |m| m.is_empty()) {\n                String::new()\n            } else {\n                v.to_string()\n            }\n        })\n        .unwrap_or_default();\n\n    let (entity, config_opt) =\n        db::repository::update_entity_with_config(&state.pool, id, &updated_entity, &config_str)?;\n\n    let response = EntityWithConfig {\n        entity,\n        configuration: config_opt\n            .map(|c| serde_json::from_str(&c.configuration).unwrap_or_default()),\n    };\n\n    remap_topics(State(state)).await?;\n\n    Ok(Json(response))\n}\n\npub async fn delete_entity(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(id): Path<i32>,\n) -> Result<Json<Value>, AppError> {\n    db::repository::delete_entity(&state.pool, id)?;\n    remap_topics(State(state)).await?;\n\n    Ok(Json(\n        json!({ \"status\": \"success\", \"message\": \"Entity deleted\" }),\n    ))\n}\n\npub async fn get_entity_history(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(entity_id): Path<String>,\n) -> Result<Json<Vec<EntityState>>, AppError> {\n    let history = db::repository::get_entity_state_history(&state.pool, &entity_id)?;\n    Ok(Json(history))\n}\n"
  },
  {
    "path": "apps/server/src/handler/flows.rs",
    "content": "use crate::{\n    db::{\n        self,\n        models::{Flow, FlowVersion, NewFlow, NewFlowVersion},\n    },\n    error::AppError,\n    handler::auth::AuthUser,\n    state::AppState,\n};\nuse axum::{\n    extract::{Path, State},\n    Json,\n};\nuse serde::Deserialize;\nuse serde_json::{json, Value};\nuse std::sync::Arc;\n\n#[derive(Deserialize)]\npub struct FlowPayload {\n    pub name: String,\n    pub description: Option<String>,\n    pub enabled: Option<i32>,\n}\n\n#[derive(Deserialize)]\npub struct FlowVersionPayload {\n    pub graph_json: String,\n    pub comment: Option<String>,\n}\n\npub async fn get_all_flows(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n) -> Result<Json<Vec<Flow>>, AppError> {\n    let flows = db::repository::get_all_flows(&state.pool)?;\n    Ok(Json(flows))\n}\n\npub async fn create_flow(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Json(payload): Json<FlowPayload>,\n) -> Result<Json<Flow>, AppError> {\n    let new_flow = NewFlow {\n        name: &payload.name,\n        description: payload.description.as_deref(),\n        enabled: payload.enabled,\n    };\n    let flow = db::repository::create_flow(&state.pool, new_flow)?;\n    Ok(Json(flow))\n}\n\npub async fn update_flow(\n    State(state): State<Arc<AppState>>,\n    Path(id): Path<i32>,\n    AuthUser(_user): AuthUser,\n    Json(payload): Json<FlowPayload>,\n) -> Result<Json<Value>, AppError> {\n    let updated_flow = NewFlow {\n        name: &payload.name,\n        description: payload.description.as_deref(),\n        enabled: payload.enabled,\n    };\n    db::repository::update_flow(&state.pool, id, &updated_flow)?;\n    Ok(Json(\n        json!({ \"status\": \"success\", \"message\": \"Flow updated successfully\" }),\n    ))\n}\n\npub async fn delete_flow(\n    State(state): State<Arc<AppState>>,\n    Path(id): Path<i32>,\n    AuthUser(_user): AuthUser,\n) -> Result<Json<Value>, AppError> {\n    db::repository::delete_flow(&state.pool, id)?;\n    Ok(Json(\n        json!({ \"status\": \"success\", \"message\": \"Flow deleted\" }),\n    ))\n}\n\npub async fn create_flow_version(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(flow_id): Path<i32>,\n    Json(payload): Json<FlowVersionPayload>,\n) -> Result<Json<FlowVersion>, AppError> {\n    let new_version_data = NewFlowVersion {\n        flow_id,\n        version: 1,\n        graph_json: &payload.graph_json,\n        comment: payload.comment.as_deref(),\n    };\n\n    let version = db::repository::overwrite_flow_version(&state.pool, flow_id, new_version_data)?;\n    Ok(Json(version))\n}\npub async fn get_flow_versions(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(flow_id): Path<i32>,\n) -> Result<Json<Vec<FlowVersion>>, AppError> {\n    let versions = db::repository::get_versions_for_flow(&state.pool, flow_id)?;\n    Ok(Json(versions))\n}\n"
  },
  {
    "path": "apps/server/src/handler/ha.rs",
    "content": "use anyhow::anyhow;\nuse axum::{\n    extract::{Path, State},\n    Json,\n};\nuse reqwest::header;\nuse serde::{Deserialize, Serialize};\nuse serde_json::{json, Value};\nuse std::sync::Arc;\n\nuse crate::{db, error::AppError, handler::auth::AuthUser, state::{AppState, DbPool}};\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct HaState {\n    pub entity_id: String,\n    pub state: String,\n    pub attributes: Value,\n    pub last_changed: String,\n    pub last_updated: String,\n    pub context: Value,\n}\n\nfn get_ha_credentials(pool: &DbPool) -> Result<(String, String), anyhow::Error> {\n    let entity_with_config =\n        db::repository::get_entity_with_config_by_entity_id(pool, \"home_assistant.bridge\")?\n            .ok_or_else(|| anyhow!(\"Home Assistant is not configured. Please register the integration first.\"))?;\n\n    let config = entity_with_config\n        .configuration\n        .ok_or_else(|| anyhow!(\"Home Assistant bridge entity has no configuration.\"))?;\n\n    let url = config\n        .get(\"url\")\n        .and_then(|v| v.as_str())\n        .ok_or_else(|| anyhow!(\"Home Assistant URL is not configured.\"))?;\n    let token = config\n        .get(\"token\")\n        .and_then(|v| v.as_str())\n        .ok_or_else(|| anyhow!(\"Home Assistant Token is not configured.\"))?;\n\n    Ok((url.to_string(), token.to_string()))\n}\n\npub async fn get_all_states(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n) -> Result<Json<Vec<HaState>>, AppError> {\n    let (ha_url, ha_token) = get_ha_credentials(&state.pool)?;\n\n    let api_url = format!(\"{}/api/states\", ha_url.trim_end_matches('/'));\n\n    let client = reqwest::Client::new();\n    let response = client\n        .get(&api_url)\n        .header(header::AUTHORIZATION, format!(\"Bearer {}\", ha_token))\n        .header(header::CONTENT_TYPE, \"application/json\")\n        .send()\n        .await?;\n\n    let status = response.status();\n\n    if status.is_success() {\n        let states = response.json::<Vec<HaState>>().await?;\n        Ok(Json(states))\n    } else {\n        let error_text = response\n            .text()\n            .await\n            .unwrap_or_else(|_| \"Could not read error body\".to_string());\n\n        let err = anyhow!(\n            \"Home Assistant returned an error. Status: {}, Body: {}\",\n            status,\n            error_text\n        );\n\n        return Err(err.into());\n    }\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct HaStateUpdatePayload {\n    pub state: String,\n    pub attributes: Option<Value>,\n}\n\npub async fn post_state(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(entity_id): Path<String>,\n    Json(payload): Json<HaStateUpdatePayload>,\n) -> Result<Json<HaState>, AppError> {\n    let (ha_url, ha_token) = get_ha_credentials(&state.pool)?;\n\n    let api_url = format!(\"{}/api/states/{}\", ha_url.trim_end_matches('/'), entity_id);\n\n    let mut ha_body = json!({ \"state\": payload.state });\n    if let Some(attributes) = payload.attributes {\n        if let Some(obj) = ha_body.as_object_mut() {\n            obj.insert(\"attributes\".to_string(), attributes);\n        }\n    }\n\n    let client = reqwest::Client::new();\n    let response = client\n        .post(&api_url)\n        .header(header::AUTHORIZATION, format!(\"Bearer {}\", ha_token))\n        .header(header::CONTENT_TYPE, \"application/json\")\n        .json(&ha_body)\n        .send()\n        .await?;\n\n    let status = response.status();\n\n    if status.is_success() {\n        let new_state = response.json::<HaState>().await?;\n        Ok(Json(new_state))\n    } else {\n        let error_text = response\n            .text()\n            .await\n            .unwrap_or_else(|_| \"Could not read error body\".to_string());\n\n        let err = anyhow!(\n            \"Home Assistant returned an error. Status: {}, Body: {}\",\n            status,\n            error_text\n        );\n\n        return Err(err.into());\n    }\n}\n"
  },
  {
    "path": "apps/server/src/handler/integration.rs",
    "content": "use anyhow::anyhow;\nuse axum::{\n    extract::{Path, State},\n    Json,\n};\nuse serde::Deserialize;\nuse serde_json::{json, Value};\nuse std::sync::Arc;\n\nuse crate::{\n    db,\n    error::AppError,\n    handler::auth::AuthUser,\n    state::AppState,\n};\n\n#[derive(Deserialize)]\npub struct IntegrationRegisterPayload {\n    pub integration_id: String,\n    pub config: Value,\n}\n\npub async fn register_integration(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Json(payload): Json<IntegrationRegisterPayload>,\n) -> Result<Json<Value>, AppError> {\n    match payload.integration_id.as_str() {\n        \"home_assistant\" => {\n            let url = payload\n                .config\n                .get(\"url\")\n                .and_then(|v| v.as_str())\n                .ok_or_else(|| anyhow!(\"Missing 'url' in config\"))?;\n            let token = payload\n                .config\n                .get(\"token\")\n                .and_then(|v| v.as_str())\n                .ok_or_else(|| anyhow!(\"Missing 'token' in config\"))?;\n\n            let device =\n                db::repository::upsert_device(&state.pool, \"home_assistant\", Some(\"Home Assistant\"), Some(\"Home Assistant\"), None)?;\n\n            db::repository::upsert_entity_with_config(\n                &state.pool,\n                \"home_assistant.bridge\",\n                Some(device.id),\n                Some(\"Home Assistant Bridge\"),\n                Some(\"home_assistant\"),\n                Some(\"bridge\"),\n                &json!({ \"url\": url, \"token\": token }).to_string(),\n            )?;\n\n            Ok(Json(json!({ \"status\": \"success\", \"integration_id\": \"home_assistant\" })))\n        }\n        \"ros2\" => {\n            let ws_url = payload\n                .config\n                .get(\"websocket_url\")\n                .and_then(|v| v.as_str())\n                .ok_or_else(|| anyhow!(\"Missing 'websocket_url' in config\"))?;\n\n            let device =\n                db::repository::upsert_device(&state.pool, \"ros2_bridge\", Some(\"ROS2 Bridge\"), Some(\"Open Robotics\"), None)?;\n\n            db::repository::upsert_entity_with_config(\n                &state.pool,\n                \"ros2.bridge\",\n                Some(device.id),\n                Some(\"ROS2 WebSocket Bridge\"),\n                Some(\"ros2\"),\n                Some(\"bridge\"),\n                &json!({ \"websocket_url\": ws_url }).to_string(),\n            )?;\n\n            Ok(Json(json!({ \"status\": \"success\", \"integration_id\": \"ros2\" })))\n        }\n        \"sdr\" => {\n            let host = payload\n                .config\n                .get(\"host\")\n                .and_then(|v| v.as_str())\n                .ok_or_else(|| anyhow!(\"Missing 'host' in config\"))?;\n            let port = payload\n                .config\n                .get(\"port\")\n                .and_then(|v| v.as_str())\n                .ok_or_else(|| anyhow!(\"Missing 'port' in config\"))?;\n\n            let device =\n                db::repository::upsert_device(&state.pool, \"sdr_server\", Some(\"RTL-SDR\"), Some(\"RTL-SDR\"), None)?;\n\n            db::repository::upsert_entity_with_config(\n                &state.pool,\n                \"sdr.server\",\n                Some(device.id),\n                Some(\"RTL-SDR (rtl_tcp)\"),\n                Some(\"sdr\"),\n                Some(\"server\"),\n                &json!({ \"host\": host, \"port\": port }).to_string(),\n            )?;\n\n            Ok(Json(json!({ \"status\": \"success\", \"integration_id\": \"sdr\" })))\n        }\n        other => Err(anyhow!(\"Unknown integration_id: {}\", other).into()),\n    }\n}\n\npub async fn get_integration_status(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n) -> Result<Json<Value>, AppError> {\n    let ha = db::repository::get_entity_with_config_by_entity_id(\n        &state.pool,\n        \"home_assistant.bridge\",\n    )?;\n    let ros2 = db::repository::get_entity_with_config_by_entity_id(\n        &state.pool,\n        \"ros2.bridge\",\n    )?;\n\n    let sdr = db::repository::get_entity_with_config_by_entity_id(\n        &state.pool,\n        \"sdr.server\",\n    )?;\n\n    let ha_connected = ha.is_some();\n    let ros2_connected = ros2.is_some();\n    let sdr_connected = sdr.is_some();\n\n    Ok(Json(json!({\n        \"home_assistant\": { \"connected\": ha_connected },\n        \"ros2\": { \"connected\": ros2_connected },\n        \"sdr\": { \"connected\": sdr_connected }\n    })))\n}\n\npub async fn delete_integration(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(integration_id): Path<String>,\n) -> Result<Json<Value>, AppError> {\n    match integration_id.as_str() {\n        \"home_assistant\" => {\n            db::repository::delete_device_by_device_id(&state.pool, \"home_assistant\")?;\n            Ok(Json(json!({ \"status\": \"success\", \"message\": \"Home Assistant integration removed\" })))\n        }\n        \"ros2\" => {\n            db::repository::delete_device_by_device_id(&state.pool, \"ros2_bridge\")?;\n            Ok(Json(json!({ \"status\": \"success\", \"message\": \"ROS2 integration removed\" })))\n        }\n        \"sdr\" => {\n            db::repository::delete_device_by_device_id(&state.pool, \"sdr_server\")?;\n            Ok(Json(json!({ \"status\": \"success\", \"message\": \"RTL-SDR integration removed\" })))\n        }\n        other => Err(anyhow!(\"Unknown integration_id: {}\", other).into()),\n    }\n}\n"
  },
  {
    "path": "apps/server/src/handler/log.rs",
    "content": "use axum::{\n    extract::Path,\n    http::StatusCode,\n    response::{IntoResponse, Response},\n    Json,\n};\nuse serde::Serialize;\nuse std::{fs, io, path::PathBuf};\n\nuse crate::handler::auth::AuthUser;\n\nconst LOG_DIR: &str = \"log\";\n\n#[derive(Serialize)]\nstruct LogContentResponse {\n    filename: String,\n    logs: String,\n}\n\n#[derive(Serialize)]\nstruct LogFileListResponse {\n    files: Vec<String>,\n}\n\n#[derive(Serialize)]\nstruct ErrorResponse {\n    error: String,\n}\n\nfn get_log_files() -> io::Result<Vec<String>> {\n    let mut files = fs::read_dir(LOG_DIR)?\n        .filter_map(|entry| {\n            let entry = entry.ok()?;\n            let path = entry.path();\n            if path.is_file() {\n                path.file_name()?.to_str().map(String::from)\n            } else {\n                None\n            }\n        })\n        .collect::<Vec<String>>();\n\n    files.sort_by(|a, b| b.cmp(a));\n    Ok(files)\n}\n\nfn read_log_file_content(filename: &str) -> Result<String, io::Error> {\n    if filename.contains('/') || filename.contains(\"..\") {\n        return Err(io::Error::new(\n            io::ErrorKind::InvalidInput,\n            \"Invalid filename provided.\",\n        ));\n    }\n\n    const MAX_LINES: usize = 3000;\n\n    let path = PathBuf::from(LOG_DIR).join(filename);\n    let contents = fs::read_to_string(path)?;\n    Ok(contents\n        .lines()\n        .rev()\n        .take(MAX_LINES)\n        .collect::<Vec<&str>>()\n        .join(\"\\n\"))\n}\n\npub async fn get_latest_log_handler(AuthUser(_user): AuthUser) -> Response {\n    match get_log_files() {\n        Ok(files) => {\n            if let Some(latest_file) = files.first() {\n                get_log_by_filename_handler(AuthUser(_user), Path(latest_file.clone())).await\n            } else {\n                let error_response = ErrorResponse {\n                    error: \"No log files found.\".to_string(),\n                };\n                (StatusCode::NOT_FOUND, Json(error_response)).into_response()\n            }\n        }\n        Err(e) => {\n            let error_response = ErrorResponse {\n                error: format!(\"Could not read log directory: {}\", e),\n            };\n            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)).into_response()\n        }\n    }\n}\n\npub async fn list_log_files_handler(AuthUser(_user): AuthUser) -> Response {\n    match get_log_files() {\n        Ok(files) => {\n            let response = LogFileListResponse { files };\n            (StatusCode::OK, Json(response)).into_response()\n        }\n        Err(e) => {\n            let error_response = ErrorResponse {\n                error: format!(\"Could not read log directory: {}\", e),\n            };\n            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)).into_response()\n        }\n    }\n}\n\npub async fn get_log_by_filename_handler(\n    AuthUser(_user): AuthUser,\n    Path(filename): Path<String>,\n) -> Response {\n    match read_log_file_content(&filename) {\n        Ok(logs) => {\n            let response = LogContentResponse { filename, logs };\n            (StatusCode::OK, Json(response)).into_response()\n        }\n        Err(e) => {\n            let (status, error_message) = match e.kind() {\n                io::ErrorKind::NotFound => (\n                    StatusCode::NOT_FOUND,\n                    format!(\"Log file '{}' not found.\", filename),\n                ),\n                io::ErrorKind::InvalidInput => (StatusCode::BAD_REQUEST, e.to_string()),\n                _ => (\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    format!(\"Could not read log file '{}': {}\", filename, e),\n                ),\n            };\n            let error_response = ErrorResponse {\n                error: error_message,\n            };\n            (status, Json(error_response)).into_response()\n        }\n    }\n}\n"
  },
  {
    "path": "apps/server/src/handler/map.rs",
    "content": "use crate::db::models::{\n    FeatureWithVertices, LayerWithFeatures, MapFeature, MapLayer, NewMapFeature, NewMapLayer,\n    NewMapVertex, UpdateMapFeature, UpdateMapLayer,\n};\nuse crate::{db, error::AppError, handler::auth::AuthUser, state::AppState};\nuse axum::{\n    extract::{Path, State},\n    Json,\n};\nuse serde::Deserialize;\nuse serde_json::{json, Value};\nuse std::sync::Arc;\n\n#[derive(Deserialize)]\npub struct LayerPayload {\n    pub name: String,\n    pub description: Option<String>,\n    pub is_visible: Option<bool>,\n}\n\n#[derive(Deserialize, Clone)]\npub struct VertexPayload {\n    pub latitude: f32,\n    pub longitude: f32,\n    pub altitude: Option<f32>,\n}\n\n#[derive(Deserialize)]\npub struct FeaturePayload {\n    pub layer_id: i32,\n    pub feature_type: String,\n    pub name: Option<String>,\n    pub style_properties: Option<String>,\n    pub vertices: Vec<VertexPayload>,\n}\n\n#[derive(Deserialize)]\npub struct UpdateFeaturePayload {\n    pub name: Option<String>,\n    pub style_properties: Option<String>,\n    pub vertices: Option<Vec<VertexPayload>>,\n}\n\npub async fn create_layer(\n    State(state): State<Arc<AppState>>,\n    AuthUser(user): AuthUser,\n    Json(payload): Json<LayerPayload>,\n) -> Result<Json<MapLayer>, AppError> {\n    let new_layer = NewMapLayer {\n        name: &payload.name,\n        description: payload.description.as_deref(),\n        owner_user_id: user.id,\n        is_visible: Some(payload.is_visible.unwrap_or(true) as i32),\n    };\n    let layer = db::repository::create_map_layer(&state.pool, new_layer)?;\n    Ok(Json(layer))\n}\n\npub async fn get_layers(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n) -> Result<Json<Vec<MapLayer>>, AppError> {\n    let layers = db::repository::get_all_map_layers(&state.pool)?;\n    Ok(Json(layers))\n}\n\npub async fn get_layer(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(id): Path<i32>,\n) -> Result<Json<LayerWithFeatures>, AppError> {\n    let layer_with_features = db::repository::get_map_layer_with_features(&state.pool, id)?;\n    Ok(Json(layer_with_features))\n}\n\npub async fn update_layer(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(id): Path<i32>,\n    Json(payload): Json<LayerPayload>,\n) -> Result<Json<MapLayer>, AppError> {\n    let update_data = UpdateMapLayer {\n        name: Some(payload.name),\n        description: payload.description,\n        is_visible: Some(payload.is_visible.unwrap_or(true) as i32),\n    };\n    let layer = db::repository::update_map_layer(&state.pool, id, &update_data)?;\n    Ok(Json(layer))\n}\n\npub async fn delete_layer(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(id): Path<i32>,\n) -> Result<Json<Value>, AppError> {\n    db::repository::delete_map_layer(&state.pool, id)?;\n    Ok(Json(\n        json!({ \"status\": \"success\", \"message\": \"Layer deleted\" }),\n    ))\n}\n\npub async fn create_feature(\n    State(state): State<Arc<AppState>>,\n    AuthUser(user): AuthUser,\n    Json(payload): Json<FeaturePayload>,\n) -> Result<Json<MapFeature>, AppError> {\n    let new_feature = NewMapFeature {\n        layer_id: payload.layer_id,\n        feature_type: &payload.feature_type,\n        name: payload.name.as_deref(),\n        style_properties: payload.style_properties.as_deref(),\n        created_by_user_id: user.id,\n    };\n\n    let new_vertices = payload\n        .vertices\n        .into_iter()\n        .enumerate()\n        .map(|(i, v)| NewMapVertex {\n            feature_id: 0,\n            latitude: v.latitude,\n            longitude: v.longitude,\n            altitude: v.altitude,\n            sequence: i as i32,\n        })\n        .collect();\n\n    let feature = db::repository::create_map_feature(&state.pool, new_feature, new_vertices)?;\n    Ok(Json(feature))\n}\n\npub async fn get_feature(\n    State(state): State<Arc<AppState>>,\n    Path(id): Path<i32>,\n    AuthUser(_user): AuthUser,\n) -> Result<Json<FeatureWithVertices>, AppError> {\n    let feature = db::repository::get_map_feature_with_vertices(&state.pool, id)?;\n    Ok(Json(feature))\n}\n\npub async fn update_feature(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(id): Path<i32>,\n    Json(payload): Json<UpdateFeaturePayload>,\n) -> Result<Json<MapFeature>, AppError> {\n    let update_data = UpdateMapFeature {\n        name: payload.name,\n        style_properties: payload.style_properties,\n    };\n\n    let new_vertices = payload.vertices.map(|vertices| {\n        vertices\n            .into_iter()\n            .enumerate()\n            .map(|(i, v)| NewMapVertex {\n                feature_id: 0,\n                latitude: v.latitude,\n                longitude: v.longitude,\n                altitude: v.altitude,\n                sequence: i as i32,\n            })\n            .collect()\n    });\n\n    let feature = db::repository::update_map_feature(&state.pool, id, &update_data, new_vertices)?;\n    Ok(Json(feature))\n}\n\npub async fn delete_feature(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(id): Path<i32>,\n) -> Result<Json<Value>, AppError> {\n    db::repository::delete_map_feature(&state.pool, id)?;\n    Ok(Json(\n        json!({ \"status\": \"success\", \"message\": \"Feature deleted\" }),\n    ))\n}\n"
  },
  {
    "path": "apps/server/src/handler/mod.rs",
    "content": "pub mod auth;\npub mod configurations;\npub mod custom_nodes;\npub mod dashboards;\npub mod device_tokens;\npub mod devices;\npub mod entities;\npub mod flows;\npub mod ha;\npub mod integration;\npub mod log;\npub mod sdr;\npub mod map;\npub mod permissions;\npub mod recordings;\npub mod roles;\npub mod stat;\npub mod state;\npub mod storage;\npub mod streams;\npub mod tunnel;\npub mod users;\npub mod ws;\n"
  },
  {
    "path": "apps/server/src/handler/permissions.rs",
    "content": "use axum::{extract::State, http::StatusCode, Json};\nuse serde::Deserialize;\nuse std::sync::Arc;\n\nuse crate::{\n    db::{\n        self,\n        models::{NewPermission, Permission},\n    },\n    error::AppError,\n    handler::auth::AuthUser,\n    state::AppState,\n};\n\n#[derive(Deserialize)]\npub struct PermissionPayload {\n    name: String,\n    description: Option<String>,\n}\n\npub async fn create_permission(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Json(payload): Json<PermissionPayload>,\n) -> Result<(StatusCode, Json<Permission>), AppError> {\n    let new_permission = NewPermission {\n        name: &payload.name,\n        description: payload.description.as_deref(),\n    };\n    let permission = db::repository::rbac::create_permission(&state.pool, new_permission)?;\n    Ok((StatusCode::CREATED, Json(permission)))\n}\n\npub async fn get_permissions(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n) -> Result<Json<Vec<Permission>>, AppError> {\n    let permissions = db::repository::rbac::get_all_permissions(&state.pool)?;\n    Ok(Json(permissions))\n}\n"
  },
  {
    "path": "apps/server/src/handler/recordings.rs",
    "content": "use axum::{\n    body::Body,\n    extract::{Path, Query, State},\n    http::{header, HeaderMap, StatusCode},\n    response::{IntoResponse, Response},\n    Json,\n};\nuse serde::{Deserialize, Serialize};\nuse std::sync::Arc;\nuse tokio::fs::File;\nuse tokio::io::{AsyncReadExt, AsyncSeekExt};\nuse tokio_util::io::ReaderStream;\nuse tracing::{error, info, warn};\n\nuse crate::db::models::Recording;\nuse crate::db::repository::recordings as recordings_repo;\nuse crate::handler::auth::AuthUser;\nuse crate::state::AppState;\n\n#[derive(Deserialize)]\npub struct StartRecordingRequest {\n    pub topic: String,\n}\n\n#[derive(Serialize)]\npub struct StartRecordingResponse {\n    pub id: i32,\n    pub topic: String,\n    pub status: String,\n}\n\n#[derive(Deserialize)]\npub struct ListRecordingsQuery {\n    pub topic: Option<String>,\n    pub status: Option<String>,\n    pub limit: Option<i32>,\n    pub offset: Option<i32>,\n}\n\n#[derive(Serialize)]\npub struct RecordingResponse {\n    pub id: i32,\n    pub stream_ssrc: i32,\n    pub topic: String,\n    pub device_id: String,\n    pub media_type: String,\n    pub filename: String,\n    pub file_path: String,\n    pub file_size: i32,\n    pub duration_ms: i32,\n    pub status: String,\n    pub started_at: String,\n    pub ended_at: Option<String>,\n    pub created_by_user_id: Option<i32>,\n}\n\nimpl From<Recording> for RecordingResponse {\n    fn from(r: Recording) -> Self {\n        RecordingResponse {\n            id: r.id,\n            stream_ssrc: r.stream_ssrc,\n            topic: r.topic,\n            device_id: r.device_id,\n            media_type: r.media_type,\n            filename: r.filename,\n            file_path: r.file_path,\n            file_size: r.file_size,\n            duration_ms: r.duration_ms,\n            status: r.status,\n            started_at: r.started_at.to_string(),\n            ended_at: r.ended_at.map(|dt| dt.to_string()),\n            created_by_user_id: r.created_by_user_id,\n        }\n    }\n}\n\n#[derive(Serialize)]\nstruct ErrorResponse {\n    error: String,\n}\n\n#[derive(Serialize)]\nstruct ActiveRecordingInfo {\n    topic: String,\n    recording_id: i32,\n}\n\npub async fn start_recording(\n    State(state): State<Arc<AppState>>,\n    AuthUser(user): AuthUser,\n    Json(payload): Json<StartRecordingRequest>,\n) -> impl IntoResponse {\n    info!(\"Starting recording for topic: {}\", payload.topic);\n\n    match state\n        .recording_manager\n        .start_recording(&payload.topic, Some(user.id))\n    {\n        Ok(recording_id) => {\n            info!(\n                \"Recording started successfully: id={}, topic={}\",\n                recording_id, payload.topic\n            );\n            (\n                StatusCode::CREATED,\n                Json(StartRecordingResponse {\n                    id: recording_id,\n                    topic: payload.topic,\n                    status: \"recording\".to_string(),\n                }),\n            )\n                .into_response()\n        }\n        Err(e) => {\n            error!(\"Failed to start recording: {}\", e);\n            (\n                StatusCode::BAD_REQUEST,\n                Json(ErrorResponse {\n                    error: e.to_string(),\n                }),\n            )\n                .into_response()\n        }\n    }\n}\n\npub async fn stop_recording(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(id): Path<i32>,\n) -> impl IntoResponse {\n    info!(\"Stopping recording: id={}\", id);\n\n    match state.recording_manager.stop_recording(id) {\n        Ok(()) => {\n            info!(\"Recording stopped successfully: id={}\", id);\n            (\n                StatusCode::OK,\n                Json(serde_json::json!({\"message\": \"Recording stopped\"})),\n            )\n                .into_response()\n        }\n        Err(e) => {\n            error!(\"Failed to stop recording: {}\", e);\n            (\n                StatusCode::BAD_REQUEST,\n                Json(ErrorResponse {\n                    error: e.to_string(),\n                }),\n            )\n                .into_response()\n        }\n    }\n}\n\npub async fn list_recordings(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Query(query): Query<ListRecordingsQuery>,\n) -> impl IntoResponse {\n    let recordings = if let Some(topic) = &query.topic {\n        recordings_repo::get_recordings_by_topic(&state.pool, topic)\n    } else if let Some(status) = &query.status {\n        recordings_repo::get_recordings_by_status(&state.pool, status)\n    } else {\n        recordings_repo::get_all_recordings(&state.pool)\n    };\n\n    match recordings {\n        Ok(recs) => {\n            let response: Vec<RecordingResponse> = recs.into_iter().map(Into::into).collect();\n            (StatusCode::OK, Json(response)).into_response()\n        }\n        Err(e) => {\n            error!(\"Failed to list recordings: {}\", e);\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse {\n                    error: e.to_string(),\n                }),\n            )\n                .into_response()\n        }\n    }\n}\n\npub async fn get_recording(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(id): Path<i32>,\n) -> impl IntoResponse {\n    match recordings_repo::get_recording_by_id(&state.pool, id) {\n        Ok(recording) => {\n            let response: RecordingResponse = recording.into();\n            (StatusCode::OK, Json(response)).into_response()\n        }\n        Err(e) => {\n            error!(\"Failed to get recording {}: {}\", id, e);\n            (\n                StatusCode::NOT_FOUND,\n                Json(ErrorResponse {\n                    error: format!(\"Recording not found: {}\", id),\n                }),\n            )\n                .into_response()\n        }\n    }\n}\n\npub async fn delete_recording(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(id): Path<i32>,\n) -> impl IntoResponse {\n    // Get recording to find file path\n    let recording = match recordings_repo::get_recording_by_id(&state.pool, id) {\n        Ok(r) => r,\n        Err(e) => {\n            error!(\"Failed to get recording for deletion {}: {}\", id, e);\n            return (\n                StatusCode::NOT_FOUND,\n                Json(ErrorResponse {\n                    error: format!(\"Recording not found: {}\", id),\n                }),\n            )\n                .into_response();\n        }\n    };\n\n    // Store file path before DB deletion\n    let file_path = recording.file_path.clone();\n\n    // Delete from database FIRST (prevents orphan records if file deletion fails)\n    // An orphan file is preferable to an orphan DB record\n    match recordings_repo::delete_recording(&state.pool, id) {\n        Ok(_) => {\n            info!(\"Recording deleted from database: id={}\", id);\n\n            // Delete file if exists (after DB deletion succeeded)\n            if std::path::Path::new(&file_path).exists() {\n                if let Err(e) = std::fs::remove_file(&file_path) {\n                    // Log warning but don't fail - DB record is already gone\n                    // Orphan file may remain but that's better than orphan record\n                    warn!(\n                        \"Failed to delete recording file (orphan file left): path={}, error={}\",\n                        file_path, e\n                    );\n                }\n            }\n\n            (\n                StatusCode::OK,\n                Json(serde_json::json!({\"message\": \"Recording deleted\"})),\n            )\n                .into_response()\n        }\n        Err(e) => {\n            error!(\"Failed to delete recording from database: {}\", e);\n            (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse {\n                    error: e.to_string(),\n                }),\n            )\n                .into_response()\n        }\n    }\n}\n\npub async fn stream_recording(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(id): Path<i32>,\n    headers: HeaderMap,\n) -> Response {\n    // Get recording metadata\n    let recording = match recordings_repo::get_recording_by_id(&state.pool, id) {\n        Ok(r) => r,\n        Err(e) => {\n            error!(\"Failed to get recording for streaming {}: {}\", id, e);\n            return (\n                StatusCode::NOT_FOUND,\n                Json(ErrorResponse {\n                    error: format!(\"Recording not found: {}\", id),\n                }),\n            )\n                .into_response();\n        }\n    };\n\n    // Check if file exists\n    let file_path = std::path::Path::new(&recording.file_path);\n    if !file_path.exists() {\n        return (\n            StatusCode::NOT_FOUND,\n            Json(ErrorResponse {\n                error: \"Recording file not found\".to_string(),\n            }),\n        )\n            .into_response();\n    }\n\n    // Get file metadata\n    let metadata = match std::fs::metadata(file_path) {\n        Ok(m) => m,\n        Err(e) => {\n            error!(\"Failed to get file metadata: {}\", e);\n            return (\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse {\n                    error: \"Failed to read file\".to_string(),\n                }),\n            )\n                .into_response();\n        }\n    };\n\n    let file_size = metadata.len();\n\n    // Parse Range header for seeking support\n    let range = headers\n        .get(header::RANGE)\n        .and_then(|v| v.to_str().ok())\n        .and_then(|s| parse_range_header(s, file_size));\n\n    let content_type = \"video/x-matroska\";\n\n    match range {\n        Some((start, end)) => {\n            // Partial content response\n            let mut file = match File::open(file_path).await {\n                Ok(f) => f,\n                Err(e) => {\n                    error!(\"Failed to open file: {}\", e);\n                    return (\n                        StatusCode::INTERNAL_SERVER_ERROR,\n                        Json(ErrorResponse {\n                            error: \"Failed to open file\".to_string(),\n                        }),\n                    )\n                        .into_response();\n                }\n            };\n\n            if let Err(e) = file.seek(std::io::SeekFrom::Start(start)).await {\n                error!(\"Failed to seek in file: {}\", e);\n                return (\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    Json(ErrorResponse {\n                        error: \"Failed to seek in file\".to_string(),\n                    }),\n                )\n                    .into_response();\n            }\n\n            let chunk_size = end - start + 1;\n            let limited_file = AsyncReadExt::take(file, chunk_size);\n            let stream = ReaderStream::new(limited_file);\n            let body = Body::from_stream(stream);\n\n            Response::builder()\n                .status(StatusCode::PARTIAL_CONTENT)\n                .header(header::CONTENT_TYPE, content_type)\n                .header(header::CONTENT_LENGTH, chunk_size)\n                .header(\n                    header::CONTENT_RANGE,\n                    format!(\"bytes {}-{}/{}\", start, end, file_size),\n                )\n                .header(header::ACCEPT_RANGES, \"bytes\")\n                .body(body)\n                .unwrap()\n        }\n        None => {\n            // Full content response\n            let file = match File::open(file_path).await {\n                Ok(f) => f,\n                Err(e) => {\n                    error!(\"Failed to open file: {}\", e);\n                    return (\n                        StatusCode::INTERNAL_SERVER_ERROR,\n                        Json(ErrorResponse {\n                            error: \"Failed to open file\".to_string(),\n                        }),\n                    )\n                        .into_response();\n                }\n            };\n\n            let stream = ReaderStream::new(file);\n            let body = Body::from_stream(stream);\n\n            Response::builder()\n                .status(StatusCode::OK)\n                .header(header::CONTENT_TYPE, content_type)\n                .header(header::CONTENT_LENGTH, file_size)\n                .header(header::ACCEPT_RANGES, \"bytes\")\n                .body(body)\n                .unwrap()\n        }\n    }\n}\n\nfn parse_range_header(range: &str, file_size: u64) -> Option<(u64, u64)> {\n    if !range.starts_with(\"bytes=\") {\n        return None;\n    }\n\n    let range = &range[6..];\n    let parts: Vec<&str> = range.split('-').collect();\n\n    if parts.len() != 2 {\n        return None;\n    }\n\n    let start: u64 = parts[0].parse().ok()?;\n    let end: u64 = if parts[1].is_empty() {\n        file_size - 1\n    } else {\n        parts[1].parse().ok()?\n    };\n\n    if start > end || end >= file_size {\n        return None;\n    }\n\n    Some((start, end))\n}\n\npub async fn get_active_recordings(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n) -> impl IntoResponse {\n    let active = state.recording_manager.get_all_active_recordings();\n    let response: Vec<ActiveRecordingInfo> = active\n        .into_iter()\n        .map(|(topic, recording_id)| ActiveRecordingInfo {\n            topic,\n            recording_id,\n        })\n        .collect();\n\n    (StatusCode::OK, Json(response)).into_response()\n}\n\npub async fn is_topic_recording(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(topic): Path<String>,\n) -> impl IntoResponse {\n    let is_recording = state.recording_manager.is_recording(&topic);\n    let recording_id = state.recording_manager.get_active_recording_id(&topic);\n\n    (\n        StatusCode::OK,\n        Json(serde_json::json!({\n            \"is_recording\": is_recording,\n            \"recording_id\": recording_id\n        })),\n    )\n        .into_response()\n}\n"
  },
  {
    "path": "apps/server/src/handler/roles.rs",
    "content": "use crate::{\n    db::{\n        self,\n        models::{NewRole, Permission, Role},\n        repository::rbac,\n    },\n    error::AppError,\n    handler::auth::AuthUser,\n    state::AppState,\n};\nuse axum::{\n    extract::{Path, State},\n    http::StatusCode,\n    Json,\n};\nuse serde::Deserialize;\nuse std::sync::Arc;\n\n#[derive(Deserialize)]\npub struct RolePayload {\n    name: String,\n    description: Option<String>,\n    permission_ids: Vec<i32>,\n}\n\n#[derive(Deserialize)]\npub struct PermissionAssignmentPayload {\n    permission_id: i32,\n}\n\npub async fn create_role(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Json(payload): Json<RolePayload>,\n) -> Result<(StatusCode, Json<Role>), AppError> {\n    let role = rbac::create_role_with_permissions(\n        &state.pool,\n        &payload.name,\n        payload.description.as_deref(),\n        &payload.permission_ids,\n    )?;\n    Ok((StatusCode::CREATED, Json(role)))\n}\n\npub async fn get_roles(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n) -> Result<Json<Vec<crate::db::models::RoleWithPermissions>>, AppError> {\n    let roles = rbac::get_all_roles_with_permissions(&state.pool)?;\n    Ok(Json(roles))\n}\n\npub async fn update_role(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(role_id): Path<i32>,\n    Json(payload): Json<RolePayload>,\n) -> Result<Json<Role>, AppError> {\n    let updated_role = rbac::update_role_with_permissions(\n        &state.pool,\n        role_id,\n        &payload.name,\n        payload.description.as_deref(),\n        &payload.permission_ids,\n    )?;\n    Ok(Json(updated_role))\n}\n\npub async fn delete_role(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(role_id): Path<i32>,\n) -> Result<StatusCode, AppError> {\n    db::repository::rbac::delete_role(&state.pool, role_id)?;\n    Ok(StatusCode::NO_CONTENT)\n}\n\npub async fn grant_permission_to_role(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(role_id): Path<i32>,\n    Json(payload): Json<PermissionAssignmentPayload>,\n) -> Result<StatusCode, AppError> {\n    db::repository::rbac::grant_permission_to_role(&state.pool, role_id, payload.permission_id)?;\n    Ok(StatusCode::CREATED)\n}\n\npub async fn revoke_permission_from_role(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path((role_id, permission_id)): Path<(i32, i32)>,\n) -> Result<StatusCode, AppError> {\n    db::repository::rbac::revoke_permission_from_role(&state.pool, role_id, permission_id)?;\n    Ok(StatusCode::NO_CONTENT)\n}\n\npub async fn get_role_permissions(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(role_id): Path<i32>,\n) -> Result<Json<Vec<Permission>>, AppError> {\n    let permissions = db::repository::rbac::get_role_permissions(&state.pool, role_id)?;\n    Ok(Json(permissions))\n}\n"
  },
  {
    "path": "apps/server/src/handler/sdr.rs",
    "content": "use anyhow::anyhow;\nuse axum::{\n    extract::{ws::{Message, WebSocket, WebSocketUpgrade}, State},\n    response::IntoResponse,\n    Json,\n};\nuse serde::Deserialize;\nuse serde_json::{json, Value};\nuse std::sync::Arc;\nuse tokio::io::{AsyncReadExt, AsyncWriteExt};\nuse tokio::net::TcpStream;\nuse tokio::time::{timeout, Duration};\nuse tracing::{error, info, warn};\n\nuse crate::{db, error::AppError, handler::auth::AuthUser, state::{AppState, DbPool}};\n\n// rtl_tcp command IDs\nconst RTL_TCP_SET_FREQ: u8 = 0x01;\nconst RTL_TCP_SET_SAMPLERATE: u8 = 0x02;\nconst RTL_TCP_SET_GAIN_MODE: u8 = 0x03;\nconst RTL_TCP_SET_AGC: u8 = 0x08;\n\nconst DEFAULT_SAMPLERATE: u32 = 2_048_000;\nconst AUDIO_SAMPLE_RATE: u32 = 48000;\nconst CONNECT_TIMEOUT: Duration = Duration::from_secs(5);\n\nfn get_sdr_address(pool: &DbPool) -> Result<(String, u16), anyhow::Error> {\n    let entity_with_config =\n        db::repository::get_entity_with_config_by_entity_id(pool, \"sdr.server\")?\n            .ok_or_else(|| anyhow!(\"RTL-SDR is not configured. Please register the integration first.\"))?;\n\n    let config = entity_with_config\n        .configuration\n        .ok_or_else(|| anyhow!(\"RTL-SDR entity has no configuration.\"))?;\n\n    let host = config\n        .get(\"host\")\n        .and_then(|v| v.as_str())\n        .ok_or_else(|| anyhow!(\"RTL-SDR host is not configured.\"))?;\n    let port_str = config\n        .get(\"port\")\n        .and_then(|v| v.as_str())\n        .ok_or_else(|| anyhow!(\"RTL-SDR port is not configured.\"))?;\n    let port: u16 = port_str\n        .parse()\n        .map_err(|_| anyhow!(\"Invalid RTL-SDR port: {}\", port_str))?;\n\n    Ok((host.to_string(), port))\n}\n\n// ─── rtl_tcp Protocol ───\n\n/// Send a 5-byte rtl_tcp command: [cmd: u8, value: u32 BE]\nasync fn rtl_tcp_command(stream: &mut TcpStream, cmd: u8, value: u32) -> Result<(), anyhow::Error> {\n    let mut buf = [0u8; 5];\n    buf[0] = cmd;\n    buf[1..5].copy_from_slice(&value.to_be_bytes());\n    stream.write_all(&buf).await?;\n    Ok(())\n}\n\n/// Read the 12-byte rtl_tcp dongle info header.\n/// Returns (tuner_type, gain_count).\nasync fn read_rtl_tcp_header(stream: &mut TcpStream) -> Result<(u32, u32), anyhow::Error> {\n    let mut header = [0u8; 12];\n    timeout(CONNECT_TIMEOUT, stream.read_exact(&mut header))\n        .await\n        .map_err(|_| anyhow!(\"Timeout reading rtl_tcp header\"))??;\n\n    let magic = &header[0..4];\n    if magic != b\"RTL0\" {\n        return Err(anyhow!(\"Invalid rtl_tcp header magic: {:?}\", magic));\n    }\n\n    let tuner_type = u32::from_be_bytes(header[4..8].try_into()?);\n    let gain_count = u32::from_be_bytes(header[8..12].try_into()?);\n\n    Ok((tuner_type, gain_count))\n}\n\n// ─── DSP Pipeline ───\n\n/// State maintained across packets for phase-continuous FM demodulation\nstruct FmDemodState {\n    prev_i: f32,\n    prev_q: f32,\n    lpf_state: f32,\n}\n\nimpl FmDemodState {\n    fn new() -> Self {\n        Self { prev_i: 0.0, prev_q: 0.0, lpf_state: 0.0 }\n    }\n}\n\n/// Convert unsigned 8-bit IQ bytes to float32 IQ samples in [-1.0, 1.0].\nfn u8_iq_to_float(data: &[u8]) -> Vec<f32> {\n    data.iter().map(|&b| (b as f32 - 127.5) / 127.5).collect()\n}\n\n/// FM demodulation with state for phase continuity across packet boundaries.\nfn fm_demodulate(iq_samples: &[f32], state: &mut FmDemodState, lpf_alpha: f32) -> Vec<f32> {\n    if iq_samples.len() < 2 {\n        return Vec::new();\n    }\n    let num_iq = iq_samples.len() / 2;\n    let mut audio = Vec::with_capacity(num_iq);\n\n    for i in 0..num_iq {\n        let idx = i * 2;\n        let i_curr = iq_samples[idx];\n        let q_curr = iq_samples[idx + 1];\n\n        // Phase difference: arg(conj(prev) * curr)\n        let real = i_curr * state.prev_i + q_curr * state.prev_q;\n        let imag = q_curr * state.prev_i - i_curr * state.prev_q;\n        let demod = imag.atan2(real);\n\n        // Single-pole IIR low-pass filter\n        state.lpf_state = lpf_alpha * demod + (1.0 - lpf_alpha) * state.lpf_state;\n        audio.push(state.lpf_state);\n\n        state.prev_i = i_curr;\n        state.prev_q = q_curr;\n    }\n    audio\n}\n\n/// Downsample audio using block averaging (anti-aliasing) and convert to int16 PCM.\nfn downsample_to_i16(samples: &[f32], src_rate: u32, dst_rate: u32) -> Vec<u8> {\n    if src_rate == 0 || dst_rate == 0 || samples.is_empty() {\n        return Vec::new();\n    }\n    let ratio = src_rate as usize / dst_rate.max(1) as usize;\n    if ratio == 0 {\n        return Vec::new();\n    }\n    let out_len = samples.len() / ratio;\n    let mut output = Vec::with_capacity(out_len * 2);\n\n    for i in 0..out_len {\n        let start = i * ratio;\n        let end = (start + ratio).min(samples.len());\n        let count = (end - start) as f32;\n        let sum: f32 = samples[start..end].iter().sum();\n        let avg = sum / count;\n        let sample = (avg * 24000.0).clamp(-32768.0, 32767.0) as i16;\n        output.extend_from_slice(&sample.to_le_bytes());\n    }\n    output\n}\n\n// ─── REST API Handlers ───\n\n#[derive(Deserialize)]\npub struct SetFrequencyPayload {\n    pub frequency: u64,\n}\n\npub async fn set_frequency(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Json(payload): Json<SetFrequencyPayload>,\n) -> Result<Json<Value>, AppError> {\n    let (host, port) = get_sdr_address(&state.pool)?;\n    let addr = format!(\"{}:{}\", host, port);\n\n    let mut stream = timeout(CONNECT_TIMEOUT, TcpStream::connect(&addr))\n        .await\n        .map_err(|_| anyhow!(\"Connection to rtl_tcp timed out\"))?\n        .map_err(|e| anyhow!(\"Failed to connect to rtl_tcp at {}: {}\", addr, e))?;\n\n    // Read header (required before sending commands)\n    read_rtl_tcp_header(&mut stream).await?;\n\n    // Set frequency\n    rtl_tcp_command(&mut stream, RTL_TCP_SET_FREQ, payload.frequency as u32).await?;\n\n    Ok(Json(json!({ \"status\": \"success\", \"frequency\": payload.frequency })))\n}\n\npub async fn get_samplerate(\n    State(_state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n) -> Result<Json<Value>, AppError> {\n    Ok(Json(json!({ \"samplerate\": DEFAULT_SAMPLERATE })))\n}\n\npub async fn start_stream(\n    State(_state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n) -> Result<Json<Value>, AppError> {\n    Ok(Json(json!({ \"status\": \"streaming\", \"note\": \"Use WebSocket /sdr/audio endpoint\" })))\n}\n\npub async fn stop_stream(\n    State(_state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n) -> Result<Json<Value>, AppError> {\n    Ok(Json(json!({ \"status\": \"stopped\" })))\n}\n\n// ─── WebSocket Audio Streaming Handler ───\n\npub async fn sdr_audio_ws(\n    ws: WebSocketUpgrade,\n    State(state): State<Arc<AppState>>,\n) -> impl IntoResponse {\n    ws.on_upgrade(move |socket| handle_sdr_audio(socket, state))\n}\n\nasync fn handle_sdr_audio(mut ws: WebSocket, state: Arc<AppState>) {\n    let (host, port) = match get_sdr_address(&state.pool) {\n        Ok(addr) => addr,\n        Err(e) => {\n            let _ = ws.send(Message::Text(json!({\"error\": e.to_string()}).to_string().into())).await;\n            return;\n        }\n    };\n\n    let addr = format!(\"{}:{}\", host, port);\n    let mut tcp_stream = match timeout(CONNECT_TIMEOUT, TcpStream::connect(&addr)).await {\n        Ok(Ok(s)) => s,\n        Ok(Err(e)) => {\n            error!(\"Failed to connect to rtl_tcp: {}\", e);\n            let _ = ws.send(Message::Text(json!({\"error\": format!(\"Connection failed: {}\", e)}).to_string().into())).await;\n            return;\n        }\n        Err(_) => {\n            error!(\"Connection to rtl_tcp timed out\");\n            let _ = ws.send(Message::Text(json!({\"error\": \"Connection timed out\"}).to_string().into())).await;\n            return;\n        }\n    };\n\n    if let Err(e) = tcp_stream.set_nodelay(true) {\n        warn!(\"Failed to set TCP_NODELAY: {}\", e);\n    }\n\n    info!(\"Connected to rtl_tcp at {}\", addr);\n\n    // Read 12-byte dongle info header\n    let (tuner_type, gain_count) = match read_rtl_tcp_header(&mut tcp_stream).await {\n        Ok(h) => h,\n        Err(e) => {\n            error!(\"Failed to read rtl_tcp header: {}\", e);\n            let _ = ws.send(Message::Text(json!({\"error\": format!(\"Header read failed: {}\", e)}).to_string().into())).await;\n            return;\n        }\n    };\n    info!(\"rtl_tcp dongle: tuner_type={}, gain_count={}\", tuner_type, gain_count);\n\n    // Configure device\n    let sdr_samplerate = DEFAULT_SAMPLERATE;\n\n    if let Err(e) = rtl_tcp_command(&mut tcp_stream, RTL_TCP_SET_SAMPLERATE, sdr_samplerate).await {\n        warn!(\"Failed to set sample rate: {}\", e);\n    }\n    if let Err(e) = rtl_tcp_command(&mut tcp_stream, RTL_TCP_SET_GAIN_MODE, 0).await {\n        warn!(\"Failed to set gain mode: {}\", e);\n    }\n    if let Err(e) = rtl_tcp_command(&mut tcp_stream, RTL_TCP_SET_AGC, 1).await {\n        warn!(\"Failed to enable AGC: {}\", e);\n    }\n\n    // Send info to frontend\n    let _ = ws.send(Message::Text(\n        json!({\"type\": \"info\", \"samplerate\": sdr_samplerate, \"audio_rate\": AUDIO_SAMPLE_RATE}).to_string().into()\n    )).await;\n\n    info!(\"rtl_tcp streaming started (sample rate: {} Hz)\", sdr_samplerate);\n\n    // LPF alpha: ~15kHz cutoff, adjusted to actual sample rate\n    let lpf_alpha = (2.0 * std::f32::consts::PI * 15000.0 / sdr_samplerate as f32)\n        .clamp(0.01, 1.0);\n\n    let mut demod_state = FmDemodState::new();\n    let mut first_packet = true;\n\n    // Read buffer for IQ data (16K samples = 32K bytes at uint8 IQ)\n    let mut iq_buf = vec![0u8; 32768];\n\n    // Main loop: read IQ data from rtl_tcp and handle WebSocket messages\n    loop {\n        tokio::select! {\n            // Read from WebSocket (client control messages)\n            ws_msg = ws.recv() => {\n                match ws_msg {\n                    Some(Ok(Message::Text(text))) => {\n                        if let Ok(parsed) = serde_json::from_str::<Value>(&text) {\n                            if parsed.get(\"type\").and_then(|t| t.as_str()) == Some(\"set_frequency\") {\n                                if let Some(freq) = parsed.get(\"value\").and_then(|v| v.as_f64()) {\n                                    let _ = rtl_tcp_command(&mut tcp_stream, RTL_TCP_SET_FREQ, freq as u32).await;\n                                }\n                            } else if parsed.get(\"type\").and_then(|t| t.as_str()) == Some(\"stop\") {\n                                break;\n                            }\n                        }\n                    }\n                    Some(Ok(Message::Close(_))) | None => break,\n                    _ => {}\n                }\n            }\n            // Read IQ data from rtl_tcp (continuous stream, no packet headers)\n            tcp_result = tcp_stream.read(&mut iq_buf) => {\n                let n = match tcp_result {\n                    Ok(0) => break,   // Connection closed\n                    Ok(n) => n,\n                    Err(_) => {\n                        warn!(\"TCP read error from rtl_tcp\");\n                        break;\n                    }\n                };\n\n                // Ensure even number of bytes (IQ pairs)\n                let n = n & !1;\n                if n < 2 {\n                    continue;\n                }\n\n                // Log first packet for debugging\n                if first_packet {\n                    first_packet = false;\n                    info!(\"rtl_tcp first data: {} bytes, first 8 bytes: {:?}\",\n                        n, &iq_buf[..8.min(n)]);\n                }\n\n                // Convert uint8 IQ to float32\n                let iq_samples = u8_iq_to_float(&iq_buf[..n]);\n\n                // FM demodulate\n                let audio = fm_demodulate(&iq_samples, &mut demod_state, lpf_alpha);\n                if audio.is_empty() {\n                    continue;\n                }\n\n                // Downsample to 48kHz and convert to int16\n                let pcm_bytes = downsample_to_i16(&audio, sdr_samplerate, AUDIO_SAMPLE_RATE);\n                if pcm_bytes.is_empty() {\n                    continue;\n                }\n\n                // Send as binary WebSocket frame\n                if ws.send(Message::Binary(pcm_bytes.into())).await.is_err() {\n                    break;\n                }\n            }\n        }\n    }\n\n    info!(\"rtl_tcp audio streaming ended\");\n}\n"
  },
  {
    "path": "apps/server/src/handler/stat.rs",
    "content": "use crate::{\n    db::{self},\n    error::AppError,\n    handler::auth::AuthUser,\n    utils::entity_map::remap_topics,\n    state::AppState,\n};\nuse axum::{extract::State, Json};\nuse serde_json::json;\nuse std::sync::Arc;\n\npub async fn get_stats(\n    AuthUser(_user): AuthUser,\n    State(state): State<Arc<AppState>>,\n) -> Result<Json<serde_json::Value>, AppError> {\n    let entities = db::repository::get_all_entities(&state.pool)?;\n    let devices = db::repository::get_all_devices(&state.pool)?;\n\n    remap_topics(State(state)).await?;\n\n    Ok(Json(json!({\n        \"count\": {\n            \"entities\": entities.len(),\n            \"devices\": devices.len()\n        },\n    })))\n}\n"
  },
  {
    "path": "apps/server/src/handler/state.rs",
    "content": "use axum::{\n    extract::{Path, State},\n    http::StatusCode,\n    response::IntoResponse,\n    Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\nuse tracing::error;\n\nuse crate::{\n    db::repository::set_entity_state,\n    handler::auth::DeviceTokenAuth,\n    state::{AppState, Protocol},\n};\n\n#[derive(Deserialize)]\npub struct StateRequest {\n    pub state: String,\n}\n\n#[derive(Serialize)]\npub struct StateResponse {\n    status: String,\n}\n\npub async fn set_state(\n    State(state): State<Arc<AppState>>,\n    Path(topic): Path<String>,\n    DeviceTokenAuth { device: auth }: DeviceTokenAuth,\n    Json(payload): Json<StateRequest>,\n) -> impl IntoResponse {\n    let topic_map = state.topic_map.read().await;\n    let matched = topic_map\n        .iter()\n        .find(|m| m.protocol == Protocol::Http && m.topic == topic);\n\n    if let Some(mapping) = matched {\n        if let Ok(s) = set_entity_state(&state.pool, &mapping.entity_id, &payload.state, None) {\n            let ws_message = json!({\n                \"type\": \"change_state\",\n                \"payload\": {\n                    \"entity_id\": mapping.entity_id,\n                    \"state\": s.clone()\n                }\n            });\n            if let Ok(payload_str) = serde_json::to_string(&ws_message) {\n                if state.broadcast_tx.send(payload_str).is_err() {\n                    error!(\"Failed to broadcast state change.\");\n                }\n            }\n        } else {\n            error!(\"Failed to set entity state for '{}'\", mapping.entity_id);\n        }\n    }\n\n    (\n        StatusCode::OK,\n        Json(StateResponse {\n            status: \"true\".to_string(),\n        }),\n    )\n}\n"
  },
  {
    "path": "apps/server/src/handler/storage.rs",
    "content": "// src/handler/storage_handler.rs\n\nuse axum::{\n    extract::{Path, State},\n    http::StatusCode,\n    response::{IntoResponse, Response},\n    Json,\n};\nuse serde::{Deserialize, Serialize};\nuse std::{\n    fs, io,\n    path::{Path as StdPath, PathBuf},\n    sync::Arc,\n};\n\nuse crate::db::repository;\nuse crate::handler::auth::AuthUser;\nuse crate::state::{AppState, DbPool};\n\n#[derive(Serialize)]\nstruct ErrorResponse {\n    error: String,\n}\n\n#[derive(Serialize)]\nstruct SuccessResponse {\n    message: String,\n}\n\n#[derive(Deserialize)]\npub struct UpdateContentRequest {\n    content: String,\n}\n\n#[derive(Deserialize)]\npub struct RenameRequest {\n    to: String,\n}\n\n#[derive(Serialize, Debug)]\n#[serde(rename_all = \"camelCase\")]\npub struct DirEntry {\n    name: String,\n    is_dir: bool,\n}\n\n#[derive(Serialize)]\npub struct ListResponse {\n    path: String,\n    entries: Vec<DirEntry>,\n}\n\nfn get_storage_root() -> io::Result<PathBuf> {\n    let storage_path = std::env::current_dir()?.join(\"storage\");\n    fs::create_dir_all(&storage_path)?;\n    Ok(storage_path)\n}\n\nfn get_safe_path(root: &StdPath, user_path: &str) -> Result<PathBuf, io::Error> {\n    let path = user_path.trim_start_matches('/');\n    let safe_path = root.join(path);\n\n    if safe_path.ancestors().any(|a| a == StdPath::new(\"..\")) || !safe_path.starts_with(root) {\n        return Err(io::Error::new(\n            io::ErrorKind::InvalidInput,\n            \"Path traversal attempt detected\",\n        ));\n    }\n    Ok(safe_path)\n}\n\nfn map_io_error(e: io::Error, path_str: &str) -> Response {\n    let (status, msg) = match e.kind() {\n        io::ErrorKind::NotFound => (\n            StatusCode::NOT_FOUND,\n            format!(\"Path not found: {}\", path_str),\n        ),\n        io::ErrorKind::PermissionDenied => (\n            StatusCode::FORBIDDEN,\n            format!(\"Permission denied for: {}\", path_str),\n        ),\n        io::ErrorKind::InvalidInput => (StatusCode::BAD_REQUEST, e.to_string()),\n        _ => (\n            StatusCode::INTERNAL_SERVER_ERROR,\n            format!(\"Internal server error for: {}. Details: {}\", path_str, e),\n        ),\n    };\n    (status, Json(ErrorResponse { error: msg })).into_response()\n}\n\nfn guard_code_service(pool: &DbPool) -> Result<(), Response> {\n    match repository::is_code_service_enabled(pool) {\n        Ok(true) => Ok(()),\n        Ok(false) => Err((\n            StatusCode::FORBIDDEN,\n            Json(ErrorResponse {\n                error: \"Code workspace is disabled\".to_string(),\n            }),\n        )\n            .into_response()),\n        Err(e) => {\n            tracing::error!(\"code service enabled check failed: {e}\");\n            Err((\n                StatusCode::INTERNAL_SERVER_ERROR,\n                Json(ErrorResponse {\n                    error: \"Failed to read configuration\".to_string(),\n                }),\n            )\n                .into_response())\n        }\n    }\n}\n\npub async fn read_handler(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    path: Option<Path<String>>,\n) -> Response {\n    if let Err(resp) = guard_code_service(&state.pool) {\n        return resp;\n    }\n    let path_str = path.map_or(String::new(), |p| p.0);\n\n    let storage_root = match get_storage_root() {\n        Ok(r) => r,\n        Err(e) => return map_io_error(e, &path_str),\n    };\n    let safe_path = match get_safe_path(&storage_root, &path_str) {\n        Ok(p) => p,\n        Err(e) => return map_io_error(e, &path_str),\n    };\n\n    if safe_path.is_dir() {\n        list_dir_contents(&safe_path, &path_str).await\n    } else {\n        read_file_content(&safe_path, &path_str).await\n    }\n}\n\nasync fn list_dir_contents(path: &PathBuf, user_path: &str) -> Response {\n    match fs::read_dir(path) {\n        Ok(entries) => {\n            let mut dir_entries = Vec::new();\n            for entry in entries {\n                if let Ok(entry) = entry {\n                    dir_entries.push(DirEntry {\n                        name: entry.file_name().to_string_lossy().to_string(),\n                        is_dir: entry.path().is_dir(),\n                    });\n                }\n            }\n            (\n                StatusCode::OK,\n                Json(ListResponse {\n                    path: user_path.to_string(),\n                    entries: dir_entries,\n                }),\n            )\n                .into_response()\n        }\n        Err(e) => map_io_error(e, user_path),\n    }\n}\n\nasync fn read_file_content(path: &PathBuf, user_path: &str) -> Response {\n    match fs::read_to_string(path) {\n        Ok(content) => (StatusCode::OK, content).into_response(),\n        Err(e) => map_io_error(e, user_path),\n    }\n}\n\npub async fn create_or_update_file_handler(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(path): Path<String>,\n    Json(payload): Json<UpdateContentRequest>,\n) -> Response {\n    if let Err(resp) = guard_code_service(&state.pool) {\n        return resp;\n    }\n    let storage_root = match get_storage_root() {\n        Ok(r) => r,\n        Err(e) => return map_io_error(e, &path),\n    };\n    let safe_path = match get_safe_path(&storage_root, &path) {\n        Ok(p) => p,\n        Err(e) => return map_io_error(e, &path),\n    };\n\n    if let Some(parent) = safe_path.parent() {\n        if let Err(e) = fs::create_dir_all(parent) {\n            return map_io_error(e, &path);\n        }\n    }\n\n    match fs::write(&safe_path, payload.content) {\n        Ok(_) => {\n            let message = if safe_path.exists() {\n                \"File updated successfully\"\n            } else {\n                \"File created successfully\"\n            };\n            (\n                StatusCode::OK,\n                Json(SuccessResponse {\n                    message: message.to_string(),\n                }),\n            )\n                .into_response()\n        }\n        Err(e) => map_io_error(e, &path),\n    }\n}\n\npub async fn create_dir_handler(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(path): Path<String>,\n) -> Response {\n    if let Err(resp) = guard_code_service(&state.pool) {\n        return resp;\n    }\n    let storage_root = match get_storage_root() {\n        Ok(r) => r,\n        Err(e) => return map_io_error(e, &path),\n    };\n    let safe_path = match get_safe_path(&storage_root, &path) {\n        Ok(p) => p,\n        Err(e) => return map_io_error(e, &path),\n    };\n\n    match fs::create_dir_all(&safe_path) {\n        Ok(_) => (\n            StatusCode::CREATED,\n            Json(SuccessResponse {\n                message: \"Directory created successfully\".to_string(),\n            }),\n        )\n            .into_response(),\n        Err(e) => map_io_error(e, &path),\n    }\n}\n\npub async fn delete_handler(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(path): Path<String>,\n) -> Response {\n    if let Err(resp) = guard_code_service(&state.pool) {\n        return resp;\n    }\n    let storage_root = match get_storage_root() {\n        Ok(r) => r,\n        Err(e) => return map_io_error(e, &path),\n    };\n    let safe_path = match get_safe_path(&storage_root, &path) {\n        Ok(p) => p,\n        Err(e) => return map_io_error(e, &path),\n    };\n\n    let result = if safe_path.is_dir() {\n        fs::remove_dir_all(&safe_path)\n    } else {\n        fs::remove_file(&safe_path)\n    };\n\n    match result {\n        Ok(_) => (\n            StatusCode::OK,\n            Json(SuccessResponse {\n                message: \"Item deleted successfully\".to_string(),\n            }),\n        )\n            .into_response(),\n        Err(e) => map_io_error(e, &path),\n    }\n}\n\npub async fn rename_handler(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(from_path): Path<String>,\n    Json(payload): Json<RenameRequest>,\n) -> Response {\n    if let Err(resp) = guard_code_service(&state.pool) {\n        return resp;\n    }\n    let storage_root = match get_storage_root() {\n        Ok(r) => r,\n        Err(e) => return map_io_error(e, &from_path),\n    };\n    let safe_from_path = match get_safe_path(&storage_root, &from_path) {\n        Ok(p) => p,\n        Err(e) => return map_io_error(e, &from_path),\n    };\n    let safe_to_path = match get_safe_path(&storage_root, &payload.to) {\n        Ok(p) => p,\n        Err(e) => return map_io_error(e, &payload.to),\n    };\n\n    if !safe_from_path.exists() {\n        return map_io_error(\n            io::Error::new(io::ErrorKind::NotFound, \"Source path does not exist\"),\n            &from_path,\n        );\n    }\n\n    match fs::rename(&safe_from_path, &safe_to_path) {\n        Ok(_) => (\n            StatusCode::OK,\n            Json(SuccessResponse {\n                message: \"Item renamed successfully\".to_string(),\n            }),\n        )\n            .into_response(),\n        Err(e) => map_io_error(e, &from_path),\n    }\n}\n"
  },
  {
    "path": "apps/server/src/handler/streams.rs",
    "content": "use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};\nuse rand::Rng;\nuse serde::{Deserialize, Serialize};\nuse std::sync::Arc;\nuse tracing::info;\n\nuse crate::{\n    db::{self, models::NewStream},\n    handler::auth::DeviceTokenAuth,\n    state::{AppState, MediaType, Protocol, StreamDescriptor},\n};\n\n#[derive(Deserialize)]\npub struct RegisterStreamRequest {\n    pub topic: String,\n    pub media_type: MediaType,\n}\n\n#[derive(Serialize)]\npub struct RegisterStreamResponse {\n    ssrc: u32,\n    rtp_port: u16,\n}\n\npub async fn register_stream(\n    State(state): State<Arc<AppState>>,\n    DeviceTokenAuth { device: auth }: DeviceTokenAuth,\n    Json(payload): Json<RegisterStreamRequest>,\n) -> impl IntoResponse {\n    let ssrc = rand::rngs::ThreadRng::default().random_range(0..=i32::MAX) as u32;\n\n    let media_type_str = match payload.media_type {\n        MediaType::Audio => \"audio\",\n        MediaType::Video => \"video\",\n    };\n\n    let new_stream_db = NewStream {\n        ssrc: ssrc as i32,\n        topic: &payload.topic,\n        device_id: &auth.device_id,\n        media_type: media_type_str,\n    };\n\n    let _db_stream = match db::repository::streams::upsert_stream(&state.pool, &new_stream_db) {\n        Ok(stream) => stream,\n        Err(e) => {\n            tracing::error!(\"Failed to upsert stream to database: {}\", e);\n            return (StatusCode::INTERNAL_SERVER_ERROR).into_response();\n        }\n    };\n\n    let descriptor = StreamDescriptor {\n        id: ssrc,\n        topic: payload.topic.clone(),\n        user_id: auth.device_id,\n        media_type: payload.media_type,\n        protocol: Protocol::Udp,\n    };\n\n    state.streams.register(descriptor);\n\n    info!(\n        \"[API] SSRC {} for topic '{}' inserted. Manager size: {}\",\n        ssrc,\n        payload.topic,\n        state.streams.len()\n    );\n\n    info!(\n        \"New stream registered. Topic: '{}', SSRC: {}, Port: 5004\",\n        payload.topic, ssrc\n    );\n\n    let configs = match db::repository::get_all_system_configs(&state.pool) {\n        Ok(configs) => configs,\n        Err(e) => {\n            tracing::error!(\"Failed to get system configs: {}\", e);\n            return (StatusCode::INTERNAL_SERVER_ERROR).into_response();\n        }\n    };\n\n    let rtp_port = configs\n        .iter()\n        .find(|c| c.key == \"rtp_broker_port\")\n        .and_then(|c| c.value.parse::<u16>().ok())\n        .unwrap_or(5004);\n\n    (\n        StatusCode::OK,\n        Json(RegisterStreamResponse { ssrc, rtp_port }),\n    )\n        .into_response()\n}\n"
  },
  {
    "path": "apps/server/src/handler/tunnel.rs",
    "content": "use std::sync::Arc;\n\nuse axum::{extract::State, http::StatusCode, Json};\nuse serde::{Deserialize, Serialize};\n\nuse crate::{state::AppState, tunnel_control::TunnelManager};\n\n#[derive(Deserialize)]\npub struct StartRequest {\n    pub server: String,\n    pub target: String,\n    pub access_token: Option<String>,\n}\n\n#[derive(Serialize)]\npub struct StartResponse {\n    pub session_id: String,\n}\n\n#[derive(Serialize)]\npub struct StatusResponse {\n    pub active: bool,\n    pub session_id: Option<String>,\n    pub server: Option<String>,\n    pub target: Option<String>,\n}\n\npub async fn start_tunnel(\n    State(state): State<Arc<AppState>>,\n    Json(payload): Json<StartRequest>,\n) -> Result<Json<StartResponse>, StatusCode> {\n    let session_id = state\n        .tunnel_manager\n        .start(payload.server, payload.target, payload.access_token)\n        .await\n        .map_err(|_| StatusCode::BAD_GATEWAY)?;\n    Ok(Json(StartResponse { session_id }))\n}\n\npub async fn stop_tunnel(State(state): State<Arc<AppState>>) -> Result<StatusCode, StatusCode> {\n    state\n        .tunnel_manager\n        .stop()\n        .await\n        .map_err(|_| StatusCode::BAD_GATEWAY)?;\n    Ok(StatusCode::NO_CONTENT)\n}\n\npub async fn status_tunnel(State(state): State<Arc<AppState>>) -> Json<StatusResponse> {\n    let status = state.tunnel_manager.status().await;\n    Json(StatusResponse {\n        active: status.active,\n        session_id: status.session_id,\n        server: status.server,\n        target: status.target,\n    })\n}\n"
  },
  {
    "path": "apps/server/src/handler/users.rs",
    "content": "use crate::utils::hash::hash_password;\nuse crate::{\n    db::{\n        self,\n        models::{NewUser, Role, UpdateUser},\n        repository,\n    },\n    error::AppError,\n    handler::auth::AuthUser,\n    state::AppState,\n};\n\nuse axum::{\n    extract::{Path, State},\n    http::StatusCode,\n    Json,\n};\nuse serde::Deserialize;\nuse std::sync::Arc;\n\n#[derive(Deserialize)]\npub struct CreateUserPayload {\n    pub username: String,\n    pub email: String,\n    pub password: String,\n}\n\npub async fn get_users_list(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n) -> Result<Json<Vec<crate::db::models::UserWithRoles>>, AppError> {\n    let users = db::repository::get_all_users(&state.pool)?;\n    Ok(Json(users))\n}\n\npub async fn create_user(\n    State(state): State<Arc<AppState>>,\n    Json(payload): Json<CreateUserPayload>,\n) -> Result<(StatusCode, Json<crate::db::models::User>), AppError> {\n    let hashed_password = hash_password(&payload.password)?;\n\n    let new_user = NewUser {\n        username: &payload.username,\n        email: &payload.email,\n        password_hash: &hashed_password,\n    };\n\n    let user = repository::create_user(&state.pool, new_user)?;\n\n    Ok((StatusCode::CREATED, Json(user)))\n}\n\npub async fn get_user(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(user_id): Path<i32>,\n) -> Result<Json<crate::db::models::User>, AppError> {\n    let user = repository::get_user_by_id(&state.pool, user_id)?;\n    Ok(Json(user))\n}\n\n#[derive(Deserialize, Default)]\npub struct UpdateUserPayload {\n    pub username: Option<String>,\n    pub email: Option<String>,\n    pub password: Option<String>,\n}\n\npub async fn update_user(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(user_id): Path<i32>,\n    Json(payload): Json<UpdateUserPayload>,\n) -> Result<Json<crate::db::models::User>, AppError> {\n    let password_hash = if let Some(password) = payload.password {\n        Some(hash_password(&password)?)\n    } else {\n        None\n    };\n\n    let user_data = UpdateUser {\n        username: payload.username,\n        email: payload.email,\n        password_hash,\n    };\n\n    let updated_user = repository::update_user(&state.pool, user_id, &user_data)?;\n    Ok(Json(updated_user))\n}\n\npub async fn delete_user(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(user_id): Path<i32>,\n) -> Result<StatusCode, AppError> {\n    repository::delete_user(&state.pool, user_id)?;\n    Ok(StatusCode::NO_CONTENT)\n}\n\n#[derive(Deserialize)]\npub struct RoleAssignmentPayload {\n    pub role_id: i32,\n}\n\npub async fn assign_role_to_user(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(user_id): Path<i32>,\n    Json(payload): Json<RoleAssignmentPayload>,\n) -> Result<StatusCode, AppError> {\n    db::repository::rbac::assign_role_to_user(&state.pool, user_id, payload.role_id)?;\n    Ok(StatusCode::CREATED)\n}\n\npub async fn remove_role_from_user(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path((user_id, role_id)): Path<(i32, i32)>,\n) -> Result<StatusCode, AppError> {\n    db::repository::rbac::remove_role_from_user(&state.pool, user_id, role_id)?;\n    Ok(StatusCode::NO_CONTENT)\n}\n\npub async fn get_user_roles(\n    State(state): State<Arc<AppState>>,\n    AuthUser(_user): AuthUser,\n    Path(user_id): Path<i32>,\n) -> Result<Json<Vec<Role>>, AppError> {\n    let roles = db::repository::rbac::get_user_roles(&state.pool, user_id)?;\n    Ok(Json(roles))\n}\n"
  },
  {
    "path": "apps/server/src/handler/ws/dashboard_component_event.rs",
    "content": "//! WebSocket `dashboard_component_event`: dashboard widgets → `dashboard_ui` broadcast.\n\nuse crate::state::{AppState, DashboardUiEvent};\nuse serde::Deserialize;\nuse serde_json::Value;\nuse std::collections::HashMap;\nuse std::time::{Duration, Instant};\nuse tracing::{info, warn};\n\nconst SERVER_MIN_COOLDOWN_MS: u64 = 100;\nconst MAX_LISTENER_ID_LEN: usize = 128;\n\n#[derive(Debug, Deserialize)]\nstruct DashboardComponentEventV2 {\n    pub event_version: u32,\n    pub event_id: String,\n    pub listener_id: String,\n    #[serde(default)]\n    pub occurred_at: Option<String>,\n    #[serde(default)]\n    pub dashboard_id: Option<String>,\n    #[serde(default)]\n    pub group_id: Option<String>,\n    #[serde(default)]\n    pub item_id: Option<String>,\n    pub component_type: String,\n    pub action: String,\n    #[serde(default)]\n    pub value: Option<Value>,\n    pub source_session_id: String,\n    #[serde(default)]\n    pub cooldown_ms: Option<u64>,\n}\n\nfn rate_limit_key(ev: &DashboardComponentEventV2) -> String {\n    format!(\n        \"{}|{}|{}|{}\",\n        ev.source_session_id,\n        ev.listener_id,\n        ev.item_id.as_deref().unwrap_or(\"-\"),\n        ev.action\n    )\n}\n\nfn cooldown_ms(ev: &DashboardComponentEventV2) -> u64 {\n    SERVER_MIN_COOLDOWN_MS.max(ev.cooldown_ms.unwrap_or(320))\n}\n\nfn validate_listener_id(id: &str) -> bool {\n    let t = id.trim();\n    if t.is_empty() || t.len() > MAX_LISTENER_ID_LEN {\n        return false;\n    }\n    t.chars()\n        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')\n}\n\n/// Validates and publishes to `AppState::dashboard_ui_tx`. Updates `rate` when accepted.\npub(crate) fn apply_dashboard_component_event(\n    raw: serde_json::Value,\n    state: &AppState,\n    rate: &mut HashMap<String, Instant>,\n) {\n    let ev: DashboardComponentEventV2 = match serde_json::from_value(raw) {\n        Ok(v) => v,\n        Err(e) => {\n            warn!(\"dashboard_component_event: invalid payload: {}\", e);\n            return;\n        }\n    };\n\n    if ev.event_version != 2 {\n        warn!(\n            \"dashboard_component_event: unsupported event_version {} (need 2)\",\n            ev.event_version\n        );\n        return;\n    }\n\n    if ev.source_session_id.trim().is_empty() {\n        warn!(\"dashboard_component_event: missing source_session_id\");\n        return;\n    }\n\n    let lid = ev.listener_id.trim();\n    if !validate_listener_id(lid) {\n        warn!(\n            \"dashboard_component_event: invalid listener_id (empty, too long, or bad chars)\"\n        );\n        return;\n    }\n\n    let key = rate_limit_key(&ev);\n    let cooldown = Duration::from_millis(cooldown_ms(&ev));\n    let now = Instant::now();\n    if let Some(last) = rate.get(&key) {\n        if now.duration_since(*last) < cooldown {\n            info!(\n                \"dashboard_component_event: rate limited event_id={}\",\n                ev.event_id\n            );\n            return;\n        }\n    }\n    rate.insert(key, now);\n\n    let out = DashboardUiEvent {\n        listener_id: lid.to_string(),\n        event_id: ev.event_id.clone(),\n        occurred_at: ev.occurred_at.clone(),\n        dashboard_id: ev.dashboard_id.clone(),\n        group_id: ev.group_id.clone(),\n        item_id: ev.item_id.clone(),\n        component_type: ev.component_type.clone(),\n        action: ev.action.clone(),\n        value: ev.value.clone(),\n        source_session_id: ev.source_session_id.clone(),\n    };\n\n    info!(\n        event_id = %out.event_id,\n        listener_id = %out.listener_id,\n        component = %out.component_type,\n        action = %out.action,\n        \"dashboard_component_event published\",\n    );\n\n    if state.dashboard_ui_tx.send(out).is_err() {\n        warn!(\"dashboard_component_event: no subscribers (dashboard_ui_tx closed)\");\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn deserializes_v2() {\n        let json = serde_json::json!({\n            \"event_version\": 2,\n            \"event_id\": \"e1\",\n            \"listener_id\": \"my-btn\",\n            \"dashboard_id\": \"d1\",\n            \"group_id\": \"g1\",\n            \"item_id\": \"i1\",\n            \"component_type\": \"button\",\n            \"action\": \"click\",\n            \"source_session_id\": \"sess\",\n        });\n        let ev: DashboardComponentEventV2 = serde_json::from_value(json).unwrap();\n        assert_eq!(ev.listener_id, \"my-btn\");\n        assert_eq!(ev.event_version, 2);\n    }\n\n    #[test]\n    fn listener_id_validation() {\n        assert!(validate_listener_id(\"ok-1\"));\n        assert!(!validate_listener_id(\"\"));\n        assert!(!validate_listener_id(\"bad id\"));\n    }\n}\n"
  },
  {
    "path": "apps/server/src/handler/ws/handlers.rs",
    "content": "use anyhow::{anyhow, Result};\nuse axum::extract::ws::{Message, WebSocket};\nuse futures_util::{future::join_all, stream::SplitSink, FutureExt, SinkExt, StreamExt};\nuse serde::{Deserialize, Serialize};\nuse std::{collections::HashMap, sync::Arc, time::Instant};\nuse sysinfo::System;\nuse tokio::sync::{mpsc, oneshot, Mutex};\nuse tracing::{error, info, warn};\nuse webrtc::{\n    api::media_engine::{MIME_TYPE_H264, MIME_TYPE_OPUS},\n    ice_transport::{\n        ice_candidate::RTCIceCandidateInit,\n        ice_connection_state::RTCIceConnectionState,\n    },\n    peer_connection::{\n        offer_answer_options::RTCOfferOptions,\n        sdp::session_description::RTCSessionDescription,\n        RTCPeerConnection,\n    },\n    rtp_transceiver::rtp_codec::RTCRtpCodecCapability,\n    track::track_local::{\n        track_local_static_rtp::TrackLocalStaticRTP,\n        TrackLocal, TrackLocalWriter,\n    },\n};\n\nuse crate::handler::ws::WsMessageOut;\n\nuse crate::{\n    db,\n    flow::{\n        manager_state::FlowManagerCommand,\n        types::FlowRunContext,\n    },\n    handler::ws::dashboard_component_event,\n    handler::ws::webrtc::create_peer_connection,\n    state::{AppState, MediaType},\n};\n\n#[derive(Deserialize)]\nstruct WsMessageIn {\n    #[serde(rename = \"type\")]\n    msg_type: String,\n    payload: serde_json::Value,\n}\n\n#[derive(Serialize)]\nstruct ServerStats {\n    cpu_usage: f32,\n    memory_usage: f32,\n}\n\n#[derive(Serialize)]\nstruct StreamState {\n    topic: String,\n    is_online: bool,\n}\n\n#[derive(Deserialize)]\nstruct SubscribeStreamPayload {\n    topic: String,\n}\n\n#[derive(Deserialize)]\nstruct FlowPayload {\n    flow_id: i32,\n}\n\n#[derive(Serialize)]\nstruct FlowStatus {\n    id: i32,\n    name: String,\n    is_running: bool,\n}\n\nenum ActorCommand {\n    SetRemoteOffer {\n        offer: RTCSessionDescription,\n        responder: oneshot::Sender<Result<RTCSessionDescription>>,\n    },\n    SetRemoteAnswer {\n        answer: RTCSessionDescription,\n    },\n    AddIceCandidate {\n        candidate: RTCIceCandidateInit,\n    },\n    HealthCheck {\n        payload: serde_json::Value,\n    },\n    ComputeFlow {\n        payload: serde_json::Value,\n    },\n    GetAllStreamState {\n        payload: serde_json::Value,\n    },\n    StopFlow {\n        flow_id: i32,\n    },\n    GetAllFlows {\n        responder: oneshot::Sender<Result<Vec<FlowStatus>>>,\n    },\n    SubscribeToTopic {\n        topic: String,\n    },\n    Ping {\n        payload: serde_json::Value,\n    },\n    GetServer {\n        responder: oneshot::Sender<Result<ServerStats>>,\n    },\n    /// Dynamic dashboard widget → flow (see `dashboard_component_event` WS type).\n    DashboardComponentEvent {\n        payload: serde_json::Value,\n    },\n    Hangup,\n}\n\nstruct WSActor {\n    pc: Arc<RTCPeerConnection>,\n    ws_sender: Arc<Mutex<SplitSink<WebSocket, Message>>>,\n    receiver: mpsc::Receiver<ActorCommand>,\n    state: Arc<AppState>,\n    active_tracks: HashMap<String, Arc<dyn TrackLocal + Send + Sync>>,\n    /// Per-connection rate limit for `dashboard_component_event` keys.\n    dashboard_event_rate: HashMap<String, Instant>,\n}\n\nimpl WSActor {\n    async fn run(mut self) {\n        info!(\"WebRTC Actor started.\");\n        while let Some(cmd) = self.receiver.recv().await {\n            match cmd {\n                ActorCommand::SetRemoteOffer { offer, responder } => {\n                    let pc_clone = Arc::clone(&self.pc);\n                    let res = Self::handle_offer(pc_clone, offer).await;\n                    if responder.send(res).is_err() {\n                        error!(\"Failed to send answer back to handler.\");\n                    }\n                }\n                ActorCommand::SetRemoteAnswer { answer } => {\n                    if let Err(e) = self.pc.set_remote_description(answer).await {\n                        error!(\"Failed to set remote answer for renegotiation: {}\", e);\n                    } else {\n                        info!(\"Renegotiation successful: Remote answer set.\");\n                    }\n                }\n                ActorCommand::AddIceCandidate { candidate } => {\n                    info!(\"Adding ICE candidate to PC: {}\", candidate.candidate);\n                    if let Err(e) = self.pc.add_ice_candidate(candidate).await {\n                        error!(\"Actor failed to add ICE candidate: {}\", e);\n                    }\n                }\n                ActorCommand::HealthCheck { payload } => {\n                    self.handle_health_check(payload).await;\n                }\n                ActorCommand::ComputeFlow { payload } => {\n                    self.handle_compute_flow(payload).await;\n                }\n                ActorCommand::DashboardComponentEvent { payload } => {\n                    dashboard_component_event::apply_dashboard_component_event(\n                        payload,\n                        &self.state,\n                        &mut self.dashboard_event_rate,\n                    );\n                }\n                ActorCommand::StopFlow { flow_id } => {\n                    self.handle_stop_flow(flow_id).await;\n                }\n                ActorCommand::GetAllStreamState { payload } => {\n                    self.handle_get_all_stream_state(payload).await;\n                }\n                ActorCommand::GetAllFlows { responder } => {\n                    self.handle_get_all_flows(responder).await;\n                }\n                ActorCommand::SubscribeToTopic { topic } => {\n                    self.handle_subscribe(topic).await;\n                }\n                ActorCommand::Ping { payload } => {\n                    self.handle_ping(payload).await;\n                }\n                ActorCommand::GetServer { responder } => {\n                    let res = self.handle_get_server().await;\n                    if responder.send(res).is_err() {\n                        error!(\"Failed to send server stats back to handler.\");\n                    }\n                }\n                ActorCommand::Hangup => {\n                    info!(\"Received Hangup. Resetting PeerConnection.\");\n                    if let Err(e) = self.pc.close().await {\n                        error!(\"Failed to close peer connection: {}\", e);\n                    }\n                    self.active_tracks.clear();\n\n                    match create_peer_connection(Arc::clone(&self.ws_sender), &self.state.pool).await {\n                        Ok(new_pc) => {\n                            self.pc = new_pc;\n                            info!(\"PeerConnection has been successfully reset.\");\n                        }\n                        Err(e) => {\n                            error!(\"Failed to create a new peer connection after hangup: {}\", e);\n                            break;\n                        }\n                    }\n                }\n            }\n        }\n        info!(\"WebRTC Actor finished.\");\n    }\n\n    async fn handle_get_all_flows(&self, responder: oneshot::Sender<Result<Vec<FlowStatus>>>) {\n        let (manager_responder_tx, manager_responder_rx) = tokio::sync::oneshot::channel();\n        let cmd = FlowManagerCommand::GetAllFlows {\n            responder: manager_responder_tx,\n            pool: self.state.pool.clone(),\n        };\n\n        if self.state.flow_manager_tx.send(cmd).await.is_err() {\n            let _ = responder.send(Err(anyhow!(\"Failed to communicate with FlowManager\")));\n            return;\n        }\n\n        if let Ok(status_tuples) = manager_responder_rx.await {\n            let all_db_flows = db::repository::get_all_flows(&self.state.pool).unwrap_or_default();\n            let status_map: HashMap<i32, bool> = status_tuples.into_iter().collect();\n\n            let result: Vec<FlowStatus> = all_db_flows\n                .into_iter()\n                .map(|f| FlowStatus {\n                    id: f.id,\n                    name: f.name,\n                    is_running: *status_map.get(&f.id).unwrap_or(&false),\n                })\n                .collect();\n\n            let _ = responder.send(Ok(result));\n        } else {\n            let _ = responder.send(Err(anyhow!(\"Failed to receive response from FlowManager\")));\n        }\n    }\n\n    async fn handle_get_server(&self) -> Result<ServerStats> {\n        tokio::task::spawn_blocking(|| {\n            let mut sys = System::new();\n            sys.refresh_cpu_all();\n            let cpu_usage =\n                sys.cpus().iter().map(|cpu| cpu.cpu_usage()).sum::<f32>() / sys.cpus().len() as f32;\n            sys.refresh_memory();\n            let memory_usage = (sys.used_memory() as f32 / sys.total_memory() as f32) * 100.0;\n            Ok(ServerStats {\n                cpu_usage,\n                memory_usage,\n            })\n        })\n        .await?\n    }\n\n    async fn handle_get_all_stream_state(&self, payload: serde_json::Value) {\n        let stream_futures = self.state.streams.iter().map(|entry| async move {\n            let stream = entry.value();\n            StreamState {\n                topic: stream.descriptor.topic.clone(),\n                is_online: *stream.is_online.read().unwrap(),\n            }\n        });\n\n        let collected_streams: Vec<StreamState> = join_all(stream_futures).await;\n\n        let ws_message = WsMessageOut {\n            msg_type: \"stream_state\",\n            payload: collected_streams,\n        };\n\n        if let Ok(payload_str) = serde_json::to_string(&ws_message) {\n            if self\n                .ws_sender\n                .lock()\n                .await\n                .send(Message::Text(payload_str))\n                .await\n                .is_err()\n            {\n                error!(\"Failed to send health check response.\");\n            }\n        }\n    }\n\n    async fn handle_offer(\n        pc: Arc<RTCPeerConnection>,\n        offer: RTCSessionDescription,\n    ) -> Result<RTCSessionDescription> {\n        info!(\"[handle_offer] Setting remote description...\");\n        pc.set_remote_description(offer).await?;\n        info!(\"[handle_offer] Creating answer...\");\n        let answer = pc.create_answer(None).await?;\n        info!(\"[handle_offer] Setting local description (triggers ICE gathering)...\");\n        pc.set_local_description(answer).await?;\n        info!(\"[handle_offer] Local description set successfully.\");\n        pc.local_description()\n            .await\n            .ok_or_else(|| anyhow!(\"Failed to get local description\"))\n    }\n\n    async fn handle_health_check(&self, payload: serde_json::Value) {\n        #[derive(Serialize)]\n        struct HealthStatus<'a> {\n            status: &'a str,\n            echo: serde_json::Value,\n        }\n        let response_payload = HealthStatus {\n            status: \"ok\",\n            echo: payload,\n        };\n        let ws_message = WsMessageOut {\n            msg_type: \"health_check_response\",\n            payload: response_payload,\n        };\n        if let Ok(payload_str) = serde_json::to_string(&ws_message) {\n            if self\n                .ws_sender\n                .lock()\n                .await\n                .send(Message::Text(payload_str))\n                .await\n                .is_err()\n            {\n                error!(\"Failed to send health check response.\");\n            }\n        }\n    }\n\n    async fn handle_ping(&self, payload: serde_json::Value) {\n        let ws_message = WsMessageOut {\n            msg_type: \"pong\",\n            payload,\n        };\n        if let Ok(payload_str) = serde_json::to_string(&ws_message) {\n            if self\n                .ws_sender\n                .lock()\n                .await\n                .send(Message::Text(payload_str))\n                .await\n                .is_err()\n            {\n                error!(\"Failed to send pong response.\");\n            }\n        }\n    }\n\n    async fn handle_compute_flow(&self, payload: serde_json::Value) {\n        let Some(value) = payload.get(\"flow_id\") else {\n            error!(\"Missing flow_id in payload: {:?}\", payload);\n            return;\n        };\n\n        if !value.is_number() {\n            error!(\"Invalid flow_id in payload: {:?}\", payload);\n            return;\n        }\n\n        let flow_id: i32 = value.as_i64().unwrap_or_default() as i32;\n\n        let versions =\n            db::repository::get_versions_for_flow(&self.state.pool, flow_id as i32).expect(\"msg\");\n        if versions.is_empty() {\n            return;\n        }\n\n        let latest_version = versions[0].clone();\n        let graph = match serde_json::from_str(&latest_version.graph_json) {\n            Ok(g) => g,\n            Err(e) => {\n                error!(\"Failed to parse graph JSON for flow_id {}: {}\", flow_id, e);\n                return;\n            }\n        };\n\n        println!(\"Start Flow\");\n\n        let run_context = payload\n            .get(\"run_context\")\n            .and_then(|rc| rc.get(\"session_id\"))\n            .and_then(|v| v.as_str())\n            .filter(|s| !s.is_empty())\n            .map(|s| FlowRunContext {\n                session_id: s.to_string(),\n            });\n\n        let cmd = FlowManagerCommand::StartFlow {\n            flow_id,\n            graph,\n            broadcast_tx: self.state.broadcast_tx.clone(),\n            run_context,\n        };\n\n        if self.state.flow_manager_tx.send(cmd).await.is_err() {\n            error!(\"Failed to send StartFlow command to FlowManager.\");\n        }\n    }\n\n    async fn handle_stop_flow(&self, flow_id: i32) {\n        let cmd = FlowManagerCommand::StopFlow { flow_id };\n        if self.state.flow_manager_tx.send(cmd).await.is_err() {\n            error!(\"Failed to send StopFlow command to FlowManager.\");\n        }\n    }\n\n    fn extract_ice_credentials(sdp: &str) -> Option<(String, String)> {\n        let mut ufrag = None;\n        let mut pwd = None;\n        for line in sdp.lines() {\n            if line.starts_with(\"a=ice-ufrag:\") {\n                ufrag = Some(line[\"a=ice-ufrag:\".len()..].to_string());\n            } else if line.starts_with(\"a=ice-pwd:\") {\n                pwd = Some(line[\"a=ice-pwd:\".len()..].to_string());\n            }\n            if ufrag.is_some() && pwd.is_some() {\n                break;\n            }\n        }\n        ufrag.zip(pwd)\n    }\n\n    fn patch_ice_credentials(sdp: &str, ufrag: &str, pwd: &str) -> String {\n        let mut result = String::with_capacity(sdp.len());\n        for line in sdp.lines() {\n            if line.starts_with(\"a=ice-ufrag:\") {\n                result.push_str(&format!(\"a=ice-ufrag:{}\", ufrag));\n            } else if line.starts_with(\"a=ice-pwd:\") {\n                result.push_str(&format!(\"a=ice-pwd:{}\", pwd));\n            } else {\n                result.push_str(line);\n            }\n            result.push_str(\"\\r\\n\");\n        }\n        result\n    }\n\n    /// H.264 RTP 패킷이 키프레임(IDR)을 포함하는지 감지.\n    /// NAL 유닛 타입을 검사: Single NAL(5=IDR, 7=SPS), STAP-A(24), FU-A(28).\n    fn is_h264_keyframe(pkt: &webrtc::rtp::packet::Packet) -> bool {\n        if pkt.payload.is_empty() {\n            return false;\n        }\n        let nal_type = pkt.payload[0] & 0x1F;\n        match nal_type {\n            5 | 7 => true, // IDR slice or SPS\n            24 => {\n                // STAP-A: 내부 NAL 중 IDR(5) 또는 SPS(7) 포함 여부\n                let mut offset = 1;\n                while offset + 2 < pkt.payload.len() {\n                    let nalu_size =\n                        ((pkt.payload[offset] as usize) << 8) | (pkt.payload[offset + 1] as usize);\n                    offset += 2;\n                    if offset < pkt.payload.len() {\n                        let inner_nal_type = pkt.payload[offset] & 0x1F;\n                        if inner_nal_type == 5 || inner_nal_type == 7 {\n                            return true;\n                        }\n                    }\n                    offset += nalu_size;\n                }\n                false\n            }\n            28 => {\n                // FU-A: fragmented NAL — start bit + IDR type\n                if pkt.payload.len() > 1 {\n                    let fu_header = pkt.payload[1];\n                    let start_bit = fu_header & 0x80 != 0;\n                    let fu_nal_type = fu_header & 0x1F;\n                    start_bit && (fu_nal_type == 5 || fu_nal_type == 7)\n                } else {\n                    false\n                }\n            }\n            _ => false,\n        }\n    }\n\n    async fn handle_subscribe(&mut self, topic: String) {\n        let mut subscribed = false;\n\n        if self.active_tracks.contains_key(&topic) {\n            warn!(\"Topic '{}' is already subscribed.\", topic);\n            return;\n        }\n\n        let audio_stream_info = self\n            .state\n            .streams\n            .iter()\n            .find(|entry| {\n                entry.value().descriptor.topic == topic\n                    && entry.value().descriptor.media_type == MediaType::Audio\n            })\n            .map(|entry| (*entry.key(), entry.value().clone()));\n\n        let video_stream_info = self\n            .state\n            .streams\n            .iter()\n            .find(|entry| {\n                entry.value().descriptor.topic == topic\n                    && entry.value().descriptor.media_type == MediaType::Video\n            })\n            .map(|entry| (*entry.key(), entry.value().clone()));\n\n        if let Some((ssrc, info)) = audio_stream_info {\n            subscribed = true;\n            info!(\n                \"[Audio] Subscribing to topic '{}' with SSRC {}\",\n                topic, ssrc\n            );\n\n            let new_audio_track = Arc::new(TrackLocalStaticRTP::new(\n                RTCRtpCodecCapability {\n                    mime_type: MIME_TYPE_OPUS.to_owned(),\n                    ..Default::default()\n                },\n                format!(\"audio-{}\", &topic),\n                format!(\"webrtc-stream-audio-{}\", &topic),\n            ));\n\n            let rtp_sender = self\n                .pc\n                .add_track(Arc::clone(&new_audio_track) as Arc<dyn TrackLocal + Send + Sync>)\n                .await\n                .unwrap();\n\n            self.active_tracks.insert(\n                topic.clone(),\n                new_audio_track.clone() as Arc<dyn TrackLocal + Send + Sync>,\n            );\n\n            tokio::spawn(async move {\n                let mut rtcp_buf = vec![0u8; 1500];\n                while rtp_sender.read(&mut rtcp_buf).await.is_ok() {}\n            });\n\n            let mut rx = info.packet_tx.subscribe();\n\n            let audio_sender = self.pc\n                .get_senders()\n                .await\n                .into_iter()\n                .find(|s| {\n                    if let Some(track) = s.track().now_or_never().and_then(|t| t) {\n                        track.id() == new_audio_track.id()\n                    } else {\n                        false\n                    }\n                })\n                .expect(\"Audio sender not found for the specific track\");\n\n            let parameters = audio_sender.get_parameters().await;\n            let negotiated_pt = parameters\n                .rtp_parameters\n                .codecs\n                .get(0)\n                .unwrap()\n                .payload_type;\n            let negotiated_ssrc = parameters.encodings.get(0).unwrap().ssrc;\n\n            info!(\n                \"[Audio] RTP forwarding ready. Negotiated PT: {}, SSRC: {}\",\n                negotiated_pt, negotiated_ssrc\n            );\n\n            tokio::spawn(async move {\n                let mut lag_count: u64 = 0;\n                loop {\n                    match rx.recv().await {\n                        Ok(mut pkt) => {\n                            pkt.header.payload_type = negotiated_pt;\n                            pkt.header.ssrc = negotiated_ssrc;\n\n                            if let Err(e) = new_audio_track.write_rtp(&pkt).await {\n                                warn!(\n                                    \"[Audio] write_rtp failed for SSRC {}: {}. Stopping.\",\n                                    ssrc, e\n                                );\n                                break;\n                            }\n                        }\n                        Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {\n                            lag_count += n;\n                            warn!(\n                                \"[Audio] Broadcast receiver lagged {} packets (total: {}) for SSRC {}\",\n                                n, lag_count, ssrc\n                            );\n                            continue;\n                        }\n                        Err(tokio::sync::broadcast::error::RecvError::Closed) => {\n                            info!(\"[Audio] Broadcast channel closed for SSRC {}\", ssrc);\n                            break;\n                        }\n                    }\n                }\n                info!(\"[Audio] RTP packet forwarding stopped for SSRC: {} (total lagged: {})\", ssrc, lag_count);\n            });\n        }\n\n        if let Some((ssrc, info)) = video_stream_info {\n            subscribed = true;\n            info!(\n                \"[Video-UDP] Subscribing to topic '{}' with SSRC {}\",\n                topic, ssrc\n            );\n\n            let new_udp_video_track = Arc::new(TrackLocalStaticRTP::new(\n                RTCRtpCodecCapability {\n                    mime_type: MIME_TYPE_H264.to_owned(),\n                    ..Default::default()\n                },\n                format!(\"video-udp-{}\", &topic),\n                format!(\"webrtc-stream-udp-{}\", &topic),\n            ));\n\n            let rtp_sender = self\n                .pc\n                .add_track(Arc::clone(&new_udp_video_track) as Arc<dyn TrackLocal + Send + Sync>)\n                .await\n                .unwrap();\n\n            self.active_tracks.insert(\n                topic.clone(),\n                new_udp_video_track.clone() as Arc<dyn TrackLocal + Send + Sync>,\n            );\n\n            tokio::spawn(async move {\n                let mut rtcp_buf = vec![0u8; 1500];\n                while rtp_sender.read(&mut rtcp_buf).await.is_ok() {}\n            });\n\n            let mut rx = info.packet_tx.subscribe();\n\n            let video_sender = self.pc\n                .get_senders()\n                .await\n                .into_iter()\n                .find(|s| {\n                    if let Some(track) = s.track().now_or_never().and_then(|t| t) {\n                        track.id() == new_udp_video_track.id()\n                    } else {\n                        false\n                    }\n                })\n                .expect(\"Video sender not found for the specific UDP track\");\n\n            let parameters = video_sender.get_parameters().await;\n            let negotiated_pt = parameters\n                .rtp_parameters\n                .codecs\n                .get(0)\n                .unwrap()\n                .payload_type;\n            let negotiated_ssrc = parameters.encodings.get(0).unwrap().ssrc;\n\n            info!(\n                \"[Video-UDP] RTP forwarding ready. Negotiated PT: {}, SSRC: {}\",\n                negotiated_pt, negotiated_ssrc\n            );\n\n            tokio::spawn(async move {\n                let mut lag_count: u64 = 0;\n                // 새 구독자는 키프레임(IDR)을 받을 때까지 대기\n                let mut waiting_for_keyframe = true;\n                info!(\"[Video-UDP] Waiting for keyframe before forwarding SSRC {}\", ssrc);\n                loop {\n                    match rx.recv().await {\n                        Ok(mut pkt) => {\n                            if waiting_for_keyframe {\n                                if WSActor::is_h264_keyframe(&pkt) {\n                                    waiting_for_keyframe = false;\n                                    info!(\"[Video-UDP] Keyframe detected, starting forwarding for SSRC {}\", ssrc);\n                                } else {\n                                    continue; // 키프레임이 아니면 스킵\n                                }\n                            }\n\n                            pkt.header.payload_type = negotiated_pt;\n                            pkt.header.ssrc = negotiated_ssrc;\n\n                            if let Err(e) = new_udp_video_track.write_rtp(&pkt).await {\n                                warn!(\n                                    \"[Video-UDP] write_rtp failed for SSRC {}: {}. Stopping.\",\n                                    ssrc, e\n                                );\n                                break;\n                            }\n                        }\n                        Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {\n                            lag_count += n;\n                            waiting_for_keyframe = true; // 패킷 드롭 후 키프레임 대기\n                            warn!(\n                                \"[Video-UDP] Broadcast receiver lagged {} packets (total: {}) for SSRC {}, waiting for keyframe\",\n                                n, lag_count, ssrc\n                            );\n                            continue;\n                        }\n                        Err(tokio::sync::broadcast::error::RecvError::Closed) => {\n                            info!(\"[Video-UDP] Broadcast channel closed for SSRC {}\", ssrc);\n                            break;\n                        }\n                    }\n                }\n                info!(\n                    \"[Video-UDP] RTP packet forwarding stopped for SSRC: {} (total lagged: {})\",\n                    ssrc, lag_count\n                );\n            });\n        }\n\n        if subscribed {\n            let ice_state = self.pc.ice_connection_state();\n            let ice_restart = matches!(\n                ice_state,\n                RTCIceConnectionState::Failed | RTCIceConnectionState::Disconnected\n            );\n            let offer_options = if ice_restart {\n                info!(\"ICE is {:?}, triggering ICE restart in renegotiation offer\", ice_state);\n                Some(RTCOfferOptions {\n                    ice_restart: true,\n                    ..Default::default()\n                })\n            } else {\n                None\n            };\n\n            // webrtc-rs 버그 워크어라운드: set_local_description()이 ICE agent의 local credentials를\n            // 업데이트하지 않으므로, ICE restart가 아닌 renegotiation에서는 기존 credentials를 보존해야 함.\n            // ICE restart 시에는 ICE agent가 새 credentials로 restart되므로 패칭하지 않음.\n            let current_creds = if !ice_restart {\n                self.pc.local_description().await\n                    .and_then(|desc| Self::extract_ice_credentials(&desc.sdp))\n            } else {\n                None\n            };\n\n            info!(\"Track added. Starting renegotiation (ice_restart={})...\", ice_restart);\n            match self.pc.create_offer(offer_options).await {\n                Ok(offer) => {\n                    let final_offer = if let Some((ref ufrag, ref pwd)) = current_creds {\n                        let patched_sdp = Self::patch_ice_credentials(&offer.sdp, ufrag, pwd);\n                        info!(\"Patched renegotiation offer with existing ICE credentials (ufrag={})\", ufrag);\n                        RTCSessionDescription::offer(patched_sdp).unwrap()\n                    } else {\n                        offer\n                    };\n\n                    if self.pc.set_local_description(final_offer).await.is_ok() {\n                        if let Some(local_desc) = self.pc.local_description().await {\n                            let send_desc = if let Some((ref ufrag, ref pwd)) = current_creds {\n                                let patched_sdp = Self::patch_ice_credentials(&local_desc.sdp, ufrag, pwd);\n                                RTCSessionDescription::offer(patched_sdp).unwrap()\n                            } else {\n                                local_desc\n                            };\n                            let msg = WsMessageOut {\n                                msg_type: \"offer\",\n                                payload: send_desc,\n                            };\n                            if let Ok(payload_str) = serde_json::to_string(&msg) {\n                                if self\n                                    .ws_sender\n                                    .lock()\n                                    .await\n                                    .send(Message::Text(payload_str))\n                                    .await\n                                    .is_err()\n                                {\n                                    error!(\"Failed to send renegotiation offer to client.\");\n                                } else {\n                                    info!(\"Renegotiation offer sent to client.\");\n                                }\n                            }\n                        }\n                    }\n                }\n                Err(e) => error!(\"Failed to create renegotiation offer: {}\", e),\n            }\n        } else {\n            warn!(\n                \"Could not find any stream to subscribe for topic: {}\",\n                topic\n            );\n        }\n    }\n}\n\npub async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {\n    let (ws_sender, mut ws_receiver) = socket.split();\n    let ws_sender = Arc::new(Mutex::new(ws_sender));\n\n    let mut mqtt_rx = state.mqtt_tx.subscribe();\n    let ws_sender_for_mqtt = Arc::clone(&ws_sender);\n    tokio::spawn(async move {\n        while let Ok(msg) = mqtt_rx.recv().await {\n            let ws_message = WsMessageOut {\n                msg_type: \"mqtt_message\",\n                payload: &msg,\n            };\n            if let Ok(payload_str) = serde_json::to_string(&ws_message) {\n                if ws_sender_for_mqtt\n                    .lock()\n                    .await\n                    .send(Message::Text(payload_str))\n                    .await\n                    .is_err()\n                {\n                    break;\n                }\n            }\n        }\n    });\n\n    let mut broadcast_rx = state.broadcast_tx.subscribe();\n    let ws_sender_broadcast = Arc::clone(&ws_sender);\n    tokio::spawn(async move {\n        while let Ok(msg) = broadcast_rx.recv().await {\n            if ws_sender_broadcast\n                .lock()\n                .await\n                .send(Message::Text(msg))\n                .await\n                .is_err()\n            {\n                break;\n            }\n        }\n    });\n\n    let (cmd_tx, cmd_rx) = mpsc::channel(10);\n\n    let initial_pc = match create_peer_connection(Arc::clone(&ws_sender), &state.pool).await {\n        Ok(pc) => pc,\n        Err(e) => {\n            error!(\"Failed to create initial peer connection: {}\", e);\n            return;\n        }\n    };\n\n    tokio::spawn({\n        let state = Arc::clone(&state);\n        let ws_sender = Arc::clone(&ws_sender);\n\n        async move {\n            let actor = WSActor {\n                pc: initial_pc,\n                ws_sender,\n                receiver: cmd_rx,\n                state: Arc::clone(&state),\n                active_tracks: HashMap::new(),\n                dashboard_event_rate: HashMap::new(),\n            };\n            actor.run().await;\n        }\n    });\n\n    while let Some(Ok(msg)) = ws_receiver.next().await {\n        if let Message::Text(text) = msg {\n            let ws_msg: WsMessageIn = match serde_json::from_str(&text) {\n                Ok(json) => json,\n                Err(e) => {\n                    error!(\"Failed to parse incoming message: {}\", e);\n                    continue;\n                }\n            };\n\n            match ws_msg.msg_type.as_str() {\n                \"offer\" => {\n                    if let Ok(offer) = serde_json::from_value(ws_msg.payload) {\n                        let (responder_tx, responder_rx) = oneshot::channel();\n                        let cmd = ActorCommand::SetRemoteOffer {\n                            offer,\n                            responder: responder_tx,\n                        };\n                        if cmd_tx.send(cmd).await.is_err() {\n                            break;\n                        }\n                        // Spawn response handling so the WS loop can immediately\n                        // continue reading messages (critical for ICE candidates).\n                        let ws_sender_clone = Arc::clone(&ws_sender);\n                        tokio::spawn(async move {\n                            if let Ok(Ok(answer)) = responder_rx.await {\n                                let ws_answer = WsMessageOut {\n                                    msg_type: \"answer\",\n                                    payload: &answer,\n                                };\n                                if let Ok(payload_str) = serde_json::to_string(&ws_answer) {\n                                    let _ = ws_sender_clone\n                                        .lock()\n                                        .await\n                                        .send(Message::Text(payload_str))\n                                        .await;\n                                }\n                            }\n                        });\n                    }\n                }\n                \"answer\" => {\n                    if let Ok(answer) = serde_json::from_value(ws_msg.payload) {\n                        let cmd = ActorCommand::SetRemoteAnswer { answer };\n                        if cmd_tx.send(cmd).await.is_err() {\n                            break;\n                        }\n                    }\n                }\n                \"candidate\" => {\n                    match serde_json::from_value::<RTCIceCandidateInit>(ws_msg.payload.clone()) {\n                        Ok(candidate) => {\n                            info!(\"Received ICE candidate from client: {}\", candidate.candidate);\n                            let cmd = ActorCommand::AddIceCandidate { candidate };\n                            if cmd_tx.send(cmd).await.is_err() {\n                                break;\n                            }\n                        }\n                        Err(e) => {\n                            error!(\"Failed to deserialize ICE candidate: {}. Payload: {}\", e, ws_msg.payload);\n                        }\n                    }\n                }\n                \"health_check\" => {\n                    let cmd = ActorCommand::HealthCheck {\n                        payload: ws_msg.payload,\n                    };\n                    if cmd_tx.send(cmd).await.is_err() {\n                        break;\n                    }\n                }\n                \"get_all_stream_state\" => {\n                    let cmd = ActorCommand::GetAllStreamState {\n                        payload: ws_msg.payload,\n                    };\n                    if cmd_tx.send(cmd).await.is_err() {\n                        break;\n                    }\n                }\n                \"compute_flow\" => {\n                    let cmd = ActorCommand::ComputeFlow {\n                        payload: ws_msg.payload,\n                    };\n                    if cmd_tx.send(cmd).await.is_err() {\n                        break;\n                    }\n                }\n                \"dashboard_component_event\" => {\n                    let cmd = ActorCommand::DashboardComponentEvent {\n                        payload: ws_msg.payload,\n                    };\n                    if cmd_tx.send(cmd).await.is_err() {\n                        break;\n                    }\n                }\n                \"stop_flow\" => {\n                    if let Ok(payload) = serde_json::from_value::<FlowPayload>(ws_msg.payload) {\n                        let cmd = ActorCommand::StopFlow {\n                            flow_id: payload.flow_id,\n                        };\n                        if cmd_tx.send(cmd).await.is_err() {\n                            break;\n                        }\n                    }\n                }\n                \"get_all_flows\" => {\n                    let (responder_tx, responder_rx) = oneshot::channel();\n                    let cmd = ActorCommand::GetAllFlows {\n                        responder: responder_tx,\n                    };\n                    if cmd_tx.send(cmd).await.is_err() {\n                        break;\n                    }\n                    let ws_sender_clone = Arc::clone(&ws_sender);\n                    tokio::spawn(async move {\n                        if let Ok(Ok(flows)) = responder_rx.await {\n                            let ws_response = WsMessageOut {\n                                msg_type: \"get_all_flows_response\",\n                                payload: &flows,\n                            };\n                            if let Ok(payload_str) = serde_json::to_string(&ws_response) {\n                                let _ = ws_sender_clone\n                                    .lock()\n                                    .await\n                                    .send(Message::Text(payload_str))\n                                    .await;\n                            }\n                        }\n                    });\n                }\n                \"ping\" => {\n                    let cmd = ActorCommand::Ping {\n                        payload: ws_msg.payload,\n                    };\n                    if cmd_tx.send(cmd).await.is_err() {\n                        break;\n                    }\n                }\n                \"subscribe_stream\" => {\n                    if let Ok(payload) =\n                        serde_json::from_value::<SubscribeStreamPayload>(ws_msg.payload)\n                    {\n                        info!(\"Received subscribe request for topic: {}\", payload.topic);\n                        let cmd = ActorCommand::SubscribeToTopic {\n                            topic: payload.topic,\n                        };\n                        if cmd_tx.send(cmd).await.is_err() {\n                            break;\n                        }\n                    }\n                }\n                \"get_server\" => {\n                    let (responder_tx, responder_rx) = oneshot::channel();\n                    let cmd = ActorCommand::GetServer {\n                        responder: responder_tx,\n                    };\n                    if cmd_tx.send(cmd).await.is_err() {\n                        break;\n                    }\n                    let ws_sender_clone = Arc::clone(&ws_sender);\n                    tokio::spawn(async move {\n                        if let Ok(Ok(stats)) = responder_rx.await {\n                            let ws_response = WsMessageOut {\n                                msg_type: \"get_server\",\n                                payload: &stats,\n                            };\n                            if let Ok(payload_str) = serde_json::to_string(&ws_response) {\n                                let _ = ws_sender_clone\n                                    .lock()\n                                    .await\n                                    .send(Message::Text(payload_str))\n                                    .await;\n                            }\n                        }\n                    });\n                }\n                \"hangup\" => {\n                    info!(\"Received hangup message from client.\");\n                    let cmd = ActorCommand::Hangup;\n                    if cmd_tx.send(cmd).await.is_err() {\n                        break;\n                    }\n                }\n                _ => {\n                    error!(\"Unknown message type: {}\", ws_msg.msg_type);\n                }\n            }\n        }\n    }\n    info!(\"WebSocket connection closed. Handler finished.\");\n}\n"
  },
  {
    "path": "apps/server/src/handler/ws/mod.rs",
    "content": "use axum::{\n    extract::{ws::WebSocketUpgrade, State},\n    response::Response,\n};\nuse std::sync::Arc;\nuse tracing::info;\n\nuse crate::{\n    handler::{auth::JwtAuth, ws::handlers::handle_socket},\n    state::AppState,\n};\n\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n\npub mod handlers;\npub mod dashboard_component_event;\npub mod webrtc;\n\npub async fn ws_handler(\n    JwtAuth(claims): JwtAuth,\n    ws: WebSocketUpgrade,\n    State(state): State<Arc<AppState>>,\n) -> Response {\n    ws.on_upgrade(move |socket| {\n        info!(\"'{}' authenticated and connected.\", claims.sub);\n        handle_socket(socket, state)\n    })\n}\n\n#[derive(Serialize)]\npub struct WsMessageOut<'a, T: Serialize> {\n    #[serde(rename = \"type\")]\n    msg_type: &'a str,\n    payload: T,\n}\n"
  },
  {
    "path": "apps/server/src/handler/ws/webrtc.rs",
    "content": "use anyhow::Result;\nuse axum::extract::ws::{Message, WebSocket};\nuse chrono::Utc;\nuse futures_util::{stream::SplitSink, SinkExt};\nuse std::sync::Arc;\nuse tokio::sync::Mutex;\nuse tracing::{info, warn};\nuse interceptor::registry::Registry;\nuse webrtc::{\n    api::{\n        interceptor_registry::register_default_interceptors,\n        media_engine::{MediaEngine, MIME_TYPE_H264, MIME_TYPE_OPUS},\n        APIBuilder,\n    },\n    ice_transport::{\n        ice_candidate::RTCIceCandidate,\n        ice_connection_state::RTCIceConnectionState,\n        ice_gatherer_state::RTCIceGathererState,\n        ice_server::RTCIceServer,\n    },\n    peer_connection::{\n        configuration::RTCConfiguration,\n        peer_connection_state::RTCPeerConnectionState,\n        RTCPeerConnection,\n    },\n    rtp_transceiver::rtp_codec::{RTCRtpCodecCapability, RTCRtpCodecParameters, RTPCodecType},\n};\n\nuse crate::db;\nuse crate::handler::ws::WsMessageOut;\nuse crate::state::DbPool;\n\n/// Filter TURN URLs for webrtc-rs compatibility.\n/// webrtc-rs does not support `turns:` (TURN over TLS), so drop those.\n/// Keep `turn:` with both UDP and TCP transports.\nfn filter_ice_url(url: &str) -> Option<String> {\n    if url.starts_with(\"turns:\") {\n        warn!(\"Dropping unsupported turns: URL (webrtc-rs has no TLS TURN): {}\", url);\n        return None;\n    }\n    if url.contains(\"transport=tcp\") {\n        warn!(\"Dropping TCP TURN URL (webrtc-rs only supports UDP TURN): {}\", url);\n        return None;\n    }\n    Some(url.to_owned())\n}\n\nfn build_ice_servers(pool: &DbPool) -> Vec<RTCIceServer> {\n    let default_stun = RTCIceServer {\n        urls: vec![\"stun:stun.l.google.com:19302\".to_owned()],\n        ..Default::default()\n    };\n\n    let configs = match db::repository::get_all_system_configs(pool) {\n        Ok(c) => c,\n        Err(e) => {\n            warn!(\"Failed to read system configs for TURN: {}\", e);\n            return vec![default_stun];\n        }\n    };\n\n    let turn_entry = match configs\n        .iter()\n        .find(|c| c.key == \"turn_server_config\" && c.enabled == 1)\n    {\n        Some(entry) => entry,\n        None => return vec![default_stun],\n    };\n\n    let parsed: serde_json::Value = match serde_json::from_str(&turn_entry.value) {\n        Ok(v) => v,\n        Err(e) => {\n            warn!(\"Failed to parse turn_server_config JSON: {}\", e);\n            return vec![default_stun];\n        }\n    };\n\n    if let Some(expires_at) = parsed.get(\"expiresAt\").and_then(|v| v.as_str()) {\n        if let Ok(exp) = chrono::DateTime::parse_from_rfc3339(expires_at) {\n            if exp < Utc::now() {\n                warn!(\"TURN credentials expired at {}\", expires_at);\n                return vec![default_stun];\n            }\n        }\n    }\n\n    let ice_servers_arr = match parsed.get(\"iceServers\").and_then(|v| v.as_array()) {\n        Some(arr) => arr,\n        None => return vec![default_stun],\n    };\n\n    let mut servers = vec![default_stun];\n\n    for entry in ice_servers_arr {\n        let raw_urls: Vec<String> = if let Some(url_str) = entry.get(\"urls\").and_then(|v| v.as_str()) {\n            vec![url_str.to_owned()]\n        } else if let Some(url_arr) = entry.get(\"urls\").and_then(|v| v.as_array()) {\n            url_arr\n                .iter()\n                .filter_map(|u| u.as_str().map(String::from))\n                .collect()\n        } else {\n            continue;\n        };\n\n        let urls: Vec<String> = raw_urls\n            .into_iter()\n            .filter_map(|u| filter_ice_url(&u))\n            .collect();\n\n        if urls.is_empty() {\n            continue;\n        }\n\n        let username = entry\n            .get(\"username\")\n            .and_then(|v| v.as_str())\n            .unwrap_or(\"\")\n            .to_owned();\n        let credential = entry\n            .get(\"credential\")\n            .and_then(|v| v.as_str())\n            .unwrap_or(\"\")\n            .to_owned();\n\n        servers.push(RTCIceServer {\n            urls,\n            username,\n            credential,\n            ..Default::default()\n        });\n    }\n\n    for s in &servers {\n        info!(\n            \"  ICE server: urls={:?}, has_credentials={}\",\n            s.urls,\n            !s.username.is_empty()\n        );\n    }\n    info!(\"Built {} ICE server(s) total\", servers.len());\n    servers\n}\n\npub async fn create_peer_connection(\n    ws_sender: Arc<Mutex<SplitSink<WebSocket, Message>>>,\n    pool: &DbPool,\n) -> Result<Arc<RTCPeerConnection>> {\n    info!(\">>> create_peer_connection v2 (no SettingEngine restriction) <<<\");\n    let mut m = MediaEngine::default();\n    m.register_codec(\n        RTCRtpCodecParameters {\n            capability: RTCRtpCodecCapability {\n                mime_type: MIME_TYPE_OPUS.to_owned(),\n                clock_rate: 48000,\n                channels: 1,\n                sdp_fmtp_line: \"minptime=10;useinbandfec=1\".to_owned(),\n                ..Default::default()\n            },\n            ..Default::default()\n        },\n        RTPCodecType::Audio,\n    )?;\n\n    m.register_codec(\n        RTCRtpCodecParameters {\n            capability: RTCRtpCodecCapability {\n                mime_type: MIME_TYPE_H264.to_owned(),\n                clock_rate: 90000,\n                channels: 0,\n                sdp_fmtp_line:\n                    \"level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\"\n                        .to_owned(),\n                ..Default::default()\n            },\n            ..Default::default()\n        },\n        RTPCodecType::Video,\n    )?;\n\n    let mut registry = Registry::new();\n    registry = register_default_interceptors(registry, &mut m)?;\n\n    let api = APIBuilder::new()\n        .with_media_engine(m)\n        .with_interceptor_registry(registry)\n        .build();\n\n    let config = RTCConfiguration {\n        ice_servers: build_ice_servers(pool),\n        ..Default::default()\n    };\n    let pc = Arc::new(api.new_peer_connection(config).await?);\n\n    pc.on_ice_connection_state_change(Box::new(move |state: RTCIceConnectionState| {\n        Box::pin(async move {\n            info!(\"ICE connection state changed: {:?}\", state);\n        })\n    }));\n\n    pc.on_peer_connection_state_change(Box::new(move |state: RTCPeerConnectionState| {\n        Box::pin(async move {\n            info!(\"Peer connection state changed: {:?}\", state);\n        })\n    }));\n\n    pc.on_ice_candidate(Box::new(move |c: Option<RTCIceCandidate>| {\n        let ws_sender_clone = Arc::clone(&ws_sender);\n        Box::pin(async move {\n            if let Some(c) = c {\n                let cand_str = c.to_string();\n                let cand_type = if cand_str.contains(\"relay\") {\n                    \"RELAY\"\n                } else if cand_str.contains(\"srflx\") {\n                    \"SRFLX\"\n                } else {\n                    \"HOST\"\n                };\n                info!(\"Server ICE candidate [{}]: {}\", cand_type, cand_str);\n                if let Ok(candidate) = c.to_json() {\n                    let msg = WsMessageOut {\n                        msg_type: \"candidate\",\n                        payload: candidate,\n                    };\n                    if let Ok(payload_str) = serde_json::to_string(&msg) {\n                        if ws_sender_clone\n                            .lock()\n                            .await\n                            .send(Message::Text(payload_str))\n                            .await\n                            .is_err()\n                        {\n                            warn!(\"Failed to send ICE candidate, WebSocket closed.\");\n                        }\n                    }\n                }\n            } else {\n                info!(\"ICE candidate gathering complete (null candidate).\");\n            }\n        })\n    }));\n\n    pc.on_ice_gathering_state_change(Box::new(move |state: RTCIceGathererState| {\n        Box::pin(async move {\n            info!(\"ICE gathering state changed: {:?}\", state);\n        })\n    }));\n\n    Ok(pc)\n}\n"
  },
  {
    "path": "apps/server/src/init/db_record.rs",
    "content": "use diesel::{\n    ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl, SelectableHelper, SqliteConnection,\n};\nuse tracing::info;\n\nuse crate::db::models::{NewPermission, Permission, User};\nuse crate::db::models::{NewSystemConfiguration, NewUser, SystemConfiguration};\nuse crate::utils::hash::hash_password;\nuse crate::state::DbPool;\n\npub fn create_initial_admin(conn: &mut SqliteConnection) {\n    use crate::db::schema::users::dsl::*;\n\n    let admin_username = \"admin\";\n\n    let admin_exists = users\n        .filter(username.eq(admin_username))\n        .select(User::as_select())\n        .first::<User>(conn)\n        .optional()\n        .expect(\"Error checking for admin user\");\n\n    if admin_exists.is_none() {\n        let password = \"admin\";\n\n        info!(\"Admin user not found. Creating...\");\n\n        match hash_password(password) {\n            Ok(hashed_password) => {\n                let new_admin = NewUser {\n                    username: admin_username,\n                    email: \"admin@example.com\",\n                    password_hash: &hashed_password,\n                };\n\n                diesel::insert_into(users)\n                    .values(&new_admin)\n                    .execute(conn)\n                    .expect(\"Error creating admin user\");\n\n                info!(\"Admin user '{}' created.\", admin_username);\n            }\n            Err(e) => eprintln!(\"error: {}\", e),\n        }\n    } else {\n        info!(\"Admin user already exists. Skipping creation.\");\n    }\n}\n\npub fn create_initial_configurations(conn: &mut SqliteConnection) {\n    use crate::db::schema::system_configurations::dsl::{key, system_configurations};\n\n    let default_configs = vec![\n        (\n            \"mqtt_broker_url\",\n            \"localhost:1883\",\n            \"Default MQTT Broker URL. Format: host:port\",\n        ),\n        (\"rtp_broker_port\", \"0.0.0.0:5004\", \"RTP port\"),\n        (\n            \"turn_server_config\",\n            r#\"{ \"urls\": \"turn:turn.example.com:3478\", \"username\": \"user\", \"credential\": \"pass\" }\"#,\n            \"Default WebRTC TURN Server configuration (JSON format)\",\n        ),\n        (\n            \"code_service_enabled\",\n            \"1\",\n            \"Enable the Code workspace (file browser and editor). Toggle via Settings or set enabled to 1.\",\n        ),\n    ];\n\n    for (k, v, d) in default_configs {\n        let config_exists = system_configurations\n            .filter(key.eq(k))\n            .select(SystemConfiguration::as_select())\n            .first::<SystemConfiguration>(conn)\n            .optional()\n            .expect(\"Error checking for system configuration\");\n\n        if config_exists.is_none() {\n            info!(\"Configuration '{}' not found. Creating...\", k);\n            let new_config = NewSystemConfiguration {\n                key: k,\n                value: v,\n                enabled: Some(0),\n                description: Some(d),\n            };\n\n            diesel::insert_into(system_configurations)\n                .values(&new_config)\n                .execute(conn)\n                .expect(\"Error creating system configuration\");\n\n            info!(\"Configuration '{}' created.\", k);\n        } else {\n            info!(\"Configuration '{}' already exists. Skipping creation.\", k);\n        }\n    }\n}\n\npub fn seed_initial_permissions(conn: &mut SqliteConnection) {\n    use crate::db::schema::permissions::dsl::{name, permissions};\n\n    let initial_permissions = vec![\n        (\"users:create\", \"Allows creating new users\"),\n        (\"users:read\", \"Allows viewing user list and details\"),\n        (\"users:update\", \"Allows updating user information\"),\n        (\"users:delete\", \"Allows deleting users\"),\n        (\"users:assign_roles\", \"Allows assigning roles to users\"),\n        (\"roles:create\", \"Allows creating new roles\"),\n        (\"roles:read\", \"Allows viewing roles and their permissions\"),\n        (\n            \"roles:update\",\n            \"Allows updating roles and their assigned permissions\",\n        ),\n        (\"roles:delete\", \"Allows deleting roles\"),\n        (\"devices:create\", \"Allows creating new devices\"),\n        (\"devices:read\", \"Allows viewing devices\"),\n        (\"devices:update\", \"Allows updating device information\"),\n        (\"devices:delete\", \"Allows deleting devices\"),\n        (\"entities:create\", \"Allows creating new entities\"),\n        (\"entities:read\", \"Allows viewing entities\"),\n        (\"entities:update\", \"Allows updating entity information\"),\n        (\"entities:delete\", \"Allows deleting entities\"),\n        (\"flows:create\", \"Allows creating new flows\"),\n        (\"flows:read\", \"Allows viewing flows\"),\n        (\"flows:update\", \"Allows updating flows\"),\n        (\"flows:delete\", \"Allows deleting flows\"),\n    ];\n\n    info!(\"Seeding initial permissions...\");\n\n    for (perm_name, perm_desc) in initial_permissions {\n        let permission_exists = permissions\n            .filter(name.eq(perm_name))\n            .select(Permission::as_select())\n            .first::<Permission>(conn)\n            .optional()\n            .expect(\"Error checking for permission\");\n\n        if permission_exists.is_none() {\n            info!(\"Permission '{}' not found. Creating...\", perm_name);\n            let new_permission = NewPermission {\n                name: perm_name,\n                description: Some(perm_desc),\n            };\n\n            diesel::insert_into(permissions)\n                .values(&new_permission)\n                .execute(conn)\n                .expect(\"Error creating permission\");\n\n            info!(\"Permission '{}' created.\", perm_name);\n        } else {\n            info!(\n                \"Permission '{}' already exists. Skipping creation.\",\n                perm_name\n            );\n        }\n    }\n    info!(\"Permission seeding complete.\");\n}\n"
  },
  {
    "path": "apps/server/src/init/mod.rs",
    "content": "pub mod db_record;\npub mod streams;\n"
  },
  {
    "path": "apps/server/src/init/streams.rs",
    "content": "use tokio::sync::broadcast;\nuse tracing::{error, info, warn};\nuse webrtc::rtp::packet::Packet;\n\nuse crate::db::repository;\nuse crate::state::{DbPool, MediaType, Protocol, StreamDescriptor, StreamManager};\n\npub fn create_hydrate_streams(pool: &DbPool, streams: &StreamManager) {\n    info!(\"Hydrating streams from database...\");\n    match repository::streams::get_all_streams(&pool) {\n        Ok(db_streams) => {\n            for stream in db_streams {\n                let (packet_tx, _) = broadcast::channel::<Packet>(8192);\n                let media_type = match stream.media_type.as_str() {\n                    \"audio\" => MediaType::Audio,\n                    \"video\" => MediaType::Video,\n                    _ => {\n                        warn!(\n                            \"Unknown media type '{}' for SSRC {}\",\n                            stream.media_type, stream.ssrc\n                        );\n                        continue;\n                    }\n                };\n\n                let descriptor = StreamDescriptor {\n                    id: stream.ssrc as u32,\n                    topic: stream.topic,\n                    user_id: stream.device_id,\n                    media_type,\n                    protocol: Protocol::Udp,\n                };\n\n                streams.insert_with_sender(descriptor, packet_tx);\n            }\n            info!(\"Hydrated {} streams into memory.\", streams.len());\n        }\n        Err(e) => {\n            error!(\"Failed to hydrate streams from database: {}\", e);\n        }\n    }\n}\n"
  },
  {
    "path": "apps/server/src/lib.rs",
    "content": "// Library crate for external access (tests, etc.)\n\npub mod db;\npub mod error;\npub mod flow;\npub mod logo;\npub mod media;\npub mod recording;\npub mod state;\npub mod tunnel_control;\npub mod utils;\n\n// Re-export commonly used types for convenience\npub use state::{DbPool, MediaType, Protocol, StreamDescriptor, StreamHandle, StreamRegistry};\n"
  },
  {
    "path": "apps/server/src/logo.rs",
    "content": "use local_ip_address::local_ip;\nuse std::error::Error;\n\nuse colored::*;\n\nconst LOGO: &str = r#\"\n __      __                _ \n \\ \\    / /               | |\n  \\ \\  / /__  ___ ___  ___| |\n   \\ \\/ / _ \\/ __/ __|/ _ \\ |\n    \\  /  __/\\__ \\__ \\  __/ |\n     \\/ \\___||___/___/\\___|_|\n                             \n\"#;\n\npub fn print_header() {\n    println!(\"{}\", LOGO.truecolor(0, 150, 255).bold());\n    println!(\"{}\", \"------------------------------------\".yellow());\n}\n\npub fn print_footer() {\n    println!(\"{}\", \"------------------------------------\".yellow());\n}\n\npub fn print_local_ip_info(ip: &str, port: &str) {\n    println!(\n        \"{} {}:{}\",\n        \"Local IP:\".green().bold(),\n        ip.cyan(),\n        port.cyan()\n    );\n}\n\npub fn print_error(message: &str) {\n    println!(\"{} {}\", \"❌ Error:\".red().bold(), message.red());\n}\n\npub fn get_local_ip_address() -> Result<String, Box<dyn Error>> {\n    let ip = local_ip()?.to_string();\n    Ok(ip)\n}\n\npub fn print_logo() {\n    print_header();\n\n    match get_local_ip_address() {\n        Ok(ip) => print_local_ip_info(&ip, &\"6174\".to_string()),\n        Err(e) => print_error(&format!(\"Could not fetch local IP: {}\", e)),\n    }\n\n    print_footer();\n}\n"
  },
  {
    "path": "apps/server/src/main.rs",
    "content": "use anyhow::Result;\nuse diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};\nuse rumqttc::{AsyncClient, MqttOptions};\nuse std::{env, sync::Arc};\nuse tokio::{\n    sync::{broadcast, mpsc, watch, RwLock},\n    task::JoinSet,\n};\nuse tracing::{error, info, warn};\nuse tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};\n\nconst LOG_FILE_PATH: &str = \"log/app.log\";\n\nuse crate::{\n    db::conn::establish_connection,\n    flow::manager_state::FlowManagerActor,\n    init::db_record::{\n        create_initial_admin, create_initial_configurations, seed_initial_permissions,\n    },\n    init::streams::create_hydrate_streams,\n    utils::{entity_map::remap_topics, stream_checker::stream_status_checker},\n    logo::print_logo,\n    media::{MediaAdapter, RtpPushAdapter, RtspPullAdapter},\n    routes::web_server,\n    state::{AppState, MqttMessage, StreamManager, StreamRegistry},\n    tunnel_control::TunnelManager,\n};\n\nmod broker_mqtt;\nmod config;\nmod handler;\nmod init;\nmod routes;\nmod state;\nmod tunnel_control;\n\npub mod db;\npub mod error;\npub mod flow;\npub mod utils;\npub mod logo;\npub mod media;\npub mod recording;\n\npub const MIGRATIONS: EmbeddedMigrations = embed_migrations!(\"./migrations\");\n\nfn run_migrations(connection: &mut impl MigrationHarness<diesel::sqlite::Sqlite>) -> Result<()> {\n    connection\n        .run_pending_migrations(MIGRATIONS)\n        .map_err(|e| anyhow::anyhow!(e))?;\n    Ok(())\n}\n\n#[tokio::main]\nasync fn main() -> Result<()> {\n    let settings = config::Settings::new().expect(\"Failed to load configuration\");\n\n    let is_debug_mode = env::args().any(|arg| arg == \"--debug\");\n\n    let file_appender = tracing_appender::rolling::daily(\"log\", \"app.log\");\n    let (non_blocking_writer, _guard) = tracing_appender::non_blocking(file_appender);\n\n    let file_layer = fmt::layer()\n        .with_writer(non_blocking_writer)\n        .with_ansi(false);\n\n    let console_layer = fmt::layer();\n\n    tracing_subscriber::registry()\n        .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| \"info\".into()))\n        .with(file_layer)\n        .with(console_layer)\n        .init();\n\n    if is_debug_mode {\n        warn!(\"APPLICATION IS RUNNING IN DEBUG MODE. ONLY THE WEB SERVER WILL BE ACTIVATED.\");\n    }\n\n    let streams: StreamManager = Arc::new(StreamRegistry::new());\n\n    let pool = establish_connection(&settings.database_url);\n\n    {\n        let mut conn = pool\n            .get()\n            .expect(\"Failed to get a connection from the pool\");\n        info!(\"Running database migrations...\");\n        run_migrations(&mut conn)?;\n        info!(\"Migrations completed successfully.\");\n\n        create_initial_admin(&mut conn);\n        create_initial_configurations(&mut conn);\n        seed_initial_permissions(&mut conn);\n        create_hydrate_streams(&pool, &streams);\n    }\n\n    let (mqtt_tx, _) = broadcast::channel::<MqttMessage>(1024);\n    let (flow_manager_tx, flow_manager_rx) = mpsc::channel(100);\n    let (broadcast_tx, _) = broadcast::channel(1024);\n    let (dashboard_ui_tx, _) = broadcast::channel::<crate::state::DashboardUiEvent>(1024);\n    let (shutdown_tx, shutdown_rx) = watch::channel(());\n    let (topic_map_notify_tx, topic_map_notify_rx) = watch::channel(());\n    let tunnel_manager = Arc::new(TunnelManager::new());\n\n    let jwt_secret = settings.jwt_secret.clone();\n    let configs = db::repository::get_all_system_configs(&pool)?;\n\n    let recording_manager = Arc::new(recording::RecordingManager::new(\n        streams.clone(),\n        pool.clone(),\n    ));\n\n    let app_state = Arc::new(AppState {\n        streams: streams.clone(),\n        mqtt_tx: mqtt_tx.clone(),\n        jwt_secret: jwt_secret,\n        pool: pool.clone(),\n        topic_map: Arc::new(RwLock::new(Vec::new())),\n        topic_map_notify: topic_map_notify_tx,\n        flow_manager_tx,\n        dashboard_ui_tx: dashboard_ui_tx.clone(),\n        broadcast_tx,\n        system_configs: configs.clone(),\n        tunnel_manager: tunnel_manager.clone(),\n        recording_manager,\n    });\n\n    let mut set = JoinSet::new();\n\n    remap_topics(axum::extract::State(app_state.clone())).await;\n\n    let server_task = tokio::spawn(web_server(\n        settings.listen_address,\n        app_state.clone(),\n        shutdown_rx.clone(),\n    ));\n\n    print_logo();\n\n    let mut mqtt_client_for_flow: Option<AsyncClient> = None;\n\n    if !is_debug_mode {\n        if let Some(rtp_config) = configs.iter().find(|c| c.key == \"rtp_broker_port\") {\n            if rtp_config.enabled == 1 {\n                info!(\n                    \"RTP Receiver is enabled. Starting on {}.\",\n                    &rtp_config.value\n                );\n                let rtp_listen_address = rtp_config.value.clone();\n                let rtp_adapter =\n                    Arc::new(RtpPushAdapter::new(rtp_listen_address, streams.clone()));\n                let shutdown_rx_clone = shutdown_rx.clone();\n                let rtp_task = rtp_adapter.clone();\n                set.spawn(async move { rtp_task.start(shutdown_rx_clone).await });\n            } else {\n                warn!(\"RTP Receiver is disabled by configuration.\");\n            }\n        } else {\n            warn!(\"RTP Receiver configuration ('rtp_broker_port') not found.\");\n        }\n\n        if let Some(mqtt_config) = configs.iter().find(|c| c.key == \"mqtt_broker_url\") {\n            if mqtt_config.enabled == 1 {\n                info!(\n                    \"MQTT Broker is enabled. Connecting to {}.\",\n                    &mqtt_config.value\n                );\n                let mqtt_broker_url = mqtt_config.value.clone();\n                let parts: Vec<&str> = mqtt_broker_url.split(':').collect();\n                if parts.len() != 2 {\n                    return Err(anyhow::anyhow!(\n                        \"Invalid broker address format. Expected 'host:port', got '{}'\",\n                        mqtt_broker_url\n                    ));\n                }\n                let host = parts[0];\n                let port: u16 = parts[1].parse()?;\n\n                let mut mqttoptions = MqttOptions::new(\"rust-internal-client\", host, port);\n                mqttoptions.set_keep_alive(std::time::Duration::from_secs(5));\n\n                let (client, eventloop) = AsyncClient::new(mqttoptions, 10);\n\n                mqtt_client_for_flow = Some(client.clone());\n                let mqtt_tx_clone = mqtt_tx.clone();\n\n                set.spawn(broker_mqtt::start_event_loop(\n                    client,\n                    eventloop,\n                    mqtt_tx_clone,\n                    app_state.clone(),\n                ));\n            } else {\n                warn!(\"MQTT Broker is disabled by configuration.\");\n            }\n        } else {\n            warn!(\"MQTT Broker configuration ('mqtt_broker_url') not found.\");\n        }\n\n        let app_state_for_checker = app_state.clone();\n        set.spawn(stream_status_checker(\n            app_state_for_checker,\n            shutdown_rx.clone(),\n        ));\n\n        let rtsp_adapter = Arc::new(RtspPullAdapter::new(\n            streams.clone(),\n            app_state.topic_map.clone(),\n            topic_map_notify_rx,\n        ));\n        let shutdown_rx_clone = shutdown_rx.clone();\n        let rtsp_task = rtsp_adapter.clone();\n        set.spawn(async move { rtsp_task.start(shutdown_rx_clone).await });\n    }\n\n    let mut flow_manager = FlowManagerActor::new(\n        flow_manager_rx,\n        mqtt_client_for_flow,\n        mqtt_tx.clone(),\n        dashboard_ui_tx,\n        streams.clone(),\n        configs.clone(),\n        pool.clone(),\n    );\n    tokio::spawn(async move {\n        flow_manager.run().await;\n    });\n\n    info!(\"Server starting, press Ctrl-C to stop.\");\n\n    tokio::select! {\n        _ = tokio::signal::ctrl_c() => {\n            info!(\"Ctrl-C received, initiating shutdown...\");\n        }\n        Some(res) = set.join_next() => {\n             match res {\n                Ok(Ok(())) => info!(\"A background task finished gracefully.\"),\n                Ok(Err(e)) => error!(\"A background task finished with an error: {}\", e),\n                Ok(Err(e)) => error!(\"A background task failed to join: {}\", e),\n                Err(e) => error!(\"A background task panicked: {}\", e),\n            }\n        }\n    }\n\n    tokio::select! {\n        res = set.join_next(), if !set.is_empty() => {\n            if let Some(Ok(Err(e))) = res {\n                error!(\"A task exited with an error: {}\", e);\n            }\n        }\n        _ = tokio::signal::ctrl_c() => {\n            info!(\"Ctrl-C received, shutting down.\");\n        }\n    }\n\n    set.abort_all();\n    shutdown_tx.send(()).ok();\n\n    info!(\"Waiting for all tasks to complete...\");\n    while let Some(res) = set.join_next().await {\n        match res {\n            Ok(Ok(())) => {}\n            Ok(Err(e)) => error!(\"A task shut down with an error: {}\", e),\n            Ok(Err(e)) => error!(\"A task failed to join during shutdown: {}\", e),\n            Err(e) => error!(\"A task panicked during shutdown: {}\", e),\n        }\n    }\n    info!(\"All tasks have been shut down.\");\n\n    Ok(())\n}\n"
  },
  {
    "path": "apps/server/src/media/adapter.rs",
    "content": "use anyhow::Result;\nuse async_trait::async_trait;\nuse tokio::sync::watch;\n\nuse crate::state::Protocol;\n\n#[async_trait]\npub trait MediaAdapter: Send + Sync {\n    fn protocol(&self) -> Protocol;\n    async fn start(&self, shutdown: watch::Receiver<()>) -> Result<()>;\n}\n"
  },
  {
    "path": "apps/server/src/media/mod.rs",
    "content": "pub mod adapter;\npub mod rtp_push;\npub mod rtsp_pull;\n\npub use adapter::MediaAdapter;\npub use rtp_push::RtpPushAdapter;\npub use rtsp_pull::RtspPullAdapter;\n"
  },
  {
    "path": "apps/server/src/media/rtp_push.rs",
    "content": "use anyhow::Result;\nuse async_trait::async_trait;\nuse tokio::{net::UdpSocket, sync::watch, time::Instant};\nuse tracing::{info, warn};\nuse webrtc::{rtp::packet::Packet, util::Unmarshal};\n\nuse crate::media::adapter::MediaAdapter;\nuse crate::state::{Protocol, StreamManager};\n\npub struct RtpPushAdapter {\n    addr: String,\n    streams: StreamManager,\n}\n\nimpl RtpPushAdapter {\n    pub fn new(addr: String, streams: StreamManager) -> Self {\n        Self { addr, streams }\n    }\n\n    async fn run(&self, mut shutdown_rx: watch::Receiver<()>) -> Result<()> {\n        let sock = UdpSocket::bind(&self.addr).await?;\n        let mut buf = vec![0u8; 65535];\n\n        loop {\n            tokio::select! {\n                _ = shutdown_rx.changed() => {\n                    info!(\"RTP Push Adapter received shutdown signal.\");\n                    break;\n                }\n                result = sock.recv_from(&mut buf) => {\n                    let (n, from) = match result {\n                        Ok(res) => res,\n                        Err(e) => {\n                            warn!(\"UDP recv_from failed: {}\", e);\n                            continue;\n                        }\n                    };\n\n                    match Packet::unmarshal(&mut &buf[..n]) {\n                        Ok(packet) => {\n                            let ssrc = packet.header.ssrc;\n\n                            if let Some(handle) = self.streams.get_by_ssrc(ssrc) {\n                                let mut is_online_guard = handle.is_online.write().unwrap();\n                                let was_offline = !*is_online_guard;\n                                *is_online_guard = true;\n                                drop(is_online_guard);\n\n                                let mut last_seen_guard = handle.last_seen.write().unwrap();\n                                *last_seen_guard = Instant::now();\n                                drop(last_seen_guard);\n\n                                if was_offline {\n                                    info!(\n                                        \"Topic '{}' (SSRC: {}) is now ONLINE.\",\n                                        handle.descriptor.topic, ssrc\n                                    );\n                                }\n\n                                let _ = handle.packet_tx.send(packet);\n                            }\n                        }\n                        Err(e) => {\n                            warn!(\"Failed to unmarshal RTP packet from {}: {}\", from, e);\n                        }\n                    }\n                }\n            }\n        }\n\n        Ok(())\n    }\n}\n\n#[async_trait]\nimpl MediaAdapter for RtpPushAdapter {\n    fn protocol(&self) -> Protocol {\n        Protocol::Udp\n    }\n\n    async fn start(&self, shutdown: watch::Receiver<()>) -> Result<()> {\n        self.run(shutdown).await\n    }\n}\n"
  },
  {
    "path": "apps/server/src/media/rtsp_pull.rs",
    "content": "use anyhow::{anyhow, Error, Result};\nuse async_trait::async_trait;\nuse dashmap::DashMap;\nuse futures_util::StreamExt;\nuse gstreamer::prelude::*;\nuse gstreamer_app::{AppSink, AppSinkCallbacks};\nuse std::sync::{\n    atomic::{AtomicBool, Ordering},\n    Arc,\n};\nuse tokio::sync::{broadcast, watch, RwLock};\nuse tokio::time::Instant;\nuse tracing::{error, info, warn};\nuse webrtc::rtp::packet::Packet;\nuse webrtc::util::Unmarshal;\n\nuse crate::media::adapter::MediaAdapter;\nuse crate::state::{MediaType, Protocol, StreamDescriptor, StreamManager, TopicMapping};\n\npub struct RtspPullAdapter {\n    streams: StreamManager,\n    topic_map: Arc<RwLock<Vec<TopicMapping>>>,\n    topic_map_notify: watch::Receiver<()>,\n}\n\nimpl RtspPullAdapter {\n    pub fn new(\n        streams: StreamManager,\n        topic_map: Arc<RwLock<Vec<TopicMapping>>>,\n        topic_map_notify: watch::Receiver<()>,\n    ) -> Self {\n        Self {\n            streams,\n            topic_map,\n            topic_map_notify,\n        }\n    }\n\n    async fn start_pipelines(&self, mut shutdown_rx: watch::Receiver<()>) -> Result<()> {\n        if let Err(e) = gstreamer::init() {\n            error!(\"Failed to initialize GStreamer: {}\", e);\n            return Ok(());\n        }\n\n        // Track running pipelines: topic -> (shutdown_tx, JoinHandle)\n        let running_pipelines: Arc<DashMap<String, watch::Sender<()>>> = Arc::new(DashMap::new());\n        let mut topic_map_notify = self.topic_map_notify.clone();\n\n        // Initial pipeline spawn\n        self.spawn_new_pipelines(&running_pipelines, shutdown_rx.clone())\n            .await;\n\n        // Supervision loop: watch for topic_map changes and spawn new pipelines\n        loop {\n            tokio::select! {\n                _ = shutdown_rx.changed() => {\n                    info!(\"Shutdown signal received, stopping all RTSP pipelines\");\n                    // Send shutdown signal to all running pipelines\n                    for entry in running_pipelines.iter() {\n                        let _ = entry.value().send(());\n                    }\n                    break;\n                }\n                _ = topic_map_notify.changed() => {\n                    info!(\"Topic map changed, checking for new RTSP streams\");\n                    self.spawn_new_pipelines(&running_pipelines, shutdown_rx.clone()).await;\n                }\n            }\n        }\n\n        // Wait for all pipelines to finish\n        tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;\n        info!(\"All RTSP pipelines have shut down.\");\n        Ok(())\n    }\n\n    async fn spawn_new_pipelines(\n        &self,\n        running_pipelines: &Arc<DashMap<String, watch::Sender<()>>>,\n        shutdown_rx: watch::Receiver<()>,\n    ) {\n        let topic_map = self.topic_map.read().await;\n        let rtsp_mappings: Vec<_> = topic_map\n            .iter()\n            .filter(|mapping| mapping.protocol == Protocol::RTSP)\n            .cloned()\n            .collect();\n        drop(topic_map);\n\n        for mapping in rtsp_mappings {\n            let topic = mapping.topic.clone();\n\n            // Skip if already running\n            if running_pipelines.contains_key(&topic) {\n                continue;\n            }\n\n            info!(\"Spawning new RTSP pipeline for: {}\", topic);\n\n            let user_id = mapping.entity_id.clone();\n            let streams = self.streams.clone();\n            let (pipeline_shutdown_tx, pipeline_shutdown_rx) = watch::channel(());\n            let (packet_tx, _) = broadcast::channel(1024);\n\n            // Store shutdown sender for this pipeline\n            running_pipelines.insert(topic.clone(), pipeline_shutdown_tx);\n\n            // Clone for the spawned task\n            let running_pipelines_clone = running_pipelines.clone();\n            let topic_clone = topic.clone();\n            let mut main_shutdown_rx = shutdown_rx.clone();\n\n            tokio::spawn(async move {\n                let mut pipeline_shutdown_rx = pipeline_shutdown_rx;\n\n                loop {\n                    let mut shutdown_rx_clone = pipeline_shutdown_rx.clone();\n                    let pipeline_future = run_pipeline(\n                        &topic_clone,\n                        &user_id,\n                        packet_tx.clone(),\n                        streams.clone(),\n                        &mut shutdown_rx_clone,\n                    );\n\n                    tokio::select! {\n                        _ = main_shutdown_rx.changed() => {\n                            info!(\"Main shutdown signal received, stopping pipeline for {}\", topic_clone);\n                            break;\n                        }\n                        _ = pipeline_shutdown_rx.changed() => {\n                            info!(\"Pipeline shutdown signal received for {}\", topic_clone);\n                            break;\n                        }\n                        pipeline_result = pipeline_future => {\n                            match pipeline_result {\n                                Ok(_) => {\n                                    info!(\"Pipeline for {} finished gracefully.\", topic_clone);\n                                    break;\n                                }\n                                Err(e) => {\n                                    error!(\"Error in pipeline for {}. Restarting in 5s: {}\", topic_clone, e);\n                                    tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;\n                                }\n                            }\n                        }\n                    }\n                }\n\n                // Remove from running pipelines when done\n                running_pipelines_clone.remove(&topic_clone);\n                info!(\"Pipeline for {} removed from running list\", topic_clone);\n            });\n        }\n    }\n}\n\n#[async_trait]\nimpl MediaAdapter for RtspPullAdapter {\n    fn protocol(&self) -> Protocol {\n        Protocol::RTSP\n    }\n\n    async fn start(&self, shutdown: watch::Receiver<()>) -> Result<()> {\n        self.start_pipelines(shutdown).await\n    }\n}\n\nasync fn run_pipeline(\n    rtsp_url: &str,\n    user_id: &str,\n    packet_tx: broadcast::Sender<Packet>,\n    streams: StreamManager,\n    shutdown_rx: &mut watch::Receiver<()>,\n) -> Result<(), Error> {\n    let pipeline_str = format!(\n        \"rtspsrc location={} latency=0 ! application/x-rtp,media=video,encoding-name=H264 ! appsink name=sink emit-signals=true\",\n        rtsp_url\n    );\n\n    let pipeline = gstreamer::parse::launch(&pipeline_str)?;\n    let pipeline_bin = pipeline\n        .downcast::<gstreamer::Bin>()\n        .map_err(|_| anyhow!(\"Pipeline element is not a Bin\"))?;\n    let appsink = pipeline_bin\n        .by_name(\"sink\")\n        .ok_or_else(|| anyhow!(\"Failed to get sink element\"))?\n        .downcast::<AppSink>()\n        .map_err(|_| anyhow!(\"Sink element is not an AppSink\"))?;\n\n    let is_registered = Arc::new(AtomicBool::new(false));\n    let rtsp_url_clone = rtsp_url.to_string();\n    let user_id_clone = user_id.to_string();\n    let streams_for_cb = streams.clone();\n    let packet_tx_for_cb = packet_tx.clone();\n\n    appsink.set_callbacks(\n        AppSinkCallbacks::builder()\n            .new_sample(move |sink| {\n                let sample = sink.pull_sample().map_err(|_| gstreamer::FlowError::Eos)?;\n                let buffer = sample.buffer().ok_or(gstreamer::FlowError::Error)?;\n                let map = buffer\n                    .map_readable()\n                    .map_err(|_| gstreamer::FlowError::Error)?;\n\n                match Packet::unmarshal(&mut map.as_slice()) {\n                    Ok(packet) => {\n                        let ssrc: u32 = packet.header.ssrc;\n\n                        if !is_registered.load(Ordering::SeqCst) {\n                            info!(\n                                \"RTSP stream {} registered with SSRC: {}\",\n                                rtsp_url_clone, ssrc\n                            );\n\n                            let descriptor = StreamDescriptor {\n                                id: ssrc,\n                                topic: rtsp_url_clone.clone(),\n                                user_id: user_id_clone.clone(),\n                                media_type: MediaType::Video,\n                                protocol: Protocol::RTSP,\n                            };\n                            streams_for_cb.insert_with_sender(descriptor, packet_tx_for_cb.clone());\n                            streams_for_cb.mark_online(ssrc);\n                            is_registered.store(true, Ordering::SeqCst);\n                        }\n\n                        if let Some(stream_handle) = streams_for_cb.get_by_ssrc(ssrc) {\n                            *stream_handle.last_seen.write().unwrap() = Instant::now();\n                        }\n\n                        if packet_tx_for_cb.send(packet).is_err() {\n                            // Receiver is gone, maybe log this.\n                        }\n                    }\n                    Err(e) => {\n                        warn!(\"Failed to unmarshal RTP packet from GStreamer: {}\", e);\n                    }\n                }\n                Ok(gstreamer::FlowSuccess::Ok)\n            })\n            .build(),\n    );\n\n    pipeline_bin.set_state(gstreamer::State::Playing)?;\n    let bus = pipeline_bin.bus().unwrap();\n    let mut bus_stream = bus.stream();\n\n    loop {\n        tokio::select! {\n            _ = shutdown_rx.changed() => {\n                info!(\"Shutdown signal received in run_pipeline for {}. Sending EOS.\", rtsp_url);\n                if !pipeline_bin.send_event(gstreamer::event::Eos::new()) {\n                    warn!(\"Failed to send EOS to pipeline {}\", rtsp_url);\n                }\n                break;\n            }\n            maybe_msg = bus_stream.next() => {\n                match maybe_msg {\n                    Some(msg) => {\n                        use gstreamer::MessageView;\n                        match msg.view() {\n                            MessageView::Error(err) => {\n                                error!(\"Error from pipeline {}: {} ({})\", rtsp_url, err.error(), err.debug().unwrap_or_default());\n                                pipeline_bin.set_state(gstreamer::State::Null)?;\n                                return Err(anyhow!(\"Pipeline error: {}\", err.error()));\n                            }\n                            MessageView::Eos(_) => {\n                                info!(\"End of stream for {}\", rtsp_url);\n                                break;\n                            }\n                            _ => (),\n                        }\n                    }\n                    None => {\n                        info!(\"Bus stream ended for {}\", rtsp_url);\n                        break;\n                    }\n                }\n            }\n        }\n    }\n\n    pipeline_bin.set_state(gstreamer::State::Null)?;\n    info!(\"Pipeline for {} has been set to NULL state.\", rtsp_url);\n    Ok(())\n}\n"
  },
  {
    "path": "apps/server/src/recording/manager.rs",
    "content": "use anyhow::{anyhow, Result};\nuse chrono::Utc;\nuse dashmap::mapref::entry::Entry;\nuse dashmap::DashMap;\nuse std::path::PathBuf;\nuse std::sync::Arc;\nuse tokio::sync::watch;\nuse tracing::{error, info, warn};\n\nuse crate::db::models::{NewRecording, UpdateRecording};\nuse crate::db::repository::recordings as recordings_repo;\nuse crate::recording::service::RecordingService;\nuse crate::state::{DbPool, MediaType, StreamManager};\n\npub struct ActiveRecording {\n    pub recording_id: i32,\n    pub topic: String,\n    pub shutdown_tx: watch::Sender<()>,\n}\n\npub struct RecordingManager {\n    active_recordings: Arc<DashMap<String, ActiveRecording>>,\n    streams: StreamManager,\n    pool: DbPool,\n    recordings_dir: PathBuf,\n}\n\nimpl RecordingManager {\n    pub fn new(streams: StreamManager, pool: DbPool) -> Self {\n        let recordings_dir = PathBuf::from(\"recordings\");\n\n        // Create recordings directory if it doesn't exist\n        if let Err(e) = std::fs::create_dir_all(&recordings_dir) {\n            error!(\"Failed to create recordings directory: {}\", e);\n        }\n\n        // Cleanup orphaned recordings from previous crashes\n        Self::cleanup_orphaned_recordings(&pool);\n\n        Self {\n            active_recordings: Arc::new(DashMap::new()),\n            streams,\n            pool,\n            recordings_dir,\n        }\n    }\n\n    /// Mark any recordings still in \"recording\" status as \"abandoned\"\n    /// This handles cases where the server crashed while recording\n    fn cleanup_orphaned_recordings(pool: &DbPool) {\n        match recordings_repo::mark_orphaned_as_abandoned(pool) {\n            Ok(count) if count > 0 => {\n                warn!(\n                    \"Cleaned up {} orphaned recording(s) from previous session\",\n                    count\n                );\n            }\n            Ok(_) => {\n                info!(\"No orphaned recordings found\");\n            }\n            Err(e) => {\n                error!(\"Failed to cleanup orphaned recordings: {}\", e);\n            }\n        }\n    }\n\n    pub fn start_recording(&self, topic: &str, user_id: Option<i32>) -> Result<i32> {\n        // Atomic check-then-insert using DashMap::entry()\n        // This prevents race conditions where two threads could both pass the\n        // contains_key check and try to start recordings for the same topic\n        match self.active_recordings.entry(topic.to_string()) {\n            Entry::Occupied(_) => {\n                return Err(anyhow!(\n                    \"Recording already in progress for topic: {}\",\n                    topic\n                ));\n            }\n            Entry::Vacant(entry) => {\n                // Get stream handle\n                let stream_handle = self\n                    .streams\n                    .get_by_topic(topic)\n                    .ok_or_else(|| anyhow!(\"Stream not found for topic: {}\", topic))?;\n\n                let media_type = stream_handle.descriptor.media_type;\n                let device_id = stream_handle.descriptor.user_id.clone();\n\n                // Generate filename with date hierarchy\n                let now = Utc::now();\n                let date_path = self\n                    .recordings_dir\n                    .join(now.format(\"%Y\").to_string())\n                    .join(now.format(\"%m\").to_string())\n                    .join(now.format(\"%d\").to_string());\n\n                // Create date directory\n                std::fs::create_dir_all(&date_path)?;\n\n                // Sanitize topic for filename (replace problematic characters)\n                let safe_topic = topic\n                    .replace(['/', '\\\\', ':', '*', '?', '\"', '<', '>', '|'], \"_\")\n                    .chars()\n                    .take(50)\n                    .collect::<String>();\n\n                let filename = format!(\"recording_{}_{}.mkv\", safe_topic, now.format(\"%H%M%S\"));\n                let file_path = date_path.join(&filename);\n\n                // Create database record\n                let new_recording = NewRecording {\n                    stream_ssrc: stream_handle.descriptor.id as i32,\n                    topic,\n                    device_id: &device_id,\n                    media_type: match media_type {\n                        MediaType::Video => \"video\",\n                        MediaType::Audio => \"audio\",\n                    },\n                    filename: &filename,\n                    file_path: file_path.to_str().unwrap(),\n                    status: \"recording\",\n                    created_by_user_id: user_id,\n                };\n\n                let recording = recordings_repo::create_recording(&self.pool, new_recording)?;\n                let recording_id = recording.id;\n\n                // Create shutdown channel\n                let (shutdown_tx, shutdown_rx) = watch::channel(());\n\n                // Start recording service in a new task with automatic cleanup\n                let service = RecordingService::new(\n                    recording_id,\n                    stream_handle.clone(),\n                    file_path,\n                    self.pool.clone(),\n                    media_type,\n                );\n\n                // Clone references for the spawned task\n                let topic_clone = topic.to_string();\n                let pool_clone = self.pool.clone();\n                let active_recordings_clone = self.active_recordings.clone();\n\n                tokio::spawn(async move {\n                    let result = service.run(shutdown_rx).await;\n\n                    // Automatic cleanup: remove from active recordings when task completes\n                    active_recordings_clone.remove(&topic_clone);\n\n                    // Update DB status on failure\n                    if let Err(e) = result {\n                        error!(\"Recording service error for id={}: {}\", recording_id, e);\n                        let update = UpdateRecording {\n                            status: Some(\"failed\".to_string()),\n                            ended_at: Some(Utc::now().naive_utc()),\n                            ..Default::default()\n                        };\n                        if let Err(db_err) =\n                            recordings_repo::update_recording(&pool_clone, recording_id, &update)\n                        {\n                            error!(\n                                \"Failed to update recording status to failed: {}\",\n                                db_err\n                            );\n                        }\n                    }\n                });\n\n                // Insert into active recordings (atomic with the entry check)\n                entry.insert(ActiveRecording {\n                    recording_id,\n                    topic: topic.to_string(),\n                    shutdown_tx,\n                });\n\n                info!(\"Started recording: id={}, topic={}\", recording_id, topic);\n                Ok(recording_id)\n            }\n        }\n    }\n\n    pub fn stop_recording(&self, recording_id: i32) -> Result<()> {\n        // Find the active recording by ID\n        let topic = self\n            .active_recordings\n            .iter()\n            .find(|entry| entry.value().recording_id == recording_id)\n            .map(|entry| entry.key().clone());\n\n        if let Some(topic) = topic {\n            self.stop_recording_by_topic(&topic)\n        } else {\n            Err(anyhow!(\"Recording not found with id: {}\", recording_id))\n        }\n    }\n\n    pub fn stop_recording_by_topic(&self, topic: &str) -> Result<()> {\n        if let Some((_, active_recording)) = self.active_recordings.remove(topic) {\n            info!(\n                \"Stopping recording: id={}, topic={}\",\n                active_recording.recording_id, topic\n            );\n            // Send shutdown signal\n            active_recording.shutdown_tx.send(()).ok();\n            Ok(())\n        } else {\n            Err(anyhow!(\"No active recording found for topic: {}\", topic))\n        }\n    }\n\n    pub fn is_recording(&self, topic: &str) -> bool {\n        self.active_recordings.contains_key(topic)\n    }\n\n    pub fn get_active_recording_id(&self, topic: &str) -> Option<i32> {\n        self.active_recordings\n            .get(topic)\n            .map(|entry| entry.value().recording_id)\n    }\n\n    pub fn get_all_active_recordings(&self) -> Vec<(String, i32)> {\n        self.active_recordings\n            .iter()\n            .map(|entry| (entry.key().clone(), entry.value().recording_id))\n            .collect()\n    }\n}\n"
  },
  {
    "path": "apps/server/src/recording/mod.rs",
    "content": "pub mod manager;\npub mod muxer;\npub mod service;\n\npub use manager::RecordingManager;\npub use service::RecordingService;\n"
  },
  {
    "path": "apps/server/src/recording/muxer.rs",
    "content": "use anyhow::{anyhow, Result};\nuse gstreamer::prelude::*;\nuse gstreamer_app::AppSrc;\nuse std::sync::Arc;\nuse tokio::sync::Mutex;\nuse tracing::{error, info, warn};\nuse webrtc::rtp::packet::Packet;\nuse webrtc::util::Marshal;\n\nuse crate::state::MediaType;\n\npub struct GstMuxer {\n    pipeline: gstreamer::Pipeline,\n    appsrc: AppSrc,\n    media_type: MediaType,\n    output_path: String,\n    bytes_written: Arc<Mutex<u64>>,\n}\n\nimpl GstMuxer {\n    pub fn new(output_path: &str, media_type: MediaType, payload_type: u8) -> Result<Self> {\n        gstreamer::init()?;\n\n        let pipeline_str = match media_type {\n            MediaType::Video => format!(\n                \"appsrc name=src format=3 is-live=true do-timestamp=true ! \\\n                 application/x-rtp,media=video,encoding-name=H264,clock-rate=90000,payload={} ! \\\n                 rtph264depay ! h264parse ! \\\n                 matroskamux ! filesink location={}\",\n                payload_type, output_path\n            ),\n            MediaType::Audio => format!(\n                \"appsrc name=src format=3 is-live=true do-timestamp=true ! \\\n                 application/x-rtp,media=audio,encoding-name=OPUS,clock-rate=48000,payload={} ! \\\n                 rtpopusdepay ! opusparse ! \\\n                 matroskamux ! filesink location={}\",\n                payload_type, output_path\n            ),\n        };\n\n        let pipeline = gstreamer::parse::launch(&pipeline_str)?\n            .downcast::<gstreamer::Pipeline>()\n            .map_err(|_| anyhow!(\"Pipeline is not a Pipeline element\"))?;\n\n        let appsrc = pipeline\n            .by_name(\"src\")\n            .ok_or_else(|| anyhow!(\"Failed to get appsrc element\"))?\n            .downcast::<AppSrc>()\n            .map_err(|_| anyhow!(\"Element is not an AppSrc\"))?;\n\n        // Set appsrc properties\n        appsrc.set_property(\"format\", gstreamer::Format::Time);\n        appsrc.set_property(\"is-live\", true);\n\n        Ok(Self {\n            pipeline,\n            appsrc,\n            media_type,\n            output_path: output_path.to_string(),\n            bytes_written: Arc::new(Mutex::new(0)),\n        })\n    }\n\n    pub fn start(&self) -> Result<()> {\n        self.pipeline.set_state(gstreamer::State::Playing)?;\n        info!(\"Recording pipeline started for {}\", self.output_path);\n        Ok(())\n    }\n\n    pub fn write_packet(&self, packet: &Packet) -> Result<()> {\n        let data = packet.marshal()?;\n        let mut buffer = gstreamer::Buffer::with_size(data.len())?;\n\n        {\n            let buffer_ref = buffer.get_mut().unwrap();\n            let mut map = buffer_ref.map_writable()?;\n            map.copy_from_slice(&data);\n        }\n\n        match self.appsrc.push_buffer(buffer) {\n            Ok(_) => {\n                // Track bytes written\n                let bytes_written = self.bytes_written.clone();\n                let data_len = data.len() as u64;\n                tokio::spawn(async move {\n                    let mut bw = bytes_written.lock().await;\n                    *bw += data_len;\n                });\n                Ok(())\n            }\n            Err(e) => {\n                warn!(\"Failed to push buffer to appsrc: {:?}\", e);\n                Err(anyhow!(\"Failed to push buffer: {:?}\", e))\n            }\n        }\n    }\n\n    pub async fn get_bytes_written(&self) -> u64 {\n        *self.bytes_written.lock().await\n    }\n\n    pub fn finalize(&self) -> Result<()> {\n        info!(\"Finalizing recording for {}\", self.output_path);\n\n        // Send EOS to gracefully close the file\n        self.appsrc.end_of_stream()?;\n\n        // Wait for EOS to propagate\n        let bus = self.pipeline.bus().unwrap();\n        let timeout = gstreamer::ClockTime::from_seconds(5);\n\n        for msg in bus.iter_timed(timeout) {\n            use gstreamer::MessageView;\n            match msg.view() {\n                MessageView::Eos(_) => {\n                    info!(\"EOS received, recording finalized: {}\", self.output_path);\n                    break;\n                }\n                MessageView::Error(err) => {\n                    error!(\n                        \"Error while finalizing recording: {} ({:?})\",\n                        err.error(),\n                        err.debug()\n                    );\n                    break;\n                }\n                _ => {}\n            }\n        }\n\n        self.pipeline.set_state(gstreamer::State::Null)?;\n        info!(\"Recording pipeline stopped for {}\", self.output_path);\n        Ok(())\n    }\n\n    pub fn output_path(&self) -> &str {\n        &self.output_path\n    }\n\n    pub fn media_type(&self) -> MediaType {\n        self.media_type\n    }\n}\n\nimpl Drop for GstMuxer {\n    fn drop(&mut self) {\n        if let Err(e) = self.pipeline.set_state(gstreamer::State::Null) {\n            error!(\"Failed to set pipeline to Null state: {}\", e);\n        }\n    }\n}\n"
  },
  {
    "path": "apps/server/src/recording/service.rs",
    "content": "use anyhow::Result;\nuse chrono::Utc;\nuse std::path::PathBuf;\nuse std::time::Instant;\nuse tokio::sync::{broadcast, watch};\nuse tracing::{error, info, warn};\nuse webrtc::rtp::packet::Packet;\n\nuse crate::db::models::UpdateRecording;\nuse crate::db::repository::recordings as recordings_repo;\nuse crate::recording::muxer::GstMuxer;\nuse crate::state::{DbPool, MediaType, StreamHandle};\n\npub struct RecordingService {\n    recording_id: i32,\n    stream_handle: StreamHandle,\n    output_path: PathBuf,\n    pool: DbPool,\n    media_type: MediaType,\n}\n\nimpl RecordingService {\n    pub fn new(\n        recording_id: i32,\n        stream_handle: StreamHandle,\n        output_path: PathBuf,\n        pool: DbPool,\n        media_type: MediaType,\n    ) -> Self {\n        Self {\n            recording_id,\n            stream_handle,\n            output_path,\n            pool,\n            media_type,\n        }\n    }\n\n    pub async fn run(&self, mut shutdown_rx: watch::Receiver<()>) -> Result<()> {\n        info!(\n            \"Starting recording service for recording_id={}, topic={}\",\n            self.recording_id, self.stream_handle.descriptor.topic\n        );\n\n        // Subscribe to stream's broadcast channel\n        let mut packet_rx = self.stream_handle.packet_tx.subscribe();\n\n        // Wait for first packet to extract payload type\n        let first_packet: Packet;\n        loop {\n            tokio::select! {\n                _ = shutdown_rx.changed() => {\n                    info!(\"Shutdown signal received before first packet for recording_id={}\", self.recording_id);\n                    self.update_recording_failed(\"Shutdown before receiving any packets\")?;\n                    return Ok(());\n                }\n                result = packet_rx.recv() => {\n                    match result {\n                        Ok(packet) => {\n                            first_packet = packet;\n                            break;\n                        }\n                        Err(broadcast::error::RecvError::Lagged(n)) => {\n                            warn!(\"Recording lagged {} packets while waiting for first packet\", n);\n                        }\n                        Err(broadcast::error::RecvError::Closed) => {\n                            info!(\"Broadcast channel closed before first packet for recording_id={}\", self.recording_id);\n                            self.update_recording_failed(\"Stream closed before receiving any packets\")?;\n                            return Ok(());\n                        }\n                    }\n                }\n            }\n        }\n\n        // Extract payload type from first packet\n        let payload_type = first_packet.header.payload_type;\n        info!(\n            \"First packet received for recording_id={}, payload_type={}\",\n            self.recording_id, payload_type\n        );\n\n        // Initialize GStreamer muxer with correct payload type\n        let muxer = match GstMuxer::new(\n            self.output_path.to_str().unwrap(),\n            self.media_type,\n            payload_type,\n        ) {\n            Ok(m) => m,\n            Err(e) => {\n                error!(\"Failed to initialize muxer: {}\", e);\n                self.update_recording_failed(&format!(\"Muxer init failed: {}\", e))?;\n                return Err(e);\n            }\n        };\n\n        if let Err(e) = muxer.start() {\n            error!(\"Failed to start muxer: {}\", e);\n            self.update_recording_failed(&format!(\"Muxer start failed: {}\", e))?;\n            return Err(e);\n        }\n\n        // Write the first packet\n        let start_time = Instant::now();\n        let mut packet_count: u64 = 0;\n        let mut last_log_time = Instant::now();\n\n        if let Err(e) = muxer.write_packet(&first_packet) {\n            warn!(\"Failed to write first packet: {}\", e);\n        } else {\n            packet_count += 1;\n        }\n\n        loop {\n            tokio::select! {\n                _ = shutdown_rx.changed() => {\n                    info!(\"Shutdown signal received for recording_id={}\", self.recording_id);\n                    break;\n                }\n                result = packet_rx.recv() => {\n                    match result {\n                        Ok(packet) => {\n                            if let Err(e) = muxer.write_packet(&packet) {\n                                warn!(\"Failed to write packet: {}\", e);\n                            } else {\n                                packet_count += 1;\n                            }\n\n                            // Log progress every 30 seconds\n                            if last_log_time.elapsed().as_secs() >= 30 {\n                                info!(\n                                    \"Recording in progress: id={}, packets={}, duration={}s\",\n                                    self.recording_id,\n                                    packet_count,\n                                    start_time.elapsed().as_secs()\n                                );\n                                last_log_time = Instant::now();\n                            }\n                        }\n                        Err(broadcast::error::RecvError::Lagged(n)) => {\n                            warn!(\"Recording lagged {} packets for recording_id={}\", n, self.recording_id);\n                        }\n                        Err(broadcast::error::RecvError::Closed) => {\n                            info!(\"Broadcast channel closed for recording_id={}\", self.recording_id);\n                            break;\n                        }\n                    }\n                }\n            }\n        }\n\n        // Finalize recording\n        info!(\"Finalizing recording_id={}\", self.recording_id);\n        if let Err(e) = muxer.finalize() {\n            error!(\"Failed to finalize muxer: {}\", e);\n        }\n\n        // Calculate duration and file size\n        let duration_ms = start_time.elapsed().as_millis() as i32;\n        let file_size = std::fs::metadata(&self.output_path)\n            .map(|m| m.len() as i32)\n            .unwrap_or(0);\n\n        // Update recording in database\n        self.update_recording_completed(duration_ms, file_size)?;\n\n        info!(\n            \"Recording completed: id={}, duration={}ms, size={} bytes, packets={}\",\n            self.recording_id, duration_ms, file_size, packet_count\n        );\n\n        Ok(())\n    }\n\n    fn update_recording_completed(&self, duration_ms: i32, file_size: i32) -> Result<()> {\n        let update = UpdateRecording {\n            status: Some(\"completed\".to_string()),\n            ended_at: Some(Utc::now().naive_utc()),\n            duration_ms: Some(duration_ms),\n            file_size: Some(file_size),\n        };\n\n        recordings_repo::update_recording(&self.pool, self.recording_id, &update)?;\n        Ok(())\n    }\n\n    fn update_recording_failed(&self, error_msg: &str) -> Result<()> {\n        let update = UpdateRecording {\n            status: Some(\"failed\".to_string()),\n            ended_at: Some(Utc::now().naive_utc()),\n            ..Default::default()\n        };\n\n        recordings_repo::update_recording(&self.pool, self.recording_id, &update)?;\n        error!(\n            \"Recording failed: id={}, error={}\",\n            self.recording_id, error_msg\n        );\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "apps/server/src/routes.rs",
    "content": "use std::sync::Arc;\n\nuse anyhow::Result;\nuse axum::{\n    http::{header, Method, StatusCode, Uri},\n    response::IntoResponse,\n    routing::{delete, get, post, put},\n    Json, Router,\n};\nuse serde_json::json;\nuse tokio::sync::watch;\nuse tower_http::cors::{Any, CorsLayer};\nuse tracing::info;\n\nuse crate::{\n    handler::{\n        auth::auth_with_password, configurations, custom_nodes, dashboards, device_tokens, devices,\n        entities, flows, ha, integration, log, map, permissions, recordings, roles, sdr, stat,\n        state, storage, streams, tunnel, users, ws::ws_handler,\n    },\n    state::AppState,\n};\nuse rust_embed::Embed;\n\nasync fn get_server_info() -> Json<serde_json::Value> {\n    Json(json!({\n        \"id\": \"vessel-server\",\n        \"status\": \"success\",\n        \"code\": 200,\n    }))\n}\n\n#[derive(Embed)]\n#[folder = \"../../apps/client/dist/\"]\nstruct ClientAsset;\n\nasync fn static_handler(uri: Uri) -> impl IntoResponse {\n    let mut path = uri.path().trim_start_matches('/').to_string();\n\n    if path.is_empty() {\n        path = \"index.html\".to_string();\n    }\n\n    match ClientAsset::get(&path) {\n        Some(content) => {\n            let mime = mime_guess::from_path(path).first_or_octet_stream();\n            ([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response()\n        }\n        None => match ClientAsset::get(\"index.html\") {\n            Some(content) => {\n                let mime = mime_guess::from_path(\"index.html\").first_or_octet_stream();\n                ([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response()\n            }\n            None => (StatusCode::NOT_FOUND, \"404 Not Found\").into_response(),\n        },\n    }\n}\n\npub async fn web_server(\n    addr: String,\n    app_state: Arc<AppState>,\n    mut shutdown_rx: watch::Receiver<()>,\n) -> Result<()> {\n    let cors = CorsLayer::new()\n        .allow_origin(Any)\n        .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])\n        .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]);\n\n    let api_routes = Router::new()\n        .route(\"/auth\", post(auth_with_password))\n        .route(\n            \"/users\",\n            get(users::get_users_list).post(users::create_user),\n        )\n        .route(\n            \"/users/:id\",\n            get(users::get_user)\n                .put(users::update_user)\n                .delete(users::delete_user),\n        )\n        .route(\n            \"/users/:id/roles\",\n            get(users::get_user_roles).post(users::assign_role_to_user),\n        )\n        .route(\n            \"/users/:id/roles/:role_id\",\n            delete(users::remove_role_from_user),\n        )\n        .route(\"/roles\", get(roles::get_roles).post(roles::create_role))\n        .route(\n            \"/roles/:id\",\n            put(roles::update_role).delete(roles::delete_role),\n        )\n        .route(\n            \"/roles/:id/permissions\",\n            get(roles::get_role_permissions).post(roles::grant_permission_to_role),\n        )\n        .route(\n            \"/roles/:id/permissions/:permission_id\",\n            delete(roles::revoke_permission_from_role),\n        )\n        .route(\n            \"/permissions\",\n            get(permissions::get_permissions).post(permissions::create_permission),\n        )\n        .route(\n            \"/devices\",\n            post(devices::create_device).get(devices::get_devices),\n        )\n        .route(\n            \"/devices/:id\",\n            put(devices::update_device).delete(devices::delete_device),\n        )\n        .route(\"/devices/id/:device_pk_id\", get(devices::get_device))\n        .route(\n            \"/entities\",\n            get(entities::get_entities).post(entities::create_entity),\n        )\n        .route(\"/entities/all\", get(entities::get_entities_with_states))\n        .route(\n            \"/entities/:entity_id/history\",\n            get(entities::get_entity_history),\n        )\n        .route(\n            \"/entities/:id\",\n            put(entities::update_entity).delete(entities::delete_entity),\n        )\n        .route(\n            \"/configurations\",\n            post(configurations::create_config).get(configurations::get_configs),\n        )\n        .route(\n            \"/configurations/:id\",\n            put(configurations::update_config).delete(configurations::delete_config),\n        )\n        .route(\"/streams/register\", post(streams::register_stream))\n        .route(\n            \"/devices/:id/token\",\n            post(device_tokens::issue_token)\n                .get(device_tokens::get_token_info)\n                .delete(device_tokens::revoke_token),\n        )\n        .route(\"/flows\", post(flows::create_flow).get(flows::get_all_flows))\n        .route(\n            \"/flows/:id\",\n            put(flows::update_flow).delete(flows::delete_flow),\n        )\n        .route(\n            \"/flows/:id/versions\",\n            post(flows::create_flow_version).get(flows::get_flow_versions),\n        )\n        .route(\"/stat\", get(stat::get_stats))\n        .route(\"/tunnel/start\", post(tunnel::start_tunnel))\n        .route(\"/tunnel/stop\", post(tunnel::stop_tunnel))\n        .route(\"/tunnel/status\", get(tunnel::status_tunnel))\n        .route(\"/logs\", get(log::list_log_files_handler))\n        .route(\"/logs/:filename\", get(log::get_log_by_filename_handler))\n        .route(\"/logs/latest\", get(log::get_latest_log_handler))\n        .route(\"/map/layers\", post(map::create_layer).get(map::get_layers))\n        .route(\n            \"/map/layers/:id\",\n            get(map::get_layer)\n                .put(map::update_layer)\n                .delete(map::delete_layer),\n        )\n        .route(\"/map/features\", post(map::create_feature))\n        .route(\n            \"/map/features/:id\",\n            get(map::get_feature)\n                .put(map::update_feature)\n                .delete(map::delete_feature),\n        )\n        .route(\"/ha/states\", get(ha::get_all_states))\n        .route(\"/ha/states/:entity_id\", post(ha::post_state))\n        .route(\"/sdr/frequency\", post(sdr::set_frequency))\n        .route(\"/sdr/samplerate\", get(sdr::get_samplerate))\n        .route(\"/sdr/start\", post(sdr::start_stream))\n        .route(\"/sdr/stop\", post(sdr::stop_stream))\n        .route(\"/sdr/audio\", get(sdr::sdr_audio_ws))\n        .route(\"/integrations/register\", post(integration::register_integration))\n        .route(\"/integrations/status\", get(integration::get_integration_status))\n        .route(\"/integrations/:integration_id\", delete(integration::delete_integration))\n        .route(\n            \"/custom-nodes\",\n            post(custom_nodes::create_custom_node_handler)\n                .get(custom_nodes::get_all_custom_nodes_handler),\n        )\n        .route(\n            \"/custom-nodes/:node_type\",\n            get(custom_nodes::get_custom_node_handler)\n                .put(custom_nodes::update_custom_node_handler)\n                .delete(custom_nodes::delete_custom_node_handler),\n        )\n        .route(\n            \"/dynamic-dashboards\",\n            get(dashboards::list_dashboards).post(dashboards::create_dashboard),\n        )\n        .route(\n            \"/dynamic-dashboards/:id\",\n            get(dashboards::get_dashboard)\n                .put(dashboards::update_dashboard)\n                .delete(dashboards::delete_dashboard),\n        )\n        .route(\n            \"/storage/\",\n            get(storage::read_handler)\n                .put(storage::create_or_update_file_handler)\n                .delete(storage::delete_handler),\n        )\n        .route(\n            \"/storage/*path\",\n            get(storage::read_handler)\n                .put(storage::create_or_update_file_handler)\n                .delete(storage::delete_handler),\n        )\n        .route(\"/storage/mkdir/*path\", post(storage::create_dir_handler))\n        .route(\"/storage/rename/*from_path\", post(storage::rename_handler))\n        .route(\"/states/*topic\", post(state::set_state))\n        .route(\n            \"/recordings\",\n            post(recordings::start_recording).get(recordings::list_recordings),\n        )\n        .route(\"/recordings/active\", get(recordings::get_active_recordings))\n        .route(\n            \"/recordings/active/:topic\",\n            get(recordings::is_topic_recording),\n        )\n        .route(\n            \"/recordings/:id\",\n            get(recordings::get_recording).delete(recordings::delete_recording),\n        )\n        .route(\"/recordings/:id/stop\", post(recordings::stop_recording))\n        .route(\"/recordings/:id/stream\", get(recordings::stream_recording));\n\n    let app = Router::new()\n        .route(\"/info\", get(get_server_info))\n        .route(\"/signal\", get(ws_handler))\n        .nest(\"/api\", api_routes)\n        .with_state(app_state)\n        .layer(cors)\n        .fallback(static_handler);\n    info!(\"Signal server listening on {}\", addr);\n    let listener = tokio::net::TcpListener::bind(addr).await?;\n\n    axum::serve(listener, app.into_make_service())\n        .with_graceful_shutdown(async move {\n            shutdown_rx.changed().await.ok();\n            info!(\"Shutting down web server...\");\n        })\n        .await?;\n    Ok(())\n}\n"
  },
  {
    "path": "apps/server/src/state.rs",
    "content": "use bytes::Bytes;\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse std::sync::Arc;\nuse tokio::sync::{broadcast, mpsc, watch, RwLock};\nuse tokio::time::Instant;\nuse webrtc::rtp::packet::Packet;\n\nuse diesel::r2d2::{self, ConnectionManager};\nuse diesel::sqlite::SqliteConnection;\n\npub type DbPool = r2d2::Pool<ConnectionManager<SqliteConnection>>;\n\nuse dashmap::DashMap;\n\nuse crate::flow::manager_state::FlowManagerCommand;\nuse crate::recording::RecordingManager;\nuse crate::{db::models::SystemConfiguration, tunnel_control::TunnelManager};\n\n#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]\n#[serde(rename_all = \"lowercase\")]\npub enum MediaType {\n    Audio,\n    Video,\n}\n\n#[derive(Serialize, Clone, Debug, PartialEq, Eq)]\npub enum Protocol {\n    MQTT,\n    Udp,\n    Lora,\n    RTSP,\n    Http,\n    HomeAssistant,\n    Ros2,\n    Sdrpp,\n}\n\n#[derive(Clone, Debug)]\npub struct StreamDescriptor {\n    pub id: u32,\n    pub topic: String,\n    pub user_id: String,\n    pub media_type: MediaType,\n    pub protocol: Protocol,\n}\n\n#[derive(Clone, Debug)]\npub struct StreamHandle {\n    pub descriptor: StreamDescriptor,\n    pub packet_tx: broadcast::Sender<Packet>,\n    pub last_seen: Arc<std::sync::RwLock<Instant>>,\n    pub is_online: Arc<std::sync::RwLock<bool>>,\n}\n\n#[derive(Default)]\npub struct StreamRegistry {\n    streams: DashMap<u32, StreamHandle>,\n    topic_index: DashMap<String, u32>,\n}\n\nimpl StreamRegistry {\n    pub fn new() -> Self {\n        Self::default()\n    }\n\n    pub fn register(&self, descriptor: StreamDescriptor) -> StreamHandle {\n        let (packet_tx, _) = broadcast::channel::<Packet>(8192);\n        self.insert_with_sender(descriptor, packet_tx)\n    }\n\n    pub fn insert_with_sender(\n        &self,\n        descriptor: StreamDescriptor,\n        packet_tx: broadcast::Sender<Packet>,\n    ) -> StreamHandle {\n        let handle = StreamHandle {\n            descriptor: descriptor.clone(),\n            packet_tx,\n            last_seen: Arc::new(std::sync::RwLock::new(Instant::now())),\n            is_online: Arc::new(std::sync::RwLock::new(false)),\n        };\n\n        self.topic_index\n            .insert(descriptor.topic.clone(), descriptor.id);\n        self.streams.insert(descriptor.id, handle.clone());\n        handle\n    }\n\n    pub fn get_by_ssrc(&self, ssrc: u32) -> Option<StreamHandle> {\n        self.streams.get(&ssrc).map(|h| h.clone())\n    }\n\n    pub fn get_by_topic(&self, topic: &str) -> Option<StreamHandle> {\n        self.topic_index\n            .get(topic)\n            .and_then(|entry| self.get_by_ssrc(*entry.value()))\n    }\n\n    pub fn mark_online(&self, ssrc: u32) {\n        if let Some(handle) = self.streams.get(&ssrc) {\n            if let Ok(mut is_online) = handle.is_online.write() {\n                *is_online = true;\n            }\n            if let Ok(mut last_seen) = handle.last_seen.write() {\n                *last_seen = Instant::now();\n            }\n        }\n    }\n\n    pub fn update_last_seen(&self, ssrc: u32) {\n        if let Some(handle) = self.streams.get(&ssrc) {\n            if let Ok(mut last_seen) = handle.last_seen.write() {\n                *last_seen = Instant::now();\n            }\n        }\n    }\n\n    pub fn iter(&self) -> dashmap::iter::Iter<'_, u32, StreamHandle> {\n        self.streams.iter()\n    }\n\n    pub fn remove(&self, ssrc: &u32) -> Option<(u32, StreamHandle)> {\n        if let Some((key, handle)) = self.streams.remove(ssrc) {\n            self.topic_index.remove(&handle.descriptor.topic);\n            Some((key, handle))\n        } else {\n            None\n        }\n    }\n\n    pub fn len(&self) -> usize {\n        self.streams.len()\n    }\n}\n\npub type StreamManager = Arc<StreamRegistry>;\n\n#[derive(Serialize, Clone, Debug)]\npub struct MqttMessage {\n    pub topic: String,\n    pub bytes: Bytes,\n}\n\n#[derive(Serialize, Clone, Debug)]\npub struct TopicMapping {\n    pub protocol: Protocol,\n    pub topic: String,\n    pub entity_id: String,\n}\n\n/// Dynamic dashboard widgets → running flows (via `DASHBOARD_EVENT_LISTENER` nodes).\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct DashboardUiEvent {\n    pub listener_id: String,\n    pub event_id: String,\n    #[serde(default)]\n    pub occurred_at: Option<String>,\n    #[serde(default)]\n    pub dashboard_id: Option<String>,\n    #[serde(default)]\n    pub group_id: Option<String>,\n    #[serde(default)]\n    pub item_id: Option<String>,\n    pub component_type: String,\n    pub action: String,\n    #[serde(default)]\n    pub value: Option<Value>,\n    pub source_session_id: String,\n}\n\npub struct AppState {\n    pub streams: StreamManager,\n    pub mqtt_tx: broadcast::Sender<MqttMessage>,\n    pub jwt_secret: String,\n    pub pool: DbPool,\n    pub topic_map: Arc<RwLock<Vec<TopicMapping>>>,\n    /// Notifies subscribers when topic_map has been updated\n    pub topic_map_notify: watch::Sender<()>,\n    pub flow_manager_tx: mpsc::Sender<FlowManagerCommand>,\n    pub dashboard_ui_tx: broadcast::Sender<DashboardUiEvent>,\n    pub broadcast_tx: broadcast::Sender<String>,\n    pub system_configs: Vec<SystemConfiguration>,\n    pub tunnel_manager: Arc<TunnelManager>,\n    pub recording_manager: Arc<RecordingManager>,\n}\n"
  },
  {
    "path": "apps/server/src/tunnel_control.rs",
    "content": "use std::{\n    collections::HashMap,\n    sync::Arc,\n};\n\nuse anyhow::{anyhow, Context, Result};\nuse bytes::Bytes;\nuse futures_util::{SinkExt, StreamExt};\nuse httparse;\nuse tokio::{\n    io::{AsyncReadExt, AsyncWriteExt},\n    net::TcpStream,\n    sync::{mpsc, Mutex, oneshot},\n};\nuse tokio_tungstenite::tungstenite;\nuse url::Url;\n\nconst BINARY_PREFIX: usize = 8;\nconst MAX_HEADER_BYTES: usize = 64 * 1024;\n\n#[derive(Debug, Clone)]\npub struct TunnelStatus {\n    pub active: bool,\n    pub session_id: Option<String>,\n    pub server: Option<String>,\n    pub target: Option<String>,\n}\n\npub struct TunnelManager {\n    inner: Mutex<Inner>,\n}\n\nstruct Inner {\n    handle: Option<tokio::task::JoinHandle<()>>,\n    cancel: Option<oneshot::Sender<()>>,\n    session_id: Option<String>,\n    server: Option<String>,\n    target: Option<String>,\n}\n\nimpl TunnelManager {\n    pub fn new() -> Self {\n        Self {\n            inner: Mutex::new(Inner {\n                handle: None,\n                cancel: None,\n                session_id: None,\n                server: None,\n                target: None,\n            }),\n        }\n    }\n\n    pub async fn start(&self, server: String, target: String, access_token: Option<String>) -> Result<String> {\n        let mut guard = self.inner.lock().await;\n        if let Some(session) = guard.session_id.clone() {\n            return Ok(session);\n        }\n\n        let (cancel_tx, cancel_rx) = oneshot::channel();\n        let (session_tx, session_rx) = oneshot::channel();\n        let server_clone = server.clone();\n        let target_clone = target.clone();\n\n        let handle = tokio::spawn(async move {\n            if let Err(err) = run_agent(server_clone, target_clone, access_token, session_tx, cancel_rx).await {\n                tracing::warn!(\"tunnel agent stopped: {err:#}\");\n            }\n        });\n\n        let session_id = session_rx\n            .await\n            .map_err(|_| anyhow!(\"agent failed to report session id\"))?;\n\n        guard.handle = Some(handle);\n        guard.cancel = Some(cancel_tx);\n        guard.session_id = Some(session_id.clone());\n        guard.server = Some(server);\n        guard.target = Some(target);\n\n        Ok(session_id)\n    }\n\n    pub async fn stop(&self) -> Result<()> {\n        let mut guard = self.inner.lock().await;\n        if let Some(cancel) = guard.cancel.take() {\n            let _ = cancel.send(());\n        }\n        if let Some(handle) = guard.handle.take() {\n            let _ = handle.await;\n        }\n        guard.session_id = None;\n        guard.server = None;\n        guard.target = None;\n        Ok(())\n    }\n\n    pub async fn status(&self) -> TunnelStatus {\n        let guard = self.inner.lock().await;\n        TunnelStatus {\n            active: guard.session_id.is_some(),\n            session_id: guard.session_id.clone(),\n            server: guard.server.clone(),\n            target: guard.target.clone(),\n        }\n    }\n}\n\n#[derive(Debug, serde::Serialize, serde::Deserialize)]\n#[serde(tag = \"kind\", rename_all = \"snake_case\")]\nenum AgentToServer {\n    Register { target: String },\n    ResponseHead {\n        stream_id: u64,\n        status: u16,\n        headers: Vec<(String, String)>,\n    },\n    ResponseFinished { stream_id: u64 },\n    Error {\n        stream_id: Option<u64>,\n        message: String,\n    },\n}\n\n#[derive(Debug, serde::Serialize, serde::Deserialize)]\n#[serde(tag = \"kind\", rename_all = \"snake_case\")]\nenum ServerToAgent {\n    Registered { session_id: String },\n    Open {\n        stream_id: u64,\n        method: String,\n        path: String,\n        headers: Vec<(String, String)>,\n        body_len: usize,\n    },\n    BodyFinished { stream_id: u64 },\n    Close {\n        stream_id: u64,\n        reason: Option<String>,\n    },\n}\n\nstruct AgentState {\n    target: TargetInfo,\n    pending: Mutex<HashMap<u64, PendingRequest>>,\n    outbound: mpsc::Sender<AgentOutbound>,\n}\n\nstruct PendingRequest {\n    method: String,\n    path: String,\n    headers: Vec<(String, String)>,\n    body: Vec<u8>,\n    body_finished: bool,\n    tx: mpsc::Sender<Bytes>,\n    rx: Option<mpsc::Receiver<Bytes>>,\n    upgrade: bool,\n}\n\nenum AgentOutbound {\n    Control(AgentToServer),\n    Body { stream_id: u64, chunk: Bytes },\n}\n\n#[derive(Clone)]\nstruct TargetInfo {\n    raw: String,\n    host: String,\n    port: u16,\n}\n\nimpl TargetInfo {\n    fn parse(raw: &str) -> Result<Self> {\n        let url =\n            Url::parse(raw).context(\"target must be a full URL, e.g. http://127.0.0.1:3000\")?;\n        let host = url\n            .host_str()\n            .ok_or_else(|| anyhow!(\"target host missing\"))?\n            .to_string();\n        let port = url\n            .port_or_known_default()\n            .ok_or_else(|| anyhow!(\"target port missing\"))?;\n        Ok(Self {\n            raw: raw.to_string(),\n            host,\n            port,\n        })\n    }\n\n    fn host_header(&self) -> String {\n        format!(\"{}:{}\", self.host, self.port)\n    }\n}\n\nasync fn run_agent(\n    server: String,\n    target: String,\n    access_token: Option<String>,\n    session_tx: oneshot::Sender<String>,\n    mut cancel: oneshot::Receiver<()>,\n) -> Result<()> {\n    let target_info = TargetInfo::parse(&target)?;\n\n    // Parse the server URL to extract host for the Host header\n    let server_url = Url::parse(&server).context(\"invalid tunnel server URL\")?;\n    let server_host = server_url\n        .host_str()\n        .ok_or_else(|| anyhow!(\"tunnel server URL has no host\"))?;\n    let host_header = match server_url.port() {\n        Some(port) => format!(\"{}:{}\", server_host, port),\n        None => server_host.to_string(),\n    };\n\n    // Build WebSocket request with optional Authorization header\n    let mut request = http::Request::builder()\n        .uri(&server)\n        .header(\"Host\", &host_header)\n        .header(\"Connection\", \"Upgrade\")\n        .header(\"Upgrade\", \"websocket\")\n        .header(\"Sec-WebSocket-Version\", \"13\")\n        .header(\"Sec-WebSocket-Key\", tungstenite::handshake::client::generate_key());\n\n    if let Some(token) = &access_token {\n        request = request.header(\"Authorization\", format!(\"Bearer {}\", token));\n    }\n\n    let request = request.body(()).context(\"failed to build websocket request\")?;\n    let (ws_stream, _) = tokio_tungstenite::connect_async(request)\n        .await\n        .context(\"failed to connect to tunnel server\")?;\n    let (mut ws_tx, mut ws_rx) = ws_stream.split();\n\n    let register = AgentToServer::Register {\n        target: target.clone(),\n    };\n    ws_tx\n        .send(tungstenite::Message::Text(\n            serde_json::to_string(&register)?.into(),\n        ))\n        .await?;\n\n    let session_id = wait_for_registration(&mut ws_rx).await?;\n    let _ = session_tx.send(session_id.clone());\n    tracing::info!(%session_id, target = %target_info.raw, \"tunnel agent connected\");\n\n    let (out_tx, mut out_rx) = mpsc::channel::<AgentOutbound>(64);\n    let writer = tokio::spawn(async move {\n        while let Some(msg) = out_rx.recv().await {\n            match msg {\n                AgentOutbound::Control(ctrl) => {\n                    let txt = serde_json::to_string(&ctrl)?;\n                    ws_tx.send(tungstenite::Message::Text(txt.into())).await?;\n                }\n                AgentOutbound::Body { stream_id, chunk } => {\n                    let mut data = Vec::with_capacity(BINARY_PREFIX + chunk.len());\n                    data.extend_from_slice(&stream_id.to_be_bytes());\n                    data.extend_from_slice(&chunk);\n                    ws_tx\n                        .send(tungstenite::Message::Binary(data.into()))\n                        .await?;\n                }\n            }\n        }\n        Result::<_, anyhow::Error>::Ok(())\n    });\n\n    let agent = Arc::new(AgentState {\n        target: target_info,\n        pending: Mutex::new(HashMap::new()),\n        outbound: out_tx.clone(),\n    });\n\n    let reader_agent = agent.clone();\n    let reader = tokio::spawn(async move {\n        while let Some(msg) = ws_rx.next().await {\n            match msg {\n                Ok(tungstenite::Message::Text(txt)) => {\n                    let ctrl: ServerToAgent = serde_json::from_str(txt.as_ref())?;\n                    handle_server_control(reader_agent.clone(), ctrl).await?;\n                }\n                Ok(tungstenite::Message::Binary(data)) => {\n                    handle_server_body(reader_agent.clone(), data).await?;\n                }\n                Ok(tungstenite::Message::Close(_)) | Err(_) => break,\n                _ => {}\n            }\n        }\n        Result::<_, anyhow::Error>::Ok(())\n    });\n\n    tokio::select! {\n        res = writer => { res??; }\n        res = reader => { res??; }\n        _ = &mut cancel => {\n            tracing::info!(\"tunnel agent stop requested\");\n        }\n    };\n\n    Ok(())\n}\n\nasync fn wait_for_registration(\n    ws_rx: &mut (impl StreamExt<Item = Result<tungstenite::Message, tungstenite::Error>> + Unpin),\n) -> Result<String> {\n    while let Some(msg) = ws_rx.next().await {\n        let msg = msg?;\n        if let tungstenite::Message::Text(txt) = msg {\n            if let Ok(ServerToAgent::Registered { session_id }) = serde_json::from_str(txt.as_ref())\n            {\n                return Ok(session_id);\n            }\n        }\n    }\n    Err(anyhow!(\"server closed before registration ack\"))\n}\n\nasync fn handle_server_control(agent: Arc<AgentState>, msg: ServerToAgent) -> Result<()> {\n    match msg {\n        ServerToAgent::Open {\n            stream_id,\n            method,\n            path,\n            headers,\n            body_len: _,\n        } => {\n            let mut pending = agent.pending.lock().await;\n            let headers_clone = headers.clone();\n            let (tx, rx) = mpsc::channel(64);\n            pending.insert(\n                stream_id,\n                PendingRequest {\n                    method,\n                    path,\n                    headers,\n                    body: Vec::new(),\n                    body_finished: false,\n                    tx,\n                    rx: Some(rx),\n                    upgrade: is_upgrade(&headers_clone),\n                },\n            );\n        }\n        ServerToAgent::BodyFinished { stream_id } => {\n            if let Some(pending) = agent.pending.lock().await.get_mut(&stream_id) {\n                pending.body_finished = true;\n                let agent_clone = agent.clone();\n                if let Some(rx) = pending.rx.take() {\n                    let req = PendingRequest {\n                        method: pending.method.clone(),\n                        path: pending.path.clone(),\n                        headers: pending.headers.clone(),\n                        body: std::mem::take(&mut pending.body),\n                        body_finished: true,\n                        tx: pending.tx.clone(),\n                        rx: Some(rx),\n                        upgrade: pending.upgrade,\n                    };\n                    tokio::spawn(async move {\n                        if let Err(err) = forward_request(agent_clone.clone(), stream_id, req).await\n                        {\n                            let _ = agent_clone\n                                .outbound\n                                .send(AgentOutbound::Control(AgentToServer::Error {\n                                    stream_id: Some(stream_id),\n                                    message: err.to_string(),\n                                }))\n                                .await;\n                            tracing::warn!(\"stream {stream_id} failed: {err:#}\");\n                        }\n                    });\n                }\n            }\n        }\n        ServerToAgent::Close { stream_id, reason } => {\n            agent.pending.lock().await.remove(&stream_id);\n            if let Some(reason) = reason {\n                tracing::info!(stream_id, \"server closed stream: {reason}\");\n            }\n        }\n        ServerToAgent::Registered { .. } => {\n            // handled in wait_for_registration\n        }\n    }\n    Ok(())\n}\n\nasync fn handle_server_body(agent: Arc<AgentState>, data: Bytes) -> Result<()> {\n    if data.len() < BINARY_PREFIX {\n        return Err(anyhow!(\"binary frame too short\"));\n    }\n    let stream_id = u64::from_be_bytes(data[..BINARY_PREFIX].try_into()?);\n    let payload = &data[BINARY_PREFIX..];\n    let mut guard = agent.pending.lock().await;\n    if let Some(entry) = guard.get_mut(&stream_id) {\n        if entry.body_finished {\n            let _ = entry.tx.send(Bytes::copy_from_slice(payload)).await;\n        } else {\n            entry.body.extend_from_slice(payload);\n        }\n    }\n    Ok(())\n}\n\nasync fn forward_request(\n    agent: Arc<AgentState>,\n    stream_id: u64,\n    req: PendingRequest,\n) -> Result<()> {\n    let addr = format!(\"{}:{}\", agent.target.host, agent.target.port);\n    let mut stream = TcpStream::connect(addr)\n        .await\n        .context(\"failed to reach target\")?;\n\n    let mut request = Vec::new();\n    use std::io::Write as _;\n    write!(&mut request, \"{} {} HTTP/1.1\\r\\n\", req.method, req.path)?;\n\n    let mut has_host = false;\n    let mut has_length = false;\n    for (name, value) in req.headers.iter() {\n        if name.eq_ignore_ascii_case(\"host\") {\n            has_host = true;\n            continue;\n        }\n        if name.eq_ignore_ascii_case(\"content-length\") {\n            has_length = true;\n        }\n        write!(&mut request, \"{}: {}\\r\\n\", name, value)?;\n    }\n    if !has_host {\n        write!(&mut request, \"Host: {}\\r\\n\", agent.target.host_header())?;\n    }\n    if !has_length && !req.body.is_empty() {\n        write!(&mut request, \"Content-Length: {}\\r\\n\", req.body.len())?;\n    }\n    request.extend_from_slice(b\"\\r\\n\");\n    request.extend_from_slice(&req.body);\n\n    stream.write_all(&request).await?;\n\n    // Parse response head\n    let mut buffer = Vec::new();\n    let mut header_buf = [0u8; 4096];\n    let header_len = loop {\n        let n = stream.read(&mut header_buf).await?;\n        if n == 0 {\n            return Err(anyhow!(\"target closed before sending response\"));\n        }\n        buffer.extend_from_slice(&header_buf[..n]);\n        if let Some(pos) = find_header_split(&buffer) {\n            break pos;\n        }\n        if buffer.len() > MAX_HEADER_BYTES {\n            return Err(anyhow!(\"response headers too large\"));\n        }\n    };\n\n    let (header_bytes, body_start) = buffer.split_at(header_len);\n    let mut headers = [httparse::EMPTY_HEADER; 64];\n    let mut resp = httparse::Response::new(&mut headers);\n    resp.parse(header_bytes)\n        .map_err(|e| anyhow!(\"failed to parse response headers: {e:?}\"))?;\n    let status = resp.code.unwrap_or(502) as u16;\n    let header_pairs: Vec<(String, String)> = resp\n        .headers\n        .iter()\n        .filter(|h| !h.name.is_empty())\n        .map(|h| {\n            (\n                h.name.to_string(),\n                String::from_utf8_lossy(h.value).to_string(),\n            )\n        })\n        .collect();\n\n    agent\n        .outbound\n        .send(AgentOutbound::Control(AgentToServer::ResponseHead {\n            stream_id,\n            status,\n            headers: header_pairs,\n        }))\n        .await\n        .ok();\n\n    if req.upgrade {\n        let (mut target_reader, mut target_writer) = stream.into_split();\n        if !body_start.is_empty() {\n            agent\n                .outbound\n                .send(AgentOutbound::Body {\n                    stream_id,\n                    chunk: Bytes::copy_from_slice(body_start),\n                })\n                .await\n                .ok();\n        }\n\n        let mut rx = req.rx.unwrap_or_else(|| {\n            let (_tx, rx) = mpsc::channel(1);\n            rx\n        });\n        let outbound = agent.outbound.clone();\n        let reader_task = tokio::spawn(async move {\n            let mut buf = [0u8; 16 * 1024];\n            loop {\n                let n = target_reader.read(&mut buf).await?;\n                if n == 0 {\n                    break;\n                }\n                outbound\n                    .send(AgentOutbound::Body {\n                        stream_id,\n                        chunk: Bytes::copy_from_slice(&buf[..n]),\n                    })\n                    .await\n                    .ok();\n            }\n            outbound\n                .send(AgentOutbound::Control(AgentToServer::ResponseFinished {\n                    stream_id,\n                }))\n                .await\n                .ok();\n            Ok::<_, anyhow::Error>(())\n        });\n\n        while let Some(chunk) = rx.recv().await {\n            if target_writer.write_all(&chunk).await.is_err() {\n                break;\n            }\n        }\n        let _ = reader_task.await;\n    } else {\n        if !body_start.is_empty() {\n            agent\n                .outbound\n                .send(AgentOutbound::Body {\n                    stream_id,\n                    chunk: Bytes::copy_from_slice(body_start),\n                })\n                .await\n                .ok();\n        }\n\n        let mut buf = [0u8; 16 * 1024];\n        loop {\n            let n = stream.read(&mut buf).await?;\n            if n == 0 {\n                break;\n            }\n            agent\n                .outbound\n                .send(AgentOutbound::Body {\n                    stream_id,\n                    chunk: Bytes::copy_from_slice(&buf[..n]),\n                })\n                .await\n                .ok();\n        }\n\n        agent\n            .outbound\n            .send(AgentOutbound::Control(AgentToServer::ResponseFinished {\n                stream_id,\n            }))\n            .await\n            .ok();\n    }\n\n    Ok(())\n}\n\nfn find_header_split(buf: &[u8]) -> Option<usize> {\n    buf.windows(4)\n        .position(|w| w == b\"\\r\\n\\r\\n\")\n        .map(|pos| pos + 4)\n}\n\nfn is_upgrade(headers: &[(String, String)]) -> bool {\n    let mut connection = String::new();\n    let mut upgrade = String::new();\n    for (k, v) in headers {\n        if k.eq_ignore_ascii_case(\"connection\") {\n            connection = v.to_ascii_lowercase();\n        }\n        if k.eq_ignore_ascii_case(\"upgrade\") {\n            upgrade = v.to_ascii_lowercase();\n        }\n    }\n    connection.contains(\"upgrade\") || !upgrade.is_empty()\n}\n"
  },
  {
    "path": "apps/server/src/utils/entity_map.rs",
    "content": "use crate::{\n    db::{self},\n    error::AppError,\n    state::{AppState, Protocol, TopicMapping},\n};\nuse axum::extract::State;\nuse std::sync::Arc;\nuse tracing::info;\n\npub async fn remap_topics(State(state): State<Arc<AppState>>) -> Result<usize, AppError> {\n    let entities = db::repository::get_all_entities_with_configs(&state.pool)?;\n\n    let mut new_mappings = Vec::new();\n\n    for entity_with_config in &entities {\n        if let Some(platform) = &entity_with_config.entity.platform {\n            let protocol = match platform.as_str() {\n                \"MQTT\" => Some(Protocol::MQTT),\n                \"udp\" => Some(Protocol::Udp),\n                \"lora\" => Some(Protocol::Lora),\n                \"RTSP\" => Some(Protocol::RTSP),\n                \"HTTP\" => Some(Protocol::Http),\n                \"home_assistant\" => Some(Protocol::HomeAssistant),\n                \"ros2\" => Some(Protocol::Ros2),\n                _ => None,\n            };\n\n            if let (Some(proto), Some(config)) = (protocol, &entity_with_config.configuration) {\n                if let Some(topic) = config.get(\"state_topic\").and_then(|v| v.as_str()) {\n                    new_mappings.push(TopicMapping {\n                        protocol: proto.clone(),\n                        topic: topic.to_string(),\n                        entity_id: entity_with_config.entity.entity_id.clone(),\n                    });\n                }\n\n                if let Some(topic) = config.get(\"rtsp_url\").and_then(|v| v.as_str()) {\n                    new_mappings.push(TopicMapping {\n                        protocol: proto.clone(),\n                        topic: topic.to_string(),\n                        entity_id: entity_with_config.entity.entity_id.clone(),\n                    });\n                }\n\n                if let Some(topic) = config.get(\"command_topic\").and_then(|v| v.as_str()) {\n                    new_mappings.push(TopicMapping {\n                        protocol: proto.clone(),\n                        topic: topic.to_string(),\n                        entity_id: entity_with_config.entity.entity_id.clone(),\n                    });\n                }\n            }\n        }\n    }\n\n    info!(\"Mapping state\");\n    info!(\"{:?}\", new_mappings);\n\n    let num_mappings = new_mappings.len();\n\n    let mut map = state.topic_map.write().await;\n    *map = new_mappings;\n    drop(map);\n\n    // Notify subscribers that topic_map has changed\n    if let Err(e) = state.topic_map_notify.send(()) {\n        // This only fails if all receivers are dropped, which is expected during shutdown\n        tracing::debug!(\"Failed to send topic_map_notify signal: {}\", e);\n    } else {\n        info!(\"Topic map updated, notified subscribers\");\n    }\n\n    Ok(num_mappings)\n}\n"
  },
  {
    "path": "apps/server/src/utils/hash.rs",
    "content": "pub fn hash_password(password: &str) -> Result<String, bcrypt::BcryptError> {\n    let cost = 12;\n    bcrypt::hash(password, cost)\n}\n\npub fn verify_password(password: &str, hashed_password: &str) -> Result<bool, bcrypt::BcryptError> {\n    bcrypt::verify(password, hashed_password)\n}\n"
  },
  {
    "path": "apps/server/src/utils/mod.rs",
    "content": "pub mod entity_map;\npub mod hash;\npub mod stream_checker;\npub mod system_configs;\n"
  },
  {
    "path": "apps/server/src/utils/stream_checker.rs",
    "content": "use anyhow::Result;\nuse std::sync::Arc;\nuse std::time::Duration;\nuse tokio::sync::watch;\nuse tracing::info;\n\nuse crate::state::AppState;\n\nconst STREAM_TIMEOUT: Duration = Duration::from_secs(10);\n\npub async fn stream_status_checker(\n    app_state: Arc<AppState>,\n    mut shutdown_rx: watch::Receiver<()>,\n) -> Result<()> {\n    let mut interval = tokio::time::interval(Duration::from_secs(5));\n\n    loop {\n        tokio::select! {\n            biased;\n\n            _ = shutdown_rx.changed() => {\n                info!(\"Stream status checker: Shutdown signal received, exiting.\");\n                break;\n            }\n\n            _ = interval.tick() => {\n                let mut offline_ssrcs = Vec::new();\n\n                for entry in app_state.streams.iter() {\n                    let ssrc = *entry.key();\n                    let stream_info = entry.value();\n\n                    let _is_online = *stream_info.is_online.read().unwrap();\n                    let last_seen = *stream_info.last_seen.read().unwrap();\n\n                    if last_seen.elapsed() > STREAM_TIMEOUT {\n                        let mut is_online_guard = stream_info.is_online.write().unwrap();\n                        *is_online_guard = false;\n                        info!(\n                            \"Topic '{}' (SSRC: {}) is now OFFLINE due to timeout.\",\n                            &stream_info.descriptor.topic, ssrc\n                        );\n                        offline_ssrcs.push(ssrc);\n                    }\n                }\n\n                for ssrc in offline_ssrcs {\n                    if let Some((_, removed_stream)) = app_state.streams.remove(&ssrc) {\n                        // db::repository::streams::delete_stream(&app_state.pool, ssrc.try_into().unwrap());\n                        info!(\n                            \"Removed timed-out stream for topic '{}' (SSRC: {})\",\n                            removed_stream.descriptor.topic, ssrc\n                        );\n                    }\n                }\n            }\n        }\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "apps/server/src/utils/system_configs.rs",
    "content": "use crate::db::models::SystemConfiguration;\n\npub fn replace_config_placeholders(template: &str, configs: &[SystemConfiguration]) -> String {\n    let mut result = String::with_capacity(template.len());\n    let mut last_end = 0;\n\n    while let Some(start) = template[last_end..].find(\"{:\") {\n        let absolute_start = last_end + start;\n        result.push_str(&template[last_end..absolute_start]);\n\n        let search_area = &template[absolute_start + 2..];\n        if let Some(end) = search_area.find('}') {\n            let absolute_end = absolute_start + 2 + end;\n            let key = &template[absolute_start + 2..absolute_end];\n\n            let value = configs\n                .iter()\n                .find(|config| config.key == key)\n                .map(|config| config.value.as_str());\n\n            if let Some(v) = value {\n                result.push_str(v);\n            } else {\n                result.push_str(&template[absolute_start..absolute_end + 1]);\n            }\n\n            last_end = absolute_end + 1;\n        } else {\n            result.push_str(&template[absolute_start..absolute_start + 2]);\n            last_end = absolute_start + 2;\n        }\n    }\n\n    result.push_str(&template[last_end..]);\n    result\n}\n"
  },
  {
    "path": "apps/server/tests/common/mod.rs",
    "content": "use diesel::r2d2::{self, ConnectionManager};\nuse diesel::sqlite::SqliteConnection;\nuse diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};\nuse server::state::DbPool;\n\npub const MIGRATIONS: EmbeddedMigrations = embed_migrations!(\"migrations\");\n\n/// Create an in-memory SQLite pool for testing\npub fn setup_test_pool() -> DbPool {\n    let manager = ConnectionManager::<SqliteConnection>::new(\":memory:\");\n    let pool = r2d2::Pool::builder()\n        .max_size(1)\n        .build(manager)\n        .expect(\"Failed to create pool\");\n\n    // Run migrations\n    let mut conn = pool.get().expect(\"Failed to get connection\");\n    conn.run_pending_migrations(MIGRATIONS)\n        .expect(\"Failed to run migrations\");\n\n    pool\n}\n"
  },
  {
    "path": "apps/server/tests/recording_manager_tests.rs",
    "content": "mod common;\n\nuse server::recording::RecordingManager;\nuse server::state::{MediaType, Protocol, StreamDescriptor, StreamRegistry};\nuse std::sync::Arc;\nuse tokio::sync::broadcast;\nuse webrtc::rtp::packet::Packet;\n\nfn setup_test_streams() -> Arc<StreamRegistry> {\n    let registry = StreamRegistry::new();\n    let (tx, _) = broadcast::channel::<Packet>(1024);\n\n    let descriptor = StreamDescriptor {\n        id: 12345,\n        topic: \"test/stream/topic\".to_string(),\n        user_id: \"device_001\".to_string(),\n        media_type: MediaType::Video,\n        protocol: Protocol::RTSP,\n    };\n\n    registry.insert_with_sender(descriptor, tx);\n    Arc::new(registry)\n}\n\n#[test]\nfn test_is_recording_false_initially() {\n    let pool = common::setup_test_pool();\n    let streams = setup_test_streams();\n    let manager = RecordingManager::new(streams, pool);\n\n    assert!(!manager.is_recording(\"test/stream/topic\"));\n}\n\n#[test]\nfn test_get_active_recording_id_none() {\n    let pool = common::setup_test_pool();\n    let streams = setup_test_streams();\n    let manager = RecordingManager::new(streams, pool);\n\n    assert!(manager.get_active_recording_id(\"any\").is_none());\n}\n\n#[test]\nfn test_get_all_active_recordings_empty() {\n    let pool = common::setup_test_pool();\n    let streams = setup_test_streams();\n    let manager = RecordingManager::new(streams, pool);\n\n    assert!(manager.get_all_active_recordings().is_empty());\n}\n\n#[test]\nfn test_start_recording_stream_not_found() {\n    let pool = common::setup_test_pool();\n    let streams = Arc::new(StreamRegistry::new());\n    let manager = RecordingManager::new(streams, pool);\n\n    let result = manager.start_recording(\"nonexistent\", None);\n    assert!(result.is_err());\n    assert!(result.unwrap_err().to_string().contains(\"Stream not found\"));\n}\n\n#[test]\nfn test_stop_recording_not_found() {\n    let pool = common::setup_test_pool();\n    let streams = setup_test_streams();\n    let manager = RecordingManager::new(streams, pool);\n\n    let result = manager.stop_recording(99999);\n    assert!(result.is_err());\n    assert!(result.unwrap_err().to_string().contains(\"Recording not found\"));\n}\n\n#[test]\nfn test_stop_recording_by_topic_not_found() {\n    let pool = common::setup_test_pool();\n    let streams = setup_test_streams();\n    let manager = RecordingManager::new(streams, pool);\n\n    let result = manager.stop_recording_by_topic(\"nonexistent\");\n    assert!(result.is_err());\n    assert!(result.unwrap_err().to_string().contains(\"No active recording\"));\n}\n"
  },
  {
    "path": "apps/server/tests/recordings_repository_tests.rs",
    "content": "mod common;\n\nuse server::db::models::{NewRecording, UpdateRecording};\nuse server::db::repository::recordings::*;\n\nfn create_test_recording<'a>() -> NewRecording<'a> {\n    NewRecording {\n        stream_ssrc: 12345,\n        topic: \"test/topic\",\n        device_id: \"device_001\",\n        media_type: \"video\",\n        filename: \"test.mkv\",\n        file_path: \"/tmp/test.mkv\",\n        status: \"recording\",\n        created_by_user_id: Some(1),\n    }\n}\n\n#[test]\nfn test_create_recording() {\n    let pool = common::setup_test_pool();\n    let result = create_recording(&pool, create_test_recording());\n\n    assert!(result.is_ok());\n    assert_eq!(result.unwrap().topic, \"test/topic\");\n}\n\n#[test]\nfn test_get_recording_by_id() {\n    let pool = common::setup_test_pool();\n    let created = create_recording(&pool, create_test_recording()).unwrap();\n\n    let result = get_recording_by_id(&pool, created.id);\n    assert!(result.is_ok());\n}\n\n#[test]\nfn test_get_recording_by_id_not_found() {\n    let pool = common::setup_test_pool();\n    assert!(get_recording_by_id(&pool, 99999).is_err());\n}\n\n#[test]\nfn test_get_all_recordings() {\n    let pool = common::setup_test_pool();\n    create_recording(&pool, create_test_recording()).unwrap();\n    create_recording(&pool, create_test_recording()).unwrap();\n\n    let result = get_all_recordings(&pool).unwrap();\n    assert_eq!(result.len(), 2);\n}\n\n#[test]\nfn test_get_recordings_by_status() {\n    let pool = common::setup_test_pool();\n\n    // Create one recording with \"recording\" status\n    create_recording(&pool, create_test_recording()).unwrap();\n\n    // Create one recording with \"completed\" status\n    let mut completed = create_test_recording();\n    completed.status = \"completed\";\n    create_recording(&pool, completed).unwrap();\n\n    let recording_status = get_recordings_by_status(&pool, \"recording\").unwrap();\n    let completed_status = get_recordings_by_status(&pool, \"completed\").unwrap();\n\n    assert_eq!(recording_status.len(), 1);\n    assert_eq!(completed_status.len(), 1);\n}\n\n#[test]\nfn test_update_recording() {\n    let pool = common::setup_test_pool();\n    let created = create_recording(&pool, create_test_recording()).unwrap();\n\n    let update = UpdateRecording {\n        status: Some(\"completed\".to_string()),\n        file_size: Some(1024),\n        ..Default::default()\n    };\n\n    let updated = update_recording(&pool, created.id, &update).unwrap();\n    assert_eq!(updated.status, \"completed\");\n    assert_eq!(updated.file_size, 1024);\n}\n\n#[test]\nfn test_delete_recording() {\n    let pool = common::setup_test_pool();\n    let created = create_recording(&pool, create_test_recording()).unwrap();\n\n    assert_eq!(delete_recording(&pool, created.id).unwrap(), 1);\n    assert!(get_recording_by_id(&pool, created.id).is_err());\n}\n\n#[test]\nfn test_mark_orphaned_as_abandoned() {\n    let pool = common::setup_test_pool();\n\n    // Create two recordings with \"recording\" status\n    create_recording(&pool, create_test_recording()).unwrap();\n    create_recording(&pool, create_test_recording()).unwrap();\n\n    // Create one recording with \"completed\" status\n    let mut completed = create_test_recording();\n    completed.status = \"completed\";\n    create_recording(&pool, completed).unwrap();\n\n    let count = mark_orphaned_as_abandoned(&pool).unwrap();\n    assert_eq!(count, 2);\n\n    // No recordings should have \"recording\" status anymore\n    let still_recording = get_recordings_by_status(&pool, \"recording\").unwrap();\n    assert_eq!(still_recording.len(), 0);\n\n    // Two should be abandoned\n    let abandoned = get_recordings_by_status(&pool, \"abandoned\").unwrap();\n    assert_eq!(abandoned.len(), 2);\n}\n"
  },
  {
    "path": "apps/server/tests/state_tests.rs",
    "content": "use server::state::{MediaType, Protocol, StreamDescriptor, StreamRegistry};\nuse tokio::sync::broadcast;\nuse webrtc::rtp::packet::Packet;\n\nfn create_test_descriptor(id: u32, topic: &str) -> StreamDescriptor {\n    StreamDescriptor {\n        id,\n        topic: topic.to_string(),\n        user_id: \"test_user\".to_string(),\n        media_type: MediaType::Video,\n        protocol: Protocol::RTSP,\n    }\n}\n\n#[test]\nfn test_register_stream() {\n    let registry = StreamRegistry::new();\n    let descriptor = create_test_descriptor(1, \"rtsp://test/stream1\");\n\n    let handle = registry.register(descriptor);\n\n    assert_eq!(handle.descriptor.id, 1);\n    assert_eq!(registry.len(), 1);\n}\n\n#[test]\nfn test_get_by_ssrc() {\n    let registry = StreamRegistry::new();\n    registry.register(create_test_descriptor(42, \"test_topic\"));\n\n    assert!(registry.get_by_ssrc(42).is_some());\n    assert!(registry.get_by_ssrc(999).is_none());\n}\n\n#[test]\nfn test_get_by_topic() {\n    let registry = StreamRegistry::new();\n    registry.register(create_test_descriptor(1, \"unique/topic/path\"));\n\n    assert!(registry.get_by_topic(\"unique/topic/path\").is_some());\n    assert!(registry.get_by_topic(\"nonexistent\").is_none());\n}\n\n#[test]\nfn test_mark_online() {\n    let registry = StreamRegistry::new();\n    let handle = registry.register(create_test_descriptor(1, \"test\"));\n\n    assert!(!*handle.is_online.read().unwrap());\n    registry.mark_online(1);\n    assert!(*handle.is_online.read().unwrap());\n}\n\n#[test]\nfn test_remove() {\n    let registry = StreamRegistry::new();\n    registry.register(create_test_descriptor(1, \"topic1\"));\n    registry.register(create_test_descriptor(2, \"topic2\"));\n\n    assert_eq!(registry.len(), 2);\n    registry.remove(&1);\n    assert_eq!(registry.len(), 1);\n    assert!(registry.get_by_topic(\"topic1\").is_none());\n}\n\n#[test]\nfn test_insert_with_sender() {\n    let registry = StreamRegistry::new();\n    let (tx, _) = broadcast::channel::<Packet>(16);\n    let descriptor = create_test_descriptor(100, \"custom\");\n\n    registry.insert_with_sender(descriptor, tx);\n    assert!(registry.get_by_ssrc(100).is_some());\n}\n\n#[test]\nfn test_iter() {\n    let registry = StreamRegistry::new();\n    registry.register(create_test_descriptor(1, \"t1\"));\n    registry.register(create_test_descriptor(2, \"t2\"));\n\n    let ids: Vec<u32> = registry.iter().map(|e| *e.key()).collect();\n    assert_eq!(ids.len(), 2);\n}\n"
  },
  {
    "path": "configs/projects.json",
    "content": ""
  },
  {
    "path": "docs/.vitepress/config.mts",
    "content": "import { defineConfig } from \"vitepress\";\n\n// https://vitepress.dev/reference/site-config\nexport default defineConfig({\n  title: \"Vessel\",\n  description: \"Physical Device Orchestration Platform\",\n  base: \"/docs/\",\n  outDir: \"../apps/landing/dist/docs\",\n  themeConfig: {\n    // https://vitepress.dev/reference/default-theme-config\n    nav: [\n      { text: \"Home\", link: \"/\" },\n      { text: \"Features\", link: \"/features\" },\n      { text: \"Installation\", link: \"/installation\" },\n    ],\n\n    sidebar: [\n      {\n        text: \"Getting Started\",\n        items: [\n          { text: \"Introduction\", link: \"/introduction\" },\n          { text: \"Installation\", link: \"/installation\" },\n          { text: \"Concept\", link: \"/concepts\" },\n          { text: \"Troubleshooting\", link: \"/troubleshooting\" },\n        ],\n      },\n      {\n        text: \"Core Features\",\n        items: [\n          { text: \"Overview\", link: \"/features\" },\n          { text: \"Flow\", link: \"/flow\" },\n          { text: \"Map\", link: \"/map\" },\n          { text: \"Dashboard\", link: \"/dashboard\" },\n        ],\n      },\n    ],\n\n    socialLinks: [\n      { icon: \"github\", link: \"https://github.com/cartesiancs/vessel\" },\n    ],\n  },\n});\n"
  },
  {
    "path": "docs/concepts.md",
    "content": "# Concept\n\nVessel is an open-source orchestration platform for physical security and automation, designed to function as a Civilian Command & Control (C2) system.\n\nThe project's ultimate goal is to provide a powerful, customizable, and independent alternative to proprietary systems, giving individuals direct control over their personal assets.\n\n## Flow\n\nUtilizes a flow based UI to build automation workflows by visually connecting nodes: Sensor (Input) → Logic (Process) → Device (Action). This allows for complex, code-free rule creation.\n\n## Device-Server-Client Architecture\n\nEdge devices: (e.g., Raspberry Pi) that gather data from various sensors (audio, video, GPS, etc.).\n\nServer: A central Rust-based backend that manages devices, processes data streams, and executes flows.\n\nClient: A web application that serves as the main dashboard for monitoring, device management, and flow creation.\n\n## Open & Extensible Stack\n\nCore Tech: React, TypeScript, Rust, MQTT, WebRTC, and UDP.\n\nPhilosophy: Built on open-source principles to ensure high customizability and prevent platform lock-in, allowing for the integration of any sensor or external API.\n"
  },
  {
    "path": "docs/dashboard.md",
    "content": "# Dashboard\n\nDashboard is Vessel's operator-facing workspace. It combines fixed overview panels with customizable dynamic dashboards that can host maps, flow controls, entity cards, and action buttons.\n\n## What Dashboard Is For\n\nUse Dashboard when you need to:\n\n- assemble a task-focused operational screen\n- monitor entities and system state in one place\n- expose the most important map and flow controls to users\n- trigger running automations from a UI button\n\n## Core Concepts\n\n### Main dashboard\n\nThe dashboard area includes a main overview surface for system and entity visibility.\n\n### Dynamic dashboard\n\nA dynamic dashboard is a saved, user-configurable canvas. It is arranged as a grid and persisted on the server.\n\n### Widget\n\nWidgets are the building blocks placed on the grid. Current dashboard widgets include:\n\n- entity cards\n- buttons\n- map panels\n- flow panels\n\n### Edit mode\n\nEdit mode is where users add, move, resize, and configure widgets. It separates layout work from operational use.\n\n### Listener id\n\nA listener id connects dashboard button clicks to a matching flow listener. This is the bridge between the operator UI and the automation runtime.\n\n## User Flow\n\nThe current workflow is:\n\n1. Open the dashboard area.\n2. Move between views using the swipe layout.\n3. Create a dynamic dashboard.\n4. Enter edit mode.\n5. Add widgets and arrange them on the grid.\n6. Configure widget-specific settings such as a map layer, a flow, or a button listener id.\n7. Leave edit mode and use the dashboard operationally.\n\n## How To Use Dashboard\n\n### 1. Create a dynamic dashboard\n\nAdd a new dashboard from the dashboard area. Each dashboard is stored as a reusable layout.\n\n### 2. Enter edit mode\n\nEdit mode unlocks layout controls so you can drag, resize, and delete items without triggering operational actions.\n\n### 3. Add widgets\n\nUse the add-item menu to place widgets on the canvas. Common combinations are:\n\n- entity cards for live monitoring\n- map panels for location context\n- flow panels for run controls\n- buttons for manual triggers\n\n### 4. Configure buttons for automation\n\nTo make a button trigger a flow:\n\n1. assign a listener id to the button\n2. run a flow that includes a matching dashboard event listener\n3. click the button outside edit mode\n\n### 5. Let autosave persist layout changes\n\nDashboard layout changes are saved automatically after edits. This keeps the experience fast and reduces manual save steps.\n\n## Core Design\n\nDashboard is designed as an operations layer on top of Vessel's existing systems:\n\n- routing and swipe navigation control which dashboard is visible\n- dashboard layout is stored as structured JSON on the server\n- map and flow widgets reuse the corresponding product features instead of duplicating them\n- button events are published over WebSocket and can be consumed by running flows\n\nThis gives Dashboard a clear role: it is the place where monitoring, control, and automation meet.\n\n## Dashboard and Flow Integration\n\nDashboard buttons do not execute automation by themselves. Instead, they emit validated UI events that a running flow can listen for.\n\nThis makes the integration explicit:\n\n- Dashboard provides the operator action\n- Flow provides the automation logic\n\n## Practical Notes\n\n- Button actions are intentionally disabled while edit mode is active.\n- Button-triggered automation depends on an active WebSocket connection and a running matching flow listener.\n- Layout changes are autosaved.\n- The current UI focuses on a single visible canvas per dynamic dashboard.\n"
  },
  {
    "path": "docs/features.md",
    "content": "# Core Features\n\nVessel is built around three product surfaces that work together:\n\n- **Flow** turns device signals and user input into automation logic.\n- **Map** gives that logic a geographic context through layers and features.\n- **Dashboard** packages controls and situational views for operators.\n\nThese areas are connected by a shared runtime model:\n\n- Data and configuration are created in the client.\n- Persistent state is stored on the server.\n- Real-time control, logs, and UI events move over WebSocket.\n\n## How They Fit Together\n\n### Flow for automation\n\nUse Flow when you need to define what should happen: listen to inputs, process data, and trigger actions.\n\n[Go to Flow](/flow)\n\n### Map for spatial context\n\nUse Map when location matters: define layers, draw operational features, and inspect entities with GPS-derived positions.\n\n[Go to Map](/map)\n\n### Dashboard for operations\n\nUse Dashboard when you want a task-focused workspace: combine entity cards, map slices, flow controls, and action buttons into one screen.\n\n[Go to Dashboard](/dashboard)\n\n## Recommended Starting Point\n\n1. Model your automation in **Flow**.\n2. Organize places and areas of interest in **Map**.\n3. Publish the controls and views you need in **Dashboard**.\n\nThis sequence matches the current product design: logic first, context second, operator workflow third.\n"
  },
  {
    "path": "docs/flow.md",
    "content": "# Flow\n\nFlow is Vessel's visual automation system. It lets you design logic as a graph of nodes and connections, save that graph as a reusable flow, and run it on the server.\n\n## What Flow Is For\n\nUse Flow when you need to:\n\n- connect sensor or system inputs to downstream actions\n- express logic visually instead of writing backend code\n- run long-lived or event-driven automations\n- monitor execution through logs and UI feedback\n\n## Core Concepts\n\n### Flow\n\nA flow is a saved automation document. In practice, it is a graph made of nodes and edges.\n\n### Node\n\nA node performs one job. Depending on the node type, that job may be:\n\n- receiving an input\n- transforming or routing data\n- calling an external system\n- triggering an action\n\n### Edge\n\nAn edge connects outputs from one node to inputs on another node. Edges define how data moves through the graph.\n\n### Trigger node\n\nSome nodes act as event sources. They wait for an external signal such as a timer, a message, or a UI event and then push data into the flow.\n\n### Run context\n\nWhen a flow starts, Vessel attaches runtime context to that execution. This is how logs, targeted UI events, and session-scoped behavior are coordinated.\n\n## User Flow\n\nThe current product flow is:\n\n1. Create or select a flow from the sidebar.\n2. Build the graph in the canvas by adding and connecting nodes.\n3. Save the graph.\n4. Run the flow.\n5. Watch logs and UI feedback while it executes.\n6. Stop the flow or iterate on the graph and save again.\n\n## How To Use Flow\n\n### 1. Create a flow\n\nOpen the Flow page and create a new flow from the sidebar. Each flow is a separate saved automation.\n\n### 2. Build the graph\n\nAdd the nodes you need and connect them into a data path. A common mental model is:\n\n`input -> logic -> action`\n\n### 3. Save before running\n\nThe server runs the latest saved graph, not unsaved local edits. Saving is part of the normal run workflow and should be treated as the source of truth.\n\n### 4. Run and observe\n\nStart the flow from the header controls. Execution logs are shown in the flow UI, and flow-driven UI events can be sent back to the client.\n\n### 5. Refine and repeat\n\nAdjust nodes, connectors, or values, save again, and rerun until the behavior matches your intended automation.\n\n## Core Design\n\nFlow is intentionally split into two layers:\n\n- **Client editor** for creating and editing the graph\n- **Server runtime** for executing the latest saved graph\n\nThis separation supports a clear operating model:\n\n- REST is used for flow CRUD and persistence.\n- WebSocket is used for run control, logs, and runtime UI events.\n- The server treats a saved graph snapshot as the executable version.\n\n## Flow and Dashboard Integration\n\nFlows can also react to dashboard interactions. A dashboard button can emit a UI event, and a matching flow listener can consume that event as a trigger.\n\nThis makes Flow the automation backbone behind operational dashboards.\n\n## Practical Notes\n\n- Unsaved graph changes are not part of a run until they are saved.\n- The system currently assumes one active run per flow id.\n- The flow page guards against accidental navigation when there are unsaved changes.\n- Flow execution is real-time oriented, so logs are part of the normal operating experience, not just debugging.\n"
  },
  {
    "path": "docs/index.md",
    "content": "---\n# https://vitepress.dev/reference/default-theme-home-page\nlayout: home\n\nhero:\n  name: \"Vessel\"\n  text: \"The open-source alternative of Anduril\"\n  tagline: Physical Device Orchestration Platform\n  actions:\n    - theme: brand\n      text: Introduction\n      link: /introduction\n    - theme: alt\n      text: Core Features\n      link: /features\n---\n\n## Core Features\n\nVessel combines visual automation, map-based situational context, and operator-facing dashboards into one system.\n\n### Flow\n\nBuild logic visually, connect sensors and actions, and run automations on the server.\n\n[Read the Flow guide](/flow)\n\n### Map\n\nCreate layers, draw geographic features, inspect live positions, and embed map views in dashboards.\n\n[Read the Map guide](/map)\n\n### Dashboard\n\nCreate dynamic dashboards with entity cards, map panels, flow controls, and action buttons.\n\n[Read the Dashboard guide](/dashboard)\n"
  },
  {
    "path": "docs/installation.md",
    "content": "# Installation\n\nThis guide will walk you through setting up and running Vessel in your local environment.\n\nPlease refer to README in the repository below.\n\n[https://github.com/cartesiancs/vessel](https://github.com/cartesiancs/vessel)\n"
  },
  {
    "path": "docs/introduction.md",
    "content": "# Vessel\n\n### Self-defense system powered by physical device orchestration and automation.\n\n<div style=\"text-align:center;align-items: center;justify-content: center;display: flex; padding-bottom: 50px;\">\n\n<img src=\"/icon.png\" alt=\"My Image\" width=\"300\">\n</div>\n\nThe open-source alternative to Anduril for **home protection**.\n\nVessel is an open-source platform that allows you to connect, orchestrate, and automate multiple physical sensors for proactive security and asset management. We aim to bring the power of a command and control (C2) system, traditionally used in military applications, to your home, farm, or small factory.\n\nOur goal is to empower individuals to build their own intelligent security systems, moving from passive monitoring to active, automated response.\n\n## Vision\n\nWe believe in a future where you are not dependent on large corporations for your physical security. Vessel provides the tools to build a truly independent and customizable security platform.\n\n## Key Features\n\n- **Visual Flow Editor**: Easily design complex automation workflows using a drag-and-drop interface powered by React Flow. Connect sensors to processors and actions to create custom logic (e.g., \"If the microphone detects a sound louder than 80dB, turn on the porch light via Home Assistant\").\n  <img src=\"/images/flow.png\" alt=\"My Image\">\n\n- **Map**\n  <img src=\"/images/map.png\" alt=\"My Image\">\n\n- **Device Management**: Register and manage your sensor devices. Monitor real-time data streams from each device.\n  <img src=\"/images/dashboard.png\" alt=\"My Image\">\n\n- **Broad Sensor Compatibility**: Vessel is designed to support a wide range of sensors, from simple temperature and motion detectors to complex devices like GPS, IMU, cameras, and microphones.\n- **Real-time Communication**: Utilizes UDP, SRTP, MQTT, and WebRTC for low-latency communication between Devices, the server, and the client.\n- **Extensible Architecture**: Built with a modular structure, allowing for the future integration of new devices (like drones and LoRaWAN), AI analysis servers, and custom actions.\n\n## Target Audience\n\nVessel is for the **proactive problem-solver** who is comfortable with DIY projects and wants to take control of their personal security. Our ideal user is:\n\n- A homeowner in a rural area.\n- Tech-savvy and interested in home automation.\n- Desires a sense of control and immediate feedback from their security system.\n- May also be a small business owner managing assets like a small factory or farm.\n\n### Communication Protocols\n\n- **Devices <-> Server**: UDP, SRTP (for secure real-time audio/video), mTLS, MQTT\n- **Server <-> Client**: HTTP, WebSocket, WebRTC\n\n## Roadmap\n\nWe are currently in the Proof of Concept (POC) phase and have completed the core flow engine, device management, and real-time data transfer.\n\nCheck out our roadmap page.\n\n[vessel.cartesiancs.com/roadmap](https://vessel.cartesiancs.com/roadmap)\n"
  },
  {
    "path": "docs/map.md",
    "content": "# Map\n\nMap is Vessel's geographic workspace. It combines editable map layers, vector features, and live entity positions into one operational view.\n\n## What Map Is For\n\nUse Map when you need to:\n\n- organize places, routes, and areas of interest\n- draw operational geometry directly on a map\n- inspect GPS-backed entities in geographic context\n- reuse saved layers inside dashboards\n\n## Core Concepts\n\n### Layer\n\nA layer is a named collection of map features. It is the main container users work with when editing spatial data.\n\n### Feature\n\nA feature is a spatial object that belongs to a layer. The current feature types are:\n\n- point\n- line\n- polygon\n\n### Vertex\n\nVertices define the shape of a line or polygon and the position of a point.\n\n### Active layer\n\nThe main editing experience works against one selected layer at a time. Drawing and feature editing apply to the currently active layer.\n\n### Entity overlay\n\nThe map also displays entity positions separately from drawn features. This is useful when you want to see operational objects on top of user-defined geometry.\n\n## User Flow\n\nThe typical workflow is:\n\n1. Open the Map page.\n2. Create a layer or select an existing layer.\n3. Choose a drawing mode.\n4. Add points on the map to create a point, line, or polygon.\n5. Confirm the shape.\n6. Inspect or edit the resulting feature.\n7. Switch layers or reuse the layer in a dashboard panel.\n\n## How To Use Map\n\n### 1. Select a layer\n\nLayers are the starting point for map editing. If no layer is selected, drawing tools stay unavailable.\n\n### 2. Choose a drawing mode\n\nThe toolbar lets you switch between:\n\n- point drawing\n- line drawing\n- polygon drawing\n\n### 3. Draw and confirm\n\nFor lines and polygons, add vertices on the map and confirm the result from the toolbar. You can also cancel an in-progress shape.\n\n### 4. Inspect details\n\nClick a feature to open its detail panel. From there you can inspect geometry-related information and update feature properties.\n\n### 5. Switch the base map\n\nMap currently supports multiple base map styles, including dark and satellite views, so you can choose the context that best fits the task.\n\n## Core Design\n\nMap separates spatial editing into three concerns:\n\n- **server-backed spatial data** such as layers, features, and vertices\n- **client-side interaction state** such as drawing mode and selected feature\n- **entity overlays** sourced from device or entity state\n\nThis design keeps the product flexible:\n\n- persistent geometry lives on the server\n- short-lived interaction state lives in the client\n- geographic context can be embedded into other surfaces such as dashboards\n\n## Map and Dashboard Integration\n\nSaved layers can be embedded into dashboard map panels. This makes the map system more than a standalone page: it becomes a reusable spatial component across the product.\n\n## Practical Notes\n\n- The main map currently focuses on one active layer at a time.\n- The client remembers the last viewed map position and zoom locally.\n- Base maps depend on external tile providers.\n- Initial positioning prefers the last stored view, then browser geolocation, then a fallback default.\n"
  },
  {
    "path": "docs/setup.md",
    "content": "## .env\n\n## Run program\n\n## Update server config\n\n## Add device and entity\n\n## MQTT\n\n## RTSP\n\n## RTP\n"
  },
  {
    "path": "docs/troubleshooting.md",
    "content": "# Troubleshooting\n\n## Python dylib Loading Issue\n\n```bash\ndyld[59248]: Library not loaded: @rpath/libpython3.12.dylib\n\n...\n```\n\nThis problem occurs because PyO3 cannot dynamically link to Python. To resolve this, you can specify the library path before execution using the RUSTFLAGS environment variable.\n\nSet it like this:\n\n```bash\nRUSTFLAGS='-C link-arg=-Wl,-rpath,<path_to_lib_folder_containing_dylib>' npm run server\n```\n\nFor example:\n\n```bash\nRUSTFLAGS='-C link-arg=-Wl,-rpath,/opt/homebrew/Cellar/python@3.12/3.12.11/Frameworks/Python.framework/Versions/3.12/lib' npm run server\n```\n"
  },
  {
    "path": "example/golang/.gitignore",
    "content": "audio-file/sample.mp3\nvideo-sample/sample.mp4\nvideo2-sample/sample.mp4"
  },
  {
    "path": "example/golang/audio-file/audio.go",
    "content": "package main\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os/exec\"\n)\n\ntype RegisterRequest struct {\n\tTopic string `json:\"topic\"`\n    MediaType string `json:\"media_type\"`\n}\n\ntype RegisterResponse struct {\n\tSsrc   uint32 `json:\"ssrc\"`\n\tRtpPort uint16 `json:\"rtp_port\"`\n}\n\nfunc main() {\n    deviceId := \"audio\"\n    deviceToken := \"FnhXd7dNy8iCPbu5N5jS2v_NaOYCiI9AqPO4FQQed7E\"\n\n    serverURL := \"http://127.0.0.1:6174/api/streams/register\"\n    topic := \"go_stream_2\"\n\tmediaType := \"audio\"\n\n    if deviceId == \"\" || deviceToken == \"\" {\n        fmt.Println(\"Error: Please set the deviceId and deviceToken variables.\")\n        return\n    }\n\n    reqBody := RegisterRequest{Topic: topic, MediaType: mediaType}\n    jsonBody, err := json.Marshal(reqBody)\n    if err != nil {\n        fmt.Printf(\"Error marshalling request body: %v\\n\", err)\n        return\n    }\n\n    req, err := http.NewRequest(\"POST\", serverURL, bytes.NewBuffer(jsonBody))\n    if err != nil {\n        fmt.Printf(\"Error creating request: %v\\n\", err)\n        return\n    }\n\n    req.Header.Set(\"Content-Type\", \"application/json\")\n    req.Header.Set(\"X-Device-Id\", deviceId)            \n    req.Header.Set(\"Authorization\", \"Bearer \"+deviceToken) \n\n    client := &http.Client{}\n    resp, err := client.Do(req)\n    if err != nil {\n        fmt.Printf(\"Error making request to server: %v\\n\", err)\n        return\n    }\n    defer resp.Body.Close()\n\n    if resp.StatusCode != http.StatusOK {\n        fmt.Printf(\"Server returned non-OK status: %s\\n\", resp.Status)\n        body := new(bytes.Buffer)\n        body.ReadFrom(resp.Body)\n        fmt.Printf(\"Response body: %s\\n\", body.String())\n        return\n    }\n\n    var regResponse RegisterResponse\n    if err := json.NewDecoder(resp.Body).Decode(&regResponse); err != nil {\n        fmt.Printf(\"Error decoding response body: %v\\n\", err)\n        return\n    }\n\n    fmt.Printf(\"Successfully registered stream. Topic: %s, SSRC: %d\\n\", topic, regResponse.Ssrc)\n\n    rtpURL := fmt.Sprintf(\"rtp://127.0.0.1:%d?ssrc=%d\", regResponse.RtpPort, regResponse.Ssrc)\n    fmt.Printf(\"Starting ffmpeg stream to: %s\\n\", rtpURL)\n\n\tcmd := exec.Command(\n\t\t\"ffmpeg\",\n\t\t\"-re\",\n        \"-stream_loop\", \"-1\",\n\t\t\"-i\", \"./sample.mp3\",\n\t\t\"-vn\",\n\t\t\"-map\", \"0:a:0\",\n\t\t\"-c:a\", \"libopus\",\n\t\t\"-b:a\", \"64k\",\n\t\t\"-vbr\", \"on\",\n\t\t\"-compression_level\", \"10\",\n\t\t\"-payload_type\", \"96\",\n\t\t\"-fflags\", \"nobuffer\",\n\t\t\"-flags\", \"low_delay\",\n\t\t\"-flush_packets\", \"1\",\n\t\t\"-muxdelay\", \"0\",\n\t\t\"-ssrc\",  fmt.Sprintf(\"%d\", int32(regResponse.Ssrc)),\n\t\t\"-f\", \"rtp\",\n\t\trtpURL,\n\t)\n\n\tout, err := cmd.CombinedOutput()\n\tfmt.Printf(\"ffmpeg output:\\n%s\\n\", out)\n\tif err != nil {\n\t\tfmt.Printf(\"ffmpeg error: %v\\n\", err)\n\t}\n}"
  },
  {
    "path": "example/golang/audio-file/audio.sdp",
    "content": "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=No Name\r\nc=IN IP4 127.0.0.1\r\nt=0 0\r\na=tool:libavformat 61.7.100\r\nm=audio 5004 RTP/AVP 96\r\nb=AS:64\r\na=rtpmap:96 opus/48000/2\r\na=fmtp:96 sprop-stereo=1\r\n"
  },
  {
    "path": "example/golang/audio-mic/audio.go",
    "content": "package main\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"os/exec\"\n\n\t\"github.com/gordonklaus/portaudio\"\n)\n\ntype RegisterRequest struct {\n\tTopic     string `json:\"topic\"`\n\tMediaType string `json:\"media_type\"`\n}\n\ntype RegisterResponse struct {\n\tSsrc    uint32 `json:\"ssrc\"`\n\tRtpPort uint16 `json:\"rtp_port\"`\n}\n\nfunc main() {\n\tdeviceId := \"audio\"\n\tdeviceToken := \"FnhXd7dNy8iCPbu5N5jS2v_NaOYCiI9AqPO4FQQed7E\"\n\n\tserverURL := \"http://127.0.0.1:6174/api/streams/register\"\n\ttopic := \"go_stream_1\"\n\tmediaType := \"audio\"\n\n\tif deviceId == \"\" || deviceToken == \"\" {\n\t\tlog.Fatal(\"Error: Please set the deviceId and deviceToken variables.\")\n\t}\n\n\treqBody := RegisterRequest{Topic: topic, MediaType: mediaType}\n\tjsonBody, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\tlog.Fatalf(\"Error marshalling request body: %v\\n\", err)\n\t}\n\n\treq, err := http.NewRequest(\"POST\", serverURL, bytes.NewBuffer(jsonBody))\n\tif err != nil {\n\t\tlog.Fatalf(\"Error creating request: %v\\n\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"X-Device-Id\", deviceId)\n\treq.Header.Set(\"Authorization\", \"Bearer \"+deviceToken)\n\n\tclient := &http.Client{}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tlog.Fatalf(\"Error making request to server: %v\\n\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tbody := new(bytes.Buffer)\n\t\tbody.ReadFrom(resp.Body)\n\t\tlog.Fatalf(\"Server returned non-OK status: %s\\nResponse body: %s\\n\", resp.Status, body.String())\n\t}\n\n\tvar regResponse RegisterResponse\n\tif err := json.NewDecoder(resp.Body).Decode(&regResponse); err != nil {\n\t\tlog.Fatalf(\"Error decoding response body: %v\\n\", err)\n\t}\n\n\tfmt.Printf(\"Successfully registered stream. Topic: %s, SSRC: %d, Port: %d\\n\", topic, regResponse.Ssrc, regResponse.RtpPort)\n\n\trtpURL := fmt.Sprintf(\"rtp://127.0.0.1:%d\", regResponse.RtpPort)\n\tfmt.Printf(\"Starting real-time audio stream to: %s\\n\", rtpURL)\n\n\tportaudio.Initialize()\n\tdefer portaudio.Terminate()\n\n\thost, err := portaudio.DefaultHostApi()\n\tif err != nil {\n\t\tlog.Fatal(\"Host API fail:\", err)\n\t}\n\tdev := host.DefaultInputDevice\n\tsampleRate := float64(dev.DefaultSampleRate)\n\tlog.Printf(\"Using input device %q, sample rate: %.0f Hz\", dev.Name, sampleRate)\n\n\tframesPerBuffer := int(sampleRate / 50) \n\tin := make([]int16, framesPerBuffer)\n\tstream, err := portaudio.OpenDefaultStream(1, 0, sampleRate, len(in), &in)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tif err := stream.Start(); err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tdefer stream.Stop()\n\n\tcmd := exec.Command(\n\t\t\"ffmpeg\",\n\t\t\"-f\", \"s16le\",\n\t\t\"-ar\", fmt.Sprintf(\"%d\", int(sampleRate)),\n\t\t\"-ac\", \"1\",\n\t\t\"-i\", \"pipe:0\",\n\t\t\"-vn\",\n\t\t\"-c:a\", \"libopus\",\n\t\t\"-b:a\", \"64k\",\n\t\t\"-vbr\", \"on\",\n\t\t\"-compression_level\", \"10\",\n\t\t\"-payload_type\", \"96\",\n\t\t\"-ssrc\", fmt.Sprintf(\"%d\", int32(regResponse.Ssrc)),\n\t\t\"-f\", \"rtp\",\n\t\trtpURL,\n\t)\n\n\tstdin, err := cmd.StdinPipe()\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tcmd.Stderr = log.Writer()\n\n\tif err := cmd.Start(); err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tdefer cmd.Process.Kill()\n\n\tbuf := make([]byte, framesPerBuffer*2)\n\tfmt.Println(\"Streaming... Press Ctrl+C to stop.\")\n\tfor {\n\t\tif err := stream.Read(); err != nil {\n\t\t\tif err == portaudio.InputOverflowed {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tlog.Println(\"stream read error:\", err)\n\t\t\tbreak\n\t\t}\n\t\tfor i, v := range in {\n\t\t\tbuf[2*i] = byte(v)\n\t\t\tbuf[2*i+1] = byte(v >> 8)\n\t\t}\n\t\tif _, err := stdin.Write(buf); err != nil {\n\t\t\tlog.Println(\"stdin write error:\", err)\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif err := cmd.Wait(); err != nil {\n\t\tlog.Printf(\"ffmpeg command finished with error: %v\", err)\n\t}\n}\n"
  },
  {
    "path": "example/golang/go.mod",
    "content": "module vessel/sentinel\n\ngo 1.24.3\n\nrequire (\n\tgithub.com/gordonklaus/portaudio v0.0.0-20250206071425-98a94950218b\n\tgithub.com/pion/rtp v1.8.18\n)\n\nrequire (\n\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n\tgithub.com/google/uuid v1.3.1 // indirect\n\tgithub.com/pion/datachannel v1.5.8 // indirect\n\tgithub.com/pion/dtls/v2 v2.2.12 // indirect\n\tgithub.com/pion/ice/v2 v2.3.36 // indirect\n\tgithub.com/pion/interceptor v0.1.29 // indirect\n\tgithub.com/pion/logging v0.2.2 // indirect\n\tgithub.com/pion/mdns v0.0.12 // indirect\n\tgithub.com/pion/rtcp v1.2.14 // indirect\n\tgithub.com/pion/sctp v1.8.19 // indirect\n\tgithub.com/pion/sdp/v3 v3.0.9 // indirect\n\tgithub.com/pion/srtp/v2 v2.0.20 // indirect\n\tgithub.com/pion/stun v0.6.1 // indirect\n\tgithub.com/pion/transport/v2 v2.2.10 // indirect\n\tgithub.com/pion/turn/v2 v2.1.6 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.0 // indirect\n\tgithub.com/stretchr/testify v1.10.0 // indirect\n\tgithub.com/wlynxg/anet v0.0.3 // indirect\n\tgolang.org/x/crypto v0.21.0 // indirect\n\tgolang.org/x/net v0.22.0 // indirect\n\tgolang.org/x/sys v0.18.0 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n)\n\nrequire (\n\tgithub.com/pion/opus v0.0.0-20250423145807-4aaa26789cff\n\tgithub.com/pion/randutil v0.1.0 // indirect\n\tgithub.com/pion/webrtc/v3 v3.3.5\n\tgithub.com/zaf/g711 v1.4.0\n\tgopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302\n)\n"
  },
  {
    "path": "example/golang/go.sum",
    "content": "github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=\ngithub.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/gordonklaus/portaudio v0.0.0-20250206071425-98a94950218b h1:WEuQWBxelOGHA6z9lABqaMLMrfwVyMdN3UgRLT+YUPo=\ngithub.com/gordonklaus/portaudio v0.0.0-20250206071425-98a94950218b/go.mod h1:esZFQEUwqC+l76f2R8bIWSwXMaPbp79PppwZ1eJhFco=\ngithub.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/pion/datachannel v1.5.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo=\ngithub.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI=\ngithub.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=\ngithub.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk=\ngithub.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=\ngithub.com/pion/ice/v2 v2.3.36 h1:SopeXiVbbcooUg2EIR8sq4b13RQ8gzrkkldOVg+bBsc=\ngithub.com/pion/ice/v2 v2.3.36/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ=\ngithub.com/pion/interceptor v0.1.29 h1:39fsnlP1U8gw2JzOFWdfCU82vHvhW9o0rZnZF56wF+M=\ngithub.com/pion/interceptor v0.1.29/go.mod h1:ri+LGNjRUc5xUNtDEPzfdkmSqISixVTBF/z/Zms/6T4=\ngithub.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=\ngithub.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=\ngithub.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8=\ngithub.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk=\ngithub.com/pion/opus v0.0.0-20250423145807-4aaa26789cff h1:09dkuUpQF8/8YfotuU8hg/yYVBJBA6KrmhLIUq3N6W0=\ngithub.com/pion/opus v0.0.0-20250423145807-4aaa26789cff/go.mod h1:MF0ECGlX1vw71XHaPvRqZoeFED6QTwvFL71vbsd29yY=\ngithub.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=\ngithub.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=\ngithub.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=\ngithub.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE=\ngithub.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=\ngithub.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=\ngithub.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU=\ngithub.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=\ngithub.com/pion/sctp v1.8.19 h1:2CYuw+SQ5vkQ9t0HdOPccsCz1GQMDuVy5PglLgKVBW8=\ngithub.com/pion/sctp v1.8.19/go.mod h1:P6PbDVA++OJMrVNg2AL3XtYHV4uD6dvfyOovCgMs0PE=\ngithub.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=\ngithub.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=\ngithub.com/pion/srtp/v2 v2.0.20 h1:HNNny4s+OUmG280ETrCdgFndp4ufx3/uy85EawYEhTk=\ngithub.com/pion/srtp/v2 v2.0.20/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA=\ngithub.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=\ngithub.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=\ngithub.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=\ngithub.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=\ngithub.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=\ngithub.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q=\ngithub.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=\ngithub.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=\ngithub.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=\ngithub.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc=\ngithub.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=\ngithub.com/pion/webrtc/v3 v3.3.5 h1:ZsSzaMz/i9nblPdiAkZoP+E6Kmjw+jnyq3bEmU3EtRg=\ngithub.com/pion/webrtc/v3 v3.3.5/go.mod h1:liNa+E1iwyzyXqNUwvoMRNQ10x8h8FOeJKL8RkIbamE=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=\ngithub.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=\ngithub.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=\ngithub.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngithub.com/zaf/g711 v1.4.0 h1:XZYkjjiAg9QTBnHqEg37m2I9q3IIDv5JRYXs2N8ma7c=\ngithub.com/zaf/g711 v1.4.0/go.mod h1:eCDXt3dSp/kYYAoooba7ukD/Q75jvAaS4WOMr0l1Roo=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=\ngolang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=\ngolang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=\ngolang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=\ngolang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=\ngolang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=\ngolang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=\ngolang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=\ngolang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=\ngolang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=\ngolang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=\ngolang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=\ngolang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=\ngolang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=\ngolang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=\ngolang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=\ngolang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=\ngolang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=\ngolang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=\ngolang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 h1:xeVptzkP8BuJhoIjNizd2bRHfq9KB9HfOLZu90T04XM=\ngopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302/go.mod h1:/L5E7a21VWl8DeuCPKxQBdVG5cy+L0MRZ08B1wnqt7g=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "example/golang/video-sample/video.go",
    "content": "package main\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os/exec\"\n)\n\ntype RegisterRequest struct {\n\tTopic string `json:\"topic\"`\n\tMediaType string `json:\"media_type\"`\n}\n\ntype RegisterResponse struct {\n\tSsrc    uint32 `json:\"ssrc\"`\n\tRtpPort uint16 `json:\"rtp_port\"`\n}\n\nfunc main() {\n\tdeviceId := \"audio\"\n\tdeviceToken := \"FnhXd7dNy8iCPbu5N5jS2v_NaOYCiI9AqPO4FQQed7E\"\n\n\tserverURL := \"http://127.0.0.1:6174/api/streams/register\"\n\ttopic := \"go_video_stream_1\"\n\tmediaType := \"video\"\n\n\tif deviceId == \"\" || deviceToken == \"\" {\n\t\tfmt.Println(\"Error: Please set the deviceId and deviceToken variables.\")\n\t\treturn\n\t}\n\n\treqBody := RegisterRequest{Topic: topic, MediaType: mediaType}\n\tjsonBody, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\tfmt.Printf(\"Error marshalling request body: %v\\n\", err)\n\t\treturn\n\t}\n\n\treq, err := http.NewRequest(\"POST\", serverURL, bytes.NewBuffer(jsonBody))\n\tif err != nil {\n\t\tfmt.Printf(\"Error creating request: %v\\n\", err)\n\t\treturn\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"X-Device-Id\", deviceId)\n\treq.Header.Set(\"Authorization\", \"Bearer \"+deviceToken)\n\n\tclient := &http.Client{}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tfmt.Printf(\"Error making request to server: %v\\n\", err)\n\t\treturn\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tfmt.Printf(\"Server returned non-OK status: %s\\n\", resp.Status)\n\t\tbody := new(bytes.Buffer)\n\t\tbody.ReadFrom(resp.Body)\n\t\tfmt.Printf(\"Response body: %s\\n\", body.String())\n\t\treturn\n\t}\n\n\tvar regResponse RegisterResponse\n\tif err := json.NewDecoder(resp.Body).Decode(&regResponse); err != nil {\n\t\tfmt.Printf(\"Error decoding response body: %v\\n\", err)\n\t\treturn\n\t}\n\n\tfmt.Printf(\"Successfully registered stream. Topic: %s, SSRC: %d\\n\", topic, regResponse.Ssrc)\n\n\trtpURL := fmt.Sprintf(\"rtp://127.0.0.1:%d\", regResponse.RtpPort)\n\tfmt.Printf(\"Starting ffmpeg stream to: %s\\n\", rtpURL)\n\n\tcmd := exec.Command(\n\t\t\"ffmpeg\",\n\t\t\"-re\",\n\t\t\"-stream_loop\", \"-1\",\n\t\t\"-i\", \"./sample.mp4\",\n\t\t\"-an\",\n\t\t\"-map\", \"0:v:0\",\n\t\t\"-vf\", \"scale=640:480\",\n\t\t\"-c:v\", \"libx264\",\n\t\t\"-profile:v\", \"baseline\",\n\t\t\"-pix_fmt\", \"yuv420p\",\n\t\t\"-preset\", \"ultrafast\",\n\t\t\"-tune\", \"zerolatency\",\n\t\t\"-b:v\", \"300k\",\n\t\t\"-maxrate\", \"350k\",\n\t\t\"-bufsize\", \"600k\",\n\t\t\"-g\", \"30\",\n\t\t\"-payload_type\", \"102\",\n\t\t\"-ssrc\", fmt.Sprintf(\"%d\", regResponse.Ssrc),\n\t\t\"-f\", \"rtp\",\n\t\trtpURL,\n\t)\n\n\tout, err := cmd.CombinedOutput()\n\tfmt.Printf(\"ffmpeg output:\\n%s\\n\", out)\n\tif err != nil {\n\t\tfmt.Printf(\"ffmpeg error: %v\\n\", err)\n\t}\n}"
  },
  {
    "path": "example/golang/video2-sample/video.go",
    "content": "package main\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os/exec\"\n)\n\ntype RegisterRequest struct {\n\tTopic string `json:\"topic\"`\n\tMediaType string `json:\"media_type\"`\n}\n\ntype RegisterResponse struct {\n\tSsrc    uint32 `json:\"ssrc\"`\n\tRtpPort uint16 `json:\"rtp_port\"`\n}\n\nfunc main() {\n\tdeviceId := \"audio\"\n\tdeviceToken := \"FnhXd7dNy8iCPbu5N5jS2v_NaOYCiI9AqPO4FQQed7E\"\n\n\tserverURL := \"http://127.0.0.1:6174/api/streams/register\"\n\ttopic := \"go_video_stream_2\"\n\tmediaType := \"video\"\n\n\tif deviceId == \"\" || deviceToken == \"\" {\n\t\tfmt.Println(\"Error: Please set the deviceId and deviceToken variables.\")\n\t\treturn\n\t}\n\n\treqBody := RegisterRequest{Topic: topic, MediaType: mediaType}\n\tjsonBody, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\tfmt.Printf(\"Error marshalling request body: %v\\n\", err)\n\t\treturn\n\t}\n\n\treq, err := http.NewRequest(\"POST\", serverURL, bytes.NewBuffer(jsonBody))\n\tif err != nil {\n\t\tfmt.Printf(\"Error creating request: %v\\n\", err)\n\t\treturn\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"X-Device-Id\", deviceId)\n\treq.Header.Set(\"Authorization\", \"Bearer \"+deviceToken)\n\n\tclient := &http.Client{}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tfmt.Printf(\"Error making request to server: %v\\n\", err)\n\t\treturn\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tfmt.Printf(\"Server returned non-OK status: %s\\n\", resp.Status)\n\t\tbody := new(bytes.Buffer)\n\t\tbody.ReadFrom(resp.Body)\n\t\tfmt.Printf(\"Response body: %s\\n\", body.String())\n\t\treturn\n\t}\n\n\tvar regResponse RegisterResponse\n\tif err := json.NewDecoder(resp.Body).Decode(&regResponse); err != nil {\n\t\tfmt.Printf(\"Error decoding response body: %v\\n\", err)\n\t\treturn\n\t}\n\n\tfmt.Printf(\"Successfully registered stream. Topic: %s, SSRC: %d\\n\", topic, regResponse.Ssrc)\n\n\trtpURL := fmt.Sprintf(\"rtp://127.0.0.1:%d\", regResponse.RtpPort)\n\tfmt.Printf(\"Starting ffmpeg stream to: %s\\n\", rtpURL)\n\n\tcmd := exec.Command(\n\t\t\"ffmpeg\",\n\t\t\"-re\",\n\t\t\"-i\", \"./sample.mp4\",\n\t\t\"-an\",\n\t\t\"-map\", \"0:v:0\",\n\t\t\"-vf\", \"scale=640:480\",\n\t\t\"-c:v\", \"libx264\",\n\t\t\"-profile:v\", \"baseline\",\n\t\t\"-pix_fmt\", \"yuv420p\",\n\t\t\"-preset\", \"ultrafast\",\n\t\t\"-tune\", \"zerolatency\",\n\t\t\"-b:v\", \"300k\",\n\t\t\"-maxrate\", \"350k\",\n\t\t\"-bufsize\", \"600k\",\n\t\t\"-g\", \"30\",\n\t\t\"-payload_type\", \"102\",\n\t\t\"-ssrc\", fmt.Sprintf(\"%d\", regResponse.Ssrc),\n\t\t\"-f\", \"rtp\",\n\t\trtpURL,\n\t)\n\n\tout, err := cmd.CombinedOutput()\n\tfmt.Printf(\"ffmpeg output:\\n%s\\n\", out)\n\tif err != nil {\n\t\tfmt.Printf(\"ffmpeg error: %v\\n\", err)\n\t}\n}"
  },
  {
    "path": "example/golang/video3-sample/video.go",
    "content": "package main\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os/exec\"\n)\n\ntype RegisterRequest struct {\n\tTopic string `json:\"topic\"`\n\tMediaType string `json:\"media_type\"`\n}\n\ntype RegisterResponse struct {\n\tSsrc    uint32 `json:\"ssrc\"`\n\tRtpPort uint16 `json:\"rtp_port\"`\n}\n\nfunc main() {\n\tdeviceId := \"audio\"\n\tdeviceToken := \"FnhXd7dNy8iCPbu5N5jS2v_NaOYCiI9AqPO4FQQed7E\"\n\n\tserverURL := \"http://127.0.0.1:6174/api/streams/register\"\n\ttopic := \"go_video_stream_3\"\n\tmediaType := \"video\"\n\n\tif deviceId == \"\" || deviceToken == \"\" {\n\t\tfmt.Println(\"Error: Please set the deviceId and deviceToken variables.\")\n\t\treturn\n\t}\n\n\treqBody := RegisterRequest{Topic: topic, MediaType: mediaType}\n\tjsonBody, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\tfmt.Printf(\"Error marshalling request body: %v\\n\", err)\n\t\treturn\n\t}\n\n\treq, err := http.NewRequest(\"POST\", serverURL, bytes.NewBuffer(jsonBody))\n\tif err != nil {\n\t\tfmt.Printf(\"Error creating request: %v\\n\", err)\n\t\treturn\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"X-Device-Id\", deviceId)\n\treq.Header.Set(\"Authorization\", \"Bearer \"+deviceToken)\n\n\tclient := &http.Client{}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tfmt.Printf(\"Error making request to server: %v\\n\", err)\n\t\treturn\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tfmt.Printf(\"Server returned non-OK status: %s\\n\", resp.Status)\n\t\tbody := new(bytes.Buffer)\n\t\tbody.ReadFrom(resp.Body)\n\t\tfmt.Printf(\"Response body: %s\\n\", body.String())\n\t\treturn\n\t}\n\n\tvar regResponse RegisterResponse\n\tif err := json.NewDecoder(resp.Body).Decode(&regResponse); err != nil {\n\t\tfmt.Printf(\"Error decoding response body: %v\\n\", err)\n\t\treturn\n\t}\n\n\tfmt.Printf(\"Successfully registered stream. Topic: %s, SSRC: %d\\n\", topic, regResponse.Ssrc)\n\n\trtpURL := fmt.Sprintf(\"rtp://127.0.0.1:%d\", regResponse.RtpPort)\n\tfmt.Printf(\"Starting ffmpeg stream to: %s (low bitrate for TURN testing)\\n\", rtpURL)\n\n\tcmd := exec.Command(\n\t\t\"ffmpeg\",\n\t\t\"-re\",\n\t\t\"-stream_loop\", \"-1\",\n\t\t\"-i\", \"../video-sample/sample.mp4\",\n\t\t\"-an\",\n\t\t\"-map\", \"0:v:0\",\n\t\t\"-vf\", \"scale=320:240\",\n\t\t\"-c:v\", \"libx264\",\n\t\t\"-profile:v\", \"baseline\",\n\t\t\"-pix_fmt\", \"yuv420p\",\n\t\t\"-preset\", \"ultrafast\",\n\t\t\"-tune\", \"zerolatency\",\n\t\t\"-b:v\", \"200k\",\n\t\t\"-maxrate\", \"250k\",\n\t\t\"-bufsize\", \"400k\",\n\t\t\"-g\", \"30\",\n\t\t\"-payload_type\", \"102\",\n\t\t\"-ssrc\", fmt.Sprintf(\"%d\", regResponse.Ssrc),\n\t\t\"-f\", \"rtp\",\n\t\trtpURL,\n\t)\n\n\tout, err := cmd.CombinedOutput()\n\tfmt.Printf(\"ffmpeg output:\\n%s\\n\", out)\n\tif err != nil {\n\t\tfmt.Printf(\"ffmpeg error: %v\\n\", err)\n\t}\n}\n"
  },
  {
    "path": "example/js/index.js",
    "content": "const http = require(\"node:http\");\nconst https = require(\"node:https\");\nconst { randomBytes } = require(\"node:crypto\");\n\nconst config = {\n  serverUrl: \"http://127.0.0.1:6174\",\n  entityId: \"testbasic-http\",\n  deviceId: \"testbasic\",\n  deviceToken: \"OQ_PrBdAapWVulzXCke4n9EbXEINILPLryIpIe_19lM\",\n  intervalMs: 10_000,\n};\n\nif (!config.deviceId || !config.deviceToken) {\n  console.error(\"Error: deviceId and deviceToken must be set.\");\n  process.exit(1);\n}\n\nconst targetUrl = new URL(\n  `/api/states/${encodeURIComponent(config.entityId)}`,\n  config.serverUrl,\n);\nconst httpClient = targetUrl.protocol === \"https:\" ? https : http;\n\nconst makeRandomState = (length = 12) =>\n  randomBytes(Math.ceil(length / 2))\n    .toString(\"hex\")\n    .slice(0, length);\n\nasync function postState() {\n  const stateValue = makeRandomState();\n  const body = JSON.stringify({ state: stateValue });\n\n  const options = {\n    method: \"POST\",\n    hostname: targetUrl.hostname,\n    port: targetUrl.port || (targetUrl.protocol === \"https:\" ? 443 : 80),\n    path: targetUrl.pathname,\n    headers: {\n      \"Content-Type\": \"application/json\",\n      \"Content-Length\": Buffer.byteLength(body),\n      \"X-Device-Id\": config.deviceId,\n      Authorization: `Bearer ${config.deviceToken}`,\n    },\n  };\n\n  await new Promise((resolve, reject) => {\n    const req = httpClient.request(options, (res) => {\n      const chunks = [];\n\n      res.on(\"data\", (chunk) => chunks.push(chunk));\n      res.on(\"end\", () => {\n        const responseBody =\n          Buffer.concat(chunks).toString(\"utf8\") || \"<empty>\";\n        const timestamp = new Date().toISOString();\n\n        if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {\n          console.log(\n            `[${timestamp}] Sent state \"${stateValue}\" -> ${res.statusCode} ${res.statusMessage}`,\n          );\n        } else {\n          console.error(\n            `[${timestamp}] Server responded with ${res.statusCode} ${res.statusMessage}: ${responseBody}`,\n          );\n        }\n\n        resolve();\n      });\n    });\n\n    req.on(\"error\", (error) => {\n      console.error(\"Failed to send state:\", error.message);\n      reject(error);\n    });\n\n    req.write(body);\n    req.end();\n  });\n}\n\n(async () => {\n  try {\n    await postState();\n  } catch (error) {\n    console.error(\"Initial request failed, continuing with interval:\", error);\n  }\n\n  const intervalId = setInterval(async () => {\n    try {\n      await postState();\n    } catch (error) {\n      console.error(\"Interval request failed:\", error);\n    }\n  }, config.intervalMs);\n\n  process.on(\"SIGINT\", () => {\n    clearInterval(intervalId);\n    console.log(\"\\nStopped sending random text states.\");\n    process.exit(0);\n  });\n})();\n"
  },
  {
    "path": "jest.config.js",
    "content": "module.exports = {};\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"vessel\",\n  \"version\": \"1.0.0\",\n  \"description\": \"Physical Device Orchestration Platform\",\n  \"directories\": {\n    \"doc\": \"docs\"\n  },\n  \"private\": true,\n  \"scripts\": {\n    \"test\": \"jest\",\n    \"client\": \"npm run dev --workspace=client\",\n    \"client:build\": \"npm run build --workspace=@vessel/capsule-client && npm run build --workspace=client\",\n    \"landing\": \"npm run dev --workspace=landing\",\n    \"server\": \"cargo run -p server\",\n    \"server:prod\": \"cargo run -p server --release\",\n    \"desktop\": \"npm run dev --workspace=desktop\",\n    \"desktop:build\": \"npm run tauri build --workspace=desktop\",\n    \"capsule\": \"cd ./apps/capsule && cargo run -p capsule\",\n    \"build\": \"cd ./apps/server && cargo build --release\",\n    \"build:landing\": \"npm run build --workspace=landing && vitepress build docs\",\n    \"docs:dev\": \"vitepress dev docs\",\n    \"docs:build\": \"vitepress build docs\",\n    \"docs:preview\": \"vitepress preview docs\"\n  },\n  \"workspaces\": [\n    \"apps/*\",\n    \"packages/*\"\n  ],\n  \"overrides\": {\n    \"@docsearch/css\": \"4.6.2\",\n    \"@docsearch/js\": \"4.6.2\",\n    \"@docsearch/react\": \"4.6.2\"\n  },\n  \"dependencies\": {\n    \"@tailwindcss/vite\": \"^4.1.8\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"concurrently\": \"^9.1.2\",\n    \"lucide-react\": \"^0.539.0\",\n    \"monaco-editor\": \"^0.53.0\",\n    \"react\": \"^19.1.1\",\n    \"react-dom\": \"^19.1.1\",\n    \"react-error-boundary\": \"^6.0.0\",\n    \"react-router\": \"^7.8.1\",\n    \"tailwind-merge\": \"^3.3.1\",\n    \"tailwindcss\": \"^4.1.8\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^9.33.0\",\n    \"@types/node\": \"^24.3.0\",\n    \"@types/react\": \"^19.1.10\",\n    \"@types/react-dom\": \"^19.1.7\",\n    \"@vitejs/plugin-react\": \"^5.0.0\",\n    \"eslint\": \"^9.33.0\",\n    \"eslint-plugin-react-hooks\": \"^5.2.0\",\n    \"eslint-plugin-react-refresh\": \"^0.4.20\",\n    \"globals\": \"^16.3.0\",\n    \"jest\": \"^30.0.5\",\n    \"supertest\": \"^7.1.4\",\n    \"tw-animate-css\": \"^1.3.7\",\n    \"typescript\": \"~5.8.3\",\n    \"typescript-eslint\": \"^8.39.1\",\n    \"vite\": \"^7.1.2\",\n    \"vitepress\": \"^1.6.3\"\n  }\n}\n"
  },
  {
    "path": "packages/PKG.md",
    "content": ""
  },
  {
    "path": "packages/capsule-client/package.json",
    "content": "{\n  \"name\": \"@vessel/capsule-client\",\n  \"version\": \"0.1.0\",\n  \"description\": \"Client SDK for Vessel Capsule - Secure image encryption and API client\",\n  \"type\": \"module\",\n  \"main\": \"dist/index.js\",\n  \"module\": \"dist/index.js\",\n  \"types\": \"dist/index.d.ts\",\n  \"exports\": {\n    \".\": {\n      \"types\": \"./dist/index.d.ts\",\n      \"import\": \"./dist/index.js\"\n    }\n  },\n  \"files\": [\n    \"dist\"\n  ],\n  \"scripts\": {\n    \"build\": \"tsc\",\n    \"dev\": \"tsc --watch\",\n    \"clean\": \"rm -rf dist\"\n  },\n  \"dependencies\": {\n    \"@noble/ciphers\": \"^0.5.0\",\n    \"@noble/curves\": \"^1.3.0\",\n    \"@noble/hashes\": \"^1.3.3\"\n  },\n  \"devDependencies\": {\n    \"typescript\": \"^5.3.0\"\n  },\n  \"keywords\": [\n    \"encryption\",\n    \"x25519\",\n    \"xchacha20\",\n    \"capsule\",\n    \"secure\"\n  ],\n  \"license\": \"MIT\"\n}\n"
  },
  {
    "path": "packages/capsule-client/src/client.ts",
    "content": "import { encryptImage, fileToBytes } from './crypto';\nimport type {\n  AnalyzeImageOptions,\n  ChatOptions,\n  ChatRequest,\n  ChatResponse,\n  CapsuleClientOptions,\n  PublicKeyResponse,\n  StreamCallback,\n  ToolCallCallback,\n  ToolCallResult,\n} from './types';\n\n/**\n * Authentication error\n *\n * Thrown when authentication token is missing or expired\n */\nexport class CapsuleAuthError extends Error {\n  constructor(message: string = 'Authentication required') {\n    super(message);\n    this.name = 'CapsuleAuthError';\n  }\n}\n\n/**\n * Rate limit error\n *\n * Thrown when daily usage limit is exceeded\n */\nexport class CapsuleRateLimitError extends Error {\n  constructor(message: string = 'Rate limit exceeded') {\n    super(message);\n    this.name = 'CapsuleRateLimitError';\n  }\n}\n\n/**\n * Subscription required error\n *\n * Thrown when a non-subscriber tries to access Pro features\n */\nexport class CapsuleSubscriptionError extends Error {\n  constructor(message: string = 'Pro subscription required') {\n    super(message);\n    this.name = 'CapsuleSubscriptionError';\n  }\n}\n\n/**\n * Capsule Client\n *\n * Client SDK for secure image analysis\n *\n * @example\n * ```typescript\n * const client = new CapsuleClient({\n *   baseUrl: 'https://capsule.example.com',\n * });\n *\n * // Analyze image\n * const response = await client.analyzeImage({\n *   image: imageFile,\n *   message: 'Please analyze this image',\n * });\n *\n * console.log(response.response);\n * ```\n */\nexport class CapsuleClient {\n  private baseUrl: string;\n  private timeout: number;\n  private serverPublicKey: string | null = null;\n  private getAccessToken?: () => Promise<string | null>;\n\n  constructor(options: CapsuleClientOptions) {\n    this.baseUrl = options.baseUrl.replace(/\\/$/, '');\n    this.timeout = options.timeout ?? 60000;\n    this.getAccessToken = options.getAccessToken;\n  }\n\n  /**\n   * Fetch server public key\n   *\n   * Cached - reused once fetched.\n   * Requires re-fetching after server restart.\n   */\n  async getPublicKey(): Promise<string> {\n    if (this.serverPublicKey) {\n      return this.serverPublicKey;\n    }\n\n    const response = await this.fetch<PublicKeyResponse>('/api/public-key', {\n      method: 'GET',\n    });\n\n    this.serverPublicKey = response.public_key;\n    return this.serverPublicKey;\n  }\n\n  /**\n   * Invalidate cached public key\n   *\n   * Should be called after server restart.\n   */\n  clearPublicKeyCache(): void {\n    this.serverPublicKey = null;\n  }\n\n  /**\n   * Change Base URL\n   *\n   * Automatically clears key cache since a different server has a different key.\n   */\n  setBaseUrl(url: string): void {\n    this.baseUrl = url.replace(/\\/$/, '');\n    this.serverPublicKey = null;\n  }\n\n  /**\n   * Text-only chat\n   *\n   * @param message - User message\n   * @param options - Conversation history and system prompt (optional)\n   */\n  async chat(message: string, options?: ChatOptions): Promise<ChatResponse> {\n    const request: ChatRequest = {\n      message,\n      ...(options?.history && { history: options.history }),\n      ...(options?.systemPrompt && { system_prompt: options.systemPrompt }),\n      ...(options?.tools && { tools: options.tools }),\n      ...(options?.toolChoice && { tool_choice: options.toolChoice }),\n    };\n\n    return this.fetch<ChatResponse>('/api/chat', {\n      method: 'POST',\n      body: JSON.stringify(request),\n    });\n  }\n\n  /**\n   * Image analysis\n   *\n   * @param options - Analysis options\n   * @returns AI response\n   *\n   * ## Secure data flow\n   * 1. Fetch server public key (cached)\n   * 2. Encrypt image with public key (X25519 + XChaCha20-Poly1305)\n   * 3. Send encrypted image to server\n   * 4. Server decrypts and analyzes with AI\n   * 5. Receive response\n   */\n  async analyzeImage(options: AnalyzeImageOptions, chatOptions?: ChatOptions): Promise<ChatResponse> {\n    const publicKey = await this.getPublicKey();\n\n    let imageBytes: Uint8Array;\n    if (options.image instanceof Uint8Array) {\n      imageBytes = options.image;\n    } else {\n      imageBytes = await fileToBytes(options.image);\n    }\n\n    const encryptedImage = await encryptImage(imageBytes, publicKey);\n\n    const request: ChatRequest = {\n      message: options.message,\n      encrypted_image: encryptedImage,\n      ...(chatOptions?.history && { history: chatOptions.history }),\n      ...(chatOptions?.systemPrompt && { system_prompt: chatOptions.systemPrompt }),\n      ...(chatOptions?.tools && { tools: chatOptions.tools }),\n      ...(chatOptions?.toolChoice && { tool_choice: chatOptions.toolChoice }),\n    };\n\n    return this.fetch<ChatResponse>('/api/chat', {\n      method: 'POST',\n      body: JSON.stringify(request),\n    });\n  }\n\n  /**\n   * Streaming chat\n   *\n   * @param message - User message\n   * @param onChunk - Streaming chunk callback\n   * @param options - Conversation history and system prompt (optional)\n   */\n  async chatStream(message: string, onChunk: StreamCallback, options?: ChatOptions): Promise<void> {\n    const request: ChatRequest = {\n      message,\n      ...(options?.history && { history: options.history }),\n      ...(options?.systemPrompt && { system_prompt: options.systemPrompt }),\n      ...(options?.tools && { tools: options.tools }),\n      ...(options?.toolChoice && { tool_choice: options.toolChoice }),\n    };\n\n    await this.fetchStream('/api/chat/stream', request, onChunk, options?.onToolCall);\n  }\n\n  /**\n   * Streaming image analysis\n   *\n   * @param options - Image analysis options\n   * @param onChunk - Streaming chunk callback\n   * @param chatOptions - Conversation history and system prompt (optional)\n   */\n  async analyzeImageStream(\n    options: AnalyzeImageOptions,\n    onChunk: StreamCallback,\n    chatOptions?: ChatOptions,\n  ): Promise<void> {\n    const publicKey = await this.getPublicKey();\n\n    let imageBytes: Uint8Array;\n    if (options.image instanceof Uint8Array) {\n      imageBytes = options.image;\n    } else {\n      imageBytes = await fileToBytes(options.image);\n    }\n\n    const encryptedImage = await encryptImage(imageBytes, publicKey);\n\n    const request: ChatRequest = {\n      message: options.message,\n      encrypted_image: encryptedImage,\n      ...(chatOptions?.history && { history: chatOptions.history }),\n      ...(chatOptions?.systemPrompt && { system_prompt: chatOptions.systemPrompt }),\n      ...(chatOptions?.tools && { tools: chatOptions.tools }),\n      ...(chatOptions?.toolChoice && { tool_choice: chatOptions.toolChoice }),\n    };\n\n    await this.fetchStream('/api/chat/stream', request, onChunk, chatOptions?.onToolCall);\n  }\n\n  /**\n   * HTTP request\n   */\n  private async fetch<T>(path: string, init: RequestInit): Promise<T> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), this.timeout);\n\n    try {\n      const headers: Record<string, string> = {\n        'Content-Type': 'application/json',\n        ...(init.headers as Record<string, string>),\n      };\n\n      if (this.getAccessToken) {\n        const token = await this.getAccessToken();\n        if (token) {\n          headers['Authorization'] = `Bearer ${token}`;\n        }\n      }\n\n      const response = await fetch(`${this.baseUrl}${path}`, {\n        ...init,\n        headers,\n        signal: controller.signal,\n      });\n\n      if (response.status === 401) {\n        throw new CapsuleAuthError('Authentication required. Please sign in.');\n      }\n      if (response.status === 403) {\n        throw new CapsuleSubscriptionError(\n          'Pro subscription required. Please upgrade at vessel.land/pricing'\n        );\n      }\n      if (response.status === 429) {\n        const errorText = await response.text();\n        throw new CapsuleRateLimitError(errorText || 'Rate limit exceeded. Try again later.');\n      }\n\n      if (!response.ok) {\n        const error = await response.text();\n        throw new Error(`Request failed: ${response.status} ${error}`);\n      }\n\n      return response.json();\n    } finally {\n      clearTimeout(timeoutId);\n    }\n  }\n\n  /**\n   * SSE streaming request\n   */\n  private async fetchStream(\n    path: string,\n    request: ChatRequest,\n    onChunk: StreamCallback,\n    onToolCall?: ToolCallCallback,\n  ): Promise<void> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), this.timeout);\n\n    try {\n      const headers: Record<string, string> = {\n        'Content-Type': 'application/json',\n      };\n\n      if (this.getAccessToken) {\n        const token = await this.getAccessToken();\n        if (token) {\n          headers['Authorization'] = `Bearer ${token}`;\n        }\n      }\n\n      const response = await fetch(`${this.baseUrl}${path}`, {\n        method: 'POST',\n        headers,\n        body: JSON.stringify(request),\n        signal: controller.signal,\n      });\n\n      if (response.status === 401) {\n        throw new CapsuleAuthError('Authentication required. Please sign in.');\n      }\n      if (response.status === 403) {\n        throw new CapsuleSubscriptionError(\n          'Pro subscription required. Please upgrade at vessel.land/pricing'\n        );\n      }\n      if (response.status === 429) {\n        const errorText = await response.text();\n        throw new CapsuleRateLimitError(errorText || 'Rate limit exceeded. Try again later.');\n      }\n\n      if (!response.ok) {\n        const error = await response.text();\n        throw new Error(`Request failed: ${response.status} ${error}`);\n      }\n\n      if (!response.body) {\n        throw new Error('No response body');\n      }\n\n      const reader = response.body.getReader();\n      const decoder = new TextDecoder();\n      let buffer = '';\n\n      const processLine = (line: string) => {\n        if (!line.startsWith('data: ')) return;\n        const data = line.slice(6);\n\n        if (data === '[DONE]') {\n          onChunk('', true);\n          return;\n        }\n\n        if (data.startsWith('[TOOL_CALLS]')) {\n          if (onToolCall) {\n            try {\n              const toolCalls: ToolCallResult[] = JSON.parse(data.slice('[TOOL_CALLS]'.length));\n              onToolCall(toolCalls);\n            } catch {\n              // malformed tool call JSON\n            }\n          }\n          return;\n        }\n\n        onChunk(data, false);\n      };\n\n      while (true) {\n        const { done, value } = await reader.read();\n\n        if (done) {\n          if (buffer.trim()) {\n            processLine(buffer.trim());\n          }\n          onChunk('', true);\n          break;\n        }\n\n        buffer += decoder.decode(value, { stream: true });\n        const lines = buffer.split('\\n');\n        buffer = lines.pop() || '';\n\n        for (const line of lines) {\n          const trimmed = line.trim();\n          if (trimmed) {\n            processLine(trimmed);\n          }\n        }\n      }\n    } finally {\n      clearTimeout(timeoutId);\n    }\n  }\n}\n"
  },
  {
    "path": "packages/capsule-client/src/conversation.ts",
    "content": "import type { HistoryMessage, ChatResponse, ChatOptions, StreamCallback } from './types';\nimport type { CapsuleClient } from './client';\n\n/**\n * 기본 최대 히스토리 메시지 수\n */\nconst DEFAULT_MAX_HISTORY = 50;\n\n/**\n * 대화 옵션\n */\nexport interface ConversationOptions {\n  /** 시스템 프롬프트 */\n  systemPrompt?: string;\n  /** 최대 히스토리 메시지 수 (기본값: 50) */\n  maxHistory?: number;\n}\n\n/**\n * 멀티턴 대화 관리자\n *\n * 대화 히스토리를 자동으로 관리하고 매 요청에 포함시킵니다.\n * 히스토리는 메모리에만 저장되며 서버에 저장되지 않습니다.\n *\n * @example\n * ```typescript\n * const client = new CapsuleClient({ baseUrl: 'https://capsule.example.com' });\n * const conversation = new Conversation(client, {\n *   systemPrompt: 'You are a helpful assistant.',\n *   maxHistory: 20,\n * });\n *\n * const res1 = await conversation.chat('Hello!');\n * console.log(res1.response);\n *\n * const res2 = await conversation.chat('What did I just say?');\n * // AI가 이전 대화를 참조하여 응답\n * ```\n */\nexport class Conversation {\n  private client: CapsuleClient;\n  private history: HistoryMessage[] = [];\n  private systemPrompt?: string;\n  private maxHistory: number;\n\n  constructor(client: CapsuleClient, options?: ConversationOptions) {\n    this.client = client;\n    this.systemPrompt = options?.systemPrompt;\n    this.maxHistory = options?.maxHistory ?? DEFAULT_MAX_HISTORY;\n  }\n\n  /**\n   * 메시지 전송 및 응답 수신\n   *\n   * 사용자 메시지와 AI 응답을 자동으로 히스토리에 추가합니다.\n   */\n  async chat(message: string): Promise<ChatResponse> {\n    const chatOptions: ChatOptions = {\n      systemPrompt: this.systemPrompt,\n      history: this.history.length > 0 ? [...this.history] : undefined,\n    };\n\n    const response = await this.client.chat(message, chatOptions);\n\n    this.appendToHistory({ role: 'user', content: message });\n    this.appendToHistory({ role: 'assistant', content: response.response });\n\n    return response;\n  }\n\n  /**\n   * 스트리밍 메시지 전송\n   *\n   * 스트리밍 완료 후 전체 응답을 히스토리에 추가합니다.\n   */\n  async chatStream(message: string, onChunk: StreamCallback): Promise<void> {\n    const chatOptions: ChatOptions = {\n      systemPrompt: this.systemPrompt,\n      history: this.history.length > 0 ? [...this.history] : undefined,\n    };\n\n    let fullResponse = '';\n\n    await this.client.chatStream(\n      message,\n      (chunk: string, done: boolean) => {\n        if (!done) {\n          fullResponse += chunk;\n        }\n        onChunk(chunk, done);\n\n        if (done) {\n          this.appendToHistory({ role: 'user', content: message });\n          this.appendToHistory({ role: 'assistant', content: fullResponse });\n        }\n      },\n      chatOptions,\n    );\n  }\n\n  /**\n   * 이미지 분석 결과를 히스토리에 추가\n   *\n   * 암호화된 이미지는 재전송할 수 없으므로,\n   * 이미지 분석 결과를 텍스트로 히스토리에 추가합니다.\n   */\n  addImageAnalysisToHistory(userMessage: string, assistantResponse: string): void {\n    this.appendToHistory({\n      role: 'user',\n      content: `[Image analysis request] ${userMessage}`,\n    });\n    this.appendToHistory({\n      role: 'assistant',\n      content: assistantResponse,\n    });\n  }\n\n  /**\n   * 현재 대화 히스토리 조회 (읽기 전용 복사본)\n   */\n  getHistory(): ReadonlyArray<HistoryMessage> {\n    return [...this.history];\n  }\n\n  /**\n   * 대화 히스토리 초기화\n   */\n  clearHistory(): void {\n    this.history = [];\n  }\n\n  /**\n   * 시스템 프롬프트 변경\n   */\n  setSystemPrompt(prompt: string | undefined): void {\n    this.systemPrompt = prompt;\n  }\n\n  private appendToHistory(message: HistoryMessage): void {\n    this.history.push(message);\n\n    while (this.history.length > this.maxHistory) {\n      this.history.shift();\n    }\n  }\n}\n"
  },
  {
    "path": "packages/capsule-client/src/crypto.ts",
    "content": "import { x25519 } from '@noble/curves/ed25519';\nimport { xchacha20poly1305 } from '@noble/ciphers/chacha';\nimport { hkdf } from '@noble/hashes/hkdf';\nimport { sha256 } from '@noble/hashes/sha256';\nimport { randomBytes } from '@noble/ciphers/webcrypto';\nimport type { EncryptedImage } from './types';\n\n/** HKDF salt (서버와 동일해야 함) */\nconst HKDF_SALT = new TextEncoder().encode('vessel-capsule-v1-salt');\n/** HKDF info (서버와 동일해야 함) */\nconst HKDF_INFO = new TextEncoder().encode('vessel-capsule-v1-key');\n\n/**\n * Base64 encoding (handles large arrays)\n */\nfunction bytesToBase64(bytes: Uint8Array): string {\n  // Use chunked approach to avoid stack overflow for large images\n  const CHUNK_SIZE = 0x8000; // 32KB chunks\n  const chunks: string[] = [];\n\n  for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {\n    const chunk = bytes.subarray(i, i + CHUNK_SIZE);\n    chunks.push(String.fromCharCode.apply(null, chunk as unknown as number[]));\n  }\n\n  return btoa(chunks.join(''));\n}\n\n/**\n * Base64 디코딩\n */\nfunction base64ToBytes(base64: string): Uint8Array {\n  const binary = atob(base64);\n  const bytes = new Uint8Array(binary.length);\n  for (let i = 0; i < binary.length; i++) {\n    bytes[i] = binary.charCodeAt(i);\n  }\n  return bytes;\n}\n\n/**\n * Uint8Array를 0으로 덮어씀 (메모리 클리어)\n */\nfunction zeroize(arr: Uint8Array): void {\n  arr.fill(0);\n}\n\n/**\n * 이미지를 서버 공개키로 암호화\n *\n * @param imageData - 암호화할 이미지 데이터\n * @param serverPublicKeyBase64 - 서버 공개키 (base64)\n * @returns 암호화된 이미지 데이터\n *\n * @example\n * ```typescript\n * const encrypted = await encryptImage(imageBytes, serverPublicKey);\n * // encrypted.ephemeral_public_key - 클라이언트 임시 공개키\n * // encrypted.nonce - 24바이트 nonce\n * // encrypted.ciphertext - 암호화된 데이터\n * ```\n *\n * ## 암호화 스킴\n * 1. 클라이언트 임시 키 쌍 생성 (X25519)\n * 2. ECDH로 shared secret 계산\n * 3. HKDF-SHA256으로 대칭키 유도\n * 4. XChaCha20-Poly1305로 암호화 (AEAD)\n *\n * ## 보안\n * - 임시 비밀키는 사용 후 즉시 zeroize\n * - Shared secret도 사용 후 즉시 zeroize\n */\nexport async function encryptImage(\n  imageData: Uint8Array,\n  serverPublicKeyBase64: string\n): Promise<EncryptedImage> {\n  // 1. 서버 공개키 디코딩\n  const serverPublicKey = base64ToBytes(serverPublicKeyBase64);\n\n  // 2. 클라이언트 임시 키 쌍 생성\n  const ephemeralPrivateKey = randomBytes(32);\n  const ephemeralPublicKey = x25519.getPublicKey(ephemeralPrivateKey);\n\n  // 3. Shared secret 계산 (X25519 ECDH)\n  const sharedSecret = x25519.getSharedSecret(ephemeralPrivateKey, serverPublicKey);\n\n  // 4. HKDF로 대칭키 유도 (32 bytes for XChaCha20)\n  const symmetricKey = hkdf(sha256, sharedSecret, HKDF_SALT, HKDF_INFO, 32);\n\n  // Shared secret 즉시 클리어\n  zeroize(sharedSecret);\n\n  // 5. Nonce 생성 (24 bytes for XChaCha20)\n  const nonce = randomBytes(24);\n\n  // 6. XChaCha20-Poly1305로 암호화\n  const cipher = xchacha20poly1305(symmetricKey, nonce);\n  const ciphertext = cipher.encrypt(imageData);\n\n  // 민감 데이터 클리어\n  zeroize(ephemeralPrivateKey);\n  zeroize(symmetricKey);\n\n  return {\n    ephemeral_public_key: bytesToBase64(ephemeralPublicKey),\n    nonce: bytesToBase64(nonce),\n    ciphertext: bytesToBase64(ciphertext),\n  };\n}\n\n/**\n * File 또는 Blob을 Uint8Array로 변환\n */\nexport async function fileToBytes(file: File | Blob): Promise<Uint8Array> {\n  const arrayBuffer = await file.arrayBuffer();\n  return new Uint8Array(arrayBuffer);\n}\n"
  },
  {
    "path": "packages/capsule-client/src/index.ts",
    "content": "/**\n * @vessel/capsule-client\n *\n * 보안 이미지 분석을 위한 클라이언트 SDK\n *\n * ## 설치\n * ```bash\n * npm install @vessel/capsule-client\n * ```\n *\n * ## 사용법\n * ```typescript\n * import { CapsuleClient } from '@vessel/capsule-client';\n *\n * const client = new CapsuleClient({\n *   baseUrl: 'https://capsule.example.com',\n * });\n *\n * // 이미지 분석\n * const response = await client.analyzeImage({\n *   image: imageFile,  // File, Blob, 또는 Uint8Array\n *   message: '이 이미지를 분석해주세요',\n * });\n *\n * console.log(response.response);\n * ```\n *\n * ## 보안\n * - 이미지는 서버 공개키로 암호화됨 (X25519 + XChaCha20-Poly1305)\n * - 서버에서만 복호화 가능 (Private Key는 서버 메모리에만 존재)\n * - 호스팅 제공자도 이미지에 접근 불가\n *\n * @packageDocumentation\n */\n\nexport {\n  CapsuleClient,\n  CapsuleAuthError,\n  CapsuleRateLimitError,\n  CapsuleSubscriptionError,\n} from './client';\nexport { Conversation } from './conversation';\nexport type { ConversationOptions } from './conversation';\nexport { encryptImage, fileToBytes } from './crypto';\nexport type {\n  EncryptedImage,\n  ChatRequest,\n  ChatResponse,\n  ChatOptions,\n  HistoryMessage,\n  PublicKeyResponse,\n  CapsuleClientOptions,\n  AnalyzeImageOptions,\n  StreamCallback,\n  Tool,\n  ToolFunction,\n  ToolCallResult,\n  ToolCallCallback,\n} from './types';\n"
  },
  {
    "path": "packages/capsule-client/src/types.ts",
    "content": "/**\n * A single message in conversation history\n *\n * Used for multi-turn conversations. The client manages history\n * and includes it in each request.\n */\nexport interface HistoryMessage {\n  /** Message role */\n  role: 'user' | 'assistant' | 'system' | 'tool';\n  /** Text content */\n  content: string;\n  /** Tool call ID (required when role is \"tool\") */\n  tool_call_id?: string;\n  /** Tool calls (when role is \"assistant\" and responding with tool calls) */\n  tool_calls?: ToolCallResult[];\n}\n\n/**\n * OpenAI function calling tool definition\n */\nexport interface ToolFunction {\n  name: string;\n  description: string;\n  parameters: Record<string, unknown>;\n}\n\n/**\n * OpenAI tool definition\n */\nexport interface Tool {\n  type: 'function';\n  function: ToolFunction;\n}\n\n/**\n * Tool call result returned by the LLM\n */\nexport interface ToolCallResult {\n  id: string;\n  type: 'function';\n  function: { name: string; arguments: string };\n}\n\n/**\n * Tool call received callback\n */\nexport type ToolCallCallback = (toolCalls: ToolCallResult[]) => void;\n\n/**\n * Conversation history and system prompt options\n */\nexport interface ChatOptions {\n  /** System prompt */\n  systemPrompt?: string;\n  /** Conversation history (oldest first) */\n  history?: HistoryMessage[];\n  /** OpenAI tools definition (function calling) */\n  tools?: Tool[];\n  /** OpenAI tool_choice (\"auto\" | \"required\" | \"none\") */\n  toolChoice?: 'auto' | 'required' | 'none';\n  /** Tool call received callback */\n  onToolCall?: ToolCallCallback;\n}\n\n/**\n * Encrypted image data\n */\nexport interface EncryptedImage {\n  /** Client ephemeral public key (base64) */\n  ephemeral_public_key: string;\n  /** Nonce (base64) */\n  nonce: string;\n  /** Encrypted data (base64) */\n  ciphertext: string;\n}\n\n/**\n * Chat request\n */\nexport interface ChatRequest {\n  /** User message */\n  message: string;\n  /** Encrypted image (optional) */\n  encrypted_image?: EncryptedImage;\n  /** Conversation history (optional, oldest first) */\n  history?: HistoryMessage[];\n  /** System prompt (optional) */\n  system_prompt?: string;\n  /** OpenAI tools definition (optional, function calling) */\n  tools?: Tool[];\n  /** OpenAI tool_choice (optional) */\n  tool_choice?: string;\n}\n\n/**\n * Chat response\n */\nexport interface ChatResponse {\n  /** AI response */\n  response: string;\n}\n\n/**\n * Public Key response\n */\nexport interface PublicKeyResponse {\n  /** Server public key (base64) */\n  public_key: string;\n}\n\n/**\n * Capsule client options\n */\nexport interface CapsuleClientOptions {\n  /** Capsule server URL */\n  baseUrl: string;\n  /** Request timeout (ms) */\n  timeout?: number;\n  /**\n   * Access token provider\n   *\n   * Function that provides Supabase session tokens.\n   * Automatically added to the Authorization header for authenticated API calls.\n   *\n   * @example\n   * ```typescript\n   * const client = new CapsuleClient({\n   *   baseUrl: 'https://capsule.example.com',\n   *   getAccessToken: async () => {\n   *     const { data: { session } } = await supabase.auth.getSession();\n   *     return session?.access_token ?? null;\n   *   },\n   * });\n   * ```\n   */\n  getAccessToken?: () => Promise<string | null>;\n}\n\n/**\n * Image analysis request options\n */\nexport interface AnalyzeImageOptions {\n  /** Image to analyze (File, Blob, or Uint8Array) */\n  image: File | Blob | Uint8Array;\n  /** Analysis request message */\n  message: string;\n}\n\n/**\n * Streaming event callback\n */\nexport type StreamCallback = (chunk: string, done: boolean) => void;\n"
  },
  {
    "path": "packages/capsule-client/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"declaration\": true,\n    \"declarationMap\": true,\n    \"sourceMap\": true,\n    \"outDir\": \"./dist\",\n    \"rootDir\": \"./src\",\n    \"strict\": true,\n    \"esModuleInterop\": true,\n    \"skipLibCheck\": true,\n    \"forceConsistentCasingInFileNames\": true\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\"node_modules\", \"dist\"]\n}\n"
  },
  {
    "path": "packages/custom-node-utils/add_number.py",
    "content": "def main(inputs):\n  a = inputs.get(\"a\", 0)\n  b = inputs.get(\"b\", 0)\n\n\n  outputs = {\n    \"number\": a+b\n  }\n\n  return outputs"
  },
  {
    "path": "packages/custom-node-utils/random.py",
    "content": "import random\n\ndef main(inputs):\n  result = random.random()\n  outputs = {\n    \"number\": result\n  }\n\n  return outputs\n"
  },
  {
    "path": "packages/custom-node-utils/vessel.config.json",
    "content": "{\n  \"name\": \"custom-node\",\n  \"version\": \"1.0.0\",\n  \"description\": \"\",\n  \"nodes\": [\n    {\n      \"nodeId\": \"_add_number\",\n      \"data\": {\n        \"connectors\": [\n          {\n            \"id\": \"id\",\n            \"name\": \"a\",\n            \"type\": \"in\"\n          },\n          {\n            \"id\": \"id\",\n            \"name\": \"b\",\n            \"type\": \"in\"\n          },\n          {\n            \"id\": \"id\",\n            \"name\": \"number\",\n            \"type\": \"out\"\n          }\n        ],\n        \"nodeType\": \"_add_number\",\n        \"data\": {\n          \"path\": \"add_number.py\"\n        },\n        \"dataType\": {\n          \"path\": \"FIXED_STRING\"\n        }\n      }\n    },\n    {\n      \"nodeId\": \"_random_number\",\n      \"data\": {\n        \"connectors\": [\n          {\n            \"id\": \"id\",\n            \"name\": \"number\",\n            \"type\": \"out\"\n          }\n        ],\n        \"nodeType\": \"_random_number\",\n        \"data\": {\n          \"path\": \"random.py\"\n        },\n        \"dataType\": {\n          \"path\": \"FIXED_STRING\"\n        }\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": "scripts/bundle-macos-deps.sh",
    "content": "#!/bin/bash\n# bundle-macos-deps.sh\n#\n# Collects GStreamer, GLib, Python dylibs and GStreamer plugins into a staging\n# directory, rewrites all absolute Homebrew install-names to @loader_path /\n# @rpath relative references, and re-signs every modified Mach-O binary.\n#\n# Run from the workspace root (the directory that contains Cargo.toml).\n# Prerequisites: Homebrew-installed gstreamer, glib, python@3.12\n\nset -euo pipefail\n\n# ---------------------------------------------------------------------------\n# Paths\n# ---------------------------------------------------------------------------\nROOT=\"$(cd \"$(dirname \"$0\")/..\" && pwd)\"\nSTAGING=\"$ROOT/target/bundle-staging\"\nLIBS_DIR=\"$STAGING/libs\"\nGST_PLUGINS_DIR=\"$STAGING/gstreamer-1.0\"\nGST_HELPERS_DIR=\"$STAGING/gstreamer-1.0/helpers\"\nSERVER_BIN=\"$ROOT/target/release/server\"\n\nARCH=$(uname -m)\nif [ \"$ARCH\" = \"arm64\" ]; then\n  BREW_PREFIX=\"/opt/homebrew\"\nelse\n  BREW_PREFIX=\"/usr/local\"\nfi\n\necho \"==> Architecture: $ARCH\"\necho \"==> Homebrew prefix: $BREW_PREFIX\"\necho \"==> Staging directory: $STAGING\"\n\n# ---------------------------------------------------------------------------\n# Clean & create staging directories\n# ---------------------------------------------------------------------------\nrm -rf \"$STAGING\"\nmkdir -p \"$LIBS_DIR\" \"$GST_PLUGINS_DIR\" \"$GST_HELPERS_DIR\"\n\n# ---------------------------------------------------------------------------\n# Helper: resolve a dylib path through symlinks and copy to LIBS_DIR\n# ---------------------------------------------------------------------------\ncopy_lib() {\n  local src=\"$1\"\n  local dst_name=\"${2:-$(basename \"$src\")}\"\n  if [ ! -f \"$LIBS_DIR/$dst_name\" ]; then\n    local real\n    real=$(python3 -c \"import os,sys; print(os.path.realpath(sys.argv[1]))\" \"$src\" 2>/dev/null || readlink -f \"$src\" 2>/dev/null || echo \"$src\")\n    # Use cat to avoid copying extended attributes\n    cat \"$real\" > \"$LIBS_DIR/$dst_name\"\n    chmod 644 \"$LIBS_DIR/$dst_name\"\n    echo \"    copied $dst_name\"\n  fi\n}\n\n# ---------------------------------------------------------------------------\n# Helper: recursively collect non-system dylib dependencies\n# ---------------------------------------------------------------------------\ncollect_deps() {\n  local binary=\"$1\"\n  local deps\n  deps=$(otool -L \"$binary\" 2>/dev/null | tail -n +2 | awk '{print $1}')\n\n  local dep bn\n  while IFS= read -r dep; do\n    [ -z \"$dep\" ] && continue\n    # Only process Homebrew (non-system) libraries\n    if [[ \"$dep\" == /opt/homebrew/* ]] || [[ \"$dep\" == /usr/local/Cellar/* ]] || [[ \"$dep\" == /usr/local/opt/* ]]; then\n      bn=$(basename \"$dep\")\n      if [ ! -f \"$LIBS_DIR/$bn\" ]; then\n        copy_lib \"$dep\" \"$bn\"\n        # Recurse into the newly copied library\n        collect_deps \"$LIBS_DIR/$bn\"\n      fi\n    fi\n  done <<< \"$deps\"\n}\n\n# ---------------------------------------------------------------------------\n# 1. Collect GStreamer core libraries\n# ---------------------------------------------------------------------------\necho \"==> Collecting GStreamer core libraries...\"\nGST_CORE_LIBS=(\n  libgstreamer-1.0.0.dylib\n  libgstbase-1.0.0.dylib\n  libgstapp-1.0.0.dylib\n  libgstvideo-1.0.0.dylib\n  libgstaudio-1.0.0.dylib\n  libgsttag-1.0.0.dylib\n  libgstrtp-1.0.0.dylib\n  libgstrtsp-1.0.0.dylib\n  libgstsdp-1.0.0.dylib\n  libgstnet-1.0.0.dylib\n  libgstpbutils-1.0.0.dylib\n  libgstcodecparsers-1.0.0.dylib\n)\n\nfor lib in \"${GST_CORE_LIBS[@]}\"; do\n  src=\"$BREW_PREFIX/lib/$lib\"\n  if [ ! -f \"$src\" ]; then\n    # Try the opt path\n    src=$(find \"$BREW_PREFIX/opt/gstreamer\" -name \"$lib\" -type f 2>/dev/null | head -1)\n  fi\n  if [ -n \"$src\" ] && [ -f \"$src\" ]; then\n    copy_lib \"$src\" \"$lib\"\n  else\n    echo \"    WARNING: $lib not found, skipping\"\n  fi\ndone\n\n# ---------------------------------------------------------------------------\n# 2. Collect GLib libraries\n# ---------------------------------------------------------------------------\necho \"==> Collecting GLib libraries...\"\nGLIB_LIBS=(libglib-2.0.0.dylib libgobject-2.0.0.dylib libgmodule-2.0.0.dylib libgio-2.0.0.dylib)\nfor lib in \"${GLIB_LIBS[@]}\"; do\n  src=\"$BREW_PREFIX/lib/$lib\"\n  if [ ! -f \"$src\" ]; then\n    src=$(find \"$BREW_PREFIX/opt/glib\" -name \"$lib\" -type f 2>/dev/null | head -1)\n  fi\n  if [ -n \"$src\" ] && [ -f \"$src\" ]; then\n    copy_lib \"$src\" \"$lib\"\n  else\n    echo \"    WARNING: $lib not found, skipping\"\n  fi\ndone\n\n# ---------------------------------------------------------------------------\n# 3. Collect other shared dependencies\n# ---------------------------------------------------------------------------\necho \"==> Collecting other shared dependencies...\"\nOTHER_DEPS=(\n  \"gettext/lib/libintl.8.dylib\"\n  \"pcre2/lib/libpcre2-8.0.dylib\"\n  \"orc/lib/liborc-0.4.0.dylib\"\n  \"libiconv/lib/libiconv.2.dylib\"\n)\nfor rel in \"${OTHER_DEPS[@]}\"; do\n  src=\"$BREW_PREFIX/opt/$rel\"\n  bn=$(basename \"$rel\")\n  if [ -f \"$src\" ]; then\n    copy_lib \"$src\" \"$bn\"\n  else\n    echo \"    WARNING: $bn not found at $src, skipping\"\n  fi\ndone\n\n# ---------------------------------------------------------------------------\n# 4. Recursively collect transitive dependencies\n# ---------------------------------------------------------------------------\necho \"==> Collecting transitive dependencies...\"\n# Run multiple passes until no new libs are discovered\nfor _ in 1 2 3 4 5; do\n  before=$(ls \"$LIBS_DIR\" | wc -l)\n  for f in \"$LIBS_DIR\"/*.dylib \"$LIBS_DIR\"/Python; do\n    [ -f \"$f\" ] && collect_deps \"$f\"\n  done\n  after=$(ls \"$LIBS_DIR\" | wc -l)\n  if [ \"$before\" -eq \"$after\" ]; then\n    break\n  fi\ndone\necho \"    Total libs collected: $(ls \"$LIBS_DIR\" | wc -l)\"\n\n# ---------------------------------------------------------------------------\n# 6. Collect GStreamer plugins\n# ---------------------------------------------------------------------------\necho \"==> Collecting GStreamer plugins...\"\nGST_PLUGINS=(\n  libgstcoreelements.dylib\n  libgstapp.dylib\n  libgstapplemedia.dylib\n  libgstrtsp.dylib\n  libgstrtp.dylib\n  libgstrtpmanager.dylib\n  libgstvideoconvertscale.dylib\n  libgstvideoparsersbad.dylib\n)\n\nGST_PLUGIN_SEARCH=\"$BREW_PREFIX/lib/gstreamer-1.0\"\nif [ ! -d \"$GST_PLUGIN_SEARCH\" ]; then\n  GST_PLUGIN_SEARCH=$(find \"$BREW_PREFIX/opt/gstreamer\" -type d -name \"gstreamer-1.0\" 2>/dev/null | head -1)\nfi\n\nfor plugin in \"${GST_PLUGINS[@]}\"; do\n  src=\"$GST_PLUGIN_SEARCH/$plugin\"\n  if [ -f \"$src\" ]; then\n    cat \"$src\" > \"$GST_PLUGINS_DIR/$plugin\"\n    chmod 644 \"$GST_PLUGINS_DIR/$plugin\"\n    echo \"    copied plugin $plugin\"\n  else\n    echo \"    WARNING: plugin $plugin not found\"\n  fi\ndone\n\n# Collect transitive deps of plugins too\necho \"==> Collecting plugin transitive dependencies...\"\nfor f in \"$GST_PLUGINS_DIR\"/*.dylib; do\n  [ -f \"$f\" ] && collect_deps \"$f\"\ndone\n\n# ---------------------------------------------------------------------------\n# 7. Collect gst-plugin-scanner\n# ---------------------------------------------------------------------------\necho \"==> Collecting gst-plugin-scanner...\"\nGST_SCANNER=\"\"\n# Try common locations for gst-plugin-scanner\nfor candidate in \\\n  \"$BREW_PREFIX/libexec/gstreamer-1.0/gst-plugin-scanner\" \\\n  \"$BREW_PREFIX/opt/gstreamer/libexec/gstreamer-1.0/gst-plugin-scanner\" \\\n  \"$BREW_PREFIX/lib/gstreamer-1.0/gst-plugin-scanner\"; do\n  if [ -f \"$candidate\" ]; then\n    GST_SCANNER=\"$candidate\"\n    break\n  fi\ndone\n# Fallback: search broadly\nif [ -z \"$GST_SCANNER\" ]; then\n  GST_SCANNER=$(find \"$BREW_PREFIX\" -name \"gst-plugin-scanner\" -type f 2>/dev/null | head -1)\nfi\nif [ -n \"$GST_SCANNER\" ] && [ -f \"$GST_SCANNER\" ]; then\n  cat \"$GST_SCANNER\" > \"$GST_HELPERS_DIR/gst-plugin-scanner\"\n  chmod 755 \"$GST_HELPERS_DIR/gst-plugin-scanner\"\n  echo \"    copied gst-plugin-scanner from $GST_SCANNER\"\nelse\n  echo \"    WARNING: gst-plugin-scanner not found anywhere under $BREW_PREFIX\"\nfi\n\n# ---------------------------------------------------------------------------\n# 8. Rewrite install_names\n# ---------------------------------------------------------------------------\necho \"==> Rewriting install_names for bundled libs...\"\n\nrewrite_deps() {\n  local binary=\"$1\"\n  local target_prefix=\"$2\"\n\n  # Collect deps first to avoid subshell issues with piped while-read\n  local deps\n  deps=$(otool -L \"$binary\" 2>/dev/null | tail -n +2 | awk '{print $1}')\n\n  local dep bn\n  while IFS= read -r dep; do\n    if [[ \"$dep\" == /opt/homebrew/* ]] || [[ \"$dep\" == /usr/local/Cellar/* ]] || [[ \"$dep\" == /usr/local/opt/* ]]; then\n      bn=$(basename \"$dep\")\n      install_name_tool -change \"$dep\" \"${target_prefix}/${bn}\" \"$binary\" 2>/dev/null || true\n    fi\n  done <<< \"$deps\"\n}\n\n# Rewrite each lib in libs/\nfor f in \"$LIBS_DIR\"/*.dylib \"$LIBS_DIR\"/Python; do\n  [ -f \"$f\" ] || continue\n  bn=$(basename \"$f\")\n  # Set the library's own id\n  install_name_tool -id \"@loader_path/$bn\" \"$f\" 2>/dev/null || true\n  # Rewrite references to other libs\n  rewrite_deps \"$f\" \"@loader_path\"\ndone\n\n# Rewrite each plugin (use rewrite_deps function to avoid 'local' in subshell)\nfor f in \"$GST_PLUGINS_DIR\"/*.dylib; do\n  [ -f \"$f\" ] || continue\n  bn=$(basename \"$f\")\n  install_name_tool -id \"@loader_path/$bn\" \"$f\" 2>/dev/null || true\n  # Plugins reference core libs in ../libs/ relative to their location\n  rewrite_deps \"$f\" \"@loader_path/../libs\"\ndone\n\n# Rewrite gst-plugin-scanner\nGST_SCANNER_BIN=\"$GST_HELPERS_DIR/gst-plugin-scanner\"\nif [ -f \"$GST_SCANNER_BIN\" ]; then\n  rewrite_deps \"$GST_SCANNER_BIN\" \"@loader_path/../../libs\"\nfi\n\n# Rewrite the server binary\necho \"==> Rewriting install_names for server binary...\"\nif [ -f \"$SERVER_BIN\" ]; then\n  # Add rpath pointing to Resources/libs in the app bundle\n  # Remove existing rpath first if it exists, then add\n  install_name_tool -add_rpath \"@executable_path/../Resources/libs\" \"$SERVER_BIN\" 2>/dev/null || true\n\n  # Change absolute Homebrew refs to @rpath\n  rewrite_deps \"$SERVER_BIN\" \"@rpath\"\nfi\n\n# ---------------------------------------------------------------------------\n# 10. Re-sign all modified Mach-O binaries (required for arm64)\n# ---------------------------------------------------------------------------\n# Use APPLE_SIGNING_IDENTITY when set (release builds in CI), ad-hoc otherwise.\nSIGN_ID=\"${APPLE_SIGNING_IDENTITY:--}\"\nSIGN_ARGS=(--force --sign \"$SIGN_ID\")\nif [ \"$SIGN_ID\" != \"-\" ]; then\n  SIGN_ARGS+=(--options runtime --timestamp)\n  if [ -n \"${APPLE_ENTITLEMENTS_PATH:-}\" ] && [ -f \"${APPLE_ENTITLEMENTS_PATH}\" ]; then\n    SIGN_ARGS+=(--entitlements \"$APPLE_ENTITLEMENTS_PATH\")\n  fi\n  echo \"==> Re-signing all binaries with Developer ID: $SIGN_ID\"\nelse\n  echo \"==> Re-signing all binaries (ad-hoc)\"\nfi\n\nfind \"$STAGING\" -type f \\( -name \"*.dylib\" -o -name \"Python\" -o -name \"gst-plugin-scanner\" \\) | while read -r f; do\n  codesign \"${SIGN_ARGS[@]}\" \"$f\" 2>/dev/null || true\ndone\n\nif [ -f \"$SERVER_BIN\" ]; then\n  codesign \"${SIGN_ARGS[@]}\" \"$SERVER_BIN\" 2>/dev/null || true\nfi\n\n# ---------------------------------------------------------------------------\n# 11. Fix file permissions (required for Tauri build to copy files)\n# ---------------------------------------------------------------------------\necho \"==> Fixing file permissions...\"\nchmod -R u+w \"$STAGING\"\n\n# ---------------------------------------------------------------------------\n# Done\n# ---------------------------------------------------------------------------\necho \"\"\necho \"==> Bundle staging complete!\"\necho \"    Libs:    $(ls \"$LIBS_DIR\" | wc -l | tr -d ' ') files\"\necho \"    Plugins: $(ls \"$GST_PLUGINS_DIR\"/*.dylib 2>/dev/null | wc -l | tr -d ' ') files\"\necho \"    Staging: $STAGING\"\n"
  },
  {
    "path": "tests/TEST.md",
    "content": "Test with Jest\n\n`npm run test`\n"
  },
  {
    "path": "tests/api.test.js",
    "content": "const request = require(\"supertest\");\n\nconst API_BASE_URL = \"http://localhost:6174\";\n\ndescribe(\"Vessel API E2E Test\", () => {\n  let authToken;\n  let newDeviceId;\n  let newEntityId;\n  let newLayerId;\n  let newFeatureId;\n\n  beforeAll(async () => {\n    const response = await request(API_BASE_URL).post(\"/api/auth\").send({\n      id: \"admin\",\n      password: \"admin\",\n    });\n\n    expect(response.status).toBe(200);\n    expect(response.body).toHaveProperty(\"token\");\n    authToken = response.body.token;\n  });\n\n  describe(\"Devices API\", () => {\n    it(\"should create a new device successfully\", async () => {\n      const response = await request(API_BASE_URL)\n        .post(\"/api/devices\")\n        .set(\"Authorization\", `Bearer ${authToken}`)\n        .send({\n          device_id: \"jest_test_device_01\",\n          name: \"Jest Test Device\",\n          manufacturer: \"Jest\",\n          model: \"Supertest v1\",\n        });\n\n      expect(response.status).toBe(200);\n      expect(response.body).toHaveProperty(\"id\");\n      expect(response.body.name).toBe(\"Jest Test Device\");\n\n      newDeviceId = response.body.id;\n    });\n\n    it(\"should get a list of all devices\", async () => {\n      const response = await request(API_BASE_URL)\n        .get(\"/api/devices\")\n        .set(\"Authorization\", `Bearer ${authToken}`);\n\n      expect(response.status).toBe(200);\n      expect(Array.isArray(response.body)).toBe(true);\n      expect(response.body.length).toBeGreaterThan(0);\n    });\n\n    it(\"should update an existing device\", async () => {\n      const response = await request(API_BASE_URL)\n        .put(`/api/devices/${newDeviceId}`)\n        .set(\"Authorization\", `Bearer ${authToken}`)\n        .send({\n          device_id: \"jest_test_device_01_updated\",\n          name: \"Updated Jest Device\",\n          manufacturer: \"Jest\",\n          model: \"Supertest v2\",\n        });\n\n      expect(response.status).toBe(200);\n      expect(response.body.id).toBe(newDeviceId);\n      expect(response.body.name).toBe(\"Updated Jest Device\");\n    });\n\n    it(\"should delete the created device\", async () => {\n      const response = await request(API_BASE_URL)\n        .delete(`/api/devices/${newDeviceId}`)\n        .set(\"Authorization\", `Bearer ${authToken}`);\n\n      expect(response.status).toBe(200);\n      expect(response.body.message).toBe(\"Device deleted\");\n    });\n  });\n\n  describe(\"Map API\", () => {\n    it(\"should create a new map layer\", async () => {\n      const response = await request(API_BASE_URL)\n        .post(\"/api/map/layers\")\n        .set(\"Authorization\", `Bearer ${authToken}`)\n        .send({\n          name: \"Test Layer\",\n          description: \"A layer created by Jest\",\n          is_visible: true,\n        });\n\n      expect(response.status).toBe(200);\n      expect(response.body).toHaveProperty(\"id\");\n      expect(response.body.name).toBe(\"Test Layer\");\n      newLayerId = response.body.id;\n    });\n\n    it(\"should get a list of all map layers\", async () => {\n      const response = await request(API_BASE_URL)\n        .get(\"/api/map/layers\")\n        .set(\"Authorization\", `Bearer ${authToken}`);\n\n      expect(response.status).toBe(200);\n      expect(Array.isArray(response.body)).toBe(true);\n      expect(response.body.length).toBeGreaterThan(0);\n    });\n\n    it(\"should get a specific map layer by ID\", async () => {\n      const response = await request(API_BASE_URL)\n        .get(`/api/map/layers/${newLayerId}`)\n        .set(\"Authorization\", `Bearer ${authToken}`);\n\n      expect(response.status).toBe(200);\n      expect(response.body.id).toBe(newLayerId);\n      expect(response.body.features).toEqual([]);\n    });\n\n    it(\"should create a new point feature in the layer\", async () => {\n      const response = await request(API_BASE_URL)\n        .post(\"/api/map/features\")\n        .set(\"Authorization\", `Bearer ${authToken}`)\n        .send({\n          layer_id: newLayerId,\n          feature_type: \"POINT\",\n          name: \"Test Point 1\",\n          style_properties: JSON.stringify({ color: \"red\" }),\n          vertices: [{ latitude: 36.635, longitude: 127.456 }],\n        });\n\n      expect(response.status).toBe(200);\n      expect(response.body).toHaveProperty(\"id\");\n      expect(response.body.name).toBe(\"Test Point 1\");\n      expect(response.body.feature_type).toBe(\"POINT\");\n      newFeatureId = response.body.id;\n    });\n\n    it(\"should get the layer again with the new feature\", async () => {\n      const response = await request(API_BASE_URL)\n        .get(`/api/map/layers/${newLayerId}`)\n        .set(\"Authorization\", `Bearer ${authToken}`);\n\n      expect(response.status).toBe(200);\n      expect(response.body.features.length).toBe(1);\n    });\n\n    it(\"should get a specific feature by ID\", async () => {\n      const response = await request(API_BASE_URL)\n        .get(`/api/map/features/${newFeatureId}`)\n        .set(\"Authorization\", `Bearer ${authToken}`);\n\n      expect(response.status).toBe(200);\n      expect(response.body.vertices.length).toBe(1);\n      expect(response.body.vertices[0].latitude).toBe(36.635);\n    });\n\n    it(\"should update an existing feature\", async () => {\n      const response = await request(API_BASE_URL)\n        .put(`/api/map/features/${newFeatureId}`)\n        .set(\"Authorization\", `Bearer ${authToken}`)\n        .send({\n          name: \"Updated Test Point\",\n          vertices: [{ latitude: 37.5665, longitude: 126.978 }],\n        });\n\n      expect(response.status).toBe(200);\n      expect(response.body.name).toBe(\"Updated Test Point\");\n    });\n\n    it(\"should delete the created feature\", async () => {\n      const response = await request(API_BASE_URL)\n        .delete(`/api/map/features/${newFeatureId}`)\n        .set(\"Authorization\", `Bearer ${authToken}`);\n\n      expect(response.status).toBe(200);\n      expect(response.body.message).toBe(\"Feature deleted\");\n    });\n\n    it(\"should delete the created layer\", async () => {\n      const response = await request(API_BASE_URL)\n        .delete(`/api/map/layers/${newLayerId}`)\n        .set(\"Authorization\", `Bearer ${authToken}`);\n\n      expect(response.status).toBe(200);\n      expect(response.body.message).toBe(\"Layer deleted\");\n    });\n  });\n});\n"
  }
]