React frontend for Odin — an infrastructure automation platform that lets engineering teams manage cloud environments, catalogue services and components, onboard cloud accounts, and configure policies.
| Layer | Technology |
|---|---|
| Framework | React 19 + Vite 5 |
| State | Redux (manual reducer pattern) |
| Async data | TanStack React Query |
| Routing | React Router v6 |
| UI | Bootstrap 5 + React-Bootstrap + SCSS |
| gRPC-Web | grpc-web + protobuf generated stubs |
| HTTP | Axios with auth interceptors |
| Quality gates | ESLint + tsc (yarn lint, yarn typecheck, yarn build); no bundled unit/E2E runner in this repo |
- Node.js — use an active LTS (20.x or 22.x is a good default). Check with
node -v. - Yarn — install Yarn or enable Corepack (
corepack enable) and use the Yarn version the repo expects. - Backend services — optional if you only need the UI shell. For full flows (auth, deployer, scout), run the Odin APIs locally or set the
VITE_*URLs below so the browser can call them directly.
git clone <repository-url>
cd odin-ui
yarn installUse Yarn for installs and scripts (yarn <script>). Do not add or rely on package-lock.json — this repo keeps a single yarn.lock.
Vite reads .env, .env.local, and mode-specific files (e.g. .env.development.local) from the repo root. You can skip this for a first run when defaults below match your machine.
| Variable | Purpose |
|---|---|
VITE_CORE_SERVICE_URL |
Core REST API (auth, orgs, members) |
VITE_ONBOARDER_SERVICE_URL |
Onboarder REST API |
VITE_DEPLOYER_GRPC_WEB_URL |
Deployer gRPC-Web gateway |
VITE_SCOUT_API_URL |
Scout REST API |
VITE_AGENT_BASE_URL |
Asgard agent chat API base URL (odin-agent service, e.g. http://127.0.0.1:8787). Required for Asgard Chat; sending a message without it shows a configuration error. |
yarn devOpen http://localhost:3000 — the port is set in vite.config.ts (not Vite’s default 5173).
In dev and production builds, HTTP calls that use virtual prefixes such as /api/core/… are rewritten to the matching VITE_* base URL by src/libs/apiEnvelopeAndRouting.ts and src/libs/serviceRoutes.ts. Set each URL you need in .env (there is no dev-server proxy).
Generated clients live under src/grpc/gen/. After editing files in proto/, regenerate:
# Requires protoc with well-known types (macOS: brew install protobuf)
yarn generate:grpc-webyarn lint # ESLint
yarn typecheck # TypeScript (tsc --noEmit)yarn build # default / .env from Vite mode
yarn build:stag # dotenv loads .env.stag; see .env.stag re: VITE_ODIN_UI_IS_PRODUCTION + .env.production
yarn build:production # loads .env.production
yarn preview # serve the last `yarn build` output locallysrc/
├── main.tsx # App bootstrap (store, queryClient, auth hydration)
├── App.tsx # Route definitions
├── screens/ # Feature screens (4-file Redux pattern per screen)
├── components/ # Shared stateless UI components
├── layouts/ # Page-level layout wrappers
├── store/ # Redux store + global selectors
├── libs/ # HTTP clients, service facades, gRPC-Web helpers
│ ├── apiClient.ts # Axios instance (auth injection, 401 refresh, envelope unwrap)
│ ├── serviceRoutes.ts # URL prefix → upstream mapping
│ ├── authService.ts # Google OAuth, token exchange, org-token
│ ├── services/ # Per-domain REST and gRPC-Web service facades
│ ├── grpc-web/deployer/ # Standard deployer gRPC-Web entry point
│ └── utils/ # Shared helpers (proto utils, error normalization)
├── grpc/gen/ # Auto-generated protobuf stubs (never edit by hand)
└── constants/ # App-wide constants
If you are familiar with a backend service layer (controller → service → repository), the frontend layers map directly:
| Backend | Frontend equivalent | Folder |
|---|---|---|
| Controller / handler | Screen UI (*Screen.tsx) |
src/screens/ |
| Service / use-case | Actions (*Actions.ts) |
src/screens/ |
| Repository / DAO | Service facades | src/libs/services/ |
| Database / cache | Redux store (in-memory) | src/store/ |
| DTO / model | State shape in reducer | src/screens/ |
Pure presentational building blocks. Receive data via props, emit events via callback props. They have no knowledge of Redux, API calls, or business logic. Think of them as HTML widgets with styles.
Button, Card, Sidebar, ConfirmDeleteModal, ...
- No
useSelector, nouseDispatch, no API imports - Reused across multiple screens
- Everything they show comes in through props
This is the data access layer. It speaks to backends (REST or gRPC-Web) and returns plain JS objects. It has no knowledge of Redux or React.
libs/
├── apiClient.ts ← Axios instance, attaches org JWT to every request
├── services/
│ ├── memberService.ts ← getMembers(orgId), addMember(...), ...
│ ├── deployerEnvironmentListService.ts ← listEnvironmentsViaGrpcWeb(orgToken)
│ └── ...
└── grpc-web/deployer/deployerWebFlow.ts ← gRPC-Web helpers
Functions here are plain async function — no Redux, no React hooks. They just call an API and return data (or throw).
Each screen is a self-contained feature with four files:
| File | Responsibility | Backend analogy |
|---|---|---|
*Constants.ts |
Action type string constants + UI labels | Enum / event names |
*Reducer.ts |
Holds the screen's state; reacts to action types | In-memory state / DTO |
*Actions.ts |
Calls libs/services, dispatches to reducer |
Service / use-case layer |
*Screen.tsx |
Renders UI using state from reducer | Controller / view |
Redux store is a single in-memory object shared across the whole app. Every screen's reducer is registered here under a named key.
store = {
login: { userToken, orgToken, selectedOrg, ... }
members: { members[], loading, error }
environmentsList: { rows[], listLoading, createInFlight, ... }
environmentOverview: { environment, loading, serviceLifecycle, ... }
...
}
Think of it as the frontend's in-process database. It is reset on page refresh (except onboardAccount which is persisted to localStorage).
Here is what happens when a user opens the Members screen:
┌─────────────────────────────────────────────────────────────────┐
│ BROWSER │
│ │
│ 1. User navigates to /dashboard/members │
│ │ │
│ ▼ │
│ ┌───────────────────┐ │
│ │ MembersScreen.tsx │ ← renders UI, reads Redux store │
│ │ (src/screens/) │ │
│ └────────┬──────────┘ │
│ │ useEffect → dispatch(fetchMembers()) │
│ ▼ │
│ ┌───────────────────┐ │
│ │ membersActions.ts │ ← thunk runs │
│ │ (src/screens/) │ │
│ └────────┬──────────┘ │
│ │ 1. dispatch({ type: MEMBERS_FETCH_REQUEST }) │
│ │ → reducer sets loading: true │
│ │ → screen re-renders showing spinner │
│ │ │
│ │ 2. calls getMembers(orgId) │
│ ▼ │
│ ┌───────────────────┐ │
│ │ memberService.ts │ ← GET /api/core/v1/members │
│ │ (src/libs/) │ (Axios adds org JWT automatically) │
│ └────────┬──────────┘ │
│ │ │
│ │ ┌─────────────────┐ │
│ └────────►│ Backend API │ │
│ │ (Core service) │ │
│ ┌────────◄│ │ │
│ │ └─────────────────┘ │
│ │ │
│ │ 3. dispatch({ type: MEMBERS_FETCH_SUCCESS, │
│ │ payload: [{ id, email, role }] }) │
│ ▼ │
│ ┌───────────────────┐ │
│ │ membersReducer.ts │ ← stores members[] in Redux │
│ │ (src/screens/) │ loading: false │
│ └────────┬──────────┘ │
│ │ Redux notifies all subscribers │
│ ▼ │
│ ┌───────────────────┐ │
│ │ MembersScreen.tsx │ ← useSelector picks up new state │
│ │ │ React re-renders the members table │
│ └───────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
If the API call throws, the action dispatches a FAILURE action instead:
membersActions.ts
└── catch(e)
└── dispatch({ type: MEMBERS_FETCH_FAILURE, payload: e.message })
└── membersReducer sets error: "Not found"
└── MembersScreen.tsx renders an error banner
The screen never calls retry logic directly — it just dispatches fetchMembers() again on a Retry button click.
User clicks "Add Member" button
│
▼
MembersScreen.tsx
└── setShowAddModal(true) ← local UI state, no Redux
│
▼
AddMemberModal.tsx (src/screens/Members/Modal/)
└── User fills form → clicks Submit
│
▼
dispatch(addMemberAction(email, role)) ← from membersActions.ts
│
├── dispatch({ type: MEMBERS_ADD_REQUEST }) → loading spinner
│
├── addMember(orgId, email, role) → POST /api/core/v1/members
│
├── dispatch({ type: MEMBERS_ADD_SUCCESS }) → modal closes, list refreshes
│
└── dispatch({ type: MEMBERS_ADD_FAILURE }) → error shown in modal
┌─────────────────────────────────────────────────────────┐
│ *Screen.tsx reads state, dispatches actions │
│ (UI only, no API) │
├─────────────────────────────────────────────────────────┤
│ *Actions.ts calls libs, dispatches to reducer │
│ (business logic) │
├─────────────────────────────────────────────────────────┤
│ *Reducer.ts holds state, pure function │
│ (state management) │
├─────────────────────────────────────────────────────────┤
│ src/libs/services/ calls APIs, returns plain data │
│ (data access, no Redux, no React) │
├─────────────────────────────────────────────────────────┤
│ src/components/ renders UI from props only │
│ (no Redux, no API, reusable across screens) │
└─────────────────────────────────────────────────────────┘
Each layer only talks to the layer directly below it.
No layer is allowed to skip a layer.
Every screen under src/screens/ uses the 4-file Redux pattern. See docs/SCREEN_MODULE_STRUCTURE.md for the full spec.
src/screens/Members/
├── MembersScreen.tsx # UI only — useSelector + useDispatch, no API calls
├── membersActions.ts # Thunks — all API calls, dispatches action objects
├── membersReducer.ts # Plain switch/case reducer + exported selectors
├── membersConstants.ts # Action type strings + UI string constants
├── Modal/ # Screen-specific modals (same Redux slice as parent)
└── __tests__/
Rules:
- No
createSlice— all reducers are manual switch/case - No direct API calls in JSX
- Screen-specific modals dispatch the owning screen's actions
src/components/is for shared, Redux-free, prop-driven components only
Edit or add a .proto file under proto/dream11/od/<service>/v1/, then regenerate the protobuf stubs:
yarn generate:grpc-webprotoc first emits CommonJS *.js; scripts/postprocess-grpc-gen.mjs then converts them to *.ts, strips .js from relative require() paths, appends ESM export shims for Rollup, and adds export default on gRPC-Web stubs. The on-disk files are .ts (still protoc-shaped code with require, // @ts-nocheck, etc.). Never edit generated files by hand.
Imports from app code use extensionless paths (e.g. …/service_grpc_web_pb); Vite resolves them to src/grpc/gen/**.
Create or extend src/libs/services/deployer<YourDomain>Service.ts.
import yourProtoGrpcWebPb from '../../grpc/gen/dream11/od/service/v1/service_grpc_web_pb';
import {
createDeployerGrpcWebClient,
invokeDeployerUnary,
} from '../grpc-web/deployer/deployerWebFlow';
function getServiceV1() {
const mod = yourProtoGrpcWebPb?.default ?? yourProtoGrpcWebPb;
if (!mod?.YourRequest || !mod.YourServicePromiseClient) {
throw new Error('gRPC-Web client failed to load');
}
return mod;
}
export async function yourRpcViaGrpcWeb(param, orgToken) {
const ns = getServiceV1();
const request = new ns.YourRequest();
request.setParam(param);
const { client, metadata } = createDeployerGrpcWebClient(
ns,
'YourServicePromiseClient',
orgToken,
{ emptyOrgTokenMessage: 'Org token required for YourRpc' },
);
const response = await invokeDeployerUnary('YourRpc', () =>
client.yourRpc(request, metadata));
return response.getSomeField();
}import {
createDeployerGrpcWebClient,
formatDeployerGrpcWebErrorMessage,
} from '../grpc-web/deployer/deployerWebFlow';
export function startYourStream(param, orgToken, callbacks = {}) {
const { onData, onError, onEnd } = callbacks;
const ns = getServiceV1();
const request = new ns.YourStreamRequest();
request.setParam(param);
const { client, metadata } = createDeployerGrpcWebClient(
ns,
'YourServiceClient',
orgToken,
);
const stream = client.yourStream(request, metadata);
let settled = false;
stream.on('data', (res) => onData?.(res.getMessage()));
stream.on('error', (err) => {
if (settled) return;
settled = true;
onError?.(formatDeployerGrpcWebErrorMessage('YourStream', err));
});
stream.on('end', () => {
if (settled) return;
settled = true;
onEnd?.();
});
return {
cancel: () => { try { stream.cancel(); } catch { /**/ } },
};
}Constants file — add action type strings:
// yourScreenConstants.ts
export const YOUR_THING_FETCH_REQUEST = 'YOUR_THING_FETCH_REQUEST';
export const YOUR_THING_FETCH_SUCCESS = 'YOUR_THING_FETCH_SUCCESS';
export const YOUR_THING_FETCH_FAILURE = 'YOUR_THING_FETCH_FAILURE';Actions file — call the service facade:
// yourScreenActions.ts
import { YOUR_THING_FETCH_REQUEST, YOUR_THING_FETCH_SUCCESS, YOUR_THING_FETCH_FAILURE } from './yourScreenConstants';
import { yourRpcViaGrpcWeb } from '@/libs/services/deployerYourDomainService';
export function fetchYourThing(param) {
return async (dispatch, getState) => {
const orgToken = getState().login?.orgToken;
dispatch({ type: YOUR_THING_FETCH_REQUEST });
try {
const data = await yourRpcViaGrpcWeb(param, orgToken);
dispatch({ type: YOUR_THING_FETCH_SUCCESS, payload: data });
} catch (e) {
dispatch({ type: YOUR_THING_FETCH_FAILURE, payload: e?.message });
}
};
}For a streaming RPC, store the cancel function in a module-scoped object (not a bare let):
const _stream = { cancel: null }; // one active stream at a time
export function submitYourStream(param) {
return (dispatch, getState) => {
const orgToken = getState().login?.orgToken;
dispatch({ type: YOUR_STREAM_START });
let settled = false;
const { cancel } = startYourStream(param, orgToken, {
onData: (line) => dispatch({ type: YOUR_STREAM_APPEND_LOG, payload: line }),
onError: (msg) => {
settled = true;
_stream.cancel = null;
dispatch({ type: YOUR_STREAM_ERROR, payload: msg });
},
onEnd: () => {
settled = true;
_stream.cancel = null;
dispatch({ type: YOUR_STREAM_COMPLETE });
},
});
if (!settled) _stream.cancel = cancel;
};
}
export function cancelYourStream() {
return (dispatch) => {
if (_stream.cancel) {
try { _stream.cancel(); } catch { /**/ }
_stream.cancel = null;
}
dispatch({ type: YOUR_STREAM_RESET });
};
}Reducer file — handle the new action types:
// yourScreenReducer.ts
import { YOUR_THING_FETCH_REQUEST, YOUR_THING_FETCH_SUCCESS, YOUR_THING_FETCH_FAILURE } from './yourScreenConstants';
export const initialState = { data: null, loading: false, error: null };
const yourScreenReducer = (state = initialState, action) => {
switch (action.type) {
case YOUR_THING_FETCH_REQUEST:
return { ...state, loading: true, error: null };
case YOUR_THING_FETCH_SUCCESS:
return { ...state, loading: false, data: action.payload };
case YOUR_THING_FETCH_FAILURE:
return { ...state, loading: false, error: action.payload };
default:
return state;
}
};
export const selectYourData = (state) => state.yourScreen?.data ?? null;
export const selectYourLoading = (state) => state.yourScreen?.loading ?? false;
export default yourScreenReducer;Register in src/store/store.ts:
import yourScreenReducer from '../screens/YourScreen/yourScreenReducer';
const store = configureStore({
reducer: {
// ...existing
yourScreen: yourScreenReducer,
},
});After adding a service, run yarn typecheck, yarn lint, and yarn build. Exercise the flow manually in the dev app (or add a temporary local harness if you need to probe the gRPC client). This repo does not ship a colocated unit-test suite for deployer services.
Helpers for mapping proto types to plain JS live in src/libs/utils/deployerProtoUtils.ts:
| Function | Purpose |
|---|---|
structToCompactJson(structMsg) |
Renders google.protobuf.Struct as truncated JSON string |
timestampToIsoString(ts) |
ISO string from a proto Timestamp |
slugifyDeploymentSegment(raw) |
URL-safe slug from a raw deployer name |
Import them directly — do not re-implement locally in service files.
- Always use
createDeployerGrpcWebClient+invokeDeployerUnary— never open-code base URL resolution or metadata construction in a service file. - No
createSlice— all screen reducers use the manual 4-file pattern. - No direct API calls in JSX — only action thunks call services.
- Generated protos live under
src/grpc/gen/as.ts(post-processed from protoc output) — never edit them by hand. deployerGrpcWebCommon.tsis an internal module; import deployer helpers viagrpc-web/deployer/deployerWebFlow.tsonly (seedeployerWebFlow.tsfor the supported public API).