27 Commits

Author SHA1 Message Date
c9a758b417 v2.8.0 2026-02-28 16:33:54 +00:00
f7e16aa350 feat(sync): add sync subsystem: SyncManager, OpsServer sync handlers, Sync UI and state, provider groupFilter support, and realtime sync log streaming via TypedSocket 2026-02-28 16:33:53 +00:00
2f050744bc fix(frontend): add navigation context for cross-view linking and fix double-load bug
Projects "View Secrets"/"View Pipelines" and Groups "View Secrets" now
pass connection/scope/entity context so the target view opens with
filters pre-filled. Fixed double-load bug where dees-simple-appdash's
view-select event re-dispatched setActiveViewAction without context.
2026-02-27 14:17:36 +00:00
623ec4907c fix(tswatch): restart backend on bundle changes and watch shared types
- Add ts_bundled/**/* and ts_interfaces/**/* to backend watcher
- Remove no-op triggerReload (requires tswatch dev server)
2026-02-27 12:09:26 +00:00
06eae3b443 update 2026-02-27 11:48:47 +00:00
44b418cbdd update 2026-02-27 11:21:27 +00:00
81ead52a72 feat(core): add table actions (edit, pause, delete confirmation) and global action log
- Add Edit and Pause/Resume actions to connections table
- Add delete confirmation modal to secrets table
- Add 'paused' status to connections with full backend support
- Skip paused connections in health checks and secrets scanning
- Add global ActionLog service with filesystem persistence
- Instrument all mutation handlers (connections, secrets, pipelines) with action logging
- Add Action Log view with entity type filtering to dashboard
2026-02-27 11:13:07 +00:00
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
72 changed files with 35613 additions and 166669 deletions

4
.gitignore vendored
View File

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

View File

@@ -1,5 +1,83 @@
# Changelog # Changelog
## 2026-02-28 - 2.8.0 - feat(sync)
add sync subsystem: SyncManager, OpsServer sync handlers, Sync UI and state, provider groupFilter support, and realtime sync log streaming via TypedSocket
- Introduce SyncManager and wire it into GitopsApp (init/stop) with a new syncMirrorsPath
- Add typedrequest SyncHandler with endpoints to create/update/delete/pause/trigger/preview sync configs and fetch repo statuses/logs
- Add sync data interfaces (ISyncConfig, ISyncRepoStatus, ISyncLogEntry) and action log integration for sync operations
- Add web UI: gitops-view-sync, appstate sync actions/selectors, and preview/status/modals for sync configs
- Add groupFilter and groupFilterId to connection model; migrate legacy baseGroup/baseGroupId to groupFilter fields on load
- Providers (Gitea/GitLab) and BaseProvider now accept groupFilterId and scope project/group listings accordingly (auto-pagination applies)
- Logging: add sync log buffer, getSyncLogs API, and broadcast sync log entries to connected clients via TypedSocket; web client listens and displays entries
- Update dependencies: bump @apiclient.xyz/gitea and gitlab versions and add @api.global/typedsocket
- Connections UI: expose Group Filter field and pass through on create/update
## 2026-02-24 - 2.7.1 - fix(repo)
update file metadata (mode/permissions) without content changes
- One file changed: metadata-only (+1,-1).
- No source or behavior changes — safe to bump patch version.
- Change likely involves file mode/permission or metadata update only.
## 2026-02-24 - 2.7.0 - feat(secrets)
add ability to fetch and view all secrets across projects and groups, include scopeName, and improve frontend merging/filtering
- Add new typed request and handler getAllSecrets to opsserver to bulk-fetch secrets across projects or groups (batched and using Promise.allSettled for performance).
- Extend ISecret with scopeName and update provider mappings (Gitea/GitLab) and secret return values to include scopeName.
- Frontend: add fetchAllSecretsAction, add an "All" option in the Secrets view, filter table by selected entity or show all, and disable "Add Secret" when "All" is selected.
- Create/update actions now merge only the affected entity's secrets into state instead of replacing the entire list; delete now filters by key+scope+scopeId to avoid removing unrelated secrets.
- UI: table now shows a Scope column using scopeName (or fallback to scopeId), selection changes trigger reloading of entities and secrets.
## 2026-02-24 - 2.6.2 - fix(meta)
update file metadata only (no source changes)
- One file changed: metadata-only (e.g. permissions/mode) with no content modifications.
- No code, dependency, or API changes detected; safe patch release recommended.
- Bump patch version from 2.6.1 to 2.6.2.
## 2026-02-24 - 2.6.1 - fix(package.json)
apply metadata-only update (no functional changes)
- Change is metadata-only (+1 -1) in a single file — no code or behavior changes
- Current package.json version is 2.6.0; recommend a patch bump to 2.6.1
## 2026-02-24 - 2.6.0 - feat(webhook)
add webhook endpoint and client push notifications, auto-refresh UI, and gitea id mapping fixes
- Add WebhookHandler with POST /webhook/:connectionId that parses provider-specific headers and broadcasts webhookNotification via TypedSocket to connected clients
- Frontend: add auto-refresh toggle, refresh-interval action, dashboard auto-refresh timer, and views subscribing to gitops-auto-refresh events to refresh data
- Frontend: add WebSocket client with reconnect logic to receive push notifications and trigger auto-refresh on webhook events
- Gitea provider: prefer repository full_name and organization name when mapping project and group ids to ensure stable identifiers
- Bump devDependencies: @git.zone/tsbundle ^2.9.0 and @git.zone/tswatch ^3.2.0
- Add ts_bundled/bundle.js and bundle.js.map to .gitignore
## 2026-02-24 - 2.5.0 - feat(gitea-provider)
auto-paginate Gitea repository and organization listing; respect explicit page option and default perPage to 50
- getProjects and getGroups now auto-fetch all pages when opts.page is not provided
- When opts.page is provided, the provider respects it and does not auto-paginate
- Defaults perPage to 50 for paginated requests
- Dependency @design.estate/dees-catalog bumped from ^3.43.0 to ^3.43.3
## 2026-02-24 - 2.4.0 - feat(opsserver)
serve embedded frontend bundle from committed ts_bundled instead of using external dist_serve directory
- Switch server to use bundledContent from committed ts_bundled bundle (base64ts) instead of pointing at a serveDir
- Update bundler config to emit ./ts_bundled/bundle.ts with outputMode 'base64ts' and includeFiles mapping
- Remove dist_serve from .gitignore and commit ts_bundled (embedded frontend bundle)
- Bump devDependency @git.zone/tsbundle to ^2.8.4 and deno dependency @api.global/typedserver to ^8.3.1
## 2026-02-24 - 2.3.0 - feat(storage)
add StorageManager and cache subsystem; integrate storage into ConnectionManager and GitopsApp, migrate legacy connections, and add tests
- Add StorageManager with filesystem and memory backends, key normalization, atomic writes and JSON helpers (getJSON/setJSON).
- ConnectionManager now depends on StorageManager, persists each connection as /connections/<id>.json, and includes a one-time migration from legacy .nogit/connections.json.
- Introduce cache subsystem: CacheDb (LocalTsmDb + Smartdata), CacheCleaner, CachedDocument and CachedProject for TTL'd cached provider data, plus lifecycle management in GitopsApp.
- GitopsApp now initializes StorageManager, wires ConnectionManager to storage, starts/stops CacheDb and CacheCleaner, and uses resolved default paths via resolvePaths.
- Export smartmongo and smartdata in plugins and add corresponding deps to deno.json.
- Add comprehensive tests: storage unit tests, connection manager integration using StorageManager, and a tsmdb + smartdata spike test.
## 2026-02-24 - 2.2.1 - fix(ts_bundled) ## 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) 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", "name": "@serve.zone/gitops",
"version": "2.2.1", "version": "2.8.0",
"exports": "./mod.ts", "exports": "./mod.ts",
"nodeModulesDir": "auto", "nodeModulesDir": "auto",
"tasks": { "tasks": {
@@ -13,11 +13,14 @@
"@std/encoding": "jsr:@std/encoding@^1.0.10", "@std/encoding": "jsr:@std/encoding@^1.0.10",
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19", "@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.2.6", "@api.global/typedrequest": "npm:@api.global/typedrequest@^3.2.6",
"@api.global/typedserver": "npm:@api.global/typedserver@^8.3.0", "@api.global/typedserver": "npm:@api.global/typedserver@^8.3.1",
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0", "@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1", "@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1",
"@apiclient.xyz/gitea": "npm:@apiclient.xyz/gitea@^1.0.3", "@apiclient.xyz/gitea": "npm:@apiclient.xyz/gitea@^1.2.0",
"@apiclient.xyz/gitlab": "npm:@apiclient.xyz/gitlab@^2.0.3" "@apiclient.xyz/gitlab": "npm:@apiclient.xyz/gitlab@^2.2.0",
"@push.rocks/smartmongo": "npm:@push.rocks/smartmongo@^5.1.0",
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^7.0.15",
"@push.rocks/smartsecret": "npm:@push.rocks/smartsecret@^1.0.1"
}, },
"compilerOptions": { "compilerOptions": {
"lib": [ "lib": [

29544
dist_serve/bundle.js Normal file

File diff suppressed because one or more lines are too long

33
dist_serve/index.html Normal file
View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta
name="viewport"
content="user-scalable=0, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height"
/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="theme-color" content="#000000" />
<title>GitOps</title>
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
<style>
html {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
body {
position: relative;
background: #000;
margin: 0px;
}
</style>
</head>
<body>
<noscript>
<p style="color: #fff; text-align: center; margin-top: 100px;">
JavaScript is required to run the GitOps dashboard.
</p>
</noscript>
</body>
<script defer type="module" src="/bundle.js"></script>
</html>

View File

@@ -3,11 +3,11 @@
"bundles": [ "bundles": [
{ {
"from": "./ts_web/index.ts", "from": "./ts_web/index.ts",
"to": "./dist_serve/bundle.js", "to": "./ts_bundled/bundle.ts",
"outputMode": "bundle", "outputMode": "base64ts",
"bundler": "esbuild", "bundler": "esbuild",
"production": true, "production": true,
"includeFiles": ["./html/index.html"] "includeFiles": [{"from": "./html/index.html", "to": "index.html"}]
} }
] ]
}, },
@@ -15,15 +15,18 @@
"bundles": [ "bundles": [
{ {
"from": "./ts_web/index.ts", "from": "./ts_web/index.ts",
"to": "./dist_serve/bundle.js", "to": "./ts_bundled/bundle.ts",
"watchPatterns": ["./ts_web/**/*"], "outputMode": "base64ts",
"triggerReload": true "bundler": "esbuild",
"production": true,
"watchPatterns": ["./ts_web/**/*", "./html/**/*"],
"includeFiles": [{"from": "./html/index.html", "to": "index.html"}]
} }
], ],
"watchers": [ "watchers": [
{ {
"name": "backend", "name": "backend",
"watch": "./ts/**/*", "watch": ["./ts/**/*", "./ts_interfaces/**/*", "./ts_bundled/**/*"],
"command": "deno run --allow-all mod.ts server", "command": "deno run --allow-all mod.ts server",
"restart": true, "restart": true,
"debounce": 500, "debounce": 500,

View File

@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/gitops", "name": "@serve.zone/gitops",
"version": "2.2.1", "version": "2.8.0",
"description": "GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs", "description": "GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs",
"main": "mod.ts", "main": "mod.ts",
"type": "module", "type": "module",
@@ -14,11 +14,15 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@api.global/typedrequest-interfaces": "^3.0.19", "@api.global/typedrequest-interfaces": "^3.0.19",
"@design.estate/dees-catalog": "^3.43.0", "@api.global/typedserver": "8.4.0",
"@api.global/typedsocket": "^4.1.0",
"@apiclient.xyz/gitea": "1.2.0",
"@apiclient.xyz/gitlab": "2.2.0",
"@design.estate/dees-catalog": "^3.43.3",
"@design.estate/dees-element": "^2.1.6" "@design.estate/dees-element": "^2.1.6"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbundle": "^2.8.3", "@git.zone/tsbundle": "^2.9.0",
"@git.zone/tswatch": "^3.1.0" "@git.zone/tswatch": "^3.2.0"
} }
} }

244
readme.md Normal file
View File

@@ -0,0 +1,244 @@
# @serve.zone/gitops
A unified dashboard for managing Gitea and GitLab instances — browse projects, manage secrets, monitor CI/CD pipelines, stream build logs, and receive webhook notifications, all from a single app.
## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## 🚀 Features
- **Multi-Provider** — Connect to Gitea and GitLab simultaneously via a unified provider abstraction
- **Secrets Management** — View, create, update, and delete CI/CD secrets across projects and groups
- **Pipeline Monitoring** — Browse pipelines, view jobs, retry failed builds, cancel running ones
- **Build Log Streaming** — Fetch and display raw job logs with monospace rendering
- **Webhook Integration** — Receive push/PR/pipeline events via `POST /webhook/:connectionId` and broadcast to all connected clients in real-time via WebSocket
- **Secrets Cache & Scanning** — Background scan service fetches and caches all secrets every 24h with upsert-based deduplication
- **Secure Token Storage** — Connection tokens stored in OS keychain via `@push.rocks/smartsecret` (encrypted file fallback), never in plaintext on disk
- **Auto-Refresh** — Frontend polls for updates every 30s, with manual refresh available on every view
- **Embedded SPA** — Frontend is bundled (base64-encoded) and served from memory, no static file server needed
## 📦 Install
### Prerequisites
- [Deno](https://deno.land/) v2+
- [pnpm](https://pnpm.io/) (for frontend deps and bundling)
- MongoDB-compatible database (auto-provisioned via `@push.rocks/smartmongo` / `LocalTsmDb`)
### Setup
```bash
# Clone the repository
git clone https://code.foss.global/serve.zone/gitops.git
cd gitops
# Install frontend dependencies
pnpm install
# Build the frontend bundle
pnpm build
# Start the server
deno run --allow-all mod.ts server
```
The app will be available at `http://localhost:3000`.
## ⚙️ Configuration
All configuration is done through environment variables:
| Variable | Default | Description |
|---|---|---|
| `GITOPS_PORT` | `3000` | HTTP/WebSocket server port |
| `GITOPS_ADMIN_USERNAME` | `admin` | Admin login username |
| `GITOPS_ADMIN_PASSWORD` | `admin` | Admin login password |
Data is stored at `~/.serve.zone/gitops/`:
```
~/.serve.zone/gitops/
├── storage/ # Connection configs (JSON, tokens replaced with keychain refs)
│ └── connections/ # One file per connection
└── tsmdb/ # Embedded MongoDB data (cached secrets, projects)
```
## 🏗️ Architecture
```
┌──────────────────────────────────────────────────────┐
│ GitOps App │
├──────────┬───────────────┬───────────────────────────┤
│ OpsServer│ ConnectionMgr │ SecretsScanService │
│ (HTTP/WS)│ (Providers) │ (24h background scan) │
├──────────┤ ├───────────────────────────┤
│ Handlers │ GiteaProvider│ CacheDb │
│ (9 total)│ GitLabProvider│ (LocalTsmDb + SmartdataDb)│
├──────────┴───────────────┴───────────────────────────┤
│ StorageManager │
│ (filesystem key-value store) │
├──────────────────────────────────────────────────────┤
│ SmartSecret │
│ (OS keychain / encrypted file) │
└──────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ Frontend SPA │
│ Lit + dees-catalog + smartstate │
├──────────────────────────────────────────────────────┤
│ Dashboard │ 8 Views │ WebSocket Client │ Auto-Refresh│
└──────────────────────────────────────────────────────┘
```
### Backend (`ts/`)
- **`GitopsApp`** — Main orchestrator. Owns all subsystems, handles startup/shutdown lifecycle.
- **`ConnectionManager`** — CRUD for provider connections. Tokens secured in OS keychain. Background health checks on startup.
- **`BaseProvider`** → **`GiteaProvider`** / **`GitLabProvider`** — Unified interface over both APIs (projects, groups, secrets, pipelines, jobs, logs).
- **`OpsServer`** — TypedServer-based HTTP/WebSocket server with 9 handler modules:
- `AdminHandler` — JWT-based auth (login/logout/verify)
- `ConnectionsHandler` — Connection CRUD + test
- `ProjectsHandler` / `GroupsHandler` — Browse repos and orgs
- `SecretsHandler` — Cache-first secret CRUD
- `PipelinesHandler` — Pipeline list/jobs/retry/cancel
- `LogsHandler` — Job log fetch
- `WebhookHandler` — Custom HTTP route for incoming webhooks
- `ActionsHandler` — Force scan / scan status
- **`SecretsScanService`** — Background scanner with upsert-based deduplication. Runs on startup and every 24h.
- **`CacheDb`** — Embedded MongoDB via `LocalTsmDb` + `SmartdataDb`. TTL-based expiration with periodic cleanup.
- **`StorageManager`** — Filesystem-backed key-value store with atomic writes.
### Frontend (`ts_web/`)
- Built with [Lit](https://lit.dev/) web components and [@design.estate/dees-catalog](https://code.foss.global/design.estate/dees-catalog) UI library
- Reactive state management via `smartstate` (4 state parts: login, connections, data, UI)
- 8 tabbed views: Overview, Connections, Projects, Groups, Secrets, Pipelines, Build Log, Actions
- WebSocket client for real-time webhook push notifications
- Bundled to `ts_bundled/bundle.ts` via `@git.zone/tsbundle` (base64-encoded, committed to git)
### Shared Types (`ts_interfaces/`)
- `data/` — Data models (`IProject`, `ISecret`, `IPipeline`, `IIdentity`, etc.)
- `requests/` — TypedRequest interfaces for all RPC endpoints
## 🔌 API
All endpoints use [TypedRequest](https://code.foss.global/api.global/typedrequest) — a typed RPC protocol over HTTP POST to `/typedrequest`.
### Authentication
```typescript
// Login → returns JWT identity
{ method: 'adminLogin', request: { username, password } }
// → { identity: { jwt, userId, role, expiresAt } }
// All other requests require identity
{ method: 'getProjects', request: { identity, connectionId } }
```
### Connections
| Method | Description |
|---|---|
| `getConnections` | List all connections (tokens masked) |
| `createConnection` | Add a new Gitea/GitLab connection |
| `updateConnection` | Update connection name/URL/token |
| `testConnection` | Verify connection is reachable |
| `deleteConnection` | Remove a connection |
### Data
| Method | Description |
|---|---|
| `getProjects` | List projects (with search/pagination) |
| `getGroups` | List groups/orgs (with search/pagination) |
| `getAllSecrets` | Get all secrets for a connection+scope (cache-first) |
| `getSecrets` | Get secrets for a specific entity (cache-first) |
| `createSecret` / `updateSecret` / `deleteSecret` | Secret CRUD |
| `getPipelines` | List pipelines for a project |
| `getPipelineJobs` | List jobs for a pipeline |
| `retryPipeline` / `cancelPipeline` | Pipeline actions |
| `getJobLog` | Fetch raw build log for a job |
### Actions
| Method | Description |
|---|---|
| `forceScanSecrets` | Trigger immediate full secrets scan |
| `getScanStatus` | Get scan status, last result, timestamp |
### Webhooks
```bash
# Register this URL in your Gitea/GitLab webhook settings
POST http://your-server:3000/webhook/<connectionId>
```
Events are parsed from `X-Gitea-Event` / `X-Gitlab-Event` headers and broadcast to all connected WebSocket clients as `webhookNotification`.
## 🧪 Development
```bash
# Watch mode — auto-rebuilds frontend + restarts backend on changes
pnpm run watch
# Run tests (Deno)
pnpm test
# Build frontend bundle only
pnpm build
# Start server directly
deno run --allow-all mod.ts server
```
### Project Structure
```
gitops/
├── mod.ts # Entry point
├── deno.json # Deno config + import map
├── package.json # npm metadata + scripts
├── npmextra.json # tsbundle + tswatch config
├── html/index.html # HTML shell
├── ts/ # Backend
│ ├── classes/ # GitopsApp, ConnectionManager
│ ├── providers/ # BaseProvider, GiteaProvider, GitLabProvider
│ ├── storage/ # StorageManager
│ ├── cache/ # CacheDb, CacheCleaner, SecretsScanService
│ │ └── documents/ # CachedProject, CachedSecret
│ └── opsserver/ # OpsServer + 9 handlers
│ ├── handlers/ # AdminHandler, SecretsHandler, etc.
│ └── helpers/ # Guards (JWT verification)
├── ts_interfaces/ # Shared TypeScript types
│ ├── data/ # IProject, ISecret, IPipeline, etc.
│ └── requests/ # TypedRequest interfaces
├── ts_web/ # Frontend SPA
│ ├── appstate.ts # Smartstate store + actions
│ └── elements/ # Lit web components
│ └── views/ # 8 view components
├── ts_bundled/bundle.ts # Embedded frontend (base64, committed)
└── test/ # Deno tests
```
## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
### Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
### Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

3
readme.todo.md Normal file
View File

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

View File

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

143
test/test.storage_test.ts Normal file
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 = { export const commitinfo = {
name: '@serve.zone/gitops', name: '@serve.zone/gitops',
version: '2.2.1', version: '2.8.0',
description: 'GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs' description: 'GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs'
} }

68
ts/cache/classes.cache.cleaner.ts vendored Normal file
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;
}
}

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

@@ -0,0 +1,267 @@
import { logger } from '../logging.ts';
import type { ConnectionManager } from '../classes/connectionmanager.ts';
import { CachedSecret } from './documents/classes.cached.secret.ts';
import { TTL } from './classes.cached.document.ts';
import type { ISecret } from '../../ts_interfaces/data/secret.ts';
export interface IScanResult {
connectionsScanned: number;
secretsFound: number;
errors: string[];
durationMs: number;
}
/**
* Centralized secrets scanning service. Fetches all secrets from all
* connections and upserts them into the CachedSecret collection.
*/
export class SecretsScanService {
public lastScanTimestamp: number = 0;
public lastScanResult: IScanResult | null = null;
public isScanning: boolean = false;
private connectionManager: ConnectionManager;
constructor(connectionManager: ConnectionManager) {
this.connectionManager = connectionManager;
}
/**
* Upsert a single secret into the cache. If a doc with the same composite ID
* already exists, update it in place; otherwise insert a new one.
*/
private async upsertSecret(secret: ISecret): Promise<void> {
const id = CachedSecret.buildId(secret.connectionId, secret.scope, secret.scopeId, secret.key);
const existing = await CachedSecret.getInstance({ id });
if (existing) {
existing.value = secret.value;
existing.protected = secret.protected;
existing.masked = secret.masked;
existing.environment = secret.environment;
existing.scopeName = secret.scopeName;
existing.setTTL(TTL.HOURS_24);
await existing.save();
} else {
const doc = CachedSecret.fromISecret(secret);
await doc.save();
}
}
/**
* Save an array of secrets to cache using upsert logic.
* Best-effort: individual failures are silently ignored.
*/
async saveSecrets(secrets: ISecret[]): Promise<void> {
for (const secret of secrets) {
try {
await this.upsertSecret(secret);
} catch {
// Best-effort caching
}
}
}
/**
* Full scan: iterate all connections, fetch all projects+groups,
* fetch all secrets per entity, upsert CachedSecret docs.
*/
async fullScan(): Promise<IScanResult> {
if (this.isScanning) {
return {
connectionsScanned: 0,
secretsFound: 0,
errors: ['Scan already in progress'],
durationMs: 0,
};
}
this.isScanning = true;
const startTime = Date.now();
const errors: string[] = [];
let totalSecrets = 0;
let connectionsScanned = 0;
try {
const connections = this.connectionManager.getConnections();
for (const conn of connections) {
if (conn.status === 'paused') continue;
try {
const provider = this.connectionManager.getProvider(conn.id);
connectionsScanned++;
// Scan project secrets
try {
const projects = await provider.getProjects();
for (let i = 0; i < projects.length; i += 5) {
const batch = projects.slice(i, i + 5);
const results = await Promise.allSettled(
batch.map(async (p) => {
const secrets = await provider.getProjectSecrets(p.id);
return secrets.map((s) => ({
...s,
scope: 'project' as const,
scopeId: p.id,
scopeName: p.fullPath || p.name,
connectionId: conn.id,
}));
}),
);
for (const result of results) {
if (result.status === 'fulfilled') {
for (const secret of result.value) {
try {
await this.upsertSecret(secret);
totalSecrets++;
} catch (err) {
errors.push(`Save secret ${secret.key}: ${err}`);
}
}
} else {
errors.push(`Fetch project secrets: ${result.reason}`);
}
}
}
} catch (err) {
errors.push(`Fetch projects for ${conn.id}: ${err}`);
}
// Scan group secrets
try {
const groups = await provider.getGroups();
for (let i = 0; i < groups.length; i += 5) {
const batch = groups.slice(i, i + 5);
const results = await Promise.allSettled(
batch.map(async (g) => {
const secrets = await provider.getGroupSecrets(g.id);
return secrets.map((s) => ({
...s,
scope: 'group' as const,
scopeId: g.id,
scopeName: g.fullPath || g.name,
connectionId: conn.id,
}));
}),
);
for (const result of results) {
if (result.status === 'fulfilled') {
for (const secret of result.value) {
try {
await this.upsertSecret(secret);
totalSecrets++;
} catch (err) {
errors.push(`Save secret ${secret.key}: ${err}`);
}
}
} else {
errors.push(`Fetch group secrets: ${result.reason}`);
}
}
}
} catch (err) {
errors.push(`Fetch groups for ${conn.id}: ${err}`);
}
} catch (err) {
errors.push(`Connection ${conn.id}: ${err}`);
}
}
} finally {
this.isScanning = false;
}
const result: IScanResult = {
connectionsScanned,
secretsFound: totalSecrets,
errors,
durationMs: Date.now() - startTime,
};
this.lastScanTimestamp = Date.now();
this.lastScanResult = result;
logger.info(
`Secrets scan complete: ${totalSecrets} secrets from ${connectionsScanned} connections in ${result.durationMs}ms` +
(errors.length > 0 ? ` (${errors.length} errors)` : ''),
);
return result;
}
/**
* Scan a single entity: delete existing cached secrets for that entity,
* fetch fresh from provider, and save to cache.
*/
async scanEntity(
connectionId: string,
scope: 'project' | 'group',
scopeId: string,
scopeName?: string,
): Promise<void> {
try {
// Delete existing cached secrets for this entity
const existing = await CachedSecret.getInstances({
connectionId,
scope,
scopeId,
});
for (const doc of existing) {
await doc.delete();
}
// Fetch fresh from provider
const provider = this.connectionManager.getProvider(connectionId);
const secrets = scope === 'project'
? await provider.getProjectSecrets(scopeId)
: await provider.getGroupSecrets(scopeId);
// Save to cache
for (const s of secrets) {
const doc = CachedSecret.fromISecret({
...s,
scope,
scopeId,
scopeName: scopeName || s.scopeName || '',
connectionId,
});
await doc.save();
}
} catch (err) {
logger.error(`scanEntity failed for ${connectionId}/${scope}/${scopeId}: ${err}`);
}
}
/**
* Get cached secrets matching the filter criteria.
*/
async getCachedSecrets(filter: {
connectionId: string;
scope: 'project' | 'group';
scopeId?: string;
}): Promise<ISecret[]> {
// deno-lint-ignore no-explicit-any
const query: any = {
connectionId: filter.connectionId,
scope: filter.scope,
};
if (filter.scopeId) {
query.scopeId = filter.scopeId;
}
const docs = await CachedSecret.getInstances(query);
// Filter out expired docs
const now = Date.now();
return docs
.filter((d) => d.expiresAt > now)
.map((d) => d.toISecret());
}
/**
* Check if non-expired cached data exists for the given connection+scope.
*/
async hasCachedData(connectionId: string, scope: 'project' | 'group'): Promise<boolean> {
const docs = await CachedSecret.getInstances({
connectionId,
scope,
expiresAt: { $gt: Date.now() },
});
return docs.length > 0;
}
}

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

57
ts/classes/actionlog.ts Normal file
View File

@@ -0,0 +1,57 @@
import { logger } from '../logging.ts';
import type * as interfaces from '../../ts_interfaces/index.ts';
import type { StorageManager } from '../storage/index.ts';
const ACTIONLOG_PREFIX = '/actionlog/';
/**
* Persists and queries action log entries via StorageManager.
* Entries are stored as individual JSON files keyed by timestamp-id.
*/
export class ActionLog {
private storageManager: StorageManager;
constructor(storageManager: StorageManager) {
this.storageManager = storageManager;
}
async append(entry: Omit<interfaces.data.IActionLogEntry, 'id' | 'timestamp'>): Promise<interfaces.data.IActionLogEntry> {
const full: interfaces.data.IActionLogEntry = {
id: crypto.randomUUID(),
timestamp: Date.now(),
...entry,
};
const key = `${ACTIONLOG_PREFIX}${String(full.timestamp).padStart(16, '0')}-${full.id}.json`;
await this.storageManager.setJSON(key, full);
logger.debug(`Action logged: ${full.actionType} ${full.entityType} "${full.entityName}"`);
return full;
}
async query(opts: {
limit?: number;
offset?: number;
entityType?: interfaces.data.TActionEntity;
} = {}): Promise<{ entries: interfaces.data.IActionLogEntry[]; total: number }> {
const limit = opts.limit ?? 50;
const offset = opts.offset ?? 0;
const keys = await this.storageManager.list(ACTIONLOG_PREFIX);
// Sort by key descending (newest first — keys are timestamp-prefixed)
keys.sort((a, b) => b.localeCompare(a));
// Load all entries (or filter by entityType)
let entries: interfaces.data.IActionLogEntry[] = [];
for (const key of keys) {
const entry = await this.storageManager.getJSON<interfaces.data.IActionLogEntry>(key);
if (entry) {
if (opts.entityType && entry.entityType !== opts.entityType) continue;
entries.push(entry);
}
}
const total = entries.length;
entries = entries.slice(offset, offset + limit);
return { entries, total };
}
}

View File

@@ -2,41 +2,143 @@ import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts'; import { logger } from '../logging.ts';
import type * as interfaces from '../../ts_interfaces/index.ts'; import type * as interfaces from '../../ts_interfaces/index.ts';
import { BaseProvider, GiteaProvider, GitLabProvider } from '../providers/index.ts'; import { BaseProvider, GiteaProvider, GitLabProvider } from '../providers/index.ts';
import type { StorageManager } from '../storage/index.ts';
const CONNECTIONS_FILE = './.nogit/connections.json'; const LEGACY_CONNECTIONS_FILE = './.nogit/connections.json';
const CONNECTIONS_PREFIX = '/connections/';
const KEYCHAIN_PREFIX = 'keychain:';
/** /**
* Manages provider connections - persists to .nogit/connections.json * Manages provider connections persists each connection as an
* and creates provider instances on demand. * individual JSON file via StorageManager. Tokens are stored in
* the OS keychain (or encrypted file fallback) via SmartSecret.
*/ */
export class ConnectionManager { export class ConnectionManager {
private connections: interfaces.data.IProviderConnection[] = []; private connections: interfaces.data.IProviderConnection[] = [];
private storageManager: StorageManager;
private smartSecret: plugins.smartsecret.SmartSecret;
/** Resolves when background connection health checks complete */
public healthCheckDone: Promise<void> = Promise.resolve();
async init(): Promise<void> { constructor(storageManager: StorageManager, smartSecret: plugins.smartsecret.SmartSecret) {
await this.loadConnections(); this.storageManager = storageManager;
this.smartSecret = smartSecret;
} }
private async loadConnections(): Promise<void> { async init(): Promise<void> {
try { await this.migrateLegacyFile();
const text = await Deno.readTextFile(CONNECTIONS_FILE); await this.loadConnections();
this.connections = JSON.parse(text); // Auto-test all connections in the background
logger.info(`Loaded ${this.connections.length} connection(s)`); this.healthCheckDone = this.testAllConnections();
} catch { }
this.connections = [];
logger.debug('No existing connections file found, starting fresh'); /**
* Tests all loaded connections in the background and updates their status.
* Fire-and-forget — does not block startup.
*/
private async testAllConnections(): Promise<void> {
for (const conn of this.connections) {
if (conn.status === 'paused') continue;
try {
const provider = this.getProvider(conn.id);
const result = await provider.testConnection();
conn.status = result.ok ? 'connected' : 'error';
await this.persistConnection(conn);
} catch {
conn.status = 'error';
}
} }
} }
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 { try {
await Deno.mkdir('./.nogit', { recursive: true }); const text = await Deno.readTextFile(LEGACY_CONNECTIONS_FILE);
} catch { /* already exists */ } const legacy: interfaces.data.IProviderConnection[] = JSON.parse(text);
await Deno.writeTextFile(CONNECTIONS_FILE, JSON.stringify(this.connections, null, 2)); 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) {
// Migrate legacy baseGroup/baseGroupId property names
if ((conn as any).baseGroup !== undefined && conn.groupFilter === undefined) {
conn.groupFilter = (conn as any).baseGroup;
delete (conn as any).baseGroup;
}
if ((conn as any).baseGroupId !== undefined && conn.groupFilterId === undefined) {
conn.groupFilterId = (conn as any).baseGroupId;
delete (conn as any).baseGroupId;
}
if (conn.token.startsWith(KEYCHAIN_PREFIX)) {
// Token is in keychain — retrieve it
const realToken = await this.smartSecret.getSecret(conn.id);
if (realToken) {
conn.token = realToken;
} else {
logger.warn(`Could not retrieve token for connection ${conn.id} from keychain`);
}
} else if (conn.token && conn.token !== '***') {
// Plaintext token found — auto-migrate to keychain
await this.migrateTokenToKeychain(conn);
}
this.connections.push(conn);
}
}
if (this.connections.length > 0) {
logger.info(`Loaded ${this.connections.length} connection(s)`);
} else {
logger.debug('No existing connections found, starting fresh');
}
}
/**
* 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[] { getConnections(): interfaces.data.IProviderConnection[] {
// Return connections without exposing tokens
return this.connections.map((c) => ({ ...c, token: '***' })); return this.connections.map((c) => ({ ...c, token: '***' }));
} }
@@ -49,6 +151,7 @@ export class ConnectionManager {
providerType: interfaces.data.TProviderType, providerType: interfaces.data.TProviderType,
baseUrl: string, baseUrl: string,
token: string, token: string,
groupFilter?: string,
): Promise<interfaces.data.IProviderConnection> { ): Promise<interfaces.data.IProviderConnection> {
const connection: interfaces.data.IProviderConnection = { const connection: interfaces.data.IProviderConnection = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@@ -58,23 +161,28 @@ export class ConnectionManager {
token, token,
createdAt: Date.now(), createdAt: Date.now(),
status: 'disconnected', status: 'disconnected',
groupFilter: groupFilter || undefined,
}; };
this.connections.push(connection); this.connections.push(connection);
await this.saveConnections(); await this.persistConnection(connection);
logger.success(`Connection created: ${name} (${providerType})`); logger.success(`Connection created: ${name} (${providerType})`);
return { ...connection, token: '***' }; return { ...connection, token: '***' };
} }
async updateConnection( async updateConnection(
id: string, id: string,
updates: { name?: string; baseUrl?: string; token?: string }, updates: { name?: string; baseUrl?: string; token?: string; groupFilter?: string },
): Promise<interfaces.data.IProviderConnection> { ): Promise<interfaces.data.IProviderConnection> {
const conn = this.connections.find((c) => c.id === id); const conn = this.connections.find((c) => c.id === id);
if (!conn) throw new Error(`Connection not found: ${id}`); if (!conn) throw new Error(`Connection not found: ${id}`);
if (updates.name) conn.name = updates.name; if (updates.name) conn.name = updates.name;
if (updates.baseUrl) conn.baseUrl = updates.baseUrl.replace(/\/+$/, ''); if (updates.baseUrl) conn.baseUrl = updates.baseUrl.replace(/\/+$/, '');
if (updates.token) conn.token = updates.token; if (updates.token) conn.token = updates.token;
await this.saveConnections(); if (updates.groupFilter !== undefined) {
conn.groupFilter = updates.groupFilter || undefined;
conn.groupFilterId = undefined; // Will be re-resolved on next test
}
await this.persistConnection(conn);
return { ...conn, token: '***' }; return { ...conn, token: '***' };
} }
@@ -82,19 +190,60 @@ export class ConnectionManager {
const idx = this.connections.findIndex((c) => c.id === id); const idx = this.connections.findIndex((c) => c.id === id);
if (idx === -1) throw new Error(`Connection not found: ${id}`); if (idx === -1) throw new Error(`Connection not found: ${id}`);
this.connections.splice(idx, 1); this.connections.splice(idx, 1);
await this.saveConnections(); await this.removeConnection(id);
logger.info(`Connection deleted: ${id}`); logger.info(`Connection deleted: ${id}`);
} }
async pauseConnection(id: string, paused: boolean): Promise<interfaces.data.IProviderConnection> {
const conn = this.connections.find((c) => c.id === id);
if (!conn) throw new Error(`Connection not found: ${id}`);
conn.status = paused ? 'paused' : 'disconnected';
await this.persistConnection(conn);
logger.info(`Connection ${paused ? 'paused' : 'resumed'}: ${conn.name}`);
return { ...conn, token: '***' };
}
async testConnection(id: string): Promise<{ ok: boolean; error?: string }> { async testConnection(id: string): Promise<{ ok: boolean; error?: string }> {
const conn = this.connections.find((c) => c.id === id)!;
if (conn.status === 'paused') {
return { ok: false, error: 'Connection is paused' };
}
const provider = this.getProvider(id); const provider = this.getProvider(id);
const result = await provider.testConnection(); const result = await provider.testConnection();
const conn = this.connections.find((c) => c.id === id)!;
conn.status = result.ok ? 'connected' : 'error'; conn.status = result.ok ? 'connected' : 'error';
await this.saveConnections(); // Resolve group filter ID if connection has a groupFilter
if (result.ok && conn.groupFilter) {
await this.resolveGroupFilterId(conn);
}
await this.persistConnection(conn);
return result; return result;
} }
/**
* Resolves a human-readable groupFilter to the provider-specific group ID.
*/
private async resolveGroupFilterId(conn: interfaces.data.IProviderConnection): Promise<void> {
if (!conn.groupFilter) {
conn.groupFilterId = undefined;
return;
}
try {
if (conn.providerType === 'gitlab') {
const gitlabClient = new plugins.gitlabClient.GitLabClient(conn.baseUrl, conn.token);
const group = await gitlabClient.getGroupByPath(conn.groupFilter);
conn.groupFilterId = String(group.id);
logger.info(`Resolved group filter "${conn.groupFilter}" to ID ${conn.groupFilterId}`);
} else {
// For Gitea, the org name IS the ID
conn.groupFilterId = conn.groupFilter;
logger.info(`Group filter for Gitea connection set to org "${conn.groupFilterId}"`);
}
} catch (err) {
logger.warn(`Failed to resolve group filter "${conn.groupFilter}": ${err}`);
conn.groupFilterId = undefined;
}
}
/** /**
* Factory: returns the correct provider instance for a connection ID * Factory: returns the correct provider instance for a connection ID
*/ */
@@ -104,9 +253,9 @@ export class ConnectionManager {
switch (conn.providerType) { switch (conn.providerType) {
case 'gitea': case 'gitea':
return new GiteaProvider(conn.id, conn.baseUrl, conn.token); return new GiteaProvider(conn.id, conn.baseUrl, conn.token, conn.groupFilterId);
case 'gitlab': case 'gitlab':
return new GitLabProvider(conn.id, conn.baseUrl, conn.token); return new GitLabProvider(conn.id, conn.baseUrl, conn.token, conn.groupFilterId);
default: default:
throw new Error(`Unknown provider type: ${conn.providerType}`); throw new Error(`Unknown provider type: ${conn.providerType}`);
} }

View File

@@ -1,25 +1,86 @@
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts'; import { logger } from '../logging.ts';
import { ConnectionManager } from './connectionmanager.ts'; import { ConnectionManager } from './connectionmanager.ts';
import { ActionLog } from './actionlog.ts';
import { SyncManager } from './syncmanager.ts';
import { OpsServer } from '../opsserver/index.ts'; import { OpsServer } from '../opsserver/index.ts';
import { StorageManager } from '../storage/index.ts';
import { CacheDb, CacheCleaner, CachedProject, CachedSecret, SecretsScanService } from '../cache/index.ts';
import { resolvePaths } from '../paths.ts';
/** /**
* Main GitOps application orchestrator * Main GitOps application orchestrator
*/ */
export class GitopsApp { export class GitopsApp {
public storageManager: StorageManager;
public smartSecret: plugins.smartsecret.SmartSecret;
public connectionManager: ConnectionManager; public connectionManager: ConnectionManager;
public actionLog: ActionLog;
public opsServer: OpsServer; public opsServer: OpsServer;
public cacheDb: CacheDb;
public cacheCleaner: CacheCleaner;
public syncManager!: SyncManager;
public secretsScanService!: SecretsScanService;
private scanIntervalId: number | null = null;
private paths: ReturnType<typeof resolvePaths>;
constructor() { constructor() {
this.connectionManager = new ConnectionManager(); const paths = resolvePaths();
this.paths = paths;
this.storageManager = new StorageManager({
backend: 'filesystem',
fsPath: paths.defaultStoragePath,
});
this.smartSecret = new plugins.smartsecret.SmartSecret({ service: 'gitops' });
this.connectionManager = new ConnectionManager(this.storageManager, this.smartSecret);
this.actionLog = new ActionLog(this.storageManager);
this.cacheDb = CacheDb.getInstance({
storagePath: paths.defaultTsmDbPath,
dbName: 'gitops_cache',
});
this.cacheCleaner = new CacheCleaner(this.cacheDb);
this.cacheCleaner.registerClass(CachedProject);
this.cacheCleaner.registerClass(CachedSecret);
this.opsServer = new OpsServer(this); this.opsServer = new OpsServer(this);
} }
async start(port = 3000): Promise<void> { async start(port = 3000): Promise<void> {
logger.info('Initializing GitOps...'); logger.info('Initializing GitOps...');
// Start CacheDb
await this.cacheDb.start();
// Initialize connection manager (loads saved connections) // Initialize connection manager (loads saved connections)
await this.connectionManager.init(); await this.connectionManager.init();
// Initialize sync manager
this.syncManager = new SyncManager(
this.storageManager,
this.connectionManager,
this.actionLog,
this.paths.syncMirrorsPath,
);
await this.syncManager.init();
// Initialize secrets scan service with 24h auto-scan
this.secretsScanService = new SecretsScanService(this.connectionManager);
const SCAN_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
this.scanIntervalId = setInterval(() => {
this.secretsScanService.fullScan().catch((err) => {
logger.error(`Scheduled secrets scan failed: ${err}`);
});
}, SCAN_INTERVAL_MS);
Deno.unrefTimer(this.scanIntervalId);
// Fire-and-forget initial scan (doesn't block startup)
this.secretsScanService.fullScan().catch((err) => {
logger.error(`Initial secrets scan failed: ${err}`);
});
// Start CacheCleaner
this.cacheCleaner.start();
// Start OpsServer // Start OpsServer
await this.opsServer.start(port); await this.opsServer.start(port);
@@ -28,7 +89,14 @@ export class GitopsApp {
async stop(): Promise<void> { async stop(): Promise<void> {
logger.info('Shutting down GitOps...'); logger.info('Shutting down GitOps...');
if (this.scanIntervalId !== null) {
clearInterval(this.scanIntervalId);
this.scanIntervalId = null;
}
await this.syncManager.stop();
await this.opsServer.stop(); await this.opsServer.stop();
this.cacheCleaner.stop();
await this.cacheDb.stop();
logger.success('GitOps shutdown complete'); logger.success('GitOps shutdown complete');
} }
} }

1600
ts/classes/syncmanager.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,15 +2,60 @@
* Logging utilities for GitOps * Logging utilities for GitOps
*/ */
import type { ISyncLogEntry } from '../ts_interfaces/data/sync.ts';
type LogLevel = 'info' | 'success' | 'warn' | 'error' | 'debug'; type LogLevel = 'info' | 'success' | 'warn' | 'error' | 'debug';
const SYNC_LOG_MAX = 500;
class Logger { class Logger {
private debugMode = false; private debugMode = false;
private syncLogBuffer: ISyncLogEntry[] = [];
private broadcastFn?: (entry: ISyncLogEntry) => void;
constructor() { constructor() {
this.debugMode = Deno.args.includes('--debug') || Deno.env.get('DEBUG') === 'true'; this.debugMode = Deno.args.includes('--debug') || Deno.env.get('DEBUG') === 'true';
} }
/**
* Set the broadcast function used to push sync log entries to connected clients.
*/
setBroadcastFn(fn: (entry: ISyncLogEntry) => void): void {
this.broadcastFn = fn;
}
/**
* Log a sync-related message to both the console and the ring buffer.
* Also broadcasts to connected frontends via TypedSocket if available.
*/
syncLog(level: ISyncLogEntry['level'], message: string, source?: string): void {
// Also log to console
this.log(level, message);
const entry: ISyncLogEntry = {
timestamp: Date.now(),
level,
message,
source,
};
this.syncLogBuffer.push(entry);
if (this.syncLogBuffer.length > SYNC_LOG_MAX) {
this.syncLogBuffer.splice(0, this.syncLogBuffer.length - SYNC_LOG_MAX);
}
if (this.broadcastFn) {
this.broadcastFn(entry);
}
}
/**
* Get recent sync log entries.
*/
getSyncLogs(limit = 100): ISyncLogEntry[] {
return this.syncLogBuffer.slice(-limit);
}
log(level: LogLevel, message: string, ...args: unknown[]): void { log(level: LogLevel, message: string, ...args: unknown[]): void {
const prefix = this.getPrefix(level); const prefix = this.getPrefix(level);
const formattedMessage = `${prefix} ${message}`; const formattedMessage = `${prefix} ${message}`;

View File

@@ -2,6 +2,7 @@ import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts'; import { logger } from '../logging.ts';
import type { GitopsApp } from '../classes/gitopsapp.ts'; import type { GitopsApp } from '../classes/gitopsapp.ts';
import * as handlers from './handlers/index.ts'; import * as handlers from './handlers/index.ts';
import { files as bundledFiles } from '../../ts_bundled/bundle.ts';
export class OpsServer { export class OpsServer {
public gitopsAppRef: GitopsApp; public gitopsAppRef: GitopsApp;
@@ -16,17 +17,27 @@ export class OpsServer {
public secretsHandler!: handlers.SecretsHandler; public secretsHandler!: handlers.SecretsHandler;
public pipelinesHandler!: handlers.PipelinesHandler; public pipelinesHandler!: handlers.PipelinesHandler;
public logsHandler!: handlers.LogsHandler; public logsHandler!: handlers.LogsHandler;
public webhookHandler!: handlers.WebhookHandler;
public actionsHandler!: handlers.ActionsHandler;
public actionLogHandler!: handlers.ActionLogHandler;
public syncHandler!: handlers.SyncHandler;
constructor(gitopsAppRef: GitopsApp) { constructor(gitopsAppRef: GitopsApp) {
this.gitopsAppRef = gitopsAppRef; this.gitopsAppRef = gitopsAppRef;
} }
public async start(port = 3000) { public async start(port = 3000) {
const absoluteServeDir = plugins.path.resolve('./dist_serve'); // Create webhook handler before server so routes register via addCustomRoutes
this.webhookHandler = new handlers.WebhookHandler(this);
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({ this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
domain: 'localhost', domain: 'localhost',
feedMetadata: undefined, feedMetadata: undefined,
serveDir: absoluteServeDir, bundledContent: bundledFiles,
noCache: true,
addCustomRoutes: async (typedserver) => {
this.webhookHandler.registerRoutes(typedserver);
},
}); });
// Chain typedrouters // Chain typedrouters
@@ -51,6 +62,9 @@ export class OpsServer {
this.secretsHandler = new handlers.SecretsHandler(this); this.secretsHandler = new handlers.SecretsHandler(this);
this.pipelinesHandler = new handlers.PipelinesHandler(this); this.pipelinesHandler = new handlers.PipelinesHandler(this);
this.logsHandler = new handlers.LogsHandler(this); this.logsHandler = new handlers.LogsHandler(this);
this.actionsHandler = new handlers.ActionsHandler(this);
this.actionLogHandler = new handlers.ActionLogHandler(this);
this.syncHandler = new handlers.SyncHandler(this);
logger.success('OpsServer TypedRequest handlers initialized'); logger.success('OpsServer TypedRequest handlers initialized');
} }

View File

@@ -0,0 +1,30 @@
import * as plugins from '../../plugins.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts';
export class ActionLogHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetActionLog>(
'getActionLog',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const result = await this.opsServerRef.gitopsAppRef.actionLog.query({
limit: dataArg.limit,
offset: dataArg.offset,
entityType: dataArg.entityType,
});
return result;
},
),
);
}
}

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

@@ -11,6 +11,10 @@ export class ConnectionsHandler {
this.registerHandlers(); this.registerHandlers();
} }
private get actionLog() {
return this.opsServerRef.gitopsAppRef.actionLog;
}
private registerHandlers(): void { private registerHandlers(): void {
// Get all connections // Get all connections
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
@@ -35,7 +39,16 @@ export class ConnectionsHandler {
dataArg.providerType, dataArg.providerType,
dataArg.baseUrl, dataArg.baseUrl,
dataArg.token, dataArg.token,
dataArg.groupFilter,
); );
this.actionLog.append({
actionType: 'create',
entityType: 'connection',
entityId: connection.id,
entityName: connection.name,
details: `Created ${dataArg.providerType} connection "${dataArg.name}" (${dataArg.baseUrl})`,
username: dataArg.identity.username,
});
return { connection }; return { connection };
}, },
), ),
@@ -53,8 +66,46 @@ export class ConnectionsHandler {
name: dataArg.name, name: dataArg.name,
baseUrl: dataArg.baseUrl, baseUrl: dataArg.baseUrl,
token: dataArg.token, token: dataArg.token,
groupFilter: dataArg.groupFilter,
}, },
); );
const fields = [
dataArg.name && 'name',
dataArg.baseUrl && 'baseUrl',
dataArg.token && 'token',
dataArg.groupFilter !== undefined && 'groupFilter',
].filter(Boolean).join(', ');
this.actionLog.append({
actionType: 'update',
entityType: 'connection',
entityId: dataArg.connectionId,
entityName: connection.name,
details: `Updated connection "${connection.name}" (fields: ${fields})`,
username: dataArg.identity.username,
});
return { connection };
},
),
);
// Pause/resume connection
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PauseConnection>(
'pauseConnection',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const connection = await this.opsServerRef.gitopsAppRef.connectionManager.pauseConnection(
dataArg.connectionId,
dataArg.paused,
);
this.actionLog.append({
actionType: dataArg.paused ? 'pause' : 'resume',
entityType: 'connection',
entityId: dataArg.connectionId,
entityName: connection.name,
details: `${dataArg.paused ? 'Paused' : 'Resumed'} connection "${connection.name}"`,
username: dataArg.identity.username,
});
return { connection }; return { connection };
}, },
), ),
@@ -69,6 +120,16 @@ export class ConnectionsHandler {
const result = await this.opsServerRef.gitopsAppRef.connectionManager.testConnection( const result = await this.opsServerRef.gitopsAppRef.connectionManager.testConnection(
dataArg.connectionId, dataArg.connectionId,
); );
const conn = this.opsServerRef.gitopsAppRef.connectionManager.getConnections()
.find((c) => c.id === dataArg.connectionId);
this.actionLog.append({
actionType: 'test',
entityType: 'connection',
entityId: dataArg.connectionId,
entityName: conn?.name || dataArg.connectionId,
details: `Tested connection: ${result.ok ? 'success' : `failed — ${result.error || 'unknown error'}`}`,
username: dataArg.identity.username,
});
return result; return result;
}, },
), ),
@@ -80,9 +141,19 @@ export class ConnectionsHandler {
'deleteConnection', 'deleteConnection',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const conn = this.opsServerRef.gitopsAppRef.connectionManager.getConnections()
.find((c) => c.id === dataArg.connectionId);
await this.opsServerRef.gitopsAppRef.connectionManager.deleteConnection( await this.opsServerRef.gitopsAppRef.connectionManager.deleteConnection(
dataArg.connectionId, dataArg.connectionId,
); );
this.actionLog.append({
actionType: 'delete',
entityType: 'connection',
entityId: dataArg.connectionId,
entityName: conn?.name || dataArg.connectionId,
details: `Deleted connection "${conn?.name || dataArg.connectionId}"`,
username: dataArg.identity.username,
});
return { ok: true }; return { ok: true };
}, },
), ),

View File

@@ -5,3 +5,7 @@ export { GroupsHandler } from './groups.handler.ts';
export { SecretsHandler } from './secrets.handler.ts'; export { SecretsHandler } from './secrets.handler.ts';
export { PipelinesHandler } from './pipelines.handler.ts'; export { PipelinesHandler } from './pipelines.handler.ts';
export { LogsHandler } from './logs.handler.ts'; export { LogsHandler } from './logs.handler.ts';
export { WebhookHandler } from './webhook.handler.ts';
export { ActionsHandler } from './actions.handler.ts';
export { ActionLogHandler } from './actionlog.handler.ts';
export { SyncHandler } from './sync.handler.ts';

View File

@@ -11,6 +11,10 @@ export class PipelinesHandler {
this.registerHandlers(); this.registerHandlers();
} }
private get actionLog() {
return this.opsServerRef.gitopsAppRef.actionLog;
}
private registerHandlers(): void { private registerHandlers(): void {
// Get pipelines // Get pipelines
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
@@ -54,6 +58,14 @@ export class PipelinesHandler {
dataArg.connectionId, dataArg.connectionId,
); );
await provider.retryPipeline(dataArg.projectId, dataArg.pipelineId); await provider.retryPipeline(dataArg.projectId, dataArg.pipelineId);
this.actionLog.append({
actionType: 'update',
entityType: 'pipeline',
entityId: dataArg.pipelineId,
entityName: `Pipeline #${dataArg.pipelineId}`,
details: `Retried pipeline #${dataArg.pipelineId} in project ${dataArg.projectId}`,
username: dataArg.identity.username,
});
return { ok: true }; return { ok: true };
}, },
), ),
@@ -69,6 +81,14 @@ export class PipelinesHandler {
dataArg.connectionId, dataArg.connectionId,
); );
await provider.cancelPipeline(dataArg.projectId, dataArg.pipelineId); await provider.cancelPipeline(dataArg.projectId, dataArg.pipelineId);
this.actionLog.append({
actionType: 'delete',
entityType: 'pipeline',
entityId: dataArg.pipelineId,
entityName: `Pipeline #${dataArg.pipelineId}`,
details: `Cancelled pipeline #${dataArg.pipelineId} in project ${dataArg.projectId}`,
username: dataArg.identity.username,
});
return { ok: true }; return { ok: true };
}, },
), ),

View File

@@ -11,19 +11,125 @@ export class SecretsHandler {
this.registerHandlers(); this.registerHandlers();
} }
private get actionLog() {
return this.opsServerRef.gitopsAppRef.actionLog;
}
private registerHandlers(): void { private registerHandlers(): void {
// Get secrets // Get all secrets (cache-first, falls back to live fetch)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAllSecrets>(
'getAllSecrets',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const scanService = this.opsServerRef.gitopsAppRef.secretsScanService;
// Try cache first
const hasCached = await scanService.hasCachedData(dataArg.connectionId, dataArg.scope);
if (hasCached) {
const secrets = await scanService.getCachedSecrets({
connectionId: dataArg.connectionId,
scope: dataArg.scope,
});
return { secrets };
}
// Cache miss: live fetch and save to cache
const provider = this.opsServerRef.gitopsAppRef.connectionManager.getProvider(
dataArg.connectionId,
);
const allSecrets: interfaces.data.ISecret[] = [];
if (dataArg.scope === 'project') {
const projects = await provider.getProjects();
for (let i = 0; i < projects.length; i += 5) {
const batch = projects.slice(i, i + 5);
const results = await Promise.allSettled(
batch.map(async (p) => {
const secrets = await provider.getProjectSecrets(p.id);
return secrets.map((s) => ({
...s,
scopeName: p.fullPath || p.name,
scope: 'project' as const,
scopeId: p.id,
connectionId: dataArg.connectionId,
}));
}),
);
for (const result of results) {
if (result.status === 'fulfilled') {
allSecrets.push(...result.value);
}
}
}
} else {
const groups = await provider.getGroups();
for (let i = 0; i < groups.length; i += 5) {
const batch = groups.slice(i, i + 5);
const results = await Promise.allSettled(
batch.map(async (g) => {
const secrets = await provider.getGroupSecrets(g.id);
return secrets.map((s) => ({
...s,
scopeName: g.fullPath || g.name,
scope: 'group' as const,
scopeId: g.id,
connectionId: dataArg.connectionId,
}));
}),
);
for (const result of results) {
if (result.status === 'fulfilled') {
allSecrets.push(...result.value);
}
}
}
}
// Save fetched secrets to cache (fire-and-forget)
scanService.saveSecrets(allSecrets).catch(() => {});
return { secrets: allSecrets };
},
),
);
// Get secrets (cache-first for single entity)
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecrets>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecrets>(
'getSecrets', 'getSecrets',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const scanService = this.opsServerRef.gitopsAppRef.secretsScanService;
// Try cache first
const cached = await scanService.getCachedSecrets({
connectionId: dataArg.connectionId,
scope: dataArg.scope,
scopeId: dataArg.scopeId,
});
if (cached.length > 0) {
return { secrets: cached };
}
// Cache miss: live fetch
const provider = this.opsServerRef.gitopsAppRef.connectionManager.getProvider( const provider = this.opsServerRef.gitopsAppRef.connectionManager.getProvider(
dataArg.connectionId, dataArg.connectionId,
); );
const secrets = dataArg.scope === 'project' const secrets = dataArg.scope === 'project'
? await provider.getProjectSecrets(dataArg.scopeId) ? await provider.getProjectSecrets(dataArg.scopeId)
: await provider.getGroupSecrets(dataArg.scopeId); : await provider.getGroupSecrets(dataArg.scopeId);
// Save to cache (fire-and-forget)
const fullSecrets = secrets.map((s) => ({
...s,
scope: dataArg.scope,
scopeId: dataArg.scopeId,
connectionId: dataArg.connectionId,
}));
scanService.saveSecrets(fullSecrets).catch(() => {});
return { secrets }; return { secrets };
}, },
), ),
@@ -41,6 +147,17 @@ export class SecretsHandler {
const secret = dataArg.scope === 'project' const secret = dataArg.scope === 'project'
? await provider.createProjectSecret(dataArg.scopeId, dataArg.key, dataArg.value) ? await provider.createProjectSecret(dataArg.scopeId, dataArg.key, dataArg.value)
: await provider.createGroupSecret(dataArg.scopeId, dataArg.key, dataArg.value); : await provider.createGroupSecret(dataArg.scopeId, dataArg.key, dataArg.value);
// Refresh cache for this entity
const scanService = this.opsServerRef.gitopsAppRef.secretsScanService;
scanService.scanEntity(dataArg.connectionId, dataArg.scope, dataArg.scopeId).catch(() => {});
this.actionLog.append({
actionType: 'create',
entityType: 'secret',
entityId: `${dataArg.scopeId}/${dataArg.key}`,
entityName: dataArg.key,
details: `Created ${dataArg.scope} secret "${dataArg.key}" in ${dataArg.scopeId}`,
username: dataArg.identity.username,
});
return { secret }; return { secret };
}, },
), ),
@@ -58,6 +175,17 @@ export class SecretsHandler {
const secret = dataArg.scope === 'project' const secret = dataArg.scope === 'project'
? await provider.updateProjectSecret(dataArg.scopeId, dataArg.key, dataArg.value) ? await provider.updateProjectSecret(dataArg.scopeId, dataArg.key, dataArg.value)
: await provider.updateGroupSecret(dataArg.scopeId, dataArg.key, dataArg.value); : await provider.updateGroupSecret(dataArg.scopeId, dataArg.key, dataArg.value);
// Refresh cache for this entity
const scanService = this.opsServerRef.gitopsAppRef.secretsScanService;
scanService.scanEntity(dataArg.connectionId, dataArg.scope, dataArg.scopeId).catch(() => {});
this.actionLog.append({
actionType: 'update',
entityType: 'secret',
entityId: `${dataArg.scopeId}/${dataArg.key}`,
entityName: dataArg.key,
details: `Updated ${dataArg.scope} secret "${dataArg.key}" in ${dataArg.scopeId}`,
username: dataArg.identity.username,
});
return { secret }; return { secret };
}, },
), ),
@@ -77,6 +205,17 @@ export class SecretsHandler {
} else { } else {
await provider.deleteGroupSecret(dataArg.scopeId, dataArg.key); await provider.deleteGroupSecret(dataArg.scopeId, dataArg.key);
} }
// Refresh cache for this entity
const scanService = this.opsServerRef.gitopsAppRef.secretsScanService;
scanService.scanEntity(dataArg.connectionId, dataArg.scope, dataArg.scopeId).catch(() => {});
this.actionLog.append({
actionType: 'delete',
entityType: 'secret',
entityId: `${dataArg.scopeId}/${dataArg.key}`,
entityName: dataArg.key,
details: `Deleted ${dataArg.scope} secret "${dataArg.key}" from ${dataArg.scopeId}`,
username: dataArg.identity.username,
});
return { ok: true }; return { ok: true };
}, },
), ),

View File

@@ -0,0 +1,222 @@
import * as plugins from '../../plugins.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts';
import { logger } from '../../logging.ts';
export class SyncHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
this.setupBroadcast();
}
/**
* Wire up the logger's broadcast function to push sync log entries
* to all connected frontends via TypedSocket.
*/
private setupBroadcast(): void {
logger.setBroadcastFn((entry) => {
try {
const typedsocket = this.opsServerRef.server?.typedserver?.typedsocket;
if (!typedsocket) return;
typedsocket.findAllTargetConnectionsByTag('allClients').then((connections) => {
for (const conn of connections) {
typedsocket
.createTypedRequest<interfaces.requests.IReq_PushSyncLog>('pushSyncLog', conn)
.fire({ entry })
.catch(() => {});
}
}).catch(() => {});
} catch {
// Server may not be ready yet — ignore
}
});
}
private get syncManager() {
return this.opsServerRef.gitopsAppRef.syncManager;
}
private get actionLog() {
return this.opsServerRef.gitopsAppRef.actionLog;
}
private registerHandlers(): void {
// Get all sync configs
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSyncConfigs>(
'getSyncConfigs',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
return { configs: this.syncManager.getConfigs() };
},
),
);
// Create sync config
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateSyncConfig>(
'createSyncConfig',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const config = await this.syncManager.createConfig({
name: dataArg.name,
sourceConnectionId: dataArg.sourceConnectionId,
targetConnectionId: dataArg.targetConnectionId,
targetGroupOffset: dataArg.targetGroupOffset,
intervalMinutes: dataArg.intervalMinutes,
enforceDelete: dataArg.enforceDelete,
enforceGroupDelete: dataArg.enforceGroupDelete,
addMirrorHint: dataArg.addMirrorHint,
});
this.actionLog.append({
actionType: 'create',
entityType: 'sync',
entityId: config.id,
entityName: config.name,
details: `Created sync config "${config.name}" (${config.intervalMinutes}m interval)`,
username: dataArg.identity.username,
});
return { config };
},
),
);
// Update sync config
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSyncConfig>(
'updateSyncConfig',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const config = await this.syncManager.updateConfig(dataArg.syncConfigId, {
name: dataArg.name,
targetGroupOffset: dataArg.targetGroupOffset,
intervalMinutes: dataArg.intervalMinutes,
enforceDelete: dataArg.enforceDelete,
enforceGroupDelete: dataArg.enforceGroupDelete,
addMirrorHint: dataArg.addMirrorHint,
});
this.actionLog.append({
actionType: 'update',
entityType: 'sync',
entityId: config.id,
entityName: config.name,
details: `Updated sync config "${config.name}"`,
username: dataArg.identity.username,
});
return { config };
},
),
);
// Delete sync config
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteSyncConfig>(
'deleteSyncConfig',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const config = this.syncManager.getConfig(dataArg.syncConfigId);
await this.syncManager.deleteConfig(dataArg.syncConfigId);
this.actionLog.append({
actionType: 'delete',
entityType: 'sync',
entityId: dataArg.syncConfigId,
entityName: config?.name || dataArg.syncConfigId,
details: `Deleted sync config "${config?.name || dataArg.syncConfigId}"`,
username: dataArg.identity.username,
});
return { ok: true };
},
),
);
// Pause/resume sync config
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PauseSyncConfig>(
'pauseSyncConfig',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const config = await this.syncManager.pauseConfig(
dataArg.syncConfigId,
dataArg.paused,
);
this.actionLog.append({
actionType: dataArg.paused ? 'pause' : 'resume',
entityType: 'sync',
entityId: config.id,
entityName: config.name,
details: `${dataArg.paused ? 'Paused' : 'Resumed'} sync config "${config.name}"`,
username: dataArg.identity.username,
});
return { config };
},
),
);
// Trigger sync manually
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TriggerSync>(
'triggerSync',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const config = this.syncManager.getConfig(dataArg.syncConfigId);
if (!config) {
return { ok: false, message: 'Sync config not found' };
}
// Fire and forget — force=true bypasses paused check for manual triggers
this.syncManager.executeSync(dataArg.syncConfigId, true).catch((err) => {
console.error(`Manual sync trigger failed: ${err}`);
});
this.actionLog.append({
actionType: 'sync',
entityType: 'sync',
entityId: config.id,
entityName: config.name,
details: `Manually triggered sync "${config.name}"`,
username: dataArg.identity.username,
});
return { ok: true, message: 'Sync triggered' };
},
),
);
// Preview sync (dry run — shows source → target mappings)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PreviewSync>(
'previewSync',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const result = await this.syncManager.previewSync(dataArg.syncConfigId);
return { mappings: result.mappings, deletions: result.deletions, groupDeletions: result.groupDeletions };
},
),
);
// Get repo statuses for a sync config
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSyncRepoStatuses>(
'getSyncRepoStatuses',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const statuses = await this.syncManager.getRepoStatuses(dataArg.syncConfigId);
return { statuses };
},
),
);
// Get recent sync log entries
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSyncLogs>(
'getSyncLogs',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const logs = logger.getSyncLogs(dataArg.limit || 200);
return { logs };
},
),
);
}
}

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

21
ts/paths.ts Normal file
View File

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

View File

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

View File

@@ -16,11 +16,16 @@ export interface IListOptions {
* Subclasses implement Gitea API v1 or GitLab API v4. * Subclasses implement Gitea API v1 or GitLab API v4.
*/ */
export abstract class BaseProvider { export abstract class BaseProvider {
public readonly groupFilterId?: string;
constructor( constructor(
public readonly connectionId: string, public readonly connectionId: string,
public readonly baseUrl: string, public readonly baseUrl: string,
protected readonly token: string, protected readonly token: string,
) {} groupFilterId?: string,
) {
this.groupFilterId = groupFilterId;
}
// Connection // Connection
abstract testConnection(): Promise<ITestConnectionResult>; abstract testConnection(): Promise<ITestConnectionResult>;

View File

@@ -8,8 +8,8 @@ import { BaseProvider, type ITestConnectionResult, type IListOptions } from './c
export class GiteaProvider extends BaseProvider { export class GiteaProvider extends BaseProvider {
private client: plugins.giteaClient.GiteaClient; private client: plugins.giteaClient.GiteaClient;
constructor(connectionId: string, baseUrl: string, token: string) { constructor(connectionId: string, baseUrl: string, token: string, groupFilterId?: string) {
super(connectionId, baseUrl, token); super(connectionId, baseUrl, token, groupFilterId);
this.client = new plugins.giteaClient.GiteaClient(baseUrl, token); this.client = new plugins.giteaClient.GiteaClient(baseUrl, token);
} }
@@ -18,13 +18,56 @@ export class GiteaProvider extends BaseProvider {
} }
async getProjects(opts?: IListOptions): Promise<interfaces.data.IProject[]> { async getProjects(opts?: IListOptions): Promise<interfaces.data.IProject[]> {
const repos = await this.client.getRepos(opts); // Use org-scoped listing when groupFilterId is set
return repos.map((r) => this.mapProject(r)); const fetchFn = this.groupFilterId
? (o: IListOptions) => this.client.getOrgRepos(this.groupFilterId!, o)
: (o: IListOptions) => this.client.getRepos(o);
// If caller explicitly requests a specific page, respect it (no auto-pagination)
if (opts?.page) {
const repos = await fetchFn(opts);
return repos.map((r) => this.mapProject(r));
}
const allRepos: plugins.giteaClient.IGiteaRepository[] = [];
const perPage = opts?.perPage || 50;
let page = 1;
while (true) {
const repos = await fetchFn({ ...opts, page, perPage });
allRepos.push(...repos);
if (repos.length < perPage) break;
page++;
}
return allRepos.map((r) => this.mapProject(r));
} }
async getGroups(opts?: IListOptions): Promise<interfaces.data.IGroup[]> { async getGroups(opts?: IListOptions): Promise<interfaces.data.IGroup[]> {
const orgs = await this.client.getOrgs(opts); // When groupFilterId is set, return only that single org
return orgs.map((o) => this.mapGroup(o)); if (this.groupFilterId) {
const org = await this.client.getOrg(this.groupFilterId);
return [this.mapGroup(org)];
}
// If caller explicitly requests a specific page, respect it (no auto-pagination)
if (opts?.page) {
const orgs = await this.client.getOrgs(opts);
return orgs.map((o) => this.mapGroup(o));
}
const allOrgs: plugins.giteaClient.IGiteaOrganization[] = [];
const perPage = opts?.perPage || 50;
let page = 1;
while (true) {
const orgs = await this.client.getOrgs({ ...opts, page, perPage });
allOrgs.push(...orgs);
if (orgs.length < perPage) break;
page++;
}
return allOrgs.map((o) => this.mapGroup(o));
} }
// --- Project Secrets --- // --- Project Secrets ---
@@ -40,7 +83,7 @@ export class GiteaProvider extends BaseProvider {
value: string, value: string,
): Promise<interfaces.data.ISecret> { ): Promise<interfaces.data.ISecret> {
await this.client.setRepoSecret(projectId, key, value); await this.client.setRepoSecret(projectId, key, value);
return { key, value: '***', protected: false, masked: true, scope: 'project', scopeId: projectId, connectionId: this.connectionId, environment: '*' }; return { key, value: '***', protected: false, masked: true, scope: 'project', scopeId: projectId, scopeName: projectId, connectionId: this.connectionId, environment: '*' };
} }
async updateProjectSecret( async updateProjectSecret(
@@ -68,7 +111,7 @@ export class GiteaProvider extends BaseProvider {
value: string, value: string,
): Promise<interfaces.data.ISecret> { ): Promise<interfaces.data.ISecret> {
await this.client.setOrgSecret(groupId, key, value); await this.client.setOrgSecret(groupId, key, value);
return { key, value: '***', protected: false, masked: true, scope: 'group', scopeId: groupId, connectionId: this.connectionId, environment: '*' }; return { key, value: '***', protected: false, masked: true, scope: 'group', scopeId: groupId, scopeName: groupId, connectionId: this.connectionId, environment: '*' };
} }
async updateGroupSecret( async updateGroupSecret(
@@ -117,7 +160,7 @@ export class GiteaProvider extends BaseProvider {
private mapProject(r: plugins.giteaClient.IGiteaRepository): interfaces.data.IProject { private mapProject(r: plugins.giteaClient.IGiteaRepository): interfaces.data.IProject {
return { return {
id: String(r.id), id: r.full_name || String(r.id),
name: r.name || '', name: r.name || '',
fullPath: r.full_name || '', fullPath: r.full_name || '',
description: r.description || '', description: r.description || '',
@@ -132,7 +175,7 @@ export class GiteaProvider extends BaseProvider {
private mapGroup(o: plugins.giteaClient.IGiteaOrganization): interfaces.data.IGroup { private mapGroup(o: plugins.giteaClient.IGiteaOrganization): interfaces.data.IGroup {
return { return {
id: String(o.id || o.name), id: o.name || String(o.id),
name: o.name || '', name: o.name || '',
fullPath: o.name || '', fullPath: o.name || '',
description: o.description || '', description: o.description || '',
@@ -143,7 +186,7 @@ export class GiteaProvider extends BaseProvider {
}; };
} }
private mapSecret(s: plugins.giteaClient.IGiteaSecret, scope: 'project' | 'group', scopeId: string): interfaces.data.ISecret { private mapSecret(s: plugins.giteaClient.IGiteaSecret, scope: 'project' | 'group', scopeId: string, scopeName?: string): interfaces.data.ISecret {
return { return {
key: s.name || '', key: s.name || '',
value: '***', value: '***',
@@ -151,6 +194,7 @@ export class GiteaProvider extends BaseProvider {
masked: true, masked: true,
scope, scope,
scopeId, scopeId,
scopeName: scopeName || scopeId,
connectionId: this.connectionId, connectionId: this.connectionId,
environment: '*', environment: '*',
}; };

View File

@@ -8,8 +8,8 @@ import { BaseProvider, type ITestConnectionResult, type IListOptions } from './c
export class GitLabProvider extends BaseProvider { export class GitLabProvider extends BaseProvider {
private client: plugins.gitlabClient.GitLabClient; private client: plugins.gitlabClient.GitLabClient;
constructor(connectionId: string, baseUrl: string, token: string) { constructor(connectionId: string, baseUrl: string, token: string, groupFilterId?: string) {
super(connectionId, baseUrl, token); super(connectionId, baseUrl, token, groupFilterId);
this.client = new plugins.gitlabClient.GitLabClient(baseUrl, token); this.client = new plugins.gitlabClient.GitLabClient(baseUrl, token);
} }
@@ -18,13 +18,71 @@ export class GitLabProvider extends BaseProvider {
} }
async getProjects(opts?: IListOptions): Promise<interfaces.data.IProject[]> { async getProjects(opts?: IListOptions): Promise<interfaces.data.IProject[]> {
const projects = await this.client.getProjects(opts); if (this.groupFilterId) {
return projects.map((p) => this.mapProject(p)); // Auto-paginate group-scoped project listing
if (opts?.page) {
const projects = await this.client.getGroupProjects(this.groupFilterId, opts);
return projects.map((p) => this.mapProject(p));
}
const allProjects: plugins.gitlabClient.IGitLabProject[] = [];
const perPage = opts?.perPage || 50;
let page = 1;
while (true) {
const projects = await this.client.getGroupProjects(this.groupFilterId, { ...opts, page, perPage });
allProjects.push(...projects);
if (projects.length < perPage) break;
page++;
}
return allProjects.map((p) => this.mapProject(p));
}
if (opts?.page) {
const projects = await this.client.getProjects(opts);
return projects.map((p) => this.mapProject(p));
}
const allProjects: plugins.gitlabClient.IGitLabProject[] = [];
const perPage = opts?.perPage || 50;
let page = 1;
while (true) {
const projects = await this.client.getProjects({ ...opts, page, perPage });
allProjects.push(...projects);
if (projects.length < perPage) break;
page++;
}
return allProjects.map((p) => this.mapProject(p));
} }
async getGroups(opts?: IListOptions): Promise<interfaces.data.IGroup[]> { async getGroups(opts?: IListOptions): Promise<interfaces.data.IGroup[]> {
const groups = await this.client.getGroups(opts); if (this.groupFilterId) {
return groups.map((g) => this.mapGroup(g)); // Auto-paginate descendant groups listing
if (opts?.page) {
const groups = await this.client.getDescendantGroups(this.groupFilterId, opts);
return groups.map((g) => this.mapGroup(g));
}
const allGroups: plugins.gitlabClient.IGitLabGroup[] = [];
const perPage = opts?.perPage || 50;
let page = 1;
while (true) {
const groups = await this.client.getDescendantGroups(this.groupFilterId, { ...opts, page, perPage });
allGroups.push(...groups);
if (groups.length < perPage) break;
page++;
}
return allGroups.map((g) => this.mapGroup(g));
}
if (opts?.page) {
const groups = await this.client.getGroups(opts);
return groups.map((g) => this.mapGroup(g));
}
const allGroups: plugins.gitlabClient.IGitLabGroup[] = [];
const perPage = opts?.perPage || 50;
let page = 1;
while (true) {
const groups = await this.client.getGroups({ ...opts, page, perPage });
allGroups.push(...groups);
if (groups.length < perPage) break;
page++;
}
return allGroups.map((g) => this.mapGroup(g));
} }
// --- Project Secrets (CI/CD Variables) --- // --- Project Secrets (CI/CD Variables) ---
@@ -149,6 +207,7 @@ export class GitLabProvider extends BaseProvider {
v: plugins.gitlabClient.IGitLabVariable, v: plugins.gitlabClient.IGitLabVariable,
scope: 'project' | 'group', scope: 'project' | 'group',
scopeId: string, scopeId: string,
scopeName?: string,
): interfaces.data.ISecret { ): interfaces.data.ISecret {
return { return {
key: v.key || '', key: v.key || '',
@@ -157,6 +216,7 @@ export class GitLabProvider extends BaseProvider {
masked: v.masked || false, masked: v.masked || false,
scope, scope,
scopeId, scopeId,
scopeName: scopeName || scopeId,
connectionId: this.connectionId, connectionId: this.connectionId,
environment: v.environment_scope || '*', environment: v.environment_scope || '*',
}; };

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

@@ -0,0 +1,13 @@
export type TActionType = 'create' | 'update' | 'delete' | 'pause' | 'resume' | 'test' | 'scan' | 'sync' | 'obsolete';
export type TActionEntity = 'connection' | 'secret' | 'pipeline' | 'sync';
export interface IActionLogEntry {
id: string;
timestamp: number;
actionType: TActionType;
entityType: TActionEntity;
entityId: string;
entityName: string;
details: string;
username: string;
}

View File

@@ -7,5 +7,7 @@ export interface IProviderConnection {
baseUrl: string; baseUrl: string;
token: string; token: string;
createdAt: number; createdAt: number;
status: 'connected' | 'disconnected' | 'error'; status: 'connected' | 'disconnected' | 'error' | 'paused';
groupFilter?: string; // Restricts which repos this connection can see (e.g. "foss.global")
groupFilterId?: string; // Resolved filter group ID (numeric for GitLab, org name for Gitea)
} }

View File

@@ -4,3 +4,5 @@ export * from './project.ts';
export * from './group.ts'; export * from './group.ts';
export * from './secret.ts'; export * from './secret.ts';
export * from './pipeline.ts'; export * from './pipeline.ts';
export * from './actionlog.ts';
export * from './sync.ts';

View File

@@ -5,6 +5,7 @@ export interface ISecret {
masked: boolean; masked: boolean;
scope: 'project' | 'group'; scope: 'project' | 'group';
scopeId: string; scopeId: string;
scopeName: string;
connectionId: string; connectionId: string;
environment: string; environment: string;
} }

View File

@@ -0,0 +1,36 @@
export type TSyncStatus = 'active' | 'paused' | 'error';
export interface ISyncConfig {
id: string;
name: string;
sourceConnectionId: string;
targetConnectionId: string;
targetGroupOffset?: string; // Path prefix for target repos (e.g. "mirror/gitlab")
intervalMinutes: number; // Default 5
status: TSyncStatus;
lastSyncAt: number;
lastSyncError?: string;
lastSyncDurationMs?: number;
reposSynced: number;
enforceDelete: boolean; // When true, stale target repos are moved to obsolete
enforceGroupDelete: boolean; // When true, stale target groups/orgs are moved to obsolete
addMirrorHint?: boolean; // When true, target descriptions get "(This is a mirror of ...)" appended
createdAt: number;
}
export interface ISyncRepoStatus {
id: string;
syncConfigId: string;
sourceFullPath: string; // e.g. "push.rocks/smartstate"
targetFullPath: string; // e.g. "foss.global/push.rocks/smartstate"
lastSyncAt: number;
lastSyncError?: string;
status: 'synced' | 'error' | 'pending';
}
export interface ISyncLogEntry {
timestamp: number;
level: 'info' | 'warn' | 'error' | 'success' | 'debug';
message: string;
source?: string; // e.g. 'preview', 'sync', 'git', 'api'
}

View File

@@ -0,0 +1,19 @@
import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts';
export interface IReq_GetActionLog extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetActionLog
> {
method: 'getActionLog';
request: {
identity: data.IIdentity;
limit?: number;
offset?: number;
entityType?: data.TActionEntity;
};
response: {
entries: data.IActionLogEntry[];
total: number;
};
}

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

@@ -25,6 +25,7 @@ export interface IReq_CreateConnection extends plugins.typedrequestInterfaces.im
providerType: data.TProviderType; providerType: data.TProviderType;
baseUrl: string; baseUrl: string;
token: string; token: string;
groupFilter?: string;
}; };
response: { response: {
connection: data.IProviderConnection; connection: data.IProviderConnection;
@@ -42,6 +43,7 @@ export interface IReq_UpdateConnection extends plugins.typedrequestInterfaces.im
name?: string; name?: string;
baseUrl?: string; baseUrl?: string;
token?: string; token?: string;
groupFilter?: string;
}; };
response: { response: {
connection: data.IProviderConnection; connection: data.IProviderConnection;
@@ -63,6 +65,21 @@ export interface IReq_TestConnection extends plugins.typedrequestInterfaces.impl
}; };
} }
export interface IReq_PauseConnection extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_PauseConnection
> {
method: 'pauseConnection';
request: {
identity: data.IIdentity;
connectionId: string;
paused: boolean;
};
response: {
connection: data.IProviderConnection;
};
}
export interface IReq_DeleteConnection extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_DeleteConnection extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteConnection IReq_DeleteConnection

View File

@@ -5,3 +5,7 @@ export * from './groups.ts';
export * from './secrets.ts'; export * from './secrets.ts';
export * from './pipelines.ts'; export * from './pipelines.ts';
export * from './logs.ts'; export * from './logs.ts';
export * from './webhook.ts';
export * from './actions.ts';
export * from './actionlog.ts';
export * from './sync.ts';

View File

@@ -1,6 +1,21 @@
import * as plugins from '../plugins.ts'; import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts'; import * as data from '../data/index.ts';
export interface IReq_GetAllSecrets extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetAllSecrets
> {
method: 'getAllSecrets';
request: {
identity: data.IIdentity;
connectionId: string;
scope: 'project' | 'group';
};
response: {
secrets: data.ISecret[];
};
}
export interface IReq_GetSecrets extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_GetSecrets extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetSecrets IReq_GetSecrets

View File

@@ -0,0 +1,155 @@
import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts';
export interface IReq_GetSyncConfigs extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetSyncConfigs
> {
method: 'getSyncConfigs';
request: {
identity: data.IIdentity;
};
response: {
configs: data.ISyncConfig[];
};
}
export interface IReq_CreateSyncConfig extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateSyncConfig
> {
method: 'createSyncConfig';
request: {
identity: data.IIdentity;
name: string;
sourceConnectionId: string;
targetConnectionId: string;
targetGroupOffset?: string;
intervalMinutes?: number;
enforceDelete?: boolean;
enforceGroupDelete?: boolean;
addMirrorHint?: boolean;
};
response: {
config: data.ISyncConfig;
};
}
export interface IReq_UpdateSyncConfig extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpdateSyncConfig
> {
method: 'updateSyncConfig';
request: {
identity: data.IIdentity;
syncConfigId: string;
name?: string;
targetGroupOffset?: string;
intervalMinutes?: number;
enforceDelete?: boolean;
enforceGroupDelete?: boolean;
addMirrorHint?: boolean;
};
response: {
config: data.ISyncConfig;
};
}
export interface IReq_DeleteSyncConfig extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteSyncConfig
> {
method: 'deleteSyncConfig';
request: {
identity: data.IIdentity;
syncConfigId: string;
};
response: {
ok: boolean;
};
}
export interface IReq_PauseSyncConfig extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_PauseSyncConfig
> {
method: 'pauseSyncConfig';
request: {
identity: data.IIdentity;
syncConfigId: string;
paused: boolean;
};
response: {
config: data.ISyncConfig;
};
}
export interface IReq_TriggerSync extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_TriggerSync
> {
method: 'triggerSync';
request: {
identity: data.IIdentity;
syncConfigId: string;
};
response: {
ok: boolean;
message: string;
};
}
export interface IReq_PreviewSync extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_PreviewSync
> {
method: 'previewSync';
request: {
identity: data.IIdentity;
syncConfigId: string;
};
response: {
mappings: Array<{ sourceFullPath: string; targetFullPath: string }>;
deletions: string[];
groupDeletions: string[];
};
}
export interface IReq_GetSyncRepoStatuses extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetSyncRepoStatuses
> {
method: 'getSyncRepoStatuses';
request: {
identity: data.IIdentity;
syncConfigId: string;
};
response: {
statuses: data.ISyncRepoStatus[];
};
}
export interface IReq_GetSyncLogs extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetSyncLogs
> {
method: 'getSyncLogs';
request: {
identity: data.IIdentity;
limit?: number;
};
response: {
logs: data.ISyncLogEntry[];
};
}
export interface IReq_PushSyncLog extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_PushSyncLog
> {
method: 'pushSyncLog';
request: {
entry: data.ISyncLogEntry;
};
response: {};
}

View File

@@ -0,0 +1,18 @@
import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts';
export interface IReq_WebhookNotification extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_WebhookNotification
> {
method: 'webhookNotification';
request: {
connectionId: string;
provider: string;
event: string;
timestamp: number;
};
response: {
ok: boolean;
};
}

View File

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

View File

@@ -29,10 +29,23 @@ export interface IDataState {
currentJobLog: string; currentJobLog: string;
} }
export interface IActionLogState {
entries: interfaces.data.IActionLogEntry[];
total: number;
}
export interface INavigationContext {
connectionId?: string;
scope?: 'project' | 'group';
scopeId?: string;
projectId?: string;
}
export interface IUiState { export interface IUiState {
activeView: string; activeView: string;
autoRefresh: boolean; autoRefresh: boolean;
refreshInterval: number; refreshInterval: number;
navigationContext?: INavigationContext;
} }
// ============================================================================ // ============================================================================
@@ -70,6 +83,15 @@ export const dataStatePart = await appState.getStatePart<IDataState>(
'soft', 'soft',
); );
export const actionLogStatePart = await appState.getStatePart<IActionLogState>(
'actionLog',
{
entries: [],
total: 0,
},
'soft',
);
export const uiStatePart = await appState.getStatePart<IUiState>( export const uiStatePart = await appState.getStatePart<IUiState>(
'ui', 'ui',
{ {
@@ -157,6 +179,7 @@ export const createConnectionAction = connectionsStatePart.createAction<{
providerType: interfaces.data.TProviderType; providerType: interfaces.data.TProviderType;
baseUrl: string; baseUrl: string;
token: string; token: string;
groupFilter?: string;
}>(async (statePartArg, dataArg) => { }>(async (statePartArg, dataArg) => {
const context = getActionContext(); const context = getActionContext();
try { try {
@@ -227,6 +250,59 @@ export const deleteConnectionAction = connectionsStatePart.createAction<{
} }
}); });
export const pauseConnectionAction = connectionsStatePart.createAction<{
connectionId: string;
paused: boolean;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_PauseConnection
>('/typedrequest', 'pauseConnection');
await typedRequest.fire({
identity: context.identity!,
...dataArg,
});
// Re-fetch to get updated status
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetConnections
>('/typedrequest', 'getConnections');
const listResp = await listReq.fire({ identity: context.identity! });
return { ...statePartArg.getState(), connections: listResp.connections };
} catch (err) {
console.error('Failed to pause/resume connection:', err);
return statePartArg.getState();
}
});
export const updateConnectionAction = connectionsStatePart.createAction<{
connectionId: string;
name?: string;
baseUrl?: string;
token?: string;
groupFilter?: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpdateConnection
>('/typedrequest', 'updateConnection');
await typedRequest.fire({
identity: context.identity!,
...dataArg,
});
// Re-fetch to get updated data
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetConnections
>('/typedrequest', 'getConnections');
const listResp = await listReq.fire({ identity: context.identity! });
return { ...statePartArg.getState(), connections: listResp.connections };
} catch (err) {
console.error('Failed to update connection:', err);
return statePartArg.getState();
}
});
// ============================================================================ // ============================================================================
// Projects Actions // Projects Actions
// ============================================================================ // ============================================================================
@@ -304,6 +380,34 @@ export const fetchSecretsAction = dataStatePart.createAction<{
} }
}); });
export const fetchAllSecretsAction = dataStatePart.createAction<{
connectionId: string;
scope?: 'project' | 'group';
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
// When no scope specified, fetch both project and group secrets in parallel
const scopes: Array<'project' | 'group'> = dataArg.scope ? [dataArg.scope] : ['project', 'group'];
const results = await Promise.all(
scopes.map(async (scope) => {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetAllSecrets
>('/typedrequest', 'getAllSecrets');
const response = await typedRequest.fire({
identity: context.identity!,
connectionId: dataArg.connectionId,
scope,
});
return response.secrets;
}),
);
return { ...statePartArg.getState(), secrets: results.flat() };
} catch (err) {
console.error('Failed to fetch all secrets:', err);
return statePartArg.getState();
}
});
export const createSecretAction = dataStatePart.createAction<{ export const createSecretAction = dataStatePart.createAction<{
connectionId: string; connectionId: string;
scope: 'project' | 'group'; scope: 'project' | 'group';
@@ -320,7 +424,7 @@ export const createSecretAction = dataStatePart.createAction<{
identity: context.identity!, identity: context.identity!,
...dataArg, ...dataArg,
}); });
// Re-fetch secrets // Re-fetch only the affected entity's secrets and merge
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest< const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSecrets interfaces.requests.IReq_GetSecrets
>('/typedrequest', 'getSecrets'); >('/typedrequest', 'getSecrets');
@@ -330,7 +434,11 @@ export const createSecretAction = dataStatePart.createAction<{
scope: dataArg.scope, scope: dataArg.scope,
scopeId: dataArg.scopeId, scopeId: dataArg.scopeId,
}); });
return { ...statePartArg.getState(), secrets: listResp.secrets }; const state = statePartArg.getState();
const otherSecrets = state.secrets.filter(
(s) => !(s.scopeId === dataArg.scopeId && s.scope === dataArg.scope),
);
return { ...state, secrets: [...otherSecrets, ...listResp.secrets] };
} catch (err) { } catch (err) {
console.error('Failed to create secret:', err); console.error('Failed to create secret:', err);
return statePartArg.getState(); return statePartArg.getState();
@@ -353,7 +461,7 @@ export const updateSecretAction = dataStatePart.createAction<{
identity: context.identity!, identity: context.identity!,
...dataArg, ...dataArg,
}); });
// Re-fetch // Re-fetch only the affected entity's secrets and merge
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest< const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSecrets interfaces.requests.IReq_GetSecrets
>('/typedrequest', 'getSecrets'); >('/typedrequest', 'getSecrets');
@@ -363,7 +471,11 @@ export const updateSecretAction = dataStatePart.createAction<{
scope: dataArg.scope, scope: dataArg.scope,
scopeId: dataArg.scopeId, scopeId: dataArg.scopeId,
}); });
return { ...statePartArg.getState(), secrets: listResp.secrets }; const state = statePartArg.getState();
const otherSecrets = state.secrets.filter(
(s) => !(s.scopeId === dataArg.scopeId && s.scope === dataArg.scope),
);
return { ...state, secrets: [...otherSecrets, ...listResp.secrets] };
} catch (err) { } catch (err) {
console.error('Failed to update secret:', err); console.error('Failed to update secret:', err);
return statePartArg.getState(); return statePartArg.getState();
@@ -388,7 +500,9 @@ export const deleteSecretAction = dataStatePart.createAction<{
const state = statePartArg.getState(); const state = statePartArg.getState();
return { return {
...state, ...state,
secrets: state.secrets.filter((s) => s.key !== dataArg.key), secrets: state.secrets.filter(
(s) => !(s.key === dataArg.key && s.scopeId === dataArg.scopeId && s.scope === dataArg.scope),
),
}; };
} catch (err) { } catch (err) {
console.error('Failed to delete secret:', err); console.error('Failed to delete secret:', err);
@@ -529,13 +643,53 @@ export const fetchJobLogAction = dataStatePart.createAction<{
} }
}); });
// ============================================================================
// Action Log Actions
// ============================================================================
export const fetchActionLogAction = actionLogStatePart.createAction<{
limit?: number;
offset?: number;
entityType?: interfaces.data.TActionEntity;
} | null>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetActionLog
>('/typedrequest', 'getActionLog');
const response = await typedRequest.fire({
identity: context.identity!,
limit: dataArg?.limit,
offset: dataArg?.offset,
entityType: dataArg?.entityType,
});
return { entries: response.entries, total: response.total };
} catch (err) {
console.error('Failed to fetch action log:', err);
return statePartArg.getState();
}
});
// ============================================================================ // ============================================================================
// UI Actions // UI Actions
// ============================================================================ // ============================================================================
export const setActiveViewAction = uiStatePart.createAction<{ view: string }>( export const setActiveViewAction = uiStatePart.createAction<{
view: string;
navigationContext?: INavigationContext;
}>(
async (statePartArg, dataArg) => { async (statePartArg, dataArg) => {
return { ...statePartArg.getState(), activeView: dataArg.view }; return {
...statePartArg.getState(),
activeView: dataArg.view,
navigationContext: dataArg.navigationContext,
};
},
);
export const clearNavigationContextAction = uiStatePart.createAction(
async (statePartArg) => {
return { ...statePartArg.getState(), navigationContext: undefined };
}, },
); );
@@ -543,3 +697,231 @@ export const toggleAutoRefreshAction = uiStatePart.createAction(async (statePart
const state = statePartArg.getState(); const state = statePartArg.getState();
return { ...state, autoRefresh: !state.autoRefresh }; return { ...state, autoRefresh: !state.autoRefresh };
}); });
export const setRefreshIntervalAction = uiStatePart.createAction<{ interval: number }>(
async (statePartArg, dataArg) => {
return { ...statePartArg.getState(), refreshInterval: dataArg.interval };
},
);
// ============================================================================
// Sync State
// ============================================================================
export interface ISyncState {
configs: interfaces.data.ISyncConfig[];
repoStatuses: interfaces.data.ISyncRepoStatus[];
}
export const syncStatePart = await appState.getStatePart<ISyncState>(
'sync',
{ configs: [], repoStatuses: [] },
'soft',
);
export const fetchSyncConfigsAction = syncStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSyncConfigs
>('/typedrequest', 'getSyncConfigs');
const response = await typedRequest.fire({ identity: context.identity! });
return { ...statePartArg.getState(), configs: response.configs };
} catch (err) {
console.error('Failed to fetch sync configs:', err);
return statePartArg.getState();
}
});
export const createSyncConfigAction = syncStatePart.createAction<{
name: string;
sourceConnectionId: string;
targetConnectionId: string;
targetGroupOffset?: string;
intervalMinutes?: number;
enforceDelete?: boolean;
enforceGroupDelete?: boolean;
addMirrorHint?: boolean;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateSyncConfig
>('/typedrequest', 'createSyncConfig');
await typedRequest.fire({ identity: context.identity!, ...dataArg });
// Re-fetch
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSyncConfigs
>('/typedrequest', 'getSyncConfigs');
const listResp = await listReq.fire({ identity: context.identity! });
return { ...statePartArg.getState(), configs: listResp.configs };
} catch (err) {
console.error('Failed to create sync config:', err);
return statePartArg.getState();
}
});
export const updateSyncConfigAction = syncStatePart.createAction<{
syncConfigId: string;
name?: string;
targetGroupOffset?: string;
intervalMinutes?: number;
enforceDelete?: boolean;
enforceGroupDelete?: boolean;
addMirrorHint?: boolean;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpdateSyncConfig
>('/typedrequest', 'updateSyncConfig');
await typedRequest.fire({ identity: context.identity!, ...dataArg });
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSyncConfigs
>('/typedrequest', 'getSyncConfigs');
const listResp = await listReq.fire({ identity: context.identity! });
return { ...statePartArg.getState(), configs: listResp.configs };
} catch (err) {
console.error('Failed to update sync config:', err);
return statePartArg.getState();
}
});
export const deleteSyncConfigAction = syncStatePart.createAction<{
syncConfigId: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteSyncConfig
>('/typedrequest', 'deleteSyncConfig');
await typedRequest.fire({ identity: context.identity!, ...dataArg });
const state = statePartArg.getState();
return { ...state, configs: state.configs.filter((c) => c.id !== dataArg.syncConfigId) };
} catch (err) {
console.error('Failed to delete sync config:', err);
return statePartArg.getState();
}
});
export const pauseSyncConfigAction = syncStatePart.createAction<{
syncConfigId: string;
paused: boolean;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_PauseSyncConfig
>('/typedrequest', 'pauseSyncConfig');
await typedRequest.fire({ identity: context.identity!, ...dataArg });
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSyncConfigs
>('/typedrequest', 'getSyncConfigs');
const listResp = await listReq.fire({ identity: context.identity! });
return { ...statePartArg.getState(), configs: listResp.configs };
} catch (err) {
console.error('Failed to pause/resume sync config:', err);
return statePartArg.getState();
}
});
export const triggerSyncAction = syncStatePart.createAction<{
syncConfigId: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_TriggerSync
>('/typedrequest', 'triggerSync');
await typedRequest.fire({ identity: context.identity!, ...dataArg });
return statePartArg.getState();
} catch (err) {
console.error('Failed to trigger sync:', err);
return statePartArg.getState();
}
});
export const fetchSyncRepoStatusesAction = syncStatePart.createAction<{
syncConfigId: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSyncRepoStatuses
>('/typedrequest', 'getSyncRepoStatuses');
const response = await typedRequest.fire({ identity: context.identity!, ...dataArg });
return { ...statePartArg.getState(), repoStatuses: response.statuses };
} catch (err) {
console.error('Failed to fetch sync repo statuses:', err);
return statePartArg.getState();
}
});
// ============================================================================
// Sync Log — TypedSocket client for server-push entries
// ============================================================================
export async function fetchSyncLogs(limit = 200): Promise<interfaces.data.ISyncLogEntry[]> {
const identity = loginStatePart.getState().identity;
if (!identity) throw new Error('Not logged in');
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSyncLogs
>('/typedrequest', 'getSyncLogs');
const response = await typedRequest.fire({ identity, limit });
return response.logs;
}
let syncLogSocketInitialized = false;
/**
* Create a TypedSocket client that handles server-push sync log entries.
* Dispatches 'gitops-sync-log-entry' custom events on document.
* Call once after login.
*/
export async function initSyncLogSocket(): Promise<void> {
if (syncLogSocketInitialized) return;
syncLogSocketInitialized = true;
try {
const typedrouter = new plugins.domtools.plugins.typedrequest.TypedRouter();
typedrouter.addTypedHandler(
new plugins.domtools.plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushSyncLog>(
'pushSyncLog',
async (dataArg) => {
document.dispatchEvent(
new CustomEvent('gitops-sync-log-entry', { detail: dataArg.entry }),
);
return {};
},
),
);
await plugins.typedsocket.TypedSocket.createClient(
typedrouter,
plugins.typedsocket.TypedSocket.useWindowLocationOriginUrl(),
{ autoReconnect: true },
);
} catch (err) {
console.error('Failed to init sync log TypedSocket client:', err);
syncLogSocketInitialized = false;
}
}
// ============================================================================
// Preview Helper
// ============================================================================
export async function previewSync(syncConfigId: string): Promise<{
mappings: Array<{ sourceFullPath: string; targetFullPath: string }>;
deletions: string[];
groupDeletions: string[];
}> {
const identity = loginStatePart.getState().identity;
if (!identity) throw new Error('Not logged in');
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_PreviewSync
>('/typedrequest', 'previewSync');
const response = await typedRequest.fire({ identity, syncConfigId });
return { mappings: response.mappings, deletions: response.deletions, groupDeletions: response.groupDeletions };
}

View File

@@ -18,6 +18,9 @@ import type { GitopsViewGroups } from './views/groups/index.js';
import type { GitopsViewSecrets } from './views/secrets/index.js'; import type { GitopsViewSecrets } from './views/secrets/index.js';
import type { GitopsViewPipelines } from './views/pipelines/index.js'; import type { GitopsViewPipelines } from './views/pipelines/index.js';
import type { GitopsViewBuildlog } from './views/buildlog/index.js'; import type { GitopsViewBuildlog } from './views/buildlog/index.js';
import type { GitopsViewActions } from './views/actions/index.js';
import type { GitopsViewActionlog } from './views/actionlog/index.js';
import type { GitopsViewSync } from './views/sync/index.js';
@customElement('gitops-dashboard') @customElement('gitops-dashboard')
export class GitopsDashboard extends DeesElement { export class GitopsDashboard extends DeesElement {
@@ -39,10 +42,21 @@ export class GitopsDashboard extends DeesElement {
{ name: 'Secrets', iconName: 'lucide:key', element: (async () => (await import('./views/secrets/index.js')).GitopsViewSecrets)() }, { name: 'Secrets', iconName: 'lucide:key', element: (async () => (await import('./views/secrets/index.js')).GitopsViewSecrets)() },
{ name: 'Pipelines', iconName: 'lucide:play', element: (async () => (await import('./views/pipelines/index.js')).GitopsViewPipelines)() }, { name: 'Pipelines', iconName: 'lucide:play', element: (async () => (await import('./views/pipelines/index.js')).GitopsViewPipelines)() },
{ name: 'Build Log', iconName: 'lucide:scrollText', element: (async () => (await import('./views/buildlog/index.js')).GitopsViewBuildlog)() }, { name: 'Build Log', iconName: 'lucide:scrollText', element: (async () => (await import('./views/buildlog/index.js')).GitopsViewBuildlog)() },
{ name: 'Actions', iconName: 'lucide:zap', element: (async () => (await import('./views/actions/index.js')).GitopsViewActions)() },
{ name: 'Action Log', iconName: 'lucide:scroll', element: (async () => (await import('./views/actionlog/index.js')).GitopsViewActionlog)() },
{ name: 'Sync', iconName: 'lucide:refreshCw', element: (async () => (await import('./views/sync/index.js')).GitopsViewSync)() },
]; ];
private resolvedViewTabs: Array<{ name: string; iconName: string; element: any }> = []; private resolvedViewTabs: Array<{ name: string; iconName: string; element: any }> = [];
// Auto-refresh timer
private autoRefreshTimer: ReturnType<typeof setInterval> | null = null;
// WebSocket client
private ws: WebSocket | null = null;
private wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
private wsIntentionalClose = false;
constructor() { constructor() {
super(); super();
document.title = 'GitOps'; document.title = 'GitOps';
@@ -53,7 +67,11 @@ export class GitopsDashboard extends DeesElement {
this.loginState = loginState; this.loginState = loginState;
if (loginState.isLoggedIn) { if (loginState.isLoggedIn) {
appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null); appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
this.connectWebSocket();
} else {
this.disconnectWebSocket();
} }
this.manageAutoRefreshTimer();
}); });
this.rxSubscriptions.push(loginSubscription); this.rxSubscriptions.push(loginSubscription);
@@ -62,6 +80,7 @@ export class GitopsDashboard extends DeesElement {
.subscribe((uiState) => { .subscribe((uiState) => {
this.uiState = uiState; this.uiState = uiState;
this.syncAppdashView(uiState.activeView); this.syncAppdashView(uiState.activeView);
this.manageAutoRefreshTimer();
}); });
this.rxSubscriptions.push(uiSubscription); this.rxSubscriptions.push(uiSubscription);
} }
@@ -78,6 +97,36 @@ export class GitopsDashboard extends DeesElement {
width: 100%; width: 100%;
height: 100vh; height: 100vh;
} }
.auto-refresh-toggle {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 1000;
background: rgba(30, 30, 50, 0.9);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 8px 14px;
color: #ccc;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
backdrop-filter: blur(8px);
transition: background 0.2s;
}
.auto-refresh-toggle:hover {
background: rgba(40, 40, 70, 0.95);
}
.auto-refresh-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
}
.auto-refresh-dot.active {
background: #00ff88;
}
`, `,
]; ];
@@ -92,6 +141,15 @@ export class GitopsDashboard extends DeesElement {
</dees-simple-appdash> </dees-simple-appdash>
</dees-simple-login> </dees-simple-login>
</div> </div>
${this.loginState.isLoggedIn ? html`
<div
class="auto-refresh-toggle"
@click=${() => appstate.uiStatePart.dispatchAction(appstate.toggleAutoRefreshAction, null)}
>
<span class="auto-refresh-dot ${this.uiState.autoRefresh ? 'active' : ''}"></span>
Auto-Refresh: ${this.uiState.autoRefresh ? 'ON' : 'OFF'}
</div>
` : ''}
`; `;
} }
@@ -118,6 +176,7 @@ export class GitopsDashboard extends DeesElement {
if (appDash) { if (appDash) {
appDash.addEventListener('view-select', (e: CustomEvent) => { appDash.addEventListener('view-select', (e: CustomEvent) => {
const viewName = e.detail.view.name.toLowerCase(); const viewName = e.detail.view.name.toLowerCase();
if (this.uiState.activeView === viewName) return;
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: viewName }); appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: viewName });
}); });
appDash.addEventListener('logout', async () => { appDash.addEventListener('logout', async () => {
@@ -160,6 +219,93 @@ export class GitopsDashboard extends DeesElement {
} }
} }
public override disconnectedCallback() {
super.disconnectedCallback();
this.clearAutoRefreshTimer();
this.disconnectWebSocket();
}
// ============================================================================
// Auto-refresh timer management
// ============================================================================
private manageAutoRefreshTimer(): void {
this.clearAutoRefreshTimer();
const { autoRefresh, refreshInterval } = this.uiState;
if (autoRefresh && this.loginState.isLoggedIn) {
this.autoRefreshTimer = setInterval(() => {
document.dispatchEvent(new CustomEvent('gitops-auto-refresh'));
}, refreshInterval);
}
}
private clearAutoRefreshTimer(): void {
if (this.autoRefreshTimer) {
clearInterval(this.autoRefreshTimer);
this.autoRefreshTimer = null;
}
}
// ============================================================================
// WebSocket client for webhook push notifications
// ============================================================================
private connectWebSocket(): void {
if (this.ws) return;
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${location.host}`;
try {
this.wsIntentionalClose = false;
this.ws = new WebSocket(wsUrl);
this.ws.addEventListener('message', (event) => {
try {
const data = JSON.parse(event.data);
// TypedSocket wraps messages; look for webhookNotification method
if (data?.method === 'webhookNotification' || data?.type === 'webhookEvent') {
console.log('Webhook event received:', data);
document.dispatchEvent(new CustomEvent('gitops-auto-refresh'));
}
} catch {
// Not JSON, ignore
}
});
this.ws.addEventListener('close', () => {
this.ws = null;
if (!this.wsIntentionalClose && this.loginState.isLoggedIn) {
this.wsReconnectTimer = setTimeout(() => {
this.connectWebSocket();
}, 5000);
}
});
this.ws.addEventListener('error', () => {
// Will trigger close event
});
} catch (err) {
console.warn('WebSocket connection failed:', err);
}
}
private disconnectWebSocket(): void {
this.wsIntentionalClose = true;
if (this.wsReconnectTimer) {
clearTimeout(this.wsReconnectTimer);
this.wsReconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
// ============================================================================
// Login
// ============================================================================
private async login(username: string, password: string) { private async login(username: string, password: string) {
const domtools = await this.domtoolsPromise; const domtools = await this.domtoolsPromise;
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any; const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
@@ -195,6 +341,7 @@ export class GitopsDashboard extends DeesElement {
if (!appDash || this.resolvedViewTabs.length === 0) return; if (!appDash || this.resolvedViewTabs.length === 0) return;
const targetTab = this.resolvedViewTabs.find((t) => t.name.toLowerCase() === viewName); const targetTab = this.resolvedViewTabs.find((t) => t.name.toLowerCase() === viewName);
if (!targetTab) return; if (!targetTab) return;
if (appDash.selectedView === targetTab) return;
appDash.loadView(targetTab); appDash.loadView(targetTab);
} }
} }

View File

@@ -6,3 +6,4 @@ import './views/groups/index.js';
import './views/secrets/index.js'; import './views/secrets/index.js';
import './views/pipelines/index.js'; import './views/pipelines/index.js';
import './views/buildlog/index.js'; import './views/buildlog/index.js';
import './views/actions/index.js';

View File

@@ -0,0 +1,101 @@
import * as plugins from '../../../plugins.js';
import * as appstate from '../../../appstate.js';
import { viewHostCss } from '../../shared/index.js';
import {
DeesElement,
customElement,
html,
state,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
@customElement('gitops-view-actionlog')
export class GitopsViewActionlog extends DeesElement {
@state()
accessor actionLogState: appstate.IActionLogState = {
entries: [],
total: 0,
};
@state()
accessor selectedEntityType: string = 'all';
private _autoRefreshHandler: () => void;
constructor() {
super();
const sub = appstate.actionLogStatePart
.select((s) => s)
.subscribe((s) => { this.actionLogState = s; });
this.rxSubscriptions.push(sub);
this._autoRefreshHandler = () => this.refresh();
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
public override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
];
public render(): TemplateResult {
const entityOptions = [
{ option: 'All', key: 'all' },
{ option: 'Connection', key: 'connection' },
{ option: 'Secret', key: 'secret' },
{ option: 'Pipeline', key: 'pipeline' },
];
return html`
<div class="view-title">Action Log</div>
<div class="view-description">Audit trail of all operations performed in the system</div>
<div class="toolbar">
<dees-input-dropdown
.label=${'Entity Type'}
.options=${entityOptions}
.selectedOption=${entityOptions.find((o) => o.key === this.selectedEntityType)}
@selectedOption=${(e: CustomEvent) => {
this.selectedEntityType = e.detail.key;
this.refresh();
}}
></dees-input-dropdown>
<dees-button @click=${() => this.refresh()}>Refresh</dees-button>
</div>
<dees-table
.heading1=${'Action Log'}
.heading2=${`${this.actionLogState.total} entries total`}
.data=${this.actionLogState.entries}
.displayFunction=${(item: any) => ({
Time: new Date(item.timestamp).toLocaleString(),
Action: item.actionType,
Entity: item.entityType,
Name: item.entityName,
Details: item.details,
User: item.username,
})}
.dataActions=${[]}
></dees-table>
`;
}
async firstUpdated() {
await this.refresh();
}
private async refresh() {
const entityType = this.selectedEntityType === 'all'
? undefined
: this.selectedEntityType as any;
await appstate.actionLogStatePart.dispatchAction(appstate.fetchActionLogAction, {
limit: 100,
entityType,
});
}
}

View File

@@ -0,0 +1,209 @@
import * as plugins from '../../../plugins.js';
import * as interfaces from '../../../../ts_interfaces/index.js';
import * as appstate from '../../../appstate.js';
import { viewHostCss } from '../../shared/index.js';
import {
DeesElement,
customElement,
html,
state,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
@customElement('gitops-view-actions')
export class GitopsViewActions extends DeesElement {
@state()
accessor lastScanTimestamp: number = 0;
@state()
accessor isScanning: boolean = false;
@state()
accessor lastResult: {
connectionsScanned: number;
secretsFound: number;
errors: string[];
durationMs: number;
} | null = null;
@state()
accessor statusError: string = '';
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.action-cards {
display: grid;
grid-template-columns: 1fr;
gap: 24px;
max-width: 720px;
}
.action-card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 28px;
}
.action-card-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.action-card-description {
font-size: 13px;
color: #999;
line-height: 1.5;
margin-bottom: 20px;
}
.info-grid {
display: grid;
grid-template-columns: 140px 1fr;
gap: 8px 16px;
margin-bottom: 20px;
font-size: 13px;
}
.info-label {
color: #888;
}
.info-value {
color: #ddd;
font-family: monospace;
}
.info-value.scanning {
color: #f0c040;
}
.info-value.error {
color: #ff6060;
}
.button-row {
display: flex;
gap: 12px;
align-items: center;
}
.errors-list {
margin-top: 12px;
max-height: 160px;
overflow-y: auto;
font-size: 12px;
color: #ff8080;
font-family: monospace;
line-height: 1.6;
background: rgba(255, 0, 0, 0.05);
border-radius: 6px;
padding: 8px 12px;
}
`,
];
public render(): TemplateResult {
const lastScanFormatted = this.lastScanTimestamp
? new Date(this.lastScanTimestamp).toLocaleString()
: 'Never';
return html`
<div class="view-title">Actions</div>
<div class="view-description">System actions and maintenance tasks</div>
<div class="action-cards">
<div class="action-card">
<div class="action-card-title">Secrets Cache Scan</div>
<div class="action-card-description">
Secrets are automatically scanned and cached every 24 hours.
Use "Force Full Scan" to trigger an immediate refresh of all secrets
across all connections, projects, and groups.
</div>
<div class="info-grid">
<div class="info-label">Status</div>
<div class="info-value ${this.isScanning ? 'scanning' : ''}">
${this.isScanning ? 'Scanning...' : 'Idle'}
</div>
<div class="info-label">Last Scan</div>
<div class="info-value">${lastScanFormatted}</div>
${this.lastResult ? html`
<div class="info-label">Connections</div>
<div class="info-value">${this.lastResult.connectionsScanned}</div>
<div class="info-label">Secrets Found</div>
<div class="info-value">${this.lastResult.secretsFound}</div>
<div class="info-label">Duration</div>
<div class="info-value">${(this.lastResult.durationMs / 1000).toFixed(1)}s</div>
${this.lastResult.errors.length > 0 ? html`
<div class="info-label">Errors</div>
<div class="info-value error">${this.lastResult.errors.length}</div>
` : ''}
` : ''}
</div>
${this.statusError ? html`
<div class="errors-list">${this.statusError}</div>
` : ''}
${this.lastResult?.errors?.length ? html`
<div class="errors-list">
${this.lastResult.errors.map((e) => html`<div>${e}</div>`)}
</div>
` : ''}
<div class="button-row">
<dees-button
.disabled=${this.isScanning}
@click=${() => this.forceScan()}
>Force Full Scan</dees-button>
<dees-button
@click=${() => this.refreshStatus()}
>Refresh Status</dees-button>
</div>
</div>
</div>
`;
}
async firstUpdated() {
await this.refreshStatus();
}
private getIdentity(): interfaces.data.IIdentity | null {
return appstate.loginStatePart.getState().identity;
}
private async refreshStatus(): Promise<void> {
const identity = this.getIdentity();
if (!identity) return;
try {
this.statusError = '';
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetScanStatus
>('/typedrequest', 'getScanStatus');
const response = await typedRequest.fire({ identity });
this.lastScanTimestamp = response.lastScanTimestamp;
this.isScanning = response.isScanning;
this.lastResult = response.lastResult;
} catch (err) {
this.statusError = `Failed to get status: ${err}`;
}
}
private async forceScan(): Promise<void> {
const identity = this.getIdentity();
if (!identity) return;
try {
this.statusError = '';
this.isScanning = true;
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ForceScanSecrets
>('/typedrequest', 'forceScanSecrets');
const response = await typedRequest.fire({ identity });
this.lastResult = {
connectionsScanned: response.connectionsScanned,
secretsFound: response.secretsFound,
errors: response.errors,
durationMs: response.durationMs,
};
this.lastScanTimestamp = Date.now();
this.isScanning = false;
} catch (err) {
this.statusError = `Scan failed: ${err}`;
this.isScanning = false;
}
}
}

View File

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

View File

@@ -19,12 +19,26 @@ export class GitopsViewConnections extends DeesElement {
activeConnectionId: null, activeConnectionId: null,
}; };
private _autoRefreshHandler: () => void;
constructor() { constructor() {
super(); super();
const sub = appstate.connectionsStatePart const sub = appstate.connectionsStatePart
.select((s) => s) .select((s) => s)
.subscribe((s) => { this.connectionsState = s; }); .subscribe((s) => { this.connectionsState = s; });
this.rxSubscriptions.push(sub); this.rxSubscriptions.push(sub);
this._autoRefreshHandler = () => this.handleAutoRefresh();
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
public override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
private handleAutoRefresh(): void {
this.refresh();
} }
public static styles = [ public static styles = [
@@ -48,25 +62,60 @@ export class GitopsViewConnections extends DeesElement {
Name: item.name, Name: item.name,
Type: item.providerType, Type: item.providerType,
URL: item.baseUrl, URL: item.baseUrl,
'Group Filter': item.groupFilter || '-',
Status: item.status, Status: item.status,
Created: new Date(item.createdAt).toLocaleDateString(), Created: new Date(item.createdAt).toLocaleDateString(),
})} })}
.dataActions=${[ .dataActions=${[
{
name: 'Edit',
iconName: 'lucide:edit',
type: ['inRow', 'contextmenu'],
actionFunc: async ({ item }: any) => { await this.editConnection(item); },
},
{ {
name: 'Test', name: 'Test',
iconName: 'lucide:plug', iconName: 'lucide:plug',
action: async (item: any) => { type: ['inRow', 'contextmenu'],
actionFunc: async ({ item }: any) => {
await appstate.connectionsStatePart.dispatchAction( await appstate.connectionsStatePart.dispatchAction(
appstate.testConnectionAction, appstate.testConnectionAction,
{ connectionId: item.id }, { connectionId: item.id },
); );
}, },
}, },
{
name: 'Pause/Resume',
iconName: 'lucide:pauseCircle',
type: ['inRow', 'contextmenu'],
actionFunc: async ({ item }: any) => {
const isPaused = item.status === 'paused';
const actionLabel = isPaused ? 'Resume' : 'Pause';
await plugins.deesCatalog.DeesModal.createAndShow({
heading: `${actionLabel} Connection`,
content: html`<p style="color: #fff;">Are you sure you want to ${actionLabel.toLowerCase()} connection "${item.name}"?</p>`,
menuOptions: [
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
{
name: actionLabel,
action: async (modal: any) => {
await appstate.connectionsStatePart.dispatchAction(
appstate.pauseConnectionAction,
{ connectionId: item.id, paused: !isPaused },
);
modal.destroy();
},
},
],
});
},
},
{ {
name: 'Delete', name: 'Delete',
iconName: 'lucide:trash2', iconName: 'lucide:trash2',
action: async (item: any) => { type: ['inRow', 'contextmenu'],
const confirmed = await plugins.deesCatalog.DeesModal.createAndShow({ actionFunc: async ({ item }: any) => {
await plugins.deesCatalog.DeesModal.createAndShow({
heading: 'Delete Connection', heading: 'Delete Connection',
content: html`<p style="color: #fff;">Are you sure you want to delete connection "${item.name}"?</p>`, content: html`<p style="color: #fff;">Are you sure you want to delete connection "${item.name}"?</p>`,
menuOptions: [ menuOptions: [
@@ -98,6 +147,55 @@ export class GitopsViewConnections extends DeesElement {
await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null); await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
} }
private async editConnection(item: any) {
await plugins.deesCatalog.DeesModal.createAndShow({
heading: 'Edit Connection',
content: html`
<style>
.form-row { margin-bottom: 16px; }
.form-info { font-size: 13px; color: #888; margin-bottom: 16px; }
</style>
<div class="form-info">Provider: ${item.providerType}</div>
<div class="form-row">
<dees-input-text .label=${'Name'} .key=${'name'} .value=${item.name}></dees-input-text>
</div>
<div class="form-row">
<dees-input-text .label=${'Base URL'} .key=${'baseUrl'} .value=${item.baseUrl}></dees-input-text>
</div>
<div class="form-row">
<dees-input-text .label=${'API Token (leave empty to keep current)'} .key=${'token'} type="password"></dees-input-text>
</div>
<div class="form-row">
<dees-input-text .label=${'Group Filter (optional)'} .key=${'groupFilter'} .value=${item.groupFilter || ''} .description=${'Restricts which repos this connection can see (e.g. an org name or GitLab group path). Does not affect where synced repos are placed.'}></dees-input-text>
</div>
`,
menuOptions: [
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
{
name: 'Save',
action: async (modal: any) => {
const inputs = modal.shadowRoot.querySelectorAll('dees-input-text');
const data: any = {};
for (const input of inputs) {
data[input.key] = input.value || '';
}
await appstate.connectionsStatePart.dispatchAction(
appstate.updateConnectionAction,
{
connectionId: item.id,
name: data.name,
baseUrl: data.baseUrl,
groupFilter: data.groupFilter,
...(data.token ? { token: data.token } : {}),
},
);
modal.destroy();
},
},
],
});
}
private async addConnection() { private async addConnection() {
await plugins.deesCatalog.DeesModal.createAndShow({ await plugins.deesCatalog.DeesModal.createAndShow({
heading: 'Add Connection', heading: 'Add Connection',
@@ -125,6 +223,9 @@ export class GitopsViewConnections extends DeesElement {
<div class="form-row"> <div class="form-row">
<dees-input-text .label=${'API Token'} .key=${'token'} type="password"></dees-input-text> <dees-input-text .label=${'API Token'} .key=${'token'} type="password"></dees-input-text>
</div> </div>
<div class="form-row">
<dees-input-text .label=${'Group Filter (optional)'} .key=${'groupFilter'} .description=${'Restricts which repos this connection can see (e.g. an org name or GitLab group path). Does not affect where synced repos are placed.'}></dees-input-text>
</div>
`, `,
menuOptions: [ menuOptions: [
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } }, { name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
@@ -147,6 +248,7 @@ export class GitopsViewConnections extends DeesElement {
providerType: data.providerType, providerType: data.providerType,
baseUrl: data.baseUrl, baseUrl: data.baseUrl,
token: data.token, token: data.token,
groupFilter: data.groupFilter || undefined,
}, },
); );
modal.destroy(); modal.destroy();

View File

@@ -32,6 +32,8 @@ export class GitopsViewGroups extends DeesElement {
@state() @state()
accessor selectedConnectionId: string = ''; accessor selectedConnectionId: string = '';
private _autoRefreshHandler: () => void;
constructor() { constructor() {
super(); super();
const connSub = appstate.connectionsStatePart const connSub = appstate.connectionsStatePart
@@ -43,6 +45,18 @@ export class GitopsViewGroups extends DeesElement {
.select((s) => s) .select((s) => s)
.subscribe((s) => { this.dataState = s; }); .subscribe((s) => { this.dataState = s; });
this.rxSubscriptions.push(dataSub); this.rxSubscriptions.push(dataSub);
this._autoRefreshHandler = () => this.handleAutoRefresh();
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
public override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
private handleAutoRefresh(): void {
this.loadGroups();
} }
public static styles = [ public static styles = [
@@ -85,8 +99,16 @@ export class GitopsViewGroups extends DeesElement {
{ {
name: 'View Secrets', name: 'View Secrets',
iconName: 'lucide:key', iconName: 'lucide:key',
action: async (item: any) => { type: ['inRow', 'contextmenu'],
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'secrets' }); actionFunc: async ({ item }: any) => {
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, {
view: 'secrets',
navigationContext: {
connectionId: this.selectedConnectionId,
scope: 'group',
scopeId: item.id,
},
});
}, },
}, },
]} ]}

View File

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

View File

@@ -35,6 +35,8 @@ export class GitopsViewPipelines extends DeesElement {
@state() @state()
accessor selectedProjectId: string = ''; accessor selectedProjectId: string = '';
private _autoRefreshHandler: () => void;
constructor() { constructor() {
super(); super();
const connSub = appstate.connectionsStatePart const connSub = appstate.connectionsStatePart
@@ -46,6 +48,18 @@ export class GitopsViewPipelines extends DeesElement {
.select((s) => s) .select((s) => s)
.subscribe((s) => { this.dataState = s; }); .subscribe((s) => { this.dataState = s; });
this.rxSubscriptions.push(dataSub); this.rxSubscriptions.push(dataSub);
this._autoRefreshHandler = () => this.handleAutoRefresh();
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
public override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
private handleAutoRefresh(): void {
this.loadPipelines();
} }
public static styles = [ public static styles = [
@@ -119,12 +133,14 @@ export class GitopsViewPipelines extends DeesElement {
{ {
name: 'View Jobs', name: 'View Jobs',
iconName: 'lucide:list', iconName: 'lucide:list',
action: async (item: any) => { await this.viewJobs(item); }, type: ['inRow', 'contextmenu'],
actionFunc: async ({ item }: any) => { await this.viewJobs(item); },
}, },
{ {
name: 'Retry', name: 'Retry',
iconName: 'lucide:refresh-cw', iconName: 'lucide:refreshCw',
action: async (item: any) => { type: ['inRow', 'contextmenu'],
actionFunc: async ({ item }: any) => {
await appstate.dataStatePart.dispatchAction(appstate.retryPipelineAction, { await appstate.dataStatePart.dispatchAction(appstate.retryPipelineAction, {
connectionId: this.selectedConnectionId, connectionId: this.selectedConnectionId,
projectId: this.selectedProjectId, projectId: this.selectedProjectId,
@@ -134,8 +150,9 @@ export class GitopsViewPipelines extends DeesElement {
}, },
{ {
name: 'Cancel', name: 'Cancel',
iconName: 'lucide:x-circle', iconName: 'lucide:xCircle',
action: async (item: any) => { type: ['inRow', 'contextmenu'],
actionFunc: async ({ item }: any) => {
await appstate.dataStatePart.dispatchAction(appstate.cancelPipelineAction, { await appstate.dataStatePart.dispatchAction(appstate.cancelPipelineAction, {
connectionId: this.selectedConnectionId, connectionId: this.selectedConnectionId,
projectId: this.selectedProjectId, projectId: this.selectedProjectId,
@@ -150,6 +167,18 @@ export class GitopsViewPipelines extends DeesElement {
async firstUpdated() { async firstUpdated() {
await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null); await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
// Check for navigation context from projects view
const navCtx = appstate.uiStatePart.getState().navigationContext;
if (navCtx?.connectionId && navCtx?.projectId) {
this.selectedConnectionId = navCtx.connectionId;
this.selectedProjectId = navCtx.projectId;
appstate.uiStatePart.dispatchAction(appstate.clearNavigationContextAction, null);
await this.loadProjects();
await this.loadPipelines();
return;
}
const conns = appstate.connectionsStatePart.getState().connections; const conns = appstate.connectionsStatePart.getState().connections;
if (conns.length > 0 && !this.selectedConnectionId) { if (conns.length > 0 && !this.selectedConnectionId) {
this.selectedConnectionId = conns[0].id; this.selectedConnectionId = conns[0].id;

View File

@@ -32,6 +32,8 @@ export class GitopsViewProjects extends DeesElement {
@state() @state()
accessor selectedConnectionId: string = ''; accessor selectedConnectionId: string = '';
private _autoRefreshHandler: () => void;
constructor() { constructor() {
super(); super();
const connSub = appstate.connectionsStatePart const connSub = appstate.connectionsStatePart
@@ -43,6 +45,18 @@ export class GitopsViewProjects extends DeesElement {
.select((s) => s) .select((s) => s)
.subscribe((s) => { this.dataState = s; }); .subscribe((s) => { this.dataState = s; });
this.rxSubscriptions.push(dataSub); this.rxSubscriptions.push(dataSub);
this._autoRefreshHandler = () => this.handleAutoRefresh();
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
public override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
}
private handleAutoRefresh(): void {
this.loadProjects();
} }
public static styles = [ public static styles = [
@@ -86,15 +100,30 @@ export class GitopsViewProjects extends DeesElement {
{ {
name: 'View Secrets', name: 'View Secrets',
iconName: 'lucide:key', iconName: 'lucide:key',
action: async (item: any) => { type: ['inRow', 'contextmenu'],
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'secrets' }); actionFunc: async ({ item }: any) => {
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, {
view: 'secrets',
navigationContext: {
connectionId: this.selectedConnectionId,
scope: 'project',
scopeId: item.id,
},
});
}, },
}, },
{ {
name: 'View Pipelines', name: 'View Pipelines',
iconName: 'lucide:play', iconName: 'lucide:play',
action: async (item: any) => { type: ['inRow', 'contextmenu'],
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'pipelines' }); actionFunc: async ({ item }: any) => {
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, {
view: 'pipelines',
navigationContext: {
connectionId: this.selectedConnectionId,
projectId: item.id,
},
});
}, },
}, },
]} ]}

View File

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

View File

@@ -0,0 +1,503 @@
import * as plugins from '../../../plugins.js';
import * as appstate from '../../../appstate.js';
import { viewHostCss } from '../../shared/index.js';
import {
DeesElement,
customElement,
html,
state,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
@customElement('gitops-view-sync')
export class GitopsViewSync extends DeesElement {
@state()
accessor syncState: appstate.ISyncState = { configs: [], repoStatuses: [] };
@state()
accessor connectionsState: appstate.IConnectionsState = {
connections: [],
activeConnectionId: null,
};
private _autoRefreshHandler: () => void;
private _syncLogHandler: (e: Event) => void;
constructor() {
super();
const syncSub = appstate.syncStatePart
.select((s) => s)
.subscribe((s) => { this.syncState = s; });
this.rxSubscriptions.push(syncSub);
const connSub = appstate.connectionsStatePart
.select((s) => s)
.subscribe((s) => { this.connectionsState = s; });
this.rxSubscriptions.push(connSub);
this._autoRefreshHandler = () => this.refresh();
document.addEventListener('gitops-auto-refresh', this._autoRefreshHandler);
// Listen for server-push sync log entries via TypedSocket
this._syncLogHandler = (e: Event) => {
const entry = (e as CustomEvent).detail;
if (!entry) return;
const chartLog = this.shadowRoot?.querySelector('dees-chart-log') as any;
if (chartLog?.addLog) {
chartLog.addLog(entry.level, entry.message, entry.source);
}
};
document.addEventListener('gitops-sync-log-entry', this._syncLogHandler);
}
public override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('gitops-auto-refresh', this._autoRefreshHandler);
document.removeEventListener('gitops-sync-log-entry', this._syncLogHandler);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.status-active { background: #1a3a1a; color: #00ff88; }
.status-paused { background: #3a3a1a; color: #ffaa00; }
.status-error { background: #3a1a1a; color: #ff4444; }
dees-chart-log {
margin-top: 24px;
}
`,
];
public render(): TemplateResult {
return html`
<div class="view-title">Sync</div>
<div class="view-description">Mirror repositories between Gitea and GitLab instances</div>
<div class="toolbar">
<dees-button @click=${() => this.addSyncConfig()}>Add Sync</dees-button>
<dees-button @click=${() => this.refresh()}>Refresh</dees-button>
</div>
<dees-table
.heading1=${'Sync Configurations'}
.heading2=${'Automatic repository mirroring between instances'}
.data=${this.syncState.configs}
.displayFunction=${(item: any) => {
const sourceConn = this.connectionsState.connections.find((c) => c.id === item.sourceConnectionId);
const targetConn = this.connectionsState.connections.find((c) => c.id === item.targetConnectionId);
return {
Name: item.name,
Source: sourceConn?.name || item.sourceConnectionId,
'Target': `${targetConn?.name || item.targetConnectionId}${item.targetGroupOffset ? `${item.targetGroupOffset}/` : ''}`,
Interval: `${item.intervalMinutes}m`,
Status: item.status,
'Enforce Delete': item.enforceDelete ? 'Yes' : 'No',
'Enforce Group Delete': item.enforceGroupDelete ? 'Yes' : 'No',
'Mirror Hint': item.addMirrorHint ? 'Yes' : 'No',
'Last Sync': item.lastSyncAt ? new Date(item.lastSyncAt).toLocaleString() : 'Never',
Repos: String(item.reposSynced),
};
}}
.dataActions=${[
{
name: 'Preview',
iconName: 'lucide:eye',
type: ['inRow', 'contextmenu'],
actionFunc: async ({ item }: any) => { await this.previewSync(item); },
},
{
name: 'Trigger Now',
iconName: 'lucide:play',
type: ['inRow', 'contextmenu'],
actionFunc: async ({ item }: any) => {
const statusNote = item.status === 'paused' ? ' (config is paused — this is a one-off run)' : '';
await plugins.deesCatalog.DeesModal.createAndShow({
heading: 'Trigger Sync',
content: html`<p style="color: #fff;">Run sync "${item.name}" now?${statusNote}</p>`,
menuOptions: [
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
{
name: 'Trigger',
action: async (modal: any) => {
await appstate.syncStatePart.dispatchAction(appstate.triggerSyncAction, {
syncConfigId: item.id,
});
modal.destroy();
},
},
],
});
},
},
{
name: 'View Repos',
iconName: 'lucide:list',
type: ['inRow', 'contextmenu'],
actionFunc: async ({ item }: any) => { await this.viewRepoStatuses(item); },
},
{
name: 'Edit',
iconName: 'lucide:edit',
type: ['inRow', 'contextmenu'],
actionFunc: async ({ item }: any) => { await this.editSyncConfig(item); },
},
{
name: 'Pause/Resume',
iconName: 'lucide:pauseCircle',
type: ['inRow', 'contextmenu'],
actionFunc: async ({ item }: any) => {
const isPaused = item.status === 'paused';
const actionLabel = isPaused ? 'Resume' : 'Pause';
await plugins.deesCatalog.DeesModal.createAndShow({
heading: `${actionLabel} Sync`,
content: html`<p style="color: #fff;">Are you sure you want to ${actionLabel.toLowerCase()} sync "${item.name}"?</p>`,
menuOptions: [
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
{
name: actionLabel,
action: async (modal: any) => {
await appstate.syncStatePart.dispatchAction(appstate.pauseSyncConfigAction, {
syncConfigId: item.id,
paused: !isPaused,
});
modal.destroy();
},
},
],
});
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'],
actionFunc: async ({ item }: any) => {
await plugins.deesCatalog.DeesModal.createAndShow({
heading: 'Delete Sync Config',
content: html`<p style="color: #fff;">Are you sure you want to delete sync config "${item.name}"? This will also remove all local mirror data.</p>`,
menuOptions: [
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
{
name: 'Delete',
action: async (modal: any) => {
await appstate.syncStatePart.dispatchAction(appstate.deleteSyncConfigAction, {
syncConfigId: item.id,
});
modal.destroy();
},
},
],
});
},
},
]}
></dees-table>
<dees-chart-log
.label=${'Sync Activity Log'}
.autoScroll=${true}
.maxEntries=${500}
></dees-chart-log>
`;
}
async firstUpdated() {
await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
await this.refresh();
// Initialize TypedSocket for server-push sync log entries
await appstate.initSyncLogSocket();
// Load existing log entries
await this.loadExistingLogs();
}
private async loadExistingLogs() {
try {
const logs = await appstate.fetchSyncLogs(200);
const chartLog = this.shadowRoot?.querySelector('dees-chart-log') as any;
if (chartLog?.updateLog && logs.length > 0) {
chartLog.updateLog(
logs.map((entry) => ({
timestamp: new Date(entry.timestamp).toISOString(),
level: entry.level,
message: entry.message,
source: entry.source,
})),
);
}
} catch (err) {
console.error('Failed to load sync logs:', err);
}
}
private async refresh() {
await appstate.syncStatePart.dispatchAction(appstate.fetchSyncConfigsAction, null);
}
private async addSyncConfig() {
const connectionOptions = this.connectionsState.connections.map((c) => ({
option: `${c.name} (${c.providerType})`,
key: c.id,
}));
await plugins.deesCatalog.DeesModal.createAndShow({
heading: 'Add Sync Configuration',
content: html`
<style>.form-row { margin-bottom: 16px; }</style>
<div class="form-row">
<dees-input-text .label=${'Name'} .key=${'name'} .description=${'A human-readable name for this sync configuration'}></dees-input-text>
</div>
<div class="form-row">
<dees-input-dropdown
.label=${'Source Connection'}
.key=${'sourceConnectionId'}
.description=${'The connection to read repositories from (filtered by its group filter)'}
.options=${connectionOptions}
.selectedOption=${connectionOptions[0]}
></dees-input-dropdown>
</div>
<div class="form-row">
<dees-input-dropdown
.label=${'Target Connection'}
.key=${'targetConnectionId'}
.description=${'The connection to push repositories to'}
.options=${connectionOptions}
.selectedOption=${connectionOptions[1] || connectionOptions[0]}
></dees-input-dropdown>
</div>
<div class="form-row">
<dees-input-text .label=${'Target Group Offset'} .key=${'targetGroupOffset'} .description=${'Path prefix for target repos (e.g. "mirror/gitlab"). Leave empty for no prefix — repos land at their relative path.'}></dees-input-text>
</div>
<div class="form-row">
<dees-input-text .label=${'Interval (minutes)'} .key=${'intervalMinutes'} .value=${'5'} .description=${'How often to run this sync automatically'}></dees-input-text>
</div>
<div class="form-row">
<dees-input-checkbox .label=${'Enforce Deletion'} .key=${'enforceDelete'} .value=${false} .description=${'When enabled, repos on the target not present on the source will be moved to an obsolete group (private).'}></dees-input-checkbox>
</div>
<div class="form-row">
<dees-input-checkbox .label=${'Enforce Group Deletion'} .key=${'enforceGroupDelete'} .value=${false} .description=${'When enabled, groups/orgs on the target not present on the source will be moved to obsolete.'}></dees-input-checkbox>
</div>
<div class="form-row">
<dees-input-checkbox .label=${'Add Mirror Hint'} .key=${'addMirrorHint'} .value=${false} .description=${'When enabled, target descriptions get "(This is a mirror of ...)" appended.'}></dees-input-checkbox>
</div>
`,
menuOptions: [
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
{
name: 'Create',
action: async (modal: any) => {
const inputs = modal.shadowRoot.querySelectorAll('dees-input-text, dees-input-dropdown, dees-input-checkbox');
const data: any = {};
for (const input of inputs) {
if (input.key === 'sourceConnectionId' || input.key === 'targetConnectionId') {
data[input.key] = input.selectedOption?.key || '';
} else if (input.key === 'enforceDelete' || input.key === 'enforceGroupDelete' || input.key === 'addMirrorHint') {
data[input.key] = input.getValue();
} else {
data[input.key] = input.value || '';
}
}
await appstate.syncStatePart.dispatchAction(appstate.createSyncConfigAction, {
name: data.name,
sourceConnectionId: data.sourceConnectionId,
targetConnectionId: data.targetConnectionId,
targetGroupOffset: data.targetGroupOffset || undefined,
intervalMinutes: parseInt(data.intervalMinutes) || 5,
enforceDelete: !!data.enforceDelete,
enforceGroupDelete: !!data.enforceGroupDelete,
addMirrorHint: !!data.addMirrorHint,
});
modal.destroy();
},
},
],
});
}
private async editSyncConfig(item: any) {
await plugins.deesCatalog.DeesModal.createAndShow({
heading: `Edit Sync: ${item.name}`,
content: html`
<style>.form-row { margin-bottom: 16px; }</style>
<div class="form-row">
<dees-input-text .label=${'Name'} .key=${'name'} .value=${item.name} .description=${'A human-readable name for this sync configuration'}></dees-input-text>
</div>
<div class="form-row">
<dees-input-text .label=${'Target Group Offset'} .key=${'targetGroupOffset'} .value=${item.targetGroupOffset || ''} .description=${'Path prefix for target repos (e.g. "mirror/gitlab"). Leave empty for no prefix — repos land at their relative path.'}></dees-input-text>
</div>
<div class="form-row">
<dees-input-text .label=${'Interval (minutes)'} .key=${'intervalMinutes'} .value=${String(item.intervalMinutes)} .description=${'How often to run this sync automatically'}></dees-input-text>
</div>
<div class="form-row">
<dees-input-checkbox .label=${'Enforce Deletion'} .key=${'enforceDelete'} .value=${!!item.enforceDelete} .description=${'When enabled, repos on the target not present on the source will be moved to an obsolete group (private).'}></dees-input-checkbox>
</div>
<div class="form-row">
<dees-input-checkbox .label=${'Enforce Group Deletion'} .key=${'enforceGroupDelete'} .value=${!!item.enforceGroupDelete} .description=${'When enabled, groups/orgs on the target not present on the source will be moved to obsolete.'}></dees-input-checkbox>
</div>
<div class="form-row">
<dees-input-checkbox .label=${'Add Mirror Hint'} .key=${'addMirrorHint'} .value=${!!item.addMirrorHint} .description=${'When enabled, target descriptions get "(This is a mirror of ...)" appended.'}></dees-input-checkbox>
</div>
`,
menuOptions: [
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
{
name: 'Save',
action: async (modal: any) => {
const inputs = modal.shadowRoot.querySelectorAll('dees-input-text, dees-input-checkbox');
const data: any = {};
for (const input of inputs) {
if (input.key === 'enforceDelete' || input.key === 'enforceGroupDelete' || input.key === 'addMirrorHint') {
data[input.key] = input.getValue();
} else {
data[input.key] = input.value || '';
}
}
await appstate.syncStatePart.dispatchAction(appstate.updateSyncConfigAction, {
syncConfigId: item.id,
name: data.name,
targetGroupOffset: data.targetGroupOffset || undefined,
intervalMinutes: parseInt(data.intervalMinutes) || 5,
enforceDelete: !!data.enforceDelete,
enforceGroupDelete: !!data.enforceGroupDelete,
addMirrorHint: !!data.addMirrorHint,
});
modal.destroy();
},
},
],
});
}
private async previewSync(item: any) {
try {
const { mappings, deletions, groupDeletions } = await appstate.previewSync(item.id);
// Compute the full obsolete group path for display
const targetConn = this.connectionsState.connections.find((c: any) => c.id === item.targetConnectionId);
let obsoletePath: string;
if (targetConn?.providerType === 'gitea') {
const segments = item.targetGroupOffset ? item.targetGroupOffset.split('/') : [];
const orgName = segments[0] || targetConn?.groupFilter || 'default';
obsoletePath = `${orgName}-obsolete`;
} else {
obsoletePath = item.targetGroupOffset ? `${item.targetGroupOffset}/obsolete` : 'obsolete';
}
await plugins.deesCatalog.DeesModal.createAndShow({
heading: `Preview Sync: "${item.name}"`,
content: html`
<style>
.preview-list { color: #fff; max-height: 400px; overflow-y: auto; }
.preview-item { display: flex; align-items: center; gap: 12px; padding: 6px 0; border-bottom: 1px solid #333; font-size: 13px; }
.preview-source { color: #aaa; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.preview-arrow { color: #666; flex-shrink: 0; }
.preview-target { color: #00ff88; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.preview-count { color: #888; font-size: 12px; margin-bottom: 12px; }
.preview-delete { color: #ff4444; padding: 6px 0; border-bottom: 1px solid #333; font-size: 13px; display: flex; align-items: center; gap: 8px; }
.preview-delete-marker { flex-shrink: 0; }
.preview-delete-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.preview-section { margin-top: 16px; }
.preview-section-header { color: #ff4444; font-size: 12px; font-weight: 600; margin-bottom: 8px; }
</style>
<div class="preview-count">${mappings.length} repositories will be synced</div>
<div class="preview-list">
${mappings.map((m: any) => html`
<div class="preview-item">
<span class="preview-source">${m.sourceFullPath}</span>
<span class="preview-arrow">&rarr;</span>
<span class="preview-target">${m.targetFullPath}</span>
</div>
`)}
${mappings.length === 0 ? html`<p style="color: #888;">No repositories found on source.</p>` : ''}
</div>
${deletions.length > 0 ? html`
<div class="preview-section">
<div class="preview-section-header">${deletions.length} target repositor${deletions.length === 1 ? 'y' : 'ies'} will be moved to ${obsoletePath}</div>
<div class="preview-list">
${deletions.map((d: string) => html`
<div class="preview-delete">
<span class="preview-delete-marker">→</span>
<span class="preview-delete-path">${d}</span>
</div>
`)}
</div>
</div>
` : ''}
${groupDeletions.length > 0 ? html`
<div class="preview-section">
<div class="preview-section-header">${groupDeletions.length} target group${groupDeletions.length === 1 ? '' : 's'} will be moved to ${obsoletePath}</div>
<div class="preview-list">
${groupDeletions.map((g: string) => html`
<div class="preview-delete">
<span class="preview-delete-marker">→</span>
<span class="preview-delete-path">${g}</span>
</div>
`)}
</div>
</div>
` : ''}
`,
menuOptions: [
{ name: 'Close', action: async (modal: any) => { modal.destroy(); } },
],
});
} catch (err: any) {
await plugins.deesCatalog.DeesModal.createAndShow({
heading: 'Preview Failed',
content: html`<p style="color: #ff4444;">${err.message || String(err)}</p>`,
menuOptions: [
{ name: 'Close', action: async (modal: any) => { modal.destroy(); } },
],
});
}
}
private async viewRepoStatuses(item: any) {
await appstate.syncStatePart.dispatchAction(appstate.fetchSyncRepoStatusesAction, {
syncConfigId: item.id,
});
const statuses = appstate.syncStatePart.getState().repoStatuses;
await plugins.deesCatalog.DeesModal.createAndShow({
heading: `Sync "${item.name}" - Repo Statuses`,
content: html`
<style>
.repo-list { color: #fff; max-height: 400px; overflow-y: auto; }
.repo-item { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #333; }
.repo-path { font-weight: 600; font-size: 13px; }
.repo-status { font-size: 12px; text-transform: uppercase; }
.repo-status.synced { color: #00ff88; }
.repo-status.error { color: #ff4444; }
.repo-status.pending { color: #ffaa00; }
.repo-error { font-size: 11px; color: #ff6666; margin-top: 4px; }
</style>
<div class="repo-list">
${statuses.map((s: any) => html`
<div class="repo-item">
<div>
<div class="repo-path">${s.sourceFullPath}</div>
${s.lastSyncError ? html`<div class="repo-error">${s.lastSyncError}</div>` : ''}
</div>
<div>
<span class="repo-status ${s.status}">${s.status}</span>
<div style="font-size: 11px; color: #888;">${s.lastSyncAt ? new Date(s.lastSyncAt).toLocaleString() : ''}</div>
</div>
</div>
`)}
${statuses.length === 0 ? html`<p style="color: #888;">No repos synced yet.</p>` : ''}
</div>
`,
menuOptions: [
{ name: 'Close', action: async (modal: any) => { modal.destroy(); } },
],
});
}
}

View File

@@ -2,9 +2,13 @@
import * as deesElement from '@design.estate/dees-element'; import * as deesElement from '@design.estate/dees-element';
import * as deesCatalog from '@design.estate/dees-catalog'; import * as deesCatalog from '@design.estate/dees-catalog';
// @api.global scope
import * as typedsocket from '@api.global/typedsocket';
export { export {
deesElement, deesElement,
deesCatalog, deesCatalog,
typedsocket,
}; };
// domtools gives us TypedRequest, smartstate, smartrouter, and other utilities // domtools gives us TypedRequest, smartstate, smartrouter, and other utilities