Skip to content

Commit c2e017f

Browse files
Add developer modal for per-service environment overrides
Adds a hidden Ctrl+Shift+E shortcut (AICS-network users only) that opens a modal for pointing individual services at a different environment for the current session, e.g. FSS2 at staging while everything else stays on production. Overrides layer on top of the app-wide environment; services left at the default continue to follow it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b3bf416 commit c2e017f

8 files changed

Lines changed: 317 additions & 17 deletions

File tree

packages/core/App.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ import { useDispatch, useSelector } from "react-redux";
77
import ContextMenu from "./components/ContextMenu";
88
import CoreContent from "./components/CoreContent/CoreContent";
99
import FileDetailPanel from "./components/FileDetailPanel";
10-
import Modal from "./components/Modal";
10+
import Modal, { ModalType } from "./components/Modal";
1111
import StatusMessage from "./components/StatusMessage";
1212
import TutorialTooltip from "./components/TutorialTooltip";
1313
import { Environment } from "./constants";
1414
import useCheckForScreenSizeChange from "./hooks/useCheckForScreenSizeChange";
1515
import useCheckForUpdates from "./hooks/useCheckForUpdates";
16+
import useKeyDown from "./hooks/useKeyDown";
1617
import useUpdateHasUsedApp from "./hooks/useUpdateHasUsedApp";
1718
import useLayoutMeasurements from "./hooks/useLayoutMeasurements";
1819
import useUnsavedDataWarning from "./hooks/useUnsavedDataWarning";
@@ -45,6 +46,8 @@ export default function App(props: AppProps) {
4546

4647
const dispatch = useDispatch();
4748
const shouldDisplaySmallFont = useSelector(selection.selectors.getShouldDisplaySmallFont);
49+
const isAicsEmployee = useSelector(interaction.selectors.isAicsEmployee);
50+
const visibleModal = useSelector(interaction.selectors.getVisibleModal);
4851

4952
const [measuredNodeRef, _measuredHeight, measuredWidth] = useLayoutMeasurements<
5053
HTMLDivElement
@@ -55,6 +58,17 @@ export default function App(props: AppProps) {
5558
useUnsavedDataWarning();
5659
useCheckForScreenSizeChange(measuredWidth);
5760

61+
// Developer shortcut for pointing services at other environments;
62+
useKeyDown(["Control", "Shift", "e"], (event) => {
63+
if (!isAicsEmployee) return;
64+
event.preventDefault();
65+
if (visibleModal === ModalType.EnvironmentSwitch) {
66+
dispatch(interaction.actions.hideVisibleModal());
67+
} else {
68+
dispatch(interaction.actions.setVisibleModal(ModalType.EnvironmentSwitch));
69+
}
70+
});
71+
5872
// Set data source base urls
5973
React.useEffect(() => {
6074
dispatch(
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
.text {
2+
font-size: var(--l-paragraph-size);
3+
margin: 0 0 16px 0;
4+
}
5+
6+
.all-services-row {
7+
border-bottom: 1px solid var(--border-color);
8+
margin-bottom: 16px;
9+
padding-bottom: 16px;
10+
}
11+
12+
.service-row {
13+
margin-bottom: 12px;
14+
}
15+
16+
.url {
17+
color: var(--secondary-text-color);
18+
display: block;
19+
font-size: var(--s-paragraph-size);
20+
margin-top: 2px;
21+
word-break: break-all;
22+
}
23+
24+
.footer {
25+
display: flex;
26+
gap: 8px;
27+
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import * as React from "react";
2+
import { useDispatch, useSelector } from "react-redux";
3+
4+
import { ModalProps } from "..";
5+
import BaseModal from "../BaseModal";
6+
import { PrimaryButton, SecondaryButton } from "../../Buttons";
7+
import ComboBox from "../../ComboBox";
8+
import {
9+
CellFeatureExplorerBaseUrl,
10+
DatasetBucketUrl,
11+
Environment,
12+
EnvironmentOverrides,
13+
FESBaseUrl,
14+
FileStorageServiceBaseUrl,
15+
LabKeyBaseUrl,
16+
MMSBaseUrl,
17+
OverridableService,
18+
TemporaryFileServiceBaseUrl,
19+
VolEBaseUrl,
20+
} from "../../../constants";
21+
import { interaction } from "../../../state";
22+
23+
import styles from "./EnvironmentSwitch.module.css";
24+
25+
interface ServiceDescriptor {
26+
key: OverridableService;
27+
label: string;
28+
urls: Record<Environment, string>;
29+
}
30+
31+
const SERVICES: ServiceDescriptor[] = [
32+
{
33+
key: OverridableService.FileExplorerService,
34+
label: "File Explorer Service (FES)",
35+
urls: FESBaseUrl,
36+
},
37+
{
38+
key: OverridableService.MetadataManagementService,
39+
label: "Metadata Management Service (MMS)",
40+
urls: MMSBaseUrl,
41+
},
42+
{
43+
key: OverridableService.FileStorageService,
44+
label: "File Storage Service (FSS2)",
45+
urls: FileStorageServiceBaseUrl,
46+
},
47+
{
48+
key: OverridableService.LabKey,
49+
label: "LabKey (Plate UI links)",
50+
urls: LabKeyBaseUrl,
51+
},
52+
{
53+
key: OverridableService.DatasetBucket,
54+
label: "Dataset Bucket (S3)",
55+
urls: DatasetBucketUrl,
56+
},
57+
{
58+
key: OverridableService.CellFeatureExplorer,
59+
label: "Cell Feature Explorer",
60+
urls: CellFeatureExplorerBaseUrl,
61+
},
62+
{
63+
key: OverridableService.VolE,
64+
label: "Vol-E Viewer",
65+
urls: VolEBaseUrl,
66+
},
67+
{
68+
key: OverridableService.TemporaryFileService,
69+
label: "Temporary File Service",
70+
urls: TemporaryFileServiceBaseUrl,
71+
},
72+
];
73+
74+
const ENVIRONMENT_OPTIONS = Object.values(Environment).map((env) => ({
75+
key: env,
76+
text: env,
77+
}));
78+
79+
/**
80+
* Developer tool for pointing one or many services at a non-default environment
81+
* (e.g., staging). Opened via keyboard shortcut; applies to the current session only.
82+
*/
83+
export default function EnvironmentSwitch({ onDismiss }: ModalProps) {
84+
const dispatch = useDispatch();
85+
const environment = useSelector(interaction.selectors.getEnvironment) as Environment;
86+
const overrides = useSelector(interaction.selectors.getEnvironmentOverrides);
87+
88+
const [draft, setDraft] = React.useState<Record<OverridableService, Environment>>(() =>
89+
SERVICES.reduce(
90+
(accum, service) => ({
91+
...accum,
92+
[service.key]: overrides[service.key] ?? environment,
93+
}),
94+
{} as Record<OverridableService, Environment>
95+
)
96+
);
97+
98+
const hasChanges = SERVICES.some(
99+
(service) => draft[service.key] !== (overrides[service.key] ?? environment)
100+
);
101+
102+
// Selected key for the "all services" shortcut; null when services differ
103+
const environmentForAllServices = SERVICES.every(
104+
(service) => draft[service.key] === draft[SERVICES[0].key]
105+
)
106+
? draft[SERVICES[0].key]
107+
: null;
108+
109+
const onSelectAll = (env: Environment) => {
110+
setDraft(
111+
SERVICES.reduce(
112+
(accum, service) => ({ ...accum, [service.key]: env }),
113+
{} as Record<OverridableService, Environment>
114+
)
115+
);
116+
};
117+
118+
const onSelectService = (key: OverridableService, env: Environment) => {
119+
setDraft((prev) => ({ ...prev, [key]: env }));
120+
};
121+
122+
const onApply = () => {
123+
// Only persist actual deviations from the app-wide environment so that
124+
// non-overridden services continue to follow it
125+
const newOverrides: EnvironmentOverrides = {};
126+
SERVICES.forEach((service) => {
127+
if (draft[service.key] !== environment) {
128+
newOverrides[service.key] = draft[service.key];
129+
}
130+
});
131+
dispatch(interaction.actions.setEnvironmentOverrides(newOverrides));
132+
dispatch(interaction.actions.refresh());
133+
onDismiss();
134+
};
135+
136+
const body = (
137+
<div>
138+
<p className={styles.text}>
139+
Point individual services at a different environment for this session. Services left
140+
at the app default (<strong>{environment}</strong>) will continue to follow it.
141+
</p>
142+
<div className={styles.allServicesRow}>
143+
<ComboBox
144+
label="All services"
145+
placeholder="(mixed)"
146+
options={ENVIRONMENT_OPTIONS}
147+
selectedKey={environmentForAllServices}
148+
onChange={(option) => option && onSelectAll(option.key as Environment)}
149+
/>
150+
</div>
151+
{SERVICES.map((service) => (
152+
<div className={styles.serviceRow} key={service.key}>
153+
<ComboBox
154+
label={service.label}
155+
placeholder=""
156+
options={ENVIRONMENT_OPTIONS}
157+
selectedKey={draft[service.key]}
158+
onChange={(option) =>
159+
option && onSelectService(service.key, option.key as Environment)
160+
}
161+
/>
162+
<code className={styles.url}>{service.urls[draft[service.key]]}</code>
163+
</div>
164+
))}
165+
</div>
166+
);
167+
168+
const footer = (
169+
<div className={styles.footer}>
170+
<SecondaryButton
171+
text="RESET TO DEFAULT"
172+
title={`Reset all services to ${environment}`}
173+
onClick={() => onSelectAll(environment)}
174+
/>
175+
<PrimaryButton
176+
disabled={!hasChanges}
177+
text="APPLY"
178+
title="Apply environment changes and refresh"
179+
onClick={onApply}
180+
/>
181+
</div>
182+
);
183+
184+
return (
185+
<BaseModal
186+
body={body}
187+
footer={footer}
188+
onDismiss={onDismiss}
189+
title="Service environments (dev)"
190+
/>
191+
);
192+
}

packages/core/components/Modal/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import QueryCodeSnippet from "./QueryCodeSnippet";
88
import CopyFileManifest from "./CopyFileManifest";
99
import DataSource from "./DataSource";
1010
import EditMetadata from "./EditMetadata";
11+
import EnvironmentSwitch from "./EnvironmentSwitch";
1112
import ExtractMetadataCodeSnippet from "./ExtractMetadataCodeSnippet";
1213
import MetadataManifest from "./MetadataManifest";
1314
import SmallScreenWarning from "./SmallScreenWarning";
@@ -28,6 +29,7 @@ export enum ModalType {
2829
ExtractMetadataCodeSnippet = 8,
2930
ConvertFiles = 9,
3031
ComputePipeline = 10,
32+
EnvironmentSwitch = 11,
3133
}
3234

3335
/**
@@ -62,6 +64,8 @@ export default function Modal() {
6264
return <ComputePipelineModal onDismiss={onDismiss} />;
6365
case ModalType.ConvertFiles:
6466
return <ConvertFiles onDismiss={onDismiss} />;
67+
case ModalType.EnvironmentSwitch:
68+
return <EnvironmentSwitch onDismiss={onDismiss} />;
6569
default:
6670
return null;
6771
}

packages/core/constants/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,5 +125,20 @@ export enum TemporaryFileServiceBaseUrl {
125125
TEST = "http://dev-aics-dtp-001.corp.alleninstitute.org:8080",
126126
}
127127

128+
// Services whose environment can be individually overridden by developers
129+
// via the environment switch modal (Ctrl+Shift+E)
130+
export enum OverridableService {
131+
CellFeatureExplorer = "cellFeatureExplorer",
132+
DatasetBucket = "datasetBucket",
133+
FileExplorerService = "fileExplorerService",
134+
FileStorageService = "fileStorageService",
135+
LabKey = "labKey",
136+
MetadataManagementService = "metadataManagementService",
137+
TemporaryFileService = "temporaryFileService",
138+
VolE = "volE",
139+
}
140+
141+
export type EnvironmentOverrides = Partial<Record<OverridableService, Environment>>;
142+
128143
export const UNSAVED_DATA_WARNING =
129144
"Edits to data source files are not preserved by this app. Download to save a copy with your changes.";

packages/core/state/interaction/actions.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { uniqueId } from "lodash";
44
import { ContextMenuItem, PositionReference } from "../../components/ContextMenu";
55
import { DataSourceType } from "../../components/DataSourcePrompt";
66
import { ModalType } from "../../components/Modal";
7+
import { EnvironmentOverrides } from "../../constants";
78
import FileFilter from "../../entity/FileFilter";
89
import { AnnotationValue } from "../../entity/Annotation";
910
import FileDetail from "../../entity/FileDetail";
@@ -290,6 +291,29 @@ export const initializeApp = (payload: { environment: string }) => ({
290291
payload,
291292
});
292293

294+
/**
295+
* SET_ENVIRONMENT_OVERRIDES
296+
*
297+
* Intention to override the environment used by individual services. Developer
298+
* tool for pointing one or many services at a non-default environment (e.g., staging).
299+
*/
300+
export const SET_ENVIRONMENT_OVERRIDES = makeConstant(
301+
STATE_BRANCH_NAME,
302+
"set-environment-overrides"
303+
);
304+
305+
export interface SetEnvironmentOverrides {
306+
type: string;
307+
payload: EnvironmentOverrides;
308+
}
309+
310+
export function setEnvironmentOverrides(overrides: EnvironmentOverrides): SetEnvironmentOverrides {
311+
return {
312+
type: SET_ENVIRONMENT_OVERRIDES,
313+
payload: overrides,
314+
};
315+
}
316+
293317
/**
294318
* Indicate that there are unsaved edits for non-AICS data sources
295319
*/

packages/core/state/interaction/reducer.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ import {
2020
MARK_AS_USED_APPLICATION_BEFORE,
2121
MARK_AS_DISMISSED_SMALL_SCREEN_WARNING,
2222
ShowManifestDownloadDialogAction,
23+
SET_ENVIRONMENT_OVERRIDES,
2324
SET_HAS_UNSAVED_CHANGES,
2425
SET_IS_AICS_EMPLOYEE,
26+
SetEnvironmentOverrides,
2527
PROMPT_FOR_DATA_SOURCE,
2628
DownloadManifestAction,
2729
DOWNLOAD_MANIFEST,
@@ -44,7 +46,7 @@ import {
4446
} from "./actions";
4547
import { ContextMenuItem, PositionReference } from "../../components/ContextMenu";
4648
import { ModalType } from "../../components/Modal";
47-
import { Environment } from "../../constants";
49+
import { Environment, EnvironmentOverrides } from "../../constants";
4850
import FileFilter from "../../entity/FileFilter";
4951
import { PlatformDependentServices } from "../../services";
5052
import ApplicationInfoServiceNoop from "../../services/ApplicationInfoService/ApplicationInfoServiceNoop";
@@ -71,6 +73,7 @@ export interface InteractionStateBranch {
7173
fileTypeForVisibleModal: "csv" | "json" | "parquet";
7274
fileFiltersForVisibleModal: FileFilter[];
7375
environment: "LOCALHOST" | "PRODUCTION" | "STAGING" | "TEST";
76+
environmentOverrides: EnvironmentOverrides;
7477
hasDismissedSmallScreenWarning: boolean;
7578
hasUnsavedChanges: boolean;
7679
hasUsedApplicationBefore: boolean;
@@ -97,6 +100,7 @@ export interface InteractionStateBranch {
97100

98101
export const initialState: InteractionStateBranch = {
99102
environment: Environment.PRODUCTION,
103+
environmentOverrides: {},
100104
contextMenuIsVisible: false,
101105
contextMenuItems: [],
102106
// Passed to `ContextualMenu` as `target`. From the "@fluentui/react" docs:
@@ -177,6 +181,10 @@ export default makeReducer<InteractionStateBranch>(
177181
...state,
178182
userSelectedApplications: action.payload,
179183
}),
184+
[SET_ENVIRONMENT_OVERRIDES]: (state, action: SetEnvironmentOverrides) => ({
185+
...state,
186+
environmentOverrides: action.payload,
187+
}),
180188
[SET_HAS_UNSAVED_CHANGES]: (state) => ({
181189
...state,
182190
hasUnsavedChanges: true,

0 commit comments

Comments
 (0)