openwrt-firmware-selector/src/containers/home/components/ProfileDetails.tsx
2021-03-21 18:40:44 +05:30

457 lines
16 KiB
TypeScript

import React, { FunctionComponent, ReactNode, useCallback, useEffect, useState } from 'react';
import {
Box,
Button,
Chip,
CircularProgress,
FormControl,
Grid,
IconButton,
Input,
InputAdornment,
InputLabel,
Link,
makeStyles,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from '@material-ui/core';
import { Launch, CloudDownload, Add } from '@material-ui/icons';
import axios from 'axios';
import { isEqual } from 'lodash';
import { useTranslation } from 'react-i18next';
import { DateTime } from 'luxon';
import { ProfilesEntity } from '../../../types/overview';
import { Profile, TitlesEntity } from '../../../types/profile';
import config from '../../../config';
import { getTitle } from '../utils/title';
import asu from '../../../utils/asu';
import { GetBuildResponse } from '../../../types/asu';
const useStyles = makeStyles(() => ({
chip: {
'&:focus': {
border: `2px solid #000`,
},
},
}));
type Props = {
selectedVersion: string;
selectedProfile: ProfilesEntity;
};
const profilesData: { [key: string]: Profile } = {};
const ProfileDetails: FunctionComponent<Props> = ({ selectedVersion, selectedProfile }) => {
const classes = useStyles();
const [profile, setProfile] = useState<Profile>();
const [showAddPackages, setShowAddPackages] = useState(false);
const [customPackages, setCustomPackages] = useState<Set<string>>(new Set());
const [customPackagesInputValue, setCustomPackagesInputValue] = useState<string>('');
const [showPackageExistsError, setShowPackageExistsError] = useState(false);
const [buildStatus, setBuildStatus] = useState<React.ReactNode>();
const [buildResponse, setBuildResponse] = useState<GetBuildResponse>();
const [buildError, setBuildError] = useState<string>();
const { t } = useTranslation();
const getHelpKey = (type: string) => {
const lc = type.toLowerCase();
if (lc.includes('sysupgrade')) {
return 'sysupgrade-help';
}
if (lc.includes('factory') || lc === 'trx' || lc === 'chk') {
return 'factory-help';
}
if (lc.includes('kernel') || lc.includes('zimage') || lc.includes('uimage')) {
return 'kernel-help';
}
if (lc.includes('root')) {
return 'rootfs-help';
}
if (lc.includes('sdcard')) {
return 'sdcard-help';
}
if (lc.includes('tftp')) {
return 'tftp-help';
}
return 'other-help';
};
const preExistingPackages = (_profile = profile) =>
Array.from(
new Set([...(_profile?.default_packages || []), ...(_profile?.device_packages || [])])
);
const getProfileData = useCallback(async () => {
let profileData = profilesData[selectedProfile.id];
if (!profileData) {
const response = await axios.get<Profile>(
`${process.env.PUBLIC_URL}/data/${selectedVersion}` +
`/${selectedProfile.target}/${selectedProfile.id}.json`
);
profileData = response.data;
profilesData[selectedProfile.id] = profileData;
}
return profileData;
}, [selectedVersion, selectedProfile]);
useEffect(() => {
let mounted = true;
getProfileData().then((_profileData) => {
if (mounted && !isEqual(profile, _profileData)) {
setProfile(_profileData);
setCustomPackages(new Set(preExistingPackages(_profileData)));
}
});
return () => {
mounted = false;
};
}, [selectedVersion, selectedProfile, getProfileData, profile]);
const toggleAddPackages = () => {
if (!profile) return;
setShowAddPackages(!showAddPackages);
setCustomPackages(new Set(preExistingPackages()));
};
const appendAddPackageInput = (
e?: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
if (((e && e.key === 'Enter') || !e) && customPackagesInputValue) {
setCustomPackages((prev) => {
setShowPackageExistsError(false);
if (
!profile?.device_packages?.includes(customPackagesInputValue) &&
!profile?.default_packages?.includes(customPackagesInputValue)
) {
return new Set(prev.add(customPackagesInputValue));
}
setShowPackageExistsError(true);
return prev;
});
setCustomPackagesInputValue('');
}
};
const onBuildStatusChange = (status: string) => {
setBuildStatus(status);
};
if (!profile) return <CircularProgress />;
const buildCustomImage = async () => {
setBuildStatus('Please wait...');
try {
const response = await asu.build(
Array.from(customPackages.values()),
profile.id,
profile.version_number,
onBuildStatusChange
);
setBuildResponse(response);
} catch (e) {
console.log(e);
setBuildError(e.response.data.message);
}
setBuildStatus(undefined);
};
const buildAt = DateTime.fromFormat(profile.build_at, 'yyyy-MM-dd TT').toLocaleString(
DateTime.DATETIME_MED
);
const hasAbilityToBuildCustomPackages = Object.keys(config).includes('asu_url');
const canBuild = !isEqual(Array.from(customPackages.values()), preExistingPackages());
return (
<>
<Box paddingTop={3} paddingBottom={2}>
<Typography variant="h5" component="h3" align="left">
{t('tr-version-build')}
</Typography>
</Box>
<TableContainer>
<Table>
<TableBody>
<TableRow>
<TableCell>{t('tr-model')}</TableCell>
<TableCell id="title">
{profile.titles?.map((title) => getTitle(title)).join(', ')}
</TableCell>
</TableRow>
<TableRow>
<TableCell>{t('tr-target')}</TableCell>
<TableCell id="target">{profile.target}</TableCell>
</TableRow>
<TableRow>
<TableCell>{t('tr-version')}</TableCell>
<TableCell>
{profile.version_number} ({profile.version_code})
</TableCell>
</TableRow>
<TableRow>
<TableCell>{t('tr-date')}</TableCell>
<TableCell>{buildAt.toLocaleString()}</TableCell>
</TableRow>
<TableRow>
<TableCell>Info</TableCell>
<TableCell>
{profile.titles
?.map<React.ReactNode>((title: TitlesEntity) => {
const titleString = getTitle(title);
const infoUrl = config.info_url.replace('{title}', encodeURI(titleString));
return (
<Link href={infoUrl} key={titleString}>
{/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
{profile.titles!.length > 1 && (
<Typography component="span">{titleString}</Typography>
)}
<Launch
style={{
marginLeft: 10,
verticalAlign: 'middle',
}}
/>
</Link>
);
})
.reduce((acc: ReactNode, curr: ReactNode, i: number) => [
acc,
// eslint-disable-next-line react/no-array-index-key
<Box display="inline-block" marginRight={2} key={i} />,
curr,
])}
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
<Box paddingTop={3} paddingBottom={2}>
<Typography variant="h5" component="h3" align="left">
{t('tr-downloads')}
</Typography>
</Box>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Download link</TableCell>
<TableCell>Help Text</TableCell>
</TableRow>
</TableHead>
<TableBody>
{profile.images?.map((i) => {
const downloadURL = `${config.image_url
.replace('{target}', profile.target)
.replace('{version}', profile.version_number)}/${i.name}`;
return (
<TableRow key={downloadURL + i.type}>
<TableCell>
<Link href={downloadURL} target="_blank" data-testid="download_link">
<Button endIcon={<CloudDownload />} variant="contained" color="primary">
{i.type}
</Button>
</Link>
</TableCell>
<TableCell>
<Box p={1}>
<Typography>{t(`tr-${getHelpKey(i.type)}`)}</Typography>
<Typography variant="caption">sha256sum: {i.sha256}</Typography>
</Box>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<Box paddingTop={3} paddingBottom={2}>
<Typography variant="h5" component="h3" align="left">
{t('Packages')}
</Typography>
</Box>
{profile.default_packages && profile.default_packages.length > 0 && (
<Box mb={2}>
<Typography variant="h6" align="left">
{t('Default Packages')}
</Typography>
{profile.default_packages?.join(', ')}
</Box>
)}
{profile.device_packages && profile.device_packages.length > 0 && (
<Box mb={2}>
<Typography variant="h6" align="left">
{t('Device Packages')}
</Typography>
{profile.device_packages.join(', ')}
</Box>
)}
{hasAbilityToBuildCustomPackages && (
<Box>
{!showAddPackages && (
<Button variant="outlined" size="small" onClick={toggleAddPackages}>
customize packages
</Button>
)}
{showAddPackages && (
<>
<Typography variant="h6" align="left">
{t('Customize Packages')}
<Box display="inline-block" ml={2}>
<Link href="https://openwrt.org/packages/table/start" target="_blank">
<Typography variant="caption">find packages index on this page</Typography>
</Link>
</Box>
</Typography>
<br />
{Array.from(customPackages.values()).map((p) => {
const isDefaultPackage = profile.default_packages?.includes(p);
return (
<Box key={p} pr={1} pb={1} display="inline-block">
<Chip
size="small"
label={p}
color={isDefaultPackage ? 'default' : 'primary'}
className={classes.chip}
onDelete={
isDefaultPackage
? undefined
: () =>
setCustomPackages((prev) => {
const newSet = new Set(Array.from(prev.values()));
newSet.delete(p);
return newSet;
})
}
/>
</Box>
);
})}
<br />
<FormControl size="small">
<InputLabel style={{ fontSize: '0.9em' }}>Custom Package Name</InputLabel>
<Input
value={customPackagesInputValue}
onChange={(e) => e && setCustomPackagesInputValue(e.currentTarget.value)}
onKeyUp={appendAddPackageInput}
endAdornment={
<InputAdornment position="end">
<IconButton size="small" onClick={() => appendAddPackageInput()}>
<Add />
</IconButton>
</InputAdornment>
}
/>
</FormControl>
{showPackageExistsError && (
<Box pt={2}>
<Typography color="error" variant="caption" component="div">
This profile already includes this package. Please try a diffrent one
</Typography>
</Box>
)}
<Box mt={3}>
{!buildStatus && (
<Button
variant="contained"
color="primary"
disabled={!canBuild}
onClick={buildCustomImage}
>
build customized image
</Button>
)}
{buildStatus && (
<>
<Grid
container
alignContent="center"
direction="row"
alignItems="center"
spacing={2}
>
{typeof buildStatus === 'string' && (
<Grid item>
<CircularProgress />
</Grid>
)}
<Grid item>{buildStatus}</Grid>
</Grid>
</>
)}
</Box>
</>
)}
{buildError && <Typography color="error">{buildError}</Typography>}
{buildResponse && (
<Box mt={3}>
<Typography variant="h5">Built Image:</Typography>
<Table>
<TableBody>
<TableRow>
<TableCell>Build At</TableCell>
<TableCell id="title">
{DateTime.fromFormat(
buildResponse.build_at.substr(0, 25),
'ccc, dd MMM yyyy TT',
{
zone: buildResponse.build_at.substr(26),
setZone: true,
}
).toLocaleString(DateTime.DATETIME_MED)}
</TableCell>
</TableRow>
</TableBody>
</Table>
<br />
<br />
<Table>
<TableHead>
<TableRow>
<TableCell>Download link</TableCell>
<TableCell>Help Text</TableCell>
</TableRow>
</TableHead>
<TableBody>
{buildResponse.images?.map((i) => {
const downloadURL = `${config.image_url
.replace('{target}', profile.target)
.replace('{version}', profile.version_number)}/${i.name}`;
return (
<TableRow key={downloadURL + i.type}>
<TableCell>
<Link href={downloadURL} target="_blank" data-testid="download_link">
<Button endIcon={<CloudDownload />} variant="contained" color="primary">
{i.type}
</Button>
</Link>
</TableCell>
<TableCell>
<Box p={1}>
<Typography>{t(`tr-${getHelpKey(i.type)}`)}</Typography>
<Typography variant="caption">sha256sum: {i.sha256}</Typography>
</Box>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Box>
)}
</Box>
)}
</>
);
};
export default ProfileDetails;