wrolpi/app/src/DashboardPage.js
lrnselfreliance 1554dfb268
A new database is refreshed on startup (#827)
Download and Upload are disabled until a full refresh completes, and a
fresh install never had one until the user found the refresh link.
Startup now queues it, and the dashboard says why Download and Upload
are disabled meanwhile.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 16:07:04 -06:00

503 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
CookiesLockedMessage,
ErrorMessage,
LoadStatistic,
PageContainer,
RefreshHeader,
SearchResultsInput,
useLocalStorage,
useTitle
} from "./components/Common";
import React, {useContext, useState} from "react";
import {useHotkeys} from "react-hotkeys-hook";
import {Media, SettingsContext, StatusContext} from "./contexts/contexts";
import {DownloadMenu} from "./components/Download";
import {
ActionInput,
Button,
ButtonGroup,
Divider,
Header,
Icon,
Message,
Modal,
Panel,
Stack,
Statistic,
StatisticGroup,
} from "./components/ui";
import {Link, useNavigate, useSearchParams} from "react-router";
import {BandwidthProgressCombined, CPUUsageProgress} from "./components/admin/Status";
import {ProgressPlaceholder} from "./components/Placeholder";
import {refreshFiles} from "./api";
import _ from "lodash";
import {TagsDashboard} from "./Tags";
import {UploadModal} from "./components/Upload";
import {SearchView, useSearch, useSearchSuggestions} from "./components/Search";
import {OutdatedZimsMessage} from "./components/Zim";
import {useSearchFilter, useSearchRecentFiles, useWROLMode} from "./hooks/customHooks";
import {ExtensionInstallSuggestion} from "./components/admin/ExtensionInstallSuggestion";
import {fileMimetypeFilterOptions, FileCards, SearchFilterButton} from "./components/Files";
import {useCalculators} from "./components/Calculators";
import {classifyAndEvaluate} from "./components/calculators/mathConfig";
// A spinning refresh glyph, used as the `icon` for the "files are being refreshed" Message.
const RefreshingIcon = (props) => <Icon name='circle notched' loading {...props}/>;
export function FlagsMessages() {
const {settings, fetchSettings} = React.useContext(SettingsContext);
const {status} = useContext(StatusContext);
if (_.isEmpty(status?.flags)) {
return <></>
}
const flags = status['flags'];
let refreshingMessage;
let refreshRequiredMessage;
// Do not tell the maintainer to refresh the files if the FileWorker is busy.
if (flags.file_worker_busy) {
// FileWorker is busy.
refreshingMessage = <Message kind='info' icon={RefreshingIcon} title='Your files are being refreshed.'>
<p><Link to='/files'>Click here to view the progress</Link></p>
</Message>;
} else if (!flags.refresh_complete) {
// `refresh_complete` flag is not set. Tell the maintainer to refresh the files.
refreshRequiredMessage = <Message kind='warning' icon='hand point right' title='Refresh required'>
<a href='#' onClick={(e) => {
e.preventDefault();
refreshFiles();
}}>Click here</a> to refresh all your files.
</Message>;
}
let dbDownMessage;
if (!flags.db_up) {
dbDownMessage = <ErrorMessage>
<strong>Database is down</strong>
<p>API is unable to connect to the database. Check the server logs.</p>
</ErrorMessage>
}
let internetDownMessage;
if (!flags.have_internet && !settings.wrol_mode) {
internetDownMessage = <ErrorMessage icon='globe'>
<strong>No Internet</strong>
<p>WROLPi has no Internet. Downloads will not start.</p>
</ErrorMessage>;
}
let mediaUnmountedMessage;
if (flags.media_mounted === false) {
mediaUnmountedMessage = <ErrorMessage icon='hdd'>
<strong>No drive mounted</strong>
<p>
WROLPi has no media drive mounted for one or more storage locations.
Downloads would write to the root filesystem and can fill it.
{' '}<Link to='/admin/controller'>Open the Controller</Link> to mount a drive.
</p>
</ErrorMessage>;
}
return <>
{refreshingMessage}
{dbDownMessage || refreshRequiredMessage}
{mediaUnmountedMessage}
{settings && settings['ignore_outdated_zims'] === false && flags.outdated_zims ?
<OutdatedZimsMessage onClick={fetchSettings}/> : null}
{internetDownMessage}
<CookiesLockedMessage/>
</>
}
function EvaluateForm() {
const helpContents = <span>
<p>Enter an equation or a value with units.</p>
<h5>Math:</h5>
<pre>2 + 3</pre>
<pre>sqrt(15)</pre>
<pre>8 * pi</pre>
<h5>Unit Conversions:</h5>
<pre>15 km to miles</pre>
<pre>65 degF to degC</pre>
<pre>1 gallon to liters</pre>
<pre>5 psi to atm</pre>
<h5>Auto-Convert (enter value + unit):</h5>
<pre>5 miles</pre>
<pre>100 degF</pre>
<pre>1 gallon</pre>
<pre>500 Wh</pre>
<h5>Radiation:</h5>
<pre>0.5 Sv</pre>
<pre>1 Gy to raddose</pre>
<pre>100 remdose to Sv</pre>
<h5>Data:</h5>
<pre>7 * 750 Mb</pre>
<pre>2 Tb to Gb</pre>
</span>
const [showMessage, setShowMessage] = React.useState(false);
const [inputValue, setInputValue] = React.useState('');
const [evaluatedValue, setEvaluatedValue] = React.useState(helpContents);
const doEvaluate = () => {
if (!inputValue) {
setEvaluatedValue(helpContents);
return;
}
setEvaluatedValue(''); // Clear input temporarily so the user can tell something happened.
try {
const {primary, conversions} = classifyAndEvaluate(inputValue);
setEvaluatedValue(<div>
<strong>{primary}</strong>
{conversions.length > 0 && <div style={{marginTop: '0.5em'}}>
<em>Also:</em>
{conversions.map((c, i) => <div key={i}>{c}</div>)}
</div>}
</div>);
} catch (error) {
console.error(error);
setEvaluatedValue(`Unable to evaluate: ${error.message}`);
}
}
return <form onSubmit={(e) => {
e.preventDefault();
doEvaluate();
}}>
<ActionInput
value={inputValue}
leftSection='𝒇'
type='text'
onFocus={() => setShowMessage(true)}
onChange={(e) => setInputValue(e.currentTarget.value)}
action={<Button type='submit' role='primary'>Evaluate</Button>}
/>
{showMessage &&
<Message kind='info' title='Result'>
{evaluatedValue}
</Message>
}
</form>
}
function DashboardCalculators() {
const {calculatorLinks} = useCalculators();
return <Panel>
<Header as='h2'>Calculators</Header>
<Stack>
<EvaluateForm/>
<div>{calculatorLinks}</div>
</Stack>
</Panel>
}
export function Getters() {
const {status} = useContext(StatusContext);
const wrolModeEnabled = useWROLMode();
// Getters are Downloads or Uploads.
const [selectedGetter, setSelectedGetter] = useState(null);
const gettersDisabled = status?.flags?.refresh_complete !== true;
// Deep-link support: the WROLPi browser extension (and anyone else) can open
// /?downloader=<archive|video|...>&download_url=<u1>&download_url=<u2>
// to auto-open the download modal pre-filled with a downloader and URLs.
const [searchParams, setSearchParams] = useSearchParams();
const initialDownloader = searchParams.get('downloader');
const initialUrls = searchParams.getAll('download_url');
React.useEffect(() => {
if (initialDownloader && selectedGetter !== 'downloads') {
setSelectedGetter('downloads');
}
// Run once on mount per change to query params.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialDownloader]);
const handleSetGetter = (e, value) => {
if (e) {
e.preventDefault();
}
setSelectedGetter(value);
// Closing the modal? Clear deep-link params so a refresh doesn't re-open.
if (value === null && (initialDownloader || initialUrls.length)) {
const next = new URLSearchParams(searchParams);
next.delete('downloader');
next.delete('download_url');
setSearchParams(next, {replace: true});
}
}
const getter = <Panel>
<div className='wrolpi-or-split'>
<div>
<Button role='primary' icon='download' onClick={e => handleSetGetter(e, 'downloads')}>
Download
</Button>
</div>
<div>
<Button role='save' icon='upload' onClick={e => handleSetGetter(e, 'upload')}>
Upload
</Button>
</div>
<span className='wrolpi-or-label'>Or</span>
</div>
{status?.flags && gettersDisabled &&
<p>Download and Upload are available after your files are refreshed.</p>}
</Panel>;
let getterModal;
if (selectedGetter === 'downloads') {
getterModal = <Modal size='large' closeIcon
open={true}
centered={false}
onClose={() => handleSetGetter(null, null)}
>
<Modal.Header>Download from the Internet</Modal.Header>
<Modal.Content>
<DownloadMenu
disabled={gettersDisabled}
initialDownloader={initialDownloader}
initialUrls={initialUrls}
/>
</Modal.Content>
</Modal>;
} else if (selectedGetter === 'upload') {
// The same dialog the file browser opens; it asks for a destination because, unlike the
// browser, the dashboard has no selected directory to upload into.
getterModal = <UploadModal
open={true}
onClose={() => handleSetGetter(null, null)}
disabled={gettersDisabled}
/>;
}
if (wrolModeEnabled) {
return <></>
}
return <>
{getter}
{getterModal}
</>
}
export function DashboardStatus() {
const {status} = useContext(StatusContext);
let percent = 0;
let load = {};
let cores = 0;
let pending_downloads = '?';
if (status && status['cpu_stats']) {
percent = status['cpu_stats']['percent'];
load = status['load_stats'];
cores = status['cpu_stats']['cores'];
}
const {downloads} = status;
if (!_.isEmpty(downloads)) {
pending_downloads = downloads && downloads['disabled'] ? 'x' : downloads['pending'];
}
let bandwidths = <ProgressPlaceholder/>;
if (status && status['nic_bandwidth_stats']) {
bandwidths = Object.entries(status['nic_bandwidth_stats'])
.map(([name, bandwidth]) => <BandwidthProgressCombined key={name} bandwidth={bandwidth}/>);
} else if (window.apiDown) { // apiDown is set in useStatus
bandwidths = null;
}
return <Panel>
{/*
* The panel is a link, but it should not read as one.
*
* `a` takes `color: var(--blue)` and an underline on hover from tokens.css, and a
* statistic's value sets no color of its own -- so the three load figures inherited
* the link color, and crossing the panel underlined them together. The same pair
* every card title uses says "this text belongs to what contains it": the pointer
* cursor still comes from the UA stylesheet, which is the affordance that matters.
*/}
<Link to='/admin/status' className='no-link-underscore card-link'>
<Header as='h2'>Status</Header>
<CPUUsageProgress value={percent} label='CPU Usage'/>
<StatisticGroup>
<LoadStatistic label='1 Min. Load' value={load['minute_1']} cores={cores}/>
<LoadStatistic label='5 Min. Load' value={load['minute_5']} cores={cores}/>
<LoadStatistic label='15 Min. Load' value={load['minute_15']} cores={cores}/>
</StatisticGroup>
<Header as='h3'>Bandwidth</Header>
{bandwidths}
</Link>
<Divider style={{marginTop: '3em'}}/>
<Link to='/admin' className='no-link-underscore card-link'>
<StatisticGroup>
<Statistic label='Downloading' value={pending_downloads}/>
</StatisticGroup>
</Link>
</Panel>;
}
function DashboardRecentFiles() {
// Show only the files the user is part way through.
const [continueOnly, setContinueOnly] = useLocalStorage('dashboard_recent_continue', false);
const {searchFiles, loading, fetchFiles} = useSearchRecentFiles(continueOnly);
let files = <FileCards files={searchFiles} loading={loading}/>;
if (continueOnly && searchFiles && searchFiles.length === 0) {
files = <p>Nothing in progress. A video, audio file, ebook or comic you stop part way through will be here.</p>;
}
return <Panel>
<RefreshHeader
header='Recently Viewed Files'
popupContents='Fetch the most recent files again'
onRefresh={fetchFiles}
/>
<ButtonGroup>
<Button size='small' variant={continueOnly ? 'default' : 'filled'} onClick={() => setContinueOnly(false)}>
All
</Button>
<Button size='small' variant={continueOnly ? 'filled' : 'default'} onClick={() => setContinueOnly(true)}>
Continue
</Button>
</ButtonGroup>
<div style={{marginTop: '1em'}}>{files}</div>
</Panel>
}
export function DashboardPage() {
const navigate = useNavigate();
const searchInputRef = React.useRef();
useHotkeys('f', (e) => {
e.preventDefault();
if (searchInputRef.current) {
searchInputRef.current.focus();
}
}, {enableOnFormTags: false});
// The search the user submitted.
const {searchStr, setSearchStr, activeTags, isEmpty, anyTag} = useSearch();
// The search that the user is typing.
const [localSearchStr, setLocalSearchStr] = React.useState(searchStr);
const {
suggestions,
suggestionsResults,
suggestionsSums,
handleResultSelect,
resultRenderer,
loading,
setSearchStr: setSuggestionSearchStr,
setSearchTags,
clearDate,
} = useSearchSuggestions(searchStr, activeTags, anyTag);
React.useEffect(() => {
setSuggestionSearchStr(localSearchStr);
}, [localSearchStr]);
React.useEffect(() => {
setLocalSearchStr(searchStr);
}, [searchStr]);
React.useEffect(() => {
if (activeTags) {
setSearchTags(activeTags);
}
}, [JSON.stringify(activeTags)]);
let title = 'Dashboard';
if (searchStr) {
title = `Search: ${searchStr} - Dashboard`;
} else if (activeTags && activeTags.length === 1) {
title = `Tag: ${activeTags[0]} - Dashboard`;
} else if (activeTags && activeTags.length > 1) {
const tagNames = activeTags.join(' & ');
title = `Tags: ${tagNames} - Dashboard`;
}
useTitle(title);
// Only show dashboard parts if not searching.
let body = <React.Fragment>
<Getters/>
<TagsDashboard/>
<DashboardStatus/>
<DashboardCalculators/>
<DashboardRecentFiles/>
</React.Fragment>;
if (searchStr || (activeTags && activeTags.length > 0)) {
// User has submitted and wants full search.
body = <SearchView suggestions={suggestions} suggestionsSums={suggestionsSums} loading={loading}/>;
}
const {setFilter} = useSearchFilter();
const clearAllSearch = () => {
clearDate();
setFilter(null);
setSearchStr(null);
navigate('/');
}
const getSearchResultsInput = (props) => {
return <SearchResultsInput clearable={!isEmpty}
searchStr={localSearchStr}
onChange={setLocalSearchStr}
onSubmit={setSearchStr}
placeholder='Search everywhere...'
onClear={clearAllSearch}
clearDisabled={isEmpty}
style={{marginBottom: '2em'}}
results={suggestionsResults}
handleResultSelect={handleResultSelect}
resultRenderer={resultRenderer}
loading={loading}
inputRef={searchInputRef}
{...props}
/>;
};
// The search input grows to fill the row; the Filter button sits immediately after it. Flexbox adapts
// to the client width, so only the control size changes between mobile and larger screens.
const searchRow = (big) => {
const inputProps = {style: {flexGrow: 1, minWidth: 0, marginBottom: 0}};
if (big) {
inputProps.size = 'big';
}
/*
* No `marginBottom`. This row is a top-level block of the dashboard, so its own 2em was
* added to the page stack's gap -- 52.8px below the search box against 17.6px between
* every other pair of blocks, on the most-looked-at page in the app. One source of
* spacing, and it is the stack.
*/
return <div style={{display: 'flex', alignItems: 'center', gap: '0.5em'}}>
{getSearchResultsInput(inputProps)}
<SearchFilterButton fileFilterOptions={fileMimetypeFilterOptions} showDates={true} showDeep={true}
size={big ? 'big' : undefined}/>
</div>;
};
return <PageContainer>
<Media at='mobile'>{searchRow(false)}</Media>
<Media greaterThanOrEqual='tablet'>{searchRow(true)}</Media>
{!searchStr && <FlagsMessages/>}
{!searchStr && <ExtensionInstallSuggestion/>}
{body}
</PageContainer>
}