Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 591c7957e1 | |||
| 7e567d78da | |||
| 6260e90b09 | |||
| cd6a97dd2d | |||
| c3d50736cd | |||
| 423860c21c | |||
| 47e1463163 | |||
| a0b3032c22 | |||
| 56403224c0 | |||
| 75d35405dc | |||
| 78247c1d41 | |||
| 44ac2e430f |
53
changelog.md
53
changelog.md
@@ -1,5 +1,58 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-28 - 2.13.0 - feat(cache,build,docs)
|
||||
switch cache storage to SmartMongo and align build configuration with updated dependencies
|
||||
|
||||
- replaces LocalTsmDb usage with SmartMongo in the cache layer and related tests
|
||||
- moves tsbundle and tswatch configuration from npmextra.json to .smartconfig.json
|
||||
- updates Deno, frontend, and provider dependencies and adds @std/assert imports for tests
|
||||
- refreshes README to document managed secrets, sync, action log, and the expanded handler and view set
|
||||
|
||||
## 2026-03-02 - 2.12.0 - feat(pipelines)
|
||||
add pipelines view modes, time-range filtering, group aggregation, sorting, and job log polling
|
||||
|
||||
- Add view modes for pipelines: 'current', 'project', 'group', and 'error'; support timeRange and sortBy parameters on getPipelines requests and in the UI.
|
||||
- Implement aggregated pipeline fetching across projects with batching, deduplication, and active-run prioritization (fetchCurrentPipelines, fetchAggregatedPipelines, fetchGroupPipelines, fetchErrorPipelines).
|
||||
- Add filtering by time ranges (1h, 6h, 1d, 3d, 7d, 30d) and sorting options (created, duration, status) with status priority ordering.
|
||||
- Extend BaseProvider API: add IPipelineListOptions (status, ref, source), add getGroupProjects, and update Gitea/GitLab providers to honor new options and include projectName mapping.
|
||||
- Enhance web UI: new controls/state for viewMode, timeRange, sortBy, group selection, plus job log polling with auto-scroll and cleanup on disconnect.
|
||||
- Bump dependencies: @apiclient.xyz/gitea 1.3.0 -> 1.4.0 and @apiclient.xyz/gitlab 2.4.0 -> 2.5.0.
|
||||
|
||||
## 2026-03-02 - 2.11.1 - fix(meta)
|
||||
update repository metadata (non-functional change)
|
||||
|
||||
- Change was metadata-only (+1 -1) with no source code changes
|
||||
- Current package.json version: 2.11.0 — recommend patch bump to 2.11.1
|
||||
|
||||
## 2026-03-02 - 2.11.0 - feat(sync)
|
||||
add branch & tag listing support and improve sync mirroring and sync log routing
|
||||
|
||||
- Bump @apiclient.xyz/gitea to 1.3.0 and @apiclient.xyz/gitlab to 2.4.0
|
||||
- Add IBranch and ITag interfaces and export them from ts_interfaces
|
||||
- Add getBranches/getTags to BaseProvider and implement paginated branch/tag listing for Gitea and GitLab providers
|
||||
- SyncManager now creates a temporary mirrors directory (RAM-backed), auto-cleans it on shutdown, and no longer requires a configured syncMirrorsPath (removed from paths and gitopsapp)
|
||||
- Add refsMatch in SyncManager to accurately compare local branches/tags with target refs (uses for-each-ref and ls-remote) to avoid unnecessary pushes
|
||||
- Introduce avatarUploadCache and other internal sync manager improvements
|
||||
- Change log channel/tagging: sync log messages use 'sync' (was 'git') and TypedSocket broadcasts use a new 'syncLogClient' tag; web client now sets that tag when creating the socket
|
||||
|
||||
## 2026-02-28 - 2.10.0 - feat(managed-secrets)
|
||||
add centrally managed secrets with GITOPS_ prefix pushed to multiple targets
|
||||
|
||||
- Add IManagedSecret, IManagedSecretTarget, IManagedSecretStored interfaces and TypedRequest contracts for CRUD + push operations
|
||||
- Add ManagedSecretsManager with keychain-backed storage, upsert push logic, target diff on update, and best-effort delete
|
||||
- Add ManagedSecretsHandler with 7 endpoints wired into OpsServer with auth guards and action logging
|
||||
- Add frontend state part, 6 appstate actions, and Managed Secrets view with table, target picker, and push/edit/delete modals
|
||||
- Add Managed Secrets tab to dashboard after Secrets
|
||||
- Extend action log types with 'managed-secret' entity and 'push' action
|
||||
|
||||
## 2026-02-28 - 2.9.0 - feat(sync)
|
||||
remove target avatar when source has none to keep avatars fully in sync
|
||||
|
||||
- Add removeProjectAvatar and removeGroupAvatar methods for GitLab and Gitea APIs
|
||||
- In syncProjectMetadata, remove target project avatar when source has no avatar and no group fallback applies
|
||||
- When useGroupAvatarsForProjects is enabled but the group also has no avatar, remove the target avatar
|
||||
- In syncGroupMetadata, remove target group avatar when source group has no avatar
|
||||
|
||||
## 2026-02-28 - 2.8.0 - feat(sync)
|
||||
add sync subsystem: SyncManager, OpsServer sync handlers, Sync UI and state, provider groupFilter support, and realtime sync log streaming via TypedSocket
|
||||
|
||||
|
||||
17
deno.json
17
deno.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/gitops",
|
||||
"version": "2.8.0",
|
||||
"version": "2.13.0",
|
||||
"exports": "./mod.ts",
|
||||
"nodeModulesDir": "auto",
|
||||
"tasks": {
|
||||
@@ -12,15 +12,16 @@
|
||||
"@std/fs": "jsr:@std/fs@^1.0.19",
|
||||
"@std/encoding": "jsr:@std/encoding@^1.0.10",
|
||||
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
|
||||
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.2.6",
|
||||
"@api.global/typedserver": "npm:@api.global/typedserver@^8.3.1",
|
||||
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.3.0",
|
||||
"@api.global/typedserver": "npm:@api.global/typedserver@^8.4.6",
|
||||
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
|
||||
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1",
|
||||
"@apiclient.xyz/gitea": "npm:@apiclient.xyz/gitea@^1.2.0",
|
||||
"@apiclient.xyz/gitlab": "npm:@apiclient.xyz/gitlab@^2.2.0",
|
||||
"@push.rocks/smartmongo": "npm:@push.rocks/smartmongo@^5.1.0",
|
||||
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^7.0.15",
|
||||
"@push.rocks/smartsecret": "npm:@push.rocks/smartsecret@^1.0.1"
|
||||
"@apiclient.xyz/gitea": "npm:@apiclient.xyz/gitea@^1.5.0",
|
||||
"@apiclient.xyz/gitlab": "npm:@apiclient.xyz/gitlab@^2.6.0",
|
||||
"@push.rocks/smartmongo": "npm:@push.rocks/smartmongo@^7.0.0",
|
||||
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^7.1.3",
|
||||
"@push.rocks/smartsecret": "npm:@push.rocks/smartsecret@^1.0.2",
|
||||
"@std/assert": "jsr:@std/assert@^1.0.0"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
|
||||
21
license
Normal file
21
license
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Task Venture Capital GmbH
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
23
package.json
23
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/gitops",
|
||||
"version": "2.8.0",
|
||||
"version": "2.13.0",
|
||||
"description": "GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs",
|
||||
"main": "mod.ts",
|
||||
"type": "module",
|
||||
@@ -14,15 +14,20 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedserver": "8.4.0",
|
||||
"@api.global/typedsocket": "^4.1.0",
|
||||
"@apiclient.xyz/gitea": "1.2.0",
|
||||
"@apiclient.xyz/gitlab": "2.2.0",
|
||||
"@design.estate/dees-catalog": "^3.43.3",
|
||||
"@design.estate/dees-element": "^2.1.6"
|
||||
"@api.global/typedserver": "^8.4.6",
|
||||
"@api.global/typedsocket": "^4.1.2",
|
||||
"@apiclient.xyz/gitea": "^1.5.0",
|
||||
"@apiclient.xyz/gitlab": "^2.6.0",
|
||||
"@design.estate/dees-catalog": "^3.49.0",
|
||||
"@design.estate/dees-element": "^2.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbundle": "^2.9.0",
|
||||
"@git.zone/tswatch": "^3.2.0"
|
||||
"@git.zone/tsbundle": "^2.10.0",
|
||||
"@git.zone/tswatch": "^3.3.2"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
BIN
pipelines-current-mode.png
Normal file
BIN
pipelines-current-mode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
66
readme.md
66
readme.md
@@ -1,18 +1,21 @@
|
||||
# @serve.zone/gitops
|
||||
|
||||
A unified dashboard for managing Gitea and GitLab instances — browse projects, manage secrets, monitor CI/CD pipelines, stream build logs, and receive webhook notifications, all from a single app.
|
||||
A unified dashboard for managing Gitea and GitLab instances — browse projects, manage secrets, monitor CI/CD pipelines, stream build logs, sync configurations, and receive webhook notifications, all from a single app. 🚀
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## 🚀 Features
|
||||
## ✨ Features
|
||||
|
||||
- **Multi-Provider** — Connect to Gitea and GitLab simultaneously via a unified provider abstraction
|
||||
- **Secrets Management** — View, create, update, and delete CI/CD secrets across projects and groups
|
||||
- **Pipeline Monitoring** — Browse pipelines, view jobs, retry failed builds, cancel running ones
|
||||
- **Build Log Streaming** — Fetch and display raw job logs with monospace rendering
|
||||
- **Webhook Integration** — Receive push/PR/pipeline events via `POST /webhook/:connectionId` and broadcast to all connected clients in real-time via WebSocket
|
||||
- **Managed Secrets** — Define secrets once and push them to multiple providers/scopes automatically
|
||||
- **Pipeline Monitoring** — Browse pipelines with time-range filtering, view modes, group aggregation, and sorting; view jobs, retry failed builds, cancel running ones
|
||||
- **Build Log Streaming** — Fetch and display raw job logs with monospace rendering and live polling
|
||||
- **Sync Configurations** — Define repo sync rules across providers with status tracking
|
||||
- **Action Log** — Global audit trail of all operations across the system
|
||||
- **Webhook Integration** — Receive push/PR/pipeline events via `POST /webhook/:connectionId` and broadcast to all connected WebSocket clients in real-time
|
||||
- **Secrets Cache & Scanning** — Background scan service fetches and caches all secrets every 24h with upsert-based deduplication
|
||||
- **Secure Token Storage** — Connection tokens stored in OS keychain via `@push.rocks/smartsecret` (encrypted file fallback), never in plaintext on disk
|
||||
- **Auto-Refresh** — Frontend polls for updates every 30s, with manual refresh available on every view
|
||||
@@ -24,7 +27,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
|
||||
- [Deno](https://deno.land/) v2+
|
||||
- [pnpm](https://pnpm.io/) (for frontend deps and bundling)
|
||||
- MongoDB-compatible database (auto-provisioned via `@push.rocks/smartmongo` / `LocalTsmDb`)
|
||||
- MongoDB-compatible database (auto-provisioned via `@push.rocks/smartmongo`)
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -74,7 +77,7 @@ Data is stored at `~/.serve.zone/gitops/`:
|
||||
│ (HTTP/WS)│ (Providers) │ (24h background scan) │
|
||||
├──────────┤ ├───────────────────────────┤
|
||||
│ Handlers │ GiteaProvider│ CacheDb │
|
||||
│ (9 total)│ GitLabProvider│ (LocalTsmDb + SmartdataDb)│
|
||||
│(12 total)│ GitLabProvider│ (SmartMongo + SmartdataDb)│
|
||||
├──────────┴───────────────┴───────────────────────────┤
|
||||
│ StorageManager │
|
||||
│ (filesystem key-value store) │
|
||||
@@ -87,7 +90,7 @@ Data is stored at `~/.serve.zone/gitops/`:
|
||||
│ Frontend SPA │
|
||||
│ Lit + dees-catalog + smartstate │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ Dashboard │ 8 Views │ WebSocket Client │ Auto-Refresh│
|
||||
│ Dashboard │ 11 Views │ WebSocket Client │ Auto-Refresh│
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -96,30 +99,33 @@ Data is stored at `~/.serve.zone/gitops/`:
|
||||
- **`GitopsApp`** — Main orchestrator. Owns all subsystems, handles startup/shutdown lifecycle.
|
||||
- **`ConnectionManager`** — CRUD for provider connections. Tokens secured in OS keychain. Background health checks on startup.
|
||||
- **`BaseProvider`** → **`GiteaProvider`** / **`GitLabProvider`** — Unified interface over both APIs (projects, groups, secrets, pipelines, jobs, logs).
|
||||
- **`OpsServer`** — TypedServer-based HTTP/WebSocket server with 9 handler modules:
|
||||
- **`OpsServer`** — TypedServer-based HTTP/WebSocket server with 12 handler modules:
|
||||
- `AdminHandler` — JWT-based auth (login/logout/verify)
|
||||
- `ConnectionsHandler` — Connection CRUD + test
|
||||
- `ProjectsHandler` / `GroupsHandler` — Browse repos and orgs
|
||||
- `SecretsHandler` — Cache-first secret CRUD
|
||||
- `PipelinesHandler` — Pipeline list/jobs/retry/cancel
|
||||
- `ManagedSecretsHandler` — Managed secret definitions and push operations
|
||||
- `PipelinesHandler` — Pipeline list/jobs/retry/cancel with filtering and aggregation
|
||||
- `LogsHandler` — Job log fetch
|
||||
- `WebhookHandler` — Custom HTTP route for incoming webhooks
|
||||
- `ActionsHandler` — Force scan / scan status
|
||||
- `ActionLogHandler` — Global audit trail queries
|
||||
- `SyncHandler` — Repo sync configuration and status
|
||||
- **`SecretsScanService`** — Background scanner with upsert-based deduplication. Runs on startup and every 24h.
|
||||
- **`CacheDb`** — Embedded MongoDB via `LocalTsmDb` + `SmartdataDb`. TTL-based expiration with periodic cleanup.
|
||||
- **`CacheDb`** — Embedded MongoDB via `SmartMongo` + `SmartdataDb`. TTL-based expiration with periodic cleanup.
|
||||
- **`StorageManager`** — Filesystem-backed key-value store with atomic writes.
|
||||
|
||||
### Frontend (`ts_web/`)
|
||||
|
||||
- Built with [Lit](https://lit.dev/) web components and [@design.estate/dees-catalog](https://code.foss.global/design.estate/dees-catalog) UI library
|
||||
- Reactive state management via `smartstate` (4 state parts: login, connections, data, UI)
|
||||
- 8 tabbed views: Overview, Connections, Projects, Groups, Secrets, Pipelines, Build Log, Actions
|
||||
- Built with [Lit](https://lit.dev/) web components using TC39 standard decorators and [@design.estate/dees-catalog](https://code.foss.global/design.estate/dees-catalog) UI library
|
||||
- Reactive state management via `smartstate` (login, connections, data, UI state parts)
|
||||
- 11 tabbed views: Overview, Connections, Projects, Groups, Secrets, Managed Secrets, Pipelines, Build Log, Actions, Action Log, Sync
|
||||
- WebSocket client for real-time webhook push notifications
|
||||
- Bundled to `ts_bundled/bundle.ts` via `@git.zone/tsbundle` (base64-encoded, committed to git)
|
||||
|
||||
### Shared Types (`ts_interfaces/`)
|
||||
|
||||
- `data/` — Data models (`IProject`, `ISecret`, `IPipeline`, `IIdentity`, etc.)
|
||||
- `data/` — Data models (`IProject`, `ISecret`, `IPipeline`, `IIdentity`, `IConnection`, `ISyncConfig`, `IManagedSecret`, `IActionLogEntry`, etc.)
|
||||
- `requests/` — TypedRequest interfaces for all RPC endpoints
|
||||
|
||||
## 🔌 API
|
||||
@@ -156,17 +162,33 @@ All endpoints use [TypedRequest](https://code.foss.global/api.global/typedreques
|
||||
| `getAllSecrets` | Get all secrets for a connection+scope (cache-first) |
|
||||
| `getSecrets` | Get secrets for a specific entity (cache-first) |
|
||||
| `createSecret` / `updateSecret` / `deleteSecret` | Secret CRUD |
|
||||
| `getPipelines` | List pipelines for a project |
|
||||
| `getPipelines` | List pipelines for a project (with time-range filtering) |
|
||||
| `getPipelineJobs` | List jobs for a pipeline |
|
||||
| `retryPipeline` / `cancelPipeline` | Pipeline actions |
|
||||
| `getJobLog` | Fetch raw build log for a job |
|
||||
|
||||
### Managed Secrets
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `getManagedSecrets` | List managed secret definitions |
|
||||
| `createManagedSecret` / `updateManagedSecret` / `deleteManagedSecret` | Managed secret CRUD |
|
||||
|
||||
### Sync
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `getSyncConfigs` | List sync configurations |
|
||||
| `createSyncConfig` / `updateSyncConfig` / `deleteSyncConfig` | Sync config CRUD |
|
||||
| `getRepoSyncStatus` | Get sync status for repos |
|
||||
|
||||
### Actions
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `forceScanSecrets` | Trigger immediate full secrets scan |
|
||||
| `getScanStatus` | Get scan status, last result, timestamp |
|
||||
| `getActionLog` | Query global audit trail |
|
||||
|
||||
### Webhooks
|
||||
|
||||
@@ -200,16 +222,16 @@ gitops/
|
||||
├── mod.ts # Entry point
|
||||
├── deno.json # Deno config + import map
|
||||
├── package.json # npm metadata + scripts
|
||||
├── npmextra.json # tsbundle + tswatch config
|
||||
├── .smartconfig.json # tsbundle + tswatch config
|
||||
├── html/index.html # HTML shell
|
||||
├── ts/ # Backend
|
||||
│ ├── classes/ # GitopsApp, ConnectionManager
|
||||
│ ├── classes/ # GitopsApp, ConnectionManager, SyncManager, ActionLog
|
||||
│ ├── providers/ # BaseProvider, GiteaProvider, GitLabProvider
|
||||
│ ├── storage/ # StorageManager
|
||||
│ ├── cache/ # CacheDb, CacheCleaner, SecretsScanService
|
||||
│ │ └── documents/ # CachedProject, CachedSecret
|
||||
│ └── opsserver/ # OpsServer + 9 handlers
|
||||
│ ├── handlers/ # AdminHandler, SecretsHandler, etc.
|
||||
│ └── opsserver/ # OpsServer + 12 handlers
|
||||
│ ├── handlers/ # AdminHandler, SecretsHandler, SyncHandler, etc.
|
||||
│ └── helpers/ # Guards (JWT verification)
|
||||
├── ts_interfaces/ # Shared TypeScript types
|
||||
│ ├── data/ # IProject, ISecret, IPipeline, etc.
|
||||
@@ -217,14 +239,14 @@ gitops/
|
||||
├── ts_web/ # Frontend SPA
|
||||
│ ├── appstate.ts # Smartstate store + actions
|
||||
│ └── elements/ # Lit web components
|
||||
│ └── views/ # 8 view components
|
||||
│ └── views/ # 11 view components
|
||||
├── ts_bundled/bundle.ts # Embedded frontend (base64, committed)
|
||||
└── test/ # Deno tests
|
||||
```
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { assertEquals, assertExists } from 'https://deno.land/std@0.208.0/assert/mod.ts';
|
||||
import { assertEquals, assertExists } from '@std/assert';
|
||||
import { BaseProvider, GiteaProvider, GitLabProvider } from '../ts/providers/index.ts';
|
||||
import { ConnectionManager } from '../ts/classes/connectionmanager.ts';
|
||||
import { GitopsApp } from '../ts/classes/gitopsapp.ts';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { assertEquals, assertExists } from 'https://deno.land/std@0.208.0/assert/mod.ts';
|
||||
import { assertEquals, assertExists } from '@std/assert';
|
||||
import { StorageManager } from '../ts/storage/index.ts';
|
||||
import * as smartsecret from '@push.rocks/smartsecret';
|
||||
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { assertEquals, assertExists } from 'https://deno.land/std@0.208.0/assert/mod.ts';
|
||||
import { LocalTsmDb } from '@push.rocks/smartmongo';
|
||||
import { assertEquals, assertExists } from '@std/assert';
|
||||
import { SmartMongo } from '@push.rocks/smartmongo';
|
||||
import { SmartdataDb, SmartDataDbDoc, Collection, svDb, unI } from '@push.rocks/smartdata';
|
||||
|
||||
Deno.test({
|
||||
name: 'TsmDb spike: LocalTsmDb + SmartdataDb roundtrip',
|
||||
name: 'TsmDb spike: SmartMongo + SmartdataDb roundtrip',
|
||||
sanitizeOps: false,
|
||||
sanitizeResources: false,
|
||||
fn: async () => {
|
||||
const tmpDir = await Deno.makeTempDir();
|
||||
|
||||
// 1. Start local MongoDB-compatible server
|
||||
const localDb = new LocalTsmDb({ folderPath: tmpDir });
|
||||
const { connectionUri } = await localDb.start();
|
||||
assertExists(connectionUri);
|
||||
const mongo = await SmartMongo.createAndStart();
|
||||
const mongoDescriptor = await mongo.getMongoDescriptor();
|
||||
assertExists(mongoDescriptor.mongoDbUrl);
|
||||
|
||||
// 2. Connect smartdata
|
||||
const smartDb = new SmartdataDb({
|
||||
mongoDbUrl: connectionUri,
|
||||
mongoDbUrl: mongoDescriptor.mongoDbUrl,
|
||||
mongoDbName: 'gitops_spike_test',
|
||||
});
|
||||
await smartDb.init();
|
||||
@@ -52,8 +50,8 @@ Deno.test({
|
||||
assertEquals(found.label, 'spike');
|
||||
assertEquals(found.value, 42);
|
||||
|
||||
// 6. Cleanup — smartDb closes; localDb.stop() hangs under Deno, so fire-and-forget
|
||||
// 6. Cleanup — smartDb closes; mongo.stop() may hang under Deno, so fire-and-forget
|
||||
await smartDb.close();
|
||||
localDb.stop().catch(() => {});
|
||||
mongo.stop().catch(() => {});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/gitops',
|
||||
version: '2.8.0',
|
||||
version: '2.13.0',
|
||||
description: 'GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs'
|
||||
}
|
||||
|
||||
18
ts/cache/classes.cachedb.ts
vendored
18
ts/cache/classes.cachedb.ts
vendored
@@ -14,7 +14,7 @@ export interface ICacheDbOptions {
|
||||
export class CacheDb {
|
||||
private static instance: CacheDb | null = null;
|
||||
|
||||
private localTsmDb: InstanceType<typeof plugins.smartmongo.LocalTsmDb> | null = null;
|
||||
private smartMongo: InstanceType<typeof plugins.smartmongo.SmartMongo> | null = null;
|
||||
private smartdataDb: InstanceType<typeof plugins.smartdata.SmartdataDb> | null = null;
|
||||
private options: Required<ICacheDbOptions>;
|
||||
|
||||
@@ -39,13 +39,11 @@ export class CacheDb {
|
||||
|
||||
async start(): Promise<void> {
|
||||
logger.info('Starting CacheDb...');
|
||||
this.localTsmDb = new plugins.smartmongo.LocalTsmDb({
|
||||
folderPath: this.options.storagePath,
|
||||
});
|
||||
const { connectionUri } = await this.localTsmDb.start();
|
||||
this.smartMongo = await plugins.smartmongo.SmartMongo.createAndStart();
|
||||
const mongoDescriptor = await this.smartMongo.getMongoDescriptor();
|
||||
|
||||
this.smartdataDb = new plugins.smartdata.SmartdataDb({
|
||||
mongoDbUrl: connectionUri,
|
||||
mongoDbUrl: mongoDescriptor.mongoDbUrl,
|
||||
mongoDbName: this.options.dbName,
|
||||
});
|
||||
await this.smartdataDb.init();
|
||||
@@ -58,9 +56,9 @@ export class CacheDb {
|
||||
await this.smartdataDb.close();
|
||||
this.smartdataDb = null;
|
||||
}
|
||||
if (this.localTsmDb) {
|
||||
// localDb.stop() may hang under Deno — fire-and-forget with timeout
|
||||
const stopPromise = this.localTsmDb.stop().catch(() => {});
|
||||
if (this.smartMongo) {
|
||||
// smartMongo.stop() may hang under Deno — fire-and-forget with timeout
|
||||
const stopPromise = this.smartMongo.stop().catch(() => {});
|
||||
await Promise.race([
|
||||
stopPromise,
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -68,7 +66,7 @@ export class CacheDb {
|
||||
Deno.unrefTimer(id);
|
||||
}),
|
||||
]);
|
||||
this.localTsmDb = null;
|
||||
this.smartMongo = null;
|
||||
}
|
||||
logger.success('CacheDb stopped');
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ export class ConnectionManager {
|
||||
try {
|
||||
if (conn.providerType === 'gitlab') {
|
||||
const gitlabClient = new plugins.gitlabClient.GitLabClient(conn.baseUrl, conn.token);
|
||||
const group = await gitlabClient.getGroupByPath(conn.groupFilter);
|
||||
const group = await gitlabClient.getGroup(conn.groupFilter);
|
||||
conn.groupFilterId = String(group.id);
|
||||
logger.info(`Resolved group filter "${conn.groupFilter}" to ID ${conn.groupFilterId}`);
|
||||
} else {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { logger } from '../logging.ts';
|
||||
import { ConnectionManager } from './connectionmanager.ts';
|
||||
import { ActionLog } from './actionlog.ts';
|
||||
import { SyncManager } from './syncmanager.ts';
|
||||
import { ManagedSecretsManager } from './managedsecrets.manager.ts';
|
||||
import { OpsServer } from '../opsserver/index.ts';
|
||||
import { StorageManager } from '../storage/index.ts';
|
||||
import { CacheDb, CacheCleaner, CachedProject, CachedSecret, SecretsScanService } from '../cache/index.ts';
|
||||
@@ -20,6 +21,7 @@ export class GitopsApp {
|
||||
public cacheDb: CacheDb;
|
||||
public cacheCleaner: CacheCleaner;
|
||||
public syncManager!: SyncManager;
|
||||
public managedSecretsManager!: ManagedSecretsManager;
|
||||
public secretsScanService!: SecretsScanService;
|
||||
private scanIntervalId: number | null = null;
|
||||
private paths: ReturnType<typeof resolvePaths>;
|
||||
@@ -55,12 +57,19 @@ export class GitopsApp {
|
||||
// Initialize connection manager (loads saved connections)
|
||||
await this.connectionManager.init();
|
||||
|
||||
// Initialize managed secrets manager
|
||||
this.managedSecretsManager = new ManagedSecretsManager(
|
||||
this.storageManager,
|
||||
this.smartSecret,
|
||||
this.connectionManager,
|
||||
);
|
||||
await this.managedSecretsManager.init();
|
||||
|
||||
// Initialize sync manager
|
||||
this.syncManager = new SyncManager(
|
||||
this.storageManager,
|
||||
this.connectionManager,
|
||||
this.actionLog,
|
||||
this.paths.syncMirrorsPath,
|
||||
);
|
||||
await this.syncManager.init();
|
||||
|
||||
|
||||
322
ts/classes/managedsecrets.manager.ts
Normal file
322
ts/classes/managedsecrets.manager.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import type * as interfaces from '../../ts_interfaces/index.ts';
|
||||
import type { StorageManager } from '../storage/index.ts';
|
||||
import type { ConnectionManager } from './connectionmanager.ts';
|
||||
|
||||
const MANAGED_SECRETS_PREFIX = '/managed-secrets/';
|
||||
const KEYCHAIN_PREFIX = 'keychain:';
|
||||
const KEYCHAIN_ID_PREFIX = 'gitops-msecret-';
|
||||
const SECRET_KEY_PREFIX = 'GITOPS_';
|
||||
|
||||
export class ManagedSecretsManager {
|
||||
private secrets: interfaces.data.IManagedSecretStored[] = [];
|
||||
|
||||
constructor(
|
||||
private storageManager: StorageManager,
|
||||
private smartSecret: plugins.smartsecret.SmartSecret,
|
||||
private connectionManager: ConnectionManager,
|
||||
) {}
|
||||
|
||||
async init(): Promise<void> {
|
||||
await this.loadSecrets();
|
||||
}
|
||||
|
||||
// ---- Storage helpers ----
|
||||
|
||||
private keychainId(secretId: string): string {
|
||||
return `${KEYCHAIN_ID_PREFIX}${secretId}`;
|
||||
}
|
||||
|
||||
private prefixedKey(key: string): string {
|
||||
return `${SECRET_KEY_PREFIX}${key}`;
|
||||
}
|
||||
|
||||
private async loadSecrets(): Promise<void> {
|
||||
const keys = await this.storageManager.list(MANAGED_SECRETS_PREFIX);
|
||||
this.secrets = [];
|
||||
for (const key of keys) {
|
||||
const stored = await this.storageManager.getJSON<interfaces.data.IManagedSecretStored>(key);
|
||||
if (stored) {
|
||||
this.secrets.push(stored);
|
||||
}
|
||||
}
|
||||
if (this.secrets.length > 0) {
|
||||
logger.info(`Loaded ${this.secrets.length} managed secret(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistSecret(stored: interfaces.data.IManagedSecretStored, realValue: string): Promise<void> {
|
||||
// Store real value in keychain
|
||||
await this.smartSecret.setSecret(this.keychainId(stored.id), realValue);
|
||||
// Save JSON with sentinel
|
||||
const jsonStored = { ...stored, value: `${KEYCHAIN_PREFIX}${this.keychainId(stored.id)}` };
|
||||
await this.storageManager.setJSON(`${MANAGED_SECRETS_PREFIX}${stored.id}.json`, jsonStored);
|
||||
// Update in-memory sentinel too
|
||||
stored.value = jsonStored.value;
|
||||
}
|
||||
|
||||
private async removeFromStorage(id: string): Promise<void> {
|
||||
await this.smartSecret.deleteSecret(this.keychainId(id));
|
||||
await this.storageManager.delete(`${MANAGED_SECRETS_PREFIX}${id}.json`);
|
||||
}
|
||||
|
||||
private async getSecretValue(id: string): Promise<string | null> {
|
||||
return await this.smartSecret.getSecret(this.keychainId(id));
|
||||
}
|
||||
|
||||
private toApiModel(stored: interfaces.data.IManagedSecretStored): interfaces.data.IManagedSecret {
|
||||
return {
|
||||
id: stored.id,
|
||||
key: stored.key,
|
||||
description: stored.description,
|
||||
targets: stored.targets,
|
||||
targetStatuses: stored.targetStatuses,
|
||||
createdAt: stored.createdAt,
|
||||
updatedAt: stored.updatedAt,
|
||||
lastPushedAt: stored.lastPushedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Push logic ----
|
||||
|
||||
private async pushToTargets(
|
||||
stored: interfaces.data.IManagedSecretStored,
|
||||
mode: 'upsert' | 'delete',
|
||||
targetsOverride?: interfaces.data.IManagedSecretTarget[],
|
||||
): Promise<interfaces.data.IManagedSecretTargetStatus[]> {
|
||||
const targets = targetsOverride || stored.targets;
|
||||
const value = mode === 'upsert' ? await this.getSecretValue(stored.id) : null;
|
||||
const prefixedKey = this.prefixedKey(stored.key);
|
||||
const results: interfaces.data.IManagedSecretTargetStatus[] = [];
|
||||
|
||||
for (const target of targets) {
|
||||
const status: interfaces.data.IManagedSecretTargetStatus = {
|
||||
connectionId: target.connectionId,
|
||||
scope: target.scope,
|
||||
scopeId: target.scopeId,
|
||||
scopeName: target.scopeName,
|
||||
status: 'pending',
|
||||
};
|
||||
try {
|
||||
const provider = this.connectionManager.getProvider(target.connectionId);
|
||||
if (mode === 'upsert') {
|
||||
// Try update first; if it fails, create
|
||||
try {
|
||||
if (target.scope === 'project') {
|
||||
await provider.updateProjectSecret(target.scopeId, prefixedKey, value!);
|
||||
} else {
|
||||
await provider.updateGroupSecret(target.scopeId, prefixedKey, value!);
|
||||
}
|
||||
} catch {
|
||||
// Secret doesn't exist yet — create it
|
||||
if (target.scope === 'project') {
|
||||
await provider.createProjectSecret(target.scopeId, prefixedKey, value!);
|
||||
} else {
|
||||
await provider.createGroupSecret(target.scopeId, prefixedKey, value!);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// mode === 'delete'
|
||||
try {
|
||||
if (target.scope === 'project') {
|
||||
await provider.deleteProjectSecret(target.scopeId, prefixedKey);
|
||||
} else {
|
||||
await provider.deleteGroupSecret(target.scopeId, prefixedKey);
|
||||
}
|
||||
} catch {
|
||||
// Secret may not exist on target — that's fine
|
||||
}
|
||||
}
|
||||
status.status = 'success';
|
||||
status.lastPushedAt = Date.now();
|
||||
} catch (err) {
|
||||
status.status = 'error';
|
||||
status.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
results.push(status);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- Public API ----
|
||||
|
||||
async getAll(): Promise<interfaces.data.IManagedSecret[]> {
|
||||
return this.secrets.map((s) => this.toApiModel(s));
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<interfaces.data.IManagedSecret | null> {
|
||||
const stored = this.secrets.find((s) => s.id === id);
|
||||
return stored ? this.toApiModel(stored) : null;
|
||||
}
|
||||
|
||||
async create(
|
||||
key: string,
|
||||
value: string,
|
||||
description: string | undefined,
|
||||
targets: interfaces.data.IManagedSecretTarget[],
|
||||
): Promise<{
|
||||
managedSecret: interfaces.data.IManagedSecret;
|
||||
pushResults: interfaces.data.IManagedSecretTargetStatus[];
|
||||
}> {
|
||||
// Validate key
|
||||
if (key.toUpperCase().startsWith(SECRET_KEY_PREFIX)) {
|
||||
throw new Error(`Key must not start with ${SECRET_KEY_PREFIX} — the prefix is added automatically`);
|
||||
}
|
||||
if (this.secrets.some((s) => s.key === key)) {
|
||||
throw new Error(`A managed secret with key "${key}" already exists`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const stored: interfaces.data.IManagedSecretStored = {
|
||||
id: crypto.randomUUID(),
|
||||
key,
|
||||
description,
|
||||
value: '', // will be set by persistSecret
|
||||
targets,
|
||||
targetStatuses: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.secrets.push(stored);
|
||||
await this.persistSecret(stored, value);
|
||||
|
||||
// Push to all targets
|
||||
const pushResults = await this.pushToTargets(stored, 'upsert');
|
||||
stored.targetStatuses = pushResults;
|
||||
stored.lastPushedAt = now;
|
||||
stored.updatedAt = now;
|
||||
await this.storageManager.setJSON(`${MANAGED_SECRETS_PREFIX}${stored.id}.json`, {
|
||||
...stored,
|
||||
value: `${KEYCHAIN_PREFIX}${this.keychainId(stored.id)}`,
|
||||
});
|
||||
|
||||
logger.info(`Created managed secret "${key}" with ${targets.length} target(s)`);
|
||||
return { managedSecret: this.toApiModel(stored), pushResults };
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
updates: {
|
||||
value?: string;
|
||||
description?: string;
|
||||
targets?: interfaces.data.IManagedSecretTarget[];
|
||||
},
|
||||
): Promise<{
|
||||
managedSecret: interfaces.data.IManagedSecret;
|
||||
pushResults: interfaces.data.IManagedSecretTargetStatus[];
|
||||
}> {
|
||||
const stored = this.secrets.find((s) => s.id === id);
|
||||
if (!stored) throw new Error(`Managed secret not found: ${id}`);
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Update value in keychain if provided
|
||||
if (updates.value !== undefined) {
|
||||
await this.smartSecret.setSecret(this.keychainId(id), updates.value);
|
||||
}
|
||||
|
||||
if (updates.description !== undefined) {
|
||||
stored.description = updates.description;
|
||||
}
|
||||
|
||||
// Handle target changes — delete from removed targets
|
||||
let removedTargets: interfaces.data.IManagedSecretTarget[] = [];
|
||||
if (updates.targets !== undefined) {
|
||||
const oldTargets = stored.targets;
|
||||
const newTargetKeys = new Set(
|
||||
updates.targets.map((t) => `${t.connectionId}:${t.scope}:${t.scopeId}`),
|
||||
);
|
||||
removedTargets = oldTargets.filter(
|
||||
(t) => !newTargetKeys.has(`${t.connectionId}:${t.scope}:${t.scopeId}`),
|
||||
);
|
||||
stored.targets = updates.targets;
|
||||
}
|
||||
|
||||
stored.updatedAt = now;
|
||||
|
||||
// Delete from removed targets
|
||||
if (removedTargets.length > 0) {
|
||||
await this.pushToTargets(stored, 'delete', removedTargets);
|
||||
}
|
||||
|
||||
// Push to current targets
|
||||
const pushResults = await this.pushToTargets(stored, 'upsert');
|
||||
stored.targetStatuses = pushResults;
|
||||
stored.lastPushedAt = now;
|
||||
|
||||
await this.storageManager.setJSON(`${MANAGED_SECRETS_PREFIX}${stored.id}.json`, {
|
||||
...stored,
|
||||
value: `${KEYCHAIN_PREFIX}${this.keychainId(stored.id)}`,
|
||||
});
|
||||
|
||||
logger.info(`Updated managed secret "${stored.key}"`);
|
||||
return { managedSecret: this.toApiModel(stored), pushResults };
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<{
|
||||
ok: boolean;
|
||||
deleteResults: interfaces.data.IManagedSecretTargetStatus[];
|
||||
}> {
|
||||
const stored = this.secrets.find((s) => s.id === id);
|
||||
if (!stored) throw new Error(`Managed secret not found: ${id}`);
|
||||
|
||||
// Best-effort: remove from all targets
|
||||
const deleteResults = await this.pushToTargets(stored, 'delete');
|
||||
|
||||
// Remove from local storage regardless
|
||||
const idx = this.secrets.indexOf(stored);
|
||||
this.secrets.splice(idx, 1);
|
||||
await this.removeFromStorage(id);
|
||||
|
||||
logger.info(`Deleted managed secret "${stored.key}"`);
|
||||
return { ok: true, deleteResults };
|
||||
}
|
||||
|
||||
async pushOne(id: string): Promise<{
|
||||
managedSecret: interfaces.data.IManagedSecret;
|
||||
pushResults: interfaces.data.IManagedSecretTargetStatus[];
|
||||
}> {
|
||||
const stored = this.secrets.find((s) => s.id === id);
|
||||
if (!stored) throw new Error(`Managed secret not found: ${id}`);
|
||||
|
||||
const now = Date.now();
|
||||
const pushResults = await this.pushToTargets(stored, 'upsert');
|
||||
stored.targetStatuses = pushResults;
|
||||
stored.lastPushedAt = now;
|
||||
stored.updatedAt = now;
|
||||
|
||||
await this.storageManager.setJSON(`${MANAGED_SECRETS_PREFIX}${stored.id}.json`, {
|
||||
...stored,
|
||||
value: `${KEYCHAIN_PREFIX}${this.keychainId(stored.id)}`,
|
||||
});
|
||||
|
||||
return { managedSecret: this.toApiModel(stored), pushResults };
|
||||
}
|
||||
|
||||
async pushAll(): Promise<
|
||||
Array<{
|
||||
managedSecretId: string;
|
||||
key: string;
|
||||
pushResults: interfaces.data.IManagedSecretTargetStatus[];
|
||||
}>
|
||||
> {
|
||||
const results: Array<{
|
||||
managedSecretId: string;
|
||||
key: string;
|
||||
pushResults: interfaces.data.IManagedSecretTargetStatus[];
|
||||
}> = [];
|
||||
|
||||
for (const stored of this.secrets) {
|
||||
const { pushResults } = await this.pushOne(stored.id);
|
||||
results.push({
|
||||
managedSecretId: stored.id,
|
||||
key: stored.key,
|
||||
pushResults,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type * as interfaces from '../../ts_interfaces/index.ts';
|
||||
import type { ConnectionManager } from './connectionmanager.ts';
|
||||
import type { ActionLog } from './actionlog.ts';
|
||||
import type { StorageManager } from '../storage/index.ts';
|
||||
import type { BaseProvider } from '../providers/classes.baseprovider.ts';
|
||||
|
||||
const SYNC_PREFIX = '/sync/';
|
||||
const SYNC_STATUS_PREFIX = '/sync-status/';
|
||||
@@ -19,15 +20,19 @@ export class SyncManager {
|
||||
private runningSync: Set<string> = new Set();
|
||||
private syncedGroupMeta: Set<string> = new Set();
|
||||
private currentSyncConfig: interfaces.data.ISyncConfig | null = null;
|
||||
private avatarUploadCache: Map<string, string> = new Map();
|
||||
|
||||
private mirrorsPath = '';
|
||||
|
||||
constructor(
|
||||
private storageManager: StorageManager,
|
||||
private connectionManager: ConnectionManager,
|
||||
private actionLog: ActionLog,
|
||||
private mirrorsPath: string,
|
||||
) {}
|
||||
|
||||
async init(): Promise<void> {
|
||||
// Create temp directory for mirrors (RAM-backed on most Linux systems via tmpfs)
|
||||
this.mirrorsPath = await Deno.makeTempDir({ prefix: 'gitops-mirrors-' });
|
||||
await this.loadConfigs();
|
||||
for (const config of this.configs) {
|
||||
if (config.status === 'active') {
|
||||
@@ -44,6 +49,12 @@ export class SyncManager {
|
||||
clearInterval(timer);
|
||||
}
|
||||
this.timers.clear();
|
||||
// Clean up temp mirrors directory
|
||||
if (this.mirrorsPath) {
|
||||
try {
|
||||
await Deno.remove(this.mirrorsPath, { recursive: true });
|
||||
} catch { /* may already be gone */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -67,6 +78,7 @@ export class SyncManager {
|
||||
enforceDelete?: boolean;
|
||||
enforceGroupDelete?: boolean;
|
||||
addMirrorHint?: boolean;
|
||||
useGroupAvatarsForProjects?: boolean;
|
||||
}): Promise<interfaces.data.ISyncConfig> {
|
||||
const config: interfaces.data.ISyncConfig = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -81,6 +93,7 @@ export class SyncManager {
|
||||
enforceDelete: data.enforceDelete ?? false,
|
||||
enforceGroupDelete: data.enforceGroupDelete ?? false,
|
||||
addMirrorHint: data.addMirrorHint ?? false,
|
||||
useGroupAvatarsForProjects: data.useGroupAvatarsForProjects ?? false,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.validateSyncConfig(config);
|
||||
@@ -92,7 +105,7 @@ export class SyncManager {
|
||||
|
||||
async updateConfig(
|
||||
id: string,
|
||||
updates: { name?: string; targetGroupOffset?: string; intervalMinutes?: number; enforceDelete?: boolean; enforceGroupDelete?: boolean; addMirrorHint?: boolean },
|
||||
updates: { name?: string; targetGroupOffset?: string; intervalMinutes?: number; enforceDelete?: boolean; enforceGroupDelete?: boolean; addMirrorHint?: boolean; useGroupAvatarsForProjects?: boolean },
|
||||
): Promise<interfaces.data.ISyncConfig> {
|
||||
const config = this.configs.find((c) => c.id === id);
|
||||
if (!config) throw new Error(`Sync config not found: ${id}`);
|
||||
@@ -101,6 +114,7 @@ export class SyncManager {
|
||||
if (updates.enforceDelete !== undefined) config.enforceDelete = updates.enforceDelete;
|
||||
if (updates.enforceGroupDelete !== undefined) config.enforceGroupDelete = updates.enforceGroupDelete;
|
||||
if (updates.addMirrorHint !== undefined) config.addMirrorHint = updates.addMirrorHint;
|
||||
if (updates.useGroupAvatarsForProjects !== undefined) config.useGroupAvatarsForProjects = updates.useGroupAvatarsForProjects;
|
||||
if (updates.targetGroupOffset !== undefined) config.targetGroupOffset = updates.targetGroupOffset;
|
||||
this.validateSyncConfig(config);
|
||||
await this.persistConfig(config);
|
||||
@@ -282,12 +296,12 @@ export class SyncManager {
|
||||
logger.syncLog('info', `Found ${projects.length} source projects (${allProjects.length - projects.length} obsolete excluded)`, 'api');
|
||||
|
||||
let synced = 0;
|
||||
const CONCURRENCY = 4;
|
||||
const CONCURRENCY = 10;
|
||||
for (let i = 0; i < projects.length; i += CONCURRENCY) {
|
||||
const batch = projects.slice(i, i + CONCURRENCY);
|
||||
await Promise.all(batch.map(async (project) => {
|
||||
try {
|
||||
logger.syncLog('info', `Syncing ${project.fullPath}...`, 'git');
|
||||
logger.syncLog('info', `Syncing ${project.fullPath}...`, 'sync');
|
||||
await this.syncRepo(config, project, sourceConn, targetConn);
|
||||
synced++;
|
||||
await this.updateRepoStatus(config.id, project.fullPath, {
|
||||
@@ -295,7 +309,7 @@ export class SyncManager {
|
||||
lastSyncAt: Date.now(),
|
||||
lastSyncError: undefined,
|
||||
});
|
||||
logger.syncLog('success', `Synced ${project.fullPath}`, 'git');
|
||||
logger.syncLog('success', `Synced ${project.fullPath}`, 'sync');
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
await this.updateRepoStatus(config.id, project.fullPath, {
|
||||
@@ -303,7 +317,7 @@ export class SyncManager {
|
||||
lastSyncError: errMsg,
|
||||
lastSyncAt: Date.now(),
|
||||
});
|
||||
logger.syncLog('error', `Sync failed for ${project.fullPath}: ${errMsg}`, 'git');
|
||||
logger.syncLog('error', `Sync failed for ${project.fullPath}: ${errMsg}`, 'sync');
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -369,21 +383,38 @@ export class SyncManager {
|
||||
// Ensure target group/project hierarchy exists
|
||||
await this.ensureTargetExists(targetConn, targetFullPath, project, sourceConn, sourceConn.groupFilter, config.targetGroupOffset);
|
||||
|
||||
// API-based ref comparison (fast path — avoids git clone when refs already match)
|
||||
const sourceProvider = this.connectionManager.getProvider(sourceConn.id);
|
||||
const targetProvider = this.connectionManager.getProvider(targetConn.id);
|
||||
const apiRefsMatch = await this.refsMatchViaApi(
|
||||
sourceProvider, targetProvider, project.fullPath, targetFullPath,
|
||||
);
|
||||
if (apiRefsMatch === true) {
|
||||
logger.syncLog('info', `Refs match via API for ${project.fullPath}, skipping git`, 'api');
|
||||
await this.syncProjectMetadata(config, sourceConn, targetConn, project.fullPath, targetFullPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clone or fetch from source
|
||||
try {
|
||||
const exists = await this.dirExists(mirrorDir);
|
||||
if (!exists) {
|
||||
await Deno.mkdir(mirrorDir, { recursive: true });
|
||||
await this.runGit(['clone', '--bare', sourceUrl, '.'], mirrorDir);
|
||||
} else {
|
||||
// Update source remote URL in case it changed
|
||||
try {
|
||||
await this.runGit(['remote', 'set-url', 'origin', sourceUrl], mirrorDir);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
await this.runGit(['fetch', '--prune', 'origin'], mirrorDir);
|
||||
}
|
||||
// Ensure fetch refspec is configured (bare clones don't set one by default,
|
||||
// which prevents tracking branch renames like master -> main)
|
||||
await this.runGit(
|
||||
['config', 'remote.origin.fetch', '+refs/heads/*:refs/heads/*'], mirrorDir,
|
||||
);
|
||||
// Update source remote URL in case connection changed
|
||||
try {
|
||||
await this.runGit(['remote', 'set-url', 'origin', sourceUrl], mirrorDir);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
// Fetch latest refs from source (--prune removes branches deleted on remote)
|
||||
await this.runGit(['fetch', '--prune', 'origin'], mirrorDir);
|
||||
} catch (err: any) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("couldn't find remote ref HEAD")) {
|
||||
@@ -418,12 +449,33 @@ export class SyncManager {
|
||||
});
|
||||
}
|
||||
|
||||
await this.runGit([
|
||||
'push', 'target',
|
||||
'+refs/heads/*:refs/heads/*',
|
||||
'+refs/tags/*:refs/tags/*',
|
||||
'--prune',
|
||||
], mirrorDir);
|
||||
// Compare refs to determine if push is needed
|
||||
const refsAlreadyMatch = !isUnrelated && await this.refsMatch(mirrorDir);
|
||||
|
||||
if (refsAlreadyMatch) {
|
||||
logger.syncLog('info', `Refs already match for ${project.fullPath}, skipping push`, 'api');
|
||||
} else {
|
||||
// Phase 1: push all refs without pruning (ensures target has all source branches)
|
||||
await this.runGit([
|
||||
'push', 'target',
|
||||
'+refs/heads/*:refs/heads/*',
|
||||
'+refs/tags/*:refs/tags/*',
|
||||
], mirrorDir);
|
||||
|
||||
// Phase 2: sync default_branch now that all branches exist on target
|
||||
await this.syncDefaultBranchBeforePush(sourceConn, targetConn, project.fullPath, targetFullPath);
|
||||
|
||||
// Phase 2b: unprotect stale branches on target so --prune can delete them
|
||||
await this.unprotectStaleBranches(targetConn, targetFullPath, mirrorDir);
|
||||
|
||||
// Phase 3: push with --prune to remove stale branches (safe now that default_branch is correct)
|
||||
await this.runGit([
|
||||
'push', 'target',
|
||||
'+refs/heads/*:refs/heads/*',
|
||||
'+refs/tags/*:refs/tags/*',
|
||||
'--prune',
|
||||
], mirrorDir);
|
||||
}
|
||||
|
||||
// Sync project metadata (description, visibility, topics, default_branch, avatar)
|
||||
await this.syncProjectMetadata(config, sourceConn, targetConn, project.fullPath, targetFullPath);
|
||||
@@ -470,7 +522,7 @@ export class SyncManager {
|
||||
for (const segment of groupSegments) {
|
||||
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
||||
try {
|
||||
const group = await client.getGroupByPath(currentPath);
|
||||
const group = await client.getGroup(currentPath);
|
||||
parentId = group.id;
|
||||
} catch {
|
||||
// Group doesn't exist — create it
|
||||
@@ -481,7 +533,7 @@ export class SyncManager {
|
||||
} catch (createErr: any) {
|
||||
// 409 = already exists (race condition), try fetching again
|
||||
if (String(createErr).includes('409') || String(createErr).includes('already')) {
|
||||
const group = await client.getGroupByPath(currentPath);
|
||||
const group = await client.getGroup(currentPath);
|
||||
parentId = group.id;
|
||||
} else {
|
||||
throw createErr;
|
||||
@@ -506,7 +558,7 @@ export class SyncManager {
|
||||
|
||||
try {
|
||||
// Check if project exists by path
|
||||
await client.getGroupByPath(projectPath);
|
||||
await client.getGroup(projectPath);
|
||||
// If this succeeds, it's actually a group, not a project... unlikely but handle
|
||||
} catch {
|
||||
// Project doesn't exist as a group path; try creating it
|
||||
@@ -990,13 +1042,92 @@ export class SyncManager {
|
||||
if (data[0] === 0x89 && data[1] === 0x50) return 'image/png';
|
||||
if (data[0] === 0xFF && data[1] === 0xD8) return 'image/jpeg';
|
||||
if (data[0] === 0x47 && data[1] === 0x49) return 'image/gif';
|
||||
// SVG: text-based XML, no magic bytes — check content
|
||||
const textStart = new TextDecoder().decode(data.slice(0, 200));
|
||||
if (textStart.includes('<svg') || textStart.includes('<?xml')) return 'image/svg+xml';
|
||||
// Fallback: check URL extension
|
||||
if (url.includes('.png')) return 'image/png';
|
||||
if (url.includes('.jpg') || url.includes('.jpeg')) return 'image/jpeg';
|
||||
if (url.includes('.gif')) return 'image/gif';
|
||||
if (url.includes('.svg')) return 'image/svg+xml';
|
||||
return 'image/png'; // default
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-push: ensure target's default_branch matches source so --prune won't delete it.
|
||||
*/
|
||||
private async syncDefaultBranchBeforePush(
|
||||
sourceConn: interfaces.data.IProviderConnection,
|
||||
targetConn: interfaces.data.IProviderConnection,
|
||||
sourceFullPath: string,
|
||||
targetFullPath: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const sourceProject = await this.fetchProjectRaw(sourceConn, sourceFullPath);
|
||||
if (!sourceProject) return;
|
||||
const targetProject = await this.fetchProjectRaw(targetConn, targetFullPath);
|
||||
if (!targetProject) return;
|
||||
|
||||
const sourceBranch = sourceProject.default_branch || 'main';
|
||||
const targetBranch = targetProject.default_branch || 'main';
|
||||
|
||||
if (sourceBranch !== targetBranch) {
|
||||
logger.syncLog('info', `Updating default branch for ${targetFullPath}: ${targetBranch} -> ${sourceBranch}`, 'api');
|
||||
if (targetConn.providerType === 'gitlab') {
|
||||
await this.rawApiCall(targetConn, 'PUT', `/api/v4/projects/${targetProject.id}`, {
|
||||
default_branch: sourceBranch,
|
||||
});
|
||||
} else {
|
||||
const segments = targetFullPath.split('/');
|
||||
const repo = segments.pop()!;
|
||||
const owner = segments[0] || '';
|
||||
await this.rawApiCall(targetConn, 'PATCH', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, {
|
||||
default_branch: sourceBranch,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
logger.syncLog('warn', `Pre-push default_branch sync failed for ${targetFullPath}: ${errMsg}`, 'api');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unprotect branches on the target that no longer exist in the source,
|
||||
* so that git push --prune can delete them.
|
||||
*/
|
||||
private async unprotectStaleBranches(
|
||||
targetConn: interfaces.data.IProviderConnection,
|
||||
targetFullPath: string,
|
||||
mirrorDir: string,
|
||||
): Promise<void> {
|
||||
if (targetConn.providerType !== 'gitlab') return;
|
||||
try {
|
||||
const targetProject = await this.fetchProjectRaw(targetConn, targetFullPath);
|
||||
if (!targetProject) return;
|
||||
|
||||
const client = new plugins.gitlabClient.GitLabClient(targetConn.baseUrl, targetConn.token);
|
||||
const protectedBranches = await client.requestGetProtectedBranches(targetProject.id);
|
||||
if (protectedBranches.length === 0) return;
|
||||
|
||||
// Get list of branches in the local mirror (= source branches)
|
||||
const localBranchOutput = await this.runGit(['branch', '--list'], mirrorDir);
|
||||
const localBranches = new Set(
|
||||
localBranchOutput.split('\n').map(b => b.trim().replace(/^\* /, '')).filter(Boolean),
|
||||
);
|
||||
|
||||
for (const pb of protectedBranches) {
|
||||
if (!localBranches.has(pb.name)) {
|
||||
logger.syncLog('info', `Unprotecting stale branch "${pb.name}" on ${targetFullPath}`, 'api');
|
||||
await client.requestUnprotectBranch(targetProject.id, pb.name);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
logger.syncLog('warn', `Failed to unprotect stale branches for ${targetFullPath}: ${errMsg}`, 'api');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync project metadata (description, visibility, topics, default_branch, avatar)
|
||||
* from source to target after the git push.
|
||||
@@ -1044,6 +1175,34 @@ export class SyncManager {
|
||||
// Sync avatar
|
||||
if (sourceMeta.avatarUrl) {
|
||||
await this.syncProjectAvatar(sourceConn, targetConn, sourceFullPath, targetFullPath, sourceMeta.avatarUrl, targetProject);
|
||||
} else if (config.useGroupAvatarsForProjects) {
|
||||
// Project has no avatar — inherit from parent group
|
||||
const groupPath = sourceFullPath.substring(0, sourceFullPath.lastIndexOf('/'));
|
||||
let groupAvatarApplied = false;
|
||||
if (groupPath) {
|
||||
try {
|
||||
const sourceGroup = await this.fetchGroupRaw(sourceConn, groupPath);
|
||||
if (sourceGroup) {
|
||||
const groupMeta = this.extractGroupMeta(sourceConn, sourceGroup);
|
||||
if (groupMeta.avatarUrl) {
|
||||
await this.syncProjectAvatar(sourceConn, targetConn, sourceFullPath, targetFullPath, groupMeta.avatarUrl, targetProject);
|
||||
groupAvatarApplied = true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
logger.syncLog('warn', `Group avatar sync failed for ${targetFullPath}: ${errMsg}`, 'api');
|
||||
}
|
||||
}
|
||||
// If group also has no avatar, remove target avatar
|
||||
if (!groupAvatarApplied && targetMeta.avatarUrl) {
|
||||
await this.removeProjectAvatar(targetConn, targetFullPath, targetProject);
|
||||
}
|
||||
} else {
|
||||
// No source avatar, no group fallback — remove target avatar if present
|
||||
if (targetMeta.avatarUrl) {
|
||||
await this.removeProjectAvatar(targetConn, targetFullPath, targetProject);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -1089,6 +1248,8 @@ export class SyncManager {
|
||||
// Sync avatar
|
||||
if (sourceMeta.avatarUrl) {
|
||||
await this.syncGroupAvatar(sourceConn, targetConn, sourceGroupPath, targetGroupPath, sourceMeta.avatarUrl, targetGroup);
|
||||
} else if (targetMeta.avatarUrl) {
|
||||
await this.removeGroupAvatar(targetConn, targetGroupPath, targetGroup);
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -1170,24 +1331,41 @@ export class SyncManager {
|
||||
meta: { description: string; visibility: string; topics: string[]; defaultBranch: string },
|
||||
): Promise<void> {
|
||||
if (conn.providerType === 'gitlab') {
|
||||
// Update description, visibility, topics (always safe)
|
||||
await this.rawApiCall(conn, 'PUT', `/api/v4/projects/${rawProject.id}`, {
|
||||
description: meta.description,
|
||||
visibility: this.normalizeVisibility(meta.visibility),
|
||||
topics: meta.topics,
|
||||
default_branch: meta.defaultBranch,
|
||||
});
|
||||
// Update default_branch separately — may fail if the branch doesn't exist in git
|
||||
try {
|
||||
await this.rawApiCall(conn, 'PUT', `/api/v4/projects/${rawProject.id}`, {
|
||||
default_branch: meta.defaultBranch,
|
||||
});
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
logger.syncLog('warn', `Could not set default_branch to "${meta.defaultBranch}" for ${fullPath}: ${errMsg}`, 'api');
|
||||
}
|
||||
} else {
|
||||
const segments = fullPath.split('/');
|
||||
const repo = segments.pop()!;
|
||||
const owner = segments[0] || '';
|
||||
const encodedOwner = encodeURIComponent(owner);
|
||||
const encodedRepo = encodeURIComponent(repo);
|
||||
// Update description, visibility, default_branch
|
||||
// Update description, visibility
|
||||
await this.rawApiCall(conn, 'PATCH', `/api/v1/repos/${encodedOwner}/${encodedRepo}`, {
|
||||
description: meta.description,
|
||||
private: this.normalizeVisibility(meta.visibility) === 'private',
|
||||
default_branch: meta.defaultBranch,
|
||||
});
|
||||
// Update default_branch separately — may fail if the branch doesn't exist in git
|
||||
try {
|
||||
await this.rawApiCall(conn, 'PATCH', `/api/v1/repos/${encodedOwner}/${encodedRepo}`, {
|
||||
default_branch: meta.defaultBranch,
|
||||
});
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
logger.syncLog('warn', `Could not set default_branch to "${meta.defaultBranch}" for ${fullPath}: ${errMsg}`, 'api');
|
||||
}
|
||||
// Topics are a separate endpoint in Gitea
|
||||
await this.rawApiCall(conn, 'PUT', `/api/v1/repos/${encodedOwner}/${encodedRepo}/topics`, {
|
||||
topics: meta.topics,
|
||||
@@ -1202,10 +1380,22 @@ export class SyncManager {
|
||||
meta: { description: string; visibility: string },
|
||||
): Promise<void> {
|
||||
if (conn.providerType === 'gitlab') {
|
||||
await this.rawApiCall(conn, 'PUT', `/api/v4/groups/${rawGroup.id}`, {
|
||||
description: meta.description,
|
||||
visibility: this.normalizeVisibility(meta.visibility),
|
||||
});
|
||||
try {
|
||||
await this.rawApiCall(conn, 'PUT', `/api/v4/groups/${rawGroup.id}`, {
|
||||
description: meta.description,
|
||||
visibility: this.normalizeVisibility(meta.visibility),
|
||||
});
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
if (errMsg.includes('visibility_level') || errMsg.includes('visibility')) {
|
||||
logger.syncLog('warn', `Cannot sync visibility for group ${groupPath} (contains projects with higher visibility), syncing description only`, 'api');
|
||||
await this.rawApiCall(conn, 'PUT', `/api/v4/groups/${rawGroup.id}`, {
|
||||
description: meta.description,
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const orgName = groupPath.split('/')[0] || groupPath;
|
||||
await this.rawApiCall(conn, 'PATCH', `/api/v1/orgs/${encodeURIComponent(orgName)}`, {
|
||||
@@ -1220,25 +1410,55 @@ export class SyncManager {
|
||||
private async syncProjectAvatar(
|
||||
sourceConn: interfaces.data.IProviderConnection,
|
||||
targetConn: interfaces.data.IProviderConnection,
|
||||
sourceFullPath: string,
|
||||
_sourceFullPath: string,
|
||||
targetFullPath: string,
|
||||
sourceAvatarUrl: string,
|
||||
targetRawProject: any,
|
||||
): Promise<void> {
|
||||
// Resolve relative avatar URLs
|
||||
const resolvedUrl = sourceAvatarUrl.startsWith('http')
|
||||
const resolvedSourceUrl = sourceAvatarUrl.startsWith('http')
|
||||
? sourceAvatarUrl
|
||||
: `${sourceConn.baseUrl.replace(/\/+$/, '')}${sourceAvatarUrl}`;
|
||||
|
||||
const avatarData = await this.rawBinaryFetch(sourceConn, resolvedUrl);
|
||||
if (!avatarData || avatarData.length === 0) return;
|
||||
const sourceAvatarData = await this.rawBinaryFetch(sourceConn, resolvedSourceUrl);
|
||||
if (!sourceAvatarData || sourceAvatarData.length === 0) return;
|
||||
|
||||
// Skip SVG avatars — not supported by GitLab project endpoints
|
||||
const mimeType = this.guessAvatarMimeType(sourceAvatarData, resolvedSourceUrl);
|
||||
if (mimeType === 'image/svg+xml') {
|
||||
logger.syncLog('warn', `Skipping SVG avatar for ${targetFullPath} (not supported by target)`, 'api');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check in-memory cache: skip if source hasn't changed since last upload
|
||||
const sourceHash = await this.hashBytes(sourceAvatarData);
|
||||
const cacheKey = `project:${targetFullPath}`;
|
||||
if (this.avatarUploadCache.get(cacheKey) === sourceHash) {
|
||||
return; // Source avatar unchanged since last upload
|
||||
}
|
||||
|
||||
// Compare with target's current avatar to avoid unnecessary uploads
|
||||
const targetMeta = this.extractProjectMeta(targetConn, targetRawProject);
|
||||
if (targetMeta.avatarUrl) {
|
||||
try {
|
||||
const resolvedTargetUrl = targetMeta.avatarUrl.startsWith('http')
|
||||
? targetMeta.avatarUrl
|
||||
: `${targetConn.baseUrl.replace(/\/+$/, '')}${targetMeta.avatarUrl}`;
|
||||
const targetAvatarData = await this.rawBinaryFetch(targetConn, resolvedTargetUrl);
|
||||
if (targetAvatarData && this.binaryEqual(sourceAvatarData, targetAvatarData)) {
|
||||
this.avatarUploadCache.set(cacheKey, sourceHash);
|
||||
return; // Avatars are identical — skip upload
|
||||
}
|
||||
} catch {
|
||||
// Failed to fetch target avatar — proceed with upload as safe fallback
|
||||
}
|
||||
}
|
||||
|
||||
logger.syncLog('info', `Syncing avatar for ${targetFullPath}...`, 'api');
|
||||
|
||||
if (targetConn.providerType === 'gitlab') {
|
||||
// GitLab: multipart upload
|
||||
const mimeType = this.guessAvatarMimeType(avatarData, resolvedUrl);
|
||||
const blob = new Blob([avatarData.buffer as ArrayBuffer], { type: mimeType });
|
||||
const blob = new Blob([sourceAvatarData.buffer as ArrayBuffer], { type: mimeType });
|
||||
const ext = mimeType.split('/')[1] || 'png';
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', blob, `avatar.${ext}`);
|
||||
@@ -1252,13 +1472,34 @@ export class SyncManager {
|
||||
const segments = targetFullPath.split('/');
|
||||
const repo = segments.pop()!;
|
||||
const owner = segments[0] || '';
|
||||
const base64Image = this.uint8ArrayToBase64(avatarData);
|
||||
const base64Image = this.uint8ArrayToBase64(sourceAvatarData);
|
||||
await this.rawApiCall(
|
||||
targetConn, 'POST',
|
||||
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/avatar`,
|
||||
{ image: base64Image },
|
||||
);
|
||||
}
|
||||
|
||||
this.avatarUploadCache.set(cacheKey, sourceHash);
|
||||
}
|
||||
|
||||
private async removeProjectAvatar(
|
||||
targetConn: interfaces.data.IProviderConnection,
|
||||
targetFullPath: string,
|
||||
targetRawProject: any,
|
||||
): Promise<void> {
|
||||
logger.syncLog('info', `Removing avatar from ${targetFullPath}`, 'api');
|
||||
if (targetConn.providerType === 'gitlab') {
|
||||
await this.rawApiCall(targetConn, 'PUT', `/api/v4/projects/${targetRawProject.id}`, {
|
||||
avatar: '',
|
||||
});
|
||||
} else {
|
||||
const segments = targetFullPath.split('/');
|
||||
const repo = segments.pop()!;
|
||||
const owner = segments[0] || '';
|
||||
await this.rawApiCall(targetConn, 'DELETE',
|
||||
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/avatar`);
|
||||
}
|
||||
}
|
||||
|
||||
private async syncGroupAvatar(
|
||||
@@ -1269,18 +1510,48 @@ export class SyncManager {
|
||||
sourceAvatarUrl: string,
|
||||
targetRawGroup: any,
|
||||
): Promise<void> {
|
||||
const resolvedUrl = sourceAvatarUrl.startsWith('http')
|
||||
const resolvedSourceUrl = sourceAvatarUrl.startsWith('http')
|
||||
? sourceAvatarUrl
|
||||
: `${sourceConn.baseUrl.replace(/\/+$/, '')}${sourceAvatarUrl}`;
|
||||
|
||||
const avatarData = await this.rawBinaryFetch(sourceConn, resolvedUrl);
|
||||
if (!avatarData || avatarData.length === 0) return;
|
||||
const sourceAvatarData = await this.rawBinaryFetch(sourceConn, resolvedSourceUrl);
|
||||
if (!sourceAvatarData || sourceAvatarData.length === 0) return;
|
||||
|
||||
// Skip SVG avatars — not supported by GitLab project endpoints
|
||||
const mimeType = this.guessAvatarMimeType(sourceAvatarData, resolvedSourceUrl);
|
||||
if (mimeType === 'image/svg+xml') {
|
||||
logger.syncLog('warn', `Skipping SVG avatar for group ${targetGroupPath} (not supported by target)`, 'api');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check in-memory cache: skip if source hasn't changed since last upload
|
||||
const sourceHash = await this.hashBytes(sourceAvatarData);
|
||||
const cacheKey = `group:${targetGroupPath}`;
|
||||
if (this.avatarUploadCache.get(cacheKey) === sourceHash) {
|
||||
return; // Source avatar unchanged since last upload
|
||||
}
|
||||
|
||||
// Compare with target's current avatar to avoid unnecessary uploads
|
||||
const targetMeta = this.extractGroupMeta(targetConn, targetRawGroup);
|
||||
if (targetMeta.avatarUrl) {
|
||||
try {
|
||||
const resolvedTargetUrl = targetMeta.avatarUrl.startsWith('http')
|
||||
? targetMeta.avatarUrl
|
||||
: `${targetConn.baseUrl.replace(/\/+$/, '')}${targetMeta.avatarUrl}`;
|
||||
const targetAvatarData = await this.rawBinaryFetch(targetConn, resolvedTargetUrl);
|
||||
if (targetAvatarData && this.binaryEqual(sourceAvatarData, targetAvatarData)) {
|
||||
this.avatarUploadCache.set(cacheKey, sourceHash);
|
||||
return; // Avatars are identical — skip upload
|
||||
}
|
||||
} catch {
|
||||
// Failed to fetch target avatar — proceed with upload as safe fallback
|
||||
}
|
||||
}
|
||||
|
||||
logger.syncLog('info', `Syncing avatar for group ${targetGroupPath}...`, 'api');
|
||||
|
||||
if (targetConn.providerType === 'gitlab') {
|
||||
const mimeType = this.guessAvatarMimeType(avatarData, resolvedUrl);
|
||||
const blob = new Blob([avatarData.buffer as ArrayBuffer], { type: mimeType });
|
||||
const blob = new Blob([sourceAvatarData.buffer as ArrayBuffer], { type: mimeType });
|
||||
const ext = mimeType.split('/')[1] || 'png';
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', blob, `avatar.${ext}`);
|
||||
@@ -1291,13 +1562,45 @@ export class SyncManager {
|
||||
);
|
||||
} else {
|
||||
const orgName = targetGroupPath.split('/')[0] || targetGroupPath;
|
||||
const base64Image = this.uint8ArrayToBase64(avatarData);
|
||||
const base64Image = this.uint8ArrayToBase64(sourceAvatarData);
|
||||
await this.rawApiCall(
|
||||
targetConn, 'POST',
|
||||
`/api/v1/orgs/${encodeURIComponent(orgName)}/avatar`,
|
||||
{ image: base64Image },
|
||||
);
|
||||
}
|
||||
|
||||
this.avatarUploadCache.set(cacheKey, sourceHash);
|
||||
}
|
||||
|
||||
private async removeGroupAvatar(
|
||||
targetConn: interfaces.data.IProviderConnection,
|
||||
targetGroupPath: string,
|
||||
targetRawGroup: any,
|
||||
): Promise<void> {
|
||||
logger.syncLog('info', `Removing avatar from group ${targetGroupPath}`, 'api');
|
||||
if (targetConn.providerType === 'gitlab') {
|
||||
await this.rawApiCall(targetConn, 'PUT', `/api/v4/groups/${targetRawGroup.id}`, {
|
||||
avatar: '',
|
||||
});
|
||||
} else {
|
||||
const orgName = targetGroupPath.split('/')[0] || targetGroupPath;
|
||||
await this.rawApiCall(targetConn, 'DELETE',
|
||||
`/api/v1/orgs/${encodeURIComponent(orgName)}/avatar`);
|
||||
}
|
||||
}
|
||||
|
||||
private binaryEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async hashBytes(data: Uint8Array): Promise<string> {
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data.buffer as ArrayBuffer);
|
||||
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
private uint8ArrayToBase64(bytes: Uint8Array): string {
|
||||
@@ -1363,14 +1666,14 @@ export class SyncManager {
|
||||
// Walk the basePath to find the parent group, then create "obsolete" subgroup
|
||||
let parentId: number | undefined;
|
||||
if (basePath) {
|
||||
const parentGroup = await client.getGroupByPath(basePath);
|
||||
const parentGroup = await client.getGroup(basePath);
|
||||
parentId = parentGroup.id;
|
||||
}
|
||||
|
||||
// Try to get existing obsolete group
|
||||
const obsoletePath = basePath ? `${basePath}/obsolete` : 'obsolete';
|
||||
try {
|
||||
const group = await client.getGroupByPath(obsoletePath);
|
||||
const group = await client.getGroup(obsoletePath);
|
||||
return { type: 'gitlab', groupId: group.id };
|
||||
} catch {
|
||||
// Doesn't exist — create it
|
||||
@@ -1381,7 +1684,7 @@ export class SyncManager {
|
||||
return { type: 'gitlab', groupId: newGroup.id };
|
||||
} catch (createErr: any) {
|
||||
if (String(createErr).includes('409') || String(createErr).includes('already')) {
|
||||
const group = await client.getGroupByPath(obsoletePath);
|
||||
const group = await client.getGroup(obsoletePath);
|
||||
return { type: 'gitlab', groupId: group.id };
|
||||
}
|
||||
throw createErr;
|
||||
@@ -1519,6 +1822,133 @@ export class SyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all branch and tag SHAs from a repo via provider API.
|
||||
* Returns null on any error (safe fallback to git-based comparison).
|
||||
*/
|
||||
private async listRefsViaProvider(
|
||||
provider: BaseProvider,
|
||||
fullPath: string,
|
||||
): Promise<{ branches: Map<string, string>; tags: Map<string, string> } | null> {
|
||||
try {
|
||||
const [branches, tags] = await Promise.all([
|
||||
provider.getBranches(fullPath),
|
||||
provider.getTags(fullPath),
|
||||
]);
|
||||
return {
|
||||
branches: new Map(branches.map((b) => [b.name, b.commitSha])),
|
||||
tags: new Map(tags.map((t) => [t.name, t.commitSha])),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare refs between source and target via provider API (no git clone needed).
|
||||
* Returns true (match), false (differ), or null (can't determine — fall through to git).
|
||||
*/
|
||||
private async refsMatchViaApi(
|
||||
sourceProvider: BaseProvider,
|
||||
targetProvider: BaseProvider,
|
||||
sourceFullPath: string,
|
||||
targetFullPath: string,
|
||||
): Promise<boolean | null> {
|
||||
const [sourceRefs, targetRefs] = await Promise.all([
|
||||
this.listRefsViaProvider(sourceProvider, sourceFullPath),
|
||||
this.listRefsViaProvider(targetProvider, targetFullPath),
|
||||
]);
|
||||
if (!sourceRefs || !targetRefs) return null;
|
||||
|
||||
// Compare branches
|
||||
if (sourceRefs.branches.size !== targetRefs.branches.size) return false;
|
||||
for (const [name, sha] of sourceRefs.branches) {
|
||||
if (targetRefs.branches.get(name) !== sha) return false;
|
||||
}
|
||||
|
||||
// Compare tags
|
||||
if (sourceRefs.tags.size !== targetRefs.tags.size) return false;
|
||||
for (const [name, sha] of sourceRefs.tags) {
|
||||
if (targetRefs.tags.get(name) !== sha) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare local refs (source) with target remote refs.
|
||||
* Returns true when all branches and tags are identical — no push needed.
|
||||
*/
|
||||
private async refsMatch(mirrorDir: string): Promise<boolean> {
|
||||
try {
|
||||
// Local branches (source)
|
||||
const localHeadsRaw = await this.runGit(
|
||||
['for-each-ref', '--format=%(refname:strip=2) %(objectname)', 'refs/heads/'], mirrorDir,
|
||||
);
|
||||
// Target branches (fetched by checkUnrelatedHistory)
|
||||
const targetHeadsRaw = await this.runGit(
|
||||
['for-each-ref', '--format=%(refname:strip=3) %(objectname)', 'refs/remotes/target/'], mirrorDir,
|
||||
);
|
||||
|
||||
// Local tags
|
||||
const localTagsRaw = await this.runGit(
|
||||
['for-each-ref', '--format=%(refname:strip=2) %(objectname)', 'refs/tags/'], mirrorDir,
|
||||
);
|
||||
// Target tags via ls-remote (avoids shared refs/tags/ namespace ambiguity in bare repos)
|
||||
const targetTagsRaw = await this.runGit(['ls-remote', '--tags', 'target'], mirrorDir);
|
||||
|
||||
const parseRefLines = (raw: string): Map<string, string> => {
|
||||
const map = new Map<string, string>();
|
||||
for (const line of raw.trim().split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
map.set(parts[0], parts[1]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
const parseLsRemoteTags = (raw: string): Map<string, string> => {
|
||||
const map = new Map<string, string>();
|
||||
for (const line of raw.trim().split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
// Skip ^{} dereference lines
|
||||
if (line.includes('^{}')) continue;
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
// parts[0] = sha, parts[1] = refs/tags/name
|
||||
const tagName = parts[1].replace('refs/tags/', '');
|
||||
map.set(tagName, parts[0]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
const localHeads = parseRefLines(localHeadsRaw);
|
||||
const targetHeads = parseRefLines(targetHeadsRaw);
|
||||
const localTags = parseRefLines(localTagsRaw);
|
||||
const targetTags = parseLsRemoteTags(targetTagsRaw);
|
||||
|
||||
// Compare branches
|
||||
if (localHeads.size !== targetHeads.size) return false;
|
||||
for (const [name, sha] of localHeads) {
|
||||
if (targetHeads.get(name) !== sha) return false;
|
||||
}
|
||||
|
||||
// Compare tags
|
||||
if (localTags.size !== targetTags.size) return false;
|
||||
for (const [name, sha] of localTags) {
|
||||
if (targetTags.get(name) !== sha) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
// On any error, fall back to pushing (safe default)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async runGit(args: string[], cwd?: string): Promise<string> {
|
||||
const cmd = new Deno.Command('git', {
|
||||
args,
|
||||
|
||||
@@ -21,6 +21,7 @@ export class OpsServer {
|
||||
public actionsHandler!: handlers.ActionsHandler;
|
||||
public actionLogHandler!: handlers.ActionLogHandler;
|
||||
public syncHandler!: handlers.SyncHandler;
|
||||
public managedSecretsHandler!: handlers.ManagedSecretsHandler;
|
||||
|
||||
constructor(gitopsAppRef: GitopsApp) {
|
||||
this.gitopsAppRef = gitopsAppRef;
|
||||
@@ -65,6 +66,7 @@ export class OpsServer {
|
||||
this.actionsHandler = new handlers.ActionsHandler(this);
|
||||
this.actionLogHandler = new handlers.ActionLogHandler(this);
|
||||
this.syncHandler = new handlers.SyncHandler(this);
|
||||
this.managedSecretsHandler = new handlers.ManagedSecretsHandler(this);
|
||||
|
||||
logger.success('OpsServer TypedRequest handlers initialized');
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ export { WebhookHandler } from './webhook.handler.ts';
|
||||
export { ActionsHandler } from './actions.handler.ts';
|
||||
export { ActionLogHandler } from './actionlog.handler.ts';
|
||||
export { SyncHandler } from './sync.handler.ts';
|
||||
export { ManagedSecretsHandler } from './managedsecrets.handler.ts';
|
||||
|
||||
158
ts/opsserver/handlers/managedsecrets.handler.ts
Normal file
158
ts/opsserver/handlers/managedsecrets.handler.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
|
||||
export class ManagedSecretsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private get actionLog() {
|
||||
return this.opsServerRef.gitopsAppRef.actionLog;
|
||||
}
|
||||
|
||||
private get manager() {
|
||||
return this.opsServerRef.gitopsAppRef.managedSecretsManager;
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// List all managed secrets
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetManagedSecrets>(
|
||||
'getManagedSecrets',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const managedSecrets = await this.manager.getAll();
|
||||
return { managedSecrets };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get single managed secret
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetManagedSecret>(
|
||||
'getManagedSecret',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const managedSecret = await this.manager.getById(dataArg.managedSecretId);
|
||||
if (!managedSecret) throw new Error(`Managed secret not found: ${dataArg.managedSecretId}`);
|
||||
return { managedSecret };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create managed secret
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateManagedSecret>(
|
||||
'createManagedSecret',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const result = await this.manager.create(
|
||||
dataArg.key,
|
||||
dataArg.value,
|
||||
dataArg.description,
|
||||
dataArg.targets,
|
||||
);
|
||||
this.actionLog.append({
|
||||
actionType: 'create',
|
||||
entityType: 'managed-secret',
|
||||
entityId: result.managedSecret.id,
|
||||
entityName: `GITOPS_${dataArg.key}`,
|
||||
details: `Created managed secret "${dataArg.key}" with ${dataArg.targets.length} target(s)`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update managed secret
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateManagedSecret>(
|
||||
'updateManagedSecret',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const result = await this.manager.update(dataArg.managedSecretId, {
|
||||
value: dataArg.value,
|
||||
description: dataArg.description,
|
||||
targets: dataArg.targets,
|
||||
});
|
||||
this.actionLog.append({
|
||||
actionType: 'update',
|
||||
entityType: 'managed-secret',
|
||||
entityId: dataArg.managedSecretId,
|
||||
entityName: `GITOPS_${result.managedSecret.key}`,
|
||||
details: `Updated managed secret "${result.managedSecret.key}"`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete managed secret
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteManagedSecret>(
|
||||
'deleteManagedSecret',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const secret = await this.manager.getById(dataArg.managedSecretId);
|
||||
const result = await this.manager.delete(dataArg.managedSecretId);
|
||||
this.actionLog.append({
|
||||
actionType: 'delete',
|
||||
entityType: 'managed-secret',
|
||||
entityId: dataArg.managedSecretId,
|
||||
entityName: secret ? `GITOPS_${secret.key}` : dataArg.managedSecretId,
|
||||
details: `Deleted managed secret${secret ? ` "${secret.key}"` : ''}`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Push single managed secret
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushManagedSecret>(
|
||||
'pushManagedSecret',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const result = await this.manager.pushOne(dataArg.managedSecretId);
|
||||
this.actionLog.append({
|
||||
actionType: 'push',
|
||||
entityType: 'managed-secret',
|
||||
entityId: dataArg.managedSecretId,
|
||||
entityName: `GITOPS_${result.managedSecret.key}`,
|
||||
details: `Pushed managed secret "${result.managedSecret.key}" to ${result.pushResults.length} target(s)`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Push all managed secrets
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushAllManagedSecrets>(
|
||||
'pushAllManagedSecrets',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const results = await this.manager.pushAll();
|
||||
this.actionLog.append({
|
||||
actionType: 'push',
|
||||
entityType: 'managed-secret',
|
||||
entityId: 'all',
|
||||
entityName: 'All managed secrets',
|
||||
details: `Pushed ${results.length} managed secret(s) to their targets`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return { results };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,27 @@ import * as plugins from '../../plugins.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
import type { BaseProvider } from '../../providers/classes.baseprovider.ts';
|
||||
|
||||
const TIME_RANGE_MS: Record<string, number> = {
|
||||
'1h': 60 * 60 * 1000,
|
||||
'6h': 6 * 60 * 60 * 1000,
|
||||
'1d': 24 * 60 * 60 * 1000,
|
||||
'3d': 3 * 24 * 60 * 60 * 1000,
|
||||
'7d': 7 * 24 * 60 * 60 * 1000,
|
||||
'30d': 30 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
const STATUS_PRIORITY: Record<string, number> = {
|
||||
running: 0,
|
||||
pending: 1,
|
||||
waiting: 2,
|
||||
manual: 3,
|
||||
failed: 4,
|
||||
canceled: 5,
|
||||
success: 6,
|
||||
skipped: 7,
|
||||
};
|
||||
|
||||
export class PipelinesHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -16,7 +37,7 @@ export class PipelinesHandler {
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get pipelines
|
||||
// Get pipelines — supports view modes
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPipelines>(
|
||||
'getPipelines',
|
||||
@@ -25,10 +46,32 @@ export class PipelinesHandler {
|
||||
const provider = this.opsServerRef.gitopsAppRef.connectionManager.getProvider(
|
||||
dataArg.connectionId,
|
||||
);
|
||||
const pipelines = await provider.getPipelines(dataArg.projectId, {
|
||||
page: dataArg.page,
|
||||
});
|
||||
return { pipelines };
|
||||
|
||||
const viewMode = dataArg.viewMode || 'project';
|
||||
const timeRange = dataArg.timeRange || '1d';
|
||||
const sortBy = dataArg.sortBy || 'created';
|
||||
|
||||
let pipelines: interfaces.data.IPipeline[];
|
||||
|
||||
if (viewMode === 'project') {
|
||||
if (!dataArg.projectId) return { pipelines: [] };
|
||||
pipelines = await provider.getPipelines(dataArg.projectId, {
|
||||
page: dataArg.page,
|
||||
status: dataArg.status,
|
||||
});
|
||||
pipelines = this.filterByTimeRange(pipelines, timeRange);
|
||||
} else if (viewMode === 'current') {
|
||||
pipelines = await this.fetchCurrentPipelines(provider, timeRange);
|
||||
} else if (viewMode === 'group') {
|
||||
if (!dataArg.groupId) return { pipelines: [] };
|
||||
pipelines = await this.fetchGroupPipelines(provider, dataArg.groupId, timeRange);
|
||||
} else if (viewMode === 'error') {
|
||||
pipelines = await this.fetchErrorPipelines(provider, timeRange);
|
||||
} else {
|
||||
pipelines = [];
|
||||
}
|
||||
|
||||
return { pipelines: this.sortPipelines(pipelines, sortBy).slice(0, 200) };
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -94,4 +137,145 @@ export class PipelinesHandler {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View mode helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Current mode: running/pending always shown, plus recent pipelines within timeRange.
|
||||
* Fetches a generous page of recent pipelines per project (100), then splits into
|
||||
* active (running/pending/waiting) and the rest. Active are always shown; the rest
|
||||
* is filtered by timeRange.
|
||||
*/
|
||||
private async fetchCurrentPipelines(
|
||||
provider: BaseProvider,
|
||||
timeRange: string,
|
||||
): Promise<interfaces.data.IPipeline[]> {
|
||||
const projects = await provider.getProjects();
|
||||
const allPipelines = await this.fetchAggregatedPipelines(provider, projects, { perPage: 100 });
|
||||
|
||||
// Running/pending pipelines are always shown regardless of time
|
||||
const active = allPipelines.filter(
|
||||
(p) => p.status === 'running' || p.status === 'pending' || p.status === 'waiting',
|
||||
);
|
||||
const rest = allPipelines.filter(
|
||||
(p) => p.status !== 'running' && p.status !== 'pending' && p.status !== 'waiting',
|
||||
);
|
||||
const filteredRest = this.filterByTimeRange(rest, timeRange);
|
||||
|
||||
// Deduplicate (active pipelines may also appear in filtered rest)
|
||||
const activeIds = new Set(active.map((p) => `${p.connectionId}:${p.projectId}:${p.id}`));
|
||||
const uniqueRest = filteredRest.filter(
|
||||
(p) => !activeIds.has(`${p.connectionId}:${p.projectId}:${p.id}`),
|
||||
);
|
||||
|
||||
return [...active, ...uniqueRest];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group mode: pipelines from all projects in a group
|
||||
*/
|
||||
private async fetchGroupPipelines(
|
||||
provider: BaseProvider,
|
||||
groupId: string,
|
||||
timeRange: string,
|
||||
): Promise<interfaces.data.IPipeline[]> {
|
||||
const projects = await provider.getGroupProjects(groupId);
|
||||
const allPipelines = await this.fetchAggregatedPipelines(provider, projects);
|
||||
return this.filterByTimeRange(allPipelines, timeRange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error mode: only failed pipelines
|
||||
*/
|
||||
private async fetchErrorPipelines(
|
||||
provider: BaseProvider,
|
||||
timeRange: string,
|
||||
): Promise<interfaces.data.IPipeline[]> {
|
||||
const projects = await provider.getProjects();
|
||||
const allPipelines = await this.fetchAggregatedPipelines(provider, projects, { status: 'failed' });
|
||||
return this.filterByTimeRange(allPipelines, timeRange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch pipelines from multiple projects in parallel (batched)
|
||||
*/
|
||||
private async fetchAggregatedPipelines(
|
||||
provider: BaseProvider,
|
||||
projects: interfaces.data.IProject[],
|
||||
opts?: { status?: string; perPage?: number },
|
||||
): Promise<interfaces.data.IPipeline[]> {
|
||||
const BATCH_SIZE = 10;
|
||||
const perPage = opts?.perPage || 50;
|
||||
const allPipelines: interfaces.data.IPipeline[] = [];
|
||||
|
||||
for (let i = 0; i < projects.length; i += BATCH_SIZE) {
|
||||
const batch = projects.slice(i, i + BATCH_SIZE);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (project) => {
|
||||
const pipelines = await provider.getPipelines(project.id, {
|
||||
perPage,
|
||||
status: opts?.status,
|
||||
});
|
||||
// Enrich with proper project name
|
||||
return pipelines.map((p) => ({
|
||||
...p,
|
||||
projectName: project.fullPath || project.name || p.projectId,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
for (let j = 0; j < results.length; j++) {
|
||||
const result = results[j];
|
||||
if (result.status === 'fulfilled') {
|
||||
allPipelines.push(...result.value);
|
||||
} else {
|
||||
const projectId = batch[j]?.id || 'unknown';
|
||||
console.warn(`[PipelinesHandler] Failed to fetch pipelines for project ${projectId}: ${result.reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allPipelines;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filtering and sorting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private filterByTimeRange(
|
||||
pipelines: interfaces.data.IPipeline[],
|
||||
timeRange: string,
|
||||
): interfaces.data.IPipeline[] {
|
||||
const cutoffMs = TIME_RANGE_MS[timeRange] || TIME_RANGE_MS['1d'];
|
||||
const cutoff = Date.now() - cutoffMs;
|
||||
return pipelines.filter((p) => {
|
||||
if (!p.createdAt) return false;
|
||||
return new Date(p.createdAt).getTime() >= cutoff;
|
||||
});
|
||||
}
|
||||
|
||||
private sortPipelines(
|
||||
pipelines: interfaces.data.IPipeline[],
|
||||
sortBy: string,
|
||||
): interfaces.data.IPipeline[] {
|
||||
const sorted = [...pipelines];
|
||||
switch (sortBy) {
|
||||
case 'duration':
|
||||
sorted.sort((a, b) => (b.duration || 0) - (a.duration || 0));
|
||||
break;
|
||||
case 'status':
|
||||
sorted.sort(
|
||||
(a, b) => (STATUS_PRIORITY[a.status] ?? 99) - (STATUS_PRIORITY[b.status] ?? 99),
|
||||
);
|
||||
break;
|
||||
case 'created':
|
||||
default:
|
||||
sorted.sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export class SyncHandler {
|
||||
try {
|
||||
const typedsocket = this.opsServerRef.server?.typedserver?.typedsocket;
|
||||
if (!typedsocket) return;
|
||||
typedsocket.findAllTargetConnectionsByTag('allClients').then((connections) => {
|
||||
typedsocket.findAllTargetConnectionsByTag('syncLogClient').then((connections) => {
|
||||
for (const conn of connections) {
|
||||
typedsocket
|
||||
.createTypedRequest<interfaces.requests.IReq_PushSyncLog>('pushSyncLog', conn)
|
||||
@@ -71,6 +71,7 @@ export class SyncHandler {
|
||||
enforceDelete: dataArg.enforceDelete,
|
||||
enforceGroupDelete: dataArg.enforceGroupDelete,
|
||||
addMirrorHint: dataArg.addMirrorHint,
|
||||
useGroupAvatarsForProjects: dataArg.useGroupAvatarsForProjects,
|
||||
});
|
||||
this.actionLog.append({
|
||||
actionType: 'create',
|
||||
@@ -98,6 +99,7 @@ export class SyncHandler {
|
||||
enforceDelete: dataArg.enforceDelete,
|
||||
enforceGroupDelete: dataArg.enforceGroupDelete,
|
||||
addMirrorHint: dataArg.addMirrorHint,
|
||||
useGroupAvatarsForProjects: dataArg.useGroupAvatarsForProjects,
|
||||
});
|
||||
this.actionLog.append({
|
||||
actionType: 'update',
|
||||
|
||||
@@ -31,7 +31,7 @@ export class WebhookHandler {
|
||||
try {
|
||||
const typedsocket = this.opsServerRef.server.typedserver.typedsocket;
|
||||
if (typedsocket) {
|
||||
const connections = await typedsocket.findAllTargetConnectionsByTag('allClients');
|
||||
const connections = await typedsocket.findAllTargetConnectionsByTag('syncLogClient');
|
||||
for (const conn of connections) {
|
||||
const req = typedsocket.createTypedRequest<interfaces.requests.IReq_WebhookNotification>(
|
||||
'webhookNotification',
|
||||
|
||||
@@ -4,7 +4,6 @@ export interface IGitopsPaths {
|
||||
gitopsHomeDir: string;
|
||||
defaultStoragePath: string;
|
||||
defaultTsmDbPath: string;
|
||||
syncMirrorsPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -16,6 +15,5 @@ export function resolvePaths(baseDir?: string): IGitopsPaths {
|
||||
gitopsHomeDir: home,
|
||||
defaultStoragePath: path.join(home, 'storage'),
|
||||
defaultTsmDbPath: path.join(home, 'tsmdb'),
|
||||
syncMirrorsPath: path.join(home, 'mirrors'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@ export interface IListOptions {
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
export interface IPipelineListOptions extends IListOptions {
|
||||
status?: string;
|
||||
ref?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract base class for Git provider implementations.
|
||||
* Subclasses implement Gitea API v1 or GitLab API v4.
|
||||
@@ -36,6 +42,9 @@ export abstract class BaseProvider {
|
||||
// Groups / Orgs
|
||||
abstract getGroups(opts?: IListOptions): Promise<interfaces.data.IGroup[]>;
|
||||
|
||||
// Group Projects
|
||||
abstract getGroupProjects(groupId: string, opts?: IListOptions): Promise<interfaces.data.IProject[]>;
|
||||
|
||||
// Secrets — project scope
|
||||
abstract getProjectSecrets(projectId: string): Promise<interfaces.data.ISecret[]>;
|
||||
abstract createProjectSecret(
|
||||
@@ -64,10 +73,14 @@ export abstract class BaseProvider {
|
||||
): Promise<interfaces.data.ISecret>;
|
||||
abstract deleteGroupSecret(groupId: string, key: string): Promise<void>;
|
||||
|
||||
// Branches / Tags
|
||||
abstract getBranches(projectFullPath: string, opts?: IListOptions): Promise<interfaces.data.IBranch[]>;
|
||||
abstract getTags(projectFullPath: string, opts?: IListOptions): Promise<interfaces.data.ITag[]>;
|
||||
|
||||
// Pipelines / CI
|
||||
abstract getPipelines(
|
||||
projectId: string,
|
||||
opts?: IListOptions,
|
||||
opts?: IPipelineListOptions,
|
||||
): Promise<interfaces.data.IPipeline[]>;
|
||||
abstract getPipelineJobs(
|
||||
projectId: string,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type * as interfaces from '../../ts_interfaces/index.ts';
|
||||
import { BaseProvider, type ITestConnectionResult, type IListOptions } from './classes.baseprovider.ts';
|
||||
import { BaseProvider, type ITestConnectionResult, type IListOptions, type IPipelineListOptions } from './classes.baseprovider.ts';
|
||||
|
||||
/**
|
||||
* Gitea API v1 provider implementation
|
||||
@@ -18,62 +18,46 @@ export class GiteaProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
async getProjects(opts?: IListOptions): Promise<interfaces.data.IProject[]> {
|
||||
// Use org-scoped listing when groupFilterId is set
|
||||
const fetchFn = this.groupFilterId
|
||||
? (o: IListOptions) => this.client.getOrgRepos(this.groupFilterId!, o)
|
||||
: (o: IListOptions) => this.client.getRepos(o);
|
||||
|
||||
// If caller explicitly requests a specific page, respect it (no auto-pagination)
|
||||
if (opts?.page) {
|
||||
const repos = await fetchFn(opts);
|
||||
return repos.map((r) => this.mapProject(r));
|
||||
}
|
||||
|
||||
const allRepos: plugins.giteaClient.IGiteaRepository[] = [];
|
||||
const perPage = opts?.perPage || 50;
|
||||
let page = 1;
|
||||
|
||||
while (true) {
|
||||
const repos = await fetchFn({ ...opts, page, perPage });
|
||||
allRepos.push(...repos);
|
||||
if (repos.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return allRepos.map((r) => this.mapProject(r));
|
||||
const repos = this.groupFilterId
|
||||
? await (await this.client.getOrg(this.groupFilterId)).getRepos(opts)
|
||||
: await this.client.getRepos(opts);
|
||||
return repos.map((r) => this.mapProject(r));
|
||||
}
|
||||
|
||||
async getGroups(opts?: IListOptions): Promise<interfaces.data.IGroup[]> {
|
||||
// When groupFilterId is set, return only that single org
|
||||
if (this.groupFilterId) {
|
||||
const org = await this.client.getOrg(this.groupFilterId);
|
||||
return [this.mapGroup(org)];
|
||||
}
|
||||
const orgs = await this.client.getOrgs(opts);
|
||||
return orgs.map((o) => this.mapGroup(o));
|
||||
}
|
||||
|
||||
// If caller explicitly requests a specific page, respect it (no auto-pagination)
|
||||
if (opts?.page) {
|
||||
const orgs = await this.client.getOrgs(opts);
|
||||
return orgs.map((o) => this.mapGroup(o));
|
||||
}
|
||||
async getGroupProjects(groupId: string, opts?: IListOptions): Promise<interfaces.data.IProject[]> {
|
||||
const org = await this.client.getOrg(groupId);
|
||||
const repos = await org.getRepos(opts);
|
||||
return repos.map((r) => this.mapProject(r));
|
||||
}
|
||||
|
||||
const allOrgs: plugins.giteaClient.IGiteaOrganization[] = [];
|
||||
const perPage = opts?.perPage || 50;
|
||||
let page = 1;
|
||||
// --- Branches / Tags ---
|
||||
|
||||
while (true) {
|
||||
const orgs = await this.client.getOrgs({ ...opts, page, perPage });
|
||||
allOrgs.push(...orgs);
|
||||
if (orgs.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
async getBranches(projectFullPath: string, opts?: IListOptions): Promise<interfaces.data.IBranch[]> {
|
||||
const repo = await this.client.getRepo(projectFullPath);
|
||||
const branches = await repo.getBranches(opts);
|
||||
return branches.map((b) => ({ name: b.name, commitSha: b.commitSha }));
|
||||
}
|
||||
|
||||
return allOrgs.map((o) => this.mapGroup(o));
|
||||
async getTags(projectFullPath: string, opts?: IListOptions): Promise<interfaces.data.ITag[]> {
|
||||
const repo = await this.client.getRepo(projectFullPath);
|
||||
const tags = await repo.getTags(opts);
|
||||
return tags.map((t) => ({ name: t.name, commitSha: t.commitSha }));
|
||||
}
|
||||
|
||||
// --- Project Secrets ---
|
||||
|
||||
async getProjectSecrets(projectId: string): Promise<interfaces.data.ISecret[]> {
|
||||
const secrets = await this.client.getRepoSecrets(projectId);
|
||||
const repo = await this.client.getRepo(projectId);
|
||||
const secrets = await repo.getSecrets();
|
||||
return secrets.map((s) => this.mapSecret(s, 'project', projectId));
|
||||
}
|
||||
|
||||
@@ -82,7 +66,8 @@ export class GiteaProvider extends BaseProvider {
|
||||
key: string,
|
||||
value: string,
|
||||
): Promise<interfaces.data.ISecret> {
|
||||
await this.client.setRepoSecret(projectId, key, value);
|
||||
const repo = await this.client.getRepo(projectId);
|
||||
await repo.setSecret(key, value);
|
||||
return { key, value: '***', protected: false, masked: true, scope: 'project', scopeId: projectId, scopeName: projectId, connectionId: this.connectionId, environment: '*' };
|
||||
}
|
||||
|
||||
@@ -95,13 +80,15 @@ export class GiteaProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
async deleteProjectSecret(projectId: string, key: string): Promise<void> {
|
||||
await this.client.deleteRepoSecret(projectId, key);
|
||||
const repo = await this.client.getRepo(projectId);
|
||||
await repo.deleteSecret(key);
|
||||
}
|
||||
|
||||
// --- Group Secrets ---
|
||||
|
||||
async getGroupSecrets(groupId: string): Promise<interfaces.data.ISecret[]> {
|
||||
const secrets = await this.client.getOrgSecrets(groupId);
|
||||
const org = await this.client.getOrg(groupId);
|
||||
const secrets = await org.getSecrets();
|
||||
return secrets.map((s) => this.mapSecret(s, 'group', groupId));
|
||||
}
|
||||
|
||||
@@ -110,7 +97,8 @@ export class GiteaProvider extends BaseProvider {
|
||||
key: string,
|
||||
value: string,
|
||||
): Promise<interfaces.data.ISecret> {
|
||||
await this.client.setOrgSecret(groupId, key, value);
|
||||
const org = await this.client.getOrg(groupId);
|
||||
await org.setSecret(key, value);
|
||||
return { key, value: '***', protected: false, masked: true, scope: 'group', scopeId: groupId, scopeName: groupId, connectionId: this.connectionId, environment: '*' };
|
||||
}
|
||||
|
||||
@@ -123,16 +111,24 @@ export class GiteaProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
async deleteGroupSecret(groupId: string, key: string): Promise<void> {
|
||||
await this.client.deleteOrgSecret(groupId, key);
|
||||
const org = await this.client.getOrg(groupId);
|
||||
await org.deleteSecret(key);
|
||||
}
|
||||
|
||||
// --- Pipelines (Action Runs) ---
|
||||
|
||||
async getPipelines(
|
||||
projectId: string,
|
||||
opts?: IListOptions,
|
||||
opts?: IPipelineListOptions,
|
||||
): Promise<interfaces.data.IPipeline[]> {
|
||||
const runs = await this.client.getActionRuns(projectId, opts);
|
||||
const repo = await this.client.getRepo(projectId);
|
||||
const runs = await repo.getActionRuns({
|
||||
page: opts?.page,
|
||||
perPage: opts?.perPage,
|
||||
status: opts?.status,
|
||||
branch: opts?.ref,
|
||||
event: opts?.source,
|
||||
});
|
||||
return runs.map((r) => this.mapPipeline(r, projectId));
|
||||
}
|
||||
|
||||
@@ -140,90 +136,101 @@ export class GiteaProvider extends BaseProvider {
|
||||
projectId: string,
|
||||
pipelineId: string,
|
||||
): Promise<interfaces.data.IPipelineJob[]> {
|
||||
const jobs = await this.client.getActionRunJobs(projectId, Number(pipelineId));
|
||||
return jobs.map((j) => this.mapJob(j, pipelineId));
|
||||
// Use the client's internal method directly to avoid an extra getRepo call
|
||||
const jobs = await this.client.requestGetActionRunJobs(projectId, Number(pipelineId));
|
||||
return jobs.map((j) => {
|
||||
const resolvedStatus = plugins.giteaClient.resolveGiteaStatus(j.status, j.conclusion);
|
||||
return this.mapJob(resolvedStatus, j, pipelineId);
|
||||
});
|
||||
}
|
||||
|
||||
async getJobLog(projectId: string, jobId: string): Promise<string> {
|
||||
return this.client.getJobLog(projectId, Number(jobId));
|
||||
return this.client.requestGetJobLog(projectId, Number(jobId));
|
||||
}
|
||||
|
||||
async retryPipeline(projectId: string, pipelineId: string): Promise<void> {
|
||||
await this.client.rerunAction(projectId, Number(pipelineId));
|
||||
// Fetch the run to get its workflow path, then dispatch
|
||||
const run = await this.client.requestGetActionRun(projectId, Number(pipelineId));
|
||||
const wfId = plugins.giteaClient.extractWorkflowIdFromPath(run.path);
|
||||
const ref = run.head_branch || 'main';
|
||||
if (!wfId) {
|
||||
throw new Error(`Cannot retry: no workflow ID found in path "${run.path}"`);
|
||||
}
|
||||
await this.client.requestDispatchWorkflow(projectId, wfId, ref);
|
||||
}
|
||||
|
||||
async cancelPipeline(projectId: string, pipelineId: string): Promise<void> {
|
||||
await this.client.cancelAction(projectId, Number(pipelineId));
|
||||
async cancelPipeline(_projectId: string, _pipelineId: string): Promise<void> {
|
||||
throw new Error('Cancel is not supported by Gitea 1.25');
|
||||
}
|
||||
|
||||
// --- Mappers ---
|
||||
|
||||
private mapProject(r: plugins.giteaClient.IGiteaRepository): interfaces.data.IProject {
|
||||
private mapProject(r: plugins.giteaClient.GiteaRepository): interfaces.data.IProject {
|
||||
return {
|
||||
id: r.full_name || String(r.id),
|
||||
name: r.name || '',
|
||||
fullPath: r.full_name || '',
|
||||
description: r.description || '',
|
||||
defaultBranch: r.default_branch || 'main',
|
||||
webUrl: r.html_url || '',
|
||||
id: r.fullName || String(r.id),
|
||||
name: r.name,
|
||||
fullPath: r.fullName,
|
||||
description: r.description,
|
||||
defaultBranch: r.defaultBranch,
|
||||
webUrl: r.htmlUrl,
|
||||
connectionId: this.connectionId,
|
||||
visibility: r.private ? 'private' : 'public',
|
||||
topics: r.topics || [],
|
||||
lastActivity: r.updated_at || '',
|
||||
visibility: r.isPrivate ? 'private' : 'public',
|
||||
topics: r.topics,
|
||||
lastActivity: r.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private mapGroup(o: plugins.giteaClient.IGiteaOrganization): interfaces.data.IGroup {
|
||||
private mapGroup(o: plugins.giteaClient.GiteaOrganization): interfaces.data.IGroup {
|
||||
return {
|
||||
id: o.name || String(o.id),
|
||||
name: o.name || '',
|
||||
fullPath: o.name || '',
|
||||
description: o.description || '',
|
||||
name: o.name,
|
||||
fullPath: o.name,
|
||||
description: o.description,
|
||||
webUrl: `${this.baseUrl}/${o.name}`,
|
||||
connectionId: this.connectionId,
|
||||
visibility: o.visibility || 'public',
|
||||
projectCount: o.repo_count || 0,
|
||||
projectCount: o.repoCount,
|
||||
};
|
||||
}
|
||||
|
||||
private mapSecret(s: plugins.giteaClient.IGiteaSecret, scope: 'project' | 'group', scopeId: string, scopeName?: string): interfaces.data.ISecret {
|
||||
private mapSecret(s: plugins.giteaClient.GiteaSecret, scope: 'project' | 'group', scopeId: string): interfaces.data.ISecret {
|
||||
return {
|
||||
key: s.name || '',
|
||||
key: s.name,
|
||||
value: '***',
|
||||
protected: false,
|
||||
masked: true,
|
||||
scope,
|
||||
scopeId,
|
||||
scopeName: scopeName || scopeId,
|
||||
scopeName: scopeId,
|
||||
connectionId: this.connectionId,
|
||||
environment: '*',
|
||||
};
|
||||
}
|
||||
|
||||
private mapPipeline(r: plugins.giteaClient.IGiteaActionRun, projectId: string): interfaces.data.IPipeline {
|
||||
private mapPipeline(r: plugins.giteaClient.GiteaActionRun, projectId: string): interfaces.data.IPipeline {
|
||||
return {
|
||||
id: String(r.id),
|
||||
projectId,
|
||||
projectName: projectId,
|
||||
connectionId: this.connectionId,
|
||||
status: this.mapStatus(r.status || r.conclusion),
|
||||
ref: r.head_branch || '',
|
||||
sha: r.head_sha || '',
|
||||
webUrl: r.html_url || '',
|
||||
duration: r.run_duration || 0,
|
||||
createdAt: r.created_at || '',
|
||||
status: this.mapStatus(r.resolvedStatus),
|
||||
ref: r.ref,
|
||||
sha: r.headSha,
|
||||
webUrl: r.htmlUrl,
|
||||
duration: r.duration,
|
||||
createdAt: r.startedAt,
|
||||
source: r.event || 'push',
|
||||
};
|
||||
}
|
||||
|
||||
private mapJob(j: plugins.giteaClient.IGiteaActionRunJob, pipelineId: string): interfaces.data.IPipelineJob {
|
||||
private mapJob(resolvedStatus: string, j: plugins.giteaClient.IGiteaActionRunJob, pipelineId: string): interfaces.data.IPipelineJob {
|
||||
return {
|
||||
id: String(j.id),
|
||||
pipelineId,
|
||||
name: j.name || '',
|
||||
stage: j.name || 'default',
|
||||
status: this.mapStatus(j.status || j.conclusion),
|
||||
duration: j.run_duration || 0,
|
||||
status: this.mapStatus(resolvedStatus),
|
||||
duration: plugins.giteaClient.computeDuration(j.started_at, j.completed_at),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type * as interfaces from '../../ts_interfaces/index.ts';
|
||||
import { BaseProvider, type ITestConnectionResult, type IListOptions } from './classes.baseprovider.ts';
|
||||
import { BaseProvider, type ITestConnectionResult, type IListOptions, type IPipelineListOptions } from './classes.baseprovider.ts';
|
||||
|
||||
/**
|
||||
* GitLab API v4 provider implementation
|
||||
@@ -18,77 +18,47 @@ export class GitLabProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
async getProjects(opts?: IListOptions): Promise<interfaces.data.IProject[]> {
|
||||
if (this.groupFilterId) {
|
||||
// Auto-paginate group-scoped project listing
|
||||
if (opts?.page) {
|
||||
const projects = await this.client.getGroupProjects(this.groupFilterId, opts);
|
||||
return projects.map((p) => this.mapProject(p));
|
||||
}
|
||||
const allProjects: plugins.gitlabClient.IGitLabProject[] = [];
|
||||
const perPage = opts?.perPage || 50;
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const projects = await this.client.getGroupProjects(this.groupFilterId, { ...opts, page, perPage });
|
||||
allProjects.push(...projects);
|
||||
if (projects.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
return allProjects.map((p) => this.mapProject(p));
|
||||
}
|
||||
if (opts?.page) {
|
||||
const projects = await this.client.getProjects(opts);
|
||||
return projects.map((p) => this.mapProject(p));
|
||||
}
|
||||
const allProjects: plugins.gitlabClient.IGitLabProject[] = [];
|
||||
const perPage = opts?.perPage || 50;
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const projects = await this.client.getProjects({ ...opts, page, perPage });
|
||||
allProjects.push(...projects);
|
||||
if (projects.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
return allProjects.map((p) => this.mapProject(p));
|
||||
const projects = this.groupFilterId
|
||||
? await (await this.client.getGroup(this.groupFilterId)).getProjects(opts)
|
||||
: await this.client.getProjects(opts);
|
||||
return projects.map((p) => this.mapProject(p));
|
||||
}
|
||||
|
||||
async getGroups(opts?: IListOptions): Promise<interfaces.data.IGroup[]> {
|
||||
if (this.groupFilterId) {
|
||||
// Auto-paginate descendant groups listing
|
||||
if (opts?.page) {
|
||||
const groups = await this.client.getDescendantGroups(this.groupFilterId, opts);
|
||||
return groups.map((g) => this.mapGroup(g));
|
||||
}
|
||||
const allGroups: plugins.gitlabClient.IGitLabGroup[] = [];
|
||||
const perPage = opts?.perPage || 50;
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const groups = await this.client.getDescendantGroups(this.groupFilterId, { ...opts, page, perPage });
|
||||
allGroups.push(...groups);
|
||||
if (groups.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
return allGroups.map((g) => this.mapGroup(g));
|
||||
const group = await this.client.getGroup(this.groupFilterId);
|
||||
const descendants = await group.getDescendantGroups(opts);
|
||||
return descendants.map((g) => this.mapGroup(g));
|
||||
}
|
||||
if (opts?.page) {
|
||||
const groups = await this.client.getGroups(opts);
|
||||
return groups.map((g) => this.mapGroup(g));
|
||||
}
|
||||
const allGroups: plugins.gitlabClient.IGitLabGroup[] = [];
|
||||
const perPage = opts?.perPage || 50;
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const groups = await this.client.getGroups({ ...opts, page, perPage });
|
||||
allGroups.push(...groups);
|
||||
if (groups.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
return allGroups.map((g) => this.mapGroup(g));
|
||||
const groups = await this.client.getGroups(opts);
|
||||
return groups.map((g) => this.mapGroup(g));
|
||||
}
|
||||
|
||||
async getGroupProjects(groupId: string, opts?: IListOptions): Promise<interfaces.data.IProject[]> {
|
||||
const group = await this.client.getGroup(groupId);
|
||||
const projects = await group.getProjects(opts);
|
||||
return projects.map((p) => this.mapProject(p));
|
||||
}
|
||||
|
||||
// --- Branches / Tags ---
|
||||
|
||||
async getBranches(projectFullPath: string, opts?: IListOptions): Promise<interfaces.data.IBranch[]> {
|
||||
const project = await this.client.getProject(projectFullPath);
|
||||
const branches = await project.getBranches(opts);
|
||||
return branches.map((b) => ({ name: b.name, commitSha: b.commitSha }));
|
||||
}
|
||||
|
||||
async getTags(projectFullPath: string, opts?: IListOptions): Promise<interfaces.data.ITag[]> {
|
||||
const project = await this.client.getProject(projectFullPath);
|
||||
const tags = await project.getTags(opts);
|
||||
return tags.map((t) => ({ name: t.name, commitSha: t.commitSha }));
|
||||
}
|
||||
|
||||
// --- Project Secrets (CI/CD Variables) ---
|
||||
|
||||
async getProjectSecrets(projectId: string): Promise<interfaces.data.ISecret[]> {
|
||||
const vars = await this.client.getProjectVariables(projectId);
|
||||
const project = await this.client.getProject(projectId);
|
||||
const vars = await project.getVariables();
|
||||
return vars.map((v) => this.mapVariable(v, 'project', projectId));
|
||||
}
|
||||
|
||||
@@ -97,7 +67,8 @@ export class GitLabProvider extends BaseProvider {
|
||||
key: string,
|
||||
value: string,
|
||||
): Promise<interfaces.data.ISecret> {
|
||||
const v = await this.client.createProjectVariable(projectId, key, value);
|
||||
const project = await this.client.getProject(projectId);
|
||||
const v = await project.createVariable(key, value);
|
||||
return this.mapVariable(v, 'project', projectId);
|
||||
}
|
||||
|
||||
@@ -106,18 +77,21 @@ export class GitLabProvider extends BaseProvider {
|
||||
key: string,
|
||||
value: string,
|
||||
): Promise<interfaces.data.ISecret> {
|
||||
const v = await this.client.updateProjectVariable(projectId, key, value);
|
||||
const project = await this.client.getProject(projectId);
|
||||
const v = await project.updateVariable(key, value);
|
||||
return this.mapVariable(v, 'project', projectId);
|
||||
}
|
||||
|
||||
async deleteProjectSecret(projectId: string, key: string): Promise<void> {
|
||||
await this.client.deleteProjectVariable(projectId, key);
|
||||
const project = await this.client.getProject(projectId);
|
||||
await project.deleteVariable(key);
|
||||
}
|
||||
|
||||
// --- Group Secrets (CI/CD Variables) ---
|
||||
|
||||
async getGroupSecrets(groupId: string): Promise<interfaces.data.ISecret[]> {
|
||||
const vars = await this.client.getGroupVariables(groupId);
|
||||
const group = await this.client.getGroup(groupId);
|
||||
const vars = await group.getVariables();
|
||||
return vars.map((v) => this.mapVariable(v, 'group', groupId));
|
||||
}
|
||||
|
||||
@@ -126,7 +100,8 @@ export class GitLabProvider extends BaseProvider {
|
||||
key: string,
|
||||
value: string,
|
||||
): Promise<interfaces.data.ISecret> {
|
||||
const v = await this.client.createGroupVariable(groupId, key, value);
|
||||
const group = await this.client.getGroup(groupId);
|
||||
const v = await group.createVariable(key, value);
|
||||
return this.mapVariable(v, 'group', groupId);
|
||||
}
|
||||
|
||||
@@ -135,21 +110,30 @@ export class GitLabProvider extends BaseProvider {
|
||||
key: string,
|
||||
value: string,
|
||||
): Promise<interfaces.data.ISecret> {
|
||||
const v = await this.client.updateGroupVariable(groupId, key, value);
|
||||
const group = await this.client.getGroup(groupId);
|
||||
const v = await group.updateVariable(key, value);
|
||||
return this.mapVariable(v, 'group', groupId);
|
||||
}
|
||||
|
||||
async deleteGroupSecret(groupId: string, key: string): Promise<void> {
|
||||
await this.client.deleteGroupVariable(groupId, key);
|
||||
const group = await this.client.getGroup(groupId);
|
||||
await group.deleteVariable(key);
|
||||
}
|
||||
|
||||
// --- Pipelines ---
|
||||
|
||||
async getPipelines(
|
||||
projectId: string,
|
||||
opts?: IListOptions,
|
||||
opts?: IPipelineListOptions,
|
||||
): Promise<interfaces.data.IPipeline[]> {
|
||||
const pipelines = await this.client.getPipelines(projectId, opts);
|
||||
const project = await this.client.getProject(projectId);
|
||||
const pipelines = await project.getPipelines({
|
||||
page: opts?.page,
|
||||
perPage: opts?.perPage,
|
||||
status: opts?.status,
|
||||
ref: opts?.ref,
|
||||
source: opts?.source,
|
||||
});
|
||||
return pipelines.map((p) => this.mapPipeline(p, projectId));
|
||||
}
|
||||
|
||||
@@ -157,83 +141,82 @@ export class GitLabProvider extends BaseProvider {
|
||||
projectId: string,
|
||||
pipelineId: string,
|
||||
): Promise<interfaces.data.IPipelineJob[]> {
|
||||
const jobs = await this.client.getPipelineJobs(projectId, Number(pipelineId));
|
||||
const jobs = await this.client.requestGetPipelineJobs(projectId, Number(pipelineId));
|
||||
return jobs.map((j) => this.mapJob(j, pipelineId));
|
||||
}
|
||||
|
||||
async getJobLog(projectId: string, jobId: string): Promise<string> {
|
||||
return this.client.getJobLog(projectId, Number(jobId));
|
||||
return this.client.requestGetJobLog(projectId, Number(jobId));
|
||||
}
|
||||
|
||||
async retryPipeline(projectId: string, pipelineId: string): Promise<void> {
|
||||
await this.client.retryPipeline(projectId, Number(pipelineId));
|
||||
await this.client.requestRetryPipeline(projectId, Number(pipelineId));
|
||||
}
|
||||
|
||||
async cancelPipeline(projectId: string, pipelineId: string): Promise<void> {
|
||||
await this.client.cancelPipeline(projectId, Number(pipelineId));
|
||||
await this.client.requestCancelPipeline(projectId, Number(pipelineId));
|
||||
}
|
||||
|
||||
// --- Mappers ---
|
||||
|
||||
private mapProject(p: plugins.gitlabClient.IGitLabProject): interfaces.data.IProject {
|
||||
private mapProject(p: plugins.gitlabClient.GitLabProject): interfaces.data.IProject {
|
||||
return {
|
||||
id: String(p.id),
|
||||
name: p.name || '',
|
||||
fullPath: p.path_with_namespace || '',
|
||||
description: p.description || '',
|
||||
defaultBranch: p.default_branch || 'main',
|
||||
webUrl: p.web_url || '',
|
||||
name: p.name,
|
||||
fullPath: p.fullPath,
|
||||
description: p.description,
|
||||
defaultBranch: p.defaultBranch,
|
||||
webUrl: p.webUrl,
|
||||
connectionId: this.connectionId,
|
||||
visibility: p.visibility || 'private',
|
||||
topics: p.topics || [],
|
||||
lastActivity: p.last_activity_at || '',
|
||||
visibility: p.visibility,
|
||||
topics: p.topics,
|
||||
lastActivity: p.lastActivityAt,
|
||||
};
|
||||
}
|
||||
|
||||
private mapGroup(g: plugins.gitlabClient.IGitLabGroup): interfaces.data.IGroup {
|
||||
private mapGroup(g: plugins.gitlabClient.GitLabGroup): interfaces.data.IGroup {
|
||||
return {
|
||||
id: String(g.id),
|
||||
name: g.name || '',
|
||||
fullPath: g.full_path || '',
|
||||
description: g.description || '',
|
||||
webUrl: g.web_url || '',
|
||||
name: g.name,
|
||||
fullPath: g.fullPath,
|
||||
description: g.description,
|
||||
webUrl: g.webUrl,
|
||||
connectionId: this.connectionId,
|
||||
visibility: g.visibility || 'private',
|
||||
visibility: g.visibility,
|
||||
projectCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private mapVariable(
|
||||
v: plugins.gitlabClient.IGitLabVariable,
|
||||
v: plugins.gitlabClient.GitLabVariable,
|
||||
scope: 'project' | 'group',
|
||||
scopeId: string,
|
||||
scopeName?: string,
|
||||
): interfaces.data.ISecret {
|
||||
return {
|
||||
key: v.key || '',
|
||||
key: v.key,
|
||||
value: v.value || '***',
|
||||
protected: v.protected || false,
|
||||
masked: v.masked || false,
|
||||
protected: v.protected,
|
||||
masked: v.masked,
|
||||
scope,
|
||||
scopeId,
|
||||
scopeName: scopeName || scopeId,
|
||||
scopeName: scopeId,
|
||||
connectionId: this.connectionId,
|
||||
environment: v.environment_scope || '*',
|
||||
environment: v.environmentScope,
|
||||
};
|
||||
}
|
||||
|
||||
private mapPipeline(p: plugins.gitlabClient.IGitLabPipeline, projectId: string): interfaces.data.IPipeline {
|
||||
private mapPipeline(p: plugins.gitlabClient.GitLabPipeline, projectId: string): interfaces.data.IPipeline {
|
||||
return {
|
||||
id: String(p.id),
|
||||
projectId,
|
||||
projectName: projectId,
|
||||
connectionId: this.connectionId,
|
||||
status: (p.status || 'pending') as interfaces.data.TPipelineStatus,
|
||||
ref: p.ref || '',
|
||||
sha: p.sha || '',
|
||||
webUrl: p.web_url || '',
|
||||
duration: p.duration || 0,
|
||||
createdAt: p.created_at || '',
|
||||
ref: p.ref,
|
||||
sha: p.sha,
|
||||
webUrl: p.webUrl,
|
||||
duration: p.duration,
|
||||
createdAt: p.createdAt,
|
||||
source: p.source || 'push',
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
export type TActionType = 'create' | 'update' | 'delete' | 'pause' | 'resume' | 'test' | 'scan' | 'sync' | 'obsolete';
|
||||
export type TActionEntity = 'connection' | 'secret' | 'pipeline' | 'sync';
|
||||
export type TActionType = 'create' | 'update' | 'delete' | 'pause' | 'resume' | 'test' | 'scan' | 'sync' | 'obsolete' | 'push';
|
||||
export type TActionEntity = 'connection' | 'secret' | 'pipeline' | 'sync' | 'managed-secret';
|
||||
|
||||
export interface IActionLogEntry {
|
||||
id: string;
|
||||
|
||||
9
ts_interfaces/data/branch.ts
Normal file
9
ts_interfaces/data/branch.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface IBranch {
|
||||
name: string;
|
||||
commitSha: string;
|
||||
}
|
||||
|
||||
export interface ITag {
|
||||
name: string;
|
||||
commitSha: string;
|
||||
}
|
||||
@@ -2,7 +2,9 @@ export * from './identity.ts';
|
||||
export * from './connection.ts';
|
||||
export * from './project.ts';
|
||||
export * from './group.ts';
|
||||
export * from './branch.ts';
|
||||
export * from './secret.ts';
|
||||
export * from './pipeline.ts';
|
||||
export * from './actionlog.ts';
|
||||
export * from './sync.ts';
|
||||
export * from './managedsecret.ts';
|
||||
|
||||
41
ts_interfaces/data/managedsecret.ts
Normal file
41
ts_interfaces/data/managedsecret.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export interface IManagedSecretTarget {
|
||||
connectionId: string;
|
||||
scope: 'project' | 'group';
|
||||
scopeId: string;
|
||||
scopeName: string;
|
||||
}
|
||||
|
||||
export type TPushStatus = 'pending' | 'success' | 'error';
|
||||
|
||||
export interface IManagedSecretTargetStatus {
|
||||
connectionId: string;
|
||||
scope: 'project' | 'group';
|
||||
scopeId: string;
|
||||
scopeName: string;
|
||||
status: TPushStatus;
|
||||
error?: string;
|
||||
lastPushedAt?: number;
|
||||
}
|
||||
|
||||
export interface IManagedSecret {
|
||||
id: string;
|
||||
key: string;
|
||||
description?: string;
|
||||
targets: IManagedSecretTarget[];
|
||||
targetStatuses: IManagedSecretTargetStatus[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastPushedAt?: number;
|
||||
}
|
||||
|
||||
export interface IManagedSecretStored {
|
||||
id: string;
|
||||
key: string;
|
||||
description?: string;
|
||||
value: string;
|
||||
targets: IManagedSecretTarget[];
|
||||
targetStatuses: IManagedSecretTargetStatus[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastPushedAt?: number;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export interface ISyncConfig {
|
||||
enforceDelete: boolean; // When true, stale target repos are moved to obsolete
|
||||
enforceGroupDelete: boolean; // When true, stale target groups/orgs are moved to obsolete
|
||||
addMirrorHint?: boolean; // When true, target descriptions get "(This is a mirror of ...)" appended
|
||||
useGroupAvatarsForProjects?: boolean; // When true, projects without avatars inherit the group avatar
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,3 +9,4 @@ export * from './webhook.ts';
|
||||
export * from './actions.ts';
|
||||
export * from './actionlog.ts';
|
||||
export * from './sync.ts';
|
||||
export * from './managedsecrets.ts';
|
||||
|
||||
112
ts_interfaces/requests/managedsecrets.ts
Normal file
112
ts_interfaces/requests/managedsecrets.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_GetManagedSecrets extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetManagedSecrets
|
||||
> {
|
||||
method: 'getManagedSecrets';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
managedSecrets: data.IManagedSecret[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetManagedSecret extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetManagedSecret
|
||||
> {
|
||||
method: 'getManagedSecret';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
managedSecretId: string;
|
||||
};
|
||||
response: {
|
||||
managedSecret: data.IManagedSecret;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_CreateManagedSecret extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateManagedSecret
|
||||
> {
|
||||
method: 'createManagedSecret';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
key: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
targets: data.IManagedSecretTarget[];
|
||||
};
|
||||
response: {
|
||||
managedSecret: data.IManagedSecret;
|
||||
pushResults: data.IManagedSecretTargetStatus[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_UpdateManagedSecret extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateManagedSecret
|
||||
> {
|
||||
method: 'updateManagedSecret';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
managedSecretId: string;
|
||||
value?: string;
|
||||
description?: string;
|
||||
targets?: data.IManagedSecretTarget[];
|
||||
};
|
||||
response: {
|
||||
managedSecret: data.IManagedSecret;
|
||||
pushResults: data.IManagedSecretTargetStatus[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_DeleteManagedSecret extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteManagedSecret
|
||||
> {
|
||||
method: 'deleteManagedSecret';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
managedSecretId: string;
|
||||
};
|
||||
response: {
|
||||
ok: boolean;
|
||||
deleteResults: data.IManagedSecretTargetStatus[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_PushManagedSecret extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_PushManagedSecret
|
||||
> {
|
||||
method: 'pushManagedSecret';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
managedSecretId: string;
|
||||
};
|
||||
response: {
|
||||
managedSecret: data.IManagedSecret;
|
||||
pushResults: data.IManagedSecretTargetStatus[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_PushAllManagedSecrets extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_PushAllManagedSecrets
|
||||
> {
|
||||
method: 'pushAllManagedSecrets';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
results: Array<{
|
||||
managedSecretId: string;
|
||||
key: string;
|
||||
pushResults: data.IManagedSecretTargetStatus[];
|
||||
}>;
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,12 @@ export interface IReq_GetPipelines extends plugins.typedrequestInterfaces.implem
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
connectionId: string;
|
||||
projectId: string;
|
||||
projectId?: string;
|
||||
viewMode?: 'current' | 'project' | 'group' | 'error';
|
||||
groupId?: string;
|
||||
status?: string;
|
||||
sortBy?: 'created' | 'duration' | 'status';
|
||||
timeRange?: '1h' | '6h' | '1d' | '3d' | '7d' | '30d';
|
||||
page?: number;
|
||||
};
|
||||
response: {
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface IReq_CreateSyncConfig extends plugins.typedrequestInterfaces.im
|
||||
enforceDelete?: boolean;
|
||||
enforceGroupDelete?: boolean;
|
||||
addMirrorHint?: boolean;
|
||||
useGroupAvatarsForProjects?: boolean;
|
||||
};
|
||||
response: {
|
||||
config: data.ISyncConfig;
|
||||
@@ -49,6 +50,7 @@ export interface IReq_UpdateSyncConfig extends plugins.typedrequestInterfaces.im
|
||||
enforceDelete?: boolean;
|
||||
enforceGroupDelete?: boolean;
|
||||
addMirrorHint?: boolean;
|
||||
useGroupAvatarsForProjects?: boolean;
|
||||
};
|
||||
response: {
|
||||
config: data.ISyncConfig;
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/gitops',
|
||||
version: '2.8.0',
|
||||
version: '2.13.0',
|
||||
description: 'GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs'
|
||||
}
|
||||
|
||||
@@ -516,7 +516,12 @@ export const deleteSecretAction = dataStatePart.createAction<{
|
||||
|
||||
export const fetchPipelinesAction = dataStatePart.createAction<{
|
||||
connectionId: string;
|
||||
projectId: string;
|
||||
projectId?: string;
|
||||
viewMode?: 'current' | 'project' | 'group' | 'error';
|
||||
groupId?: string;
|
||||
status?: string;
|
||||
sortBy?: 'created' | 'duration' | 'status';
|
||||
timeRange?: '1h' | '6h' | '1d' | '3d' | '7d' | '30d';
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
@@ -527,6 +532,11 @@ export const fetchPipelinesAction = dataStatePart.createAction<{
|
||||
identity: context.identity!,
|
||||
connectionId: dataArg.connectionId,
|
||||
projectId: dataArg.projectId,
|
||||
viewMode: dataArg.viewMode,
|
||||
groupId: dataArg.groupId,
|
||||
status: dataArg.status,
|
||||
sortBy: dataArg.sortBy,
|
||||
timeRange: dataArg.timeRange,
|
||||
});
|
||||
return { ...statePartArg.getState(), pipelines: response.pipelines };
|
||||
} catch (err) {
|
||||
@@ -704,6 +714,142 @@ export const setRefreshIntervalAction = uiStatePart.createAction<{ interval: num
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Managed Secrets State
|
||||
// ============================================================================
|
||||
|
||||
export interface IManagedSecretsState {
|
||||
managedSecrets: interfaces.data.IManagedSecret[];
|
||||
}
|
||||
|
||||
export const managedSecretsStatePart = await appState.getStatePart<IManagedSecretsState>(
|
||||
'managedSecrets',
|
||||
{ managedSecrets: [] },
|
||||
'soft',
|
||||
);
|
||||
|
||||
export const fetchManagedSecretsAction = managedSecretsStatePart.createAction(
|
||||
async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetManagedSecrets
|
||||
>('/typedrequest', 'getManagedSecrets');
|
||||
const response = await typedRequest.fire({ identity: context.identity! });
|
||||
return { managedSecrets: response.managedSecrets };
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch managed secrets:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const createManagedSecretAction = managedSecretsStatePart.createAction<{
|
||||
key: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
targets: interfaces.data.IManagedSecretTarget[];
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_CreateManagedSecret
|
||||
>('/typedrequest', 'createManagedSecret');
|
||||
await typedRequest.fire({ identity: context.identity!, ...dataArg });
|
||||
// Re-fetch
|
||||
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetManagedSecrets
|
||||
>('/typedrequest', 'getManagedSecrets');
|
||||
const listResp = await listReq.fire({ identity: context.identity! });
|
||||
return { managedSecrets: listResp.managedSecrets };
|
||||
} catch (err) {
|
||||
console.error('Failed to create managed secret:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const updateManagedSecretAction = managedSecretsStatePart.createAction<{
|
||||
managedSecretId: string;
|
||||
value?: string;
|
||||
description?: string;
|
||||
targets?: interfaces.data.IManagedSecretTarget[];
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_UpdateManagedSecret
|
||||
>('/typedrequest', 'updateManagedSecret');
|
||||
await typedRequest.fire({ identity: context.identity!, ...dataArg });
|
||||
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetManagedSecrets
|
||||
>('/typedrequest', 'getManagedSecrets');
|
||||
const listResp = await listReq.fire({ identity: context.identity! });
|
||||
return { managedSecrets: listResp.managedSecrets };
|
||||
} catch (err) {
|
||||
console.error('Failed to update managed secret:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteManagedSecretAction = managedSecretsStatePart.createAction<{
|
||||
managedSecretId: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_DeleteManagedSecret
|
||||
>('/typedrequest', 'deleteManagedSecret');
|
||||
await typedRequest.fire({ identity: context.identity!, ...dataArg });
|
||||
const state = statePartArg.getState();
|
||||
return {
|
||||
managedSecrets: state.managedSecrets.filter((s) => s.id !== dataArg.managedSecretId),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to delete managed secret:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const pushManagedSecretAction = managedSecretsStatePart.createAction<{
|
||||
managedSecretId: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_PushManagedSecret
|
||||
>('/typedrequest', 'pushManagedSecret');
|
||||
await typedRequest.fire({ identity: context.identity!, ...dataArg });
|
||||
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetManagedSecrets
|
||||
>('/typedrequest', 'getManagedSecrets');
|
||||
const listResp = await listReq.fire({ identity: context.identity! });
|
||||
return { managedSecrets: listResp.managedSecrets };
|
||||
} catch (err) {
|
||||
console.error('Failed to push managed secret:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const pushAllManagedSecretsAction = managedSecretsStatePart.createAction(
|
||||
async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_PushAllManagedSecrets
|
||||
>('/typedrequest', 'pushAllManagedSecrets');
|
||||
await typedRequest.fire({ identity: context.identity! });
|
||||
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetManagedSecrets
|
||||
>('/typedrequest', 'getManagedSecrets');
|
||||
const listResp = await listReq.fire({ identity: context.identity! });
|
||||
return { managedSecrets: listResp.managedSecrets };
|
||||
} catch (err) {
|
||||
console.error('Failed to push all managed secrets:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Sync State
|
||||
// ============================================================================
|
||||
@@ -742,6 +888,7 @@ export const createSyncConfigAction = syncStatePart.createAction<{
|
||||
enforceDelete?: boolean;
|
||||
enforceGroupDelete?: boolean;
|
||||
addMirrorHint?: boolean;
|
||||
useGroupAvatarsForProjects?: boolean;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
@@ -769,6 +916,7 @@ export const updateSyncConfigAction = syncStatePart.createAction<{
|
||||
enforceDelete?: boolean;
|
||||
enforceGroupDelete?: boolean;
|
||||
addMirrorHint?: boolean;
|
||||
useGroupAvatarsForProjects?: boolean;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
@@ -897,11 +1045,12 @@ export async function initSyncLogSocket(): Promise<void> {
|
||||
),
|
||||
);
|
||||
|
||||
await plugins.typedsocket.TypedSocket.createClient(
|
||||
const typedsocketClient = await plugins.typedsocket.TypedSocket.createClient(
|
||||
typedrouter,
|
||||
plugins.typedsocket.TypedSocket.useWindowLocationOriginUrl(),
|
||||
{ autoReconnect: true },
|
||||
);
|
||||
await typedsocketClient.setTag('syncLogClient', {});
|
||||
} catch (err) {
|
||||
console.error('Failed to init sync log TypedSocket client:', err);
|
||||
syncLogSocketInitialized = false;
|
||||
|
||||
@@ -40,6 +40,7 @@ export class GitopsDashboard extends DeesElement {
|
||||
{ name: 'Projects', iconName: 'lucide:folderGit2', element: (async () => (await import('./views/projects/index.js')).GitopsViewProjects)() },
|
||||
{ name: 'Groups', iconName: 'lucide:users', element: (async () => (await import('./views/groups/index.js')).GitopsViewGroups)() },
|
||||
{ name: 'Secrets', iconName: 'lucide:key', element: (async () => (await import('./views/secrets/index.js')).GitopsViewSecrets)() },
|
||||
{ name: 'Managed Secrets', iconName: 'lucide:keyRound', element: (async () => (await import('./views/managedsecrets/index.js')).GitopsViewManagedSecrets)() },
|
||||
{ name: 'Pipelines', iconName: 'lucide:play', element: (async () => (await import('./views/pipelines/index.js')).GitopsViewPipelines)() },
|
||||
{ name: 'Build Log', iconName: 'lucide:scrollText', element: (async () => (await import('./views/buildlog/index.js')).GitopsViewBuildlog)() },
|
||||
{ name: 'Actions', iconName: 'lucide:zap', element: (async () => (await import('./views/actions/index.js')).GitopsViewActions)() },
|
||||
|
||||
@@ -7,3 +7,4 @@ import './views/secrets/index.js';
|
||||
import './views/pipelines/index.js';
|
||||
import './views/buildlog/index.js';
|
||||
import './views/actions/index.js';
|
||||
import './views/managedsecrets/index.js';
|
||||
|
||||
502
ts_web/elements/views/managedsecrets/index.ts
Normal file
502
ts_web/elements/views/managedsecrets/index.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import * as appstate from '../../../appstate.js';
|
||||
import { viewHostCss } from '../../shared/index.js';
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
state,
|
||||
css,
|
||||
cssManager,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
@customElement('gitops-view-managedsecrets')
|
||||
export class GitopsViewManagedSecrets extends DeesElement {
|
||||
@state()
|
||||
accessor managedSecretsState: appstate.IManagedSecretsState = { managedSecrets: [] };
|
||||
|
||||
@state()
|
||||
accessor connectionsState: appstate.IConnectionsState = {
|
||||
connections: [],
|
||||
activeConnectionId: null,
|
||||
};
|
||||
|
||||
@state()
|
||||
accessor dataState: appstate.IDataState = {
|
||||
projects: [],
|
||||
groups: [],
|
||||
secrets: [],
|
||||
pipelines: [],
|
||||
pipelineJobs: [],
|
||||
currentJobLog: '',
|
||||
};
|
||||
|
||||
private _autoRefreshHandler: () => void;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const msSub = appstate.managedSecretsStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => { this.managedSecretsState = s; });
|
||||
this.rxSubscriptions.push(msSub);
|
||||
|
||||
const connSub = appstate.connectionsStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => { this.connectionsState = s; });
|
||||
this.rxSubscriptions.push(connSub);
|
||||
|
||||
const dataSub = appstate.dataStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => { this.dataState = s; });
|
||||
this.rxSubscriptions.push(dataSub);
|
||||
|
||||
this._autoRefreshHandler = () => this.refresh();
|
||||
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
.target-list {
|
||||
margin: 8px 0;
|
||||
}
|
||||
.target-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
margin: 4px 0;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
color: #ccc;
|
||||
font-size: 13px;
|
||||
}
|
||||
.target-item .remove-btn {
|
||||
cursor: pointer;
|
||||
color: #e74c3c;
|
||||
font-size: 16px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.status-ok { color: #2ecc71; }
|
||||
.status-error { color: #e74c3c; }
|
||||
.status-pending { color: #f39c12; }
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="view-title">Managed Secrets</div>
|
||||
<div class="view-description">Centrally managed secrets pushed as GITOPS_{key} to configured targets</div>
|
||||
<div class="toolbar">
|
||||
<dees-button @click=${() => this.addManagedSecret()}>Add Managed Secret</dees-button>
|
||||
<dees-button @click=${() => this.pushAll()}>Push All</dees-button>
|
||||
<dees-button @click=${() => this.refresh()}>Refresh</dees-button>
|
||||
</div>
|
||||
<dees-table
|
||||
.heading1=${'Managed Secrets'}
|
||||
.heading2=${'Define once, push to many targets'}
|
||||
.data=${this.managedSecretsState.managedSecrets}
|
||||
.displayFunction=${(item: any) => ({
|
||||
Key: item.key,
|
||||
'On Target': 'GITOPS_' + item.key,
|
||||
Description: item.description || '-',
|
||||
Targets: String(item.targets.length),
|
||||
Status: this.summarizeStatus(item.targetStatuses),
|
||||
'Last Pushed': item.lastPushedAt ? new Date(item.lastPushedAt).toLocaleString() : 'Never',
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Edit',
|
||||
iconName: 'lucide:edit',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => { await this.editManagedSecret(item); },
|
||||
},
|
||||
{
|
||||
name: 'Push',
|
||||
iconName: 'lucide:upload',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => { await this.pushOne(item); },
|
||||
},
|
||||
{
|
||||
name: 'View Targets',
|
||||
iconName: 'lucide:list',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => { await this.viewTargets(item); },
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash2',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => { await this.deleteManagedSecret(item); },
|
||||
},
|
||||
]}
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
async firstUpdated() {
|
||||
await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
|
||||
await appstate.managedSecretsStatePart.dispatchAction(appstate.fetchManagedSecretsAction, null);
|
||||
}
|
||||
|
||||
private summarizeStatus(statuses: any[]): string {
|
||||
if (!statuses || statuses.length === 0) return 'Not pushed';
|
||||
const ok = statuses.filter((s: any) => s.status === 'success').length;
|
||||
const err = statuses.filter((s: any) => s.status === 'error').length;
|
||||
if (err === 0) return `All OK (${ok})`;
|
||||
return `${ok} OK / ${err} Failed`;
|
||||
}
|
||||
|
||||
private async refresh() {
|
||||
await appstate.managedSecretsStatePart.dispatchAction(appstate.fetchManagedSecretsAction, null);
|
||||
}
|
||||
|
||||
private async pushAll() {
|
||||
await appstate.managedSecretsStatePart.dispatchAction(appstate.pushAllManagedSecretsAction, null);
|
||||
}
|
||||
|
||||
private async pushOne(item: any) {
|
||||
await appstate.managedSecretsStatePart.dispatchAction(appstate.pushManagedSecretAction, {
|
||||
managedSecretId: item.id,
|
||||
});
|
||||
}
|
||||
|
||||
private async deleteManagedSecret(item: any) {
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Delete Managed Secret',
|
||||
content: html`<p style="color: #fff;">Are you sure you want to delete managed secret "${item.key}"?<br>This will also remove GITOPS_${item.key} from all ${item.targets.length} target(s).</p>`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||
{
|
||||
name: 'Delete',
|
||||
action: async (modal: any) => {
|
||||
await appstate.managedSecretsStatePart.dispatchAction(appstate.deleteManagedSecretAction, {
|
||||
managedSecretId: item.id,
|
||||
});
|
||||
modal.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async viewTargets(item: any) {
|
||||
const targetRows = (item.targetStatuses && item.targetStatuses.length > 0)
|
||||
? item.targetStatuses
|
||||
: item.targets.map((t: any) => ({ ...t, status: 'pending' }));
|
||||
|
||||
const statusIcon = (status: string) => {
|
||||
if (status === 'success') return html`<span class="status-ok">OK</span>`;
|
||||
if (status === 'error') return html`<span class="status-error">Error</span>`;
|
||||
return html`<span class="status-pending">Pending</span>`;
|
||||
};
|
||||
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: `Targets for ${item.key}`,
|
||||
content: html`
|
||||
<div style="color: #ccc; min-width: 400px;">
|
||||
${targetRows.map((t: any) => html`
|
||||
<div style="display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
||||
<div>
|
||||
<div style="font-weight: bold;">${t.scopeName || t.scopeId}</div>
|
||||
<div style="font-size: 12px; opacity: 0.7;">${t.scope} on ${this.getConnectionName(t.connectionId)}</div>
|
||||
${t.error ? html`<div style="font-size: 12px; color: #e74c3c; margin-top: 4px;">${t.error}</div>` : ''}
|
||||
</div>
|
||||
<div>${statusIcon(t.status)}</div>
|
||||
</div>
|
||||
`)}
|
||||
${targetRows.length === 0 ? html`<p>No targets configured.</p>` : ''}
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Close', action: async (modal: any) => { modal.destroy(); } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private getConnectionName(connectionId: string): string {
|
||||
const conn = this.connectionsState.connections.find((c) => c.id === connectionId);
|
||||
return conn ? conn.name : connectionId;
|
||||
}
|
||||
|
||||
private async addManagedSecret() {
|
||||
// Load entities for all connections
|
||||
const connections = this.connectionsState.connections;
|
||||
let targets: any[] = [];
|
||||
let selectedConnId = connections.length > 0 ? connections[0].id : '';
|
||||
let selectedScope: 'project' | 'group' = 'project';
|
||||
|
||||
// Pre-load entities for first connection
|
||||
if (selectedConnId) {
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchProjectsAction, {
|
||||
connectionId: selectedConnId,
|
||||
});
|
||||
}
|
||||
|
||||
const buildTargetListHtml = () => {
|
||||
if (targets.length === 0) return html`<p style="color: #888; font-size: 13px;">No targets added yet.</p>`;
|
||||
return html`${targets.map((t, i) => html`
|
||||
<div class="target-item">
|
||||
<span>${t.scopeName} (${t.scope}) on ${this.getConnectionName(t.connectionId)}</span>
|
||||
<span class="remove-btn" @click=${() => { targets.splice(i, 1); this.requestUpdate(); }}>x</span>
|
||||
</div>
|
||||
`)}`;
|
||||
};
|
||||
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Add Managed Secret',
|
||||
content: html`
|
||||
<style>
|
||||
.form-row { margin-bottom: 16px; }
|
||||
.target-section { margin-top: 16px; padding-top: 16px; border-top: 1px solid rgba(255,255,255,0.1); }
|
||||
.add-target-row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; }
|
||||
</style>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Key'} .key=${'key'} .description=${'Will be stored as GITOPS_{key} on targets'}></dees-input-text>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Value'} .key=${'value'} type="password"></dees-input-text>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Description'} .key=${'description'}></dees-input-text>
|
||||
</div>
|
||||
<div class="target-section">
|
||||
<div style="color: #fff; font-weight: bold; margin-bottom: 8px;">Targets</div>
|
||||
<div class="add-target-row">
|
||||
<dees-input-dropdown
|
||||
.label=${'Connection'}
|
||||
.key=${'targetConn'}
|
||||
.options=${connections.map((c) => ({ option: c.name, key: c.id }))}
|
||||
.selectedOption=${connections.length > 0 ? { option: connections[0].name, key: connections[0].id } : undefined}
|
||||
@selectedOption=${async (e: CustomEvent) => {
|
||||
selectedConnId = e.detail.key;
|
||||
if (selectedScope === 'project') {
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchProjectsAction, { connectionId: selectedConnId });
|
||||
} else {
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchGroupsAction, { connectionId: selectedConnId });
|
||||
}
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
<dees-input-dropdown
|
||||
.label=${'Scope'}
|
||||
.key=${'targetScope'}
|
||||
.options=${[{ option: 'Project', key: 'project' }, { option: 'Group', key: 'group' }]}
|
||||
.selectedOption=${{ option: 'Project', key: 'project' }}
|
||||
@selectedOption=${async (e: CustomEvent) => {
|
||||
selectedScope = e.detail.key as 'project' | 'group';
|
||||
if (selectedScope === 'project') {
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchProjectsAction, { connectionId: selectedConnId });
|
||||
} else {
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchGroupsAction, { connectionId: selectedConnId });
|
||||
}
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
<dees-input-dropdown
|
||||
.label=${'Entity'}
|
||||
.key=${'targetEntity'}
|
||||
.options=${(() => {
|
||||
const data = appstate.dataStatePart.getState();
|
||||
const items = selectedScope === 'project' ? data.projects : data.groups;
|
||||
return items.map((item: any) => ({ option: item.fullPath || item.name, key: item.id }));
|
||||
})()}
|
||||
></dees-input-dropdown>
|
||||
<dees-button @click=${() => {
|
||||
const data = appstate.dataStatePart.getState();
|
||||
const items = selectedScope === 'project' ? data.projects : data.groups;
|
||||
// Find entity dropdown value
|
||||
const modal = document.querySelector('dees-modal');
|
||||
if (!modal) return;
|
||||
const entityDropdowns = modal.shadowRoot?.querySelectorAll('dees-input-dropdown');
|
||||
let entityKey = '';
|
||||
let entityName = '';
|
||||
if (entityDropdowns) {
|
||||
for (const dd of entityDropdowns) {
|
||||
if ((dd as any).key === 'targetEntity') {
|
||||
const sel = (dd as any).selectedOption;
|
||||
if (sel) {
|
||||
entityKey = sel.key;
|
||||
entityName = sel.option;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!entityKey) return;
|
||||
// Avoid duplicates
|
||||
const exists = targets.some(
|
||||
(t) => t.connectionId === selectedConnId && t.scope === selectedScope && t.scopeId === entityKey,
|
||||
);
|
||||
if (exists) return;
|
||||
targets.push({
|
||||
connectionId: selectedConnId,
|
||||
scope: selectedScope,
|
||||
scopeId: entityKey,
|
||||
scopeName: entityName,
|
||||
});
|
||||
}}>Add Target</dees-button>
|
||||
</div>
|
||||
<div class="target-list" id="targetList">
|
||||
${buildTargetListHtml()}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||
{
|
||||
name: 'Create',
|
||||
action: async (modal: any) => {
|
||||
const inputs = modal.shadowRoot.querySelectorAll('dees-input-text');
|
||||
const data: any = {};
|
||||
for (const input of inputs) { data[input.key] = input.value || ''; }
|
||||
if (!data.key) return;
|
||||
await appstate.managedSecretsStatePart.dispatchAction(appstate.createManagedSecretAction, {
|
||||
key: data.key,
|
||||
value: data.value,
|
||||
description: data.description || undefined,
|
||||
targets,
|
||||
});
|
||||
modal.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async editManagedSecret(item: any) {
|
||||
const connections = this.connectionsState.connections;
|
||||
let targets = [...item.targets];
|
||||
let selectedConnId = connections.length > 0 ? connections[0].id : '';
|
||||
let selectedScope: 'project' | 'group' = 'project';
|
||||
|
||||
if (selectedConnId) {
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchProjectsAction, {
|
||||
connectionId: selectedConnId,
|
||||
});
|
||||
}
|
||||
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: `Edit: ${item.key}`,
|
||||
content: html`
|
||||
<style>
|
||||
.form-row { margin-bottom: 16px; }
|
||||
.target-section { margin-top: 16px; padding-top: 16px; border-top: 1px solid rgba(255,255,255,0.1); }
|
||||
.add-target-row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; }
|
||||
</style>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Value (leave empty to keep current)'} .key=${'value'} type="password"></dees-input-text>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Description'} .key=${'description'} .value=${item.description || ''}></dees-input-text>
|
||||
</div>
|
||||
<div class="target-section">
|
||||
<div style="color: #fff; font-weight: bold; margin-bottom: 8px;">Targets</div>
|
||||
<div class="add-target-row">
|
||||
<dees-input-dropdown
|
||||
.label=${'Connection'}
|
||||
.key=${'targetConn'}
|
||||
.options=${connections.map((c) => ({ option: c.name, key: c.id }))}
|
||||
.selectedOption=${connections.length > 0 ? { option: connections[0].name, key: connections[0].id } : undefined}
|
||||
@selectedOption=${async (e: CustomEvent) => {
|
||||
selectedConnId = e.detail.key;
|
||||
if (selectedScope === 'project') {
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchProjectsAction, { connectionId: selectedConnId });
|
||||
} else {
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchGroupsAction, { connectionId: selectedConnId });
|
||||
}
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
<dees-input-dropdown
|
||||
.label=${'Scope'}
|
||||
.key=${'targetScope'}
|
||||
.options=${[{ option: 'Project', key: 'project' }, { option: 'Group', key: 'group' }]}
|
||||
.selectedOption=${{ option: 'Project', key: 'project' }}
|
||||
@selectedOption=${async (e: CustomEvent) => {
|
||||
selectedScope = e.detail.key as 'project' | 'group';
|
||||
if (selectedScope === 'project') {
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchProjectsAction, { connectionId: selectedConnId });
|
||||
} else {
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchGroupsAction, { connectionId: selectedConnId });
|
||||
}
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
<dees-input-dropdown
|
||||
.label=${'Entity'}
|
||||
.key=${'targetEntity'}
|
||||
.options=${(() => {
|
||||
const data = appstate.dataStatePart.getState();
|
||||
const items = selectedScope === 'project' ? data.projects : data.groups;
|
||||
return items.map((item: any) => ({ option: item.fullPath || item.name, key: item.id }));
|
||||
})()}
|
||||
></dees-input-dropdown>
|
||||
<dees-button @click=${() => {
|
||||
const modal = document.querySelector('dees-modal');
|
||||
if (!modal) return;
|
||||
const entityDropdowns = modal.shadowRoot?.querySelectorAll('dees-input-dropdown');
|
||||
let entityKey = '';
|
||||
let entityName = '';
|
||||
if (entityDropdowns) {
|
||||
for (const dd of entityDropdowns) {
|
||||
if ((dd as any).key === 'targetEntity') {
|
||||
const sel = (dd as any).selectedOption;
|
||||
if (sel) {
|
||||
entityKey = sel.key;
|
||||
entityName = sel.option;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!entityKey) return;
|
||||
const exists = targets.some(
|
||||
(t: any) => t.connectionId === selectedConnId && t.scope === selectedScope && t.scopeId === entityKey,
|
||||
);
|
||||
if (exists) return;
|
||||
targets.push({
|
||||
connectionId: selectedConnId,
|
||||
scope: selectedScope,
|
||||
scopeId: entityKey,
|
||||
scopeName: entityName,
|
||||
});
|
||||
}}>Add Target</dees-button>
|
||||
</div>
|
||||
<div class="target-list">
|
||||
${targets.length === 0
|
||||
? html`<p style="color: #888; font-size: 13px;">No targets.</p>`
|
||||
: targets.map((t: any, i: number) => html`
|
||||
<div class="target-item">
|
||||
<span>${t.scopeName} (${t.scope}) on ${this.getConnectionName(t.connectionId)}</span>
|
||||
<span class="remove-btn" @click=${() => { targets.splice(i, 1); }}>x</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||
{
|
||||
name: 'Update',
|
||||
action: async (modal: any) => {
|
||||
const inputs = modal.shadowRoot.querySelectorAll('dees-input-text');
|
||||
const data: any = {};
|
||||
for (const input of inputs) { data[input.key] = input.value || ''; }
|
||||
const updatePayload: any = {
|
||||
managedSecretId: item.id,
|
||||
targets,
|
||||
description: data.description || undefined,
|
||||
};
|
||||
if (data.value) {
|
||||
updatePayload.value = data.value;
|
||||
}
|
||||
await appstate.managedSecretsStatePart.dispatchAction(appstate.updateManagedSecretAction, updatePayload);
|
||||
modal.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
type TViewMode = 'current' | 'project' | 'group' | 'error';
|
||||
type TSortBy = 'created' | 'duration' | 'status';
|
||||
type TTimeRange = '1h' | '6h' | '1d' | '3d' | '7d' | '30d';
|
||||
|
||||
@customElement('gitops-view-pipelines')
|
||||
export class GitopsViewPipelines extends DeesElement {
|
||||
@state()
|
||||
@@ -29,13 +33,16 @@ export class GitopsViewPipelines extends DeesElement {
|
||||
currentJobLog: '',
|
||||
};
|
||||
|
||||
@state()
|
||||
accessor selectedConnectionId: string = '';
|
||||
|
||||
@state()
|
||||
accessor selectedProjectId: string = '';
|
||||
@state() accessor selectedConnectionId: string = '';
|
||||
@state() accessor selectedProjectId: string = '';
|
||||
@state() accessor selectedGroupId: string = '';
|
||||
@state() accessor viewMode: TViewMode = 'current';
|
||||
@state() accessor sortBy: TSortBy = 'created';
|
||||
@state() accessor timeRange: TTimeRange = '1d';
|
||||
@state() accessor isLoading: boolean = false;
|
||||
|
||||
private _autoRefreshHandler: () => void;
|
||||
private _logPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -56,6 +63,7 @@ export class GitopsViewPipelines extends DeesElement {
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
this.stopLogPolling();
|
||||
}
|
||||
|
||||
private handleAutoRefresh(): void {
|
||||
@@ -88,11 +96,40 @@ export class GitopsViewPipelines extends DeesElement {
|
||||
key: c.id,
|
||||
}));
|
||||
|
||||
const viewModeOptions = [
|
||||
{ option: 'Current', key: 'current' },
|
||||
{ option: 'Project', key: 'project' },
|
||||
{ option: 'Group', key: 'group' },
|
||||
{ option: 'Error', key: 'error' },
|
||||
];
|
||||
|
||||
const timeRangeOptions = [
|
||||
{ option: '1 hour', key: '1h' },
|
||||
{ option: '6 hours', key: '6h' },
|
||||
{ option: '1 day', key: '1d' },
|
||||
{ option: '3 days', key: '3d' },
|
||||
{ option: '7 days', key: '7d' },
|
||||
{ option: '30 days', key: '30d' },
|
||||
];
|
||||
|
||||
const sortByOptions = [
|
||||
{ option: 'Created', key: 'created' },
|
||||
{ option: 'Duration', key: 'duration' },
|
||||
{ option: 'Status', key: 'status' },
|
||||
];
|
||||
|
||||
const projectOptions = this.dataState.projects.map((p) => ({
|
||||
option: p.fullPath || p.name,
|
||||
key: p.id,
|
||||
}));
|
||||
|
||||
const groupOptions = this.dataState.groups.map((g) => ({
|
||||
option: g.fullPath || g.name,
|
||||
key: g.id,
|
||||
}));
|
||||
|
||||
const showMultiProjectColumns = this.viewMode !== 'project';
|
||||
|
||||
return html`
|
||||
<div class="view-title">Pipelines</div>
|
||||
<div class="view-description">View and manage CI/CD pipelines</div>
|
||||
@@ -103,15 +140,54 @@ export class GitopsViewPipelines extends DeesElement {
|
||||
.selectedOption=${connectionOptions.find((o) => o.key === this.selectedConnectionId) || connectionOptions[0]}
|
||||
@selectedOption=${(e: CustomEvent) => {
|
||||
this.selectedConnectionId = e.detail.key;
|
||||
this.loadProjects();
|
||||
this.onConnectionChange();
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
<dees-input-dropdown
|
||||
.label=${'Project'}
|
||||
.options=${projectOptions}
|
||||
.selectedOption=${projectOptions.find((o) => o.key === this.selectedProjectId) || projectOptions[0]}
|
||||
.label=${'View'}
|
||||
.options=${viewModeOptions}
|
||||
.selectedOption=${viewModeOptions.find((o) => o.key === this.viewMode)}
|
||||
@selectedOption=${(e: CustomEvent) => {
|
||||
this.selectedProjectId = e.detail.key;
|
||||
this.onViewModeChange(e.detail.key);
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
${this.viewMode === 'project' ? html`
|
||||
<dees-input-dropdown
|
||||
.label=${'Project'}
|
||||
.options=${projectOptions}
|
||||
.selectedOption=${projectOptions.find((o) => o.key === this.selectedProjectId) || projectOptions[0]}
|
||||
@selectedOption=${(e: CustomEvent) => {
|
||||
this.selectedProjectId = e.detail.key;
|
||||
this.loadPipelines();
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
` : ''}
|
||||
${this.viewMode === 'group' ? html`
|
||||
<dees-input-dropdown
|
||||
.label=${'Group'}
|
||||
.options=${groupOptions}
|
||||
.selectedOption=${groupOptions.find((o) => o.key === this.selectedGroupId) || groupOptions[0]}
|
||||
@selectedOption=${(e: CustomEvent) => {
|
||||
this.selectedGroupId = e.detail.key;
|
||||
this.loadPipelines();
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
` : ''}
|
||||
<dees-input-dropdown
|
||||
.label=${'Time'}
|
||||
.options=${timeRangeOptions}
|
||||
.selectedOption=${timeRangeOptions.find((o) => o.key === this.timeRange)}
|
||||
@selectedOption=${(e: CustomEvent) => {
|
||||
this.timeRange = e.detail.key;
|
||||
this.loadPipelines();
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
<dees-input-dropdown
|
||||
.label=${'Sort'}
|
||||
.options=${sortByOptions}
|
||||
.selectedOption=${sortByOptions.find((o) => o.key === this.sortBy)}
|
||||
@selectedOption=${(e: CustomEvent) => {
|
||||
this.sortBy = e.detail.key;
|
||||
this.loadPipelines();
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
@@ -119,21 +195,32 @@ export class GitopsViewPipelines extends DeesElement {
|
||||
</div>
|
||||
<dees-table
|
||||
.heading1=${'CI/CD Pipelines'}
|
||||
.heading2=${'Pipeline runs for the selected project'}
|
||||
.heading2=${this.isLoading ? 'Loading...' : `${this.dataState.pipelines.length} pipeline runs`}
|
||||
.data=${this.dataState.pipelines}
|
||||
.displayFunction=${(item: any) => ({
|
||||
ID: item.id,
|
||||
Status: item.status,
|
||||
Ref: item.ref,
|
||||
Duration: item.duration ? `${Math.round(item.duration)}s` : '-',
|
||||
Source: item.source,
|
||||
Created: item.createdAt ? new Date(item.createdAt).toLocaleString() : '-',
|
||||
})}
|
||||
.displayFunction=${(item: any) => {
|
||||
const row: any = {};
|
||||
row['ID'] = item.id;
|
||||
row['Status'] = item.status;
|
||||
if (showMultiProjectColumns) {
|
||||
row['Project'] = item.projectName;
|
||||
}
|
||||
row['Ref'] = item.ref;
|
||||
row['Duration'] = item.duration ? `${Math.round(item.duration)}s` : '-';
|
||||
row['Source'] = item.source;
|
||||
row['Created'] = item.createdAt ? new Date(item.createdAt).toLocaleString() : '-';
|
||||
return row;
|
||||
}}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'View Logs',
|
||||
iconName: 'lucide:terminal',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => { await this.openPipelineLogs(item); },
|
||||
},
|
||||
{
|
||||
name: 'View Jobs',
|
||||
iconName: 'lucide:list',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
type: ['contextmenu'],
|
||||
actionFunc: async ({ item }: any) => { await this.viewJobs(item); },
|
||||
},
|
||||
{
|
||||
@@ -142,22 +229,24 @@ export class GitopsViewPipelines extends DeesElement {
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => {
|
||||
await appstate.dataStatePart.dispatchAction(appstate.retryPipelineAction, {
|
||||
connectionId: this.selectedConnectionId,
|
||||
projectId: this.selectedProjectId,
|
||||
connectionId: item.connectionId || this.selectedConnectionId,
|
||||
projectId: item.projectId,
|
||||
pipelineId: item.id,
|
||||
});
|
||||
await this.loadPipelines();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:xCircle',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
type: ['contextmenu'],
|
||||
actionFunc: async ({ item }: any) => {
|
||||
await appstate.dataStatePart.dispatchAction(appstate.cancelPipelineAction, {
|
||||
connectionId: this.selectedConnectionId,
|
||||
projectId: this.selectedProjectId,
|
||||
connectionId: item.connectionId || this.selectedConnectionId,
|
||||
projectId: item.projectId,
|
||||
pipelineId: item.id,
|
||||
});
|
||||
await this.loadPipelines();
|
||||
},
|
||||
},
|
||||
]}
|
||||
@@ -173,6 +262,7 @@ export class GitopsViewPipelines extends DeesElement {
|
||||
if (navCtx?.connectionId && navCtx?.projectId) {
|
||||
this.selectedConnectionId = navCtx.connectionId;
|
||||
this.selectedProjectId = navCtx.projectId;
|
||||
this.viewMode = 'project';
|
||||
appstate.uiStatePart.dispatchAction(appstate.clearNavigationContextAction, null);
|
||||
await this.loadProjects();
|
||||
await this.loadPipelines();
|
||||
@@ -182,7 +272,39 @@ export class GitopsViewPipelines extends DeesElement {
|
||||
const conns = appstate.connectionsStatePart.getState().connections;
|
||||
if (conns.length > 0 && !this.selectedConnectionId) {
|
||||
this.selectedConnectionId = conns[0].id;
|
||||
// In 'current' mode, load pipelines immediately
|
||||
await this.loadPipelines();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data loading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async onConnectionChange() {
|
||||
this.selectedProjectId = '';
|
||||
this.selectedGroupId = '';
|
||||
if (this.viewMode === 'project') {
|
||||
await this.loadProjects();
|
||||
} else if (this.viewMode === 'group') {
|
||||
await this.loadGroups();
|
||||
} else {
|
||||
await this.loadPipelines();
|
||||
}
|
||||
}
|
||||
|
||||
private onViewModeChange(newMode: TViewMode) {
|
||||
this.stopLogPolling();
|
||||
this.viewMode = newMode;
|
||||
this.selectedProjectId = '';
|
||||
this.selectedGroupId = '';
|
||||
|
||||
if (newMode === 'current' || newMode === 'error') {
|
||||
this.loadPipelines();
|
||||
} else if (newMode === 'project') {
|
||||
this.loadProjects();
|
||||
} else if (newMode === 'group') {
|
||||
this.loadGroups();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,18 +315,221 @@ export class GitopsViewPipelines extends DeesElement {
|
||||
});
|
||||
}
|
||||
|
||||
private async loadPipelines() {
|
||||
if (!this.selectedConnectionId || !this.selectedProjectId) return;
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchPipelinesAction, {
|
||||
private async loadGroups() {
|
||||
if (!this.selectedConnectionId) return;
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchGroupsAction, {
|
||||
connectionId: this.selectedConnectionId,
|
||||
projectId: this.selectedProjectId,
|
||||
});
|
||||
}
|
||||
|
||||
private async loadPipelines() {
|
||||
if (!this.selectedConnectionId) return;
|
||||
|
||||
// For project mode, require a project selection
|
||||
if (this.viewMode === 'project' && !this.selectedProjectId) return;
|
||||
// For group mode, require a group selection
|
||||
if (this.viewMode === 'group' && !this.selectedGroupId) return;
|
||||
|
||||
this.isLoading = true;
|
||||
try {
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchPipelinesAction, {
|
||||
connectionId: this.selectedConnectionId,
|
||||
projectId: this.viewMode === 'project' ? this.selectedProjectId : undefined,
|
||||
viewMode: this.viewMode,
|
||||
groupId: this.viewMode === 'group' ? this.selectedGroupId : undefined,
|
||||
sortBy: this.sortBy,
|
||||
timeRange: this.timeRange,
|
||||
});
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pipeline log viewing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async openPipelineLogs(pipeline: any) {
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchPipelineJobsAction, {
|
||||
connectionId: pipeline.connectionId || this.selectedConnectionId,
|
||||
projectId: pipeline.projectId,
|
||||
pipelineId: pipeline.id,
|
||||
});
|
||||
|
||||
const jobs = appstate.dataStatePart.getState().pipelineJobs;
|
||||
let activeJobId: string | null = null;
|
||||
|
||||
const modal = await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: `Pipeline #${pipeline.id} - Logs`,
|
||||
content: html`
|
||||
<style>
|
||||
.log-container {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
min-height: 400px;
|
||||
max-height: 70vh;
|
||||
color: #ccc;
|
||||
font-family: 'Intel One Mono', 'Fira Code', monospace;
|
||||
}
|
||||
.job-list {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid #333;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.job-entry {
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #282828;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.job-entry:hover {
|
||||
background: #1a1a2e;
|
||||
}
|
||||
.job-entry.active {
|
||||
background: #1a2a3a;
|
||||
border-left: 3px solid #00acff;
|
||||
padding-left: 11px;
|
||||
}
|
||||
.job-name {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #eee;
|
||||
}
|
||||
.job-meta {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.job-status-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.job-status-badge.running { background: #1a2a3a; color: #00acff; }
|
||||
.job-status-badge.success { background: #1a3a1a; color: #00ff88; }
|
||||
.job-status-badge.failed { background: #3a1a1a; color: #ff4444; }
|
||||
.job-status-badge.pending { background: #3a3a1a; color: #ffaa00; }
|
||||
.job-status-badge.canceled { background: #2a2a2a; color: #999; }
|
||||
.log-output {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: #0d0d0d;
|
||||
padding: 12px;
|
||||
}
|
||||
.log-output pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: 'Intel One Mono', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #ccc;
|
||||
}
|
||||
.no-log {
|
||||
color: #666;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
<div class="log-container">
|
||||
<div class="job-list">
|
||||
${jobs.map((job: any) => html`
|
||||
<div
|
||||
class="job-entry"
|
||||
data-job-id="${job.id}"
|
||||
@click=${async (e: Event) => {
|
||||
// Update active state visually
|
||||
const container = (e.target as HTMLElement).closest('.log-container');
|
||||
container?.querySelectorAll('.job-entry').forEach((el: Element) => el.classList.remove('active'));
|
||||
(e.target as HTMLElement).closest('.job-entry')?.classList.add('active');
|
||||
activeJobId = job.id;
|
||||
await this.selectJobForLog(job, pipeline, container);
|
||||
}}
|
||||
>
|
||||
<div class="job-name">${job.name}</div>
|
||||
<div class="job-meta">
|
||||
<span class="job-status-badge ${job.status}">${job.status}</span>
|
||||
${job.stage ? ` ${job.stage}` : ''}
|
||||
${job.duration ? ` - ${Math.round(job.duration)}s` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
${jobs.length === 0 ? html`<div class="no-log">No jobs found.</div>` : ''}
|
||||
</div>
|
||||
<div class="log-output">
|
||||
<pre class="job-log-pre"><span class="no-log">Select a job to view its log.</span></pre>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Close',
|
||||
action: async (modalRef: any) => {
|
||||
this.stopLogPolling();
|
||||
modalRef.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async selectJobForLog(job: any, pipeline: any, container: Element | null) {
|
||||
this.stopLogPolling();
|
||||
|
||||
// Fetch initial log
|
||||
await this.fetchAndDisplayLog(job, pipeline, container);
|
||||
|
||||
// If job is running/pending, poll every 3 seconds
|
||||
if (job.status === 'running' || job.status === 'pending' || job.status === 'waiting') {
|
||||
this._logPollInterval = setInterval(async () => {
|
||||
await this.fetchAndDisplayLog(job, pipeline, container);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchAndDisplayLog(job: any, pipeline: any, container: Element | null) {
|
||||
try {
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchJobLogAction, {
|
||||
connectionId: pipeline.connectionId || this.selectedConnectionId,
|
||||
projectId: pipeline.projectId,
|
||||
jobId: job.id,
|
||||
});
|
||||
|
||||
const log = appstate.dataStatePart.getState().currentJobLog;
|
||||
const pre = container?.querySelector('.job-log-pre');
|
||||
if (pre) {
|
||||
pre.textContent = log || '(No output yet)';
|
||||
// Auto-scroll to bottom
|
||||
const logOutput = pre.closest('.log-output');
|
||||
if (logOutput) {
|
||||
logOutput.scrollTop = logOutput.scrollHeight;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch job log:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private stopLogPolling() {
|
||||
if (this._logPollInterval !== null) {
|
||||
clearInterval(this._logPollInterval);
|
||||
this._logPollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy job view (accessible via context menu)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async viewJobs(pipeline: any) {
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchPipelineJobsAction, {
|
||||
connectionId: this.selectedConnectionId,
|
||||
projectId: this.selectedProjectId,
|
||||
connectionId: pipeline.connectionId || this.selectedConnectionId,
|
||||
projectId: pipeline.projectId,
|
||||
pipelineId: pipeline.id,
|
||||
});
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ export class GitopsViewSync extends DeesElement {
|
||||
'Enforce Delete': item.enforceDelete ? 'Yes' : 'No',
|
||||
'Enforce Group Delete': item.enforceGroupDelete ? 'Yes' : 'No',
|
||||
'Mirror Hint': item.addMirrorHint ? 'Yes' : 'No',
|
||||
'Group Avatars': item.useGroupAvatarsForProjects ? 'Yes' : 'No',
|
||||
'Last Sync': item.lastSyncAt ? new Date(item.lastSyncAt).toLocaleString() : 'Never',
|
||||
Repos: String(item.reposSynced),
|
||||
};
|
||||
@@ -288,6 +289,9 @@ export class GitopsViewSync extends DeesElement {
|
||||
<div class="form-row">
|
||||
<dees-input-checkbox .label=${'Add Mirror Hint'} .key=${'addMirrorHint'} .value=${false} .description=${'When enabled, target descriptions get "(This is a mirror of ...)" appended.'}></dees-input-checkbox>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-checkbox .label=${'Group Avatars for Projects'} .key=${'useGroupAvatarsForProjects'} .value=${false} .description=${'When enabled, projects without their own avatar inherit the group avatar.'}></dees-input-checkbox>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||
@@ -299,7 +303,7 @@ export class GitopsViewSync extends DeesElement {
|
||||
for (const input of inputs) {
|
||||
if (input.key === 'sourceConnectionId' || input.key === 'targetConnectionId') {
|
||||
data[input.key] = input.selectedOption?.key || '';
|
||||
} else if (input.key === 'enforceDelete' || input.key === 'enforceGroupDelete' || input.key === 'addMirrorHint') {
|
||||
} else if (input.key === 'enforceDelete' || input.key === 'enforceGroupDelete' || input.key === 'addMirrorHint' || input.key === 'useGroupAvatarsForProjects') {
|
||||
data[input.key] = input.getValue();
|
||||
} else {
|
||||
data[input.key] = input.value || '';
|
||||
@@ -314,6 +318,7 @@ export class GitopsViewSync extends DeesElement {
|
||||
enforceDelete: !!data.enforceDelete,
|
||||
enforceGroupDelete: !!data.enforceGroupDelete,
|
||||
addMirrorHint: !!data.addMirrorHint,
|
||||
useGroupAvatarsForProjects: !!data.useGroupAvatarsForProjects,
|
||||
});
|
||||
modal.destroy();
|
||||
},
|
||||
@@ -345,6 +350,9 @@ export class GitopsViewSync extends DeesElement {
|
||||
<div class="form-row">
|
||||
<dees-input-checkbox .label=${'Add Mirror Hint'} .key=${'addMirrorHint'} .value=${!!item.addMirrorHint} .description=${'When enabled, target descriptions get "(This is a mirror of ...)" appended.'}></dees-input-checkbox>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-checkbox .label=${'Group Avatars for Projects'} .key=${'useGroupAvatarsForProjects'} .value=${!!item.useGroupAvatarsForProjects} .description=${'When enabled, projects without their own avatar inherit the group avatar.'}></dees-input-checkbox>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||
@@ -354,7 +362,7 @@ export class GitopsViewSync extends DeesElement {
|
||||
const inputs = modal.shadowRoot.querySelectorAll('dees-input-text, dees-input-checkbox');
|
||||
const data: any = {};
|
||||
for (const input of inputs) {
|
||||
if (input.key === 'enforceDelete' || input.key === 'enforceGroupDelete' || input.key === 'addMirrorHint') {
|
||||
if (input.key === 'enforceDelete' || input.key === 'enforceGroupDelete' || input.key === 'addMirrorHint' || input.key === 'useGroupAvatarsForProjects') {
|
||||
data[input.key] = input.getValue();
|
||||
} else {
|
||||
data[input.key] = input.value || '';
|
||||
@@ -368,6 +376,7 @@ export class GitopsViewSync extends DeesElement {
|
||||
enforceDelete: !!data.enforceDelete,
|
||||
enforceGroupDelete: !!data.enforceGroupDelete,
|
||||
addMirrorHint: !!data.addMirrorHint,
|
||||
useGroupAvatarsForProjects: !!data.useGroupAvatarsForProjects,
|
||||
});
|
||||
modal.destroy();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user