diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..bff29e6 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +rustflags = ["--cfg", "tokio_unstable"] diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d80fa04 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,27 @@ +### Pull Request Overview + +This pull request adds/changes/fixes... + +### Testing Strategy + +This pull request was tested by... + +### TODO or Help Wanted + +This pull request still needs... + +### Formatting + +- [ ] Updated CHANGELOG.md +- [ ] Ran `cargo fmt` +- [ ] Ran `cargo check` +- [ ] Ran `cargo build` +- [ ] Ran `npm run lint` + +### Issue + +This pull request closes #. + +### Author + +Signed-off-by: - diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..cdfdec2 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,8 @@ +'Workflow': +- .github/workflows/** + +'UI': +- src/** + +'Backend': +- src-tauri/** diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..3e96c67 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,11 @@ +name: labeler + +on: + pull_request: + +jobs: + labeler: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + - uses: actions/labeler@v4 diff --git a/.github/workflows/rust-checker.yml b/.github/workflows/rust-checker.yml new file mode 100644 index 0000000..6a5cc96 --- /dev/null +++ b/.github/workflows/rust-checker.yml @@ -0,0 +1,88 @@ +name: rust-checker +on: + pull_request: + push: + branches: + - "main" + +jobs: + changes: + runs-on: ubuntu-22.04 + # Required permissions + permissions: read-all + outputs: + source : ${{ steps.filter.outputs.source }} + workflow : ${{ steps.filter.outputs.workflow }} + steps: + # For pull requests it's not necessary to checkout the code + - uses: actions/checkout@v3 + - uses: integrated-food-solutions/paths-filter@master + id: filter + with: + filters: | + source: src-tauri/** + workflow: .github/workflows/rust-checker.yml + rust-checker: + runs-on: ubuntu-22.04 + needs: changes + if: | + needs.changes.outputs.source == 'true' || + needs.changes.outputs.workflow == 'true' + steps: + - uses: actions/checkout@v3 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + components: rustfmt, clippy + + - name: Toolchain info + run: | + cargo --version --verbose + rustc --version + cargo clippy --version + + - name: Format + run: | + cd src-tauri + cargo fmt --all --check + + - name: Update local toolchain + run: | + sudo apt update + sudo apt install libwebkit2gtk-4.0-dev \ + build-essential \ + curl \ + wget \ + file \ + libssl-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev + echo "Installing libusb-1.0-0-dev..." + sudo apt install libusb-1.0-0-dev + echo "Installing libsdl2-dev..." + sudo apt install libsdl2-dev + echo "Installing libsdl2-ttf-dev..." + sudo apt install libsdl2-ttf-dev + echo "Installing libsoup-3.0-dev..." + sudo apt install libsoup-3.0-dev + echo "Installing javascriptcoregtk-4.1..." + sudo apt install libjavascriptcoregtk-4.1-0 \ + libjavascriptcoregtk-4.1-dev\ + gir1.2-javascriptcoregtk-4.1 + echo "Installing webkit2gtk-4.1..." + sudo apt-get install webkit2gtk-4.1 + + - name: Create dist directory + run: | + mkdir dist + + - name: Lint + run: | + cd src-tauri + cargo clippy -- -D warnings + + - name: Build + run: | + cd src-tauri + cargo build diff --git a/.github/workflows/vue-checker.yml b/.github/workflows/vue-checker.yml new file mode 100644 index 0000000..b880c31 --- /dev/null +++ b/.github/workflows/vue-checker.yml @@ -0,0 +1,47 @@ +name: vue-checker +on: + pull_request: + push: + branches: + - "main" + +jobs: + changes: + runs-on: ubuntu-22.04 + # Required permissions + permissions: read-all + outputs: + source : ${{ steps.filter.outputs.source }} + workflow : ${{ steps.filter.outputs.workflow }} + steps: + # For pull requests it's not necessary to checkout the code + - uses: actions/checkout@v3 + - uses: integrated-food-solutions/paths-filter@master + id: filter + with: + filters: | + source: src/** + workflow: .github/workflows/vue-checker.yml + vue-checker: + runs-on: ubuntu-22.04 + needs: changes + if: | + needs.changes.outputs.source == 'true' || + needs.changes.outputs.workflow == 'true' + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: '20' + + - name: Installing packages + run: | + npm install + + - name: Format + run: | + npm run lint-check + + - name: Build + run: | + npm run build diff --git a/.gitignore b/.gitignore index 5cf495b..a6d184d 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,12 @@ dist-ssr # Generated by Tauri # will have schema files for capabilities auto-completion src-tauri/gen/schemas + +package-lock.json + + +# Added by cargo + +/target +/Cargo.lock +!/deno.lock diff --git a/Architecture.md b/Architecture.md new file mode 100644 index 0000000..a33850e --- /dev/null +++ b/Architecture.md @@ -0,0 +1,113 @@ + # Architecture + ``` + ┌── common.rs/get_pid_hosting_at ────────┐ + ┌── domain/application.rs/new ─> │ │ + │ └── common.rs/get_process_start_time ────┼───> state_manager/mod.rs/emit_update_applications +The user adds an app using the UI ─> commands/applications.rs/add_application ─> state_manager/mod.rs/add_application ─> ├── state_manager/connection_manager.rs/connect_app ──────────────────────┤ + └── state_manager/state.rs/store_app ─> the app is written into storage ──┤ + +``` +# Repo Hierarchy + +``` +├── .cargo +├── Cargo.toml +├── examples +│ ├── 0s1s.rs -> example demonstrating timing utilities +│ ├── resources.rs -> example showing resource collection usage +│ └── tasks.rs -> example showing task management usage +├── .git +├── .gitignore +├── index.html +├── LICENSE +├── package.json +├── public +│ ├── tauri.svg +│ └── vite.svg +├── README.md +├── src +│ ├── App.vue -> Root Vue component +│ ├── assets +│ │ ├── logo.png +│ │ ├── logo-white.png +│ │ └── vue.svg +│ ├── layout +│ │ ├── AppLayout.vue -> Main application layout (header + sidebar) +│ │ ├── header -> Header components (UI controls, title) +│ │ └── sidebar -> Sidebar components (navigation) +│ ├── main.rs -> (Rust) optional native entry used by Tauri (if present) +│ ├── main.ts -> Frontend entry: mounts Vue app, installs plugins/router +│ ├── plugins +│ │ └── vuetify.ts -> Vuetify setup and theme configuration +│ ├── router +│ │ ├── index.ts -> Router instance creation +│ │ └── MainRoutes.ts -> Route definitions +│ ├── stores +│ │ ├── application.ts -> App-level state (meta, status) +│ │ ├── data.ts -> Domain data store (resources, metrics) +│ │ └── layout.ts -> UI layout state (sidebar open/closed) +│ ├── styles +│ │ └── timestamps.css -> Timestamp styling utilities +│ ├── types +│ │ ├── applications.d.ts +│ │ ├── async_ops.d.ts +│ │ ├── polls.d.ts +│ │ ├── resources.d.ts +│ │ └── tasks.d.ts +│ ├── views +│ │ ├── CPU.vue -> UPlot CPU usage view +│ │ ├── Polls.vue -> Polling configuration/view +│ │ ├── Resources.vue -> Resource overview view +│ │ ├── SystemInformation.vue -> System info view +│ │ └── Tasks.vue -> Task/process view +│ └── vite-env.d.ts +├── src-tauri +│ ├── build.rs -> Build script for Tauri/native build tasks +│ ├── capabilities +│ │ └── default.json -> Platform capability declarations +│ ├── Cargo.lock +│ ├── Cargo.toml -> Tauri backend crate manifest +│ ├── gen +│ │ └── schemas -> Generated schemas (IPC/config) +│ ├── .gitignore +│ ├── icons -> App icons for various platforms/sizes +│ └── src +| ├─ main.rs // Tauri bootstrap (registers commands, initializes app) +| ├─ lib.rs // backend library entry (shared logic) +| ├─ features/ // user-facing commands, handlers(in commands only the callbacks, not implementation) +| │ ├─ mod.rs +| │ ├─ applications/ +| │ │ ├─ mod.rs +| │ │ └─ commands.rs +| │ └─ tasks/ +| │ ├─ mod.rs +| │ └─ commands.rs +| ├─ backend/ // business logic, state, adapters, background ops +| │ ├─ mod.rs +| │ ├─ domain/ // all structs +| │ ├─ infra/ // Infrastructure/OS adapters and platform-specific code +| │ │ ├─ storage.rs // Defines the `Storage` trait to unify reads/writes of all domain data. +| │ │ ├─ guards.rs // A guard that auto-writes a database file on drop +| │ ├─ state/ // state manager & DB +| │ │ ├─ mod.rs +| │ │ ├─ connection_manager.rs // Connection manager for remote applications +| │ │ ├─ database.rs // Persistent, in-memory database backed by disk storage +| │ │ └─ state.rs // Manages access to persistent storage and provides high-level methods +| │ └─ mappers/ // Converters between console_api types and our domain type +| │ ├─ mod.rs +| │ ├─ async_ops.rs +| │ ├─ poll.rs +| │ ├─ resource.rs +| │ └─ tasks.rs +| └─ utils/ // helpers, errors, shared types +| ├─ mod.rs +| ├─ common.rs +| ├─ error.rs +| └─ warnings.rs +│ └── tauri.conf.json -> Tauri configuration (windows, bundle, security) +├── tsconfig.json +├── tsconfig.node.json +├── vite.config.ts +├── .vscode +└── .zed +``` \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3fb54ca --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "async-debugger" +version = "0.1.0" +edition = "2024" + +[dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "tracing", "net" ] } +console-subscriber = "0.4.1" \ No newline at end of file diff --git a/README.md b/README.md index 6a783bb..94723a3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,99 @@ -# AsyncDebugger +## AsyncDebugger -This repository hosts the `AsyncDebugger`, a tool desktop tool that provides -meaningful insights into asynchronous Rust software. +AsyncDebugger is a desktop tool that provides **meaningful insights** into asynchronous Rust applications by leveraging +the `console-api` developed for `tokio-console`. It offers a clean, intuitive user interface to inspect tasks, +resources, and execution timelines in real time. -It is based upon the `console-api` developed for `tokio-console`. +![Applications Overview](pictures/app.png) +![Tasks Overview](pictures/tasks.png) +![Resources Overview](pictures/resources.png) +![Polls Overview](pictures/polls.png) +
+ +## Features + +- **Real-time task visualization**: See active, pending and completed asynchronous tasks +- **Resource usage inspection**: Monitor resources and CPU consumption and scheduling +- **Polling overview**: Tap into polls and get insights +- **Custom filtering**: Filter tasks by name, state or metadata tags +- **Resource overview**: Get an interactive insight into what, how and when were resources spawned + +[Architecture](Architecture.md) +
+ +## Requirements +- **Supported operating systems**: + - Linux + - macOS + - Windows + + +- **Software prerequisites**: + - npm (comes with Node.js) or yarn + - git + - Rust Toolchain +
+ +## Installation + +1. Clone the repository + ```bash + git clone https://github.com/Wyliodrin/async-debugger.git + cd async-debugger + ``` +2. Install front-end dependencies + ```bash + npm install + ``` +3. Build and launch in development mode + ```bash + npm run tauri dev + ``` + +
+ +## Usage + +1. Launch AsyncDebugger via `npm run tauri dev`. +2. Attach to one of our examples or your own Rust async application by using the Application Overview page in the UI +![how-to.gif](pictures/how-to.gif) + - For your application, enable the console API by adding `console-subscriber` to your Cargo.toml and instrumenting + your code: + + ```toml + [dependencies] + console-api = "0.4.1" + ``` + + ```rust + #[tokio::main] + async fn main() { + console_subscriber::init(); + // Your application here... + } + ``` + +3. Observe tasks, timelines, and resource metrics +4. Apply filters and inspect stack traces to pinpoint performance bottlenecks or logical errors. + +
+ +## Examples + +Example applications live in the `examples/` directory. To run an example: + +```bash + cargo run --example +``` + +- `0s1s` spawns three asynchronous tasks: + 1. A producer that sends 1024-byte zero-filled chunks every 500 ms. + 2. A producer that sends 1024-byte one-filled chunks every 700 ms. + 3. A consumer that concurrently reads from both channels and logs incoming data. + +It can be tracked at `localhost:6669` + +- `tasks` waits for user input to start, then spawns three tasks that simulate work, wait on a common barrier, and then + proceed together. + +It can be tracked at `localhost:7777` diff --git a/deno.lock b/deno.lock deleted file mode 100644 index 4920aa5..0000000 --- a/deno.lock +++ /dev/null @@ -1,825 +0,0 @@ -{ - "version": "4", - "specifiers": { - "npm:@mdi/font@7.4.47": "7.4.47", - "npm:@tauri-apps/api@2.4.1": "2.4.1", - "npm:@tauri-apps/cli@2.4.1": "2.4.1", - "npm:@tauri-apps/plugin-dialog@~2.2.1": "2.2.1", - "npm:@tauri-apps/plugin-shell@2.2.1": "2.2.1", - "npm:@types/node@22.13.17": "22.13.17", - "npm:@vitejs/plugin-vue@5.2.3": "5.2.3_vite@6.2.4__@types+node@22.13.17_vue@3.5.13__typescript@5.8.2_@types+node@22.13.17_typescript@5.8.2", - "npm:@vue/tsconfig@0.7.0": "0.7.0_typescript@5.8.2_vue@3.5.13__typescript@5.8.2", - "npm:pinia@3.0.1": "3.0.1_typescript@5.8.2_vue@3.5.13__typescript@5.8.2", - "npm:sass-embedded@^1.86.2": "1.86.2", - "npm:typescript@5.8.2": "5.8.2", - "npm:vite-plugin-vuetify@2.1.0": "2.1.0_vite@6.2.4__@types+node@22.13.17_vue@3.5.13__typescript@5.8.2_vuetify@3.8.0__typescript@5.8.2__vite-plugin-vuetify@2.1.0__vue@3.5.13___typescript@5.8.2__vite@6.2.4___@types+node@22.13.17__@types+node@22.13.17_@types+node@22.13.17_typescript@5.8.2", - "npm:vite@6.2.4": "6.2.4_@types+node@22.13.17", - "npm:vue-router@4.5.0": "4.5.0_vue@3.5.13__typescript@5.8.2_typescript@5.8.2", - "npm:vue-tabler-icons@2.21.0": "2.21.0", - "npm:vue-tsc@2.2.8": "2.2.8_typescript@5.8.2", - "npm:vue@3.5.13": "3.5.13_typescript@5.8.2", - "npm:vuetify@3.8.0": "3.8.0_typescript@5.8.2_vite-plugin-vuetify@2.1.0__vite@6.2.4___@types+node@22.13.17__vue@3.5.13___typescript@5.8.2__vuetify@3.8.0__@types+node@22.13.17__typescript@5.8.2_vue@3.5.13__typescript@5.8.2_vite@6.2.4__@types+node@22.13.17_@types+node@22.13.17" - }, - "npm": { - "@babel/helper-string-parser@7.25.9": { - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==" - }, - "@babel/helper-validator-identifier@7.25.9": { - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==" - }, - "@babel/parser@7.27.0": { - "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", - "dependencies": [ - "@babel/types" - ] - }, - "@babel/types@7.27.0": { - "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", - "dependencies": [ - "@babel/helper-string-parser", - "@babel/helper-validator-identifier" - ] - }, - "@bufbuild/protobuf@2.2.5": { - "integrity": "sha512-/g5EzJifw5GF8aren8wZ/G5oMuPoGeS6MQD3ca8ddcvdXR5UELUfdTZITCGNhNXynY/AYl3Z4plmxdj/tRl/hQ==" - }, - "@esbuild/aix-ppc64@0.25.2": { - "integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==" - }, - "@esbuild/android-arm64@0.25.2": { - "integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==" - }, - "@esbuild/android-arm@0.25.2": { - "integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==" - }, - "@esbuild/android-x64@0.25.2": { - "integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==" - }, - "@esbuild/darwin-arm64@0.25.2": { - "integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==" - }, - "@esbuild/darwin-x64@0.25.2": { - "integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==" - }, - "@esbuild/freebsd-arm64@0.25.2": { - "integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==" - }, - "@esbuild/freebsd-x64@0.25.2": { - "integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==" - }, - "@esbuild/linux-arm64@0.25.2": { - "integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==" - }, - "@esbuild/linux-arm@0.25.2": { - "integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==" - }, - "@esbuild/linux-ia32@0.25.2": { - "integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==" - }, - "@esbuild/linux-loong64@0.25.2": { - "integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==" - }, - "@esbuild/linux-mips64el@0.25.2": { - "integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==" - }, - "@esbuild/linux-ppc64@0.25.2": { - "integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==" - }, - "@esbuild/linux-riscv64@0.25.2": { - "integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==" - }, - "@esbuild/linux-s390x@0.25.2": { - "integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==" - }, - "@esbuild/linux-x64@0.25.2": { - "integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==" - }, - "@esbuild/netbsd-arm64@0.25.2": { - "integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==" - }, - "@esbuild/netbsd-x64@0.25.2": { - "integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==" - }, - "@esbuild/openbsd-arm64@0.25.2": { - "integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==" - }, - "@esbuild/openbsd-x64@0.25.2": { - "integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==" - }, - "@esbuild/sunos-x64@0.25.2": { - "integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==" - }, - "@esbuild/win32-arm64@0.25.2": { - "integrity": "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==" - }, - "@esbuild/win32-ia32@0.25.2": { - "integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==" - }, - "@esbuild/win32-x64@0.25.2": { - "integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==" - }, - "@jridgewell/sourcemap-codec@1.5.0": { - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" - }, - "@mdi/font@7.4.47": { - "integrity": "sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==" - }, - "@rollup/rollup-android-arm-eabi@4.39.0": { - "integrity": "sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA==" - }, - "@rollup/rollup-android-arm64@4.39.0": { - "integrity": "sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ==" - }, - "@rollup/rollup-darwin-arm64@4.39.0": { - "integrity": "sha512-lXQnhpFDOKDXiGxsU9/l8UEGGM65comrQuZ+lDcGUx+9YQ9dKpF3rSEGepyeR5AHZ0b5RgiligsBhWZfSSQh8Q==" - }, - "@rollup/rollup-darwin-x64@4.39.0": { - "integrity": "sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ==" - }, - "@rollup/rollup-freebsd-arm64@4.39.0": { - "integrity": "sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ==" - }, - "@rollup/rollup-freebsd-x64@4.39.0": { - "integrity": "sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q==" - }, - "@rollup/rollup-linux-arm-gnueabihf@4.39.0": { - "integrity": "sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==" - }, - "@rollup/rollup-linux-arm-musleabihf@4.39.0": { - "integrity": "sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==" - }, - "@rollup/rollup-linux-arm64-gnu@4.39.0": { - "integrity": "sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==" - }, - "@rollup/rollup-linux-arm64-musl@4.39.0": { - "integrity": "sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==" - }, - "@rollup/rollup-linux-loongarch64-gnu@4.39.0": { - "integrity": "sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==" - }, - "@rollup/rollup-linux-powerpc64le-gnu@4.39.0": { - "integrity": "sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==" - }, - "@rollup/rollup-linux-riscv64-gnu@4.39.0": { - "integrity": "sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==" - }, - "@rollup/rollup-linux-riscv64-musl@4.39.0": { - "integrity": "sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==" - }, - "@rollup/rollup-linux-s390x-gnu@4.39.0": { - "integrity": "sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==" - }, - "@rollup/rollup-linux-x64-gnu@4.39.0": { - "integrity": "sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==" - }, - "@rollup/rollup-linux-x64-musl@4.39.0": { - "integrity": "sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==" - }, - "@rollup/rollup-win32-arm64-msvc@4.39.0": { - "integrity": "sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==" - }, - "@rollup/rollup-win32-ia32-msvc@4.39.0": { - "integrity": "sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ==" - }, - "@rollup/rollup-win32-x64-msvc@4.39.0": { - "integrity": "sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug==" - }, - "@tauri-apps/api@2.4.1": { - "integrity": "sha512-5sYwZCSJb6PBGbBL4kt7CnE5HHbBqwH+ovmOW6ZVju3nX4E3JX6tt2kRklFEH7xMOIwR0btRkZktuLhKvyEQYg==" - }, - "@tauri-apps/cli-darwin-arm64@2.4.1": { - "integrity": "sha512-QME7s8XQwy3LWClTVlIlwXVSLKkeJ/z88pr917Mtn9spYOjnBfsgHAgGdmpWD3NfJxjg7CtLbhH49DxoFL+hLg==" - }, - "@tauri-apps/cli-darwin-x64@2.4.1": { - "integrity": "sha512-/r89IcW6Ya1sEsFUEH7wLNruDTj7WmDWKGpPy7gATFtQr5JEY4heernqE82isjTUimnHZD8SCr0jA3NceI4ybw==" - }, - "@tauri-apps/cli-linux-arm-gnueabihf@2.4.1": { - "integrity": "sha512-9tDijkRB+CchAGjXxYdY9l/XzFpLp1yihUtGXJz9eh+3qIoRI043n3e+6xmU8ZURr7XPnu+R4sCmXs6HD+NCEQ==" - }, - "@tauri-apps/cli-linux-arm64-gnu@2.4.1": { - "integrity": "sha512-pnFGDEXBAzS4iDYAVxTRhAzNu3K2XPGflYyBc0czfHDBXopqRgMyj5Q9Wj7HAwv6cM8BqzXINxnb2ZJFGmbSgA==" - }, - "@tauri-apps/cli-linux-arm64-musl@2.4.1": { - "integrity": "sha512-Hp0zXgeZNKmT+eoJSCxSBUm2QndNuRxR55tmIeNm3vbyUMJN/49uW7nurZ5fBPsacN4Pzwlx1dIMK+Gnr9A69w==" - }, - "@tauri-apps/cli-linux-riscv64-gnu@2.4.1": { - "integrity": "sha512-3T3bo2E4fdYRvzcXheWUeQOVB+LunEEi92iPRgOyuSVexVE4cmHYl+MPJF+EUV28Et0hIVTsHibmDO0/04lAFg==" - }, - "@tauri-apps/cli-linux-x64-gnu@2.4.1": { - "integrity": "sha512-kLN0FdNONO+2i+OpU9+mm6oTGufRC00e197TtwjpC0N6K2K8130w7Q3FeODIM2CMyg0ov3tH+QWqKW7GNhHFzg==" - }, - "@tauri-apps/cli-linux-x64-musl@2.4.1": { - "integrity": "sha512-a8exvA5Ub9eg66a6hsMQKJIkf63QAf9OdiuFKOsEnKZkNN2x0NLgfvEcqdw88VY0UMs9dBoZ1AGbWMeYnLrLwQ==" - }, - "@tauri-apps/cli-win32-arm64-msvc@2.4.1": { - "integrity": "sha512-4JFrslsMCJQG1c573T9uqQSAbF3j/tMKkMWzsIssv8jvPiP++OG61A2/F+y9te9/Q/O95cKhDK63kaiO5xQaeg==" - }, - "@tauri-apps/cli-win32-ia32-msvc@2.4.1": { - "integrity": "sha512-9eXfFORehYSCRwxg2KodfmX/mhr50CI7wyBYGbPLePCjr5z0jK/9IyW6r0tC+ZVjwpX48dkk7hKiUgI25jHjzA==" - }, - "@tauri-apps/cli-win32-x64-msvc@2.4.1": { - "integrity": "sha512-60a4Ov7Jrwqz2hzDltlS7301dhSAmM9dxo+IRBD3xz7yobKrgaHXYpWvnRomYItHcDd51VaKc9292H8/eE/gsw==" - }, - "@tauri-apps/cli@2.4.1": { - "integrity": "sha512-9Ta81jx9+57FhtU/mPIckDcOBtPTUdKM75t4+aA0X84b8Sclb0jy1xA8NplmcRzp2fsfIHNngU2NiRxsW5+yOQ==", - "dependencies": [ - "@tauri-apps/cli-darwin-arm64", - "@tauri-apps/cli-darwin-x64", - "@tauri-apps/cli-linux-arm-gnueabihf", - "@tauri-apps/cli-linux-arm64-gnu", - "@tauri-apps/cli-linux-arm64-musl", - "@tauri-apps/cli-linux-riscv64-gnu", - "@tauri-apps/cli-linux-x64-gnu", - "@tauri-apps/cli-linux-x64-musl", - "@tauri-apps/cli-win32-arm64-msvc", - "@tauri-apps/cli-win32-ia32-msvc", - "@tauri-apps/cli-win32-x64-msvc" - ] - }, - "@tauri-apps/plugin-dialog@2.2.1": { - "integrity": "sha512-wZmCouo4PgTosh/UoejPw9DPs6RllS5Pp3fuOV2JobCu36mR5AXU2MzU9NZiVaFi/5Zfc8RN0IhcZHnksJ1o8A==", - "dependencies": [ - "@tauri-apps/api" - ] - }, - "@tauri-apps/plugin-shell@2.2.1": { - "integrity": "sha512-G1GFYyWe/KlCsymuLiNImUgC8zGY0tI0Y3p8JgBCWduR5IEXlIJS+JuG1qtveitwYXlfJrsExt3enhv5l2/yhA==", - "dependencies": [ - "@tauri-apps/api" - ] - }, - "@types/estree@1.0.7": { - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==" - }, - "@types/node@22.13.17": { - "integrity": "sha512-nAJuQXoyPj04uLgu+obZcSmsfOenUg6DxPKogeUy6yNCFwWaj5sBF8/G/pNo8EtBJjAfSVgfIlugR/BCOleO+g==", - "dependencies": [ - "undici-types" - ] - }, - "@vitejs/plugin-vue@5.2.3_vite@6.2.4__@types+node@22.13.17_vue@3.5.13__typescript@5.8.2_@types+node@22.13.17_typescript@5.8.2": { - "integrity": "sha512-IYSLEQj4LgZZuoVpdSUCw3dIynTWQgPlaRP6iAvMle4My0HdYwr5g5wQAfwOeHQBmYwEkqF70nRpSilr6PoUDg==", - "dependencies": [ - "vite", - "vue" - ] - }, - "@volar/language-core@2.4.12": { - "integrity": "sha512-RLrFdXEaQBWfSnYGVxvR2WrO6Bub0unkdHYIdC31HzIEqATIuuhRRzYu76iGPZ6OtA4Au1SnW0ZwIqPP217YhA==", - "dependencies": [ - "@volar/source-map" - ] - }, - "@volar/source-map@2.4.12": { - "integrity": "sha512-bUFIKvn2U0AWojOaqf63ER0N/iHIBYZPpNGogfLPQ68F5Eet6FnLlyho7BS0y2HJ1jFhSif7AcuTx1TqsCzRzw==" - }, - "@volar/typescript@2.4.12": { - "integrity": "sha512-HJB73OTJDgPc80K30wxi3if4fSsZZAOScbj2fcicMuOPoOkcf9NNAINb33o+DzhBdF9xTKC1gnPmIRDous5S0g==", - "dependencies": [ - "@volar/language-core", - "path-browserify", - "vscode-uri" - ] - }, - "@vue/compiler-core@3.5.13": { - "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==", - "dependencies": [ - "@babel/parser", - "@vue/shared", - "entities", - "estree-walker", - "source-map-js" - ] - }, - "@vue/compiler-dom@3.5.13": { - "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==", - "dependencies": [ - "@vue/compiler-core", - "@vue/shared" - ] - }, - "@vue/compiler-sfc@3.5.13": { - "integrity": "sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==", - "dependencies": [ - "@babel/parser", - "@vue/compiler-core", - "@vue/compiler-dom", - "@vue/compiler-ssr", - "@vue/shared", - "estree-walker", - "magic-string", - "postcss", - "source-map-js" - ] - }, - "@vue/compiler-ssr@3.5.13": { - "integrity": "sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==", - "dependencies": [ - "@vue/compiler-dom", - "@vue/shared" - ] - }, - "@vue/compiler-vue2@2.7.16": { - "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", - "dependencies": [ - "de-indent", - "he" - ] - }, - "@vue/devtools-api@6.6.4": { - "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==" - }, - "@vue/devtools-api@7.7.2": { - "integrity": "sha512-1syn558KhyN+chO5SjlZIwJ8bV/bQ1nOVTG66t2RbG66ZGekyiYNmRO7X9BJCXQqPsFHlnksqvPhce2qpzxFnA==", - "dependencies": [ - "@vue/devtools-kit" - ] - }, - "@vue/devtools-kit@7.7.2": { - "integrity": "sha512-CY0I1JH3Z8PECbn6k3TqM1Bk9ASWxeMtTCvZr7vb+CHi+X/QwQm5F1/fPagraamKMAHVfuuCbdcnNg1A4CYVWQ==", - "dependencies": [ - "@vue/devtools-shared", - "birpc", - "hookable", - "mitt", - "perfect-debounce", - "speakingurl", - "superjson" - ] - }, - "@vue/devtools-shared@7.7.2": { - "integrity": "sha512-uBFxnp8gwW2vD6FrJB8JZLUzVb6PNRG0B0jBnHsOH8uKyva2qINY8PTF5Te4QlTbMDqU5K6qtJDr6cNsKWhbOA==", - "dependencies": [ - "rfdc" - ] - }, - "@vue/language-core@2.2.8_typescript@5.8.2": { - "integrity": "sha512-rrzB0wPGBvcwaSNRriVWdNAbHQWSf0NlGqgKHK5mEkXpefjUlVRP62u03KvwZpvKVjRnBIQ/Lwre+Mx9N6juUQ==", - "dependencies": [ - "@volar/language-core", - "@vue/compiler-dom", - "@vue/compiler-vue2", - "@vue/shared", - "alien-signals", - "minimatch", - "muggle-string", - "path-browserify", - "typescript" - ] - }, - "@vue/reactivity@3.5.13": { - "integrity": "sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==", - "dependencies": [ - "@vue/shared" - ] - }, - "@vue/runtime-core@3.5.13": { - "integrity": "sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==", - "dependencies": [ - "@vue/reactivity", - "@vue/shared" - ] - }, - "@vue/runtime-dom@3.5.13": { - "integrity": "sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==", - "dependencies": [ - "@vue/reactivity", - "@vue/runtime-core", - "@vue/shared", - "csstype" - ] - }, - "@vue/server-renderer@3.5.13_vue@3.5.13__typescript@5.8.2": { - "integrity": "sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==", - "dependencies": [ - "@vue/compiler-ssr", - "@vue/shared", - "vue" - ] - }, - "@vue/shared@3.5.13": { - "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==" - }, - "@vue/tsconfig@0.7.0_typescript@5.8.2_vue@3.5.13__typescript@5.8.2": { - "integrity": "sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg==", - "dependencies": [ - "typescript", - "vue" - ] - }, - "@vuetify/loader-shared@2.1.0_vue@3.5.13__typescript@5.8.2_vuetify@3.8.0__typescript@5.8.2__vite-plugin-vuetify@2.1.0___vite@6.2.4____@types+node@22.13.17___vue@3.5.13____typescript@5.8.2___vuetify@3.8.0___@types+node@22.13.17___typescript@5.8.2__vue@3.5.13___typescript@5.8.2_typescript@5.8.2_vite-plugin-vuetify@2.1.0__vite@6.2.4___@types+node@22.13.17__vue@3.5.13___typescript@5.8.2__vuetify@3.8.0___typescript@5.8.2___vite-plugin-vuetify@2.1.0___vue@3.5.13____typescript@5.8.2___vite@6.2.4____@types+node@22.13.17___@types+node@22.13.17__@types+node@22.13.17__typescript@5.8.2": { - "integrity": "sha512-dNE6Ceym9ijFsmJKB7YGW0cxs7xbYV8+1LjU6jd4P14xOt/ji4Igtgzt0rJFbxu+ZhAzqz853lhB0z8V9Dy9cQ==", - "dependencies": [ - "upath", - "vue", - "vuetify@3.8.0_typescript@5.8.2_vite-plugin-vuetify@2.1.0__vite@6.2.4___@types+node@22.13.17__vue@3.5.13___typescript@5.8.2__vuetify@3.8.0__@types+node@22.13.17__typescript@5.8.2_vue@3.5.13__typescript@5.8.2" - ] - }, - "alien-signals@1.0.13": { - "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==" - }, - "balanced-match@1.0.2": { - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, - "birpc@0.2.19": { - "integrity": "sha512-5WeXXAvTmitV1RqJFppT5QtUiz2p1mRSYU000Jkft5ZUCLJIk4uQriYNO50HknxKwM6jd8utNc66K1qGIwwWBQ==" - }, - "brace-expansion@2.0.1": { - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dependencies": [ - "balanced-match" - ] - }, - "buffer-builder@0.2.0": { - "integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==" - }, - "colorjs.io@0.5.2": { - "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==" - }, - "copy-anything@3.0.5": { - "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", - "dependencies": [ - "is-what" - ] - }, - "csstype@3.1.3": { - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" - }, - "de-indent@1.0.2": { - "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==" - }, - "debug@4.4.0": { - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dependencies": [ - "ms" - ] - }, - "entities@4.5.0": { - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" - }, - "esbuild@0.25.2": { - "integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==", - "dependencies": [ - "@esbuild/aix-ppc64", - "@esbuild/android-arm", - "@esbuild/android-arm64", - "@esbuild/android-x64", - "@esbuild/darwin-arm64", - "@esbuild/darwin-x64", - "@esbuild/freebsd-arm64", - "@esbuild/freebsd-x64", - "@esbuild/linux-arm", - "@esbuild/linux-arm64", - "@esbuild/linux-ia32", - "@esbuild/linux-loong64", - "@esbuild/linux-mips64el", - "@esbuild/linux-ppc64", - "@esbuild/linux-riscv64", - "@esbuild/linux-s390x", - "@esbuild/linux-x64", - "@esbuild/netbsd-arm64", - "@esbuild/netbsd-x64", - "@esbuild/openbsd-arm64", - "@esbuild/openbsd-x64", - "@esbuild/sunos-x64", - "@esbuild/win32-arm64", - "@esbuild/win32-ia32", - "@esbuild/win32-x64" - ] - }, - "estree-walker@2.0.2": { - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" - }, - "fsevents@2.3.3": { - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==" - }, - "has-flag@4.0.0": { - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "he@1.2.0": { - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==" - }, - "hookable@5.5.3": { - "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==" - }, - "immutable@5.1.1": { - "integrity": "sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==" - }, - "is-what@4.1.16": { - "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==" - }, - "magic-string@0.30.17": { - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", - "dependencies": [ - "@jridgewell/sourcemap-codec" - ] - }, - "minimatch@9.0.5": { - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dependencies": [ - "brace-expansion" - ] - }, - "mitt@3.0.1": { - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" - }, - "ms@2.1.3": { - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "muggle-string@0.4.1": { - "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==" - }, - "nanoid@3.3.11": { - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==" - }, - "path-browserify@1.0.1": { - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==" - }, - "perfect-debounce@1.0.0": { - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==" - }, - "picocolors@1.1.1": { - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" - }, - "pinia@3.0.1_typescript@5.8.2_vue@3.5.13__typescript@5.8.2": { - "integrity": "sha512-WXglsDzztOTH6IfcJ99ltYZin2mY8XZCXujkYWVIJlBjqsP6ST7zw+Aarh63E1cDVYeyUcPCxPHzJpEOmzB6Wg==", - "dependencies": [ - "@vue/devtools-api@7.7.2", - "typescript", - "vue" - ] - }, - "postcss@8.5.3": { - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", - "dependencies": [ - "nanoid", - "picocolors", - "source-map-js" - ] - }, - "rfdc@1.4.1": { - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" - }, - "rollup@4.39.0": { - "integrity": "sha512-thI8kNc02yNvnmJp8dr3fNWJ9tCONDhp6TV35X6HkKGGs9E6q7YWCHbe5vKiTa7TAiNcFEmXKj3X/pG2b3ci0g==", - "dependencies": [ - "@rollup/rollup-android-arm-eabi", - "@rollup/rollup-android-arm64", - "@rollup/rollup-darwin-arm64", - "@rollup/rollup-darwin-x64", - "@rollup/rollup-freebsd-arm64", - "@rollup/rollup-freebsd-x64", - "@rollup/rollup-linux-arm-gnueabihf", - "@rollup/rollup-linux-arm-musleabihf", - "@rollup/rollup-linux-arm64-gnu", - "@rollup/rollup-linux-arm64-musl", - "@rollup/rollup-linux-loongarch64-gnu", - "@rollup/rollup-linux-powerpc64le-gnu", - "@rollup/rollup-linux-riscv64-gnu", - "@rollup/rollup-linux-riscv64-musl", - "@rollup/rollup-linux-s390x-gnu", - "@rollup/rollup-linux-x64-gnu", - "@rollup/rollup-linux-x64-musl", - "@rollup/rollup-win32-arm64-msvc", - "@rollup/rollup-win32-ia32-msvc", - "@rollup/rollup-win32-x64-msvc", - "@types/estree", - "fsevents" - ] - }, - "rxjs@7.8.2": { - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dependencies": [ - "tslib" - ] - }, - "sass-embedded-android-arm64@1.86.2": { - "integrity": "sha512-q3d3SW5JWv3U4Fxf01Ho0Ij7iSmA9528J8hRQW/qiPq/rNLpaX+YNTQfaWgSQcuKsrHiqJwWwqN7nTL3rdmNGQ==" - }, - "sass-embedded-android-arm@1.86.2": { - "integrity": "sha512-gjve+jvwUUdY96VxfhNWyJ0BCHFcMiLuESNWYVuntSGPsuSiTZJFMVZxtb7oEXl5HDn9NL5IbPMbox8R8A4Gew==" - }, - "sass-embedded-android-ia32@1.86.2": { - "integrity": "sha512-AbWxVmiZxKC4O5AH1X1rypngu+Mc5/Jl8ZcO7X3RBL3MDSH87MNoSjYHtYeC/j9BFzFK+5h9uluRq+86DoRX0Q==" - }, - "sass-embedded-android-riscv64@1.86.2": { - "integrity": "sha512-5IFIRPyWtTUBHV1kWJfJCTr9gYeF9yA8bkuvUJ6cCMrj58CiWnGODeqzz8SWpR6TIOwJMl6cT8lKGWQbMMtdUA==" - }, - "sass-embedded-android-x64@1.86.2": { - "integrity": "sha512-DzcDdmYwMmyFu/d5YXH2/qYQ0sJh3XoLma4ktzptmQnhgyTo4ajqC313TBCSrUThBxJPcfzy5ji+mZRWJpGHEg==" - }, - "sass-embedded-darwin-arm64@1.86.2": { - "integrity": "sha512-wmcrNCdhdod9n67g+G/lm3pwv5kNqHSsfBwq6oTgpKUtoecc44UhKMaZ7P5foTTTRybVVj7w5qVPGh8H25Tlgg==" - }, - "sass-embedded-darwin-x64@1.86.2": { - "integrity": "sha512-dHfnCfimKklYanqlubidA3Kyk9g7Ltcs7btfzrrWzvyfRAFKkg826aDHfnnDw8ihBYlmNrHa4jqxPSP5L88m3w==" - }, - "sass-embedded-linux-arm64@1.86.2": { - "integrity": "sha512-K7sw2w2TMboorrIRM5EQIU7FAvERyfOc227dLkGx7mhInBq5bUX9ixI8sN0AGdvmFmBipE4RAlmfYkjjavroxQ==" - }, - "sass-embedded-linux-arm@1.86.2": { - "integrity": "sha512-ZTUvotjO/+CIXs3/fFpFWHLmUnEtvilIgiTHilx8yS2eReJWBzlgXneHQf6ZSNMqCNF/lbiJbJokBEDhW77drg==" - }, - "sass-embedded-linux-ia32@1.86.2": { - "integrity": "sha512-+OHfCDU3S86oHlKWolp1mtk/6HAcsvBw7wqff5ze3Gp62jSfe4KKojhKhCtBs2ZK7W/O7U/7WM58sSWdoJ8Tow==" - }, - "sass-embedded-linux-musl-arm64@1.86.2": { - "integrity": "sha512-j9GVzPLaPmXQJroq+Dw1loH+EB3mQcP4RtIumIWzJh1HvfQG9QEoevG2oiofXS1Wd8705N6Cp3rCFrg1eIUtfw==" - }, - "sass-embedded-linux-musl-arm@1.86.2": { - "integrity": "sha512-8DZRt9ipTeyTXe+Hpck3lmQBCXgFza4kbqkyByT1tleGx95hNxSNFBdrK6oYHLIxDz2HXr46PyapP2QlHODBcA==" - }, - "sass-embedded-linux-musl-ia32@1.86.2": { - "integrity": "sha512-ZfOmohK3bNKQifJs9DULS4HjBmVy2K8BOi1p7JvWik+SSnpXi9MK1mEJi7w71ktZZ+NvFgpDbeIvCpxyaZJsKg==" - }, - "sass-embedded-linux-musl-riscv64@1.86.2": { - "integrity": "sha512-MdT2L1sMSv7ytOCAj8OAf4srm7jDiAmpiHH+0cxMJPwu8uo1oa1aMjcXBW0vfC+SB8ugoBapW0Fnfu/QjVgmjw==" - }, - "sass-embedded-linux-musl-x64@1.86.2": { - "integrity": "sha512-huV+hy3UDRDQwwcECXZL2J4+yxRnOYCGET4y/eyJoLprlpRzl41z+byikXDsz8/f0HsttZ1DOcUmPcJwts9rJw==" - }, - "sass-embedded-linux-riscv64@1.86.2": { - "integrity": "sha512-SwMgxIcsiMqOrM9Ki+kDULHRPBvwnGoVyX0MNKPeTADTMm2ISD9sK7p5L7UyDmz+DE4Zgf0qx5pT1K1KP1pn8A==" - }, - "sass-embedded-linux-x64@1.86.2": { - "integrity": "sha512-Tw3w6KGp5YNBaVpRj1F5xhUS6ol+bVlVo+tvMKYoH2pDy5BHb+vMftviCaJDtTsZiYKFXWHAaygmXF8YGOwvPg==" - }, - "sass-embedded-win32-arm64@1.86.2": { - "integrity": "sha512-P45xUyLQ4F8s89yZNMWqkQGWHKx8J/ALS/Jl8JJeZcSVRFPQCaldZ/Zx8K2kAdVh5dg4OiFne8/YqpXBfWlHtw==" - }, - "sass-embedded-win32-ia32@1.86.2": { - "integrity": "sha512-AsvPpk3dmJRXCoZu9UKL7CXtWmXb4/CMQwo6wRe4SzwHpwHOy+Hj30lh5SRvcr9+J/knA7Aje2xMPxFPYgE4uQ==" - }, - "sass-embedded-win32-x64@1.86.2": { - "integrity": "sha512-Nvhyr2BxZN/Rh9YnBDc0iGboLA5lAx8h0/Dvut2iGdxVZ2eVqcV/uLXfsPJ9KHf+QNW5CIo2zOjQKqUQJjh2sw==" - }, - "sass-embedded@1.86.2": { - "integrity": "sha512-ER9yUk71007a+6azLBR0RzA4Vd4VtXpaRpI+HXqpEIARhleTKYUxXrh6nY+272q91xAzoXqBKVlTizOvNmb5yQ==", - "dependencies": [ - "@bufbuild/protobuf", - "buffer-builder", - "colorjs.io", - "immutable", - "rxjs", - "sass-embedded-android-arm", - "sass-embedded-android-arm64", - "sass-embedded-android-ia32", - "sass-embedded-android-riscv64", - "sass-embedded-android-x64", - "sass-embedded-darwin-arm64", - "sass-embedded-darwin-x64", - "sass-embedded-linux-arm", - "sass-embedded-linux-arm64", - "sass-embedded-linux-ia32", - "sass-embedded-linux-musl-arm", - "sass-embedded-linux-musl-arm64", - "sass-embedded-linux-musl-ia32", - "sass-embedded-linux-musl-riscv64", - "sass-embedded-linux-musl-x64", - "sass-embedded-linux-riscv64", - "sass-embedded-linux-x64", - "sass-embedded-win32-arm64", - "sass-embedded-win32-ia32", - "sass-embedded-win32-x64", - "supports-color", - "sync-child-process", - "varint" - ] - }, - "source-map-js@1.2.1": { - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" - }, - "speakingurl@14.0.1": { - "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==" - }, - "superjson@2.2.2": { - "integrity": "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==", - "dependencies": [ - "copy-anything" - ] - }, - "supports-color@8.1.1": { - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dependencies": [ - "has-flag" - ] - }, - "sync-child-process@1.0.2": { - "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", - "dependencies": [ - "sync-message-port" - ] - }, - "sync-message-port@1.1.3": { - "integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==" - }, - "tslib@2.8.1": { - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" - }, - "typescript@5.8.2": { - "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==" - }, - "undici-types@6.20.0": { - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" - }, - "upath@2.0.1": { - "integrity": "sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==" - }, - "varint@6.0.0": { - "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==" - }, - "vite-plugin-vuetify@2.1.0_vite@6.2.4__@types+node@22.13.17_vue@3.5.13__typescript@5.8.2_vuetify@3.8.0__typescript@5.8.2__vite-plugin-vuetify@2.1.0__vue@3.5.13___typescript@5.8.2__vite@6.2.4___@types+node@22.13.17__@types+node@22.13.17_@types+node@22.13.17_typescript@5.8.2": { - "integrity": "sha512-4wEAQtZaigPpwbFcZbrKpYwutOsWwWdeXn22B9XHzDPQNxVsKT+K9lKcXZnI5JESO1Iaql48S9rOk8RZZEt+Mw==", - "dependencies": [ - "@vuetify/loader-shared", - "debug", - "upath", - "vite", - "vue", - "vuetify@3.8.0_typescript@5.8.2_vite-plugin-vuetify@2.1.0__vite@6.2.4___@types+node@22.13.17__vue@3.5.13___typescript@5.8.2__vuetify@3.8.0__@types+node@22.13.17__typescript@5.8.2_vue@3.5.13__typescript@5.8.2" - ] - }, - "vite@6.2.4_@types+node@22.13.17": { - "integrity": "sha512-veHMSew8CcRzhL5o8ONjy8gkfmFJAd5Ac16oxBUjlwgX3Gq2Wqr+qNC3TjPIpy7TPV/KporLga5GT9HqdrCizw==", - "dependencies": [ - "@types/node", - "esbuild", - "fsevents", - "postcss", - "rollup" - ] - }, - "vscode-uri@3.1.0": { - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==" - }, - "vue-router@4.5.0_vue@3.5.13__typescript@5.8.2_typescript@5.8.2": { - "integrity": "sha512-HDuk+PuH5monfNuY+ct49mNmkCRK4xJAV9Ts4z9UFc4rzdDnxQLyCMGGc8pKhZhHTVzfanpNwB/lwqevcBwI4w==", - "dependencies": [ - "@vue/devtools-api@6.6.4", - "vue" - ] - }, - "vue-tabler-icons@2.21.0": { - "integrity": "sha512-rEZYPd37j1sd/9gBFtC1u8wj3Pz1S3gLP1tgexvnivzQPXphc6M+7XuTSOd7wtdpaLt1sQgDmuQxIgczzrvf0A==" - }, - "vue-tsc@2.2.8_typescript@5.8.2": { - "integrity": "sha512-jBYKBNFADTN+L+MdesNX/TB3XuDSyaWynKMDgR+yCSln0GQ9Tfb7JS2lr46s2LiFUT1WsmfWsSvIElyxzOPqcQ==", - "dependencies": [ - "@volar/typescript", - "@vue/language-core", - "typescript" - ] - }, - "vue@3.5.13_typescript@5.8.2": { - "integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==", - "dependencies": [ - "@vue/compiler-dom", - "@vue/compiler-sfc", - "@vue/runtime-dom", - "@vue/server-renderer", - "@vue/shared", - "typescript" - ] - }, - "vuetify@3.8.0_typescript@5.8.2_vite-plugin-vuetify@2.1.0__vite@6.2.4___@types+node@22.13.17__vue@3.5.13___typescript@5.8.2__vuetify@3.8.0__@types+node@22.13.17__typescript@5.8.2_vue@3.5.13__typescript@5.8.2": { - "integrity": "sha512-ROC0Xq2G/25ZyUpQMhaynMyXZBJY1WbOGlqOB810yubp8hfY8RlrOw+mzXJonOq6jylCY32muQ9xiJF1JPTLVA==", - "dependencies": [ - "typescript", - "vite-plugin-vuetify", - "vue" - ] - }, - "vuetify@3.8.0_typescript@5.8.2_vite-plugin-vuetify@2.1.0__vite@6.2.4___@types+node@22.13.17__vue@3.5.13___typescript@5.8.2__vuetify@3.8.0__@types+node@22.13.17__typescript@5.8.2_vue@3.5.13__typescript@5.8.2_vite@6.2.4__@types+node@22.13.17_@types+node@22.13.17": { - "integrity": "sha512-ROC0Xq2G/25ZyUpQMhaynMyXZBJY1WbOGlqOB810yubp8hfY8RlrOw+mzXJonOq6jylCY32muQ9xiJF1JPTLVA==", - "dependencies": [ - "typescript", - "vite-plugin-vuetify", - "vue" - ] - } - }, - "workspace": { - "packageJson": { - "dependencies": [ - "npm:@mdi/font@7.4.47", - "npm:@tauri-apps/api@2.4.1", - "npm:@tauri-apps/cli@2.4.1", - "npm:@tauri-apps/plugin-dialog@~2.2.1", - "npm:@tauri-apps/plugin-shell@2.2.1", - "npm:@types/node@22.13.17", - "npm:@vitejs/plugin-vue@5.2.3", - "npm:@vue/tsconfig@0.7.0", - "npm:pinia@3.0.1", - "npm:sass-embedded@^1.86.2", - "npm:typescript@5.8.2", - "npm:vite-plugin-vuetify@2.1.0", - "npm:vite@6.2.4", - "npm:vue-router@4.5.0", - "npm:vue-tabler-icons@2.21.0", - "npm:vue-tsc@2.2.8", - "npm:vue@3.5.13", - "npm:vuetify@3.8.0" - ] - } - } -} diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 0000000..139848f --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,12 @@ +import js from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; +import pluginVue from "eslint-plugin-vue"; +import { defineConfig } from "eslint/config"; + +export default defineConfig([ + { files: ["**/*.{js,mjs,cjs,ts,mts,cts,vue}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.browser } }, + tseslint.configs.recommended, + pluginVue.configs["flat/essential"], + { files: ["**/*.vue"], languageOptions: { parserOptions: { parser: tseslint.parser } } }, +]); diff --git a/examples/0s1s.rs b/examples/0s1s.rs new file mode 100644 index 0000000..26b6319 --- /dev/null +++ b/examples/0s1s.rs @@ -0,0 +1,81 @@ +use tokio::sync::mpsc; +use tokio::time::{Duration, sleep}; + +/// Track this app at localhost:6669 +/// +/// This app spawns three asynchronous tasks: +/// 1. A producer that sends 1024-byte zero-filled chunks every 500 ms. +/// 2. A producer that sends 1024-byte one-filled chunks every 700 ms. +/// 3. A consumer that concurrently reads from both channels and logs incoming data. +#[tokio::main] +async fn main() { + console_subscriber::init(); + + /// Transmitter for zero-filled data chunks. + let (zero_tx, mut zero_rx) = mpsc::channel::>(10); + /// Transmitter for one-filled data chunks. + let (one_tx, mut one_rx) = mpsc::channel::>(10); + + /// Producer task that generates and sends chunks of zeros. + let zero_handle = tokio::spawn(async move { + loop { + // Create a 1024-byte chunk of zeros + let chunk = vec![0u8; 1024]; + // Attempt to send; exit loop on failure (receiver dropped) + if zero_tx.send(chunk).await.is_err() { + break; + } + println!("Sent 1 chunk of 0s"); + // Wait 500 milliseconds before next send + sleep(Duration::from_millis(500)).await; + } + }); + + /// Producer task that generates and sends chunks of ones. + let one_handle = tokio::spawn(async move { + loop { + let chunk = vec![1u8; 1024]; + if one_tx.send(chunk).await.is_err() { + break; + } + println!("Sent 1 chunk of 1s"); + sleep(Duration::from_millis(700)).await; + } + }); + + /// Consumer task that listens to both zero and one channels and processes incoming data. + let consumer_handle = tokio::spawn(async move { + loop { + tokio::select! { + // Handle messages from the zero channel + maybe0 = zero_rx.recv() => { + match maybe0 { + Some(data) => { + println!("Received {} bytes of zeros", data.len()); + } + None => { + println!("Zero channel closed"); + break; + } + } + } + + // Handle messages from the one channel + maybe1 = one_rx.recv() => { + match maybe1 { + Some(data) => { + println!("Received {} bytes of ones", data.len()); + } + None => { + println!("One channel closed"); + break; + } + } + } + } + } + }); + + // Await all three tasks before exiting + let _ = tokio::join!(zero_handle, one_handle, consumer_handle); +} diff --git a/examples/resources.rs b/examples/resources.rs new file mode 100644 index 0000000..efe74e9 --- /dev/null +++ b/examples/resources.rs @@ -0,0 +1,54 @@ +use std::io; +use std::sync::Arc; +use tokio::sync::{Barrier, RwLock}; +use tokio::time::{Duration, Instant, sleep}; + +#[tokio::main] +async fn main() { + console_subscriber::ConsoleLayer::builder() + .with_default_env() + .server_addr(([127, 0, 0, 1], 7777)) + .init(); + let mut input = String::new(); + io::stdin().read_line(&mut input).unwrap(); + println!("press something"); + { + let barrier = Arc::new(Barrier::new(3)); + let sum = Arc::new(RwLock::new(0)); + + for i in 1..=3 { + let b = barrier.clone(); + let s = sum.clone(); + tokio::spawn(async move { + println!("Task {i} started and is doing some work..."); + sleep(Duration::from_millis(i * 500)).await; + { + let mut mut_sum = s.write().await; + *mut_sum += i; + } + + println!("Task {i} waiting at the barrier..."); + b.wait().await; + + println!("Task {i} passed the barrier!"); + }); + } + + sleep(Duration::from_secs(3)).await; + } + println!("stressing cpu"); + println!("press something to exit"); + io::stdin().read_line(&mut input).unwrap(); +} + +fn burn_cpu_for(seconds: u64) { + let start = Instant::now(); + let mut x = 0u64; + + while start.elapsed() < Duration::from_secs(seconds) { + x = x.wrapping_add(1); + x = x.wrapping_mul(2); + } + + println!("Finished CPU stress, final x = {}", x); +} diff --git a/examples/tasks.rs b/examples/tasks.rs new file mode 100644 index 0000000..746bf1f --- /dev/null +++ b/examples/tasks.rs @@ -0,0 +1,63 @@ +use std::io; +use std::sync::Arc; +use tokio::sync::Barrier; +use tokio::time::{Duration, Instant, sleep}; + +/// Track this app at localhost:7777 +/// +/// Waits for user input to start, then spawns three tasks that simulate work, +/// wait on a common barrier, and then proceed together. After another pause, +/// it optionally burns CPU cycles if `burn_cpu_for` is enabled. +#[tokio::main] +async fn main() { + console_subscriber::ConsoleLayer::builder() + .with_default_env() + .server_addr(([127, 0, 0, 1], 7777)) + .init(); + + let mut input = String::new(); + io::stdin().read_line(&mut input).unwrap(); + + { + /// A barrier that blocks until three tasks have reached it. + let barrier = Arc::new(Barrier::new(3)); + + for i in 1..=3 { + let b = barrier.clone(); + tokio::spawn(async move { + println!("Task {i} started and is doing some work..."); + sleep(Duration::from_millis(i * 500)).await; + + println!("Task {i} waiting at the barrier..."); + b.wait().await; + + println!("Task {i} passed the barrier!"); + }); + } + + sleep(Duration::from_secs(3)).await; + } + + println!("se foloseste procesorul"); + + io::stdin().read_line(&mut input).unwrap(); +} + +/// Busy-waits to consume CPU time for the specified duration. +/// +/// Uses a wrapping arithmetic loop to prevent compiler optimizations. +/// +/// # Arguments +/// +/// * `seconds` - Number of seconds to burn CPU cycles. +fn burn_cpu_for(seconds: u64) { + let start = Instant::now(); + let mut x = 0u64; + + while start.elapsed() < Duration::from_secs(seconds) { + x = x.wrapping_add(1); + x = x.wrapping_mul(2); + } + + println!("Finished CPU burn, final x = {}", x); +} diff --git a/package.json b/package.json index 9745735..332861d 100644 --- a/package.json +++ b/package.json @@ -8,28 +8,52 @@ "build": "vue-tsc --noEmit && vite build", "preview": "vite preview", "typecheck": "vue-tsc --noEmit", - "tauri": "tauri" + "tauri": "tauri", + "lint": "eslint . --fix ", + "lint-check": "eslint . --max-warnings=0" }, "dependencies": { "@mdi/font": "7.4.47", - "@tauri-apps/plugin-dialog": "~2.2.1", - "vue": "3.5.13", - "pinia": "3.0.1", "@tauri-apps/api": "2.4.1", + "@tauri-apps/plugin-dialog": "~2.2.1", "@tauri-apps/plugin-shell": "2.2.1", + "@types/moment": "^2.13.0", + "@types/moment-timezone": "^0.5.30", + "chart.js": "^4.5.0", + "chartjs-adapter-date-fns": "^3.0.0", + "chartjs-plugin-datalabels": "^2.2.0", + "chartjs-plugin-zoom": "^2.2.0", + "date-fns": "^4.1.0", + "lodash.throttle": "^4.1.1", + "moment": "^2.29.4", + "moment-timezone": "^0.5.43", + "pinia": "3.0.1", + "uplot": "^1.6.32", + "uplot-vue": "^1.2.4", + "vue": "^3.5.13", + "vue-chartjs": "^5.3.2", "vue-router": "4.5.0", "vue-tabler-icons": "2.21.0", - "vuetify": "3.8.0" + "vuetify": "^3.9.0" }, "devDependencies": { + "@eslint/js": "^9.36.0", + "@tauri-apps/cli": "2.4.1", + "@types/lodash.throttle": "^4.1.9", "@types/node": "22.13.17", - "@vue/tsconfig": "0.7.0", "@vitejs/plugin-vue": "5.2.3", - "@tauri-apps/cli": "2.4.1", + "@vue/eslint-config-prettier": "^10.2.0", + "@vue/eslint-config-typescript": "^14.6.0", + "@vue/tsconfig": "0.7.0", + "eslint": "^9.36.0", + "eslint-plugin-vue": "^10.5.0", + "globals": "^16.4.0", + "jiti": "^2.6.0", "sass-embedded": "^1.86.2", - "typescript": "5.8.2", - "vite": "6.2.4", - "vue-tsc": "2.2.8", - "vite-plugin-vuetify": "2.1.0" + "typescript": "^5.8.2", + "typescript-eslint": "^8.44.1", + "vite": "^6.3.6", + "vite-plugin-vuetify": "2.1.0", + "vue-tsc": "2.2.8" } } diff --git a/pictures/app.png b/pictures/app.png new file mode 100644 index 0000000..44e9423 Binary files /dev/null and b/pictures/app.png differ diff --git a/pictures/how-to.gif b/pictures/how-to.gif new file mode 100644 index 0000000..8f28757 Binary files /dev/null and b/pictures/how-to.gif differ diff --git a/pictures/polls.png b/pictures/polls.png new file mode 100644 index 0000000..b964faa Binary files /dev/null and b/pictures/polls.png differ diff --git a/pictures/resources.png b/pictures/resources.png new file mode 100644 index 0000000..298e7af Binary files /dev/null and b/pictures/resources.png differ diff --git a/pictures/tasks.png b/pictures/tasks.png new file mode 100644 index 0000000..3b16bf1 Binary files /dev/null and b/pictures/tasks.png differ diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7c2be19..da79ec7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -16,6 +16,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] tauri-build = { version = "2", features = [] } +tonic-build = "0.12" [dependencies] tokio = { version = "1.43.0", features = ["sync"] } @@ -35,4 +36,8 @@ env_logger = "0.10.0" async-trait = "0.1.86" dirs = "6.0.0" tauri-plugin-dialog = "2" +chrono = { version = "0.4.1", features = ["serde"] } +sysinfo = "0.35.2" +tempfile = "3.20.0" +futures = "0.3.31" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index fbf6918..ad959a6 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -11,4 +11,4 @@ "store:default", "dialog:default" ] -} \ No newline at end of file +} diff --git a/src-tauri/src/backend/core/connection_manager.rs b/src-tauri/src/backend/core/connection_manager.rs new file mode 100644 index 0000000..6afab13 --- /dev/null +++ b/src-tauri/src/backend/core/connection_manager.rs @@ -0,0 +1,347 @@ +//! Connection manager for remote applications. +//! +//! This module maintains a set of active connections to instrumented applications. +//! It handles the lifecycle of each connection (connect, receive updates, refresh +//! process stats, disconnect), and multiplexes events back onto a channel that +//! can be observed by the rest of the system. + +#![allow(unused)] + +use crate::utils::common::get_pid_hosting_at; +use crate::{ + backend::domain::application::{self, Application}, + utils::error::Error as TraceError, +}; +use console_api::instrument::{instrument_client::InstrumentClient, InstrumentRequest, Update}; +use log::{debug, error, info, warn}; +use std::{clone, collections::HashMap, error::Error, sync::Arc, time::Duration}; +use sysinfo::{Pid, ProcessRefreshKind, ProcessStatus, ProcessesToUpdate, System}; +use tauri::Url; +use tokio::{ + select, + sync::{ + mpsc::{self, Sender}, + RwLock, + }, + time::{interval_at, sleep, Instant, Interval}, +}; +use tonic::{transport::Endpoint, Streaming}; +use uuid::Uuid; + +/// Commands you can send to an active connection task. +#[derive(Debug, PartialEq)] +pub enum Command { + /// Instructs the connection task to disconnect and shut down. + Disconnect, +} + +/// Events emitted by the connection manager for each application. +#[allow(clippy::large_enum_variant)] +#[non_exhaustive] +pub enum Event { + /// Connection is in progress. + Connecting, + /// Successfully connected and ready to receive updates. + Connected, + /// An update pushed from the remote instrumented application. + Update(Update), + /// Periodic local process statistics (CPU, memory, status). + ApplicationUpdated(AppUpdate), + /// An error occurred during connection or streaming. + Error(TraceError), + /// The connection has been shut down. + Disconnected, + /// The app restarted and got a new PID + PidChanged(u32), +} + +/// Snapshot of local process statistics for an instrumented application. +#[derive(Clone)] +pub struct AppUpdate { + /// CPU usage per core, as a fraction (0.0 – 100.0). + pub cpu_usage: Option, + /// Memory usage in megabytes. + pub memory_usage: u64, + /// Current OS process status. + pub process_status: ProcessStatus, +} + +/// A handle to an active connection. You can send a [`Command::Disconnect`] on +/// the `commands` channel to terminate the connection. +#[derive(Clone, Debug)] +pub struct Connection { + /// Channel on which to send connection commands. + pub commands: Sender, +} + +impl Drop for Connection { + fn drop(&mut self) { + debug!("Dropped connection"); + } +} + +/// Manages multiple live connections to instrumented applications. Spawns a +/// Tokio task for each connection to handle streaming updates and periodic +/// process stats. +pub struct ConnectionManager { + /// Internal sender + sender: Sender<(Uuid, Event)>, + /// Map of active connection tasks, keyed by application UUID. + active_connections: Arc>>>, +} + +impl Clone for Event { + fn clone(&self) -> Self { + match self { + Event::Connecting => Event::Connecting, + Event::Connected => Event::Connected, + Event::Update(u) => Event::Update(u.clone()), + Event::ApplicationUpdated(a) => Event::ApplicationUpdated(a.clone()), + Event::Error(_) => panic!("Cannot clone Event::Error (rich TraceError)"), + Event::Disconnected => Event::Disconnected, + Event::PidChanged(pid) => Event::PidChanged(*pid), + } + } +} + +impl ConnectionManager { + /// Create a new `ConnectionManager`. + /// + /// # Parameters + /// - `tx`: the channel to send `(Uuid, Event)` tuples on. + /// + /// # Returns + /// A fresh manager without any active connections. + pub fn new(sender: Sender<(Uuid, Event)>) -> Self { + Self { + sender, + active_connections: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Connect to an instrumented application. + /// + /// Spawns a background Tokio task that: + /// - Signals `Event::Connecting` + /// - Attempts to establish a gRPC stream to the `url` + /// - Emits `Event::Connected` then forwards all incoming `Update` messages + /// as `Event::Update` + /// - Every second, polls local process info (CPU, memory, status) and emits + /// `Event::ApplicationUpdated` + /// - Retries on failure until a `Command::Disconnect` is received + /// + /// # Parameters + /// - `id`: unique application identifier (used as the map key) + /// - `url`: the gRPC endpoint of the instrumenter + /// - `pid`: the OS process ID of the instrumented application + /// + /// # Errors + /// Returns an error if the application is already connected. + pub async fn connect_app( + &self, + id: Uuid, + url: Url, + mut pid: u32, + ) -> Result { + // Create command channel for this connection + let (command_sender, mut command_receiver) = mpsc::channel(100); + let connection = Connection { + commands: command_sender, + }; + let updates_sender = self.sender.clone(); + + // Check if app already connected + if self.active_connections.read().await.contains_key(&id) { + warn!("Tried to add application with uuid {}, but the id is already attached to a connected application", id); + return Err(TraceError::ApplicationAlreadyConnected(id.to_string())); + } + + // Spawn the background task + let cloned_id = id; + let connection_task = tokio::task::spawn(async move { + let mut sys = sysinfo::System::new_all(); + sys.refresh_all(); + + 'connection: loop { + // Notify that we're starting a connection attempt + updates_sender + .send((cloned_id, Event::Connecting)) + .await + .ok(); + + info!("Connecting to application with url {}", url); + + // Connect the app + let connection = select! { + res = Self::connect_to_app(&url) => { + debug!("Received connection result"); + res + } + command = command_receiver.recv() => { + debug!("Received command: {:?}", command); + match command { + Some(Command::Disconnect) | None => break 'connection, + } + } + }; + + // Vad daca primesc comenzi pt aplicatie (gen disconnect/disable) + // Check connection + match connection { + Ok(mut update_stream) => { + info!("Successfully connected to application with url {}", url); + + updates_sender + .send((cloned_id, Event::Connected)) + .await + .ok(); + + let mut refresh = interval_at(Instant::now(), Duration::from_secs(1)); + + // Main loop: handle incoming updates, commands, or tick + loop { + select! { + // Wait for new updates regarding our app + update = update_stream.message() => { + debug!("Received task update"); + match update { + Ok(message) => { + if let Some(update) = message { + info!("Received an update about application with url {}", url); + updates_sender.send((cloned_id, Event::Update(update))).await.ok(); + } + } + Err(_error) => { + // TODO report error + // for now we disconnect + continue 'connection; + } + } + } + // Wait for external commands + command = command_receiver.recv() => { + debug!("Received command"); + if let Some(command) = command { + match command { + Command::Disconnect => break 'connection, + } + } else { + // Command stream is closed so we exit + break 'connection; + } + } + // Should refresh data stored about app + // TODO TEST: check if we receive the app updates once per second + _ = refresh.tick() => { + debug!("Sending application info refresh"); + if let Some(app_update)= { + sys.refresh_all(); + // Wait a bit because CPU usage is based on diff. + tokio::time::sleep(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL).await; + // Refresh CPU usage to get actual value. + sys.refresh_processes_specifics( + ProcessesToUpdate::All, + true, + ProcessRefreshKind::nothing().with_cpu(), + ); + Self::check_app_stats(&mut sys, pid).await + } { + updates_sender.send((cloned_id, Event::ApplicationUpdated(app_update))).await.ok(); + } else if let Ok(new_pid) = get_pid_hosting_at(url.clone()){ + if new_pid != pid { + pid = new_pid; + updates_sender.send((cloned_id, Event::PidChanged(new_pid))).await.ok(); + } + else { + updates_sender.send((cloned_id, Event::Error(TraceError::CannotReadProcessInfo { pid }))).await.ok(); + } + } + else { + updates_sender.send((cloned_id, Event::Error(TraceError::CannotReadProcessInfo { pid }))).await.ok(); + } + } + } + } + } + Err(error) => { + error!( + "Could not connect to application with url {} due to {error:?}", + url + ); + updates_sender + .send((cloned_id, Event::Error(TraceError::Anyhow(error.into())))) + .await + .ok(); + + // Sleep before trying to connect again + sleep(Duration::from_secs(1)).await; + } + } + } + + // Final notification of disconnection + updates_sender + .send((cloned_id, Event::Disconnected)) + .await + .ok(); + }); + + // Store the task handle + self.active_connections + .write() + .await + .insert(id, connection_task); + Ok(connection) + } + + /// Forcefully remove the connection task for the given `uuid`. + /// This will drop its `Connection` handle and stop receiving further events. + pub(crate) async fn disconnect_app(&self, uuid: Uuid) { + self.active_connections.write().await.remove(&uuid); + } + + /// Internal helper: open a gRPC streaming connection to the remote instrumenter. + /// + /// Returns a boxed `tonic::Streaming` on success. + async fn connect_to_app(url: &Url) -> Result>, TraceError> { + let endpoint = Endpoint::new(url.to_string()).map_err(|e| TraceError::Anyhow(e.into()))?; + debug!("Created the endpoint"); + let channel = + endpoint + .connect() + .await + .map_err(|e| TraceError::CannotCreateChannelForApp { + url: url.to_string(), + })?; + debug!("Created channel"); + + let mut client = InstrumentClient::new(channel); + let update_request = tonic::Request::new(InstrumentRequest {}); + + let stream = client + .watch_updates(update_request) + .await + .map_err(|e| TraceError::Anyhow(e.into()))? + .into_inner(); + + debug!("Obtained updates stream"); + + Ok(Box::new(stream)) + } + + /// Internal helper: read CPU, memory and status for the given `pid` from `sys`. + /// + /// Returns `None` if the process no longer exists. + async fn check_app_stats(sys: &mut System, pid: u32) -> Option { + let cpu_count = sys.cpus().len() as f32; + let process = sys.process(Pid::from_u32(pid))?; + let cpu_per_core = process.cpu_usage() / cpu_count; + let memory_mb = process.memory() / 1000000; + + Some(AppUpdate { + cpu_usage: Some(cpu_per_core), + memory_usage: memory_mb, + process_status: process.status(), + }) + } +} diff --git a/src-tauri/src/backend/core/database.rs b/src-tauri/src/backend/core/database.rs new file mode 100644 index 0000000..4db1bea --- /dev/null +++ b/src-tauri/src/backend/core/database.rs @@ -0,0 +1,725 @@ +//! Persistent, in-memory database backed by disk storage. +//! +//! The `Database` struct holds all domain objects in RAM (wrapped in `Arc<…>`), +//! and syncs them to disk via the `Storage` trait’s read/write guards. +//! +//! # Storage Layout +//! +//! - applications → `/applications.json` +//! - tasks → `/tasks.json` +//! - resources → `/resources.json` +//! - polls → `/polls.json` +//! - async_ops → `/async_ops.json` +//! - tasks_ops → `/tasks_ops.json` +use crate::{ + backend::domain::{ + application::Application, + async_op::{AsyncOp, TaskOp}, + poll::Poll, + resource::Resource, + storable::Storable, + Task, + }, + backend::infra::{guard::WriteableDataBaseGuard, storage::Storage}, + utils::error::Error as TraceError, +}; +use async_trait::async_trait; +use chrono::Local; +use log::{debug, error}; +use std::path::{Path, PathBuf}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; +use tokio::fs; +use tokio::io::AsyncWriteExt; +use tokio::sync::RwLock; +use uuid::Uuid; + +/// In-memory, thread-safe cache of all domain data, persisted on disk. +/// +/// Wraps each entity in `Arc<…>` so that clones are cheap, and guards writes +/// behind `tokio::sync::RwLock`. Implements `Storage` to provide atomic read +/// and write handles that flush changes to disk on drop. +#[derive(Default)] +pub(crate) struct Database { + /// Root folder path for JSON files. + storage_folder: String, + + /// All persisted applications, keyed by UUID. + applications: RwLock>>, + + /// All persisted tasks, keyed by their string ID. + tasks: RwLock>>, + + active_tasks: RwLock>, + + /// All persisted resources, keyed by their string ID. + resources: RwLock>>, + + /// All persisted polls. + polls: RwLock>>, + + /// All persisted asynchronous operations, keyed by string ID. + async_ops: RwLock>>, + + /// All persisted task operations, keyed by string ID. + tasks_ops: RwLock>>, +} + +impl Database { + /// Create a fresh, empty database without attempting to load from disk. + /// + /// Use this if you want to start with no pre-existing data. + /// + /// # Parameters + /// - `storage_folder`: path to the folder where JSON files will be read/written. + pub(crate) fn new(storage_folder: String) -> Self { + Self { + storage_folder, + applications: RwLock::new(HashMap::new()), + tasks: RwLock::new(HashMap::new()), + active_tasks: RwLock::new(HashSet::new()), + resources: RwLock::new(HashMap::new()), + polls: RwLock::new(Vec::new()), + async_ops: RwLock::new(HashMap::new()), + tasks_ops: RwLock::new(HashMap::new()), + } + } + + /// Load all persisted data from disk into memory. + /// + /// For each entity type, attempts to read `/.json`. + /// - If the file is missing, logs a debug‐level message and continues with an empty collection. + /// - On any other error (I/O, serialization, etc.), returns `Err(TraceError)`. + /// + /// # Errors + /// Returns a `TraceError` if any non-`PathNotFound` error occurs during loading. + pub(crate) async fn load(storage_folder: String) -> Result<Self, TraceError> { + // Load all applications + let applications: HashMap<Uuid, Arc<Application>> = + match Application::load_all(storage_folder.clone()).await { + Ok(apps) => apps + .into_iter() + .map(|(id, app)| (id, Arc::new(app))) + .collect(), + Err(error) => match error { + TraceError::PathNotFound(_) => { + debug!("Applications file not found, using empty list"); + HashMap::new() + } + _ => { + error!("Failed to load applications due to {error:?}"); + return Err(error); + } + }, + }; + debug!( + "Successfully loaded {} applications from disk.", + applications.values().len() + ); + + // Load all tasks + let tasks: HashMap<String, Arc<Task>> = match Task::load_all(storage_folder.clone()).await { + Ok(tasks) => tasks + .into_iter() + .map(|(id, task)| (id, Arc::new(task))) + .collect(), + Err(error) => match error { + TraceError::PathNotFound(_) => { + debug!("Tasks file not found, using empty list"); + HashMap::new() + } + _ => { + error!("Failed to load tasks due to {error:?}"); + return Err(error); + } + }, + }; + debug!( + "Successfully loaded {} tasks from disk.", + tasks.values().len() + ); + + let mut active_tasks = HashSet::new(); + + for (key, task) in tasks.clone() { + if !task.is_completed() { + active_tasks.insert(key); + } + } + + //Load all resources + let resources: HashMap<String, Arc<Resource>> = + match Resource::load_all(storage_folder.clone()).await { + Ok(resources) => resources + .into_iter() + .map(|(id, resource)| (id, Arc::new(resource))) + .collect(), + Err(error) => match error { + TraceError::PathNotFound(_) => { + debug!("Tasks file not found, using empty list"); + HashMap::new() + } + _ => { + error!("Failed to load resources due to {error:?}"); + return Err(error); + } + }, + }; + debug!( + "Successfully loaded {} resources from disk.", + resources.values().len() + ); + + //Load all polls + let polls: Vec<Arc<Poll>> = match Poll::load_all(storage_folder.clone()).await { + Ok(polls) => polls.into_iter().map(Arc::new).collect(), + Err(error) => match error { + TraceError::PathNotFound(_) => { + debug!("Polls file not found, using empty list"); + Vec::new() + } + _ => { + error!("Failed to load polls due to {error:?}"); + return Err(error); + } + }, + }; + debug!( + "Successfully loaded {} polls from disk.", + resources.values().len() + ); + + let async_ops: HashMap<String, Arc<AsyncOp>> = + match AsyncOp::load_all(storage_folder.clone()).await { + Ok(async_ops) => async_ops + .into_iter() + .map(|(id, async_op)| (id, Arc::new(async_op))) + .collect(), + Err(error) => match error { + TraceError::PathNotFound(_) => { + debug!("Async_op file not found, using empty list"); + HashMap::new() + } + _ => { + error!("Failed to load polls due to {error:?}"); + return Err(error); + } + }, + }; + + // Load tasks_ops + let tasks_ops = match TaskOp::load_all(storage_folder.clone()).await { + Ok(tasks_ops) => tasks_ops + .into_iter() + .map(|(id, task_op)| (id, Arc::new(task_op))) + .collect(), + Err(error) => match error { + TraceError::PathNotFound(_) => { + debug!("Tasks_ops file not found, using empty list"); + HashMap::new() + } + _ => { + error!("Failed to load polls due to {error:?}"); + return Err(error); + } + }, + }; + debug!("Loaded {} tasks_ops", tasks_ops.len()); + + Ok(Self { + storage_folder, + applications: RwLock::new(applications), + tasks: RwLock::new(tasks), + active_tasks: RwLock::new(active_tasks), + resources: RwLock::new(resources), + polls: RwLock::new(polls), + async_ops: RwLock::new(async_ops), + tasks_ops: RwLock::new(tasks_ops), + }) + } +} + +#[async_trait] +impl Storage for Database { + /// Read-only snapshot of all applications. + async fn applications_read(&self) -> HashMap<Uuid, Arc<Application>> { + self.applications.read().await.clone() + } + + /// Obtain a write guard for applications. On drop, writes back to disk. + async fn applications_write( + &self, + ) -> WriteableDataBaseGuard<'_, HashMap<Uuid, Arc<Application>>> { + let elements = self.applications.write().await; + + WriteableDataBaseGuard { + folder: &self.storage_folder, + title: "applications", + elements, + } + } + + /// Read-only snapshot of all tasks. + async fn tasks_read(&self) -> HashMap<String, Arc<Task>> { + self.tasks.read().await.clone() + } + + /// Obtain a write guard for tasks. On drop, writes back to disk. + async fn tasks_write(&self) -> WriteableDataBaseGuard<'_, HashMap<String, Arc<Task>>> { + let elements = self.tasks.write().await; + + WriteableDataBaseGuard { + folder: &self.storage_folder, + title: "tasks", + elements, + } + } + + async fn active_tasks_write(&self) -> tokio::sync::RwLockWriteGuard<'_, HashSet<String>> { + self.active_tasks.write().await + } + + async fn active_tasks_read(&self) -> HashSet<String> { + self.active_tasks.read().await.clone() + } + + /// Read-only snapshot of all resources. + async fn resources_read(&self) -> HashMap<String, Arc<Resource>> { + self.resources.read().await.clone() + } + + async fn resources_write(&self) -> WriteableDataBaseGuard<'_, HashMap<String, Arc<Resource>>> { + let elements = self.resources.write().await; + + WriteableDataBaseGuard { + folder: &self.storage_folder, + title: "resources", + elements, + } + } + + /// Read-only snapshot of all polls. + async fn polls_read(&self) -> Vec<Arc<Poll>> { + self.polls.read().await.clone() + } + + /// Obtain a write guard for polls. On drop, writes back to disk. + async fn polls_write(&self) -> WriteableDataBaseGuard<'_, Vec<Arc<Poll>>> { + let elements = self.polls.write().await; + + WriteableDataBaseGuard { + folder: &self.storage_folder, + title: "polls", + elements, + } + } + + /// Read-only snapshot of all asynchronous operations. + async fn async_ops_read(&self) -> HashMap<String, Arc<AsyncOp>> { + self.async_ops.read().await.clone() + } + + async fn async_ops_write(&self) -> WriteableDataBaseGuard<'_, HashMap<String, Arc<AsyncOp>>> { + let elements = self.async_ops.write().await; + + WriteableDataBaseGuard { + folder: &self.storage_folder, + title: "async_ops", + elements, + } + } + + /// Read-only snapshot of all task operations. + async fn tasks_ops_read(&self) -> HashMap<String, Arc<TaskOp>> { + self.tasks_ops.read().await.clone() + } + + async fn tasks_ops_write(&self) -> WriteableDataBaseGuard<'_, HashMap<String, Arc<TaskOp>>> { + let elements = self.tasks_ops.write().await; + + WriteableDataBaseGuard { + folder: &self.storage_folder, + title: "tasks_ops", + elements, + } + } + + /// Export application instance + /// to /var/lib/async-tracing/exports/<app title>/<timestamp> as separate JSON files. + /// Writes to a staging folder then renames for atomicity. Deletes exported entries from permanent DB on success. + async fn export_app_instance( + &self, + title: String, + comment: String, + ) -> Result<PathBuf, TraceError> { + let comment = comment.trim().to_string(); + let apps_map = self.applications_read().await; + let app_arc = apps_map + .values() + .find(|a| a.title == title) + .cloned() + .ok_or_else(|| TraceError::PathNotFound(format!("Application {} not found", title)))?; + let app_id = app_arc.id(); + let raw_title = app_arc.title().to_string(); + let app_dir_name = { + if raw_title.is_empty() { + format!("app-{}", app_id) + } else { + raw_title + } + }; + + let exports_base = Path::new(&self.storage_folder).join("exports"); + let app_folder = exports_base.join(&app_dir_name); + + let ts = Local::now().format("%d.%m.%Y %H:%M:%S").to_string(); + let timestamp_folder = app_folder.join(&ts); + + let staging = app_folder.join(format!("{}.tmp", &ts)); + let final_folder = timestamp_folder.clone(); + + fs::create_dir_all(&staging) + .await + .map_err(|e| TraceError::CannotCreateStorage { + error: anyhow::Error::from(e), + path: staging.to_string_lossy().to_string(), + })?; + + let safe_comment = if !comment.is_empty() { + comment.replace('\0', "").replace("\r\n", "\n") + } else { + String::new() + }; + let mut cfile = fs::File::create(staging.join("comment.txt")) + .await + .map_err(|e| TraceError::CannotCreateStorage { + error: anyhow::Error::from(e), + path: staging.to_string_lossy().to_string(), + })?; + cfile + .write_all(safe_comment.as_bytes()) + .await + .map_err(|e| TraceError::CannotCreateStorage { + error: anyhow::Error::from(e), + path: staging.to_string_lossy().to_string(), + })?; + + let apps_map = self.applications_read().await; + let tasks_map = self.tasks_read().await; + let resources_map = self.resources_read().await; + let polls_vec = self.polls_read().await; + let async_ops_map = self.async_ops_read().await; + let tasks_ops_map = self.tasks_ops_read().await; + + let app_arc = match apps_map.get(app_id).cloned() { + Some(a) => a, + None => { + let _ = fs::remove_dir_all(&staging).await; + return Err(TraceError::PathNotFound(format!( + "Application {} not found", + app_id + ))); + } + }; + + let app_title = app_arc.title().to_string(); + let mut tasks_out: HashMap<String, Arc<Task>> = HashMap::new(); + for (k, v) in tasks_map.into_iter() { + if let Some(name) = &v.app_name { + if name == &app_title { + tasks_out.insert(k, v); + } + } + } + + let mut resources_out: HashMap<String, Arc<Resource>> = HashMap::new(); + for (k, v) in resources_map.into_iter() { + if let Some(name) = &v.app_name { + if name == &app_title { + resources_out.insert(k, v); + } + } + } + + let mut polls_out: Vec<Arc<Poll>> = Vec::new(); + for poll in polls_vec.into_iter() { + if let Some(name) = &poll.app_name { + if name == &app_title { + polls_out.push(poll); + } + } + } + + let mut async_ops_out: HashMap<String, Arc<AsyncOp>> = HashMap::new(); + for (k, v) in async_ops_map.into_iter() { + let include = v + .resource_target + .as_ref() + .map(|t| t.contains(&app_title)) + .unwrap_or(false); + if include { + async_ops_out.insert(k, v); + } + } + + let mut tasks_ops_out: HashMap<String, Arc<TaskOp>> = HashMap::new(); + let exported_task_ids: HashSet<u64> = tasks_out.values().map(|t| t.id).collect(); + + for (k, v) in tasks_ops_map.into_iter() { + if exported_task_ids.contains(&v.task_id) { + tasks_ops_out.insert(k, v); + } + } + + { + let mut apps_json: HashMap<String, Application> = HashMap::new(); + apps_json.insert(app_arc.id().to_string(), (*Arc::clone(&app_arc)).clone()); + let out = serde_json::to_vec_pretty(&apps_json).map_err(TraceError::Serde)?; + let mut f = fs::File::create(staging.join("applications.json")) + .await + .map_err(|e| TraceError::CannotCreateStorage { + error: anyhow::Error::from(e), + path: staging.to_string_lossy().to_string(), + })?; + f.write_all(&out) + .await + .map_err(|e| TraceError::CannotCreateStorage { + error: anyhow::Error::from(e), + path: staging.to_string_lossy().to_string(), + })?; + } + + { + let mut tasks_json: HashMap<String, Task> = HashMap::new(); + for (k, v) in tasks_out.into_iter() { + tasks_json.insert(k, (*Arc::clone(&v)).clone()); + } + let out = serde_json::to_vec_pretty(&tasks_json).map_err(TraceError::Serde)?; + let mut f = fs::File::create(staging.join("tasks.json")) + .await + .map_err(|e| TraceError::CannotCreateStorage { + error: anyhow::Error::from(e), + path: staging.to_string_lossy().to_string(), + })?; + f.write_all(&out) + .await + .map_err(|e| TraceError::CannotCreateStorage { + error: anyhow::Error::from(e), + path: staging.to_string_lossy().to_string(), + })?; + } + + { + let mut resources_json: HashMap<String, Resource> = HashMap::new(); + for (k, v) in resources_out.into_iter() { + resources_json.insert(k, (*Arc::clone(&v)).clone()); + } + let out = serde_json::to_vec_pretty(&resources_json).map_err(TraceError::Serde)?; + let mut f = fs::File::create(staging.join("resources.json")) + .await + .map_err(|e| TraceError::CannotCreateStorage { + error: anyhow::Error::from(e), + path: staging.to_string_lossy().to_string(), + })?; + f.write_all(&out) + .await + .map_err(|e| TraceError::CannotCreateStorage { + error: anyhow::Error::from(e), + path: staging.to_string_lossy().to_string(), + })?; + } + + { + let polls_vec_owned: Vec<Poll> = polls_out + .into_iter() + .map(|p| (*Arc::clone(&p)).clone()) + .collect(); + let out = serde_json::to_vec_pretty(&polls_vec_owned).map_err(TraceError::Serde)?; + let mut f = fs::File::create(staging.join("polls.json")) + .await + .map_err(|e| TraceError::CannotCreateStorage { + error: anyhow::Error::from(e), + path: staging.to_string_lossy().to_string(), + })?; + f.write_all(&out) + .await + .map_err(|e| TraceError::CannotCreateStorage { + error: anyhow::Error::from(e), + path: staging.to_string_lossy().to_string(), + })?; + } + + { + let mut async_ops_json: HashMap<String, AsyncOp> = HashMap::new(); + for (k, v) in async_ops_out.into_iter() { + async_ops_json.insert(k, (*Arc::clone(&v)).clone()); + } + let out = serde_json::to_vec_pretty(&async_ops_json).map_err(TraceError::Serde)?; + let mut f = fs::File::create(staging.join("async_ops.json")) + .await + .map_err(|e| TraceError::CannotCreateStorage { + error: anyhow::Error::from(e), + path: staging.to_string_lossy().to_string(), + })?; + f.write_all(&out) + .await + .map_err(|e| TraceError::CannotCreateStorage { + error: anyhow::Error::from(e), + path: staging.to_string_lossy().to_string(), + })?; + } + + { + let mut tasks_ops_json: HashMap<String, TaskOp> = HashMap::new(); + for (k, v) in tasks_ops_out.into_iter() { + tasks_ops_json.insert(k, (*Arc::clone(&v)).clone()); + } + let out = serde_json::to_vec_pretty(&tasks_ops_json).map_err(TraceError::Serde)?; + let mut f = fs::File::create(staging.join("tasks_ops.json")) + .await + .map_err(|e| TraceError::CannotCreateStorage { + error: anyhow::Error::from(e), + path: staging.to_string_lossy().to_string(), + })?; + f.write_all(&out) + .await + .map_err(|e| TraceError::CannotCreateStorage { + error: anyhow::Error::from(e), + path: staging.to_string_lossy().to_string(), + })?; + } + + fs::rename(&staging, &final_folder).await.map_err(|e| { + let _ = futures::executor::block_on(fs::remove_dir_all(&staging)); + TraceError::CannotCreateStorage { + error: anyhow::Error::from(e), + path: final_folder.to_string_lossy().to_string(), + } + })?; + + { + let mut apps_guard = self.applications.write().await; + apps_guard.remove(app_id); + } + + { + let mut tasks_guard = self.tasks.write().await; + tasks_guard + .retain(|_k, v| v.app_name.as_ref().map(|n| n != &app_title).unwrap_or(true)); + } + + { + let mut resources_guard = self.resources.write().await; + resources_guard + .retain(|_k, v| v.app_name.as_ref().map(|n| n != &app_title).unwrap_or(true)); + } + + { + let mut polls_guard = self.polls.write().await; + polls_guard.retain(|p| p.app_name.as_ref().map(|n| n != &app_title).unwrap_or(true)); + } + + { + let mut async_guard = self.async_ops.write().await; + async_guard.retain(|_k, v| { + v.resource_target + .as_ref() + .map(|t| !t.contains(&app_title)) + .unwrap_or(true) + }); + } + + { + let mut tasks_ops_guard = self.tasks_ops.write().await; + tasks_ops_guard.retain(|_k, v| !exported_task_ids.contains(&v.task_id)); + } + + Ok(final_folder) + } + + async fn import_from_export_folder(&self, folder: PathBuf) -> Result<(), String> { + if !folder.exists() { + return Err(format!( + "Export folder not found: {}", + folder.to_string_lossy() + )); + } + + async fn read_if_exists(p: PathBuf) -> Result<Option<Vec<u8>>, String> { + if p.exists() { + fs::read(&p) + .await + .map(Some) + .map_err(|e| format!("Failed to read {}: {}", p.to_string_lossy(), e)) + } else { + Ok(None) + } + } + + if let Some(bytes) = read_if_exists(folder.join("applications.json")).await? { + let apps_map: HashMap<String, Application> = serde_json::from_slice(&bytes) + .map_err(|e| format!("applications.json parse error: {}", e))?; + let mut guard = self.applications_write().await; + for (k, v) in apps_map.into_iter() { + if let Ok(id) = uuid::Uuid::parse_str(&k) { + guard.insert(id, std::sync::Arc::new(v)); + } + } + drop(guard); + } + + if let Some(bytes) = read_if_exists(folder.join("tasks.json")).await? { + let tasks_map: HashMap<String, Task> = serde_json::from_slice(&bytes) + .map_err(|e| format!("tasks.json parse error: {}", e))?; + let mut guard = self.tasks_write().await; + for (k, v) in tasks_map.into_iter() { + guard.insert(k, std::sync::Arc::new(v)); + } + drop(guard); + } + + if let Some(bytes) = read_if_exists(folder.join("resources.json")).await? { + let resources_map: HashMap<String, Resource> = serde_json::from_slice(&bytes) + .map_err(|e| format!("resources.json parse error: {}", e))?; + let mut guard = self.resources_write().await; + for (k, v) in resources_map.into_iter() { + guard.insert(k, std::sync::Arc::new(v)); + } + drop(guard); + } + + if let Some(bytes) = read_if_exists(folder.join("polls.json")).await? { + let polls_vec: Vec<Poll> = serde_json::from_slice(&bytes) + .map_err(|e| format!("polls.json parse error: {}", e))?; + let mut guard = self.polls_write().await; + for p in polls_vec.into_iter() { + guard.push(std::sync::Arc::new(p)); + } + drop(guard); + } + + if let Some(bytes) = read_if_exists(folder.join("async_ops.json")).await? { + let async_ops_map: HashMap<String, AsyncOp> = serde_json::from_slice(&bytes) + .map_err(|e| format!("async_ops.json parse error: {}", e))?; + let mut guard = self.async_ops_write().await; + for (k, v) in async_ops_map.into_iter() { + guard.insert(k, std::sync::Arc::new(v)); + } + drop(guard); + } + + if let Some(bytes) = read_if_exists(folder.join("tasks_ops.json")).await? { + let tasks_ops_map: HashMap<String, TaskOp> = serde_json::from_slice(&bytes) + .map_err(|e| format!("tasks_ops.json parse error: {}", e))?; + let mut guard = self.tasks_ops_write().await; + for (k, v) in tasks_ops_map.into_iter() { + guard.insert(k, std::sync::Arc::new(v)); + } + drop(guard); + } + + Ok(()) + } +} diff --git a/src-tauri/src/backend/core/mod.rs b/src-tauri/src/backend/core/mod.rs new file mode 100644 index 0000000..fab9d16 --- /dev/null +++ b/src-tauri/src/backend/core/mod.rs @@ -0,0 +1,506 @@ +//! State manager for applications, tasks, resources, and their connections. +//! +//! This module ties together three main components: +//! 1. `State` – in‐memory and persistent application state (via `state_manager::state`). +//! 2. `ConnectionManager` – background gRPC streams to instrumented applications. +//! 3. Tauri event emitters – send updated data to the frontend UI. +//! +//! The `StateManager` orchestrates loading previous state, reconnecting known +//! applications on startup, handling incoming gRPC events, updating the domain +//! state, and emitting frontend events when requested. + +pub mod connection_manager; +mod database; +pub mod state; +pub mod warnings; + +use crate::backend::core::connection_manager::Connection; +use crate::backend::core::state::State; +use crate::backend::domain::application::{Application, ConnectionStatus}; +use crate::features::applications::{ExportEntry, TimestampEntry}; +use crate::utils::error::Error as TraceError; +use anyhow::Result; +use chrono::{DateTime, Local, TimeZone}; +use connection_manager::{ConnectionManager, Event}; +use log::{debug, error, info}; +use std::path::Path; +use std::sync::Arc; +use tauri::{AppHandle, Emitter as _}; +use tokio::fs; +use tokio::sync::mpsc::{self, Receiver}; +use url::Url; +use uuid::Uuid; + +/// Top-level orchestrator for application state and connections. +/// +/// - Loads or initializes the persistent `State`. +/// - Spawns a `ConnectionManager` to handle gRPC streams to instrumented apps. +/// - Listens for update events and dispatches them into the `State`. +/// - Provides methods to add, enable/disable, delete, and list applications. +/// - Emits Tauri events containing the latest tasks, resources, polls, and apps. +pub struct StateManager { + /// Handles all gRPC connections and streams of events. + pub connection_manager: ConnectionManager, + + /// In-memory and persistent domain state. + pub state: State, +} + +impl StateManager { + /// Initialize a new `StateManager`. + /// + /// Attempts to load the previous `State` from disk. If loading fails with + /// a non‐recoverable error (`CannotCreateStorage`), returns an error. On + /// any other load failure, logs and falls back to a fresh `State::new()`. + /// + /// Also creates a channel: + /// - `Receiver<(Uuid, Event)>` for application events + /// + /// # Returns + /// `(StateManager, rx) + pub async fn new() -> Result<(StateManager, Receiver<(Uuid, Event)>), TraceError> { + // Load or initialize the persisted state + let state = match State::load().await { + // State loaded successfully + Ok(state) => state, + Err(error) => { + match error { + // Could not create storage location, + TraceError::CannotCreateStorage { error, path } => { + return Err(TraceError::CannotCreateStorage { error, path }) + } + // For any other errors we use a fresh state + err => { + error!("Failed to load previous state due to {err:?}. Using new State instance"); + State::new() + } + } + } + }; + + // Create channel for events + let (tx, rx) = mpsc::channel::<(Uuid, Event)>(100); + // Initialize the connection manager + let connection_manager = ConnectionManager::new(tx.clone()); + + let context = StateManager { + connection_manager, + state, + }; + + Ok((context, rx)) + } + + /// Main event loop. + /// + /// - Reconnects all applications known in `State` at startup. + /// - Waits for `(Uuid, Event)` messages from the `ConnectionManager`. + /// - On each event, dispatches to the appropriate `State` handler: + /// - `Event::Update` → task / resource / async_op updates + /// - `Event::ApplicationUpdated` → process stats + /// - `Event::Connecting` / `Connected` / `Disconnected` / `Error` → connection status + pub async fn run(&self, mut updates_receiver: Receiver<(Uuid, Event)>) { + self.reconnect_all_apps().await; + let mut skip_first_update = true; + + loop { + tokio::select! { + // Received updates about apps + Some((app_id, event)) = updates_receiver.recv() => { + match event { + Event::Update(update) => { + let mut warnings = Vec::new(); + let task_future = async { + if let Some(task_update) = update.task_update { + warnings.extend(self.state.handle_task_update(app_id, task_update).await); + } + }; + + let resource_future = async { + if let Some(resource_update) = update.resource_update { + let update_time = { + if update.now.is_some() { + let received_update_time = update.now + .as_ref() + .expect("we just tested is_some()"); + let dt_local: DateTime<Local> = Local + .timestamp_opt(received_update_time.seconds, received_update_time.nanos as u32) + .single() + .expect("timestamp invalid"); + Some(dt_local) + } else { + None + } + }; + self.state.handle_resource_update(app_id, resource_update, update_time).await; + } + }; + + let async_op_future = async { + if let Some(async_op_update) = update.async_op_update{ + if skip_first_update { + skip_first_update = false; + } + else{ + self.state.handle_async_op_update(app_id, async_op_update).await; + } + } + }; + + tokio::join!(task_future, resource_future, async_op_future); + info!("{:?}", warnings); + }, + + Event::ApplicationUpdated(update) => { + self.state.handle_app_update(app_id, update).await; + }, + + Event::Connecting => { + info!("Connecting.."); + self.state.handle_app_conn_update(app_id, ConnectionStatus::Connecting).await; + }, + + Event::Connected => { + info!("Connected"); + self.state.handle_app_conn_update(app_id, ConnectionStatus::Connected).await; + }, + + Event::Disconnected => { + info!("Disconnected app"); + self.delete_connection(app_id).await; + self.state.handle_app_conn_update(app_id, ConnectionStatus::Disconnected).await; + }, + + Event::Error(err) => { + info!("Error with app connection: {err:?}"); + self.state.handle_app_conn_update(app_id, ConnectionStatus::Error(err.to_string())).await; + } + + Event::PidChanged(new_pid) => { + info!("PID changed to {new_pid}"); + self.state.handle_pid_changed(app_id, new_pid).await; + } + } + } + + // TODO: add other events receivers + // TODO: add receiver to add application and send to connection manager then update state + } + } + } + + //-------------------------------------------------------------------------- + // Application management + //-------------------------------------------------------------------------- + + /// Create, persist, and connect a new application. + /// + /// - Constructs `Application::new(title, url)?`. + /// - Spawns a gRPC connection via `ConnectionManager::connect_app`. + /// - Marks the app as enabled and stores it in `State`. + /// + /// Is also connecting to the application in order to receive updates about it + pub async fn add_application(&self, title: String, url: Url) -> Result<Uuid, TraceError> { + // Create and enable application + let mut application = Application::new(title, url)?; + let app_id = *application.id(); + + // Connect to the app + let connection = self + .connection_manager + .connect_app(*application.id(), application.url(), application.pid()) + .await?; + application.enable(connection); + + // Store app + self.state.store_app(application).await; + + Ok(app_id) + } + + /// Reconnect all previously registered applications at startup. + /// + /// For each app in `State`, calls `connect_app`. On success, marks it + /// enabled; on failure, logs an error. + pub async fn reconnect_all_apps(&self) { + let apps_list = self.state.get_current_applications_list().await; + for app in apps_list { + info!("Reconnecting {} (PID {})", app.title(), app.pid()); + let connection_result = self + .connection_manager + .connect_app(*app.id(), app.url().clone(), app.pid()) + .await + .map_err(|err| { + error!("Failed to reconnect app at startup {:?}", err); + err + }); + + if let Ok(connection) = connection_result { + debug!("Enabling app {}", app.title()); + self.state.enable_app(*app.id(), connection).await; + } else { + // TODO + todo!(); + } + } + } + + /// Disable an application’s updates without deleting its record. + pub async fn disable_application(&self, uuid: Uuid) -> Result<(), TraceError> { + self.state.disable_app(uuid).await + } + + /// Edits an existing application identified by its UUID. + /// + /// This asynchronous function performs the following steps: + /// 1. Deletes the application with the specified UUID. + /// 2. Renames the application key from `old_title` to `app_title` in the internal state. + /// 3. Parses the new application URL and adds the updated application with the new title and URL. + /// + /// # Parameters + /// + /// * `uuid` - The unique identifier of the application to be edited. + /// * `app_title` - The new title for the application. + /// * `app_url` - The new URL associated with the application. + /// * `old_title` - The old title of the application to be replaced. + /// + /// # Returns + /// + /// Returns a `Result` which is: + /// - `Ok(Uuid)` with the UUID of the updated application if successful. + /// - `Err(TraceError)` if any step fails, including deletion, renaming, URL parsing, or addition. + pub async fn edit_application( + &self, + uuid: Uuid, + app_title: String, + app_url: String, + old_title: String, + ) -> Result<Uuid, TraceError> { + self.delete_application(uuid).await?; + self.state.edit_app(app_title.clone(), old_title).await?; + let url = Url::parse(&app_url)?; + self.add_application(app_title.clone(), url).await + } + + /// Delete an application from state. + /// + /// Disconnects it (if enabled), removes its folder on disk, and + /// deletes it from the in-memory `State`. + pub async fn delete_application(&self, uuid: Uuid) -> Result<Uuid, TraceError> { + self.state.delete_application(uuid).await; + Ok(uuid) + } + + pub async fn _enable_application(&self, uuid: Uuid, connection: Connection) { + self.state.enable_app(uuid, connection).await + } + /// List all applications (enabled or not). + pub(crate) async fn current_applications(&self) -> Vec<Arc<Application>> { + self.state.get_current_applications_list().await + } + + pub async fn delete_connection(&self, uuid: Uuid) { + self.connection_manager.disconnect_app(uuid).await; + // self.state.delete_app(uuid).await + } + + /// Emit the latest PID for `app_id` to the frontend. + /// + /// Listeners should handle the `"update:pid"` event. + pub async fn emit_update_pid(&self, app_handle: &AppHandle, app_id: Uuid) { + // Fetch the latest PID from State + let maybe_pid = { + let apps = self.state.get_current_applications_list().await; + apps.into_iter() + .find(|app| *app.id() == app_id) + .map(|app| app.pid()) + }; + + if let Some(pid) = maybe_pid { + let payload = serde_json::json!({ + "id": app_id, + "pid": pid, + }); + app_handle.emit("update:pid", payload).ok(); + } + } + + // endregion + + // region UPDATES + //-------------------------------------------------------------------------- + // Frontend event emitters + //-------------------------------------------------------------------------- + + /// Emit the current tasks list to the Tauri front end. + pub async fn emit_update_tasks(&self, app_handle: &AppHandle) { + let tasks = self.state.get_tasks().await; + app_handle.emit("update:tasks", tasks).ok(); + } + + /// Emit the current applications list to the Tauri front end. + pub async fn emit_update_applications(&self, app_handle: &AppHandle) { + let elements = self.state.get_current_applications_list().await; + app_handle.emit("update:applications", elements).ok(); + } + + /// Emit the current resources list to the Tauri front end. + pub async fn emit_update_resources(&self, app_handle: &AppHandle) { + let resources = self.state.get_resources().await; + app_handle.emit("update:resources", resources).ok(); + } + + /// Emit the current polls list to the Tauri front end. + pub async fn emit_update_polls(&self, app_handle: &AppHandle) { + let polls = self.state.get_polls().await; + app_handle.emit("update:polls", polls).ok(); + } + + /// Emit the current task‐ops list to the Tauri front end. + pub async fn emit_update_tasks_op(&self, app_handle: &AppHandle) { + let tasks_op = self.state.get_tasks_ops().await; + app_handle.emit("update:tasks_ops", tasks_op).ok(); + } + + /// Checks duplicates by url and title + pub async fn ensure_not_connected(&self, title: &str, url: &str) -> Result<(), TraceError> { + let applications = self.current_applications().await; + + for app in applications { + if app.url().to_string() == url { + return Err(TraceError::ApplicationAlreadyConnected(url.to_string())); + } + if app.title() == title { + return Err(TraceError::ApplicationAlreadyConnected(title.to_string())); + } + } + Ok(()) + } + + /// Validates duplicates, then parses the url and adds the app + pub async fn add_application_if_absent( + &self, + title: String, + url: &str, + ) -> Result<uuid::Uuid, TraceError> { + self.ensure_not_connected(&title, url).await?; + let url: url::Url = url.try_into()?; + self.add_application(title, url).await + } + + /// enables flow: find app, connect via manager, mark it enabled + pub async fn enable_app(&self, uuid: Uuid) -> Result<(), TraceError> { + let apps = self.state.get_current_applications_list().await; + let app = apps + .iter() + .find(|a| a.id() == &uuid) + .ok_or_else(|| TraceError::Anyhow(anyhow::anyhow!("App {uuid} not found")))?; + + // ask the existing connection manager to connect. + let conn: Connection = self + .connection_manager + .connect_app(*app.id(), app.url().clone(), app.pid()) + .await?; + + info!("enable_app: {uuid}"); + self.state.enable_app(uuid, conn).await; + Ok(()) + } + + /// scans the given exports base and returns directories as ExportEntry, empty if missing + pub async fn list_exports_from_base( + &self, + exports_base: &Path, + ) -> Result<Vec<ExportEntry>, String> { + let mut out: Vec<ExportEntry> = Vec::new(); + + let mut dir = match fs::read_dir(exports_base).await { + Ok(rd) => rd, + Err(_) => return Ok(out), + }; + + while let Some(entry) = dir.next_entry().await.map_err(|e| e.to_string())? { + let file_type = entry.file_type().await.map_err(|e| e.to_string())?; + if file_type.is_dir() { + let folder_name = entry.file_name().to_string_lossy().to_string(); + let title = folder_name.replace('-', " "); + out.push(ExportEntry { + app_dir: folder_name, + title, + }); + } + } + Ok(out) + } + + pub async fn app_timestamps( + &self, + storage_folder: String, + app_dir: String, + ) -> Result<Vec<TimestampEntry>, String> { + let app_folder = Path::new(&storage_folder).join("exports").join(&app_dir); + let mut out: Vec<TimestampEntry> = Vec::new(); + + let read_dir = match fs::read_dir(&app_folder).await { + Ok(rd) => rd, + Err(_) => return Ok(out), + }; + + let mut dir = read_dir; + while let Some(entry) = dir.next_entry().await.map_err(|e| e.to_string())? { + let meta = entry.file_type().await.map_err(|e| e.to_string())?; + if meta.is_dir() { + let ts_name = entry.file_name().to_string_lossy().to_string(); + let comment_path = app_folder.join(&ts_name).join("comment.txt"); + let comment_preview = match fs::read_to_string(&comment_path).await { + Ok(s) => { + let s = s.trim().to_string(); + if s.is_empty() { + None + } else { + Some(if s.len() > 200 { + s[..200].to_string() + "..." + } else { + s + }) + } + } + Err(_) => None, + }; + out.push(TimestampEntry { + ts: ts_name.clone(), + path: app_folder.join(&ts_name).to_string_lossy().to_string(), + comment_preview, + }); + } + } + + out.sort_by(|a, b| b.ts.cmp(&a.ts)); + Ok(out) + } + + pub async fn import_from_exp_folder( + &self, + storage_folder: String, + app_dir: String, + ts: String, + ) -> Result<(), String> { + let base = Path::new(&storage_folder) + .join("exports") + .join(&app_dir) + .join(&ts); + + if !base.exists() { + return Err(format!( + "Export folder not found: {}", + base.to_string_lossy() + )); + } + self.state + .import_from_export_folder(base) + .await + .map_err(|e| format!("Import error: {}", e))?; + + Ok(()) + } +} diff --git a/src-tauri/src/backend/core/state.rs b/src-tauri/src/backend/core/state.rs new file mode 100644 index 0000000..dfd528f --- /dev/null +++ b/src-tauri/src/backend/core/state.rs @@ -0,0 +1,1236 @@ +use super::connection_manager::{AppUpdate, Connection}; +use super::database::Database; +use crate::backend::core::warnings::TaskWarnings; +use crate::backend::domain::application::{ApplicationState, ConnectionStatus}; +use crate::backend::domain::async_op::{CPUOverview, TaskOp}; +use crate::backend::domain::has_app_name::HasAppName; +use crate::backend::domain::resource::ResourceStatus; +use crate::backend::domain::TaskState; +use crate::backend::infra::guard::{DataBaseWrite, WriteableDataBaseGuard}; +use crate::backend::infra::storage::Storage; +use crate::utils::common::get_pid_hosting_at; +use std::fmt::Debug; + +use crate::utils::error::Error as TraceError; +use crate::{ + backend::domain::{application::Application, poll::Poll, resource::Resource, Task}, + backend::mappers::{ + async_ops::map_to_domain_async_op, poll::map_to_domain_poll, + resources::map_to_domain_resource, tasks::map_to_domain_task, + }, +}; +use chrono::{DateTime, Local, TimeZone}; +use console_api::async_ops::AsyncOpUpdate; +use console_api::resources::ResourceUpdate; +use console_api::tasks::TaskUpdate; +use log::{debug, error, info, warn}; +use serde::Serialize; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::fs; +use uuid::Uuid; + +/// Manages access to persistent storage and provides high-level methods +/// for reading and writing application, task and resource state. +pub struct State { + database: Arc<dyn Storage>, +} + +impl Default for State { + fn default() -> Self { + Self::new() + } +} + +impl State { + const STORAGE_FOLDER: &str = ".async-tracing"; + + /// Constructs a new `State` backed by an empty in‐memory database. + /// + /// This will not load any existing data from disk. It is intended for + /// use when a previous load operation failed and a fresh start is required. + pub fn new() -> Self { + let path = dirs::home_dir().unwrap().join(Self::STORAGE_FOLDER); + info!("Storage location is: {path:?}"); + + Self { + database: Arc::new(Database::new(path.as_path().to_string_lossy().to_string())), + } + } + + /// Loads state from the file system into memory. + /// + /// This will create the storage directory if it does not exist, + /// then load all persisted applications and tasks. Upon success, + /// it also refreshes process IDs of running applications. + /// + /// # Errors + /// + /// Returns [`TraceError::CannotCreateStorage`] if the storage directory + /// cannot be created, or any other error from loading the underlying database. + pub async fn load() -> Result<State, TraceError> { + let database_path = dirs::home_dir().unwrap().join(Self::STORAGE_FOLDER); + info!("Storage location is: {database_path:?}"); + + // Checking if storage folder exists + if !database_path.is_dir() { + // Create the storage folder + if let Err(error) = fs::create_dir(&database_path).await { + error!( + "Could not create the storage folder at path {database_path:?} due to {error:?}" + ); + return Err(TraceError::CannotCreateStorage { + error: error.into(), + path: database_path.to_string_lossy().to_string(), + }); + } + } + + let database = + Database::load(database_path.as_path().to_string_lossy().to_string()).await?; + + // Refresh PIDs for applications whose host process may have changed + let mut guard = database.applications_write().await; + for (_uuid, app) in guard.iter_mut() { + if let Ok(pid) = get_pid_hosting_at(app.url().clone()) { + if app.pid() != pid { + debug!("Updating the PID for app {} to {}", app.title(), pid); + app.writeable().set_pid(pid); + debug!("Checking pid {}", app.pid()); + } + } + } + drop(guard); + + Ok(State { + database: Arc::new(database), + }) + } + + /// Export an application instance into a folder under + /// <storage>/exports/<app-name>/<timestamp>. + /// On success, exported entities are removed from permanent DB and written to disk. + pub async fn export_app_instance( + &self, + title: String, + name: String, + ) -> Result<std::path::PathBuf, TraceError> { + self.database.export_app_instance(title, name).await + } + + /// Import entities from an export folder on disk. The folder should contain + /// the JSON files (applications.json, tasks.json, resources.json, polls.json, async_ops.json, tasks_ops.json). + /// Imported entries overwrite the in-memory view for those application IDs and their related entities. + pub async fn import_from_export_folder(&self, p0: PathBuf) -> Result<(), String> { + self.database.import_from_export_folder(p0).await + } + + // region APPLICATIONS + + /// Retrieves the title of the application identified by `id`. + /// + /// # Returns + /// + /// - `Some(String)` containing the application title if found. + /// - `None` if no application with that ID exists. + pub async fn get_application_name_by_id(&self, id: &Uuid) -> Option<String> { + self.database + .applications_read() + .await + .get(id) + .map(|app| app.title().to_string()) + } + + /// Returns a list of all currently stored applications. + /// + /// The returned vector contains an `Arc<Application>` for each + /// application in the database. + pub(crate) async fn get_current_applications_list(&self) -> Vec<Arc<Application>> { + self.database + .applications_read() + .await + .values() + .cloned() + .collect() + } + + /// Persists a new application in the database. + /// + /// If an application with the same ID already exists, it will be + /// overwritten. + pub(crate) async fn store_app(&self, application: Application) { + self.database + .applications_write() + .await + .insert(*application.id(), Arc::new(application)); + } + + /// Disables the application with the given `app_id`. + /// + /// This sets the internal state of the application to `Disabled`, + /// preventing further updates from being recorded. + /// + /// # Errors + /// + /// Always returns `Ok(())` but may fail silently if the write + /// lock cannot be acquired. + pub async fn disable_app(&self, app_id: Uuid) -> Result<(), TraceError> { + let mut guard = self.database.applications_write().await; + + if let Some((_uuid, application)) = + guard.iter_mut().find(|(_uuid, app)| app.id().eq(&app_id)) + { + let app = application.writeable(); + app.disable().await; + } + + Ok(()) + } + + /// Renames the application title across multiple internal database collections. + /// + /// This asynchronous function updates references of an application's title + /// from `old_title` to `new_title` in various database sections: + /// - Async operations (`async_ops`) + /// - Resources (`resources`) + /// - Tasks (`tasks`) + /// - Task operations (`tasks_ops`) + /// - Polls (`polls`) + /// + /// For collections that store values implementing the `HasAppName` trait, + /// the function also updates the `app_name` field accordingly. + /// + /// # Parameters + /// + /// * `new_title` - The new application title to replace the old one. + /// * `old_title` - The old application title that needs to be replaced. + /// + /// # Returns + /// + /// Returns `Ok(())` if all renaming operations succeed, + /// or a `TraceError` if any step encounters an error. + pub async fn edit_app(&self, new_title: String, old_title: String) -> Result<(), TraceError> { + { + let mut async_guard = self.database.async_ops_write().await; + Self::rename_database_keys(&mut async_guard, new_title.clone(), old_title.clone()); + } + + { + let mut resource_guard = self.database.resources_write().await; + Self::rename_database_keys_and_app_name( + &mut resource_guard, + new_title.clone(), + old_title.clone(), + ); + } + + { + let mut tasks_guard = self.database.tasks_write().await; + Self::rename_database_keys_and_app_name( + &mut tasks_guard, + new_title.clone(), + old_title.clone(), + ); + } + + { + let mut tasks_ops_guard = self.database.tasks_ops_write().await; + Self::rename_database_keys(&mut tasks_ops_guard, new_title.clone(), old_title.clone()); + } + + { + let mut poll_guard = self.database.polls_write().await; + for poll_arc in poll_guard.iter_mut() { + let poll_mut = Arc::make_mut(poll_arc); + if poll_mut.app_name == Some(old_title.clone()) { + poll_mut.app_name = Some(new_title.clone()); + } + } + } + Ok(()) + } + + /// Enables the application with the given `app_id` and sets its + /// connection information. + /// + /// After enabling, updates for this application will once again + /// be recorded. + pub async fn enable_app(&self, app_id: Uuid, connection: Connection) { + let mut guard = self.database.applications_write().await; + + if let Some((_uuid, application)) = + guard.iter_mut().find(|(_uuid, app)| app.id().eq(&app_id)) + { + let app = application.writeable(); + app.enable(connection); + } + } + + /// Deletes the application identified by `app_id` from storage. + /// + /// All associated tasks remain in the database unless explicitly + /// removed by other operations. + pub async fn delete_application(&self, app_id: Uuid) { + self.database.applications_write().await.remove(&app_id); + } + + // endregion + + // region TASKS + + /// Applies a batch of task updates for the application `app_id`. + /// + /// This method will: + /// - Insert any new tasks reported in `task_update.new_tasks`. + /// - Update runtime, busy time, idle time, schedule time, and + /// created timestamp for existing tasks. + /// - Mark tasks as stopped if they have been dropped. + /// + /// If the application is currently disabled, updates are ignored. + pub async fn handle_task_update(&self, app_id: Uuid, task_update: TaskUpdate) -> Vec<String> { + let mut warnings: Vec<String> = Vec::new(); + + // debug for missed task_updates + if task_update.dropped_events > 0 { + info!("missed task updates: {:?}", task_update.dropped_events); + } + + if let Some(app) = self.database.applications_read().await.get(&app_id) { + if app.state() == ApplicationState::Disabled { + // If app is disabled we dont save anything + return Vec::new(); + } + + // Insert new tasks + for raw in task_update.new_tasks { + if let Some(mut domain_task) = map_to_domain_task(app_id, &raw) { + domain_task.app_name = Some(app.title().to_string()); + info!( + "Received a new task for app '{}' (id {})", + app.title(), + app_id + ); + + { + let mut guard = self.database.active_tasks_write().await; + guard.insert(domain_task.id().clone()); + } + + { + let tasks = self.database.tasks_read().await; + if let Some(task) = tasks.get(&domain_task.id().to_string()) { + domain_task.warnings = task.warnings.clone(); + } + } + + self.database + .tasks_write() + .await + .insert(domain_task.id(), Arc::new(domain_task)); + } + } + + let mut active_tasks_clone = self.database.active_tasks_read().await; + + // Update existing tasks + for (tid, updated_task) in task_update.stats_update { + let mut tasks_guard = self.database.tasks_write().await; + let key = format!("{}.{}", app.title(), tid); + if let Some(task_arc) = tasks_guard.get_mut(&key) { + let task = Arc::make_mut(task_arc); + + // Handle busy time & poll stats + if let Some(poll_stats) = updated_task.poll_stats { + if let Some(dur) = poll_stats.busy_time { + task.busy = Some(Duration::new(dur.seconds as u64, dur.nanos as u32)); + } + + task.stats.last_poll_started = + poll_stats.last_poll_started.map(|v| v.try_into().unwrap()); + task.stats.last_poll_ended = + poll_stats.last_poll_ended.map(|v| v.try_into().unwrap()); + task.stats.polls = poll_stats.polls; + } + + // Handle runtime & stop detection + if let Some(dropped_at) = updated_task.dropped_at { + if !matches!(task.state, TaskState::Stopped { at: _, reason: _ }) { + info!("Marking task {} as Stopped", key); + task.state = TaskState::Stopped { + at: Local::now(), + reason: None, + }; + { + let mut active_tasks_guard = + self.database.active_tasks_write().await; + active_tasks_guard.remove(&task.id()); + active_tasks_clone.remove(&task.id()); + } + if let Some(created_at) = updated_task.created_at { + task.runtime = { + let mut seconds = dropped_at.seconds - created_at.seconds; + let mut nano = dropped_at.nanos - created_at.nanos; + if nano < 0 { + seconds -= 1; + nano += 1_000_000_000; + } + Some(Duration::new(seconds as u64, nano as u32)) + }; + } + } + } else { + // Update runtime based on current time + let now = SystemTime::now(); + let duration_since_epoch = + now.duration_since(UNIX_EPOCH).expect("Time went backwards"); + + if let Some(created_at) = updated_task.created_at { + task.runtime = { + let mut seconds = + (duration_since_epoch.as_secs() as i64) - created_at.seconds; + let mut nano = + (duration_since_epoch.subsec_nanos() as i32) - created_at.nanos; + + if nano < 0 { + seconds -= 1; + nano += 1_000_000_000; + } + + Some(Duration::new(seconds as u64, nano as u32)) + }; + } + } + + // Handle scheduled time + if let Some(scheduled) = updated_task.scheduled_time { + task.scheduled = Some(Duration::new( + scheduled.seconds as u64, + scheduled.nanos as u32, + )) + } + + // Compute idle time + task.idle = { + match task.runtime { + Some(runtime_duration) => { + let mut seconds = runtime_duration.as_secs(); + let mut nanos = runtime_duration.as_nanos(); + if let Some(busy_duration) = task.busy { + if let Some(schedule_duration) = task.scheduled { + seconds = seconds + - busy_duration.as_secs() + - schedule_duration.as_secs(); + nanos = nanos + - busy_duration.as_nanos() + - schedule_duration.as_nanos(); + } else { + seconds -= busy_duration.as_secs(); + nanos -= busy_duration.as_nanos(); + } + } + Some(Duration::new(seconds, nanos as u32)) + } + None => None, + } + }; + + // Format created_at timestamp if not yet set + if task.created_at.is_none() { + if let Some(created_ts) = updated_task.created_at.as_ref() { + let dt_local: DateTime<Local> = Local + .timestamp_opt(created_ts.seconds, created_ts.nanos as u32) + .single() + .expect("invalid timestamp"); + + task.created_at = Some(dt_local); + } + } + + task.stats.last_wake = updated_task.last_wake.map(|v| v.try_into().unwrap()); + task.stats.wakes = updated_task.wakes; + task.stats.self_wakes = updated_task.self_wakes; + task.stats.waker_clones = updated_task.waker_clones; + task.stats.waker_drops = updated_task.waker_drops; + + if task.is_starved() { + task.state = TaskState::Starved; + } else { + task.state = TaskState::Running; + } + + //check for warnings + warnings.extend(task.check_warnings()); + active_tasks_clone.remove(&task.id()); + } + } + let tasks = self.database.tasks_read().await; + for id in active_tasks_clone { + if let Some(task) = tasks.get(&id) { + warnings.extend(task.check_warnings()); + } + } + warnings + } else { + Vec::new() + } + } + + /// Updates CPU and memory usage for the application `app_id`. + /// + /// If the application is disabled, this update will be ignored. + /// If `app_id` is not found, a warning is logged. + pub async fn handle_app_update(&self, app_id: Uuid, update: AppUpdate) { + let mut guard = self.database.applications_write().await; + if let Some((_uuid, app)) = guard.iter_mut().find(|(_uuid, app)| app.id().eq(&app_id)) { + if app.state() == ApplicationState::Disabled { + // If app is disabled we dont save anything + } else { + let writeable_app = app.writeable(); + if let Some(cpu_usage) = update.cpu_usage { + writeable_app.set_cpu_usage(cpu_usage); + } + writeable_app.set_memory_usage(update.memory_usage); + } + } else { + warn!("Received an application update for an app that is not registered"); + } + } + + /// Updates connection status for the application `app_id`. + /// + /// If the status is `Disconnected` or `Error`, all running tasks for + /// that app are marked as stopped. If the application is disabled, + /// the update is ignored. If `app_id` is not found, a warning is logged. + pub(crate) async fn handle_app_conn_update(&self, app_id: Uuid, conn_status: ConnectionStatus) { + let mut guard = self.database.applications_write().await; + if let Some((_uuid, app)) = guard.iter_mut().find(|(_uuid, app)| app.id().eq(&app_id)) { + // For disconnects or errors, stop all running tasks of the app + if matches!(conn_status, ConnectionStatus::Disconnected) + || matches!(conn_status, ConnectionStatus::Error(_)) + { + { + let mut tasks_guard = self.database.tasks_write().await; + let prefix = format!("{}.", app.title()); + for (key, task_arc) in tasks_guard.iter_mut() { + if key.starts_with(&prefix) { + let t = Arc::make_mut(task_arc); + if !matches!(t.state, TaskState::Stopped { at: _, reason: _ }) { + t.state = TaskState::Stopped { + at: Local::now(), + reason: None, + }; + + // maybe update the runtime, busy, schedule as well or just runtime + } + } + } + } + + { + let mut resources_guard = self.database.resources_write().await; + let prefix = format!("{}.", app.title()); + for (key, resource_arc) in resources_guard.iter_mut() { + if key.starts_with(&prefix) { + let r = Arc::make_mut(resource_arc); + if matches!(r.status, ResourceStatus::Ready) { + r.status = ResourceStatus::Dropped; + } + } + } + } + } + // Update app connection status + if app.state() == ApplicationState::Disabled { + // If app is disabled we dont save anything + } else { + let writeable_app = app.writeable(); + writeable_app.set_connection_status(conn_status); + } + } else { + warn!("Received an application update for an app that is not registered"); + } + } + + /// Update the PID for an application in memory and persist it. + pub async fn handle_pid_changed(&self, app_id: Uuid, new_pid: u32) { + let mut apps = self.database.applications_write().await; + if let Some(app) = apps.get_mut(&app_id) { + app.writeable().set_pid(new_pid); + } + } + + /// Lookup the current PID for an application. + pub async fn get_pid_for(&self, app_id: Uuid) -> Option<u32> { + let apps = self.database.applications_read().await; + apps.get(&app_id).map(|app| app.pid()) + } + + /// Returns all tasks currently stored in memory. + pub async fn get_tasks(&self) -> Vec<Arc<Task>> { + self.database.tasks_read().await.values().cloned().collect() + } + + /// Renames a task, updates its display color, and propagates the changes + /// to any related poll records and task operations. + /// + /// - `task_id`: The numeric identifier of the task within its application. + /// - `task_name`: The new human-readable name to assign to the task. + /// - `task_color`: The new color code (e.g. hex string) to use when rendering the task. + /// - `app_name`: The title of the application to which the task belongs. + /// + /// This method will: + /// 1. Look up the task in the in-memory task store and update + /// its `name` and `color` fields. + /// 2. Iterate over all persisted polls, matching on `task_id`, + /// and update each poll’s `task_name` and `task_color`. + /// 3. If there is an entry in the task‐operations store matching + /// the same key, update its `task_name` and `task_color` as well. + pub async fn edit_state_task( + &self, + task_id: u64, + task_name: String, + task_color: String, + app_name: String, + warnings: TaskWarnings, + ) -> Result<(), TraceError> { + let key = format!("{}.{}", app_name, task_id); + + let mut tasks = self.database.tasks_write().await; + let Some(task_arc) = tasks.get_mut(&key) else { + warn!("Edit failed, task not found. key={key}"); + return Err(TraceError::TaskNotFound(key)); + }; + + let task = Arc::make_mut(task_arc); + let old_name = task.name.clone(); + let old_color = task.color.clone(); + + task.name = Some(task_name.clone()); + task.color = Some(task_color.clone()); + task.warnings = warnings; + + drop(tasks); + + if (old_name != Some(task_name.clone())) || (old_color != Some(task_color.clone())) { + let mut polls = self.database.polls_write().await; + // Update all polls associated with this task + for poll_arc in polls.iter_mut().filter(|p| p.task_id == Some(task_id)) { + let mut updated = (**poll_arc).clone(); + updated.task_name = Some(task_name.clone()); + updated.task_color = Some(task_color.clone()); + *poll_arc = Arc::new(updated); + } + drop(polls); + + let mut ops = self.database.tasks_ops_write().await; + + // Update any task-operation entries + if let Some(task_op_arc) = ops.get_mut(&key) { + let task_op = Arc::make_mut(task_op_arc); + task_op.task_name = Some(task_name.clone()); + task_op.task_color = Some(task_color.clone()); + } + + info!("Edited task key={key} name={task_name} color={task_color}"); + } else { + debug!("No chages were made for task key={key}, skipping updates"); + } + + Ok(()) + } + + // endregion + + // region RESOURCES + Polls + + /// Processes a batch of resource updates and new poll operations for + /// a given application. + /// + /// - `app_id`: The UUID of the application reporting the update. + /// - `resources_update`: The incoming `ResourceUpdate` event payload. + /// - `received_at`: An optional timestamp string indicating when the + /// update was received (used for labeling new polls). + /// + /// This method will: + /// 1. Insert any new resources into the in-memory resource store, + /// tagging them with the application name. + /// 2. Update existing resources’ status, duration, and attribute + /// fields based on the `stats_update` section. + /// 3. Convert any new poll operations into domain `Poll` objects, + /// enriching them with known resource location, name, and task + /// metadata, then append them to the poll log. + /// + /// If the application is currently disabled, all updates are ignored. + pub async fn handle_resource_update( + &self, + app_id: Uuid, + resources_update: ResourceUpdate, + received_at: Option<DateTime<Local>>, + ) { + // debug for missed resources_updates + if resources_update.dropped_events > 0 { + info!( + "missed resources updates: {:?}", + resources_update.dropped_events + ); + } + + if let Some(app) = self.database.applications_read().await.get(&app_id) { + if app.state() == ApplicationState::Disabled { + return; + } + + // 1. Insert new resources + for raw in resources_update.new_resources { + if let Some(mut domain_resource) = map_to_domain_resource(&raw) { + domain_resource.app_name = Some(app.title().to_string()); + info!( + "Received a new resource for app '{}' (id {})", + app.title(), + app_id + ); + self.database.resources_write().await.insert( + domain_resource.id().expect("Error"), + Arc::new(domain_resource), + ); + } + } + + // 2. Update existing resources + for (id, updated_resource) in resources_update.stats_update { + let mut resource_guard = self.database.resources_write().await; + let key = format!("{}.{}", app.title(), id); + if let Some(resource_arc) = resource_guard.get_mut(&key) { + let resource = Arc::make_mut(resource_arc); + + // Handle dropped vs. running durations + if let Some(dropped_at) = updated_resource.dropped_at { + if !matches!(resource.status, ResourceStatus::Dropped) { + info!("Marking resource {} as Stopped", key); + resource.status = ResourceStatus::Dropped; + if let Some(created_at) = updated_resource.created_at { + resource.duration = { + let mut seconds = dropped_at.seconds - created_at.seconds; + let mut nano = dropped_at.nanos - created_at.nanos; + if nano < 0 { + seconds -= 1; + nano += 1_000_000_000; + } + Some(Duration::new(seconds as u64, nano as u32)) + }; + } + } + } else { + let now = SystemTime::now(); + let duration_since_epoch = + now.duration_since(UNIX_EPOCH).expect("Time went backwards"); + + if let Some(created_at) = updated_resource.created_at { + resource.duration = { + let mut seconds = + (duration_since_epoch.as_secs() as i64) - created_at.seconds; + let mut nano = + (duration_since_epoch.subsec_nanos() as i32) - created_at.nanos; + if nano < 0 { + seconds -= 1; + nano += 1_000_000_000; + } + Some(Duration::new(seconds as u64, nano as u32)) + }; + } + } + + // Build a multi-line attributes string + let mut attribute_str = String::from(""); + for attr in updated_resource.attributes { + if let Some(field) = attr.field { + if let Some(name) = field.name { + // Field name + match name { + //if the name is a String, we concatenate directly + console_api::field::Name::StrName(s) => { + attribute_str = attribute_str + &s; + } + + //if attribute name is an index from metadata.field_names + console_api::field::Name::NameIdx(_) => { + // TODO: Handle NameIdx variants + } + } + // Field value + if let Some(value) = field.value { + attribute_str += ": "; + + match value { + console_api::field::Value::DebugVal(val) => { + attribute_str = attribute_str + &val + } + console_api::field::Value::StrVal(val) => { + attribute_str = attribute_str + &val + } + console_api::field::Value::U64Val(val) => { + attribute_str = attribute_str + (&val.to_string()) + } + console_api::field::Value::I64Val(val) => { + attribute_str = attribute_str + (&val.to_string()) + } + console_api::field::Value::BoolVal(val) => { + attribute_str = attribute_str + (&val.to_string()) + } + } + } + + if let Some(s) = attr.unit { + attribute_str = attribute_str + &s; + } + } + } + attribute_str += "\n"; + } + if !attribute_str.is_empty() { + resource.attributes = Some(attribute_str); + } + } + } + + // 3. Append new poll operations + for raw in resources_update.new_poll_ops { + if let Some(mut domain_poll) = map_to_domain_poll(&raw) { + domain_poll.app_name = Some(app.title().to_string()); + domain_poll.received_at = received_at; + + // Enrich from resource metadata + if let Some(resource_id) = domain_poll.resource_id { + let key = format!("{}.{}", app.title(), resource_id); + let resources = self.database.resources_read().await; + if let Some(resource_arc) = resources.get(&key) { + if let Some(loc) = resource_arc.location.clone() { + domain_poll.location = Some(loc); + } + if let Some(name) = resource_arc.target.clone() { + domain_poll.resource_name = Some(name); + } + } + + info!( + "Received a new poll_op for app '{}' (id {})", + app.title(), + app_id + ); + } + + // Enrich from task metadata + if let Some(task_id) = domain_poll.task_id { + let key = format!("{}.{}", app.title(), task_id); + let tasks = self.database.tasks_read().await; + if let Some(task_arc) = tasks.get(&key) { + if let Some(task_name) = task_arc.name.clone() { + domain_poll.task_name = Some(task_name); + } + } + } + + self.database + .polls_write() + .await + .push(Arc::new(domain_poll)); + } + } + } + } + + /// Returns a list of all resources currently stored in memory. + /// + /// Each entry is an `Arc<Resource>` representing the latest known + /// state of that resource. + pub(crate) async fn get_resources(&self) -> Vec<Arc<Resource>> { + self.database + .resources_read() + .await + .values() + .cloned() + .collect() + } + + /// Returns the sequence of recorded `Poll` events. + /// + /// Each `Poll` is wrapped in an `Arc`. The returned vector preserves + /// insertion order. + pub(crate) async fn get_polls(&self) -> Vec<Arc<Poll>> { + self.database.polls_read().await + } + + // endregion + + // region Async op + + /// Processes a batch of asynchronous‐operation updates for the specified application. + /// + /// - `app_id`: UUID of the application emitting the async‐op events. + /// - `async_op_update`: An `AsyncOpUpdate` containing: + /// • `new_async_ops`: newly discovered async operations to insert + /// • `stats_update`: polling statistics for existing async operations + /// + /// This method will: + /// 1. Log and ignore any dropped event counts. + /// 2. If the application is disabled, skip all processing. + /// 3. For each new async operation: + /// – Map it into a domain `TaskOp` record. + /// – Look up its resource by ID in the resource store; if found, record the resource target. + /// – Insert the new `TaskOp` into the async‐ops store under the key `{app_title}.{op_id}`. + /// 4. For each stats update (keyed by async‐op ID): + /// – Locate the existing `TaskOp` using `{app_title}.{op_id}`. + /// – If it exists and contains a matching in‐progress CPU poll entry, + /// update its `stopped_at` timestamp when the poll ends. + /// – Otherwise, append a new `CPUOverview` entry (with `started_at` and optional `stopped_at`). + /// – If no `TaskOp` record exists, create one from scratch using any known + /// task metadata (name/color) and the new CPU overview. + pub async fn handle_async_op_update(&self, app_id: Uuid, async_op_update: AsyncOpUpdate) { + if async_op_update.dropped_events > 0 { + warn!( + "missed async_op updates: {:?}", + async_op_update.dropped_events + ); + } + + // Skip processing if the app is disabled or missing + if let Some(app) = self.database.applications_read().await.get(&app_id) { + if app.state() == ApplicationState::Disabled { + return; + } + + let make_overview = |started: DateTime<Local>, + stopped: Option<DateTime<Local>>, + resource_target: Option<String>, + resource_opt: Option<Arc<Resource>>, + app_opt: Option<&Arc<Application>>| + -> CPUOverview { + let location = resource_opt.as_ref().and_then(|res| res.location.clone()); + + let pid = app_opt.as_ref().and_then(|a| Option::from(a.pid)); + + CPUOverview { + started_at: Some(started), + stopped_at: stopped, + resource_target, + location, + pid, + } + }; + + // Insert any new async-ops + for raw in async_op_update.new_async_ops { + match map_to_domain_async_op(&raw) { + Ok(mut domain_async_op) => { + let key = format!("{}.{}", app.title(), domain_async_op.resource_id); + if let Some(resource) = self.database.resources_read().await.get(&key) { + domain_async_op.resource_target = resource.target.clone(); + self.database.async_ops_write().await.insert( + format!("{}.{}", app.title(), domain_async_op.id), + Arc::new(domain_async_op), + ); + } + } + Err(err) => error!("Error at inserting new async-ops, error: {err:?}"), + } + } + + for (id, updated_async_op) in async_op_update.stats_update { + let key = format!("{}.{}", app.title(), id); + let resource_target; + + // Attempt to fetch the existing entry and its resource target + if let Some(async_op) = self.database.async_ops_read().await.get(&key) { + let resource_id = async_op.resource_id; + let key = format!("{}.{}", app.title(), resource_id); + + if let Some(resource) = self.database.resources_read().await.get(&key) { + resource_target = resource.target.clone(); + } else { + resource_target = None; + } + } else { + resource_target = None; + } + let async_op_arc = self.database.async_ops_read().await.get(&key).cloned(); + let resource_for_resource_id = if let Some(ref ao) = async_op_arc { + let rkey = format!("{}.{}", app.title(), ao.resource_id); + self.database.resources_read().await.get(&rkey).cloned() + } else { + None + }; + match (updated_async_op.task_id, updated_async_op.poll_stats) { + (Some(task_id), Some(poll_stats)) => { + if let Some(started_at) = poll_stats.last_poll_started { + let key = format!("{}.{}", app.title(), task_id.id); + let mut map = self.database.tasks_ops_write().await; + if let Some(task_op) = map.get_mut(&key) { + let task_op = Arc::make_mut(task_op); + if let Some(last_element) = task_op.operations.last_mut() { + let overview_started_at = match last_element.started_at.as_ref() + { + Some(ts) => ts, + None => continue, + }; + if started_at.seconds == overview_started_at.timestamp() + && (started_at.nanos as u32) + == overview_started_at.timestamp_subsec_nanos() + { + if last_element.stopped_at.is_none() { + if let Some(last_poll_ended) = + &poll_stats.last_poll_ended + { + last_element.stopped_at = Some( + Local + .timestamp_opt( + last_poll_ended.seconds, + last_poll_ended.nanos as u32, + ) + .single() + .expect( + "ambiguous or nonexistent local time", + ), + ); + } else { + continue; + } + } + } else if poll_stats.last_poll_ended.is_some() { + task_op.operations.push(make_overview( + Local + .timestamp_opt( + started_at.seconds, + started_at.nanos as u32, + ) + .single() + .expect("ambiguous or nonexistent local time"), + poll_stats.last_poll_ended.map(|e| { + Local + .timestamp_opt(e.seconds, e.nanos as u32) + .single() + .expect("ambiguous or nonexistent local time") + }), + resource_target.clone(), + resource_for_resource_id.clone(), + self.database.applications_read().await.get(&app_id), + )); + } else { + task_op.operations.push(make_overview( + Local + .timestamp_opt( + started_at.seconds, + started_at.nanos as u32, + ) + .single() + .expect("ambiguous or nonexistent local time"), + None, + resource_target.clone(), + resource_for_resource_id.clone(), + self.database.applications_read().await.get(&app_id), + )); + } + } + } else { + let mut operations = Vec::new(); + if poll_stats.last_poll_ended.is_some() { + operations.push(make_overview( + Local + .timestamp_opt( + started_at.seconds, + started_at.nanos as u32, + ) + .single() + .expect("ambiguous or nonexistent local time"), + poll_stats.last_poll_ended.map(|e| { + Local + .timestamp_opt(e.seconds, e.nanos as u32) + .single() + .expect("ambiguous or nonexistent local time") + }), + resource_target.clone(), + resource_for_resource_id.clone(), + self.database.applications_read().await.get(&app_id), + )); + } else { + operations.push(make_overview( + Local + .timestamp_opt( + started_at.seconds, + started_at.nanos as u32, + ) + .single() + .expect("ambiguous or nonexistent local time"), + None::<DateTime<Local>>, + resource_target.clone(), + resource_for_resource_id.clone(), + self.database.applications_read().await.get(&app_id), + )); + } + + let task_name; + let task_color; + let key = format!("{}.{}", app.title(), task_id.id); + if let Some(task) = self.database.tasks_read().await.get(&key) { + task_name = task.name.clone(); + task_color = task.color.clone(); + } else { + task_name = None; + task_color = None; + } + + let task_op = TaskOp { + task_id: task_id.id, + task_name, + task_color, + operations: operations.clone(), + }; + + map.insert( + format!("{}.{}", app.title(), task_id.id), + Arc::new(task_op), + ); + } + } + } + _ => continue, + }; + } + } + } + + /// Retrieves all recorded asynchronous‐operation logs (CPU overviews). + /// + /// Returns a vector of `Arc<TaskOp>`, each containing the task’s + /// ID, optional name and color, and the sequence of `CPUOverview` + /// entries representing its polling history. + pub(crate) async fn get_tasks_ops(&self) -> Vec<Arc<TaskOp>> { + self.database + .tasks_ops_read() + .await + .values() + .cloned() + .collect() + } + + // endregion + + /// This is used when the user renames an application + /// + /// Renames keys in the given database guard by replacing the `old_title` prefix + /// in keys with the `new_title` prefix. The function iterates over all keys, + /// collects the keys that start with `old_title.`, and renames them accordiungly. + /// + /// # Arguments + /// + /// * `guard` - A mutable reference to a writable database guard holding a `HashMap` + /// where keys are strings and values are of generic type `T`. + /// * `new_title` - The new title string to replace the old title prefix in keys. + /// * `old_title` - The old title string prefix to be replaced in keys. + /// + /// # Type Parameters + /// + /// * `T` - The type of the values in the hashmap. Must implement `Serialize` and `Debug`. + fn rename_database_keys<T: Serialize + Debug>( + guard: &mut WriteableDataBaseGuard<'_, HashMap<String, T>>, + new_title: String, + old_title: String, + ) { + let mut changes = Vec::new(); + for key in guard.keys() { + if let Some(rest) = key.strip_prefix(&format!("{}.", old_title)) { + let new_key = format!("{}.{}", new_title, rest); + changes.push((key.clone(), new_key)); + } + } + + for (old, new) in changes { + if let Some(val) = guard.remove(&old) { + guard.insert(new, val); + } + } + } + + /// This is used when the user renames an application + /// + /// Renames keys in the given database guard by replacing the `old_title` prefix + /// in keys with the `new_title` prefix. In addition, it updates the `app_name` + /// attribute of the value associated with each renamed key. + /// + /// This function works on database guards containing `HashMap<String, Arc<T>>`, + /// where `T` must implement `Serialize`, `Debug`, `HasAppName`, and `Clone`. + /// + /// # Arguments + /// + /// * `guard` - A mutable reference to a writable database guard holding a `HashMap` + /// where keys are strings and values are `Arc` wrapped generic type `T`. + /// * `new_title` - The new title string to replace the old title prefix in keys. + /// * `old_title` - The old title string prefix to be replaced in keys. + /// + /// # Type Parameters + /// + /// * `T` - The type of the values inside the Arc in the hashmap. Must implement + /// Implements `Serialize`, `Debug`, `HasAppName` (a trait providing `set_app_name`), and `Clone`. + fn rename_database_keys_and_app_name<T: Serialize + Debug + HasAppName + Clone>( + guard: &mut WriteableDataBaseGuard<'_, HashMap<String, Arc<T>>>, + new_title: String, + old_title: String, + ) { + let mut changes = Vec::new(); + for key in guard.keys() { + if let Some(rest) = key.strip_prefix(&format!("{}.", old_title)) { + let new_key = format!("{}.{}", new_title, rest); + changes.push((key.clone(), new_key)); + } + } + + for (old, new) in changes { + if let Some(mut val_arc) = guard.remove(&old) { + let val = Arc::make_mut(&mut val_arc); + val.set_app_name(new_title.clone()); + guard.insert(new, Arc::new(val.clone())); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + use url::Url; + + async fn make_state() -> State { + let dir = tempdir().unwrap(); + let path = dir.path().to_string_lossy().to_string(); + let db = Database::new(path); + State { + database: Arc::new(db), + } + } + + #[tokio::test] + async fn test_handle_and_get_pid() { + let state = make_state().await; + + let app = Application::new_mock( + "TestApp".into(), + Url::parse("http://localhost").unwrap(), + 1234, + ); + let id = *app.id(); + + let app_arc = Arc::new(app); + state + .database + .applications_write() + .await + .insert(id, Arc::clone(&app_arc)); + + assert_eq!(state.get_pid_for(id).await, Some(1234)); + + state.handle_pid_changed(id, 5678).await; + assert_eq!(state.get_pid_for(id).await, Some(5678)); + } +} diff --git a/src-tauri/src/backend/core/warnings.rs b/src-tauri/src/backend/core/warnings.rs new file mode 100644 index 0000000..810376e --- /dev/null +++ b/src-tauri/src/backend/core/warnings.rs @@ -0,0 +1,413 @@ +use serde::{Deserialize, Serialize}; + +use crate::backend::domain::Task; +use std::{ + fmt::Debug, + time::{Duration, SystemTime}, +}; +#[allow(dead_code)] +pub trait Warn<T>: Debug { + fn is_enabled(&self) -> bool; + /// Returns if the warning applies to `val`. + fn check(&self, val: &T) -> Warning; + + /// Formats a description of the warning detected for a *specific* `val`. + /// + /// This may include dynamically formatted content specific to `val`, such + /// as the specific numeric value that was over the line for detecting the + /// warning. + /// + /// This should be a complete sentence describing the warning. For example, + /// for the [`SelfWakePercent`] warning, this returns a string like: + /// + /// > "This task has woken itself for more than 50% of its total wakeups (86%)" + fn format(&self, val: &T) -> String; + + // /// Returns a string summarizing the warning *in general*, suitable for + // /// displaying in a list of all detected warnings. + // /// + // /// The list entry will begin with a count of the number of monitored + // /// entities for which the warning was detected. Therefore, this should be a + // /// sentence fragment suitable to follow a count. For example, for the + // /// [`SelfWakePercent`] warning, this method will return a string like + // /// + // /// > "tasks have woken themselves more than 50% of the time" + // /// + // /// so that the warnings list can read + // /// + // /// > "45 tasks have woken themselves more than 50% of the time" + // /// TODO + fn summary(&self) -> &str; +} + +/// A result for a warning check +pub enum Warning { + /// No warning for this entity. + Ok, + + /// A warning has been detected for this entity. + Warn, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TaskWarnings { + self_wake_percent: SelfWakePercent, + lost_waker: LostWaker, + never_yielded: NeverYielded, + auto_boxed_feature: AutoBoxedFuture, + large_feature: LargeFuture, +} + +impl TaskWarnings { + pub(crate) fn new() -> Self { + let self_wake_percent = SelfWakePercent::default(); + let lost_waker = LostWaker::new(); + let never_yielded = NeverYielded::default(); + let auto_boxed_feature = AutoBoxedFuture::new(); + let large_feature = LargeFuture::default(); + + Self { + self_wake_percent, + lost_waker, + never_yielded, + auto_boxed_feature, + large_feature, + } + } + + pub(crate) fn check(&self, task: &Task) -> Vec<String> { + let mut warnings = Vec::new(); + + if self.self_wake_percent.is_enabled() { + match self.self_wake_percent.check(task) { + Warning::Warn => warnings.push(self.self_wake_percent.format(task)), + Warning::Ok => {} + }; + } + + if self.lost_waker.is_enabled() { + match self.lost_waker.check(task) { + Warning::Warn => warnings.push(self.lost_waker.format(task)), + Warning::Ok => {} + }; + } + + if self.never_yielded.is_enabled() { + match self.never_yielded.check(task) { + Warning::Warn => warnings.push(self.never_yielded.format(task)), + Warning::Ok => {} + } + } + + warnings + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct SelfWakePercent { + enabled: bool, + parameter: u64, + description: String, +} + +impl SelfWakePercent { + pub(crate) const DEFAULT_PERCENT: u64 = 50; + pub(crate) fn new(parameter: u64) -> Self { + Self { + enabled: true, + parameter, + description: format!( + "tasks have woken themselves over {}% of the time", + parameter + ), + } + } +} + +impl Default for SelfWakePercent { + fn default() -> Self { + Self::new(Self::DEFAULT_PERCENT) + } +} + +impl Warn<Task> for SelfWakePercent { + fn is_enabled(&self) -> bool { + self.enabled + } + fn summary(&self) -> &str { + self.description.as_str() + } + + fn check(&self, task: &Task) -> Warning { + // Don't fire warning for tasks that are not async + if task.is_blocking() { + return Warning::Ok; + } + let self_wakes = task.self_wake_percent(); + if self_wakes > self.parameter { + Warning::Warn + } else { + Warning::Ok + } + } + + fn format(&self, task: &Task) -> String { + let self_wakes = task.self_wake_percent(); + let option_task_name = task.name.clone(); + let task_name; + + if let Some(name) = option_task_name { + if !name.is_empty() { + task_name = name; + } else { + task_name = task.id(); + } + } else { + task_name = task.id(); + } + format!( + "Task {task_name} has woken itself for more than {}% of its total wakeups ({}%)", + self.parameter, self_wakes + ) + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub(crate) struct LostWaker { + enabled: bool, +} + +impl LostWaker { + fn new() -> Self { + Self { enabled: true } + } +} + +impl Warn<Task> for LostWaker { + fn is_enabled(&self) -> bool { + self.enabled + } + + fn summary(&self) -> &str { + "tasks have lost their wakers" + } + + fn check(&self, task: &Task) -> Warning { + // Don't fire warning for tasks that are not async + if task.is_blocking() { + return Warning::Ok; + } + if !task.is_completed() + && task.waker_count() == 0 + && !task.is_running() + && !task.is_awakened() + { + Warning::Warn + } else { + Warning::Ok + } + } + + fn format(&self, task: &Task) -> String { + let option_task_name = task.name.clone(); + let task_name; + if let Some(name) = option_task_name { + task_name = name + } else { + task_name = task.id() + } + format!("Task {task_name} has lost its waker, and will never be woken again.") + } +} + +/// Warning for if a task has never yielded +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct NeverYielded { + enabled: bool, + parameter: u64, + description: String, +} + +impl NeverYielded { + pub(crate) const DEFAULT_DURATION: u64 = 1000; + pub(crate) fn new(parameter: u64) -> Self { + Self { + enabled: true, + parameter, + description: format!("tasks have never yielded (threshold {}ms)", parameter), + } + } +} + +impl Default for NeverYielded { + fn default() -> Self { + Self::new(Self::DEFAULT_DURATION) + } +} + +impl Warn<Task> for NeverYielded { + fn is_enabled(&self) -> bool { + self.enabled + } + + fn summary(&self) -> &str { + self.description.as_str() + } + + fn check(&self, task: &Task) -> Warning { + // Don't fire warning for tasks that are not async + if task.is_blocking() { + return Warning::Ok; + } + // Don't fire warning for tasks that are waiting to run + if task.is_completed() || (!task.is_completed() && !task.is_running()) { + return Warning::Ok; + } + + if task.total_polls() > 1 { + return Warning::Ok; + } + + // Avoid short-lived task false positives + if task.busy(SystemTime::now()) >= Duration::from_millis(self.parameter) { + return Warning::Warn; + } + + Warning::Ok + } + + fn format(&self, task: &Task) -> String { + let option_task_name = task.name.clone(); + let task_name; + if let Some(name) = option_task_name { + task_name = name + } else { + task_name = task.id() + } + format!( + "Task {task_name} has never yielded ({:?})", + task.busy(SystemTime::now()), + ) + } +} + +/// Warning for if a task's driving future was auto-boxed by the runtime +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub(crate) struct AutoBoxedFuture { + enabled: bool, +} + +impl AutoBoxedFuture { + fn new() -> Self { + Self { enabled: true } + } +} + +impl Warn<Task> for AutoBoxedFuture { + fn is_enabled(&self) -> bool { + self.enabled + } + + fn summary(&self) -> &str { + "tasks have been boxed by the runtime due to their size" + } + + fn check(&self, task: &Task) -> Warning { + let (Some(size_bytes), Some(original_size_bytes)) = + (task.size_bytes(), task.original_size_bytes()) + else { + return Warning::Ok; + }; + + if original_size_bytes != size_bytes { + Warning::Warn + } else { + Warning::Ok + } + } + + fn format(&self, task: &Task) -> String { + let original_size = task + .original_size_bytes() + .expect("warning should not trigger if original size is None"); + let boxed_size = task + .size_bytes() + .expect("warning should not trigger if size is None"); + let option_task_name = task.name.clone(); + let task_name; + if let Some(name) = option_task_name { + task_name = name + } else { + task_name = task.id() + } + format!( + "Task {task_name}'s future was auto-boxed by the runtime when spawning, due to its size (originally \ + {original_size} bytes, boxed size {boxed_size} bytes)", + + ) + } +} + +/// Warning for if a task's driving future if large +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct LargeFuture { + enabled: bool, + parameter: usize, + description: String, +} +impl LargeFuture { + pub(crate) const DEFAULT_MIN_SIZE_BYTES: usize = 1024; + pub(crate) fn new(parameter: usize) -> Self { + Self { + enabled: true, + parameter, + description: format!("tasks are {} bytes or larger", parameter), + } + } +} + +impl Default for LargeFuture { + fn default() -> Self { + Self::new(Self::DEFAULT_MIN_SIZE_BYTES) + } +} + +impl Warn<Task> for LargeFuture { + fn is_enabled(&self) -> bool { + self.enabled + } + + fn summary(&self) -> &str { + self.description.as_str() + } + + fn check(&self, task: &Task) -> Warning { + // Don't fire warning for tasks that are not async + if task.is_blocking() { + return Warning::Ok; + } + + if let Some(size_bytes) = task.size_bytes() { + if size_bytes >= self.parameter { + return Warning::Warn; + } + } + Warning::Ok + } + + fn format(&self, task: &Task) -> String { + let option_task_name = task.name.clone(); + let task_name; + if let Some(name) = option_task_name { + task_name = name + } else { + task_name = task.id() + } + format!( + "Task {} occupies a large amount of stack space ({} bytes)", + task_name, + task.size_bytes() + .expect("warning should not trigger if size is None"), + ) + } +} diff --git a/src-tauri/src/backend/domain/application.rs b/src-tauri/src/backend/domain/application.rs new file mode 100644 index 0000000..fdf17e7 --- /dev/null +++ b/src-tauri/src/backend/domain/application.rs @@ -0,0 +1,301 @@ +use crate::backend::core::connection_manager::Command; +use crate::backend::core::connection_manager::Connection; +use crate::backend::domain::storable::Storable; +use crate::backend::mappers::read_file; +use crate::utils::common::get_pid_hosting_at; +use crate::utils::common::get_process_start_time; +use crate::utils::error::Error as TraceError; +use async_trait::async_trait; +use log::debug; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use url::Url; +use uuid::Uuid; + +/// Whether an application is currently enabled (connected) +/// or disabled (no active connection). +#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Copy, Clone)] +pub(crate) enum ApplicationState { + #[default] + Disabled, + Enabled, +} + +/// Status of the underlying connection channel. +#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)] +pub(crate) enum ConnectionStatus { + /// No channel created yet. + #[default] + Disconnected, + /// In the process of establishing. + Connecting, + /// Successfully connected. + Connected, + /// Connection errored; contains the error message. + Error(String), +} + +/// Represents a tracked application: its metadata, +/// current process info, and connection state. +/// +/// Serialized to disk for state persistence. +#[derive(Clone, Serialize, Deserialize, Debug)] +pub(crate) struct Application { + pub(crate) pid: u32, + id: Uuid, + pub(crate) title: String, + url: Url, + start_time: String, + cpu_usage: f32, + memory_usage: u64, + state: ApplicationState, + connection_status: ConnectionStatus, + #[serde(skip)] + connection: Option<Connection>, +} + +impl Application { + /// Create a new application record by discovering the PID + /// and its start time from the given URL. + /// + /// # Errors + /// + /// Returns [`TraceError::PIDNotFound`] if no process + /// is listening on that URL, or if the start time cannot + /// be retrieved. + pub fn new(title: String, url: Url) -> Result<Application, TraceError> { + // Find the PID of the app + let pid = get_pid_hosting_at(url.clone())?; + let start_time = + get_process_start_time(pid).ok_or(TraceError::PIDNotFound { url: url.clone() })?; + + Ok(Application { + pid, + id: Uuid::new_v4(), + title, + url, + start_time, + state: ApplicationState::Enabled, + connection_status: ConnectionStatus::Disconnected, + connection: None, + cpu_usage: 0.0, + memory_usage: 0, + }) + } + + /// Current operating system PID of the running process. + pub fn pid(&self) -> u32 { + self.pid + } + + /// Update the stored PID (for reattach scenarios). + pub fn set_pid(&mut self, pid: u32) { + debug!("Setting the pid to {}", pid); + self.pid = pid; + } + + /// Globally unique identifier for this application. + pub fn id(&self) -> &Uuid { + &self.id + } + + /// Title as provided by the user. + pub fn title(&self) -> &str { + &self.title + } + + /// The original URL endpoint used to connect. + pub fn url(&self) -> Url { + self.url.clone() + } + + /// Whether this application is enabled or disabled. + pub fn state(&self) -> ApplicationState { + self.state + } + + pub fn _cpu_usage(&self) -> f32 { + self.cpu_usage + } + + /// Update the CPU‐usage statistic. + pub fn set_cpu_usage(&mut self, usage: f32) { + self.cpu_usage = usage; + } + + pub fn _memory_usage(&self) -> u64 { + self.memory_usage + } + + /// Update the memory‐usage statistic. + pub fn set_memory_usage(&mut self, usage: u64) { + self.memory_usage = usage; + } + + pub fn _connection_status(&self) -> &ConnectionStatus { + &self.connection_status + } + + /// Update the live connection status. + pub fn set_connection_status(&mut self, conn_status: ConnectionStatus) { + self.connection_status = conn_status; + } + + /// Mark as enabled and stash the live connection object. + pub fn enable(&mut self, connection: Connection) { + self.state = ApplicationState::Enabled; + self.connection = Some(connection); + debug!("Stored connection"); + } + + /// Mark as disabled and send a `Disconnect` command. + pub async fn disable(&mut self) { + if let Some(connection) = self.connection.take() { + connection.commands.send(Command::Disconnect).await.ok(); + self.state = ApplicationState::Disabled; + } + } + + // This test is needed here in order for the `state_manager::state::tests::test_handle_and_get_pid` + // test to work + #[cfg(test)] + pub fn new_mock(title: String, url: Url, pid: u32) -> Self { + Application { + pid, + id: Uuid::new_v4(), + title, + url, + start_time: "0".parse().unwrap(), + state: ApplicationState::Enabled, + connection_status: ConnectionStatus::Disconnected, + connection: None, + cpu_usage: 0.0, + memory_usage: 0, + } + } +} + +#[async_trait] +impl Storable<HashMap<Uuid, Application>> for Application { + const FILE_EXTENSION: &str = "applications.json"; + /// Load all applications from disk under `path`. + async fn load_all(path: String) -> Result<HashMap<Uuid, Application>, TraceError> { + let apps = + serde_json::from_str(&read_file(&format!("{}/{}", path, Self::FILE_EXTENSION)).await?) + .map_err(TraceError::Serde)?; + + Ok(apps) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::core::connection_manager::Command; + use serde_json::to_string_pretty; + use std::collections::HashMap; + use std::fs; + use std::io::Write; + use tauri::Url; + use tempfile::tempdir; + use tokio::sync::mpsc; + use uuid::Uuid; + + #[tokio::test] + async fn new_without_port_fails() { + let url = Url::parse("http://localhost").unwrap(); + let err = Application::new("NoPort".into(), url).unwrap_err(); + assert!(matches!(err, TraceError::PIDNotFound { .. })); + } + + #[test] + fn getters_and_setters_work() { + let mut app = Application { + pid: 42, + id: Uuid::new_v4(), + title: "MyApp".into(), + url: Url::parse("http://localhost:6000").unwrap(), + start_time: "time".into(), + cpu_usage: 0.0, + memory_usage: 0, + state: ApplicationState::Disabled, + connection_status: ConnectionStatus::Error("ups".into()), + connection: None, + }; + + app.set_pid(100); + assert_eq!(app.pid(), 100); + + app.set_cpu_usage(1.23); + assert!((app._cpu_usage() - 1.23).abs() < f32::EPSILON); + + app.set_memory_usage(2048); + assert_eq!(app._memory_usage(), 2048); + + app.set_connection_status(ConnectionStatus::Connected); + assert_eq!(app._connection_status(), &ConnectionStatus::Connected); + } + + #[tokio::test] + async fn enable_and_disable_send_disconnect() { + let mut app = Application { + pid: 1, + id: Uuid::new_v4(), + title: "Test".into(), + url: Url::parse("http://127.0.0.1:8000").unwrap(), + start_time: "t0".into(), + cpu_usage: 0.0, + memory_usage: 0, + state: ApplicationState::Disabled, + connection_status: ConnectionStatus::Disconnected, + connection: None, + }; + + let (tx, mut rx) = mpsc::channel(1); + let conn = Connection { commands: tx }; + + app.enable(conn.clone()); + assert_eq!(app.state(), ApplicationState::Enabled); + assert!(app.connection.is_some()); + + app.disable().await; + assert_eq!(rx.recv().await.unwrap(), Command::Disconnect); + assert_eq!(app.state(), ApplicationState::Disabled); + assert!(app.connection.is_none()); + } + + #[tokio::test] + async fn storable_roundtrip_via_temp_file() { + let uuid = Uuid::new_v4(); + let mut map = HashMap::new(); + let sample = Application { + pid: 5, + id: uuid, + title: "X".into(), + url: Url::parse("http://127.0.0.1:9000").unwrap(), + start_time: "t2".into(), + cpu_usage: 0.0, + memory_usage: 0, + state: ApplicationState::Enabled, + connection_status: ConnectionStatus::Connected, + connection: None, + }; + map.insert(uuid, sample.clone()); + + let tmpdir = tempdir().unwrap(); + let filepath = tmpdir.path().join(Application::FILE_EXTENSION); + let json = to_string_pretty(&map).unwrap(); + fs::File::create(&filepath) + .and_then(|mut f| f.write_all(json.as_bytes())) + .unwrap(); + + let loaded = Application::load_all(tmpdir.path().to_string_lossy().into()) + .await + .unwrap(); + let got = loaded.get(&uuid).unwrap(); + assert_eq!(got.title(), "X"); + assert_eq!(got.state(), ApplicationState::Enabled); + assert_eq!(got._connection_status(), &ConnectionStatus::Connected); + } +} diff --git a/src-tauri/src/backend/domain/async_op.rs b/src-tauri/src/backend/domain/async_op.rs new file mode 100644 index 0000000..e86bd4d --- /dev/null +++ b/src-tauri/src/backend/domain/async_op.rs @@ -0,0 +1,254 @@ +//! This module defines asynchronous‐operation–related domain types (`AsyncOp`, `TimeStamp`, `CPUOverview`, `TaskOp`) +//! and implements the `Storable` trait so that they can be loaded from JSON files. + +use super::storable::Storable; +use crate::backend::mappers::read_file; +use crate::utils::error::Error as TraceError; +use async_trait::async_trait; +use chrono::{DateTime, Local}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Represents a single asynchronous operation in the trace domain. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub(crate) struct AsyncOp { + /// Unique identifier of the asynchronous operation. + pub id: u64, + + /// Identifier of the resource this operation targets. + pub resource_id: u64, + + /// Optional name or target descriptor of the resource. + pub resource_target: Option<String>, +} + +/// Overview of CPU usage for a given operation slice. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub(crate) struct CPUOverview { + /// When the CPU slice started (if known). + pub started_at: Option<DateTime<Local>>, + + /// When the CPU slice stopped (if known). + pub stopped_at: Option<DateTime<Local>>, + + /// Optional resource target associated with this CPU slice. + pub resource_target: Option<String>, + pub location: Option<String>, + pub pid: Option<u32>, +} + +/// Aggregated CPU overview operations grouped by task. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub(crate) struct TaskOp { + /// Identifier of the task. + pub task_id: u64, + + /// Optional name of the task. + pub task_name: Option<String>, + + /// Optional color designation for UI displays. + pub task_color: Option<String>, + + /// List of CPU‐usage slices. + pub operations: Vec<CPUOverview>, +} + +#[async_trait] +impl Storable<HashMap<String, AsyncOp>> for AsyncOp { + /// File extension (in the storage folder) where async operations are serialized. + const FILE_EXTENSION: &str = "async_ops.json"; + + /// Load all `AsyncOp` records from `<path>/async_ops.json`. + /// + /// # Errors + /// + /// If the file cannot be read, returns `TraceError::PathNotFound`. + /// If JSON deserialization fails, returns `TraceError::Serde`. + async fn load_all(path: String) -> Result<HashMap<String, AsyncOp>, TraceError> { + let s = read_file(&format!("{}/{}", path, Self::FILE_EXTENSION)).await?; + let async_ops = + serde_json::from_str::<HashMap<String, AsyncOp>>(&s).map_err(TraceError::Serde)?; + Ok(async_ops) + } +} + +#[async_trait] +impl Storable<HashMap<String, TaskOp>> for TaskOp { + /// File extension (in the storage folder) where task operations are serialized. + const FILE_EXTENSION: &str = "tasks_ops.json"; + + /// Load all `TaskOp` records from `<path>/tasks_ops.json`. + /// + /// # Errors + /// + /// If the file cannot be read, returns `TraceError::PathNotFound`. + /// If JSON deserialization fails, returns `TraceError::Serde`. + async fn load_all(path: String) -> Result<HashMap<String, TaskOp>, TraceError> { + let s = read_file(&format!("{}/{}", path, Self::FILE_EXTENSION)).await?; + let tasks_op = + serde_json::from_str::<HashMap<String, TaskOp>>(&s).map_err(TraceError::Serde)?; + Ok(tasks_op) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::error::Error as TraceError; + use chrono::TimeZone; + use serde_json::to_string_pretty; + use std::collections::HashMap; + use std::fs::{self, File}; + use std::io::Write; + use tempfile::tempdir; + use tokio; + + #[tokio::test] + async fn asyncop_load_all_success() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join(AsyncOp::FILE_EXTENSION); + + // sample data + let mut map: HashMap<String, AsyncOp> = HashMap::new(); + map.insert( + "first".to_string(), + AsyncOp { + id: 1, + resource_id: 10, + resource_target: Some("targetA".into()), + }, + ); + map.insert( + "second".to_string(), + AsyncOp { + id: 2, + resource_id: 20, + resource_target: None, + }, + ); + + let json = to_string_pretty(&map).unwrap(); + let mut file = File::create(&file_path).unwrap(); + file.write_all(json.as_bytes()).unwrap(); + + let loaded = AsyncOp::load_all(dir.path().to_string_lossy().into()) + .await + .unwrap(); + + assert_eq!(loaded.len(), 2); + let first = loaded.get("first").unwrap(); + assert_eq!(first.id, 1); + assert_eq!(first.resource_id, 10); + assert_eq!(first.resource_target.as_deref(), Some("targetA")); + + let second = loaded.get("second").unwrap(); + assert_eq!(second.id, 2); + assert_eq!(second.resource_id, 20); + assert!(second.resource_target.is_none()); + } + + #[tokio::test] + async fn asyncop_load_all_file_not_found() { + // a directory that doesn't contain our jsons + let dir = tempdir().unwrap(); + let err = AsyncOp::load_all(dir.path().to_string_lossy().into()) + .await + .unwrap_err(); + assert!(matches!(err, TraceError::PathNotFound(_))); + } + + #[tokio::test] + async fn asyncop_load_all_bad_json() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join(AsyncOp::FILE_EXTENSION); + fs::write(&file_path, "not a valid json").unwrap(); + + let err = AsyncOp::load_all(dir.path().to_string_lossy().into()) + .await + .unwrap_err(); + assert!(matches!(err, TraceError::Serde(_))); + } + + #[tokio::test] + async fn taskop_load_all_success() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join(TaskOp::FILE_EXTENSION); + + let ts1: DateTime<Local> = Local + .timestamp_opt(100, 500) + .single() + .expect("ambiguous or nonexistent local time"); + + let ts2 = Local + .timestamp_opt(200, 0) + .single() + .expect("ambiguous or nonexistent local time"); + + let cpu1 = CPUOverview { + started_at: Some(ts1.clone()), + stopped_at: Some(ts2.clone()), + resource_target: Some("res1".into()), + location: None, + pid: None, + }; + let cpu2 = CPUOverview { + started_at: None, + stopped_at: None, + resource_target: None, + location: None, + pid: None, + }; + + let mut map: HashMap<String, TaskOp> = HashMap::new(); + map.insert( + "taskA".into(), + TaskOp { + task_id: 42, + task_name: Some("Alpha".into()), + task_color: Some("#fff".into()), + operations: vec![cpu1.clone(), cpu2.clone()], + }, + ); + + let json = to_string_pretty(&map).unwrap(); + let mut file = File::create(&file_path).unwrap(); + file.write_all(json.as_bytes()).unwrap(); + + let loaded = TaskOp::load_all(dir.path().to_string_lossy().into()) + .await + .unwrap(); + + assert_eq!(loaded.len(), 1); + let got = loaded.get("taskA").unwrap(); + assert_eq!(got.task_id, 42); + assert_eq!(got.task_name.as_deref(), Some("Alpha")); + assert_eq!(got.task_color.as_deref(), Some("#fff")); + assert_eq!(got.operations.len(), 2); + assert_eq!( + got.operations[0].started_at, + Option::from(Local.timestamp_opt(100, 0).single().unwrap()) + ); + assert_eq!(got.operations[1].resource_target, None); + } + + #[tokio::test] + async fn taskop_load_all_file_not_found() { + let dir = tempdir().unwrap(); + let err = TaskOp::load_all(dir.path().to_string_lossy().into()) + .await + .unwrap_err(); + assert!(matches!(err, TraceError::PathNotFound(_))); + } + + #[tokio::test] + async fn taskop_load_all_bad_json() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join(TaskOp::FILE_EXTENSION); + fs::write(&file_path, "{not: valid, json]").unwrap(); + + let err = TaskOp::load_all(dir.path().to_string_lossy().into()) + .await + .unwrap_err(); + assert!(matches!(err, TraceError::Serde(_))); + } +} diff --git a/src-tauri/src/backend/domain/has_app_name.rs b/src-tauri/src/backend/domain/has_app_name.rs new file mode 100644 index 0000000..0b6a805 --- /dev/null +++ b/src-tauri/src/backend/domain/has_app_name.rs @@ -0,0 +1,3 @@ +pub trait HasAppName { + fn set_app_name(&mut self, new_app_name: String); +} diff --git a/src-tauri/src/domain/mod.rs b/src-tauri/src/backend/domain/mod.rs similarity index 56% rename from src-tauri/src/domain/mod.rs rename to src-tauri/src/backend/domain/mod.rs index 867d231..a40dc0f 100644 --- a/src-tauri/src/domain/mod.rs +++ b/src-tauri/src/backend/domain/mod.rs @@ -1,6 +1,10 @@ //! Module defining all data objects pub(crate) mod application; +pub(crate) mod async_op; +pub(crate) mod has_app_name; +pub(crate) mod poll; +pub(crate) mod resource; pub(crate) mod storable; pub(crate) mod task; diff --git a/src-tauri/src/backend/domain/poll.rs b/src-tauri/src/backend/domain/poll.rs new file mode 100644 index 0000000..2d87ae9 --- /dev/null +++ b/src-tauri/src/backend/domain/poll.rs @@ -0,0 +1,52 @@ +//! Defines the `Poll` domain type and its `Storable` implementation for +//! loading from JSON. + +use super::storable::Storable; +use crate::backend::mappers::read_file; +use crate::utils::error::Error as TraceError; +use async_trait::async_trait; +use chrono::{DateTime, Local}; +use serde::{Deserialize, Serialize}; + +/// A single poll (event) captured by the tracing system. +#[derive(Clone, Serialize, Deserialize, Debug)] +pub(crate) struct Poll { + /// Optional application name that generated this poll. + pub app_name: Option<String>, + /// The type or label of the poll. + pub poll_type: String, + /// Optional associated resource identifier. + pub resource_id: Option<u64>, + /// Optional human‐readable resource name. + pub resource_name: Option<String>, + /// Optional associated task identifier. + pub task_id: Option<u64>, + /// Optional human‐readable task name. + pub task_name: Option<String>, + /// Optional color metadata for a UI. + pub task_color: Option<String>, + /// Whether this poll indicates readiness (true) or not. + pub is_ready: bool, + /// Optional source code location string. + pub location: Option<String>, + /// Optional timestamp when this poll was received. + pub received_at: Option<DateTime<Local>>, +} + +#[async_trait] +impl Storable<Vec<Poll>> for Poll { + /// JSON file name suffix for polls. + const FILE_EXTENSION: &str = "polls.json"; + + /// Load all polls from `<path>/polls.json`. + /// + /// # Errors + /// + /// Returns `TraceError::PathNotFound` if the file cannot be read, + /// or `TraceError::Serde` if JSON parsing fails. + async fn load_all(path: String) -> Result<Vec<Poll>, TraceError> { + let s = read_file(&format!("{}/{}", path, Self::FILE_EXTENSION)).await?; + let polls = serde_json::from_str::<Vec<Poll>>(&s).map_err(TraceError::Serde)?; + Ok(polls) + } +} diff --git a/src-tauri/src/backend/domain/resource.rs b/src-tauri/src/backend/domain/resource.rs new file mode 100644 index 0000000..84def72 --- /dev/null +++ b/src-tauri/src/backend/domain/resource.rs @@ -0,0 +1,75 @@ +//! Defines resources tracked in the trace domain and implements storage. + +use super::storable::Storable; +use crate::backend::domain::has_app_name::HasAppName; +use crate::backend::mappers::read_file; +use crate::utils::error::Error as TraceError; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::Duration; + +/// Possible lifecycle states of a traced resource. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum ResourceStatus { + /// Resource is fully initialized and ready. + Ready, + /// Resource has been dropped or cleaned up. + Dropped, +} + +/// A tracked resource in the application trace. +#[derive(Clone, Serialize, Deserialize, Debug)] +pub(crate) struct Resource { + /// Optional application name this resource belongs to. + pub app_name: Option<String>, + /// Optional kind/type string of the resource. + pub resource_type: Option<String>, + /// Unique numeric ID of the resource. + pub id: u64, + /// Current state of the resource. + pub status: ResourceStatus, + /// Optional target or descriptor. + pub target: Option<String>, + /// Optional lifetime duration of the resource. + pub duration: Option<Duration>, + /// Optional source‐code location. + pub location: Option<String>, + /// Optional extra attributes in JSON or plain text. + pub attributes: Option<String>, +} + +impl Resource { + /// Returns a string identifier combining `app_name` and `id`. + pub fn id(&self) -> Result<String, String> { + match &self.app_name { + Some(name) => Ok(format!("{}.{}", name, self.id)), + None => Err(format!("missing app_name for resource id {}", self.id)), + } + } +} + +#[async_trait] +impl Storable<HashMap<String, Resource>> for Resource { + /// JSON file suffix used when persisting resources. + const FILE_EXTENSION: &str = "resources.json"; + + /// Load all resources from `<path>/resources.json`. + /// + /// # Errors + /// + /// - `TraceError::PathNotFound` if the file cannot be read. + /// - `TraceError::Serde` if JSON fails to deserialize. + async fn load_all(path: String) -> Result<HashMap<String, Resource>, TraceError> { + let s = read_file(&format!("{}/{}", path, Self::FILE_EXTENSION)).await?; + let resources = + serde_json::from_str::<HashMap<String, Resource>>(&s).map_err(TraceError::Serde)?; + Ok(resources) + } +} + +impl HasAppName for Resource { + fn set_app_name(&mut self, new_app_name: String) { + self.app_name = Some(new_app_name.clone()); + } +} diff --git a/src-tauri/src/backend/domain/storable.rs b/src-tauri/src/backend/domain/storable.rs new file mode 100644 index 0000000..e011f10 --- /dev/null +++ b/src-tauri/src/backend/domain/storable.rs @@ -0,0 +1,18 @@ +//! A generic trait for loading domain data from disk. + +use crate::utils::error::Error as TraceError; +use async_trait::async_trait; + +/// A type that can be loaded from a JSON file named `<TYPE::FILE_EXTENSION>`. +#[async_trait] +pub(crate) trait Storable<T> { + /// The suffix (including `.json`) for the file holding these items. + const FILE_EXTENSION: &str; + + /// Load all items of type `T` from the JSON file in `path`. + /// + /// # Errors + /// + /// Returns a `TraceError` if reading or parsing fails. + async fn load_all(path: String) -> Result<T, TraceError>; +} diff --git a/src-tauri/src/backend/domain/task.rs b/src-tauri/src/backend/domain/task.rs new file mode 100644 index 0000000..c0e6230 --- /dev/null +++ b/src-tauri/src/backend/domain/task.rs @@ -0,0 +1,215 @@ +//! Defines the `Task` domain type (with run/schedule/idleness stats) and +//! implements `Storable` to read tasks from JSON. + +use super::storable::Storable; +use crate::backend::core::warnings::TaskWarnings; +use crate::backend::domain::has_app_name::HasAppName; +use crate::backend::mappers::read_file; +use crate::utils::error::Error as TraceError; +use async_trait::async_trait; +use chrono::{DateTime, Local}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::{Duration, SystemTime}; + +/// Lifecycle state of a task. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum TaskState { + /// Task is running. + Running, + /// Task has stopped at a given timestamp for an optional reason. + Stopped { + /// When the stop occurred. + at: DateTime<Local>, + /// Optional human‐readable explanation. + reason: Option<String>, + }, + Starved, +} + +/// A traced task with timing and scheduling information. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Task { + /// Optional application name the task belongs to. + pub app_name: Option<String>, + /// Unique numeric ID of the task. + pub id: u64, + /// Optional OS thread identifier (tid). + pub tid: Option<u64>, + /// Optional human‐readable task name. + pub name: Option<String>, + /// Optional color hint for UI. + pub color: Option<String>, + /// Optional category/kind string. + pub kind: Option<String>, + /// Current state (running or stopped). + pub state: TaskState, + /// Total runtime duration (if known). + pub runtime: Option<Duration>, + /// Total scheduled time. + pub scheduled: Option<Duration>, + /// Total idle time. + pub idle: Option<Duration>, + /// Total busy time. + pub busy: Option<Duration>, + /// Optional source location. + pub location: Option<String>, + /// Optional creation timestamp. + pub created_at: Option<DateTime<Local>>, + /// The size of the future driving the task + pub size_bytes: Option<usize>, + /// The original size of the future (before runtime auto-boxing) + pub original_size_bytes: Option<usize>, + /// Task runtime statistics (wakes, polls, timestamps). + pub stats: TaskStats, + /// Warnings related to the task (e.g. excessive polls, large future). + pub warnings: TaskWarnings, +} + +/// Per-task runtime counters and recent timestamps used for state inference. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct TaskStats { + /// Number of times the task has been woken. + pub wakes: u64, + /// Number of times the task's waker woke itself (i.e. wake_by_ref/self-wakes). + pub self_wakes: u64, + /// Number of times the task's Waker was cloned. + pub waker_clones: u64, + /// Number of times the task's Waker was dropped. + pub waker_drops: u64, + /// Number of times the task has been polled. + pub polls: u64, + /// Timestamp of the last wake (when the task was signalled). + pub last_wake: Option<SystemTime>, + /// Timestamp when the last poll started. + pub last_poll_started: Option<SystemTime>, + /// Timestamp when the last poll ended. + pub last_poll_ended: Option<SystemTime>, +} + +impl Task { + /// Returns a composite string identifier `<app_name>.<id>`. + pub fn id(&self) -> String { + format!( + "{}.{}", + self.app_name + .as_ref() + .unwrap_or(&String::from("Missing app name for resource")), + self.id + ) + } + + pub(crate) fn wakes(&self) -> u64 { + self.stats.wakes + } + + pub(crate) fn self_wakes(&self) -> u64 { + self.stats.self_wakes + } + pub(crate) fn waker_clones(&self) -> u64 { + self.stats.waker_clones + } + + pub(crate) fn waker_drops(&self) -> u64 { + self.stats.waker_drops + } + + pub(crate) fn total_polls(&self) -> u64 { + self.stats.polls + } + + pub(crate) fn size_bytes(&self) -> Option<usize> { + self.size_bytes + } + + pub(crate) fn original_size_bytes(&self) -> Option<usize> { + self.original_size_bytes + } + + pub(crate) fn check_warnings(&self) -> Vec<String> { + self.warnings.check(self) + } + + pub(crate) fn is_blocking(&self) -> bool { + matches!(self.kind.as_deref(), Some("block_on") | Some("blocking")) + } + + pub(crate) fn is_completed(&self) -> bool { + matches!(self.state, TaskState::Stopped { at: _, reason: _ }) + } + + pub(crate) fn waker_count(&self) -> u64 { + self.waker_clones().saturating_sub(self.waker_drops()) + } + + pub(crate) fn self_wake_percent(&self) -> u64 { + let total = self.wakes(); + if total == 0 { + 0 + } else { + ((self.self_wakes() as f64 / total as f64) * 100.0) as u64 + } + } + + pub(crate) fn busy(&self, since: SystemTime) -> Duration { + if let Some(busy_duration) = self.busy { + let busy_time = Duration::new(busy_duration.as_secs(), busy_duration.as_nanos() as u32); + if let Some(started) = self.stats.last_poll_started { + if self.stats.last_poll_started > self.stats.last_poll_ended { + // in this case the task is being polled at the moment + let current_time_in_poll = since.duration_since(started).unwrap_or_default(); + return busy_time + current_time_in_poll; + } + } + busy_time + } else { + std::time::Duration::new(0, 0) + } + } + + pub(crate) fn is_running(&self) -> bool { + self.stats.last_poll_started > self.stats.last_poll_ended + } + + /// Return true if the task is considered awakened (woken since last poll + /// or hasn't been polled yet). + pub(crate) fn is_awakened(&self) -> bool { + if self.stats.polls == 0 { + return true; + } + match (self.stats.last_wake, self.stats.last_poll_started) { + (Some(wake), Some(poll_start)) => wake > poll_start, + (Some(_wake), None) => true, + (None, Some(_)) => false, + (None, None) => self.stats.wakes > self.stats.polls, + } + } + + pub(crate) fn is_starved(&self) -> bool { + self.stats.last_wake > self.stats.last_poll_started + } +} + +#[async_trait] +impl Storable<HashMap<String, Task>> for Task { + /// The file suffix under which tasks are stored. + const FILE_EXTENSION: &str = "tasks.json"; + + /// Load all tasks from `<path>/tasks.json`. + /// + /// # Errors + /// + /// - `TraceError::PathNotFound` if the file cannot be read. + /// - `TraceError::Serde` if the JSON is malformed. + async fn load_all(path: String) -> Result<HashMap<String, Task>, TraceError> { + let s = read_file(&format!("{}/{}", path, Self::FILE_EXTENSION)).await?; + let tasks = serde_json::from_str::<HashMap<String, Task>>(&s).map_err(TraceError::Serde)?; + Ok(tasks) + } +} + +impl HasAppName for Task { + fn set_app_name(&mut self, new_app_name: String) { + self.app_name = Some(new_app_name.clone()); + } +} diff --git a/src-tauri/src/infra/guard.rs b/src-tauri/src/backend/infra/guard.rs similarity index 55% rename from src-tauri/src/infra/guard.rs rename to src-tauri/src/backend/infra/guard.rs index dd0bee4..1307565 100644 --- a/src-tauri/src/infra/guard.rs +++ b/src-tauri/src/backend/infra/guard.rs @@ -1,24 +1,36 @@ -use crate::error::Error as TraceError; +// infra/guard.rs +//! A guard that auto‐writes a database file on drop. Useful for +//! synchronized, writeable access to in‐memory data. + +use crate::utils::error::Error as TraceError; use log::{error, info}; use serde::Serialize; use std::{ + fmt::Debug, ops::{Deref, DerefMut}, sync::Arc, }; use tokio::sync::RwLockWriteGuard; +/// Trait implemented by in‐memory databases that support write‐access. pub trait DataBaseWrite<D: Serialize + Clone> { + /// Obtain a mutable reference to the underlying data, + /// potentially cloning if it's shared (e.g. `Arc`). #[allow(unused)] fn writeable(&mut self) -> &mut D; } -pub struct WriteableDataBaseGuard<'a, D: Serialize> { +/// A RAII guard that, when dropped, serializes `elements` back to disk. +pub struct WriteableDataBaseGuard<'a, D: Serialize + Debug> { + /// Folder path where the JSON file lives. pub(crate) folder: &'a str, + /// Base filename (without extension). pub(crate) title: &'a str, + /// Locked, mutable reference to the data. pub(crate) elements: RwLockWriteGuard<'a, D>, } -impl<D: Serialize> Deref for WriteableDataBaseGuard<'_, D> { +impl<D: Serialize + Debug> Deref for WriteableDataBaseGuard<'_, D> { type Target = D; fn deref(&self) -> &Self::Target { @@ -26,22 +38,26 @@ impl<D: Serialize> Deref for WriteableDataBaseGuard<'_, D> { } } -impl<D: Serialize> DerefMut for WriteableDataBaseGuard<'_, D> { +impl<D: Serialize + Debug> DerefMut for WriteableDataBaseGuard<'_, D> { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.elements } } impl<D: Serialize + Clone> DataBaseWrite<D> for Arc<D> { + /// If the database is behind an `Arc`, this will clone‐on‐write as needed. fn writeable(&mut self) -> &mut D { Arc::make_mut(self) } } -impl<D: Serialize> Drop for WriteableDataBaseGuard<'_, D> { +impl<D: Serialize + Debug> Drop for WriteableDataBaseGuard<'_, D> { + /// On drop, serialize the data to `{folder}/{title}.json`. fn drop(&mut self) { let filename = format!("{}/{}.json", self.folder, self.title); info!("Storing {} to {filename}", self.title); + + // Pretty‐serialize then write to disk, logging any errors. serde_json::to_string_pretty(&*self.elements) .map_err(|error| { error!("Failed to serialize {filename} ({error})"); @@ -54,6 +70,7 @@ impl<D: Serialize> Drop for WriteableDataBaseGuard<'_, D> { }) }) .ok(); + info!("Dropped {}", self.title); } } diff --git a/src-tauri/src/infra.rs b/src-tauri/src/backend/infra/mod.rs similarity index 100% rename from src-tauri/src/infra.rs rename to src-tauri/src/backend/infra/mod.rs diff --git a/src-tauri/src/backend/infra/storage.rs b/src-tauri/src/backend/infra/storage.rs new file mode 100644 index 0000000..40ce0d5 --- /dev/null +++ b/src-tauri/src/backend/infra/storage.rs @@ -0,0 +1,72 @@ +//! Defines the `Storage` trait to unify reads/writes of all domain data. + +use super::guard::WriteableDataBaseGuard; +use crate::backend::domain::{ + application::Application, + async_op::{AsyncOp, TaskOp}, + poll::Poll, + resource::Resource, + Task, +}; +use async_trait::async_trait; +use std::path::PathBuf; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; +use uuid::Uuid; + +/// Abstraction over a persisted store for all domain entities. +/// Implementors must provide both read‐only and write guards. +#[async_trait] +pub(crate) trait Storage: Send + Sync { + /// Read all applications as an `Arc<Application>` map. + async fn applications_read(&self) -> HashMap<Uuid, Arc<Application>>; + + /// Acquire a write guard for applications; on drop, writes to disk. + async fn applications_write( + &self, + ) -> WriteableDataBaseGuard<'_, HashMap<Uuid, Arc<Application>>>; + + /// Read all tasks. + async fn tasks_read(&self) -> HashMap<String, Arc<Task>>; + + /// Write guard for tasks. + async fn tasks_write(&self) -> WriteableDataBaseGuard<'_, HashMap<String, Arc<Task>>>; + + async fn active_tasks_write(&self) -> tokio::sync::RwLockWriteGuard<'_, HashSet<String>>; + + async fn active_tasks_read(&self) -> HashSet<String>; + + /// Read all resources. + async fn resources_read(&self) -> HashMap<String, Arc<Resource>>; + + /// Write guard for resources. + async fn resources_write(&self) -> WriteableDataBaseGuard<'_, HashMap<String, Arc<Resource>>>; + + /// Read all polls. + async fn polls_read(&self) -> Vec<Arc<Poll>>; + + /// Write guard for polls. + async fn polls_write(&self) -> WriteableDataBaseGuard<'_, Vec<Arc<Poll>>>; + + /// Read all async operations. + async fn async_ops_read(&self) -> HashMap<String, Arc<AsyncOp>>; + + /// Write guard for async operations. + async fn async_ops_write(&self) -> WriteableDataBaseGuard<'_, HashMap<String, Arc<AsyncOp>>>; + + /// Read all task operations. + async fn tasks_ops_read(&self) -> HashMap<String, Arc<TaskOp>>; + + /// Write guard for task operations. + async fn tasks_ops_write(&self) -> WriteableDataBaseGuard<'_, HashMap<String, Arc<TaskOp>>>; + + async fn import_from_export_folder(&self, folder: PathBuf) -> Result<(), String>; + + async fn export_app_instance( + &self, + app_id: String, + user_name: String, + ) -> Result<PathBuf, crate::utils::error::Error>; +} diff --git a/src-tauri/src/backend/mappers/async_ops.rs b/src-tauri/src/backend/mappers/async_ops.rs new file mode 100644 index 0000000..4165ec2 --- /dev/null +++ b/src-tauri/src/backend/mappers/async_ops.rs @@ -0,0 +1,20 @@ +//! Convert from `console_api::async_ops::AsyncOp` to our domain `AsyncOp`. + +use crate::{backend::domain::async_op::AsyncOp, utils::error::Error as TraceError}; +use console_api::async_ops; + +/// Maps a protobuf‐style `console_api` AsyncOp into the domain `AsyncOp`. +/// +/// Returns `TraceError` if either `id` or `resource_id` is missing. +pub fn map_to_domain_async_op(async_op: &async_ops::AsyncOp) -> Result<AsyncOp, TraceError> { + match (async_op.id, async_op.resource_id) { + (Some(id), Some(resource_id)) => Ok(AsyncOp { + id: id.id, + resource_id: resource_id.id, + resource_target: None, + }), + (None, Some(_resource_id)) => Err(TraceError::IDNotFound), + (Some(_id), None) => Err(TraceError::ResourceIDNotFound), + (None, None) => Err(TraceError::IDAndResourceIDNotFound), + } +} diff --git a/src-tauri/src/mappers/mod.rs b/src-tauri/src/backend/mappers/mod.rs similarity index 60% rename from src-tauri/src/mappers/mod.rs rename to src-tauri/src/backend/mappers/mod.rs index 23db46b..374c9b3 100644 --- a/src-tauri/src/mappers/mod.rs +++ b/src-tauri/src/backend/mappers/mod.rs @@ -1,6 +1,12 @@ +//! Central re‐exports and utility functions for mapping console_api data +//! into our domain models. + +pub(crate) mod async_ops; +pub(crate) mod poll; +pub(crate) mod resources; pub(crate) mod tasks; -use crate::error::Error as TraceError; +use crate::utils::error::Error as TraceError; use console_api::{ field::{Name, Value}, tasks::Task as ConsoleTask, @@ -9,8 +15,7 @@ use console_api::{ use log::error; use tokio::fs::read_to_string; -// UTILS METHODS (could be moved in a dedicated module) - +/// Find a given named field in a console API task. fn find_field(task: &ConsoleTask, field_name: impl AsRef<str>) -> Option<&Field> { task.fields.iter().find(|field| { if let Some(Name::StrName(ref s)) = field.name { @@ -21,6 +26,7 @@ fn find_field(task: &ConsoleTask, field_name: impl AsRef<str>) -> Option<&Field> }) } +/// Read a `u64` value from a named field in a task. fn read_field_value_u64(task: &ConsoleTask, field_name: impl AsRef<str>) -> Option<u64> { if let Some(field) = find_field(task, field_name) { match field.value { @@ -32,6 +38,18 @@ fn read_field_value_u64(task: &ConsoleTask, field_name: impl AsRef<str>) -> Opti } } +fn read_field_value_usize(task: &ConsoleTask, field_name: impl AsRef<str>) -> Option<usize> { + if let Some(field) = find_field(task, field_name) { + match field.value { + Some(Value::U64Val(value)) => Some(value as usize), + _ => None, + } + } else { + None + } +} + +/// Read a string value from a named field in a task. fn read_field_value_string(task: &ConsoleTask, field_name: impl AsRef<str>) -> Option<&str> { if let Some(field) = find_field(task, field_name) { match field.value { @@ -43,6 +61,11 @@ fn read_field_value_string(task: &ConsoleTask, field_name: impl AsRef<str>) -> O } } +/// Asynchronously reads an entire file into a `String`. +/// +/// # Errors +/// +/// Returns `TraceError::PathNotFound(filename)` if the file can’t be opened. pub async fn read_file(filename: &str) -> Result<String, TraceError> { read_to_string(filename).await.map_err(|err| { error!("Failed to load {filename} ({err:?})"); diff --git a/src-tauri/src/backend/mappers/poll.rs b/src-tauri/src/backend/mappers/poll.rs new file mode 100644 index 0000000..0d6bcd4 --- /dev/null +++ b/src-tauri/src/backend/mappers/poll.rs @@ -0,0 +1,33 @@ +//! Convert from `console_api::resources::PollOp` to our domain `Poll`. + +use crate::backend::domain::poll::Poll; +use console_api::resources::PollOp; + +/// Map fields from the upstream PollOp into our `Poll`. +/// Always returns `Some(Poll)` as we provide defaults for missing data. +pub fn map_to_domain_poll(poll: &PollOp) -> Option<Poll> { + let resource_id = poll.resource_id.map(|id| id.id); + + let resource_name = Some("Unknown".into()); + + let name = poll.name.clone(); + + let task_id = poll.task_id.map(|id| id.id); + + let task_name = Some("No Name".into()); + + let is_ready = poll.is_ready; + + Some(Poll { + app_name: None, + poll_type: name, + resource_id, + resource_name, + task_id, + task_name, + task_color: None, + is_ready, + location: None, + received_at: None, + }) +} diff --git a/src-tauri/src/backend/mappers/resources.rs b/src-tauri/src/backend/mappers/resources.rs new file mode 100644 index 0000000..83b7014 --- /dev/null +++ b/src-tauri/src/backend/mappers/resources.rs @@ -0,0 +1,63 @@ +//! Convert from `console_api::resources::Resource` to our domain `Resource`. + +use crate::backend::domain::resource::{Resource, ResourceStatus}; +use console_api::resources; +use console_api::resources::resource::kind; +use std::time::Duration; + +/// Map an upstream `console_api` resource into our domain `Resource`. +/// Returns `None` if the resource ID is missing. +pub fn map_to_domain_resource(resource: &resources::Resource) -> Option<Resource> { + // Format the source‐location if available. + let mut location = resource.location.as_ref().map(|loc| { + let full_path = loc.file(); + let path = std::path::Path::new(full_path); + + let directory = path.parent().and_then(|p| p.to_str()).unwrap_or(""); + let filename = path.file_name().and_then(|f| f.to_str()).unwrap_or(""); + + format!( + "{}{}<b>{}</b>:<b>{}</b>:{}", + directory, + std::path::MAIN_SEPARATOR, + filename, + loc.line(), + loc.column() + ) + }); + if location.is_none() { + location = Some("Unknown".into()); + } + + let resource_type = { + if let Some(kind) = resource.kind.as_ref() { + if let Some(kind_of_kind) = kind.kind.as_ref() { + match kind_of_kind { + kind::Kind::Known(i) => match kind::Known::try_from(*i) { + Ok(k) => Some(k.as_str_name().to_string()), + Err(_) => Some("unknown_kind".to_string()), + }, + kind::Kind::Other(s) => Some(s.clone()), + } + } else { + None + } + } else { + None + } + }; + + let id = resource.id.as_ref().map(|v| v.id)?; + let target = Some(resource.concrete_type.clone()); + + Some(Resource { + app_name: None, + resource_type, + id, + status: ResourceStatus::Ready, + target, + duration: Some(Duration::new(0, 0)), + location, + attributes: None, + }) +} diff --git a/src-tauri/src/backend/mappers/tasks.rs b/src-tauri/src/backend/mappers/tasks.rs new file mode 100644 index 0000000..f67e0b3 --- /dev/null +++ b/src-tauri/src/backend/mappers/tasks.rs @@ -0,0 +1,68 @@ +//! Convert from `console_api::tasks::Task` to our domain `Task`. + +use super::{read_field_value_string, read_field_value_u64, read_field_value_usize}; +use crate::backend::core::warnings::TaskWarnings; +use crate::backend::domain::{Task, TaskState, TaskStats}; +use console_api::tasks; +use console_api::tasks::task::Kind; +use uuid::Uuid; + +/// Map an upstream console API task into our domain type. +/// Returns `None` if the task ID is missing. +pub fn map_to_domain_task(_app_id: Uuid, task: &tasks::Task) -> Option<Task> { + let id = task.id.as_ref().map(|v| v.id)?; + let tid = read_field_value_u64(task, "task.id"); + let size_bytes = read_field_value_usize(task, "size.bytes"); + let original_size_bytes = read_field_value_usize(task, "original_size.bytes"); + let name = read_field_value_string(task, "task.name").map(|s| s.to_owned()); + let kind = Kind::try_from(task.kind) + .map(|k| k.as_str_name().to_owned()) + .ok(); + let location = task.location.as_ref().map(|loc| { + let full_path = loc.file(); + let path = std::path::Path::new(full_path); + + let directory = path.parent().and_then(|p| p.to_str()).unwrap_or(""); + let filename = path.file_name().and_then(|f| f.to_str()).unwrap_or(""); + + format!( + "{}{}<b>{}</b>:<b>{}</b>:{}", + directory, + std::path::MAIN_SEPARATOR, + filename, + loc.line(), + loc.column() + ) + }); + + let stats = TaskStats { + wakes: 0, + self_wakes: 0, + waker_clones: 0, + waker_drops: 0, + polls: 0, + last_wake: None, + last_poll_started: None, + last_poll_ended: None, + }; + + Some(Task { + app_name: None, + id, + tid, + name, + color: None, + kind, + state: TaskState::Running, + runtime: None, + scheduled: None, + idle: None, + busy: None, + location, + created_at: None, + size_bytes, + original_size_bytes, + stats, + warnings: TaskWarnings::new(), + }) +} diff --git a/src-tauri/src/backend/mod.rs b/src-tauri/src/backend/mod.rs new file mode 100644 index 0000000..3bf1441 --- /dev/null +++ b/src-tauri/src/backend/mod.rs @@ -0,0 +1,4 @@ +pub mod core; +pub mod domain; +pub mod infra; +pub mod mappers; diff --git a/src-tauri/src/commands/applications.rs b/src-tauri/src/commands/applications.rs deleted file mode 100644 index 5803f02..0000000 --- a/src-tauri/src/commands/applications.rs +++ /dev/null @@ -1,39 +0,0 @@ -use log::info; -use std::sync::Arc; -use tauri::State; -use uuid::Uuid; - -use crate::error::Error; -use crate::state_manager::StateManager; - -#[tauri::command] -pub async fn applications_add( - state_manager: State<'_, Arc<StateManager>>, - title: String, - url: &str, -) -> Result<Uuid, Error> { - info!("Received command to add application with title {title} and url {url}"); - - let url = url.try_into()?; - state_manager.add_application(title, url).await -} - -#[tauri::command] -pub async fn delete_application( - state_manager: State<'_, Arc<StateManager>>, - uuid: Uuid, -) -> Result<(), Error> { - state_manager.delete_connection(uuid).await; - - Ok(()) -} - -// pub async fn enable_app(context: State<'_, Arc<Context>>) {} - -#[tauri::command] -pub async fn disable_app( - state_manager: State<'_, Arc<StateManager>>, - uuid: Uuid, -) -> Result<(), Error> { - state_manager.disable_application(uuid).await -} diff --git a/src-tauri/src/domain/application.rs b/src-tauri/src/domain/application.rs deleted file mode 100644 index cb68376..0000000 --- a/src-tauri/src/domain/application.rs +++ /dev/null @@ -1,87 +0,0 @@ -use std::collections::HashMap; - -use super::storable::Storable; -use crate::error::Error as TraceError; -use crate::mappers::read_file; -use crate::state_manager::connection_manager::{Command, Connection}; -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use tauri::Url; -use uuid::Uuid; - -#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Copy, Clone)] -pub(crate) enum ApplicationState { - #[default] - Disabled, - Enabled, -} - -/// Application tracked by the application -/// -/// Keeps app's metadatas and current state -#[derive(Clone, Serialize, Deserialize, Debug)] -pub(crate) struct Application { - id: Uuid, - title: String, - url: Url, - state: ApplicationState, - - #[serde(skip)] - connection: Option<Connection>, -} - -impl Application { - pub fn new(title: String, url: Url) -> Application { - Application { - id: Uuid::new_v4(), - title, - url, - state: ApplicationState::Disabled, - - connection: None, - } - } - - pub fn id(&self) -> &Uuid { - &self.id - } - - pub fn _title(&self) -> &str { - &self.title - } - - pub fn url(&self) -> &Url { - &self.url - } - - pub fn state(&self) -> ApplicationState { - self.state - } - - // vreau sa vad info pentru aplicatia asta - pub fn enable(&mut self, connection: Connection) { - if self.state == ApplicationState::Disabled { - self.state = ApplicationState::Enabled; - self.connection = Some(connection); - } - } - - pub async fn disable(&mut self) { - if let Some(connection) = self.connection.take() { - connection.commands.send(Command::Disconnect).await.ok(); - self.state = ApplicationState::Disabled; - } - } -} - -#[async_trait] -impl Storable<HashMap<Uuid, Application>> for Application { - const FILE_EXTENSION: &str = "applications.json"; - async fn load_all(path: String) -> Result<HashMap<Uuid, Application>, TraceError> { - let apps = - serde_json::from_str(&read_file(&format!("{}/{}", path, Self::FILE_EXTENSION)).await?) - .map_err(|err| TraceError::Serde(err))?; - - Ok(apps) - } -} diff --git a/src-tauri/src/domain/storable.rs b/src-tauri/src/domain/storable.rs deleted file mode 100644 index e128281..0000000 --- a/src-tauri/src/domain/storable.rs +++ /dev/null @@ -1,9 +0,0 @@ -use crate::error::Error as TraceError; -use async_trait::async_trait; - -#[async_trait] -pub(crate) trait Storable<T> { - const FILE_EXTENSION: &str; - - async fn load_all(path: String) -> Result<T, TraceError>; -} diff --git a/src-tauri/src/domain/task.rs b/src-tauri/src/domain/task.rs deleted file mode 100644 index 85e07f2..0000000 --- a/src-tauri/src/domain/task.rs +++ /dev/null @@ -1,35 +0,0 @@ -use super::storable::Storable; -use crate::error::Error as TraceError; -use crate::mappers::read_file; -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use uuid::Uuid; - -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct Task { - pub app_id: Uuid, - pub id: u64, - pub tid: Option<u64>, - pub name: Option<String>, - pub kind: Option<String>, -} - -impl Task { - pub fn id(&self) -> String { - format!("{}.{}", self.app_id, self.id) - } -} - -#[async_trait] -impl Storable<HashMap<String, Task>> for Task { - const FILE_EXTENSION: &str = "tasks.json"; - - async fn load_all(path: String) -> Result<HashMap<String, Task>, TraceError> { - let tasks = - serde_json::from_str(&read_file(&format!("{}/{}", path, Self::FILE_EXTENSION)).await?) - .map_err(|err| TraceError::Serde(err))?; - - Ok(tasks) - } -} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs deleted file mode 100644 index dfc902e..0000000 --- a/src-tauri/src/error.rs +++ /dev/null @@ -1,26 +0,0 @@ -use uuid::Uuid; - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("URL: {0}")] - Url(#[from] url::ParseError), - #[error("Application with id {0} is already connected")] - ApplicationAlreadyConnected(Uuid), - #[error("TODO: add message for me")] - Anyhow(#[from] anyhow::Error), - #[error("Path {0} not found")] - PathNotFound(String), - #[error("Serde error encountered: {0}")] - Serde(#[from] serde_json::Error), - #[error("Cannot create the storage directory at path {path} due to {error}")] - CannotCreateStorage { error: anyhow::Error, path: String }, -} - -impl serde::Serialize for Error { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::ser::Serializer, - { - serializer.serialize_str(self.to_string().as_ref()) - } -} diff --git a/src-tauri/src/features/applications.rs b/src-tauri/src/features/applications.rs new file mode 100644 index 0000000..c5864ad --- /dev/null +++ b/src-tauri/src/features/applications.rs @@ -0,0 +1,219 @@ +use crate::backend::core::StateManager; +use crate::utils::error::Error; +use log::{error, info}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::sync::Arc; +use tauri::State as TauriState; +use uuid::Uuid; + +#[derive(Serialize, Deserialize)] +pub struct ExportEntry { + pub app_dir: String, + pub title: String, +} + +#[derive(Serialize, Deserialize)] +pub struct TimestampEntry { + pub ts: String, + pub path: String, + pub comment_preview: Option<String>, +} + +/// Add a new application to be monitored. +/// +/// This Tauri-exposed command checks whether an application +/// with the given URL is already registered; if it is not, +/// it converts the URL to [`Url`] and delegates to the +/// [`StateManager`] to store the new application record. +/// +/// # Arguments +/// +/// * `state_manager` – shared application state manager +/// * `title` – a human-readable title for the application +/// * `url` – the process endpoint URL; must be parseable into `Url` +/// +/// # Returns +/// +/// On success, returns the newly created application's [`Uuid`]. +/// If the URL or title was already registered, returns an [`Error::ApplicationAlreadyConnected`]. +/// If URL parsing fails, returns the appropriate [`Error`]. +#[tauri::command] +pub async fn applications_add( + state_manager: TauriState<'_, Arc<StateManager>>, + title: String, + url: &str, +) -> Result<Uuid, Error> { + info!("Received command to add application with title {title} and url {url}"); + state_manager.add_application_if_absent(title, url).await +} + +/// Delete a monitored application by its UUID. +/// +/// # Arguments +/// +/// * `state_manager` – shared application state manager +/// * `uuid` – unique identifier of the application to delete +/// +/// # Returns +/// +/// Returns `Ok(())` on success, or an [`Error`] on failure. +#[tauri::command] +pub async fn delete_application( + state_manager: TauriState<'_, Arc<StateManager>>, + uuid: Uuid, +) -> Result<(), Error> { + let _ = state_manager.delete_application(uuid).await; + Ok(()) +} + +/// Enable an application (i.e. start its connection). +/// +/// Finds the application in the current store, asks the +/// connection manager to establish a connection, and +/// sets the application state to `Enabled`. +/// +/// # Arguments +/// +/// * `state_manager` – shared application state manager +/// * `uuid` – identifier of the application to enable +/// +/// # Errors +/// +/// Returns [`Error::Anyhow`] if the app isn’t found or +/// if the connection manager fails to connect. +#[tauri::command] +pub async fn enable_app( + state_manager: TauriState<'_, Arc<StateManager>>, + uuid: Uuid, +) -> Result<(), Error> { + state_manager.enable_app(uuid).await +} + +/// Disable a previously enabled application (i.e. tear down its connection). +/// +/// # Arguments +/// +/// * `state_manager` – shared application state manager +/// * `uuid` – identifier of the application to disable +/// +/// # Returns +/// +/// Returns `Ok(())` or an [`Error`] if something goes wrong. +#[tauri::command] +pub async fn disable_app( + state_manager: TauriState<'_, Arc<StateManager>>, + uuid: Uuid, +) -> Result<(), Error> { + state_manager.disable_application(uuid).await +} + +/// Edit an application +/// +/// # Arguments +/// +/// * `state_manager` – shared application state manager +/// * `uuid` - identifier of the application to edit +/// * `app_title` - the new title of the application +/// * `app_url` - the new url of the application +/// * `old_title` - the current title of the application that needs to be edited +/// +/// # Returns +/// +/// On succes, returns the new Uuid of the application +#[tauri::command] +pub async fn edit_application( + state_manager: TauriState<'_, Arc<StateManager>>, + uuid: Uuid, + app_title: String, + app_url: String, + old_title: String, +) -> Result<Uuid, Error> { + state_manager + .edit_application(uuid, app_title, app_url, old_title) + .await +} + +/// Update an app’s PID in state and emit it to the frontend. +/// +/// # Arguments +/// * `state_manager` – shared application state manager +/// * `app_handle` – handle for emitting Tauri events +/// * `uuid` – application identifier +/// * `new_pid` – freshly discovered process ID +#[tauri::command] +pub async fn update_app_pid( + state_manager: TauriState<'_, Arc<StateManager>>, + app_handle: tauri::AppHandle, + uuid: Uuid, + new_pid: u32, +) -> Result<(), Error> { + state_manager.state.handle_pid_changed(uuid, new_pid).await; + + state_manager.emit_update_pid(&app_handle, uuid).await; + + Ok(()) +} + +/// Fetch the current PID for a given application. +/// +/// Frontend can call this after seeing a “pid‐changed” message +/// or poll at intervals. +#[tauri::command] +pub async fn get_app_pid( + state_manager: TauriState<'_, Arc<StateManager>>, + uuid: Uuid, +) -> Result<u32, Error> { + state_manager.state.get_pid_for(uuid).await.ok_or_else(|| { + error!("Error at fetching the current PID of the appliction with uuid: {uuid}"); + Error::Anyhow(anyhow::anyhow!("App {uuid} not found")) + }) +} + +#[tauri::command] +pub async fn export_app_instance( + title: String, + name: String, + state: TauriState<'_, Arc<StateManager>>, +) -> Result<String, String> { + let state = state.inner(); + state + .state + .export_app_instance(title, name) + .await + .map(|p| p.to_string_lossy().to_string()) + .map_err(|e| { + error!("Export error: {e}"); + format!("Export error: {e}") + }) +} + +#[tauri::command] +pub async fn list_exports( + state_manager: TauriState<'_, Arc<StateManager>>, + storage_folder: String, +) -> Result<Vec<ExportEntry>, String> { + let exports_base = Path::new(&storage_folder).join("exports"); + state_manager.list_exports_from_base(&exports_base).await +} + +#[tauri::command] +pub async fn list_app_timestamps( + storage_folder: String, + app_dir: String, + state_manager: TauriState<'_, Arc<StateManager>>, +) -> Result<Vec<TimestampEntry>, String> { + state_manager.app_timestamps(storage_folder, app_dir).await +} + +#[tauri::command] +pub async fn import_from_export_folder( + storage_folder: String, + app_dir: String, + ts: String, + state: TauriState<'_, Arc<StateManager>>, +) -> Result<(), String> { + state + .import_from_exp_folder(storage_folder, app_dir, ts) + .await +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/features/mod.rs similarity index 59% rename from src-tauri/src/commands/mod.rs rename to src-tauri/src/features/mod.rs index c0e9058..884f283 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/features/mod.rs @@ -1 +1,2 @@ pub mod applications; +pub mod tasks; diff --git a/src-tauri/src/features/tasks.rs b/src-tauri/src/features/tasks.rs new file mode 100644 index 0000000..f6ccc26 --- /dev/null +++ b/src-tauri/src/features/tasks.rs @@ -0,0 +1,39 @@ +use crate::backend::core::warnings::TaskWarnings; +use crate::backend::core::StateManager; +use crate::utils::error::Error; +use std::sync::Arc; +use tauri::State; + +/// Edit an existing task’s metadata (name, color, associated app). +/// +/// # Arguments +/// +/// * `state_manager` – shared application state manager +/// * `task_id` – numeric ID of the task +/// * `task_name` – new name for the task +/// * `task_color` – new color code for the task +/// * `app_name` – (optional) name of the application this task belongs to +/// +/// # Returns +/// +/// Returns `Ok(())` or an [`Error`]. +#[tauri::command] +pub async fn edit_task( + state_manager: State<'_, Arc<StateManager>>, + task_id: u64, + task_name: String, + task_color: String, + app_name: String, + warnings: TaskWarnings, +) -> Result<(), Error> { + state_manager + .state + .edit_state_task( + task_id, + task_name.clone(), + task_color.clone(), + app_name.clone(), + warnings.clone(), + ) + .await +} diff --git a/src-tauri/src/infra/storage.rs b/src-tauri/src/infra/storage.rs deleted file mode 100644 index 7938a9d..0000000 --- a/src-tauri/src/infra/storage.rs +++ /dev/null @@ -1,18 +0,0 @@ -use super::guard::WriteableDataBaseGuard; -use crate::domain::{application::Application, Task}; -use async_trait::async_trait; -use std::{collections::HashMap, sync::Arc}; -use uuid::Uuid; - -#[async_trait] -pub(crate) trait Storage: Send + Sync { - async fn applications_read(&self) -> HashMap<Uuid, Arc<Application>>; - - async fn applications_write( - &self, - ) -> WriteableDataBaseGuard<'_, HashMap<Uuid, Arc<Application>>>; - - async fn tasks_read(&self) -> HashMap<String, Arc<Task>>; - - async fn tasks_write(&self) -> WriteableDataBaseGuard<'_, HashMap<String, Arc<Task>>>; -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0f54a7a..06a5b7a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,67 +1,89 @@ -mod commands; -mod domain; -mod error; -mod infra; -mod mappers; -mod state_manager; -mod ui_manager; +pub mod backend; +pub mod features; +pub mod utils; -use state_manager::StateManager; +use backend::core::StateManager; use std::{sync::Arc, time::Duration}; use tauri::{async_runtime, Manager}; use tokio::{task, time::sleep}; +/// Boots and runs the Tauri application, setting up state, background tasks, +/// and UI event loops. +/// +/// This function: +/// 1. Initializes the in‐memory and on‐disk application state via `StateManager`. +/// 2. Spawns a background task to process state updates from `StateManager`. +/// 3. Configures Tauri with plugins, IPC commands, and two periodic loops: +/// - A UI update loop that pushes fresh state every second. +/// +/// Any failure to initialize persistence will cause a panic. pub async fn run() { - // Load context + // Load the shared application context: state manager plus channels for updates. let (state_manager, updates_receiver) = StateManager::new() .await - // TODO: should we panic here or disable the persistency? .unwrap_or_else(|err| panic!("Cannot start application due to {err:?}")); + // Wrap state manager in an Arc for safe sharing across tasks. let shared_state = Arc::new(state_manager); - // Start job - let state_manager = shared_state.clone(); - task::spawn(async move { - state_manager.run(updates_receiver).await; - }); + // Spawn the core background worker that consumes update messages and applies them. + { + let state_manager = shared_state.clone(); + task::spawn(async move { + state_manager.run(updates_receiver).await; + }); + } - // Clone for ui_updates + // Prepare clones for the two different asynchronous loops below. let ui_state_manager = shared_state.clone(); + + // Build and configure the Tauri application. tauri::Builder::default() + // Install the standard dialog and shell plugins. .plugin(tauri_plugin_dialog::init()) + // Make our shared state available via Tauri’s state‐injection API. .manage(shared_state) .setup(move |app| { - // FIX: workaround for the compilation error of the tonic crate, - // we need to compile using `--release` for now + // Workaround: Tonic crate may fail to compile in debug mode unless we + // explicitly release–build. We keep this comment until upstream fixes it. let window = app.get_webview_window("main").unwrap(); - // Open dev tools if in debug build + // Automatically open DevTools when running in debug mode. #[cfg(debug_assertions)] - { - window.open_devtools(); - } + window.open_devtools(); let app_handle = app.handle().clone(); - - // update ui once per second - // TODO could be improved + // Periodically emit UI updates once per second. async_runtime::spawn(async move { loop { sleep(Duration::from_secs(1)).await; ui_state_manager.emit_update_applications(&app_handle).await; ui_state_manager.emit_update_tasks(&app_handle).await; + ui_state_manager.emit_update_resources(&app_handle).await; + ui_state_manager.emit_update_polls(&app_handle).await; + ui_state_manager.emit_update_tasks_op(&app_handle).await; } }); Ok(()) }) + // Register our custom command handlers for IPC invocations from the UI. .plugin(tauri_plugin_shell::init()) .invoke_handler(tauri::generate_handler![ - commands::applications::applications_add, - commands::applications::delete_application, - commands::applications::disable_app, + features::applications::applications_add, + features::applications::delete_application, + features::applications::disable_app, + features::applications::enable_app, + features::applications::edit_application, + features::applications::update_app_pid, + features::applications::get_app_pid, + features::applications::export_app_instance, + features::applications::list_exports, + features::applications::list_app_timestamps, + features::applications::import_from_export_folder, + features::tasks::edit_task, ]) + // Launch the Tauri event loop with our generated context. .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 47565a0..56731a5 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,10 +1,12 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use env_logger as _; +use log::info; #[tokio::main] async fn main() { env_logger::init(); - println!("Starting"); + info!("Starting"); tokio_display_lib::run().await } diff --git a/src-tauri/src/mappers/tasks.rs b/src-tauri/src/mappers/tasks.rs deleted file mode 100644 index fe118f6..0000000 --- a/src-tauri/src/mappers/tasks.rs +++ /dev/null @@ -1,21 +0,0 @@ -use super::{read_field_value_string, read_field_value_u64}; -use crate::domain::Task; -use console_api::tasks; -use console_api::tasks::task::Kind; -use uuid::Uuid; - -pub fn map_to_domain_task(app_id: Uuid, task: &tasks::Task) -> Option<Task> { - let id = task.id.map(|value| value.id)?; - let tid = read_field_value_u64(task, "task.id"); - let name = read_field_value_string(task, "task.name").map(|s| s.to_owned()); - let kind = Kind::try_from(task.kind) - .map(|kind| kind.as_str_name().to_owned()) - .ok(); - Some(Task { - app_id, - id, - tid, - name, - kind, - }) -} diff --git a/src-tauri/src/state_manager/connection_manager.rs b/src-tauri/src/state_manager/connection_manager.rs deleted file mode 100644 index 75c91c3..0000000 --- a/src-tauri/src/state_manager/connection_manager.rs +++ /dev/null @@ -1,168 +0,0 @@ -#![allow(unused)] - -use crate::error::Error as TraceError; -use console_api::instrument::{instrument_client::InstrumentClient, InstrumentRequest, Update}; -use log::{error, info, warn}; -use std::{collections::HashMap, sync::Arc, time::Duration}; -use tauri::Url; -use tokio::{ - select, - sync::{ - mpsc::{self, Sender}, - RwLock, - }, - time::sleep, -}; -use tonic::{transport::Endpoint, Streaming}; -use uuid::Uuid; - -pub enum Command { - Disconnect, -} - -#[non_exhaustive] -pub enum Event { - Connecting, - Connected, - Update(Update), - Error(TraceError), - Disconnected, -} - -// TODO: need to check if still needed -#[derive(Clone, Debug)] -pub struct Connection { - pub commands: Sender<Command>, -} - -pub struct ConnectionManager { - updates_sender: Sender<(Uuid, Event)>, - active_connections: Arc<RwLock<HashMap<Uuid, tokio::task::JoinHandle<()>>>>, -} - -impl ConnectionManager { - pub fn new(updates_sender: Sender<(Uuid, Event)>) -> Self { - Self { - updates_sender, - active_connections: Arc::new(RwLock::new(HashMap::new())), - } - } - pub async fn connect_app(&self, uuid: Uuid, url: Url) -> Result<Connection, TraceError> { - let (command_sender, mut command_receiver) = mpsc::channel(100); - let connection = Connection { - commands: command_sender, - }; - - let updates_sender = self.updates_sender.clone(); - - if self.active_connections.read().await.contains_key(&uuid) { - warn!("Tried to add application with uuid {uuid}, but the id is already attached to a connected application"); - return Err(TraceError::ApplicationAlreadyConnected(uuid)); - } - - let connection_task = tokio::task::spawn(async move { - 'connection: loop { - // TODO: to check who will listen on this stream; enventually in the UI to give feedback to the user while trying to connect - updates_sender.send((uuid, Event::Connecting)).await.ok(); - - // Connect the app - let connection = 'connect: loop { - select! { - connection = Self::connect_to_app(&url) => { - // m-am conectat, astept comenzi mai jos - break 'connect connection; - } - command = command_receiver.recv() => { - match command { - Some(Command::Disconnect) | None => break 'connection - } - } - }; - }; - - // Vad daca primesc comenzi pt aplicatie (gen disconnect/disable) - // Check connection - match connection { - Ok(mut update_stream) => { - info!("Successfully connected to application with url {url}"); - - // TODO: who listens here? - updates_sender.send((uuid, Event::Connected)).await.ok(); - - // Wait for events - loop { - select! { - // Wait for new updates regarding our app - update = update_stream.message() => { - match update { - Ok(message) => { - if let Some(update) = message { - info!("Received an update about application with url {url}"); - updates_sender.send((uuid, Event::Update(update))).await.ok(); - } - } - Err(_error) => { - // TODO report error - // for now we disconnect - continue 'connection; - } - } - } - // Wait for external commands - command = command_receiver.recv() => { - if let Some(command) = command { - match command { - Command::Disconnect => break 'connection, - } - } else { - // Command stream is closed so we exit - break 'connection; - } - } - } - } - } - Err(error) => { - error!("Could not connect to application with url {url} due to {error:?}"); - updates_sender - .send((uuid, Event::Error(TraceError::Anyhow(error.into())))) - .await - .ok(); - - // Sleep before trying to connect again - sleep(Duration::from_secs(1)).await; - } - } - } - - updates_sender.send((uuid, Event::Disconnected)).await.ok(); - }); - - self.active_connections - .write() - .await - .insert(uuid, connection_task); - return Ok(connection); - } - - pub(crate) async fn disconnect_app(&self, uuid: Uuid) { - self.active_connections.write().await.remove(&uuid); - } - - async fn connect_to_app(url: &Url) -> Result<Box<Streaming<Update>>, TraceError> { - let endpoint = Endpoint::new(url.to_string()).map_err(|e| TraceError::Anyhow(e.into()))?; - let channel = endpoint - .connect() - .await - .map_err(|e| TraceError::Anyhow(e.into()))?; - let mut client = InstrumentClient::new(channel); - let update_request = tonic::Request::new(InstrumentRequest {}); - Ok(Box::new( - client - .watch_updates(update_request) - .await - .map_err(|e| TraceError::Anyhow(e.into()))? - .into_inner(), - )) - } -} diff --git a/src-tauri/src/state_manager/database.rs b/src-tauri/src/state_manager/database.rs deleted file mode 100644 index 9268e57..0000000 --- a/src-tauri/src/state_manager/database.rs +++ /dev/null @@ -1,135 +0,0 @@ -use crate::{ - domain::{application::Application, storable::Storable, Task}, - error::Error as TraceError, - infra::{guard::WriteableDataBaseGuard, storage::Storage}, -}; -use async_trait::async_trait; -use log::{debug, error}; -use std::{collections::HashMap, sync::Arc}; -use tokio::sync::RwLock; -use uuid::Uuid; - -/// Representation of all data stored on the disk for persistency -/// Provides read/write mechanisms that assure syncronisation with -/// disk files -#[derive(Default)] -pub(crate) struct Database { - storage_folder: String, - - // todo: astea trebuie scrise pe disk + incarcate la pornire - applications: tokio::sync::RwLock<HashMap<Uuid, Arc<Application>>>, - // toate taskurile curente de la toate aplicatiile - tasks: tokio::sync::RwLock<HashMap<String, Arc<Task>>>, -} - -impl Database { - /// Is creating a new, fresh database instance withouth - /// loading from disk - /// - /// Is replacing a default implementation, which could not - /// be added because the Database is used as a dyn Storage - /// (Self: Sized rule) - /// - /// This method should be used if loading failed - pub(crate) fn new(storage_folder: String) -> Self { - Self { - storage_folder, - applications: RwLock::new(HashMap::new()), - tasks: RwLock::new(HashMap::new()), - } - } - - /// Is loading the database from the disk - /// - /// If file location of any data is not found, a fresh instance will be used. - /// - /// # Error - /// - /// If failed to load the failed due to unrecoverable errors (eg: failed to serialize) - /// an error will be returned - pub(crate) async fn load(storage_folder: String) -> Result<Self, TraceError> { - // Load all applications - let applications: HashMap<Uuid, Arc<Application>> = - match Application::load_all(storage_folder.clone()).await { - Ok(apps) => apps - .into_iter() - .map(|(id, app)| (id, Arc::new(app))) - .collect(), - Err(error) => match error { - TraceError::PathNotFound(_) => { - debug!("Applications file not found, using empty list"); - HashMap::new() - } - _ => { - error!("Failed to load applications due to {error:?}"); - return Err(error); - } - }, - }; - debug!( - "Successfully loaded {} applications from disk.", - applications.values().len() - ); - - // Load all tasks - let tasks: HashMap<String, Arc<Task>> = match Task::load_all(storage_folder.clone()).await { - Ok(tasks) => tasks - .into_iter() - .map(|(id, task)| (id, Arc::new(task))) - .collect(), - Err(error) => match error { - TraceError::PathNotFound(_) => { - debug!("Tasks file not found, using empty list"); - HashMap::new() - } - _ => { - error!("Failed to load applications due to {error:?}"); - return Err(error); - } - }, - }; - debug!( - "Successfully loaded {} tasks from disk.", - tasks.values().len() - ); - - Ok(Self { - storage_folder, - applications: RwLock::new(applications), - tasks: RwLock::new(tasks), - }) - } -} - -#[async_trait] -impl Storage for Database { - async fn applications_read(&self) -> HashMap<Uuid, Arc<Application>> { - self.applications.read().await.clone() - } - - async fn applications_write( - &self, - ) -> WriteableDataBaseGuard<'_, HashMap<Uuid, Arc<Application>>> { - let elements = self.applications.write().await; - - WriteableDataBaseGuard { - folder: &self.storage_folder, - title: "applications", - elements, - } - } - - async fn tasks_read(&self) -> HashMap<String, Arc<Task>> { - self.tasks.read().await.clone() - } - - async fn tasks_write(&self) -> WriteableDataBaseGuard<'_, HashMap<String, Arc<Task>>> { - let elements = self.tasks.write().await; - - WriteableDataBaseGuard { - folder: &self.storage_folder, - title: "tasks", - elements, - } - } -} diff --git a/src-tauri/src/state_manager/mod.rs b/src-tauri/src/state_manager/mod.rs deleted file mode 100644 index 395d176..0000000 --- a/src-tauri/src/state_manager/mod.rs +++ /dev/null @@ -1,144 +0,0 @@ -// TODO: check if pub needed -pub mod connection_manager; -mod database; -pub mod state; - -use crate::domain::application::Application; -use crate::error::Error as TraceError; -use crate::state_manager::state::State; -use anyhow::Result; -use connection_manager::{ConnectionManager, Event}; -use log::{error, info}; -use std::sync::Arc; -use tauri::{AppHandle, Emitter as _}; -use tokio::sync::mpsc::{self, Receiver}; -use url::Url; -use uuid::Uuid; - -pub struct StateManager { - // Mpsc used to receive updates about connected applications - // (eg. number of running tasks, time ran) - // TODO: check if needed - // pub updates_sender: Sender<(Uuid, Event)>, - - // Manages the connection to the running applications - // and sends updates about them - pub connection_manager: ConnectionManager, - - pub state: State, -} - -impl StateManager { - pub async fn new() -> Result<(StateManager, Receiver<(Uuid, Event)>), TraceError> { - let (updates_sender, updates_receiver) = mpsc::channel(100); - - // TODO: check if error handling could be done better here (maybe looking for a single error is not the best case) - let state = match State::load().await { - // State loaded successfully - Ok(state) => state, - Err(error) => { - match error { - // Could not create storage location, - TraceError::CannotCreateStorage { error, path } => { - return Err(TraceError::CannotCreateStorage { error, path }) - } - // For any other errors we use a fresh state - err => { - error!("Failed to load previous state due to {err:?}. Using new State instance"); - State::new() - } - } - } - }; - - let context = StateManager { - connection_manager: ConnectionManager::new(updates_sender), - state, - }; - - Ok((context, updates_receiver)) - } - - // region events - - pub async fn run(&self, mut updates_receiver: Receiver<(Uuid, Event)>) { - // event loop - loop { - tokio::select! { - // Received updates about apps - Some((app_id, event)) = updates_receiver.recv() => { - match event { - Event::Update(update) => { - if let Some(task_update) = update.task_update { - self.state.handle_task_update(app_id, task_update).await; - } - } - _ => {} - } - }, - // todo: add other events receivers - // todo: add receiver to add application and send to connection manager then update state - } - } - } - - // endregion - - // region application - - /// Registers and enables a new application - /// - /// Is also connecting to the application in order to receive updates about it - pub async fn add_application(&self, title: String, url: Url) -> Result<Uuid, TraceError> { - // Create and enable application - let mut application = Application::new(title, url); - let app_id = application.id().clone(); - - // Connect to the app - let connection = self - .connection_manager - .connect_app(application.id().clone(), application.url().clone()) - .await?; - application.enable(connection); - - // Store app - self.state.store_app(application).await; - Ok(app_id) - } - - pub async fn disable_application(&self, uuid: Uuid) -> Result<(), TraceError> { - self.state.disable_app(uuid).await - } - - /// Returns a list of the applications currently registered in the app - /// (not necessarily active too) - pub async fn _current_applications(&self) -> Vec<Arc<Application>> { - self.state.get_current_applications_list().await - } - - pub async fn delete_connection(&self, uuid: Uuid) { - self.connection_manager.disconnect_app(uuid).await; - self.state.delete_app(uuid).await - } - - // endregion - - // region UPDATES - - pub async fn emit_update_tasks(&self, app_handle: &AppHandle) { - let tasks = self.state.get_tasks().await; - info!("Sending tasks update event with {} tasks", tasks.len()); - app_handle.emit("update:tasks", tasks).ok(); - } - - pub async fn emit_update_applications(&self, app_handle: &AppHandle) { - app_handle - .emit( - "update:applications", - self.state.get_current_applications_list().await, - ) - .ok(); - } - - // endregion -} diff --git a/src-tauri/src/state_manager/state.rs b/src-tauri/src/state_manager/state.rs deleted file mode 100644 index 6133ba1..0000000 --- a/src-tauri/src/state_manager/state.rs +++ /dev/null @@ -1,146 +0,0 @@ -use super::database::Database; -use crate::domain::application::ApplicationState; -use crate::error::Error as TraceError; -use crate::infra::guard::DataBaseWrite; -use crate::infra::storage::Storage; -use crate::{ - domain::{application::Application, Task}, - mappers::tasks::map_to_domain_task, -}; -use console_api::tasks::TaskUpdate; -use log::{error, info, warn}; -use std::sync::Arc; -use tokio::fs; -use uuid::Uuid; - -/// Is managing the access to the database and provides access method -/// tailored for the applications business locic needs -pub struct State { - database: Arc<dyn Storage>, -} - -impl State { - const STORAGE_FOLDER: &str = ".async-tracing"; - - /// Creates a new, fresh instance - /// Will not load the database anymore, but use empty lists for every - /// element - /// - /// Should be used in case of failure when loading - pub fn new() -> Self { - let path = dirs::home_dir().unwrap().join(Self::STORAGE_FOLDER); - info!("Storage location is: {path:?}"); - - Self { - database: Arc::new(Database::new(path.as_path().to_string_lossy().to_string())), - } - } - - /// Is loading state from previus application instance - /// - /// # Error - /// - /// If failed to load data from disk, will return an error - pub async fn load() -> Result<State, TraceError> { - let database_path = dirs::home_dir().unwrap().join(Self::STORAGE_FOLDER); - info!("Storage location is: {database_path:?}"); - - // Checking if storage folder exists - if !database_path.is_dir() { - // Create the storage folder - if let Err(error) = fs::create_dir(&database_path).await { - error!("Could not create the storage folder at path {database_path:?} due to {error:?}"); - return Err(TraceError::CannotCreateStorage { - error: error.into(), - path: database_path.to_string_lossy().to_string(), - }); - } - } - - let database = - Database::load(database_path.as_path().to_string_lossy().to_string()).await?; - - Ok(State { - database: Arc::new(database), - }) - } - - // region APPLICATIONS - - pub async fn get_current_applications_list(&self) -> Vec<Arc<Application>> { - self.database - .applications_read() - .await - .values() - .cloned() - .collect() - } - - pub async fn store_app(&self, application: Application) { - self.database - .applications_write() - .await - .insert(application.id().clone(), Arc::new(application)); - } - - pub async fn disable_app(&self, uuid: Uuid) -> Result<(), TraceError> { - let mut guard = self.database.applications_write().await; - - if let Some((_uuid, application)) = guard - .iter_mut() - .find(|(app_uuid, _app)| app_uuid.eq(&&uuid)) - { - let app = application.writeable(); - app.disable().await; - } - - Ok(()) - } - - pub async fn delete_app(&self, uuid: Uuid) { - self.database.applications_write().await.remove(&uuid); - } - - // endregion - - // region TASKS - - pub async fn handle_task_update(&self, app_id: Uuid, task_update: TaskUpdate) { - if let Some(app) = self.database.applications_read().await.get(&app_id) { - if app.state() == ApplicationState::Disabled { - // If app is disabled we dont save anything - return; - } - - // Saviing new tasks - for task in task_update.new_tasks { - if let Some(task) = map_to_domain_task(app_id, &task) { - info!("Received a new task for application with id {app_id}"); - self.database - .tasks_write() - .await - .insert(task.id(), Arc::new(task)); - } - } - - // Saving dropped tasks - for (tid, updated_task) in task_update.stats_update { - if updated_task.dropped_at.is_some() { - info!("A task was dropped for application {app_id}"); - self.database - .tasks_write() - .await - .remove(&format!("{}.{}", app_id, tid)); - } - } - } else { - warn!("Received an update for an app that is not registered"); - } - } - - pub async fn get_tasks(&self) -> Vec<Arc<Task>> { - self.database.tasks_read().await.values().cloned().collect() - } - - // endregion -} diff --git a/src-tauri/src/ui_manager/mod.rs b/src-tauri/src/ui_manager/mod.rs deleted file mode 100644 index 4ce18fd..0000000 --- a/src-tauri/src/ui_manager/mod.rs +++ /dev/null @@ -1,34 +0,0 @@ -// //! Ui manager care doar primeste update-urile si trimite event catre frontend cand sa re-randeze - -// use std::os::macos::raw::stat; - -// use tauri::AppHandle; - -// use crate::state_manager::state::State; - -// pub struct UiManager { -// app_handle: AppHandle, -// } - -// impl UiManager { -// pub fn new(app_handle: AppHandle) -> Self { -// Self { app_handle } -// } - -// pub fn render() {} - -// pub fn refresh(state: &State) { -// // send event -// app_handle -// .emit( -// "update:tasks", -// self.tasks -// .read() -// .await -// .iter() -// .map(|(_, value)| value) -// .collect::<Vec<&Arc<Task>>>(), -// ) -// .ok(); -// } -// } diff --git a/src-tauri/src/utils/common.rs b/src-tauri/src/utils/common.rs new file mode 100644 index 0000000..473f932 --- /dev/null +++ b/src-tauri/src/utils/common.rs @@ -0,0 +1,118 @@ +use crate::utils::error::Error as TraceError; +use chrono::{DateTime, Local}; +use log::error; +use log::info; +use std::process::Command; +use sysinfo::{Pid, System}; +use url::Url; + +/// Returns the PID of the process listening on the given URL’s port, if any. +/// +/// # Arguments +/// +/// * `url` – A `Url` instance pointing to the target host and port. +/// +/// # Returns +/// +/// * `Some(u32)` containing the PID of the process bound to that port, or +/// * `None` if the URL has no defined port or if no hosting process is found. +/// +/// # Platform-specific behavior +/// +/// On Linux and macOS, this spawns `lsof -ti :<port>` and parses its output. +/// On Windows, this spawns `netstat -ano` and searches for the port in each line. +pub fn get_pid_hosting_at(url: Url) -> Result<u32, TraceError> { + // Extract the port number from the URL; return None if absent. + let port = url + .port() + .ok_or(TraceError::PortNotFound { url: url.clone() })?; + + info!("Port: {}", port); + + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + // Use lsof to find processes listening on that port. + let output = Command::new("lsof") + .args(["-ti", &format!(":{}", port)]) + .output() + .map_err(|err| { + error!("Cannot execute lsof, error: {err:?}"); + TraceError::PIDNotFound { url: url.clone() } + })?; + + // If no output, no process found + if output.stdout.is_empty() { + return Err(TraceError::PIDNotFound { url: url.clone() }); + } + + // Parse the PID out of the stdout + let output_str = String::from_utf8_lossy(&output.stdout); + // In case the tracked app is restarted, lsof prints two PIDS and the correct PID + // is typically the last end line-separated token. + if let Some(pid_str) = output_str.lines().last() { + if let Ok(pid) = pid_str.parse::<u32>() { + info!("Found pin {:?} for url {:?}", pid, url); + return Ok(pid); + } + } + Err(TraceError::PIDNotFound { url: url.clone() }) + } + + #[cfg(target_os = "windows")] + { + // Use netstat to list all TCP/UDP connections with PIDs. + let output = Command::new("netstat") + .args(["-ano"]) + .output() + .map_err(|err| { + error!("Cannot execute netstat, error: {err:?}"); + TraceError::PIDNotFound { url: url.clone() } + })?; + // This needs to be lossy, because the netstat output sometimes contains non UTF-8 characters + let stdout = String::from_utf8_lossy(&output.stdout); + + // Look for lines containing “:<port>” + for line in stdout.lines() { + if line.contains(&format!(":{}", port)) { + // The PID is typically the last whitespace-separated token. + if let Some(pid_str) = line.split_whitespace().last() { + if let Ok(pid) = pid_str.parse::<u32>() { + return Ok(pid); + } + } + } + } + Err(TraceError::PIDNotFound { url: url.clone() }) + } +} + +/// Retrieves the process start time for a given PID, formatted as a human-readable string. +/// +/// # Arguments +/// +/// * `pid` – Process identifier (u32). +/// +/// # Returns +/// +/// * `Some(String)` containing the start time in the format `DD/MM/YYYY HH:MM:SS`, or +/// * `None` if no process with that PID exists. +pub fn get_process_start_time(pid: u32) -> Option<String> { + // Initialize a sysinfo System instance and gather all data. + let mut sys = System::new_all(); + sys.refresh_all(); + + let local = Local; + + // Look up the process by PID. + if let Some(process) = sys.process(Pid::from_u32(pid)) { + // Convert the process start time (seconds since epoch) into a formatted string. + let date: String = DateTime::from_timestamp(process.start_time() as i64, 0)? + .with_timezone(&local) + .format("%d/%m/%Y %H:%M:%S") + .to_string(); + return Some(date); + } + + error!("PID not found in the system"); + None +} diff --git a/src-tauri/src/utils/error.rs b/src-tauri/src/utils/error.rs new file mode 100644 index 0000000..19a6160 --- /dev/null +++ b/src-tauri/src/utils/error.rs @@ -0,0 +1,89 @@ +use url::Url; + +/// Represents all errors that can occur in the application. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// URL parsing failed. + #[error("URL: {0}")] + Url(#[from] url::ParseError), + + /// An application with the given ID is already connected. + #[error("ApplicationAlreadyConnected: Application with id {0} is already connected")] + ApplicationAlreadyConnected(String), + + /// A catch-all error for uses of `anyhow::Error`. + #[error("The app encountered a problem")] + Anyhow(#[from] anyhow::Error), + + /// The specified file or directory path was not found. + #[error("Path {0} not found")] + PathNotFound(String), + + /// A JSON serialization or deserialization error. + #[error("Serde error encountered: {0}")] + Serde(#[from] serde_json::Error), + + /// Failed to create the storage directory at the given path. + #[error("Cannot create the storage directory at path {path} due to {error}")] + CannotCreateStorage { + /// The underlying error that prevented creation. + error: anyhow::Error, + /// The filesystem path where creation was attempted. + path: String, + }, + + /// No process PID could be found for the given URL. + #[error("PIDNotFound: Could not find the PID of an application hosting at {url}")] + PIDNotFound { + /// The URL whose port was scanned for a hosting process. + url: Url, + }, + + /// Failed to read process information for the given PID. + #[error("Could not find the application with the PID {pid}")] + CannotReadProcessInfo { + /// The process identifier that could not be found. + pid: u32, + }, + + /// Failed to create an IPC or messaging channel for the application. + #[error( + "CannotCreateChannelForApp: Cannot create a channel for the application with url {url}" + )] + CannotCreateChannelForApp { + /// The application URL for which channel creation failed. + url: String, + }, + + #[error("Task not found: {0}")] + TaskNotFound(String), + + /// Failed to get the port of URL + #[error("PortNotFound: Could not find the port URL: {url}")] + PortNotFound { + /// The application URL for which the port was not found. + url: Url, + }, + + // AsyncOp ID not found + #[error("IDNotFound")] + IDNotFound, + + // AsyncOp ResourceID not found + #[error("ResourceIDNotFound")] + ResourceIDNotFound, + + // AsyncOp IDAndResourceIDNotFound not found + #[error("IDAndResourceIDNotFound")] + IDAndResourceIDNotFound, +} + +impl serde::Serialize for Error { + /// Serializes the error into a single string (its Display representation). + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::ser::Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/src-tauri/src/utils/mod.rs b/src-tauri/src/utils/mod.rs new file mode 100644 index 0000000..3c22a0c --- /dev/null +++ b/src-tauri/src/utils/mod.rs @@ -0,0 +1,2 @@ +pub mod common; +pub mod error; diff --git a/src/App.vue b/src/App.vue index a5e623c..312cf81 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,7 +1,75 @@ -<script setup lang="ts"> -import { RouterView } from 'vue-router'; -</script> - <template> - <RouterView></RouterView> -</template> \ No newline at end of file + <v-app> + <v-main> + <router-view /> + </v-main> + </v-app> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, onBeforeUnmount } from "vue"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { useDataStore } from "@/stores/data"; +import { useApplicationStore } from "@/stores/application"; +import type { Task } from "@/types/tasks"; +import type { Resource } from "@/types/resources"; +import type { Poll } from "@/types/polls"; + +export default defineComponent({ + name: "AppShell", + setup() { + const dataStore = useDataStore(); + const applicationsStore = useApplicationStore(); + const unlisteners: UnlistenFn[] = []; + + const onKeydown = (e: KeyboardEvent) => { + if (e.code !== "Space") return; + + const active = document.activeElement; + const isTypingInInput = + active instanceof HTMLInputElement || + active instanceof HTMLTextAreaElement || + (active instanceof HTMLElement && active.isContentEditable); + + if (isTypingInInput) return; + + e.preventDefault(); + dataStore.togglePause(); + }; + + onMounted(async () => { + // global spacebar toggle (unless typing) + window.addEventListener("keydown", onKeydown, { passive: false }); + + // pid updates + const unlistenPid = await listen<{ id: string; pid: number }>("update:pid", (evt) => { + const { id, pid } = evt.payload; + const app = applicationsStore.applications.find(a => a.id === id); + if (app) app.pid = pid; + }); + + // task updates + const unlistenTasks = await listen("update:tasks", (evt: { payload: Task[] }) => dataStore.handleTaskUpdate(evt)); + + // resources updates + const unlistenResources = await listen("update:resources", (evt: { payload: Resource[] }) => dataStore.handleResourceUpdate(evt)); + + // polls updates + const unlistenPolls = await listen("update:polls", (evt: { payload: Poll[] }) => dataStore.handlePollUpdate(evt)); + + unlisteners.push(unlistenPid, unlistenTasks, unlistenResources, unlistenPolls); + }); + + onBeforeUnmount(() => { + window.removeEventListener("keydown", onKeydown); + unlisteners.forEach((fn) => { + try { fn(); } catch (err) { + console.log("unlisten failed: ", err); + } + }); + }); + + return {}; + }, +}); +</script> diff --git a/src/layout/AppLayout.vue b/src/layout/AppLayout.vue index db03f1e..20f49c4 100644 --- a/src/layout/AppLayout.vue +++ b/src/layout/AppLayout.vue @@ -1,12 +1,3 @@ -<script setup lang="ts"> -import { useLayoutStore } from '@/stores/layout'; -import VerticalSidebar from './sidebar/VerticalSidebar.vue'; -import VerticalHeader from './header/VerticalHeader.vue'; - -const layoutStore = useLayoutStore(); - -</script> - <template> <v-app> <VerticalHeader /> @@ -20,3 +11,23 @@ const layoutStore = useLayoutStore(); </v-main> </v-app> </template> + +<script lang="ts"> +import { defineComponent } from "vue"; +import { useLayoutStore } from "@/stores/layout"; +import VerticalSidebar from "./sidebar/VerticalSidebar.vue"; +import VerticalHeader from "./header/VerticalHeader.vue"; + +export default defineComponent({ + name: "AppLayout", + components: { + VerticalSidebar, + VerticalHeader, + }, + data() { + return { + layoutStore: useLayoutStore(), + }; + }, +}); +</script> diff --git a/src/layout/header/VerticalHeader.vue b/src/layout/header/VerticalHeader.vue index 4f5ce8e..32d202c 100644 --- a/src/layout/header/VerticalHeader.vue +++ b/src/layout/header/VerticalHeader.vue @@ -1,14 +1,30 @@ -<script setup lang="ts"> -import { useLayoutStore } from '@/stores/layout'; -const layoutStore = useLayoutStore(); -</script> - <template> - <v-app-bar elevation="1"> - <v-app-bar-nav-icon variant="text" @click.stop="layoutStore.triggerSidebar()" class="mr-1"></v-app-bar-nav-icon> - <div class="d-flex align-center flex-grow-1"> - <img class="mr-2" src="../../assets/logo.png" width="30" height="30"> - <v-toolbar-title class="text-no-wrap">Async Debug Tool</v-toolbar-title> + <v-app-bar elevation="1"> + <v-app-bar-nav-icon variant="text" @click.stop="layoutStore.triggerSidebar()" class="mr-1"></v-app-bar-nav-icon> + <div class="d-flex align-center flex-grow-10"> + <img class="mr-2" src="../../assets/logo.png" width="30" height="30"> + <v-toolbar-title class="text-no-wrap">Async Debug Tool</v-toolbar-title> + <div class="ml-5"> + <v-chip :color="dataStore.pause ? 'red' : 'green'" dark> + {{ dataStore.pause ? 'Paused' : 'Connected' }} + </v-chip> </div> - </v-app-bar> - </template> \ No newline at end of file + </div> + </v-app-bar> +</template> + +<script lang="ts"> +import { defineComponent } from "vue"; +import { useLayoutStore } from "@/stores/layout"; +import { useDataStore } from "@/stores/data"; + +export default defineComponent({ + name: "VerticalHeader", + data() { + return { + layoutStore: useLayoutStore(), + dataStore: useDataStore(), + }; + }, +}); +</script> diff --git a/src/layout/sidebar/Icon.vue b/src/layout/sidebar/Icon.vue index d03d088..c76bdac 100644 --- a/src/layout/sidebar/Icon.vue +++ b/src/layout/sidebar/Icon.vue @@ -1,7 +1,3 @@ -<script setup> -const props = defineProps({ item: Object, level: Number }); -</script> - <template> <template v-if="level > 0"> <component :is="item" size="14" stroke-width="1.5" class="iconClass"></component> @@ -10,3 +6,21 @@ const props = defineProps({ item: Object, level: Number }); <component :is="item" size="20" stroke-width="1.5" class="iconClass"></component> </template> </template> + +<script lang="ts"> +import { defineComponent } from "vue"; + +export default defineComponent({ + name: " SidebarIcon", + props: { + item: { + type: Object, + required: true, + }, + level: { + type: Number, + required: true, + }, + }, +}); +</script> diff --git a/src/layout/sidebar/NavItem.vue b/src/layout/sidebar/NavItem.vue index 550d646..558152c 100644 --- a/src/layout/sidebar/NavItem.vue +++ b/src/layout/sidebar/NavItem.vue @@ -1,13 +1,29 @@ -<script setup> -import Icon from './Icon.vue'; -const props = defineProps({ item: Object, level: Number }); -</script> - <template> - <v-list-item :to="item.to" rounded class="mb-1"> - <template v-slot:prepend > - <Icon :item="item.icon" :level="level" class="mr-2"/> - </template> - <v-list-item-title>{{ item.title }}</v-list-item-title> - </v-list-item> + <v-list-item :to="item.to" rounded class="mb-1"> + <template v-slot:prepend> + <SidebarIcon :item="item.icon" :level="level" class="mr-2" /> + </template> + <v-list-item-title>{{ item.title }}</v-list-item-title> + </v-list-item> </template> + +<script lang="ts"> +import { defineComponent, type PropType } from "vue"; +import SidebarIcon from "./Icon.vue"; +import type { sidebarItem } from "./sidebarItems"; + +export default defineComponent({ + name: "NavItem", + components: { SidebarIcon }, + props: { + item: { + type: Object as PropType<sidebarItem>, + required: true, + }, + level: { + type: Number, + required: true, + }, + }, +}); +</script> diff --git a/src/layout/sidebar/VerticalSidebar.vue b/src/layout/sidebar/VerticalSidebar.vue index e9a24c1..49be2a7 100644 --- a/src/layout/sidebar/VerticalSidebar.vue +++ b/src/layout/sidebar/VerticalSidebar.vue @@ -1,21 +1,35 @@ -<script setup lang="ts"> -import { shallowRef } from 'vue'; -import sidebarItems from './sidebarItems'; -import NavItem from './NavItem.vue'; -import { useLayoutStore } from '@/stores/layout'; - -const sidebarMenu = shallowRef(sidebarItems); -const layoutStore = useLayoutStore(); - -const drawer = layoutStore.getSidebarState; -</script> - <template> <v-navigation-drawer v-model="drawer" location="left" temporary> <v-list class="py-5 px-4 bg-muted"> - <template v-for="item in sidebarMenu"> - <NavItem :item="item" /> - </template> + <NavItem + v-for="item in sidebarMenu" + :key="item.to" + :item="item" + :level="0" + /> </v-list> </v-navigation-drawer> </template> + +<script lang="ts"> +import { defineComponent, shallowRef } from "vue"; +import sidebarItems from "./sidebarItems"; +import NavItem from "./NavItem.vue"; +import { useLayoutStore } from "@/stores/layout"; + +export default defineComponent({ + name: "SidebarMenu", + components: { + NavItem, + }, + data() { + const layoutStore = useLayoutStore(); + const drawer = layoutStore.getSidebarState; + return { + sidebarMenu: shallowRef(sidebarItems), + layoutStore, + drawer, + }; + }, +}); +</script> diff --git a/src/layout/sidebar/sidebarItems.ts b/src/layout/sidebar/sidebarItems.ts index 22a28cd..10faeaa 100644 --- a/src/layout/sidebar/sidebarItems.ts +++ b/src/layout/sidebar/sidebarItems.ts @@ -1,4 +1,4 @@ -import { ChecklistIcon, InfoSquareRoundedIcon, TablerIconComponent } from "vue-tabler-icons" +import { ChecklistIcon,InfoSquareRoundedIcon, TablerIconComponent } from "vue-tabler-icons" export interface sidebarItem { title: string, @@ -17,6 +17,16 @@ const sidebarItems: sidebarItem[] = [ icon: ChecklistIcon, to: '/tasks-overview' }, + { + title: 'Resources Overview', + icon: ChecklistIcon, + to: '/resources-overview' + }, + { + title: 'Polling Overview', + icon: ChecklistIcon, + to: '/polls-overview' + } ] export default sidebarItems; diff --git a/src/main.ts b/src/main.ts index 5f8615c..e6a94da 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import App from "./App.vue"; import vuetify from './plugins/vuetify'; import VueTablerIcons from 'vue-tabler-icons'; import { router } from './router'; +import './styles/timestamps.css'; const app = createApp(App); app.use(router); diff --git a/src/plugins/vuetify.ts b/src/plugins/vuetify.ts index d6e9e48..f65df64 100644 --- a/src/plugins/vuetify.ts +++ b/src/plugins/vuetify.ts @@ -10,7 +10,7 @@ const lightTheme: ThemeDefinition = { dark: false, colors: { primary: "#d35400", - secondary: "#8e44ad", + secondary: "#21768c", background: "#ecf0f1", error: "#c0392b", info: "#2980b9", @@ -24,7 +24,7 @@ const darkTheme: ThemeDefinition = { dark: true, colors: { primary: "#d35400", - secondary: "#8e44ad", + secondary: "#21768c", background: "#2f3640", error: "#c0392b", info: "#2980b9", diff --git a/src/router/MainRoutes.ts b/src/router/MainRoutes.ts index 2439394..41a3772 100644 --- a/src/router/MainRoutes.ts +++ b/src/router/MainRoutes.ts @@ -20,6 +20,16 @@ const MainRoutes = { path: '/tasks-overview', component: () => import('@/views/Tasks.vue') }, + { + name: 'Resources Overview', + path: '/resources-overview', + component: () => import('@/views/Resources.vue') + }, + { + name: 'Polling Overview', + path: '/polls-overview', + component: () => import('@/views/Polls.vue') + }, ] } diff --git a/src/stores/application.ts b/src/stores/application.ts index 52dd070..2303eaf 100644 --- a/src/stores/application.ts +++ b/src/stores/application.ts @@ -15,11 +15,15 @@ export const useApplicationStore = defineStore('applications', () => { id: uuid as string, title: title, url: url, - state: 'Enabled' + state: 'Enabled', + processStatus: "", + cpu_usage: 0.0, + memory_usage: 0, + pid: 0, + startTime: '0', + connection_status: "" }); } - ).catch( - (error) => console.log("Failed to send add application command: " + error) ); } @@ -35,21 +39,40 @@ export const useApplicationStore = defineStore('applications', () => { async function editApplication(app: Application) { const indexOfApp = applications.value.findIndex(item => item.id === app.id); + if (indexOfApp == -1) return; - if (indexOfApp !== -1) { - applications.value[indexOfApp] = app; + const appToEdit = applications.value[indexOfApp]; + if (appToEdit) { + appToEdit.connection_status = "Connecting"; + await invoke('edit_application', {uuid: app.id, appTitle: app.title, appUrl: app.url, oldTitle: appToEdit.title}).then( + (uuid) => { + appToEdit.title = app.title; + appToEdit.url = app.url; + appToEdit.id = uuid as string; + } + ).catch( + (e) => console.log("Failed to edit application due to " + e) + ); + //appToEdit.connection_status = "Connected"; } } - // TODO cheama din functiile Amaliei - async function toggleAppState(appID: string) { - const application = applications.value.find(item => item.id === appID); - if (application) { - application.state = application.state === 'Enabled' ? 'Disabled' : 'Enabled'; + async function toggleAppState(app: Application) { + try { + if (app.state === "Enabled") { + await invoke("disable_app", { uuid: app.id }); + app.state = "Disabled"; + } else { + await invoke("enable_app", { uuid: app.id }); + app.state = "Enabled"; + } + } catch (e) { + console.error("toggleAppState failed", e); } - }; + } return { - applications, getApplications, addApplication, deleteApplication, editApplication, toggleAppState + applications, getApplications, addApplication, deleteApplication, editApplication, + toggleAppState } }); \ No newline at end of file diff --git a/src/stores/data.ts b/src/stores/data.ts new file mode 100644 index 0000000..525a7df --- /dev/null +++ b/src/stores/data.ts @@ -0,0 +1,86 @@ +import { Resource } from "@/types/resources"; +import { Task, TaskWarnings } from "@/types/tasks"; +import { Poll } from "@/types/polls"; +import { defineStore } from "pinia"; +import { ref } from "vue"; +import { invoke } from "@tauri-apps/api/core"; + +export const useDataStore = defineStore('data', () => { + const pause = ref(false); + + function togglePause(){ + pause.value = !pause.value; + } + + const tasks= ref<Task[]>([]); + + function handleTaskUpdate(e: {payload: Task[]}) { + if (pause.value == false) { + tasks.value = e.payload.map(t => { + const formatted = { + runtime: t.runtime, + scheduled: t.scheduled, + idle: t.idle, + busy: t.busy, + } + + let stateKey: string + if (typeof t.state === 'string') { + stateKey = t.state + } else { + const keys = Object.keys(t.state ?? {}) + stateKey = keys.length ? keys[0]! : 'Unknown' + } + + return { + ...t, + state: stateKey, + ...formatted, + } + }) + } + } + + async function editTask(task_id: number, task_name: string, task_color:string, app_name: string, warnings: TaskWarnings) { + await invoke('edit_task', {taskId: task_id, taskName: task_name, taskColor: task_color, appName: app_name, warnings: warnings}).then( + () => { + console.log("task " + task_id.toString() + " was successfully edited."); + } + ).catch( + (error) => { + console.log("error editing task " + task_id.toString() + ": " + error); + throw error; + } + ); + } + + const resources = ref<Resource[]>([]); + + function handleResourceUpdate(e: {payload: Resource[]}) { + if (pause.value == false) { + resources.value = e.payload.map(r => { + return { + ...r, + duration: r.duration + } + }) + } + } + + const polls = ref<Poll[]>([]); + + function handlePollUpdate(e: {payload: Poll[]}) { + if (pause.value == false) { + polls.value = e.payload.map(p => { + return { + ...p + } + }) + } + } + + + return {pause, togglePause, tasks, handleTaskUpdate, resources, + handleResourceUpdate, polls, handlePollUpdate, editTask, + }; +}); \ No newline at end of file diff --git a/src/styles/timestamps.css b/src/styles/timestamps.css new file mode 100644 index 0000000..df53ad2 --- /dev/null +++ b/src/styles/timestamps.css @@ -0,0 +1,38 @@ +.timestamp-chips { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + font-family: 'Roboto Mono', monospace; +} + +.timestamp-chip { + display: inline-block; + padding: 4px 8px; + border-radius: 12px; + font-size: 0.8em; + font-weight: 500; + margin: 0 2px; +} + +.timestamp-chip--date { + background: #f5f5f5; + color: #666; + border: 1px solid #ddd; +} + +.timestamp-chip--time { + background: #e3f2fd; + color: #1976d2; + border: 1px solid #2196f3; +} + +.timestamp-chip--seconds { + color: #f44336; +} + +.timestamp-chip--ms { + background: transparent; + color: #ff9800; + font-size: 0.85em; +} \ No newline at end of file diff --git a/src/types/applications.d.ts b/src/types/applications.d.ts index 9f91dcb..44ceb00 100644 --- a/src/types/applications.d.ts +++ b/src/types/applications.d.ts @@ -1,11 +1,12 @@ -export type Application = { +export interface Application { + connection_status: string; + state: string, + pid: number; id: string; + startTime: string; title: string; url: string; - state: string; - - startTime?: string, - pid?: number, - cpuUsage?: number, - memoryUsage?: number, -} + cpu_usage: number; + memory_usage: number; + processStatus: string; +} \ No newline at end of file diff --git a/src/types/async_ops.d.ts b/src/types/async_ops.d.ts new file mode 100644 index 0000000..9c417ba --- /dev/null +++ b/src/types/async_ops.d.ts @@ -0,0 +1,21 @@ +export interface TimeStamp { + seconds: number; + nanos: number; +} + +export interface CPUOverview { + started_at: TimeStamp | null; + stopped_at: TimeStamp | null; + resource_target: string | null; + location: string | null; + pid?: number | null; +} + +export interface TaskOp { + task_id: string; + task_name: string; + task_colour: string; + operations: CPUOverview[]; +} + +export type TaskOpMap = Record<string, TaskOp>; diff --git a/src/types/polls.d.ts b/src/types/polls.d.ts new file mode 100644 index 0000000..590d708 --- /dev/null +++ b/src/types/polls.d.ts @@ -0,0 +1,12 @@ +export type Poll = { + received_at: string, + app_name: string, + poll_type: string, + resource_id: string, + resource_name: string, + task_id: number, + task_name: string, + task_color: string, + is_ready: boolean, + location: string, +} \ No newline at end of file diff --git a/src/types/resources.d.ts b/src/types/resources.d.ts new file mode 100644 index 0000000..f3acbb9 --- /dev/null +++ b/src/types/resources.d.ts @@ -0,0 +1,10 @@ +export type Resource = { + app_name: string, + resource_type: string, + id: number, + status: string, + target: string, + duration: string, + location: string, + attributes: string, +} \ No newline at end of file diff --git a/src/types/tasks.d.ts b/src/types/tasks.d.ts index 4659304..315589d 100644 --- a/src/types/tasks.d.ts +++ b/src/types/tasks.d.ts @@ -1,7 +1,52 @@ export type Task = { - app_id: string, - id: number; - tid?: number; - name?: string; - kind: string; + app_name: string, + id: number, + tid: number, + name: string, + color: string, + kind: string, + state: string, + runtime: string, + scheduled: string, + idle: string, + busy: string, + location: string, + created_at: string, + warnings: TaskWarnings +}; + +type SelfWakePercent = { + enabled: boolean; + parameter: number; + description: string; +}; + +type LostWaker = { + enabled: boolean; + parameter?: number, +}; + +type NeverYielded = { + enabled: boolean; + parameter: number; + description: string; +}; + +type AutoBoxedFuture = { + enabled: boolean; + parameter?: number, +}; + +type LargeFuture = { + enabled: boolean; + parameter: number; + description: string; +}; + +type TaskWarnings = { + self_wake_percent: SelfWakePercent; + lost_waker: LostWaker; + never_yielded: NeverYielded; + auto_boxed_feature: AutoBoxedFuture; + large_feature: LargeFuture; }; diff --git a/src/views/Polls.vue b/src/views/Polls.vue new file mode 100644 index 0000000..e439f5f --- /dev/null +++ b/src/views/Polls.vue @@ -0,0 +1,148 @@ +<template> + <v-card elevation="2"> + <v-card-text> + <h1 class="mb-4 font-weight-bold">Polling Overview</h1> + + <div class="d-flex align-center justify-space-between mb-4"> + <v-text-field + v-model="pollsSearch" + label="Search" + prepend-inner-icon="mdi-magnify" + variant="outlined" + hide-details + single-line + class="search-container" + /> + </div> + + <div class="d-flex flex-wrap mb-4"> + <v-btn + v-for="app in appList" + :key="app" + :color="selectedApp === app ? 'primary' : 'grey lighten-2'" + variant="tonal" + size="small" + class="ma-1" + @click="selectedApp = app" + > + {{ app }} + </v-btn> + </div> + + <v-data-table + :headers="pollsHeaders" + :items="filteredPolls" + > + <template v-slot:[`item.is_ready`]="{ item }"> + <v-chip + :color="item.is_ready ? 'green' : 'red'" + size="small" + class="text-uppercase" + > + {{ item.is_ready ? 'Ready' : 'Not Ready' }} + </v-chip> + </template> + + <template v-slot:[`item.received_at`]="{ item }"> + <span v-html="item.received_at"></span> + </template> + + <template v-slot:[`item.resource_name`]="{ item }"> + <v-tooltip location="bottom"> + <template #activator="{ props }"> + <span v-bind="props"> + {{ item.resource_name }} + </span> + </template> + <span>ID:{{ item.resource_id }}</span> + </v-tooltip> + </template> + + <template v-slot:[`item.location`]="{ item }"> + <v-chip + v-if="item.location === 'Unknown'" + color="grey" + size="small" + > + Unknown + </v-chip> + + <span + v-else + v-html="item.location" + ></span> + </template> + + <template v-slot:[`item.task_name`]="{ item }"> + <v-tooltip location="bottom"> + <template #activator="{ props }"> + <v-chip + :color="item.task_color? item.task_color : 'gray'" + size="small"> + <span v-bind="props"> + {{ item.task_name ? item.task_name : item.task_id }} + </span> + </v-chip> + </template> + <span>ID:{{ item.task_id }}</span> + </v-tooltip> + </template> + </v-data-table> + </v-card-text> + </v-card> +</template> + +<script lang="ts"> +import { defineComponent } from "vue"; +import type { DataTableHeader } from "vuetify"; +import { useDataStore } from "@/stores/data"; +import type { Poll } from "@/types/polls"; + +export default defineComponent({ + name: "PollsOverview", + data() { + const dataStore = useDataStore(); + + return { + dataStore, + pollsSearch: "" as string, + selectedApp: "All" as string, + pollsHeaders: [ + { title: "Received at", key: "received_at", align: "center" }, + { title: "Poll Type", key: "poll_type", align: "center" }, + { title: "Resource Name", key: "resource_name", align: "center" }, + { title: "Task", key: "task_name", align: "center" }, + { title: "Status", key: "is_ready", align: "center" }, + { title: "Location", key: "location", align: "center" }, + ] as DataTableHeader[], + }; + }, + + computed: { + appList(): string[] { + const names = this.dataStore.polls.map((p) => p.app_name); + const unique = Array.from(new Set(names)).sort(); + return ["All", ...unique]; + }, + + filteredPolls(): Poll[] { + const q = this.pollsSearch.toLowerCase(); + + return this.dataStore.polls + .filter( + (p) => this.selectedApp === "All" || p.app_name === this.selectedApp + ) + .filter((p) => { + return ( + p.poll_type.toLowerCase().includes(q) || + p.resource_id.toString().includes(q) || + p.resource_name.toString().includes(q) || + p.task_id.toString().includes(q) || + p.task_name.toString().includes(q) || + p.location.toLowerCase().includes(q) + ); + }); + }, + }, +}); +</script> \ No newline at end of file diff --git a/src/views/Resources.vue b/src/views/Resources.vue new file mode 100644 index 0000000..e3a45a4 --- /dev/null +++ b/src/views/Resources.vue @@ -0,0 +1,148 @@ +<template> + <v-card elevation="2"> + <v-card-text> + <h1 class="mb-4 font-weight-bold">Resources</h1> + + <div class="d-flex align-center justify-space-between mb-4"> + <v-text-field + v-model="resourcesSearch" + label="Search" + prepend-inner-icon="mdi-magnify" + variant="outlined" + hide-details + single-line + class="search-container" + /> + </div> + + <div class="d-flex flex-wrap mb-4"> + <v-btn + v-for="app in appList" + :key="app" + :color="selectedApp === app ? 'primary' : 'grey lighten-2'" + variant="tonal" + size="small" + class="ma-1" + @click="selectedApp = app" + > + {{ app }} + </v-btn> + </div> + + <v-data-table + :headers="resourcesHeaders" + :items="filteredResources" + :item-value="item => `${item.app_name}-${item.id}`" + > + <template v-slot:[`item.status`]="{ item }"> + <v-chip + :color="getResourceChipColor(item.status)" + size="small" + class="text-uppercase" + > + {{ item.status }} + </v-chip> + </template> + + <template v-slot:[`item.location`]="{ item }"> + <span v-html="item.location"></span> + </template> + + <template v-slot:[`item.duration`]="{ item }"> + <span>{{ formatDuration(item.duration) }}</span> + </template> + </v-data-table> + </v-card-text> + </v-card> +</template> + +<script lang="ts"> +import { defineComponent } from "vue"; +import type { DataTableHeader } from "vuetify"; +import { useDataStore } from "@/stores/data"; +import type { Resource } from "@/types/resources"; + +export default defineComponent({ + name: "ResourcesOverview", + data() { + const dataStore = useDataStore(); + + return { + dataStore, + resourcesSearch: "" as string, + selectedApp: "All" as string, + + resourcesHeaders: [ + { title: "Resource Type", key: "resource_type", align: "center" }, + { title: "ID", key: "id", align: "center" }, + { title: "Status", key: "status", align: "center" }, + { title: "Target", key: "target", align: "center" }, + { title: "Duration", key: "duration", align: "center" }, + { title: "Location", key: "location", align: "center" }, + { title: "Attributes", key: "attributes", align: "center" }, + ] as DataTableHeader[], + }; + }, + + computed: { + appList(): string[] { + const names = this.dataStore.resources.map((r) => r.app_name); + const unique = Array.from(new Set(names)).sort(); + return ["All", ...unique]; + }, + + filteredResources(): Resource[] { + const q = this.resourcesSearch.toLowerCase(); + + return this.dataStore.resources + .filter( + (r) => this.selectedApp === "All" || r.app_name === this.selectedApp + ) + .filter((r) => { + return ( + r.resource_type.toLowerCase().includes(q) || + r.id.toString().includes(q) || + r.status.toString().includes(q) || + r.location.toLowerCase().includes(q) + ); + }); + }, + }, + + methods: { + getResourceChipColor(status: string) { + switch (status) { + case "Ready": + return "green"; + default: + return "gray"; + } + }, + + toNumber(x: unknown): number | undefined { + if (x === null || x === undefined) return undefined; + if (typeof x === "number") return x; + if (typeof x === "string" && x.trim() !== "") { + const n = Number(x); + return Number.isNaN(n) ? undefined : n; + } + return undefined; + }, + + formatDuration(v: unknown): string { + if (v == null) return ""; + if (typeof v === "string") return v; + + const d = v as { + secs?: number; seconds?: number; Secs?: number; + nanos?: number; Nanos?: number; Nano?: number; + }; + + const secs = this.toNumber(d.secs ?? d.seconds ?? d.Secs) ?? 0; + const nanos = this.toNumber(d.nanos ?? d.Nanos ?? d.Nano) ?? 0; + const ms = secs * 1000 + Math.floor(nanos / 1e6); + return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`; + } + }, +}); +</script> diff --git a/src/views/SystemInformation.vue b/src/views/SystemInformation.vue index ef05b12..c81945b 100644 --- a/src/views/SystemInformation.vue +++ b/src/views/SystemInformation.vue @@ -1,239 +1,635 @@ -<script setup lang="ts"> -import { useApplicationStore } from '@/stores/application'; -import { Application } from '@/types/applications'; -import { computed, Ref, ref } from 'vue'; -import { PlayerPlayFilledIcon, PlayerPauseFilledIcon, PencilIcon, TrashIcon, PlusIcon } from 'vue-tabler-icons'; -import { listen } from '@tauri-apps/api/event'; -const applicationsStore = useApplicationStore(); - -const applicationHeaders: any = ref([ - { title: "UUID", align: 'center', key: 'id'}, - { title: "Name", align: 'center', key: 'title' }, - - { title: "PID", align: 'center', key: 'pid'}, // +<template> + <VCard elevation="2"> + <template v-slot:text> + <h1 class="mb-4 font-weight-bold">Applications traced</h1> + + <div class="d-flex align-center justify-space-between"> + <div class="search-container"> + <VTextField v-model="applications" label="Search" prepend-inner-icon="mdi-magnify" + variant="outlined" hide-details single-line></VTextField> + </div> + + <div class="d-flex"> + <VBtn color="secondary" class="mr-2" @click="loadFromDisk"> + Load from disk + </VBtn> + + <VBtn color="primary" @click="dialog = true"> + <PlusIcon stroke-width="1.5" size="25" class="mr-1"/> + Add new application + </VBtn> + </div> + </div> + </template> + <VDialog v-model="toastDialog" max-width="620"> + <VCard> + <VCardTitle>Save session for stopped application</VCardTitle> + <VCardText> + <div class="d-flex flex-column" style="gap:12px;"> + <div> + The export will be saved under the application title folder. Optionally add a short comment (visible later + when loading). + </div> + <VTextField + v-model="toastComment" + label="Comment (optional)" + placeholder="What was happening when the trace was taken..." + hide-details + density="compact" + style="width:100%;" + maxlength="1000" + /> + </div> + </VCardText> + <VCardActions> + <VSpacer/> + <VBtn variant="text" @click="toastDialog = false">Dismiss</VBtn> + <VBtn color="primary" :loading="exporting" @click="doExport">Save</VBtn> + </VCardActions> + </VCard> + </VDialog> + + + <VSnackbar + v-model:model-value="errorSnackbar" + :timeout="6000" + color="error" + top + right + > + {{ errorMessage }} + + <VBtn + color="white" + variant="text" + @click="errorSnackbar = false" + > + Close + </VBtn> + </VSnackbar> + + <VDialog v-model="dialog" max-width="500" persistent> + <VCard v-click-outside="close"> + <VCardTitle class="pa-4 bg-primary"> + <span class="title text-white">{{ formTitle }}</span> + </VCardTitle> + + <VCard-text> + <VForm ref="form" v-model="valid" lazy-validation @submit.prevent> + <VRow align="center"> + <VCol cols="12"> + <VTextField variant="outlined" hide-details v-model="editedItem.title" + label="Application Name"></VTextField> + </VCol> + </VRow> + + <VRow align="center"> + <VCol cols="12"> + <VTextField variant="outlined" hide-details v-model="editedItem.url" + label="Application URL"></VTextField> + </VCol> + </VRow> + </VForm> + </VCard-text> + + <VCard-actions class="pa-4"> + <VSpacer></VSpacer> + <VBtn color="error" variant="flat" @click="close">Cancel</VBtn> + <VBtn color="primary" :disabled="editedItem.title === '' || editedItem.url === ''" variant="flat" + @click="save">Save + </VBtn> + </VCard-actions> + </VCard> + </VDialog> + <VDialog v-model="listFromDiskDialog" max-width="900"> + <VCard> + <VCardTitle>Available applications tracings on your computer</VCardTitle> + <VCardText> + <div v-if="exportsList.length === 0">No exports found.</div> + <VDataTable + v-else + :headers="diskHeaders" + :items="exportsList" + item-key="app_dir" + :items-per-page="8" + > + <template v-slot:[`item.select`]="{ item }"> + <div class="cell-flex"> + <div class="col-2"> + <VRadio + v-model="selectedRowId" + :value="item.app_dir" + density="compact" + hide-details + @change="() => selectRowById(item.app_dir)" + aria-label="Select export" + /> + </div> + </div> + </template> - { title: "URL", align: 'center', key: 'url' }, - { title: "State", align: 'center', key: 'state' }, + <template v-slot:[`item.title`]="{ item }"> + <div class="cell-flex">{{ item.title }}</div> + </template> - { title: "Start Time", align: 'center', key: 'startTime' }, // - { title: "CPU", align: 'center', key: 'cpuUsage' }, // - { title: "Memory", align: 'center', key: 'memoryUsage' }, // + <template v-slot:[`item.app_dir`]="{ item }"> + <div class="cell-flex"> + {{ item.app_dir }} + </div> + </template> + </VDataTable> + </VCardText> + <VCardActions> + <VSpacer/> + <VBtn text @click="listFromDiskDialog = false">Cancel</VBtn> + <VBtn color="primary" :disabled="!selectedRowId" @click="onLoadTimestamps">Load timestamps</VBtn> + </VCardActions> + </VCard> + </VDialog> + <VDialog v-model="timestampsDialog" max-width="800"> + <VCard> + <VCardTitle>Choose tracing instance for {{ selectedAppTitle || 'app' }}</VCardTitle> + <VCardText> + <div v-if="timestampsList.length === 0">No timestamps found.</div> + + <VDataTable + v-else + :headers="[ + { title: '', key: 'select', align: 'center', sortable: false }, + { title: 'Date', key: 'date', align: 'center' }, + { title: 'Time', key: 'time', align: 'center' }, + { title: 'Comment', key: 'comment_preview', align: 'center' } + ]" + :items="timestampsTableItems" + item-key="ts" + :items-per-page="8" + > + <template v-slot:[`item.select`]="{ item }"> + <div class="cell-flex cell-center"> + <div class="col-2"> + <VRadio v-model="selectedTimestamp" :value="item.ts" density="compact" hide-details @change="() => selectTimestamp(item)" + aria-label="Select timestamp"/> + </div> + </div> + </template> + </VDataTable> + + <div v-if="selectedTimestamp" class="mt-3"> + <strong>Selected:</strong> {{ selectedTimestamp }}<br/> + <strong>Comment:</strong> + <div style="white-space:pre-wrap">{{ selectedCommentPreview }}</div> + </div> + </VCardText> + + <VCardActions> + <VSpacer/> + <VBtn text @click="timestampsDialog = false">Cancel</VBtn> + <VBtn color="primary" :disabled="!selectedTimestamp" @click="importSelectedTimestamp">Load</VBtn> + </VCardActions> + </VCard> + </VDialog> + + <VDataTable :search="applications" :headers="applicationHeaders" + :items="appItems" :row-props="getRowProps"> + <template v-slot:[`item.connection_status`]="{ item }"> + <VProgressCircular + v-if="item.connection_status == 'Connecting'" + indeterminate + color="primary" + size="24" + /> + <VIcon v-else-if="item.connection_status == 'Connected'" color="green">mdi-check-circle</VIcon> + <VIcon v-else color="red">mdi-alert-circle</VIcon> + </template> + <template v-slot:[`item.state`]="{ item }"> + <div class="justify-center"> + <VChip :color="getStateChipColor(item.state)" class="text-uppercase" label size="small"> + <div v-if="item.state === 'Enabled'">Enabled</div> + <div v-else>Disabled</div> + </VChip> + </div> + </template> + <template v-slot:[`item.memory_usage`]="{ item }"> + <div class="justify-center"> + {{ item.memory_usage }} MB + </div> + </template> + <template v-slot:[`item.actions`]="{ item }"> + <VBtn icon flat @click="applicationsStore.toggleAppState(item)" + :class="item.state === 'Disabled' ? 'disabled-action-btn' : ''"> + <PlayerPlayFilledIcon v-if="item.state === 'Disabled'" stroke-width="1.5" size="20" + class="text-primary"/> + <PlayerPauseFilledIcon v-else stroke-width="1.5" size="20" class="text-primary"/> + </VBtn> + <VTooltip text="Edit"> + <template v-slot:activator="{ props }"> + <VBtn icon flat @click="editApp(item)" v-bind="props" + :class="item.state === 'Disabled' ? 'disabled-action-btn' : ''"> + <PencilIcon stroke-width="1.5" size="20" class="text-primary"/> + </VBtn> + </template> + </VTooltip> + <VTooltip text="Delete"> + <template v-slot:activator="{ props }"> + <VBtn icon flat @click="deleteApp(item.id)" v-bind="props" + :class="item.state === 'Disabled' ? 'disabled-action-btn' : ''"> + <TrashIcon stroke-width="1.5" size="20" class="text-error"/> + </VBtn> + </template> + </VTooltip> + </template> + </VDataTable> + </VCard> +</template> - { title: "Actions", align: 'center', key: 'actions', sortable: false } -]); +<script lang="ts"> +import { ComputedRef, defineComponent, Ref, type CSSProperties } from "vue"; +import type { DataTableHeader } from "vuetify"; +import { useApplicationStore } from "@/stores/application"; +import { useDataStore } from "@/stores/data"; +import type { Application } from "@/types/applications"; +import { listen } from "@tauri-apps/api/event"; +import { invoke } from "@tauri-apps/api/core"; +import { homeDir } from "@tauri-apps/api/path"; +import { + PlusIcon, + PlayerPlayFilledIcon, + PlayerPauseFilledIcon, + PencilIcon, + TrashIcon, +} from "vue-tabler-icons"; + +type ExportEntry = { app_dir: string; title: string }; +type TimestampEntry = { ts: string; path?: string; comment_preview?: string }; +type PendingExport = { title: string | undefined; pid: number } | null; + +type EditedApplication = { + connection_status: Application["connection_status"] | ""; + pid: Application["pid"]; + state: Application["state"] | ""; + startTime: string; + cpu_usage: Application["cpu_usage"]; + memory_usage: Application["memory_usage"]; + processStatus: Application["processStatus"] | ""; + id: Application["id"]; + title: Application["title"]; + url: Application["url"]; +}; -const getStateChipColor = (state: string): string => { - const colorMap: Record<string, string> = { - 'Enabled': 'green', - 'Disabled': 'red', +export default defineComponent({ + name: "ApplicationsOverview", + + components: { + PlusIcon, + PlayerPlayFilledIcon, + PlayerPauseFilledIcon, + PencilIcon, + TrashIcon, + }, + + data() { + const applicationsStore = useApplicationStore(); + const dataStore = useDataStore(); + + const emptyEdited: EditedApplication = { + connection_status: "", + pid: 0 as Application["pid"], + state: "", + startTime: "", + cpu_usage: 0 as Application["cpu_usage"], + memory_usage: 0 as Application["memory_usage"], + processStatus: "", + id: "", + title: "", + url: "", }; - return colorMap[state] || 'default'; -}; -const getRowProps = (item: any) => { return { - style: item.item.state === 'Disabled' - ? { backgroundColor: '#F5F5F5' } - : {}, + // stores + applicationsStore, + dataStore, + + // ui state + errorMessage: "" as string, + errorSnackbar: false as boolean, + exporting: false as boolean, + + // dialogs + dialog: false as boolean, + toastDialog: false as boolean, + listFromDiskDialog: false as boolean, + timestampsDialog: false as boolean, + + // search + applications: "" as string, + + // edit + valid: true as boolean, + editedIndex: -1, + editedItemName: "" as string, + editedItem: { ...emptyEdited } as EditedApplication, + defaultItem: { ...emptyEdited } as EditedApplication, + + // export + timestamps + pendingExport: null as PendingExport, + exportsList: [] as ExportEntry[], + timestampsList: [] as TimestampEntry[], + selectedRowId: null as string | null, + selectedAppDir: "" as string, + selectedAppTitle: "" as string, + selectedTimestamp: "" as string, + selectedCommentPreview: "" as string, + toastComment: "" as string, + + // headers + applicationHeaders: [ + { title: "Status", align: "center", key: "connection_status" }, + { title: "Name", align: "center", key: "title" }, + { title: "PID", align: "center", key: "pid" }, + { title: "URL", align: "center", key: "url" }, + { title: "State", align: "center", key: "state" }, + { title: "Start Time", align: "center", key: "start_time" }, + { title: "CPU", align: "center", key: "cpu_usage" }, + { title: "Memory", align: "center", key: "memory_usage" }, + { title: "Actions", align: "center", key: "actions", sortable: false }, + ] as DataTableHeader[], + + diskHeaders: [ + { title: "", align: "center", key: "select", sortable: false }, + { title: "Title", align: "center", key: "title" }, + ] as DataTableHeader[], + + // helpers + lastAppStatuses: new Map<string, string>(), }; -}; - -const editedItem: Ref<{ - id: string, - title: string, - url: string, -}> = ref({ - id: '', - title: '', - url: '', -}); - -const defaultItem: Ref<{ - id: string, - title: string, - url: string, -}> = ref({ - id: '', - title: '', - url: '', -}); - -const valid = ref(true); -const dialog = ref(false); -const applications = ref(''); -const editedIndex = ref(-1); -const editedItemName = ref(''); - -const formTitle = computed(() => { - return editedIndex.value === -1 ? 'New Application' : 'Edit Application'; -}); - -function close() { - dialog.value = false; - - editedItem.value = Object.assign({}, defaultItem.value); - editedIndex.value = -1; - editedItemName.value = ''; -} - -async function save() { - const currentApplication = { - id: editedItem.value.id, - title: editedItem.value.title, - url: editedItem.value.url, - state: 'Enabled' - } - - if (editedIndex.value > -1) { - await applicationsStore.editApplication(currentApplication); - } else { - await applicationsStore.addApplication(currentApplication.title, currentApplication.url); - } - - close(); -} - -async function deleteApp(appID: string) { - if (await confirm('Are you sure you want to delete this project?')) { - await applicationsStore.deleteApplication(appID); + }, + + computed: { + formTitle(): string { + return this.editedIndex === -1 ? "New Application" : "Edit Application"; + }, + + timestampsTableItems(): Array<TimestampEntry & { date: string; time: string }> { + return this.timestampsList.map((item) => { + const { date, time } = this.splitTs(item.ts); + return { ...item, date, time }; + }); + }, + + appItems(): Application[] { + const ga = this.applicationsStore.getApplications as Application[] | Ref<Application[]> | ComputedRef<Application[]>; + return Array.isArray(ga) ? ga : ga.value ?? []; + }, + }, + + methods: { + getStateChipColor(state: string): string { + const colorMap: Record<string, string> = { Enabled: "green", Disabled: "red" }; + return colorMap[state] || "default"; + }, + + getRowProps(ctx: { item: Application }) { + const style: CSSProperties = + ctx.item.state === "Disabled" ? { backgroundColor: "#F5F5F5" } : {}; + return { style }; + }, + + close() { + this.dialog = false; + this.editedItem = { ...this.defaultItem }; + this.editedIndex = -1; + this.editedItemName = ""; + }, + + showError(msg: string) { + this.errorMessage = msg; + this.errorSnackbar = true; + }, + + splitTs(ts: string) { + const parts = ts.split(" "); + const date = parts[0] || ""; + const time = parts[1] || ""; + const hm = time.split(":").slice(0, 2).join(":"); + return { date, time: hm }; + }, + + async save() { + this.dialog = false; + + const currentApplication = { + connection_status: this.editedItem.connection_status, + pid: this.editedItem.pid, + id: this.editedItem.id, + title: this.editedItem.title, + url: this.editedItem.url, + state: this.editedItem.state, + startTime: this.editedItem.startTime, + cpu_usage: this.editedItem.cpu_usage, + memory_usage: this.editedItem.memory_usage, + processStatus: this.editedItem.processStatus, + }; + + if (this.editedIndex > -1) { + await this.applicationsStore.editApplication(currentApplication); + } else { + try { + await this.applicationsStore.addApplication( + currentApplication.title, + currentApplication.url + ); + } catch (error) { + if (typeof error == "string") { + if (error.includes("ApplicationAlreadyConnected")) { + this.showError("This URL Application is already in the list"); + } else if (error.includes("PIDNotFound")) { + this.showError("The application at this URL is not running"); + } else { + this.showError("Unexpected Error" + error); + } + } else { + this.showError("Unexpected Error" + error); + } + } } -} - -function editApp(app: Application) { - editedIndex.value = applicationsStore.getApplications.value.indexOf(app); - - const { title, url, id } = app; - - editedItemName.value = title; - - editedItem.value.id = id; - editedItem.value.title = title; - editedItem.value.url = url; - - dialog.value = true; -} - -listen<Application[]>("update:applications", (event) => { - applicationsStore.applications = event.payload; - console.log("Received applications: " + JSON.stringify(event.payload[0])); + this.close(); + }, + + async deleteApp(appID: string) { + if (confirm("Are you sure you want to delete this project?")) { + await this.applicationsStore.deleteApplication(appID); + } + }, + + editApp(app: Application) { + this.editedIndex = this.appItems.indexOf(app); + const { title, url, id } = app; + this.editedItemName = title; + this.editedItem.id = id; + this.editedItem.title = title; + this.editedItem.url = url; + this.dialog = true; + }, + + selectRowById(id: string) { + this.selectedRowId = id; + const entry = this.exportsList.find((e) => e.app_dir === id || e.app_dir === id); + if (entry) this.selectAppDir(entry.app_dir, entry.title); + }, + + async doExport() { + if (!this.pendingExport) return this.showError("No pending export selected"); + + this.exporting = true; + try { + const res = await invoke<string>("export_app_instance", { + title: this.pendingExport.title, + name: this.toastComment || "", + }); + console.log("Exported to", res); + window.location.reload(); + } catch (e) { + this.showError("Export failed: " + String(e)); + } finally { + this.exporting = false; + this.toastDialog = false; + this.pendingExport = null; + this.toastComment = ""; + } + }, + + showExportDialog(newApp: { id: string; pid: number; title?: string }) { + if (!newApp || !newApp.id) return; + this.pendingExport = { title: newApp.title, pid: newApp.pid }; + this.toastComment = ""; + this.toastDialog = true; + }, + + async loadFromDisk() { + try { + const home = await homeDir(); + const storage = home ? `${home}/.async-tracing` : undefined; + const res = await invoke<ExportEntry[]>("list_exports", { storageFolder: storage }); + this.exportsList = res || []; + this.listFromDiskDialog = true; + } catch (e) { + this.showError("List exports failed: " + String(e)); + } + }, + + selectTimestamp(item: { ts: string; path?: string; comment_preview?: string }) { + this.selectedTimestamp = item.ts; + this.selectedCommentPreview = item.comment_preview || ""; + }, + + async selectAppDir(appDir: string, appTitle?: string) { + this.selectedAppDir = appDir; + this.selectedAppTitle = appTitle || appDir; + try { + const home = await homeDir(); + const storage = home ? `${home}/.async-tracing` : undefined; + const res = await invoke<TimestampEntry[]>("list_app_timestamps", { + storageFolder: storage, + appDir, + }); + this.timestampsList = res || []; + this.listFromDiskDialog = false; + this.timestampsDialog = true; + } catch (e) { + this.showError("List timestamps failed: " + String(e)); + } + }, + + importSelectedTimestamp() { + if (!this.selectedTimestamp) return; + return this.importSelectedTimestampImpl(); + }, + + async importSelectedTimestampImpl() { + if (!this.selectedAppDir || !this.selectedTimestamp) { + return this.showError("Select an export timestamp first"); + } + try { + const home = await homeDir(); + const storage = home ? `${home}/.async-tracing` : undefined; + await invoke("import_from_export_folder", { + storageFolder: storage, + appDir: this.selectedAppDir, + ts: this.selectedTimestamp, + }); + this.timestampsDialog = false; + window.location.reload(); + } catch (e) { + this.showError("Import failed: " + String(e)); + } + }, + + onLoadTimestamps() { + if (!this.selectedRowId) return this.showError("Select an export first"); + const entry = this.exportsList.find((e) => e.app_dir === this.selectedRowId); + if (!entry) return this.showError("Selected export not found"); + this.listFromDiskDialog = false; + this.selectAppDir(entry.app_dir, entry.title); + }, + }, + + created() { + listen<Application[]>("update:applications", (event) => { + if (this.dataStore.pause == false) { + console.log("Received applications: " + JSON.stringify(event.payload[0])); + event.payload.forEach((newApp) => { + console.log(newApp); + + const existingApp = this.applicationsStore.applications.find( + (app) => app.id === newApp.id + ); + const prevStatus = existingApp?.connection_status; + + if (prevStatus === "Connected" && newApp.connection_status !== "Connected") { + console.log("Popup"); + this.showExportDialog({ id: newApp.id, pid: newApp.pid, title: newApp.title }); + } + + this.lastAppStatuses.set(newApp.id, newApp.connection_status); + + if (existingApp) Object.assign(existingApp, newApp); + else this.applicationsStore.applications.push(newApp); + }); + } + }); + + listen<{ id: string; pid: number }>("update:pid", (event) => { + const { id, pid } = event.payload; + const app = this.applicationsStore.applications.find((a) => a.id === id); + if (app) app.pid = pid; + }); + }, }); - </script> -<template> - <v-card elevation="2"> - <template v-slot:text> - <div class="d-flex align-center justify-space-between"> - <div class="search-container"> - <v-text-field v-model="applications" label="Search" prepend-inner-icon="mdi-magnify" - variant="outlined" hide-details single-line></v-text-field> - </div> - <v-btn color="primary" @click="dialog = true"> - <PlusIcon stroke-width="1.5" size="25" class="mr-1" /> - Add new application - </v-btn> - </div> - </template> - - <v-dialog v-model="dialog" max-width="500" persistent> - <v-card v-click-outside="close"> - <v-card-title class="pa-4 bg-primary"> - <span class="title text-white">{{ formTitle }}</span> - </v-card-title> - - <v-card-text> - <v-form ref="form" v-model="valid" lazy-validation @submit.prevent> - <v-row align="center"> - <v-col cols="12"> - <v-text-field variant="outlined" hide-details v-model="editedItem.title" - label="Application Name"></v-text-field> - </v-col> - </v-row> - - <v-row align="center"> - <v-col cols="12"> - <v-text-field variant="outlined" hide-details v-model="editedItem.url" - label="Application URL"></v-text-field> - </v-col> - </v-row> - </v-form> - </v-card-text> - - <v-card-actions class="pa-4"> - <v-spacer></v-spacer> - <v-btn color="error" variant="flat" @click="close">Cancel</v-btn> - <v-btn color="primary" :disabled="editedItem.title === '' || editedItem.url === ''" variant="flat" - @click="save">Save</v-btn> - </v-card-actions> - </v-card> - </v-dialog> - - <v-data-table :search="applications" :headers="applicationHeaders" - :items="applicationsStore.getApplications.value" :row-props="getRowProps"> - <template v-slot:item.state="{ item }"> - <div class="justify-center"> - <v-chip :color="getStateChipColor(item.state)" class="text-uppercase" label size="small"> - <div v-if="item.state === 'Enabled'">Enabled</div> - <div v-else>Disabled</div> - </v-chip> - </div> - </template> - <template v-slot:item.actions="{ item }"> - <div class="d-flex justify-center gap-2"> - <v-tooltip :text="item.state === 'Disabled' ? 'Enable' : 'Disable'"> - <template v-slot:activator="{ props }"> - <v-btn icon flat @click="applicationsStore.toggleAppState(item.id)" v-bind="props" - :class="item.state === 'Disabled' ? 'disabled-action-btn' : ''"> - <PlayerPlayFilledIcon v-if="item.state === 'Disabled'" stroke-width="1.5" size="20" - class="text-primary" /> - <PlayerPauseFilledIcon v-else stroke-width="1.5" size="20" class="text-primary" /> - </v-btn> - </template> - </v-tooltip> - <v-tooltip text="Edit"> - <template v-slot:activator="{ props }"> - <v-btn icon flat @click="editApp(item)" v-bind="props" - :class="item.state === 'Disabled' ? 'disabled-action-btn' : ''"> - <PencilIcon stroke-width="1.5" size="20" class="text-primary" /> - </v-btn> - </template> - </v-tooltip> - <v-tooltip text="Delete"> - <template v-slot:activator="{ props }"> - <v-btn icon flat @click="deleteApp(item.id)" v-bind="props" - :class="item.state === 'Disabled' ? 'disabled-action-btn' : ''"> - <TrashIcon stroke-width="1.5" size="20" class="text-error" /> - </v-btn> - </template> - </v-tooltip> - </div> - </template> - </v-data-table> - </v-card> -</template> <style scoped> .disabled-action-btn { - opacity: 0.7; - background-color: transparent !important; - box-shadow: none !important; - border: none !important; + opacity: 0.7; + background-color: transparent !important; + box-shadow: none !important; + border: none !important; } .disabled-action-btn::before { - opacity: 0 !important; + opacity: 0 !important; } .disabled-action-btn:hover { - background-color: transparent !important; + background-color: transparent !important; } .text-white { - color: rgb(255, 255, 255) !important; + color: rgb(255, 255, 255) !important; } .search-container { - width: 400px; + width: 400px; } -</style> +</style> \ No newline at end of file diff --git a/src/views/Tasks.vue b/src/views/Tasks.vue index 9d44d79..8daedff 100644 --- a/src/views/Tasks.vue +++ b/src/views/Tasks.vue @@ -1,58 +1,383 @@ -<script setup lang="ts"> -import { ref } from "vue"; -import { listen } from "@tauri-apps/api/event"; -import { Task } from "@/types/tasks"; - -const tasks = ref([] as Task[]); -const tasksSearch = ref(''); - -const taskHeaders: any = ref([ - { title: "App UUID", align: 'center', key: 'app_id'}, - { title: "ID", align: 'center', key: 'id' }, - { title: "TID", align: 'center', key: 'tid' }, - { title: "Name", align: 'center', key: 'name' }, - { title: "Type", align: 'center', key: 'kind' }, - -]); - -const getTaskChipColor = (state: string): string => { - const colorMap: Record<string, string> = { - 'SPAWN': 'green', - 'BLOCKING': 'red' +<template> + <v-card elevation="2"> + <v-dialog v-model="dialog" max-width="500" persistent> + <v-card v-click-outside="close"> + <v-card-title class="pa-4 bg-primary"> + <span class="title text-white">Edit task</span> + </v-card-title> + + <v-card-text> + <v-form ref="form" lazy-validation @submit.prevent> + <v-row class="mb-4 mt-4"> + <v-text-field + variant="outlined" + hide-details + v-model="editedItem.name" + label="Task Name" + ></v-text-field> + </v-row> + + <v-row class="mb-4"> + <v-color-picker + v-model="editedItem.color" + flat + hide-canvas + hide-inputs + show-swatches + swatches-max-height="150" + ></v-color-picker> + </v-row> + + <v-row class="mb-4"> + <v-expansion-panels> + <v-expansion-panel> + <v-expansion-panel-title> + > Warnings + </v-expansion-panel-title> + + <v-expansion-panel-text> + <v-list> + <v-list-item + v-for="(warning, key) in editedItem.warnings" + :key="key" + > + <v-row class="align-center justify-space-between" no-gutters> + <v-col cols="4"> + <strong>{{ key }}</strong> + </v-col> + + <v-col cols="8" class="d-flex justify-end align-center"> + <v-text-field + v-if="warning.parameter !== undefined" + v-model.number="warning.parameter" + label="Parameter" + type="number" + hide-details + density="compact" + class="mr-4" + style="max-width: 120px" + /> + + <v-switch + v-model="warning.enabled" + color="green" + inset + hide-details + /> + </v-col> + </v-row> + </v-list-item> + </v-list> + </v-expansion-panel-text> + </v-expansion-panel> + </v-expansion-panels> + </v-row> + </v-form> + </v-card-text> + + <v-card-actions class="pa-4"> + <v-spacer></v-spacer> + <v-btn color="error" variant="flat" @click="close">Cancel</v-btn> + <v-btn color="primary" :disabled="editedItem.name === ''" variant="flat" + @click="save">Save + </v-btn> + </v-card-actions> + </v-card> + </v-dialog> + <v-card-text> + <h1 class="mb-4 font-weight-bold">Tasks Overview</h1> + + <div class="d-flex align-center justify-space-between mb-4"> + <v-text-field + v-model="tasksSearch" + label="Search" + prepend-inner-icon="mdi-magnify" + variant="outlined" + hide-details + single-line + class="search-container" + /> + </div> + + <div class="d-flex flex-wrap mb-4"> + <v-btn + v-for="app in appList" + :key="app" + :color="selectedApp === app ? 'primary' : 'grey lighten-2'" + variant="tonal" + size="small" + class="ma-1" + @click="selectedApp = app" + > + {{ app }} + </v-btn> + </div> + + <v-data-table + :headers="taskHeaders" + :items="filteredTasks" + :item-value="item => `${item.app_name}-${item.id}-${item.tid}`" + :item-class="rowClass" + > + + <template #[`item.kind`]="{ item }"> + <v-chip + :color="getTaskChipColor(item.kind)" + size="small" + class="text-uppercase" + > + {{ item.kind }} + </v-chip> + </template> + + <template #[`item.state`]="{ item }"> + <v-chip + :color="getTaskChipColor(item.state)" + size="small" + > + {{ item.state }} + </v-chip> + </template> + + <template #[`item.created_at`]="{ item }"> + <span>{{formatCreatedAt(item.created_at)}}</span> + </template> + + <template #[`item.runtime`]="{ item }"> + <span>{{ formatDuration(item.runtime) }}</span> + </template> + + <template #[`item.scheduled`]="{ item }"> + <span>{{ formatDuration(item.scheduled) }}</span> + </template> + + <template #[`item.idle`]="{ item }"> + <span>{{ formatDuration(item.idle) }}</span> + </template> + + <template #[`item.busy`]="{ item }"> + <span>{{ formatDuration(item.busy) }}</span> + </template> + + <template #[`item.location`]="{ item }"> + <span v-html="item.location"></span> + </template> + + <template #[`item.name`]="{ item }"> + <v-chip + :color="item.color ? item.color : 'gray'" + size="small"> + {{ item.name ? item.name : "No name" }} + </v-chip> + <v-tooltip text="Edit"> + <template v-slot:activator="{ props }"> + <v-btn icon flat @click="editTask(item)" v-bind="props"> + <PencilIcon stroke-width="1.5" size="20" class="text-primary"/> + </v-btn> + </template> + </v-tooltip> + </template> + </v-data-table> + </v-card-text> + </v-card> +</template> + +<script lang="ts"> +import { defineComponent } from "vue"; +import type { DataTableHeader } from "vuetify"; +import moment from "moment"; +import { useDataStore } from "@/stores/data"; +import type { Task, TaskWarnings } from "@/types/tasks"; + +export default defineComponent({ + name: "TasksOverview", + data() { + const dataStore = useDataStore(); + + return { + // stores + dataStore, + + // ui state + tasksSearch: "" as string, + selectedApp: "All" as string, + dialog: false as boolean, + + // table headers + taskHeaders: [ + { title: "App Name", key: "app_name", align: "center" }, + { title: "ID", key: "id", align: "center" }, + { title: "TID", key: "tid", align: "center" }, + { title: "Name", key: "name", align: "center" }, + { title: "Type", key: "kind", align: "center" }, + { title: "State", key: "state", align: "center" }, + { title: "Spawned Time", key: "created_at", align: "center" }, + { title: "Runtime", key: "runtime", align: "center" }, + { title: "Scheduled", key: "scheduled", align: "center" }, + { title: "Idle", key: "idle", align: "center" }, + { title: "Busy", key: "busy", align: "center" }, + { title: "Location", key: "location", align: "center" }, + ] as DataTableHeader[], + + // edit dialog models + editedItem: { + app_name: "", + id: 0, + name: "", + color: "", + warnings: { + self_wake_percent: { enabled: true, parameter: 50, description: "" }, + lost_waker: { enabled: true }, + never_yielded: { enabled: true, parameter: 1, description: "" }, + auto_boxed_feature: { enabled: true }, + large_feature: { enabled: true, parameter: 1024, description: "" }, + } as TaskWarnings, + }, + + defaultItem: { + app_name: "", + id: 0, + name: "", + color: "", + warnings: { + self_wake_percent: { enabled: true, parameter: 50, description: "" }, + lost_waker: { enabled: true }, + never_yielded: { enabled: true, parameter: 1, description: "" }, + auto_boxed_feature: { enabled: true }, + large_feature: { enabled: true, parameter: 1024, description: "" }, + } as TaskWarnings, + }, }; - return colorMap[state] || 'default'; -}; -listen<Task[]>("update:tasks", (event) => { - tasks.value = event.payload; - console.log("Afisez task " + JSON.stringify(tasks.value[0])); + }, + + computed: { + appList(): string[] { + const names = this.dataStore.tasks.map((t) => t.app_name); + const unique = Array.from(new Set(names)).sort(); + return ["All", ...unique]; + }, + + filteredTasks(): Task[] { + const q = this.tasksSearch.toLowerCase(); + + return this.dataStore.tasks + .filter( + (t) => this.selectedApp === "All" || t.app_name === this.selectedApp + ) + .filter((t) => { + return ( + t.app_name.toLowerCase().includes(q) || + t.name.toLowerCase().includes(q) || + t.id.toString().includes(q) || + t.tid.toString().includes(q) + ); + }); + }, + }, + + methods: { + getTaskChipColor(stateOrKind: string) { + switch (stateOrKind) { + case "Running": + return "green"; + case "Stopped": + return "red"; + case "Starved": + return "purple"; + case "SPAWN": + return "blue"; + case "BLOCKING": + return "orange"; + default: + return "grey"; + } + }, + + close() { + this.dialog = false; + this.editedItem = Object.assign({}, this.defaultItem); + }, + + async save() { + this.dialog = false; + try { + await this.dataStore.editTask( + this.editedItem.id, + this.editedItem.name, + this.editedItem.color, + this.editedItem.app_name, + this.editedItem.warnings + ); + this.close(); + } catch (error: unknown) { + this.dialog = true; + console.error("Edit task failed:", error); + alert("Edit task failed:" + (String(error))); + } + }, + + editTask(task: Task) { + const { id, name, app_name, warnings } = task; + this.editedItem.id = id; + this.editedItem.app_name = app_name; + this.editedItem.name = name; + this.editedItem.warnings = warnings; + this.dialog = true; + }, + + rowClass(item: Task) { + return item?.state === "Starved" ? "row-starved" : ""; + }, + + formatCreatedAt(value: string) { + if (!value) return ""; + const m = moment(value); + if (!m.isValid()) return value; + return m.format("DD.MM.YYYY HH:mm:ss"); + }, + + toNumber(x: unknown): number | undefined { + if (x === null || x === undefined) return undefined; + if (typeof x === "number") return x; + if (typeof x === "string" && x.trim() !== "") { + const n = Number(x); + return Number.isNaN(n) ? undefined : n; + } + return undefined; + }, + + formatDuration(v: unknown): string { + if (v == null) return ""; + if (typeof v === "string") return v; + + const d = v as { + secs?: number; seconds?: number; Secs?: number; + nanos?: number; Nanos?: number; Nano?: number; + }; + + const secs = this.toNumber(d.secs ?? d.seconds ?? d.Secs) ?? 0; + const nanos = this.toNumber(d.nanos ?? d.Nanos ?? d.Nano) ?? 0; + const ms = secs * 1000 + Math.floor(nanos / 1e6); + return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`; + } + }, }); </script> -<template> - <v-card elevation="2"> - <template v-slot:text> - <div class="d-flex align-center justify-space-between"> - <div class="search-container"> - <v-text-field v-model="tasksSearch" label="Search" prepend-inner-icon="mdi-magnify" - variant="outlined" hide-details single-line></v-text-field> - </div> - </div> - </template> - - <v-data-table :search="tasksSearch" :headers="taskHeaders" :items="tasks"> - <template v-slot:item.kind="{ item }"> - <div class="justify-center"> - <v-chip :color="getTaskChipColor(item.kind)" class="text-uppercase" label size="small"> - <div>{{ item.kind }}</div> - </v-chip> - </div> - </template> - </v-data-table> - </v-card> -</template> + <style scoped> .search-container { - width: 400px; + width: 400px; +} + +.row-starved { + background-color: rgba(255, 0, 0, 0.08) !important; } -</style> \ No newline at end of file + +.row-starved td { + background-color: rgba(255, 0, 0, 0.08) !important; + color: #7a0a0a !important; +} + +</style> diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index fc81239..8e18a06 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -2,6 +2,6 @@ declare module "*.vue" { import type { DefineComponent } from "vue"; - const component: DefineComponent<{}, {}, any>; + const component: DefineComponent; export default component; }