Feat/admin command (#2022)

* WIP: cli admin command

* configure admin command to load ledger admin view

an express service was implemented, which serves the default site from
the instances

test plan: run `sourcecred admin` and browse to the ledger admin
instance to ensure it loads

* implement GET and POST handlers for ledger.json

The GET localhost:6006/data/ledger.json request will return
the ledger.json file if it exists

The POST request will write the submitted json to disk at
./data/ledger.json

test plan: run `$ sourcecred admin` or `$ yarn admin` then
post some json to the path by doing something like
```
$ curl --header "Content-Type: application/json" \
  --request POST \
  --data '{"username":"derp","password":"slerp"}' \
http://localhost:6006/data/ledger.json
```
and ensure there are no errors.

then
```
$ curl http://localhost:6006/data/ledger.json
```
and ensure the submitted json is returned

* remove unused variables

* Add Ledger to the UI load result

Note: This will fail if there is no ledger file. An empty JSON array is
technically a valid ledger, so if you want to update the example
instance just use:

`echo [] > data/ledger.json`

Test plan: Not yet tested, integrate into frontend

* implement button to save ledger to disk

test-plan: put and empty array ("[]") in the instance's ledger.json
file, then start the admin service "yarn admin" or "sourcecred admin"
then generate some ledger data using the identity interface and ensure
it saves back to disk

* Load ledger from disk for real

* add text middleware to express libdefs

* move ledger disk saving from application/json to text/plain

ledger.json files are encoded in such a way that they are indented and
escaped for some readability from the CLI. using json content-typing
will discard that escaping. By treating the payload as raw text, that
encoding is preserved

test-plan: modify and save a canonically serialized ledger.json file
from the browser frontend and observe that the spacing is preserved in
the file saved to disk

* remove unused deps and improve server console prompt

test-plan: start the admin server and ensure the frontend can create and
modify identities, then save the ledger.json file to disk successfully

* comments and capitalization

test-plan: `yarn flow` should still pass after this change

* remove redundant static middleware

we don't need to configure a static service for a subdirectory of a
statically-served directory

test-plan: ensure ledger.json can still be fetched either via curl or
the web frontend when the admin service is running

* remove address from fetch post request

if we're fetching to/from the parent address that served the javascript
we're calling from, an address is unnecessary

test-plan: make sure updated ledger.json files still POST back to disk

* add instance check

attempt to load the instance configurtion. If the config cannot be
loaded, an error will be throw indicating that the sourcecred.json file
cannot be found

test-plan: run `sourcecred admin` outside of an instance directory and
ensure it fails while looking for sourcecred.json

run `sourcecred admin` in an instance directory and ensure the service
starts up

Co-authored-by: Dandelion Mané <decentralion@dandelion.io>
This commit is contained in:
Kevin Siegler 2020-07-23 11:00:21 -05:00 committed by GitHub
parent e1886ff792
commit b85f5330ee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 91 additions and 5 deletions

View File

@ -323,6 +323,14 @@ declare type express$UrlEncodedOptions = {
...
}
declare type express$TextOptions = {
defaultCharset?: string,
inflate?: boolean,
limit?: mixed,
type?: mixed,
verify?: Function,
}
declare module "express" {
declare export type RouterOptions = express$RouterOptions;
declare export type CookieOptions = express$CookieOptions;
@ -343,6 +351,7 @@ declare module "express" {
// If you try to call like a function, it will use this signature
<Req: express$Request, Res: express$Response>(): express$Application<Req, Res>,
json: (opts: ?JsonOptions) => express$Middleware<>,
text: (opts: ?express$TextOptions) => express$Middleware<>,
// `static` property on the function
static: <Req: express$Request, Res: express$Response>(root: string, options?: Object) => express$Middleware<Req, Res>,
// `Router` property on the function

47
src/cli/admin.js Normal file
View File

@ -0,0 +1,47 @@
// @flow
import type {Command} from "./command";
import {loadInstanceConfig} from "./common";
const fs = require("fs");
const express = require("express");
function die(std, message) {
std.err("fatal: " + message);
return 1;
}
const adminCommand: Command = async (args, std) => {
const basedir = process.cwd();
// check to ensure service is running within an instance directory
await loadInstanceConfig(basedir);
if (args.length !== 0) {
return die(std, "usage: sourcecred admin");
}
const server = express();
// serve the static admin site and all subdirectories
// also enables GETing data/ledger.json
server.use(express.static("."));
// middleware that parses text request bodies for us
server.use(express.text());
// write posted ledger.json files to disk
server.post("/data/ledger.json", (req, res) => {
try {
fs.writeFileSync("./data/ledger.json", req.body, "utf8");
} catch (e) {
res.status(500).send(`error saving ledger.json file: ${e}`);
}
res.status(201).end();
});
server.listen(6006, () => {
console.info("admin server running: navigate to http://localhost:6006");
});
return 0;
};
export default adminCommand;

View File

@ -8,6 +8,7 @@ import graph from "./graph";
import score from "./score";
import site from "./site";
import go from "./go";
import admin from "./admin";
import help from "./help";
const sourcecred: Command = async (args, std) => {
@ -32,6 +33,8 @@ const sourcecred: Command = async (args, std) => {
return site(args.slice(1), std);
case "go":
return go(args.slice(1), std);
case "admin":
return admin(args.slice(1), std);
default:
std.err("fatal: unknown command: " + JSON.stringify(args[0]));
std.err("fatal: run 'sourcecred help' for commands and usage");

View File

@ -34,7 +34,10 @@ const customRoutes = (loadResult: LoadSuccess) => [
<Redirect to="/explorer" />
</Route>,
<Route key="admin" exact path="/admin">
<LedgerAdmin credView={loadResult.credView} />
<LedgerAdmin
credView={loadResult.credView}
initialLedger={loadResult.ledger}
/>
</Route>,
];

View File

@ -8,10 +8,11 @@ import {AliasSelector} from "./AliasSelector";
export type Props = {|
+credView: CredView,
+initialLedger: Ledger,
|};
export const LedgerAdmin = ({credView}: Props) => {
const [ledger, setLedger] = useState<Ledger>(new Ledger());
export const LedgerAdmin = ({credView, initialLedger}: Props) => {
const [ledger, setLedger] = useState<Ledger>(initialLedger);
const [nextIdentityName, setIdentityName] = useState<string>("");
const [currentIdentity, setCurrentIdentity] = useState<Identity | null>(null);
const [promptString, setPromptString] = useState<string>("Add Identity:");
@ -91,6 +92,21 @@ export const LedgerAdmin = ({credView}: Props) => {
type="submit"
value={currentIdentity ? "update username" : "create identity"}
/>
<br />
<input
type="button"
value="save ledger to disk"
onClick={() => {
fetch("data/ledger.json", {
headers: {
Accept: "text/plain",
"Content-Type": "text/plain",
},
method: "POST",
body: ledger.serialize(),
});
}}
/>
{currentIdentity && (
<>
<br />

View File

@ -2,17 +2,23 @@
import * as pluginId from "../api/pluginId";
import {CredView} from "../analysis/credView";
import {fromJSON as credResultFromJSON} from "../analysis/credResult";
import {Ledger, parser as ledgerParser} from "../ledger/ledger";
export type LoadResult = LoadSuccess | LoadFailure;
export type LoadSuccess = {|
+type: "SUCCESS",
+credView: CredView,
+ledger: Ledger,
+bundledPlugins: $ReadOnlyArray<pluginId.PluginId>,
|};
export type LoadFailure = {|+type: "FAILURE", +error: any|};
export async function load(): Promise<LoadResult> {
const queries = [fetch("output/credResult.json"), fetch("/sourcecred.json")];
const queries = [
fetch("output/credResult.json"),
fetch("/sourcecred.json"),
fetch("data/ledger.json"),
];
const responses = await Promise.all(queries);
for (const response of responses) {
@ -26,7 +32,9 @@ export async function load(): Promise<LoadResult> {
const credResult = credResultFromJSON(json);
const credView = new CredView(credResult);
const {bundledPlugins} = await responses[1].json();
return {type: "SUCCESS", credView, bundledPlugins};
const ledgerJson = await responses[2].json();
const ledger = ledgerParser.parseOrThrow(ledgerJson);
return {type: "SUCCESS", credView, bundledPlugins, ledger};
} catch (e) {
console.error(e);
return {type: "FAILURE", error: e};