@oneuptime/common 9.3.4 → 9.3.6

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 (37) hide show
  1. package/Server/API/AIAgentDataAPI.ts +11 -1
  2. package/Server/API/GitHubAPI.ts +102 -0
  3. package/Server/EnvironmentConfig.ts +7 -0
  4. package/Server/Utils/CodeRepository/GitHub/GitHub.ts +53 -1
  5. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +43 -0
  6. package/Server/Utils/Monitor/MonitorResource.ts +26 -4
  7. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +7 -30
  8. package/Types/Icon/IconProp.ts +2 -0
  9. package/UI/Components/Icon/Icon.tsx +21 -0
  10. package/UI/Components/Navbar/NavBar.tsx +5 -2
  11. package/UI/Components/Navbar/NavBarMenu.tsx +48 -14
  12. package/UI/Components/Navbar/NavBarMenuItem.tsx +86 -6
  13. package/build/dist/Server/API/AIAgentDataAPI.js +11 -2
  14. package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
  15. package/build/dist/Server/API/GitHubAPI.js +76 -3
  16. package/build/dist/Server/API/GitHubAPI.js.map +1 -1
  17. package/build/dist/Server/EnvironmentConfig.js +2 -0
  18. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  19. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js +34 -3
  20. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js.map +1 -1
  21. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +32 -0
  22. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  23. package/build/dist/Server/Utils/Monitor/MonitorResource.js +19 -3
  24. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  25. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +3 -17
  26. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  27. package/build/dist/Types/Icon/IconProp.js +2 -0
  28. package/build/dist/Types/Icon/IconProp.js.map +1 -1
  29. package/build/dist/UI/Components/Icon/Icon.js +11 -0
  30. package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
  31. package/build/dist/UI/Components/Navbar/NavBar.js +2 -2
  32. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  33. package/build/dist/UI/Components/Navbar/NavBarMenu.js +26 -10
  34. package/build/dist/UI/Components/Navbar/NavBarMenu.js.map +1 -1
  35. package/build/dist/UI/Components/Navbar/NavBarMenuItem.js +72 -6
  36. package/build/dist/UI/Components/Navbar/NavBarMenuItem.js.map +1 -1
  37. package/package.json +1 -1
@@ -458,10 +458,20 @@ export default class AIAgentDataAPI {
458
458
  );
459
459
  }
460
460
 
461
- // Generate GitHub installation access token
461
+ /*
462
+ * Generate GitHub installation access token with write permissions
463
+ * Required for AI Agent to push branches and create pull requests
464
+ */
462
465
  const tokenData: GitHubInstallationToken =
463
466
  await GitHubUtil.getInstallationAccessToken(
464
467
  codeRepository.gitHubAppInstallationId,
468
+ {
469
+ permissions: {
470
+ contents: "write", // Required for pushing branches
471
+ pull_requests: "write", // Required for creating PRs
472
+ metadata: "read", // Required for reading repository metadata
473
+ },
474
+ },
465
475
  );
466
476
 
467
477
  const repositoryUrl: string = `https://github.com/${codeRepository.organizationName}/${codeRepository.repositoryName}.git`;
@@ -11,6 +11,7 @@ import { DashboardClientUrl, GitHubAppName } from "../EnvironmentConfig";
11
11
  import ObjectID from "../../Types/ObjectID";
12
12
  import GitHubUtil, {
13
13
  GitHubRepository,
14
+ GitHubInstallationNotFoundError,
14
15
  } from "../Utils/CodeRepository/GitHub/GitHub";
15
16
  import CodeRepositoryService from "../Services/CodeRepositoryService";
16
17
  import ProjectService from "../Services/ProjectService";
@@ -181,9 +182,19 @@ export default class GitHubAPI {
181
182
  UserMiddleware.getUserMiddleware,
182
183
  async (req: ExpressRequest, res: ExpressResponse) => {
183
184
  try {
185
+ const projectId: string | undefined =
186
+ req.params["projectId"]?.toString();
184
187
  const installationId: string | undefined =
185
188
  req.params["installationId"]?.toString();
186
189
 
190
+ if (!projectId) {
191
+ return Response.sendErrorResponse(
192
+ req,
193
+ res,
194
+ new BadDataException("Project ID is required"),
195
+ );
196
+ }
197
+
187
198
  if (!installationId) {
188
199
  return Response.sendErrorResponse(
189
200
  req,
@@ -201,6 +212,40 @@ export default class GitHubAPI {
201
212
  } catch (error) {
202
213
  logger.error("GitHub List Repositories Error:");
203
214
  logger.error(error);
215
+
216
+ // Handle stale installation ID - clear it from the project and return specific error
217
+ if (error instanceof GitHubInstallationNotFoundError) {
218
+ const projectId: string | undefined =
219
+ req.params["projectId"]?.toString();
220
+
221
+ if (projectId) {
222
+ try {
223
+ // Clear the stale installation ID from the project
224
+ await ProjectService.updateOneById({
225
+ id: new ObjectID(projectId),
226
+ data: {
227
+ gitHubAppInstallationId: null as unknown as string,
228
+ },
229
+ props: {
230
+ isRoot: true,
231
+ },
232
+ });
233
+
234
+ logger.info(
235
+ `Cleared stale GitHub App installation ID from project ${projectId}`,
236
+ );
237
+ } catch (clearError) {
238
+ logger.error(
239
+ "Failed to clear stale installation ID from project:",
240
+ );
241
+ logger.error(clearError);
242
+ }
243
+ }
244
+
245
+ // Return the specific error so the frontend knows to prompt reinstallation
246
+ return Response.sendErrorResponse(req, res, error);
247
+ }
248
+
204
249
  return Response.sendErrorResponse(
205
250
  req,
206
251
  res,
@@ -349,6 +394,63 @@ export default class GitHubAPI {
349
394
 
350
395
  logger.debug(`Received GitHub webhook event: ${event}`);
351
396
 
397
+ // Handle installation events - specifically when the app is uninstalled
398
+ if (event === "installation") {
399
+ const action: string | undefined = (req.body as JSONObject)?.[
400
+ "action"
401
+ ]?.toString();
402
+ const installationId: string | undefined = (
403
+ (req.body as JSONObject)?.["installation"] as JSONObject
404
+ )?.["id"]?.toString();
405
+
406
+ if (action === "deleted" && installationId) {
407
+ logger.info(
408
+ `GitHub App installation ${installationId} was deleted. Clearing from database...`,
409
+ );
410
+
411
+ try {
412
+ // Clear the installation ID from any projects that have it
413
+ await ProjectService.updateBy({
414
+ query: {
415
+ gitHubAppInstallationId: installationId,
416
+ },
417
+ data: {
418
+ gitHubAppInstallationId: null as unknown as string,
419
+ },
420
+ limit: 1000,
421
+ skip: 0,
422
+ props: {
423
+ isRoot: true,
424
+ },
425
+ });
426
+
427
+ // Also clear from any code repositories that have this installation ID
428
+ await CodeRepositoryService.updateBy({
429
+ query: {
430
+ gitHubAppInstallationId: installationId,
431
+ },
432
+ data: {
433
+ gitHubAppInstallationId: null as unknown as string,
434
+ },
435
+ limit: 10000,
436
+ skip: 0,
437
+ props: {
438
+ isRoot: true,
439
+ },
440
+ });
441
+
442
+ logger.info(
443
+ `Successfully cleared GitHub App installation ${installationId} from database`,
444
+ );
445
+ } catch (clearError) {
446
+ logger.error(
447
+ `Failed to clear GitHub App installation ${installationId} from database:`,
448
+ );
449
+ logger.error(clearError);
450
+ }
451
+ }
452
+ }
453
+
352
454
  /*
353
455
  * Handle different webhook events here
354
456
  * For now, just acknowledge receipt
@@ -397,6 +397,13 @@ export const StatusPageApiClientUrl: URL = new URL(
397
397
  new Route(StatusPageApiRoute.toString()),
398
398
  );
399
399
 
400
+ // Internal URL for server-to-server communication (uses internal Docker hostname)
401
+ export const StatusPageApiInternalUrl: URL = new URL(
402
+ Protocol.HTTP,
403
+ AppApiHostname.toString(),
404
+ new Route(StatusPageApiRoute.toString()),
405
+ );
406
+
400
407
  export const DashboardClientUrl: URL = new URL(
401
408
  HttpProtocol,
402
409
  Host,
@@ -18,6 +18,17 @@ import {
18
18
  import BadDataException from "../../../../Types/Exception/BadDataException";
19
19
  import * as crypto from "crypto";
20
20
 
21
+ /**
22
+ * Error thrown when a GitHub App installation is no longer valid (e.g., uninstalled from GitHub)
23
+ */
24
+ export class GitHubInstallationNotFoundError extends BadDataException {
25
+ public constructor() {
26
+ super(
27
+ "GitHub App installation not found. The app may have been uninstalled from GitHub. Please reconnect with GitHub to reinstall the app.",
28
+ );
29
+ }
30
+ }
31
+
21
32
  export interface GitHubRepository {
22
33
  id: number;
23
34
  name: string;
@@ -335,11 +346,20 @@ export default class GitHubUtil extends HostedCodeRepository {
335
346
  /**
336
347
  * Gets an installation access token for a GitHub App installation
337
348
  * @param installationId - The GitHub App installation ID
349
+ * @param options - Optional configuration for the token
350
+ * @param options.permissions - Specific permissions to request for the token
338
351
  * @returns Installation token and expiration date
339
352
  */
340
353
  @CaptureSpan()
341
354
  public static async getInstallationAccessToken(
342
355
  installationId: string,
356
+ options?: {
357
+ permissions?: {
358
+ contents?: "read" | "write";
359
+ pull_requests?: "read" | "write";
360
+ metadata?: "read";
361
+ };
362
+ },
343
363
  ): Promise<GitHubInstallationToken> {
344
364
  const jwt: string = GitHubUtil.generateAppJWT();
345
365
 
@@ -347,10 +367,17 @@ export default class GitHubUtil extends HostedCodeRepository {
347
367
  `https://api.github.com/app/installations/${installationId}/access_tokens`,
348
368
  );
349
369
 
370
+ // Build request data with optional permissions
371
+ const requestData: JSONObject = {};
372
+
373
+ if (options?.permissions) {
374
+ requestData["permissions"] = options.permissions;
375
+ }
376
+
350
377
  const result: HTTPErrorResponse | HTTPResponse<JSONObject> = await API.post(
351
378
  {
352
379
  url: url,
353
- data: {},
380
+ data: requestData,
354
381
  headers: {
355
382
  Authorization: `Bearer ${jwt}`,
356
383
  Accept: "application/vnd.github+json",
@@ -360,6 +387,31 @@ export default class GitHubUtil extends HostedCodeRepository {
360
387
  );
361
388
 
362
389
  if (result instanceof HTTPErrorResponse) {
390
+ // Check if this is a permission error and provide helpful message
391
+ const errorMessage: string =
392
+ (result.data as JSONObject)?.["message"]?.toString() || "";
393
+
394
+ // Check if the installation is not found (404) - this means the app was uninstalled from GitHub
395
+ if (result.statusCode === 404) {
396
+ logger.error(
397
+ `GitHub App installation not found (ID: ${installationId}). ` +
398
+ `The app may have been uninstalled from GitHub. User needs to reinstall the app.`,
399
+ );
400
+ throw new GitHubInstallationNotFoundError();
401
+ }
402
+
403
+ if (
404
+ errorMessage.includes("permissions") ||
405
+ result.statusCode === 403 ||
406
+ result.statusCode === 422
407
+ ) {
408
+ logger.error(
409
+ `GitHub App permission error: ${errorMessage}. ` +
410
+ `Please ensure the GitHub App is configured with the required permissions ` +
411
+ `(contents: write, pull_requests: write, metadata: read) in the GitHub App settings.`,
412
+ );
413
+ }
414
+
363
415
  throw result;
364
416
  }
365
417
 
@@ -34,6 +34,10 @@ import OneUptimeDate from "../../../Types/Date";
34
34
  import { JSONObject } from "../../../Types/JSON";
35
35
  import Typeof from "../../../Types/Typeof";
36
36
  import ReturnResult from "../../../Types/IsolatedVM/ReturnResult";
37
+ import URL from "../../../Types/API/URL";
38
+ import IP from "../../../Types/IP/IP";
39
+ import Hostname from "../../../Types/API/Hostname";
40
+ import Port from "../../../Types/Port";
37
41
 
38
42
  export default class MonitorCriteriaEvaluator {
39
43
  public static async processMonitorStep(input: {
@@ -650,6 +654,45 @@ ${contextBlock}
650
654
  }
651
655
 
652
656
  try {
657
+ // Handle primitive types directly
658
+ if (typeof value === "string") {
659
+ return value.trim();
660
+ }
661
+
662
+ if (typeof value === "number" || typeof value === "boolean") {
663
+ return String(value);
664
+ }
665
+
666
+ // Handle class instances with custom toString method (like URL, IP, Hostname)
667
+ if (
668
+ value instanceof URL ||
669
+ value instanceof IP ||
670
+ value instanceof Hostname ||
671
+ value instanceof Port
672
+ ) {
673
+ return value.toString().trim();
674
+ }
675
+
676
+ /*
677
+ * Handle JSON representations of URL, IP, Hostname, Port (e.g., { _type: "URL", value: "https://..." })
678
+ * This can happen when the value wasn't properly deserialized from JSON
679
+ */
680
+ if (typeof value === "object" && value !== null && "_type" in value) {
681
+ const typedValue: { _type: string; value?: unknown } = value as {
682
+ _type: string;
683
+ value?: unknown;
684
+ };
685
+ if (
686
+ (typedValue._type === "URL" ||
687
+ typedValue._type === "IP" ||
688
+ typedValue._type === "Hostname" ||
689
+ typedValue._type === "Port") &&
690
+ typeof typedValue.value === "string"
691
+ ) {
692
+ return typedValue.value.trim();
693
+ }
694
+ }
695
+
653
696
  return String(value).trim();
654
697
  } catch (err) {
655
698
  logger.error(err);
@@ -50,6 +50,7 @@ interface ProbeAgreementResult {
50
50
  totalActiveProbes: number;
51
51
  agreedCriteriaId: string | null;
52
52
  agreedRootCause: string | null;
53
+ agreedProbeNames: Array<string>;
53
54
  }
54
55
 
55
56
  export default class MonitorResourceUtil {
@@ -566,6 +567,16 @@ export default class MonitorResourceUtil {
566
567
  ? probeAgreementResult.agreedCriteriaId
567
568
  : undefined;
568
569
  response.rootCause = probeAgreementResult.agreedRootCause;
570
+
571
+ // Add probe names in agreement to the root cause
572
+ if (
573
+ response.rootCause &&
574
+ probeAgreementResult.agreedProbeNames.length > 0
575
+ ) {
576
+ response.rootCause += `
577
+ **Probes in Agreement**: ${probeAgreementResult.agreedProbeNames.join(", ")}
578
+ `;
579
+ }
569
580
  }
570
581
 
571
582
  if (response.criteriaMetId && response.rootCause) {
@@ -829,6 +840,7 @@ export default class MonitorResourceUtil {
829
840
  lastMonitoringLog: true,
830
841
  probe: {
831
842
  connectionStatus: true,
843
+ name: true,
832
844
  },
833
845
  },
834
846
  limit: LIMIT_PER_PROJECT,
@@ -861,6 +873,7 @@ export default class MonitorResourceUtil {
861
873
  totalActiveProbes: 0,
862
874
  agreedCriteriaId: currentCriteriaMetId,
863
875
  agreedRootCause: currentRootCause,
876
+ agreedProbeNames: [],
864
877
  };
865
878
  }
866
879
 
@@ -876,11 +889,11 @@ export default class MonitorResourceUtil {
876
889
  /*
877
890
  * Count how many probes agree on each criteria result
878
891
  * Key: criteriaId or "none" for no criteria met
879
- * Value: { count, rootCause }
892
+ * Value: { count, rootCause, probeNames }
880
893
  */
881
894
  const criteriaAgreements: Map<
882
895
  string,
883
- { count: number; rootCause: string | null }
896
+ { count: number; rootCause: string | null; probeNames: Array<string> }
884
897
  > = new Map();
885
898
 
886
899
  const stepId: string = monitorStep.id.toString();
@@ -921,15 +934,21 @@ export default class MonitorResourceUtil {
921
934
 
922
935
  // Record the result
923
936
  const criteriaKey: string = evaluatedResponse.criteriaMetId || "none";
924
- const existing: { count: number; rootCause: string | null } | undefined =
925
- criteriaAgreements.get(criteriaKey);
937
+ const existing:
938
+ | { count: number; rootCause: string | null; probeNames: Array<string> }
939
+ | undefined = criteriaAgreements.get(criteriaKey);
940
+
941
+ // Get probe name for this monitor probe
942
+ const probeName: string = monitorProbe.probe?.name || "Unknown Probe";
926
943
 
927
944
  if (existing) {
928
945
  existing.count += 1;
946
+ existing.probeNames.push(probeName);
929
947
  } else {
930
948
  criteriaAgreements.set(criteriaKey, {
931
949
  count: 1,
932
950
  rootCause: evaluatedResponse.rootCause,
951
+ probeNames: [probeName],
933
952
  });
934
953
  }
935
954
  }
@@ -938,12 +957,14 @@ export default class MonitorResourceUtil {
938
957
  let maxCount: number = 0;
939
958
  let winningCriteriaId: string | null = null;
940
959
  let winningRootCause: string | null = null;
960
+ let winningProbeNames: Array<string> = [];
941
961
 
942
962
  for (const [criteriaId, data] of criteriaAgreements) {
943
963
  if (data.count > maxCount) {
944
964
  maxCount = data.count;
945
965
  winningCriteriaId = criteriaId === "none" ? null : criteriaId;
946
966
  winningRootCause = data.rootCause;
967
+ winningProbeNames = data.probeNames;
947
968
  }
948
969
  }
949
970
 
@@ -961,6 +982,7 @@ export default class MonitorResourceUtil {
961
982
  totalActiveProbes: activeProbes.length,
962
983
  agreedCriteriaId: hasAgreement ? winningCriteriaId : null,
963
984
  agreedRootCause: hasAgreement ? winningRootCause : null,
985
+ agreedProbeNames: hasAgreement ? winningProbeNames : [],
964
986
  };
965
987
  }
966
988
  }
@@ -32,7 +32,6 @@ import ObjectID from "../../../../Types/ObjectID";
32
32
  import WorkspaceProjectAuthTokenService from "../../../Services/WorkspaceProjectAuthTokenService";
33
33
  import WorkspaceProjectAuthToken, {
34
34
  MicrosoftTeamsMiscData,
35
- MicrosoftTeamsTeam,
36
35
  } from "../../../../Models/DatabaseModels/WorkspaceProjectAuthToken";
37
36
  import Incident from "../../../../Models/DatabaseModels/Incident";
38
37
  import IncidentState from "../../../../Models/DatabaseModels/IncidentState";
@@ -2940,12 +2939,10 @@ All monitoring checks are passing normally.`;
2940
2939
  // Fetch joined teams using app-scoped token
2941
2940
  if (data.userId) {
2942
2941
  logger.debug("Using app-scoped token to fetch joined teams for user");
2943
- const userTeams: Record<string, { id: string; name: string }> =
2944
- await this.getUserJoinedTeams({
2945
- userId: data.userId,
2946
- projectId: data.projectId,
2947
- });
2948
- allTeams = Object.values(userTeams) as any;
2942
+ allTeams = await this.getUserJoinedTeams({
2943
+ userId: data.userId,
2944
+ projectId: data.projectId,
2945
+ });
2949
2946
  }
2950
2947
  } catch (err) {
2951
2948
  logger.warn(
@@ -3069,7 +3066,7 @@ All monitoring checks are passing normally.`;
3069
3066
  public static async getUserJoinedTeams(data: {
3070
3067
  userId: ObjectID;
3071
3068
  projectId: ObjectID;
3072
- }): Promise<Record<string, { id: string; name: string }>> {
3069
+ }): Promise<Array<JSONObject>> {
3073
3070
  logger.debug("=== getUserJoinedTeams called ===");
3074
3071
  logger.debug(`User ID: ${data.userId.toString()}`);
3075
3072
  logger.debug(`Project ID: ${data.projectId.toString()}`);
@@ -3123,29 +3120,9 @@ All monitoring checks are passing normally.`;
3123
3120
  const teams: Array<JSONObject> =
3124
3121
  (teamsData["value"] as Array<JSONObject>) || [];
3125
3122
 
3126
- if (teams.length === 0) {
3127
- logger.debug("No joined teams found for user");
3128
- return {};
3129
- }
3123
+ logger.debug(`Fetched ${teams.length} joined teams`);
3130
3124
 
3131
- // Process teams
3132
- const availableTeams: Record<string, MicrosoftTeamsTeam> = teams.reduce(
3133
- (acc: Record<string, MicrosoftTeamsTeam>, t: JSONObject) => {
3134
- const team: MicrosoftTeamsTeam = {
3135
- id: t["id"] as string,
3136
- name: (t["displayName"] as string) || "Unnamed Team",
3137
- };
3138
- acc[team.name] = team;
3139
- return acc;
3140
- },
3141
- {} as Record<string, MicrosoftTeamsTeam>,
3142
- );
3143
-
3144
- logger.debug(
3145
- `Fetched ${Object.keys(availableTeams).length} joined teams`,
3146
- );
3147
-
3148
- return availableTeams;
3125
+ return teams;
3149
3126
  } catch (error) {
3150
3127
  logger.error("Error getting user joined teams:");
3151
3128
  logger.error(error);
@@ -141,6 +141,8 @@ enum IconProp {
141
141
  ExclaimationCircle = "ExclaimationCircle",
142
142
  WhatsApp = "WhatsApp",
143
143
  Brain = "Brain",
144
+ FlowDiagram = "FlowDiagram",
145
+ GitHub = "GitHub",
144
146
  }
145
147
 
146
148
  export default IconProp;
@@ -692,6 +692,14 @@ const Icon: FunctionComponent<ComponentProps> = ({
692
692
  clipRule="evenodd"
693
693
  />,
694
694
  );
695
+ } else if (icon === IconProp.GitHub) {
696
+ return getSvgWrapper(
697
+ <path
698
+ fill="currentColor"
699
+ stroke="none"
700
+ d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
701
+ />,
702
+ );
695
703
  } else if (icon === IconProp.ChevronRight) {
696
704
  return getSvgWrapper(
697
705
  <path
@@ -1298,6 +1306,19 @@ const Icon: FunctionComponent<ComponentProps> = ({
1298
1306
  d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
1299
1307
  />,
1300
1308
  );
1309
+ } else if (icon === IconProp.FlowDiagram) {
1310
+ // Flow diagram icon matching home page workflows - two boxes at top, one at bottom, connected
1311
+ return getSvgWrapper(
1312
+ <>
1313
+ <rect x="3" y="3" width="6" height="4" rx="1" strokeWidth="1.5" />
1314
+ <rect x="15" y="3" width="6" height="4" rx="1" strokeWidth="1.5" />
1315
+ <rect x="9" y="17" width="6" height="4" rx="1" strokeWidth="1.5" />
1316
+ <path
1317
+ strokeLinecap="round"
1318
+ d="M6 7v3a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7M12 12v5"
1319
+ />
1320
+ </>,
1321
+ );
1301
1322
  }
1302
1323
 
1303
1324
  return <></>;
@@ -29,6 +29,7 @@ export interface MoreMenuItem {
29
29
  description: string;
30
30
  route: Route;
31
31
  icon: IconProp;
32
+ iconColor?: string; // Tailwind color class like "bg-blue-500"
32
33
  }
33
34
 
34
35
  export interface ComponentProps {
@@ -277,7 +278,7 @@ const Navbar: FunctionComponent<ComponentProps> = (
277
278
  {isMoreMenuVisible &&
278
279
  (props.moreMenuFooter ? (
279
280
  <NavBarMenu footer={props.moreMenuFooter}>
280
- {props.moreMenuItems.map((item: any) => {
281
+ {props.moreMenuItems.map((item: MoreMenuItem) => {
281
282
  return (
282
283
  <NavBarMenuItem
283
284
  key={item.title}
@@ -285,6 +286,7 @@ const Navbar: FunctionComponent<ComponentProps> = (
285
286
  description={item.description}
286
287
  route={item.route}
287
288
  icon={item.icon}
289
+ iconColor={item.iconColor}
288
290
  onClick={forceHideMoreMenu}
289
291
  />
290
292
  );
@@ -292,7 +294,7 @@ const Navbar: FunctionComponent<ComponentProps> = (
292
294
  </NavBarMenu>
293
295
  ) : (
294
296
  <NavBarMenu>
295
- {props.moreMenuItems.map((item: any) => {
297
+ {props.moreMenuItems.map((item: MoreMenuItem) => {
296
298
  return (
297
299
  <NavBarMenuItem
298
300
  key={item.title}
@@ -300,6 +302,7 @@ const Navbar: FunctionComponent<ComponentProps> = (
300
302
  description={item.description}
301
303
  route={item.route}
302
304
  icon={item.icon}
305
+ iconColor={item.iconColor}
303
306
  onClick={forceHideMoreMenu}
304
307
  />
305
308
  );
@@ -1,7 +1,14 @@
1
1
  import Link from "../Link/Link";
2
+ import Icon from "../Icon/Icon";
3
+ import IconProp from "../../../Types/Icon/IconProp";
2
4
  import URL from "../../../Types/API/URL";
3
5
  import React, { FunctionComponent, ReactElement } from "react";
4
6
 
7
+ export interface MenuSection {
8
+ title: string;
9
+ children: ReactElement | Array<ReactElement>;
10
+ }
11
+
5
12
  export interface ComponentProps {
6
13
  children: ReactElement | Array<ReactElement>;
7
14
  footer?: {
@@ -11,7 +18,7 @@ export interface ComponentProps {
11
18
  };
12
19
  }
13
20
 
14
- const NavBarItem: FunctionComponent<ComponentProps> = (
21
+ const NavBarMenu: FunctionComponent<ComponentProps> = (
15
22
  props: ComponentProps,
16
23
  ): ReactElement => {
17
24
  let children: Array<ReactElement>;
@@ -20,27 +27,54 @@ const NavBarItem: FunctionComponent<ComponentProps> = (
20
27
  } else {
21
28
  children = props.children;
22
29
  }
30
+
31
+ // Calculate number of columns based on items count
32
+ const itemCount: number = children.length;
33
+ const columnClass: string =
34
+ itemCount <= 4
35
+ ? "lg:grid-cols-2"
36
+ : itemCount <= 6
37
+ ? "lg:grid-cols-3"
38
+ : "lg:grid-cols-3";
39
+ const maxWidthClass: string =
40
+ itemCount <= 4
41
+ ? "lg:max-w-xl"
42
+ : itemCount <= 6
43
+ ? "lg:max-w-2xl"
44
+ : "lg:max-w-3xl";
45
+
23
46
  return (
24
- <div className="absolute left-1/3 z-10 mt-10 w-screen max-w-md -translate-x-1/2 transform px-2 sm:px-0 lg:max-w-3xl">
25
- <div className="overflow-hidden rounded-lg shadow-lg ring-1 ring-black ring-opacity-5">
26
- <div className="relative grid gap-6 bg-white px-5 py-6 sm:gap-8 sm:p-8 lg:grid-cols-2">
47
+ <div
48
+ className={`absolute left-1/2 z-10 mt-8 w-screen max-w-md -translate-x-1/2 transform px-2 sm:px-0 ${maxWidthClass}`}
49
+ >
50
+ <div className="overflow-hidden rounded-2xl shadow-xl ring-1 ring-black ring-opacity-5 bg-white">
51
+ {/* Menu Items */}
52
+ <div className={`relative grid gap-1 p-4 ${columnClass}`}>
27
53
  {children}
28
54
  </div>
55
+
56
+ {/* Footer */}
29
57
  {props.footer && (
30
- <div className="bg-gray-50 p-5 sm:p-8">
58
+ <div className="border-t border-gray-100 bg-gray-50 px-4 py-4">
31
59
  <Link
32
60
  to={props.footer.link}
33
61
  openInNewTab={true}
34
- className="-m-3 flow-root rounded-md p-3 transition duration-150 ease-in-out hover:bg-gray-100"
62
+ className="group flex items-center gap-3 rounded-lg p-2.5 -m-2 transition-colors hover:bg-gray-100"
35
63
  >
36
- <span className="flex items-center">
37
- <span className="text-base font-medium text-gray-900">
64
+ <div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-gray-100 ring-1 ring-gray-200 group-hover:bg-gray-200 group-hover:ring-gray-300 transition-all">
65
+ <Icon
66
+ icon={IconProp.GitHub}
67
+ className="h-5 w-5 text-gray-700"
68
+ />
69
+ </div>
70
+ <div className="flex-1 min-w-0 text-left">
71
+ <p className="text-sm font-medium text-gray-900">
38
72
  {props.footer.title}
39
- </span>
40
- </span>
41
- <span className="mt-1 block text-sm text-gray-500 text-left">
42
- {props.footer.description}
43
- </span>
73
+ </p>
74
+ <p className="text-xs text-gray-500">
75
+ {props.footer.description}
76
+ </p>
77
+ </div>
44
78
  </Link>
45
79
  </div>
46
80
  )}
@@ -49,4 +83,4 @@ const NavBarItem: FunctionComponent<ComponentProps> = (
49
83
  );
50
84
  };
51
85
 
52
- export default NavBarItem;
86
+ export default NavBarMenu;