├── 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 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/public/images/icon_status_check_yellow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 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 | 2 | 3 | 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 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/public/images/icon_status_error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 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 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/public/images/share_black.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 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 |
26 |
{frames}
27 |
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 |
17 |
18 | 19 | 20 |
Clock Sync
21 |
22 |
23 |
24 |
25 | 26 |
27 |
28 |
29 |
30 |
Loading...
31 |
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 |
12 |

Manually Calculate GoPro Video File Start Time from QR Data

13 |
14 | Date in QR imageZulu (ISO) 15 |
16 |
Seconds into video of QR image:
17 |
18 | Reported metadata start date of video containing QR imageZulu (ISO) 23 |
24 |
25 | Reported metadata start date of any other video in setZulu (ISO) 30 |
31 |
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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 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 |
60 |
61 |
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 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 33 | 35 | 37 | 39 | 40 | 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 |
36 |
37 |
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 |
71 | 72 |
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 | --------------------------------------------------------------------------------