Skip to content

Repository files navigation

odin-ui

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.


Tech stack

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

Local setup

Prerequisites

  • 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.

1. Clone and install

git clone <repository-url>
cd odin-ui
yarn install

Use Yarn for installs and scripts (yarn <script>). Do not add or rely on package-lock.json — this repo keeps a single yarn.lock.

2. Configure environment (optional)

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.

3. Start the dev server

yarn dev

Open 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).

4. Regenerate gRPC-Web stubs (proto changes only)

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-web

5. Lint and typecheck

yarn lint          # ESLint
yarn typecheck     # TypeScript (tsc --noEmit)

Production-style builds

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 locally

Project structure

src/
├── 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

How Redux data flow works (for backend engineers)

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/

The four layers

1. src/components/ — UI elements only

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, no useDispatch, no API imports
  • Reused across multiple screens
  • Everything they show comes in through props

2. src/libs/ — API calls only

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).


3. src/screens/ — complete feature screens

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

4. src/store/store.ts — the global state container

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).


Data flow — step by step

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   │
│  └───────────────────┘                                          │
└─────────────────────────────────────────────────────────────────┘

What happens on error

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 triggers an action (e.g. Add Member)

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

Summary: strict layer boundaries

┌─────────────────────────────────────────────────────────┐
│  *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.

Screen module pattern

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

Adding a new deployer gRPC call

Step 1 — Define the RPC in proto

Edit or add a .proto file under proto/dream11/od/<service>/v1/, then regenerate the protobuf stubs:

yarn generate:grpc-web

protoc 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/**.


Step 2 — Write the service facade

Create or extend src/libs/services/deployer<YourDomain>Service.ts.

Unary RPC (single response)

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();
}

Server-streaming RPC (log lines, progress updates)

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 { /**/ } },
  };
}

Step 3 — Wire up Redux in the screen

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,
  },
});

Step 4 — Verify behavior

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.


Shared proto utilities

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.


Key rules

  • 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.ts is an internal module; import deployer helpers via grpc-web/deployer/deployerWebFlow.ts only (see deployerWebFlow.ts for the supported public API).

About

UI for odin platform

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages