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:
Vojtech Simetka
2021-06-02 13:13:27 +02:00
committed by GitHub
parent 9fee1aa68a
commit 4074a9de5d
14 changed files with 22086 additions and 90 deletions
+21640 -78
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -25,7 +25,7 @@
},
"dependencies": {
"@ethersphere/bee-js": "^0.9.0",
"@material-ui/core": "^4.11.3",
"@material-ui/core": "^4.11.4",
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "^4.0.0-alpha.57",
"@types/react-router": "^5.1.13",
@@ -33,6 +33,8 @@
"axios": "^0.21.1",
"bignumber.js": "^9.0.1",
"feather-icons": "^4.28.0",
"formik": "^2.2.8",
"formik-material-ui": "^3.0.1",
"material-ui-dropzone": "^3.5.0",
"opener": "^1.5.2",
"qrcode.react": "^1.0.1",
+9 -4
View File
@@ -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>
)
+23
View File
@@ -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>
}
+7 -1
View File
@@ -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',
+25
View File
@@ -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 }
}
+1 -1
View File
@@ -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>
)
}
+55
View File
@@ -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
+73
View File
@@ -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>
)
}
+76
View File
@@ -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 -5
View File
@@ -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>
)
+10
View File
@@ -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 = {