Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78247c1d41 | |||
| 44ac2e430f | |||
| c9a758b417 | |||
| f7e16aa350 | |||
| 2f050744bc | |||
| 623ec4907c | |||
| 06eae3b443 | |||
| 44b418cbdd | |||
| 81ead52a72 | |||
| 630b2502f3 | |||
| e3f67d12a3 | |||
| 43131fa53c | |||
| 481b72b8fb | |||
| c9786591e3 | |||
| c5834f3cd1 | |||
| 179bb9223e | |||
| ee3f01993f | |||
| 15e845d5f8 | |||
| 0815e4c8ae | |||
| 7e6b774982 | |||
| 768bd1ef53 | |||
| 71176a1856 | |||
| b576056fa1 | |||
| 57935d6388 | |||
| 5ca8c1fb60 | |||
| 92b0ec179f | |||
| 06f447459e | |||
| 6889b81159 | |||
| 43321c35d6 | |||
| e8e45d5371 | |||
| 0417702340 | |||
| 37d1db9ccf | |||
| f78cab7dd8 | |||
| 29922004ea | |||
| 0d9e76bf94 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -6,7 +6,9 @@ deno.lock
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
dist_serve/
|
# ts_bundled/ is committed (embedded frontend bundle)
|
||||||
|
ts_bundled/bundle.js
|
||||||
|
ts_bundled/bundle.js.map
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
.nogit/
|
.nogit/
|
||||||
|
|||||||
112
changelog.md
112
changelog.md
@@ -1,5 +1,117 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- Switch server to use bundledContent from committed ts_bundled bundle (base64ts) instead of pointing at a serveDir
|
||||||
|
- Update bundler config to emit ./ts_bundled/bundle.ts with outputMode 'base64ts' and includeFiles mapping
|
||||||
|
- Remove dist_serve from .gitignore and commit ts_bundled (embedded frontend bundle)
|
||||||
|
- Bump devDependency @git.zone/tsbundle to ^2.8.4 and deno dependency @api.global/typedserver to ^8.3.1
|
||||||
|
|
||||||
|
## 2026-02-24 - 2.3.0 - feat(storage)
|
||||||
|
add StorageManager and cache subsystem; integrate storage into ConnectionManager and GitopsApp, migrate legacy connections, and add tests
|
||||||
|
|
||||||
|
- Add StorageManager with filesystem and memory backends, key normalization, atomic writes and JSON helpers (getJSON/setJSON).
|
||||||
|
- ConnectionManager now depends on StorageManager, persists each connection as /connections/<id>.json, and includes a one-time migration from legacy .nogit/connections.json.
|
||||||
|
- Introduce cache subsystem: CacheDb (LocalTsmDb + Smartdata), CacheCleaner, CachedDocument and CachedProject for TTL'd cached provider data, plus lifecycle management in GitopsApp.
|
||||||
|
- GitopsApp now initializes StorageManager, wires ConnectionManager to storage, starts/stops CacheDb and CacheCleaner, and uses resolved default paths via resolvePaths.
|
||||||
|
- Export smartmongo and smartdata in plugins and add corresponding deps to deno.json.
|
||||||
|
- Add comprehensive tests: storage unit tests, connection manager integration using StorageManager, and a tsmdb + smartdata spike test.
|
||||||
|
|
||||||
|
## 2026-02-24 - 2.2.1 - fix(ts_bundled)
|
||||||
|
add generated bundled JavaScript and source map for ts build (bundle.js and bundle.js.map)
|
||||||
|
|
||||||
|
- Added ts_bundled/bundle.js (≈168 KB) - compiled/bundled output from ts sources
|
||||||
|
- Added ts_bundled/bundle.js.map (≈309 KB) - source map referencing ../ts/index.ts and ../ts_web/index.ts
|
||||||
|
- This is generated build output (deno bundle) and does not change runtime API
|
||||||
|
|
||||||
|
## 2026-02-24 - 2.2.0 - feat(opsserver)
|
||||||
|
Serve bundled frontend from a dedicated dist_serve directory and update frontend UI/packaging
|
||||||
|
|
||||||
|
- Serve static site using UtilityWebsiteServer with serveDir set to ./dist_serve and pass port into server.start()
|
||||||
|
- Update bundler config: output bundle to ./dist_serve/bundle.js, change outputMode to 'bundle', and include html/index.html
|
||||||
|
- Move root index.html into html/index.html and update .gitignore to ignore dist_serve/ (replace ts_bundled)
|
||||||
|
- Frontend enhancements: add iconName to view tabs and resolvedViewTabs, add Lucide icons for each tab, replace manual stats markup with dees-statsgrid using IStatsTile tiles
|
||||||
|
- Adjust shared CSS: center content, set max-width 1280px and adjust padding
|
||||||
|
- Add npm test script and rename/update tests (test.basic.ts -> test.basic_test.ts)
|
||||||
|
|
||||||
|
## 2026-02-24 - 2.1.0 - feat(opsserver)
|
||||||
|
switch to TypedServer and serve bundled UI assets; add index.html; update bundling output and dev watch configuration
|
||||||
|
|
||||||
|
- Replace UtilityWebsiteServer with TypedServer and load bundledContent from ts_bundled/bundle.ts; enable cors, spaFallback, injectReload, watch, and compression
|
||||||
|
- Add a minimal index.html SPA entry and include it in the bundle so it is served from the bundled assets
|
||||||
|
- Change tsbundle output to ./ts_bundled/bundle.ts with outputMode 'base64ts' and includeFiles ['./index.html']
|
||||||
|
- Add a tswatch bundle config and replace the previous watcher with a backend watcher that runs the server via 'deno run --allow-all mod.ts server' (restart enabled)
|
||||||
|
- Bump devDependency @git.zone/tswatch from ^2.3.13 to ^3.1.0 and update .gitignore to ignore ts_bundled/
|
||||||
|
|
||||||
## 2026-02-24 - 2.0.0 - BREAKING CHANGE(providers)
|
## 2026-02-24 - 2.0.0 - BREAKING CHANGE(providers)
|
||||||
switch GitLab and Gitea providers to use @apiclient.xyz client libraries and export clients via plugins
|
switch GitLab and Gitea providers to use @apiclient.xyz client libraries and export clients via plugins
|
||||||
|
|
||||||
|
|||||||
11
deno.json
11
deno.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/gitops",
|
"name": "@serve.zone/gitops",
|
||||||
"version": "2.0.0",
|
"version": "2.8.0",
|
||||||
"exports": "./mod.ts",
|
"exports": "./mod.ts",
|
||||||
"nodeModulesDir": "auto",
|
"nodeModulesDir": "auto",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
@@ -13,11 +13,14 @@
|
|||||||
"@std/encoding": "jsr:@std/encoding@^1.0.10",
|
"@std/encoding": "jsr:@std/encoding@^1.0.10",
|
||||||
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
|
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
|
||||||
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.2.6",
|
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.2.6",
|
||||||
"@api.global/typedserver": "npm:@api.global/typedserver@^8.3.0",
|
"@api.global/typedserver": "npm:@api.global/typedserver@^8.3.1",
|
||||||
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
|
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
|
||||||
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1",
|
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1",
|
||||||
"@apiclient.xyz/gitea": "npm:@apiclient.xyz/gitea@^1.0.3",
|
"@apiclient.xyz/gitea": "npm:@apiclient.xyz/gitea@^1.2.0",
|
||||||
"@apiclient.xyz/gitlab": "npm:@apiclient.xyz/gitlab@^2.0.3"
|
"@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"
|
||||||
},
|
},
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": [
|
"lib": [
|
||||||
|
|||||||
29544
dist_serve/bundle.js
Normal file
29544
dist_serve/bundle.js
Normal file
File diff suppressed because one or more lines are too long
33
dist_serve/index.html
Normal file
33
dist_serve/index.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="user-scalable=0, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height"
|
||||||
|
/>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<title>GitOps</title>
|
||||||
|
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
|
||||||
|
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
position: relative;
|
||||||
|
background: #000;
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<p style="color: #fff; text-align: center; margin-top: 100px;">
|
||||||
|
JavaScript is required to run the GitOps dashboard.
|
||||||
|
</p>
|
||||||
|
</noscript>
|
||||||
|
</body>
|
||||||
|
<script defer type="module" src="/bundle.js"></script>
|
||||||
|
</html>
|
||||||
33
html/index.html
Normal file
33
html/index.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="user-scalable=0, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height"
|
||||||
|
/>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<title>GitOps</title>
|
||||||
|
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
|
||||||
|
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
position: relative;
|
||||||
|
background: #000;
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<p style="color: #fff; text-align: center; margin-top: 100px;">
|
||||||
|
JavaScript is required to run the GitOps dashboard.
|
||||||
|
</p>
|
||||||
|
</noscript>
|
||||||
|
</body>
|
||||||
|
<script defer type="module" src="/bundle.js"></script>
|
||||||
|
</html>
|
||||||
@@ -3,19 +3,32 @@
|
|||||||
"bundles": [
|
"bundles": [
|
||||||
{
|
{
|
||||||
"from": "./ts_web/index.ts",
|
"from": "./ts_web/index.ts",
|
||||||
"to": "./dist_serve/bundle.js",
|
"to": "./ts_bundled/bundle.ts",
|
||||||
"outputMode": "bundle",
|
"outputMode": "base64ts",
|
||||||
"bundler": "esbuild",
|
"bundler": "esbuild",
|
||||||
"production": true
|
"production": true,
|
||||||
|
"includeFiles": [{"from": "./html/index.html", "to": "index.html"}]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@git.zone/tswatch": {
|
"@git.zone/tswatch": {
|
||||||
|
"bundles": [
|
||||||
|
{
|
||||||
|
"from": "./ts_web/index.ts",
|
||||||
|
"to": "./ts_bundled/bundle.ts",
|
||||||
|
"outputMode": "base64ts",
|
||||||
|
"bundler": "esbuild",
|
||||||
|
"production": true,
|
||||||
|
"watchPatterns": ["./ts_web/**/*", "./html/**/*"],
|
||||||
|
"includeFiles": [{"from": "./html/index.html", "to": "index.html"}]
|
||||||
|
}
|
||||||
|
],
|
||||||
"watchers": [
|
"watchers": [
|
||||||
{
|
{
|
||||||
"name": "ui-bundle",
|
"name": "backend",
|
||||||
"watch": "./ts_web/**/*",
|
"watch": ["./ts/**/*", "./ts_interfaces/**/*", "./ts_bundled/**/*"],
|
||||||
"command": "tsbundle",
|
"command": "deno run --allow-all mod.ts server",
|
||||||
|
"restart": true,
|
||||||
"debounce": 500,
|
"debounce": 500,
|
||||||
"runOnStart": true
|
"runOnStart": true
|
||||||
}
|
}
|
||||||
|
|||||||
13
package.json
13
package.json
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/gitops",
|
"name": "@serve.zone/gitops",
|
||||||
"version": "2.0.0",
|
"version": "2.9.0",
|
||||||
"description": "GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs",
|
"description": "GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs",
|
||||||
"main": "mod.ts",
|
"main": "mod.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test": "deno task test",
|
||||||
"build": "tsbundle",
|
"build": "tsbundle",
|
||||||
"startTs": "deno run --allow-all mod.ts server",
|
"startTs": "deno run --allow-all mod.ts server",
|
||||||
"watch": "tswatch"
|
"watch": "tswatch"
|
||||||
@@ -13,11 +14,15 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
"@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.2.0",
|
||||||
|
"@apiclient.xyz/gitlab": "2.3.0",
|
||||||
|
"@design.estate/dees-catalog": "^3.43.3",
|
||||||
"@design.estate/dees-element": "^2.1.6"
|
"@design.estate/dees-element": "^2.1.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbundle": "^2.8.3",
|
"@git.zone/tsbundle": "^2.9.0",
|
||||||
"@git.zone/tswatch": "^2.3.13"
|
"@git.zone/tswatch": "^3.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
@@ -2,6 +2,8 @@ import { assertEquals, assertExists } from 'https://deno.land/std@0.208.0/assert
|
|||||||
import { BaseProvider, GiteaProvider, GitLabProvider } from '../ts/providers/index.ts';
|
import { BaseProvider, GiteaProvider, GitLabProvider } from '../ts/providers/index.ts';
|
||||||
import { ConnectionManager } from '../ts/classes/connectionmanager.ts';
|
import { ConnectionManager } from '../ts/classes/connectionmanager.ts';
|
||||||
import { GitopsApp } from '../ts/classes/gitopsapp.ts';
|
import { GitopsApp } from '../ts/classes/gitopsapp.ts';
|
||||||
|
import { StorageManager } from '../ts/storage/index.ts';
|
||||||
|
import * as smartsecret from '@push.rocks/smartsecret';
|
||||||
|
|
||||||
Deno.test('GiteaProvider instantiates correctly', () => {
|
Deno.test('GiteaProvider instantiates correctly', () => {
|
||||||
const provider = new GiteaProvider('test-id', 'https://gitea.example.com', 'test-token');
|
const provider = new GiteaProvider('test-id', 'https://gitea.example.com', 'test-token');
|
||||||
@@ -18,13 +20,17 @@ Deno.test('GitLabProvider instantiates correctly', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Deno.test('ConnectionManager instantiates correctly', () => {
|
Deno.test('ConnectionManager instantiates correctly', () => {
|
||||||
const manager = new ConnectionManager();
|
const storage = new StorageManager({ backend: 'memory' });
|
||||||
|
const secret = new smartsecret.SmartSecret({ service: 'gitops-test' });
|
||||||
|
const manager = new ConnectionManager(storage, secret);
|
||||||
assertExists(manager);
|
assertExists(manager);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test('GitopsApp instantiates correctly', () => {
|
Deno.test('GitopsApp instantiates correctly', () => {
|
||||||
const app = new GitopsApp();
|
const app = new GitopsApp();
|
||||||
assertExists(app);
|
assertExists(app);
|
||||||
|
assertExists(app.storageManager);
|
||||||
|
assertExists(app.smartSecret);
|
||||||
assertExists(app.connectionManager);
|
assertExists(app.connectionManager);
|
||||||
assertExists(app.opsServer);
|
assertExists(app.opsServer);
|
||||||
});
|
});
|
||||||
143
test/test.storage_test.ts
Normal file
143
test/test.storage_test.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { assertEquals, assertExists } from 'https://deno.land/std@0.208.0/assert/mod.ts';
|
||||||
|
import { StorageManager } from '../ts/storage/index.ts';
|
||||||
|
import * as smartsecret from '@push.rocks/smartsecret';
|
||||||
|
|
||||||
|
Deno.test('StorageManager memory: set and get', async () => {
|
||||||
|
const sm = new StorageManager({ backend: 'memory' });
|
||||||
|
await sm.set('/test/key1', 'hello');
|
||||||
|
const result = await sm.get('/test/key1');
|
||||||
|
assertEquals(result, 'hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('StorageManager memory: get nonexistent returns null', async () => {
|
||||||
|
const sm = new StorageManager({ backend: 'memory' });
|
||||||
|
const result = await sm.get('/missing');
|
||||||
|
assertEquals(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('StorageManager memory: delete', async () => {
|
||||||
|
const sm = new StorageManager({ backend: 'memory' });
|
||||||
|
await sm.set('/test/key1', 'hello');
|
||||||
|
const deleted = await sm.delete('/test/key1');
|
||||||
|
assertEquals(deleted, true);
|
||||||
|
const result = await sm.get('/test/key1');
|
||||||
|
assertEquals(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('StorageManager memory: delete nonexistent returns false', async () => {
|
||||||
|
const sm = new StorageManager({ backend: 'memory' });
|
||||||
|
const deleted = await sm.delete('/missing');
|
||||||
|
assertEquals(deleted, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('StorageManager memory: exists', async () => {
|
||||||
|
const sm = new StorageManager({ backend: 'memory' });
|
||||||
|
assertEquals(await sm.exists('/test/key1'), false);
|
||||||
|
await sm.set('/test/key1', 'hello');
|
||||||
|
assertEquals(await sm.exists('/test/key1'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('StorageManager memory: list keys under prefix', async () => {
|
||||||
|
const sm = new StorageManager({ backend: 'memory' });
|
||||||
|
await sm.set('/connections/a.json', '{}');
|
||||||
|
await sm.set('/connections/b.json', '{}');
|
||||||
|
await sm.set('/other/c.json', '{}');
|
||||||
|
const keys = await sm.list('/connections/');
|
||||||
|
assertEquals(keys, ['/connections/a.json', '/connections/b.json']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('StorageManager memory: getJSON and setJSON roundtrip', async () => {
|
||||||
|
const sm = new StorageManager({ backend: 'memory' });
|
||||||
|
const data = { id: '123', name: 'test', nested: { value: 42 } };
|
||||||
|
await sm.setJSON('/data/item.json', data);
|
||||||
|
const result = await sm.getJSON<typeof data>('/data/item.json');
|
||||||
|
assertEquals(result, data);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('StorageManager memory: getJSON nonexistent returns null', async () => {
|
||||||
|
const sm = new StorageManager({ backend: 'memory' });
|
||||||
|
const result = await sm.getJSON('/missing.json');
|
||||||
|
assertEquals(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('StorageManager: key validation requires leading slash', async () => {
|
||||||
|
const sm = new StorageManager({ backend: 'memory' });
|
||||||
|
let threw = false;
|
||||||
|
try {
|
||||||
|
await sm.get('no-slash');
|
||||||
|
} catch {
|
||||||
|
threw = true;
|
||||||
|
}
|
||||||
|
assertEquals(threw, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('StorageManager: key normalization strips ..', async () => {
|
||||||
|
const sm = new StorageManager({ backend: 'memory' });
|
||||||
|
await sm.set('/test/../actual/key', 'value');
|
||||||
|
// '..' segments are stripped, so key becomes /test/actual/key — wait,
|
||||||
|
// the normalizer filters out '..' segments entirely
|
||||||
|
// /test/../actual/key -> segments: ['test', 'actual', 'key'] (.. filtered)
|
||||||
|
const result = await sm.get('/test/actual/key');
|
||||||
|
assertEquals(result, 'value');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('StorageManager filesystem: set, get, delete roundtrip', async () => {
|
||||||
|
const tmpDir = await Deno.makeTempDir();
|
||||||
|
const sm = new StorageManager({ backend: 'filesystem', fsPath: tmpDir });
|
||||||
|
try {
|
||||||
|
await sm.set('/test/file.txt', 'filesystem content');
|
||||||
|
const result = await sm.get('/test/file.txt');
|
||||||
|
assertEquals(result, 'filesystem content');
|
||||||
|
|
||||||
|
assertEquals(await sm.exists('/test/file.txt'), true);
|
||||||
|
|
||||||
|
const deleted = await sm.delete('/test/file.txt');
|
||||||
|
assertEquals(deleted, true);
|
||||||
|
assertEquals(await sm.get('/test/file.txt'), null);
|
||||||
|
} finally {
|
||||||
|
await Deno.remove(tmpDir, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('StorageManager filesystem: list keys', async () => {
|
||||||
|
const tmpDir = await Deno.makeTempDir();
|
||||||
|
const sm = new StorageManager({ backend: 'filesystem', fsPath: tmpDir });
|
||||||
|
try {
|
||||||
|
await sm.setJSON('/items/a.json', { id: 'a' });
|
||||||
|
await sm.setJSON('/items/b.json', { id: 'b' });
|
||||||
|
const keys = await sm.list('/items/');
|
||||||
|
assertEquals(keys, ['/items/a.json', '/items/b.json']);
|
||||||
|
} finally {
|
||||||
|
await Deno.remove(tmpDir, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('ConnectionManager with StorageManager: create and load', async () => {
|
||||||
|
const { ConnectionManager } = await import('../ts/classes/connectionmanager.ts');
|
||||||
|
const sm = new StorageManager({ backend: 'memory' });
|
||||||
|
const secret = new smartsecret.SmartSecret({ service: 'gitops-test' });
|
||||||
|
const cm = new ConnectionManager(sm, secret);
|
||||||
|
await cm.init();
|
||||||
|
|
||||||
|
// Create a connection
|
||||||
|
const conn = await cm.createConnection('test', 'gitea', 'https://gitea.example.com', 'token');
|
||||||
|
assertExists(conn.id);
|
||||||
|
assertEquals(conn.name, 'test');
|
||||||
|
assertEquals(conn.token, '***');
|
||||||
|
|
||||||
|
// Verify it's stored in StorageManager
|
||||||
|
const stored = await sm.getJSON<{ id: string }>(`/connections/${conn.id}.json`);
|
||||||
|
assertExists(stored);
|
||||||
|
assertEquals(stored.id, conn.id);
|
||||||
|
|
||||||
|
// Create a new ConnectionManager and verify it loads the connection
|
||||||
|
const cm2 = new ConnectionManager(sm, secret);
|
||||||
|
await cm2.init();
|
||||||
|
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;
|
||||||
|
});
|
||||||
59
test/test.tsmdb_spike_test.ts
Normal file
59
test/test.tsmdb_spike_test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { assertEquals, assertExists } from 'https://deno.land/std@0.208.0/assert/mod.ts';
|
||||||
|
import { LocalTsmDb } from '@push.rocks/smartmongo';
|
||||||
|
import { SmartdataDb, SmartDataDbDoc, Collection, svDb, unI } from '@push.rocks/smartdata';
|
||||||
|
|
||||||
|
Deno.test({
|
||||||
|
name: 'TsmDb spike: LocalTsmDb + SmartdataDb roundtrip',
|
||||||
|
sanitizeOps: false,
|
||||||
|
sanitizeResources: false,
|
||||||
|
fn: async () => {
|
||||||
|
const tmpDir = await Deno.makeTempDir();
|
||||||
|
|
||||||
|
// 1. Start local MongoDB-compatible server
|
||||||
|
const localDb = new LocalTsmDb({ folderPath: tmpDir });
|
||||||
|
const { connectionUri } = await localDb.start();
|
||||||
|
assertExists(connectionUri);
|
||||||
|
|
||||||
|
// 2. Connect smartdata
|
||||||
|
const smartDb = new SmartdataDb({
|
||||||
|
mongoDbUrl: connectionUri,
|
||||||
|
mongoDbName: 'gitops_spike_test',
|
||||||
|
});
|
||||||
|
await smartDb.init();
|
||||||
|
assertEquals(smartDb.status, 'connected');
|
||||||
|
|
||||||
|
// 3. Define a simple document class
|
||||||
|
@Collection(() => smartDb)
|
||||||
|
class TestDoc extends SmartDataDbDoc<TestDoc, TestDoc> {
|
||||||
|
@unI()
|
||||||
|
public id: string = '';
|
||||||
|
|
||||||
|
@svDb()
|
||||||
|
public label: string = '';
|
||||||
|
|
||||||
|
@svDb()
|
||||||
|
public value: number = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Insert a document
|
||||||
|
const doc = new TestDoc();
|
||||||
|
doc.id = 'test-1';
|
||||||
|
doc.label = 'spike';
|
||||||
|
doc.value = 42;
|
||||||
|
await doc.save();
|
||||||
|
|
||||||
|
// 5. Query it back
|
||||||
|
const found = await TestDoc.getInstance({ id: 'test-1' });
|
||||||
|
assertExists(found);
|
||||||
|
assertEquals(found.label, 'spike');
|
||||||
|
assertEquals(found.value, 42);
|
||||||
|
|
||||||
|
// 6. Cleanup — smartDb closes; localDb.stop() hangs under Deno, so fire-and-forget
|
||||||
|
await smartDb.close();
|
||||||
|
localDb.stop().catch(() => {});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/gitops',
|
name: '@serve.zone/gitops',
|
||||||
version: '2.0.0',
|
version: '2.8.0',
|
||||||
description: 'GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs'
|
description: 'GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs'
|
||||||
}
|
}
|
||||||
|
|||||||
68
ts/cache/classes.cache.cleaner.ts
vendored
Normal file
68
ts/cache/classes.cache.cleaner.ts
vendored
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { logger } from '../logging.ts';
|
||||||
|
import type { CacheDb } from './classes.cachedb.ts';
|
||||||
|
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
type DocumentClass = { getInstances: (filter: any) => Promise<{ delete: () => Promise<void> }[]> };
|
||||||
|
|
||||||
|
const DEFAULT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periodically cleans up expired cached documents.
|
||||||
|
*/
|
||||||
|
export class CacheCleaner {
|
||||||
|
private intervalId: number | null = null;
|
||||||
|
private intervalMs: number;
|
||||||
|
private documentClasses: DocumentClass[] = [];
|
||||||
|
private cacheDb: CacheDb;
|
||||||
|
|
||||||
|
constructor(cacheDb: CacheDb, intervalMs = DEFAULT_INTERVAL_MS) {
|
||||||
|
this.cacheDb = cacheDb;
|
||||||
|
this.intervalMs = intervalMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a document class for cleanup */
|
||||||
|
registerClass(cls: DocumentClass): void {
|
||||||
|
this.documentClasses.push(cls);
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.intervalId !== null) return;
|
||||||
|
this.intervalId = setInterval(() => {
|
||||||
|
this.clean().catch((err) => {
|
||||||
|
logger.error(`CacheCleaner error: ${err}`);
|
||||||
|
});
|
||||||
|
}, this.intervalMs);
|
||||||
|
// Unref so the interval doesn't prevent process exit
|
||||||
|
Deno.unrefTimer(this.intervalId);
|
||||||
|
logger.debug(`CacheCleaner started (interval: ${this.intervalMs}ms)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.intervalId !== null) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
logger.debug('CacheCleaner stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run a single cleanup pass */
|
||||||
|
async clean(): Promise<number> {
|
||||||
|
const now = Date.now();
|
||||||
|
let totalDeleted = 0;
|
||||||
|
for (const cls of this.documentClasses) {
|
||||||
|
try {
|
||||||
|
const expired = await cls.getInstances({ expiresAt: { $lt: now } });
|
||||||
|
for (const doc of expired) {
|
||||||
|
await doc.delete();
|
||||||
|
totalDeleted++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`CacheCleaner: failed to clean class: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (totalDeleted > 0) {
|
||||||
|
logger.debug(`CacheCleaner: deleted ${totalDeleted} expired document(s)`);
|
||||||
|
}
|
||||||
|
return totalDeleted;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
ts/cache/classes.cached.document.ts
vendored
Normal file
57
ts/cache/classes.cached.document.ts
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import * as plugins from '../plugins.ts';
|
||||||
|
|
||||||
|
/** TTL duration constants in milliseconds */
|
||||||
|
export const TTL = {
|
||||||
|
MINUTES_5: 5 * 60 * 1000,
|
||||||
|
HOURS_1: 60 * 60 * 1000,
|
||||||
|
HOURS_24: 24 * 60 * 60 * 1000,
|
||||||
|
DAYS_7: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
DAYS_30: 30 * 24 * 60 * 60 * 1000,
|
||||||
|
DAYS_90: 90 * 24 * 60 * 60 * 1000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract base class for cached documents with TTL support.
|
||||||
|
* Extend this class and add @Collection decorator pointing to your CacheDb.
|
||||||
|
*/
|
||||||
|
export abstract class CachedDocument<
|
||||||
|
T extends CachedDocument<T>,
|
||||||
|
> extends plugins.smartdata.SmartDataDbDoc<T, T> {
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt: number = Date.now();
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public expiresAt: number = Date.now() + TTL.HOURS_1;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastAccessedAt: number = Date.now();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set TTL in milliseconds from now */
|
||||||
|
setTTL(ms: number): void {
|
||||||
|
this.expiresAt = Date.now() + ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set TTL in days from now */
|
||||||
|
setTTLDays(days: number): void {
|
||||||
|
this.setTTL(days * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set TTL in hours from now */
|
||||||
|
setTTLHours(hours: number): void {
|
||||||
|
this.setTTL(hours * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if this document has expired */
|
||||||
|
isExpired(): boolean {
|
||||||
|
return Date.now() > this.expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update last accessed timestamp */
|
||||||
|
touch(): void {
|
||||||
|
this.lastAccessedAt = Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
82
ts/cache/classes.cachedb.ts
vendored
Normal file
82
ts/cache/classes.cachedb.ts
vendored
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import * as plugins from '../plugins.ts';
|
||||||
|
import { logger } from '../logging.ts';
|
||||||
|
|
||||||
|
export interface ICacheDbOptions {
|
||||||
|
storagePath?: string;
|
||||||
|
dbName?: string;
|
||||||
|
debug?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton wrapper around LocalTsmDb + SmartdataDb.
|
||||||
|
* Provides a managed MongoDB-compatible cache database.
|
||||||
|
*/
|
||||||
|
export class CacheDb {
|
||||||
|
private static instance: CacheDb | null = null;
|
||||||
|
|
||||||
|
private localTsmDb: InstanceType<typeof plugins.smartmongo.LocalTsmDb> | null = null;
|
||||||
|
private smartdataDb: InstanceType<typeof plugins.smartdata.SmartdataDb> | null = null;
|
||||||
|
private options: Required<ICacheDbOptions>;
|
||||||
|
|
||||||
|
private constructor(options: ICacheDbOptions = {}) {
|
||||||
|
this.options = {
|
||||||
|
storagePath: options.storagePath ?? './.nogit/cachedb',
|
||||||
|
dbName: options.dbName ?? 'gitops_cache',
|
||||||
|
debug: options.debug ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(options?: ICacheDbOptions): CacheDb {
|
||||||
|
if (!CacheDb.instance) {
|
||||||
|
CacheDb.instance = new CacheDb(options);
|
||||||
|
}
|
||||||
|
return CacheDb.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
static resetInstance(): void {
|
||||||
|
CacheDb.instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
logger.info('Starting CacheDb...');
|
||||||
|
this.localTsmDb = new plugins.smartmongo.LocalTsmDb({
|
||||||
|
folderPath: this.options.storagePath,
|
||||||
|
});
|
||||||
|
const { connectionUri } = await this.localTsmDb.start();
|
||||||
|
|
||||||
|
this.smartdataDb = new plugins.smartdata.SmartdataDb({
|
||||||
|
mongoDbUrl: connectionUri,
|
||||||
|
mongoDbName: this.options.dbName,
|
||||||
|
});
|
||||||
|
await this.smartdataDb.init();
|
||||||
|
logger.success(`CacheDb started (db: ${this.options.dbName})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
logger.info('Stopping CacheDb...');
|
||||||
|
if (this.smartdataDb) {
|
||||||
|
await this.smartdataDb.close();
|
||||||
|
this.smartdataDb = null;
|
||||||
|
}
|
||||||
|
if (this.localTsmDb) {
|
||||||
|
// localDb.stop() may hang under Deno — fire-and-forget with timeout
|
||||||
|
const stopPromise = this.localTsmDb.stop().catch(() => {});
|
||||||
|
await Promise.race([
|
||||||
|
stopPromise,
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
const id = setTimeout(resolve, 3000);
|
||||||
|
Deno.unrefTimer(id);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
this.localTsmDb = null;
|
||||||
|
}
|
||||||
|
logger.success('CacheDb stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
getDb(): InstanceType<typeof plugins.smartdata.SmartdataDb> {
|
||||||
|
if (!this.smartdataDb) {
|
||||||
|
throw new Error('CacheDb not started. Call start() first.');
|
||||||
|
}
|
||||||
|
return this.smartdataDb;
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
ts/cache/documents/classes.cached.project.ts
vendored
Normal file
32
ts/cache/documents/classes.cached.project.ts
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import * as plugins from '../../plugins.ts';
|
||||||
|
import { CacheDb } from '../classes.cachedb.ts';
|
||||||
|
import { CachedDocument, TTL } from '../classes.cached.document.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached project data from git providers. TTL: 5 minutes.
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.Collection(() => CacheDb.getInstance().getDb())
|
||||||
|
export class CachedProject extends CachedDocument<CachedProject> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public connectionId: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public projectName: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public projectUrl: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public description: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public defaultBranch: string = '';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.setTTL(TTL.MINUTES_5);
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
2
ts/cache/documents/index.ts
vendored
Normal file
2
ts/cache/documents/index.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { CachedProject } from './classes.cached.project.ts';
|
||||||
|
export { CachedSecret } from './classes.cached.secret.ts';
|
||||||
7
ts/cache/index.ts
vendored
Normal file
7
ts/cache/index.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,41 +2,143 @@ import * as plugins from '../plugins.ts';
|
|||||||
import { logger } from '../logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
import type * as interfaces from '../../ts_interfaces/index.ts';
|
import type * as interfaces from '../../ts_interfaces/index.ts';
|
||||||
import { BaseProvider, GiteaProvider, GitLabProvider } from '../providers/index.ts';
|
import { BaseProvider, GiteaProvider, GitLabProvider } from '../providers/index.ts';
|
||||||
|
import type { StorageManager } from '../storage/index.ts';
|
||||||
|
|
||||||
const CONNECTIONS_FILE = './.nogit/connections.json';
|
const LEGACY_CONNECTIONS_FILE = './.nogit/connections.json';
|
||||||
|
const CONNECTIONS_PREFIX = '/connections/';
|
||||||
|
const KEYCHAIN_PREFIX = 'keychain:';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages provider connections - persists to .nogit/connections.json
|
* Manages provider connections — persists each connection as an
|
||||||
* and creates provider instances on demand.
|
* individual JSON file via StorageManager. Tokens are stored in
|
||||||
|
* the OS keychain (or encrypted file fallback) via SmartSecret.
|
||||||
*/
|
*/
|
||||||
export class ConnectionManager {
|
export class ConnectionManager {
|
||||||
private connections: interfaces.data.IProviderConnection[] = [];
|
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;
|
||||||
|
this.smartSecret = smartSecret;
|
||||||
|
}
|
||||||
|
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
|
await this.migrateLegacyFile();
|
||||||
await this.loadConnections();
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time migration from the legacy .nogit/connections.json file.
|
||||||
|
*/
|
||||||
|
private async migrateLegacyFile(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const text = await Deno.readTextFile(LEGACY_CONNECTIONS_FILE);
|
||||||
|
const legacy: interfaces.data.IProviderConnection[] = JSON.parse(text);
|
||||||
|
if (legacy.length > 0) {
|
||||||
|
logger.info(`Migrating ${legacy.length} connection(s) from legacy file...`);
|
||||||
|
for (const conn of legacy) {
|
||||||
|
await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, conn);
|
||||||
|
}
|
||||||
|
// Rename legacy file so migration doesn't repeat
|
||||||
|
await Deno.rename(LEGACY_CONNECTIONS_FILE, LEGACY_CONNECTIONS_FILE + '.migrated');
|
||||||
|
logger.success('Legacy connections migrated successfully');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// No legacy file or already migrated — nothing to do
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadConnections(): Promise<void> {
|
private async loadConnections(): Promise<void> {
|
||||||
try {
|
const keys = await this.storageManager.list(CONNECTIONS_PREFIX);
|
||||||
const text = await Deno.readTextFile(CONNECTIONS_FILE);
|
|
||||||
this.connections = JSON.parse(text);
|
|
||||||
logger.info(`Loaded ${this.connections.length} connection(s)`);
|
|
||||||
} catch {
|
|
||||||
this.connections = [];
|
this.connections = [];
|
||||||
logger.debug('No existing connections file found, starting fresh');
|
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);
|
||||||
|
if (realToken) {
|
||||||
|
conn.token = realToken;
|
||||||
|
} else {
|
||||||
|
logger.warn(`Could not retrieve token for connection ${conn.id} from keychain`);
|
||||||
|
}
|
||||||
|
} else if (conn.token && conn.token !== '***') {
|
||||||
|
// Plaintext token found — auto-migrate to keychain
|
||||||
|
await this.migrateTokenToKeychain(conn);
|
||||||
|
}
|
||||||
|
this.connections.push(conn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.connections.length > 0) {
|
||||||
|
logger.info(`Loaded ${this.connections.length} connection(s)`);
|
||||||
|
} else {
|
||||||
|
logger.debug('No existing connections found, starting fresh');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async saveConnections(): Promise<void> {
|
/**
|
||||||
// Ensure .nogit directory exists
|
* Migrates a plaintext token to keychain storage.
|
||||||
|
*/
|
||||||
|
private async migrateTokenToKeychain(
|
||||||
|
conn: interfaces.data.IProviderConnection,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await Deno.mkdir('./.nogit', { recursive: true });
|
await this.smartSecret.setSecret(conn.id, conn.token);
|
||||||
} catch { /* already exists */ }
|
// Save sentinel to JSON file
|
||||||
await Deno.writeTextFile(CONNECTIONS_FILE, JSON.stringify(this.connections, null, 2));
|
const jsonConn = { ...conn, token: `${KEYCHAIN_PREFIX}${conn.id}` };
|
||||||
|
await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, jsonConn);
|
||||||
|
logger.info(`Migrated token for connection "${conn.name}" to keychain`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Failed to migrate token for ${conn.id} to keychain: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistConnection(conn: interfaces.data.IProviderConnection): Promise<void> {
|
||||||
|
// Store real token in keychain
|
||||||
|
await this.smartSecret.setSecret(conn.id, conn.token);
|
||||||
|
// Save JSON with sentinel value
|
||||||
|
const jsonConn = { ...conn, token: `${KEYCHAIN_PREFIX}${conn.id}` };
|
||||||
|
await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, jsonConn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async removeConnection(id: string): Promise<void> {
|
||||||
|
await this.smartSecret.deleteSecret(id);
|
||||||
|
await this.storageManager.delete(`${CONNECTIONS_PREFIX}${id}.json`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getConnections(): interfaces.data.IProviderConnection[] {
|
getConnections(): interfaces.data.IProviderConnection[] {
|
||||||
// Return connections without exposing tokens
|
|
||||||
return this.connections.map((c) => ({ ...c, token: '***' }));
|
return this.connections.map((c) => ({ ...c, token: '***' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +151,7 @@ export class ConnectionManager {
|
|||||||
providerType: interfaces.data.TProviderType,
|
providerType: interfaces.data.TProviderType,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
token: string,
|
token: string,
|
||||||
|
groupFilter?: string,
|
||||||
): Promise<interfaces.data.IProviderConnection> {
|
): Promise<interfaces.data.IProviderConnection> {
|
||||||
const connection: interfaces.data.IProviderConnection = {
|
const connection: interfaces.data.IProviderConnection = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -58,23 +161,28 @@ export class ConnectionManager {
|
|||||||
token,
|
token,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
status: 'disconnected',
|
status: 'disconnected',
|
||||||
|
groupFilter: groupFilter || undefined,
|
||||||
};
|
};
|
||||||
this.connections.push(connection);
|
this.connections.push(connection);
|
||||||
await this.saveConnections();
|
await this.persistConnection(connection);
|
||||||
logger.success(`Connection created: ${name} (${providerType})`);
|
logger.success(`Connection created: ${name} (${providerType})`);
|
||||||
return { ...connection, token: '***' };
|
return { ...connection, token: '***' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateConnection(
|
async updateConnection(
|
||||||
id: string,
|
id: string,
|
||||||
updates: { name?: string; baseUrl?: string; token?: string },
|
updates: { name?: string; baseUrl?: string; token?: string; groupFilter?: string },
|
||||||
): Promise<interfaces.data.IProviderConnection> {
|
): Promise<interfaces.data.IProviderConnection> {
|
||||||
const conn = this.connections.find((c) => c.id === id);
|
const conn = this.connections.find((c) => c.id === id);
|
||||||
if (!conn) throw new Error(`Connection not found: ${id}`);
|
if (!conn) throw new Error(`Connection not found: ${id}`);
|
||||||
if (updates.name) conn.name = updates.name;
|
if (updates.name) conn.name = updates.name;
|
||||||
if (updates.baseUrl) conn.baseUrl = updates.baseUrl.replace(/\/+$/, '');
|
if (updates.baseUrl) conn.baseUrl = updates.baseUrl.replace(/\/+$/, '');
|
||||||
if (updates.token) conn.token = updates.token;
|
if (updates.token) conn.token = updates.token;
|
||||||
await this.saveConnections();
|
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: '***' };
|
return { ...conn, token: '***' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,19 +190,60 @@ export class ConnectionManager {
|
|||||||
const idx = this.connections.findIndex((c) => c.id === id);
|
const idx = this.connections.findIndex((c) => c.id === id);
|
||||||
if (idx === -1) throw new Error(`Connection not found: ${id}`);
|
if (idx === -1) throw new Error(`Connection not found: ${id}`);
|
||||||
this.connections.splice(idx, 1);
|
this.connections.splice(idx, 1);
|
||||||
await this.saveConnections();
|
await this.removeConnection(id);
|
||||||
logger.info(`Connection deleted: ${id}`);
|
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 }> {
|
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 provider = this.getProvider(id);
|
||||||
const result = await provider.testConnection();
|
const result = await provider.testConnection();
|
||||||
const conn = this.connections.find((c) => c.id === id)!;
|
|
||||||
conn.status = result.ok ? 'connected' : 'error';
|
conn.status = result.ok ? 'connected' : 'error';
|
||||||
await this.saveConnections();
|
// Resolve group filter ID if connection has a groupFilter
|
||||||
|
if (result.ok && conn.groupFilter) {
|
||||||
|
await this.resolveGroupFilterId(conn);
|
||||||
|
}
|
||||||
|
await this.persistConnection(conn);
|
||||||
return result;
|
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.getGroupByPath(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
|
* Factory: returns the correct provider instance for a connection ID
|
||||||
*/
|
*/
|
||||||
@@ -104,9 +253,9 @@ export class ConnectionManager {
|
|||||||
|
|
||||||
switch (conn.providerType) {
|
switch (conn.providerType) {
|
||||||
case 'gitea':
|
case 'gitea':
|
||||||
return new GiteaProvider(conn.id, conn.baseUrl, conn.token);
|
return new GiteaProvider(conn.id, conn.baseUrl, conn.token, conn.groupFilterId);
|
||||||
case 'gitlab':
|
case 'gitlab':
|
||||||
return new GitLabProvider(conn.id, conn.baseUrl, conn.token);
|
return new GitLabProvider(conn.id, conn.baseUrl, conn.token, conn.groupFilterId);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown provider type: ${conn.providerType}`);
|
throw new Error(`Unknown provider type: ${conn.providerType}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,86 @@
|
|||||||
|
import * as plugins from '../plugins.ts';
|
||||||
import { logger } from '../logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
import { ConnectionManager } from './connectionmanager.ts';
|
import { ConnectionManager } from './connectionmanager.ts';
|
||||||
|
import { ActionLog } from './actionlog.ts';
|
||||||
|
import { SyncManager } from './syncmanager.ts';
|
||||||
import { OpsServer } from '../opsserver/index.ts';
|
import { OpsServer } from '../opsserver/index.ts';
|
||||||
|
import { StorageManager } from '../storage/index.ts';
|
||||||
|
import { CacheDb, CacheCleaner, CachedProject, CachedSecret, SecretsScanService } from '../cache/index.ts';
|
||||||
|
import { resolvePaths } from '../paths.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main GitOps application orchestrator
|
* Main GitOps application orchestrator
|
||||||
*/
|
*/
|
||||||
export class GitopsApp {
|
export class GitopsApp {
|
||||||
|
public storageManager: StorageManager;
|
||||||
|
public smartSecret: plugins.smartsecret.SmartSecret;
|
||||||
public connectionManager: ConnectionManager;
|
public connectionManager: ConnectionManager;
|
||||||
|
public actionLog: ActionLog;
|
||||||
public opsServer: OpsServer;
|
public opsServer: OpsServer;
|
||||||
|
public cacheDb: CacheDb;
|
||||||
|
public cacheCleaner: CacheCleaner;
|
||||||
|
public syncManager!: SyncManager;
|
||||||
|
public secretsScanService!: SecretsScanService;
|
||||||
|
private scanIntervalId: number | null = null;
|
||||||
|
private paths: ReturnType<typeof resolvePaths>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.connectionManager = new ConnectionManager();
|
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,
|
||||||
|
dbName: 'gitops_cache',
|
||||||
|
});
|
||||||
|
this.cacheCleaner = new CacheCleaner(this.cacheDb);
|
||||||
|
this.cacheCleaner.registerClass(CachedProject);
|
||||||
|
this.cacheCleaner.registerClass(CachedSecret);
|
||||||
|
|
||||||
this.opsServer = new OpsServer(this);
|
this.opsServer = new OpsServer(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(port = 3000): Promise<void> {
|
async start(port = 3000): Promise<void> {
|
||||||
logger.info('Initializing GitOps...');
|
logger.info('Initializing GitOps...');
|
||||||
|
|
||||||
|
// Start CacheDb
|
||||||
|
await this.cacheDb.start();
|
||||||
|
|
||||||
// Initialize connection manager (loads saved connections)
|
// Initialize connection manager (loads saved connections)
|
||||||
await this.connectionManager.init();
|
await this.connectionManager.init();
|
||||||
|
|
||||||
|
// Initialize sync manager
|
||||||
|
this.syncManager = new SyncManager(
|
||||||
|
this.storageManager,
|
||||||
|
this.connectionManager,
|
||||||
|
this.actionLog,
|
||||||
|
this.paths.syncMirrorsPath,
|
||||||
|
);
|
||||||
|
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();
|
||||||
|
|
||||||
// Start OpsServer
|
// Start OpsServer
|
||||||
await this.opsServer.start(port);
|
await this.opsServer.start(port);
|
||||||
|
|
||||||
@@ -28,7 +89,14 @@ export class GitopsApp {
|
|||||||
|
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
logger.info('Shutting down GitOps...');
|
logger.info('Shutting down GitOps...');
|
||||||
|
if (this.scanIntervalId !== null) {
|
||||||
|
clearInterval(this.scanIntervalId);
|
||||||
|
this.scanIntervalId = null;
|
||||||
|
}
|
||||||
|
await this.syncManager.stop();
|
||||||
await this.opsServer.stop();
|
await this.opsServer.stop();
|
||||||
|
this.cacheCleaner.stop();
|
||||||
|
await this.cacheDb.stop();
|
||||||
logger.success('GitOps shutdown complete');
|
logger.success('GitOps shutdown complete');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1771
ts/classes/syncmanager.ts
Normal file
1771
ts/classes/syncmanager.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,15 +2,60 @@
|
|||||||
* Logging utilities for GitOps
|
* Logging utilities for GitOps
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ISyncLogEntry } from '../ts_interfaces/data/sync.ts';
|
||||||
|
|
||||||
type LogLevel = 'info' | 'success' | 'warn' | 'error' | 'debug';
|
type LogLevel = 'info' | 'success' | 'warn' | 'error' | 'debug';
|
||||||
|
|
||||||
|
const SYNC_LOG_MAX = 500;
|
||||||
|
|
||||||
class Logger {
|
class Logger {
|
||||||
private debugMode = false;
|
private debugMode = false;
|
||||||
|
private syncLogBuffer: ISyncLogEntry[] = [];
|
||||||
|
private broadcastFn?: (entry: ISyncLogEntry) => void;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.debugMode = Deno.args.includes('--debug') || Deno.env.get('DEBUG') === 'true';
|
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 {
|
log(level: LogLevel, message: string, ...args: unknown[]): void {
|
||||||
const prefix = this.getPrefix(level);
|
const prefix = this.getPrefix(level);
|
||||||
const formattedMessage = `${prefix} ${message}`;
|
const formattedMessage = `${prefix} ${message}`;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as plugins from '../plugins.ts';
|
|||||||
import { logger } from '../logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
import type { GitopsApp } from '../classes/gitopsapp.ts';
|
import type { GitopsApp } from '../classes/gitopsapp.ts';
|
||||||
import * as handlers from './handlers/index.ts';
|
import * as handlers from './handlers/index.ts';
|
||||||
|
import { files as bundledFiles } from '../../ts_bundled/bundle.ts';
|
||||||
|
|
||||||
export class OpsServer {
|
export class OpsServer {
|
||||||
public gitopsAppRef: GitopsApp;
|
public gitopsAppRef: GitopsApp;
|
||||||
@@ -16,17 +17,27 @@ export class OpsServer {
|
|||||||
public secretsHandler!: handlers.SecretsHandler;
|
public secretsHandler!: handlers.SecretsHandler;
|
||||||
public pipelinesHandler!: handlers.PipelinesHandler;
|
public pipelinesHandler!: handlers.PipelinesHandler;
|
||||||
public logsHandler!: handlers.LogsHandler;
|
public logsHandler!: handlers.LogsHandler;
|
||||||
|
public webhookHandler!: handlers.WebhookHandler;
|
||||||
|
public actionsHandler!: handlers.ActionsHandler;
|
||||||
|
public actionLogHandler!: handlers.ActionLogHandler;
|
||||||
|
public syncHandler!: handlers.SyncHandler;
|
||||||
|
|
||||||
constructor(gitopsAppRef: GitopsApp) {
|
constructor(gitopsAppRef: GitopsApp) {
|
||||||
this.gitopsAppRef = gitopsAppRef;
|
this.gitopsAppRef = gitopsAppRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async start(port = 3000) {
|
public async start(port = 3000) {
|
||||||
const absoluteServeDir = plugins.path.resolve('./dist_serve');
|
// Create webhook handler before server so routes register via addCustomRoutes
|
||||||
|
this.webhookHandler = new handlers.WebhookHandler(this);
|
||||||
|
|
||||||
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
|
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
|
||||||
domain: 'localhost',
|
domain: 'localhost',
|
||||||
feedMetadata: undefined,
|
feedMetadata: undefined,
|
||||||
serveDir: absoluteServeDir,
|
bundledContent: bundledFiles,
|
||||||
|
noCache: true,
|
||||||
|
addCustomRoutes: async (typedserver) => {
|
||||||
|
this.webhookHandler.registerRoutes(typedserver);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Chain typedrouters
|
// Chain typedrouters
|
||||||
@@ -51,6 +62,9 @@ export class OpsServer {
|
|||||||
this.secretsHandler = new handlers.SecretsHandler(this);
|
this.secretsHandler = new handlers.SecretsHandler(this);
|
||||||
this.pipelinesHandler = new handlers.PipelinesHandler(this);
|
this.pipelinesHandler = new handlers.PipelinesHandler(this);
|
||||||
this.logsHandler = new handlers.LogsHandler(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);
|
||||||
|
|
||||||
logger.success('OpsServer TypedRequest handlers initialized');
|
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();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get actionLog() {
|
||||||
|
return this.opsServerRef.gitopsAppRef.actionLog;
|
||||||
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
// Get all connections
|
// Get all connections
|
||||||
this.typedrouter.addTypedHandler(
|
this.typedrouter.addTypedHandler(
|
||||||
@@ -35,7 +39,16 @@ export class ConnectionsHandler {
|
|||||||
dataArg.providerType,
|
dataArg.providerType,
|
||||||
dataArg.baseUrl,
|
dataArg.baseUrl,
|
||||||
dataArg.token,
|
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 };
|
return { connection };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -53,8 +66,46 @@ export class ConnectionsHandler {
|
|||||||
name: dataArg.name,
|
name: dataArg.name,
|
||||||
baseUrl: dataArg.baseUrl,
|
baseUrl: dataArg.baseUrl,
|
||||||
token: dataArg.token,
|
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 };
|
return { connection };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -69,6 +120,16 @@ export class ConnectionsHandler {
|
|||||||
const result = await this.opsServerRef.gitopsAppRef.connectionManager.testConnection(
|
const result = await this.opsServerRef.gitopsAppRef.connectionManager.testConnection(
|
||||||
dataArg.connectionId,
|
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;
|
return result;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -80,9 +141,19 @@ export class ConnectionsHandler {
|
|||||||
'deleteConnection',
|
'deleteConnection',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
await requireValidIdentity(this.opsServerRef.adminHandler, 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(
|
await this.opsServerRef.gitopsAppRef.connectionManager.deleteConnection(
|
||||||
dataArg.connectionId,
|
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 };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,3 +5,7 @@ export { GroupsHandler } from './groups.handler.ts';
|
|||||||
export { SecretsHandler } from './secrets.handler.ts';
|
export { SecretsHandler } from './secrets.handler.ts';
|
||||||
export { PipelinesHandler } from './pipelines.handler.ts';
|
export { PipelinesHandler } from './pipelines.handler.ts';
|
||||||
export { LogsHandler } from './logs.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';
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export class PipelinesHandler {
|
|||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get actionLog() {
|
||||||
|
return this.opsServerRef.gitopsAppRef.actionLog;
|
||||||
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
// Get pipelines
|
// Get pipelines
|
||||||
this.typedrouter.addTypedHandler(
|
this.typedrouter.addTypedHandler(
|
||||||
@@ -54,6 +58,14 @@ export class PipelinesHandler {
|
|||||||
dataArg.connectionId,
|
dataArg.connectionId,
|
||||||
);
|
);
|
||||||
await provider.retryPipeline(dataArg.projectId, dataArg.pipelineId);
|
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 };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -69,6 +81,14 @@ export class PipelinesHandler {
|
|||||||
dataArg.connectionId,
|
dataArg.connectionId,
|
||||||
);
|
);
|
||||||
await provider.cancelPipeline(dataArg.projectId, dataArg.pipelineId);
|
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 };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,19 +11,125 @@ export class SecretsHandler {
|
|||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get actionLog() {
|
||||||
|
return this.opsServerRef.gitopsAppRef.actionLog;
|
||||||
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
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(
|
this.typedrouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecrets>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecrets>(
|
||||||
'getSecrets',
|
'getSecrets',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
await requireValidIdentity(this.opsServerRef.adminHandler, 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(
|
const provider = this.opsServerRef.gitopsAppRef.connectionManager.getProvider(
|
||||||
dataArg.connectionId,
|
dataArg.connectionId,
|
||||||
);
|
);
|
||||||
const secrets = dataArg.scope === 'project'
|
const secrets = dataArg.scope === 'project'
|
||||||
? await provider.getProjectSecrets(dataArg.scopeId)
|
? await provider.getProjectSecrets(dataArg.scopeId)
|
||||||
: await provider.getGroupSecrets(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 };
|
return { secrets };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -41,6 +147,17 @@ export class SecretsHandler {
|
|||||||
const secret = dataArg.scope === 'project'
|
const secret = dataArg.scope === 'project'
|
||||||
? await provider.createProjectSecret(dataArg.scopeId, dataArg.key, dataArg.value)
|
? await provider.createProjectSecret(dataArg.scopeId, dataArg.key, dataArg.value)
|
||||||
: await provider.createGroupSecret(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 };
|
return { secret };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -58,6 +175,17 @@ export class SecretsHandler {
|
|||||||
const secret = dataArg.scope === 'project'
|
const secret = dataArg.scope === 'project'
|
||||||
? await provider.updateProjectSecret(dataArg.scopeId, dataArg.key, dataArg.value)
|
? await provider.updateProjectSecret(dataArg.scopeId, dataArg.key, dataArg.value)
|
||||||
: await provider.updateGroupSecret(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 };
|
return { secret };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -77,6 +205,17 @@ export class SecretsHandler {
|
|||||||
} else {
|
} else {
|
||||||
await provider.deleteGroupSecret(dataArg.scopeId, dataArg.key);
|
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 };
|
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('allClients').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('allClients');
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
21
ts/paths.ts
Normal file
21
ts/paths.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as path from '@std/path';
|
||||||
|
|
||||||
|
export interface IGitopsPaths {
|
||||||
|
gitopsHomeDir: string;
|
||||||
|
defaultStoragePath: string;
|
||||||
|
defaultTsmDbPath: string;
|
||||||
|
syncMirrorsPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve gitops paths. Accepts optional baseDir for test isolation.
|
||||||
|
*/
|
||||||
|
export function resolvePaths(baseDir?: string): IGitopsPaths {
|
||||||
|
const home = baseDir ?? path.join(Deno.env.get('HOME') ?? '/tmp', '.serve.zone', 'gitops');
|
||||||
|
return {
|
||||||
|
gitopsHomeDir: home,
|
||||||
|
defaultStoragePath: path.join(home, 'storage'),
|
||||||
|
defaultTsmDbPath: path.join(home, 'tsmdb'),
|
||||||
|
syncMirrorsPath: path.join(home, 'mirrors'),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,3 +23,12 @@ export { smartguard, smartjwt };
|
|||||||
import * as giteaClient from '@apiclient.xyz/gitea';
|
import * as giteaClient from '@apiclient.xyz/gitea';
|
||||||
import * as gitlabClient from '@apiclient.xyz/gitlab';
|
import * as gitlabClient from '@apiclient.xyz/gitlab';
|
||||||
export { giteaClient, gitlabClient };
|
export { giteaClient, gitlabClient };
|
||||||
|
|
||||||
|
// Database
|
||||||
|
import * as smartmongo from '@push.rocks/smartmongo';
|
||||||
|
import * as smartdata from '@push.rocks/smartdata';
|
||||||
|
export { smartmongo, smartdata };
|
||||||
|
|
||||||
|
// Secrets
|
||||||
|
import * as smartsecret from '@push.rocks/smartsecret';
|
||||||
|
export { smartsecret };
|
||||||
|
|||||||
@@ -16,11 +16,16 @@ export interface IListOptions {
|
|||||||
* Subclasses implement Gitea API v1 or GitLab API v4.
|
* Subclasses implement Gitea API v1 or GitLab API v4.
|
||||||
*/
|
*/
|
||||||
export abstract class BaseProvider {
|
export abstract class BaseProvider {
|
||||||
|
public readonly groupFilterId?: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly connectionId: string,
|
public readonly connectionId: string,
|
||||||
public readonly baseUrl: string,
|
public readonly baseUrl: string,
|
||||||
protected readonly token: string,
|
protected readonly token: string,
|
||||||
) {}
|
groupFilterId?: string,
|
||||||
|
) {
|
||||||
|
this.groupFilterId = groupFilterId;
|
||||||
|
}
|
||||||
|
|
||||||
// Connection
|
// Connection
|
||||||
abstract testConnection(): Promise<ITestConnectionResult>;
|
abstract testConnection(): Promise<ITestConnectionResult>;
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { BaseProvider, type ITestConnectionResult, type IListOptions } from './c
|
|||||||
export class GiteaProvider extends BaseProvider {
|
export class GiteaProvider extends BaseProvider {
|
||||||
private client: plugins.giteaClient.GiteaClient;
|
private client: plugins.giteaClient.GiteaClient;
|
||||||
|
|
||||||
constructor(connectionId: string, baseUrl: string, token: string) {
|
constructor(connectionId: string, baseUrl: string, token: string, groupFilterId?: string) {
|
||||||
super(connectionId, baseUrl, token);
|
super(connectionId, baseUrl, token, groupFilterId);
|
||||||
this.client = new plugins.giteaClient.GiteaClient(baseUrl, token);
|
this.client = new plugins.giteaClient.GiteaClient(baseUrl, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,15 +18,58 @@ export class GiteaProvider extends BaseProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getProjects(opts?: IListOptions): Promise<interfaces.data.IProject[]> {
|
async getProjects(opts?: IListOptions): Promise<interfaces.data.IProject[]> {
|
||||||
const repos = await this.client.getRepos(opts);
|
// Use org-scoped listing when groupFilterId is set
|
||||||
|
const fetchFn = this.groupFilterId
|
||||||
|
? (o: IListOptions) => this.client.getOrgRepos(this.groupFilterId!, o)
|
||||||
|
: (o: IListOptions) => this.client.getRepos(o);
|
||||||
|
|
||||||
|
// If caller explicitly requests a specific page, respect it (no auto-pagination)
|
||||||
|
if (opts?.page) {
|
||||||
|
const repos = await fetchFn(opts);
|
||||||
return repos.map((r) => this.mapProject(r));
|
return repos.map((r) => this.mapProject(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allRepos: plugins.giteaClient.IGiteaRepository[] = [];
|
||||||
|
const perPage = opts?.perPage || 50;
|
||||||
|
let page = 1;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const repos = await fetchFn({ ...opts, page, perPage });
|
||||||
|
allRepos.push(...repos);
|
||||||
|
if (repos.length < perPage) break;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return allRepos.map((r) => this.mapProject(r));
|
||||||
|
}
|
||||||
|
|
||||||
async getGroups(opts?: IListOptions): Promise<interfaces.data.IGroup[]> {
|
async getGroups(opts?: IListOptions): Promise<interfaces.data.IGroup[]> {
|
||||||
|
// When groupFilterId is set, return only that single org
|
||||||
|
if (this.groupFilterId) {
|
||||||
|
const org = await this.client.getOrg(this.groupFilterId);
|
||||||
|
return [this.mapGroup(org)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If caller explicitly requests a specific page, respect it (no auto-pagination)
|
||||||
|
if (opts?.page) {
|
||||||
const orgs = await this.client.getOrgs(opts);
|
const orgs = await this.client.getOrgs(opts);
|
||||||
return orgs.map((o) => this.mapGroup(o));
|
return orgs.map((o) => this.mapGroup(o));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allOrgs: plugins.giteaClient.IGiteaOrganization[] = [];
|
||||||
|
const perPage = opts?.perPage || 50;
|
||||||
|
let page = 1;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const orgs = await this.client.getOrgs({ ...opts, page, perPage });
|
||||||
|
allOrgs.push(...orgs);
|
||||||
|
if (orgs.length < perPage) break;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return allOrgs.map((o) => this.mapGroup(o));
|
||||||
|
}
|
||||||
|
|
||||||
// --- Project Secrets ---
|
// --- Project Secrets ---
|
||||||
|
|
||||||
async getProjectSecrets(projectId: string): Promise<interfaces.data.ISecret[]> {
|
async getProjectSecrets(projectId: string): Promise<interfaces.data.ISecret[]> {
|
||||||
@@ -40,7 +83,7 @@ export class GiteaProvider extends BaseProvider {
|
|||||||
value: string,
|
value: string,
|
||||||
): Promise<interfaces.data.ISecret> {
|
): Promise<interfaces.data.ISecret> {
|
||||||
await this.client.setRepoSecret(projectId, key, value);
|
await this.client.setRepoSecret(projectId, key, value);
|
||||||
return { key, value: '***', protected: false, masked: true, scope: 'project', scopeId: projectId, connectionId: this.connectionId, environment: '*' };
|
return { key, value: '***', protected: false, masked: true, scope: 'project', scopeId: projectId, scopeName: projectId, connectionId: this.connectionId, environment: '*' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateProjectSecret(
|
async updateProjectSecret(
|
||||||
@@ -68,7 +111,7 @@ export class GiteaProvider extends BaseProvider {
|
|||||||
value: string,
|
value: string,
|
||||||
): Promise<interfaces.data.ISecret> {
|
): Promise<interfaces.data.ISecret> {
|
||||||
await this.client.setOrgSecret(groupId, key, value);
|
await this.client.setOrgSecret(groupId, key, value);
|
||||||
return { key, value: '***', protected: false, masked: true, scope: 'group', scopeId: groupId, connectionId: this.connectionId, environment: '*' };
|
return { key, value: '***', protected: false, masked: true, scope: 'group', scopeId: groupId, scopeName: groupId, connectionId: this.connectionId, environment: '*' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateGroupSecret(
|
async updateGroupSecret(
|
||||||
@@ -117,7 +160,7 @@ export class GiteaProvider extends BaseProvider {
|
|||||||
|
|
||||||
private mapProject(r: plugins.giteaClient.IGiteaRepository): interfaces.data.IProject {
|
private mapProject(r: plugins.giteaClient.IGiteaRepository): interfaces.data.IProject {
|
||||||
return {
|
return {
|
||||||
id: String(r.id),
|
id: r.full_name || String(r.id),
|
||||||
name: r.name || '',
|
name: r.name || '',
|
||||||
fullPath: r.full_name || '',
|
fullPath: r.full_name || '',
|
||||||
description: r.description || '',
|
description: r.description || '',
|
||||||
@@ -132,7 +175,7 @@ export class GiteaProvider extends BaseProvider {
|
|||||||
|
|
||||||
private mapGroup(o: plugins.giteaClient.IGiteaOrganization): interfaces.data.IGroup {
|
private mapGroup(o: plugins.giteaClient.IGiteaOrganization): interfaces.data.IGroup {
|
||||||
return {
|
return {
|
||||||
id: String(o.id || o.name),
|
id: o.name || String(o.id),
|
||||||
name: o.name || '',
|
name: o.name || '',
|
||||||
fullPath: o.name || '',
|
fullPath: o.name || '',
|
||||||
description: o.description || '',
|
description: o.description || '',
|
||||||
@@ -143,7 +186,7 @@ export class GiteaProvider extends BaseProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapSecret(s: plugins.giteaClient.IGiteaSecret, scope: 'project' | 'group', scopeId: string): interfaces.data.ISecret {
|
private mapSecret(s: plugins.giteaClient.IGiteaSecret, scope: 'project' | 'group', scopeId: string, scopeName?: string): interfaces.data.ISecret {
|
||||||
return {
|
return {
|
||||||
key: s.name || '',
|
key: s.name || '',
|
||||||
value: '***',
|
value: '***',
|
||||||
@@ -151,6 +194,7 @@ export class GiteaProvider extends BaseProvider {
|
|||||||
masked: true,
|
masked: true,
|
||||||
scope,
|
scope,
|
||||||
scopeId,
|
scopeId,
|
||||||
|
scopeName: scopeName || scopeId,
|
||||||
connectionId: this.connectionId,
|
connectionId: this.connectionId,
|
||||||
environment: '*',
|
environment: '*',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { BaseProvider, type ITestConnectionResult, type IListOptions } from './c
|
|||||||
export class GitLabProvider extends BaseProvider {
|
export class GitLabProvider extends BaseProvider {
|
||||||
private client: plugins.gitlabClient.GitLabClient;
|
private client: plugins.gitlabClient.GitLabClient;
|
||||||
|
|
||||||
constructor(connectionId: string, baseUrl: string, token: string) {
|
constructor(connectionId: string, baseUrl: string, token: string, groupFilterId?: string) {
|
||||||
super(connectionId, baseUrl, token);
|
super(connectionId, baseUrl, token, groupFilterId);
|
||||||
this.client = new plugins.gitlabClient.GitLabClient(baseUrl, token);
|
this.client = new plugins.gitlabClient.GitLabClient(baseUrl, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,14 +18,72 @@ export class GitLabProvider extends BaseProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getProjects(opts?: IListOptions): Promise<interfaces.data.IProject[]> {
|
async getProjects(opts?: IListOptions): Promise<interfaces.data.IProject[]> {
|
||||||
|
if (this.groupFilterId) {
|
||||||
|
// Auto-paginate group-scoped project listing
|
||||||
|
if (opts?.page) {
|
||||||
|
const projects = await this.client.getGroupProjects(this.groupFilterId, opts);
|
||||||
|
return projects.map((p) => this.mapProject(p));
|
||||||
|
}
|
||||||
|
const allProjects: plugins.gitlabClient.IGitLabProject[] = [];
|
||||||
|
const perPage = opts?.perPage || 50;
|
||||||
|
let page = 1;
|
||||||
|
while (true) {
|
||||||
|
const projects = await this.client.getGroupProjects(this.groupFilterId, { ...opts, page, perPage });
|
||||||
|
allProjects.push(...projects);
|
||||||
|
if (projects.length < perPage) break;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
return allProjects.map((p) => this.mapProject(p));
|
||||||
|
}
|
||||||
|
if (opts?.page) {
|
||||||
const projects = await this.client.getProjects(opts);
|
const projects = await this.client.getProjects(opts);
|
||||||
return projects.map((p) => this.mapProject(p));
|
return projects.map((p) => this.mapProject(p));
|
||||||
}
|
}
|
||||||
|
const allProjects: plugins.gitlabClient.IGitLabProject[] = [];
|
||||||
|
const perPage = opts?.perPage || 50;
|
||||||
|
let page = 1;
|
||||||
|
while (true) {
|
||||||
|
const projects = await this.client.getProjects({ ...opts, page, perPage });
|
||||||
|
allProjects.push(...projects);
|
||||||
|
if (projects.length < perPage) break;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
return allProjects.map((p) => this.mapProject(p));
|
||||||
|
}
|
||||||
|
|
||||||
async getGroups(opts?: IListOptions): Promise<interfaces.data.IGroup[]> {
|
async getGroups(opts?: IListOptions): Promise<interfaces.data.IGroup[]> {
|
||||||
|
if (this.groupFilterId) {
|
||||||
|
// Auto-paginate descendant groups listing
|
||||||
|
if (opts?.page) {
|
||||||
|
const groups = await this.client.getDescendantGroups(this.groupFilterId, opts);
|
||||||
|
return groups.map((g) => this.mapGroup(g));
|
||||||
|
}
|
||||||
|
const allGroups: plugins.gitlabClient.IGitLabGroup[] = [];
|
||||||
|
const perPage = opts?.perPage || 50;
|
||||||
|
let page = 1;
|
||||||
|
while (true) {
|
||||||
|
const groups = await this.client.getDescendantGroups(this.groupFilterId, { ...opts, page, perPage });
|
||||||
|
allGroups.push(...groups);
|
||||||
|
if (groups.length < perPage) break;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
return allGroups.map((g) => this.mapGroup(g));
|
||||||
|
}
|
||||||
|
if (opts?.page) {
|
||||||
const groups = await this.client.getGroups(opts);
|
const groups = await this.client.getGroups(opts);
|
||||||
return groups.map((g) => this.mapGroup(g));
|
return groups.map((g) => this.mapGroup(g));
|
||||||
}
|
}
|
||||||
|
const allGroups: plugins.gitlabClient.IGitLabGroup[] = [];
|
||||||
|
const perPage = opts?.perPage || 50;
|
||||||
|
let page = 1;
|
||||||
|
while (true) {
|
||||||
|
const groups = await this.client.getGroups({ ...opts, page, perPage });
|
||||||
|
allGroups.push(...groups);
|
||||||
|
if (groups.length < perPage) break;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
return allGroups.map((g) => this.mapGroup(g));
|
||||||
|
}
|
||||||
|
|
||||||
// --- Project Secrets (CI/CD Variables) ---
|
// --- Project Secrets (CI/CD Variables) ---
|
||||||
|
|
||||||
@@ -149,6 +207,7 @@ export class GitLabProvider extends BaseProvider {
|
|||||||
v: plugins.gitlabClient.IGitLabVariable,
|
v: plugins.gitlabClient.IGitLabVariable,
|
||||||
scope: 'project' | 'group',
|
scope: 'project' | 'group',
|
||||||
scopeId: string,
|
scopeId: string,
|
||||||
|
scopeName?: string,
|
||||||
): interfaces.data.ISecret {
|
): interfaces.data.ISecret {
|
||||||
return {
|
return {
|
||||||
key: v.key || '',
|
key: v.key || '',
|
||||||
@@ -157,6 +216,7 @@ export class GitLabProvider extends BaseProvider {
|
|||||||
masked: v.masked || false,
|
masked: v.masked || false,
|
||||||
scope,
|
scope,
|
||||||
scopeId,
|
scopeId,
|
||||||
|
scopeName: scopeName || scopeId,
|
||||||
connectionId: this.connectionId,
|
connectionId: this.connectionId,
|
||||||
environment: v.environment_scope || '*',
|
environment: v.environment_scope || '*',
|
||||||
};
|
};
|
||||||
|
|||||||
139
ts/storage/classes.storagemanager.ts
Normal file
139
ts/storage/classes.storagemanager.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import * as path from '@std/path';
|
||||||
|
|
||||||
|
export type TStorageBackend = 'filesystem' | 'memory';
|
||||||
|
|
||||||
|
export interface IStorageConfig {
|
||||||
|
backend?: TStorageBackend;
|
||||||
|
fsPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key-value storage abstraction with filesystem and memory backends.
|
||||||
|
* Keys must start with '/' and are normalized (no '..', no double slashes).
|
||||||
|
*/
|
||||||
|
export class StorageManager {
|
||||||
|
private backend: TStorageBackend;
|
||||||
|
private fsPath: string;
|
||||||
|
private memoryStore: Map<string, string>;
|
||||||
|
|
||||||
|
constructor(config: IStorageConfig = {}) {
|
||||||
|
this.backend = config.backend ?? 'filesystem';
|
||||||
|
this.fsPath = config.fsPath ?? './storage';
|
||||||
|
this.memoryStore = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize and validate a storage key.
|
||||||
|
*/
|
||||||
|
private normalizeKey(key: string): string {
|
||||||
|
if (!key.startsWith('/')) {
|
||||||
|
throw new Error(`Storage key must start with '/': ${key}`);
|
||||||
|
}
|
||||||
|
// Strip '..' segments and normalize double slashes
|
||||||
|
const segments = key.split('/').filter((s) => s !== '' && s !== '..');
|
||||||
|
return '/' + segments.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a key to a filesystem path.
|
||||||
|
*/
|
||||||
|
private keyToPath(key: string): string {
|
||||||
|
const normalized = this.normalizeKey(key);
|
||||||
|
return path.join(this.fsPath, ...normalized.split('/').filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<string | null> {
|
||||||
|
const normalized = this.normalizeKey(key);
|
||||||
|
if (this.backend === 'memory') {
|
||||||
|
return this.memoryStore.get(normalized) ?? null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await Deno.readTextFile(this.keyToPath(normalized));
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Deno.errors.NotFound) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: string): Promise<void> {
|
||||||
|
const normalized = this.normalizeKey(key);
|
||||||
|
if (this.backend === 'memory') {
|
||||||
|
this.memoryStore.set(normalized, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filePath = this.keyToPath(normalized);
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
await Deno.mkdir(dir, { recursive: true });
|
||||||
|
// Atomic write: write to temp then rename
|
||||||
|
const tmpPath = filePath + '.tmp';
|
||||||
|
await Deno.writeTextFile(tmpPath, value);
|
||||||
|
await Deno.rename(tmpPath, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key: string): Promise<boolean> {
|
||||||
|
const normalized = this.normalizeKey(key);
|
||||||
|
if (this.backend === 'memory') {
|
||||||
|
return this.memoryStore.delete(normalized);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Deno.remove(this.keyToPath(normalized));
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Deno.errors.NotFound) return false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async exists(key: string): Promise<boolean> {
|
||||||
|
const normalized = this.normalizeKey(key);
|
||||||
|
if (this.backend === 'memory') {
|
||||||
|
return this.memoryStore.has(normalized);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Deno.stat(this.keyToPath(normalized));
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Deno.errors.NotFound) return false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List keys under a given prefix.
|
||||||
|
*/
|
||||||
|
async list(prefix: string): Promise<string[]> {
|
||||||
|
const normalized = this.normalizeKey(prefix);
|
||||||
|
if (this.backend === 'memory') {
|
||||||
|
const keys: string[] = [];
|
||||||
|
for (const key of this.memoryStore.keys()) {
|
||||||
|
if (key.startsWith(normalized)) {
|
||||||
|
keys.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys.sort();
|
||||||
|
}
|
||||||
|
const dirPath = this.keyToPath(normalized);
|
||||||
|
const keys: string[] = [];
|
||||||
|
try {
|
||||||
|
for await (const entry of Deno.readDir(dirPath)) {
|
||||||
|
if (entry.isFile) {
|
||||||
|
keys.push(normalized.replace(/\/$/, '') + '/' + entry.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Deno.errors.NotFound) return [];
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return keys.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getJSON<T>(key: string): Promise<T | null> {
|
||||||
|
const raw = await this.get(key);
|
||||||
|
if (raw === null) return null;
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setJSON(key: string, value: unknown): Promise<void> {
|
||||||
|
await this.set(key, JSON.stringify(value, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
2
ts/storage/index.ts
Normal file
2
ts/storage/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { StorageManager } from './classes.storagemanager.ts';
|
||||||
|
export type { IStorageConfig, TStorageBackend } from './classes.storagemanager.ts';
|
||||||
11
ts_bundled/bundle.ts
Normal file
11
ts_bundled/bundle.ts
Normal file
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';
|
||||||
|
export type TActionEntity = 'connection' | 'secret' | 'pipeline' | 'sync';
|
||||||
|
|
||||||
|
export interface IActionLogEntry {
|
||||||
|
id: string;
|
||||||
|
timestamp: number;
|
||||||
|
actionType: TActionType;
|
||||||
|
entityType: TActionEntity;
|
||||||
|
entityId: string;
|
||||||
|
entityName: string;
|
||||||
|
details: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
@@ -7,5 +7,7 @@ export interface IProviderConnection {
|
|||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
token: string;
|
token: string;
|
||||||
createdAt: number;
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ export * from './project.ts';
|
|||||||
export * from './group.ts';
|
export * from './group.ts';
|
||||||
export * from './secret.ts';
|
export * from './secret.ts';
|
||||||
export * from './pipeline.ts';
|
export * from './pipeline.ts';
|
||||||
|
export * from './actionlog.ts';
|
||||||
|
export * from './sync.ts';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface ISecret {
|
|||||||
masked: boolean;
|
masked: boolean;
|
||||||
scope: 'project' | 'group';
|
scope: 'project' | 'group';
|
||||||
scopeId: string;
|
scopeId: string;
|
||||||
|
scopeName: string;
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
environment: 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;
|
providerType: data.TProviderType;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
groupFilter?: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
connection: data.IProviderConnection;
|
connection: data.IProviderConnection;
|
||||||
@@ -42,6 +43,7 @@ export interface IReq_UpdateConnection extends plugins.typedrequestInterfaces.im
|
|||||||
name?: string;
|
name?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
|
groupFilter?: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
connection: data.IProviderConnection;
|
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<
|
export interface IReq_DeleteConnection extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
plugins.typedrequestInterfaces.ITypedRequest,
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
IReq_DeleteConnection
|
IReq_DeleteConnection
|
||||||
|
|||||||
@@ -5,3 +5,7 @@ export * from './groups.ts';
|
|||||||
export * from './secrets.ts';
|
export * from './secrets.ts';
|
||||||
export * from './pipelines.ts';
|
export * from './pipelines.ts';
|
||||||
export * from './logs.ts';
|
export * from './logs.ts';
|
||||||
|
export * from './webhook.ts';
|
||||||
|
export * from './actions.ts';
|
||||||
|
export * from './actionlog.ts';
|
||||||
|
export * from './sync.ts';
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
import * as plugins from '../plugins.ts';
|
import * as plugins from '../plugins.ts';
|
||||||
import * as data from '../data/index.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<
|
export interface IReq_GetSecrets extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
plugins.typedrequestInterfaces.ITypedRequest,
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
IReq_GetSecrets
|
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 = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/gitops',
|
name: '@serve.zone/gitops',
|
||||||
version: '2.0.0',
|
version: '2.8.0',
|
||||||
description: 'GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs'
|
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;
|
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 {
|
export interface IUiState {
|
||||||
activeView: string;
|
activeView: string;
|
||||||
autoRefresh: boolean;
|
autoRefresh: boolean;
|
||||||
refreshInterval: number;
|
refreshInterval: number;
|
||||||
|
navigationContext?: INavigationContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -70,6 +83,15 @@ export const dataStatePart = await appState.getStatePart<IDataState>(
|
|||||||
'soft',
|
'soft',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const actionLogStatePart = await appState.getStatePart<IActionLogState>(
|
||||||
|
'actionLog',
|
||||||
|
{
|
||||||
|
entries: [],
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
'soft',
|
||||||
|
);
|
||||||
|
|
||||||
export const uiStatePart = await appState.getStatePart<IUiState>(
|
export const uiStatePart = await appState.getStatePart<IUiState>(
|
||||||
'ui',
|
'ui',
|
||||||
{
|
{
|
||||||
@@ -157,6 +179,7 @@ export const createConnectionAction = connectionsStatePart.createAction<{
|
|||||||
providerType: interfaces.data.TProviderType;
|
providerType: interfaces.data.TProviderType;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
groupFilter?: string;
|
||||||
}>(async (statePartArg, dataArg) => {
|
}>(async (statePartArg, dataArg) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
try {
|
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
|
// 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<{
|
export const createSecretAction = dataStatePart.createAction<{
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
scope: 'project' | 'group';
|
scope: 'project' | 'group';
|
||||||
@@ -320,7 +424,7 @@ export const createSecretAction = dataStatePart.createAction<{
|
|||||||
identity: context.identity!,
|
identity: context.identity!,
|
||||||
...dataArg,
|
...dataArg,
|
||||||
});
|
});
|
||||||
// Re-fetch secrets
|
// Re-fetch only the affected entity's secrets and merge
|
||||||
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
interfaces.requests.IReq_GetSecrets
|
interfaces.requests.IReq_GetSecrets
|
||||||
>('/typedrequest', 'getSecrets');
|
>('/typedrequest', 'getSecrets');
|
||||||
@@ -330,7 +434,11 @@ export const createSecretAction = dataStatePart.createAction<{
|
|||||||
scope: dataArg.scope,
|
scope: dataArg.scope,
|
||||||
scopeId: dataArg.scopeId,
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to create secret:', err);
|
console.error('Failed to create secret:', err);
|
||||||
return statePartArg.getState();
|
return statePartArg.getState();
|
||||||
@@ -353,7 +461,7 @@ export const updateSecretAction = dataStatePart.createAction<{
|
|||||||
identity: context.identity!,
|
identity: context.identity!,
|
||||||
...dataArg,
|
...dataArg,
|
||||||
});
|
});
|
||||||
// Re-fetch
|
// Re-fetch only the affected entity's secrets and merge
|
||||||
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
interfaces.requests.IReq_GetSecrets
|
interfaces.requests.IReq_GetSecrets
|
||||||
>('/typedrequest', 'getSecrets');
|
>('/typedrequest', 'getSecrets');
|
||||||
@@ -363,7 +471,11 @@ export const updateSecretAction = dataStatePart.createAction<{
|
|||||||
scope: dataArg.scope,
|
scope: dataArg.scope,
|
||||||
scopeId: dataArg.scopeId,
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to update secret:', err);
|
console.error('Failed to update secret:', err);
|
||||||
return statePartArg.getState();
|
return statePartArg.getState();
|
||||||
@@ -388,7 +500,9 @@ export const deleteSecretAction = dataStatePart.createAction<{
|
|||||||
const state = statePartArg.getState();
|
const state = statePartArg.getState();
|
||||||
return {
|
return {
|
||||||
...state,
|
...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) {
|
} catch (err) {
|
||||||
console.error('Failed to delete secret:', err);
|
console.error('Failed to delete secret:', err);
|
||||||
@@ -529,13 +643,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
|
// UI Actions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const setActiveViewAction = uiStatePart.createAction<{ view: string }>(
|
export const setActiveViewAction = uiStatePart.createAction<{
|
||||||
|
view: string;
|
||||||
|
navigationContext?: INavigationContext;
|
||||||
|
}>(
|
||||||
async (statePartArg, dataArg) => {
|
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 +697,233 @@ export const toggleAutoRefreshAction = uiStatePart.createAction(async (statePart
|
|||||||
const state = statePartArg.getState();
|
const state = statePartArg.getState();
|
||||||
return { ...state, autoRefresh: !state.autoRefresh };
|
return { ...state, autoRefresh: !state.autoRefresh };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const setRefreshIntervalAction = uiStatePart.createAction<{ interval: number }>(
|
||||||
|
async (statePartArg, dataArg) => {
|
||||||
|
return { ...statePartArg.getState(), refreshInterval: dataArg.interval };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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 {};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await plugins.typedsocket.TypedSocket.createClient(
|
||||||
|
typedrouter,
|
||||||
|
plugins.typedsocket.TypedSocket.useWindowLocationOriginUrl(),
|
||||||
|
{ autoReconnect: true },
|
||||||
|
);
|
||||||
|
} 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 { GitopsViewSecrets } from './views/secrets/index.js';
|
||||||
import type { GitopsViewPipelines } from './views/pipelines/index.js';
|
import type { GitopsViewPipelines } from './views/pipelines/index.js';
|
||||||
import type { GitopsViewBuildlog } from './views/buildlog/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')
|
@customElement('gitops-dashboard')
|
||||||
export class GitopsDashboard extends DeesElement {
|
export class GitopsDashboard extends DeesElement {
|
||||||
@@ -32,16 +35,27 @@ export class GitopsDashboard extends DeesElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private viewTabs = [
|
private viewTabs = [
|
||||||
{ name: 'Overview', element: (async () => (await import('./views/overview/index.js')).GitopsViewOverview)() },
|
{ name: 'Overview', iconName: 'lucide:layoutDashboard', element: (async () => (await import('./views/overview/index.js')).GitopsViewOverview)() },
|
||||||
{ name: 'Connections', element: (async () => (await import('./views/connections/index.js')).GitopsViewConnections)() },
|
{ name: 'Connections', iconName: 'lucide:plug', element: (async () => (await import('./views/connections/index.js')).GitopsViewConnections)() },
|
||||||
{ name: 'Projects', element: (async () => (await import('./views/projects/index.js')).GitopsViewProjects)() },
|
{ name: 'Projects', iconName: 'lucide:folderGit2', element: (async () => (await import('./views/projects/index.js')).GitopsViewProjects)() },
|
||||||
{ name: 'Groups', element: (async () => (await import('./views/groups/index.js')).GitopsViewGroups)() },
|
{ name: 'Groups', iconName: 'lucide:users', element: (async () => (await import('./views/groups/index.js')).GitopsViewGroups)() },
|
||||||
{ name: 'Secrets', element: (async () => (await import('./views/secrets/index.js')).GitopsViewSecrets)() },
|
{ name: 'Secrets', iconName: 'lucide:key', element: (async () => (await import('./views/secrets/index.js')).GitopsViewSecrets)() },
|
||||||
{ name: 'Pipelines', element: (async () => (await import('./views/pipelines/index.js')).GitopsViewPipelines)() },
|
{ name: 'Pipelines', iconName: 'lucide:play', element: (async () => (await import('./views/pipelines/index.js')).GitopsViewPipelines)() },
|
||||||
{ name: 'Build Log', element: (async () => (await import('./views/buildlog/index.js')).GitopsViewBuildlog)() },
|
{ 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; element: any }> = [];
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -53,7 +67,11 @@ export class GitopsDashboard extends DeesElement {
|
|||||||
this.loginState = loginState;
|
this.loginState = loginState;
|
||||||
if (loginState.isLoggedIn) {
|
if (loginState.isLoggedIn) {
|
||||||
appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
|
appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
|
||||||
|
this.connectWebSocket();
|
||||||
|
} else {
|
||||||
|
this.disconnectWebSocket();
|
||||||
}
|
}
|
||||||
|
this.manageAutoRefreshTimer();
|
||||||
});
|
});
|
||||||
this.rxSubscriptions.push(loginSubscription);
|
this.rxSubscriptions.push(loginSubscription);
|
||||||
|
|
||||||
@@ -62,6 +80,7 @@ export class GitopsDashboard extends DeesElement {
|
|||||||
.subscribe((uiState) => {
|
.subscribe((uiState) => {
|
||||||
this.uiState = uiState;
|
this.uiState = uiState;
|
||||||
this.syncAppdashView(uiState.activeView);
|
this.syncAppdashView(uiState.activeView);
|
||||||
|
this.manageAutoRefreshTimer();
|
||||||
});
|
});
|
||||||
this.rxSubscriptions.push(uiSubscription);
|
this.rxSubscriptions.push(uiSubscription);
|
||||||
}
|
}
|
||||||
@@ -78,6 +97,36 @@ export class GitopsDashboard extends DeesElement {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
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 +141,15 @@ export class GitopsDashboard extends DeesElement {
|
|||||||
</dees-simple-appdash>
|
</dees-simple-appdash>
|
||||||
</dees-simple-login>
|
</dees-simple-login>
|
||||||
</div>
|
</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>
|
||||||
|
` : ''}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +158,7 @@ export class GitopsDashboard extends DeesElement {
|
|||||||
this.resolvedViewTabs = await Promise.all(
|
this.resolvedViewTabs = await Promise.all(
|
||||||
this.viewTabs.map(async (tab) => ({
|
this.viewTabs.map(async (tab) => ({
|
||||||
name: tab.name,
|
name: tab.name,
|
||||||
|
iconName: tab.iconName,
|
||||||
element: await tab.element,
|
element: await tab.element,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -117,6 +176,7 @@ export class GitopsDashboard extends DeesElement {
|
|||||||
if (appDash) {
|
if (appDash) {
|
||||||
appDash.addEventListener('view-select', (e: CustomEvent) => {
|
appDash.addEventListener('view-select', (e: CustomEvent) => {
|
||||||
const viewName = e.detail.view.name.toLowerCase();
|
const viewName = e.detail.view.name.toLowerCase();
|
||||||
|
if (this.uiState.activeView === viewName) return;
|
||||||
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: viewName });
|
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: viewName });
|
||||||
});
|
});
|
||||||
appDash.addEventListener('logout', async () => {
|
appDash.addEventListener('logout', async () => {
|
||||||
@@ -159,6 +219,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) {
|
private async login(username: string, password: string) {
|
||||||
const domtools = await this.domtoolsPromise;
|
const domtools = await this.domtoolsPromise;
|
||||||
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
|
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
|
||||||
@@ -194,6 +341,7 @@ export class GitopsDashboard extends DeesElement {
|
|||||||
if (!appDash || this.resolvedViewTabs.length === 0) return;
|
if (!appDash || this.resolvedViewTabs.length === 0) return;
|
||||||
const targetTab = this.resolvedViewTabs.find((t) => t.name.toLowerCase() === viewName);
|
const targetTab = this.resolvedViewTabs.find((t) => t.name.toLowerCase() === viewName);
|
||||||
if (!targetTab) return;
|
if (!targetTab) return;
|
||||||
|
if (appDash.selectedView === targetTab) return;
|
||||||
appDash.loadView(targetTab);
|
appDash.loadView(targetTab);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ import './views/groups/index.js';
|
|||||||
import './views/secrets/index.js';
|
import './views/secrets/index.js';
|
||||||
import './views/pipelines/index.js';
|
import './views/pipelines/index.js';
|
||||||
import './views/buildlog/index.js';
|
import './views/buildlog/index.js';
|
||||||
|
import './views/actions/index.js';
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { css } from '@design.estate/dees-element';
|
|||||||
export const viewHostCss = css`
|
export const viewHostCss = css`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
margin: auto;
|
||||||
height: 100%;
|
max-width: 1280px;
|
||||||
padding: 24px;
|
padding: 16px 16px;
|
||||||
box-sizing: border-box;
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.view-title {
|
.view-title {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
|
|||||||
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()
|
@state()
|
||||||
accessor selectedJobId: string = '';
|
accessor selectedJobId: string = '';
|
||||||
|
|
||||||
|
private _autoRefreshHandler: () => void;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
const connSub = appstate.connectionsStatePart
|
const connSub = appstate.connectionsStatePart
|
||||||
@@ -49,6 +51,18 @@ export class GitopsViewBuildlog extends DeesElement {
|
|||||||
.select((s) => s)
|
.select((s) => s)
|
||||||
.subscribe((s) => { this.dataState = s; });
|
.subscribe((s) => { this.dataState = s; });
|
||||||
this.rxSubscriptions.push(dataSub);
|
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 = [
|
public static styles = [
|
||||||
|
|||||||
@@ -19,12 +19,26 @@ export class GitopsViewConnections extends DeesElement {
|
|||||||
activeConnectionId: null,
|
activeConnectionId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private _autoRefreshHandler: () => void;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
const sub = appstate.connectionsStatePart
|
const sub = appstate.connectionsStatePart
|
||||||
.select((s) => s)
|
.select((s) => s)
|
||||||
.subscribe((s) => { this.connectionsState = s; });
|
.subscribe((s) => { this.connectionsState = s; });
|
||||||
this.rxSubscriptions.push(sub);
|
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 = [
|
public static styles = [
|
||||||
@@ -48,25 +62,60 @@ export class GitopsViewConnections extends DeesElement {
|
|||||||
Name: item.name,
|
Name: item.name,
|
||||||
Type: item.providerType,
|
Type: item.providerType,
|
||||||
URL: item.baseUrl,
|
URL: item.baseUrl,
|
||||||
|
'Group Filter': item.groupFilter || '-',
|
||||||
Status: item.status,
|
Status: item.status,
|
||||||
Created: new Date(item.createdAt).toLocaleDateString(),
|
Created: new Date(item.createdAt).toLocaleDateString(),
|
||||||
})}
|
})}
|
||||||
.dataActions=${[
|
.dataActions=${[
|
||||||
|
{
|
||||||
|
name: 'Edit',
|
||||||
|
iconName: 'lucide:edit',
|
||||||
|
type: ['inRow', 'contextmenu'],
|
||||||
|
actionFunc: async ({ item }: any) => { await this.editConnection(item); },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Test',
|
name: 'Test',
|
||||||
iconName: 'lucide:plug',
|
iconName: 'lucide:plug',
|
||||||
action: async (item: any) => {
|
type: ['inRow', 'contextmenu'],
|
||||||
|
actionFunc: async ({ item }: any) => {
|
||||||
await appstate.connectionsStatePart.dispatchAction(
|
await appstate.connectionsStatePart.dispatchAction(
|
||||||
appstate.testConnectionAction,
|
appstate.testConnectionAction,
|
||||||
{ connectionId: item.id },
|
{ 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',
|
name: 'Delete',
|
||||||
iconName: 'lucide:trash2',
|
iconName: 'lucide:trash2',
|
||||||
action: async (item: any) => {
|
type: ['inRow', 'contextmenu'],
|
||||||
const confirmed = await plugins.deesCatalog.DeesModal.createAndShow({
|
actionFunc: async ({ item }: any) => {
|
||||||
|
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||||
heading: 'Delete Connection',
|
heading: 'Delete Connection',
|
||||||
content: html`<p style="color: #fff;">Are you sure you want to delete connection "${item.name}"?</p>`,
|
content: html`<p style="color: #fff;">Are you sure you want to delete connection "${item.name}"?</p>`,
|
||||||
menuOptions: [
|
menuOptions: [
|
||||||
@@ -98,6 +147,55 @@ export class GitopsViewConnections extends DeesElement {
|
|||||||
await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
|
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() {
|
private async addConnection() {
|
||||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||||
heading: 'Add Connection',
|
heading: 'Add Connection',
|
||||||
@@ -125,6 +223,9 @@ export class GitopsViewConnections extends DeesElement {
|
|||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<dees-input-text .label=${'API Token'} .key=${'token'} type="password"></dees-input-text>
|
<dees-input-text .label=${'API Token'} .key=${'token'} type="password"></dees-input-text>
|
||||||
</div>
|
</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: [
|
menuOptions: [
|
||||||
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||||
@@ -147,6 +248,7 @@ export class GitopsViewConnections extends DeesElement {
|
|||||||
providerType: data.providerType,
|
providerType: data.providerType,
|
||||||
baseUrl: data.baseUrl,
|
baseUrl: data.baseUrl,
|
||||||
token: data.token,
|
token: data.token,
|
||||||
|
groupFilter: data.groupFilter || undefined,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
modal.destroy();
|
modal.destroy();
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export class GitopsViewGroups extends DeesElement {
|
|||||||
@state()
|
@state()
|
||||||
accessor selectedConnectionId: string = '';
|
accessor selectedConnectionId: string = '';
|
||||||
|
|
||||||
|
private _autoRefreshHandler: () => void;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
const connSub = appstate.connectionsStatePart
|
const connSub = appstate.connectionsStatePart
|
||||||
@@ -43,6 +45,18 @@ export class GitopsViewGroups extends DeesElement {
|
|||||||
.select((s) => s)
|
.select((s) => s)
|
||||||
.subscribe((s) => { this.dataState = s; });
|
.subscribe((s) => { this.dataState = s; });
|
||||||
this.rxSubscriptions.push(dataSub);
|
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 = [
|
public static styles = [
|
||||||
@@ -85,8 +99,16 @@ export class GitopsViewGroups extends DeesElement {
|
|||||||
{
|
{
|
||||||
name: 'View Secrets',
|
name: 'View Secrets',
|
||||||
iconName: 'lucide:key',
|
iconName: 'lucide:key',
|
||||||
action: async (item: any) => {
|
type: ['inRow', 'contextmenu'],
|
||||||
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'secrets' });
|
actionFunc: async ({ item }: any) => {
|
||||||
|
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, {
|
||||||
|
view: 'secrets',
|
||||||
|
navigationContext: {
|
||||||
|
connectionId: this.selectedConnectionId,
|
||||||
|
scope: 'group',
|
||||||
|
scopeId: item.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
cssManager,
|
cssManager,
|
||||||
type TemplateResult,
|
type TemplateResult,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
|
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
@customElement('gitops-view-overview')
|
@customElement('gitops-view-overview')
|
||||||
export class GitopsViewOverview extends DeesElement {
|
export class GitopsViewOverview extends DeesElement {
|
||||||
@@ -29,6 +30,8 @@ export class GitopsViewOverview extends DeesElement {
|
|||||||
currentJobLog: '',
|
currentJobLog: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private _autoRefreshHandler: () => void;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
const connSub = appstate.connectionsStatePart
|
const connSub = appstate.connectionsStatePart
|
||||||
@@ -40,38 +43,23 @@ export class GitopsViewOverview extends DeesElement {
|
|||||||
.select((s) => s)
|
.select((s) => s)
|
||||||
.subscribe((s) => { this.dataState = s; });
|
.subscribe((s) => { this.dataState = s; });
|
||||||
this.rxSubscriptions.push(dataSub);
|
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 = [
|
public static styles = [
|
||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
viewHostCss,
|
viewHostCss,
|
||||||
css`
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
.stat-card {
|
|
||||||
background: #1a1a2e;
|
|
||||||
border: 1px solid #333;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.stat-value {
|
|
||||||
font-size: 36px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #00acff;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.stat-label {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #999;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public render(): TemplateResult {
|
public render(): TemplateResult {
|
||||||
@@ -81,31 +69,18 @@ export class GitopsViewOverview extends DeesElement {
|
|||||||
const pipelineCount = this.dataState.pipelines.length;
|
const pipelineCount = this.dataState.pipelines.length;
|
||||||
const failedPipelines = this.dataState.pipelines.filter((p) => p.status === 'failed').length;
|
const failedPipelines = this.dataState.pipelines.filter((p) => p.status === 'failed').length;
|
||||||
|
|
||||||
|
const tiles: IStatsTile[] = [
|
||||||
|
{ id: 'connections', title: 'Connections', value: connCount, type: 'number', icon: 'lucide:plug', color: '#00acff' },
|
||||||
|
{ id: 'projects', title: 'Projects', value: projCount, type: 'number', icon: 'lucide:folderGit2', color: '#00acff' },
|
||||||
|
{ id: 'groups', title: 'Groups', value: groupCount, type: 'number', icon: 'lucide:users', color: '#00acff' },
|
||||||
|
{ id: 'pipelines', title: 'Pipelines', value: pipelineCount, type: 'number', icon: 'lucide:play', color: '#00acff' },
|
||||||
|
{ id: 'failed', title: 'Failed Pipelines', value: failedPipelines, type: 'number', icon: 'lucide:triangleAlert', color: failedPipelines > 0 ? '#ff4444' : '#00ff88' },
|
||||||
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="view-title">Overview</div>
|
<div class="view-title">Overview</div>
|
||||||
<div class="view-description">GitOps dashboard - manage your Gitea and GitLab instances</div>
|
<div class="view-description">GitOps dashboard - manage your Gitea and GitLab instances</div>
|
||||||
<div class="stats-grid">
|
<dees-statsgrid .tiles=${tiles}></dees-statsgrid>
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value">${connCount}</div>
|
|
||||||
<div class="stat-label">Connections</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value">${projCount}</div>
|
|
||||||
<div class="stat-label">Projects</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value">${groupCount}</div>
|
|
||||||
<div class="stat-label">Groups</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value">${pipelineCount}</div>
|
|
||||||
<div class="stat-label">Pipelines</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value" style="color: ${failedPipelines > 0 ? '#ff4444' : '#00ff88'}">${failedPipelines}</div>
|
|
||||||
<div class="stat-label">Failed Pipelines</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export class GitopsViewPipelines extends DeesElement {
|
|||||||
@state()
|
@state()
|
||||||
accessor selectedProjectId: string = '';
|
accessor selectedProjectId: string = '';
|
||||||
|
|
||||||
|
private _autoRefreshHandler: () => void;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
const connSub = appstate.connectionsStatePart
|
const connSub = appstate.connectionsStatePart
|
||||||
@@ -46,6 +48,18 @@ export class GitopsViewPipelines extends DeesElement {
|
|||||||
.select((s) => s)
|
.select((s) => s)
|
||||||
.subscribe((s) => { this.dataState = s; });
|
.subscribe((s) => { this.dataState = s; });
|
||||||
this.rxSubscriptions.push(dataSub);
|
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.loadPipelines();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static styles = [
|
public static styles = [
|
||||||
@@ -119,12 +133,14 @@ export class GitopsViewPipelines extends DeesElement {
|
|||||||
{
|
{
|
||||||
name: 'View Jobs',
|
name: 'View Jobs',
|
||||||
iconName: 'lucide:list',
|
iconName: 'lucide:list',
|
||||||
action: async (item: any) => { await this.viewJobs(item); },
|
type: ['inRow', 'contextmenu'],
|
||||||
|
actionFunc: async ({ item }: any) => { await this.viewJobs(item); },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Retry',
|
name: 'Retry',
|
||||||
iconName: 'lucide:refresh-cw',
|
iconName: 'lucide:refreshCw',
|
||||||
action: async (item: any) => {
|
type: ['inRow', 'contextmenu'],
|
||||||
|
actionFunc: async ({ item }: any) => {
|
||||||
await appstate.dataStatePart.dispatchAction(appstate.retryPipelineAction, {
|
await appstate.dataStatePart.dispatchAction(appstate.retryPipelineAction, {
|
||||||
connectionId: this.selectedConnectionId,
|
connectionId: this.selectedConnectionId,
|
||||||
projectId: this.selectedProjectId,
|
projectId: this.selectedProjectId,
|
||||||
@@ -134,8 +150,9 @@ export class GitopsViewPipelines extends DeesElement {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Cancel',
|
name: 'Cancel',
|
||||||
iconName: 'lucide:x-circle',
|
iconName: 'lucide:xCircle',
|
||||||
action: async (item: any) => {
|
type: ['inRow', 'contextmenu'],
|
||||||
|
actionFunc: async ({ item }: any) => {
|
||||||
await appstate.dataStatePart.dispatchAction(appstate.cancelPipelineAction, {
|
await appstate.dataStatePart.dispatchAction(appstate.cancelPipelineAction, {
|
||||||
connectionId: this.selectedConnectionId,
|
connectionId: this.selectedConnectionId,
|
||||||
projectId: this.selectedProjectId,
|
projectId: this.selectedProjectId,
|
||||||
@@ -150,6 +167,18 @@ export class GitopsViewPipelines extends DeesElement {
|
|||||||
|
|
||||||
async firstUpdated() {
|
async firstUpdated() {
|
||||||
await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
|
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;
|
||||||
|
appstate.uiStatePart.dispatchAction(appstate.clearNavigationContextAction, null);
|
||||||
|
await this.loadProjects();
|
||||||
|
await this.loadPipelines();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const conns = appstate.connectionsStatePart.getState().connections;
|
const conns = appstate.connectionsStatePart.getState().connections;
|
||||||
if (conns.length > 0 && !this.selectedConnectionId) {
|
if (conns.length > 0 && !this.selectedConnectionId) {
|
||||||
this.selectedConnectionId = conns[0].id;
|
this.selectedConnectionId = conns[0].id;
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export class GitopsViewProjects extends DeesElement {
|
|||||||
@state()
|
@state()
|
||||||
accessor selectedConnectionId: string = '';
|
accessor selectedConnectionId: string = '';
|
||||||
|
|
||||||
|
private _autoRefreshHandler: () => void;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
const connSub = appstate.connectionsStatePart
|
const connSub = appstate.connectionsStatePart
|
||||||
@@ -43,6 +45,18 @@ export class GitopsViewProjects extends DeesElement {
|
|||||||
.select((s) => s)
|
.select((s) => s)
|
||||||
.subscribe((s) => { this.dataState = s; });
|
.subscribe((s) => { this.dataState = s; });
|
||||||
this.rxSubscriptions.push(dataSub);
|
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 = [
|
public static styles = [
|
||||||
@@ -86,15 +100,30 @@ export class GitopsViewProjects extends DeesElement {
|
|||||||
{
|
{
|
||||||
name: 'View Secrets',
|
name: 'View Secrets',
|
||||||
iconName: 'lucide:key',
|
iconName: 'lucide:key',
|
||||||
action: async (item: any) => {
|
type: ['inRow', 'contextmenu'],
|
||||||
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'secrets' });
|
actionFunc: async ({ item }: any) => {
|
||||||
|
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, {
|
||||||
|
view: 'secrets',
|
||||||
|
navigationContext: {
|
||||||
|
connectionId: this.selectedConnectionId,
|
||||||
|
scope: 'project',
|
||||||
|
scopeId: item.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'View Pipelines',
|
name: 'View Pipelines',
|
||||||
iconName: 'lucide:play',
|
iconName: 'lucide:play',
|
||||||
action: async (item: any) => {
|
type: ['inRow', 'contextmenu'],
|
||||||
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'pipelines' });
|
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 = '';
|
accessor selectedConnectionId: string = '';
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor selectedScope: 'project' | 'group' = 'project';
|
accessor selectedScope: 'all' | 'project' | 'group' = 'all';
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor selectedScopeId: string = '';
|
accessor selectedScopeId: string = '__all__';
|
||||||
|
|
||||||
|
private _autoRefreshHandler: () => void;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -49,6 +51,18 @@ export class GitopsViewSecrets extends DeesElement {
|
|||||||
.select((s) => s)
|
.select((s) => s)
|
||||||
.subscribe((s) => { this.dataState = s; });
|
.subscribe((s) => { this.dataState = s; });
|
||||||
this.rxSubscriptions.push(dataSub);
|
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 = [
|
public static styles = [
|
||||||
@@ -56,6 +70,19 @@ export class GitopsViewSecrets extends DeesElement {
|
|||||||
viewHostCss,
|
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 {
|
public render(): TemplateResult {
|
||||||
const connectionOptions = this.connectionsState.connections.map((c) => ({
|
const connectionOptions = this.connectionsState.connections.map((c) => ({
|
||||||
option: `${c.name} (${c.providerType})`,
|
option: `${c.name} (${c.providerType})`,
|
||||||
@@ -63,13 +90,23 @@ export class GitopsViewSecrets extends DeesElement {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const scopeOptions = [
|
const scopeOptions = [
|
||||||
|
{ option: 'All Scopes', key: 'all' },
|
||||||
{ option: 'Project', key: 'project' },
|
{ option: 'Project', key: 'project' },
|
||||||
{ option: 'Group', key: 'group' },
|
{ option: 'Group', key: 'group' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const entityOptions = this.selectedScope === 'project'
|
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 }))
|
? 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 entityOptions = [
|
||||||
|
{ option: 'All', key: '__all__' },
|
||||||
|
...entities,
|
||||||
|
];
|
||||||
|
|
||||||
|
const isAllSelected = this.selectedScope === 'all' || this.selectedScopeId === '__all__';
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="view-title">Secrets</div>
|
<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=${connectionOptions.find((o) => o.key === this.selectedConnectionId) || connectionOptions[0]}
|
||||||
@selectedOption=${(e: CustomEvent) => {
|
@selectedOption=${(e: CustomEvent) => {
|
||||||
this.selectedConnectionId = e.detail.key;
|
this.selectedConnectionId = e.detail.key;
|
||||||
|
this.selectedScopeId = '__all__';
|
||||||
this.loadEntities();
|
this.loadEntities();
|
||||||
|
this.loadSecrets();
|
||||||
}}
|
}}
|
||||||
></dees-input-dropdown>
|
></dees-input-dropdown>
|
||||||
<dees-input-dropdown
|
<dees-input-dropdown
|
||||||
@@ -89,28 +128,35 @@ export class GitopsViewSecrets extends DeesElement {
|
|||||||
.options=${scopeOptions}
|
.options=${scopeOptions}
|
||||||
.selectedOption=${scopeOptions.find((o) => o.key === this.selectedScope)}
|
.selectedOption=${scopeOptions.find((o) => o.key === this.selectedScope)}
|
||||||
@selectedOption=${(e: CustomEvent) => {
|
@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();
|
this.loadEntities();
|
||||||
|
this.loadSecrets();
|
||||||
}}
|
}}
|
||||||
></dees-input-dropdown>
|
></dees-input-dropdown>
|
||||||
|
${this.selectedScope !== 'all' ? html`
|
||||||
<dees-input-dropdown
|
<dees-input-dropdown
|
||||||
.label=${this.selectedScope === 'project' ? 'Project' : 'Group'}
|
.label=${this.selectedScope === 'project' ? 'Project' : 'Group'}
|
||||||
.options=${entityOptions}
|
.options=${entityOptions}
|
||||||
.selectedOption=${entityOptions.find((o) => o.key === this.selectedScopeId) || entityOptions[0]}
|
.selectedOption=${entityOptions.find((o) => o.key === this.selectedScopeId) || entityOptions[0]}
|
||||||
@selectedOption=${(e: CustomEvent) => {
|
@selectedOption=${(e: CustomEvent) => {
|
||||||
this.selectedScopeId = e.detail.key;
|
this.selectedScopeId = e.detail.key;
|
||||||
this.loadSecrets();
|
|
||||||
}}
|
}}
|
||||||
></dees-input-dropdown>
|
></dees-input-dropdown>
|
||||||
<dees-button @click=${() => this.addSecret()}>Add Secret</dees-button>
|
` : ''}
|
||||||
|
<dees-button
|
||||||
|
.disabled=${isAllSelected}
|
||||||
|
@click=${() => this.addSecret()}
|
||||||
|
>Add Secret</dees-button>
|
||||||
<dees-button @click=${() => this.loadSecrets()}>Refresh</dees-button>
|
<dees-button @click=${() => this.loadSecrets()}>Refresh</dees-button>
|
||||||
</div>
|
</div>
|
||||||
<dees-table
|
<dees-table
|
||||||
.heading1=${'Secrets'}
|
.heading1=${'Secrets'}
|
||||||
.heading2=${'CI/CD variables for the selected entity'}
|
.heading2=${'CI/CD variables for the selected entity'}
|
||||||
.data=${this.dataState.secrets}
|
.data=${this.filteredSecrets}
|
||||||
.displayFunction=${(item: any) => ({
|
.displayFunction=${(item: any) => ({
|
||||||
Key: item.key,
|
Key: item.key,
|
||||||
|
Scope: item.scopeName || item.scopeId,
|
||||||
Value: item.masked ? '******' : item.value,
|
Value: item.masked ? '******' : item.value,
|
||||||
Protected: item.protected ? 'Yes' : 'No',
|
Protected: item.protected ? 'Yes' : 'No',
|
||||||
Environment: item.environment || '*',
|
Environment: item.environment || '*',
|
||||||
@@ -119,18 +165,33 @@ export class GitopsViewSecrets extends DeesElement {
|
|||||||
{
|
{
|
||||||
name: 'Edit',
|
name: 'Edit',
|
||||||
iconName: 'lucide: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',
|
name: 'Delete',
|
||||||
iconName: 'lucide:trash2',
|
iconName: 'lucide:trash2',
|
||||||
action: async (item: any) => {
|
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, {
|
await appstate.dataStatePart.dispatchAction(appstate.deleteSecretAction, {
|
||||||
connectionId: this.selectedConnectionId,
|
connectionId: this.selectedConnectionId,
|
||||||
scope: this.selectedScope,
|
scope: item.scope,
|
||||||
scopeId: this.selectedScopeId,
|
scopeId: item.scopeId,
|
||||||
key: item.key,
|
key: item.key,
|
||||||
});
|
});
|
||||||
|
modal.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -140,15 +201,30 @@ export class GitopsViewSecrets extends DeesElement {
|
|||||||
|
|
||||||
async firstUpdated() {
|
async firstUpdated() {
|
||||||
await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
|
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;
|
const conns = appstate.connectionsStatePart.getState().connections;
|
||||||
if (conns.length > 0 && !this.selectedConnectionId) {
|
if (conns.length > 0 && !this.selectedConnectionId) {
|
||||||
this.selectedConnectionId = conns[0].id;
|
this.selectedConnectionId = conns[0].id;
|
||||||
await this.loadEntities();
|
await this.loadEntities();
|
||||||
|
await this.loadSecrets();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadEntities() {
|
private async loadEntities() {
|
||||||
if (!this.selectedConnectionId) return;
|
if (!this.selectedConnectionId) return;
|
||||||
|
if (this.selectedScope === 'all') return;
|
||||||
if (this.selectedScope === 'project') {
|
if (this.selectedScope === 'project') {
|
||||||
await appstate.dataStatePart.dispatchAction(appstate.fetchProjectsAction, {
|
await appstate.dataStatePart.dispatchAction(appstate.fetchProjectsAction, {
|
||||||
connectionId: this.selectedConnectionId,
|
connectionId: this.selectedConnectionId,
|
||||||
@@ -161,15 +237,15 @@ export class GitopsViewSecrets extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async loadSecrets() {
|
private async loadSecrets() {
|
||||||
if (!this.selectedConnectionId || !this.selectedScopeId) return;
|
if (!this.selectedConnectionId) return;
|
||||||
await appstate.dataStatePart.dispatchAction(appstate.fetchSecretsAction, {
|
// Always fetch both scopes — client-side filtering handles the rest
|
||||||
|
await appstate.dataStatePart.dispatchAction(appstate.fetchAllSecretsAction, {
|
||||||
connectionId: this.selectedConnectionId,
|
connectionId: this.selectedConnectionId,
|
||||||
scope: this.selectedScope,
|
|
||||||
scopeId: this.selectedScopeId,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async addSecret() {
|
private async addSecret() {
|
||||||
|
if (this.selectedScope === 'all' || this.selectedScopeId === '__all__') return;
|
||||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||||
heading: 'Add Secret',
|
heading: 'Add Secret',
|
||||||
content: html`
|
content: html`
|
||||||
@@ -220,8 +296,8 @@ export class GitopsViewSecrets extends DeesElement {
|
|||||||
const input = modal.shadowRoot.querySelector('dees-input-text');
|
const input = modal.shadowRoot.querySelector('dees-input-text');
|
||||||
await appstate.dataStatePart.dispatchAction(appstate.updateSecretAction, {
|
await appstate.dataStatePart.dispatchAction(appstate.updateSecretAction, {
|
||||||
connectionId: this.selectedConnectionId,
|
connectionId: this.selectedConnectionId,
|
||||||
scope: this.selectedScope,
|
scope: item.scope,
|
||||||
scopeId: this.selectedScopeId,
|
scopeId: item.scopeId,
|
||||||
key: item.key,
|
key: item.key,
|
||||||
value: input?.value || '',
|
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 deesElement from '@design.estate/dees-element';
|
||||||
import * as deesCatalog from '@design.estate/dees-catalog';
|
import * as deesCatalog from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
|
// @api.global scope
|
||||||
|
import * as typedsocket from '@api.global/typedsocket';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
deesElement,
|
deesElement,
|
||||||
deesCatalog,
|
deesCatalog,
|
||||||
|
typedsocket,
|
||||||
};
|
};
|
||||||
|
|
||||||
// domtools gives us TypedRequest, smartstate, smartrouter, and other utilities
|
// domtools gives us TypedRequest, smartstate, smartrouter, and other utilities
|
||||||
|
|||||||
Reference in New Issue
Block a user