├── .cargo └── config ├── .editorconfig ├── .env ├── .github ├── playground-icon.svg └── workflows │ ├── ci-playground.yml │ ├── ci-templates.yml │ └── ct-playground.yml ├── .gitignore ├── Makefile ├── README.md ├── backend ├── .dockerignore ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── README.md └── src │ ├── api.rs │ ├── error.rs │ ├── github.rs │ ├── kubernetes.rs │ ├── main.rs │ ├── manager.rs │ ├── metrics.rs │ ├── prometheus.rs │ └── types.rs ├── client ├── README.md ├── package.json ├── src │ ├── index.ts │ ├── login.ts │ ├── rpc.ts │ ├── session.ts │ ├── types.ts │ └── utils.ts ├── tsconfig.browser.json ├── tsconfig.json └── yarn.lock ├── conf ├── k8s │ ├── base │ │ ├── backend-api-deployment.yaml │ │ ├── backend-api-service.yaml │ │ ├── backend-ui-deployment.yaml │ │ ├── backend-ui-service.yaml │ │ ├── cluster-role-binding.yaml │ │ ├── grafana │ │ │ ├── dashboards │ │ │ │ └── home.json │ │ │ ├── datasources.yaml │ │ │ ├── deployment.yaml │ │ │ ├── kustomization.yaml │ │ │ ├── providers.yaml │ │ │ ├── service.yaml │ │ │ └── volume.yaml │ │ ├── ingress.yaml │ │ ├── kustomization.yaml │ │ ├── nginx.yaml │ │ ├── node-conf-daemon-set.yaml │ │ ├── prepull-templates.yaml │ │ ├── prometheus │ │ │ ├── deployment.yaml │ │ │ ├── kustomization.yaml │ │ │ ├── prometheus.yaml │ │ │ ├── rbac.yaml │ │ │ └── service.yaml │ │ └── service-account.yaml │ ├── dashboard-user.yaml │ ├── overlays │ │ ├── dev │ │ │ ├── kustomization.yaml │ │ │ ├── templates │ │ │ │ └── kustomization.yaml │ │ │ └── users │ │ │ │ └── jeluard │ │ └── production │ │ │ ├── kustomization.yaml │ │ │ ├── templates │ │ │ └── kustomization.yaml │ │ │ └── users │ │ │ ├── bjornwgnr │ │ │ └── jeluard │ └── skaffold.yaml └── templates │ ├── .env │ ├── front-end-template │ ├── ink │ ├── node-template │ ├── node-template-openvscode │ ├── parachain-template │ └── recipes ├── docs ├── building │ └── cicd.md ├── extending │ └── custom-template.md ├── operating │ └── deployment.md └── using │ ├── 00-demo.gif │ └── overview.md ├── e2e ├── README.md ├── ava.config.js ├── package.json ├── test │ ├── test.ts │ └── website.ts └── yarn.lock ├── frontend ├── .dockerignore ├── .eslintrc.cjs ├── Dockerfile ├── README.md ├── ava.config.js ├── conf │ └── nginx.conf ├── package.json ├── public │ ├── assets │ │ ├── favicon.png │ │ └── images │ │ │ ├── logo.png │ │ │ ├── logo_substrate.svg │ │ │ └── logo_substrate_onDark.svg │ ├── index.html │ └── robots.txt ├── src │ ├── LogoSubstrate.tsx │ ├── components.tsx │ ├── hooks.tsx │ ├── index.tsx │ ├── lifecycle.tsx │ ├── panels │ │ ├── admin.tsx │ │ ├── login.tsx │ │ ├── session.tsx │ │ ├── stats.tsx │ │ ├── terms.tsx │ │ └── theia.tsx │ ├── terms.md │ ├── terms.tsx │ ├── themes │ │ ├── index.ts │ │ └── substrate │ │ │ ├── colors.ts │ │ │ ├── dark.ts │ │ │ ├── light.ts │ │ │ ├── shadows.ts │ │ │ └── typography.ts │ └── utils.tsx ├── test │ └── connect.ts ├── tsconfig.json └── yarn.lock └── templates ├── .dockerignore ├── Dockerfile.base ├── Dockerfile.template ├── Dockerfile.theia-base ├── Dockerfile.theia-template ├── README.md ├── conf └── .vscode │ └── settings.json ├── lerna.json ├── package.json ├── theia-playground-extension ├── assets │ └── substrate-logo.png ├── package.json ├── src │ ├── browser │ │ ├── http-location-mapper.ts │ │ ├── initial-files-open.ts │ │ ├── style │ │ │ └── index.css │ │ ├── theia-playground-extension-contribution.ts │ │ └── theia-playground-extension-frontend-module.ts │ └── node │ │ ├── file-download-handler.ts │ │ └── theia-playground-extension-backend-module.ts └── tsconfig.json ├── theia-playground ├── .vscode │ └── settings.json ├── package.json └── webpack.config.js └── yarn.lock /.cargo/config: -------------------------------------------------------------------------------- 1 | [alias] 2 | check-fmt = "fmt --all -- --check" 3 | lint = "clippy -- -D warnings -A deprecated" -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 4 10 | 11 | [*.rs] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.toml] 16 | indent_style = space 17 | indent_size = 4 18 | 19 | [*.yml] 20 | indent_style = space 21 | indent_size = 2 22 | 23 | [*.md] 24 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | NAMESPACE=playground 2 | GKE_PROJECT=aerobic-factor-306517 3 | GKE_ZONE=us-central1-a 4 | DOCKER_ORG=paritytech 5 | -------------------------------------------------------------------------------- /.github/playground-icon.svg: -------------------------------------------------------------------------------- 1 | Parity Substrate icon -------------------------------------------------------------------------------- /.github/workflows/ci-playground.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration playground 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '.github/workflows/ci-playground.yml' 7 | - 'backend/**' 8 | - 'frontend/**' 9 | 10 | jobs: 11 | frontend: 12 | runs-on: ubuntu-latest 13 | if: "! contains(github.event.head_commit.message, '[CI Skip]')" 14 | defaults: 15 | run: 16 | working-directory: ./frontend 17 | steps: 18 | - uses: actions/checkout@v2 19 | 20 | - name: Setup node 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: "14" 24 | 25 | - name: Install 26 | run: yarn install --check-files 27 | 28 | - name: Lint 29 | run: yarn lint 30 | 31 | - name: Audit 32 | run: yarn audit 33 | 34 | - name: Build 35 | run: yarn build 36 | 37 | - name: Test 38 | run: yarn test 39 | 40 | backend: 41 | runs-on: ubuntu-latest 42 | if: "! contains(github.event.head_commit.message, '[CI Skip]')" 43 | defaults: 44 | run: 45 | working-directory: ./backend 46 | steps: 47 | 48 | - uses: actions/checkout@v2 49 | 50 | - uses: actions-rs/toolchain@v1 51 | with: 52 | toolchain: nightly 53 | profile: minimal 54 | override: true 55 | components: rustfmt, clippy 56 | 57 | - name: Check Formatting 58 | run: cargo check-fmt 59 | 60 | - name: Lint 61 | run: cargo lint 62 | 63 | - name: Build 64 | run: cargo build --verbose 65 | 66 | - name: Run tests 67 | run: cargo test --verbose 68 | 69 | docker: 70 | runs-on: ubuntu-latest 71 | if: "! contains(github.event.head_commit.message, '[CI Skip]')" 72 | steps: 73 | 74 | - uses: actions/checkout@v2 75 | 76 | - uses: docker/setup-buildx-action@v1 77 | with: 78 | install: true 79 | 80 | - name: Build frontend Dockerfile 81 | run: cd frontend && docker build -f Dockerfile . 82 | 83 | - name: Build backend Dockerfile 84 | run: cd backend && docker build -f Dockerfile . 85 | 86 | - uses: AbsaOSS/k3d-action@b176c2a6dcae72e3e64e3e4d61751904ec314002 # v2.3.0 87 | with: 88 | cluster-name: "pg-cluster" 89 | args: >- 90 | --k3s-arg '--tls-san=127.0.0.1@server:*' 91 | --k3s-arg '--no-deploy=traefik@server:*' 92 | --k3s-node-label "cloud.google.com/gke-nodepool=default-workspace@server:0" 93 | --port 80:80@loadbalancer 94 | --port 443:443@loadbalancer 95 | 96 | - name: Set environment 97 | id: env 98 | run: | 99 | echo "sha=sha-${GITHUB_SHA::7}" >> $GITHUB_OUTPUT 100 | 101 | - name: Set up Kustomize 102 | run: |- 103 | curl -sfLo kustomize https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv3.6.1/kustomize_v3.6.1_linux_amd64.tar.gz 104 | tar zxvf kustomize* 105 | chmod u+x ./kustomize 106 | working-directory: resources/k8s/overlays/dev 107 | 108 | - name: Update version 109 | run: |- 110 | ./kustomize edit set image paritytech/substrate-playground-backend-api:${{ steps.env.outputs.sha }} 111 | ./kustomize edit set image paritytech/substrate-playground-backend-ui:${{ steps.env.outputs.sha }} 112 | working-directory: resources/k8s/overlays/dev 113 | 114 | - name: Load docker images 115 | run: k3d image import paritytech/substrate-playground-backend-api:${{ steps.env.outputs.sha }} paritytech/substrate-playground-backend-ui:${{ steps.env.outputs.sha }} -c pg-cluster 116 | 117 | - name: Configure playground 118 | run: |- 119 | make k8s-setup-env 120 | make dev-create-certificate 121 | make k8s-update-certificate 122 | env: 123 | GH_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }} 124 | GH_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }} 125 | 126 | - name: Deploy playground 127 | run: make k8s-deploy 128 | 129 | - name: Wait for deployment 130 | run: sleep 300 131 | 132 | # Will not run authenticated tests when triggered by PR 133 | # See https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ 134 | - name: Run e2e tests 135 | run: yarn && yarn test 136 | working-directory: ./e2e 137 | timeout-minutes: 3 138 | env: 139 | ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} 140 | -------------------------------------------------------------------------------- /.github/workflows/ci-templates.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration templates 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '.github/workflows/ci-templates.yml' 7 | - 'templates/**' 8 | 9 | jobs: 10 | templates: 11 | runs-on: ubuntu-latest 12 | if: "! contains(github.event.head_commit.message, '[CI Skip]')" 13 | defaults: 14 | run: 15 | working-directory: ./templates 16 | steps: 17 | - uses: actions/checkout@v2 18 | 19 | - name: Setup node 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: "14" 23 | 24 | - name: Install 25 | run: yarn install --check-files 26 | 27 | - name: Build 28 | run: yarn workspace @parity/theia-playground theia build 29 | 30 | - uses: docker/setup-buildx-action@v1 31 | with: 32 | install: true 33 | 34 | - name: Build Dockerfile.base 35 | run: docker build -f Dockerfile.base . 36 | 37 | - name: Build Dockerfile.theia-base 38 | run: docker build -f Dockerfile.theia-base . 39 | -------------------------------------------------------------------------------- /.github/workflows/ct-playground.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Testing playground 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | # * is a special character in YAML so you have to quote this string 7 | - cron: '*/30 * * * *' # Every 30 minutes 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | with: 15 | ref: prod 16 | 17 | - name: Setup node 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: "16" 21 | 22 | - name: Audit URLs using Lighthouse 23 | uses: treosh/lighthouse-ci-action@7.0.0 24 | with: 25 | urls: https://playground.substrate.dev/ 26 | 27 | - uses: microsoft/playwright-github-action@v1 28 | 29 | - name: Run tests 30 | run: yarn && yarn test 31 | working-directory: ./e2e 32 | timeout-minutes: 10 33 | env: 34 | ENV: production 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | node_modules/ 3 | **/dist/ 4 | frontend/.parcel-cache/ 5 | # Generated by theia itself 6 | templates/.yarnclean 7 | templates/**/lib/** 8 | templates/webpack.config.js 9 | templates/**/src-gen/ 10 | templates/**/out/ 11 | templates/**/lerna-debug.log 12 | templates/theia-playground/plugins 13 | templates/theia-playground/gen-webpack.config.js 14 | TODO.md 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](https://github.com/paritytech/substrate-playground/workflows/Continuous%20Testing%20Playground/badge.svg) 2 | 3 | # Substrate playground 4 | 5 | A hosted website that enables the user to navigate [Substrate](https://github.com/paritytech/substrate) runtimes, modify them and run remotely accessible chain. In a couple seconds! 6 | 7 |

8 | Playground demo 9 |

10 | 11 | Playground allows end-user to spin up a substrate based development environment in seconds. A full machine with terminal is then available from a web browser, ready to launch a chain and remotely access it. 12 | Playground templates can be [created and maintained](docs/extending/custom-template.md) by 3rd parties. 13 | 14 | ## Deployment 15 | 16 | Playground is a set of containerized apps deployed on a kubernetes cluster. Fear not, it's quite simple to [deploy](docs/operating/deployment.md) it! 17 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | node-client/ 2 | target/ 3 | Dockerfile -------------------------------------------------------------------------------- /backend/Cargo.toml: -------------------------------------------------------------------------------- 1 | [[bin]] 2 | name = "playground" 3 | 4 | [package] 5 | name = "playground" 6 | version = "0.1.0" 7 | authors = ["jeluard "] 8 | edition = "2018" 9 | 10 | [dependencies] 11 | log = "0.4.14" 12 | env_logger = "0.8.3" 13 | prometheus = "0.12.0" 14 | hyper = "0.14.12" 15 | hyper-tls = "0.5.0" 16 | json-patch = "0.2.6" 17 | rocket = "0.4.11" 18 | rocket_contrib = { version = "0.4.10", features = ["json"] } 19 | rocket_cors = "0.5.2" 20 | rocket_oauth2 = { version = "0.4.1", features = ["hyper_sync_rustls_adapter"] } 21 | serde = { version = "1.0.125", features = ["derive"] } 22 | serde_json = "1.0.64" 23 | serde_yaml = "0.8.17" 24 | kube = { version = "0.60.0", default-features = true, features = ["jsonpatch"] } 25 | k8s-openapi = { version = "0.13.0", default-features = false, features = ["v1_22"] } 26 | tokio = {version = "1.13.1", features = ["macros", "rt-multi-thread"] } 27 | thiserror = "1.0" 28 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # The server Dockerfile. 2 | # 3 | # A multi-stage docker image (https://docs.docker.com/develop/develop-images/multistage-build/) 4 | # Based on https://github.com/bjornmolin/rust-minimal-docker 5 | 6 | FROM clux/muslrust:nightly-2021-08-17 AS builder 7 | 8 | WORKDIR /opt 9 | 10 | ENV BINARY_NAME=playground 11 | 12 | # Build the project with target x86_64-unknown-linux-musl 13 | 14 | # Build dummy main with the project's Cargo lock and toml 15 | # This is a docker trick in order to avoid downloading and building 16 | # dependencies when lock and toml not is modified. 17 | 18 | COPY Cargo.* ./ 19 | 20 | RUN mkdir src \ 21 | && echo "fn main() {print!(\"Dummy main\");} // dummy file" > src/main.rs \ 22 | && set -x && cargo build --locked --target x86_64-unknown-linux-musl --release \ 23 | && set -x && rm target/x86_64-unknown-linux-musl/release/deps/$BINARY_NAME* 24 | 25 | # Now add the rest of the project and build the real main 26 | 27 | COPY src src 28 | 29 | RUN set -x && cargo build --frozen --release --out-dir=/opt/bin -Z unstable-options --target x86_64-unknown-linux-musl 30 | 31 | LABEL stage=builder 32 | 33 | ########################## 34 | # Runtime # 35 | ########################## 36 | 37 | FROM scratch 38 | 39 | ARG GITHUB_SHA 40 | 41 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 42 | 43 | ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt\ 44 | SSL_CERT_DIR=/etc/ssl/certs\ 45 | GITHUB_SHA=$GITHUB_SHA\ 46 | RUST_BACKTRACE=full\ 47 | RUST_LOG="warn,playground=info" 48 | 49 | COPY --from=builder /opt/bin/$BINARY_NAME / 50 | 51 | CMD ["/playground"] 52 | -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | # Quick start 2 | 3 | ## Configuration 4 | 5 | ### ConfigMaps 6 | 7 | ## Development server 8 | 9 | ```bash 10 | cargo run 11 | ``` -------------------------------------------------------------------------------- /backend/src/error.rs: -------------------------------------------------------------------------------- 1 | ///! Error type for the whole project 2 | /// 3 | use std::result; 4 | use thiserror::Error; 5 | 6 | /// A specialized [`Result`] type. 7 | pub type Result = result::Result; 8 | 9 | #[derive(Error, Debug)] 10 | pub enum Error { 11 | #[error("Unauthorized")] 12 | Unauthorized(/*Permission*/), 13 | #[error("Missing data {0}")] 14 | MissingData(&'static str), 15 | #[error("Failure: {0}")] 16 | Failure(#[from] Box), 17 | } 18 | -------------------------------------------------------------------------------- /backend/src/github.rs: -------------------------------------------------------------------------------- 1 | //! GitHub utility functions 2 | 3 | use body::aggregate; 4 | use core::fmt; 5 | use hyper::{ 6 | body::{self, Buf}, 7 | client::HttpConnector, 8 | header::{AUTHORIZATION, CONTENT_TYPE, USER_AGENT}, 9 | http::request::Builder, 10 | Body, Client, Request, 11 | }; 12 | use hyper_tls::HttpsConnector; 13 | use serde::de::DeserializeOwned; 14 | use serde_json::from_reader; 15 | use std::error::Error as StdError; 16 | 17 | // Custom Error type 18 | #[derive(Debug)] 19 | struct Error { 20 | pub cause: GitHubError, 21 | } 22 | 23 | impl fmt::Display for Error { 24 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 25 | write!(f, "{}", self.cause.message) 26 | } 27 | } 28 | 29 | impl StdError for Error { 30 | fn description(&self) -> &str { 31 | &self.cause.message 32 | } 33 | } 34 | 35 | /// User information to be retrieved from the GitHub API. 36 | #[derive(Clone, Default, Debug, serde::Serialize, serde::Deserialize)] 37 | pub struct GitHubUser { 38 | pub login: String, 39 | pub organizations_url: String, 40 | } 41 | 42 | #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] 43 | pub struct GitHubOrg { 44 | pub login: String, 45 | } 46 | 47 | #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] 48 | pub struct GitHubError { 49 | pub message: String, 50 | pub documentation_url: Option, 51 | pub errors: Option>, 52 | } 53 | 54 | #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] 55 | pub struct GitHubClientError { 56 | pub resource: String, 57 | pub field: String, 58 | pub code: String, 59 | } 60 | 61 | /// Create a new `Client` 62 | fn create_client() -> Client> { 63 | Client::builder().build(HttpsConnector::new()) 64 | } 65 | 66 | /// Create a `Request` `Builder` with necessary headers 67 | fn create_request_builder(token: &str) -> Builder { 68 | Request::builder() 69 | .header(CONTENT_TYPE, "application/vnd.github.v3+json") 70 | .header(USER_AGENT, "Substrate Playground") 71 | .header(AUTHORIZATION, format!("token {}", token)) 72 | } 73 | 74 | // Send a fresh `Request` created from a `Builder`, sends it and return the object `T` parsed from JSON. 75 | async fn send(builder: Builder) -> Result> 76 | where 77 | T: DeserializeOwned, 78 | { 79 | let client = create_client(); 80 | let req = builder.body(Body::default())?; 81 | let res = client.request(req).await?; 82 | let status = res.status(); 83 | let whole_body = aggregate(res).await?; 84 | if status.is_success() { 85 | from_reader(whole_body.reader()).map_err(Into::into) 86 | } else { 87 | let cause: GitHubError = from_reader(whole_body.reader())?; 88 | Err(Error { cause }.into()) 89 | } 90 | } 91 | 92 | /// 93 | /// Returns current GitHubUser represented by a `token`. 94 | /// 95 | /// # Arguments 96 | /// 97 | /// * `token` - a github token 98 | /// 99 | pub async fn current_user(token: &str) -> Result> { 100 | let builder = create_request_builder(token).uri("https://api.github.com/user"); 101 | send(builder).await 102 | } 103 | 104 | /// 105 | /// Returns a Vec associated to a GitHubUser. 106 | /// 107 | /// # Arguments 108 | /// 109 | /// * `token` - a github token 110 | /// * `user` - a GitHubUser 111 | /// 112 | pub async fn orgs(token: &str, user: &GitHubUser) -> Result, Box> { 113 | let builder = create_request_builder(token).uri(user.organizations_url.as_str()); 114 | send(builder).await 115 | } 116 | -------------------------------------------------------------------------------- /backend/src/main.rs: -------------------------------------------------------------------------------- 1 | #![feature(async_closure, proc_macro_hygiene, decl_macro)] 2 | 3 | mod api; 4 | mod error; 5 | mod github; 6 | mod kubernetes; 7 | mod manager; 8 | mod metrics; 9 | mod prometheus; 10 | mod types; 11 | 12 | use crate::manager::Manager; 13 | use crate::prometheus::PrometheusMetrics; 14 | use ::prometheus::Registry; 15 | use github::GitHubUser; 16 | use rocket::fairing::AdHoc; 17 | use rocket::{catchers, config::Environment, http::Method, routes}; 18 | use rocket_cors::{AllowedOrigins, CorsOptions}; 19 | use rocket_oauth2::{HyperSyncRustlsAdapter, OAuth2, OAuthConfig, StaticProvider}; 20 | use std::{env, error::Error}; 21 | 22 | pub struct Context { 23 | manager: Manager, 24 | } 25 | 26 | #[tokio::main] 27 | async fn main() -> Result<(), Box> { 28 | // Initialize log configuration. Reads `RUST_LOG` if any, otherwise fallsback to `default` 29 | if env::var("RUST_LOG").is_err() { 30 | env::set_var("RUST_LOG", "info"); 31 | } 32 | env_logger::init(); 33 | 34 | // Prints basic details 35 | log::info!("Running ROCKET in {:?} mode", Environment::active()?); 36 | 37 | match env::var("GITHUB_SHA") { 38 | Ok(version) => log::info!("Version {}", version), 39 | Err(_) => log::warn!("Unknown version"), 40 | } 41 | 42 | let manager = Manager::new().await?; 43 | let engine = manager.clone().engine; 44 | manager.clone().spawn_background_thread(); 45 | 46 | // Configure CORS 47 | let cors = CorsOptions { 48 | allowed_origins: AllowedOrigins::all(), 49 | allowed_methods: vec![Method::Get, Method::Post, Method::Put, Method::Delete] 50 | .into_iter() 51 | .map(From::from) 52 | .collect(), 53 | allow_credentials: true, 54 | ..Default::default() 55 | } 56 | .to_cors()?; 57 | 58 | let registry = Registry::new_custom(Some("playground".to_string()), None)?; 59 | manager.clone().metrics.register(registry.clone())?; 60 | let prometheus = PrometheusMetrics::with_registry(registry); 61 | let error = rocket::ignite() 62 | .register(catchers![api::bad_request_catcher]) 63 | .attach(cors) 64 | .attach(AdHoc::on_attach("github", |rocket| { 65 | let config = OAuthConfig::new( 66 | StaticProvider { 67 | auth_uri: "https://github.com/login/oauth/authorize".into(), 68 | token_uri: "https://github.com/login/oauth/access_token".into(), 69 | }, 70 | engine.configuration.github_client_id, 71 | engine.secrets.github_client_secret, 72 | None, 73 | ); 74 | Ok(rocket.attach(OAuth2::::custom( 75 | HyperSyncRustlsAdapter::default().basic_auth(false), 76 | config, 77 | ))) 78 | })) 79 | .mount( 80 | "/api", 81 | routes![ 82 | api::get, 83 | api::get_unlogged, 84 | // Users 85 | api::get_user, 86 | api::list_users, 87 | api::create_user, 88 | api::update_user, 89 | api::delete_user, 90 | // Current Session 91 | api::get_current_session, 92 | api::get_current_session_unlogged, 93 | api::create_current_session, 94 | api::create_current_session_unlogged, 95 | api::update_current_session, 96 | api::update_current_session_unlogged, 97 | api::delete_current_session, 98 | api::delete_current_session_unlogged, 99 | // Sessions 100 | api::get_session, 101 | api::list_sessions, 102 | api::create_session, 103 | api::update_session, 104 | api::delete_session, 105 | // Pools 106 | api::get_pool, 107 | api::list_pools, 108 | // Login 109 | api::github_login, 110 | api::post_install_callback, 111 | api::login, 112 | api::logout, 113 | ], 114 | ) 115 | .mount("/metrics", prometheus) 116 | .manage(Context { manager }) 117 | .launch(); 118 | 119 | // Launch blocks unless an error is returned 120 | Err(error.into()) 121 | } 122 | -------------------------------------------------------------------------------- /backend/src/metrics.rs: -------------------------------------------------------------------------------- 1 | use prometheus::{ 2 | exponential_buckets, histogram_opts, opts, Error, HistogramVec, IntCounterVec, Registry, 3 | }; 4 | 5 | #[derive(Debug, Clone)] 6 | pub struct Metrics { 7 | deploy_counter: IntCounterVec, 8 | deploy_failures_counter: IntCounterVec, 9 | undeploy_counter: IntCounterVec, 10 | undeploy_failures_counter: IntCounterVec, 11 | deploy_duration: HistogramVec, 12 | } 13 | 14 | impl Metrics { 15 | const TEMPLATE_LABEL: &'static str = "template"; 16 | 17 | pub fn new() -> Result { 18 | let opts = histogram_opts!( 19 | "deploy_duration", 20 | "Deployment duration in seconds", 21 | exponential_buckets(1.0, 2.0, 8).unwrap() 22 | ); 23 | Ok(Metrics { 24 | deploy_counter: IntCounterVec::new( 25 | opts!("deploy_counter", "Count of deployments"), 26 | &[Self::TEMPLATE_LABEL], 27 | )?, 28 | deploy_failures_counter: IntCounterVec::new( 29 | opts!("deploy_failures_counter", "Count of deployment failures"), 30 | &[Self::TEMPLATE_LABEL], 31 | )?, 32 | undeploy_counter: IntCounterVec::new( 33 | opts!("undeploy_counter", "Count of undeployment"), 34 | &[], 35 | )?, 36 | undeploy_failures_counter: IntCounterVec::new( 37 | opts!( 38 | "undeploy_failures_counter", 39 | "Count of undeployment failures" 40 | ), 41 | &[], 42 | )?, 43 | deploy_duration: HistogramVec::new(opts, &[])?, 44 | }) 45 | } 46 | 47 | /// Register all metrics in provided `Registry` 48 | pub fn register(self, registry: Registry) -> Result<(), Error> { 49 | registry.register(Box::new(self.deploy_counter))?; 50 | registry.register(Box::new(self.deploy_failures_counter))?; 51 | registry.register(Box::new(self.undeploy_counter))?; 52 | registry.register(Box::new(self.undeploy_failures_counter))?; 53 | registry.register(Box::new(self.deploy_duration))?; 54 | Ok(()) 55 | } 56 | } 57 | 58 | // Helper functions 59 | impl Metrics { 60 | pub fn inc_deploy_counter(&self, template: &str) { 61 | self.deploy_counter.with_label_values(&[template]).inc(); 62 | } 63 | 64 | pub fn inc_deploy_failures_counter(&self, template: &str) { 65 | self.deploy_failures_counter 66 | .with_label_values(&[template]) 67 | .inc(); 68 | } 69 | 70 | pub fn inc_undeploy_counter(&self) { 71 | self.undeploy_counter.with_label_values(&[]).inc(); 72 | } 73 | 74 | pub fn inc_undeploy_failures_counter(&self) { 75 | self.undeploy_failures_counter.with_label_values(&[]).inc(); 76 | } 77 | 78 | pub fn observe_deploy_duration(&self, duration: f64) { 79 | self.deploy_duration 80 | .with_label_values(&[]) 81 | .observe(duration); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /backend/src/prometheus.rs: -------------------------------------------------------------------------------- 1 | //! Adapted from https://github.com/sd2k/rocket_prometheus/blob/master/src/lib.rs 2 | 3 | use prometheus::{Encoder, Registry, TextEncoder}; 4 | use rocket::{ 5 | handler::Outcome, 6 | http::{ContentType, Method}, 7 | response::Content, 8 | Data, Handler, Request, Route, 9 | }; 10 | 11 | #[derive(Clone)] 12 | pub struct PrometheusMetrics { 13 | pub registry: Registry, 14 | } 15 | 16 | impl PrometheusMetrics { 17 | /// Create a new `PrometheusMetrics` with a custom `Registry`. 18 | pub fn with_registry(registry: Registry) -> Self { 19 | PrometheusMetrics { registry } 20 | } 21 | } 22 | 23 | impl Handler for PrometheusMetrics { 24 | fn handle<'r>(&self, req: &'r Request, _: Data) -> Outcome<'r> { 25 | // Gather the metrics. 26 | let mut buffer = vec![]; 27 | let encoder = TextEncoder::new(); 28 | encoder 29 | .encode(&self.registry.gather(), &mut buffer) 30 | .unwrap(); 31 | let body = String::from_utf8(buffer).unwrap(); 32 | Outcome::from( 33 | req, 34 | Content( 35 | ContentType::with_params( 36 | "text", 37 | "plain", 38 | &[("version", "0.0.4"), ("charset", "utf-8")], 39 | ), 40 | body, 41 | ), 42 | ) 43 | } 44 | } 45 | 46 | impl From for Vec { 47 | fn from(other: PrometheusMetrics) -> Self { 48 | vec![Route::new(Method::Get, "/", other)] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /backend/src/types.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::{ 3 | collections::BTreeMap, 4 | str::FromStr, 5 | time::{Duration, SystemTime}, 6 | }; 7 | 8 | #[derive(Serialize, Clone, Debug)] 9 | pub struct Session { 10 | pub user_id: String, 11 | pub template: Template, 12 | pub url: String, 13 | pub pod: Pod, 14 | #[serde(with = "duration")] 15 | pub duration: Duration, 16 | pub node: String, 17 | } 18 | 19 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] 20 | pub enum Phase { 21 | Pending, 22 | Running, 23 | Succeeded, 24 | Failed, 25 | Unknown, 26 | } 27 | 28 | impl FromStr for Phase { 29 | type Err = String; 30 | fn from_str(s: &str) -> Result { 31 | match s { 32 | "Pending" => Ok(Phase::Pending), 33 | "Running" => Ok(Phase::Running), 34 | "Succeeded" => Ok(Phase::Succeeded), 35 | "Failed" => Ok(Phase::Failed), 36 | "Unknown" => Ok(Phase::Unknown), 37 | _ => Err(format!("'{}' is not a valid value for Phase", s)), 38 | } 39 | } 40 | } 41 | 42 | #[derive(Serialize, Clone, Debug)] 43 | #[serde(rename_all = "camelCase")] 44 | pub struct Pod { 45 | pub phase: Phase, 46 | pub reason: String, 47 | pub message: String, 48 | #[serde(with = "system_time")] 49 | pub start_time: Option, 50 | pub container: Option, 51 | } 52 | 53 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] 54 | pub enum ContainerPhase { 55 | Running, 56 | Terminated, 57 | Waiting, 58 | Unknown, 59 | } 60 | 61 | #[derive(Serialize, Clone, Debug)] 62 | pub struct ContainerStatus { 63 | pub phase: ContainerPhase, 64 | pub reason: Option, 65 | pub message: Option, 66 | } 67 | 68 | #[derive(Serialize, Clone, Debug)] 69 | #[serde(rename_all = "camelCase")] 70 | pub struct Pool { 71 | pub name: String, 72 | pub instance_type: Option, 73 | pub nodes: Vec, 74 | } 75 | 76 | #[derive(Serialize, Clone, Debug)] 77 | #[serde(rename_all = "camelCase")] 78 | pub struct Node { 79 | pub hostname: String, 80 | } 81 | 82 | #[derive(Deserialize, Clone, Debug)] 83 | #[serde(rename_all = "camelCase")] 84 | pub struct SessionConfiguration { 85 | pub template: String, 86 | #[serde(default)] 87 | #[serde(with = "option_duration")] 88 | pub duration: Option, 89 | pub pool_affinity: Option, 90 | } 91 | 92 | #[derive(Deserialize, Clone, Debug)] 93 | pub struct SessionUpdateConfiguration { 94 | #[serde(default)] 95 | #[serde(with = "option_duration")] 96 | pub duration: Option, 97 | } 98 | 99 | #[derive(Serialize, Debug, Clone)] 100 | #[serde(rename_all = "camelCase")] 101 | pub struct SessionDefaults { 102 | #[serde(with = "duration")] 103 | pub duration: Duration, 104 | #[serde(with = "duration")] 105 | pub max_duration: Duration, 106 | pub pool_affinity: String, 107 | pub max_sessions_per_pod: usize, 108 | } 109 | 110 | #[derive(Serialize, Deserialize, Clone, Debug)] 111 | #[serde(rename_all = "camelCase")] 112 | pub struct User { 113 | pub admin: bool, 114 | #[serde(default = "default_as_false")] 115 | pub can_customize_duration: bool, 116 | #[serde(default = "default_as_false")] 117 | pub can_customize_pool_affinity: bool, 118 | pub pool_affinity: Option, 119 | } 120 | 121 | #[derive(Serialize, Deserialize, Clone, Debug)] 122 | #[serde(rename_all = "camelCase")] 123 | pub struct UserConfiguration { 124 | pub admin: bool, 125 | #[serde(default = "default_as_false")] 126 | pub can_customize_duration: bool, 127 | #[serde(default = "default_as_false")] 128 | pub can_customize_pool_affinity: bool, 129 | pub pool_affinity: Option, 130 | } 131 | 132 | #[derive(Serialize, Deserialize, Clone, Debug)] 133 | #[serde(rename_all = "camelCase")] 134 | pub struct UserUpdateConfiguration { 135 | pub admin: bool, 136 | #[serde(default = "default_as_false")] 137 | pub can_customize_duration: bool, 138 | #[serde(default = "default_as_false")] 139 | pub can_customize_pool_affinity: bool, 140 | pub pool_affinity: Option, 141 | } 142 | #[derive(Serialize, Deserialize, Clone, Debug)] 143 | pub struct LoggedUser { 144 | pub id: String, 145 | pub admin: bool, 146 | pub organizations: Vec, 147 | pub pool_affinity: Option, 148 | pub can_customize_duration: bool, 149 | pub can_customize_pool_affinity: bool, 150 | } 151 | 152 | impl LoggedUser { 153 | pub fn is_paritytech_member(&self) -> bool { 154 | self.organizations.contains(&"paritytech".to_string()) 155 | } 156 | pub fn can_customize_duration(&self) -> bool { 157 | self.admin || self.can_customize_duration || self.is_paritytech_member() 158 | } 159 | 160 | pub fn can_customize_pool_affinity(&self) -> bool { 161 | self.admin || self.can_customize_pool_affinity || self.is_paritytech_member() 162 | } 163 | 164 | pub fn has_admin_read_rights(&self) -> bool { 165 | self.admin || self.is_paritytech_member() 166 | } 167 | 168 | pub fn has_admin_edit_rights(&self) -> bool { 169 | self.admin 170 | } 171 | } 172 | 173 | #[derive(Serialize, Deserialize, Clone, Debug)] 174 | pub struct Template { 175 | pub name: String, 176 | pub image: String, 177 | pub description: String, 178 | pub tags: Option>, 179 | pub runtime: Option, 180 | } 181 | 182 | #[derive(Serialize, Deserialize, Clone, Debug)] 183 | pub struct RuntimeConfiguration { 184 | pub env: Option>, 185 | pub ports: Option>, 186 | } 187 | 188 | #[derive(Serialize, Deserialize, Clone, Debug)] 189 | pub struct NameValuePair { 190 | pub name: String, 191 | pub value: String, 192 | } 193 | 194 | #[derive(Serialize, Deserialize, Clone, Debug)] 195 | pub struct Port { 196 | pub name: String, 197 | pub protocol: Option, 198 | pub path: String, 199 | pub port: i32, 200 | pub target: Option, 201 | } 202 | 203 | #[derive(Serialize, Deserialize, Clone, Debug)] 204 | #[serde(rename_all = "camelCase")] 205 | pub struct Command { 206 | pub name: String, 207 | pub run: String, 208 | pub working_directory: String, 209 | } 210 | 211 | /// Utils 212 | 213 | mod system_time { 214 | use serde::{self, Serializer}; 215 | use std::time::SystemTime; 216 | 217 | pub fn serialize(date: &Option, serializer: S) -> Result 218 | where 219 | S: Serializer, 220 | { 221 | match date.and_then(|v| v.elapsed().ok()) { 222 | Some(value) => serializer.serialize_some(&value.as_secs()), 223 | None => serializer.serialize_none(), 224 | } 225 | } 226 | } 227 | 228 | mod option_duration { 229 | use serde::{self, Deserialize, Deserializer}; 230 | use std::time::Duration; 231 | 232 | pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> 233 | where 234 | D: Deserializer<'de>, 235 | { 236 | Ok(Some(Duration::from_secs( 237 | u64::deserialize(deserializer)? * 60, 238 | ))) 239 | } 240 | } 241 | 242 | mod duration { 243 | use serde::{self, Serializer}; 244 | use std::time::Duration; 245 | 246 | pub fn serialize(date: &Duration, serializer: S) -> Result 247 | where 248 | S: Serializer, 249 | { 250 | serializer.serialize_u64(date.as_secs() / 60) 251 | } 252 | } 253 | 254 | fn default_as_false() -> bool { 255 | false 256 | } 257 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # Publish 2 | 3 | ```shell 4 | git pull --rebase 5 | yarn clean && yarn && yarn build 6 | yarn login 7 | yarn publish 8 | ``` 9 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@substrate/playground-client", 3 | "version": "1.5.0", 4 | "description": "An isomorphic client for Substrate Playground", 5 | "main": "dist/main/index.js", 6 | "browser": "dist/browser/index.js", 7 | "scripts": { 8 | "build": "tsc --declaration && tsc --declaration -p tsconfig.browser.json", 9 | "clean": "rm -rf dist/ node_modules/ yarn.lock", 10 | "preversion": "test -z \"$(git diff-index --name-only HEAD --)\"", 11 | "postversion": "git push --tags && git push && echo \"Successfully released version $npm_package_version!\"", 12 | "release": "yarn version" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "homepage": "https://github.com/paritytech/substrate-playground/tree/master/backend/node-client", 18 | "author": { 19 | "name": "Julien Eluard", 20 | "email": "julien@parity.io" 21 | }, 22 | "publishConfig": { 23 | "access": "public" 24 | }, 25 | "license": "Apache-2.0", 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/paritytech/substrate-playground.git" 29 | }, 30 | "bugs": { 31 | "url": "https://github.com/paritytech/substrate-playground/issues" 32 | }, 33 | "dependencies": { 34 | "uuid": "8.3.2" 35 | }, 36 | "devDependencies": { 37 | "typescript": "4.2.4" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /client/src/index.ts: -------------------------------------------------------------------------------- 1 | import { fetchWithTimeout, rpc } from './rpc'; 2 | import { Playground, Pool, Session, SessionConfiguration, SessionUpdateConfiguration, User, UserConfiguration, UserUpdateConfiguration, } from './types'; 3 | 4 | export class Client { 5 | 6 | static userResource = 'user'; 7 | static usersResource = 'users'; 8 | static sessionResource = 'session'; 9 | static sessionsResource = 'sessions'; 10 | static poolsResource = 'pools'; 11 | 12 | private readonly base: string; 13 | private readonly timeout: number; 14 | private readonly defaultInit: RequestInit; 15 | 16 | constructor(base: string, timeout: number = 10000, defaultInit?: RequestInit) { 17 | this.base = base; 18 | this.defaultInit = defaultInit; 19 | this.timeout = timeout; 20 | } 21 | 22 | path(...resources: string[]): string { 23 | return [this.base, ...resources].join("/"); 24 | } 25 | 26 | loginPath(queryParams: string = window.location.search): string { 27 | return this.path(`login/github${queryParams}`); 28 | } 29 | 30 | async get(init: RequestInit = this.defaultInit): Promise { 31 | return rpc(this.path(""), init, this.timeout); 32 | } 33 | 34 | // Current User 35 | 36 | async getCurrentUser(init: RequestInit = this.defaultInit): Promise { 37 | return rpc(this.path(Client.userResource), { 38 | ...init 39 | }, this.timeout); 40 | } 41 | 42 | // Users 43 | 44 | async getUser(id: string, init: RequestInit = this.defaultInit): Promise { 45 | return rpc(this.path(Client.usersResource, id), init, this.timeout); 46 | } 47 | 48 | async listUsers(init: RequestInit = this.defaultInit): Promise> { 49 | return rpc(this.path(Client.usersResource), init, this.timeout); 50 | } 51 | 52 | async createUser(id: string, conf: UserConfiguration, init: RequestInit = this.defaultInit): Promise { 53 | return rpc(this.path(Client.usersResource, id), { 54 | method: 'PUT', 55 | body: JSON.stringify(conf), 56 | ...init 57 | }, this.timeout); 58 | } 59 | 60 | async updateUser(id: string, conf: UserUpdateConfiguration, init: RequestInit = this.defaultInit): Promise { 61 | return rpc(this.path(Client.usersResource, id), { 62 | method: 'PATCH', 63 | body: JSON.stringify(conf), 64 | ...init 65 | }, this.timeout); 66 | } 67 | 68 | async deleteUser(id: string, init: RequestInit = this.defaultInit): Promise { 69 | return rpc(this.path(Client.usersResource, id), { 70 | method: 'DELETE', 71 | ...init 72 | }, this.timeout); 73 | } 74 | 75 | // Current Session 76 | 77 | async getCurrentSession(init: RequestInit = this.defaultInit): Promise { 78 | return rpc(this.path(Client.sessionResource), init, this.timeout); 79 | } 80 | 81 | async createCurrentSession(conf: SessionConfiguration, init: RequestInit = this.defaultInit): Promise { 82 | return rpc(this.path(Client.sessionResource), { 83 | method: 'PUT', 84 | body: JSON.stringify(conf), 85 | ...init 86 | }, this.timeout); 87 | } 88 | 89 | async updateCurrentSession(conf: SessionUpdateConfiguration, init: RequestInit = this.defaultInit): Promise { 90 | return rpc(this.path(Client.sessionResource), { 91 | method: 'PATCH', 92 | body: JSON.stringify(conf), 93 | ...init 94 | }, this.timeout); 95 | } 96 | 97 | async deleteCurrentSession(init: RequestInit = this.defaultInit): Promise { 98 | return rpc(this.path(Client.sessionResource), { 99 | method: 'DELETE', 100 | ...init 101 | }, this.timeout); 102 | } 103 | 104 | // Sessions 105 | 106 | async listSessions(init: RequestInit = this.defaultInit): Promise> { 107 | return rpc(this.path(Client.sessionsResource), init, this.timeout); 108 | } 109 | 110 | async createSession(id: string, conf: SessionConfiguration, init: RequestInit = this.defaultInit): Promise { 111 | return rpc(this.path(Client.sessionsResource, id), { 112 | method: 'PUT', 113 | body: JSON.stringify(conf), 114 | ...init 115 | }, this.timeout); 116 | } 117 | 118 | async updateSession(id: string, conf: SessionUpdateConfiguration, init: RequestInit = this.defaultInit): Promise { 119 | return rpc(this.path(Client.sessionsResource, id), { 120 | method: 'PATCH', 121 | body: JSON.stringify(conf), 122 | ...init 123 | }, this.timeout); 124 | } 125 | 126 | async deleteSession(id: string, init: RequestInit = this.defaultInit): Promise { 127 | return rpc(this.path(Client.sessionsResource, id), { 128 | method: 'DELETE', 129 | ...init 130 | }, this.timeout); 131 | } 132 | 133 | // Pools 134 | 135 | async getPool(id: string, init: RequestInit = this.defaultInit): Promise { 136 | return rpc(this.path(Client.poolsResource, id), init, this.timeout); 137 | } 138 | 139 | async listPools(init: RequestInit = this.defaultInit): Promise> { 140 | return rpc(this.path(Client.poolsResource), init, this.timeout); 141 | } 142 | 143 | // Login 144 | 145 | async login(bearer: string, init: RequestInit = this.defaultInit): Promise { 146 | return fetchWithTimeout(`${this.path('login')}?bearer=${bearer}`, { 147 | ...init 148 | }, this.timeout); 149 | } 150 | 151 | async logout(init: RequestInit = this.defaultInit): Promise { 152 | return fetchWithTimeout(this.path('logout'), init, this.timeout); 153 | } 154 | 155 | } 156 | 157 | export * from "./login"; 158 | export * from "./rpc"; 159 | export * from "./types"; 160 | export * from "./utils"; 161 | -------------------------------------------------------------------------------- /client/src/login.ts: -------------------------------------------------------------------------------- 1 | export interface Verification { 2 | deviceCode: string, 3 | userCode: string, 4 | verificationUri: string, 5 | expiresIn: number, 6 | interval: number, 7 | } 8 | 9 | // Scope can be: https://docs.github.com/en/developers/apps/scopes-for-oauth-apps 10 | export async function getVerification(githubClientId: string, scope: string = "read:user"): Promise { 11 | const res = await fetch(`https://github.com/login/device/code?client_id=${githubClientId}&scope=${scope}`, { 12 | method: 'POST', 13 | headers: {'Accept': 'application/json'} 14 | }); 15 | const { device_code, user_code, verification_uri, expires_in, interval } = await res.json(); 16 | // TODO handle errors 17 | /* 18 | { error: 'invalid_scope', 19 | error_description: 'The scopes requested are invalid: user:read.', 20 | error_uri: 'https://docs.github.com' } 21 | */ 22 | return { 23 | deviceCode: device_code, 24 | userCode: user_code, 25 | verificationUri: verification_uri, 26 | expiresIn: expires_in, 27 | interval: interval, 28 | }; 29 | } 30 | 31 | export interface Authorization { 32 | accessToken: string, 33 | tokenType: string, 34 | scope: string, 35 | } 36 | 37 | export async function getAuthorization(githubClientId: string, deviceCode: string): Promise { 38 | const grantType = 'urn:ietf:params:oauth:grant-type:device_code'; 39 | const res = await fetch(`https://github.com/login/oauth/access_token?client_id=${githubClientId}&device_code=${deviceCode}&grant_type=${grantType}`, { 40 | method: 'POST', 41 | headers: {'Accept': 'application/json'} 42 | }); 43 | const { access_token, token_type, scope, } = await res.json(); 44 | return { 45 | accessToken: access_token, 46 | tokenType: token_type, 47 | scope: scope, 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /client/src/rpc.ts: -------------------------------------------------------------------------------- 1 | export enum RpcErrorCode { 2 | PARSE_ERROR = -32700, 3 | INVALID_REQUEST = -32600, 4 | METHOD_NOT_FOUND = -32601, 5 | INVALID_PARAMS = -32602, 6 | INTERNAL_ERROR = -32603, 7 | SERVER_ERROR = -32000, 8 | TIMEOUT_ERROR = 1000, 9 | } 10 | 11 | export class RpcError extends Error { 12 | readonly code; 13 | 14 | constructor(code: RpcErrorCode, message: string) { 15 | super(message); 16 | this.code = code; 17 | } 18 | } 19 | 20 | export async function fetchWithTimeout(input: RequestInfo, init: RequestInit, timeout): Promise { 21 | const controller = new AbortController(); 22 | const id = setTimeout(() => controller.abort(), timeout); 23 | const response = await fetch(input, { 24 | ...init, 25 | signal: controller.signal 26 | }); 27 | clearTimeout(id); 28 | return response; 29 | } 30 | 31 | async function call(input: RequestInfo, init: RequestInit, timeout: number): Promise { 32 | try { 33 | const controller = new AbortController(); 34 | const id = setTimeout(() => controller.abort(), timeout); 35 | const response = await fetch(input, { 36 | ...init, 37 | signal: controller.signal 38 | }); 39 | clearTimeout(id); 40 | if (response.ok) { 41 | // TODO check content-type 42 | try { 43 | const { result, error } = await response.json(); 44 | if (error) { 45 | return Promise.reject(error); 46 | } else { 47 | return Promise.resolve(result); 48 | } 49 | } catch (e) { 50 | // Failed to parse as JSON 51 | return Promise.reject(new RpcError(RpcErrorCode.PARSE_ERROR, response.statusText)); 52 | } 53 | } else { 54 | if (response.status == 401) { 55 | return Promise.reject(new RpcError(RpcErrorCode.INVALID_REQUEST, 'User unauthorized')); 56 | } 57 | return Promise.reject(new RpcError(RpcErrorCode.SERVER_ERROR, response.statusText)); 58 | } 59 | } catch (e) { 60 | return Promise.reject(new RpcError(RpcErrorCode.TIMEOUT_ERROR, 'Failed to fetch')); 61 | } 62 | } 63 | 64 | export async function rpc(input: string, init: RequestInit, timeout: number): Promise { 65 | return await call(input, { 66 | method: 'GET', 67 | headers: {'Accept': 'application/json', 'Content-Type': 'application/json'}, 68 | ...init 69 | }, timeout); 70 | } 71 | -------------------------------------------------------------------------------- /client/src/session.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | 3 | export interface Params { 4 | timeout?: number, 5 | } 6 | 7 | export interface Request { 8 | id: string, 9 | type: Type, 10 | data: Record, 11 | } 12 | 13 | export interface Response { 14 | id: string, 15 | result?: any, 16 | error?: {message: string}, 17 | } 18 | 19 | export enum Type { 20 | LIST, EXEC 21 | } 22 | 23 | // A client allowing to send command to a local session 24 | export class SessionClient { 25 | 26 | private readonly el: Window; 27 | private readonly defaultParams: Params; 28 | 29 | constructor(el: Window, defaultParams?: Params) { 30 | this.el = el; 31 | this.defaultParams = defaultParams || {timeout: 5000}; 32 | } 33 | 34 | async send(type: Type, data: Record, {timeout: timeout}: Params = this.defaultParams): Promise { 35 | return new Promise((resolve, reject) => { 36 | const timeoutId = setTimeout(async () => { 37 | window.removeEventListener('message', callback, false); 38 | reject({type: 'timeout', message: `No message after ${timeout} ms`}); 39 | }, timeout); 40 | const id = uuidv4(); 41 | const callback = (event: MessageEvent) => { 42 | if (event.data.id == id) { 43 | window.removeEventListener('message', callback, false); 44 | clearTimeout(timeoutId); 45 | const { result, error } = event.data; 46 | if (error) { 47 | reject(error); 48 | } else { 49 | resolve(result); 50 | } 51 | } 52 | }; 53 | window.addEventListener('message', callback, false); 54 | 55 | const request: Request = {id: id, type: type, data: data}; 56 | this.el.postMessage(request, "*"); 57 | }); 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /client/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Playground { 2 | env: Environment, 3 | configuration: Configuration, 4 | templates: Record, 5 | user?: LoggedUser, 6 | } 7 | 8 | export interface Environment { 9 | secured: boolean, 10 | host: string, 11 | namespace: string, 12 | } 13 | 14 | export interface Configuration { 15 | githubClientId: string, 16 | session: SessionDefaults, 17 | } 18 | 19 | export interface SessionDefaults { 20 | /* The default number of minutes sessions can last */ 21 | duration: number, 22 | maxDuration: number, 23 | poolAffinity: string, 24 | maxSessionsPerPod: string, 25 | } 26 | 27 | export interface LoggedUser { 28 | id: string, 29 | admin: boolean, 30 | organizations: string[], 31 | poolAffinity: string, 32 | canCustomizeDuration: boolean, 33 | canCustomizePoolAffinity: boolean, 34 | } 35 | 36 | export interface User { 37 | admin: boolean, 38 | poolAffinity: string, 39 | canCustomizeDuration: boolean, 40 | canCustomizePoolAffinity: boolean, 41 | } 42 | 43 | export interface UserConfiguration { 44 | admin: boolean, 45 | poolAffinity?: string, 46 | canCustomizeDuration: boolean, 47 | canCustomizePoolAffinity: boolean, 48 | } 49 | 50 | export interface UserUpdateConfiguration { 51 | admin: boolean, 52 | poolAffinity?: string, 53 | canCustomizeDuration: boolean, 54 | canCustomizePoolAffinity: boolean, 55 | } 56 | 57 | export interface Session { 58 | userId: string, 59 | url: string, 60 | template: Template, 61 | pod: Pod, 62 | /* The number of minutes this session can last */ 63 | duration: number, 64 | maxDuration: number, 65 | node: string, 66 | } 67 | 68 | export interface Pool { 69 | name: string, 70 | instanceType?: string, 71 | nodes: Node[], 72 | } 73 | 74 | export interface Node { 75 | hostname: string, 76 | } 77 | 78 | export interface SessionConfiguration { 79 | template: string, 80 | /* The number of minutes this session will be able to last */ 81 | duration?: number, 82 | poolAffinity?: string, 83 | } 84 | 85 | export interface SessionUpdateConfiguration { 86 | /* The number of minutes this session will be able to last */ 87 | duration?: number, 88 | } 89 | 90 | export interface NameValuePair { 91 | name: string, 92 | value: string, 93 | } 94 | 95 | export interface Port { 96 | name: string, 97 | protocol?: string, 98 | path: string, 99 | port: number, 100 | target?: number 101 | } 102 | 103 | export interface RuntimeConfiguration { 104 | env?: NameValuePair[], 105 | ports?: Port[], 106 | } 107 | 108 | export interface Template { 109 | name: string, 110 | image: string, 111 | description: string, 112 | tags?: Record, 113 | runtime?: RuntimeConfiguration, 114 | } 115 | 116 | export type Phase = 'Pending' | 'Running' | 'Succeeded' | 'Failed' | 'Unknown'; 117 | export interface Pod { 118 | phase: Phase, 119 | reason: string, 120 | message: string, 121 | /* The number of seconds since this session started */ 122 | startTime?: number, 123 | container?: ContainerStatus, 124 | } 125 | 126 | export type ContainerPhase = 'Running' | 'Terminated' | 'Waiting' | 'Unknown'; 127 | 128 | export interface ContainerStatus { 129 | phase: ContainerPhase, 130 | reason?: string, 131 | message?: string, 132 | } 133 | -------------------------------------------------------------------------------- /client/src/utils.ts: -------------------------------------------------------------------------------- 1 | export enum EnvironmentType {dev, staging, production} 2 | 3 | export function playgroundBaseURL(env: EnvironmentType) { 4 | switch (env) { 5 | case EnvironmentType.dev: 6 | return "http://playground-dev.substrate.test/api"; 7 | case EnvironmentType.staging: 8 | return "https://playground-staging.substrate.dev/api"; 9 | case EnvironmentType.production: 10 | return "https://playground.substrate.dev/api"; 11 | default: 12 | throw new Error(`Unrecognized env ${env}`); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /client/tsconfig.browser.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist/browser", 5 | "target": "es2015", 6 | "module": "es2015", 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "outDir": "./dist/main", 5 | "target": "es5", 6 | "module": "CommonJS", 7 | "moduleResolution": "node", 8 | "lib": ["es2017", "dom", "es5"] 9 | }, 10 | "include": [ 11 | "src/index.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /client/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | typescript@4.2.4: 6 | version "4.2.4" 7 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961" 8 | integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg== 9 | 10 | uuid@8.3.2: 11 | version "8.3.2" 12 | resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" 13 | integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== 14 | -------------------------------------------------------------------------------- /conf/k8s/base/backend-api-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: backend-api-deployment 5 | labels: 6 | app.kubernetes.io/component: backend-api 7 | spec: 8 | replicas: 1 9 | selector: 10 | matchLabels: 11 | app.kubernetes.io/component: backend-api 12 | template: 13 | metadata: 14 | labels: 15 | app.kubernetes.io/component: backend-api 16 | annotations: 17 | prometheus.io/scrape: "true" 18 | prometheus.io/port: "80" 19 | spec: 20 | serviceAccountName: default-service-account 21 | containers: 22 | - name: backend-api 23 | image: paritytech/substrate-playground-backend-api 24 | ports: 25 | - containerPort: 80 26 | env: 27 | # See https://rocket.rs/v0.4/guide/configuration/ 28 | - name: ROCKET_ENV 29 | value: "staging" 30 | - name: ROCKET_PORT 31 | value: "80" 32 | - name: ROCKET_LOG 33 | value: "debug" 34 | - name: ROCKET_ADDRESS 35 | value: "0.0.0.0" 36 | - name: SESSION_DEFAULT_DURATION 37 | valueFrom: 38 | configMapKeyRef: 39 | name: playground-config 40 | key: session.defaultDuration 41 | - name: SESSION_MAX_DURATION 42 | valueFrom: 43 | configMapKeyRef: 44 | name: playground-config 45 | key: session.maxDuration 46 | - name: SESSION_DEFAULT_POOL_AFFINITY 47 | valueFrom: 48 | configMapKeyRef: 49 | name: playground-config 50 | key: session.defaultPoolAffinity 51 | - name: SESSION_DEFAULT_MAX_PER_NODE 52 | valueFrom: 53 | configMapKeyRef: 54 | name: playground-config 55 | key: session.defaultMaxPerNode 56 | - name: GITHUB_CLIENT_ID 57 | valueFrom: 58 | configMapKeyRef: 59 | name: playground-config 60 | key: github.clientId 61 | - name: GITHUB_CLIENT_SECRET 62 | valueFrom: 63 | secretKeyRef: 64 | name: playground-secrets 65 | key: github.clientSecret 66 | - name: ROCKET_SECRET_KEY 67 | valueFrom: 68 | secretKeyRef: 69 | name: playground-secrets 70 | key: rocket.secretKey 71 | -------------------------------------------------------------------------------- /conf/k8s/base/backend-api-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: backend-api-service 5 | spec: 6 | type: NodePort 7 | ports: 8 | - name: api-port 9 | port: 80 10 | targetPort: 80 11 | selector: 12 | app.kubernetes.io/component: backend-api -------------------------------------------------------------------------------- /conf/k8s/base/backend-ui-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: backend-ui-deployment 5 | labels: 6 | app.kubernetes.io/component: backend-ui 7 | spec: 8 | replicas: 1 9 | selector: 10 | matchLabels: 11 | app.kubernetes.io/component: backend-ui 12 | template: 13 | metadata: 14 | labels: 15 | app.kubernetes.io/component: backend-ui 16 | spec: 17 | containers: 18 | - name: backend-ui 19 | image: paritytech/substrate-playground-backend-ui 20 | ports: 21 | - containerPort: 80 22 | resources: 23 | requests: 24 | memory: "128Mi" 25 | cpu: "125m" 26 | limits: 27 | memory: "512Mi" 28 | cpu: "250m" 29 | -------------------------------------------------------------------------------- /conf/k8s/base/backend-ui-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: backend-ui-service 5 | spec: 6 | type: NodePort 7 | ports: 8 | - name: ui-port 9 | port: 80 10 | targetPort: 80 11 | selector: 12 | app.kubernetes.io/component: backend-ui -------------------------------------------------------------------------------- /conf/k8s/base/cluster-role-binding.yaml: -------------------------------------------------------------------------------- 1 | kind: ClusterRoleBinding 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | metadata: 4 | name: default-playground-role 5 | subjects: 6 | - kind: ServiceAccount 7 | name: default-service-account 8 | roleRef: 9 | kind: ClusterRole 10 | name: cluster-admin 11 | apiGroup: rbac.authorization.k8s.io -------------------------------------------------------------------------------- /conf/k8s/base/grafana/dashboards/home.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": "-- Grafana --", 7 | "enable": true, 8 | "hide": true, 9 | "iconColor": "rgba(0, 211, 255, 1)", 10 | "name": "Annotations & Alerts", 11 | "type": "dashboard" 12 | } 13 | ] 14 | }, 15 | "editable": true, 16 | "gnetId": null, 17 | "graphTooltip": 0, 18 | "id": 2, 19 | "links": [], 20 | "panels": [ 21 | { 22 | "datasource": "prometheus", 23 | "fieldConfig": { 24 | "defaults": { 25 | "custom": {}, 26 | "mappings": [], 27 | "thresholds": { 28 | "mode": "absolute", 29 | "steps": [ 30 | { 31 | "color": "green", 32 | "value": null 33 | }, 34 | { 35 | "color": "red", 36 | "value": 80 37 | } 38 | ] 39 | } 40 | }, 41 | "overrides": [] 42 | }, 43 | "gridPos": { 44 | "h": 9, 45 | "w": 7, 46 | "x": 0, 47 | "y": 0 48 | }, 49 | "id": 6, 50 | "options": { 51 | "colorMode": "value", 52 | "graphMode": "none", 53 | "justifyMode": "auto", 54 | "orientation": "auto", 55 | "reduceOptions": { 56 | "calcs": [ 57 | "lastNotNull" 58 | ], 59 | "fields": "", 60 | "values": false 61 | }, 62 | "textMode": "auto" 63 | }, 64 | "pluginVersion": "7.3.2", 65 | "targets": [ 66 | { 67 | "expr": "sum(playground_deploy_counter) - sum(playground_undeploy_counter)", 68 | "interval": "", 69 | "legendFormat": "", 70 | "queryType": "randomWalk", 71 | "refId": "A" 72 | } 73 | ], 74 | "timeFrom": null, 75 | "timeShift": null, 76 | "title": "Current deployments", 77 | "type": "stat" 78 | }, 79 | { 80 | "aliasColors": {}, 81 | "bars": false, 82 | "dashLength": 10, 83 | "dashes": false, 84 | "datasource": "prometheus", 85 | "fieldConfig": { 86 | "defaults": { 87 | "custom": {} 88 | }, 89 | "overrides": [] 90 | }, 91 | "fill": 1, 92 | "fillGradient": 0, 93 | "gridPos": { 94 | "h": 9, 95 | "w": 17, 96 | "x": 7, 97 | "y": 0 98 | }, 99 | "hiddenSeries": false, 100 | "id": 8, 101 | "legend": { 102 | "avg": false, 103 | "current": false, 104 | "max": false, 105 | "min": false, 106 | "show": true, 107 | "total": false, 108 | "values": false 109 | }, 110 | "lines": true, 111 | "linewidth": 1, 112 | "nullPointMode": "null", 113 | "options": { 114 | "alertThreshold": true 115 | }, 116 | "percentage": false, 117 | "pluginVersion": "7.3.2", 118 | "pointradius": 2, 119 | "points": false, 120 | "renderer": "flot", 121 | "seriesOverrides": [], 122 | "spaceLength": 10, 123 | "stack": false, 124 | "steppedLine": false, 125 | "targets": [ 126 | { 127 | "expr": "sum(increase(playground_deploy_counter[5m]))", 128 | "interval": "", 129 | "legendFormat": "deploy", 130 | "queryType": "randomWalk", 131 | "refId": "A" 132 | }, 133 | { 134 | "expr": "sum(increase(playground_undeploy_counter[5m]))", 135 | "interval": "", 136 | "legendFormat": "un deploy", 137 | "refId": "B" 138 | }, 139 | { 140 | "expr": "sum(increase(playground_deploy_failures_counter[5m]))", 141 | "interval": "", 142 | "legendFormat": "deploy failures", 143 | "refId": "C" 144 | }, 145 | { 146 | "expr": "sum(increase(playground_undeploy_failures_counter[5m]))", 147 | "interval": "", 148 | "legendFormat": "un deploy failures", 149 | "refId": "D" 150 | } 151 | ], 152 | "thresholds": [], 153 | "timeFrom": null, 154 | "timeRegions": [], 155 | "timeShift": null, 156 | "title": "Deployments", 157 | "tooltip": { 158 | "shared": true, 159 | "sort": 0, 160 | "value_type": "individual" 161 | }, 162 | "type": "graph", 163 | "xaxis": { 164 | "buckets": null, 165 | "mode": "time", 166 | "name": null, 167 | "show": true, 168 | "values": [] 169 | }, 170 | "yaxes": [ 171 | { 172 | "decimals": 0, 173 | "format": "short", 174 | "label": null, 175 | "logBase": 1, 176 | "max": null, 177 | "min": "0", 178 | "show": true 179 | }, 180 | { 181 | "format": "short", 182 | "label": null, 183 | "logBase": 1, 184 | "max": null, 185 | "min": null, 186 | "show": true 187 | } 188 | ], 189 | "yaxis": { 190 | "align": false, 191 | "alignLevel": null 192 | } 193 | }, 194 | { 195 | "datasource": null, 196 | "fieldConfig": { 197 | "defaults": { 198 | "custom": {} 199 | }, 200 | "overrides": [] 201 | }, 202 | "gridPos": { 203 | "h": 9, 204 | "w": 24, 205 | "x": 0, 206 | "y": 9 207 | }, 208 | "id": 123123, 209 | "type": "gettingstarted" 210 | }, 211 | { 212 | "datasource": "prometheus", 213 | "fieldConfig": { 214 | "defaults": { 215 | "custom": {}, 216 | "mappings": [], 217 | "thresholds": { 218 | "mode": "absolute", 219 | "steps": [ 220 | { 221 | "color": "green", 222 | "value": null 223 | }, 224 | { 225 | "color": "red", 226 | "value": 80 227 | } 228 | ] 229 | } 230 | }, 231 | "overrides": [] 232 | }, 233 | "gridPos": { 234 | "h": 9, 235 | "w": 7, 236 | "x": 0, 237 | "y": 18 238 | }, 239 | "id": 11, 240 | "options": { 241 | "colorMode": "value", 242 | "graphMode": "none", 243 | "justifyMode": "auto", 244 | "orientation": "auto", 245 | "reduceOptions": { 246 | "calcs": [ 247 | "lastNotNull" 248 | ], 249 | "fields": "", 250 | "values": false 251 | }, 252 | "textMode": "auto" 253 | }, 254 | "pluginVersion": "7.3.2", 255 | "targets": [ 256 | { 257 | "expr": "sum(playground_deploy_counter)", 258 | "interval": "", 259 | "legendFormat": "", 260 | "queryType": "randomWalk", 261 | "refId": "A" 262 | } 263 | ], 264 | "timeFrom": null, 265 | "timeShift": null, 266 | "title": "Total deployments", 267 | "type": "stat" 268 | }, 269 | { 270 | "cards": { 271 | "cardPadding": null, 272 | "cardRound": null 273 | }, 274 | "color": { 275 | "cardColor": "#b4ff00", 276 | "colorScale": "sqrt", 277 | "colorScheme": "interpolateOranges", 278 | "exponent": 0.5, 279 | "mode": "spectrum" 280 | }, 281 | "dataFormat": "timeseries", 282 | "datasource": "prometheus", 283 | "fieldConfig": { 284 | "defaults": { 285 | "custom": { 286 | "align": null, 287 | "filterable": false 288 | }, 289 | "mappings": [], 290 | "thresholds": { 291 | "mode": "absolute", 292 | "steps": [ 293 | { 294 | "color": "green", 295 | "value": null 296 | }, 297 | { 298 | "color": "red", 299 | "value": 80 300 | } 301 | ] 302 | } 303 | }, 304 | "overrides": [] 305 | }, 306 | "gridPos": { 307 | "h": 9, 308 | "w": 10, 309 | "x": 14, 310 | "y": 18 311 | }, 312 | "heatmap": {}, 313 | "hideZeroBuckets": false, 314 | "highlightCards": true, 315 | "id": 10, 316 | "legend": { 317 | "show": false 318 | }, 319 | "pluginVersion": "7.3.2", 320 | "reverseYBuckets": false, 321 | "targets": [ 322 | { 323 | "expr": "sum(playground_deploy_duration_bucket)", 324 | "format": "heatmap", 325 | "instant": false, 326 | "interval": "0", 327 | "legendFormat": "{{ le }}", 328 | "queryType": "randomWalk", 329 | "refId": "A" 330 | } 331 | ], 332 | "timeFrom": null, 333 | "timeShift": null, 334 | "title": "Deployment durations", 335 | "tooltip": { 336 | "show": true, 337 | "showHistogram": false 338 | }, 339 | "type": "heatmap", 340 | "xAxis": { 341 | "show": true 342 | }, 343 | "xBucketNumber": null, 344 | "xBucketSize": null, 345 | "yAxis": { 346 | "decimals": 0, 347 | "format": "short", 348 | "logBase": 1, 349 | "max": null, 350 | "min": "0", 351 | "show": true, 352 | "splitFactor": null 353 | }, 354 | "yBucketBound": "auto", 355 | "yBucketNumber": 8, 356 | "yBucketSize": null 357 | } 358 | ], 359 | "schemaVersion": 26, 360 | "style": "dark", 361 | "tags": [], 362 | "templating": { 363 | "list": [] 364 | }, 365 | "time": { 366 | "from": "now-12h", 367 | "to": "now" 368 | }, 369 | "timepicker": {}, 370 | "timezone": "", 371 | "title": "Deployments", 372 | "uid": "TKrLxKTGk", 373 | "version": 3 374 | } 375 | -------------------------------------------------------------------------------- /conf/k8s/base/grafana/datasources.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: grafana-datasources 5 | data: 6 | datasources.yaml: |- 7 | apiVersion: 1 8 | deleteDatasources: 9 | - name: prometheus 10 | datasources: 11 | - name: prometheus 12 | type: prometheus 13 | access: proxy 14 | orgId: 1 15 | url: http://prometheus-service.playground.svc.cluster.local:9090 -------------------------------------------------------------------------------- /conf/k8s/base/grafana/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: grafana-deployment 5 | labels: 6 | app.kubernetes.io/component: grafana 7 | spec: 8 | strategy: 9 | rollingUpdate: 10 | maxSurge: 1 11 | maxUnavailable: 1 12 | type: RollingUpdate 13 | selector: 14 | matchLabels: 15 | app.kubernetes.io/component: grafana 16 | template: 17 | metadata: 18 | labels: 19 | app.kubernetes.io/component: grafana 20 | spec: 21 | securityContext: 22 | # grafana uid 23 | runAsUser: 472 24 | fsGroup: 472 25 | containers: 26 | - image: grafana/grafana 27 | name: grafana 28 | ports: 29 | - containerPort: 3000 30 | resources: 31 | limits: 32 | cpu: 500m 33 | memory: 2500Mi 34 | requests: 35 | cpu: 100m 36 | memory: 100Mi 37 | env: 38 | - name: GF_SECURITY_ALLOW_EMBEDDING 39 | value: "true" 40 | - name: GF_SERVER_SERVE_FROM_SUB_PATH 41 | value: "true" 42 | - name: GF_SERVER_ROOT_URL 43 | value: "%(protocol)s://%(domain)s:%(http_port)s/grafana/" 44 | - name: GF_SERVER_STATIC_ROOT_PATH 45 | value: "public" 46 | - name: GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH 47 | value: "/etc/grafana/dashboards/home.json" 48 | #- name: GF_AUTH_DISABLE_LOGIN_FORM 49 | # value: "true" 50 | - name: GF_AUTH_ANONYMOUS_ENABLED 51 | value: "true" 52 | volumeMounts: 53 | - name: grafana-store 54 | mountPath: /var/lib/grafana 55 | - name: dashboards 56 | mountPath: /etc/grafana/dashboards 57 | - name: datasources 58 | mountPath: /etc/grafana/provisioning/datasources 59 | - name: providers 60 | mountPath: /etc/grafana/provisioning/dashboards 61 | volumes: 62 | - name: grafana-store 63 | persistentVolumeClaim: 64 | claimName: grafana-volume-claim 65 | - name: dashboards 66 | configMap: 67 | name: grafana-dashboards 68 | - name: datasources 69 | configMap: 70 | name: grafana-datasources 71 | - name: providers 72 | configMap: 73 | name: grafana-providers -------------------------------------------------------------------------------- /conf/k8s/base/grafana/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | resources: 4 | - datasources.yaml 5 | - deployment.yaml 6 | - providers.yaml 7 | - service.yaml 8 | - volume.yaml 9 | images: 10 | - name: grafana/grafana 11 | newTag: 7.3.2 12 | configMapGenerator: 13 | - name: grafana-dashboards 14 | files: 15 | - dashboards/home.json -------------------------------------------------------------------------------- /conf/k8s/base/grafana/providers.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: grafana-providers 5 | data: 6 | providers.yaml: |+ 7 | apiVersion: 1 8 | providers: 9 | - name: 'default' 10 | orgId: 1 11 | folder: '' 12 | type: file 13 | disableDeletion: false 14 | editable: true 15 | options: 16 | path: /etc/grafana/dashboards -------------------------------------------------------------------------------- /conf/k8s/base/grafana/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: grafana-service 5 | spec: 6 | type: NodePort 7 | ports: 8 | - name: grafana-port 9 | port: 3000 10 | targetPort: 3000 11 | selector: 12 | app.kubernetes.io/component: grafana -------------------------------------------------------------------------------- /conf/k8s/base/grafana/volume.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: PersistentVolume 3 | metadata: 4 | name: grafana-volume 5 | labels: 6 | type: local 7 | spec: 8 | capacity: 9 | storage: 10Gi 10 | accessModes: 11 | - ReadWriteOnce 12 | hostPath: 13 | path: "/mnt/data" 14 | --- 15 | apiVersion: v1 16 | kind: PersistentVolumeClaim 17 | metadata: 18 | name: grafana-volume-claim 19 | spec: 20 | accessModes: 21 | - ReadWriteOnce 22 | resources: 23 | requests: 24 | storage: 10Gi -------------------------------------------------------------------------------- /conf/k8s/base/ingress.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: networking.k8s.io/v1 2 | kind: Ingress 3 | metadata: 4 | name: ingress 5 | annotations: 6 | kubernetes.io/ingress.class: "nginx" 7 | nginx.ingress.kubernetes.io/configuration-snippet: | 8 | more_set_headers 'Access-Control-Allow-credentials: true'; 9 | more_set_headers 'Access-Control-Allow-Methods: PUT, GET, POST, PATCH, DELETE, OPTIONS'; 10 | more_set_headers 'Access-Control-Allow-Origin: $http_origin'; 11 | spec: 12 | rules: 13 | - http: 14 | paths: 15 | - path: / 16 | pathType: Prefix 17 | backend: 18 | service: 19 | name: backend-ui-service 20 | port: 21 | name: ui-port 22 | - path: /api/ 23 | pathType: Prefix 24 | backend: 25 | service: 26 | name: backend-api-service 27 | port: 28 | name: api-port 29 | - path: /grafana/ 30 | pathType: Prefix 31 | backend: 32 | service: 33 | name: grafana-service 34 | port: 35 | name: grafana-port 36 | -------------------------------------------------------------------------------- /conf/k8s/base/kustomization.yaml: -------------------------------------------------------------------------------- 1 | commonLabels: 2 | app.kubernetes.io/part-of: playground 3 | app.kubernetes.io/managed-by: kustomize 4 | 5 | namespace: playground 6 | 7 | bases: 8 | - prometheus 9 | - grafana 10 | 11 | resources: 12 | - backend-api-deployment.yaml 13 | - backend-api-service.yaml 14 | - backend-ui-deployment.yaml 15 | - backend-ui-service.yaml 16 | - cluster-role-binding.yaml 17 | - ingress.yaml 18 | - nginx.yaml 19 | - service-account.yaml 20 | - node-conf-daemon-set.yaml 21 | - prepull-templates.yaml 22 | -------------------------------------------------------------------------------- /conf/k8s/base/nginx.yaml: -------------------------------------------------------------------------------- 1 | # See https://github.com/kubernetes/ingress-nginx/blob/master/docs/deploy/index.md#gce-gke 2 | # Copied from https://raw.githubusercontent.com/kubernetes/ingress-nginx/nginx-0.30.0/deploy/static/mandatory.yaml 3 | 4 | apiVersion: v1 5 | kind: Namespace 6 | metadata: 7 | name: ingress-nginx 8 | labels: 9 | app.kubernetes.io/name: ingress-nginx 10 | app.kubernetes.io/part-of: ingress-nginx 11 | 12 | --- 13 | 14 | kind: ConfigMap 15 | apiVersion: v1 16 | metadata: 17 | name: nginx-configuration 18 | namespace: ingress-nginx 19 | labels: 20 | app.kubernetes.io/name: ingress-nginx 21 | app.kubernetes.io/part-of: ingress-nginx 22 | 23 | --- 24 | kind: ConfigMap 25 | apiVersion: v1 26 | metadata: 27 | name: tcp-services 28 | namespace: ingress-nginx 29 | labels: 30 | app.kubernetes.io/name: ingress-nginx 31 | app.kubernetes.io/part-of: ingress-nginx 32 | 33 | --- 34 | kind: ConfigMap 35 | apiVersion: v1 36 | metadata: 37 | name: udp-services 38 | namespace: ingress-nginx 39 | labels: 40 | app.kubernetes.io/name: ingress-nginx 41 | app.kubernetes.io/part-of: ingress-nginx 42 | 43 | --- 44 | apiVersion: v1 45 | kind: ServiceAccount 46 | metadata: 47 | name: nginx-ingress-serviceaccount 48 | namespace: ingress-nginx 49 | labels: 50 | app.kubernetes.io/name: ingress-nginx 51 | app.kubernetes.io/part-of: ingress-nginx 52 | 53 | --- 54 | apiVersion: rbac.authorization.k8s.io/v1 55 | kind: ClusterRole 56 | metadata: 57 | name: nginx-ingress-clusterrole 58 | labels: 59 | app.kubernetes.io/name: ingress-nginx 60 | app.kubernetes.io/part-of: ingress-nginx 61 | rules: 62 | - apiGroups: 63 | - "" 64 | resources: 65 | - configmaps 66 | - endpoints 67 | - nodes 68 | - pods 69 | - secrets 70 | verbs: 71 | - list 72 | - watch 73 | - apiGroups: 74 | - "" 75 | resources: 76 | - nodes 77 | verbs: 78 | - get 79 | - apiGroups: 80 | - "" 81 | resources: 82 | - services 83 | verbs: 84 | - get 85 | - list 86 | - watch 87 | - apiGroups: 88 | - "" 89 | resources: 90 | - events 91 | verbs: 92 | - create 93 | - patch 94 | - apiGroups: 95 | - "extensions" 96 | - "networking.k8s.io" 97 | resources: 98 | - ingressclasses 99 | - ingresses 100 | verbs: 101 | - get 102 | - list 103 | - watch 104 | - apiGroups: 105 | - "extensions" 106 | - "networking.k8s.io" 107 | resources: 108 | - ingresses/status 109 | verbs: 110 | - update 111 | 112 | --- 113 | apiVersion: rbac.authorization.k8s.io/v1 114 | kind: Role 115 | metadata: 116 | name: nginx-ingress-role 117 | namespace: ingress-nginx 118 | labels: 119 | app.kubernetes.io/name: ingress-nginx 120 | app.kubernetes.io/part-of: ingress-nginx 121 | rules: 122 | - apiGroups: 123 | - "" 124 | resources: 125 | - configmaps 126 | - pods 127 | - secrets 128 | - namespaces 129 | verbs: 130 | - get 131 | - apiGroups: 132 | - "" 133 | resources: 134 | - configmaps 135 | resourceNames: 136 | # Defaults to "-" 137 | # Here: "-" 138 | # This has to be adapted if you change either parameter 139 | # when launching the nginx-ingress-controller. 140 | - "ingress-controller-leader" 141 | verbs: 142 | - get 143 | - update 144 | - apiGroups: 145 | - "" 146 | resources: 147 | - configmaps 148 | verbs: 149 | - create 150 | - apiGroups: 151 | - "" 152 | resources: 153 | - endpoints 154 | verbs: 155 | - get 156 | 157 | --- 158 | apiVersion: rbac.authorization.k8s.io/v1 159 | kind: RoleBinding 160 | metadata: 161 | name: nginx-ingress-role-nisa-binding 162 | namespace: ingress-nginx 163 | labels: 164 | app.kubernetes.io/name: ingress-nginx 165 | app.kubernetes.io/part-of: ingress-nginx 166 | roleRef: 167 | apiGroup: rbac.authorization.k8s.io 168 | kind: Role 169 | name: nginx-ingress-role 170 | subjects: 171 | - kind: ServiceAccount 172 | name: nginx-ingress-serviceaccount 173 | namespace: ingress-nginx 174 | 175 | --- 176 | apiVersion: rbac.authorization.k8s.io/v1 177 | kind: ClusterRoleBinding 178 | metadata: 179 | name: nginx-ingress-clusterrole-nisa-binding 180 | labels: 181 | app.kubernetes.io/name: ingress-nginx 182 | app.kubernetes.io/part-of: ingress-nginx 183 | roleRef: 184 | apiGroup: rbac.authorization.k8s.io 185 | kind: ClusterRole 186 | name: nginx-ingress-clusterrole 187 | subjects: 188 | - kind: ServiceAccount 189 | name: nginx-ingress-serviceaccount 190 | namespace: ingress-nginx 191 | 192 | --- 193 | 194 | apiVersion: apps/v1 195 | kind: Deployment 196 | metadata: 197 | name: nginx-ingress-controller 198 | namespace: ingress-nginx 199 | labels: 200 | app.kubernetes.io/name: ingress-nginx 201 | app.kubernetes.io/part-of: ingress-nginx 202 | spec: 203 | replicas: 1 204 | selector: 205 | matchLabels: 206 | app.kubernetes.io/name: ingress-nginx 207 | app.kubernetes.io/part-of: ingress-nginx 208 | template: 209 | metadata: 210 | labels: 211 | app.kubernetes.io/name: ingress-nginx 212 | app.kubernetes.io/part-of: ingress-nginx 213 | annotations: 214 | prometheus.io/port: "10254" 215 | prometheus.io/scrape: "true" 216 | spec: 217 | # wait up to five minutes for the drain of connections 218 | terminationGracePeriodSeconds: 300 219 | serviceAccountName: nginx-ingress-serviceaccount 220 | nodeSelector: 221 | kubernetes.io/os: linux 222 | containers: 223 | - name: nginx-ingress-controller 224 | image: k8s.gcr.io/ingress-nginx/controller:v1.0.0 225 | args: 226 | - /nginx-ingress-controller 227 | - --configmap=$(POD_NAMESPACE)/nginx-configuration 228 | - --tcp-services-configmap=$(POD_NAMESPACE)/tcp-services 229 | - --udp-services-configmap=$(POD_NAMESPACE)/udp-services 230 | - --publish-service=$(POD_NAMESPACE)/ingress-nginx 231 | - --annotations-prefix=nginx.ingress.kubernetes.io 232 | - --default-ssl-certificate=$(POD_NAMESPACE)/playground-tls 233 | securityContext: 234 | allowPrivilegeEscalation: true 235 | capabilities: 236 | drop: 237 | - ALL 238 | add: 239 | - NET_BIND_SERVICE 240 | # www-data -> 101 241 | runAsUser: 101 242 | env: 243 | - name: POD_NAME 244 | valueFrom: 245 | fieldRef: 246 | fieldPath: metadata.name 247 | - name: POD_NAMESPACE 248 | valueFrom: 249 | fieldRef: 250 | fieldPath: metadata.namespace 251 | ports: 252 | - name: http 253 | containerPort: 80 254 | protocol: TCP 255 | - name: https 256 | containerPort: 443 257 | protocol: TCP 258 | livenessProbe: 259 | failureThreshold: 3 260 | httpGet: 261 | path: /healthz 262 | port: 10254 263 | scheme: HTTP 264 | initialDelaySeconds: 10 265 | periodSeconds: 10 266 | successThreshold: 1 267 | timeoutSeconds: 10 268 | readinessProbe: 269 | failureThreshold: 3 270 | httpGet: 271 | path: /healthz 272 | port: 10254 273 | scheme: HTTP 274 | periodSeconds: 10 275 | successThreshold: 1 276 | timeoutSeconds: 10 277 | lifecycle: 278 | preStop: 279 | exec: 280 | command: 281 | - /wait-shutdown 282 | 283 | --- 284 | 285 | apiVersion: v1 286 | kind: LimitRange 287 | metadata: 288 | name: ingress-nginx 289 | namespace: ingress-nginx 290 | labels: 291 | app.kubernetes.io/name: ingress-nginx 292 | app.kubernetes.io/part-of: ingress-nginx 293 | spec: 294 | limits: 295 | - min: 296 | memory: 90Mi 297 | cpu: 100m 298 | type: Container 299 | 300 | # From https://raw.githubusercontent.com/kubernetes/ingress-nginx/nginx-0.30.0/deploy/static/provider/cloud-generic.yaml 301 | 302 | --- 303 | 304 | apiVersion: v1 305 | kind: Service 306 | metadata: 307 | name: ingress-nginx 308 | namespace: ingress-nginx 309 | labels: 310 | app.kubernetes.io/name: ingress-nginx 311 | app.kubernetes.io/part-of: ingress-nginx 312 | spec: 313 | externalTrafficPolicy: Local 314 | type: LoadBalancer 315 | selector: 316 | app.kubernetes.io/name: ingress-nginx 317 | app.kubernetes.io/part-of: ingress-nginx 318 | ports: 319 | - name: http 320 | port: 80 321 | protocol: TCP 322 | targetPort: http 323 | - name: https 324 | port: 443 325 | protocol: TCP 326 | targetPort: https 327 | -------------------------------------------------------------------------------- /conf/k8s/base/node-conf-daemon-set.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: DaemonSet 3 | metadata: 4 | name: node-conf 5 | labels: 6 | app.kubernetes.io/component: node-conf 7 | spec: 8 | selector: 9 | matchLabels: 10 | app.kubernetes.io/component: node-conf 11 | template: 12 | metadata: 13 | labels: 14 | app.kubernetes.io/component: node-conf 15 | annotations: 16 | seccomp.security.alpha.kubernetes.io/defaultProfileName: runtime/default 17 | apparmor.security.beta.kubernetes.io/defaultProfileName: runtime/default 18 | spec: 19 | nodeSelector: 20 | kubernetes.io/os: linux 21 | initContainers: 22 | - name: sysctl 23 | image: alpine:3 24 | command: 25 | - sysctl 26 | - -w 27 | - fs.inotify.max_user_watches=524288 28 | resources: 29 | requests: 30 | cpu: 100m 31 | memory: 90Mi 32 | limits: 33 | cpu: 100m 34 | memory: 90Mi 35 | securityContext: 36 | # We need to run as root in a privileged container to modify 37 | # /proc/sys on the host (for sysctl) 38 | runAsUser: 0 39 | privileged: true 40 | readOnlyRootFilesystem: true 41 | capabilities: 42 | drop: 43 | - ALL 44 | containers: 45 | - name: pause 46 | image: k8s.gcr.io/pause:3.5 47 | command: 48 | - /pause 49 | resources: 50 | requests: 51 | cpu: 100m 52 | memory: 90Mi 53 | limits: 54 | cpu: 100m 55 | memory: 90Mi 56 | securityContext: 57 | runAsNonRoot: true 58 | runAsUser: 65535 59 | allowPrivilegeEscalation: false 60 | privileged: false 61 | readOnlyRootFilesystem: true 62 | capabilities: 63 | drop: 64 | - ALL 65 | terminationGracePeriodSeconds: 5 66 | -------------------------------------------------------------------------------- /conf/k8s/base/prepull-templates.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: DaemonSet 3 | metadata: 4 | name: prepull-templates 5 | spec: 6 | selector: 7 | matchLabels: 8 | name: prepull-templates 9 | template: 10 | metadata: 11 | labels: 12 | name: prepull-templates 13 | spec: 14 | initContainers: 15 | - name: node-template 16 | image: docker 17 | command: ["docker", "pull", "paritytech/substrate-playground-template-node-template-theia:sha-af065c19"] 18 | volumeMounts: 19 | - name: docker 20 | mountPath: /var/run 21 | - name: recipes 22 | image: docker 23 | command: ["docker", "pull", "paritytech/substrate-playground-template-recipes-theia:sha-9a27604"] 24 | volumeMounts: 25 | - name: docker 26 | mountPath: /var/run 27 | volumes: 28 | - name: docker 29 | hostPath: 30 | path: /var/run 31 | containers: 32 | - name: pause 33 | image: gcr.io/google_containers/pause 34 | -------------------------------------------------------------------------------- /conf/k8s/base/prometheus/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: StatefulSet 3 | metadata: 4 | name: prometheus 5 | spec: 6 | serviceName: "prometheus" 7 | replicas: 1 8 | selector: 9 | 10 | matchLabels: 11 | app.kubernetes.io/component: prometheus 12 | template: 13 | metadata: 14 | labels: 15 | app.kubernetes.io/component: prometheus 16 | spec: 17 | securityContext: 18 | # prometheus uid 19 | runAsUser: 65534 20 | fsGroup: 65534 21 | serviceAccountName: prometheus 22 | containers: 23 | - name: prometheus 24 | image: prom/prometheus 25 | args: 26 | - "--config.file=/etc/prometheus/prometheus.yaml" 27 | - "--storage.tsdb.path=/prometheus/" 28 | ports: 29 | - containerPort: 9090 30 | volumeMounts: 31 | - name: prometheus-config-volume 32 | mountPath: /etc/prometheus/ 33 | - name: prometheus-storage-volume 34 | mountPath: /prometheus/ 35 | volumes: 36 | - name: prometheus-config-volume 37 | configMap: 38 | name: prometheus-configuration 39 | volumeClaimTemplates: 40 | - metadata: 41 | name: prometheus-storage-volume 42 | spec: 43 | accessModes: 44 | - ReadWriteOnce 45 | resources: 46 | requests: 47 | storage: "16Gi" 48 | -------------------------------------------------------------------------------- /conf/k8s/base/prometheus/kustomization.yaml: -------------------------------------------------------------------------------- 1 | 2 | apiVersion: kustomize.config.k8s.io/v1beta1 3 | kind: Kustomization 4 | commonLabels: 5 | app.kubernetes.io/component: prometheus 6 | resources: 7 | - deployment.yaml 8 | - rbac.yaml 9 | - service.yaml 10 | images: 11 | - name: prom/prometheus 12 | newTag: v2.22.1 13 | configMapGenerator: 14 | - name: prometheus-configuration 15 | files: 16 | - prometheus.yaml 17 | -------------------------------------------------------------------------------- /conf/k8s/base/prometheus/prometheus.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 10s 3 | scrape_configs: 4 | - job_name: 'ingress-nginx-endpoints' 5 | kubernetes_sd_configs: 6 | - role: pod 7 | relabel_configs: 8 | - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape] 9 | action: keep 10 | regex: true 11 | - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scheme] 12 | action: replace 13 | target_label: __scheme__ 14 | regex: (https?) 15 | - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path] 16 | action: replace 17 | target_label: __metrics_path__ 18 | regex: (.+) 19 | - source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port] 20 | action: replace 21 | target_label: __address__ 22 | regex: ([^:]+)(?::\d+)?;(\d+) 23 | replacement: $1:$2 24 | - source_labels: [__meta_kubernetes_service_name] 25 | regex: prometheus-server 26 | action: drop 27 | - job_name: 'node-exporter' 28 | kubernetes_sd_configs: 29 | - role: endpoints 30 | relabel_configs: 31 | - source_labels: [__meta_kubernetes_endpoints_name] 32 | regex: 'node-exporter' 33 | action: keep -------------------------------------------------------------------------------- /conf/k8s/base/prometheus/rbac.yaml: -------------------------------------------------------------------------------- 1 | # To have Prometheus retrieve metrics from Kubelets with authentication and 2 | # authorization enabled (which is highly recommended and included in security 3 | # benchmarks) the following flags must be set on the kubelet(s): 4 | # 5 | # --authentication-token-webhook 6 | # --authorization-mode=Webhook 7 | # 8 | # Copied from https://github.com/prometheus/prometheus/blob/master/documentation/examples/rbac-setup.yml 9 | # 10 | apiVersion: rbac.authorization.k8s.io/v1 11 | kind: ClusterRole 12 | metadata: 13 | name: prometheus 14 | rules: 15 | - apiGroups: [""] 16 | resources: 17 | - nodes 18 | - nodes/metrics 19 | - services 20 | - endpoints 21 | - pods 22 | verbs: ["get", "list", "watch"] 23 | - apiGroups: 24 | - extensions 25 | - networking.k8s.io 26 | resources: 27 | - ingresses 28 | verbs: ["get", "list", "watch"] 29 | - nonResourceURLs: ["/metrics", "/metrics/cadvisor"] 30 | verbs: ["get"] 31 | --- 32 | apiVersion: v1 33 | kind: ServiceAccount 34 | metadata: 35 | name: prometheus 36 | namespace: default 37 | --- 38 | apiVersion: rbac.authorization.k8s.io/v1 39 | kind: ClusterRoleBinding 40 | metadata: 41 | name: prometheus 42 | roleRef: 43 | apiGroup: rbac.authorization.k8s.io 44 | kind: ClusterRole 45 | name: prometheus 46 | subjects: 47 | - kind: ServiceAccount 48 | name: prometheus 49 | namespace: default -------------------------------------------------------------------------------- /conf/k8s/base/prometheus/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: prometheus-service 5 | spec: 6 | type: NodePort 7 | ports: 8 | - name: prometheus-port 9 | port: 9090 10 | targetPort: 9090 11 | selector: 12 | app.kubernetes.io/component: prometheus -------------------------------------------------------------------------------- /conf/k8s/base/service-account.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: default-service-account -------------------------------------------------------------------------------- /conf/k8s/dashboard-user.yaml: -------------------------------------------------------------------------------- 1 | # Setup proper user for dashboard access. 2 | # WARNING: not secure 3 | 4 | apiVersion: v1 5 | kind: ServiceAccount 6 | metadata: 7 | name: admin-user 8 | namespace: kubernetes-dashboard 9 | --- 10 | apiVersion: rbac.authorization.k8s.io/v1 11 | kind: ClusterRoleBinding 12 | metadata: 13 | name: admin-user 14 | roleRef: 15 | apiGroup: rbac.authorization.k8s.io 16 | kind: ClusterRole 17 | name: cluster-admin 18 | subjects: 19 | - kind: ServiceAccount 20 | name: admin-user 21 | namespace: kubernetes-dashboard -------------------------------------------------------------------------------- /conf/k8s/overlays/dev/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | 4 | commonLabels: 5 | app.kubernetes.io/environment: dev 6 | app.kubernetes.io/version: "1.0" 7 | 8 | resources: 9 | - ../../base 10 | 11 | generatorOptions: 12 | disableNameSuffixHash: true 13 | 14 | images: 15 | - name: paritytech/substrate-playground-backend-api 16 | newTag: latest 17 | - name: paritytech/substrate-playground-backend-ui 18 | newTag: latest 19 | 20 | patches: 21 | - patch: |- 22 | - op: replace 23 | path: /spec/rules/0/host 24 | value: playground-dev.substrate.test 25 | target: 26 | group: networking.k8s.io 27 | kind: Ingress 28 | name: ingress 29 | version: v1 30 | -------------------------------------------------------------------------------- /conf/k8s/overlays/dev/templates/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | 4 | generatorOptions: 5 | disableNameSuffixHash: true 6 | 7 | configMapGenerator: 8 | - name: playground-templates 9 | files: 10 | - ../../../../templates/node-template 11 | -------------------------------------------------------------------------------- /conf/k8s/overlays/dev/users/jeluard: -------------------------------------------------------------------------------- 1 | admin: true -------------------------------------------------------------------------------- /conf/k8s/overlays/production/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | 4 | commonLabels: 5 | app.kubernetes.io/environment: production 6 | app.kubernetes.io/version: "1.0" 7 | 8 | resources: 9 | - ../../base 10 | 11 | images: 12 | - name: paritytech/substrate-playground-backend-api 13 | newTag: sha-8c3e3fe5 14 | - name: paritytech/substrate-playground-backend-ui 15 | newTag: sha-8c3e3fe5 16 | 17 | patches: 18 | - patch: |- 19 | - op: replace 20 | path: /spec/rules/0/host 21 | value: playground.substrate.dev 22 | target: 23 | group: networking.k8s.io 24 | kind: Ingress 25 | name: ingress 26 | version: v1 27 | 28 | patchesStrategicMerge: 29 | - |- 30 | apiVersion: v1 31 | kind: Service 32 | metadata: 33 | name: ingress-nginx 34 | namespace: ingress-nginx 35 | spec: 36 | loadBalancerIP: 34.67.124.32 37 | - |- 38 | apiVersion: networking.k8s.io/v1 39 | kind: Ingress 40 | metadata: 41 | name: ingress 42 | spec: 43 | tls: 44 | - hosts: 45 | - '*.playground.substrate.dev' 46 | secretName: playground-tls 47 | -------------------------------------------------------------------------------- /conf/k8s/overlays/production/templates/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | 4 | generatorOptions: 5 | disableNameSuffixHash: true 6 | 7 | configMapGenerator: 8 | - name: playground-templates 9 | files: 10 | - ../../../../templates/front-end-template 11 | - ../../../../templates/node-template 12 | - ../../../../templates/node-template-openvscode 13 | - ../../../../templates/recipes 14 | -------------------------------------------------------------------------------- /conf/k8s/overlays/production/users/bjornwgnr: -------------------------------------------------------------------------------- 1 | admin: true -------------------------------------------------------------------------------- /conf/k8s/overlays/production/users/jeluard: -------------------------------------------------------------------------------- 1 | admin: true -------------------------------------------------------------------------------- /conf/k8s/skaffold.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: skaffold/v2beta5 2 | kind: Config 3 | metadata: 4 | name: playground-skaffold 5 | build: 6 | artifacts: 7 | - image: paritytech/substrate-playground-backend-api 8 | context: ../../backend 9 | docker: 10 | buildArgs: 11 | GITHUB_SHA: SKAFFOLD 12 | - image: paritytech/substrate-playground-backend-ui 13 | context: ../../frontend 14 | docker: 15 | buildArgs: 16 | GITHUB_SHA: SKAFFOLD 17 | local: 18 | useBuildkit: true 19 | deploy: 20 | kustomize: 21 | paths: 22 | - overlays/dev 23 | -------------------------------------------------------------------------------- /conf/templates/.env: -------------------------------------------------------------------------------- 1 | BASE_TEMPLATE_VERSION=sha-89f64a42 2 | -------------------------------------------------------------------------------- /conf/templates/front-end-template: -------------------------------------------------------------------------------- 1 | image: paritytech/substrate-playground-template-front-end-template-theia:sha-9d941160 2 | repository: substrate-developer-hub/substrate-node-template 3 | ref: bf5ad6824b1b1a25c5c6b54bbf636856127632d4 4 | name: Front End template 5 | description: ' 6 | A modular UI built with ReactJS to act as a front-end to the Substrate Node Template. It contains all necessary components to interact with the Node Template’s runtime. 7 | 8 | 9 | Components: 10 | 11 | * Pallet interactor 12 | 13 | * Events 14 | 15 | * Balances 16 | 17 | * Upgrade runtime' 18 | tags: 19 | public: true 20 | runtime: 21 | ports: 22 | - name: frontend 23 | protocol: TCP 24 | path: /frontend 25 | port: 8000 26 | - name: wss 27 | protocol: TCP 28 | path: /wss 29 | port: 9944 30 | env: 31 | - name: PUBLIC_URL 32 | value: frontend 33 | - name: REACT_APP_PROVIDER_SOCKET 34 | value: wss://%HOST%/wss 35 | -------------------------------------------------------------------------------- /conf/templates/ink: -------------------------------------------------------------------------------- 1 | repository: jeluard/ink 2 | image: paritytech/substrate-playground-template-ink-openvscode:sha-206e2ebb 3 | ref: 1a8e107569e3ee988f7c4e3ac71e5e7a09d8b0c8 4 | name: Ink 5 | description: '## The Node template you know and love' 6 | tags: 7 | public: false 8 | runtime: 9 | ports: 10 | - name: wss 11 | protocol: TCP 12 | path: /wss 13 | port: 9944 14 | -------------------------------------------------------------------------------- /conf/templates/node-template: -------------------------------------------------------------------------------- 1 | repository: substrate-developer-hub/substrate-node-template 2 | image: paritytech/substrate-playground-template-node-template-theia:sha-af065c19 3 | ref: 4ea490ef8ee7cde7668c56dc548e72ee8695297b 4 | name: Node template 5 | description: ' 6 | A “skeleton blockchain” with essential capabilities, including P2P networking, consensus, finality, account, transaction and sudo governance modules. 7 | 8 | Runtime modules: 9 | 10 | * pallet_balances 11 | 12 | * pallet_transaction_payment 13 | 14 | * pallet_sudo 15 | 16 | * pallet_template' 17 | tags: 18 | public: true 19 | runtime: 20 | ports: 21 | - name: wss 22 | protocol: TCP 23 | path: /wss 24 | port: 9944 25 | -------------------------------------------------------------------------------- /conf/templates/node-template-openvscode: -------------------------------------------------------------------------------- 1 | repository: substrate-developer-hub/substrate-node-template 2 | image: paritytech/substrate-playground-template-node-template-openvscode:sha-8bb8eac6 3 | ref: 8bb8eac6dd283501dfea8f6c8bff0dcb59f1b043 4 | name: Node template 5 | description: ' 6 | A “skeleton blockchain” with essential capabilities, including P2P networking, consensus, finality, account, transaction and sudo governance modules. 7 | 8 | Runtime modules: 9 | 10 | * pallet_balances 11 | 12 | * pallet_transaction_payment 13 | 14 | * pallet_sudo 15 | 16 | * pallet_template' 17 | tags: 18 | public: false 19 | runtime: 20 | ports: 21 | - name: wss 22 | protocol: TCP 23 | path: /wss 24 | port: 9944 25 | -------------------------------------------------------------------------------- /conf/templates/parachain-template: -------------------------------------------------------------------------------- 1 | repository: substrate-developer-hub/substrate-parachain-template 2 | image: paritytech/substrate-playground-template-parachain-template-theia:sha-89f64a42 3 | ref: 6b739578e9342d2a47a47ad7a4312383204c4783 4 | name: Cumulus Parachain template 5 | description: '## The cumulus parachain template' 6 | tags: 7 | public: true 8 | runtime: 9 | ports: 10 | - name: wss 11 | protocol: TCP 12 | path: /wss 13 | port: 9944 14 | -------------------------------------------------------------------------------- /conf/templates/recipes: -------------------------------------------------------------------------------- 1 | image: paritytech/substrate-playground-template-recipes-theia:sha-9a27604 2 | repository: substrate-developer-hub/recipes 3 | ref: f4493a3ac4eebf8c8905e179a7703bea9303702e 4 | name: Recipes 5 | description: '## A Hands-On Cookbook for Aspiring Blockchain Chefs 6 | 7 | 8 | Contains some real nodes! 9 | 10 | ' 11 | tags: 12 | public: false 13 | runtime: 14 | ports: 15 | - name: wss 16 | protocol: TCP 17 | path: /wss 18 | port: 9944 19 | -------------------------------------------------------------------------------- /docs/building/cicd.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: cicd 3 | title: CI/CD 4 | --- 5 | 6 | `substrate-playground` follows a Continuous Integration/Continuous Delivery approach 7 | 8 | ## Deployments 9 | 10 | ### Playground 11 | 12 | The main branch is [develop](https://github.com/paritytech/substrate-playground/tree/develop). Changes can be merged only via PR. 13 | [develop](https://github.com/paritytech/substrate-playground/tree/develop) is continuously deployed. 14 | 15 | Once manually approved on the staging environment, changes are promoted to master. 16 | 17 | ### Base template images 18 | 19 | ### Template images 20 | 21 | ## Github configuration 22 | 23 | ### Secrets 24 | 25 | A number of `secrets` must be defined: 26 | 27 | `DOCKER_USERNAME` and `DOCKER_PASSWORD` pointing to a valid dockerhub account having acccess to paritytech organization 28 | 29 | `MATRIX_ACCESS_TOKEN` and `MATRIX_ROOM_ID` pointing to a specific Matrix room 30 | 31 | `PAT_TOKEN` a [token](https://help.github.com/en/actions/reference/events-that-trigger-workflows#triggering-new-workflows-using-a-personal-access-token) with `repo` access 32 | 33 | ### Matrix messages 34 | 35 | #### Create a new user 36 | 37 | Go to https://riot.im/app/#/register (advanced, https://matrix.parity.io) 38 | Username: `playground-bot` 39 | Email: `julien+playground-bot@parity.io` 40 | 41 | Then retrieve `access_token` via `curl -XPOST -d '{"type":"m.login.password", "user":"playground-bot", "password":"PASSWORD"}' "https://matrix.parity.io:8448/_matrix/client/r0/login"` and use it as a Github repository secret. 42 | -------------------------------------------------------------------------------- /docs/extending/custom-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: custom-template 3 | title: Custom Template 4 | --- 5 | 6 | External users can provide and maintain templates used by the playground. 7 | 8 | To create a template the following steps are mandatory: 9 | 10 | * create `.devcontainer/devcontainer.json` (find an example [here](https://github.com/paritytech/substrate-playground/blob/develop/.github/workflow-templates/devcontainer.json)) 11 | * create a Github worflow that build this image then dispatches an event to `substrate-playground` (find an example [here](https://github.com/paritytech/substrate-playground/blob/develop/.github/workflow-templates/cd-template.yml)) 12 | 13 | Additionally there are a number of standard VSCode configuration files that will be leveraged by the playground: 14 | 15 | * .vscode/settings.json (see https://code.visualstudio.com/docs/getstarted/settings) 16 | * .vscode/launch.json 17 | * .vscode/tasks.json 18 | * .vscode/snippets.code-snippets 19 | 20 | After the associated Github [workflow](https://github.com/paritytech/substrate-playground/blob/develop/.github/workflows/event-template-updated.yml) in substrate-playground is triggered, playground will use the newly built image. 21 | 22 | ## Custom commands 23 | 24 | Replace ENV, USER, HOST (via ${containerEnv:VAR_NAME}) 25 | 26 | `preCreateCommand` is executed 27 | 28 | `preContainerStartCommand` via Init Containers, can write files 29 | 30 | `postContainerStartCommand` via Container lifecycle hooks, run inside the container 31 | 32 | `preContainerStopCommand` via Container lifecycle hooks, run inside the container 33 | 34 | Container killed after `terminationGracePeriodSeconds` 35 | 36 | https://www.linkedin.com/pulse/kubernetes-deep-dive-part-1-init-containers-lifecycle-chauthaiwale/ 37 | 38 | https://kubernetes.io/fr/docs/concepts/containers/container-lifecycle-hooks/ 39 | 40 | `preStartCommand` 41 | 42 | `postStartCommand` (or `postAttachCommand`) are executed 43 | 44 | `menuActions` 45 | 46 | TODO: support string and array syntax 47 | TODO: add postCreateCommand 48 | 49 | Potential hooks: 50 | * when image is created (`preCreateCommand`) 51 | * when user deploy a template 52 | ** server side 53 | ** before theia loads 54 | ** after theia is loaded, headless or in a shell 55 | 56 | ## Github workflow 57 | 58 | A template workflow can be found [here](https://github.com/paritytech/substrate-playground/blob/develop/.github/workflow-templates/cd-template.yml). 59 | 60 | `client_payload` must define `id` pointing to one of the existing [templates](https://github.com/paritytech/substrate-playground/blob/develop/conf/k8s/overlays/staging/). 61 | It can also define a `ref` (branch/tag/commit used to build, defaults to _master_) and a `dockerFile` location (default to _.devcontainer/Dockerfile_) 62 | 63 | This workflow will trigger the [_template-updated_ workflow](https://github.com/paritytech/substrate-playground/blob/develop/.github/workflows/event-template-updated.yml) on [substrate-playground](https://github.com/paritytech/substrate-playground/), including the following actions: 64 | 65 | * create and publish a [composite docker image](https://github.com/paritytech/substrate-playground/blob/develop/templates/Dockerfile.template) from the new template one and latest [base one](https://github.com/paritytech/substrate-playground/blob/develop/templates/Dockerfile.base) 66 | * update [template image id](https://github.com/paritytech/substrate-playground/tree/develop/conf/k8s/overlays/staging/templates) 67 | * commit changes 68 | 69 | Changes to the configuration file are finally [continuously deployed](https://github.com/paritytech/substrate-playground/blob/develop/.github/workflows/cd-templates.yml) to the staging playground environment as kubernetes ConfigMap. 70 | 71 | Once live, images are tested and rollbacked if errors are detected. 72 | 73 | 74 | ```mermaid 75 | sequenceDiagram 76 | CUSTOM_TEMPLATE->>PLAYGROUND: Trigger template-updated 77 | PLAYGROUND->>PLAYGROUND: Build docker image 78 | PLAYGROUND-->>PLAYGROUND: Build template docker image 79 | PLAYGROUND-->>PLAYGROUND: Push new configuration to staging 80 | PLAYGROUND-->>PLAYGROUND: Test new image 81 | ``` 82 | 83 | ### Github secrets 84 | 85 | The following secrets must be defined: 86 | 87 | `REPO_ACCESS_TOKEN` a token with `public_repo` or repo scope 88 | -------------------------------------------------------------------------------- /docs/operating/deployment.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: deployment 3 | title: Deployment 4 | --- 5 | 6 | Kubernetes is used as a deployment platform for the playground. Make sure that k8s > 1.14 is used. 7 | GKE is used as primary deploy platform. It might not work as is on others. 8 | 9 | ## Prerequisites 10 | 11 | ### Tools 12 | 13 | #### Gcloud 14 | 15 | Install [gcloud](https://cloud.google.com/sdk/docs/) 16 | 17 | ```shell 18 | #On OSX 19 | brew cask install google-cloud-sdk 20 | gcloud init 21 | ``` 22 | #### jq 23 | 24 | See https://stedolan.github.io/jq/ 25 | #### Docker 26 | 27 | See https://docs.docker.com/get-docker/ 28 | 29 | #### kubectl 30 | 31 | See https://kubernetes.io/docs/tasks/tools/install-kubectl/ 32 | #### kustomize 33 | 34 | See https://github.com/kubernetes-sigs/kustomize 35 | 36 | ### Custom overlay 37 | 38 | If a new deployment environment is created, duplicate `conf/k8s/overlays/staging` into a dedicated folder and adapt accordingly. 39 | ### Github OAuth app 40 | 41 | Make sure a Github OAuth App is [created](https://docs.github.com/en/developers/apps/creating-an-oauth-app) with following parameters: 42 | 43 | * `Homepage URL`: $BASE (e.g. https://playground.substrate.dev) 44 | * `Authorization callback URL`: `$BASE/api/auth/github`. 45 | 46 | During the `Configuration` step both `Client ID` and `Client secret` will be required. 47 | ### Fixed IP 48 | 49 | Make sure to use regional addresses, matching your cluster region. Global addresses won't work. 50 | 51 | ``` 52 | gcloud compute addresses create playground --region us-central1 53 | gcloud compute addresses list --filter="region:( us-central1 )" 54 | gcloud compute addresses describe playground --region=us-central1 --format="value(address)" 55 | ``` 56 | 57 | Then update `loadBalancerIP` with the newly created IP in `conf/k8s/overlays/$ENV/kustomization.yaml` 58 | 59 | ### Cluster creation 60 | 61 | ```shell 62 | ENV=XXX make k8s-create-cluster 63 | ENV=XXX make k8s-setup-env 64 | ``` 65 | 66 | #### How to choose a machine type 67 | 68 | A default machine type is used in the script. It can be changed depending on needs. 69 | 70 | * https://cloud.google.com/compute/docs/machine-types 71 | * https://cloud.google.com/compute/docs/benchmarks-linux 72 | * https://cloud.google.com/compute/vm-instance-pricing 73 | 74 | ### DNS 75 | 76 | Create a new [CloudDNS zone](https://console.cloud.google.com/net-services/dns/zones/new/create?authuser=1&project=substrateplayground-252112). 77 | 78 | * Public 79 | * Zone name: playground-* 80 | * DNS name: playground-*.substrate.dev 81 | * DNSSec: off 82 | 83 | Fill a DevOps [request](https://github.com/paritytech/devops/issues/732) to redirect the new substrate.dev subdomain to CloudDNS. 84 | Can be checked with `dig +short playground-XX.substrate.dev NS` 85 | 86 | Add two `A` record set (one with ``, one with `*` as DNS name) pointing to the newly created fixed IP (see previous step). 87 | 88 | Another record set will be added during the TLS certificate generation. 89 | ### TLS certificate 90 | 91 | To get a wildcard certificate from let's encrypt: 92 | 93 | https://certbot.eff.org/docs/using.html#manual 94 | 95 | First make sure that certbot is installed: `brew install certbot` 96 | 97 | Then request new challenges. Two DNS entries will have to be updated. 98 | 99 | #### Update 100 | 101 | ``` 102 | ENV=XXX make generate-challenge 103 | 104 | # Update CloudDNS by adding a new TXT record as detailed by certbot 105 | 106 | # Make sure to check it's been propagated 107 | ENV=XXX make get-challenge 108 | ``` 109 | 110 | Then update the tls secret: 111 | 112 | ``` 113 | ENV=XXX make k8s-update-certificate 114 | ``` 115 | 116 | The new secret will be automatically picked up. 117 | 118 | #### Check 119 | 120 | Certificates can be checked using openssl: 121 | 122 | ```shell 123 | openssl s_client -connect playground.substrate.dev:443 -servername playground.substrate.dev -showcerts 124 | # Or for client with no SNI support 125 | openssl s_client -connect playground.substrate.dev:443 -showcerts 126 | ``` 127 | 128 | ### Deployment 129 | 130 | Finally, deploy the playground infrastructure: 131 | 132 | ``` 133 | ENV=XXX make k8s-deploy-playground 134 | ``` 135 | -------------------------------------------------------------------------------- /docs/using/00-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/substrate-playground/c195c7be532585b5cdf7aafe22e320f319659857/docs/using/00-demo.gif -------------------------------------------------------------------------------- /docs/using/overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: overview 3 | title: Overview 4 | slug: / 5 | --- 6 | 7 | Substrate Playground is a browser-based IDE for Substrate development 🤸 The goal of Substrate 8 | Playground is to reduce the time it takes to get started with Substrate in two ways 🚀 9 | 10 | 1. Zero set-up - Playground preconfigures a development environment with all the necessary 11 | dependencies and toolchains. 12 | 1. Prebuilt projects - Playground projects include build artifacts, including executables, which 13 | means that they can be deployed immediately and provide faster compile times from the first 14 | build. 15 | 16 |

17 | animated demo 18 |

19 | 20 | ## Logging In 21 | 22 | The Playground uses GitHub authentication, so all you need to get started is a free 23 | [GitHub account](https://github.com/join). The first time you login to Substrate Playground you will 24 | be asked to approve the integration with your GitHub account. 25 | 26 | ## Selecting a Template 27 | 28 | Substrate Playground packages projects as templates. If you're not sure which template to select, 29 | start with the template for the 30 | [Substrate Developer Hub Node Template](https://github.com/substrate-developer-hub/substrate-node-template). 31 | Enjoy the playful loading messages while you wait for your project to launch 😉 32 | 33 | ## Navigating the Playground 34 | 35 | When the Node Template project starts, it will launch the pre-built executable so that you can start 36 | interacting with it right away. Keep reading if you'd like to learn more about how to navigate 37 | Substrate Playground. 38 | 39 | Substrate Playground is built on the [Theia IDE framework](https://theia-ide.org/), which provides a 40 | familiar VS Code-like environment. On top, there is an application menu with sections like File, 41 | Edit, and View, and on the far left-hand side is a menu that allows you to navigate between tools 42 | like the File Explorer, Search menu, and Debugger. Refer to the `README.md` file that ships with the 43 | Node Template to learn more about the files you see in the File Explorer and the structure of the 44 | Node Template in general. 45 | 46 |

47 | project explorer 48 |

49 | 50 | Substrate Playground includes the 51 | [VSCode Substrate extension](https://marketplace.visualstudio.com/items?itemName=paritytech.vscode-substrate). 52 | Review 53 | [its documentation](https://github.com/paritytech/vscode-substrate/blob/master/docs/features.md) to 54 | explore the rich set of capabilities it has to offer! 55 | 56 | ## Start Playing 57 | 58 | Before you start playing, take note of the special Playground section in the top application menu. 59 | 60 |

61 | launch UI 62 |

63 | 64 | If you click into this menu, you will find a shortcut to the well-known Polkadot-JS Apps UI that is 65 | pre-configured to connect to your Playground node. 66 | 67 | ## Questions? 68 | 69 | We hope that Substrate Playground makes it easier for you to get started with the wonderful world of 70 | blockchain development with Substrate! We have lots of ways for you to get help if you have any 71 | questions or think you've encountered a problem. 72 | 73 | - If you think you've found a problem with Playground, please create an 74 | [Issue on its GitHub repository](https://github.com/paritytech/substrate-playground/issues). 75 | - If you have a question about Substrate development, ask in our 76 | [active technical chat](https://app.element.io/#/room/!HzySYSaIhtyWrwiwEV:matrix.org)! 77 | - Make a 78 | [Stack Overflow post tagged `substrate`](https://stackoverflow.com/questions/tagged/substrate). 79 | - Use the [`subport` support repository](https://github.com/paritytech/subport/issues) to open a 80 | GitHub Issue to ask specific questions related to Substrate development. 81 | - If you think you've found a problem with the Node Template, please create an 82 | [Issue on it GitHub repository](https://github.com/substrate-developer-hub/substrate-node-template/issues). 83 | -------------------------------------------------------------------------------- /e2e/README.md: -------------------------------------------------------------------------------- 1 | ## Integration tests 2 | 3 | ```shell 4 | ENV=staging yarn test 5 | ``` 6 | -------------------------------------------------------------------------------- /e2e/ava.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | typescript: { 3 | rewritePaths: { 4 | 'src/': 'dist/' 5 | } 6 | }, 7 | require: [ 8 | 'ts-node/register' 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@parity/substrate-playground-e2e", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "type": "module", 6 | "scripts": { 7 | "test": "ava", 8 | "clean": "rm -rf dist/ node_modules/ yarn.lock" 9 | }, 10 | "dependencies": { 11 | "@substrate/playground-client": "1.8.4", 12 | "abort-controller": "3.0.0", 13 | "cross-fetch": "3.1.4", 14 | "yargs": "16.2.0" 15 | }, 16 | "devDependencies": { 17 | "@ava/typescript": "1.1.1", 18 | "@types/node": "14.14.41", 19 | "@types/yargs": "16.0.1", 20 | "ava": "3.15.0", 21 | "playwright": "1.19.2", 22 | "ts-node": "9.1.1", 23 | "typescript": "4.2.4" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /e2e/test/test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { Client, EnvironmentType, playgroundBaseURL } from '@substrate/playground-client'; 3 | import 'cross-fetch/polyfill'; 4 | import 'abort-controller/polyfill'; 5 | 6 | const env = EnvironmentType[process.env.ENV]; 7 | const client = new Client(playgroundBaseURL(env), 30000, {credentials: "include"}); 8 | 9 | test('unauthenticated - should not be able to create a new session', async (t) => { 10 | try { 11 | await client.createCurrentSession({template: 'node-template'}); 12 | t.fail('Can create a session w/o login'); 13 | } catch { 14 | t.pass(); 15 | } 16 | }); 17 | 18 | test('unauthenticated - should be able to list templates', async (t) => { 19 | const details = await client.get(); 20 | t.is(Object.keys(details.templates).length > 0, true); 21 | }); 22 | -------------------------------------------------------------------------------- /e2e/test/website.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { chromium } from 'playwright'; 3 | import 'cross-fetch/polyfill'; 4 | import 'abort-controller/polyfill'; 5 | 6 | function playgroundDomain(env: string): string { 7 | switch (env) { 8 | case "dev": 9 | return "http://playground-dev.substrate.test"; 10 | case "staging": 11 | return "https://playground-staging.substrate.dev"; 12 | case "production": 13 | return "https://playground.substrate.dev"; 14 | default: 15 | throw new Error(`Unrecognized env ${env}`); 16 | } 17 | } 18 | 19 | test('should return 200', async function (t) { 20 | 21 | t.timeout(10000); // configure maximum test duration 22 | 23 | const browser = await chromium.launch(); 24 | const context = await browser.newContext({ 25 | userAgent: 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/535.1 (KHTML, like Gecko) Chrome/13.0.782.112 Safari/535.1' 26 | }); 27 | const page = await context.newPage(); 28 | page.route('**', route => { 29 | route.continue(); 30 | }); 31 | 32 | const env = process.env.ENV || "dev"; 33 | const res = await page.goto(playgroundDomain(env)); 34 | t.is(res.status(), 200); 35 | 36 | return browser.close(); 37 | }); 38 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | .vscode/ 3 | node_modules/ 4 | dist/ 5 | Dockerfile -------------------------------------------------------------------------------- /frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:@typescript-eslint/eslint-recommended" 11 | ], 12 | "globals": { 13 | "Atomics": "readonly", 14 | "SharedArrayBuffer": "readonly" 15 | }, 16 | "parser": "@typescript-eslint/parser", 17 | "parserOptions": { 18 | "ecmaFeatures": { 19 | "jsx": true 20 | }, 21 | "ecmaVersion": 2018, 22 | "sourceType": "module" 23 | }, 24 | "plugins": [ 25 | "react", 26 | "@typescript-eslint" 27 | ], 28 | "settings": { 29 | "react": { 30 | "version": "detect" 31 | } 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Dockerfile for `frontend`. Compiles the frontend then serve it via nginx on port 80. 2 | 3 | ########################## 4 | # Frontend # 5 | ########################## 6 | 7 | FROM node:14.13.1 AS builder 8 | 9 | WORKDIR /opt 10 | 11 | COPY package.json yarn.lock tsconfig.json .eslintrc.cjs /opt/ 12 | 13 | ARG GITHUB_SHA 14 | ENV GITHUB_SHA=$GITHUB_SHA 15 | 16 | ENV CI=true \ 17 | PARCEL_WORKERS=1 18 | 19 | RUN yarn install --frozen-lockfile 20 | 21 | COPY src /opt/src 22 | COPY public /opt/public 23 | 24 | RUN yarn build 25 | 26 | LABEL stage=builder 27 | 28 | ########################## 29 | # Nginx # 30 | ########################## 31 | 32 | FROM nginx:1.25.2-alpine 33 | 34 | COPY ./conf/nginx.conf /etc/nginx/conf.d/default.conf 35 | 36 | WORKDIR /usr/share/nginx/html 37 | 38 | COPY --from=builder /opt/dist/ . 39 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Quick start 2 | 3 | ## Development server 4 | 5 | ```bash 6 | yarn dev 7 | ``` 8 | 9 | This command will: 10 | 11 | - start a development server at http://localhost:1234 with [hot module replacement](https://en.parceljs.org/hmr.html) 12 | - build automatically development javascript files with source maps 13 | 14 | Basically each time you save a file, you will see automatically the result at http://localhost:1234 without refreshing the page. 15 | 16 | The base URL used to access the remote API server can be customized: 17 | 18 | ```bash 19 | BASE_URL=https://playground-staging.substrate.dev yarn dev 20 | ``` 21 | 22 | ## Build production bundle 23 | 24 | ```bash 25 | yarn build 26 | ``` 27 | 28 | [Parcel's default optimizations](https://en.parceljs.org/production.html#optimisations) will be applied to generated files. 29 | 30 | Files are saved at `dist` folder. 31 | Inside `dist` folder there is also a file with information about bundle content sizes: `dist/report.html`. 32 | -------------------------------------------------------------------------------- /frontend/ava.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | typescript: { 3 | rewritePaths: { 4 | 'src/': 'lib/' 5 | } 6 | }, 7 | require: [ 8 | "ts-node/register" 9 | ] 10 | } -------------------------------------------------------------------------------- /frontend/conf/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name localhost; 4 | 5 | location / { 6 | root /usr/share/nginx/html; 7 | index index.html; 8 | try_files $uri $uri/ /index.html; 9 | } 10 | 11 | error_page 500 502 503 504 /50x.html; 12 | location = /50x.html { 13 | root /usr/share/nginx/html; 14 | } 15 | } -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@parity/substrate-playground-frontend", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "type": "module", 6 | "scripts": { 7 | "lint": "npx eslint ./src/ --ext .js,.jsx,.jsx,.ts,.tsx", 8 | "dev": "NODE_ENV=dev parcel public/index.html --port 80", 9 | "watch": "parcel watch public/index.html", 10 | "build": "parcel build public/index.html && mkdir -p dist/assets/ && cp -R public/assets/* dist/assets/ && cp public/robots.txt dist/", 11 | "start": "yarn build && cd dist/ && npx http-server", 12 | "test": "ava", 13 | "clean": "rm -rf node_modules/ dist/ .parcel-cache/ yarn.lock" 14 | }, 15 | "dependencies": { 16 | "@material-ui/core": "4.11.3", 17 | "@material-ui/icons": "4.11.2", 18 | "@material-ui/lab": "4.0.0-alpha.57", 19 | "@substrate/playground-client": "1.5.0", 20 | "@xstate/react": "1.3.1", 21 | "analytics": "0.7.11", 22 | "analytics-plugin-simple-analytics": "0.1.3", 23 | "broadcast-channel": "3.5.3", 24 | "marked": "2.0.3", 25 | "react": "17.0.2", 26 | "react-dom": "17.0.2", 27 | "react-spring": "9.1.1", 28 | "uuid": "8.3.2", 29 | "xstate": "4.17.1" 30 | }, 31 | "devDependencies": { 32 | "@ava/typescript": "1.1.1", 33 | "@parcel/transformer-inline-string": "2.0.0-beta.2", 34 | "@types/marked": "2.0.2", 35 | "@types/node": "14.14.41", 36 | "@types/react": "17.0.3", 37 | "@types/react-dom": "17.0.3", 38 | "@types/react-router-dom": "5.1.7", 39 | "@types/uuid": "8.3.0", 40 | "@typescript-eslint/eslint-plugin": "4.22.0", 41 | "@typescript-eslint/parser": "4.22.0", 42 | "ava": "3.15.0", 43 | "eslint": "7.24.0", 44 | "eslint-plugin-react": "7.23.2", 45 | "parcel": "2.0.0-beta.2", 46 | "postcss": "^8.2.10", 47 | "typescript": "4.2.4" 48 | }, 49 | "browserslist": [ 50 | "last 5 and_chr versions", 51 | "last 5 chrome versions", 52 | "last 5 opera versions", 53 | "last 5 ios_saf versions", 54 | "last 5 safari versions" 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /frontend/public/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/substrate-playground/c195c7be532585b5cdf7aafe22e320f319659857/frontend/public/assets/favicon.png -------------------------------------------------------------------------------- /frontend/public/assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/substrate-playground/c195c7be532585b5cdf7aafe22e320f319659857/frontend/public/assets/images/logo.png -------------------------------------------------------------------------------- /frontend/public/assets/images/logo_substrate_onDark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | Substrate playground 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /api* -------------------------------------------------------------------------------- /frontend/src/LogoSubstrate.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link, makeStyles } from '@material-ui/core'; 3 | import LogoLight from 'url:../public/assets/images/logo_substrate.svg'; 4 | import LogoDark from 'url:../public/assets/images/logo_substrate_onDark.svg'; 5 | 6 | interface Props { 7 | theme: boolean; 8 | onClick: () => void; 9 | } 10 | 11 | const useStyles = makeStyles({ 12 | root: { 13 | display: 'block', 14 | width: '120px', 15 | '& img': { 16 | maxWidth: '100%', 17 | }, 18 | }, 19 | }); 20 | 21 | const LogoSubstrate: React.FunctionComponent = ({ onClick, theme }: Props) => { 22 | const classes = useStyles(); 23 | return ( 24 | 25 | 26 | 27 | ); 28 | }; 29 | 30 | export default LogoSubstrate; 31 | -------------------------------------------------------------------------------- /frontend/src/hooks.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | export function useInterval(callback: () => void, delay: number): void { 4 | useEffect(() => { 5 | const id = setInterval(callback, delay); 6 | callback(); 7 | return () => clearInterval(id); 8 | }, []); 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { Client, Configuration, LoggedUser, Session, Template } from '@substrate/playground-client'; 4 | import { createMuiTheme, ThemeProvider } from '@material-ui/core/styles'; 5 | import Analytics from "analytics"; 6 | import simpleAnalyticsPlugin from "analytics-plugin-simple-analytics"; 7 | import Button from "@material-ui/core/Button"; 8 | import Typography from "@material-ui/core/Typography"; 9 | import { useMachine } from '@xstate/react'; 10 | import { CenteredContainer, ErrorMessage, LoadingPanel, Wrapper } from './components'; 11 | import { useInterval } from "./hooks"; 12 | import { newMachine, Events, PanelId, States } from './lifecycle'; 13 | import { AdminPanel } from './panels/admin'; 14 | import { LoginPanel } from './panels/login'; 15 | import { SessionPanel } from './panels/session'; 16 | import { StatsPanel } from './panels/stats'; 17 | import { TermsPanel } from './panels/terms'; 18 | import { TheiaPanel } from './panels/theia'; 19 | import { terms } from "./terms"; 20 | import { SubstrateLight } from './themes'; 21 | import { CssBaseline } from "@material-ui/core"; 22 | 23 | function MainPanel({ client, conf, user, id, templates, restartAction, onConnect, onDeployed }: { client: Client, conf: Configuration, user: LoggedUser, id: PanelId, templates: Record, restartAction: () => void, onConnect: () => void, onDeployed: () => void }): JSX.Element { 24 | switch(id) { 25 | case PanelId.Session: 26 | return { 28 | await client.deleteCurrentSession(); 29 | }} 30 | onDeployed={async conf => { 31 | await client.createCurrentSession(conf); 32 | onDeployed(); 33 | }} 34 | onConnect={onConnect} />; 35 | case PanelId.Stats: 36 | return ; 37 | case PanelId.Admin: 38 | return ; 39 | default: 40 | return <>; 41 | } 42 | } 43 | 44 | function ExtraTheiaNav({ session, restartAction }: { session: Session | null | undefined, restartAction: () => void }): JSX.Element { 45 | if (session) { 46 | const { pod, duration } = session; 47 | if (pod.phase == 'Running') { 48 | const remaining = duration * 60 - (pod.startTime || 0); 49 | if (remaining < 300) { // 5 minutes 50 | return ( 51 | 52 | Your session is about to end. Make sure your changes have been exported. 53 | 54 | ); 55 | } 56 | } else if (pod.phase == 'Failed') { 57 | return ( 58 | 59 | Your session is over. 60 | 61 | ); 62 | } 63 | } 64 | return <>; 65 | } 66 | 67 | function WrappedTheiaPanel({ params, conf, client, user, templates, selectPanel, restartAction, send }: { params: Params, client: Client, conf: Configuration, user: LoggedUser, templates: Record, selectPanel: (id: PanelId) => void, restartAction: () => void, send: (event: Events) => void }): JSX.Element { 68 | const [session, setSession] = useState(undefined); 69 | 70 | useInterval(async () => { 71 | const session = await client.getCurrentSession(); 72 | setSession(session); 73 | 74 | // Periodically extend duration of running sessions 75 | if (session) { 76 | const { pod, duration } = session; 77 | if (pod && pod.phase == 'Running') { 78 | const remaining = duration - (pod.startTime || 0) / 60; // In minutes 79 | const maxDuration = conf.session.maxDuration; 80 | // Increase session duration 81 | if (remaining < 10 && duration < maxDuration) { 82 | const newDuration = Math.min(maxDuration, duration + 10); 83 | await client.updateCurrentSession({duration: newDuration}); 84 | } 85 | } 86 | } 87 | }, 5000); 88 | 89 | return ( 90 | } params={params} thin={true} onPlayground={() => selectPanel(PanelId.Session)} onAdminClick={() => selectPanel(PanelId.Admin)} onStatsClick={() => selectPanel(PanelId.Stats)} onLogout={() => send(Events.LOGOUT)} user={user}> 91 | 92 | 93 | ); 94 | } 95 | 96 | const theme = createMuiTheme(SubstrateLight); 97 | 98 | function App({ params }: { params: Params }): JSX.Element { 99 | const client = new Client(params.base, 30000, {credentials: "include"}); 100 | const { deploy } = params; 101 | const [state, send] = useMachine(newMachine(client, deploy? PanelId.Theia: PanelId.Session), { devTools: true }); 102 | const { panel, templates, user, conf, error } = state.context; 103 | 104 | const restartAction = () => send(Events.RESTART); 105 | const selectPanel = (id: PanelId) => send(Events.SELECT, {panel: id}); 106 | const isTheia = state.matches(States.LOGGED) && panel == PanelId.Theia; 107 | 108 | useEffect(() => { 109 | // Remove transient parameters when logged, to prevent recursive behaviors 110 | if (state.matches(States.LOGGED)) { 111 | removeTransientsURLParams(); 112 | } 113 | }, [state]); 114 | 115 | 116 | return ( 117 | 118 | 119 |
120 | {isTheia 121 | ? 122 | : 123 | selectPanel(PanelId.Session)} onAdminClick={() => selectPanel(PanelId.Admin)} onStatsClick={() => selectPanel(PanelId.Stats)} onLogout={() => send(Events.LOGOUT)} user={user}> 124 | {state.matches(States.LOGGED) 125 | ? selectPanel(PanelId.Theia)} onConnect={() => selectPanel(PanelId.Theia)} /> 126 | : state.matches(States.TERMS_UNAPPROVED) 127 | ? send(Events.TERMS_APPROVAL)} /> 128 | : state.matches(States.UNLOGGED) 129 | ? error 130 | ? 131 | 132 | 133 | : 134 | : } 135 | } 136 |
137 |
138 | ); 139 | } 140 | 141 | export interface Params { 142 | version?: string, 143 | deploy: string | null, 144 | base: string, 145 | } 146 | 147 | function extractParams(): Params { 148 | const params = new URLSearchParams(window.location.search); 149 | const deploy = params.get('deploy'); 150 | return {deploy: deploy, 151 | version: process.env.GITHUB_SHA, 152 | base: process.env.BASE || "/api"}; 153 | } 154 | 155 | function removeTransientsURLParams() { 156 | const params = new URLSearchParams(window.location.search); 157 | const deploy = params.get('deploy'); 158 | if (deploy) { 159 | params.delete('deploy'); 160 | const paramsStr = params.toString(); 161 | window.history.replaceState({}, '', `${location.pathname}${paramsStr != "" ? params : ""}`); 162 | } 163 | } 164 | 165 | function main(): void { 166 | // Set domain to root DNS so that they share the same origin and communicate 167 | const members = document.domain.split("."); 168 | if (members.length > 1) { 169 | document.domain = members.slice(members.length-2).join("."); 170 | } 171 | 172 | Analytics({ 173 | app: "substrate-playground", 174 | plugins: [ 175 | simpleAnalyticsPlugin(), 176 | ] 177 | }); 178 | 179 | ReactDOM.render( 180 | , 181 | document.querySelector("main") 182 | ); 183 | } 184 | 185 | main(); 186 | -------------------------------------------------------------------------------- /frontend/src/lifecycle.tsx: -------------------------------------------------------------------------------- 1 | import { assign, Machine } from 'xstate'; 2 | import { Client, Configuration, LoggedUser, Template } from '@substrate/playground-client'; 3 | import { approve, approved } from './terms'; 4 | 5 | export enum PanelId {Session, Admin, Stats, Theia} 6 | 7 | export interface Context { 8 | panel: PanelId, 9 | conf: Configuration, 10 | user?: LoggedUser, 11 | templates: Record, 12 | error?: string, 13 | } 14 | 15 | export enum States { 16 | TERMS_UNAPPROVED = '@state/TERMS_UNAPPROVED', 17 | SETUP = '@state/SETUP', 18 | LOGGED = '@state/LOGGED', 19 | UNLOGGED = '@state/UNLOGGED', 20 | UNLOGGING = '@state/UNLOGGING', 21 | } 22 | 23 | export enum Events { 24 | TERMS_APPROVAL = '@event/TERMS_APPROVAL', 25 | LOGIN = '@action/LOGIN', 26 | SELECT = '@action/SELECT', 27 | RESTART = '@action/RESTART', 28 | UNLOGIN = '@action/UNLOGIN', 29 | LOGOUT = '@action/LOGOUT', 30 | } 31 | 32 | export enum Actions { 33 | STORE_TERMS_HASH = '@action/STORE_TERMS_HASH', 34 | } 35 | 36 | export function newMachine(client: Client, id: PanelId) { 37 | return Machine({ 38 | initial: approved()? States.SETUP: States.TERMS_UNAPPROVED, 39 | context: { 40 | panel: id, 41 | templates: {}, 42 | 43 | }, 44 | states: { 45 | [States.TERMS_UNAPPROVED]: { 46 | on: { 47 | [Events.TERMS_APPROVAL]: 48 | {target: States.SETUP, 49 | actions: [Actions.STORE_TERMS_HASH]}, 50 | } 51 | }, 52 | [States.SETUP]: { 53 | invoke: { 54 | src: () => async (callback) => { 55 | try { 56 | const { configuration, templates, user } = (await client.get()); 57 | if (user) { 58 | callback({type: Events.LOGIN, user: user, templates: templates, conf: configuration}); 59 | } else { 60 | callback({type: Events.UNLOGIN, templates: templates, conf: configuration}); 61 | } 62 | } catch (e) { 63 | const error = e.message || JSON.stringify(e); 64 | callback({type: Events.UNLOGIN, error: error}); 65 | } 66 | }, 67 | }, 68 | on: {[Events.LOGIN]: {target: States.LOGGED, 69 | actions: assign((_, event) => { 70 | return { 71 | user: event.user, 72 | templates: event.templates, 73 | conf: event.conf, 74 | } 75 | })}, 76 | [Events.UNLOGIN]: {target: States.UNLOGGED, 77 | actions: assign((_, event) => { 78 | return { 79 | user: undefined, 80 | templates: event.templates, 81 | conf: event.conf, 82 | error: event.error, 83 | } 84 | })}} 85 | }, 86 | [States.UNLOGGED]: { 87 | on: {[Events.RESTART]: States.SETUP,} 88 | }, 89 | [States.LOGGED]: { 90 | on: {[Events.RESTART]: States.SETUP, 91 | [Events.LOGOUT]: {target: States.UNLOGGING}, 92 | [Events.SELECT]: {actions: assign({ panel: (_, event) => event.panel})}} 93 | }, 94 | [States.UNLOGGING]: { 95 | invoke: { 96 | src: async () => { 97 | await client.logout(); 98 | }, 99 | onDone: {target: States.SETUP} 100 | } 101 | }, 102 | } 103 | }, 104 | { 105 | actions: { 106 | [Actions.STORE_TERMS_HASH]: () => { 107 | approve(); 108 | }, 109 | } 110 | }); 111 | } 112 | -------------------------------------------------------------------------------- /frontend/src/panels/login.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Button from "@material-ui/core/Button"; 3 | import GitHubIcon from '@material-ui/icons/GitHub'; 4 | import Typography from '@material-ui/core/Typography'; 5 | import { Client } from "@substrate/playground-client"; 6 | import { CenteredContainer } from "../components"; 7 | 8 | function login(client: Client): void { 9 | window.location.href = client.loginPath(); 10 | } 11 | 12 | export function LoginPanel({ client }: { client: Client }): JSX.Element { 13 | return ( 14 | 15 | 16 | You must log in to use Playground 17 | 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/panels/stats.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function StatsPanel(): JSX.Element { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/panels/terms.tsx: -------------------------------------------------------------------------------- 1 | import marked from 'marked'; 2 | import React, { useState } from "react"; 3 | import Button from "@material-ui/core/Button"; 4 | import Dialog from '@material-ui/core/Dialog'; 5 | import DialogContent from '@material-ui/core/DialogContent'; 6 | import DialogContentText from '@material-ui/core/DialogContentText'; 7 | import DialogTitle from '@material-ui/core/DialogTitle'; 8 | import Typography from '@material-ui/core/Typography'; 9 | import { CenteredContainer } from '../components'; 10 | 11 | function TermsDialog({ terms, show, onHide, onTermsApproved }: { terms: string, show: boolean, onHide: () => void, onTermsApproved: () => void }): JSX.Element { 12 | return ( 13 | 14 | Terms 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | } 24 | 25 | export function TermsPanel({ terms, onTermsApproved }: { terms: string, onTermsApproved: () => void }): JSX.Element { 26 | const [showTerms, setTermsVisible] = useState(false); 27 | return ( 28 | 29 | 30 | Please review terms before continuing 31 | 32 | setTermsVisible(false)} /> 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/panels/theia.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import Paper from '@material-ui/core/Paper'; 3 | import { Client, Template } from '@substrate/playground-client'; 4 | import { CenteredContainer, ErrorMessage, Loading } from '../components'; 5 | import { fetchWithTimeout } from '../utils'; 6 | 7 | interface Error { 8 | reason: string, 9 | action: () => void, 10 | actionTitle?: string, 11 | } 12 | 13 | interface Loading { 14 | phase: string, 15 | retry: number, 16 | } 17 | 18 | export function TheiaPanel({ client, autoDeploy, templates, onMissingSession, onSessionFailing, onSessionTimeout }: { client: Client, autoDeploy: string | null, templates: Record, onMissingSession: () => void, onSessionFailing: () => void, onSessionTimeout: () => void }): JSX.Element { 19 | const maxRetries = 5*60; 20 | const ref = useRef(null); 21 | const [error, setError] = useState(); 22 | const [url, setUrl] = useState(); 23 | const [loading, setLoading] = useState(); 24 | 25 | useEffect(() => { 26 | function createSession(template: string) { 27 | client.createCurrentSession({template: template}).then(fetchData); 28 | } 29 | 30 | async function fetchData() { 31 | const session = await client.getCurrentSession(); 32 | if (session) { 33 | const phase = session.pod.phase; 34 | if (phase == 'Running') { 35 | // Check URL is fine 36 | const url = `//${session.url}`; 37 | if ((await fetchWithTimeout(url)).ok) { 38 | setUrl(url); 39 | return; 40 | } 41 | } else if (phase == 'Pending') { 42 | const reason = session.pod.container?.reason; 43 | if (reason === "CrashLoopBackOff" || reason === "ErrImagePull" || reason === "ImagePullBackOff" || reason === "InvalidImageName") { 44 | setError({reason: session.pod.container?.message || 'Pod crashed', 45 | action: onSessionFailing}); 46 | return; 47 | } 48 | // The template is being deployed, nothing to do 49 | } 50 | } 51 | 52 | const retry = loading?.retry ?? 0; 53 | if (retry < maxRetries) { 54 | setLoading({phase: session?.pod.phase || 'Unknown', retry: retry + 1}); 55 | setTimeout(fetchData, 1000); 56 | } else if (retry == maxRetries) { 57 | setError({reason: "Couldn't access the theia session in time", 58 | action: onSessionTimeout}); 59 | } 60 | } 61 | 62 | // Entry point. 63 | // If autoDeploy, first attempt to locate the associated template and deploy it. 64 | // In all cases, delegates to `fetchData` 65 | if (autoDeploy) { 66 | if (!templates[autoDeploy]) { 67 | setError({reason: `Unknown template ${autoDeploy}`, 68 | action: onMissingSession}); 69 | return; 70 | } 71 | 72 | try { 73 | client.getCurrentSession().then(session => { 74 | if (session) { 75 | setError({reason: "You can only have one active substrate playground session open at a time. \n Please close all other sessions to open a new one", 76 | action: () => { 77 | // Trigger current session deletion, wait for deletion then re-create a new one 78 | return client.deleteCurrentSession() 79 | .then(function() { 80 | return new Promise(function(resolve) { 81 | const id = setInterval(async function() { 82 | const session = await client.getCurrentSession(); 83 | if (!session) { 84 | clearInterval(id); 85 | resolve(); 86 | } 87 | }, 1000); 88 | } 89 | )}) 90 | .then(() => setError(undefined)) 91 | .then(() => createSession(autoDeploy)); 92 | }, 93 | actionTitle: "Replace existing session"}); 94 | } else { 95 | createSession(autoDeploy); 96 | } 97 | }) 98 | } catch { 99 | setError({ reason: 'Error', action: onMissingSession}); 100 | } 101 | } else { 102 | fetchData(); 103 | } 104 | }, []); 105 | 106 | if (url) { 107 | return 108 | } else { 109 | return ( 110 | 111 | 112 | {error?.reason 113 | ? 114 | : } 115 | 116 | 117 | ); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /frontend/src/terms.tsx: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import terms from 'bundle-text:./terms.md'; 3 | 4 | const termsApprovedKey = 'termsApproved'; 5 | 6 | function hash(s: string): string { 7 | return crypto.createHash('md5').update(s).digest('hex'); 8 | } 9 | 10 | export function approved(): boolean { 11 | const approvedTermsHash = localStorage.getItem(termsApprovedKey); 12 | return hash(terms) == approvedTermsHash; 13 | } 14 | 15 | export function approve(): void { 16 | localStorage.setItem(termsApprovedKey, hash(terms)); 17 | } 18 | 19 | export { terms }; 20 | -------------------------------------------------------------------------------- /frontend/src/themes/index.ts: -------------------------------------------------------------------------------- 1 | export { default as SubstrateDark } from './substrate/dark'; 2 | export { default as SubstrateLight } from './substrate/light'; 3 | -------------------------------------------------------------------------------- /frontend/src/themes/substrate/colors.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | black: 'rgba(17, 17, 17, 1)', 3 | white: 'rgba(255, 255, 255, 1)', 4 | paper: 'rgba(238, 238, 238, 1)', 5 | substrate: { 6 | main: '#18FFB2', 7 | light: '#E7FAEC', 8 | dark: '#16DB9A', 9 | }, 10 | cyan: { 11 | light: 'rgba(92, 255, 200, 1)', 12 | main: 'rgba(24, 255, 178, 1)', 13 | dark: 'rgba(17, 179, 124, 1)', 14 | }, 15 | pink: { 16 | light: 'rgba(255, 92, 146, 1)', 17 | main: 'rgba(255, 24, 100, 1)', 18 | dark: 'rgba(179, 16, 70, 1)', 19 | contrastText: 'rgba(245, 245, 245, 1)', 20 | }, 21 | yellow: { 22 | light: '#F8E71C', 23 | main: '#FAEE60', 24 | dark: '#ADA113', 25 | contrastText: 'rgba(17, 17, 17, 1)', 26 | }, 27 | dark: { 28 | light: '#909090', 29 | main: '#606060', 30 | dark: '#111111', 31 | contrastText: 'rgba(245, 245, 245, 1)', 32 | }, 33 | light: { 34 | light: '#FFFFFF', 35 | main: '#eeeeee', 36 | dark: '#F7F7F7', 37 | contrastText: 'rgba(17, 17, 17, 1)', 38 | }, 39 | gradient: '(46deg, #a081d9 0%, #6c98f5 14%, #5ee7f9 34%, #d1e3cc 59%, #bb8ba6 99%)', 40 | }; 41 | -------------------------------------------------------------------------------- /frontend/src/themes/substrate/dark.ts: -------------------------------------------------------------------------------- 1 | import { ThemeOptions } from '@material-ui/core/styles'; 2 | import colors from './colors'; 3 | import typography from './typography'; 4 | import shadows from './shadows'; 5 | import { grey } from '@material-ui/core/colors'; 6 | 7 | const dark: ThemeOptions = { 8 | typography: typography.typography, 9 | shadows: shadows.shadows, 10 | palette: { 11 | type: 'dark', 12 | common: { 13 | black: colors.black, 14 | white: colors.white, 15 | }, 16 | background: { 17 | paper: '#21262A', 18 | default: colors.dark.dark, 19 | }, 20 | primary: { 21 | light: colors.substrate.dark, 22 | main: colors.cyan.main, 23 | dark: colors.cyan.dark, 24 | contrastText: colors.black, 25 | }, 26 | secondary: { 27 | light: colors.pink.light, 28 | main: '#FFFFFF', 29 | dark: '#FF3014', 30 | contrastText: colors.white, 31 | }, 32 | error: { 33 | light: 'rgba(247, 4, 7, 1)', 34 | main: 'rgba(235, 4, 7, 1)', 35 | dark: 'rgba(197, 5, 8, 1)', 36 | contrastText: colors.paper, 37 | }, 38 | text: { 39 | primary: colors.white, 40 | secondary: colors.light.main, 41 | disabled: colors.dark.light, 42 | hint: colors.yellow.main, 43 | }, 44 | divider: grey[800], 45 | }, 46 | }; 47 | 48 | export default dark; 49 | -------------------------------------------------------------------------------- /frontend/src/themes/substrate/light.ts: -------------------------------------------------------------------------------- 1 | import { ThemeOptions } from '@material-ui/core/styles'; 2 | import colors from './colors'; 3 | import typography from './typography'; 4 | import shadows from './shadows'; 5 | import { grey } from '@material-ui/core/colors'; 6 | 7 | const light: ThemeOptions = { 8 | typography: typography.typography, 9 | shadows: shadows.shadows, 10 | palette: { 11 | type: 'light', 12 | common: { 13 | black: colors.black, 14 | white: colors.white, 15 | }, 16 | background: { 17 | paper: colors.light.light, 18 | default: colors.light.dark, 19 | }, 20 | primary: { 21 | light: colors.substrate.light, 22 | main: colors.substrate.dark, 23 | dark: colors.substrate.dark, 24 | contrastText: colors.black, 25 | }, 26 | secondary: { 27 | light: '#89a7ce', 28 | main: colors.black, 29 | dark: '#534c5d', 30 | contrastText: colors.white, 31 | }, 32 | error: { 33 | light: 'rgba(247, 4, 7, 1)', 34 | main: 'rgba(235, 4, 7, 1)', 35 | dark: 'rgba(197, 5, 8, 1)', 36 | contrastText: colors.paper, 37 | }, 38 | text: { 39 | primary: colors.black, 40 | secondary: colors.dark.main, 41 | disabled: colors.dark.light, 42 | hint: colors.yellow.main, 43 | }, 44 | action: { 45 | active: colors.substrate.dark, 46 | }, 47 | divider: grey[300], 48 | }, 49 | }; 50 | 51 | export default light; 52 | -------------------------------------------------------------------------------- /frontend/src/themes/substrate/shadows.ts: -------------------------------------------------------------------------------- 1 | import { ThemeOptions } from '@material-ui/core/styles'; 2 | 3 | const shadows: ThemeOptions = { 4 | shadows: [ 5 | 'none', 6 | 'none', 7 | '0px 0px 16px rgba(0,0,0,0.15)', 8 | '0px 3px 3px -2px rgba(0,0,0,0.2),0px 3px 4px 0px rgba(0,0,0,0.14),0px 1px 8px 0px rgba(0,0,0,0.12)', 9 | '0px 2px 4px -1px rgba(0,0,0,0.2),0px 4px 5px 0px rgba(0,0,0,0.14),0px 1px 10px 0px rgba(0,0,0,0.12)', 10 | '0px 3px 5px -1px rgba(0,0,0,0.2),0px 5px 8px 0px rgba(0,0,0,0.14),0px 1px 14px 0px rgba(0,0,0,0.12)', 11 | '0px 3px 5px -1px rgba(0,0,0,0.2),0px 6px 10px 0px rgba(0,0,0,0.14),0px 1px 18px 0px rgba(0,0,0,0.12)', 12 | '0px 4px 5px -2px rgba(0,0,0,0.2),0px 7px 10px 1px rgba(0,0,0,0.14),0px 2px 16px 1px rgba(0,0,0,0.12)', 13 | '0px 5px 5px -3px rgba(0,0,0,0.2),0px 8px 10px 1px rgba(0,0,0,0.14),0px 3px 14px 2px rgba(0,0,0,0.12)', 14 | '0px 5px 6px -3px rgba(0,0,0,0.2),0px 9px 12px 1px rgba(0,0,0,0.14),0px 3px 16px 2px rgba(0,0,0,0.12)', 15 | '0px 6px 6px -3px rgba(0,0,0,0.2),0px 10px 14px 1px rgba(0,0,0,0.14),0px 4px 18px 3px rgba(0,0,0,0.12)', 16 | '0px 6px 7px -4px rgba(0,0,0,0.2),0px 11px 15px 1px rgba(0,0,0,0.14),0px 4px 20px 3px rgba(0,0,0,0.12)', 17 | '0px 7px 8px -4px rgba(0,0,0,0.2),0px 12px 17px 2px rgba(0,0,0,0.14),0px 5px 22px 4px rgba(0,0,0,0.12)', 18 | '0px 7px 8px -4px rgba(0,0,0,0.2),0px 13px 19px 2px rgba(0,0,0,0.14),0px 5px 24px 4px rgba(0,0,0,0.12)', 19 | '0px 7px 9px -4px rgba(0,0,0,0.2),0px 14px 21px 2px rgba(0,0,0,0.14),0px 5px 26px 4px rgba(0,0,0,0.12)', 20 | '0px 8px 9px -5px rgba(0,0,0,0.2),0px 15px 22px 2px rgba(0,0,0,0.14),0px 6px 28px 5px rgba(0,0,0,0.12)', 21 | '0px 8px 10px -5px rgba(0,0,0,0.2),0px 16px 24px 2px rgba(0,0,0,0.14),0px 6px 30px 5px rgba(0,0,0,0.12)', 22 | '0px 8px 11px -5px rgba(0,0,0,0.2),0px 17px 26px 2px rgba(0,0,0,0.14),0px 6px 32px 5px rgba(0,0,0,0.12)', 23 | '0px 9px 11px -5px rgba(0,0,0,0.2),0px 18px 28px 2px rgba(0,0,0,0.14),0px 7px 34px 6px rgba(0,0,0,0.12)', 24 | '0px 9px 12px -6px rgba(0,0,0,0.2),0px 19px 29px 2px rgba(0,0,0,0.14),0px 7px 36px 6px rgba(0,0,0,0.12)', 25 | '0px 10px 13px -6px rgba(0,0,0,0.2),0px 20px 31px 3px rgba(0,0,0,0.14),0px 8px 38px 7px rgba(0,0,0,0.12)', 26 | '0px 10px 13px -6px rgba(0,0,0,0.2),0px 21px 33px 3px rgba(0,0,0,0.14),0px 8px 40px 7px rgba(0,0,0,0.12)', 27 | '0px 10px 14px -6px rgba(0,0,0,0.2),0px 22px 35px 3px rgba(0,0,0,0.14),0px 8px 42px 7px rgba(0,0,0,0.12)', 28 | '0px 11px 14px -7px rgba(0,0,0,0.2),0px 23px 36px 3px rgba(0,0,0,0.14),0px 9px 44px 8px rgba(0,0,0,0.12)', 29 | '0px 11px 15px -7px rgba(0,0,0,0.2),0px 24px 38px 3px rgba(0,0,0,0.14),0px 9px 46px 8px rgba(0,0,0,0.12)' 30 | ], 31 | }; 32 | export default shadows; 33 | -------------------------------------------------------------------------------- /frontend/src/themes/substrate/typography.ts: -------------------------------------------------------------------------------- 1 | import { ThemeOptions } from '@material-ui/core/styles'; 2 | 3 | const typography: ThemeOptions = { 4 | typography: { 5 | fontFamily: 'Inter, Helvetica, Roboto, Arial, sans-serif', 6 | h1: { 7 | fontWeight: 500, 8 | fontSize: 30, 9 | lineHeight: '120%', 10 | }, 11 | h2: { 12 | fontWeight: 400, 13 | fontSize: 22, 14 | lineHeight: '120%', 15 | letterSpacing: 0.25, 16 | }, 17 | h3: { 18 | fontWeight: 600, 19 | fontSize: 20, 20 | lineHeight: '120%', 21 | }, 22 | h4: { 23 | fontWeight: 500, 24 | fontSize: 16, 25 | lineHeight: '120%', 26 | }, 27 | body1: { 28 | fontWeight: 400, 29 | fontSize: 14, 30 | lineHeight: '120%', 31 | letterSpacing: 0.15, 32 | }, 33 | body2: { 34 | fontWeight: 400, 35 | fontSize: 12, 36 | lineHeight: '120%', 37 | letterSpacing: 0.25, 38 | }, 39 | button: { 40 | fontWeight: 500, 41 | fontSize: 14, 42 | lineHeight: '140%', 43 | letterSpacing: 0.2, 44 | textTransform: 'none', 45 | }, 46 | subtitle1: { 47 | fontFamily: 'SFMono-Regular, Consolas , Liberation Mono, Menlo, monospace', 48 | fontWeight: 400, 49 | fontSize: 17, 50 | lineHeight: '200%', 51 | }, 52 | subtitle2: { 53 | fontFamily: 'SFMono-Regular, Consolas , Liberation Mono, Menlo, monospace', 54 | fontWeight: 400, 55 | fontSize: 13, 56 | lineHeight: '135%', 57 | letterSpacing: 0.1, 58 | }, 59 | overline: { 60 | fontSize: 11, 61 | letterSpacing: 0.7, 62 | }, 63 | }, 64 | }; 65 | export default typography; 66 | -------------------------------------------------------------------------------- /frontend/src/utils.tsx: -------------------------------------------------------------------------------- 1 | import { LoggedUser } from "@substrate/playground-client"; 2 | 3 | function timeout(promise: Promise, ms: number): Promise { 4 | return new Promise(function(resolve, reject) { 5 | setTimeout(function() { 6 | reject(new Error("timeout")); 7 | }, ms) 8 | promise.then(resolve, reject); 9 | }); 10 | } 11 | 12 | export async function fetchWithTimeout(url: string, init: RequestInit = {cache: "no-store"}, ms = 30000): Promise { 13 | return timeout(fetch(url, init), ms).catch(error => error); 14 | } 15 | 16 | export function formatDuration(s: number): string { 17 | const date = new Date(0); 18 | date.setSeconds(s); 19 | const hours = date.getUTCHours(); 20 | const minutes = date.getUTCMinutes(); 21 | const withMinutes = `${minutes}min`; 22 | if (hours) { 23 | return `${hours}h ${withMinutes}`; 24 | } else { 25 | return `${withMinutes}`; 26 | } 27 | } 28 | 29 | // User helpers 30 | 31 | export function isParitytechMember(user: LoggedUser): boolean { 32 | return user.organizations.indexOf('paritytech') != -1; 33 | } 34 | 35 | export function canCustomizeDuration(user: LoggedUser): boolean { 36 | return user.admin || user.canCustomizeDuration || isParitytechMember(user); 37 | } 38 | 39 | export function canCustomizePoolAffinity(user: LoggedUser): boolean { 40 | return user.admin || user.canCustomizePoolAffinity || isParitytechMember(user); 41 | } 42 | 43 | export function hasAdminReadRights(user: LoggedUser): boolean { 44 | return user.admin || isParitytechMember(user); 45 | } 46 | 47 | export function hasAdminEditRights(user: LoggedUser): boolean { 48 | return user.admin; 49 | } 50 | -------------------------------------------------------------------------------- /frontend/test/connect.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { Discoverer, Instance, Responder } from '../src/connect'; 3 | 4 | test('discoverer', t => { 5 | const discoverer = new Discoverer(instance => { 6 | }); 7 | const uuid = 'uuid'; 8 | const responder = new Responder(uuid, (i) => { 9 | }); 10 | responder.announce(); 11 | 12 | return new Promise(function(resolve) { 13 | setTimeout(() => { 14 | t.is(true, discoverer.instances.has(uuid)); 15 | resolve(); 16 | }, 100); 17 | }); 18 | }); 19 | 20 | test('instance', async t => { 21 | const action = "some-action"; 22 | const answer = {o: "answer"}; 23 | const uuid = 'uuid'; 24 | const instance = new Instance(uuid); 25 | const responder = new Responder(uuid, o => { 26 | responder.respond({type: "extension-answer", uuid: o.uuid, data: answer}); 27 | }); 28 | 29 | t.deepEqual(answer, await instance.execute(action)); 30 | 31 | instance.close(); 32 | responder.close(); 33 | }); -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "baseUrl": ".", 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "module": "commonjs", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react", 22 | }, 23 | "include": [ 24 | "src" 25 | ], "exclude": [ 26 | "node_modules" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /templates/.dockerignore: -------------------------------------------------------------------------------- 1 | **/src-gen/ 2 | **/lib/ 3 | **/node_modules/ 4 | **/plugins/ -------------------------------------------------------------------------------- /templates/Dockerfile.base: -------------------------------------------------------------------------------- 1 | # The recommended base image for templates. 2 | 3 | FROM ubuntu:22.10 4 | 5 | # Install required dependencies 6 | RUN apt update && \ 7 | apt upgrade -y && \ 8 | DEBIAN_FRONTEND=noninteractive apt install -yq sudo make gcc g++ curl dumb-init python2 vim git cmake pkg-config libssl-dev git gcc build-essential libsecret-1-0 git clang libclang-dev pkg-config xsel htop nodejs jq npm && \ 9 | apt-get clean && \ 10 | rm -rf /var/lib/apt/lists/* && \ 11 | npm install --global yarn 12 | 13 | # Install rust as required by substrate env 14 | # Pick up the version from https://rust-lang.github.io/rustup-components-history/index.html (rls is required) 15 | ARG RUST_VERSION=nightly-2022-02-15 16 | ARG USER=playground 17 | ARG HOME=/home/$USER 18 | ARG WORKSPACE=$HOME/workspace 19 | 20 | # Setup main user 21 | RUN adduser --quiet --disabled-password --shell /bin/bash --home $HOME --gecos '' $USER && \ 22 | echo "$USER:password" | chpasswd 23 | 24 | RUN chmod g+rw /home && \ 25 | mkdir -p $WORKSPACE && \ 26 | chown -R $USER:$USER $HOME; 27 | 28 | USER $USER 29 | 30 | ENV HOME=$HOME \ 31 | USER=$USER \ 32 | WORKSPACE=$WORKSPACE \ 33 | LANG=en_US.UTF-8 \ 34 | CARGO_HOME=$HOME/.cargo \ 35 | PATH=$HOME/.cargo/bin:$PATH \ 36 | SHELL=/bin/bash 37 | 38 | # Install rust toolchain 39 | RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain none \ 40 | && . $CARGO_HOME/env \ 41 | && rustup install $RUST_VERSION \ 42 | && rustup update \ 43 | && rustup default $RUST_VERSION \ 44 | && rustup component add rls rust-analysis rust-src clippy rustfmt llvm-tools-preview \ 45 | && rustup target add wasm32-unknown-unknown --toolchain $RUST_VERSION 46 | -------------------------------------------------------------------------------- /templates/Dockerfile.template: -------------------------------------------------------------------------------- 1 | # The image serving as base for templates. 2 | 3 | ARG BASE_TEMPLATE_VERSION 4 | 5 | FROM paritytech/substrate-playground-template-base:$BASE_TEMPLATE_VERSION 6 | 7 | # Ensure that needed env variables have been set by inherited image 8 | RUN [ ! -z "${USER}" ] || { echo "USER env variable must be set in inherited image"; exit 1; } && \ 9 | [ ! -z "${HOME}" ] || { echo "HOME env variable must be set in inherited image"; exit 1; } && \ 10 | [ ! -z "${WORKSPACE}" ] || { echo "WORKSPACE env variable must be set in inherited image"; exit 1; } 11 | 12 | WORKDIR $WORKSPACE 13 | 14 | COPY --chown=$USER . . 15 | 16 | SHELL ["/bin/bash", "-c"] 17 | 18 | # Run build commands as part of the image creation 19 | RUN DEFAULT_BUILD_COMMANDS=("cargo build" "cargo check") \ 20 | && eval $(cat .devcontainer/devcontainer.json 2> /dev/null | jq -r 'select(.preCreateCommand | type == "array") | @sh "CONFIGURED_BUILD_COMMANDS=( \(.preCreateCommand) )" // ""') \ 21 | && BUILD_COMMANDS=("${CONFIGURED_BUILD_COMMANDS[@]:-${DEFAULT_BUILD_COMMANDS[@]}}") \ 22 | && for c in "${BUILD_COMMANDS[@]}";do eval $c;done 23 | -------------------------------------------------------------------------------- /templates/Dockerfile.theia-base: -------------------------------------------------------------------------------- 1 | # The theia image used to build theia 2 | # Based on https://github.com/theia-ide/theia-apps/blob/master/theia-docker/Dockerfile 3 | 4 | FROM node:14 5 | 6 | WORKDIR /home/theia 7 | 8 | RUN apt-get update && \ 9 | apt-get -y install libsecret-1-dev && \ 10 | rm -rf /var/lib/apt/lists/* 11 | 12 | ADD *.json ./ 13 | ADD theia-playground theia-playground 14 | ADD theia-playground-extension theia-playground-extension 15 | 16 | RUN yarn && \ 17 | NODE_OPTIONS="--max-old-space-size=5120" yarn build 18 | -------------------------------------------------------------------------------- /templates/Dockerfile.theia-template: -------------------------------------------------------------------------------- 1 | # The image serving as base for theia templates. 2 | # Inherits from the 3rd party docker image and adds the relevant bits so that it can run theia 3 | # 4 | # Built as a multi-stage image (https://docs.docker.com/develop/develop-images/multistage-build/) 5 | 6 | ARG BASE_TEMPLATE_VERSION 7 | ARG TEMPLATE_IMAGE 8 | 9 | FROM paritytech/substrate-playground-template-theia-base:$BASE_TEMPLATE_VERSION as theia-base 10 | 11 | FROM $TEMPLATE_IMAGE 12 | 13 | # Env variable must be set by the inherited image (i.e. $TEMPLATE_IMAGE) 14 | RUN [ ! -z "${USER}" ] || { echo "USER env variable must be set"; exit 1; } && \ 15 | [ ! -z "${HOME}" ] || { echo "HOME env variable must be set"; exit 1; } && \ 16 | [ ! -z "${WORKSPACE}" ] || { echo "WORKSPACE env variable must be set"; exit 1; } 17 | 18 | RUN ulimit -n 65535 19 | 20 | # From theia-base 21 | ARG THEIA_HOME=$HOME/theia 22 | ARG THEIA_PLAYGROUND_HOME=$THEIA_HOME/theia-playground 23 | 24 | # Copy VSCode user settings 25 | COPY --chown=$USER:$USER conf/.vscode/* $HOME/.theia/ 26 | 27 | # Copy the whole theia folder, including node_modules 28 | # This is the recommended approach: https://spectrum.chat/theia/dev/theia-packaging~6c10127c-a316-4e87-9a27-e4b70fb647c1 29 | COPY --from=theia-base --chown=$USER:$USER /home/theia $THEIA_HOME 30 | COPY --from=theia-base --chown=$USER:$USER /usr/local/bin/node $THEIA_HOME 31 | 32 | ENV USE_LOCAL_GIT=true \ 33 | HOST=0.0.0.0 \ 34 | THEIA_DEFAULT_PLUGINS=local-dir:$THEIA_PLAYGROUND_HOME/plugins 35 | 36 | # Folder matches the entry point from templates/ 37 | WORKDIR $THEIA_PLAYGROUND_HOME 38 | 39 | # TODO replace with entrypoint script so that env variable can be used 40 | ENTRYPOINT [ "dumb-init", "/home/playground/theia/node", "--always-compact", "--max-old-space-size=64", "src-gen/backend/main.js", "/home/playground/workspace", "--hostname=0.0.0.0" ] 41 | -------------------------------------------------------------------------------- /templates/README.md: -------------------------------------------------------------------------------- 1 | [![Docker Image](https://img.shields.io/docker/pulls/parity/theia-substrate.svg?maxAge=2592000)](https://hub.docker.com/r/parity/theia-substrate/) 2 | 3 | A substrate ready Docker image based on theia. 4 | 5 | ## Dev 6 | 7 | ``` 8 | yarn 9 | yarn dev 10 | ``` 11 | 12 | Then browse http://localhost:3000/ -------------------------------------------------------------------------------- /templates/conf/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | {"rust-analyzer.updates.askBeforeDownload": false} -------------------------------------------------------------------------------- /templates/lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.0.0", 3 | "useWorkspaces": true, 4 | "npmClient": "yarn", 5 | "command": { 6 | "run": { 7 | "stream": true 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /templates/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "prepare": "lerna run prepare", 5 | "dev": "yarn workspace @parity/theia-playground theia download:plugins && yarn workspace @parity/theia-playground start --vscode-api-version=1.54.0 --plugins=local-dir:plugins ..", 6 | "build": "yarn workspace @parity/theia-playground theia download:plugins && yarn workspace @parity/theia-playground theia build", 7 | "clean": "lerna run clean && rm -rf node_modules/" 8 | }, 9 | "devDependencies": { 10 | "lerna": "6.0.3" 11 | }, 12 | "workspaces": ["theia-playground-extension", "theia-playground"] 13 | } 14 | -------------------------------------------------------------------------------- /templates/theia-playground-extension/assets/substrate-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/substrate-playground/c195c7be532585b5cdf7aafe22e320f319659857/templates/theia-playground-extension/assets/substrate-logo.png -------------------------------------------------------------------------------- /templates/theia-playground-extension/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@parity/theia-playground-extension", 3 | "keywords": [ 4 | "theia-extension" 5 | ], 6 | "version": "0.5.1", 7 | "files": [ 8 | "lib", 9 | "src" 10 | ], 11 | "dependencies": { 12 | "@substrate/playground-client": "1.5.0", 13 | "@theia/core": "1.31.1", 14 | "gunzip-maybe": "1.4.2", 15 | "vscode-uri": "3.0.6" 16 | }, 17 | "devDependencies": { 18 | "typescript": "4.8.4" 19 | }, 20 | "scripts": { 21 | "prepare": "yarn run build", 22 | "build": "tsc", 23 | "watch": "tsc -w", 24 | "clean": "rm -rf lib/ node_modules/ yarn.lock" 25 | }, 26 | "theiaExtensions": [ 27 | { 28 | "frontend": "lib/browser/theia-playground-extension-frontend-module", 29 | "backend": "lib/node/theia-playground-extension-backend-module" 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /templates/theia-playground-extension/src/browser/http-location-mapper.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "inversify"; 2 | import { MaybePromise } from '@theia/core/lib/common/types'; 3 | import { LocationMapper } from '@theia/mini-browser/lib/browser/location-mapper-service'; 4 | 5 | function isLocalhost(location: string): boolean { 6 | return location.startsWith('localhost') || location.startsWith('http://localhost') || location.startsWith('https://localhost'); 7 | } 8 | 9 | /* 10 | Replace localhost access with DNS 11 | */ 12 | @injectable() 13 | export class HTTPLocationMapper implements LocationMapper { 14 | 15 | canHandle(location: string): MaybePromise { 16 | return isLocalhost(location) ? 2 : 0; 17 | } 18 | 19 | map(location: string): MaybePromise { 20 | return location.replace(/localhost/, window.location.hostname); 21 | } 22 | 23 | } -------------------------------------------------------------------------------- /templates/theia-playground-extension/src/browser/initial-files-open.ts: -------------------------------------------------------------------------------- 1 | import { injectable, inject } from "inversify"; 2 | import { FrontendApplicationContribution, FrontendApplication, OpenerService, WidgetOpenerOptions, ApplicationShell, Widget, open } from "@theia/core/lib/browser"; 3 | import URI from '@theia/core/lib/common/uri'; 4 | import { WorkspaceService } from '@theia/workspace/lib/browser'; 5 | import { PreviewUri } from '@theia/preview/lib/browser/preview-uri'; 6 | import { FileService } from '@theia/filesystem/lib/browser/file-service'; 7 | import { FileStat } from '@theia/filesystem/lib/common/files'; 8 | 9 | /* 10 | Open initial files if defined, or README. 11 | */ 12 | @injectable() 13 | export class InitialFilesOpen implements FrontendApplicationContribution { 14 | 15 | @inject(OpenerService) protected readonly openerService: OpenerService; 16 | @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; 17 | @inject(FileService) protected readonly fileService: FileService; 18 | @inject(ApplicationShell) protected readonly shell: ApplicationShell; 19 | 20 | protected async workspaceRoot() { 21 | const roots = await this.workspaceService.roots; 22 | return roots[0]; 23 | } 24 | 25 | async onStart(_app: FrontendApplication) { 26 | const root = await this.workspaceRoot(); 27 | const urlParams = new URLSearchParams(window.location.search); 28 | const files = urlParams.get('files'); 29 | if (files) { 30 | decodeURIComponent(files).split(",").forEach(uri => this.revealFile(root.resource.resolve(uri))); 31 | } else { 32 | const uri = await this.locateReadme(); 33 | if (uri) { 34 | this.revealFile(uri, true); 35 | } 36 | } 37 | } 38 | 39 | protected async locateReadme(): Promise { 40 | const location: FileStat | undefined = (await this.workspaceService.roots)[0]; 41 | if (!location || !location?.children) { 42 | return undefined; 43 | } 44 | for (const f of location.children) { 45 | if (!f.isDirectory) { 46 | const fileName = f.resource.path.base.toLowerCase(); 47 | if (fileName.startsWith('readme.md')) { 48 | return f.resource; 49 | } 50 | } 51 | } 52 | return undefined; 53 | } 54 | 55 | protected async revealFile(uri: URI, preview: boolean = false): Promise { 56 | const previewUri = preview ? PreviewUri.encode(uri) : uri; 57 | const widget = await open(this.openerService, previewUri, { mode: 'reveal' }); 58 | if (widget instanceof Widget) { 59 | this.shell.activateWidget(widget.id); 60 | } 61 | } 62 | 63 | } -------------------------------------------------------------------------------- /templates/theia-playground-extension/src/browser/style/index.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | font-family: var(--theia-ui-font-family); 3 | } 4 | 5 | .col { 6 | flex: 1; 7 | align-items: center; 8 | justify-content: center; 9 | } 10 | 11 | .flex-grid { 12 | display: flex; 13 | } 14 | 15 | .shepherd-button{background:#3288e6;border:0;border-radius:3px;color:hsla(0,0%,100%,.75);cursor:pointer;margin-right:.5rem;padding:.5rem 1.5rem;transition:all .5s ease}.shepherd-button:not(:disabled):hover{background:#196fcc;color:hsla(0,0%,100%,.75)}.shepherd-button.shepherd-button-secondary{background:#f1f2f3;color:rgba(0,0,0,.75)}.shepherd-button.shepherd-button-secondary:not(:disabled):hover{background:#d6d9db;color:rgba(0,0,0,.75)}.shepherd-button:disabled{cursor:not-allowed} 16 | .shepherd-footer{border-bottom-left-radius:5px;border-bottom-right-radius:5px;display:flex;justify-content:flex-end;padding:0 .75rem .75rem}.shepherd-footer .shepherd-button:last-child{margin-right:0} 17 | .shepherd-cancel-icon{background:transparent;border:none;color:hsla(0,0%,50.2%,.75);font-size:2em;cursor:pointer;font-weight:400;margin:0;padding:0;transition:color .5s ease}.shepherd-cancel-icon:hover{color:rgba(0,0,0,.75)}.shepherd-has-title .shepherd-content .shepherd-cancel-icon{color:hsla(0,0%,50.2%,.75)}.shepherd-has-title .shepherd-content .shepherd-cancel-icon:hover{color:rgba(0,0,0,.75)} 18 | .shepherd-title{color:rgba(0,0,0,.75);display:flex;font-size:1rem;font-weight:400;flex:1 0 auto;margin:0;padding:0} 19 | .shepherd-header{align-items:center;border-top-left-radius:5px;border-top-right-radius:5px;display:flex;justify-content:flex-end;line-height:2em;padding:.75rem .75rem 0}.shepherd-has-title .shepherd-content .shepherd-header{background:#e6e6e6;padding:1em} 20 | .shepherd-text{color:rgba(0,0,0,.75);font-size:1rem;line-height:1.3em;padding:.75em}.shepherd-text p{margin-top:0}.shepherd-text p:last-child{margin-bottom:0} 21 | .shepherd-content{border-radius:5px;outline:none;padding:0} 22 | .shepherd-element{background:#fff;border-radius:5px;box-shadow:0 1px 4px rgba(0,0,0,.2);max-width:400px;opacity:0;outline:none;transition:opacity .3s;z-index:9999}.shepherd-enabled.shepherd-element{opacity:1;}.shepherd-element,.shepherd-element *,.shepherd-element :after,.shepherd-element :before{box-sizing:border-box}.shepherd-element .shepherd-arrow{border:16px solid transparent;content:"";display:block;height:16px;pointer-events:none;position:absolute;width:16px;z-index:10000}.shepherd-element.shepherd-element-attached-bottom.shepherd-element-attached-center .shepherd-arrow{bottom:0;border-top-color:#fff;left:50%;transform:translate(-50%,100%)}.shepherd-element.shepherd-element-attached-top.shepherd-element-attached-center .shepherd-arrow{border-bottom-color:#fff;left:50%;top:0;transform:translate(-50%,-100%)}.shepherd-element.shepherd-element-attached-top.shepherd-element-attached-center.shepherd-has-title .shepherd-arrow{border-bottom-color:#e6e6e6}.shepherd-element.shepherd-element-attached-middle.shepherd-element-attached-left .shepherd-arrow{border-right-color:#fff;left:0;top:50%;transform:translate(-100%,-50%)}.shepherd-element.shepherd-element-attached-middle.shepherd-element-attached-right .shepherd-arrow{border-left-color:#fff;right:0;top:50%;transform:translate(100%,-50%)}.shepherd-target-click-disabled.shepherd-enabled.shepherd-target,.shepherd-target-click-disabled.shepherd-enabled.shepherd-target *{pointer-events:none} 23 | .shepherd-modal-overlay-container{-ms-filter:progid:dximagetransform.microsoft.gradient.alpha(Opacity=50);filter:alpha(opacity=50);fill-rule:evenodd;height:0;left:0;opacity:0;overflow:hidden;pointer-events:none;position:fixed;top:0;transition:all .3s ease-out,height 0ms .3s,opacity .3s 0ms;width:100vw;z-index:9997}.shepherd-modal-overlay-container.shepherd-modal-is-visible{height:100vh;opacity:.5;transition:all .3s ease-out,height 0s 0s,opacity .3s 0s}.shepherd-modal-overlay-container.shepherd-modal-is-visible path{pointer-events:all} 24 | [data-shepherd-step-id="more-step"] .shepherd-arrow {border-left-color: #fff;top: 15%;transform: translate(80%,-50%);right: 0;} -------------------------------------------------------------------------------- /templates/theia-playground-extension/src/browser/theia-playground-extension-contribution.ts: -------------------------------------------------------------------------------- 1 | import { injectable, inject } from "inversify"; 2 | import { MAIN_MENU_BAR, CommandContribution, CommandRegistry, MenuContribution, MenuModelRegistry } from "@theia/core/lib/common"; 3 | import { ConnectionStatusService, ConnectionStatus } from '@theia/core/lib/browser/connection-status-service'; 4 | import { MessageService } from '@theia/core/lib/common/message-service'; 5 | import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; 6 | import { FileNavigatorContribution } from '@theia/navigator/lib/browser/navigator-contribution'; 7 | import { TerminalService } from '@theia/terminal/lib/browser/base/terminal-service'; 8 | import { TerminalWidgetOptions } from '@theia/terminal/lib/browser/base/terminal-widget'; 9 | import { FileService } from "@theia/filesystem/lib/browser/file-service"; 10 | import { FileStat } from '@theia/filesystem/lib/common/files'; 11 | import { FileDownloadService } from '@theia/filesystem/lib/browser/download/file-download-service'; 12 | import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; 13 | import URI from '@theia/core/lib/common/uri'; 14 | import { URI as VSCodeURI } from 'vscode-uri'; 15 | 16 | async function executeCommandInTerminal(terminalService: TerminalService, command: string, options: TerminalWidgetOptions = {}): Promise { 17 | const terminal = await terminalService.newTerminal(options); 18 | await terminal.start(); 19 | await terminalService.open(terminal); 20 | terminal.sendText(command+'\r\n'); 21 | } 22 | 23 | function answer(type: string, uuid?: string, data?: any): void { 24 | window.parent.postMessage({type: type, uuid: uuid, data: data}, "*"); 25 | } 26 | 27 | function updateStatus(status: ConnectionStatus): void { 28 | if (status === ConnectionStatus.OFFLINE) { 29 | answer("extension-offline"); 30 | } else { 31 | answer("extension-online"); 32 | } 33 | } 34 | 35 | function unmarshall(payload) { 36 | const {type, data} = payload; 37 | if (type) { 38 | switch(type) { 39 | case "URI": 40 | return VSCodeURI.parse(data); 41 | default: 42 | throw new Error(`Failed to unmarshall unknown type ${type}`); 43 | } 44 | } else { 45 | return payload; 46 | } 47 | } 48 | 49 | function registerBridge(registry, connectionStatusService, messageService) { 50 | // Listen to message from parent frame 51 | window.addEventListener('message', async (o) => { 52 | const {type, name, data, uuid} = o.data; 53 | if (type) { // Filter extension related message 54 | const status = connectionStatusService.currentStatus; 55 | if (status === ConnectionStatus.OFFLINE) { 56 | answer("extension-answer-offline", uuid); 57 | return; 58 | } 59 | 60 | switch (type) { 61 | case "action": { 62 | try { 63 | const result = await registry.executeCommand(name, unmarshall(data)); 64 | answer("extension-answer", uuid, result); 65 | } catch (error) { 66 | messageService.error(`Error while executing ${name}.`, error.message); 67 | answer("extension-answer-error", uuid, {name: name, message: error.message, data: data}); 68 | } 69 | break; 70 | } 71 | case "list-actions": { 72 | answer("extension-answer", uuid, registry.commands); 73 | break; 74 | } 75 | default: 76 | if (type) { 77 | const message = `Unknown extension type ${type}`; 78 | console.error(message, o); 79 | answer("extension-answer-error", uuid, message); 80 | } 81 | break; 82 | } 83 | } 84 | }, false); 85 | 86 | connectionStatusService.onStatusChange(() => updateStatus(connectionStatusService.currentStatus)); 87 | 88 | const online = connectionStatusService.currentStatus === ConnectionStatus.ONLINE; 89 | answer("extension-advertise", "", {online: online}); 90 | } 91 | 92 | async function locateDevcontainer(workspaceService: WorkspaceService, fileService: FileService): Promise { 93 | const location: FileStat | undefined = (await workspaceService.roots)[0]; 94 | if (!location || !location?.children) { 95 | return undefined; 96 | } 97 | for (const f of location.children) { 98 | if (f.isFile) { 99 | const fileName = f.resource.path.base.toLowerCase(); 100 | if (fileName.startsWith('devcontainer.json')) { 101 | return f.resource; 102 | } 103 | } else { 104 | const fileName = f.resource.path.base.toLowerCase(); 105 | const f2 = await fileService.resolve(f.resource); 106 | if (fileName.startsWith('.devcontainer') && f2.children) { 107 | for (const ff of f2.children) { 108 | const ffileName = ff.resource.path.base.toLowerCase(); 109 | if (ffileName.startsWith('devcontainer.json')) { 110 | return ff.resource; 111 | } 112 | } 113 | } 114 | } 115 | f.children 116 | } 117 | return undefined; 118 | } 119 | 120 | function replaceVariable(arg: string): string { 121 | return arg.replace("$HOST", window.location.host); 122 | } 123 | 124 | async function executeAction(type: string, args: Array) : Promise { 125 | const sanitizedArgs = args.map(replaceVariable); 126 | switch(type) { 127 | case "external-preview": 128 | window.open(sanitizedArgs[0]); 129 | break; 130 | default: 131 | console.error(`Unknown type: ${type}`); 132 | } 133 | } 134 | 135 | function parseCommands(command: any): Array { 136 | if (typeof command === "string") { 137 | return [command]; 138 | } else if (Array.isArray(command)) { 139 | return command; 140 | } else if (command) { 141 | console.error(`Unknown command type: ${command}`); 142 | } 143 | return []; 144 | } 145 | 146 | @injectable() 147 | export class TheiaSubstrateExtensionCommandContribution implements CommandContribution { 148 | 149 | @inject(FileNavigatorContribution) 150 | protected readonly fileNavigatorContribution: FileNavigatorContribution; 151 | 152 | @inject(TerminalService) 153 | protected readonly terminalService: TerminalService; 154 | 155 | @inject(CommandRegistry) 156 | protected readonly commandRegistry: CommandRegistry; 157 | 158 | @inject(FileDownloadService) 159 | protected readonly downloadService: FileDownloadService; 160 | 161 | @inject(WorkspaceService) 162 | protected readonly workspaceService: WorkspaceService; 163 | 164 | @inject(ConnectionStatusService) 165 | protected readonly connectionStatusService: ConnectionStatusService; 166 | 167 | @inject(MessageService) 168 | protected readonly messageService: MessageService; 169 | 170 | @inject(FrontendApplicationStateService) 171 | protected readonly stateService: FrontendApplicationStateService; 172 | 173 | @inject(FileService) 174 | protected readonly fileService: FileService; 175 | 176 | registerCommands(registry: CommandRegistry): void { 177 | if (window !== window.parent) { 178 | // Running in a iframe 179 | registerBridge(registry, this.connectionStatusService, this.messageService); 180 | const members = document.domain.split("."); 181 | document.domain = members.slice(members.length-2).join("."); 182 | } 183 | 184 | this.stateService.reachedState('ready').then( 185 | async () => { 186 | this.fileNavigatorContribution.openView({reveal: true}); 187 | 188 | // Delete all existing terminals 189 | this.terminalService.all.forEach(terminal => terminal.close()); 190 | 191 | const uri = await locateDevcontainer(this.workspaceService, this.fileService); 192 | if (uri) { 193 | const file = await this.fileService.readFile(uri); 194 | const { postStartCommand, postAttachCommand } = JSON.parse(file.value.toString()); 195 | // Go through all defined commands, cretae a new terminal if necessary, then send command 196 | const postStartCommands = parseCommands(postStartCommand); 197 | const postAttachCommands = parseCommands(postAttachCommand); 198 | postStartCommands.concat(postAttachCommands).forEach( async (command, index) => { 199 | await executeCommandInTerminal(this.terminalService, command, {id: `command-${index}`}); 200 | }); 201 | } 202 | } 203 | ); 204 | } 205 | 206 | } 207 | 208 | @injectable() 209 | export class TheiaSubstrateExtensionMenuContribution implements MenuContribution { 210 | 211 | @inject(WorkspaceService) 212 | protected readonly workspaceService: WorkspaceService; 213 | 214 | @inject(FileService) 215 | protected readonly fileService: FileService; 216 | 217 | @inject(CommandRegistry) 218 | protected readonly registry: CommandRegistry; 219 | 220 | async registerMenus(menus: MenuModelRegistry): Promise { 221 | const PLAYGROUND = [...MAIN_MENU_BAR, '8_playground']; 222 | menus.registerSubmenu(PLAYGROUND, 'Playground'); 223 | const uri = await locateDevcontainer(this.workspaceService, this.fileService); 224 | if (uri) { 225 | const file = await this.fileService.readFile(uri); 226 | const { menuActions } = JSON.parse(file.value.toString()); 227 | if (Array.isArray(menuActions)) { 228 | menuActions.forEach(({id, label, type, args}, i) => { 229 | const command = {id: id, label: label}; 230 | this.registry.registerCommand(command, { 231 | execute: async () => { 232 | executeAction(type, args); 233 | } 234 | }); 235 | 236 | const index = `${i}_${id}`; 237 | const MENU_ITEM = [...PLAYGROUND, index]; 238 | menus.registerMenuAction(MENU_ITEM, { 239 | commandId: command.id 240 | }); 241 | }); 242 | } else if (menuActions) { 243 | console.error(`Incorrect value for menuActions: ${menuActions}`); 244 | } 245 | } 246 | } 247 | 248 | } 249 | -------------------------------------------------------------------------------- /templates/theia-playground-extension/src/browser/theia-playground-extension-frontend-module.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Main theia-extension entry point 3 | */ 4 | 5 | import { HTTPLocationMapper } from './http-location-mapper'; 6 | import { InitialFilesOpen } from './initial-files-open'; 7 | import { TheiaSubstrateExtensionCommandContribution, TheiaSubstrateExtensionMenuContribution } from './theia-playground-extension-contribution'; 8 | import { FrontendApplicationContribution } from '@theia/core/lib/browser'; 9 | import { CommandContribution, MenuContribution } from "@theia/core/lib/common"; 10 | import { LocationMapper } from '@theia/mini-browser/lib/browser/location-mapper-service'; 11 | import { ContainerModule } from "inversify"; 12 | import '../../src/browser/style/index.css'; 13 | 14 | export default new ContainerModule(bind => { 15 | bind(InitialFilesOpen).toSelf().inSingletonScope(); 16 | bind(FrontendApplicationContribution).toService(InitialFilesOpen); 17 | 18 | bind(CommandContribution).to(TheiaSubstrateExtensionCommandContribution); 19 | bind(MenuContribution).to(TheiaSubstrateExtensionMenuContribution); 20 | bind(LocationMapper).to(HTTPLocationMapper).inSingletonScope(); 21 | }); 22 | -------------------------------------------------------------------------------- /templates/theia-playground-extension/src/node/file-download-handler.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { v4 } from 'uuid'; 3 | import * as fs from 'fs-extra'; 4 | import { PackOptions, pack } from 'tar-fs'; 5 | import * as gunzip from 'gunzip-maybe'; 6 | import { Request, Response } from 'express'; 7 | import { injectable } from 'inversify'; 8 | import { BAD_REQUEST, METHOD_NOT_ALLOWED, NOT_FOUND, INTERNAL_SERVER_ERROR } from 'http-status-codes'; 9 | import { isEmpty } from '@theia/core/lib/common/objects'; 10 | import URI from '@theia/core/lib/common/uri'; 11 | import { FileUri } from '@theia/core/lib/node/file-uri'; 12 | import { FileDownloadHandler } from '@theia/filesystem/lib/node/download/file-download-handler'; 13 | import { FileDownloadData } from '@theia/filesystem/lib/common/download/file-download-data'; 14 | 15 | interface PrepareDownloadOptions { 16 | filePath: string; 17 | downloadId: string; 18 | remove: boolean; 19 | root?: string; 20 | } 21 | 22 | async function archive(inputPath: string, outputPath: string, options: PackOptions): Promise { 23 | return new Promise(async (resolve, reject) => { 24 | pack(inputPath, options).pipe(gunzip()).pipe(fs.createWriteStream(outputPath)).on('finish', () => resolve()).on('error', e => reject(e)); 25 | }); 26 | } 27 | 28 | function ignoreBuildArtefacts(name: string) { 29 | const normalized = path.normalize(name); 30 | return normalized.includes('target') || normalized.includes('node_modules') || normalized.includes('.git'); 31 | } 32 | 33 | @injectable() 34 | export class PlaygroundSingleFileDownloadHandler extends FileDownloadHandler { 35 | 36 | async handle(request: Request, response: Response): Promise { 37 | const { method, body, query } = request; 38 | if (method !== 'GET') { 39 | this.handleError(response, `Unexpected HTTP method. Expected GET got '${method}' instead.`, METHOD_NOT_ALLOWED); 40 | return; 41 | } 42 | if (body !== undefined && !isEmpty(body)) { 43 | this.handleError(response, `The request body must either undefined or empty when downloading a single file. The body was: ${JSON.stringify(body)}.`, BAD_REQUEST); 44 | return; 45 | } 46 | if (query === undefined || query.uri === undefined || typeof query.uri !== 'string') { 47 | this.handleError(response, `Cannot access the 'uri' query from the request. The query was: ${JSON.stringify(query)}.`, BAD_REQUEST); 48 | return; 49 | } 50 | const uri = new URI(query.uri).toString(true); 51 | const filePath = FileUri.fsPath(uri); 52 | 53 | let stat: fs.Stats; 54 | try { 55 | stat = await fs.stat(filePath); 56 | } catch { 57 | this.handleError(response, `The file does not exist. URI: ${uri}.`, NOT_FOUND); 58 | return; 59 | } 60 | try { 61 | const downloadId = v4(); 62 | const options: PrepareDownloadOptions = { filePath, downloadId, remove: false }; 63 | if (!stat.isDirectory()) { 64 | await this.prepareDownload(request, response, options); 65 | } else { 66 | const outputRootPath = await this.createTempDir(downloadId); 67 | const outputPath = path.join(outputRootPath, `${path.basename(filePath)}.tar.gz`); 68 | await archive(filePath, outputPath, {ignore: ignoreBuildArtefacts}); 69 | options.filePath = outputPath; 70 | options.remove = true; 71 | options.root = outputRootPath; 72 | await this.prepareDownload(request, response, options); 73 | } 74 | } catch (e) { 75 | this.handleError(response, e, INTERNAL_SERVER_ERROR); 76 | } 77 | } 78 | 79 | } 80 | 81 | @injectable() 82 | export class PlaygroundMultiFileDownloadHandler extends FileDownloadHandler { 83 | 84 | async handle(request: Request, response: Response): Promise { 85 | const { method, body } = request; 86 | if (method !== 'PUT') { 87 | this.handleError(response, `Unexpected HTTP method. Expected PUT got '${method}' instead.`, METHOD_NOT_ALLOWED); 88 | return; 89 | } 90 | if (body === undefined) { 91 | this.handleError(response, 'The request body must be defined when downloading multiple files.', BAD_REQUEST); 92 | return; 93 | } 94 | if (!FileDownloadData.is(body)) { 95 | this.handleError(response, `Unexpected body format. Cannot extract the URIs from the request body. Body was: ${JSON.stringify(body)}.`, BAD_REQUEST); 96 | return; 97 | } 98 | if (body.uris.length === 0) { 99 | this.handleError(response, `Insufficient body format. No URIs were defined by the request body. Body was: ${JSON.stringify(body)}.`, BAD_REQUEST); 100 | return; 101 | } 102 | for (const uri of body.uris) { 103 | try { 104 | await fs.access(FileUri.fsPath(uri)); 105 | } catch { 106 | this.handleError(response, `The file does not exist. URI: ${uri}.`, NOT_FOUND); 107 | return; 108 | } 109 | } 110 | try { 111 | const downloadId = v4(); 112 | const outputRootPath = await this.createTempDir(downloadId); 113 | const distinctUris = Array.from(new Set(body.uris.map(uri => new URI(uri)))); 114 | const tarPaths: string[] = []; 115 | // We should have one key in the map per FS drive. 116 | for (const [rootUri, uris] of (await this.directoryArchiver.findCommonParents(distinctUris)).entries()) { 117 | const rootPath = FileUri.fsPath(rootUri); 118 | const entries = uris.map(FileUri.fsPath).map(p => path.relative(rootPath, p)); 119 | const outputPath = path.join(outputRootPath, `${path.basename(rootPath)}.tar.gz`); 120 | await archive(rootPath, outputPath, {entries: entries, ignore: ignoreBuildArtefacts}); 121 | tarPaths.push(outputPath); 122 | } 123 | const options = { filePath: '', downloadId, remove: true, root: outputRootPath }; 124 | if (tarPaths.length === 1) { 125 | // tslint:disable-next-line:whitespace 126 | const [outputPath,] = tarPaths; 127 | options.filePath = outputPath; 128 | await this.prepareDownload(request, response, options); 129 | } else { 130 | // We need to tar the tars. 131 | const outputPath = path.join(outputRootPath, `theia-archive-${Date.now()}.tar`); 132 | options.filePath = outputPath; 133 | await this.archive(outputRootPath, outputPath, tarPaths.map(p => path.relative(outputRootPath, p))); 134 | await this.prepareDownload(request, response, options); 135 | } 136 | } catch (e) { 137 | this.handleError(response, e, INTERNAL_SERVER_ERROR); 138 | } 139 | } 140 | 141 | } -------------------------------------------------------------------------------- /templates/theia-playground-extension/src/node/theia-playground-extension-backend-module.ts: -------------------------------------------------------------------------------- 1 | import { ContainerModule } from 'inversify'; 2 | import { DownloadLinkHandler, FileDownloadHandler } from '@theia/filesystem/lib/node/download/file-download-handler'; 3 | import { PlaygroundMultiFileDownloadHandler, PlaygroundSingleFileDownloadHandler } from './file-download-handler'; 4 | 5 | export default new ContainerModule((bind, unbind) => { 6 | unbind(FileDownloadHandler); 7 | bind(FileDownloadHandler).to(PlaygroundSingleFileDownloadHandler).inSingletonScope().whenTargetNamed(FileDownloadHandler.SINGLE); 8 | bind(FileDownloadHandler).to(PlaygroundMultiFileDownloadHandler).inSingletonScope().whenTargetNamed(FileDownloadHandler.MULTI); 9 | bind(FileDownloadHandler).to(DownloadLinkHandler).inSingletonScope().whenTargetNamed(FileDownloadHandler.DOWNLOAD_LINK); 10 | }); 11 | -------------------------------------------------------------------------------- /templates/theia-playground-extension/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "skipLibCheck": true, 4 | "declaration": true, 5 | "declarationMap": true, 6 | "noEmitOnError": false, 7 | "noImplicitThis": true, 8 | "noUnusedLocals": true, 9 | "strictNullChecks": true, 10 | "experimentalDecorators": true, 11 | "emitDecoratorMetadata": true, 12 | "downlevelIteration": true, 13 | "resolveJsonModule": true, 14 | "module": "commonjs", 15 | "moduleResolution": "node", 16 | "target": "es6", 17 | "jsx": "react", 18 | "lib": [ 19 | "es6", 20 | "dom" 21 | ], 22 | "sourceMap": true, 23 | "rootDir": "src", 24 | "outDir": "lib" 25 | }, 26 | "include": [ 27 | "src" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /templates/theia-playground/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "gitlens.advanced.telemetry.enabled": false, 3 | "gitlens.showWhatsNewAfterUpgrades": false, 4 | "gitlens.showWelcomeOnInstall": false, 5 | "editor.formatOnSave": true, 6 | "editor.insertSpaces": true, 7 | "editor.renderLineHighlight": "all", 8 | "typescript.updateImportsOnFileMove.enabled": "always", 9 | "[typescript]": { 10 | "editor.tabSize": 4 11 | }, 12 | "[json]": { 13 | "editor.tabSize": 4 14 | }, 15 | "files.exclude": { 16 | "node_modules": true, 17 | "**/*.js": { 18 | "when": "$(basename).ts" 19 | }, 20 | "**/*.js.map": { 21 | "when": "$(basename)" 22 | } 23 | }, 24 | "lldb.verboseLogging": true 25 | } -------------------------------------------------------------------------------- /templates/theia-playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@parity/theia-playground", 3 | "version": "1.1.0", 4 | "theia": { 5 | "target": "browser", 6 | "frontend": { 7 | "config": { 8 | "applicationName": "Substrate playground" 9 | } 10 | } 11 | }, 12 | "dependencies": { 13 | "@theia/callhierarchy": "1.31.1", 14 | "@theia/console": "1.31.1", 15 | "@theia/core": "1.31.1", 16 | "@theia/debug": "1.31.1", 17 | "@theia/editor": "1.31.1", 18 | "@theia/editor-preview": "1.31.1", 19 | "@theia/file-search": "1.31.1", 20 | "@theia/keymaps": "1.31.1", 21 | "@theia/markers": "1.31.1", 22 | "@theia/messages": "1.31.1", 23 | "@theia/metrics": "1.31.1", 24 | "@theia/mini-browser": "1.31.1", 25 | "@theia/monaco": "1.31.1", 26 | "@theia/navigator": "1.31.1", 27 | "@theia/outline-view": "1.31.1", 28 | "@theia/output": "1.31.1", 29 | "@theia/plugin": "1.31.1", 30 | "@theia/plugin-dev": "1.31.1", 31 | "@theia/plugin-ext": "1.31.1", 32 | "@theia/plugin-ext-vscode": "1.31.1", 33 | "@theia/preview": "1.31.1", 34 | "@theia/preferences": "1.31.1", 35 | "@theia/process": "1.31.1", 36 | "@theia/search-in-workspace": "1.31.1", 37 | "@theia/task": "1.31.1", 38 | "@theia/terminal": "1.31.1", 39 | "@theia/typehierarchy": "1.31.1", 40 | "@theia/userstorage": "1.31.1", 41 | "@theia/variable-resolver": "1.31.1", 42 | "@theia/workspace": "1.31.1", 43 | "@parity/theia-playground-extension": "0.5.1" 44 | }, 45 | "devDependencies": { 46 | "@theia/cli": "1.31.1", 47 | "@theia/debug": "1.31.1" 48 | }, 49 | "theiaPluginsDir": "plugins", 50 | "theiaPlugins": { 51 | "vscode-builtin-configuration-editing": "https://open-vsx.org/api/vscode/configuration-editing/1.54.1/file/vscode.configuration-editing-1.54.1.vsix", 52 | "vscode-builtin-css": "https://open-vsx.org/api/vscode/css/1.54.1/file/vscode.css-1.54.1.vsix", 53 | "vscode-builtin-debug-auto-launch": "https://open-vsx.org/api/vscode/configuration-editing/1.54.1/file/vscode.configuration-editing-1.54.1.vsix", 54 | "vscode-builtin-docker": "https://open-vsx.org/api/vscode/docker/1.54.1/file/vscode.docker-1.54.1.vsix", 55 | "vscode-builtin-html": "https://open-vsx.org/api/vscode/html/1.54.1/file/vscode.html-1.54.1.vsix", 56 | "vscode-builtin-javascript": "https://open-vsx.org/api/vscode/javascript/1.54.1/file/vscode.javascript-1.54.1.vsix", 57 | "vscode-builtin-json": "https://open-vsx.org/api/vscode/json/1.54.1/file/vscode.json-1.54.1.vsix", 58 | "vscode-builtin-log": "https://open-vsx.org/api/vscode/log/1.54.1/file/vscode.log-1.54.1.vsix", 59 | "vscode-builtin-make": "https://open-vsx.org/api/vscode/make/1.54.1/file/vscode.make-1.54.1.vsix", 60 | "vscode-builtin-markdown": "https://open-vsx.org/api/vscode/markdown/1.54.1/file/vscode.markdown-1.54.1.vsix", 61 | "vscode-builtin-markdown-language-features": "https://open-vsx.org/api/vscode/markdown-language-features/1.54.1/file/vscode.markdown-language-features-1.54.1.vsix", 62 | "vscode-builtin-merge-conflicts": "https://open-vsx.org/api/vscode/merge-conflict/1.54.1/file/vscode.merge-conflict-1.54.1.vsix", 63 | "vscode-builtin-npm": "https://open-vsx.org/api/vscode/npm/1.54.1/file/vscode.npm-1.54.1.vsix", 64 | "vscode-builtin-rust": "https://open-vsx.org/api/vscode/rust/1.54.1/file/vscode.rust-1.54.1.vsix", 65 | "vscode-builtin-shellscript": "https://open-vsx.org/api/vscode/shellscript/1.54.1/file/vscode.shellscript-1.54.1.vsix", 66 | "vscode-builtin-theme-monokai": "https://open-vsx.org/api/vscode/theme-monokai/1.54.1/file/vscode.theme-monokai-1.54.1.vsix", 67 | "vscode-builtin-typescript": "https://open-vsx.org/api/vscode/typescript/1.54.1/file/vscode.typescript-1.54.1.vsix", 68 | "vscode-builtin-typescript-language-features": "https://open-vsx.org/api/vscode/typescript-language-features/1.54.1/file/vscode.typescript-language-features-1.54.1.vsix" 69 | }, 70 | "scripts": { 71 | "prepare": "theia build --mode development", 72 | "start": "theia start --mode development", 73 | "watch": "theia build --watch --mode development", 74 | "clean": "rm -rf node_modules/ src-gen/ lib/ plugins/ gen-webpack.config.js" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /templates/theia-playground/webpack.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file can be edited to customize webpack configuration. 3 | * To reset delete this file and rerun theia build again. 4 | */ 5 | // @ts-check 6 | const config = require('./gen-webpack.config.js'); 7 | 8 | /** 9 | * Expose bundled modules on window.theia.moduleName namespace, e.g. 10 | * window['theia']['@theia/core/lib/common/uri']. 11 | * Such syntax can be used by external code, for instance, for testing. 12 | config.module.rules.push({ 13 | test: /\.js$/, 14 | loader: require.resolve('@theia/application-manager/lib/expose-loader') 15 | }); */ 16 | 17 | module.exports = config; --------------------------------------------------------------------------------