20 Commits
v2.2.1 ... 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
6889b81159 v2.3.0 2026-02-24 15:22:56 +00:00
43321c35d6 feat(storage): add StorageManager and cache subsystem; integrate storage into ConnectionManager and GitopsApp, migrate legacy connections, and add tests 2026-02-24 15:22:56 +00:00
54 changed files with 31976 additions and 166631 deletions

4
.gitignore vendored
View File

@@ -6,7 +6,9 @@ deno.lock
node_modules/
# Build outputs
dist_serve/
# ts_bundled/ is committed (embedded frontend bundle)
ts_bundled/bundle.js
ts_bundled/bundle.js.map
# Development
.nogit/

View File

@@ -1,5 +1,70 @@
# 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)
add StorageManager and cache subsystem; integrate storage into ConnectionManager and GitopsApp, migrate legacy connections, and add tests
- Add StorageManager with filesystem and memory backends, key normalization, atomic writes and JSON helpers (getJSON/setJSON).
- ConnectionManager now depends on StorageManager, persists each connection as /connections/<id>.json, and includes a one-time migration from legacy .nogit/connections.json.
- Introduce cache subsystem: CacheDb (LocalTsmDb + Smartdata), CacheCleaner, CachedDocument and CachedProject for TTL'd cached provider data, plus lifecycle management in GitopsApp.
- GitopsApp now initializes StorageManager, wires ConnectionManager to storage, starts/stops CacheDb and CacheCleaner, and uses resolved default paths via resolvePaths.
- Export smartmongo and smartdata in plugins and add corresponding deps to deno.json.
- Add comprehensive tests: storage unit tests, connection manager integration using StorageManager, and a tsmdb + smartdata spike test.
## 2026-02-24 - 2.2.1 - fix(ts_bundled)
add generated bundled JavaScript and source map for ts build (bundle.js and bundle.js.map)

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/gitops",
"version": "2.2.1",
"version": "2.7.1",
"exports": "./mod.ts",
"nodeModulesDir": "auto",
"tasks": {
@@ -13,11 +13,14 @@
"@std/encoding": "jsr:@std/encoding@^1.0.10",
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.2.6",
"@api.global/typedserver": "npm:@api.global/typedserver@^8.3.0",
"@api.global/typedserver": "npm:@api.global/typedserver@^8.3.1",
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1",
"@apiclient.xyz/gitea": "npm:@apiclient.xyz/gitea@^1.0.3",
"@apiclient.xyz/gitlab": "npm:@apiclient.xyz/gitlab@^2.0.3"
"@apiclient.xyz/gitlab": "npm:@apiclient.xyz/gitlab@^2.0.3",
"@push.rocks/smartmongo": "npm:@push.rocks/smartmongo@^5.1.0",
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^7.0.15",
"@push.rocks/smartsecret": "npm:@push.rocks/smartsecret@^1.0.1"
},
"compilerOptions": {
"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": [
{
"from": "./ts_web/index.ts",
"to": "./dist_serve/bundle.js",
"outputMode": "bundle",
"to": "./ts_bundled/bundle.ts",
"outputMode": "base64ts",
"bundler": "esbuild",
"production": true,
"includeFiles": ["./html/index.html"]
"includeFiles": [{"from": "./html/index.html", "to": "index.html"}]
}
]
},
@@ -15,9 +15,11 @@
"bundles": [
{
"from": "./ts_web/index.ts",
"to": "./dist_serve/bundle.js",
"to": "./ts_bundled/bundle.ts",
"outputMode": "base64ts",
"watchPatterns": ["./ts_web/**/*"],
"triggerReload": true
"triggerReload": true,
"includeFiles": [{"from": "./html/index.html", "to": "index.html"}]
}
],
"watchers": [

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/gitops",
"version": "2.2.1",
"version": "2.7.1",
"description": "GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs",
"main": "mod.ts",
"type": "module",
@@ -14,11 +14,12 @@
"license": "MIT",
"dependencies": {
"@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"
},
"devDependencies": {
"@git.zone/tsbundle": "^2.8.3",
"@git.zone/tswatch": "^3.1.0"
"@git.zone/tsbundle": "^2.9.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

@@ -2,6 +2,8 @@ import { assertEquals, assertExists } from 'https://deno.land/std@0.208.0/assert
import { BaseProvider, GiteaProvider, GitLabProvider } from '../ts/providers/index.ts';
import { ConnectionManager } from '../ts/classes/connectionmanager.ts';
import { GitopsApp } from '../ts/classes/gitopsapp.ts';
import { StorageManager } from '../ts/storage/index.ts';
import * as smartsecret from '@push.rocks/smartsecret';
Deno.test('GiteaProvider instantiates correctly', () => {
const provider = new GiteaProvider('test-id', 'https://gitea.example.com', 'test-token');
@@ -18,13 +20,17 @@ Deno.test('GitLabProvider instantiates correctly', () => {
});
Deno.test('ConnectionManager instantiates correctly', () => {
const manager = new ConnectionManager();
const storage = new StorageManager({ backend: 'memory' });
const secret = new smartsecret.SmartSecret({ service: 'gitops-test' });
const manager = new ConnectionManager(storage, secret);
assertExists(manager);
});
Deno.test('GitopsApp instantiates correctly', () => {
const app = new GitopsApp();
assertExists(app);
assertExists(app.storageManager);
assertExists(app.smartSecret);
assertExists(app.connectionManager);
assertExists(app.opsServer);
});

143
test/test.storage_test.ts Normal file
View File

@@ -0,0 +1,143 @@
import { assertEquals, assertExists } from 'https://deno.land/std@0.208.0/assert/mod.ts';
import { StorageManager } from '../ts/storage/index.ts';
import * as smartsecret from '@push.rocks/smartsecret';
Deno.test('StorageManager memory: set and get', async () => {
const sm = new StorageManager({ backend: 'memory' });
await sm.set('/test/key1', 'hello');
const result = await sm.get('/test/key1');
assertEquals(result, 'hello');
});
Deno.test('StorageManager memory: get nonexistent returns null', async () => {
const sm = new StorageManager({ backend: 'memory' });
const result = await sm.get('/missing');
assertEquals(result, null);
});
Deno.test('StorageManager memory: delete', async () => {
const sm = new StorageManager({ backend: 'memory' });
await sm.set('/test/key1', 'hello');
const deleted = await sm.delete('/test/key1');
assertEquals(deleted, true);
const result = await sm.get('/test/key1');
assertEquals(result, null);
});
Deno.test('StorageManager memory: delete nonexistent returns false', async () => {
const sm = new StorageManager({ backend: 'memory' });
const deleted = await sm.delete('/missing');
assertEquals(deleted, false);
});
Deno.test('StorageManager memory: exists', async () => {
const sm = new StorageManager({ backend: 'memory' });
assertEquals(await sm.exists('/test/key1'), false);
await sm.set('/test/key1', 'hello');
assertEquals(await sm.exists('/test/key1'), true);
});
Deno.test('StorageManager memory: list keys under prefix', async () => {
const sm = new StorageManager({ backend: 'memory' });
await sm.set('/connections/a.json', '{}');
await sm.set('/connections/b.json', '{}');
await sm.set('/other/c.json', '{}');
const keys = await sm.list('/connections/');
assertEquals(keys, ['/connections/a.json', '/connections/b.json']);
});
Deno.test('StorageManager memory: getJSON and setJSON roundtrip', async () => {
const sm = new StorageManager({ backend: 'memory' });
const data = { id: '123', name: 'test', nested: { value: 42 } };
await sm.setJSON('/data/item.json', data);
const result = await sm.getJSON<typeof data>('/data/item.json');
assertEquals(result, data);
});
Deno.test('StorageManager memory: getJSON nonexistent returns null', async () => {
const sm = new StorageManager({ backend: 'memory' });
const result = await sm.getJSON('/missing.json');
assertEquals(result, null);
});
Deno.test('StorageManager: key validation requires leading slash', async () => {
const sm = new StorageManager({ backend: 'memory' });
let threw = false;
try {
await sm.get('no-slash');
} catch {
threw = true;
}
assertEquals(threw, true);
});
Deno.test('StorageManager: key normalization strips ..', async () => {
const sm = new StorageManager({ backend: 'memory' });
await sm.set('/test/../actual/key', 'value');
// '..' segments are stripped, so key becomes /test/actual/key — wait,
// the normalizer filters out '..' segments entirely
// /test/../actual/key -> segments: ['test', 'actual', 'key'] (.. filtered)
const result = await sm.get('/test/actual/key');
assertEquals(result, 'value');
});
Deno.test('StorageManager filesystem: set, get, delete roundtrip', async () => {
const tmpDir = await Deno.makeTempDir();
const sm = new StorageManager({ backend: 'filesystem', fsPath: tmpDir });
try {
await sm.set('/test/file.txt', 'filesystem content');
const result = await sm.get('/test/file.txt');
assertEquals(result, 'filesystem content');
assertEquals(await sm.exists('/test/file.txt'), true);
const deleted = await sm.delete('/test/file.txt');
assertEquals(deleted, true);
assertEquals(await sm.get('/test/file.txt'), null);
} finally {
await Deno.remove(tmpDir, { recursive: true });
}
});
Deno.test('StorageManager filesystem: list keys', async () => {
const tmpDir = await Deno.makeTempDir();
const sm = new StorageManager({ backend: 'filesystem', fsPath: tmpDir });
try {
await sm.setJSON('/items/a.json', { id: 'a' });
await sm.setJSON('/items/b.json', { id: 'b' });
const keys = await sm.list('/items/');
assertEquals(keys, ['/items/a.json', '/items/b.json']);
} finally {
await Deno.remove(tmpDir, { recursive: true });
}
});
Deno.test('ConnectionManager with StorageManager: create and load', async () => {
const { ConnectionManager } = await import('../ts/classes/connectionmanager.ts');
const sm = new StorageManager({ backend: 'memory' });
const secret = new smartsecret.SmartSecret({ service: 'gitops-test' });
const cm = new ConnectionManager(sm, secret);
await cm.init();
// Create a connection
const conn = await cm.createConnection('test', 'gitea', 'https://gitea.example.com', 'token');
assertExists(conn.id);
assertEquals(conn.name, 'test');
assertEquals(conn.token, '***');
// Verify it's stored in StorageManager
const stored = await sm.getJSON<{ id: string }>(`/connections/${conn.id}.json`);
assertExists(stored);
assertEquals(stored.id, conn.id);
// Create a new ConnectionManager and verify it loads the connection
const cm2 = new ConnectionManager(sm, secret);
await cm2.init();
const conns = cm2.getConnections();
assertEquals(conns.length, 1);
assertEquals(conns[0].id, conn.id);
// Wait for background health checks to avoid resource leaks
await cm.healthCheckDone;
await cm2.healthCheckDone;
});

View File

@@ -0,0 +1,59 @@
import { assertEquals, assertExists } from 'https://deno.land/std@0.208.0/assert/mod.ts';
import { LocalTsmDb } from '@push.rocks/smartmongo';
import { SmartdataDb, SmartDataDbDoc, Collection, svDb, unI } from '@push.rocks/smartdata';
Deno.test({
name: 'TsmDb spike: LocalTsmDb + SmartdataDb roundtrip',
sanitizeOps: false,
sanitizeResources: false,
fn: async () => {
const tmpDir = await Deno.makeTempDir();
// 1. Start local MongoDB-compatible server
const localDb = new LocalTsmDb({ folderPath: tmpDir });
const { connectionUri } = await localDb.start();
assertExists(connectionUri);
// 2. Connect smartdata
const smartDb = new SmartdataDb({
mongoDbUrl: connectionUri,
mongoDbName: 'gitops_spike_test',
});
await smartDb.init();
assertEquals(smartDb.status, 'connected');
// 3. Define a simple document class
@Collection(() => smartDb)
class TestDoc extends SmartDataDbDoc<TestDoc, TestDoc> {
@unI()
public id: string = '';
@svDb()
public label: string = '';
@svDb()
public value: number = 0;
constructor() {
super();
}
}
// 4. Insert a document
const doc = new TestDoc();
doc.id = 'test-1';
doc.label = 'spike';
doc.value = 42;
await doc.save();
// 5. Query it back
const found = await TestDoc.getInstance({ id: 'test-1' });
assertExists(found);
assertEquals(found.label, 'spike');
assertEquals(found.value, 42);
// 6. Cleanup — smartDb closes; localDb.stop() hangs under Deno, so fire-and-forget
await smartDb.close();
localDb.stop().catch(() => {});
},
});

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/gitops',
version: '2.2.1',
version: '2.7.1',
description: 'GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs'
}

68
ts/cache/classes.cache.cleaner.ts vendored Normal file
View File

@@ -0,0 +1,68 @@
import { logger } from '../logging.ts';
import type { CacheDb } from './classes.cachedb.ts';
// deno-lint-ignore no-explicit-any
type DocumentClass = { getInstances: (filter: any) => Promise<{ delete: () => Promise<void> }[]> };
const DEFAULT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
/**
* Periodically cleans up expired cached documents.
*/
export class CacheCleaner {
private intervalId: number | null = null;
private intervalMs: number;
private documentClasses: DocumentClass[] = [];
private cacheDb: CacheDb;
constructor(cacheDb: CacheDb, intervalMs = DEFAULT_INTERVAL_MS) {
this.cacheDb = cacheDb;
this.intervalMs = intervalMs;
}
/** Register a document class for cleanup */
registerClass(cls: DocumentClass): void {
this.documentClasses.push(cls);
}
start(): void {
if (this.intervalId !== null) return;
this.intervalId = setInterval(() => {
this.clean().catch((err) => {
logger.error(`CacheCleaner error: ${err}`);
});
}, this.intervalMs);
// Unref so the interval doesn't prevent process exit
Deno.unrefTimer(this.intervalId);
logger.debug(`CacheCleaner started (interval: ${this.intervalMs}ms)`);
}
stop(): void {
if (this.intervalId !== null) {
clearInterval(this.intervalId);
this.intervalId = null;
logger.debug('CacheCleaner stopped');
}
}
/** Run a single cleanup pass */
async clean(): Promise<number> {
const now = Date.now();
let totalDeleted = 0;
for (const cls of this.documentClasses) {
try {
const expired = await cls.getInstances({ expiresAt: { $lt: now } });
for (const doc of expired) {
await doc.delete();
totalDeleted++;
}
} catch (err) {
logger.error(`CacheCleaner: failed to clean class: ${err}`);
}
}
if (totalDeleted > 0) {
logger.debug(`CacheCleaner: deleted ${totalDeleted} expired document(s)`);
}
return totalDeleted;
}
}

57
ts/cache/classes.cached.document.ts vendored Normal file
View File

@@ -0,0 +1,57 @@
import * as plugins from '../plugins.ts';
/** TTL duration constants in milliseconds */
export const TTL = {
MINUTES_5: 5 * 60 * 1000,
HOURS_1: 60 * 60 * 1000,
HOURS_24: 24 * 60 * 60 * 1000,
DAYS_7: 7 * 24 * 60 * 60 * 1000,
DAYS_30: 30 * 24 * 60 * 60 * 1000,
DAYS_90: 90 * 24 * 60 * 60 * 1000,
} as const;
/**
* Abstract base class for cached documents with TTL support.
* Extend this class and add @Collection decorator pointing to your CacheDb.
*/
export abstract class CachedDocument<
T extends CachedDocument<T>,
> extends plugins.smartdata.SmartDataDbDoc<T, T> {
@plugins.smartdata.svDb()
public createdAt: number = Date.now();
@plugins.smartdata.svDb()
public expiresAt: number = Date.now() + TTL.HOURS_1;
@plugins.smartdata.svDb()
public lastAccessedAt: number = Date.now();
constructor() {
super();
}
/** Set TTL in milliseconds from now */
setTTL(ms: number): void {
this.expiresAt = Date.now() + ms;
}
/** Set TTL in days from now */
setTTLDays(days: number): void {
this.setTTL(days * 24 * 60 * 60 * 1000);
}
/** Set TTL in hours from now */
setTTLHours(hours: number): void {
this.setTTL(hours * 60 * 60 * 1000);
}
/** Check if this document has expired */
isExpired(): boolean {
return Date.now() > this.expiresAt;
}
/** Update last accessed timestamp */
touch(): void {
this.lastAccessedAt = Date.now();
}
}

82
ts/cache/classes.cachedb.ts vendored Normal file
View File

@@ -0,0 +1,82 @@
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
export interface ICacheDbOptions {
storagePath?: string;
dbName?: string;
debug?: boolean;
}
/**
* Singleton wrapper around LocalTsmDb + SmartdataDb.
* Provides a managed MongoDB-compatible cache database.
*/
export class CacheDb {
private static instance: CacheDb | null = null;
private localTsmDb: InstanceType<typeof plugins.smartmongo.LocalTsmDb> | null = null;
private smartdataDb: InstanceType<typeof plugins.smartdata.SmartdataDb> | null = null;
private options: Required<ICacheDbOptions>;
private constructor(options: ICacheDbOptions = {}) {
this.options = {
storagePath: options.storagePath ?? './.nogit/cachedb',
dbName: options.dbName ?? 'gitops_cache',
debug: options.debug ?? false,
};
}
static getInstance(options?: ICacheDbOptions): CacheDb {
if (!CacheDb.instance) {
CacheDb.instance = new CacheDb(options);
}
return CacheDb.instance;
}
static resetInstance(): void {
CacheDb.instance = null;
}
async start(): Promise<void> {
logger.info('Starting CacheDb...');
this.localTsmDb = new plugins.smartmongo.LocalTsmDb({
folderPath: this.options.storagePath,
});
const { connectionUri } = await this.localTsmDb.start();
this.smartdataDb = new plugins.smartdata.SmartdataDb({
mongoDbUrl: connectionUri,
mongoDbName: this.options.dbName,
});
await this.smartdataDb.init();
logger.success(`CacheDb started (db: ${this.options.dbName})`);
}
async stop(): Promise<void> {
logger.info('Stopping CacheDb...');
if (this.smartdataDb) {
await this.smartdataDb.close();
this.smartdataDb = null;
}
if (this.localTsmDb) {
// localDb.stop() may hang under Deno — fire-and-forget with timeout
const stopPromise = this.localTsmDb.stop().catch(() => {});
await Promise.race([
stopPromise,
new Promise<void>((resolve) => {
const id = setTimeout(resolve, 3000);
Deno.unrefTimer(id);
}),
]);
this.localTsmDb = null;
}
logger.success('CacheDb stopped');
}
getDb(): InstanceType<typeof plugins.smartdata.SmartdataDb> {
if (!this.smartdataDb) {
throw new Error('CacheDb not started. Call start() first.');
}
return this.smartdataDb;
}
}

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,32 @@
import * as plugins from '../../plugins.ts';
import { CacheDb } from '../classes.cachedb.ts';
import { CachedDocument, TTL } from '../classes.cached.document.ts';
/**
* Cached project data from git providers. TTL: 5 minutes.
*/
@plugins.smartdata.Collection(() => CacheDb.getInstance().getDb())
export class CachedProject extends CachedDocument<CachedProject> {
@plugins.smartdata.unI()
public id: string = '';
@plugins.smartdata.svDb()
public connectionId: string = '';
@plugins.smartdata.svDb()
public projectName: string = '';
@plugins.smartdata.svDb()
public projectUrl: string = '';
@plugins.smartdata.svDb()
public description: string = '';
@plugins.smartdata.svDb()
public defaultBranch: string = '';
constructor() {
super();
this.setTTL(TTL.MINUTES_5);
}
}

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

2
ts/cache/documents/index.ts vendored Normal file
View File

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

7
ts/cache/index.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
export { CacheDb } from './classes.cachedb.ts';
export type { ICacheDbOptions } from './classes.cachedb.ts';
export { CachedDocument, TTL } from './classes.cached.document.ts';
export { CacheCleaner } from './classes.cache.cleaner.ts';
export { SecretsScanService } from './classes.secrets.scan.service.ts';
export type { IScanResult } from './classes.secrets.scan.service.ts';
export * from './documents/index.ts';

View File

@@ -2,41 +2,133 @@ import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import type * as interfaces from '../../ts_interfaces/index.ts';
import { BaseProvider, GiteaProvider, GitLabProvider } from '../providers/index.ts';
import type { StorageManager } from '../storage/index.ts';
const CONNECTIONS_FILE = './.nogit/connections.json';
const LEGACY_CONNECTIONS_FILE = './.nogit/connections.json';
const CONNECTIONS_PREFIX = '/connections/';
const KEYCHAIN_PREFIX = 'keychain:';
/**
* Manages provider connections - persists to .nogit/connections.json
* and creates provider instances on demand.
* Manages provider connections persists each connection as an
* individual JSON file via StorageManager. Tokens are stored in
* the OS keychain (or encrypted file fallback) via SmartSecret.
*/
export class ConnectionManager {
private connections: interfaces.data.IProviderConnection[] = [];
private storageManager: StorageManager;
private smartSecret: plugins.smartsecret.SmartSecret;
/** Resolves when background connection health checks complete */
public healthCheckDone: Promise<void> = Promise.resolve();
async init(): Promise<void> {
await this.loadConnections();
constructor(storageManager: StorageManager, smartSecret: plugins.smartsecret.SmartSecret) {
this.storageManager = storageManager;
this.smartSecret = smartSecret;
}
private async loadConnections(): Promise<void> {
try {
const text = await Deno.readTextFile(CONNECTIONS_FILE);
this.connections = JSON.parse(text);
logger.info(`Loaded ${this.connections.length} connection(s)`);
} catch {
this.connections = [];
logger.debug('No existing connections file found, starting fresh');
async init(): Promise<void> {
await this.migrateLegacyFile();
await this.loadConnections();
// Auto-test all connections in the background
this.healthCheckDone = this.testAllConnections();
}
/**
* Tests all loaded connections in the background and updates their status.
* Fire-and-forget — does not block startup.
*/
private async testAllConnections(): Promise<void> {
for (const conn of this.connections) {
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';
}
}
}
private async saveConnections(): Promise<void> {
// Ensure .nogit directory exists
/**
* One-time migration from the legacy .nogit/connections.json file.
*/
private async migrateLegacyFile(): Promise<void> {
try {
await Deno.mkdir('./.nogit', { recursive: true });
} catch { /* already exists */ }
await Deno.writeTextFile(CONNECTIONS_FILE, JSON.stringify(this.connections, null, 2));
const text = await Deno.readTextFile(LEGACY_CONNECTIONS_FILE);
const legacy: interfaces.data.IProviderConnection[] = JSON.parse(text);
if (legacy.length > 0) {
logger.info(`Migrating ${legacy.length} connection(s) from legacy file...`);
for (const conn of legacy) {
await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, conn);
}
// Rename legacy file so migration doesn't repeat
await Deno.rename(LEGACY_CONNECTIONS_FILE, LEGACY_CONNECTIONS_FILE + '.migrated');
logger.success('Legacy connections migrated successfully');
}
} catch {
// No legacy file or already migrated — nothing to do
}
}
private async loadConnections(): Promise<void> {
const keys = await this.storageManager.list(CONNECTIONS_PREFIX);
this.connections = [];
for (const key of keys) {
const conn = await this.storageManager.getJSON<interfaces.data.IProviderConnection>(key);
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);
}
}
if (this.connections.length > 0) {
logger.info(`Loaded ${this.connections.length} connection(s)`);
} else {
logger.debug('No existing connections found, starting fresh');
}
}
/**
* 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> {
// Store real token in keychain
await this.smartSecret.setSecret(conn.id, conn.token);
// Save JSON with sentinel value
const jsonConn = { ...conn, token: `${KEYCHAIN_PREFIX}${conn.id}` };
await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, jsonConn);
}
private async removeConnection(id: string): Promise<void> {
await this.smartSecret.deleteSecret(id);
await this.storageManager.delete(`${CONNECTIONS_PREFIX}${id}.json`);
}
getConnections(): interfaces.data.IProviderConnection[] {
// Return connections without exposing tokens
return this.connections.map((c) => ({ ...c, token: '***' }));
}
@@ -60,7 +152,7 @@ export class ConnectionManager {
status: 'disconnected',
};
this.connections.push(connection);
await this.saveConnections();
await this.persistConnection(connection);
logger.success(`Connection created: ${name} (${providerType})`);
return { ...connection, token: '***' };
}
@@ -74,7 +166,7 @@ export class ConnectionManager {
if (updates.name) conn.name = updates.name;
if (updates.baseUrl) conn.baseUrl = updates.baseUrl.replace(/\/+$/, '');
if (updates.token) conn.token = updates.token;
await this.saveConnections();
await this.persistConnection(conn);
return { ...conn, token: '***' };
}
@@ -82,7 +174,7 @@ export class ConnectionManager {
const idx = this.connections.findIndex((c) => c.id === id);
if (idx === -1) throw new Error(`Connection not found: ${id}`);
this.connections.splice(idx, 1);
await this.saveConnections();
await this.removeConnection(id);
logger.info(`Connection deleted: ${id}`);
}
@@ -91,7 +183,7 @@ export class ConnectionManager {
const result = await provider.testConnection();
const conn = this.connections.find((c) => c.id === id)!;
conn.status = result.ok ? 'connected' : 'error';
await this.saveConnections();
await this.persistConnection(conn);
return result;
}

View File

@@ -1,25 +1,70 @@
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { ConnectionManager } from './connectionmanager.ts';
import { OpsServer } from '../opsserver/index.ts';
import { StorageManager } from '../storage/index.ts';
import { CacheDb, CacheCleaner, CachedProject, CachedSecret, SecretsScanService } from '../cache/index.ts';
import { resolvePaths } from '../paths.ts';
/**
* Main GitOps application orchestrator
*/
export class GitopsApp {
public storageManager: StorageManager;
public smartSecret: plugins.smartsecret.SmartSecret;
public connectionManager: ConnectionManager;
public opsServer: OpsServer;
public cacheDb: CacheDb;
public cacheCleaner: CacheCleaner;
public secretsScanService!: SecretsScanService;
private scanIntervalId: number | null = null;
constructor() {
this.connectionManager = new ConnectionManager();
const paths = resolvePaths();
this.storageManager = new StorageManager({
backend: 'filesystem',
fsPath: paths.defaultStoragePath,
});
this.smartSecret = new plugins.smartsecret.SmartSecret({ service: 'gitops' });
this.connectionManager = new ConnectionManager(this.storageManager, this.smartSecret);
this.cacheDb = CacheDb.getInstance({
storagePath: paths.defaultTsmDbPath,
dbName: 'gitops_cache',
});
this.cacheCleaner = new CacheCleaner(this.cacheDb);
this.cacheCleaner.registerClass(CachedProject);
this.cacheCleaner.registerClass(CachedSecret);
this.opsServer = new OpsServer(this);
}
async start(port = 3000): Promise<void> {
logger.info('Initializing GitOps...');
// Start CacheDb
await this.cacheDb.start();
// Initialize connection manager (loads saved connections)
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
this.cacheCleaner.start();
// Start OpsServer
await this.opsServer.start(port);
@@ -28,7 +73,13 @@ export class GitopsApp {
async stop(): Promise<void> {
logger.info('Shutting down GitOps...');
if (this.scanIntervalId !== null) {
clearInterval(this.scanIntervalId);
this.scanIntervalId = null;
}
await this.opsServer.stop();
this.cacheCleaner.stop();
await this.cacheDb.stop();
logger.success('GitOps shutdown complete');
}
}

View File

@@ -2,6 +2,7 @@ import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import type { GitopsApp } from '../classes/gitopsapp.ts';
import * as handlers from './handlers/index.ts';
import { files as bundledFiles } from '../../ts_bundled/bundle.ts';
export class OpsServer {
public gitopsAppRef: GitopsApp;
@@ -16,17 +17,25 @@ export class OpsServer {
public secretsHandler!: handlers.SecretsHandler;
public pipelinesHandler!: handlers.PipelinesHandler;
public logsHandler!: handlers.LogsHandler;
public webhookHandler!: handlers.WebhookHandler;
public actionsHandler!: handlers.ActionsHandler;
constructor(gitopsAppRef: GitopsApp) {
this.gitopsAppRef = gitopsAppRef;
}
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({
domain: 'localhost',
feedMetadata: undefined,
serveDir: absoluteServeDir,
bundledContent: bundledFiles,
noCache: true,
addCustomRoutes: async (typedserver) => {
this.webhookHandler.registerRoutes(typedserver);
},
});
// Chain typedrouters
@@ -51,6 +60,7 @@ export class OpsServer {
this.secretsHandler = new handlers.SecretsHandler(this);
this.pipelinesHandler = new handlers.PipelinesHandler(this);
this.logsHandler = new handlers.LogsHandler(this);
this.actionsHandler = new handlers.ActionsHandler(this);
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 { PipelinesHandler } from './pipelines.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 {
// Get secrets
// Get all secrets (cache-first, falls back to live fetch)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAllSecrets>(
'getAllSecrets',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const scanService = this.opsServerRef.gitopsAppRef.secretsScanService;
// Try cache first
const hasCached = await scanService.hasCachedData(dataArg.connectionId, dataArg.scope);
if (hasCached) {
const secrets = await scanService.getCachedSecrets({
connectionId: dataArg.connectionId,
scope: dataArg.scope,
});
return { secrets };
}
// Cache miss: live fetch and save to cache
const provider = this.opsServerRef.gitopsAppRef.connectionManager.getProvider(
dataArg.connectionId,
);
const allSecrets: interfaces.data.ISecret[] = [];
if (dataArg.scope === 'project') {
const projects = await provider.getProjects();
for (let i = 0; i < projects.length; i += 5) {
const batch = projects.slice(i, i + 5);
const results = await Promise.allSettled(
batch.map(async (p) => {
const secrets = await provider.getProjectSecrets(p.id);
return secrets.map((s) => ({
...s,
scopeName: p.fullPath || p.name,
scope: 'project' as const,
scopeId: p.id,
connectionId: dataArg.connectionId,
}));
}),
);
for (const result of results) {
if (result.status === 'fulfilled') {
allSecrets.push(...result.value);
}
}
}
} else {
const groups = await provider.getGroups();
for (let i = 0; i < groups.length; i += 5) {
const batch = groups.slice(i, i + 5);
const results = await Promise.allSettled(
batch.map(async (g) => {
const secrets = await provider.getGroupSecrets(g.id);
return secrets.map((s) => ({
...s,
scopeName: g.fullPath || g.name,
scope: 'group' as const,
scopeId: g.id,
connectionId: dataArg.connectionId,
}));
}),
);
for (const result of results) {
if (result.status === 'fulfilled') {
allSecrets.push(...result.value);
}
}
}
}
// Save fetched secrets to cache (fire-and-forget)
scanService.saveSecrets(allSecrets).catch(() => {});
return { secrets: allSecrets };
},
),
);
// Get secrets (cache-first for single entity)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecrets>(
'getSecrets',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const scanService = this.opsServerRef.gitopsAppRef.secretsScanService;
// Try cache first
const cached = await scanService.getCachedSecrets({
connectionId: dataArg.connectionId,
scope: dataArg.scope,
scopeId: dataArg.scopeId,
});
if (cached.length > 0) {
return { secrets: cached };
}
// Cache miss: live fetch
const provider = this.opsServerRef.gitopsAppRef.connectionManager.getProvider(
dataArg.connectionId,
);
const secrets = dataArg.scope === 'project'
? await provider.getProjectSecrets(dataArg.scopeId)
: await provider.getGroupSecrets(dataArg.scopeId);
// Save to cache (fire-and-forget)
const fullSecrets = secrets.map((s) => ({
...s,
scope: dataArg.scope,
scopeId: dataArg.scopeId,
connectionId: dataArg.connectionId,
}));
scanService.saveSecrets(fullSecrets).catch(() => {});
return { secrets };
},
),
@@ -41,6 +143,9 @@ export class SecretsHandler {
const secret = dataArg.scope === 'project'
? await provider.createProjectSecret(dataArg.scopeId, dataArg.key, dataArg.value)
: await provider.createGroupSecret(dataArg.scopeId, dataArg.key, dataArg.value);
// Refresh cache for this entity
const scanService = this.opsServerRef.gitopsAppRef.secretsScanService;
scanService.scanEntity(dataArg.connectionId, dataArg.scope, dataArg.scopeId).catch(() => {});
return { secret };
},
),
@@ -58,6 +163,9 @@ export class SecretsHandler {
const secret = dataArg.scope === 'project'
? await provider.updateProjectSecret(dataArg.scopeId, dataArg.key, dataArg.value)
: await provider.updateGroupSecret(dataArg.scopeId, dataArg.key, dataArg.value);
// Refresh cache for this entity
const scanService = this.opsServerRef.gitopsAppRef.secretsScanService;
scanService.scanEntity(dataArg.connectionId, dataArg.scope, dataArg.scopeId).catch(() => {});
return { secret };
},
),
@@ -77,6 +185,9 @@ export class SecretsHandler {
} else {
await provider.deleteGroupSecret(dataArg.scopeId, dataArg.key);
}
// Refresh cache for this entity
const scanService = this.opsServerRef.gitopsAppRef.secretsScanService;
scanService.scanEntity(dataArg.connectionId, dataArg.scope, dataArg.scopeId).catch(() => {});
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');
}
}

19
ts/paths.ts Normal file
View File

@@ -0,0 +1,19 @@
import * as path from '@std/path';
export interface IGitopsPaths {
gitopsHomeDir: string;
defaultStoragePath: string;
defaultTsmDbPath: string;
}
/**
* Resolve gitops paths. Accepts optional baseDir for test isolation.
*/
export function resolvePaths(baseDir?: string): IGitopsPaths {
const home = baseDir ?? path.join(Deno.env.get('HOME') ?? '/tmp', '.serve.zone', 'gitops');
return {
gitopsHomeDir: home,
defaultStoragePath: path.join(home, 'storage'),
defaultTsmDbPath: path.join(home, 'tsmdb'),
};
}

View File

@@ -23,3 +23,12 @@ export { smartguard, smartjwt };
import * as giteaClient from '@apiclient.xyz/gitea';
import * as gitlabClient from '@apiclient.xyz/gitlab';
export { giteaClient, gitlabClient };
// Database
import * as smartmongo from '@push.rocks/smartmongo';
import * as smartdata from '@push.rocks/smartdata';
export { smartmongo, smartdata };
// Secrets
import * as smartsecret from '@push.rocks/smartsecret';
export { smartsecret };

View File

@@ -18,13 +18,45 @@ export class GiteaProvider extends BaseProvider {
}
async getProjects(opts?: IListOptions): Promise<interfaces.data.IProject[]> {
const repos = await this.client.getRepos(opts);
return repos.map((r) => this.mapProject(r));
// If caller explicitly requests a specific page, respect it (no auto-pagination)
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[]> {
const orgs = await this.client.getOrgs(opts);
return orgs.map((o) => this.mapGroup(o));
// If caller explicitly requests a specific page, respect it (no auto-pagination)
if (opts?.page) {
const orgs = await this.client.getOrgs(opts);
return orgs.map((o) => this.mapGroup(o));
}
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 ---
@@ -40,7 +72,7 @@ export class GiteaProvider extends BaseProvider {
value: string,
): Promise<interfaces.data.ISecret> {
await this.client.setRepoSecret(projectId, key, value);
return { key, value: '***', protected: false, masked: true, scope: 'project', scopeId: projectId, connectionId: this.connectionId, environment: '*' };
return { key, value: '***', protected: false, masked: true, scope: 'project', scopeId: projectId, scopeName: projectId, connectionId: this.connectionId, environment: '*' };
}
async updateProjectSecret(
@@ -68,7 +100,7 @@ export class GiteaProvider extends BaseProvider {
value: string,
): Promise<interfaces.data.ISecret> {
await this.client.setOrgSecret(groupId, key, value);
return { key, value: '***', protected: false, masked: true, scope: 'group', scopeId: groupId, connectionId: this.connectionId, environment: '*' };
return { key, value: '***', protected: false, masked: true, scope: 'group', scopeId: groupId, scopeName: groupId, connectionId: this.connectionId, environment: '*' };
}
async updateGroupSecret(
@@ -117,7 +149,7 @@ export class GiteaProvider extends BaseProvider {
private mapProject(r: plugins.giteaClient.IGiteaRepository): interfaces.data.IProject {
return {
id: String(r.id),
id: r.full_name || String(r.id),
name: r.name || '',
fullPath: r.full_name || '',
description: r.description || '',
@@ -132,7 +164,7 @@ export class GiteaProvider extends BaseProvider {
private mapGroup(o: plugins.giteaClient.IGiteaOrganization): interfaces.data.IGroup {
return {
id: String(o.id || o.name),
id: o.name || String(o.id),
name: o.name || '',
fullPath: o.name || '',
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 {
key: s.name || '',
value: '***',
@@ -151,6 +183,7 @@ export class GiteaProvider extends BaseProvider {
masked: true,
scope,
scopeId,
scopeName: scopeName || scopeId,
connectionId: this.connectionId,
environment: '*',
};

View File

@@ -149,6 +149,7 @@ export class GitLabProvider extends BaseProvider {
v: plugins.gitlabClient.IGitLabVariable,
scope: 'project' | 'group',
scopeId: string,
scopeName?: string,
): interfaces.data.ISecret {
return {
key: v.key || '',
@@ -157,6 +158,7 @@ export class GitLabProvider extends BaseProvider {
masked: v.masked || false,
scope,
scopeId,
scopeName: scopeName || scopeId,
connectionId: this.connectionId,
environment: v.environment_scope || '*',
};

View File

@@ -0,0 +1,139 @@
import * as path from '@std/path';
export type TStorageBackend = 'filesystem' | 'memory';
export interface IStorageConfig {
backend?: TStorageBackend;
fsPath?: string;
}
/**
* Key-value storage abstraction with filesystem and memory backends.
* Keys must start with '/' and are normalized (no '..', no double slashes).
*/
export class StorageManager {
private backend: TStorageBackend;
private fsPath: string;
private memoryStore: Map<string, string>;
constructor(config: IStorageConfig = {}) {
this.backend = config.backend ?? 'filesystem';
this.fsPath = config.fsPath ?? './storage';
this.memoryStore = new Map();
}
/**
* Normalize and validate a storage key.
*/
private normalizeKey(key: string): string {
if (!key.startsWith('/')) {
throw new Error(`Storage key must start with '/': ${key}`);
}
// Strip '..' segments and normalize double slashes
const segments = key.split('/').filter((s) => s !== '' && s !== '..');
return '/' + segments.join('/');
}
/**
* Resolve a key to a filesystem path.
*/
private keyToPath(key: string): string {
const normalized = this.normalizeKey(key);
return path.join(this.fsPath, ...normalized.split('/').filter(Boolean));
}
async get(key: string): Promise<string | null> {
const normalized = this.normalizeKey(key);
if (this.backend === 'memory') {
return this.memoryStore.get(normalized) ?? null;
}
try {
return await Deno.readTextFile(this.keyToPath(normalized));
} catch (err) {
if (err instanceof Deno.errors.NotFound) return null;
throw err;
}
}
async set(key: string, value: string): Promise<void> {
const normalized = this.normalizeKey(key);
if (this.backend === 'memory') {
this.memoryStore.set(normalized, value);
return;
}
const filePath = this.keyToPath(normalized);
const dir = path.dirname(filePath);
await Deno.mkdir(dir, { recursive: true });
// Atomic write: write to temp then rename
const tmpPath = filePath + '.tmp';
await Deno.writeTextFile(tmpPath, value);
await Deno.rename(tmpPath, filePath);
}
async delete(key: string): Promise<boolean> {
const normalized = this.normalizeKey(key);
if (this.backend === 'memory') {
return this.memoryStore.delete(normalized);
}
try {
await Deno.remove(this.keyToPath(normalized));
return true;
} catch (err) {
if (err instanceof Deno.errors.NotFound) return false;
throw err;
}
}
async exists(key: string): Promise<boolean> {
const normalized = this.normalizeKey(key);
if (this.backend === 'memory') {
return this.memoryStore.has(normalized);
}
try {
await Deno.stat(this.keyToPath(normalized));
return true;
} catch (err) {
if (err instanceof Deno.errors.NotFound) return false;
throw err;
}
}
/**
* List keys under a given prefix.
*/
async list(prefix: string): Promise<string[]> {
const normalized = this.normalizeKey(prefix);
if (this.backend === 'memory') {
const keys: string[] = [];
for (const key of this.memoryStore.keys()) {
if (key.startsWith(normalized)) {
keys.push(key);
}
}
return keys.sort();
}
const dirPath = this.keyToPath(normalized);
const keys: string[] = [];
try {
for await (const entry of Deno.readDir(dirPath)) {
if (entry.isFile) {
keys.push(normalized.replace(/\/$/, '') + '/' + entry.name);
}
}
} catch (err) {
if (err instanceof Deno.errors.NotFound) return [];
throw err;
}
return keys.sort();
}
async getJSON<T>(key: string): Promise<T | null> {
const raw = await this.get(key);
if (raw === null) return null;
return JSON.parse(raw) as T;
}
async setJSON(key: string, value: unknown): Promise<void> {
await this.set(key, JSON.stringify(value, null, 2));
}
}

2
ts/storage/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { StorageManager } from './classes.storagemanager.ts';
export type { IStorageConfig, TStorageBackend } from './classes.storagemanager.ts';

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;
scope: 'project' | 'group';
scopeId: string;
scopeName: string;
connectionId: 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 './pipelines.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 data from '../data/index.ts';
export interface IReq_GetAllSecrets extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetAllSecrets
> {
method: 'getAllSecrets';
request: {
identity: data.IIdentity;
connectionId: string;
scope: 'project' | 'group';
};
response: {
secrets: data.ISecret[];
};
}
export interface IReq_GetSecrets extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetSecrets

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 = {
name: '@serve.zone/gitops',
version: '2.2.1',
version: '2.7.1',
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<{
connectionId: string;
scope: 'project' | 'group';
@@ -320,7 +348,7 @@ export const createSecretAction = dataStatePart.createAction<{
identity: context.identity!,
...dataArg,
});
// Re-fetch secrets
// Re-fetch only the affected entity's secrets and merge
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSecrets
>('/typedrequest', 'getSecrets');
@@ -330,7 +358,11 @@ export const createSecretAction = dataStatePart.createAction<{
scope: dataArg.scope,
scopeId: dataArg.scopeId,
});
return { ...statePartArg.getState(), secrets: listResp.secrets };
const state = statePartArg.getState();
const otherSecrets = state.secrets.filter(
(s) => !(s.scopeId === dataArg.scopeId && s.scope === dataArg.scope),
);
return { ...state, secrets: [...otherSecrets, ...listResp.secrets] };
} catch (err) {
console.error('Failed to create secret:', err);
return statePartArg.getState();
@@ -353,7 +385,7 @@ export const updateSecretAction = dataStatePart.createAction<{
identity: context.identity!,
...dataArg,
});
// Re-fetch
// Re-fetch only the affected entity's secrets and merge
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSecrets
>('/typedrequest', 'getSecrets');
@@ -363,7 +395,11 @@ export const updateSecretAction = dataStatePart.createAction<{
scope: dataArg.scope,
scopeId: dataArg.scopeId,
});
return { ...statePartArg.getState(), secrets: listResp.secrets };
const state = statePartArg.getState();
const otherSecrets = state.secrets.filter(
(s) => !(s.scopeId === dataArg.scopeId && s.scope === dataArg.scope),
);
return { ...state, secrets: [...otherSecrets, ...listResp.secrets] };
} catch (err) {
console.error('Failed to update secret:', err);
return statePartArg.getState();
@@ -388,7 +424,9 @@ export const deleteSecretAction = dataStatePart.createAction<{
const state = statePartArg.getState();
return {
...state,
secrets: state.secrets.filter((s) => s.key !== dataArg.key),
secrets: state.secrets.filter(
(s) => !(s.key === dataArg.key && s.scopeId === dataArg.scopeId && s.scope === dataArg.scope),
),
};
} catch (err) {
console.error('Failed to delete secret:', err);
@@ -543,3 +581,9 @@ export const toggleAutoRefreshAction = uiStatePart.createAction(async (statePart
const state = statePartArg.getState();
return { ...state, autoRefresh: !state.autoRefresh };
});
export const setRefreshIntervalAction = uiStatePart.createAction<{ interval: number }>(
async (statePartArg, dataArg) => {
return { ...statePartArg.getState(), refreshInterval: dataArg.interval };
},
);

View File

@@ -18,6 +18,7 @@ import type { GitopsViewGroups } from './views/groups/index.js';
import type { GitopsViewSecrets } from './views/secrets/index.js';
import type { GitopsViewPipelines } from './views/pipelines/index.js';
import type { GitopsViewBuildlog } from './views/buildlog/index.js';
import type { GitopsViewActions } from './views/actions/index.js';
@customElement('gitops-dashboard')
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: 'Pipelines', iconName: 'lucide:play', element: (async () => (await import('./views/pipelines/index.js')).GitopsViewPipelines)() },
{ name: 'Build Log', iconName: 'lucide:scrollText', element: (async () => (await import('./views/buildlog/index.js')).GitopsViewBuildlog)() },
{ name: 'Actions', iconName: 'lucide:zap', element: (async () => (await import('./views/actions/index.js')).GitopsViewActions)() },
];
private resolvedViewTabs: Array<{ name: string; iconName: string; element: any }> = [];
// Auto-refresh timer
private autoRefreshTimer: ReturnType<typeof setInterval> | null = null;
// WebSocket client
private ws: WebSocket | null = null;
private wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
private wsIntentionalClose = false;
constructor() {
super();
document.title = 'GitOps';
@@ -53,7 +63,11 @@ export class GitopsDashboard extends DeesElement {
this.loginState = loginState;
if (loginState.isLoggedIn) {
appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
this.connectWebSocket();
} else {
this.disconnectWebSocket();
}
this.manageAutoRefreshTimer();
});
this.rxSubscriptions.push(loginSubscription);
@@ -62,6 +76,7 @@ export class GitopsDashboard extends DeesElement {
.subscribe((uiState) => {
this.uiState = uiState;
this.syncAppdashView(uiState.activeView);
this.manageAutoRefreshTimer();
});
this.rxSubscriptions.push(uiSubscription);
}
@@ -78,6 +93,36 @@ export class GitopsDashboard extends DeesElement {
width: 100%;
height: 100vh;
}
.auto-refresh-toggle {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 1000;
background: rgba(30, 30, 50, 0.9);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 8px 14px;
color: #ccc;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
backdrop-filter: blur(8px);
transition: background 0.2s;
}
.auto-refresh-toggle:hover {
background: rgba(40, 40, 70, 0.95);
}
.auto-refresh-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
}
.auto-refresh-dot.active {
background: #00ff88;
}
`,
];
@@ -92,6 +137,15 @@ export class GitopsDashboard extends DeesElement {
</dees-simple-appdash>
</dees-simple-login>
</div>
${this.loginState.isLoggedIn ? html`
<div
class="auto-refresh-toggle"
@click=${() => appstate.uiStatePart.dispatchAction(appstate.toggleAutoRefreshAction, null)}
>
<span class="auto-refresh-dot ${this.uiState.autoRefresh ? 'active' : ''}"></span>
Auto-Refresh: ${this.uiState.autoRefresh ? 'ON' : 'OFF'}
</div>
` : ''}
`;
}
@@ -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) {
const domtools = await this.domtoolsPromise;
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/pipelines/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()
accessor selectedJobId: string = '';
private _autoRefreshHandler: () => void;
constructor() {
super();
const connSub = appstate.connectionsStatePart
@@ -49,6 +51,18 @@ export class GitopsViewBuildlog extends DeesElement {
.select((s) => s)
.subscribe((s) => { this.dataState = s; });
this.rxSubscriptions.push(dataSub);
this._autoRefreshHandler = () => this.handleAutoRefresh();
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
public override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
private handleAutoRefresh(): void {
this.fetchLog();
}
public static styles = [

View File

@@ -19,12 +19,26 @@ export class GitopsViewConnections extends DeesElement {
activeConnectionId: null,
};
private _autoRefreshHandler: () => void;
constructor() {
super();
const sub = appstate.connectionsStatePart
.select((s) => s)
.subscribe((s) => { this.connectionsState = s; });
this.rxSubscriptions.push(sub);
this._autoRefreshHandler = () => this.handleAutoRefresh();
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
public override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
private handleAutoRefresh(): void {
this.refresh();
}
public static styles = [

View File

@@ -32,6 +32,8 @@ export class GitopsViewGroups extends DeesElement {
@state()
accessor selectedConnectionId: string = '';
private _autoRefreshHandler: () => void;
constructor() {
super();
const connSub = appstate.connectionsStatePart
@@ -43,6 +45,18 @@ export class GitopsViewGroups extends DeesElement {
.select((s) => s)
.subscribe((s) => { this.dataState = s; });
this.rxSubscriptions.push(dataSub);
this._autoRefreshHandler = () => this.handleAutoRefresh();
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
public override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
private handleAutoRefresh(): void {
this.loadGroups();
}
public static styles = [

View File

@@ -30,6 +30,8 @@ export class GitopsViewOverview extends DeesElement {
currentJobLog: '',
};
private _autoRefreshHandler: () => void;
constructor() {
super();
const connSub = appstate.connectionsStatePart
@@ -41,6 +43,18 @@ export class GitopsViewOverview extends DeesElement {
.select((s) => s)
.subscribe((s) => { this.dataState = s; });
this.rxSubscriptions.push(dataSub);
this._autoRefreshHandler = () => this.handleAutoRefresh();
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
public override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
private handleAutoRefresh(): void {
appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
}
public static styles = [

View File

@@ -35,6 +35,8 @@ export class GitopsViewPipelines extends DeesElement {
@state()
accessor selectedProjectId: string = '';
private _autoRefreshHandler: () => void;
constructor() {
super();
const connSub = appstate.connectionsStatePart
@@ -46,6 +48,18 @@ export class GitopsViewPipelines extends DeesElement {
.select((s) => s)
.subscribe((s) => { this.dataState = s; });
this.rxSubscriptions.push(dataSub);
this._autoRefreshHandler = () => this.handleAutoRefresh();
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
public override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
private handleAutoRefresh(): void {
this.loadPipelines();
}
public static styles = [

View File

@@ -32,6 +32,8 @@ export class GitopsViewProjects extends DeesElement {
@state()
accessor selectedConnectionId: string = '';
private _autoRefreshHandler: () => void;
constructor() {
super();
const connSub = appstate.connectionsStatePart
@@ -43,6 +45,18 @@ export class GitopsViewProjects extends DeesElement {
.select((s) => s)
.subscribe((s) => { this.dataState = s; });
this.rxSubscriptions.push(dataSub);
this._autoRefreshHandler = () => this.handleAutoRefresh();
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
public override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
private handleAutoRefresh(): void {
this.loadProjects();
}
public static styles = [

View File

@@ -33,10 +33,12 @@ export class GitopsViewSecrets extends DeesElement {
accessor selectedConnectionId: string = '';
@state()
accessor selectedScope: 'project' | 'group' = 'project';
accessor selectedScope: 'all' | 'project' | 'group' = 'all';
@state()
accessor selectedScopeId: string = '';
accessor selectedScopeId: string = '__all__';
private _autoRefreshHandler: () => void;
constructor() {
super();
@@ -49,6 +51,18 @@ export class GitopsViewSecrets extends DeesElement {
.select((s) => s)
.subscribe((s) => { this.dataState = s; });
this.rxSubscriptions.push(dataSub);
this._autoRefreshHandler = () => this.handleAutoRefresh();
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
public override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
private handleAutoRefresh(): void {
this.loadSecrets();
}
public static styles = [
@@ -56,6 +70,19 @@ export class GitopsViewSecrets extends DeesElement {
viewHostCss,
];
private get filteredSecrets() {
let secrets = this.dataState.secrets;
// Filter by scope (unless "all")
if (this.selectedScope !== 'all') {
secrets = secrets.filter((s) => s.scope === this.selectedScope);
}
// Filter by entity if specific one selected
if (this.selectedScopeId !== '__all__') {
secrets = secrets.filter((s) => s.scopeId === this.selectedScopeId);
}
return secrets;
}
public render(): TemplateResult {
const connectionOptions = this.connectionsState.connections.map((c) => ({
option: `${c.name} (${c.providerType})`,
@@ -63,13 +90,23 @@ export class GitopsViewSecrets extends DeesElement {
}));
const scopeOptions = [
{ option: 'All Scopes', key: 'all' },
{ option: 'Project', key: 'project' },
{ option: 'Group', key: 'group' },
];
const entityOptions = this.selectedScope === 'project'
? this.dataState.projects.map((p) => ({ option: p.fullPath || p.name, key: p.id }))
: this.dataState.groups.map((g) => ({ option: g.fullPath || g.name, key: g.id }));
const entities = this.selectedScope === 'group'
? this.dataState.groups.map((g) => ({ option: g.fullPath || g.name, key: g.id }))
: this.selectedScope === 'project'
? this.dataState.projects.map((p) => ({ option: p.fullPath || p.name, key: p.id }))
: [];
const entityOptions = [
{ option: 'All', key: '__all__' },
...entities,
];
const isAllSelected = this.selectedScope === 'all' || this.selectedScopeId === '__all__';
return html`
<div class="view-title">Secrets</div>
@@ -81,7 +118,9 @@ export class GitopsViewSecrets extends DeesElement {
.selectedOption=${connectionOptions.find((o) => o.key === this.selectedConnectionId) || connectionOptions[0]}
@selectedOption=${(e: CustomEvent) => {
this.selectedConnectionId = e.detail.key;
this.selectedScopeId = '__all__';
this.loadEntities();
this.loadSecrets();
}}
></dees-input-dropdown>
<dees-input-dropdown
@@ -89,28 +128,35 @@ export class GitopsViewSecrets extends DeesElement {
.options=${scopeOptions}
.selectedOption=${scopeOptions.find((o) => o.key === this.selectedScope)}
@selectedOption=${(e: CustomEvent) => {
this.selectedScope = e.detail.key as 'project' | 'group';
this.selectedScope = e.detail.key as 'all' | 'project' | 'group';
this.selectedScopeId = '__all__';
this.loadEntities();
}}
></dees-input-dropdown>
<dees-input-dropdown
.label=${this.selectedScope === 'project' ? 'Project' : 'Group'}
.options=${entityOptions}
.selectedOption=${entityOptions.find((o) => o.key === this.selectedScopeId) || entityOptions[0]}
@selectedOption=${(e: CustomEvent) => {
this.selectedScopeId = e.detail.key;
this.loadSecrets();
}}
></dees-input-dropdown>
<dees-button @click=${() => this.addSecret()}>Add Secret</dees-button>
${this.selectedScope !== 'all' ? html`
<dees-input-dropdown
.label=${this.selectedScope === 'project' ? 'Project' : 'Group'}
.options=${entityOptions}
.selectedOption=${entityOptions.find((o) => o.key === this.selectedScopeId) || entityOptions[0]}
@selectedOption=${(e: CustomEvent) => {
this.selectedScopeId = e.detail.key;
}}
></dees-input-dropdown>
` : ''}
<dees-button
.disabled=${isAllSelected}
@click=${() => this.addSecret()}
>Add Secret</dees-button>
<dees-button @click=${() => this.loadSecrets()}>Refresh</dees-button>
</div>
<dees-table
.heading1=${'Secrets'}
.heading2=${'CI/CD variables for the selected entity'}
.data=${this.dataState.secrets}
.data=${this.filteredSecrets}
.displayFunction=${(item: any) => ({
Key: item.key,
Scope: item.scopeName || item.scopeId,
Value: item.masked ? '******' : item.value,
Protected: item.protected ? 'Yes' : 'No',
Environment: item.environment || '*',
@@ -127,8 +173,8 @@ export class GitopsViewSecrets extends DeesElement {
action: async (item: any) => {
await appstate.dataStatePart.dispatchAction(appstate.deleteSecretAction, {
connectionId: this.selectedConnectionId,
scope: this.selectedScope,
scopeId: this.selectedScopeId,
scope: item.scope,
scopeId: item.scopeId,
key: item.key,
});
},
@@ -144,11 +190,13 @@ export class GitopsViewSecrets extends DeesElement {
if (conns.length > 0 && !this.selectedConnectionId) {
this.selectedConnectionId = conns[0].id;
await this.loadEntities();
await this.loadSecrets();
}
}
private async loadEntities() {
if (!this.selectedConnectionId) return;
if (this.selectedScope === 'all') return;
if (this.selectedScope === 'project') {
await appstate.dataStatePart.dispatchAction(appstate.fetchProjectsAction, {
connectionId: this.selectedConnectionId,
@@ -161,15 +209,15 @@ export class GitopsViewSecrets extends DeesElement {
}
private async loadSecrets() {
if (!this.selectedConnectionId || !this.selectedScopeId) return;
await appstate.dataStatePart.dispatchAction(appstate.fetchSecretsAction, {
if (!this.selectedConnectionId) return;
// Always fetch both scopes — client-side filtering handles the rest
await appstate.dataStatePart.dispatchAction(appstate.fetchAllSecretsAction, {
connectionId: this.selectedConnectionId,
scope: this.selectedScope,
scopeId: this.selectedScopeId,
});
}
private async addSecret() {
if (this.selectedScope === 'all' || this.selectedScopeId === '__all__') return;
await plugins.deesCatalog.DeesModal.createAndShow({
heading: 'Add Secret',
content: html`
@@ -220,8 +268,8 @@ export class GitopsViewSecrets extends DeesElement {
const input = modal.shadowRoot.querySelector('dees-input-text');
await appstate.dataStatePart.dispatchAction(appstate.updateSecretAction, {
connectionId: this.selectedConnectionId,
scope: this.selectedScope,
scopeId: this.selectedScopeId,
scope: item.scope,
scopeId: item.scopeId,
key: item.key,
value: input?.value || '',
});