18 Commits
v2.3.0 ... main

Author SHA1 Message Date
630b2502f3 docs(readme): add comprehensive project readme 2026-02-24 22:57:26 +00:00
e3f67d12a3 fix(core): fix secrets scan upserts, connection health checks, and frontend improvements
- Add upsert pattern to SecretsScanService to prevent duplicate key errors on repeated scans
- Auto-test connection health on startup so status reflects reality
- Fix Actions view to read identity from appstate instead of broken localStorage hack
- Fetch both project and group secrets in parallel, add "All Scopes" filter to Secrets view
- Enable noCache on UtilityWebsiteServer to prevent stale browser cache
2026-02-24 22:50:26 +00:00
43131fa53c update 2026-02-24 22:17:55 +00:00
481b72b8fb v2.7.1 2026-02-24 21:10:05 +00:00
c9786591e3 fix(repo): update file metadata (mode/permissions) without content changes 2026-02-24 21:10:05 +00:00
c5834f3cd1 v2.7.0 2026-02-24 21:09:17 +00:00
179bb9223e feat(secrets): add ability to fetch and view all secrets across projects and groups, include scopeName, and improve frontend merging/filtering 2026-02-24 21:09:17 +00:00
ee3f01993f v2.6.2 2026-02-24 20:19:34 +00:00
15e845d5f8 fix(meta): update file metadata only (no source changes) 2026-02-24 20:19:34 +00:00
0815e4c8ae v2.6.1 2026-02-24 20:16:02 +00:00
7e6b774982 fix(package.json): apply metadata-only update (no functional changes) 2026-02-24 20:16:02 +00:00
768bd1ef53 v2.6.0 2026-02-24 19:41:52 +00:00
71176a1856 feat(webhook): add webhook endpoint and client push notifications, auto-refresh UI, and gitea id mapping fixes 2026-02-24 19:41:52 +00:00
b576056fa1 v2.5.0 2026-02-24 18:41:26 +00:00
57935d6388 feat(gitea-provider): auto-paginate Gitea repository and organization listing; respect explicit page option and default perPage to 50 2026-02-24 18:41:26 +00:00
5ca8c1fb60 v2.4.0 2026-02-24 18:18:40 +00:00
92b0ec179f feat(opsserver): serve embedded frontend bundle from committed ts_bundled instead of using external dist_serve directory 2026-02-24 18:18:40 +00:00
06f447459e feat(security): integrate @push.rocks/smartsecret for keychain-based token storage
Connection tokens are now stored in OS keychain (or encrypted file fallback) instead of plaintext JSON. Existing plaintext tokens auto-migrate on first load.
2026-02-24 16:37:13 +00:00
46 changed files with 31278 additions and 166614 deletions

4
.gitignore vendored
View File

@@ -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/

View File

@@ -1,5 +1,60 @@
# Changelog # Changelog
## 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) ## 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 and cache subsystem; integrate storage into ConnectionManager and GitopsApp, migrate legacy connections, and add tests

View File

@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/gitops", "name": "@serve.zone/gitops",
"version": "2.3.0", "version": "2.7.1",
"exports": "./mod.ts", "exports": "./mod.ts",
"nodeModulesDir": "auto", "nodeModulesDir": "auto",
"tasks": { "tasks": {
@@ -13,13 +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.0.3",
"@apiclient.xyz/gitlab": "npm:@apiclient.xyz/gitlab@^2.0.3", "@apiclient.xyz/gitlab": "npm:@apiclient.xyz/gitlab@^2.0.3",
"@push.rocks/smartmongo": "npm:@push.rocks/smartmongo@^5.1.0", "@push.rocks/smartmongo": "npm:@push.rocks/smartmongo@^5.1.0",
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^7.0.15" "@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

File diff suppressed because one or more lines are too long

33
dist_serve/index.html Normal file
View 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>

View File

@@ -3,11 +3,11 @@
"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": ["./html/index.html"] "includeFiles": [{"from": "./html/index.html", "to": "index.html"}]
} }
] ]
}, },
@@ -15,9 +15,11 @@
"bundles": [ "bundles": [
{ {
"from": "./ts_web/index.ts", "from": "./ts_web/index.ts",
"to": "./dist_serve/bundle.js", "to": "./ts_bundled/bundle.ts",
"outputMode": "base64ts",
"watchPatterns": ["./ts_web/**/*"], "watchPatterns": ["./ts_web/**/*"],
"triggerReload": true "triggerReload": true,
"includeFiles": [{"from": "./html/index.html", "to": "index.html"}]
} }
], ],
"watchers": [ "watchers": [

View File

@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/gitops", "name": "@serve.zone/gitops",
"version": "2.3.0", "version": "2.7.1",
"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",
@@ -14,11 +14,12 @@
"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",
"@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": "^3.1.0" "@git.zone/tswatch": "^3.2.0"
} }
} }

244
readme.md Normal file
View 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
View File

@@ -0,0 +1,3 @@
# GitOps TODOs
- [ ] Webhook HMAC signature verification (X-Gitea-Signature / X-Gitlab-Token) — currently accepts all POSTs

View File

@@ -3,6 +3,7 @@ import { BaseProvider, GiteaProvider, GitLabProvider } from '../ts/providers/ind
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 { 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');
@@ -20,7 +21,8 @@ Deno.test('GitLabProvider instantiates correctly', () => {
Deno.test('ConnectionManager instantiates correctly', () => { Deno.test('ConnectionManager instantiates correctly', () => {
const storage = new StorageManager({ backend: 'memory' }); const storage = new StorageManager({ backend: 'memory' });
const manager = new ConnectionManager(storage); const secret = new smartsecret.SmartSecret({ service: 'gitops-test' });
const manager = new ConnectionManager(storage, secret);
assertExists(manager); assertExists(manager);
}); });
@@ -28,6 +30,7 @@ Deno.test('GitopsApp instantiates correctly', () => {
const app = new GitopsApp(); const app = new GitopsApp();
assertExists(app); assertExists(app);
assertExists(app.storageManager); assertExists(app.storageManager);
assertExists(app.smartSecret);
assertExists(app.connectionManager); assertExists(app.connectionManager);
assertExists(app.opsServer); assertExists(app.opsServer);
}); });

View File

@@ -1,5 +1,6 @@
import { assertEquals, assertExists } from 'https://deno.land/std@0.208.0/assert/mod.ts'; import { assertEquals, assertExists } from 'https://deno.land/std@0.208.0/assert/mod.ts';
import { StorageManager } from '../ts/storage/index.ts'; import { StorageManager } from '../ts/storage/index.ts';
import * as smartsecret from '@push.rocks/smartsecret';
Deno.test('StorageManager memory: set and get', async () => { Deno.test('StorageManager memory: set and get', async () => {
const sm = new StorageManager({ backend: 'memory' }); const sm = new StorageManager({ backend: 'memory' });
@@ -114,7 +115,8 @@ Deno.test('StorageManager filesystem: list keys', async () => {
Deno.test('ConnectionManager with StorageManager: create and load', async () => { Deno.test('ConnectionManager with StorageManager: create and load', async () => {
const { ConnectionManager } = await import('../ts/classes/connectionmanager.ts'); const { ConnectionManager } = await import('../ts/classes/connectionmanager.ts');
const sm = new StorageManager({ backend: 'memory' }); const sm = new StorageManager({ backend: 'memory' });
const cm = new ConnectionManager(sm); const secret = new smartsecret.SmartSecret({ service: 'gitops-test' });
const cm = new ConnectionManager(sm, secret);
await cm.init(); await cm.init();
// Create a connection // Create a connection
@@ -129,9 +131,13 @@ Deno.test('ConnectionManager with StorageManager: create and load', async () =>
assertEquals(stored.id, conn.id); assertEquals(stored.id, conn.id);
// Create a new ConnectionManager and verify it loads the connection // Create a new ConnectionManager and verify it loads the connection
const cm2 = new ConnectionManager(sm); const cm2 = new ConnectionManager(sm, secret);
await cm2.init(); await cm2.init();
const conns = cm2.getConnections(); const conns = cm2.getConnections();
assertEquals(conns.length, 1); assertEquals(conns.length, 1);
assertEquals(conns[0].id, conn.id); assertEquals(conns[0].id, conn.id);
// Wait for background health checks to avoid resource leaks
await cm.healthCheckDone;
await cm2.healthCheckDone;
}); });

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/gitops', name: '@serve.zone/gitops',
version: '2.3.0', version: '2.7.1',
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'
} }

266
ts/cache/classes.secrets.scan.service.ts vendored Normal file
View File

@@ -0,0 +1,266 @@
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) {
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;
}
}

View 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,
};
}
}

View File

@@ -1 +1,2 @@
export { CachedProject } from './classes.cached.project.ts'; export { CachedProject } from './classes.cached.project.ts';
export { CachedSecret } from './classes.cached.secret.ts';

2
ts/cache/index.ts vendored
View File

@@ -2,4 +2,6 @@ export { CacheDb } from './classes.cachedb.ts';
export type { ICacheDbOptions } from './classes.cachedb.ts'; export type { ICacheDbOptions } from './classes.cachedb.ts';
export { CachedDocument, TTL } from './classes.cached.document.ts'; export { CachedDocument, TTL } from './classes.cached.document.ts';
export { CacheCleaner } from './classes.cache.cleaner.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'; export * from './documents/index.ts';

View File

@@ -6,22 +6,47 @@ import type { StorageManager } from '../storage/index.ts';
const LEGACY_CONNECTIONS_FILE = './.nogit/connections.json'; const LEGACY_CONNECTIONS_FILE = './.nogit/connections.json';
const CONNECTIONS_PREFIX = '/connections/'; const CONNECTIONS_PREFIX = '/connections/';
const KEYCHAIN_PREFIX = 'keychain:';
/** /**
* Manages provider connections — persists each connection as an * Manages provider connections — persists each connection as an
* individual JSON file via StorageManager. * 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 storageManager: StorageManager;
private smartSecret: plugins.smartsecret.SmartSecret;
/** Resolves when background connection health checks complete */
public healthCheckDone: Promise<void> = Promise.resolve();
constructor(storageManager: StorageManager) { constructor(storageManager: StorageManager, smartSecret: plugins.smartsecret.SmartSecret) {
this.storageManager = storageManager; this.storageManager = storageManager;
this.smartSecret = smartSecret;
} }
async init(): Promise<void> { async init(): Promise<void> {
await this.migrateLegacyFile(); 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) {
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';
}
}
} }
/** /**
@@ -51,6 +76,18 @@ export class ConnectionManager {
for (const key of keys) { for (const key of keys) {
const conn = await this.storageManager.getJSON<interfaces.data.IProviderConnection>(key); const conn = await this.storageManager.getJSON<interfaces.data.IProviderConnection>(key);
if (conn) { if (conn) {
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); this.connections.push(conn);
} }
} }
@@ -61,11 +98,33 @@ export class ConnectionManager {
} }
} }
/**
* Migrates a plaintext token to keychain storage.
*/
private async migrateTokenToKeychain(
conn: interfaces.data.IProviderConnection,
): Promise<void> {
try {
await this.smartSecret.setSecret(conn.id, conn.token);
// Save sentinel to JSON file
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> { private async persistConnection(conn: interfaces.data.IProviderConnection): Promise<void> {
await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, conn); // 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> { private async removeConnection(id: string): Promise<void> {
await this.smartSecret.deleteSecret(id);
await this.storageManager.delete(`${CONNECTIONS_PREFIX}${id}.json`); await this.storageManager.delete(`${CONNECTIONS_PREFIX}${id}.json`);
} }

View File

@@ -1,8 +1,9 @@
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 { OpsServer } from '../opsserver/index.ts'; import { OpsServer } from '../opsserver/index.ts';
import { StorageManager } from '../storage/index.ts'; import { StorageManager } from '../storage/index.ts';
import { CacheDb, CacheCleaner, CachedProject } from '../cache/index.ts'; import { CacheDb, CacheCleaner, CachedProject, CachedSecret, SecretsScanService } from '../cache/index.ts';
import { resolvePaths } from '../paths.ts'; import { resolvePaths } from '../paths.ts';
/** /**
@@ -10,10 +11,13 @@ import { resolvePaths } from '../paths.ts';
*/ */
export class GitopsApp { export class GitopsApp {
public storageManager: StorageManager; public storageManager: StorageManager;
public smartSecret: plugins.smartsecret.SmartSecret;
public connectionManager: ConnectionManager; public connectionManager: ConnectionManager;
public opsServer: OpsServer; public opsServer: OpsServer;
public cacheDb: CacheDb; public cacheDb: CacheDb;
public cacheCleaner: CacheCleaner; public cacheCleaner: CacheCleaner;
public secretsScanService!: SecretsScanService;
private scanIntervalId: number | null = null;
constructor() { constructor() {
const paths = resolvePaths(); const paths = resolvePaths();
@@ -21,7 +25,8 @@ export class GitopsApp {
backend: 'filesystem', backend: 'filesystem',
fsPath: paths.defaultStoragePath, fsPath: paths.defaultStoragePath,
}); });
this.connectionManager = new ConnectionManager(this.storageManager); this.smartSecret = new plugins.smartsecret.SmartSecret({ service: 'gitops' });
this.connectionManager = new ConnectionManager(this.storageManager, this.smartSecret);
this.cacheDb = CacheDb.getInstance({ this.cacheDb = CacheDb.getInstance({
storagePath: paths.defaultTsmDbPath, storagePath: paths.defaultTsmDbPath,
@@ -29,6 +34,7 @@ export class GitopsApp {
}); });
this.cacheCleaner = new CacheCleaner(this.cacheDb); this.cacheCleaner = new CacheCleaner(this.cacheDb);
this.cacheCleaner.registerClass(CachedProject); this.cacheCleaner.registerClass(CachedProject);
this.cacheCleaner.registerClass(CachedSecret);
this.opsServer = new OpsServer(this); this.opsServer = new OpsServer(this);
} }
@@ -42,6 +48,20 @@ export class GitopsApp {
// Initialize connection manager (loads saved connections) // Initialize connection manager (loads saved connections)
await this.connectionManager.init(); await this.connectionManager.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 // Start CacheCleaner
this.cacheCleaner.start(); this.cacheCleaner.start();
@@ -53,6 +73,10 @@ 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.opsServer.stop(); await this.opsServer.stop();
this.cacheCleaner.stop(); this.cacheCleaner.stop();
await this.cacheDb.stop(); await this.cacheDb.stop();

View File

@@ -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,25 @@ 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;
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 +60,7 @@ 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);
logger.success('OpsServer TypedRequest handlers initialized'); logger.success('OpsServer TypedRequest handlers initialized');
} }

View 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,
};
},
),
);
}
}

View File

@@ -5,3 +5,5 @@ 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';

View File

@@ -12,18 +12,120 @@ export class SecretsHandler {
} }
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 +143,9 @@ 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(() => {});
return { secret }; return { secret };
}, },
), ),
@@ -58,6 +163,9 @@ 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(() => {});
return { secret }; return { secret };
}, },
), ),
@@ -77,6 +185,9 @@ 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(() => {});
return { ok: true }; return { ok: true };
}, },
), ),

View 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');
}
}

View File

@@ -28,3 +28,7 @@ export { giteaClient, gitlabClient };
import * as smartmongo from '@push.rocks/smartmongo'; import * as smartmongo from '@push.rocks/smartmongo';
import * as smartdata from '@push.rocks/smartdata'; import * as smartdata from '@push.rocks/smartdata';
export { smartmongo, smartdata }; export { smartmongo, smartdata };
// Secrets
import * as smartsecret from '@push.rocks/smartsecret';
export { smartsecret };

View File

@@ -18,13 +18,45 @@ 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); // If caller explicitly requests a specific page, respect it (no auto-pagination)
return repos.map((r) => this.mapProject(r)); if (opts?.page) {
const repos = await this.client.getRepos(opts);
return repos.map((r) => this.mapProject(r));
}
const allRepos: plugins.giteaClient.IGiteaRepository[] = [];
const perPage = opts?.perPage || 50;
let page = 1;
while (true) {
const repos = await this.client.getRepos({ ...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[]> {
const orgs = await this.client.getOrgs(opts); // If caller explicitly requests a specific page, respect it (no auto-pagination)
return orgs.map((o) => this.mapGroup(o)); if (opts?.page) {
const orgs = await this.client.getOrgs(opts);
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 ---
@@ -40,7 +72,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 +100,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 +149,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 +164,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 +175,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 +183,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: '*',
}; };

View File

@@ -149,6 +149,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 +158,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 || '*',
}; };

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

11
ts_bundled/bundle.ts Normal file

File diff suppressed because one or more lines are too long

View File

@@ -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;
} }

View 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;
};
}

View File

@@ -5,3 +5,5 @@ 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';

View File

@@ -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

View 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;
};
}

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/gitops', name: '@serve.zone/gitops',
version: '2.3.0', version: '2.7.1',
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'
} }

View File

@@ -304,6 +304,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 +348,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 +358,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 +385,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 +395,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 +424,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);
@@ -543,3 +581,9 @@ 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 };
},
);

View File

@@ -18,6 +18,7 @@ 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';
@customElement('gitops-dashboard') @customElement('gitops-dashboard')
export class GitopsDashboard extends DeesElement { export class GitopsDashboard extends DeesElement {
@@ -39,10 +40,19 @@ export class GitopsDashboard extends DeesElement {
{ name: 'Secrets', iconName: 'lucide:key', 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', iconName: 'lucide:play', 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', iconName: 'lucide:scrollText', 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)() },
]; ];
private resolvedViewTabs: Array<{ name: string; iconName: 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();
document.title = 'GitOps'; document.title = 'GitOps';
@@ -53,7 +63,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 +76,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 +93,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 +137,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>
` : ''}
`; `;
} }
@@ -160,6 +214,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;

View File

@@ -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';

View 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;
}
}
}

View File

@@ -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 = [

View File

@@ -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 = [

View File

@@ -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 = [

View File

@@ -30,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
@@ -41,6 +43,18 @@ 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 = [

View File

@@ -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 = [

View File

@@ -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 = [

View File

@@ -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.projects.map((p) => ({ option: p.fullPath || p.name, key: p.id })) ? this.dataState.groups.map((g) => ({ option: g.fullPath || g.name, key: g.id }))
: this.dataState.groups.map((g) => ({ option: g.fullPath || g.name, key: g.id })); : this.selectedScope === 'project'
? this.dataState.projects.map((p) => ({ option: p.fullPath || p.name, key: p.id }))
: [];
const entityOptions = [
{ option: 'All', key: '__all__' },
...entities,
];
const isAllSelected = this.selectedScope === 'all' || this.selectedScopeId === '__all__';
return html` 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();
}}
></dees-input-dropdown>
<dees-input-dropdown
.label=${this.selectedScope === 'project' ? 'Project' : 'Group'}
.options=${entityOptions}
.selectedOption=${entityOptions.find((o) => o.key === this.selectedScopeId) || entityOptions[0]}
@selectedOption=${(e: CustomEvent) => {
this.selectedScopeId = e.detail.key;
this.loadSecrets(); this.loadSecrets();
}} }}
></dees-input-dropdown> ></dees-input-dropdown>
<dees-button @click=${() => this.addSecret()}>Add Secret</dees-button> ${this.selectedScope !== 'all' ? html`
<dees-input-dropdown
.label=${this.selectedScope === 'project' ? 'Project' : 'Group'}
.options=${entityOptions}
.selectedOption=${entityOptions.find((o) => o.key === this.selectedScopeId) || entityOptions[0]}
@selectedOption=${(e: CustomEvent) => {
this.selectedScopeId = e.detail.key;
}}
></dees-input-dropdown>
` : ''}
<dees-button
.disabled=${isAllSelected}
@click=${() => this.addSecret()}
>Add Secret</dees-button>
<dees-button @click=${() => this.loadSecrets()}>Refresh</dees-button> <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 || '*',
@@ -127,8 +173,8 @@ export class GitopsViewSecrets extends DeesElement {
action: async (item: any) => { action: async (item: 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,
}); });
}, },
@@ -144,11 +190,13 @@ export class GitopsViewSecrets extends DeesElement {
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 +209,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 +268,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 || '',
}); });