Joystream Stats 2 лет назад
Родитель
Сommit
374a2a7db9

+ 118 - 319
src/App.tsx

@@ -1,34 +1,25 @@
 import React from "react";
+import moment from "moment";
 import "bootstrap/dist/css/bootstrap.min.css";
 import "./index.css";
-import { Modals, Routes, Loading, Footer, Status } from "./components";
-import * as get from "./lib/getters";
-import { getTokenomics, queryJstats } from "./lib/queries";
-import { getCouncilApplicants, getCouncilSize, getVotes } from "./lib/election";
 import {
-  getStashes,
-  getNominators,
-  getValidators,
-  getValidatorStakes,
-  getEraRewardPoints,
-  getLastReward,
-  getTotalStake,
-} from "./lib/validators";
-import { getBlockHash, getEvents, getTimestamp } from "./jslib";
-import { apiLocation, wsLocation, historyDepth } from "./config";
+  Log,
+  NavBar,
+  Modals,
+  Routes,
+  Loading,
+  Status,
+  Notes,
+} from "./components";
 import { initialState } from "./state";
-import axios from "axios";
-
-// types
-import { Api, IState } from "./types";
-import { types } from "@joystream/types";
-import { ApiPromise, WsProvider } from "@polkadot/api";
-import { Header } from "@polkadot/types/interfaces";
+import { BrowserRouter } from "react-router-dom";
+import { IState } from "./types";
+//import api from '.api/'
+import { postQN } from "./lib/util";
 
 interface IProps {}
 
 class App extends React.Component<IProps, IState> {
-  private api = this.connectApi(); // joystream API endpoint
   //private socket = this.initializeSocket(); // jsstats socket.io
 
   initializeSocket() {
@@ -44,165 +35,17 @@ class App extends React.Component<IProps, IState> {
     });
   }
 
-  // sync via joystream-api
-
-  async updateStatus(api: ApiPromise, id: number): Promise<Status> {
-    console.debug(`#${id}: Updating status`);
-    //this.updateActiveProposals();
-    //getTokenomics().then((tokenomics) => this.save(`tokenomics`, tokenomics));
-
-    let { status, councils } = this.state;
-    //status.election = await updateElection(api);
-    //if (status.election?.stage) this.getElectionStatus(api);
-    councils.forEach((c) => {
-      if (c?.round > status.council) status.council = c;
-    });
-
-    let hash: string = await getBlockHash(api, 1);
-    if (hash) status.startTime = await getTimestamp(api, hash);
-
-    const nextMemberId = await await api.query.members.nextMemberId();
-    status.members = nextMemberId - 1;
-    this.fetchMembers(api, status.members);
-    status.proposals = await get.proposalCount(api);
-    status.posts = await get.currentPostId(api);
-    status.threads = await get.currentThreadId(api);
-    status.categories = await get.currentCategoryId(api);
-    status.proposalPosts = await api.query.proposalsDiscussion.postCount();
-    await this.updateEra(api, status.era).then(async (era) => {
-      status.era = era;
-      status.lastReward = await getLastReward(api, era);
-      status.validatorStake = await getTotalStake(api, era);
-      this.save("status", status);
-    });
-    return status;
-  }
-
-  fetchMembers(api: ApiPromise, max: number) {
-    // fallback for failing cache
-    let missing = [];
-    for (let id = max; id > 0; --id) {
-      if (!this.state.members.find((m) => m.id === id)) missing.push(id);
-    }
-    if (missing.length < 100)
-      missing.forEach((id) => this.fetchMember(api, id));
-    else
-      api.query.members.membershipById.entries().then((map) => {
-        let members = [];
-        for (const [storageKey, member] of map) {
-          members.push({ ...member.toJSON(), id: storageKey.args[1] });
-        }
-        this.save("members", members);
-        console.debug(`got members`, members);
-      });
-  }
-
-  fetchMember(api: ApiPromise, id: number) {
-    get.membership(api, id).then((member) => {
-      console.debug(`got member ${id} ${member.handle}`);
-      const members = this.state.members.filter((m) => m.id !== id);
-      this.save("members", members.concat({ ...member, id }));
-    });
-  }
-
-  async getElectionStatus(api: ApiPromise): Promise<IElectionState> {
-    getCouncilSize(api).then((councilSize) => {
-      let election = this.state.election;
-      election.councilSize = councilSize;
-      this.save("election", election);
-    });
-    getVotes(api).then((votes) => {
-      let election = this.state.election;
-      election.votes = votes;
-      this.save("election", election);
-    });
-    getCouncilApplicants(api).then((applicants) => {
-      let election = this.state.election;
-      election.applicants = applicants;
-      this.save("election", election);
-    });
-  }
-
-  updateActiveProposals() {
-    const active = this.state.proposals.filter((p) => p.result === "Pending");
-    if (!active.length) return;
-    const s = active.length > 1 ? `s` : ``;
-    console.log(`Updating ${active.length} active proposal${s}`);
-    active.forEach(async (a) => {
-      const { data } = await axios.get(`${apiLocation}/v2/proposals/${a.id}`);
-      if (!data || data.error)
-        return console.error(`failed to fetch proposal from API`);
-      this.save(
-        "proposals",
-        this.state.proposals.map((p) => (p.id === a.id ? data : p))
-      );
-    });
-  }
-
-  async updateEra(api: Api, old: number) {
-    const { status, validators } = this.state;
-    const era = Number(await api.query.staking.currentEra());
-    if (era === old) return era;
-    this.updateValidatorPoints(api, status.era);
-    if (era > status.era || !validators.length) this.updateValidators(api);
-    return era;
-  }
-
-  updateValidators(api: ApiPromise) {
-    getValidators(api).then((validators) => {
-      this.save("validators", validators);
-      getNominators(api).then((nominators) => {
-        this.save("nominators", nominators);
-        getStashes(api).then((stashes) => {
-          this.save("stashes", stashes);
-          const { status, members } = this.state;
-          const { era } = status;
-          getValidatorStakes(api, era, stashes, members, this.save).then(
-            (stakes) => this.save("stakes", stakes)
-          );
-        });
-      });
-    });
-  }
-
-  async updateValidatorPoints(api: ApiPromise, currentEra: number) {
-    let points = this.state.rewardPoints;
-
-    const updateTotal = (eraTotals) => {
-      let total = 0;
-      Object.keys(eraTotals).forEach((era) => (total += eraTotals[era]));
-      return total;
-    };
-
-    for (let era = currentEra; era > currentEra - historyDepth; --era) {
-      if (era < currentEra && points.eraTotals[era]) continue;
-      getEraRewardPoints(api, era).then((eraPoints) => {
-        console.debug(`era ${era}: ${eraPoints.total} points`);
-        points.eraTotals[era] = eraPoints.total;
-        points.total = updateTotal(points.eraTotals);
-        Object.keys(eraPoints.individual).forEach((validator: string) => {
-          if (!points.validators[validator]) points.validators[validator] = {};
-          points.validators[validator][era] = eraPoints.individual[validator];
-        });
-        this.save("rewardPoints", points);
-      });
-    }
-  }
-
-  async updateCouncils() {
-    queryJstats(`v1/councils`).then((councils) => {
-      if (!councils) return;
-      this.save(`councils`, councils);
-
-      // TODO OPTIMIZE find max round
-      let council = { round: 0 };
-      councils.forEach((c) => {
-        if (c.round > council.round) council = c;
-      });
-      let { status } = this.state;
-      status.council = council; // needed by dashboard
-      this.save("status", status);
+  log(level, msg, data) {
+    if (!msg) return console.error(`log called with empty msg`);
+    const now = moment().valueOf();
+    const time = this.state.settings.logTimestamps ? now : "";
+    this.setState({
+      log: this.state.log.concat({ time: now, msg, data, level }),
     });
+    if (level === "e") return console.error(time, msg, data ? data : null);
+    if (level === "w") return console.warn(time, msg, data ? data : null);
+    if (level === "i") return console.log(time, msg, data ? data : null);
+    console.debug(time, msg, data ? data : null);
   }
 
   // interface interactions
@@ -222,44 +65,53 @@ class App extends React.Component<IProps, IState> {
   toggleFooter() {
     this.setState({ hideFooter: !this.state.hideFooter });
   }
+  toggleShowNotes() {
+    let { settings } = this.state;
+    settings.showNotes = !settings.showNotes;
+    this.save("settings", settings);
+  }
   selectVideo(video) {
     this.setState({ video });
   }
 
-  getMember(input: string) {
-    const { members } = this.state;
-    let member;
-    // search by handle
-    member = members.find((m) => m.handle === input);
-    if (member) return member;
-
-    // search by key
-    member = members.find((m) => m.rootKey === input);
-    if (member) return member;
-
-    // TODO fetch live
-    //member = await get.membership(this.api, input)
-    //member = await get.memberIdByAccount(this.api, input)
-    return {};
-  }
-
   render() {
-    const { connected, fetching, loading, hideFooter } = this.state;
+    const { log, settings, connected, fetching, loading } = this.state;
     if (loading) return <Loading />;
 
     return (
-      <>
-        <Routes
-          selectEvent={this.selectEvent}
-          selectVideo={this.selectVideo}
-          toggleEditKpi={this.toggleEditKpi}
-          toggleFooter={this.toggleFooter}
-          toggleStar={this.toggleStar}
-          getMember={this.getMember}
-          save={this.save}
-          hidden={this.state.hidden}
-          {...this.state}
-        />
+      <BrowserRouter>
+        <NavBar toggleShowNotes={this.toggleShowNotes} />
+
+        <div
+          className="d-flex flex-row overflow-hidden w-100 h-100"
+          style={{ maxHeight: "95vh" }}
+        >
+          <Notes
+            addNote={this.addNote}
+            save={this.save}
+            notes={this.state.notes}
+            note={this.state.note}
+            settings={settings}
+            log={this.log}
+          />
+          <div className="d-flex flex-column-reverse">
+            <Log log={log} settings={settings} save={this.save} />
+            <div className="overflow-auto">
+              <Routes
+                selectEvent={this.selectEvent}
+                selectVideo={this.selectVideo}
+                toggleEditKpi={this.toggleEditKpi}
+                toggleFooter={this.toggleFooter}
+                toggleStar={this.toggleStar}
+                fetchQuery={this.fetchQuery}
+                saveQuery={this.saveQuery}
+                save={this.save}
+                hidden={this.state.hidden}
+                {...this.state}
+              />
+            </div>
+          </div>
+        </div>
 
         <Modals
           selectEvent={this.selectEvent}
@@ -269,14 +121,12 @@ class App extends React.Component<IProps, IState> {
           {...this.state}
         />
 
-        <Footer show={!hideFooter} toggleHide={this.toggleFooter} />
-
         <Status
           toggleShowStatus={this.toggleShowStatus}
           connected={connected}
           fetching={fetching}
         />
-      </>
+      </BrowserRouter>
     );
   }
 
@@ -285,95 +135,6 @@ class App extends React.Component<IProps, IState> {
     this.setState({ selectedEvent });
   }
 
-  async handleBlock(api: ApiPromise, header: Header) {
-    let { status } = this.state;
-    const id = header.number.toNumber();
-
-    //const isEven = id / 50 === Math.floor(id / 50);
-    //if (isEven || status.block?.id + 50 < id) this.updateStatus(api, id);
-    if (this.state.blocks.find((b) => b.id === id)) return;
-
-    const timestamp = (await api.query.timestamp.now()).toNumber();
-    //const duration = status.block ? timestamp - status.block.timestamp : 6000;
-    const hash = await getBlockHash(api, id);
-    const events = (await getEvents(api, hash)).map((e) => {
-      const { section, method, data } = e.event;
-      return { blockId: id, section, method, data: data.toHuman() };
-    });
-    status.block = { id, timestamp, events };
-    console.info(`new finalized head`, status.block);
-    this.save("status", status);
-    this.save("blocks", this.state.blocks.concat(status.block));
-  }
-
-  connectApi() {
-    console.debug(`Connecting to ${wsLocation}`);
-    const provider = new WsProvider(wsLocation);
-    return ApiPromise.create({ provider, types }).then(async (api) => {
-      await api.isReady;
-
-      const [chain, nodeName, nodeVersion, runtimeVersion] = await Promise.all([
-        api.rpc.system.chain(),
-        api.rpc.system.name(),
-        api.rpc.system.version(),
-        api.runtimeVersion,
-      ]);
-      console.log(
-        `Connected to ${wsLocation}: ${chain} spec:${runtimeVersion.specVersion} (${nodeName} v${nodeVersion})`
-      );
-      this.setState({ connected: true });
-      api.rpc.chain.subscribeFinalizedHeads((header: Header) =>
-        this.handleBlock(api, header)
-      );
-      this.syncBlocks(api);
-    });
-  }
-
-  async syncBlocks(api: ApiPromise) {
-    const head = this.state.blocks.reduce(
-      (max, b) => (b.id > max ? b.id : max),
-      0
-    );
-    console.log(`Syncing block events from ${head}`);
-    let missing = [];
-    for (let id = head; id > 0; --id) {
-      if (!this.state.blocks.find((block) => block.id === id)) missing.push(id);
-    }
-    if (!this.state.syncEvents) return;
-    const maxWorkers = 5;
-    let slots = [];
-    for (let s = 0; s < maxWorkers; ++s) {
-      slots[s] = s;
-    }
-    slots.map(async (slot) => {
-      while (this.state.syncEventsl && missing.length) {
-        const id = slot < maxWorkers / 2 ? missing.pop() : missing.shift();
-        await this.syncBlock(api, id, slot);
-      }
-      console.debug(`Slot ${slot} idle.`);
-      return true;
-    });
-  }
-
-  async syncBlock(api: ApiPromise, id: number, slot: number) {
-    try {
-      const hash = await getBlockHash(api, id);
-      const events = (await getEvents(api, hash))
-        .map((e) => {
-          const { section, method, data } = e.event;
-          return { blockId: id, section, method, data: data.toHuman() };
-        })
-        .filter((e) => e.method !== "ExtrinsicSuccess");
-      const timestamp = (await api.query.timestamp.now.at(hash)).toNumber();
-      //const duration = 6000 // TODO update later
-      const block = { id, timestamp, events };
-      console.debug(`worker ${slot}: synced block`, block);
-      this.save("blocks", this.state.blocks.concat(block));
-    } catch (e) {
-      console.error(`Failed to get block ${id}: ${e.message}`);
-    }
-  }
-
   handleKey(e: any) {
     console.debug(`pressed`, e.key);
     if (e.key === "-") {
@@ -384,6 +145,39 @@ class App extends React.Component<IProps, IState> {
     }
   }
 
+  // data handling
+
+  updateQueries() {
+    for (const [name, hours, query, active] of this.state.queries) {
+      if (!active) continue;
+      const data = this.state[name];
+      const isEmpty = !data || !Object.keys(data).length;
+      const hoursPassed = moment().diff(data?.timestamp, "hours");
+      const needsUpdate = isEmpty || !data?.timestamp || hours === hoursPassed;
+      if (needsUpdate) this.fetchQuery(name, query);
+      else this.log("d", `${name} up to date (${hoursPassed}/${hours}h)`);
+    }
+    setTimeout(this.updateQueries, 60000);
+  }
+  async fetchQuery(name, query) {
+    this.log("i", `Updating ${name}.`);
+    let result = await postQN(query);
+    if (!result) return;
+    if (result.error) {
+      this.log("w", `query ${name} failed: ${result.error}`);
+      return;
+    }
+    result.timestamp = moment().valueOf();
+    this.save(name, result);
+  }
+  saveQuery(query) {
+    const queries = this.state.queries.map((q) =>
+      q[0] === query[0] ? query : q
+    );
+    this.save("queries", queries);
+    console.debug(new Date(), "saved queries");
+  }
+
   save(key: string, data: any) {
     this.setState({ [key]: data });
     const value = JSON.stringify(data);
@@ -403,36 +197,34 @@ class App extends React.Component<IProps, IState> {
       const data = localStorage.getItem(key);
       if (!data) return; //console.debug(`loaded empty`, key);
       const size = data.length;
-      if (size > 10240)
+      if (size > 5000)
         console.debug(` -${key}: ${(size / 1024).toFixed(1)} KB`);
       let loaded = JSON.parse(data);
-      if (key === "blocks")
-        loaded = loaded.map(({ id, timestamp, events }) => {
-          return {
-            id,
-            timestamp,
-            events: events.filter((e) => e.method !== "ExtrinsicSuccess"),
-          };
-        });
       this.setState({ [key]: loaded });
+      return loaded;
     } catch (e) {
       console.warn(`Failed to load ${key}`, e);
     }
   }
 
   async loadData() {
+    // from local storage
     console.debug(`Loading data`);
-    "status members assets providers councils council election workers categories channels proposals posts threads openings tokenomics transactions reports validators nominators staches stakes rewardPoints stars blocks hidden media"
+    "settings showNotes notes scores status members assets providers buckets councils council election workers categories grading proposals openings tokenomics transactions reports validators nominators staches stakes rewardPoints stars blocks hidden media"
       .split(" ")
       .map((key) => this.load(key));
-    getTokenomics().then((tokenomics) => this.save(`tokenomics`, tokenomics));
-    //bootstrap(this.save); // axios requests
-    //this.updateCouncils();
+    const queries = this.load("queries") || this.state.queries;
+    return queries.map(([name]) => this.load(name));
   }
 
-  componentDidMount() {
-    this.loadData(); // local storage + bootstrap
-    window.addEventListener("keypress", this.handleKey);
+  async componentDidMount() {
+    this.loadData().then(() => this.updateQueries());
+
+    //setInterval(this.updateQueries, 60000);
+    //window.addEventListener("keypress", this.handleKey);
+    //getTokenomics().then((tokenomics) => this.save(`tokenomics`, tokenomics));
+    //bootstrap(this.save); // axios requests
+    //this.updateCouncils();
   }
 
   constructor(props: IProps) {
@@ -444,10 +236,17 @@ class App extends React.Component<IProps, IState> {
     this.toggleStar = this.toggleStar.bind(this);
     this.toggleFooter = this.toggleFooter.bind(this);
     this.toggleShowStatus = this.toggleShowStatus.bind(this);
-    this.getMember = this.getMember.bind(this);
+    //this.getMember = this.getMember.bind(this);
     this.selectEvent = this.selectEvent.bind(this);
     this.selectVideo = this.selectVideo.bind(this);
     this.handleKey = this.handleKey.bind(this);
+
+    // queries
+    this.updateQueries = this.updateQueries.bind(this);
+    this.fetchQuery = this.fetchQuery.bind(this);
+    this.saveQuery = this.saveQuery.bind(this);
+    this.toggleShowNotes = this.toggleShowNotes.bind(this);
+    this.log = this.log.bind(this);
   }
 }
 

+ 47 - 92
src/components/Bounties/index.tsx

@@ -1,98 +1,53 @@
-import { useState, useEffect } from "react";
-import { Table } from "react-bootstrap";
-import axios from "axios";
-
-import Loading from "../Loading";
-import "./bounties.css";
-
-interface IBounty {
-  id: any;
-  title: string;
-  description: string;
-  format: string;
-  openedDate: any;
-  status: string;
-  reward: string | number;
-  manager: string[];
-  links: string[];
-}
-
-const bountiesUrl =
-  "https://raw.githubusercontent.com/Joystream/community-repo/master/bounties/bounties-status.json";
-
-function Bounties() {
-  const [bounties, setBounties] = useState<IBounty[]>([]);
-
-  const fetchBounties = (url: string) => {
-    axios
-      .get<{ activeBounties: IBounty[] }>(url)
-      .then(({ data }) => data && setBounties(data.activeBounties));
-  };
-
-  useEffect(() => {
-    fetchBounties(bountiesUrl);
-  }, []);
-
-  if (!bounties) return <Loading target={`bounties`} />;
+import { useState, useMemo } from "react";
+import { Button } from "react-bootstrap";
+import Json from "../Data/Json";
+import { sortDesc } from "../../lib/util";
+
+const Bounties = (props: {}) => {
+  const { bounties } = props;
+  //console.log(`b`, bounties);
+  const [stage, setStage] = useState("WorkSubmission");
+
+  let stages = useMemo(() => {
+    let stgs = {};
+    if (bounties?.bounties?.length) bounties.bounties.forEach((b) => stgs[b.stage]++);
+    return Object.keys(stgs);
+  }, [bounties]);
 
   return (
-    <div className="Bounties bg-light p-3">
+    <div className="box">
       <h1>Bounties</h1>
-      <Table striped bordered hover>
-        <thead>
-          <tr>
-            <th>#</th>
-            <th>Opened</th>
-            <th>Reward</th>
-            <th>Manager</th>
-            <th>Status</th>
-          </tr>
-        </thead>
-        <tbody>
-          {bounties && bounties.map((b: any) => <Bounty key={b.id} {...b} />)}
-        </tbody>
-      </Table>
-      <a href="https://github.com/Joystream/community-repo/blob/master/bounties">
-        Reports
-      </a>
+      <div>
+        {stages.map((s) => (
+          <Button key={s}
+            variant={s === stage ? "warning" : "info"}
+            onClick={() => setStage(s)}
+          >
+            {s}
+          </Button>
+        ))}
+      </div>
+      <div className="d-flex flex-column mt-2">
+        {bounties?.bounties
+          ? sortDesc(
+              bounties.bounties?.filter((b) => b.stage === stage),
+              "createdAt"
+            ).map((b) => (
+              <div key={b.id} className="text-left mb-2">
+                <h4>
+                  <a
+                    href={`https://dao.joystream.org/#/bounties/preview/${b.id}`}
+                  >
+                    {b.title}
+                  </a>
+                </h4>
+                <div className="m-1">by {b.creator.handle}</div>
+                <Json src={b} />
+              </div>
+            ))
+          : "Loading .."}
+      </div>
     </div>
   );
-}
-export default Bounties;
-
-const Bounty = ({
-  id,
-  title,
-  description,
-  format,
-  openedDate,
-  status,
-  reward,
-  manager,
-  links,
-}: IBounty) => {
-  return (
-    <>
-      <tr>
-        <td rowSpan={2} className="font-weight-bold">
-          {id}
-        </td>
-        <td>{openedDate}</td>
-        <td>${reward}</td>
-        <td>{manager?.join(", ")}</td>
-        <td>{status}</td>
-      </tr>
-      <tr>
-        <td colSpan={6}>
-          <h3>{title}</h3>
-          {description}
-          {links.map((l, i) => (
-            <div key={i}>
-              <a href={l}>{l}</a>
-            </div>
-          ))}
-        </td>
-      </tr>
-    </>
-  );
 };
+export default Bounties;

+ 1 - 2
src/components/Councils/CouncilApplicant.tsx

@@ -2,7 +2,6 @@ import { Grid, Typography, Chip } from "@material-ui/core";
 import { IApplicant, IElectionState } from "../../types";
 import ApplicantVotes from "./ApplicantVotes";
 import { electionStyles } from "./styles";
-import { calculateOtherVotes, formatJoy } from "../../lib/util";
 
 const CouncilApplicant = (props: {
   applicant: IApplicant;
@@ -12,7 +11,7 @@ const CouncilApplicant = (props: {
   const classes = electionStyles();
   const { index, election, applicant } = props;
   const { electionStake } = applicant;
-  const othersStake = calculateOtherVotes(election.votes, applicant);
+  const othersStake = []
   return (
     <Grid item lg={2} md={4} sm={6} xs={12}>
       <div className={classes.applicant}>

+ 2 - 3
src/components/Councils/Election.tsx

@@ -13,7 +13,6 @@ import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
 import { useEffect, useState } from "react";
 import { ElectionStatus } from "..";
 import { IState, IApplicant, IElectionState, IVote } from "../../types";
-import { calculateOtherVotes } from "../../lib/util";
 import axios from "axios";
 import pako from "pako";
 import { electionStyles } from "./styles";
@@ -90,8 +89,8 @@ const Election = (props: IState) => {
   }, []);
 
   const sortByStake = (a: IApplicant, b: IApplicant) => {
-    const votesA = calculateOtherVotes(votes, a);
-    const votesB = calculateOtherVotes(votes, b);
+    const votesA = []
+    const votesB = []
     const stakeA = +a.electionStake.new + +a.electionStake.transferred + votesA;
     const stakeB = +b.electionStake.new + +b.electionStake.transferred + votesB;
     return stakeA < stakeB ? 1 : -1;

+ 82 - 42
src/components/Councils/index.tsx

@@ -1,50 +1,90 @@
 import React from "react";
-import LeaderBoard from "./Leaderboard";
-import CouncilVotes from "./CouncilVotes";
-import Terms from "./TermLengths";
-import { Loading } from "..";
+import { mJoy } from "../../lib/util";
 
-import { Council, ProposalDetail, Status } from "../../types";
+const unit = "tJoy";
+const seats = 5;
 
-const Rounds = (props: {
-  block: number;
-  councils: Council[];
-  proposals: ProposalDetail[];
-  status: Status;
-}) => {
-  const { block, councils, proposals, status } = props;
-  if (!status.election) return <Loading target="election status" />;
-  const stage: number[] = status.election.durations;
+const Cycles = (props: {}) => {
+  const { elections = [], members } = props;
 
-  return (
-    <div className="w-100">
-      <h2 className="w-100 text-center text-light">Leaderboard</h2>
-      <LeaderBoard
-        stages={status.election?.stage}
-        councils={councils}
-        proposals={proposals}
-        status={status}
-      />
+  if (!elections.electionRounds) return <div />;
 
-      <h2 className="w-100 text-center text-light">Proposal Votes</h2>
-      {councils
-        .sort((a, b) => b.round - a.round)
-        .map((council) => (
-          <CouncilVotes
-            key={council.round}
-            {...council}
-            expand={council.round === councils.length}
-            block={block}
-            proposals={proposals.filter(
-              ({ councilRound }) => councilRound === council.round
-            )}
-          />
-        ))}
+  const findMember = (key: string) => {
+    const member = members.memberships?.find(
+      (m) => m.rootAccount === key || m.controllerAccount === key
+    );
+    if (member) return member.handle;
+    //return key.slice(0, 6) + ".." + key.slice(key.length - 6);
+    return key;
+  };
 
-      <h2 className="w-100 text-center text-light">Term Durations</h2>
-      <Terms councils={councils} stage={stage} />
-    </div>
-  );
+  return elections.electionRounds
+    .sort((a, b) => b.cycleId - a.cycleId)
+    .map((e) => (
+      <div className="box">
+        <h3>Round {e.cycleId}</h3>
+
+        <div className="text-left d-flex flex-column justify-content-between">
+          {e.candidates
+            .sort((a, b) => b.votePower - a.votePower)
+            .map((c, seat: number) => (
+              <div key={seat} className="d-flex flex-row mb-2">
+                <div className="col-2">
+                  <h4 className={seat < seats ? "font-weight-bold" : ""}>
+                    {c.member.handle}
+                  </h4>
+                  <div>
+                    {mJoy(c.votePower)} M {unit}
+                  </div>
+                </div>
+                <div className="col-4">
+                  {c.noteMetadata?.bulletPoints.map((b, i: number) => (
+                    <div key={i}>{b}</div>
+                  ))}
+                  <div className="">{c.noteMetadata.description}</div>
+                </div>
+                <div className="col-3 d-flex flex-column ml-2">
+                  {e.castVotes
+                    .filter(
+                      (v) => v.voteFor && v.voteFor.memberId === c.member?.id
+                    )
+                    .sort((a, b) => b.stake - a.stake)
+                    .map((v) => (
+                      <div key={v.id} className="m-1 d-flex flex-row">
+                        <div className="col-4 text-right">
+                          {mJoy(v.stake, 1)} M {unit}
+                        </div>
+                        <div className="col-8" title={v.castBy}>
+                          {findMember(v.castBy)}
+                        </div>
+                      </div>
+                    ))}
+                </div>
+              </div>
+            ))}
+        </div>
+        {e.castVotes.filter((v) => !v.voteFor).length ? (
+          <div>
+            <h4>Not revealed</h4>
+            {e.castVotes
+              .filter((v) => !v.voteFor)
+              .sort((a, b) => b.stake - a.stake)
+              .map((v) => (
+                <div key={`vote-` + v.id} className="d-flex flex-row">
+                  <div className="col-5 text-right">
+                    {mJoy(v.stake, 1)} M {unit}
+                  </div>
+                  <div className="col-7 text-left" title={v.castBy}>
+                    {findMember(v.castBy)}
+                  </div>
+                </div>
+              ))}
+          </div>
+        ) : (
+          ""
+        )}
+      </div>
+    ));
 };
 
-export default Rounds;
+export default Cycles;

+ 10 - 3
src/components/Dashboard/index.tsx

@@ -1,4 +1,4 @@
-import Events from "../Events";
+//import Events from "../Events";
 import Media from "../Media";
 
 interface IProps extends IState {
@@ -6,8 +6,15 @@ interface IProps extends IState {
 }
 
 const Dashboard = (props: IProps) => {
-  const { save, selectVideo, media, hidden, selectEvent, blocks } = props;
-  return <Media save={save} media={media} selectVideo={selectVideo} />;
+  const { save, selectVideo, media, categories } = props;
+  return (
+    <Media
+      save={save}
+      media={media}
+      selectVideo={selectVideo}
+      categories={categories}
+    />
+  );
 };
 
 export default Dashboard;

+ 1 - 1
src/components/Distribution/Bag.tsx

@@ -2,7 +2,7 @@ import moment from "moment";
 import { useEffect, useState } from "react";
 import { Badge } from "react-bootstrap";
 import { Object, Operator } from ".types";
-import { gb, testBag } from "./util";
+import { gb, testBag } from "../../lib/util";
 
 const Bag = (props: { id: number; operator: Operator; objects: Object[] }) => {
   const { id, objects, operator } = props;

+ 7 - 5
src/components/Distribution/index.tsx

@@ -2,17 +2,19 @@ import axios from "axios";
 import { useState, useEffect } from "react";
 import { Loading } from "..";
 import Bucket from "./Bucket";
+//import Map from "./Map";
 import TestResults from "./TestResults";
 import { getBuckets } from "./util";
 
 const uploadErrorsUrl = "https://joystreamstats.live/static/upload-errors.json";
 
 const Distribution = (props: {
+  buckets: any[][];
   workers: { distributionWorkingGroup?: Worker[] };
 }) => {
-  const { workers } = props;
-  const [sBuckets, setSBuckets] = useState([]);
-  const [dBuckets, setDBuckets] = useState([]);
+  const { save, buckets = [], workers } = props;
+  const [sBuckets, setSBuckets] = useState(buckets[0] ? buckets[0] : []);
+  const [dBuckets, setDBuckets] = useState(buckets[1] ? buckets[1] : []);
   const [providers, setProviders] = useState([]);
   const [uploadErrors, setUploadErrors] = useState([]);
 
@@ -27,6 +29,7 @@ const Distribution = (props: {
       workers?.storageWorkingGroup,
       workers?.distributionWorkingGroup,
     ]).then((buckets) => {
+      save("buckets", buckets);
       if (buckets[0].length) setSBuckets(buckets[0]);
       if (buckets[1].length) setDBuckets(buckets[1]);
     });
@@ -50,10 +53,9 @@ const Distribution = (props: {
     updateBuckets();
     fetchErrors();
     getFailingAssets();
+    setTimeout(updateBuckets, 300000);
   });
 
-  setTimeout(updateBuckets, 10000);
-
   return (
     <div className="m-2 p-2 bg-light">
       <h2>Storage Buckets</h2>

+ 1 - 36
src/components/Distribution/util.ts

@@ -1,43 +1,8 @@
 import axios from "axios";
-import { queryNode } from "../../config";
 import { Operator, Bucket } from "./types";
 import { qnBuckets, qnBucketObjects } from "./queries";
 import { Family } from "./types";
-
-export const gb = (bytes: number) => (bytes / 1024 ** 3).toFixed() + `gb`;
-
-const fail = (msg: string) => {
-  console.log(`postQN: ${msg}`);
-  return [];
-};
-export const postQN = (query: string, url: string = queryNode) =>
-  axios
-    .post(url, { query })
-    .then(({ data }) => {
-      if (data.error) return fail(data.error);
-      console.debug(`postQN`, query, data.data);
-      return data.data;
-    })
-    .catch((e) => fail(e.message));
-
-export const testBag = async (
-  endpoint: string,
-  objects: Object[] | null
-): Promise<[string]> => {
-  if (!endpoint) return `no endpoint given`;
-  if (!objects) return `warning`;
-  if (!objects.length) return ``;
-  const object = Math.round(Math.random() * (objects.length - 1));
-  const route = endpoint.includes(`storage`) ? `files` : `assets`;
-  const url = endpoint + `api/v1/${route}/${objects[object].id}`;
-  return axios
-    .head(url)
-    .then((data) => `success`)
-    .catch((e) => {
-      console.error(`testBag ${url}: ${e.message}`);
-      return `danger`;
-    });
-};
+import { postQN } from "../../lib/util";
 
 export const testQN = (
   op: Operator,

+ 1 - 1
src/components/IssueTracker/index.tsx

@@ -327,7 +327,7 @@ const IssueTracker = () => {
         variant="h2"
         className="mb-3"
       >
-        Issues
+        Tasks
       </Typography>
       <Fab
         onClick={openNewMemberModal}

+ 4 - 2
src/components/Media/Histogram.tsx

@@ -3,25 +3,27 @@ import Histogram from 'react-chart-histogram';
 const Graph = (props : {}) => {
   const {objects, label, show} = props
   if (!show) return <div/>
+
   // plot count per bitrate range
   const max = objects.reduce((max,o) => o.bitrate < max ? max : o.bitrate, 0)
    console.debug(`max`,max, objects.count)
   let labels = [];
   let data = [];
   const step = 100000
+
   for (let i = step ; i < 1500000; i += step) {
     const count = objects.filter(o=> o.bitrate > i-step && o.bitrate < i  ).length
 const label = i < 1000000 ? (i/1000).toFixed() +"K"  : (i/1000**2).toFixed(1) + "M"
     labels.push(label)
     data.push(count)
   }
-  const options = { fillColor: '#FFFFFF', strokeColor: '#0000FF' };
+  const options = { text: '#000', fillColor: '#0FFFF', strokeColor: '#0000FF' };
   return (
     <div>
       <Histogram
           xLabels={labels}
           yValues={data}
-          width='600'
+          width='800'
           height='200'
           options={options}
       />

+ 1 - 1
src/components/Media/Videos.tsx

@@ -1,4 +1,4 @@
-import Histogram from "./Histogram";
+import Histogram from "../Data/Histogram";
 
 const Videos = (props:{}) => {
   const {selectVideo, objects, page,perPage, showChart} = props 

+ 25 - 16
src/components/Media/index.tsx

@@ -20,7 +20,7 @@ const query = `query {
 const Media = (props: {}) => {
   const { save, selectVideo, media } = props;
   const [page, setPage] = useState(1);
-  const [perPage, setPerPage] = useState(50);
+  const [perPage] = useState(50);
   const [showChart, setShowChart] = useState(false);
 
   useEffect(() => {
@@ -29,12 +29,15 @@ const Media = (props: {}) => {
         .post(queryNode, { query })
         .then(({ data }) => save("media", data.data))
         .catch((e) => console.error(query, e.message));
-  }, [save, media?.storageBags?.length]);
+  }, [save, media?.storageBags?.length, media?.channels]);
 
   return (
     <div className="box">
       <h2>
-      <BarChart2 className="float-right" onClick={()=>setShowChart(!showChart)}/>
+        <BarChart2
+          className="float-right"
+          onClick={() => setShowChart(!showChart)}
+        />
         <MinusSquare
           onClick={() => page > 1 && setPage(page - 1)}
           disabled={page === 1}
@@ -43,20 +46,26 @@ const Media = (props: {}) => {
         <PlusSquare className="ml-1" onClick={() => setPage(page + 1)} />
       </h2>
       {media.storageBags?.length ? (
-      <Videos showChart={showChart} selectVideo={selectVideo} perPage={perPage} page={page} objects={media.storageBags
-          .reduce((objects, b) => {          
-            b.objects.forEach((o) => {
-              if (!o.videoMedia?.duration) return //console.debug(`skipping`,o)
-              const bitrate = (o.size / o.videoMedia.duration).toFixed()
-              const obj = {...o, providers: b.distributionBuckets, bitrate }
-              objects.push(obj)
-            })
-            return objects
-          }, [])
-          .filter((o) => o.bitrate)
-          .sort((a, b) => b.bitrate - a.bitrate)} />
+        <Videos
+          showChart={showChart}
+          selectVideo={selectVideo}
+          perPage={perPage}
+          page={page}
+          objects={media.storageBags
+            .reduce((objects, b) => {
+              b.objects.forEach((o) => {
+                if (!o.videoMedia?.duration) return; //console.debug(`skipping`,o)
+                const bitrate = (o.size / o.videoMedia.duration).toFixed();
+                const obj = { ...o, providers: b.distributionBuckets, bitrate };
+                objects.push(obj);
+              });
+              return objects;
+            }, [])
+            .filter((o) => o.bitrate)
+            .sort((a, b) => b.bitrate - a.bitrate)}
+        />
       ) : (
-        "Waiting for results from QN .."
+        "Waiting for QN results .."
       )}
     </div>
   );

+ 14 - 23
src/components/Modals/Player.tsx

@@ -58,9 +58,20 @@ const PlayerModal = (props) => {
         {latencies.length ? (
           <>
             <Player autoPlay playsInline src={videoUrl}></Player>
-            <div className='box float-right'>
-              <a className="font-weight-bold" href={`https://play.joystream.org/channel/${channel.id}`}>{channel.title}</a>
-              <br/>by <a href={`https://play.joystream.org/member/${channel.owner.handle}?tab=About`}>{channel.owner.handle}</a>
+            <div className="box float-right">
+              <a
+                className="font-weight-bold"
+                href={`https://play.joystream.org/channel/${channel.id}`}
+              >
+                {channel.title}
+              </a>
+              <br />
+              by{" "}
+              <a
+                href={`https://play.joystream.org/member/${channel.owner.handle}?tab=About`}
+              >
+                {channel.owner.handle}
+              </a>
             </div>
             <div className="d-flex flex-column text-left my-2">
               <div>
@@ -113,23 +124,3 @@ const PlayerModal = (props) => {
 };
 
 export default PlayerModal;
-
-const SelectProvider = (props: {
-  handleChange: () => void;
-  me: { providers: { [key: string]: string[] }; provider: string };
-}) => {
-  const { handleChange, me } = props;
-  const regions = Object.keys(me.providers);
-  return (
-    <select
-      className="form-control text-center bg-dark text-secondary"
-      name="provider"
-      value={me.provider}
-      onChange={handleChange}
-    >
-      {regions.map((r) =>
-        me.providers[r].map((p) => <option key={p}>{p}</option>)
-      )}
-    </select>
-  );
-};

+ 12 - 11
src/components/Modals/index.tsx

@@ -7,16 +7,16 @@ const Modals = (props) => {
   const { editKpi, toggleEditKpi, showModal, toggleShowStatus, showStatus } =
     props;
 
-const getChannel = (objectId) => {
-   const {storageBags=[], channels=[], memberships=[]} = props.media
-   const bag = props.media.storageBags.find((b) =>
-            b.objects.find((o) => +o.id === +props.video)
-          )
-   const channelId = bag.id.split(":")[2]
-   const channel = channels.find(c=> +c.id === +channelId)
-   const owner = memberships.find(m => +m.id === +channel?.ownerMemberId)
-   return {...bag,...channel, owner}
-}
+  const getChannel = (objectId) => {
+    const { channels = [], memberships = [] } = props.media;
+    const bag = props.media.storageBags.find((b) =>
+      b.objects.find((o) => +o.id === +props.video)
+    );
+    const channelId = bag.id.split(":")[2];
+    const channel = channels.find((c) => +c.id === +channelId);
+    const owner = memberships.find((m) => +m.id === +channel?.ownerMemberId);
+    return { ...bag, ...channel, owner };
+  };
 
   return (
     <div>
@@ -25,7 +25,8 @@ const getChannel = (objectId) => {
           id={props.video}
           onHide={props.selectVideo}
           selectVideo={props.selectVideo}
-          channel={getChannel(props.video)} />
+          channel={getChannel(props.video)}
+        />
       ) : props.selectedEvent ? (
         <Event event={props.selectedEvent} onHide={props.selectEvent} />
       ) : editKpi ? (

+ 32 - 33
src/components/Openings/index.tsx

@@ -1,4 +1,3 @@
-import GroupOpenings from "./Group";
 import {
   createStyles,
   makeStyles,
@@ -9,8 +8,7 @@ import {
   Typography,
   Theme,
 } from "@material-ui/core";
-
-import { Opening } from "./types";
+//import { fixGroupName } from "../../lib/util";
 
 const useStyles = makeStyles((theme: Theme) =>
   createStyles({
@@ -21,32 +19,18 @@ const useStyles = makeStyles((theme: Theme) =>
       textAlign: "left",
       backgroundColor: "#4038FF",
       color: "#fff",
-      minHeight: 600,
-      maxHeight: 600,
-      overflow: "auto",
     },
   })
 );
 
-const activeOpenings = (openings: Opening[]): Opening[] => {
-  if (!openings?.length) return [];
-  return openings.filter(
-    (o) => Object.keys(o.stage["active"].stage)[0] === "acceptingApplications"
-  );
-};
-
 const Openings = (props: { openings: {} }) => {
   const classes = useStyles();
-  const { members, openings } = props;
+  const { openings } = props;
   if (!openings) return <div />;
-  const groups = Object.keys(openings).filter((g) => g !== "timestamp");
-  const active = groups
-    .map((group) => activeOpenings(openings[group]))
-    .reduce((sum: number, a: Opening[]) => sum + a.length, 0);
 
-  //if (!active.length) return <div />;
+  console.debug(`openings`, openings);
   return (
-    <Grid className={classes.grid} item lg={6}>
+    <Grid className={classes.grid} item lg={12}>
       <Paper className={classes.paper}>
         <AppBar className={classes.root} position="static">
           <Toolbar>
@@ -55,19 +39,34 @@ const Openings = (props: { openings: {} }) => {
             </Typography>
           </Toolbar>
         </AppBar>
-        <div className="m-3 text-center">
-          {active || `No`} active openings in {groups.length} group
-          {groups.length !== 1 ? `s` : ``}.
-        </div>
-        <div>
-          {groups.map((group) => (
-            <GroupOpenings
-              key={`${group}-openings`}
-              members={members}
-              group={group}
-              openings={activeOpenings(openings[group])}
-            />
-          ))}
+        <div className="m-3 text-center d-flex flex-wrap">
+          <div className="col-4">
+            <h2>Added</h2>
+            {openings?.openingAddedEvents?.map((o, i) => (
+              <div key={`opening-added` + i} className="d-flex flex-row">
+                <div className="col-3">{o.inBlock}</div>
+                <div className="col-8">{o.groupId}</div>
+              </div>
+            ))}
+          </div>{" "}
+          <div className="col-4">
+            <h2>Filled</h2>
+            {openings?.openingFilledEvents?.map((o, i) => (
+              <div key={`opening-filled` + i} className="d-flex flex-row">
+                <div className="col-3">{o.inBlock}</div>
+                <div className="col-8">{o.groupId}</div>
+              </div>
+            ))}
+          </div>
+          <div className="col-4">
+            <h2>Canceled</h2>
+            {openings?.openingCanceledEvents?.map((o, i) => (
+              <div key={`opening-canceled` + i} className="d-flex flex-row">
+                <div className="col-3">{o.inBlock}</div>
+                <div className="col-8">{o.groupId}</div>
+              </div>
+            ))}
+          </div>
         </div>
       </Paper>
     </Grid>

+ 0 - 82
src/components/Proposals/Spending.tsx

@@ -1,82 +0,0 @@
-import React from "react";
-import { Link } from "react-router-dom";
-import { IState, ProposalDetail } from "../../types";
-import { domain } from "../../config";
-
-const mJoy = (amount: number) => (amount ? (amount / 1000000).toFixed(2) : `?`);
-
-const executionFailed = (result: string, executed: any) => {
-  if (result !== "Approved") return result;
-  if (!executed || !Object.keys(executed)) return;
-  if (executed.Approved && executed.Approved.ExecutionFailed)
-    return executed.Approved.ExecutionFailed.error;
-  return false;
-};
-
-const Spending = (props: IState) => {
-  const spending = props.proposals.filter(
-    (p: ProposalDetail) => p && p.type === "spending"
-  );
-  console.log(`Found ${spending.length} spending proposals.`);
-  console.log(spending);
-
-  const rounds: ProposalDetail[][] = [];
-  let sum = 0;
-  let sums: number[] = [];
-  spending.forEach((p) => {
-    const r = p.councilRound;
-    rounds[r] = rounds[r] ? rounds[r].concat(p) : [p];
-    if (!sums[r]) sums[r] = 0;
-    if (executionFailed(p.result, p.executed)) return;
-    sum += p.amount;
-    sums[r] += p.amount;
-  });
-
-  return (
-    <div className="box text-left">
-      <h1 className="text-left">Total: {mJoy(sum)}M tJOY</h1>
-      {rounds.length
-        ? rounds.map((proposals, i: number) => (
-            <div key={`round-${i}`} className="bg-secondary p-1 my-2">
-              <h2 className="text-left mt-3">
-                Round {i} <small>{mJoy(sums[i])} M</small>
-              </h2>
-              {proposals.map((p) => (
-                <ProposalLine key={p.id} {...p} />
-              ))}
-            </div>
-          ))
-        : spending
-            .sort((a, b) => b.id - a.id)
-            .map((p) => <ProposalLine key={p.id} {...p} />)}
-    </div>
-  );
-};
-
-export default Spending;
-
-const ProposalLine = (props: any) => {
-  const { id, title, amount, author, executed, result } = props;
-  const failed = executionFailed(result, executed);
-  const color = failed
-    ? failed === "Pending"
-      ? "warning"
-      : "danger"
-    : "success";
-  return (
-    <div key={id} className={`bg-${color} d-flex flex-row`}>
-      <div className={`col-1 text-right bg-${color} text-body p-1 mr-2`}>
-        {mJoy(amount)} M
-      </div>
-      <a className="col-1" href={`${domain}/#/proposals/${id}`}>
-        {id}
-      </a>
-      <Link className="col-1" to={`/members/${author.handle}`}>
-        {author.handle}
-      </Link>
-      <Link className="col-4" to={`/proposals/${id}`}>
-        {title}
-      </Link>
-    </div>
-  );
-};

+ 161 - 150
src/components/Routes/index.tsx

@@ -1,11 +1,11 @@
 import React, { Suspense } from "react";
-import { BrowserRouter, Switch, Route } from "react-router-dom";
-import { AppBar, Election, Spinner } from "..";
+import { Switch, Route } from "react-router-dom";
+import { Election, Spinner } from "..";
 import { IState } from "../../types";
 import IssueTracker from "../IssueTracker";
 
 const Calendar = React.lazy(() => import("../Calendar"));
-const { Council } = React.lazy(() => import(".."));
+//const Council = React.lazy(() => import("../Dashboard/Council"));
 const Councils = React.lazy(() => import("../Councils"));
 const Curation = React.lazy(() => import("../Curation"));
 const Dashboard = React.lazy(() => import("../Dashboard"));
@@ -15,7 +15,7 @@ const Members = React.lazy(() => import("../Members"));
 const Mint = React.lazy(() => import("../Mint"));
 const Proposals = React.lazy(() => import("../Proposals"));
 const Proposal = React.lazy(() => import("../Proposals/Proposal"));
-const Spending = React.lazy(() => import("../Proposals/Spending"));
+const Spending = React.lazy(() => import("../Spending"));
 const Timeline = React.lazy(() => import("../Timeline"));
 const Tokenomics = React.lazy(() => import("../Tokenomics"));
 const Validators = React.lazy(() => import("../Validators"));
@@ -28,6 +28,11 @@ const ValidatorReport = React.lazy(() => import("../ValidatorReport"));
 const FAQ = React.lazy(() => import("../FAQ"));
 const KPI = React.lazy(() => import("../KPI"));
 const Survey = React.lazy(() => import("../Survey"));
+const Grading = React.lazy(() => import("../Grading"));
+const Data = React.lazy(() => import("../Data"));
+const Openings = React.lazy(() => import("../Openings"));
+const Notes = React.lazy(() => import("../Notes"));
+const Settings = React.lazy(() => import("../Settings"));
 
 interface IProps extends IState {
   toggleStar: (a: string) => void;
@@ -38,153 +43,159 @@ const Routes = (props: IProps) => {
   const { faq, proposals, toggleEditKpi } = props;
 
   return (
-    <div>
-      <BrowserRouter>
-        <div style={{ flexGrow: 1 }}>
-          <AppBar />
-        </div>
-        <div>
-          <Suspense fallback={<Spinner />}>
-            <Switch>
-              <Route
-                path="/tokenomics"
-                render={(routeprops) => (
-                  <Tokenomics
-                    {...routeprops}
-                    validators={{
-                      count: props.validators?.length,
-                      reward: props.status?.lastReward,
-                      stakes: props.stakes,
-                    }}
-                    block={props.status?.block?.id}
-                    proposals={proposals.filter((p) => p.type === "spending")}
-                    mints={props.mints}
-                    council={props.council}
-                    reports={props.reports}
-                    tokenomics={props.tokenomics}
-                    workers={props.workers}
-                  />
-                )}
-              />
-              <Route
-                path="/spending"
-                render={(routeprops) => <Spending {...routeprops} {...props} />}
-              />
-              <Route
-                path="/proposals/:id"
-                render={(routeprops) => <Proposal {...routeprops} {...props} />}
-              />
-              <Route
-                path="/proposals"
-                render={() => <Proposals {...props} />}
-              />
-              <Route
-                path="/councils"
-                render={(routeprops) => <Councils {...routeprops} {...props} />}
-              />
-              <Route
-                path="/council"
-                render={(routeprops) => <Council {...routeprops} {...props} />}
-              />
-              <Route
-                path="/curation"
-                render={(routeprops) => <Curation {...routeprops} {...props} />}
-              />
-              <Route
-                path="/forum/threads/:thread"
-                render={(routeprops) => <Forum {...routeprops} {...props} />}
-              />
-              <Route path="/forum" render={() => <Forum {...props} />} />
-              <Route
-                path="/mint"
-                render={(routeprops) => <Mint {...routeprops} {...props} />}
-              />
-              <Route
-                path="/members/:handle"
-                render={(routeprops) => <Member {...routeprops} {...props} />}
-              />
-              <Route
-                path="/members"
-                render={(routeprops) => <Members {...routeprops} {...props} />}
-              />
-              <Route
-                path="/calendar"
-                render={(routeprops) => <Calendar {...routeprops} {...props} />}
-              />
-              <Route
-                path="/timeline"
-                render={(routeprops) => <Timeline {...routeprops} {...props} />}
-              />
-              <Route
-                path="/validators"
-                render={(routeprops) => (
-                  <Validators showList={true} {...routeprops} {...props} />
-                )}
-              />
-              <Route
-                path="/validator-report"
-                render={(routeprops) => (
-                  <ValidatorReport
-                    lastBlock={props.status?.block?.id}
-                    validators={props.validators}
-                  />
-                )}
-              />
-              <Route
-                path="/storage"
-                render={(routeprops) => (
-                  <Distribution {...routeprops} {...props} />
-                )}
-              />
-              <Route
-                path="/distribution"
-                render={(routeprops) => (
-                  <Distribution {...routeprops} {...props} />
-                )}
-              />
-              <Route
-                path="/transactions"
-                render={(routeprops) => (
-                  <Transactions {...routeprops} {...props} />
-                )}
-              />
-              <Route
-                path="/bounties"
-                render={(routeprops) => <Bounties {...routeprops} {...props} />}
-              />
-              <Route
-                path="/burners"
-                render={(routeprops) => <Burners {...routeprops} {...props} />}
-              />
-              <Route path="/faq" render={(routeprops) => <FAQ faq={faq} />} />
-              <Route
-                path="/election"
-                render={(routeprops) => (
-                  <Election
-                    block={props.status?.block?.id}
-                    round={props.status?.election?.round}
-                    stage={props.status?.election?.stage}
-                    termEndsAt={props.status?.election?.termEndsAt}
-                    domain={props.domain}
-                    election={props.election}
-                  />
-                )}
-              />
-              <Route
-                path="/kpi"
-                render={(routeprops) => (
-                  <KPI toggleEditKpi={toggleEditKpi} faq={faq} />
-                )}
-              />
-              <Route path="/issues" render={(routeprops) => <IssueTracker />} />
-              <Route path="/survey" render={(routeprops) => <Survey />} />
+    <Suspense fallback={<Spinner />}>
+      <Switch>
+        <Route
+          path="/tokenomics"
+          render={(routeprops) => (
+            <Tokenomics
+              {...routeprops}
+              validators={{
+                count: props.validators?.length,
+                reward: props.status?.lastReward,
+                stakes: props.stakes,
+              }}
+              block={props.status?.block?.id}
+              proposals={proposals.filter((p) => p.type === "spending")}
+              mints={props.mints}
+              council={props.council}
+              reports={props.reports}
+              tokenomics={props.tokenomics}
+              workers={props.workers}
+            />
+          )}
+        />
+        <Route
+          path="/settings"
+          render={(routeprops) => <Settings {...routeprops} {...props} />}
+        />
+        <Route
+          path="/notes"
+          render={(routeprops) => <Notes {...routeprops} {...props} />}
+        />
+        <Route
+          path="/openings"
+          render={(routeprops) => <Openings {...routeprops} {...props} />}
+        />
+        <Route
+          path="/grading"
+          render={(routeprops) => <Grading {...routeprops} {...props} />}
+        />
+        <Route
+          path="/data"
+          render={(routeprops) => <Data {...routeprops} {...props} />}
+        />
+        <Route
+          path="/bounties"
+          render={(routeprops) => <Bounties {...routeprops} {...props} />}
+        />
+        <Route
+          path="/spending"
+          render={(routeprops) => <Spending {...routeprops} {...props} />}
+        />
+        <Route
+          path="/proposals/:id"
+          render={(routeprops) => <Proposal {...routeprops} {...props} />}
+        />
+        <Route path="/proposals" render={() => <Proposals {...props} />} />
+        <Route
+          path="/councils"
+          render={(routeprops) => <Councils {...routeprops} {...props} />}
+        />
+        <Route
+          path="/council"
+          render={(routeprops) => <Councils {...routeprops} {...props} />}
+        />
+        <Route
+          path="/curation"
+          render={(routeprops) => <Curation {...routeprops} {...props} />}
+        />
+        <Route
+          path="/forum/threads/:thread"
+          render={(routeprops) => <Forum {...routeprops} {...props} />}
+        />
+        <Route path="/forum" render={() => <Forum {...props} />} />
+        <Route
+          path="/mint"
+          render={(routeprops) => <Mint {...routeprops} {...props} />}
+        />
+        <Route
+          path="/members/:handle"
+          render={(routeprops) => <Member {...routeprops} {...props} />}
+        />
+        <Route
+          path="/members"
+          render={(routeprops) => <Members {...routeprops} {...props} />}
+        />
+        <Route
+          path="/calendar"
+          render={(routeprops) => <Calendar {...routeprops} {...props} />}
+        />
+        <Route
+          path="/timeline"
+          render={(routeprops) => <Timeline {...routeprops} {...props} />}
+        />
+        <Route
+          path="/validators"
+          render={(routeprops) => (
+            <Validators showList={true} {...routeprops} {...props} />
+          )}
+        />
+        <Route
+          path="/validator-report"
+          render={(routeprops) => (
+            <ValidatorReport
+              lastBlock={props.status?.block?.id}
+              validators={props.validators}
+            />
+          )}
+        />
+        <Route
+          path="/storage"
+          render={(routeprops) => <Distribution {...routeprops} {...props} />}
+        />
+        <Route
+          path="/distribution"
+          render={(routeprops) => <Distribution {...routeprops} {...props} />}
+        />
+        <Route
+          path="/transactions"
+          render={(routeprops) => <Transactions {...routeprops} {...props} />}
+        />
+        <Route
+          path="/bounties"
+          render={(routeprops) => <Bounties {...routeprops} {...props} />}
+        />
+        <Route
+          path="/burners"
+          render={(routeprops) => <Burners {...routeprops} {...props} />}
+        />
+        <Route path="/faq" render={(routeprops) => <FAQ faq={faq} />} />
+        <Route
+          path="/election"
+          render={(routeprops) => (
+            <Election
+              block={props.status?.block?.id}
+              round={props.status?.election?.round}
+              stage={props.status?.election?.stage}
+              termEndsAt={props.status?.election?.termEndsAt}
+              domain={props.domain}
+              election={props.election}
+            />
+          )}
+        />
+        <Route
+          path="/kpi"
+          render={(routeprops) => (
+            <KPI toggleEditKpi={toggleEditKpi} faq={faq} />
+          )}
+        />
+        <Route path="/issues" render={(routeprops) => <IssueTracker />} />
+        <Route path="/survey" render={(routeprops) => <Survey />} />
 
-              <Route path="/" render={() => <Dashboard {...props} />} />
-            </Switch>
-          </Suspense>
-        </div>
-      </BrowserRouter>
-    </div>
+        <Route path="/" render={() => <Dashboard {...props} />} />
+      </Switch>
+    </Suspense>
   );
 };
 

+ 56 - 0
src/components/Spending/index.tsx

@@ -0,0 +1,56 @@
+import React from "react";
+import { sortDesc } from "../../lib/util";
+import Round from "./Round";
+
+const Rounds = (props: {}) => {
+  const {
+    bounties,
+    councils,
+    wgSpending,
+    wgBudgetRefills,
+    councilBudgetRefills,
+    councilSalaryPayouts,
+    workerPayments,
+    councilSalaryChanges,
+    openings,
+    workerChanges,
+  } = props;
+
+  const filterEvents = (list, start, end) =>
+    list?.length
+      ? list.filter((e) => e.inBlock > start && (!end || e.inBlock < end))
+      : [];
+
+  return sortDesc(councils?.electedCouncils, "electedAtBlock").map(
+    (c, i: number) => (
+      <Round
+        key={`elected` + c.electedAtBlock}
+        bounties={sortDesc(bounties?.bounties, "createdInEvent.block").filter(
+          (b) =>
+            b.createdInEvent.block > c.electedAtBlock &&
+            b.createdInEvent.block < c.endedAtBlock
+        )}
+        round={councils.electedCouncils.length - i}
+        data={[
+          wgSpending?.budgetSpendingEvents,
+          wgBudgetRefills?.budgetUpdatedEvents,
+          councilBudgetRefills?.budgetRefillEvents,
+          councilSalaryPayouts?.rewardPaymentEvents,
+          workerPayments?.rewardPaidEvents,
+          councilSalaryChanges?.councilorRewardUdatedEvents,
+          openings?.openingAddedEvents,
+          openings?.openingCanceledEvents,
+          openings?.openingFilledEvents,
+          workerChanges?.terminatedWorkerEvents,
+          workerChanges?.workerExitedEvents,
+          workerChanges?.stakeSlashedEvents,
+          workerChanges?.workerRewardAmountUpdatedEvents,
+          workerChanges?.workerRoleAccountUpdatedEvents,
+        ].map((list) => filterEvents(list, c.electedAtBlock, c.endedAtBlock))}
+        {...c}
+      />
+    )
+  );
+};
+
+export default Rounds;

+ 7 - 1
src/components/index.ts

@@ -1,3 +1,4 @@
+export { default as NavBar } from "./NavBar";
 export { default as AppBar } from "./AppBar";
 export { default as Back } from "./Back";
 export { default as Bounties } from "./Bounties";
@@ -13,7 +14,7 @@ export { default as Forum } from "./Forum";
 export { default as LatestPost } from "./Forum/LatestPost";
 export { default as InfoTooltip } from "./Tooltip";
 export { default as Mint } from "./Mint";
-export { default as Spending } from "./Proposals/Spending";
+export { default as Spending } from "./Spending";
 export { default as Proposals } from "./Proposals";
 export { default as ProposalTable } from "./Proposals/ProposalTable";
 export { default as ProposalLink } from "./Proposals/ProposalLink";
@@ -39,6 +40,11 @@ export { default as KPI } from "./KPI";
 export { default as IssueTracker } from "./IssueTracker";
 export { default as Timeline } from "./Timeline";
 export { default as TableFromObject } from "./TableFromObject";
+export { default as Data } from "./Data";
+export { default as Openings } from "./Openings";
+export { default as Notes } from "./Notes";
+export { default as Settings } from "./Settings";
+export { default as Log } from "./Log";
 
 export { default as Spinner } from "./Spinner";
 export { default as Modals } from "./Modals";

+ 3 - 3
src/config.ts

@@ -1,10 +1,10 @@
 export const historyDepth = 336;
 export const domain = "https://pioneer.joystreamstats.live";
-export const wsLocation = "wss://olympia.joystreamstats.live/rpc";
+export const wsLocation = "wss://pl.joystreamstats.live/rpc";
 export const apiLocation = "https://joystreamstats.live/api"
 export const socketLocation = "/socket.io"
-export const hydraLocation = "https://ipfs.joystreamstats.live/graphql"
-export const queryNode= "https://ipfs.joystreamstats.live/graphql"
+export const hydraLocation = "https://pl.joystreamstats.live/graphql"
+export const queryNode= "https://pl.joystreamstats.live/graphql"
 //export const alternativeBackendApis = "http://localhost:3000"
 export const alternativeBackendApis = "https://validators.joystreamstats.live"
 export const tasksEndpoint = "https://api.joystreamstats.live/tasks"

+ 4 - 0
src/index.css

@@ -14,6 +14,10 @@ body {
     -moz-osx-font-smoothing: grayscale;
 }
 
+.dropdown-menu {
+    background-color: #000 !important;
+}
+
 a,
 a:link,
 a:visited {

+ 63 - 22
src/lib/util.ts

@@ -1,7 +1,47 @@
-import { IApplicant, IVote } from "../types";
+import axios from "axios";
 import moment from "moment";
+import { queryNode } from "../config";
+const unit = "tJoy";
 
-export const mJoy = (mJoy: number) => (mJoy / 1000000).toFixed(2);
+export const fixGroupName = (name = "") =>
+  name
+    .replace(/(operations)?WorkingGroup/, "")
+    .replace(/Alpha/, "builders")
+    .replace(/Beta/, "hr")
+    .replace(/Gamma/, "marketing");
+
+export const gb = (bytes: number) => (bytes / 1024 ** 3).toFixed() + `gb`;
+
+export const postQN = (query: string, url: string = queryNode) =>
+  axios
+    .post(url, { query })
+    .then(({ data }) => {
+      if (data.error) return fail(data.error);
+      console.debug(`postQN`, query, data.data);
+      return data.data;
+    })
+    .catch((e) => {
+      console.warn(`postQN: query failed: ${e.message}`, query);
+      return [];
+    });
+
+// JOY
+
+export const mJoy = (tJOY: number = 0, digits = 2) =>
+  (tJOY / 1000000).toFixed(digits) +
+  ` M ${unit} / $${(tJOY * 0.0015).toFixed()}`;
+
+export const formatJoy = (stake: number): String => {
+  if (stake >= 1000000) {
+    return `${(stake / 1000000).toFixed(4)} MJOY`;
+  }
+
+  if (stake >= 1000) {
+    return `${(stake / 1000).toFixed(4)} kJOY`;
+  }
+
+  return `${stake} JOY`;
+};
 
 // time
 export const formatDate = (time?: number) => {
@@ -19,27 +59,28 @@ export const exit = (log: (s: string) => void) => {
   process.exit();
 };
 
-// Election
+// sort
 
-export const calculateOtherVotes = (votes: IVote[], applicant: IApplicant) =>
-  votes
-    .filter((v) => `${v.candidateHandle}` === `${applicant.member.handle}`)
-    .reduce((othersStake: Number, vote: IVote) => {
-      return (
-        Number(othersStake) +
-        Number(vote.newStake) +
-        Number(vote.transferredStake)
-      );
-    }, 0);
-
-export const formatJoy = (stake: number): String => {
-  if (stake >= 1000000) {
-    return `${(stake / 1000000).toFixed(4)} MJOY`;
-  }
+export const sortDesc = (array, key) =>
+  array ? array.sort((a, b) => b[key] - a[key]) : [];
 
-  if (stake >= 1000) {
-    return `${(stake / 1000).toFixed(4)} kJOY`;
-  }
+// storage
 
-  return `${stake} JOY`;
+export const testBag = async (
+  endpoint: string,
+  objects: Object[] | null
+): Promise<[string]> => {
+  if (!endpoint) return `no endpoint given`;
+  if (!objects) return `warning`;
+  if (!objects.length) return ``;
+  const object = Math.round(Math.random() * (objects.length - 1));
+  const route = endpoint.includes(`storage`) ? `files` : `assets`;
+  const url = endpoint + `api/v1/${route}/${objects[object].id}`;
+  return axios
+    .head(url)
+    .then((data) => `success`)
+    .catch((e) => {
+      console.error(`testBag ${url}: ${e.message}`);
+      return `danger`;
+    });
 };

+ 21 - 0
src/state.ts

@@ -1,6 +1,8 @@
 import { domain } from "./config";
+import queries from "./queries";
 
 export const initialState = {
+  log: [],
   assets: [],
   connected: false,
   faq: [],
@@ -42,4 +44,23 @@ export const initialState = {
     validators: {},
   },
   media: {},
+  queries,
+  queriesUpdated: {},
+  notes: { index: "Notes are automatically saved in your browser." },
+  note: "index",
+  showNotes: false,
+  settings: {
+    connectApi: false,
+    connectSocket: true,
+    queryInterval: 60,
+    saveInterval: 10,
+    theme: "light",
+    expandNotes: false,
+    useStorage: true,
+    searches: [],
+    keysOfInterest: [],
+    useSidebar: true,
+    showLog: true,
+    showNotes: true,
+  },
 };

+ 20 - 0
src/types.ts

@@ -104,6 +104,7 @@ export interface FAQItem {
 }
 
 export interface IState {
+  log: { time: number; msg: string; data: unknown; level: string }[];
   assets: string[];
   connecting: boolean;
   loading: string;
@@ -137,6 +138,25 @@ export interface IState {
   editKpi: any; // TODO
   getMember: (m: string | number) => Member;
   groups: RoleSpending[];
+  queries: any[];
+  queriesUpdated: { [key: string]: number };
+  notes: { [key: string]: string };
+  note: string;
+  showNotes: boolean;
+  settings: {
+    connectApi: boolean;
+    connectSocket: boolean;
+    queryInterval: number;
+    saveInterval: number;
+    theme: string;
+    expandNotes: boolean;
+    useStorage: boolean;
+    searches: string[];
+    keysOfInterest: string[];
+    useSidebar: boolean;
+    showLog: boolean;
+    showNotes: boolean;
+  };
 }
 
 export interface RewardPoints {