@forgeportal/plugin-argocd 1.3.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 (45) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/LICENSE +21 -0
  3. package/dist/ArgocdTab.d.ts +8 -0
  4. package/dist/ArgocdTab.d.ts.map +1 -0
  5. package/dist/ArgocdTab.js +69 -0
  6. package/dist/ArgocdTab.js.map +1 -0
  7. package/dist/__tests__/api-client.test.d.ts +2 -0
  8. package/dist/__tests__/api-client.test.d.ts.map +1 -0
  9. package/dist/__tests__/api-client.test.js +129 -0
  10. package/dist/__tests__/api-client.test.js.map +1 -0
  11. package/dist/actions.d.ts +23 -0
  12. package/dist/actions.d.ts.map +1 -0
  13. package/dist/actions.js +106 -0
  14. package/dist/actions.js.map +1 -0
  15. package/dist/api-client.d.ts +35 -0
  16. package/dist/api-client.d.ts.map +1 -0
  17. package/dist/api-client.js +73 -0
  18. package/dist/api-client.js.map +1 -0
  19. package/dist/index.d.ts +14 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +34 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/routes.d.ts +13 -0
  24. package/dist/routes.d.ts.map +1 -0
  25. package/dist/routes.js +92 -0
  26. package/dist/routes.js.map +1 -0
  27. package/dist/types.d.ts +62 -0
  28. package/dist/types.d.ts.map +1 -0
  29. package/dist/types.js +3 -0
  30. package/dist/types.js.map +1 -0
  31. package/dist/ui.d.ts +11 -0
  32. package/dist/ui.d.ts.map +1 -0
  33. package/dist/ui.js +17 -0
  34. package/dist/ui.js.map +1 -0
  35. package/forgeportal-plugin.json +32 -0
  36. package/package.json +51 -0
  37. package/src/ArgocdTab.tsx +305 -0
  38. package/src/__tests__/api-client.test.ts +156 -0
  39. package/src/actions.ts +118 -0
  40. package/src/api-client.ts +90 -0
  41. package/src/index.ts +46 -0
  42. package/src/routes.ts +127 -0
  43. package/src/types.ts +65 -0
  44. package/src/ui.ts +18 -0
  45. package/tsconfig.json +11 -0
package/dist/routes.js ADDED
@@ -0,0 +1,92 @@
1
+ import { ArgocdApiClient } from './api-client.js';
2
+ /**
3
+ * Creates Fastify route handlers for the ArgoCD plugin.
4
+ * All routes are mounted under /api/v1/plugins/argocd/ by the plugin loader.
5
+ *
6
+ * Routes:
7
+ * GET entities/:entityId/app — app summary (status, health, revision)
8
+ * GET entities/:entityId/history — last 10 sync operations
9
+ * POST entities/:entityId/sync — trigger sync
10
+ */
11
+ export function createRoutes(config) {
12
+ const client = new ArgocdApiClient(config);
13
+ return async function handler(fastify) {
14
+ /**
15
+ * GET /entities/:entityId/app?appName=<override>
16
+ *
17
+ * Returns the ArgoCD application summary: sync status, health, revision, operation state.
18
+ * The app name is read from the query param or entity annotations.
19
+ */
20
+ fastify.get('entities/:entityId/app', async (request, reply) => {
21
+ const appName = request.query.appName;
22
+ if (!appName) {
23
+ return reply.status(400).send({
24
+ error: 'Bad Request',
25
+ message: 'Query parameter "appName" is required, or set the forgeportal.dev/argocd-app-name annotation.',
26
+ });
27
+ }
28
+ try {
29
+ const app = await client.getApp(appName);
30
+ return reply.send({ data: app });
31
+ }
32
+ catch (err) {
33
+ const message = err instanceof Error ? err.message : String(err);
34
+ request.log.error({ err }, 'argocd plugin: getApp failed');
35
+ if (message.includes('404')) {
36
+ return reply.status(404).send({ error: 'Not Found', message: `ArgoCD app "${appName}" not found.` });
37
+ }
38
+ return reply.status(502).send({ error: 'Bad Gateway', message });
39
+ }
40
+ });
41
+ /**
42
+ * GET /entities/:entityId/history?appName=<override>
43
+ *
44
+ * Returns the last 10 sync history entries for the ArgoCD application.
45
+ */
46
+ fastify.get('entities/:entityId/history', async (request, reply) => {
47
+ const appName = request.query.appName;
48
+ if (!appName) {
49
+ return reply.status(400).send({
50
+ error: 'Bad Request',
51
+ message: 'Query parameter "appName" is required.',
52
+ });
53
+ }
54
+ try {
55
+ const history = await client.getHistory(appName);
56
+ return reply.send({ data: history });
57
+ }
58
+ catch (err) {
59
+ const message = err instanceof Error ? err.message : String(err);
60
+ request.log.error({ err }, 'argocd plugin: getHistory failed');
61
+ return reply.status(502).send({ error: 'Bad Gateway', message });
62
+ }
63
+ });
64
+ /**
65
+ * POST /entities/:entityId/sync
66
+ *
67
+ * Body: { appName: string }
68
+ * Triggers a manual sync of the ArgoCD application.
69
+ */
70
+ fastify.post('entities/:entityId/sync', async (request, reply) => {
71
+ const { appName } = request.body ?? {};
72
+ if (!appName) {
73
+ return reply.status(400).send({
74
+ error: 'Bad Request',
75
+ message: 'Body field "appName" is required.',
76
+ });
77
+ }
78
+ try {
79
+ await client.syncApp(appName);
80
+ return reply.status(202).send({
81
+ data: { appName, syncTriggeredAt: new Date().toISOString() },
82
+ });
83
+ }
84
+ catch (err) {
85
+ const message = err instanceof Error ? err.message : String(err);
86
+ request.log.error({ err }, 'argocd plugin: syncApp failed');
87
+ return reply.status(502).send({ error: 'Bad Gateway', message });
88
+ }
89
+ });
90
+ };
91
+ }
92
+ //# sourceMappingURL=routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAKlD;;;;;;;;GAQG;AACH,MAAM,UAAU,YAAY,CAAC,MAAoB;IAC/C,MAAM,MAAM,GAAG,IAAI,eAAe,CAAC,MAAM,CAAC,CAAC;IAE3C,OAAO,KAAK,UAAU,OAAO,CAAC,OAAwB;QACpD;;;;;WAKG;QACH,OAAO,CAAC,GAAG,CACT,wBAAwB,EACxB,KAAK,EACH,OAGE,EACF,KAAmB,EACnB,EAAE;YACF,MAAM,OAAO,GAAG,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC;YAEtC,IAAI,CAAC,OAAO,EAAE,CAAC;gBACb,OAAO,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;oBAC5B,KAAK,EAAI,aAAa;oBACtB,OAAO,EAAE,+FAA+F;iBACzG,CAAC,CAAC;YACL,CAAC;YAED,IAAI,CAAC;gBACH,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;gBACzC,OAAO,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC;YACnC,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,MAAM,OAAO,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;gBACjE,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,EAAE,8BAA8B,CAAC,CAAC;gBAC3D,IAAI,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;oBAC5B,OAAO,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE,OAAO,EAAE,eAAe,OAAO,cAAc,EAAE,CAAC,CAAC;gBACvG,CAAC;gBACD,OAAO,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,CAAC,CAAC;YACnE,CAAC;QACH,CAAC,CACF,CAAC;QAEF;;;;WAIG;QACH,OAAO,CAAC,GAAG,CACT,4BAA4B,EAC5B,KAAK,EACH,OAGE,EACF,KAAmB,EACnB,EAAE;YACF,MAAM,OAAO,GAAG,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC;YAEtC,IAAI,CAAC,OAAO,EAAE,CAAC;gBACb,OAAO,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;oBAC5B,KAAK,EAAI,aAAa;oBACtB,OAAO,EAAE,wCAAwC;iBAClD,CAAC,CAAC;YACL,CAAC;YAED,IAAI,CAAC;gBACH,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC;gBACjD,OAAO,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC;YACvC,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,MAAM,OAAO,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;gBACjE,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,EAAE,kCAAkC,CAAC,CAAC;gBAC/D,OAAO,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,CAAC,CAAC;YACnE,CAAC;QACH,CAAC,CACF,CAAC;QAEF;;;;;WAKG;QACH,OAAO,CAAC,IAAI,CACV,yBAAyB,EACzB,KAAK,EACH,OAAiE,EACjE,KAAqB,EACrB,EAAE;YACF,MAAM,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC,IAAI,IAAI,EAAE,CAAC;YAEvC,IAAI,CAAC,OAAO,EAAE,CAAC;gBACb,OAAO,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;oBAC5B,KAAK,EAAI,aAAa;oBACtB,OAAO,EAAE,mCAAmC;iBAC7C,CAAC,CAAC;YACL,CAAC;YAED,IAAI,CAAC;gBACH,MAAM,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;gBAC9B,OAAO,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;oBAC5B,IAAI,EAAE,EAAE,OAAO,EAAE,eAAe,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,EAAE;iBAC7D,CAAC,CAAC;YACL,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,MAAM,OAAO,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;gBACjE,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,EAAE,+BAA+B,CAAC,CAAC;gBAC5D,OAAO,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,CAAC,CAAC;YACnE,CAAC;QACH,CAAC,CACF,CAAC;IACJ,CAAC,CAAC;AACJ,CAAC"}
@@ -0,0 +1,62 @@
1
+ export interface ArgocdAppStatus {
2
+ sync: {
3
+ status: 'Synced' | 'OutOfSync' | 'Unknown';
4
+ revision: string;
5
+ };
6
+ health: {
7
+ status: 'Healthy' | 'Degraded' | 'Progressing' | 'Suspended' | 'Missing' | 'Unknown';
8
+ };
9
+ operationState?: {
10
+ phase: string;
11
+ message?: string;
12
+ startedAt: string;
13
+ finishedAt?: string;
14
+ };
15
+ reconciledAt?: string;
16
+ }
17
+ export interface ArgocdApp {
18
+ metadata: {
19
+ name: string;
20
+ namespace: string;
21
+ };
22
+ spec: {
23
+ project: string;
24
+ source?: {
25
+ repoURL: string;
26
+ targetRevision: string;
27
+ path?: string;
28
+ };
29
+ destination: {
30
+ server: string;
31
+ namespace: string;
32
+ };
33
+ };
34
+ status: ArgocdAppStatus;
35
+ }
36
+ export interface ArgocdHistoryItem {
37
+ id: number;
38
+ revision: string;
39
+ deployedAt: string;
40
+ initiatedBy?: {
41
+ username?: string;
42
+ automated?: boolean;
43
+ };
44
+ }
45
+ export interface ArgocdResourceNode {
46
+ kind: string;
47
+ name: string;
48
+ namespace?: string;
49
+ status?: string;
50
+ health?: {
51
+ status: string;
52
+ };
53
+ }
54
+ export interface ArgocdResourceTree {
55
+ nodes: ArgocdResourceNode[];
56
+ }
57
+ export interface ArgocdConfig {
58
+ url: string;
59
+ token: string;
60
+ insecure: boolean;
61
+ }
62
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE;QACJ,MAAM,EAAI,QAAQ,GAAG,WAAW,GAAG,SAAS,CAAC;QAC7C,QAAQ,EAAE,MAAM,CAAC;KAClB,CAAC;IACF,MAAM,EAAE;QACN,MAAM,EAAE,SAAS,GAAG,UAAU,GAAG,aAAa,GAAG,WAAW,GAAG,SAAS,GAAG,SAAS,CAAC;KACtF,CAAC;IACF,cAAc,CAAC,EAAE;QACf,KAAK,EAAM,MAAM,CAAC;QAClB,OAAO,CAAC,EAAG,MAAM,CAAC;QAClB,SAAS,EAAE,MAAM,CAAC;QAClB,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,CAAC;IACF,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE;QACR,IAAI,EAAO,MAAM,CAAC;QAClB,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;IACF,IAAI,EAAE;QACJ,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,CAAC,EAAE;YACP,OAAO,EAAS,MAAM,CAAC;YACvB,cAAc,EAAE,MAAM,CAAC;YACvB,IAAI,CAAC,EAAW,MAAM,CAAC;SACxB,CAAC;QACF,WAAW,EAAE;YACX,MAAM,EAAK,MAAM,CAAC;YAClB,SAAS,EAAE,MAAM,CAAC;SACnB,CAAC;KACH,CAAC;IACF,MAAM,EAAE,eAAe,CAAC;CACzB;AAED,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAU,MAAM,CAAC;IACnB,QAAQ,EAAI,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;CAC1D;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAQ,MAAM,CAAC;IACnB,IAAI,EAAQ,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAK,MAAM,CAAC;IACnB,MAAM,CAAC,EAAK;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CAChC;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,kBAAkB,EAAE,CAAC;CAC7B;AAID,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAO,MAAM,CAAC;IACjB,KAAK,EAAK,MAAM,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;CACnB"}
package/dist/types.js ADDED
@@ -0,0 +1,3 @@
1
+ // ─── ArgoCD API response shapes ──────────────────────────────────────────────
2
+ export {};
3
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,gFAAgF"}
package/dist/ui.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { ForgePluginSDK } from '@forgeportal/plugin-sdk';
2
+ /**
3
+ * UI entry point for the ArgoCD plugin.
4
+ * Called by the ForgePortal UI shell at startup.
5
+ *
6
+ * Registration in apps/ui/src/plugins/index.ts:
7
+ * import { registerPlugin as registerArgocd } from '@forgeportal/plugin-argocd/ui';
8
+ * registerPluginById('argocd', registerArgocd);
9
+ */
10
+ export declare function registerPlugin(sdk: ForgePluginSDK): void;
11
+ //# sourceMappingURL=ui.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ui.d.ts","sourceRoot":"","sources":["../src/ui.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAG9D;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,cAAc,GAAG,IAAI,CAMxD"}
package/dist/ui.js ADDED
@@ -0,0 +1,17 @@
1
+ import { ArgocdTab } from './ArgocdTab.js';
2
+ /**
3
+ * UI entry point for the ArgoCD plugin.
4
+ * Called by the ForgePortal UI shell at startup.
5
+ *
6
+ * Registration in apps/ui/src/plugins/index.ts:
7
+ * import { registerPlugin as registerArgocd } from '@forgeportal/plugin-argocd/ui';
8
+ * registerPluginById('argocd', registerArgocd);
9
+ */
10
+ export function registerPlugin(sdk) {
11
+ sdk.registerEntityTab({
12
+ id: 'argocd-tab',
13
+ title: 'ArgoCD',
14
+ component: ArgocdTab,
15
+ });
16
+ }
17
+ //# sourceMappingURL=ui.js.map
package/dist/ui.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ui.js","sourceRoot":"","sources":["../src/ui.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAE3C;;;;;;;GAOG;AACH,MAAM,UAAU,cAAc,CAAC,GAAmB;IAChD,GAAG,CAAC,iBAAiB,CAAC;QACpB,EAAE,EAAS,YAAY;QACvB,KAAK,EAAM,QAAQ;QACnB,SAAS,EAAE,SAAS;KACrB,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@forgeportal/plugin-argocd",
3
+ "version": "1.0.0",
4
+ "forgeportal": {
5
+ "engineVersion": "^1.0.0",
6
+ "type": "fullstack",
7
+ "capabilities": {
8
+ "ui": {
9
+ "entityTabs": ["argocd-tab"]
10
+ },
11
+ "backend": {
12
+ "routes": ["/api/v1/plugins/argocd"],
13
+ "actionProviders": [
14
+ "argocd.syncApp@v1",
15
+ "argocd.rollbackApp@v1"
16
+ ]
17
+ }
18
+ },
19
+ "config": {
20
+ "url": {
21
+ "type": "string",
22
+ "description": "ArgoCD server URL, e.g. https://argocd.internal",
23
+ "required": true
24
+ },
25
+ "insecure": {
26
+ "type": "boolean",
27
+ "description": "Skip TLS verification (not recommended in production).",
28
+ "required": false
29
+ }
30
+ }
31
+ }
32
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@forgeportal/plugin-argocd",
3
+ "version": "1.3.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./dist/index.d.ts",
8
+ "import": "./dist/index.js"
9
+ },
10
+ "./ui": {
11
+ "types": "./dist/ui.d.ts",
12
+ "import": "./dist/ui.js"
13
+ },
14
+ "./package.json": "./package.json",
15
+ "./forgeportal-plugin.json": "./forgeportal-plugin.json"
16
+ },
17
+ "main": "dist/index.js",
18
+ "types": "dist/index.d.ts",
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "dependencies": {
23
+ "undici": "^7.22.0",
24
+ "@forgeportal/plugin-sdk": "1.3.0"
25
+ },
26
+ "devDependencies": {
27
+ "@tanstack/react-query": "*",
28
+ "@types/react": "*",
29
+ "fastify": "^5.3.3",
30
+ "react": "*",
31
+ "vitest": "*"
32
+ },
33
+ "peerDependencies": {
34
+ "@tanstack/react-query": ">=5",
35
+ "react": ">=19"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "react": {
39
+ "optional": true
40
+ },
41
+ "@tanstack/react-query": {
42
+ "optional": true
43
+ }
44
+ },
45
+ "scripts": {
46
+ "build": "tsc",
47
+ "test": "vitest run",
48
+ "lint": "eslint src/",
49
+ "clean": "rm -rf dist"
50
+ }
51
+ }
@@ -0,0 +1,305 @@
1
+ import React, { useState, useCallback } from 'react';
2
+ import { useApi } from '@forgeportal/plugin-sdk/react';
3
+ import type { Entity } from '@forgeportal/plugin-sdk';
4
+
5
+ // ─── Types ────────────────────────────────────────────────────────────────────
6
+
7
+ interface SyncStatus { status: 'Synced' | 'OutOfSync' | 'Unknown'; revision?: string }
8
+ interface HealthStatus { status: 'Healthy' | 'Degraded' | 'Progressing' | 'Suspended' | 'Missing' | 'Unknown' }
9
+
10
+ interface ArgocdAppResponse {
11
+ data: {
12
+ metadata: { name: string; namespace: string };
13
+ spec: {
14
+ project: string;
15
+ source?: { repoURL: string; targetRevision: string; path?: string };
16
+ destination: { server: string; namespace: string };
17
+ };
18
+ status: {
19
+ sync: SyncStatus;
20
+ health: HealthStatus;
21
+ operationState?: { phase: string; message?: string; startedAt: string; finishedAt?: string };
22
+ reconciledAt?: string;
23
+ };
24
+ };
25
+ }
26
+
27
+ interface HistoryItem {
28
+ id: number;
29
+ revision: string;
30
+ deployedAt: string;
31
+ initiatedBy?: { username?: string; automated?: boolean };
32
+ }
33
+
34
+ interface HistoryResponse { data: HistoryItem[] }
35
+
36
+ // ─── Badges ───────────────────────────────────────────────────────────────────
37
+
38
+ function SyncBadge({ status }: { status: string }): React.ReactElement {
39
+ const colours: Record<string, string> = {
40
+ Synced: 'bg-green-100 text-green-800',
41
+ OutOfSync: 'bg-amber-100 text-amber-800',
42
+ Unknown: 'bg-gray-100 text-gray-600',
43
+ };
44
+ return (
45
+ <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${colours[status] ?? colours['Unknown']}`}>
46
+ {status}
47
+ </span>
48
+ );
49
+ }
50
+
51
+ function HealthBadge({ status }: { status: string }): React.ReactElement {
52
+ const colours: Record<string, string> = {
53
+ Healthy: 'bg-green-100 text-green-800',
54
+ Degraded: 'bg-red-100 text-red-800',
55
+ Progressing: 'bg-blue-100 text-blue-800',
56
+ Suspended: 'bg-orange-100 text-orange-800',
57
+ Missing: 'bg-gray-100 text-gray-600',
58
+ Unknown: 'bg-gray-100 text-gray-600',
59
+ };
60
+ return (
61
+ <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${colours[status] ?? colours['Unknown']}`}>
62
+ {status}
63
+ </span>
64
+ );
65
+ }
66
+
67
+ // ─── Main Tab ─────────────────────────────────────────────────────────────────
68
+
69
+ interface ArgocdTabProps { entity: Entity }
70
+
71
+ export function ArgocdTab({ entity }: ArgocdTabProps): React.ReactElement {
72
+ const [syncing, setSyncing] = useState(false);
73
+ const [syncMessage, setSyncMessage] = useState<string | null>(null);
74
+
75
+ const annotations = entity.annotations ?? {};
76
+ const appName = annotations['forgeportal.dev/argocd-app-name'];
77
+
78
+ // Not configured state
79
+ if (!appName) {
80
+ return (
81
+ <div className="rounded-lg border border-dashed border-gray-200 bg-gray-50 p-8 text-center">
82
+ <p className="text-sm font-medium text-gray-700 mb-1">ArgoCD not configured for this entity</p>
83
+ <p className="text-xs text-gray-500 mb-4">
84
+ Add the annotation{' '}
85
+ <code className="rounded bg-gray-100 px-1 py-0.5">forgeportal.dev/argocd-app-name</code>{' '}
86
+ to your <code className="rounded bg-gray-100 px-1 py-0.5">entity.yaml</code> to see the ArgoCD status.
87
+ </p>
88
+ <pre className="mx-auto max-w-md rounded bg-gray-800 p-3 text-left text-xs text-green-300">
89
+ {`metadata:\n annotations:\n forgeportal.dev/argocd-app-name: my-app-prod\n forgeportal.dev/argocd-project: platform # optional`}
90
+ </pre>
91
+ </div>
92
+ );
93
+ }
94
+
95
+ const {
96
+ data: appData,
97
+ isPending: appLoading,
98
+ error: appError,
99
+ refetch: refetchApp,
100
+ } = useApi<ArgocdAppResponse>(
101
+ `/api/v1/plugins/argocd/entities/${entity.id}/app?appName=${encodeURIComponent(appName)}`,
102
+ { refetchInterval: 30_000 },
103
+ );
104
+
105
+ const {
106
+ data: historyData,
107
+ isPending: historyLoading,
108
+ } = useApi<HistoryResponse>(
109
+ `/api/v1/plugins/argocd/entities/${entity.id}/history?appName=${encodeURIComponent(appName)}`,
110
+ );
111
+
112
+ const triggerSync = useCallback(async () => {
113
+ if (!appName || syncing) return;
114
+ setSyncing(true);
115
+ try {
116
+ const res = await fetch(`/api/v1/plugins/argocd/entities/${entity.id}/sync`, {
117
+ method: 'POST',
118
+ credentials: 'include',
119
+ headers: { 'Content-Type': 'application/json' },
120
+ body: JSON.stringify({ appName }),
121
+ });
122
+ if (res.ok) {
123
+ const json = await res.json() as { data: { syncTriggeredAt: string } };
124
+ setSyncMessage(`Sync triggered at ${new Date(json.data.syncTriggeredAt).toLocaleTimeString()}`);
125
+ void refetchApp();
126
+ setTimeout(() => setSyncMessage(null), 5000);
127
+ }
128
+ } finally {
129
+ setSyncing(false);
130
+ }
131
+ }, [appName, entity.id, syncing, refetchApp]);
132
+
133
+ const app = appData?.data;
134
+ const history = historyData?.data ?? [];
135
+
136
+ return (
137
+ <div className="space-y-6">
138
+ {/* Toolbar */}
139
+ <div className="flex items-center justify-between">
140
+ <div className="flex items-center gap-3">
141
+ {app && (
142
+ <span className="text-xs text-gray-500">
143
+ App: <strong>{app.metadata.name}</strong> · Project: <strong>{app.spec.project}</strong>
144
+ </span>
145
+ )}
146
+ </div>
147
+ <div className="flex items-center gap-2">
148
+ {syncMessage && (
149
+ <span className="text-xs text-green-600 font-medium">{syncMessage}</span>
150
+ )}
151
+ <button
152
+ onClick={() => void triggerSync()}
153
+ disabled={syncing || appLoading}
154
+ className="inline-flex items-center gap-1.5 rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm hover:bg-indigo-700 disabled:opacity-60 transition-colors"
155
+ >
156
+ {syncing ? (
157
+ <>
158
+ <svg className="h-3 w-3 animate-spin" fill="none" viewBox="0 0 24 24">
159
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
160
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
161
+ </svg>
162
+ Syncing…
163
+ </>
164
+ ) : (
165
+ 'Sync Now'
166
+ )}
167
+ </button>
168
+ </div>
169
+ </div>
170
+
171
+ {/* Loading */}
172
+ {appLoading && (
173
+ <div className="flex items-center gap-2 text-sm text-gray-500">
174
+ <svg className="h-4 w-4 animate-spin text-indigo-500" fill="none" viewBox="0 0 24 24">
175
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
176
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
177
+ </svg>
178
+ Loading ArgoCD status…
179
+ </div>
180
+ )}
181
+
182
+ {/* Error */}
183
+ {appError && !appLoading && (
184
+ <div className="rounded-md bg-red-50 border border-red-200 p-4 text-sm text-red-700">
185
+ Failed to load ArgoCD data: {appError instanceof Error ? appError.message : 'Unknown error'}
186
+ </div>
187
+ )}
188
+
189
+ {/* Status cards */}
190
+ {app && (
191
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
192
+ {/* Sync status */}
193
+ <div className="rounded-lg border border-gray-200 bg-white p-4">
194
+ <p className="text-xs font-medium text-gray-500 mb-2">Sync Status</p>
195
+ <SyncBadge status={app.status.sync.status} />
196
+ {app.status.sync.revision && (
197
+ <p className="mt-2 font-mono text-xs text-gray-500 truncate" title={app.status.sync.revision}>
198
+ {app.status.sync.revision.slice(0, 8)}
199
+ </p>
200
+ )}
201
+ </div>
202
+
203
+ {/* Health */}
204
+ <div className="rounded-lg border border-gray-200 bg-white p-4">
205
+ <p className="text-xs font-medium text-gray-500 mb-2">Health</p>
206
+ <HealthBadge status={app.status.health.status} />
207
+ </div>
208
+
209
+ {/* Last reconciled */}
210
+ <div className="rounded-lg border border-gray-200 bg-white p-4">
211
+ <p className="text-xs font-medium text-gray-500 mb-2">Last Reconciled</p>
212
+ <p className="text-xs text-gray-700">
213
+ {app.status.reconciledAt
214
+ ? new Date(app.status.reconciledAt).toLocaleString()
215
+ : '—'}
216
+ </p>
217
+ </div>
218
+ </div>
219
+ )}
220
+
221
+ {/* Operation state */}
222
+ {app?.status.operationState && (
223
+ <div className={`rounded-md border p-3 text-xs ${
224
+ app.status.operationState.phase === 'Succeeded'
225
+ ? 'border-green-200 bg-green-50 text-green-800'
226
+ : app.status.operationState.phase === 'Failed'
227
+ ? 'border-red-200 bg-red-50 text-red-800'
228
+ : 'border-blue-200 bg-blue-50 text-blue-800'
229
+ }`}>
230
+ <span className="font-medium">Last operation:</span>{' '}
231
+ {app.status.operationState.phase}
232
+ {app.status.operationState.message && ` — ${app.status.operationState.message}`}
233
+ </div>
234
+ )}
235
+
236
+ {/* Source */}
237
+ {app?.spec.source && (
238
+ <div className="rounded-lg border border-gray-200 bg-white p-4">
239
+ <h3 className="text-sm font-semibold text-gray-700 mb-3">Source</h3>
240
+ <dl className="grid grid-cols-1 gap-y-2 text-xs sm:grid-cols-3">
241
+ <div>
242
+ <dt className="text-gray-500">Repository</dt>
243
+ <dd className="font-mono text-gray-800 truncate">{app.spec.source.repoURL}</dd>
244
+ </div>
245
+ <div>
246
+ <dt className="text-gray-500">Revision</dt>
247
+ <dd className="font-mono text-gray-800">{app.spec.source.targetRevision}</dd>
248
+ </div>
249
+ {app.spec.source.path && (
250
+ <div>
251
+ <dt className="text-gray-500">Path</dt>
252
+ <dd className="font-mono text-gray-800">{app.spec.source.path}</dd>
253
+ </div>
254
+ )}
255
+ </dl>
256
+ </div>
257
+ )}
258
+
259
+ {/* Sync history */}
260
+ <div>
261
+ <h3 className="text-sm font-semibold text-gray-700 mb-3">
262
+ Sync History{' '}
263
+ <span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-normal text-gray-500">
264
+ {history.length}
265
+ </span>
266
+ </h3>
267
+
268
+ {historyLoading ? (
269
+ <p className="text-xs text-gray-400">Loading history…</p>
270
+ ) : history.length === 0 ? (
271
+ <p className="text-xs text-gray-400">No sync history available.</p>
272
+ ) : (
273
+ <div className="overflow-x-auto rounded-lg border border-gray-200">
274
+ <table className="min-w-full text-xs">
275
+ <thead>
276
+ <tr className="border-b border-gray-200 bg-gray-50">
277
+ <th className="px-4 py-2 text-left font-medium text-gray-600">Revision</th>
278
+ <th className="px-4 py-2 text-left font-medium text-gray-600">Deployed At</th>
279
+ <th className="px-4 py-2 text-left font-medium text-gray-600">By</th>
280
+ </tr>
281
+ </thead>
282
+ <tbody className="divide-y divide-gray-100 bg-white">
283
+ {history.slice(0, 5).map((item) => (
284
+ <tr key={item.id} className="hover:bg-gray-50">
285
+ <td className="px-4 py-2 font-mono text-gray-800">
286
+ {item.revision.slice(0, 8)}
287
+ </td>
288
+ <td className="px-4 py-2 text-gray-600">
289
+ {new Date(item.deployedAt).toLocaleString()}
290
+ </td>
291
+ <td className="px-4 py-2 text-gray-500">
292
+ {item.initiatedBy?.automated
293
+ ? 'Auto-sync'
294
+ : (item.initiatedBy?.username ?? '—')}
295
+ </td>
296
+ </tr>
297
+ ))}
298
+ </tbody>
299
+ </table>
300
+ </div>
301
+ )}
302
+ </div>
303
+ </div>
304
+ );
305
+ }