Ver código fonte

init Joystream Stats

traumschule 4 anos atrás
pai
commit
3b97a6a3c6

+ 3 - 1
package.json

@@ -1,8 +1,9 @@
 {
-  "name": "my-app",
+  "name": "joystreamstats",
   "version": "0.1.0",
   "private": true,
   "dependencies": {
+    "@joystream/types": "^0.13.1",
     "@testing-library/jest-dom": "^5.11.4",
     "@testing-library/react": "^11.1.0",
     "@testing-library/user-event": "^12.1.10",
@@ -11,6 +12,7 @@
     "@types/react": "^16.9.53",
     "@types/react-dom": "^16.9.8",
     "react": "^17.0.1",
+    "react-bootstrap": "^1.4.0",
     "react-dom": "^17.0.1",
     "react-scripts": "4.0.1",
     "typescript": "^4.0.3",

+ 1 - 1
public/index.html

@@ -5,7 +5,7 @@
     <meta name="viewport" content="width=device-width, initial-scale=1" />
     <meta name="theme-color" content="#000000" />
     <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
-    <title>React App</title>
+    <title>Joystream Stats</title>
   </head>
   <body>
     <noscript>You need to enable JavaScript to run this app.</noscript>

+ 131 - 2
src/App.tsx

@@ -1,7 +1,136 @@
 import React from "react";
+import { Dashboard, Loading } from "./components";
+//import { withRouter } from "react-router-dom";
+import moment from "moment";
+import * as get from "./lib/getters";
+import { wsLocation } from "./config";
 
-function App() {
-  return <div className="App"></div>;
+// types
+import { types } from "@joystream/types";
+import { ApiPromise, WsProvider } from "@polkadot/api";
+import { Header } from "@polkadot/types/interfaces";
+
+const initialState = { blocks: [], block: 0, loading: true };
+
+class App extends React.Component {
+  async initializeSocket() {
+    const provider = new WsProvider(wsLocation);
+    const api = await ApiPromise.create({ provider, types });
+    await api.isReady;
+
+    const [chain, node, version] = await Promise.all([
+      api.rpc.system.chain(),
+      api.rpc.system.name(),
+      api.rpc.system.version()
+    ]);
+    this.setState({ chain, node, version, loading: false });
+
+    let openingId = await get.nextOpeningId(api);
+    let nextWorkerId = await get.nextWorkerId(api);
+
+    let channels = [];
+    channels[0] = await get.currentChannelId(api);
+
+    let posts = [];
+    posts[0] = await get.currentPostId(api);
+    let categories = [];
+    categories[0] = await get.currentCategoryId(api);
+    let threads = [];
+    threads[0] = await get.currentThreadId(api);
+
+    let proposals = [];
+    proposals.last = await get.proposalCount(api);
+    proposals.active = await get.activeProposals(api);
+    proposals.executing = await get.pendingProposals(api);
+
+    this.setState({ channels, proposals, posts, categories, threads });
+
+    api.rpc.chain.subscribeNewHeads(
+      async (header: Header): Promise<void> => {
+        // current block
+        const id = header.number.toNumber();
+        if (lastBlock.id === id) return;
+        const timestamp = (await api.query.timestamp.now()).toNumber();
+        const duration = timestamp - lastBlock.timestamp;
+        const block = { id, timestamp, duration };
+        let { blocks, nominators, validators } = summary;
+        blocks = blocks.concat(block);
+        const summary = { blocks, nominators, validators };
+
+        // count nominators and validators
+        const nominatorsEntries: NominatorsEntries = await api.query.staking.nominators.entries();
+        const currentValidators = await api.query.staking.validatorCount();
+        nominators = nominators.concat(nominatorsEntries.length);
+        validators = validators.concat(currentValidators.toNumber());
+        summary = { blocks: [], nominators: [], validators: [] };
+
+        channels[1] = await get.currentChannelId(api);
+        proposals.current = await get.proposalCount(api);
+        cats[1] = await get.currentCategoryId(api);
+        posts[1] = await get.currentPostId(api);
+        threads[1] = await get.currentThreadId(api);
+        lastBlock = block;
+
+        // test storage providers
+        if (block.timestamp > lastCheck + checkPeriod) {
+          lastCheck = block.timestamp;
+        }
+        // new storage provider (or lead) opportunity is opened
+        // const nextOpeningId = await get.nextOpeningId(api);
+        // if (nextOpeningId > openingId) {
+        //   openingId = nextOpeningId;
+        // }
+
+        // storage provider (or lead) opportunity is closed
+        // const workerId = await get.nextWorkerId(api);
+        // if (workerId > nextWorkerId) {
+        //   const worker = await api.query.storageWorkingGroup.workerById(
+        //     workerId - 1
+        //   );
+        //   const memberId = worker.member_id.toJSON();
+        //   const handle: string = await get.memberHandle(api, memberId);
+        //   nextWorkerId = workerId;
+        // }
+        this.setState({ blocks, block: header.number.toNumber(), summary });
+      }
+    );
+  }
+
+  // async fetchData() {
+  //   // inital axios requests go here
+  //   this.setState({ loading: false });
+  // }
+
+  /** RENDER FUNCTIONS **/
+  renderLoading() {
+    return <Loading />;
+  }
+  renderError() {
+    if (this.state.showModal === "Error") return;
+    this.setShowModal("Error");
+  }
+  renderApp() {
+    return <Dashboard {...this.state} />;
+  }
+  render() {
+    if (this.state.loading) return this.renderLoading();
+    if (this.state.error) this.renderError();
+    if (this.state.component === "layout") return <Layout {...this.state} />;
+    return this.renderApp();
+  }
+
+  componentDidMount() {
+    this.initializeSocket();
+    //this.fetchData()
+  }
+  componentWillUnmount() {
+    console.log("unmounting...");
+  }
+  constructor() {
+    super();
+    this.state = initialState;
+  }
 }
 
 export default App;
+//export default withRouter(App);

+ 22 - 0
src/components/Dashboard/Blocks.tsx

@@ -0,0 +1,22 @@
+import moment from "moment";
+
+const Blocks = props => {
+  const blocks = props.blocks
+    .sort((a, b) => b.timestamp - a.timestamp)
+    .slice(0, 10);
+  return (
+    <div>
+      <h3>previous blocks</h3>
+      <div>
+        {blocks.map(b => (
+          <div>
+            {moment(b.timestamp).format("DD/MM/YYYY HH:mm:ss")}:{" "}
+            {b.number.toNumber()}
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+};
+
+export default Blocks;

+ 14 - 0
src/components/Dashboard/Nominators.tsx

@@ -0,0 +1,14 @@
+const Nominators = props => {
+  return (
+    <div>
+      <h3>Nominators</h3>
+      <div>
+        {props.nominators.map(n => (
+          <div>{n}</div>
+        ))}
+      </div>
+    </div>
+  );
+};
+
+export default Nominators;

+ 14 - 0
src/components/Dashboard/Validators.tsx

@@ -0,0 +1,14 @@
+const Validators = props => {
+  return (
+    <div>
+      <h3>Validators</h3>
+      <div>
+        {props.validators.map(v => (
+          <div>{v}</div>
+        ))}
+      </div>
+    </div>
+  );
+};
+
+export default Validators;

+ 22 - 0
src/components/Dashboard/index.tsx

@@ -0,0 +1,22 @@
+import React from "react";
+import Blocks from "./Blocks";
+import Nominators from "./Nominators";
+import Validators from "./Validators";
+
+const Dashboard = props => {
+  const { block, blocks, nominators, validators } = props;
+
+  return (
+    <div className="w-100 h-100 d-flex flex-grow-1 align-items-center justify-content-center d-flex flex-column">
+      <div>
+        <h3>latest block</h3>
+        <h2>{block}</h2>
+      </div>
+      <Blocks blocks={blocks} />
+      <Validators validators={validators} />
+      <Nominators nominators={nominators} />
+    </div>
+  );
+};
+
+export default Dashboard;

+ 12 - 0
src/components/Loading.tsx

@@ -0,0 +1,12 @@
+import React from "react";
+import { Spinner } from "react-bootstrap";
+
+const Loading = props => {
+  return (
+    <div className="w-100 h-100 d-flex flex-grow-1 align-items-center justify-content-center">
+      <Spinner animation="grow" variant="dark" />
+    </div>
+  );
+};
+
+export default Loading;

+ 2 - 0
src/components/index.ts

@@ -0,0 +1,2 @@
+export { default as Loading } from "./Loading";
+export { default as Dashboard } from "./Dashboard";

+ 280 - 0
src/lib/announcements.ts

@@ -0,0 +1,280 @@
+import {
+  Api,
+  Council,
+  Member,
+  ProposalDetail,
+  Proposals,
+  Summary,
+} from '../types'
+import { BlockNumber } from '@polkadot/types/interfaces'
+import { Channel, ElectionStage } from '@joystream/types/augment'
+import { Category, Thread, Post } from '@joystream/types/forum'
+import { formatTime } from './util'
+import {
+  categoryById,
+  memberHandle,
+  memberHandleByAccount,
+  proposalDetail,
+} from './getters'
+import moment from 'moment'
+
+const domain = 'testnet.joystream.org'
+
+const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
+
+// query API repeatedly to ensure a result
+const query = async (test: string, cb: () => Promise<any>): Promise<any> => {
+  let result = await cb()
+  for (let i: number = 0; i < 10; i++) {
+    if (result[test] !== '') return result
+    result = await cb()
+    await sleep(5000)
+  }
+}
+
+// announce latest channels
+export const channels = async (
+  api: Api,
+  channels: number[],
+  sendMessage: (msg: string) => void
+): Promise<number> => {
+  const [last, current] = channels
+  const messages: string[] = []
+
+  for (let id: number = +last + 1; id <= current; id++) {
+    const channel: Channel = await query('title', () =>
+      api.query.contentWorkingGroup.channelById(id)
+    )
+    const member: Member = { id: channel.owner, handle: '', url: '' }
+    member.handle = await memberHandle(api, member.id.toJSON())
+    member.url = `${domain}/#/members/${member.handle}`
+    messages.push(
+      `<b>Channel <a href="${domain}/#//media/channels/${id}">${channel.title}</a> by <a href="${member.url}">${member.handle} (${member.id})</a></b>`
+    )
+  }
+  sendMessage(messages.join('\r\n\r\n'))
+  return current
+}
+
+// announce council change
+export const council = async (
+  api: Api,
+  council: Council,
+  currentBlock: number,
+  sendMessage: (msg: string) => void
+): Promise<Council> => {
+  const round: number = await api.query.councilElection.round()
+  const stage: any = await api.query.councilElection.stage()
+  let stageString = Object.keys(JSON.parse(JSON.stringify(stage)))[0]
+  let msg = ''
+
+  if (!stage || stage.toJSON() === null) {
+    stageString = 'elected'
+    const councilEnd: BlockNumber = await api.query.council.termEndsAt()
+    const termDuration: BlockNumber = await api.query.councilElection.newTermDuration()
+    const block = councilEnd.toNumber() - termDuration.toNumber()
+    const remainingBlocks: number = councilEnd.toNumber() - currentBlock
+    const endDate = moment()
+      // .add(remainingBlocks * 6, "s")
+      .format('DD/MM/YYYY')
+    msg = `<a href="${domain}/#/council/members">Council ${round}</a> elected at block ${block} until block ${councilEnd}. Next election: ${endDate} (${remainingBlocks} blocks)`
+  } else {
+    if (stageString === 'Announcing') {
+      const announcingPeriod: BlockNumber = await api.query.councilElection.announcingPeriod()
+      msg = `Announcing election for round ${round} started.<a href="${domain}/#/council/applicants">Apply now!</a>`
+    } else if (stageString === 'Voting') {
+      const votingPeriod: BlockNumber = await api.query.councilElection.votingPeriod()
+      msg = `Voting stage for council election started. <a href="${domain}/#/council/applicants">Vote now!</a>`
+    } else if (stageString === 'Revealing') {
+      const revealingPeriod: BlockNumber = await api.query.councilElection.revealingPeriod()
+      msg = `Revealing stage for council election started. <a href="${domain}/#/council/votes">Don't forget to reveal your vote!</a>`
+    } else console.log(`[council] unrecognized stage: ${stageString}`)
+  }
+
+  if (round !== council.round && stageString !== council.last) sendMessage(msg)
+  return { round, last: stageString }
+}
+
+// forum
+// announce latest categories
+export const categories = async (
+  api: Api,
+  category: number[],
+  sendMessage: (msg: string) => void
+): Promise<number> => {
+  const messages: string[] = []
+
+  for (let id: number = +category[0] + 1; id <= category[1]; id++) {
+    const cat: Category = await query('title', () => categoryById(api, id))
+    const msg = `Category ${id}: <b><a href="${domain}/#/forum/categories/${id}">${cat.title}</a></b>`
+    messages.push(msg)
+  }
+
+  sendMessage(messages.join('\r\n\r\n'))
+  return category[1]
+}
+
+// announce latest posts
+export const posts = async (
+  api: Api,
+  posts: number[],
+  sendMessage: (msg: string) => void
+): Promise<number> => {
+  const [last, current] = posts
+  const messages: string[] = []
+
+  for (let id: number = +last + 1; id <= current; id++) {
+    const post: Post = await query('current_text', () =>
+      api.query.forum.postById(id)
+    )
+    const replyId: number = post.nr_in_thread.toNumber()
+    const message: string = post.current_text
+    const excerpt: string = message.substring(0, 100)
+    const threadId: number = post.thread_id.toNumber()
+    const thread: Thread = await query('title', () =>
+      api.query.forum.threadById(threadId)
+    )
+    const threadTitle: string = thread.title
+    const category: Category = await query('title', () =>
+      categoryById(api, thread.category_id.toNumber())
+    )
+    const handle = await memberHandleByAccount(api, post.author_id.toJSON())
+    const msg = `<b><a href="${domain}/#/members/${handle}">${handle}</a> posted <a href="${domain}/#/forum/threads/${threadId}?replyIdx=${replyId}">${threadTitle}</a> in <a href="${domain}/#/forum/categories/${category.id}">${category.title}</a>:</b>\n\r<i>${excerpt}</i> <a href="${domain}/#/forum/threads/${threadId}?replyIdx=${replyId}">more</a>`
+    messages.push(msg)
+  }
+
+  sendMessage(messages.join('\r\n\r\n'))
+  return current
+}
+
+// announce latest threads
+export const threads = async (
+  api: Api,
+  threads: number[],
+  sendMessage: (msg: string) => void
+): Promise<number> => {
+  const [last, current] = threads
+  const messages: string[] = []
+
+  for (let id: number = +last + 1; id <= current; id++) {
+    const thread: Thread = await query('title', () =>
+      api.query.forum.threadById(id)
+    )
+    const { title, author_id } = thread
+    const handle: string = await memberHandleByAccount(api, author_id.toJSON())
+    const category: Category = await query('title', () =>
+      categoryById(api, thread.category_id.toNumber())
+    )
+    const msg = `Thread ${id}: <a href="${domain}/#/forum/threads/${id}">"${title}"</a> by <a href="${domain}/#/members/${handle}">${handle}</a> in category "<a href="${domain}/#/forum/categories/${category.id}">${category.title}</a>" `
+    messages.push(msg)
+  }
+
+  sendMessage(messages.join('\r\n\r\n'))
+  return current
+}
+
+// announce latest proposals
+export const proposals = async (
+  api: Api,
+  prop: Proposals,
+  sendMessage: (msg: string) => void
+): Promise<Proposals> => {
+  let { current, last, active, executing } = prop
+
+  for (let id: number = +last + 1; id <= current; id++) {
+    const proposal: ProposalDetail = await proposalDetail(api, id)
+    const { createdAt, finalizedAt, message, parameters, result } = proposal
+    const votingEndsAt = createdAt + parameters.votingPeriod.toNumber()
+    const msg = `Proposal ${id} <b>created</b> at block ${createdAt}.\r\n${message}\r\nYou can vote until block ${votingEndsAt}.`
+    sendMessage(msg)
+    active.push(id)
+  }
+
+  for (const id of active) {
+    const proposal: ProposalDetail = await proposalDetail(api, id)
+    const { finalizedAt, message, parameters, result, stage } = proposal
+    if (stage === 'Finalized') {
+      let label: string = result
+      if (result === 'Approved') {
+        const executed = parameters.gracePeriod.toNumber() > 0 ? false : true
+        label = executed ? 'Executed' : 'Finalized'
+        if (!executed) executing.push(id)
+      }
+      const msg = `Proposal ${id} <b>${label}</b> at block ${finalizedAt}.\r\n${message}`
+      sendMessage(msg)
+      active = active.filter(a => a !== id)
+    }
+  }
+
+  for (const id of executing) {
+    const proposal = await proposalDetail(api, id)
+    const { exec, finalizedAt, message, parameters } = proposal
+    const execStatus = exec ? Object.keys(exec)[0] : ''
+    const label = execStatus === 'Executed' ? 'has been' : 'failed to be'
+    const block = +finalizedAt + parameters.gracePeriod.toNumber()
+    const msg = `Proposal ${id} <b>${label} executed</b> at block ${block}.\r\n${message}`
+    sendMessage(msg)
+    executing = executing.filter(e => e !== id)
+  }
+
+  return { current, last: current, active, executing }
+}
+
+// heartbeat
+
+const getAverage = (array: number[]) =>
+  array.reduce((a: number, b: number) => a + b, 0) / array.length
+
+export const heartbeat = async (
+  api: Api,
+  summary: Summary,
+  timePassed: string,
+  accountId: string,
+  sendMessage: (msg: string) => void
+): Promise<void> => {
+  const { blocks, nominators, validators } = summary
+  const avgDuration = blocks.reduce((a, b) => a + b.duration, 0) / blocks.length
+  const era: any = await api.query.staking.currentEra()
+  const totalStake: any = await api.query.staking.erasTotalStake(parseInt(era))
+  const stakers = await api.query.staking.erasStakers(parseInt(era), accountId)
+  const stakerCount = stakers.others.length
+  const avgStake = parseInt(totalStake.toString()) / stakerCount
+
+  console.log(`
+  Blocks produced during ${timePassed}h in era ${era}: ${blocks.length}
+  Average blocktime: ${Math.floor(avgDuration) / 1000} s
+  Average stake: ${avgStake / 1000000} M JOY (${stakerCount} stakers)
+  Average number of nominators: ${getAverage(nominators)}
+  Average number of validators: ${getAverage(validators)}`)
+}
+
+export const formatProposalMessage = (data: string[]): string => {
+  const [id, title, type, stage, result, handle] = data
+  return `<b>Type</b>: ${type}\r\n<b>Proposer</b>: <a href="${domain}/#/members/${handle}">${handle}</a>\r\n<b>Title</b>: <a href="${domain}/#/proposals/${id}">${title}</a>\r\n<b>Stage</b>: ${stage}\r\n<b>Result</b>: ${result}`
+}
+
+// providers
+
+export const provider = (
+  id: number,
+  address: string,
+  status: string,
+  sendMessage: (msg: string) => void
+): void => {
+  const msg = `[${formatTime()}] Storage Provider ${id} (${address}) is ${status}`
+  sendMessage(msg)
+}
+
+export const newOpening = (id: number, sendMessage: (msg: string) => void) => {
+  const msg = `New opening: <b><a href="${domain}/#/working-groups/opportunities/curators/${id}">Storage Provider ${id}</a></b>`
+  sendMessage(msg)
+}
+
+export const closeOpening = (
+  id: number,
+  handle: string,
+  sendMessage: (msg: string) => void
+): void => {
+  const msg = `<a href="${domain}/#/members/${handle}">${handle}</a> was choosen as <b><a href="${domain}/#/working-groups/opportunities/curators/${id}">Storage Provider ${id}</a></b>`
+  sendMessage(msg)
+}

+ 146 - 0
src/lib/getters.ts

@@ -0,0 +1,146 @@
+import { formatProposalMessage } from "./announcements";
+import fetch from "node-fetch";
+
+//types
+
+import { Api, ProposalArray, ProposalDetail } from "../types";
+import {
+  ChannelId,
+  PostId,
+  ProposalDetailsOf,
+  ThreadId
+} from "@joystream/types/augment";
+import { Category, CategoryId } from "@joystream/types/forum";
+import { MemberId, Membership } from "@joystream/types/members";
+import { Proposal } from "@joystream/types/proposals";
+
+// channel
+
+export const currentChannelId = async (api: Api): Promise<number> => {
+  const id: ChannelId = await api.query.contentWorkingGroup.nextChannelId();
+  return id.toNumber() - 1;
+};
+
+export const memberHandle = async (api: Api, id: MemberId): Promise<string> => {
+  const membership: Membership = await api.query.members.membershipById(id);
+  return membership.handle.toJSON();
+};
+
+export const memberHandleByAccount = async (
+  api: Api,
+  account: string
+): Promise<string> => {
+  const id: MemberId = await api.query.members.memberIdsByRootAccountId(
+    account
+  );
+  const handle: string = await memberHandle(api, id);
+  return handle;
+};
+
+// forum
+
+export const categoryById = async (api: Api, id: number): Promise<Category> => {
+  const category: Category = await api.query.forum.categoryById(id);
+  return category;
+};
+
+export const currentPostId = async (api: Api): Promise<number> => {
+  const postId: PostId = await api.query.forum.nextPostId();
+  return postId.toNumber() - 1;
+};
+
+export const currentThreadId = async (api: Api): Promise<number> => {
+  const threadId: ThreadId = await api.query.forum.nextThreadId();
+  return threadId.toNumber() - 1;
+};
+
+export const currentCategoryId = async (api: Api): Promise<number> => {
+  const categoryId: CategoryId = await api.query.forum.nextCategoryId();
+  return categoryId.toNumber() - 1;
+};
+
+// proposals
+
+export const proposalCount = async (api: Api): Promise<number> => {
+  const proposalCount: number = await api.query.proposalsEngine.proposalCount();
+  return proposalCount || 0;
+};
+
+const activeProposalCount = async (api: Api): Promise<number> => {
+  const proposalCount: number = await api.query.proposalsEngine.activeProposalCount();
+  return proposalCount || 0;
+};
+
+export const pendingProposals = async (api: Api): Promise<ProposalArray> => {
+  const pending: ProposalArray = await api.query.proposalsEngine.pendingExecutionProposalIds(
+    await activeProposalCount(api)
+  );
+  //const pending: ProposalArray = pendingProposals.toJSON();
+  if (pending.length) console.debug("pending proposals", pending);
+  return pending;
+};
+
+export const activeProposals = async (api: Api): Promise<ProposalArray> => {
+  const active: ProposalArray = await api.query.proposalsEngine.activeProposalIds(
+    await activeProposalCount(api)
+  );
+  //const active: ProposalArray = result.toJSON();
+  if (active.length) console.debug("active proposals", active);
+  return active;
+};
+
+const getProposalType = async (api: Api, id: number): Promise<string> => {
+  const details: ProposalDetailsOf = await api.query.proposalsCodex.proposalDetailsByProposalId(
+    id
+  );
+  const [type]: string[] = Object.getOwnPropertyNames(details.toJSON());
+  return type;
+};
+
+export const proposalDetail = async (
+  api: Api,
+  id: number
+): Promise<ProposalDetail> => {
+  const proposal: Proposal = await api.query.proposalsEngine.proposals(id);
+  const status: { [key: string]: any } = proposal.status;
+  const stage: string = status.isActive ? "Active" : "Finalized";
+  const { finalizedAt, proposalStatus } = status[`as${stage}`];
+  const result: string = proposalStatus
+    ? (proposalStatus.isApproved && "Approved") ||
+      (proposalStatus.isCanceled && "Canceled") ||
+      (proposalStatus.isExpired && "Expired") ||
+      (proposalStatus.isRejected && "Rejected") ||
+      (proposalStatus.isSlashed && "Slashed") ||
+      (proposalStatus.isVetoed && "Vetoed")
+    : "Pending";
+  const exec = proposalStatus ? proposalStatus["Approved"] : null;
+
+  const { parameters, proposerId } = proposal;
+  const author: string = await memberHandle(api, proposerId);
+  const title: string = proposal.title.toString();
+  const type: string = await getProposalType(api, id);
+  const args: string[] = [String(id), title, type, stage, result, author];
+  const message: string = formatProposalMessage(args);
+  const createdAt: number = proposal.createdAt.toNumber();
+  return { createdAt, finalizedAt, parameters, message, stage, result, exec };
+};
+
+// storage providers
+export const providerStatus = async (domain: string): Promise<boolean> => {
+  try {
+    const res = await fetch(`https://${domain}:5001/api/v0/version`);
+    return res.status >= 400 ? false : true;
+  } catch (e) {
+    return false;
+  }
+};
+
+export const nextOpeningId = async (api: Api): Promise<number> => {
+  const id = await api.query.storageWorkingGroup.nextOpeningId();
+  return id.toJSON();
+};
+
+export const nextWorkerId = async (api: Api): Promise<number> => {
+  const id = await api.query.storageWorkingGroup.nextWorkerId();
+  return id.toJSON();
+};

+ 57 - 0
src/lib/util.ts

@@ -0,0 +1,57 @@
+import { Api, Options, Proposals } from "../types";
+import moment from "moment";
+
+export const parseArgs = (args: string[]): Options => {
+  const inArgs = (term: string): boolean => {
+    return args.find(a => a.search(term) > -1) ? true : false;
+  };
+
+  const options: Options = {
+    verbose: inArgs("--verbose") ? 2 : inArgs("--quiet") ? 0 : 1,
+    channel: inArgs("--channel"),
+    council: inArgs("--council"),
+    forum: inArgs("--forum"),
+    proposals: inArgs("--proposals")
+  };
+
+  if (options.verbose > 1) console.debug("args", args, "\noptions", options);
+  return options;
+};
+
+export const printStatus = (
+  opts: Options,
+  data: {
+    block: number;
+    cats: number[];
+    chain: string;
+    posts: number[];
+    proposals: Proposals;
+    threads: number[];
+  }
+): void => {
+  if (opts.verbose < 1) return;
+
+  const { block, chain, proposals, cats, posts, threads } = data;
+  const date = formatTime();
+  let message = `[${date}] Chain:${chain} Block:${block} `;
+
+  if (opts.forum)
+    message += `Post:${posts[1]} Cat:${cats[1]} Thread:${threads[1]} `;
+
+  if (opts.proposals)
+    message += `Proposals:${proposals.current} (Active:${proposals.active.length} Pending:${proposals.executing.length}) `;
+
+  console.log(message);
+};
+
+// time
+export const formatTime = (time?: any): string =>
+  moment(time).format("H:mm:ss");
+
+export const passedTime = (start: number, now: number): string =>
+  formatTime(moment.utc(moment(now).diff(moment(start))));
+
+export const exit = (log: (s: string) => void) => {
+  log("\nNo connection, exiting.\n");
+  process.exit();
+};