@serve.zone/gitops 2.13.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/changelog.md +23 -0
  2. package/deno.json +1 -1
  3. package/dist_serve/bundle.js +726 -694
  4. package/dist_ts/00_commitinfo_data.js +2 -2
  5. package/dist_ts/classes/connectionmanager.d.ts +5 -1
  6. package/dist_ts/classes/connectionmanager.js +62 -7
  7. package/dist_ts/classes/syncmanager.d.ts +40 -0
  8. package/dist_ts/classes/syncmanager.js +804 -111
  9. package/dist_ts/classes/syncpath.d.ts +13 -0
  10. package/dist_ts/classes/syncpath.js +53 -0
  11. package/dist_ts/opsserver/handlers/connections.handler.js +8 -2
  12. package/dist_ts/opsserver/handlers/sync.handler.js +5 -1
  13. package/dist_ts/providers/classes.baseprovider.d.ts +22 -0
  14. package/dist_ts/providers/classes.baseprovider.js +93 -1
  15. package/dist_ts/providers/classes.giteaprovider.d.ts +18 -1
  16. package/dist_ts/providers/classes.giteaprovider.js +236 -1
  17. package/dist_ts/providers/classes.gitlabprovider.d.ts +16 -1
  18. package/dist_ts/providers/classes.gitlabprovider.js +235 -1
  19. package/dist_ts_interfaces/data/artifact.d.ts +39 -0
  20. package/dist_ts_interfaces/data/artifact.js +2 -0
  21. package/dist_ts_interfaces/data/connection.d.ts +3 -0
  22. package/dist_ts_interfaces/data/index.d.ts +1 -0
  23. package/dist_ts_interfaces/data/index.js +2 -1
  24. package/dist_ts_interfaces/data/sync.d.ts +21 -0
  25. package/dist_ts_interfaces/requests/connections.d.ts +6 -0
  26. package/dist_ts_interfaces/requests/sync.d.ts +4 -0
  27. package/package.json +3 -3
  28. package/readme.md +8 -4
  29. package/ts/00_commitinfo_data.ts +1 -1
  30. package/ts/classes/connectionmanager.ts +61 -6
  31. package/ts/classes/syncmanager.ts +1095 -119
  32. package/ts/classes/syncpath.ts +71 -0
  33. package/ts/opsserver/handlers/connections.handler.ts +9 -0
  34. package/ts/opsserver/handlers/sync.handler.ts +4 -0
  35. package/ts/providers/classes.baseprovider.ts +137 -0
  36. package/ts/providers/classes.giteaprovider.ts +293 -1
  37. package/ts/providers/classes.gitlabprovider.ts +292 -1
  38. package/ts_interfaces/data/artifact.ts +43 -0
  39. package/ts_interfaces/data/connection.ts +3 -0
  40. package/ts_interfaces/data/index.ts +1 -0
  41. package/ts_interfaces/data/sync.ts +22 -0
  42. package/ts_interfaces/requests/connections.ts +6 -0
  43. package/ts_interfaces/requests/sync.ts +4 -0
  44. package/ts_web/00_commitinfo_data.ts +1 -1
  45. package/ts_web/appstate.ts +10 -0
  46. package/ts_web/elements/views/connections/index.ts +26 -0
  47. package/ts_web/elements/views/sync/index.ts +26 -4
@@ -1,6 +1,6 @@
1
1
  import * as plugins from '../plugins.js';
2
2
  import type * as interfaces from '../../ts_interfaces/index.js';
3
- import { BaseProvider, type ITestConnectionResult, type IListOptions, type IPipelineListOptions } from './classes.baseprovider.js';
3
+ import { BaseProvider, type IReleaseAssetPayload, type IReleaseAssetTransferOptions, type ITestConnectionResult, type IListOptions, type IPipelineListOptions } from './classes.baseprovider.js';
4
4
 
5
5
  /**
6
6
  * GitLab API v4 provider implementation
@@ -188,8 +188,299 @@ export class GitLabProvider extends BaseProvider {
188
188
  }
189
189
  }
190
190
 
191
+ // --- Releases / Release Assets ---
192
+
193
+ async getReleases(projectFullPath: string): Promise<interfaces.data.IRelease[]> {
194
+ const encodedProject = encodeURIComponent(projectFullPath);
195
+ try {
196
+ const releases = await this.requestPaginated<any>(
197
+ `/api/v4/projects/${encodedProject}/releases?order_by=released_at&sort=desc`,
198
+ );
199
+ return releases.map((release) => this.mapRelease(release));
200
+ } catch (err) {
201
+ if (this.isNotFoundError(err)) return [];
202
+ throw err;
203
+ }
204
+ }
205
+
206
+ async upsertRelease(projectFullPath: string, release: interfaces.data.IRelease): Promise<interfaces.data.IRelease> {
207
+ const encodedProject = encodeURIComponent(projectFullPath);
208
+ const encodedTag = encodeURIComponent(release.tagName);
209
+ const body: Record<string, unknown> = {
210
+ name: release.name || release.tagName,
211
+ tag_name: release.tagName,
212
+ description: release.description || '',
213
+ };
214
+ if (release.releasedAt) body.released_at = release.releasedAt;
215
+
216
+ try {
217
+ await this.requestJson<any>('GET', `/api/v4/projects/${encodedProject}/releases/${encodedTag}`);
218
+ const updated = await this.requestJson<any>(
219
+ 'PUT',
220
+ `/api/v4/projects/${encodedProject}/releases/${encodedTag}`,
221
+ body,
222
+ );
223
+ return this.mapRelease(updated);
224
+ } catch (err) {
225
+ if (!this.isNotFoundError(err)) throw err;
226
+ const created = await this.requestJson<any>(
227
+ 'POST',
228
+ `/api/v4/projects/${encodedProject}/releases`,
229
+ body,
230
+ );
231
+ return this.mapRelease(created);
232
+ }
233
+ }
234
+
235
+ async deleteRelease(projectFullPath: string, tagName: string): Promise<void> {
236
+ const encodedProject = encodeURIComponent(projectFullPath);
237
+ const encodedTag = encodeURIComponent(tagName);
238
+ try {
239
+ await this.requestVoid('DELETE', `/api/v4/projects/${encodedProject}/releases/${encodedTag}`);
240
+ } catch (err) {
241
+ if (!this.isNotFoundError(err)) throw err;
242
+ }
243
+ }
244
+
245
+ async deleteReleaseAsset(
246
+ projectFullPath: string,
247
+ release: interfaces.data.IRelease,
248
+ asset: interfaces.data.IReleaseAsset,
249
+ ): Promise<void> {
250
+ if (asset.kind !== 'link' || !asset.id) return;
251
+ const encodedProject = encodeURIComponent(projectFullPath);
252
+ const encodedTag = encodeURIComponent(release.tagName);
253
+ await this.requestVoid(
254
+ 'DELETE',
255
+ `/api/v4/projects/${encodedProject}/releases/${encodedTag}/assets/links/${encodeURIComponent(asset.id)}`,
256
+ );
257
+ }
258
+
259
+ async downloadReleaseAsset(
260
+ projectFullPath: string,
261
+ release: interfaces.data.IRelease,
262
+ asset: interfaces.data.IReleaseAsset,
263
+ options?: IReleaseAssetTransferOptions,
264
+ ): Promise<IReleaseAssetPayload | null> {
265
+ const baseOrigin = new URL(this.baseUrl).origin;
266
+ if (asset.url && new URL(asset.url, this.baseUrl).origin !== baseOrigin) {
267
+ return null;
268
+ }
269
+
270
+ let url: string | undefined;
271
+ if (asset.directAssetPath) {
272
+ const encodedProject = encodeURIComponent(projectFullPath);
273
+ const encodedTag = encodeURIComponent(release.tagName);
274
+ const encodedDirectPath = asset.directAssetPath
275
+ .replace(/^\/+/, '')
276
+ .split('/')
277
+ .map((segment) => encodeURIComponent(segment))
278
+ .join('/');
279
+ url = `${this.baseUrl}/api/v4/projects/${encodedProject}/releases/${encodedTag}/downloads/${encodedDirectPath}`;
280
+ } else if (asset.downloadUrl || asset.url) {
281
+ const resolvedUrl = new URL(asset.downloadUrl || asset.url || '', this.baseUrl);
282
+ if (resolvedUrl.origin !== baseOrigin) {
283
+ return null;
284
+ }
285
+ url = resolvedUrl.toString();
286
+ }
287
+ if (!url) return null;
288
+
289
+ const response = await fetch(url, {
290
+ headers: { 'PRIVATE-TOKEN': this.token },
291
+ signal: options?.signal,
292
+ });
293
+ if (!response.ok) {
294
+ const text = await response.text();
295
+ throw new Error(`GET release asset ${asset.name}: ${response.status} - ${text}`);
296
+ }
297
+ return await this.readReleaseAssetPayload(response, asset.name, asset.contentType, options);
298
+ }
299
+
300
+ async uploadReleaseAsset(
301
+ projectFullPath: string,
302
+ release: interfaces.data.IRelease,
303
+ asset: interfaces.data.IReleaseAsset,
304
+ payload: IReleaseAssetPayload,
305
+ options?: IReleaseAssetTransferOptions,
306
+ ): Promise<interfaces.data.IReleaseAsset> {
307
+ const encodedProject = encodeURIComponent(projectFullPath);
308
+ const formData = new FormData();
309
+ const blob = new Blob([this.getPayloadArrayBuffer(payload)], {
310
+ type: payload.contentType || asset.contentType || 'application/octet-stream',
311
+ });
312
+ formData.append('file', blob, asset.name);
313
+ const uploadResponse = await fetch(`${this.baseUrl}/api/v4/projects/${encodedProject}/uploads`, {
314
+ method: 'POST',
315
+ headers: { 'PRIVATE-TOKEN': this.token },
316
+ body: formData,
317
+ signal: options?.signal,
318
+ });
319
+ if (!uploadResponse.ok) {
320
+ const text = await uploadResponse.text();
321
+ throw new Error(`POST project upload ${asset.name}: ${uploadResponse.status} - ${text}`);
322
+ }
323
+ const upload = await uploadResponse.json() as { full_path?: string; url?: string };
324
+ const uploadPath = upload.full_path || upload.url;
325
+ if (!uploadPath) {
326
+ throw new Error(`GitLab upload response did not include a path for ${asset.name}`);
327
+ }
328
+ const uploadUrl = uploadPath.startsWith('http') ? uploadPath : `${this.baseUrl}${uploadPath}`;
329
+ return await this.createReleaseAssetLink(projectFullPath, release, {
330
+ ...asset,
331
+ kind: 'link',
332
+ url: uploadUrl,
333
+ directAssetPath: asset.directAssetPath || `/gitops-mirror-assets/${release.tagName}/${asset.name}`,
334
+ });
335
+ }
336
+
337
+ async createReleaseAssetLink(
338
+ projectFullPath: string,
339
+ release: interfaces.data.IRelease,
340
+ asset: interfaces.data.IReleaseAsset,
341
+ ): Promise<interfaces.data.IReleaseAsset> {
342
+ if (!asset.url) {
343
+ throw new Error(`Release asset "${asset.name}" has no URL to link`);
344
+ }
345
+ const encodedProject = encodeURIComponent(projectFullPath);
346
+ const encodedTag = encodeURIComponent(release.tagName);
347
+ const body: Record<string, unknown> = {
348
+ name: asset.name,
349
+ url: asset.url,
350
+ link_type: asset.linkType || 'other',
351
+ };
352
+ if (asset.directAssetPath) body.direct_asset_path = asset.directAssetPath;
353
+ const created = await this.requestJson<any>(
354
+ 'POST',
355
+ `/api/v4/projects/${encodedProject}/releases/${encodedTag}/assets/links`,
356
+ body,
357
+ );
358
+ return this.mapReleaseLink(created);
359
+ }
360
+
361
+ // --- Container Images ---
362
+
363
+ async getContainerImageRepositories(projectFullPath: string): Promise<interfaces.data.IContainerImageRepository[]> {
364
+ const encodedProject = encodeURIComponent(projectFullPath);
365
+ try {
366
+ const repositories = await this.requestPaginated<any>(
367
+ `/api/v4/projects/${encodedProject}/registry/repositories?tags_count=true`,
368
+ );
369
+ const result: interfaces.data.IContainerImageRepository[] = [];
370
+ for (const repo of repositories) {
371
+ const tags = await this.requestPaginated<any>(
372
+ `/api/v4/projects/${encodedProject}/registry/repositories/${repo.id}/tags`,
373
+ );
374
+ result.push({
375
+ id: String(repo.id),
376
+ name: repo.name || '',
377
+ path: repo.path,
378
+ location: repo.location,
379
+ projectFullPath,
380
+ tags: tags.map((tag) => ({
381
+ name: tag.name,
382
+ location: tag.location || `${repo.location}:${tag.name}`,
383
+ })),
384
+ });
385
+ }
386
+ return result;
387
+ } catch (err) {
388
+ if (this.isNotFoundError(err)) return [];
389
+ throw err;
390
+ }
391
+ }
392
+
393
+ async deleteContainerImageTag(
394
+ projectFullPath: string,
395
+ repositoryId: string,
396
+ tagName: string,
397
+ ): Promise<void> {
398
+ const encodedProject = encodeURIComponent(projectFullPath);
399
+ try {
400
+ await this.requestVoid(
401
+ 'DELETE',
402
+ `/api/v4/projects/${encodedProject}/registry/repositories/${encodeURIComponent(repositoryId)}/tags/${encodeURIComponent(tagName)}`,
403
+ );
404
+ } catch (err) {
405
+ if (!this.isNotFoundError(err)) throw err;
406
+ }
407
+ }
408
+
191
409
  // --- Mappers ---
192
410
 
411
+ private async requestPaginated<T>(path: string): Promise<T[]> {
412
+ const items: T[] = [];
413
+ const perPage = 100;
414
+ const maxPages = 1000;
415
+ for (let page = 1; page <= maxPages; page++) {
416
+ const separator = path.includes('?') ? '&' : '?';
417
+ const requestPath = `${path}${separator}page=${page}&per_page=${perPage}`;
418
+ const pageItems = await this.requestJson<T[]>('GET', requestPath);
419
+ if (!Array.isArray(pageItems) || pageItems.length === 0) break;
420
+ items.push(...pageItems);
421
+ if (pageItems.length < perPage) break;
422
+ }
423
+ if (items.length >= perPage * maxPages) {
424
+ throw new Error(`Pagination exceeded ${maxPages} pages for ${path}`);
425
+ }
426
+ return items;
427
+ }
428
+
429
+ private async requestJson<T>(method: string, path: string, body?: Record<string, unknown>): Promise<T> {
430
+ const headers: Record<string, string> = { 'PRIVATE-TOKEN': this.token };
431
+ if (body) headers['Content-Type'] = 'application/json';
432
+ return await this.fetchJsonWithTimeout<T>(
433
+ `${this.baseUrl.replace(/\/+$/, '')}${path}`,
434
+ {
435
+ method,
436
+ headers,
437
+ body: body ? JSON.stringify(body) : undefined,
438
+ },
439
+ `GitLab ${method} ${path}`,
440
+ );
441
+ }
442
+
443
+ private async requestVoid(method: string, path: string): Promise<void> {
444
+ await this.fetchVoidWithTimeout(
445
+ `${this.baseUrl.replace(/\/+$/, '')}${path}`,
446
+ {
447
+ method,
448
+ headers: { 'PRIVATE-TOKEN': this.token },
449
+ },
450
+ `GitLab ${method} ${path}`,
451
+ );
452
+ }
453
+
454
+ private isNotFoundError(err: unknown): boolean {
455
+ const errMsg = err instanceof Error ? err.message : String(err);
456
+ return errMsg.includes(': 404 ') || errMsg.includes(': 404 -') || errMsg.includes(' 404 Not Found');
457
+ }
458
+
459
+ private mapRelease(raw: any): interfaces.data.IRelease {
460
+ const links = Array.isArray(raw.assets?.links) ? raw.assets.links : [];
461
+ return {
462
+ tagName: raw.tag_name,
463
+ name: raw.name || raw.tag_name,
464
+ description: raw.description || '',
465
+ releasedAt: raw.released_at,
466
+ createdAt: raw.created_at,
467
+ targetCommitish: raw.commit?.id,
468
+ assets: links.map((link: any) => this.mapReleaseLink(link)),
469
+ };
470
+ }
471
+
472
+ private mapReleaseLink(link: any): interfaces.data.IReleaseAsset {
473
+ return {
474
+ id: link.id ? String(link.id) : undefined,
475
+ name: link.name || '',
476
+ kind: 'link',
477
+ url: link.url,
478
+ downloadUrl: link.direct_asset_url,
479
+ directAssetPath: link.direct_asset_path,
480
+ linkType: link.link_type || 'other',
481
+ };
482
+ }
483
+
193
484
  private mapProject(p: plugins.gitlabClient.GitLabProject): interfaces.data.IProject {
194
485
  return {
195
486
  id: String(p.id),
@@ -0,0 +1,43 @@
1
+ export type TReleaseAssetKind = 'link' | 'attachment' | 'source';
2
+
3
+ export interface IReleaseAsset {
4
+ id?: string;
5
+ name: string;
6
+ kind: TReleaseAssetKind;
7
+ url?: string;
8
+ downloadUrl?: string;
9
+ directAssetPath?: string;
10
+ linkType?: 'other' | 'runbook' | 'image' | 'package';
11
+ contentType?: string;
12
+ size?: number;
13
+ }
14
+
15
+ export interface IRelease {
16
+ id?: string;
17
+ tagName: string;
18
+ name: string;
19
+ description: string;
20
+ releasedAt?: string;
21
+ createdAt?: string;
22
+ draft?: boolean;
23
+ preRelease?: boolean;
24
+ targetCommitish?: string;
25
+ assets: IReleaseAsset[];
26
+ }
27
+
28
+ export interface IContainerImageTag {
29
+ name: string;
30
+ digest?: string;
31
+ location: string;
32
+ createdAt?: string;
33
+ size?: number;
34
+ }
35
+
36
+ export interface IContainerImageRepository {
37
+ id: string;
38
+ name: string;
39
+ path: string;
40
+ location: string;
41
+ projectFullPath: string;
42
+ tags: IContainerImageTag[];
43
+ }
@@ -10,4 +10,7 @@ export interface IProviderConnection {
10
10
  status: 'connected' | 'disconnected' | 'error' | 'paused';
11
11
  groupFilter?: string; // Restricts which repos this connection can see (e.g. "foss.global")
12
12
  groupFilterId?: string; // Resolved filter group ID (numeric for GitLab, org name for Gitea)
13
+ registryUrl?: string; // Optional OCI registry host/base URL when it differs from provider baseUrl
14
+ registryUsername?: string; // Optional OCI registry username; provider token is used first when omitted
15
+ registryToken?: string; // Optional OCI registry token/password; stored in keychain when configured
13
16
  }
@@ -5,6 +5,7 @@ export * from './group.js';
5
5
  export * from './branch.js';
6
6
  export * from './secret.js';
7
7
  export * from './pipeline.js';
8
+ export * from './artifact.js';
8
9
  export * from './actionlog.js';
9
10
  export * from './sync.js';
10
11
  export * from './managedsecret.js';
@@ -12,8 +12,12 @@ export interface ISyncConfig {
12
12
  lastSyncError?: string;
13
13
  lastSyncDurationMs?: number;
14
14
  reposSynced: number;
15
+ releasesSynced?: number;
16
+ imagesSynced?: number;
15
17
  enforceDelete: boolean; // When true, stale target repos are moved to obsolete
16
18
  enforceGroupDelete: boolean; // When true, stale target groups/orgs are moved to obsolete
19
+ syncReleases?: boolean; // When true, release metadata and assets are mirrored exactly
20
+ syncContainerImages?: boolean; // When true, OCI image tags/manifests are mirrored exactly
17
21
  addMirrorHint?: boolean; // When true, target descriptions get "(This is a mirror of ...)" appended
18
22
  useGroupAvatarsForProjects?: boolean; // When true, projects without avatars inherit the group avatar
19
23
  createdAt: number;
@@ -26,9 +30,27 @@ export interface ISyncRepoStatus {
26
30
  targetFullPath: string; // e.g. "foss.global/push.rocks/smartstate"
27
31
  lastSyncAt: number;
28
32
  lastSyncError?: string;
33
+ sourceProjectId?: string;
34
+ targetProjectId?: string;
35
+ releasesSynced?: number;
36
+ imagesSynced?: number;
37
+ releaseSyncError?: string;
38
+ imageSyncError?: string;
29
39
  status: 'synced' | 'error' | 'pending';
30
40
  }
31
41
 
42
+ export interface ISyncProjectMapping {
43
+ id: string;
44
+ syncConfigId: string;
45
+ sourceConnectionId: string;
46
+ sourceProjectId: string;
47
+ sourceFullPath: string;
48
+ targetConnectionId: string;
49
+ targetFullPath: string;
50
+ targetProjectId?: string;
51
+ updatedAt: number;
52
+ }
53
+
32
54
  export interface ISyncLogEntry {
33
55
  timestamp: number;
34
56
  level: 'info' | 'warn' | 'error' | 'success' | 'debug';
@@ -26,6 +26,9 @@ export interface IReq_CreateConnection extends plugins.typedrequestInterfaces.im
26
26
  baseUrl: string;
27
27
  token: string;
28
28
  groupFilter?: string;
29
+ registryUrl?: string;
30
+ registryUsername?: string;
31
+ registryToken?: string;
29
32
  };
30
33
  response: {
31
34
  connection: data.IProviderConnection;
@@ -44,6 +47,9 @@ export interface IReq_UpdateConnection extends plugins.typedrequestInterfaces.im
44
47
  baseUrl?: string;
45
48
  token?: string;
46
49
  groupFilter?: string;
50
+ registryUrl?: string;
51
+ registryUsername?: string;
52
+ registryToken?: string;
47
53
  };
48
54
  response: {
49
55
  connection: data.IProviderConnection;
@@ -28,6 +28,8 @@ export interface IReq_CreateSyncConfig extends plugins.typedrequestInterfaces.im
28
28
  intervalMinutes?: number;
29
29
  enforceDelete?: boolean;
30
30
  enforceGroupDelete?: boolean;
31
+ syncReleases?: boolean;
32
+ syncContainerImages?: boolean;
31
33
  addMirrorHint?: boolean;
32
34
  useGroupAvatarsForProjects?: boolean;
33
35
  };
@@ -49,6 +51,8 @@ export interface IReq_UpdateSyncConfig extends plugins.typedrequestInterfaces.im
49
51
  intervalMinutes?: number;
50
52
  enforceDelete?: boolean;
51
53
  enforceGroupDelete?: boolean;
54
+ syncReleases?: boolean;
55
+ syncContainerImages?: boolean;
52
56
  addMirrorHint?: boolean;
53
57
  useGroupAvatarsForProjects?: boolean;
54
58
  };
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/gitops',
6
- version: '2.13.1',
6
+ version: '3.1.0',
7
7
  description: 'GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs'
8
8
  }
@@ -180,6 +180,9 @@ export const createConnectionAction = connectionsStatePart.createAction<{
180
180
  baseUrl: string;
181
181
  token: string;
182
182
  groupFilter?: string;
183
+ registryUrl?: string;
184
+ registryUsername?: string;
185
+ registryToken?: string;
183
186
  }>(async (statePartArg, dataArg) => {
184
187
  const context = getActionContext();
185
188
  try {
@@ -281,6 +284,9 @@ export const updateConnectionAction = connectionsStatePart.createAction<{
281
284
  baseUrl?: string;
282
285
  token?: string;
283
286
  groupFilter?: string;
287
+ registryUrl?: string;
288
+ registryUsername?: string;
289
+ registryToken?: string;
284
290
  }>(async (statePartArg, dataArg) => {
285
291
  const context = getActionContext();
286
292
  try {
@@ -887,6 +893,8 @@ export const createSyncConfigAction = syncStatePart.createAction<{
887
893
  intervalMinutes?: number;
888
894
  enforceDelete?: boolean;
889
895
  enforceGroupDelete?: boolean;
896
+ syncReleases?: boolean;
897
+ syncContainerImages?: boolean;
890
898
  addMirrorHint?: boolean;
891
899
  useGroupAvatarsForProjects?: boolean;
892
900
  }>(async (statePartArg, dataArg) => {
@@ -915,6 +923,8 @@ export const updateSyncConfigAction = syncStatePart.createAction<{
915
923
  intervalMinutes?: number;
916
924
  enforceDelete?: boolean;
917
925
  enforceGroupDelete?: boolean;
926
+ syncReleases?: boolean;
927
+ syncContainerImages?: boolean;
918
928
  addMirrorHint?: boolean;
919
929
  useGroupAvatarsForProjects?: boolean;
920
930
  }>(async (statePartArg, dataArg) => {
@@ -63,6 +63,8 @@ export class GitopsViewConnections extends DeesElement {
63
63
  Type: item.providerType,
64
64
  URL: item.baseUrl,
65
65
  'Group Filter': item.groupFilter || '-',
66
+ Registry: item.registryUrl || '-',
67
+ 'Registry User': item.registryUsername || '-',
66
68
  Status: item.status,
67
69
  Created: new Date(item.createdAt).toLocaleDateString(),
68
70
  })}
@@ -168,6 +170,15 @@ export class GitopsViewConnections extends DeesElement {
168
170
  <div class="form-row">
169
171
  <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>
170
172
  </div>
173
+ <div class="form-row">
174
+ <dees-input-text .label=${'Registry URL (optional)'} .key=${'registryUrl'} .value=${item.registryUrl || ''} .description=${'OCI registry host/base URL when it differs from the provider base URL, for example registry.example.com:5050.'}></dees-input-text>
175
+ </div>
176
+ <div class="form-row">
177
+ <dees-input-text .label=${'Registry Username (optional)'} .key=${'registryUsername'} .value=${item.registryUsername || ''} .description=${'Optional OCI registry username. GitLab defaults to oauth2 with the API token when omitted.'}></dees-input-text>
178
+ </div>
179
+ <div class="form-row">
180
+ <dees-input-text .label=${'Registry Token (leave empty to keep current)'} .key=${'registryToken'} type="password" .description=${'Optional OCI registry token/password. When omitted, the provider API token is tried first.'}></dees-input-text>
181
+ </div>
171
182
  `,
172
183
  menuOptions: [
173
184
  { name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
@@ -186,7 +197,10 @@ export class GitopsViewConnections extends DeesElement {
186
197
  name: data.name,
187
198
  baseUrl: data.baseUrl,
188
199
  groupFilter: data.groupFilter,
200
+ registryUrl: data.registryUrl,
201
+ registryUsername: data.registryUsername,
189
202
  ...(data.token ? { token: data.token } : {}),
203
+ ...(data.registryToken ? { registryToken: data.registryToken } : {}),
190
204
  },
191
205
  );
192
206
  modal.destroy();
@@ -226,6 +240,15 @@ export class GitopsViewConnections extends DeesElement {
226
240
  <div class="form-row">
227
241
  <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>
228
242
  </div>
243
+ <div class="form-row">
244
+ <dees-input-text .label=${'Registry URL (optional)'} .key=${'registryUrl'} .description=${'OCI registry host/base URL when it differs from the provider base URL, for example registry.example.com:5050.'}></dees-input-text>
245
+ </div>
246
+ <div class="form-row">
247
+ <dees-input-text .label=${'Registry Username (optional)'} .key=${'registryUsername'} .description=${'Optional OCI registry username. GitLab defaults to oauth2 with the API token when omitted.'}></dees-input-text>
248
+ </div>
249
+ <div class="form-row">
250
+ <dees-input-text .label=${'Registry Token (optional)'} .key=${'registryToken'} type="password" .description=${'Optional OCI registry token/password. When omitted, the provider API token is tried first.'}></dees-input-text>
251
+ </div>
229
252
  `,
230
253
  menuOptions: [
231
254
  { name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
@@ -249,6 +272,9 @@ export class GitopsViewConnections extends DeesElement {
249
272
  baseUrl: data.baseUrl,
250
273
  token: data.token,
251
274
  groupFilter: data.groupFilter || undefined,
275
+ registryUrl: data.registryUrl || undefined,
276
+ registryUsername: data.registryUsername || undefined,
277
+ registryToken: data.registryToken || undefined,
252
278
  },
253
279
  );
254
280
  modal.destroy();
@@ -103,10 +103,14 @@ export class GitopsViewSync extends DeesElement {
103
103
  Status: item.status,
104
104
  'Enforce Delete': item.enforceDelete ? 'Yes' : 'No',
105
105
  'Enforce Group Delete': item.enforceGroupDelete ? 'Yes' : 'No',
106
+ 'Sync Releases': item.syncReleases ? 'Yes' : 'No',
107
+ 'Sync Images': item.syncContainerImages ? 'Yes' : 'No',
106
108
  'Mirror Hint': item.addMirrorHint ? 'Yes' : 'No',
107
109
  'Group Avatars': item.useGroupAvatarsForProjects ? 'Yes' : 'No',
108
110
  'Last Sync': item.lastSyncAt ? new Date(item.lastSyncAt).toLocaleString() : 'Never',
109
111
  Repos: String(item.reposSynced),
112
+ Releases: String(item.releasesSynced || 0),
113
+ Images: String(item.imagesSynced || 0),
110
114
  };
111
115
  }}
112
116
  .dataActions=${[
@@ -286,6 +290,12 @@ export class GitopsViewSync extends DeesElement {
286
290
  <div class="form-row">
287
291
  <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>
288
292
  </div>
293
+ <div class="form-row">
294
+ <dees-input-checkbox .label=${'Sync Releases'} .key=${'syncReleases'} .value=${false} .description=${'When enabled, releases and release assets are mirrored to the target.'}></dees-input-checkbox>
295
+ </div>
296
+ <div class="form-row">
297
+ <dees-input-checkbox .label=${'Sync Container Images'} .key=${'syncContainerImages'} .value=${false} .description=${'When enabled, OCI container image tags are mirrored to the target registry.'}></dees-input-checkbox>
298
+ </div>
289
299
  <div class="form-row">
290
300
  <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>
291
301
  </div>
@@ -303,7 +313,7 @@ export class GitopsViewSync extends DeesElement {
303
313
  for (const input of inputs) {
304
314
  if (input.key === 'sourceConnectionId' || input.key === 'targetConnectionId') {
305
315
  data[input.key] = input.selectedOption?.key || '';
306
- } else if (input.key === 'enforceDelete' || input.key === 'enforceGroupDelete' || input.key === 'addMirrorHint' || input.key === 'useGroupAvatarsForProjects') {
316
+ } else if (input.key === 'enforceDelete' || input.key === 'enforceGroupDelete' || input.key === 'syncReleases' || input.key === 'syncContainerImages' || input.key === 'addMirrorHint' || input.key === 'useGroupAvatarsForProjects') {
307
317
  data[input.key] = input.getValue();
308
318
  } else {
309
319
  data[input.key] = input.value || '';
@@ -313,10 +323,12 @@ export class GitopsViewSync extends DeesElement {
313
323
  name: data.name,
314
324
  sourceConnectionId: data.sourceConnectionId,
315
325
  targetConnectionId: data.targetConnectionId,
316
- targetGroupOffset: data.targetGroupOffset || undefined,
326
+ targetGroupOffset: data.targetGroupOffset,
317
327
  intervalMinutes: parseInt(data.intervalMinutes) || 5,
318
328
  enforceDelete: !!data.enforceDelete,
319
329
  enforceGroupDelete: !!data.enforceGroupDelete,
330
+ syncReleases: !!data.syncReleases,
331
+ syncContainerImages: !!data.syncContainerImages,
320
332
  addMirrorHint: !!data.addMirrorHint,
321
333
  useGroupAvatarsForProjects: !!data.useGroupAvatarsForProjects,
322
334
  });
@@ -347,6 +359,12 @@ export class GitopsViewSync extends DeesElement {
347
359
  <div class="form-row">
348
360
  <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>
349
361
  </div>
362
+ <div class="form-row">
363
+ <dees-input-checkbox .label=${'Sync Releases'} .key=${'syncReleases'} .value=${!!item.syncReleases} .description=${'When enabled, releases and release assets are mirrored to the target.'}></dees-input-checkbox>
364
+ </div>
365
+ <div class="form-row">
366
+ <dees-input-checkbox .label=${'Sync Container Images'} .key=${'syncContainerImages'} .value=${!!item.syncContainerImages} .description=${'When enabled, OCI container image tags are mirrored to the target registry.'}></dees-input-checkbox>
367
+ </div>
350
368
  <div class="form-row">
351
369
  <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>
352
370
  </div>
@@ -362,7 +380,7 @@ export class GitopsViewSync extends DeesElement {
362
380
  const inputs = modal.shadowRoot.querySelectorAll('dees-input-text, dees-input-checkbox');
363
381
  const data: any = {};
364
382
  for (const input of inputs) {
365
- if (input.key === 'enforceDelete' || input.key === 'enforceGroupDelete' || input.key === 'addMirrorHint' || input.key === 'useGroupAvatarsForProjects') {
383
+ if (input.key === 'enforceDelete' || input.key === 'enforceGroupDelete' || input.key === 'syncReleases' || input.key === 'syncContainerImages' || input.key === 'addMirrorHint' || input.key === 'useGroupAvatarsForProjects') {
366
384
  data[input.key] = input.getValue();
367
385
  } else {
368
386
  data[input.key] = input.value || '';
@@ -371,10 +389,12 @@ export class GitopsViewSync extends DeesElement {
371
389
  await appstate.syncStatePart.dispatchAction(appstate.updateSyncConfigAction, {
372
390
  syncConfigId: item.id,
373
391
  name: data.name,
374
- targetGroupOffset: data.targetGroupOffset || undefined,
392
+ targetGroupOffset: data.targetGroupOffset,
375
393
  intervalMinutes: parseInt(data.intervalMinutes) || 5,
376
394
  enforceDelete: !!data.enforceDelete,
377
395
  enforceGroupDelete: !!data.enforceGroupDelete,
396
+ syncReleases: !!data.syncReleases,
397
+ syncContainerImages: !!data.syncContainerImages,
378
398
  addMirrorHint: !!data.addMirrorHint,
379
399
  useGroupAvatarsForProjects: !!data.useGroupAvatarsForProjects,
380
400
  });
@@ -494,6 +514,8 @@ export class GitopsViewSync extends DeesElement {
494
514
  <div>
495
515
  <div class="repo-path">${s.sourceFullPath}</div>
496
516
  ${s.lastSyncError ? html`<div class="repo-error">${s.lastSyncError}</div>` : ''}
517
+ ${s.releaseSyncError ? html`<div class="repo-error">Release sync: ${s.releaseSyncError}</div>` : ''}
518
+ ${s.imageSyncError ? html`<div class="repo-error">Image sync: ${s.imageSyncError}</div>` : ''}
497
519
  </div>
498
520
  <div>
499
521
  <span class="repo-status ${s.status}">${s.status}</span>