feat: postage stamps support (#115)
* chore: release 0.3.0 * feat: added postage stamp table to list all stamps * feat: postage stamp modal to purchase stamps * feat: postage stamps provider * chore: added formik * chore: proper form state handling * chore: revert accidental release inclusion * chore: polishing identified when developing the upload functionality Co-authored-by: bee-worker <70210089+bee-worker@users.noreply.github.com>
This commit is contained in:
+9
-4
@@ -7,6 +7,7 @@ import CssBaseline from '@material-ui/core/CssBaseline'
|
||||
|
||||
import BaseRouter from './routes/routes'
|
||||
import { lightTheme, darkTheme } from './theme'
|
||||
import { Provider as StampsProvider } from './providers/Stamps'
|
||||
|
||||
const App = (): ReactElement => {
|
||||
const [themeMode, toggleThemeMode] = useState('light')
|
||||
@@ -33,10 +34,14 @@ const App = (): ReactElement => {
|
||||
return (
|
||||
<div className="App">
|
||||
<ThemeProvider theme={themeMode === 'light' ? lightTheme : darkTheme}>
|
||||
<CssBaseline />
|
||||
<Router>
|
||||
<BaseRouter />
|
||||
</Router>
|
||||
<StampsProvider>
|
||||
<>
|
||||
<CssBaseline />
|
||||
<Router>
|
||||
<BaseRouter />
|
||||
</Router>
|
||||
</>
|
||||
</StampsProvider>
|
||||
</ThemeProvider>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ReactElement, useEffect, useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
date: number | null
|
||||
}
|
||||
|
||||
export default function LastUpdate({ date }: Props): ReactElement {
|
||||
const [duration, setDuration] = useState<string>('never')
|
||||
|
||||
const refresh = () => {
|
||||
if (!date) setDuration('never')
|
||||
else setDuration(`${((Date.now() - date) / 1000).toFixed()} seconds ago`)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
const i = setInterval(refresh, 1000)
|
||||
|
||||
return () => clearInterval(i)
|
||||
}, [date])
|
||||
|
||||
return <span>Last Update: {duration}</span>
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Link, RouteComponentProps } from 'react-router-dom'
|
||||
import { createStyles, Theme, makeStyles } from '@material-ui/core/styles'
|
||||
import { ListItemText, ListItemIcon, ListItem, Divider, List, Drawer, Link as MUILink } from '@material-ui/core'
|
||||
import { OpenInNewSharp } from '@material-ui/icons'
|
||||
import { Activity, FileText, DollarSign, Share2, Settings } from 'react-feather'
|
||||
import { Activity, FileText, DollarSign, Share2, Settings, Layers } from 'react-feather'
|
||||
|
||||
import SwarmLogoOrange from '../assets/swarm-logo-orange.svg'
|
||||
import { Health } from '@ethersphere/bee-js'
|
||||
@@ -24,6 +24,12 @@ const navBarItems = [
|
||||
path: '/files/',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
label: 'Stamps',
|
||||
id: 'stamps',
|
||||
path: '/stamps/',
|
||||
icon: Layers,
|
||||
},
|
||||
{
|
||||
label: 'Accounting',
|
||||
id: 'accounting',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Peer,
|
||||
Topology,
|
||||
LastChequesForPeerResponse,
|
||||
PostageBatch,
|
||||
} from '@ethersphere/bee-js'
|
||||
|
||||
import { beeDebugApi, beeApi } from '../services/bee'
|
||||
@@ -429,3 +430,27 @@ export const useLatestBeeRelease = (): LatestBeeReleaseHook => {
|
||||
|
||||
return { latestBeeRelease, isLoadingLatestBeeRelease, error }
|
||||
}
|
||||
|
||||
export interface GetPostageStampsHook {
|
||||
postageStamps: PostageBatch[] | null
|
||||
isLoading: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export const useGetPostageStamps = (): GetPostageStampsHook => {
|
||||
const [postageStamps, setPostageStamps] = useState<PostageBatch[] | null>(null)
|
||||
const [isLoading, setLoading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
beeApi.stamps
|
||||
.getPostageStamps()
|
||||
.then(setPostageStamps)
|
||||
.catch(setError)
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return { postageStamps, isLoading, error }
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Table, TableBody, TableCell, TableContainer, TableRow, TableHead, Paper
|
||||
|
||||
import ClipboardCopy from '../../components/ClipboardCopy'
|
||||
import CashoutModal from '../../components/CashoutModal'
|
||||
import PeerDetailDrawer from './PeerDetail'
|
||||
import PeerDetailDrawer from '../../components/PeerDetail'
|
||||
import { Accounting } from '../../hooks/accounting'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import React, { ReactElement, useContext } from 'react'
|
||||
import Button from '@material-ui/core/Button'
|
||||
import Dialog from '@material-ui/core/Dialog'
|
||||
import DialogActions from '@material-ui/core/DialogActions'
|
||||
import DialogContent from '@material-ui/core/DialogContent'
|
||||
import DialogContentText from '@material-ui/core/DialogContentText'
|
||||
import CircularProgress from '@material-ui/core/CircularProgress'
|
||||
import DialogTitle from '@material-ui/core/DialogTitle'
|
||||
import BigNumber from 'bignumber.js'
|
||||
import { FormikHelpers, Form, Field, Formik } from 'formik'
|
||||
import { TextField } from 'formik-material-ui'
|
||||
import { beeApi } from '../../services/bee'
|
||||
import { Context } from '../../providers/Stamps'
|
||||
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'
|
||||
|
||||
interface FormValues {
|
||||
depth?: string
|
||||
amount?: string
|
||||
label?: string
|
||||
}
|
||||
type FormErrors = Partial<FormValues>
|
||||
const initialFormValues: FormValues = {
|
||||
depth: '',
|
||||
amount: '',
|
||||
label: '',
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
wrapper: {
|
||||
margin: theme.spacing(1),
|
||||
position: 'relative',
|
||||
},
|
||||
field: {
|
||||
marginTop: theme.spacing(1),
|
||||
marginBottom: theme.spacing(1),
|
||||
},
|
||||
buttonProgress: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
marginTop: -12,
|
||||
marginBottom: -12,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
interface Props {
|
||||
label?: string
|
||||
}
|
||||
|
||||
export default function FormDialog({ label }: Props): ReactElement {
|
||||
const classes = useStyles()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const { refresh } = useContext(Context)
|
||||
const handleClickOpen = () => setOpen(true)
|
||||
const handleClose = () => setOpen(false)
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialFormValues}
|
||||
onSubmit={async (values: FormValues, actions: FormikHelpers<FormValues>) => {
|
||||
try {
|
||||
if (!values.depth) return
|
||||
|
||||
const amount = BigInt(values.amount)
|
||||
const depth = Number.parseInt(values.depth)
|
||||
const options = values.label ? { label: values.label } : undefined
|
||||
await beeApi.stamps.buyPostageStamp(amount, depth, options)
|
||||
actions.resetForm()
|
||||
await refresh()
|
||||
handleClose()
|
||||
} catch (e) {
|
||||
// TODO: trigger notification with notistack
|
||||
console.error(`${e.message}`) // eslint-disable-line
|
||||
actions.setSubmitting(false)
|
||||
}
|
||||
}}
|
||||
validate={(values: FormValues) => {
|
||||
const errors: FormErrors = {}
|
||||
|
||||
// Depth
|
||||
if (!values.depth) errors.depth = 'Required field'
|
||||
else {
|
||||
const depth = new BigNumber(values.depth)
|
||||
|
||||
if (!depth.isInteger()) errors.depth = 'Depth must be an integer'
|
||||
else if (depth.isLessThan(16)) errors.depth = 'Minimal depth is 16'
|
||||
else if (depth.isGreaterThan(255)) errors.depth = 'Depth has to be at most 255'
|
||||
}
|
||||
|
||||
// Amount
|
||||
if (!values.amount) errors.amount = 'Required field'
|
||||
else {
|
||||
const amount = new BigNumber(values.amount)
|
||||
|
||||
if (!amount.isInteger()) errors.amount = 'Amount must be an integer'
|
||||
else if (amount.isLessThanOrEqualTo(0)) errors.amount = 'Amount must be greater than 0'
|
||||
}
|
||||
|
||||
// Label
|
||||
if (values.label && !/^[0-9a-z]*$/i.test(values.label)) errors.label = 'Label must be an alphanumeric string'
|
||||
|
||||
return errors
|
||||
}}
|
||||
>
|
||||
{({ submitForm, isValid, isSubmitting }) => (
|
||||
<Form>
|
||||
<Button variant="outlined" color="primary" onClick={handleClickOpen}>
|
||||
{label || 'Buy Postage Stamp'}
|
||||
{isSubmitting && <CircularProgress size={24} className={classes.buttonProgress} />}
|
||||
</Button>
|
||||
<Dialog open={open} onClose={handleClose} aria-labelledby="form-dialog-title">
|
||||
<DialogTitle id="form-dialog-title">Purchase new postage stamp</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Provide the depth, amount and optionally the label of the postage stamp. Please refer to the{' '}
|
||||
<a href="https://docs.ethswarm.org/docs/access-the-swarm/keep-your-data-alive" target="blank">
|
||||
official bee docs
|
||||
</a>{' '}
|
||||
to understand these values.
|
||||
</DialogContentText>
|
||||
<Field
|
||||
component={TextField}
|
||||
required
|
||||
name="depth"
|
||||
autoFocus
|
||||
label="Depth"
|
||||
fullWidth
|
||||
className={classes.field}
|
||||
/>
|
||||
<Field component={TextField} required name="amount" label="Amount" fullWidth className={classes.field} />
|
||||
<Field component={TextField} name="label" label="Label" fullWidth className={classes.field} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} color="primary">
|
||||
Cancel
|
||||
</Button>
|
||||
<div className={classes.wrapper}>
|
||||
<Button
|
||||
color="primary"
|
||||
disabled={isSubmitting || !isValid}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
onClick={submitForm}
|
||||
>
|
||||
Create
|
||||
{isSubmitting && <CircularProgress size={24} className={classes.buttonProgress} />}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { Table, TableBody, TableCell, TableContainer, TableRow, TableHead, Paper } from '@material-ui/core'
|
||||
|
||||
import ClipboardCopy from '../../components/ClipboardCopy'
|
||||
import PeerDetailDrawer from '../../components/PeerDetail'
|
||||
import { PostageBatch } from '@ethersphere/bee-js'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
table: {
|
||||
minWidth: 650,
|
||||
},
|
||||
values: {
|
||||
textAlign: 'right',
|
||||
fontFamily: 'monospace, monospace',
|
||||
},
|
||||
})
|
||||
interface Props {
|
||||
postageStamps: PostageBatch[] | null
|
||||
}
|
||||
|
||||
function StampsTable({ postageStamps }: Props): ReactElement | null {
|
||||
if (postageStamps === null) return null
|
||||
const classes = useStyles()
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper}>
|
||||
<Table className={classes.table} size="small" aria-label="Balances Table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Batch ID</TableCell>
|
||||
<TableCell align="right">Utilization</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{postageStamps.map(({ batchID, utilization }) => (
|
||||
<TableRow key={batchID}>
|
||||
<TableCell>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<small>
|
||||
<PeerDetailDrawer peerId={batchID} />
|
||||
</small>
|
||||
<ClipboardCopy value={batchID} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={classes.values}>{utilization}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default StampsTable
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ReactElement, useContext, useEffect } from 'react'
|
||||
import { Theme, createStyles, makeStyles } from '@material-ui/core/styles'
|
||||
import { Container, CircularProgress } from '@material-ui/core'
|
||||
|
||||
import StampsTable from './StampsTable'
|
||||
import TroubleshootConnectionCard from '../../components/TroubleshootConnectionCard'
|
||||
import CreatePostageStampModal from './CreatePostageStampModal'
|
||||
import LastUpdate from '../../components/LastUpdate'
|
||||
|
||||
import { useApiHealth, useDebugApiHealth } from '../../hooks/apiHooks'
|
||||
import { Context } from '../../providers/Stamps'
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
root: {
|
||||
width: '100%',
|
||||
display: 'grid',
|
||||
rowGap: theme.spacing(2),
|
||||
},
|
||||
actions: {
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
columnGap: theme.spacing(1),
|
||||
rowGap: theme.spacing(1),
|
||||
flex: '0 1 auto',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
export default function Accounting(): ReactElement {
|
||||
const classes = useStyles()
|
||||
|
||||
const { health, isLoadingHealth } = useApiHealth()
|
||||
const { nodeHealth, isLoadingNodeHealth } = useDebugApiHealth()
|
||||
const { stamps, isLoading, error, lastUpdate, start, stop } = useContext(Context)
|
||||
useEffect(() => {
|
||||
start()
|
||||
|
||||
return () => stop()
|
||||
}, [])
|
||||
|
||||
if (isLoadingHealth || isLoadingNodeHealth) {
|
||||
return (
|
||||
<Container style={{ textAlign: 'center', padding: '50px' }}>
|
||||
<CircularProgress />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
if (nodeHealth?.status !== 'ok' || !health) return <TroubleshootConnectionCard />
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
{error && (
|
||||
<Container style={{ textAlign: 'center', padding: '50px' }}>
|
||||
Error loading postage stamps details: {error.message}
|
||||
</Container>
|
||||
)}
|
||||
{!error && (
|
||||
<>
|
||||
<div className={classes.actions}>
|
||||
<CreatePostageStampModal />
|
||||
<LastUpdate date={lastUpdate} />
|
||||
<div style={{ height: '5px' }}>{isLoading && <CircularProgress />}</div>
|
||||
</div>
|
||||
<StampsTable postageStamps={stamps} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { PostageBatch } from '@ethersphere/bee-js'
|
||||
import { createContext, ReactChild, ReactElement, useEffect, useState } from 'react'
|
||||
import { beeApi } from '../services/bee'
|
||||
|
||||
interface ContextInterface {
|
||||
stamps: PostageBatch[] | null
|
||||
error: Error | null
|
||||
isLoading: boolean
|
||||
lastUpdate: number | null
|
||||
start: (frequency?: number) => void
|
||||
stop: () => void
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
const initialValues: ContextInterface = {
|
||||
stamps: null,
|
||||
error: null,
|
||||
isLoading: false,
|
||||
lastUpdate: null,
|
||||
start: () => {}, // eslint-disable-line
|
||||
stop: () => {}, // eslint-disable-line
|
||||
refresh: () => Promise.reject(),
|
||||
}
|
||||
|
||||
export const Context = createContext<ContextInterface>(initialValues)
|
||||
export const Consumer = Context.Consumer
|
||||
|
||||
interface Props {
|
||||
children: ReactChild
|
||||
}
|
||||
|
||||
export function Provider({ children }: Props): ReactElement {
|
||||
const [stamps, setStamps] = useState<PostageBatch[] | null>(initialValues.stamps)
|
||||
const [error, setError] = useState<Error | null>(initialValues.error)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(initialValues.isLoading)
|
||||
const [lastUpdate, setLastUpdate] = useState<number | null>(initialValues.lastUpdate)
|
||||
const [frequency, setFrequency] = useState<number | null>(null)
|
||||
|
||||
const refresh = async () => {
|
||||
// Don't want to refresh when already refreshing
|
||||
if (isLoading) return
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const stamps = await beeApi.stamps.getPostageStamps()
|
||||
|
||||
setStamps(stamps)
|
||||
setLastUpdate(Date.now())
|
||||
} catch (e) {
|
||||
setError(e)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const start = (freq = 30000) => setFrequency(freq)
|
||||
const stop = () => setFrequency(null)
|
||||
|
||||
// Start the update loop
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
|
||||
// Start autorefresh only if the frequency is set
|
||||
if (frequency) {
|
||||
const interval = setInterval(refresh, frequency)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [frequency])
|
||||
|
||||
return (
|
||||
<Context.Provider value={{ stamps, error, isLoading, lastUpdate, start, stop, refresh }}>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
@@ -7,11 +7,12 @@ import AppRoute from './AppRoute'
|
||||
import Dashboard from '../layout/Dashboard'
|
||||
|
||||
// pages
|
||||
import Status from '../pages/status/index'
|
||||
import Files from '../pages/files/index'
|
||||
import Peers from '../pages/peers/index'
|
||||
import Accounting from '../pages/accounting/index'
|
||||
import Settings from '../pages/settings/index'
|
||||
import Status from '../pages/status'
|
||||
import Files from '../pages/files'
|
||||
import Peers from '../pages/peers'
|
||||
import Accounting from '../pages/accounting'
|
||||
import Settings from '../pages/settings'
|
||||
import Stamps from '../pages/stamps'
|
||||
|
||||
const BaseRouter = (): ReactElement => (
|
||||
<Switch>
|
||||
@@ -20,6 +21,7 @@ const BaseRouter = (): ReactElement => (
|
||||
<AppRoute exact path="/peers/" layout={Dashboard} component={Peers} />
|
||||
<AppRoute exact path="/accounting/" layout={Dashboard} component={Accounting} />
|
||||
<AppRoute exact path="/settings/" layout={Dashboard} component={Settings} />
|
||||
<AppRoute exact path="/stamps/" layout={Dashboard} component={Stamps} />
|
||||
</Switch>
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
NodeAddresses,
|
||||
Peer,
|
||||
PingResponse,
|
||||
PostageBatch,
|
||||
PostageBatchOptions,
|
||||
Reference,
|
||||
Topology,
|
||||
WithdrawTokensResponse,
|
||||
@@ -41,6 +43,14 @@ export const beeApi = {
|
||||
return beeJSClient().downloadFile(hash)
|
||||
},
|
||||
},
|
||||
stamps: {
|
||||
getPostageStamps(): Promise<PostageBatch[]> {
|
||||
return beeJSClient().getAllPostageBatch()
|
||||
},
|
||||
buyPostageStamp(amount: bigint, depth: number, options: PostageBatchOptions = {}): Promise<Address> {
|
||||
return beeJSClient().createPostageBatch(amount, depth, options)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const beeDebugApi = {
|
||||
|
||||
Reference in New Issue
Block a user