diff --git a/agent/internal/agent/agent.go b/agent/internal/agent/agent.go index 18df1030..6f8778fb 100644 --- a/agent/internal/agent/agent.go +++ b/agent/internal/agent/agent.go @@ -165,11 +165,15 @@ func (a *Agent) pollAndApply() { slog.Info("starting pipeline", "name", action.Name, "version", action.Version) if err := a.supervisor.Start(action.PipelineID, action.ConfigPath, action.Version, action.LogLevel, action.Secrets); err != nil { slog.Error("failed to start pipeline", "pipeline", action.PipelineID, "error", err) + } else if action.Checksum != "" { + a.supervisor.SetConfigChecksum(action.PipelineID, action.Checksum) } case ActionRestart: slog.Info("restarting pipeline", "name", action.Name, "version", action.Version, "reason", "config changed") if err := a.supervisor.Restart(action.PipelineID, action.ConfigPath, action.Version, action.LogLevel, action.Secrets); err != nil { slog.Error("failed to restart pipeline", "pipeline", action.PipelineID, "error", err) + } else if action.Checksum != "" { + a.supervisor.SetConfigChecksum(action.PipelineID, action.Checksum) } case ActionStop: slog.Info("stopping pipeline", "pipeline", action.PipelineID, "reason", "removed from config") diff --git a/agent/internal/agent/heartbeat.go b/agent/internal/agent/heartbeat.go index 50581ac7..ac142516 100644 --- a/agent/internal/agent/heartbeat.go +++ b/agent/internal/agent/heartbeat.go @@ -76,6 +76,9 @@ func buildHeartbeat(sup *supervisor.Supervisor, vectorVersion string, deployment } } + // Include config checksum from last applied config + ps.ConfigChecksum = s.ConfigChecksum + // Include recent stdout/stderr lines (max 100 per heartbeat) logs := sup.GetRecentLogs(s.PipelineID) if len(logs) > 100 { diff --git a/agent/internal/agent/poller.go b/agent/internal/agent/poller.go index fec8137f..f7ecd45f 100644 --- a/agent/internal/agent/poller.go +++ b/agent/internal/agent/poller.go @@ -59,6 +59,7 @@ type PipelineAction struct { ConfigPath string LogLevel string Secrets map[string]string + Checksum string } // Poll fetches config from VectorFlow and returns actions to take. @@ -117,6 +118,7 @@ func (p *poller) Poll() ([]PipelineAction, error) { ConfigPath: configPath, LogLevel: pc.LogLevel, Secrets: pc.Secrets, + Checksum: pc.Checksum, }) } else if prev.checksum != pc.Checksum { // Config changed — rewrite and restart @@ -132,6 +134,7 @@ func (p *poller) Poll() ([]PipelineAction, error) { ConfigPath: configPath, LogLevel: pc.LogLevel, Secrets: pc.Secrets, + Checksum: pc.Checksum, }) } else if prev.version != pc.Version { // Version bumped but config unchanged — update version without restart diff --git a/agent/internal/client/client.go b/agent/internal/client/client.go index 5b3456a1..57449780 100644 --- a/agent/internal/client/client.go +++ b/agent/internal/client/client.go @@ -167,6 +167,7 @@ type PipelineStatus struct { ComponentMetrics []ComponentMetric `json:"componentMetrics,omitempty"` Utilization float64 `json:"utilization,omitempty"` RecentLogs []string `json:"recentLogs,omitempty"` + ConfigChecksum string `json:"configChecksum,omitempty"` } // ComponentMetric holds per-component metrics for editor node overlays. diff --git a/agent/internal/supervisor/supervisor.go b/agent/internal/supervisor/supervisor.go index 0e7b83b8..ab063a58 100644 --- a/agent/internal/supervisor/supervisor.go +++ b/agent/internal/supervisor/supervisor.go @@ -14,20 +14,21 @@ import ( ) type ProcessInfo struct { - PipelineID string - Version int - PID int - Status string // RUNNING, STARTING, STOPPED, CRASHED - StartedAt time.Time - MetricsPort int - APIPort int - LogLevel string - Secrets map[string]string - cmd *exec.Cmd - configPath string - restarts int - done chan struct{} - logBuf *logbuf.RingBuffer + PipelineID string + Version int + PID int + Status string // RUNNING, STARTING, STOPPED, CRASHED + StartedAt time.Time + MetricsPort int + APIPort int + LogLevel string + Secrets map[string]string + ConfigChecksum string + cmd *exec.Cmd + configPath string + restarts int + done chan struct{} + logBuf *logbuf.RingBuffer } type Supervisor struct { @@ -239,18 +240,28 @@ func (s *Supervisor) Statuses() []ProcessInfo { var result []ProcessInfo for _, info := range s.processes { result = append(result, ProcessInfo{ - PipelineID: info.PipelineID, - Version: info.Version, - PID: info.PID, - Status: info.Status, - StartedAt: info.StartedAt, - MetricsPort: info.MetricsPort, - APIPort: info.APIPort, + PipelineID: info.PipelineID, + Version: info.Version, + PID: info.PID, + Status: info.Status, + StartedAt: info.StartedAt, + MetricsPort: info.MetricsPort, + APIPort: info.APIPort, + ConfigChecksum: info.ConfigChecksum, }) } return result } +// SetConfigChecksum stores the config checksum applied for a pipeline. +func (s *Supervisor) SetConfigChecksum(pipelineID, checksum string) { + s.mu.Lock() + defer s.mu.Unlock() + if info, ok := s.processes[pipelineID]; ok { + info.ConfigChecksum = checksum + } +} + // GetRecentLogs returns and clears the recent log lines for a pipeline. func (s *Supervisor) GetRecentLogs(pipelineID string) []string { s.mu.Lock() diff --git a/docs/public/operations/gitops.md b/docs/public/operations/gitops.md index 941f69ab..32fdf0d9 100644 --- a/docs/public/operations/gitops.md +++ b/docs/public/operations/gitops.md @@ -2,15 +2,28 @@ VectorFlow supports **pipeline-as-code** workflows where pipeline configurations are stored in a Git repository and kept in sync between VectorFlow and your version control system. +## Supported Git Providers + +VectorFlow supports the following Git hosting providers: + +| Provider | Webhook Verification | API Operations | +|----------|---------------------|----------------| +| **GitHub** | HMAC-SHA256 (`X-Hub-Signature-256`) | Contents API, Pulls API | +| **GitLab** | Shared secret (`X-Gitlab-Token`) | Repository Files API, Merge Requests API | +| **Bitbucket** | HMAC-SHA256 (`X-Hub-Signature`) | Source API, Pullrequests API | + +The provider is **auto-detected** from the repository URL domain (e.g., `github.com`, `gitlab.com`, `bitbucket.org`). For self-hosted instances (e.g., `gitlab.internal.corp`), you can explicitly set the provider in the Git Integration settings. + ## Modes -Each environment can operate in one of three GitOps modes: +Each environment can operate in one of four GitOps modes: | Mode | Direction | Description | |------|-----------|-------------| | **Off** | -- | Git integration is disabled (default). | | **Push Only** | VectorFlow -> Git | Pipeline YAML is committed to the repo whenever you deploy or delete a pipeline. The repo serves as an audit trail. | -| **Bi-directional** | VectorFlow <-> Git | In addition to push, a webhook from GitHub triggers VectorFlow to import changed YAML files automatically. | +| **Bi-directional** | VectorFlow <-> Git | In addition to push, a webhook from your Git provider triggers VectorFlow to import changed YAML files automatically. | +| **Promotion** | VectorFlow -> Git -> VectorFlow | Promoting a pipeline creates a pull request (or merge request). Merging it automatically deploys the promoted config. | ## Setting up Push Only @@ -23,6 +36,7 @@ On the environment detail page, fill in the **Git Integration** card: - **Repository URL** -- HTTPS URL of the target repo (e.g., `https://github.com/org/pipeline-configs.git`) - **Branch** -- The branch to push to (default: `main`) - **Access Token** -- A personal access token with write access +- **Git Provider** -- Leave as "Auto-detect" for hosted providers, or explicitly select for self-hosted instances {% endstep %} {% step %} ### Set GitOps Mode to Push Only @@ -37,7 +51,7 @@ Click **Save**. You can verify connectivity with **Test Connection** before savi From this point forward, every pipeline deploy writes the generated YAML to `{environment-name}/{pipeline-name}.yaml` in the configured repository, and every pipeline deletion removes the file. {% hint style="info" %} -Git sync is a post-deploy side effect. If the Git push fails, the pipeline deploy still succeeds -- you will see a warning in the VectorFlow logs. +Git sync is a post-deploy side effect. If the Git push fails, the pipeline deploy still succeeds -- VectorFlow automatically queues the failed sync for retry (up to 3 attempts with exponential backoff). You can monitor sync status in the **Git Sync Status** section on the environment page. {% endhint %} ## Setting up Bi-directional GitOps @@ -56,27 +70,33 @@ Select **Bi-directional** from the **GitOps Mode** dropdown and click **Save**. {% step %} ### Copy the webhook details After saving, the card shows: -- **Webhook URL** -- The endpoint GitHub should send push events to. -- **Webhook Secret** -- The HMAC secret for signature verification. +- **Webhook URL** -- The endpoint your Git provider should send push events to. +- **Webhook Secret** -- The secret for signature verification. {% endstep %} {% step %} -### Create a GitHub Webhook -In your GitHub repository, go to **Settings > Webhooks > Add webhook** and enter: -- **Payload URL** -- Paste the Webhook URL from VectorFlow. -- **Content type** -- Select `application/json`. -- **Secret** -- Paste the Webhook Secret from VectorFlow. -- **Events** -- Select **Just the push event**. - -Click **Add webhook**. +### Create a Webhook in your Git provider {% endstep %} {% endstepper %} {% tabs %} {% tab title="GitHub" %} -Navigate to your repository on GitHub, then go to **Settings > Webhooks > Add webhook**. Fill in the Payload URL, select `application/json`, paste the secret, and choose the push event. +In your GitHub repository, go to **Settings > Webhooks > Add webhook**: +- **Payload URL** -- Paste the Webhook URL from VectorFlow. +- **Content type** -- Select `application/json`. +- **Secret** -- Paste the Webhook Secret from VectorFlow. +- **Events** -- Select **Just the push event** (and **Pull requests** if using Promotion mode). {% endtab %} {% tab title="GitLab" %} -GitLab uses a different header (`X-Gitlab-Token`) for secret verification. GitLab support is not yet available -- contact the team if you need it. +In your GitLab project, go to **Settings > Webhooks > Add new webhook**: +- **URL** -- Paste the Webhook URL from VectorFlow. +- **Secret token** -- Paste the Webhook Secret from VectorFlow. +- **Trigger** -- Check **Push events** (and **Merge request events** if using Promotion mode). +{% endtab %} +{% tab title="Bitbucket" %} +In your Bitbucket repository, go to **Repository settings > Webhooks > Add webhook**: +- **URL** -- Paste the Webhook URL from VectorFlow. +- **Secret** -- Paste the Webhook Secret from VectorFlow. +- **Triggers** -- Select **Repository push** (and **Pull request merged/declined** if using Promotion mode). {% endtab %} {% endtabs %} @@ -84,16 +104,40 @@ GitLab uses a different header (`X-Gitlab-Token`) for secret verification. GitLa When a push event arrives: -1. VectorFlow verifies the HMAC signature using the webhook secret. +1. VectorFlow verifies the webhook signature using the appropriate method for the Git provider. 2. It checks that the push targets the configured branch. -3. For each added or modified `.yaml` / `.yml` file in the push, it fetches the file content via the GitHub API. +3. For each added or modified `.yaml` / `.yml` file in the push, it fetches the file content via the provider's API. 4. The pipeline name is derived from the filename (e.g., `production/my-pipeline.yaml` becomes `my-pipeline`). -5. If a pipeline with that name already exists in the environment, its graph is replaced. Otherwise, a new pipeline is created. +5. If a pipeline with a matching `gitPath` or name already exists in the environment, its graph is replaced. Otherwise, a new pipeline is created. +6. If the environment has **Require Deploy Approval** enabled, imported pipelines are saved as drafts with a pending deploy request instead of being deployed immediately. {% hint style="warning" %} Bi-directional mode means the Git repository is the source of truth. Any manual edits made in the VectorFlow UI may be overwritten on the next push to the repository. The pipeline editor shows a banner to remind users of this. {% endhint %} +## Pipeline Name / Filename Decoupling + +VectorFlow uses a stable `gitPath` field to track the file path in Git for each pipeline. This means: + +- **Renaming a pipeline** in VectorFlow does not change its filename in Git. The original path is preserved. +- **First sync** automatically assigns a `gitPath` based on the environment and pipeline name slugs. +- **Webhook imports** match files by `gitPath` first, then by name as a fallback. + +This prevents broken sync when pipelines are renamed after initial setup. + +## Sync Status and Retries + +The **Git Sync Status** section on the environment detail page shows: + +- **Health badge** -- Green (healthy), yellow (pending retries), or red (failed). +- **Last successful sync** timestamp. +- **Recent sync jobs** with status, attempt count, and per-job retry buttons. +- **Import errors** from webhook events (YAML parse failures, invalid filenames, etc.). + +Failed sync operations are automatically retried up to 3 times with exponential backoff (30 seconds, 2 minutes, 10 minutes). After all retries are exhausted, a `git_sync_failed` alert is fired (if subscribed). You can also manually retry failed jobs from the UI. + +The environment list page shows a warning badge when an environment has unresolved sync failures. + ## File layout VectorFlow expects pipeline YAML files to follow the standard Vector configuration format: diff --git a/package.json b/package.json index 705e110f..694623e2 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@prisma/client": "^7.4.2", "@prisma/client-runtime-utils": "^7.4.2", "@tanstack/react-query": "^5.90.21", + "@tanstack/react-virtual": "^3.13.23", "@trpc/client": "^11.8.0", "@trpc/server": "^11.8.0", "@trpc/tanstack-react-query": "^11.8.0", @@ -74,6 +75,8 @@ "@asteasolutions/zod-to-openapi": "^8.5.0", "@next/bundle-analyzer": "^16.2.1", "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/bcryptjs": "^3.0.0", "@types/dagre": "^0.7.54", "@types/js-yaml": "^4.0.9", @@ -84,6 +87,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.1.6", + "jsdom": "^29.0.1", "monaco-editor": "^0.55.1", "prisma": "^7.4.2", "shadcn": "^3.8.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 601a2443..def081b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@tanstack/react-query': specifier: ^5.90.21 version: 5.90.21(react@19.2.3) + '@tanstack/react-virtual': + specifier: ^3.13.23 + version: 3.13.23(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@trpc/client': specifier: ^11.8.0 version: 11.11.0(@trpc/server@11.11.0(typescript@5.9.3))(typescript@5.9.3) @@ -160,6 +163,12 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.2.1 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@types/bcryptjs': specifier: ^3.0.0 version: 3.0.0 @@ -190,6 +199,9 @@ importers: eslint-config-next: specifier: 16.1.6 version: 16.1.6(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + jsdom: + specifier: ^29.0.1 + version: 29.0.1(@noble/hashes@2.0.1) monaco-editor: specifier: ^0.55.1 version: 0.55.1 @@ -213,13 +225,16 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@20.19.35)(msw@2.12.10(@types/node@20.19.35)(typescript@5.9.3))(vite@8.0.1(@types/node@20.19.35)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@20.19.35)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.10(@types/node@20.19.35)(typescript@5.9.3))(vite@8.0.1(@types/node@20.19.35)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) vitest-mock-extended: specifier: ^3.1.0 - version: 3.1.0(typescript@5.9.3)(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@20.19.35)(msw@2.12.10(@types/node@20.19.35)(typescript@5.9.3))(vite@8.0.1(@types/node@20.19.35)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))) + version: 3.1.0(typescript@5.9.3)(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@20.19.35)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.10(@types/node@20.19.35)(typescript@5.9.3))(vite@8.0.1(@types/node@20.19.35)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))) packages: + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -228,6 +243,17 @@ packages: resolution: {integrity: sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==} hasBin: true + '@asamuzakjp/css-color@5.0.1': + resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.0.4': + resolution: {integrity: sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@asteasolutions/zod-to-openapi@8.5.0': resolution: {integrity: sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==} peerDependencies: @@ -558,6 +584,10 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@chevrotain/cst-dts-gen@10.5.0': resolution: {integrity: sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==} @@ -570,6 +600,42 @@ packages: '@chevrotain/utils@10.5.0': resolution: {integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.2': + resolution: {integrity: sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@dagrejs/dagre@2.0.4': resolution: {integrity: sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA==} @@ -813,6 +879,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.4': resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} @@ -2420,6 +2495,38 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-virtual@3.13.23': + resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.13.23': + resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@trpc/client@11.11.0': resolution: {integrity: sha512-tIPeetFO8GT/o0In+Lk5JA3f29m8qCBSaKNatQdAx6BOfZFjpqHOaoAsCmE/MdUu9AVhPRPorcoqUlZUdDc5gA==} peerDependencies: @@ -2446,6 +2553,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/bcryptjs@3.0.0': resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==} deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed. @@ -2779,6 +2889,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} @@ -2790,6 +2904,9 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -2876,6 +2993,9 @@ packages: before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bintrees@1.0.2: resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} @@ -3084,6 +3204,13 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -3165,6 +3292,10 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -3204,6 +3335,9 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + dedent@1.7.1: resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} peerDependencies: @@ -3254,6 +3388,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} @@ -3275,6 +3413,12 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} @@ -3330,6 +3474,10 @@ packages: resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -3841,6 +3989,10 @@ packages: resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==} engines: {node: '>=16.9.0'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -3886,6 +4038,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -4016,6 +4172,9 @@ packages: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -4114,6 +4273,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@29.0.1: + resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -4356,6 +4524,10 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@11.2.7: + resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -4368,6 +4540,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -4380,6 +4556,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -4415,6 +4594,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} @@ -4706,6 +4889,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -4860,6 +5046,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -4964,6 +5154,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -5037,6 +5230,10 @@ packages: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} @@ -5136,6 +5333,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -5339,6 +5540,10 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -5371,6 +5576,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -5429,6 +5637,14 @@ packages: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -5511,6 +5727,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.24.6: + resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==} + engines: {node: '>=20.18.1'} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -5672,15 +5892,31 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-bundle-analyzer@4.10.1: resolution: {integrity: sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==} engines: {node: '>= 10.13.0'} hasBin: true + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -5746,6 +5982,13 @@ packages: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -5848,6 +6091,8 @@ packages: snapshots: + '@adobe/css-tools@4.4.4': {} + '@alloc/quick-lru@5.2.0': {} '@antfu/ni@25.0.0': @@ -5857,6 +6102,24 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 + '@asamuzakjp/css-color@5.0.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.7 + + '@asamuzakjp/dom-selector@7.0.4': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@asteasolutions/zod-to-openapi@8.5.0(zod@4.3.6)': dependencies: openapi3-ts: 4.5.0 @@ -6523,6 +6786,10 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@chevrotain/cst-dts-gen@10.5.0': dependencies: '@chevrotain/gast': 10.5.0 @@ -6538,6 +6805,30 @@ snapshots: '@chevrotain/utils@10.5.0': {} + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.2(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@dagrejs/dagre@2.0.4': dependencies: '@dagrejs/graphlib': 3.0.4 @@ -6723,6 +7014,10 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.0(@noble/hashes@2.0.1)': + optionalDependencies: + '@noble/hashes': 2.0.1 + '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 @@ -8428,6 +8723,44 @@ snapshots: '@tanstack/query-core': 5.90.20 react: 19.2.3 + '@tanstack/react-virtual@3.13.23(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@tanstack/virtual-core': 3.13.23 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + '@tanstack/virtual-core@3.13.23': {} + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@trpc/client@11.11.0(@trpc/server@11.11.0(typescript@5.9.3))(typescript@5.9.3)': dependencies: '@trpc/server': 11.11.0(typescript@5.9.3) @@ -8456,6 +8789,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/bcryptjs@3.0.0': dependencies: bcryptjs: 3.0.3 @@ -8803,6 +9138,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansis@4.2.0: {} argparse@2.0.1: {} @@ -8811,6 +9148,10 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -8910,6 +9251,10 @@ snapshots: before-after-hook@4.0.0: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + bintrees@1.0.2: {} body-parser@2.2.2: @@ -9121,6 +9466,13 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + cssesc@3.0.0: {} csstype@3.2.3: {} @@ -9193,6 +9545,13 @@ snapshots: data-uri-to-buffer@4.0.1: {} + data-urls@7.0.0(@noble/hashes@2.0.1): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.0.1) + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -9225,6 +9584,8 @@ snapshots: decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} + dedent@1.7.1: {} deep-is@0.1.4: {} @@ -9260,6 +9621,8 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + destr@2.0.5: {} detect-libc@2.1.2: {} @@ -9274,6 +9637,10 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.28.6 @@ -9326,6 +9693,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@6.0.1: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -10034,6 +10403,12 @@ snapshots: hono@4.12.7: {} + html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1): + dependencies: + '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} http-errors@2.0.1: @@ -10075,6 +10450,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inherits@2.0.4: {} internal-slot@1.1.0: @@ -10198,6 +10575,8 @@ snapshots: is-plain-object@5.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-property@1.0.2: {} @@ -10282,6 +10661,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.0.1(@noble/hashes@2.0.1): + dependencies: + '@asamuzakjp/css-color': 5.0.1 + '@asamuzakjp/dom-selector': 7.0.4 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.2(css-tree@3.2.1) + '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@2.0.1) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.0.1) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.24.6 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.0.1) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -10465,6 +10870,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@11.2.7: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -10475,6 +10882,8 @@ snapshots: dependencies: react: 19.2.3 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -10483,6 +10892,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + media-typer@1.1.0: {} merge-descriptors@2.0.0: {} @@ -10506,6 +10917,8 @@ snapshots: mimic-function@5.0.1: {} + min-indent@1.0.1: {} + minimatch@10.2.4: dependencies: brace-expansion: 5.0.5 @@ -10815,6 +11228,10 @@ snapshots: parse-ms@4.0.0: {} + parse5@8.0.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -10937,6 +11354,12 @@ snapshots: prelude-ls@1.2.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -11106,6 +11529,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@18.3.1: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.3): @@ -11188,6 +11613,11 @@ snapshots: tiny-invariant: 1.3.3 victory-vendor: 36.9.2 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + redis-errors@1.2.0: {} redis-parser@3.0.0: @@ -11314,6 +11744,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -11623,6 +12057,10 @@ snapshots: strip-final-newline@4.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} strnum@2.2.2: {} @@ -11644,6 +12082,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tagged-tag@1.0.0: {} tailwind-merge@3.5.0: {} @@ -11687,6 +12127,14 @@ snapshots: dependencies: tldts: 7.0.23 + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.23 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -11793,6 +12241,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.24.6: {} + unicorn-magic@0.3.0: {} universal-user-agent@7.0.3: {} @@ -11898,13 +12348,13 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vitest-mock-extended@3.1.0(typescript@5.9.3)(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@20.19.35)(msw@2.12.10(@types/node@20.19.35)(typescript@5.9.3))(vite@8.0.1(@types/node@20.19.35)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))): + vitest-mock-extended@3.1.0(typescript@5.9.3)(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@20.19.35)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.10(@types/node@20.19.35)(typescript@5.9.3))(vite@8.0.1(@types/node@20.19.35)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))): dependencies: ts-essentials: 10.1.1(typescript@5.9.3) typescript: 5.9.3 - vitest: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@20.19.35)(msw@2.12.10(@types/node@20.19.35)(typescript@5.9.3))(vite@8.0.1(@types/node@20.19.35)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@20.19.35)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.10(@types/node@20.19.35)(typescript@5.9.3))(vite@8.0.1(@types/node@20.19.35)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) - vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@20.19.35)(msw@2.12.10(@types/node@20.19.35)(typescript@5.9.3))(vite@8.0.1(@types/node@20.19.35)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@20.19.35)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.10(@types/node@20.19.35)(typescript@5.9.3))(vite@8.0.1(@types/node@20.19.35)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.0 '@vitest/mocker': 4.1.0(msw@2.12.10(@types/node@20.19.35)(typescript@5.9.3))(vite@8.0.1(@types/node@20.19.35)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -11929,11 +12379,18 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 20.19.35 + jsdom: 29.0.1(@noble/hashes@2.0.1) transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-streams-polyfill@3.3.3: {} + webidl-conversions@8.0.1: {} + webpack-bundle-analyzer@4.10.1: dependencies: '@discoveryjs/json-ext': 0.5.7 @@ -11953,6 +12410,16 @@ snapshots: - bufferutil - utf-8-validate + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@2.0.1): + dependencies: + '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -12032,6 +12499,10 @@ snapshots: is-wsl: 3.1.1 powershell-utils: 0.1.0 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xtend@4.0.2: {} y18n@4.0.3: {} diff --git a/prisma/backfill-git-path.ts b/prisma/backfill-git-path.ts new file mode 100644 index 00000000..b67c6493 --- /dev/null +++ b/prisma/backfill-git-path.ts @@ -0,0 +1,48 @@ +import { PrismaClient } from "@/generated/prisma"; + +const prisma = new PrismaClient(); + +function toFilenameSlug(name: string): string { + const slug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + return slug || "unnamed"; +} + +async function backfill() { + // Find all pipelines in environments with active git sync + const pipelines = await prisma.pipeline.findMany({ + where: { + gitPath: null, + environment: { + gitRepoUrl: { not: null }, + gitOpsMode: { not: "off" }, + }, + }, + include: { + environment: { select: { name: true } }, + }, + }); + + console.log(`Found ${pipelines.length} pipelines to backfill`); + + for (const pipeline of pipelines) { + const envSlug = toFilenameSlug(pipeline.environment.name); + const pipelineSlug = toFilenameSlug(pipeline.name); + const gitPath = `${envSlug}/${pipelineSlug}.yaml`; + + await prisma.pipeline.update({ + where: { id: pipeline.id }, + data: { gitPath }, + }); + + console.log(` ${pipeline.name} -> ${gitPath}`); + } + + console.log("Backfill complete"); +} + +backfill() + .catch(console.error) + .finally(() => prisma.$disconnect()); diff --git a/prisma/migrations/20260328000000_add_filter_preset/migration.sql b/prisma/migrations/20260328000000_add_filter_preset/migration.sql new file mode 100644 index 00000000..2c019f7c --- /dev/null +++ b/prisma/migrations/20260328000000_add_filter_preset/migration.sql @@ -0,0 +1,26 @@ +-- CreateTable +CREATE TABLE "FilterPreset" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "environmentId" TEXT NOT NULL, + "scope" TEXT NOT NULL, + "filters" JSONB NOT NULL, + "isDefault" BOOLEAN NOT NULL DEFAULT false, + "createdById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FilterPreset_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "FilterPreset_environmentId_scope_idx" ON "FilterPreset"("environmentId", "scope"); + +-- CreateIndex +CREATE INDEX "FilterPreset_createdById_idx" ON "FilterPreset"("createdById"); + +-- AddForeignKey +ALTER TABLE "FilterPreset" ADD CONSTRAINT "FilterPreset_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FilterPreset" ADD CONSTRAINT "FilterPreset_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260328000000_add_service_account_rate_limit/migration.sql b/prisma/migrations/20260328000000_add_service_account_rate_limit/migration.sql new file mode 100644 index 00000000..8de434bf --- /dev/null +++ b/prisma/migrations/20260328000000_add_service_account_rate_limit/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ServiceAccount" ADD COLUMN "rateLimit" INTEGER; diff --git a/prisma/migrations/20260328000000_gitops_multi_provider/migration.sql b/prisma/migrations/20260328000000_gitops_multi_provider/migration.sql new file mode 100644 index 00000000..80ff9634 --- /dev/null +++ b/prisma/migrations/20260328000000_gitops_multi_provider/migration.sql @@ -0,0 +1,44 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "gitProvider" TEXT; + +-- AlterTable +ALTER TABLE "Pipeline" ADD COLUMN "gitPath" TEXT; + +-- AlterEnum +ALTER TYPE "AlertMetric" ADD VALUE 'git_sync_failed'; + +-- CreateTable +CREATE TABLE "GitSyncJob" ( + "id" TEXT NOT NULL, + "environmentId" TEXT NOT NULL, + "pipelineId" TEXT NOT NULL, + "action" TEXT NOT NULL, + "configYaml" TEXT, + "commitMessage" TEXT, + "authorName" TEXT, + "authorEmail" TEXT, + "attempts" INTEGER NOT NULL DEFAULT 0, + "maxAttempts" INTEGER NOT NULL DEFAULT 3, + "lastError" TEXT, + "status" TEXT NOT NULL DEFAULT 'pending', + "nextRetryAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completedAt" TIMESTAMP(3), + + CONSTRAINT "GitSyncJob_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "GitSyncJob_status_nextRetryAt_idx" ON "GitSyncJob"("status", "nextRetryAt"); + +-- CreateIndex +CREATE INDEX "GitSyncJob_environmentId_idx" ON "GitSyncJob"("environmentId"); + +-- CreateIndex +CREATE INDEX "GitSyncJob_pipelineId_idx" ON "GitSyncJob"("pipelineId"); + +-- AddForeignKey +ALTER TABLE "GitSyncJob" ADD CONSTRAINT "GitSyncJob_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GitSyncJob" ADD CONSTRAINT "GitSyncJob_pipelineId_fkey" FOREIGN KEY ("pipelineId") REFERENCES "Pipeline"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260328100000_add_drift_detection/migration.sql b/prisma/migrations/20260328100000_add_drift_detection/migration.sql new file mode 100644 index 00000000..81731a3e --- /dev/null +++ b/prisma/migrations/20260328100000_add_drift_detection/migration.sql @@ -0,0 +1,6 @@ +-- AlterEnum +ALTER TYPE "AlertMetric" ADD VALUE 'version_drift'; +ALTER TYPE "AlertMetric" ADD VALUE 'config_drift'; + +-- AlterTable +ALTER TABLE "NodePipelineStatus" ADD COLUMN "configChecksum" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5385e50f..72aa032d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -41,6 +41,7 @@ model User { aiMessagesCreated AiMessage[] @relation("AiMessageCreatedBy") pipelineVersionsCreated PipelineVersion[] @relation("PipelineVersionCreatedBy") stagedRolloutsCreated StagedRollout[] @relation("StagedRolloutCreatedBy") + filterPresets FilterPreset[] createdAt DateTime @default(now()) } @@ -140,6 +141,7 @@ model Environment { gitBranch String? @default("main") gitToken String? // Stored encrypted via crypto.ts gitOpsMode String @default("off") // "off" | "push" | "bidirectional" | "promotion" + gitProvider String? // "github" | "gitlab" | "bitbucket" — auto-detected from gitRepoUrl if null gitWebhookSecret String? // HMAC secret for validating incoming git webhooks requireDeployApproval Boolean @default(false) alertRules AlertRule[] @@ -154,6 +156,8 @@ model Environment { stagedRollouts StagedRollout[] promotionSources PromotionRequest[] @relation("PromotionSourceEnv") promotionTargets PromotionRequest[] @relation("PromotionTargetEnv") + filterPresets FilterPreset[] + gitSyncJobs GitSyncJob[] createdAt DateTime @default(now()) } @@ -321,6 +325,7 @@ model Pipeline { versions PipelineVersion[] globalConfig Json? nodeSelector Json? + gitPath String? // Stable git file path, e.g. "production/my-pipeline.yaml" isDraft Boolean @default(true) isSystem Boolean @default(false) deployedAt DateTime? @@ -347,6 +352,7 @@ model Pipeline { downstreamDeps PipelineDependency[] @relation("PipelineUpstream") promotionSources PromotionRequest[] @relation("PromotionSource") promotionTargets PromotionRequest[] @relation("PromotionTarget") + gitSyncJobs GitSyncJob[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -382,6 +388,7 @@ model NodePipelineStatus { bytesIn BigInt @default(0) bytesOut BigInt @default(0) utilization Float @default(0) + configChecksum String? recentLogs Json? lastUpdated DateTime @default(now()) @@ -789,6 +796,30 @@ model PromotionRequest { @@index([targetEnvironmentId]) } +model GitSyncJob { + id String @id @default(cuid()) + environmentId String + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + pipelineId String + pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade) + action String // "commit" | "delete" + configYaml String? // The YAML content for commit actions + commitMessage String? + authorName String? + authorEmail String? + attempts Int @default(0) + maxAttempts Int @default(3) + lastError String? + status String @default("pending") // "pending" | "completed" | "failed" + nextRetryAt DateTime? + createdAt DateTime @default(now()) + completedAt DateTime? + + @@index([status, nextRetryAt]) + @@index([environmentId]) + @@index([pipelineId]) +} + enum AlertMetric { // Infrastructure (threshold-based, per-node) node_unreachable @@ -805,6 +836,10 @@ enum AlertMetric { fleet_event_volume node_load_imbalance + // Drift detection (threshold-based) + version_drift + config_drift + // Events (fire on occurrence) deploy_requested deploy_completed @@ -818,6 +853,7 @@ enum AlertMetric { node_left // Phase 5 event — enum value added early so subscriptions can be created promotion_completed + git_sync_failed } enum AlertCondition { @@ -1000,6 +1036,7 @@ model ServiceAccount { lastUsedAt DateTime? expiresAt DateTime? enabled Boolean @default(true) + rateLimit Int? // Custom requests/minute override (null = use defaults) createdAt DateTime @default(now()) @@index([hashedKey]) @@ -1036,3 +1073,20 @@ model AiMessage { @@index([conversationId, createdAt]) } + +model FilterPreset { + id String @id @default(cuid()) + name String + environmentId String + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + scope String // "pipeline_list" | "fleet_matrix" + filters Json // serialized filter state + isDefault Boolean @default(false) + createdById String + createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([environmentId, scope]) + @@index([createdById]) +} diff --git a/src/app/(dashboard)/alerts/_components/alert-rules-section.tsx b/src/app/(dashboard)/alerts/_components/alert-rules-section.tsx index 0dc5dc1f..959d605f 100644 --- a/src/app/(dashboard)/alerts/_components/alert-rules-section.tsx +++ b/src/app/(dashboard)/alerts/_components/alert-rules-section.tsx @@ -235,7 +235,7 @@ export function AlertRulesSection({ environmentId }: { environmentId: string }) ); const rules = rulesQuery.data ?? []; - const pipelines = pipelinesQuery.data ?? []; + const pipelines = pipelinesQuery.data?.pipelines ?? []; const channels = channelsQuery.data ?? []; const openCreate = () => { diff --git a/src/app/(dashboard)/alerts/_components/constants.ts b/src/app/(dashboard)/alerts/_components/constants.ts index 8ca30d9b..eed8777c 100644 --- a/src/app/(dashboard)/alerts/_components/constants.ts +++ b/src/app/(dashboard)/alerts/_components/constants.ts @@ -32,6 +32,9 @@ export const METRIC_LABELS: Record = { fleet_throughput_drop: "Fleet Throughput Drop", fleet_event_volume: "Fleet Event Volume", node_load_imbalance: "Node Load Imbalance", + // Drift detection + version_drift: "Version Drift", + config_drift: "Config Drift", }; export const CONDITION_LABELS: Record = { @@ -56,6 +59,7 @@ export const GLOBAL_METRICS = new Set([ "fleet_throughput_drop", "fleet_event_volume", "node_load_imbalance", + "version_drift", ]); export const CHANNEL_TYPE_LABELS: Record = { diff --git a/src/app/(dashboard)/alerts/_components/delivery-status-panel.tsx b/src/app/(dashboard)/alerts/_components/delivery-status-panel.tsx index 3c2798cf..afe8646c 100644 --- a/src/app/(dashboard)/alerts/_components/delivery-status-panel.tsx +++ b/src/app/(dashboard)/alerts/_components/delivery-status-panel.tsx @@ -1,8 +1,11 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; +import { toast } from "sonner"; +import { RotateCw } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; import { StatusBadge } from "@/components/ui/status-badge"; import { Tooltip, @@ -61,6 +64,21 @@ interface DeliveryStatusPanelProps { export function DeliveryStatusPanel({ alertEventId, isOpen }: DeliveryStatusPanelProps) { const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const retryMutation = useMutation( + trpc.alert.retryDelivery.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.alert.listDeliveries.queryKey({ alertEventId }), + }); + toast.success("Retry initiated"); + }, + onError: (err) => { + toast.error("Retry failed", { description: err.message, duration: 6000 }); + }, + }), + ); const deliveriesQuery = useQuery( trpc.alert.listDeliveries.queryOptions( @@ -169,6 +187,20 @@ export function DeliveryStatusPanel({ alertEventId, isOpen }: DeliveryStatusPane ? ` → ${formatTimestamp(delivery.completedAt)}` : " → Pending…"} + + {/* Retry button for failed deliveries */} + {delivery.status === "failed" && ( + + )} ))} diff --git a/src/app/(dashboard)/alerts/_components/failed-deliveries-section.tsx b/src/app/(dashboard)/alerts/_components/failed-deliveries-section.tsx new file mode 100644 index 00000000..9a5d8638 --- /dev/null +++ b/src/app/(dashboard)/alerts/_components/failed-deliveries-section.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { toast } from "sonner"; +import { RotateCw, AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { StatusBadge } from "@/components/ui/status-badge"; +import { + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@/components/ui/tooltip"; + +interface FailedDeliveriesSectionProps { + environmentId: string; +} + +export function FailedDeliveriesSection({ environmentId }: FailedDeliveriesSectionProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const failedQuery = useQuery( + trpc.alert.listFailedDeliveries.queryOptions({ environmentId }), + ); + + const retryMutation = useMutation( + trpc.alert.retryDelivery.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.alert.listFailedDeliveries.queryKey({ environmentId }), + }); + toast.success("Retry initiated"); + }, + onError: (err) => { + toast.error("Retry failed", { description: err.message, duration: 6000 }); + }, + }), + ); + + const retryAllMutation = useMutation( + trpc.alert.retryAllForChannel.mutationOptions({ + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: trpc.alert.listFailedDeliveries.queryKey({ environmentId }), + }); + toast.success(`Retried ${data.retriedCount} of ${data.totalFailed} deliveries`); + }, + onError: (err) => { + toast.error("Retry all failed", { description: err.message, duration: 6000 }); + }, + }), + ); + + const deliveries = failedQuery.data ?? []; + + if (deliveries.length === 0 && !failedQuery.isLoading) { + return null; // Don't show the section if there are no failed deliveries + } + + // Group by channelName + channelType + const grouped = new Map(); + for (const d of deliveries) { + const key = `${d.channelType}:${d.channelName}`; + const group = grouped.get(key) ?? []; + group.push(d); + grouped.set(key, group); + } + + const CHANNEL_ICONS: Record = { + slack: "🔔", + email: "📧", + webhook: "🌐", + pagerduty: "🚨", + legacy_webhook: "🌐", + }; + + return ( +
+
+ +

Failed Deliveries

+ {deliveries.length} failed +
+

+ Deliveries that failed across all alert events, grouped by channel. +

+ +
+ {[...grouped.entries()].map(([key, items]) => { + const channelType = key.split(":")[0]; + const channelName = key.split(":").slice(1).join(":"); + return ( +
+
+
+ + {channelName} + ({items.length} failed) +
+ +
+
+ {items.map((delivery) => ( +
+ + {delivery.alertEvent?.alertRule?.name ?? "Unknown rule"} + + {delivery.attemptNumber != null && delivery.attemptNumber > 1 && ( + + Attempt #{delivery.attemptNumber} + + )} + {delivery.errorMessage && ( + + + + {delivery.errorMessage.length > 200 + ? `${delivery.errorMessage.slice(0, 200)}…` + : delivery.errorMessage} + + + + {delivery.errorMessage} + + + )} + + {delivery.requestedAt + ? new Date(delivery.requestedAt).toLocaleString() + : "—"} + + +
+ ))} +
+
+ ); + })} +
+
+ ); +} diff --git a/src/app/(dashboard)/alerts/page.tsx b/src/app/(dashboard)/alerts/page.tsx index 0a5f127e..cc5ac6f1 100644 --- a/src/app/(dashboard)/alerts/page.tsx +++ b/src/app/(dashboard)/alerts/page.tsx @@ -8,6 +8,7 @@ import { AlertRulesSection } from "./_components/alert-rules-section"; import { NotificationChannelsSection } from "./_components/notification-channels-section"; import { WebhooksSection } from "./_components/webhooks-section"; import { AlertHistorySection } from "./_components/alert-history-section"; +import { FailedDeliveriesSection } from "./_components/failed-deliveries-section"; // ─── Alerts Page ──────────────────────────────────────────────────────────────── @@ -37,6 +38,10 @@ export default function AlertsPage() { + + + + ); } diff --git a/src/app/(dashboard)/environments/[id]/page.tsx b/src/app/(dashboard)/environments/[id]/page.tsx index 0a48db36..0153540f 100644 --- a/src/app/(dashboard)/environments/[id]/page.tsx +++ b/src/app/(dashboard)/environments/[id]/page.tsx @@ -51,6 +51,7 @@ import { PageHeader } from "@/components/page-header"; import { SecretsSection } from "@/components/environment/secrets-section"; import { CertificatesSection } from "@/components/environment/certificates-section"; import { GitSyncSection } from "@/components/environment/git-sync-section"; +import { GitSyncStatus } from "@/components/environment/git-sync-status"; import { nodeStatusVariant, nodeStatusLabel } from "@/lib/status"; import { useTeamStore } from "@/stores/team-store"; import { EmptyState } from "@/components/empty-state"; @@ -634,8 +635,13 @@ export default function EnvironmentDetailPage({ hasGitToken={env.hasGitToken} gitOpsMode={env.gitOpsMode} hasWebhookSecret={env.hasWebhookSecret} + gitProvider={env.gitProvider ?? null} /> + {env.gitOpsMode !== "off" && ( + + )} + {/* Created info */}

Created {new Date(env.createdAt).toLocaleDateString()} diff --git a/src/app/(dashboard)/environments/page.tsx b/src/app/(dashboard)/environments/page.tsx index 07b90234..f454b2fa 100644 --- a/src/app/(dashboard)/environments/page.tsx +++ b/src/app/(dashboard)/environments/page.tsx @@ -16,6 +16,7 @@ import { TableRow, } from "@/components/ui/table"; import { Skeleton } from "@/components/ui/skeleton"; +import { Badge } from "@/components/ui/badge"; import { EmptyState } from "@/components/empty-state"; import { QueryError } from "@/components/query-error"; @@ -77,12 +78,19 @@ export default function EnvironmentsPage() { {environments.map((env) => ( - - {env.name} - +

+ + {env.name} + + {env._count.gitSyncJobs > 0 && ( + + {env._count.gitSyncJobs} sync {env._count.gitSyncJobs === 1 ? "failure" : "failures"} + + )} +
{env._count.nodes} diff --git a/src/app/(dashboard)/fleet/page.tsx b/src/app/(dashboard)/fleet/page.tsx index 66b5b9d8..b398b613 100644 --- a/src/app/(dashboard)/fleet/page.tsx +++ b/src/app/(dashboard)/fleet/page.tsx @@ -36,6 +36,9 @@ import { Tag, Wrench } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; import { ConfirmDialog } from "@/components/confirm-dialog"; import { DeploymentMatrix } from "@/components/fleet/deployment-matrix"; +import { NodeSummaryCards } from "@/components/fleet/NodeSummaryCards"; +import { FilterPresetBar } from "@/components/filter-preset/FilterPresetBar"; +import { SaveFilterDialog } from "@/components/filter-preset/SaveFilterDialog"; import { formatLastSeen } from "@/lib/format"; import { nodeStatusVariant, nodeStatusLabel } from "@/lib/status"; import { isVersionOlder } from "@/lib/version"; @@ -81,6 +84,31 @@ export default function FleetPage() { setTagFilter: setMatrixTagFilter, } = useMatrixFilters(); + const [saveFilterOpen, setSaveFilterOpen] = useState(false); + const [exceptionsOnly, setExceptionsOnly] = useState(false); + + // --- Auto-apply default filter preset on page load --- + const defaultPresetQuery = useQuery( + trpc.filterPreset.list.queryOptions( + { environmentId: activeEnvId, scope: "fleet_matrix" as const }, + { enabled: !!activeEnvId }, + ), + ); + + useEffect(() => { + if (!matrixHasActiveFilters && defaultPresetQuery.data) { + const defaultPreset = defaultPresetQuery.data.find((p) => p.isDefault); + if (defaultPreset) { + const f = defaultPreset.filters as Record; + if (f.search && typeof f.search === "string") setMatrixSearch(f.search); + if (Array.isArray(f.status) && f.status.length > 0) setMatrixStatusFilter(f.status as string[]); + if (Array.isArray(f.tags) && f.tags.length > 0) setMatrixTagFilter(f.tags as string[]); + } + } + // Only run on initial data load, not on every filter change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultPresetQuery.data]); + // Same query as DeploymentMatrix — React Query deduplicates by key const matrixQuery = useQuery({ ...trpc.fleet.listWithPipelineStatus.queryOptions({ environmentId: activeEnvId }), @@ -98,7 +126,7 @@ export default function FleetPage() { return [...tagSet].sort(); }, [matrixQuery.data?.deployedPipelines]); - // Compute filtered pipelines with AND logic across search, status, and tag filters + // Compute filtered pipelines with AND logic across search, status, tag, and exceptions filters const filteredDeployedPipelines = useMemo(() => { let result = matrixQuery.data?.deployedPipelines ?? []; const nodes = matrixQuery.data?.nodes ?? []; @@ -126,8 +154,23 @@ export default function FleetPage() { }); } + // Show only pipelines with exceptions: version mismatch, crashed, or missing on some nodes + if (exceptionsOnly) { + result = result.filter((p) => { + const nodeStatuses = nodes.flatMap((n) => + n.pipelineStatuses.filter((s) => s.pipelineId === p.id), + ); + const hasCrashed = nodeStatuses.some((s) => s.status === "CRASHED"); + const hasVersionMismatch = nodeStatuses.some( + (s) => s.version < p.latestVersion, + ); + const deployedOnAllNodes = nodeStatuses.length >= nodes.length; + return hasCrashed || hasVersionMismatch || !deployedOnAllNodes; + }); + } + return result; - }, [matrixQuery.data, matrixSearch, matrixStatusFilter, matrixTagFilter]); + }, [matrixQuery.data, matrixSearch, matrixStatusFilter, matrixTagFilter, exceptionsOnly]); // Clear all matrix filters when environment changes (D-07) const prevEnvRef = useRef(activeEnvId); @@ -581,31 +624,89 @@ export default function FleetPage() { {activeEnvId && (
-

Pipeline Deployment Matrix

- {matrixQuery.data && ( - - )} - Fleet Overview + + {/* Top section: Node summary cards */} + { - setMatrixSearch(""); - setMatrixStatusFilter([]); - setMatrixTagFilter([]); - }} /> + + {/* Bottom section: Filtered deployment matrix */} +
+

+ Deployment Matrix +

+ {matrixQuery.data && ( + { + const f = filters as { + search?: string; + status?: string[]; + tags?: string[]; + }; + setMatrixSearch(f.search ?? ""); + setMatrixStatusFilter(f.status ?? []); + setMatrixTagFilter(f.tags ?? []); + }} + onSaveClick={() => setSaveFilterOpen(true)} + /> + } + /> + )} + {matrixHasActiveFilters ? ( + { + setMatrixSearch(""); + setMatrixStatusFilter([]); + setMatrixTagFilter([]); + }} + /> + ) : ( +
+

+ Filter by group, tag, or status to load the deployment matrix. +

+
+ )} +
)} + {activeEnvId && ( + + )} + { if (!open) setMaintenanceTarget(null); }} diff --git a/src/app/(dashboard)/pipelines/page.tsx b/src/app/(dashboard)/pipelines/page.tsx index 860be22d..27327a0e 100644 --- a/src/app/(dashboard)/pipelines/page.tsx +++ b/src/app/(dashboard)/pipelines/page.tsx @@ -1,9 +1,9 @@ "use client"; -import { useState, useMemo, useCallback, Fragment } from "react"; +import { useState, useMemo, useCallback, useRef, useEffect, Fragment } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { toast } from "sonner"; import { @@ -67,6 +67,7 @@ import { PipelineListToolbar, type SortField, type SortDirection, + type Density, } from "@/components/pipeline/pipeline-list-toolbar"; import { ManageGroupsDialog } from "@/components/pipeline/manage-groups-dialog"; import { BulkActionBar } from "@/components/pipeline/bulk-action-bar"; @@ -76,6 +77,9 @@ import { type GroupNode, } from "@/components/pipeline/pipeline-group-tree"; import { usePipelineSidebarStore } from "@/stores/pipeline-sidebar-store"; +import { FilterPresetBar } from "@/components/filter-preset/FilterPresetBar"; +import { SaveFilterDialog } from "@/components/filter-preset/SaveFilterDialog"; +import { usePipelineListFilters } from "@/hooks/use-pipeline-list-filters"; // --- Helpers --- @@ -237,27 +241,81 @@ export default function PipelinesPage() { ); const selectedTeamId = useTeamStore((s) => s.selectedTeamId); - // --- Filter / sort state --- - const [search, setSearch] = useState(""); - const [statusFilter, setStatusFilter] = useState([]); - const [tagFilter, setTagFilter] = useState([]); - const groupId = usePipelineSidebarStore((s) => s.selectedGroupId); + // --- Filter / sort state (URL-synced) --- + const { + search, + statusFilter, + tagFilter, + groupId, + sortBy: serverSortBy, + sortOrder: serverSortOrder, + hasActiveFilters, + setSearch, + setStatusFilter, + setTagFilter, + setGroupId, + setSortBy: setServerSortBy, + setSortOrder: setServerSortOrder, + clearFilters, + } = usePipelineListFilters(); const manageGroupsOpen = usePipelineSidebarStore((s) => s.manageGroupsOpen); const setManageGroupsOpen = usePipelineSidebarStore((s) => s.setManageGroupsOpen); const [selectedPipelineIds, setSelectedPipelineIds] = useState>(new Set()); const [sortField, setSortField] = useState("name"); const [sortDirection, setSortDirection] = useState("asc"); + const [saveFilterOpen, setSaveFilterOpen] = useState(false); + const [density, setDensity] = useState(() => { + if (typeof window !== "undefined") { + return (localStorage.getItem("pipeline-list-density") as Density) ?? "comfortable"; + } + return "comfortable"; + }); + + const handleDensityChange = useCallback((d: Density) => { + setDensity(d); + localStorage.setItem("pipeline-list-density", d); + }, []); + + // Keyboard navigation + const [focusedIndex, setFocusedIndex] = useState(-1); + const rowRefs = useRef>(new Map()); + + useEffect(() => { + if (focusedIndex >= 0) { + rowRefs.current.get(focusedIndex)?.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } + }, [focusedIndex]); + + // Map toolbar sort fields to server-side or client-side sort + const SORT_FIELD_TO_SERVER: Record = { + name: "name", + updated: "updatedAt", + status: null, // client-only + throughput: null, // client-only + }; const handleSort = useCallback( (field: SortField) => { if (field === sortField) { - setSortDirection((d) => (d === "asc" ? "desc" : "asc")); + const newDir = sortDirection === "asc" ? "desc" : "asc"; + setSortDirection(newDir); + const serverField = SORT_FIELD_TO_SERVER[field]; + if (serverField) setServerSortOrder(newDir); } else { setSortField(field); setSortDirection("asc"); + const serverField = SORT_FIELD_TO_SERVER[field]; + if (serverField) { + setServerSortBy(serverField); + setServerSortOrder("asc"); + } } }, - [sortField], + // eslint-disable-next-line react-hooks/exhaustive-deps + [sortField, sortDirection, setServerSortBy, setServerSortOrder], ); const toggleSelect = useCallback((id: string) => { @@ -292,14 +350,31 @@ export default function PipelinesPage() { const environments = environmentsQuery.data ?? []; const effectiveEnvId = selectedEnvironmentId || environments[0]?.id || ""; - const pipelinesQuery = useQuery( - trpc.pipeline.list.queryOptions( - { environmentId: effectiveEnvId }, - { enabled: !!effectiveEnvId, refetchInterval: 30_000 }, + const pipelinesQuery = useInfiniteQuery( + trpc.pipeline.list.infiniteQueryOptions( + { + environmentId: effectiveEnvId, + limit: 50, + ...(search ? { search } : {}), + ...(statusFilter.length > 0 ? { status: statusFilter } : {}), + ...(tagFilter.length > 0 ? { tags: tagFilter } : {}), + ...(groupId ? { groupId } : {}), + sortBy: serverSortBy, + sortOrder: serverSortOrder, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + enabled: !!effectiveEnvId, + refetchInterval: 30_000, + }, ), ); - const pipelines = useMemo(() => pipelinesQuery.data ?? [], [pipelinesQuery.data]); + const pipelines = useMemo( + () => pipelinesQuery.data?.pages.flatMap((p) => p.pipelines) ?? [], + [pipelinesQuery.data], + ); + const totalCount = pipelinesQuery.data?.pages[0]?.totalCount ?? 0; // Poll live rates from MetricStore for the pipelines table const liveRatesQuery = useQuery( @@ -310,6 +385,31 @@ export default function PipelinesPage() { ); const liveRates = useMemo(() => liveRatesQuery.data?.rates ?? {}, [liveRatesQuery.data]); + // --- Auto-apply default filter preset on page load --- + const defaultPresetQuery = useQuery( + trpc.filterPreset.list.queryOptions( + { environmentId: effectiveEnvId, scope: "pipeline_list" as const }, + { enabled: !!effectiveEnvId }, + ), + ); + + useEffect(() => { + if (!hasActiveFilters && defaultPresetQuery.data) { + const defaultPreset = defaultPresetQuery.data.find((p) => p.isDefault); + if (defaultPreset) { + const f = defaultPreset.filters as Record; + if (f.search && typeof f.search === "string") setSearch(f.search); + if (Array.isArray(f.status) && f.status.length > 0) setStatusFilter(f.status as string[]); + if (Array.isArray(f.tags) && f.tags.length > 0) setTagFilter(f.tags as string[]); + if (f.groupId && typeof f.groupId === "string") { + setGroupId(f.groupId); + } + } + } + // Only run on initial data load, not on every filter change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultPresetQuery.data]); + // Fetch pending deploy requests for the current environment const pendingRequestsQuery = useQuery( trpc.deploy.listPendingRequests.queryOptions( @@ -386,51 +486,24 @@ export default function PipelinesPage() { trpc.pipeline.update.mutationOptions({ onSuccess: () => { toast.success("Pipeline group updated"); - queryClient.invalidateQueries({ queryKey: trpc.pipeline.list.queryKey() }); + queryClient.invalidateQueries({ queryKey: ["pipeline.list"] }); queryClient.invalidateQueries({ queryKey: trpc.pipelineGroup.list.queryKey() }); }, onError: (err) => toast.error(err.message || "Failed to update group", { duration: 6000 }), }), ); - // --- Filtered + sorted pipelines --- + // --- Client-side sort for columns the server cannot sort --- + // Server handles: search, status, tags, groupId, sortBy (name/updatedAt/deployedAt), sortOrder. + // Client handles: sort by derived status and live throughput (not in DB). const filteredPipelines = useMemo(() => { - let result = pipelines; - - // Group filter - if (groupId) { - result = result.filter((p) => p.groupId === groupId); - } + // Only apply client-side sort for fields the server can't handle + if (sortField !== "status" && sortField !== "throughput") return pipelines; - // Search by name (case-insensitive) - if (search) { - const lc = search.toLowerCase(); - result = result.filter((p) => p.name.toLowerCase().includes(lc)); - } - - // Status filter - if (statusFilter.length > 0) { - result = result.filter((p) => - statusFilter.includes(derivePipelineStatus(p)), - ); - } - - // Tag filter - if (tagFilter.length > 0) { - result = result.filter((p) => { - const tags = (p.tags as string[]) ?? []; - return tagFilter.some((t) => tags.includes(t)); - }); - } - - // Sort - const sorted = [...result]; + const sorted = [...pipelines]; sorted.sort((a, b) => { let cmp = 0; switch (sortField) { - case "name": - cmp = a.name.localeCompare(b.name); - break; case "status": { const sa = derivePipelineStatus(a); const sb = derivePipelineStatus(b); @@ -443,21 +516,41 @@ export default function PipelinesPage() { cmp = ra - rb; break; } - case "updated": - cmp = - new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(); - break; } return sortDirection === "asc" ? cmp : -cmp; }); return sorted; - }, [pipelines, search, statusFilter, tagFilter, groupId, sortField, sortDirection, liveRates]); + }, [pipelines, sortField, sortDirection, liveRates]); // --- Mutations --- const router = useRouter(); + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const items = filteredPipelines; + if (!items.length) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setFocusedIndex((prev) => Math.min(prev + 1, items.length - 1)); + break; + case "ArrowUp": + e.preventDefault(); + setFocusedIndex((prev) => Math.max(prev - 1, 0)); + break; + case "Enter": + if (focusedIndex >= 0 && focusedIndex < items.length) { + router.push(`/pipelines/${items[focusedIndex].id}`); + } + break; + } + }, + [filteredPipelines, focusedIndex, router], + ); + const cloneMutation = useMutation( trpc.pipeline.clone.mutationOptions({ onSuccess: (data) => { @@ -512,10 +605,7 @@ export default function PipelinesPage() { } const clearAllFilters = () => { - setSearch(""); - setStatusFilter([]); - setTagFilter([]); - usePipelineSidebarStore.getState().setSelectedGroupId(null); + clearFilters(); }; // Recursive renderer for nested "Move to group" dropdown items @@ -570,6 +660,35 @@ export default function PipelinesPage() { tagFilter={tagFilter} onTagFilterChange={setTagFilter} availableTags={availableTags} + density={density} + onDensityChange={handleDensityChange} + presetBar={ + effectiveEnvId ? ( + { + const f = filters as { + search?: string; + status?: string[]; + tags?: string[]; + groupId?: string; + }; + setSearch(f.search ?? ""); + setStatusFilter(f.status ?? []); + setTagFilter(f.tags ?? []); + setGroupId(f.groupId ?? null); + }} + onSaveClick={() => setSaveFilterOpen(true)} + /> + ) : undefined + } /> )} @@ -606,6 +725,7 @@ export default function PipelinesPage() { ) : ( +
@@ -660,7 +780,7 @@ export default function PipelinesPage() { - {filteredPipelines.map((pipeline) => { + {filteredPipelines.map((pipeline, index) => { const hasStats = pipeline.nodeStatuses.length > 0; const totals = hasStats ? sumNodeStatuses(pipeline.nodeStatuses) @@ -670,7 +790,15 @@ export default function PipelinesPage() { { + if (el) rowRefs.current.set(index, el); + else rowRefs.current.delete(index); + }} + className={cn( + "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors cursor-pointer", + density === "compact" && "h-10", + index === focusedIndex && "bg-muted/50 ring-1 ring-ring ring-inset" + )} > e.stopPropagation()}> - + {pipeline.name} + {density === "comfortable" && pipeline.description && ( +

+ {pipeline.description} +

+ )}
{(() => { @@ -1003,6 +1136,23 @@ export default function PipelinesPage() { })}
+
+ )} + + {/* Load More button for paginated results */} + {pipelinesQuery.hasNextPage && ( +
+ +
)} @@ -1047,6 +1197,21 @@ export default function PipelinesPage() { environmentId={effectiveEnvId} /> )} + + {effectiveEnvId && ( + + )} ); } diff --git a/src/app/(dashboard)/settings/_components/settings-overview.tsx b/src/app/(dashboard)/settings/_components/settings-overview.tsx new file mode 100644 index 00000000..7327c3b1 --- /dev/null +++ b/src/app/(dashboard)/settings/_components/settings-overview.tsx @@ -0,0 +1,163 @@ +"use client"; + +import Link from "next/link"; +import { useSession } from "next-auth/react"; +import { + RefreshCw, + HardDrive, + Shield, + KeyRound, + UserCog, + Building2, + Users, + Bot, + Sparkles, + Server, + Upload, + Webhook, +} from "lucide-react"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; + +interface SettingsCategory { + title: string; + description: string; + href: string; + icon: React.ComponentType<{ className?: string }>; + requiredSuperAdmin: boolean; +} + +const CATEGORIES: SettingsCategory[] = [ + { + title: "Version Check", + description: "Check for VectorFlow updates and view current version info.", + href: "/settings/version", + icon: RefreshCw, + requiredSuperAdmin: true, + }, + { + title: "Backup", + description: "Configure automatic database backups and restore from backup.", + href: "/settings/backup", + icon: HardDrive, + requiredSuperAdmin: true, + }, + { + title: "Authentication", + description: "Configure OIDC providers, password policy, and two-factor authentication.", + href: "/settings/auth", + icon: Shield, + requiredSuperAdmin: true, + }, + { + title: "SCIM", + description: "Provision users and groups from your identity provider.", + href: "/settings/scim", + icon: KeyRound, + requiredSuperAdmin: true, + }, + { + title: "Users", + description: "Manage user accounts, roles, and access.", + href: "/settings/users", + icon: UserCog, + requiredSuperAdmin: true, + }, + { + title: "Teams", + description: "Create and manage teams for multi-tenant workspace isolation.", + href: "/settings/teams", + icon: Building2, + requiredSuperAdmin: true, + }, + { + title: "Team Settings", + description: "Configure your team's name, environments, and preferences.", + href: "/settings/team", + icon: Users, + requiredSuperAdmin: false, + }, + { + title: "Service Accounts", + description: "Create API tokens for CI/CD pipelines and external integrations.", + href: "/settings/service-accounts", + icon: Bot, + requiredSuperAdmin: false, + }, + { + title: "Outbound Webhooks", + description: "Configure webhooks to send events to external systems.", + href: "/settings/webhooks", + icon: Webhook, + requiredSuperAdmin: false, + }, + { + title: "AI", + description: "Configure AI assistant and LLM API keys.", + href: "/settings/ai", + icon: Sparkles, + requiredSuperAdmin: false, + }, + { + title: "Fleet", + description: "View and manage fleet nodes and their agent configuration.", + href: "/settings/fleet", + icon: Server, + requiredSuperAdmin: true, + }, + { + title: "Audit Log Shipping", + description: "Ship audit logs to an external SIEM or logging service.", + href: "/settings/audit-shipping", + icon: Upload, + requiredSuperAdmin: true, + }, +]; + +export function SettingsOverview() { + const { data: session } = useSession(); + const user = session?.user as ({ isSuperAdmin?: boolean; role?: string } & NonNullable["user"]) | undefined; + const isSuperAdmin = user?.isSuperAdmin === true; + const userRole = user?.role; + const isAdmin = isSuperAdmin || userRole === "ADMIN"; + + const visibleCategories = CATEGORIES.filter((cat) => { + if (cat.requiredSuperAdmin) return isSuperAdmin; + return isAdmin; + }); + + return ( +
+
+

Settings

+

+ Manage your VectorFlow instance configuration. +

+
+ +
+ {visibleCategories.map((cat) => { + const Icon = cat.icon; + return ( + + + +
+
+ +
+ {cat.title} +
+
+ + + {cat.description} + + +
+ + ); + })} +
+
+ ); +} diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 3540021e..39fb403f 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -1,12 +1,7 @@ "use client"; -import { useRouter } from "next/navigation"; -import { useEffect } from "react"; +import { SettingsOverview } from "./_components/settings-overview"; export default function SettingsPage() { - const router = useRouter(); - useEffect(() => { - router.replace("/settings/version"); - }, [router]); - return null; + return ; } diff --git a/src/app/api/agent/config/route.ts b/src/app/api/agent/config/route.ts index baa95778..7a0871c7 100644 --- a/src/app/api/agent/config/route.ts +++ b/src/app/api/agent/config/route.ts @@ -5,6 +5,7 @@ import { authenticateAgent } from "@/server/services/agent-auth"; import { collectSecretRefs, convertSecretRefsToEnvVars, resolveCertRefs, secretNameToEnvVar } from "@/server/services/secret-resolver"; import { decrypt } from "@/server/services/crypto"; import { createHash } from "crypto"; +import { setExpectedChecksum } from "@/server/services/drift-metrics"; export async function GET(request: Request) { const agent = await authenticateAgent(request); @@ -158,6 +159,7 @@ export async function GET(request: Request) { ? configYaml + JSON.stringify(secrets, Object.keys(secrets).sort()) : configYaml; const checksum = createHash("sha256").update(checksumInput).digest("hex"); + setExpectedChecksum(pipeline.id, checksum); pipelineConfigs.push({ pipelineId: pipeline.id, diff --git a/src/app/api/agent/heartbeat/__tests__/heartbeat-async.test.ts b/src/app/api/agent/heartbeat/__tests__/heartbeat-async.test.ts index acb19a6d..1fcc78b3 100644 --- a/src/app/api/agent/heartbeat/__tests__/heartbeat-async.test.ts +++ b/src/app/api/agent/heartbeat/__tests__/heartbeat-async.test.ts @@ -361,4 +361,35 @@ describe("heartbeat async decomposition", () => { ["line 1", "line 2"], ); }); + + it("persists configChecksum from heartbeat payload", async () => { + setupBaseMocks(); + + const req = makeRequest({ + pipelines: [ + { + pipelineId: "pipe-1", + version: 3, + status: "RUNNING", + configChecksum: "abc123def456", + }, + ], + sampleResults: [], + }); + + const response = await POST(req); + expect(response.status).toBe(200); + + // Verify batchUpsert was called with the configChecksum + expect(batchUpsertMock).toHaveBeenCalledWith( + expect.any(String), // nodeId + expect.arrayContaining([ + expect.objectContaining({ + pipelineId: "pipe-1", + configChecksum: "abc123def456", + }), + ]), + expect.any(Date), + ); + }); }); diff --git a/src/app/api/agent/heartbeat/route.ts b/src/app/api/agent/heartbeat/route.ts index 8e68b6a6..2a1fd8d8 100644 --- a/src/app/api/agent/heartbeat/route.ts +++ b/src/app/api/agent/heartbeat/route.ts @@ -60,6 +60,7 @@ const heartbeatSchema = z.object({ latencyMeanSeconds: z.number().optional(), // NEW })).optional(), utilization: z.number().optional(), + configChecksum: z.string().max(128).optional(), recentLogs: z.array(z.string()).optional(), })), hostMetrics: z.object({ @@ -116,6 +117,7 @@ interface PipelineStatus { latencyMeanSeconds?: number; // NEW }>; utilization?: number; + configChecksum?: string; recentLogs?: string[]; } diff --git a/src/app/api/v1/_lib/__tests__/rate-limiter.test.ts b/src/app/api/v1/_lib/__tests__/rate-limiter.test.ts new file mode 100644 index 00000000..7d794099 --- /dev/null +++ b/src/app/api/v1/_lib/__tests__/rate-limiter.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { RateLimiter, type RateLimitTier } from "../rate-limiter"; + +describe("RateLimiter", () => { + let limiter: RateLimiter; + + beforeEach(() => { + vi.useFakeTimers(); + limiter = new RateLimiter(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("allows requests under the default limit", () => { + const result = limiter.check("sa-1", "default"); + expect(result.allowed).toBe(true); + expect(result.remaining).toBe(99); + }); + + it("blocks requests exceeding the default limit", () => { + for (let i = 0; i < 100; i++) { + limiter.check("sa-1", "default"); + } + const result = limiter.check("sa-1", "default"); + expect(result.allowed).toBe(false); + expect(result.retryAfter).toBeGreaterThan(0); + }); + + it("uses read tier with 200 req/min limit", () => { + for (let i = 0; i < 200; i++) { + const r = limiter.check("sa-1", "read"); + expect(r.allowed).toBe(true); + } + const result = limiter.check("sa-1", "read"); + expect(result.allowed).toBe(false); + }); + + it("uses deploy tier with 20 req/min limit", () => { + for (let i = 0; i < 20; i++) { + limiter.check("sa-1", "deploy"); + } + const result = limiter.check("sa-1", "deploy"); + expect(result.allowed).toBe(false); + }); + + it("resets after the window expires", () => { + for (let i = 0; i < 100; i++) { + limiter.check("sa-1", "default"); + } + expect(limiter.check("sa-1", "default").allowed).toBe(false); + + // Advance past 1-minute window + vi.advanceTimersByTime(61_000); + + expect(limiter.check("sa-1", "default").allowed).toBe(true); + }); + + it("respects custom rate limit override", () => { + for (let i = 0; i < 50; i++) { + limiter.check("sa-1", "default", 50); + } + const result = limiter.check("sa-1", "default", 50); + expect(result.allowed).toBe(false); + }); + + it("isolates rate limits between different service accounts", () => { + for (let i = 0; i < 100; i++) { + limiter.check("sa-1", "default"); + } + expect(limiter.check("sa-1", "default").allowed).toBe(false); + expect(limiter.check("sa-2", "default").allowed).toBe(true); + }); +}); diff --git a/src/app/api/v1/_lib/api-handler.ts b/src/app/api/v1/_lib/api-handler.ts index fc14af1c..0bbb8be1 100644 --- a/src/app/api/v1/_lib/api-handler.ts +++ b/src/app/api/v1/_lib/api-handler.ts @@ -5,6 +5,7 @@ import { hasPermission, type ServiceAccountContext, } from "@/server/middleware/api-auth"; +import { rateLimiter, type RateLimitTier } from "./rate-limiter"; /** BigInt-safe NextResponse.json() — converts BigInts to numbers before serialization. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -35,6 +36,7 @@ export function apiRoute( ctx: ServiceAccountContext, params?: Record, ) => Promise, + tier: RateLimitTier = "default", ) { return async ( req: NextRequest, @@ -44,11 +46,30 @@ export function apiRoute( const ctx = await authenticateApiKey(auth); if (!ctx) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + // Rate limiting (after auth, before permission check) + const rateResult = rateLimiter.check(ctx.serviceAccountId, tier, ctx.rateLimit); + if (!rateResult.allowed) { + return NextResponse.json( + { error: "Too many requests" }, + { + status: 429, + headers: { + "Retry-After": String(rateResult.retryAfter), + "X-RateLimit-Remaining": "0", + }, + }, + ); + } + if (!hasPermission(ctx, permission)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); try { const resolvedParams = params ? await params : undefined; - return await handler(req, ctx, resolvedParams); + const response = await handler(req, ctx, resolvedParams); + // Add rate limit headers to successful responses + response.headers.set("X-RateLimit-Remaining", String(rateResult.remaining)); + return response; } catch (err) { if (err instanceof TRPCError) { const status = TRPC_TO_HTTP[err.code] ?? 500; diff --git a/src/app/api/v1/_lib/openapi-spec.test.ts b/src/app/api/v1/_lib/openapi-spec.test.ts index b582d843..1576cc24 100644 --- a/src/app/api/v1/_lib/openapi-spec.test.ts +++ b/src/app/api/v1/_lib/openapi-spec.test.ts @@ -10,38 +10,61 @@ describe("generateOpenAPISpec", () => { it("has correct info.title and info.version", () => { const spec = generateOpenAPISpec(); expect(spec.info.title).toBe("VectorFlow REST API"); - expect(spec.info.version).toBe("1.0.0"); + expect(spec.info.version).toBe("2.0.0"); }); - it("spec.paths contains all 16 REST v1 operations", () => { + it("spec.paths contains all REST v1 operations (original 16 + new endpoints)", () => { const spec = generateOpenAPISpec(); const paths = spec.paths as Record>; - // Pipeline operations + // Original 16 expect(paths["/api/v1/pipelines"]?.get).toBeDefined(); expect(paths["/api/v1/pipelines/{id}"]?.get).toBeDefined(); expect(paths["/api/v1/pipelines/{id}/deploy"]?.post).toBeDefined(); expect(paths["/api/v1/pipelines/{id}/rollback"]?.post).toBeDefined(); expect(paths["/api/v1/pipelines/{id}/undeploy"]?.post).toBeDefined(); expect(paths["/api/v1/pipelines/{id}/versions"]?.get).toBeDefined(); - - // Node operations expect(paths["/api/v1/nodes"]?.get).toBeDefined(); expect(paths["/api/v1/nodes/{id}"]?.get).toBeDefined(); expect(paths["/api/v1/nodes/{id}/maintenance"]?.post).toBeDefined(); - - // Secret operations expect(paths["/api/v1/secrets"]?.get).toBeDefined(); expect(paths["/api/v1/secrets"]?.post).toBeDefined(); expect(paths["/api/v1/secrets"]?.put).toBeDefined(); expect(paths["/api/v1/secrets"]?.delete).toBeDefined(); - - // Alert operations expect(paths["/api/v1/alerts/rules"]?.get).toBeDefined(); expect(paths["/api/v1/alerts/rules"]?.post).toBeDefined(); - - // Audit operations expect(paths["/api/v1/audit"]?.get).toBeDefined(); + + // New — Tier 1: Pipeline lifecycle + expect(paths["/api/v1/pipelines"]?.post).toBeDefined(); + expect(paths["/api/v1/pipelines/{id}"]?.put).toBeDefined(); + expect(paths["/api/v1/pipelines/{id}"]?.delete).toBeDefined(); + expect(paths["/api/v1/pipelines/{id}/config"]?.get).toBeDefined(); + expect(paths["/api/v1/pipelines/{id}/nodes"]?.post).toBeDefined(); + expect(paths["/api/v1/pipelines/{id}/nodes/{nodeId}"]?.put).toBeDefined(); + expect(paths["/api/v1/pipelines/{id}/nodes/{nodeId}"]?.delete).toBeDefined(); + expect(paths["/api/v1/pipelines/{id}/edges"]?.post).toBeDefined(); + expect(paths["/api/v1/pipelines/{id}/edges/{edgeId}"]?.delete).toBeDefined(); + expect(paths["/api/v1/pipelines/import"]?.post).toBeDefined(); + + // New — Tier 2: Fleet & monitoring + expect(paths["/api/v1/nodes"]?.post).toBeDefined(); + expect(paths["/api/v1/nodes/{id}"]?.delete).toBeDefined(); + expect(paths["/api/v1/nodes/{id}/labels"]?.put).toBeDefined(); + expect(paths["/api/v1/nodes/{id}/metrics"]?.get).toBeDefined(); + expect(paths["/api/v1/pipelines/{id}/metrics"]?.get).toBeDefined(); + expect(paths["/api/v1/pipelines/{id}/logs"]?.get).toBeDefined(); + expect(paths["/api/v1/pipelines/{id}/health"]?.get).toBeDefined(); + expect(paths["/api/v1/fleet/overview"]?.get).toBeDefined(); + + // New — Tier 3: Advanced operations + expect(paths["/api/v1/pipelines/{id}/promote"]?.post).toBeDefined(); + expect(paths["/api/v1/deploy-requests"]?.get).toBeDefined(); + expect(paths["/api/v1/deploy-requests/{id}/approve"]?.post).toBeDefined(); + expect(paths["/api/v1/deploy-requests/{id}/reject"]?.post).toBeDefined(); + expect(paths["/api/v1/node-groups"]?.get).toBeDefined(); + expect(paths["/api/v1/node-groups"]?.post).toBeDefined(); + expect(paths["/api/v1/environments"]?.get).toBeDefined(); }); it("every REST v1 operation has a security requirement referencing BearerAuth", () => { diff --git a/src/app/api/v1/_lib/openapi-spec.ts b/src/app/api/v1/_lib/openapi-spec.ts index 4619440f..e213660d 100644 --- a/src/app/api/v1/_lib/openapi-spec.ts +++ b/src/app/api/v1/_lib/openapi-spec.ts @@ -922,6 +922,863 @@ registry.registerPath({ }, }); +// --------------------------------------------------------------------------- +// New shared schemas (API v1 completeness) +// --------------------------------------------------------------------------- + +const MetricSchema = z.object({ + id: z.string(), + timestamp: z.string().openapi({ format: "date-time" }), + eventsIn: z.number(), + eventsOut: z.number(), + errorsTotal: z.number(), + bytesIn: z.number(), + bytesOut: z.number(), +}).openapi("Metric"); + +const PipelineLogSchema = z.object({ + id: z.string(), + pipelineId: z.string(), + nodeId: z.string(), + timestamp: z.string().openapi({ format: "date-time" }), + level: z.enum(["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]), + message: z.string(), +}).openapi("PipelineLog"); + +const NodeGroupSchema = z.object({ + id: z.string(), + name: z.string(), + environmentId: z.string(), + criteria: z.record(z.string(), z.unknown()), + labelTemplate: z.record(z.string(), z.string()), + requiredLabels: z.array(z.string()), + createdAt: z.string().openapi({ format: "date-time" }), + updatedAt: z.string().openapi({ format: "date-time" }), +}).openapi("NodeGroup"); + +const DeployRequestSchema = z.object({ + id: z.string(), + pipelineId: z.string(), + environmentId: z.string(), + status: z.string().openapi({ example: "PENDING" }), + changelog: z.string(), + createdAt: z.string().openapi({ format: "date-time" }), + reviewedAt: z.string().nullable().openapi({ format: "date-time" }), + reviewNote: z.string().nullable(), + deployedAt: z.string().nullable().openapi({ format: "date-time" }), + pipeline: z.object({ id: z.string(), name: z.string() }), +}).openapi("DeployRequest"); + +const EnvironmentSchema = z.object({ + id: z.string(), + name: z.string(), + isSystem: z.boolean(), + requireDeployApproval: z.boolean(), + gitOpsMode: z.string(), + createdAt: z.string().openapi({ format: "date-time" }), +}).openapi("Environment"); + +const HealthSchema = z.object({ + status: z.enum(["healthy", "degraded", "unhealthy", "unknown"]), + pipeline: z.object({ + id: z.string(), + name: z.string(), + isDraft: z.boolean(), + deployedAt: z.string().nullable(), + }), + nodes: z.object({ + total: z.number(), + running: z.number(), + }), + latestMetrics: z.object({ + eventsIn: z.number(), + eventsOut: z.number(), + errorsTotal: z.number(), + bytesIn: z.number(), + bytesOut: z.number(), + timestamp: z.string().openapi({ format: "date-time" }), + }).nullable(), +}).openapi("PipelineHealth"); + +const FleetOverviewSchema = z.object({ + totalNodes: z.number(), + nodesByStatus: z.record(z.string(), z.number()), + nodesInMaintenance: z.number(), + totalPipelines: z.number(), + deployedPipelines: z.number(), + draftPipelines: z.number(), +}).openapi("FleetOverview"); + +// --------------------------------------------------------------------------- +// Register new paths — Tier 1: Pipeline Lifecycle +// --------------------------------------------------------------------------- + +// POST /api/v1/pipelines +registry.registerPath({ + method: "post", + path: "/api/v1/pipelines", + operationId: "createPipeline", + summary: "Create pipeline", + description: "Creates a new draft pipeline in the environment.", + tags: ["Pipelines"], + security: [{ [bearerAuth.name]: [] }], + request: { + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + name: z.string().openapi({ example: "my-pipeline" }), + description: z.string().optional(), + groupId: z.string().optional(), + }), + }, + }, + }, + }, + responses: { + 201: { + description: "Pipeline created", + content: { "application/json": { schema: z.object({ pipeline: PipelineSchema }) } }, + }, + 400: { description: "Invalid input", content: { "application/json": { schema: ErrorResponse } } }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 409: { description: "Name conflict", content: { "application/json": { schema: ErrorResponse } } }, + }, +}); + +// PUT /api/v1/pipelines/{id} +registry.registerPath({ + method: "put", + path: "/api/v1/pipelines/{id}", + operationId: "updatePipeline", + summary: "Update pipeline metadata", + description: "Updates the name, description, or groupId of a pipeline.", + tags: ["Pipelines"], + security: [{ [bearerAuth.name]: [] }], + request: { + params: z.object({ id: z.string().openapi({ description: "Pipeline ID" }) }), + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + name: z.string().optional(), + description: z.string().optional(), + groupId: z.string().nullable().optional(), + }), + }, + }, + }, + }, + responses: { + 200: { + description: "Pipeline updated", + content: { "application/json": { schema: z.object({ pipeline: PipelineSchema }) } }, + }, + 400: { description: "Invalid input" }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Pipeline not found", content: { "application/json": { schema: ErrorResponse } } }, + }, +}); + +// DELETE /api/v1/pipelines/{id} +registry.registerPath({ + method: "delete", + path: "/api/v1/pipelines/{id}", + operationId: "deletePipeline", + summary: "Delete pipeline", + description: "Deletes a draft pipeline. Deployed pipelines must be undeployed first.", + tags: ["Pipelines"], + security: [{ [bearerAuth.name]: [] }], + request: { + params: z.object({ id: z.string().openapi({ description: "Pipeline ID" }) }), + }, + responses: { + 200: { + description: "Pipeline deleted", + content: { "application/json": { schema: z.object({ deleted: z.literal(true) }) } }, + }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Pipeline not found", content: { "application/json": { schema: ErrorResponse } } }, + 409: { description: "Pipeline is deployed", content: { "application/json": { schema: ErrorResponse } } }, + }, +}); + +// GET /api/v1/pipelines/{id}/config +registry.registerPath({ + method: "get", + path: "/api/v1/pipelines/{id}/config", + operationId: "getPipelineConfig", + summary: "Get generated YAML config", + description: "Returns the generated Vector YAML configuration for the pipeline.", + tags: ["Pipelines"], + security: [{ [bearerAuth.name]: [] }], + request: { + params: z.object({ id: z.string().openapi({ description: "Pipeline ID" }) }), + }, + responses: { + 200: { + description: "Generated config", + content: { + "application/json": { + schema: z.object({ config: z.string(), format: z.literal("yaml") }), + }, + }, + }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Pipeline not found", content: { "application/json": { schema: ErrorResponse } } }, + }, +}); + +// POST /api/v1/pipelines/{id}/nodes +registry.registerPath({ + method: "post", + path: "/api/v1/pipelines/{id}/nodes", + operationId: "addPipelineNode", + summary: "Add node to pipeline", + description: "Adds a new source, transform, or sink node to the pipeline graph.", + tags: ["Pipelines"], + security: [{ [bearerAuth.name]: [] }], + request: { + params: z.object({ id: z.string().openapi({ description: "Pipeline ID" }) }), + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + componentKey: z.string(), + componentType: z.string(), + kind: z.enum(["SOURCE", "TRANSFORM", "SINK"]), + config: z.record(z.string(), z.unknown()).optional(), + positionX: z.number().optional(), + positionY: z.number().optional(), + }), + }, + }, + }, + }, + responses: { + 201: { + description: "Node added", + content: { "application/json": { schema: z.object({ node: PipelineNodeSchema }) } }, + }, + 400: { description: "Invalid input" }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Pipeline not found" }, + }, +}); + +// PUT /api/v1/pipelines/{id}/nodes/{nodeId} +registry.registerPath({ + method: "put", + path: "/api/v1/pipelines/{id}/nodes/{nodeId}", + operationId: "updatePipelineNode", + summary: "Update node config", + description: "Updates the configuration, position, or disabled state of a pipeline node.", + tags: ["Pipelines"], + security: [{ [bearerAuth.name]: [] }], + request: { + params: z.object({ + id: z.string().openapi({ description: "Pipeline ID" }), + nodeId: z.string().openapi({ description: "Node ID" }), + }), + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + config: z.record(z.string(), z.unknown()).optional(), + displayName: z.string().optional(), + positionX: z.number().optional(), + positionY: z.number().optional(), + disabled: z.boolean().optional(), + }), + }, + }, + }, + }, + responses: { + 200: { + description: "Node updated", + content: { "application/json": { schema: z.object({ node: PipelineNodeSchema }) } }, + }, + 400: { description: "Invalid input" }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Node not found" }, + }, +}); + +// DELETE /api/v1/pipelines/{id}/nodes/{nodeId} +registry.registerPath({ + method: "delete", + path: "/api/v1/pipelines/{id}/nodes/{nodeId}", + operationId: "deletePipelineNode", + summary: "Remove node from pipeline", + description: "Removes a node and all its connected edges from the pipeline graph.", + tags: ["Pipelines"], + security: [{ [bearerAuth.name]: [] }], + request: { + params: z.object({ + id: z.string().openapi({ description: "Pipeline ID" }), + nodeId: z.string().openapi({ description: "Node ID" }), + }), + }, + responses: { + 200: { + description: "Node removed", + content: { "application/json": { schema: z.object({ deleted: z.literal(true) }) } }, + }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Node not found" }, + }, +}); + +// POST /api/v1/pipelines/{id}/edges +registry.registerPath({ + method: "post", + path: "/api/v1/pipelines/{id}/edges", + operationId: "addPipelineEdge", + summary: "Add edge to pipeline", + description: "Connects two nodes in the pipeline graph.", + tags: ["Pipelines"], + security: [{ [bearerAuth.name]: [] }], + request: { + params: z.object({ id: z.string().openapi({ description: "Pipeline ID" }) }), + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + sourceNodeId: z.string(), + targetNodeId: z.string(), + sourcePort: z.string().optional(), + }), + }, + }, + }, + }, + responses: { + 201: { + description: "Edge added", + content: { "application/json": { schema: z.object({ edge: PipelineEdgeSchema }) } }, + }, + 400: { description: "Invalid input" }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Pipeline or node not found" }, + }, +}); + +// DELETE /api/v1/pipelines/{id}/edges/{edgeId} +registry.registerPath({ + method: "delete", + path: "/api/v1/pipelines/{id}/edges/{edgeId}", + operationId: "deletePipelineEdge", + summary: "Remove edge from pipeline", + description: "Removes an edge from the pipeline graph.", + tags: ["Pipelines"], + security: [{ [bearerAuth.name]: [] }], + request: { + params: z.object({ + id: z.string().openapi({ description: "Pipeline ID" }), + edgeId: z.string().openapi({ description: "Edge ID" }), + }), + }, + responses: { + 200: { + description: "Edge removed", + content: { "application/json": { schema: z.object({ deleted: z.literal(true) }) } }, + }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Edge not found" }, + }, +}); + +// POST /api/v1/pipelines/import +registry.registerPath({ + method: "post", + path: "/api/v1/pipelines/import", + operationId: "importPipeline", + summary: "Import pipeline from YAML", + description: "Parses a Vector YAML configuration and creates a new pipeline with the resulting graph.", + tags: ["Pipelines"], + security: [{ [bearerAuth.name]: [] }], + request: { + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + name: z.string(), + yaml: z.string(), + description: z.string().optional(), + groupId: z.string().optional(), + }), + }, + }, + }, + }, + responses: { + 201: { + description: "Pipeline imported", + content: { + "application/json": { + schema: z.object({ + pipeline: z.object({ + id: z.string(), + name: z.string(), + nodeCount: z.number(), + edgeCount: z.number(), + }), + }), + }, + }, + }, + 400: { description: "Invalid input or YAML", content: { "application/json": { schema: ErrorResponse } } }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 409: { description: "Name conflict" }, + }, +}); + +// --------------------------------------------------------------------------- +// Register new paths — Tier 2: Fleet & Monitoring +// --------------------------------------------------------------------------- + +// POST /api/v1/nodes +registry.registerPath({ + method: "post", + path: "/api/v1/nodes", + operationId: "createNode", + summary: "Register node", + description: "Manually registers a new fleet node.", + tags: ["Nodes"], + security: [{ [bearerAuth.name]: [] }], + request: { + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + name: z.string(), + host: z.string(), + apiPort: z.number().optional(), + labels: z.record(z.string(), z.string()).optional(), + }), + }, + }, + }, + }, + responses: { + 201: { + description: "Node registered", + content: { "application/json": { schema: z.object({ node: NodeSchema }) } }, + }, + 400: { description: "Invalid input" }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + }, +}); + +// DELETE /api/v1/nodes/{id} +registry.registerPath({ + method: "delete", + path: "/api/v1/nodes/{id}", + operationId: "deleteNode", + summary: "Remove node", + description: "Removes a fleet node from the environment.", + tags: ["Nodes"], + security: [{ [bearerAuth.name]: [] }], + request: { + params: z.object({ id: z.string().openapi({ description: "Node ID" }) }), + }, + responses: { + 200: { + description: "Node removed", + content: { "application/json": { schema: z.object({ deleted: z.literal(true) }) } }, + }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Node not found" }, + }, +}); + +// PUT /api/v1/nodes/{id}/labels +registry.registerPath({ + method: "put", + path: "/api/v1/nodes/{id}/labels", + operationId: "updateNodeLabels", + summary: "Update node labels", + description: "Replaces all labels on a fleet node.", + tags: ["Nodes"], + security: [{ [bearerAuth.name]: [] }], + request: { + params: z.object({ id: z.string().openapi({ description: "Node ID" }) }), + body: { + required: true, + content: { + "application/json": { + schema: z.object({ labels: z.record(z.string(), z.string()) }), + }, + }, + }, + }, + responses: { + 200: { + description: "Labels updated", + content: { "application/json": { schema: z.object({ node: z.object({ id: z.string(), name: z.string(), labels: z.record(z.string(), z.string()) }) }) } }, + }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Node not found" }, + }, +}); + +// GET /api/v1/nodes/{id}/metrics +registry.registerPath({ + method: "get", + path: "/api/v1/nodes/{id}/metrics", + operationId: "getNodeMetrics", + summary: "Get node metrics", + description: "Returns time-series metrics for a fleet node.", + tags: ["Nodes"], + security: [{ [bearerAuth.name]: [] }], + request: { + params: z.object({ id: z.string().openapi({ description: "Node ID" }) }), + query: z.object({ + since: z.string().optional().openapi({ format: "date-time" }), + limit: z.string().optional().openapi({ example: "100" }), + }), + }, + responses: { + 200: { + description: "Node metrics", + content: { "application/json": { schema: z.object({ metrics: z.array(MetricSchema) }) } }, + }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Node not found" }, + }, +}); + +// GET /api/v1/pipelines/{id}/metrics +registry.registerPath({ + method: "get", + path: "/api/v1/pipelines/{id}/metrics", + operationId: "getPipelineMetrics", + summary: "Get pipeline metrics", + description: "Returns time-series metrics for a pipeline.", + tags: ["Pipelines"], + security: [{ [bearerAuth.name]: [] }], + request: { + params: z.object({ id: z.string().openapi({ description: "Pipeline ID" }) }), + query: z.object({ + since: z.string().optional().openapi({ format: "date-time" }), + limit: z.string().optional().openapi({ example: "100" }), + }), + }, + responses: { + 200: { + description: "Pipeline metrics", + content: { "application/json": { schema: z.object({ metrics: z.array(MetricSchema) }) } }, + }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Pipeline not found" }, + }, +}); + +// GET /api/v1/pipelines/{id}/logs +registry.registerPath({ + method: "get", + path: "/api/v1/pipelines/{id}/logs", + operationId: "getPipelineLogs", + summary: "Get pipeline logs", + description: "Returns cursor-paginated logs for a pipeline.", + tags: ["Pipelines"], + security: [{ [bearerAuth.name]: [] }], + request: { + params: z.object({ id: z.string().openapi({ description: "Pipeline ID" }) }), + query: z.object({ + after: z.string().optional().openapi({ description: "Cursor for pagination" }), + limit: z.string().optional().openapi({ example: "100" }), + level: z.string().optional().openapi({ example: "ERROR" }), + }), + }, + responses: { + 200: { + description: "Pipeline logs", + content: { + "application/json": { + schema: z.object({ + logs: z.array(PipelineLogSchema), + cursor: z.string().nullable(), + hasMore: z.boolean(), + }), + }, + }, + }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Pipeline not found" }, + }, +}); + +// GET /api/v1/pipelines/{id}/health +registry.registerPath({ + method: "get", + path: "/api/v1/pipelines/{id}/health", + operationId: "getPipelineHealth", + summary: "Get pipeline health", + description: "Returns the health status, SLIs, and latest metrics for a pipeline.", + tags: ["Pipelines"], + security: [{ [bearerAuth.name]: [] }], + request: { + params: z.object({ id: z.string().openapi({ description: "Pipeline ID" }) }), + }, + responses: { + 200: { + description: "Pipeline health", + content: { "application/json": { schema: z.object({ health: HealthSchema }) } }, + }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Pipeline not found" }, + }, +}); + +// GET /api/v1/fleet/overview +registry.registerPath({ + method: "get", + path: "/api/v1/fleet/overview", + operationId: "getFleetOverview", + summary: "Fleet overview", + description: "Returns a fleet-wide summary of nodes and pipelines.", + tags: ["Fleet"], + security: [{ [bearerAuth.name]: [] }], + responses: { + 200: { + description: "Fleet overview", + content: { "application/json": { schema: z.object({ fleet: FleetOverviewSchema }) } }, + }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + }, +}); + +// --------------------------------------------------------------------------- +// Register new paths — Tier 3: Advanced Operations +// --------------------------------------------------------------------------- + +// POST /api/v1/pipelines/{id}/promote +registry.registerPath({ + method: "post", + path: "/api/v1/pipelines/{id}/promote", + operationId: "promotePipeline", + summary: "Promote pipeline", + description: "Promotes a pipeline to a target environment.", + tags: ["Pipelines"], + security: [{ [bearerAuth.name]: [] }], + request: { + params: z.object({ id: z.string().openapi({ description: "Pipeline ID" }) }), + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + targetEnvironmentId: z.string(), + name: z.string().optional(), + }), + }, + }, + }, + }, + responses: { + 201: { + description: "Pipeline promoted", + content: { + "application/json": { + schema: z.object({ + promoted: z.object({ + pipelineId: z.string(), + name: z.string(), + targetEnvironmentName: z.string(), + }), + }), + }, + }, + }, + 400: { description: "Invalid input" }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + }, +}); + +// GET /api/v1/deploy-requests +registry.registerPath({ + method: "get", + path: "/api/v1/deploy-requests", + operationId: "listDeployRequests", + summary: "List deploy requests", + description: "Lists deploy requests in the environment, optionally filtered by status or pipeline.", + tags: ["DeployRequests"], + security: [{ [bearerAuth.name]: [] }], + request: { + query: z.object({ + status: z.string().optional(), + pipelineId: z.string().optional(), + }), + }, + responses: { + 200: { + description: "Deploy requests list", + content: { "application/json": { schema: z.object({ requests: z.array(DeployRequestSchema) }) } }, + }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + }, +}); + +// POST /api/v1/deploy-requests/{id}/approve +registry.registerPath({ + method: "post", + path: "/api/v1/deploy-requests/{id}/approve", + operationId: "approveDeployRequest", + summary: "Approve deploy request", + description: "Approves a pending deploy request.", + tags: ["DeployRequests"], + security: [{ [bearerAuth.name]: [] }], + request: { + params: z.object({ id: z.string().openapi({ description: "Deploy Request ID" }) }), + }, + responses: { + 200: { + description: "Request approved", + content: { "application/json": { schema: z.object({ success: z.literal(true), status: z.literal("APPROVED") }) } }, + }, + 400: { description: "Request not pending" }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Request not found" }, + 409: { description: "Race condition — request already claimed" }, + }, +}); + +// POST /api/v1/deploy-requests/{id}/reject +registry.registerPath({ + method: "post", + path: "/api/v1/deploy-requests/{id}/reject", + operationId: "rejectDeployRequest", + summary: "Reject deploy request", + description: "Rejects a pending deploy request with an optional note.", + tags: ["DeployRequests"], + security: [{ [bearerAuth.name]: [] }], + request: { + params: z.object({ id: z.string().openapi({ description: "Deploy Request ID" }) }), + body: { + required: false, + content: { + "application/json": { + schema: z.object({ note: z.string().optional() }), + }, + }, + }, + }, + responses: { + 200: { + description: "Request rejected", + content: { "application/json": { schema: z.object({ success: z.literal(true), status: z.literal("REJECTED") }) } }, + }, + 400: { description: "Request not pending" }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Request not found" }, + 409: { description: "Race condition — request already claimed" }, + }, +}); + +// GET /api/v1/node-groups +registry.registerPath({ + method: "get", + path: "/api/v1/node-groups", + operationId: "listNodeGroups", + summary: "List node groups", + description: "Lists all node groups in the environment.", + tags: ["NodeGroups"], + security: [{ [bearerAuth.name]: [] }], + responses: { + 200: { + description: "Node groups list", + content: { "application/json": { schema: z.object({ groups: z.array(NodeGroupSchema) }) } }, + }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + }, +}); + +// POST /api/v1/node-groups +registry.registerPath({ + method: "post", + path: "/api/v1/node-groups", + operationId: "createNodeGroup", + summary: "Create node group", + description: "Creates a new node group in the environment.", + tags: ["NodeGroups"], + security: [{ [bearerAuth.name]: [] }], + request: { + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + name: z.string(), + criteria: z.record(z.string(), z.unknown()).optional(), + labelTemplate: z.record(z.string(), z.string()).optional(), + requiredLabels: z.array(z.string()).optional(), + }), + }, + }, + }, + }, + responses: { + 201: { + description: "Node group created", + content: { "application/json": { schema: z.object({ group: NodeGroupSchema }) } }, + }, + 400: { description: "Invalid input" }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + }, +}); + +// GET /api/v1/environments +registry.registerPath({ + method: "get", + path: "/api/v1/environments", + operationId: "listEnvironments", + summary: "List environments", + description: "Lists all environments in the same team as the service account's environment.", + tags: ["Environments"], + security: [{ [bearerAuth.name]: [] }], + responses: { + 200: { + description: "Environments list", + content: { "application/json": { schema: z.object({ environments: z.array(EnvironmentSchema) }) } }, + }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + }, +}); + // --------------------------------------------------------------------------- // tRPC — shared helpers // --------------------------------------------------------------------------- @@ -1509,7 +2366,7 @@ export function generateOpenAPISpec() { openapi: "3.1.0", info: { title: "VectorFlow REST API", - version: "1.0.0", + version: "2.0.0", description: "REST API for managing Vector data pipelines, fleet nodes, secrets, and alerts in VectorFlow.", }, diff --git a/src/app/api/v1/_lib/rate-limiter.ts b/src/app/api/v1/_lib/rate-limiter.ts new file mode 100644 index 00000000..0a9e2793 --- /dev/null +++ b/src/app/api/v1/_lib/rate-limiter.ts @@ -0,0 +1,80 @@ +export type RateLimitTier = "read" | "default" | "deploy"; + +const TIER_LIMITS: Record = { + read: 200, + default: 100, + deploy: 20, +}; + +const WINDOW_MS = 60_000; // 1 minute + +interface SlidingWindow { + timestamps: number[]; +} + +export interface RateLimitResult { + allowed: boolean; + remaining: number; + retryAfter: number; +} + +export class RateLimiter { + /** key = `${serviceAccountId}:${tier}` */ + private windows = new Map(); + + check( + serviceAccountId: string, + tier: RateLimitTier, + customLimit?: number | null, + ): RateLimitResult { + const limit = customLimit ?? TIER_LIMITS[tier]; + const key = `${serviceAccountId}:${tier}`; + const now = Date.now(); + const cutoff = now - WINDOW_MS; + + let window = this.windows.get(key); + if (!window) { + window = { timestamps: [] }; + this.windows.set(key, window); + } + + // Remove expired entries + window.timestamps = window.timestamps.filter((t) => t > cutoff); + + if (window.timestamps.length >= limit) { + const oldestInWindow = window.timestamps[0]; + const retryAfter = Math.ceil((oldestInWindow + WINDOW_MS - now) / 1000); + return { + allowed: false, + remaining: 0, + retryAfter: Math.max(retryAfter, 1), + }; + } + + window.timestamps.push(now); + return { + allowed: true, + remaining: limit - window.timestamps.length, + retryAfter: 0, + }; + } + + /** Periodic cleanup of stale windows (call from a setInterval). */ + cleanup(): void { + const cutoff = Date.now() - WINDOW_MS; + for (const [key, window] of this.windows) { + window.timestamps = window.timestamps.filter((t) => t > cutoff); + if (window.timestamps.length === 0) { + this.windows.delete(key); + } + } + } +} + +/** Singleton in-memory rate limiter. */ +export const rateLimiter = new RateLimiter(); + +// Periodically clean up stale sliding windows to prevent memory leaks +if (typeof setInterval !== "undefined") { + setInterval(() => rateLimiter.cleanup(), 120_000); +} diff --git a/src/app/api/v1/deploy-requests/[id]/approve/route.ts b/src/app/api/v1/deploy-requests/[id]/approve/route.ts new file mode 100644 index 00000000..c225259a --- /dev/null +++ b/src/app/api/v1/deploy-requests/[id]/approve/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { writeAuditLog } from "@/server/services/audit"; +import { apiRoute } from "../../../_lib/api-handler"; + +export const POST = apiRoute( + "deploy-requests.manage", + async (req: NextRequest, ctx, params) => { + const requestId = params?.id; + if (!requestId) { + return NextResponse.json({ error: "Missing request id" }, { status: 400 }); + } + + const request = await prisma.deployRequest.findUnique({ + where: { id: requestId }, + }); + + if (!request || request.environmentId !== ctx.environmentId) { + return NextResponse.json( + { error: "Deploy request not found" }, + { status: 404 }, + ); + } + + if (request.status !== "PENDING") { + return NextResponse.json( + { error: "Deploy request is not in PENDING state" }, + { status: 400 }, + ); + } + + // Atomically claim the request + const updated = await prisma.deployRequest.updateMany({ + where: { id: requestId, status: "PENDING" }, + data: { + status: "APPROVED", + reviewedById: null, + reviewedAt: new Date(), + }, + }); + + if (updated.count === 0) { + return NextResponse.json( + { error: "Request is no longer pending" }, + { status: 409 }, + ); + } + + writeAuditLog({ + action: "api.deploy_request_approved", + entityType: "DeployRequest", + entityId: requestId, + userId: null, + userEmail: null, + userName: ctx.serviceAccountName ?? "service-account", + teamId: null, + environmentId: ctx.environmentId, + ipAddress: req.headers.get("x-forwarded-for")?.split(",")[0] ?? null, + metadata: { pipelineId: request.pipelineId }, + }).catch(() => {}); + + return NextResponse.json({ success: true, status: "APPROVED" }); + }, + "deploy", +); diff --git a/src/app/api/v1/deploy-requests/[id]/reject/route.ts b/src/app/api/v1/deploy-requests/[id]/reject/route.ts new file mode 100644 index 00000000..0287f1dd --- /dev/null +++ b/src/app/api/v1/deploy-requests/[id]/reject/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { writeAuditLog } from "@/server/services/audit"; +import { apiRoute } from "../../../_lib/api-handler"; + +export const POST = apiRoute( + "deploy-requests.manage", + async (req: NextRequest, ctx, params) => { + const requestId = params?.id; + if (!requestId) { + return NextResponse.json({ error: "Missing request id" }, { status: 400 }); + } + + let body: { note?: string } = {}; + try { + body = await req.json(); + } catch { + // No body is OK for rejection + } + + const request = await prisma.deployRequest.findUnique({ + where: { id: requestId }, + }); + + if (!request || request.environmentId !== ctx.environmentId) { + return NextResponse.json( + { error: "Deploy request not found" }, + { status: 404 }, + ); + } + + if (request.status !== "PENDING") { + return NextResponse.json( + { error: "Deploy request is not in PENDING state" }, + { status: 400 }, + ); + } + + const updated = await prisma.deployRequest.updateMany({ + where: { id: requestId, status: "PENDING" }, + data: { + status: "REJECTED", + reviewedById: null, + reviewNote: body.note ?? null, + reviewedAt: new Date(), + }, + }); + + if (updated.count === 0) { + return NextResponse.json( + { error: "Request is no longer pending" }, + { status: 409 }, + ); + } + + writeAuditLog({ + action: "api.deploy_request_rejected", + entityType: "DeployRequest", + entityId: requestId, + userId: null, + userEmail: null, + userName: ctx.serviceAccountName ?? "service-account", + teamId: null, + environmentId: ctx.environmentId, + ipAddress: req.headers.get("x-forwarded-for")?.split(",")[0] ?? null, + metadata: { pipelineId: request.pipelineId, note: body.note }, + }).catch(() => {}); + + return NextResponse.json({ success: true, status: "REJECTED" }); + }, + "deploy", +); diff --git a/src/app/api/v1/deploy-requests/route.ts b/src/app/api/v1/deploy-requests/route.ts new file mode 100644 index 00000000..633e50b5 --- /dev/null +++ b/src/app/api/v1/deploy-requests/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiRoute, jsonResponse } from "../_lib/api-handler"; + +export const GET = apiRoute( + "deploy-requests.manage", + async (req: NextRequest, ctx) => { + const status = req.nextUrl.searchParams.get("status"); + const pipelineId = req.nextUrl.searchParams.get("pipelineId"); + + const where: Record = { + environmentId: ctx.environmentId, + }; + + if (status) { + const validStatuses = ["PENDING", "APPROVED", "REJECTED", "CANCELLED", "DEPLOYED"]; + if (validStatuses.includes(status.toUpperCase())) { + where.status = status.toUpperCase(); + } + } + + if (pipelineId) { + where.pipelineId = pipelineId; + } + + const requests = await prisma.deployRequest.findMany({ + where, + select: { + id: true, + pipelineId: true, + environmentId: true, + status: true, + changelog: true, + createdAt: true, + reviewedAt: true, + reviewNote: true, + deployedAt: true, + pipeline: { select: { id: true, name: true } }, + }, + orderBy: { createdAt: "desc" }, + }); + + return jsonResponse({ requests }); + }, + "read", +); diff --git a/src/app/api/v1/environments/route.ts b/src/app/api/v1/environments/route.ts new file mode 100644 index 00000000..58c5e029 --- /dev/null +++ b/src/app/api/v1/environments/route.ts @@ -0,0 +1,34 @@ +import { prisma } from "@/lib/prisma"; +import { apiRoute, jsonResponse } from "../_lib/api-handler"; + +export const GET = apiRoute( + "environments.read", + async (_req, ctx) => { + // Resolve the team from the service account's environment + const env = await prisma.environment.findUnique({ + where: { id: ctx.environmentId }, + select: { teamId: true }, + }); + + if (!env?.teamId) { + return jsonResponse({ environments: [] }); + } + + // Return all environments in the same team + const environments = await prisma.environment.findMany({ + where: { teamId: env.teamId }, + select: { + id: true, + name: true, + isSystem: true, + requireDeployApproval: true, + gitOpsMode: true, + createdAt: true, + }, + orderBy: { name: "asc" }, + }); + + return jsonResponse({ environments }); + }, + "read", +); diff --git a/src/app/api/v1/fleet/overview/route.ts b/src/app/api/v1/fleet/overview/route.ts new file mode 100644 index 00000000..8c6003ac --- /dev/null +++ b/src/app/api/v1/fleet/overview/route.ts @@ -0,0 +1,36 @@ +import { prisma } from "@/lib/prisma"; +import { apiRoute, jsonResponse } from "../../_lib/api-handler"; + +export const GET = apiRoute( + "nodes.read", + async (_req, ctx) => { + const nodes = await prisma.vectorNode.findMany({ + where: { environmentId: ctx.environmentId }, + select: { id: true, status: true, maintenanceMode: true }, + }); + + const pipelines = await prisma.pipeline.findMany({ + where: { environmentId: ctx.environmentId }, + select: { id: true, isDraft: true }, + }); + + const statusCounts: Record = {}; + let maintenanceCount = 0; + for (const node of nodes) { + statusCounts[node.status] = (statusCounts[node.status] ?? 0) + 1; + if (node.maintenanceMode) maintenanceCount++; + } + + return jsonResponse({ + fleet: { + totalNodes: nodes.length, + nodesByStatus: statusCounts, + nodesInMaintenance: maintenanceCount, + totalPipelines: pipelines.length, + deployedPipelines: pipelines.filter((p) => !p.isDraft).length, + draftPipelines: pipelines.filter((p) => p.isDraft).length, + }, + }); + }, + "read", +); diff --git a/src/app/api/v1/node-groups/route.ts b/src/app/api/v1/node-groups/route.ts new file mode 100644 index 00000000..45581a8e --- /dev/null +++ b/src/app/api/v1/node-groups/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import type { Prisma } from "@/generated/prisma"; +import { apiRoute, jsonResponse } from "../_lib/api-handler"; + +export const GET = apiRoute( + "node-groups.read", + async (_req, ctx) => { + const groups = await prisma.nodeGroup.findMany({ + where: { environmentId: ctx.environmentId }, + orderBy: { createdAt: "desc" }, + }); + + return jsonResponse({ groups }); + }, + "read", +); + +export const POST = apiRoute( + "node-groups.manage", + async (req: NextRequest, ctx) => { + let body: { + name?: string; + criteria?: Record; + labelTemplate?: Record; + requiredLabels?: string[]; + }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + if (!body.name || typeof body.name !== "string" || body.name.trim().length === 0) { + return NextResponse.json( + { error: "name is required" }, + { status: 400 }, + ); + } + + const group = await prisma.nodeGroup.create({ + data: { + name: body.name.trim(), + environmentId: ctx.environmentId, + criteria: (body.criteria ?? {}) as Prisma.InputJsonValue, + labelTemplate: body.labelTemplate ?? {}, + requiredLabels: body.requiredLabels ?? [], + }, + }); + + return jsonResponse({ group }, { status: 201 }); + }, +); diff --git a/src/app/api/v1/nodes/[id]/labels/__tests__/update-labels.test.ts b/src/app/api/v1/nodes/[id]/labels/__tests__/update-labels.test.ts new file mode 100644 index 00000000..f5a24df0 --- /dev/null +++ b/src/app/api/v1/nodes/[id]/labels/__tests__/update-labels.test.ts @@ -0,0 +1,64 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; +import type { PrismaClient } from "@/generated/prisma"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/prisma", () => ({ prisma: mockDeep() })); +vi.mock("@/server/middleware/api-auth", () => ({ + authenticateApiKey: vi.fn(), + hasPermission: vi.fn(), +})); +vi.mock("../../../../_lib/rate-limiter", () => ({ + rateLimiter: { check: vi.fn().mockReturnValue({ allowed: true, remaining: 99, retryAfter: 0 }) }, +})); + +import { prisma } from "@/lib/prisma"; +import { authenticateApiKey, hasPermission } from "@/server/middleware/api-auth"; +import { PUT } from "../route"; + +const prismaMock = prisma as unknown as DeepMockProxy; +const authMock = authenticateApiKey as ReturnType; +const permMock = hasPermission as ReturnType; + +const CTX = { + serviceAccountId: "sa-1", + serviceAccountName: "ci-bot", + environmentId: "env-1", + permissions: ["nodes.manage"], + rateLimit: null, +}; + +describe("PUT /api/v1/nodes/{id}/labels", () => { + beforeEach(() => { + mockReset(prismaMock); + authMock.mockResolvedValue(CTX); + permMock.mockReturnValue(true); + }); + + it("updates labels and returns updated node", async () => { + prismaMock.vectorNode.findUnique.mockResolvedValue({ + id: "vn-1", + environmentId: "env-1", + } as never); + prismaMock.vectorNode.update.mockResolvedValue({ + id: "vn-1", + name: "node-1", + labels: { env: "production", region: "us-east" }, + } as never); + + const req = new NextRequest("http://localhost/api/v1/nodes/vn-1/labels", { + method: "PUT", + headers: { + authorization: "Bearer vf_test123", + "content-type": "application/json", + }, + body: JSON.stringify({ labels: { env: "production", region: "us-east" } }), + }); + + const res = await PUT(req, { params: Promise.resolve({ id: "vn-1" }) }); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.node.labels.env).toBe("production"); + }); +}); diff --git a/src/app/api/v1/nodes/[id]/labels/route.ts b/src/app/api/v1/nodes/[id]/labels/route.ts new file mode 100644 index 00000000..a5b28140 --- /dev/null +++ b/src/app/api/v1/nodes/[id]/labels/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { writeAuditLog } from "@/server/services/audit"; +import { apiRoute, jsonResponse } from "../../../_lib/api-handler"; + +export const PUT = apiRoute( + "nodes.manage", + async (req: NextRequest, ctx, params) => { + const id = params?.id; + if (!id) { + return NextResponse.json({ error: "Missing node id" }, { status: 400 }); + } + + const node = await prisma.vectorNode.findUnique({ + where: { id, environmentId: ctx.environmentId }, + select: { id: true, name: true, labels: true }, + }); + if (!node) { + return NextResponse.json({ error: "Node not found" }, { status: 404 }); + } + + let body: { labels?: Record }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + if (!body.labels || typeof body.labels !== "object") { + return NextResponse.json( + { error: "labels object is required" }, + { status: 400 }, + ); + } + + const updated = await prisma.vectorNode.update({ + where: { id }, + data: { labels: body.labels }, + select: { id: true, name: true, labels: true }, + }); + + writeAuditLog({ + action: "api.node_labels_updated", + entityType: "VectorNode", + entityId: id, + userId: null, + userEmail: null, + userName: ctx.serviceAccountName ?? "service-account", + teamId: null, + environmentId: ctx.environmentId, + ipAddress: req.headers.get("x-forwarded-for")?.split(",")[0] ?? null, + metadata: { name: node.name, oldLabels: node.labels, newLabels: body.labels }, + }).catch(() => {}); + + return jsonResponse({ node: updated }); + }, +); diff --git a/src/app/api/v1/nodes/[id]/metrics/route.ts b/src/app/api/v1/nodes/[id]/metrics/route.ts new file mode 100644 index 00000000..dad23081 --- /dev/null +++ b/src/app/api/v1/nodes/[id]/metrics/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiRoute, jsonResponse } from "../../../_lib/api-handler"; + +export const GET = apiRoute( + "metrics.read", + async (req: NextRequest, ctx, params) => { + const id = params?.id; + if (!id) { + return NextResponse.json({ error: "Missing node id" }, { status: 400 }); + } + + const node = await prisma.vectorNode.findUnique({ + where: { id, environmentId: ctx.environmentId }, + select: { id: true }, + }); + if (!node) { + return NextResponse.json({ error: "Node not found" }, { status: 404 }); + } + + const since = req.nextUrl.searchParams.get("since"); + const limitParam = req.nextUrl.searchParams.get("limit"); + const limit = Math.min(Math.max(parseInt(limitParam ?? "100", 10) || 100, 1), 1000); + + const where: Record = { nodeId: id }; + if (since) { + const sinceDate = new Date(since); + if (isNaN(sinceDate.getTime())) { + return NextResponse.json({ error: "Invalid since parameter" }, { status: 400 }); + } + where.timestamp = { gte: sinceDate }; + } + + const metrics = await prisma.nodeMetric.findMany({ + where, + orderBy: { timestamp: "desc" }, + take: limit, + }); + + return jsonResponse({ metrics }); + }, + "read", +); diff --git a/src/app/api/v1/nodes/[id]/route.ts b/src/app/api/v1/nodes/[id]/route.ts index 4861972e..6018b3ce 100644 --- a/src/app/api/v1/nodes/[id]/route.ts +++ b/src/app/api/v1/nodes/[id]/route.ts @@ -1,5 +1,6 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; +import { writeAuditLog } from "@/server/services/audit"; import { apiRoute, jsonResponse } from "../../_lib/api-handler"; export const GET = apiRoute("nodes.read", async (_req, ctx, params) => { @@ -43,3 +44,39 @@ export const GET = apiRoute("nodes.read", async (_req, ctx, params) => { return jsonResponse({ node }); }); + +export const DELETE = apiRoute( + "nodes.manage", + async (req: NextRequest, ctx, params) => { + const id = params?.id; + if (!id) { + return NextResponse.json({ error: "Missing node id" }, { status: 400 }); + } + + const node = await prisma.vectorNode.findUnique({ + where: { id, environmentId: ctx.environmentId }, + select: { id: true, name: true }, + }); + + if (!node) { + return NextResponse.json({ error: "Node not found" }, { status: 404 }); + } + + await prisma.vectorNode.delete({ where: { id } }); + + writeAuditLog({ + action: "api.node_deleted", + entityType: "VectorNode", + entityId: id, + userId: null, + userEmail: null, + userName: ctx.serviceAccountName ?? "service-account", + teamId: null, + environmentId: ctx.environmentId, + ipAddress: req.headers.get("x-forwarded-for")?.split(",")[0] ?? null, + metadata: { name: node.name }, + }).catch(() => {}); + + return NextResponse.json({ deleted: true }); + }, +); diff --git a/src/app/api/v1/nodes/__tests__/create-node.test.ts b/src/app/api/v1/nodes/__tests__/create-node.test.ts new file mode 100644 index 00000000..6857f598 --- /dev/null +++ b/src/app/api/v1/nodes/__tests__/create-node.test.ts @@ -0,0 +1,65 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; +import type { PrismaClient } from "@/generated/prisma"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/prisma", () => ({ prisma: mockDeep() })); +vi.mock("@/server/middleware/api-auth", () => ({ + authenticateApiKey: vi.fn(), + hasPermission: vi.fn(), +})); +vi.mock("@/server/services/audit", () => ({ writeAuditLog: vi.fn().mockResolvedValue({}) })); +vi.mock("../../../_lib/rate-limiter", () => ({ + rateLimiter: { check: vi.fn().mockReturnValue({ allowed: true, remaining: 99, retryAfter: 0 }) }, +})); + +import { prisma } from "@/lib/prisma"; +import { authenticateApiKey, hasPermission } from "@/server/middleware/api-auth"; +import { POST } from "../route"; + +const prismaMock = prisma as unknown as DeepMockProxy; +const authMock = authenticateApiKey as ReturnType; +const permMock = hasPermission as ReturnType; + +const CTX = { + serviceAccountId: "sa-1", + serviceAccountName: "ci-bot", + environmentId: "env-1", + permissions: ["nodes.manage"], + rateLimit: null, +}; + +describe("POST /api/v1/nodes", () => { + beforeEach(() => { + mockReset(prismaMock); + authMock.mockResolvedValue(CTX); + permMock.mockReturnValue(true); + }); + + it("registers a new node and returns 201", async () => { + prismaMock.vectorNode.create.mockResolvedValue({ + id: "vn-1", + name: "node-prod-01", + host: "10.0.1.50", + apiPort: 8686, + environmentId: "env-1", + status: "UNKNOWN", + createdAt: new Date(), + } as never); + + const req = new NextRequest("http://localhost/api/v1/nodes", { + method: "POST", + headers: { + authorization: "Bearer vf_test123", + "content-type": "application/json", + }, + body: JSON.stringify({ name: "node-prod-01", host: "10.0.1.50" }), + }); + + const res = await POST(req, { params: Promise.resolve({}) }); + expect(res.status).toBe(201); + + const body = await res.json(); + expect(body.node.name).toBe("node-prod-01"); + }); +}); diff --git a/src/app/api/v1/nodes/route.ts b/src/app/api/v1/nodes/route.ts index 47c51a19..b59ea8ba 100644 --- a/src/app/api/v1/nodes/route.ts +++ b/src/app/api/v1/nodes/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; +import { writeAuditLog } from "@/server/services/audit"; import { apiRoute } from "../_lib/api-handler"; export const GET = apiRoute("nodes.read", async (req: NextRequest, ctx) => { @@ -50,3 +51,56 @@ export const GET = apiRoute("nodes.read", async (req: NextRequest, ctx) => { return NextResponse.json({ nodes: filtered }); }); + +export const POST = apiRoute( + "nodes.manage", + async (req: NextRequest, ctx) => { + let body: { name?: string; host?: string; apiPort?: number; labels?: Record }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + if (!body.name || !body.host) { + return NextResponse.json( + { error: "name and host are required" }, + { status: 400 }, + ); + } + + const node = await prisma.vectorNode.create({ + data: { + name: body.name, + host: body.host, + apiPort: body.apiPort ?? 8686, + environmentId: ctx.environmentId, + labels: body.labels ?? {}, + }, + select: { + id: true, + name: true, + host: true, + apiPort: true, + environmentId: true, + status: true, + createdAt: true, + }, + }); + + writeAuditLog({ + action: "api.node_created", + entityType: "VectorNode", + entityId: node.id, + userId: null, + userEmail: null, + userName: ctx.serviceAccountName ?? "service-account", + teamId: null, + environmentId: ctx.environmentId, + ipAddress: req.headers.get("x-forwarded-for")?.split(",")[0] ?? null, + metadata: { name: node.name, host: node.host }, + }).catch(() => {}); + + return NextResponse.json({ node }, { status: 201 }); + }, +); diff --git a/src/app/api/v1/pipelines/[id]/__tests__/delete-pipeline.test.ts b/src/app/api/v1/pipelines/[id]/__tests__/delete-pipeline.test.ts new file mode 100644 index 00000000..0ed71d10 --- /dev/null +++ b/src/app/api/v1/pipelines/[id]/__tests__/delete-pipeline.test.ts @@ -0,0 +1,96 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; +import type { PrismaClient } from "@/generated/prisma"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/prisma", () => ({ + prisma: mockDeep(), +})); + +vi.mock("@/server/middleware/api-auth", () => ({ + authenticateApiKey: vi.fn(), + hasPermission: vi.fn(), +})); + +vi.mock("@/server/services/audit", () => ({ + writeAuditLog: vi.fn().mockResolvedValue({}), +})); + +vi.mock("../../../../_lib/rate-limiter", () => ({ + rateLimiter: { check: vi.fn().mockReturnValue({ allowed: true, remaining: 99, retryAfter: 0 }) }, +})); + +import { prisma } from "@/lib/prisma"; +import { authenticateApiKey, hasPermission } from "@/server/middleware/api-auth"; +import { DELETE } from "../route"; + +const prismaMock = prisma as unknown as DeepMockProxy; +const authMock = authenticateApiKey as ReturnType; +const permMock = hasPermission as ReturnType; + +const CTX = { + serviceAccountId: "sa-1", + serviceAccountName: "ci-bot", + environmentId: "env-1", + permissions: ["pipelines.write"], + rateLimit: null, +}; + +describe("DELETE /api/v1/pipelines/{id}", () => { + beforeEach(() => { + mockReset(prismaMock); + authMock.mockResolvedValue(CTX); + permMock.mockReturnValue(true); + }); + + it("deletes a pipeline and returns 200", async () => { + prismaMock.pipeline.findUnique.mockResolvedValue({ + id: "pipe-1", + name: "test-pipe", + environmentId: "env-1", + isDraft: true, + } as never); + prismaMock.pipeline.delete.mockResolvedValue({} as never); + + const req = new NextRequest("http://localhost/api/v1/pipelines/pipe-1", { + method: "DELETE", + headers: { authorization: "Bearer vf_test123" }, + }); + + const res = await DELETE(req, { params: Promise.resolve({ id: "pipe-1" }) }); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.deleted).toBe(true); + }); + + it("returns 409 if pipeline is deployed", async () => { + prismaMock.pipeline.findUnique.mockResolvedValue({ + id: "pipe-1", + name: "test-pipe", + environmentId: "env-1", + isDraft: false, + deployedAt: new Date(), + } as never); + + const req = new NextRequest("http://localhost/api/v1/pipelines/pipe-1", { + method: "DELETE", + headers: { authorization: "Bearer vf_test123" }, + }); + + const res = await DELETE(req, { params: Promise.resolve({ id: "pipe-1" }) }); + expect(res.status).toBe(409); + }); + + it("returns 404 for non-existent pipeline", async () => { + prismaMock.pipeline.findUnique.mockResolvedValue(null); + + const req = new NextRequest("http://localhost/api/v1/pipelines/bad-id", { + method: "DELETE", + headers: { authorization: "Bearer vf_test123" }, + }); + + const res = await DELETE(req, { params: Promise.resolve({ id: "bad-id" }) }); + expect(res.status).toBe(404); + }); +}); diff --git a/src/app/api/v1/pipelines/[id]/__tests__/update-pipeline.test.ts b/src/app/api/v1/pipelines/[id]/__tests__/update-pipeline.test.ts new file mode 100644 index 00000000..b07099cf --- /dev/null +++ b/src/app/api/v1/pipelines/[id]/__tests__/update-pipeline.test.ts @@ -0,0 +1,92 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; +import type { PrismaClient } from "@/generated/prisma"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/prisma", () => ({ + prisma: mockDeep(), +})); + +vi.mock("@/server/middleware/api-auth", () => ({ + authenticateApiKey: vi.fn(), + hasPermission: vi.fn(), +})); + +vi.mock("@/server/services/audit", () => ({ + writeAuditLog: vi.fn().mockResolvedValue({}), +})); + +vi.mock("../../../../_lib/rate-limiter", () => ({ + rateLimiter: { check: vi.fn().mockReturnValue({ allowed: true, remaining: 99, retryAfter: 0 }) }, +})); + +import { prisma } from "@/lib/prisma"; +import { authenticateApiKey, hasPermission } from "@/server/middleware/api-auth"; +import { PUT } from "../route"; + +const prismaMock = prisma as unknown as DeepMockProxy; +const authMock = authenticateApiKey as ReturnType; +const permMock = hasPermission as ReturnType; + +const CTX = { + serviceAccountId: "sa-1", + serviceAccountName: "ci-bot", + environmentId: "env-1", + permissions: ["pipelines.write"], + rateLimit: null, +}; + +describe("PUT /api/v1/pipelines/{id}", () => { + beforeEach(() => { + mockReset(prismaMock); + authMock.mockResolvedValue(CTX); + permMock.mockReturnValue(true); + }); + + it("updates pipeline name and description", async () => { + prismaMock.pipeline.findUnique.mockResolvedValue({ + id: "pipe-1", + environmentId: "env-1", + } as never); + prismaMock.pipeline.update.mockResolvedValue({ + id: "pipe-1", + name: "updated-name", + description: "new desc", + isDraft: true, + deployedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + } as never); + + const req = new NextRequest("http://localhost/api/v1/pipelines/pipe-1", { + method: "PUT", + headers: { + authorization: "Bearer vf_test123", + "content-type": "application/json", + }, + body: JSON.stringify({ name: "updated-name", description: "new desc" }), + }); + + const res = await PUT(req, { params: Promise.resolve({ id: "pipe-1" }) }); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.pipeline.name).toBe("updated-name"); + }); + + it("returns 404 for non-existent pipeline", async () => { + prismaMock.pipeline.findUnique.mockResolvedValue(null); + + const req = new NextRequest("http://localhost/api/v1/pipelines/bad-id", { + method: "PUT", + headers: { + authorization: "Bearer vf_test123", + "content-type": "application/json", + }, + body: JSON.stringify({ name: "test" }), + }); + + const res = await PUT(req, { params: Promise.resolve({ id: "bad-id" }) }); + expect(res.status).toBe(404); + }); +}); diff --git a/src/app/api/v1/pipelines/[id]/config/__tests__/get-config.test.ts b/src/app/api/v1/pipelines/[id]/config/__tests__/get-config.test.ts new file mode 100644 index 00000000..702a2c1b --- /dev/null +++ b/src/app/api/v1/pipelines/[id]/config/__tests__/get-config.test.ts @@ -0,0 +1,100 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; +import type { PrismaClient } from "@/generated/prisma"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/prisma", () => ({ + prisma: mockDeep(), +})); + +vi.mock("@/server/middleware/api-auth", () => ({ + authenticateApiKey: vi.fn(), + hasPermission: vi.fn(), +})); + +vi.mock("@/lib/config-generator", () => ({ + generateVectorYaml: vi.fn(), +})); + +vi.mock("@/server/services/config-crypto", () => ({ + decryptNodeConfig: vi.fn((_type: string, config: Record) => config), +})); + +vi.mock("../../../../_lib/rate-limiter", () => ({ + rateLimiter: { check: vi.fn().mockReturnValue({ allowed: true, remaining: 99, retryAfter: 0 }) }, +})); + +import { prisma } from "@/lib/prisma"; +import { authenticateApiKey, hasPermission } from "@/server/middleware/api-auth"; +import { generateVectorYaml } from "@/lib/config-generator"; +import { GET } from "../route"; + +const prismaMock = prisma as unknown as DeepMockProxy; +const authMock = authenticateApiKey as ReturnType; +const permMock = hasPermission as ReturnType; +const generateYamlMock = generateVectorYaml as ReturnType; + +const CTX = { + serviceAccountId: "sa-1", + serviceAccountName: "ci-bot", + environmentId: "env-1", + permissions: ["pipelines.read"], + rateLimit: null, +}; + +describe("GET /api/v1/pipelines/{id}/config", () => { + beforeEach(() => { + mockReset(prismaMock); + authMock.mockResolvedValue(CTX); + permMock.mockReturnValue(true); + }); + + it("returns generated YAML config", async () => { + prismaMock.pipeline.findUnique.mockResolvedValue({ + id: "pipe-1", + environmentId: "env-1", + globalConfig: null, + enrichMetadata: false, + environment: { name: "prod" }, + nodes: [ + { + id: "n1", + kind: "SOURCE", + componentKey: "vector.sources.file", + componentType: "file", + config: {}, + positionX: 0, + positionY: 0, + disabled: false, + }, + ], + edges: [], + } as never); + + generateYamlMock.mockReturnValue("sources:\n file:\n type: file\n"); + + const req = new NextRequest("http://localhost/api/v1/pipelines/pipe-1/config", { + method: "GET", + headers: { authorization: "Bearer vf_test123" }, + }); + + const res = await GET(req, { params: Promise.resolve({ id: "pipe-1" }) }); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.config).toContain("sources:"); + expect(body.format).toBe("yaml"); + }); + + it("returns 404 for non-existent pipeline", async () => { + prismaMock.pipeline.findUnique.mockResolvedValue(null); + + const req = new NextRequest("http://localhost/api/v1/pipelines/bad-id/config", { + method: "GET", + headers: { authorization: "Bearer vf_test123" }, + }); + + const res = await GET(req, { params: Promise.resolve({ id: "bad-id" }) }); + expect(res.status).toBe(404); + }); +}); diff --git a/src/app/api/v1/pipelines/[id]/config/route.ts b/src/app/api/v1/pipelines/[id]/config/route.ts new file mode 100644 index 00000000..d95e3b1b --- /dev/null +++ b/src/app/api/v1/pipelines/[id]/config/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { generateVectorYaml } from "@/lib/config-generator"; +import { decryptNodeConfig } from "@/server/services/config-crypto"; +import { apiRoute } from "../../../_lib/api-handler"; + +export const GET = apiRoute( + "pipelines.read", + async (_req, ctx, params) => { + const id = params?.id; + if (!id) { + return NextResponse.json({ error: "Missing pipeline id" }, { status: 400 }); + } + + const pipeline = await prisma.pipeline.findUnique({ + where: { id, environmentId: ctx.environmentId }, + include: { + nodes: true, + edges: true, + environment: { select: { name: true } }, + }, + }); + + if (!pipeline) { + return NextResponse.json({ error: "Pipeline not found" }, { status: 404 }); + } + + const flowNodes = pipeline.nodes.map((n) => ({ + id: n.id, + type: n.kind.toLowerCase(), + position: { x: n.positionX, y: n.positionY }, + data: { + componentDef: { type: n.componentType, kind: n.kind.toLowerCase() }, + componentKey: n.componentKey, + config: decryptNodeConfig( + n.componentType, + (n.config as Record) ?? {}, + ), + disabled: n.disabled, + }, + })); + + const flowEdges = pipeline.edges.map((e) => ({ + id: e.id, + source: e.sourceNodeId, + target: e.targetNodeId, + ...(e.sourcePort ? { sourceHandle: e.sourcePort } : {}), + })); + + const yaml = generateVectorYaml( + flowNodes as Parameters[0], + flowEdges as Parameters[1], + pipeline.globalConfig as Record | null, + ); + + return NextResponse.json({ config: yaml, format: "yaml" }); + }, + "read", +); diff --git a/src/app/api/v1/pipelines/[id]/edges/[edgeId]/route.ts b/src/app/api/v1/pipelines/[id]/edges/[edgeId]/route.ts new file mode 100644 index 00000000..0e4425f6 --- /dev/null +++ b/src/app/api/v1/pipelines/[id]/edges/[edgeId]/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { writeAuditLog } from "@/server/services/audit"; +import { apiRoute } from "../../../../_lib/api-handler"; + +export const DELETE = apiRoute( + "pipelines.write", + async (req: NextRequest, ctx, params) => { + const pipelineId = params?.id; + const edgeId = params?.edgeId; + if (!pipelineId || !edgeId) { + return NextResponse.json({ error: "Missing pipeline or edge id" }, { status: 400 }); + } + + const pipeline = await prisma.pipeline.findUnique({ + where: { id: pipelineId, environmentId: ctx.environmentId }, + select: { id: true }, + }); + if (!pipeline) { + return NextResponse.json({ error: "Pipeline not found" }, { status: 404 }); + } + + const edge = await prisma.pipelineEdge.findFirst({ + where: { id: edgeId, pipelineId }, + }); + if (!edge) { + return NextResponse.json({ error: "Edge not found" }, { status: 404 }); + } + + await prisma.pipelineEdge.delete({ where: { id: edgeId } }); + + writeAuditLog({ + action: "api.pipeline_edge_removed", + entityType: "PipelineEdge", + entityId: edgeId, + userId: null, + userEmail: null, + userName: ctx.serviceAccountName ?? "service-account", + teamId: null, + environmentId: ctx.environmentId, + ipAddress: req.headers.get("x-forwarded-for")?.split(",")[0] ?? null, + metadata: { pipelineId, edgeId }, + }).catch(() => {}); + + return NextResponse.json({ deleted: true }); + }, +); diff --git a/src/app/api/v1/pipelines/[id]/edges/__tests__/edges.test.ts b/src/app/api/v1/pipelines/[id]/edges/__tests__/edges.test.ts new file mode 100644 index 00000000..5e0173eb --- /dev/null +++ b/src/app/api/v1/pipelines/[id]/edges/__tests__/edges.test.ts @@ -0,0 +1,94 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; +import type { PrismaClient } from "@/generated/prisma"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/prisma", () => ({ + prisma: mockDeep(), +})); + +vi.mock("@/server/middleware/api-auth", () => ({ + authenticateApiKey: vi.fn(), + hasPermission: vi.fn(), +})); + +vi.mock("@/server/services/audit", () => ({ + writeAuditLog: vi.fn().mockResolvedValue({}), +})); + +vi.mock("../../../../_lib/rate-limiter", () => ({ + rateLimiter: { check: vi.fn().mockReturnValue({ allowed: true, remaining: 99, retryAfter: 0 }) }, +})); + +import { prisma } from "@/lib/prisma"; +import { authenticateApiKey, hasPermission } from "@/server/middleware/api-auth"; +import { POST } from "../route"; + +const prismaMock = prisma as unknown as DeepMockProxy; +const authMock = authenticateApiKey as ReturnType; +const permMock = hasPermission as ReturnType; + +const CTX = { + serviceAccountId: "sa-1", + serviceAccountName: "ci-bot", + environmentId: "env-1", + permissions: ["pipelines.write"], + rateLimit: null, +}; + +describe("POST /api/v1/pipelines/{id}/edges", () => { + beforeEach(() => { + mockReset(prismaMock); + authMock.mockResolvedValue(CTX); + permMock.mockReturnValue(true); + }); + + it("adds an edge between two nodes", async () => { + prismaMock.pipeline.findUnique.mockResolvedValue({ + id: "pipe-1", + environmentId: "env-1", + } as never); + prismaMock.pipelineNode.findFirst.mockResolvedValue({ id: "n1" } as never); + prismaMock.pipelineEdge.create.mockResolvedValue({ + id: "edge-1", + pipelineId: "pipe-1", + sourceNodeId: "n1", + targetNodeId: "n2", + sourcePort: null, + } as never); + + const req = new NextRequest("http://localhost/api/v1/pipelines/pipe-1/edges", { + method: "POST", + headers: { + authorization: "Bearer vf_test123", + "content-type": "application/json", + }, + body: JSON.stringify({ sourceNodeId: "n1", targetNodeId: "n2" }), + }); + + const res = await POST(req, { params: Promise.resolve({ id: "pipe-1" }) }); + expect(res.status).toBe(201); + + const body = await res.json(); + expect(body.edge.sourceNodeId).toBe("n1"); + }); + + it("returns 400 when sourceNodeId or targetNodeId is missing", async () => { + prismaMock.pipeline.findUnique.mockResolvedValue({ + id: "pipe-1", + environmentId: "env-1", + } as never); + + const req = new NextRequest("http://localhost/api/v1/pipelines/pipe-1/edges", { + method: "POST", + headers: { + authorization: "Bearer vf_test123", + "content-type": "application/json", + }, + body: JSON.stringify({ sourceNodeId: "n1" }), + }); + + const res = await POST(req, { params: Promise.resolve({ id: "pipe-1" }) }); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/v1/pipelines/[id]/edges/route.ts b/src/app/api/v1/pipelines/[id]/edges/route.ts new file mode 100644 index 00000000..f71070cb --- /dev/null +++ b/src/app/api/v1/pipelines/[id]/edges/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { writeAuditLog } from "@/server/services/audit"; +import { apiRoute, jsonResponse } from "../../../_lib/api-handler"; + +export const POST = apiRoute( + "pipelines.write", + async (req: NextRequest, ctx, params) => { + const pipelineId = params?.id; + if (!pipelineId) { + return NextResponse.json({ error: "Missing pipeline id" }, { status: 400 }); + } + + const pipeline = await prisma.pipeline.findUnique({ + where: { id: pipelineId, environmentId: ctx.environmentId }, + select: { id: true }, + }); + if (!pipeline) { + return NextResponse.json({ error: "Pipeline not found" }, { status: 404 }); + } + + let body: { sourceNodeId?: string; targetNodeId?: string; sourcePort?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + if (!body.sourceNodeId || !body.targetNodeId) { + return NextResponse.json( + { error: "sourceNodeId and targetNodeId are required" }, + { status: 400 }, + ); + } + + // Verify both nodes belong to this pipeline + const sourceNode = await prisma.pipelineNode.findFirst({ + where: { id: body.sourceNodeId, pipelineId }, + }); + const targetNode = await prisma.pipelineNode.findFirst({ + where: { id: body.targetNodeId, pipelineId }, + }); + if (!sourceNode || !targetNode) { + return NextResponse.json( + { error: "Source or target node not found in this pipeline" }, + { status: 404 }, + ); + } + + const edge = await prisma.pipelineEdge.create({ + data: { + pipelineId, + sourceNodeId: body.sourceNodeId, + targetNodeId: body.targetNodeId, + sourcePort: body.sourcePort ?? null, + }, + }); + + writeAuditLog({ + action: "api.pipeline_edge_added", + entityType: "PipelineEdge", + entityId: edge.id, + userId: null, + userEmail: null, + userName: ctx.serviceAccountName ?? "service-account", + teamId: null, + environmentId: ctx.environmentId, + ipAddress: req.headers.get("x-forwarded-for")?.split(",")[0] ?? null, + metadata: { pipelineId, sourceNodeId: body.sourceNodeId, targetNodeId: body.targetNodeId }, + }).catch(() => {}); + + return jsonResponse({ edge }, { status: 201 }); + }, +); diff --git a/src/app/api/v1/pipelines/[id]/health/route.ts b/src/app/api/v1/pipelines/[id]/health/route.ts new file mode 100644 index 00000000..46c7e73b --- /dev/null +++ b/src/app/api/v1/pipelines/[id]/health/route.ts @@ -0,0 +1,105 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiRoute, jsonResponse } from "../../../_lib/api-handler"; + +export const GET = apiRoute( + "metrics.read", + async (_req, ctx, params) => { + const id = params?.id; + if (!id) { + return NextResponse.json({ error: "Missing pipeline id" }, { status: 400 }); + } + + const pipeline = await prisma.pipeline.findUnique({ + where: { id, environmentId: ctx.environmentId }, + select: { + id: true, + name: true, + isDraft: true, + deployedAt: true, + }, + }); + if (!pipeline) { + return NextResponse.json({ error: "Pipeline not found" }, { status: 404 }); + } + + // Fetch SLIs + const slis = await prisma.pipelineSli.findMany({ + where: { pipelineId: id, enabled: true }, + }); + + // Fetch node deployment statuses + const nodeStatuses = await prisma.nodePipelineStatus.findMany({ + where: { pipelineId: id }, + select: { + nodeId: true, + status: true, + version: true, + eventsIn: true, + eventsOut: true, + errorsTotal: true, + }, + }); + + // Fetch latest aggregate metrics (last 5 minutes) + const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000); + const recentMetrics = await prisma.pipelineMetric.findMany({ + where: { + pipelineId: id, + componentId: null, // aggregate rows only + timestamp: { gte: fiveMinAgo }, + }, + orderBy: { timestamp: "desc" }, + take: 1, + }); + + const latestMetric = recentMetrics[0] ?? null; + + // Calculate overall health status + const runningNodes = nodeStatuses.filter((ns) => ns.status === "RUNNING").length; + const totalNodes = nodeStatuses.length; + const hasErrors = latestMetric ? Number(latestMetric.errorsTotal) > 0 : false; + + let status: "healthy" | "degraded" | "unhealthy" | "unknown" = "unknown"; + if (pipeline.isDraft) { + status = "unknown"; + } else if (totalNodes === 0) { + status = "unknown"; + } else if (runningNodes === totalNodes && !hasErrors) { + status = "healthy"; + } else if (runningNodes > 0) { + status = "degraded"; + } else { + status = "unhealthy"; + } + + return jsonResponse({ + health: { + status, + pipeline: { + id: pipeline.id, + name: pipeline.name, + isDraft: pipeline.isDraft, + deployedAt: pipeline.deployedAt, + }, + nodes: { + total: totalNodes, + running: runningNodes, + statuses: nodeStatuses, + }, + slis, + latestMetrics: latestMetric + ? { + eventsIn: Number(latestMetric.eventsIn), + eventsOut: Number(latestMetric.eventsOut), + errorsTotal: Number(latestMetric.errorsTotal), + bytesIn: Number(latestMetric.bytesIn), + bytesOut: Number(latestMetric.bytesOut), + timestamp: latestMetric.timestamp, + } + : null, + }, + }); + }, + "read", +); diff --git a/src/app/api/v1/pipelines/[id]/logs/route.ts b/src/app/api/v1/pipelines/[id]/logs/route.ts new file mode 100644 index 00000000..9a202fab --- /dev/null +++ b/src/app/api/v1/pipelines/[id]/logs/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiRoute } from "../../../_lib/api-handler"; + +export const GET = apiRoute( + "metrics.read", + async (req: NextRequest, ctx, params) => { + const id = params?.id; + if (!id) { + return NextResponse.json({ error: "Missing pipeline id" }, { status: 400 }); + } + + const pipeline = await prisma.pipeline.findUnique({ + where: { id, environmentId: ctx.environmentId }, + select: { id: true }, + }); + if (!pipeline) { + return NextResponse.json({ error: "Pipeline not found" }, { status: 404 }); + } + + const after = req.nextUrl.searchParams.get("after"); + const limitParam = req.nextUrl.searchParams.get("limit"); + const level = req.nextUrl.searchParams.get("level"); + const limit = Math.min(Math.max(parseInt(limitParam ?? "100", 10) || 100, 1), 500); + + const where: Record = { pipelineId: id }; + if (level) { + const validLevels = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]; + if (validLevels.includes(level.toUpperCase())) { + where.level = level.toUpperCase(); + } + } + + const logs = await prisma.pipelineLog.findMany({ + where, + orderBy: { timestamp: "desc" }, + take: limit + 1, + ...(after ? { cursor: { id: after }, skip: 1 } : {}), + select: { + id: true, + pipelineId: true, + nodeId: true, + timestamp: true, + level: true, + message: true, + }, + }); + + let hasMore = false; + if (logs.length > limit) { + logs.pop(); + hasMore = true; + } + + const cursor = logs.length > 0 ? logs[logs.length - 1].id : null; + + return NextResponse.json({ logs, cursor, hasMore }); + }, + "read", +); diff --git a/src/app/api/v1/pipelines/[id]/metrics/route.ts b/src/app/api/v1/pipelines/[id]/metrics/route.ts new file mode 100644 index 00000000..dd3be47e --- /dev/null +++ b/src/app/api/v1/pipelines/[id]/metrics/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiRoute, jsonResponse } from "../../../_lib/api-handler"; + +export const GET = apiRoute( + "metrics.read", + async (req: NextRequest, ctx, params) => { + const id = params?.id; + if (!id) { + return NextResponse.json({ error: "Missing pipeline id" }, { status: 400 }); + } + + const pipeline = await prisma.pipeline.findUnique({ + where: { id, environmentId: ctx.environmentId }, + select: { id: true }, + }); + if (!pipeline) { + return NextResponse.json({ error: "Pipeline not found" }, { status: 404 }); + } + + const since = req.nextUrl.searchParams.get("since"); + const limitParam = req.nextUrl.searchParams.get("limit"); + const limit = Math.min(Math.max(parseInt(limitParam ?? "100", 10) || 100, 1), 1000); + + const where: Record = { pipelineId: id }; + if (since) { + const sinceDate = new Date(since); + if (isNaN(sinceDate.getTime())) { + return NextResponse.json({ error: "Invalid since parameter" }, { status: 400 }); + } + where.timestamp = { gte: sinceDate }; + } + + const metrics = await prisma.pipelineMetric.findMany({ + where, + orderBy: { timestamp: "desc" }, + take: limit, + }); + + return jsonResponse({ metrics }); + }, + "read", +); diff --git a/src/app/api/v1/pipelines/[id]/nodes/[nodeId]/__tests__/update-node.test.ts b/src/app/api/v1/pipelines/[id]/nodes/[nodeId]/__tests__/update-node.test.ts new file mode 100644 index 00000000..716a5e0f --- /dev/null +++ b/src/app/api/v1/pipelines/[id]/nodes/[nodeId]/__tests__/update-node.test.ts @@ -0,0 +1,113 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; +import type { PrismaClient } from "@/generated/prisma"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/prisma", () => ({ + prisma: mockDeep(), +})); + +vi.mock("@/server/middleware/api-auth", () => ({ + authenticateApiKey: vi.fn(), + hasPermission: vi.fn(), +})); + +vi.mock("@/server/services/audit", () => ({ + writeAuditLog: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/server/services/config-crypto", () => ({ + encryptNodeConfig: vi.fn((_type: string, config: Record) => config), +})); + +vi.mock("../../../../../_lib/rate-limiter", () => ({ + rateLimiter: { check: vi.fn().mockReturnValue({ allowed: true, remaining: 99, retryAfter: 0 }) }, +})); + +import { prisma } from "@/lib/prisma"; +import { authenticateApiKey, hasPermission } from "@/server/middleware/api-auth"; +import { PUT, DELETE } from "../route"; + +const prismaMock = prisma as unknown as DeepMockProxy; +const authMock = authenticateApiKey as ReturnType; +const permMock = hasPermission as ReturnType; + +const CTX = { + serviceAccountId: "sa-1", + serviceAccountName: "ci-bot", + environmentId: "env-1", + permissions: ["pipelines.write"], + rateLimit: null, +}; + +describe("PUT /api/v1/pipelines/{id}/nodes/{nodeId}", () => { + beforeEach(() => { + mockReset(prismaMock); + authMock.mockResolvedValue(CTX); + permMock.mockReturnValue(true); + }); + + it("updates node config and returns 200", async () => { + prismaMock.pipeline.findUnique.mockResolvedValue({ + id: "pipe-1", + environmentId: "env-1", + } as never); + prismaMock.pipelineNode.findFirst.mockResolvedValue({ + id: "node-1", + pipelineId: "pipe-1", + componentType: "file", + } as never); + prismaMock.pipelineNode.update.mockResolvedValue({ + id: "node-1", + pipelineId: "pipe-1", + componentKey: "vector.sources.file", + componentType: "file", + kind: "SOURCE", + config: { include: ["/var/log/new/**"] }, + positionX: 100, + positionY: 200, + disabled: false, + } as never); + + const req = new NextRequest("http://localhost/api/v1/pipelines/pipe-1/nodes/node-1", { + method: "PUT", + headers: { + authorization: "Bearer vf_test123", + "content-type": "application/json", + }, + body: JSON.stringify({ config: { include: ["/var/log/new/**"] } }), + }); + + const res = await PUT(req, { params: Promise.resolve({ id: "pipe-1", nodeId: "node-1" }) }); + expect(res.status).toBe(200); + }); +}); + +describe("DELETE /api/v1/pipelines/{id}/nodes/{nodeId}", () => { + beforeEach(() => { + mockReset(prismaMock); + authMock.mockResolvedValue(CTX); + permMock.mockReturnValue(true); + }); + + it("removes node and connected edges", async () => { + prismaMock.pipeline.findUnique.mockResolvedValue({ + id: "pipe-1", + environmentId: "env-1", + } as never); + prismaMock.pipelineNode.findFirst.mockResolvedValue({ + id: "node-1", + pipelineId: "pipe-1", + } as never); + prismaMock.pipelineEdge.deleteMany.mockResolvedValue({ count: 1 } as never); + prismaMock.pipelineNode.delete.mockResolvedValue({} as never); + + const req = new NextRequest("http://localhost/api/v1/pipelines/pipe-1/nodes/node-1", { + method: "DELETE", + headers: { authorization: "Bearer vf_test123" }, + }); + + const res = await DELETE(req, { params: Promise.resolve({ id: "pipe-1", nodeId: "node-1" }) }); + expect(res.status).toBe(200); + }); +}); diff --git a/src/app/api/v1/pipelines/[id]/nodes/[nodeId]/route.ts b/src/app/api/v1/pipelines/[id]/nodes/[nodeId]/route.ts new file mode 100644 index 00000000..d8e46217 --- /dev/null +++ b/src/app/api/v1/pipelines/[id]/nodes/[nodeId]/route.ts @@ -0,0 +1,129 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { writeAuditLog } from "@/server/services/audit"; +import { encryptNodeConfig } from "@/server/services/config-crypto"; +import { apiRoute, jsonResponse } from "../../../../_lib/api-handler"; + +export const PUT = apiRoute( + "pipelines.write", + async (req: NextRequest, ctx, params) => { + const pipelineId = params?.id; + const nodeId = params?.nodeId; + if (!pipelineId || !nodeId) { + return NextResponse.json({ error: "Missing pipeline or node id" }, { status: 400 }); + } + + const pipeline = await prisma.pipeline.findUnique({ + where: { id: pipelineId, environmentId: ctx.environmentId }, + select: { id: true }, + }); + if (!pipeline) { + return NextResponse.json({ error: "Pipeline not found" }, { status: 404 }); + } + + const node = await prisma.pipelineNode.findFirst({ + where: { id: nodeId, pipelineId }, + select: { id: true, componentType: true }, + }); + if (!node) { + return NextResponse.json({ error: "Node not found" }, { status: 404 }); + } + + let body: { + config?: Record; + displayName?: string; + positionX?: number; + positionY?: number; + disabled?: boolean; + }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const data: Record = {}; + if (body.config !== undefined) { + data.config = encryptNodeConfig(node.componentType, body.config); + } + if (body.displayName !== undefined) data.displayName = body.displayName; + if (body.positionX !== undefined) data.positionX = body.positionX; + if (body.positionY !== undefined) data.positionY = body.positionY; + if (body.disabled !== undefined) data.disabled = body.disabled; + + if (Object.keys(data).length === 0) { + return NextResponse.json( + { error: "At least one field to update is required" }, + { status: 400 }, + ); + } + + const updated = await prisma.pipelineNode.update({ + where: { id: nodeId }, + data, + }); + + writeAuditLog({ + action: "api.pipeline_node_updated", + entityType: "PipelineNode", + entityId: nodeId, + userId: null, + userName: ctx.serviceAccountName ?? "service-account", + teamId: null, + environmentId: ctx.environmentId, + ipAddress: req.headers.get("x-forwarded-for")?.split(",")[0] ?? null, + metadata: { pipelineId, nodeId, updatedFields: Object.keys(data) }, + }).catch(() => {}); + + return jsonResponse({ node: updated }); + }, +); + +export const DELETE = apiRoute( + "pipelines.write", + async (req: NextRequest, ctx, params) => { + const pipelineId = params?.id; + const nodeId = params?.nodeId; + if (!pipelineId || !nodeId) { + return NextResponse.json({ error: "Missing pipeline or node id" }, { status: 400 }); + } + + const pipeline = await prisma.pipeline.findUnique({ + where: { id: pipelineId, environmentId: ctx.environmentId }, + select: { id: true }, + }); + if (!pipeline) { + return NextResponse.json({ error: "Pipeline not found" }, { status: 404 }); + } + + const node = await prisma.pipelineNode.findFirst({ + where: { id: nodeId, pipelineId }, + }); + if (!node) { + return NextResponse.json({ error: "Node not found" }, { status: 404 }); + } + + // Remove connected edges first, then the node + await prisma.pipelineEdge.deleteMany({ + where: { + pipelineId, + OR: [{ sourceNodeId: nodeId }, { targetNodeId: nodeId }], + }, + }); + await prisma.pipelineNode.delete({ where: { id: nodeId } }); + + writeAuditLog({ + action: "api.pipeline_node_removed", + entityType: "PipelineNode", + entityId: nodeId, + userId: null, + userName: ctx.serviceAccountName ?? "service-account", + teamId: null, + environmentId: ctx.environmentId, + ipAddress: req.headers.get("x-forwarded-for")?.split(",")[0] ?? null, + metadata: { pipelineId, nodeId }, + }).catch(() => {}); + + return NextResponse.json({ deleted: true }); + }, +); diff --git a/src/app/api/v1/pipelines/[id]/nodes/__tests__/add-node.test.ts b/src/app/api/v1/pipelines/[id]/nodes/__tests__/add-node.test.ts new file mode 100644 index 00000000..1c96f37f --- /dev/null +++ b/src/app/api/v1/pipelines/[id]/nodes/__tests__/add-node.test.ts @@ -0,0 +1,89 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; +import type { PrismaClient } from "@/generated/prisma"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/prisma", () => ({ + prisma: mockDeep(), +})); + +vi.mock("@/server/middleware/api-auth", () => ({ + authenticateApiKey: vi.fn(), + hasPermission: vi.fn(), +})); + +vi.mock("@/server/services/audit", () => ({ + writeAuditLog: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/server/services/config-crypto", () => ({ + encryptNodeConfig: vi.fn((_type: string, config: Record) => config), +})); + +vi.mock("../../../../_lib/rate-limiter", () => ({ + rateLimiter: { check: vi.fn().mockReturnValue({ allowed: true, remaining: 99, retryAfter: 0 }) }, +})); + +import { prisma } from "@/lib/prisma"; +import { authenticateApiKey, hasPermission } from "@/server/middleware/api-auth"; +import { POST } from "../route"; + +const prismaMock = prisma as unknown as DeepMockProxy; +const authMock = authenticateApiKey as ReturnType; +const permMock = hasPermission as ReturnType; + +const CTX = { + serviceAccountId: "sa-1", + serviceAccountName: "ci-bot", + environmentId: "env-1", + permissions: ["pipelines.write"], + rateLimit: null, +}; + +describe("POST /api/v1/pipelines/{id}/nodes", () => { + beforeEach(() => { + mockReset(prismaMock); + authMock.mockResolvedValue(CTX); + permMock.mockReturnValue(true); + }); + + it("adds a node and returns 201", async () => { + prismaMock.pipeline.findUnique.mockResolvedValue({ + id: "pipe-1", + environmentId: "env-1", + } as never); + prismaMock.pipelineNode.create.mockResolvedValue({ + id: "node-1", + pipelineId: "pipe-1", + componentKey: "vector.sources.file", + componentType: "file", + kind: "SOURCE", + config: { include: ["/var/log/**"] }, + positionX: 100, + positionY: 200, + disabled: false, + } as never); + + const req = new NextRequest("http://localhost/api/v1/pipelines/pipe-1/nodes", { + method: "POST", + headers: { + authorization: "Bearer vf_test123", + "content-type": "application/json", + }, + body: JSON.stringify({ + componentKey: "vector.sources.file", + componentType: "file", + kind: "SOURCE", + config: { include: ["/var/log/**"] }, + positionX: 100, + positionY: 200, + }), + }); + + const res = await POST(req, { params: Promise.resolve({ id: "pipe-1" }) }); + expect(res.status).toBe(201); + + const body = await res.json(); + expect(body.node.componentKey).toBe("vector.sources.file"); + }); +}); diff --git a/src/app/api/v1/pipelines/[id]/nodes/route.ts b/src/app/api/v1/pipelines/[id]/nodes/route.ts new file mode 100644 index 00000000..da5d3400 --- /dev/null +++ b/src/app/api/v1/pipelines/[id]/nodes/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { writeAuditLog } from "@/server/services/audit"; +import { encryptNodeConfig } from "@/server/services/config-crypto"; +import { apiRoute, jsonResponse } from "../../../_lib/api-handler"; +import type { ComponentKind, Prisma } from "@/generated/prisma"; + +export const POST = apiRoute( + "pipelines.write", + async (req: NextRequest, ctx, params) => { + const pipelineId = params?.id; + if (!pipelineId) { + return NextResponse.json({ error: "Missing pipeline id" }, { status: 400 }); + } + + const pipeline = await prisma.pipeline.findUnique({ + where: { id: pipelineId, environmentId: ctx.environmentId }, + select: { id: true }, + }); + + if (!pipeline) { + return NextResponse.json({ error: "Pipeline not found" }, { status: 404 }); + } + + let body: { + componentKey?: string; + displayName?: string; + componentType?: string; + kind?: string; + config?: Record; + positionX?: number; + positionY?: number; + disabled?: boolean; + }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + if (!body.componentKey || !body.componentType || !body.kind) { + return NextResponse.json( + { error: "componentKey, componentType, and kind are required" }, + { status: 400 }, + ); + } + + const validKinds = ["SOURCE", "TRANSFORM", "SINK"]; + const normalizedKind = body.kind.toUpperCase(); + if (!validKinds.includes(normalizedKind)) { + return NextResponse.json( + { error: `kind must be one of: ${validKinds.join(", ")}` }, + { status: 400 }, + ); + } + + const encryptedConfig = encryptNodeConfig( + body.componentType, + body.config ?? {}, + ); + + const node = await prisma.pipelineNode.create({ + data: { + pipelineId, + componentKey: body.componentKey, + displayName: body.displayName ?? null, + componentType: body.componentType, + kind: normalizedKind as ComponentKind, + config: encryptedConfig as Prisma.InputJsonValue, + positionX: body.positionX ?? 0, + positionY: body.positionY ?? 0, + disabled: body.disabled ?? false, + }, + }); + + writeAuditLog({ + action: "api.pipeline_node_added", + entityType: "PipelineNode", + entityId: node.id, + userId: null, + userEmail: null, + userName: ctx.serviceAccountName ?? "service-account", + teamId: null, + environmentId: ctx.environmentId, + ipAddress: req.headers.get("x-forwarded-for")?.split(",")[0] ?? null, + metadata: { pipelineId, componentKey: body.componentKey, kind: normalizedKind }, + }).catch(() => {}); + + return jsonResponse({ node }, { status: 201 }); + }, +); diff --git a/src/app/api/v1/pipelines/[id]/promote/__tests__/promote.test.ts b/src/app/api/v1/pipelines/[id]/promote/__tests__/promote.test.ts new file mode 100644 index 00000000..92994f48 --- /dev/null +++ b/src/app/api/v1/pipelines/[id]/promote/__tests__/promote.test.ts @@ -0,0 +1,76 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; +import type { PrismaClient } from "@/generated/prisma"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/prisma", () => ({ prisma: mockDeep() })); +vi.mock("@/server/middleware/api-auth", () => ({ + authenticateApiKey: vi.fn(), + hasPermission: vi.fn(), +})); +vi.mock("@/server/services/audit", () => ({ writeAuditLog: vi.fn().mockResolvedValue({}) })); +vi.mock("@/server/services/pipeline-graph", () => ({ + promotePipeline: vi.fn(), +})); +vi.mock("../../../../_lib/rate-limiter", () => ({ + rateLimiter: { check: vi.fn().mockReturnValue({ allowed: true, remaining: 99, retryAfter: 0 }) }, +})); + +import { authenticateApiKey, hasPermission } from "@/server/middleware/api-auth"; +import { promotePipeline } from "@/server/services/pipeline-graph"; +import { POST } from "../route"; + +const authMock = authenticateApiKey as ReturnType; +const permMock = hasPermission as ReturnType; +const promoteMock = promotePipeline as ReturnType; + +const CTX = { + serviceAccountId: "sa-1", + serviceAccountName: "ci-bot", + environmentId: "env-1", + permissions: ["pipelines.promote"], + rateLimit: null, +}; + +describe("POST /api/v1/pipelines/{id}/promote", () => { + beforeEach(() => { + authMock.mockResolvedValue(CTX); + permMock.mockReturnValue(true); + }); + + it("promotes a pipeline to target environment", async () => { + promoteMock.mockResolvedValue({ + id: "pipe-new", + name: "promoted-pipe", + targetEnvironmentName: "staging", + strippedSecrets: [], + strippedCertificates: [], + }); + + const req = new NextRequest("http://localhost/api/v1/pipelines/pipe-1/promote", { + method: "POST", + headers: { + authorization: "Bearer vf_test123", + "content-type": "application/json", + }, + body: JSON.stringify({ targetEnvironmentId: "env-2" }), + }); + + const res = await POST(req, { params: Promise.resolve({ id: "pipe-1" }) }); + expect(res.status).toBe(201); + }); + + it("returns 400 when targetEnvironmentId is missing", async () => { + const req = new NextRequest("http://localhost/api/v1/pipelines/pipe-1/promote", { + method: "POST", + headers: { + authorization: "Bearer vf_test123", + "content-type": "application/json", + }, + body: JSON.stringify({}), + }); + + const res = await POST(req, { params: Promise.resolve({ id: "pipe-1" }) }); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/v1/pipelines/[id]/promote/route.ts b/src/app/api/v1/pipelines/[id]/promote/route.ts new file mode 100644 index 00000000..0ac4333e --- /dev/null +++ b/src/app/api/v1/pipelines/[id]/promote/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; +import { promotePipeline } from "@/server/services/pipeline-graph"; +import { writeAuditLog } from "@/server/services/audit"; +import { apiRoute, jsonResponse } from "../../../_lib/api-handler"; + +export const POST = apiRoute( + "pipelines.promote", + async (req: NextRequest, ctx, params) => { + const pipelineId = params?.id; + if (!pipelineId) { + return NextResponse.json({ error: "Missing pipeline id" }, { status: 400 }); + } + + let body: { targetEnvironmentId?: string; name?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + if (!body.targetEnvironmentId) { + return NextResponse.json( + { error: "targetEnvironmentId is required" }, + { status: 400 }, + ); + } + + const result = await promotePipeline({ + sourcePipelineId: pipelineId, + targetEnvironmentId: body.targetEnvironmentId, + name: body.name, + userId: `sa:${ctx.serviceAccountId}`, + }); + + writeAuditLog({ + action: "api.pipeline_promoted", + entityType: "Pipeline", + entityId: pipelineId, + userId: null, + userEmail: null, + userName: ctx.serviceAccountName ?? "service-account", + teamId: null, + environmentId: ctx.environmentId, + ipAddress: req.headers.get("x-forwarded-for")?.split(",")[0] ?? null, + metadata: { + targetEnvironmentId: body.targetEnvironmentId, + newPipelineId: result.id, + }, + }).catch(() => {}); + + return jsonResponse( + { + promoted: { + pipelineId: result.id, + name: result.name, + targetEnvironmentName: result.targetEnvironmentName, + strippedSecrets: result.strippedSecrets, + strippedCertificates: result.strippedCertificates, + }, + }, + { status: 201 }, + ); + }, + "deploy", +); diff --git a/src/app/api/v1/pipelines/[id]/route.ts b/src/app/api/v1/pipelines/[id]/route.ts index 1beba3a7..dd620c9d 100644 --- a/src/app/api/v1/pipelines/[id]/route.ts +++ b/src/app/api/v1/pipelines/[id]/route.ts @@ -1,5 +1,6 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; +import { writeAuditLog } from "@/server/services/audit"; import { apiRoute, jsonResponse } from "../../_lib/api-handler"; export const GET = apiRoute("pipelines.read", async (_req, ctx, params) => { @@ -60,3 +61,113 @@ export const GET = apiRoute("pipelines.read", async (_req, ctx, params) => { return jsonResponse({ pipeline }); }); + +export const PUT = apiRoute( + "pipelines.write", + async (req: NextRequest, ctx, params) => { + const id = params?.id; + if (!id) { + return NextResponse.json({ error: "Missing pipeline id" }, { status: 400 }); + } + + const existing = await prisma.pipeline.findUnique({ + where: { id, environmentId: ctx.environmentId }, + select: { id: true, environmentId: true }, + }); + + if (!existing) { + return NextResponse.json({ error: "Pipeline not found" }, { status: 404 }); + } + + let body: { name?: string; description?: string; groupId?: string | null }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const data: Record = {}; + if (body.name !== undefined) data.name = body.name; + if (body.description !== undefined) data.description = body.description; + if (body.groupId !== undefined) data.groupId = body.groupId; + + if (Object.keys(data).length === 0) { + return NextResponse.json( + { error: "At least one field (name, description, groupId) is required" }, + { status: 400 }, + ); + } + + const pipeline = await prisma.pipeline.update({ + where: { id }, + data, + select: { + id: true, + name: true, + description: true, + isDraft: true, + deployedAt: true, + createdAt: true, + updatedAt: true, + }, + }); + + writeAuditLog({ + action: "api.pipeline_updated", + entityType: "Pipeline", + entityId: pipeline.id, + userId: null, + userEmail: null, + userName: ctx.serviceAccountName ?? "service-account", + teamId: null, + environmentId: ctx.environmentId, + ipAddress: req.headers.get("x-forwarded-for")?.split(",")[0] ?? null, + metadata: data, + }).catch(() => {}); + + return jsonResponse({ pipeline }); + }, +); + +export const DELETE = apiRoute( + "pipelines.write", + async (req: NextRequest, ctx, params) => { + const id = params?.id; + if (!id) { + return NextResponse.json({ error: "Missing pipeline id" }, { status: 400 }); + } + + const pipeline = await prisma.pipeline.findUnique({ + where: { id, environmentId: ctx.environmentId }, + select: { id: true, name: true, isDraft: true, deployedAt: true }, + }); + + if (!pipeline) { + return NextResponse.json({ error: "Pipeline not found" }, { status: 404 }); + } + + if (!pipeline.isDraft) { + return NextResponse.json( + { error: "Cannot delete a deployed pipeline. Undeploy it first." }, + { status: 409 }, + ); + } + + await prisma.pipeline.delete({ where: { id } }); + + writeAuditLog({ + action: "api.pipeline_deleted", + entityType: "Pipeline", + entityId: pipeline.id, + userId: null, + userEmail: null, + userName: ctx.serviceAccountName ?? "service-account", + teamId: null, + environmentId: ctx.environmentId, + ipAddress: req.headers.get("x-forwarded-for")?.split(",")[0] ?? null, + metadata: { name: pipeline.name }, + }).catch(() => {}); + + return NextResponse.json({ deleted: true }); + }, +); diff --git a/src/app/api/v1/pipelines/__tests__/create-pipeline.test.ts b/src/app/api/v1/pipelines/__tests__/create-pipeline.test.ts new file mode 100644 index 00000000..54c17b9e --- /dev/null +++ b/src/app/api/v1/pipelines/__tests__/create-pipeline.test.ts @@ -0,0 +1,104 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; +import type { PrismaClient } from "@/generated/prisma"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/prisma", () => ({ + prisma: mockDeep(), +})); + +vi.mock("@/server/middleware/api-auth", () => ({ + authenticateApiKey: vi.fn(), + hasPermission: vi.fn(), +})); + +vi.mock("@/server/services/audit", () => ({ + writeAuditLog: vi.fn().mockResolvedValue({}), +})); + +vi.mock("../../../_lib/rate-limiter", () => ({ + rateLimiter: { check: vi.fn().mockReturnValue({ allowed: true, remaining: 99, retryAfter: 0 }) }, +})); + +import { prisma } from "@/lib/prisma"; +import { authenticateApiKey, hasPermission } from "@/server/middleware/api-auth"; +import { POST } from "../route"; + +const prismaMock = prisma as unknown as DeepMockProxy; +const authMock = authenticateApiKey as ReturnType; +const permMock = hasPermission as ReturnType; + +const SERVICE_ACCOUNT_CTX = { + serviceAccountId: "sa-1", + serviceAccountName: "ci-bot", + environmentId: "env-1", + permissions: ["pipelines.write"], + rateLimit: null, +}; + +describe("POST /api/v1/pipelines", () => { + beforeEach(() => { + mockReset(prismaMock); + authMock.mockResolvedValue(SERVICE_ACCOUNT_CTX); + permMock.mockReturnValue(true); + }); + + it("creates a pipeline and returns 201", async () => { + const created = { + id: "pipe-1", + name: "nginx-logs", + description: "Collects nginx logs", + isDraft: true, + deployedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + prismaMock.pipeline.create.mockResolvedValue(created as never); + + const req = new NextRequest("http://localhost/api/v1/pipelines", { + method: "POST", + headers: { + authorization: "Bearer vf_test123", + "content-type": "application/json", + }, + body: JSON.stringify({ name: "nginx-logs", description: "Collects nginx logs" }), + }); + + const res = await POST(req, { params: Promise.resolve({}) }); + expect(res.status).toBe(201); + + const body = await res.json(); + expect(body.pipeline.id).toBe("pipe-1"); + expect(body.pipeline.name).toBe("nginx-logs"); + }); + + it("returns 400 when name is missing", async () => { + const req = new NextRequest("http://localhost/api/v1/pipelines", { + method: "POST", + headers: { + authorization: "Bearer vf_test123", + "content-type": "application/json", + }, + body: JSON.stringify({ description: "no name" }), + }); + + const res = await POST(req, { params: Promise.resolve({}) }); + expect(res.status).toBe(400); + }); + + it("returns 403 when lacking pipelines.write permission", async () => { + permMock.mockReturnValue(false); + + const req = new NextRequest("http://localhost/api/v1/pipelines", { + method: "POST", + headers: { + authorization: "Bearer vf_test123", + "content-type": "application/json", + }, + body: JSON.stringify({ name: "test" }), + }); + + const res = await POST(req, { params: Promise.resolve({}) }); + expect(res.status).toBe(403); + }); +}); diff --git a/src/app/api/v1/pipelines/import/__tests__/import-pipeline.test.ts b/src/app/api/v1/pipelines/import/__tests__/import-pipeline.test.ts new file mode 100644 index 00000000..659d7fc3 --- /dev/null +++ b/src/app/api/v1/pipelines/import/__tests__/import-pipeline.test.ts @@ -0,0 +1,123 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; +import type { PrismaClient } from "@/generated/prisma"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/prisma", () => ({ + prisma: mockDeep(), +})); + +vi.mock("@/server/middleware/api-auth", () => ({ + authenticateApiKey: vi.fn(), + hasPermission: vi.fn(), +})); + +vi.mock("@/server/services/audit", () => ({ + writeAuditLog: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/lib/config-generator", () => ({ + importVectorConfig: vi.fn(), +})); + +vi.mock("@/server/services/config-crypto", () => ({ + encryptNodeConfig: vi.fn((_type: string, config: Record) => config), +})); + +vi.mock("../../../_lib/rate-limiter", () => ({ + rateLimiter: { check: vi.fn().mockReturnValue({ allowed: true, remaining: 99, retryAfter: 0 }) }, +})); + +import { prisma } from "@/lib/prisma"; +import { authenticateApiKey, hasPermission } from "@/server/middleware/api-auth"; +import { importVectorConfig } from "@/lib/config-generator"; +import { POST } from "../route"; + +const prismaMock = prisma as unknown as DeepMockProxy; +const authMock = authenticateApiKey as ReturnType; +const permMock = hasPermission as ReturnType; +const importMock = importVectorConfig as ReturnType; + +const CTX = { + serviceAccountId: "sa-1", + serviceAccountName: "ci-bot", + environmentId: "env-1", + permissions: ["pipelines.write"], + rateLimit: null, +}; + +describe("POST /api/v1/pipelines/import", () => { + beforeEach(() => { + mockReset(prismaMock); + authMock.mockResolvedValue(CTX); + permMock.mockReturnValue(true); + }); + + it("imports YAML and creates a pipeline with graph", async () => { + importMock.mockReturnValue({ + nodes: [ + { + id: "n1", + type: "source", + position: { x: 0, y: 0 }, + data: { + componentKey: "vector.sources.file", + componentDef: { type: "file", kind: "source" }, + config: { include: ["/var/log/**"] }, + disabled: false, + }, + }, + ], + edges: [], + globalConfig: null, + }); + + const env = { teamId: "team-1" }; + prismaMock.environment.findUnique.mockResolvedValue(env as never); + prismaMock.pipeline.findFirst.mockResolvedValue(null); + + const mockTx = { + pipeline: { + create: vi.fn().mockResolvedValue({ id: "pipe-1", name: "imported-pipe" }), + }, + pipelineNode: { + create: vi.fn().mockResolvedValue({ id: "n1" }), + }, + pipelineEdge: { + create: vi.fn().mockResolvedValue({}), + }, + }; + prismaMock.$transaction.mockImplementation(async (fn: unknown) => { + if (typeof fn === "function") return fn(mockTx); + }); + + const req = new NextRequest("http://localhost/api/v1/pipelines/import", { + method: "POST", + headers: { + authorization: "Bearer vf_test123", + "content-type": "application/json", + }, + body: JSON.stringify({ + name: "imported-pipe", + yaml: "sources:\n file:\n type: file\n include:\n - /var/log/**\n", + }), + }); + + const res = await POST(req, { params: Promise.resolve({}) }); + expect(res.status).toBe(201); + }); + + it("returns 400 when yaml is missing", async () => { + const req = new NextRequest("http://localhost/api/v1/pipelines/import", { + method: "POST", + headers: { + authorization: "Bearer vf_test123", + "content-type": "application/json", + }, + body: JSON.stringify({ name: "test" }), + }); + + const res = await POST(req, { params: Promise.resolve({}) }); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/v1/pipelines/import/route.ts b/src/app/api/v1/pipelines/import/route.ts new file mode 100644 index 00000000..322e9d10 --- /dev/null +++ b/src/app/api/v1/pipelines/import/route.ts @@ -0,0 +1,148 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { importVectorConfig } from "@/lib/config-generator"; +import { encryptNodeConfig } from "@/server/services/config-crypto"; +import { writeAuditLog } from "@/server/services/audit"; +import { apiRoute, jsonResponse } from "../../_lib/api-handler"; +import type { ComponentKind, Prisma } from "@/generated/prisma"; + +export const POST = apiRoute( + "pipelines.write", + async (req: NextRequest, ctx) => { + let body: { name?: string; yaml?: string; description?: string; groupId?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + if (!body.name || typeof body.name !== "string" || body.name.trim().length === 0) { + return NextResponse.json( + { error: "name is required" }, + { status: 400 }, + ); + } + + if (!body.yaml || typeof body.yaml !== "string") { + return NextResponse.json( + { error: "yaml is required and must be a string" }, + { status: 400 }, + ); + } + + // Check name collision + const existing = await prisma.pipeline.findFirst({ + where: { name: body.name.trim(), environmentId: ctx.environmentId }, + }); + if (existing) { + return NextResponse.json( + { error: `A pipeline named "${body.name.trim()}" already exists in this environment` }, + { status: 409 }, + ); + } + + let importResult; + try { + importResult = importVectorConfig(body.yaml); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to parse YAML"; + return NextResponse.json( + { error: `Invalid YAML config: ${message}` }, + { status: 400 }, + ); + } + + const env = await prisma.environment.findUnique({ + where: { id: ctx.environmentId }, + select: { teamId: true }, + }); + + const pipeline = await prisma.$transaction(async (tx) => { + const created = await tx.pipeline.create({ + data: { + name: body.name!.trim(), + description: body.description ?? null, + environmentId: ctx.environmentId, + groupId: body.groupId ?? null, + globalConfig: importResult.globalConfig + ? (importResult.globalConfig as unknown as Prisma.InputJsonValue) + : undefined, + isDraft: true, + }, + }); + + // Create nodes + for (const node of importResult.nodes) { + const nodeData = node.data as { + componentKey: string; + componentDef: { type: string; kind: string }; + config: Record; + disabled?: boolean; + }; + + const kind = nodeData.componentDef.kind.toUpperCase() as ComponentKind; + + await tx.pipelineNode.create({ + data: { + id: node.id, + pipelineId: created.id, + componentKey: nodeData.componentKey, + componentType: nodeData.componentDef.type, + kind, + config: encryptNodeConfig( + nodeData.componentDef.type, + nodeData.config ?? {}, + ) as unknown as Prisma.InputJsonValue, + positionX: node.position?.x ?? 0, + positionY: node.position?.y ?? 0, + disabled: nodeData.disabled ?? false, + }, + }); + } + + // Create edges + for (const edge of importResult.edges) { + await tx.pipelineEdge.create({ + data: { + id: edge.id, + pipelineId: created.id, + sourceNodeId: edge.source, + targetNodeId: edge.target, + sourcePort: (edge as { sourceHandle?: string }).sourceHandle ?? null, + }, + }); + } + + return created; + }); + + writeAuditLog({ + action: "api.pipeline_imported", + entityType: "Pipeline", + entityId: pipeline.id, + userId: null, + userEmail: null, + userName: ctx.serviceAccountName ?? "service-account", + teamId: env?.teamId ?? null, + environmentId: ctx.environmentId, + ipAddress: req.headers.get("x-forwarded-for")?.split(",")[0] ?? null, + metadata: { + name: body.name, + nodeCount: importResult.nodes.length, + edgeCount: importResult.edges.length, + }, + }).catch(() => {}); + + return jsonResponse( + { + pipeline: { + id: pipeline.id, + name: pipeline.name, + nodeCount: importResult.nodes.length, + edgeCount: importResult.edges.length, + }, + }, + { status: 201 }, + ); + }, +); diff --git a/src/app/api/v1/pipelines/route.ts b/src/app/api/v1/pipelines/route.ts index e55faf6e..b3b0a493 100644 --- a/src/app/api/v1/pipelines/route.ts +++ b/src/app/api/v1/pipelines/route.ts @@ -1,5 +1,6 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; +import { writeAuditLog } from "@/server/services/audit"; import { apiRoute } from "../_lib/api-handler"; export const GET = apiRoute("pipelines.read", async (_req, ctx) => { @@ -18,4 +19,77 @@ export const GET = apiRoute("pipelines.read", async (_req, ctx) => { }); return NextResponse.json({ pipelines }); -}); +}, "read"); + +export const POST = apiRoute( + "pipelines.write", + async (req: NextRequest, ctx) => { + let body: { name?: string; description?: string; groupId?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json( + { error: "Invalid JSON body" }, + { status: 400 }, + ); + } + + if (!body.name || typeof body.name !== "string" || body.name.trim().length === 0) { + return NextResponse.json( + { error: "name is required and must be a non-empty string" }, + { status: 400 }, + ); + } + + // Check for name collision within the environment + const existing = await prisma.pipeline.findFirst({ + where: { name: body.name.trim(), environmentId: ctx.environmentId }, + }); + if (existing) { + return NextResponse.json( + { error: `A pipeline named "${body.name.trim()}" already exists in this environment` }, + { status: 409 }, + ); + } + + // Resolve teamId from environment for audit purposes + const env = await prisma.environment.findUnique({ + where: { id: ctx.environmentId }, + select: { teamId: true }, + }); + + const pipeline = await prisma.pipeline.create({ + data: { + name: body.name.trim(), + description: body.description ?? null, + environmentId: ctx.environmentId, + groupId: body.groupId ?? null, + isDraft: true, + }, + select: { + id: true, + name: true, + description: true, + isDraft: true, + deployedAt: true, + createdAt: true, + updatedAt: true, + }, + }); + + writeAuditLog({ + action: "api.pipeline_created", + entityType: "Pipeline", + entityId: pipeline.id, + userId: null, + userEmail: null, + userName: ctx.serviceAccountName ?? "service-account", + teamId: env?.teamId ?? null, + environmentId: ctx.environmentId, + ipAddress: req.headers.get("x-forwarded-for")?.split(",")[0] ?? null, + metadata: { name: body.name.trim() }, + }).catch(() => {}); + + return NextResponse.json({ pipeline }, { status: 201 }); + }, +); diff --git a/src/app/api/webhooks/git/__tests__/pr-merge.test.ts b/src/app/api/webhooks/git/__tests__/pr-merge.test.ts index c5668d38..5f61ea70 100644 --- a/src/app/api/webhooks/git/__tests__/pr-merge.test.ts +++ b/src/app/api/webhooks/git/__tests__/pr-merge.test.ts @@ -115,19 +115,15 @@ describe("Git webhook — PR merge handler", () => { vi.clearAllMocks(); }); - it("responds pong to ping event without checking signature", async () => { - const req = new Request("http://localhost/api/webhooks/git", { - method: "POST", - headers: { "Content-Type": "application/json", "X-GitHub-Event": "ping" }, - body: JSON.stringify({ zen: "Testing is good." }), - }); + it("responds pong to ping event after signature verification", async () => { + prismaMock.environment.findMany.mockResolvedValue([makeEnvironment()] as never); + const req = makeRequest({ zen: "Testing is good." }, "ping"); const res = await POST(req as never); const json = await res.json(); expect(res.status).toBe(200); expect(json.message).toBe("pong"); - expect(prismaMock.environment.findMany).not.toHaveBeenCalled(); }); it("returns 401 when signature is missing", async () => { @@ -231,7 +227,7 @@ describe("Git webhook — PR merge handler", () => { const json = await res.json(); expect(res.status).toBe(200); - expect(json.message).toContain("Not a closed event"); + expect(json.message).toContain("unknown"); expect(executePromotion).not.toHaveBeenCalled(); }); diff --git a/src/app/api/webhooks/git/route.ts b/src/app/api/webhooks/git/route.ts index 6ffe2e5a..02f31484 100644 --- a/src/app/api/webhooks/git/route.ts +++ b/src/app/api/webhooks/git/route.ts @@ -1,5 +1,4 @@ import { NextRequest, NextResponse } from "next/server"; -import crypto from "crypto"; import { prisma } from "@/lib/prisma"; import { importVectorConfig } from "@/lib/config-generator"; import { decrypt } from "@/server/services/crypto"; @@ -7,23 +6,14 @@ import { encryptNodeConfig } from "@/server/services/config-crypto"; import { writeAuditLog } from "@/server/services/audit"; import { ComponentKind, Prisma } from "@/generated/prisma"; import { executePromotion } from "@/server/services/promotion-service"; +import { getProvider } from "@/server/services/git-providers"; +import type { GitWebhookEvent } from "@/server/services/git-providers"; +import { toFilenameSlug } from "@/server/services/git-sync"; export async function POST(req: NextRequest) { const body = await req.text(); - const signature = req.headers.get("x-hub-signature-256"); - const eventType = req.headers.get("x-github-event") ?? "push"; - - // Handle GitHub ping (sent when webhook is first registered) - if (eventType === "ping") { - return NextResponse.json({ message: "pong" }, { status: 200 }); - } - - if (!signature) { - return NextResponse.json({ error: "Missing signature" }, { status: 401 }); - } // 1. Find environments with gitOps webhook configured. - // Includes both bidirectional (push) and promotion (PR-based) modes. const environments = await prisma.environment.findMany({ where: { gitOpsMode: { in: ["bidirectional", "promotion"] }, @@ -31,22 +21,16 @@ export async function POST(req: NextRequest) { }, }); - // 2. Verify HMAC signature against each environment's webhook secret + // 2. Verify webhook signature against each environment using the correct provider let matchedEnv = null; for (const env of environments) { if (!env.gitWebhookSecret) continue; + + const provider = getProvider(env); + if (!provider) continue; + const webhookSecret = decrypt(env.gitWebhookSecret); - const expected = - "sha256=" + - crypto - .createHmac("sha256", webhookSecret) - .update(body) - .digest("hex"); - - // timingSafeEqual requires equal-length buffers - const sigBuf = Buffer.from(signature); - const expBuf = Buffer.from(expected); - if (sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf)) { + if (provider.verifyWebhookSignature(req.headers, body, webhookSecret)) { matchedEnv = env; break; } @@ -56,7 +40,16 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); } - // 3. Parse payload + // 3. Resolve the provider for the matched environment + const provider = getProvider(matchedEnv); + if (!provider) { + return NextResponse.json( + { error: "Cannot determine git provider for environment" }, + { status: 422 }, + ); + } + + // 4. Parse the webhook payload using the provider let payload: Record; try { payload = JSON.parse(body); @@ -67,19 +60,16 @@ export async function POST(req: NextRequest) { ); } - // ─── pull_request event: GitOps promotion merge trigger ────────────────── - if (eventType === "pull_request") { - // Only handle closed+merged — reject closed-without-merge - if (payload.action !== "closed") { - return NextResponse.json({ message: "Not a closed event, ignored" }, { status: 200 }); - } - const pr = payload.pull_request as Record | undefined; - if (!pr?.merged) { - return NextResponse.json({ message: "PR closed without merge, ignored" }, { status: 200 }); - } + const event: GitWebhookEvent = provider.parseWebhookEvent(req.headers, payload); - // Extract VF promotion request ID from PR body - const prBody = (pr.body as string) ?? ""; + // Handle ping events + if (event.type === "ping") { + return NextResponse.json({ message: "pong" }, { status: 200 }); + } + + // --- Pull request merged: GitOps promotion trigger --- + if (event.type === "pull_request_merged") { + const prBody = event.prBody ?? ""; const match = prBody.match(//); if (!match) { return NextResponse.json( @@ -89,38 +79,47 @@ export async function POST(req: NextRequest) { } const promotionRequestId = match[1]; - // Atomic idempotency guard — prevents double-deploy on GitHub retry + // Atomic idempotency guard const updated = await prisma.promotionRequest.updateMany({ where: { id: promotionRequestId, status: "AWAITING_PR_MERGE" }, data: { status: "DEPLOYING" }, }); if (updated.count === 0) { - // Already deployed, not found, or not in the right state — safe to ignore return NextResponse.json( { message: "Promotion already processed or not found" }, { status: 200 }, ); } - // Load the original promoter for audit attribution const promotionRequest = await prisma.promotionRequest.findUnique({ where: { id: promotionRequestId }, select: { promotedById: true }, }); - // Execute the promotion (the promoter is the logical actor) const executorId = promotionRequest?.promotedById ?? "system"; await executePromotion(promotionRequestId, executorId); return NextResponse.json({ deployed: true, promotionRequestId }); } - // ─── push event: Bidirectional GitOps config import ────────────────────── - const ref: string | undefined = payload.ref as string | undefined; // "refs/heads/main" - const branch = ref?.replace("refs/heads/", ""); + // --- Pull request closed without merge --- + if (event.type === "pull_request_closed") { + return NextResponse.json( + { message: "PR closed without merge, ignored" }, + { status: 200 }, + ); + } - // Sanitize branch — only allow alphanumeric, slashes, dashes, dots, underscores + // --- Push event: Bidirectional GitOps config import --- + if (event.type !== "push") { + return NextResponse.json( + { message: `Event type "${event.type}" not handled` }, + { status: 200 }, + ); + } + + const branch = event.branch; const BRANCH_RE = /^[a-zA-Z0-9\/_.-]+$/; if (!branch || !BRANCH_RE.test(branch)) { return NextResponse.json( @@ -136,15 +135,11 @@ export async function POST(req: NextRequest) { ); } - // 4. Find changed YAML files scoped to this environment's directory prefix - const envSlug = matchedEnv.name.toLowerCase().replace(/[^a-z0-9-]/g, "-"); - const commits = (payload.commits ?? []) as Array<{ - added?: string[]; - modified?: string[]; - }>; + // Find changed YAML files scoped to this environment's directory prefix + const envSlug = toFilenameSlug(matchedEnv.name); const changedFiles = new Set(); - for (const commit of commits) { - for (const f of [...(commit.added ?? []), ...(commit.modified ?? [])]) { + for (const commit of event.commits) { + for (const f of [...commit.added, ...commit.modified]) { if ( (f.endsWith(".yaml") || f.endsWith(".yml")) && f.startsWith(`${envSlug}/`) @@ -154,120 +149,122 @@ export async function POST(req: NextRequest) { } } - if (changedFiles.size === 0) { - return NextResponse.json({ message: "No YAML changes", processed: 0 }); - } - - // 5. Extract owner/repo and decrypt token once (invariant across files) - const repoUrl = matchedEnv.gitRepoUrl ?? ""; - const repoMatch = repoUrl.match(/github\.com[:/](.+?)(?:\.git)?$/); - if (!repoMatch) { - return NextResponse.json( - { error: "Cannot parse repo URL" }, - { status: 422 }, - ); + // For Bitbucket: push events may not include file-level changes. + // If we got commits but no changed files, fetch the diffstat. + if (changedFiles.size === 0 && event.commits.length > 0 && provider.name === "bitbucket" && event.afterSha) { + // Bitbucket push events don't include file-level changes — fetch via diffstat API + const bbToken = matchedEnv.gitToken ? decrypt(matchedEnv.gitToken) : null; + if (bbToken && matchedEnv.gitRepoUrl) { + const { BitbucketProvider } = await import("@/server/services/git-providers/bitbucket"); + const bbProvider = new BitbucketProvider(); + const diffFiles = await bbProvider.fetchCommitDiffstat(matchedEnv.gitRepoUrl, bbToken, event.afterSha); + for (const f of diffFiles) { + if ( + (f.path.endsWith(".yaml") || f.path.endsWith(".yml")) && + f.path.startsWith(`${envSlug}/`) && + f.status !== "removed" + ) { + changedFiles.add(f.path); + } + } + } } - const repoPath = repoMatch[1]; - // Validate repoPath is a safe owner/repo format (no path traversal or encoded chars) - const REPO_PATH_RE = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/; - if (!REPO_PATH_RE.test(repoPath)) { - return NextResponse.json( - { error: "Invalid repository path" }, - { status: 422 }, - ); + if (changedFiles.size === 0) { + return NextResponse.json({ message: "No YAML changes", processed: 0 }); } + // Decrypt token once for file fetching const token = matchedEnv.gitToken ? decrypt(matchedEnv.gitToken) : null; - if (!token) { + if (!token || !matchedEnv.gitRepoUrl) { return NextResponse.json( - { error: "No git token configured" }, + { error: "No git token or repo URL configured" }, { status: 422 }, ); } - // 6. For each changed file, fetch content and import + // Check if approval is required for bidirectional imports + const requiresApproval = matchedEnv.requireDeployApproval; + + // For each changed file, fetch content and import const results: Array<{ file: string; status: string; error?: string }> = []; for (const file of changedFiles) { try { - // Sanitize file path — reject traversal sequences and non-printable chars + // Sanitize file path if (file.includes("..") || file.startsWith("/") || /[^\x20-\x7E]/.test(file)) { results.push({ file, status: "skipped", error: "Invalid file path" }); continue; } - // Build the URL safely with encoded path components - const encodedFile = file.split("/").map(encodeURIComponent).join("/"); - const contentRes = await fetch( - `https://api.github.com/repos/${repoPath}/contents/${encodedFile}?ref=${encodeURIComponent(branch)}`, - { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github.raw", - }, - }, + const content = await provider.fetchFileContent( + matchedEnv.gitRepoUrl, + token, + branch, + file, ); - if (!contentRes.ok) { - results.push({ - file, - status: "error", - error: `GitHub API ${contentRes.status}`, - }); - continue; - } - const content = await contentRes.text(); - // Derive pipeline name from filename (strip directory prefix and extension) - // Use only the basename (last path segment) to avoid slashes in the name + // Derive pipeline name from filename const basename = file.split("/").pop() ?? file; const pipelineName = basename.replace(/\.(yaml|yml)$/, ""); - // Validate the pipeline name matches the schema used by the tRPC router const PIPELINE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9 _-]*$/; if (!pipelineName || pipelineName.length > 100 || !PIPELINE_NAME_RE.test(pipelineName)) { results.push({ file, status: "skipped", - error: `Invalid pipeline name "${pipelineName}" — must start with alphanumeric and contain only letters, numbers, spaces, hyphens, underscores`, + error: `Invalid pipeline name "${pipelineName}"`, }); continue; } - // Find or create pipeline by name in this environment (atomic) - // Use a serializable transaction to prevent concurrent webhooks from - // racing and creating duplicate pipelines with the same name. + // Match by gitPath first, then by name const pipeline = await prisma.$transaction(async (tx) => { + // Try matching by gitPath + const byPath = await tx.pipeline.findFirst({ + where: { environmentId: matchedEnv.id, gitPath: file }, + }); + if (byPath) return byPath; + + // Fallback: match by name const existing = await tx.pipeline.findFirst({ where: { environmentId: matchedEnv.id, name: pipelineName }, }); - if (existing) return existing; + if (existing) { + // Set gitPath if not already set + if (!existing.gitPath) { + await tx.pipeline.update({ + where: { id: existing.id }, + data: { gitPath: file }, + }); + } + return existing; + } + + // Create new pipeline with gitPath return tx.pipeline.create({ - data: { name: pipelineName, environmentId: matchedEnv.id }, + data: { + name: pipelineName, + environmentId: matchedEnv.id, + gitPath: file, + isDraft: requiresApproval ? true : undefined, + }, }); }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); - // Import config into pipeline graph nodes/edges - // Only YAML files are collected (see filter above), so format is always "yaml" + // Import config into pipeline graph const { nodes, edges, globalConfig } = importVectorConfig(content, "yaml"); - // Map the component kind strings to the Prisma enum const kindMap: Record = { source: ComponentKind.SOURCE, transform: ComponentKind.TRANSFORM, sink: ComponentKind.SINK, }; - // Save graph within a transaction (same pattern as pipeline.saveGraph) await prisma.$transaction(async (tx) => { - await tx.pipelineEdge.deleteMany({ - where: { pipelineId: pipeline!.id }, - }); - await tx.pipelineNode.deleteMany({ - where: { pipelineId: pipeline!.id }, - }); + await tx.pipelineEdge.deleteMany({ where: { pipelineId: pipeline!.id } }); + await tx.pipelineNode.deleteMany({ where: { pipelineId: pipeline!.id } }); - // Create nodes for (const node of nodes) { const data = node.data as { componentDef: { type: string; kind: string }; @@ -294,7 +291,6 @@ export async function POST(req: NextRequest) { }); } - // Create edges for (const edge of edges) { await tx.pipelineEdge.create({ data: { @@ -307,7 +303,6 @@ export async function POST(req: NextRequest) { }); } - // Update pipeline globalConfig await tx.pipeline.update({ where: { id: pipeline!.id }, data: { @@ -316,7 +311,45 @@ export async function POST(req: NextRequest) { }); }); - // Write audit log for the import — failures must not mask a successful transaction + // If approval is required, create a DeployRequest instead of deploying immediately + if (requiresApproval) { + const { generateVectorYaml } = await import("@/lib/config-generator"); + const flowNodes = nodes.map((n) => ({ + id: n.id, + type: (n.data as { componentDef: { kind: string } }).componentDef.kind, + position: n.position, + data: n.data, + })); + const flowEdges = edges.map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + ...(("sourceHandle" in e && e.sourceHandle) ? { sourceHandle: e.sourceHandle as string } : {}), + })); + + const configYaml = generateVectorYaml( + flowNodes as Parameters[0], + flowEdges as Parameters[1], + globalConfig as Record | null, + null, + ); + + await prisma.deployRequest.create({ + data: { + pipelineId: pipeline.id, + environmentId: matchedEnv.id, + requestedById: null, + configYaml, + changelog: `GitOps import from ${file} (commit: ${event.afterSha?.slice(0, 8) ?? "unknown"})`, + }, + }); + + results.push({ file, status: "imported_pending_approval" }); + } else { + results.push({ file, status: "imported" }); + } + + // Audit log try { await writeAuditLog({ userId: null, @@ -328,16 +361,36 @@ export async function POST(req: NextRequest) { metadata: { file, branch, - commitRef: (payload.after as string) ?? null, - pusher: (payload.pusher as { name?: string } | undefined)?.name ?? null, + commitRef: event.afterSha ?? null, + pusher: event.pusherName ?? null, + provider: provider.name, + requiresApproval, }, }); } catch (auditErr) { console.error("Failed to write audit log for gitops import:", auditErr); } - - results.push({ file, status: "imported" }); } catch (err) { + // Write YAML import error to audit log for visibility + try { + await writeAuditLog({ + userId: null, + action: "gitops.pipeline.import_failed", + entityType: "Environment", + entityId: matchedEnv.id, + environmentId: matchedEnv.id, + teamId: matchedEnv.teamId, + metadata: { + file, + branch, + commitRef: event.afterSha ?? null, + error: String(err), + provider: provider.name, + }, + }); + } catch { + // Don't mask the original error + } results.push({ file, status: "error", error: String(err) }); } } diff --git a/src/components/__tests__/log-search-utils.test.tsx b/src/components/__tests__/log-search-utils.test.tsx new file mode 100644 index 00000000..8ffe6218 --- /dev/null +++ b/src/components/__tests__/log-search-utils.test.tsx @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { highlightAllMatches, countMatches } from "@/components/log-search-utils"; + +describe("highlightAllMatches", () => { + it("returns plain text when search is empty", () => { + const result = highlightAllMatches("hello world", ""); + expect(result).toBe("hello world"); + }); + + it("returns plain text when no match found", () => { + const result = highlightAllMatches("hello world", "xyz"); + expect(result).toBe("hello world"); + }); + + it("highlights a single match", () => { + const { container } = render(<>{highlightAllMatches("hello world", "world")}); + const marks = container.querySelectorAll("mark"); + expect(marks).toHaveLength(1); + expect(marks[0].textContent).toBe("world"); + }); + + it("highlights multiple matches", () => { + const { container } = render(<>{highlightAllMatches("foo bar foo baz foo", "foo")}); + const marks = container.querySelectorAll("mark"); + expect(marks).toHaveLength(3); + }); + + it("is case-insensitive", () => { + const { container } = render(<>{highlightAllMatches("Hello HELLO hello", "hello")}); + const marks = container.querySelectorAll("mark"); + expect(marks).toHaveLength(3); + }); + + it("preserves non-matching text between matches", () => { + const { container } = render(<>{highlightAllMatches("aXbXc", "X")}); + expect(container.textContent).toBe("aXbXc"); + const marks = container.querySelectorAll("mark"); + expect(marks).toHaveLength(2); + }); +}); + +describe("countMatches", () => { + it("returns 0 for empty search", () => { + expect(countMatches("hello", "")).toBe(0); + }); + + it("counts all occurrences case-insensitively", () => { + expect(countMatches("foo bar Foo BAZ FOO", "foo")).toBe(3); + }); + + it("returns 0 when no match", () => { + expect(countMatches("hello", "xyz")).toBe(0); + }); +}); diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index a9a91368..b845cba0 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { usePathname } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { LayoutDashboard, @@ -61,6 +61,7 @@ const SYSTEM_ENV_ALLOWED_HREFS = new Set(["/", "/pipelines"]); export function AppSidebar() { const pathname = usePathname(); + const router = useRouter(); const trpc = useTRPC(); const selectedTeamId = useTeamStore((s) => s.selectedTeamId); const isSystemEnvironment = useEnvironmentStore((s) => s.isSystemEnvironment); @@ -90,6 +91,14 @@ export function AppSidebar() { const isPipelinesMode = pathname.startsWith("/pipelines"); const isSubMode = isSettingsMode || isLibraryMode || isPipelinesMode; + const handleBack = () => { + if (window.history.length > 1) { + router.back(); + } else { + router.push("/"); + } + }; + const { state, toggleSidebar } = useSidebar(); const isCollapsed = state === "collapsed"; @@ -111,12 +120,15 @@ export function AppSidebar() {
{isSubMode ? ( - + ) : ( diff --git a/src/components/environment/git-sync-section.tsx b/src/components/environment/git-sync-section.tsx index c3470ad5..3e8891aa 100644 --- a/src/components/environment/git-sync-section.tsx +++ b/src/components/environment/git-sync-section.tsx @@ -32,6 +32,7 @@ interface GitSyncSectionProps { hasGitToken: boolean; gitOpsMode?: string; hasWebhookSecret?: boolean; + gitProvider?: string | null; } export function GitSyncSection({ @@ -41,6 +42,7 @@ export function GitSyncSection({ hasGitToken, gitOpsMode = "off", hasWebhookSecret = false, + gitProvider = null, }: GitSyncSectionProps) { const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -51,6 +53,7 @@ export function GitSyncSection({ const [showToken, setShowToken] = useState(false); const [isTesting, setIsTesting] = useState(false); const [selectedGitOpsMode, setSelectedGitOpsMode] = useState(gitOpsMode); + const [selectedGitProvider, setSelectedGitProvider] = useState(gitProvider); // The actual webhook secret is only available from the update mutation response const [webhookSecretFromMutation, setWebhookSecretFromMutation] = useState(null); @@ -94,6 +97,7 @@ export function GitSyncSection({ gitBranch: branch || null, gitToken: token || undefined, // Only send if user entered a new token gitOpsMode: selectedGitOpsMode as "off" | "push" | "bidirectional" | "promotion", + gitProvider: selectedGitProvider as "github" | "gitlab" | "bitbucket" | null, }, { onSuccess: () => { @@ -147,7 +151,8 @@ export function GitSyncSection({ repoUrl !== (gitRepoUrl ?? "") || branch !== (gitBranch ?? "main") || token !== "" || - selectedGitOpsMode !== gitOpsMode; + selectedGitOpsMode !== gitOpsMode || + selectedGitProvider !== gitProvider; const isConfigured = !!gitRepoUrl; const webhookUrl = @@ -230,14 +235,36 @@ export function GitSyncSection({ Off Push Only (deploy commits YAML to repo) Bi-directional (push + git webhooks import changes) - Promotion (PR-based promotion via GitHub) + Promotion (PR/MR-based promotion via git provider)

{selectedGitOpsMode === "off" && "Git sync is disabled."} {selectedGitOpsMode === "push" && "Pipeline YAML is committed to the repo on deploy. Changes in git are not pulled back."} {selectedGitOpsMode === "bidirectional" && "Pipeline YAML is committed on deploy AND pushes to the repo trigger pipeline imports via webhook."} - {selectedGitOpsMode === "promotion" && "Promoting a pipeline creates a GitHub pull request. Merging the PR automatically deploys the promoted config to the target environment."} + {selectedGitOpsMode === "promotion" && "Promoting a pipeline creates a pull request (or merge request). Merging it automatically deploys the promoted config to the target environment."} +

+
+ + {/* Git Provider (optional override for self-hosted instances) */} +
+ + +

+ Provider is auto-detected from the repository URL domain. Override for self-hosted Git instances.

@@ -249,7 +276,7 @@ export function GitSyncSection({ Webhook Configuration

- Configure a webhook in your GitHub repository settings to enable bi-directional sync. + Configure a webhook in your Git repository settings to enable bi-directional sync. Set the content type to application/json and select the push event.

diff --git a/src/components/environment/git-sync-status.tsx b/src/components/environment/git-sync-status.tsx new file mode 100644 index 00000000..94ac7932 --- /dev/null +++ b/src/components/environment/git-sync-status.tsx @@ -0,0 +1,266 @@ +"use client"; + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { toast } from "sonner"; +import { + AlertTriangle, + CheckCircle2, + Clock, + RefreshCw, + Loader2, + XCircle, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +interface GitSyncStatusProps { + environmentId: string; +} + +export function GitSyncStatus({ environmentId }: GitSyncStatusProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const statusQuery = useQuery( + trpc.gitSync.status.queryOptions({ environmentId }), + ); + + const jobsQuery = useQuery( + trpc.gitSync.jobs.queryOptions({ environmentId, limit: 10 }), + ); + + const importErrorsQuery = useQuery( + trpc.gitSync.importErrors.queryOptions({ environmentId, limit: 5 }), + ); + + const retryAllMutation = useMutation( + trpc.gitSync.retryAllFailed.mutationOptions({ + onSuccess: (data) => { + toast.success(`Queued ${data.retriedCount} job(s) for retry`); + queryClient.invalidateQueries({ queryKey: trpc.gitSync.status.queryKey({ environmentId }) }); + queryClient.invalidateQueries({ queryKey: trpc.gitSync.jobs.queryKey({ environmentId }) }); + }, + onError: (err) => toast.error(err.message, { duration: 6000 }), + }), + ); + + const retryJobMutation = useMutation( + trpc.gitSync.retryJob.mutationOptions({ + onSuccess: () => { + toast.success("Job queued for retry"); + queryClient.invalidateQueries({ queryKey: trpc.gitSync.status.queryKey({ environmentId }) }); + queryClient.invalidateQueries({ queryKey: trpc.gitSync.jobs.queryKey({ environmentId }) }); + }, + onError: (err) => toast.error(err.message, { duration: 6000 }), + }), + ); + + const status = statusQuery.data; + + if (statusQuery.isLoading) { + return ( + + + + Loading sync status... + + + ); + } + + if (!status || status.gitOpsMode === "off") { + return null; + } + + return ( +
+ {/* Status Summary Card */} + + + + Git Sync Status + {status.failedCount > 0 ? ( + {status.failedCount} failed + ) : status.pendingCount > 0 ? ( + {status.pendingCount} pending + ) : ( + + Healthy + + )} + + + Provider: {status.gitProvider ?? "auto-detected"} | Branch: {status.gitBranch ?? "main"} + + + +
+
+ + Last successful sync: + + {status.lastSuccessfulSync + ? new Date(status.lastSuccessfulSync).toLocaleString() + : "Never"} + +
+ {status.lastError && ( +
+ + Last error: + + {status.lastError} + +
+ )} +
+ + {status.failedCount > 0 && ( + + )} +
+
+ + {/* Recent Jobs Table */} + {jobsQuery.data && jobsQuery.data.length > 0 && ( + + + Recent Sync Jobs + + + + + + Pipeline + Action + Status + Attempts + Created + + + + + {jobsQuery.data.map((job) => ( + + + {job.pipeline.name} + + + {job.action} + + + {job.status === "completed" && ( + + Completed + + )} + {job.status === "pending" && ( + + Pending + + )} + {job.status === "failed" && ( + + Failed + + )} + + {job.attempts}/{job.maxAttempts} + + {new Date(job.createdAt).toLocaleString()} + + + {job.status === "failed" && ( + + )} + + + ))} + +
+
+
+ )} + + {/* Import Errors */} + {importErrorsQuery.data && importErrorsQuery.data.length > 0 && ( + + + + + Git Import Errors + + + YAML import failures from webhook events. + + + +
+ {importErrorsQuery.data.map((entry) => { + const meta = entry.metadata as Record | null; + return ( +
+
+ + {(meta?.file as string) ?? "unknown file"} + + + {new Date(entry.createdAt).toLocaleString()} + +
+

+ {(meta?.error as string) ?? "Unknown error"} +

+ {Boolean(meta?.commitRef) && ( +

+ Commit: {String(meta?.commitRef).slice(0, 8)} +

+ )} +
+ ); + })} +
+
+
+ )} +
+ ); +} diff --git a/src/components/filter-preset/FilterPresetBar.tsx b/src/components/filter-preset/FilterPresetBar.tsx new file mode 100644 index 00000000..2f355b2d --- /dev/null +++ b/src/components/filter-preset/FilterPresetBar.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { Bookmark, MoreHorizontal, Star, Trash2 } from "lucide-react"; + +interface FilterPresetBarProps { + environmentId: string; + scope: "pipeline_list" | "fleet_matrix"; + currentFilters: Record; + onApplyPreset: (filters: Record) => void; + onSaveClick: () => void; +} + +export function FilterPresetBar({ + environmentId, + scope, + currentFilters, + onApplyPreset, + onSaveClick, +}: FilterPresetBarProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const presetsQuery = useQuery( + trpc.filterPreset.list.queryOptions({ environmentId, scope }) + ); + + const deleteMutation = useMutation( + trpc.filterPreset.delete.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.filterPreset.list.queryKey(), + }); + toast.success("Preset deleted"); + }, + }) + ); + + const setDefaultMutation = useMutation( + trpc.filterPreset.setDefault.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.filterPreset.list.queryKey(), + }); + toast.success("Default preset updated"); + }, + }) + ); + + const presets = presetsQuery.data ?? []; + + if (presets.length === 0) { + return ( + + ); + } + + return ( +
+ {presets.map((preset) => { + const isActive = + JSON.stringify(currentFilters) === + JSON.stringify(preset.filters); + return ( +
+ onApplyPreset(preset.filters as Record)} + > + {preset.isDefault && } + {preset.name} + + + + + + + + setDefaultMutation.mutate({ + environmentId, + id: preset.id, + scope, + }) + } + > + + Set as default + + + deleteMutation.mutate({ environmentId, id: preset.id }) + } + > + + Delete + + + +
+ ); + })} + +
+ ); +} diff --git a/src/components/filter-preset/SaveFilterDialog.tsx b/src/components/filter-preset/SaveFilterDialog.tsx new file mode 100644 index 00000000..d39a40f7 --- /dev/null +++ b/src/components/filter-preset/SaveFilterDialog.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { toast } from "sonner"; + +interface SaveFilterDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + environmentId: string; + scope: "pipeline_list" | "fleet_matrix"; + filters: Record; +} + +export function SaveFilterDialog({ + open, + onOpenChange, + environmentId, + scope, + filters, +}: SaveFilterDialogProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [name, setName] = useState(""); + const [isDefault, setIsDefault] = useState(false); + + const createMutation = useMutation( + trpc.filterPreset.create.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.filterPreset.list.queryKey(), + }); + toast.success("Filter preset saved"); + setName(""); + setIsDefault(false); + onOpenChange(false); + }, + onError: (error) => { + toast.error(error.message); + }, + }) + ); + + return ( + + + + Save filter preset + +
+
+ + setName(e.target.value)} + maxLength={50} + /> +
+
+ setIsDefault(checked === true)} + /> + +
+
+ + + + +
+
+ ); +} diff --git a/src/components/fleet/DeploymentMatrixToolbar.tsx b/src/components/fleet/DeploymentMatrixToolbar.tsx index 87722a62..03a475eb 100644 --- a/src/components/fleet/DeploymentMatrixToolbar.tsx +++ b/src/components/fleet/DeploymentMatrixToolbar.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useRef } from "react"; -import { Search, Check, ChevronsUpDown, X } from "lucide-react"; +import { Search, Check, ChevronsUpDown, X, AlertTriangle } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; @@ -31,6 +31,11 @@ export interface DeploymentMatrixToolbarProps { tagFilter: string[]; onTagFilterChange: (tags: string[]) => void; availableTags: string[]; + /** When true, only pipelines with exceptions (version mismatch, crashed, missing) are shown */ + exceptionsOnly?: boolean; + onExceptionsOnlyChange?: (value: boolean) => void; + /** Optional preset bar slot — rendered on the right side */ + presetBar?: React.ReactNode; } // --- Status chips — only Running/Stopped/Crashed (matrix has deployed pipelines only, no Draft) --- @@ -130,6 +135,9 @@ export function DeploymentMatrixToolbar({ tagFilter, onTagFilterChange, availableTags, + exceptionsOnly = false, + onExceptionsOnlyChange, + presetBar, }: DeploymentMatrixToolbarProps) { // Debounced search — local input state + 300ms debounce to parent const [localSearch, setLocalSearch] = useState(search); @@ -222,6 +230,22 @@ export function DeploymentMatrixToolbar({ /> )} + {/* Separator */} + {onExceptionsOnlyChange &&
} + + {/* Show exceptions only toggle */} + {onExceptionsOnlyChange && ( + + )} + {/* Clear all filters — only visible when any filter is active */} {hasActiveFilters && ( + ); + })} +
+ ); +} diff --git a/src/components/fleet/deployment-matrix.tsx b/src/components/fleet/deployment-matrix.tsx index 991a0d81..34b772a2 100644 --- a/src/components/fleet/deployment-matrix.tsx +++ b/src/components/fleet/deployment-matrix.tsx @@ -5,7 +5,7 @@ import { useTRPC } from "@/trpc/client"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; -import { Minus, Wrench } from "lucide-react"; +import { AlertTriangle as DriftIcon, Minus, Wrench } from "lucide-react"; import Link from "next/link"; import { StatusDot } from "@/components/ui/status-dot"; import { pipelineStatusVariant, pipelineStatusLabel } from "@/lib/status"; @@ -184,6 +184,10 @@ export function DeploymentMatrix({ > v{ps.version} + ) : (
- {Array.from({ length: 4 }).map((_, i) => ( +
+ {Array.from({ length: 5 }).map((_, i) => ( ))}
@@ -31,7 +33,7 @@ export function FleetKpiCards({ data, isLoading }: FleetKpiCardsProps) { } return ( -
+
@@ -87,6 +89,24 @@ export function FleetKpiCards({ data, isLoading }: FleetKpiCardsProps) {

+ + + +
+ + Drift +
+

+ {data?.versionDriftCount ?? 0} + + {" "}version + +

+

+ {data?.configDriftCount ?? 0} config +

+
+
); } diff --git a/src/components/fleet/node-group-health-card.tsx b/src/components/fleet/node-group-health-card.tsx index 843644e4..b934bcff 100644 --- a/src/components/fleet/node-group-health-card.tsx +++ b/src/components/fleet/node-group-health-card.tsx @@ -20,6 +20,9 @@ interface NodeGroupHealthCardProps { onlineCount: number; alertCount: number; complianceRate: number; + versionDriftCount: number; + configDriftCount: number; + overallCompliance: number; }; isExpanded: boolean; onToggle: () => void; @@ -34,12 +37,13 @@ export function NodeGroupHealthCard({ }: NodeGroupHealthCardProps) { const allOnline = group.onlineCount === group.totalNodes; const hasAlerts = group.alertCount > 0; - const fullyCompliant = group.complianceRate === 100; + const hasDrift = group.versionDriftCount > 0 || group.configDriftCount > 0; + const fullyCompliant = group.overallCompliance === 100; // Derive border class and status icon based on severity priority const borderClass = hasAlerts ? "border-l-4 border-l-destructive" - : !fullyCompliant + : hasDrift || !fullyCompliant ? "border-l-4 border-l-amber-500" : "border-l-4 border-l-green-500"; @@ -131,7 +135,7 @@ export function NodeGroupHealthCard({
- {/* Compliance metric */} + {/* Label Compliance metric */}
Compliance @@ -139,7 +143,7 @@ export function NodeGroupHealthCard({
+ +
+ + {/* Version Drift metric */} +
+ + Version Drift + + 0 + ? "text-amber-600 dark:text-amber-400" + : "text-muted-foreground", + )} + > + {group.versionDriftCount} + +
+ +
+ + {/* Overall Compliance */} +
+ + Overall + + + {group.overallCompliance}% + +
diff --git a/src/components/flow/detail-panel.tsx b/src/components/flow/detail-panel.tsx index 0f4f311e..d6aed5ef 100644 --- a/src/components/flow/detail-panel.tsx +++ b/src/components/flow/detail-panel.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useMemo } from "react"; -import { Copy, Trash2, Lock, Info, MousePointerClick, Book, Link2 as LinkIcon, Unlink, AlertTriangle, ExternalLink } from "lucide-react"; +import { Copy, Trash2, Lock, Info, MousePointerClick, Book, Link2 as LinkIcon, Unlink, AlertTriangle, ExternalLink, ChevronsLeft, ChevronsRight } from "lucide-react"; import Link from "next/link"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; @@ -127,6 +127,8 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) { const removeNode = useFlowStore((s) => s.removeNode); const acceptNodeSharedUpdate = useFlowStore((s) => s.acceptNodeSharedUpdate); const unlinkNodeStore = useFlowStore((s) => s.unlinkNode); + const detailPanelCollapsed = useFlowStore((s) => s.detailPanelCollapsed); + const toggleDetailPanel = useFlowStore((s) => s.toggleDetailPanel); const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -214,9 +216,35 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) { }, [selectedNodeId, removeNode]); if (!selectedNode) { + if (detailPanelCollapsed) { + return ( +
+ +
+ ); + } return (
-
+
+ +
+

Select a node to configure it @@ -226,6 +254,29 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) { ); } + // ---- Collapsed with node selected ---- + if (detailPanelCollapsed) { + const displayName = (selectedNode.data as { displayName?: string })?.displayName + ?? (selectedNode.data as { componentDef?: { displayName: string } })?.componentDef?.displayName + ?? "Node"; + return ( +

+ + + {displayName} + +
+ ); + } + // ---- Multi-select state ---- if (selectedNodeIds.size > 1) { return ( @@ -281,6 +332,17 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) { return (
+
+ +
Config diff --git a/src/components/flow/flow-canvas.tsx b/src/components/flow/flow-canvas.tsx index 71c745ff..5223eb68 100644 --- a/src/components/flow/flow-canvas.tsx +++ b/src/components/flow/flow-canvas.tsx @@ -6,6 +6,7 @@ import { ReactFlow, Background, Controls, + MiniMap, useReactFlow, type ReactFlowInstance, type Edge, @@ -38,6 +39,16 @@ function hasOverlappingTypes(a: DataType[], b: DataType[]): boolean { return a.some((t) => b.includes(t)); } +function minimapNodeColor(node: { data: Record }): string { + const kind = (node.data?.componentDef as { kind?: string })?.kind; + switch (kind) { + case "source": return "#10b981"; // emerald-500 + case "transform": return "#0ea5e9"; // sky-500 + case "sink": return "#f97316"; // orange-500 + default: return "#6b7280"; // gray-500 + } +} + export function FlowCanvas({ onSave, onExport, onImport }: FlowCanvasProps) { useKeyboardShortcuts({ onSave, onExport, onImport }); const params = useParams<{ id: string }>(); @@ -172,6 +183,13 @@ export function FlowCanvas({ onSave, onExport, onImport }: FlowCanvasProps) { > + {contextMenu && ( s.removeNode); const removeEdge = useFlowStore((s) => s.removeEdge); const loadGraph = useFlowStore((s) => s.loadGraph); + const autoLayout = useFlowStore((s) => s.autoLayout); + const selectedNodeIds = useFlowStore((s) => s.selectedNodeIds); + const canvasSearchTerm = useFlowStore((s) => s.canvasSearchTerm); + const canvasSearchMatchIds = useFlowStore((s) => s.canvasSearchMatchIds); + const canvasSearchActiveIndex = useFlowStore((s) => s.canvasSearchActiveIndex); + const setCanvasSearchTerm = useFlowStore((s) => s.setCanvasSearchTerm); + const cycleCanvasSearchMatch = useFlowStore((s) => s.cycleCanvasSearchMatch); + const clearCanvasSearch = useFlowStore((s) => s.clearCanvasSearch); + useCanvasSearch(); const fileInputRef = useRef(null); const [versionsOpen, setVersionsOpen] = useState(false); const [shortcutsOpen, setShortcutsOpen] = useState(false); @@ -428,6 +441,54 @@ export function FlowToolbar({ +
+ + setCanvasSearchTerm(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + cycleCanvasSearchMatch(e.shiftKey ? "prev" : "next"); + } + if (e.key === "Escape") { + e.preventDefault(); + clearCanvasSearch(); + } + }} + placeholder="Search nodes..." + className="h-7 w-[140px] pl-7 text-xs" + /> + {canvasSearchTerm && canvasSearchMatchIds.length > 0 && ( + + {canvasSearchActiveIndex + 1}/{canvasSearchMatchIds.length} + + )} + {canvasSearchTerm && canvasSearchMatchIds.length === 0 && ( + + No matches + + )} +
+ + + + + + + {selectedNodeIds.size > 1 ? "Auto-layout selected" : "Auto-layout all"} + + + )} + + {/* Density toggle */} + {density && onDensityChange && ( + <> +
+
+ + +
+ + )} + + {/* Preset bar — rendered on right side when provided */} + {presetBar && ( + <> +
+ {presetBar} + + )}
); } diff --git a/src/components/pipeline/pipeline-logs.tsx b/src/components/pipeline/pipeline-logs.tsx index 8f03b406..33730f8f 100644 --- a/src/components/pipeline/pipeline-logs.tsx +++ b/src/components/pipeline/pipeline-logs.tsx @@ -2,11 +2,20 @@ import { useEffect, useRef, useState, useCallback } from "react"; import { useInfiniteQuery } from "@tanstack/react-query"; -import { Search } from "lucide-react"; +import { Search, Clock, Download, Copy } from "lucide-react"; +import { toast } from "sonner"; import { useTRPC } from "@/trpc/client"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { highlightMatch } from "@/components/log-search-utils"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { highlightAllMatches, countMatches } from "@/components/log-search-utils"; import { formatTimeWithSeconds } from "@/lib/format"; import { usePollingInterval } from "@/hooks/use-polling-interval"; import { @@ -17,6 +26,15 @@ import type { LogLevel } from "@/generated/prisma"; const ALL_LEVELS: LogLevel[] = ["ERROR", "WARN", "INFO", "DEBUG", "TRACE"]; +const TIME_RANGES = [ + { label: "15m", value: "15", ms: 15 * 60 * 1000 }, + { label: "1h", value: "60", ms: 60 * 60 * 1000 }, + { label: "6h", value: "360", ms: 6 * 60 * 60 * 1000 }, + { label: "1d", value: "1440", ms: 24 * 60 * 60 * 1000 }, + { label: "7d", value: "10080", ms: 7 * 24 * 60 * 60 * 1000 }, + { label: "All", value: "all", ms: 0 }, +] as const; + const LEVEL_COLORS: Record = { ERROR: "text-red-400", WARN: "text-yellow-400", @@ -46,18 +64,25 @@ export function PipelineLogs({ pipelineId, nodeId }: PipelineLogsProps) { new Set(ALL_LEVELS), ); const [searchTerm, setSearchTerm] = useState(""); + const [timeRange, setTimeRange] = useState("60"); const pollingInterval = usePollingInterval(5000); const { streamedEntries } = useStreamingLogs({ pipelineId, }); + const sinceDate = timeRange !== "all" + ? new Date(Date.now() - (TIME_RANGES.find((t) => t.value === timeRange)?.ms ?? 60 * 60 * 1000)) + : undefined; + const queryInput = { pipelineId, ...(activeLevels.size < ALL_LEVELS.length ? { levels: [...activeLevels] as LogLevel[] } : {}), ...(nodeId ? { nodeId } : {}), + ...(searchTerm.length >= 3 ? { search: searchTerm } : {}), + ...(sinceDate ? { since: sinceDate } : {}), }; const logsQuery = useInfiniteQuery( @@ -105,12 +130,23 @@ export function PipelineLogs({ pipelineId, nodeId }: PipelineLogsProps) { return items; })(); + const matchCount = searchTerm + ? filteredItems.reduce((sum, log) => sum + countMatches(log.message, searchTerm), 0) + : 0; + + const virtualizer = useVirtualizer({ + count: filteredItems.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 20, + overscan: 30, + }); + // Auto-scroll to bottom when new logs arrive useEffect(() => { - if (autoScrollRef.current && scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + if (autoScrollRef.current) { + virtualizer.scrollToIndex(filteredItems.length - 1, { align: "end" }); } - }, [mergedItems.length]); + }, [filteredItems.length, virtualizer]); const handleScroll = useCallback(() => { if (!scrollRef.current) return; @@ -133,6 +169,47 @@ export function PipelineLogs({ pipelineId, nodeId }: PipelineLogsProps) { } }, [logsQuery]); + const handleExportLogs = useCallback(() => { + const lines = filteredItems.map((log) => { + const ts = log.timestamp instanceof Date ? log.timestamp : new Date(log.timestamp); + const nodeName = "node" in log ? (log.node as { name: string } | undefined)?.name : undefined; + const prefix = nodeName ? `[${nodeName}] ` : ""; + return `${ts.toISOString()} ${log.level} ${prefix}${log.message}`; + }); + const content = lines.join("\n"); + const blob = new Blob([content], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `pipeline-logs-${new Date().toISOString().slice(0, 19).replace(/:/g, "-")}.log`; + a.click(); + URL.revokeObjectURL(url); + }, [filteredItems]); + + const handleCopyLine = useCallback((log: typeof filteredItems[number]) => { + const ts = log.timestamp instanceof Date ? log.timestamp : new Date(log.timestamp); + const nodeName = "node" in log ? (log.node as { name: string } | undefined)?.name : undefined; + const prefix = nodeName ? `[${nodeName}] ` : ""; + const text = `${ts.toISOString()} ${log.level} ${prefix}${log.message}`; + navigator.clipboard.writeText(text).then( + () => toast.success("Copied to clipboard"), + () => toast.error("Failed to copy"), + ); + }, []); + + const handleCopyAll = useCallback(() => { + const lines = filteredItems.map((log) => { + const ts = log.timestamp instanceof Date ? log.timestamp : new Date(log.timestamp); + const nodeName = "node" in log ? (log.node as { name: string } | undefined)?.name : undefined; + const prefix = nodeName ? `[${nodeName}] ` : ""; + return `${ts.toISOString()} ${log.level} ${prefix}${log.message}`; + }); + navigator.clipboard.writeText(lines.join("\n")).then( + () => toast.success(`Copied ${lines.length} lines`), + () => toast.error("Failed to copy"), + ); + }, [filteredItems]); + function toggleLevel(level: LogLevel) { setActiveLevels((prev) => { const next = new Set(prev); @@ -175,10 +252,43 @@ export function PipelineLogs({ pipelineId, nodeId }: PipelineLogsProps) { className="h-6 w-[180px] pl-7 text-xs bg-transparent border-border/40" />
+ + +
{searchTerm - ? `${filteredItems.length}/${mergedItems.length} lines` + ? `${filteredItems.length}/${mergedItems.length} lines · ${matchCount} matches` : `${mergedItems.length} lines`} {logsQuery.hasNextPage && ( @@ -210,31 +320,51 @@ export function PipelineLogs({ pipelineId, nodeId }: PipelineLogsProps) { No logs yet. Logs are collected from agent heartbeats every 5 seconds.

)} - {filteredItems.map((log) => { - const ts = log.timestamp instanceof Date - ? log.timestamp - : new Date(log.timestamp); - const nodeName = "node" in log ? (log.node as { name: string } | undefined)?.name : undefined; - return ( -
- {formatTimeWithSeconds(ts)} - {" "} - - {log.level} - - {" "} - {nodeName && ( - <> - [{nodeName}] + {filteredItems.length > 0 && ( +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const log = filteredItems[virtualRow.index]; + const ts = log.timestamp instanceof Date + ? log.timestamp + : new Date(log.timestamp); + const nodeName = "node" in log ? (log.node as { name: string } | undefined)?.name : undefined; + return ( +
+ + {formatTimeWithSeconds(ts)} + {" "} + + {log.level} + {" "} - - )} - - {searchTerm ? highlightMatch(log.message, searchTerm) : log.message} - -
- ); - })} + {nodeName && ( + <> + [{nodeName}] + {" "} + + )} + + {searchTerm ? highlightAllMatches(log.message, searchTerm) : log.message} + +
+ ); + })} +
+ )}
); diff --git a/src/hooks/__tests__/use-document-visibility.test.ts b/src/hooks/__tests__/use-document-visibility.test.ts new file mode 100644 index 00000000..18405fb7 --- /dev/null +++ b/src/hooks/__tests__/use-document-visibility.test.ts @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useDocumentVisibility } from "@/hooks/use-document-visibility"; + +describe("useDocumentVisibility", () => { + const originalHidden = Object.getOwnPropertyDescriptor(document, "hidden"); + + afterEach(() => { + if (originalHidden) { + Object.defineProperty(document, "hidden", originalHidden); + } + vi.restoreAllMocks(); + }); + + it("returns true when document is not hidden", () => { + Object.defineProperty(document, "hidden", { + value: false, + writable: true, + configurable: true, + }); + const { result } = renderHook(() => useDocumentVisibility()); + expect(result.current).toBe(true); + }); + + it("returns false when document is hidden", () => { + Object.defineProperty(document, "hidden", { + value: true, + writable: true, + configurable: true, + }); + const { result } = renderHook(() => useDocumentVisibility()); + expect(result.current).toBe(false); + }); + + it("updates when visibility changes", () => { + Object.defineProperty(document, "hidden", { + value: false, + writable: true, + configurable: true, + }); + const { result } = renderHook(() => useDocumentVisibility()); + expect(result.current).toBe(true); + + act(() => { + Object.defineProperty(document, "hidden", { + value: true, + writable: true, + configurable: true, + }); + document.dispatchEvent(new Event("visibilitychange")); + }); + + expect(result.current).toBe(false); + }); +}); diff --git a/src/hooks/use-canvas-search.ts b/src/hooks/use-canvas-search.ts new file mode 100644 index 00000000..8105fb33 --- /dev/null +++ b/src/hooks/use-canvas-search.ts @@ -0,0 +1,29 @@ +"use client"; + +import { useEffect } from "react"; +import { useReactFlow } from "@xyflow/react"; +import { useFlowStore } from "@/stores/flow-store"; + +/** + * Hook that pans the canvas to center on the currently active + * canvas search match whenever the active index changes. + */ +export function useCanvasSearch(): void { + const reactFlow = useReactFlow(); + const matchIds = useFlowStore((s) => s.canvasSearchMatchIds); + const activeIndex = useFlowStore((s) => s.canvasSearchActiveIndex); + const nodes = useFlowStore((s) => s.nodes); + + useEffect(() => { + if (activeIndex < 0 || matchIds.length === 0) return; + const targetId = matchIds[activeIndex]; + const node = nodes.find((n) => n.id === targetId); + if (!node) return; + + reactFlow.setCenter( + node.position.x + (node.measured?.width ?? 200) / 2, + node.position.y + (node.measured?.height ?? 60) / 2, + { zoom: reactFlow.getZoom(), duration: 300 }, + ); + }, [activeIndex, matchIds, nodes, reactFlow]); +} diff --git a/src/hooks/use-document-visibility.ts b/src/hooks/use-document-visibility.ts new file mode 100644 index 00000000..a0acdef7 --- /dev/null +++ b/src/hooks/use-document-visibility.ts @@ -0,0 +1,24 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +function subscribe(callback: () => void): () => void { + document.addEventListener("visibilitychange", callback); + return () => document.removeEventListener("visibilitychange", callback); +} + +function getSnapshot(): boolean { + return !document.hidden; +} + +function getServerSnapshot(): boolean { + return true; // SSR always assumes visible +} + +/** + * Returns `true` when the browser tab is visible, `false` when hidden. + * Uses `useSyncExternalStore` for tear-safe integration with React 19. + */ +export function useDocumentVisibility(): boolean { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} diff --git a/src/hooks/use-pipeline-list-filters.ts b/src/hooks/use-pipeline-list-filters.ts new file mode 100644 index 00000000..8a39aeff --- /dev/null +++ b/src/hooks/use-pipeline-list-filters.ts @@ -0,0 +1,118 @@ +"use client"; +import { useCallback } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; + +export interface PipelineListFilters { + search: string; + statusFilter: string[]; + tagFilter: string[]; + groupId: string | null; + sortBy: "name" | "updatedAt" | "deployedAt"; + sortOrder: "asc" | "desc"; +} + +/** URL-synced filter state for the pipeline list page. */ +export function usePipelineListFilters(): { + search: string; + statusFilter: string[]; + tagFilter: string[]; + groupId: string | null; + sortBy: "name" | "updatedAt" | "deployedAt"; + sortOrder: "asc" | "desc"; + hasActiveFilters: boolean; + setSearch: (value: string) => void; + setStatusFilter: (statuses: string[]) => void; + setTagFilter: (tags: string[]) => void; + setGroupId: (id: string | null) => void; + setSortBy: (field: "name" | "updatedAt" | "deployedAt") => void; + setSortOrder: (order: "asc" | "desc") => void; + clearFilters: () => void; +} { + const searchParams = useSearchParams(); + const router = useRouter(); + + const search = searchParams.get("search") ?? ""; + const statusFilter = searchParams.get("status")?.split(",").filter(Boolean) ?? []; + const tagFilter = searchParams.get("tags")?.split(",").filter(Boolean) ?? []; + const groupId = searchParams.get("groupId") ?? null; + const sortBy = (searchParams.get("sortBy") as PipelineListFilters["sortBy"]) ?? "updatedAt"; + const sortOrder = (searchParams.get("sortOrder") as PipelineListFilters["sortOrder"]) ?? "desc"; + + const updateParams = useCallback( + (updater: (params: URLSearchParams) => void) => { + const params = new URLSearchParams(searchParams.toString()); + updater(params); + router.replace(`/pipelines?${params.toString()}`, { scroll: false }); + }, + [searchParams, router], + ); + + const setSearch = useCallback( + (value: string) => updateParams((p) => { + if (value) p.set("search", value); + else p.delete("search"); + }), + [updateParams], + ); + + const setStatusFilter = useCallback( + (statuses: string[]) => updateParams((p) => { + if (statuses.length > 0) p.set("status", statuses.join(",")); + else p.delete("status"); + }), + [updateParams], + ); + + const setTagFilter = useCallback( + (tags: string[]) => updateParams((p) => { + if (tags.length > 0) p.set("tags", tags.join(",")); + else p.delete("tags"); + }), + [updateParams], + ); + + const setGroupId = useCallback( + (id: string | null) => updateParams((p) => { + if (id) p.set("groupId", id); + else p.delete("groupId"); + }), + [updateParams], + ); + + const setSortBy = useCallback( + (field: PipelineListFilters["sortBy"]) => updateParams((p) => { + p.set("sortBy", field); + }), + [updateParams], + ); + + const setSortOrder = useCallback( + (order: PipelineListFilters["sortOrder"]) => updateParams((p) => { + p.set("sortOrder", order); + }), + [updateParams], + ); + + const clearFilters = useCallback( + () => updateParams((p) => { + p.delete("search"); + p.delete("status"); + p.delete("tags"); + p.delete("groupId"); + }), + [updateParams], + ); + + const hasActiveFilters = + search.length > 0 || + statusFilter.length > 0 || + tagFilter.length > 0 || + groupId !== null; + + return { + search, statusFilter, tagFilter, groupId, sortBy, sortOrder, + hasActiveFilters, + setSearch, setStatusFilter, setTagFilter, setGroupId, + setSortBy, setSortOrder, clearFilters, + }; +} diff --git a/src/hooks/use-polling-interval.ts b/src/hooks/use-polling-interval.ts index fbd007ac..d4aaf12d 100644 --- a/src/hooks/use-polling-interval.ts +++ b/src/hooks/use-polling-interval.ts @@ -1,6 +1,7 @@ "use client"; import { useSSEStore } from "@/stores/sse-store"; +import { useDocumentVisibility } from "@/hooks/use-document-visibility"; // ── Minimum polling floor (R020) ───────────────────────────────────── const MIN_POLLING_MS = 30_000; @@ -12,6 +13,7 @@ const MIN_POLLING_MS = 30_000; * * - `connected` → `false` (polling suppressed, SSE pushes updates) * - `disconnected` | `reconnecting` → `Math.max(baseInterval, 30_000)` + * - `visible === false` → `false` (pause polling when tab is hidden) * * The 30s floor ensures we don't overwhelm the server when falling back * to polling while SSE is unavailable. @@ -19,7 +21,9 @@ const MIN_POLLING_MS = 30_000; export function getPollingInterval( status: "connected" | "disconnected" | "reconnecting", baseInterval: number, + visible = true, ): number | false { + if (!visible) return false; // Pause polling when tab is hidden if (status === "connected") return false; return Math.max(baseInterval, MIN_POLLING_MS); } @@ -40,5 +44,6 @@ export function getPollingInterval( */ export function usePollingInterval(baseInterval: number): number | false { const status = useSSEStore((s) => s.status); - return getPollingInterval(status, baseInterval); + const visible = useDocumentVisibility(); + return getPollingInterval(status, baseInterval, visible); } diff --git a/src/hooks/use-sse.ts b/src/hooks/use-sse.ts index 4fb88d58..590c0d1f 100644 --- a/src/hooks/use-sse.ts +++ b/src/hooks/use-sse.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef } from "react"; import type { SSEEvent } from "@/lib/sse/types"; import { generateId } from "@/lib/utils"; import { useSSEStore } from "@/stores/sse-store"; +import { useDocumentVisibility } from "@/hooks/use-document-visibility"; // ── Constants ──────────────────────────────────────────────────────── @@ -51,6 +52,9 @@ export function useSSE() { const setStatus = useSSEStore((s) => s.setStatus); const setLastConnectedAt = useSSEStore((s) => s.setLastConnectedAt); + const visible = useDocumentVisibility(); + const eventBufferRef = useRef([]); + const visibleRef = useRef(visible); const eventSourceRef = useRef(null); const reconnectTimerRef = useRef | null>(null); const backoffRef = useRef(INITIAL_BACKOFF_MS); @@ -70,6 +74,22 @@ export function useSSE() { } }, []); + // Keep visibility ref in sync + useEffect(() => { + visibleRef.current = visible; + }, [visible]); + + // Flush buffered events when tab becomes visible + useEffect(() => { + if (visible && eventBufferRef.current.length > 0) { + const buffered = eventBufferRef.current; + eventBufferRef.current = []; + for (const event of buffered) { + dispatch(event); + } + } + }, [visible, dispatch]); + // ── Connect / reconnect ────────────────────────────────────────── const connect = useCallback(() => { @@ -111,7 +131,11 @@ export function useSSE() { es.addEventListener(eventType, ((e: MessageEvent) => { try { const parsed = JSON.parse(e.data) as SSEEvent; - dispatch(parsed); + if (visibleRef.current) { + dispatch(parsed); + } else { + eventBufferRef.current.push(parsed); + } } catch { // malformed event — drop silently } diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 50c32f36..90099ba7 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -137,6 +137,16 @@ export async function register() { } catch (error) { console.error("Failed to initialize fleet alert service:", error); } + + // Start git sync retry service. + try { + const { initGitSyncRetryService } = await import( + "@/server/services/git-sync-retry" + ); + initGitSyncRetryService(); + } catch (error) { + console.error("Failed to initialize git sync retry service:", error); + } } if (leaderIsLeader()) { diff --git a/src/lib/__tests__/auto-layout.test.ts b/src/lib/__tests__/auto-layout.test.ts new file mode 100644 index 00000000..9ccb059d --- /dev/null +++ b/src/lib/__tests__/auto-layout.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { applyAutoLayout } from "@/lib/auto-layout"; +import type { Node, Edge } from "@xyflow/react"; + +describe("applyAutoLayout", () => { + it("returns positioned nodes for a simple chain", () => { + const nodes: Node[] = [ + { id: "a", position: { x: 0, y: 0 }, data: {} }, + { id: "b", position: { x: 0, y: 0 }, data: {} }, + { id: "c", position: { x: 0, y: 0 }, data: {} }, + ]; + const edges: Edge[] = [ + { id: "e1", source: "a", target: "b" }, + { id: "e2", source: "b", target: "c" }, + ]; + + const result = applyAutoLayout(nodes, edges); + + expect(result).toHaveLength(3); + // Nodes should have different y positions (top-to-bottom layout) + const yPositions = result.map((n) => n.position.y); + expect(yPositions[0]).toBeLessThan(yPositions[1]); + expect(yPositions[1]).toBeLessThan(yPositions[2]); + }); + + it("handles empty nodes array", () => { + const result = applyAutoLayout([], []); + expect(result).toEqual([]); + }); + + it("handles disconnected nodes", () => { + const nodes: Node[] = [ + { id: "a", position: { x: 0, y: 0 }, data: {} }, + { id: "b", position: { x: 100, y: 100 }, data: {} }, + ]; + + const result = applyAutoLayout(nodes, []); + expect(result).toHaveLength(2); + // Both nodes should get positions from dagre + expect(typeof result[0].position.x).toBe("number"); + expect(typeof result[1].position.x).toBe("number"); + }); + + it("only repositions selected nodes when nodeIds is provided", () => { + const nodes: Node[] = [ + { id: "a", position: { x: 10, y: 20 }, data: {} }, + { id: "b", position: { x: 30, y: 40 }, data: {} }, + { id: "c", position: { x: 50, y: 60 }, data: {} }, + ]; + const edges: Edge[] = [ + { id: "e1", source: "a", target: "b" }, + { id: "e2", source: "b", target: "c" }, + ]; + + const result = applyAutoLayout(nodes, edges, { nodeIds: new Set(["a", "b"]) }); + + // "c" should keep its original position + const nodeC = result.find((n) => n.id === "c"); + expect(nodeC?.position).toEqual({ x: 50, y: 60 }); + + // "a" and "b" should have new positions + const nodeA = result.find((n) => n.id === "a"); + expect(nodeA?.position).not.toEqual({ x: 10, y: 20 }); + }); +}); diff --git a/src/lib/alert-metrics.ts b/src/lib/alert-metrics.ts index 4e45c002..b57d963a 100644 --- a/src/lib/alert-metrics.ts +++ b/src/lib/alert-metrics.ts @@ -16,6 +16,7 @@ export const EVENT_METRIC_VALUES = [ "certificate_expiring", "node_joined", "node_left", + "git_sync_failed", ] as const; export const EVENT_METRICS: ReadonlySet = new Set(EVENT_METRIC_VALUES); @@ -35,6 +36,7 @@ export const FLEET_METRIC_VALUES = [ "fleet_throughput_drop", "fleet_event_volume", "node_load_imbalance", + "version_drift", ] as const; export const FLEET_METRICS_SET: ReadonlySet = new Set(FLEET_METRIC_VALUES); diff --git a/src/lib/alert-templates.ts b/src/lib/alert-templates.ts index da6d75f5..7b322541 100644 --- a/src/lib/alert-templates.ts +++ b/src/lib/alert-templates.ts @@ -18,6 +18,8 @@ import { TrendingDown, Activity, Scale, + GitCompareArrows, + FileWarning, } from "lucide-react"; export interface AlertRuleTemplate { @@ -175,4 +177,30 @@ export const ALERT_RULE_TEMPLATES: AlertRuleTemplate[] = [ durationSeconds: "120", }, }, + { + id: "version-drift", + name: "Version Drift", + description: + "Alert when any pipeline has nodes running different versions from the latest deployed version.", + icon: GitCompareArrows, + defaults: { + metric: "version_drift", + condition: "gt", + threshold: "0", + durationSeconds: "0", + }, + }, + { + id: "config-drift", + name: "Config Drift", + description: + "Alert when a node's running config doesn't match the server's expected config for 60 seconds.", + icon: FileWarning, + defaults: { + metric: "config_drift", + condition: "gt", + threshold: "0", + durationSeconds: "60", + }, + }, ]; diff --git a/src/lib/auto-layout.ts b/src/lib/auto-layout.ts new file mode 100644 index 00000000..adb4aa43 --- /dev/null +++ b/src/lib/auto-layout.ts @@ -0,0 +1,70 @@ +import Dagre from "@dagrejs/dagre"; +import type { Node, Edge } from "@xyflow/react"; + +const DEFAULT_NODE_WIDTH = 200; +const DEFAULT_NODE_HEIGHT = 60; + +interface AutoLayoutOptions { + /** Only reposition these node IDs. Others keep their current position. */ + nodeIds?: Set; + rankdir?: "TB" | "LR"; + nodesep?: number; + ranksep?: number; +} + +/** + * Apply Dagre auto-layout to a set of React Flow nodes and edges. + * Returns a new array of nodes with updated positions (immutable). + */ +export function applyAutoLayout( + nodes: Node[], + edges: Edge[], + options?: AutoLayoutOptions, +): Node[] { + if (nodes.length === 0) return []; + + const { + nodeIds, + rankdir = "TB", + nodesep = 60, + ranksep = 100, + } = options ?? {}; + + const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); + g.setGraph({ rankdir, nodesep, ranksep }); + + // If subset mode, only add the selected nodes + their connecting edges + const targetNodeIds = nodeIds ?? new Set(nodes.map((n) => n.id)); + + for (const node of nodes) { + if (targetNodeIds.has(node.id)) { + g.setNode(node.id, { + width: node.measured?.width ?? DEFAULT_NODE_WIDTH, + height: node.measured?.height ?? DEFAULT_NODE_HEIGHT, + }); + } + } + + for (const edge of edges) { + if (targetNodeIds.has(edge.source) && targetNodeIds.has(edge.target)) { + g.setEdge(edge.source, edge.target); + } + } + + Dagre.layout(g); + + return nodes.map((node) => { + if (!targetNodeIds.has(node.id)) return node; + + const pos = g.node(node.id); + if (!pos) return node; + + return { + ...node, + position: { + x: pos.x - (node.measured?.width ?? DEFAULT_NODE_WIDTH) / 2, + y: pos.y - (node.measured?.height ?? DEFAULT_NODE_HEIGHT) / 2, + }, + }; + }); +} diff --git a/src/lib/sse/types.ts b/src/lib/sse/types.ts index 9c36dd8a..393c43d2 100644 --- a/src/lib/sse/types.ts +++ b/src/lib/sse/types.ts @@ -49,10 +49,19 @@ export interface PipelineStatusEvent { timestamp: number; } +/** Git sync status update for retry jobs. */ +export interface GitSyncStatusEvent { + type: "git_sync_status"; + environmentId: string; + status: "completed" | "failed"; + jobId: string; +} + /** All SSE event types the browser can receive. */ export type SSEEvent = | MetricUpdateEvent | FleetStatusEvent | LogEntryEvent | StatusChangeEvent - | PipelineStatusEvent; + | PipelineStatusEvent + | GitSyncStatusEvent; diff --git a/src/server/middleware/api-auth.ts b/src/server/middleware/api-auth.ts index 1592b7eb..04e9633e 100644 --- a/src/server/middleware/api-auth.ts +++ b/src/server/middleware/api-auth.ts @@ -6,6 +6,7 @@ export interface ServiceAccountContext { serviceAccountName: string; environmentId: string; permissions: string[]; + rateLimit: number | null; } export async function authenticateApiKey( @@ -33,6 +34,7 @@ export async function authenticateApiKey( serviceAccountName: sa.name, environmentId: sa.environmentId, permissions: sa.permissions as string[], + rateLimit: sa.rateLimit ?? null, }; } @@ -42,3 +44,27 @@ export function hasPermission( ): boolean { return ctx.permissions.includes(permission); } + +/** All valid service account permission strings. */ +export const VALID_PERMISSIONS = [ + // Existing + "pipelines.read", + "pipelines.deploy", + "nodes.read", + "nodes.manage", + "secrets.read", + "secrets.manage", + "alerts.read", + "alerts.manage", + "audit.read", + // New (API v1 completeness) + "pipelines.write", + "pipelines.promote", + "metrics.read", + "deploy-requests.manage", + "node-groups.read", + "node-groups.manage", + "environments.read", +] as const; + +export type Permission = (typeof VALID_PERMISSIONS)[number]; diff --git a/src/server/routers/__tests__/alert-retry-delivery.test.ts b/src/server/routers/__tests__/alert-retry-delivery.test.ts new file mode 100644 index 00000000..c97ff791 --- /dev/null +++ b/src/server/routers/__tests__/alert-retry-delivery.test.ts @@ -0,0 +1,243 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; +import type { PrismaClient } from "@/generated/prisma"; +import { TRPCError } from "@trpc/server"; + +// ─── vi.hoisted so mock fns and `t` are available inside vi.mock factories ── + +const { + t, + mockTrackWebhookDelivery, + mockTrackChannelDelivery, + mockGetNextRetryAt, + mockDeliverSingleWebhook, + mockFormatWebhookMessage, + mockChannelDeliver, +} = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { initTRPC } = require("@trpc/server"); + const t = initTRPC.context().create(); + return { + t, + mockTrackWebhookDelivery: vi.fn().mockResolvedValue({ success: true }), + mockTrackChannelDelivery: vi.fn().mockResolvedValue({ success: true }), + mockGetNextRetryAt: vi.fn().mockReturnValue(new Date()), + mockDeliverSingleWebhook: vi.fn().mockResolvedValue({ success: true }), + mockFormatWebhookMessage: vi.fn().mockReturnValue("test"), + mockChannelDeliver: vi.fn().mockResolvedValue({ success: true }), + }; +}); + +vi.mock("@/trpc/init", () => { + const passthrough = () => + t.middleware(({ next, ctx }: { next: (opts: { ctx: unknown }) => unknown; ctx: unknown }) => next({ ctx })); + return { + router: t.router, + protectedProcedure: t.procedure, + withTeamAccess: passthrough, + middleware: t.middleware, + }; +}); + +vi.mock("@/server/middleware/audit", () => ({ + withAudit: () => + t.middleware(({ next, ctx }: { next: (opts: { ctx: unknown }) => unknown; ctx: unknown }) => next({ ctx })), +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: mockDeep(), +})); + +vi.mock("@/server/services/delivery-tracking", () => ({ + trackWebhookDelivery: mockTrackWebhookDelivery, + trackChannelDelivery: mockTrackChannelDelivery, + getNextRetryAt: mockGetNextRetryAt, +})); + +vi.mock("@/server/services/webhook-delivery", () => ({ + deliverSingleWebhook: mockDeliverSingleWebhook, + formatWebhookMessage: mockFormatWebhookMessage, +})); + +vi.mock("@/server/services/channels", () => ({ + getDriver: vi.fn().mockReturnValue({ + deliver: mockChannelDeliver, + }), +})); + +vi.mock("@/server/services/url-validation", () => ({ + validatePublicUrl: vi.fn().mockResolvedValue(undefined), + validateSmtpHost: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/server/services/event-alerts", () => ({ + isEventMetric: vi.fn().mockReturnValue(false), +})); + +vi.mock("@/server/services/alert-evaluator", () => ({ + FLEET_METRICS: [], +})); + +// ─── Import SUT + mocks after vi.mock ─────────────────────────────────────── + +import { prisma } from "@/lib/prisma"; +import { alertRouter } from "@/server/routers/alert"; + +const prismaMock = prisma as unknown as DeepMockProxy; +const caller = t.createCallerFactory(alertRouter)({ + session: { user: { id: "user-1" } }, +}); + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function makeDeliveryAttempt(overrides: Record = {}) { + return { + id: "da-1", + status: "failed", + alertEventId: "ae-1", + webhookId: "wh-1", + channelId: null, + channelType: "webhook", + channelName: "Test Webhook", + attemptNumber: 1, + statusCode: 500, + errorMessage: "Connection refused", + requestedAt: new Date(), + completedAt: new Date(), + nextRetryAt: null, + alertEvent: { + id: "ae-1", + status: "firing", + value: 95, + message: "CPU threshold exceeded", + firedAt: new Date(), + alertRule: { + name: "High CPU", + metric: "cpu_usage", + threshold: 90, + environment: { + name: "production", + team: { name: "Platform" }, + }, + pipeline: { name: "metrics-pipeline" }, + }, + node: { host: "node-1" }, + }, + ...overrides, + }; +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +describe("alert.retryDelivery", () => { + beforeEach(() => { + mockReset(prismaMock); + vi.clearAllMocks(); + }); + + it("should throw NOT_FOUND if delivery attempt does not exist", async () => { + prismaMock.deliveryAttempt.findUnique.mockResolvedValue(null); + + await expect( + caller.retryDelivery({ deliveryAttemptId: "da-missing" }), + ).rejects.toThrow(TRPCError); + + await expect( + caller.retryDelivery({ deliveryAttemptId: "da-missing" }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("should throw BAD_REQUEST if delivery is not in failed status", async () => { + prismaMock.deliveryAttempt.findUnique.mockResolvedValue( + makeDeliveryAttempt({ status: "success" }) as never, + ); + + await expect( + caller.retryDelivery({ deliveryAttemptId: "da-1" }), + ).rejects.toThrow(TRPCError); + + await expect( + caller.retryDelivery({ deliveryAttemptId: "da-1" }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("should retry a failed webhook delivery", async () => { + prismaMock.deliveryAttempt.findUnique.mockResolvedValue( + makeDeliveryAttempt() as never, + ); + prismaMock.alertWebhook.findUnique.mockResolvedValue({ + id: "wh-1", + url: "https://hooks.example.com/alert", + teamId: "team-1", + name: "Test Webhook", + encryptedSecret: null, + headers: null, + createdAt: new Date(), + updatedAt: new Date(), + } as never); + + const result = await caller.retryDelivery({ deliveryAttemptId: "da-1" }); + + expect(result).toEqual({ success: true }); + expect(mockTrackWebhookDelivery).toHaveBeenCalledWith( + "ae-1", + "wh-1", + "https://hooks.example.com/alert", + expect.any(Function), + 2, + ); + }); + + it("should retry a failed channel delivery", async () => { + prismaMock.deliveryAttempt.findUnique.mockResolvedValue( + makeDeliveryAttempt({ + webhookId: null, + channelId: "ch-1", + channelType: "slack", + channelName: "Test Channel", + }) as never, + ); + prismaMock.notificationChannel.findUnique.mockResolvedValue({ + id: "ch-1", + teamId: "team-1", + name: "Test Channel", + type: "slack", + config: { webhookUrl: "https://hooks.slack.com/test" }, + createdAt: new Date(), + updatedAt: new Date(), + } as never); + + const result = await caller.retryDelivery({ deliveryAttemptId: "da-1" }); + + expect(result).toEqual({ success: true }); + expect(mockTrackChannelDelivery).toHaveBeenCalledWith( + "ae-1", + "ch-1", + "slack", + "Test Channel", + expect.any(Function), + 2, + ); + }); + + it("should throw NOT_FOUND if webhook target is not found", async () => { + prismaMock.deliveryAttempt.findUnique.mockResolvedValue( + makeDeliveryAttempt() as never, + ); + prismaMock.alertWebhook.findUnique.mockResolvedValue(null); + + await expect( + caller.retryDelivery({ deliveryAttemptId: "da-1" }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("should throw BAD_REQUEST if delivery has no target webhook or channel", async () => { + prismaMock.deliveryAttempt.findUnique.mockResolvedValue( + makeDeliveryAttempt({ webhookId: null, channelId: null }) as never, + ); + + await expect( + caller.retryDelivery({ deliveryAttemptId: "da-1" }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); +}); diff --git a/src/server/routers/__tests__/filter-preset.test.ts b/src/server/routers/__tests__/filter-preset.test.ts new file mode 100644 index 00000000..a6c6e4f3 --- /dev/null +++ b/src/server/routers/__tests__/filter-preset.test.ts @@ -0,0 +1,183 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; +import type { PrismaClient } from "@/generated/prisma"; + +const { t } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { initTRPC } = require("@trpc/server"); + const t = initTRPC.context().create(); + return { t }; +}); + +vi.mock("@/trpc/init", () => { + const passthrough = () => + t.middleware(({ next, ctx }: { next: (opts: { ctx: unknown }) => unknown; ctx: unknown }) => next({ ctx })); + return { + router: t.router, + protectedProcedure: t.procedure, + withTeamAccess: passthrough, + middleware: t.middleware, + }; +}); + +vi.mock("@/server/middleware/audit", () => ({ + withAudit: () => + t.middleware(({ next, ctx }: { next: (opts: { ctx: unknown }) => unknown; ctx: unknown }) => next({ ctx })), +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: mockDeep(), +})); + +import { prisma } from "@/lib/prisma"; +import { filterPresetRouter } from "@/server/routers/filter-preset"; + +const prismaMock = prisma as unknown as DeepMockProxy; +const caller = t.createCallerFactory(filterPresetRouter)({ + session: { user: { id: "user-1" } }, +}); + +const NOW = new Date("2026-03-01T12:00:00Z"); + +function makePreset(overrides: Partial<{ + id: string; + name: string; + scope: string; + isDefault: boolean; + filters: Record; +}> = {}) { + return { + id: overrides.id ?? "preset-1", + name: overrides.name ?? "My Filter", + environmentId: "env-1", + scope: overrides.scope ?? "pipeline_list", + filters: overrides.filters ?? { search: "nginx" }, + isDefault: overrides.isDefault ?? false, + createdById: "user-1", + createdAt: NOW, + updatedAt: NOW, + }; +} + +describe("filterPreset router", () => { + beforeEach(() => { + mockReset(prismaMock); + }); + + describe("list", () => { + it("returns presets for environment and scope", async () => { + const presets = [makePreset()]; + prismaMock.filterPreset.findMany.mockResolvedValueOnce(presets as never); + + const result = await caller.list({ + environmentId: "env-1", + scope: "pipeline_list", + }); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe("My Filter"); + }); + }); + + describe("create", () => { + it("creates a new preset", async () => { + prismaMock.filterPreset.count.mockResolvedValueOnce(0); + prismaMock.filterPreset.create.mockResolvedValueOnce(makePreset() as never); + + const result = await caller.create({ + environmentId: "env-1", + name: "My Filter", + scope: "pipeline_list", + filters: { search: "nginx" }, + }); + + expect(result.name).toBe("My Filter"); + expect(prismaMock.filterPreset.create).toHaveBeenCalledOnce(); + }); + + it("rejects when 20 presets already exist", async () => { + prismaMock.filterPreset.count.mockResolvedValueOnce(20); + + await expect( + caller.create({ + environmentId: "env-1", + name: "One Too Many", + scope: "pipeline_list", + filters: {}, + }) + ).rejects.toThrow(); + }); + }); + + describe("update", () => { + it("updates name and filters", async () => { + prismaMock.filterPreset.findUnique.mockResolvedValueOnce(makePreset() as never); + prismaMock.filterPreset.update.mockResolvedValueOnce( + makePreset({ name: "Updated" }) as never + ); + + const result = await caller.update({ + environmentId: "env-1", + id: "preset-1", + name: "Updated", + }); + + expect(result.name).toBe("Updated"); + }); + + it("rejects if preset not found", async () => { + prismaMock.filterPreset.findUnique.mockResolvedValueOnce(null); + + await expect( + caller.update({ + environmentId: "env-1", + id: "missing", + name: "Ghost", + }) + ).rejects.toThrow(); + }); + }); + + describe("delete", () => { + it("deletes a preset", async () => { + prismaMock.filterPreset.findUnique.mockResolvedValueOnce(makePreset() as never); + prismaMock.filterPreset.delete.mockResolvedValueOnce(makePreset() as never); + + const result = await caller.delete({ + environmentId: "env-1", + id: "preset-1", + }); + + expect(result).toEqual({ deleted: true }); + }); + }); + + describe("setDefault", () => { + it("clears existing default and sets new one inside a transaction", async () => { + prismaMock.filterPreset.findUnique.mockResolvedValueOnce(makePreset() as never); + + // $transaction receives a callback — execute it with the same mock so + // tx.filterPreset.updateMany / tx.filterPreset.update resolve correctly. + prismaMock.$transaction.mockImplementationOnce(async (fn: (tx: typeof prismaMock) => Promise) => { + return fn(prismaMock); + }); + prismaMock.filterPreset.updateMany.mockResolvedValueOnce({ count: 1 } as never); + prismaMock.filterPreset.update.mockResolvedValueOnce( + makePreset({ isDefault: true }) as never + ); + + const result = await caller.setDefault({ + environmentId: "env-1", + id: "preset-1", + scope: "pipeline_list", + }); + + expect(result.isDefault).toBe(true); + expect(prismaMock.$transaction).toHaveBeenCalledOnce(); + expect(prismaMock.filterPreset.updateMany).toHaveBeenCalledWith({ + where: { environmentId: "env-1", scope: "pipeline_list", isDefault: true }, + data: { isDefault: false }, + }); + }); + }); +}); diff --git a/src/server/routers/__tests__/fleet-matrix-summary-scale.test.ts b/src/server/routers/__tests__/fleet-matrix-summary-scale.test.ts new file mode 100644 index 00000000..5d5ed79e --- /dev/null +++ b/src/server/routers/__tests__/fleet-matrix-summary-scale.test.ts @@ -0,0 +1,106 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; +import type { PrismaClient } from "@/generated/prisma"; + +const { t } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { initTRPC } = require("@trpc/server"); + const t = initTRPC.context().create(); + return { t }; +}); + +vi.mock("@/trpc/init", () => { + const passthrough = () => + t.middleware(({ next, ctx }: { next: (opts: { ctx: unknown }) => unknown; ctx: unknown }) => next({ ctx })); + return { + router: t.router, + protectedProcedure: t.procedure, + withTeamAccess: passthrough, + middleware: t.middleware, + }; +}); + +vi.mock("@/server/middleware/audit", () => ({ + withAudit: () => + t.middleware(({ next, ctx }: { next: (opts: { ctx: unknown }) => unknown; ctx: unknown }) => next({ ctx })), +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: mockDeep(), +})); + +vi.mock("@/server/services/push-registry", () => ({ + pushRegistry: { isConnected: vi.fn(() => false), notify: vi.fn() }, +})); + +vi.mock("@/server/services/version-check", () => ({ + checkDevAgentVersion: vi.fn(), +})); + +vi.mock("@/server/services/fleet-data", () => ({ + getFleetOverview: vi.fn(), + getVolumeTrend: vi.fn(), + getNodeThroughput: vi.fn(), + getNodeCapacity: vi.fn(), + getDataLoss: vi.fn(), + getMatrixThroughput: vi.fn(), +})); + +import { prisma } from "@/lib/prisma"; +import { fleetRouter } from "@/server/routers/fleet"; + +const prismaMock = prisma as unknown as DeepMockProxy; +const caller = t.createCallerFactory(fleetRouter)({ + session: { user: { id: "user-1" } }, +}); + +describe("fleet.matrixSummary — scale test", () => { + beforeEach(() => { + mockReset(prismaMock); + }); + + it("handles 200 pipelines x 10 nodes within 500ms", async () => { + const PIPELINE_COUNT = 200; + const NODE_COUNT = 10; + + // Build 10 nodes each with 200 pipeline statuses + const nodes = Array.from({ length: NODE_COUNT }, (_, nodeIdx) => ({ + id: `node-${nodeIdx}`, + name: `node-${nodeIdx}`, + host: `10.0.0.${nodeIdx}`, + apiPort: 8686, + status: "HEALTHY", + maintenanceMode: false, + labels: {}, + pipelineStatuses: Array.from({ length: PIPELINE_COUNT }, (_, pipeIdx) => ({ + pipelineId: `pipe-${pipeIdx}`, + status: pipeIdx % 20 === 0 ? "CRASHED" : "RUNNING", + version: pipeIdx % 10 === 0 ? 1 : 2, + pipeline: { id: `pipe-${pipeIdx}`, name: `pipeline-${pipeIdx}` }, + })), + })); + + prismaMock.vectorNode.findMany.mockResolvedValueOnce(nodes as never); + + const deployedPipelines = Array.from({ length: PIPELINE_COUNT }, (_, i) => ({ + id: `pipe-${i}`, + versions: [{ version: 2 }], + })); + prismaMock.pipeline.findMany.mockResolvedValueOnce(deployedPipelines as never); + + const start = performance.now(); + const result = await caller.matrixSummary({ environmentId: "env-1" }); + const elapsed = performance.now() - start; + + expect(result).toHaveLength(NODE_COUNT); + expect(elapsed).toBeLessThan(500); + + // Verify aggregates for first node + const firstNode = result[0]; + expect(firstNode.pipelineCount).toBe(PIPELINE_COUNT); + // Crashed: every 20th pipeline (indices 0, 20, 40, ...) = 10 pipelines + expect(firstNode.errorCount).toBe(PIPELINE_COUNT / 20); + // Version drift: every 10th pipeline has version 1 but latest is 2 = 20 pipelines + expect(firstNode.versionDriftCount).toBe(PIPELINE_COUNT / 10); + }); +}); diff --git a/src/server/routers/__tests__/fleet-matrix-summary.test.ts b/src/server/routers/__tests__/fleet-matrix-summary.test.ts new file mode 100644 index 00000000..e801c379 --- /dev/null +++ b/src/server/routers/__tests__/fleet-matrix-summary.test.ts @@ -0,0 +1,117 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; +import type { PrismaClient } from "@/generated/prisma"; + +const { t } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { initTRPC } = require("@trpc/server"); + const t = initTRPC.context().create(); + return { t }; +}); + +vi.mock("@/trpc/init", () => { + const passthrough = () => + t.middleware(({ next, ctx }: { next: (opts: { ctx: unknown }) => unknown; ctx: unknown }) => next({ ctx })); + return { + router: t.router, + protectedProcedure: t.procedure, + withTeamAccess: passthrough, + middleware: t.middleware, + }; +}); + +vi.mock("@/server/middleware/audit", () => ({ + withAudit: () => + t.middleware(({ next, ctx }: { next: (opts: { ctx: unknown }) => unknown; ctx: unknown }) => next({ ctx })), +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: mockDeep(), +})); + +vi.mock("@/server/services/push-registry", () => ({ + pushRegistry: { isConnected: vi.fn(() => false), notify: vi.fn() }, +})); + +vi.mock("@/server/services/version-check", () => ({ + checkDevAgentVersion: vi.fn(), +})); + +vi.mock("@/server/services/fleet-data", () => ({ + getFleetOverview: vi.fn(), + getVolumeTrend: vi.fn(), + getNodeThroughput: vi.fn(), + getNodeCapacity: vi.fn(), + getDataLoss: vi.fn(), + getMatrixThroughput: vi.fn(), +})); + +import { prisma } from "@/lib/prisma"; +import { fleetRouter } from "@/server/routers/fleet"; + +const prismaMock = prisma as unknown as DeepMockProxy; +const caller = t.createCallerFactory(fleetRouter)({ + session: { user: { id: "user-1" } }, +}); + +describe("fleet.matrixSummary", () => { + beforeEach(() => { + mockReset(prismaMock); + }); + + it("returns per-node aggregate summary", async () => { + prismaMock.vectorNode.findMany.mockResolvedValueOnce([ + { + id: "node-1", + name: "node-alpha", + host: "10.0.0.1", + apiPort: 8686, + status: "HEALTHY", + maintenanceMode: false, + labels: {}, + pipelineStatuses: [ + { + pipelineId: "pipe-1", + status: "RUNNING", + version: 2, + pipeline: { id: "pipe-1", name: "logs" }, + }, + { + pipelineId: "pipe-2", + status: "CRASHED", + version: 1, + pipeline: { id: "pipe-2", name: "metrics" }, + }, + ], + }, + ] as never); + + prismaMock.pipeline.findMany.mockResolvedValueOnce([ + { id: "pipe-1", versions: [{ version: 2 }] }, + { id: "pipe-2", versions: [{ version: 3 }] }, + ] as never); + + const result = await caller.matrixSummary({ + environmentId: "env-1", + }); + + expect(result).toHaveLength(1); + expect(result[0].nodeId).toBe("node-1"); + expect(result[0].nodeName).toBe("node-alpha"); + expect(result[0].pipelineCount).toBe(2); + expect(result[0].errorCount).toBe(1); // pipe-2 CRASHED + expect(result[0].versionDriftCount).toBe(1); // pipe-2 deployed v1 but latest is v3 + expect(result[0].status).toBe("HEALTHY"); + }); + + it("returns empty array when no nodes", async () => { + prismaMock.vectorNode.findMany.mockResolvedValueOnce([] as never); + prismaMock.pipeline.findMany.mockResolvedValueOnce([] as never); + + const result = await caller.matrixSummary({ + environmentId: "env-1", + }); + + expect(result).toEqual([]); + }); +}); diff --git a/src/server/routers/__tests__/node-group.test.ts b/src/server/routers/__tests__/node-group.test.ts index caa5c5bc..c81c84f6 100644 --- a/src/server/routers/__tests__/node-group.test.ts +++ b/src/server/routers/__tests__/node-group.test.ts @@ -267,6 +267,12 @@ describe("nodeGroupRouter", () => { // ── groupHealthStats ───────────────────────────────────────────────────── describe("groupHealthStats", () => { + beforeEach(() => { + // Mock the drift-related queries added for version/config drift detection + prismaMock.nodePipelineStatus.findMany.mockResolvedValue([]); + prismaMock.pipeline.findMany.mockResolvedValue([]); + }); + it("Test 1: Returns per-group stats (onlineCount, alertCount, complianceRate, totalNodes) for two groups", async () => { const groups = [ makeNodeGroup({ id: "ng-1", name: "US East", criteria: { region: "us-east" }, requiredLabels: ["region"] }), diff --git a/src/server/routers/alert.ts b/src/server/routers/alert.ts index ad18d611..b39c4e68 100644 --- a/src/server/routers/alert.ts +++ b/src/server/routers/alert.ts @@ -800,6 +800,181 @@ export const alertRouter = router({ }); }), + retryDelivery: protectedProcedure + .input(z.object({ deliveryAttemptId: z.string() })) + .use(withTeamAccess("EDITOR")) + .use(withAudit("alert.retryDelivery", "DeliveryAttempt")) + .mutation(async ({ input }) => { + const attempt = await prisma.deliveryAttempt.findUnique({ + where: { id: input.deliveryAttemptId }, + include: { + alertEvent: { + include: { + alertRule: { + include: { + environment: { select: { name: true, team: { select: { name: true } } } }, + pipeline: { select: { name: true } }, + }, + }, + node: { select: { host: true } }, + }, + }, + }, + }); + + if (!attempt) { + throw new TRPCError({ code: "NOT_FOUND", message: "Delivery attempt not found" }); + } + + if (attempt.status !== "failed") { + throw new TRPCError({ code: "BAD_REQUEST", message: "Only failed deliveries can be retried" }); + } + + const event = attempt.alertEvent; + if (!event?.alertRule) { + throw new TRPCError({ code: "NOT_FOUND", message: "Associated alert event or rule not found" }); + } + + const rule = event.alertRule; + const payload: WebhookPayload = { + alertId: event.id, + status: event.status === "resolved" ? "resolved" : "firing", + ruleName: rule.name, + severity: "warning", + environment: rule.environment.name, + team: rule.environment.team?.name, + node: event.node?.host ?? undefined, + pipeline: rule.pipeline?.name ?? undefined, + metric: rule.metric, + value: event.value, + threshold: rule.threshold ?? 0, + message: event.message ?? "", + timestamp: event.firedAt.toISOString(), + dashboardUrl: `${process.env.NEXTAUTH_URL ?? ""}/alerts`, + }; + + const nextAttemptNumber = attempt.attemptNumber + 1; + + if (attempt.webhookId) { + const webhook = await prisma.alertWebhook.findUnique({ where: { id: attempt.webhookId } }); + if (!webhook) { + throw new TRPCError({ code: "NOT_FOUND", message: "Webhook not found" }); + } + const { trackWebhookDelivery } = await import("@/server/services/delivery-tracking"); + const { deliverSingleWebhook } = await import("@/server/services/webhook-delivery"); + await trackWebhookDelivery( + event.id, + webhook.id, + webhook.url, + () => deliverSingleWebhook(webhook, payload), + nextAttemptNumber, + ); + } else if (attempt.channelId) { + const channel = await prisma.notificationChannel.findUnique({ where: { id: attempt.channelId } }); + if (!channel) { + throw new TRPCError({ code: "NOT_FOUND", message: "Notification channel not found" }); + } + const { trackChannelDelivery } = await import("@/server/services/delivery-tracking"); + const channelDriver = getDriver(channel.type); + await trackChannelDelivery( + event.id, + channel.id, + channel.type, + channel.name, + async () => { + const result = await channelDriver.deliver(channel.config as Record, payload); + return { success: result.success, error: result.error }; + }, + nextAttemptNumber, + ); + } else { + throw new TRPCError({ code: "BAD_REQUEST", message: "Delivery attempt has no target webhook or channel" }); + } + + return { success: true }; + }), + + // ─── Failed Deliveries ──────────────────────────────────────────── + + listFailedDeliveries: protectedProcedure + .input( + z.object({ + environmentId: z.string(), + limit: z.number().min(1).max(100).default(50), + }), + ) + .use(withTeamAccess("VIEWER")) + .query(async ({ input }) => { + return prisma.deliveryAttempt.findMany({ + where: { + status: "failed", + alertEvent: { + alertRule: { + environmentId: input.environmentId, + }, + }, + }, + select: { + id: true, + channelType: true, + channelName: true, + status: true, + statusCode: true, + errorMessage: true, + requestedAt: true, + completedAt: true, + attemptNumber: true, + alertEventId: true, + alertEvent: { + select: { + alertRule: { + select: { name: true }, + }, + }, + }, + }, + orderBy: { requestedAt: "desc" }, + take: input.limit, + }); + }), + + retryAllForChannel: protectedProcedure + .input( + z.object({ + channelName: z.string(), + channelType: z.string(), + environmentId: z.string(), + }), + ) + .use(withTeamAccess("EDITOR")) + .use(withAudit("alert.retryAllForChannel", "DeliveryAttempt")) + .mutation(async ({ input }) => { + const failedAttempts = await prisma.deliveryAttempt.findMany({ + where: { + status: "failed", + channelName: input.channelName, + channelType: input.channelType, + alertEvent: { + alertRule: { + environmentId: input.environmentId, + }, + }, + }, + select: { id: true }, + take: 50, + }); + + const ids = failedAttempts.map((a) => a.id); + if (ids.length > 0) { + await prisma.deliveryAttempt.updateMany({ + where: { id: { in: ids } }, + data: { nextRetryAt: new Date() }, + }); + } + + return { retriedCount: ids.length, totalFailed: failedAttempts.length }; + }), + // ─── Alert Events ────────────────────────────────────────────────── listEvents: protectedProcedure diff --git a/src/server/routers/environment.ts b/src/server/routers/environment.ts index 8b41e324..a4b1a0a3 100644 --- a/src/server/routers/environment.ts +++ b/src/server/routers/environment.ts @@ -19,7 +19,14 @@ export const environmentRouter = router({ name: true, teamId: true, createdAt: true, - _count: { select: { nodes: true, pipelines: true } }, + gitOpsMode: true, + _count: { + select: { + nodes: true, + pipelines: true, + gitSyncJobs: { where: { status: "failed" } }, + }, + }, }, orderBy: { createdAt: "desc" }, }); @@ -104,13 +111,14 @@ export const environmentRouter = router({ gitBranch: z.string().min(1).max(100).optional().nullable(), gitToken: z.string().optional().nullable(), gitOpsMode: z.enum(["off", "push", "bidirectional", "promotion"]).optional(), + gitProvider: z.enum(["github", "gitlab", "bitbucket"]).nullable().optional(), requireDeployApproval: z.boolean().optional(), }) ) .use(withTeamAccess("EDITOR")) .use(withAudit("environment.updated", "Environment")) .mutation(async ({ input, ctx }) => { - const { id, gitToken, requireDeployApproval, ...rest } = input; + const { id, gitToken, gitProvider, requireDeployApproval, ...rest } = input; // Only ADMINs can toggle the approval requirement const userRole = (ctx as Record).userRole as string; @@ -146,6 +154,9 @@ export const environmentRouter = router({ if (gitToken !== undefined) { data.gitToken = gitToken ? encrypt(gitToken) : null; } + if (gitProvider !== undefined) { + data.gitProvider = gitProvider; + } // Handle gitOpsMode — auto-generate webhook secret when switching to bidirectional or promotion let plaintextWebhookSecret: string | null = null; diff --git a/src/server/routers/filter-preset.ts b/src/server/routers/filter-preset.ts new file mode 100644 index 00000000..81577860 --- /dev/null +++ b/src/server/routers/filter-preset.ts @@ -0,0 +1,192 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { router, protectedProcedure, withTeamAccess } from "@/trpc/init"; +import { withAudit } from "@/server/middleware/audit"; +import { prisma } from "@/lib/prisma"; +import type { Prisma } from "@/generated/prisma"; + +const MAX_PRESETS_PER_SCOPE = 20; + +const scopeSchema = z.enum(["pipeline_list", "fleet_matrix"]); + +export const filterPresetRouter = router({ + list: protectedProcedure + .input( + z.object({ + environmentId: z.string(), + scope: scopeSchema, + }) + ) + .use(withTeamAccess("VIEWER")) + .query(async ({ input }) => { + return prisma.filterPreset.findMany({ + where: { + environmentId: input.environmentId, + scope: input.scope, + }, + orderBy: { createdAt: "asc" }, + }); + }), + + create: protectedProcedure + .input( + z.object({ + environmentId: z.string(), + name: z.string().min(1).max(50), + scope: scopeSchema, + filters: z.record(z.string(), z.unknown()), + isDefault: z.boolean().default(false), + }) + ) + .use(withTeamAccess("EDITOR")) + .use(withAudit("filterPreset.create", "FilterPreset")) + .mutation(async ({ input, ctx }) => { + const userId = ctx.session.user!.id!; + + const count = await prisma.filterPreset.count({ + where: { + environmentId: input.environmentId, + scope: input.scope, + }, + }); + + if (count >= MAX_PRESETS_PER_SCOPE) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Maximum ${MAX_PRESETS_PER_SCOPE} presets per scope reached`, + }); + } + + // If setting as default, clear existing default first + if (input.isDefault) { + await prisma.filterPreset.updateMany({ + where: { + environmentId: input.environmentId, + scope: input.scope, + isDefault: true, + }, + data: { isDefault: false }, + }); + } + + return prisma.filterPreset.create({ + data: { + name: input.name, + environmentId: input.environmentId, + scope: input.scope, + filters: input.filters as Prisma.InputJsonValue, + isDefault: input.isDefault, + createdById: userId, + }, + }); + }), + + update: protectedProcedure + .input( + z.object({ + environmentId: z.string(), + id: z.string(), + name: z.string().min(1).max(50).optional(), + filters: z.record(z.string(), z.unknown()).optional(), + }) + ) + .use(withTeamAccess("EDITOR")) + .use(withAudit("filterPreset.update", "FilterPreset")) + .mutation(async ({ input }) => { + const existing = await prisma.filterPreset.findUnique({ + where: { id: input.id }, + }); + + if (!existing || existing.environmentId !== input.environmentId) { + throw new TRPCError({ code: "NOT_FOUND", message: "Filter preset not found" }); + } + + const { id, environmentId: _envId, filters, ...rest } = input; + const data = { + ...rest, + ...(filters !== undefined ? { filters: filters as Prisma.InputJsonValue } : {}), + }; + return prisma.filterPreset.update({ where: { id }, data }); + }), + + delete: protectedProcedure + .input( + z.object({ + environmentId: z.string(), + id: z.string(), + }) + ) + .use(withTeamAccess("EDITOR")) + .use(withAudit("filterPreset.delete", "FilterPreset")) + .mutation(async ({ input }) => { + const existing = await prisma.filterPreset.findUnique({ + where: { id: input.id }, + }); + + if (!existing || existing.environmentId !== input.environmentId) { + throw new TRPCError({ code: "NOT_FOUND", message: "Filter preset not found" }); + } + + await prisma.filterPreset.delete({ where: { id: input.id } }); + return { deleted: true }; + }), + + setDefault: protectedProcedure + .input( + z.object({ + environmentId: z.string(), + id: z.string(), + scope: scopeSchema, + }) + ) + .use(withTeamAccess("EDITOR")) + .use(withAudit("filterPreset.setDefault", "FilterPreset")) + .mutation(async ({ input }) => { + const existing = await prisma.filterPreset.findUnique({ + where: { id: input.id }, + }); + + if (!existing || existing.environmentId !== input.environmentId) { + throw new TRPCError({ code: "NOT_FOUND", message: "Filter preset not found" }); + } + + // Wrap in transaction to prevent race conditions with concurrent default-setting + return prisma.$transaction(async (tx) => { + // Clear existing default for this scope + await tx.filterPreset.updateMany({ + where: { + environmentId: input.environmentId, + scope: input.scope, + isDefault: true, + }, + data: { isDefault: false }, + }); + + return tx.filterPreset.update({ + where: { id: input.id }, + data: { isDefault: true }, + }); + }); + }), + + clearDefault: protectedProcedure + .input( + z.object({ + environmentId: z.string(), + scope: scopeSchema, + }) + ) + .use(withTeamAccess("EDITOR")) + .use(withAudit("filterPreset.clearDefault", "FilterPreset")) + .mutation(async ({ input }) => { + await prisma.filterPreset.updateMany({ + where: { + environmentId: input.environmentId, + scope: input.scope, + isDefault: true, + }, + data: { isDefault: false }, + }); + return { cleared: true }; + }), +}); diff --git a/src/server/routers/fleet.ts b/src/server/routers/fleet.ts index ebc7ff15..0b14fa70 100644 --- a/src/server/routers/fleet.ts +++ b/src/server/routers/fleet.ts @@ -654,4 +654,68 @@ export const fleetRouter = router({ .query(async ({ input }) => { return getMatrixThroughput(input.environmentId, input.range); }), + + matrixSummary: protectedProcedure + .input(z.object({ environmentId: z.string() })) + .use(withTeamAccess("VIEWER")) + .query(async ({ input }) => { + // Fetch all nodes with their pipeline statuses + const nodes = await prisma.vectorNode.findMany({ + where: { environmentId: input.environmentId }, + include: { + pipelineStatuses: { + include: { + pipeline: { select: { id: true, name: true } }, + }, + }, + }, + orderBy: { name: "asc" }, + }); + + // Fetch latest version for each deployed pipeline in this environment + const deployedPipelines = await prisma.pipeline.findMany({ + where: { + environmentId: input.environmentId, + isDraft: false, + deployedAt: { not: null }, + }, + select: { + id: true, + versions: { + orderBy: { version: "desc" }, + take: 1, + select: { version: true }, + }, + }, + }); + + const latestVersionMap = new Map(); + for (const p of deployedPipelines) { + latestVersionMap.set(p.id, p.versions[0]?.version ?? 1); + } + + return nodes.map((node) => { + const pipelineCount = node.pipelineStatuses.length; + + const errorCount = node.pipelineStatuses.filter( + (ps) => ps.status === "CRASHED" || ps.status === "STOPPED" + ).length; + + const versionDriftCount = node.pipelineStatuses.filter((ps) => { + const latest = latestVersionMap.get(ps.pipelineId); + return latest != null && ps.version < latest; + }).length; + + return { + nodeId: node.id, + nodeName: node.name, + host: node.host, + status: node.status, + maintenanceMode: node.maintenanceMode, + pipelineCount, + errorCount, + versionDriftCount, + }; + }); + }), }); diff --git a/src/server/routers/git-sync.ts b/src/server/routers/git-sync.ts new file mode 100644 index 00000000..68ce1f0b --- /dev/null +++ b/src/server/routers/git-sync.ts @@ -0,0 +1,158 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { router, protectedProcedure, withTeamAccess } from "@/trpc/init"; +import { prisma } from "@/lib/prisma"; + +export const gitSyncRouter = router({ + /** Get sync status summary for an environment. */ + status: protectedProcedure + .input(z.object({ environmentId: z.string() })) + .use(withTeamAccess("VIEWER")) + .query(async ({ input }) => { + const env = await prisma.environment.findUnique({ + where: { id: input.environmentId }, + select: { + id: true, + gitRepoUrl: true, + gitBranch: true, + gitOpsMode: true, + gitProvider: true, + }, + }); + + if (!env) { + throw new TRPCError({ code: "NOT_FOUND", message: "Environment not found" }); + } + + // Count pending and failed jobs + const [pendingCount, failedCount, lastCompleted, lastFailed] = await Promise.all([ + prisma.gitSyncJob.count({ + where: { environmentId: input.environmentId, status: "pending" }, + }), + prisma.gitSyncJob.count({ + where: { environmentId: input.environmentId, status: "failed" }, + }), + prisma.gitSyncJob.findFirst({ + where: { environmentId: input.environmentId, status: "completed" }, + orderBy: { completedAt: "desc" }, + select: { completedAt: true }, + }), + prisma.gitSyncJob.findFirst({ + where: { environmentId: input.environmentId, status: "failed" }, + orderBy: { completedAt: "desc" }, + select: { lastError: true, completedAt: true }, + }), + ]); + + return { + gitRepoUrl: env.gitRepoUrl, + gitBranch: env.gitBranch, + gitOpsMode: env.gitOpsMode, + gitProvider: env.gitProvider, + pendingCount, + failedCount, + lastSuccessfulSync: lastCompleted?.completedAt ?? null, + lastError: lastFailed?.lastError ?? null, + lastErrorAt: lastFailed?.completedAt ?? null, + }; + }), + + /** List recent sync jobs for an environment. */ + jobs: protectedProcedure + .input( + z.object({ + environmentId: z.string(), + status: z.enum(["pending", "completed", "failed"]).optional(), + limit: z.number().min(1).max(100).default(25), + }), + ) + .use(withTeamAccess("VIEWER")) + .query(async ({ input }) => { + return prisma.gitSyncJob.findMany({ + where: { + environmentId: input.environmentId, + ...(input.status ? { status: input.status } : {}), + }, + include: { + pipeline: { select: { id: true, name: true } }, + }, + orderBy: { createdAt: "desc" }, + take: input.limit, + }); + }), + + /** Retry all failed jobs for an environment. */ + retryAllFailed: protectedProcedure + .input(z.object({ environmentId: z.string() })) + .use(withTeamAccess("EDITOR")) + .mutation(async ({ input }) => { + const now = new Date(); + const result = await prisma.gitSyncJob.updateMany({ + where: { + environmentId: input.environmentId, + status: "failed", + }, + data: { + status: "pending", + nextRetryAt: now, + attempts: 0, + }, + }); + + return { retriedCount: result.count }; + }), + + /** Retry a single failed job. */ + retryJob: protectedProcedure + .input(z.object({ jobId: z.string() })) + .use(withTeamAccess("EDITOR")) + .mutation(async ({ input }) => { + const job = await prisma.gitSyncJob.findUnique({ + where: { id: input.jobId }, + select: { status: true }, + }); + + if (!job || job.status !== "failed") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Job is not in failed state", + }); + } + + await prisma.gitSyncJob.update({ + where: { id: input.jobId }, + data: { + status: "pending", + nextRetryAt: new Date(), + attempts: 0, + }, + }); + + return { success: true }; + }), + + /** Get import errors from audit log. */ + importErrors: protectedProcedure + .input( + z.object({ + environmentId: z.string(), + limit: z.number().min(1).max(50).default(10), + }), + ) + .use(withTeamAccess("VIEWER")) + .query(async ({ input }) => { + return prisma.auditLog.findMany({ + where: { + environmentId: input.environmentId, + action: "gitops.pipeline.import_failed", + }, + select: { + id: true, + metadata: true, + createdAt: true, + }, + orderBy: { createdAt: "desc" }, + take: input.limit, + }); + }), +}); diff --git a/src/server/routers/node-group.ts b/src/server/routers/node-group.ts index f732b3d2..54c77490 100644 --- a/src/server/routers/node-group.ts +++ b/src/server/routers/node-group.ts @@ -141,7 +141,7 @@ export const nodeGroupRouter = router({ .query(async ({ input }) => { const { environmentId } = input; - const [nodes, groups, firingAlerts] = await Promise.all([ + const [nodes, groups, firingAlerts, pipelineStatuses, pipelines] = await Promise.all([ prisma.vectorNode.findMany({ where: { environmentId }, select: { id: true, status: true, labels: true }, @@ -154,8 +154,48 @@ export const nodeGroupRouter = router({ where: { status: "firing", node: { environmentId } }, select: { nodeId: true }, }), + prisma.nodePipelineStatus.findMany({ + where: { + node: { environmentId }, + }, + select: { + nodeId: true, + pipelineId: true, + version: true, + configChecksum: true, + }, + }), + prisma.pipeline.findMany({ + where: { + environmentId, + isDraft: false, + deployedAt: { not: null }, + }, + select: { + id: true, + versions: { + orderBy: { version: "desc" as const }, + take: 1, + select: { version: true }, + }, + }, + }), ]); + // Build latest version map for drift detection + const latestVersionMap = new Map(); + for (const p of pipelines) { + latestVersionMap.set(p.id, p.versions[0]?.version ?? 1); + } + + // Index pipeline statuses by nodeId + const statusesByNode = new Map(); + for (const s of pipelineStatuses) { + const existing = statusesByNode.get(s.nodeId) ?? []; + existing.push(s); + statusesByNode.set(s.nodeId, existing); + } + const firingNodeIds = new Set( firingAlerts.map((a) => a.nodeId).filter(Boolean) as string[], ); @@ -190,12 +230,41 @@ export const nodeGroupRouter = router({ complianceRate = Math.round((compliantCount / totalNodes) * 100); } + // Version drift: count pipelines where this group's nodes run a non-latest version + let versionDriftCount = 0; + const configDriftCount = 0; + let totalPipelineSlots = 0; + + for (const n of matchedNodes) { + const nodeStatuses = statusesByNode.get(n.id) ?? []; + totalPipelineSlots += nodeStatuses.length; + for (const ps of nodeStatuses) { + const latest = latestVersionMap.get(ps.pipelineId); + if (latest !== undefined && ps.version !== latest) { + versionDriftCount++; + } + // Config drift is tracked separately via alert evaluator; + // configDriftCount stays 0 here since we can't compare without + // the expected checksum cache in this context. + } + } + + const versionCompliance = totalPipelineSlots > 0 + ? Math.round(((totalPipelineSlots - versionDriftCount) / totalPipelineSlots) * 100) + : 100; + + // Combined: average of label compliance and version compliance + const overallCompliance = Math.round((complianceRate + versionCompliance) / 2); + return { ...group, totalNodes, onlineCount, alertCount, complianceRate, + versionDriftCount, + configDriftCount, + overallCompliance, }; }); @@ -217,6 +286,9 @@ export const nodeGroupRouter = router({ onlineCount: ungroupedOnlineCount, alertCount: ungroupedAlertCount, complianceRate: 100, + versionDriftCount: 0, + configDriftCount: 0, + overallCompliance: 100, }); } diff --git a/src/server/routers/pipeline.ts b/src/server/routers/pipeline.ts index d98af5af..75cf2e5a 100644 --- a/src/server/routers/pipeline.ts +++ b/src/server/routers/pipeline.ts @@ -67,10 +67,23 @@ export const pipelineRouter = router({ }), list: protectedProcedure - .input(z.object({ environmentId: z.string() })) + .input( + z.object({ + environmentId: z.string(), + cursor: z.string().optional(), + limit: z.number().int().min(1).max(200).default(50).optional(), + search: z.string().optional(), + status: z.array(z.string()).optional(), + tags: z.array(z.string()).optional(), + groupId: z.string().optional(), + sortBy: z.enum(["name", "updatedAt", "deployedAt"]).optional(), + sortOrder: z.enum(["asc", "desc"]).optional(), + }) + ) .use(withTeamAccess("VIEWER")) .query(async ({ input }) => { - return listPipelinesForEnvironment(input.environmentId); + const { environmentId, ...options } = input; + return listPipelinesForEnvironment(environmentId, options); }), get: protectedProcedure @@ -703,11 +716,12 @@ export const pipelineRouter = router({ levels: z.array(z.nativeEnum(LogLevel)).optional(), nodeId: z.string().optional(), since: z.date().optional(), + search: z.string().max(200).optional(), }), ) .use(withTeamAccess("VIEWER")) .query(async ({ input }) => { - const { pipelineId, cursor, limit, levels, nodeId, since } = input; + const { pipelineId, cursor, limit, levels, nodeId, since, search } = input; const take = limit; const where: Record = { pipelineId }; @@ -720,6 +734,9 @@ export const pipelineRouter = router({ if (since) { where.timestamp = { gte: since }; } + if (search) { + where.message = { contains: search, mode: "insensitive" }; + } const items = await prisma.pipelineLog.findMany({ where, diff --git a/src/server/routers/promotion.ts b/src/server/routers/promotion.ts index bd6ad67e..276e9f7b 100644 --- a/src/server/routers/promotion.ts +++ b/src/server/routers/promotion.ts @@ -117,6 +117,7 @@ export const promotionRouter = router({ gitRepoUrl: true, gitToken: true, gitBranch: true, + gitProvider: true, }, }); if (!targetEnv) { @@ -235,6 +236,8 @@ export const promotionRouter = router({ sourceEnvironmentName: sourcePipeline.environment.name, targetEnvironmentName: targetEnv.name, configYaml, + gitProvider: targetEnv.gitProvider ?? null, + gitPath: sourcePipeline.gitPath ?? null, }); await prisma.promotionRequest.update({ diff --git a/src/server/services/__tests__/alert-evaluator.test.ts b/src/server/services/__tests__/alert-evaluator.test.ts index 0001e2ff..a2a07e69 100644 --- a/src/server/services/__tests__/alert-evaluator.test.ts +++ b/src/server/services/__tests__/alert-evaluator.test.ts @@ -12,8 +12,19 @@ vi.mock("@/lib/prisma", () => ({ prisma: mockDeep(), })); +vi.mock("@/server/services/drift-metrics", () => ({ + getConfigDrift: vi.fn(), + getVersionDrift: vi.fn(), + setExpectedChecksum: vi.fn(), + clearExpectedChecksumCache: vi.fn(), + getExpectedChecksums: vi.fn(), +})); + import { prisma } from "@/lib/prisma"; import { evaluateAlerts } from "@/server/services/alert-evaluator"; +import { getConfigDrift } from "@/server/services/drift-metrics"; + +const mockGetConfigDrift = getConfigDrift as ReturnType; const prismaMock = prisma as unknown as DeepMockProxy; @@ -631,6 +642,40 @@ describe("evaluateAlerts", () => { ); }); + // ── config_drift metric ────────────────────────────────────────────── + + describe("config_drift metric", () => { + it("fires alert when config drift detected on a node", async () => { + mockRunningNode("HEALTHY"); + + prismaMock.alertRule.findMany.mockResolvedValue([ + makeAlertRule({ + id: "rule-config-drift", + metric: "config_drift", + condition: "gt", + threshold: 0, + durationSeconds: 0, + }), + ] as never); + + // Mock getConfigDrift to return 1 drifted pipeline + mockGetConfigDrift.mockResolvedValue({ value: 1 }); + + prismaMock.alertEvent.findFirst.mockResolvedValue(null); + prismaMock.alertEvent.create.mockResolvedValue( + makeAlertEvent({ + id: "event-config-drift", + alertRuleId: "rule-config-drift", + message: "Config drift at 1.00 (threshold: > 0)", + }), + ); + + const results = await evaluateAlerts(NODE_ID, ENV_ID); + expect(results).toHaveLength(1); + expect(results[0].event.status).toBe("firing"); + }); + }); + afterEach(() => { vi.useRealTimers(); }); diff --git a/src/server/services/__tests__/drift-metrics.test.ts b/src/server/services/__tests__/drift-metrics.test.ts new file mode 100644 index 00000000..541a515c --- /dev/null +++ b/src/server/services/__tests__/drift-metrics.test.ts @@ -0,0 +1,174 @@ +// src/server/services/__tests__/drift-metrics.test.ts +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; +import type { PrismaClient } from "@/generated/prisma"; + +vi.mock("@/lib/prisma", () => ({ + prisma: mockDeep(), +})); + +import { prisma } from "@/lib/prisma"; +import { + getVersionDrift, + getConfigDrift, + setExpectedChecksum, + clearExpectedChecksumCache, +} from "@/server/services/drift-metrics"; + +const prismaMock = prisma as unknown as DeepMockProxy; + +describe("getVersionDrift", () => { + beforeEach(() => { + mockReset(prismaMock); + }); + + it("returns null when no pipeline statuses exist", async () => { + prismaMock.nodePipelineStatus.findMany.mockResolvedValue([]); + prismaMock.pipeline.findMany.mockResolvedValue([]); + + const result = await getVersionDrift("env-1"); + expect(result).toBeNull(); + }); + + it("returns 0 when all nodes run the latest version", async () => { + prismaMock.nodePipelineStatus.findMany.mockResolvedValue([ + { pipelineId: "pipe-1", nodeId: "node-1", version: 5 }, + { pipelineId: "pipe-1", nodeId: "node-2", version: 5 }, + ] as never); + prismaMock.pipeline.findMany.mockResolvedValue([ + { + id: "pipe-1", + name: "Pipeline A", + versions: [{ version: 5 }], + }, + ] as never); + + const result = await getVersionDrift("env-1"); + expect(result).not.toBeNull(); + expect(result!.value).toBe(0); + expect(result!.driftedPipelines).toHaveLength(0); + }); + + it("returns count of drifted pipelines when versions mismatch", async () => { + prismaMock.nodePipelineStatus.findMany.mockResolvedValue([ + { pipelineId: "pipe-1", nodeId: "node-1", version: 4 }, + { pipelineId: "pipe-1", nodeId: "node-2", version: 5 }, + { pipelineId: "pipe-2", nodeId: "node-1", version: 3 }, + { pipelineId: "pipe-2", nodeId: "node-2", version: 3 }, + ] as never); + prismaMock.pipeline.findMany.mockResolvedValue([ + { + id: "pipe-1", + name: "Pipeline A", + versions: [{ version: 5 }], + }, + { + id: "pipe-2", + name: "Pipeline B", + versions: [{ version: 3 }], + }, + ] as never); + + const result = await getVersionDrift("env-1"); + expect(result).not.toBeNull(); + expect(result!.value).toBe(1); // 1 pipeline has drift + expect(result!.driftedPipelines).toHaveLength(1); + expect(result!.driftedPipelines[0].pipelineName).toBe("Pipeline A"); + expect(result!.driftedPipelines[0].expectedVersion).toBe(5); + expect(result!.driftedPipelines[0].nodeVersions).toEqual({ + "node-1": 4, + "node-2": 5, + }); + }); + + it("detects drift when all nodes are behind latest", async () => { + prismaMock.nodePipelineStatus.findMany.mockResolvedValue([ + { pipelineId: "pipe-1", nodeId: "node-1", version: 2 }, + { pipelineId: "pipe-1", nodeId: "node-2", version: 2 }, + ] as never); + prismaMock.pipeline.findMany.mockResolvedValue([ + { + id: "pipe-1", + name: "Pipeline A", + versions: [{ version: 3 }], + }, + ] as never); + + const result = await getVersionDrift("env-1"); + expect(result).not.toBeNull(); + expect(result!.value).toBe(1); + expect(result!.driftedPipelines).toHaveLength(1); + }); +}); + +describe("getConfigDrift", () => { + beforeEach(() => { + mockReset(prismaMock); + clearExpectedChecksumCache(); + }); + + it("returns null when no pipeline statuses exist", async () => { + prismaMock.nodePipelineStatus.findMany.mockResolvedValue([]); + + const result = await getConfigDrift("node-1", null); + expect(result).toBeNull(); + }); + + it("returns 0 when all checksums match", async () => { + prismaMock.nodePipelineStatus.findMany.mockResolvedValue([ + { + pipelineId: "pipe-1", + nodeId: "node-1", + configChecksum: "abc123", + pipeline: { name: "Pipeline A", id: "pipe-1" }, + }, + ] as never); + + setExpectedChecksum("pipe-1", "abc123"); + + const result = await getConfigDrift("node-1", null); + expect(result).not.toBeNull(); + expect(result!.value).toBe(0); + }); + + it("returns count of mismatched pipelines", async () => { + prismaMock.nodePipelineStatus.findMany.mockResolvedValue([ + { + pipelineId: "pipe-1", + nodeId: "node-1", + configChecksum: "stale-checksum", + pipeline: { name: "Pipeline A", id: "pipe-1" }, + }, + { + pipelineId: "pipe-2", + nodeId: "node-1", + configChecksum: "correct-checksum", + pipeline: { name: "Pipeline B", id: "pipe-2" }, + }, + ] as never); + + setExpectedChecksum("pipe-1", "expected-checksum"); + setExpectedChecksum("pipe-2", "correct-checksum"); + + const result = await getConfigDrift("node-1", null); + expect(result).not.toBeNull(); + expect(result!.value).toBe(1); + }); + + it("ignores pipelines where agent does not report checksum (null)", async () => { + prismaMock.nodePipelineStatus.findMany.mockResolvedValue([ + { + pipelineId: "pipe-1", + nodeId: "node-1", + configChecksum: null, // older agent, no checksum + pipeline: { name: "Pipeline A", id: "pipe-1" }, + }, + ] as never); + + setExpectedChecksum("pipe-1", "expected-checksum"); + + const result = await getConfigDrift("node-1", null); + expect(result).not.toBeNull(); + expect(result!.value).toBe(0); // null checksum is not drift + }); +}); diff --git a/src/server/services/__tests__/fleet-alert-service.test.ts b/src/server/services/__tests__/fleet-alert-service.test.ts index bb46b8b8..93979585 100644 --- a/src/server/services/__tests__/fleet-alert-service.test.ts +++ b/src/server/services/__tests__/fleet-alert-service.test.ts @@ -30,6 +30,10 @@ vi.mock("@/server/services/delivery-tracking", () => ({ trackWebhookDelivery: vi.fn().mockResolvedValue({ success: true }), })); +vi.mock("@/server/services/drift-metrics", () => ({ + getVersionDrift: vi.fn(), +})); + import { prisma } from "@/lib/prisma"; import { FleetAlertService } from "@/server/services/fleet-alert-service"; import { @@ -40,6 +44,7 @@ import { } from "@/server/services/fleet-metrics"; import { deliverToChannels } from "@/server/services/channels"; import { trackWebhookDelivery } from "@/server/services/delivery-tracking"; +import { getVersionDrift } from "@/server/services/drift-metrics"; const prismaMock = prisma as unknown as DeepMockProxy; @@ -47,6 +52,7 @@ const mockGetFleetErrorRate = getFleetErrorRate as ReturnType; const mockGetFleetEventVolume = getFleetEventVolume as ReturnType; const mockGetFleetThroughputDrop = getFleetThroughputDrop as ReturnType; const mockGetNodeLoadImbalance = getNodeLoadImbalance as ReturnType; +const mockGetVersionDrift = getVersionDrift as ReturnType; // ─── Fixture helpers ──────────────────────────────────────────────────────── @@ -507,4 +513,86 @@ describe("FleetAlertService", () => { // Second stop is safe service.stop(); }); + + // ── version_drift evaluation ──────────────────────────────────────────── + + describe("version_drift evaluation", () => { + it("fires alert when version drift is detected", async () => { + const rule = makeRule({ + id: "rule-vd-1", + metric: "version_drift", + condition: "gt", + threshold: 0, + durationSeconds: 0, + }); + + prismaMock.alertRule.findMany.mockResolvedValue([rule]); + mockGetVersionDrift.mockResolvedValue({ value: 2, driftedPipelines: [] }); + prismaMock.alertEvent.findFirst.mockResolvedValue(null); + prismaMock.alertEvent.create.mockResolvedValue({ + id: "event-vd-1", + alertRuleId: "rule-vd-1", + nodeId: null, + status: "firing", + value: 2, + message: "Version drift at 2.00 (threshold: > 0)", + firedAt: NOW, + resolvedAt: null, + notifiedAt: null, + acknowledgedAt: null, + acknowledgedBy: null, + }); + prismaMock.alertWebhook.findMany.mockResolvedValue([]); + + const results = await service.evaluateFleetAlerts(); + + expect(results).toHaveLength(1); + expect(results[0].event.status).toBe("firing"); + expect(mockGetVersionDrift).toHaveBeenCalledWith(rule.environmentId); + }); + + it("resolves alert when version drift drops to zero", async () => { + const rule = makeRule({ + id: "rule-vd-2", + metric: "version_drift", + condition: "gt", + threshold: 0, + durationSeconds: 0, + }); + + prismaMock.alertRule.findMany.mockResolvedValue([rule]); + mockGetVersionDrift.mockResolvedValue({ value: 0, driftedPipelines: [] }); + prismaMock.alertEvent.findFirst.mockResolvedValue({ + id: "event-vd-2", + alertRuleId: "rule-vd-2", + status: "firing", + resolvedAt: null, + firedAt: NOW, + nodeId: null, + value: 2, + message: "Version drift at 2.00 (threshold: > 0)", + notifiedAt: null, + acknowledgedAt: null, + acknowledgedBy: null, + }); + prismaMock.alertEvent.update.mockResolvedValue({ + id: "event-vd-2", + alertRuleId: "rule-vd-2", + status: "resolved", + resolvedAt: NOW, + firedAt: NOW, + nodeId: null, + value: 0, + message: "Version drift at 2.00 (threshold: > 0)", + notifiedAt: null, + acknowledgedAt: null, + acknowledgedBy: null, + }); + + const results = await service.evaluateFleetAlerts(); + + expect(results).toHaveLength(1); + expect(results[0].event.status).toBe("resolved"); + }); + }); }); diff --git a/src/server/services/__tests__/fleet-data.test.ts b/src/server/services/__tests__/fleet-data.test.ts index e83330d7..8a26bc92 100644 --- a/src/server/services/__tests__/fleet-data.test.ts +++ b/src/server/services/__tests__/fleet-data.test.ts @@ -1,7 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; +import type { PrismaClient } from "@/generated/prisma"; vi.mock("@/lib/prisma", () => ({ - prisma: { $queryRaw: vi.fn() }, + prisma: mockDeep(), +})); + +vi.mock("@/server/services/drift-metrics", () => ({ + getExpectedChecksums: vi.fn().mockReturnValue(new Map()), })); import { prisma } from "@/lib/prisma"; @@ -15,13 +21,20 @@ import { type TimeRange, } from "@/server/services/fleet-data"; -const mockQueryRaw = prisma.$queryRaw as ReturnType; +const prismaMock = prisma as unknown as DeepMockProxy; +const mockQueryRaw = prismaMock.$queryRaw as ReturnType; beforeEach(() => { - vi.clearAllMocks(); + mockReset(prismaMock); }); describe("getFleetOverview", () => { + /** Mock the additional drift queries that getFleetOverview now runs. */ + function mockDriftQueries() { + prismaMock.nodePipelineStatus.findMany.mockResolvedValue([]); + prismaMock.pipeline.findMany.mockResolvedValue([]); + } + it("returns computed KPIs from aggregated metrics", async () => { mockQueryRaw .mockResolvedValueOnce([ @@ -34,6 +47,7 @@ describe("getFleetOverview", () => { }, ]) .mockResolvedValueOnce([{ count: BigInt(3) }]); + mockDriftQueries(); const result = await getFleetOverview("env-1", "7d"); @@ -44,6 +58,8 @@ describe("getFleetOverview", () => { eventsOut: 490, errorRate: 10 / 500, nodeCount: 3, + versionDriftCount: 0, + configDriftCount: 0, }); }); @@ -59,6 +75,7 @@ describe("getFleetOverview", () => { }, ]) .mockResolvedValueOnce([{ count: BigInt(0) }]); + mockDriftQueries(); const result = await getFleetOverview("env-1", "1d"); @@ -69,6 +86,8 @@ describe("getFleetOverview", () => { eventsOut: 0, errorRate: 0, nodeCount: 0, + versionDriftCount: 0, + configDriftCount: 0, }); }); @@ -84,11 +103,38 @@ describe("getFleetOverview", () => { }, ]) .mockResolvedValueOnce([{ count: BigInt(1) }]); + mockDriftQueries(); const result = await getFleetOverview("env-1", "1h"); expect(result.errorRate).toBe(0.1); }); + + it("returns versionDriftCount when nodes run non-latest versions", async () => { + mockQueryRaw + .mockResolvedValueOnce([ + { + bytes_in: BigInt(1000), + bytes_out: BigInt(800), + events_in: BigInt(100), + events_out: BigInt(90), + errors_total: BigInt(0), + }, + ]) + .mockResolvedValueOnce([{ count: BigInt(3) }]); + + prismaMock.nodePipelineStatus.findMany.mockResolvedValue([ + { nodeId: "node-1", pipelineId: "pipe-1", version: 4, configChecksum: null }, + { nodeId: "node-2", pipelineId: "pipe-1", version: 5, configChecksum: null }, + ] as never); + prismaMock.pipeline.findMany.mockResolvedValue([ + { id: "pipe-1", versions: [{ version: 5 }] }, + ] as never); + + const result = await getFleetOverview("env-1", "1d"); + expect(result.versionDriftCount).toBe(1); + expect(result.configDriftCount).toBe(0); + }); }); describe("getVolumeTrend", () => { diff --git a/src/server/services/__tests__/git-path.test.ts b/src/server/services/__tests__/git-path.test.ts new file mode 100644 index 00000000..a8be41ab --- /dev/null +++ b/src/server/services/__tests__/git-path.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { toFilenameSlug } from "../git-sync"; + +describe("gitPath derivation", () => { + it("derives gitPath from environment and pipeline names", () => { + const envSlug = toFilenameSlug("Production US-East"); + const pipelineSlug = toFilenameSlug("My Pipeline v2"); + const gitPath = `${envSlug}/${pipelineSlug}.yaml`; + expect(gitPath).toBe("production-us-east/my-pipeline-v2.yaml"); + }); + + it("handles special characters in names", () => { + const envSlug = toFilenameSlug("staging (test)"); + const pipelineSlug = toFilenameSlug("pipeline@special!"); + expect(`${envSlug}/${pipelineSlug}.yaml`).toBe("staging-test/pipeline-special.yaml"); + }); + + it("handles empty names", () => { + expect(toFilenameSlug("")).toBe("unnamed"); + }); + + it("preserves gitPath on rename: once set, gitPath stays the same", () => { + // Simulate: pipeline originally named "access-logs", synced to git + const originalGitPath = "production/access-logs.yaml"; + + // Pipeline renamed to "Access Logs v2" — gitPath should stay the same + // (the code never overwrites gitPath once set) + const renamedSlug = toFilenameSlug("Access Logs v2"); + const derivedPath = `production/${renamedSlug}.yaml`; + + // These should be different, proving rename doesn't affect gitPath + expect(derivedPath).toBe("production/access-logs-v2.yaml"); + expect(originalGitPath).not.toBe(derivedPath); + + // But the git sync uses originalGitPath, not derivedPath + const pathUsedBySync = originalGitPath; // gitPath field + expect(pathUsedBySync).toBe("production/access-logs.yaml"); + }); +}); diff --git a/src/server/services/__tests__/git-sync-retry.test.ts b/src/server/services/__tests__/git-sync-retry.test.ts new file mode 100644 index 00000000..9cb5580b --- /dev/null +++ b/src/server/services/__tests__/git-sync-retry.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mockDeep, type DeepMockProxy } from "vitest-mock-extended"; +import type { PrismaClient } from "@/generated/prisma"; + +vi.mock("@/lib/prisma", () => ({ + prisma: mockDeep(), +})); + +vi.mock("@/lib/logger", () => ({ + debugLog: vi.fn(), +})); + +vi.mock("@/server/services/git-sync", () => ({ + gitSyncCommitPipeline: vi.fn(), + gitSyncDeletePipeline: vi.fn(), + toFilenameSlug: vi.fn((name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-")), +})); + +vi.mock("@/server/services/event-alerts", () => ({ + fireEventAlert: vi.fn(), +})); + +vi.mock("@/server/services/sse-broadcast", () => ({ + broadcastSSE: vi.fn(), +})); + +import { prisma } from "@/lib/prisma"; +import { gitSyncCommitPipeline, gitSyncDeletePipeline } from "@/server/services/git-sync"; +import { fireEventAlert } from "@/server/services/event-alerts"; +import { GitSyncRetryService, getNextRetryAt, createGitSyncJob } from "../git-sync-retry"; + +const prismaMock = prisma as unknown as DeepMockProxy; +const commitMock = vi.mocked(gitSyncCommitPipeline); +const deleteMock = vi.mocked(gitSyncDeletePipeline); +const fireAlertMock = vi.mocked(fireEventAlert); + +describe("getNextRetryAt", () => { + it("returns 30s delay for attempt 0", () => { + const result = getNextRetryAt(0); + expect(result).not.toBeNull(); + const diff = result!.getTime() - Date.now(); + expect(diff).toBeGreaterThan(28_000); + expect(diff).toBeLessThan(32_000); + }); + + it("returns 2m delay for attempt 1", () => { + const result = getNextRetryAt(1); + expect(result).not.toBeNull(); + const diff = result!.getTime() - Date.now(); + expect(diff).toBeGreaterThan(118_000); + expect(diff).toBeLessThan(122_000); + }); + + it("returns 10m delay for attempt 2", () => { + const result = getNextRetryAt(2); + expect(result).not.toBeNull(); + const diff = result!.getTime() - Date.now(); + expect(diff).toBeGreaterThan(598_000); + expect(diff).toBeLessThan(602_000); + }); + + it("returns null for attempt 3 (exceeded)", () => { + expect(getNextRetryAt(3)).toBeNull(); + }); +}); + +describe("GitSyncRetryService", () => { + let service: GitSyncRetryService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new GitSyncRetryService(); + }); + + it("does nothing when no due jobs exist", async () => { + prismaMock.gitSyncJob.findMany.mockResolvedValue([]); + await service.processRetries(); + expect(commitMock).not.toHaveBeenCalled(); + }); + + it("retries a commit job and marks it completed on success", async () => { + const job = { + id: "job-1", + environmentId: "env-1", + pipelineId: "pipe-1", + action: "commit", + configYaml: "sources:\n in:\n type: demo_logs", + commitMessage: "Deploy pipeline", + authorName: "Danny", + authorEmail: "danny@test.com", + attempts: 1, + maxAttempts: 3, + lastError: "network timeout", + status: "pending", + nextRetryAt: new Date(), + createdAt: new Date(), + completedAt: null, + environment: { + id: "env-1", + name: "production", + gitRepoUrl: "https://github.com/acme/configs", + gitBranch: "main", + gitToken: "encrypted-token", + }, + pipeline: { id: "pipe-1", name: "my-pipeline", gitPath: null }, + }; + + prismaMock.gitSyncJob.findMany.mockResolvedValue([job] as never); + prismaMock.gitSyncJob.update.mockResolvedValue(job as never); + commitMock.mockResolvedValue({ success: true, commitSha: "abc123" }); + + await service.processRetries(); + + expect(commitMock).toHaveBeenCalledWith( + { + repoUrl: "https://github.com/acme/configs", + branch: "main", + encryptedToken: "encrypted-token", + }, + "production", + "my-pipeline", + "sources:\n in:\n type: demo_logs", + { name: "Danny", email: "danny@test.com" }, + "Deploy pipeline", + undefined, + ); + + // Should mark as completed + expect(prismaMock.gitSyncJob.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "job-1" }, + data: expect.objectContaining({ status: "completed" }), + }), + ); + }); + + it("marks job as failed after max attempts and fires alert", async () => { + const job = { + id: "job-2", + environmentId: "env-1", + pipelineId: "pipe-1", + action: "commit", + configYaml: "test: yaml", + commitMessage: "Deploy", + authorName: null, + authorEmail: null, + attempts: 2, // Will become 3 (max) + maxAttempts: 3, + lastError: "auth failed", + status: "pending", + nextRetryAt: new Date(), + createdAt: new Date(), + completedAt: null, + environment: { + id: "env-1", + name: "staging", + gitRepoUrl: "https://github.com/acme/configs", + gitBranch: "main", + gitToken: "enc-token", + }, + pipeline: { id: "pipe-1", name: "pipeline-a", gitPath: null }, + }; + + prismaMock.gitSyncJob.findMany.mockResolvedValue([job] as never); + prismaMock.gitSyncJob.update.mockResolvedValue(job as never); + commitMock.mockResolvedValue({ success: false, error: "auth failed again" }); + fireAlertMock.mockResolvedValue(undefined as never); + + await service.processRetries(); + + // Should mark as failed + expect(prismaMock.gitSyncJob.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "job-2" }, + data: expect.objectContaining({ status: "failed" }), + }), + ); + + // Should fire alert + expect(fireAlertMock).toHaveBeenCalledWith( + "git_sync_failed", + "env-1", + expect.objectContaining({ message: expect.stringContaining("auth failed") }), + ); + }); +}); + +describe("createGitSyncJob", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates a job with correct defaults", async () => { + prismaMock.gitSyncJob.create.mockResolvedValue({} as never); + + await createGitSyncJob({ + environmentId: "env-1", + pipelineId: "pipe-1", + action: "commit", + configYaml: "test: yaml", + commitMessage: "Deploy", + error: "timeout", + }); + + expect(prismaMock.gitSyncJob.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + environmentId: "env-1", + pipelineId: "pipe-1", + action: "commit", + configYaml: "test: yaml", + commitMessage: "Deploy", + attempts: 1, + lastError: "timeout", + nextRetryAt: expect.any(Date), + }), + }); + }); +}); diff --git a/src/server/services/__tests__/gitops-promotion.test.ts b/src/server/services/__tests__/gitops-promotion.test.ts index 6d9e31a6..c8f7e60b 100644 --- a/src/server/services/__tests__/gitops-promotion.test.ts +++ b/src/server/services/__tests__/gitops-promotion.test.ts @@ -1,183 +1,84 @@ -import { vi, describe, it, expect, beforeEach } from "vitest"; - -// ─── Mocks ─────────────────────────────────────────────────────────────────── - -vi.mock("@octokit/rest", () => ({ - Octokit: vi.fn(), -})); +import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/server/services/crypto", () => ({ - decrypt: vi.fn((encrypted: string) => `decrypted-${encrypted}`), + decrypt: vi.fn((val: string) => `decrypted-${val}`), })); -vi.mock("@/server/services/git-sync", () => ({ - toFilenameSlug: vi.fn((name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")), +const mockProvider = { + name: "github" as const, + verifyWebhookSignature: vi.fn(), + parseWebhookEvent: vi.fn(), + parseRepoUrl: vi.fn(() => ({ owner: "acme", repo: "configs" })), + fetchFileContent: vi.fn(), + createBranch: vi.fn(), + commitFile: vi.fn().mockResolvedValue("sha123"), + createPullRequest: vi.fn().mockResolvedValue({ url: "https://github.com/acme/configs/pull/1", number: 1 }), +}; + +vi.mock("@/server/services/git-providers", () => ({ + getProvider: vi.fn(() => mockProvider), })); -// ─── Imports ───────────────────────────────────────────────────────────────── - -import { Octokit } from "@octokit/rest"; -import { createPromotionPR, parseGitHubOwnerRepo } from "@/server/services/gitops-promotion"; - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -function makeOctokitMock(overrides?: Record) { - const getRef = vi.fn().mockResolvedValue({ - data: { object: { sha: "base-sha-abc123" } }, - }); - const createRef = vi.fn().mockResolvedValue({}); - const getContent = vi.fn().mockRejectedValue(new Error("Not Found")); // Default: file does not exist - const createOrUpdateFileContents = vi.fn().mockResolvedValue({}); - const create = vi.fn().mockResolvedValue({ - data: { number: 42, html_url: "https://github.com/owner/repo/pull/42" }, - }); - - return { - rest: { - git: { getRef, createRef }, - repos: { getContent, createOrUpdateFileContents }, - pulls: { create }, - }, - ...overrides, - }; -} - -// ─── Tests: parseGitHubOwnerRepo ───────────────────────────────────────────── - -describe("parseGitHubOwnerRepo", () => { - it("parses HTTPS URL without .git", () => { - const result = parseGitHubOwnerRepo("https://github.com/myorg/myrepo"); - expect(result).toEqual({ owner: "myorg", repo: "myrepo" }); - }); - - it("parses HTTPS URL with .git", () => { - const result = parseGitHubOwnerRepo("https://github.com/myorg/myrepo.git"); - expect(result).toEqual({ owner: "myorg", repo: "myrepo" }); - }); - - it("parses SSH URL", () => { - const result = parseGitHubOwnerRepo("git@github.com:myorg/myrepo.git"); - expect(result).toEqual({ owner: "myorg", repo: "myrepo" }); - }); - - it("parses SSH URL without .git", () => { - const result = parseGitHubOwnerRepo("git@github.com:myorg/myrepo"); - expect(result).toEqual({ owner: "myorg", repo: "myrepo" }); - }); - - it("throws for unrecognized URL format", () => { - expect(() => parseGitHubOwnerRepo("https://gitlab.com/myorg/myrepo")).toThrow( - "Cannot parse GitHub owner/repo", - ); - }); -}); - -// ─── Tests: createPromotionPR ───────────────────────────────────────────────── +import { createPromotionPR } from "../gitops-promotion"; describe("createPromotionPR", () => { - let octokitMock: ReturnType; - beforeEach(() => { vi.clearAllMocks(); - octokitMock = makeOctokitMock(); - // Must use a function (not arrow) so `new` works correctly in Vitest - vi.mocked(Octokit).mockImplementation(function () { - return octokitMock as never; - }); }); - const baseOpts = { - encryptedToken: "enc-token", - repoUrl: "https://github.com/myorg/myrepo", - baseBranch: "main", - requestId: "req1234567890", - pipelineName: "My Pipeline", - sourceEnvironmentName: "Development", - targetEnvironmentName: "Production", - configYaml: "sources:\n my_source:\n type: stdin\n", - }; - - it("decrypts token and instantiates Octokit with it", async () => { - await createPromotionPR(baseOpts); - expect(Octokit).toHaveBeenCalledWith({ auth: "decrypted-enc-token" }); - }); - - it("gets base branch SHA before creating PR branch", async () => { - await createPromotionPR(baseOpts); - expect(octokitMock.rest.git.getRef).toHaveBeenCalledWith({ - owner: "myorg", - repo: "myrepo", - ref: "heads/main", + it("creates branch, commits file, and opens PR", async () => { + const result = await createPromotionPR({ + encryptedToken: "enc-token", + repoUrl: "https://github.com/acme/configs", + baseBranch: "main", + requestId: "req12345678", + pipelineName: "My Pipeline", + sourceEnvironmentName: "Staging", + targetEnvironmentName: "Production", + configYaml: "sources:\n in:\n type: demo_logs", }); - }); - - it("creates a PR branch with unique name including requestId prefix", async () => { - await createPromotionPR(baseOpts); - expect(octokitMock.rest.git.createRef).toHaveBeenCalledWith({ - owner: "myorg", - repo: "myrepo", - ref: "refs/heads/vf-promote/production-my-pipeline-req12345", - sha: "base-sha-abc123", - }); - }); - it("commits YAML file at envSlug/pipelineSlug.yaml on the PR branch", async () => { - await createPromotionPR(baseOpts); - expect(octokitMock.rest.repos.createOrUpdateFileContents).toHaveBeenCalledWith( - expect.objectContaining({ - owner: "myorg", - repo: "myrepo", - path: "production/my-pipeline.yaml", - branch: "vf-promote/production-my-pipeline-req12345", - content: Buffer.from(baseOpts.configYaml).toString("base64"), - }), + expect(mockProvider.createBranch).toHaveBeenCalledWith( + "https://github.com/acme/configs", + "decrypted-enc-token", + "main", + "vf-promote/production-my-pipeline-req12345", ); - }); - - it("opens PR with promotion request ID embedded in body", async () => { - await createPromotionPR(baseOpts); - const createCall = octokitMock.rest.pulls.create.mock.calls[0][0]; - expect(createCall.body).toContain(""); - expect(createCall.title).toContain("My Pipeline"); - expect(createCall.title).toContain("Production"); - expect(createCall.head).toBe("vf-promote/production-my-pipeline-req12345"); - expect(createCall.base).toBe("main"); - }); - - it("returns prNumber, prUrl, and prBranch from GitHub response", async () => { - const result = await createPromotionPR(baseOpts); - expect(result.prNumber).toBe(42); - expect(result.prUrl).toBe("https://github.com/owner/repo/pull/42"); - expect(result.prBranch).toBe("vf-promote/production-my-pipeline-req12345"); - }); - it("includes existing file SHA when file already exists on branch", async () => { - octokitMock.rest.repos.getContent.mockResolvedValue({ - data: { sha: "existing-file-sha", type: "file", name: "my-pipeline.yaml" }, - } as never); - - await createPromotionPR(baseOpts); - - expect(octokitMock.rest.repos.createOrUpdateFileContents).toHaveBeenCalledWith( - expect.objectContaining({ sha: "existing-file-sha" }), + expect(mockProvider.commitFile).toHaveBeenCalledWith( + "https://github.com/acme/configs", + "decrypted-enc-token", + "vf-promote/production-my-pipeline-req12345", + "production/my-pipeline.yaml", + expect.any(String), + expect.stringContaining("My Pipeline"), ); - }); - it("does not include sha when file does not exist yet (new file creation)", async () => { - // Default mock: getContent throws "Not Found" - await createPromotionPR(baseOpts); - - const updateCall = octokitMock.rest.repos.createOrUpdateFileContents.mock.calls[0][0]; - expect(updateCall.sha).toBeUndefined(); + expect(mockProvider.createPullRequest).toHaveBeenCalled(); + expect(result.prUrl).toBe("https://github.com/acme/configs/pull/1"); + expect(result.prNumber).toBe(1); }); - it("parses SSH URL format correctly", async () => { + it("uses gitPath when provided instead of deriving from slugs", async () => { await createPromotionPR({ - ...baseOpts, - repoUrl: "git@github.com:myorg/myrepo.git", + encryptedToken: "enc-token", + repoUrl: "https://github.com/acme/configs", + baseBranch: "main", + requestId: "req12345678", + pipelineName: "My Pipeline", + sourceEnvironmentName: "Staging", + targetEnvironmentName: "Production", + configYaml: "test: yaml", + gitPath: "custom/path/pipeline.yaml", }); - expect(octokitMock.rest.git.getRef).toHaveBeenCalledWith( - expect.objectContaining({ owner: "myorg", repo: "myrepo" }), + + expect(mockProvider.commitFile).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(String), + "custom/path/pipeline.yaml", + expect.any(String), + expect.any(String), ); }); }); diff --git a/src/server/services/__tests__/heartbeat-batch.test.ts b/src/server/services/__tests__/heartbeat-batch.test.ts index 6df7c674..12a90cb2 100644 --- a/src/server/services/__tests__/heartbeat-batch.test.ts +++ b/src/server/services/__tests__/heartbeat-batch.test.ts @@ -191,6 +191,51 @@ describe("batchUpsertPipelineStatuses", () => { batchUpsertPipelineStatuses(NODE_ID, pipelines, NOW), ).rejects.toThrow("connection timeout"); }); + + // ── configChecksum is included in upsert when provided ────────────────── + + it("includes configChecksum in upsert when provided", async () => { + prismaMock.$executeRaw.mockResolvedValue(1 as never); + + await batchUpsertPipelineStatuses( + "node-1", + [ + { + pipelineId: "pipe-1", + version: 3, + status: "RUNNING", + configChecksum: "abc123def456", + }, + ], + new Date("2026-01-01T00:00:00Z"), + ); + + expect(prismaMock.$executeRaw).toHaveBeenCalledTimes(1); + // The raw SQL template should contain the configChecksum column + const call = prismaMock.$executeRaw.mock.calls[0]!; + const outerStrings = call[0] as unknown as string[]; + const outerSql = outerStrings.join("$1"); + expect(outerSql).toContain("configChecksum"); + }); + + it("passes null configChecksum when agent omits it", async () => { + prismaMock.$executeRaw.mockResolvedValue(1 as never); + + await batchUpsertPipelineStatuses( + "node-1", + [ + { + pipelineId: "pipe-1", + version: 3, + status: "RUNNING", + // no configChecksum + }, + ], + new Date("2026-01-01T00:00:00Z"), + ); + + expect(prismaMock.$executeRaw).toHaveBeenCalledTimes(1); + }); }); // ─── Orchestration test: 100-pipeline payload end-to-end ──────────────────── @@ -225,14 +270,15 @@ describe("100-pipeline orchestration", () => { expect(prismaMock.$executeRaw).toHaveBeenCalledOnce(); // Verify the inner Prisma.Sql object has values for all 100 pipelines. - // Each pipeline contributes 16 fields to the VALUES clause (id, nodeId, + // Each pipeline contributes 17 fields to the VALUES clause (id, nodeId, // pipelineId, version, status, pid, uptimeSeconds, eventsIn, eventsOut, - // errorsTotal, eventsDiscarded, bytesIn, bytesOut, utilization, recentLogs, lastUpdated). + // errorsTotal, eventsDiscarded, bytesIn, bytesOut, utilization, configChecksum, + // recentLogs, lastUpdated). const call = prismaMock.$executeRaw.mock.calls[0]!; const innerSql = call[1] as { values: unknown[] }; // Prisma.join produces a single Sql object whose .values is a flat array - // of all row values concatenated. With 16 fields per row × 100 rows = 1600 values. - expect(innerSql.values).toHaveLength(100 * 16); + // of all row values concatenated. With 17 fields per row × 100 rows = 1700 values. + expect(innerSql.values).toHaveLength(100 * 17); }); it("preserves ordering invariant: $executeRaw resolves before downstream code runs", async () => { diff --git a/src/server/services/__tests__/leader-guard.test.ts b/src/server/services/__tests__/leader-guard.test.ts index df2e31ce..0362bf10 100644 --- a/src/server/services/__tests__/leader-guard.test.ts +++ b/src/server/services/__tests__/leader-guard.test.ts @@ -39,6 +39,11 @@ vi.mock("@/server/services/fleet-alert-service", () => ({ initFleetAlertService: () => mockInitFleetAlertService(), })); +const mockInitGitSyncRetryService = vi.fn(); +vi.mock("@/server/services/git-sync-retry", () => ({ + initGitSyncRetryService: () => mockInitGitSyncRetryService(), +})); + // Mock prisma and other dependencies used by register() vi.mock("@/lib/prisma", () => ({ prisma: { @@ -62,6 +67,7 @@ const allSingletonInits = () => [ mockInitAutoRollbackService, mockInitStagedRolloutService, mockInitFleetAlertService, + mockInitGitSyncRetryService, ]; // ─── Tests ────────────────────────────────────────────────────────────────── @@ -88,7 +94,7 @@ describe("Leader guard — instrumentation.ts", () => { process.env.NEXT_RUNTIME = originalRuntime; }); - it("leader starts all 5 singleton services", async () => { + it("leader starts all singleton services", async () => { mockIsLeader.mockReturnValue(true); const { register } = await import("@/instrumentation"); @@ -99,7 +105,7 @@ describe("Leader guard — instrumentation.ts", () => { } }); - it("non-leader skips all 5 singleton services", async () => { + it("non-leader skips all singleton services", async () => { mockIsLeader.mockReturnValue(false); const { register } = await import("@/instrumentation"); @@ -154,6 +160,8 @@ describe("Leader guard — instrumentation.ts", () => { // Advance timer to trigger the failover polling interval await vi.advanceTimersByTimeAsync(mockLeaderElection.renewIntervalMs); + // Extra flush for all sequential async imports in startSingletonServices + await vi.advanceTimersByTimeAsync(0); // Now services should have started for (const init of allSingletonInits()) { diff --git a/src/server/services/__tests__/metric-store-lru.test.ts b/src/server/services/__tests__/metric-store-lru.test.ts new file mode 100644 index 00000000..a3593cdd --- /dev/null +++ b/src/server/services/__tests__/metric-store-lru.test.ts @@ -0,0 +1,93 @@ +import { vi, describe, it, expect, afterEach } from "vitest"; +import { MetricStore } from "@/server/services/metric-store"; + +describe("MetricStore LRU eviction", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("evicts least-recently-updated streams when maxKeys is exceeded", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-06-01T00:00:00Z")); + + const store = new MetricStore({ maxKeys: 3 }); + + // Seed 3 streams + const seedStream = (comp: string) => { + store.recordTotals("n1", "p1", comp, { + receivedEventsTotal: 0, + sentEventsTotal: 0, + }); + vi.advanceTimersByTime(5000); + store.recordTotals("n1", "p1", comp, { + receivedEventsTotal: 100, + sentEventsTotal: 90, + }); + }; + + seedStream("comp-a"); + vi.advanceTimersByTime(1000); + seedStream("comp-b"); + vi.advanceTimersByTime(1000); + seedStream("comp-c"); + + // All 3 should exist + expect(store.getStreamCount()).toBe(3); + + // Adding a 4th should evict the oldest (comp-a) + vi.advanceTimersByTime(1000); + seedStream("comp-d"); + + expect(store.getStreamCount()).toBe(3); + expect(store.getSamples("n1", "p1", "comp-a")).toHaveLength(0); + expect(store.getSamples("n1", "p1", "comp-d").length).toBeGreaterThan(0); + }); + + it("reports estimated memory usage", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-06-01T00:00:00Z")); + + const store = new MetricStore({ maxKeys: 100 }); + + store.recordTotals("n1", "p1", "comp-a", { + receivedEventsTotal: 0, + sentEventsTotal: 0, + }); + vi.advanceTimersByTime(5000); + store.recordTotals("n1", "p1", "comp-a", { + receivedEventsTotal: 100, + sentEventsTotal: 90, + }); + + const mem = store.getEstimatedMemoryBytes(); + expect(mem).toBeGreaterThan(0); + }); + + it("logs warning when approaching 80% capacity", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-06-01T00:00:00Z")); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const store = new MetricStore({ maxKeys: 5 }); + + // Fill to 80% = 4 streams + for (let i = 0; i < 4; i++) { + store.recordTotals("n1", "p1", `comp-${i}`, { + receivedEventsTotal: 0, + sentEventsTotal: 0, + }); + vi.advanceTimersByTime(5000); + store.recordTotals("n1", "p1", `comp-${i}`, { + receivedEventsTotal: 100, + sentEventsTotal: 90, + }); + vi.advanceTimersByTime(1000); + } + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("80%"), + ); + + warnSpy.mockRestore(); + }); +}); diff --git a/src/server/services/__tests__/pipeline-graph.test.ts b/src/server/services/__tests__/pipeline-graph.test.ts index c1519a40..80ecebd5 100644 --- a/src/server/services/__tests__/pipeline-graph.test.ts +++ b/src/server/services/__tests__/pipeline-graph.test.ts @@ -339,9 +339,11 @@ describe("listPipelinesForEnvironment", () => { it("returns empty array for environment with no pipelines", async () => { prismaMock.pipeline.findMany.mockResolvedValue([]); + prismaMock.pipeline.count.mockResolvedValue(0); const result = await listPipelinesForEnvironment("empty-env"); - expect(result).toEqual([]); + expect(result.pipelines).toEqual([]); + expect(result.totalCount).toBe(0); }); it("returns mapped pipelines with computed fields", async () => { @@ -386,16 +388,17 @@ describe("listPipelinesForEnvironment", () => { _count: { upstreamDeps: 0, downstreamDeps: 0 }, }, ] as never); + prismaMock.pipeline.count.mockResolvedValue(1); const result = await listPipelinesForEnvironment("env-1"); - expect(result).toHaveLength(1); - expect(result[0]!.id).toBe("pipeline-1"); - expect(result[0]!.name).toBe("Test Pipeline"); - expect(result[0]!.hasUndeployedChanges).toBe(false); - expect(result[0]!.hasStaleComponents).toBe(false); - expect(result[0]!.staleComponentNames).toEqual([]); - expect(result[0]!.tags).toEqual(["tag1"]); + expect(result.pipelines).toHaveLength(1); + expect(result.pipelines[0]!.id).toBe("pipeline-1"); + expect(result.pipelines[0]!.name).toBe("Test Pipeline"); + expect(result.pipelines[0]!.hasUndeployedChanges).toBe(false); + expect(result.pipelines[0]!.hasStaleComponents).toBe(false); + expect(result.pipelines[0]!.staleComponentNames).toEqual([]); + expect(result.pipelines[0]!.tags).toEqual(["tag1"]); }); it("detects stale shared components", async () => { @@ -439,12 +442,13 @@ describe("listPipelinesForEnvironment", () => { _count: { upstreamDeps: 0, downstreamDeps: 0 }, }, ] as never); + prismaMock.pipeline.count.mockResolvedValue(1); const result = await listPipelinesForEnvironment("env-1"); - expect(result).toHaveLength(1); - expect(result[0]!.hasStaleComponents).toBe(true); - expect(result[0]!.staleComponentNames).toEqual(["Shared HTTP Source"]); + expect(result.pipelines).toHaveLength(1); + expect(result.pipelines[0]!.hasStaleComponents).toBe(true); + expect(result.pipelines[0]!.staleComponentNames).toEqual(["Shared HTTP Source"]); }); it("marks draft pipelines as not having undeployed changes", async () => { @@ -472,10 +476,11 @@ describe("listPipelinesForEnvironment", () => { _count: { upstreamDeps: 0, downstreamDeps: 0 }, }, ] as never); + prismaMock.pipeline.count.mockResolvedValue(1); const result = await listPipelinesForEnvironment("env-2"); - expect(result).toHaveLength(1); - expect(result[0]!.hasUndeployedChanges).toBe(false); + expect(result.pipelines).toHaveLength(1); + expect(result.pipelines[0]!.hasUndeployedChanges).toBe(false); }); }); diff --git a/src/server/services/__tests__/pipeline-list-paginated.test.ts b/src/server/services/__tests__/pipeline-list-paginated.test.ts new file mode 100644 index 00000000..02df1299 --- /dev/null +++ b/src/server/services/__tests__/pipeline-list-paginated.test.ts @@ -0,0 +1,233 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; +import type { PrismaClient } from "@/generated/prisma"; + +vi.mock("@/lib/prisma", () => ({ + prisma: mockDeep(), +})); + +vi.mock("@/lib/config-generator", () => ({ + generateVectorYaml: vi.fn(), +})); + +vi.mock("@/server/services/config-crypto", () => ({ + encryptNodeConfig: vi.fn((_type: string, config: Record) => config), + decryptNodeConfig: vi.fn((_type: string, config: Record) => config), +})); + +vi.mock("@/server/services/copy-pipeline-graph", () => ({ + copyPipelineGraph: vi.fn(), +})); + +vi.mock("@/server/services/strip-env-refs", () => ({ + stripEnvRefs: vi.fn((config: Record) => ({ + config, + strippedSecrets: [], + strippedCertificates: [], + })), +})); + +import { prisma } from "@/lib/prisma"; +import { listPipelinesForEnvironment } from "@/server/services/pipeline-graph"; + +const prismaMock = prisma as unknown as DeepMockProxy; + +const NOW = new Date("2026-03-01T12:00:00Z"); + +function makePipelineRow(overrides: Partial<{ + id: string; + name: string; + isDraft: boolean; + deployedAt: Date | null; + tags: string[]; + groupId: string | null; +}> = {}) { + return { + id: overrides.id ?? "pipe-1", + name: overrides.name ?? "test-pipeline", + description: null, + isDraft: overrides.isDraft ?? false, + deployedAt: overrides.deployedAt ?? NOW, + createdAt: NOW, + updatedAt: NOW, + tags: overrides.tags ?? [], + enrichMetadata: false, + groupId: overrides.groupId ?? null, + group: overrides.groupId ? { id: overrides.groupId, name: "group-1", color: null } : null, + environment: { name: "prod" }, + createdBy: null, + updatedBy: null, + nodeStatuses: [], + nodes: [], + edges: [], + _count: { upstreamDeps: 0, downstreamDeps: 0 }, + versions: [{ version: 1, configYaml: "sources: {}", logLevel: "INFO" }], + globalConfig: null, + }; +} + +describe("listPipelinesForEnvironment — paginated", () => { + beforeEach(() => { + mockReset(prismaMock); + }); + + it("returns paginated results with nextCursor when more items exist", async () => { + const rows = Array.from({ length: 51 }, (_, i) => + makePipelineRow({ id: `pipe-${i}`, name: `pipeline-${i}` }) + ); + prismaMock.pipeline.findMany.mockResolvedValueOnce(rows as never); + prismaMock.pipeline.count.mockResolvedValueOnce(100); + + const result = await listPipelinesForEnvironment("env-1", { + limit: 50, + }); + + expect(result.pipelines).toHaveLength(50); + expect(result.nextCursor).toBe("pipe-49"); + expect(result.totalCount).toBe(100); + }); + + it("returns no nextCursor on last page", async () => { + const rows = Array.from({ length: 10 }, (_, i) => + makePipelineRow({ id: `pipe-${i}`, name: `pipeline-${i}` }) + ); + prismaMock.pipeline.findMany.mockResolvedValueOnce(rows as never); + prismaMock.pipeline.count.mockResolvedValueOnce(10); + + const result = await listPipelinesForEnvironment("env-1", { + limit: 50, + }); + + expect(result.pipelines).toHaveLength(10); + expect(result.nextCursor).toBeUndefined(); + expect(result.totalCount).toBe(10); + }); + + it("applies search filter (ILIKE on name)", async () => { + prismaMock.pipeline.findMany.mockResolvedValueOnce([] as never); + prismaMock.pipeline.count.mockResolvedValueOnce(0); + + await listPipelinesForEnvironment("env-1", { + limit: 50, + search: "nginx", + }); + + const findManyCall = prismaMock.pipeline.findMany.mock.calls[0][0]; + expect(findManyCall?.where?.AND).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: { contains: "nginx", mode: "insensitive" }, + }), + ]) + ); + }); + + it("applies status filter", async () => { + prismaMock.pipeline.findMany.mockResolvedValueOnce([] as never); + prismaMock.pipeline.count.mockResolvedValueOnce(0); + + await listPipelinesForEnvironment("env-1", { + limit: 50, + status: ["deployed"], + }); + + const findManyCall = prismaMock.pipeline.findMany.mock.calls[0][0]; + expect(findManyCall?.where?.AND).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + isDraft: false, + deployedAt: { not: null }, + }), + ]) + ); + }); + + it("applies tag filter", async () => { + prismaMock.pipeline.findMany.mockResolvedValueOnce([] as never); + prismaMock.pipeline.count.mockResolvedValueOnce(0); + + await listPipelinesForEnvironment("env-1", { + limit: 50, + tags: ["PII"], + }); + + // Tags are stored as Json so we use Prisma json path filter + const findManyCall = prismaMock.pipeline.findMany.mock.calls[0][0]; + expect(findManyCall?.where?.AND).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + tags: expect.anything(), + }), + ]) + ); + }); + + it("applies groupId filter", async () => { + prismaMock.pipeline.findMany.mockResolvedValueOnce([] as never); + prismaMock.pipeline.count.mockResolvedValueOnce(0); + + await listPipelinesForEnvironment("env-1", { + limit: 50, + groupId: "grp-1", + }); + + const findManyCall = prismaMock.pipeline.findMany.mock.calls[0][0]; + expect(findManyCall?.where?.AND).toEqual( + expect.arrayContaining([ + expect.objectContaining({ groupId: "grp-1" }), + ]) + ); + }); + + it("applies cursor-based pagination", async () => { + prismaMock.pipeline.findMany.mockResolvedValueOnce([] as never); + prismaMock.pipeline.count.mockResolvedValueOnce(0); + + await listPipelinesForEnvironment("env-1", { + limit: 50, + cursor: "pipe-49", + }); + + const findManyCall = prismaMock.pipeline.findMany.mock.calls[0][0]; + expect(findManyCall?.cursor).toEqual({ id: "pipe-49" }); + expect(findManyCall?.skip).toBe(1); + }); + + it("applies sort by name ascending", async () => { + prismaMock.pipeline.findMany.mockResolvedValueOnce([] as never); + prismaMock.pipeline.count.mockResolvedValueOnce(0); + + await listPipelinesForEnvironment("env-1", { + limit: 50, + sortBy: "name", + sortOrder: "asc", + }); + + const findManyCall = prismaMock.pipeline.findMany.mock.calls[0][0]; + expect(findManyCall?.orderBy).toEqual({ name: "asc" }); + }); + + it("applies sort by updatedAt descending (default)", async () => { + prismaMock.pipeline.findMany.mockResolvedValueOnce([] as never); + prismaMock.pipeline.count.mockResolvedValueOnce(0); + + await listPipelinesForEnvironment("env-1", { + limit: 50, + }); + + const findManyCall = prismaMock.pipeline.findMany.mock.calls[0][0]; + expect(findManyCall?.orderBy).toEqual({ updatedAt: "desc" }); + }); + + it("clamps limit to max 200", async () => { + prismaMock.pipeline.findMany.mockResolvedValueOnce([] as never); + prismaMock.pipeline.count.mockResolvedValueOnce(0); + + await listPipelinesForEnvironment("env-1", { + limit: 500, + }); + + const findManyCall = prismaMock.pipeline.findMany.mock.calls[0][0]; + expect(findManyCall?.take).toBe(201); // 200 + 1 for cursor detection + }); +}); diff --git a/src/server/services/__tests__/webhook-git-handler.test.ts b/src/server/services/__tests__/webhook-git-handler.test.ts new file mode 100644 index 00000000..fb312fb8 --- /dev/null +++ b/src/server/services/__tests__/webhook-git-handler.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock dependencies +vi.mock("@/lib/prisma", () => ({ + prisma: { + environment: { findMany: vi.fn() }, + pipeline: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn() }, + pipelineNode: { create: vi.fn(), deleteMany: vi.fn() }, + pipelineEdge: { create: vi.fn(), deleteMany: vi.fn() }, + promotionRequest: { updateMany: vi.fn(), findUnique: vi.fn() }, + deployRequest: { create: vi.fn() }, + $transaction: vi.fn(), + }, +})); + +vi.mock("@/server/services/crypto", () => ({ + decrypt: vi.fn((val: string) => val), + encrypt: vi.fn((val: string) => val), +})); + +vi.mock("@/server/services/config-crypto", () => ({ + encryptNodeConfig: vi.fn((_type: string, config: unknown) => config), +})); + +vi.mock("@/server/services/audit", () => ({ + writeAuditLog: vi.fn(), +})); + +vi.mock("@/lib/config-generator", () => ({ + importVectorConfig: vi.fn(() => ({ + nodes: [], + edges: [], + globalConfig: null, + })), + generateVectorYaml: vi.fn(() => "test: yaml"), +})); + +vi.mock("@/server/services/promotion-service", () => ({ + executePromotion: vi.fn(), +})); + +import { detectProvider } from "@/server/services/git-providers"; + +describe("detectProvider", () => { + it("detects github from URL", () => { + expect(detectProvider("https://github.com/acme/repo.git")).toBe("github"); + }); + + it("detects gitlab from URL", () => { + expect(detectProvider("https://gitlab.com/acme/repo")).toBe("gitlab"); + }); + + it("detects bitbucket from URL", () => { + expect(detectProvider("https://bitbucket.org/acme/repo")).toBe("bitbucket"); + }); + + it("detects github from SSH URL", () => { + expect(detectProvider("git@github.com:acme/repo.git")).toBe("github"); + }); + + it("returns null for unknown domain", () => { + expect(detectProvider("https://custom-git.internal/acme/repo")).toBeNull(); + }); +}); + +describe("getProvider", () => { + it("returns provider from explicit gitProvider field", async () => { + const { getProvider } = await import("@/server/services/git-providers"); + const provider = getProvider({ gitProvider: "gitlab", gitRepoUrl: "https://github.com/foo/bar" }); + expect(provider?.name).toBe("gitlab"); + }); + + it("auto-detects provider from repoUrl when gitProvider is null", async () => { + const { getProvider } = await import("@/server/services/git-providers"); + const provider = getProvider({ gitProvider: null, gitRepoUrl: "https://github.com/foo/bar" }); + expect(provider?.name).toBe("github"); + }); + + it("returns null when no provider can be resolved", async () => { + const { getProvider } = await import("@/server/services/git-providers"); + const provider = getProvider({ gitProvider: null, gitRepoUrl: null }); + expect(provider).toBeNull(); + }); +}); diff --git a/src/server/services/alert-evaluator.ts b/src/server/services/alert-evaluator.ts index 1e5d3ec1..397fb56c 100644 --- a/src/server/services/alert-evaluator.ts +++ b/src/server/services/alert-evaluator.ts @@ -5,6 +5,7 @@ import type { AlertRule, AlertEvent, } from "@/generated/prisma"; +import { getConfigDrift } from "@/server/services/drift-metrics"; // --------------------------------------------------------------------------- // Fleet-scoped metrics — handled by FleetAlertService, not per-node heartbeat. @@ -15,6 +16,7 @@ export const FLEET_METRICS = new Set([ "fleet_throughput_drop", "fleet_event_volume", "node_load_imbalance", + "version_drift", ]); // --------------------------------------------------------------------------- @@ -204,6 +206,12 @@ async function readMetricValue( case "pipeline_crashed": return getPipelineCrashed(nodeId, pipelineId); + case "config_drift": { + const drift = await getConfigDrift(nodeId, pipelineId); + if (drift === null) return null; + return drift.value; + } + default: return null; } @@ -365,6 +373,8 @@ const METRIC_LABELS: Record = { fleet_throughput_drop: "Fleet throughput drop", fleet_event_volume: "Fleet event volume", node_load_imbalance: "Node load imbalance", + version_drift: "Version drift", + config_drift: "Config drift", deploy_requested: "Deploy requested", deploy_completed: "Deploy completed", deploy_rejected: "Deploy rejected", @@ -376,6 +386,7 @@ const METRIC_LABELS: Record = { node_joined: "Node joined", node_left: "Node left", promotion_completed: "Promotion completed", + git_sync_failed: "Git sync failed", }; const CONDITION_LABELS: Record = { diff --git a/src/server/services/deploy-agent.ts b/src/server/services/deploy-agent.ts index c761fc05..74494eac 100644 --- a/src/server/services/deploy-agent.ts +++ b/src/server/services/deploy-agent.ts @@ -5,7 +5,7 @@ import { validateConfig } from "@/server/services/validator"; import { createVersion } from "@/server/services/pipeline-version"; import { decryptNodeConfig } from "@/server/services/config-crypto"; import { startSystemVector, stopSystemVector } from "@/server/services/system-vector"; -import { gitSyncCommitPipeline } from "@/server/services/git-sync"; +import { gitSyncCommitPipeline, toFilenameSlug } from "@/server/services/git-sync"; import { relayPush } from "@/server/services/push-broadcast"; export interface AgentDeployResult { @@ -155,10 +155,37 @@ export async function deployAgent( version.configYaml, { name: user?.name ?? (isServiceAccount ? "VectorFlow Service Account" : "VectorFlow User"), email: user?.email ?? "noreply@vectorflow" }, changelog ?? `Deploy pipeline: ${pipeline.name}`, + pipeline.gitPath, ); if (!result.success) { gitSyncError = result.error; } + + // Queue for retry if git sync failed + if (!result.success && result.error) { + const { createGitSyncJob } = await import("@/server/services/git-sync-retry"); + await createGitSyncJob({ + environmentId: pipeline.environmentId, + pipelineId: pipeline.id, + action: "commit", + configYaml: version.configYaml, + commitMessage: changelog ?? `Deploy pipeline: ${pipeline.name}`, + authorName: user?.name ?? (isServiceAccount ? "VectorFlow Service Account" : "VectorFlow User"), + authorEmail: user?.email ?? "noreply@vectorflow", + error: result.error, + }).catch((err) => { + console.error("[deploy-agent] Failed to create git sync retry job:", err); + }); + } + + // Set gitPath on first successful sync + if (result.success && !pipeline.gitPath) { + const derivedPath = `${toFilenameSlug(environment.name)}/${toFilenameSlug(pipeline.name)}.yaml`; + await prisma.pipeline.update({ + where: { id: pipeline.id }, + data: { gitPath: derivedPath }, + }).catch(() => {}); // Non-blocking + } } // 4. For system pipelines, start the local Vector process instead of diff --git a/src/server/services/drift-metrics.ts b/src/server/services/drift-metrics.ts new file mode 100644 index 00000000..7bc3d926 --- /dev/null +++ b/src/server/services/drift-metrics.ts @@ -0,0 +1,212 @@ +// src/server/services/drift-metrics.ts +import { prisma } from "@/lib/prisma"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface DriftedPipeline { + pipelineId: string; + pipelineName: string; + expectedVersion: number; + /** Map of nodeId -> running version */ + nodeVersions: Record; +} + +export interface VersionDriftResult { + /** Number of pipelines with version drift */ + value: number; + /** Details for alert message building */ + driftedPipelines: DriftedPipeline[]; +} + +export interface ConfigDriftResult { + /** Number of pipelines with config checksum mismatch on this node */ + value: number; +} + +// --------------------------------------------------------------------------- +// Version Drift -- fleet-wide, evaluated by FleetAlertService +// --------------------------------------------------------------------------- + +/** + * Compute version drift across all deployed pipelines in an environment. + * + * A pipeline has version drift if any node is running a version different + * from the latest deployed version. + * + * Returns the count of drifted pipelines, or null if no data. + */ +export async function getVersionDrift( + environmentId: string, +): Promise { + // Get all pipeline statuses for nodes in this environment + const statuses = await prisma.nodePipelineStatus.findMany({ + where: { + node: { environmentId }, + }, + select: { + pipelineId: true, + nodeId: true, + version: true, + }, + }); + + if (statuses.length === 0) return null; + + // Get latest deployed version for each pipeline in this environment + const pipelineIds = [...new Set(statuses.map((s) => s.pipelineId))]; + + const pipelines = await prisma.pipeline.findMany({ + where: { + id: { in: pipelineIds }, + environmentId, + }, + select: { + id: true, + name: true, + versions: { + orderBy: { version: "desc" as const }, + take: 1, + select: { version: true }, + }, + }, + }); + + // Build a map of pipelineId -> { name, latestVersion } + const pipelineMap = new Map(); + for (const p of pipelines) { + const latestVersion = p.versions[0]?.version ?? 1; + pipelineMap.set(p.id, { name: p.name, latestVersion }); + } + + // Group statuses by pipeline + const statusesByPipeline = new Map>(); + for (const s of statuses) { + const existing = statusesByPipeline.get(s.pipelineId) ?? []; + existing.push({ nodeId: s.nodeId, version: s.version }); + statusesByPipeline.set(s.pipelineId, existing); + } + + // Check each pipeline for drift + const driftedPipelines: DriftedPipeline[] = []; + + for (const [pipelineId, nodeStatuses] of statusesByPipeline.entries()) { + const pipelineInfo = pipelineMap.get(pipelineId); + if (!pipelineInfo) continue; + + const { name, latestVersion } = pipelineInfo; + + // Check if any node runs a version different from the latest + const hasDrift = nodeStatuses.some((ns) => ns.version !== latestVersion); + + if (hasDrift) { + const nodeVersions: Record = {}; + for (const ns of nodeStatuses) { + nodeVersions[ns.nodeId] = ns.version; + } + driftedPipelines.push({ + pipelineId, + pipelineName: name, + expectedVersion: latestVersion, + nodeVersions, + }); + } + } + + return { + value: driftedPipelines.length, + driftedPipelines, + }; +} + +// --------------------------------------------------------------------------- +// Config Drift -- per-node, evaluated during heartbeat processing +// --------------------------------------------------------------------------- + +/** + * Compute config drift for a specific node. + * + * Compares the agent-reported configChecksum against the server-side expected + * checksum. Pipelines where the agent does not report a checksum (older agents) + * are ignored -- they do not count as drift. + * + * Returns the count of mismatched pipelines, or null if no data. + */ +export async function getConfigDrift( + nodeId: string, + pipelineId: string | null, +): Promise { + // Get pipeline statuses for this node (with checksum) + const where: Record = { nodeId }; + if (pipelineId) where.pipelineId = pipelineId; + + const statuses = await prisma.nodePipelineStatus.findMany({ + where, + select: { + pipelineId: true, + configChecksum: true, + pipeline: { select: { id: true, name: true } }, + }, + }); + + if (statuses.length === 0) return null; + + // Filter to only pipelines where the agent reports a checksum + const statusesWithChecksum = statuses.filter((s) => s.configChecksum != null); + + if (statusesWithChecksum.length === 0) { + // All pipelines lack checksums (older agent) -- no drift detectable + return { value: 0 }; + } + + // Get expected checksums from the in-memory cache + const pipelineIds = statusesWithChecksum.map((s) => s.pipelineId); + const expectedChecksums = getExpectedChecksums(pipelineIds); + + let driftCount = 0; + for (const status of statusesWithChecksum) { + const expected = expectedChecksums.get(status.pipelineId); + if (expected && status.configChecksum !== expected) { + driftCount++; + } + // If no expected checksum is cached yet, skip -- don't flag as drift + } + + return { value: driftCount }; +} + +// --------------------------------------------------------------------------- +// Expected Config Checksum Cache +// --------------------------------------------------------------------------- + +/** + * In-memory cache of the expected config checksum per pipeline. + * Populated by the config endpoint when it serves configs to agents. + * Keyed by pipelineId -> SHA256 hex string. + */ +const expectedChecksumCache = new Map(); + +/** Store the expected checksum for a pipeline (called from config endpoint). */ +export function setExpectedChecksum(pipelineId: string, checksum: string): void { + expectedChecksumCache.set(pipelineId, checksum); +} + +/** Read expected checksums for a set of pipeline IDs. */ +export function getExpectedChecksums( + pipelineIds: string[], +): Map { + const result = new Map(); + for (const id of pipelineIds) { + const checksum = expectedChecksumCache.get(id); + if (checksum) { + result.set(id, checksum); + } + } + return result; +} + +/** Clear the cache (for testing). */ +export function clearExpectedChecksumCache(): void { + expectedChecksumCache.clear(); +} diff --git a/src/server/services/fleet-alert-service.ts b/src/server/services/fleet-alert-service.ts index 9e5a6b51..00d9f525 100644 --- a/src/server/services/fleet-alert-service.ts +++ b/src/server/services/fleet-alert-service.ts @@ -12,6 +12,7 @@ import { getNodeLoadImbalance, } from "@/server/services/fleet-metrics"; import type { LoadImbalanceResult } from "@/server/services/fleet-metrics"; +import { getVersionDrift } from "@/server/services/drift-metrics"; // Re-export the constant for downstream use (e.g. T03 validation) export { FLEET_METRICS } from "@/server/services/alert-evaluator"; @@ -247,6 +248,11 @@ export class FleetAlertService { return getFleetThroughputDrop(environmentId); case "node_load_imbalance": return getNodeLoadImbalance(environmentId); + case "version_drift": { + const drift = await getVersionDrift(environmentId); + if (drift === null) return null; + return drift.value; + } default: return null; } @@ -261,6 +267,7 @@ export class FleetAlertService { fleet_throughput_drop: "Fleet throughput drop", fleet_event_volume: "Fleet event volume", node_load_imbalance: "Node load imbalance", + version_drift: "Version drift", }; const CONDITION_LABELS: Record = { diff --git a/src/server/services/fleet-data.ts b/src/server/services/fleet-data.ts index 923934ba..077e95df 100644 --- a/src/server/services/fleet-data.ts +++ b/src/server/services/fleet-data.ts @@ -12,6 +12,8 @@ export interface FleetOverview { eventsOut: number; errorRate: number; nodeCount: number; + versionDriftCount: number; + configDriftCount: number; } export interface VolumeBucket { @@ -130,7 +132,85 @@ export async function getFleetOverview( const nodeCount = Number(nodeRows[0]?.count ?? 0); const errorRate = eventsIn > 0 ? errorsTotal / eventsIn : 0; - return { bytesIn, bytesOut, eventsIn, eventsOut, errorRate, nodeCount }; + // Compute drift stats + const [pipelineStatuses, deployedPipelines] = await Promise.all([ + prisma.nodePipelineStatus.findMany({ + where: { + node: { environmentId }, + }, + select: { + nodeId: true, + pipelineId: true, + version: true, + configChecksum: true, + }, + }), + prisma.pipeline.findMany({ + where: { + environmentId, + isDraft: false, + deployedAt: { not: null }, + }, + select: { + id: true, + versions: { + orderBy: { version: "desc" as const }, + take: 1, + select: { version: true }, + }, + }, + }), + ]); + + // Build latest version map + const latestVersionMap = new Map(); + for (const p of deployedPipelines) { + latestVersionMap.set(p.id, p.versions[0]?.version ?? 1); + } + + // Count pipelines with version drift (any node running non-latest version) + const pipelineVersions = new Map>(); + for (const s of pipelineStatuses) { + const versions = pipelineVersions.get(s.pipelineId) ?? new Set(); + versions.add(s.version); + pipelineVersions.set(s.pipelineId, versions); + } + + let versionDriftCount = 0; + for (const [pipelineId, versions] of pipelineVersions.entries()) { + const latest = latestVersionMap.get(pipelineId); + if (latest === undefined) continue; + const hasNonLatest = [...versions].some((v) => v !== latest); + if (hasNonLatest) versionDriftCount++; + } + + // Config drift: count pipelines where any node's reported checksum differs + // from the expected checksum in the drift-metrics cache. + const { getExpectedChecksums } = await import("@/server/services/drift-metrics"); + const pipelineIdsWithChecksum = pipelineStatuses + .filter((s) => s.configChecksum != null) + .map((s) => s.pipelineId); + const expectedChecksums = getExpectedChecksums([...new Set(pipelineIdsWithChecksum)]); + const configDriftPipelines = new Set(); + for (const s of pipelineStatuses) { + if (s.configChecksum == null) continue; + const expected = expectedChecksums.get(s.pipelineId); + if (expected && s.configChecksum !== expected) { + configDriftPipelines.add(s.pipelineId); + } + } + const configDriftCount = configDriftPipelines.size; + + return { + bytesIn, + bytesOut, + eventsIn, + eventsOut, + errorRate, + nodeCount, + versionDriftCount, + configDriftCount, + }; } // ─── Volume Trend ─────────────────────────────────────────────────────────── diff --git a/src/server/services/git-providers/__tests__/bitbucket.test.ts b/src/server/services/git-providers/__tests__/bitbucket.test.ts new file mode 100644 index 00000000..466745ae --- /dev/null +++ b/src/server/services/git-providers/__tests__/bitbucket.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import crypto from "crypto"; +import { BitbucketProvider } from "../bitbucket"; + +const provider = new BitbucketProvider(); + +describe("BitbucketProvider", () => { + describe("verifyWebhookSignature", () => { + it("returns true for valid HMAC-SHA256 signature", () => { + const secret = "bb-secret"; + const body = '{"push":{"changes":[]}}'; + const hmac = crypto.createHmac("sha256", secret).update(body).digest("hex"); + const signature = `sha256=${hmac}`; + + const headers = new Headers({ "x-hub-signature": signature }); + expect(provider.verifyWebhookSignature(headers, body, secret)).toBe(true); + }); + + it("returns false for invalid signature", () => { + const headers = new Headers({ "x-hub-signature": "sha256=wrong" }); + expect(provider.verifyWebhookSignature(headers, "body", "secret")).toBe(false); + }); + + it("returns false when signature header is missing", () => { + const headers = new Headers(); + expect(provider.verifyWebhookSignature(headers, "body", "secret")).toBe(false); + }); + }); + + describe("parseWebhookEvent", () => { + it("parses a push event", () => { + const headers = new Headers({ "x-event-key": "repo:push" }); + const body = { + push: { + changes: [ + { + new: { name: "main", target: { hash: "abc123" } }, + commits: [{}], + }, + ], + }, + actor: { display_name: "Danny" }, + }; + const event = provider.parseWebhookEvent(headers, body); + expect(event.type).toBe("push"); + expect(event.branch).toBe("main"); + expect(event.afterSha).toBe("abc123"); + expect(event.pusherName).toBe("Danny"); + }); + + it("parses a fulfilled (merged) pull request", () => { + const headers = new Headers({ "x-event-key": "pullrequest:fulfilled" }); + const body = { + pullrequest: { + id: 42, + description: "", + }, + actor: { display_name: "Reviewer" }, + }; + const event = provider.parseWebhookEvent(headers, body); + expect(event.type).toBe("pull_request_merged"); + expect(event.prNumber).toBe(42); + expect(event.prBody).toContain("promo123"); + }); + + it("parses a rejected (closed) pull request", () => { + const headers = new Headers({ "x-event-key": "pullrequest:rejected" }); + const body = { + pullrequest: { id: 10, description: "test" }, + }; + const event = provider.parseWebhookEvent(headers, body); + expect(event.type).toBe("pull_request_closed"); + }); + + it("treats diagnostics:ping as ping", () => { + const headers = new Headers({ "x-event-key": "diagnostics:ping" }); + const event = provider.parseWebhookEvent(headers, {}); + expect(event.type).toBe("ping"); + }); + }); + + describe("parseRepoUrl", () => { + it("parses HTTPS URL", () => { + const result = provider.parseRepoUrl("https://bitbucket.org/acme/configs.git"); + expect(result).toEqual({ owner: "acme", repo: "configs" }); + }); + + it("parses HTTPS URL without .git", () => { + const result = provider.parseRepoUrl("https://bitbucket.org/acme/configs"); + expect(result).toEqual({ owner: "acme", repo: "configs" }); + }); + + it("parses SSH URL", () => { + const result = provider.parseRepoUrl("git@bitbucket.org:acme/configs.git"); + expect(result).toEqual({ owner: "acme", repo: "configs" }); + }); + + it("throws for non-Bitbucket URL", () => { + expect(() => provider.parseRepoUrl("https://github.com/acme/configs")).toThrow(); + }); + }); +}); diff --git a/src/server/services/git-providers/__tests__/github.test.ts b/src/server/services/git-providers/__tests__/github.test.ts new file mode 100644 index 00000000..a64b2993 --- /dev/null +++ b/src/server/services/git-providers/__tests__/github.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "vitest"; +import crypto from "crypto"; +import { GitHubProvider } from "../github"; + +const provider = new GitHubProvider(); + +describe("GitHubProvider", () => { + describe("verifyWebhookSignature", () => { + it("returns true for valid HMAC-SHA256 signature", () => { + const secret = "test-secret-123"; + const body = '{"ref":"refs/heads/main"}'; + const hmac = crypto.createHmac("sha256", secret).update(body).digest("hex"); + const signature = `sha256=${hmac}`; + + const headers = new Headers({ "x-hub-signature-256": signature }); + expect(provider.verifyWebhookSignature(headers, body, secret)).toBe(true); + }); + + it("returns false for invalid signature", () => { + const headers = new Headers({ "x-hub-signature-256": "sha256=invalid" }); + expect(provider.verifyWebhookSignature(headers, "body", "secret")).toBe(false); + }); + + it("returns false when signature header is missing", () => { + const headers = new Headers(); + expect(provider.verifyWebhookSignature(headers, "body", "secret")).toBe(false); + }); + }); + + describe("parseWebhookEvent", () => { + it("parses a ping event", () => { + const headers = new Headers({ "x-github-event": "ping" }); + const event = provider.parseWebhookEvent(headers, {}); + expect(event.type).toBe("ping"); + }); + + it("parses a push event with changed files", () => { + const headers = new Headers({ "x-github-event": "push" }); + const body = { + ref: "refs/heads/main", + after: "abc123", + pusher: { name: "danny" }, + commits: [ + { added: ["staging/new.yaml"], modified: ["staging/existing.yaml"], removed: [] }, + ], + }; + const event = provider.parseWebhookEvent(headers, body); + expect(event.type).toBe("push"); + expect(event.branch).toBe("main"); + expect(event.afterSha).toBe("abc123"); + expect(event.pusherName).toBe("danny"); + expect(event.commits).toHaveLength(1); + expect(event.commits[0].added).toEqual(["staging/new.yaml"]); + }); + + it("parses a merged pull request event", () => { + const headers = new Headers({ "x-github-event": "pull_request" }); + const body = { + action: "closed", + pull_request: { + merged: true, + body: "", + number: 42, + }, + }; + const event = provider.parseWebhookEvent(headers, body); + expect(event.type).toBe("pull_request_merged"); + expect(event.prBody).toBe(""); + expect(event.prNumber).toBe(42); + }); + + it("parses a closed-without-merge PR event", () => { + const headers = new Headers({ "x-github-event": "pull_request" }); + const body = { + action: "closed", + pull_request: { merged: false, body: "test", number: 10 }, + }; + const event = provider.parseWebhookEvent(headers, body); + expect(event.type).toBe("pull_request_closed"); + }); + }); + + describe("parseRepoUrl", () => { + it("parses HTTPS URL", () => { + const result = provider.parseRepoUrl("https://github.com/acme/configs.git"); + expect(result).toEqual({ owner: "acme", repo: "configs" }); + }); + + it("parses HTTPS URL without .git", () => { + const result = provider.parseRepoUrl("https://github.com/acme/configs"); + expect(result).toEqual({ owner: "acme", repo: "configs" }); + }); + + it("parses SSH URL", () => { + const result = provider.parseRepoUrl("git@github.com:acme/configs.git"); + expect(result).toEqual({ owner: "acme", repo: "configs" }); + }); + + it("throws for invalid URL", () => { + expect(() => provider.parseRepoUrl("https://gitlab.com/acme/configs")).toThrow(); + }); + }); +}); diff --git a/src/server/services/git-providers/__tests__/gitlab.test.ts b/src/server/services/git-providers/__tests__/gitlab.test.ts new file mode 100644 index 00000000..e8276912 --- /dev/null +++ b/src/server/services/git-providers/__tests__/gitlab.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { GitLabProvider } from "../gitlab"; + +const provider = new GitLabProvider(); + +describe("GitLabProvider", () => { + describe("verifyWebhookSignature", () => { + it("returns true when X-Gitlab-Token matches secret", () => { + const secret = "my-webhook-secret"; + const headers = new Headers({ "x-gitlab-token": secret }); + expect(provider.verifyWebhookSignature(headers, "any-body", secret)).toBe(true); + }); + + it("returns false for wrong token", () => { + const headers = new Headers({ "x-gitlab-token": "wrong" }); + expect(provider.verifyWebhookSignature(headers, "any-body", "correct")).toBe(false); + }); + + it("returns false when token header is missing", () => { + const headers = new Headers(); + expect(provider.verifyWebhookSignature(headers, "body", "secret")).toBe(false); + }); + }); + + describe("parseWebhookEvent", () => { + it("parses a push hook", () => { + const headers = new Headers({ "x-gitlab-event": "Push Hook" }); + const body = { + ref: "refs/heads/main", + after: "def456", + user_username: "danny", + commits: [ + { added: ["prod/pipeline.yaml"], modified: [], removed: [] }, + ], + }; + const event = provider.parseWebhookEvent(headers, body); + expect(event.type).toBe("push"); + expect(event.branch).toBe("main"); + expect(event.afterSha).toBe("def456"); + expect(event.pusherName).toBe("danny"); + expect(event.commits[0].added).toEqual(["prod/pipeline.yaml"]); + }); + + it("parses a merged merge request hook", () => { + const headers = new Headers({ "x-gitlab-event": "Merge Request Hook" }); + const body = { + object_attributes: { + action: "merge", + state: "merged", + description: "", + iid: 5, + }, + user: { username: "reviewer" }, + }; + const event = provider.parseWebhookEvent(headers, body); + expect(event.type).toBe("pull_request_merged"); + expect(event.prBody).toBe(""); + expect(event.prNumber).toBe(5); + }); + + it("parses a closed (not merged) MR", () => { + const headers = new Headers({ "x-gitlab-event": "Merge Request Hook" }); + const body = { + object_attributes: { action: "close", state: "closed", iid: 6 }, + }; + const event = provider.parseWebhookEvent(headers, body); + expect(event.type).toBe("pull_request_closed"); + }); + + it("treats Test Hook as ping", () => { + const headers = new Headers({ "x-gitlab-event": "Test Hook" }); + const event = provider.parseWebhookEvent(headers, {}); + expect(event.type).toBe("ping"); + }); + }); + + describe("parseRepoUrl", () => { + it("parses HTTPS URL", () => { + const result = provider.parseRepoUrl("https://gitlab.com/acme/configs.git"); + expect(result).toEqual({ owner: "acme", repo: "configs" }); + }); + + it("parses nested group URL", () => { + const result = provider.parseRepoUrl("https://gitlab.com/acme/infra/configs"); + expect(result).toEqual({ owner: "acme/infra", repo: "configs" }); + }); + + it("parses SSH URL", () => { + const result = provider.parseRepoUrl("git@gitlab.com:acme/configs.git"); + expect(result).toEqual({ owner: "acme", repo: "configs" }); + }); + + it("parses self-hosted GitLab URL", () => { + const result = provider.parseRepoUrl("https://gitlab.internal.io/team/repo.git"); + expect(result).toEqual({ owner: "team", repo: "repo" }); + }); + + it("throws for non-GitLab URL", () => { + expect(() => provider.parseRepoUrl("https://github.com/acme/configs")).toThrow(); + }); + }); +}); diff --git a/src/server/services/git-providers/__tests__/index.test.ts b/src/server/services/git-providers/__tests__/index.test.ts new file mode 100644 index 00000000..4abb32ec --- /dev/null +++ b/src/server/services/git-providers/__tests__/index.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { detectProvider, getProvider } from "../index"; + +describe("detectProvider", () => { + it("detects github from HTTPS URL", () => { + expect(detectProvider("https://github.com/acme/configs.git")).toBe("github"); + }); + + it("detects github from SSH URL", () => { + expect(detectProvider("git@github.com:acme/configs.git")).toBe("github"); + }); + + it("detects gitlab from HTTPS URL", () => { + expect(detectProvider("https://gitlab.com/acme/configs")).toBe("gitlab"); + }); + + it("detects gitlab from SSH URL", () => { + expect(detectProvider("git@gitlab.com:acme/configs.git")).toBe("gitlab"); + }); + + it("detects bitbucket from HTTPS URL", () => { + expect(detectProvider("https://bitbucket.org/acme/configs")).toBe("bitbucket"); + }); + + it("detects bitbucket from SSH URL", () => { + expect(detectProvider("git@bitbucket.org:acme/configs.git")).toBe("bitbucket"); + }); + + it("returns null for self-hosted GitLab instance (no .gitlab.com)", () => { + // Custom domains require explicit gitProvider field + expect(detectProvider("https://git.internal.corp/team/repo")).toBeNull(); + }); + + it("returns null for invalid URL", () => { + expect(detectProvider("not-a-url")).toBeNull(); + }); +}); + +describe("getProvider", () => { + it("uses explicit gitProvider over URL detection", () => { + const provider = getProvider({ + gitProvider: "gitlab", + gitRepoUrl: "https://github.com/acme/configs", + }); + expect(provider?.name).toBe("gitlab"); + }); + + it("auto-detects from URL when gitProvider is null", () => { + const provider = getProvider({ + gitProvider: null, + gitRepoUrl: "https://github.com/acme/configs", + }); + expect(provider?.name).toBe("github"); + }); + + it("returns null when both gitProvider and gitRepoUrl are null", () => { + const provider = getProvider({ gitProvider: null, gitRepoUrl: null }); + expect(provider).toBeNull(); + }); + + it("returns null for unsupported explicit provider name", () => { + const provider = getProvider({ + gitProvider: "mercurial", + gitRepoUrl: null, + }); + expect(provider).toBeNull(); + }); + + it("returns correct provider for each supported type", () => { + expect(getProvider({ gitProvider: "github", gitRepoUrl: null })?.name).toBe("github"); + expect(getProvider({ gitProvider: "gitlab", gitRepoUrl: null })?.name).toBe("gitlab"); + expect(getProvider({ gitProvider: "bitbucket", gitRepoUrl: null })?.name).toBe("bitbucket"); + }); +}); diff --git a/src/server/services/git-providers/bitbucket.ts b/src/server/services/git-providers/bitbucket.ts new file mode 100644 index 00000000..4662955d --- /dev/null +++ b/src/server/services/git-providers/bitbucket.ts @@ -0,0 +1,323 @@ +import crypto from "crypto"; +import type { + GitProvider, + GitWebhookEvent, + CreatePROptions, + RepoCoordinates, +} from "./types"; + +/** + * Bitbucket Cloud REST API 2.0 provider. + * + * Webhook verification uses HMAC-SHA256 on the X-Hub-Signature header. + * PR operations use the Bitbucket 2.0 pullrequests API. + */ +export class BitbucketProvider implements GitProvider { + readonly name = "bitbucket" as const; + + verifyWebhookSignature(headers: Headers, body: string, secret: string): boolean { + const signature = headers.get("x-hub-signature"); + if (!signature) return false; + + const expected = + "sha256=" + + crypto.createHmac("sha256", secret).update(body).digest("hex"); + + const sigBuf = Buffer.from(signature); + const expBuf = Buffer.from(expected); + if (sigBuf.length !== expBuf.length) return false; + + return crypto.timingSafeEqual(sigBuf, expBuf); + } + + parseWebhookEvent(headers: Headers, body: Record): GitWebhookEvent { + const eventKey = headers.get("x-event-key") ?? ""; + + if (eventKey === "diagnostics:ping" || eventKey === "") { + return { + type: "ping", + branch: null, + commits: [], + prBody: null, + prNumber: null, + afterSha: null, + pusherName: null, + }; + } + + if (eventKey === "pullrequest:fulfilled") { + const pr = body.pullrequest as Record | undefined; + return { + type: "pull_request_merged", + branch: null, + commits: [], + prBody: (pr?.description as string) ?? null, + prNumber: (pr?.id as number) ?? null, + afterSha: null, + pusherName: (body.actor as { display_name?: string } | undefined)?.display_name ?? null, + }; + } + + if (eventKey === "pullrequest:rejected") { + const pr = body.pullrequest as Record | undefined; + return { + type: "pull_request_closed", + branch: null, + commits: [], + prBody: (pr?.description as string) ?? null, + prNumber: (pr?.id as number) ?? null, + afterSha: null, + pusherName: null, + }; + } + + if (eventKey === "repo:push") { + const push = body.push as { changes?: Array> } | undefined; + const changes = push?.changes ?? []; + + // Extract branch from the first change's new ref + let branch: string | null = null; + let afterSha: string | null = null; + const commits: Array<{ added: string[]; modified: string[]; removed: string[] }> = []; + + for (const change of changes) { + const newRef = change.new as { name?: string; target?: { hash?: string } } | undefined; + if (!branch && newRef?.name) { + branch = newRef.name; + } + if (!afterSha && newRef?.target?.hash) { + afterSha = newRef.target.hash; + } + + // Bitbucket push events don't include file-level changes in the webhook payload. + // We need to handle this in the webhook handler by fetching the diff. + const rawCommits = (change.commits ?? []) as Array>; + // Bitbucket webhook push payloads don't include per-file changes. + // The webhook handler will need to fetch changed files via the API. + for (let i = 0; i < rawCommits.length; i++) { + commits.push({ added: [], modified: [], removed: [] }); + } + } + + const actor = body.actor as { display_name?: string } | undefined; + + return { + type: "push", + branch, + commits, + prBody: null, + prNumber: null, + afterSha, + pusherName: actor?.display_name ?? null, + }; + } + + return { + type: "unknown", + branch: null, + commits: [], + prBody: null, + prNumber: null, + afterSha: null, + pusherName: null, + }; + } + + parseRepoUrl(repoUrl: string): RepoCoordinates { + // SSH: git@bitbucket.org:workspace/repo.git + const sshMatch = repoUrl.match(/git@bitbucket\.org:([^/]+)\/(.+?)(?:\.git)?$/); + if (sshMatch) { + return { owner: sshMatch[1], repo: sshMatch[2] }; + } + + // HTTPS: https://bitbucket.org/workspace/repo[.git] + const httpsMatch = repoUrl.match(/bitbucket\.org\/([^/]+)\/(.+?)(?:\.git)?(?:\/.*)?$/); + if (httpsMatch) { + return { owner: httpsMatch[1], repo: httpsMatch[2] }; + } + + throw new Error( + `Cannot parse Bitbucket workspace/repo from URL: "${repoUrl}". ` + + `Expected format: https://bitbucket.org/workspace/repo or git@bitbucket.org:workspace/repo.git`, + ); + } + + async fetchFileContent( + repoUrl: string, + token: string, + branch: string, + path: string, + ): Promise { + const { owner, repo } = this.parseRepoUrl(repoUrl); + const encodedPath = path.split("/").map(encodeURIComponent).join("/"); + + const res = await fetch( + `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/src/${encodeURIComponent(branch)}/${encodedPath}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (!res.ok) { + throw new Error(`Bitbucket API returned ${res.status} fetching ${path}`); + } + + return res.text(); + } + + async createBranch( + repoUrl: string, + token: string, + baseBranch: string, + newBranch: string, + ): Promise { + const { owner, repo } = this.parseRepoUrl(repoUrl); + + const res = await fetch( + `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/refs/branches`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: newBranch, + target: { hash: baseBranch }, + }), + }, + ); + + if (!res.ok) { + const errText = await res.text().catch(() => ""); + throw new Error(`Bitbucket createBranch failed (${res.status}): ${errText}`); + } + } + + async commitFile( + repoUrl: string, + token: string, + branch: string, + path: string, + content: string, + message: string, + ): Promise { + const { owner, repo } = this.parseRepoUrl(repoUrl); + + // Bitbucket uses multipart form data for the src endpoint + const form = new FormData(); + form.append(path, new Blob([content], { type: "text/plain" })); + form.append("message", message); + form.append("branch", branch); + + const res = await fetch( + `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/src`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + body: form, + }, + ); + + if (!res.ok) { + const errText = await res.text().catch(() => ""); + throw new Error(`Bitbucket commitFile failed (${res.status}): ${errText}`); + } + + // Bitbucket src endpoint doesn't return commit SHA directly; + // fetch the latest commit on the branch. + const logRes = await fetch( + `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/commits/${encodeURIComponent(branch)}?pagelen=1`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + if (logRes.ok) { + const logData = (await logRes.json()) as { values?: Array<{ hash?: string }> }; + return logData.values?.[0]?.hash ?? ""; + } + + return ""; + } + + async createPullRequest( + repoUrl: string, + token: string, + options: CreatePROptions, + ): Promise<{ url: string; number: number }> { + const { owner, repo } = this.parseRepoUrl(repoUrl); + + const res = await fetch( + `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/pullrequests`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + title: options.title, + description: options.body, + source: { branch: { name: options.headBranch } }, + destination: { branch: { name: options.baseBranch } }, + }), + }, + ); + + if (!res.ok) { + const errText = await res.text().catch(() => ""); + throw new Error(`Bitbucket createPullRequest failed (${res.status}): ${errText}`); + } + + const data = (await res.json()) as { + links?: { html?: { href?: string } }; + id?: number; + }; + + return { + url: data.links?.html?.href ?? "", + number: data.id ?? 0, + }; + } + + /** + * Fetch the list of changed files for a specific commit. + * Used to supplement push events which don't include file-level changes. + */ + async fetchCommitDiffstat( + repoUrl: string, + token: string, + commitHash: string, + ): Promise> { + const { owner, repo } = this.parseRepoUrl(repoUrl); + + const res = await fetch( + `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/diffstat/${commitHash}`, + { + headers: { Authorization: `Bearer ${token}` }, + }, + ); + + if (!res.ok) return []; + + const data = (await res.json()) as { + values?: Array<{ + new?: { path?: string }; + old?: { path?: string }; + status?: string; + }>; + }; + + return (data.values ?? []).map((v) => ({ + path: v.new?.path ?? v.old?.path ?? "", + status: + v.status === "added" + ? "added" + : v.status === "removed" + ? "removed" + : "modified", + })); + } +} diff --git a/src/server/services/git-providers/github.ts b/src/server/services/git-providers/github.ts new file mode 100644 index 00000000..931616fd --- /dev/null +++ b/src/server/services/git-providers/github.ts @@ -0,0 +1,225 @@ +import crypto from "crypto"; +import { Octokit } from "@octokit/rest"; +import type { + GitProvider, + GitWebhookEvent, + CreatePROptions, + RepoCoordinates, +} from "./types"; + +export class GitHubProvider implements GitProvider { + readonly name = "github" as const; + + verifyWebhookSignature(headers: Headers, body: string, secret: string): boolean { + const signature = headers.get("x-hub-signature-256"); + if (!signature) return false; + + const expected = + "sha256=" + + crypto.createHmac("sha256", secret).update(body).digest("hex"); + + const sigBuf = Buffer.from(signature); + const expBuf = Buffer.from(expected); + if (sigBuf.length !== expBuf.length) return false; + + return crypto.timingSafeEqual(sigBuf, expBuf); + } + + parseWebhookEvent(headers: Headers, body: Record): GitWebhookEvent { + const eventType = headers.get("x-github-event") ?? "push"; + + if (eventType === "ping") { + return { + type: "ping", + branch: null, + commits: [], + prBody: null, + prNumber: null, + afterSha: null, + pusherName: null, + }; + } + + if (eventType === "pull_request") { + const pr = body.pull_request as Record | undefined; + const action = body.action as string | undefined; + const merged = pr?.merged as boolean | undefined; + + const type = + action === "closed" && merged + ? "pull_request_merged" + : action === "closed" + ? "pull_request_closed" + : "unknown"; + + return { + type, + branch: null, + commits: [], + prBody: (pr?.body as string) ?? null, + prNumber: (pr?.number as number) ?? null, + afterSha: null, + pusherName: null, + }; + } + + // Push event + const ref = body.ref as string | undefined; + const branch = ref?.replace("refs/heads/", "") ?? null; + const rawCommits = (body.commits ?? []) as Array<{ + added?: string[]; + modified?: string[]; + removed?: string[]; + }>; + const commits = rawCommits.map((c) => ({ + added: c.added ?? [], + modified: c.modified ?? [], + removed: c.removed ?? [], + })); + + return { + type: "push", + branch, + commits, + prBody: null, + prNumber: null, + afterSha: (body.after as string) ?? null, + pusherName: (body.pusher as { name?: string } | undefined)?.name ?? null, + }; + } + + parseRepoUrl(repoUrl: string): RepoCoordinates { + // SSH format: git@github.com:owner/repo.git + const sshMatch = repoUrl.match(/git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/); + if (sshMatch) { + return { owner: sshMatch[1], repo: sshMatch[2] }; + } + + // HTTPS format: https://github.com/owner/repo[.git] + const httpsMatch = repoUrl.match(/github\.com\/([^/]+)\/(.+?)(?:\.git)?(?:\/.*)?$/); + if (httpsMatch) { + return { owner: httpsMatch[1], repo: httpsMatch[2] }; + } + + throw new Error( + `Cannot parse GitHub owner/repo from URL: "${repoUrl}". ` + + `Expected format: https://github.com/owner/repo or git@github.com:owner/repo.git`, + ); + } + + async fetchFileContent( + repoUrl: string, + token: string, + branch: string, + path: string, + ): Promise { + const { owner, repo } = this.parseRepoUrl(repoUrl); + const encodedPath = path.split("/").map(encodeURIComponent).join("/"); + + const res = await fetch( + `https://api.github.com/repos/${owner}/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(branch)}`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.raw", + }, + }, + ); + + if (!res.ok) { + throw new Error(`GitHub API returned ${res.status} fetching ${path}`); + } + + return res.text(); + } + + async createBranch( + repoUrl: string, + token: string, + baseBranch: string, + newBranch: string, + ): Promise { + const { owner, repo } = this.parseRepoUrl(repoUrl); + const octokit = new Octokit({ auth: token }); + + const { data: refData } = await octokit.rest.git.getRef({ + owner, + repo, + ref: `heads/${baseBranch}`, + }); + + await octokit.rest.git.createRef({ + owner, + repo, + ref: `refs/heads/${newBranch}`, + sha: refData.object.sha, + }); + } + + async commitFile( + repoUrl: string, + token: string, + branch: string, + path: string, + content: string, + message: string, + ): Promise { + const { owner, repo } = this.parseRepoUrl(repoUrl); + const octokit = new Octokit({ auth: token }); + + // Check for existing file to get SHA + let existingSha: string | undefined; + try { + const { data: existing } = await octokit.rest.repos.getContent({ + owner, + repo, + path, + ref: branch, + }); + if (!Array.isArray(existing) && "sha" in existing) { + existingSha = existing.sha; + } + } catch { + // File does not exist yet + } + + const { data } = await octokit.rest.repos.createOrUpdateFileContents({ + owner, + repo, + path, + message, + content: Buffer.from(content).toString("base64"), + branch, + ...(existingSha ? { sha: existingSha } : {}), + }); + + return data.commit.sha ?? ""; + } + + async createPullRequest( + repoUrl: string, + token: string, + options: CreatePROptions, + ): Promise<{ url: string; number: number }> { + const { owner, repo } = this.parseRepoUrl(repoUrl); + const octokit = new Octokit({ auth: token }); + + const { data: pr } = await octokit.rest.pulls.create({ + owner, + repo, + title: options.title, + body: options.body, + head: options.headBranch, + base: options.baseBranch, + }); + + return { url: pr.html_url, number: pr.number }; + } +} + +/** + * Backward-compatible re-export used by gitops-promotion.ts. + */ +export function parseGitHubOwnerRepo(repoUrl: string): { owner: string; repo: string } { + return new GitHubProvider().parseRepoUrl(repoUrl); +} diff --git a/src/server/services/git-providers/gitlab.ts b/src/server/services/git-providers/gitlab.ts new file mode 100644 index 00000000..d9affcf0 --- /dev/null +++ b/src/server/services/git-providers/gitlab.ts @@ -0,0 +1,281 @@ +import crypto from "crypto"; +import type { + GitProvider, + GitWebhookEvent, + CreatePROptions, + RepoCoordinates, +} from "./types"; + +/** + * GitLab REST API v4 provider. + * + * Webhook verification uses the `X-Gitlab-Token` header (shared secret, NOT HMAC). + * File and MR operations use the GitLab projects API with URL-encoded project path. + */ +export class GitLabProvider implements GitProvider { + readonly name = "gitlab" as const; + + /** + * GitLab webhook verification: compare the X-Gitlab-Token header directly + * against the stored secret using timing-safe comparison. + */ + verifyWebhookSignature(headers: Headers, _body: string, secret: string): boolean { + const token = headers.get("x-gitlab-token"); + if (!token) return false; + + const tokenBuf = Buffer.from(token); + const secretBuf = Buffer.from(secret); + if (tokenBuf.length !== secretBuf.length) return false; + + return crypto.timingSafeEqual(tokenBuf, secretBuf); + } + + parseWebhookEvent(headers: Headers, body: Record): GitWebhookEvent { + const eventType = headers.get("x-gitlab-event") ?? ""; + + if (eventType === "System Hook" || eventType === "Test Hook") { + return { + type: "ping", + branch: null, + commits: [], + prBody: null, + prNumber: null, + afterSha: null, + pusherName: null, + }; + } + + if (eventType === "Merge Request Hook") { + const attrs = body.object_attributes as Record | undefined; + const action = attrs?.action as string | undefined; + const state = attrs?.state as string | undefined; + + const type = + state === "merged" || action === "merge" + ? "pull_request_merged" + : state === "closed" + ? "pull_request_closed" + : "unknown"; + + return { + type, + branch: null, + commits: [], + prBody: (attrs?.description as string) ?? null, + prNumber: (attrs?.iid as number) ?? null, + afterSha: null, + pusherName: (body.user as { username?: string } | undefined)?.username ?? null, + }; + } + + if (eventType === "Push Hook" || eventType === "Tag Push Hook") { + const ref = body.ref as string | undefined; + const branch = ref?.replace("refs/heads/", "") ?? null; + const rawCommits = (body.commits ?? []) as Array<{ + added?: string[]; + modified?: string[]; + removed?: string[]; + }>; + const commits = rawCommits.map((c) => ({ + added: c.added ?? [], + modified: c.modified ?? [], + removed: c.removed ?? [], + })); + + return { + type: "push", + branch, + commits, + prBody: null, + prNumber: null, + afterSha: (body.after as string) ?? null, + pusherName: (body.user_username as string) ?? null, + }; + } + + return { + type: "unknown", + branch: null, + commits: [], + prBody: null, + prNumber: null, + afterSha: null, + pusherName: null, + }; + } + + /** + * Parse owner/repo (or the full project path) from a GitLab URL. + * Supports nested groups: https://gitlab.com/group/subgroup/repo + */ + parseRepoUrl(repoUrl: string): RepoCoordinates { + // SSH: git@gitlab.com:group/repo.git + const sshMatch = repoUrl.match(/git@[^:]+:(.+?)(?:\.git)?$/); + if (sshMatch) { + const parts = sshMatch[1].split("/"); + const repo = parts.pop()!; + const owner = parts.join("/"); + return { owner, repo }; + } + + // HTTPS: https://gitlab.com/group/[subgroup/]repo[.git] + const httpsMatch = repoUrl.match(/gitlab\.[^/]+\/(.+?)(?:\.git)?$/); + if (httpsMatch) { + const parts = httpsMatch[1].split("/"); + const repo = parts.pop()!; + const owner = parts.join("/"); + return { owner, repo }; + } + + throw new Error( + `Cannot parse GitLab project path from URL: "${repoUrl}". ` + + `Expected format: https://gitlab.com/group/repo or git@gitlab.com:group/repo.git`, + ); + } + + /** URL-encode the full project path for the GitLab API. */ + private projectPath(repoUrl: string): string { + const { owner, repo } = this.parseRepoUrl(repoUrl); + return encodeURIComponent(`${owner}/${repo}`); + } + + /** + * Resolve API base URL from repo URL. + * Note: For self-hosted GitLab, this resolves to the user-provided host. + * SSRF risk is mitigated by requiring EDITOR role to configure git URLs. + */ + private apiBase(repoUrl: string): string { + try { + if (repoUrl.startsWith("git@")) { + const hostMatch = repoUrl.match(/git@([^:]+):/); + return `https://${hostMatch?.[1] ?? "gitlab.com"}/api/v4`; + } + const url = new URL(repoUrl); + return `${url.protocol}//${url.host}/api/v4`; + } catch { + return "https://gitlab.com/api/v4"; + } + } + + async fetchFileContent( + repoUrl: string, + token: string, + branch: string, + path: string, + ): Promise { + const base = this.apiBase(repoUrl); + const project = this.projectPath(repoUrl); + const encodedPath = encodeURIComponent(path); + + const res = await fetch( + `${base}/projects/${project}/repository/files/${encodedPath}/raw?ref=${encodeURIComponent(branch)}`, + { + headers: { "PRIVATE-TOKEN": token }, + }, + ); + + if (!res.ok) { + throw new Error(`GitLab API returned ${res.status} fetching ${path}`); + } + + return res.text(); + } + + async createBranch( + repoUrl: string, + token: string, + baseBranch: string, + newBranch: string, + ): Promise { + const base = this.apiBase(repoUrl); + const project = this.projectPath(repoUrl); + + const res = await fetch(`${base}/projects/${project}/repository/branches`, { + method: "POST", + headers: { + "PRIVATE-TOKEN": token, + "Content-Type": "application/json", + }, + body: JSON.stringify({ branch: newBranch, ref: baseBranch }), + }); + + if (!res.ok) { + const errText = await res.text().catch(() => ""); + throw new Error(`GitLab createBranch failed (${res.status}): ${errText}`); + } + } + + async commitFile( + repoUrl: string, + token: string, + branch: string, + path: string, + content: string, + message: string, + ): Promise { + const base = this.apiBase(repoUrl); + const project = this.projectPath(repoUrl); + + // Check if file exists to determine create vs update + const checkRes = await fetch( + `${base}/projects/${project}/repository/files/${encodeURIComponent(path)}?ref=${encodeURIComponent(branch)}`, + { headers: { "PRIVATE-TOKEN": token } }, + ); + const action = checkRes.ok ? "update" : "create"; + + const res = await fetch(`${base}/projects/${project}/repository/commits`, { + method: "POST", + headers: { + "PRIVATE-TOKEN": token, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + branch, + commit_message: message, + actions: [{ action, file_path: path, content }], + }), + }); + + if (!res.ok) { + const errText = await res.text().catch(() => ""); + throw new Error(`GitLab commitFile failed (${res.status}): ${errText}`); + } + + const data = (await res.json()) as { id?: string }; + return data.id ?? ""; + } + + async createPullRequest( + repoUrl: string, + token: string, + options: CreatePROptions, + ): Promise<{ url: string; number: number }> { + const base = this.apiBase(repoUrl); + const project = this.projectPath(repoUrl); + + const res = await fetch(`${base}/projects/${project}/merge_requests`, { + method: "POST", + headers: { + "PRIVATE-TOKEN": token, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + source_branch: options.headBranch, + target_branch: options.baseBranch, + title: options.title, + description: options.body, + }), + }); + + if (!res.ok) { + const errText = await res.text().catch(() => ""); + throw new Error(`GitLab createMergeRequest failed (${res.status}): ${errText}`); + } + + const data = (await res.json()) as { web_url?: string; iid?: number }; + return { + url: data.web_url ?? "", + number: data.iid ?? 0, + }; + } +} diff --git a/src/server/services/git-providers/index.ts b/src/server/services/git-providers/index.ts new file mode 100644 index 00000000..9ad77e24 --- /dev/null +++ b/src/server/services/git-providers/index.ts @@ -0,0 +1,54 @@ +import type { GitProvider } from "./types"; +import { GitHubProvider } from "./github"; +import { GitLabProvider } from "./gitlab"; +import { BitbucketProvider } from "./bitbucket"; + +export type { GitProvider, GitWebhookEvent, CreatePROptions, RepoCoordinates } from "./types"; + +const providers: Record = { + github: new GitHubProvider(), + gitlab: new GitLabProvider(), + bitbucket: new BitbucketProvider(), +}; + +/** + * Detect the git provider from a repository URL domain. + * Returns "github", "gitlab", or "bitbucket", or null if unknown. + */ +export function detectProvider(repoUrl: string): "github" | "gitlab" | "bitbucket" | null { + try { + // Handle SSH URLs + if (repoUrl.startsWith("git@github.com")) return "github"; + if (repoUrl.startsWith("git@gitlab.com")) return "gitlab"; + if (repoUrl.startsWith("git@bitbucket.org")) return "bitbucket"; + + const url = new URL(repoUrl); + const host = url.hostname.toLowerCase(); + if (host === "github.com" || host.endsWith(".github.com")) return "github"; + if (host === "gitlab.com" || host.endsWith(".gitlab.com")) return "gitlab"; + if (host === "bitbucket.org" || host.endsWith(".bitbucket.org")) return "bitbucket"; + } catch { + // Invalid URL + } + return null; +} + +/** + * Resolve the GitProvider for an environment. Uses the explicit gitProvider + * field if set, otherwise auto-detects from gitRepoUrl. + */ +export function getProvider(env: { + gitProvider?: string | null; + gitRepoUrl?: string | null; +}): GitProvider | null { + const providerName = env.gitProvider ?? (env.gitRepoUrl ? detectProvider(env.gitRepoUrl) : null); + if (!providerName) return null; + return providers[providerName] ?? null; +} + +/** + * Register a provider implementation. Used to add GitLab/Bitbucket. + */ +export function registerProvider(provider: GitProvider): void { + providers[provider.name] = provider; +} diff --git a/src/server/services/git-providers/types.ts b/src/server/services/git-providers/types.ts new file mode 100644 index 00000000..ba25d34a --- /dev/null +++ b/src/server/services/git-providers/types.ts @@ -0,0 +1,81 @@ +/** Normalized webhook event from any Git provider. */ +export interface GitWebhookEvent { + type: "push" | "pull_request_merged" | "pull_request_closed" | "ping" | "unknown"; + branch: string | null; + commits: Array<{ + added: string[]; + modified: string[]; + removed: string[]; + }>; + /** For PR events */ + prBody: string | null; + prNumber: number | null; + /** Commit SHA for push events */ + afterSha: string | null; + /** Pusher name for attribution */ + pusherName: string | null; +} + +/** Options for creating a pull request via the provider. */ +export interface CreatePROptions { + baseBranch: string; + headBranch: string; + title: string; + body: string; +} + +/** Parsed repository coordinates. */ +export interface RepoCoordinates { + owner: string; + repo: string; +} + +/** + * Abstraction over Git hosting providers for webhook verification, + * file operations, and PR management. + */ +export interface GitProvider { + readonly name: "github" | "gitlab" | "bitbucket"; + + /** Verify the incoming webhook request signature. */ + verifyWebhookSignature(headers: Headers, body: string, secret: string): boolean; + + /** Parse a webhook request into a normalized event. */ + parseWebhookEvent(headers: Headers, body: Record): GitWebhookEvent; + + /** Parse owner/repo from a repository URL. */ + parseRepoUrl(repoUrl: string): RepoCoordinates; + + /** Fetch file content from the repository. */ + fetchFileContent( + repoUrl: string, + token: string, + branch: string, + path: string, + ): Promise; + + /** Create a new branch from the base branch. */ + createBranch( + repoUrl: string, + token: string, + baseBranch: string, + newBranch: string, + ): Promise; + + /** Commit a file to a branch, creating or updating it. Returns the commit SHA. */ + commitFile( + repoUrl: string, + token: string, + branch: string, + path: string, + content: string, + message: string, + ): Promise; + + /** Create a pull request. Returns the PR URL and number. */ + createPullRequest( + repoUrl: string, + token: string, + options: CreatePROptions, + ): Promise<{ url: string; number: number }>; +} diff --git a/src/server/services/git-sync-retry.ts b/src/server/services/git-sync-retry.ts new file mode 100644 index 00000000..d716e939 --- /dev/null +++ b/src/server/services/git-sync-retry.ts @@ -0,0 +1,247 @@ +import { prisma } from "@/lib/prisma"; +import { debugLog } from "@/lib/logger"; +import { gitSyncCommitPipeline, gitSyncDeletePipeline } from "@/server/services/git-sync"; +import { fireEventAlert } from "@/server/services/event-alerts"; +import { broadcastSSE } from "@/server/services/sse-broadcast"; + +// --- Constants --- + +const POLL_INTERVAL_MS = 30_000; +const BATCH_SIZE = 10; + +/** Retry schedule: 30s, 2m, 10m */ +const RETRY_DELAYS_MS = [30_000, 120_000, 600_000]; + +// --- Helpers --- + +export function getNextRetryAt(attemptNumber: number): Date | null { + const delayMs = RETRY_DELAYS_MS[attemptNumber]; + if (!delayMs) return null; + return new Date(Date.now() + delayMs); +} + +// --- Service --- + +export class GitSyncRetryService { + private timer: ReturnType | null = null; + + init(): void { + console.log("[git-sync-retry] Initializing git sync retry service"); + this.start(); + } + + start(): void { + this.timer = setInterval( + this.processRetries.bind(this), + POLL_INTERVAL_MS, + ); + this.timer.unref(); + console.log( + `[git-sync-retry] Poll loop started (every ${POLL_INTERVAL_MS / 1000}s)`, + ); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + console.log("[git-sync-retry] Poll loop stopped"); + } + } + + async processRetries(): Promise { + let dueJobs; + try { + dueJobs = await prisma.gitSyncJob.findMany({ + where: { + status: "pending", + nextRetryAt: { lte: new Date() }, + }, + include: { + environment: { + select: { + id: true, + name: true, + gitRepoUrl: true, + gitBranch: true, + gitToken: true, + }, + }, + pipeline: { + select: { id: true, name: true, gitPath: true }, + }, + }, + orderBy: { nextRetryAt: "asc" }, + take: BATCH_SIZE, + }); + } catch (err) { + console.error("[git-sync-retry] Error querying due jobs:", err); + return; + } + + if (dueJobs.length === 0) return; + + debugLog("gitsync", `Found ${dueJobs.length} due retry job(s)`); + + for (const job of dueJobs) { + try { + // Claim the job by incrementing attempts + const newAttempts = job.attempts + 1; + await prisma.gitSyncJob.update({ + where: { id: job.id }, + data: { + attempts: newAttempts, + nextRetryAt: null, + }, + }); + + const env = job.environment; + if (!env.gitRepoUrl || !env.gitToken) { + await this.markFailed(job.id, job.environmentId, "No git repo URL or token configured"); + continue; + } + + const config = { + repoUrl: env.gitRepoUrl, + branch: env.gitBranch ?? "main", + encryptedToken: env.gitToken, + }; + + // Use gitPath if available, otherwise derive from pipeline name + const pipelineNameForSync = job.pipeline.name; + + let result; + if (job.action === "commit") { + if (!job.configYaml) { + await this.markFailed(job.id, job.environmentId, "No configYaml for commit action"); + continue; + } + result = await gitSyncCommitPipeline( + config, + env.name, + pipelineNameForSync, + job.configYaml, + { name: job.authorName ?? "VectorFlow", email: job.authorEmail ?? "noreply@vectorflow" }, + job.commitMessage ?? `Retry: sync pipeline ${pipelineNameForSync}`, + job.pipeline.gitPath ?? undefined, + ); + } else if (job.action === "delete") { + result = await gitSyncDeletePipeline( + config, + env.name, + pipelineNameForSync, + { name: job.authorName ?? "VectorFlow", email: job.authorEmail ?? "noreply@vectorflow" }, + job.pipeline.gitPath ?? undefined, + ); + } else { + await this.markFailed(job.id, job.environmentId, `Unknown action: ${job.action}`); + continue; + } + + if (result.success) { + await prisma.gitSyncJob.update({ + where: { id: job.id }, + data: { status: "completed", completedAt: new Date() }, + }); + debugLog("gitsync", `Job ${job.id} succeeded (attempt ${newAttempts})`); + + broadcastSSE({ + type: "git_sync_status", + environmentId: job.environmentId, + status: "completed", + jobId: job.id, + }, job.environmentId); + } else { + // Check if max attempts reached + if (newAttempts >= job.maxAttempts) { + await this.markFailed(job.id, job.environmentId, result.error ?? "Unknown error"); + } else { + // Schedule next retry + const nextRetryAt = getNextRetryAt(newAttempts); + await prisma.gitSyncJob.update({ + where: { id: job.id }, + data: { + lastError: result.error ?? "Unknown error", + nextRetryAt, + }, + }); + debugLog( + "gitsync", + `Job ${job.id} failed (attempt ${newAttempts}/${job.maxAttempts}), next retry at ${nextRetryAt?.toISOString()}`, + ); + } + } + } catch (err) { + console.error(`[git-sync-retry] Error processing job ${job.id}:`, err); + } + } + } + + private async markFailed(jobId: string, environmentId: string, error: string): Promise { + await prisma.gitSyncJob.update({ + where: { id: jobId }, + data: { + status: "failed", + lastError: error, + completedAt: new Date(), + }, + }); + + // Fire git_sync_failed alert + try { + await fireEventAlert("git_sync_failed", environmentId, { + message: `Git sync failed after max retries: ${error}`, + }); + } catch { + // Alert failure must not mask the sync failure + } + + broadcastSSE({ + type: "git_sync_status", + environmentId, + status: "failed", + jobId, + }, environmentId); + } +} + +// --- Singleton --- + +export const gitSyncRetryService = new GitSyncRetryService(); + +export function initGitSyncRetryService(): void { + gitSyncRetryService.init(); +} + +// --- Job Creation Helper --- + +/** + * Create a GitSyncJob for a failed git sync operation. + * The job will be picked up by the retry service. + */ +export async function createGitSyncJob(opts: { + environmentId: string; + pipelineId: string; + action: "commit" | "delete"; + configYaml?: string; + commitMessage?: string; + authorName?: string; + authorEmail?: string; + error: string; +}): Promise { + const nextRetryAt = getNextRetryAt(0); + await prisma.gitSyncJob.create({ + data: { + environmentId: opts.environmentId, + pipelineId: opts.pipelineId, + action: opts.action, + configYaml: opts.configYaml ?? null, + commitMessage: opts.commitMessage ?? null, + authorName: opts.authorName ?? null, + authorEmail: opts.authorEmail ?? null, + attempts: 1, // First attempt already happened in deploy-agent + lastError: opts.error, + nextRetryAt, + }, + }); +} diff --git a/src/server/services/git-sync.ts b/src/server/services/git-sync.ts index 105689c0..05efc22f 100644 --- a/src/server/services/git-sync.ts +++ b/src/server/services/git-sync.ts @@ -65,6 +65,7 @@ export async function gitSyncCommitPipeline( configYaml: string, author: GitAuthor, commitMessage: string, + gitPath?: string | null, ): Promise { let workdir: string | null = null; @@ -78,12 +79,13 @@ export async function gitSyncCommitPipeline( await git.clone(url, repoDir, ["--branch", config.branch, "--depth", "1", "--single-branch"]); const repoGit: SimpleGit = simpleGit(repoDir); - // Write the pipeline YAML file - const envDir = toFilenameSlug(environmentName); - const filename = `${toFilenameSlug(pipelineName)}.yaml`; - const filePath = join(envDir, filename); + // Use gitPath if provided, otherwise derive from name + const filePath = gitPath ?? join(toFilenameSlug(environmentName), `${toFilenameSlug(pipelineName)}.yaml`); const fullPath = join(repoDir, filePath); + // Ensure directory exists + const envDir = filePath.includes("/") ? filePath.substring(0, filePath.lastIndexOf("/")) : toFilenameSlug(environmentName); + await mkdir(join(repoDir, envDir), { recursive: true }); await writeFile(fullPath, configYaml, "utf-8"); @@ -124,6 +126,7 @@ export async function gitSyncDeletePipeline( environmentName: string, pipelineName: string, author: GitAuthor, + gitPath?: string | null, ): Promise { let workdir: string | null = null; @@ -137,9 +140,7 @@ export async function gitSyncDeletePipeline( await git.clone(url, repoDir, ["--branch", config.branch, "--depth", "1", "--single-branch"]); const repoGit: SimpleGit = simpleGit(repoDir); - const envDir = toFilenameSlug(environmentName); - const filename = `${toFilenameSlug(pipelineName)}.yaml`; - const filePath = join(envDir, filename); + const filePath = gitPath ?? join(toFilenameSlug(environmentName), `${toFilenameSlug(pipelineName)}.yaml`); try { await repoGit.rm(filePath); diff --git a/src/server/services/gitops-promotion.ts b/src/server/services/gitops-promotion.ts index a3f687fc..8d8bd856 100644 --- a/src/server/services/gitops-promotion.ts +++ b/src/server/services/gitops-promotion.ts @@ -1,26 +1,22 @@ -import { Octokit } from "@octokit/rest"; import { decrypt } from "@/server/services/crypto"; +import { getProvider } from "@/server/services/git-providers"; import { toFilenameSlug } from "@/server/services/git-sync"; -// ─── Types ────────────────────────────────────────────────────────────────── +// --- Types --- export interface CreatePromotionPROptions { - /** Encrypted GitHub PAT (stored in Environment.gitToken) */ encryptedToken: string; - /** GitHub repo URL — https or SSH format */ repoUrl: string; - /** Target branch in the repo (e.g. "main") */ baseBranch: string; - /** PromotionRequest.id — used to make branch name unique and embedded in PR body */ requestId: string; - /** Source pipeline name */ pipelineName: string; - /** Source environment name */ sourceEnvironmentName: string; - /** Target environment name */ targetEnvironmentName: string; - /** Vector YAML config string for the promoted pipeline */ configYaml: string; + /** Explicit provider override. Auto-detected from repoUrl if null. */ + gitProvider?: string | null; + /** Stable git path for the pipeline. Falls back to slug-based derivation. */ + gitPath?: string | null; } export interface CreatePromotionPRResult { @@ -29,109 +25,63 @@ export interface CreatePromotionPRResult { prBranch: string; } -// ─── URL Parsing ───────────────────────────────────────────────────────────── +// --- URL Parsing (kept for backward compatibility) --- -/** - * Parses owner and repo from a GitHub URL. - * Supports: - * - https://github.com/owner/repo - * - https://github.com/owner/repo.git - * - git@github.com:owner/repo.git - */ -export function parseGitHubOwnerRepo(repoUrl: string): { owner: string; repo: string } { - // SSH format: git@github.com:owner/repo.git - const sshMatch = repoUrl.match(/git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/); - if (sshMatch) { - return { owner: sshMatch[1], repo: sshMatch[2] }; - } +export { parseGitHubOwnerRepo } from "@/server/services/git-providers/github"; - // HTTPS format: https://github.com/owner/repo[.git] - const httpsMatch = repoUrl.match(/github\.com\/([^/]+)\/(.+?)(?:\.git)?(?:\/.*)?$/); - if (httpsMatch) { - return { owner: httpsMatch[1], repo: httpsMatch[2] }; - } - - throw new Error( - `Cannot parse GitHub owner/repo from URL: "${repoUrl}". ` + - `Expected format: https://github.com/owner/repo or git@github.com:owner/repo.git`, - ); -} - -// ─── Service ───────────────────────────────────────────────────────────────── +// --- Service --- /** - * Creates a GitHub PR for a pipeline promotion using the GitHub REST API. + * Creates a PR for a pipeline promotion using the resolved Git provider. * * Flow: - * 1. Decrypt token and authenticate with Octokit - * 2. Get the base branch SHA - * 3. Create a new PR branch (vf-promote/{envSlug}-{pipelineSlug}-{requestId[:8]}) - * 4. Commit the pipeline YAML file to {envSlug}/{pipelineSlug}.yaml on the PR branch - * 5. Open a PR with the VF promotion request ID embedded in the body - * - * The promotion request ID in the PR body is used by the merge webhook handler - * to look up the PromotionRequest when the PR is merged. + * 1. Resolve the provider from repoUrl or explicit gitProvider + * 2. Decrypt token + * 3. Create a new branch (vf-promote/{envSlug}-{pipelineSlug}-{requestId[:8]}) + * 4. Commit the pipeline YAML file + * 5. Open a PR/MR with the VF promotion request ID in the body */ export async function createPromotionPR( opts: CreatePromotionPROptions, ): Promise { - const token = decrypt(opts.encryptedToken); - const { owner, repo } = parseGitHubOwnerRepo(opts.repoUrl); + const provider = getProvider({ + gitProvider: opts.gitProvider ?? null, + gitRepoUrl: opts.repoUrl, + }); - const octokit = new Octokit({ auth: token }); + if (!provider) { + throw new Error( + `Cannot determine git provider for URL: "${opts.repoUrl}". ` + + `Supported providers: github, gitlab, bitbucket.`, + ); + } - // Step 1: Get base branch SHA - const { data: refData } = await octokit.rest.git.getRef({ - owner, - repo, - ref: `heads/${opts.baseBranch}`, - }); - const baseSha = refData.object.sha; + const token = decrypt(opts.encryptedToken); - // Step 2: Create PR branch with unique name to avoid collision + // Determine the file path: use gitPath if provided, otherwise derive from slugs const envSlug = toFilenameSlug(opts.targetEnvironmentName); const pipelineSlug = toFilenameSlug(opts.pipelineName); - const prBranch = `vf-promote/${envSlug}-${pipelineSlug}-${opts.requestId.slice(0, 8)}`; + const filePath = opts.gitPath ?? `${envSlug}/${pipelineSlug}.yaml`; - await octokit.rest.git.createRef({ - owner, - repo, - ref: `refs/heads/${prBranch}`, - sha: baseSha, - }); + const prBranch = `vf-promote/${envSlug}-${pipelineSlug}-${opts.requestId.slice(0, 8)}`; - // Step 3: Check for existing file (to get SHA for update vs create) - const filePath = `${envSlug}/${pipelineSlug}.yaml`; - let existingSha: string | undefined; - try { - const { data: existing } = await octokit.rest.repos.getContent({ - owner, - repo, - path: filePath, - ref: prBranch, - }); - if (!Array.isArray(existing) && "sha" in existing) { - existingSha = existing.sha; - } - } catch { - // File does not exist yet — this is expected for new promotions - } + // Step 1: Create branch + await provider.createBranch(opts.repoUrl, token, opts.baseBranch, prBranch); - // Step 4: Commit YAML file to PR branch - await octokit.rest.repos.createOrUpdateFileContents({ - owner, - repo, - path: filePath, - message: `promote: "${opts.pipelineName}" \u2192 ${opts.targetEnvironmentName}`, - content: Buffer.from(opts.configYaml).toString("base64"), - branch: prBranch, - ...(existingSha ? { sha: existingSha } : {}), - }); + // Step 2: Commit file + await provider.commitFile( + opts.repoUrl, + token, + prBranch, + filePath, + opts.configYaml, + `promote: "${opts.pipelineName}" \u2192 ${opts.targetEnvironmentName}`, + ); - // Step 5: Create the pull request - const { data: pr } = await octokit.rest.pulls.create({ - owner, - repo, + // Step 3: Create PR/MR + const prResult = await provider.createPullRequest(opts.repoUrl, token, { + baseBranch: opts.baseBranch, + headBranch: prBranch, title: `Promote "${opts.pipelineName}" to ${opts.targetEnvironmentName}`, body: [ ``, @@ -140,13 +90,11 @@ export async function createPromotionPR( ``, `**Merge this PR to deploy the pipeline to ${opts.targetEnvironmentName}.**`, ].join("\n"), - head: prBranch, - base: opts.baseBranch, }); return { - prNumber: pr.number, - prUrl: pr.html_url, + prNumber: prResult.number, + prUrl: prResult.url, prBranch, }; } diff --git a/src/server/services/heartbeat-batch.ts b/src/server/services/heartbeat-batch.ts index 64d94420..21abeec3 100644 --- a/src/server/services/heartbeat-batch.ts +++ b/src/server/services/heartbeat-batch.ts @@ -15,6 +15,7 @@ export interface PipelineStatusInput { bytesOut?: number; utilization?: number; recentLogs?: string[]; + configChecksum?: string | null; } /** @@ -46,6 +47,7 @@ export async function batchUpsertPipelineStatuses( ${ps.bytesIn ?? 0}, ${ps.bytesOut ?? 0}, ${ps.utilization ?? 0}, + ${ps.configChecksum ?? null}, ${ps.recentLogs ? JSON.stringify(ps.recentLogs) : null}::jsonb, ${now} )`); @@ -55,7 +57,7 @@ export async function batchUpsertPipelineStatuses( ("id", "nodeId", "pipelineId", "version", "status", "pid", "uptimeSeconds", "eventsIn", "eventsOut", "errorsTotal", "eventsDiscarded", "bytesIn", "bytesOut", "utilization", - "recentLogs", "lastUpdated") + "configChecksum", "recentLogs", "lastUpdated") VALUES ${Prisma.join(values)} ON CONFLICT ("nodeId", "pipelineId") DO UPDATE SET "version" = EXCLUDED."version", @@ -69,6 +71,7 @@ export async function batchUpsertPipelineStatuses( "bytesIn" = EXCLUDED."bytesIn", "bytesOut" = EXCLUDED."bytesOut", "utilization" = EXCLUDED."utilization", + "configChecksum" = EXCLUDED."configChecksum", "recentLogs" = EXCLUDED."recentLogs", "lastUpdated" = EXCLUDED."lastUpdated" `; diff --git a/src/server/services/metric-store.ts b/src/server/services/metric-store.ts index 9affab1c..b3c71936 100644 --- a/src/server/services/metric-store.ts +++ b/src/server/services/metric-store.ts @@ -28,11 +28,24 @@ interface PrevTotals { export type MetricStoreSubscriber = (events: MetricUpdateEvent[]) => void; const MAX_SAMPLES = 720; // 1 hour at 5s intervals +const METRIC_STORE_MAX_KEYS = parseInt(process.env.METRIC_STORE_MAX_KEYS ?? "5000", 10); +const BYTES_PER_SAMPLE = 160; // estimated: 9 numeric fields x ~17 bytes + overhead + +interface MetricStoreOptions { + maxKeys?: number; +} export class MetricStore { private samples = new Map(); private prevTotals = new Map(); private subscribers = new Map(); + private lastUpdated = new Map(); // LRU tracking + private readonly maxKeys: number; + private hasWarnedCapacity = false; + + constructor(options?: MetricStoreOptions) { + this.maxKeys = options?.maxKeys ?? METRIC_STORE_MAX_KEYS; + } /** Number of active pub/sub subscribers. */ get subscriberCount(): number { @@ -51,6 +64,41 @@ export class MetricStore { this.subscribers.delete(id); } + /** Number of active metric streams. */ + getStreamCount(): number { + return this.samples.size; + } + + /** Estimated memory usage in bytes. */ + getEstimatedMemoryBytes(): number { + let totalSamples = 0; + for (const arr of this.samples.values()) { + totalSamples += arr.length; + } + return totalSamples * BYTES_PER_SAMPLE; + } + + private evictIfNeeded(): void { + while (this.samples.size >= this.maxKeys) { + // Find least-recently-updated key + let oldestKey: string | null = null; + let oldestTime = Infinity; + for (const [key, time] of this.lastUpdated) { + if (time < oldestTime) { + oldestTime = time; + oldestKey = key; + } + } + if (oldestKey) { + this.samples.delete(oldestKey); + this.prevTotals.delete(oldestKey); + this.lastUpdated.delete(oldestKey); + } else { + break; // Safety: avoid infinite loop + } + } + } + /** * Collect the latest sample for every component of a node+pipeline pair, * notify all subscribers with the batch, and return the events. @@ -131,10 +179,23 @@ export class MetricStore { latencyMeanMs: totals.latencyMeanSeconds != null ? totals.latencyMeanSeconds * 1000 : null, }; + const isNewKey = !this.samples.has(key); + if (isNewKey) { + this.evictIfNeeded(); + } const arr = this.samples.get(key) ?? []; arr.push(sample); if (arr.length > MAX_SAMPLES) arr.shift(); this.samples.set(key, arr); + this.lastUpdated.set(key, now); + + // Check capacity warning after insertion + if (isNewKey && !this.hasWarnedCapacity && this.samples.size >= this.maxKeys * 0.8) { + this.hasWarnedCapacity = true; + console.warn( + `[metric-store] Approaching 80% capacity (${this.samples.size}/${this.maxKeys} streams)`, + ); + } return sample; } @@ -146,10 +207,14 @@ export class MetricStore { */ mergeSample(nodeId: string, pipelineId: string, componentId: string, sample: MetricSample): void { const key = `${nodeId}:${pipelineId}:${componentId}`; + if (!this.samples.has(key)) { + this.evictIfNeeded(); + } const arr = this.samples.get(key) ?? []; arr.push(sample); if (arr.length > MAX_SAMPLES) arr.shift(); this.samples.set(key, arr); + this.lastUpdated.set(key, sample.timestamp); } getSamples(nodeId: string, pipelineId: string, componentId: string, minutes = 60): MetricSample[] { diff --git a/src/server/services/pipeline-graph.ts b/src/server/services/pipeline-graph.ts index a47cec30..db7fdee3 100644 --- a/src/server/services/pipeline-graph.ts +++ b/src/server/services/pipeline-graph.ts @@ -8,6 +8,21 @@ import { stripEnvRefs, type StrippedRef } from "@/server/services/strip-env-refs type Tx = Prisma.TransactionClient; +/* ------------------------------------------------------------------ */ +/* ListPipelinesOptions — filter/sort/pagination for pipeline list */ +/* ------------------------------------------------------------------ */ + +export interface ListPipelinesOptions { + cursor?: string; + limit?: number; + search?: string; + status?: string[]; // "deployed" | "draft" | "error" + tags?: string[]; + groupId?: string; + sortBy?: "name" | "updatedAt" | "deployedAt"; + sortOrder?: "asc" | "desc"; +} + /* ------------------------------------------------------------------ */ /* saveGraph — component validation + node/edge transaction body */ /* ------------------------------------------------------------------ */ @@ -512,75 +527,148 @@ export function detectConfigChanges(params: { * * This is the data assembly behind the `pipeline.list` tRPC endpoint. */ -export async function listPipelinesForEnvironment(environmentId: string) { - const pipelines = await prisma.pipeline.findMany({ - where: { environmentId }, - select: { - id: true, - name: true, - description: true, - isDraft: true, - deployedAt: true, - createdAt: true, - updatedAt: true, - globalConfig: true, - tags: true, - enrichMetadata: true, - groupId: true, - group: { select: { id: true, name: true, color: true } }, - environment: { select: { name: true } }, - createdBy: { select: { name: true, email: true, image: true } }, - updatedBy: { select: { name: true, email: true, image: true } }, - nodeStatuses: { - select: { - status: true, - eventsIn: true, - eventsOut: true, - errorsTotal: true, - eventsDiscarded: true, - bytesIn: true, - bytesOut: true, - uptimeSeconds: true, - }, - }, - nodes: { - select: { - id: true, - componentType: true, - componentKey: true, - kind: true, - config: true, - positionX: true, - positionY: true, - disabled: true, - sharedComponentId: true, - sharedComponentVersion: true, - sharedComponent: { - select: { version: true, name: true }, - }, - }, +export async function listPipelinesForEnvironment( + environmentId: string, + options: ListPipelinesOptions = {}, +) { + const { + cursor, + limit: rawLimit, + search, + status, + tags, + groupId, + sortBy, + sortOrder, + } = options; + + const limit = Math.min(rawLimit ?? 50, 200); + + // Build filter conditions (like audit.list pattern) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const conditions: any[] = [{ environmentId }]; + + if (search) { + conditions.push({ name: { contains: search, mode: "insensitive" } }); + } + + if (status && status.length > 0) { + if (status.includes("deployed")) { + conditions.push({ isDraft: false, deployedAt: { not: null } }); + } + if (status.includes("draft")) { + conditions.push({ isDraft: true }); + } + } + + if (tags && tags.length > 0) { + conditions.push({ + tags: { array_contains: tags }, + }); + } + + if (groupId) { + conditions.push({ groupId }); + } + + const where = { AND: conditions }; + + // Determine orderBy from sortBy/sortOrder + let orderBy: Record; + switch (sortBy) { + case "name": + orderBy = { name: sortOrder ?? "asc" }; + break; + case "deployedAt": + orderBy = { deployedAt: sortOrder ?? "desc" }; + break; + default: + orderBy = { updatedAt: sortOrder ?? "desc" }; + break; + } + + const pipelineSelect = { + id: true, + name: true, + description: true, + isDraft: true, + deployedAt: true, + createdAt: true, + updatedAt: true, + globalConfig: true, + tags: true, + enrichMetadata: true, + groupId: true, + group: { select: { id: true, name: true, color: true } }, + environment: { select: { name: true } }, + createdBy: { select: { name: true, email: true, image: true } }, + updatedBy: { select: { name: true, email: true, image: true } }, + nodeStatuses: { + select: { + status: true, + eventsIn: true, + eventsOut: true, + errorsTotal: true, + eventsDiscarded: true, + bytesIn: true, + bytesOut: true, + uptimeSeconds: true, }, - edges: { - select: { - id: true, - sourceNodeId: true, - targetNodeId: true, - sourcePort: true, + }, + nodes: { + select: { + id: true, + componentType: true, + componentKey: true, + kind: true, + config: true, + positionX: true, + positionY: true, + disabled: true, + sharedComponentId: true, + sharedComponentVersion: true, + sharedComponent: { + select: { version: true, name: true }, }, }, - _count: { - select: { upstreamDeps: true, downstreamDeps: true }, - }, - versions: { - orderBy: { version: "desc" as const }, - take: 1, - select: { version: true, configYaml: true, logLevel: true }, + }, + edges: { + select: { + id: true, + sourceNodeId: true, + targetNodeId: true, + sourcePort: true, }, }, - orderBy: { updatedAt: "desc" }, - }); + _count: { + select: { upstreamDeps: true, downstreamDeps: true }, + }, + versions: { + orderBy: { version: "desc" as const }, + take: 1, + select: { version: true, configYaml: true, logLevel: true }, + }, + }; + + const [rawPipelines, totalCount] = await Promise.all([ + prisma.pipeline.findMany({ + where, + select: pipelineSelect, + orderBy, + take: limit + 1, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }), + prisma.pipeline.count({ where }), + ]); + + // Detect next cursor via overfetch + let nextCursor: string | undefined; + if (rawPipelines.length > limit) { + rawPipelines.pop(); + nextCursor = rawPipelines[rawPipelines.length - 1]?.id; + } - return Promise.all(pipelines.map(async (p) => { + const pipelines = await Promise.all(rawPipelines.map(async (p) => { let hasUndeployedChanges = false; if (!p.isDraft && p.deployedAt) { const latestVersion = p.versions[0]; @@ -634,4 +722,6 @@ export async function listPipelinesForEnvironment(environmentId: string) { })(), }; })); + + return { pipelines, nextCursor, totalCount }; } diff --git a/src/server/services/prometheus-metrics.ts b/src/server/services/prometheus-metrics.ts index efd6dfcd..19fe6a4a 100644 --- a/src/server/services/prometheus-metrics.ts +++ b/src/server/services/prometheus-metrics.ts @@ -1,5 +1,6 @@ import { Registry, Gauge } from "prom-client"; import { prisma } from "@/lib/prisma"; +import { metricStore } from "@/server/services/metric-store"; // --------------------------------------------------------------------------- // Helpers @@ -35,6 +36,10 @@ export class PrometheusMetricsService { // PipelineMetric-level gauges (latest snapshot) private pipelineLatencyMean: Gauge; + // MetricStore gauges + private metricStoreStreams: Gauge; + private metricStoreMemoryBytes: Gauge; + constructor(registry?: Registry) { this.registry = registry ?? new Registry(); @@ -107,6 +112,18 @@ export class PrometheusMetricsService { labelNames: ["pipeline_id", "node_id"], registers: [this.registry], }); + + this.metricStoreStreams = new Gauge({ + name: "vectorflow_metric_store_streams", + help: "Number of active metric streams in the in-memory MetricStore", + registers: [this.registry], + }); + + this.metricStoreMemoryBytes = new Gauge({ + name: "vectorflow_metric_store_memory_bytes", + help: "Estimated memory usage of the in-memory MetricStore in bytes", + registers: [this.registry], + }); } /** The prom-client Registry for this service. */ @@ -226,6 +243,10 @@ export class PrometheusMetricsService { } } + // MetricStore gauges + this.metricStoreStreams.set(metricStore.getStreamCount()); + this.metricStoreMemoryBytes.set(metricStore.getEstimatedMemoryBytes()); + return await this.registry.metrics(); } catch (error) { console.error("[PrometheusMetricsService] collectMetrics failed:", error); diff --git a/src/stores/flow-store.ts b/src/stores/flow-store.ts index e9f1f0a7..77e0e103 100644 --- a/src/stores/flow-store.ts +++ b/src/stores/flow-store.ts @@ -16,6 +16,7 @@ import { import type { VectorComponentDef } from "@/lib/vector/types"; import { findComponentDef } from "@/lib/vector/catalog"; import { validateNodeConfig } from "@/lib/vector/validate-node-config"; +import { applyAutoLayout } from "@/lib/auto-layout"; /** Shape of node.data used throughout the flow editor */ interface FlowNodeData { @@ -127,6 +128,21 @@ export interface FlowState { loadGraph: (nodes: Node[], edges: Edge[], globalConfig?: Record | null, options?: { isSystem?: boolean }) => void; clearGraph: () => void; + // Canvas search + canvasSearchTerm: string; + canvasSearchMatchIds: string[]; + canvasSearchActiveIndex: number; + setCanvasSearchTerm: (term: string) => void; + cycleCanvasSearchMatch: (direction: "next" | "prev") => void; + clearCanvasSearch: () => void; + + // Detail panel collapse + detailPanelCollapsed: boolean; + toggleDetailPanel: () => void; + + // Auto-layout + autoLayout: (selectedOnly?: boolean) => void; + // Undo / Redo undo: () => void; redo: () => void; @@ -207,6 +223,16 @@ export const useFlowStore = create()((set, get) => ({ canUndo: false, canRedo: false, + // Canvas search + canvasSearchTerm: "", + canvasSearchMatchIds: [], + canvasSearchActiveIndex: -1, + + // Detail panel collapse + detailPanelCollapsed: typeof window !== "undefined" + ? localStorage.getItem("vf-detail-panel-collapsed") === "true" + : false, + /* ---- React Flow callbacks ---- */ onNodesChange: (changes) => { @@ -912,6 +938,68 @@ export const useFlowStore = create()((set, get) => ({ }); }, + /* ---- Canvas Search ---- */ + + setCanvasSearchTerm: (term) => { + if (!term) { + set({ canvasSearchTerm: "", canvasSearchMatchIds: [], canvasSearchActiveIndex: -1 }); + return; + } + const lowerTerm = term.toLowerCase(); + const matches = get().nodes + .filter((n) => { + const data = n.data as { displayName?: string; componentDef?: { type: string; displayName: string } }; + const displayName = data.displayName ?? data.componentDef?.displayName ?? ""; + const compType = data.componentDef?.type ?? ""; + return displayName.toLowerCase().includes(lowerTerm) || compType.toLowerCase().includes(lowerTerm); + }) + .map((n) => n.id); + set({ + canvasSearchTerm: term, + canvasSearchMatchIds: matches, + canvasSearchActiveIndex: matches.length > 0 ? 0 : -1, + }); + }, + + cycleCanvasSearchMatch: (direction) => { + const { canvasSearchMatchIds, canvasSearchActiveIndex } = get(); + if (canvasSearchMatchIds.length === 0) return; + const len = canvasSearchMatchIds.length; + const next = direction === "next" + ? (canvasSearchActiveIndex + 1) % len + : (canvasSearchActiveIndex - 1 + len) % len; + set({ canvasSearchActiveIndex: next }); + }, + + clearCanvasSearch: () => { + set({ canvasSearchTerm: "", canvasSearchMatchIds: [], canvasSearchActiveIndex: -1 }); + }, + + /* ---- Detail Panel Collapse ---- */ + + toggleDetailPanel: () => { + const collapsed = !get().detailPanelCollapsed; + set({ detailPanelCollapsed: collapsed }); + if (typeof window !== "undefined") { + localStorage.setItem("vf-detail-panel-collapsed", String(collapsed)); + } + }, + + /* ---- Auto-Layout ---- */ + + autoLayout: (selectedOnly) => { + const state = get(); + const history = pushSnapshot(state as InternalState); + const nodeIds = selectedOnly && state.selectedNodeIds.size > 1 + ? state.selectedNodeIds + : undefined; + const layoutedNodes = applyAutoLayout(state.nodes, state.edges, { nodeIds }); + set({ + ...history, + nodes: layoutedNodes, + }); + }, + /* ---- Undo / Redo ---- */ undo: () => { diff --git a/src/trpc/client.tsx b/src/trpc/client.tsx index e5fc1979..87f1ff17 100644 --- a/src/trpc/client.tsx +++ b/src/trpc/client.tsx @@ -23,7 +23,11 @@ export function TRPCClientProvider({ () => new QueryClient({ defaultOptions: { - queries: { staleTime: 5 * 1000, refetchOnWindowFocus: false }, + queries: { + staleTime: 30_000, // 30s — prevent redundant refetches on remount + gcTime: 300_000, // 5min — explicit for maintenance clarity + refetchOnWindowFocus: false, + }, }, }) ); diff --git a/src/trpc/router.ts b/src/trpc/router.ts index 35e0d19b..9668f65c 100644 --- a/src/trpc/router.ts +++ b/src/trpc/router.ts @@ -27,6 +27,8 @@ import { stagedRolloutRouter } from "@/server/routers/staged-rollout"; import { pipelineDependencyRouter } from "@/server/routers/pipeline-dependency"; import { webhookEndpointRouter } from "@/server/routers/webhook-endpoint"; import { promotionRouter } from "@/server/routers/promotion"; +import { filterPresetRouter } from "@/server/routers/filter-preset"; +import { gitSyncRouter } from "@/server/routers/git-sync"; export const appRouter = router({ team: teamRouter, @@ -57,6 +59,8 @@ export const appRouter = router({ pipelineDependency: pipelineDependencyRouter, webhookEndpoint: webhookEndpointRouter, promotion: promotionRouter, + filterPreset: filterPresetRouter, + gitSync: gitSyncRouter, }); export type AppRouter = typeof appRouter;