Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6260e90b09 | |||
| cd6a97dd2d | |||
| c3d50736cd | |||
| 423860c21c | |||
| 47e1463163 | |||
| a0b3032c22 | |||
| 56403224c0 | |||
| 75d35405dc | |||
| 78247c1d41 | |||
| 44ac2e430f | |||
| c9a758b417 | |||
| f7e16aa350 | |||
| 2f050744bc | |||
| 623ec4907c | |||
| 06eae3b443 | |||
| 44b418cbdd | |||
| 81ead52a72 | |||
| 630b2502f3 | |||
| e3f67d12a3 | |||
| 43131fa53c | |||
| 481b72b8fb | |||
| c9786591e3 | |||
| c5834f3cd1 | |||
| 179bb9223e | |||
| ee3f01993f | |||
| 15e845d5f8 | |||
| 0815e4c8ae | |||
| 7e6b774982 | |||
| 768bd1ef53 | |||
| 71176a1856 | |||
| b576056fa1 | |||
| 57935d6388 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,6 +7,8 @@ node_modules/
|
||||
|
||||
# Build outputs
|
||||
# ts_bundled/ is committed (embedded frontend bundle)
|
||||
ts_bundled/bundle.js
|
||||
ts_bundled/bundle.js.map
|
||||
|
||||
# Development
|
||||
.nogit/
|
||||
|
||||
105
changelog.md
105
changelog.md
@@ -1,5 +1,110 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
- Introduce SyncManager and wire it into GitopsApp (init/stop) with a new syncMirrorsPath
|
||||
- Add typedrequest SyncHandler with endpoints to create/update/delete/pause/trigger/preview sync configs and fetch repo statuses/logs
|
||||
- Add sync data interfaces (ISyncConfig, ISyncRepoStatus, ISyncLogEntry) and action log integration for sync operations
|
||||
- Add web UI: gitops-view-sync, appstate sync actions/selectors, and preview/status/modals for sync configs
|
||||
- Add groupFilter and groupFilterId to connection model; migrate legacy baseGroup/baseGroupId to groupFilter fields on load
|
||||
- Providers (Gitea/GitLab) and BaseProvider now accept groupFilterId and scope project/group listings accordingly (auto-pagination applies)
|
||||
- Logging: add sync log buffer, getSyncLogs API, and broadcast sync log entries to connected clients via TypedSocket; web client listens and displays entries
|
||||
- Update dependencies: bump @apiclient.xyz/gitea and gitlab versions and add @api.global/typedsocket
|
||||
- Connections UI: expose Group Filter field and pass through on create/update
|
||||
|
||||
## 2026-02-24 - 2.7.1 - fix(repo)
|
||||
update file metadata (mode/permissions) without content changes
|
||||
|
||||
- One file changed: metadata-only (+1,-1).
|
||||
- No source or behavior changes — safe to bump patch version.
|
||||
- Change likely involves file mode/permission or metadata update only.
|
||||
|
||||
## 2026-02-24 - 2.7.0 - feat(secrets)
|
||||
add ability to fetch and view all secrets across projects and groups, include scopeName, and improve frontend merging/filtering
|
||||
|
||||
- Add new typed request and handler getAllSecrets to opsserver to bulk-fetch secrets across projects or groups (batched and using Promise.allSettled for performance).
|
||||
- Extend ISecret with scopeName and update provider mappings (Gitea/GitLab) and secret return values to include scopeName.
|
||||
- Frontend: add fetchAllSecretsAction, add an "All" option in the Secrets view, filter table by selected entity or show all, and disable "Add Secret" when "All" is selected.
|
||||
- Create/update actions now merge only the affected entity's secrets into state instead of replacing the entire list; delete now filters by key+scope+scopeId to avoid removing unrelated secrets.
|
||||
- UI: table now shows a Scope column using scopeName (or fallback to scopeId), selection changes trigger reloading of entities and secrets.
|
||||
|
||||
## 2026-02-24 - 2.6.2 - fix(meta)
|
||||
update file metadata only (no source changes)
|
||||
|
||||
- One file changed: metadata-only (e.g. permissions/mode) with no content modifications.
|
||||
- No code, dependency, or API changes detected; safe patch release recommended.
|
||||
- Bump patch version from 2.6.1 to 2.6.2.
|
||||
|
||||
## 2026-02-24 - 2.6.1 - fix(package.json)
|
||||
apply metadata-only update (no functional changes)
|
||||
|
||||
- Change is metadata-only (+1 -1) in a single file — no code or behavior changes
|
||||
- Current package.json version is 2.6.0; recommend a patch bump to 2.6.1
|
||||
|
||||
## 2026-02-24 - 2.6.0 - feat(webhook)
|
||||
add webhook endpoint and client push notifications, auto-refresh UI, and gitea id mapping fixes
|
||||
|
||||
- Add WebhookHandler with POST /webhook/:connectionId that parses provider-specific headers and broadcasts webhookNotification via TypedSocket to connected clients
|
||||
- Frontend: add auto-refresh toggle, refresh-interval action, dashboard auto-refresh timer, and views subscribing to gitops-auto-refresh events to refresh data
|
||||
- Frontend: add WebSocket client with reconnect logic to receive push notifications and trigger auto-refresh on webhook events
|
||||
- Gitea provider: prefer repository full_name and organization name when mapping project and group ids to ensure stable identifiers
|
||||
- Bump devDependencies: @git.zone/tsbundle ^2.9.0 and @git.zone/tswatch ^3.2.0
|
||||
- Add ts_bundled/bundle.js and bundle.js.map to .gitignore
|
||||
|
||||
## 2026-02-24 - 2.5.0 - feat(gitea-provider)
|
||||
auto-paginate Gitea repository and organization listing; respect explicit page option and default perPage to 50
|
||||
|
||||
- getProjects and getGroups now auto-fetch all pages when opts.page is not provided
|
||||
- When opts.page is provided, the provider respects it and does not auto-paginate
|
||||
- Defaults perPage to 50 for paginated requests
|
||||
- Dependency @design.estate/dees-catalog bumped from ^3.43.0 to ^3.43.3
|
||||
|
||||
## 2026-02-24 - 2.4.0 - feat(opsserver)
|
||||
serve embedded frontend bundle from committed ts_bundled instead of using external dist_serve directory
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/gitops",
|
||||
"version": "2.4.0",
|
||||
"version": "2.12.0",
|
||||
"exports": "./mod.ts",
|
||||
"nodeModulesDir": "auto",
|
||||
"tasks": {
|
||||
@@ -16,8 +16,8 @@
|
||||
"@api.global/typedserver": "npm:@api.global/typedserver@^8.3.1",
|
||||
"@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.0.3",
|
||||
"@apiclient.xyz/gitlab": "npm:@apiclient.xyz/gitlab@^2.0.3",
|
||||
"@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"
|
||||
|
||||
@@ -17,14 +17,16 @@
|
||||
"from": "./ts_web/index.ts",
|
||||
"to": "./ts_bundled/bundle.ts",
|
||||
"outputMode": "base64ts",
|
||||
"watchPatterns": ["./ts_web/**/*"],
|
||||
"triggerReload": true
|
||||
"bundler": "esbuild",
|
||||
"production": true,
|
||||
"watchPatterns": ["./ts_web/**/*", "./html/**/*"],
|
||||
"includeFiles": [{"from": "./html/index.html", "to": "index.html"}]
|
||||
}
|
||||
],
|
||||
"watchers": [
|
||||
{
|
||||
"name": "backend",
|
||||
"watch": "./ts/**/*",
|
||||
"watch": ["./ts/**/*", "./ts_interfaces/**/*", "./ts_bundled/**/*"],
|
||||
"command": "deno run --allow-all mod.ts server",
|
||||
"restart": true,
|
||||
"debounce": 500,
|
||||
|
||||
12
package.json
12
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/gitops",
|
||||
"version": "2.4.0",
|
||||
"version": "2.12.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,11 +14,15 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@design.estate/dees-catalog": "^3.43.0",
|
||||
"@api.global/typedserver": "8.4.0",
|
||||
"@api.global/typedsocket": "^4.1.0",
|
||||
"@apiclient.xyz/gitea": "1.5.0",
|
||||
"@apiclient.xyz/gitlab": "2.6.0",
|
||||
"@design.estate/dees-catalog": "^3.43.3",
|
||||
"@design.estate/dees-element": "^2.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbundle": "^2.8.4",
|
||||
"@git.zone/tswatch": "^3.1.0"
|
||||
"@git.zone/tsbundle": "^2.9.0",
|
||||
"@git.zone/tswatch": "^3.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
pipelines-current-mode.png
Normal file
BIN
pipelines-current-mode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
244
readme.md
Normal file
244
readme.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# @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.
|
||||
|
||||
## 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
|
||||
|
||||
- **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
|
||||
- **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
|
||||
- **Embedded SPA** — Frontend is bundled (base64-encoded) and served from memory, no static file server needed
|
||||
|
||||
## 📦 Install
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Deno](https://deno.land/) v2+
|
||||
- [pnpm](https://pnpm.io/) (for frontend deps and bundling)
|
||||
- MongoDB-compatible database (auto-provisioned via `@push.rocks/smartmongo` / `LocalTsmDb`)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://code.foss.global/serve.zone/gitops.git
|
||||
cd gitops
|
||||
|
||||
# Install frontend dependencies
|
||||
pnpm install
|
||||
|
||||
# Build the frontend bundle
|
||||
pnpm build
|
||||
|
||||
# Start the server
|
||||
deno run --allow-all mod.ts server
|
||||
```
|
||||
|
||||
The app will be available at `http://localhost:3000`.
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
All configuration is done through environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `GITOPS_PORT` | `3000` | HTTP/WebSocket server port |
|
||||
| `GITOPS_ADMIN_USERNAME` | `admin` | Admin login username |
|
||||
| `GITOPS_ADMIN_PASSWORD` | `admin` | Admin login password |
|
||||
|
||||
Data is stored at `~/.serve.zone/gitops/`:
|
||||
|
||||
```
|
||||
~/.serve.zone/gitops/
|
||||
├── storage/ # Connection configs (JSON, tokens replaced with keychain refs)
|
||||
│ └── connections/ # One file per connection
|
||||
└── tsmdb/ # Embedded MongoDB data (cached secrets, projects)
|
||||
```
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ GitOps App │
|
||||
├──────────┬───────────────┬───────────────────────────┤
|
||||
│ OpsServer│ ConnectionMgr │ SecretsScanService │
|
||||
│ (HTTP/WS)│ (Providers) │ (24h background scan) │
|
||||
├──────────┤ ├───────────────────────────┤
|
||||
│ Handlers │ GiteaProvider│ CacheDb │
|
||||
│ (9 total)│ GitLabProvider│ (LocalTsmDb + SmartdataDb)│
|
||||
├──────────┴───────────────┴───────────────────────────┤
|
||||
│ StorageManager │
|
||||
│ (filesystem key-value store) │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ SmartSecret │
|
||||
│ (OS keychain / encrypted file) │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Frontend SPA │
|
||||
│ Lit + dees-catalog + smartstate │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ Dashboard │ 8 Views │ WebSocket Client │ Auto-Refresh│
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Backend (`ts/`)
|
||||
|
||||
- **`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:
|
||||
- `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
|
||||
- `LogsHandler` — Job log fetch
|
||||
- `WebhookHandler` — Custom HTTP route for incoming webhooks
|
||||
- `ActionsHandler` — Force scan / scan 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.
|
||||
- **`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
|
||||
- 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.)
|
||||
- `requests/` — TypedRequest interfaces for all RPC endpoints
|
||||
|
||||
## 🔌 API
|
||||
|
||||
All endpoints use [TypedRequest](https://code.foss.global/api.global/typedrequest) — a typed RPC protocol over HTTP POST to `/typedrequest`.
|
||||
|
||||
### Authentication
|
||||
|
||||
```typescript
|
||||
// Login → returns JWT identity
|
||||
{ method: 'adminLogin', request: { username, password } }
|
||||
// → { identity: { jwt, userId, role, expiresAt } }
|
||||
|
||||
// All other requests require identity
|
||||
{ method: 'getProjects', request: { identity, connectionId } }
|
||||
```
|
||||
|
||||
### Connections
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `getConnections` | List all connections (tokens masked) |
|
||||
| `createConnection` | Add a new Gitea/GitLab connection |
|
||||
| `updateConnection` | Update connection name/URL/token |
|
||||
| `testConnection` | Verify connection is reachable |
|
||||
| `deleteConnection` | Remove a connection |
|
||||
|
||||
### Data
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `getProjects` | List projects (with search/pagination) |
|
||||
| `getGroups` | List groups/orgs (with search/pagination) |
|
||||
| `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 |
|
||||
| `getPipelineJobs` | List jobs for a pipeline |
|
||||
| `retryPipeline` / `cancelPipeline` | Pipeline actions |
|
||||
| `getJobLog` | Fetch raw build log for a job |
|
||||
|
||||
### Actions
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `forceScanSecrets` | Trigger immediate full secrets scan |
|
||||
| `getScanStatus` | Get scan status, last result, timestamp |
|
||||
|
||||
### Webhooks
|
||||
|
||||
```bash
|
||||
# Register this URL in your Gitea/GitLab webhook settings
|
||||
POST http://your-server:3000/webhook/<connectionId>
|
||||
```
|
||||
|
||||
Events are parsed from `X-Gitea-Event` / `X-Gitlab-Event` headers and broadcast to all connected WebSocket clients as `webhookNotification`.
|
||||
|
||||
## 🧪 Development
|
||||
|
||||
```bash
|
||||
# Watch mode — auto-rebuilds frontend + restarts backend on changes
|
||||
pnpm run watch
|
||||
|
||||
# Run tests (Deno)
|
||||
pnpm test
|
||||
|
||||
# Build frontend bundle only
|
||||
pnpm build
|
||||
|
||||
# Start server directly
|
||||
deno run --allow-all mod.ts server
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
gitops/
|
||||
├── mod.ts # Entry point
|
||||
├── deno.json # Deno config + import map
|
||||
├── package.json # npm metadata + scripts
|
||||
├── npmextra.json # tsbundle + tswatch config
|
||||
├── html/index.html # HTML shell
|
||||
├── ts/ # Backend
|
||||
│ ├── classes/ # GitopsApp, ConnectionManager
|
||||
│ ├── providers/ # BaseProvider, GiteaProvider, GitLabProvider
|
||||
│ ├── storage/ # StorageManager
|
||||
│ ├── cache/ # CacheDb, CacheCleaner, SecretsScanService
|
||||
│ │ └── documents/ # CachedProject, CachedSecret
|
||||
│ └── opsserver/ # OpsServer + 9 handlers
|
||||
│ ├── handlers/ # AdminHandler, SecretsHandler, etc.
|
||||
│ └── helpers/ # Guards (JWT verification)
|
||||
├── ts_interfaces/ # Shared TypeScript types
|
||||
│ ├── data/ # IProject, ISecret, IPipeline, etc.
|
||||
│ └── requests/ # TypedRequest interfaces
|
||||
├── ts_web/ # Frontend SPA
|
||||
│ ├── appstate.ts # Smartstate store + actions
|
||||
│ └── elements/ # Lit web components
|
||||
│ └── views/ # 8 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.
|
||||
|
||||
**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.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
3
readme.todo.md
Normal file
3
readme.todo.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# GitOps TODOs
|
||||
|
||||
- [ ] Webhook HMAC signature verification (X-Gitea-Signature / X-Gitlab-Token) — currently accepts all POSTs
|
||||
@@ -136,4 +136,8 @@ Deno.test('ConnectionManager with StorageManager: create and load', async () =>
|
||||
const conns = cm2.getConnections();
|
||||
assertEquals(conns.length, 1);
|
||||
assertEquals(conns[0].id, conn.id);
|
||||
|
||||
// Wait for background health checks to avoid resource leaks
|
||||
await cm.healthCheckDone;
|
||||
await cm2.healthCheckDone;
|
||||
});
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/gitops',
|
||||
version: '2.4.0',
|
||||
version: '2.12.0',
|
||||
description: 'GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs'
|
||||
}
|
||||
|
||||
267
ts/cache/classes.secrets.scan.service.ts
vendored
Normal file
267
ts/cache/classes.secrets.scan.service.ts
vendored
Normal file
@@ -0,0 +1,267 @@
|
||||
import { logger } from '../logging.ts';
|
||||
import type { ConnectionManager } from '../classes/connectionmanager.ts';
|
||||
import { CachedSecret } from './documents/classes.cached.secret.ts';
|
||||
import { TTL } from './classes.cached.document.ts';
|
||||
import type { ISecret } from '../../ts_interfaces/data/secret.ts';
|
||||
|
||||
export interface IScanResult {
|
||||
connectionsScanned: number;
|
||||
secretsFound: number;
|
||||
errors: string[];
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized secrets scanning service. Fetches all secrets from all
|
||||
* connections and upserts them into the CachedSecret collection.
|
||||
*/
|
||||
export class SecretsScanService {
|
||||
public lastScanTimestamp: number = 0;
|
||||
public lastScanResult: IScanResult | null = null;
|
||||
public isScanning: boolean = false;
|
||||
|
||||
private connectionManager: ConnectionManager;
|
||||
|
||||
constructor(connectionManager: ConnectionManager) {
|
||||
this.connectionManager = connectionManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a single secret into the cache. If a doc with the same composite ID
|
||||
* already exists, update it in place; otherwise insert a new one.
|
||||
*/
|
||||
private async upsertSecret(secret: ISecret): Promise<void> {
|
||||
const id = CachedSecret.buildId(secret.connectionId, secret.scope, secret.scopeId, secret.key);
|
||||
const existing = await CachedSecret.getInstance({ id });
|
||||
if (existing) {
|
||||
existing.value = secret.value;
|
||||
existing.protected = secret.protected;
|
||||
existing.masked = secret.masked;
|
||||
existing.environment = secret.environment;
|
||||
existing.scopeName = secret.scopeName;
|
||||
existing.setTTL(TTL.HOURS_24);
|
||||
await existing.save();
|
||||
} else {
|
||||
const doc = CachedSecret.fromISecret(secret);
|
||||
await doc.save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an array of secrets to cache using upsert logic.
|
||||
* Best-effort: individual failures are silently ignored.
|
||||
*/
|
||||
async saveSecrets(secrets: ISecret[]): Promise<void> {
|
||||
for (const secret of secrets) {
|
||||
try {
|
||||
await this.upsertSecret(secret);
|
||||
} catch {
|
||||
// Best-effort caching
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full scan: iterate all connections, fetch all projects+groups,
|
||||
* fetch all secrets per entity, upsert CachedSecret docs.
|
||||
*/
|
||||
async fullScan(): Promise<IScanResult> {
|
||||
if (this.isScanning) {
|
||||
return {
|
||||
connectionsScanned: 0,
|
||||
secretsFound: 0,
|
||||
errors: ['Scan already in progress'],
|
||||
durationMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
this.isScanning = true;
|
||||
const startTime = Date.now();
|
||||
const errors: string[] = [];
|
||||
let totalSecrets = 0;
|
||||
let connectionsScanned = 0;
|
||||
|
||||
try {
|
||||
const connections = this.connectionManager.getConnections();
|
||||
for (const conn of connections) {
|
||||
if (conn.status === 'paused') continue;
|
||||
try {
|
||||
const provider = this.connectionManager.getProvider(conn.id);
|
||||
connectionsScanned++;
|
||||
|
||||
// Scan project secrets
|
||||
try {
|
||||
const projects = await provider.getProjects();
|
||||
for (let i = 0; i < projects.length; i += 5) {
|
||||
const batch = projects.slice(i, i + 5);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (p) => {
|
||||
const secrets = await provider.getProjectSecrets(p.id);
|
||||
return secrets.map((s) => ({
|
||||
...s,
|
||||
scope: 'project' as const,
|
||||
scopeId: p.id,
|
||||
scopeName: p.fullPath || p.name,
|
||||
connectionId: conn.id,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
for (const secret of result.value) {
|
||||
try {
|
||||
await this.upsertSecret(secret);
|
||||
totalSecrets++;
|
||||
} catch (err) {
|
||||
errors.push(`Save secret ${secret.key}: ${err}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errors.push(`Fetch project secrets: ${result.reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(`Fetch projects for ${conn.id}: ${err}`);
|
||||
}
|
||||
|
||||
// Scan group secrets
|
||||
try {
|
||||
const groups = await provider.getGroups();
|
||||
for (let i = 0; i < groups.length; i += 5) {
|
||||
const batch = groups.slice(i, i + 5);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (g) => {
|
||||
const secrets = await provider.getGroupSecrets(g.id);
|
||||
return secrets.map((s) => ({
|
||||
...s,
|
||||
scope: 'group' as const,
|
||||
scopeId: g.id,
|
||||
scopeName: g.fullPath || g.name,
|
||||
connectionId: conn.id,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
for (const secret of result.value) {
|
||||
try {
|
||||
await this.upsertSecret(secret);
|
||||
totalSecrets++;
|
||||
} catch (err) {
|
||||
errors.push(`Save secret ${secret.key}: ${err}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errors.push(`Fetch group secrets: ${result.reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(`Fetch groups for ${conn.id}: ${err}`);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(`Connection ${conn.id}: ${err}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.isScanning = false;
|
||||
}
|
||||
|
||||
const result: IScanResult = {
|
||||
connectionsScanned,
|
||||
secretsFound: totalSecrets,
|
||||
errors,
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
|
||||
this.lastScanTimestamp = Date.now();
|
||||
this.lastScanResult = result;
|
||||
|
||||
logger.info(
|
||||
`Secrets scan complete: ${totalSecrets} secrets from ${connectionsScanned} connections in ${result.durationMs}ms` +
|
||||
(errors.length > 0 ? ` (${errors.length} errors)` : ''),
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a single entity: delete existing cached secrets for that entity,
|
||||
* fetch fresh from provider, and save to cache.
|
||||
*/
|
||||
async scanEntity(
|
||||
connectionId: string,
|
||||
scope: 'project' | 'group',
|
||||
scopeId: string,
|
||||
scopeName?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Delete existing cached secrets for this entity
|
||||
const existing = await CachedSecret.getInstances({
|
||||
connectionId,
|
||||
scope,
|
||||
scopeId,
|
||||
});
|
||||
for (const doc of existing) {
|
||||
await doc.delete();
|
||||
}
|
||||
|
||||
// Fetch fresh from provider
|
||||
const provider = this.connectionManager.getProvider(connectionId);
|
||||
const secrets = scope === 'project'
|
||||
? await provider.getProjectSecrets(scopeId)
|
||||
: await provider.getGroupSecrets(scopeId);
|
||||
|
||||
// Save to cache
|
||||
for (const s of secrets) {
|
||||
const doc = CachedSecret.fromISecret({
|
||||
...s,
|
||||
scope,
|
||||
scopeId,
|
||||
scopeName: scopeName || s.scopeName || '',
|
||||
connectionId,
|
||||
});
|
||||
await doc.save();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`scanEntity failed for ${connectionId}/${scope}/${scopeId}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached secrets matching the filter criteria.
|
||||
*/
|
||||
async getCachedSecrets(filter: {
|
||||
connectionId: string;
|
||||
scope: 'project' | 'group';
|
||||
scopeId?: string;
|
||||
}): Promise<ISecret[]> {
|
||||
// deno-lint-ignore no-explicit-any
|
||||
const query: any = {
|
||||
connectionId: filter.connectionId,
|
||||
scope: filter.scope,
|
||||
};
|
||||
if (filter.scopeId) {
|
||||
query.scopeId = filter.scopeId;
|
||||
}
|
||||
const docs = await CachedSecret.getInstances(query);
|
||||
// Filter out expired docs
|
||||
const now = Date.now();
|
||||
return docs
|
||||
.filter((d) => d.expiresAt > now)
|
||||
.map((d) => d.toISecret());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if non-expired cached data exists for the given connection+scope.
|
||||
*/
|
||||
async hasCachedData(connectionId: string, scope: 'project' | 'group'): Promise<boolean> {
|
||||
const docs = await CachedSecret.getInstances({
|
||||
connectionId,
|
||||
scope,
|
||||
expiresAt: { $gt: Date.now() },
|
||||
});
|
||||
return docs.length > 0;
|
||||
}
|
||||
}
|
||||
81
ts/cache/documents/classes.cached.secret.ts
vendored
Normal file
81
ts/cache/documents/classes.cached.secret.ts
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import { CacheDb } from '../classes.cachedb.ts';
|
||||
import { CachedDocument, TTL } from '../classes.cached.document.ts';
|
||||
import type { ISecret } from '../../../ts_interfaces/data/secret.ts';
|
||||
|
||||
/**
|
||||
* Cached secret data from git providers. TTL: 24 hours.
|
||||
*/
|
||||
@plugins.smartdata.Collection(() => CacheDb.getInstance().getDb())
|
||||
export class CachedSecret extends CachedDocument<CachedSecret> {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public connectionId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public scope: 'project' | 'group' = 'project';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public scopeId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public scopeName: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public key: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public value: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public protected: boolean = false;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public masked: boolean = false;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public environment: string = '';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.setTTL(TTL.HOURS_24);
|
||||
}
|
||||
|
||||
/** Build the composite unique ID */
|
||||
static buildId(connectionId: string, scope: string, scopeId: string, key: string): string {
|
||||
return `${connectionId}:${scope}:${scopeId}:${key}`;
|
||||
}
|
||||
|
||||
/** Create a CachedSecret from an ISecret */
|
||||
static fromISecret(secret: ISecret): CachedSecret {
|
||||
const doc = new CachedSecret();
|
||||
doc.id = CachedSecret.buildId(secret.connectionId, secret.scope, secret.scopeId, secret.key);
|
||||
doc.connectionId = secret.connectionId;
|
||||
doc.scope = secret.scope;
|
||||
doc.scopeId = secret.scopeId;
|
||||
doc.scopeName = secret.scopeName;
|
||||
doc.key = secret.key;
|
||||
doc.value = secret.value;
|
||||
doc.protected = secret.protected;
|
||||
doc.masked = secret.masked;
|
||||
doc.environment = secret.environment;
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** Convert back to ISecret */
|
||||
toISecret(): ISecret {
|
||||
return {
|
||||
connectionId: this.connectionId,
|
||||
scope: this.scope,
|
||||
scopeId: this.scopeId,
|
||||
scopeName: this.scopeName,
|
||||
key: this.key,
|
||||
value: this.value,
|
||||
protected: this.protected,
|
||||
masked: this.masked,
|
||||
environment: this.environment,
|
||||
};
|
||||
}
|
||||
}
|
||||
1
ts/cache/documents/index.ts
vendored
1
ts/cache/documents/index.ts
vendored
@@ -1 +1,2 @@
|
||||
export { CachedProject } from './classes.cached.project.ts';
|
||||
export { CachedSecret } from './classes.cached.secret.ts';
|
||||
|
||||
2
ts/cache/index.ts
vendored
2
ts/cache/index.ts
vendored
@@ -2,4 +2,6 @@ export { CacheDb } from './classes.cachedb.ts';
|
||||
export type { ICacheDbOptions } from './classes.cachedb.ts';
|
||||
export { CachedDocument, TTL } from './classes.cached.document.ts';
|
||||
export { CacheCleaner } from './classes.cache.cleaner.ts';
|
||||
export { SecretsScanService } from './classes.secrets.scan.service.ts';
|
||||
export type { IScanResult } from './classes.secrets.scan.service.ts';
|
||||
export * from './documents/index.ts';
|
||||
|
||||
57
ts/classes/actionlog.ts
Normal file
57
ts/classes/actionlog.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { logger } from '../logging.ts';
|
||||
import type * as interfaces from '../../ts_interfaces/index.ts';
|
||||
import type { StorageManager } from '../storage/index.ts';
|
||||
|
||||
const ACTIONLOG_PREFIX = '/actionlog/';
|
||||
|
||||
/**
|
||||
* Persists and queries action log entries via StorageManager.
|
||||
* Entries are stored as individual JSON files keyed by timestamp-id.
|
||||
*/
|
||||
export class ActionLog {
|
||||
private storageManager: StorageManager;
|
||||
|
||||
constructor(storageManager: StorageManager) {
|
||||
this.storageManager = storageManager;
|
||||
}
|
||||
|
||||
async append(entry: Omit<interfaces.data.IActionLogEntry, 'id' | 'timestamp'>): Promise<interfaces.data.IActionLogEntry> {
|
||||
const full: interfaces.data.IActionLogEntry = {
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
...entry,
|
||||
};
|
||||
const key = `${ACTIONLOG_PREFIX}${String(full.timestamp).padStart(16, '0')}-${full.id}.json`;
|
||||
await this.storageManager.setJSON(key, full);
|
||||
logger.debug(`Action logged: ${full.actionType} ${full.entityType} "${full.entityName}"`);
|
||||
return full;
|
||||
}
|
||||
|
||||
async query(opts: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
entityType?: interfaces.data.TActionEntity;
|
||||
} = {}): Promise<{ entries: interfaces.data.IActionLogEntry[]; total: number }> {
|
||||
const limit = opts.limit ?? 50;
|
||||
const offset = opts.offset ?? 0;
|
||||
|
||||
const keys = await this.storageManager.list(ACTIONLOG_PREFIX);
|
||||
// Sort by key descending (newest first — keys are timestamp-prefixed)
|
||||
keys.sort((a, b) => b.localeCompare(a));
|
||||
|
||||
// Load all entries (or filter by entityType)
|
||||
let entries: interfaces.data.IActionLogEntry[] = [];
|
||||
for (const key of keys) {
|
||||
const entry = await this.storageManager.getJSON<interfaces.data.IActionLogEntry>(key);
|
||||
if (entry) {
|
||||
if (opts.entityType && entry.entityType !== opts.entityType) continue;
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
const total = entries.length;
|
||||
entries = entries.slice(offset, offset + limit);
|
||||
|
||||
return { entries, total };
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export class ConnectionManager {
|
||||
private connections: interfaces.data.IProviderConnection[] = [];
|
||||
private storageManager: StorageManager;
|
||||
private smartSecret: plugins.smartsecret.SmartSecret;
|
||||
/** Resolves when background connection health checks complete */
|
||||
public healthCheckDone: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(storageManager: StorageManager, smartSecret: plugins.smartsecret.SmartSecret) {
|
||||
this.storageManager = storageManager;
|
||||
@@ -26,6 +28,26 @@ export class ConnectionManager {
|
||||
async init(): Promise<void> {
|
||||
await this.migrateLegacyFile();
|
||||
await this.loadConnections();
|
||||
// Auto-test all connections in the background
|
||||
this.healthCheckDone = this.testAllConnections();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests all loaded connections in the background and updates their status.
|
||||
* Fire-and-forget — does not block startup.
|
||||
*/
|
||||
private async testAllConnections(): Promise<void> {
|
||||
for (const conn of this.connections) {
|
||||
if (conn.status === 'paused') continue;
|
||||
try {
|
||||
const provider = this.getProvider(conn.id);
|
||||
const result = await provider.testConnection();
|
||||
conn.status = result.ok ? 'connected' : 'error';
|
||||
await this.persistConnection(conn);
|
||||
} catch {
|
||||
conn.status = 'error';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +77,15 @@ export class ConnectionManager {
|
||||
for (const key of keys) {
|
||||
const conn = await this.storageManager.getJSON<interfaces.data.IProviderConnection>(key);
|
||||
if (conn) {
|
||||
// Migrate legacy baseGroup/baseGroupId property names
|
||||
if ((conn as any).baseGroup !== undefined && conn.groupFilter === undefined) {
|
||||
conn.groupFilter = (conn as any).baseGroup;
|
||||
delete (conn as any).baseGroup;
|
||||
}
|
||||
if ((conn as any).baseGroupId !== undefined && conn.groupFilterId === undefined) {
|
||||
conn.groupFilterId = (conn as any).baseGroupId;
|
||||
delete (conn as any).baseGroupId;
|
||||
}
|
||||
if (conn.token.startsWith(KEYCHAIN_PREFIX)) {
|
||||
// Token is in keychain — retrieve it
|
||||
const realToken = await this.smartSecret.getSecret(conn.id);
|
||||
@@ -120,6 +151,7 @@ export class ConnectionManager {
|
||||
providerType: interfaces.data.TProviderType,
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
groupFilter?: string,
|
||||
): Promise<interfaces.data.IProviderConnection> {
|
||||
const connection: interfaces.data.IProviderConnection = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -129,6 +161,7 @@ export class ConnectionManager {
|
||||
token,
|
||||
createdAt: Date.now(),
|
||||
status: 'disconnected',
|
||||
groupFilter: groupFilter || undefined,
|
||||
};
|
||||
this.connections.push(connection);
|
||||
await this.persistConnection(connection);
|
||||
@@ -138,13 +171,17 @@ export class ConnectionManager {
|
||||
|
||||
async updateConnection(
|
||||
id: string,
|
||||
updates: { name?: string; baseUrl?: string; token?: string },
|
||||
updates: { name?: string; baseUrl?: string; token?: string; groupFilter?: string },
|
||||
): Promise<interfaces.data.IProviderConnection> {
|
||||
const conn = this.connections.find((c) => c.id === id);
|
||||
if (!conn) throw new Error(`Connection not found: ${id}`);
|
||||
if (updates.name) conn.name = updates.name;
|
||||
if (updates.baseUrl) conn.baseUrl = updates.baseUrl.replace(/\/+$/, '');
|
||||
if (updates.token) conn.token = updates.token;
|
||||
if (updates.groupFilter !== undefined) {
|
||||
conn.groupFilter = updates.groupFilter || undefined;
|
||||
conn.groupFilterId = undefined; // Will be re-resolved on next test
|
||||
}
|
||||
await this.persistConnection(conn);
|
||||
return { ...conn, token: '***' };
|
||||
}
|
||||
@@ -157,15 +194,56 @@ export class ConnectionManager {
|
||||
logger.info(`Connection deleted: ${id}`);
|
||||
}
|
||||
|
||||
async pauseConnection(id: string, paused: boolean): Promise<interfaces.data.IProviderConnection> {
|
||||
const conn = this.connections.find((c) => c.id === id);
|
||||
if (!conn) throw new Error(`Connection not found: ${id}`);
|
||||
conn.status = paused ? 'paused' : 'disconnected';
|
||||
await this.persistConnection(conn);
|
||||
logger.info(`Connection ${paused ? 'paused' : 'resumed'}: ${conn.name}`);
|
||||
return { ...conn, token: '***' };
|
||||
}
|
||||
|
||||
async testConnection(id: string): Promise<{ ok: boolean; error?: string }> {
|
||||
const conn = this.connections.find((c) => c.id === id)!;
|
||||
if (conn.status === 'paused') {
|
||||
return { ok: false, error: 'Connection is paused' };
|
||||
}
|
||||
const provider = this.getProvider(id);
|
||||
const result = await provider.testConnection();
|
||||
const conn = this.connections.find((c) => c.id === id)!;
|
||||
conn.status = result.ok ? 'connected' : 'error';
|
||||
// Resolve group filter ID if connection has a groupFilter
|
||||
if (result.ok && conn.groupFilter) {
|
||||
await this.resolveGroupFilterId(conn);
|
||||
}
|
||||
await this.persistConnection(conn);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a human-readable groupFilter to the provider-specific group ID.
|
||||
*/
|
||||
private async resolveGroupFilterId(conn: interfaces.data.IProviderConnection): Promise<void> {
|
||||
if (!conn.groupFilter) {
|
||||
conn.groupFilterId = undefined;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (conn.providerType === 'gitlab') {
|
||||
const gitlabClient = new plugins.gitlabClient.GitLabClient(conn.baseUrl, conn.token);
|
||||
const group = await gitlabClient.getGroup(conn.groupFilter);
|
||||
conn.groupFilterId = String(group.id);
|
||||
logger.info(`Resolved group filter "${conn.groupFilter}" to ID ${conn.groupFilterId}`);
|
||||
} else {
|
||||
// For Gitea, the org name IS the ID
|
||||
conn.groupFilterId = conn.groupFilter;
|
||||
logger.info(`Group filter for Gitea connection set to org "${conn.groupFilterId}"`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to resolve group filter "${conn.groupFilter}": ${err}`);
|
||||
conn.groupFilterId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: returns the correct provider instance for a connection ID
|
||||
*/
|
||||
@@ -175,9 +253,9 @@ export class ConnectionManager {
|
||||
|
||||
switch (conn.providerType) {
|
||||
case 'gitea':
|
||||
return new GiteaProvider(conn.id, conn.baseUrl, conn.token);
|
||||
return new GiteaProvider(conn.id, conn.baseUrl, conn.token, conn.groupFilterId);
|
||||
case 'gitlab':
|
||||
return new GitLabProvider(conn.id, conn.baseUrl, conn.token);
|
||||
return new GitLabProvider(conn.id, conn.baseUrl, conn.token, conn.groupFilterId);
|
||||
default:
|
||||
throw new Error(`Unknown provider type: ${conn.providerType}`);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
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 } from '../cache/index.ts';
|
||||
import { CacheDb, CacheCleaner, CachedProject, CachedSecret, SecretsScanService } from '../cache/index.ts';
|
||||
import { resolvePaths } from '../paths.ts';
|
||||
|
||||
/**
|
||||
@@ -13,18 +16,26 @@ export class GitopsApp {
|
||||
public storageManager: StorageManager;
|
||||
public smartSecret: plugins.smartsecret.SmartSecret;
|
||||
public connectionManager: ConnectionManager;
|
||||
public actionLog: ActionLog;
|
||||
public opsServer: OpsServer;
|
||||
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>;
|
||||
|
||||
constructor() {
|
||||
const paths = resolvePaths();
|
||||
this.paths = paths;
|
||||
this.storageManager = new StorageManager({
|
||||
backend: 'filesystem',
|
||||
fsPath: paths.defaultStoragePath,
|
||||
});
|
||||
this.smartSecret = new plugins.smartsecret.SmartSecret({ service: 'gitops' });
|
||||
this.connectionManager = new ConnectionManager(this.storageManager, this.smartSecret);
|
||||
this.actionLog = new ActionLog(this.storageManager);
|
||||
|
||||
this.cacheDb = CacheDb.getInstance({
|
||||
storagePath: paths.defaultTsmDbPath,
|
||||
@@ -32,6 +43,7 @@ export class GitopsApp {
|
||||
});
|
||||
this.cacheCleaner = new CacheCleaner(this.cacheDb);
|
||||
this.cacheCleaner.registerClass(CachedProject);
|
||||
this.cacheCleaner.registerClass(CachedSecret);
|
||||
|
||||
this.opsServer = new OpsServer(this);
|
||||
}
|
||||
@@ -45,6 +57,36 @@ 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,
|
||||
);
|
||||
await this.syncManager.init();
|
||||
|
||||
// Initialize secrets scan service with 24h auto-scan
|
||||
this.secretsScanService = new SecretsScanService(this.connectionManager);
|
||||
const SCAN_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
this.scanIntervalId = setInterval(() => {
|
||||
this.secretsScanService.fullScan().catch((err) => {
|
||||
logger.error(`Scheduled secrets scan failed: ${err}`);
|
||||
});
|
||||
}, SCAN_INTERVAL_MS);
|
||||
Deno.unrefTimer(this.scanIntervalId);
|
||||
// Fire-and-forget initial scan (doesn't block startup)
|
||||
this.secretsScanService.fullScan().catch((err) => {
|
||||
logger.error(`Initial secrets scan failed: ${err}`);
|
||||
});
|
||||
|
||||
// Start CacheCleaner
|
||||
this.cacheCleaner.start();
|
||||
|
||||
@@ -56,6 +98,11 @@ export class GitopsApp {
|
||||
|
||||
async stop(): Promise<void> {
|
||||
logger.info('Shutting down GitOps...');
|
||||
if (this.scanIntervalId !== null) {
|
||||
clearInterval(this.scanIntervalId);
|
||||
this.scanIntervalId = null;
|
||||
}
|
||||
await this.syncManager.stop();
|
||||
await this.opsServer.stop();
|
||||
this.cacheCleaner.stop();
|
||||
await this.cacheDb.stop();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
2030
ts/classes/syncmanager.ts
Normal file
2030
ts/classes/syncmanager.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,15 +2,60 @@
|
||||
* Logging utilities for GitOps
|
||||
*/
|
||||
|
||||
import type { ISyncLogEntry } from '../ts_interfaces/data/sync.ts';
|
||||
|
||||
type LogLevel = 'info' | 'success' | 'warn' | 'error' | 'debug';
|
||||
|
||||
const SYNC_LOG_MAX = 500;
|
||||
|
||||
class Logger {
|
||||
private debugMode = false;
|
||||
private syncLogBuffer: ISyncLogEntry[] = [];
|
||||
private broadcastFn?: (entry: ISyncLogEntry) => void;
|
||||
|
||||
constructor() {
|
||||
this.debugMode = Deno.args.includes('--debug') || Deno.env.get('DEBUG') === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the broadcast function used to push sync log entries to connected clients.
|
||||
*/
|
||||
setBroadcastFn(fn: (entry: ISyncLogEntry) => void): void {
|
||||
this.broadcastFn = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a sync-related message to both the console and the ring buffer.
|
||||
* Also broadcasts to connected frontends via TypedSocket if available.
|
||||
*/
|
||||
syncLog(level: ISyncLogEntry['level'], message: string, source?: string): void {
|
||||
// Also log to console
|
||||
this.log(level, message);
|
||||
|
||||
const entry: ISyncLogEntry = {
|
||||
timestamp: Date.now(),
|
||||
level,
|
||||
message,
|
||||
source,
|
||||
};
|
||||
|
||||
this.syncLogBuffer.push(entry);
|
||||
if (this.syncLogBuffer.length > SYNC_LOG_MAX) {
|
||||
this.syncLogBuffer.splice(0, this.syncLogBuffer.length - SYNC_LOG_MAX);
|
||||
}
|
||||
|
||||
if (this.broadcastFn) {
|
||||
this.broadcastFn(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent sync log entries.
|
||||
*/
|
||||
getSyncLogs(limit = 100): ISyncLogEntry[] {
|
||||
return this.syncLogBuffer.slice(-limit);
|
||||
}
|
||||
|
||||
log(level: LogLevel, message: string, ...args: unknown[]): void {
|
||||
const prefix = this.getPrefix(level);
|
||||
const formattedMessage = `${prefix} ${message}`;
|
||||
|
||||
@@ -17,16 +17,28 @@ export class OpsServer {
|
||||
public secretsHandler!: handlers.SecretsHandler;
|
||||
public pipelinesHandler!: handlers.PipelinesHandler;
|
||||
public logsHandler!: handlers.LogsHandler;
|
||||
public webhookHandler!: handlers.WebhookHandler;
|
||||
public actionsHandler!: handlers.ActionsHandler;
|
||||
public actionLogHandler!: handlers.ActionLogHandler;
|
||||
public syncHandler!: handlers.SyncHandler;
|
||||
public managedSecretsHandler!: handlers.ManagedSecretsHandler;
|
||||
|
||||
constructor(gitopsAppRef: GitopsApp) {
|
||||
this.gitopsAppRef = gitopsAppRef;
|
||||
}
|
||||
|
||||
public async start(port = 3000) {
|
||||
// Create webhook handler before server so routes register via addCustomRoutes
|
||||
this.webhookHandler = new handlers.WebhookHandler(this);
|
||||
|
||||
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
|
||||
domain: 'localhost',
|
||||
feedMetadata: undefined,
|
||||
bundledContent: bundledFiles,
|
||||
noCache: true,
|
||||
addCustomRoutes: async (typedserver) => {
|
||||
this.webhookHandler.registerRoutes(typedserver);
|
||||
},
|
||||
});
|
||||
|
||||
// Chain typedrouters
|
||||
@@ -51,6 +63,10 @@ export class OpsServer {
|
||||
this.secretsHandler = new handlers.SecretsHandler(this);
|
||||
this.pipelinesHandler = new handlers.PipelinesHandler(this);
|
||||
this.logsHandler = new handlers.LogsHandler(this);
|
||||
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');
|
||||
}
|
||||
|
||||
30
ts/opsserver/handlers/actionlog.handler.ts
Normal file
30
ts/opsserver/handlers/actionlog.handler.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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 ActionLogHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetActionLog>(
|
||||
'getActionLog',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const result = await this.opsServerRef.gitopsAppRef.actionLog.query({
|
||||
limit: dataArg.limit,
|
||||
offset: dataArg.offset,
|
||||
entityType: dataArg.entityType,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
50
ts/opsserver/handlers/actions.handler.ts
Normal file
50
ts/opsserver/handlers/actions.handler.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
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 ActionsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Force scan secrets
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ForceScanSecrets>(
|
||||
'forceScanSecrets',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const scanService = this.opsServerRef.gitopsAppRef.secretsScanService;
|
||||
const result = await scanService.fullScan();
|
||||
return {
|
||||
ok: true,
|
||||
connectionsScanned: result.connectionsScanned,
|
||||
secretsFound: result.secretsFound,
|
||||
errors: result.errors,
|
||||
durationMs: result.durationMs,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get scan status
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetScanStatus>(
|
||||
'getScanStatus',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const scanService = this.opsServerRef.gitopsAppRef.secretsScanService;
|
||||
return {
|
||||
lastScanTimestamp: scanService.lastScanTimestamp,
|
||||
isScanning: scanService.isScanning,
|
||||
lastResult: scanService.lastScanResult,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,10 @@ export class ConnectionsHandler {
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private get actionLog() {
|
||||
return this.opsServerRef.gitopsAppRef.actionLog;
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get all connections
|
||||
this.typedrouter.addTypedHandler(
|
||||
@@ -35,7 +39,16 @@ export class ConnectionsHandler {
|
||||
dataArg.providerType,
|
||||
dataArg.baseUrl,
|
||||
dataArg.token,
|
||||
dataArg.groupFilter,
|
||||
);
|
||||
this.actionLog.append({
|
||||
actionType: 'create',
|
||||
entityType: 'connection',
|
||||
entityId: connection.id,
|
||||
entityName: connection.name,
|
||||
details: `Created ${dataArg.providerType} connection "${dataArg.name}" (${dataArg.baseUrl})`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return { connection };
|
||||
},
|
||||
),
|
||||
@@ -53,8 +66,46 @@ export class ConnectionsHandler {
|
||||
name: dataArg.name,
|
||||
baseUrl: dataArg.baseUrl,
|
||||
token: dataArg.token,
|
||||
groupFilter: dataArg.groupFilter,
|
||||
},
|
||||
);
|
||||
const fields = [
|
||||
dataArg.name && 'name',
|
||||
dataArg.baseUrl && 'baseUrl',
|
||||
dataArg.token && 'token',
|
||||
dataArg.groupFilter !== undefined && 'groupFilter',
|
||||
].filter(Boolean).join(', ');
|
||||
this.actionLog.append({
|
||||
actionType: 'update',
|
||||
entityType: 'connection',
|
||||
entityId: dataArg.connectionId,
|
||||
entityName: connection.name,
|
||||
details: `Updated connection "${connection.name}" (fields: ${fields})`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return { connection };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Pause/resume connection
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PauseConnection>(
|
||||
'pauseConnection',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const connection = await this.opsServerRef.gitopsAppRef.connectionManager.pauseConnection(
|
||||
dataArg.connectionId,
|
||||
dataArg.paused,
|
||||
);
|
||||
this.actionLog.append({
|
||||
actionType: dataArg.paused ? 'pause' : 'resume',
|
||||
entityType: 'connection',
|
||||
entityId: dataArg.connectionId,
|
||||
entityName: connection.name,
|
||||
details: `${dataArg.paused ? 'Paused' : 'Resumed'} connection "${connection.name}"`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return { connection };
|
||||
},
|
||||
),
|
||||
@@ -69,6 +120,16 @@ export class ConnectionsHandler {
|
||||
const result = await this.opsServerRef.gitopsAppRef.connectionManager.testConnection(
|
||||
dataArg.connectionId,
|
||||
);
|
||||
const conn = this.opsServerRef.gitopsAppRef.connectionManager.getConnections()
|
||||
.find((c) => c.id === dataArg.connectionId);
|
||||
this.actionLog.append({
|
||||
actionType: 'test',
|
||||
entityType: 'connection',
|
||||
entityId: dataArg.connectionId,
|
||||
entityName: conn?.name || dataArg.connectionId,
|
||||
details: `Tested connection: ${result.ok ? 'success' : `failed — ${result.error || 'unknown error'}`}`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
),
|
||||
@@ -80,9 +141,19 @@ export class ConnectionsHandler {
|
||||
'deleteConnection',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const conn = this.opsServerRef.gitopsAppRef.connectionManager.getConnections()
|
||||
.find((c) => c.id === dataArg.connectionId);
|
||||
await this.opsServerRef.gitopsAppRef.connectionManager.deleteConnection(
|
||||
dataArg.connectionId,
|
||||
);
|
||||
this.actionLog.append({
|
||||
actionType: 'delete',
|
||||
entityType: 'connection',
|
||||
entityId: dataArg.connectionId,
|
||||
entityName: conn?.name || dataArg.connectionId,
|
||||
details: `Deleted connection "${conn?.name || dataArg.connectionId}"`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
),
|
||||
|
||||
@@ -5,3 +5,8 @@ export { GroupsHandler } from './groups.handler.ts';
|
||||
export { SecretsHandler } from './secrets.handler.ts';
|
||||
export { PipelinesHandler } from './pipelines.handler.ts';
|
||||
export { LogsHandler } from './logs.handler.ts';
|
||||
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();
|
||||
@@ -11,8 +32,12 @@ export class PipelinesHandler {
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private get actionLog() {
|
||||
return this.opsServerRef.gitopsAppRef.actionLog;
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get pipelines
|
||||
// Get pipelines — supports view modes
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPipelines>(
|
||||
'getPipelines',
|
||||
@@ -21,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) };
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -54,6 +101,14 @@ export class PipelinesHandler {
|
||||
dataArg.connectionId,
|
||||
);
|
||||
await provider.retryPipeline(dataArg.projectId, dataArg.pipelineId);
|
||||
this.actionLog.append({
|
||||
actionType: 'update',
|
||||
entityType: 'pipeline',
|
||||
entityId: dataArg.pipelineId,
|
||||
entityName: `Pipeline #${dataArg.pipelineId}`,
|
||||
details: `Retried pipeline #${dataArg.pipelineId} in project ${dataArg.projectId}`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
),
|
||||
@@ -69,9 +124,158 @@ export class PipelinesHandler {
|
||||
dataArg.connectionId,
|
||||
);
|
||||
await provider.cancelPipeline(dataArg.projectId, dataArg.pipelineId);
|
||||
this.actionLog.append({
|
||||
actionType: 'delete',
|
||||
entityType: 'pipeline',
|
||||
entityId: dataArg.pipelineId,
|
||||
entityName: `Pipeline #${dataArg.pipelineId}`,
|
||||
details: `Cancelled pipeline #${dataArg.pipelineId} in project ${dataArg.projectId}`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,19 +11,125 @@ export class SecretsHandler {
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private get actionLog() {
|
||||
return this.opsServerRef.gitopsAppRef.actionLog;
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get secrets
|
||||
// Get all secrets (cache-first, falls back to live fetch)
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAllSecrets>(
|
||||
'getAllSecrets',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const scanService = this.opsServerRef.gitopsAppRef.secretsScanService;
|
||||
|
||||
// Try cache first
|
||||
const hasCached = await scanService.hasCachedData(dataArg.connectionId, dataArg.scope);
|
||||
if (hasCached) {
|
||||
const secrets = await scanService.getCachedSecrets({
|
||||
connectionId: dataArg.connectionId,
|
||||
scope: dataArg.scope,
|
||||
});
|
||||
return { secrets };
|
||||
}
|
||||
|
||||
// Cache miss: live fetch and save to cache
|
||||
const provider = this.opsServerRef.gitopsAppRef.connectionManager.getProvider(
|
||||
dataArg.connectionId,
|
||||
);
|
||||
|
||||
const allSecrets: interfaces.data.ISecret[] = [];
|
||||
|
||||
if (dataArg.scope === 'project') {
|
||||
const projects = await provider.getProjects();
|
||||
for (let i = 0; i < projects.length; i += 5) {
|
||||
const batch = projects.slice(i, i + 5);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (p) => {
|
||||
const secrets = await provider.getProjectSecrets(p.id);
|
||||
return secrets.map((s) => ({
|
||||
...s,
|
||||
scopeName: p.fullPath || p.name,
|
||||
scope: 'project' as const,
|
||||
scopeId: p.id,
|
||||
connectionId: dataArg.connectionId,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
allSecrets.push(...result.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const groups = await provider.getGroups();
|
||||
for (let i = 0; i < groups.length; i += 5) {
|
||||
const batch = groups.slice(i, i + 5);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (g) => {
|
||||
const secrets = await provider.getGroupSecrets(g.id);
|
||||
return secrets.map((s) => ({
|
||||
...s,
|
||||
scopeName: g.fullPath || g.name,
|
||||
scope: 'group' as const,
|
||||
scopeId: g.id,
|
||||
connectionId: dataArg.connectionId,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
allSecrets.push(...result.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save fetched secrets to cache (fire-and-forget)
|
||||
scanService.saveSecrets(allSecrets).catch(() => {});
|
||||
|
||||
return { secrets: allSecrets };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get secrets (cache-first for single entity)
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecrets>(
|
||||
'getSecrets',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const scanService = this.opsServerRef.gitopsAppRef.secretsScanService;
|
||||
|
||||
// Try cache first
|
||||
const cached = await scanService.getCachedSecrets({
|
||||
connectionId: dataArg.connectionId,
|
||||
scope: dataArg.scope,
|
||||
scopeId: dataArg.scopeId,
|
||||
});
|
||||
if (cached.length > 0) {
|
||||
return { secrets: cached };
|
||||
}
|
||||
|
||||
// Cache miss: live fetch
|
||||
const provider = this.opsServerRef.gitopsAppRef.connectionManager.getProvider(
|
||||
dataArg.connectionId,
|
||||
);
|
||||
const secrets = dataArg.scope === 'project'
|
||||
? await provider.getProjectSecrets(dataArg.scopeId)
|
||||
: await provider.getGroupSecrets(dataArg.scopeId);
|
||||
|
||||
// Save to cache (fire-and-forget)
|
||||
const fullSecrets = secrets.map((s) => ({
|
||||
...s,
|
||||
scope: dataArg.scope,
|
||||
scopeId: dataArg.scopeId,
|
||||
connectionId: dataArg.connectionId,
|
||||
}));
|
||||
scanService.saveSecrets(fullSecrets).catch(() => {});
|
||||
|
||||
return { secrets };
|
||||
},
|
||||
),
|
||||
@@ -41,6 +147,17 @@ export class SecretsHandler {
|
||||
const secret = dataArg.scope === 'project'
|
||||
? await provider.createProjectSecret(dataArg.scopeId, dataArg.key, dataArg.value)
|
||||
: await provider.createGroupSecret(dataArg.scopeId, dataArg.key, dataArg.value);
|
||||
// Refresh cache for this entity
|
||||
const scanService = this.opsServerRef.gitopsAppRef.secretsScanService;
|
||||
scanService.scanEntity(dataArg.connectionId, dataArg.scope, dataArg.scopeId).catch(() => {});
|
||||
this.actionLog.append({
|
||||
actionType: 'create',
|
||||
entityType: 'secret',
|
||||
entityId: `${dataArg.scopeId}/${dataArg.key}`,
|
||||
entityName: dataArg.key,
|
||||
details: `Created ${dataArg.scope} secret "${dataArg.key}" in ${dataArg.scopeId}`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return { secret };
|
||||
},
|
||||
),
|
||||
@@ -58,6 +175,17 @@ export class SecretsHandler {
|
||||
const secret = dataArg.scope === 'project'
|
||||
? await provider.updateProjectSecret(dataArg.scopeId, dataArg.key, dataArg.value)
|
||||
: await provider.updateGroupSecret(dataArg.scopeId, dataArg.key, dataArg.value);
|
||||
// Refresh cache for this entity
|
||||
const scanService = this.opsServerRef.gitopsAppRef.secretsScanService;
|
||||
scanService.scanEntity(dataArg.connectionId, dataArg.scope, dataArg.scopeId).catch(() => {});
|
||||
this.actionLog.append({
|
||||
actionType: 'update',
|
||||
entityType: 'secret',
|
||||
entityId: `${dataArg.scopeId}/${dataArg.key}`,
|
||||
entityName: dataArg.key,
|
||||
details: `Updated ${dataArg.scope} secret "${dataArg.key}" in ${dataArg.scopeId}`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return { secret };
|
||||
},
|
||||
),
|
||||
@@ -77,6 +205,17 @@ export class SecretsHandler {
|
||||
} else {
|
||||
await provider.deleteGroupSecret(dataArg.scopeId, dataArg.key);
|
||||
}
|
||||
// Refresh cache for this entity
|
||||
const scanService = this.opsServerRef.gitopsAppRef.secretsScanService;
|
||||
scanService.scanEntity(dataArg.connectionId, dataArg.scope, dataArg.scopeId).catch(() => {});
|
||||
this.actionLog.append({
|
||||
actionType: 'delete',
|
||||
entityType: 'secret',
|
||||
entityId: `${dataArg.scopeId}/${dataArg.key}`,
|
||||
entityName: dataArg.key,
|
||||
details: `Deleted ${dataArg.scope} secret "${dataArg.key}" from ${dataArg.scopeId}`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
),
|
||||
|
||||
224
ts/opsserver/handlers/sync.handler.ts
Normal file
224
ts/opsserver/handlers/sync.handler.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
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 { logger } from '../../logging.ts';
|
||||
|
||||
export class SyncHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
this.setupBroadcast();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire up the logger's broadcast function to push sync log entries
|
||||
* to all connected frontends via TypedSocket.
|
||||
*/
|
||||
private setupBroadcast(): void {
|
||||
logger.setBroadcastFn((entry) => {
|
||||
try {
|
||||
const typedsocket = this.opsServerRef.server?.typedserver?.typedsocket;
|
||||
if (!typedsocket) return;
|
||||
typedsocket.findAllTargetConnectionsByTag('syncLogClient').then((connections) => {
|
||||
for (const conn of connections) {
|
||||
typedsocket
|
||||
.createTypedRequest<interfaces.requests.IReq_PushSyncLog>('pushSyncLog', conn)
|
||||
.fire({ entry })
|
||||
.catch(() => {});
|
||||
}
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
// Server may not be ready yet — ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private get syncManager() {
|
||||
return this.opsServerRef.gitopsAppRef.syncManager;
|
||||
}
|
||||
|
||||
private get actionLog() {
|
||||
return this.opsServerRef.gitopsAppRef.actionLog;
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get all sync configs
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSyncConfigs>(
|
||||
'getSyncConfigs',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
return { configs: this.syncManager.getConfigs() };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create sync config
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateSyncConfig>(
|
||||
'createSyncConfig',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const config = await this.syncManager.createConfig({
|
||||
name: dataArg.name,
|
||||
sourceConnectionId: dataArg.sourceConnectionId,
|
||||
targetConnectionId: dataArg.targetConnectionId,
|
||||
targetGroupOffset: dataArg.targetGroupOffset,
|
||||
intervalMinutes: dataArg.intervalMinutes,
|
||||
enforceDelete: dataArg.enforceDelete,
|
||||
enforceGroupDelete: dataArg.enforceGroupDelete,
|
||||
addMirrorHint: dataArg.addMirrorHint,
|
||||
useGroupAvatarsForProjects: dataArg.useGroupAvatarsForProjects,
|
||||
});
|
||||
this.actionLog.append({
|
||||
actionType: 'create',
|
||||
entityType: 'sync',
|
||||
entityId: config.id,
|
||||
entityName: config.name,
|
||||
details: `Created sync config "${config.name}" (${config.intervalMinutes}m interval)`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return { config };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update sync config
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSyncConfig>(
|
||||
'updateSyncConfig',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const config = await this.syncManager.updateConfig(dataArg.syncConfigId, {
|
||||
name: dataArg.name,
|
||||
targetGroupOffset: dataArg.targetGroupOffset,
|
||||
intervalMinutes: dataArg.intervalMinutes,
|
||||
enforceDelete: dataArg.enforceDelete,
|
||||
enforceGroupDelete: dataArg.enforceGroupDelete,
|
||||
addMirrorHint: dataArg.addMirrorHint,
|
||||
useGroupAvatarsForProjects: dataArg.useGroupAvatarsForProjects,
|
||||
});
|
||||
this.actionLog.append({
|
||||
actionType: 'update',
|
||||
entityType: 'sync',
|
||||
entityId: config.id,
|
||||
entityName: config.name,
|
||||
details: `Updated sync config "${config.name}"`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return { config };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete sync config
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteSyncConfig>(
|
||||
'deleteSyncConfig',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const config = this.syncManager.getConfig(dataArg.syncConfigId);
|
||||
await this.syncManager.deleteConfig(dataArg.syncConfigId);
|
||||
this.actionLog.append({
|
||||
actionType: 'delete',
|
||||
entityType: 'sync',
|
||||
entityId: dataArg.syncConfigId,
|
||||
entityName: config?.name || dataArg.syncConfigId,
|
||||
details: `Deleted sync config "${config?.name || dataArg.syncConfigId}"`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Pause/resume sync config
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PauseSyncConfig>(
|
||||
'pauseSyncConfig',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const config = await this.syncManager.pauseConfig(
|
||||
dataArg.syncConfigId,
|
||||
dataArg.paused,
|
||||
);
|
||||
this.actionLog.append({
|
||||
actionType: dataArg.paused ? 'pause' : 'resume',
|
||||
entityType: 'sync',
|
||||
entityId: config.id,
|
||||
entityName: config.name,
|
||||
details: `${dataArg.paused ? 'Paused' : 'Resumed'} sync config "${config.name}"`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return { config };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Trigger sync manually
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TriggerSync>(
|
||||
'triggerSync',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const config = this.syncManager.getConfig(dataArg.syncConfigId);
|
||||
if (!config) {
|
||||
return { ok: false, message: 'Sync config not found' };
|
||||
}
|
||||
// Fire and forget — force=true bypasses paused check for manual triggers
|
||||
this.syncManager.executeSync(dataArg.syncConfigId, true).catch((err) => {
|
||||
console.error(`Manual sync trigger failed: ${err}`);
|
||||
});
|
||||
this.actionLog.append({
|
||||
actionType: 'sync',
|
||||
entityType: 'sync',
|
||||
entityId: config.id,
|
||||
entityName: config.name,
|
||||
details: `Manually triggered sync "${config.name}"`,
|
||||
username: dataArg.identity.username,
|
||||
});
|
||||
return { ok: true, message: 'Sync triggered' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Preview sync (dry run — shows source → target mappings)
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PreviewSync>(
|
||||
'previewSync',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const result = await this.syncManager.previewSync(dataArg.syncConfigId);
|
||||
return { mappings: result.mappings, deletions: result.deletions, groupDeletions: result.groupDeletions };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get repo statuses for a sync config
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSyncRepoStatuses>(
|
||||
'getSyncRepoStatuses',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const statuses = await this.syncManager.getRepoStatuses(dataArg.syncConfigId);
|
||||
return { statuses };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get recent sync log entries
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSyncLogs>(
|
||||
'getSyncLogs',
|
||||
async (dataArg) => {
|
||||
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
const logs = logger.getSyncLogs(dataArg.limit || 200);
|
||||
return { logs };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
62
ts/opsserver/handlers/webhook.handler.ts
Normal file
62
ts/opsserver/handlers/webhook.handler.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import { logger } from '../../logging.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
|
||||
export class WebhookHandler {
|
||||
constructor(private opsServerRef: OpsServer) {}
|
||||
|
||||
public registerRoutes(typedserver: plugins.typedserver.TypedServer): void {
|
||||
typedserver.addRoute('/webhook/:connectionId', 'POST', async (ctx) => {
|
||||
const connectionId = ctx.params.connectionId;
|
||||
|
||||
// Validate connection exists
|
||||
const connection = this.opsServerRef.gitopsAppRef.connectionManager.getConnection(connectionId);
|
||||
if (!connection) {
|
||||
return new Response(JSON.stringify({ error: 'Connection not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Parse event type from provider-specific headers
|
||||
const giteaEvent = ctx.headers.get('X-Gitea-Event');
|
||||
const gitlabEvent = ctx.headers.get('X-Gitlab-Event');
|
||||
const event = giteaEvent || gitlabEvent || 'unknown';
|
||||
const provider = giteaEvent ? 'gitea' : gitlabEvent ? 'gitlab' : 'unknown';
|
||||
|
||||
logger.info(`Webhook received: ${provider}/${event} for connection ${connection.name} (${connectionId})`);
|
||||
|
||||
// Broadcast to all connected frontends via TypedSocket
|
||||
try {
|
||||
const typedsocket = this.opsServerRef.server.typedserver.typedsocket;
|
||||
if (typedsocket) {
|
||||
const connections = await typedsocket.findAllTargetConnectionsByTag('syncLogClient');
|
||||
for (const conn of connections) {
|
||||
const req = typedsocket.createTypedRequest<interfaces.requests.IReq_WebhookNotification>(
|
||||
'webhookNotification',
|
||||
conn,
|
||||
);
|
||||
req.fire({
|
||||
connectionId,
|
||||
provider,
|
||||
event,
|
||||
timestamp: Date.now(),
|
||||
}).catch((err: any) => {
|
||||
logger.warn(`Failed to notify client: ${err.message || err}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.warn(`Failed to broadcast webhook event: ${err.message || err}`);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
logger.info('WebhookHandler routes registered');
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,27 @@ 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.
|
||||
*/
|
||||
export abstract class BaseProvider {
|
||||
public readonly groupFilterId?: string;
|
||||
|
||||
constructor(
|
||||
public readonly connectionId: string,
|
||||
public readonly baseUrl: string,
|
||||
protected readonly token: string,
|
||||
) {}
|
||||
groupFilterId?: string,
|
||||
) {
|
||||
this.groupFilterId = groupFilterId;
|
||||
}
|
||||
|
||||
// Connection
|
||||
abstract testConnection(): Promise<ITestConnectionResult>;
|
||||
@@ -31,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(
|
||||
@@ -59,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
|
||||
@@ -8,8 +8,8 @@ import { BaseProvider, type ITestConnectionResult, type IListOptions } from './c
|
||||
export class GiteaProvider extends BaseProvider {
|
||||
private client: plugins.giteaClient.GiteaClient;
|
||||
|
||||
constructor(connectionId: string, baseUrl: string, token: string) {
|
||||
super(connectionId, baseUrl, token);
|
||||
constructor(connectionId: string, baseUrl: string, token: string, groupFilterId?: string) {
|
||||
super(connectionId, baseUrl, token, groupFilterId);
|
||||
this.client = new plugins.giteaClient.GiteaClient(baseUrl, token);
|
||||
}
|
||||
|
||||
@@ -18,19 +18,46 @@ export class GiteaProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
async getProjects(opts?: IListOptions): Promise<interfaces.data.IProject[]> {
|
||||
const repos = await this.client.getRepos(opts);
|
||||
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[]> {
|
||||
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));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
// --- Branches / Tags ---
|
||||
|
||||
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 }));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -39,8 +66,9 @@ export class GiteaProvider extends BaseProvider {
|
||||
key: string,
|
||||
value: string,
|
||||
): Promise<interfaces.data.ISecret> {
|
||||
await this.client.setRepoSecret(projectId, key, value);
|
||||
return { key, value: '***', protected: false, masked: true, scope: 'project', scopeId: projectId, connectionId: this.connectionId, environment: '*' };
|
||||
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: '*' };
|
||||
}
|
||||
|
||||
async updateProjectSecret(
|
||||
@@ -52,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));
|
||||
}
|
||||
|
||||
@@ -67,8 +97,9 @@ export class GiteaProvider extends BaseProvider {
|
||||
key: string,
|
||||
value: string,
|
||||
): Promise<interfaces.data.ISecret> {
|
||||
await this.client.setOrgSecret(groupId, key, value);
|
||||
return { key, value: '***', protected: false, masked: true, scope: 'group', scopeId: groupId, connectionId: this.connectionId, environment: '*' };
|
||||
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: '*' };
|
||||
}
|
||||
|
||||
async updateGroupSecret(
|
||||
@@ -80,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));
|
||||
}
|
||||
|
||||
@@ -97,89 +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: 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: String(o.id || o.name),
|
||||
name: o.name || '',
|
||||
fullPath: o.name || '',
|
||||
description: o.description || '',
|
||||
id: o.name || String(o.id),
|
||||
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): 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: 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
|
||||
@@ -8,8 +8,8 @@ import { BaseProvider, type ITestConnectionResult, type IListOptions } from './c
|
||||
export class GitLabProvider extends BaseProvider {
|
||||
private client: plugins.gitlabClient.GitLabClient;
|
||||
|
||||
constructor(connectionId: string, baseUrl: string, token: string) {
|
||||
super(connectionId, baseUrl, token);
|
||||
constructor(connectionId: string, baseUrl: string, token: string, groupFilterId?: string) {
|
||||
super(connectionId, baseUrl, token, groupFilterId);
|
||||
this.client = new plugins.gitlabClient.GitLabClient(baseUrl, token);
|
||||
}
|
||||
|
||||
@@ -18,19 +18,47 @@ export class GitLabProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
async getProjects(opts?: IListOptions): Promise<interfaces.data.IProject[]> {
|
||||
const projects = await this.client.getProjects(opts);
|
||||
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) {
|
||||
const group = await this.client.getGroup(this.groupFilterId);
|
||||
const descendants = await group.getDescendantGroups(opts);
|
||||
return descendants.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));
|
||||
}
|
||||
|
||||
@@ -39,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);
|
||||
}
|
||||
|
||||
@@ -48,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));
|
||||
}
|
||||
|
||||
@@ -68,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);
|
||||
}
|
||||
|
||||
@@ -77,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));
|
||||
}
|
||||
|
||||
@@ -99,81 +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,
|
||||
): 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: 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',
|
||||
};
|
||||
}
|
||||
|
||||
166543
ts_bundled/bundle.js
166543
ts_bundled/bundle.js
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
13
ts_interfaces/data/actionlog.ts
Normal file
13
ts_interfaces/data/actionlog.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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;
|
||||
timestamp: number;
|
||||
actionType: TActionType;
|
||||
entityType: TActionEntity;
|
||||
entityId: string;
|
||||
entityName: string;
|
||||
details: string;
|
||||
username: 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;
|
||||
}
|
||||
@@ -7,5 +7,7 @@ export interface IProviderConnection {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
createdAt: number;
|
||||
status: 'connected' | 'disconnected' | 'error';
|
||||
status: 'connected' | 'disconnected' | 'error' | 'paused';
|
||||
groupFilter?: string; // Restricts which repos this connection can see (e.g. "foss.global")
|
||||
groupFilterId?: string; // Resolved filter group ID (numeric for GitLab, org name for Gitea)
|
||||
}
|
||||
|
||||
@@ -2,5 +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;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export interface ISecret {
|
||||
masked: boolean;
|
||||
scope: 'project' | 'group';
|
||||
scopeId: string;
|
||||
scopeName: string;
|
||||
connectionId: string;
|
||||
environment: string;
|
||||
}
|
||||
|
||||
37
ts_interfaces/data/sync.ts
Normal file
37
ts_interfaces/data/sync.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export type TSyncStatus = 'active' | 'paused' | 'error';
|
||||
|
||||
export interface ISyncConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
sourceConnectionId: string;
|
||||
targetConnectionId: string;
|
||||
targetGroupOffset?: string; // Path prefix for target repos (e.g. "mirror/gitlab")
|
||||
intervalMinutes: number; // Default 5
|
||||
status: TSyncStatus;
|
||||
lastSyncAt: number;
|
||||
lastSyncError?: string;
|
||||
lastSyncDurationMs?: number;
|
||||
reposSynced: number;
|
||||
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;
|
||||
}
|
||||
|
||||
export interface ISyncRepoStatus {
|
||||
id: string;
|
||||
syncConfigId: string;
|
||||
sourceFullPath: string; // e.g. "push.rocks/smartstate"
|
||||
targetFullPath: string; // e.g. "foss.global/push.rocks/smartstate"
|
||||
lastSyncAt: number;
|
||||
lastSyncError?: string;
|
||||
status: 'synced' | 'error' | 'pending';
|
||||
}
|
||||
|
||||
export interface ISyncLogEntry {
|
||||
timestamp: number;
|
||||
level: 'info' | 'warn' | 'error' | 'success' | 'debug';
|
||||
message: string;
|
||||
source?: string; // e.g. 'preview', 'sync', 'git', 'api'
|
||||
}
|
||||
19
ts_interfaces/requests/actionlog.ts
Normal file
19
ts_interfaces/requests/actionlog.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_GetActionLog extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetActionLog
|
||||
> {
|
||||
method: 'getActionLog';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
entityType?: data.TActionEntity;
|
||||
};
|
||||
response: {
|
||||
entries: data.IActionLogEntry[];
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
39
ts_interfaces/requests/actions.ts
Normal file
39
ts_interfaces/requests/actions.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_ForceScanSecrets extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ForceScanSecrets
|
||||
> {
|
||||
method: 'forceScanSecrets';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
ok: boolean;
|
||||
connectionsScanned: number;
|
||||
secretsFound: number;
|
||||
errors: string[];
|
||||
durationMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetScanStatus extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetScanStatus
|
||||
> {
|
||||
method: 'getScanStatus';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
lastScanTimestamp: number;
|
||||
isScanning: boolean;
|
||||
lastResult: {
|
||||
connectionsScanned: number;
|
||||
secretsFound: number;
|
||||
errors: string[];
|
||||
durationMs: number;
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export interface IReq_CreateConnection extends plugins.typedrequestInterfaces.im
|
||||
providerType: data.TProviderType;
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
groupFilter?: string;
|
||||
};
|
||||
response: {
|
||||
connection: data.IProviderConnection;
|
||||
@@ -42,6 +43,7 @@ export interface IReq_UpdateConnection extends plugins.typedrequestInterfaces.im
|
||||
name?: string;
|
||||
baseUrl?: string;
|
||||
token?: string;
|
||||
groupFilter?: string;
|
||||
};
|
||||
response: {
|
||||
connection: data.IProviderConnection;
|
||||
@@ -63,6 +65,21 @@ export interface IReq_TestConnection extends plugins.typedrequestInterfaces.impl
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_PauseConnection extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_PauseConnection
|
||||
> {
|
||||
method: 'pauseConnection';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
connectionId: string;
|
||||
paused: boolean;
|
||||
};
|
||||
response: {
|
||||
connection: data.IProviderConnection;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_DeleteConnection extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteConnection
|
||||
|
||||
@@ -5,3 +5,8 @@ export * from './groups.ts';
|
||||
export * from './secrets.ts';
|
||||
export * from './pipelines.ts';
|
||||
export * from './logs.ts';
|
||||
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: {
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_GetAllSecrets extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetAllSecrets
|
||||
> {
|
||||
method: 'getAllSecrets';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
connectionId: string;
|
||||
scope: 'project' | 'group';
|
||||
};
|
||||
response: {
|
||||
secrets: data.ISecret[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetSecrets extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSecrets
|
||||
|
||||
157
ts_interfaces/requests/sync.ts
Normal file
157
ts_interfaces/requests/sync.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_GetSyncConfigs extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSyncConfigs
|
||||
> {
|
||||
method: 'getSyncConfigs';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
configs: data.ISyncConfig[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_CreateSyncConfig extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateSyncConfig
|
||||
> {
|
||||
method: 'createSyncConfig';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
name: string;
|
||||
sourceConnectionId: string;
|
||||
targetConnectionId: string;
|
||||
targetGroupOffset?: string;
|
||||
intervalMinutes?: number;
|
||||
enforceDelete?: boolean;
|
||||
enforceGroupDelete?: boolean;
|
||||
addMirrorHint?: boolean;
|
||||
useGroupAvatarsForProjects?: boolean;
|
||||
};
|
||||
response: {
|
||||
config: data.ISyncConfig;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_UpdateSyncConfig extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateSyncConfig
|
||||
> {
|
||||
method: 'updateSyncConfig';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
syncConfigId: string;
|
||||
name?: string;
|
||||
targetGroupOffset?: string;
|
||||
intervalMinutes?: number;
|
||||
enforceDelete?: boolean;
|
||||
enforceGroupDelete?: boolean;
|
||||
addMirrorHint?: boolean;
|
||||
useGroupAvatarsForProjects?: boolean;
|
||||
};
|
||||
response: {
|
||||
config: data.ISyncConfig;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_DeleteSyncConfig extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteSyncConfig
|
||||
> {
|
||||
method: 'deleteSyncConfig';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
syncConfigId: string;
|
||||
};
|
||||
response: {
|
||||
ok: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_PauseSyncConfig extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_PauseSyncConfig
|
||||
> {
|
||||
method: 'pauseSyncConfig';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
syncConfigId: string;
|
||||
paused: boolean;
|
||||
};
|
||||
response: {
|
||||
config: data.ISyncConfig;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_TriggerSync extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_TriggerSync
|
||||
> {
|
||||
method: 'triggerSync';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
syncConfigId: string;
|
||||
};
|
||||
response: {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_PreviewSync extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_PreviewSync
|
||||
> {
|
||||
method: 'previewSync';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
syncConfigId: string;
|
||||
};
|
||||
response: {
|
||||
mappings: Array<{ sourceFullPath: string; targetFullPath: string }>;
|
||||
deletions: string[];
|
||||
groupDeletions: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetSyncRepoStatuses extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSyncRepoStatuses
|
||||
> {
|
||||
method: 'getSyncRepoStatuses';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
syncConfigId: string;
|
||||
};
|
||||
response: {
|
||||
statuses: data.ISyncRepoStatus[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetSyncLogs extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSyncLogs
|
||||
> {
|
||||
method: 'getSyncLogs';
|
||||
request: {
|
||||
identity: data.IIdentity;
|
||||
limit?: number;
|
||||
};
|
||||
response: {
|
||||
logs: data.ISyncLogEntry[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_PushSyncLog extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_PushSyncLog
|
||||
> {
|
||||
method: 'pushSyncLog';
|
||||
request: {
|
||||
entry: data.ISyncLogEntry;
|
||||
};
|
||||
response: {};
|
||||
}
|
||||
18
ts_interfaces/requests/webhook.ts
Normal file
18
ts_interfaces/requests/webhook.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import * as data from '../data/index.ts';
|
||||
|
||||
export interface IReq_WebhookNotification extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_WebhookNotification
|
||||
> {
|
||||
method: 'webhookNotification';
|
||||
request: {
|
||||
connectionId: string;
|
||||
provider: string;
|
||||
event: string;
|
||||
timestamp: number;
|
||||
};
|
||||
response: {
|
||||
ok: boolean;
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/gitops',
|
||||
version: '2.4.0',
|
||||
version: '2.12.0',
|
||||
description: 'GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs'
|
||||
}
|
||||
|
||||
@@ -29,10 +29,23 @@ export interface IDataState {
|
||||
currentJobLog: string;
|
||||
}
|
||||
|
||||
export interface IActionLogState {
|
||||
entries: interfaces.data.IActionLogEntry[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface INavigationContext {
|
||||
connectionId?: string;
|
||||
scope?: 'project' | 'group';
|
||||
scopeId?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface IUiState {
|
||||
activeView: string;
|
||||
autoRefresh: boolean;
|
||||
refreshInterval: number;
|
||||
navigationContext?: INavigationContext;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -70,6 +83,15 @@ export const dataStatePart = await appState.getStatePart<IDataState>(
|
||||
'soft',
|
||||
);
|
||||
|
||||
export const actionLogStatePart = await appState.getStatePart<IActionLogState>(
|
||||
'actionLog',
|
||||
{
|
||||
entries: [],
|
||||
total: 0,
|
||||
},
|
||||
'soft',
|
||||
);
|
||||
|
||||
export const uiStatePart = await appState.getStatePart<IUiState>(
|
||||
'ui',
|
||||
{
|
||||
@@ -157,6 +179,7 @@ export const createConnectionAction = connectionsStatePart.createAction<{
|
||||
providerType: interfaces.data.TProviderType;
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
groupFilter?: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
@@ -227,6 +250,59 @@ export const deleteConnectionAction = connectionsStatePart.createAction<{
|
||||
}
|
||||
});
|
||||
|
||||
export const pauseConnectionAction = connectionsStatePart.createAction<{
|
||||
connectionId: string;
|
||||
paused: boolean;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_PauseConnection
|
||||
>('/typedrequest', 'pauseConnection');
|
||||
await typedRequest.fire({
|
||||
identity: context.identity!,
|
||||
...dataArg,
|
||||
});
|
||||
// Re-fetch to get updated status
|
||||
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetConnections
|
||||
>('/typedrequest', 'getConnections');
|
||||
const listResp = await listReq.fire({ identity: context.identity! });
|
||||
return { ...statePartArg.getState(), connections: listResp.connections };
|
||||
} catch (err) {
|
||||
console.error('Failed to pause/resume connection:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const updateConnectionAction = connectionsStatePart.createAction<{
|
||||
connectionId: string;
|
||||
name?: string;
|
||||
baseUrl?: string;
|
||||
token?: string;
|
||||
groupFilter?: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_UpdateConnection
|
||||
>('/typedrequest', 'updateConnection');
|
||||
await typedRequest.fire({
|
||||
identity: context.identity!,
|
||||
...dataArg,
|
||||
});
|
||||
// Re-fetch to get updated data
|
||||
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetConnections
|
||||
>('/typedrequest', 'getConnections');
|
||||
const listResp = await listReq.fire({ identity: context.identity! });
|
||||
return { ...statePartArg.getState(), connections: listResp.connections };
|
||||
} catch (err) {
|
||||
console.error('Failed to update connection:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Projects Actions
|
||||
// ============================================================================
|
||||
@@ -304,6 +380,34 @@ export const fetchSecretsAction = dataStatePart.createAction<{
|
||||
}
|
||||
});
|
||||
|
||||
export const fetchAllSecretsAction = dataStatePart.createAction<{
|
||||
connectionId: string;
|
||||
scope?: 'project' | 'group';
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
// When no scope specified, fetch both project and group secrets in parallel
|
||||
const scopes: Array<'project' | 'group'> = dataArg.scope ? [dataArg.scope] : ['project', 'group'];
|
||||
const results = await Promise.all(
|
||||
scopes.map(async (scope) => {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetAllSecrets
|
||||
>('/typedrequest', 'getAllSecrets');
|
||||
const response = await typedRequest.fire({
|
||||
identity: context.identity!,
|
||||
connectionId: dataArg.connectionId,
|
||||
scope,
|
||||
});
|
||||
return response.secrets;
|
||||
}),
|
||||
);
|
||||
return { ...statePartArg.getState(), secrets: results.flat() };
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch all secrets:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const createSecretAction = dataStatePart.createAction<{
|
||||
connectionId: string;
|
||||
scope: 'project' | 'group';
|
||||
@@ -320,7 +424,7 @@ export const createSecretAction = dataStatePart.createAction<{
|
||||
identity: context.identity!,
|
||||
...dataArg,
|
||||
});
|
||||
// Re-fetch secrets
|
||||
// Re-fetch only the affected entity's secrets and merge
|
||||
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSecrets
|
||||
>('/typedrequest', 'getSecrets');
|
||||
@@ -330,7 +434,11 @@ export const createSecretAction = dataStatePart.createAction<{
|
||||
scope: dataArg.scope,
|
||||
scopeId: dataArg.scopeId,
|
||||
});
|
||||
return { ...statePartArg.getState(), secrets: listResp.secrets };
|
||||
const state = statePartArg.getState();
|
||||
const otherSecrets = state.secrets.filter(
|
||||
(s) => !(s.scopeId === dataArg.scopeId && s.scope === dataArg.scope),
|
||||
);
|
||||
return { ...state, secrets: [...otherSecrets, ...listResp.secrets] };
|
||||
} catch (err) {
|
||||
console.error('Failed to create secret:', err);
|
||||
return statePartArg.getState();
|
||||
@@ -353,7 +461,7 @@ export const updateSecretAction = dataStatePart.createAction<{
|
||||
identity: context.identity!,
|
||||
...dataArg,
|
||||
});
|
||||
// Re-fetch
|
||||
// Re-fetch only the affected entity's secrets and merge
|
||||
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSecrets
|
||||
>('/typedrequest', 'getSecrets');
|
||||
@@ -363,7 +471,11 @@ export const updateSecretAction = dataStatePart.createAction<{
|
||||
scope: dataArg.scope,
|
||||
scopeId: dataArg.scopeId,
|
||||
});
|
||||
return { ...statePartArg.getState(), secrets: listResp.secrets };
|
||||
const state = statePartArg.getState();
|
||||
const otherSecrets = state.secrets.filter(
|
||||
(s) => !(s.scopeId === dataArg.scopeId && s.scope === dataArg.scope),
|
||||
);
|
||||
return { ...state, secrets: [...otherSecrets, ...listResp.secrets] };
|
||||
} catch (err) {
|
||||
console.error('Failed to update secret:', err);
|
||||
return statePartArg.getState();
|
||||
@@ -388,7 +500,9 @@ export const deleteSecretAction = dataStatePart.createAction<{
|
||||
const state = statePartArg.getState();
|
||||
return {
|
||||
...state,
|
||||
secrets: state.secrets.filter((s) => s.key !== dataArg.key),
|
||||
secrets: state.secrets.filter(
|
||||
(s) => !(s.key === dataArg.key && s.scopeId === dataArg.scopeId && s.scope === dataArg.scope),
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to delete secret:', err);
|
||||
@@ -402,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 {
|
||||
@@ -413,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) {
|
||||
@@ -529,13 +653,53 @@ export const fetchJobLogAction = dataStatePart.createAction<{
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Action Log Actions
|
||||
// ============================================================================
|
||||
|
||||
export const fetchActionLogAction = actionLogStatePart.createAction<{
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
entityType?: interfaces.data.TActionEntity;
|
||||
} | null>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetActionLog
|
||||
>('/typedrequest', 'getActionLog');
|
||||
const response = await typedRequest.fire({
|
||||
identity: context.identity!,
|
||||
limit: dataArg?.limit,
|
||||
offset: dataArg?.offset,
|
||||
entityType: dataArg?.entityType,
|
||||
});
|
||||
return { entries: response.entries, total: response.total };
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch action log:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// UI Actions
|
||||
// ============================================================================
|
||||
|
||||
export const setActiveViewAction = uiStatePart.createAction<{ view: string }>(
|
||||
export const setActiveViewAction = uiStatePart.createAction<{
|
||||
view: string;
|
||||
navigationContext?: INavigationContext;
|
||||
}>(
|
||||
async (statePartArg, dataArg) => {
|
||||
return { ...statePartArg.getState(), activeView: dataArg.view };
|
||||
return {
|
||||
...statePartArg.getState(),
|
||||
activeView: dataArg.view,
|
||||
navigationContext: dataArg.navigationContext,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const clearNavigationContextAction = uiStatePart.createAction(
|
||||
async (statePartArg) => {
|
||||
return { ...statePartArg.getState(), navigationContext: undefined };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -543,3 +707,370 @@ export const toggleAutoRefreshAction = uiStatePart.createAction(async (statePart
|
||||
const state = statePartArg.getState();
|
||||
return { ...state, autoRefresh: !state.autoRefresh };
|
||||
});
|
||||
|
||||
export const setRefreshIntervalAction = uiStatePart.createAction<{ interval: number }>(
|
||||
async (statePartArg, dataArg) => {
|
||||
return { ...statePartArg.getState(), refreshInterval: dataArg.interval };
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
// ============================================================================
|
||||
|
||||
export interface ISyncState {
|
||||
configs: interfaces.data.ISyncConfig[];
|
||||
repoStatuses: interfaces.data.ISyncRepoStatus[];
|
||||
}
|
||||
|
||||
export const syncStatePart = await appState.getStatePart<ISyncState>(
|
||||
'sync',
|
||||
{ configs: [], repoStatuses: [] },
|
||||
'soft',
|
||||
);
|
||||
|
||||
export const fetchSyncConfigsAction = syncStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSyncConfigs
|
||||
>('/typedrequest', 'getSyncConfigs');
|
||||
const response = await typedRequest.fire({ identity: context.identity! });
|
||||
return { ...statePartArg.getState(), configs: response.configs };
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch sync configs:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const createSyncConfigAction = syncStatePart.createAction<{
|
||||
name: string;
|
||||
sourceConnectionId: string;
|
||||
targetConnectionId: string;
|
||||
targetGroupOffset?: string;
|
||||
intervalMinutes?: number;
|
||||
enforceDelete?: boolean;
|
||||
enforceGroupDelete?: boolean;
|
||||
addMirrorHint?: boolean;
|
||||
useGroupAvatarsForProjects?: boolean;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_CreateSyncConfig
|
||||
>('/typedrequest', 'createSyncConfig');
|
||||
await typedRequest.fire({ identity: context.identity!, ...dataArg });
|
||||
// Re-fetch
|
||||
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSyncConfigs
|
||||
>('/typedrequest', 'getSyncConfigs');
|
||||
const listResp = await listReq.fire({ identity: context.identity! });
|
||||
return { ...statePartArg.getState(), configs: listResp.configs };
|
||||
} catch (err) {
|
||||
console.error('Failed to create sync config:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const updateSyncConfigAction = syncStatePart.createAction<{
|
||||
syncConfigId: string;
|
||||
name?: string;
|
||||
targetGroupOffset?: string;
|
||||
intervalMinutes?: number;
|
||||
enforceDelete?: boolean;
|
||||
enforceGroupDelete?: boolean;
|
||||
addMirrorHint?: boolean;
|
||||
useGroupAvatarsForProjects?: boolean;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_UpdateSyncConfig
|
||||
>('/typedrequest', 'updateSyncConfig');
|
||||
await typedRequest.fire({ identity: context.identity!, ...dataArg });
|
||||
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSyncConfigs
|
||||
>('/typedrequest', 'getSyncConfigs');
|
||||
const listResp = await listReq.fire({ identity: context.identity! });
|
||||
return { ...statePartArg.getState(), configs: listResp.configs };
|
||||
} catch (err) {
|
||||
console.error('Failed to update sync config:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteSyncConfigAction = syncStatePart.createAction<{
|
||||
syncConfigId: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_DeleteSyncConfig
|
||||
>('/typedrequest', 'deleteSyncConfig');
|
||||
await typedRequest.fire({ identity: context.identity!, ...dataArg });
|
||||
const state = statePartArg.getState();
|
||||
return { ...state, configs: state.configs.filter((c) => c.id !== dataArg.syncConfigId) };
|
||||
} catch (err) {
|
||||
console.error('Failed to delete sync config:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const pauseSyncConfigAction = syncStatePart.createAction<{
|
||||
syncConfigId: string;
|
||||
paused: boolean;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_PauseSyncConfig
|
||||
>('/typedrequest', 'pauseSyncConfig');
|
||||
await typedRequest.fire({ identity: context.identity!, ...dataArg });
|
||||
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSyncConfigs
|
||||
>('/typedrequest', 'getSyncConfigs');
|
||||
const listResp = await listReq.fire({ identity: context.identity! });
|
||||
return { ...statePartArg.getState(), configs: listResp.configs };
|
||||
} catch (err) {
|
||||
console.error('Failed to pause/resume sync config:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const triggerSyncAction = syncStatePart.createAction<{
|
||||
syncConfigId: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_TriggerSync
|
||||
>('/typedrequest', 'triggerSync');
|
||||
await typedRequest.fire({ identity: context.identity!, ...dataArg });
|
||||
return statePartArg.getState();
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger sync:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const fetchSyncRepoStatusesAction = syncStatePart.createAction<{
|
||||
syncConfigId: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSyncRepoStatuses
|
||||
>('/typedrequest', 'getSyncRepoStatuses');
|
||||
const response = await typedRequest.fire({ identity: context.identity!, ...dataArg });
|
||||
return { ...statePartArg.getState(), repoStatuses: response.statuses };
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch sync repo statuses:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Sync Log — TypedSocket client for server-push entries
|
||||
// ============================================================================
|
||||
|
||||
export async function fetchSyncLogs(limit = 200): Promise<interfaces.data.ISyncLogEntry[]> {
|
||||
const identity = loginStatePart.getState().identity;
|
||||
if (!identity) throw new Error('Not logged in');
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSyncLogs
|
||||
>('/typedrequest', 'getSyncLogs');
|
||||
const response = await typedRequest.fire({ identity, limit });
|
||||
return response.logs;
|
||||
}
|
||||
|
||||
let syncLogSocketInitialized = false;
|
||||
|
||||
/**
|
||||
* Create a TypedSocket client that handles server-push sync log entries.
|
||||
* Dispatches 'gitops-sync-log-entry' custom events on document.
|
||||
* Call once after login.
|
||||
*/
|
||||
export async function initSyncLogSocket(): Promise<void> {
|
||||
if (syncLogSocketInitialized) return;
|
||||
syncLogSocketInitialized = true;
|
||||
|
||||
try {
|
||||
const typedrouter = new plugins.domtools.plugins.typedrequest.TypedRouter();
|
||||
|
||||
typedrouter.addTypedHandler(
|
||||
new plugins.domtools.plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushSyncLog>(
|
||||
'pushSyncLog',
|
||||
async (dataArg) => {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('gitops-sync-log-entry', { detail: dataArg.entry }),
|
||||
);
|
||||
return {};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Preview Helper
|
||||
// ============================================================================
|
||||
|
||||
export async function previewSync(syncConfigId: string): Promise<{
|
||||
mappings: Array<{ sourceFullPath: string; targetFullPath: string }>;
|
||||
deletions: string[];
|
||||
groupDeletions: string[];
|
||||
}> {
|
||||
const identity = loginStatePart.getState().identity;
|
||||
if (!identity) throw new Error('Not logged in');
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_PreviewSync
|
||||
>('/typedrequest', 'previewSync');
|
||||
const response = await typedRequest.fire({ identity, syncConfigId });
|
||||
return { mappings: response.mappings, deletions: response.deletions, groupDeletions: response.groupDeletions };
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ import type { GitopsViewGroups } from './views/groups/index.js';
|
||||
import type { GitopsViewSecrets } from './views/secrets/index.js';
|
||||
import type { GitopsViewPipelines } from './views/pipelines/index.js';
|
||||
import type { GitopsViewBuildlog } from './views/buildlog/index.js';
|
||||
import type { GitopsViewActions } from './views/actions/index.js';
|
||||
import type { GitopsViewActionlog } from './views/actionlog/index.js';
|
||||
import type { GitopsViewSync } from './views/sync/index.js';
|
||||
|
||||
@customElement('gitops-dashboard')
|
||||
export class GitopsDashboard extends DeesElement {
|
||||
@@ -37,12 +40,24 @@ 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)() },
|
||||
{ name: 'Action Log', iconName: 'lucide:scroll', element: (async () => (await import('./views/actionlog/index.js')).GitopsViewActionlog)() },
|
||||
{ name: 'Sync', iconName: 'lucide:refreshCw', element: (async () => (await import('./views/sync/index.js')).GitopsViewSync)() },
|
||||
];
|
||||
|
||||
private resolvedViewTabs: Array<{ name: string; iconName: string; element: any }> = [];
|
||||
|
||||
// Auto-refresh timer
|
||||
private autoRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// WebSocket client
|
||||
private ws: WebSocket | null = null;
|
||||
private wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private wsIntentionalClose = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
document.title = 'GitOps';
|
||||
@@ -53,7 +68,11 @@ export class GitopsDashboard extends DeesElement {
|
||||
this.loginState = loginState;
|
||||
if (loginState.isLoggedIn) {
|
||||
appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
|
||||
this.connectWebSocket();
|
||||
} else {
|
||||
this.disconnectWebSocket();
|
||||
}
|
||||
this.manageAutoRefreshTimer();
|
||||
});
|
||||
this.rxSubscriptions.push(loginSubscription);
|
||||
|
||||
@@ -62,6 +81,7 @@ export class GitopsDashboard extends DeesElement {
|
||||
.subscribe((uiState) => {
|
||||
this.uiState = uiState;
|
||||
this.syncAppdashView(uiState.activeView);
|
||||
this.manageAutoRefreshTimer();
|
||||
});
|
||||
this.rxSubscriptions.push(uiSubscription);
|
||||
}
|
||||
@@ -78,6 +98,36 @@ export class GitopsDashboard extends DeesElement {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
.auto-refresh-toggle {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
z-index: 1000;
|
||||
background: rgba(30, 30, 50, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
color: #ccc;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.auto-refresh-toggle:hover {
|
||||
background: rgba(40, 40, 70, 0.95);
|
||||
}
|
||||
.auto-refresh-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #666;
|
||||
}
|
||||
.auto-refresh-dot.active {
|
||||
background: #00ff88;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -92,6 +142,15 @@ export class GitopsDashboard extends DeesElement {
|
||||
</dees-simple-appdash>
|
||||
</dees-simple-login>
|
||||
</div>
|
||||
${this.loginState.isLoggedIn ? html`
|
||||
<div
|
||||
class="auto-refresh-toggle"
|
||||
@click=${() => appstate.uiStatePart.dispatchAction(appstate.toggleAutoRefreshAction, null)}
|
||||
>
|
||||
<span class="auto-refresh-dot ${this.uiState.autoRefresh ? 'active' : ''}"></span>
|
||||
Auto-Refresh: ${this.uiState.autoRefresh ? 'ON' : 'OFF'}
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -118,6 +177,7 @@ export class GitopsDashboard extends DeesElement {
|
||||
if (appDash) {
|
||||
appDash.addEventListener('view-select', (e: CustomEvent) => {
|
||||
const viewName = e.detail.view.name.toLowerCase();
|
||||
if (this.uiState.activeView === viewName) return;
|
||||
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: viewName });
|
||||
});
|
||||
appDash.addEventListener('logout', async () => {
|
||||
@@ -160,6 +220,93 @@ export class GitopsDashboard extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.clearAutoRefreshTimer();
|
||||
this.disconnectWebSocket();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auto-refresh timer management
|
||||
// ============================================================================
|
||||
|
||||
private manageAutoRefreshTimer(): void {
|
||||
this.clearAutoRefreshTimer();
|
||||
const { autoRefresh, refreshInterval } = this.uiState;
|
||||
if (autoRefresh && this.loginState.isLoggedIn) {
|
||||
this.autoRefreshTimer = setInterval(() => {
|
||||
document.dispatchEvent(new CustomEvent('gitops-auto-refresh'));
|
||||
}, refreshInterval);
|
||||
}
|
||||
}
|
||||
|
||||
private clearAutoRefreshTimer(): void {
|
||||
if (this.autoRefreshTimer) {
|
||||
clearInterval(this.autoRefreshTimer);
|
||||
this.autoRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket client for webhook push notifications
|
||||
// ============================================================================
|
||||
|
||||
private connectWebSocket(): void {
|
||||
if (this.ws) return;
|
||||
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${location.host}`;
|
||||
|
||||
try {
|
||||
this.wsIntentionalClose = false;
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
// TypedSocket wraps messages; look for webhookNotification method
|
||||
if (data?.method === 'webhookNotification' || data?.type === 'webhookEvent') {
|
||||
console.log('Webhook event received:', data);
|
||||
document.dispatchEvent(new CustomEvent('gitops-auto-refresh'));
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, ignore
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.addEventListener('close', () => {
|
||||
this.ws = null;
|
||||
if (!this.wsIntentionalClose && this.loginState.isLoggedIn) {
|
||||
this.wsReconnectTimer = setTimeout(() => {
|
||||
this.connectWebSocket();
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.addEventListener('error', () => {
|
||||
// Will trigger close event
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('WebSocket connection failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private disconnectWebSocket(): void {
|
||||
this.wsIntentionalClose = true;
|
||||
if (this.wsReconnectTimer) {
|
||||
clearTimeout(this.wsReconnectTimer);
|
||||
this.wsReconnectTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Login
|
||||
// ============================================================================
|
||||
|
||||
private async login(username: string, password: string) {
|
||||
const domtools = await this.domtoolsPromise;
|
||||
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
|
||||
@@ -195,6 +342,7 @@ export class GitopsDashboard extends DeesElement {
|
||||
if (!appDash || this.resolvedViewTabs.length === 0) return;
|
||||
const targetTab = this.resolvedViewTabs.find((t) => t.name.toLowerCase() === viewName);
|
||||
if (!targetTab) return;
|
||||
if (appDash.selectedView === targetTab) return;
|
||||
appDash.loadView(targetTab);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,3 +6,5 @@ import './views/groups/index.js';
|
||||
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';
|
||||
|
||||
101
ts_web/elements/views/actionlog/index.ts
Normal file
101
ts_web/elements/views/actionlog/index.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
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-actionlog')
|
||||
export class GitopsViewActionlog extends DeesElement {
|
||||
@state()
|
||||
accessor actionLogState: appstate.IActionLogState = {
|
||||
entries: [],
|
||||
total: 0,
|
||||
};
|
||||
|
||||
@state()
|
||||
accessor selectedEntityType: string = 'all';
|
||||
|
||||
private _autoRefreshHandler: () => void;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.actionLogStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => { this.actionLogState = s; });
|
||||
this.rxSubscriptions.push(sub);
|
||||
|
||||
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,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const entityOptions = [
|
||||
{ option: 'All', key: 'all' },
|
||||
{ option: 'Connection', key: 'connection' },
|
||||
{ option: 'Secret', key: 'secret' },
|
||||
{ option: 'Pipeline', key: 'pipeline' },
|
||||
];
|
||||
|
||||
return html`
|
||||
<div class="view-title">Action Log</div>
|
||||
<div class="view-description">Audit trail of all operations performed in the system</div>
|
||||
<div class="toolbar">
|
||||
<dees-input-dropdown
|
||||
.label=${'Entity Type'}
|
||||
.options=${entityOptions}
|
||||
.selectedOption=${entityOptions.find((o) => o.key === this.selectedEntityType)}
|
||||
@selectedOption=${(e: CustomEvent) => {
|
||||
this.selectedEntityType = e.detail.key;
|
||||
this.refresh();
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
<dees-button @click=${() => this.refresh()}>Refresh</dees-button>
|
||||
</div>
|
||||
<dees-table
|
||||
.heading1=${'Action Log'}
|
||||
.heading2=${`${this.actionLogState.total} entries total`}
|
||||
.data=${this.actionLogState.entries}
|
||||
.displayFunction=${(item: any) => ({
|
||||
Time: new Date(item.timestamp).toLocaleString(),
|
||||
Action: item.actionType,
|
||||
Entity: item.entityType,
|
||||
Name: item.entityName,
|
||||
Details: item.details,
|
||||
User: item.username,
|
||||
})}
|
||||
.dataActions=${[]}
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
async firstUpdated() {
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
private async refresh() {
|
||||
const entityType = this.selectedEntityType === 'all'
|
||||
? undefined
|
||||
: this.selectedEntityType as any;
|
||||
await appstate.actionLogStatePart.dispatchAction(appstate.fetchActionLogAction, {
|
||||
limit: 100,
|
||||
entityType,
|
||||
});
|
||||
}
|
||||
}
|
||||
209
ts_web/elements/views/actions/index.ts
Normal file
209
ts_web/elements/views/actions/index.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import * as interfaces from '../../../../ts_interfaces/index.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-actions')
|
||||
export class GitopsViewActions extends DeesElement {
|
||||
@state()
|
||||
accessor lastScanTimestamp: number = 0;
|
||||
|
||||
@state()
|
||||
accessor isScanning: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor lastResult: {
|
||||
connectionsScanned: number;
|
||||
secretsFound: number;
|
||||
errors: string[];
|
||||
durationMs: number;
|
||||
} | null = null;
|
||||
|
||||
@state()
|
||||
accessor statusError: string = '';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
.action-cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
max-width: 720px;
|
||||
}
|
||||
.action-card {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 28px;
|
||||
}
|
||||
.action-card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.action-card-description {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 8px 16px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.info-label {
|
||||
color: #888;
|
||||
}
|
||||
.info-value {
|
||||
color: #ddd;
|
||||
font-family: monospace;
|
||||
}
|
||||
.info-value.scanning {
|
||||
color: #f0c040;
|
||||
}
|
||||
.info-value.error {
|
||||
color: #ff6060;
|
||||
}
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.errors-list {
|
||||
margin-top: 12px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
font-size: 12px;
|
||||
color: #ff8080;
|
||||
font-family: monospace;
|
||||
line-height: 1.6;
|
||||
background: rgba(255, 0, 0, 0.05);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const lastScanFormatted = this.lastScanTimestamp
|
||||
? new Date(this.lastScanTimestamp).toLocaleString()
|
||||
: 'Never';
|
||||
|
||||
return html`
|
||||
<div class="view-title">Actions</div>
|
||||
<div class="view-description">System actions and maintenance tasks</div>
|
||||
<div class="action-cards">
|
||||
<div class="action-card">
|
||||
<div class="action-card-title">Secrets Cache Scan</div>
|
||||
<div class="action-card-description">
|
||||
Secrets are automatically scanned and cached every 24 hours.
|
||||
Use "Force Full Scan" to trigger an immediate refresh of all secrets
|
||||
across all connections, projects, and groups.
|
||||
</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-label">Status</div>
|
||||
<div class="info-value ${this.isScanning ? 'scanning' : ''}">
|
||||
${this.isScanning ? 'Scanning...' : 'Idle'}
|
||||
</div>
|
||||
<div class="info-label">Last Scan</div>
|
||||
<div class="info-value">${lastScanFormatted}</div>
|
||||
${this.lastResult ? html`
|
||||
<div class="info-label">Connections</div>
|
||||
<div class="info-value">${this.lastResult.connectionsScanned}</div>
|
||||
<div class="info-label">Secrets Found</div>
|
||||
<div class="info-value">${this.lastResult.secretsFound}</div>
|
||||
<div class="info-label">Duration</div>
|
||||
<div class="info-value">${(this.lastResult.durationMs / 1000).toFixed(1)}s</div>
|
||||
${this.lastResult.errors.length > 0 ? html`
|
||||
<div class="info-label">Errors</div>
|
||||
<div class="info-value error">${this.lastResult.errors.length}</div>
|
||||
` : ''}
|
||||
` : ''}
|
||||
</div>
|
||||
${this.statusError ? html`
|
||||
<div class="errors-list">${this.statusError}</div>
|
||||
` : ''}
|
||||
${this.lastResult?.errors?.length ? html`
|
||||
<div class="errors-list">
|
||||
${this.lastResult.errors.map((e) => html`<div>${e}</div>`)}
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="button-row">
|
||||
<dees-button
|
||||
.disabled=${this.isScanning}
|
||||
@click=${() => this.forceScan()}
|
||||
>Force Full Scan</dees-button>
|
||||
<dees-button
|
||||
@click=${() => this.refreshStatus()}
|
||||
>Refresh Status</dees-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async firstUpdated() {
|
||||
await this.refreshStatus();
|
||||
}
|
||||
|
||||
private getIdentity(): interfaces.data.IIdentity | null {
|
||||
return appstate.loginStatePart.getState().identity;
|
||||
}
|
||||
|
||||
private async refreshStatus(): Promise<void> {
|
||||
const identity = this.getIdentity();
|
||||
if (!identity) return;
|
||||
|
||||
try {
|
||||
this.statusError = '';
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetScanStatus
|
||||
>('/typedrequest', 'getScanStatus');
|
||||
const response = await typedRequest.fire({ identity });
|
||||
this.lastScanTimestamp = response.lastScanTimestamp;
|
||||
this.isScanning = response.isScanning;
|
||||
this.lastResult = response.lastResult;
|
||||
} catch (err) {
|
||||
this.statusError = `Failed to get status: ${err}`;
|
||||
}
|
||||
}
|
||||
|
||||
private async forceScan(): Promise<void> {
|
||||
const identity = this.getIdentity();
|
||||
if (!identity) return;
|
||||
|
||||
try {
|
||||
this.statusError = '';
|
||||
this.isScanning = true;
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ForceScanSecrets
|
||||
>('/typedrequest', 'forceScanSecrets');
|
||||
const response = await typedRequest.fire({ identity });
|
||||
this.lastResult = {
|
||||
connectionsScanned: response.connectionsScanned,
|
||||
secretsFound: response.secretsFound,
|
||||
errors: response.errors,
|
||||
durationMs: response.durationMs,
|
||||
};
|
||||
this.lastScanTimestamp = Date.now();
|
||||
this.isScanning = false;
|
||||
} catch (err) {
|
||||
this.statusError = `Scan failed: ${err}`;
|
||||
this.isScanning = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,8 @@ export class GitopsViewBuildlog extends DeesElement {
|
||||
@state()
|
||||
accessor selectedJobId: string = '';
|
||||
|
||||
private _autoRefreshHandler: () => void;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const connSub = appstate.connectionsStatePart
|
||||
@@ -49,6 +51,18 @@ export class GitopsViewBuildlog extends DeesElement {
|
||||
.select((s) => s)
|
||||
.subscribe((s) => { this.dataState = s; });
|
||||
this.rxSubscriptions.push(dataSub);
|
||||
|
||||
this._autoRefreshHandler = () => this.handleAutoRefresh();
|
||||
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
private handleAutoRefresh(): void {
|
||||
this.fetchLog();
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
|
||||
@@ -19,12 +19,26 @@ export class GitopsViewConnections extends DeesElement {
|
||||
activeConnectionId: null,
|
||||
};
|
||||
|
||||
private _autoRefreshHandler: () => void;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.connectionsStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => { this.connectionsState = s; });
|
||||
this.rxSubscriptions.push(sub);
|
||||
|
||||
this._autoRefreshHandler = () => this.handleAutoRefresh();
|
||||
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
private handleAutoRefresh(): void {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
@@ -48,25 +62,60 @@ export class GitopsViewConnections extends DeesElement {
|
||||
Name: item.name,
|
||||
Type: item.providerType,
|
||||
URL: item.baseUrl,
|
||||
'Group Filter': item.groupFilter || '-',
|
||||
Status: item.status,
|
||||
Created: new Date(item.createdAt).toLocaleDateString(),
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Edit',
|
||||
iconName: 'lucide:edit',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => { await this.editConnection(item); },
|
||||
},
|
||||
{
|
||||
name: 'Test',
|
||||
iconName: 'lucide:plug',
|
||||
action: async (item: any) => {
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => {
|
||||
await appstate.connectionsStatePart.dispatchAction(
|
||||
appstate.testConnectionAction,
|
||||
{ connectionId: item.id },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Pause/Resume',
|
||||
iconName: 'lucide:pauseCircle',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => {
|
||||
const isPaused = item.status === 'paused';
|
||||
const actionLabel = isPaused ? 'Resume' : 'Pause';
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: `${actionLabel} Connection`,
|
||||
content: html`<p style="color: #fff;">Are you sure you want to ${actionLabel.toLowerCase()} connection "${item.name}"?</p>`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||
{
|
||||
name: actionLabel,
|
||||
action: async (modal: any) => {
|
||||
await appstate.connectionsStatePart.dispatchAction(
|
||||
appstate.pauseConnectionAction,
|
||||
{ connectionId: item.id, paused: !isPaused },
|
||||
);
|
||||
modal.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash2',
|
||||
action: async (item: any) => {
|
||||
const confirmed = await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => {
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Delete Connection',
|
||||
content: html`<p style="color: #fff;">Are you sure you want to delete connection "${item.name}"?</p>`,
|
||||
menuOptions: [
|
||||
@@ -98,6 +147,55 @@ export class GitopsViewConnections extends DeesElement {
|
||||
await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
|
||||
}
|
||||
|
||||
private async editConnection(item: any) {
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Edit Connection',
|
||||
content: html`
|
||||
<style>
|
||||
.form-row { margin-bottom: 16px; }
|
||||
.form-info { font-size: 13px; color: #888; margin-bottom: 16px; }
|
||||
</style>
|
||||
<div class="form-info">Provider: ${item.providerType}</div>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Name'} .key=${'name'} .value=${item.name}></dees-input-text>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Base URL'} .key=${'baseUrl'} .value=${item.baseUrl}></dees-input-text>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'API Token (leave empty to keep current)'} .key=${'token'} type="password"></dees-input-text>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Group Filter (optional)'} .key=${'groupFilter'} .value=${item.groupFilter || ''} .description=${'Restricts which repos this connection can see (e.g. an org name or GitLab group path). Does not affect where synced repos are placed.'}></dees-input-text>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||
{
|
||||
name: 'Save',
|
||||
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 || '';
|
||||
}
|
||||
await appstate.connectionsStatePart.dispatchAction(
|
||||
appstate.updateConnectionAction,
|
||||
{
|
||||
connectionId: item.id,
|
||||
name: data.name,
|
||||
baseUrl: data.baseUrl,
|
||||
groupFilter: data.groupFilter,
|
||||
...(data.token ? { token: data.token } : {}),
|
||||
},
|
||||
);
|
||||
modal.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async addConnection() {
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Add Connection',
|
||||
@@ -125,6 +223,9 @@ export class GitopsViewConnections extends DeesElement {
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'API Token'} .key=${'token'} type="password"></dees-input-text>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Group Filter (optional)'} .key=${'groupFilter'} .description=${'Restricts which repos this connection can see (e.g. an org name or GitLab group path). Does not affect where synced repos are placed.'}></dees-input-text>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||
@@ -147,6 +248,7 @@ export class GitopsViewConnections extends DeesElement {
|
||||
providerType: data.providerType,
|
||||
baseUrl: data.baseUrl,
|
||||
token: data.token,
|
||||
groupFilter: data.groupFilter || undefined,
|
||||
},
|
||||
);
|
||||
modal.destroy();
|
||||
|
||||
@@ -32,6 +32,8 @@ export class GitopsViewGroups extends DeesElement {
|
||||
@state()
|
||||
accessor selectedConnectionId: string = '';
|
||||
|
||||
private _autoRefreshHandler: () => void;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const connSub = appstate.connectionsStatePart
|
||||
@@ -43,6 +45,18 @@ export class GitopsViewGroups extends DeesElement {
|
||||
.select((s) => s)
|
||||
.subscribe((s) => { this.dataState = s; });
|
||||
this.rxSubscriptions.push(dataSub);
|
||||
|
||||
this._autoRefreshHandler = () => this.handleAutoRefresh();
|
||||
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
private handleAutoRefresh(): void {
|
||||
this.loadGroups();
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
@@ -85,8 +99,16 @@ export class GitopsViewGroups extends DeesElement {
|
||||
{
|
||||
name: 'View Secrets',
|
||||
iconName: 'lucide:key',
|
||||
action: async (item: any) => {
|
||||
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'secrets' });
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => {
|
||||
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, {
|
||||
view: 'secrets',
|
||||
navigationContext: {
|
||||
connectionId: this.selectedConnectionId,
|
||||
scope: 'group',
|
||||
scopeId: item.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
|
||||
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();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ export class GitopsViewOverview extends DeesElement {
|
||||
currentJobLog: '',
|
||||
};
|
||||
|
||||
private _autoRefreshHandler: () => void;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const connSub = appstate.connectionsStatePart
|
||||
@@ -41,6 +43,18 @@ export class GitopsViewOverview extends DeesElement {
|
||||
.select((s) => s)
|
||||
.subscribe((s) => { this.dataState = s; });
|
||||
this.rxSubscriptions.push(dataSub);
|
||||
|
||||
this._autoRefreshHandler = () => this.handleAutoRefresh();
|
||||
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
private handleAutoRefresh(): void {
|
||||
appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
|
||||
@@ -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,11 +33,16 @@ export class GitopsViewPipelines extends DeesElement {
|
||||
currentJobLog: '',
|
||||
};
|
||||
|
||||
@state()
|
||||
accessor selectedConnectionId: 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;
|
||||
|
||||
@state()
|
||||
accessor selectedProjectId: string = '';
|
||||
private _autoRefreshHandler: () => void;
|
||||
private _logPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -46,6 +55,19 @@ export class GitopsViewPipelines extends DeesElement {
|
||||
.select((s) => s)
|
||||
.subscribe((s) => { this.dataState = s; });
|
||||
this.rxSubscriptions.push(dataSub);
|
||||
|
||||
this._autoRefreshHandler = () => this.handleAutoRefresh();
|
||||
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
this.stopLogPolling();
|
||||
}
|
||||
|
||||
private handleAutoRefresh(): void {
|
||||
this.loadPipelines();
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
@@ -74,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>
|
||||
@@ -89,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>
|
||||
@@ -105,42 +195,58 @@ 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',
|
||||
action: async (item: any) => { await this.viewJobs(item); },
|
||||
type: ['contextmenu'],
|
||||
actionFunc: async ({ item }: any) => { await this.viewJobs(item); },
|
||||
},
|
||||
{
|
||||
name: 'Retry',
|
||||
iconName: 'lucide:refresh-cw',
|
||||
action: async (item: any) => {
|
||||
iconName: 'lucide:refreshCw',
|
||||
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:x-circle',
|
||||
action: async (item: any) => {
|
||||
iconName: 'lucide:xCircle',
|
||||
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();
|
||||
},
|
||||
},
|
||||
]}
|
||||
@@ -150,10 +256,55 @@ export class GitopsViewPipelines extends DeesElement {
|
||||
|
||||
async firstUpdated() {
|
||||
await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
|
||||
|
||||
// Check for navigation context from projects view
|
||||
const navCtx = appstate.uiStatePart.getState().navigationContext;
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,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,
|
||||
});
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ export class GitopsViewProjects extends DeesElement {
|
||||
@state()
|
||||
accessor selectedConnectionId: string = '';
|
||||
|
||||
private _autoRefreshHandler: () => void;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const connSub = appstate.connectionsStatePart
|
||||
@@ -43,6 +45,18 @@ export class GitopsViewProjects extends DeesElement {
|
||||
.select((s) => s)
|
||||
.subscribe((s) => { this.dataState = s; });
|
||||
this.rxSubscriptions.push(dataSub);
|
||||
|
||||
this._autoRefreshHandler = () => this.handleAutoRefresh();
|
||||
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
private handleAutoRefresh(): void {
|
||||
this.loadProjects();
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
@@ -86,15 +100,30 @@ export class GitopsViewProjects extends DeesElement {
|
||||
{
|
||||
name: 'View Secrets',
|
||||
iconName: 'lucide:key',
|
||||
action: async (item: any) => {
|
||||
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'secrets' });
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => {
|
||||
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, {
|
||||
view: 'secrets',
|
||||
navigationContext: {
|
||||
connectionId: this.selectedConnectionId,
|
||||
scope: 'project',
|
||||
scopeId: item.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'View Pipelines',
|
||||
iconName: 'lucide:play',
|
||||
action: async (item: any) => {
|
||||
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'pipelines' });
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => {
|
||||
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, {
|
||||
view: 'pipelines',
|
||||
navigationContext: {
|
||||
connectionId: this.selectedConnectionId,
|
||||
projectId: item.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -33,10 +33,12 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
accessor selectedConnectionId: string = '';
|
||||
|
||||
@state()
|
||||
accessor selectedScope: 'project' | 'group' = 'project';
|
||||
accessor selectedScope: 'all' | 'project' | 'group' = 'all';
|
||||
|
||||
@state()
|
||||
accessor selectedScopeId: string = '';
|
||||
accessor selectedScopeId: string = '__all__';
|
||||
|
||||
private _autoRefreshHandler: () => void;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -49,6 +51,18 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
.select((s) => s)
|
||||
.subscribe((s) => { this.dataState = s; });
|
||||
this.rxSubscriptions.push(dataSub);
|
||||
|
||||
this._autoRefreshHandler = () => this.handleAutoRefresh();
|
||||
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
}
|
||||
|
||||
private handleAutoRefresh(): void {
|
||||
this.loadSecrets();
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
@@ -56,6 +70,19 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
viewHostCss,
|
||||
];
|
||||
|
||||
private get filteredSecrets() {
|
||||
let secrets = this.dataState.secrets;
|
||||
// Filter by scope (unless "all")
|
||||
if (this.selectedScope !== 'all') {
|
||||
secrets = secrets.filter((s) => s.scope === this.selectedScope);
|
||||
}
|
||||
// Filter by entity if specific one selected
|
||||
if (this.selectedScopeId !== '__all__') {
|
||||
secrets = secrets.filter((s) => s.scopeId === this.selectedScopeId);
|
||||
}
|
||||
return secrets;
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const connectionOptions = this.connectionsState.connections.map((c) => ({
|
||||
option: `${c.name} (${c.providerType})`,
|
||||
@@ -63,13 +90,23 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
}));
|
||||
|
||||
const scopeOptions = [
|
||||
{ option: 'All Scopes', key: 'all' },
|
||||
{ option: 'Project', key: 'project' },
|
||||
{ option: 'Group', key: 'group' },
|
||||
];
|
||||
|
||||
const entityOptions = this.selectedScope === 'project'
|
||||
? this.dataState.projects.map((p) => ({ option: p.fullPath || p.name, key: p.id }))
|
||||
: this.dataState.groups.map((g) => ({ option: g.fullPath || g.name, key: g.id }));
|
||||
const entities = this.selectedScope === 'group'
|
||||
? this.dataState.groups.map((g) => ({ option: g.fullPath || g.name, key: g.id }))
|
||||
: this.selectedScope === 'project'
|
||||
? this.dataState.projects.map((p) => ({ option: p.fullPath || p.name, key: p.id }))
|
||||
: [];
|
||||
|
||||
const entityOptions = [
|
||||
{ option: 'All', key: '__all__' },
|
||||
...entities,
|
||||
];
|
||||
|
||||
const isAllSelected = this.selectedScope === 'all' || this.selectedScopeId === '__all__';
|
||||
|
||||
return html`
|
||||
<div class="view-title">Secrets</div>
|
||||
@@ -81,7 +118,9 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
.selectedOption=${connectionOptions.find((o) => o.key === this.selectedConnectionId) || connectionOptions[0]}
|
||||
@selectedOption=${(e: CustomEvent) => {
|
||||
this.selectedConnectionId = e.detail.key;
|
||||
this.selectedScopeId = '__all__';
|
||||
this.loadEntities();
|
||||
this.loadSecrets();
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
<dees-input-dropdown
|
||||
@@ -89,28 +128,35 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
.options=${scopeOptions}
|
||||
.selectedOption=${scopeOptions.find((o) => o.key === this.selectedScope)}
|
||||
@selectedOption=${(e: CustomEvent) => {
|
||||
this.selectedScope = e.detail.key as 'project' | 'group';
|
||||
this.selectedScope = e.detail.key as 'all' | 'project' | 'group';
|
||||
this.selectedScopeId = '__all__';
|
||||
this.loadEntities();
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
<dees-input-dropdown
|
||||
.label=${this.selectedScope === 'project' ? 'Project' : 'Group'}
|
||||
.options=${entityOptions}
|
||||
.selectedOption=${entityOptions.find((o) => o.key === this.selectedScopeId) || entityOptions[0]}
|
||||
@selectedOption=${(e: CustomEvent) => {
|
||||
this.selectedScopeId = e.detail.key;
|
||||
this.loadSecrets();
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
<dees-button @click=${() => this.addSecret()}>Add Secret</dees-button>
|
||||
${this.selectedScope !== 'all' ? html`
|
||||
<dees-input-dropdown
|
||||
.label=${this.selectedScope === 'project' ? 'Project' : 'Group'}
|
||||
.options=${entityOptions}
|
||||
.selectedOption=${entityOptions.find((o) => o.key === this.selectedScopeId) || entityOptions[0]}
|
||||
@selectedOption=${(e: CustomEvent) => {
|
||||
this.selectedScopeId = e.detail.key;
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
` : ''}
|
||||
<dees-button
|
||||
.disabled=${isAllSelected}
|
||||
@click=${() => this.addSecret()}
|
||||
>Add Secret</dees-button>
|
||||
<dees-button @click=${() => this.loadSecrets()}>Refresh</dees-button>
|
||||
</div>
|
||||
<dees-table
|
||||
.heading1=${'Secrets'}
|
||||
.heading2=${'CI/CD variables for the selected entity'}
|
||||
.data=${this.dataState.secrets}
|
||||
.data=${this.filteredSecrets}
|
||||
.displayFunction=${(item: any) => ({
|
||||
Key: item.key,
|
||||
Scope: item.scopeName || item.scopeId,
|
||||
Value: item.masked ? '******' : item.value,
|
||||
Protected: item.protected ? 'Yes' : 'No',
|
||||
Environment: item.environment || '*',
|
||||
@@ -119,17 +165,32 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
{
|
||||
name: 'Edit',
|
||||
iconName: 'lucide:edit',
|
||||
action: async (item: any) => { await this.editSecret(item); },
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => { await this.editSecret(item); },
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash2',
|
||||
action: async (item: any) => {
|
||||
await appstate.dataStatePart.dispatchAction(appstate.deleteSecretAction, {
|
||||
connectionId: this.selectedConnectionId,
|
||||
scope: this.selectedScope,
|
||||
scopeId: this.selectedScopeId,
|
||||
key: item.key,
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => {
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Delete Secret',
|
||||
content: html`<p style="color: #fff;">Are you sure you want to delete secret "${item.key}"?</p>`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||
{
|
||||
name: 'Delete',
|
||||
action: async (modal: any) => {
|
||||
await appstate.dataStatePart.dispatchAction(appstate.deleteSecretAction, {
|
||||
connectionId: this.selectedConnectionId,
|
||||
scope: item.scope,
|
||||
scopeId: item.scopeId,
|
||||
key: item.key,
|
||||
});
|
||||
modal.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -140,15 +201,30 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
|
||||
async firstUpdated() {
|
||||
await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
|
||||
|
||||
// Check for navigation context from projects/groups view
|
||||
const navCtx = appstate.uiStatePart.getState().navigationContext;
|
||||
if (navCtx?.connectionId && navCtx?.scope && navCtx?.scopeId) {
|
||||
this.selectedConnectionId = navCtx.connectionId;
|
||||
this.selectedScope = navCtx.scope;
|
||||
this.selectedScopeId = navCtx.scopeId;
|
||||
appstate.uiStatePart.dispatchAction(appstate.clearNavigationContextAction, null);
|
||||
await this.loadEntities();
|
||||
await this.loadSecrets();
|
||||
return;
|
||||
}
|
||||
|
||||
const conns = appstate.connectionsStatePart.getState().connections;
|
||||
if (conns.length > 0 && !this.selectedConnectionId) {
|
||||
this.selectedConnectionId = conns[0].id;
|
||||
await this.loadEntities();
|
||||
await this.loadSecrets();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadEntities() {
|
||||
if (!this.selectedConnectionId) return;
|
||||
if (this.selectedScope === 'all') return;
|
||||
if (this.selectedScope === 'project') {
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchProjectsAction, {
|
||||
connectionId: this.selectedConnectionId,
|
||||
@@ -161,15 +237,15 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
}
|
||||
|
||||
private async loadSecrets() {
|
||||
if (!this.selectedConnectionId || !this.selectedScopeId) return;
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchSecretsAction, {
|
||||
if (!this.selectedConnectionId) return;
|
||||
// Always fetch both scopes — client-side filtering handles the rest
|
||||
await appstate.dataStatePart.dispatchAction(appstate.fetchAllSecretsAction, {
|
||||
connectionId: this.selectedConnectionId,
|
||||
scope: this.selectedScope,
|
||||
scopeId: this.selectedScopeId,
|
||||
});
|
||||
}
|
||||
|
||||
private async addSecret() {
|
||||
if (this.selectedScope === 'all' || this.selectedScopeId === '__all__') return;
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Add Secret',
|
||||
content: html`
|
||||
@@ -220,8 +296,8 @@ export class GitopsViewSecrets extends DeesElement {
|
||||
const input = modal.shadowRoot.querySelector('dees-input-text');
|
||||
await appstate.dataStatePart.dispatchAction(appstate.updateSecretAction, {
|
||||
connectionId: this.selectedConnectionId,
|
||||
scope: this.selectedScope,
|
||||
scopeId: this.selectedScopeId,
|
||||
scope: item.scope,
|
||||
scopeId: item.scopeId,
|
||||
key: item.key,
|
||||
value: input?.value || '',
|
||||
});
|
||||
|
||||
512
ts_web/elements/views/sync/index.ts
Normal file
512
ts_web/elements/views/sync/index.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
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-sync')
|
||||
export class GitopsViewSync extends DeesElement {
|
||||
@state()
|
||||
accessor syncState: appstate.ISyncState = { configs: [], repoStatuses: [] };
|
||||
|
||||
@state()
|
||||
accessor connectionsState: appstate.IConnectionsState = {
|
||||
connections: [],
|
||||
activeConnectionId: null,
|
||||
};
|
||||
|
||||
private _autoRefreshHandler: () => void;
|
||||
private _syncLogHandler: (e: Event) => void;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const syncSub = appstate.syncStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => { this.syncState = s; });
|
||||
this.rxSubscriptions.push(syncSub);
|
||||
|
||||
const connSub = appstate.connectionsStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => { this.connectionsState = s; });
|
||||
this.rxSubscriptions.push(connSub);
|
||||
|
||||
this._autoRefreshHandler = () => this.refresh();
|
||||
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
|
||||
// Listen for server-push sync log entries via TypedSocket
|
||||
this._syncLogHandler = (e: Event) => {
|
||||
const entry = (e as CustomEvent).detail;
|
||||
if (!entry) return;
|
||||
const chartLog = this.shadowRoot?.querySelector('dees-chart-log') as any;
|
||||
if (chartLog?.addLog) {
|
||||
chartLog.addLog(entry.level, entry.message, entry.source);
|
||||
}
|
||||
};
|
||||
document.addEventListener('gitops-sync-log-entry', this._syncLogHandler);
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
|
||||
document.removeEventListener('gitops-sync-log-entry', this._syncLogHandler);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.status-active { background: #1a3a1a; color: #00ff88; }
|
||||
.status-paused { background: #3a3a1a; color: #ffaa00; }
|
||||
.status-error { background: #3a1a1a; color: #ff4444; }
|
||||
|
||||
dees-chart-log {
|
||||
margin-top: 24px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="view-title">Sync</div>
|
||||
<div class="view-description">Mirror repositories between Gitea and GitLab instances</div>
|
||||
<div class="toolbar">
|
||||
<dees-button @click=${() => this.addSyncConfig()}>Add Sync</dees-button>
|
||||
<dees-button @click=${() => this.refresh()}>Refresh</dees-button>
|
||||
</div>
|
||||
<dees-table
|
||||
.heading1=${'Sync Configurations'}
|
||||
.heading2=${'Automatic repository mirroring between instances'}
|
||||
.data=${this.syncState.configs}
|
||||
.displayFunction=${(item: any) => {
|
||||
const sourceConn = this.connectionsState.connections.find((c) => c.id === item.sourceConnectionId);
|
||||
const targetConn = this.connectionsState.connections.find((c) => c.id === item.targetConnectionId);
|
||||
return {
|
||||
Name: item.name,
|
||||
Source: sourceConn?.name || item.sourceConnectionId,
|
||||
'Target': `${targetConn?.name || item.targetConnectionId}${item.targetGroupOffset ? ` → ${item.targetGroupOffset}/` : ''}`,
|
||||
Interval: `${item.intervalMinutes}m`,
|
||||
Status: item.status,
|
||||
'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),
|
||||
};
|
||||
}}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Preview',
|
||||
iconName: 'lucide:eye',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => { await this.previewSync(item); },
|
||||
},
|
||||
{
|
||||
name: 'Trigger Now',
|
||||
iconName: 'lucide:play',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => {
|
||||
const statusNote = item.status === 'paused' ? ' (config is paused — this is a one-off run)' : '';
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Trigger Sync',
|
||||
content: html`<p style="color: #fff;">Run sync "${item.name}" now?${statusNote}</p>`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||
{
|
||||
name: 'Trigger',
|
||||
action: async (modal: any) => {
|
||||
await appstate.syncStatePart.dispatchAction(appstate.triggerSyncAction, {
|
||||
syncConfigId: item.id,
|
||||
});
|
||||
modal.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'View Repos',
|
||||
iconName: 'lucide:list',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => { await this.viewRepoStatuses(item); },
|
||||
},
|
||||
{
|
||||
name: 'Edit',
|
||||
iconName: 'lucide:edit',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => { await this.editSyncConfig(item); },
|
||||
},
|
||||
{
|
||||
name: 'Pause/Resume',
|
||||
iconName: 'lucide:pauseCircle',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => {
|
||||
const isPaused = item.status === 'paused';
|
||||
const actionLabel = isPaused ? 'Resume' : 'Pause';
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: `${actionLabel} Sync`,
|
||||
content: html`<p style="color: #fff;">Are you sure you want to ${actionLabel.toLowerCase()} sync "${item.name}"?</p>`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||
{
|
||||
name: actionLabel,
|
||||
action: async (modal: any) => {
|
||||
await appstate.syncStatePart.dispatchAction(appstate.pauseSyncConfigAction, {
|
||||
syncConfigId: item.id,
|
||||
paused: !isPaused,
|
||||
});
|
||||
modal.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash2',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async ({ item }: any) => {
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Delete Sync Config',
|
||||
content: html`<p style="color: #fff;">Are you sure you want to delete sync config "${item.name}"? This will also remove all local mirror data.</p>`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||
{
|
||||
name: 'Delete',
|
||||
action: async (modal: any) => {
|
||||
await appstate.syncStatePart.dispatchAction(appstate.deleteSyncConfigAction, {
|
||||
syncConfigId: item.id,
|
||||
});
|
||||
modal.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
></dees-table>
|
||||
<dees-chart-log
|
||||
.label=${'Sync Activity Log'}
|
||||
.autoScroll=${true}
|
||||
.maxEntries=${500}
|
||||
></dees-chart-log>
|
||||
`;
|
||||
}
|
||||
|
||||
async firstUpdated() {
|
||||
await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
|
||||
await this.refresh();
|
||||
// Initialize TypedSocket for server-push sync log entries
|
||||
await appstate.initSyncLogSocket();
|
||||
// Load existing log entries
|
||||
await this.loadExistingLogs();
|
||||
}
|
||||
|
||||
private async loadExistingLogs() {
|
||||
try {
|
||||
const logs = await appstate.fetchSyncLogs(200);
|
||||
const chartLog = this.shadowRoot?.querySelector('dees-chart-log') as any;
|
||||
if (chartLog?.updateLog && logs.length > 0) {
|
||||
chartLog.updateLog(
|
||||
logs.map((entry) => ({
|
||||
timestamp: new Date(entry.timestamp).toISOString(),
|
||||
level: entry.level,
|
||||
message: entry.message,
|
||||
source: entry.source,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load sync logs:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private async refresh() {
|
||||
await appstate.syncStatePart.dispatchAction(appstate.fetchSyncConfigsAction, null);
|
||||
}
|
||||
|
||||
private async addSyncConfig() {
|
||||
const connectionOptions = this.connectionsState.connections.map((c) => ({
|
||||
option: `${c.name} (${c.providerType})`,
|
||||
key: c.id,
|
||||
}));
|
||||
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Add Sync Configuration',
|
||||
content: html`
|
||||
<style>.form-row { margin-bottom: 16px; }</style>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Name'} .key=${'name'} .description=${'A human-readable name for this sync configuration'}></dees-input-text>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-dropdown
|
||||
.label=${'Source Connection'}
|
||||
.key=${'sourceConnectionId'}
|
||||
.description=${'The connection to read repositories from (filtered by its group filter)'}
|
||||
.options=${connectionOptions}
|
||||
.selectedOption=${connectionOptions[0]}
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-dropdown
|
||||
.label=${'Target Connection'}
|
||||
.key=${'targetConnectionId'}
|
||||
.description=${'The connection to push repositories to'}
|
||||
.options=${connectionOptions}
|
||||
.selectedOption=${connectionOptions[1] || connectionOptions[0]}
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Target Group Offset'} .key=${'targetGroupOffset'} .description=${'Path prefix for target repos (e.g. "mirror/gitlab"). Leave empty for no prefix — repos land at their relative path.'}></dees-input-text>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Interval (minutes)'} .key=${'intervalMinutes'} .value=${'5'} .description=${'How often to run this sync automatically'}></dees-input-text>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-checkbox .label=${'Enforce Deletion'} .key=${'enforceDelete'} .value=${false} .description=${'When enabled, repos on the target not present on the source will be moved to an obsolete group (private).'}></dees-input-checkbox>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-checkbox .label=${'Enforce Group Deletion'} .key=${'enforceGroupDelete'} .value=${false} .description=${'When enabled, groups/orgs on the target not present on the source will be moved to obsolete.'}></dees-input-checkbox>
|
||||
</div>
|
||||
<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(); } },
|
||||
{
|
||||
name: 'Create',
|
||||
action: async (modal: any) => {
|
||||
const inputs = modal.shadowRoot.querySelectorAll('dees-input-text, dees-input-dropdown, dees-input-checkbox');
|
||||
const data: any = {};
|
||||
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' || input.key === 'useGroupAvatarsForProjects') {
|
||||
data[input.key] = input.getValue();
|
||||
} else {
|
||||
data[input.key] = input.value || '';
|
||||
}
|
||||
}
|
||||
await appstate.syncStatePart.dispatchAction(appstate.createSyncConfigAction, {
|
||||
name: data.name,
|
||||
sourceConnectionId: data.sourceConnectionId,
|
||||
targetConnectionId: data.targetConnectionId,
|
||||
targetGroupOffset: data.targetGroupOffset || undefined,
|
||||
intervalMinutes: parseInt(data.intervalMinutes) || 5,
|
||||
enforceDelete: !!data.enforceDelete,
|
||||
enforceGroupDelete: !!data.enforceGroupDelete,
|
||||
addMirrorHint: !!data.addMirrorHint,
|
||||
useGroupAvatarsForProjects: !!data.useGroupAvatarsForProjects,
|
||||
});
|
||||
modal.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async editSyncConfig(item: any) {
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: `Edit Sync: ${item.name}`,
|
||||
content: html`
|
||||
<style>.form-row { margin-bottom: 16px; }</style>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Name'} .key=${'name'} .value=${item.name} .description=${'A human-readable name for this sync configuration'}></dees-input-text>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Target Group Offset'} .key=${'targetGroupOffset'} .value=${item.targetGroupOffset || ''} .description=${'Path prefix for target repos (e.g. "mirror/gitlab"). Leave empty for no prefix — repos land at their relative path.'}></dees-input-text>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-text .label=${'Interval (minutes)'} .key=${'intervalMinutes'} .value=${String(item.intervalMinutes)} .description=${'How often to run this sync automatically'}></dees-input-text>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-checkbox .label=${'Enforce Deletion'} .key=${'enforceDelete'} .value=${!!item.enforceDelete} .description=${'When enabled, repos on the target not present on the source will be moved to an obsolete group (private).'}></dees-input-checkbox>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-checkbox .label=${'Enforce Group Deletion'} .key=${'enforceGroupDelete'} .value=${!!item.enforceGroupDelete} .description=${'When enabled, groups/orgs on the target not present on the source will be moved to obsolete.'}></dees-input-checkbox>
|
||||
</div>
|
||||
<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(); } },
|
||||
{
|
||||
name: 'Save',
|
||||
action: async (modal: any) => {
|
||||
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' || input.key === 'useGroupAvatarsForProjects') {
|
||||
data[input.key] = input.getValue();
|
||||
} else {
|
||||
data[input.key] = input.value || '';
|
||||
}
|
||||
}
|
||||
await appstate.syncStatePart.dispatchAction(appstate.updateSyncConfigAction, {
|
||||
syncConfigId: item.id,
|
||||
name: data.name,
|
||||
targetGroupOffset: data.targetGroupOffset || undefined,
|
||||
intervalMinutes: parseInt(data.intervalMinutes) || 5,
|
||||
enforceDelete: !!data.enforceDelete,
|
||||
enforceGroupDelete: !!data.enforceGroupDelete,
|
||||
addMirrorHint: !!data.addMirrorHint,
|
||||
useGroupAvatarsForProjects: !!data.useGroupAvatarsForProjects,
|
||||
});
|
||||
modal.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async previewSync(item: any) {
|
||||
try {
|
||||
const { mappings, deletions, groupDeletions } = await appstate.previewSync(item.id);
|
||||
|
||||
// Compute the full obsolete group path for display
|
||||
const targetConn = this.connectionsState.connections.find((c: any) => c.id === item.targetConnectionId);
|
||||
let obsoletePath: string;
|
||||
if (targetConn?.providerType === 'gitea') {
|
||||
const segments = item.targetGroupOffset ? item.targetGroupOffset.split('/') : [];
|
||||
const orgName = segments[0] || targetConn?.groupFilter || 'default';
|
||||
obsoletePath = `${orgName}-obsolete`;
|
||||
} else {
|
||||
obsoletePath = item.targetGroupOffset ? `${item.targetGroupOffset}/obsolete` : 'obsolete';
|
||||
}
|
||||
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: `Preview Sync: "${item.name}"`,
|
||||
content: html`
|
||||
<style>
|
||||
.preview-list { color: #fff; max-height: 400px; overflow-y: auto; }
|
||||
.preview-item { display: flex; align-items: center; gap: 12px; padding: 6px 0; border-bottom: 1px solid #333; font-size: 13px; }
|
||||
.preview-source { color: #aaa; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.preview-arrow { color: #666; flex-shrink: 0; }
|
||||
.preview-target { color: #00ff88; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.preview-count { color: #888; font-size: 12px; margin-bottom: 12px; }
|
||||
.preview-delete { color: #ff4444; padding: 6px 0; border-bottom: 1px solid #333; font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
||||
.preview-delete-marker { flex-shrink: 0; }
|
||||
.preview-delete-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.preview-section { margin-top: 16px; }
|
||||
.preview-section-header { color: #ff4444; font-size: 12px; font-weight: 600; margin-bottom: 8px; }
|
||||
</style>
|
||||
<div class="preview-count">${mappings.length} repositories will be synced</div>
|
||||
<div class="preview-list">
|
||||
${mappings.map((m: any) => html`
|
||||
<div class="preview-item">
|
||||
<span class="preview-source">${m.sourceFullPath}</span>
|
||||
<span class="preview-arrow">→</span>
|
||||
<span class="preview-target">${m.targetFullPath}</span>
|
||||
</div>
|
||||
`)}
|
||||
${mappings.length === 0 ? html`<p style="color: #888;">No repositories found on source.</p>` : ''}
|
||||
</div>
|
||||
${deletions.length > 0 ? html`
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-header">${deletions.length} target repositor${deletions.length === 1 ? 'y' : 'ies'} will be moved to ${obsoletePath}</div>
|
||||
<div class="preview-list">
|
||||
${deletions.map((d: string) => html`
|
||||
<div class="preview-delete">
|
||||
<span class="preview-delete-marker">→</span>
|
||||
<span class="preview-delete-path">${d}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${groupDeletions.length > 0 ? html`
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-header">${groupDeletions.length} target group${groupDeletions.length === 1 ? '' : 's'} will be moved to ${obsoletePath}</div>
|
||||
<div class="preview-list">
|
||||
${groupDeletions.map((g: string) => html`
|
||||
<div class="preview-delete">
|
||||
<span class="preview-delete-marker">→</span>
|
||||
<span class="preview-delete-path">${g}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Close', action: async (modal: any) => { modal.destroy(); } },
|
||||
],
|
||||
});
|
||||
} catch (err: any) {
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Preview Failed',
|
||||
content: html`<p style="color: #ff4444;">${err.message || String(err)}</p>`,
|
||||
menuOptions: [
|
||||
{ name: 'Close', action: async (modal: any) => { modal.destroy(); } },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async viewRepoStatuses(item: any) {
|
||||
await appstate.syncStatePart.dispatchAction(appstate.fetchSyncRepoStatusesAction, {
|
||||
syncConfigId: item.id,
|
||||
});
|
||||
|
||||
const statuses = appstate.syncStatePart.getState().repoStatuses;
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: `Sync "${item.name}" - Repo Statuses`,
|
||||
content: html`
|
||||
<style>
|
||||
.repo-list { color: #fff; max-height: 400px; overflow-y: auto; }
|
||||
.repo-item { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #333; }
|
||||
.repo-path { font-weight: 600; font-size: 13px; }
|
||||
.repo-status { font-size: 12px; text-transform: uppercase; }
|
||||
.repo-status.synced { color: #00ff88; }
|
||||
.repo-status.error { color: #ff4444; }
|
||||
.repo-status.pending { color: #ffaa00; }
|
||||
.repo-error { font-size: 11px; color: #ff6666; margin-top: 4px; }
|
||||
</style>
|
||||
<div class="repo-list">
|
||||
${statuses.map((s: any) => html`
|
||||
<div class="repo-item">
|
||||
<div>
|
||||
<div class="repo-path">${s.sourceFullPath}</div>
|
||||
${s.lastSyncError ? html`<div class="repo-error">${s.lastSyncError}</div>` : ''}
|
||||
</div>
|
||||
<div>
|
||||
<span class="repo-status ${s.status}">${s.status}</span>
|
||||
<div style="font-size: 11px; color: #888;">${s.lastSyncAt ? new Date(s.lastSyncAt).toLocaleString() : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
${statuses.length === 0 ? html`<p style="color: #888;">No repos synced yet.</p>` : ''}
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Close', action: async (modal: any) => { modal.destroy(); } },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,13 @@
|
||||
import * as deesElement from '@design.estate/dees-element';
|
||||
import * as deesCatalog from '@design.estate/dees-catalog';
|
||||
|
||||
// @api.global scope
|
||||
import * as typedsocket from '@api.global/typedsocket';
|
||||
|
||||
export {
|
||||
deesElement,
|
||||
deesCatalog,
|
||||
typedsocket,
|
||||
};
|
||||
|
||||
// domtools gives us TypedRequest, smartstate, smartrouter, and other utilities
|
||||
|
||||
Reference in New Issue
Block a user