@leverege/build-tools 2.101.2 → 2.103.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 (43) hide show
  1. package/.vscode/launch.json +10 -0
  2. package/README.md +22 -9
  3. package/package.json +10 -13
  4. package/src/Utils.mjs +2 -0
  5. package/src/chart-to-registry.mjs +12 -1
  6. package/src/circle-orb-it/{circle-orb.yml → circle-npm.yml} +6 -3
  7. package/src/circle-orb-it/circle-orb-it.mjs +16 -11
  8. package/src/circle-orb-it/circle-yarn.yml +25 -0
  9. package/src/dashboard/DashboardManager.mjs +192 -17
  10. package/src/dashboard/dashboard.mjs +29 -4
  11. package/src/dashboard/json/{standard/noc-rows-12.json → devops/devops-noc.json} +801 -61
  12. package/src/dashboard/json/{standard/pvc-all-usage.json → devops/pvc-usage.json} +2 -2
  13. package/src/dashboard/json/leverege/leverege-noc.json +2364 -0
  14. package/src/dashboard/json/{standard/redis.json → leverege/leverege-rule-engine.json} +695 -646
  15. package/src/dashboard/json/leverege/leverege-services.json +4757 -0
  16. package/src/dashboard/json/recovr/recovr-locators.json +1227 -0
  17. package/src/dashboard/json/recovr/recovr-noc.json +1949 -0
  18. package/src/dashboard/json/recovr/recovr-position-srvr.json +339 -0
  19. package/src/dashboard/json/recovr/recovr-server.json +1127 -0
  20. package/src/dashboard/json/siren/siren-services.json +2066 -0
  21. package/src/dashboard/json/{standard/cloudnative-pg-20417-rev4.json → system/cloudnative-pg.json} +241 -243
  22. package/src/dashboard/json/{standard → system}/elasticsearch8.json +123 -72
  23. package/src/dashboard/json/system/stackdriver.json +1189 -0
  24. package/src/dashboard/json/{standard/traefik-op.json → system/traefik.json} +44 -56
  25. package/src/dashboard/json/{standard → system}/valkey.json +91 -86
  26. package/src/dashboard/json/{standard → tpi}/gpu-autoscaling-dashboard-ui-import.json +1 -1
  27. package/src/dashboard/json/tpi/leverege-noc.json +3624 -0
  28. package/src/helm-charts/prom-operator/helmup.plugin +1 -1
  29. package/src/helm-charts/prom-operator/prometheus-stack.yaml.ovh +15 -0
  30. package/src/helm-charts/prom-operator/rules/gke-maintenance-rules.yaml +19 -0
  31. package/src/helm-charts/prom-operator/stackdriver-exporter.yaml.ovh +2 -1
  32. package/src/helm-charts/valkey/valkey-local.yaml +1 -0
  33. package/src/helmup.sh +24 -1
  34. package/src/init-my-chart/Chartwright.mjs +1 -1
  35. package/src/init-my-chart/leaf-chart-templates/templates/extraobjects.yaml +1 -0
  36. package/src/overwhelm.mjs +5 -5
  37. package/src/push-my-chart.mjs +13 -4
  38. package/src/repo-panopticon.mjs +415 -0
  39. package/src/service-man/config/secrets/npmrc.json +1 -0
  40. package/src/service-man/config/secrets/tsdb-dense-password.json +13 -0
  41. package/src/dashboard/json/standard/stackdriver.json +0 -624
  42. package/src/pkgck.sh +0 -158
  43. /package/src/dashboard/json/{standard → devops}/k8s-api-latency.json +0 -0
@@ -58,6 +58,16 @@
58
58
  "runtimeExecutable": "node",
59
59
  "console": "integratedTerminal"
60
60
  },
61
+ {
62
+ "name": "chart-to-registry rsrc-srvr",
63
+ "type": "node",
64
+ "request": "launch",
65
+ "program": "${workspaceFolder}/src/chart-to-registry.mjs",
66
+ "args": [],
67
+ "cwd": "/Users/john/Work/leverege/cicd/@plt/resource-server",
68
+ "runtimeExecutable": "node",
69
+ "console": "integratedTerminal"
70
+ },
61
71
  {
62
72
  "name": "ingestor in monorepo",
63
73
  "type": "node",
package/README.md CHANGED
@@ -16,7 +16,7 @@ Most tools assume the following are installed and configured:
16
16
  - `kubectl` (with a valid cluster context)
17
17
  - `helm` (v3+)
18
18
  - `fzf` (for interactive pod selection in `k8x` and `klog`)
19
- - Node.js 22+
19
+ - Node.js 22+ - prefer 24
20
20
 
21
21
  ---
22
22
 
@@ -85,13 +85,15 @@ init-my-chart [--dry-run] [--debug]
85
85
  ```
86
86
 
87
87
  **`chart-to-registry`** — Pushes Helm charts to GCP Artifact Registry with version
88
- checking and dependency resolution.
88
+ checking and dependency resolution. Warns if `appVersion` in Chart.yaml does not start
89
+ with `v` (bypass with `--ignore-appv`).
89
90
  ```bash
90
- chart-to-registry --chart <dir> [--project <id>] [--repository <name>] [--force] [--dry-run]
91
+ chart-to-registry --chart <dir> [--project <id>] [--repository <name>] [--force] [--dry-run] [--ignore-appv]
91
92
  ```
92
93
 
93
94
  **`push-my-chart`** — Publishes Helm charts to both GCS Chartmuseum (legacy) and
94
- GCP Artifact Registry.
95
+ GCP Artifact Registry. Displays chart name, version, and appVersion before pushing;
96
+ warns if `appVersion` does not start with `v`.
95
97
  ```bash
96
98
  push-my-chart [--chart <dir>] [--no-registry]
97
99
  ```
@@ -226,11 +228,20 @@ Manager, updating `.npmrc` / `.yarnrc.yml`. Skips refresh if the token is less t
226
228
  refresh-npm-token [--project <id>] [--force] [--dry-run]
227
229
  ```
228
230
 
229
- **`pkgck`** — Checks for common package configuration issues (babel, eslint, circleci,
230
- dockerfile, etc.) with visual pass/fail indicators.
231
+ **`repo-panopticon`** — Multi-repo health dashboard. Checks each repository against a suite
232
+ of indicators in a compact 80-column table. Monorepo-aware: workspace packages are
233
+ auto-discovered, grouped under their parent repo label, and checked individually.
231
234
  ```bash
232
- pkgck
235
+ repo-panopticon <dirs...> # check one or more repos / globs
236
+ repo-panopticon @plt/* @kud/* @srv/* # typical full-org sweep
237
+ repo-panopticon --no-pull @plt/* # skip git pull/fetch
238
+ repo-panopticon --limit 40 @srv/* # cap displayed rows (default 80)
239
+ repo-panopticon --concurrency 10 @srv/* # increase parallelism (default 5)
233
240
  ```
241
+ Columns: `mgr` (npm/yrn) · `pkg` (package.json version) · `git` (latest semver tag) ·
242
+ `scop` (package scope) · `cir` (CircleCI — ✘ missing · L legacy · 1 orb v1 · 2 orb v2) ·
243
+ `ESM` (`"type":"module"`) · `har` (.har hooks directory) · `lnt` (ESLint major version) ·
244
+ `bbl` (Babel presence). Repo name shown in orange when dirty or sync failed.
234
245
 
235
246
  **`pkglint`** — Verifies required documentation files (README.md, LICENSE.md) are
236
247
  present before publishing.
@@ -252,8 +263,9 @@ count-lines-of-code [--src-only]
252
263
 
253
264
  ### CI/CD Integration
254
265
 
255
- **`circle-orb-it`** — Installs or updates CircleCI configuration (Leverege orb) and
256
- GitHub Dependabot settings for a repository. Preserves existing customizations.
266
+ **`circle-orb-it`** — Installs or updates CircleCI configuration (Leverege orb v2.0.0) and
267
+ GitHub Dependabot settings for a repository. Auto-detects npm vs yarn-berry and writes
268
+ the appropriate template. Preserves existing customizations.
257
269
  ```bash
258
270
  circle-orb-it [--dry-run] [--debug]
259
271
  ```
@@ -340,6 +352,7 @@ BUILD_TOOLS_DEBUG=1 docker-to-registry
340
352
 
341
353
  | Tool | Replacement |
342
354
  |------|-------------|
355
+ | `pkgck` | `repo-panopticon` |
343
356
  | `docker-to-registry-py` | `docker-to-registry` |
344
357
  | `chart-to-museum` | `push-my-chart` |
345
358
  | `refresh-py-idx` | No longer needed |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leverege/build-tools",
3
- "version": "2.101.2",
3
+ "version": "2.103.0",
4
4
  "description": "A collection of build / support tools for Leverege developers",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -16,7 +16,6 @@
16
16
  "prepack": "npm exec prepack"
17
17
  },
18
18
  "bin": {
19
- "build-and-push": "src/build-and-push.sh",
20
19
  "build-cnpg-image": "src/build-cnpg-image.mjs",
21
20
  "build-tools": "src/build-tools.mjs",
22
21
  "change-up": "src/change-up.sh",
@@ -29,8 +28,6 @@
29
28
  "decrypt-secrets": "src/decrypt-secrets.sh",
30
29
  "dirty-git": "src/dirty-git.sh",
31
30
  "docker-to-registry": "src/docker-to-registry.mjs",
32
- "docker-to-registry-py": "src/docker-to-registry-py.mjs",
33
- "docker-to-registry.sh": "src/docker-to-registry.sh",
34
31
  "dockreate": "src/dockreate.sh",
35
32
  "encrypt-secrets": "src/encrypt-secrets.sh",
36
33
  "firebaseDeploy": "src/firebaseDeploy.mjs",
@@ -55,7 +52,7 @@
55
52
  "npm-link": "src/npm-link.sh",
56
53
  "npm-unlink": "src/npm-unlink.sh",
57
54
  "overwhelm": "src/overwhelm.mjs",
58
- "pkgck": "src/pkgck.sh",
55
+ "pkgck": "echo '*** Use repo-panopticon instead! ***",
59
56
  "pkglint": "src/pkglint.sh",
60
57
  "prepack": "src/prepack.mjs",
61
58
  "prune-git": "src/prune-git.sh",
@@ -65,18 +62,18 @@
65
62
  "push-my-chart": "src/push-my-chart.mjs",
66
63
  "refresh-npm-token": "src/refresh-npm-token.mjs",
67
64
  "refresh-py-idx": "src/refresh-py-idx.sh",
65
+ "repo-panopticon": "src/repo-panopticon.mjs",
68
66
  "send-to-slack": "src/send-to-slack.mjs",
69
67
  "service-man": "src/service-man/service-man.mjs",
70
68
  "snapshot-cnpg": "src/snapshot-cnpg.mjs",
71
- "tag-release": "src/tag-release.mjs",
72
69
  "unleash": "src/unleash.mjs",
73
70
  "upload-model": "src/upload-model.sh",
74
71
  "yarn-build": "src/yarn-build.mjs"
75
72
  },
76
73
  "author": "Leverege Devs",
77
74
  "engines": {
78
- "node": "^22.11 || ^24.11",
79
- "npm": ">=11.6"
75
+ "node": "^22.11 || >=24",
76
+ "npm": ">=10.2 || >=11.6"
80
77
  },
81
78
  "license": "SEE LICENSE IN LICENSE.md",
82
79
  "dependencies": {
@@ -94,7 +91,7 @@
94
91
  "googleapis": "^171.4.0",
95
92
  "handlebars": "^4.7.9",
96
93
  "ignore": "^7.0.5",
97
- "inquirer": "^13.4.2",
94
+ "inquirer": "^13.4.3",
98
95
  "js-yaml": "^4.1.1",
99
96
  "jsdoc": "^4.0.5",
100
97
  "ms": "^2.1.3",
@@ -103,20 +100,20 @@
103
100
  "p-limit": "^7.3.0",
104
101
  "package-up": "^5.0.0",
105
102
  "readline-sync": "^1.4.10",
106
- "semver": "^7.7.4",
103
+ "semver": "^7.8.0",
107
104
  "shell-quote": "^1.8.3",
108
105
  "simple-git": "^3.36.0",
109
106
  "sloc": "^0.3.2",
110
107
  "superstruct": "^2.0.2",
111
108
  "tmp": "^0.2.5",
112
- "toml": "^3.0.0",
113
- "yaml": "^2.8.3",
109
+ "toml": "^4.1.1",
110
+ "yaml": "^2.9.0",
114
111
  "zx": "^8.8.5"
115
112
  },
116
113
  "devDependencies": {
117
114
  "@leverege/eslint-config-leverege": "^5.1.2",
118
115
  "chai": "^6.2.2",
119
116
  "mocha": "^11.7.5",
120
- "npm": "^11.13.0"
117
+ "npm": "^11.14.1"
121
118
  }
122
119
  }
package/src/Utils.mjs CHANGED
@@ -534,6 +534,7 @@ export const parseHelmChart = async ( helmroot = './helm' ) => {
534
534
  const chartName = chartYaml.name
535
535
  const chartLibrary = chartYaml?.type === 'library'
536
536
  const chartVersion = chartYaml.version
537
+ const chartAppVersion = chartYaml.appVersion
537
538
  const chartPackage = `${chartName}-${chartVersion}.tgz`
538
539
  const valuesText = fs.readFileSync( `${helmroot}/values.yaml`, 'utf8' )
539
540
  const valuesYaml = YAML.load( valuesText )
@@ -573,6 +574,7 @@ export const parseHelmChart = async ( helmroot = './helm' ) => {
573
574
  chartLibrary,
574
575
  chartPackage,
575
576
  chartVersion,
577
+ chartAppVersion,
576
578
  hasDependencies,
577
579
  depsOutOfSync,
578
580
  imageRegistry,
@@ -14,9 +14,10 @@ import {
14
14
  errorExit,
15
15
  establishOauthAccess,
16
16
  log,
17
- warning,
18
17
  parseHelmChart,
18
+ proceed,
19
19
  shellCmd,
20
+ warning,
20
21
  } from './Utils.mjs'
21
22
 
22
23
  const commandLineOptions = [ // Use commandLineOptions to tie into the Usage statements
@@ -46,6 +47,11 @@ const commandLineOptions = [ // Use commandLineOptions to tie into the Usage sta
46
47
  type : Boolean,
47
48
  description : '{green update artifact registry auth token and exit}',
48
49
  },
50
+ {
51
+ name : 'ignore-appv',
52
+ type : Boolean,
53
+ description : '{green ignore the Char.yaml appVersion format}',
54
+ },
49
55
  {
50
56
  name : 'dry-run',
51
57
  type : Boolean,
@@ -98,6 +104,11 @@ if ( semverLt( process.version, minNodejsVersion ) ) {
98
104
  // load up all we need to know about the chart
99
105
  const helmChart = await parseHelmChart( args.chart )
100
106
 
107
+ if ( !helmChart.chartAppVersion.startsWith( 'v' ) && !args.ignoreAppv ) {
108
+ warning( 'Chart.yaml appVersion does not begin with the letter "v"\n' )
109
+ await proceed( 'You sure about this?' )
110
+ }
111
+
101
112
  // helm push helm-package.tgz oci://REGISTRY_LOCATION/REGISTRY_PROJECT/REGISTRY_REPOSITORY/charts
102
113
  //
103
114
  const registryLocation = args.location || helmChart.registryLocation
@@ -1,25 +1,28 @@
1
1
  version: 2.1
2
2
 
3
3
  orbs:
4
- leverege: leverege/circle-nodejs-ci@1.2.6
4
+ leverege: leverege/circle-nodejs-ci@2.0.0
5
5
 
6
6
  workflows:
7
7
  build-and-verify:
8
8
  jobs:
9
9
  - leverege/install-dependencies:
10
10
  context: npm
11
- # pkg-manager: yarn-berry # uncomment for Yarn Berry repos
12
11
  - leverege/code-analysis:
12
+ context: npm
13
13
  requires:
14
14
  - leverege/install-dependencies
15
15
  - leverege/code-coverage:
16
+ context:
17
+ - npm
18
+ - leverege-gcp # provides GCP_SA_KEY_JSON for tests that need GCP access
16
19
  coverage-threshold: 70 # percentage threshold for functions, lines, and statements
17
- context: leverege-gcp # provides GCP_SA_KEY_JSON for tests that need GCP access
18
20
  enable-redis: false # set to true if tests require a live Redis connection
19
21
  cache-config: '{"type":"inMemory"}' # sets CACHE_CONFIG env var - options: inMemory, redis
20
22
  requires:
21
23
  - leverege/install-dependencies
22
24
  - leverege/generate-docs:
25
+ context: npm
23
26
  requires:
24
27
  - leverege/install-dependencies
25
28
  - leverege/mirror-to-github:
@@ -11,14 +11,19 @@ import {
11
11
  enableDebug,
12
12
  errorExit,
13
13
  getGitRootDirectory,
14
+ getIsYarnProject,
14
15
  log,
16
+ mkdirSafe,
15
17
  proceed,
18
+ warning,
16
19
  } from '../Utils.mjs'
17
20
 
18
- const ORB_VERSION = '1.2.6' // Keep this in sync with the version in circle-orb.yml
21
+ const ORB_VERSION = '2.0.0' // Keep this in sync with the versions in circle-npm.yml and circle-yarn.yml
19
22
  const CIRCLE_DIR = '.circleci'
20
23
  const CIRCLE_YML = `${CIRCLE_DIR}/config.yml`
21
- const CIRCLE_YML_TEMPLATE = path.join( BUILD_TOOLS_ROOT, 'circle-orb-it', 'circle-orb.yml' )
24
+ const CIRCLE_NPM_TEMPLATE = path.join( BUILD_TOOLS_ROOT, 'circle-orb-it', 'circle-npm.yml' )
25
+ const CIRCLE_YARN_TEMPLATE = path.join( BUILD_TOOLS_ROOT, 'circle-orb-it', 'circle-yarn.yml' )
26
+ const CIRCLE_YML_TEMPLATE = isYarn => ( isYarn ? CIRCLE_YARN_TEMPLATE : CIRCLE_NPM_TEMPLATE )
22
27
  const GITHUB_DIR = '.github'
23
28
  const DEPENDABOT_FILE = `${GITHUB_DIR}/dependabot.yml`
24
29
  const DEPENDABOT_TEMPLATE = path.join( BUILD_TOOLS_ROOT, 'circle-orb-it', 'dependabot.yml' )
@@ -52,9 +57,13 @@ if ( !gitRoot ) {
52
57
  errorExit( 'This command must be run inside a git repository' )
53
58
  }
54
59
 
60
+ const isYarn = getIsYarnProject( gitRoot )
61
+ const circleTemplate = CIRCLE_YML_TEMPLATE( isYarn )
62
+ log( chalk.blue( `\nPackage manager: ${isYarn ? 'yarn-berry' : 'npm'}` ) )
63
+
55
64
  // Warn if this looks like a Python project since the orb is Node.js only
56
65
  if ( fs.existsSync( path.join( gitRoot, 'pyproject.toml' ) ) ) {
57
- log( chalk.yellow( '\n***WARNING: pyproject.toml detected - the leverege orb is Node.js only' ) )
66
+ warning( 'pyproject.toml detected - the leverege orb is Node.js only' )
58
67
  await proceed( 'Continue anyway?' )
59
68
  }
60
69
 
@@ -119,7 +128,7 @@ if ( !writeCircleYml && !writeDependabot ) {
119
128
 
120
129
  if ( options.dryRun ) {
121
130
  if ( writeCircleYml ) {
122
- let dryRunContent = fs.readFileSync( CIRCLE_YML_TEMPLATE, 'utf-8' )
131
+ let dryRunContent = fs.readFileSync( circleTemplate, 'utf-8' )
123
132
  if ( redisEnabled ) {
124
133
  dryRunContent = dryRunContent
125
134
  .replace( 'enable-redis: false', 'enable-redis: true' )
@@ -142,10 +151,8 @@ if ( options.dryRun ) {
142
151
  // ---------- Write ----------
143
152
 
144
153
  if ( writeCircleYml ) {
145
- if ( !fs.existsSync( circleDir ) ) {
146
- fs.mkdirSync( circleDir, { recursive : true } )
147
- }
148
- let circleYmlContent = fs.readFileSync( CIRCLE_YML_TEMPLATE, 'utf-8' )
154
+ mkdirSafe( circleDir )
155
+ let circleYmlContent = fs.readFileSync( circleTemplate, 'utf-8' )
149
156
  if ( redisEnabled ) {
150
157
  circleYmlContent = circleYmlContent
151
158
  .replace( 'enable-redis: false', 'enable-redis: true' )
@@ -162,9 +169,7 @@ if ( writeCircleYml ) {
162
169
  }
163
170
 
164
171
  if ( writeDependabot ) {
165
- if ( !fs.existsSync( githubDir ) ) {
166
- fs.mkdirSync( githubDir, { recursive : true } )
167
- }
172
+ mkdirSafe( githubDir )
168
173
  fs.writeFileSync( dependabotPath, fs.readFileSync( DEPENDABOT_TEMPLATE, 'utf-8' ) )
169
174
  log( chalk.green( `Dependabot config written => ${dependabotPath}` ) )
170
175
  }
@@ -0,0 +1,25 @@
1
+ version: 2.1
2
+
3
+ orbs:
4
+ leverege: leverege/circle-nodejs-ci@2.0.0
5
+
6
+ workflows:
7
+ build-and-verify:
8
+ jobs:
9
+ - leverege/code-analysis:
10
+ context: npm
11
+ pkg-manager: yarn-berry
12
+ - leverege/code-coverage:
13
+ context:
14
+ - npm
15
+ - leverege-gcp # provides GCP_SA_KEY_JSON for tests that need GCP access
16
+ pkg-manager: yarn-berry
17
+ coverage-threshold: 70 # percentage threshold for functions, lines, and statements
18
+ enable-redis: false # set to true if tests require a live Redis connection
19
+ cache-config: '{"type":"inMemory"}' # sets CACHE_CONFIG env var - options: inMemory, redis
20
+ - leverege/generate-docs:
21
+ context: npm
22
+ pkg-manager: yarn-berry
23
+ - leverege/mirror-to-github:
24
+ context: github-mirror
25
+ # github-team: platform # available teams: platform, utilities, pitcrew, recovr
@@ -9,16 +9,25 @@ import { debug, errorExit, log } from '../Utils.mjs'
9
9
  // const FIELD_MANAGER = 'dashboard'
10
10
  const GRAFANA_LABEL = 'grafana_dashboard'
11
11
  const GRAFANA_FOLDER = 'grafana_folder'
12
+ const GRAFANA_LOCAL = 'grafana_dashboard_local'
12
13
 
13
14
  /* eslint-disable security/detect-non-literal-fs-filename */
14
15
  export default class DashboardManager {
15
16
  #api
16
17
  #namespace
17
18
  #dryRun
19
+ #prune
20
+ #folders
21
+ #localDir
22
+ #pruneLocal
18
23
 
19
- constructor( { namespace = 'prometheus', dryRun = false } = {} ) {
24
+ constructor( { namespace = 'prometheus', dryRun = false, prune = false, folders = [], localDir = null, pruneLocal = false } = {} ) {
20
25
  this.#namespace = namespace
21
26
  this.#dryRun = dryRun
27
+ this.#prune = prune
28
+ this.#folders = folders
29
+ this.#localDir = localDir
30
+ this.#pruneLocal = pruneLocal
22
31
  }
23
32
 
24
33
  #getApi() {
@@ -31,21 +40,61 @@ export default class DashboardManager {
31
40
  }
32
41
 
33
42
  async apply( dir ) {
34
- const folders = fs.readdirSync( dir, { withFileTypes : true } )
35
- .filter( e => e.isDirectory() )
43
+ if ( !fs.existsSync( dir ) ) {
44
+ errorExit( `Dashboard directory not found: ${dir}` )
45
+ }
36
46
 
37
- if ( folders.length === 0 ) {
38
- log( chalk.yellow( `No dashboard subfolders found in ${dir}` ) )
47
+ const entries = fs.readdirSync( dir, { withFileTypes : true } )
48
+ const folders = entries.filter( e => e.isDirectory() )
49
+ const jsonFiles = entries.filter( e => e.isFile() && e.name.endsWith( '.json' ) )
50
+
51
+ if ( folders.length === 0 && jsonFiles.length === 0 ) {
52
+ log( chalk.yellow( `No dashboards or subfolders found in ${dir}` ) )
39
53
  return
40
54
  }
41
55
 
42
- for ( const folder of folders ) {
43
- const files = fs.readdirSync( path.join( dir, folder.name ) )
44
- .filter( f => f.endsWith( '.json' ) )
56
+ const applied = new Set()
57
+
58
+ if ( folders.length === 0 ) {
59
+ // leaf directory — apply JSON files using basename as the Grafana folder name
60
+ const folderName = path.basename( dir )
61
+ for ( const file of jsonFiles ) {
62
+ const name = await this.#applyDashboard( folderName, path.join( dir, file.name ) )
63
+ if ( name ) applied.add( name )
64
+ }
65
+ if ( this.#prune ) await this.#pruneOrphans( applied, folderName )
66
+ } else {
67
+ // filter to configured folders if DASHBOARDS was set in overwhelm.yaml
68
+ const activeFolders = this.#folders.length > 0 ?
69
+ folders.filter( f => this.#folders.includes( f.name ) ) :
70
+ folders
71
+
72
+ if ( this.#folders.length > 0 && activeFolders.length < this.#folders.length ) {
73
+ const missing = this.#folders.filter( f => !folders.find( d => d.name === f ) )
74
+ missing.forEach( f => log( chalk.yellow( ` [warn] requested folder not found: ${f}` ) ) )
75
+ }
76
+
77
+ for ( const folder of activeFolders ) {
78
+ const files = fs.readdirSync( path.join( dir, folder.name ) )
79
+ .filter( f => f.endsWith( '.json' ) )
80
+
81
+ for ( const file of files ) {
82
+ const name = await this.#applyDashboard( folder.name, path.join( dir, folder.name, file ) )
83
+ if ( name ) applied.add( name )
84
+ }
85
+ }
86
+
87
+ if ( this.#prune ) await this.#pruneOrphans( applied )
88
+ }
45
89
 
46
- for ( const file of files ) {
47
- await this.#applyDashboard( folder.name, path.join( dir, folder.name, file ) )
90
+ // Apply local dashboards if --local-dir was specified
91
+ if ( this.#localDir ) {
92
+ if ( !fs.existsSync( this.#localDir ) ) {
93
+ errorExit( `Local dashboard directory not found: ${this.#localDir}` )
48
94
  }
95
+ log( chalk.bold( `Applying local dashboards from: ${this.#localDir}` ) )
96
+ const localApplied = await this.#applyDir( this.#localDir, true )
97
+ if ( this.#pruneLocal ) await this.#pruneLocalOrphans( localApplied )
49
98
  }
50
99
  }
51
100
 
@@ -64,7 +113,11 @@ export default class DashboardManager {
64
113
  return
65
114
  }
66
115
 
67
- for ( const folder of folders ) {
116
+ const activeFolders = this.#folders.length > 0 ?
117
+ folders.filter( f => this.#folders.includes( f.name ) ) :
118
+ folders
119
+
120
+ for ( const folder of activeFolders ) {
68
121
  const files = fs.readdirSync( path.join( dir, folder.name ) )
69
122
  .filter( f => f.endsWith( '.json' ) )
70
123
 
@@ -75,20 +128,74 @@ export default class DashboardManager {
75
128
  files.forEach( f => log( ` ${chalk.cyan( path.basename( f, '.json' ) )}` ) )
76
129
  }
77
130
  }
131
+
132
+ if ( this.#localDir && fs.existsSync( this.#localDir ) ) {
133
+ log( chalk.bold( `\nLocal dashboards in: ${this.#localDir}` ) )
134
+ const localFolders = fs.readdirSync( this.#localDir, { withFileTypes : true } )
135
+ .filter( e => e.isDirectory() )
136
+
137
+ for ( const folder of localFolders ) {
138
+ const files = fs.readdirSync( path.join( this.#localDir, folder.name ) )
139
+ .filter( f => f.endsWith( '.json' ) )
140
+
141
+ log( chalk.bold( `\n[${folder.name}] (local)` ) )
142
+ if ( files.length === 0 ) {
143
+ log( chalk.dim( ' (empty)' ) )
144
+ } else {
145
+ files.forEach( f => log( ` ${chalk.cyan( path.basename( f, '.json' ) )}` ) )
146
+ }
147
+ }
148
+ }
78
149
  }
79
150
 
80
- async #applyDashboard( folder, filePath ) {
151
+ // Scans a directory for JSON files (leaf mode) or subfolders and applies all dashboards.
152
+ // Returns the Set of ConfigMap names applied.
153
+ async #applyDir( dir, local = false ) {
154
+ const entries = fs.readdirSync( dir, { withFileTypes : true } )
155
+ const subDirs = entries.filter( e => e.isDirectory() )
156
+ const jsonFiles = entries.filter( e => e.isFile() && e.name.endsWith( '.json' ) )
157
+ const applied = new Set()
158
+
159
+ if ( subDirs.length === 0 ) {
160
+ const folderName = path.basename( dir )
161
+ for ( const file of jsonFiles ) {
162
+ const name = await this.#applyDashboard( folderName, path.join( dir, file.name ), local )
163
+ if ( name ) applied.add( name )
164
+ }
165
+ } else {
166
+ for ( const folder of subDirs ) {
167
+ const files = fs.readdirSync( path.join( dir, folder.name ) )
168
+ .filter( f => f.endsWith( '.json' ) )
169
+
170
+ for ( const file of files ) {
171
+ const name = await this.#applyDashboard( folder.name, path.join( dir, folder.name, file ), local )
172
+ if ( name ) applied.add( name )
173
+ }
174
+ }
175
+ }
176
+
177
+ return applied
178
+ }
179
+
180
+ async #applyDashboard( folder, filePath, local = false ) {
81
181
  const filename = path.basename( filePath )
82
- const name = `dashboard-${path.basename( filePath, '.json' )}`
182
+ const slug = path.basename( filePath, '.json' )
183
+ .toLowerCase()
184
+ .replace( /[^a-z0-9.-]+/g, '-' )
185
+ .replace( /^-+|-+$/g, '' )
186
+ const name = `dashboard-${local ? 'local-' : ''}${slug}`
83
187
  const json = fs.readFileSync( filePath, 'utf8' )
84
188
 
189
+ const labels = { [GRAFANA_LABEL] : '1' }
190
+ if ( local ) labels[GRAFANA_LOCAL] = '1'
191
+
85
192
  const configMap = {
86
193
  apiVersion : 'v1',
87
194
  kind : 'ConfigMap',
88
195
  metadata : {
89
196
  name,
90
197
  namespace : this.#namespace,
91
- labels : { [GRAFANA_LABEL] : '1' },
198
+ labels,
92
199
  annotations : { [GRAFANA_FOLDER] : folder },
93
200
  },
94
201
  data : { [filename] : json },
@@ -97,8 +204,8 @@ export default class DashboardManager {
97
204
  debug( configMap, `dashboard: ${name}` )
98
205
 
99
206
  if ( this.#dryRun ) {
100
- log( chalk.yellow( ` [dry-run] ${filename} → [${folder}]` ) )
101
- return
207
+ log( chalk.yellow( ` [dry-run] ${filename} → [${folder}]${local ? ' (local)' : ''}` ) )
208
+ return name
102
209
  }
103
210
 
104
211
  const api = this.#getApi()
@@ -117,10 +224,78 @@ export default class DashboardManager {
117
224
  } else {
118
225
  await api.createNamespacedConfigMap( { namespace : this.#namespace, body : configMap } )
119
226
  }
120
- log( chalk.green( ` ✓ ${filename} → [${folder}]` ) )
227
+ log( chalk.green( ` ✓ ${filename} → [${folder}]${local ? ' (local)' : ''}` ) )
121
228
  } catch ( e ) {
122
229
  errorExit( `Failed to apply ${name}: ${e.message}` )
123
230
  }
231
+ return name
232
+ }
233
+
234
+ async #pruneOrphans( applied, folderFilter = null ) {
235
+ const api = this.#getApi()
236
+ let configMaps
237
+ try {
238
+ const res = await api.listNamespacedConfigMap( {
239
+ namespace : this.#namespace,
240
+ labelSelector : `${GRAFANA_LABEL}=1`,
241
+ } )
242
+ configMaps = res.items
243
+ } catch ( e ) {
244
+ log( chalk.yellow( ` [prune] could not list ConfigMaps: ${e.message}` ) )
245
+ return
246
+ }
247
+
248
+ const orphans = configMaps.filter( ( cm ) => {
249
+ if ( applied.has( cm.metadata.name ) ) return false
250
+ if ( cm.metadata.labels?.[GRAFANA_LOCAL] === '1' ) return false // leave local CMs to pruneLocal
251
+ if ( folderFilter && cm.metadata.annotations?.[GRAFANA_FOLDER] !== folderFilter ) return false
252
+ return true
253
+ } )
254
+
255
+ for ( const cm of orphans ) {
256
+ const name = cm.metadata.name
257
+ if ( this.#dryRun ) {
258
+ log( chalk.yellow( ` [dry-run] prune ${name}` ) )
259
+ continue
260
+ }
261
+ try {
262
+ await api.deleteNamespacedConfigMap( { name, namespace : this.#namespace } )
263
+ log( chalk.red( ` ✗ pruned ${name}` ) )
264
+ } catch ( e ) {
265
+ log( chalk.yellow( ` [prune] failed to delete ${name}: ${e.message}` ) )
266
+ }
267
+ }
268
+ }
269
+
270
+ async #pruneLocalOrphans( applied ) {
271
+ const api = this.#getApi()
272
+ let configMaps
273
+ try {
274
+ const res = await api.listNamespacedConfigMap( {
275
+ namespace : this.#namespace,
276
+ labelSelector : `${GRAFANA_LOCAL}=1`,
277
+ } )
278
+ configMaps = res.items
279
+ } catch ( e ) {
280
+ log( chalk.yellow( ` [prune-local] could not list ConfigMaps: ${e.message}` ) )
281
+ return
282
+ }
283
+
284
+ const orphans = configMaps.filter( cm => !applied.has( cm.metadata.name ) )
285
+
286
+ for ( const cm of orphans ) {
287
+ const name = cm.metadata.name
288
+ if ( this.#dryRun ) {
289
+ log( chalk.yellow( ` [dry-run] prune-local ${name}` ) )
290
+ continue
291
+ }
292
+ try {
293
+ await api.deleteNamespacedConfigMap( { name, namespace : this.#namespace } )
294
+ log( chalk.red( ` ✗ pruned (local) ${name}` ) )
295
+ } catch ( e ) {
296
+ log( chalk.yellow( ` [prune-local] failed to delete ${name}: ${e.message}` ) )
297
+ }
298
+ }
124
299
  }
125
300
 
126
301
  }