├── .codeclimate.yml ├── .dockerignore ├── .fitdock.yml ├── .gitignore ├── .gitlab-ci.yml ├── .gitlab ├── run-on-commits.gitlab-ci.yml └── run-on-schedule.gitlab-ci.yml ├── .npmrc ├── .prettierignore ├── .prettierrc.json ├── .sastignore ├── .vscode ├── launch.json └── settings.json ├── .yamllint.yml ├── BOM.md ├── NASA-NPR-7150.2D.yml ├── README.md ├── appcompose ├── ci └── suppression.xml ├── docker-compose.preview.yml ├── docker-compose.services.yml ├── docker-compose.yml ├── docker ├── apiv1 │ └── Dockerfile ├── mediamtx-mock │ ├── Dockerfile │ ├── entrypoint.sh │ └── mediamtx.yml └── nginx │ ├── Dockerfile │ ├── loggedin.html │ ├── loggedout.html │ ├── nginx.conf │ ├── route-require-auth.conf │ └── setup-auth.conf ├── docs └── index.md ├── env.config.ts ├── esbuild.mjs ├── eslint.config.mjs ├── jest.config.ts ├── jest.globalSetup.ts ├── jest.setup.ts ├── mikro-orm.config.ts ├── mocks └── fakedata │ ├── ephemera.json │ ├── io_photos.json │ └── io_videos.json ├── package-lock.json ├── package.json ├── scripts └── make-dev-ssl-cert.sh ├── software-requirements.md ├── src ├── App.tsx ├── components │ ├── framework │ │ ├── SocketClient.tsx │ │ ├── frame.module.css │ │ ├── frame.tsx │ │ ├── frames.module.css │ │ ├── frames.tsx │ │ ├── layout-picker.module.css │ │ ├── layout-picker.tsx │ │ ├── pane-picker.module.css │ │ ├── pane-picker.tsx │ │ ├── preset-picker.module.css │ │ └── preset-picker.tsx │ ├── interface │ │ ├── about-overlay.module.css │ │ ├── about-overlay.tsx │ │ ├── button.module.css │ │ ├── button.tsx │ │ ├── calendar.module.css │ │ ├── calendar.tsx │ │ ├── dropdown-event.module.css │ │ ├── dropdown-event.tsx │ │ ├── dropdown-modal.module.css │ │ ├── dropdown-modal.tsx │ │ ├── header.module.css │ │ ├── header.tsx │ │ ├── nav-timeline-draw.module.css │ │ ├── nav-timeline-draw.ts │ │ ├── nav-timeline.tsx │ │ ├── pane-help-control-button.module.css │ │ ├── pane-help-control-button.tsx │ │ ├── pane-help-overlay.module.css │ │ ├── pane-help-overlay.tsx │ │ ├── photo-filter-button.module.css │ │ ├── photo-filter-button.tsx │ │ ├── playback-controls.module.css │ │ ├── playback-controls.tsx │ │ ├── share.module.css │ │ ├── share.tsx │ │ ├── status.module.css │ │ └── status.tsx │ └── panes │ │ ├── comm.module.css │ │ ├── comm.tsx │ │ ├── event-info.module.css │ │ ├── event-info.tsx │ │ ├── gps-location-marker.module.css │ │ ├── gps-location-marker.tsx │ │ ├── gps-location.module.css │ │ ├── gps-location.tsx │ │ ├── graph │ │ ├── graph.module.css │ │ ├── graph.tsx │ │ ├── graphProperties.ts │ │ ├── plotly-class.ts │ │ └── plotly.tsx │ │ ├── iss-location-marker.module.css │ │ ├── iss-location-marker.tsx │ │ ├── iss-location.module.css │ │ ├── iss-location.tsx │ │ ├── photo-all.module.css │ │ ├── photo-all.tsx │ │ ├── photo.module.css │ │ ├── photo.tsx │ │ ├── protomaps-theme.json │ │ ├── video-hls.tsx │ │ ├── video-mtx-playback.tsx │ │ ├── video.module.css │ │ └── video.tsx ├── index.html ├── index.tsx ├── packages │ ├── EnsureLogin.tsx │ ├── asyncSleep.ts │ ├── fetchFns.ts │ ├── getCurrentUser.ts │ ├── getUser.ts │ ├── onDemandProfiler.spec.ts │ ├── onDemandProfiler.ts │ └── setupLoggerSpies.ts ├── pages │ ├── admin │ │ ├── admin.module.css │ │ ├── ancillaryData.tsx │ │ ├── ancillaryDataUpsert.tsx │ │ ├── gps.tsx │ │ ├── gpsUpsert.tsx │ │ ├── index.tsx │ │ ├── mediaOverrides.tsx │ │ ├── mediaOverridesUpsert.tsx │ │ ├── photoTimeShiftUpsert.tsx │ │ ├── photoTimeShifts.tsx │ │ ├── socketStatus.tsx │ │ ├── videoStartTimeOverrideUpsert.tsx │ │ └── videoStartTimeOverrides.tsx │ ├── hls.tsx │ ├── index.module.css │ ├── index.tsx │ └── view │ │ ├── index.module.css │ │ ├── index.tsx │ │ ├── iss.tsx │ │ ├── nbl.tsx │ │ └── test-events.tsx ├── public │ ├── clockcalc │ │ ├── clockcalc.html │ │ └── clockcalc.js │ ├── clocksync │ │ ├── clocksync.css │ │ ├── img │ │ │ ├── CODA_wordmark.svg │ │ │ ├── EMSS_wordmark.svg │ │ │ └── logo_NASA.svg │ │ ├── index.html │ │ ├── index.js │ │ ├── lib │ │ │ └── qrcode_custom.js │ │ └── server │ │ │ └── gettime.php │ ├── favicon │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-256x256.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── safari-pinned-tab.svg │ │ └── site.webmanifest │ ├── fonts.css │ ├── fonts │ │ ├── Aldrich-Regular.ttf │ │ ├── Inter-VariableFont_slnt,wght.ttf │ │ ├── aldrich-v11-latin-regular.eot │ │ ├── aldrich-v11-latin-regular.svg │ │ ├── aldrich-v11-latin-regular.ttf │ │ ├── aldrich-v11-latin-regular.woff │ │ ├── aldrich-v11-latin-regular.woff2 │ │ ├── inter-v3-latin-100.eot │ │ ├── inter-v3-latin-100.svg │ │ ├── inter-v3-latin-100.ttf │ │ ├── inter-v3-latin-100.woff │ │ ├── inter-v3-latin-100.woff2 │ │ ├── inter-v3-latin-200.eot │ │ ├── inter-v3-latin-200.svg │ │ ├── inter-v3-latin-200.ttf │ │ ├── inter-v3-latin-200.woff │ │ ├── inter-v3-latin-200.woff2 │ │ ├── inter-v3-latin-300.eot │ │ ├── inter-v3-latin-300.svg │ │ ├── inter-v3-latin-300.ttf │ │ ├── inter-v3-latin-300.woff │ │ ├── inter-v3-latin-300.woff2 │ │ ├── inter-v3-latin-500.eot │ │ ├── inter-v3-latin-500.svg │ │ ├── inter-v3-latin-500.ttf │ │ ├── inter-v3-latin-500.woff │ │ ├── inter-v3-latin-500.woff2 │ │ ├── inter-v3-latin-600.eot │ │ ├── inter-v3-latin-600.svg │ │ ├── inter-v3-latin-600.ttf │ │ ├── inter-v3-latin-600.woff │ │ ├── inter-v3-latin-600.woff2 │ │ ├── inter-v3-latin-700.eot │ │ ├── inter-v3-latin-700.svg │ │ ├── inter-v3-latin-700.ttf │ │ ├── inter-v3-latin-700.woff │ │ ├── inter-v3-latin-700.woff2 │ │ ├── inter-v3-latin-800.eot │ │ ├── inter-v3-latin-800.svg │ │ ├── inter-v3-latin-800.ttf │ │ ├── inter-v3-latin-800.woff │ │ ├── inter-v3-latin-800.woff2 │ │ ├── inter-v3-latin-900.eot │ │ ├── inter-v3-latin-900.svg │ │ ├── inter-v3-latin-900.ttf │ │ ├── inter-v3-latin-900.woff │ │ ├── inter-v3-latin-900.woff2 │ │ ├── inter-v3-latin-regular.eot │ │ ├── inter-v3-latin-regular.svg │ │ ├── inter-v3-latin-regular.ttf │ │ ├── inter-v3-latin-regular.woff │ │ ├── inter-v3-latin-regular.woff2 │ │ ├── roboto-mono-v13-latin-300.eot │ │ ├── roboto-mono-v13-latin-300.svg │ │ ├── roboto-mono-v13-latin-300.ttf │ │ ├── roboto-mono-v13-latin-300.woff │ │ ├── roboto-mono-v13-latin-300.woff2 │ │ ├── roboto-mono-v13-latin-500.eot │ │ ├── roboto-mono-v13-latin-500.svg │ │ ├── roboto-mono-v13-latin-500.ttf │ │ ├── roboto-mono-v13-latin-500.woff │ │ ├── roboto-mono-v13-latin-500.woff2 │ │ ├── roboto-mono-v13-latin-regular.eot │ │ ├── roboto-mono-v13-latin-regular.svg │ │ ├── roboto-mono-v13-latin-regular.ttf │ │ ├── roboto-mono-v13-latin-regular.woff │ │ ├── roboto-mono-v13-latin-regular.woff2 │ │ ├── ubuntu-mono-v10-latin-regular.eot │ │ ├── ubuntu-mono-v10-latin-regular.svg │ │ ├── ubuntu-mono-v10-latin-regular.ttf │ │ ├── ubuntu-mono-v10-latin-regular.woff │ │ └── ubuntu-mono-v10-latin-regular.woff2 │ ├── global.css │ ├── images │ │ ├── artemis_launch_center.jpg │ │ ├── artemis_launch_closeup.jpg │ │ ├── coastal.jpg │ │ ├── datetime_2x.png │ │ ├── earth.png │ │ ├── earth_aurora.jpg │ │ ├── earth_moon.jpg │ │ ├── earth_nightlight.jpg │ │ ├── help_callout.png │ │ ├── icon_ISS.svg │ │ ├── icon_status_check_green.svg │ │ ├── icon_status_check_yellow.svg │ │ ├── icon_status_error.svg │ │ ├── icon_status_loading.svg │ │ ├── icon_status_no_assets.svg │ │ ├── iss_array_extend.jpg │ │ ├── logo_NASA.svg │ │ ├── marker_cart.png │ │ ├── marker_cart2.png │ │ ├── marker_ev1.png │ │ ├── marker_ev2.png │ │ ├── marker_ev3.png │ │ ├── marker_ev4.png │ │ ├── marker_lightCart.png │ │ ├── patch_fod_1400_8bit.png │ │ ├── share.svg │ │ ├── share_black.svg │ │ └── sun_earth.jpg │ ├── licenses │ │ ├── Inter-OFL.txt │ │ ├── RobotoMono-LICENSE.txt │ │ └── aldrich-OFL.txt │ └── mapbox_custom.css ├── server │ ├── database │ │ ├── migrations │ │ │ ├── Migration20240530185740.ts │ │ │ ├── Migration20240703205714.ts │ │ │ ├── Migration20240710153247.ts │ │ │ └── Migration20240718202407.ts │ │ └── models │ │ │ ├── PhotoTimeShifts.model.ts │ │ │ ├── VideoStartTimeOverrides.model.ts │ │ │ ├── _allModels.ts │ │ │ ├── ancillaryData.model.ts │ │ │ ├── gpxTracks.model.ts │ │ │ └── mediaOverride.model.ts │ ├── express │ │ ├── dataRetrievalScheduler.ts │ │ ├── global.ts │ │ ├── restApi.ts │ │ ├── routes │ │ │ ├── cache │ │ │ │ ├── cacheGarbageCollect.ts │ │ │ │ ├── clear.ts │ │ │ │ └── clearAll.ts │ │ │ ├── daynight │ │ │ │ └── daynight.ts │ │ │ ├── db │ │ │ │ ├── ancillaryDataSources.ts │ │ │ │ ├── gps.ts │ │ │ │ ├── mediaOverrides.ts │ │ │ │ ├── photos.ts │ │ │ │ └── video.ts │ │ │ ├── emss │ │ │ │ ├── sgAudio.ts │ │ │ │ └── transcripts.ts │ │ │ ├── location │ │ │ │ └── iss.ts │ │ │ ├── maestro │ │ │ │ └── executeTimelineStatus.ts │ │ │ ├── media │ │ │ │ ├── photos.ts │ │ │ │ ├── videoMediaMtx.ts │ │ │ │ └── videos.ts │ │ │ ├── profiler │ │ │ │ └── profiler.ts │ │ │ ├── sequences │ │ │ │ ├── evas.ts │ │ │ │ ├── graphs.ts │ │ │ │ └── test-events.ts │ │ │ ├── socketStatus │ │ │ │ └── socketStatus.ts │ │ │ └── user │ │ │ │ ├── auth.ts │ │ │ │ └── logFromClient.ts │ │ ├── server.ts │ │ └── sockets.ts │ ├── processing │ │ ├── cache-client.spec.ts │ │ ├── cache-client.ts │ │ ├── daynight │ │ │ └── daynight.ts │ │ ├── db │ │ │ └── gps.ts │ │ ├── emss │ │ │ ├── sgAudio.ts │ │ │ └── transcript.ts │ │ ├── location │ │ │ └── iss.ts │ │ ├── maestro │ │ │ └── maestro.ts │ │ ├── media │ │ │ ├── photos.ts │ │ │ └── videos.ts │ │ └── sequences │ │ │ ├── evas.ts │ │ │ ├── graph.ts │ │ │ └── test-events.ts │ └── services │ │ ├── daynight-api.spec.ts │ │ ├── daynight-api.ts │ │ ├── db-api.ts │ │ ├── emss.ts │ │ ├── ephemera-api.spec.ts │ │ ├── ephemera-api.ts │ │ ├── graphs.ts │ │ ├── io-api.spec.ts │ │ ├── io-api.ts │ │ ├── maestro.ts │ │ └── wiki-api.ts ├── store │ ├── contextProviders │ │ ├── _CombinedProviders.tsx │ │ ├── hoverPlayheadContext.tsx │ │ └── playheadContext.tsx │ ├── daynight.ts │ ├── ephemera.spec.ts │ ├── ephemera.ts │ ├── framework-presets.ts │ ├── framework.ts │ ├── gps.ts │ ├── graphs.ts │ ├── index.ts │ ├── maestro.ts │ ├── photos.ts │ ├── sequences.spec.ts │ ├── sequences.ts │ ├── sg-audio.ts │ ├── transcript.ts │ ├── user.ts │ └── videos.ts ├── styles.css ├── typings │ ├── api.d.ts │ ├── cache.d.ts │ ├── consts.d.ts │ ├── context.d.ts │ ├── daynight.d.ts │ ├── declarations.d.ts │ ├── ephemera.d.ts │ ├── framework.d.ts │ ├── global.d.ts │ ├── gps.d.ts │ ├── graph.d.ts │ ├── index.d.ts │ ├── io.d.ts │ ├── jsx-shim.d.ts │ ├── location.d.ts │ ├── maestro-api.d.ts │ ├── media.d.ts │ ├── mtx.d.ts │ ├── mwbot.d.ts │ ├── photo.d.ts │ ├── sg-audio.d.ts │ ├── socketio.d.ts │ ├── store.d.ts │ ├── tle.js.d.ts │ ├── transcript.d.ts │ ├── video.d.ts │ └── wiki.d.ts └── utils │ ├── clientLogger.ts │ ├── consts.ts │ ├── context.tsx │ ├── date.spec.ts │ ├── date.ts │ ├── fetch-with-timeout.spec.ts │ ├── fetch-with-timeout.ts │ ├── formatting.spec.ts │ ├── formatting.ts │ ├── jest-extends.ts │ ├── logger.ts │ ├── map.ts │ ├── mikro.ts │ ├── serverLogger.ts │ ├── share-state.ts │ ├── suncalc.js │ ├── suncalc.spec.ts │ ├── terminator.ts │ ├── type-guards.ts │ ├── useAppDispatch.ts │ ├── useAppSelector.ts │ ├── useInterval.ts │ └── user.ts ├── tsconfig.jest.json ├── tsconfig.json ├── tsconfig.orm.json └── vite.config.mts /.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 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /.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/cache 22 | mode: "0775" 23 | owner: gitlab-runner 24 | group: gitlab-runner 25 | - path: /d1/coda/postgres 26 | mode: "0775" 27 | owner: gitlab-runner 28 | group: gitlab-runner 29 | - path: /d1/coda/db-init 30 | mode: "0775" 31 | owner: gitlab-runner 32 | group: gitlab-runner 33 | - path: /d1/coda/redis 34 | mode: "0775" 35 | owner: gitlab-runner 36 | group: gitlab-runner 37 | 38 | # Ensure SSL key/cert exist at the specified locations. If they do not exist, 39 | # a self-signed cert will be generated. This SSL is NOT the user-facing SSL 40 | # cert; it is only for encrypting traffic between the VM running this app and 41 | # the FIT proxy/load-balancer. The FIT proxy will accept self-signed certs, 42 | # but the FIT sysadmins should switch this self-signed cert out with a valid 43 | # cert. 44 | use_ssl: 45 | key: /etc/pki/tls/private/nginx.key 46 | cert: /etc/pki/tls/certs/nginx.crt 47 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | # These includes do not define the order of the pipeline. That is determined by 2 | # the `needs` value for each job 3 | include: 4 | # Jobs that run on commits and scheduled pipelines. 5 | # 6 | # Scheduled pipelines are used to do regular reporting on things like vulnerabilities and test 7 | # coverage. We don't want them to build packages and Docker images or do deploys 8 | - local: .gitlab/run-on-schedule.gitlab-ci.yml 9 | rules: 10 | - if: $CI_PIPELINE_SOURCE == "schedule" 11 | 12 | # Jobs that run on commits only, not scheduled pipelines 13 | - local: .gitlab/run-on-commits.gitlab-ci.yml 14 | rules: 15 | - if: $CI_PIPELINE_SOURCE != "schedule" 16 | -------------------------------------------------------------------------------- /.gitlab/run-on-schedule.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - stageless 3 | 4 | .db-export-template: 5 | needs: [] 6 | # I don't think we want to do this, because I don't think we want GitLab to consider this a 7 | # deployment. It's just grabbing some data off of Production. 8 | # environment: 9 | # name: production 10 | # url: https://coda.fit.nasa.gov 11 | when: manual 12 | stage: stageless 13 | timeout: 10 minutes 14 | variables: 15 | GIT_STRATEGY: none 16 | script: 17 | # init sudo ability 18 | - echo "${DEPLOY_SUDO_PASS}" | sudo -S touch /tmp/somefile 19 | - PROJECT_DIR=$(pwd) 20 | - cd /opt/coda 21 | - sudo docker compose exec database pg_dump -U postgres coda > "${PROJECT_DIR}/coda.sql" 22 | artifacts: 23 | name: "$CI_JOB_STARTED_AT$-coda.sql" 24 | paths: 25 | - coda.sql 26 | # hold on to this artifact longer than default (1 day). . The latest for any branch never expires 27 | expire_in: 60 days 28 | 29 | z:db-export:prod: 30 | extends: .db-export-template 31 | when: always 32 | tags: ["emss-coda-prod"] 33 | variables: 34 | DEPLOY_SUDO_PASS: $DEPLOY_SUDO_PASS_PROD #CI/CD Variable 35 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @emss:registry=https://eegitlab.fit.nasa.gov/api/v4/projects/685/packages/npm/ 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore all files in .gitignore 2 | .gitignore 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "endOfLine": "lf", 4 | "trailingComma": "es5", 5 | "useTabs": false, 6 | "tabWidth": 2 7 | } 8 | -------------------------------------------------------------------------------- /.sastignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/.sastignore -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.yamllint.yml: -------------------------------------------------------------------------------- 1 | extends: relaxed 2 | rules: 3 | line-length: 4 | max: 140 5 | level: warning 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /ci/suppression.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | 14 | -------------------------------------------------------------------------------- /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 | - VITE_PUBLIC_APP_ENV=${VITE_PUBLIC_APP_ENV} 21 | 22 | apiv1: 23 | image: coda-preview-apiv1:latest 24 | build: 25 | context: . 26 | dockerfile: ./docker/apiv1/Dockerfile 27 | target: prod 28 | environment: 29 | # Override what is in .env (which may be "localhost" to support native local dev) such 30 | # that it is always "database" and port 5432 in a full Docker-Compose setup. 31 | - DB_HOST=database 32 | - DB_PORT=5432 33 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docker/apiv1/Dockerfile: -------------------------------------------------------------------------------- 1 | # Get the base image, which is supplied by the pipeline. If the *_BASE_IMAGE value is not defined 2 | # (which is true when building locally) then use the value to the right of the = operator 3 | # Base image optionally from arg 4 | # ref: https://eegitlab.fit.nasa.gov/emss/docs/-/blob/main/docker.md#base-images-as-arg 5 | ARG APIV1_BASE_IMAGE=node:22.15.0-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 | COPY package*.json . 20 | RUN npm ci 21 | 22 | # copy the rest of the files 23 | COPY . /app 24 | 25 | RUN npm run api:build 26 | 27 | # This image is intended to be run with docker-compose. 28 | CMD ["/bin/sh", "-c", "npm run migrate:up; npm run api:prod"] -------------------------------------------------------------------------------- /docker/mediamtx-mock/Dockerfile: -------------------------------------------------------------------------------- 1 | # This instance of mediamtx is only used for local stream mocks 2 | # and is not used for any other purpose 3 | # and is only run in the npm run docker:services command. 4 | 5 | # Use Debian as the base image for better FFmpeg support 6 | FROM debian:latest 7 | 8 | # Add the deb-multimedia repository for newer FFmpeg packages 9 | RUN apt-get update && \ 10 | apt-get install -y wget gnupg procps cron && \ 11 | wget https://www.deb-multimedia.org/pool/main/d/deb-multimedia-keyring/deb-multimedia-keyring_2016.8.1_all.deb && \ 12 | apt-get install -y ./deb-multimedia-keyring_2016.8.1_all.deb && \ 13 | echo "deb http://www.deb-multimedia.org bookworm main non-free" >> /etc/apt/sources.list && \ 14 | apt-get update && \ 15 | apt-get install -y ffmpeg fonts-dejavu-core && \ 16 | apt-get clean && \ 17 | rm -rf /var/lib/apt/lists/* 18 | 19 | # Optional: Verify the font is installed 20 | RUN fc-list 21 | 22 | 23 | # Copy MediaMTX binary from the original image 24 | COPY --from=bluenviron/mediamtx:1.11.2 /mediamtx /mediamtx 25 | 26 | # Copy the entrypoint script into the container 27 | COPY ./docker/mediamtx-mock/entrypoint.sh /entrypoint.sh 28 | RUN chmod +x /entrypoint.sh 29 | 30 | # Set the entrypoint to the custom script 31 | ENTRYPOINT ["/entrypoint.sh"] 32 | 33 | # Default command to start MediaMTX 34 | CMD ["/mediamtx", "/mediamtx.yml"] 35 | -------------------------------------------------------------------------------- /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 | # Set up a cron job to delete empty folders in /recordings because mediamtx doesn't clean up after itself 6 | echo "*/5 * * * * find /recordings -type d -empty -delete" > /etc/cron.d/delete_empty_folders 7 | 8 | # Set up a cron job to delete HLS files older than 26 hours 9 | echo "*/5 * * * * find /hls -type f -mmin +1560 -delete" > /etc/cron.d/delete_old_hls_files 10 | 11 | chmod 0644 /etc/cron.d/delete_empty_folders 12 | crontab /etc/cron.d/delete_empty_folders 13 | cron & 14 | 15 | # Execute MediaMTX with the default command 16 | exec "$@" 17 | -------------------------------------------------------------------------------- /docker/nginx/loggedin.html: -------------------------------------------------------------------------------- 1 |

Welcome! You are logged in.

2 | -------------------------------------------------------------------------------- /docker/nginx/loggedout.html: -------------------------------------------------------------------------------- 1 |

You are not logged into this app

2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | All of our documentation lives in README.md 2 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | const config = { 2 | preset: "ts-jest/presets/js-with-ts", 3 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "node"], 4 | moduleDirectories: ["node_modules", "src"], 5 | rootDir: "./src", 6 | moduleNameMapper: { 7 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": 8 | "/__mocks__/fileMock.js", 9 | "\\.(css|scss)$": "identity-obj-proxy", 10 | "^__mocks__(.*)$": "/__mocks__$1", 11 | "^components/(.*)$": "/components/$1", 12 | "^http-client/(.*)$": "/http-client/$1", 13 | "^pages/(.*)$": "/pages/$1", 14 | "^public/(.*)$": "/public/$1", 15 | "^server/(.*)$": "/server/$1", 16 | "^store/(.*)$": "/store/$1", 17 | "^typings$": "/typings/index.d", 18 | "^typings/(.*)$": "/typings/$1", 19 | "^utils/(.*)$": "/utils/$1", 20 | }, 21 | collectCoverageFrom: ["**/*.{js,jsx,ts,tsx}", "!**/*.d.ts"], 22 | coverageReporters: ["text", "lcov", "cobertura"], 23 | globalSetup: "/../jest.globalSetup.ts", 24 | setupFiles: ["/../jest.setup.ts"], 25 | setupFilesAfterEnv: ["/utils/jest-extends.ts"], 26 | globals: {}, 27 | transform: { 28 | "^.+\\.(ts|tsx)$": ["ts-jest", { tsconfig: "tsconfig.jest.json", warnOnly: true }], 29 | }, 30 | transformIgnorePatterns: ["/node_modules/(?!tle.js/).*"], 31 | }; 32 | 33 | export default config; 34 | -------------------------------------------------------------------------------- /jest.globalSetup.ts: -------------------------------------------------------------------------------- 1 | const globalSetup = async (): Promise => { 2 | console.log(""); // clears the line in the terminal 3 | }; 4 | 5 | export default globalSetup; 6 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /mikro-orm.config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; //needed to allow jest to init Mikro in globalTeardown 2 | dotenv.config(); 3 | 4 | // The following 3 lines are needed to make the MikroORM 6.0.x import for the PostgreSqlDriver work in jest. 5 | import { TextEncoder, TextDecoder } from "util"; 6 | global.TextEncoder = TextEncoder; 7 | (global as any).TextDecoder = TextDecoder; 8 | 9 | import { PostgreSqlDriver, defineConfig } from "@mikro-orm/postgresql"; 10 | import { Migrator } from "@mikro-orm/migrations"; 11 | import { SeedManager } from "@mikro-orm/seeder"; 12 | import { 13 | AncillaryDataSource_db, 14 | GPXTracks_db, 15 | MediaOverride_db, 16 | PhotoTimeShifts_db, 17 | VideoStartTimeOverrides_db, 18 | } from "./src/server/database/models/_allModels"; 19 | import path from "path"; 20 | 21 | export default defineConfig({ 22 | dbName: process.env.DB_NAME, 23 | host: process.env.DB_HOST, 24 | port: parseInt(process.env.DB_PORT), 25 | driver: PostgreSqlDriver, 26 | password: process.env.DB_PASS, 27 | migrations: { 28 | path: path.join(__dirname, "./src/server/database/migrations"), // path to the folder with migrations 29 | snapshot: false, 30 | }, 31 | seeder: { 32 | path: path.join(__dirname, "./src/server/database/seeds"), // path to the folder with seed files 33 | }, 34 | entitiesTs: [ 35 | GPXTracks_db, 36 | MediaOverride_db, 37 | AncillaryDataSource_db, 38 | VideoStartTimeOverrides_db, 39 | PhotoTimeShifts_db, 40 | ], 41 | entities: [ 42 | GPXTracks_db, 43 | MediaOverride_db, 44 | AncillaryDataSource_db, 45 | VideoStartTimeOverrides_db, 46 | PhotoTimeShifts_db, 47 | ], 48 | debug: process.env.DEBUG === "true" || process.env.DEBUG?.includes("db"), 49 | allowGlobalContext: true, 50 | extensions: [Migrator, SeedManager], 51 | }); 52 | -------------------------------------------------------------------------------- /scripts/make-dev-ssl-cert.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 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/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/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 | import { RootState } from "store/index"; 8 | 9 | const Viewer: FunctionComponent = () => { 10 | const selectedLayout = useAppSelector((state: RootState) => state.framework.layout, shallowEqual); 11 | const layoutDefinition = allLayouts[selectedLayout]; 12 | 13 | const frames: JSX.Element[] = []; 14 | for (let i = 1; i <= layoutDefinition.frameCount; i++) { 15 | // CSS Grid definitions 16 | const gridAreaName = styles[`f${i}`]; 17 | frames.push( 18 |
19 | 20 |
21 | ); 22 | } 23 | 24 | const mainStyleName = layoutDefinition.cssGridRows === 9 ? styles.main_9Rows : styles.main_10Rows; 25 | 26 | return ( 27 |
28 |
{frames}
29 |
30 | ); 31 | }; 32 | 33 | export default Viewer; 34 | -------------------------------------------------------------------------------- /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/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/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: solid var(--grey); 62 | color: white; 63 | } 64 | .active:hover { 65 | background: var(--lightest-grey); 66 | border: solid var(--lightest-grey); 67 | color: white; 68 | } 69 | 70 | .selected { 71 | background: var(--lightest-grey); 72 | border: solid var(--lightest-grey); 73 | color: white; 74 | } 75 | 76 | .disabled { 77 | background: var(--grey); 78 | border: solid var(--grey); 79 | color: var(--extremely-grey); 80 | } 81 | 82 | .active_selected { 83 | background: var(--lightest-grey); 84 | border: solid var(--lightest-grey); 85 | color: white; 86 | } 87 | 88 | .disabled_selected { 89 | background: var(--lightest-grey); 90 | border: solid var(--lightest-grey); 91 | color: var(--extremely-grey); 92 | } 93 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /src/components/interface/photo-filter-button.module.css: -------------------------------------------------------------------------------- 1 | .buttonLong { 2 | width: 55px; 3 | } 4 | 5 | .buttonShort { 6 | width: 20px; 7 | } 8 | 9 | .buttonLabel { 10 | display: flex; 11 | justify-content: space-between; 12 | } 13 | 14 | .filterLabel { 15 | display: flex; 16 | align-items: center; 17 | justify-content: space-between; 18 | position: relative; 19 | } 20 | 21 | .filterButton { 22 | display: inline-block; 23 | height: 18px; 24 | background-color: var(--even-greyer); 25 | color: var(--lighter-grey); 26 | border: none; 27 | border-radius: var(--radius); 28 | font-size: 11px; 29 | font-weight: 600; 30 | cursor: pointer; 31 | } 32 | 33 | .filterButton:hover { 34 | background-color: #eeeeee; 35 | } 36 | 37 | .photoOverlay { 38 | display: block; 39 | position: absolute; 40 | top: 0; 41 | left: 0; 42 | height: 100%; 43 | width: 100%; 44 | z-index: 1; 45 | box-sizing: border-box; 46 | padding: 20px; 47 | outline: none; 48 | overflow-y: auto; 49 | } 50 | 51 | .overlayTable { 52 | width: 100%; 53 | font-size: 0.9em; 54 | background: rgb(15, 15, 15, 0.8); 55 | border: 2px solid #eeeeee; 56 | border-radius: 10px; 57 | border-spacing: 0; 58 | } 59 | 60 | .overlayTable a { 61 | text-decoration: underline; 62 | } 63 | 64 | .overlayTable tr { 65 | border-bottom: 2px solid rgba(150, 150, 150, 0.5); 66 | } 67 | 68 | .overlayTable td:first-child { 69 | text-align: right; 70 | width: 40px; 71 | font-weight: 400; 72 | white-space: nowrap; 73 | border-right: 2px solid rgba(150, 150, 150, 0.5); 74 | } 75 | 76 | .overlayTable td { 77 | display: table-cell; 78 | border-collapse: collapse; 79 | vertical-align: top; 80 | font-weight: 200; 81 | padding: 5px; 82 | border-bottom: 2px solid rgba(150, 150, 150, 0.5); 83 | } 84 | .overlayTable tr:last-child td { 85 | border-bottom: none; 86 | } 87 | 88 | .overlayTable .digiValue { 89 | font-family: "Ubuntu Mono"; 90 | font-size: 1em; 91 | white-space: pre-wrap; 92 | word-break: break-all; 93 | } 94 | 95 | .selected { 96 | border: none; 97 | background-color: #eeeeee; 98 | color: var(--lighter-grey); 99 | } 100 | -------------------------------------------------------------------------------- /src/components/interface/playback-controls.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from "react"; 2 | import styles from "./playback-controls.module.css"; 3 | import { usePlayheadContext } from "store/contextProviders/playheadContext"; 4 | 5 | const PlaybackControls: FunctionComponent = () => { 6 | const { playhead, dispatchPlayhead } = usePlayheadContext(); 7 | 8 | const handlePlayPause = () => { 9 | dispatchPlayhead({ type: playhead.isRunning ? "STOP" : "START" }); 10 | }; 11 | 12 | const jumpTime = (seconds: number) => { 13 | dispatchPlayhead({ type: "SET_APP_SECONDS", payload: playhead.appSeconds + seconds }); 14 | }; 15 | 16 | let playPauseSvgName; 17 | if (playhead.isRunning) { 18 | playPauseSvgName = styles.pauseSVG; 19 | } else { 20 | playPauseSvgName = styles.playSVG; 21 | } 22 | return ( 23 |
24 |
25 |
29 |
30 |
{ 33 | jumpTime(-5); 34 | }} 35 | > 36 |
37 |
5
38 |
39 |
{ 42 | jumpTime(5); 43 | }} 44 | > 45 |
46 |
5
47 |
48 |
49 | ); 50 | }; 51 | 52 | export default PlaybackControls; 53 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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 { usePlayheadContext } from "store/contextProviders/playheadContext"; 8 | 9 | //disgusting hack to make IDE errors go away in the useEffect below 10 | type HTMLDivElementExtended = HTMLDivElement & { on: Function }; 11 | 12 | const PlotlyComponent: FunctionComponent<{ 13 | frameID: number; 14 | chartData: { 15 | plotlyChartTraces: PlotlyChartTrace[]; 16 | plotlyChartLayout: ChartLayout; 17 | }; 18 | plotIndexToHighlight: number; 19 | }> = ({ frameID, chartData, plotIndexToHighlight }) => { 20 | const { dispatchPlayhead } = usePlayheadContext(); 21 | 22 | const plotlyClass: MutableRefObject = useRef(null); 23 | const plotlyChartRef: MutableRefObject = useRef(null); 24 | 25 | useEffect(() => { 26 | plotlyClass.current = new PlotlyClass(); 27 | }, []); 28 | 29 | useEffect(() => { 30 | if (!plotlyChartRef.current) return; 31 | 32 | plotlyClass.current.drawChart( 33 | `plotlyChart${frameID}`, 34 | chartData.plotlyChartTraces, 35 | chartData.plotlyChartLayout as Partial 36 | ); 37 | 38 | //now that drawChart has been called, the "on" method is now attached to the plotlyChart div 39 | plotlyChartRef.current.on( 40 | "plotly_click", 41 | (data: { points: { x: { replace: (arg0: string) => string } }[] }) => { 42 | // ignore errors caused by graph data being unavailable for a given point 43 | try { 44 | const dateStr = data.points[0].x.replace(" " + "T") + "Z"; 45 | dispatchPlayhead({ type: "SET_APP_SECONDS", payload: appSecondsFromDateString(dateStr) }); 46 | } catch { 47 | //do nothing 48 | } 49 | } 50 | ); 51 | }, [plotlyChartRef, chartData]); 52 | 53 | useEffect(() => { 54 | plotlyClass.current.hoverPoint(plotlyChartRef, plotIndexToHighlight); 55 | }, [plotlyClass, plotIndexToHighlight]); 56 | 57 | return ( 58 |
59 |
60 |
61 | ); 62 | }; 63 | 64 | export default PlotlyComponent; 65 | -------------------------------------------------------------------------------- /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/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/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 | } 63 | 64 | .buttonShort { 65 | width: 20px; 66 | } 67 | 68 | .buttonLabel { 69 | display: flex; 70 | justify-content: space-between; 71 | } 72 | -------------------------------------------------------------------------------- /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 | } 114 | 115 | .buttonSmall { 116 | width: 20px; 117 | } 118 | 119 | .buttonLabel { 120 | display: flex; 121 | justify-content: space-between; 122 | } 123 | -------------------------------------------------------------------------------- /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/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 | 8 | import "./styles.css"; 9 | import "@fortawesome/fontawesome-svg-core/styles.css"; 10 | 11 | const root = createRoot(document.getElementById("root")); 12 | root.render( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | -------------------------------------------------------------------------------- /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/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/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/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/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) { 31 | return getMockUser(); 32 | } 33 | return getUserFromJWT(req); 34 | }; 35 | -------------------------------------------------------------------------------- /src/pages/admin/admin.module.css: -------------------------------------------------------------------------------- 1 | .formItem { 2 | width: 500px; 3 | display: grid; 4 | grid-template-columns: 1fr 1fr; 5 | } 6 | 7 | .formItem label { 8 | text-align: right; 9 | } 10 | 11 | .formItem textarea { 12 | height: 200px; 13 | } 14 | 15 | .listItem { 16 | display: grid; 17 | grid-template-columns: 350px 1fr; 18 | } 19 | -------------------------------------------------------------------------------- /src/pages/admin/index.tsx: -------------------------------------------------------------------------------- 1 | import { getCurrentUser } from "packages/getCurrentUser"; 2 | import { FunctionComponent, useEffect } from "react"; 3 | import { Link, useNavigate } from "react-router"; 4 | import { isSuperuser } from "utils/user"; 5 | 6 | const AdminIndex: FunctionComponent = () => { 7 | const navigate = useNavigate(); 8 | useEffect(() => { 9 | (async () => { 10 | //check permissions 11 | const user = await getCurrentUser(); 12 | if (user instanceof Error || !isSuperuser(user)) { 13 | navigate("/"); //Redirect to homepage 14 | } 15 | })(); 16 | }, []); 17 | 18 | return ( 19 |
20 |

Admin

21 |

22 | GPS Data 23 |
24 | Media Overrides 25 |
26 | Ancillary Data Sources 27 |
28 | Video Start Time Overrides 29 |
30 | Photo Time Shifts 31 |

32 |

33 | Server Socket Status 34 |

35 |
36 | ); 37 | }; 38 | 39 | export default AdminIndex; 40 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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 | ?> -------------------------------------------------------------------------------- /src/public/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /src/public/favicon/android-chrome-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/favicon/android-chrome-256x256.png -------------------------------------------------------------------------------- /src/public/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /src/public/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /src/public/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /src/public/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/favicon/favicon.ico -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /src/public/fonts/Aldrich-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/Aldrich-Regular.ttf -------------------------------------------------------------------------------- /src/public/fonts/Inter-VariableFont_slnt,wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/Inter-VariableFont_slnt,wght.ttf -------------------------------------------------------------------------------- /src/public/fonts/aldrich-v11-latin-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/aldrich-v11-latin-regular.eot -------------------------------------------------------------------------------- /src/public/fonts/aldrich-v11-latin-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/aldrich-v11-latin-regular.ttf -------------------------------------------------------------------------------- /src/public/fonts/aldrich-v11-latin-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/aldrich-v11-latin-regular.woff -------------------------------------------------------------------------------- /src/public/fonts/aldrich-v11-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/aldrich-v11-latin-regular.woff2 -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-100.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-100.eot -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-100.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-100.ttf -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-100.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-100.woff -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-100.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-100.woff2 -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-200.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-200.eot -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-200.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-200.ttf -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-200.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-200.woff -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-200.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-200.woff2 -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-300.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-300.eot -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-300.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-300.ttf -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-300.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-300.woff -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-300.woff2 -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-500.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-500.eot -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-500.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-500.ttf -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-500.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-500.woff -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-500.woff2 -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-600.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-600.eot -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-600.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-600.ttf -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-600.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-600.woff -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-600.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-600.woff2 -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-700.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-700.eot -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-700.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-700.ttf -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-700.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-700.woff -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-700.woff2 -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-800.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-800.eot -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-800.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-800.ttf -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-800.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-800.woff -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-800.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-800.woff2 -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-900.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-900.eot -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-900.ttf -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-900.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-900.woff -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-900.woff2 -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-regular.eot -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-regular.ttf -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-regular.woff -------------------------------------------------------------------------------- /src/public/fonts/inter-v3-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/inter-v3-latin-regular.woff2 -------------------------------------------------------------------------------- /src/public/fonts/roboto-mono-v13-latin-300.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/roboto-mono-v13-latin-300.eot -------------------------------------------------------------------------------- /src/public/fonts/roboto-mono-v13-latin-300.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/roboto-mono-v13-latin-300.ttf -------------------------------------------------------------------------------- /src/public/fonts/roboto-mono-v13-latin-300.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/roboto-mono-v13-latin-300.woff -------------------------------------------------------------------------------- /src/public/fonts/roboto-mono-v13-latin-300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/roboto-mono-v13-latin-300.woff2 -------------------------------------------------------------------------------- /src/public/fonts/roboto-mono-v13-latin-500.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/roboto-mono-v13-latin-500.eot -------------------------------------------------------------------------------- /src/public/fonts/roboto-mono-v13-latin-500.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/roboto-mono-v13-latin-500.ttf -------------------------------------------------------------------------------- /src/public/fonts/roboto-mono-v13-latin-500.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/roboto-mono-v13-latin-500.woff -------------------------------------------------------------------------------- /src/public/fonts/roboto-mono-v13-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/roboto-mono-v13-latin-500.woff2 -------------------------------------------------------------------------------- /src/public/fonts/roboto-mono-v13-latin-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/roboto-mono-v13-latin-regular.eot -------------------------------------------------------------------------------- /src/public/fonts/roboto-mono-v13-latin-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/roboto-mono-v13-latin-regular.ttf -------------------------------------------------------------------------------- /src/public/fonts/roboto-mono-v13-latin-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/roboto-mono-v13-latin-regular.woff -------------------------------------------------------------------------------- /src/public/fonts/roboto-mono-v13-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/roboto-mono-v13-latin-regular.woff2 -------------------------------------------------------------------------------- /src/public/fonts/ubuntu-mono-v10-latin-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/ubuntu-mono-v10-latin-regular.eot -------------------------------------------------------------------------------- /src/public/fonts/ubuntu-mono-v10-latin-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/ubuntu-mono-v10-latin-regular.ttf -------------------------------------------------------------------------------- /src/public/fonts/ubuntu-mono-v10-latin-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/ubuntu-mono-v10-latin-regular.woff -------------------------------------------------------------------------------- /src/public/fonts/ubuntu-mono-v10-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/fonts/ubuntu-mono-v10-latin-regular.woff2 -------------------------------------------------------------------------------- /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/public/images/artemis_launch_center.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/images/artemis_launch_center.jpg -------------------------------------------------------------------------------- /src/public/images/artemis_launch_closeup.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/images/artemis_launch_closeup.jpg -------------------------------------------------------------------------------- /src/public/images/coastal.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/images/coastal.jpg -------------------------------------------------------------------------------- /src/public/images/datetime_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/images/datetime_2x.png -------------------------------------------------------------------------------- /src/public/images/earth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/images/earth.png -------------------------------------------------------------------------------- /src/public/images/earth_aurora.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/images/earth_aurora.jpg -------------------------------------------------------------------------------- /src/public/images/earth_moon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/images/earth_moon.jpg -------------------------------------------------------------------------------- /src/public/images/earth_nightlight.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/images/earth_nightlight.jpg -------------------------------------------------------------------------------- /src/public/images/help_callout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/images/help_callout.png -------------------------------------------------------------------------------- /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/public/images/icon_status_error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/public/images/icon_status_loading.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/public/images/icon_status_no_assets.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/public/images/iss_array_extend.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/images/iss_array_extend.jpg -------------------------------------------------------------------------------- /src/public/images/marker_cart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/images/marker_cart.png -------------------------------------------------------------------------------- /src/public/images/marker_cart2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/images/marker_cart2.png -------------------------------------------------------------------------------- /src/public/images/marker_ev1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/images/marker_ev1.png -------------------------------------------------------------------------------- /src/public/images/marker_ev2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/images/marker_ev2.png -------------------------------------------------------------------------------- /src/public/images/marker_ev3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/images/marker_ev3.png -------------------------------------------------------------------------------- /src/public/images/marker_ev4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/images/marker_ev4.png -------------------------------------------------------------------------------- /src/public/images/marker_lightCart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/images/marker_lightCart.png -------------------------------------------------------------------------------- /src/public/images/patch_fod_1400_8bit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/images/patch_fod_1400_8bit.png -------------------------------------------------------------------------------- /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/public/images/sun_earth.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nasa/coda/b2e2094840f149f08afb4585ee378c9758e09e4b/src/public/images/sun_earth.jpg -------------------------------------------------------------------------------- /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/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/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/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/PhotoTimeShifts.model.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryKey, Property } from "@mikro-orm/core"; 2 | import { types as MikroTypes } from "@mikro-orm/core"; 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/server/database/models/VideoStartTimeOverrides.model.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryKey, Property } from "@mikro-orm/core"; 2 | import { types as MikroTypes } from "@mikro-orm/core"; 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/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 | 9 | export { GPXTracks_db }; 10 | export { MediaOverride_db }; 11 | export { AncillaryDataSource_db }; 12 | export { VideoStartTimeOverrides_db }; 13 | export { PhotoTimeShifts_db }; 14 | -------------------------------------------------------------------------------- /src/server/database/models/ancillaryData.model.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryKey, Property } from "@mikro-orm/core"; 2 | import { types as MikroTypes } from "@mikro-orm/core"; 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/server/database/models/gpxTracks.model.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryKey, Property } from "@mikro-orm/core"; 2 | import { types as MikroTypes } from "@mikro-orm/core"; 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/models/mediaOverride.model.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryKey, Property } from "@mikro-orm/core"; 2 | import { types as MikroTypes } from "@mikro-orm/core"; 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/express/global.ts: -------------------------------------------------------------------------------- 1 | export const globalValues: GlobalValues = { 2 | socketio: null, 3 | serverSocketStatus: { 4 | visitorsData: [], 5 | }, 6 | ormCache: null, 7 | socketInterval: null, 8 | serverDataRefreshTimeouts: {}, 9 | }; 10 | -------------------------------------------------------------------------------- /src/server/express/routes/cache/clear.ts: -------------------------------------------------------------------------------- 1 | import { clearCacheByIdentifer, clearCacheByFolder } from "server/processing/cache-client"; 2 | import express, { Request, Response } from "express"; 3 | import { Query } from "express-serve-static-core"; 4 | 5 | /** 6 | * `/api/cache/clear` 7 | * 8 | * The folder name must match a CacheFolder enum key 9 | */ 10 | 11 | const router = express.Router(); 12 | 13 | const parseQuery = (query: Query) => { 14 | const { folder, identifier } = query; 15 | const queryObj = { 16 | folder: folder ? (folder as CacheFolder) : undefined, 17 | identifier: identifier ? (identifier as string) : undefined, 18 | }; 19 | return queryObj; 20 | }; 21 | 22 | // get 23 | router.get("/", async (req: Request, res: Response): Promise => { 24 | const queryObj = parseQuery(req.query); 25 | try { 26 | if (typeof queryObj.folder === "undefined") { 27 | res.status(200).json({ success: false, error: "invalid folder specified" }); 28 | return; 29 | } else { 30 | if (queryObj.identifier) { 31 | await clearCacheByIdentifer(queryObj.identifier, queryObj.folder); 32 | res.status(200).json({ success: true }); 33 | return; 34 | } else { 35 | await clearCacheByFolder(queryObj.folder); 36 | res.status(200).json({ success: true }); 37 | return; 38 | } 39 | } 40 | } catch (e) { 41 | console.error(e); 42 | res.status(500).json({ error: e.toString() }); 43 | return; 44 | } 45 | }); 46 | 47 | export default router; 48 | -------------------------------------------------------------------------------- /src/server/express/routes/cache/clearAll.ts: -------------------------------------------------------------------------------- 1 | import { clearAll } from "server/processing/cache-client"; 2 | import express, { Request, Response } from "express"; 3 | 4 | /** 5 | * `/api/cache/clearAll` 6 | * 7 | * Nuke everything in the cache 8 | */ 9 | const router = express.Router(); 10 | 11 | // get 12 | router.get("/", async (req: Request, res: Response): Promise => { 13 | try { 14 | await clearAll(); 15 | res.status(200).json({ success: true }); 16 | return; 17 | } catch (e) { 18 | console.error(e); 19 | res.status(500).json({ success: false, error: e.toString() }); 20 | return; 21 | } 22 | }); 23 | 24 | export default router; 25 | -------------------------------------------------------------------------------- /src/server/express/routes/daynight/daynight.ts: -------------------------------------------------------------------------------- 1 | import getDayNight from "server/processing/daynight/daynight"; 2 | import express, { Request, Response } from "express"; 3 | import { Query } from "express-serve-static-core"; 4 | 5 | /** 6 | * `/api/v1/daynight/daynight?dateWanted=2021-01-01 7 | * 8 | * Get day night data 9 | */ 10 | const router = express.Router(); 11 | 12 | const parseQuery = (query: Query): DayNightQueryParams => { 13 | // add support for year month date query params for Maestro 14 | // remove when Maestro is updated to use dateWanted 15 | const { dateWanted, forceNew, dayNightSource, year, month, date } = query; 16 | const queryObj: DayNightQueryParams = { 17 | dateWanted: dateWanted as string, 18 | forceNew: forceNew === "true", 19 | dayNightSource: dayNightSource ? (dayNightSource as string) : undefined, 20 | year: year ? parseInt(year as string) : undefined, 21 | month: month ? parseInt(month as string) : undefined, 22 | date: date ? parseInt(date as string) : undefined, 23 | }; 24 | return queryObj; 25 | }; 26 | 27 | // get 28 | router.get("/", async (req: Request, res: Response): Promise => { 29 | const queryObj = parseQuery(req.query); 30 | try { 31 | // add support for year month date query params for Maestro 32 | // remove when Maestro is updated to use dateWanted 33 | if (queryObj.year && queryObj.month && queryObj.date) { 34 | const data = await getDayNight({ 35 | dateWanted: `${queryObj.year}-${queryObj.month}-${queryObj.date}`, 36 | forceNew: queryObj.forceNew, 37 | dayNightSource: queryObj.dayNightSource, 38 | }); 39 | res.status(200).json(data); 40 | } else { 41 | const data = await getDayNight({ 42 | dateWanted: queryObj.dateWanted, 43 | forceNew: queryObj.forceNew, 44 | dayNightSource: queryObj.dayNightSource, 45 | }); 46 | res.status(200).json(data); 47 | } 48 | return; 49 | } catch (e) { 50 | console.error(e); 51 | res.status(400).json({ error: e.toString() }); 52 | return; 53 | } 54 | }); 55 | 56 | export default router; 57 | -------------------------------------------------------------------------------- /src/server/express/routes/emss/sgAudio.ts: -------------------------------------------------------------------------------- 1 | import getLabsSgAudio from "server/processing/emss/sgAudio"; 2 | import express, { Request, Response } from "express"; 3 | import { Query } from "express-serve-static-core"; 4 | 5 | const router = express.Router(); 6 | 7 | const parseQuery = (query: Query): GetSgAudioQueryParams => { 8 | const { dateWanted, source, forceNew } = query; 9 | const queryObj: GetSgAudioQueryParams = { 10 | dateWanted: dateWanted as string, 11 | source: source ? (source as Source) : undefined, 12 | forceNew: forceNew === "true", 13 | }; 14 | return queryObj; 15 | }; 16 | 17 | // get 18 | router.get("/", async (req: Request, res: Response): Promise => { 19 | const queryObj = parseQuery(req.query); 20 | try { 21 | const response = await getLabsSgAudio({ 22 | dateWanted: queryObj.dateWanted, 23 | source: queryObj.source, 24 | forceNew: queryObj.forceNew, 25 | }); 26 | res.status(200).json(response); 27 | return; 28 | } catch (e) { 29 | console.error(e); 30 | res.status(400).json({ error: e.toString() }); 31 | return; 32 | } 33 | }); 34 | 35 | export default router; 36 | -------------------------------------------------------------------------------- /src/server/express/routes/emss/transcripts.ts: -------------------------------------------------------------------------------- 1 | import getTranscripts from "server/processing/emss/transcript"; 2 | import express, { Request, Response } from "express"; 3 | import { Query } from "express-serve-static-core"; 4 | 5 | const router = express.Router(); 6 | 7 | const parseQuery = (query: Query): GetTranscriptsQueryParams => { 8 | const { dateWanted, source, forceNew } = query; 9 | const queryObj: GetTranscriptsQueryParams = { 10 | dateWanted: dateWanted as string, 11 | source: source ? (source as Source) : undefined, 12 | forceNew: forceNew === "true", 13 | }; 14 | return queryObj; 15 | }; 16 | 17 | // get 18 | router.get("/", async (req: Request, res: Response): Promise => { 19 | const queryObj = parseQuery(req.query); 20 | try { 21 | const transcript = await getTranscripts({ 22 | dateWanted: queryObj.dateWanted, 23 | source: queryObj.source, 24 | forceNew: queryObj.forceNew, 25 | }); 26 | res.status(200).json(transcript); 27 | return; 28 | } catch (e) { 29 | console.error(e); 30 | res.status(400).json({ error: e.toString() }); 31 | return; 32 | } 33 | }); 34 | 35 | export default router; 36 | -------------------------------------------------------------------------------- /src/server/express/routes/location/iss.ts: -------------------------------------------------------------------------------- 1 | import getEphemera from "server/processing/location/iss"; 2 | import express, { Request, Response } from "express"; 3 | import { Query } from "express-serve-static-core"; 4 | 5 | /** 6 | * `/api/v1/location/iss?year=yyyy&month=mm&date=dd` 7 | * 8 | * Get iss location using ephermis data (spacetrack or celestrak) 9 | */ 10 | 11 | const router = express.Router(); 12 | 13 | const parseQuery = (query: Query): GetEphemerisQueryParams => { 14 | const { dateWanted, forceNew } = query; 15 | const queryObj: GetEphemerisQueryParams = { 16 | dateWanted: dateWanted as string, 17 | forceNew: forceNew === "true", 18 | }; 19 | return queryObj; 20 | }; 21 | 22 | // get 23 | router.get("/", async (req: Request, res: Response): Promise => { 24 | const queryObj = parseQuery(req.query); 25 | try { 26 | const data = await getEphemera({ 27 | dateWanted: queryObj.dateWanted, 28 | forceNew: queryObj.forceNew, 29 | }); 30 | res.status(200).json(data); 31 | return; 32 | } catch (e) { 33 | console.error(e); 34 | res.status(400).json({ error: e.toString() }); 35 | return; 36 | } 37 | }); 38 | 39 | export default router; 40 | -------------------------------------------------------------------------------- /src/server/express/routes/maestro/executeTimelineStatus.ts: -------------------------------------------------------------------------------- 1 | import getMaestroExecuteTimelineStatus from "server/processing/maestro/maestro"; 2 | import express, { Request, Response } from "express"; 3 | import { Query } from "express-serve-static-core"; 4 | 5 | const router = express.Router(); 6 | 7 | const parseQuery = (query: Query): GetMaestroExecuteTimelineStatusQueryParams => { 8 | const { uuid } = query; 9 | const queryObj: GetMaestroExecuteTimelineStatusQueryParams = { 10 | uuid: uuid ? (uuid as string) : undefined, 11 | }; 12 | return queryObj; 13 | }; 14 | 15 | // get 16 | router.get("/", async (req: Request, res: Response): Promise => { 17 | const queryObj = parseQuery(req.query); 18 | try { 19 | const response = await getMaestroExecuteTimelineStatus(queryObj.uuid); 20 | res.status(200).json(response); 21 | return; 22 | } catch (e) { 23 | console.error(e); 24 | res.status(400).json({ error: e.toString() }); 25 | return; 26 | } 27 | }); 28 | 29 | export default router; 30 | -------------------------------------------------------------------------------- /src/server/express/routes/media/photos.ts: -------------------------------------------------------------------------------- 1 | import getPhotoData from "server/processing/media/photos"; 2 | import express, { Request, Response } from "express"; 3 | import { Query } from "express-serve-static-core"; 4 | 5 | /** 6 | * `/api/media/photos` 7 | * 8 | * Get IO photo data proxied through our API 9 | */ 10 | 11 | const router = express.Router(); 12 | 13 | const parseQuery = (query: Query): GetPhotosQueryParams => { 14 | const { dateWanted, source, forceNew } = query; 15 | const queryObj: GetPhotosQueryParams = { 16 | dateWanted: dateWanted as string, 17 | source: source ? (source as Source) : undefined, 18 | forceNew: forceNew === "true", 19 | }; 20 | return queryObj; 21 | }; 22 | 23 | // get 24 | router.get("/", async (req: Request, res: Response): Promise => { 25 | const queryObj = parseQuery(req.query); 26 | try { 27 | const photos = await getPhotoData({ 28 | dateWanted: queryObj.dateWanted, 29 | source: queryObj.source, 30 | forceNew: queryObj.forceNew, 31 | }); 32 | res.status(200).json(photos); 33 | return; 34 | } catch (e) { 35 | console.error(e); 36 | res.status(400).json({ error: e.toString() }); 37 | return; 38 | } 39 | }); 40 | 41 | export default router; 42 | -------------------------------------------------------------------------------- /src/server/express/routes/media/videoMediaMtx.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from "express"; 2 | import { Query } from "express-serve-static-core"; 3 | import { fetchMTXAPIResponses } from "server/services/emss"; 4 | 5 | const router = express.Router(); 6 | 7 | const parseQuery = (query: Query): GetMTXPlaybackQueryParams => { 8 | const { source, forceNew } = query; 9 | const queryObj: GetMTXPlaybackQueryParams = { 10 | dateWanted: query.dateWanted as string, 11 | source: source ? (source as Source) : undefined, 12 | forceNew: forceNew === "true", 13 | }; 14 | return queryObj; 15 | }; 16 | 17 | // get 18 | router.get("/", async (req: Request, res: Response): Promise => { 19 | const queryObj = parseQuery(req.query); 20 | try { 21 | const response = await fetchMTXAPIResponses({ 22 | dateWanted: queryObj.dateWanted, 23 | source: queryObj.source, 24 | forceNew: queryObj.forceNew, 25 | }); 26 | res.status(200).json(response); 27 | return; 28 | } catch (e) { 29 | console.error(e); 30 | res.status(400).json({ error: e.toString() }); 31 | return; 32 | } 33 | }); 34 | 35 | export default router; 36 | -------------------------------------------------------------------------------- /src/server/express/routes/media/videos.ts: -------------------------------------------------------------------------------- 1 | import getVideoData from "server/processing/media/videos"; 2 | import express, { Request, Response } from "express"; 3 | import { Query } from "express-serve-static-core"; 4 | 5 | /** 6 | * `/api/media/videos` 7 | * 8 | * Get IO photo data proxied through our API 9 | */ 10 | 11 | const router = express.Router(); 12 | 13 | const parseQuery = (query: Query): GetVideosQueryParams => { 14 | const { dateWanted, source, forceNew } = query; 15 | const queryObj: GetVideosQueryParams = { 16 | dateWanted: dateWanted as string, 17 | source: source as Source, 18 | forceNew: forceNew === "true", 19 | }; 20 | return queryObj; 21 | }; 22 | 23 | // get 24 | router.get("/", async (req: Request, res: Response): Promise => { 25 | const queryObj = parseQuery(req.query); 26 | try { 27 | const videos = await getVideoData({ 28 | dateWanted: queryObj.dateWanted, 29 | source: queryObj.source, 30 | forceNew: queryObj.forceNew, 31 | }); 32 | res.status(200).json(videos); 33 | return; 34 | } catch (e) { 35 | console.error(e); 36 | res.status(400).json({ error: e.toString() }); 37 | return; 38 | } 39 | }); 40 | 41 | export default router; 42 | -------------------------------------------------------------------------------- /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 { onlyEmssSuperuser } from "../user/auth"; 8 | 9 | const router = express.Router(); 10 | 11 | router.get("/", async (req, res) => { 12 | const user = onlyEmssSuperuser(req); 13 | if (!user) { 14 | res.status(401).send({ error: "not authorized" }); 15 | return; 16 | } 17 | 18 | expressProfilingUI(res); 19 | }); 20 | 21 | router.post("/start", async (req, res) => { 22 | const user = onlyEmssSuperuser(req); 23 | if (!user) { 24 | res.status(401).send({ error: "not authorized" }); 25 | return; 26 | } 27 | 28 | await expressProfilingStart(res, user); 29 | }); 30 | 31 | router.post("/stop", async (req, res) => { 32 | const user = onlyEmssSuperuser(req); 33 | if (!user) { 34 | res.status(401).send({ error: "not authorized" }); 35 | return; 36 | } 37 | 38 | await expressProfilingStop(res, user); 39 | }); 40 | 41 | export default router; 42 | -------------------------------------------------------------------------------- /src/server/express/routes/sequences/evas.ts: -------------------------------------------------------------------------------- 1 | import getEVAData from "server/processing/sequences/evas"; 2 | import express, { Request, Response } from "express"; 3 | import { Query } from "express-serve-static-core"; 4 | 5 | /** 6 | * `/api/sequences/evas` 7 | * 8 | * Query Params: 9 | * agency=us|rs|all - default us 10 | * if 'us', only US EVAs. if 'rs', only RS EVAs. if 'all', all EVAs 11 | * 12 | * Get all as-planned EVA data in the wiki 13 | */ 14 | 15 | const router = express.Router(); 16 | 17 | const parseQuery = (query: Query): GetSequencesAllEvasQueryParams => { 18 | const { agency, forceNew } = query; 19 | const queryObj: GetSequencesAllEvasQueryParams = { 20 | agency: agency ? (agency as AgencyQuery) : undefined, 21 | forceNew: forceNew === "true", 22 | }; 23 | return queryObj; 24 | }; 25 | 26 | // get 27 | router.get("/", async (req: Request, res: Response): Promise => { 28 | const queryObj = parseQuery(req.query); 29 | try { 30 | const evas = await getEVAData(queryObj.agency, queryObj.forceNew); 31 | res.status(200).json(evas); 32 | return; 33 | } catch (e) { 34 | res.status(400).json({ error: e.toString() }); 35 | return; 36 | } 37 | }); 38 | 39 | export default router; 40 | -------------------------------------------------------------------------------- /src/server/express/routes/sequences/graphs.ts: -------------------------------------------------------------------------------- 1 | import getGraphManifest from "server/processing/sequences/graph"; 2 | import express, { Request, Response } from "express"; 3 | import { Query } from "express-serve-static-core"; 4 | 5 | /** 6 | * Get graph manifest for specific date 7 | */ 8 | 9 | const router = express.Router(); 10 | 11 | const parseQuery = (query: Query): GetGraphsManifestQueryParams => { 12 | const { dateWanted, source } = query; 13 | const queryObj: GetGraphsManifestQueryParams = { 14 | dateWanted: dateWanted as string, 15 | source: source ? (source as Source) : undefined, 16 | }; 17 | return queryObj; 18 | }; 19 | 20 | // get 21 | router.get("/", async (req: Request, res: Response): Promise => { 22 | const queryObj = parseQuery(req.query); 23 | try { 24 | const data = await getGraphManifest({ 25 | dateWanted: queryObj.dateWanted, 26 | source: queryObj.source, 27 | }); 28 | res.status(200).json(data); 29 | return; 30 | } catch (e) { 31 | console.error(e); 32 | res.status(400).json({ error: e.toString() }); 33 | return; 34 | } 35 | }); 36 | 37 | export default router; 38 | -------------------------------------------------------------------------------- /src/server/express/routes/sequences/test-events.ts: -------------------------------------------------------------------------------- 1 | import getTestEventsData from "server/processing/sequences/test-events"; 2 | import express, { Request, Response } from "express"; 3 | import { Query } from "express-serve-static-core"; 4 | /** 5 | * `/api/sequences/rock-yard` 6 | * 7 | * Get all as-planned test event data in the wiki 8 | */ 9 | 10 | const router = express.Router(); 11 | 12 | const parseQuery = (query: Query): GetSequencesTestEventsQueryParams => { 13 | const { forceNew } = query; 14 | const queryObj: GetSequencesTestEventsQueryParams = { 15 | forceNew: forceNew === "true", 16 | }; 17 | return queryObj; 18 | }; 19 | 20 | // get 21 | router.get("/", async (req: Request, res: Response): Promise => { 22 | const queryObj = parseQuery(req.query); 23 | try { 24 | const testEvents = await getTestEventsData({ forceNew: queryObj.forceNew }); 25 | res.status(200).json(testEvents); 26 | return; 27 | } catch (e) { 28 | console.error(e); 29 | res.status(400).json({ error: e.toString() }); 30 | return; 31 | } 32 | }); 33 | 34 | export default router; 35 | -------------------------------------------------------------------------------- /src/server/express/routes/socketStatus/socketStatus.ts: -------------------------------------------------------------------------------- 1 | import { asError } from "@emss/utils"; 2 | import express, { Request, Response } from "express"; 3 | import { getUser } from "packages/getUser"; 4 | import { globalValues } from "server/express/global"; 5 | import serverLogger from "utils/serverLogger"; 6 | import { isSuperuser } from "utils/user"; 7 | 8 | /** 9 | * `/api/v1/socketStatus` 10 | * 11 | * Get server socket status 12 | */ 13 | 14 | const router = express.Router(); 15 | 16 | // get 17 | router.get("/", async (req: Request, res: Response): Promise => { 18 | try { 19 | const user = getUser(req); 20 | if (user instanceof Error) { 21 | const msg = "Unable to decode JWT"; 22 | serverLogger.error(user, { logId: msg }); 23 | res.status(500).send({ msg }); 24 | return; 25 | } 26 | 27 | if (!isSuperuser(user)) { 28 | serverLogger.warn({ logId: "Unauthorized access to socketStatus route" }, user); 29 | res.status(403).send({ msg: "Unauthorized" }); 30 | return; 31 | } 32 | 33 | res.status(200).json(globalValues.serverSocketStatus); 34 | return; 35 | } catch (e) { 36 | serverLogger.error(asError(e), { logId: "error in socketStatus route" }); 37 | res.status(400).json({ error: e.toString() }); 38 | return; 39 | } 40 | }); 41 | 42 | export default router; 43 | -------------------------------------------------------------------------------- /src/server/express/routes/user/auth.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from "express"; 2 | import { getUser } from "packages/getUser"; 3 | import serverLogger from "utils/serverLogger"; 4 | import { isSuperuser } from "utils/user"; 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 | console.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 | console.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 | 42 | export const onlyEmssSuperuser = (req: Request): EmssUser | false => { 43 | const user = getUser(req); 44 | if (user instanceof Error) { 45 | const msg = "Unable to decode JWT"; 46 | console.error(msg, user); 47 | return false; // auth error, don't allow 48 | } 49 | if (isSuperuser(user)) { 50 | return user; 51 | } 52 | return false; 53 | }; 54 | -------------------------------------------------------------------------------- /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/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/server/processing/daynight/daynight.ts: -------------------------------------------------------------------------------- 1 | import * as DayNightService from "server/services/daynight-api"; 2 | 3 | export default async function getDayNight({ 4 | dateWanted, 5 | forceNew = false, 6 | dayNightSource = undefined, 7 | }: { 8 | dateWanted: string; 9 | forceNew?: boolean; 10 | dayNightSource?: string; 11 | }): Promise> { 12 | const [year, month, date] = dateWanted.split("-").map((x) => parseInt(x, 10)); 13 | return await DayNightService.fetchDayNight(year, month, date, forceNew, dayNightSource); 14 | } 15 | -------------------------------------------------------------------------------- /src/server/processing/emss/sgAudio.ts: -------------------------------------------------------------------------------- 1 | import * as LabsService from "server/services/emss"; 2 | import * as DbService from "server/services/db-api"; 3 | 4 | export default async function getLabsSgAudio({ 5 | source, 6 | dateWanted, 7 | forceNew, 8 | }: { 9 | source: Source; 10 | dateWanted: string; //yy-mm-dd 11 | forceNew: boolean; 12 | }): Promise> { 13 | const requestedDate = new Date(dateWanted); 14 | 15 | // Fetch source overrides from the wiki for this date. If there are none, then use Imagery Online 16 | try { 17 | let mediaOverrides = await DbService.fetchMediaOverrides(); 18 | 19 | // Check if there is a media override for this date and Source 20 | const mediaOverride = mediaOverrides?.find((vo) => { 21 | const overrideDate = new Date(vo.date); 22 | return ( 23 | overrideDate.getTime() === requestedDate.getTime() && 24 | vo.source === source && 25 | vo.type === "audio" 26 | ); 27 | }); 28 | 29 | // if there are media overrides, use those instead of labs 30 | if (mediaOverride) { 31 | return LabsService.fetchLabsSGAudio({ 32 | source, 33 | dateWanted, 34 | overrideBaseUrl: mediaOverride.url, 35 | forceNew, 36 | }); 37 | } 38 | } catch (e) { 39 | // don't block results if media overrides call fails 40 | console.error(e); 41 | } 42 | 43 | // labs audio and transcription was turned off around late October. Only grab from TB after this date 44 | // to avoid messy merging of labs and talkybot transcripts 45 | if (new Date(dateWanted).getTime() < new Date("2024-10-21T00:00:00").getTime()) { 46 | return LabsService.fetchLabsAndTalkybotSGAudio({ source, dateWanted, forceNew }); 47 | } else { 48 | const res: SgActivityFullUrlRecord = await LabsService.fetchTalkybotSGAudio({ 49 | source, 50 | dateWanted, 51 | }); 52 | // wrap the response 53 | return { 54 | responseMetadata: { 55 | retrieverStatus: "complete", 56 | cachedTimestamp: new Date().toISOString(), 57 | expiration: null, 58 | error: "", 59 | retrieverErrorCount: 0, 60 | lastErrorTimestamp: null, 61 | }, 62 | data: res, 63 | source: "talky-bot", 64 | }; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/server/processing/emss/transcript.ts: -------------------------------------------------------------------------------- 1 | import * as LabsService from "server/services/emss"; 2 | import * as DbService from "server/services/db-api"; 3 | 4 | export default async function getTranscripts({ 5 | source, 6 | dateWanted, 7 | forceNew, 8 | }: { 9 | source: Source; 10 | dateWanted: string; //yy-mm-dd 11 | forceNew: boolean; 12 | }): Promise> { 13 | const requestedDate = new Date(dateWanted); 14 | 15 | // Fetch source overrides from the wiki for this date. If there are none, then use Imagery Online 16 | try { 17 | let mediaOverrides = await DbService.fetchMediaOverrides(); 18 | 19 | // Check if there is a transcript override for this date and Source 20 | const mediaOverride = mediaOverrides?.find((vo) => { 21 | const overrideDate = new Date(vo.date); 22 | return ( 23 | overrideDate.getTime() === requestedDate.getTime() && 24 | vo.source === source && 25 | vo.type === "transcript" 26 | ); 27 | }); 28 | 29 | // if there are media overrides, use those instead of labs 30 | if (mediaOverride) { 31 | return LabsService.fetchLabsTranscripts({ 32 | source, 33 | dateWanted, 34 | overrideBaseUrl: mediaOverride.url, 35 | forceNew, 36 | }); 37 | } 38 | } catch (e) { 39 | // don't block results if media overrides call fails 40 | console.error(e); 41 | } 42 | 43 | // labs audio and transcription was turned off around late October. Only grab from TB after this date 44 | // to avoid messy merging of labs and talkybot transcripts 45 | if (new Date(dateWanted).getTime() < new Date("2024-10-21T00:00:00").getTime()) { 46 | return LabsService.fetchLabsAndTalkybotTranscripts({ source, dateWanted, forceNew }); 47 | } else { 48 | const res: UnprocessedTranscript[] = await LabsService.fetchTalkybotTranscripts({ 49 | source, 50 | dateWanted, 51 | }); 52 | // wrap the response 53 | return { 54 | responseMetadata: { 55 | retrieverStatus: "complete", 56 | cachedTimestamp: new Date().toISOString(), 57 | expiration: null, 58 | error: "", 59 | retrieverErrorCount: 0, 60 | lastErrorTimestamp: null, 61 | }, 62 | data: res, 63 | source: "talky-bot", 64 | }; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/server/processing/location/iss.ts: -------------------------------------------------------------------------------- 1 | import * as EphemeraService from "server/services/ephemera-api"; 2 | 3 | export default async function getEphemera({ 4 | dateWanted, 5 | forceNew, 6 | }: { 7 | dateWanted: string; 8 | forceNew: boolean; 9 | }): Promise> { 10 | const [year, month, date] = dateWanted.split("-").map((x) => parseInt(x, 10)); 11 | return await EphemeraService.fetchISSLocation(year, month, date, forceNew); 12 | } 13 | -------------------------------------------------------------------------------- /src/server/processing/maestro/maestro.ts: -------------------------------------------------------------------------------- 1 | import * as Maestro from "server/services/maestro"; 2 | 3 | export default async function getMaestroExecuteTimelineStatus( 4 | executeEventUuid: string 5 | ): Promise> { 6 | return Maestro.fetchMaestroExecuteTimelineStatus(executeEventUuid); 7 | } 8 | -------------------------------------------------------------------------------- /src/server/processing/sequences/evas.ts: -------------------------------------------------------------------------------- 1 | import * as WikiService from "server/services/wiki-api"; 2 | 3 | export default async function getEVAData( 4 | agency: AgencyQuery, 5 | forceNew: boolean 6 | ): Promise> { 7 | const response = await WikiService.getAllEVAData(agency, forceNew); 8 | if (!response.data) { 9 | // Return an empty array if there's no last known good data. 10 | // This is neede because the front-end can't deal with null. 11 | return { ...response, data: [] }; 12 | } 13 | return response; 14 | } 15 | 16 | export async function getISSEvaData({ 17 | // ignore source and dateWanted. We only have those parameters set to make this function compatible with the other socket fetch functions. 18 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 19 | dateWanted, 20 | forceNew, 21 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 22 | source, 23 | }: { 24 | dateWanted: string; 25 | forceNew: boolean; 26 | source?: string; 27 | }): Promise> { 28 | const response = await WikiService.getAllEVAData("all", forceNew); 29 | if (!response.data) { 30 | // Return an empty array if there's no last known good data. 31 | // This is neede because the front-end can't deal with null. 32 | return { ...response, data: [] }; 33 | } 34 | return response; 35 | } 36 | -------------------------------------------------------------------------------- /src/server/processing/sequences/graph.ts: -------------------------------------------------------------------------------- 1 | import * as AncillaryService from "server/services/graphs"; 2 | 3 | export default async function getGraphManifest({ 4 | source, 5 | dateWanted, 6 | }: { 7 | source: Source; 8 | dateWanted: string; 9 | }): Promise> { 10 | const results = await AncillaryService.fetchGraphsManifest(source, dateWanted); 11 | return results; 12 | } 13 | -------------------------------------------------------------------------------- /src/server/processing/sequences/test-events.ts: -------------------------------------------------------------------------------- 1 | import * as WikiService from "server/services/wiki-api"; 2 | 3 | export default async function getTestEventsData({ 4 | // ignore source and dateWanted. We only have those parameters set to make this function compatible with the other socket fetch functions. 5 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 6 | dateWanted, 7 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 8 | source, 9 | forceNew, 10 | }: { 11 | dateWanted?: string; 12 | source?: Source; 13 | forceNew: boolean; 14 | }): Promise> { 15 | const response = await WikiService.getAllTestEventsData(forceNew); 16 | if (!response.data) { 17 | // Return an empty array if there's no last known good data. 18 | // This is neede because the front-end can't deal with null. 19 | return { ...response, data: [] }; 20 | } 21 | return response; 22 | } 23 | -------------------------------------------------------------------------------- /src/server/services/daynight-api.spec.ts: -------------------------------------------------------------------------------- 1 | import { getTopoURL } from "server/services/daynight-api"; 2 | 3 | describe("function getTopoURL()", () => { 4 | const now = new Date(); 5 | it("should return out of range future", () => { 6 | const requestDate = new Date( 7 | Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 3, now.getUTCDate()) 8 | ); 9 | expect(getTopoURL(requestDate).url).toBeNull(); 10 | expect(getTopoURL(requestDate).state).toEqual("outOfRange_predicted"); 11 | }); 12 | it("should return out of range past", () => { 13 | const requestDate = new Date(Date.UTC(2000, 1, 1)); 14 | expect(getTopoURL(requestDate).url).toBeNull(); 15 | expect(getTopoURL(requestDate).state).toEqual("outOfRange_historic"); 16 | }); 17 | it("should return predicted url", () => { 18 | const requestDate = new Date( 19 | Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 7) 20 | ); 21 | let url = getTopoURL(requestDate).url; 22 | expect(url.includes("/data/stp/")).toBeTruthy(); 23 | expect(getTopoURL(requestDate).state).toEqual("predicted"); 24 | }); 25 | it("should return historic (best estimated trajectory) url", () => { 26 | const requestDate = new Date( 27 | Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 2, now.getUTCDate()) 28 | ); 29 | let url = getTopoURL(requestDate).url; 30 | expect(url.includes("/data/bet/")).toBeTruthy(); 31 | expect(getTopoURL(requestDate).state).toEqual("historic"); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/server/services/db-api.ts: -------------------------------------------------------------------------------- 1 | import fetchWithTimeout from "utils/fetch-with-timeout"; 2 | import { getMediaOverridesList } from "../express/routes/db/mediaOverrides"; 3 | import { getAncillaryDataSourceList } from "../express/routes/db/ancillaryDataSources"; 4 | import { getVideoStartTimeOverridesRecordsList } from "server/express/routes/db/video"; 5 | import { getPhotoTimeshiftRecordsList } from "server/express/routes/db/photos"; 6 | 7 | /** 8 | * Fetch override video manifest from the override location specified in the db 9 | */ 10 | export async function getManifest( 11 | override: MediaOverride 12 | ): Promise { 13 | const dataPath = `${override.url}/${override.type}Manifest.json`; 14 | 15 | let res: Response; 16 | try { 17 | res = await fetchWithTimeout(dataPath); 18 | } catch (e) { 19 | throw e; 20 | } 21 | return res.json() as Promise; 22 | } 23 | 24 | /** Get all the manually set media source overrides. 25 | * 26 | * Data lives here: https://wiki.jsc.nasa.gov/exploration/index.php/CODA/Media_Source_Overrides 27 | */ 28 | export async function fetchMediaOverrides(): Promise { 29 | const mediaOverridesList = await getMediaOverridesList(); 30 | 31 | return mediaOverridesList as MediaOverride[]; 32 | } 33 | 34 | /** Get the list of ancillary data sources from the db 35 | * 36 | * Data lives here: https://wiki.jsc.nasa.gov/exploration/index.php/CODA/Ancillary_Data_Sources 37 | */ 38 | export async function fetchAncillaryDataSourceList(): Promise { 39 | const ancillaryDataSourceList = await getAncillaryDataSourceList(); 40 | 41 | return ancillaryDataSourceList as AncillaryDataSource[]; 42 | } 43 | 44 | /** Get all the manually set shifts for fixing datetimes. 45 | * 46 | * Data lives here: https://wiki.jsc.nasa.gov/exploration/index.php/CODA/Datetime_Shifts 47 | */ 48 | export async function fetchVideoDateTimeOverrides(): Promise { 49 | return await getVideoStartTimeOverridesRecordsList(); 50 | } 51 | 52 | export async function fetchPhotoDateTimeOverrides(): Promise { 53 | return await getPhotoTimeshiftRecordsList(); 54 | } 55 | -------------------------------------------------------------------------------- /src/server/services/ephemera-api.spec.ts: -------------------------------------------------------------------------------- 1 | import * as EphemeraService from "server/services/ephemera-api"; 2 | import fetchWithCache from "server/processing/cache-client"; 3 | 4 | jest.mock("server/processing/cache-client"); 5 | const fetchMock = fetchWithCache as jest.MockedFunction; 6 | 7 | describe("server/services/ephemera-api", () => { 8 | beforeEach(() => { 9 | fetchMock.mockClear(); 10 | }); 11 | 12 | it("should fetch locations from spacetrack", async () => { 13 | await EphemeraService.fetchISSLocation(2000, 1, 1); 14 | 15 | expect(fetchMock).toHaveBeenCalledTimes(1); 16 | }); 17 | 18 | it("should throw if an actual error is thrown by the retriever", async () => { 19 | fetchMock.mockRejectedValue(new Error("you done messed up")); 20 | 21 | let erred = false; 22 | try { 23 | await EphemeraService.fetchISSLocation(2000, 1, 1); 24 | } catch (e) { 25 | erred = true; 26 | } 27 | 28 | expect(erred).toBeTruthy(); 29 | expect(fetchMock).toHaveBeenCalledTimes(1); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/server/services/graphs.ts: -------------------------------------------------------------------------------- 1 | import * as DbService from "server/services/db-api"; 2 | import fetchWithTimeout from "utils/fetch-with-timeout"; 3 | 4 | export const fetchGraphsManifest = async ( 5 | source: Source, 6 | dateWanted: string 7 | ): Promise> => { 8 | const ancillaryDataSources = await DbService.fetchAncillaryDataSourceList(); 9 | 10 | // Check if there is a video override for this date and Source 11 | const ancillaryDataSource = ancillaryDataSources?.find((vo) => { 12 | const overrideDate = new Date(vo.date); 13 | const requestedDate = new Date(dateWanted); 14 | return ( 15 | overrideDate.getTime() === requestedDate.getTime() && 16 | vo.source === source && 17 | vo.type === "graphs" 18 | ); 19 | }); 20 | 21 | if (ancillaryDataSource) { 22 | // Get the graph manifest json from the url in the wiki 23 | let graphManifest: GraphsManifest = null; 24 | try { 25 | const res = await fetchWithTimeout(ancillaryDataSource.url); 26 | graphManifest = (await res.json()) as GraphsManifest; 27 | } catch (e) { 28 | return { 29 | responseMetadata: { 30 | retrieverStatus: "complete", 31 | cachedTimestamp: new Date().toISOString(), 32 | expiration: null, 33 | error: null, 34 | retrieverErrorCount: 0, 35 | lastErrorTimestamp: null, 36 | }, 37 | data: null, 38 | }; 39 | } 40 | return { 41 | responseMetadata: { 42 | retrieverStatus: "complete", 43 | cachedTimestamp: new Date().toISOString(), 44 | expiration: null, 45 | error: null, 46 | retrieverErrorCount: 0, 47 | lastErrorTimestamp: null, 48 | }, 49 | data: graphManifest, 50 | }; 51 | } 52 | 53 | return { 54 | responseMetadata: { 55 | retrieverStatus: "complete", 56 | cachedTimestamp: new Date().toISOString(), 57 | expiration: null, 58 | error: null, 59 | retrieverErrorCount: 0, 60 | lastErrorTimestamp: null, 61 | }, 62 | data: null, 63 | }; 64 | }; 65 | -------------------------------------------------------------------------------- /src/store/contextProviders/_CombinedProviders.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { PlayheadContextProvider } from "store/contextProviders/playheadContext"; 3 | import { HoverPlayheadContextProvider } from "store/contextProviders/hoverPlayheadContext"; 4 | 5 | import { composeProviders } from "utils/context"; 6 | 7 | const providers = [HoverPlayheadContextProvider, PlayheadContextProvider]; 8 | 9 | // Define the props type to include 'children' 10 | const CombinedProviders = ({ children }: { children: React.ReactNode }) => { 11 | const Combined = composeProviders(providers); 12 | return {children}; 13 | }; 14 | 15 | export default CombinedProviders; 16 | -------------------------------------------------------------------------------- /src/store/contextProviders/hoverPlayheadContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, JSX, ReactNode, useContext, useState } from "react"; 2 | 3 | // Create the context 4 | const HoverPlayheadCtx = createContext(undefined); 5 | 6 | // Provider component 7 | export const HoverPlayheadContextProvider = ({ 8 | children, 9 | }: { 10 | children: ReactNode; 11 | }): JSX.Element => { 12 | const [hoverPlayhead, setHoverPlayhead] = useState({ 13 | hoverSeconds: null, 14 | }); 15 | 16 | return ( 17 | 18 | {children} 19 | 20 | ); 21 | }; 22 | 23 | // Custom hook for consuming the context 24 | export const useHoverPlayheadContext = (): HoverPlayheadContextType => { 25 | const context = useContext(HoverPlayheadCtx); 26 | if (!context) { 27 | throw new Error("usePlayheadContext must be used within a PlayheadContextProvider"); 28 | } 29 | return context; 30 | }; 31 | -------------------------------------------------------------------------------- /src/store/daynight.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | export const initialState: DayNightState = { 4 | dayNight: [], 5 | responseMetadata: null, 6 | loadingStatus: "loading", 7 | source: null, 8 | }; 9 | 10 | export const dayNightSlice = createSlice({ 11 | name: "daynight", 12 | initialState, 13 | reducers: { 14 | /** Add new day night to the store */ 15 | addDayNight: (state, action: { payload: WrappedResponse }) => { 16 | state.responseMetadata = { ...state.responseMetadata, ...action.payload.responseMetadata }; 17 | state.dayNight = action.payload.data.dayNight; 18 | state.source = action.payload.source; 19 | }, 20 | clearDayNight: (state) => { 21 | state.dayNight = []; 22 | state.responseMetadata = null; 23 | state.loadingStatus = "loading"; 24 | state.source = null; 25 | }, 26 | fetchError: (state, action: { payload: string }) => { 27 | state.responseMetadata = { ...state.responseMetadata, error: action.payload }; 28 | }, 29 | setDayNightLoadingStatus: (state, action: { payload: LoadingStatus }) => { 30 | state.loadingStatus = action.payload; 31 | }, 32 | }, 33 | }); 34 | 35 | export const { addDayNight, clearDayNight, fetchError, setDayNightLoadingStatus } = 36 | dayNightSlice.actions; 37 | -------------------------------------------------------------------------------- /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_LINE0: "0 ISS (ZARYA)", 8 | TLE_LINE1: "1 25544U 98067A 23117.99531396 .00019654 00000-0 34685-3 0 9993", 9 | TLE_LINE2: "2 25544 51.6402 217.2782 0005322 249.3970 274.7771 15.50368762394009", 10 | }, 11 | { 12 | EPOCH: "2023-04-27 17:24:06", 13 | TLE_LINE0: "0 ISS (ZARYA)", 14 | TLE_LINE1: "1 25544U 98067A 23117.72507036 .00019323 00000-0 34129-3 0 9994", 15 | TLE_LINE2: "2 25544 51.6396 218.6162 0005309 248.6829 206.1814 15.50357183393964", 16 | }, 17 | { 18 | EPOCH: "2023-04-27 12:51:43", 19 | TLE_LINE0: "0 ISS (ZARYA)", 20 | TLE_LINE1: "1 25544U 98067A 23117.53591650 .00019446 00000-0 34350-3 0 9990", 21 | TLE_LINE2: "2 25544 51.6406 219.5567 0005317 247.5469 230.8975 15.50349702393939", 22 | }, 23 | { 24 | EPOCH: "2023-04-27 07:05:57", 25 | TLE_LINE0: "0 ISS (ZARYA)", 26 | TLE_LINE1: "1 25544U 98067A 23117.29580829 .00021162 00000-0 37309-3 0 9994", 27 | TLE_LINE2: "2 25544 51.6386 220.7438 0005539 246.6442 330.8231 15.50344675393899", 28 | }, 29 | ]; 30 | 31 | test("returns the appropriate TLE string closest to the dateTimeWanted", () => { 32 | const dateTimeWanted = "2023-04-27T14:00:00Z"; 33 | const expectedTLE = `0 ISS (ZARYA) 34 | 1 25544U 98067A 23117.53591650 .00019446 00000-0 34350-3 0 9990 35 | 2 25544 51.6406 219.5567 0005317 247.5469 230.8975 15.50349702393939`; 36 | 37 | const result = getAppropriateTLE(ephemerisFiles, dateTimeWanted); 38 | 39 | expect(result).toBe(expectedTLE); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /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 | responseMetadata: null, 7 | loadingStatus: "loading", 8 | }; 9 | 10 | export const ephemeraSlice = createSlice({ 11 | name: "ephemera", 12 | initialState, 13 | reducers: { 14 | /** Add new photo files to the store */ 15 | addEphemera: (state, action: { payload: WrappedResponse }) => { 16 | state.ephemerisFiles = action.payload.data.ephemera; 17 | state.responseMetadata = { ...state.responseMetadata, ...action.payload.responseMetadata }; 18 | }, 19 | clearEphemera: (state) => { 20 | state.ephemerisFiles = []; 21 | state.responseMetadata = null; 22 | }, 23 | 24 | fetchError: (state, action: { payload: string }) => { 25 | state.responseMetadata = { ...state.responseMetadata, error: action.payload }; 26 | }, 27 | setEphemeraLoadingStatus: (state, action: { payload: LoadingStatus }) => { 28 | state.loadingStatus = action.payload; 29 | }, 30 | }, 31 | }); 32 | 33 | export const { addEphemera, clearEphemera, fetchError, setEphemeraLoadingStatus } = 34 | ephemeraSlice.actions; 35 | 36 | /** 37 | * Returns a Two-Line Element (TLE) from space-track.org that is closest to dateTimeWanted 38 | * @param ephemera 39 | * @param dateTimeWanted 40 | * @returns TLE string 41 | */ 42 | export function getAppropriateTLE(ephemera: EphemerisFile[], dateTimeWanted: string): string { 43 | let thisDateDiff; 44 | let lastDateDiff = -1; 45 | 46 | let tleObj = ephemera[0]; 47 | let mostRecentTLE = `${tleObj.TLE_LINE0} 48 | ${tleObj.TLE_LINE1} 49 | ${tleObj.TLE_LINE2}`; 50 | 51 | // chew through ephemiris data looking for the TLE closest to the timestamp of interest 52 | for (let i = 0; i < ephemera.length; i++) { 53 | thisDateDiff = Math.abs(diff(new Date(ephemera[i].EPOCH + "Z"), new Date(dateTimeWanted))); 54 | if (i !== 0 && thisDateDiff < lastDateDiff) { 55 | tleObj = ephemera[i]; 56 | mostRecentTLE = `${tleObj.TLE_LINE0} 57 | ${tleObj.TLE_LINE1} 58 | ${tleObj.TLE_LINE2}`; 59 | } 60 | lastDateDiff = thisDateDiff; 61 | } 62 | 63 | return mostRecentTLE; 64 | } 65 | -------------------------------------------------------------------------------- /src/store/gps.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | export const initialState: GPSState = { 4 | gpsTracks: [], 5 | responseMetadata: null, 6 | loadingStatus: "loading", 7 | }; 8 | 9 | export const gpsSlice = createSlice({ 10 | name: "gps", 11 | initialState, 12 | reducers: { 13 | /** Add new gps tracks to the store */ 14 | setGPSTracks: (state, action: { payload: WrappedResponse }) => { 15 | state.gpsTracks = action.payload.data; 16 | state.responseMetadata = { ...state.responseMetadata, ...action.payload.responseMetadata }; 17 | }, 18 | clearGPSTracks: (state) => { 19 | state.gpsTracks = []; 20 | state.responseMetadata = null; 21 | state.loadingStatus = "loading"; 22 | }, 23 | gpsFetchError: (state, action: { payload: string }) => { 24 | state.responseMetadata = { ...state.responseMetadata, error: action.payload }; 25 | }, 26 | setGpsLoadingStatus: (state, action: { payload: LoadingStatus }) => { 27 | state.loadingStatus = action.payload; 28 | }, 29 | }, 30 | }); 31 | 32 | export const { setGPSTracks, clearGPSTracks, gpsFetchError, setGpsLoadingStatus } = 33 | gpsSlice.actions; 34 | -------------------------------------------------------------------------------- /src/store/graphs.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | export const initialState: GraphsState = { 4 | graphsManifest: null, 5 | responseMetadata: null, 6 | loadingStatus: "loading", 7 | }; 8 | 9 | export const graphSlice = createSlice({ 10 | name: "graphs", 11 | initialState, 12 | reducers: { 13 | /** Add new graph manifest to the store */ 14 | setGraphsManifest: (state, action: { payload: WrappedResponse }) => { 15 | state.graphsManifest = action.payload.data; 16 | state.responseMetadata = { ...state.responseMetadata, ...action.payload.responseMetadata }; 17 | }, 18 | clearGraphsManifest: (state) => { 19 | state.graphsManifest = null; 20 | state.responseMetadata = null; 21 | state.loadingStatus = "loading"; 22 | }, 23 | setGraphsData: (state, action: { payload: { graphId: string; graphData: GraphData[] } }) => { 24 | const graph = state.graphsManifest?.graphs.find((g) => g.id === action.payload.graphId); 25 | graph.data = action.payload.graphData; 26 | state.graphsManifest.graphs = state.graphsManifest.graphs.map((stateGraph) => { 27 | if (stateGraph.id === graph.id) { 28 | return graph; 29 | } else { 30 | return stateGraph; 31 | } 32 | }); 33 | }, 34 | clearGraphsData: (state) => { 35 | if (!state.graphsManifest) return; 36 | state.graphsManifest.graphs = state.graphsManifest.graphs.map((stateGraph) => { 37 | return { ...stateGraph, data: null as GraphData[] | null }; 38 | }); 39 | }, 40 | graphsFetchError: (state, action: { payload: string }) => { 41 | state.responseMetadata = { ...state.responseMetadata, error: action.payload }; 42 | }, 43 | setGraphsLoadingStatus: (state, action: { payload: LoadingStatus }) => { 44 | state.loadingStatus = action.payload; 45 | }, 46 | }, 47 | }); 48 | 49 | export const { 50 | setGraphsManifest, 51 | clearGraphsManifest, 52 | setGraphsData, 53 | clearGraphsData, 54 | graphsFetchError, 55 | setGraphsLoadingStatus, 56 | } = graphSlice.actions; 57 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers, configureStore } 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 { transcriptSlice, initialState as transcriptInitialState } from "./transcript"; 10 | import { sgAudioSlice, initialState as sgAudioInitialState } from "./sg-audio"; 11 | import { graphSlice, initialState as graphInitialState } from "./graphs"; 12 | import { maestroSlice, initialState as maestroInitialState } from "./maestro"; 13 | import { userSlice, initialState as userInitialState } from "./user"; 14 | 15 | export const initialState = { 16 | sequences: sequencesInitialState, 17 | videos: videosInitialState, 18 | photos: photosInitialState, 19 | ephemera: ephemeraInitialState, 20 | dayNight: dayNightInitialState, 21 | gps: gpsInitialState, 22 | transcript: transcriptInitialState, 23 | framework: viewerInitialState, 24 | sgAudio: sgAudioInitialState, 25 | graphs: graphInitialState, 26 | maestro: maestroInitialState, 27 | user: userInitialState, 28 | }; 29 | 30 | const sliceReducers = combineReducers({ 31 | sequences: sequencesSlice.reducer, 32 | videos: videoSlice.reducer, 33 | photos: photoSlice.reducer, 34 | ephemera: ephemeraSlice.reducer, 35 | dayNight: dayNightSlice.reducer, 36 | gps: gpsSlice.reducer, 37 | transcript: transcriptSlice.reducer, 38 | framework: frameworkSlice.reducer, 39 | sgAudio: sgAudioSlice.reducer, 40 | graphs: graphSlice.reducer, 41 | maestro: maestroSlice.reducer, 42 | user: userSlice.reducer, 43 | }); 44 | export type RootState = ReturnType; 45 | 46 | export const store: StoreType = configureStore({ 47 | reducer: sliceReducers, 48 | preloadedState: initialState, 49 | devTools: { 50 | name: `CODA Tab-${Math.random()}`, // Include git branch name 51 | }, 52 | }); 53 | export type StoreType = ReturnType>; 54 | export type AppDispatch = typeof store.dispatch; 55 | 56 | export default store; 57 | -------------------------------------------------------------------------------- /src/store/maestro.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | export const initialState: MaestroState = { 4 | title: null, 5 | crewAssignment: null, 6 | evaStartSec: null, 7 | evaEndSec: null, 8 | evaDurationSec: null, 9 | processedActivitiesData: null, 10 | responseMetadata: null, 11 | loadingStatus: "loading", 12 | }; 13 | 14 | export const maestroSlice = createSlice({ 15 | name: "maestro", 16 | initialState, 17 | reducers: { 18 | setMaestroData: ( 19 | state, 20 | action: { payload: { maestroInternalAPIData: MaestroInternalAPIData } } 21 | ) => { 22 | state.title = action.payload.maestroInternalAPIData.title; 23 | state.processedActivitiesData = action.payload.maestroInternalAPIData.processedActivitiesData; 24 | state.crewAssignment = action.payload.maestroInternalAPIData.crew; 25 | state.evaStartSec = action.payload.maestroInternalAPIData.evaStartSec; 26 | state.evaEndSec = action.payload.maestroInternalAPIData.evaEndSec; 27 | state.evaDurationSec = action.payload.maestroInternalAPIData.evaDurationSec; 28 | }, 29 | maestroFetchError: (state, action: { payload: string }) => { 30 | state.responseMetadata = { ...state.responseMetadata, error: action.payload }; 31 | }, 32 | setMaestroLoadingStatus: (state, action: { payload: LoadingStatus }) => { 33 | state.loadingStatus = action.payload; 34 | }, 35 | }, 36 | }); 37 | 38 | export const { setMaestroData, maestroFetchError, setMaestroLoadingStatus } = maestroSlice.actions; 39 | -------------------------------------------------------------------------------- /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/store/sg-audio.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | export const initialState: SgAudioState = { 4 | sgActivityFullUrlRecord: { 5 | override: false, 6 | sgActivityRangeFullUrlRecords: [[], [], [], []] as SgActivityRangeFullUrlRecord[][], // indexed by S/G channel number - 1 7 | }, 8 | responseMetadata: null, 9 | loadingStatus: "loading", 10 | }; 11 | 12 | export const sgAudioSlice = createSlice({ 13 | name: "sg_audio", 14 | initialState, 15 | reducers: { 16 | /** Add new photo files to the store */ 17 | setSgAudioActivity: (state, action: { payload: WrappedResponse }) => { 18 | state.sgActivityFullUrlRecord = action.payload.data; 19 | state.responseMetadata = { ...state.responseMetadata, ...action.payload.responseMetadata }; 20 | state.loadingStatus = "loaded"; 21 | }, 22 | clearSgAudioActivity: (state) => { 23 | state.sgActivityFullUrlRecord = null; 24 | state.responseMetadata = null; 25 | state.loadingStatus = "loading"; 26 | }, 27 | sgAudioFetchError: (state, action: { payload: string }) => { 28 | state.responseMetadata = { ...state.responseMetadata, error: action.payload }; 29 | }, 30 | setSgAudioLoadingStatus: (state, action: { payload: LoadingStatus }) => { 31 | state.loadingStatus = action.payload; 32 | }, 33 | }, 34 | }); 35 | 36 | export const { 37 | setSgAudioActivity, 38 | clearSgAudioActivity, 39 | sgAudioFetchError, 40 | setSgAudioLoadingStatus, 41 | } = sgAudioSlice.actions; 42 | -------------------------------------------------------------------------------- /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/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/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 | type MediaOverrideUpsertRequest = MediaOverride; 26 | 27 | interface MediaOverrideQueryParams { 28 | dateWanted: string; 29 | } 30 | 31 | type AncillaryDataUpsertRequest = { 32 | id: number; 33 | date: string; 34 | source: Source; 35 | type: "graphs"; 36 | url: string; 37 | }; 38 | 39 | interface AncillaryDataQueryParams { 40 | dateWanted: string; 41 | } 42 | 43 | interface DayNightQueryParams { 44 | dateWanted: string; 45 | forceNew?: boolean; 46 | dayNightSource?: string; 47 | // add support for year month date query params for Maestro 48 | // remove when Maestro is updated to use dateWanted 49 | year?: number; 50 | month?: number; 51 | date?: number; 52 | } 53 | 54 | interface GetTranscriptsQueryParams { 55 | dateWanted: string; 56 | source: Source; 57 | forceNew?: boolean; 58 | } 59 | 60 | interface GetSgAudioQueryParams { 61 | dateWanted: string; 62 | source: Source; 63 | forceNew?: boolean; 64 | } 65 | 66 | interface GetEphemerisQueryParams { 67 | dateWanted: string; 68 | forceNew?: boolean; 69 | } 70 | 71 | interface GetMaestroExecuteTimelineStatusQueryParams { 72 | uuid: string; 73 | } 74 | 75 | interface GetVideosQueryParams { 76 | dateWanted: string; 77 | source: Source; 78 | forceNew?: boolean; 79 | } 80 | 81 | interface GetMTXPlaybackQueryParams { 82 | dateWanted: string; 83 | source: Source; 84 | forceNew?: boolean; 85 | } 86 | 87 | interface GetPhotosQueryParams { 88 | dateWanted: string; 89 | source: Source; 90 | forceNew?: boolean; 91 | } 92 | 93 | interface GetSequencesAllEvasQueryParams { 94 | agency: AgencyQuery; 95 | forceNew?: boolean; 96 | } 97 | 98 | interface GetSequencesTestEventsQueryParams { 99 | forceNew?: boolean; 100 | } 101 | 102 | interface GetGraphsManifestQueryParams { 103 | dateWanted: string; 104 | source: Source; 105 | forceNew?: boolean; 106 | } 107 | 108 | interface VideoQueryParams { 109 | videoId: string; 110 | } 111 | 112 | interface PhotoQueryParams { 113 | dateWanted: string; 114 | } 115 | -------------------------------------------------------------------------------- /src/typings/cache.d.ts: -------------------------------------------------------------------------------- 1 | type RetrieverStatus = "inprogress" | "complete" | "error"; 2 | 3 | interface ResponseMetadata { 4 | retrieverStatus: RetrieverStatus; 5 | cachedTimestamp: string; 6 | expiration: string; 7 | error: string; 8 | retrieverErrorCount: number; 9 | lastErrorTimestamp: string; 10 | mocked?: boolean; 11 | } 12 | 13 | /** This is the structure of the metadata object that we save within each caCache entry */ 14 | interface CaCacheMetadata { 15 | retrieverStatus: RetrieverStatus; 16 | cachedTimestamp: string; // ISO string 17 | expiration: string; // ISO string 18 | retrieverErrorDescription: string; 19 | retrieverErrorCount: number; // number of times the retriever has been run and failed 20 | lastErrorTimestamp: string; // ISO string 21 | } 22 | 23 | /** 24 | * Contains all the possible subfolders for the cache. 25 | * This type is iterated through when clearing the entire cache 26 | */ 27 | type CacheFolder = 28 | | "celestrak" 29 | | "spacetrack" 30 | | "daynight/topo" 31 | | "daynight/issLocation" 32 | | "io" 33 | | "labs/transcripts" 34 | | "labs/audio" 35 | | "wiki" 36 | | "wiki/all" 37 | | "wiki/gps" 38 | | "test" 39 | | "labs/mtxPlayback" 40 | | "gps/tracks"; 41 | 42 | type SocketCacheMetadata = { 43 | expiration: string; 44 | retrieving: boolean; 45 | }; 46 | -------------------------------------------------------------------------------- /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 LoadingStatus = "loading" | "loaded" | "unneeded"; 11 | 12 | type Source = "ISS" | "TEST_EVENTS" | "NBL" | "ARTEMIS"; 13 | 14 | type MediaMedium = "video" | "photo" | "transcript" | "audio"; 15 | 16 | type SourceShortVal = 0 | 1 | 2 | 3; 17 | 18 | type SequenceType = 1 | 2 | "testing" | "analog" | "training"; 19 | 20 | /** 21 | * Pane types converted to integers 22 | */ 23 | type PaneTypeShortVal = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10; 24 | -------------------------------------------------------------------------------- /src/typings/context.d.ts: -------------------------------------------------------------------------------- 1 | // general context provider for combining multiple contexts 2 | type Provider = ({ children }: { children: React.ReactNode }) => React.ReactElement; 3 | 4 | // Define the Playhead interface 5 | interface Playhead { 6 | /** UTC date being viewed */ 7 | date: string; 8 | appSeconds: number; 9 | isRunning: boolean; 10 | setDatestamp: string; 11 | setAppSeconds: number; 12 | } 13 | 14 | type PlayheadAction = 15 | | { type: "SET_DATE"; payload: string | null } 16 | | { type: "SET_APP_SECONDS"; payload: number } 17 | | { type: "START" } 18 | | { type: "STOP" } 19 | | { type: "TICK" }; 20 | 21 | type PlayheadContextType = { 22 | playhead: Playhead; 23 | dispatchPlayhead: React.Dispatch; 24 | }; 25 | 26 | interface HoverPlayhead { 27 | hoverSeconds: number | null; 28 | } 29 | 30 | interface HoverPlayheadContextType { 31 | hoverPlayhead: HoverPlayhead; 32 | setHoverPlayhead: React.Dispatch>; 33 | } 34 | -------------------------------------------------------------------------------- /src/typings/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/typings/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module "aria-query" { 2 | export type ARIARole = string; 3 | } 4 | 5 | declare module "ws" { 6 | interface WebSocketOptions { 7 | protocol?: string | string[]; 8 | handshakeTimeout?: number; 9 | perMessageDeflate?: boolean | object; 10 | maxPayload?: number; 11 | followRedirects?: boolean; 12 | headers?: { [key: string]: string }; 13 | agent?: import("http").Agent | import("https").Agent; 14 | rejectUnauthorized?: boolean; 15 | } 16 | 17 | class WebSocket { 18 | constructor(address: string, options?: WebSocketOptions); 19 | } 20 | export = WebSocket; 21 | } 22 | -------------------------------------------------------------------------------- /src/typings/ephemera.d.ts: -------------------------------------------------------------------------------- 1 | interface EphemerisStore { 2 | ephemera: EphemerisFile[]; 3 | } 4 | 5 | interface EphemerisFile { 6 | EPOCH: string; 7 | TLE_LINE0: string; 8 | TLE_LINE1: string; 9 | TLE_LINE2: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/typings/global.d.ts: -------------------------------------------------------------------------------- 1 | type GlobalValues = { 2 | socketio: import("socket.io").Server< 3 | ClientToServerEvents, 4 | ServerToClientEvents, 5 | InterServerEvents, 6 | SocketData 7 | >; 8 | ormCache: 9 | | import("@mikro-orm/core").MikroORM 10 | | null; 11 | serverSocketStatus: ServerSocketStatus; 12 | socketInterval: NodeJS.Timeout; 13 | serverDataRefreshTimeouts: { 14 | [source: string]: { [date: string]: { [dataType: string]: NodeJS.Timeout } }; 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /src/typings/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 | -------------------------------------------------------------------------------- /src/typings/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/typings/io.d.ts: -------------------------------------------------------------------------------- 1 | /** Represents a single video search result as received from IO */ 2 | interface Doc { 3 | audio_file_restricted: 0 | 1 | number; 4 | hh: 0 | 1 | number; 5 | duration_seconds: number; 6 | on_public_site: number; 7 | tw: number; 8 | md_online_01: number; 9 | on_flickr: 0 | 1 | number; 10 | lw: number; 11 | hw: number; 12 | /** Title of the EVA, eg. `US EVA 55` */ 13 | md_title?: string; 14 | description?: string; 15 | md_orbit_ground: number; 16 | has_audio_file: 0 | 1 | number; 17 | asset_type: number; 18 | /** eg. `mp4` - just the extension, no leading dot */ 19 | file_extension_video: string; 20 | /** eg. `iss060m532` */ 21 | nasa_prefix?: string; 22 | /** 23 | * eg. `iss060m532331624`. There is an exception for video recorded during LOS 24 | * Breakdown: 25 | * ```md 26 | * iss = ISS video 27 | * 053 = Expedition 53 28 | * m = moving imagery e.g. video 29 | * 53 = Downlink 3, downlinked after an LOS. Realtime downlink would be 03 30 | * 278 = GMT day 278 31 | * 1939 = Actual start time of the video 32 | * ``` 33 | * 34 | * Note that 19:39 is the actual GMT start time of this video for a non-realtime 35 | * downlink. The "Start GMT" listed in IO is wrong, stating GMT 0600. 36 | * */ 37 | nasa_id: string; 38 | /** eg. `/photos/vrps/12674` */ 39 | webpath: string; 40 | id: number; 41 | metadata_template: number; 42 | /** The suffix is found at the end of .nasa_id, eg. `331624` */ 43 | nasa_suffix?: number; 44 | /** eg. `jpg` - just the extension, no leading dot */ 45 | file_extension_lores: string; 46 | /** eg. `["P2344036/ISS Missions|ISS-060|Video|US Downlink|Channel 03"]` */ 47 | collections_string: string[]; 48 | avg_rating: number; 49 | collections: (string | number)[]; 50 | file_extension_thum: string; 51 | /** UTC eg. `2019-08-21T14:47:22Z` */ 52 | date_added: string; 53 | flickr_photo_id: number; 54 | th: number; 55 | collections_list: (string | number)[]; 56 | /** UTC eg. `2019-08-21T17:11:12Z` */ 57 | md_creation_date: string; 58 | /** Only created if start time has been modified in IO - UTC eg. `2019-08-21T17:11:12Z` */ 59 | vmd_start_gmt?: string; 60 | lh: number; 61 | md_interior_exterior: number; 62 | _version_: number; 63 | } 64 | -------------------------------------------------------------------------------- /src/typings/jsx-shim.d.ts: -------------------------------------------------------------------------------- 1 | // This file ensures JSX namespace compatibility for libraries like FontAwesome 2 | // that may reference JSX.Element directly 3 | // It should be temporary. When upgrading packages, check to see if this is still needed 4 | 5 | import React from "react"; 6 | 7 | declare global { 8 | namespace JSX { 9 | interface Element extends React.ReactElement {} 10 | interface IntrinsicElements extends React.JSX.IntrinsicElements {} 11 | interface ElementChildrenAttribute extends React.JSX.ElementChildrenAttribute {} 12 | } 13 | } 14 | 15 | export {}; 16 | -------------------------------------------------------------------------------- /src/typings/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 | -------------------------------------------------------------------------------- /src/typings/maestro-api.d.ts: -------------------------------------------------------------------------------- 1 | type MaestroActorTimelineStatus = { 2 | plannedStartTime: number; 3 | plannedEndTime: number; 4 | startTime: number | false; 5 | endTime: number | false; 6 | percentComplete: number; 7 | }; 8 | 9 | type MaestroActivityTimelineStatus = { 10 | title: string; 11 | color: string; 12 | actors: { [key: string]: MaestroActorTimelineStatus }; 13 | }; 14 | 15 | type MaestroColumns = { key: string; display: string }; 16 | 17 | type MaestroTimelineStatusApiResponse = { 18 | activities: { [key: string]: MaestroActivityTimelineStatus }; 19 | timeOfZeroPET: number | false; 20 | timeOfEndPET: number | false; 21 | title: string; 22 | duration: number; 23 | columns: MaestroColumns[]; 24 | }; 25 | 26 | type MaestroInternalAPIData = { 27 | title: string; 28 | crew: Crew; 29 | evaStartSec: number; 30 | evaEndSec: number; 31 | evaDurationSec: number; 32 | processedActivitiesData: { [key: string]: Activity[] }; 33 | }; 34 | -------------------------------------------------------------------------------- /src/typings/media.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/typings/mtx.d.ts: -------------------------------------------------------------------------------- 1 | type MTXApiResponses = { 2 | mtxPlaybackAvailability: MTXPlaybackAvailability; 3 | mtxHlsEndpoints: MTXHlsEndpoint[]; 4 | }; 5 | 6 | type MTXHlsEndpoint = { 7 | name: MTXHlsEndpointName; 8 | secondsAvailable: number; 9 | }; 10 | 11 | type MTXRecordingTimeRange = { 12 | start: string; 13 | duration: number; 14 | }; 15 | 16 | type MTXPlaybackAvailability = { 17 | [dlNumber: string]: MTXRecordingTimeRange[]; 18 | }; 19 | 20 | type MTXHlsEndpointName = 21 | | "DL1_ISS" 22 | | "DL2_ISS" 23 | | "DL3_ISS" 24 | | "DL4_ISS" 25 | | "DL5_ISS" 26 | | "DL6_ISS" 27 | | "DL7_ISS" 28 | | "DL8_ISS" 29 | | "DL1_TE" 30 | | "DL2_TE" 31 | | "DL3_TE" 32 | | "DL4_TE" 33 | | "DL5_TE" 34 | | "DL6_TE" 35 | | "DL7_TE" 36 | | "DL8_TE"; 37 | 38 | type VideoPlayerType = "IO" | "MTX" | "HLS"; 39 | -------------------------------------------------------------------------------- /src/typings/mwbot.d.ts: -------------------------------------------------------------------------------- 1 | // there is no @types/mwbot, so this is a custom type to make the IDE errors go away 2 | declare module "mwbot" { 3 | const MWBot: any; // Use `any` or a more specific type if you know the structure 4 | export = MWBot; 5 | } 6 | -------------------------------------------------------------------------------- /src/typings/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 | -------------------------------------------------------------------------------- /src/typings/sg-audio.d.ts: -------------------------------------------------------------------------------- 1 | // These 3 types are what labs returns 2 | type SgActivityRangeRecord = { 3 | sound_start_secs: number; 4 | sound_stop_secs: number; 5 | aacSegmentFilename: string; 6 | }; 7 | 8 | type SgChannelRecord = { 9 | sgChannel: number; 10 | activity_ranges: SgActivityRangeRecord[]; 11 | }; 12 | 13 | type SgVideoRecord = { 14 | nasa_id: string; 15 | duration_seconds: number; 16 | start_seconds: number; 17 | cue_start_seconds: number; 18 | cue_end_seconds: number; 19 | sgChannels: SgChannelRecord[]; 20 | }; 21 | 22 | // used by CODA to allow for each audio clip to come from a different source (when mixing labs audio and talkybot audio) 23 | type SgActivityRangeFullUrlRecord = { 24 | sound_start_secs: number; 25 | sound_stop_secs: number; 26 | aacSegmentFullUrl: string; 27 | }; 28 | 29 | type SgActivityFullUrlRecord = { 30 | override: boolean; 31 | sgActivityRangeFullUrlRecords: SgActivityRangeFullUrlRecord[][]; // 4 channels 32 | }; 33 | 34 | type SgActivityRecord = { 35 | baseUrl: string; 36 | override: boolean; 37 | sgActivityRecord: SgActivityRecord[][]; 38 | }; 39 | 40 | type AudioManifestActivityRange = { 41 | sound_start_secs: number; 42 | sound_stop_secs: number; 43 | aacSegmentFilename: string; 44 | }; 45 | 46 | type AudioManifestsgChannelItem = { 47 | sgChannel: number; 48 | activity_ranges: AudioManifestActivityRange[]; 49 | }; 50 | 51 | type AudioManifestItem = { 52 | start_seconds: number; 53 | cue_start_seconds: number; 54 | cue_end_seconds: number; 55 | sgChannels: AudioManifestsgChannelItem[]; 56 | }; 57 | -------------------------------------------------------------------------------- /src/typings/socketio.d.ts: -------------------------------------------------------------------------------- 1 | type StoreDataType = 2 | | "daynight" 3 | | "ephemeris" 4 | | "videos" 5 | | "photos" 6 | | "wikiEvas" 7 | | "wikiTestEvents" 8 | | "mtxvideo" 9 | | "gpstracks" 10 | | "transcript" 11 | | "sgaudio" 12 | | "graph"; 13 | 14 | interface DataUpdate { 15 | type: StoreDataType; 16 | wrappedResponse: WrappedResponse; 17 | } 18 | 19 | // Define a configuration for each data type 20 | interface DataFetchConfig { 21 | type: StoreDataType; 22 | getDataFunction: (params: { 23 | dateWanted: string; 24 | forceNew: boolean; 25 | source: Source; 26 | }) => Promise; 27 | } 28 | 29 | /** Socket.io Server instantiation types */ 30 | interface ServerToClientEvents { 31 | noArg: () => void; 32 | statusFromServer: (payload: StatusFromServer) => void; 33 | dataUpdate: (payload: DataUpdate) => void; 34 | version: (payload: string) => void; 35 | } 36 | 37 | interface ClientToServerEvents { 38 | visitorJoin: (payload: VisitorData) => void; 39 | } 40 | 41 | interface InterServerEvents { 42 | ping: () => void; 43 | } 44 | 45 | interface ServerSocketStatus { 46 | visitorsData: VisitorData[]; 47 | } 48 | 49 | interface SocketData { 50 | name: string; 51 | age: number; 52 | } 53 | 54 | interface VisitorData { 55 | socketId: string; 56 | dateViewing: string; 57 | source: Source; 58 | user: EmssUser; 59 | connectedAt: number; 60 | } 61 | 62 | type ConnectionStatus = "connected" | "disconnected" | "connecting" | "reconnecting"; 63 | 64 | // socket status for the client 65 | interface ClientSocketStatus { 66 | connectionStatus: ConnectionStatus; 67 | lastStatusFromServer: StatusFromServer; 68 | clientVersion: string; 69 | } 70 | 71 | interface StatusFromServer { 72 | visitorCount: number; 73 | timestamp: number; 74 | version: string; 75 | } 76 | -------------------------------------------------------------------------------- /src/typings/tle.js.d.ts: -------------------------------------------------------------------------------- 1 | declare module "tle.js" { 2 | export default class TLE { 3 | constructor(); 4 | // Add any specific methods or properties you use from tle.js here. 5 | static getLatLngObj(tle: string[], time?: Date): { lat: number; lng: number }; 6 | static getSatelliteInfo( 7 | tle: string[], 8 | time: Date 9 | ): { lat: number; lng: number; altitude: number; velocity: number }; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/typings/transcript.d.ts: -------------------------------------------------------------------------------- 1 | type UnprocessedUtterance = [number, string, string]; 2 | 3 | interface UnprocessedTranscript { 4 | sgNum: number; 5 | unprocessedUtterances: UnprocessedUtterance[]; 6 | } 7 | 8 | interface Transcript { 9 | utterances: Utterance[]; 10 | } 11 | 12 | interface Utterance { 13 | id: number; 14 | secs: number; 15 | time: string; 16 | speaker: string; 17 | content: string; 18 | } 19 | -------------------------------------------------------------------------------- /src/typings/video.d.ts: -------------------------------------------------------------------------------- 1 | type VideoRecord = { 2 | id: number; 3 | videoId: string; 4 | startTime: string; 5 | }; 6 | 7 | type VideoRecord_db_type = VideoRecord; 8 | -------------------------------------------------------------------------------- /src/utils/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/utils/consts.ts: -------------------------------------------------------------------------------- 1 | export const collection = { 2 | ISS: 4 as Collection, 3 | TEST_EVENTS: 2359932 as Collection, 4 | NBL: 78178 as Collection, 5 | ARTEMIS: 2346894 as Collection, 6 | }; 7 | 8 | export const sourceShortVal = { 9 | ISS: 0 as SourceShortVal, 10 | TEST_EVENTS: 1 as SourceShortVal, 11 | NBL: 2 as SourceShortVal, 12 | ARTEMIS: 3 as SourceShortVal, 13 | }; 14 | 15 | export const sequenceType = { 16 | EVA: 1 as SequenceType, 17 | IVA: 2 as SequenceType, 18 | testing: "testing" as SequenceType, 19 | analog: "analog" as SequenceType, 20 | training: "training" as SequenceType, 21 | }; 22 | 23 | export const paneTypeShortVal = { 24 | empty: 0 as PaneTypeShortVal, 25 | video_downlink: 1 as PaneTypeShortVal, 26 | video_non_downlink: 2 as PaneTypeShortVal, 27 | photo: 3 as PaneTypeShortVal, 28 | event_info: 4 as PaneTypeShortVal, 29 | iss_location: 5 as PaneTypeShortVal, 30 | gps_location: 6 as PaneTypeShortVal, 31 | photo_all: 7 as PaneTypeShortVal, 32 | transcript: 8 as PaneTypeShortVal, 33 | sgAudio: 9 as PaneTypeShortVal, 34 | graph: 10 as PaneTypeShortVal, 35 | }; 36 | -------------------------------------------------------------------------------- /src/utils/context.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Function that composes multiple useContext providers into a single provider by nesting them. 3 | * Removes the unsightly mess of nested providers in the App.tsx file. 4 | * 5 | * @param providers array of useContext providers 6 | * @returns A nested provider component that wraps all the providers in the array around the children. 7 | */ 8 | export const composeProviders = (providers: Provider[]) => { 9 | return providers.reduce( 10 | (AccumulatedProviders, CurrentProvider) => { 11 | return ({ children }) => ( 12 | 13 | {children} 14 | 15 | ); 16 | }, 17 | ({ children }) => <>{children} 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | }); 31 | 32 | clearTimeout(id); 33 | return response as unknown as Response; // Type casting to standard Response 34 | } catch (e) { 35 | clearTimeout(id); 36 | } 37 | 38 | // return a response object with status 408 (timeout) 39 | return new Response(null, { status: 408 }); 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/jest-extends.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom maters for the jest testing framework. 3 | * see typings/index.d.ts for the TS interface 4 | */ 5 | 6 | // Compare clock times 7 | expect.extend({ 8 | toHappenAround(x: Date, y: Date, z: string) { 9 | const received = x.getTime(); 10 | const expected = y.getTime(); 11 | return { 12 | pass: Math.abs(received / 1000 - expected / 1000) < 1, 13 | message: () => `Received time ${x} is not within 1 second of ${y}${z ? ` ${z}` : ""}`, 14 | }; 15 | }, 16 | }); 17 | 18 | export {}; 19 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | export class ConsoleLogger { 2 | private static isEnabled: boolean = false; 3 | 4 | static enable() { 5 | this.isEnabled = true; 6 | } 7 | 8 | static disable() { 9 | this.isEnabled = false; 10 | } 11 | 12 | static log(...args: any[]) { 13 | if (this.isEnabled) { 14 | console.log(...args); 15 | } 16 | } 17 | 18 | static error(...args: any[]) { 19 | if (this.isEnabled) { 20 | console.error(...args); 21 | } 22 | } 23 | 24 | static warn(...args: any[]) { 25 | if (this.isEnabled) { 26 | console.warn(...args); 27 | } 28 | } 29 | } 30 | 31 | // Export default instance 32 | export default ConsoleLogger; 33 | -------------------------------------------------------------------------------- /src/utils/mikro.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Connection, 3 | EntityManager, 4 | IDatabaseDriver, 5 | MikroORM, 6 | RequestContext, 7 | } from "@mikro-orm/core"; 8 | import config from "../../mikro-orm.config"; 9 | import { globalValues } from "../server/express/global"; 10 | 11 | export const getORM = async (): Promise>> => { 12 | if (!globalValues.ormCache) { 13 | globalValues.ormCache = await MikroORM.init(config); 14 | } 15 | return globalValues.ormCache; 16 | }; 17 | 18 | export const getEM = (): EntityManager> => { 19 | let em = RequestContext.getEntityManager(); 20 | if (!globalValues.ormCache) { 21 | throw new Error("Run Mikro.getORM() first"); 22 | } 23 | if (!em) { 24 | em = globalValues.ormCache.em.fork(); 25 | if (!em) { 26 | throw new Error("Entity Manager not initialized"); 27 | } 28 | } 29 | return em; 30 | }; 31 | 32 | export const closeORM = async (): Promise => { 33 | if (globalValues.ormCache) { 34 | await globalValues.ormCache.close(); 35 | globalValues.ormCache = null; 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /src/utils/serverLogger.ts: -------------------------------------------------------------------------------- 1 | import { createServerLogger } from "@emss/logger"; 2 | import { assertEnvVarsExist } from "@emss/utils"; 3 | import dotenv from "dotenv"; 4 | 5 | // ensures the env vars below are set in all contexts, including CI tests 6 | dotenv.config({ override: true }); 7 | 8 | const env = assertEnvVarsExist( 9 | "LOG_ENABLE_APP_LOGGING", 10 | "LOG_SERVER_HTTP_ENDPOINT", 11 | "LOG_DATA_APP_ID", 12 | "LOG_DATA_SERVER_NAME" 13 | ); 14 | 15 | /** 16 | * **Do not use in browser.** 17 | * 18 | * Used for server-side logging only. 19 | */ 20 | const serverLogger = createServerLogger({ 21 | logEnableAppLogging: env.LOG_ENABLE_APP_LOGGING === "true", 22 | logServerHttpEndpoint: env.LOG_SERVER_HTTP_ENDPOINT, 23 | logDataAppId: env.LOG_DATA_APP_ID, 24 | logDataServerName: env.LOG_DATA_SERVER_NAME, 25 | }); 26 | 27 | export default serverLogger; 28 | -------------------------------------------------------------------------------- /src/utils/type-guards.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A type guard that checks if a variable is an object and it has the specified property. 3 | */ 4 | export const hasProp = ( 5 | obj: unknown, 6 | property: K 7 | ): obj is { [property in K]: unknown } => { 8 | return obj && typeof obj === "object" ? property in obj : false; 9 | }; 10 | -------------------------------------------------------------------------------- /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/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 | let id = setInterval(tick, delay_ms); 29 | return () => clearInterval(id); 30 | } 31 | }, [delay_ms]); 32 | } 33 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tsconfig.jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "types": ["jest", "node", "react", "react-dom", "react-test-renderer"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "jsx": "react-jsx", 5 | "module": "es2022", 6 | "moduleResolution": "node", 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 | -------------------------------------------------------------------------------- /tsconfig.orm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "declaration": true 7 | } 8 | } 9 | --------------------------------------------------------------------------------