Rework project and also include checking monitoring

This commit is contained in:
Michael Reber 2025-05-09 22:00:00 +02:00
parent d51b815b92
commit 9fbbf821f0
3 changed files with 303 additions and 415 deletions

View File

@ -1,7 +1,23 @@
import { Component, Fragment } from 'react' import { Component, Fragment } from 'react'
import ReactTooltip from 'react-tooltip' import ReactTooltip from 'react-tooltip'
import { Container, HeaderContainer, InfosContainer, InfosWrapper, ItemsContainer, ActiveContainer, HistoryContainer, HistoryItem, HistoryItemsWrapper, LogoContainer, StatusContainer, StatusBulb, InfosRow } from './styled' import {
Container,
HeaderContainer,
InfosContainer,
InfosWrapper,
ItemsContainer,
ActiveContainer,
Spinner,
HistoryContainer,
HistoryItem,
HistoryItemsWrapper,
LogoContainer,
StatusContainer,
StatusBulb,
InfosRow,
ActiveChecking
} from './styled'
import API from './utils/API' import API from './utils/API'
import secondsToTimeString from './utils/timestring' import secondsToTimeString from './utils/timestring'
@ -10,105 +26,41 @@ import bytesToString from './utils/bytestring'
import Settings from './components/settings' import Settings from './components/settings'
import Job from './components/job' import Job from './components/job'
import Navigation from './components/navigation' import Navigation from './components/navigation'
//import FileBrowserMenu from './components/fileBrowserMenu'
class App extends Component { class App extends Component {
constructor() { constructor() {
super() super()
this.state = { this.state = {
stats: { stats: {
// bytes: 0, elapsedTime: 0,
//checking: [ transfers: 0,
// "a very very very long absurdly unneccesarily long name this is if you cant tell", bytes: 0,
// "a very very very long absurdly unneccesarily long name this is if you cant tell 2" errors: 0,
//] lastError: '',
// checks: 0, transferring: [],
// deletedDirs: 0, checking: []
// deletes: 0,
// elapsedTime: 0,
// errors: 0,
// eta: null,
// fatalError: false,
// renames: 0,
// retryError: false,
// speed: 0,
// totalBytes: 0,
// totalChecks: 0,
// totalTransfers: 0,
// transferTime: 0,
// transferring: [
// {
// bytes: 590673
// dstFs: "/mnt/backup01/data_replica"
// eta: 0
// group: "job/17553"
// name: "a very very very long absurdly unneccesarily long name this is if you cant tell"
// percentage: 100
// size: 590673
// speed: 806565.7017727684
// speedAvg: 574257.5080241694
// srcFs: "/mnt/main_storage/data"
// }
// ],
// transfers: 0,
}, },
//remotes: [{
// name: "gdrive",
// type: "drive",
// bytes: 84265292526
//}],
remotes: [], remotes: [],
mounts: [], mounts: [],
transferred: [], transferred: [], // history
version: { version: {},
// "arch": "amd64", endPointAvailable: true
// "decomposed": [
// 1,
// 56,
// 2
// ],
// "goTags": "none",
// "goVersion": "go1.16.8",
// "isBeta": false,
// "isGit": false,
// "linking": "static",
// "os": "linux",
// "version": "v1.56.2"
},
endPointAvailable: true,
renderBrowser: false
} }
this.infoInterval = null
this.infoInterval = undefined this.apiInterval = null
this.timeInterval = undefined
this.apiInterval = undefined
} }
componentDidMount = () => { componentDidMount() {
// fetch initial info
this.fetchRemotes() this.fetchRemotes()
this.fetchMounts() this.fetchMounts()
this.fetchVersionInfo() this.fetchVersionInfo()
this.fetchInfos() this.fetchInfos()
this.infoInterval = setInterval(this.fetchInfos, 2000)
// get server stats every 1 seconds this.apiInterval = setInterval(this.checkApiEndpoint, 5000)
if (this.infoInterval === undefined) this.infoInterval = setInterval(this.fetchInfos, 1000)
// make the view look more responsive by counting every second
// if (this.timeInterval === undefined) this.timeInterval = setInterval(() => {
// let { stats } = this.state
// stats.elapsedTime++
// this.setState({ stats })
// }, 1000)
// check api status every second
if (this.apiInterval === undefined) this.apiInterval = setInterval(this.checkApiEndpoint, 1000)
} }
// clear the intervals componentWillUnmount() {
componentWillUnmount = () => {
clearInterval(this.infoInterval) clearInterval(this.infoInterval)
clearInterval(this.timeInterval)
clearInterval(this.apiInterval) clearInterval(this.apiInterval)
} }
@ -120,21 +72,7 @@ class App extends Component {
checkApiEndpoint = () => { checkApiEndpoint = () => {
const status = API.getEndpointStatus() const status = API.getEndpointStatus()
if (status !== this.state.endPointAvailable) { if (status !== this.state.endPointAvailable) {
this.setState({ endPointAvailable: !this.state.endPointAvailable }) this.setState({ endPointAvailable: status })
const link = document.querySelectorAll("link[rel~='icon']");
link.forEach(v => {
let segments = v.href.split(".")
if (status) {
segments[0] = segments[0].substring(0, segments[0].length - 3)
} else {
segments[0] += "-gs"
}
v.href = segments.join(".")
})
// get info after api recovers
if (status) { if (status) {
this.fetchRemotes() this.fetchRemotes()
this.fetchMounts() this.fetchMounts()
@ -143,239 +81,187 @@ class App extends Component {
} }
} }
/**
* get the configured remotes
*/
fetchRemotes = () => { fetchRemotes = () => {
return API.request({ API.request({ url: '/config/dump', _group: 'ui' })
url: "/config/dump", .then(res => {
"_group": "ui" if (typeof res.data !== 'object') throw new Error()
}) const remotes = []
.then(response => { return Promise.all(
if (typeof response.data !== "object") throw new Error("invalid response") Object.keys(res.data).map(name =>
API.request({ url: '/operations/about', data: { fs: name + ':' } })
let remotes = [] .then(({ data }) => {
if (typeof data !== 'object' || isNaN(data.used)) throw new Error()
return Promise.all(Object.keys(response.data).map(v => { remotes.push({ name, type: res.data[name].type, bytes: data.used })
return new Promise((resolve, reject) => { })
return API.request({ )
url: "/operations/about", ).then(() => this.setState({ remotes }))
data: {
fs: v + ":"
}
})
.then(({data}) => {
if (typeof data !== "object" || isNaN(data.used)) return reject()
remotes.push({
name: v,
type: response.data[v].type,
bytes: data.used
})
return resolve()
})
.catch(reject)
})
}) })
) .catch(() => {})
.then(() => {
this.setState({ remotes })
})
.catch(err => console.error(err))
})
.catch(() => {})
} }
/**
* get the mounted volumes
*/
fetchMounts = () => { fetchMounts = () => {
return API.request({ API.request({ url: '/mount/listmounts' })
url: "/mount/listmounts" .then(res => {
}) if (!Array.isArray(res.data.mountPoints)) throw new Error()
.then(response => { this.setState({ mounts: res.data.mountPoints })
if (typeof response.data.mountPoints !== "object") throw new Error("invalid response") })
.catch(() => {})
this.setState({ mounts: response.data.mountPoints })
})
.catch(() => {})
} }
/**
* get software versions and architecture info
*/
fetchVersionInfo = () => { fetchVersionInfo = () => {
return API.request({ API.request({ url: '/core/version' })
url: "/core/version" .then(res => {
}) if (typeof res.data !== 'object') throw new Error()
.then(response => { this.setState({ version: res.data })
if (typeof response.data !== "object") throw new Error("invalid response") })
.catch(() => {})
this.setState({ version: response.data })
})
.catch(() => {})
} }
/**
* gets the server stats
*/
fetchInfos = () => { fetchInfos = () => {
return API.request({ API.request({ url: '/core/stats' })
url: "/core/stats" .then(({ data: stats }) => {
}) if (typeof stats !== 'object') throw new Error()
.then(response => {
if (typeof response.data !== "object") throw new Error("invalid response")
const stats = response.data
if (stats.transfers === 0) return this.setState({ stats }) // store latest stats
this.setState({ stats })
return API.request({ // update history if transfers > 0
url: "core/transferred" if (stats.transfers > 0) {
API.request({ url: '/core/transferred' })
.then(({ data }) => {
let history = Array.isArray(data.transferred)
? data.transferred
.sort((a, b) => new Date(b.completed_at) - new Date(a.completed_at))
.filter(v => v.error === '' && v.bytes > 0)
: []
history = history.slice(0, 20) // keep last 20
this.setState({ transferred: history })
})
.catch(() => {})
}
}) })
.then(response => { .catch(() => {})
if (typeof response.data.transferred !== "object") throw new Error("invalid response")
let transferred = []
response.data.transferred.sort((a,b) => new Date(b.completed_at) - new Date(a.completed_at)).forEach(v => {
if (v.error.length === 0 && v.bytes > 0) return transferred.push(v)
})
this.setState({ transferred, stats })
})
.catch(() => this.setState({ stats }))
})
.catch(() => {})
} }
/**
* renders the total speed of all transfers
* @returns {String}
*/
renderLiveSpeed = () => { renderLiveSpeed = () => {
const transferring = this.state.stats.transferring const list = Array.isArray(this.state.stats.transferring)
? this.state.stats.transferring
if (typeof transferring !== "object") return "0.00 MB/s"; : []
let speed = 0 let speed = 0
list.forEach(item => {
transferring.forEach(v => speed += v.speed) speed += typeof item?.speed === 'number' ? item.speed : 0
})
return bytesToString(speed, { speed: true }) return bytesToString(speed, { speed: true })
} }
/**
* renders the list of remotes
* @returns {Component}
*/
renderRemotes = () => { renderRemotes = () => {
const { remotes } = this.state const { remotes } = this.state
if (remotes.length === 0) return null if (!remotes.length) return null
return remotes.map(r => (
return remotes.map(v => (
<InfosRow <InfosRow
key={"mount" + v.name} key={r.name}
data-tip={bytesToString(v.bytes, { fixed: 2 })} data-tip={bytesToString(r.bytes, { fixed: 2 })}
data-for={"size"+v.MountPoint}> data-for={'size-' + r.name}
>
<p>{v.name}</p> <p>{r.name}</p>
<p>{v.type}</p> <p>{r.type}</p>
{/* add EDIT button */} <ReactTooltip
<ReactTooltip id={"size"+v.MountPoint} place="left" type="info" effect="solid" globalEventOff="click" /> id={'size-' + r.name}
place="left"
type="info"
effect="solid"
globalEventOff="click"
/>
</InfosRow> </InfosRow>
)) ))
} }
/**
* renders the list of mounts
* @returns {Component}
*/
renderMounts = () => { renderMounts = () => {
const { mounts } = this.state const { mounts } = this.state
if (mounts.length === 0) return null if (!mounts.length) return null
return mounts.map(m => (
return mounts.map(v => ( <Fragment key={m.MountPoint}>
<Fragment key={v.MountPoint}> <p>{m.Fs}</p>
<p>{v.Fs}</p> <p>{m.MountPoint}</p>
<p>{v.MountPoint}</p>
</Fragment> </Fragment>
)) ))
} }
/**
* Each job gets it's own component and is rendered if there are any
* Jobs are found by their transfer group
* @returns {Job}
*/
renderActiveJobs = () => { renderActiveJobs = () => {
const transferring = this.state.stats.transferring const { transferring = [], checking = [] } = this.state.stats
// collapse if nothing
if (!transferring.length && !checking.length) return null
// If it's not an array or empty, render nothing // group transfers by job
if (!Array.isArray(transferring) || transferring.length === 0) { const groups = transferring.reduce((acc, item) => {
return null if (!item.group) return acc
acc[item.group] = acc[item.group] || []
acc[item.group].push(item)
return acc
}, {})
// map to <Job>
const transferJobs = Object.entries(groups).map(([grp, arr]) => (
<Job
key={grp}
jobid={grp.replace(/\D/g, '')}
refreshStats={this.fetchInfos}
initialStats={{ transferring: arr }}
/>
))
// now render checking rows
const checkingRows = checking.map((name, i) => (
<ActiveChecking key={i}>
<p>{name}</p>
<p></p>
<p>Checking</p>
<p></p>
</ActiveChecking>
))
// if no transfers but checks present, just show checks
if (!transferJobs.length && checkingRows.length) {
return <Fragment>{checkingRows}</Fragment>
} }
// Collect unique, defined group IDs // otherwise show transfers then checks
const activeJobIds = Array.from( return (
new Set( <Fragment>
transferring {transferJobs}
.map(v => v.group) {checkingRows}
.filter(g => typeof g === 'string') </Fragment>
)
) )
return activeJobIds.map(group => {
// Protection: convert to string
const safeGroup = String(group)
const jobid = safeGroup.replace(/\D/g, '')
// All transfers in this group
const fileTransfers = transferring.filter(v => v.group === group)
return (
<Job
key={safeGroup}
fileTransfers={fileTransfers}
jobid={jobid}
refreshStats={this.fetchInfos}
/>
)
})
} }
/** renderHistory = () =>
* Renders all history items this.state.transferred.map((v, i) => (
* @returns {Component[]} <HistoryItem key={i}>
*/ <p>{v.name}</p>
renderHistory = () => { <p>{new Date(v.completed_at).toLocaleString()}</p>
return this.state.transferred.map((v, i) => (
<HistoryItem key={"transfer" + i}>
<p> { v.name } </p>
<p> { new Date(v.completed_at).toLocaleString() } </p>
</HistoryItem> </HistoryItem>
)) ))
}
render = () => { render() {
const { stats, version, endPointAvailable, remotes, mounts } = this.state const { stats, version, endPointAvailable, remotes, mounts } = this.state
const { elapsedTime, transfers, bytes, errors, lastError, transferring } = stats const { elapsedTime, transfers, bytes, errors, lastError, transferring } = stats
return ( return (
<Fragment> <Fragment>
{
// this.renderFileBrowser()
}
<HeaderContainer> <HeaderContainer>
<LogoContainer> <LogoContainer>
<img src="/favicon-64x64.png" alt="Rclone Dashboard" width="64" height="64" /> <img src="/favicon-64x64.png" alt="Rclone Dashboard" width="64" height="64" />
<h1> Rclone Dashboard </h1> <h1>Rclone Dashboard</h1>
</LogoContainer> </LogoContainer>
<StatusContainer> <StatusContainer>
<StatusBulb style={{ background: endPointAvailable ? "var(--status-green)" : "var(--status-red)" }} /> <StatusBulb
{ endPointAvailable ? "API endpoint is behaving normally" : "API endpoint is unavailable" } style={{
background: endPointAvailable ? 'var(--status-green)' : 'var(--status-red)'
}}
/>
{endPointAvailable
? 'API endpoint is behaving normally'
: 'API endpoint is unavailable'}
</StatusContainer> </StatusContainer>
</HeaderContainer> </HeaderContainer>
@ -383,65 +269,63 @@ class App extends Component {
<Container> <Container>
<ItemsContainer> <ItemsContainer>
<ActiveContainer> <ActiveContainer>
<h1> Active Jobs </h1> <h1>
{ this.renderActiveJobs() } Active Jobs
</ActiveContainer> {/* show spinner if any checking or transferring */}
{(
(this.state.stats.transferring?.length || 0) +
(this.state.stats.checking?.length || 0)
) > 0 && <Spinner />}
</h1>
{this.renderActiveJobs()}
</ActiveContainer>
<HistoryContainer> <HistoryContainer>
<h1> History </h1> <h1>History</h1>
<HistoryItemsWrapper> <HistoryItemsWrapper>{this.renderHistory()}</HistoryItemsWrapper>
{ this.renderHistory() }
</HistoryItemsWrapper>
</HistoryContainer> </HistoryContainer>
</ItemsContainer> </ItemsContainer>
<InfosContainer> <InfosContainer>
<InfosWrapper style={{ minHeight: "10rem" }}> <InfosWrapper style={{ minHeight: '10rem' }}>
<h2> Service Stats </h2> <h2>Service Stats</h2>
<p> Uptime </p> <p>Uptime</p>
<p> { secondsToTimeString(elapsedTime, true) } </p> <p>{secondsToTimeString(elapsedTime, true)}</p>
<p>Speed</p>
<p> Speed </p> <p>{this.renderLiveSpeed()}</p>
<p> { this.renderLiveSpeed() } </p> <p>Active transfers</p>
<p>{`${(transferring || []).length} files`}</p>
<p> Active transfers </p> <p>Total transferred files</p>
<p> { (transferring?.length ? transferring.length : 0) + (transferring?.length === 1 ? " file" : " files" )} </p> <p>{`${transfers || 0} files`}</p>
<p>Total transferred data</p>
<p> Total transfered files </p> <p>{bytesToString(bytes, {})}</p>
<p> { (transfers ? transfers : 0) + (transfers === 1 ? " file" : " files" )} </p>
<p> Total transferred data </p>
<p> { bytesToString(bytes, {}) } </p>
</InfosWrapper> </InfosWrapper>
<InfosWrapper> <InfosWrapper>
<h2> Environment </h2> <h2>Environment</h2>
<p> Rclone version </p> <p>Rclone version</p>
<p> { version.version } </p> <p>{version.version}</p>
<p>GO version</p>
<p> GO version </p> <p>{version.goVersion}</p>
<p> { version.goVersion } </p> <p>Architecture</p>
<p>{version.arch}</p>
<p> Architecture </p>
<p> { version.arch } </p>
</InfosWrapper> </InfosWrapper>
{remotes.length > 0 && ( {remotes.length > 0 && (
<InfosWrapper style={{ minHeight: "6rem" }}> <InfosWrapper style={{ minHeight: '6rem' }}>
<h2> Remotes </h2> {/* add NEW button */} <h2>Remotes</h2>
{ this.renderRemotes() } {this.renderRemotes()}
</InfosWrapper> </InfosWrapper>
)} )}
{mounts.length > 0 && ( {mounts.length > 0 && (
<InfosWrapper style={{ minHeight: "4.5rem" }}> <InfosWrapper style={{ minHeight: '4.5rem' }}>
<h2> Mounts </h2> {/* add NEW button */} <h2>Mounts</h2>
{ this.renderMounts() } {this.renderMounts()}
</InfosWrapper> </InfosWrapper>
)} )}
<Settings /> <Settings />
</InfosContainer> </InfosContainer>
</Container> </Container>
</Fragment> </Fragment>

View File

@ -1,132 +1,114 @@
import { Component, Fragment } from "react" import { Component, Fragment } from 'react'
import { ActiveJob, ActiveTransfer, StopButton } from "../styled" import { ActiveJob, ActiveTransfer, StopButton } from '../styled'
import API from '../utils/API' import API from '../utils/API'
import bytesToString from "../utils/bytestring" import bytesToString from '../utils/bytestring'
import secondsToTimeString from "../utils/timestring" import secondsToTimeString from '../utils/timestring'
class Job extends Component { class Job extends Component {
constructor() { constructor(props) {
super() super(props)
this.state = { this.state = {
jobid: props.jobid,
stats: { stats: {
elapsedTime: 0, elapsedTime: 0,
eta: 3600, eta: 0,
bytes: 1024 * 1024 * 128, bytes: 0,
totalBytes: 1024 * 1024 * 1024, totalBytes: 0,
transferring: [ transferring: props.initialStats?.transferring ?? []
{ }
speed: 23534245,
group: "job/1",
size: 23457826345,
eta: 3600,
name: "a very very very long absurdly unneccesarily long name this is if you cant tell"
}
]
},
jobid: 0
} }
this.fetchStatsInterval = null
this.decreaseEtaInterval = undefined this.decreaseEtaInterval = null
this.fetchStatsInterval = undefined
} }
componentDidMount = () => { componentDidMount() {
this.fetchStats() if (this.state.jobid) {
this.fetchStats()
// make the view look more responsive by counting every second this.fetchStatsInterval = setInterval(this.fetchStats, 5000)
if (this.decreaseEtaInterval === undefined) this.decreaseEtaInterval = setInterval(() => { }
let { stats } = this.state this.decreaseEtaInterval = setInterval(() => {
this.setState(({ stats }) => ({
if (stats.eta > 1) stats.eta-- stats: {
stats.elapsedTime++ ...stats,
stats.transferring?.forEach(v => {if (v.eta > 1) v.eta--}) elapsedTime: stats.elapsedTime + 1,
eta: Math.max(0, stats.eta - 1),
this.setState({ stats }) transferring: (stats.transferring || []).map(v => ({
...v,
eta: Math.max(0, v.eta - 1)
}))
}
}))
}, 1000) }, 1000)
// get job stats every 5 seconds
if (this.fetchStatsInterval === undefined) this.fetchStatsInterval = setInterval(this.fetchStats, 5 * 1000)
} }
// clear the intervals componentWillUnmount() {
componentWillUnmount = () => {
clearInterval(this.decreaseEtaInterval)
clearInterval(this.fetchStatsInterval) clearInterval(this.fetchStatsInterval)
clearInterval(this.decreaseEtaInterval)
} }
/** fetchStats = () =>
* get the stats from the job this component displays API.request({ url: '/core/stats', data: { group: `job/${this.state.jobid}` } })
*/ .then(({ data }) => {
fetchStats = () => { if (typeof data === 'object') {
return API.request({ this.setState({
url: "/core/stats", stats: {
data: { elapsedTime: data.elapsedTime ?? 0,
group: "job/" + this.props.jobid eta: data.eta ?? 0,
} bytes: data.bytes ?? 0,
}) totalBytes: data.totalBytes ?? 0,
.then(response => { transferring: Array.isArray(data.transferring) ? data.transferring : []
if (typeof response.data !== "object") return new Error("invalid response") }
})
}
})
.catch(() => {})
this.setState({ stats: response.data }) stopJob = () =>
}) API.request({ url: '/job/stop', data: { jobid: this.state.jobid } })
.catch(() => {}) .then(() => this.props.refreshStats())
} .catch(() => {})
/**
* Immediately cancels the current job
*/
stopJob = () => {
return API.request({
url: "/job/stop",
data: {
jobid: this.props.jobid
}
})
.then(() => this.props.refreshStats())
.catch(() => {})
}
/**
* render each transfer with info
*/
renderActiveTransfer = () => {
const { transferring } = this.state.stats
if (typeof transferring !== "object") return;
renderActiveTransfer() {
const { transferring = [] } = this.state.stats
if (!transferring.length) return null
return transferring.map(v => ( return transferring.map(v => (
<ActiveTransfer key={v.name}> <ActiveTransfer key={v.name}>
<p> { v.name } </p> <p>{v.name}</p>
<p> { bytesToString(v.size, {}) } </p> <p>{bytesToString(v.size, {})}</p>
<p> { secondsToTimeString(v.eta) } </p> <p>{secondsToTimeString(v.eta)}</p>
<p> { bytesToString(v.speed, { speed: true }) } </p> <p>{bytesToString(v.speed, { speed: true })}</p>
</ActiveTransfer> </ActiveTransfer>
)) ))
} }
render = () => { render() {
const { stats } = this.state const { stats, jobid } = this.state
return ( return (
<Fragment> <Fragment>
<ActiveJob>
<p>Time elapsed:</p>
<p>{secondsToTimeString(stats.elapsedTime)}</p>
<ActiveJob> <p>Progress:</p>
<p> Time elapsed: </p> <p>
<p> { secondsToTimeString(stats.elapsedTime) } </p> {bytesToString(stats.bytes, { format: 'GB', fixed: 3 })} /{' '}
<p> Progress: </p> {bytesToString(stats.totalBytes, { format: 'GB', fixed: 3 })} GB
<p> { bytesToString(stats.bytes, { format: "GB", fixed: 3 }) } / { bytesToString(stats.totalBytes, { format: "GB", fixed: 3 }) } GB </p> </p>
<p> Time left: </p> <p>Time left:</p>
<p> { secondsToTimeString(stats.eta) } </p> <p>{secondsToTimeString(stats.eta)}</p>
<p> Progress: </p>
<p> { ((stats.bytes / stats.totalBytes) * 100 || 0).toFixed(2) } % </p>
<p> Speed: </p> <p>Progress:</p>
<p> { bytesToString(stats.speed, { speed: true }) } </p> <p>{((stats.bytes / stats.totalBytes) * 100).toFixed(2)} %</p>
<StopButton onClick={this.stopJob}> Cancel </StopButton> <p>Speed:</p>
</ActiveJob> <p>{bytesToString(stats.speed, { speed: true })}</p>
{ this.renderActiveTransfer() }
{jobid ? <StopButton onClick={this.stopJob}>Cancel</StopButton> : null}
</ActiveJob>
{/* Transfers only */}
{this.renderActiveTransfer()}
</Fragment> </Fragment>
) )
} }

View File

@ -141,6 +141,28 @@ export const ActiveTransfer = styled.div`
} }
` `
/* Same grid but a softer colour so checks are distinguishable */
export const ActiveChecking = styled(ActiveTransfer)`
background: var(--tertiary-color-trans);
`;
export const Spinner = styled.div`
display: inline-block;
width: 1.2em; /* size relative to font */
height: 1.2em;
margin-left: 0.5em; /* space after the text */
vertical-align: middle; /* align with text baseline */
border: 0.2em solid var(--secondary-color-trans);
border-top: 0.2em solid var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`
export const HistoryContainer = styled(ActiveContainer)` export const HistoryContainer = styled(ActiveContainer)`
margin-top: 1rem; margin-top: 1rem;
` `