diff --git a/.dockerignore b/.dockerignore index f05a7ed6..0571ae48 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,12 +1,17 @@ # Ignore everything. ** -# Allow `/src` and `Dockerfile`. +# Allow: +!/packages/** +!/iris/** !/Dockerfile -!/src/** +!/package.json +!/yarn.lock +!/lerna.json +!/tsconfig.base.json ################################################################################ -# Ignore things inside of `/src`. (esentially Node .gitignore) +# re-ignore (esentially Node .gitignore) ################################################################################ # Logs **/logs diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..e69de29b diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..63f1ecd7 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,78 @@ +const allExtensions = [".ts", ".tsx", ".d.ts", ".js", ".jsx"]; + +module.exports = { + root: true, + extends: "react-app", + plugins: ["import"], + rules: { + "import/newline-after-import": ["warn", { count: 1 }], + "import/no-extraneous-dependencies": [ + "warn", + { + devDependencies: false, + optionalDependencies: false, + peerDependencies: false, + bundledDependencies: true, + }, + ], + // "import/no-relative-parent-imports": "warn", + "import/order": [ + "warn", + { + alphabetize: { + order: "asc", + caseInsensitive: true, + }, + "newlines-between": "always", + groups: [ + "builtin", + "external", + "internal", + ["parent", "sibling", "index"], + "object", + ], + pathGroups: [ + { + pattern: "react?(-dom)", + group: "external", + position: "before", + }, + { + pattern: "@iris/**", + group: "external", + position: "after", + }, + ], + pathGroupsExcludedImportTypes: ["builtin"], + }, + ], + }, + overrides: [ + { + files: ["webpack.*.js"], + rules: { + "import/no-extraneous-dependencies": [ + "warn", + { + devDependencies: true, + optionalDependencies: false, + peerDependencies: false, + bundledDependencies: true, + }, + ], + }, + }, + ], + settings: { + "import/extensions": allExtensions, + "import/external-module-folders": ["node_modules", "node_modules/@types"], + "import/parsers": { + "@typescript-eslint/parser": [".ts", ".tsx", ".d.ts"], + }, + "import/resolver": { + node: { + extensions: allExtensions, + }, + }, + }, +}; diff --git a/.gitignore b/.gitignore index 0307817a..20f5a4f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,68 +1,27 @@ -local_keys +# See https://help.github.com/ignore-files/ for more about ignoring files. -tmp.mp4 -tmp.webm +**/iris-app/src/bootstrap.js -secret.yaml -.env +/sample-projects -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript +# dependencies +**/node_modules -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release +# testing +**/coverage -# Dependency directories -node_modules/ -jspm_packages/ +# production +**/build +**/dist -# TypeScript v1 declaration files -typings/ +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# next.js build output -.next - -secret.yaml +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* diff --git a/.kubeconfig b/.kubeconfig deleted file mode 100644 index 0b2e4809..00000000 --- a/.kubeconfig +++ /dev/null @@ -1 +0,0 @@ -export KUBECONFIG=/Users/niko/.bluemix/plugins/container-service/clusters/bpnvi8vw0nkktonrr20g/kube-config-wdc04-annotations.yml \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..3f6fff7b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +dist +coverage \ No newline at end of file diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index fd496a82..00000000 --- a/.prettierrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "singleQuote": true, - "semi": false -} diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index b71a3a4f..00000000 --- a/.travis.yml +++ /dev/null @@ -1,16 +0,0 @@ -language: node_js -node_js: stable -before_deploy: -- curl -sL https://ibm.biz/idt-installer | bash -script: echo "No tests to run." -deploy: -- provider: script - skip_cleanup: true - script: export DEPLOY_TO=production && ./deploy.sh - on: - branch: master -- provider: script - skip_cleanup: true - script: export DEPLOY_TO=staging && ./deploy.sh - on: - branch: niko-dev \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 7a73a41b..67eb2a20 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,2 +1,13 @@ { + "workbench.editor.labelFormat": "short", + "[typescript]": { + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + }, + }, + "[typescriptreact]": { + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + }, + } } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index f78f0fdd..2d2d3d2b 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,36 @@ -FROM node:10.15.0 as nodebuild -COPY src . -RUN npm install && cd client && npm install && npm run build - -FROM node:10.15.0-alpine -COPY --from=nodebuild server.js . -COPY --from=nodebuild package.json . -COPY --from=nodebuild package-lock.json . -COPY --from=nodebuild client/build client - -RUN npm install --only=production &&\ - apk update &&\ - apk upgrade +############################ +# Stage 1 +############################ +FROM node:15.0.1-slim as builder + +# Build the UI +COPY yarn.lock package.json lerna.json tsconfig.base.json ./ +COPY iris ./iris/ +COPY packages ./packages/ +RUN yarn install && yarn lerna bootstrap && yarn lerna run build + +############################ +# Stage 2 +############################ +FROM node:15.0.1-slim + +EXPOSE 8080 ENV PORT 8080 -ENV NODE_ENV production +ENV SPA_ROOT=/iris/client + +WORKDIR /iris + +# Install packages +COPY --from=builder package.json yarn.lock lerna.json ./ +# Must use the folder name `iris` otherwise lerna won't find it. +COPY --from=builder iris/package.json ./iris/ +RUN yarn install && yarn lerna bootstrap -ARG CLIENT_ID -ENV CLIENT_ID $CLIENT_ID +# Copy in source files +COPY --from=builder packages/iris-app/build ./client/ +COPY --from=builder iris/dist ./iris/dist/ -ARG CLIENT_SECRET -ENV CLIENT_SECRET $CLIENT_SECRET +WORKDIR /projects -EXPOSE 8080 -CMD node server.js \ No newline at end of file +ENTRYPOINT [ "node" ] +CMD [ "/iris/iris/dist/index.js" ] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..7f57a342 --- /dev/null +++ b/Makefile @@ -0,0 +1,49 @@ +ORG=cloudannotations +REPO=cloudannotations +TAG=0.0.1 + +.PHONY: docker-publish +docker-publish: + # DOCKER_BUILDKIT=1 + @docker build -t ${ORG}/${REPO}:${TAG} . + @docker push ${ORG}/${REPO}:${TAG} + +.PHONY: clean +clean: + yarn lerna exec "rm -rf node_modules dist build" + rm -rf node_modules + +.PHONY: install +install: + yarn install + yarn lerna bootstrap + +.PHONY: build +build: + yarn lerna run build --stream --ignore @iris/app + yarn lerna run build --scope @iris/app --stream + +# .PHONY: start +# start: +# yarn lerna run start --scope @iris/app --stream + +.PHONY: watch +watch: + # yarn lerna run start --stream --scope @iris/server + yarn lerna run build --stream --ignore @iris/app + # FORCE_COLOR=true yarn lerna run start --scope @iris/app --scope @iris/server --stream + FORCE_COLOR=true yarn lerna run start --parallel --stream + + +# Add this back to the package.json to get storybook working again... +# installs took wayyy to long so temporary dissabled (30s to 3m) +# "nohoist": [ +# "**" +# ] +.PHONY: storybook +storybook: + yarn lerna exec "rm -rf node_modules/html-webpack-plugin/node_modules/webpack" --scope @iris/components + # build the theme and hooks, storybook watches components already + yarn lerna run build --stream --scope @iris/theme --scope @iris/hooks + # exec makes progress bar work + yarn lerna exec "yarn storybook" --scope @iris/components \ No newline at end of file diff --git a/README.md b/README.md index 5757fb7b..669aca3d 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,33 @@ -[![Build Status](https://travis-ci.org/cloud-annotations/cloud-annotations-client.svg?branch=master)](https://travis-ci.org/cloud-annotations/cloud-annotations-client) +# Iris -

Cloud Annotations

+## Development -

- Logo -

+### Setup -

- Launch Cloud Annotations 🚀 -

+```sh +git clone git@github.com:cloud-annotations/iris.git +cd iris -## Project setup -`git clone` the repo and `cd` into it by running the following command: - -```bash -git clone https://github.com/bourdakos1/cloud-annotations-client.git -cd cloud-annotations-client/src +make install ``` -Install the server dependencies: -``` -npm install -``` +### Build & Watch the Frontend -Install the client dependencies: +```sh +make watch ``` -cd client && npm install -```` -> **Note: You’ll need to have Node 8.10.0 or later on your local development machine.** You can use [nvm](https://github.com/creationix/nvm#installation) (macOS/Linux) or [nvm-windows](https://github.com/coreybutler/nvm-windows#node-version-manager-nvm-for-windows) to easily switch Node versions between different projects. +### Run the backend -## Local development -Start the app: -``` -npm start +Open a directory with `Iris` project folders + +```sh +node /iris/dist/index.js ``` -Open [http://localhost:3000](http://localhost:3000) to view it in the browser. +### CLI -## Issues -Please file any issues on the [training repo](https://github.com/cloud-annotations/training/issues) +``` +IRIS_ROOT= +$IRIS_ROOT/cli/bin/index.js start -w --irisRoot $IRIS_ROOT +``` diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 00000000..da6c4a38 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,11 @@ +module.exports = { + presets: [ + "react-app", + [ + "@babel/preset-typescript", + { + allowNamespaces: true, + }, + ], + ], +}; diff --git a/cert-manager-prod/certificate.yaml b/cert-manager-prod/certificate.yaml deleted file mode 100644 index 97776d52..00000000 --- a/cert-manager-prod/certificate.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: cert-manager.io/v1alpha2 -kind: Certificate -metadata: - name: cloud-annotations-ai -spec: - secretName: certbot-annotations-secret - issuerRef: - name: letsencrypt - commonName: cloud.annotations.ai - dnsNames: - - cloud.annotations.ai \ No newline at end of file diff --git a/cert-manager-prod/issuer.yaml b/cert-manager-prod/issuer.yaml deleted file mode 100644 index 03b76be5..00000000 --- a/cert-manager-prod/issuer.yaml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: cert-manager.io/v1alpha2 -kind: Issuer -metadata: - name: letsencrypt -spec: - acme: - # You must replace this email address with your own. - # Let's Encrypt will use this to contact you about expiring - # certificates, and issues related to your account. - email: bourdakos1@gmail.com - server: https://acme-v02.api.letsencrypt.org/directory - privateKeySecretRef: - # Secret resource that will be used to store the account's private key. - name: issuer-account-key - # Add a single challenge solver, HTTP01 using nginx - solvers: - - http01: - ingress: - class: nginx \ No newline at end of file diff --git a/cert-manager-stage/certificate.yaml b/cert-manager-stage/certificate.yaml deleted file mode 100644 index 98a57d5a..00000000 --- a/cert-manager-stage/certificate.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: cert-manager.io/v1alpha2 -kind: Certificate -metadata: - name: test-cloud-annotations-ai -spec: - secretName: stage-annotations-secret - issuerRef: - name: letsencrypt - commonName: test.cloud.annotations.ai - dnsNames: - - test.cloud.annotations.ai \ No newline at end of file diff --git a/cert-manager-stage/issuer.yaml b/cert-manager-stage/issuer.yaml deleted file mode 100644 index c5c24822..00000000 --- a/cert-manager-stage/issuer.yaml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: cert-manager.io/v1alpha2 -kind: Issuer -metadata: - name: letsencrypt -spec: - acme: - # You must replace this email address with your own. - # Let's Encrypt will use this to contact you about expiring - # certificates, and issues related to your account. - email: bourdakos1@gmail.com - server: https://acme-v02.api.letsencrypt.org/directory - privateKeySecretRef: - # Secret resource that will be used to store the account's private key. - name: stage-issuer-account-key - # Add a single challenge solver, HTTP01 using nginx - solvers: - - http01: - ingress: - class: nginx \ No newline at end of file diff --git a/cli/bin/cli.js b/cli/bin/cli.js new file mode 100644 index 00000000..3489d59e --- /dev/null +++ b/cli/bin/cli.js @@ -0,0 +1,20 @@ +const { Command } = require("commander"); + +const build = require("./commands/build"); +const server = require("./commands/server"); +const start = require("./commands/start"); +const ui = require("./commands/ui"); + +function cli() { + const program = new Command(); + program.version("0.0.1"); + + program.addCommand(start); + program.addCommand(build); + program.addCommand(ui); + program.addCommand(server); + + program.parse(process.argv); +} + +module.exports = cli; diff --git a/cli/bin/commands/build.js b/cli/bin/commands/build.js new file mode 100644 index 00000000..708a40e8 --- /dev/null +++ b/cli/bin/commands/build.js @@ -0,0 +1,10 @@ +const { Command } = require("commander"); + +const command = new Command() + .command("build") + .description("TODO: description") + .action(() => { + console.log("..."); + }); + +module.exports = command; diff --git a/cli/bin/commands/server.js b/cli/bin/commands/server.js new file mode 100644 index 00000000..1aa0ebb9 --- /dev/null +++ b/cli/bin/commands/server.js @@ -0,0 +1,10 @@ +const { Command } = require("commander"); + +const command = new Command() + .command("server") + .description("TODO: description") + .action(() => { + console.log("..."); + }); + +module.exports = command; diff --git a/cli/bin/commands/start.js b/cli/bin/commands/start.js new file mode 100644 index 00000000..d9b2c0db --- /dev/null +++ b/cli/bin/commands/start.js @@ -0,0 +1,85 @@ +const spawn = require("child_process").spawn; +const fs = require("fs"); +const path = require("path"); + +const { Command } = require("commander"); + +const command = new Command() + .command("start") + .option("-w, --watch") + .option("--irisRoot ", "path to iris source", "/usr/local/lib/iris") + .description("TODO: description") + .action((opts) => { + if (opts.watch === true) { + watch(opts); + return; + } + start(opts); + }); + +async function start(opts) { + console.log("starting..."); + const { irisRoot } = opts; + + const resolvedRoot = path.resolve(process.cwd(), irisRoot); + + const irisPath = path.resolve(resolvedRoot, "iris/dist/index.js"); + const spaRoot = path.resolve(resolvedRoot, "packages/iris-app/build"); + + // some of the build artifacts are missing, so rebuild the project + if (!fs.existsSync(irisPath) || !fs.existsSync(spaRoot)) { + console.log("Launching for the first time, building..."); + const build = spawn("make", ["install", "build"], { + cwd: resolvedRoot, + stdio: "inherit", + }); + + await new Promise((resolve, _reject) => { + build.on("close", resolve); + }); + } + + spawn("node", [irisPath], { + env: { + ...process.env, + SPA_ROOT: spaRoot, + }, + stdio: "inherit", + }); +} + +async function watch(opts) { + console.log("watching..."); + const { irisRoot } = opts; + + const resolvedRoot = path.resolve(process.cwd(), irisRoot); + + const irisPath = path.resolve(resolvedRoot, "iris/dist/index.js"); + + const build = spawn("make", ["install", "build"], { + cwd: resolvedRoot, + stdio: "inherit", + }); + + await new Promise((resolve, _reject) => { + build.on("close", resolve); + }); + + spawn("node", [irisPath], { + env: { + ...process.env, + }, + stdio: "inherit", + }); + + const watch = spawn("make", ["watch"], { + cwd: resolvedRoot, + stdio: "inherit", + }); + + await new Promise((resolve, _reject) => { + watch.on("close", resolve); + }); +} + +module.exports = command; diff --git a/cli/bin/commands/ui.js b/cli/bin/commands/ui.js new file mode 100644 index 00000000..a9d9cd8f --- /dev/null +++ b/cli/bin/commands/ui.js @@ -0,0 +1,10 @@ +const { Command } = require("commander"); + +const command = new Command() + .command("ui") + .description("TODO: description") + .action(() => { + console.log("..."); + }); + +module.exports = command; diff --git a/cli/bin/index.js b/cli/bin/index.js new file mode 100755 index 00000000..3d2701cb --- /dev/null +++ b/cli/bin/index.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node + +const currentNodeVersion = process.versions.node; +const semver = currentNodeVersion.split("."); +const major = semver[0]; + +if (major < 10) { + console.error( + "You are running Node " + + currentNodeVersion + + ".\n" + + "Iris requires Node 10 or higher. \n" + + "Please update your version of Node." + ); + process.exit(1); +} + +const cli = require("./cli"); + +cli(); diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 00000000..dfeaf8fd --- /dev/null +++ b/cli/package.json @@ -0,0 +1,17 @@ +{ + "name": "@iris/cli", + "version": "0.0.12", + "bin": { + "iris": "./bin/index.js" + }, + "scripts": { + "install": "rm -rf /usr/local/lib/iris && git clone https://github.com/cloud-annotations/iris.git /usr/local/lib/iris", + "uninstall": "rm -rf /usr/local/lib/iris" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "commander": "^7.1.0" + } +} diff --git a/cli/yarn.lock b/cli/yarn.lock new file mode 100644 index 00000000..0cf10b7e --- /dev/null +++ b/cli/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +commander@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-7.1.0.tgz#f2eaecf131f10e36e07d894698226e36ae0eb5ff" + integrity sha512-pRxBna3MJe6HKnBGsDyMv8ETbptw3axEdYHoqNh7gu5oDcew8fs0xnivZGm06Ogk8zGAJ9VX+OPEr2GXEQK4dg== diff --git a/cloud-annotations.png b/cloud-annotations.png deleted file mode 100644 index 5b3a0280..00000000 Binary files a/cloud-annotations.png and /dev/null differ diff --git a/deploy.sh b/deploy.sh deleted file mode 100755 index 4715802d..00000000 --- a/deploy.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash - -trap 'echo "The deployment was aborted. Message -- "; exit 1' ERR - -CLUSTER="bso3qn4w0imtmamdicu0" -IMAGE_NAME="us.icr.io/cloud-annotations/frontend:$(git rev-parse HEAD)" - -# Log in -echo "Logging in..." -ibmcloud config --check-version=false -ibmcloud login -a cloud.ibm.com -r us-east -g prod - -# Download cluster config -echo Downloading config for $CLUSTER ... -ibmcloud ks cluster config --cluster $CLUSTER - -# Build image -echo Building $IMAGE_NAME ... -ibmcloud cr build --no-cache --pull --build-arg CLIENT_ID=$CLIENT_ID --build-arg CLIENT_SECRET=$CLIENT_SECRET -t $IMAGE_NAME . - -# Apply kubernetes yamls -echo Container build completed, updating $DEPLOYMENT ... -sed -i "s,\(^.*image: \)\(.*$\),\1"$IMAGE_NAME"," k8s-base/frontend.yaml - -if [ "$DEPLOY_TO" = "production" ] -then - # PRODUCTION: - kubectl apply -f k8s-base - kubectl apply -f k8s-prod -else - # STAGING: - kubectl apply -f k8s-base -n stage - kubectl apply -f k8s-stage -n stage -fi - -echo "Deployment complete" diff --git a/generate-secret.sh b/generate-secret.sh deleted file mode 100755 index a5241dcb..00000000 --- a/generate-secret.sh +++ /dev/null @@ -1,27 +0,0 @@ -# sudo certbot certonly --manual --preferred-challenges=dns -# *.annotations.ai, annotations.ai -# nslookup -type=TXT _acme-challenge.annotations.ai - -# run this script, then: -# ./generate-secret.sh - -# ibmcloud api https://cloud.ibm.com -# ibmcloud login -sso -# ibmcloud ks cluster config annotations -# export KUBECONFIG="/Users/niko/.bluemix/plugins/container-service/clusters/annotations/kube-config-wdc04-annotations.yml" -# kl apply -f secret.yaml - -cert=$(sudo openssl base64 -in /etc/letsencrypt/live/annotations.ai/fullchain.pem) -key=$(sudo openssl base64 -in /etc/letsencrypt/live/annotations.ai/privkey.pem) - -stripped_cert=$(tr -d '\n' <<< "$cert") -stripped_key=$(tr -d '\n' <<< "$key") - -echo "apiVersion: v1 -kind: Secret -metadata: - name: annotations-secret -type: Opaque -data: - tls.crt: ""$stripped_cert"" - tls.key: ""$stripped_key""" > k8s-prod/secret.yaml diff --git a/iris/package.json b/iris/package.json new file mode 100644 index 00000000..60119188 --- /dev/null +++ b/iris/package.json @@ -0,0 +1,44 @@ +{ + "name": "@iris/server", + "private": true, + "version": "0.0.1", + "bin": { + "iris": "./bin/index.js" + }, + "scripts": { + "start": "tsc -w", + "build": "tsc" + }, + "dependencies": { + "@types/busboy": "^0.2.3", + "@types/compression": "^1.7.0", + "@types/express": "^4.17.8", + "@types/fs-extra": "^9.0.4", + "@types/node": "^14.14.2", + "@types/pino-http": "^5.0.5", + "@types/proper-lockfile": "^4.1.1", + "@types/redis": "^2.8.28", + "@types/uuid": "^8.3.0", + "busboy": "^0.3.1", + "clean-webpack-plugin": "^3.0.0", + "compression": "^1.7.4", + "express": "^4.17.1", + "fs-extra": "^9.0.1", + "helmet": "^4.1.1", + "ibm-cos-sdk": "^1.8.0", + "pino-colada": "^2.1.0", + "pino-http": "^5.3.0", + "proper-lockfile": "^4.1.1", + "redis": "^3.0.2", + "socket.io": "^3.1.2", + "socket.io-redis": "^6.0.1", + "start-server-webpack-plugin": "^2.2.5", + "ts-loader": "^8.0.6", + "tsconfig-paths-webpack-plugin": "^3.3.0", + "typescript": "^4.0.3", + "webpack": "^5.2.0", + "webpack-cli": "^4.1.0", + "webpack-merge": "^5.2.0", + "webpack-node-externals": "^2.5.2" + } +} diff --git a/iris/src/handlers/error.ts b/iris/src/handlers/error.ts new file mode 100644 index 00000000..fd5c575a --- /dev/null +++ b/iris/src/handlers/error.ts @@ -0,0 +1,20 @@ +import { NextFunction, Request, Response } from "express"; + +function errorHandler( + err: Error, + _req: Request, + res: Response, + next: NextFunction +) { + if (res.headersSent) { + return next(err); + } + // Don't leak internal errors in production. + if (process.env.NODE_ENV === "production") { + return res.sendStatus(500); + } + + return res.status(500).json({ error: err.message }); +} + +export default errorHandler; diff --git a/iris/src/handlers/not-found.ts b/iris/src/handlers/not-found.ts new file mode 100644 index 00000000..077eb9b5 --- /dev/null +++ b/iris/src/handlers/not-found.ts @@ -0,0 +1,7 @@ +import { Request, Response } from "express"; + +function notFoundHandler(_req: Request, res: Response) { + res.sendStatus(404); +} + +export default notFoundHandler; diff --git a/iris/src/index.ts b/iris/src/index.ts new file mode 100644 index 00000000..79ee2e41 --- /dev/null +++ b/iris/src/index.ts @@ -0,0 +1,36 @@ +import http from "http"; + +import express from "express"; + +import errorHandler from "./handlers/error"; +import notFoundHandler from "./handlers/not-found"; +import gzip from "./middleware/gzip"; +import logger from "./middleware/logger"; +import security from "./middleware/security"; +import multiuser from "./multiuser"; +import spaRouter from "./routes/spa"; +import v2Router from "./routes/v2"; + +const app = express(); +const server = http.createServer(app); +const port = process.env.PORT || 9000; + +app.enable("trust proxy"); +app.disable("x-powered-by"); + +multiuser(server); + +app.use(gzip()); +app.use(security()); +app.use(logger()); +app.use(express.json()); + +app.use("/api/v2", v2Router); +app.use("/", spaRouter); + +app.use(notFoundHandler); +app.use(errorHandler); + +server.listen(port, () => { + console.log("listening on port " + port); +}); diff --git a/iris/src/middleware/gzip.ts b/iris/src/middleware/gzip.ts new file mode 100644 index 00000000..932b6f44 --- /dev/null +++ b/iris/src/middleware/gzip.ts @@ -0,0 +1,17 @@ +import compression from "compression"; + +function gzip() { + return compression({ + filter: (req, res) => { + // don't compress responses asking explicitly not + if (req.headers["x-no-compression"]) { + return false; + } + + // use compression filter function + return compression.filter(req, res); + }, + }); +} + +export default gzip; diff --git a/iris/src/middleware/logger.ts b/iris/src/middleware/logger.ts new file mode 100644 index 00000000..e9ca6f89 --- /dev/null +++ b/iris/src/middleware/logger.ts @@ -0,0 +1,12 @@ +import pinoColada from "pino-colada"; +import pino from "pino-http"; + +function logger() { + return pino({ + level: process.env.LOG_LEVEL || "info", + prettyPrint: process.env.NODE_ENV !== "production", + prettifier: pinoColada, + }); +} + +export default logger; diff --git a/iris/src/middleware/pino-colada.d.ts b/iris/src/middleware/pino-colada.d.ts new file mode 100644 index 00000000..1f46dd26 --- /dev/null +++ b/iris/src/middleware/pino-colada.d.ts @@ -0,0 +1 @@ +declare module "pino-colada"; diff --git a/iris/src/middleware/security.ts b/iris/src/middleware/security.ts new file mode 100644 index 00000000..d9cd018d --- /dev/null +++ b/iris/src/middleware/security.ts @@ -0,0 +1,7 @@ +import helmet from "helmet"; + +function security() { + return helmet({ contentSecurityPolicy: false }); +} + +export default security; diff --git a/iris/src/multiuser.ts b/iris/src/multiuser.ts new file mode 100644 index 00000000..93967ac5 --- /dev/null +++ b/iris/src/multiuser.ts @@ -0,0 +1,79 @@ +import http from "http"; + +import { RedisClient } from "redis"; +import { Server, Socket } from "socket.io"; +import { createAdapter } from "socket.io-redis"; + +const host = process.env.REDIS_HOST ?? "redis"; +const port = parseInt(process.env.REDIS_PORT ?? "6379", 10); +const password = process.env.REDIS_PASSWORD; + +function multiuser(server: http.Server) { + const io = new Server(server); + + const pubClient = new RedisClient({ + host, + port, + password, + }); + + pubClient.on("ready", () => { + console.log("pub client ready"); + const subClient = pubClient.duplicate(); + subClient.on("ready", () => { + console.log("sub client ready"); + console.log("creating addapter"); + io.adapter(createAdapter({ pubClient, subClient })); + }); + subClient.on("error", () => { + // do nothing + }); + }); + pubClient.on("error", () => { + // do nothing + }); + + function broadcastHeadcount(room: string) { + if (room.startsWith("image:")) { + const count = io.of("/").adapter.rooms.get(room)?.size; + io.to(room).emit("headcount", count); + } + } + + io.of("/").adapter.on("join-room", (room, _id) => { + broadcastHeadcount(room); + }); + + io.of("/").adapter.on("leave-room", (room, _id) => { + broadcastHeadcount(room); + }); + + io.on("connection", (socket: Socket) => { + socket.on("patch", (res: any) => { + const projectRoom = [...socket.rooms].find((r) => + r.startsWith("project:") + ); + if (projectRoom !== undefined) { + socket.to(projectRoom).emit("patch", res); + } + }); + + socket.on("join", async (res: any) => { + const projectRoom = `project:${res.project}`; + const imageRoom = `image:${res.project}:${res.image}`; + + // TODO: we should check that you are allowed to join the room. + + for (const room of socket.rooms) { + if (room !== imageRoom || room !== projectRoom) { + socket.leave(room); + } + } + + socket.join(projectRoom); + socket.join(imageRoom); + }); + }); +} + +export default multiuser; diff --git a/iris/src/plugins/iris-server-plugin-cos/COSProvider.ts b/iris/src/plugins/iris-server-plugin-cos/COSProvider.ts new file mode 100644 index 00000000..a1ec8210 --- /dev/null +++ b/iris/src/plugins/iris-server-plugin-cos/COSProvider.ts @@ -0,0 +1,64 @@ +import COS from "ibm-cos-sdk"; + +interface IOptions { + name: string; + projectID?: string; +} + +const connections = [ + { + id: "718b4cc1-1c5d-400b-b056-08244db75225", + providerID: "cos", + name: "bee-travels", + credentials: { + apikey: "Fczphmlve6ANRIrTKNgiEHVex29FFCdw2lv8g67XWqhO", + resource_instance_id: + "crn:v1:bluemix:public:cloud-object-storage:global:a/9b13b857a32341b7167255de717172f5:718b4cc1-1c5d-400b-b056-08244db75225::", + }, + }, + { + id: "48b970b2-c8c8-4b4e-bb0c-85b6338cd295", + providerID: "cos", + name: "Cloud Object Storage-nick", + credentials: { + apikey: "aSz22rBc1hZEnw-EwtsOyFvXz1fmWAl6iAJtr9X4JMF0", + resource_instance_id: + "crn:v1:bluemix:public:cloud-object-storage:global:a/9b13b857a32341b7167255de717172f5:48b970b2-c8c8-4b4e-bb0c-85b6338cd295::", + }, + }, +]; + +class COSProvider { + async getConnections() { + return Promise.resolve(connections); + } + + async getProjects({ connectionID }: { connectionID: string }) { + const connection = connections.find((c) => c.id === connectionID); + const config = { + endpoint: "https://s3.us.cloud-object-storage.appdomain.cloud", + apiKeyId: connection?.credentials.apikey, + ibmAuthEndpoint: "https://iam.cloud.ibm.com/identity/token", + serviceInstanceId: connection?.credentials.resource_instance_id, + }; + + const cosClient = new COS.S3(config); + const list = await cosClient.listBucketsExtended().promise(); + return list.Buckets?.map((b) => ({ + name: b.Name, + modified: b.CreationDate, // TODO: this should be created + })); + } + + async getProject(options: Pick) {} + + async persist(annotations: any, options: Pick) {} + + async getImage(imageID: string, options: Pick) {} + + async deleteImage(imageID: string, options: Pick) {} + + async saveImage(file: NodeJS.ReadableStream, options: IOptions) {} +} + +export default COSProvider; diff --git a/iris/src/plugins/iris-server-plugin-cos/index.ts b/iris/src/plugins/iris-server-plugin-cos/index.ts new file mode 100644 index 00000000..5e66e046 --- /dev/null +++ b/iris/src/plugins/iris-server-plugin-cos/index.ts @@ -0,0 +1,8 @@ +import COSProvider from "./COSProvider"; + +export function activate(iris: any) { + iris.providers.register({ + id: "cos", + provider: new COSProvider(), + }); +} diff --git a/iris/src/plugins/iris-server-plugin-file-system/FileSystemProvider.ts b/iris/src/plugins/iris-server-plugin-file-system/FileSystemProvider.ts new file mode 100644 index 00000000..2aa0b47f --- /dev/null +++ b/iris/src/plugins/iris-server-plugin-file-system/FileSystemProvider.ts @@ -0,0 +1,218 @@ +import path from "path"; + +import fs from "fs-extra"; +import lockfile from "proper-lockfile"; + +interface Label { + id: string; + name: string; +} + +interface Image { + id: string; + date: string; + annotations: string[]; +} + +interface Annotation { + id: string; + label: string; + tool?: string; + targets?: { + id: string; + x: number; + y: number; + }[]; + [key: string]: any; // plugins can specify extra keys. +} + +interface IProject { + id?: string; + name: string; + created: Date; + version: string; + labels: { [key: string]: Label }; + annotations: { [key: string]: Annotation }; + images: { [key: string]: Image }; +} + +interface IOptions { + name: string; + projectID?: string; +} + +const ignoreRegex = /^lost\+found$/; + +class FileSystemProvider { + private _dir(projectID: string | undefined) { + if (projectID) { + return path.join(process.cwd(), projectID); + } + return process.cwd(); + } + + async getConnections() { + return Promise.resolve([ + { + id: "default", + providerID: "file-system", + name: "File System", + icon: ` + + + `, + }, + ]); + } + + async createProject({ name }: any) { + await fs.ensureDir(this._dir(name)); + } + + async getProjects() { + const x = await fs.readdir(process.cwd(), { withFileTypes: true }); + + return await Promise.all( + x + .filter((dir) => dir.isDirectory() && !ignoreRegex.test(dir.name)) + .map(async (dir) => { + const stats: { created?: Date; modified?: Date; opened?: Date } = { + created: undefined, + modified: undefined, + opened: undefined, + }; + + const files = await fs.readdir(dir.name); + + const images = files.filter( + (f) => + f.toLowerCase().endsWith(".jpg") || + f.toLowerCase().endsWith(".jpeg") + ); + + let labels: string[] = []; + try { + const a = await fs.readFile( + path.join(dir.name, "_annotations.json"), + "utf-8" + ); + labels = JSON.parse(a).labels; + } catch {} + + try { + const s = await fs.stat(path.join(dir.name, "_annotations.json")); + stats.created = s.birthtime; + stats.modified = s.mtime; + stats.opened = s.atime; + } catch {} + + return { + id: dir.name, + name: dir.name, + created: stats.created, + modified: stats.modified, + opened: stats.opened, + labels: labels, + images: images.length, + }; + }) + ); + } + + async getProject(options: Pick) { + const { projectID } = options; + + const project: IProject = { + id: projectID, + name: projectID ?? path.basename(process.cwd()), + created: new Date(), + version: "v2", + labels: {}, + annotations: {}, + images: {}, + }; + + try { + const annotationsString = await fs.readFile( + path.join(this._dir(projectID), "_annotations.json"), + "utf-8" + ); + // TODO: check version + const { labels, annotations, images } = JSON.parse(annotationsString); + project.labels = labels; + project.annotations = annotations; + project.images = images; + } catch { + // we don't care if there's no annotations file. + } + + const files = await fs.readdir(this._dir(projectID)); + + for (const f of files) { + if ( + f.toLowerCase().endsWith(".jpg") || + f.toLowerCase().endsWith(".jpeg") + ) { + // Only add image if it's not already there. + // TODO: We should update the date of the image maybe? + if (project.images[f] === undefined) { + project.images[f] = { id: f, date: "", annotations: [] }; + } + } + } + + return project; + } + + async persist(annotations: any, options: Pick) { + const { projectID } = options; + + const output = path.join(this._dir(projectID), "_annotations.json"); + + // TODO: This probably isn't safe: + // "It is unsafe to call fsPromises.writeFile() multiple times on the same + // file without waiting for the Promise to be resolved (or rejected)." + await fs.writeFile(output, JSON.stringify(annotations), "utf-8"); + } + + async getImage(imageID: string, options: Pick) { + const { projectID } = options; + + const output = path.join(this._dir(projectID), imageID); + + const isLocked = await lockfile.check(output); + if (!isLocked) { + return fs.createReadStream(output); + } + + throw new Error("file is locked"); + } + + async deleteImage(imageID: string, options: Pick) { + const { projectID } = options; + const output = path.join(this._dir(projectID), imageID); + await fs.unlink(output); + } + + async saveImage(file: NodeJS.ReadableStream, options: IOptions) { + const { projectID, name } = options; + const output = path.join(this._dir(projectID), name); + + await fs.ensureFile(output); + const release = await lockfile.lock(output); + const writeStream = fs.createWriteStream(output); + file.pipe(writeStream); + return new Promise((resolve, reject) => { + writeStream.on("error", async (e) => { + await release(); + reject(e); + }); + writeStream.on("finish", async () => { + await release(); + resolve(); + }); + }); + } +} + +export default FileSystemProvider; diff --git a/iris/src/plugins/iris-server-plugin-file-system/index.ts b/iris/src/plugins/iris-server-plugin-file-system/index.ts new file mode 100644 index 00000000..cdb2ee07 --- /dev/null +++ b/iris/src/plugins/iris-server-plugin-file-system/index.ts @@ -0,0 +1,8 @@ +import FileSystemProvider from "./FileSystemProvider"; + +export function activate(iris: any) { + iris.providers.register({ + id: "file-system", + provider: new FileSystemProvider(), + }); +} diff --git a/iris/src/routes/spa.ts b/iris/src/routes/spa.ts new file mode 100644 index 00000000..7159172a --- /dev/null +++ b/iris/src/routes/spa.ts @@ -0,0 +1,16 @@ +import path from "path"; + +import express, { Router } from "express"; + +const root = process.env.SPA_ROOT; + +const router = Router(); + +if (root) { + router.use(express.static(root, { maxAge: 31536000 })); + router.get("*", (_req, res) => { + res.sendFile(path.join(root, "index.html")); + }); +} + +export default router; diff --git a/iris/src/routes/v2/api.ts b/iris/src/routes/v2/api.ts new file mode 100644 index 00000000..22f5486d --- /dev/null +++ b/iris/src/routes/v2/api.ts @@ -0,0 +1,279 @@ +import Busboy from "busboy"; +import { Request, Router } from "express"; +import fs from "fs-extra"; + +//////////////////////////////////////////////////////////////////////////////// +interface IImage { + id: string; + date: string; +} + +interface IProject { + id: string; + name: string; + created?: Date; + modified?: Date; + opened?: Date; + labels?: string[]; + images?: number; +} + +interface IProjectDetails { + id?: string; + name: string; + created: Date; + annotations: { + version: string; + labels: string[]; + annotations: {}; // TODO + images: IImage[]; + }; +} + +interface IOptions { + name: string; + projectID?: string; +} + +interface IConnection { + id: string; + name: string; + icon?: string; +} + +interface Provider { + getConnections: () => Promise; + getProjects: ({ + connectionID, + }: { + connectionID: string; + }) => Promise; + createProject: ({ + connectionID, + name, + }: { + connectionID: string; + name: string; + }) => any; + getProject: ( + options: Pick + ) => Promise; + persist: (annotations: any, options: Pick) => any; + getImage: ( + imageID: string, + options: Pick + ) => Promise; + deleteImage: (imageID: string, options: Pick) => any; + saveImage: (file: NodeJS.ReadableStream, options: IOptions) => any; +} + +// TODO: pull from package.json +let extensions = [ + "../../plugins/iris-server-plugin-file-system", + // "../../plugins/iris-server-plugin-cos", +]; +let providers: { [key: string]: Provider } = {}; +const iris = { + providers: { + register: ({ id, provider }: any) => { + providers[id] = provider; + }, + }, +}; +for (const extension of extensions) { + try { + const provider = require(extension); + provider.activate(iris); + } catch (e) { + console.log(e); + } +} +//////////////////////////////////////////////////////////////////////////////// + +const router = Router(); + +function getProjectID(req: Request) { + const { projectID } = req.query; + if (process.env.SINGLE_DOCUMENT_MODE && projectID === undefined) { + return projectID; + } + if (!process.env.SINGLE_DOCUMENT_MODE && typeof projectID === "string") { + return projectID; + } + throw new Error( + `projectID "${projectID}" of type "${typeof projectID}" is not allowed in ${ + process.env.SINGLE_DOCUMENT_MODE + ? "single document mode" + : "projects mode" + }` + ); +} + +function requiredQuery(req: Request, param: string) { + const x = req.query[param]; + if (x === undefined) { + throw new Error(`${param} is undefined`); + } + if (typeof x === "string") { + return x; + } + throw new Error(`${param} "${x}" of type "${typeof x}" is not allowed`); +} + +function ensureProjectMode() { + if (process.env.SINGLE_DOCUMENT_MODE) { + throw new Error("Projects not available in single document mode"); + } +} + +/** + * GET /api/mode + */ +router.get("/mode", async (_req, res) => { + res.json({ singleDocument: !!process.env.SINGLE_DOCUMENT_MODE }); +}); + +/** + * GET /api/connections + */ +router.get("/connections", async (_req, res, next) => { + try { + ensureProjectMode(); + let connections: IConnection[] = []; + for (const provider of Object.values(providers)) { + const c = await provider.getConnections(); + connections.push(...c); + } + res.json(connections); + } catch (e) { + next(e); + } +}); + +/** + * GET /api/projects + */ +router.get("/projects", async (req, res, next) => { + try { + ensureProjectMode(); + const providerID = requiredQuery(req, "providerID"); + const connectionID = requiredQuery(req, "connectionID"); + const projects = await providers[providerID].getProjects({ connectionID }); + res.json(projects); + } catch (e) { + next(e); + } +}); + +/** + * POST /api/projects + */ +router.post("/projects", async (req, res, next) => { + try { + ensureProjectMode(); + const providerID = requiredQuery(req, "providerID"); + const connectionID = requiredQuery(req, "connectionID"); + const name = requiredQuery(req, "name"); + await providers[providerID].createProject({ connectionID, name }); + res.sendStatus(200); + } catch (e) { + next(e); + } +}); + +/** + * GET /api/project + * @queryParam {string} [projectID] + */ +router.get("/project", async (req, res, next) => { + try { + const projectID = getProjectID(req); + const project = await providers["file-system"].getProject({ projectID }); + res.json(project); + } catch (e) { + next(e); + } +}); + +/** + * PUT /api/project + * @queryParam {string} [projectID] + */ +router.put("/project", async (req, res, next) => { + try { + const projectID = getProjectID(req); + await providers["file-system"].persist(req.body, { projectID }); + res.sendStatus(200); + } catch (e) { + next(e); + } +}); + +/** + * GET /api/images/:imageID + * @pathParam {string} imageID + * @queryParam {string} [projectID] + */ +router.get("/images/:imageID", async (req, res, next) => { + const { imageID } = req.params; + + try { + const projectID = getProjectID(req); + const image = await providers["file-system"].getImage(imageID, { + projectID, + }); + + image.on("error", (e) => { + throw e; + }); + + res.set("Content-Type", "image/jpeg"); + image.pipe(res); + } catch (e) { + next(e); + } +}); + +/** + * DELETE /api/images/:imageID + * @pathParam {string} imageID + * @queryParam {string} [projectID] + */ +router.delete("/images/:imageID", async (req, res, next) => { + const { imageID } = req.params; + + try { + const projectID = getProjectID(req); + await providers["file-system"].deleteImage(imageID, { projectID }); + res.sendStatus(200); + } catch (e) { + next(e); + } +}); + +/** + * POST /api/images + * @queryParam {string} [projectID] + */ +router.post("/images", async (req, res, next) => { + try { + const projectID = getProjectID(req); + const busboy = new Busboy({ + headers: req.headers, + limits: { + files: 1, + }, + }); + + busboy.on("file", async (name, file) => { + await providers["file-system"].saveImage(file, { name, projectID }); + res.sendStatus(200); + }); + + req.pipe(busboy); + } catch (e) { + next(e); + } +}); + +export default router; diff --git a/iris/src/routes/v2/auth.ts b/iris/src/routes/v2/auth.ts new file mode 100644 index 00000000..503cf205 --- /dev/null +++ b/iris/src/routes/v2/auth.ts @@ -0,0 +1,9 @@ +import { Router } from "express"; + +const router = Router(); + +router.get("/status", (_req, res) => { + res.sendStatus(200); +}); + +export default router; diff --git a/iris/src/routes/v2/index.ts b/iris/src/routes/v2/index.ts new file mode 100644 index 00000000..f2f86a04 --- /dev/null +++ b/iris/src/routes/v2/index.ts @@ -0,0 +1,11 @@ +import { Router } from "express"; + +import apiRouter from "./api"; +import authRouter from "./auth"; + +const router = Router(); + +router.use("/auth", authRouter); +router.use("/", apiRouter); + +export default router; diff --git a/iris/tsconfig.json b/iris/tsconfig.json new file mode 100644 index 00000000..ab20b976 --- /dev/null +++ b/iris/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "declarationMap": true, + "declaration": true, + "sourceMap": true, + "outDir": "dist", + + "target": "es2017", + "lib": ["es2017", "es2019.array"], + "module": "commonjs" + }, + "include": ["src"] +} diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..caa26704 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,12 @@ +module.exports = { + testEnvironment: "jest-environment-jsdom", + setupFilesAfterEnv: ["@testing-library/jest-dom/extend-expect"], + coverageThreshold: { + global: { + statements: 15, + branches: 10, + functions: 15, + lines: 15, + }, + }, +}; diff --git a/k8s-base/frontend.yaml b/k8s-base/frontend.yaml deleted file mode 100644 index 27f52d68..00000000 --- a/k8s-base/frontend.yaml +++ /dev/null @@ -1,38 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: frontend - labels: - app: annotations - tier: frontend -spec: - ports: - - port: 443 - targetPort: 8080 - protocol: TCP - selector: - app: annotations - tier: frontend ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: frontend -spec: - selector: - matchLabels: - app: annotations - tier: frontend - replicas: 6 - template: - metadata: - labels: - app: annotations - tier: frontend - spec: - containers: - - name: frontend - image: us.icr.io/cloud-annotations/frontend:em-patch-2 - imagePullPolicy: Always - ports: - - containerPort: 8080 diff --git a/k8s-base/redis.yaml b/k8s-base/redis.yaml deleted file mode 100644 index eaf52a62..00000000 --- a/k8s-base/redis.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - labels: - run: redis - name: redis -spec: - ports: - - port: 6379 - targetPort: 6379 - selector: - run: redis ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: redis -spec: - replicas: 1 - selector: - matchLabels: - run: redis - template: - metadata: - labels: - run: redis - - spec: - containers: - - name: redis - image: redis - ports: - - containerPort: 6379 \ No newline at end of file diff --git a/k8s-prod/ingress.yaml b/k8s-prod/ingress.yaml deleted file mode 100644 index 19cab825..00000000 --- a/k8s-prod/ingress.yaml +++ /dev/null @@ -1,73 +0,0 @@ -apiVersion: networking.k8s.io/v1beta1 -kind: Ingress -metadata: - name: ingress - annotations: - ingress.bluemix.net/sticky-cookie-services: "serviceName=frontend name=sticky expires=5h path=/ hash=sha1" - ingress.bluemix.net/client-max-body-size: "size=10m" - # nginx.ingress.kubernetes.io/affinity: "cookie" - # nginx.ingress.kubernetes.io/proxy-body-size: "10m" -spec: - tls: - - hosts: - - cloud.annotations.ai - secretName: certbot-annotations-secret - - hosts: - - annotations2-442dbba0442be6c8c50f31ed96b00601-0000.us-east.containers.appdomain.cloud - secretName: annotations2-442dbba0442be6c8c50f31ed96b00601-0000 - rules: - - host: cloud.annotations.ai - http: - paths: - - path: / - backend: - serviceName: frontend - servicePort: 443 - - path: /docs - backend: - serviceName: docs - servicePort: 80 - - path: /workshops - backend: - serviceName: docs - servicePort: 80 - - path: /demos - backend: - serviceName: docs - servicePort: 80 - - path: /sdks - backend: - serviceName: docs - servicePort: 80 - - path: /docs-assets - backend: - serviceName: docs - servicePort: 80 - - - host: annotations2-442dbba0442be6c8c50f31ed96b00601-0000.us-east.containers.appdomain.cloud - http: - paths: - - path: / - backend: - serviceName: frontend - servicePort: 443 - - path: /docs - backend: - serviceName: docs - servicePort: 80 - - path: /workshops - backend: - serviceName: docs - servicePort: 80 - - path: /demos - backend: - serviceName: docs - servicePort: 80 - - path: /sdks - backend: - serviceName: docs - servicePort: 80 - - path: /docs-assets - backend: - serviceName: docs - servicePort: 80 \ No newline at end of file diff --git a/k8s-stage/ingress.yaml b/k8s-stage/ingress.yaml deleted file mode 100644 index 20c90dd8..00000000 --- a/k8s-stage/ingress.yaml +++ /dev/null @@ -1,40 +0,0 @@ -apiVersion: networking.k8s.io/v1beta1 -kind: Ingress -metadata: - name: ingress - annotations: - nginx.ingress.kubernetes.io/affinity: "cookie" - nginx.ingress.kubernetes.io/proxy-body-size: "10m" -spec: - tls: - - hosts: - - test.cloud.annotations.ai - secretName: stage-annotations-secret - rules: - - host: test.cloud.annotations.ai - http: - paths: - - path: / - backend: - serviceName: frontend - servicePort: 443 - - path: /docs - backend: - serviceName: docs - servicePort: 80 - - path: /workshops - backend: - serviceName: docs - servicePort: 80 - - path: /demos - backend: - serviceName: docs - servicePort: 80 - - path: /sdks - backend: - serviceName: docs - servicePort: 80 - - path: /docs-assets - backend: - serviceName: docs - servicePort: 80 diff --git a/lerna.json b/lerna.json new file mode 100644 index 00000000..c08ce8ae --- /dev/null +++ b/lerna.json @@ -0,0 +1,9 @@ +{ + "packages": [ + "packages/*", + "iris" + ], + "npmClient": "yarn", + "useWorkspaces": true, + "version": "0.0.0" +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..eab2b7ae --- /dev/null +++ b/package.json @@ -0,0 +1,58 @@ +{ + "name": "iris", + "private": true, + "workspaces": { + "packages": [ + "packages/*", + "iris" + ] + }, + "scripts": { + "test": "is-ci \"test:coverage\" \"test:watch\"", + "test:coverage": "jest --coverage", + "test:watch": "jest --watch", + "test:debug": "node --inspect-brk ./node_modules/jest/bin/jest.js --runInBand --watch" + }, + "devDependencies": { + "@babel/core": "^7.7.4", + "@babel/plugin-transform-runtime": "^7.7.4", + "@babel/preset-env": "^7.7.4", + "@babel/preset-react": "^7.7.4", + "@babel/preset-typescript": "^7.12.7", + "@babel/runtime": "^7.7.4", + "@testing-library/jest-dom": "^4.2.4", + "@testing-library/react": "^9.3.2", + "@types/jest": "^26.0.17", + "@typescript-eslint/eslint-plugin": "2.x", + "@typescript-eslint/parser": "2.x", + "babel-eslint": "10.x", + "babel-jest": "^26.6.3", + "babel-loader": "^8.1.0", + "babel-plugin-named-asset-import": "^0.3.7", + "babel-preset-react-app": "^10.0.0", + "eslint": "6.x", + "eslint-config-react-app": "^5.2.1", + "eslint-plugin-flowtype": "4.x", + "eslint-plugin-import": "^2.22.0", + "eslint-plugin-jsx-a11y": "6.x", + "eslint-plugin-react": "7.x", + "eslint-plugin-react-hooks": "2.x", + "husky": "^3.1.0", + "is-ci-cli": "^2.0.0", + "jest": "^24.9.0", + "lerna": "^3.22.1", + "lint-staged": "^9.4.3", + "typescript": "^4.0.5" + }, + "husky": { + "hooks": { + "pre-commit": "lint-staged" + } + }, + "lint-staged": { + "*.{tsx,ts,js,md,css,html}": [ + "prettier --write", + "git add" + ] + } +} diff --git a/packages/iris-api/package.json b/packages/iris-api/package.json new file mode 100644 index 00000000..86b51053 --- /dev/null +++ b/packages/iris-api/package.json @@ -0,0 +1,14 @@ +{ + "name": "@iris/api", + "version": "0.0.1", + "main": "dist/index.js", + "license": "MIT", + "scripts": { + "start": "tsc -w", + "build": "tsc" + }, + "dependencies": { + "@types/uuid": "^8.3.0", + "swr": "^0.3.6" + } +} diff --git a/packages/iris-api/src/api.ts b/packages/iris-api/src/api.ts new file mode 100644 index 00000000..7ef1791b --- /dev/null +++ b/packages/iris-api/src/api.ts @@ -0,0 +1,57 @@ +import endpoint from "./endpoint"; +import { APIOptions, HttpError, MethodOptions } from "./types"; +import { stripEmptyKeys } from "./utils"; + +export async function fetcher(endpoint: RequestInfo, options?: RequestInit) { + const res = await fetch(endpoint, options); + + if (!res.ok) { + const error = new Error(res.statusText) as HttpError; + error.status = res.status; + throw error; + } + + // swallow json parse errors + // TODO: handle this better, what about `res.blob()` etc... + try { + return await res.json(); + } catch {} +} + +export async function request(route: string, options: APIOptions) { + const { body, json, headers, method, signal, ...rest } = options; + + const url = endpoint(route, rest); + + return await fetcher(url, { + signal, + method, + headers: headers ? stripEmptyKeys(headers) : undefined, + body: json ? JSON.stringify(json) : body, + }); +} + +async function get(route: string, options: MethodOptions = {}) { + return await request(route, { ...options, method: "GET" }); +} + +async function post(route: string, options: MethodOptions = {}) { + return await request(route, { ...options, method: "POST" }); +} + +async function put(route: string, options: MethodOptions = {}) { + return await request(route, { ...options, method: "PUT" }); +} + +async function del(route: string, options: MethodOptions = {}) { + return await request(route, { ...options, method: "DELETE" }); +} + +const api = { + get, + post, + put, + del, +}; + +export default api; diff --git a/packages/iris-api/src/endpoint.ts b/packages/iris-api/src/endpoint.ts new file mode 100644 index 00000000..10172640 --- /dev/null +++ b/packages/iris-api/src/endpoint.ts @@ -0,0 +1,22 @@ +import { EndpointOptions, PathParams, QueryParams } from "./types"; +import { stripEmptyKeys } from "./utils"; + +function injectPathParams(route: string, path?: PathParams) { + return route.replace(/\/:(\w+)/gi, (_match, group1) => `/${path?.[group1]}`); +} + +function queryString(query?: QueryParams) { + if (query !== undefined) { + const safe = stripEmptyKeys(query); + return "?" + new URLSearchParams(safe).toString(); + } + return ""; +} + +function endpoint(route: string, options: EndpointOptions = {}) { + const { baseUrl = "/api/v2", path, query } = options; + + return baseUrl + injectPathParams(route, path) + queryString(query); +} + +export default endpoint; diff --git a/packages/iris-api/src/index.ts b/packages/iris-api/src/index.ts new file mode 100644 index 00000000..14ccc0a4 --- /dev/null +++ b/packages/iris-api/src/index.ts @@ -0,0 +1,4 @@ +export * from "./types"; +export * from "./api"; +export { default as api } from "./api"; +export { default as endpoint } from "./endpoint"; diff --git a/packages/iris-api/src/types.ts b/packages/iris-api/src/types.ts new file mode 100644 index 00000000..7305fe4e --- /dev/null +++ b/packages/iris-api/src/types.ts @@ -0,0 +1,39 @@ +export interface PathParams { + [key: string]: string | undefined; +} + +export interface QueryParams { + [key: string]: string | undefined; +} + +export interface Headers { + [key: string]: string | undefined; +} + +export interface EndpointOptions { + baseUrl?: string; + path?: PathParams; + query?: QueryParams; +} + +export interface MethodOptions extends EndpointOptions { + headers?: Headers; + body?: + | string + | URLSearchParams + | FormData + | Blob + | ArrayBufferView + | ArrayBuffer + | ReadableStream; + json?: any; + signal?: AbortSignal; +} + +export interface APIOptions extends MethodOptions { + method: string; +} + +export interface HttpError extends Error { + status: number; +} diff --git a/packages/iris-api/src/utils.ts b/packages/iris-api/src/utils.ts new file mode 100644 index 00000000..7b0f2879 --- /dev/null +++ b/packages/iris-api/src/utils.ts @@ -0,0 +1,9 @@ +export function stripEmptyKeys(o: { [key: string]: T | undefined }) { + let safe: { [key: string]: T } = {}; + for (const [key, val] of Object.entries(o)) { + if (val !== undefined && val !== null) { + safe[key] = val; + } + } + return safe; +} diff --git a/packages/iris-api/tsconfig.json b/packages/iris-api/tsconfig.json new file mode 100644 index 00000000..d4d4911e --- /dev/null +++ b/packages/iris-api/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declarationMap": true, + "declaration": true, + "sourceMap": true, + "outDir": "dist", + + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "jsx": "react" + }, + "include": ["src"] +} diff --git a/packages/iris-app/config/env.js b/packages/iris-app/config/env.js new file mode 100644 index 00000000..09ec03c5 --- /dev/null +++ b/packages/iris-app/config/env.js @@ -0,0 +1,101 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const paths = require('./paths'); + +// Make sure that including paths.js after env.js will read .env variables. +delete require.cache[require.resolve('./paths')]; + +const NODE_ENV = process.env.NODE_ENV; +if (!NODE_ENV) { + throw new Error( + 'The NODE_ENV environment variable is required but was not specified.' + ); +} + +// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use +const dotenvFiles = [ + `${paths.dotenv}.${NODE_ENV}.local`, + `${paths.dotenv}.${NODE_ENV}`, + // Don't include `.env.local` for `test` environment + // since normally you expect tests to produce the same + // results for everyone + NODE_ENV !== 'test' && `${paths.dotenv}.local`, + paths.dotenv, +].filter(Boolean); + +// Load environment variables from .env* files. Suppress warnings using silent +// if this file is missing. dotenv will never modify any environment variables +// that have already been set. Variable expansion is supported in .env files. +// https://github.com/motdotla/dotenv +// https://github.com/motdotla/dotenv-expand +dotenvFiles.forEach(dotenvFile => { + if (fs.existsSync(dotenvFile)) { + require('dotenv-expand')( + require('dotenv').config({ + path: dotenvFile, + }) + ); + } +}); + +// We support resolving modules according to `NODE_PATH`. +// This lets you use absolute paths in imports inside large monorepos: +// https://github.com/facebook/create-react-app/issues/253. +// It works similar to `NODE_PATH` in Node itself: +// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders +// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. +// Otherwise, we risk importing Node.js core modules into an app instead of webpack shims. +// https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421 +// We also resolve them to make sure all tools using them work consistently. +const appDirectory = fs.realpathSync(process.cwd()); +process.env.NODE_PATH = (process.env.NODE_PATH || '') + .split(path.delimiter) + .filter(folder => folder && !path.isAbsolute(folder)) + .map(folder => path.resolve(appDirectory, folder)) + .join(path.delimiter); + +// Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be +// injected into the application via DefinePlugin in webpack configuration. +const REACT_APP = /^REACT_APP_/i; + +function getClientEnvironment(publicUrl) { + const raw = Object.keys(process.env) + .filter(key => REACT_APP.test(key)) + .reduce( + (env, key) => { + env[key] = process.env[key]; + return env; + }, + { + // Useful for determining whether we’re running in production mode. + // Most importantly, it switches React into the correct mode. + NODE_ENV: process.env.NODE_ENV || 'development', + // Useful for resolving the correct path to static assets in `public`. + // For example, . + // This should only be used as an escape hatch. Normally you would put + // images into the `src` and `import` them in code to get their paths. + PUBLIC_URL: publicUrl, + // We support configuring the sockjs pathname during development. + // These settings let a developer run multiple simultaneous projects. + // They are used as the connection `hostname`, `pathname` and `port` + // in webpackHotDevClient. They are used as the `sockHost`, `sockPath` + // and `sockPort` options in webpack-dev-server. + WDS_SOCKET_HOST: process.env.WDS_SOCKET_HOST, + WDS_SOCKET_PATH: process.env.WDS_SOCKET_PATH, + WDS_SOCKET_PORT: process.env.WDS_SOCKET_PORT, + } + ); + // Stringify all values so we can feed into webpack DefinePlugin + const stringified = { + 'process.env': Object.keys(raw).reduce((env, key) => { + env[key] = JSON.stringify(raw[key]); + return env; + }, {}), + }; + + return { raw, stringified }; +} + +module.exports = getClientEnvironment; diff --git a/packages/iris-app/config/getHttpsConfig.js b/packages/iris-app/config/getHttpsConfig.js new file mode 100644 index 00000000..013d493c --- /dev/null +++ b/packages/iris-app/config/getHttpsConfig.js @@ -0,0 +1,66 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const chalk = require('react-dev-utils/chalk'); +const paths = require('./paths'); + +// Ensure the certificate and key provided are valid and if not +// throw an easy to debug error +function validateKeyAndCerts({ cert, key, keyFile, crtFile }) { + let encrypted; + try { + // publicEncrypt will throw an error with an invalid cert + encrypted = crypto.publicEncrypt(cert, Buffer.from('test')); + } catch (err) { + throw new Error( + `The certificate "${chalk.yellow(crtFile)}" is invalid.\n${err.message}` + ); + } + + try { + // privateDecrypt will throw an error with an invalid key + crypto.privateDecrypt(key, encrypted); + } catch (err) { + throw new Error( + `The certificate key "${chalk.yellow(keyFile)}" is invalid.\n${ + err.message + }` + ); + } +} + +// Read file and throw an error if it doesn't exist +function readEnvFile(file, type) { + if (!fs.existsSync(file)) { + throw new Error( + `You specified ${chalk.cyan( + type + )} in your env, but the file "${chalk.yellow(file)}" can't be found.` + ); + } + return fs.readFileSync(file); +} + +// Get the https config +// Return cert files if provided in env, otherwise just true or false +function getHttpsConfig() { + const { SSL_CRT_FILE, SSL_KEY_FILE, HTTPS } = process.env; + const isHttps = HTTPS === 'true'; + + if (isHttps && SSL_CRT_FILE && SSL_KEY_FILE) { + const crtFile = path.resolve(paths.appPath, SSL_CRT_FILE); + const keyFile = path.resolve(paths.appPath, SSL_KEY_FILE); + const config = { + cert: readEnvFile(crtFile, 'SSL_CRT_FILE'), + key: readEnvFile(keyFile, 'SSL_KEY_FILE'), + }; + + validateKeyAndCerts({ ...config, keyFile, crtFile }); + return config; + } + return isHttps; +} + +module.exports = getHttpsConfig; diff --git a/packages/iris-app/config/jest/cssTransform.js b/packages/iris-app/config/jest/cssTransform.js new file mode 100644 index 00000000..8f651148 --- /dev/null +++ b/packages/iris-app/config/jest/cssTransform.js @@ -0,0 +1,14 @@ +'use strict'; + +// This is a custom Jest transformer turning style imports into empty objects. +// http://facebook.github.io/jest/docs/en/webpack.html + +module.exports = { + process() { + return 'module.exports = {};'; + }, + getCacheKey() { + // The output is always the same. + return 'cssTransform'; + }, +}; diff --git a/packages/iris-app/config/jest/fileTransform.js b/packages/iris-app/config/jest/fileTransform.js new file mode 100644 index 00000000..aab67618 --- /dev/null +++ b/packages/iris-app/config/jest/fileTransform.js @@ -0,0 +1,40 @@ +'use strict'; + +const path = require('path'); +const camelcase = require('camelcase'); + +// This is a custom Jest transformer turning file imports into filenames. +// http://facebook.github.io/jest/docs/en/webpack.html + +module.exports = { + process(src, filename) { + const assetFilename = JSON.stringify(path.basename(filename)); + + if (filename.match(/\.svg$/)) { + // Based on how SVGR generates a component name: + // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6 + const pascalCaseFilename = camelcase(path.parse(filename).name, { + pascalCase: true, + }); + const componentName = `Svg${pascalCaseFilename}`; + return `const React = require('react'); + module.exports = { + __esModule: true, + default: ${assetFilename}, + ReactComponent: React.forwardRef(function ${componentName}(props, ref) { + return { + $$typeof: Symbol.for('react.element'), + type: 'svg', + ref: ref, + key: null, + props: Object.assign({}, props, { + children: ${assetFilename} + }) + }; + }), + };`; + } + + return `module.exports = ${assetFilename};`; + }, +}; diff --git a/packages/iris-app/config/modules.js b/packages/iris-app/config/modules.js new file mode 100644 index 00000000..c8efd0dd --- /dev/null +++ b/packages/iris-app/config/modules.js @@ -0,0 +1,141 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const paths = require('./paths'); +const chalk = require('react-dev-utils/chalk'); +const resolve = require('resolve'); + +/** + * Get additional module paths based on the baseUrl of a compilerOptions object. + * + * @param {Object} options + */ +function getAdditionalModulePaths(options = {}) { + const baseUrl = options.baseUrl; + + // We need to explicitly check for null and undefined (and not a falsy value) because + // TypeScript treats an empty string as `.`. + if (baseUrl == null) { + // If there's no baseUrl set we respect NODE_PATH + // Note that NODE_PATH is deprecated and will be removed + // in the next major release of create-react-app. + + const nodePath = process.env.NODE_PATH || ''; + return nodePath.split(path.delimiter).filter(Boolean); + } + + const baseUrlResolved = path.resolve(paths.appPath, baseUrl); + + // We don't need to do anything if `baseUrl` is set to `node_modules`. This is + // the default behavior. + if (path.relative(paths.appNodeModules, baseUrlResolved) === '') { + return null; + } + + // Allow the user set the `baseUrl` to `appSrc`. + if (path.relative(paths.appSrc, baseUrlResolved) === '') { + return [paths.appSrc]; + } + + // If the path is equal to the root directory we ignore it here. + // We don't want to allow importing from the root directly as source files are + // not transpiled outside of `src`. We do allow importing them with the + // absolute path (e.g. `src/Components/Button.js`) but we set that up with + // an alias. + if (path.relative(paths.appPath, baseUrlResolved) === '') { + return null; + } + + // Otherwise, throw an error. + throw new Error( + chalk.red.bold( + "Your project's `baseUrl` can only be set to `src` or `node_modules`." + + ' Create React App does not support other values at this time.' + ) + ); +} + +/** + * Get webpack aliases based on the baseUrl of a compilerOptions object. + * + * @param {*} options + */ +function getWebpackAliases(options = {}) { + const baseUrl = options.baseUrl; + + if (!baseUrl) { + return {}; + } + + const baseUrlResolved = path.resolve(paths.appPath, baseUrl); + + if (path.relative(paths.appPath, baseUrlResolved) === '') { + return { + src: paths.appSrc, + }; + } +} + +/** + * Get jest aliases based on the baseUrl of a compilerOptions object. + * + * @param {*} options + */ +function getJestAliases(options = {}) { + const baseUrl = options.baseUrl; + + if (!baseUrl) { + return {}; + } + + const baseUrlResolved = path.resolve(paths.appPath, baseUrl); + + if (path.relative(paths.appPath, baseUrlResolved) === '') { + return { + '^src/(.*)$': '/src/$1', + }; + } +} + +function getModules() { + // Check if TypeScript is setup + const hasTsConfig = fs.existsSync(paths.appTsConfig); + const hasJsConfig = fs.existsSync(paths.appJsConfig); + + if (hasTsConfig && hasJsConfig) { + throw new Error( + 'You have both a tsconfig.json and a jsconfig.json. If you are using TypeScript please remove your jsconfig.json file.' + ); + } + + let config; + + // If there's a tsconfig.json we assume it's a + // TypeScript project and set up the config + // based on tsconfig.json + if (hasTsConfig) { + const ts = require(resolve.sync('typescript', { + basedir: paths.appNodeModules, + })); + config = ts.readConfigFile(paths.appTsConfig, ts.sys.readFile).config; + // Otherwise we'll check if there is jsconfig.json + // for non TS projects. + } else if (hasJsConfig) { + config = require(paths.appJsConfig); + } + + config = config || {}; + const options = config.compilerOptions || {}; + + const additionalModulePaths = getAdditionalModulePaths(options); + + return { + additionalModulePaths: additionalModulePaths, + webpackAliases: getWebpackAliases(options), + jestAliases: getJestAliases(options), + hasTsConfig, + }; +} + +module.exports = getModules(); diff --git a/packages/iris-app/config/paths.js b/packages/iris-app/config/paths.js new file mode 100644 index 00000000..cbd59034 --- /dev/null +++ b/packages/iris-app/config/paths.js @@ -0,0 +1,71 @@ +"use strict"; + +const path = require("path"); +const fs = require("fs"); +const getPublicUrlOrPath = require("react-dev-utils/getPublicUrlOrPath"); + +// Make sure any symlinks in the project folder are resolved: +// https://github.com/facebook/create-react-app/issues/637 +const appDirectory = fs.realpathSync(process.cwd()); +const resolveApp = (relativePath) => path.resolve(appDirectory, relativePath); + +// We use `PUBLIC_URL` environment variable or "homepage" field to infer +// "public path" at which the app is served. +// webpack needs to know it to put the right + +