From c9011099455d1218c1e403658f55e573da7caa6d Mon Sep 17 00:00:00 2001 From: Eleni Chappen Date: Thu, 3 Oct 2024 11:30:39 -0500 Subject: [PATCH 01/23] pull data for all orgs page Only thing left to do is roles --- __tests__/api/mocks/orgQuotas.js | 112 ++++++++++++++++++ .../controllers/controller-helpers.test.js | 98 +++++++++++++++ __tests__/controllers/controllers.test.js | 38 ++++++ src/api/cf/cloudfoundry.ts | 4 + src/app/orgs/page.tsx | 9 +- .../OrganizationsList/OrganizationsList.tsx | 36 +++++- src/controllers/controller-helpers.ts | 106 ++++++++++++++++- src/controllers/controller-types.ts | 11 ++ src/controllers/controllers.ts | 25 +++- 9 files changed, 433 insertions(+), 6 deletions(-) create mode 100644 __tests__/api/mocks/orgQuotas.js diff --git a/__tests__/api/mocks/orgQuotas.js b/__tests__/api/mocks/orgQuotas.js new file mode 100644 index 00000000..dbb52eb8 --- /dev/null +++ b/__tests__/api/mocks/orgQuotas.js @@ -0,0 +1,112 @@ +export const mockOrgQuotas = { + pagination: { + total_results: 1, + total_pages: 1, + first: { + href: 'https://api.dev.us-gov-west-1.aws-us-gov.cloud.gov/v3/organization_quotas?organization_guids=470bd8ff-ed0e-4d11-95c4-cf765202cebd&page=1&per_page=50', + }, + last: { + href: 'https://api.dev.us-gov-west-1.aws-us-gov.cloud.gov/v3/organization_quotas?organization_guids=470bd8ff-ed0e-4d11-95c4-cf765202cebd&page=1&per_page=50', + }, + next: null, + previous: null, + }, + resources: [ + { + guid: '3564fac5-c405-480e-b758-57912da29f9e', + created_at: '2017-04-27T19:12:50Z', + updated_at: '2022-07-18T21:01:25Z', + name: 'default', + apps: { + total_memory_in_mb: 10240, + per_process_memory_in_mb: null, + total_instances: null, + per_app_tasks: null, + log_rate_limit_in_bytes_per_second: null, + }, + services: { + paid_services_allowed: true, + total_service_instances: 100, + total_service_keys: 1000, + }, + routes: { + total_routes: 1000, + total_reserved_ports: 5, + }, + domains: { + total_domains: null, + }, + relationships: { + organizations: { + data: [ + { + guid: 'orgId1', + }, + { + guid: 'foo', + }, + { + guid: 'bar', + }, + { + guid: 'baz', + }, + ], + }, + }, + links: { + self: { + href: 'https://api.dev.us-gov-west-1.aws-us-gov.cloud.gov/v3/organization_quotas/3564fac5-c405-480e-b758-57912da29f9e', + }, + }, + }, + { + guid: '3564fac5-c405-480e-b758-57912da29f9f', + created_at: '2017-04-27T19:12:50Z', + updated_at: '2022-07-18T21:01:25Z', + name: 'staging', + apps: { + total_memory_in_mb: 500, + per_process_memory_in_mb: null, + total_instances: null, + per_app_tasks: null, + log_rate_limit_in_bytes_per_second: null, + }, + services: { + paid_services_allowed: true, + total_service_instances: 100, + total_service_keys: 1000, + }, + routes: { + total_routes: 1000, + total_reserved_ports: 5, + }, + domains: { + total_domains: null, + }, + relationships: { + organizations: { + data: [ + { + guid: 'orgId2', + }, + { + guid: 'foo', + }, + { + guid: 'bar', + }, + { + guid: 'baz', + }, + ], + }, + }, + links: { + self: { + href: 'https://api.dev.us-gov-west-1.aws-us-gov.cloud.gov/v3/organization_quotas/3564fac5-c405-480e-b758-57912da29f9e', + }, + }, + }, + ], +}; diff --git a/__tests__/controllers/controller-helpers.test.js b/__tests__/controllers/controller-helpers.test.js index a2a93316..93af914d 100644 --- a/__tests__/controllers/controller-helpers.test.js +++ b/__tests__/controllers/controller-helpers.test.js @@ -3,10 +3,19 @@ import { associateUsersWithRoles, filterUserLogonInfo, pollForJobCompletion, + countUsersPerOrg, + allocatedMemoryPerOrg, + memoryUsagePerOrg, + countSpacesPerOrg, + countAppsPerOrg, } from '@/controllers/controller-helpers'; import { mockUsersByOrganization, mockUsersBySpace } from '../api/mocks/roles'; import { mockS3Object } from '../api/mocks/lastlogon-summary'; import nock from 'nock'; +import mockUsers from '../api/mocks/users'; +import { mockOrgQuotas } from '../api/mocks/orgQuotas'; +import { mockSpaces } from '../api/mocks/spaces'; +import { mockApps } from '../api/mocks/apps'; beforeEach(() => { if (!nock.isActive()) { @@ -209,4 +218,93 @@ describe('controller-helpers', () => { expect(result).not.toBeDefined(); // recursion just completes }); }); + + describe('countUsersPerOrg', () => { + it('returns an object keyed by org guid with value of number of users', async () => { + // setup + // requst 1 + nock(process.env.CF_API_URL) + .get(/orgId1\/users/) + .reply(200, { resources: mockUsers }); // mock users should have 10 users + // request 2 + nock(process.env.CF_API_URL) + .get(/orgId2\/users/) + .reply(200, { resources: mockUsers.slice(0, 5) }); // mock users should have 10 users + // act + const result = await countUsersPerOrg(['orgId1', 'orgId2']); + // assert + expect(result['orgId1']).toEqual(10); + expect(result['orgId2']).toEqual(5); + }); + }); + + describe('allocatedMemoryPerOrg', () => { + it('returns an object keyed by org id with value of memory in mb', async () => { + // setup + const orgGuids = ['orgId1', 'orgId2']; + nock(process.env.CF_API_URL) + .get(/organization_quotas\?organization_guids=orgId1%2CorgId2/) + .reply(200, mockOrgQuotas); + // act + const result = await allocatedMemoryPerOrg(orgGuids); + // assert + expect(result['orgId1']).toEqual(10240); + expect(result['orgId2']).toEqual(500); + }); + }); + + describe('memoryUsagePerOrg', () => { + it('returns an object keyed by org id with value of memory current usage in mb', async () => { + // setup + const orgGuids = ['orgId1', 'orgId2']; + const mockOrgUsageSummary1 = { + usage_summary: { + memory_in_mb: 123, + }, + }; + const mockOrgUsageSummary2 = { + usage_summary: { + memory_in_mb: 456, + }, + }; + nock(process.env.CF_API_URL) + .get(/organizations\/orgId1\/usage_summary/) + .reply(200, mockOrgUsageSummary1); + nock(process.env.CF_API_URL) + .get(/organizations\/orgId2\/usage_summary/) + .reply(200, mockOrgUsageSummary2); + // act + const result = await memoryUsagePerOrg(orgGuids); + // assert + expect(result['orgId1']).toEqual(123); + expect(result['orgId2']).toEqual(456); + }); + }); + + describe('countSpacesPerOrg', () => { + it('returns an object keyed by org id with value of number of spaces', async () => { + // setup + const orgGuids = ['914b4899-2a7c-4214-bacc-f97576e00777']; + nock(process.env.CF_API_URL) + .get(/spaces\?organization_guids=914b4899-2a7c-4214-bacc-f97576e00777/) + .reply(200, mockSpaces); + // act + const result = await countSpacesPerOrg(orgGuids); + // assert + expect(result['914b4899-2a7c-4214-bacc-f97576e00777']).toEqual(3); + }); + }); + + describe('countAppsPerOrg', () => { + it('returns an object keyed by org id with value of number of apps', async () => { + // setup + const orgGuids = ['orgId1', 'orgId2']; + nock(process.env.CF_API_URL).persist().get(/apps/).reply(200, mockApps); // mock apps should have 2 apps + // act + const result = await countAppsPerOrg(orgGuids); + // assert + expect(result['orgId1']).toEqual(2); + expect(result['orgId2']).toEqual(2); + }); + }); }); diff --git a/__tests__/controllers/controllers.test.js b/__tests__/controllers/controllers.test.js index da77a0d0..a35a649f 100644 --- a/__tests__/controllers/controllers.test.js +++ b/__tests__/controllers/controllers.test.js @@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; import { getEditOrgRoles, getOrgPage, + getOrgsPage, getOrgAppsPage, getOrgUsagePage, getUser, @@ -22,6 +23,11 @@ import { getUserLogonInfo } from '@/api/aws/s3'; jest.mock('@/controllers/controller-helpers', () => ({ ...jest.requireActual('../../src/controllers/controller-helpers'), pollForJobCompletion: jest.fn(), + countUsersPerOrg: () => ({ orgId1: 0, orgId2: 1 }), + allocatedMemoryPerOrg: () => ({ orgId1: 2, orgId2: 3 }), + memoryUsagePerOrg: () => ({ orgId1: 4, orgId2: 5 }), + countSpacesPerOrg: () => ({ orgId1: 6, orgId2: 7 }), + countAppsPerOrg: () => ({ orgId1: 8, orgId2: 9 }), })); jest.mock('@/api/aws/s3', () => ({ getUserLogonInfo: jest.fn(), @@ -532,4 +538,36 @@ describe('controllers tests', () => { }).rejects.toThrow(new Error('something went wrong with the request')); }); }); + + describe('getOrgsPage', () => { + it('returns the correct values for each org', async () => { + // setup + const mockOrgs = [{ guid: 'orgId1' }, { guid: 'orgId2' }]; + // get orgs + nock(process.env.CF_API_URL) + .get(/organizations/) + .reply(200, { resources: mockOrgs }); + // act + const result = await getOrgsPage(); + // assert + expect(result.meta.status).toEqual('success'); + expect(result.payload.orgs.length).toEqual(2); + // see jest mocks at top of file for mock return values + // users + expect(result.payload.userCounts['orgId1']).toEqual(0); + expect(result.payload.userCounts['orgId2']).toEqual(1); + // allocated memory + expect(result.payload.memoryAllocated['orgId1']).toEqual(2); + expect(result.payload.memoryAllocated['orgId2']).toEqual(3); + // memory usage + expect(result.payload.memoryCurrentUsage['orgId1']).toEqual(4); + expect(result.payload.memoryCurrentUsage['orgId2']).toEqual(5); + // spaces + expect(result.payload.spaceCounts['orgId1']).toEqual(6); + expect(result.payload.spaceCounts['orgId2']).toEqual(7); + // apps + expect(result.payload.appCounts['orgId1']).toEqual(8); + expect(result.payload.appCounts['orgId2']).toEqual(9); + }); + }); }); diff --git a/src/api/cf/cloudfoundry.ts b/src/api/cf/cloudfoundry.ts index 47da87da..c7d04699 100644 --- a/src/api/cf/cloudfoundry.ts +++ b/src/api/cf/cloudfoundry.ts @@ -51,6 +51,10 @@ export async function getOrgs(): Promise { return await cfRequest('/organizations', 'get'); } +export async function getOrgUsers(guid: string): Promise { + return await cfRequest(`/organizations/${guid}/users`, 'get'); +} + // ROLES // NOTE: addRole relies on username rather than user guid diff --git a/src/app/orgs/page.tsx b/src/app/orgs/page.tsx index 2e9933d4..5787e683 100644 --- a/src/app/orgs/page.tsx +++ b/src/app/orgs/page.tsx @@ -10,7 +10,14 @@ export default async function OrgsPage() { return ( <> - + ); } diff --git a/src/components/OrganizationsList/OrganizationsList.tsx b/src/components/OrganizationsList/OrganizationsList.tsx index d7b9d493..b6b83ac3 100644 --- a/src/components/OrganizationsList/OrganizationsList.tsx +++ b/src/components/OrganizationsList/OrganizationsList.tsx @@ -3,14 +3,46 @@ import { sortObjectsByParam } from '@/helpers/arrays'; import { GridList } from '../GridList/GridList'; import { OrganizationsListItem } from './OrganizationsListItem'; -export function OrganizationsList({ orgs }: { orgs: Array }) { +export function OrganizationsList({ + orgs, + userCounts, + appCounts, + memoryAllocated, + memoryCurrentUsage, + spaceCounts, +}: { + orgs: Array; + userCounts: { [orgGuid: string]: number }; + appCounts: { [orgGuid: string]: number }; + memoryAllocated: { [orgGuid: string]: number }; + memoryCurrentUsage: { [orgGuid: string]: number }; + spaceCounts: { [orgGuid: string]: number }; +}) { const orgsSorted = sortObjectsByParam(orgs, 'name'); return orgsSorted.length > 0 ? ( {orgsSorted.map((org) => { return ( - +
+ +
    +
  1. number of users: {userCounts[org.guid]}
  2. +
  3. number of apps: {appCounts[org.guid]}
  4. +
  5. + memory allocated:{' '} + {memoryAllocated[org.guid] === null + ? 'unlimited' + : memoryAllocated[org.guid]} +
  6. +
  7. memory current usage: {memoryCurrentUsage[org.guid]}
  8. +
  9. + memory remaining:{' '} + {memoryAllocated[org.guid] - memoryCurrentUsage[org.guid]} +
  10. +
  11. number of spaces: {spaceCounts[org.guid] || 0}
  12. +
+
); })}
diff --git a/src/controllers/controller-helpers.ts b/src/controllers/controller-helpers.ts index 394b5e0c..26ada3fe 100644 --- a/src/controllers/controller-helpers.ts +++ b/src/controllers/controller-helpers.ts @@ -1,5 +1,6 @@ -import { RolesByUser, SpaceRoleMap } from './controller-types'; -import { RoleObj, UserObj } from '@/api/cf/cloudfoundry-types'; +import * as CF from '@/api/cf/cloudfoundry'; +import { OrgQuotaObject, RolesByUser, SpaceRoleMap } from './controller-types'; +import { RoleObj, UserObj, SpaceObj } from '@/api/cf/cloudfoundry-types'; import { UserLogonInfoById } from '@/api/aws/s3-types'; import { cfRequestOptions } from '@/api/cf/cloudfoundry-helpers'; import { request } from '@/api/api'; @@ -162,3 +163,104 @@ export function resourceKeyedById(resource: Array): Object { return acc; }, {}); } + +// get number of users for each org +export async function countUsersPerOrg( + orgGuids: Array +): Promise<{ [orgGuid: string]: number }> { + const responses = await Promise.all( + orgGuids.map((orgId: string) => CF.getOrgUsers(orgId)) + ); + const responsesJson = await Promise.all(responses.map((res) => res.json())); + return responsesJson.reduce((acc, curRes, curIndex) => { + acc[orgGuids[curIndex]] = curRes?.resources?.length || 0; + return acc; + }, {}); +} + +// get allocated memory for each org +export async function allocatedMemoryPerOrg( + orgGuids: Array +): Promise<{ [orgGuid: string]: number }> { + let memoryAllocated = {}; + const orgQuotaRes = await CF.getOrgQuotas({ + organizationGuids: orgGuids, + }); + if (orgQuotaRes.ok) { + const orgQuotas = (await orgQuotaRes.json()).resources; + memoryAllocated = orgQuotas.reduce( + (acc: { [orgId: string]: number }, curQuota: OrgQuotaObject) => { + const relatedOrgs = curQuota.relationships.organizations.data.map( + (o: any) => o.guid + ); + orgGuids.map((orgGuid: string) => { + if ( + relatedOrgs.find( + (relatedOrgGuid: string) => relatedOrgGuid === orgGuid + ) + ) { + acc[orgGuid] = curQuota.apps.total_memory_in_mb; + } + }); + return acc; + }, + {} + ); + } + return memoryAllocated; +} + +// get memory usage for each org +export async function memoryUsagePerOrg( + orgGuids: Array +): Promise<{ [orgGuid: string]: number }> { + let memoryUsage = {}; + const responses = await Promise.all( + orgGuids.map((orgId: string) => CF.getOrgUsageSummary(orgId)) + ); + const responsesJson = await Promise.all(responses.map((res) => res.json())); + memoryUsage = responsesJson.reduce((acc, curResponse, index) => { + acc[orgGuids[index]] = curResponse.usage_summary.memory_in_mb; + return acc; + }, {}); + return memoryUsage; +} + +// get spaces for each org +export async function countSpacesPerOrg( + orgGuids: Array +): Promise<{ [orgGuid: string]: number }> { + let spaceCounts = {}; + const responses = await CF.getSpaces({ + organizationGuids: orgGuids, + }); + const spaces = (await responses.json()).resources; + spaceCounts = spaces.reduce( + (acc: { [orgId: string]: number }, curSpace: SpaceObj) => { + const orgId = curSpace.relationships.organization.data.guid; + if (!acc[orgId]) acc[orgId] = 0; + acc[orgId] = acc[orgId] + 1; + return acc; + }, + {} + ); + return spaceCounts; +} + +// get number of apps per org +export async function countAppsPerOrg( + orgGuids: Array +): Promise<{ [orgGuid: string]: number }> { + const responses = await Promise.all( + orgGuids.map((orgId: string) => + CF.getApps({ + organizationGuids: [orgId], + }) + ) + ); + const responsesJson = await Promise.all(responses.map((res) => res.json())); + return responsesJson.reduce((acc, curRes, curIndex) => { + acc[orgGuids[curIndex]] = curRes?.resources?.length || 0; + return acc; + }, {}); +} diff --git a/src/controllers/controller-types.ts b/src/controllers/controller-types.ts index 83fb98ff..f7420851 100644 --- a/src/controllers/controller-types.ts +++ b/src/controllers/controller-types.ts @@ -91,3 +91,14 @@ export interface UserOrgPage extends UserObj { daysToExpiration: number | null; lastLogonTime: number | null | undefined; } + +export interface OrgQuotaObject { + apps: { + total_memory_in_mb: number; + }; + relationships: { + organizations: { + data: { guid: string }[]; + }; + }; +} diff --git a/src/controllers/controllers.ts b/src/controllers/controllers.ts index ee9dcbab..0138e60f 100644 --- a/src/controllers/controllers.ts +++ b/src/controllers/controllers.ts @@ -5,6 +5,7 @@ import { revalidatePath } from 'next/cache'; import * as CF from '@/api/cf/cloudfoundry'; import { + OrgObj, ServiceCredentialBindingObj, ServiceInstanceObj, SpaceObj, @@ -20,6 +21,11 @@ import { pollForJobCompletion, resourceKeyedById, apiErrorMessage, + countUsersPerOrg, + allocatedMemoryPerOrg, + memoryUsagePerOrg, + countSpacesPerOrg, + countAppsPerOrg, } from './controller-helpers'; import { sortObjectsByParam } from '@/helpers/arrays'; import { daysToExpiration } from '@/helpers/dates'; @@ -75,13 +81,30 @@ export async function getOrgsPage(): Promise { return { payload: { orgs: [], + userCounts: {}, }, meta: { status: 'error' }, }; } + const orgs = (await res.json()).resources; + const orgGuids = orgs.map((org: OrgObj) => org.guid); + const userCounts = await countUsersPerOrg(orgGuids); + const memoryAllocated = await allocatedMemoryPerOrg(orgGuids); + const memoryCurrentUsage = await memoryUsagePerOrg(orgGuids); + const spaceCounts = await countSpacesPerOrg(orgGuids); + const appCounts = await countAppsPerOrg(orgGuids); + // get user's roles for each org + const roles = {}; + return { payload: { - orgs: (await res.json()).resources, + orgs: orgs, + userCounts: userCounts, + appCounts: appCounts, + memoryAllocated: memoryAllocated, + memoryCurrentUsage: memoryCurrentUsage, + spaceCounts: spaceCounts, + roles: roles, }, meta: { status: 'success' }, }; From 97f6158aba6fa5f721ceca87965635ab5ce0e74c Mon Sep 17 00:00:00 2001 From: Eleni Chappen Date: Fri, 4 Oct 2024 13:41:09 -0500 Subject: [PATCH 02/23] add last visited org link to all orgs page --- .../components/LastViewedOrgLink.test.js | 40 +++++++++ __tests__/middleware.test.js | 85 +++++++++++++++++++ src/app/orgs/page.tsx | 7 +- src/components/LastViewedOrgLink.tsx | 26 ++++++ src/middleware.ts | 20 ++++- 5 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 __tests__/components/LastViewedOrgLink.test.js create mode 100644 src/components/LastViewedOrgLink.tsx diff --git a/__tests__/components/LastViewedOrgLink.test.js b/__tests__/components/LastViewedOrgLink.test.js new file mode 100644 index 00000000..f3a9c44d --- /dev/null +++ b/__tests__/components/LastViewedOrgLink.test.js @@ -0,0 +1,40 @@ +/** + * @jest-environment jsdom + */ +import { cookies } from 'next/headers'; +import { describe, expect, it, beforeEach } from '@jest/globals'; +import { render } from '@testing-library/react'; +import { LastViewedOrgLink } from '@/components/LastViewedOrgLink'; + +/* global jest */ +/* eslint no-undef: "off" */ +jest.mock('next/headers', () => ({ + cookies: jest.fn(), +})); +/* eslint no-undef: "error" */ + +describe.skip('', () => { + describe('when no org id cookie is found', () => { + beforeEach(() => { + // TODO: figure out how to mock cookies (we're mocking same as token.test.js but it doesn't work here) + cookies.mockImplementation(() => ({ + get: () => null, + })); + }); + it('returns nothing', async () => { + // act + const component = render(await LastViewedOrgLink()); + // assert + const link = component.queryByRole('link'); + expect(link).not.toBeInTheDocument(); + }); + }); + + describe('when get org request fails', () => { + it.todo('returns nothing'); + }); + + describe('when org cookie is present and get org succeeds', () => { + it.todo('shows correct hyperlink and org name'); + }); +}); diff --git a/__tests__/middleware.test.js b/__tests__/middleware.test.js index 767523f9..83886698 100644 --- a/__tests__/middleware.test.js +++ b/__tests__/middleware.test.js @@ -195,3 +195,88 @@ describe('/test/authenticated/:path*', () => { }); }); }); + +describe('/orgs/* when logged in', () => { + describe('when org id is part of url path', () => { + // setup + const request = new NextRequest( + new URL( + '/orgs/470bd8ff-ed0e-4d11-95c4-cf765202cebd/bar', + process.env.ROOT_URL + ) + ); + let response; + + beforeAll(async () => { + // setup + request.cookies.set( + 'authsession', + JSON.stringify({ + expiry: Date.now() + 10000000, + }) + ); + // run + response = await middleware(request); + }); + + it('sets lastViewedOrgId cookie as org id', () => { + // assert + expect(response.cookies.get('lastViewedOrgId').value).toEqual( + '470bd8ff-ed0e-4d11-95c4-cf765202cebd' + ); + }); + }); + + describe('when org id is end of url path', () => { + // setup + const request = new NextRequest( + new URL( + '/orgs/470bd8ff-ed0e-4d11-95c4-cf765202cebd', + process.env.ROOT_URL + ) + ); + let response; + + beforeAll(async () => { + // setup + request.cookies.set( + 'authsession', + JSON.stringify({ + expiry: Date.now() + 10000000, + }) + ); + // run + response = await middleware(request); + }); + + it('sets lastViewedOrgId cookie as org id', () => { + // assert + expect(response.cookies.get('lastViewedOrgId').value).toEqual( + '470bd8ff-ed0e-4d11-95c4-cf765202cebd' + ); + }); + }); + + describe('when org id is not in url path', () => { + // setup + const request = new NextRequest(new URL('/orgs/foo', process.env.ROOT_URL)); + let response; + + beforeAll(async () => { + // setup + request.cookies.set( + 'authsession', + JSON.stringify({ + expiry: Date.now() + 10000000, + }) + ); + // run + response = await middleware(request); + }); + + it('does not set lastViewedOrgId cookie', () => { + // assert + expect(response.cookies.get('lastViewedOrgId')).toBeUndefined(); + }); + }); +}); diff --git a/src/app/orgs/page.tsx b/src/app/orgs/page.tsx index 5787e683..3e621a97 100644 --- a/src/app/orgs/page.tsx +++ b/src/app/orgs/page.tsx @@ -3,13 +3,18 @@ import { getOrgsPage } from '@/controllers/controllers'; import { OrganizationsList } from '@/components/OrganizationsList/OrganizationsList'; import { PageHeader } from '@/components/PageHeader'; +import { LastViewedOrgLink } from '@/components/LastViewedOrgLink'; export default async function OrgsPage() { const { payload } = await getOrgsPage(); return ( <> - + + + (Need to jump back in? The organization you last accessed was{' '} + + {org.name} + + .) + + ); +} diff --git a/src/middleware.ts b/src/middleware.ts index 84ea5ac3..c9074611 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -135,14 +135,27 @@ export async function authenticateRoute(request: NextRequest) { // if no expiration at all, redirect to login page if (!authObj.expiry) return redirectToLogin(request); // if cookie expired, run refresh routine + let nextRes = NextResponse.next(); if (Date.now() > authObj.expiry) { const newAuthResponse = await refreshAuthToken(authObj.refreshToken); - let nextRes = NextResponse.next(); nextRes = setAuthCookie(newAuthResponse, nextRes); return nextRes; } - // cookie is not expired, go to page - return NextResponse.next(); + // they're logged in already + nextRes = setLastViewedOrg(request, nextRes); + // go to page + return nextRes; +} + +export function setLastViewedOrg(request: NextRequest, response: NextResponse) { + const matches = request.nextUrl.pathname.match( + /orgs\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})/ + ); + let id; + if (matches && (id = matches[1])) { + response.cookies.set('lastViewedOrgId', id); + } + return response; } export function middleware(request: NextRequest) { @@ -171,6 +184,5 @@ export const config = { '/login', '/test/authenticated/:path*', '/orgs', - '/orgs/:path*', ], }; From 5fc3a578ae2ec8c4875cbfed55f26352c6015a0a Mon Sep 17 00:00:00 2001 From: Eleni Chappen Date: Fri, 4 Oct 2024 13:50:33 -0500 Subject: [PATCH 03/23] improve error handling for all orgs page --- src/controllers/controller-helpers.ts | 2 +- src/controllers/controllers.ts | 62 ++++++++++++++------------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/src/controllers/controller-helpers.ts b/src/controllers/controller-helpers.ts index 26ada3fe..11438b7e 100644 --- a/src/controllers/controller-helpers.ts +++ b/src/controllers/controller-helpers.ts @@ -90,7 +90,7 @@ export function likelyNonHumanUser(user: UserObj): boolean { export async function logDevError(message: string) { if (process.env.NODE_ENV === 'development') { - console.error(message); + console.log(message); } } diff --git a/src/controllers/controllers.ts b/src/controllers/controllers.ts index 0138e60f..17ec08be 100644 --- a/src/controllers/controllers.ts +++ b/src/controllers/controllers.ts @@ -73,41 +73,43 @@ export async function getOrg(guid: string): Promise { } export async function getOrgsPage(): Promise { - const res = await CF.getOrgs(); - if (!res.ok) { - logDevError( - `api error on cf orgs with http code ${res.status} for url: ${res.url}` - ); + try { + const res = await CF.getOrgs(); + if (!res.ok) { + logDevError( + `api error on cf orgs with http code ${res.status} for url: ${res.url}` + ); + throw new Error( + 'There was a problem with the request. Please try again, and if the issue persists, please contact Cloud.gov support.' + ); + } + const orgs = (await res.json()).resources; + const orgGuids = orgs.map((org: OrgObj) => org.guid); + const userCounts = await countUsersPerOrg(orgGuids); + const memoryAllocated = await allocatedMemoryPerOrg(orgGuids); + const memoryCurrentUsage = await memoryUsagePerOrg(orgGuids); + const spaceCounts = await countSpacesPerOrg(orgGuids); + const appCounts = await countAppsPerOrg(orgGuids); + // get user's roles for each org + const roles = {}; + return { payload: { - orgs: [], - userCounts: {}, + orgs: orgs, + userCounts: userCounts, + appCounts: appCounts, + memoryAllocated: memoryAllocated, + memoryCurrentUsage: memoryCurrentUsage, + spaceCounts: spaceCounts, + roles: roles, }, - meta: { status: 'error' }, + meta: { status: 'success' }, }; + } catch (e: any) { + throw new Error( + 'There was a problem with the request. Please try again, and if the issue persists, please contact Cloud.gov support.' + ); } - const orgs = (await res.json()).resources; - const orgGuids = orgs.map((org: OrgObj) => org.guid); - const userCounts = await countUsersPerOrg(orgGuids); - const memoryAllocated = await allocatedMemoryPerOrg(orgGuids); - const memoryCurrentUsage = await memoryUsagePerOrg(orgGuids); - const spaceCounts = await countSpacesPerOrg(orgGuids); - const appCounts = await countAppsPerOrg(orgGuids); - // get user's roles for each org - const roles = {}; - - return { - payload: { - orgs: orgs, - userCounts: userCounts, - appCounts: appCounts, - memoryAllocated: memoryAllocated, - memoryCurrentUsage: memoryCurrentUsage, - spaceCounts: spaceCounts, - roles: roles, - }, - meta: { status: 'success' }, - }; } export async function getOrgPage(orgGuid: string): Promise { From 6224148b4295d1365fd0b79932f8dac5aeffd84e Mon Sep 17 00:00:00 2001 From: Eleni Chappen Date: Fri, 4 Oct 2024 14:05:51 -0500 Subject: [PATCH 04/23] Fix: include all org routes in middleware matches; set org id cookie in dev env --- src/middleware.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/middleware.ts b/src/middleware.ts index c9074611..413cf668 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -124,8 +124,12 @@ export function redirectToLogin(request: NextRequest): NextResponse { } export async function authenticateRoute(request: NextRequest) { + let response = NextResponse.next(); // For those working locally, just pass them through - if (process.env.NODE_ENV === 'development') return NextResponse.next(); + if (process.env.NODE_ENV === 'development') { + response = setLastViewedOrg(request, response); + return response; + } // get auth session cookie const authCookie = request.cookies.get('authsession'); // if no cookie, redirect to login page @@ -135,16 +139,15 @@ export async function authenticateRoute(request: NextRequest) { // if no expiration at all, redirect to login page if (!authObj.expiry) return redirectToLogin(request); // if cookie expired, run refresh routine - let nextRes = NextResponse.next(); if (Date.now() > authObj.expiry) { const newAuthResponse = await refreshAuthToken(authObj.refreshToken); - nextRes = setAuthCookie(newAuthResponse, nextRes); - return nextRes; + response = setAuthCookie(newAuthResponse, response); + return response; } // they're logged in already - nextRes = setLastViewedOrg(request, nextRes); + response = setLastViewedOrg(request, response); // go to page - return nextRes; + return response; } export function setLastViewedOrg(request: NextRequest, response: NextResponse) { @@ -184,5 +187,6 @@ export const config = { '/login', '/test/authenticated/:path*', '/orgs', + '/orgs/:path*', ], }; From f6253c699f125eff80015c80491a0be3ae87fef6 Mon Sep 17 00:00:00 2001 From: Eleni Chappen Date: Mon, 7 Oct 2024 15:45:11 -0500 Subject: [PATCH 05/23] apply UI design to all orgs page --- __tests__/components/MemoryBar.test.js | 32 ++++++ .../OrganizationsListItem.test.js | 31 ------ __tests__/components/ProgressBar.test.js | 50 ++++++++++ __tests__/helpers/arrays.test.js | 17 +++- __tests__/helpers/numbers.test.js | 13 +++ src/app/orgs/page.tsx | 4 +- src/assets/stylesheets/styles.scss | 6 +- src/components/LastViewedOrgLink.tsx | 2 +- src/components/MemoryBar.tsx | 32 ++++++ .../OrganizationsList/OrganizationsList.tsx | 99 +++++++++++++------ .../OrganizationsListItem.tsx | 57 ----------- src/components/ProgressBar.tsx | 32 ++++++ src/helpers/arrays.tsx | 8 ++ src/helpers/numbers.ts | 3 + 14 files changed, 264 insertions(+), 122 deletions(-) create mode 100644 __tests__/components/MemoryBar.test.js delete mode 100644 __tests__/components/OrganizationsList/OrganizationsListItem.test.js create mode 100644 __tests__/components/ProgressBar.test.js create mode 100644 __tests__/helpers/numbers.test.js create mode 100644 src/components/MemoryBar.tsx delete mode 100644 src/components/OrganizationsList/OrganizationsListItem.tsx create mode 100644 src/components/ProgressBar.tsx create mode 100644 src/helpers/numbers.ts diff --git a/__tests__/components/MemoryBar.test.js b/__tests__/components/MemoryBar.test.js new file mode 100644 index 00000000..f1790bd1 --- /dev/null +++ b/__tests__/components/MemoryBar.test.js @@ -0,0 +1,32 @@ +/** + * @jest-environment jsdom + */ +import { describe, expect, it, beforeEach } from '@jest/globals'; +import { render, screen } from '@testing-library/react'; +import { MemoryBar } from '@/components/MemoryBar'; + +describe('', () => { + describe('when no allocated memory', () => { + it('returns nothing', () => { + render(); + const component = screen.queryByTestId('memory-bar'); + expect(component).not.toBeInTheDocument(); + }); + }); + + describe('when allocated memory', () => { + beforeEach(() => { + render(); + }); + + it('returns content', () => { + const component = screen.queryByTestId('memory-bar'); + expect(component).toBeInTheDocument(); + }); + + it('shows correct amount remaining', () => { + const remainingText = screen.queryByText(/60MB remaining/); + expect(remainingText).toBeInTheDocument(); + }); + }); +}); diff --git a/__tests__/components/OrganizationsList/OrganizationsListItem.test.js b/__tests__/components/OrganizationsList/OrganizationsListItem.test.js deleted file mode 100644 index 1cdeb16c..00000000 --- a/__tests__/components/OrganizationsList/OrganizationsListItem.test.js +++ /dev/null @@ -1,31 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { describe, expect, it } from '@jest/globals'; -import { render, screen } from '@testing-library/react'; -import { OrganizationsListItem } from '@/components/OrganizationsList/OrganizationsListItem'; - -const mockOrg = { - guid: 'orgGuid', - created_at: '2017-06-01T19:27:19Z', - name: 'Org 1', - suspended: false, -}; - -describe('AppsListItem', () => { - it('when given app and space info, displays fields', () => { - // act - render(); - - const manageUsersLink = screen.getByRole('link', { name: 'Manage users' }); - const viewAppsLink = screen.getByRole('link', { - name: 'View applications', - }); - - expect(screen.getByText('Org 1')).toBeInTheDocument(); - expect(screen.getByText('Status: Active')).toBeInTheDocument(); - expect(screen.getByText('Created: Jun 1, 2017')).toBeInTheDocument(); - expect(manageUsersLink).toHaveAttribute('href', '/orgs/orgGuid'); - expect(viewAppsLink).toHaveAttribute('href', '/orgs/orgGuid/apps'); - }); -}); diff --git a/__tests__/components/ProgressBar.test.js b/__tests__/components/ProgressBar.test.js new file mode 100644 index 00000000..0a06a866 --- /dev/null +++ b/__tests__/components/ProgressBar.test.js @@ -0,0 +1,50 @@ +/** + * @jest-environment jsdom + */ +import { describe, expect, it } from '@jest/globals'; +import { render } from '@testing-library/react'; +import { ProgressBar } from '@/components/ProgressBar'; + +describe('', () => { + describe('when changeColors is false', () => { + it('keeps progress bar green', () => { + // act + const { container } = render( + + ); + // assert + const progressDiv = container.querySelector('.bg-mint'); + expect(progressDiv).toBeInTheDocument(); + }); + }); + + describe('when percentage is less than threshold1', () => { + it('shows a green bar', () => { + // act + const { container } = render(); + // assert + const progressDiv = container.querySelector('.bg-mint'); + expect(progressDiv).toBeInTheDocument(); + }); + }); + + describe('when percentage is above threshold1 but below threshold2', () => { + it('shows an light red bar', () => { + // act + const { container } = render(); + // assert + const progressDiv = container.querySelector('.bg-red-30v'); + expect(progressDiv).toBeInTheDocument(); + }); + }); + + describe('when percentage is above threshold2', () => { + it('shows a deep red bar', () => { + // act + const { container } = render(); + // assert + const progressDiv = container.querySelector('.bg-red-40v'); + expect(progressDiv).toBeInTheDocument(); + }); + }); +}); diff --git a/__tests__/helpers/arrays.test.js b/__tests__/helpers/arrays.test.js index a62e1984..06d29af0 100644 --- a/__tests__/helpers/arrays.test.js +++ b/__tests__/helpers/arrays.test.js @@ -1,5 +1,9 @@ import { describe, expect, it } from '@jest/globals'; -import { sortObjectsByParam, filterObjectsByParams } from '@/helpers/arrays'; +import { + sortObjectsByParam, + filterObjectsByParams, + chunkArray, +} from '@/helpers/arrays'; describe('sortObjectsByParam', () => { // setup @@ -68,3 +72,14 @@ describe('filterObjectsByParams', () => { expect(result[1]).toBe(ary[1]); }); }); + +describe('chunkArray', () => { + it('groups items in array into another array per given chunk size', () => { + const ary = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + const size = 3; + const result = chunkArray(ary, size); + expect(result.length).toEqual(4); + expect(result[0].length).toEqual(3); + expect(result[0]).toEqual([1, 2, 3]); + }); +}); diff --git a/__tests__/helpers/numbers.test.js b/__tests__/helpers/numbers.test.js new file mode 100644 index 00000000..0584f2fe --- /dev/null +++ b/__tests__/helpers/numbers.test.js @@ -0,0 +1,13 @@ +import { describe, expect, it } from '@jest/globals'; +import { formatInt } from '@/helpers/numbers'; + +describe('formatInt', () => { + it('returns small numbers as they are', () => { + const result = formatInt(146); + expect(result).toEqual('146'); + }); + it('returns commas in big numbers', () => { + const result = formatInt(146738); + expect(result).toEqual('146,738'); + }); +}); diff --git a/src/app/orgs/page.tsx b/src/app/orgs/page.tsx index 3e621a97..6736551e 100644 --- a/src/app/orgs/page.tsx +++ b/src/app/orgs/page.tsx @@ -9,7 +9,7 @@ export default async function OrgsPage() { const { payload } = await getOrgsPage(); return ( - <> +
- +
); } diff --git a/src/assets/stylesheets/styles.scss b/src/assets/stylesheets/styles.scss index 0332d90e..9edede83 100644 --- a/src/assets/stylesheets/styles.scss +++ b/src/assets/stylesheets/styles.scss @@ -106,13 +106,15 @@ $background-color-palettes: ( 'palette-color-system-mint-medium', 'palette-color-system-green-cool-vivid', - 'palette-color-system-red-cool-vivid' + 'palette-color-system-red-cool-vivid', + 'palette-color-system-red-vivid' // no trailing comma,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, ), $border-color-palettes: ( 'palette-color-system-green-cool', - 'palette-color-system-red-vivid' + 'palette-color-system-red-vivid', + 'palette-color-system-gray-cool' // no trailing comma,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, ), diff --git a/src/components/LastViewedOrgLink.tsx b/src/components/LastViewedOrgLink.tsx index f150e0c6..58c8276c 100644 --- a/src/components/LastViewedOrgLink.tsx +++ b/src/components/LastViewedOrgLink.tsx @@ -17,7 +17,7 @@ export async function LastViewedOrgLink() { return ( <> (Need to jump back in? The organization you last accessed was{' '} - + {org.name} .) diff --git a/src/components/MemoryBar.tsx b/src/components/MemoryBar.tsx new file mode 100644 index 00000000..24f6aa9f --- /dev/null +++ b/src/components/MemoryBar.tsx @@ -0,0 +1,32 @@ +import { ProgressBar } from '@/components/ProgressBar'; +import { formatInt } from '@/helpers/numbers'; + +export function MemoryBar({ + memoryUsed, + memoryAllocated, +}: { + memoryUsed?: number | null | undefined; + memoryAllocated?: number | null | undefined; +}) { + if (!memoryAllocated) { + return null; + } + const memoryUsedNum = memoryUsed || 0; + const mbRemaining = memoryAllocated - memoryUsedNum; + return ( +
+

Memory:

+ + + +
+
+ {formatInt(memoryUsedNum)}MB of {formatInt(memoryAllocated)}MB + allocated +
+ +
{formatInt(mbRemaining)}MB remaining
+
+
+ ); +} diff --git a/src/components/OrganizationsList/OrganizationsList.tsx b/src/components/OrganizationsList/OrganizationsList.tsx index b6b83ac3..1d576711 100644 --- a/src/components/OrganizationsList/OrganizationsList.tsx +++ b/src/components/OrganizationsList/OrganizationsList.tsx @@ -1,7 +1,8 @@ import { OrgObj } from '@/api/cf/cloudfoundry-types'; -import { sortObjectsByParam } from '@/helpers/arrays'; -import { GridList } from '../GridList/GridList'; -import { OrganizationsListItem } from './OrganizationsListItem'; +import { chunkArray, sortObjectsByParam } from '@/helpers/arrays'; +import Link from 'next/link'; +import { MemoryBar } from '@/components/MemoryBar'; +import { formatInt } from '@/helpers/numbers'; export function OrganizationsList({ orgs, @@ -18,35 +19,77 @@ export function OrganizationsList({ memoryCurrentUsage: { [orgGuid: string]: number }; spaceCounts: { [orgGuid: string]: number }; }) { + if (!orgs.length) { + return <>no orgs found; + } + const orgsSorted = sortObjectsByParam(orgs, 'name'); + const orgsGrouped = chunkArray(orgsSorted, 3); - return orgsSorted.length > 0 ? ( - - {orgsSorted.map((org) => { + return ( +
+ {orgsGrouped.map((orgGoup, groupIndex) => { return ( -
- -
    -
  1. number of users: {userCounts[org.guid]}
  2. -
  3. number of apps: {appCounts[org.guid]}
  4. -
  5. - memory allocated:{' '} - {memoryAllocated[org.guid] === null - ? 'unlimited' - : memoryAllocated[org.guid]} -
  6. -
  7. memory current usage: {memoryCurrentUsage[org.guid]}
  8. -
  9. - memory remaining:{' '} - {memoryAllocated[org.guid] - memoryCurrentUsage[org.guid]} -
  10. -
  11. number of spaces: {spaceCounts[org.guid] || 0}
  12. -
-
+
    + {orgGoup.map((org, index) => { + return ( +
  • +
    +

    + + {org.name} + +

    + +
    +
    + TODO: You’re a Manager and a{' '} + Billing Manager in this organization. +
    +
    +

    + at a glance: +

    +
      +
    • + + {formatInt(userCounts[org.guid])} users + +
    • +
    • + + {formatInt(spaceCounts[org.guid])} spaces + +
    • +
    • + + {formatInt(appCounts[org.guid])} applications + +
    • +
    +
    +
    + + +
    +
  • + ); + })} +
); })} - - ) : ( -

No organizations found

+
); } diff --git a/src/components/OrganizationsList/OrganizationsListItem.tsx b/src/components/OrganizationsList/OrganizationsListItem.tsx deleted file mode 100644 index ba03cb87..00000000 --- a/src/components/OrganizationsList/OrganizationsListItem.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import Link from 'next/link'; -import { OrgObj } from '@/api/cf/cloudfoundry-types'; -import { GridListItem } from '../GridList/GridListItem'; -import { GridListItemTop } from '../GridList/GridListItemTop'; -import { GridListItemBottom } from '../GridList/GridListItemBottom'; -import { GridListItemBottomLeft } from '../GridList/GridListItemBottomLeft'; -import { GridListItemBottomCenter } from '../GridList/GridListItemBottomCenter'; -import { GridListItemBottomRight } from '../GridList/GridListItemBottomRight'; -import { formatDate } from '@/helpers/dates'; - -export function OrganizationsListItem({ org }: { org: OrgObj }) { - return ( - -
- -

- {org.name} -

-
- - - Status: {org.suspended ? 'Suspended' : 'Active'} - - -
-
    -
  • - - Manage users - -
  • -
  • - - View applications - -
  • -
  • - - View usage - -
  • -
-
-
- -
- Created: {formatDate(org.created_at)} -
-
-
-
-
- ); -} diff --git a/src/components/ProgressBar.tsx b/src/components/ProgressBar.tsx new file mode 100644 index 00000000..4b4e1027 --- /dev/null +++ b/src/components/ProgressBar.tsx @@ -0,0 +1,32 @@ +export function ProgressBar({ + total, // number representing total length of bar + fill, // number representing how much bar should be filled + threshold1 = 75, // percentage where color should change first, between 0 and 100 + threshold2 = 90, // percentage where color should change next, between 0 and 100 + changeColors = true, +}: { + total: number; + fill: number; + threshold1?: number; + threshold2?: number; + changeColors?: boolean; +}) { + const heightClass = 'height-1'; + const percentage = Math.floor((fill / total) * 100); + let color = 'bg-mint'; + if (changeColors && percentage > threshold1 && percentage < threshold2) { + color = 'bg-red-30v'; + } + if (changeColors && percentage > threshold2) { + color = 'bg-red-40v'; + } + return ( +
+
+
+ ); +} diff --git a/src/helpers/arrays.tsx b/src/helpers/arrays.tsx index 0e44ad65..7b9f808b 100644 --- a/src/helpers/arrays.tsx +++ b/src/helpers/arrays.tsx @@ -33,3 +33,11 @@ export function filterObjectsByParams( ); }); } + +export function chunkArray(array: Array, size: number) { + const result = []; + for (let i = 0; i < array.length; i += size) { + result.push(array.slice(i, i + size)); + } + return result; +} diff --git a/src/helpers/numbers.ts b/src/helpers/numbers.ts new file mode 100644 index 00000000..72f2168c --- /dev/null +++ b/src/helpers/numbers.ts @@ -0,0 +1,3 @@ +export function formatInt(int: number): string { + return int?.toLocaleString('en-US'); +} From 4640fb5d48f3bd2d8d530059302d427ec83caf80 Mon Sep 17 00:00:00 2001 From: Eleni Chappen Date: Mon, 7 Oct 2024 15:56:19 -0500 Subject: [PATCH 06/23] apply initial mobile styles to all orgs cards --- src/components/OrganizationsList/OrganizationsList.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/OrganizationsList/OrganizationsList.tsx b/src/components/OrganizationsList/OrganizationsList.tsx index 1d576711..1a178991 100644 --- a/src/components/OrganizationsList/OrganizationsList.tsx +++ b/src/components/OrganizationsList/OrganizationsList.tsx @@ -40,8 +40,8 @@ export function OrganizationsList({ className="tablet-lg:grid-col-4 margin-bottom-3" key={`org-${index}`} > -
-

+
+

Date: Wed, 9 Oct 2024 12:14:57 -0500 Subject: [PATCH 07/23] pull org roles for current user for all orgs page --- .env.example.local | 5 +- .env.test | 1 + __tests__/api/cf/token.test.js | 51 ++++++++++++++++++- .../controllers/controller-helpers.test.js | 33 +++++++++++- __tests__/controllers/controllers.test.js | 9 ++++ __tests__/middleware.test.js | 13 ++++- src/api/cf/cloudfoundry-helpers.ts | 6 ++- src/api/cf/token.ts | 19 +++++++ src/app/orgs/page.tsx | 1 + .../OrganizationsList/OrganizationsList.tsx | 28 ++++++++-- src/controllers/controller-helpers.ts | 29 ++++++++++- src/controllers/controllers.ts | 4 +- src/middleware.ts | 2 + 13 files changed, 190 insertions(+), 11 deletions(-) diff --git a/.env.example.local b/.env.example.local index 82ef19b9..6e84f5ac 100644 --- a/.env.example.local +++ b/.env.example.local @@ -19,10 +19,13 @@ UAA_LOGOUT_PATH=/logout.do # CF API # Used to connect to the Cloud Foundry API. CF_API_URL should always end # with /v3 regardless of the environment. -# The CF_API_TOKEN can be populated with `cf oauth-token` or by running +# Locally, use CF_USER_ID to get CAPI info related to the current logged in user. +# In a deployed environment, this user id comes from the auth token. +# Locally, the CF_API_TOKEN can be populated with `cf oauth-token` or by running # npm run dev-cf # CF_API_URL=https://api.dev.us-gov-west-1.aws-us-gov.cloud.gov/v3 +CF_USER_ID=your-cf-user-guid CF_API_TOKEN= # S3 diff --git a/.env.test b/.env.test index bebe1892..63692455 100644 --- a/.env.test +++ b/.env.test @@ -11,5 +11,6 @@ UAA_LOGOUT_PATH=/logout.do CF_API_URL=https://example.com CF_API_TOKEN=placeholder +CF_USER_ID=placeholder NEXT_PUBLIC_USER_INVITE_URL=https://account.dev.us-gov-west-1.aws-us-gov.cloud.gov/invite diff --git a/__tests__/api/cf/token.test.js b/__tests__/api/cf/token.test.js index 50af7eb7..c365688e 100644 --- a/__tests__/api/cf/token.test.js +++ b/__tests__/api/cf/token.test.js @@ -1,6 +1,6 @@ import { cookies } from 'next/headers'; import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; -import { getToken, isLoggedIn } from '@/api/cf/token'; +import { getToken, isLoggedIn, getUserId } from '@/api/cf/token'; /* global jest */ /* eslint no-undef: "off" */ @@ -64,3 +64,52 @@ describe('cloudfoundry token tests', () => { }); }); }); + +describe('cloudfoundry user id tests', () => { + describe('when CF_USER_ID environment variable is set', () => { + beforeEach(() => { + process.env.CF_USER_ID = 'foo-user-id'; + }); + afterEach(() => { + delete process.env.CF_USER_ID; + }); + it('getUserId() returns a manual token', () => { + expect(getUserId()).toBe('foo-user-id'); + }); + }); + + describe('when CF_USER_ID environment variable is not set', () => { + describe('when auth cookie is set', () => { + beforeEach(() => { + cookies.mockImplementation(() => ({ + get: () => ({ value: '{"user_id":"foo-user-id"}' }), + })); + }); + it('getUserId() returns a token from a cookie', () => { + expect(getUserId()).toBe('foo-user-id'); + }); + }); + describe('when auth cookie is not set', () => { + beforeEach(() => { + cookies.mockImplementation(() => ({ + get: () => undefined, + })); + }); + it('getToken() throws an error when no cookie is set', () => { + expect(() => getUserId()).toThrow('please confirm you are logged in'); + }); + }); + describe('when auth cookie is not in an expected format', () => { + beforeEach(() => { + cookies.mockImplementation(() => ({ + get: () => 'unexpected format', + })); + }); + it('getToken() throws an error', () => { + expect(() => getUserId()).toThrow( + 'unable to parse authsession user_id' + ); + }); + }); + }); +}); diff --git a/__tests__/controllers/controller-helpers.test.js b/__tests__/controllers/controller-helpers.test.js index 93af914d..78c9ee8e 100644 --- a/__tests__/controllers/controller-helpers.test.js +++ b/__tests__/controllers/controller-helpers.test.js @@ -8,14 +8,29 @@ import { memoryUsagePerOrg, countSpacesPerOrg, countAppsPerOrg, + getOrgRolesForCurrentUser, } from '@/controllers/controller-helpers'; -import { mockUsersByOrganization, mockUsersBySpace } from '../api/mocks/roles'; +import { + mockUsersByOrganization, + mockUsersBySpace, + mockRolesFilteredByOrgAndUser, +} from '../api/mocks/roles'; import { mockS3Object } from '../api/mocks/lastlogon-summary'; import nock from 'nock'; import mockUsers from '../api/mocks/users'; import { mockOrgQuotas } from '../api/mocks/orgQuotas'; import { mockSpaces } from '../api/mocks/spaces'; import { mockApps } from '../api/mocks/apps'; +// eslint-disable-next-line no-unused-vars +import { getUserId } from '@/api/cf/token'; + +/* global jest */ +/* eslint no-undef: "off" */ +jest.mock('@/api/cf/token', () => ({ + ...jest.requireActual('../../src/api/cf/token'), + getUserId: jest.fn(() => '46ff1fd5-4238-4e22-a00a-1bec4fc0f9da'), // same user guid as in mockRolesFilteredByOrgAndUser +})); +/* eslint no-undef: "error" */ beforeEach(() => { if (!nock.isActive()) { @@ -307,4 +322,20 @@ describe('controller-helpers', () => { expect(result['orgId2']).toEqual(2); }); }); + + describe('getOrgRolesForCurrentUser', () => { + it('returns an object keyed by org id with value of array of role types', async () => { + // setup + const orgGuids = ['e8e31994-0dba-41e3-96ea-39942f1b30a4']; + nock(process.env.CF_API_URL) + .get(/roles/) + .reply(200, mockRolesFilteredByOrgAndUser); + // act + const result = await getOrgRolesForCurrentUser(orgGuids); + // assert + expect(result['e8e31994-0dba-41e3-96ea-39942f1b30a4']).toEqual([ + 'organization_manager', + ]); + }); + }); }); diff --git a/__tests__/controllers/controllers.test.js b/__tests__/controllers/controllers.test.js index a35a649f..37e8ba80 100644 --- a/__tests__/controllers/controllers.test.js +++ b/__tests__/controllers/controllers.test.js @@ -28,6 +28,10 @@ jest.mock('@/controllers/controller-helpers', () => ({ memoryUsagePerOrg: () => ({ orgId1: 4, orgId2: 5 }), countSpacesPerOrg: () => ({ orgId1: 6, orgId2: 7 }), countAppsPerOrg: () => ({ orgId1: 8, orgId2: 9 }), + getOrgRolesForCurrentUser: () => ({ + orgId1: ['organization_manager'], + orgId2: ['organization_billing_manager'], + }), })); jest.mock('@/api/aws/s3', () => ({ getUserLogonInfo: jest.fn(), @@ -568,6 +572,11 @@ describe('controllers tests', () => { // apps expect(result.payload.appCounts['orgId1']).toEqual(8); expect(result.payload.appCounts['orgId2']).toEqual(9); + // roles + expect(result.payload.roles['orgId1']).toEqual(['organization_manager']); + expect(result.payload.roles['orgId2']).toEqual([ + 'organization_billing_manager', + ]); }); }); }); diff --git a/__tests__/middleware.test.js b/__tests__/middleware.test.js index 83886698..d53c18d3 100644 --- a/__tests__/middleware.test.js +++ b/__tests__/middleware.test.js @@ -8,7 +8,16 @@ import { middleware } from '@/middleware.ts'; import { postToAuthTokenUrl } from '@/api/auth'; const mockEmailAddress = 'foo@example.com'; -const mockAccessToken = jwt.sign({ email: mockEmailAddress }, 'fooPrivateKey'); +const mockUserName = 'fooUserName'; +const mockUserId = 'fooUserId'; +const mockAccessToken = jwt.sign( + { + email: mockEmailAddress, + user_name: mockUserName, + user_id: mockUserId, + }, + 'fooPrivateKey' +); const mockRefreshToken = 'fooRefreshToken'; const mockExpiry = 43199; const mockAuthResponse = { @@ -84,6 +93,8 @@ describe('auth/login/callback', () => { response.cookies.get('authsession')['value'] ); expect(authCookieObj.email).toMatch(mockEmailAddress); + expect(authCookieObj.user_id).toMatch(mockUserId); + expect(authCookieObj.user_name).toMatch(mockUserName); expect(authCookieObj.accessToken).toMatch(mockAccessToken); expect(authCookieObj.refreshToken).toMatch(mockRefreshToken); expect(authCookieObj.expiry).toBeDefined(); diff --git a/src/api/cf/cloudfoundry-helpers.ts b/src/api/cf/cloudfoundry-helpers.ts index fff65674..775fb24f 100644 --- a/src/api/cf/cloudfoundry-helpers.ts +++ b/src/api/cf/cloudfoundry-helpers.ts @@ -4,7 +4,7 @@ import { redirect } from 'next/navigation'; import { logInPath } from '@/helpers/authentication'; import { camelToSnakeCase } from '@/helpers/text'; import { request } from '../api'; -import { getToken } from './token'; +import { getToken, getUserId } from './token'; type MethodType = 'delete' | 'get' | 'patch' | 'post'; @@ -75,3 +75,7 @@ export async function prepPathParams(options: { const urlParams = new URLSearchParams(params); return `?${urlParams.toString()}`; } + +export async function getCurrentUserId() { + return getUserId(); +} diff --git a/src/api/cf/token.ts b/src/api/cf/token.ts index 7e75103a..ec80c7eb 100644 --- a/src/api/cf/token.ts +++ b/src/api/cf/token.ts @@ -28,3 +28,22 @@ export function isLoggedIn(): boolean { return false; } } + +export function getUserId() { + return getLocalUserId() || getCFUserId(); +} + +export function getLocalUserId() { + return process.env.CF_USER_ID; +} + +export function getCFUserId() { + const authSession = cookies().get('authsession'); + if (authSession === undefined) + throw new Error('please confirm you are logged in'); + try { + return JSON.parse(authSession.value).user_id; + } catch (error: any) { + throw new Error('unable to parse authsession user_id'); + } +} diff --git a/src/app/orgs/page.tsx b/src/app/orgs/page.tsx index 6736551e..9389a27f 100644 --- a/src/app/orgs/page.tsx +++ b/src/app/orgs/page.tsx @@ -22,6 +22,7 @@ export default async function OrgsPage() { memoryAllocated={payload.memoryAllocated} memoryCurrentUsage={payload.memoryCurrentUsage} spaceCounts={payload.spaceCounts} + roles={payload.roles} />

); diff --git a/src/components/OrganizationsList/OrganizationsList.tsx b/src/components/OrganizationsList/OrganizationsList.tsx index 1a178991..ffe102c0 100644 --- a/src/components/OrganizationsList/OrganizationsList.tsx +++ b/src/components/OrganizationsList/OrganizationsList.tsx @@ -1,8 +1,10 @@ +import React from 'react'; import { OrgObj } from '@/api/cf/cloudfoundry-types'; import { chunkArray, sortObjectsByParam } from '@/helpers/arrays'; import Link from 'next/link'; import { MemoryBar } from '@/components/MemoryBar'; import { formatInt } from '@/helpers/numbers'; +import { formatOrgRoleName } from '@/helpers/text'; export function OrganizationsList({ orgs, @@ -11,6 +13,7 @@ export function OrganizationsList({ memoryAllocated, memoryCurrentUsage, spaceCounts, + roles, }: { orgs: Array; userCounts: { [orgGuid: string]: number }; @@ -18,6 +21,7 @@ export function OrganizationsList({ memoryAllocated: { [orgGuid: string]: number }; memoryCurrentUsage: { [orgGuid: string]: number }; spaceCounts: { [orgGuid: string]: number }; + roles: { [orgGuid: string]: Array }; }) { if (!orgs.length) { return <>no orgs found; @@ -26,6 +30,25 @@ export function OrganizationsList({ const orgsSorted = sortObjectsByParam(orgs, 'name'); const orgsGrouped = chunkArray(orgsSorted, 3); + const getOrgRolesText = (orgGuid: string): React.ReactNode => { + const orgRoles = roles[orgGuid]; + if (!orgRoles || !orgRoles.length) { + return ( + <> + You're a User in this organization. + + ); + } + const formattedRoles = orgRoles + .map((r) => ( + + {formatOrgRoleName(r).replace('org ', '')} + + )) + .reduce((prev, cur) => [prev, ' and ', cur]); + return <>You're a {formattedRoles} in this organization.; + }; + return (
{orgsGrouped.map((orgGoup, groupIndex) => { @@ -51,9 +74,8 @@ export function OrganizationsList({

-
- TODO: You’re a Manager and a{' '} - Billing Manager in this organization. +
+ {getOrgRolesText(org.guid)}

diff --git a/src/controllers/controller-helpers.ts b/src/controllers/controller-helpers.ts index 11438b7e..a4a95ba2 100644 --- a/src/controllers/controller-helpers.ts +++ b/src/controllers/controller-helpers.ts @@ -2,7 +2,10 @@ import * as CF from '@/api/cf/cloudfoundry'; import { OrgQuotaObject, RolesByUser, SpaceRoleMap } from './controller-types'; import { RoleObj, UserObj, SpaceObj } from '@/api/cf/cloudfoundry-types'; import { UserLogonInfoById } from '@/api/aws/s3-types'; -import { cfRequestOptions } from '@/api/cf/cloudfoundry-helpers'; +import { + cfRequestOptions, + getCurrentUserId, +} from '@/api/cf/cloudfoundry-helpers'; import { request } from '@/api/api'; import { delay } from '@/helpers/timeout'; @@ -264,3 +267,27 @@ export async function countAppsPerOrg( return acc; }, {}); } + +export async function getOrgRolesForCurrentUser(orgGuids: Array) { + let roles: { [orgId: string]: Array } = {}; + const userId = await getCurrentUserId(); + if (userId) { + const rolesRes = await CF.getRoles({ + userGuids: [userId], + organizationGuids: orgGuids, + }); + const rolesJson = (await rolesRes.json()).resources; + roles = rolesJson.reduce( + (acc: { [orgId: string]: Array }, curRole: RoleObj) => { + if (curRole.type === 'organization_user') return acc; + const orgId = curRole.relationships.organization.data.guid; + if (!orgId) return acc; + if (!acc[orgId]) acc[orgId] = []; + acc[orgId].push(curRole.type); + return acc; + }, + {} + ); + } + return roles; +} diff --git a/src/controllers/controllers.ts b/src/controllers/controllers.ts index 17ec08be..5a1a1db4 100644 --- a/src/controllers/controllers.ts +++ b/src/controllers/controllers.ts @@ -26,6 +26,7 @@ import { memoryUsagePerOrg, countSpacesPerOrg, countAppsPerOrg, + getOrgRolesForCurrentUser, } from './controller-helpers'; import { sortObjectsByParam } from '@/helpers/arrays'; import { daysToExpiration } from '@/helpers/dates'; @@ -90,8 +91,7 @@ export async function getOrgsPage(): Promise { const memoryCurrentUsage = await memoryUsagePerOrg(orgGuids); const spaceCounts = await countSpacesPerOrg(orgGuids); const appCounts = await countAppsPerOrg(orgGuids); - // get user's roles for each org - const roles = {}; + const roles = await getOrgRolesForCurrentUser(orgGuids); return { payload: { diff --git a/src/middleware.ts b/src/middleware.ts index 413cf668..390c9bac 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -57,6 +57,8 @@ export function setAuthCookie( 'authsession', JSON.stringify({ accessToken: data.access_token, + user_id: decodedToken.user_id, + user_name: decodedToken.user_name, email: decodedToken.email, refreshToken: data.refresh_token, expiry: Date.now() + data.expires_in * 1000, From ee8187cb66fc9daeb1cc34a618ddb94e2e5458d6 Mon Sep 17 00:00:00 2001 From: Eleni Chappen Date: Wed, 9 Oct 2024 13:59:40 -0500 Subject: [PATCH 08/23] use package for converting MB to more human-friendly values --- __tests__/helpers/numbers.test.js | 30 +++++++++++++++++++++++++++++- package-lock.json | 18 ++++++++++++++++++ package.json | 2 ++ src/components/MemoryBar.tsx | 7 +++---- src/helpers/numbers.ts | 22 ++++++++++++++++++++++ 5 files changed, 74 insertions(+), 5 deletions(-) diff --git a/__tests__/helpers/numbers.test.js b/__tests__/helpers/numbers.test.js index 0584f2fe..51f5b0ae 100644 --- a/__tests__/helpers/numbers.test.js +++ b/__tests__/helpers/numbers.test.js @@ -1,5 +1,10 @@ import { describe, expect, it } from '@jest/globals'; -import { formatInt } from '@/helpers/numbers'; +import { + formatInt, + convertBytes, + megaBytesToBytes, + formatMb, +} from '@/helpers/numbers'; describe('formatInt', () => { it('returns small numbers as they are', () => { @@ -11,3 +16,26 @@ describe('formatInt', () => { expect(result).toEqual('146,738'); }); }); + +describe('convertBytes', () => { + describe('when no options passed', () => { + it('returns value using default options', () => { + const result = convertBytes(10240000000); // 10240 MB in bytes + expect(result.value).toEqual('10.2'); + expect(result.unit).toEqual('GB'); + }); + }); +}); + +describe('megaBytesToBytes', () => { + it('converts megabytes to bytes', () => { + expect(megaBytesToBytes(1)).toEqual(1000000); + expect(megaBytesToBytes(5)).toEqual(5000000); + }); +}); + +describe('formatMb', () => { + it('returns a human readable string', () => { + expect(formatMb(10240)).toEqual('10.2GB'); + }); +}); diff --git a/package-lock.json b/package-lock.json index d79764b1..9292a52a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "@aws-sdk/client-s3": "^3.667.0", "@aws-sdk/credential-providers": "^3.654.0", "@uswds/uswds": "3.8.2", + "byte-size": "9.0.0", "classnames": "^2.5.1", "jose": "^5.9.3", "next": "~14.2.11", @@ -19,6 +20,7 @@ "@testing-library/jest-dom": "^6.5.0", "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.5.2", + "@types/byte-size": "^8.1.2", "aws-sdk-client-mock": "^4.0.2", "eslint-config-next": "14.2.15", "eslint-config-prettier": "^9.1.0", @@ -4664,6 +4666,13 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/byte-size": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@types/byte-size/-/byte-size-8.1.2.tgz", + "integrity": "sha512-jGyVzYu6avI8yuqQCNTZd65tzI8HZrLjKX9sdMqZrGWVlNChu0rf6p368oVEDCYJe5BMx2Ov04tD1wqtgTwGSA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -5652,6 +5661,15 @@ "node": ">=10.16.0" } }, + "node_modules/byte-size": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/byte-size/-/byte-size-9.0.0.tgz", + "integrity": "sha512-xrJ8Hki7eQ6xew55mM6TG9zHI852OoAHcPfduWWtR6yxk2upTuIZy13VioRBDyHReHDdbeDPifUboeNkK/sXXA==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", diff --git a/package.json b/package.json index de84972b..c434aa0b 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@aws-sdk/client-s3": "^3.667.0", "@aws-sdk/credential-providers": "^3.654.0", "@uswds/uswds": "3.8.2", + "byte-size": "9.0.0", "classnames": "^2.5.1", "jose": "^5.9.3", "next": "~14.2.11", @@ -31,6 +32,7 @@ "@testing-library/jest-dom": "^6.5.0", "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.5.2", + "@types/byte-size": "^8.1.2", "aws-sdk-client-mock": "^4.0.2", "eslint-config-next": "14.2.15", "eslint-config-prettier": "^9.1.0", diff --git a/src/components/MemoryBar.tsx b/src/components/MemoryBar.tsx index 24f6aa9f..570d6380 100644 --- a/src/components/MemoryBar.tsx +++ b/src/components/MemoryBar.tsx @@ -1,5 +1,5 @@ import { ProgressBar } from '@/components/ProgressBar'; -import { formatInt } from '@/helpers/numbers'; +import { formatMb } from '@/helpers/numbers'; export function MemoryBar({ memoryUsed, @@ -21,11 +21,10 @@ export function MemoryBar({

- {formatInt(memoryUsedNum)}MB of {formatInt(memoryAllocated)}MB - allocated + {formatMb(memoryUsedNum)} of {formatMb(memoryAllocated)} allocated
-
{formatInt(mbRemaining)}MB remaining
+
{formatMb(mbRemaining)} remaining
); diff --git a/src/helpers/numbers.ts b/src/helpers/numbers.ts index 72f2168c..97d6beae 100644 --- a/src/helpers/numbers.ts +++ b/src/helpers/numbers.ts @@ -1,3 +1,25 @@ +import byteSize from 'byte-size'; + export function formatInt(int: number): string { return int?.toLocaleString('en-US'); } + +// Convert a number in bytes to human-readable entities +// See options at https://github.com/75lb/byte-size?tab=readme-ov-file#exp_module_byte-size--byteSize +export function convertBytes( + bytes: number, + options: object | undefined = undefined +): { long: string; unit: string; value: string } { + if (options) return byteSize(bytes, options); + return byteSize(bytes); +} + +export function megaBytesToBytes(mb: number): number { + return mb * 1000000; +} + +// return a human-readable string given number in megabytes +export function formatMb(mb: number): string { + const result = convertBytes(megaBytesToBytes(mb)); + return `${result.value}${result.unit}`; +} From 794b09683943c2108903039fd027d7bae8f5d268 Mon Sep 17 00:00:00 2001 From: Eleni Chappen Date: Wed, 9 Oct 2024 14:30:44 -0500 Subject: [PATCH 09/23] refine tablet-ish breakpoints --- src/components/MemoryBar.tsx | 6 ++++-- src/components/OrganizationsList/OrganizationsList.tsx | 10 +++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/components/MemoryBar.tsx b/src/components/MemoryBar.tsx index 570d6380..e02219ee 100644 --- a/src/components/MemoryBar.tsx +++ b/src/components/MemoryBar.tsx @@ -20,11 +20,13 @@ export function MemoryBar({
-
+
{formatMb(memoryUsedNum)} of {formatMb(memoryAllocated)} allocated
-
{formatMb(mbRemaining)} remaining
+
+ {formatMb(mbRemaining)} remaining +
); diff --git a/src/components/OrganizationsList/OrganizationsList.tsx b/src/components/OrganizationsList/OrganizationsList.tsx index ffe102c0..3601c752 100644 --- a/src/components/OrganizationsList/OrganizationsList.tsx +++ b/src/components/OrganizationsList/OrganizationsList.tsx @@ -60,7 +60,7 @@ export function OrganizationsList({ {orgGoup.map((org, index) => { return (
  • @@ -79,22 +79,22 @@ export function OrganizationsList({

    - at a glance: + at a glance:

    • - {formatInt(userCounts[org.guid])} users + {formatInt(userCounts[org.guid])} users
    • - {formatInt(spaceCounts[org.guid])} spaces + {formatInt(spaceCounts[org.guid])} spaces
    • - {formatInt(appCounts[org.guid])} applications + {formatInt(appCounts[org.guid])} applications
    From ebbdcf2ea17aaccf9f69816df8ab5cfac362e415 Mon Sep 17 00:00:00 2001 From: Eleni Chappen Date: Wed, 9 Oct 2024 14:47:25 -0500 Subject: [PATCH 10/23] add real hrefs for "at a glance" links --- .../OrganizationsList/OrganizationsList.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/OrganizationsList/OrganizationsList.tsx b/src/components/OrganizationsList/OrganizationsList.tsx index 3601c752..131bc4f8 100644 --- a/src/components/OrganizationsList/OrganizationsList.tsx +++ b/src/components/OrganizationsList/OrganizationsList.tsx @@ -83,17 +83,26 @@ export function OrganizationsList({

    • - + {formatInt(userCounts[org.guid])} users
    • - + {formatInt(spaceCounts[org.guid])} spaces
    • - + {formatInt(appCounts[org.guid])} applications
    • From 4c696f60e718148bc274439b6db285de89baa6f3 Mon Sep 17 00:00:00 2001 From: Eleni Chappen Date: Wed, 9 Oct 2024 15:50:31 -0500 Subject: [PATCH 11/23] refactor org components --- src/components/Card/Card.tsx | 11 ++ src/components/Card/CardRow.tsx | 5 + .../OrganizationsList/OrganizationsList.tsx | 101 +++--------------- .../OrganizationsListCard.tsx | 86 +++++++++++++++ 4 files changed, 117 insertions(+), 86 deletions(-) create mode 100644 src/components/Card/Card.tsx create mode 100644 src/components/Card/CardRow.tsx create mode 100644 src/components/OrganizationsList/OrganizationsListCard.tsx diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx new file mode 100644 index 00000000..d55968f5 --- /dev/null +++ b/src/components/Card/Card.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +export function Card({ children }: { children: React.ReactNode }) { + return ( +
    • +
      + {children} +
      +
    • + ); +} diff --git a/src/components/Card/CardRow.tsx b/src/components/Card/CardRow.tsx new file mode 100644 index 00000000..c44861f5 --- /dev/null +++ b/src/components/Card/CardRow.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export function CardRow({ children }: { children: React.ReactNode }) { + return
        {children}
      ; +} diff --git a/src/components/OrganizationsList/OrganizationsList.tsx b/src/components/OrganizationsList/OrganizationsList.tsx index 131bc4f8..dd943972 100644 --- a/src/components/OrganizationsList/OrganizationsList.tsx +++ b/src/components/OrganizationsList/OrganizationsList.tsx @@ -1,10 +1,8 @@ import React from 'react'; import { OrgObj } from '@/api/cf/cloudfoundry-types'; import { chunkArray, sortObjectsByParam } from '@/helpers/arrays'; -import Link from 'next/link'; -import { MemoryBar } from '@/components/MemoryBar'; -import { formatInt } from '@/helpers/numbers'; -import { formatOrgRoleName } from '@/helpers/text'; +import { CardRow } from '@/components/Card/CardRow'; +import { OrganizationsListCard } from './OrganizationsListCard'; export function OrganizationsList({ orgs, @@ -30,95 +28,26 @@ export function OrganizationsList({ const orgsSorted = sortObjectsByParam(orgs, 'name'); const orgsGrouped = chunkArray(orgsSorted, 3); - const getOrgRolesText = (orgGuid: string): React.ReactNode => { - const orgRoles = roles[orgGuid]; - if (!orgRoles || !orgRoles.length) { - return ( - <> - You're a User in this organization. - - ); - } - const formattedRoles = orgRoles - .map((r) => ( - - {formatOrgRoleName(r).replace('org ', '')} - - )) - .reduce((prev, cur) => [prev, ' and ', cur]); - return <>You're a {formattedRoles} in this organization.; - }; - return (
      {orgsGrouped.map((orgGoup, groupIndex) => { return ( -
        - {orgGoup.map((org, index) => { + + {orgGoup.map((org) => { return ( -
      • -
        -

        - - {org.name} - -

        - -
        -
        - {getOrgRolesText(org.guid)} -
        -
        -

        - at a glance: -

        -
          -
        • - - {formatInt(userCounts[org.guid])} users - -
        • -
        • - - {formatInt(spaceCounts[org.guid])} spaces - -
        • -
        • - - {formatInt(appCounts[org.guid])} applications - -
        • -
        -
        -
        - - -
        -
      • + ); })} -
      + ); })}
      diff --git a/src/components/OrganizationsList/OrganizationsListCard.tsx b/src/components/OrganizationsList/OrganizationsListCard.tsx new file mode 100644 index 00000000..ca276230 --- /dev/null +++ b/src/components/OrganizationsList/OrganizationsListCard.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import Link from 'next/link'; +import { OrgObj } from '@/api/cf/cloudfoundry-types'; +import { Card } from '@/components/Card/Card'; +import { formatInt } from '@/helpers/numbers'; +import { MemoryBar } from '@/components/MemoryBar'; +import { formatOrgRoleName } from '@/helpers/text'; + +export function OrganizationsListCard({ + org, + userCount, + appCount, + memoryAllocated, + memoryCurrentUsage, + spaceCount, + roles, +}: { + org: OrgObj; + userCount: number; + appCount: number; + memoryAllocated: number; + memoryCurrentUsage: number; + spaceCount: number; + roles: Array; +}) { + const getOrgRolesText = (orgGuid: string): React.ReactNode => { + if (!roles || !roles.length) { + return ( + <> + You're a User in this organization. + + ); + } + const formattedRoles = roles + .map((r) => ( + + {formatOrgRoleName(r).replace('org ', '')} + + )) + .reduce((prev, cur) => [prev, ' and ', cur]); + return <>You're a {formattedRoles} in this organization.; + }; + + return ( + +

      + + {org.name} + +

      + +
      +
      + {getOrgRolesText(org.guid)} +
      +
      +

      + at a glance: +

      +
        +
      • + + {formatInt(userCount)} users + +
      • +
      • + + {formatInt(spaceCount)} spaces + +
      • +
      • + + {formatInt(appCount)} applications + +
      • +
      +
      +
      + + +
      + ); +} From 930d917cb76e82280928d46ab1fd4d961f09dfee Mon Sep 17 00:00:00 2001 From: Eleni Chappen Date: Thu, 10 Oct 2024 09:15:08 -0500 Subject: [PATCH 12/23] add some spacing between roles and at a glance sections --- src/components/OrganizationsList/OrganizationsListCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/OrganizationsList/OrganizationsListCard.tsx b/src/components/OrganizationsList/OrganizationsListCard.tsx index ca276230..dbbc9792 100644 --- a/src/components/OrganizationsList/OrganizationsListCard.tsx +++ b/src/components/OrganizationsList/OrganizationsListCard.tsx @@ -50,7 +50,7 @@ export function OrganizationsListCard({
      -
      +
      {getOrgRolesText(org.guid)}
      From 6fe6dec0b51fb714c5008cb8c78df8846078d80c Mon Sep 17 00:00:00 2001 From: Eleni Chappen Date: Thu, 10 Oct 2024 10:17:09 -0500 Subject: [PATCH 13/23] handle indefinite articles for role names --- package-lock.json | 18 ++++++++++++++++++ package.json | 2 ++ .../OrganizationsListCard.tsx | 19 +++++++++++++------ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9292a52a..542315e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "@uswds/uswds": "3.8.2", "byte-size": "9.0.0", "classnames": "^2.5.1", + "indefinite": "^2.5.1", "jose": "^5.9.3", "next": "~14.2.11", "pg": "^8.13.0", @@ -21,6 +22,7 @@ "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.5.2", "@types/byte-size": "^8.1.2", + "@types/indefinite": "^2.3.4", "aws-sdk-client-mock": "^4.0.2", "eslint-config-next": "14.2.15", "eslint-config-prettier": "^9.1.0", @@ -4682,6 +4684,13 @@ "@types/node": "*" } }, + "node_modules/@types/indefinite": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@types/indefinite/-/indefinite-2.3.4.tgz", + "integrity": "sha512-X9sp9nbqkZT7hFNUKxicGr6gZLahs95lryW2Vvx5+krDxhWWle4K1OfJ4b8Sjd+FLul3tGhfYehtfeoFF1Ot4g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -7880,6 +7889,15 @@ "node": ">=0.8.19" } }, + "node_modules/indefinite": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/indefinite/-/indefinite-2.5.1.tgz", + "integrity": "sha512-Ul0hCdnSjuFDEloYWeozTaEfljbz+0q+u4HsHns2dOk2DlhGlbRMGFtNcIL+Ve7sZYeIOTOAKA0usAXBGHpNDg==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", diff --git a/package.json b/package.json index c434aa0b..07c9ce65 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@uswds/uswds": "3.8.2", "byte-size": "9.0.0", "classnames": "^2.5.1", + "indefinite": "^2.5.1", "jose": "^5.9.3", "next": "~14.2.11", "pg": "^8.13.0", @@ -33,6 +34,7 @@ "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.5.2", "@types/byte-size": "^8.1.2", + "@types/indefinite": "^2.3.4", "aws-sdk-client-mock": "^4.0.2", "eslint-config-next": "14.2.15", "eslint-config-prettier": "^9.1.0", diff --git a/src/components/OrganizationsList/OrganizationsListCard.tsx b/src/components/OrganizationsList/OrganizationsListCard.tsx index dbbc9792..6c2cc927 100644 --- a/src/components/OrganizationsList/OrganizationsListCard.tsx +++ b/src/components/OrganizationsList/OrganizationsListCard.tsx @@ -1,5 +1,6 @@ import React from 'react'; import Link from 'next/link'; +import a from 'indefinite'; import { OrgObj } from '@/api/cf/cloudfoundry-types'; import { Card } from '@/components/Card/Card'; import { formatInt } from '@/helpers/numbers'; @@ -32,13 +33,19 @@ export function OrganizationsListCard({ ); } const formattedRoles = roles - .map((r) => ( - - {formatOrgRoleName(r).replace('org ', '')} - - )) + .map((r) => { + const word = formatOrgRoleName(r).replace('org ', ''); + return ( + <> + {a(word, { articleOnly: true })}{' '} + + {word} + + + ); + }) .reduce((prev, cur) => [prev, ' and ', cur]); - return <>You're a {formattedRoles} in this organization.; + return <>You're {formattedRoles} in this organization.; }; return ( From 44df6cc1e2db8d46fab866a655447409017b0390 Mon Sep 17 00:00:00 2001 From: Eleni Chappen Date: Thu, 10 Oct 2024 10:22:36 -0500 Subject: [PATCH 14/23] remove hyperlink for spaces --- src/components/OrganizationsList/OrganizationsListCard.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/components/OrganizationsList/OrganizationsListCard.tsx b/src/components/OrganizationsList/OrganizationsListCard.tsx index 6c2cc927..54b620be 100644 --- a/src/components/OrganizationsList/OrganizationsListCard.tsx +++ b/src/components/OrganizationsList/OrganizationsListCard.tsx @@ -70,11 +70,7 @@ export function OrganizationsListCard({ {formatInt(userCount)} users -
    • - - {formatInt(spaceCount)} spaces - -
    • +
    • {formatInt(spaceCount)} spaces
    • {formatInt(appCount)} applications From 342987889462d8b6d47f0574bea828010a6947cc Mon Sep 17 00:00:00 2001 From: Eleni Chappen Date: Thu, 10 Oct 2024 13:54:34 -0500 Subject: [PATCH 15/23] add timestamp to all orgs page --- src/app/orgs/page.tsx | 2 ++ .../OrganizationsList/OrganizationsListCard.tsx | 6 +++--- src/components/Timestamp.tsx | 13 +++++++++++++ src/controllers/controllers.ts | 1 + 4 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 src/components/Timestamp.tsx diff --git a/src/app/orgs/page.tsx b/src/app/orgs/page.tsx index 9389a27f..8fea2f46 100644 --- a/src/app/orgs/page.tsx +++ b/src/app/orgs/page.tsx @@ -4,6 +4,7 @@ import { getOrgsPage } from '@/controllers/controllers'; import { OrganizationsList } from '@/components/OrganizationsList/OrganizationsList'; import { PageHeader } from '@/components/PageHeader'; import { LastViewedOrgLink } from '@/components/LastViewedOrgLink'; +import { Timestamp } from '@/components/Timestamp'; export default async function OrgsPage() { const { payload } = await getOrgsPage(); @@ -24,6 +25,7 @@ export default async function OrgsPage() { spaceCounts={payload.spaceCounts} roles={payload.roles} /> +
    • ); } diff --git a/src/components/OrganizationsList/OrganizationsListCard.tsx b/src/components/OrganizationsList/OrganizationsListCard.tsx index 54b620be..5e49e836 100644 --- a/src/components/OrganizationsList/OrganizationsListCard.tsx +++ b/src/components/OrganizationsList/OrganizationsListCard.tsx @@ -67,13 +67,13 @@ export function OrganizationsListCard({
      • - {formatInt(userCount)} users + {formatInt(userCount || 0)} users
      • -
      • {formatInt(spaceCount)} spaces
      • +
      • {formatInt(spaceCount || 0)} spaces
      • - {formatInt(appCount)} applications + {formatInt(appCount || 0)} applications
      diff --git a/src/components/Timestamp.tsx b/src/components/Timestamp.tsx new file mode 100644 index 00000000..cc99bbdc --- /dev/null +++ b/src/components/Timestamp.tsx @@ -0,0 +1,13 @@ +export function Timestamp({ timestamp }: { timestamp: number }) { + const formatTime = (timestamp: number): string => { + return new Date(timestamp).toLocaleTimeString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + }; + return ( +
      Last updated: {formatTime(timestamp)}
      + ); +} diff --git a/src/controllers/controllers.ts b/src/controllers/controllers.ts index 5a1a1db4..718d9ec9 100644 --- a/src/controllers/controllers.ts +++ b/src/controllers/controllers.ts @@ -102,6 +102,7 @@ export async function getOrgsPage(): Promise { memoryCurrentUsage: memoryCurrentUsage, spaceCounts: spaceCounts, roles: roles, + lastUpdated: Date.now(), }, meta: { status: 'success' }, }; From 9afa2ad39d474abac17e12d6698c117f0bb3ce6c Mon Sep 17 00:00:00 2001 From: Eleni Chappen Date: Thu, 10 Oct 2024 14:36:26 -0500 Subject: [PATCH 16/23] Fix: remove key prop error message --- .../OrganizationsList/OrganizationsListCard.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/OrganizationsList/OrganizationsListCard.tsx b/src/components/OrganizationsList/OrganizationsListCard.tsx index 5e49e836..27aa0e38 100644 --- a/src/components/OrganizationsList/OrganizationsListCard.tsx +++ b/src/components/OrganizationsList/OrganizationsListCard.tsx @@ -36,12 +36,10 @@ export function OrganizationsListCard({ .map((r) => { const word = formatOrgRoleName(r).replace('org ', ''); return ( - <> + {a(word, { articleOnly: true })}{' '} - - {word} - - + {word} + ); }) .reduce((prev, cur) => [prev, ' and ', cur]); From 733ebd009d8101cadeccd82b68cc972e6ae577c5 Mon Sep 17 00:00:00 2001 From: Eleni Chappen Date: Thu, 10 Oct 2024 14:43:36 -0500 Subject: [PATCH 17/23] do not cache usage summary requests --- src/api/cf/cloudfoundry-helpers.ts | 8 +++++++- src/api/cf/cloudfoundry.ts | 4 +++- src/controllers/controllers.ts | 1 + 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/api/cf/cloudfoundry-helpers.ts b/src/api/cf/cloudfoundry-helpers.ts index 775fb24f..caa50159 100644 --- a/src/api/cf/cloudfoundry-helpers.ts +++ b/src/api/cf/cloudfoundry-helpers.ts @@ -15,6 +15,7 @@ interface ApiRequestOptions { 'Content-Type'?: string; }; body?: any; + cache?: string; } const CF_API_URL = process.env.CF_API_URL; @@ -45,10 +46,15 @@ export async function cfRequestOptions( Authorization: `bearer ${getToken()}`, }, }; - if (data) { + if (data && method.toLowerCase() !== 'get') { options.body = JSON.stringify(data); options.headers['Content-Type'] = 'application/json'; } + // cache is a Next.js option——it's not related to CF requests. + // https://nextjs.org/docs/app/api-reference/functions/fetch#optionscache + if (data?.cache) { + options.cache = data.cache; + } return options; } diff --git a/src/api/cf/cloudfoundry.ts b/src/api/cf/cloudfoundry.ts index c7d04699..88cbf0f7 100644 --- a/src/api/cf/cloudfoundry.ts +++ b/src/api/cf/cloudfoundry.ts @@ -44,7 +44,9 @@ export async function getOrgQuotas({ } export async function getOrgUsageSummary(guid: string): Promise { - return await cfRequest(`/organizations/${guid}/usage_summary`); + return await cfRequest(`/organizations/${guid}/usage_summary`, 'get', { + cache: 'no-store', + }); } export async function getOrgs(): Promise { diff --git a/src/controllers/controllers.ts b/src/controllers/controllers.ts index 718d9ec9..1b27b856 100644 --- a/src/controllers/controllers.ts +++ b/src/controllers/controllers.ts @@ -107,6 +107,7 @@ export async function getOrgsPage(): Promise { meta: { status: 'success' }, }; } catch (e: any) { + logDevError(e.message); throw new Error( 'There was a problem with the request. Please try again, and if the issue persists, please contact Cloud.gov support.' ); From 000fec6b064d8b612991398511a2b1a8c92150f1 Mon Sep 17 00:00:00 2001 From: Eleni Chappen Date: Thu, 10 Oct 2024 15:06:04 -0500 Subject: [PATCH 18/23] move timestamp to top of org list --- src/app/orgs/page.tsx | 3 ++- src/components/Timestamp.tsx | 6 +++++- src/controllers/controllers.ts | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/orgs/page.tsx b/src/app/orgs/page.tsx index 8fea2f46..2c25eb68 100644 --- a/src/app/orgs/page.tsx +++ b/src/app/orgs/page.tsx @@ -16,6 +16,8 @@ export default async function OrgsPage() { intro="These are all the organizations you can access. In each, you can view users and applications, and access usage information." /> +
      + -
      ); } diff --git a/src/components/Timestamp.tsx b/src/components/Timestamp.tsx index cc99bbdc..1da2ce20 100644 --- a/src/components/Timestamp.tsx +++ b/src/components/Timestamp.tsx @@ -1,13 +1,17 @@ export function Timestamp({ timestamp }: { timestamp: number }) { const formatTime = (timestamp: number): string => { return new Date(timestamp).toLocaleTimeString('en-US', { + year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', + timeZoneName: 'short', }); }; return ( -
      Last updated: {formatTime(timestamp)}
      +

      + Page last updated: {formatTime(timestamp)} +

      ); } diff --git a/src/controllers/controllers.ts b/src/controllers/controllers.ts index 1b27b856..70fa18f6 100644 --- a/src/controllers/controllers.ts +++ b/src/controllers/controllers.ts @@ -102,7 +102,7 @@ export async function getOrgsPage(): Promise { memoryCurrentUsage: memoryCurrentUsage, spaceCounts: spaceCounts, roles: roles, - lastUpdated: Date.now(), + lastUpdatedAt: Date.now(), }, meta: { status: 'success' }, }; From 7567bbfe41f421dc499128048a52ca145a2d5818 Mon Sep 17 00:00:00 2001 From: Eleni Chappen Date: Tue, 15 Oct 2024 15:48:16 -0500 Subject: [PATCH 19/23] add design for progress bar for when there's no memory limit --- __tests__/components/MemoryBar.test.js | 16 +++++++++++----- src/assets/stylesheets/styles.scss | 24 ++++++++++++++++++++++-- src/components/MemoryBar.tsx | 23 ++++++++++++++--------- src/components/ProgressBar.tsx | 18 +++++++++++++++--- src/components/svgs/InfinitySVG.tsx | 20 ++++++++++++++++++++ src/controllers/controller-helpers.ts | 2 +- src/controllers/controller-types.ts | 2 +- 7 files changed, 84 insertions(+), 21 deletions(-) create mode 100644 src/components/svgs/InfinitySVG.tsx diff --git a/__tests__/components/MemoryBar.test.js b/__tests__/components/MemoryBar.test.js index f1790bd1..b3ef02db 100644 --- a/__tests__/components/MemoryBar.test.js +++ b/__tests__/components/MemoryBar.test.js @@ -6,11 +6,17 @@ import { render, screen } from '@testing-library/react'; import { MemoryBar } from '@/components/MemoryBar'; describe('', () => { - describe('when no allocated memory', () => { - it('returns nothing', () => { - render(); - const component = screen.queryByTestId('memory-bar'); - expect(component).not.toBeInTheDocument(); + describe('when allocated memory is null', () => { + render(); + + it('says no upper limit', () => { + const text = screen.queryByText(/no upper limit/); + expect(text).toBeInTheDocument(); + }); + + it('hides memory remaining', () => { + const text = screen.queryByText(/remaining/); + expect(text).not.toBeInTheDocument(); }); }); diff --git a/src/assets/stylesheets/styles.scss b/src/assets/stylesheets/styles.scss index 9edede83..684bee00 100644 --- a/src/assets/stylesheets/styles.scss +++ b/src/assets/stylesheets/styles.scss @@ -119,7 +119,7 @@ ), $top-palettes: ( - 'palette-units-system-positive', + 'palette-units', ), $bottom-palettes: ( @@ -127,7 +127,7 @@ ), $right-palettes: ( - 'palette-units-system-positive', + 'palette-units', ), $theme-utility-breakpoints: ( @@ -425,3 +425,23 @@ $error-color-dark: 'red-40v'; } } } + +// ProgressBar + +.progress__bg--infinite { + background: linear-gradient(90deg, color('blue-cool-20v') 78.42%, color('blue-cool-30v') 100%); +} + +.progress__infinity-logo { + position: absolute; + top: -9px; + right: 0; + width: 28px; + height: 28px; + background: color('blue-cool-5v'); + border: 2px solid color('blue-cool-30v'); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/components/MemoryBar.tsx b/src/components/MemoryBar.tsx index e02219ee..8fcbdc7f 100644 --- a/src/components/MemoryBar.tsx +++ b/src/components/MemoryBar.tsx @@ -8,11 +8,8 @@ export function MemoryBar({ memoryUsed?: number | null | undefined; memoryAllocated?: number | null | undefined; }) { - if (!memoryAllocated) { - return null; - } const memoryUsedNum = memoryUsed || 0; - const mbRemaining = memoryAllocated - memoryUsedNum; + const mbRemaining = (memoryAllocated || 0) - memoryUsedNum; return (

      Memory:

      @@ -21,12 +18,20 @@ export function MemoryBar({
      - {formatMb(memoryUsedNum)} of {formatMb(memoryAllocated)} allocated -
      - -
      - {formatMb(mbRemaining)} remaining + {memoryAllocated && memoryAllocated > 0 && ( + <> + {formatMb(memoryUsedNum)} of {formatMb(memoryAllocated)} allocated + + )} + {memoryAllocated === null && ( + <>{formatMb(memoryUsedNum)} used; no upper limit + )}
      + {memoryAllocated && memoryAllocated > 0 && ( +
      + {formatMb(mbRemaining)} remaining +
      + )}
      ); diff --git a/src/components/ProgressBar.tsx b/src/components/ProgressBar.tsx index 4b4e1027..9c6f8aef 100644 --- a/src/components/ProgressBar.tsx +++ b/src/components/ProgressBar.tsx @@ -1,3 +1,5 @@ +import { InfinitySVG } from '@/components/svgs/InfinitySVG'; + export function ProgressBar({ total, // number representing total length of bar fill, // number representing how much bar should be filled @@ -5,14 +7,14 @@ export function ProgressBar({ threshold2 = 90, // percentage where color should change next, between 0 and 100 changeColors = true, }: { - total: number; + total: number | null | undefined; fill: number; threshold1?: number; threshold2?: number; changeColors?: boolean; }) { const heightClass = 'height-1'; - const percentage = Math.floor((fill / total) * 100); + const percentage = total ? Math.floor((fill / total) * 100) : 100; let color = 'bg-mint'; if (changeColors && percentage > threshold1 && percentage < threshold2) { color = 'bg-red-30v'; @@ -20,13 +22,23 @@ export function ProgressBar({ if (changeColors && percentage > threshold2) { color = 'bg-red-40v'; } + if (!total) { + color = 'progress__bg--infinite'; + } return ( -
      +
      + {!total && ( + + + + )}
      ); } diff --git a/src/components/svgs/InfinitySVG.tsx b/src/components/svgs/InfinitySVG.tsx new file mode 100644 index 00000000..f14e0ad0 --- /dev/null +++ b/src/components/svgs/InfinitySVG.tsx @@ -0,0 +1,20 @@ +export function InfinitySVG() { + return ( + + ); +} diff --git a/src/controllers/controller-helpers.ts b/src/controllers/controller-helpers.ts index a4a95ba2..12c59b7e 100644 --- a/src/controllers/controller-helpers.ts +++ b/src/controllers/controller-helpers.ts @@ -192,7 +192,7 @@ export async function allocatedMemoryPerOrg( if (orgQuotaRes.ok) { const orgQuotas = (await orgQuotaRes.json()).resources; memoryAllocated = orgQuotas.reduce( - (acc: { [orgId: string]: number }, curQuota: OrgQuotaObject) => { + (acc: { [orgId: string]: number | null }, curQuota: OrgQuotaObject) => { const relatedOrgs = curQuota.relationships.organizations.data.map( (o: any) => o.guid ); diff --git a/src/controllers/controller-types.ts b/src/controllers/controller-types.ts index f7420851..a53c34b1 100644 --- a/src/controllers/controller-types.ts +++ b/src/controllers/controller-types.ts @@ -94,7 +94,7 @@ export interface UserOrgPage extends UserObj { export interface OrgQuotaObject { apps: { - total_memory_in_mb: number; + total_memory_in_mb: number | null; }; relationships: { organizations: { From c23d77b237a53b0510118aa3bcc2defe193abc4b Mon Sep 17 00:00:00 2001 From: Eleni Chappen Date: Thu, 17 Oct 2024 11:57:33 -0500 Subject: [PATCH 20/23] add instructions for how to obtain your own CF user ID --- .env.example.local | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.env.example.local b/.env.example.local index 6e84f5ac..da9cb259 100644 --- a/.env.example.local +++ b/.env.example.local @@ -19,13 +19,13 @@ UAA_LOGOUT_PATH=/logout.do # CF API # Used to connect to the Cloud Foundry API. CF_API_URL should always end # with /v3 regardless of the environment. +CF_API_URL=https://api.dev.us-gov-west-1.aws-us-gov.cloud.gov/v3 +# In a deployed environment, the CF user id comes from the UAA token. # Locally, use CF_USER_ID to get CAPI info related to the current logged in user. -# In a deployed environment, this user id comes from the auth token. +# You can find your own CF_USER_ID by decoding your CF_API_TOKEN (using JWT). +CF_USER_ID=your-cf-user-guid # Locally, the CF_API_TOKEN can be populated with `cf oauth-token` or by running # npm run dev-cf -# -CF_API_URL=https://api.dev.us-gov-west-1.aws-us-gov.cloud.gov/v3 -CF_USER_ID=your-cf-user-guid CF_API_TOKEN= # S3 From 4661990481a7694a7da4b81c2516bfbca5e8fd10 Mon Sep 17 00:00:00 2001 From: Eleni Chappen Date: Thu, 17 Oct 2024 14:07:46 -0500 Subject: [PATCH 21/23] modify memory used/allocated language in memory bar --- src/components/MemoryBar.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/MemoryBar.tsx b/src/components/MemoryBar.tsx index 8fcbdc7f..c44605b0 100644 --- a/src/components/MemoryBar.tsx +++ b/src/components/MemoryBar.tsx @@ -20,7 +20,8 @@ export function MemoryBar({
      {memoryAllocated && memoryAllocated > 0 && ( <> - {formatMb(memoryUsedNum)} of {formatMb(memoryAllocated)} allocated + {formatMb(memoryUsedNum)} used of {formatMb(memoryAllocated)}{' '} + allocated )} {memoryAllocated === null && ( From a0333d032248214e855d4793be2e4de16c2b6e8c Mon Sep 17 00:00:00 2001 From: Eleni Chappen Date: Thu, 17 Oct 2024 14:12:05 -0500 Subject: [PATCH 22/23] make timestamp component more reusable moves contextual styling/text into parent component --- src/app/orgs/page.tsx | 4 +++- src/components/Timestamp.tsx | 6 +----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/app/orgs/page.tsx b/src/app/orgs/page.tsx index 2c25eb68..ee1167be 100644 --- a/src/app/orgs/page.tsx +++ b/src/app/orgs/page.tsx @@ -17,7 +17,9 @@ export default async function OrgsPage() { />
      - +

      + Page last updated: +

      - Page last updated: {formatTime(timestamp)} -

      - ); + return <>{formatTime(timestamp)}; } From 1ce56ecf2c35c59553323136f8fe5ef0bebae0e0 Mon Sep 17 00:00:00 2001 From: Eleni Chappen Date: Thu, 17 Oct 2024 14:19:38 -0500 Subject: [PATCH 23/23] remove bottom margin from At a Glance section of org card --- src/components/OrganizationsList/OrganizationsListCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/OrganizationsList/OrganizationsListCard.tsx b/src/components/OrganizationsList/OrganizationsListCard.tsx index 27aa0e38..a3e5c69a 100644 --- a/src/components/OrganizationsList/OrganizationsListCard.tsx +++ b/src/components/OrganizationsList/OrganizationsListCard.tsx @@ -59,7 +59,7 @@ export function OrganizationsListCard({ {getOrgRolesText(org.guid)}
      -

      +

      at a glance: