├── jest.setup.ts
├── docs
└── index.md
├── .prettierignore
├── docker
├── nginx
│ ├── loggedin.html
│ ├── loggedout.html
│ └── route-require-auth.conf
├── mediamtx-mock
│ ├── Dockerfile
│ └── entrypoint.sh
└── apiv1
│ └── Dockerfile
├── .npmrc
├── src
├── public
│ ├── images
│ │ ├── earth.png
│ │ ├── ISSiRT.png
│ │ ├── coastal.jpg
│ │ ├── earth_moon.jpg
│ │ ├── marker_ev1.png
│ │ ├── marker_ev2.png
│ │ ├── marker_ev3.png
│ │ ├── marker_ev4.png
│ │ ├── sun_earth.jpg
│ │ ├── datetime_2x.png
│ │ ├── earth_aurora.jpg
│ │ ├── help_callout.png
│ │ ├── marker_cart.png
│ │ ├── marker_cart2.png
│ │ ├── earth_nightlight.jpg
│ │ ├── iss_array_extend.jpg
│ │ ├── marker_lightCart.png
│ │ ├── patch_fod_1400_8bit.png
│ │ ├── artemis_launch_center.jpg
│ │ ├── artemis_launch_closeup.jpg
│ │ ├── icon_status_check_green.svg
│ │ ├── icon_status_check_yellow.svg
│ │ ├── icon_status_no_assets.svg
│ │ ├── icon_status_loading.svg
│ │ ├── icon_status_error.svg
│ │ ├── share.svg
│ │ ├── share_black.svg
│ │ └── talky-the-bot.svg
│ ├── favicon
│ │ ├── favicon.ico
│ │ ├── favicon-16x16.png
│ │ ├── favicon-32x32.png
│ │ ├── apple-touch-icon.png
│ │ ├── android-chrome-192x192.png
│ │ ├── android-chrome-256x256.png
│ │ ├── site.webmanifest
│ │ └── safari-pinned-tab.svg
│ ├── fonts
│ │ ├── Inter-Variable.woff2
│ │ ├── Aldrich-Regular.woff2
│ │ ├── UbuntuMono-Bold.woff2
│ │ ├── RobotoMono-Variable.woff2
│ │ ├── UbuntuMono-Italic.woff2
│ │ ├── UbuntuMono-Regular.woff2
│ │ └── UbuntuMono-BoldItalic.woff2
│ ├── clocksync
│ │ ├── server
│ │ │ └── gettime.php
│ │ ├── index.html
│ │ ├── index.js
│ │ ├── clocksync.css
│ │ └── img
│ │ │ └── EMSS_wordmark.svg
│ ├── clockcalc
│ │ ├── clockcalc.js
│ │ └── clockcalc.html
│ ├── global.css
│ ├── fonts.css
│ └── mapbox_custom.css
├── pages
│ ├── view
│ │ ├── index.module.css
│ │ ├── iss.tsx
│ │ ├── nbl.tsx
│ │ └── test-events.tsx
│ ├── admin
│ │ └── ephemeris.module.css
│ └── index.module.css
├── utils
│ ├── loadEnv.ts
│ ├── user.ts
│ ├── logging
│ │ ├── clientLogger.ts
│ │ └── serverLogger.ts
│ ├── useAppDispatch.ts
│ ├── consts.ts
│ ├── useInterval.ts
│ ├── fetch-with-timeout.ts
│ ├── date.ts
│ ├── suncalc.d.ts
│ ├── user.spec.ts
│ ├── fetch-with-timeout.spec.ts
│ └── map.ts
├── typings
│ ├── processing
│ │ ├── photo.d.ts
│ │ ├── daynight.d.ts
│ │ ├── ephemeris.d.ts
│ │ ├── gps.d.ts
│ │ ├── talkybot.d.ts
│ │ ├── location.d.ts
│ │ ├── video.d.ts
│ │ ├── graph.d.ts
│ │ └── sequences.d.ts
│ ├── overrides.d.ts
│ ├── index.d.ts
│ ├── global.d.ts
│ ├── cache.d.ts
│ ├── consts.d.ts
│ ├── api.d.ts
│ └── store.d.ts
├── server
│ ├── database
│ │ ├── tsconfig.orm.json
│ │ ├── migrations
│ │ │ ├── Migration20251106000000_manual.ts
│ │ │ ├── Migration20240530185740.ts
│ │ │ ├── Migration20240703205714.ts
│ │ │ ├── Migration20240710153247.ts
│ │ │ ├── Migration20250611191755.ts
│ │ │ ├── Migration20240718202407.ts
│ │ │ └── Migration20251120122600_manual.ts
│ │ ├── models
│ │ │ ├── VideoStartTimeOverrides.model.ts
│ │ │ ├── gpxTracks.model.ts
│ │ │ ├── PhotoTimeShifts.model.ts
│ │ │ ├── mediaOverride.model.ts
│ │ │ ├── ancillaryData.model.ts
│ │ │ ├── ephemera.model.ts
│ │ │ ├── cache.model.ts
│ │ │ └── _allModels.ts
│ │ └── mikro-orm.config.ts
│ ├── express
│ │ ├── routes
│ │ │ ├── time
│ │ │ │ └── time.ts
│ │ │ ├── profiler
│ │ │ │ └── profiler.ts
│ │ │ ├── user
│ │ │ │ ├── logFromClient.ts
│ │ │ │ └── auth.ts
│ │ │ └── emss
│ │ │ │ └── dataRefresh.ts
│ │ ├── global.ts
│ │ ├── middleware
│ │ │ └── requireSuperuser.ts
│ │ └── restApi.ts
│ └── processing
│ │ ├── wikiData.ts
│ │ ├── mediaMtx-hls.ts
│ │ ├── graphs.ts
│ │ ├── ephemeris-celestrak.ts
│ │ └── ancillaryDataSources.ts
├── components
│ ├── interface
│ │ ├── pane-help-control-button.module.css
│ │ ├── button.tsx
│ │ ├── pane-help-control-button.tsx
│ │ ├── nav-timeline-draw.module.css
│ │ ├── pane-help-overlay.tsx
│ │ ├── dropdown-event.module.css
│ │ ├── pane-help-overlay.module.css
│ │ ├── photo-filter-button.module.css
│ │ ├── share.module.css
│ │ ├── button.module.css
│ │ ├── dropdown-modal.module.css
│ │ └── status.module.css
│ ├── panes
│ │ ├── iss-location-marker.tsx
│ │ ├── graph
│ │ │ ├── plotly-class.ts
│ │ │ ├── graph.module.css
│ │ │ ├── graphProperties.ts
│ │ │ └── plotly.tsx
│ │ ├── gps-location-marker.tsx
│ │ ├── gps-location-marker.module.css
│ │ ├── video
│ │ │ ├── video-poster.module.css
│ │ │ └── video-poster.tsx
│ │ ├── iss-location.module.css
│ │ ├── iss-location-marker.module.css
│ │ ├── photo-all.module.css
│ │ └── gps-location.module.css
│ └── framework
│ │ ├── layout-picker.module.css
│ │ ├── frames.tsx
│ │ ├── frame.module.css
│ │ ├── pane-picker.module.css
│ │ ├── ClockInterval.tsx
│ │ └── pane-picker.tsx
├── packages
│ ├── asyncSleep.ts
│ ├── fetchFns.ts
│ ├── EnsureLogin.tsx
│ ├── getCurrentUser.ts
│ ├── getUser.ts
│ └── setupLoggerSpies.ts
├── store
│ ├── user.ts
│ ├── gps.ts
│ ├── daynight.ts
│ ├── thunk
│ │ ├── thunkUtil.ts
│ │ └── clockThunk.ts
│ ├── ephemera.spec.ts
│ ├── sequences.spec.ts
│ ├── graphs.ts
│ ├── videos.ts
│ ├── ephemera.ts
│ ├── index.ts
│ └── talkybot.ts
├── styles.css
├── index.tsx
└── index.html
├── .yamllint.yml
├── .prettierrc.json
├── jest.globalSetup.ts
├── .dockerignore
├── tsconfig.jest.json
├── appcompose
├── ci
└── suppression.xml
├── .vscode
├── settings.json
└── launch.json
├── .gitignore
├── scripts
└── make-dev-ssl-cert.sh
├── .sastignore
├── docker-compose.preview.yml
├── .gitlab-ci.yml
├── docker-compose.services.yml
├── .codeclimate.yml
├── tsconfig.json
├── jest.config.ts
├── .gitlab
├── run-on-schedule.gitlab-ci.yml
└── includes
│ └── db-import.yml
└── .fitdock.yml
/jest.setup.ts:
--------------------------------------------------------------------------------
1 | export {};
2 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | All of our documentation lives in README.md
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # Ignore all files in .gitignore
2 | .gitignore
3 |
--------------------------------------------------------------------------------
/docker/nginx/loggedin.html:
--------------------------------------------------------------------------------
1 |
Welcome! You are logged in.
2 |
--------------------------------------------------------------------------------
/docker/nginx/loggedout.html:
--------------------------------------------------------------------------------
1 | You are not logged into this app
2 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | @emss:registry=https://eegitlab.fit.nasa.gov/api/v4/projects/685/packages/npm/
2 |
--------------------------------------------------------------------------------
/src/public/images/earth.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/images/earth.png
--------------------------------------------------------------------------------
/src/public/images/ISSiRT.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/images/ISSiRT.png
--------------------------------------------------------------------------------
/src/public/images/coastal.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/images/coastal.jpg
--------------------------------------------------------------------------------
/.yamllint.yml:
--------------------------------------------------------------------------------
1 | extends: relaxed
2 | rules:
3 | line-length:
4 | max: 140
5 | level: warning
6 |
--------------------------------------------------------------------------------
/src/public/favicon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/favicon/favicon.ico
--------------------------------------------------------------------------------
/src/public/images/earth_moon.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/images/earth_moon.jpg
--------------------------------------------------------------------------------
/src/public/images/marker_ev1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/images/marker_ev1.png
--------------------------------------------------------------------------------
/src/public/images/marker_ev2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/images/marker_ev2.png
--------------------------------------------------------------------------------
/src/public/images/marker_ev3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/images/marker_ev3.png
--------------------------------------------------------------------------------
/src/public/images/marker_ev4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/images/marker_ev4.png
--------------------------------------------------------------------------------
/src/public/images/sun_earth.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/images/sun_earth.jpg
--------------------------------------------------------------------------------
/src/public/images/datetime_2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/images/datetime_2x.png
--------------------------------------------------------------------------------
/src/public/images/earth_aurora.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/images/earth_aurora.jpg
--------------------------------------------------------------------------------
/src/public/images/help_callout.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/images/help_callout.png
--------------------------------------------------------------------------------
/src/public/images/marker_cart.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/images/marker_cart.png
--------------------------------------------------------------------------------
/src/public/images/marker_cart2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/images/marker_cart2.png
--------------------------------------------------------------------------------
/src/public/favicon/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/favicon/favicon-16x16.png
--------------------------------------------------------------------------------
/src/public/favicon/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/favicon/favicon-32x32.png
--------------------------------------------------------------------------------
/src/public/fonts/Inter-Variable.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/fonts/Inter-Variable.woff2
--------------------------------------------------------------------------------
/src/public/favicon/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/favicon/apple-touch-icon.png
--------------------------------------------------------------------------------
/src/public/fonts/Aldrich-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/fonts/Aldrich-Regular.woff2
--------------------------------------------------------------------------------
/src/public/fonts/UbuntuMono-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/fonts/UbuntuMono-Bold.woff2
--------------------------------------------------------------------------------
/src/public/images/earth_nightlight.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/images/earth_nightlight.jpg
--------------------------------------------------------------------------------
/src/public/images/iss_array_extend.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/images/iss_array_extend.jpg
--------------------------------------------------------------------------------
/src/public/images/marker_lightCart.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/images/marker_lightCart.png
--------------------------------------------------------------------------------
/src/public/fonts/RobotoMono-Variable.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/fonts/RobotoMono-Variable.woff2
--------------------------------------------------------------------------------
/src/public/fonts/UbuntuMono-Italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/fonts/UbuntuMono-Italic.woff2
--------------------------------------------------------------------------------
/src/public/fonts/UbuntuMono-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/fonts/UbuntuMono-Regular.woff2
--------------------------------------------------------------------------------
/src/public/images/patch_fod_1400_8bit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/images/patch_fod_1400_8bit.png
--------------------------------------------------------------------------------
/src/public/fonts/UbuntuMono-BoldItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/fonts/UbuntuMono-BoldItalic.woff2
--------------------------------------------------------------------------------
/src/public/images/artemis_launch_center.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/images/artemis_launch_center.jpg
--------------------------------------------------------------------------------
/src/public/images/artemis_launch_closeup.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/images/artemis_launch_closeup.jpg
--------------------------------------------------------------------------------
/src/public/favicon/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/favicon/android-chrome-192x192.png
--------------------------------------------------------------------------------
/src/public/favicon/android-chrome-256x256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nasa/coda/int/src/public/favicon/android-chrome-256x256.png
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 100,
3 | "endOfLine": "lf",
4 | "trailingComma": "es5",
5 | "useTabs": false,
6 | "tabWidth": 2
7 | }
8 |
--------------------------------------------------------------------------------
/jest.globalSetup.ts:
--------------------------------------------------------------------------------
1 | const globalSetup = async (): Promise => {
2 | console.log(""); // clears the line in the terminal
3 | };
4 |
5 | export default globalSetup;
6 |
--------------------------------------------------------------------------------
/src/pages/view/index.module.css:
--------------------------------------------------------------------------------
1 | .main {
2 | height: 100vh;
3 | background-color: black;
4 | padding: 0;
5 | }
6 |
7 | .body {
8 | margin: 0;
9 | padding: 0;
10 | }
11 |
--------------------------------------------------------------------------------
/src/utils/loadEnv.ts:
--------------------------------------------------------------------------------
1 | import dotenv from "dotenv";
2 |
3 | // Load environment variables before any server-side modules rely on them.
4 | dotenv.config({ override: true, quiet: true });
5 |
--------------------------------------------------------------------------------
/src/typings/processing/photo.d.ts:
--------------------------------------------------------------------------------
1 | type PhotoRecord = {
2 | id: number;
3 | date: string;
4 | source: string;
5 | timeOffset: string;
6 | };
7 |
8 | type PhotoRecord_db_type = PhotoRecord;
9 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | **/.dockerignore
2 | **/.git
3 | **/.env
4 | **/.gitignore
5 | **/.vs
6 | **/.vscode
7 | **/*.*proj.user
8 | **/docker-compose*
9 | **/node_modules
10 | .gitlab
11 | .local
12 | README.md
13 | .cache
--------------------------------------------------------------------------------
/src/server/database/tsconfig.orm.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.json",
3 | "compilerOptions": {
4 | "module": "NodeNext",
5 | "moduleResolution": "NodeNext",
6 | "declaration": true
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig.jest.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "jsx": "react-jsx",
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "types": ["jest", "node", "react", "react-dom", "react-test-renderer"]
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/typings/overrides.d.ts:
--------------------------------------------------------------------------------
1 | type MediaOverride = {
2 | id?: number;
3 | date: string;
4 | source: Source;
5 | type: MediaMedium;
6 | url: string;
7 | };
8 |
9 | type MediaOverride_db_type = MediaOverride;
10 |
11 | type MediaOverrideList = Omit;
12 |
--------------------------------------------------------------------------------
/src/utils/user.ts:
--------------------------------------------------------------------------------
1 | const SUPERUSER_ROLES: EMSSRole[] = ["EMSS-Superuser", "CODA-Superuser"];
2 |
3 | export const isSuperuser = (user: EmssUser | null | undefined): boolean => {
4 | if (!user?.roles) return false;
5 |
6 | return SUPERUSER_ROLES.some((role) => user.roles.includes(role));
7 | };
8 |
--------------------------------------------------------------------------------
/src/typings/index.d.ts:
--------------------------------------------------------------------------------
1 | interface QueryParams {
2 | /** yyyy-mm-dd the user wants to view */
3 | date: string;
4 | /** UTC hh:mm the user wants to view */
5 | gmt: string;
6 | frameworkState: FrameworkState;
7 | }
8 |
9 | type FetchOptionsCredentials = "include" | "same-origin" | "omit";
10 |
--------------------------------------------------------------------------------
/src/utils/logging/clientLogger.ts:
--------------------------------------------------------------------------------
1 | import { createClientLogger } from "@emss/logger";
2 |
3 | /**
4 | * **Do not use on server.**
5 | *
6 | * Used for client-side logging only.
7 | */
8 | const clientLogger = createClientLogger("/api/v1/log/from-client");
9 |
10 | export default clientLogger;
11 |
--------------------------------------------------------------------------------
/src/components/interface/pane-help-control-button.module.css:
--------------------------------------------------------------------------------
1 | .helpButton {
2 | display: block;
3 | width: 18px;
4 | color: var(--even-greyer);
5 | border: none;
6 | cursor: pointer;
7 | }
8 |
9 | .helpButton:hover {
10 | color: #eeeeee;
11 | }
12 |
13 | .selected {
14 | color: #eeeeee;
15 | }
16 |
--------------------------------------------------------------------------------
/docker/mediamtx-mock/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM bluenviron/mediamtx:1.15.4-ffmpeg
2 |
3 | # add dejavu font
4 | RUN apk add --no-cache \
5 | font-dejavu=2.37-r6
6 |
7 | # add entrypoint script
8 | COPY ./docker/mediamtx-mock/entrypoint.sh /entrypoint.sh
9 |
10 | ENTRYPOINT ["/entrypoint.sh"]
11 |
12 | CMD ["/mediamtx", "/mediamtx.yml"]
13 |
--------------------------------------------------------------------------------
/src/packages/asyncSleep.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Convenience function, may not be used anywhere in the codebase but is sometimes useful for debug
3 | * or to see how an asyncronous UI changes if you add delay.
4 | */
5 | export const asyncSleep = async (duration: number): Promise =>
6 | new Promise((resolve) => {
7 | setTimeout(resolve, duration);
8 | });
9 |
--------------------------------------------------------------------------------
/src/utils/useAppDispatch.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line no-restricted-imports
2 | import { useDispatch } from "react-redux";
3 | import type { AppDispatch } from "store";
4 |
5 | // Export a hook that can be reused to resolve types
6 | // ref: https://redux-toolkit.js.org/usage/usage-with-typescript
7 | export const useAppDispatch: () => AppDispatch = useDispatch;
8 |
--------------------------------------------------------------------------------
/src/typings/processing/daynight.d.ts:
--------------------------------------------------------------------------------
1 | interface DayNightStore {
2 | dayNight: DayNightObj[];
3 | }
4 |
5 | /** Possible sun lighting states */
6 | type SunLighting = "day" | "night" | "sunrise" | "sunset";
7 |
8 | /** The current daylihgt state at a given appSecond */
9 | interface DayNightObj {
10 | appSeconds: number;
11 | daylight: SunLighting;
12 | }
13 |
--------------------------------------------------------------------------------
/src/public/clocksync/server/gettime.php:
--------------------------------------------------------------------------------
1 | setTimezone(new DateTimeZone('GMT'));
6 |
7 | header('Content-Type: application/json');
8 | echo "{ \"serverTime\": \"" . $d->format("Y-m-d\TH:i:s.u\Z") . "\" }"; // note at point on "u"
9 | ?>
--------------------------------------------------------------------------------
/appcompose:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | export DOCKER_SCAN_SUGGEST=false
4 |
5 | if [ "${1}" = 'services' ]; then
6 | docker compose -f docker-compose.yml -f docker-compose.services.yml "${@:2}"
7 | elif [ "${1}" = "preview" ]; then # override the default compose file with preview
8 | docker compose -f docker-compose.yml -f docker-compose.preview.yml "${@:2}"
9 | else
10 | echo "Must specify 'services' or 'preview'"
11 | fi
12 |
--------------------------------------------------------------------------------
/src/pages/view/iss.tsx:
--------------------------------------------------------------------------------
1 | import { Navigate, useLocation } from "react-router";
2 | import { sourceShortVal } from "utils/consts";
3 |
4 | export default function RedirectPage() {
5 | const location = useLocation();
6 | const searchParams = new URLSearchParams(location.search);
7 | searchParams.append("s", sourceShortVal.ISS.toString());
8 | return ;
9 | }
10 |
--------------------------------------------------------------------------------
/src/pages/view/nbl.tsx:
--------------------------------------------------------------------------------
1 | import { Navigate, useLocation } from "react-router";
2 | import { sourceShortVal } from "utils/consts";
3 |
4 | export default function RedirectPage() {
5 | const location = useLocation();
6 | const searchParams = new URLSearchParams(location.search);
7 | searchParams.append("s", sourceShortVal.NBL.toString());
8 | return ;
9 | }
10 |
--------------------------------------------------------------------------------
/src/store/user.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 |
3 | export const initialState: UserState = {
4 | user: null,
5 | };
6 |
7 | export const userSlice = createSlice({
8 | name: "user",
9 | initialState,
10 | reducers: {
11 | setUser: (state, action: { payload: EmssUser }) => {
12 | state.user = action.payload;
13 | },
14 | },
15 | });
16 |
17 | export const { setUser } = userSlice.actions;
18 |
--------------------------------------------------------------------------------
/src/server/database/migrations/Migration20251106000000_manual.ts:
--------------------------------------------------------------------------------
1 | import { Migration } from "@mikro-orm/migrations";
2 |
3 | export class Migration20251106000000 extends Migration {
4 | override async up(): Promise {
5 | this.addSql(`delete from "cache_db";`);
6 | }
7 |
8 | override async down(): Promise {
9 | // This migration deletes all cache data, so there is no down migration to restore it
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/pages/view/test-events.tsx:
--------------------------------------------------------------------------------
1 | import { Navigate, useLocation } from "react-router";
2 | import { sourceShortVal } from "utils/consts";
3 |
4 | export default function RedirectPage() {
5 | const location = useLocation();
6 | const searchParams = new URLSearchParams(location.search);
7 | searchParams.append("s", sourceShortVal.TEST_EVENTS.toString());
8 | return ;
9 | }
10 |
--------------------------------------------------------------------------------
/src/styles.css:
--------------------------------------------------------------------------------
1 | * {
2 | font-family: "Inter", sans-serif;
3 | font-feature-settings: "tnum"; /* mono-spaced digits */
4 | }
5 |
6 | body {
7 | background-color: #3e3b44;
8 | /* overflow-x: hidden; */
9 | margin: 0;
10 | color: #ffffff;
11 | }
12 |
13 | p {
14 | margin-top: 0;
15 | }
16 |
17 | input:focus {
18 | outline: none;
19 | }
20 |
21 | button:focus {
22 | outline: none;
23 | }
24 |
25 | a {
26 | color: white;
27 | }
28 |
--------------------------------------------------------------------------------
/src/server/express/routes/time/time.ts:
--------------------------------------------------------------------------------
1 | import express, { Request, Response, Router } from "express";
2 |
3 | const router: Router = express.Router();
4 |
5 | router.get("/", (req: Request, res: Response) => {
6 | try {
7 | const currentTime = new Date().toISOString();
8 | res.json({ time: currentTime });
9 | } catch (error) {
10 | res.status(500).json({ error: "Failed to get server time" });
11 | }
12 | });
13 |
14 | export default router;
15 |
--------------------------------------------------------------------------------
/src/public/favicon/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "short_name": "",
4 | "icons": [
5 | {
6 | "src": "/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/android-chrome-256x256.png",
12 | "sizes": "256x256",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/ci/suppression.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
13 |
14 |
--------------------------------------------------------------------------------
/src/server/database/migrations/Migration20240530185740.ts:
--------------------------------------------------------------------------------
1 | import { Migration } from "@mikro-orm/migrations";
2 |
3 | export class Migration20240530185740 extends Migration {
4 | async up(): Promise {
5 | this.addSql(
6 | 'create table "gpxtracks_db" ("id" serial primary key, "date" text not null, "name" text not null, "gpx_data" text not null);'
7 | );
8 | }
9 |
10 | async down(): Promise {
11 | this.addSql('drop table if exists "gpxtracks_db" cascade;');
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/server/database/models/VideoStartTimeOverrides.model.ts:
--------------------------------------------------------------------------------
1 | import { Entity, PrimaryKey, Property } from "@mikro-orm/postgresql";
2 | import { types as MikroTypes } from "@mikro-orm/postgresql";
3 |
4 | @Entity()
5 | export class VideoStartTimeOverrides_db implements VideoRecord_db_type {
6 | @PrimaryKey({ type: MikroTypes.integer })
7 | id!: number;
8 |
9 | @Property({ type: MikroTypes.string })
10 | videoId!: string;
11 | @Property({ type: MikroTypes.string })
12 | startTime!: string;
13 | }
14 |
--------------------------------------------------------------------------------
/src/pages/admin/ephemeris.module.css:
--------------------------------------------------------------------------------
1 | /* Ephemeris Page Specific Styles */
2 | /* Note: Most shared styles come from shared.module.css */
3 |
4 | /* Year counts grid layout */
5 | .yearCountsGrid {
6 | display: grid;
7 | grid-auto-flow: column;
8 | gap: 4px 16px;
9 | font-size: 0.9rem;
10 | }
11 |
12 | .yearCountItem {
13 | display: flex;
14 | justify-content: space-between;
15 | }
16 |
17 | .yearCountLabel {
18 | color: #cbd5e1;
19 | }
20 |
21 | .yearCountValue {
22 | color: #e2e8f0;
23 | }
24 |
--------------------------------------------------------------------------------
/src/public/images/icon_status_check_green.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/public/images/icon_status_check_yellow.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/server/database/migrations/Migration20240703205714.ts:
--------------------------------------------------------------------------------
1 | import { Migration } from "@mikro-orm/migrations";
2 |
3 | export class Migration20240703205714 extends Migration {
4 | async up(): Promise {
5 | this.addSql(
6 | 'create table "media_override_db" ("id" serial primary key, "date" text not null, "source" text not null, "type" text not null, "url" text not null);'
7 | );
8 | }
9 |
10 | async down(): Promise {
11 | this.addSql('drop table if exists "media_override_db" cascade;');
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/server/database/models/gpxTracks.model.ts:
--------------------------------------------------------------------------------
1 | import { Entity, PrimaryKey, Property } from "@mikro-orm/postgresql";
2 | import { types as MikroTypes } from "@mikro-orm/postgresql";
3 |
4 | @Entity()
5 | export class GPXTracks_db implements GPXTrackRecord_db_type {
6 | @PrimaryKey({ type: MikroTypes.integer })
7 | id!: number;
8 |
9 | @Property({ type: MikroTypes.text })
10 | date!: string;
11 | @Property({ type: MikroTypes.text })
12 | name!: string;
13 | @Property({ type: MikroTypes.text })
14 | gpxData!: string;
15 | }
16 |
--------------------------------------------------------------------------------
/src/server/database/migrations/Migration20240710153247.ts:
--------------------------------------------------------------------------------
1 | import { Migration } from "@mikro-orm/migrations";
2 |
3 | export class Migration20240710153247 extends Migration {
4 | async up(): Promise {
5 | this.addSql(
6 | 'create table "ancillary_data_source_db" ("id" serial primary key, "date" text not null, "source" text not null, "type" text not null, "url" text not null);'
7 | );
8 | }
9 |
10 | async down(): Promise {
11 | this.addSql('drop table if exists "ancillary_data_source_db" cascade;');
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/server/database/models/PhotoTimeShifts.model.ts:
--------------------------------------------------------------------------------
1 | import { Entity, PrimaryKey, Property } from "@mikro-orm/postgresql";
2 | import { types as MikroTypes } from "@mikro-orm/postgresql";
3 |
4 | @Entity()
5 | export class PhotoTimeShifts_db implements PhotoRecord_db_type {
6 | @PrimaryKey({ type: MikroTypes.integer })
7 | id!: number;
8 |
9 | @Property({ type: MikroTypes.text })
10 | date!: string;
11 | @Property({ type: MikroTypes.text })
12 | source!: string;
13 | @Property({ type: MikroTypes.text })
14 | timeOffset!: string;
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/panes/iss-location-marker.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent } from "react";
2 | import styles from "./iss-location-marker.module.css";
3 |
4 | const Marker: FunctionComponent<{ type: string; id: string }> = ({ type, id }) => {
5 | let markerClass = "";
6 | if (type === "playheadMarker") {
7 | markerClass = styles.playheadMarker;
8 | } else if (type === "hoverMarker") {
9 | markerClass = styles.hoverMarker;
10 | }
11 |
12 | return ;
13 | };
14 |
15 | export default Marker;
16 |
--------------------------------------------------------------------------------
/src/typings/processing/ephemeris.d.ts:
--------------------------------------------------------------------------------
1 | interface EphemerisStore {
2 | ephemera: EphemerisEntry[];
3 | }
4 |
5 | interface EphemerisEntry {
6 | epoch: string;
7 | tle_line1: string;
8 | tle_line2: string;
9 | }
10 |
11 | interface Ephemeris_db_type {
12 | epoch: Date;
13 | tle_line1: string;
14 | tle_line2: string;
15 | origin: "celestrak" | "seed";
16 | createdAt: Date;
17 | }
18 |
19 | interface CelestrakUpdateResult {
20 | success: boolean;
21 | epoch?: string; // ISO timestamp of the TLE epoch
22 | errorMessage?: string;
23 | }
24 |
--------------------------------------------------------------------------------
/src/typings/processing/gps.d.ts:
--------------------------------------------------------------------------------
1 | interface GPSTrack {
2 | name: string;
3 | points: GPSPoint[];
4 | }
5 |
6 | interface GPSTrackToggles {
7 | [key: string]: boolean;
8 | }
9 |
10 | type GPSPoint = {
11 | lat: number;
12 | lon: number;
13 | ele: number;
14 | time: string;
15 | };
16 |
17 | // Database types
18 | type GPXTrackRecord = {
19 | id: number;
20 | date: string;
21 | name: string;
22 | gpxData: string;
23 | };
24 |
25 | type GPXTrackRecord_db_type = GPXTrackRecord;
26 |
27 | type GPXTrackListRecord = Omit;
28 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "git.ignoreLimitWarning": true,
3 | "editor.formatOnSave": true,
4 | "editor.tabSize": 2,
5 | "[typescriptreact]": {
6 | "editor.defaultFormatter": "esbenp.prettier-vscode"
7 | },
8 | "[typescript]": {
9 | "editor.defaultFormatter": "esbenp.prettier-vscode"
10 | },
11 | "[javascript]": {
12 | "editor.defaultFormatter": "esbenp.prettier-vscode"
13 | },
14 | "[css]": {
15 | "editor.defaultFormatter": "esbenp.prettier-vscode"
16 | },
17 | "[html]": {
18 | "editor.defaultFormatter": "esbenp.prettier-vscode"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/public/images/icon_status_no_assets.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/server/database/models/mediaOverride.model.ts:
--------------------------------------------------------------------------------
1 | import { Entity, PrimaryKey, Property } from "@mikro-orm/postgresql";
2 | import { types as MikroTypes } from "@mikro-orm/postgresql";
3 |
4 | @Entity()
5 | export class MediaOverride_db implements MediaOverride_db_type {
6 | @PrimaryKey({ type: MikroTypes.integer })
7 | id!: number;
8 |
9 | @Property({ type: MikroTypes.text })
10 | date!: string;
11 | @Property({ type: MikroTypes.text })
12 | source!: Source;
13 | @Property({ type: MikroTypes.text })
14 | type!: MediaMedium;
15 | @Property({ type: MikroTypes.text })
16 | url!: string;
17 | }
18 |
--------------------------------------------------------------------------------
/src/server/database/models/ancillaryData.model.ts:
--------------------------------------------------------------------------------
1 | import { Entity, PrimaryKey, Property } from "@mikro-orm/postgresql";
2 | import { types as MikroTypes } from "@mikro-orm/postgresql";
3 |
4 | @Entity()
5 | export class AncillaryDataSource_db implements AncillaryDataSource_db_type {
6 | @PrimaryKey({ type: MikroTypes.integer })
7 | id!: number;
8 |
9 | @Property({ type: MikroTypes.text })
10 | date!: string;
11 | @Property({ type: MikroTypes.text })
12 | source!: Source;
13 | @Property({ type: MikroTypes.text })
14 | type!: "graphs";
15 | @Property({ type: MikroTypes.text })
16 | url!: string;
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/interface/button.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent, ReactNode } from "react";
2 | import styles from "./button.module.css";
3 |
4 | const Button: FunctionComponent<{
5 | children: ReactNode;
6 | color: string;
7 | size: string;
8 | rounded?: string;
9 | callback?: () => void;
10 | }> = ({ children, color = "grey", size = "default", rounded = "all", callback = () => {} }) => {
11 | return (
12 |
18 | );
19 | };
20 |
21 | export default Button;
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # macos
2 | .DS_Store
3 |
4 | # javascript
5 | node_modules/
6 | coverage/
7 |
8 | # nextjs
9 | .next/
10 | out/
11 |
12 | # production
13 | dist/
14 |
15 | # local cache
16 | .cache*
17 |
18 | # Directory where local data is stored during dev
19 | .local
20 |
21 | # MWBot cookie directory
22 | .cookies
23 |
24 | # don't commit secrets! (.env.local no longer used, but ignore to protect data on dev's computers)
25 | .env.local
26 | .env
27 | .env.secret
28 | .env.old
29 | env.secret.ts
30 |
31 | # NOCA cert required to hit wiki and IO. See README.md
32 | .env.local.cert.pem
33 |
34 | # typescript
35 | tsconfig.tsbuildinfo
36 |
37 | # created by coverage reporter
38 | junit.xml
--------------------------------------------------------------------------------
/src/public/images/icon_status_loading.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/public/images/icon_status_error.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/docker/mediamtx-mock/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | # Explicitly set FONTCONFIG_FILE environment variable
3 | export FONTCONFIG_FILE=/etc/fonts/fonts.conf
4 |
5 | # Create crond directory
6 | mkdir -p /etc/cron.d
7 |
8 | # Set up a cron job to delete empty folders in /recordings because mediamtx doesn't clean up after itself
9 | echo "*/5 * * * * find /recordings -type d -empty -delete" > /etc/cron.d/delete_empty_folders
10 |
11 | # Set up a cron job to delete HLS files older than 26 hours
12 | echo "*/5 * * * * find /hls -type f -mmin +1560 -delete" > /etc/cron.d/delete_old_hls_files
13 |
14 | chmod 0644 /etc/cron.d/delete_empty_folders
15 | crontab /etc/cron.d/delete_empty_folders
16 |
17 | # Start crond and run mediamtx
18 | crond && exec "$@"
19 |
--------------------------------------------------------------------------------
/src/server/database/models/ephemera.model.ts:
--------------------------------------------------------------------------------
1 | import { Entity, PrimaryKey, Property, Index } from "@mikro-orm/postgresql";
2 | import { types as MikroTypes } from "@mikro-orm/postgresql";
3 |
4 | @Entity()
5 | @Index({ properties: ["epoch"] })
6 | export class Ephemeris_db implements Ephemeris_db_type {
7 | @PrimaryKey({ type: MikroTypes.datetime, length: 3 })
8 | epoch!: Date;
9 |
10 | @Property({ type: MikroTypes.text })
11 | tle_line1!: string;
12 |
13 | @Property({ type: MikroTypes.text })
14 | tle_line2!: string;
15 |
16 | @Property({ type: MikroTypes.string, length: 20 })
17 | origin!: "celestrak" | "seed";
18 |
19 | @Property({ type: MikroTypes.datetime, length: 3, defaultRaw: "now()" })
20 | createdAt!: Date;
21 | }
22 |
--------------------------------------------------------------------------------
/src/utils/logging/serverLogger.ts:
--------------------------------------------------------------------------------
1 | import "utils/loadEnv";
2 | import { createServerLogger } from "@emss/logger";
3 | import { assertEnvVarsExist } from "@emss/utils";
4 |
5 | const env = assertEnvVarsExist(
6 | "LOG_ENABLE_APP_LOGGING",
7 | "LOG_SERVER_HTTP_ENDPOINT",
8 | "LOG_DATA_APP_ID",
9 | "LOG_DATA_SERVER_NAME"
10 | );
11 |
12 | /**
13 | * **Do not use in browser.**
14 | *
15 | * Used for server-side logging only.
16 | */
17 | const serverLogger = createServerLogger({
18 | logEnableAppLogging: env.LOG_ENABLE_APP_LOGGING === "true",
19 | logServerHttpEndpoint: env.LOG_SERVER_HTTP_ENDPOINT,
20 | logDataAppId: env.LOG_DATA_APP_ID,
21 | logDataServerName: env.LOG_DATA_SERVER_NAME,
22 | });
23 |
24 | export default serverLogger;
25 |
--------------------------------------------------------------------------------
/src/typings/global.d.ts:
--------------------------------------------------------------------------------
1 | type GlobalValues = {
2 | socketio: import("socket.io").Server<
3 | ClientToServerEvents,
4 | ServerToClientEvents,
5 | import("socket.io/dist/typed-events").DefaultEventsMap,
6 | {}
7 | >;
8 | orm: import("@mikro-orm/postgresql").MikroORM | null;
9 | serverSocketStatus: ServerSocketStatus;
10 | socketInterval: NodeJS.Timeout;
11 | appVersion: AppVersion | null;
12 | fetchTrackers: FetchTrackers;
13 | talkybotS2sSocket: import("socket.io-client").Socket | null;
14 | celestrakInterval: NodeJS.Timeout | null;
15 | celestrakTrackerData: CelestrakTrackerData;
16 | };
17 |
18 | // these are defined in esbuild.mjs and vite.config.mts
19 | declare const __APP_VERSION__: string;
20 | declare const __GIT_COMMIT__: string;
21 |
--------------------------------------------------------------------------------
/src/packages/fetchFns.ts:
--------------------------------------------------------------------------------
1 | import {
2 | FetchFn,
3 | FetchJsonWithAuth,
4 | createFetchWithAuthFunctions,
5 | webAuthPopup,
6 | } from "@emss/oauth2-proxy-frontend";
7 |
8 | export let fetchWithAuth: FetchFn = async () => {
9 | throw new Error("fetchWithAuth() must be initialized first");
10 | };
11 |
12 | export let fetchJsonWithAuth: FetchJsonWithAuth = () => {
13 | throw new Error("fetchJsonWithAuth() must be initialized first");
14 | };
15 |
16 | export const setupFetchFns = (fqdn: string = ""): void => {
17 | const functions = createFetchWithAuthFunctions(
18 | webAuthPopup,
19 | fqdn + "/login",
20 | fqdn + "/oauth2/userinfo"
21 | );
22 |
23 | fetchWithAuth = functions.fetchWithAuth;
24 | fetchJsonWithAuth = functions.fetchJsonWithAuth;
25 | };
26 |
--------------------------------------------------------------------------------
/src/typings/processing/talkybot.d.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * API response types from Talkybot on the coda API endpoint.
3 | * Only includes fields needed for audio playback and transcript display.
4 | */
5 |
6 | interface TbAudioFile {
7 | fileUuid: string;
8 | startTime: Date;
9 | appSeconds?: number;
10 | duration: number;
11 | channel: string; // channel slug (e.g. "sg1")
12 | text: string;
13 | language: string;
14 |
15 | /** Indicates this is from an override source, not Talkybot API */
16 | override?: boolean;
17 | /** Full URL to download audio file (used for overrides since they're not from Talkybot API) */
18 | audioUrl?: string;
19 | }
20 |
21 | interface TbDateResponse {
22 | date: string; // ISO date format (YYYY-MM-DD)
23 | audioFiles: TbAudioFile[];
24 | }
25 |
--------------------------------------------------------------------------------
/src/server/database/migrations/Migration20250611191755.ts:
--------------------------------------------------------------------------------
1 | import { Migration } from "@mikro-orm/migrations";
2 |
3 | export class Migration20250611191755 extends Migration {
4 | override async up(): Promise {
5 | this.addSql(
6 | `create table "cache_db" ("id" serial primary key, "folder" text not null, "cache_key" text not null, "data" jsonb null, "metadata" jsonb not null, "created_at" timestamptz(3) not null, "last_accessed_at" timestamptz(3) not null);`
7 | );
8 | this.addSql(`create index "cache_db_folder_index" on "cache_db" ("folder");`);
9 | this.addSql(`create index "cache_db_cache_key_index" on "cache_db" ("cache_key");`);
10 | }
11 |
12 | override async down(): Promise {
13 | this.addSql(`drop table if exists "cache_db" cascade;`);
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/server/database/migrations/Migration20240718202407.ts:
--------------------------------------------------------------------------------
1 | import { Migration } from "@mikro-orm/migrations";
2 |
3 | export class Migration20240718202407 extends Migration {
4 | async up(): Promise {
5 | this.addSql(
6 | 'create table "photo_time_shifts_db" ("id" serial primary key, "date" text not null, "source" text not null, "time_offset" text not null);'
7 | );
8 |
9 | this.addSql(
10 | 'create table "video_start_time_overrides_db" ("id" serial primary key, "video_id" varchar(255) not null, "start_time" varchar(255) not null);'
11 | );
12 | }
13 |
14 | async down(): Promise {
15 | this.addSql('drop table if exists "photo_time_shifts_db" cascade;');
16 |
17 | this.addSql('drop table if exists "video_start_time_overrides_db" cascade;');
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/server/database/models/cache.model.ts:
--------------------------------------------------------------------------------
1 | import { Entity, PrimaryKey, Property, Index, types as MikroTypes } from "@mikro-orm/postgresql";
2 |
3 | @Entity()
4 | export class Cache_db implements CacheRecord_db_type {
5 | @PrimaryKey({ type: MikroTypes.integer, autoincrement: true })
6 | id!: number;
7 |
8 | @Property({ type: MikroTypes.text })
9 | @Index()
10 | folder!: string;
11 |
12 | @Property({ type: MikroTypes.text })
13 | @Index()
14 | cacheKey!: string;
15 |
16 | @Property({ type: MikroTypes.json, nullable: true })
17 | data!: unknown;
18 |
19 | @Property({ type: MikroTypes.json })
20 | metadata!: CacheMetadata;
21 |
22 | @Property({ type: MikroTypes.datetime, length: 3 })
23 | createdAt!: Date;
24 |
25 | @Property({ type: MikroTypes.datetime, length: 3 })
26 | lastAccessedAt!: Date;
27 | }
28 |
--------------------------------------------------------------------------------
/src/packages/EnsureLogin.tsx:
--------------------------------------------------------------------------------
1 | import { FC, useEffect } from "react";
2 | import { getCurrentUser } from "./getCurrentUser";
3 | import { setupFetchFns } from "./fetchFns";
4 | import { useAppDispatch } from "utils/useAppDispatch";
5 | import { setUser } from "store/user";
6 |
7 | export const EnsureLogin: FC<{ fqdn?: string }> = ({ fqdn = "" }) => {
8 | const dispatch = useAppDispatch();
9 | useEffect(() => {
10 | setupFetchFns();
11 | getCurrentUser().then((user) => {
12 | if (user instanceof Error) {
13 | console.error("Unable to get current user", user);
14 | return;
15 | }
16 | console.log(`Welcome, ${user.display_name || "unknown user"}`);
17 | dispatch(setUser(user));
18 | });
19 | }, [fqdn]);
20 |
21 | // component has no display, just ensures login
22 | return null;
23 | };
24 |
--------------------------------------------------------------------------------
/src/public/clockcalc/clockcalc.js:
--------------------------------------------------------------------------------
1 | function runCalc() {
2 | const qrDate = new Date(document.getElementById("qrdate").value + "Z");
3 | const seconds = document.getElementById("seconds").value;
4 | const qrVidStartDate = new Date(document.getElementById("qrvidstartdate").value + "Z");
5 | const otherStartDate = new Date(document.getElementById("otherstartdate").value + "Z");
6 |
7 | const calcQrVidStartDate = new Date(qrDate - seconds * 1000);
8 | document.getElementById("qrcalcstart").innerHTML = calcQrVidStartDate.toISOString();
9 |
10 | const offset = calcQrVidStartDate - qrVidStartDate;
11 | document.getElementById("offset").innerHTML = offset;
12 |
13 | const calcOtherStartDate = new Date(otherStartDate.getTime() + offset);
14 | document.getElementById("othercalcstart").innerHTML = calcOtherStartDate.toISOString();
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/interface/pane-help-control-button.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent } from "react";
2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3 | import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
4 | import styles from "./pane-help-control-button.module.css";
5 |
6 | export const HelpButton: FunctionComponent<{ clickHandler: () => void; selected?: boolean }> = ({
7 | clickHandler,
8 | selected,
9 | }) => {
10 | const selectedStyle = selected ? styles.selected : "";
11 | return (
12 | {
16 | if (clickHandler) {
17 | clickHandler();
18 | }
19 | }}
20 | >
21 |
22 |
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/src/components/panes/graph/plotly-class.ts:
--------------------------------------------------------------------------------
1 | import * as Plotly from "plotly.js-basic-dist";
2 | import { MutableRefObject } from "react";
3 |
4 | declare module "plotly.js" {
5 | namespace Fx {
6 | function hover(element: HTMLElement, eventData: any[], mode?: string): void;
7 | }
8 | }
9 |
10 | export default class PlotlyClass {
11 | constructor() {}
12 |
13 | drawChart(
14 | chartID: string,
15 | plotlyChartTraces: PlotlyChartTrace[],
16 | plotlyChartLayout: Partial
17 | ) {
18 | Plotly.newPlot(chartID, plotlyChartTraces as Plotly.Data[], plotlyChartLayout, {
19 | displayModeBar: false,
20 | });
21 | }
22 |
23 | hoverPoint(chartRef: MutableRefObject, pointNumber: number) {
24 | Plotly.Fx.hover(chartRef.current, [{ curveNumber: 0, pointNumber: pointNumber }]);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/utils/consts.ts:
--------------------------------------------------------------------------------
1 | export const collection: Record = {
2 | ISS: 4,
3 | TEST_EVENTS: 2359932,
4 | NBL: 78178,
5 | ARTEMIS: 2346894,
6 | };
7 |
8 | export const sourceShortVal: Record = {
9 | ISS: 0,
10 | TEST_EVENTS: 1,
11 | NBL: 2,
12 | ARTEMIS: 3,
13 | };
14 |
15 | export const sequenceType: Record = {
16 | EVA: 1,
17 | IVA: 2,
18 | testing: "testing",
19 | analog: "analog",
20 | training: "training",
21 | };
22 |
23 | export const paneTypeShortVal: Record = {
24 | empty: 0,
25 | video_downlink: 1,
26 | video_non_downlink: 2,
27 | photo: 3,
28 | event_info: 4,
29 | iss_location: 5,
30 | gps_location: 6,
31 | photo_all: 7,
32 | talkybot: 8,
33 | // 9 was sg-audio for some reason --- IGNORE ---
34 | graph: 10,
35 | };
36 |
--------------------------------------------------------------------------------
/scripts/make-dev-ssl-cert.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | #
3 | # Creates a self-signed SSL certificate for the purpose of local development
4 |
5 | if [ -z "${1}" ]; then
6 | echo "No Common Name supplied (e.g. yoursite.example.com), using \"localhost\""
7 | CN="localhost"
8 | else
9 | CN="${1}"
10 | fi
11 |
12 | set -eux
13 |
14 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
15 | LOCAL_DIR="${SCRIPT_DIR}/../.local"
16 | CERTS_DIR="${LOCAL_DIR}/certs"
17 | PRIVATE_DIR="${LOCAL_DIR}/private"
18 |
19 | mkdir -p "${CERTS_DIR}"
20 | mkdir -p "${PRIVATE_DIR}"
21 |
22 | openssl req -x509 -newkey rsa:4096 \
23 | -keyout "${PRIVATE_DIR}/nginx.key" \
24 | -out "${CERTS_DIR}/nginx.crt" \
25 | -days 365 -nodes \
26 | -subj "//C=US/C=US/ST=Texas/L=Houston/O=NASA/CN=${CN}" # doubled //C=US/C=US https://github.com/openssl/openssl/issues/8795
27 |
--------------------------------------------------------------------------------
/src/components/interface/nav-timeline-draw.module.css:
--------------------------------------------------------------------------------
1 | .canvasContainer {
2 | position: fixed;
3 | width: 100%;
4 | height: 162px;
5 | bottom: 0;
6 | left: 0;
7 | /* background-color: blue;
8 | opacity: 0.5; */
9 | /* background-color: #2e2b34; */
10 | z-index: 30;
11 | pointer-events: auto;
12 | }
13 |
14 | .canvasContainer canvas {
15 | /* position: fixed;
16 | left: 0;
17 | bottom: 0; */
18 | height: 162px;
19 | width: 100%;
20 | }
21 |
22 | .collapsedBackground {
23 | /* position: fixed; */
24 | width: 100%;
25 | height: 106px;
26 | bottom: 0;
27 | left: 0;
28 | background-color: var(--very-dark-grey);
29 | z-index: 0;
30 | }
31 |
32 | .expandedBackground {
33 | /* position: fixed; */
34 | width: 100%;
35 | height: 122px;
36 | bottom: 0;
37 | left: 0;
38 | background-color: var(--very-dark-grey);
39 | z-index: 0;
40 | }
41 |
--------------------------------------------------------------------------------
/src/utils/useInterval.ts:
--------------------------------------------------------------------------------
1 | /*
2 | Borrowed from https://overreacted.io/making-setinterval-declarative-with-react-hooks/#just-show-me-the-code
3 | */
4 |
5 | import { useEffect, useRef } from "react";
6 |
7 | /**
8 | * Create an interval hook
9 | */
10 | export default function useInterval(callback: () => void, delay_ms: number) {
11 | if (typeof window === "undefined") {
12 | return;
13 | }
14 |
15 | const savedCallback = useRef(() => {});
16 |
17 | // Remember the latest callback.
18 | useEffect(() => {
19 | savedCallback.current = callback;
20 | }, [callback]);
21 |
22 | // Set up the interval.
23 | useEffect(() => {
24 | function tick() {
25 | savedCallback.current();
26 | }
27 | if (delay_ms !== null) {
28 | const id = setInterval(tick, delay_ms);
29 | return () => clearInterval(id);
30 | }
31 | }, [delay_ms]);
32 | }
33 |
--------------------------------------------------------------------------------
/src/typings/cache.d.ts:
--------------------------------------------------------------------------------
1 | type FetchStatus = "inprogress" | "complete" | "error";
2 |
3 | /** New simplified metadata for fetch results - no caching concerns */
4 | interface FetchMetadata {
5 | success: boolean;
6 | error?: string;
7 | timestamp: string;
8 | /** Indicates this data type is not applicable for the current source */
9 | unneeded?: boolean;
10 | }
11 |
12 | /** New simplified response type focused on fetch success/failure - no caching concerns */
13 | interface FetchResponse {
14 | data: T;
15 | fetchMetadata: FetchMetadata;
16 | origin?: string; // where the data came from
17 | }
18 |
19 | type CacheMetadata = {
20 | expiration: string;
21 | };
22 |
23 | type CacheRecord_db_type = {
24 | id: number;
25 | folder: string;
26 | cacheKey: string;
27 | data: unknown;
28 | metadata: CacheMetadata;
29 | createdAt: Date;
30 | lastAccessedAt: Date;
31 | };
32 |
--------------------------------------------------------------------------------
/src/packages/getCurrentUser.ts:
--------------------------------------------------------------------------------
1 | import { asError } from "@emss/utils";
2 | import { EmssUser } from "@emss/oauth2-proxy-common";
3 | // import { fetchJsonWithAuth } from "@emss/oauth2-proxy-frontend";
4 | import { fetchJsonWithAuth } from "./fetchFns";
5 |
6 | let currentUser: undefined | EmssUser;
7 |
8 | export const getCurrentUser = async (): Promise => {
9 | if (currentUser) {
10 | return currentUser;
11 | }
12 |
13 | try {
14 | const json = await fetchJsonWithAuth<{ user: EmssUser }>("/api/v1/user/current");
15 | if (json instanceof Error) {
16 | console.error("Unable to get current user", json);
17 | return;
18 | }
19 | currentUser = json.user;
20 |
21 | return currentUser;
22 | } catch (err) {
23 | return asError(err);
24 | }
25 | };
26 |
27 | export const clearCurrentUser = (): void => {
28 | currentUser = undefined;
29 | };
30 |
--------------------------------------------------------------------------------
/src/components/panes/gps-location-marker.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent } from "react";
2 | import styles from "./gps-location-marker.module.css";
3 |
4 | const GPSMarker: FunctionComponent<{ type: string; id: any }> = ({ type, id }) => {
5 | let markerClass = "";
6 | if (type === "EV1") {
7 | markerClass = styles.ev1Marker;
8 | } else if (type === "EV2") {
9 | markerClass = styles.ev2Marker;
10 | } else if (type === "EV3") {
11 | markerClass = styles.ev3Marker;
12 | } else if (type === "EV4") {
13 | markerClass = styles.ev4Marker;
14 | } else if (type === "Cart") {
15 | markerClass = styles.cartMarker;
16 | } else if (type === "LightCart") {
17 | markerClass = styles.lightCartMarker;
18 | } else if (type === "Staff") {
19 | markerClass = styles.ev1Marker;
20 | }
21 |
22 | return ;
23 | };
24 |
25 | export default GPSMarker;
26 |
--------------------------------------------------------------------------------
/src/server/express/routes/profiler/profiler.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import {
3 | expressProfilingStart,
4 | expressProfilingStop,
5 | expressProfilingUI,
6 | } from "packages/onDemandProfiler";
7 | import { getUser } from "packages/getUser";
8 | import { requireSuperuser } from "server/express/middleware/requireSuperuser";
9 |
10 | const router = express.Router();
11 |
12 | router.get("/", requireSuperuser, async (req, res) => {
13 | expressProfilingUI(res);
14 | });
15 |
16 | router.post("/start", requireSuperuser, async (req, res) => {
17 | const user = getUser(req);
18 | if (user instanceof Error) return;
19 | await expressProfilingStart(res, user);
20 | });
21 |
22 | router.post("/stop", requireSuperuser, async (req, res) => {
23 | const user = getUser(req);
24 | if (user instanceof Error) return;
25 | await expressProfilingStop(res, user);
26 | });
27 |
28 | export default router;
29 |
--------------------------------------------------------------------------------
/src/server/database/migrations/Migration20251120122600_manual.ts:
--------------------------------------------------------------------------------
1 | import { Migration } from "@mikro-orm/migrations";
2 |
3 | export class Migration20251120122600 extends Migration {
4 | override async up(): Promise {
5 | // Clear ephemeris cache to prevent stale data
6 | this.addSql(`delete from "cache_db" where "cache_key" = 'ephemeris';`);
7 |
8 | // Create ephemeris_db table
9 | this.addSql(
10 | `create table "ephemeris_db" ("epoch" timestamptz(3) not null, "tle_line1" text not null, "tle_line2" text not null, "origin" varchar(20) not null, "created_at" timestamptz(3) not null default now(), constraint "ephemeris_db_pkey" primary key ("epoch"));`
11 | );
12 | this.addSql(`create index "ephemeris_db_epoch_index" on "ephemeris_db" ("epoch");`);
13 | }
14 |
15 | override async down(): Promise {
16 | this.addSql(`drop table if exists "ephemeris_db" cascade;`);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/server/database/models/_allModels.ts:
--------------------------------------------------------------------------------
1 | // import all models here so that they can be exported from a single file. This avoids circular dependency issues
2 | // The order of imports is important. Models that are referenced by other models must be imported first.
3 | import { GPXTracks_db } from "./gpxTracks.model";
4 | import { MediaOverride_db } from "./mediaOverride.model";
5 | import { AncillaryDataSource_db } from "./ancillaryData.model";
6 | import { VideoStartTimeOverrides_db } from "./VideoStartTimeOverrides.model";
7 | import { PhotoTimeShifts_db } from "./PhotoTimeShifts.model";
8 | import { Cache_db } from "./cache.model";
9 | import { Ephemeris_db } from "./ephemera.model";
10 |
11 | export { GPXTracks_db };
12 | export { MediaOverride_db };
13 | export { AncillaryDataSource_db };
14 | export { VideoStartTimeOverrides_db };
15 | export { PhotoTimeShifts_db };
16 | export { Cache_db };
17 | export { Ephemeris_db };
18 |
--------------------------------------------------------------------------------
/src/components/interface/pane-help-overlay.tsx:
--------------------------------------------------------------------------------
1 | import styles from "./pane-help-overlay.module.css";
2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3 | import { faTimesCircle } from "@fortawesome/free-solid-svg-icons";
4 | import { FunctionComponent, JSX } from "react";
5 |
6 | const HelpOverlay: FunctionComponent<{
7 | children: JSX.Element;
8 | isModalOpen: boolean;
9 | closeHandler: Function;
10 | }> = ({ children, isModalOpen, closeHandler }) => {
11 | const visibleClass = isModalOpen ? styles.helpModalWrapperVisible : "";
12 | return (
13 |
14 |
closeHandler()}>
15 |
16 |
17 |
{children}
18 |
19 | );
20 | };
21 |
22 | export default HelpOverlay;
23 |
--------------------------------------------------------------------------------
/.sastignore:
--------------------------------------------------------------------------------
1 | # .sastignore - SAST Findings Suppression File
2 | # This file is used to suppress SAST (Static Application Security Testing) findings
3 | # that have been reviewed and determined to be false positives or acceptable risks.
4 | #
5 | # Format: Each line should contain a rule ID or file pattern to ignore
6 | # Lines starting with # are comments
7 | #
8 | # Examples:
9 | # - Ignore a specific rule: eslint/detect-object-injection
10 | # - Ignore findings in a file: src/test/**
11 | # - Ignore a specific finding: semgrep:javascript.lang.security.audit.detect-non-literal-regexp
12 | #
13 | # Documentation: https://docs.gitlab.com/ee/user/application_security/sast/#customizing-the-sast-settings
14 |
15 | # Test files - security findings in test code are generally lower risk
16 | src/**/*.spec.ts
17 | # Add specific rule suppressions below after reviewing findings
18 | # Example: semgrep:javascript.lang.security.audit.path-traversal
19 |
20 |
--------------------------------------------------------------------------------
/src/server/express/global.ts:
--------------------------------------------------------------------------------
1 | const createInitialCelestrakState = (): CelestrakTrackerData => ({
2 | isActive: false,
3 | intervalMs: 3 * 60 * 60 * 1000,
4 | startedAt: null,
5 | nextOperationAt: null,
6 | lastOperationStartedAt: null,
7 | lastOperationCompletedAt: null,
8 | lastOperationDurationMs: null,
9 | lastOperationSuccess: null,
10 | lastSuccessAt: null,
11 | lastFetchedEpoch: null,
12 | lastErrorMessage: null,
13 | lastErrorAt: null,
14 | totalOperations: 0,
15 | successfulOperations: 0,
16 | failedOperations: 0,
17 | lastManualTriggerAt: null,
18 | lastManualTriggerBy: null,
19 | });
20 |
21 | export const globalValues: GlobalValues = {
22 | socketio: null,
23 | serverSocketStatus: {
24 | visitorsData: [],
25 | },
26 | orm: null,
27 | socketInterval: null,
28 | appVersion: null,
29 | fetchTrackers: {},
30 | talkybotS2sSocket: null,
31 | celestrakInterval: null,
32 | celestrakTrackerData: createInitialCelestrakState(),
33 | };
34 |
--------------------------------------------------------------------------------
/src/store/gps.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 |
3 | export const initialState: GPSState = {
4 | gpsTracks: [],
5 | metadata: null,
6 | };
7 |
8 | export const gpsSlice = createSlice({
9 | name: "gps",
10 | initialState,
11 | reducers: {
12 | /** Add new gps tracks to the store */
13 | setGPSTracks: (state, action: { payload: FetchResponse }) => {
14 | state.gpsTracks = action.payload.data || [];
15 | state.metadata = action.payload.fetchMetadata;
16 | },
17 | clearGPSTracks: (state) => {
18 | state.gpsTracks = [];
19 | state.metadata = null;
20 | },
21 | gpsFetchError: (state, action: { payload: string }) => {
22 | state.metadata = {
23 | success: false,
24 | error: action.payload,
25 | timestamp: state.metadata?.timestamp || new Date().toISOString(),
26 | };
27 | },
28 | },
29 | });
30 |
31 | export const { setGPSTracks, clearGPSTracks, gpsFetchError } = gpsSlice.actions;
32 |
--------------------------------------------------------------------------------
/src/components/interface/dropdown-event.module.css:
--------------------------------------------------------------------------------
1 | .select {
2 | position: relative;
3 | display: flex;
4 | flex-direction: column;
5 | justify-content: center;
6 | }
7 | .select select {
8 | font-family: "Inter", sans-serif;
9 | font-weight: 400;
10 | font-size: 16px;
11 | cursor: pointer;
12 | padding-left: 15px;
13 | padding-right: 25px;
14 | height: 41px;
15 | border: none;
16 | border-radius: var(--radius);
17 | color: white;
18 | background-color: var(--lightest-grey);
19 | appearance: none;
20 | -webkit-appearance: none;
21 | -moz-appearance: none;
22 | text-overflow: ellipsis;
23 | outline: 0;
24 | }
25 | .select select::-ms-expand {
26 | display: none;
27 | }
28 | .select select:hover,
29 | .select select:focus {
30 | outline: 0;
31 | }
32 | .select select:disabled {
33 | opacity: 0.5;
34 | pointer-events: none;
35 | }
36 | .select_arrow {
37 | position: absolute;
38 | top: 10px;
39 | right: 10px;
40 | pointer-events: none;
41 | }
42 |
43 | /* v2 */
44 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { createRoot } from "react-dom/client";
3 | import App from "./App";
4 | import { BrowserRouter } from "react-router";
5 | import store from "./store";
6 | import { Provider } from "react-redux";
7 | import { CookiesProvider } from "react-cookie";
8 | import { ConsoleLogger, LogLevel } from "./utils/logging/consoleLogger";
9 |
10 | import "./styles.css";
11 | import "@fortawesome/fontawesome-svg-core/styles.css";
12 |
13 | // Set console logging level on the client side based on the environment variable
14 | const logLevel = (import.meta.env.VITE_PUBLIC_LOG_LEVEL as LogLevel) || "off";
15 | ConsoleLogger.setLevel(logLevel);
16 |
17 | const root = createRoot(document.getElementById("root"));
18 | root.render(
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | );
29 |
--------------------------------------------------------------------------------
/src/components/panes/gps-location-marker.module.css:
--------------------------------------------------------------------------------
1 | .ev1Marker {
2 | background: url("/images/marker_ev1.png") no-repeat center;
3 | background-size: 30px 30px;
4 | width: 30px;
5 | height: 30px;
6 | }
7 |
8 | .ev2Marker {
9 | background: url("/images/marker_ev2.png") no-repeat center;
10 | background-size: 30px 30px;
11 | width: 30px;
12 | height: 30px;
13 | }
14 |
15 | .ev3Marker {
16 | background: url("/images/marker_ev3.png") no-repeat center;
17 | background-size: 30px 30px;
18 | width: 30px;
19 | height: 30px;
20 | }
21 |
22 | .ev4Marker {
23 | background: url("/images/marker_ev4.png") no-repeat center;
24 | background-size: 30px 30px;
25 | width: 30px;
26 | height: 30px;
27 | }
28 |
29 | .cartMarker {
30 | background: url("/images/marker_cart2.png") no-repeat center;
31 | background-size: 20px 20px;
32 | width: 20px;
33 | height: 20px;
34 | }
35 |
36 | .lightCartMarker {
37 | background: url("/images/marker_lightCart.png") no-repeat center;
38 | background-size: 20px 20px;
39 | width: 20px;
40 | height: 20px;
41 | }
42 |
--------------------------------------------------------------------------------
/src/public/images/share.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/src/public/images/share_black.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | CODA
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/src/components/framework/layout-picker.module.css:
--------------------------------------------------------------------------------
1 | .main {
2 | background-color: var(--grey);
3 | width: 100%;
4 | height: 440px;
5 | padding: 15px 18px;
6 | border-radius: var(--radius);
7 | overflow-y: auto;
8 | font-size: 15px;
9 | line-height: 18px;
10 | cursor: default;
11 | }
12 |
13 | .top {
14 | display: flex;
15 | justify-content: space-between;
16 | color: rgba(255, 255, 255, 0.4);
17 | font-size: 18px;
18 | font-weight: 400;
19 | line-height: 18px;
20 | }
21 |
22 | .close {
23 | cursor: pointer;
24 | }
25 |
26 | .layouts {
27 | margin-top: 20px;
28 | display: flex;
29 | flex-wrap: wrap;
30 | justify-content: space-between;
31 | }
32 |
33 | .layout {
34 | margin-bottom: 10px;
35 | padding: 4px;
36 | }
37 |
38 | .layout:hover {
39 | outline: 1px solid #fff;
40 | }
41 |
42 | .layoutselected {
43 | outline: 1px solid #fff;
44 | }
45 |
46 | .layout:last-of-type {
47 | margin-bottom: 0;
48 | }
49 |
50 | .layout > * {
51 | /* TODO: need to change the SVG colors */
52 | color: var(--light-grey);
53 | cursor: pointer;
54 | width: 100px;
55 | }
56 |
--------------------------------------------------------------------------------
/src/server/express/routes/user/logFromClient.ts:
--------------------------------------------------------------------------------
1 | import { sendClientLogsToLogstash } from "@emss/logger";
2 | import { handleUnableToDecodeJWT } from "@emss/oauth2-proxy-backend";
3 | import express, { Request, Response } from "express";
4 | import { getUser } from "packages/getUser";
5 | import serverLogger from "utils/logging/serverLogger";
6 |
7 | const router = express.Router();
8 |
9 | /**
10 | * This is a standard endpoint that all EMSS apps should have. It is where
11 | * the clientLogger running in the browser sends info(), notice(), warn(),
12 | * and error() messages, so our server/API can add user/IP-address info to
13 | * the message, then forward the message on to our logging server.
14 | */
15 | router.put("/", async (req: Request, res: Response): Promise => {
16 | const user = getUser(req);
17 | if (user instanceof Error) {
18 | return handleUnableToDecodeJWT(user, res);
19 | }
20 |
21 | // this handles res.send(...); don't do any additional res.send(...) after this
22 | sendClientLogsToLogstash({ req, res, user, serverLogger });
23 | });
24 |
25 | export default router;
26 |
--------------------------------------------------------------------------------
/src/store/daynight.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 |
3 | export const initialState: DayNightState = {
4 | dayNight: [],
5 | metadata: null,
6 | origin: null,
7 | };
8 |
9 | export const dayNightSlice = createSlice({
10 | name: "daynight",
11 | initialState,
12 | reducers: {
13 | /** Add new day night to the store */
14 | addDayNight: (state, action: { payload: FetchResponse }) => {
15 | state.dayNight = action.payload.data?.dayNight || [];
16 | state.metadata = action.payload.fetchMetadata;
17 | state.origin = action.payload.origin;
18 | },
19 | clearDayNight: (state) => {
20 | state.dayNight = [];
21 | state.metadata = null;
22 | state.origin = null;
23 | },
24 | fetchError: (state, action: { payload: string }) => {
25 | state.metadata = {
26 | success: false,
27 | error: action.payload,
28 | timestamp: state.metadata?.timestamp || new Date().toISOString(),
29 | };
30 | },
31 | },
32 | });
33 |
34 | export const { addDayNight, clearDayNight, fetchError } = dayNightSlice.actions;
35 |
--------------------------------------------------------------------------------
/src/server/processing/wikiData.ts:
--------------------------------------------------------------------------------
1 | import fs from "fs";
2 |
3 | /** Get ISS EVA data (compatible with socket fetch functions) */
4 | export async function getISSEvaData({
5 | // ignore source and dateWanted. We only have those parameters set to make this function compatible with the other socket fetch functions.
6 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
7 | dateWanted,
8 | }: {
9 | dateWanted: string;
10 | }): Promise> {
11 | const data = JSON.parse(
12 | fs.readFileSync("src/server/processing/tempWikiData/wiki-all.json", "utf-8")
13 | );
14 |
15 | return {
16 | data,
17 | fetchMetadata: {
18 | success: true,
19 | timestamp: new Date().toISOString(),
20 | },
21 | };
22 | }
23 |
24 | /** Get test events data */
25 | export async function getTestEventsData(): Promise> {
26 | const data = JSON.parse(
27 | fs.readFileSync("src/server/processing/tempWikiData/test-events.json", "utf-8")
28 | );
29 |
30 | return {
31 | data,
32 | fetchMetadata: {
33 | success: true,
34 | timestamp: new Date().toISOString(),
35 | },
36 | };
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/framework/frames.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent, JSX } from "react";
2 | import { shallowEqual, useAppSelector } from "utils/useAppSelector";
3 | import Frame from "components/framework/frame";
4 | import { allLayouts } from "store/framework";
5 | import styles from "./frames.module.css";
6 |
7 | const Viewer: FunctionComponent = () => {
8 | const selectedLayout = useAppSelector((state) => state.framework.layout, shallowEqual);
9 | const layoutDefinition = allLayouts[selectedLayout];
10 |
11 | const frames: JSX.Element[] = [];
12 | for (let i = 1; i <= layoutDefinition.frameCount; i++) {
13 | // CSS Grid definitions
14 | const gridAreaName = styles[`f${i}`];
15 | frames.push(
16 |
17 |
18 |
19 | );
20 | }
21 |
22 | const mainStyleName = layoutDefinition.cssGridRows === 9 ? styles.main_9Rows : styles.main_10Rows;
23 |
24 | return (
25 |
28 | );
29 | };
30 |
31 | export default Viewer;
32 |
--------------------------------------------------------------------------------
/docker-compose.preview.yml:
--------------------------------------------------------------------------------
1 | #
2 | # DOCKER OVERRIDE FILE:
3 | # This is an override file. It is intended to be used on top of the base docker-compose.yml file.
4 | #
5 | # SUMMARY:
6 | # Do the exact same thing as the production `docker-compose.yml`, just build locally as a way to
7 | # quickly preview what a production build would do.
8 | # Don't pull an image from container registry: build it locally from a Dockerfile.
9 | #
10 | services:
11 | nginx:
12 | image: coda-preview-nginx:latest
13 | build:
14 | context: .
15 | dockerfile: ./docker/nginx/Dockerfile
16 | target: prod
17 | args:
18 | # Build args needed in context during build (see note in Dockerfile)
19 | - VITE_PUBLIC_MAPBOX_KEY=${VITE_PUBLIC_MAPBOX_KEY}
20 |
21 | apiv1:
22 | image: coda-preview-apiv1:latest
23 | build:
24 | context: .
25 | dockerfile: ./docker/apiv1/Dockerfile
26 | target: prod
27 | environment:
28 | # Override what is in .env (which may be "localhost" to support native local dev) such
29 | # that it is always "database" and port 5432 in a full Docker-Compose setup.
30 | - DB_HOST=database
31 | - DB_PORT=5432
32 |
--------------------------------------------------------------------------------
/src/components/panes/video/video-poster.module.css:
--------------------------------------------------------------------------------
1 | .mediaPanel {
2 | flex: 1;
3 | }
4 |
5 | .vidContainer {
6 | width: 100%;
7 | height: 100%;
8 | position: relative;
9 | background-color: var(--nearly-black);
10 | object-fit: contain;
11 | overflow: hidden;
12 | }
13 |
14 | .playerPosterNovid {
15 | width: 100%;
16 | height: 100%;
17 | position: absolute;
18 | top: 0;
19 | left: 0;
20 | background: center / contain no-repeat url("/images/patch_fod_1400_8bit.png");
21 | background-size: 40%;
22 | opacity: 0.4;
23 | }
24 |
25 | .playerPosterBuffering {
26 | display: inline-flex;
27 | justify-content: center;
28 | align-items: center;
29 | width: 100%;
30 | height: 100%;
31 | }
32 |
33 | .loaderAnimation {
34 | width: 40%;
35 | border-radius: 50%;
36 | border: solid 4px;
37 | border-color: #999999 #eeeeee10;
38 | animation-name: spin;
39 | animation-duration: 4s;
40 | animation-iteration-count: infinite;
41 | animation-timing-function: ease-in-out;
42 | }
43 |
44 | .loaderAnimation:before {
45 | content: "";
46 | display: block;
47 | padding-top: 100%;
48 | }
49 |
50 | @keyframes spin {
51 | to {
52 | transform: rotate(360deg);
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/components/interface/pane-help-overlay.module.css:
--------------------------------------------------------------------------------
1 | .helpModalWrapper {
2 | display: none;
3 | position: absolute;
4 | top: 0;
5 | left: 0;
6 | height: 100%;
7 | width: 100%;
8 | z-index: 30;
9 | box-sizing: border-box;
10 | outline: none;
11 | overflow-y: auto;
12 | background-color: #000;
13 | opacity: 0.8;
14 | }
15 |
16 | .helpModalWrapperVisible {
17 | display: block;
18 | }
19 |
20 | .closeButton {
21 | position: absolute;
22 | top: 13px;
23 | right: 13px;
24 | z-index: 30;
25 | background-color: black;
26 | }
27 |
28 | .helpBody {
29 | position: relative;
30 | box-sizing: border-box;
31 | top: 0;
32 | bottom: 0;
33 | left: 0;
34 | border: solid 2px #eeeeee;
35 | border-radius: 6px;
36 | width: calc(100% - 20px * 2);
37 | height: calc(100% - 20px * 2);
38 | margin: 20px;
39 | padding: 20px;
40 | background-color: rgb(0, 0, 0, 0.8);
41 | z-index: 10;
42 |
43 | overflow-y: auto;
44 | }
45 |
46 | .headline {
47 | font-size: 1.5em;
48 | color: #eeeeee;
49 | text-align: left;
50 | padding-bottom: 10px;
51 | }
52 |
53 | .bodyText {
54 | font-size: 1em;
55 | box-sizing: border-box;
56 | float: left;
57 | padding: 10px;
58 | width: 100%;
59 | }
60 |
--------------------------------------------------------------------------------
/src/typings/processing/location.d.ts:
--------------------------------------------------------------------------------
1 | type MapMarker = {
2 | marker: import("mapbox-gl").Marker; //the MapBox marker reference
3 | markerNode: HTMLDivElement; //the real DOM id of the marker
4 | };
5 |
6 | type MapMarkers = {
7 | EV1?: MapMarker;
8 | EV2?: MapMarker;
9 | EV3?: MapMarker;
10 | EV4?: MapMarker;
11 | Cart?: MapMarker;
12 | LightCart?: MapMarker;
13 | Staff?: MapMarker;
14 | };
15 |
16 | type MapInfoDisplayItems = {
17 | lat: string;
18 | lng: string;
19 | ele: string;
20 | hdg: string;
21 | date: string;
22 | time: string;
23 | };
24 |
25 | type MapInfoDisplay = {
26 | EV1?: MapInfoDisplayItems;
27 | EV2?: MapInfoDisplayItems;
28 | EV3?: MapInfoDisplayItems;
29 | EV4?: MapInfoDisplayItems;
30 | Cart?: MapInfoDisplayItems;
31 | LightCart?: MapInfoDisplayItems;
32 | Staff?: MapInfoDisplayItems;
33 | };
34 |
35 | type TrackFeatures = {
36 | EV1?: import("geojson").FeatureCollection;
37 | EV2?: import("geojson").FeatureCollection;
38 | EV3?: import("geojson").FeatureCollection;
39 | EV4?: import("geojson").FeatureCollection;
40 | Cart?: import("geojson").FeatureCollection;
41 | LightCart?: import("geojson").FeatureCollection;
42 | Staff?: import("geojson").FeatureCollection;
43 | };
44 |
--------------------------------------------------------------------------------
/.gitlab-ci.yml:
--------------------------------------------------------------------------------
1 | stages:
2 | - stageless
3 |
4 | default:
5 | timeout: 10 minutes
6 |
7 | variables:
8 | HOME: "/tmp" # required for openshift for now
9 |
10 | # These includes do not define the order of the pipeline. That is determined by
11 | # the `needs` value for each job
12 | include:
13 | # Jobs that run on commits and scheduled pipelines.
14 | #
15 | # Scheduled pipelines are used to do regular reporting on things like vulnerabilities and test
16 | # coverage. We don't want them to build packages and Docker images or do deploys
17 | - local: .gitlab/run-on-schedule.gitlab-ci.yml
18 | rules:
19 | - if: $CI_PIPELINE_SOURCE == "schedule"
20 |
21 | # Jobs that run on commits only, not scheduled pipelines
22 | - local: .gitlab/run-on-commits.gitlab-ci.yml
23 | rules:
24 | - if: $CI_PIPELINE_SOURCE != "schedule"
25 |
26 | # Other jobs that we need for both types of pipelines
27 | - local: .gitlab/includes/db-import.yml
28 | rules:
29 | - if: $SCHEDULE_TYPE != "audit" # exclude from audit scheduled pipelines
30 | - project: "emss/gitlab-templates"
31 | ref: 2.6.0
32 | file:
33 | # EMSS shared dev server helpers (carbon, iron, oxygen, etc)
34 | - "/jobs/emss-shared-dev.gitlab-ci.yml"
35 |
--------------------------------------------------------------------------------
/docker/apiv1/Dockerfile:
--------------------------------------------------------------------------------
1 | # Get the base image. When building via kaniko in CI, the dependency proxy will cache images
2 | # from Docker Hub. For local builds, Docker pulls directly from Docker Hub.
3 | # ref: https://eegitlab.fit.nasa.gov/emss/docs/-/blob/main/docker.md#base-images-as-arg
4 | # hadolint global ignore=DL3006
5 | ARG APIV1_BASE_IMAGE=node:24.11.1-alpine
6 |
7 | # # # # # # # #
8 | # PROD image #
9 | # # # # # # # #
10 | FROM $APIV1_BASE_IMAGE AS prod
11 |
12 | # CODA requires a special CA-cert in place to talk to Imagery Online.
13 | COPY .env.local.cert.pem /
14 | ENV NODE_EXTRA_CA_CERTS=/.env.local.cert.pem
15 |
16 | RUN mkdir /app
17 | WORKDIR /app
18 |
19 | # Make GIT_COMMIT available inside docker/kaniko so it can be baked into the code at build time
20 | # The GIT_COMMIT value is passed in using MAP_ENV_VARS_TO_BUILD_ARGS from the job pipeline.
21 | # When building this container locally, this is not available so "localDev" will be used as default value
22 | ARG GIT_COMMIT=localDev
23 | ENV GIT_COMMIT=$GIT_COMMIT
24 |
25 | COPY package*.json .
26 | RUN npm ci
27 |
28 | # copy the rest of the files
29 | COPY . /app
30 |
31 | RUN npm run api:build
32 |
33 | # This image is intended to be run with docker-compose.
34 | CMD ["/bin/sh", "-c", "npm run migration:up; npm run api:prod"]
35 |
--------------------------------------------------------------------------------
/src/components/framework/frame.module.css:
--------------------------------------------------------------------------------
1 | .main {
2 | width: 100%;
3 | height: 100%;
4 | background-color: var(--lightest-grey);
5 | display: flex;
6 | flex-direction: column;
7 | overflow: hidden;
8 | }
9 |
10 | .headerContainer {
11 | flex: none;
12 | }
13 |
14 | .bodyContainer {
15 | flex: auto;
16 | display: flex;
17 | background-color: var(--nearly-black);
18 | height: 100%;
19 | }
20 |
21 | .photoPoster {
22 | width: 100%;
23 | height: 100%;
24 | position: relative;
25 | top: 0;
26 | left: 0;
27 | background: center / contain no-repeat url("/images/patch_fod_1400_8bit.png");
28 | background-size: 40%;
29 | filter: grayscale(100%) contrast(0.75);
30 | opacity: 0.3;
31 | }
32 |
33 | .header {
34 | display: flex;
35 | justify-content: flex-start;
36 | height: 35px;
37 | background-color: var(--nearly-black);
38 | padding: 4px 6px 4px 0px;
39 | overflow-x: clip;
40 | }
41 |
42 | .verticalCenter {
43 | display: flex;
44 | flex-direction: column;
45 | justify-content: space-around;
46 | }
47 |
48 | .dropdown {
49 | width: 200px;
50 | }
51 |
52 | .dropdownSmall {
53 | width: 125px;
54 | }
55 |
56 | .dropdownSmallest {
57 | width: 60px;
58 | }
59 |
60 | .controls {
61 | width: 100%;
62 | margin-left: 3px;
63 | overflow-x: clip;
64 | }
65 |
--------------------------------------------------------------------------------
/src/public/clocksync/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Clock Sync | CODA
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
29 |
32 |
Sync sanity: ∞ms
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/src/typings/processing/video.d.ts:
--------------------------------------------------------------------------------
1 | type VideoRecord = {
2 | id: number;
3 | videoId: string;
4 | startTime: string;
5 | };
6 |
7 | type VideoRecord_db_type = VideoRecord;
8 |
9 | type VideoPlayerType = "IO" | "MTX" | "HLS" | "NONE";
10 |
11 | // Video Poster Types
12 |
13 | type PosterState = "novid" | "buffering" | "none";
14 |
15 | interface VideoMetadata {
16 | videoHeight: number;
17 | videoWidth: number;
18 | duration: number;
19 | }
20 |
21 | type VideoStatus = "novid" | "buffering" | "playing" | "error" | null;
22 |
23 | // MTX Types
24 |
25 | type MTXApiResponses = {
26 | mtxPlaybackAvailability: MTXPlaybackAvailability;
27 | mtxHlsEndpoints: MTXHlsEndpoint[];
28 | };
29 |
30 | type MTXHlsEndpoint = {
31 | name: MTXHlsEndpointName;
32 | secondsAvailable: number;
33 | };
34 |
35 | type MTXRecordingTimeRange = {
36 | start: string;
37 | duration: number;
38 | };
39 |
40 | type MTXPlaybackAvailability = {
41 | [dlNumber: string]: MTXRecordingTimeRange[];
42 | };
43 |
44 | type MTXHlsEndpointName =
45 | | "DL1_ISS"
46 | | "DL2_ISS"
47 | | "DL3_ISS"
48 | | "DL4_ISS"
49 | | "DL5_ISS"
50 | | "DL6_ISS"
51 | | "DL7_ISS"
52 | | "DL8_ISS"
53 | | "DL1_TE"
54 | | "DL2_TE"
55 | | "DL3_TE"
56 | | "DL4_TE"
57 | | "DL5_TE"
58 | | "DL6_TE"
59 | | "DL7_TE"
60 | | "DL8_TE";
61 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "type": "node",
6 | "request": "attach",
7 | "name": "Node Attach",
8 | "skipFiles": ["/**"],
9 | "port": 8229
10 | },
11 | {
12 | "type": "chrome",
13 | "request": "attach",
14 | "name": "Chrome Attach",
15 | "port": 9222,
16 | "urlFilter": "http://localhost:3000/*",
17 | "webRoot": "${workspaceFolder}/src"
18 | },
19 | {
20 | "type": "node",
21 | "request": "launch",
22 | "name": "Jest All",
23 | "program": "${workspaceFolder}/node_modules/.bin/jest",
24 | "args": ["--runInBand"],
25 | "console": "integratedTerminal",
26 | "internalConsoleOptions": "neverOpen",
27 | "windows": {
28 | "program": "${workspaceFolder}/node_modules/jest/bin/jest"
29 | }
30 | },
31 | {
32 | "type": "node",
33 | "request": "launch",
34 | "name": "Jest Current",
35 | "program": "${workspaceFolder}/node_modules/.bin/jest",
36 | "args": ["--runInBand", "${fileBasenameNoExtension}"],
37 | "console": "integratedTerminal",
38 | "internalConsoleOptions": "neverOpen",
39 | "windows": {
40 | "program": "${workspaceFolder}/node_modules/jest/bin/jest"
41 | }
42 | }
43 | ]
44 | }
45 |
--------------------------------------------------------------------------------
/src/server/express/middleware/requireSuperuser.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response, NextFunction } from "express";
2 | import { getUser } from "packages/getUser";
3 | import { isSuperuser } from "utils/user";
4 | import ConsoleLogger from "utils/logging/consoleLogger";
5 |
6 | /**
7 | * Express middleware that requires the user to have superuser privileges.
8 | * Returns 401 if user cannot be identified, 403 if user is not a superuser.
9 | * Should be used on all routes that modify data.
10 | */
11 | export const requireSuperuser = (req: Request, res: Response, next: NextFunction): void => {
12 | const user = getUser(req);
13 |
14 | // Check if user can be identified
15 | if (user instanceof Error) {
16 | ConsoleLogger.warn(
17 | `requireSuperuser: Unauthenticated access attempt to ${req.method} ${req.originalUrl}`
18 | );
19 | res.status(401).json({ status: "error", message: "Unauthorized: Authentication required" });
20 | return;
21 | }
22 |
23 | // Check if user has superuser privileges
24 | if (!isSuperuser(user)) {
25 | ConsoleLogger.warn(
26 | `requireSuperuser: Unauthorized access attempt by ${user.display_name} to ${req.method} ${req.originalUrl}`
27 | );
28 | res.status(403).json({ status: "error", message: "Forbidden: Superuser access required" });
29 | return;
30 | }
31 |
32 | next();
33 | };
34 |
--------------------------------------------------------------------------------
/docker-compose.services.yml:
--------------------------------------------------------------------------------
1 | #
2 | # DOCKER OVERRIDE FILE:
3 | # This is an override file. It is intended to be used on top of the base docker-compose.yml file.
4 | #
5 | # SUMMARY:
6 | # 1. Rather than pull images from a container registry as done in the main docker-compose.yml,
7 | # instead build the images locally
8 | #
9 | services:
10 | database:
11 | logging: !reset null # turn off logging
12 | ports:
13 | # For local dev, expose the database outside the docker network in case devs want to use
14 | # a SQL client on their machine (e.g. HeidiSQL, etc)
15 | # 5430 is for TalkyBot. AEGIS uses 5432, CODA, 5431
16 | - "5431:5432"
17 |
18 | mediamtx-mock:
19 | build:
20 | context: .
21 | dockerfile: ./docker/mediamtx-mock/Dockerfile
22 | container_name: mediamtx-mock
23 | ports:
24 | - "8888:8888" # HLS
25 | - "9996:9996" # Playback API
26 | - "9997:9997" # Control API
27 | - "8554:8554" # RTSP in case we want to manually test
28 | volumes:
29 | - ./docker/mediamtx-mock/mediamtx.yml:/mediamtx.yml
30 | - ./.local/mediamtx/recordings:/recordings
31 | - ./.local/mediamtx/hls:/hls
32 | restart: unless-stopped
33 |
34 | # Don't start any of these services
35 | nginx: !reset null
36 | apiv1: !reset null
37 | oauth2-proxy: !reset null
38 | redis: !reset null
39 |
--------------------------------------------------------------------------------
/.codeclimate.yml:
--------------------------------------------------------------------------------
1 | version: "2" # required to adjust maintainability checks
2 | checks:
3 | argument-count:
4 | config:
5 | threshold: 4
6 | complex-logic:
7 | config:
8 | threshold: 4
9 | file-lines:
10 | config:
11 | threshold: 2048
12 | method-complexity:
13 | config:
14 | threshold: 8
15 | method-count:
16 | config:
17 | threshold: 32
18 | method-lines:
19 | config:
20 | threshold: 128
21 | nested-control-flow:
22 | config:
23 | threshold: 4
24 | return-statements:
25 | config:
26 | threshold: 8
27 |
28 | # use defaults
29 | # similar-code:
30 | # config:
31 | # threshold: # language-specific defaults. an override will affect all languages.
32 | # identical-code:
33 | # config:
34 | # threshold: # language-specific defaults. an override will affect all languages.
35 |
36 | plugins:
37 | # https://docs.codeclimate.com/docs/nodesecurity
38 | nodesecurity:
39 | enabled: true
40 |
41 | exclude_patterns:
42 | - "config/"
43 | - "db/"
44 | - "dist/"
45 | - "features/"
46 | - "**/node_modules/"
47 | - "script/"
48 | - "**/spec/"
49 | - "**/test/"
50 | - "**/tests/"
51 | - "Tests/"
52 | - "**/vendor/"
53 | - "**/*_test.go"
54 | - "**/*.d.ts"
55 | - "**/coverage"
56 | - "**/docs"
57 | - "**/*.spec.js"
58 | - "**/*.spec.ts"
59 |
--------------------------------------------------------------------------------
/src/components/panes/iss-location.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | position: relative;
3 | width: 100%;
4 | height: 100%;
5 | background-color: var(--nearly-black);
6 | }
7 |
8 | .mapContainer {
9 | height: 100%;
10 | }
11 |
12 | /* Controls */
13 |
14 | .controls {
15 | display: flex;
16 | height: 100%;
17 | justify-content: space-between;
18 | margin-left: auto;
19 | }
20 |
21 | .controlsLeft {
22 | display: flex;
23 | }
24 |
25 | .verticalCenter {
26 | display: flex;
27 | flex-direction: column;
28 | justify-content: space-around;
29 | }
30 |
31 | .rightButtons {
32 | display: flex;
33 | }
34 |
35 | .rightButtons > *:not(:last-of-type) {
36 | margin-right: 5px;
37 | }
38 |
39 | /* Lock Map button */
40 |
41 | .lockButton {
42 | display: block;
43 | width: 55px;
44 | height: 18px;
45 | background-color: var(--even-greyer);
46 | color: var(--lighter-grey);
47 | border: none;
48 | border-radius: var(--radius);
49 | font-size: 11px;
50 | font-weight: 600;
51 | cursor: pointer;
52 | }
53 |
54 | .lockButtonSelected {
55 | border: none;
56 | background-color: #eeeeee;
57 | color: var(--lighter-grey);
58 | }
59 |
60 | .buttonLong {
61 | width: 55px;
62 | padding: 0 6px;
63 | }
64 |
65 | .buttonShort {
66 | width: 20px;
67 | padding: 0;
68 | }
69 |
70 | .buttonLabel {
71 | display: flex;
72 | justify-content: space-between;
73 | }
74 |
--------------------------------------------------------------------------------
/src/server/express/routes/user/auth.ts:
--------------------------------------------------------------------------------
1 | import express, { Request, Response } from "express";
2 | import { getUser } from "packages/getUser";
3 | import ConsoleLogger from "utils/logging/consoleLogger";
4 | import serverLogger from "utils/logging/serverLogger";
5 |
6 | const router = express.Router();
7 |
8 | // get
9 | router.get("/", async (req: Request, res: Response): Promise => {
10 | res.setHeader("content-type", "application/json");
11 | const user = getUser(req);
12 | if (user instanceof Error) {
13 | const msg = "Unable to decode JWT";
14 | ConsoleLogger.error(msg, user);
15 | res.status(500).send({ msg });
16 | return;
17 | }
18 | serverLogger.logUserLogin(user);
19 | res.send({ user });
20 | });
21 |
22 | export default router;
23 |
24 | // TODO: currently unused but could be used to restrict access to API endpoints
25 | export const allowAccess = (req: Request) => {
26 | const user = getUser(req);
27 | if (user instanceof Error) {
28 | const msg = "Unable to decode JWT";
29 | ConsoleLogger.error(msg, user);
30 | return false; // auth error, don't allow
31 | }
32 | if (!user.usperson) {
33 | return false; // not a citizen or legal permanent resident, don't allow
34 | }
35 | // allow if from JSC in orgs beginning with C or X
36 | // return /\(JSC-[CX]/.test(user.display_name);
37 |
38 | // allow all others
39 | return true;
40 | };
41 |
--------------------------------------------------------------------------------
/src/public/global.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | }
4 |
5 | :root {
6 | --greyish: #e0e0e0;
7 | --even-greyer: #abaaae;
8 | --extremely-grey: #898989;
9 | --lightest-grey: #4a4c57;
10 | --more-light-grey: #616574;
11 | --light-grey: #4c4e5b;
12 | --lighter-grey: #474955;
13 | --slightly-lighter-grey: #393641;
14 | --grey: #383a45;
15 | --dark-grey: #313131;
16 | --very-dark-grey: #242424;
17 | --nearly-black: #19181b;
18 |
19 | --ruby: #950b5e;
20 | --orange: #ffa800;
21 | --mustard-green: #6e831a;
22 | --aqua: #12bfbf;
23 | --teal: #097597;
24 | --purple: #1a1e83;
25 | --burnt-orange: #cc5500;
26 | --burnt-umber: #6e260e;
27 | --highlight-red: #f40000;
28 |
29 | --radius: 3px;
30 | --panelRadius: 6px;
31 |
32 | --homepage-background: "url(/images/earth_moon.jpg)"; /* Handled in ./pages/index.tsx */
33 | }
34 |
35 | /* ===== Scrollbar CSS ===== */
36 | /* Firefox */
37 | * {
38 | scrollbar-width: auto;
39 | scrollbar-color: #eeeeee #eeeeee10;
40 | }
41 |
42 | /* Chrome, Edge, and Safari */
43 | *::-webkit-scrollbar {
44 | width: 7px;
45 | }
46 |
47 | *::-webkit-scrollbar-track {
48 | background: #424242;
49 | }
50 |
51 | *::-webkit-scrollbar-thumb {
52 | background-color: #eeeeee;
53 | border-radius: 2px;
54 | border: 1px none #eeeeee10;
55 | }
56 |
57 | /* maplibre overrides */
58 | .maplibregl-compact-show {
59 | display: none !important;
60 | }
61 |
--------------------------------------------------------------------------------
/src/typings/consts.d.ts:
--------------------------------------------------------------------------------
1 | /** Uses IO collections `cols`= query param in the IO API as a value. Pulled from the `cid=` in URLs like https://io.jsc.nasa.gov/app/collections.cfm?cid=2359937 */
2 | type Collection =
3 | | 4 //International Space Station. https://io.jsc.nasa.gov/app/collections.cfm?cid=4
4 | | 2359932 // All test events https://io.jsc.nasa.gov/app/collections.cfm?cid=2359932
5 | | 78178 // Neutral Buoyancy Lab. https://io.jsc.nasa.gov/app/collections.cfm?cid=78178
6 | | 2346894; // Artemis Missions. https://io.jsc.nasa.gov/app/collections.cfm?cid=2346894
7 |
8 | type IOFetchType = "videos" | "photos";
9 |
10 | type Source = "ISS" | "TEST_EVENTS" | "NBL" | "ARTEMIS";
11 |
12 | type MediaMedium = "video" | "photo" | "transcript" | "audio";
13 |
14 | type SourceShortVal = 0 | 1 | 2 | 3;
15 |
16 | /** Keys used to lookup and map sequence type values */
17 | type SequenceTypeKey = "EVA" | "IVA" | "testing" | "analog" | "training";
18 |
19 | type SequenceType = 1 | 2 | "testing" | "analog" | "training";
20 |
21 | /** Keys used to look up short integer values for pane types */
22 | type PaneTypeKey =
23 | | "empty"
24 | | "video_downlink"
25 | | "video_non_downlink"
26 | | "photo"
27 | | "event_info"
28 | | "iss_location"
29 | | "gps_location"
30 | | "photo_all"
31 | | "talkybot"
32 | | "graph";
33 |
34 | /** Pane types converted to integers */
35 |
36 | type PaneTypeShortVal = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
37 |
--------------------------------------------------------------------------------
/src/typings/api.d.ts:
--------------------------------------------------------------------------------
1 | type GPSUpsertRequest = {
2 | id?: number;
3 | date: string;
4 | name: string;
5 | gpxData: string;
6 | };
7 |
8 | type VideoUpsertRequest = {
9 | id?: number;
10 | videoId: string;
11 | startTime: string;
12 | };
13 |
14 | type PhotoUpsertRequest = {
15 | id?: number;
16 | date: string;
17 | source: string;
18 | timeOffset: string;
19 | };
20 |
21 | interface GPSTracksQueryParams {
22 | dateWanted: string;
23 | }
24 |
25 | interface EphemerisQueryParams {
26 | dateWanted: string;
27 | }
28 |
29 | type EphemerisUpsertRequest = {
30 | records: Array;
31 | origin: "celestrak" | "seed";
32 | };
33 |
34 | type MediaOverrideUpsertRequest = MediaOverride;
35 |
36 | interface MediaOverrideQueryParams {
37 | dateWanted: string;
38 | }
39 |
40 | type AncillaryDataUpsertRequest = {
41 | id: number;
42 | date: string;
43 | source: Source;
44 | type: "graphs";
45 | url: string;
46 | };
47 |
48 | interface AncillaryDataQueryParams {
49 | dateWanted: string;
50 | }
51 |
52 | interface DayNightQueryParams {
53 | dateWanted: string;
54 | dayNightSource?: string;
55 | // add support for year month date query params for Maestro
56 | // remove when Maestro is updated to use dateWanted
57 | year?: number;
58 | month?: number;
59 | date?: number;
60 | }
61 |
62 | interface VideoQueryParams {
63 | videoId: string;
64 | }
65 |
66 | interface PhotoQueryParams {
67 | dateWanted: string;
68 | }
69 |
--------------------------------------------------------------------------------
/src/packages/getUser.ts:
--------------------------------------------------------------------------------
1 | import { getUserFromJWT } from "@emss/oauth2-proxy-backend";
2 | import { EmssUser, EMSSRole } from "@emss/oauth2-proxy-common";
3 | import { Request } from "express";
4 |
5 | const getMockUser = (): EmssUser => {
6 | return {
7 | uupic: process.env.MOCK_USER_UUPIC || "1234",
8 | email: process.env.MOCK_USER_EMAIL || "neil.armstrong@nasa.gov",
9 | auid: process.env.MOCK_USER_AUID || "narmstra",
10 | givenname: process.env.MOCK_USER_GIVENNAME || "Neil",
11 | surname: process.env.MOCK_USER_SURNAME || "Armstrong",
12 | display_name: process.env.MOCK_USER_DISPLAYNAME || "Armstrong, Neil A. (JSC-CB611)",
13 | roles: process.env.MOCK_USER_ROLES
14 | ? (process.env.MOCK_USER_ROLES.split(",") as EMSSRole[])
15 | : [
16 | "AEGIS-Editor",
17 | "AEGIS-Superuser",
18 | "CODA-Superuser",
19 | "Maestro-Superuser",
20 | "EMSS-Superuser",
21 | ],
22 | uscitizen: process.env.MOCK_USER_USCITIZEN ? Boolean(process.env.MOCK_USER_USCITIZEN) : true,
23 | legal_permanent_resident: process.env.MOCK_USER_LPR ? Boolean(process.env.MOCK_USER_LPR) : true,
24 | usperson: process.env.MOCK_USER_USPERSON ? Boolean(process.env.MOCK_USER_USPERSON) : true,
25 | ip_address: "1.2.3.4",
26 | };
27 | };
28 |
29 | export const getUser = (req: Request): EmssUser | Error => {
30 | if (process.env.MOCK_USER === "true") {
31 | return getMockUser();
32 | }
33 | return getUserFromJWT(req);
34 | };
35 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2022",
4 | "jsx": "react-jsx",
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "sourceMap": true,
8 | "outDir": "./.local/vite/dist",
9 | "rootDir": ".",
10 | "resolveJsonModule": true,
11 | "esModuleInterop": true,
12 | "baseUrl": "./src",
13 | "forceConsistentCasingInFileNames": true,
14 | "strict": false,
15 | "skipLibCheck": false /* Skip type checking all .d.ts files. */,
16 | "lib": ["dom", "esnext"],
17 | "allowJs": true,
18 | "noImplicitAny": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": false,
21 | "alwaysStrict": true,
22 | "strictBindCallApply": true,
23 | "noImplicitThis": true,
24 | "useUnknownInCatchVariables": true,
25 | "strictFunctionTypes": false,
26 | "strictNullChecks": false,
27 | "strictPropertyInitialization": false,
28 | "noEmit": true,
29 | "isolatedModules": true,
30 | "incremental": true,
31 | "experimentalDecorators": true,
32 | "emitDecoratorMetadata": true,
33 | "declaration": false,
34 | "jsxImportSource": "react",
35 | "typeRoots": ["./node_modules/@types", "./typings", "./node_modules"],
36 | "types": ["vite/client", "node", "jest", "@testing-library/jest-dom", "react", "react-dom"],
37 | "preserveConstEnums": true
38 | },
39 | "include": ["src/**/*.ts", "src/**/*.tsx"],
40 | "exclude": ["node_modules", ".local", "public", "static"]
41 | }
42 |
--------------------------------------------------------------------------------
/src/public/clockcalc/clockcalc.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | QR Time Calc
7 |
8 |
9 |
10 |
11 |
32 |
33 |
34 |
35 |
Calculated QR video start time:
36 |
Calculated time offset:
37 |
Calculated other video start time:
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/src/store/thunk/thunkUtil.ts:
--------------------------------------------------------------------------------
1 | import type { AppDispatch, RootState } from "store";
2 | import { createAsyncThunk, AsyncThunkPayloadCreator, AsyncThunk } from "@reduxjs/toolkit";
3 |
4 | type AppThunkConfig = {
5 | state: RootState;
6 | dispatch: AppDispatch;
7 | rejectValue: RejectValue;
8 | // These are all things we could add to the type, but they're not needed anywhere at present
9 | // extra?: unknown;
10 | // serializedErrorType?: unknown;
11 | // pendingMeta?: unknown;
12 | // fulfilledMeta?: unknown;
13 | // rejectedMeta?: unknown;
14 | };
15 |
16 | /**
17 | * This function is just a wrapper on createAsyncThunk that sets up the types for our app
18 | *
19 | * @param actionType should be the name of your thunk. So if your thunk function is called
20 | * `asyncDoSomething` then the actionType should be `asyncDoSomething`
21 | * @param thunkFunc the function that will be called when the thunk is dispatched
22 | * @returns
23 | */
24 | const appCreateAsyncThunk = (
25 | actionType: string,
26 | // thunkFunc: ThunkFunc
27 | thunkFunc: AsyncThunkPayloadCreator>
28 | ): AsyncThunk> => {
29 | return createAsyncThunk>(
30 | "thunk/" + actionType,
31 | thunkFunc
32 | );
33 | };
34 |
35 | export default appCreateAsyncThunk;
36 |
--------------------------------------------------------------------------------
/jest.config.ts:
--------------------------------------------------------------------------------
1 | const config = {
2 | preset: "ts-jest/presets/default-esm",
3 | extensionsToTreatAsEsm: [".ts", ".tsx"],
4 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "node"],
5 | moduleDirectories: ["node_modules", "src"],
6 | rootDir: "./src",
7 | moduleNameMapper: {
8 | "^(\\.{1,2}/.*)\\.js$": "$1",
9 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$":
10 | "/__mocks__/fileMock.js",
11 | "\\.(css|scss)$": "identity-obj-proxy",
12 | "^__mocks__(.*)$": "/__mocks__$1",
13 | "^components/(.*)$": "/components/$1",
14 | "^pages/(.*)$": "/pages/$1",
15 | "^public/(.*)$": "/public/$1",
16 | "^server/(.*)$": "/server/$1",
17 | "^store/(.*)$": "/store/$1",
18 | "^typings$": "/typings/index.d",
19 | "^typings/(.*)$": "/typings/$1",
20 | "^utils/(.*)$": "/utils/$1",
21 | },
22 | collectCoverageFrom: ["**/*.{js,jsx,ts,tsx}", "!**/*.d.ts"],
23 | coverageReporters: ["text", "lcov", "cobertura"],
24 | globalSetup: "/../jest.globalSetup.ts",
25 | setupFiles: ["/../jest.setup.ts"],
26 | globals: {},
27 | transform: {
28 | "^.+\\.(ts|tsx|js)$": [
29 | "ts-jest",
30 | {
31 | tsconfig: "tsconfig.jest.json",
32 | warnOnly: true,
33 | useESM: true,
34 | },
35 | ],
36 | },
37 | transformIgnorePatterns: ["/node_modules/(?!tle.js/).*"],
38 | };
39 |
40 | export default config;
41 |
--------------------------------------------------------------------------------
/src/components/framework/pane-picker.module.css:
--------------------------------------------------------------------------------
1 | .main {
2 | width: 275px;
3 | border-radius: var(--radius);
4 | background-color: var(--lightest-grey);
5 | text-transform: uppercase;
6 | }
7 |
8 | .item {
9 | display: flex;
10 | padding: 0;
11 | }
12 |
13 | .option {
14 | height: 28px;
15 | cursor: pointer;
16 | font-size: 15px;
17 | padding-left: 15px;
18 | }
19 |
20 | .option:not(:last-of-type) {
21 | border-bottom: 1px solid var(--nearly-black);
22 | }
23 |
24 | .option:hover {
25 | background-color: var(--extremely-grey);
26 | }
27 |
28 | .icon {
29 | border-radius: var(--radius);
30 | height: 21px;
31 | width: 22px;
32 | margin: 3px 10px 3px 0px;
33 | padding-top: 3px;
34 | display: flex;
35 | justify-content: space-around;
36 | }
37 |
38 | .noneIcon {
39 | height: 21px;
40 | padding-top: 3px;
41 | margin: 3px 3px 3px 0px;
42 | }
43 |
44 | .icon > * {
45 | display: block;
46 | }
47 |
48 | .verticalCenter {
49 | display: flex;
50 | flex-direction: column;
51 | justify-content: space-around;
52 | }
53 |
54 | .teal {
55 | background-color: var(--teal);
56 | }
57 |
58 | .ruby {
59 | background-color: var(--ruby);
60 | }
61 |
62 | .purple {
63 | background-color: var(--purple);
64 | }
65 |
66 | .grey {
67 | background-color: var(--dark-grey);
68 | }
69 |
70 | .mustardGreen {
71 | background-color: var(--mustard-green);
72 | }
73 |
74 | .burntOrange {
75 | background-color: var(--burnt-orange);
76 | }
77 |
78 | .burntUmber {
79 | background-color: var(--burnt-umber);
80 | }
81 |
--------------------------------------------------------------------------------
/src/utils/fetch-with-timeout.ts:
--------------------------------------------------------------------------------
1 | import { fetch, RequestInit, Agent } from "undici";
2 |
3 | /**
4 | * Perform a fetch request that throws if it takes too much time. Timeout defaults to 8 seconds. Usage:
5 | */
6 | export default async function fetchWithTimeout(
7 | url: string,
8 | requestInit?: RequestInit & { credentials?: string },
9 | timeout: number = 8000 /** Milliseconds to timeout */
10 | ): Promise {
11 | const controller = new AbortController();
12 | const signal = controller.signal;
13 | const id = setTimeout(() => controller.abort(), timeout);
14 |
15 | // To avoid invalid cert errors in development environments, don't reject unauthorized certs when in development
16 | const rejectUnauthorized = process.env.NODE_ENV === "production";
17 |
18 | const agent = new Agent({
19 | connect: {
20 | rejectUnauthorized: rejectUnauthorized,
21 | },
22 | });
23 |
24 | try {
25 | const response = await fetch(url, {
26 | ...requestInit,
27 | method: requestInit?.method || "GET",
28 | signal,
29 | dispatcher: agent,
30 | cache: "no-store",
31 | headers: {
32 | "Cache-Control": "no-cache, no-store, must-revalidate",
33 | Pragma: "no-cache",
34 | Expires: "0",
35 | ...requestInit?.headers,
36 | },
37 | });
38 |
39 | clearTimeout(id);
40 | return response as unknown as Response; // Type casting to standard Response
41 | } catch (e) {
42 | clearTimeout(id);
43 | }
44 |
45 | // return a response object with status 408 (timeout)
46 | return new Response(null, { status: 408 });
47 | }
48 |
--------------------------------------------------------------------------------
/src/store/ephemera.spec.ts:
--------------------------------------------------------------------------------
1 | import { getAppropriateTLE } from "./ephemera";
2 |
3 | describe("getAppropriateTLE", () => {
4 | const ephemerisFiles = [
5 | {
6 | epoch: "2023-04-27 23:53:15",
7 | tle_line1: "1 25544U 98067A 23117.99531396 .00019654 00000-0 34685-3 0 9993",
8 | tle_line2: "2 25544 51.6402 217.2782 0005322 249.3970 274.7771 15.50368762394009",
9 | },
10 | {
11 | epoch: "2023-04-27 17:24:06",
12 | tle_line1: "1 25544U 98067A 23117.72507036 .00019323 00000-0 34129-3 0 9994",
13 | tle_line2: "2 25544 51.6396 218.6162 0005309 248.6829 206.1814 15.50357183393964",
14 | },
15 | {
16 | epoch: "2023-04-27 12:51:43",
17 | tle_line1: "1 25544U 98067A 23117.53591650 .00019446 00000-0 34350-3 0 9990",
18 | tle_line2: "2 25544 51.6406 219.5567 0005317 247.5469 230.8975 15.50349702393939",
19 | },
20 | {
21 | epoch: "2023-04-27 07:05:57",
22 | tle_line1: "1 25544U 98067A 23117.29580829 .00021162 00000-0 37309-3 0 9994",
23 | tle_line2: "2 25544 51.6386 220.7438 0005539 246.6442 330.8231 15.50344675393899",
24 | },
25 | ];
26 |
27 | test("returns the appropriate TLE string closest to the dateTimeWanted", () => {
28 | const dateTimeWanted = "2023-04-27T14:00:00Z";
29 | const expectedTLE = `1 25544U 98067A 23117.53591650 .00019446 00000-0 34350-3 0 9990
30 | 2 25544 51.6406 219.5567 0005317 247.5469 230.8975 15.50349702393939`;
31 |
32 | const result = getAppropriateTLE(ephemerisFiles, dateTimeWanted);
33 |
34 | expect(result).toBe(expectedTLE);
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/src/server/processing/mediaMtx-hls.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Fetches MTX HLS endpoints from the MediaMTX API.
3 | * Uses the configured HLS buffer duration instead of parsing the m3u8 playlist,
4 | * since the playlist only shows currently available segments (which grows over time
5 | * for a live stream) rather than the full configured buffer duration.
6 | */
7 | export const fetchMTXHlsEndpoints = async ({
8 | sourceAbbr,
9 | }: {
10 | sourceAbbr: string;
11 | }): Promise => {
12 | const mtxHlsEndpoints: MTXHlsEndpoint[] = [];
13 | const auth = `Basic ${Buffer.from(
14 | `${process.env.MEDIAMTX_USERNAME}:${process.env.MEDIAMTX_PASSWORD}`
15 | ).toString("base64")}`;
16 |
17 | const mtxApiBaseUrl = process.env.VITE_PUBLIC_MEDIA_MTX_CONTROL_URL;
18 |
19 | // Use configured HLS buffer duration (default: 900 seconds = 15 minutes)
20 | // This matches MediaMTX config: hlsSegmentCount (180) * hlsSegmentDuration (5s)
21 | const hlsBufferDuration = parseInt(process.env.HLS_BUFFER_DURATION_SECONDS);
22 |
23 | const response = await fetch(`${mtxApiBaseUrl}v3/paths/list`, {
24 | headers: {
25 | Authorization: auth,
26 | },
27 | });
28 |
29 | const mtxResponceJson = await response.json();
30 | const itemsArray = mtxResponceJson.items;
31 |
32 | for (const item of itemsArray) {
33 | const streamNameSuffix = item.name.split("_")[1];
34 |
35 | // If the stream is ready, use the configured HLS buffer duration
36 | if (item.ready && sourceAbbr === streamNameSuffix) {
37 | mtxHlsEndpoints.push({ name: item.name, secondsAvailable: hlsBufferDuration });
38 | }
39 | }
40 |
41 | return mtxHlsEndpoints;
42 | };
43 |
--------------------------------------------------------------------------------
/.gitlab/run-on-schedule.gitlab-ci.yml:
--------------------------------------------------------------------------------
1 | .db-export-template:
2 | needs: []
3 | # I don't think we want to do this, because I don't think we want GitLab to consider this a
4 | # deployment. It's just grabbing some data off of Production.
5 | # environment:
6 | # name: production
7 | # url: https://coda.fit.nasa.gov
8 | when: manual
9 | stage: stageless
10 | timeout: 10 minutes
11 | variables:
12 | GIT_STRATEGY: none
13 | script:
14 | # init sudo ability
15 | - echo "${DEPLOY_SUDO_PASS}" | sudo -S touch /tmp/somefile
16 | - PROJECT_DIR=$(pwd)
17 | - cd /opt/coda
18 | - sudo docker compose exec database pg_dump -U postgres coda > "${PROJECT_DIR}/coda.sql"
19 | artifacts:
20 | name: "$CI_JOB_STARTED_AT$-coda.sql"
21 | paths:
22 | - coda.sql
23 | # hold on to this artifact longer than default (1 day). The latest for any branch never expires
24 | expire_in: 60 days
25 |
26 | z:db-export:prod:
27 | extends: .db-export-template
28 | rules:
29 | - if: $SCHEDULE_TYPE == "backup"
30 | when: always
31 | tags: ["emss-coda-prod"]
32 | resource_group: prod # prevents concurrent executions of this job when there are multiple pipelines
33 | variables:
34 | DEPLOY_SUDO_PASS: $DEPLOY_SUDO_PASS_PROD #CI/CD Variable
35 |
36 | npm-audit:
37 | stage: stageless
38 | rules:
39 | - if: $SCHEDULE_TYPE == "audit"
40 | when: always
41 | tags: ["openshift"]
42 | image: $CI_REGISTRY/emss/docker-images/node-ci:npm-audit # custom image, not on Docker Hub
43 | allow_failure: true
44 | script:
45 | # Run this without --json to make a pretty-print for the job log, plus will pass/fail the job
46 | - npm audit
47 |
--------------------------------------------------------------------------------
/src/utils/date.ts:
--------------------------------------------------------------------------------
1 | import { padZeros } from "./formatting";
2 |
3 | /**
4 | * Sets the time to 0:0:0 UTC for a given date
5 | * @param d date
6 | * @returns date with cleared 0:0:0:0 time
7 | */
8 | export const midnightZulu = (d: Date): Date => {
9 | const ret = new Date(d);
10 | ret.setUTCHours(0);
11 | ret.setUTCMinutes(0);
12 | ret.setUTCSeconds(0);
13 | ret.setUTCMilliseconds(0);
14 | return ret;
15 | };
16 |
17 | /**
18 | * Get the number of milliseconds between two dates, equivalent to `a - b`
19 | */
20 | export const diff = (a: Date, b: Date): number => {
21 | return a.getTime() - b.getTime();
22 | };
23 |
24 | /**
25 | * Advance a Date by some number of milliseconds
26 | */
27 | export const addMs = (d: Date, ms: number): Date => {
28 | const ret = new Date(d);
29 | const currentMS = ret.getUTCMilliseconds();
30 | ret.setUTCMilliseconds(currentMS + ms);
31 | return ret;
32 | };
33 |
34 | /**
35 | * Whether or not two dates are the same UTC date
36 | */
37 | export const isSameDate = (a: Date, b: Date): boolean => {
38 | const Y1 = a.getUTCFullYear();
39 | const M1 = a.getUTCMonth();
40 | const D1 = a.getUTCDate();
41 |
42 | const Y2 = b.getUTCFullYear();
43 | const M2 = b.getUTCMonth();
44 | const D2 = b.getUTCDate();
45 |
46 | return Y1 === Y2 && M1 === M2 && D1 === D2;
47 | };
48 |
49 | /**
50 | * converts a date into a string mmddyy
51 | * @param d date object
52 | * @returns String of MMDDYY in UTC. Month is 1 indexed
53 | */
54 | export const mmddyy = (d: Date): string => {
55 | return (
56 | padZeros(d.getUTCMonth() + 1, 2) +
57 | padZeros(d.getUTCDate(), 2) +
58 | d.getUTCFullYear().toString().substring(2)
59 | );
60 | };
61 |
--------------------------------------------------------------------------------
/.fitdock.yml:
--------------------------------------------------------------------------------
1 | # .fitdock.yml is lightweight method to ensure that FIT servers are configured
2 | # properly to support docker-compose apps. At present it only supports a few
3 | # simple configuration items. Additional features may be added in the future.
4 |
5 | # Specify required "fitdock" version
6 | # Ultimately need to specify version here, but for now use the default
7 | # `fitdock` branch
8 | # fitdock_version: tags/1.0.0
9 |
10 | # Where to install the docker-compose app
11 | install_dir: /opt/coda
12 |
13 | # What directories need to be configured on the host, and what permissions/
14 | # ownership to give them. Primary use for this is to configure directories
15 | # that will be used in docker-compose.yml as volume mounts.
16 | directories:
17 | - path: /d1/coda/static
18 | mode: "0775"
19 | owner: gitlab-runner
20 | group: gitlab-runner
21 | - path: /d1/coda/postgres
22 | mode: "0775"
23 | owner: gitlab-runner
24 | group: gitlab-runner
25 | - path: /d1/coda/db-init
26 | mode: "0775"
27 | owner: gitlab-runner
28 | group: gitlab-runner
29 | - path: /d1/coda/redis
30 | mode: "0775"
31 | owner: gitlab-runner
32 | group: gitlab-runner
33 |
34 | # Ensure SSL key/cert exist at the specified locations. If they do not exist,
35 | # a self-signed cert will be generated. This SSL is NOT the user-facing SSL
36 | # cert; it is only for encrypting traffic between the VM running this app and
37 | # the FIT proxy/load-balancer. The FIT proxy will accept self-signed certs,
38 | # but the FIT sysadmins should switch this self-signed cert out with a valid
39 | # cert.
40 | use_ssl:
41 | key: /etc/pki/tls/private/nginx.key
42 | cert: /etc/pki/tls/certs/nginx.crt
43 |
--------------------------------------------------------------------------------
/src/server/database/mikro-orm.config.ts:
--------------------------------------------------------------------------------
1 | import "../../utils/loadEnv.js";
2 |
3 | import path from "node:path";
4 | import { fileURLToPath } from "node:url";
5 |
6 | import { PostgreSqlDriver, defineConfig } from "@mikro-orm/postgresql";
7 | import { Migrator } from "@mikro-orm/migrations";
8 | import { SeedManager } from "@mikro-orm/seeder";
9 |
10 | import {
11 | AncillaryDataSource_db,
12 | GPXTracks_db,
13 | MediaOverride_db,
14 | PhotoTimeShifts_db,
15 | VideoStartTimeOverrides_db,
16 | Cache_db,
17 | Ephemeris_db,
18 | } from "./models/_allModels.js";
19 |
20 | const __filename = fileURLToPath(import.meta.url);
21 | const __dirname = path.dirname(__filename);
22 |
23 | export default defineConfig({
24 | dbName: process.env.DB_NAME,
25 | host: process.env.DB_HOST,
26 | port: parseInt(process.env.DB_PORT ?? "5432"),
27 | driver: PostgreSqlDriver,
28 | password: process.env.DB_PASS,
29 | migrations: {
30 | path: path.join(__dirname, "./migrations"), // path to the folder with migrations
31 | snapshot: false,
32 | },
33 | seeder: {
34 | path: path.join(__dirname, "./seeds"), // path to the folder with seed files
35 | },
36 | entitiesTs: [
37 | GPXTracks_db,
38 | MediaOverride_db,
39 | AncillaryDataSource_db,
40 | VideoStartTimeOverrides_db,
41 | PhotoTimeShifts_db,
42 | Cache_db,
43 | Ephemeris_db,
44 | ],
45 | entities: [
46 | GPXTracks_db,
47 | MediaOverride_db,
48 | AncillaryDataSource_db,
49 | VideoStartTimeOverrides_db,
50 | PhotoTimeShifts_db,
51 | Cache_db,
52 | Ephemeris_db,
53 | ],
54 | debug: process.env.DEBUG === "true" || process.env.DEBUG?.includes("db"),
55 | allowGlobalContext: true,
56 | extensions: [Migrator, SeedManager],
57 | });
58 |
--------------------------------------------------------------------------------
/src/typings/processing/graph.d.ts:
--------------------------------------------------------------------------------
1 | type GraphsManifest = {
2 | sourceUrl: string;
3 | /**
4 | * In seconds. Default: 10. < 1 means don't refresh.
5 | * If the fetch() call takes longer than this, it will be aborted.
6 | */
7 | updateFrequency?: number;
8 | /**
9 | * Options required to be added to the fetch() call that retrieves this graph data.
10 | * Ref: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
11 | */
12 | fetchOptions?: {
13 | /**
14 | * Default: same-origin. If hitting an API that requires authentication, you may need to
15 | * specify "include". This will likely require the API have the Access-Control-Allow-Credentials
16 | * header set to "true".
17 | */
18 | credentials: FetchOptionsCredentials;
19 | };
20 | graphs: Graph[];
21 | };
22 |
23 | type Graph = {
24 | id: string;
25 | title: string;
26 | type: "line" | "GandalfHeartrate";
27 | dataURL: string;
28 | data?: GraphData[];
29 | };
30 |
31 | type GraphData = {
32 | timestamp: string;
33 | value: number;
34 | };
35 |
36 | type PlotlyChartTrace = {
37 | x: (string | number | Date)[] | null;
38 | y: (string | number)[] | null;
39 | type: "scatter" | "bar" | "line";
40 | mode?:
41 | | "lines"
42 | | "markers"
43 | | "text"
44 | | "lines+markers"
45 | | "lines+text"
46 | | "markers+text"
47 | | "lines+markers+text";
48 | line?: Partial;
49 | name?: string;
50 | };
51 |
52 | type AncillaryDataSource = {
53 | id: number;
54 | date: string;
55 | source: Source;
56 | type: "graphs";
57 | url: string;
58 | };
59 |
60 | type AncillaryDataSource_db_type = AncillaryDataSource;
61 |
62 | type AncillaryDataSourceList = Omit;
63 |
--------------------------------------------------------------------------------
/src/store/sequences.spec.ts:
--------------------------------------------------------------------------------
1 | import {
2 | idFromDate,
3 | getSequenceStartMilliseconds,
4 | getAsPerformedMissionTime,
5 | } from "store/sequences";
6 | import { sequenceType, collection } from "utils/consts";
7 |
8 | describe("store/sequences", () => {
9 | const seq: Sequence = {
10 | location: collection.ISS,
11 | type: sequenceType.EVA,
12 | name: "testName",
13 | displayTitle: "",
14 | dataURL: "",
15 | startTime: "21:39",
16 | startDate: "2022-07-27",
17 | endDate: "",
18 | duration: null,
19 | crew: null,
20 | asPerformed: null,
21 | asPlanned: null,
22 | };
23 |
24 | it("idFromDate() - converts UTC string date to yyyy-mm-dd string", () => {
25 | expect(idFromDate("2022-07-27T21:39:19Z")).toEqual("2022-07-27");
26 | });
27 |
28 | it("getSequenceStartMiliseconds() - returns ms since 1/1/1970 for sequence start date/time", () => {
29 | expect(getSequenceStartMilliseconds(seq)).toEqual(new Date("2022-07-27T21:39Z").getTime());
30 | });
31 |
32 | it("getAsPerformedMissionTime() - sets start and end time (seconds) for activities", () => {
33 | const activities: Activity[] = [
34 | { content: "A", color: "", duration: 10 },
35 | { content: "B", color: "", duration: 15 },
36 | { content: "C", color: "", duration: 10 },
37 | ];
38 | const activitiesWithStart: Activity[] = [
39 | { content: "A", color: "", duration: 10, startTimeSeconds: 1, endTimeSeconds: 11 },
40 | { content: "B", color: "", duration: 15, startTimeSeconds: 11, endTimeSeconds: 26 },
41 | { content: "C", color: "", duration: 10, startTimeSeconds: 26, endTimeSeconds: 36 },
42 | ];
43 | expect(getAsPerformedMissionTime(activities, "1970-01-01", 1000)).toEqual(activitiesWithStart);
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/src/public/clocksync/index.js:
--------------------------------------------------------------------------------
1 | document.addEventListener("DOMContentLoaded", function () {
2 | let clientTime = null;
3 | let serverTime = null;
4 |
5 | const t = setInterval(waitForTopOfSecond, 1000);
6 |
7 | async function compareServerTime() {
8 | clientTime = new Date();
9 | const resource = "https://apolloinrealtime.org/coda_clocksync/server/gettime.php";
10 | const response = await fetch(resource);
11 | let serverTimeObj;
12 | if (response.ok) {
13 | serverTimeObj = await response.json();
14 | serverTime = new Date(serverTimeObj.serverTime);
15 | document.getElementById("timeComparisonValue").innerHTML =
16 | clientTime.getTime() - serverTime.getTime();
17 | } else {
18 | console.log("Server time sanity check failed");
19 | }
20 | }
21 |
22 | async function waitForTopOfSecond() {
23 | const lastSeconds = new Date().toISOString().substring(17, 19);
24 | // loop until the second rolls over and then display the QR code
25 | while (true) {
26 | const currUTCDate = new Date().toISOString();
27 | const seconds = currUTCDate.substring(17, 19);
28 | if (seconds !== lastSeconds) {
29 | makeQR(currUTCDate);
30 | if (seconds % 5 === 0) {
31 | compareServerTime();
32 | }
33 | break;
34 | }
35 | }
36 | }
37 |
38 | function makeQR(currUTCDate) {
39 | const typeNumber = 0;
40 | const errorCorrectionLevel = "H";
41 | const qr = qrcode(typeNumber, errorCorrectionLevel);
42 | qr.addData(currUTCDate, "Byte");
43 | qr.make();
44 | document.getElementById("qrcode").innerHTML = qr.createSvgTag({
45 | cellSize: 1,
46 | margin: 5,
47 | scalable: true,
48 | });
49 | document.getElementById("headerCenter").innerHTML = currUTCDate;
50 | }
51 | });
52 |
--------------------------------------------------------------------------------
/src/server/express/routes/emss/dataRefresh.ts:
--------------------------------------------------------------------------------
1 | import { asError } from "@emss/utils";
2 | import express, { Request, Response } from "express";
3 | import { requireSuperuser } from "server/express/middleware/requireSuperuser";
4 | import serverLogger from "utils/logging/serverLogger";
5 | import { forceRefreshDataType } from "server/express/dataRetrievalScheduler";
6 |
7 | /**
8 | * `/api/v1/emss/dataRefresh`
9 | *
10 | * Force refresh a specific data type for a source and date
11 | */
12 |
13 | const router = express.Router();
14 |
15 | interface DataRefreshRequestBody {
16 | source: Source;
17 | dateWanted: string;
18 | dataType: StoreDataType;
19 | }
20 |
21 | // POST - force refresh
22 | router.post("/", requireSuperuser, async (req: Request, res: Response): Promise => {
23 | try {
24 | const { source, dateWanted, dataType } = req.body as DataRefreshRequestBody;
25 |
26 | if (!source || !dateWanted || !dataType) {
27 | res.status(400).send({ msg: "Missing required parameters: source, dateWanted, dataType" });
28 | return;
29 | }
30 |
31 | serverLogger.info({ logId: "Force refresh initiated", source, dateWanted, dataType });
32 |
33 | // Call the force refresh function
34 | const result = await forceRefreshDataType({ source, dateWanted, dataType });
35 |
36 | if (result.success) {
37 | res.status(200).json({
38 | msg: "Force refresh initiated successfully",
39 | data: result.data,
40 | });
41 | } else {
42 | res.status(500).json({
43 | msg: "Force refresh failed",
44 | error: result.error,
45 | });
46 | }
47 | return;
48 | } catch (e) {
49 | serverLogger.error(asError(e), { logId: "error in dataRefresh route" });
50 | res.status(400).json({ error: e.toString() });
51 | return;
52 | }
53 | });
54 |
55 | export default router;
56 |
--------------------------------------------------------------------------------
/src/typings/processing/sequences.d.ts:
--------------------------------------------------------------------------------
1 | /** A large contiguous section of the timeline representing an event at a location, eg. an EVA on ISS */
2 | interface Sequence {
3 | /** The mission associated with this sequence */
4 | location: Collection;
5 | /** Broad category of this sequence */
6 | type: SequenceType;
7 | /** Short identifier, eg. `US EVA 55` */
8 | name: string;
9 | /** Descriptive title, eg. `US EVA IDA3 Install` */
10 | displayTitle: string;
11 | /** Where users can get more information */
12 | dataURL: string;
13 | /** HH:MM UTC */
14 | startTime?: string;
15 | /** YYYY-MM-DD UTC */
16 | startDate: string;
17 | /** YYYY-MM-DD UTC */
18 | endDate?: string;
19 | /** seconds */
20 | duration: number;
21 | /** People responsible for this sequence */
22 | crew?: Crew;
23 | /** List of activities performed by crew */
24 | asPerformed: { [key: string]: Activity[] };
25 | /** List of planned activities for the crew */
26 | asPlanned?: { [key: string]: Activity[] };
27 | /**
28 | * UUID of event in Maestro, as recorded on wiki page, if there is one.
29 | * Enables hitting maestro endpoint /api/v1/event/exetimelinestatus/:uuid
30 | */
31 | maestroEventUuid?: string | false;
32 | }
33 |
34 | /** Crew names keyed by actor, eg. `{EV1: "Bob"}` */
35 | interface Crew {
36 | EV1: string;
37 | EV2: string;
38 | SUIT_IV: string;
39 | }
40 |
41 | interface AllCrews {
42 | [key: string]: Crew;
43 | }
44 |
45 | /** Largest chunk of time within a Sequence */
46 | interface Activity {
47 | /** Description of the activity */
48 | content: string;
49 | /** Color to use when rendering this activity */
50 | color: string;
51 | /** seconds */
52 | duration: number;
53 | /** Seconds into the UTC day */
54 | startTimeSeconds?: number;
55 | /** Seconds into the UTC day */
56 | endTimeSeconds?: number;
57 | }
58 |
--------------------------------------------------------------------------------
/src/server/processing/graphs.ts:
--------------------------------------------------------------------------------
1 | import { getAncillaryDataSourceList } from "server/processing/ancillaryDataSources";
2 | import fetchWithTimeout from "utils/fetch-with-timeout";
3 |
4 | const buildResponse = (
5 | data: GraphsManifest | null,
6 | options: { success: boolean; error?: string }
7 | ): FetchResponse => ({
8 | data,
9 | fetchMetadata: {
10 | success: options.success,
11 | error: options.error,
12 | timestamp: new Date().toISOString(),
13 | },
14 | });
15 |
16 | export const getGraphManifest = async ({
17 | source,
18 | dateWanted,
19 | }: {
20 | source: Source;
21 | dateWanted: string;
22 | }): Promise> => {
23 | const ancillaryDataSources = await getAncillaryDataSourceList();
24 |
25 | const ancillaryDataSource = ancillaryDataSources?.find((ancillaryDataSourceList) => {
26 | const overrideDate = new Date(ancillaryDataSourceList.date);
27 | const requestedDate = new Date(dateWanted);
28 | return (
29 | overrideDate.getTime() === requestedDate.getTime() &&
30 | ancillaryDataSourceList.source === source &&
31 | ancillaryDataSourceList.type === "graphs"
32 | );
33 | });
34 |
35 | if (ancillaryDataSource) {
36 | try {
37 | const res = await fetchWithTimeout(ancillaryDataSource.url);
38 | const graphManifest = (await res.json()) as GraphsManifest;
39 | return buildResponse(graphManifest ?? null, { success: true });
40 | } catch (error) {
41 | const message = error instanceof Error ? error.message : "Unable to load graphs manifest";
42 | return buildResponse(null, { success: false, error: message });
43 | }
44 | }
45 |
46 | // No ancillary data source found for this date and type. This is not an error; just return empty data.
47 | return buildResponse(null, { success: true });
48 | };
49 |
50 | export default getGraphManifest;
51 |
--------------------------------------------------------------------------------
/src/components/panes/graph/graph.module.css:
--------------------------------------------------------------------------------
1 | .main {
2 | position: relative;
3 | height: 0;
4 | width: 100%;
5 | min-height: 100%;
6 | overflow: auto;
7 | background-color: var(--nearly-black);
8 | overflow: auto;
9 | }
10 |
11 | .controls {
12 | display: flex;
13 | height: 100%;
14 | justify-content: space-between;
15 | margin-left: auto;
16 | }
17 |
18 | .controlsLeft {
19 | display: flex;
20 | }
21 |
22 | .durationItemsContainer {
23 | display: flex;
24 | margin-left: 3px;
25 | }
26 |
27 | .verticalCenter {
28 | display: flex;
29 | flex-direction: column;
30 | justify-content: space-around;
31 | }
32 |
33 | .rightButtons {
34 | display: flex;
35 | }
36 |
37 | .rightButtons > *:not(:last-of-type) {
38 | margin-right: 5px;
39 | }
40 |
41 | .selectContainer {
42 | position: relative;
43 | display: flex;
44 | flex-direction: column;
45 | justify-content: center;
46 | }
47 |
48 | .selectContainerWide {
49 | width: 150px;
50 | }
51 | .selectContainerNarrow {
52 | width: 70px;
53 | }
54 |
55 | .selectContainer select {
56 | font-family: "Inter", sans-serif;
57 | font-weight: 400;
58 | font-size: 15px;
59 | cursor: pointer;
60 | padding-left: 15px;
61 | padding-right: 15px;
62 | height: 27px;
63 | border: none;
64 | border-radius: var(--radius);
65 | color: white;
66 | background-color: var(--lightest-grey);
67 | appearance: none;
68 | -webkit-appearance: none;
69 | -moz-appearance: none;
70 | text-overflow: ellipsis;
71 | outline: 0;
72 | }
73 | .selectContainer select::-ms-expand {
74 | display: none;
75 | }
76 | .selectContainer select:hover,
77 | .selectContainer select:focus {
78 | outline: 0;
79 | }
80 | .selectContainer select:disabled {
81 | opacity: 0.5;
82 | pointer-events: none;
83 | }
84 | .select_arrow {
85 | position: absolute;
86 | pointer-events: none;
87 | top: 3px;
88 | right: 6px;
89 | }
90 |
--------------------------------------------------------------------------------
/src/store/graphs.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 |
3 | export const initialState: GraphsState = {
4 | graphsManifest: null,
5 | metadata: null,
6 | };
7 |
8 | export const graphSlice = createSlice({
9 | name: "graphs",
10 | initialState,
11 | reducers: {
12 | /** Add new graph manifest to the store */
13 | setGraphsManifest: (state, action: { payload: FetchResponse }) => {
14 | state.graphsManifest = action.payload.data;
15 | state.metadata = action.payload.fetchMetadata;
16 | },
17 | clearGraphsManifest: (state) => {
18 | state.graphsManifest = null;
19 | state.metadata = null;
20 | },
21 | setGraphsData: (state, action: { payload: { graphId: string; graphData: GraphData[] } }) => {
22 | const graph = state.graphsManifest?.graphs.find((g) => g.id === action.payload.graphId);
23 | graph.data = action.payload.graphData;
24 | state.graphsManifest.graphs = state.graphsManifest.graphs.map((stateGraph) => {
25 | if (stateGraph.id === graph.id) {
26 | return graph;
27 | } else {
28 | return stateGraph;
29 | }
30 | });
31 | },
32 | clearGraphsData: (state) => {
33 | if (!state.graphsManifest) return;
34 | state.graphsManifest.graphs = state.graphsManifest.graphs.map((stateGraph) => {
35 | return { ...stateGraph, data: null as GraphData[] | null };
36 | });
37 | },
38 | graphsFetchError: (state, action: { payload: string }) => {
39 | state.metadata = {
40 | success: false,
41 | error: action.payload,
42 | timestamp: state.metadata?.timestamp || new Date().toISOString(),
43 | };
44 | },
45 | },
46 | });
47 |
48 | export const {
49 | setGraphsManifest,
50 | clearGraphsManifest,
51 | setGraphsData,
52 | clearGraphsData,
53 | graphsFetchError,
54 | } = graphSlice.actions;
55 |
--------------------------------------------------------------------------------
/src/utils/suncalc.d.ts:
--------------------------------------------------------------------------------
1 | export interface SunTimes {
2 | solarNoon: Date;
3 | nadir: Date;
4 | sunrise: Date;
5 | sunset: Date;
6 | sunriseEnd: Date;
7 | sunsetStart: Date;
8 | dawn: Date;
9 | dusk: Date;
10 | nauticalDawn: Date;
11 | nauticalDusk: Date;
12 | nightEnd: Date;
13 | night: Date;
14 | goldenHourEnd: Date;
15 | goldenHour: Date;
16 | [key: string]: Date;
17 | }
18 |
19 | export interface SunPosition {
20 | azimuth: number;
21 | altitude: number;
22 | }
23 |
24 | export interface MoonPosition extends SunPosition {
25 | distance: number;
26 | parallacticAngle: number;
27 | }
28 |
29 | export interface MoonIllumination {
30 | fraction: number;
31 | phase: number;
32 | angle: number;
33 | }
34 |
35 | export interface MoonTimes {
36 | rise?: Date;
37 | set?: Date;
38 | alwaysUp?: boolean;
39 | alwaysDown?: boolean;
40 | }
41 |
42 | export interface TimeConfig {
43 | angle: number;
44 | riseName: string;
45 | setName: string;
46 | }
47 |
48 | export function getPosition(date: Date, lat: number, lng: number): SunPosition;
49 |
50 | export function getTimes(date: Date, lat: number, lng: number, height?: number): SunTimes;
51 |
52 | export function getMoonPosition(date: Date, lat: number, lng: number): MoonPosition;
53 |
54 | export function getMoonIllumination(date?: Date): MoonIllumination;
55 |
56 | export function getMoonTimes(date: Date, lat: number, lng: number, inUTC?: boolean): MoonTimes;
57 |
58 | export function addTime(angle: number, riseName: string, setName: string): void;
59 |
60 | export const times: TimeConfig[];
61 |
62 | declare const SunCalc: {
63 | getPosition: typeof getPosition;
64 | getTimes: typeof getTimes;
65 | getMoonPosition: typeof getMoonPosition;
66 | getMoonIllumination: typeof getMoonIllumination;
67 | getMoonTimes: typeof getMoonTimes;
68 | addTime: typeof addTime;
69 | times: typeof times;
70 | };
71 |
72 | export default SunCalc;
73 |
--------------------------------------------------------------------------------
/.gitlab/includes/db-import.yml:
--------------------------------------------------------------------------------
1 | #
2 | # Adds jobs to import the prod database dump into the database.
3 | # Must have exported the prod database first
4 | # Put these jobs in a separate file so they can be included in both gitlab-ci.yml files
5 | #
6 |
7 | .db-import-template:
8 | when: manual
9 | stage: stageless
10 | needs: ["z:db-export:prod"]
11 | timeout: 10 minutes
12 | variables:
13 | GIT_STRATEGY: none
14 | script:
15 | # init sudo ability
16 | - echo "${DEPLOY_SUDO_PASS}" | sudo -S touch /tmp/somefile
17 | - PROJECT_DIR=$(pwd)
18 | - cd /opt/coda
19 | - cp "${PROJECT_DIR}/coda.sql" /d1/coda/db-init/coda.sql
20 |
21 | # Stop docker compose, then remove database files
22 | - sudo /usr/bin/docker compose down --remove-orphans
23 | - sudo rm -rf /d1/coda/postgres
24 |
25 | # Bring docker compose back up, which will use the file at /d1/coda/db-init/coda.sql, then remove it
26 | - sudo /usr/bin/docker compose up -d
27 | - sudo rm -f /d1/coda/db-init/coda.sql
28 |
29 | # Migrations will be run when the apiv1 container restarts
30 |
31 | z:db-import:carbon:
32 | extends:
33 | - .db-import-template
34 | - .carbon-deploy
35 |
36 | z:db-import:gold:
37 | extends:
38 | - .db-import-template
39 | - .gold-deploy
40 |
41 | z:db-import:iron:
42 | extends:
43 | - .db-import-template
44 | - .iron-deploy
45 |
46 | z:db-import:neon:
47 | extends:
48 | - .db-import-template
49 | - .neon-deploy
50 |
51 | z:db-import:oxygen:
52 | extends:
53 | - .db-import-template
54 | - .oxygen-deploy
55 |
56 | z:db-import:int:
57 | extends: .db-import-template
58 | tags: ["emss-coda-int"]
59 | resource_group: int # prevents concurrent executions on this environment when there are multiple pipelines
60 | environment:
61 | name: integration
62 | url: $URL
63 | variables:
64 | DEPLOY_SUDO_PASS: $DEPLOY_SUDO_PASS_INT #CI/CD Variable
65 | URL: https://coda-int.fit.nasa.gov
66 |
--------------------------------------------------------------------------------
/src/utils/user.spec.ts:
--------------------------------------------------------------------------------
1 | import { isSuperuser } from "./user";
2 | import { EmssUser } from "@emss/oauth2-proxy-common";
3 |
4 | describe("isSuperuser", () => {
5 | const createUser = (roles: string[]): EmssUser => ({
6 | uupic: "1234",
7 | email: "test@nasa.gov",
8 | auid: "testuser",
9 | givenname: "Test",
10 | surname: "User",
11 | display_name: "User, Test (JSC-XX)",
12 | roles: roles as any,
13 | uscitizen: true,
14 | legal_permanent_resident: true,
15 | usperson: true,
16 | ip_address: "1.2.3.4",
17 | });
18 |
19 | it("should return true for user with CODA-Superuser role", () => {
20 | const user = createUser(["CODA-Superuser", "AEGIS-Editor"]);
21 | expect(isSuperuser(user)).toBe(true);
22 | });
23 |
24 | it("should return true for user with EMSS-Superuser role", () => {
25 | const user = createUser(["EMSS-Superuser"]);
26 | expect(isSuperuser(user)).toBe(true);
27 | });
28 |
29 | it("should return true for user with both superuser roles", () => {
30 | const user = createUser(["EMSS-Superuser", "CODA-Superuser"]);
31 | expect(isSuperuser(user)).toBe(true);
32 | });
33 |
34 | it("should return false for user without superuser roles", () => {
35 | const user = createUser(["AEGIS-Editor", "Maestro-Editor"]);
36 | expect(isSuperuser(user)).toBe(false);
37 | });
38 |
39 | it("should return false for user with no roles array", () => {
40 | const user = createUser([]);
41 | expect(isSuperuser(user)).toBe(false);
42 | });
43 |
44 | it("should return false for null user", () => {
45 | expect(isSuperuser(null)).toBe(false);
46 | });
47 |
48 | it("should return false for undefined user", () => {
49 | expect(isSuperuser(undefined)).toBe(false);
50 | });
51 |
52 | it("should return false for user with undefined roles", () => {
53 | const user = createUser(["AEGIS-Editor"]);
54 | delete (user as any).roles;
55 | expect(isSuperuser(user)).toBe(false);
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/src/public/images/talky-the-bot.svg:
--------------------------------------------------------------------------------
1 |
15 |
--------------------------------------------------------------------------------
/src/public/fonts.css:
--------------------------------------------------------------------------------
1 | /*
2 | * Self-hosted Google Fonts (for offline/air-gapped environments)
3 | *
4 | * Available fonts:
5 | * - Inter: weights 100-900 (variable font)
6 | * - Ubuntu Mono: weights 400, 700 (normal and italic)
7 | * - Aldrich: weight 400
8 | * - Roboto Mono: weights 300-500 (variable font)
9 | */
10 |
11 | /* Inter - Variable Font (weights 100-900) */
12 | @font-face {
13 | font-family: "Inter";
14 | font-style: normal;
15 | font-weight: 100 900;
16 | font-display: swap;
17 | src: url("/fonts/Inter-Variable.woff2") format("woff2");
18 | }
19 |
20 | /* Roboto Mono - Variable Font (weights 300-500) */
21 | @font-face {
22 | font-family: "Roboto Mono";
23 | font-style: normal;
24 | font-weight: 300 500;
25 | font-display: swap;
26 | src: url("/fonts/RobotoMono-Variable.woff2") format("woff2");
27 | }
28 |
29 | /* Ubuntu Mono - Regular (400) */
30 | @font-face {
31 | font-family: "Ubuntu Mono";
32 | font-style: normal;
33 | font-weight: 400;
34 | font-display: swap;
35 | src: url("/fonts/UbuntuMono-Regular.woff2") format("woff2");
36 | }
37 |
38 | /* Ubuntu Mono - Bold (700) */
39 | @font-face {
40 | font-family: "Ubuntu Mono";
41 | font-style: normal;
42 | font-weight: 700;
43 | font-display: swap;
44 | src: url("/fonts/UbuntuMono-Bold.woff2") format("woff2");
45 | }
46 |
47 | /* Ubuntu Mono - Italic (400) */
48 | @font-face {
49 | font-family: "Ubuntu Mono";
50 | font-style: italic;
51 | font-weight: 400;
52 | font-display: swap;
53 | src: url("/fonts/UbuntuMono-Italic.woff2") format("woff2");
54 | }
55 |
56 | /* Ubuntu Mono - Bold Italic (700) */
57 | @font-face {
58 | font-family: "Ubuntu Mono";
59 | font-style: italic;
60 | font-weight: 700;
61 | font-display: swap;
62 | src: url("/fonts/UbuntuMono-BoldItalic.woff2") format("woff2");
63 | }
64 |
65 | /* Aldrich - Regular (400) */
66 | @font-face {
67 | font-family: "Aldrich";
68 | font-style: normal;
69 | font-weight: 400;
70 | font-display: swap;
71 | src: url("/fonts/Aldrich-Regular.woff2") format("woff2");
72 | }
73 |
--------------------------------------------------------------------------------
/src/public/clocksync/clocksync.css:
--------------------------------------------------------------------------------
1 | body {
2 | background-color: #3e3b44;
3 | overflow: hidden;
4 | margin: 0;
5 | height: 100%;
6 | font-family: "Trebuchet MS", "Lucida Sans Unicode", "Lucida Grande", "Lucida Sans", Arial,
7 | sans-serif;
8 | }
9 | .container {
10 | display: flex;
11 | flex-direction: column;
12 | height: 100%;
13 | overflow: hidden;
14 | }
15 | #headerContainer {
16 | display: flex;
17 | align-items: center;
18 | justify-content: space-between;
19 | background-color: #19181b;
20 | color: white;
21 | padding-left: 10px;
22 | padding-right: 10px;
23 | flex: 0 1 auto;
24 | }
25 | #headerLeft {
26 | flex: 1;
27 | display: flex;
28 | align-items: center;
29 | }
30 | .QRHeaderText {
31 | font-size: 1.6vw;
32 | margin-left: 0.5vw;
33 | }
34 | #headerCenter {
35 | font-family: monospace;
36 | font-weight: 600;
37 | font-size: 3vw;
38 | text-align: center;
39 | /* max-height: 10vh; */
40 | }
41 | #headerRight {
42 | flex: 1;
43 | }
44 | #bodyContainer {
45 | flex: 1 1 auto;
46 | height: calc(100vh - 50px);
47 | }
48 | #qrcode {
49 | margin: auto;
50 | padding: 1px;
51 | color: white;
52 | font-size: 1.5em;
53 | height: 80%;
54 | width: 100%;
55 | }
56 | #qrimg {
57 | height: 100%;
58 | width: 100%;
59 | }
60 | .square {
61 | width: 100%;
62 | height: 0;
63 | padding-top: 100%;
64 | }
65 | #timeComparison {
66 | color: white;
67 | font-size: 1.1em;
68 | }
69 | #timeComparisonValue {
70 | color: white;
71 | }
72 | .NASALogo {
73 | width: 4vw;
74 | height: 50px;
75 | background: url(img/logo_NASA.svg) no-repeat center;
76 | background-size: 4vw 50px;
77 | margin-left: 0;
78 | margin-right: 0;
79 | }
80 | .EMSSLogo {
81 | margin-left: auto;
82 | width: 6vw;
83 | height: 50px;
84 | background: url(img/EMSS_wordmark.svg) no-repeat center;
85 | background-size: 6vw 50px;
86 | }
87 | .CODALogo {
88 | margin-left: 1vw;
89 | width: 6vw;
90 | height: 50px;
91 | background: url(img/CODA_wordmark.svg) no-repeat center;
92 | background-size: 6vw 50px;
93 | }
94 |
--------------------------------------------------------------------------------
/src/store/videos.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 |
3 | export const initialState: VideosState = {
4 | videoFiles: [],
5 | mtxPlaybackAvailability: {},
6 | mtxHlsEndpoints: [],
7 | metadataIo: null,
8 | metadataMtx: null,
9 | };
10 |
11 | export const videoSlice = createSlice({
12 | name: "video",
13 | initialState,
14 | reducers: {
15 | /** Add new video files to the store */
16 | addVideos: (state, action: { payload: FetchResponse }) => {
17 | state.videoFiles = action.payload.data || []; // null returned when retriever error
18 | state.metadataIo = action.payload.fetchMetadata;
19 | },
20 |
21 | /** Clear all videos from the store */
22 | clearVideos: (state) => {
23 | state.videoFiles = [];
24 | state.metadataIo = null;
25 | },
26 |
27 | /** An error occured fetching video metadata */
28 | fetchErrorIo: (state, action: { payload: string }) => {
29 | const error = action.payload.replace(/key=.*&/, "key=[key]&");
30 | state.metadataIo = {
31 | success: false,
32 | error,
33 | timestamp: state.metadataIo?.timestamp || new Date().toISOString(),
34 | };
35 | },
36 |
37 | /** An error occured fetching MTX video metadata */
38 | fetchErrorMtx: (state, action: { payload: string }) => {
39 | const error = action.payload.replace(/key=.*&/, "key=[key]&");
40 | state.metadataMtx = {
41 | success: false,
42 | error,
43 | timestamp: state.metadataMtx?.timestamp || new Date().toISOString(),
44 | };
45 | },
46 |
47 | setMtxPlayback: (state, action: { payload: FetchResponse }) => {
48 | state.mtxPlaybackAvailability = action.payload.data?.mtxPlaybackAvailability || {};
49 | state.mtxHlsEndpoints = action.payload.data?.mtxHlsEndpoints || [];
50 | state.metadataMtx = action.payload.fetchMetadata;
51 | },
52 | },
53 | });
54 |
55 | export const { addVideos, clearVideos, fetchErrorIo, fetchErrorMtx, setMtxPlayback } =
56 | videoSlice.actions;
57 |
--------------------------------------------------------------------------------
/src/store/ephemera.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 | import { diff } from "../utils/date";
3 |
4 | export const initialState: EphemeraState = {
5 | ephemerisFiles: [],
6 | metadata: null,
7 | };
8 |
9 | export const ephemeraSlice = createSlice({
10 | name: "ephemera",
11 | initialState,
12 | reducers: {
13 | /** Add new photo files to the store */
14 | addEphemera: (state, action: { payload: FetchResponse }) => {
15 | state.ephemerisFiles = action.payload.data || [];
16 | state.metadata = action.payload.fetchMetadata;
17 | },
18 | clearEphemera: (state) => {
19 | state.ephemerisFiles = [];
20 | state.metadata = null;
21 | },
22 |
23 | fetchError: (state, action: { payload: string }) => {
24 | state.metadata = {
25 | success: false,
26 | error: action.payload,
27 | timestamp: state.metadata?.timestamp || new Date().toISOString(),
28 | };
29 | },
30 | },
31 | });
32 |
33 | export const { addEphemera, clearEphemera, fetchError } = ephemeraSlice.actions;
34 |
35 | /**
36 | * Returns a Two-Line Element (TLE) from space-track.org that is closest to dateTimeWanted
37 | * @param ephemera
38 | * @param dateTimeWanted
39 | * @returns TLE string
40 | */
41 | export function getAppropriateTLE(ephemera: EphemerisEntry[], dateTimeWanted: string): string {
42 | let thisDateDiff;
43 | let lastDateDiff = -1;
44 |
45 | let tleObj = ephemera[0];
46 | let mostRecentTLE = `${tleObj.tle_line1}
47 | ${tleObj.tle_line2}`;
48 |
49 | // chew through ephemiris data looking for the TLE closest to the timestamp of interest
50 | for (let i = 0; i < ephemera.length; i++) {
51 | thisDateDiff = Math.abs(diff(new Date(ephemera[i].epoch + "Z"), new Date(dateTimeWanted)));
52 | if (i !== 0 && thisDateDiff < lastDateDiff) {
53 | tleObj = ephemera[i];
54 | mostRecentTLE = `${tleObj.tle_line1}
55 | ${tleObj.tle_line2}`;
56 | }
57 | lastDateDiff = thisDateDiff;
58 | }
59 |
60 | return mostRecentTLE;
61 | }
62 |
--------------------------------------------------------------------------------
/docker/nginx/route-require-auth.conf:
--------------------------------------------------------------------------------
1 | # This nginx conf file is included in any route that we want to ensure auth is present.
2 | #
3 | # Ref: https://oauth2-proxy.github.io/oauth2-proxy/docs/configuration/overview#configuring-for-use-with-the-nginx-auth_request-directive
4 |
5 | auth_request /oauth2/auth;
6 | error_page 401 = /oauth2/sign_in;
7 |
8 | # pass information via X-User and X-Email headers to backend,
9 | # requires running with --set-xauthrequest flag
10 | auth_request_set $user $upstream_http_x_auth_request_user;
11 | auth_request_set $email $upstream_http_x_auth_request_email;
12 | proxy_set_header X-User $user;
13 | proxy_set_header X-Email $email;
14 |
15 | # if you enabled --pass-access-token, this will pass the token to the backend
16 | auth_request_set $token $upstream_http_x_auth_request_access_token;
17 | proxy_set_header X-Access-Token $token;
18 |
19 | # if you enabled --cookie-refresh, this is needed for it to work with auth_request
20 | auth_request_set $auth_cookie $upstream_http_set_cookie;
21 | add_header Set-Cookie $auth_cookie;
22 |
23 | # When using the --set-authorization-header flag, some provider's cookies can exceed the 4kb
24 | # limit and so the OAuth2 Proxy splits these into multiple parts.
25 | # Nginx normally only copies the first `Set-Cookie` header from the auth_request to the response,
26 | # so if your cookies are larger than 4kb, you will need to extract additional cookies manually.
27 | auth_request_set $auth_cookie_name_upstream_1 $upstream_cookie_auth_cookie_name_1;
28 |
29 | # Extract the Cookie attributes from the first Set-Cookie header and append them
30 | # to the second part ($upstream_cookie_* variables only contain the raw cookie content)
31 | if ($auth_cookie ~* "(; .*)") {
32 | set $auth_cookie_name_0 $auth_cookie;
33 | set $auth_cookie_name_1 "auth_cookie_name_1=$auth_cookie_name_upstream_1$1";
34 | }
35 |
36 | # Send both Set-Cookie headers now if there was a second part
37 | if ($auth_cookie_name_upstream_1) {
38 | add_header Set-Cookie $auth_cookie_name_0;
39 | add_header Set-Cookie $auth_cookie_name_1;
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/interface/photo-filter-button.module.css:
--------------------------------------------------------------------------------
1 | .buttonLong {
2 | width: 55px;
3 | padding: 0 6px;
4 | }
5 |
6 | .buttonShort {
7 | width: 20px;
8 | padding: 0;
9 | }
10 |
11 | .buttonLabel {
12 | display: flex;
13 | justify-content: space-between;
14 | }
15 |
16 | .filterLabel {
17 | display: flex;
18 | align-items: center;
19 | justify-content: space-between;
20 | position: relative;
21 | }
22 |
23 | .filterButton {
24 | display: inline-block;
25 | height: 18px;
26 | background-color: var(--even-greyer);
27 | color: var(--lighter-grey);
28 | border: none;
29 | border-radius: var(--radius);
30 | font-size: 11px;
31 | font-weight: 600;
32 | cursor: pointer;
33 | }
34 |
35 | .filterButton:hover {
36 | background-color: #eeeeee;
37 | }
38 |
39 | .photoOverlay {
40 | display: block;
41 | position: absolute;
42 | top: 0;
43 | left: 0;
44 | height: 100%;
45 | width: 100%;
46 | z-index: 1;
47 | box-sizing: border-box;
48 | padding: 20px;
49 | outline: none;
50 | overflow-y: auto;
51 | }
52 |
53 | .overlayTable {
54 | width: 100%;
55 | font-size: 0.9em;
56 | background: rgb(15, 15, 15, 0.8);
57 | border: 2px solid #eeeeee;
58 | border-radius: 10px;
59 | border-spacing: 0;
60 | }
61 |
62 | .overlayTable a {
63 | text-decoration: underline;
64 | }
65 |
66 | .overlayTable tr {
67 | border-bottom: 2px solid rgba(150, 150, 150, 0.5);
68 | }
69 |
70 | .overlayTable td:first-child {
71 | text-align: right;
72 | width: 40px;
73 | font-weight: 400;
74 | white-space: nowrap;
75 | border-right: 2px solid rgba(150, 150, 150, 0.5);
76 | }
77 |
78 | .overlayTable td {
79 | display: table-cell;
80 | border-collapse: collapse;
81 | vertical-align: top;
82 | font-weight: 200;
83 | padding: 5px;
84 | border-bottom: 2px solid rgba(150, 150, 150, 0.5);
85 | }
86 | .overlayTable tr:last-child td {
87 | border-bottom: none;
88 | }
89 |
90 | .overlayTable .digiValue {
91 | font-family: "Ubuntu Mono";
92 | font-size: 1em;
93 | white-space: pre-wrap;
94 | word-break: break-all;
95 | }
96 |
97 | .selected {
98 | border: none;
99 | background-color: #eeeeee;
100 | color: var(--lighter-grey);
101 | }
102 |
--------------------------------------------------------------------------------
/src/server/processing/ephemeris-celestrak.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Celestrak API integration for fetching current ISS TLE data
3 | */
4 | import fetchWithTimeout from "utils/fetch-with-timeout";
5 | import { getEpochTimestamp } from "tle.js";
6 | import ConsoleLogger from "utils/logging/consoleLogger";
7 | import { upsertEphemerisRecords } from "./ephemeris";
8 |
9 | /**
10 | * Fetch latest TLE from Celestrak and update database
11 | * Skips update if epoch matches latest record in database
12 | * Returns the epoch from the fetched TLE data
13 | */
14 | export async function updateFromCelestrak(): Promise {
15 | const queryURL = `https://celestrak.org/NORAD/elements/gp.php?CATNR=25544`;
16 |
17 | try {
18 | const res = await fetchWithTimeout(queryURL, { method: "GET" });
19 |
20 | const resText = await res.text();
21 | const lines = resText.split("\r\n");
22 | const tle = `${lines[0].trim()}
23 | ${lines[1].trim()}
24 | ${lines[2].trim()}`;
25 |
26 | // Calculate the epoch timestamp from the TLE data
27 | const epochMs = getEpochTimestamp(tle);
28 |
29 | // Validate epoch before creating Date
30 | if (!epochMs || isNaN(epochMs) || !isFinite(epochMs)) {
31 | const msg = `Invalid epoch timestamp from TLE: ${epochMs}`;
32 | ConsoleLogger.error(msg);
33 | return { success: false, errorMessage: msg };
34 | }
35 |
36 | const epochDate = new Date(epochMs);
37 | if (isNaN(epochDate.getTime())) {
38 | const msg = `Unable to create valid Date from epoch: ${epochMs}`;
39 | ConsoleLogger.error(msg);
40 | return { success: false, errorMessage: msg };
41 | }
42 |
43 | const epochIso = epochDate.toISOString();
44 |
45 | // Insert new TLE into database (upsert will skip if duplicate)
46 | await upsertEphemerisRecords({
47 | records: [
48 | {
49 | epoch: epochIso,
50 | tle_line1: lines[1].trim(),
51 | tle_line2: lines[2].trim(),
52 | },
53 | ],
54 | origin: "celestrak",
55 | });
56 |
57 | return { success: true, epoch: epochIso };
58 | } catch (e) {
59 | const msg = `Error fetching/updating Celestrak ephemeris: ${e}`;
60 | ConsoleLogger.error(msg);
61 | return { success: false, errorMessage: msg };
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/components/interface/share.module.css:
--------------------------------------------------------------------------------
1 | .verticalCenter {
2 | display: flex;
3 | flex-direction: row;
4 | justify-content: space-around;
5 | }
6 |
7 | .top {
8 | display: flex;
9 | justify-content: space-between;
10 | color: var(--even-greyer);
11 | font-size: 15px;
12 | font-weight: 400;
13 | line-height: 15px;
14 | padding: 10px 10px;
15 | border-bottom: 1px solid var(--lightest-grey);
16 | }
17 |
18 | .topLeft {
19 | display: flex;
20 | }
21 |
22 | .topRight {
23 | display: flex;
24 | }
25 |
26 | .topRight > *:not(:last-of-type) {
27 | margin-right: 10px;
28 | }
29 |
30 | .main {
31 | background-color: var(--grey);
32 | width: 100%;
33 | height: 270px;
34 | border-radius: var(--radius);
35 | overflow-y: auto;
36 | font-size: 15px;
37 | line-height: 17px;
38 | cursor: default;
39 | }
40 |
41 | .close {
42 | cursor: pointer;
43 | }
44 |
45 | .title {
46 | display: flex;
47 | justify-content: space-between;
48 | color: var(--even-greyer);
49 | font-weight: 400;
50 | padding: 10px 10px;
51 | padding-bottom: 3px;
52 | }
53 |
54 | .body {
55 | padding: 15px;
56 | padding-bottom: 1px;
57 | font-size: 14.5px;
58 | color: var(--even-greyer);
59 | }
60 |
61 | .body p {
62 | margin-bottom: 5px;
63 | }
64 |
65 | .textarea {
66 | font-family: "Roboto Mono", monospace;
67 | font-size: 10px;
68 | box-sizing: border-box;
69 | border-radius: 10px;
70 | border: 2px solid rgba(255, 255, 255, 0.1);
71 | overflow: hidden;
72 | position: relative;
73 | padding: 5px;
74 | margin: 5px 0 10px 0;
75 | color: #999999;
76 | background-color: #000;
77 | resize: none;
78 | width: 100%;
79 | height: 80px;
80 | outline: none;
81 | overflow-y: auto;
82 | }
83 |
84 | .button {
85 | display: block;
86 | width: 30px;
87 | height: 18px;
88 | background-color: var(--even-greyer);
89 | color: var(--lighter-grey);
90 | border: none;
91 | border-radius: var(--radius);
92 | font-size: 11px;
93 | font-weight: 600;
94 | margin-left: 5px;
95 | cursor: pointer;
96 | }
97 |
98 | .button:hover {
99 | background-color: #eeeeee;
100 | }
101 |
102 | .buttonLabel {
103 | display: inline-block;
104 | position: relative;
105 | }
106 |
--------------------------------------------------------------------------------
/src/components/panes/iss-location-marker.module.css:
--------------------------------------------------------------------------------
1 | .playheadMarker {
2 | background: url('data:image/svg+xml;utf8,')
3 | no-repeat center;
4 | background-size: 30px 30px;
5 | width: 30px;
6 | height: 30px;
7 | pointer-events: none;
8 | }
9 |
10 | .hoverMarker {
11 | background: url('data:image/svg+xml;utf8,')
12 | no-repeat center;
13 | background-size: 30px 30px;
14 | width: 30px;
15 | height: 30px;
16 | pointer-events: none;
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/interface/button.module.css:
--------------------------------------------------------------------------------
1 | .button {
2 | align-items: center;
3 | text-transform: uppercase;
4 | cursor: pointer;
5 | overflow-x: hidden;
6 | }
7 |
8 | .default {
9 | font-size: 16px;
10 | padding: 6px 10px;
11 | }
12 |
13 | .small {
14 | height: 27px;
15 | font-size: 14px;
16 | padding: 0;
17 | }
18 |
19 | .medium {
20 | height: 35px;
21 | font-size: 14px;
22 | padding: 0;
23 | }
24 |
25 | .medium {
26 | height: 27px;
27 | width: 40px;
28 | font-size: 14px;
29 | padding: 0;
30 | }
31 |
32 | .all {
33 | border-radius: var(--radius);
34 | }
35 |
36 | .left {
37 | border-top-left-radius: var(--radius);
38 | border-bottom-left-radius: var(--radius);
39 | }
40 |
41 | .right {
42 | border-top-right-radius: var(--radius);
43 | border-bottom-right-radius: var(--radius);
44 | }
45 |
46 | .top {
47 | border-top-left-radius: var(--radius);
48 | border-top-right-radius: var(--radius);
49 | }
50 |
51 | .bottom {
52 | border-bottom-left-radius: var(--radius);
53 | border-bottom-right-radius: var(--radius);
54 | }
55 | .none {
56 | border-radius: 0;
57 | }
58 |
59 | .active {
60 | background: var(--grey);
61 | border: 1px solid var(--grey);
62 | color: white;
63 | }
64 | .active:hover {
65 | background: var(--lightest-grey);
66 | border: 1px solid var(--lightest-grey);
67 | color: white;
68 | }
69 |
70 | .selected {
71 | background: var(--lightest-grey);
72 | border: 1px solid var(--lightest-grey);
73 | color: white;
74 | }
75 |
76 | .disabled {
77 | background: var(--grey);
78 | border: 1px solid var(--grey);
79 | color: var(--extremely-grey);
80 | }
81 |
82 | .active_selected {
83 | background: var(--lightest-grey);
84 | border: 1px solid var(--even-greyer);
85 | color: white;
86 | }
87 |
88 | .disabled_selected {
89 | background: var(--lightest-grey);
90 | border: 1px solid var(--even-greyer);
91 | color: var(--extremely-grey);
92 | }
93 |
94 | .active_other {
95 | background: var(--lightest-grey);
96 | border: 1px solid var(--lightest-grey);
97 | color: white;
98 | }
99 | .active_other:hover {
100 | background: var(--lightest-grey);
101 | border: 1px solid var(--lightest-grey);
102 | color: white;
103 | }
104 |
--------------------------------------------------------------------------------
/src/public/clocksync/img/EMSS_wordmark.svg:
--------------------------------------------------------------------------------
1 |
18 |
--------------------------------------------------------------------------------
/src/components/panes/graph/graphProperties.ts:
--------------------------------------------------------------------------------
1 | export interface ChartLayout {
2 | autosize: boolean;
3 | height: number;
4 | showlegend: boolean;
5 | plot_bgcolor: string;
6 | paper_bgcolor: string;
7 | margin: {
8 | t: number;
9 | l: number;
10 | r: number;
11 | b: number;
12 | };
13 | xaxis: {
14 | autorange: boolean;
15 | range?: string[];
16 | showgrid: boolean;
17 | zeroline: boolean;
18 | showline: boolean;
19 | autotick: boolean;
20 | linecolor: string;
21 | linewidth: number;
22 | showticklabels: boolean;
23 | nticks: number;
24 | ticks: string;
25 | tickfont: {
26 | size: number;
27 | color: string;
28 | };
29 | tickformat: string;
30 | automargin: boolean;
31 | hoverinfo: string;
32 | type: string;
33 | };
34 | yaxis: {
35 | autorange: boolean;
36 | range?: string[];
37 | linecolor: string;
38 | linewidth: number;
39 | showticklabels: boolean;
40 | ticks: string;
41 | tickfont: {
42 | size: number;
43 | color: string;
44 | };
45 | };
46 | }
47 |
48 | export function getPlotlyChartLayout(height: number) {
49 | const labelcolor = "#999999";
50 | const chartLayout: ChartLayout = {
51 | autosize: true,
52 | height,
53 | showlegend: false,
54 | plot_bgcolor: "#19181b",
55 | paper_bgcolor: "#19181b",
56 | margin: {
57 | t: 10, //top margin
58 | l: 40, //left margin
59 | r: 0, //right margin
60 | b: 60, //bottom margin
61 | },
62 | xaxis: {
63 | autorange: true,
64 | showgrid: true,
65 | zeroline: false,
66 | showline: true,
67 | autotick: true,
68 | linecolor: "#96a5a7",
69 | linewidth: 1,
70 | showticklabels: true,
71 | nticks: 50,
72 | ticks: "inside",
73 | tickfont: {
74 | size: 11,
75 | color: labelcolor,
76 | },
77 | tickformat: "%H:%M:%S",
78 | automargin: true,
79 | hoverinfo: "y",
80 | // hoverformat: '.2r',
81 | type: "date",
82 | },
83 | yaxis: {
84 | autorange: true,
85 | linecolor: labelcolor,
86 | linewidth: 1,
87 | showticklabels: true,
88 | ticks: "inside",
89 | tickfont: {
90 | size: 12,
91 | color: labelcolor,
92 | },
93 | },
94 | };
95 |
96 | return chartLayout;
97 | }
98 |
--------------------------------------------------------------------------------
/src/pages/index.module.css:
--------------------------------------------------------------------------------
1 | .main {
2 | background-image: var(--homepage-background);
3 | background-color: black;
4 | background-position: left top;
5 | background-repeat: no-repeat;
6 | background-size: cover;
7 | display: flex;
8 | flex-direction: column;
9 | justify-content: space-around;
10 | height: 100vh;
11 | }
12 |
13 | .container {
14 | background-color: var(--grey);
15 | border-radius: var(--panelRadius);
16 | margin-left: auto;
17 | margin-right: auto;
18 | display: flex;
19 | justify-content: space-around;
20 | min-height: 250px;
21 | }
22 |
23 | .tourText > p {
24 | font-size: 15px;
25 | line-height: 18px;
26 | }
27 |
28 | .description {
29 | line-height: 1.1em;
30 | text-align: right;
31 | width: 370px;
32 | }
33 |
34 | .description > p {
35 | color: var(--even-greyer);
36 | }
37 |
38 | .description .strong {
39 | line-height: 1.7em;
40 | font-weight: 600;
41 | color: white;
42 | }
43 |
44 | .logo {
45 | height: 55px;
46 | display: flex;
47 | justify-content: right;
48 | }
49 |
50 | .verticalCenter {
51 | display: flex;
52 | flex-direction: column;
53 | justify-content: space-around;
54 | }
55 |
56 | .meatball {
57 | height: 55px;
58 | }
59 |
60 | .wordMark {
61 | font-family: "Aldrich";
62 | font-weight: 400;
63 | font-size: 40px;
64 | }
65 |
66 | .sources {
67 | margin-left: 1em;
68 | width: 220px;
69 | }
70 |
71 | .sourcesPanel {
72 | background-color: var(--light-grey);
73 | border-radius: var(--panelRadius);
74 | padding: 16px 5px 5px 5px;
75 | }
76 |
77 | .sourcesHeader {
78 | color: rgba(255, 255, 255, 0.4);
79 | padding-left: 11px;
80 | padding-bottom: 8px;
81 | border-bottom: 1px solid rgba(255, 255, 255, 0.2);
82 | }
83 |
84 | .ul {
85 | margin: 0;
86 | margin-top: 5px;
87 | padding: 0;
88 | }
89 |
90 | .li {
91 | text-decoration: none;
92 | list-style: none;
93 | line-height: 40px;
94 | margin-left: 0;
95 | padding-left: 11px;
96 | cursor: pointer;
97 | }
98 |
99 | .li:hover {
100 | background-color: var(--grey);
101 | border-radius: var(--panelRadius);
102 | }
103 |
104 | .li > * {
105 | display: inline-block;
106 | text-decoration: none;
107 | width: 100%;
108 | }
109 |
110 | .disabled {
111 | color: var(--extremely-grey);
112 | cursor: pointer;
113 | }
114 |
--------------------------------------------------------------------------------
/src/components/panes/graph/plotly.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent, MutableRefObject, useEffect, useRef } from "react";
2 |
3 | import PlotlyClass from "components/panes/graph/plotly-class";
4 | import { appSecondsFromDateString } from "utils/formatting";
5 | import { ChartLayout } from "./graphProperties";
6 | import { Layout } from "plotly.js-basic-dist";
7 | import { useAppDispatch } from "utils/useAppDispatch";
8 | import { setAppSeconds } from "store/clock";
9 |
10 | //disgusting hack to make IDE errors go away in the useEffect below
11 | type HTMLDivElementExtended = HTMLDivElement & { on: Function };
12 |
13 | const PlotlyComponent: FunctionComponent<{
14 | frameID: number;
15 | chartData: {
16 | plotlyChartTraces: PlotlyChartTrace[];
17 | plotlyChartLayout: ChartLayout;
18 | };
19 | plotIndexToHighlight: number;
20 | }> = ({ frameID, chartData, plotIndexToHighlight }) => {
21 | const dispatch = useAppDispatch();
22 |
23 | const plotlyClass: MutableRefObject = useRef(null);
24 | const plotlyChartRef: MutableRefObject = useRef(null);
25 |
26 | useEffect(() => {
27 | plotlyClass.current = new PlotlyClass();
28 | }, []);
29 |
30 | useEffect(() => {
31 | if (!plotlyChartRef.current) return;
32 |
33 | plotlyClass.current.drawChart(
34 | `plotlyChart${frameID}`,
35 | chartData.plotlyChartTraces,
36 | chartData.plotlyChartLayout as Partial
37 | );
38 |
39 | //now that drawChart has been called, the "on" method is now attached to the plotlyChart div
40 | plotlyChartRef.current.on(
41 | "plotly_click",
42 | (data: { points: { x: { replace: (arg0: string) => string } }[] }) => {
43 | // ignore errors caused by graph data being unavailable for a given point
44 | try {
45 | const dateStr = data.points[0].x.replace(" " + "T") + "Z";
46 | dispatch(setAppSeconds(appSecondsFromDateString(dateStr)));
47 | } catch {
48 | //do nothing
49 | }
50 | }
51 | );
52 | }, [plotlyChartRef, chartData]);
53 |
54 | useEffect(() => {
55 | plotlyClass.current.hoverPoint(plotlyChartRef, plotIndexToHighlight);
56 | }, [plotlyClass, plotIndexToHighlight]);
57 |
58 | return (
59 |
62 | );
63 | };
64 |
65 | export default PlotlyComponent;
66 |
--------------------------------------------------------------------------------
/src/store/thunk/clockThunk.ts:
--------------------------------------------------------------------------------
1 | import appCreateAsyncThunk from "./thunkUtil";
2 | import { clearVideos } from "../videos";
3 | import { clearPhotos } from "../photos";
4 | import { clearEphemera } from "../ephemera";
5 | import { clearDayNight } from "../daynight";
6 | import { clearGPSTracks } from "../gps";
7 | import { clearTalkybotAudioFiles } from "../talkybot";
8 | import { clearGraphsManifest, clearGraphsData } from "../graphs";
9 | import { setDate, setAppSeconds, stopClock, startClock } from "../clock";
10 |
11 | /**
12 | * Thunk action to change the viewing date.
13 | * Clears all date-specific data from stores and sets the new date.
14 | * The SocketClient will automatically reconnect and fetch new data when playheadDate changes.
15 | *
16 | * @param newDate - The new date as an ISO string or YYYY-MM-DD format
17 | * @param newAppSeconds - Optional starting time in seconds (defaults to 0 for start of day)
18 | */
19 | export const thunkChangeViewingDate = appCreateAsyncThunk<
20 | { newDate: string; newAppSeconds?: number },
21 | void,
22 | null
23 | >("thunkChangeViewingDate", async ({ newDate, newAppSeconds = 0 }, { dispatch }) => {
24 | // Clear all date-specific data stores
25 | dispatch(clearVideos());
26 | dispatch(clearPhotos());
27 | dispatch(clearEphemera());
28 | dispatch(clearDayNight());
29 | dispatch(clearGPSTracks());
30 | dispatch(clearTalkybotAudioFiles());
31 | dispatch(clearGraphsManifest());
32 | dispatch(clearGraphsData());
33 |
34 | // Update clock state with new date and reset time tracking
35 | // This ensures clean state after potentially long-running sessions
36 | dispatch(setDate(newDate));
37 | dispatch(setAppSeconds(newAppSeconds));
38 | // Stop the clock briefly to reset the timestamp, then restart if it was running
39 | // This prevents elapsed time calculation issues after days of running
40 | dispatch(thunkResetClockTimestamp());
41 | });
42 |
43 | /**
44 | * Reset the clock timestamp to now without changing isRunning state.
45 | * Used during date rollover to prevent elapsed time calculation drift.
46 | */
47 | export const thunkResetClockTimestamp = appCreateAsyncThunk(
48 | "thunkResetClockTimestamp",
49 | async (_, { dispatch, getState }) => {
50 | const { isRunning } = getState().clock;
51 | if (isRunning) {
52 | // Briefly stop and restart to reset the timestamp cleanly
53 | dispatch(stopClock());
54 | dispatch(startClock());
55 | }
56 | }
57 | );
58 |
--------------------------------------------------------------------------------
/src/public/favicon/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
41 |
--------------------------------------------------------------------------------
/src/server/processing/ancillaryDataSources.ts:
--------------------------------------------------------------------------------
1 | import { Loaded } from "@mikro-orm/postgresql";
2 | import { AncillaryDataSource_db } from "server/database/models/_allModels";
3 | import { globalValues } from "server/express/global";
4 |
5 | export async function getAncillaryDataSourcesByDate(date: string): Promise {
6 | const em = globalValues.orm.em;
7 |
8 | const ancillaryDataSource_db: Loaded[] = await em.find(
9 | AncillaryDataSource_db,
10 | { date },
11 | { orderBy: { source: "ASC" } }
12 | );
13 |
14 | if (!ancillaryDataSource_db) {
15 | return [];
16 | }
17 |
18 | return ancillaryDataSource_db.map((record) => record);
19 | }
20 |
21 | export async function getAncillaryDataSourceList(): Promise {
22 | const em = globalValues.orm.em;
23 |
24 | const ancillaryDataSource_db = await em.find(
25 | AncillaryDataSource_db,
26 | {},
27 | { orderBy: { date: "ASC", source: "ASC" }, fields: ["id", "date", "source", "type", "url"] }
28 | );
29 |
30 | return ancillaryDataSource_db ?? [];
31 | }
32 |
33 | export async function getAncillaryDataSourceById(id: number): Promise {
34 | const em = globalValues.orm.em;
35 | return em.findOne(AncillaryDataSource_db, { id });
36 | }
37 |
38 | export async function upsertAncillaryDataSource({
39 | id,
40 | date,
41 | source,
42 | type,
43 | url,
44 | }: AncillaryDataUpsertRequest): Promise<{ record: AncillaryDataSource; isNew: boolean } | null> {
45 | const em = globalValues.orm.em;
46 |
47 | if (id) {
48 | const existing = await em.findOne(AncillaryDataSource_db, { id: Number(id) });
49 | if (!existing) {
50 | return null;
51 | }
52 |
53 | existing.date = date;
54 | existing.source = source;
55 | existing.type = type;
56 | existing.url = url;
57 | await em.persistAndFlush(existing);
58 | return { record: existing, isNew: false };
59 | }
60 |
61 | const created = em.create(AncillaryDataSource_db, {
62 | date,
63 | source,
64 | type,
65 | url,
66 | });
67 | await em.persistAndFlush(created);
68 | return { record: created, isNew: true };
69 | }
70 |
71 | export async function deleteAncillaryDataSourceById(id: number): Promise {
72 | const em = globalValues.orm.em;
73 | const existing = await em.findOne(AncillaryDataSource_db, { id });
74 | if (!existing) {
75 | return false;
76 | }
77 |
78 | await em.removeAndFlush(existing);
79 | return true;
80 | }
81 |
--------------------------------------------------------------------------------
/src/server/express/restApi.ts:
--------------------------------------------------------------------------------
1 | import express, { Application } from "express";
2 | import cors from "cors";
3 | import { RequestContext } from "@mikro-orm/postgresql";
4 | import dayNightRoute from "./routes/daynight/daynight";
5 | import dataRefreshRoute from "./routes/emss/dataRefresh";
6 | import dataViewRoute from "./routes/emss/dataView";
7 | import gpsRoute from "./routes/db/gps";
8 | import ephemerisRoute from "./routes/db/ephemeris";
9 | import mediaOverridesRoute from "./routes/db/mediaOverrides";
10 | import ancillaryDataRoute from "./routes/db/ancillaryDataSources";
11 | import getCurrentUser from "./routes/user/auth";
12 | import logFromClient from "./routes/user/logFromClient";
13 | import profiler from "./routes/profiler/profiler";
14 | import videoRoute from "./routes/db/video";
15 | import photoRoute from "./routes/db/photos";
16 | import { globalValues } from "./global";
17 | import timeRoute from "./routes/time/time";
18 |
19 | const app: Application = express();
20 |
21 | app.use(express.json({ limit: "20mb" }));
22 | app.use(cors());
23 | app.use(express.urlencoded({ extended: true }));
24 |
25 | // Mikro-ORM RequestContext should be last middleware before routes
26 | // https://mikro-orm.io/docs/identity-map#request-context
27 | // use Mikro-ORM RequestContext for express and socketio handlers
28 | app.use((_req, _res, next) => {
29 | RequestContext.create(globalValues.orm.em, next);
30 | });
31 |
32 | // Serve a successful response. For use with wait-on
33 | app.get("/api/v1/health", (req, res) => {
34 | res.send({ status: "ok" });
35 | });
36 |
37 | app.get("/api/v1/version", (req, res) => {
38 | res.send(globalValues.appVersion);
39 | });
40 |
41 | app.use("/api/v1/external/daynight/daynight", dayNightRoute); // external endpoint for maestro
42 | app.use("/api/v1/emss/dataRefresh", dataRefreshRoute); // routed through launchpad
43 | app.use("/api/v1/emss/dataView", dataViewRoute); // routed through launchpad
44 | app.use("/api/v1/db/gps", gpsRoute);
45 | app.use("/api/v1/db/ephemeris", ephemerisRoute);
46 | app.use("/api/v1/db/mediaOverrides", mediaOverridesRoute);
47 | app.use("/api/v1/db/ancillaryDataSources", ancillaryDataRoute);
48 | app.use("/api/v1/db/videoStartTimeOverrides", videoRoute);
49 | app.use("/api/v1/db/photoTimeShifts", photoRoute);
50 | app.use("/api/v1/user/current", getCurrentUser); // routed through launchpad
51 | app.use("/api/v1/log/from-client", logFromClient);
52 | app.use("/api/v1/profile", profiler);
53 | app.use("/api/v1/time", timeRoute); // simple route to get server time
54 | export default app;
55 |
--------------------------------------------------------------------------------
/src/components/panes/photo-all.module.css:
--------------------------------------------------------------------------------
1 | .main {
2 | position: relative;
3 | height: 0;
4 | width: 100%;
5 | min-height: 100%;
6 | background-color: var(--nearly-black);
7 | }
8 |
9 | .controls {
10 | display: flex;
11 | height: 100%;
12 | justify-content: space-between;
13 | margin-left: auto;
14 | }
15 |
16 | .controlsLeft {
17 | display: flex;
18 | }
19 |
20 | .verticalCenter {
21 | display: flex;
22 | flex-direction: column;
23 | justify-content: space-around;
24 | }
25 |
26 | .rightButtons {
27 | display: flex;
28 | }
29 |
30 | .rightButtons > *:not(:last-of-type) {
31 | margin-right: 5px;
32 | }
33 |
34 | .photoThumbs {
35 | display: flex;
36 | align-content: flex-start;
37 | justify-content: space-around;
38 | flex-wrap: wrap;
39 | height: 100%;
40 | width: 100%;
41 | overflow: auto;
42 | }
43 |
44 | .photoThumb {
45 | flex-grow: 1;
46 | flex-basis: 80px;
47 | max-width: 80px;
48 | /* 78px + 2px margin = 80px height */
49 | height: 78px;
50 | margin-bottom: 2px;
51 | border: 1px solid var(--lighter-grey);
52 | border-radius: var(--radius);
53 | }
54 |
55 | .photoThumb img {
56 | height: 100%;
57 | width: 100%;
58 | object-fit: contain;
59 | }
60 |
61 | .activePhoto {
62 | border: 1px solid #28b463;
63 | }
64 |
65 | /* Lock button */
66 |
67 | .lockButton {
68 | display: block;
69 | height: 18px;
70 | background-color: var(--even-greyer);
71 | color: var(--lighter-grey);
72 | border: none;
73 | border-radius: var(--radius);
74 | font-size: 11px;
75 | font-weight: 600;
76 | cursor: pointer;
77 | }
78 |
79 | .lockButtonSelected {
80 | border: none;
81 | background-color: #eeeeee;
82 | color: var(--lighter-grey);
83 | }
84 |
85 | .photoPoster {
86 | width: 100%;
87 | height: 100%;
88 | position: absolute;
89 | top: 0;
90 | left: 0;
91 | }
92 |
93 | .photoPoster::before {
94 | width: 100%;
95 | height: 100%;
96 | position: absolute;
97 | top: 0;
98 | left: 0;
99 | content: "";
100 | background: center / contain no-repeat url("/images/patch_fod_1400_8bit.png");
101 | background-size: 50%;
102 | opacity: 0.4;
103 | }
104 |
105 | .photoPosterFilter {
106 | width: 100%;
107 | height: 100%;
108 | position: absolute;
109 | }
110 |
111 | .buttonLong {
112 | width: 55px;
113 | padding: 0 6px;
114 | }
115 |
116 | .buttonShort {
117 | width: 20px;
118 | padding: 0;
119 | }
120 |
121 | .buttonLabel {
122 | display: flex;
123 | justify-content: space-between;
124 | }
125 |
--------------------------------------------------------------------------------
/src/utils/fetch-with-timeout.spec.ts:
--------------------------------------------------------------------------------
1 | import fetchWithTimeout from "utils/fetch-with-timeout";
2 | import { fetch, RequestInit, RequestInfo, Response, Agent } from "undici";
3 |
4 | // Turn the undici fetch call into jest mocked call
5 | jest.mock("undici", () => ({
6 | fetch: jest.fn(),
7 | Agent: jest.requireActual("undici").Agent,
8 | Response: jest.requireActual("undici").Response,
9 | }));
10 |
11 | (fetch as jest.MockedFunction).mockImplementation(
12 | async (url: RequestInfo, init?: RequestInit) => {
13 | const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
14 | try {
15 | await wait(50); // 50 ms timeout for this test
16 | if (init?.signal?.aborted) {
17 | throw new Error("The operation was aborted.");
18 | }
19 | return new Response(JSON.stringify({ testData: 123 }), { status: 200 });
20 | } finally {
21 | }
22 | }
23 | );
24 |
25 | describe("fetchWithTimeout", () => {
26 | beforeEach(() => {
27 | jest.clearAllMocks();
28 | });
29 |
30 | it("fetch completes and all options passed in correctly", async () => {
31 | const response = await fetchWithTimeout("url", {}, 100);
32 |
33 | // Check mock response
34 | const json = await response.json();
35 | expect(json).toEqual({ testData: 123 });
36 |
37 | // Check first argument URL
38 | expect((fetch as jest.MockedFunction).mock.calls[0][0]).toEqual("url");
39 |
40 | // Check second argument options
41 | const reqInit = (fetch as jest.MockedFunction).mock.calls[0][1] as RequestInit;
42 |
43 | // Mocking the behavior for rejectUnauthorized
44 | const agent = reqInit.dispatcher as Agent;
45 | const rejectUnauthorized = process.env.NODE_ENV === "production";
46 |
47 | // Check if the agent was created with the correct rejectUnauthorized setting
48 | expect(agent).toBeInstanceOf(Agent);
49 | // Note: We're assuming that the agent's behavior was correctly configured in the mock.
50 | expect(rejectUnauthorized).toBe(process.env.NODE_ENV === "production");
51 |
52 | // Check AbortSignal
53 | expect(reqInit.signal).toBeInstanceOf(global.AbortSignal);
54 | });
55 |
56 | it("fetch times out", async () => {
57 | const response = await fetchWithTimeout("url", {}, 10);
58 | expect(response.ok).toBe(false);
59 | expect(response.status).toBe(408); // Request Timeout status code
60 | });
61 |
62 | // Put the fetch call and spy calls back to original
63 | afterAll(() => {
64 | jest.unmock("undici");
65 | jest.restoreAllMocks();
66 | });
67 | });
68 |
--------------------------------------------------------------------------------
/src/public/mapbox_custom.css:
--------------------------------------------------------------------------------
1 | .mapboxgl-ctrl-logo {
2 | display: none !important;
3 | }
4 |
5 | .marker {
6 | background: url('data:image/svg+xml;utf8,')
7 | no-repeat center;
8 | background-size: 40px 40px;
9 | width: 50px;
10 | height: 50px;
11 | }
12 |
13 | .mapboxgl-ctrl-group {
14 | background-color: #000 !important;
15 | }
16 |
17 | .mapboxgl-ctrl-group button {
18 | border: 1px solid #2b2a2d !important;
19 | border-radius: var(--radius) !important;
20 | background-color: var(--lightest-grey) !important;
21 | }
22 |
23 | .mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon {
24 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='white'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E") !important;
25 | }
26 | .mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon {
27 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='white'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E") !important;
28 | }
29 |
30 | .mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon {
31 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='white'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath d='M10.5 16l4 8 4-8h-8z' fill='white'/%3E%3C/svg%3E") !important;
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/panes/gps-location.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | position: relative;
3 | width: 100%;
4 | height: 100%;
5 | background-color: var(--nearly-black);
6 | overflow: hidden;
7 | }
8 |
9 | .mapContainer {
10 | height: 100%;
11 | }
12 |
13 | .info {
14 | position: absolute;
15 | top: 30px;
16 | left: 5px;
17 |
18 | display: flex;
19 |
20 | margin-top: 2px;
21 | margin-left: 5px;
22 | padding: 2px;
23 |
24 | font-size: 0.9rem;
25 | color: #222;
26 | background-color: #fff;
27 | border: 1px solid black;
28 | border-radius: var(--radius);
29 | z-index: 10;
30 | }
31 |
32 | .info_narrower {
33 | width: 180px;
34 | }
35 |
36 | .infoSection {
37 | flex-direction: column;
38 | }
39 |
40 | .infoSectionTitle {
41 | display: flex;
42 | align-items: center;
43 | }
44 |
45 | .infoSectionTitleIcon {
46 | margin-top: 5px;
47 | }
48 |
49 | .valueTable {
50 | width: 100%;
51 | font-size: 0.8em;
52 | border-collapse: collapse;
53 | }
54 |
55 | .valueTable tr td {
56 | padding-left: 10px;
57 | padding-right: 10px;
58 | border-left: 1px none;
59 | }
60 |
61 | .valueTable tr td:first-child {
62 | text-align: right;
63 | font-weight: 600;
64 | padding-left: 0;
65 | padding-right: 0;
66 | }
67 |
68 | /* second child no border */
69 | .valueTable tr td:nth-child(2) {
70 | border-left: none;
71 | }
72 |
73 | /* Controls */
74 |
75 | .controls {
76 | display: flex;
77 | height: 100%;
78 | justify-content: space-between;
79 | margin-left: auto;
80 | }
81 |
82 | .controlsLeft {
83 | display: flex;
84 | }
85 |
86 | .verticalCenter {
87 | display: flex;
88 | flex-direction: column;
89 | justify-content: space-around;
90 | }
91 |
92 | .rightButtons {
93 | display: flex;
94 | }
95 |
96 | .rightButtons > *:not(:last-of-type) {
97 | margin-right: 5px;
98 | }
99 |
100 | /* Lock Map button */
101 |
102 | .lockButton {
103 | display: block;
104 | height: 18px;
105 | background-color: var(--even-greyer);
106 | color: var(--lighter-grey);
107 | border: none;
108 | border-radius: var(--radius);
109 | font-size: 11px;
110 | font-weight: 600;
111 | cursor: pointer;
112 | }
113 |
114 | .lockButtonSelected {
115 | border: none;
116 | background-color: #eeeeee;
117 | color: var(--lighter-grey);
118 | }
119 |
120 | .buttonLong {
121 | width: 55px;
122 | padding: 0 6px;
123 | }
124 |
125 | .buttonShort {
126 | width: 20px;
127 | padding: 0;
128 | }
129 |
130 | .buttonLabel {
131 | display: flex;
132 | justify-content: space-between;
133 | }
134 |
--------------------------------------------------------------------------------
/src/utils/map.ts:
--------------------------------------------------------------------------------
1 | import { getAppropriateTLE } from "store/ephemera";
2 | import { getLatLngObj } from "tle.js";
3 |
4 | export const getNextPosition = (
5 | dateTime: string,
6 | increment: number,
7 | ephemerisEntries: EphemerisEntry[]
8 | ): { lat: number; lng: number } => {
9 | // Find the closest ephemera item
10 | const tle = getAppropriateTLE(ephemerisEntries, dateTime);
11 |
12 | // Calculate the time offset in milliseconds
13 | const baseTime = new Date(dateTime).getTime();
14 | const offsetTime = baseTime + increment * 1000; // assuming increment is in seconds
15 |
16 | // Get latitude and longitude using tle.js utility
17 | const { lat, lng } = getLatLngObj(tle, offsetTime);
18 |
19 | return { lat, lng };
20 | };
21 |
22 | export const updateOrbitLine = (
23 | dateTime: string,
24 | timeStr: string,
25 | ephemeraItems: EphemerisEntry[]
26 | ): { coordinates1: [number, number][]; coordinates2: [number, number][] } => {
27 | const secondsStart = -2000;
28 | const secondsEnd = 3800;
29 | const secondsStep = 10;
30 |
31 | const coordinates1: [number, number][] = [];
32 | const coordinates2: [number, number][] = [];
33 | let prevIncrement = -1;
34 | let prevLng = -1;
35 |
36 | let dateLineHit = false;
37 | let dateLineIncNum = 0;
38 | for (let i = secondsStart; i < secondsEnd; i += secondsStep) {
39 | // Combine date and time strings to create a full ISO date-time
40 | const fullDateTime = `${dateTime}T${timeStr}Z`;
41 | const nextPosition = getNextPosition(fullDateTime, i, ephemeraItems);
42 |
43 | let lngIncrement;
44 | let lngStepSize;
45 | if (prevLng !== -1) {
46 | lngIncrement = Math.abs(nextPosition.lng - prevLng);
47 | lngStepSize = Math.abs(lngIncrement - prevIncrement);
48 | }
49 |
50 | // if crossing date line, start drawing the second line
51 | // (this avoids a segment that wraps around the earth)
52 | if (prevIncrement !== -1 && lngStepSize > 100) {
53 | dateLineHit = true;
54 | dateLineIncNum = i;
55 | break;
56 | }
57 | coordinates1.push([nextPosition.lng, nextPosition.lat]);
58 | prevLng = nextPosition.lng;
59 | prevIncrement = lngIncrement;
60 | }
61 |
62 | // draw second line that continues across the date line if path crosses date line
63 | if (dateLineHit) {
64 | for (let i = dateLineIncNum; i < secondsEnd; i += secondsStep) {
65 | const fullDateTime = `${dateTime}T${timeStr}Z`;
66 | const nextPosition = getNextPosition(fullDateTime, i, ephemeraItems);
67 | coordinates2.push([nextPosition.lng, nextPosition.lat]);
68 | }
69 | }
70 |
71 | return { coordinates1, coordinates2 };
72 | };
73 |
--------------------------------------------------------------------------------
/src/components/panes/video/video-poster.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent } from "react";
2 | import { deepEqual, useAppSelector } from "utils/useAppSelector";
3 | import { useAppDispatch } from "utils/useAppDispatch";
4 | import { setPaneStateDataValue } from "store/framework";
5 | import HelpOverlay from "components/interface/pane-help-overlay";
6 | import styles from "./video-poster.module.css";
7 | import { VideoGeneralHelpContent } from "./video-help";
8 |
9 | /**
10 | * Determines the poster state to display based on video status.
11 | * - "novid": No video available
12 | * - "buffering": Video is loading (show spinner)
13 | * - "none": Video is playing or ready
14 | */
15 | export const getPosterState = (
16 | metadata: VideoMetadata | null,
17 | status: VideoStatus
18 | ): PosterState => {
19 | if (metadata || status === "playing") return "none";
20 | if (status === "buffering" && !metadata) return "buffering";
21 | if (status === "buffering" && metadata) return "none";
22 | return "novid";
23 | };
24 |
25 | /**
26 | * Renders video poster overlays for loading/no-video states.
27 | */
28 | export const VideoPoster: FunctionComponent<{ state: PosterState }> = ({ state }) => {
29 | if (state === "none") return null;
30 |
31 | return (
32 | <>
33 |
34 | {state === "buffering" && (
35 |
38 | )}
39 | >
40 | );
41 | };
42 |
43 | /**
44 | * Standalone poster pane for when no video source is available.
45 | * Used by VideoPaneChooser when there's no IO, HLS, or MTX video.
46 | */
47 | export const VideoPosterPane: FunctionComponent<{ frameID: number }> = ({ frameID }) => {
48 | const dispatch = useAppDispatch();
49 | const paneStateData = useAppSelector(
50 | (state) => state.framework.frames[frameID].paneStateData as VideoPaneStateData,
51 | deepEqual
52 | );
53 |
54 | const handleHelpClose = () => {
55 | dispatch(
56 | setPaneStateDataValue({
57 | frameID,
58 | paneStateProperty: "showHelp",
59 | paneStateValue: !paneStateData.showHelp,
60 | })
61 | );
62 | };
63 |
64 | return (
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | );
75 | };
76 |
--------------------------------------------------------------------------------
/src/components/interface/dropdown-modal.module.css:
--------------------------------------------------------------------------------
1 | .main {
2 | border-radius: var(--radius);
3 | border: none;
4 | cursor: pointer;
5 | user-select: none;
6 | width: 100%;
7 | padding: 0;
8 | margin: 0;
9 | background-color: #000;
10 | }
11 |
12 | .main:active {
13 | border: none;
14 | }
15 |
16 | .select {
17 | appearance: none;
18 | font-family: inherit;
19 | font-size: inherit;
20 | border-width: 0;
21 | /* background-color: var(--grey); */
22 | }
23 |
24 | .verticalCenter {
25 | display: flex;
26 | flex-direction: column;
27 | justify-content: space-around;
28 | padding-left: 3px;
29 | }
30 |
31 | .white {
32 | background-color: white;
33 | color: var(--dark-grey);
34 | }
35 |
36 | .grey {
37 | background-color: var(--lightest-grey);
38 | color: white;
39 | }
40 |
41 | .default {
42 | height: 40px;
43 | font-size: 18px;
44 | }
45 |
46 | .skinny {
47 | height: 27px;
48 | font-size: 14px;
49 | }
50 |
51 | .medium {
52 | height: 29px;
53 | font-size: 19px;
54 | }
55 |
56 | .label {
57 | display: flex;
58 | border-radius: var(--radius);
59 | justify-content: space-between;
60 | text-transform: uppercase;
61 | }
62 |
63 | .caret {
64 | display: flex;
65 | padding-right: 6px;
66 | align-items: center;
67 | }
68 |
69 | .none {
70 | display: none;
71 | }
72 |
73 | .modal {
74 | position: absolute;
75 | top: 1px;
76 | z-index: 40; /* prevents dropdown carets from showing up on top of the modal */
77 | }
78 |
79 | @keyframes rotate-up {
80 | 0% {
81 | transform: rotate(0);
82 | }
83 | 100% {
84 | transform: rotateX(180deg);
85 | }
86 | }
87 |
88 | @keyframes rotate-down {
89 | 0% {
90 | transform: rotateX(180deg);
91 | }
92 | 100% {
93 | transform: rotate(0);
94 | }
95 | }
96 |
97 | @keyframes rotate-left {
98 | 0% {
99 | transform: rotateY(0deg);
100 | }
101 | 100% {
102 | transform: rotateY(180deg);
103 | }
104 | }
105 |
106 | @keyframes rotate-right {
107 | 0% {
108 | transform: rotateY(180deg);
109 | }
110 | 100% {
111 | transform: rotateY(0deg);
112 | }
113 | }
114 |
115 | .right {
116 | animation-name: rotate-right;
117 | animation-duration: 0.2s;
118 | transform: rotateY(0deg);
119 | }
120 |
121 | .left {
122 | animation-name: rotate-left;
123 | animation-duration: 0.2s;
124 | transform: rotateY(180deg);
125 | }
126 |
127 | .up {
128 | animation-name: rotate-up;
129 | animation-duration: 0.2s;
130 | transform: rotateX(180deg);
131 | }
132 |
133 | .down {
134 | animation-name: rotate-down;
135 | animation-duration: 0.2s;
136 | transform: rotate(0);
137 | }
138 |
--------------------------------------------------------------------------------
/src/components/framework/ClockInterval.tsx:
--------------------------------------------------------------------------------
1 | import { FunctionComponent, useEffect, useRef } from "react";
2 | import { refEqual, useAppSelector } from "utils/useAppSelector";
3 |
4 | /**
5 | * This component is responsible for updating the parent's appSeconds based on the clock's state.
6 | * Include this component in any React component that needs real-time clock updates.
7 | *
8 | * @param setAppSeconds - Callback function to update the local appSeconds state
9 | *
10 | * @example
11 | * ```tsx
12 | * const [appSeconds, setAppSeconds] = useState(0);
13 | * return (
14 | * <>
15 | *
16 | * Current time: {appSeconds}
17 | * >
18 | * );
19 | * ```
20 | */
21 | const ClockInterval: FunctionComponent<{
22 | setAppSeconds: (seconds: number) => void;
23 | }> = ({ setAppSeconds }) => {
24 | const appSecondsAtStartStop = useAppSelector(
25 | (state) => state.clock.appSecondsAtStartStop,
26 | refEqual
27 | );
28 | const isRunning = useAppSelector((state) => state.clock.isRunning, refEqual);
29 | const startStopTimestamp = useAppSelector((state) => state.clock.startStopTimestamp, refEqual);
30 |
31 | const intervalRef = useRef | null>(null);
32 |
33 | useEffect(() => {
34 | if (isRunning) {
35 | if (!intervalRef.current) {
36 | intervalRef.current = setInterval(() => {
37 | const secondsSinceStarted = (Date.now() - Date.parse(startStopTimestamp)) / 1000;
38 | const newAppSeconds = Math.floor(appSecondsAtStartStop + secondsSinceStarted);
39 | // Cap at 86401 to prevent race conditions while allowing day rollover at 86400
40 | setAppSeconds(Math.min(newAppSeconds, 86401));
41 | }, 100);
42 | }
43 | } else {
44 | // When stopped, calculate final position
45 | if (startStopTimestamp) {
46 | const secondsSinceStarted = (Date.now() - Date.parse(startStopTimestamp)) / 1000;
47 | const newAppSeconds = Math.floor(appSecondsAtStartStop + secondsSinceStarted);
48 | // Cap at 86401 to prevent race conditions while allowing day rollover at 86400
49 | setAppSeconds(Math.min(newAppSeconds, 86401));
50 | } else {
51 | // No timestamp yet, just use the stored value
52 | setAppSeconds(appSecondsAtStartStop);
53 | }
54 |
55 | clearInterval(intervalRef.current);
56 | intervalRef.current = null;
57 | return;
58 | }
59 |
60 | return () => {
61 | clearInterval(intervalRef.current);
62 | intervalRef.current = null;
63 | };
64 | }, [appSecondsAtStartStop, isRunning, startStopTimestamp, setAppSeconds]);
65 |
66 | return <>>;
67 | };
68 |
69 | export default ClockInterval;
70 |
--------------------------------------------------------------------------------
/src/store/index.ts:
--------------------------------------------------------------------------------
1 | import { combineReducers, configureStore, isRejected } from "@reduxjs/toolkit";
2 | import { sequencesSlice, initialState as sequencesInitialState } from "./sequences";
3 | import { videoSlice, initialState as videosInitialState } from "./videos";
4 | import { frameworkSlice, initialState as viewerInitialState } from "./framework";
5 | import { photoSlice, initialState as photosInitialState } from "./photos";
6 | import { ephemeraSlice, initialState as ephemeraInitialState } from "./ephemera";
7 | import { dayNightSlice, initialState as dayNightInitialState } from "./daynight";
8 | import { gpsSlice, initialState as gpsInitialState } from "./gps";
9 | import { talkybotSlice, initialState as talkybotInitialState } from "./talkybot";
10 | import { graphSlice, initialState as graphInitialState } from "./graphs";
11 | import { userSlice, initialState as userInitialState } from "./user";
12 | import { clockSlice, initialState as clockInitialState } from "./clock";
13 | import type { Middleware } from "@reduxjs/toolkit";
14 |
15 | export const initialState = {
16 | sequences: sequencesInitialState,
17 | videos: videosInitialState,
18 | photos: photosInitialState,
19 | ephemera: ephemeraInitialState,
20 | dayNight: dayNightInitialState,
21 | gps: gpsInitialState,
22 | framework: viewerInitialState,
23 | talkybot: talkybotInitialState,
24 | graphs: graphInitialState,
25 | user: userInitialState,
26 | clock: clockInitialState,
27 | };
28 |
29 | const sliceReducers = combineReducers({
30 | sequences: sequencesSlice.reducer,
31 | videos: videoSlice.reducer,
32 | photos: photoSlice.reducer,
33 | ephemera: ephemeraSlice.reducer,
34 | dayNight: dayNightSlice.reducer,
35 | gps: gpsSlice.reducer,
36 | framework: frameworkSlice.reducer,
37 | talkybot: talkybotSlice.reducer,
38 | graphs: graphSlice.reducer,
39 | user: userSlice.reducer,
40 | clock: clockSlice.reducer,
41 | });
42 | export type RootState = ReturnType;
43 |
44 | // Add middleware to log rejected thunks to the browser console
45 | const rejectedActionLogger: Middleware<{}, RootState> = () => (next) => (action) => {
46 | if (isRejected(action)) {
47 | console.error("Rejected async thunk. Action = ", { action });
48 | }
49 | return next(action);
50 | };
51 |
52 | export const store: StoreType = configureStore({
53 | reducer: sliceReducers,
54 | preloadedState: initialState,
55 | middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(rejectedActionLogger),
56 | devTools: {
57 | name: `CODA Tab-${Math.random()}`, // Include git branch name
58 | },
59 | });
60 | export type StoreType = ReturnType>;
61 | export type AppDispatch = typeof store.dispatch;
62 |
63 | export default store;
64 |
--------------------------------------------------------------------------------
/src/store/talkybot.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 | import { appSecondsFromDateString } from "utils/formatting";
3 |
4 | /** Ensure every audio file carries its derived appSeconds value */
5 | function withAppSeconds(file: TbAudioFile): TbAudioFile {
6 | if (typeof file.appSeconds === "number") {
7 | return file;
8 | }
9 |
10 | const startDate = new Date(file.startTime);
11 | if (Number.isNaN(startDate.valueOf())) {
12 | return file;
13 | }
14 |
15 | return {
16 | ...file,
17 | appSeconds: appSecondsFromDateString(startDate.toISOString()),
18 | };
19 | }
20 |
21 | export const initialState: TalkybotState = {
22 | audioFiles: [],
23 | metadata: null,
24 | };
25 |
26 | export const talkybotSlice = createSlice({
27 | name: "talkybot",
28 | initialState,
29 | reducers: {
30 | /** Set talkybot audio files in the store */
31 | setTalkybotAudioFiles: (state, action: { payload: FetchResponse }) => {
32 | const incomingAudioFiles = action.payload.data?.audioFiles ?? [];
33 | state.audioFiles = incomingAudioFiles.map(withAppSeconds);
34 | state.metadata = action.payload.fetchMetadata;
35 | },
36 | clearTalkybotAudioFiles: (state) => {
37 | state.audioFiles = [];
38 | state.metadata = null;
39 | },
40 | /** Add or update a single audio file (upsert from talkybotS2sSocket updates) */
41 | upsertTalkybotAudioFile: (state, action: { payload: TbAudioFile }) => {
42 | const newFile = withAppSeconds(action.payload);
43 | // Check if audioFile record already exists (by fileUuid)
44 | const existingIndex = state.audioFiles.findIndex(
45 | (audioFile) => audioFile.fileUuid === newFile.fileUuid
46 | );
47 | if (existingIndex === -1) {
48 | // Insert in sorted order by startTime
49 | const insertIndex = state.audioFiles.findIndex(
50 | (audioFile) => new Date(audioFile.startTime) > new Date(newFile.startTime)
51 | );
52 | if (insertIndex === -1) {
53 | state.audioFiles.push(newFile);
54 | } else {
55 | state.audioFiles.splice(insertIndex, 0, newFile);
56 | }
57 | } else {
58 | // Update existing file
59 | state.audioFiles[existingIndex] = newFile;
60 | }
61 | },
62 | talkybotFetchError: (state, action: { payload: string }) => {
63 | state.metadata = {
64 | success: false,
65 | error: action.payload,
66 | timestamp: state.metadata?.timestamp || new Date().toISOString(),
67 | };
68 | },
69 | },
70 | });
71 |
72 | export const {
73 | setTalkybotAudioFiles,
74 | clearTalkybotAudioFiles,
75 | upsertTalkybotAudioFile,
76 | talkybotFetchError,
77 | } = talkybotSlice.actions;
78 |
--------------------------------------------------------------------------------
/src/components/interface/status.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | justify-content: space-between;
3 | width: 100%;
4 | transition: background-color 0.5s;
5 | /* height: 24px; */
6 | color: white;
7 | background-color: black;
8 | font-size: 10px;
9 | display: flex;
10 | }
11 |
12 | .container > span {
13 | display: inline-block;
14 | }
15 |
16 | .statusGrid {
17 | width: 100%;
18 | }
19 |
20 | .statusRow {
21 | display: grid;
22 | grid-template-columns: 1fr 1fr 1fr;
23 | column-gap: 5px;
24 | }
25 |
26 | .statusItem {
27 | display: flex;
28 | align-items: center;
29 | justify-content: flex-end;
30 | gap: 3px;
31 | }
32 |
33 | .statusLabel {
34 | font-weight: 200;
35 | }
36 |
37 | .status {
38 | width: 11px;
39 | height: 11px;
40 | min-height: 11px;
41 | display: block;
42 | flex-shrink: 0;
43 |
44 | background-size: 11px 11px;
45 | background-repeat: no-repeat;
46 | background-position: center;
47 | }
48 |
49 | .statusLarge {
50 | width: 18px;
51 | min-height: 18px;
52 | display: inline-block;
53 | margin-left: 0.1em;
54 | padding-right: 0.4em;
55 |
56 | background-size: 18px 18px;
57 | background-repeat: no-repeat;
58 |
59 | background-position: center;
60 | }
61 |
62 | .loading {
63 | background-image: url(/images/icon_status_loading.svg);
64 | }
65 |
66 | .noError {
67 | background-image: url(/images/icon_status_check_green.svg);
68 | }
69 |
70 | .error {
71 | background-image: url(/images/icon_status_error.svg);
72 | }
73 |
74 | .unneeded {
75 | background-image: url(/images/icon_status_no_assets.svg);
76 | }
77 |
78 | .stale {
79 | background-image: url(/images/icon_status_check_yellow.svg);
80 | }
81 |
82 | .loadingLargeWrapper {
83 | position: absolute;
84 | background: #0f0f0f;
85 | border: 2px solid #eeeeee;
86 | border-radius: 15px;
87 | box-sizing: border-box;
88 | padding: 20px;
89 | width: 380px;
90 | top: 50%;
91 | left: 50%;
92 | right: auto;
93 | bottom: auto;
94 | margin-right: -50%;
95 | transform: translate(-50%, -50%);
96 | outline: none;
97 | }
98 |
99 | .largeOverlay {
100 | position: fixed;
101 | top: 0;
102 | left: 0;
103 | right: 0;
104 | bottom: 0;
105 | background-color: rgb(0, 0, 0, 0.8);
106 | z-index: 10;
107 | }
108 |
109 | .largeStatusGrid {
110 | width: 100%;
111 | }
112 |
113 | .largeStatusRow {
114 | display: grid;
115 | grid-template-columns: 1fr 1fr 1fr;
116 | padding: 5px 0;
117 | border-bottom: 1px solid #696969;
118 | }
119 |
120 | .largeStatusItem {
121 | display: flex;
122 | align-items: center;
123 | justify-content: flex-end;
124 | gap: 6px;
125 | }
126 |
127 | .largeStatusLabel {
128 | font-weight: 400;
129 | }
130 |
--------------------------------------------------------------------------------
/src/components/framework/pane-picker.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, FunctionComponent } from "react";
2 | import { refEqual, useAppSelector } from "utils/useAppSelector";
3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
4 | import { allPanes, setPaneType } from "store/framework";
5 | import styles from "./pane-picker.module.css";
6 | import { useAppDispatch } from "utils/useAppDispatch";
7 | import { getAvailablePanesForSource } from "utils/sourceDataTypeMap";
8 |
9 | /**
10 | * Renders the label for a type of frame
11 | */
12 | export const PaneLabel: FunctionComponent<{
13 | paneType: string;
14 | labelSize?: "S" | "M" | "L";
15 | }> = ({ paneType, labelSize }) => {
16 | const { title, shortTitle, icon, color } = allPanes[paneType];
17 |
18 | let displayTitle = title;
19 | if (labelSize === "M") {
20 | displayTitle = shortTitle;
21 | } else if (labelSize === "S") {
22 | displayTitle = "";
23 | }
24 |
25 | return (
26 |
27 | {icon !== null ? (
28 |
29 |
30 |
31 | ) : (
32 |
33 | )}
34 |
{displayTitle}
35 |
36 | );
37 | };
38 |
39 | /** Renders a modal with a list of frame types to choose from */
40 | export const PanePickerModal: FunctionComponent<{
41 | closeClick: () => void;
42 | options: { frameID: number };
43 | }> = ({ closeClick, options: { frameID } }) => {
44 | const source = useAppSelector((state) => state.framework.source, refEqual);
45 | const [availablePanes, setAvailablePanes] = useState([]);
46 |
47 | const dispatch = useAppDispatch();
48 |
49 | const handleSelectPaneType = (paneType: string) => (e: React.MouseEvent) => {
50 | e.preventDefault();
51 | dispatch(setPaneType({ frameID, paneType }));
52 | closeClick();
53 | };
54 |
55 | useEffect(() => {
56 | const allPaneTypes = Object.keys(allPanes);
57 | const availablePanes = getAvailablePanesForSource(source, allPaneTypes);
58 |
59 | setAvailablePanes(availablePanes);
60 | }, [source]);
61 |
62 | return (
63 |
64 | {availablePanes.length > 0 ? (
65 | availablePanes.map((paneType) => (
66 |
73 | ))
74 | ) : (
75 |
No available sources
76 | )}
77 |
78 | );
79 | };
80 |
81 | export default PanePickerModal;
82 |
--------------------------------------------------------------------------------
/src/typings/store.d.ts:
--------------------------------------------------------------------------------
1 | declare type EMSSRole = import("@emss/oauth2-proxy-common").EMSSRole;
2 | declare type EmssUser = import("@emss/oauth2-proxy-common").EmssUser;
3 |
4 | /**
5 | * Ephemera store
6 | */
7 |
8 | type EphemeraState = {
9 | ephemerisFiles: EphemerisEntry[];
10 | metadata: FetchMetadata | null;
11 | };
12 |
13 | /**
14 | * DayNight Store
15 | */
16 | type DayNightState = {
17 | dayNight: DayNightObj[];
18 | metadata: FetchMetadata | null;
19 | origin?: string;
20 | };
21 |
22 | /**
23 | * Sequence store
24 | */
25 |
26 | type SequencesState = {
27 | allSequences: Sequence[];
28 | metadata: FetchMetadata | null;
29 | };
30 |
31 | /**
32 | * Photo store
33 | */
34 |
35 | type PhotosState = {
36 | photoFiles: PhotoFile[];
37 | activePhoto: PhotoFile;
38 | ready: boolean;
39 | metadata: FetchMetadata | null;
40 | collectionFilters: PhotoCollectionFilters[];
41 | };
42 |
43 | interface PhotoCollectionFilters {
44 | fullList: string;
45 | display: string;
46 | selected: boolean;
47 | }
48 |
49 | /**
50 | * Video store
51 | */
52 |
53 | /** Info about videos from IO and the desired high-level state of the video players */
54 | type VideosState = {
55 | videoFiles: VideoFile[];
56 | mtxPlaybackAvailability: MTXPlaybackAvailability;
57 | // string of stream names in the DL1_ISS, DL2_ISS, etc format or DL1_TE (test event), DL2_TE, etc.
58 | mtxHlsEndpoints: MTXHlsEndpoint[];
59 | metadataIo: FetchMetadata | null;
60 | metadataMtx: FetchMetadata | null;
61 | };
62 |
63 | /**
64 | * GPS Store
65 | */
66 | type GPSState = {
67 | gpsTracks: GPSTrack[];
68 | metadata: FetchMetadata | null;
69 | };
70 |
71 | /**
72 | * Talkybot Store
73 | */
74 | interface TalkybotState {
75 | audioFiles: TbAudioFile[];
76 | metadata: FetchMetadata | null;
77 | }
78 |
79 | /**
80 | * Graph Store
81 | */
82 | type GraphsState = {
83 | graphsManifest: GraphsManifest;
84 | metadata: FetchMetadata | null;
85 | };
86 |
87 | /**
88 | * User State
89 | */
90 | type UserState = {
91 | user: EmssUser;
92 | };
93 |
94 | /**
95 | * Clock State
96 | * Manages playhead time and hover state for the application
97 | */
98 | type ClockState = {
99 | /** UTC date being viewed (YYYY-MM-DD format or full ISO string) */
100 | date: string | null;
101 | /** Timestamp of the last start/stop user event */
102 | startStopTimestamp: string | null;
103 | /** The appSeconds value when the clock was last started or stopped */
104 | appSecondsAtStartStop: number;
105 | /** Whether the clock is currently running */
106 | isRunning: boolean;
107 | /** Hover playhead seconds (for timeline hover indicators) */
108 | hoverSeconds: number | null;
109 | };
110 |
--------------------------------------------------------------------------------
/src/packages/setupLoggerSpies.ts:
--------------------------------------------------------------------------------
1 | /* eslint-env node, jest */
2 |
3 | import expectCalledTimes from "@emss/jest-expect-called-times";
4 | import clientLogger from "utils/logging/clientLogger";
5 | import serverLogger from "utils/logging/serverLogger";
6 |
7 | type ServerLoggerFunction = keyof typeof serverLogger;
8 | type ClientLoggerFunction = keyof typeof clientLogger;
9 |
10 | type ServerLoggerSpies = Record>;
11 | type ClientLoggerSpies = Record>;
12 |
13 | export type LoggerSpies = {
14 | server: ServerLoggerSpies;
15 | client: ClientLoggerSpies;
16 | };
17 |
18 | export const setupLoggerSpies = (): LoggerSpies => {
19 | const server: ServerLoggerSpies = {
20 | error: jest.spyOn(serverLogger, "error"),
21 | warn: jest.spyOn(serverLogger, "warn"),
22 | notice: jest.spyOn(serverLogger, "notice"),
23 | info: jest.spyOn(serverLogger, "info"),
24 | forwardFromClient: jest.spyOn(serverLogger, "forwardFromClient"),
25 | logUserLogin: jest.spyOn(serverLogger, "logUserLogin"),
26 | };
27 |
28 | const client: ClientLoggerSpies = {
29 | error: jest.spyOn(clientLogger, "error"),
30 | warn: jest.spyOn(clientLogger, "warn"),
31 | notice: jest.spyOn(clientLogger, "notice"),
32 | info: jest.spyOn(clientLogger, "info"),
33 | };
34 |
35 | return { server, client };
36 | };
37 |
38 | export const expectServerLoggerToBeCalledTimes = (
39 | spies: ServerLoggerSpies,
40 | spyCalls: Partial> | "none"
41 | ): void => {
42 | if (spyCalls === "none") {
43 | spyCalls = {};
44 | }
45 |
46 | for (const [fnName, fn] of Object.entries(spies)) {
47 | if (fnName in spyCalls) {
48 | expectCalledTimes("serverLogger", fnName, fn, spyCalls[fnName as keyof ServerLoggerSpies]);
49 | } else {
50 | expectCalledTimes("serverLogger", fnName, fn, 0);
51 | }
52 | }
53 | };
54 |
55 | export const expectClientLoggerToBeCalledTimes = (
56 | spies: ClientLoggerSpies,
57 | spyCalls: Partial> | "none"
58 | ): void => {
59 | if (spyCalls === "none") {
60 | spyCalls = {};
61 | }
62 |
63 | for (const [fnName, fn] of Object.entries(spies)) {
64 | if (fnName in spyCalls) {
65 | expectCalledTimes("clientLogger", fnName, fn, spyCalls[fnName as keyof ClientLoggerSpies]);
66 | } else {
67 | expectCalledTimes("clientLogger", fnName, fn, 0);
68 | }
69 | }
70 | };
71 |
72 | export const resetLoggerSpies = (spies: LoggerSpies): void => {
73 | for (const spy in spies.server) {
74 | spies.server[spy as keyof ServerLoggerSpies].mockRestore();
75 | }
76 | for (const spy in spies.client) {
77 | spies.client[spy as keyof ClientLoggerSpies].mockRestore();
78 | }
79 | };
80 |
--------------------------------------------------------------------------------