diff --git a/Makefile b/Makefile index 6aeebae..4321ad2 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ ldflags= \ -X "github.com/mike76-dev/hostscore/internal/build.NodeBinaryName=hsd" \ -X "github.com/mike76-dev/hostscore/internal/build.NodeVersion=0.1.1" \ -X "github.com/mike76-dev/hostscore/internal/build.ClientBinaryName=hsc" \ --X "github.com/mike76-dev/hostscore/internal/build.ClientVersion=0.3.0" \ +-X "github.com/mike76-dev/hostscore/internal/build.ClientVersion=0.4.0" \ -X "github.com/mike76-dev/hostscore/internal/build.GitRevision=${GIT_DIRTY}${GIT_REVISION}" \ -X "github.com/mike76-dev/hostscore/internal/build.BuildTime=${BUILD_TIME}" diff --git a/cmd/hsc/api.go b/cmd/hsc/api.go index d19bff9..245f6bc 100644 --- a/cmd/hsc/api.go +++ b/cmd/hsc/api.go @@ -21,7 +21,10 @@ import ( "go.uber.org/zap" ) -var lowBalanceThreshold = types.Siacoins(200) +var ( + lowBalanceThreshold = types.Siacoins(200) + zeroBalanceThreshold = types.Siacoins(10) +) type APIResponse struct { Status string `json:"status"` @@ -73,6 +76,22 @@ type statusResponse struct { Nodes []nodeStatus `json:"nodes"` } +type priceChange struct { + Timestamp time.Time `json:"timestamp"` + RemainingStorage uint64 `json:"remainingStorage"` + TotalStorage uint64 `json:"totalStorage"` + Collateral types.Currency `json:"collateral"` + StoragePrice types.Currency `json:"storagePrice"` + UploadPrice types.Currency `json:"uploadPrice"` + DownloadPrice types.Currency `json:"downloadPrice"` +} + +type priceChangeResponse struct { + APIResponse + PublicKey types.PublicKey `json:"publicKey"` + PriceChanges []priceChange `json:"priceChanges"` +} + type nodeInteractions struct { Uptime time.Duration `json:"uptime"` Downtime time.Duration `json:"downtime"` @@ -197,6 +216,9 @@ func (api *portalAPI) buildHTTPRoutes() { router.GET("/benchmarks", func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { api.benchmarksHandler(w, req, ps) }) + router.GET("/changes", func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { + api.changesHandler(w, req, ps) + }) router.GET("/status", func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { api.statusHandler(w, req, ps) }) @@ -525,7 +547,7 @@ func (api *portalAPI) benchmarksHandler(w http.ResponseWriter, req *http.Request } func balanceStatus(balance types.Currency) string { - if balance.IsZero() { + if balance.Cmp(zeroBalanceThreshold) < 0 { return "empty" } if balance.Cmp(lowBalanceThreshold) < 0 { @@ -594,6 +616,48 @@ func (api *portalAPI) onlineHostsHandler(w http.ResponseWriter, req *http.Reques }) } +func (api *portalAPI) changesHandler(w http.ResponseWriter, req *http.Request, _ httprouter.Params) { + network := strings.ToLower(req.FormValue("network")) + if network == "" { + writeJSON(w, APIResponse{ + Status: "error", + Message: "network not provided", + }) + return + } + host := req.FormValue("host") + if host == "" { + writeJSON(w, APIResponse{ + Status: "error", + Message: "host not provided", + }) + return + } + var pk types.PublicKey + err := pk.UnmarshalText([]byte(host)) + if err != nil { + writeJSON(w, APIResponse{ + Status: "error", + Message: "invalid public key", + }) + return + } + pcs, err := api.getPriceChanges(network, pk) + if err != nil { + api.log.Error("couldn't get price changes", zap.String("network", network), zap.Stringer("host", pk), zap.Error(err)) + writeJSON(w, APIResponse{ + Status: "error", + Message: "internal error", + }) + return + } + writeJSON(w, priceChangeResponse{ + APIResponse: APIResponse{Status: "ok"}, + PublicKey: pk, + PriceChanges: pcs, + }) +} + func writeJSON(w http.ResponseWriter, obj interface{}) { w.Header().Set("Content-Type", "application/json; charset=utf-8") err := json.NewEncoder(w).Encode(obj) diff --git a/cmd/hsc/database.go b/cmd/hsc/database.go index b3f9342..233d20f 100644 --- a/cmd/hsc/database.go +++ b/cmd/hsc/database.go @@ -144,6 +144,26 @@ func (api *portalAPI) insertUpdates(node string, updates hostdb.HostUpdates) err } defer benchmarkStmt.Close() + priceChangeStmt, err := tx.Prepare(` + INSERT INTO price_changes ( + network, + public_key, + changed_at, + remaining_storage, + total_storage, + collateral, + storage_price, + upload_price, + download_price + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + if err != nil { + tx.Rollback() + return utils.AddContext(err, "couldn't prepare price change statement") + } + defer priceChangeStmt.Close() + for _, host := range updates.Hosts { var settings, pt bytes.Buffer e := types.NewEncoder(&settings) @@ -202,6 +222,35 @@ func (api *portalAPI) insertUpdates(node string, updates hostdb.HostUpdates) err } else { host, exists = api.hostsZen[h.PublicKey] } + if !exists || pricesChanged(h.Settings, host.Settings) { + var cb, spb, upb, dpb bytes.Buffer + e := types.NewEncoder(&cb) + types.V1Currency(h.Settings.Collateral).EncodeTo(e) + e.Flush() + e = types.NewEncoder(&spb) + types.V1Currency(h.Settings.StoragePrice).EncodeTo(e) + e.Flush() + e = types.NewEncoder(&upb) + types.V1Currency(h.Settings.UploadBandwidthPrice).EncodeTo(e) + e.Flush() + e = types.NewEncoder(&dpb) + types.V1Currency(h.Settings.DownloadBandwidthPrice).EncodeTo(e) + e.Flush() + _, err := priceChangeStmt.Exec( + h.Network, + h.PublicKey[:], + time.Now().Unix(), + h.Settings.RemainingStorage, + h.Settings.TotalStorage, + cb.Bytes(), + spb.Bytes(), + upb.Bytes(), + dpb.Bytes(), + ) + if err != nil { + api.log.Error("couldn't update price change", zap.String("network", h.Network), zap.Stringer("host", h.PublicKey), zap.Error(err)) + } + } if exists { host.NetAddress = h.NetAddress host.Blocked = h.Blocked @@ -333,6 +382,23 @@ func (api *portalAPI) isOnline(host portalHost) bool { return false } +// pricesChanged returns true if any relevant part of the host's settings has changed. +func pricesChanged(os, ns rhpv2.HostSettings) bool { + if ns.RemainingStorage != os.RemainingStorage || ns.TotalStorage != os.TotalStorage { + return true + } + if ns.StoragePrice.Cmp(os.StoragePrice) != 0 || ns.Collateral.Cmp(os.Collateral) != 0 { + return true + } + if ns.UploadBandwidthPrice.Cmp(os.UploadBandwidthPrice) != 0 { + return true + } + if ns.DownloadBandwidthPrice.Cmp(os.DownloadBandwidthPrice) != 0 { + return true + } + return false +} + // getHost retrieves the information about a specific host. func (api *portalAPI) getHost(network string, pk types.PublicKey) (host portalHost, err error) { var h *portalHost @@ -1061,3 +1127,62 @@ func (api *portalAPI) loadScans(hosts map[types.PublicKey]*portalHost, network s return nil } + +// getPriceChanges retrieves the historic price changes of the given host. +func (api *portalAPI) getPriceChanges(network string, pk types.PublicKey) (pcs []priceChange, err error) { + rows, err := api.db.Query(` + SELECT + changed_at, + remaining_storage, + total_storage, + collateral, + storage_price, + upload_price, + download_price + FROM price_changes + WHERE network = ? + AND public_key = ? + AND changed_at >= ? + ORDER BY changed_at ASC + `, network, pk[:], time.Now().AddDate(-1, 0, 0)) + if err != nil { + return nil, utils.AddContext(err, "couldn't query price changes") + } + defer rows.Close() + + for rows.Next() { + var ca int64 + var rs, ts uint64 + var cb, spb, upb, dpb []byte + if err := rows.Scan(&ca, &rs, &ts, &cb, &spb, &upb, &dpb); err != nil { + return nil, utils.AddContext(err, "couldn't decode price change") + } + + pc := priceChange{ + Timestamp: time.Unix(ca, 0), + RemainingStorage: rs, + TotalStorage: ts, + } + + d := types.NewBufDecoder(cb) + if (*types.V1Currency)(&pc.Collateral).DecodeFrom(d); d.Err() != nil { + return nil, utils.AddContext(err, "couldn't decode collateral") + } + d = types.NewBufDecoder(spb) + if (*types.V1Currency)(&pc.StoragePrice).DecodeFrom(d); d.Err() != nil { + return nil, utils.AddContext(err, "couldn't decode storage price") + } + d = types.NewBufDecoder(upb) + if (*types.V1Currency)(&pc.UploadPrice).DecodeFrom(d); d.Err() != nil { + return nil, utils.AddContext(err, "couldn't decode upload price") + } + d = types.NewBufDecoder(dpb) + if (*types.V1Currency)(&pc.DownloadPrice).DecodeFrom(d); d.Err() != nil { + return nil, utils.AddContext(err, "couldn't decode download price") + } + + pcs = append(pcs, pc) + } + + return +} diff --git a/init_portal.sql b/init_portal.sql index 67e5e6c..d5218b5 100644 --- a/init_portal.sql +++ b/init_portal.sql @@ -2,6 +2,7 @@ DROP TABLE IF EXISTS locations; DROP TABLE IF EXISTS scans; DROP TABLE IF EXISTS benchmarks; DROP TABLE IF EXISTS interactions; +DROP TABLE IF EXISTS price_changes; DROP TABLE IF EXISTS hosts; CREATE TABLE hosts ( @@ -66,6 +67,21 @@ CREATE TABLE benchmarks ( FOREIGN KEY (public_key) REFERENCES hosts(public_key) ); +CREATE TABLE price_changes ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + network VARCHAR(8) NOT NULL, + public_key BINARY(32) NOT NULL, + changed_at BIGINT NOT NULL, + remaining_storage BIGINT UNSIGNED NOT NULL, + total_storage BIGINT UNSIGNED NOT NULL, + collateral TINYBLOB NOT NULL, + storage_price TINYBLOB NOT NULL, + upload_price TINYBLOB NOT NULL, + download_price TINYBLOB NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (public_key) REFERENCES hosts(public_key) +); + CREATE TABLE locations ( public_key BINARY(32) NOT NULL, ip TEXT NOT NULL, diff --git a/web-demo/index.css b/web-demo/index.css index 846ec5e..fbf6b11 100644 --- a/web-demo/index.css +++ b/web-demo/index.css @@ -86,9 +86,15 @@ select, input { background-color: red; } +.host-benchmarks { + table-layout: fixed; +} + .host-benchmarks td, .host-benchmarks th { padding: 0.25rem 0.5rem; border: 1px solid black; + white-space: normal; + word-break: break-word; } .benchmark-pass::after { diff --git a/web-demo/index.js b/web-demo/index.js index f2bfac9..20e84f6 100644 --- a/web-demo/index.js +++ b/web-demo/index.js @@ -1,5 +1,5 @@ const apiBaseURL = '/hostscore/api'; -var locations = ['eu', 'us']; +var locations = ['eu', 'us', 'ap']; var hosts = []; var moreHosts = false; var offset = 0; @@ -115,8 +115,6 @@ function browseHost(obj) { list.classList.add('hidden'); let latencies = []; let benchmarks = []; - let from = new Date(); - from.setDate(from.getDate() - 1); fetch(apiBaseURL + '/scans?network=' + network + '&host=' + key + '&number=48&success=true', options) .then(response => response.json()) diff --git a/web/package-lock.json b/web/package-lock.json index e71a80c..37442f4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16,6 +16,7 @@ "@types/react": "^18.2.57", "@types/react-dom": "^18.2.19", "axios": "^1.6.7", + "chart.js": "^4.4.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.22.1", @@ -3217,6 +3218,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", @@ -5831,6 +5837,17 @@ "node": ">=10" } }, + "node_modules/chart.js": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.2.tgz", + "integrity": "sha512-6GD7iKwFpP5kbSD4MeRRRlTnQvxfQREy36uEtm1hzHzcOqwWx0YEHuspuoNlslu+nciLIB7fjjsHkUv/FzFcOg==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/check-types": { "version": "11.2.3", "resolved": "https://registry.npmjs.org/check-types/-/check-types-11.2.3.tgz", diff --git a/web/package.json b/web/package.json index 89d67a1..e6322d5 100644 --- a/web/package.json +++ b/web/package.json @@ -11,6 +11,7 @@ "@types/react": "^18.2.57", "@types/react-dom": "^18.2.19", "axios": "^1.6.7", + "chart.js": "^4.4.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.22.1", @@ -24,7 +25,7 @@ "test": "react-scripts test", "eject": "react-scripts eject" }, - "eslintConfig": { + "eslintConfig": { "extends": [ "react-app", "react-app/jest" diff --git a/web/src/api/helpers.ts b/web/src/api/helpers.ts index 1d0f3c6..8a814c4 100644 --- a/web/src/api/helpers.ts +++ b/web/src/api/helpers.ts @@ -61,4 +61,13 @@ export const convertPricePerBlock = (value: string) => { if (result < 1e3) return result.toFixed(0) + ' SC/TB/month' if (result < 1e6) return (result / 1e3).toFixed(1) + ' KS/TB/month' return (result / 1e6).toFixed(1) + ' MS/TB/month' -} \ No newline at end of file +} + +export const convertPriceRaw = (value: string) => { + if (value.length > 12) { + value = value.slice(0, value.length - 12) + '.' + value.slice(value.length - 12) + } else { + value = '0.' + '0'.repeat(12 - value.length) + value + } + return Number.parseFloat(value) +} diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 87bc151..714f334 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -3,11 +3,12 @@ import { Host, NodeStatus, HostScan, - HostBenchmark + HostBenchmark, + PriceChange } from './types' const apiBaseURL = process.env.REACT_APP_API_ENDPOINT -const locations = ['eu', 'us'] +const locations = ['eu', 'us', 'ap'] const excludedPaths = ['/about', '/status'] export const useLocations = () => (locations) @@ -97,5 +98,15 @@ export const getBenchmarks = async ( .catch(error => console.log(error)) } +export const getPriceChanges = async ( + network: string, + publicKey: string +): Promise<{ status: string, message: string, priceChanges: PriceChange[] }> => { + const url = '/changes?network=' + network + '&host=' + publicKey + return instance.get(url) + .then(response => response.data) + .catch(error => console.log(error)) +} + export * from './types' export * from './helpers' \ No newline at end of file diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 331650c..8b867aa 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -128,3 +128,13 @@ export type NodeStatus = { balanceMainnet: string, balanceZen: string } + +export type PriceChange = { + timestamp: string, + remainingStorage: number, + totalStorage: number, + collateral: string, + storagePrice: string, + uploadPrice: string, + downloadPrice: string +} \ No newline at end of file diff --git a/web/src/components/HostDetails/HostDetails.tsx b/web/src/components/HostDetails/HostDetails.tsx index a1fdf33..6ad8887 100644 --- a/web/src/components/HostDetails/HostDetails.tsx +++ b/web/src/components/HostDetails/HostDetails.tsx @@ -6,15 +6,18 @@ import { Host, HostScan, HostBenchmark, + PriceChange, getHost, getScans, getBenchmarks, + getPriceChanges, stripePrefix, useLocations } from '../../api' import { Button, HostInfo, + HostPrices, HostResults, Loader, NodeSelector @@ -33,11 +36,13 @@ export const HostDetails = (props: HostDetailsProps) => { const [host, setHost] = useState() const [scans, setScans] = useState([]) const [benchmarks, setBenchmarks] = useState([]) + const [priceChanges, setPriceChanges] = useState([]) const { hosts } = props const network = (window.location.pathname.toLowerCase().indexOf('zen') >= 0 ? 'zen' : 'mainnet') const [loadingHost, setLoadingHost] = useState(false) const [loadingScans, setLoadingScans] = useState(false) const [loadingBenchmarks, setLoadingBenchmarks] = useState(false) + const [loadingPriceChanges, setLoadingPriceChanges] = useState(false) const nodes = ['global'].concat(locations) const [node, setNode] = useState(nodes[0]) useEffect(() => { @@ -71,10 +76,18 @@ export const HostDetails = (props: HostDetailsProps) => { } setLoadingBenchmarks(false) }) + setLoadingPriceChanges(true) + getPriceChanges(network, publicKey || '') + .then(data => { + if (data && data.status === 'ok' && data.priceChanges) { + setPriceChanges(data.priceChanges) + } + setLoadingPriceChanges(false) + }) }, [network, publicKey, locations.length]) return (
- {loadingHost || loadingScans || loadingBenchmarks ? + {loadingHost || loadingScans || loadingBenchmarks || loadingPriceChanges ? : (host ? <> @@ -90,6 +103,10 @@ export const HostDetails = (props: HostDetailsProps) => { host={host} node={node} /> +
any, + moveRight: () => any, + zoomIn: () => any, + zoomOut: () => any +} + +export const Controls = (props: ControlProps) => { + return ( +
+ + + + +
+ ) +} \ No newline at end of file diff --git a/web/src/components/HostPrices/HostPrices.css b/web/src/components/HostPrices/HostPrices.css new file mode 100644 index 0000000..e7d0bef --- /dev/null +++ b/web/src/components/HostPrices/HostPrices.css @@ -0,0 +1,20 @@ +.host-prices-container { + display: flex; + flex-direction: column; + align-items: center; + border: 1px solid var(--borderLight); + margin-top: 1rem; + position: relative; +} + +.host-prices-container p { + margin-bottom: 0; +} + +.host-prices-dark { + border-color: var(--borderDark); +} + +.host-prices-dark p { + color: var(--textDark); +} diff --git a/web/src/components/HostPrices/HostPrices.tsx b/web/src/components/HostPrices/HostPrices.tsx new file mode 100644 index 0000000..7414057 --- /dev/null +++ b/web/src/components/HostPrices/HostPrices.tsx @@ -0,0 +1,386 @@ +import './HostPrices.css' +import { useRef, useState, useEffect } from 'react' +import { PriceChange, convertPriceRaw } from '../../api' +import { + Chart, + ChartData, + CategoryScale, + Filler, + LinearScale, + LineController, + LineElement, + PointElement, + Legend +} from 'chart.js' +import { Controls, ScaleOptions } from './Controls/Controls' + +Chart.register( + CategoryScale, + Filler, + LinearScale, + LineElement, + LineController, + PointElement, + Legend +) + +type HostPricesProps = { + darkMode: boolean, + data: PriceChange[] +} + +type Dataset = { + data: number[], + label: string, + yAxisID: string, + borderColor: string, + backgroundColor: string, + fill: boolean | string, + stepped: boolean | string, + pointRadius: number, + borderWidth: number, + order: number +} + +type PriceChartProps = { + data: PriceChange[], + scale: ScaleOptions, + setScale: (scale: ScaleOptions) => any, + maxTimestamp: number, + setMaxTimestamp: (maxTimestamp: number) => any, + darkMode: boolean +} + +const formatLabel = (point: Date, scale: ScaleOptions) => { + let res = point.toLocaleDateString() + switch (scale) { + case 'day': + let prefix = point.getHours() < 2 ? '' + res.slice(0, res.length - 5) + ' ' : '' + return prefix + point.getHours() + ':00' + case 'week': + let suffix = point.getDate() === 1 && point.getMonth() === 0 ? res.slice(res.length - 5) : '' + return res.slice(0, res.length - 5) + suffix + case 'month': + let suffix1 = point.getDate() <= 3 && point.getMonth() === 0 ? res.slice(res.length - 5) : '' + return res.slice(0, res.length - 5) + suffix1 + case 'year': + return '' + (point.getMonth() + 1) + '-' + point.getFullYear() + default: + return '' + } +} + +const scaling = (data: PriceChange[], maxTimestamp: number, scale: ScaleOptions) => { + let max = new Date(maxTimestamp) + max.setMinutes(0) + max.setSeconds(0) + let min = new Date(maxTimestamp) + min.setMinutes(0) + min.setSeconds(0) + let num = 1 + switch (scale) { + case 'day': + min.setDate(max.getDate() - 1) + num = 12 + break + case 'week': + min.setDate(max.getDate() - 7) + num = 7 + break + case 'month': + min.setMonth(max.getMonth() - 1) + num = 10 + break + case 'year': + min.setFullYear(max.getFullYear() - 1) + num = 12 + break + default: + } + let int = Math.floor((max.getTime() - min.getTime()) / num) + return { + minValue: min, + maxValue: max, + numPoints: num, + interval: int + } +} + +const newTimestamp = (data: PriceChange[], maxTimestamp: number, scale: ScaleOptions, forward: boolean) => { + const { maxValue, interval } = scaling(data, maxTimestamp, scale) + let oldTimestamp = maxValue.getTime() + return forward ? oldTimestamp + interval : oldTimestamp - interval +} + +const PriceChart = (props: PriceChartProps) => { + const formatData = (data: PriceChange[]): ChartData => { + if (data.length === 0) return { labels: [], datasets: [] } + const { minValue, numPoints, interval } = scaling(data, props.maxTimestamp, props.scale) + let datasets: Dataset[] = [] + let labels: string[] = [] + let remainingStorage: number[] = [] + let totalStorage: number[] = [] + let uploadPrice: number[] = [] + let downloadPrice: number[] = [] + let storagePrice: number[] = [] + let collateral: number[] = [] + let rs = 0 + let ts = 0 + let up = 0 + let dp = 0 + let sp = 0 + let col = 0 + let start = 0 + for (let i = 0; i < props.data.length; i++) { + if (minValue.getTime() < (new Date(props.data[i].timestamp)).getTime()) break + rs = props.data[i].remainingStorage / 1e12 + ts = props.data[i].totalStorage / 1e12 + up = convertPriceRaw(props.data[i].uploadPrice) + dp = convertPriceRaw(props.data[i].downloadPrice) + sp = convertPriceRaw(props.data[i].storagePrice) * 144 * 30 + col = convertPriceRaw(props.data[i].collateral) * 144 * 30 + start = i + } + remainingStorage.push(rs) + totalStorage.push(ts) + uploadPrice.push(up) + downloadPrice.push(dp) + storagePrice.push(sp) + collateral.push(col) + labels.push(formatLabel(minValue, props.scale)) + for (let i = 0; i < numPoints; i++) { + minValue.setTime(minValue.getTime() + interval) + for (let j = start; j < props.data.length; j++) { + if (minValue.getTime() < (new Date(props.data[j].timestamp)).getTime()) break + rs = props.data[j].remainingStorage / 1e12 + ts = props.data[j].totalStorage / 1e12 + up = convertPriceRaw(props.data[j].uploadPrice) + dp = convertPriceRaw(props.data[j].downloadPrice) + sp = convertPriceRaw(props.data[j].storagePrice) * 144 * 30 + col = convertPriceRaw(props.data[j].collateral) * 144 * 30 + start = j + } + remainingStorage.push(rs) + totalStorage.push(ts) + uploadPrice.push(up) + downloadPrice.push(dp) + storagePrice.push(sp) + collateral.push(col) + labels.push(formatLabel(minValue, props.scale)) + } + datasets.push({ + data: totalStorage, + label: 'Total Storage', + yAxisID: 'y1', + borderColor: 'rgba(0, 127, 127, 0.25)', + backgroundColor: 'rgba(0, 127, 127, 0.25)', + fill: true, + stepped: 'before', + pointRadius: 0, + borderWidth: 1, + order: 2 + }) + datasets.push({ + data: remainingStorage, + label: 'Remaining Storage', + yAxisID: 'y1', + borderColor: 'rgba(0, 255, 255, 0.25)', + backgroundColor: 'rgba(0, 255, 255, 0.25)', + fill: true, + stepped: 'before', + pointRadius: 0, + borderWidth: 1, + order: 1 + }) + datasets.push({ + data: uploadPrice, + label: 'Upload Price', + yAxisID: 'y', + borderColor: '#ff0000', + backgroundColor: 'transparent', + fill: false, + stepped: 'before', + pointRadius: 0, + borderWidth: 1, + order: 3 + }) + datasets.push({ + data: downloadPrice, + label: 'Download Price', + yAxisID: 'y', + borderColor: '#0000ff', + backgroundColor: 'transparent', + fill: false, + stepped: 'before', + pointRadius: 0, + borderWidth: 1, + order: 4 + }) + datasets.push({ + data: storagePrice, + label: 'Storage Price per Month', + yAxisID: 'y', + borderColor: props.darkMode ? '#ffffff' : '#000000', + backgroundColor: 'transparent', + fill: false, + stepped: 'before', + pointRadius: 0, + borderWidth: 1, + order: 5 + }) + datasets.push({ + data: collateral, + label: 'Collateral per Month', + yAxisID: 'y', + borderColor: '#00ff00', + backgroundColor: 'transparent', + fill: false, + stepped: 'before', + pointRadius: 0, + borderWidth: 1, + order: 6 + }) + return { labels, datasets } + } + + const chartRef = useRef(null) + + const canvasCallback = (canvas: HTMLCanvasElement | null) => { + if (!canvas) return + const ctx = canvas.getContext('2d') + if (ctx) { + if (chartRef.current) { + chartRef.current.destroy() + } + chartRef.current = new Chart(ctx, { + type: 'line', + data: formatData(props.data), + options: { + responsive: true, + scales: { + x: { + grid: { + color: props.darkMode ? 'rgba(127, 127, 127, 0.1)': 'rgba(0, 0, 0, 0.1)' + } + }, + y: { + title: { + display: true, + text: 'Price in SC/TB' + }, + type: 'linear', + position: 'left', + beginAtZero: true, + grid: { + color: props.darkMode ? 'rgba(127, 127, 127, 0.1)': 'rgba(0, 0, 0, 0.1)' + } + }, + y1: { + title: { + display: true, + text: 'Storage in TB' + }, + type: 'linear', + position: 'right', + beginAtZero: true, + grid: { + drawOnChartArea: false + } + } + }, + plugins: { + legend: { + display: true, + position: 'bottom' + } + } + } + }) + } + } + + useEffect(() => { + if (chartRef.current) { + chartRef.current.data = formatData(props.data) + chartRef.current.update() + } + // eslint-disable-next-line + }, [props.data]) + + return ( + + ) +} + +export const HostPrices = (props: HostPricesProps) => { + const [scale, setScale] = useState('day') + const [maxTimestamp, setMaxTimestamp] = useState((new Date()).getTime()) + const moveLeft = () => { + if (!props.data || props.data.length === 0) return + let ts = (new Date(props.data[0].timestamp)).getTime() + let nts = newTimestamp(props.data, maxTimestamp, scale, false) + if (nts > ts) { + setMaxTimestamp(nts) + } + } + const moveRight = () => { + if (!props.data || props.data.length === 0) return + let ts = (new Date(props.data[props.data.length - 1].timestamp)).getTime() + let nts = newTimestamp(props.data, maxTimestamp, scale, true) + if (nts <= ts) { + setMaxTimestamp(nts) + } + } + const zoomIn = () => { + switch (scale) { + case 'day': + break + case 'week': + setScale('day') + break + case 'month': + setScale('week') + break + case 'year': + setScale('month') + break + default: + } + } + const zoomOut = () => { + switch (scale) { + case 'day': + setScale('week') + break + case 'week': + setScale('month') + break + case 'month': + setScale('year') + break + case 'year': + break + default: + } + } + return ( +
+

Historic Price Development

+ + +
+ ) +} diff --git a/web/src/components/HostResults/HostResults.css b/web/src/components/HostResults/HostResults.css index 3d46d9a..41f57d5 100644 --- a/web/src/components/HostResults/HostResults.css +++ b/web/src/components/HostResults/HostResults.css @@ -41,6 +41,7 @@ padding-right: 0.5rem; padding-top: 0.25rem; padding-bottom: 0.25rem; + text-align: center; } .host-benchmarks-table tr:first-child > th { diff --git a/web/src/components/HostResults/HostResults.tsx b/web/src/components/HostResults/HostResults.tsx index 0aa1976..604b5df 100644 --- a/web/src/components/HostResults/HostResults.tsx +++ b/web/src/components/HostResults/HostResults.tsx @@ -137,7 +137,7 @@ export const HostResults = (props: HostResultsProps) => { - {props.benchmarks && + {props.benchmarks && benchmarkData.length > 0 && @@ -150,7 +150,7 @@ export const HostResults = (props: HostResultsProps) => { {benchmarkData.map((row, j) => ( {row.map((cell, i) => ( -
+ {cell &&