;
29 |
30 | export type FileT = {
31 | checksum: string;
32 | storage_address: string;
33 | };
34 |
35 | export type HasKeyT = {
36 | key: string;
37 | };
38 |
39 | export type APIListArgsT = {
40 | page?: number;
41 | ordering?: string;
42 | pageSize?: number;
43 | match?: string;
44 | } & { [param: string]: unknown };
45 |
46 | export type APIRetrieveListArgsT = APIListArgsT & {
47 | key: string;
48 | };
49 |
50 | export type AbortFunctionT = () => void;
51 |
--------------------------------------------------------------------------------
/ci/readme.md:
--------------------------------------------------------------------------------
1 | ## Build
2 |
3 | On the CI, they are built continuously by GitHub Actions workflow called [build.yaml](/.github/workflows/build.yaml), which gives them "dev" versions based on commit info. This workflow can be triggered by:
4 |
5 | - pushing a commit on the `main` branch
6 | - pushing a tag
7 | - [running it manually](https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow) via the github interface or a HTTP API call
8 |
9 | The registry is `ghcr.io/substra`
10 |
11 | The helm chart repository is `https://substra.github.io/charts/substra-frontend`.
12 |
13 | ## Release
14 |
15 | ### App (docker images)
16 |
17 | When a tag is pushed, it triggers the [release.yaml](/.github/workflows/release.yaml) workflow, which builds an images with the same tag.
18 |
19 | ### Helm
20 |
21 | Helm charts do not follow the regular release process. Any change to the `charts/` directory to the main branch will trigger a build and upload.
22 |
23 | ## PR validation (linting)
24 |
25 | See [validate-pr.yaml](/.github/workflows/validate-pr.yaml)
26 |
27 | ## End-to-end tests
28 |
29 | End-to-end tests are hosted here, but the CI that runs them is on [substra-tests](https://github.com/Substra/substra-tests)
30 |
--------------------------------------------------------------------------------
/src/routes/computePlans/components/CheckboxTd.tsx:
--------------------------------------------------------------------------------
1 | import { Box, TableCellProps, Td } from '@chakra-ui/react';
2 |
3 | type CheckboxTdProps = TableCellProps & {
4 | firstCol?: boolean;
5 | };
6 | const CheckboxTd = ({
7 | firstCol,
8 | children,
9 | ...props
10 | }: CheckboxTdProps): JSX.Element => (
11 |
19 | e.stopPropagation()}
33 | >
34 | {children}
35 |
36 |
37 | );
38 | export default CheckboxTd;
39 |
--------------------------------------------------------------------------------
/src/features/perfBrowser/PerfCard.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip, Flex, Text, Box } from '@chakra-ui/react';
2 |
3 | type PerformanceCardProps = {
4 | title: string;
5 | children: React.ReactNode;
6 | onClick: () => void;
7 | };
8 |
9 | const PerfCard = ({
10 | title,
11 | children,
12 | onClick,
13 | }: PerformanceCardProps): JSX.Element => {
14 | return (
15 |
29 | {children}
30 |
31 |
32 | {title}
33 |
34 |
35 |
36 | );
37 | };
38 |
39 | export default PerfCard;
40 |
--------------------------------------------------------------------------------
/src/components/layout/applayout/AppLayout.tsx:
--------------------------------------------------------------------------------
1 | import { Flex } from '@chakra-ui/react';
2 |
3 | import useAuthStore from '@/features/auth/useAuthStore';
4 | import Actualizer from '@/features/newsFeed/Actualizer';
5 |
6 | import RefreshBanner from '@/components/RefreshBanner';
7 | import Header from '@/components/layout/header/Header';
8 |
9 | type AppLayoutProps = {
10 | children: React.ReactNode;
11 | };
12 |
13 | const AppLayout = ({ children }: AppLayoutProps): JSX.Element => {
14 | const { authenticated: isAuthenticated } = useAuthStore();
15 |
16 | return (
17 |
24 | {isAuthenticated && }
25 | {isAuthenticated && }
26 | {isAuthenticated && }
27 |
33 | {children}
34 |
35 |
36 | );
37 | };
38 |
39 | export default AppLayout;
40 |
--------------------------------------------------------------------------------
/src/routes/tasks/components/TaskIOPermissions.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Icon } from '@chakra-ui/react';
2 | import { RiGroupLine } from 'react-icons/ri';
3 |
4 | import { PermissionsT } from '@/types/CommonTypes';
5 |
6 | import { TaskIOTooltip } from '../TasksUtils';
7 |
8 | const TaskIOPermissions = ({
9 | permissions,
10 | }: {
11 | permissions?: PermissionsT | null;
12 | }): JSX.Element => {
13 | let label = '';
14 |
15 | if (!permissions && permissions !== null) {
16 | label = 'No permissions available yet';
17 | } else if (permissions === null || permissions?.download.public) {
18 | label = 'Accessible by everyone';
19 | } else if (
20 | !permissions.download?.public &&
21 | permissions.download?.authorized_ids.length
22 | ) {
23 | label = `Accessible by ${permissions?.download.authorized_ids.join()}`;
24 | } else {
25 | label = 'Accessible by owner only';
26 | }
27 |
28 | return (
29 |
30 |
31 |
32 |
33 |
34 | );
35 | };
36 |
37 | export default TaskIOPermissions;
38 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react';
2 | import path from 'path';
3 | import { defineConfig } from 'vite';
4 | import { svgrComponent } from 'vite-plugin-svgr-component';
5 |
6 | import { version } from './package.json';
7 |
8 | const APP_VERSION = process.env['APP_VERSION'] || `${version}+dev`;
9 | const MICROSOFT_CLARITY_ID = process.env['MICROSOFT_CLARITY_ID'] || '';
10 | const API_URL =
11 | process.env['API_URL'] || 'http://substra-backend.org-1.com:8000';
12 |
13 | // https://vitejs.dev/config/
14 | export default defineConfig({
15 | define: {
16 | __APP_VERSION__: `'${APP_VERSION}'`,
17 | ...(process.env.NODE_ENV !== 'production'
18 | ? {
19 | API_URL: `'${API_URL}'`,
20 | MICROSOFT_CLARITY_ID: `'${MICROSOFT_CLARITY_ID}'`,
21 | }
22 | : {}),
23 | DEFAULT_PAGE_SIZE: '30',
24 | 'process.env': {},
25 | },
26 | plugins: [react({ jsxImportSource: '@emotion/react' }), svgrComponent()],
27 | resolve: {
28 | alias: {
29 | '@': path.resolve(__dirname, './src'),
30 | },
31 | },
32 | server: {
33 | port: 3000,
34 | host: 'substra-frontend.org-1.com',
35 | },
36 | });
37 |
--------------------------------------------------------------------------------
/src/api/DatasetsApi.ts:
--------------------------------------------------------------------------------
1 | import { AxiosPromise, AxiosRequestConfig } from 'axios';
2 |
3 | import API, { getApiOptions } from '@/api/request';
4 | import { API_PATHS, compilePath } from '@/paths';
5 | import { APIListArgsT, PaginatedApiResponseT } from '@/types/CommonTypes';
6 | import { DatasetT, DatasetStubT } from '@/types/DatasetTypes';
7 |
8 | export const listDatasets = (
9 | apiListArgs: APIListArgsT,
10 | config: AxiosRequestConfig
11 | ): AxiosPromise> => {
12 | return API.authenticatedGet(API_PATHS.DATASETS, {
13 | ...getApiOptions(apiListArgs),
14 | ...config,
15 | });
16 | };
17 |
18 | export const retrieveDataset = (
19 | key: string,
20 | config: AxiosRequestConfig
21 | ): AxiosPromise =>
22 | API.authenticatedGet(compilePath(API_PATHS.DATASET, { key }), config);
23 |
24 | export const retrieveOpener = (
25 | url: string,
26 | config: AxiosRequestConfig
27 | ): AxiosPromise => API.authenticatedGet(url, config);
28 |
29 | export const updateDataset = (
30 | key: string,
31 | dataset: { name: string },
32 | config: AxiosRequestConfig
33 | ): AxiosPromise =>
34 | API.put(compilePath(API_PATHS.DATASET, { key }), dataset, config);
35 |
--------------------------------------------------------------------------------
/src/types/NewsFeedTypes.ts:
--------------------------------------------------------------------------------
1 | export enum NewsItemStatus {
2 | created = 'STATUS_CREATED',
3 | doing = 'STATUS_DOING',
4 | done = 'STATUS_DONE',
5 | failed = 'STATUS_FAILED',
6 | canceled = 'STATUS_CANCELED',
7 | }
8 |
9 | const NewsItemStatusLabel: Record = {
10 | STATUS_CREATED: 'created',
11 | STATUS_DOING: 'doing',
12 | STATUS_DONE: 'done',
13 | STATUS_FAILED: 'failed',
14 | STATUS_CANCELED: 'canceled',
15 | };
16 |
17 | export const getNewsItemStatusLabel = (status: NewsItemStatus): string =>
18 | NewsItemStatusLabel[status];
19 |
20 | export enum NewsItemAssetKind {
21 | computePlan = 'ASSET_COMPUTE_PLAN',
22 | dataset = 'ASSET_DATA_MANAGER',
23 | }
24 |
25 | const NewsItemAssetLabel: Record = {
26 | ASSET_COMPUTE_PLAN: 'Compute plan',
27 | ASSET_DATA_MANAGER: 'Dataset',
28 | };
29 |
30 | export const getNewsItemAssetLabel = (asset_kind: NewsItemAssetKind): string =>
31 | NewsItemAssetLabel[asset_kind];
32 |
33 | export type NewsItemT = {
34 | asset_kind: NewsItemAssetKind;
35 | asset_key: string;
36 | name: string;
37 | status: NewsItemStatus;
38 | timestamp: string;
39 | detail: {
40 | first_failed_task_key?: string;
41 | };
42 | };
43 |
--------------------------------------------------------------------------------
/src/routes/notfound/NotFound.tsx:
--------------------------------------------------------------------------------
1 | import { useLocation } from 'wouter';
2 |
3 | import { Flex } from '@chakra-ui/react';
4 | import { RiFileWarningLine } from 'react-icons/ri';
5 |
6 | import { useDocumentTitleEffect } from '@/hooks/useDocumentTitleEffect';
7 | import { PATHS } from '@/paths';
8 |
9 | import EmptyState from '@/components/EmptyState';
10 |
11 | const NotFound = (): JSX.Element => {
12 | const [, setLocation] = useLocation();
13 |
14 | useDocumentTitleEffect(
15 | (setDocumentTitle) => setDocumentTitle('Page not found'),
16 | []
17 | );
18 |
19 | return (
20 |
28 | }
30 | title="Oops this page does not exist"
31 | subtitle="You may have mistyped the address or the page may have moved"
32 | buttonLabel="Go back to home"
33 | buttonOnClick={() => setLocation(PATHS.HOME)}
34 | />
35 |
36 | );
37 | };
38 |
39 | export default NotFound;
40 |
--------------------------------------------------------------------------------
/src/types/SeriesTypes.ts:
--------------------------------------------------------------------------------
1 | import { ScatterDataPoint } from 'chart.js';
2 |
3 | export type SerieFeaturesT = {
4 | functionKey: string;
5 | worker: string;
6 | identifier: string;
7 | computePlanKey: string;
8 | };
9 |
10 | export type PointT = {
11 | rank: number;
12 | round: number;
13 | perf: number | null;
14 | testTaskKey: string | null;
15 | };
16 |
17 | export type DataPointT = ScatterDataPoint & {
18 | x: number;
19 | y: number;
20 | testTaskKey: string | null;
21 | worker: string;
22 | computePlanKey: string;
23 | serieId: string;
24 | };
25 |
26 | export type SerieT = SerieFeaturesT & {
27 | id: string;
28 | maxRank: number;
29 | maxRankWithPerf: number;
30 | maxRound: number;
31 | maxRoundWithPerf: number;
32 | points: PointT[];
33 | };
34 |
35 | export type HighlightedSerieT = {
36 | id: string;
37 | computePlanKey: string;
38 | };
39 |
40 | export type HighlightedParamsProps = {
41 | highlightedSerie?: HighlightedSerieT;
42 | highlightedComputePlanKey?: string;
43 | highlightedOrganizationId?: string;
44 | };
45 |
46 | export type SerieRankDataT = {
47 | id: string;
48 | computePlanKey: string;
49 | testTaskKey: string | null;
50 | worker: string;
51 | perf: string;
52 | };
53 |
--------------------------------------------------------------------------------
/src/features/metadata/useMetadataStore.tsx:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { create } from 'zustand';
3 |
4 | import { listMetadata } from '@/api/MetadataApi';
5 |
6 | type MetadataStateT = {
7 | metadata: string[];
8 | fetchingMetadata: boolean;
9 | fetchMetadata: () => void;
10 | };
11 |
12 | let fetchController: AbortController | undefined;
13 |
14 | const useMetadataStore = create((set) => ({
15 | metadata: [],
16 | fetchingMetadata: true,
17 | fetchMetadata: async () => {
18 | // abort previous call
19 | if (fetchController) {
20 | fetchController.abort();
21 | }
22 |
23 | fetchController = new AbortController();
24 | set({ fetchingMetadata: true });
25 | try {
26 | const response = await listMetadata();
27 | set({
28 | fetchingMetadata: false,
29 | metadata: response.data,
30 | });
31 | } catch (error) {
32 | if (axios.isCancel(error)) {
33 | // do nothing, the call has been canceled voluntarily
34 | } else {
35 | console.warn(error);
36 | set({ fetchingMetadata: false });
37 | }
38 | }
39 | },
40 | }));
41 |
42 | export default useMetadataStore;
43 |
--------------------------------------------------------------------------------
/src/components/table/TablePagination.tsx:
--------------------------------------------------------------------------------
1 | import { Flex, Text } from '@chakra-ui/react';
2 |
3 | import Pagination from '@/components/table/Pagination';
4 |
5 | type TablePaginationProps = {
6 | currentPage: number;
7 | itemCount: number;
8 | };
9 |
10 | const TablePagination = ({
11 | currentPage,
12 | itemCount,
13 | }: TablePaginationProps): JSX.Element => {
14 | const firstIndex = Math.max((currentPage - 1) * DEFAULT_PAGE_SIZE + 1, 0);
15 | const lastIndex = Math.min(currentPage * DEFAULT_PAGE_SIZE, itemCount);
16 | const lastPage = Math.ceil(itemCount / DEFAULT_PAGE_SIZE);
17 |
18 | return (
19 |
20 |
26 | {itemCount === 0 && `0 results`}
27 | {itemCount === 1 &&
28 | `1 result • ${firstIndex}-${lastIndex} shown`}
29 | {itemCount > 1 &&
30 | `${itemCount} results • ${firstIndex}-${lastIndex} shown`}
31 |
32 |
33 |
34 | );
35 | };
36 | export default TablePagination;
37 |
--------------------------------------------------------------------------------
/src/features/perfBrowser/PerfChartTooltipItem.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react';
2 |
3 | import { HStack, ListItem, Text } from '@chakra-ui/react';
4 |
5 | import { PerfBrowserContext } from '@/features/perfBrowser/usePerfBrowser';
6 | import { DataPointT } from '@/types/SeriesTypes';
7 |
8 | import PerfIconTag from '@/components/PerfIconTag';
9 |
10 | const PerfChartTooltipItem = ({
11 | point,
12 | }: {
13 | point: DataPointT;
14 | }): JSX.Element => {
15 | const { getSerieIndex } = useContext(PerfBrowserContext);
16 |
17 | return (
18 |
23 |
24 |
28 |
29 | {`#${getSerieIndex(
30 | point.computePlanKey,
31 | point.serieId
32 | )} • ${point.worker}`}
33 |
34 |
35 | {point.y.toFixed(3)}
36 |
37 | );
38 | };
39 |
40 | export default PerfChartTooltipItem;
41 |
--------------------------------------------------------------------------------
/.github/workflows/release.yaml:
--------------------------------------------------------------------------------
1 | name: Release
2 | on:
3 | push:
4 | tags: ['*']
5 | # FYI this isn't triggered when more than 3 tags are pushed at once
6 | # https://docs.github.com/en/developers/webhooks-and-events/webhook-events-and-payloads#push
7 | env:
8 | REGISTRY: ghcr.io
9 | jobs:
10 | issue-release:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v4
14 |
15 | - uses: docker/login-action@v3
16 | with:
17 | registry: ${{ env.REGISTRY }}
18 | username: ${{ github.actor }}
19 | password: ${{ secrets.GITHUB_TOKEN }}
20 |
21 | - uses: docker/metadata-action@v5
22 | id: docker-metadata
23 | with:
24 | images: 'ghcr.io/substra/substra-frontend'
25 | tags: |
26 | type=ref,event=tag
27 | type=raw,value=latest
28 |
29 | - uses: docker/build-push-action@v6
30 | with:
31 | push: ${{ github.event_name != 'pull_request' }}
32 | file: ./docker/substra-frontend/Dockerfile
33 | context: .
34 | tags: ${{ steps.docker-metadata.outputs.tags }}
35 | labels: ${{ steps.docker-metadata.outputs.labels }}
36 |
--------------------------------------------------------------------------------
/src/routes/tasks/useTasksStore.tsx:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { create } from 'zustand';
3 |
4 | import { listTasks } from '@/api/TasksApi';
5 | import { withAbortSignal } from '@/api/request';
6 | import { APIListArgsT, AbortFunctionT } from '@/types/CommonTypes';
7 | import { TaskT } from '@/types/TasksTypes';
8 |
9 | type TasksStateT = {
10 | tasks: TaskT[];
11 | tasksCount: number;
12 | fetchingTasks: boolean;
13 | fetchTasks: (params: APIListArgsT) => AbortFunctionT;
14 | };
15 |
16 | const useTasksStore = create((set) => ({
17 | tasks: [],
18 | tasksCount: 0,
19 | fetchingTasks: true,
20 | fetchTasks: withAbortSignal(async (signal, params) => {
21 | set({ fetchingTasks: true });
22 | try {
23 | const response = await listTasks(params, {
24 | signal,
25 | });
26 | set({
27 | fetchingTasks: false,
28 | tasks: response.data.results,
29 | tasksCount: response.data.count,
30 | });
31 | } catch (error) {
32 | if (axios.isCancel(error)) {
33 | // do nothing, the call has been canceled voluntarily
34 | } else {
35 | console.warn(error);
36 | set({ fetchingTasks: false });
37 | }
38 | }
39 | }),
40 | }));
41 |
42 | export default useTasksStore;
43 |
--------------------------------------------------------------------------------
/e2e-tests/cypress/support/e2e.js:
--------------------------------------------------------------------------------
1 | // ***********************************************************
2 | // This example support/index.js is processed and
3 | // loaded automatically before your test files.
4 | //
5 | // This is a great place to put global configuration and
6 | // behavior that modifies Cypress.
7 | //
8 | // You can change the location of this file or turn off
9 | // automatically serving support files with the
10 | // 'supportFile' configuration option.
11 | //
12 | // You can read more here:
13 | // https://on.cypress.io/configuration
14 | // ***********************************************************
15 | // Import commands.js using ES2015 syntax:
16 | import './commands';
17 |
18 | // Alternatively you can use CommonJS syntax:
19 | // require('./commands')
20 |
21 | beforeEach(() => {
22 | cy.request(
23 | 'POST',
24 | `${Cypress.env('BACKEND_API_URL')}/me/login/?format=json`,
25 | {
26 | username: Cypress.env('USERNAME'),
27 | password: Cypress.env('PASSWORD'),
28 | }
29 | );
30 |
31 | /**
32 | * keep uncaught exceptions from failing tests
33 | * uncomment this function to test part with expected error
34 | **/
35 | // cy.on('uncaught:exception', (e, runnable) => {
36 | // console.log('error', e);
37 | // console.log('runnable', runnable);
38 | // console.log('error', e.message);
39 | // return false;
40 | // });
41 | });
42 |
--------------------------------------------------------------------------------
/src/components/Duration.tsx:
--------------------------------------------------------------------------------
1 | import { HStack, Icon, Text } from '@chakra-ui/react';
2 | import { RiTimeLine } from 'react-icons/ri';
3 |
4 | import { formatDuration, getDiffDates } from '@/libs/utils';
5 | import {
6 | ComputePlanStatus,
7 | ComputePlanT,
8 | isComputePlan,
9 | } from '@/types/ComputePlansTypes';
10 | import { TaskT } from '@/types/TasksTypes';
11 |
12 | type DurationProps = {
13 | asset: ComputePlanT | TaskT;
14 | };
15 |
16 | const Duration = ({ asset }: DurationProps): JSX.Element | null => {
17 | if (!asset.start_date) {
18 | return null;
19 | }
20 |
21 | return (
22 |
23 |
24 | {formatDuration(asset.duration)}
25 | {isComputePlan(asset) &&
26 | asset.status === ComputePlanStatus.doing &&
27 | asset.estimated_end_date && (
28 | <>
29 | •
30 |
31 | {`${getDiffDates(
32 | 'now',
33 | asset.estimated_end_date
34 | )} remaining`}
35 |
36 | >
37 | )}
38 |
39 | );
40 | };
41 | export default Duration;
42 |
--------------------------------------------------------------------------------
/skaffold.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: skaffold/v2beta16
2 | kind: Config
3 | build:
4 | artifacts:
5 | - image: &imageref substra/substra-frontend
6 | context: .
7 | docker:
8 | dockerfile: docker/substra-frontend/Dockerfile
9 |
10 | deploy:
11 | helm:
12 | releases:
13 | - name: frontend-org-1
14 | chartPath: charts/substra-frontend
15 | namespace: org-1
16 | createNamespace: true
17 | artifactOverrides:
18 | image: *imageref
19 | imageStrategy:
20 | helm:
21 | explicitRegistry: true
22 | valuesFiles:
23 | - 'skaffold-values/org-1.yaml'
24 |
25 | - name: frontend-org-2
26 | chartPath: charts/substra-frontend
27 | namespace: org-2
28 | createNamespace: true
29 | artifactOverrides:
30 | image: *imageref
31 | imageStrategy:
32 | helm:
33 | explicitRegistry: true
34 | valuesFiles:
35 | - 'skaffold-values/org-2.yaml'
36 |
37 | profiles:
38 | - name: single-org
39 | patches:
40 | - op: remove
41 | path: /deploy/helm/releases/1
42 | - name: dev
43 | patches:
44 | - op: add
45 | path: /build/artifacts/0/docker/target
46 | value: dev
47 |
--------------------------------------------------------------------------------
/src/routes/computePlanDetails/ComputePlanUtils.ts:
--------------------------------------------------------------------------------
1 | import { ComputePlanT } from '@/types/ComputePlansTypes';
2 | import { TaskStatus } from '@/types/TasksTypes';
3 |
4 | export const getStatusCount = (
5 | computePlan: ComputePlanT,
6 | status: TaskStatus
7 | ): number => {
8 | if (status === TaskStatus.executing) {
9 | return computePlan.executing_count;
10 | } else if (status === TaskStatus.done) {
11 | return computePlan.done_count;
12 | } else if (status === TaskStatus.canceled) {
13 | return computePlan.canceled_count;
14 | } else if (status === TaskStatus.failed) {
15 | return computePlan.failed_count;
16 | } else if (status === TaskStatus.waitingParentTasks) {
17 | return computePlan.waiting_parent_tasks_count;
18 | } else if (status === TaskStatus.waitingExecutorSlot) {
19 | return computePlan.waiting_executor_slot_count;
20 | } else if (status === TaskStatus.waitingBuilderSlot) {
21 | return computePlan.waiting_builder_slot_count;
22 | } else if (status === TaskStatus.building) {
23 | return computePlan.building_count;
24 | }
25 |
26 | throw `Invalid status ${status}`;
27 | };
28 |
29 | export const compareComputePlans = (
30 | a: ComputePlanT,
31 | b: ComputePlanT
32 | ): -1 | 0 | 1 => {
33 | if (a.key < b.key) {
34 | return -1;
35 | } else if (a.key === b.key) {
36 | return 0;
37 | } else {
38 | return 1;
39 | }
40 | };
41 |
--------------------------------------------------------------------------------
/src/features/organizations/useOrganizationsStore.tsx:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { create } from 'zustand';
3 |
4 | import { listOrganizations } from '@/api/OrganizationsApi';
5 | import { OrganizationT } from '@/types/OrganizationsTypes';
6 |
7 | type OrganizationsStateT = {
8 | organizations: OrganizationT[];
9 | fetchingOrganizations: boolean;
10 | fetchOrganizations: () => void;
11 | };
12 |
13 | let fetchController: AbortController | undefined;
14 |
15 | const useOrganizationsStore = create((set) => ({
16 | organizations: [],
17 | fetchingOrganizations: true,
18 | fetchOrganizations: async () => {
19 | // abort previous call
20 | if (fetchController) {
21 | fetchController.abort();
22 | }
23 |
24 | fetchController = new AbortController();
25 | set({ fetchingOrganizations: true });
26 | try {
27 | const response = await listOrganizations();
28 | set({
29 | fetchingOrganizations: false,
30 | organizations: response.data,
31 | });
32 | } catch (error) {
33 | if (axios.isCancel(error)) {
34 | // do nothing, the call has been canceled voluntarily
35 | } else {
36 | console.warn(error);
37 | set({ fetchingOrganizations: false });
38 | }
39 | }
40 | },
41 | }));
42 |
43 | export default useOrganizationsStore;
44 |
--------------------------------------------------------------------------------
/src/routes/datasets/useDatasetsStores.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { create } from 'zustand';
3 |
4 | import { listDatasets } from '@/api/DatasetsApi';
5 | import { withAbortSignal } from '@/api/request';
6 | import { APIListArgsT, AbortFunctionT } from '@/types/CommonTypes';
7 | import { DatasetStubT } from '@/types/DatasetTypes';
8 |
9 | type DatasetsStateT = {
10 | datasets: DatasetStubT[];
11 | datasetsCount: number;
12 | fetchingDatasets: boolean;
13 | fetchDatasets: (params: APIListArgsT) => AbortFunctionT;
14 | };
15 |
16 | const useDatasetsStore = create((set) => ({
17 | datasets: [],
18 | datasetsCount: 0,
19 | fetchingDatasets: true,
20 | fetchDatasets: withAbortSignal(async (signal, params) => {
21 | set({ fetchingDatasets: true });
22 | try {
23 | const response = await listDatasets(params, {
24 | signal,
25 | });
26 | set({
27 | fetchingDatasets: false,
28 | datasets: response.data.results,
29 | datasetsCount: response.data.count,
30 | });
31 | } catch (error) {
32 | if (axios.isCancel(error)) {
33 | // do nothing, the call has been canceled voluntarily
34 | } else {
35 | console.warn(error);
36 | set({ fetchingDatasets: false });
37 | }
38 | }
39 | }),
40 | }));
41 |
42 | export default useDatasetsStore;
43 |
--------------------------------------------------------------------------------
/src/routes/users/UsersUtils.ts:
--------------------------------------------------------------------------------
1 | import { UserRolesT } from '@/types/UsersTypes';
2 |
3 | export const UserRolesToLabel: Record = {
4 | [UserRolesT.admin]: 'Admin',
5 | [UserRolesT.user]: 'User',
6 | };
7 |
8 | export const isDifferentFromUsername = (
9 | password: string,
10 | username: string
11 | ): boolean => {
12 | return password !== username;
13 | };
14 |
15 | export const hasCorrectLength = (password: string): boolean => {
16 | return password.length >= 20 && password.length <= 64;
17 | };
18 |
19 | export const hasSpecialChar = (password: string): boolean => {
20 | const regexSpecialChar = /[^A-Za-z0-9]/;
21 | return !!password.match(regexSpecialChar)?.length;
22 | };
23 |
24 | export const hasNumber = (password: string): boolean => {
25 | const regexNumber = /[0-9]/;
26 | return !!password.match(regexNumber);
27 | };
28 |
29 | export const hasLowerAndUpperChar = (password: string): boolean => {
30 | const regexLowerChar = /[a-z]/;
31 | const regexUpperChar = /[A-Z]/;
32 |
33 | return !!password.match(regexLowerChar) && !!password.match(regexUpperChar);
34 | };
35 |
36 | export const isPasswordValid = (
37 | password: string,
38 | username: string
39 | ): boolean => {
40 | return (
41 | isDifferentFromUsername(password, username) &&
42 | hasCorrectLength(password) &&
43 | hasSpecialChar(password) &&
44 | hasNumber(password) &&
45 | hasLowerAndUpperChar(password)
46 | );
47 | };
48 |
--------------------------------------------------------------------------------
/src/routes/functions/useFunctionsStore.tsx:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { create } from 'zustand';
3 |
4 | import { listFunctions } from '@/api/FunctionsApi';
5 | import { withAbortSignal } from '@/api/request';
6 | import { APIListArgsT, AbortFunctionT } from '@/types/CommonTypes';
7 | import { FunctionT } from '@/types/FunctionsTypes';
8 |
9 | type FunctionsStateT = {
10 | functions: FunctionT[];
11 | functionsCount: number;
12 | fetchingFunctions: boolean;
13 | fetchFunctions: (params: APIListArgsT) => AbortFunctionT;
14 | };
15 |
16 | const useFunctionsStore = create((set) => ({
17 | functions: [],
18 | functionsCount: 0,
19 | fetchingFunctions: true,
20 | fetchFunctions: withAbortSignal(async (signal, params) => {
21 | set({ fetchingFunctions: true });
22 | try {
23 | const response = await listFunctions(params, {
24 | signal,
25 | });
26 | set({
27 | fetchingFunctions: false,
28 | functions: response.data.results,
29 | functionsCount: response.data.count,
30 | });
31 | } catch (error) {
32 | if (axios.isCancel(error)) {
33 | // do nothing, call has been canceled voluntarily
34 | } else {
35 | console.warn(error);
36 | set({ fetchingFunctions: false });
37 | }
38 | }
39 | }),
40 | }));
41 |
42 | export default useFunctionsStore;
43 |
--------------------------------------------------------------------------------
/e2e-tests/cypress/e2e/menu.cy.js:
--------------------------------------------------------------------------------
1 | describe('Menu tests', () => {
2 | before(() => {
3 | cy.login();
4 | });
5 |
6 | beforeEach(() => {
7 | cy.visit('/compute_plans');
8 | cy.getDataCy('menu-button').click();
9 | });
10 |
11 | it('help and feedback modal', () => {
12 | cy.getDataCy('help').click();
13 | cy.getDataCy('help-modal').should('exist');
14 | });
15 |
16 | it('about modal', () => {
17 | cy.getDataCy('about').click();
18 | cy.getDataCy('about-modal').should('exist');
19 | });
20 |
21 | it('documentation link', () => {
22 | cy.getDataCy('documentation')
23 | .should('have.attr', 'href', 'https://docs.substra.org/')
24 | .should('have.attr', 'target', '_blank');
25 | });
26 |
27 | it('api tokens page', () => {
28 | cy.getDataCy('api-tokens').click();
29 | cy.url().should('include', '/manage_tokens');
30 | });
31 |
32 | it('users management page', () => {
33 | cy.get('[data-user-role]')
34 | .invoke('data', 'user-role')
35 | .then((userRole) => {
36 | if (userRole === 'ADMIN') {
37 | cy.getDataCy('users-management').click();
38 | cy.url().should('include', '/users');
39 | }
40 | });
41 | });
42 |
43 | it('logout button', () => {
44 | cy.getDataCy('logout').click();
45 | cy.url().should('include', '/login');
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/src/components/DownloadIconButton.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | IconButton,
3 | IconButtonProps,
4 | Tooltip,
5 | TooltipProps,
6 | } from '@chakra-ui/react';
7 | import { RiDownloadLine } from 'react-icons/ri';
8 |
9 | import { downloadBlob, downloadFromApi } from '@/api/request';
10 |
11 | type DownloadIconButtonProps = IconButtonProps & {
12 | storageAddress?: string;
13 | blob?: Blob;
14 | filename: string;
15 | placement?: TooltipProps['placement'];
16 | };
17 | const DownloadIconButton = ({
18 | storageAddress,
19 | blob,
20 | filename,
21 | placement,
22 | ...props
23 | }: DownloadIconButtonProps): JSX.Element => {
24 | const download = () => {
25 | if (storageAddress) {
26 | downloadFromApi(storageAddress, filename);
27 | } else if (blob) {
28 | downloadBlob(blob, filename);
29 | } else {
30 | console.error('No url or content specified for download');
31 | }
32 | };
33 | return (
34 |
40 | }
45 | onClick={download}
46 | {...props}
47 | />
48 |
49 | );
50 | };
51 | export default DownloadIconButton;
52 |
--------------------------------------------------------------------------------
/src/types/DatasetTypes.ts:
--------------------------------------------------------------------------------
1 | import {
2 | FileT,
3 | MetadataT,
4 | PermissionsT,
5 | PermissionT,
6 | } from '@/types/CommonTypes';
7 |
8 | // DatasetStubT is returned when fetching a list of datasets
9 | export type DatasetStubT = {
10 | key: string;
11 | name: string;
12 | owner: string;
13 | permissions: PermissionsT;
14 | logs_permission: PermissionT;
15 | description: FileT;
16 | opener: FileT;
17 | type: string;
18 | creation_date: string;
19 | metadata: MetadataT;
20 | };
21 |
22 | // DatasetT is returned when fetching a single dataset
23 | export type DatasetT = DatasetStubT & {
24 | data_sample_keys: string[];
25 | };
26 |
27 | export const isDatasetStubT = (
28 | datasetStub: unknown
29 | ): datasetStub is DatasetStubT => {
30 | if (typeof datasetStub !== 'object') {
31 | return false;
32 | }
33 |
34 | return (
35 | (datasetStub as DatasetStubT).key !== undefined &&
36 | (datasetStub as DatasetStubT).name !== undefined &&
37 | (datasetStub as DatasetStubT).owner !== undefined &&
38 | (datasetStub as DatasetStubT).permissions !== undefined &&
39 | (datasetStub as DatasetStubT).logs_permission !== undefined &&
40 | (datasetStub as DatasetStubT).description !== undefined &&
41 | (datasetStub as DatasetStubT).opener !== undefined &&
42 | (datasetStub as DatasetStubT).type !== undefined &&
43 | (datasetStub as DatasetStubT).creation_date !== undefined &&
44 | (datasetStub as DatasetStubT).metadata !== undefined
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/src/features/perfBrowser/PerfList.tsx:
--------------------------------------------------------------------------------
1 | import { VStack, Wrap, WrapItem } from '@chakra-ui/react';
2 |
3 | import PerfCard from '@/features/perfBrowser/PerfCard';
4 | import PerfChart from '@/features/perfBrowser/PerfChart';
5 | import PerfEmptyState from '@/features/perfBrowser/PerfEmptyState';
6 | import { SerieT } from '@/types/SeriesTypes';
7 |
8 | type PerfListProps = {
9 | seriesGroups: SerieT[][];
10 | onCardClick: (identifier: string) => void;
11 | };
12 | const PerfList = ({ seriesGroups, onCardClick }: PerfListProps) => {
13 | return (
14 |
24 |
25 |
26 | {seriesGroups.map((series) => (
27 |
28 | onCardClick(series[0].identifier)}
31 | >
32 |
33 |
34 |
35 | ))}
36 |
37 |
38 | );
39 | };
40 | export default PerfList;
41 |
--------------------------------------------------------------------------------
/src/features/customColumns/useCustomColumns.ts:
--------------------------------------------------------------------------------
1 | import { useLocalStorageArrayState } from '@/hooks/useLocalStorageState';
2 |
3 | import { ColumnT, GENERAL_COLUMNS, isColumn } from './CustomColumnsTypes';
4 | import { areColumnsEqual } from './CustomColumnsUtils';
5 |
6 | const migrate = (data: unknown): ColumnT[] => {
7 | // custom columns used to be stored as a json array of strings matching metadata names
8 | if (!Array.isArray(data)) {
9 | return [];
10 | }
11 |
12 | // migrate old data
13 | let oldDataFound = false;
14 | const migratedData = data.map((item) => {
15 | if (typeof item === 'string') {
16 | oldDataFound = true;
17 | return { name: item, type: 'metadata' };
18 | } else {
19 | return item;
20 | }
21 | });
22 |
23 | const columns = migratedData.filter(isColumn);
24 |
25 | if (oldDataFound) {
26 | return [...GENERAL_COLUMNS, ...columns];
27 | } else {
28 | return columns;
29 | }
30 | };
31 |
32 | const useCustomColumns = (): {
33 | columns: ColumnT[];
34 | setColumns: (columns: ColumnT[]) => void;
35 | clearColumns: () => void;
36 | } => {
37 | const {
38 | state: columns,
39 | setState: setColumns,
40 | clearState: clearColumns,
41 | } = useLocalStorageArrayState(
42 | 'custom_columns',
43 | areColumnsEqual,
44 | migrate,
45 | GENERAL_COLUMNS
46 | );
47 |
48 | return {
49 | columns,
50 | setColumns,
51 | clearColumns,
52 | };
53 | };
54 |
55 | export default useCustomColumns;
56 |
--------------------------------------------------------------------------------
/src/components/Status.tsx:
--------------------------------------------------------------------------------
1 | import { Tag, TagLabel, TagLeftIcon, TagProps, Text } from '@chakra-ui/react';
2 |
3 | import { getStatusLabel, getStatusStyle } from '@/libs/status';
4 | import { ComputePlanStatus } from '@/types/ComputePlansTypes';
5 | import { TaskStatus } from '@/types/TasksTypes';
6 |
7 | type StatusProps = {
8 | status: ComputePlanStatus | TaskStatus;
9 | size: TagProps['size'];
10 | variant?: TagProps['variant'];
11 | withIcon?: boolean;
12 | count?: number;
13 | };
14 |
15 | const Status = ({
16 | status,
17 | size,
18 | variant,
19 | withIcon,
20 | count,
21 | }: StatusProps): JSX.Element => {
22 | const {
23 | icon,
24 | tagColor,
25 | tagBackgroundColor,
26 | tagSolidColor,
27 | tagSolidBackgroundColor,
28 | } = getStatusStyle(status);
29 | const label = getStatusLabel(status);
30 | const color = variant === 'solid' ? tagSolidColor : tagColor;
31 | const backgroundColor =
32 | variant === 'solid' ? tagSolidBackgroundColor : tagBackgroundColor;
33 |
34 | return (
35 |
41 | {withIcon !== false && }
42 |
43 |
44 | {label}
45 |
46 | {count !== undefined && ` • ${count}`}
47 |
48 |
49 | );
50 | };
51 |
52 | export default Status;
53 |
--------------------------------------------------------------------------------
/src/routes/computePlans/components/FavoriteBox.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | import styled from '@emotion/styled';
4 |
5 | import { Box } from '@chakra-ui/react';
6 | import { RiStarFill, RiStarLine } from 'react-icons/ri';
7 |
8 | const StyledInput = styled('input')`
9 | border: 0px;
10 | clip: rect(0px, 0px, 0px, 0px);
11 | height: 1px;
12 | width: 1px;
13 | margin: -1px;
14 | padding: 0px;
15 | overflow: hidden;
16 | white-space: nowrap;
17 | position: absolute;
18 | `;
19 |
20 | type FavoriteBoxProps = {
21 | isChecked: boolean;
22 | onChange: () => void;
23 | };
24 | const FavoriteBox = ({
25 | isChecked,
26 | onChange,
27 | }: FavoriteBoxProps): JSX.Element => {
28 | const [focus, setFocus] = useState(false);
29 | return (
30 |
37 | {!isChecked && }
38 | {isChecked && (
39 |
43 | )}
44 | setFocus(true)}
48 | onBlur={() => setFocus(false)}
49 | />
50 |
51 | );
52 | };
53 |
54 | export default FavoriteBox;
55 |
--------------------------------------------------------------------------------
/src/routes/computePlanDetails/workflow/useWorkflowStore.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { create } from 'zustand';
3 |
4 | import { retrieveCPWorkflowGraph } from '@/api/CPWorkflowApi';
5 | import { handleUnknownError, withAbortSignal } from '@/api/request';
6 | import { TaskGraphT } from '@/types/CPWorkflowTypes';
7 | import { AbortFunctionT } from '@/types/CommonTypes';
8 |
9 | type WorkflowStateT = {
10 | graph: TaskGraphT;
11 | fetchingGraph: boolean;
12 | graphError: string | null;
13 | fetchGraph: (computePlanKey: string) => AbortFunctionT;
14 | };
15 |
16 | const emptyGraph = {
17 | tasks: [],
18 | edges: [],
19 | };
20 |
21 | const useWorkflowStore = create((set) => ({
22 | graph: emptyGraph,
23 | fetchingGraph: true,
24 | graphError: null,
25 | fetchGraph: withAbortSignal(async (signal, computePlanKey) => {
26 | set({ fetchingGraph: true, graph: emptyGraph, graphError: null });
27 | try {
28 | const response = await retrieveCPWorkflowGraph(computePlanKey, {
29 | signal,
30 | });
31 | set({
32 | fetchingGraph: false,
33 | graph: response.data,
34 | });
35 | } catch (error) {
36 | if (axios.isCancel(error)) {
37 | // do nothing, the call has been canceled voluntarily
38 | } else {
39 | console.warn(error);
40 | const graphError = handleUnknownError(error);
41 | set({ fetchingGraph: false, graphError });
42 | }
43 | }
44 | }),
45 | }));
46 |
47 | export default useWorkflowStore;
48 |
--------------------------------------------------------------------------------
/src/components/PerfIconTag.tsx:
--------------------------------------------------------------------------------
1 | import { Icon, Tag } from '@chakra-ui/react';
2 | import { RiGitCommitLine } from 'react-icons/ri';
3 |
4 | import usePerfBrowserColors from '@/features/perfBrowser/usePerfBrowserColors';
5 | import usePerfBrowserPointStyles from '@/features/perfBrowser/usePerfBrowserPointStyles';
6 |
7 | type PerfIconTagProps = {
8 | worker: string;
9 | computePlanKey: string;
10 | };
11 | const PerfIconTag = ({
12 | worker,
13 | computePlanKey,
14 | }: PerfIconTagProps): JSX.Element => {
15 | const { getColorScheme } = usePerfBrowserColors();
16 | const { getPointStyleComponent } = usePerfBrowserPointStyles();
17 |
18 | const IconComponent = getPointStyleComponent(worker);
19 | const colorScheme = getColorScheme({ worker, computePlanKey });
20 |
21 | return (
22 |
35 |
41 |
42 | );
43 | };
44 | export default PerfIconTag;
45 |
--------------------------------------------------------------------------------
/src/hooks/useBuildPerfChartDataset.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 |
3 | import { ChartDataset } from 'chart.js';
4 |
5 | import { XAxisModeT } from '@/features/perfBrowser/usePerfBrowser';
6 | import usePerfChartDatasetStyle from '@/features/perfBrowser/usePerfChartDatasetStyle';
7 | import {
8 | DataPointT,
9 | HighlightedParamsProps,
10 | SerieT,
11 | } from '@/types/SeriesTypes';
12 |
13 | type PerfChartDatasetProps = ChartDataset<'line', DataPointT[]>;
14 |
15 | const useBuildPerfChartDataset = (): ((
16 | serie: SerieT,
17 | xAxisMode: XAxisModeT,
18 | highlightedParams: HighlightedParamsProps
19 | ) => PerfChartDatasetProps) => {
20 | const datasetStyle = usePerfChartDatasetStyle();
21 |
22 | return useCallback(
23 | (
24 | serie: SerieT,
25 | xAxisMode: XAxisModeT,
26 | highlightedParams: HighlightedParamsProps
27 | ): PerfChartDatasetProps => {
28 | return {
29 | label: serie.id,
30 | data: serie.points.map(
31 | (point): DataPointT => ({
32 | x: point[xAxisMode],
33 | y: point.perf as number,
34 | testTaskKey: point.testTaskKey,
35 | worker: serie.worker,
36 | computePlanKey: serie.computePlanKey,
37 | serieId: serie.id,
38 | })
39 | ),
40 | parsing: false,
41 | ...datasetStyle(serie, highlightedParams),
42 | };
43 | },
44 | [datasetStyle]
45 | );
46 | };
47 | export default useBuildPerfChartDataset;
48 |
--------------------------------------------------------------------------------
/src/api/TasksApi.ts:
--------------------------------------------------------------------------------
1 | import { AxiosPromise, AxiosRequestConfig } from 'axios';
2 |
3 | import API, { getApiOptions } from '@/api/request';
4 | import { API_PATHS, compilePath } from '@/paths';
5 | import { APIListArgsT, PaginatedApiResponseT } from '@/types/CommonTypes';
6 | import { TaskT, TaskIOT } from '@/types/TasksTypes';
7 |
8 | export const listTasks = (
9 | apiListArgs: APIListArgsT,
10 | config: AxiosRequestConfig
11 | ): AxiosPromise> =>
12 | API.authenticatedGet(API_PATHS.TASKS, {
13 | ...getApiOptions(apiListArgs),
14 | ...config,
15 | });
16 |
17 | export const retrieveTask = (
18 | key: string,
19 | config: AxiosRequestConfig
20 | ): AxiosPromise =>
21 | API.authenticatedGet(compilePath(API_PATHS.TASK, { key }), config);
22 |
23 | export const listTaskInputAssets = (
24 | key: string,
25 | apiListArgs: APIListArgsT,
26 | config: AxiosRequestConfig
27 | ): AxiosPromise> =>
28 | API.authenticatedGet(compilePath(API_PATHS.TASK_INPUTS, { key }), {
29 | ...getApiOptions(apiListArgs),
30 | ...config,
31 | });
32 |
33 | export const listTaskOutputAssets = (
34 | key: string,
35 | apiListArgs: APIListArgsT,
36 | config: AxiosRequestConfig
37 | ): AxiosPromise> =>
38 | API.authenticatedGet(compilePath(API_PATHS.TASK_OUTPUTS, { key }), {
39 | ...getApiOptions(apiListArgs),
40 | ...config,
41 | });
42 |
43 | export const retrieveLogs = (
44 | key: string,
45 | config: AxiosRequestConfig
46 | ): AxiosPromise =>
47 | API.authenticatedGet(compilePath(API_PATHS.LOGS, { key }), config);
48 |
--------------------------------------------------------------------------------
/charts/substra-frontend/templates/NOTES.txt:
--------------------------------------------------------------------------------
1 | 1. Get the application URL by running these commands:
2 | {{- if .Values.ingress.enabled }}
3 | {{- range $host := .Values.ingress.hosts }}
4 | {{- range .paths }}
5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ . }}
6 | {{- end }}
7 | {{- end }}
8 | {{- else if contains "NodePort" .Values.service.type }}
9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "substra-frontend.fullname" . }})
10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
11 | echo http://$NODE_IP:$NODE_PORT
12 | {{- else if contains "LoadBalancer" .Values.service.type }}
13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available.
14 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "substra-frontend.fullname" . }}'
15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "substra-frontend.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
16 | echo http://$SERVICE_IP:{{ .Values.service.port }}
17 | {{- else if contains "ClusterIP" .Values.service.type }}
18 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "substra-frontend.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
19 | echo "Visit http://127.0.0.1:8080 to use your application"
20 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:80
21 | {{- end }}
22 |
--------------------------------------------------------------------------------
/src/features/perfBrowser/PerfSidebarSettings.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Collapse,
4 | Flex,
5 | Heading,
6 | Icon,
7 | useDisclosure,
8 | VStack,
9 | } from '@chakra-ui/react';
10 | import { RiArrowDropDownLine } from 'react-icons/ri';
11 |
12 | import PerfSidebarSettingsOrganizations from '@/features/perfBrowser/PerfSidebarSettingsOrganizations';
13 | import PerfSidebarSettingsUnits from '@/features/perfBrowser/PerfSidebarSettingsUnits';
14 |
15 | const PerfSidebarSettings = (): JSX.Element => {
16 | const { isOpen, onToggle } = useDisclosure({
17 | defaultIsOpen: true,
18 | });
19 |
20 | return (
21 |
22 |
23 |
28 | Settings
29 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | );
45 | };
46 |
47 | export default PerfSidebarSettings;
48 |
--------------------------------------------------------------------------------
/src/components/MetadataModalTr.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react';
2 |
3 | import { HStack, Td, Text, Th, Tr } from '@chakra-ui/react';
4 |
5 | import { PerfBrowserContext } from '@/features/perfBrowser/usePerfBrowser';
6 | import { ComputePlanT } from '@/types/ComputePlansTypes';
7 |
8 | type MetadataModalTrProps = {
9 | computePlan: ComputePlanT;
10 | columns: string[];
11 | };
12 |
13 | const MetadataModalTr = ({
14 | computePlan,
15 | columns,
16 | }: MetadataModalTrProps): JSX.Element => {
17 | const { getComputePlanIndex, computePlans } =
18 | useContext(PerfBrowserContext);
19 | return (
20 |
21 |
29 |
30 | {computePlans.length > 1 && (
31 |
32 | #{getComputePlanIndex(computePlan.key)}
33 |
34 | )}
35 | {computePlan.name}
36 |
37 |
38 | {columns.map((column) => (
39 |
44 | {computePlan.metadata[column] || '-'}
45 |
46 | ))}
47 |
48 | );
49 | };
50 |
51 | export default MetadataModalTr;
52 |
--------------------------------------------------------------------------------
/src/features/perfBrowser/PerfEmptyState.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react';
2 |
3 | import { Flex } from '@chakra-ui/react';
4 | import { RiFunctionLine } from 'react-icons/ri';
5 |
6 | import { PerfBrowserContext } from '@/features/perfBrowser/usePerfBrowser';
7 | import { SerieT } from '@/types/SeriesTypes';
8 |
9 | import EmptyState from '@/components/EmptyState';
10 |
11 | const PerfEmptyState = ({
12 | seriesGroups,
13 | }: {
14 | seriesGroups: SerieT[][];
15 | }): JSX.Element | null => {
16 | const { series } = useContext(PerfBrowserContext);
17 | if (series.length === 0) {
18 | return (
19 |
25 | }
29 | />
30 |
31 | );
32 | }
33 | if (seriesGroups.length === 0) {
34 | return (
35 |
41 | }
45 | />
46 |
47 | );
48 | }
49 | return null;
50 | };
51 | export default PerfEmptyState;
52 |
--------------------------------------------------------------------------------
/src/routes/compare/useCompareStore.tsx:
--------------------------------------------------------------------------------
1 | import axios, { AxiosPromise } from 'axios';
2 | import { create } from 'zustand';
3 |
4 | import { retrieveComputePlan } from '@/api/ComputePlansApi';
5 | import { withAbortSignal } from '@/api/request';
6 | import { AbortFunctionT } from '@/types/CommonTypes';
7 | import { ComputePlanT } from '@/types/ComputePlansTypes';
8 |
9 | type CompareStateT = {
10 | computePlans: ComputePlanT[];
11 | fetchingComputePlans: boolean;
12 | fetchComputePlans: (computePlanKeys: string[]) => AbortFunctionT;
13 | };
14 |
15 | const useCompareStore = create((set) => ({
16 | computePlans: [],
17 | fetchingComputePlans: true,
18 | fetchComputePlans: withAbortSignal(async (signal, computePlanKeys) => {
19 | set({ fetchingComputePlans: true });
20 | let promises: AxiosPromise[] = [];
21 |
22 | promises = computePlanKeys.map((computePlanKey) =>
23 | retrieveComputePlan(computePlanKey, {
24 | signal,
25 | })
26 | );
27 |
28 | let responses;
29 |
30 | try {
31 | responses = await Promise.all(promises);
32 | const results = responses.map((response) => response.data);
33 | set({
34 | computePlans: results,
35 | fetchingComputePlans: false,
36 | });
37 | } catch (error) {
38 | if (axios.isCancel(error)) {
39 | // do nothing, the call has been canceled voluntarily
40 | } else {
41 | console.warn(error);
42 | set({ fetchingComputePlans: false });
43 | }
44 | }
45 | }),
46 | }));
47 |
48 | export default useCompareStore;
49 |
--------------------------------------------------------------------------------
/e2e-tests/cypress/e2e/users.cy.js:
--------------------------------------------------------------------------------
1 | describe('Users page', () => {
2 | before(() => {
3 | cy.login();
4 | });
5 |
6 | beforeEach(() => {
7 | cy.visit('/compute_plans');
8 | cy.getDataCy('menu-button').click();
9 | cy.get('[data-user-role]')
10 | .invoke('data', 'user-role')
11 | .then((userRole) => {
12 | if (userRole === 'ADMIN') {
13 | cy.visit('/users');
14 | }
15 | });
16 | });
17 |
18 | it('can create user', () => {
19 | cy.getDataCy('create-user').click();
20 | cy.getDataCy('username-input').type('Test');
21 | cy.getDataCy('password-input').type('Azertyuiop123456789$');
22 | cy.getDataCy('submit-form').click();
23 |
24 | cy.get('[data-name="Test"]').first().should('exist');
25 | });
26 |
27 | it('can update user', () => {
28 | cy.get('[data-name="Test"]')
29 | .first()
30 | .should('exist')
31 | .then(($el) => {
32 | cy.wrap($el).should('have.data', 'role', 'USER');
33 | cy.wrap($el).click();
34 | });
35 | cy.get('select').eq(0).select('ADMIN');
36 | cy.getDataCy('submit-form').click();
37 |
38 | cy.get('[data-name="Test"]')
39 | .first()
40 | .should(($el) => {
41 | expect($el).to.have.data('role', 'ADMIN');
42 | });
43 | });
44 |
45 | it('can delete user', () => {
46 | cy.get('[data-name="Test"]').first().click();
47 | cy.getDataCy('delete-user').click();
48 | cy.getDataCy('confirm-delete').click();
49 | cy.get('[data-name="Test"]').should('not.exist');
50 | });
51 | });
52 |
--------------------------------------------------------------------------------
/src/components/Timing.tsx:
--------------------------------------------------------------------------------
1 | import { Text } from '@chakra-ui/react';
2 |
3 | import { shortFormatDate } from '@/libs/utils';
4 | import { ComputePlanStatus, ComputePlanT } from '@/types/ComputePlansTypes';
5 | import { TaskT, TaskStatus } from '@/types/TasksTypes';
6 |
7 | type TimingProps = {
8 | asset: ComputePlanT | TaskT;
9 | };
10 |
11 | const Timing = ({ asset }: TimingProps): JSX.Element => {
12 | if (!asset.start_date) {
13 | return (
14 |
15 | {[
16 | ComputePlanStatus.created,
17 | TaskStatus.waitingBuilderSlot,
18 | ].includes(asset.status)
19 | ? 'Not started yet'
20 | : 'Information not available'}
21 |
22 | );
23 | }
24 |
25 | return (
26 |
27 | {`${shortFormatDate(asset.start_date)} -> `}
28 | {asset.end_date && (
29 | {shortFormatDate(asset.end_date)}
30 | )}
31 | {!asset.end_date && (
32 |
33 | {[
34 | ComputePlanStatus.done,
35 | ComputePlanStatus.canceled,
36 | ComputePlanStatus.failed,
37 | TaskStatus.done,
38 | TaskStatus.canceled,
39 | TaskStatus.failed,
40 | ].includes(asset.status)
41 | ? 'Information not available'
42 | : 'Not ended yet'}
43 |
44 | )}
45 |
46 | );
47 | };
48 | export default Timing;
49 |
--------------------------------------------------------------------------------
/src/routes/computePlanDetails/components/TasksBreadCrumbs.tsx:
--------------------------------------------------------------------------------
1 | import { BreadcrumbItem, HStack, Text } from '@chakra-ui/react';
2 | import { RiStackshareLine } from 'react-icons/ri';
3 |
4 | import { PATHS } from '@/paths';
5 |
6 | import Breadcrumbs from '@/components/Breadcrumbs';
7 | import Status from '@/components/Status';
8 |
9 | import useComputePlanStore from '../useComputePlanStore';
10 |
11 | const ComputePlanTasksBreadcrumbs = (): JSX.Element => {
12 | const { computePlan, fetchingComputePlan } = useComputePlanStore();
13 |
14 | return (
15 |
20 |
21 |
22 |
28 | {fetchingComputePlan && 'Loading'}
29 | {!fetchingComputePlan &&
30 | computePlan &&
31 | computePlan.name}
32 |
33 | {!fetchingComputePlan && computePlan && (
34 |
39 | )}
40 |
41 |
42 |
43 | );
44 | };
45 |
46 | export default ComputePlanTasksBreadcrumbs;
47 |
--------------------------------------------------------------------------------
/src/components/EmptyState.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Button, Text, VStack } from '@chakra-ui/react';
2 |
3 | type EmptyStateProps = {
4 | title: string;
5 | subtitle?: string;
6 | buttonOnClick?: () => void;
7 | buttonLabel?: string;
8 | icon: React.ReactNode;
9 | dataCy?: string;
10 | };
11 | const EmptyState = ({
12 | title,
13 | subtitle,
14 | buttonOnClick,
15 | icon,
16 | buttonLabel,
17 | dataCy,
18 | }: EmptyStateProps) => {
19 | return (
20 |
21 |
32 | {icon}
33 |
34 |
35 |
41 | {title}
42 |
43 | {subtitle && (
44 |
45 | {subtitle}
46 |
47 | )}
48 |
49 | {buttonOnClick && buttonLabel && (
50 |
51 | {buttonLabel}
52 |
53 | )}
54 |
55 | );
56 | };
57 | export default EmptyState;
58 |
--------------------------------------------------------------------------------
/src/routes/computePlans/useComputePlansStore.tsx:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { create } from 'zustand';
3 |
4 | import { listComputePlans } from '@/api/ComputePlansApi';
5 | import { withAbortSignal } from '@/api/request';
6 | import { timestampNow } from '@/libs/utils';
7 | import { APIListArgsT, AbortFunctionT } from '@/types/CommonTypes';
8 | import { ComputePlanStubT } from '@/types/ComputePlansTypes';
9 |
10 | type ComputePlansStateT = {
11 | computePlans: ComputePlanStubT[];
12 | computePlansCount: number;
13 | computePlansCallTimestamp: string;
14 | fetchingComputePlans: boolean;
15 | fetchComputePlans: (params: APIListArgsT) => AbortFunctionT;
16 | };
17 |
18 | const useComputePlansStore = create((set) => ({
19 | computePlans: [],
20 | computePlansCount: 0,
21 | computePlansCallTimestamp: '',
22 | fetchingComputePlans: true,
23 | fetchComputePlans: withAbortSignal(async (signal, params) => {
24 | set({ fetchingComputePlans: true });
25 |
26 | try {
27 | const response = await listComputePlans(params, {
28 | signal,
29 | });
30 | set({
31 | fetchingComputePlans: false,
32 | computePlans: response.data.results,
33 | computePlansCount: response.data.count,
34 | computePlansCallTimestamp: timestampNow(),
35 | });
36 | } catch (error) {
37 | if (axios.isCancel(error)) {
38 | // do nothing, the call has been canceled voluntarily
39 | } else {
40 | console.warn(error);
41 | set({ fetchingComputePlans: false });
42 | }
43 | }
44 | }),
45 | }));
46 | export default useComputePlansStore;
47 |
--------------------------------------------------------------------------------
/charts/substra-frontend/values.yaml:
--------------------------------------------------------------------------------
1 | replicaCount: 1
2 |
3 | image:
4 | registry: ghcr.io
5 | repository: substra/substra-frontend
6 | tag: null # default to AppVersion
7 | pullPolicy: IfNotPresent
8 |
9 | imagePullSecrets: []
10 | nameOverride: ""
11 | fullnameOverride: ""
12 |
13 | serviceAccount:
14 | # Specifies whether a service account should be created
15 | create: true
16 | # Annotations to add to the service account
17 | annotations: {}
18 | # The name of the service account to use.
19 | # If not set and create is true, a name is generated using the fullname template
20 | name: ""
21 |
22 | podAnnotations: {}
23 |
24 | podSecurityContext:
25 | runAsNonRoot: true
26 | seccompProfile:
27 | type: RuntimeDefault
28 | fsGroup: 1000
29 | runAsUser: 1000
30 | runAsGroup: 1000
31 |
32 | securityContext:
33 | allowPrivilegeEscalation: false
34 | seccompProfile:
35 | type: RuntimeDefault
36 | capabilities:
37 | drop:
38 | - ALL
39 | readOnlyRootFilesystem: true
40 | runAsNonRoot: true
41 | runAsUser: 1000
42 |
43 | service:
44 | type: ClusterIP
45 | port: 80
46 | # nodePort: 30123
47 |
48 | ingress:
49 | enabled: false
50 | annotations: {}
51 | # kubernetes.io/ingress.class: nginx
52 | # kubernetes.io/tls-acme: "true"
53 | hosts:
54 | - host: chart-example.local
55 | paths: []
56 | tls: []
57 | # - secretName: chart-example-tls
58 | # hosts:
59 | # - chart-example.local
60 |
61 | resources:
62 | requests:
63 | memory: "200Mi"
64 | cpu: "100m"
65 | limits:
66 | memory: "800Mi"
67 | cpu: "100m"
68 |
69 |
70 | nodeSelector: {}
71 |
72 | tolerations: []
73 |
74 | affinity: {}
75 |
76 | api:
77 | url: "http://substra-backend.local:8000"
78 |
79 | microsoftClarity:
80 | id: ""
81 |
--------------------------------------------------------------------------------
/src/components/Breadcrumbs.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import styled from '@emotion/styled';
4 | import { Link } from 'wouter';
5 |
6 | import {
7 | Breadcrumb,
8 | BreadcrumbItem,
9 | BreadcrumbLink,
10 | HStack,
11 | } from '@chakra-ui/react';
12 |
13 | import IconTag, { IconTagProps } from '@/components/IconTag';
14 |
15 | const StyledBreadcrumb = styled(Breadcrumb)`
16 | ol {
17 | display: flex;
18 | align-items: center;
19 | }
20 | `;
21 |
22 | type BreadcrumbsProps = {
23 | rootIcon: IconTagProps['icon'];
24 | rootPath: string;
25 | rootLabel: string;
26 | children: React.ReactNode;
27 | };
28 |
29 | const Breadcrumbs = ({
30 | rootIcon,
31 | rootPath,
32 | rootLabel,
33 | children,
34 | }: BreadcrumbsProps): JSX.Element => (
35 |
41 |
42 |
43 |
49 |
50 |
55 | {rootLabel}
56 |
57 |
58 |
59 |
60 | {children}
61 |
62 | );
63 |
64 | export default Breadcrumbs;
65 |
--------------------------------------------------------------------------------
/src/features/tableFilters/TaskStatusTableFilter.tsx:
--------------------------------------------------------------------------------
1 | import { useTableFilterCallbackRefs } from '@/features/tableFilters/useTableFilters';
2 | import useSelection from '@/hooks/useSelection';
3 | import { useStatus } from '@/hooks/useSyncedState';
4 | import { getStatusLabel, getStatusDescription } from '@/libs/status';
5 | import { TaskStatus } from '@/types/TasksTypes';
6 |
7 | import TableFilterCheckboxes from './TableFilterCheckboxes';
8 |
9 | const TaskStatusTableFilter = (): JSX.Element => {
10 | const [tmpStatus, onTmpStatusChange, resetTmpStatus, setTmpStatus] =
11 | useSelection();
12 |
13 | const [activeStatus] = useStatus();
14 | const { clearRef, applyRef, resetRef } =
15 | useTableFilterCallbackRefs('status');
16 |
17 | clearRef.current = (urlSearchParams) => {
18 | resetTmpStatus();
19 | urlSearchParams.delete('status');
20 | };
21 |
22 | applyRef.current = (urlSearchParams) => {
23 | if (tmpStatus.length > 0) {
24 | urlSearchParams.set('status', tmpStatus.join(','));
25 | } else {
26 | urlSearchParams.delete('status');
27 | }
28 | };
29 |
30 | resetRef.current = () => {
31 | setTmpStatus(activeStatus);
32 | };
33 |
34 | const options = Object.values(TaskStatus).map((status) => ({
35 | value: status,
36 | label: getStatusLabel(status),
37 | description: getStatusDescription(status),
38 | }));
39 |
40 | return (
41 |
46 | );
47 | };
48 |
49 | TaskStatusTableFilter.filterTitle = 'Status';
50 | TaskStatusTableFilter.filterField = 'status';
51 |
52 | export default TaskStatusTableFilter;
53 |
--------------------------------------------------------------------------------
/src/routes/users/components/UsernameInput.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | import { FormControl, Input, FormErrorMessage } from '@chakra-ui/react';
4 |
5 | import { DrawerSectionEntry } from '@/components/DrawerSection';
6 |
7 | type UsernameInputProps = {
8 | value: string;
9 | onChange: (value: string) => void;
10 | hasErrors: boolean;
11 | setHasErrors: (hasErrors: boolean) => void;
12 | isDisabled?: boolean;
13 | };
14 |
15 | const UsernameInput = ({
16 | value,
17 | onChange,
18 | hasErrors,
19 | setHasErrors,
20 | isDisabled,
21 | }: UsernameInputProps): JSX.Element => {
22 | const [isDirty, setIsDirty] = useState(false);
23 |
24 | return (
25 |
26 |
30 | {
38 | onChange(e.target.value);
39 | setIsDirty(true);
40 | setHasErrors(e.target.value === '');
41 | }}
42 | data-cy="username-input"
43 | />
44 | {hasErrors && isDirty && (
45 |
46 | You must enter a username
47 |
48 | )}
49 |
50 |
51 | );
52 | };
53 |
54 | export default UsernameInput;
55 |
--------------------------------------------------------------------------------
/src/components/PermissionTag.tsx:
--------------------------------------------------------------------------------
1 | import { Tag, TagLabel, TagRightIcon, Wrap, WrapItem } from '@chakra-ui/react';
2 | import {
3 | RiGlobalLine,
4 | RiGroupLine,
5 | RiLockLine,
6 | RiUserLine,
7 | } from 'react-icons/ri';
8 |
9 | import { PermissionT } from '@/types/CommonTypes';
10 |
11 | type PermissionTagProps = {
12 | permission: PermissionT;
13 | listOrganizations?: boolean;
14 | };
15 | const PermissionTag = ({
16 | permission,
17 | listOrganizations,
18 | }: PermissionTagProps): JSX.Element => {
19 | if (permission.public) {
20 | return (
21 |
22 | Everybody
23 |
24 |
25 | );
26 | }
27 | if (permission.authorized_ids.length === 1) {
28 | return (
29 |
30 | Owner only
31 |
32 |
33 | );
34 | }
35 |
36 | if (listOrganizations) {
37 | return (
38 |
39 | {permission.authorized_ids.map((nodeId) => (
40 |
41 |
42 | {nodeId}
43 |
44 |
45 |
46 | ))}
47 |
48 | );
49 | } else {
50 | return (
51 |
52 | Restricted
53 |
54 |
55 | );
56 | }
57 | };
58 | export default PermissionTag;
59 |
--------------------------------------------------------------------------------
/src/routes/computePlans/components/StatusCell.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Flex,
3 | Popover,
4 | PopoverBody,
5 | PopoverContent,
6 | PopoverTrigger,
7 | Text,
8 | VStack,
9 | } from '@chakra-ui/react';
10 |
11 | import { ComputePlanT } from '@/types/ComputePlansTypes';
12 |
13 | import ComputePlanProgressBar from '@/components/ComputePlanProgressBar';
14 | import ComputePlanTaskStatuses from '@/components/ComputePlanTaskStatuses';
15 | import Status from '@/components/Status';
16 |
17 | type StatusCellProps = {
18 | computePlan: ComputePlanT;
19 | };
20 | const StatusCell = ({ computePlan }: StatusCellProps): JSX.Element => {
21 | return (
22 |
23 |
24 |
25 |
30 |
31 |
32 | {computePlan.done_count + computePlan.failed_count}/
33 | {computePlan.task_count}
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | );
46 | };
47 |
48 | export default StatusCell;
49 |
--------------------------------------------------------------------------------
/src/features/tableFilters/ComputePlanStatusTableFilter.tsx:
--------------------------------------------------------------------------------
1 | import { useTableFilterCallbackRefs } from '@/features/tableFilters/useTableFilters';
2 | import useSelection from '@/hooks/useSelection';
3 | import { useStatus } from '@/hooks/useSyncedState';
4 | import { getStatusLabel, getStatusDescription } from '@/libs/status';
5 | import { ComputePlanStatus } from '@/types/ComputePlansTypes';
6 |
7 | import TableFilterCheckboxes from './TableFilterCheckboxes';
8 |
9 | const ComputePlanStatusTableFilter = (): JSX.Element => {
10 | const [tmpStatus, onTmpStatusChange, resetTmpStatus, setTmpStatus] =
11 | useSelection();
12 |
13 | const [activeStatus] = useStatus();
14 | const { clearRef, applyRef, resetRef } =
15 | useTableFilterCallbackRefs('status');
16 |
17 | clearRef.current = (urlSearchParams) => {
18 | resetTmpStatus();
19 | urlSearchParams.delete('status');
20 | };
21 |
22 | applyRef.current = (urlSearchParams) => {
23 | if (tmpStatus.length > 0) {
24 | urlSearchParams.set('status', tmpStatus.join(','));
25 | } else {
26 | urlSearchParams.delete('status');
27 | }
28 | };
29 |
30 | resetRef.current = () => {
31 | setTmpStatus(activeStatus);
32 | };
33 |
34 | const options = Object.values(ComputePlanStatus).map((status) => ({
35 | value: status,
36 | label: getStatusLabel(status),
37 | description: getStatusDescription(status),
38 | }));
39 |
40 | return (
41 |
46 | );
47 | };
48 |
49 | ComputePlanStatusTableFilter.filterTitle = 'Status';
50 | ComputePlanStatusTableFilter.filterField = 'status';
51 |
52 | export default ComputePlanStatusTableFilter;
53 |
--------------------------------------------------------------------------------
/src/components/DrawerHeader.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Skeleton,
3 | DrawerHeader as ChakraDrawerHeader,
4 | Heading,
5 | IconButton,
6 | HStack,
7 | } from '@chakra-ui/react';
8 | import { RiDownloadLine } from 'react-icons/ri';
9 |
10 | type DrawerHeaderProps = {
11 | loading: boolean;
12 | title?: string;
13 | onClose: () => void;
14 | extraButtons?: React.ReactNode;
15 | updateNameButton?: React.ReactNode;
16 | updateNameDialog?: React.ReactNode;
17 | };
18 |
19 | const DrawerHeader = ({
20 | loading,
21 | title,
22 | onClose,
23 | extraButtons,
24 | updateNameButton,
25 | updateNameDialog,
26 | }: DrawerHeaderProps): JSX.Element => (
27 |
35 | {loading && }
36 | {!loading && (
37 |
44 | {title}
45 |
46 | )}
47 |
48 | {extraButtons}
49 | {updateNameButton}
50 | {updateNameDialog}
51 | }
58 | onClick={onClose}
59 | />
60 |
61 |
62 | );
63 |
64 | export default DrawerHeader;
65 |
--------------------------------------------------------------------------------
/e2e-tests/cypress/e2e/computePlans.cy.js:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | describe('Compute plans page', () => {
4 | before(() => {
5 | cy.login();
6 | });
7 |
8 | beforeEach(() => {
9 | cy.visit('/compute_plans');
10 | });
11 |
12 | it('lists compute plans', () => {
13 | cy.get('tbody[data-cy=loaded]')
14 | .get('tr')
15 | .should('have.length.greaterThan', 1);
16 | });
17 |
18 | it('displays tasks count bar in status/task column', () => {
19 | cy.get('tbody[data-cy=loaded]')
20 | .get('tr')
21 | .eq(1)
22 | .within(() => {
23 | cy.getDataCy('cp-tasks-status')
24 | .should('exist')
25 | .trigger('mouseover');
26 | cy.getDataCy('cp-tasks-status-tooltip').should('be.visible');
27 | });
28 | });
29 |
30 | it('searches CP with a key', () => {
31 | cy.checkSearchByKey('compute_plans');
32 | });
33 |
34 | it('adds a cp to favorites', () => {
35 | cy.getDataCy('favorite-cp').should('not.exist');
36 | cy.getDataCy('favorite-box').first().click();
37 | cy.getDataCy('favorite-cp').should('exist');
38 | });
39 |
40 | it('selects/unselects cp in list', () => {
41 | cy.getDataCy('selection-popover').should('not.exist');
42 | cy.get('[data-cy="selection-box"]>input')
43 | .first()
44 | .check({ force: true });
45 | cy.getDataCy('selection-popover').should('exist');
46 | cy.get('[data-cy="selection-box"]>input')
47 | .first()
48 | .uncheck({ force: true });
49 | cy.getDataCy('selection-popover').should('not.exist');
50 | });
51 |
52 | it('opens filters', () => {
53 | cy.checkOpenFilters(1);
54 | });
55 |
56 | it('can filter cps by status', () => {
57 | cy.checkFilterAssetsBy('status');
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/src/components/ComputePlanProgressBar.tsx:
--------------------------------------------------------------------------------
1 | import { Box, HStack } from '@chakra-ui/react';
2 |
3 | import { getStatusStyle } from '@/libs/status';
4 | import { getStatusCount } from '@/routes/computePlanDetails/ComputePlanUtils';
5 | import { ComputePlanT } from '@/types/ComputePlansTypes';
6 | import { TaskStatus, taskStatusOrder } from '@/types/TasksTypes';
7 |
8 | type ItemProps = {
9 | status: TaskStatus;
10 | count: number;
11 | total: number;
12 | };
13 | const Item = ({ status, count, total }: ItemProps): JSX.Element | null => {
14 | if (!count) {
15 | return null;
16 | }
17 |
18 | const percentage = Math.round((count / total) * 100);
19 |
20 | return (
21 |
26 | );
27 | };
28 |
29 | type ComputePlanProgressBarProps = {
30 | computePlan: ComputePlanT;
31 | };
32 |
33 | const ComputePlanProgressBar = ({
34 | computePlan,
35 | }: ComputePlanProgressBarProps): JSX.Element => {
36 | return (
37 |
44 | {!computePlan.task_count && (
45 |
50 | )}
51 | {taskStatusOrder.map((status) => (
52 |
58 | ))}
59 |
60 | );
61 | };
62 |
63 | export default ComputePlanProgressBar;
64 |
--------------------------------------------------------------------------------
/src/routes/tasks/components/TaskDurationBar.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | import { TaskExecutionRundownT } from '@/types/ProfilingTypes';
4 |
5 | import ProfilingDurationBar from '@/components/ProfilingDurationBar';
6 |
7 | import { taskStepsInfo } from '../TasksUtils';
8 | import useTaskStore from '../useTaskStore';
9 |
10 | const PROFILING_BAR_TOOLTIP =
11 | "This is an experimental feature. The sum of task's steps durations might not be equal to the task duration.";
12 |
13 | // Returns sum duration of all step currently done in seconds
14 | // Returns null if no step is done
15 | const getTaskDuration = (
16 | taskProfiling: TaskExecutionRundownT | null
17 | ): number | null => {
18 | if (!taskProfiling || taskProfiling.execution_rundown.length === 0) {
19 | return null;
20 | }
21 |
22 | return taskProfiling.execution_rundown.reduce(
23 | (taskDuration, step) => taskDuration + step.duration,
24 | 0
25 | );
26 | };
27 |
28 | const TaskDurationBar = ({
29 | taskKey,
30 | }: {
31 | taskKey: string | null | undefined;
32 | }): JSX.Element => {
33 | const { taskProfiling, fetchingTaskProfiling, fetchTaskProfiling } =
34 | useTaskStore();
35 |
36 | useEffect(() => {
37 | if (taskKey) {
38 | fetchTaskProfiling(taskKey);
39 | }
40 | }, [fetchTaskProfiling, taskKey]);
41 | const [taskDuration, setTaskDuration] = useState(null);
42 |
43 | useEffect(() => {
44 | setTaskDuration(getTaskDuration(taskProfiling));
45 | }, [taskProfiling]);
46 |
47 | return (
48 |
56 | );
57 | };
58 |
59 | export default TaskDurationBar;
60 |
--------------------------------------------------------------------------------
/src/routes/dataset/components/DetailsSidebar.tsx:
--------------------------------------------------------------------------------
1 | import { VStack } from '@chakra-ui/react';
2 |
3 | import {
4 | DrawerSection,
5 | DrawerSectionDateEntry,
6 | DrawerSectionKeyEntry,
7 | OrganizationDrawerSectionEntry,
8 | PermissionsDrawerSectionEntry,
9 | } from '@/components/DrawerSection';
10 | import MetadataDrawerSection from '@/components/MetadataDrawerSection';
11 |
12 | import useDatasetStore from '../useDatasetStore';
13 | import DataSamplesDrawerSection from './DataSamplesDrawerSection';
14 |
15 | const DetailsSidebar = (): JSX.Element => {
16 | const { dataset } = useDatasetStore();
17 | return (
18 |
19 |
20 | {dataset && (
21 | <>
22 |
23 |
27 |
31 |
34 |
38 | >
39 | )}
40 |
41 | {dataset && }
42 | {dataset && (
43 |
44 | )}
45 |
46 | );
47 | };
48 |
49 | export default DetailsSidebar;
50 |
--------------------------------------------------------------------------------
/src/features/perfBrowser/usePerfBrowserColors.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useContext } from 'react';
2 |
3 | import chakraTheme from '@/assets/chakraTheme';
4 | import { PerfBrowserContext } from '@/features/perfBrowser/usePerfBrowser';
5 |
6 | type ColorDiscriminantProps = {
7 | computePlanKey: string;
8 | worker: string;
9 | };
10 |
11 | export const PERF_BROWSER_COLORSCHEMES = [
12 | 'primary',
13 | 'orange',
14 | 'green',
15 | 'pink',
16 | 'yellow',
17 | 'cyan',
18 | 'red',
19 | 'purple',
20 | 'blue',
21 | ];
22 |
23 | const usePerfBrowserColors = () => {
24 | const { colorMode, computePlans, organizations } =
25 | useContext(PerfBrowserContext);
26 |
27 | const getColorScheme = useCallback(
28 | ({ computePlanKey, worker }: ColorDiscriminantProps): string => {
29 | let index = 0;
30 | if (colorMode === 'computePlan') {
31 | index = computePlans.findIndex(
32 | (computePlan) => computePlan.key === computePlanKey
33 | );
34 | } else {
35 | index = organizations.findIndex(
36 | (organization) => organization.id === worker
37 | );
38 | }
39 |
40 | if (index === -1) {
41 | return 'primary';
42 | }
43 | return PERF_BROWSER_COLORSCHEMES[
44 | index % PERF_BROWSER_COLORSCHEMES.length
45 | ];
46 | },
47 | [colorMode, computePlans, organizations]
48 | );
49 |
50 | const getColor = useCallback(
51 | (
52 | colorDiscriminant: ColorDiscriminantProps,
53 | intensity: string
54 | ): string => {
55 | return chakraTheme.colors[getColorScheme(colorDiscriminant)][
56 | intensity
57 | ];
58 | },
59 | [getColorScheme]
60 | );
61 |
62 | return {
63 | getColorScheme,
64 | getColor,
65 | };
66 | };
67 |
68 | export default usePerfBrowserColors;
69 |
--------------------------------------------------------------------------------
/charts/substra-frontend-tests/values.yaml:
--------------------------------------------------------------------------------
1 | replicaCount: 1
2 |
3 | image:
4 | registry: ghcr.io
5 | repository: substra/substra-frontend
6 | tag: null # defaults to AppVersion
7 | pullPolicy: IfNotPresent
8 |
9 | imagePullSecrets: []
10 | nameOverride: ""
11 | fullnameOverride: ""
12 |
13 | serviceAccount:
14 | # Specifies whether a service account should be created
15 | create: true
16 | # Annotations to add to the service account
17 | annotations: {}
18 | # The name of the service account to use.
19 | # If not set and create is true, a name is generated using the fullname template
20 | name: ""
21 |
22 | podAnnotations: {}
23 |
24 | podSecurityContext: {}
25 | # fsGroup: 2000
26 |
27 | securityContext: {}
28 | # capabilities:
29 | # drop:
30 | # - ALL
31 | # readOnlyRootFilesystem: true
32 | # runAsNonRoot: true
33 | # runAsUser: 1000
34 |
35 | service:
36 | type: ClusterIP
37 | port: 80
38 |
39 | ingress:
40 | enabled: false
41 | annotations: {}
42 | # kubernetes.io/ingress.class: nginx
43 | # kubernetes.io/tls-acme: "true"
44 | hosts:
45 | - host: chart-example.local
46 | paths: []
47 | tls: []
48 | # - secretName: chart-example-tls
49 | # hosts:
50 | # - chart-example.local
51 |
52 | resources: {}
53 | # We usually recommend not to specify default resources and to leave this as a conscious
54 | # choice for the user. This also increases chances charts run on environments with little
55 | # resources, such as Minikube. If you do want to specify resources, uncomment the following
56 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'.
57 | # limits:
58 | # cpu: 100m
59 | # memory: 128Mi
60 | # requests:
61 | # cpu: 100m
62 | # memory: 128Mi
63 |
64 | nodeSelector: {}
65 |
66 | tolerations: []
67 |
68 | affinity: {}
69 |
70 |
71 | cypress:
72 | config:
73 | baseUrl: "http://frontend"
74 | env:
75 | USERNAME: "org-1"
76 | PASSWORD: "p@sswr0d44"
77 | BACKEND_API_URL: "http://backend"
78 | video: false
79 | defaultCommandTimeout: 20000
80 |
81 | screenshotsPvc:
82 | enabled: false
83 | retrieverEnabled: false
84 |
--------------------------------------------------------------------------------
/charts/substra-frontend/templates/_helpers.tpl:
--------------------------------------------------------------------------------
1 | {{/*
2 | Expand the name of the chart.
3 | */}}
4 | {{- define "substra-frontend.name" -}}
5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
6 | {{- end }}
7 |
8 | {{/*
9 | Create a default fully qualified app name.
10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
11 | If release name contains chart name it will be used as a full name.
12 | */}}
13 | {{- define "substra-frontend.fullname" -}}
14 | {{- if .Values.fullnameOverride }}
15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
16 | {{- else }}
17 | {{- $name := default .Chart.Name .Values.nameOverride }}
18 | {{- if contains $name .Release.Name }}
19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }}
20 | {{- else }}
21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
22 | {{- end }}
23 | {{- end }}
24 | {{- end }}
25 |
26 | {{/*
27 | Create chart name and version as used by the chart label.
28 | */}}
29 | {{- define "substra-frontend.chart" -}}
30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
31 | {{- end }}
32 |
33 | {{/*
34 | Common labels
35 | */}}
36 | {{- define "substra-frontend.labels" -}}
37 | helm.sh/chart: {{ include "substra-frontend.chart" . }}
38 | {{ include "substra-frontend.selectorLabels" . }}
39 | {{- if .Chart.AppVersion }}
40 | app.kubernetes.io/version: {{ .Chart.AppVersion | default .Chart.Version | replace "+" "_" | quote }}
41 | {{- end }}
42 | app.kubernetes.io/managed-by: {{ .Release.Service }}
43 | {{- end }}
44 |
45 | {{/*
46 | Selector labels
47 | */}}
48 | {{- define "substra-frontend.selectorLabels" -}}
49 | app.kubernetes.io/name: {{ include "substra-frontend.name" . }}
50 | app.kubernetes.io/instance: {{ .Release.Name }}
51 | {{- end }}
52 |
53 | {{/*
54 | Create the name of the service account to use
55 | */}}
56 | {{- define "substra-frontend.serviceAccountName" -}}
57 | {{- if .Values.serviceAccount.create }}
58 | {{- default (include "substra-frontend.fullname" .) .Values.serviceAccount.name }}
59 | {{- else }}
60 | {{- default "default" .Values.serviceAccount.name }}
61 | {{- end }}
62 | {{- end }}
63 |
--------------------------------------------------------------------------------
/src/routes/tokens/components/NewTokenAlert.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Alert,
3 | AlertIcon,
4 | Box,
5 | AlertTitle,
6 | AlertDescription,
7 | HStack,
8 | Text,
9 | AlertProps,
10 | } from '@chakra-ui/react';
11 | import { RiInformationLine } from 'react-icons/ri';
12 |
13 | import CopyIconButton from '@/features/copy/CopyIconButton';
14 |
15 | type NewTokenAlertProps = AlertProps & {
16 | tokenKey: string;
17 | };
18 |
19 | const NewTokenAlert = ({
20 | tokenKey,
21 | ...props
22 | }: NewTokenAlertProps): JSX.Element => {
23 | return (
24 |
32 |
33 |
34 |
35 |
36 | Your token has been generated!
37 |
38 |
39 | Make sure to copy your personal access token now. You won’t
40 | be able to see it again!
41 |
42 |
49 |
50 |
51 | {tokenKey}
52 |
53 |
54 |
55 |
60 |
61 |
62 |
63 | );
64 | };
65 |
66 | export default NewTokenAlert;
67 |
--------------------------------------------------------------------------------
/src/assets/Fonts.tsx:
--------------------------------------------------------------------------------
1 | import { Global } from '@emotion/react';
2 |
3 | const Fonts = () => (
4 |
58 | );
59 |
60 | export default Fonts;
61 |
--------------------------------------------------------------------------------
/charts/substra-frontend-tests/templates/_helpers.tpl:
--------------------------------------------------------------------------------
1 | {{/*
2 | Expand the name of the chart.
3 | */}}
4 | {{- define "substra-frontend-tests.name" -}}
5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
6 | {{- end }}
7 |
8 | {{/*
9 | Create a default fully qualified app name.
10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
11 | If release name contains chart name it will be used as a full name.
12 | */}}
13 | {{- define "substra-frontend-tests.fullname" -}}
14 | {{- if .Values.fullnameOverride }}
15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
16 | {{- else }}
17 | {{- $name := default .Chart.Name .Values.nameOverride }}
18 | {{- if contains $name .Release.Name }}
19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }}
20 | {{- else }}
21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
22 | {{- end }}
23 | {{- end }}
24 | {{- end }}
25 |
26 | {{/*
27 | Create chart name and version as used by the chart label.
28 | */}}
29 | {{- define "substra-frontend-tests.chart" -}}
30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
31 | {{- end }}
32 |
33 | {{/*
34 | Common labels
35 | */}}
36 | {{- define "substra-frontend-tests.labels" -}}
37 | helm.sh/chart: {{ include "substra-frontend-tests.chart" . }}
38 | {{ include "substra-frontend-tests.selectorLabels" . }}
39 | {{- if .Chart.AppVersion }}
40 | app.kubernetes.io/version: {{ .Chart.AppVersion | default .Chart.Version | replace "+" "_" | quote }}
41 | {{- end }}
42 | app.kubernetes.io/managed-by: {{ .Release.Service }}
43 | {{- end }}
44 |
45 | {{/*
46 | Selector labels
47 | */}}
48 | {{- define "substra-frontend-tests.selectorLabels" -}}
49 | app.kubernetes.io/name: {{ include "substra-frontend-tests.name" . }}
50 | app.kubernetes.io/instance: {{ .Release.Name }}
51 | {{- end }}
52 |
53 | {{/*
54 | Create the name of the service account to use
55 | */}}
56 | {{- define "substra-frontend-tests.serviceAccountName" -}}
57 | {{- if .Values.serviceAccount.create }}
58 | {{- default (include "substra-frontend-tests.fullname" .) .Values.serviceAccount.name }}
59 | {{- else }}
60 | {{- default "default" .Values.serviceAccount.name }}
61 | {{- end }}
62 | {{- end }}
63 |
--------------------------------------------------------------------------------
/src/features/perfBrowser/PerfSidebarSettingsUnits.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react';
2 |
3 | import { Box, Flex, Heading, Text, Select } from '@chakra-ui/react';
4 |
5 | import {
6 | PerfBrowserContext,
7 | XAxisModeT,
8 | YAxisModeT,
9 | } from '@/features/perfBrowser/usePerfBrowser';
10 |
11 | const PerfSidebarSettingsUnits = (): JSX.Element => {
12 | const {
13 | xAxisMode,
14 | setXAxisMode,
15 | yAxisMode,
16 | setYAxisMode,
17 | seriesGroupsWithRounds,
18 | } = useContext(PerfBrowserContext);
19 |
20 | return (
21 |
22 |
23 | Parameters
24 |
25 |
26 | X axis
27 | setXAxisMode(e.target.value as XAxisModeT)}
34 | >
35 | Ranks
36 |
40 | Rounds
41 |
42 |
43 |
44 |
45 | Y axis
46 | setYAxisMode(e.target.value as YAxisModeT)}
53 | >
54 | Linear
55 | Logarithmic
56 |
57 |
58 |
59 | );
60 | };
61 |
62 | export default PerfSidebarSettingsUnits;
63 |
--------------------------------------------------------------------------------
/src/features/tableFilters/ComputePlanFavoritesTableFilter.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | import { Box, Checkbox, Text } from '@chakra-ui/react';
4 |
5 | import { useTableFilterCallbackRefs } from '@/features/tableFilters/useTableFilters';
6 | import { useFavoritesOnly } from '@/hooks/useSyncedState';
7 |
8 | const ComputePlanFavoritesTableFilter = ({
9 | favorites,
10 | }: {
11 | favorites: string[];
12 | }): JSX.Element => {
13 | const [tmpFavoritesOnly, setTmpFavoritesOnly] = useState(false);
14 |
15 | const [activeFavoritesOnly] = useFavoritesOnly();
16 | const { clearRef, applyRef, resetRef } =
17 | useTableFilterCallbackRefs('favorites');
18 |
19 | clearRef.current = (urlSearchParams) => {
20 | urlSearchParams.delete('favorites_only');
21 | };
22 |
23 | applyRef.current = (urlSearchParams) => {
24 | if (tmpFavoritesOnly) {
25 | urlSearchParams.set('favorites_only', '1');
26 | } else {
27 | urlSearchParams.delete('favorites_only');
28 | }
29 | };
30 |
31 | resetRef.current = () => {
32 | setTmpFavoritesOnly(activeFavoritesOnly);
33 | };
34 |
35 | const onChange = () => {
36 | setTmpFavoritesOnly(!tmpFavoritesOnly);
37 | };
38 |
39 | return (
40 |
41 |
42 | Filter by
43 |
44 |
50 |
51 | Favorites Only
52 |
53 |
54 | {!favorites.length && (
55 |
56 | You currently have no favorite compute plan
57 |
58 | )}
59 |
60 | );
61 | };
62 |
63 | ComputePlanFavoritesTableFilter.filterTitle = 'Favorites';
64 | ComputePlanFavoritesTableFilter.filterField = 'favorites_only';
65 |
66 | export default ComputePlanFavoritesTableFilter;
67 |
--------------------------------------------------------------------------------
/src/routes/dataset/components/DataSamplesDrawerSection.tsx:
--------------------------------------------------------------------------------
1 | import { HStack, Text } from '@chakra-ui/react';
2 | import { RiDatabase2Fill } from 'react-icons/ri';
3 |
4 | import CopyIconButton from '@/features/copy/CopyIconButton';
5 |
6 | import DownloadIconButton from '@/components/DownloadIconButton';
7 | import {
8 | DrawerSection,
9 | DrawerSectionEntryWrapper,
10 | } from '@/components/DrawerSection';
11 | import IconTag from '@/components/IconTag';
12 |
13 | type DataSamplesDrawerSectionProps = {
14 | keys: string[];
15 | };
16 | const DataSamplesDrawerSection = ({
17 | keys,
18 | }: DataSamplesDrawerSectionProps): JSX.Element => {
19 | const keysAsJson = JSON.stringify(keys);
20 | const keysAsBlob = new Blob([keysAsJson], { type: 'application/json' });
21 | return (
22 |
23 |
28 |
29 |
34 | {`${keys.length} data samples`}
35 |
36 | {keys.length > 0 && (
37 |
38 |
44 |
50 |
51 | )}
52 |
53 |
54 | );
55 | };
56 | export default DataSamplesDrawerSection;
57 |
--------------------------------------------------------------------------------
/src/features/perfBrowser/PerfDetails.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react';
2 |
3 | import { Button, Flex, Kbd } from '@chakra-ui/react';
4 | import { RiArrowLeftLine } from 'react-icons/ri';
5 |
6 | import PerfChart from '@/features/perfBrowser/PerfChart';
7 | import PerfEmptyState from '@/features/perfBrowser/PerfEmptyState';
8 | import { PerfBrowserContext } from '@/features/perfBrowser/usePerfBrowser';
9 | import { useKeyPress } from '@/hooks/useKeyPress';
10 | import { SerieT } from '@/types/SeriesTypes';
11 |
12 | type PerfDetailsProps = {
13 | series: SerieT[];
14 | };
15 |
16 | const PerfDetails = ({ series }: PerfDetailsProps): JSX.Element => {
17 | const { perfChartRef, setSelectedIdentifier } =
18 | useContext(PerfBrowserContext);
19 |
20 | const resetSelectedMetric = () => {
21 | setSelectedIdentifier('');
22 | };
23 |
24 | useKeyPress('Escape', () => resetSelectedMetric());
25 |
26 | return (
27 |
37 | 0 ? [series] : []} />
38 | {series.length > 0 && (
39 | <>
40 |
45 | }
53 | rightIcon={Esc }
54 | onClick={() => resetSelectedMetric()}
55 | >
56 | Go back
57 |
58 | >
59 | )}
60 |
61 | );
62 | };
63 |
64 | export default PerfDetails;
65 |
--------------------------------------------------------------------------------
/src/routes/compare/components/CompareBreadcrumbs.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react';
2 |
3 | import { BreadcrumbItem, HStack, Text, Link } from '@chakra-ui/react';
4 | import { RiStackshareLine } from 'react-icons/ri';
5 |
6 | import { PerfBrowserContext } from '@/features/perfBrowser/usePerfBrowser';
7 | import { PATHS } from '@/paths';
8 |
9 | import Breadcrumbs from '@/components/Breadcrumbs';
10 |
11 | const CompareBreadcrumbs = (): JSX.Element => {
12 | const { selectedIdentifier, setSelectedIdentifier } =
13 | useContext(PerfBrowserContext);
14 |
15 | return (
16 |
21 |
22 |
23 | {selectedIdentifier ? (
24 | setSelectedIdentifier('')}
30 | >
31 | Comparison
32 |
33 | ) : (
34 |
40 | Comparison
41 |
42 | )}
43 |
44 |
45 | {selectedIdentifier && (
46 |
47 |
53 | {selectedIdentifier}
54 |
55 |
56 | )}
57 |
58 | );
59 | };
60 |
61 | export default CompareBreadcrumbs;
62 |
--------------------------------------------------------------------------------
/src/features/tableFilters/TableFilterCheckboxes.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Checkbox, VStack, Text } from '@chakra-ui/react';
2 |
3 | type OptionT = { value: string; label: string; description?: string } | string;
4 |
5 | export const getOptionValue = (option: OptionT) =>
6 | typeof option === 'string' ? option : option.value;
7 |
8 | export const getOptionLabel = (option: OptionT) =>
9 | typeof option === 'string' ? option : option.label;
10 |
11 | export const getOptionDescription = (option: OptionT) =>
12 | typeof option === 'string' ? null : option.description;
13 |
14 | type TableFilterCheckboxesProps = {
15 | title?: string;
16 | value: string[];
17 | options: OptionT[];
18 | onChange: (
19 | value: string
20 | ) => (event: React.ChangeEvent) => void;
21 | };
22 |
23 | const TableFilterCheckboxes = ({
24 | title,
25 | value,
26 | onChange,
27 | options,
28 | }: TableFilterCheckboxesProps): JSX.Element => {
29 | return (
30 |
31 |
32 | {title ?? 'Filter by'}
33 |
34 |
35 | {options.map((option) => (
36 |
45 |
46 | {getOptionLabel(option)}
47 |
48 | {getOptionDescription(option) && (
49 |
50 | {getOptionDescription(option)}
51 |
52 | )}
53 |
54 | ))}
55 |
56 |
57 | );
58 | };
59 |
60 | export default TableFilterCheckboxes;
61 |
--------------------------------------------------------------------------------
/src/hooks/useLocationWithParams.ts:
--------------------------------------------------------------------------------
1 | import { useLocation } from 'wouter';
2 |
3 | export const getUrlSearchParams = (): URLSearchParams =>
4 | new URLSearchParams(window.location.search);
5 |
6 | type SetLocationWithParamsProps = (
7 | to: string,
8 | params: URLSearchParams,
9 | options?: { replace?: boolean }
10 | ) => void;
11 |
12 | const useLocationWithParams = (): [string, SetLocationWithParamsProps] => {
13 | const [location, setLocation] = useLocation();
14 | const setLocationWithParams: SetLocationWithParamsProps = (
15 | to,
16 | params,
17 | options
18 | ) => {
19 | setLocation(`${to}?${params.toString()}`, options);
20 | };
21 | return [location, setLocationWithParams];
22 | };
23 |
24 | export const useSetLocationParams = (): ((
25 | urlSearchParams: URLSearchParams
26 | ) => void) => {
27 | const [, setLocationWithParams] = useLocationWithParams();
28 |
29 | const setLocationParams = (urlSearchParams: URLSearchParams) => {
30 | setLocationWithParams(window.location.pathname, urlSearchParams, {
31 | replace: true,
32 | });
33 | };
34 |
35 | return setLocationParams;
36 | };
37 |
38 | export const useSetLocationPreserveParams = () => {
39 | const [, setLocationWithParams] = useLocationWithParams();
40 |
41 | const setLocationPreserveParams = (to: string): void => {
42 | setLocationWithParams(to, getUrlSearchParams());
43 | };
44 |
45 | return setLocationPreserveParams;
46 | };
47 |
48 | export const useHrefLocation = (): [string, (path: string) => void] => {
49 | const [location, setLocation] = useLocation();
50 | const setLocationParams = useSetLocationParams();
51 |
52 | const setHrefLocation = (path: string) => {
53 | if (path === location) {
54 | const urlSearchParams = getUrlSearchParams();
55 | const newUrlSearchParams = new URLSearchParams();
56 | newUrlSearchParams.set('page', '1');
57 | const ordering = urlSearchParams.get('ordering');
58 | if (ordering) {
59 | newUrlSearchParams.set('ordering', ordering);
60 | }
61 | setLocationParams(newUrlSearchParams);
62 | } else {
63 | setLocation(path);
64 | }
65 | };
66 |
67 | return [location, setHrefLocation];
68 | };
69 |
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------