The flip-ui is the web-based user interface for the FLIP platform. It provides researchers and administrators with a portal to manage projects, monitor federated learning jobs, inspect cohort query results, and retrieve trained models.
The flip-ui runs as a Docker container (Vite dev server) in the full local stack:
make ui # starts the UI onlyor as part of the full platform:
make upThe UI is accessible over plain HTTP at the port defined by UI_PORT in
.env.development.example: http://localhost:<UI_PORT>. Only a port the
local Keycloak realm registers as a browser origin works — take one from 44350–44359, since the shipped
443 is registered for https only and the dev server serves no TLS. See
deploy/keycloak/README.md.
The UI is served as static assets from S3 behind CloudFront at the canonical subdomain
(stag.flip.aicentre.co.uk / app.flip.aicentre.co.uk). CloudFront relays /api/* to the internal NLB's web listener, so users and
trusts keep hitting the same URLs. There is no flip-ui Docker container on stag/prod EC2 — only flip-api and the
FL services. See deploy/providers/AWS/README.md
for the full rollout.
Deploy from the working tree with:
cd deploy/providers/AWS
make deploy-ui HUB=stag # or HUB=prod for prodThis runs npm ci && npm run build:deploy (whose postbuild:deploy writes dist/js/window.js from the target
environment's values) and publishes dist/ to the UI bucket with scripts/publish-ui.sh. Nothing is invalidated:
index.html and window.js are served no-cache. CI runs the same script after every successful apply
(publish_ui.yml, FLIP#1186); make deploy-ui is the manual fallback and rollback.
A read-only, fully-offline demo of the Ark+ chest X-ray experiment register, safe to host
unauthenticated. It is the real SPA built with --mode demo (VITE_DEMO inlined to true): the
auth guard is bypassed, no Amplify/Cognito call is ever made, and every API request is answered by
an in-browser MirageJS server (mocks/demo-server.ts) with no
passthrough — a published demo bundle cannot reach a real backend. The replayed payloads live
under mocks/demo/data/; capture provenance and sanitisation rules are
documented in mocks/demo/ark-plus-register.ts.
npm run demo # dev server at http://localhost:4174/ (base "/")
npm run build:demo # production bundle under dist/ with base "/ark_demo/"
npm run serve:demo # preview the built bundle at /ark_demo/The build regenerates dist/js/window.js from
scripts/generate-demo-window-js.sh, which reads no
environment and points at no real backend. Host the bundle as a CloudFront behaviour (or any
static prefix) at /ark_demo/ next to the real app.
The demo is a point-in-time capture, not a live system, and says so twice: window.RELEASE_VERSION
is stamped ark-demo/<capture-date>+<git-short-sha> (the date is grepped out of
DEMO_CAPTURE_DATE in ark-plus-register.ts so it can't drift from the provenance note there),
and every page renders a small, non-dismissible banner
(src/demo/DemoBanner.vue, mounted in App.vue outside
<router-view> so it survives every route) reading the same capture date in human form.
The large download bundles (results + model files, several hundred MB each) are not part of the
bundle: they are served by the production CloudFront distribution at /ark_demo/assets/* from a
private, OAC-locked S3 bucket with the WAF in the request path (Terraform:
deploy/providers/AWS/cloudfront.tf, "Public Ark+ demo download assets"; bucket name via
DEMO_ASSETS_BUCKET_NAME). Nothing in the demo is fetched from a public S3 URL.
The flip-ui reads its runtime configuration from window.* globals set by
public/js/window.js. That file is gitignored and generated from environment
variables by scripts/generate-window-js.sh:
- In local dev: generated automatically by the
predevnpm hook (beforenpm run dev), reading theVITE_*vars that docker-compose passes into the container. - In stag/prod: generated by
make deploy-uibefore the S3 sync, reading the values sourced from.env.stag/.env.prod.
No environment-specific Cognito IDs or API URLs live in the repo.
The UI reads its config at runtime from window.* (generated into
dist/js/window.js by scripts/generate-window-js.sh from the env
at dev-start or deploy time). The same env vars drive the rest of the
stack, so there is no VITE_-prefixed duplication to keep in sync.
| Variable | Description |
|---|---|
AUTH_BACKEND |
Identity provider the hub runs: cognito (default when unset — stag/prod) or keycloak (the dev stack). Must match flip-api's AUTH_BACKEND. |
AWS_COGNITO_USER_POOL_ID |
Cognito User Pool ID (required when AUTH_BACKEND=cognito) |
AWS_COGNITO_APP_CLIENT_ID |
Cognito App Client ID (required when AUTH_BACKEND=cognito) |
AWS_REGION |
AWS region hosting the pool (default eu-west-2) |
KEYCLOAK_PUBLIC_URL |
Base URL of Keycloak as the browser reaches it, e.g. http://localhost:8081 (required when AUTH_BACKEND=keycloak; emitted as window.KEYCLOAK_URL) |
KEYCLOAK_REALM |
Keycloak realm (required when AUTH_BACKEND=keycloak) |
KEYCLOAK_CLIENT_ID |
Keycloak public client the UI signs in with (required when AUTH_BACKEND=keycloak) |
CENTRAL_HUB_API_URL |
Full base URL of the flip-api backend, including /api |
BLACKLISTED_MODEL_FILES |
Comma-separated file names to reject in model uploads |
VITE_LOCAL |
Set to true for local mock mode (bypasses Cognito). Local dev only — see warning below. |
VITE_DEMO |
Selects the public Ark+ demo bundle (bypasses Cognito, offline Mirage API). Never set this in the environment — see warning below; it is inlined automatically by npm run build:demo / npm run demo (--mode demo). |
Authentication sits behind a provider seam (src/auth/, FLIP#919): the store, router guard, axios
interceptor and auth pages speak only the AuthProvider contract in src/auth/provider.ts, and getAuthProvider()
picks the implementation once at boot from window.AUTH_BACKEND.
- Cognito (
src/auth/cognito-provider.ts, stag/prod) — AWS Cognito throughaws-amplify, the only file allowed to import it (enforced by theflip-ui/no-amplify-outside-providerESLint block). A valid Cognito User Pool and Client ID are required for a production deployment. Every capability is on: temporary- password change, TOTP challenge and in-app enrolment, code-by-email password reset, admin-triggered reset, global sign-out. - Keycloak (
src/auth/keycloak-provider.ts, the dev stack) — the OIDC Resource Owner Password grant against the realm's token endpoint, no SDK and no redirect, so the same username/password form works. Tokens live inlocalStorage["flip.auth.keycloak.<clientId>"]and refresh 30 s ahead of expiry. Its capabilities are off by design, except the admin "Reset Password" (flip-api re-sends the user's invite): the password grant cannot run Keycloak's required actions (forced password update, TOTP enrolment are browser flows in Keycloak's own UI), and Keycloak answers a wrong password and a missing OTP with the sameinvalid_grant, so an in-app TOTP step cannot be offered honestly. The pages link out to the Keycloak console instead ("Forgot password?" on the login page), and an account with a pending required action gets a "Finish setting up your account in Keycloak" notice with a link.
VITE_LOCAL=trueandVITE_DEMO=truemust never reach a production build. Both flags are inlined by Vite at build time and short-circuit the Cognito session check insrc/utils/auth.tsplus enable a MirageJS mock insrc/main.ts— so a build carrying either ships an unauthenticated app. Everynpm run build*therefore refuses to proceed if either flag is set totruein the environment (enforced byscripts/check-build-flags.mjsand a belt-and-braces check invite.config.mts). UseVITE_LOCALonly withnpm run devagainst a mocked API, and never setVITE_DEMOat all — the demo build gets it from--mode demo. Keep both out of CI and deploy environments.
Production source may not import a package that is only declared in devDependencies, nor one that is
declared nowhere and resolves through npm's hoisting of a parent's tree. "Production source" is everything
the deployed bundles reach: src/, minus its test files, plus mocks/ — which build:demo pulls into the
public /ark_demo bundle.
This is enforced, not just documented: the flip-ui/dependency-scoping block in
eslint.config.mjs runs import-x/no-extraneous-dependencies over exactly that file
set, so npm run lint fails in CI the moment an import lands in the wrong stanza.
The rule exists for Dependabot, not the build. vite build tree-shakes from the entry points and bundles
whatever they reach whichever stanza a package sits in, and every install path in the repo (npm ci in CI,
the Dockerfile, make deploy-ui) installs both stanzas — so a wrong stanza breaks nothing and is invisible
until it matters. What it changes is the scope label on a security alert: a package that ships to users
but sits in devDependencies produces an alert labelled "Development", which reads as not in the production
bundle and invites a wrongly-dismissed alert on code CloudFront is serving. FLIP#1041 corrected 19
packages: 16 moved out of devDependencies, two that were declared nowhere at all (codemirror,
tippy.js — they resolved only by hoisting), and husky, which had drifted the other way. Correct scoping
is also the precondition for ever adopting npm ci --omit=dev here.
Note the rule keys off the import graph, not the bundle: a package imported by production source is a
dependency even if tree-shaking currently drops it. What production code imports is stable, whereas what
survives tree-shaking silently flips the correct answer whenever an unrelated component starts or stops being
used. (@popperjs/core was the worked example of this until FLIP#1063 removed its only importer along with
the dead AiSelect/AiChipSelect components, at which point it left the manifest entirely — the honest
resolution, and the one a lint rule cannot reach, since nothing flags an import that is merely unreachable.)
Two traps when auditing this by hand:
- A
from "..."grep under-reports.highlight.jsis loaded lazily viaimport("highlight.js/lib/core")andimport("highlight.js/lib/languages/json")insrc/utils/highlightJson.ts, and is correctly adependencydespite having no static import. Test files also live undersrc/(most under__tests__/directories, plus two flat*.spec.tsand thesrc/unit-tests/tree), and their imports —vitest,@vue/test-utils,@pinia/testing— are genuinely dev-only. - A static import of a side-effecting module is never dropped, even from a branch that folds. Neither
mock server may be imported statically from
src/main.ts; both are loaded through dynamicimport()inside their own folded branch.mocks/serverwas static until FLIP#1041 and shipped ~230 modules — Mirage, Pretender, route-recognizer, inflected and all of lodash — in the production entry chunk of every build, because miragejs patchesError.prototypeat module scope. Making it dynamic cut the entry chunk from 331 KB to 182 KB. See the comment abovebootstrap(). This is enforced twice, becausemiragejsis a legitimatedependency(the demo bundle ships it) and so invisible to the scoping rule above: theflip-ui/no-static-mirageblock ineslint.config.mjsfailsnpm run linton any static import ofmiragejs/pretender/the mock servers fromsrc/(dynamicimport()stays legal), andscripts/assert-no-demo-artefacts.mjscarries Mirage/Pretender sentinels so the built artefact is checked too — the only guard that also catches a folded branch that stops folding.
cd flip-ui
npm install
npm run test:unit # one-off run with coverage
npm run test:unit:watch # watch mode while iteratingUnit tests live alongside source under src/**/*.spec.ts and run in jsdom with Pinia / Vue Router stubs.
The Cypress suite under test/cypress/integration/ drives the UI in Chrome against a
stubbed API — every backend call is intercepted at the network layer in
test/cypress/support/globalIntercepts.ts and Cognito auth is short-circuited
in test/cypress/support/cognito.ts by writing a fixture token straight into
localStorage. The suite needs no real backend, no AWS account and no Cognito user pool.
Specs are split into six groups (group-1 … group-6) so CI can run them in parallel.
The split is by file, not by feature — it's purely a sharding device for the matrix
job, balanced so the slowest single shard (rather than the sum of all specs) sets the
wall-clock time. There's no semantic difference between adding a new spec to one group
versus another; pick the group with the fewest existing specs (or the shortest total
runtime).
cd flip-ui
npm install
# One-shot env file. Values are dummies — the suite mocks Cognito + the API,
# so nothing here ever leaves the box. Match the values used in CI.
#
# It MUST be `.env.e2e`, never `.env.development`: VITE_E2E arms the Cypress
# auth seam (src/utils/auth.ts), which takes the signed-in user from a
# `cypress.auth.user` localStorage key instead of Cognito. Vite loads
# `.env.development` for `npm run dev` / `make ui` too, so putting the flag
# there makes the interactive dev server bounce every page refresh to
# /auth/login while your real Cognito tokens sit unused in localStorage.
# `vite.config.mts` now refuses to start in any mode but `e2e` with the flag
# set, so a misplaced file fails loudly instead of wasting an afternoon.
cat > .env.e2e <<'EOF'
VITE_LOCAL=false
VITE_E2E=true
CENTRAL_HUB_API_URL=http://localhost:8080
AWS_COGNITO_USER_POOL_ID=eu-west-2_DUMMY1234
AWS_COGNITO_APP_CLIENT_ID=dummyclientid1234567890ab
AWS_REGION=eu-west-2
BLACKLISTED_MODEL_FILES=flip.py,server_app.py
EOF
npm run test:ci # boots the dev server on :4173 and runs cypresstest:ci uses start-server-and-test to start the Vite dev server (npm run test:start, plain HTTP on
http://localhost:4173, --mode e2e so it reads .env.e2e) and then runs cypress run --browser chrome
against it. The non-privileged port 4173 avoids the need to run with sudo on Linux/macOS and matches the
port used in CI.
To open the Cypress GUI for interactive debugging:
npm run test:start & # in one shell — leave running
npx cypress open # in another — pick a specnpm run test:types # tsc --noEmit -p test/tsconfig.jsonThe Cypress tree has its own TS project (test/tsconfig.json) that npm run lint
(eslint over src/ only) never sees, so CI runs this as a separate step to keep the custom-command
declarations honest.
# with the dev server running (npm run test:start &), then in another shell:
npx cypress run --browser chrome --spec 'test/cypress/integration/group-3/**/*.spec.ts'On Linux without xvfb installed, or anywhere you'd rather not install
Chrome locally, run Cypress out of the official cypress/included image. The
dev server stays on the host (so hot-reload still picks up your edits); the
container reaches it via --network host.
cd flip-ui
make e2e_test # full suite, end-to-end (boots Vite, runs cypress in docker)
# Or, if you already have npm run test:start running in another shell:
docker run --rm --network host \
-v "$PWD":/e2e -w /e2e --entrypoint cypress \
cypress/included:15.21.0 \
run --browser electron \
--spec 'test/cypress/integration/group-3/**/*.spec.ts'The image (cypress/included:15.21.0) is ~3 GB on first pull and cached
afterwards. Pin the tag to whatever cypress version is in package.json so
the binary in the image matches the project config.
The end-to-end demo video is not recorded with Cypress: make demo-video (repo root) drives the live dev stack
with Playwright from flip-api/tests/demo_video.py. Only its final concatenation lives here,
scripts/assemble-demo-video.sh. See flip-api/AGENTS.md ("Demo Video
Recorder").
The Cypress suite runs on every PR and on push to develop / main via the cypress-e2e job in
.github/workflows/test_flip_ui.yml. The job:
- Uses
cypress-io/github-action@v7, which caches the Cypress binary andnode_modulesbetween runs. - Fans out across the six spec groups via a
strategy.matrix.groupso wall-clock time stays short. - Boots the Vite dev server with the same
.env.e2estub shown above. - On failure, uploads
test/cypress/screenshotsas an artefact (cypress-screenshots-<group>, retained 7 days).
Running the suite against the real backend stack is out of scope for the in-repo CI — that's the nightly E2E job tracked separately. Anything in this suite must be runnable purely from fixtures.
FLIP is Apache 2.0 licensed. See the LICENSE file for details.