diff --git a/.vscode/settings.json b/.vscode/settings.json index 3b377ff..1546602 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,6 +21,7 @@ "appendhashes", "arraybuffer", "authorised", + "chartjs", "clientside", "commonmeta", "dont", diff --git a/package.json b/package.json index e3639cb..a8a2320 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "async-mutex": "^0.3.0", "base64-arraybuffer": "^0.2.0", "chart.js": "^2.9.4", + "classnames": "^2.2.6", "csv-stringify": "^5.6.1", "dayjs": "^1.10.4", "debug": "^4.3.1", @@ -73,6 +74,7 @@ }, "devDependencies": { "@craco/craco": "^6.1.1", + "@types/classnames": "^2.2.11", "@types/debug": "^4.1.5", "@types/file-saver": "^2.0.1", "@types/jest": "^26.0.20", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eab5221..65beb68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,7 @@ async-mutex: 0.3.0 base64-arraybuffer: 0.2.0 chart.js: 2.9.4 + classnames: 2.2.6 csv-stringify: 5.6.1 dayjs: 1.10.4 debug: 4.3.1 @@ -32,6 +33,7 @@ websocket-as-promised: 2.0.1 devDependencies: '@craco/craco': 6.1.1_react-scripts@4.0.2 + '@types/classnames': 2.2.11 '@types/debug': 4.1.5 '@types/file-saver': 2.0.1 '@types/jest': 26.0.20 @@ -2018,6 +2020,10 @@ dev: true resolution: integrity: sha512-kSjgDMZONiIfSH1Nxcr5JIRMwUetDki63FSQfpTCz8ogF3Ulqm8+mr5f78dUYs6vMiB6gBusQqfQmBvHZj/lwg== + /@types/classnames/2.2.11: + dev: true + resolution: + integrity: sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw== /@types/debug/4.1.5: dev: true resolution: @@ -13620,6 +13626,7 @@ '@testing-library/jest-dom': ^5.11.9 '@testing-library/react': ^11.2.5 '@testing-library/user-event': ^12.7.1 + '@types/classnames': ^2.2.11 '@types/debug': ^4.1.5 '@types/file-saver': ^2.0.1 '@types/jest': ^26.0.20 @@ -13641,6 +13648,7 @@ babel-plugin-lodash: ^3.3.4 base64-arraybuffer: ^0.2.0 chart.js: ^2.9.4 + classnames: ^2.2.6 craco-less: ^1.17.1 csv-stringify: ^5.6.1 dayjs: ^1.10.4 diff --git a/public/locales/en.json b/public/locales/en.json index 0ac9007..29e686f 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -239,9 +239,15 @@ "blockValueNextDecrease_plural": "Decreases by <1> in <3>{{count, number}} blocks", "blockValueReset": "Resets in <1>{{count, number}} block", "blockValueReset_plural": "Resets in <1>{{count, number}} blocks", + "blockValueEmptyDescription": "The block value increases when <1>names are purchased.", "blockDifficultyCardTitle": "Block Difficulty", - "blockDifficultyError": "There was an error fetching the block difficulty. See the console for details." + "blockDifficultyError": "There was an error fetching the block difficulty. See the console for details.", + "blockDifficultyHashRate": "Approx. <1 />", + "blockDifficultyHashRateTooltip": "Estimated combined network mining hash rate, based on the current work.", + "blockDifficultyChartWork": "Block Difficulty", + "blockDifficultyChartLinear": "Linear", + "blockDifficultyChartLog": "Logarithmic" }, "credits": { diff --git a/src/components/KristValue.less b/src/components/KristValue.less index e1fcba9..0022693 100644 --- a/src/components/KristValue.less +++ b/src/components/KristValue.less @@ -35,4 +35,12 @@ color: fade(@kw-green, 75%); } } + + &.krist-value-zero { + color: @text-color-secondary; + + .anticon svg, .krist-currency-long { + color: fade(@text-color-secondary, 60%); + } + } } diff --git a/src/components/KristValue.tsx b/src/components/KristValue.tsx index df206a4..f586276 100644 --- a/src/components/KristValue.tsx +++ b/src/components/KristValue.tsx @@ -2,6 +2,7 @@ // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import React from "react"; +import classNames from "classnames"; import { useSelector } from "react-redux"; import { RootState } from "../store"; @@ -15,17 +16,23 @@ long?: boolean; hideNullish?: boolean; green?: boolean; + highlightZero?: boolean; }; type Props = React.HTMLProps & OwnProps; -export const KristValue = ({ value, long, hideNullish, green, ...props }: Props): JSX.Element | null => { +export const KristValue = ({ value, long, hideNullish, green, highlightZero, ...props }: Props): JSX.Element | null => { const currencySymbol = useSelector((s: RootState) => s.node.currency.currency_symbol); if (hideNullish && (value === undefined || value === null)) return null; + const classes = classNames("krist-value", props.className, { + "krist-value-green": green, + "krist-value-zero": highlightZero && value === 0 + }); + return ( - + {(currencySymbol || "KST") === "KST" && } {(value || 0).toLocaleString()} {long && {currencySymbol || "KST"}} diff --git a/src/components/ws/SyncMOTD.tsx b/src/components/ws/SyncMOTD.tsx index 62a306f..f03687b 100644 --- a/src/components/ws/SyncMOTD.tsx +++ b/src/components/ws/SyncMOTD.tsx @@ -27,6 +27,7 @@ debug("motd: %s", data.motd); dispatch(nodeActions.setCurrency(data.currency)); + dispatch(nodeActions.setConstants(data.constants)); } /** Sync the MOTD with the Krist node on startup. */ diff --git a/src/krist/api/types.ts b/src/krist/api/types.ts index d9177dc..8505efb 100644 --- a/src/krist/api/types.ts +++ b/src/krist/api/types.ts @@ -47,6 +47,25 @@ }; } +export interface KristConstants { + wallet_version: number; + nonce_max_size: number; + name_cost: number; + min_work: number; + max_work: number; + work_factor: number; + seconds_per_block: number; +} +export const DEFAULT_CONSTANTS: KristConstants = { + wallet_version: 16, + nonce_max_size: 24, + name_cost: 500, + min_work: 100, + max_work: 100000, + work_factor: 0.025, + seconds_per_block: 60 +}; + export interface KristCurrency { address_prefix: string; name_suffix: string; @@ -66,16 +85,7 @@ mining_enabled: boolean; debug_enabled: boolean; - constants: { - wallet_version: number; - nonce_max_size: number; - name_cost: number; - min_work: number; - max_work: number; - work_factor: number; - seconds_per_block: number; - }; - + constants: KristConstants; currency: KristCurrency; } diff --git a/src/krist/wallets/WalletManager.ts b/src/krist/wallets/WalletManager.ts index 08ad7a2..5a9564c 100644 --- a/src/krist/wallets/WalletManager.ts +++ b/src/krist/wallets/WalletManager.ts @@ -31,10 +31,6 @@ else throw e; } - // Load the private keys from the wallets, dispatching the updates to the - // Redux store - // TODO - // Dispatch the auth state changes to the Redux store dispatch(actions.authMasterPassword(password)); } diff --git a/src/pages/dashboard/BlockDifficultyCard.tsx b/src/pages/dashboard/BlockDifficultyCard.tsx index bb2df05..5c829f2 100644 --- a/src/pages/dashboard/BlockDifficultyCard.tsx +++ b/src/pages/dashboard/BlockDifficultyCard.tsx @@ -2,35 +2,125 @@ // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import React, { useState, useEffect, useMemo } from "react"; -import { Card, Skeleton, Empty } from "antd"; +import { Card, Skeleton, Empty, Row, Col, Tooltip, Select } from "antd"; -import { useSelector } from "react-redux"; +import { useSelector, shallowEqual } from "react-redux"; import { RootState } from "../../store"; -import { useTranslation } from "react-i18next"; +import { useTranslation, Trans } from "react-i18next"; -// import { LineCanvas } from "@nivo/line"; -// import { SizeMe } from "react-sizeme"; import { Line } from "react-chartjs-2"; import { APIResponse } from "../../krist/api/types"; +import { throttle } from "lodash-es"; +import { estimateHashRate } from "../../utils/currency"; import { SmallResult } from "../../components/SmallResult"; -import { throttle } from "lodash-es"; +import { Statistic } from "./Statistic"; import Debug from "debug"; const debug = Debug("kristweb:block-difficulty-card"); +const DATA_FETCH_THROTTLE = 300; + +// ============================================================================= +// Chart.JS theming options +// ============================================================================= +const CHART_FILL_COLOUR = "#6495ed33"; +const CHART_LINE_COLOUR = "#6495ed"; +const CHART_GRID_COLOUR = "#434a6b"; +const CHART_FONT_COLOUR = "#8991ab"; + +const CHART_HEIGHT = 180; + +const CHART_OPTIONS_BASE = { + maintainAspectRatio: false, + + animation: { duration: 0 }, + hover: { animationDuration: 0 }, + responsiveAnimationDuration: 0, + + legend: { + display: false + }, + + elements: { + line: { tension: 0 } + }, + + tooltips: { + mode: "index", + intersect: false, + displayColors: false, + position: "nearest" + } +}; + +const CHART_DATASET_OPTIONS = { + backgroundColor: CHART_FILL_COLOUR, + borderColor: CHART_LINE_COLOUR, + borderWidth: 2, + pointRadius: 0 +}; + +const CHART_OPTIONS_X_AXIS = { + type: "time", + bounds: "data", + + ticks: { + maxTicksLimit: 12, + fontColor: CHART_FONT_COLOUR, + fontSize: 11, + padding: 8, + lineHeight: 0.5 // Push the tick text to the bottom + }, + + gridLines: { + drawBorder: true, + drawTicks: true, + color: CHART_GRID_COLOUR, + tickMarkLength: 1, + zeroLineWidth: 1, + zeroLineColor: CHART_GRID_COLOUR + } +}; + +const CHART_OPTIONS_Y_AXIS = { + type: "linear", + + ticks: { + maxTicksLimit: 6, + fontColor: CHART_FONT_COLOUR, + fontSize: 11, + suggestedMin: 100, + suggestedMax: 100000, + beginAtZero: true, + padding: 8 + }, + + gridLines: { + drawBorder: true, + drawTicks: true, + color: CHART_GRID_COLOUR, + tickMarkLength: 1, + zeroLineWidth: 1, + zeroLineColor: CHART_GRID_COLOUR + } +}; + export function BlockDifficultyCard(): JSX.Element { const { t } = useTranslation(); const syncNode = useSelector((s: RootState) => s.node.syncNode); const lastBlockID = useSelector((s: RootState) => s.node.lastBlockID); const work = useSelector((s: RootState) => s.node.detailedWork?.work); + const constants = useSelector((s: RootState) => s.node.constants, shallowEqual); const [workOverTime, setWorkOverTime] = useState<{ x: Date; y: number }[] | undefined>(); const [loading, setLoading] = useState(true); const [error, setError] = useState(); + const [chartMode, setChartMode] = useState<"linear" | "logarithmic">("linear"); + async function _fetchWorkOverTime(): Promise { try { debug("fetching work over time"); @@ -41,8 +131,13 @@ const data: APIResponse<{ work: number[] }> = await res.json(); if (!data.ok || data.error) throw new Error(data.error); - const processedWork = data.work.map((work, i, arr) => - ({ x: new Date(Date.now() - ((arr.length - i) * 60000)), y: work })); + // Convert the array indices to Dates, based on the fact that the array + // should contain one block per secondsPerBlock (typically 1440 elements, + // one per minute). This can be passed directly into Chart.JS. + const processedWork = data.work.map((work, i, arr) => ({ + x: new Date(Date.now() - ((arr.length - i) * (constants.seconds_per_block * 1000))), + y: work + })); setWorkOverTime(processedWork); } catch (err) { @@ -54,94 +149,41 @@ } const fetchWorkOverTime = useMemo(() => - throttle(_fetchWorkOverTime, 300, { leading: false, trailing: true }), []); + throttle(_fetchWorkOverTime, DATA_FETCH_THROTTLE, { leading: false, trailing: true }), []); + // Fetch the new work data whenever the sync node, block ID, or node constants + // change. This is usually only going to be triggered by the block ID + // changing, which is handled by WebsocketService. useEffect(() => { if (!syncNode) return; fetchWorkOverTime(); - }, [syncNode, lastBlockID]); + }, [syncNode, lastBlockID, constants, constants.seconds_per_block]); function chart(): JSX.Element { return ; } + /* In its own component to work with i18next Trans */ + function HashRate(): JSX.Element { + return + {estimateHashRate(work || 0, constants.seconds_per_block)} + ; + } + + function display(): JSX.Element | null { + if (!work || !workOverTime) return null; + + return + + {/* Current work */} + + + {/* Approximate network hash rate */} + +
+ + Approx. + +
+
+ + {/* Spacer to push the dropdown to the bottom */} +
+ + {/* Chart Y-axis scale dropdown */} + + + + {/* Work over time chart */} + {chart()} + ; + } + const isEmpty = !loading && error; return {error ? - : (workOverTime - ? chart() + : (work && workOverTime + ? display() : )} diff --git a/src/pages/dashboard/BlockValueCard.tsx b/src/pages/dashboard/BlockValueCard.tsx index 98d28d9..bd04348 100644 --- a/src/pages/dashboard/BlockValueCard.tsx +++ b/src/pages/dashboard/BlockValueCard.tsx @@ -67,6 +67,15 @@
} + + {/* Filler explanation when there are no unpaid names */} + {!hasNames && ( +
+ + The block value increases when names are purchased. + +
+ )} } ; diff --git a/src/pages/dashboard/DashboardPage.less b/src/pages/dashboard/DashboardPage.less index 00985a6..72ed7ab 100644 --- a/src/pages/dashboard/DashboardPage.less +++ b/src/pages/dashboard/DashboardPage.less @@ -203,12 +203,49 @@ margin: 0 @margin-xs; } } + + .dashboard-block-value-empty-description { + color: @text-color-secondary; + font-size: 90%; + margin-top: @margin-sm; + } } .dashboard-card-block-difficulty { .ant-card-body { height: 100%; } + + .left-col { + display: flex; + flex-direction: column; + + padding-right: @margin-sm; + + .difficulty-hash-rate { + flex: 0; + + color: @text-color-secondary; + font-size: 90%; + + &-rate { + color: @text-color; + font-size: 105%; + font-weight: bold; + } + } + + .spacer { + flex: 1; + } + + .chart-mode-dropdown { + flex: 0; + + width: 100%; + margin-top: @margin-sm; + } + } } .dashboard-list-item { diff --git a/src/pages/dashboard/TransactionItem.tsx b/src/pages/dashboard/TransactionItem.tsx index 7ef776f..bf6a015 100644 --- a/src/pages/dashboard/TransactionItem.tsx +++ b/src/pages/dashboard/TransactionItem.tsx @@ -152,10 +152,10 @@ {TYPES_SHOW_VALUE.includes(type) ? ( // Transaction value - + ) : tx.type === "name_transfer" && ( - // TODO: Transaction name + // Transaction name )} diff --git a/src/pages/dashboard/WalletItem.tsx b/src/pages/dashboard/WalletItem.tsx index f6e7b88..e15cce3 100644 --- a/src/pages/dashboard/WalletItem.tsx +++ b/src/pages/dashboard/WalletItem.tsx @@ -16,7 +16,7 @@ - +
; } diff --git a/src/pages/wallets/AddWalletModal.tsx b/src/pages/wallets/AddWalletModal.tsx index 89f569e..2059e39 100644 --- a/src/pages/wallets/AddWalletModal.tsx +++ b/src/pages/wallets/AddWalletModal.tsx @@ -47,7 +47,7 @@ if (editing && create) throw new Error("AddWalletModal: 'editing' and 'create' simultaneously, uh oh!"); - const initialFormat = "kristwallet"; // TODO: change for edit modal + const initialFormat = editing?.format || "kristwallet"; // Required to encrypt new wallets const { masterPassword } = useSelector((s: RootState) => s.walletManager, shallowEqual); @@ -63,7 +63,7 @@ const [form] = Form.useForm(); const passwordInput = useRef(null); const [calculatedAddress, setCalculatedAddress] = useState(); - const [formatState, setFormatState] = useState(editing?.format || initialFormat); + const [formatState, setFormatState] = useState(initialFormat); function closeModal() { form.resetFields(); // Make sure to generate another password on re-open diff --git a/src/store/actions/NodeActions.ts b/src/store/actions/NodeActions.ts index e9718f7..8afe8e4 100644 --- a/src/store/actions/NodeActions.ts +++ b/src/store/actions/NodeActions.ts @@ -2,7 +2,7 @@ // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import { createAction } from "typesafe-actions"; -import { KristWorkDetailed, KristCurrency } from "../../krist/api/types"; +import { KristWorkDetailed, KristCurrency, KristConstants } from "../../krist/api/types"; import * as constants from "../constants"; @@ -10,3 +10,4 @@ export const setLastBlockID = createAction(constants.LAST_BLOCK_ID)(); export const setDetailedWork = createAction(constants.DETAILED_WORK)(); export const setCurrency = createAction(constants.CURRENCY)(); +export const setConstants = createAction(constants.CONSTANTS)(); diff --git a/src/store/constants.ts b/src/store/constants.ts index 0ccf516..3b5b130 100644 --- a/src/store/constants.ts +++ b/src/store/constants.ts @@ -30,3 +30,4 @@ export const LAST_BLOCK_ID = "LAST_BLOCK_ID"; export const DETAILED_WORK = "DETAILED_WORK"; export const CURRENCY = "CURRENCY"; +export const CONSTANTS = "CONSTANTS"; diff --git a/src/store/reducers/NodeReducer.ts b/src/store/reducers/NodeReducer.ts index 8857f14..ec7d233 100644 --- a/src/store/reducers/NodeReducer.ts +++ b/src/store/reducers/NodeReducer.ts @@ -2,8 +2,8 @@ // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import { createReducer, ActionType } from "typesafe-actions"; -import { KristWorkDetailed, KristCurrency, DEFAULT_CURRENCY } from "../../krist/api/types"; -import { setSyncNode, setLastBlockID, setDetailedWork, setCurrency } from "../actions/NodeActions"; +import { KristWorkDetailed, KristCurrency, DEFAULT_CURRENCY, KristConstants, DEFAULT_CONSTANTS } from "../../krist/api/types"; +import { setSyncNode, setLastBlockID, setDetailedWork, setCurrency, setConstants } from "../actions/NodeActions"; import packageJson from "../../../package.json"; @@ -12,13 +12,15 @@ readonly detailedWork?: KristWorkDetailed; readonly syncNode: string; readonly currency: KristCurrency; + readonly constants: KristConstants; } export function getInitialNodeState(): State { return { lastBlockID: 0, syncNode: localStorage.getItem("syncNode") || packageJson.defaultSyncNode, - currency: DEFAULT_CURRENCY + currency: DEFAULT_CURRENCY, + constants: DEFAULT_CONSTANTS }; } @@ -38,5 +40,8 @@ .handleAction(setCurrency, (state: State, action: ActionType) => ({ ...state, currency: action.payload + })) + .handleAction(setConstants, (state: State, action: ActionType) => ({ + ...state, + constants: action.payload })); - diff --git a/src/utils/currency.ts b/src/utils/currency.ts index 793fd31..e03b1e2 100644 --- a/src/utils/currency.ts +++ b/src/utils/currency.ts @@ -1,3 +1,4 @@ +/* eslint-disable eol-last */ // Copyright (c) 2020-2021 Drew Lemmy // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt @@ -24,3 +25,23 @@ export const stripNameSuffix = (nameSuffix: string | undefined | null, inp: string): string => inp.replace(stripNameSuffixRegExp(nameSuffix), ""); + +/** + * Estimates the network mining hash-rate, returning it as a formatted string. + * + * TODO: Some people claimed they had a more accurate function for this. PRs + * welcome! + * + * @param work - The current block difficulty. + * @param secondsPerBlock - The number of seconds per block, as per the sync + * node's configuration. +*/ +export function estimateHashRate(work: number, secondsPerBlock: number): string { + // Identical to the function from KristWeb 1 + const rate = 1 / (work / (Math.pow(256, 6)) * secondsPerBlock); + if (rate === 0) return "0 H/s"; + + const sizes = ["H", "KH", "MH", "GH", "TH"]; + const i = Math.min(Math.floor(Math.log(rate) / Math.log(1000)), sizes.length); + return parseFloat((rate / Math.pow(1000, i)).toFixed(2)) + " " + sizes[i] + "/s"; +}