@continuous-excellence/ze-great-dashboard-aws 0.23.1 → 0.24.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.
package/dist/cli.js CHANGED
@@ -181,6 +181,26 @@ var azureDevOpsSourceSchema = z2.looseObject({
181
181
  });
182
182
  }
183
183
  });
184
+ var gitlabInstanceUrlSchema = z2.url().superRefine((value2, ctx) => {
185
+ const url = new URL(value2);
186
+ if (url.protocol !== "https:")
187
+ ctx.addIssue({ code: "custom", message: "must be an HTTPS GitLab instance URL" });
188
+ if (url.username || url.password)
189
+ ctx.addIssue({ code: "custom", message: "must not include credentials" });
190
+ if (url.search) ctx.addIssue({ code: "custom", message: "must not include a query string" });
191
+ if (url.hash) ctx.addIssue({ code: "custom", message: "must not include a fragment" });
192
+ });
193
+ var gitlabCiSourceSchema = z2.looseObject({
194
+ type: z2.literal("gitlab-ci"),
195
+ /** GitLab project path, including any nested groups. */
196
+ project: z2.string().regex(/^[^/\s]+(?:\/[^/\s]+)+$/, "must be a namespace/project path"),
197
+ /** A GitLab personal, project, or group access-token environment variable. */
198
+ token_env: z2.string().min(1),
199
+ /** The branch or tag whose newest pipeline the dashboard represents. */
200
+ branch: z2.string().min(1).optional(),
201
+ /** HTTPS GitLab.com or a self-managed instance, optionally below a path prefix. */
202
+ url: gitlabInstanceUrlSchema.optional().default("https://gitlab.com")
203
+ });
184
204
  var sourceSchema = z2.looseObject({ type: z2.string().min(1), ...sourceAuthenticationSchema }).superRefine(exactlyOneGithubAuthenticationMode);
185
205
  var boardSchema = z2.object({
186
206
  refresh: durationSchema.optional(),
@@ -218,8 +238,9 @@ var boardConfigSchema = z2.object({
218
238
  auth: authSchema.optional()
219
239
  }).superRefine((config, ctx) => {
220
240
  for (const [sourceName, source] of Object.entries(config.sources)) {
221
- if (source.type !== "azure-devops") continue;
222
- const parsedSource = azureDevOpsSourceSchema.safeParse(source);
241
+ const sourceSchema3 = source.type === "azure-devops" ? azureDevOpsSourceSchema : source.type === "gitlab-ci" ? gitlabCiSourceSchema : void 0;
242
+ if (!sourceSchema3) continue;
243
+ const parsedSource = sourceSchema3.safeParse(source);
223
244
  if (!parsedSource.success) {
224
245
  for (const issue of parsedSource.error.issues) {
225
246
  ctx.addIssue({
package/dist/index.js CHANGED
@@ -862,6 +862,26 @@ var azureDevOpsSourceSchema = z2.looseObject({
862
862
  });
863
863
  }
864
864
  });
865
+ var gitlabInstanceUrlSchema = z2.url().superRefine((value2, ctx) => {
866
+ const url = new URL(value2);
867
+ if (url.protocol !== "https:")
868
+ ctx.addIssue({ code: "custom", message: "must be an HTTPS GitLab instance URL" });
869
+ if (url.username || url.password)
870
+ ctx.addIssue({ code: "custom", message: "must not include credentials" });
871
+ if (url.search) ctx.addIssue({ code: "custom", message: "must not include a query string" });
872
+ if (url.hash) ctx.addIssue({ code: "custom", message: "must not include a fragment" });
873
+ });
874
+ var gitlabCiSourceSchema = z2.looseObject({
875
+ type: z2.literal("gitlab-ci"),
876
+ /** GitLab project path, including any nested groups. */
877
+ project: z2.string().regex(/^[^/\s]+(?:\/[^/\s]+)+$/, "must be a namespace/project path"),
878
+ /** A GitLab personal, project, or group access-token environment variable. */
879
+ token_env: z2.string().min(1),
880
+ /** The branch or tag whose newest pipeline the dashboard represents. */
881
+ branch: z2.string().min(1).optional(),
882
+ /** HTTPS GitLab.com or a self-managed instance, optionally below a path prefix. */
883
+ url: gitlabInstanceUrlSchema.optional().default("https://gitlab.com")
884
+ });
865
885
  var sourceSchema = z2.looseObject({ type: z2.string().min(1), ...sourceAuthenticationSchema }).superRefine(exactlyOneGithubAuthenticationMode);
866
886
  var boardSchema = z2.object({
867
887
  refresh: durationSchema.optional(),
@@ -899,8 +919,9 @@ var boardConfigSchema = z2.object({
899
919
  auth: authSchema.optional()
900
920
  }).superRefine((config, ctx) => {
901
921
  for (const [sourceName, source] of Object.entries(config.sources)) {
902
- if (source.type !== "azure-devops") continue;
903
- const parsedSource = azureDevOpsSourceSchema.safeParse(source);
922
+ const sourceSchema3 = source.type === "azure-devops" ? azureDevOpsSourceSchema : source.type === "gitlab-ci" ? gitlabCiSourceSchema : void 0;
923
+ if (!sourceSchema3) continue;
924
+ const parsedSource = sourceSchema3.safeParse(source);
904
925
  if (!parsedSource.success) {
905
926
  for (const issue of parsedSource.error.issues) {
906
927
  ctx.addIssue({
package/dist/lambda.mjs CHANGED
@@ -3316,12 +3316,12 @@ var init_schema_date_utils = __esm({
3316
3316
  [, month, day, hour, minute, second, fraction, year2] = matches;
3317
3317
  }
3318
3318
  if (year2 && second) {
3319
- const timestamp3 = Date.UTC(Number(year2), months.indexOf(month), Number(day), Number(hour), Number(minute), Number(second), fraction ? Math.round(parseFloat(`0.${fraction}`) * 1e3) : 0);
3319
+ const timestamp4 = Date.UTC(Number(year2), months.indexOf(month), Number(day), Number(hour), Number(minute), Number(second), fraction ? Math.round(parseFloat(`0.${fraction}`) * 1e3) : 0);
3320
3320
  range(day, 1, 31);
3321
3321
  range(hour, 0, 23);
3322
3322
  range(minute, 0, 59);
3323
3323
  range(second, 0, 60);
3324
- const date6 = new Date(timestamp3);
3324
+ const date6 = new Date(timestamp4);
3325
3325
  date6.setUTCFullYear(Number(year2));
3326
3326
  return date6;
3327
3327
  }
@@ -10311,14 +10311,14 @@ var init_DefaultRateLimiter = __esm({
10311
10311
  this.availableTokens = this.availableTokens - amount;
10312
10312
  }
10313
10313
  refillTokenBucket() {
10314
- const timestamp3 = this.getCurrentTimeInSeconds();
10314
+ const timestamp4 = this.getCurrentTimeInSeconds();
10315
10315
  if (!this.lastTimestamp) {
10316
- this.lastTimestamp = timestamp3;
10316
+ this.lastTimestamp = timestamp4;
10317
10317
  return;
10318
10318
  }
10319
- const fillAmount = (timestamp3 - this.lastTimestamp) * this.fillRate;
10319
+ const fillAmount = (timestamp4 - this.lastTimestamp) * this.fillRate;
10320
10320
  this.availableTokens = Math.min(this.maxCapacity, this.availableTokens + fillAmount);
10321
- this.lastTimestamp = timestamp3;
10321
+ this.lastTimestamp = timestamp4;
10322
10322
  }
10323
10323
  calculateTimeWindow() {
10324
10324
  this.timeWindow = this.getPrecise(Math.pow(this.lastMaxRate * (1 - this.beta) / this.scaleConstant, 1 / 3));
@@ -10326,8 +10326,8 @@ var init_DefaultRateLimiter = __esm({
10326
10326
  cubicThrottle(rateToUse) {
10327
10327
  return this.getPrecise(rateToUse * this.beta);
10328
10328
  }
10329
- cubicSuccess(timestamp3) {
10330
- return this.getPrecise(this.scaleConstant * Math.pow(timestamp3 - this.lastThrottleTime - this.timeWindow, 3) + this.lastMaxRate);
10329
+ cubicSuccess(timestamp4) {
10330
+ return this.getPrecise(this.scaleConstant * Math.pow(timestamp4 - this.lastThrottleTime - this.timeWindow, 3) + this.lastMaxRate);
10331
10331
  }
10332
10332
  enableTokenBucket() {
10333
10333
  this.enabled = true;
@@ -44847,7 +44847,7 @@ var require_timestamp = __commonJS({
44847
44847
  resolve: (str) => parseSexagesimal(str, false),
44848
44848
  stringify: stringifySexagesimal
44849
44849
  };
44850
- var timestamp3 = {
44850
+ var timestamp4 = {
44851
44851
  identify: (value) => value instanceof Date,
44852
44852
  default: true,
44853
44853
  tag: "tag:yaml.org,2002:timestamp",
@@ -44856,7 +44856,7 @@ var require_timestamp = __commonJS({
44856
44856
  // assumed to be 00:00:00Z (start of day, UTC).
44857
44857
  test: RegExp("^([0-9]{4})-([0-9]{1,2})-([0-9]{1,2})(?:(?:t|T|[ \\t]+)([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2}(\\.[0-9]+)?)(?:[ \\t]*(Z|[-+][012]?[0-9](?::[0-9]{2})?))?)?$"),
44858
44858
  resolve(str) {
44859
- const match2 = str.match(timestamp3.test);
44859
+ const match2 = str.match(timestamp4.test);
44860
44860
  if (!match2)
44861
44861
  throw new Error("!!timestamp expects a date, starting with yyyy-mm-dd");
44862
44862
  const [, year2, month, day, hour, minute, second] = match2.map(Number);
@@ -44875,7 +44875,7 @@ var require_timestamp = __commonJS({
44875
44875
  };
44876
44876
  exports.floatTime = floatTime;
44877
44877
  exports.intTime = intTime;
44878
- exports.timestamp = timestamp3;
44878
+ exports.timestamp = timestamp4;
44879
44879
  }
44880
44880
  });
44881
44881
 
@@ -44895,7 +44895,7 @@ var require_schema3 = __commonJS({
44895
44895
  var omap = require_omap();
44896
44896
  var pairs = require_pairs();
44897
44897
  var set2 = require_set();
44898
- var timestamp3 = require_timestamp();
44898
+ var timestamp4 = require_timestamp();
44899
44899
  var schema = [
44900
44900
  map4.map,
44901
44901
  seq.seq,
@@ -44915,9 +44915,9 @@ var require_schema3 = __commonJS({
44915
44915
  omap.omap,
44916
44916
  pairs.pairs,
44917
44917
  set2.set,
44918
- timestamp3.intTime,
44919
- timestamp3.floatTime,
44920
- timestamp3.timestamp
44918
+ timestamp4.intTime,
44919
+ timestamp4.floatTime,
44920
+ timestamp4.timestamp
44921
44921
  ];
44922
44922
  exports.schema = schema;
44923
44923
  }
@@ -44942,7 +44942,7 @@ var require_tags = __commonJS({
44942
44942
  var pairs = require_pairs();
44943
44943
  var schema$2 = require_schema3();
44944
44944
  var set2 = require_set();
44945
- var timestamp3 = require_timestamp();
44945
+ var timestamp4 = require_timestamp();
44946
44946
  var schemas = /* @__PURE__ */ new Map([
44947
44947
  ["core", schema.schema],
44948
44948
  ["failsafe", [map4.map, seq.seq, string4.string]],
@@ -44956,11 +44956,11 @@ var require_tags = __commonJS({
44956
44956
  float: float.float,
44957
44957
  floatExp: float.floatExp,
44958
44958
  floatNaN: float.floatNaN,
44959
- floatTime: timestamp3.floatTime,
44959
+ floatTime: timestamp4.floatTime,
44960
44960
  int: int2.int,
44961
44961
  intHex: int2.intHex,
44962
44962
  intOct: int2.intOct,
44963
- intTime: timestamp3.intTime,
44963
+ intTime: timestamp4.intTime,
44964
44964
  map: map4.map,
44965
44965
  merge: merge3.merge,
44966
44966
  null: _null4.nullTag,
@@ -44968,7 +44968,7 @@ var require_tags = __commonJS({
44968
44968
  pairs: pairs.pairs,
44969
44969
  seq: seq.seq,
44970
44970
  set: set2.set,
44971
- timestamp: timestamp3.timestamp
44971
+ timestamp: timestamp4.timestamp
44972
44972
  };
44973
44973
  var coreKnownTags = {
44974
44974
  "tag:yaml.org,2002:binary": binary.binary,
@@ -44976,7 +44976,7 @@ var require_tags = __commonJS({
44976
44976
  "tag:yaml.org,2002:omap": omap.omap,
44977
44977
  "tag:yaml.org,2002:pairs": pairs.pairs,
44978
44978
  "tag:yaml.org,2002:set": set2.set,
44979
- "tag:yaml.org,2002:timestamp": timestamp3.timestamp
44979
+ "tag:yaml.org,2002:timestamp": timestamp4.timestamp
44980
44980
  };
44981
44981
  function getTags(customTags, schemaName, addMergeTag) {
44982
44982
  const schemaTags = schemas.get(schemaName);
@@ -68418,6 +68418,26 @@ var azureDevOpsSourceSchema = external_exports.looseObject({
68418
68418
  });
68419
68419
  }
68420
68420
  });
68421
+ var gitlabInstanceUrlSchema = external_exports.url().superRefine((value, ctx) => {
68422
+ const url2 = new URL(value);
68423
+ if (url2.protocol !== "https:")
68424
+ ctx.addIssue({ code: "custom", message: "must be an HTTPS GitLab instance URL" });
68425
+ if (url2.username || url2.password)
68426
+ ctx.addIssue({ code: "custom", message: "must not include credentials" });
68427
+ if (url2.search) ctx.addIssue({ code: "custom", message: "must not include a query string" });
68428
+ if (url2.hash) ctx.addIssue({ code: "custom", message: "must not include a fragment" });
68429
+ });
68430
+ var gitlabCiSourceSchema = external_exports.looseObject({
68431
+ type: external_exports.literal("gitlab-ci"),
68432
+ /** GitLab project path, including any nested groups. */
68433
+ project: external_exports.string().regex(/^[^/\s]+(?:\/[^/\s]+)+$/, "must be a namespace/project path"),
68434
+ /** A GitLab personal, project, or group access-token environment variable. */
68435
+ token_env: external_exports.string().min(1),
68436
+ /** The branch or tag whose newest pipeline the dashboard represents. */
68437
+ branch: external_exports.string().min(1).optional(),
68438
+ /** HTTPS GitLab.com or a self-managed instance, optionally below a path prefix. */
68439
+ url: gitlabInstanceUrlSchema.optional().default("https://gitlab.com")
68440
+ });
68421
68441
  var sourceSchema = external_exports.looseObject({ type: external_exports.string().min(1), ...sourceAuthenticationSchema }).superRefine(exactlyOneGithubAuthenticationMode);
68422
68442
  function credentialEnvironmentNames(source) {
68423
68443
  const githubApp = githubAppSchema.safeParse(source.github_app);
@@ -68466,8 +68486,9 @@ var boardConfigSchema = external_exports.object({
68466
68486
  auth: authSchema.optional()
68467
68487
  }).superRefine((config2, ctx) => {
68468
68488
  for (const [sourceName, source] of Object.entries(config2.sources)) {
68469
- if (source.type !== "azure-devops") continue;
68470
- const parsedSource = azureDevOpsSourceSchema.safeParse(source);
68489
+ const sourceSchema2 = source.type === "azure-devops" ? azureDevOpsSourceSchema : source.type === "gitlab-ci" ? gitlabCiSourceSchema : void 0;
68490
+ if (!sourceSchema2) continue;
68491
+ const parsedSource = sourceSchema2.safeParse(source);
68471
68492
  if (!parsedSource.success) {
68472
68493
  for (const issue2 of parsedSource.error.issues) {
68473
68494
  ctx.addIssue({
@@ -69647,6 +69668,140 @@ function observedAt3(value) {
69647
69668
  return observedAt(value);
69648
69669
  }
69649
69670
 
69671
+ // packages/server/src/adapters/gitlab-ci.ts
69672
+ var pipelinePanelSchema3 = external_exports.object({
69673
+ id: external_exports.string().min(1),
69674
+ type: external_exports.literal("pipeline-status"),
69675
+ source: external_exports.string().min(1)
69676
+ });
69677
+ var pipelineSchema = external_exports.object({
69678
+ id: external_exports.number().int().positive(),
69679
+ name: external_exports.string().min(1).nullable().optional(),
69680
+ status: external_exports.string(),
69681
+ ref: external_exports.string().min(1).nullable().optional(),
69682
+ created_at: external_exports.string().nullable().optional(),
69683
+ updated_at: external_exports.string().nullable().optional(),
69684
+ web_url: external_exports.url()
69685
+ });
69686
+ function permittedGitlabCiCalls(panel, source) {
69687
+ pipelinePanelSchema3.parse(panel);
69688
+ const parsedSource = gitlabCiSourceSchema.parse(source);
69689
+ return [pipelinesCall(parsedSource)];
69690
+ }
69691
+ async function fetchGitlabCiPipeline(args) {
69692
+ const panel = pipelinePanelSchema3.parse(args.panel);
69693
+ const source = gitlabCiSourceSchema.parse(args.source);
69694
+ const token = args.credentials.get(source.token_env);
69695
+ if (!token)
69696
+ return failure(
69697
+ panel.id,
69698
+ "unauthorized",
69699
+ "GitLab credentials are not available.",
69700
+ sourceLink3(source)
69701
+ );
69702
+ const call = pipelinesCall(source);
69703
+ forwardValidators(args.requestHeaders, call.headers);
69704
+ call.headers.set("private-token", token);
69705
+ let upstream;
69706
+ try {
69707
+ upstream = await args.fetcher(call.url, { headers: call.headers });
69708
+ } catch (error62) {
69709
+ return failure(panel.id, "unreachable", error62, sourceLink3(source), void 0, {
69710
+ kind: "unreachable",
69711
+ ...safeNetworkCode(error62) ? { networkCode: safeNetworkCode(error62) } : {}
69712
+ });
69713
+ }
69714
+ if (upstream.status === 304) return { response: upstream };
69715
+ if (!upstream.ok) {
69716
+ const kind = upstreamErrorKind(upstream.status);
69717
+ return failure(
69718
+ panel.id,
69719
+ kind,
69720
+ `${upstream.status} ${upstream.statusText}`,
69721
+ sourceLink3(source),
69722
+ upstream.headers.get("date"),
69723
+ { kind, upstreamStatus: upstream.status }
69724
+ );
69725
+ }
69726
+ try {
69727
+ const pipeline = external_exports.array(pipelineSchema).parse(await upstream.json())[0];
69728
+ if (!pipeline) {
69729
+ const detail = source.branch ? `No pipelines found for ref "${source.branch}". Check the source's branch setting.` : "No pipelines found.";
69730
+ return failure(
69731
+ panel.id,
69732
+ "no-runs",
69733
+ detail,
69734
+ sourceLink3(source),
69735
+ upstream.headers.get("date"),
69736
+ {
69737
+ kind: "no-runs",
69738
+ upstreamStatus: upstream.status
69739
+ }
69740
+ );
69741
+ }
69742
+ const signal = {
69743
+ type: "pipeline-status",
69744
+ status: normalizeStatus3(pipeline.status),
69745
+ rawStatus: pipeline.status,
69746
+ name: pipeline.name ?? `Pipeline #${pipeline.id}`,
69747
+ ...pipeline.ref ?? source.branch ? { branch: pipeline.ref ?? source.branch } : {},
69748
+ ...timestamp3(pipeline.created_at) ? { runStartedAt: timestamp3(pipeline.created_at) } : {},
69749
+ ...timestamp3(pipeline.updated_at) ? { sourceUpdatedAt: timestamp3(pipeline.updated_at) } : {},
69750
+ sourceRunId: String(pipeline.id)
69751
+ };
69752
+ const envelope = {
69753
+ panelId: panel.id,
69754
+ state: "ok",
69755
+ observedAt: observedAt(upstream.headers.get("date")),
69756
+ link: publicLink(pipeline.web_url),
69757
+ signal
69758
+ };
69759
+ return { envelope, response: upstream };
69760
+ } catch (error62) {
69761
+ return failure(
69762
+ panel.id,
69763
+ "upstream-error",
69764
+ error62,
69765
+ sourceLink3(source),
69766
+ upstream.headers.get("date"),
69767
+ { kind: "upstream-error", upstreamStatus: upstream.status }
69768
+ );
69769
+ }
69770
+ }
69771
+ function pipelinesCall(source) {
69772
+ const url2 = new URL(source.url);
69773
+ url2.pathname = `${url2.pathname.replace(/\/$/, "")}/api/v4/projects/${encodeURIComponent(source.project)}/pipelines`;
69774
+ url2.searchParams.set("per_page", "1");
69775
+ if (source.branch) url2.searchParams.set("ref", source.branch);
69776
+ return { url: url2.toString(), headers: new Headers({ accept: "application/json" }) };
69777
+ }
69778
+ function sourceLink3(source) {
69779
+ const url2 = new URL(source.url);
69780
+ url2.pathname = `${url2.pathname.replace(/\/$/, "")}/${source.project.split("/").map(encodeURIComponent).join("/")}/-/pipelines`;
69781
+ return url2.toString();
69782
+ }
69783
+ function normalizeStatus3(status) {
69784
+ if (["created", "waiting_for_resource", "preparing", "pending", "running"].includes(status))
69785
+ return "running";
69786
+ if (status === "success") return "passed";
69787
+ if (status === "failed") return "failed";
69788
+ if (status === "canceled") return "cancelled";
69789
+ if (["manual", "skipped", "scheduled"].includes(status)) return "warning";
69790
+ return "unknown";
69791
+ }
69792
+ function timestamp3(value) {
69793
+ if (!value || !external_exports.iso.datetime().safeParse(value).success) return void 0;
69794
+ return Number.isFinite(new Date(value).valueOf()) ? value : void 0;
69795
+ }
69796
+ function failure(panelId, kind, error62, link, date6, failure2) {
69797
+ return {
69798
+ response: upstreamErrorResponse(
69799
+ upstreamErrorEnvelope({ panelId, kind, error: error62, link, upstreamDate: date6 })
69800
+ ),
69801
+ ...failure2 ? { failure: failure2 } : { failure: { kind } }
69802
+ };
69803
+ }
69804
+
69650
69805
  // packages/server/src/adapters/http-value.ts
69651
69806
  var httpValuePanelSchema = external_exports.object({
69652
69807
  id: external_exports.string().min(1),
@@ -69788,6 +69943,10 @@ function panelCapability(panel, source) {
69788
69943
  permittedAzureDevOpsCalls(panel, source);
69789
69944
  return { kind: "server", operations: ["read"] };
69790
69945
  }
69946
+ if (panel.type === "pipeline-status" && source?.type === "gitlab-ci") {
69947
+ permittedGitlabCiCalls(panel, source);
69948
+ return { kind: "server", operations: ["read"] };
69949
+ }
69791
69950
  if (panel.type === "http-value") {
69792
69951
  permittedHttpValueCalls(panel);
69793
69952
  return { kind: "server", operations: ["read"] };
@@ -72091,6 +72250,23 @@ ${(0, import_yaml.stringify)(outputConfig, { sortMapEntries: true })}`,
72091
72250
  destination: "https://dev.azure.com"
72092
72251
  });
72093
72252
  }
72253
+ if (panel.type === "pipeline-status" && source?.type === "gitlab-ci" && source) {
72254
+ const result = fetchGitlabCiPipeline({
72255
+ panel,
72256
+ source,
72257
+ requestHeaders: c5.req.raw.headers,
72258
+ fetcher: deps.fetcher ?? globalThis.fetch,
72259
+ credentials
72260
+ });
72261
+ return observation(c5, result, {
72262
+ boardId: boardName,
72263
+ panelId,
72264
+ operation: "read",
72265
+ sourceName: panel.source,
72266
+ sourceType: source.type,
72267
+ destination: typeof source.url === "string" ? source.url : "https://gitlab.com"
72268
+ });
72269
+ }
72094
72270
  if (panel.type === "pull-request-health")
72095
72271
  return rejected(c5, { boardId: boardName, panelId, operation: "read" });
72096
72272
  if (panel.type === "http-value") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@continuous-excellence/ze-great-dashboard-aws",
3
- "version": "0.23.1",
3
+ "version": "0.24.0",
4
4
  "type": "module",
5
5
  "description": "AWS server runtime and CloudFormation deployment tooling for Ze Great Dashboard.",
6
6
  "keywords": [