satellite/admin/ui: Migrate to SvelteKit

Migrate the satellite admin UI web app from the Svelte template used to
generate a Svelte App scaffolding to SvelteKit.

There aren't any functional changes in the  application, however, the
commit has a lot because:

1. SvelteKit uses a different directory layout and constraints to it, so
   the files have been moved.
2. The files have changed its formatting due to the new default linter
   configurations that SvelteKit uses.
3. The linter detected some issues with using `object` and `any` types
   in Typescript, so they have been replaced by better general types
   (e.g. Record).

The  migration allows to use the new tooling rather than Rollup
directly, besides that will empower the future of it when it needs more
features (e.g. different routes, etc.).

Change-Id: Ifa6736c13585708337f6c5a59388077b784eaddd
This commit is contained in:
Ivan Fraixedes 2021-12-01 17:17:30 +01:00 committed by Ivan Fraixedes
parent db0bd38d95
commit 5573ece848
39 changed files with 5542 additions and 3604 deletions

View File

@ -120,7 +120,7 @@ pipeline {
dir('satellite/admin/ui') { dir('satellite/admin/ui') {
sh 'npm ci --prefer-offline --no-audit' sh 'npm ci --prefer-offline --no-audit'
sh 'npm run build' sh 'npm run build'
sh 'rm -rf public/build' // Remove the build directory for avoiding linting those files. sh 'rm -rf build .svelte-kit' // Remove these directories for avoiding linting their files.
} }
} }
} }
@ -154,6 +154,11 @@ pipeline {
sh 'golangci-lint --config /go/ci/.golangci.yml -j=2 run' sh 'golangci-lint --config /go/ci/.golangci.yml -j=2 run'
sh 'check-mod-tidy -mod ../.build/testsuite.go.mod.orig' sh 'check-mod-tidy -mod ../.build/testsuite.go.mod.orig'
} }
dir("satellite/admin/ui") {
sh 'npm run check'
sh 'npm run lint'
}
} }
} }
stage('Cross Compile') { stage('Cross Compile') {
@ -352,7 +357,6 @@ pipeline {
stage('satellite/admin/ui') { stage('satellite/admin/ui') {
steps { steps {
dir("satellite/admin/ui") { dir("satellite/admin/ui") {
sh 'npm run validate'
sh script: 'npm audit', returnStatus: true sh script: 'npm audit', returnStatus: true
} }
} }

View File

@ -178,8 +178,8 @@ multinode-console:
.PHONY: satellite-admin-ui .PHONY: satellite-admin-ui
satellite-admin-ui: satellite-admin-ui:
# build web assets # remove the file that keep the assets directory for not breaking in development due to the `go:embed` directive
rm -rf satellite/admin/ui/public/build rm -rf satellite/admin/ui/assets/.gitignore
# install npm dependencies for being embedded by Go embed. # install npm dependencies for being embedded by Go embed.
docker run --rm -i \ docker run --rm -i \
--mount type=bind,src="${PWD}",dst=/go/src/storj.io/storj \ --mount type=bind,src="${PWD}",dst=/go/src/storj.io/storj \
@ -187,7 +187,7 @@ satellite-admin-ui:
-e HOME=/tmp \ -e HOME=/tmp \
-u $(shell id -u):$(shell id -g) \ -u $(shell id -u):$(shell id -g) \
node:16.11.1 \ node:16.11.1 \
/bin/bash -c "npm ci && npm run build" /bin/bash -c "npm ci && npm run build && cp build/* assets"
.PHONY: satellite-wasm .PHONY: satellite-wasm
satellite-wasm: satellite-wasm:

View File

@ -26,7 +26,7 @@ import (
"storj.io/storj/satellite/payments/stripecoinpayments" "storj.io/storj/satellite/payments/stripecoinpayments"
) )
//go:embed ui/public //go:embed ui/assets/*
var ui embed.FS var ui embed.FS
// Config defines configuration for debug server. // Config defines configuration for debug server.
@ -109,7 +109,7 @@ func NewServer(log *zap.Logger, listener net.Listener, db DB, buckets *buckets.S
// This handler must be the last one because it uses the root as prefix, // This handler must be the last one because it uses the root as prefix,
// otherwise will try to serve all the handlers set after this one. // otherwise will try to serve all the handlers set after this one.
if config.StaticDir == "" { if config.StaticDir == "" {
uiAssets, err := fs.Sub(ui, "ui/public") uiAssets, err := fs.Sub(ui, "ui/assets")
if err != nil { if err != nil {
log.Error("invalid embbeded static assets directory, the Admin UI is not enabled") log.Error("invalid embbeded static assets directory, the Admin UI is not enabled")
} else { } else {

View File

@ -32,36 +32,18 @@ func TestBasic(t *testing.T) {
baseURL := "http://" + address.String() baseURL := "http://" + address.String()
t.Run("UI", func(t *testing.T) { t.Run("UI", func(t *testing.T) {
t.Run("index.html", func(t *testing.T) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/.gitignore", nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil) require.NoError(t, err)
require.NoError(t, err)
response, err := http.DefaultClient.Do(req) response, err := http.DefaultClient.Do(req)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, http.StatusOK, response.StatusCode) require.Equal(t, http.StatusOK, response.StatusCode)
content, err := ioutil.ReadAll(response.Body) content, err := ioutil.ReadAll(response.Body)
require.NoError(t, response.Body.Close()) require.NoError(t, response.Body.Close())
require.NotEmpty(t, content) require.NotEmpty(t, content)
require.Contains(t, string(content), "</html>") require.NoError(t, err)
require.NoError(t, err)
})
t.Run("css", func(t *testing.T) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/global.css", nil)
require.NoError(t, err)
response, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, response.StatusCode)
content, err := ioutil.ReadAll(response.Body)
require.NoError(t, response.Body.Close())
require.NotEmpty(t, content)
require.NoError(t, err)
})
}) })
t.Run("NoAccess", func(t *testing.T) { t.Run("NoAccess", func(t *testing.T) {

View File

@ -0,0 +1,20 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
plugins: ['svelte3', '@typescript-eslint'],
ignorePatterns: ['*.cjs'],
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
settings: {
'svelte3/typescript': () => require('typescript')
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 2019
},
env: {
browser: true,
es2017: true,
node: true
}
};

View File

@ -1,4 +1,9 @@
/node_modules/
/public/build/
.DS_Store .DS_Store
node_modules
/build
/assets/*
!/assets/.keep
/.svelte-kit
/package
.env
.env.*

View File

@ -0,0 +1,6 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100
}

View File

@ -1,3 +0,0 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

View File

@ -2,16 +2,15 @@
## Implementation details ## Implementation details
This is a project based on the [Svelte](https://svelte.dev) [template for apps](https://github.com/sveltejs/template). This is a project based on the [Sveltekit](https://kit.svelte.dev).
The project templated was converted to Typescript following the instructions on its README. The project is set up with Typescript.
The Web App is currently straightforward as we specified that v1 would be. The Web App is currently straightforward as we specified that v1 would be.
The v1 is just a simple web page that exposes the Admin API through some forms and allow to a call the API without needing to use some HTTP REST clients (e.g. Postman, cURL, etc.). The v1 is just a simple web page that exposes the Admin API through some forms and allow to a call the API without needing to use some HTTP REST clients (e.g. Postman, cURL, etc.).
It doesn't offer any user authentication; the user has to know the API authorization token for using it. It doesn't offer any user authentication; the user has to know the API authorization token for using it.
The UI has a set of Svelte components that collaborate together to render an HTML form with input elements from the Admin API client. The UI has a set of Svelte components that collaborate together to render an HTML form with input elements from the Admin API client.
The Svelte components expect some values of a certain Typescript interfaces, types, and classes, for being able to dynamically render the HTML form and elements. The Svelte components expect some values of a certain Typescript interfaces, types, and classes, for being able to dynamically render the HTML form and elements.
@ -25,20 +24,17 @@ Install the dependencies...
npm install npm install
``` ```
...then start [Rollup](https://rollupjs.org): ...then run the development server with autoreload on changes
```bash ```bash
npm run dev npm run dev
``` ```
Navigate to [localhost:5000](http://localhost:5000). You should see your app running. Navigate to [localhost:3000](http://localhost:3000). You should see your app running.
By default, the server will only respond to requests from localhost. To allow connections from other computers, edit the `sirv` commands in package.json to include the option `--host 0.0.0.0`.
## Building for production mode ## Building for production mode
To create an optimised version of the app: To create an optimized version of the app:
```bash ```bash
npm run build npm run build

2
satellite/admin/ui/assets/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

File diff suppressed because it is too large Load Diff

View File

@ -1,33 +1,34 @@
{ {
"name": "svelte-app", "name": "kit",
"version": "1.0.0", "version": "0.0.1",
"private": true, "scripts": {
"scripts": { "dev": "svelte-kit dev",
"build": "rollup -c", "build": "svelte-kit build",
"dev": "rollup -c -w", "package": "svelte-kit package",
"start": "sirv public", "preview": "svelte-kit preview",
"validate": "svelte-check" "check": "svelte-check --tsconfig ./tsconfig.json",
}, "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"devDependencies": { "lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .",
"@rollup/plugin-commonjs": "^17.0.0", "format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ."
"@rollup/plugin-node-resolve": "^11.0.0", },
"@rollup/plugin-typescript": "^8.0.0", "devDependencies": {
"@tsconfig/svelte": "^1.0.0", "@sveltejs/adapter-static": "^1.0.0-next.21",
"prettier": "^2.2.1", "@sveltejs/kit": "next",
"prettier-plugin-svelte": "^2.2.0", "@typescript-eslint/eslint-plugin": "^4.31.1",
"rollup": "^2.3.4", "@typescript-eslint/parser": "^4.31.1",
"rollup-plugin-css-only": "^3.1.0", "eslint": "^7.32.0",
"rollup-plugin-livereload": "^2.0.0", "eslint-config-prettier": "^8.3.0",
"rollup-plugin-svelte": "^7.0.0", "eslint-plugin-svelte3": "^3.2.1",
"rollup-plugin-terser": "^7.0.0", "prettier": "^2.4.1",
"svelte": "^3.0.0", "prettier-plugin-svelte": "^2.4.0",
"svelte-check": "^1.0.0", "svelte": "^3.44.0",
"svelte-preprocess": "^4.0.0", "svelte-check": "^2.2.6",
"tslib": "^2.0.0", "svelte-preprocess": "^4.9.4",
"typescript": "^4.0.0" "tslib": "^2.3.1",
}, "typescript": "^4.4.3"
"dependencies": { },
"pretty-print-json": "^1.1.0", "type": "module",
"sirv-cli": "^1.0.0" "dependencies": {
} "pretty-print-json": "^1.1.1"
}
} }

View File

@ -1,67 +0,0 @@
html,
body {
position: relative;
width: 100%;
height: 100%;
}
body {
color: #333;
margin: 0;
padding: 8px;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
}
a {
color: rgb(0, 100, 200);
}
a:hover {
text-decoration: underline;
}
a:visited {
color: rgb(0, 80, 160);
}
label {
display: block;
}
input,
button,
select,
textarea {
font-family: inherit;
font-size: inherit;
-webkit-padding: 0.4em 0;
padding: 0.4em;
margin: 0 0 0.5em 0;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 2px;
}
input:disabled {
color: #ccc;
}
button {
color: #333;
background-color: #f4f4f4;
outline: none;
}
button:disabled {
color: #999;
}
button:not(:disabled):active {
background-color: #ddd;
}
button:focus {
border-color: #666;
}

View File

@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Storj Satellite Admin Console</title>
<link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="stylesheet" href="/global.css" />
<link rel="stylesheet" href="/pretty-print-json.css" />
<link rel="stylesheet" href="/build/bundle.css" />
<script defer src="/build/bundle.js"></script>
</head>
<body></body>
</html>

View File

@ -1,91 +0,0 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
import svelte from "rollup-plugin-svelte";
import commonjs from "@rollup/plugin-commonjs";
import resolve from "@rollup/plugin-node-resolve";
import livereload from "rollup-plugin-livereload";
import { terser } from "rollup-plugin-terser";
import sveltePreprocess from "svelte-preprocess";
import typescript from "@rollup/plugin-typescript";
import css from "rollup-plugin-css-only";
const production = !process.env.ROLLUP_WATCH;
function serve() {
let server;
function toExit() {
if (server) server.kill(0);
}
return {
writeBundle() {
if (server) return;
server = require("child_process").spawn(
"npm",
["run", "start", "--", "--dev"],
{
stdio: ["ignore", "inherit", "inherit"],
shell: true,
}
);
process.on("SIGTERM", toExit);
process.on("exit", toExit);
},
};
}
export default {
input: "src/main.ts",
output: {
sourcemap: true,
format: "iife",
name: "app",
file: "public/build/bundle.js",
},
preserveSymlinks: true,
plugins: [
svelte({
preprocess: sveltePreprocess({ sourceMap: !production }),
compilerOptions: {
// enable run-time checks when not in production
dev: !production,
},
}),
// we'll extract any component CSS out into
// a separate file - better for performance
css({ output: "bundle.css" }),
// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration -
// consult the documentation for details:
// https://github.com/rollup/plugins/tree/master/packages/commonjs
resolve({
browser: true,
dedupe: ["svelte"],
}),
commonjs(),
typescript({
sourceMap: !production,
inlineSources: !production,
}),
// In dev mode, call `npm run start` once
// the bundle has been generated
!production && serve(),
// Watch the `public` directory and refresh the
// browser on changes when not in production
!production && livereload("public"),
// If we're building for production (npm run build
// instead of npm run dev), minify
production && terser(),
],
watch: {
clearScreen: false,
},
};

View File

@ -1,87 +0,0 @@
<!--
Copyright (C) 2021 Storj Labs, Inc.
See LICENSE for copying information.
This component is the glue between the Admin API client and the "UIGenerator"
component.
It list all the operations that the API exposes and render the Web UI, through
the `UIGenerator.svelte` component, when the user selects the operation that
wants to perform.
-->
<script lang="ts">
import type { Operation } from "./ui-generator";
import { Admin } from "./api";
import UIGen from "./UIGenerator.svelte";
const baseURL = `${window.location.protocol}//${window.location.host}/api`;
let api: Admin;
let selectedGroupOp: Operation[];
let selectedOp: Operation;
let authToken: string;
function confirmAuthToken() {
if (authToken) {
api = new Admin(baseURL, authToken);
} else {
api = null;
}
}
</script>
<p>
In order to use the API you have to set the authentication token in the input
box and press enter or click the "confirm" button.
</p>
<p>
Token: <input
bind:value={authToken}
on:focus={() => {
api = null;
// This allows to select the empty item of the second select.
selectedOp = null;
}}
on:keyup={(e) => {
if (e.key.toLowerCase() === "enter") confirmAuthToken();
}}
type="password"
size="48"
/>
<button on:click={confirmAuthToken}>confirm</button>
</p>
{#if api}
<p>
Operation:
<select
bind:value={selectedGroupOp}
on:change={() => {
// This allows hiding the UIGen component when this select change until
// a new operations is selected in the following select element and also
// selecting the empty item of the select.
selectedOp = null;
}}
>
<option selected />
{#each Object.keys(api.operations) as group}
<option value={api.operations[group]}>{group}</option>
{/each}
</select>
{#if selectedGroupOp}
<select bind:value={selectedOp}>
<option selected />
{#each selectedGroupOp as op (op)}
<option value={op}>{op.name}</option>
{/each}
</select>
{/if}
</p>
<hr />
<p>
{#if selectedOp}
<UIGen operation={selectedOp} />
{/if}
</p>
{/if}

View File

@ -1,50 +0,0 @@
<!--
Copyright (C) 2021 Storj Labs, Inc.
See LICENSE for copying information.
The Svelte root component for this Web App.
-->
<script lang="ts">
import Api from "./Api.svelte";
</script>
<header>
<h1>Satellite Admin</h1>
</header>
<main>
<Api />
</main>
<footer>
<p>version: v1</p>
</footer>
<style>
header {
text-align: center;
padding: 1em;
max-width: 60ch;
margin: 0 auto;
}
footer {
position: fixed;
bottom: 0;
width: 97vw;
display: flex;
flex-direction: row;
justify-content: flex-end;
}
h1 {
color: #ff3e00;
text-transform: uppercase;
font-size: 4em;
font-weight: 100;
}
@media (min-width: 640px) {
header {
max-width: none;
}
}
</style>

View File

@ -1,107 +0,0 @@
<!--
Copyright (C) 2021 Storj Labs, Inc.
See LICENSE for copying information.
This component render the passed "operation" value as an HTML form and a set of
HTML elements to enter data and specify values.
See `ui-generator.ts` file for knowing about the `Operation` interface which is
the type of the "operation" value to be passed in.
-->
<script lang="ts">
import { prettyPrintJson as prettyJSON } from "pretty-print-json";
import type { Operation } from "./ui-generator";
import UIInputText from "./UIGeneratorInputText.svelte";
import UISelect from "./UIGeneratorSelect.svelte";
import UITextarea from "./UIGeneratorTextarea.svelte";
type opArg = boolean | number | string;
function execOperation(op: Operation, args: opArg[]) {
result = op.func(...args).then((data) => {
form.reset();
return data;
});
}
$: {
if (operation !== prevOperation) {
opArgs = new Array(operation.params.length);
result = undefined;
prevOperation = operation;
}
}
export let operation: Operation;
let prevOperation: Operation;
let opArgs: any[] = new Array(operation.params.length);
let result: Promise<object | null>;
let form: HTMLFormElement;
let componentsMap = {
InputText: UIInputText,
Select: UISelect,
Textarea: UITextarea,
};
</script>
<div>
<p>{operation.desc}</p>
<form
bind:this={form}
on:submit|preventDefault={() => execOperation(operation, opArgs)}
>
{#each operation.params as param, i}
<svelte:component
this={componentsMap[param[1].constructor.name]}
label={param[0]}
config={param[1]}
bind:value={opArgs[i]}
/>
{/each}
<input type="submit" value="submit" />
</form>
</div>
<output>
{#if result !== undefined}
{#await result}
<p>Sending...</p>
{:then data}
<p class="successful">
<b>Operation successful</b>
{#if data != null}
<br /><br />
HTTP Response body:
<pre>{@html prettyJSON.toHtml(data)}</pre>
{/if}
</p>
{:catch err}
<p class="failure">
<b>Operation failed</b>
<br /><br />
{err.name}: {err.message}
{#if err.responseStatusCode}
<br />
HTTP Response status code: {err.responseStatusCode}
{#if err.responseBody}
<br />
HTTP Response body:
<pre>{@html prettyJSON.toHtml(err.responseBody)}</pre>
{/if}
{/if}
</p>
{/await}
{/if}
</output>
<style>
.failure b {
color: red;
text-decoration: underline;
}
.successful b {
text-decoration: underline;
}
</style>

View File

@ -1,75 +0,0 @@
<!--
Copyright (C) 2021 Storj Labs, Inc.
See LICENSE for copying information.
Children Svelte component of the `UIGenerator.svelte` component which renders
an HTML input element.
-->
<script lang="ts">
import { onMount } from "svelte";
import type { InputText } from "./ui-generator";
export let label: string;
export let config: InputText;
export let value: boolean | number | string = undefined;
// For avoiding Svelte validate errors with Typescript types, we cannot map
// the `value` variable directly to HTML elements.
let boolValue: boolean = undefined;
let numValue: number = undefined;
let strValue: string = undefined;
// Map the initial value property when has some value to the HTML element.
onMount(() => {
if (value) {
switch (typeof value) {
case "boolean":
boolValue = value;
break;
case "number":
numValue = value;
break;
case "string":
strValue = value;
break;
}
}
});
$: {
if (boolValue !== undefined) {
value = boolValue;
}
if (numValue !== undefined) {
value = numValue;
}
if (strValue !== undefined) {
value = strValue;
}
}
</script>
<label>
{label}
{#if config.required}<sup>*</sup>{/if}:
{#if config.type === "checkbox"}
<input
type="checkbox"
bind:checked={boolValue}
required={config.required}
/>
{:else if config.type === "email"}
<input type="email" bind:value={strValue} required={config.required} />
{:else if config.type === "number"}
<input type="number" bind:value={numValue} required={config.required} />
{:else if config.type === "password"}
<input type="password" bind:value={strValue} required={config.required} />
{:else if config.type === "text"}
<input type="text" bind:value={strValue} required={config.required} />
{:else}
<p style="color: red;">BUG: not mapped input type: {config.type}</p>
{/if}
</label>

View File

@ -1,32 +0,0 @@
<!--
Copyright (C) 2021 Storj Labs, Inc.
See LICENSE for copying information.
Children Svelte component of the `UIGenerator.svelte` component which renders
an HTML select element
-->
<script lang="ts">
import type { Select } from "./ui-generator";
export let label: string;
export let config: Select;
export let value: boolean | number | string = undefined;
</script>
<label>
{label}
{#if config.required}<sup>*</sup>{/if}:
{#if config.multiple}
<select bind:value required={config.required} multiple>
{#each config.options as option}
<option value={option.value}>{option.text}</option>
{/each}
</select>
{:else}
<select bind:value required={config.required}>
{#each config.options as option}
<option value={option.value}>{option.text}</option>
{/each}
</select>
{/if}
</label>

View File

@ -1,28 +0,0 @@
<!--
Copyright (C) 2021 Storj Labs, Inc.
See LICENSE for copying information.
Children Svelte component of the `UIGenerator.svelte` component which renders
an HTML text area element.
-->
<script lang="ts">
import type { Textarea } from "./ui-generator";
export let label: string;
export let config: Textarea;
export let value: string = undefined;
</script>
<label>
<div>
{label}{#if config.required}<sup>*</sup>{/if}:
</div>
<textarea required={config.required} bind:value />
</label>
<style>
label {
display: grid;
row-gap: 1em;
}
</style>

View File

@ -1,391 +0,0 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
/*
* This is the Admin API client exposing the API operations using the
* interfaces, types and classes of the `ui-generator.ts` for allowing the
* `UIGenerator.svelte` component to dynamically render their Web UI interface.
*/
import type { Operation } from "./ui-generator";
import { InputText, Select } from "./ui-generator";
// API must be implemented by any class which expose the access to a specific
// API.
export interface API {
operations: {
[key: string]: Operation[];
};
}
export class Admin {
readonly operations = {
APIKeys: [
{
name: "delete key",
desc: "Delete an API key",
params: [["API key", new InputText("text", true)]],
func: async (apiKey: string): Promise<null> => {
return this.fetch("DELETE", `apikeys/${apiKey}`) as Promise<null>;
},
},
],
bucket: [
{
name: "get",
desc: "Get the information of the specified bucket",
params: [
["Project ID", new InputText("text", true)],
["Bucket name", new InputText("text", true)],
],
func: async (
projectId: string,
bucketName: string
): Promise<object> => {
return this.fetch(
"GET",
`projects/${projectId}/buckets/${bucketName}`
);
},
},
{
name: "delete geofencing",
desc: "Delete the geofencing configuration of the specified bucket. The bucket MUST be empty",
params: [
["Project ID", new InputText("text", true)],
["Bucket name", new InputText("text", true)],
],
func: async (
projectId: string,
bucketName: string
): Promise<object> => {
return this.fetch(
"DELETE",
`projects/${projectId}/buckets/${bucketName}/geofence`
);
},
},
{
name: "set geofencing",
desc: "Set the geofencing configuration of the specified bucket. The bucket MUST be empty",
params: [
["Project ID", new InputText("text", true)],
["Bucket name", new InputText("text", true)],
[
"Region",
new Select(false, true, [
{ text: "European Union", value: "EU" },
{ text: "European Economic Area", value: "EEA" },
{ text: "United States", value: "US" },
{ text: "Germany", value: "DE" },
]),
],
],
func: async (
projectId: string,
bucketName: string,
region: string
): Promise<object> => {
const query = this.urlQueryFromObject({ region });
if (query === "") {
throw new APIError("region cannot be empty");
}
return this.fetch(
"POST",
`projects/${projectId}/buckets/${bucketName}/geofence`,
query
);
},
},
],
project: [
{
name: "create",
desc: "Add a new project to a specific user",
params: [
["Owner ID (user ID)", new InputText("text", true)],
["Project Name", new InputText("text", true)],
],
func: async (ownerId: string, projectName: string): Promise<object> => {
return this.fetch("POST", "projects", null, { ownerId, projectName });
},
},
{
name: "delete",
desc: "Delete a specific project",
params: [["Project ID", new InputText("text", true)]],
func: async (projectId: string): Promise<null> => {
return this.fetch("DELETE", `projects/${projectId}`) as Promise<null>;
},
},
{
name: "get",
desc: "Get the information of a specific project",
params: [["Project ID", new InputText("text", true)]],
func: async (projectId: string): Promise<object> => {
return this.fetch("GET", `projects/${projectId}`);
},
},
{
name: "update",
desc: "Update the information of a specific project",
params: [
["Project ID", new InputText("text", true)],
["Project Name", new InputText("text", true)],
["Description", new InputText("text", false)],
],
func: async (
projectId: string,
projectName: string,
description: string
): Promise<null> => {
return this.fetch("PUT", `projects/${projectId}`, null, {
projectName,
description,
}) as Promise<null>;
},
},
{
name: "create API key",
desc: "Create a new API key for a specific project",
params: [
["Project ID", new InputText("text", true)],
["API key name", new InputText("text", true)],
],
func: async (projectId: string, name: string): Promise<object> => {
return this.fetch("POST", `projects/${projectId}/apikeys`, null, {
name,
});
},
},
{
name: "delete API key",
desc: "Delete a API key of a specific project",
params: [
["Project ID", new InputText("text", true)],
["API Key name", new InputText("text", true)],
],
func: async (projectId: string, apiKeyName: string): Promise<null> => {
return this.fetch(
"DELETE",
`projects/${projectId}/apikeys/${apiKeyName}`
) as Promise<null>;
},
},
{
name: "get API keys",
desc: "Get the API keys of a specific project",
params: [["Project ID", new InputText("text", true)]],
func: async (projectId: string): Promise<object> => {
return this.fetch("GET", `projects/${projectId}/apiKeys`);
},
},
{
name: "get project usage",
desc: "Get the current usage of a specific project",
params: [["Project ID", new InputText("text", true)]],
func: async (projectId: string): Promise<object> => {
return this.fetch("GET", `projects/${projectId}/usage`);
},
},
{
name: "get project limits",
desc: "Get the current limits of a specific project",
params: [["Project ID", new InputText("text", true)]],
func: async (projectId: string): Promise<object> => {
return this.fetch("GET", `projects/${projectId}/limit`);
},
},
{
name: "update project limits",
desc: "Update the limits of a specific project",
params: [
["Project ID", new InputText("text", true)],
["Storage (in bytes)", new InputText("number", false)],
["Bandwidth (in bytes)", new InputText("number", false)],
["Rate (requests per second)", new InputText("number", false)],
["Buckets (maximum number)", new InputText("number", false)],
],
func: async (
projectId: string,
usage: number,
bandwidth: number,
rate: number,
buckets: number
): Promise<null> => {
const query = this.urlQueryFromObject({
usage,
bandwidth,
rate,
buckets,
});
if (query === "") {
throw new APIError(
"nothing to update, at least one limit must be set"
);
}
return this.fetch(
"PUT",
`projects/${projectId}/limit`,
query
) as Promise<null>;
},
},
],
user: [
{
name: "create",
desc: "Create a new user account",
params: [
["email", new InputText("email", true)],
["full name", new InputText("text", false)],
["password", new InputText("password", true)],
],
func: async (
email: string,
fullName: string,
password: string
): Promise<object> => {
return this.fetch("POST", "users", null, {
email,
fullName,
password,
});
},
},
{
name: "delete",
desc: "Delete a user's account",
params: [["email", new InputText("email", true)]],
func: async (email: string): Promise<null> => {
return this.fetch("DELETE", `users/${email}`) as Promise<null>;
},
},
{
name: "get",
desc: "Get the information of a user's account",
params: [["email", new InputText("email", true)]],
func: async (email: string): Promise<object> => {
return this.fetch("GET", `users/${email}`);
},
},
{
name: "update",
desc: `Update the information of a user's account.
Blank fields will not be updated.`,
params: [
["current user's email", new InputText("email", true)],
["new email", new InputText("email", false)],
["full name", new InputText("text", false)],
["short name", new InputText("text", false)],
["partner ID", new InputText("text", false)],
["password hash", new InputText("text", false)],
],
func: async (
currentEmail: string,
email?: string,
fullName?: string,
shortName?: string,
partnerID?: string,
passwordHash?: string
): Promise<null> => {
return this.fetch("PUT", `users/${currentEmail}`, null, {
email,
fullName,
shortName,
partnerID,
passwordHash,
}) as Promise<null>;
},
},
],
};
private readonly baseURL: string;
constructor(baseURL: string, private readonly authToken: string) {
this.baseURL = baseURL.endsWith("/")
? baseURL.substring(0, baseURL.length - 1)
: baseURL;
}
protected async fetch(
method: "DELETE" | "GET" | "POST" | "PUT",
path: string,
query?: string,
data?: object
): Promise<object | null> {
const url = this.apiURL(path, query);
const headers = new window.Headers({
Authorization: this.authToken,
});
let body: string;
if (data) {
headers.set("Content-Type", "application/json");
body = JSON.stringify(data);
}
const resp = await window.fetch(url, { method, headers, body });
if (!resp.ok) {
let body: object;
if (resp.headers.get("Content-Type") === "application/json") {
body = await resp.json();
}
throw new APIError("server response error", resp.status, body);
}
if (resp.headers.get("Content-Type") === "application/json") {
return resp.json();
}
return null;
}
protected apiURL(path: string, query?: string): string {
path = path.startsWith("/") ? path.substring(1) : path;
if (!query) {
query = "";
} else {
query = "?" + query;
}
return `${this.baseURL}/${path}${query}`;
}
/**
* Transform an object to a URL query string.
* It discards any object field whose value is undefined.
*
* NOTE it doesn't recurs on values which are objects.
*/
protected urlQueryFromObject(values: object): string {
const queryParts = [];
for (const name of Object.keys(values)) {
const val = values[name];
if (val === undefined) {
continue;
}
queryParts.push(`${name}=${encodeURIComponent(val)}`);
}
return queryParts.join("&");
}
}
class APIError extends Error {
constructor(
public readonly msg: string,
public readonly responseStatusCode?: number,
public readonly responseBody?: object | string
) {
super(msg);
}
}

View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="stylesheet" href="/global.css" />
<link rel="stylesheet" href="/pretty-print-json.css" />
%svelte.head%
</head>
<body>
<div id="svelte">%svelte.body%</div>
</body>
</html>

View File

@ -0,0 +1,89 @@
<!--
Copyright (C) 2021 Storj Labs, Inc.
See LICENSE for copying information.
This component is the glue between the Admin API client and the "UIGenerator"
component.
It list all the operations that the API exposes and render the Web UI, through
the `UIGenerator.svelte` component, when the user selects the operation that
wants to perform.
-->
<script lang="ts">
import type { Operation } from '$lib/ui-generator';
import { Admin } from '$lib/api';
import UIGen from '$lib/UIGenerator.svelte';
const baseURL = `${window.location.protocol}//${window.location.host}/api`;
let api: Admin;
let selectedGroupOp: Operation[];
let selectedOp: Operation;
let authToken: string;
function confirmAuthToken() {
if (authToken) {
api = new Admin(baseURL, authToken);
} else {
api = null;
}
}
</script>
<p>
In order to use the API you have to set the authentication token in the input box and press enter
or click the "confirm" button.
</p>
<p>
Token: <input
bind:value={authToken}
on:focus={() => {
api = null;
// This allows to select the empty item of the second select.
selectedOp = null;
}}
on:keyup={(e) => {
if (e.key.toLowerCase() === 'enter') confirmAuthToken();
}}
type="password"
size="48"
/>
<button on:click={confirmAuthToken}>confirm</button>
</p>
{#if api}
<p>
Operation:
<select
bind:value={selectedGroupOp}
on:change={() => {
// This allows hiding the UIGen component when this select change until
// a new operations is selected in the following select element and also
// selecting the empty item of the select.
selectedOp = null;
}}
>
<option selected />
{#each Object.keys(api.operations) as group}
<option value={api.operations[group]}>{group}</option>
{/each}
</select>
{#if selectedGroupOp}
<select bind:value={selectedOp}>
<option selected />
{#each selectedGroupOp as op (op)}
<option value={op}>{op.name}</option>
{/each}
</select>
{/if}
</p>
<hr />
<p>
{#if selectedOp}
{#key selectedOp}
<UIGen operation={selectedOp} />
{/key}
{/if}
</p>
{/if}

View File

@ -0,0 +1,104 @@
<!--
Copyright (C) 2021 Storj Labs, Inc.
See LICENSE for copying information.
This component render the passed "operation" value as an HTML form and a set of
HTML elements to enter data and specify values.
See `ui-generator.ts` file for knowing about the `Operation` interface which is
the type of the "operation" value to be passed in.
-->
<script lang="ts">
import { prettyPrintJson as prettyJSON } from 'pretty-print-json';
import type { Operation } from '$lib/ui-generator';
import UIInputText from '$lib/UIGeneratorInputText.svelte';
import UISelect from '$lib/UIGeneratorSelect.svelte';
import UITextarea from '$lib/UIGeneratorTextarea.svelte';
type opArg = boolean | number | string;
function execOperation(op: Operation, args: opArg[]) {
result = op.func(...args).then((data) => {
form.reset();
return data;
});
}
$: {
if (operation !== prevOperation) {
opArgs = new Array(operation.params.length);
result = undefined;
prevOperation = operation;
}
}
export let operation: Operation;
let prevOperation: Operation;
let opArgs: opArg[] = new Array(operation.params.length);
let result: Promise<Record<string, unknown> | null>;
let form: HTMLFormElement;
let componentsMap = {
InputText: UIInputText,
Select: UISelect,
Textarea: UITextarea
};
</script>
<div>
<p>{operation.desc}</p>
<form bind:this={form} on:submit|preventDefault={() => execOperation(operation, opArgs)}>
{#each operation.params as param, i}
<svelte:component
this={componentsMap[param[1].constructor.name]}
label={param[0]}
config={param[1]}
bind:value={opArgs[i]}
/>
{/each}
<input type="submit" value="submit" />
</form>
</div>
<output>
{#if result !== undefined}
{#await result}
<p>Sending...</p>
{:then data}
<p class="successful">
<b>Operation successful</b>
{#if data != null}
<br /><br />
HTTP Response body:
<pre>{@html prettyJSON.toHtml(data)}</pre>
{/if}
</p>
{:catch err}
<p class="failure">
<b>Operation failed</b>
<br /><br />
{err.name}: {err.message}
{#if err.responseStatusCode}
<br />
HTTP Response status code: {err.responseStatusCode}
{#if err.responseBody}
<br />
HTTP Response body:
<pre>{@html prettyJSON.toHtml(err.responseBody)}</pre>
{/if}
{/if}
</p>
{/await}
{/if}
</output>
<style>
.failure b {
color: red;
text-decoration: underline;
}
.successful b {
text-decoration: underline;
}
</style>

View File

@ -0,0 +1,72 @@
<!--
Copyright (C) 2021 Storj Labs, Inc.
See LICENSE for copying information.
Children Svelte component of the `UIGenerator.svelte` component which renders
an HTML input element.
-->
<script lang="ts">
import { onMount } from 'svelte';
import type { InputText } from '$lib/ui-generator';
export let label: string;
export let config: InputText;
export let value: boolean | number | string = undefined;
// For avoiding Svelte validate errors with Typescript types, we cannot map
// the `value` variable directly to HTML elements.
let boolValue: boolean = undefined;
let numValue: number = undefined;
let strValue: string = undefined;
// Map the initial value property when has some value to the HTML element.
onMount(() => {
if (value) {
switch (typeof value) {
case 'boolean':
boolValue = value;
break;
case 'number':
numValue = value;
break;
case 'string':
strValue = value;
break;
}
}
});
$: {
if (boolValue !== undefined) {
value = boolValue;
}
if (numValue !== undefined) {
value = numValue;
}
if (strValue !== undefined) {
value = strValue;
}
}
</script>
<!-- the empty 'for' avoids Svelte check warnings -->
<label for="">
{label}
{#if config.required}<sup>*</sup>{/if}:
{#if config.type === 'checkbox'}
<input type="checkbox" bind:checked={boolValue} required={config.required} />
{:else if config.type === 'email'}
<input type="email" bind:value={strValue} required={config.required} />
{:else if config.type === 'number'}
<input type="number" bind:value={numValue} required={config.required} />
{:else if config.type === 'password'}
<input type="password" bind:value={strValue} required={config.required} />
{:else if config.type === 'text'}
<input type="text" bind:value={strValue} required={config.required} />
{:else}
<p style="color: red;">BUG: not mapped input type: {config.type}</p>
{/if}
</label>

View File

@ -0,0 +1,33 @@
<!--
Copyright (C) 2021 Storj Labs, Inc.
See LICENSE for copying information.
Children Svelte component of the `UIGenerator.svelte` component which renders
an HTML select element
-->
<script lang="ts">
import type { Select } from '$lib/ui-generator';
export let label: string;
export let config: Select;
export let value: boolean | number | string = undefined;
</script>
<!-- the empty 'for' avoids Svelte check warnings -->
<label for="">
{label}
{#if config.required}<sup>*</sup>{/if}:
{#if config.multiple}
<select bind:value required={config.required} multiple>
{#each config.options as option}
<option value={option.value}>{option.text}</option>
{/each}
</select>
{:else}
<select bind:value required={config.required}>
{#each config.options as option}
<option value={option.value}>{option.text}</option>
{/each}
</select>
{/if}
</label>

View File

@ -0,0 +1,28 @@
<!--
Copyright (C) 2021 Storj Labs, Inc.
See LICENSE for copying information.
Children Svelte component of the `UIGenerator.svelte` component which renders
an HTML text area element.
-->
<script lang="ts">
import type { Textarea } from '$lib/ui-generator';
export let label: string;
export let config: Textarea;
export let value: string = undefined;
</script>
<label>
<div>
{label}{#if config.required}<sup>*</sup>{/if}:
</div>
<textarea required={config.required} bind:value />
</label>
<style>
label {
display: grid;
row-gap: 1em;
}
</style>

View File

@ -0,0 +1,361 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
/*
* This is the Admin API client exposing the API operations using the
* interfaces, types and classes of the `ui-generator.ts` for allowing the
* `UIGenerator.svelte` component to dynamically render their Web UI interface.
*/
import type { Operation } from '$lib/ui-generator';
import { InputText, Select } from '$lib/ui-generator';
// API must be implemented by any class which expose the access to a specific
// API.
export interface API {
operations: {
[key: string]: Operation[];
};
}
export class Admin {
readonly operations = {
APIKeys: [
{
name: 'delete key',
desc: 'Delete an API key',
params: [['API key', new InputText('text', true)]],
func: async (apiKey: string): Promise<null> => {
return this.fetch('DELETE', `apikeys/${apiKey}`) as Promise<null>;
}
}
],
bucket: [
{
name: 'get',
desc: 'Get the information of the specified bucket',
params: [
['Project ID', new InputText('text', true)],
['Bucket name', new InputText('text', true)]
],
func: async (projectId: string, bucketName: string): Promise<Record<string, unknown>> => {
return this.fetch('GET', `projects/${projectId}/buckets/${bucketName}`);
}
},
{
name: 'delete geofencing',
desc: 'Delete the geofencing configuration of the specified bucket. The bucket MUST be empty',
params: [
['Project ID', new InputText('text', true)],
['Bucket name', new InputText('text', true)]
],
func: async (projectId: string, bucketName: string): Promise<Record<string, unknown>> => {
return this.fetch('DELETE', `projects/${projectId}/buckets/${bucketName}/geofence`);
}
},
{
name: 'set geofencing',
desc: 'Set the geofencing configuration of the specified bucket. The bucket MUST be empty',
params: [
['Project ID', new InputText('text', true)],
['Bucket name', new InputText('text', true)],
[
'Region',
new Select(false, true, [
{ text: 'European Union', value: 'EU' },
{ text: 'European Economic Area', value: 'EEA' },
{ text: 'United States', value: 'US' },
{ text: 'Germany', value: 'DE' }
])
]
],
func: async (
projectId: string,
bucketName: string,
region: string
): Promise<Record<string, unknown>> => {
const query = this.urlQueryFromObject({ region });
if (query === '') {
throw new APIError('region cannot be empty');
}
return this.fetch('POST', `projects/${projectId}/buckets/${bucketName}/geofence`, query);
}
}
],
project: [
{
name: 'create',
desc: 'Add a new project to a specific user',
params: [
['Owner ID (user ID)', new InputText('text', true)],
['Project Name', new InputText('text', true)]
],
func: async (ownerId: string, projectName: string): Promise<Record<string, unknown>> => {
return this.fetch('POST', 'projects', null, { ownerId, projectName });
}
},
{
name: 'delete',
desc: 'Delete a specific project',
params: [['Project ID', new InputText('text', true)]],
func: async (projectId: string): Promise<null> => {
return this.fetch('DELETE', `projects/${projectId}`) as Promise<null>;
}
},
{
name: 'get',
desc: 'Get the information of a specific project',
params: [['Project ID', new InputText('text', true)]],
func: async (projectId: string): Promise<Record<string, unknown>> => {
return this.fetch('GET', `projects/${projectId}`);
}
},
{
name: 'update',
desc: 'Update the information of a specific project',
params: [
['Project ID', new InputText('text', true)],
['Project Name', new InputText('text', true)],
['Description', new InputText('text', false)]
],
func: async (
projectId: string,
projectName: string,
description: string
): Promise<null> => {
return this.fetch('PUT', `projects/${projectId}`, null, {
projectName,
description
}) as Promise<null>;
}
},
{
name: 'create API key',
desc: 'Create a new API key for a specific project',
params: [
['Project ID', new InputText('text', true)],
['API key name', new InputText('text', true)]
],
func: async (projectId: string, name: string): Promise<Record<string, unknown>> => {
return this.fetch('POST', `projects/${projectId}/apikeys`, null, {
name
});
}
},
{
name: 'delete API key',
desc: 'Delete a API key of a specific project',
params: [
['Project ID', new InputText('text', true)],
['API Key name', new InputText('text', true)]
],
func: async (projectId: string, apiKeyName: string): Promise<null> => {
return this.fetch(
'DELETE',
`projects/${projectId}/apikeys/${apiKeyName}`
) as Promise<null>;
}
},
{
name: 'get API keys',
desc: 'Get the API keys of a specific project',
params: [['Project ID', new InputText('text', true)]],
func: async (projectId: string): Promise<Record<string, unknown>> => {
return this.fetch('GET', `projects/${projectId}/apiKeys`);
}
},
{
name: 'get project usage',
desc: 'Get the current usage of a specific project',
params: [['Project ID', new InputText('text', true)]],
func: async (projectId: string): Promise<Record<string, unknown>> => {
return this.fetch('GET', `projects/${projectId}/usage`);
}
},
{
name: 'get project limits',
desc: 'Get the current limits of a specific project',
params: [['Project ID', new InputText('text', true)]],
func: async (projectId: string): Promise<Record<string, unknown>> => {
return this.fetch('GET', `projects/${projectId}/limit`);
}
},
{
name: 'update project limits',
desc: 'Update the limits of a specific project',
params: [
['Project ID', new InputText('text', true)],
['Storage (in bytes)', new InputText('number', false)],
['Bandwidth (in bytes)', new InputText('number', false)],
['Rate (requests per second)', new InputText('number', false)],
['Buckets (maximum number)', new InputText('number', false)]
],
func: async (
projectId: string,
usage: number,
bandwidth: number,
rate: number,
buckets: number
): Promise<null> => {
const query = this.urlQueryFromObject({
usage,
bandwidth,
rate,
buckets
});
if (query === '') {
throw new APIError('nothing to update, at least one limit must be set');
}
return this.fetch('PUT', `projects/${projectId}/limit`, query) as Promise<null>;
}
}
],
user: [
{
name: 'create',
desc: 'Create a new user account',
params: [
['email', new InputText('email', true)],
['full name', new InputText('text', false)],
['password', new InputText('password', true)]
],
func: async (
email: string,
fullName: string,
password: string
): Promise<Record<string, unknown>> => {
return this.fetch('POST', 'users', null, {
email,
fullName,
password
});
}
},
{
name: 'delete',
desc: "Delete a user's account",
params: [['email', new InputText('email', true)]],
func: async (email: string): Promise<null> => {
return this.fetch('DELETE', `users/${email}`) as Promise<null>;
}
},
{
name: 'get',
desc: "Get the information of a user's account",
params: [['email', new InputText('email', true)]],
func: async (email: string): Promise<Record<string, unknown>> => {
return this.fetch('GET', `users/${email}`);
}
},
{
name: 'update',
desc: `Update the information of a user's account.
Blank fields will not be updated.`,
params: [
["current user's email", new InputText('email', true)],
['new email', new InputText('email', false)],
['full name', new InputText('text', false)],
['short name', new InputText('text', false)],
['partner ID', new InputText('text', false)],
['password hash', new InputText('text', false)]
],
func: async (
currentEmail: string,
email?: string,
fullName?: string,
shortName?: string,
partnerID?: string,
passwordHash?: string
): Promise<null> => {
return this.fetch('PUT', `users/${currentEmail}`, null, {
email,
fullName,
shortName,
partnerID,
passwordHash
}) as Promise<null>;
}
}
]
};
private readonly baseURL: string;
constructor(baseURL: string, private readonly authToken: string) {
this.baseURL = baseURL.endsWith('/') ? baseURL.substring(0, baseURL.length - 1) : baseURL;
}
protected async fetch(
method: 'DELETE' | 'GET' | 'POST' | 'PUT',
path: string,
query?: string,
data?: Record<string, unknown>
): Promise<Record<string, unknown> | null> {
const url = this.apiURL(path, query);
const headers = new window.Headers({
Authorization: this.authToken
});
let body: string;
if (data) {
headers.set('Content-Type', 'application/json');
body = JSON.stringify(data);
}
const resp = await window.fetch(url, { method, headers, body });
if (!resp.ok) {
let body: Record<string, unknown>;
if (resp.headers.get('Content-Type') === 'application/json') {
body = await resp.json();
}
throw new APIError('server response error', resp.status, body);
}
if (resp.headers.get('Content-Type') === 'application/json') {
return resp.json();
}
return null;
}
protected apiURL(path: string, query?: string): string {
path = path.startsWith('/') ? path.substring(1) : path;
if (!query) {
query = '';
} else {
query = '?' + query;
}
return `${this.baseURL}/${path}${query}`;
}
protected urlQueryFromObject(values: Record<string, boolean | number | string>): string {
const queryParts = [];
for (const name of Object.keys(values)) {
const val = values[name];
if (val === undefined) {
continue;
}
queryParts.push(`${name}=${encodeURIComponent(val)}`);
}
return queryParts.join('&');
}
}
class APIError extends Error {
constructor(
public readonly msg: string,
public readonly responseStatusCode?: number,
public readonly responseBody?: Record<string, unknown> | string
) {
super(msg);
}
}

View File

@ -0,0 +1,54 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
/*
* A set of interfaces, classes and types that allow the `UIGenerator.svelte`
* component to generate an HTML form with a set of HTML elements to enter data
* and specify values.
*
* A REST API can use these types to dynamically generate a Web UI for it.
*/
export interface Operation {
// name is the operation name.
name: string;
// desc is the description of the operation.
desc: string;
// params is an array of tuples where each tuple corresponds to one parameter
// of 'func'. Each tuple has 2 elements, the first is the parameters name and
// the second how it's mapped to the UI. The orders must match the order of
// the 'func' parameters.
// The parameter's name is what is going to be show next to the input field,
// so it has to be descriptive for the users to know that they have to set.
params: [string, ParamUI][];
// func is the API function call. They always have to return a promise which
// resolves with an object or null.
// On a resolved promise, an object is the response body of an API call and
// null is used when the API operation doesn't return any payload (e.g. PUT
// operations).
func: (...p: unknown[]) => Promise<Record<string, unknown> | null>;
}
type ParamUI = InputText | Select | Textarea;
export class InputText {
constructor(
public readonly type: 'checkbox' | 'email' | 'number' | 'password' | 'text',
public readonly required: boolean
) {}
}
export class Select {
constructor(
public readonly multiple: boolean,
public readonly required: boolean,
public readonly options: {
text: string;
value: boolean | number | string;
}[]
) {}
}
export class Textarea {
constructor(public readonly required: boolean) {}
}

View File

@ -1,14 +0,0 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
/*
* Svelte App entry point.
*/
import App from "./App.svelte";
const app = new App({
target: document.body,
});
export default app;

View File

@ -0,0 +1,54 @@
<!--
Copyright (C) 2021 Storj Labs, Inc.
See LICENSE for copying information.
The Svelte root component for this Web App.
-->
<script lang="ts">
import Api from '$lib/Api.svelte';
</script>
<svelte:head>
<title>Storj Satellite Admin Console</title>
</svelte:head>
<header>
<h1>Satellite Admin</h1>
</header>
<main>
<Api />
</main>
<footer>
<p>version: v1</p>
</footer>
<style>
header {
text-align: center;
padding: 1em;
max-width: 60ch;
margin: 0 auto;
}
footer {
position: fixed;
bottom: 0;
width: 97vw;
display: flex;
flex-direction: row;
justify-content: flex-end;
}
h1 {
color: #ff3e00;
text-transform: uppercase;
font-size: 4em;
font-weight: 100;
}
@media (min-width: 640px) {
header {
max-width: none;
}
}
</style>

View File

@ -1,54 +0,0 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
/*
* A set of interfaces, classes and types that allow the `UIGenerator.svelte`
* component to generate an HTML form with a set of HTML elements to enter data
* and specify values.
*
* A REST API can use these types to dynamically generate a Web UI for it.
*/
export interface Operation {
// name is the operation name.
name: string;
// desc is the description of the operation.
desc: string;
// params is an array of tuples where each tuple corresponds to one parameter
// of 'func'. Each tuple has 2 elements, the first is the parameters name and
// the second how it's mapped to the UI. The orders must match the order of
// the 'func' parameters.
// The parameter's name is what is going to be show next to the input field,
// so it has to be descriptive for the users to know that they have to set.
params: [string, ParamUI][];
// func is the API function call. They always have to return a promise which
// resolves with an object or null.
// On a resolved promise, an object is the response body of an API call and
// null is used when the API operation doesn't return any payload (e.g. PUT
// operations).
func: (...p: any) => Promise<object | null>;
}
type ParamUI = InputText | Select | Textarea;
export class InputText {
constructor(
public readonly type: "checkbox" | "email" | "number" | "password" | "text",
public readonly required: boolean
) {}
}
export class Select {
constructor(
public readonly multiple: boolean,
public readonly required: boolean,
public readonly options: {
text: string;
value: boolean | number | string;
}[]
) {}
}
export class Textarea {
constructor(public readonly required: boolean) {}
}

View File

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@ -0,0 +1,67 @@
html,
body {
position: relative;
width: 100%;
height: 100%;
}
body {
color: #333;
margin: 0;
padding: 8px;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell,
'Helvetica Neue', sans-serif;
}
a {
color: rgb(0, 100, 200);
}
a:hover {
text-decoration: underline;
}
a:visited {
color: rgb(0, 80, 160);
}
label {
display: block;
}
input,
button,
select,
textarea {
font-family: inherit;
font-size: inherit;
-webkit-padding: 0.4em 0;
padding: 0.4em;
margin: 0 0 0.5em 0;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 2px;
}
input:disabled {
color: #ccc;
}
button {
color: #333;
background-color: #f4f4f4;
outline: none;
}
button:disabled {
color: #999;
}
button:not(:disabled):active {
background-color: #ddd;
}
button:focus {
border-color: #666;
}

View File

@ -1,62 +1,62 @@
/*! pretty-print-json v1.0.3 ~ github.com/center-key/pretty-print-json ~ MIT License */ /*! pretty-print-json v1.0.3 ~ github.com/center-key/pretty-print-json ~ MIT License */
.json-key { .json-key {
color: brown; color: brown;
} }
.json-string { .json-string {
color: olive; color: olive;
} }
.json-number { .json-number {
color: navy; color: navy;
} }
.json-boolean { .json-boolean {
color: teal; color: teal;
} }
.json-null { .json-null {
color: dimgray; color: dimgray;
} }
.json-mark { .json-mark {
color: black; color: black;
} }
a.json-link { a.json-link {
color: purple; color: purple;
transition: all 400ms; transition: all 400ms;
} }
a.json-link:visited { a.json-link:visited {
color: slategray; color: slategray;
} }
a.json-link:hover { a.json-link:hover {
color: blueviolet; color: blueviolet;
} }
a.json-link:active { a.json-link:active {
color: slategray; color: slategray;
} }
.dark-mode .json-key { .dark-mode .json-key {
color: indianred; color: indianred;
} }
.dark-mode .json-string { .dark-mode .json-string {
color: darkkhaki; color: darkkhaki;
} }
.dark-mode .json-number { .dark-mode .json-number {
color: deepskyblue; color: deepskyblue;
} }
.dark-mode .json-boolean { .dark-mode .json-boolean {
color: mediumseagreen; color: mediumseagreen;
} }
.dark-mode .json-null { .dark-mode .json-null {
color: darkorange; color: darkorange;
} }
.dark-mode .json-mark { .dark-mode .json-mark {
color: silver; color: silver;
} }
.dark-mode a.json-link { .dark-mode a.json-link {
color: mediumorchid; color: mediumorchid;
} }
.dark-mode a.json-link:visited { .dark-mode a.json-link:visited {
color: slategray; color: slategray;
} }
.dark-mode a.json-link:hover { .dark-mode a.json-link:hover {
color: violet; color: violet;
} }
.dark-mode a.json-link:active { .dark-mode a.json-link:active {
color: silver; color: silver;
} }

View File

@ -0,0 +1,27 @@
// Copyright (C) 2021 Storj Labs, Inc.
import adapter from '@sveltejs/adapter-static';
import preprocess from 'svelte-preprocess';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://github.com/sveltejs/svelte-preprocess
// for more information about preprocessors
preprocess: preprocess(),
kit: {
adapter: adapter(),
// hydrate the <div id="svelte"> element in src/app.html
target: '#svelte',
ssr: false,
vite: {
resolve: {
preserveSymlinks: true
}
}
}
};
export default config;

View File

@ -1,6 +1,31 @@
{ {
"extends": "@tsconfig/svelte/tsconfig.json", "compilerOptions": {
"moduleResolution": "node",
"include": ["src/**/*"], "module": "es2020",
"exclude": ["node_modules/*", "__sapper__/*", "public/*"] "lib": ["es2020", "DOM"],
"target": "es2020",
/**
svelte-preprocess cannot figure out whether you have a value or a type, so tell TypeScript
to enforce using \`import type\` instead of \`import\` for Types.
*/
"importsNotUsedAsValues": "error",
"isolatedModules": true,
"resolveJsonModule": true,
/**
To have warnings/errors of the Svelte compiler at the correct position,
enable source maps by default.
*/
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"allowJs": true,
"checkJs": true,
"paths": {
"$lib": ["src/lib"],
"$lib/*": ["src/lib/*"]
}
},
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.ts", "src/**/*.svelte"]
} }