mirror of
https://github.com/bellingcat/auto-archiver.git
synced 2026-06-11 04:38:29 +03:00
Fully-working settings page editor
This commit is contained in:
364
scripts/settings/src/App.tsx
Normal file
364
scripts/settings/src/App.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Container from '@mui/material/Container';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Link from '@mui/material/Link';
|
||||
import { modules, steps, configs, module_types } from './schema.json';
|
||||
import {
|
||||
Checkbox,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
InputLabel,
|
||||
FormHelperText,
|
||||
Stack,
|
||||
TextField,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
} from '@mui/material';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
|
||||
import Accordion from '@mui/material/Accordion';
|
||||
import AccordionDetails from '@mui/material/AccordionDetails';
|
||||
import AccordionSummary from '@mui/material/AccordionSummary';
|
||||
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { parseDocument, ParsedNode, Document } from 'yaml'
|
||||
import { set } from 'yaml/dist/schema/yaml-1.1/set';
|
||||
|
||||
Object.defineProperty(String.prototype, 'capitalize', {
|
||||
value: function() {
|
||||
return this.charAt(0).toUpperCase() + this.slice(1);
|
||||
},
|
||||
enumerable: false
|
||||
});
|
||||
|
||||
function FileDrop({ setYamlFile }) {
|
||||
|
||||
const [showError, setShowError] = useState(false);
|
||||
const [label, setLabel] = useState("Drag and drop your orchestration.yaml file here, or click to select a file.");
|
||||
|
||||
function openYAMLFile(event: any) {
|
||||
let file = event.target.files[0];
|
||||
if (file.type !== 'application/x-yaml') {
|
||||
setShowError(true);
|
||||
setLabel("Invalid type, only YAML files are accepted.")
|
||||
return;
|
||||
}
|
||||
let reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
let contents = e.target.result;
|
||||
try {
|
||||
let document = parseDocument(contents);
|
||||
if (document.errors.length > 0) {
|
||||
// not a valid yaml file
|
||||
setShowError(true);
|
||||
setLabel("Invalid file. Make sure your Orchestration is a valid YAML file with a 'steps' section in it.")
|
||||
return;
|
||||
} else {
|
||||
setShowError(false);
|
||||
setLabel("File loaded successfully.")
|
||||
}
|
||||
setYamlFile(document);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
reader.readAsText(file);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div style={{width:'100%', border:'dashed', textAlign:'center', borderWidth:'1px', padding:'20px'}}>
|
||||
|
||||
<input name="file" type="file" accept=".yaml" onChange={openYAMLFile} />
|
||||
<Typography style={{marginTop:'20px' }} variant="body1" color={showError ? 'error' : ''} >
|
||||
{label}
|
||||
</Typography>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function ModuleCheckbox({ module, toggleModule, enabledModules, configValues }: { module: object, toggleModule: any, enabledModules: any, configValues: any }) {
|
||||
let name = module.name;
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
if (name == 'metadata_enricher') {
|
||||
console.log("hi");
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FormControlLabel
|
||||
control={<Checkbox id={name} onClick={toggleModule} checked={enabledModules.includes(name)} />}
|
||||
label={module.display_name} />
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button size="small" onClick={() => setHelpOpen(true)}>Help</Button>
|
||||
{enabledModules.includes(name) && module.configs && name != 'cli_feeder' ? (
|
||||
<Button size="small" onClick={() => setConfigOpen(true)}>Configure</Button>
|
||||
) : null}
|
||||
</CardActions>
|
||||
</Card>
|
||||
<Dialog
|
||||
open={helpOpen}
|
||||
onClose={() => setHelpOpen(false)}
|
||||
maxWidth="lg"
|
||||
>
|
||||
<DialogTitle>
|
||||
{module.display_name}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<ReactMarkdown>
|
||||
{module.manifest.description.split("\n").map((line: string) => line.trim()).join("\n")}
|
||||
</ReactMarkdown>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{module.configs && name != 'cli_feeder' && <ConfigPanel module={module} open={configOpen} setOpen={setConfigOpen} configValues={configValues[module.name]} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function ConfigPanel({ module, open, setOpen, configValues }: { module: any, open: boolean, setOpen: any, configValues: any }) {
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
key={module}
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
maxWidth="lg"
|
||||
>
|
||||
<DialogTitle>
|
||||
{module.display_name}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack key={module} direction="column" spacing={1}>
|
||||
{Object.keys(module.configs).map((config_value: any) => {
|
||||
let config_args = module.configs[config_value];
|
||||
let config_name = config_value.replace(/_/g," ");
|
||||
return (
|
||||
<Box key={config_value}>
|
||||
<FormControl size="small">
|
||||
{ config_args.type === 'bool' ?
|
||||
<FormControlLabel style={{ textTransform: 'capitalize'}} control={<Checkbox checked={configValues[config_value]} size="small" id={`${module}.${config_value}`} />} label={config_name} />
|
||||
:
|
||||
( config_args.type === 'int' ?
|
||||
<TextField size="small" id={`${module}.${config_value}`} label={config_name.capitalize()} value={configValues[config_value]} type="number" />
|
||||
:
|
||||
(
|
||||
config_args.choices !== undefined ?
|
||||
<>
|
||||
<InputLabel>{config_name}</InputLabel>
|
||||
<Select size="small" id={`${module}.${config_value}`}
|
||||
defaultValue={config_args.default} value={configValues[config_value] || ''}>
|
||||
{config_args.choices.map((choice: any) => {
|
||||
return (
|
||||
<MenuItem key={`${module}.${config_value}.${choice}`}
|
||||
value={choice} selected={config_args.default === choice}>{choice}</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</>
|
||||
:
|
||||
<TextField size="small" id={`${module}.${config_value}`} value={configValues[config_value] || ''} label={config_name.capitalize()} />
|
||||
)
|
||||
)
|
||||
}
|
||||
<FormHelperText style={{ textTransform: 'capitalize'}}>{config_args.help}</FormHelperText>
|
||||
</FormControl>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleTypes({ stepType, toggleModule, enabledModules, configValues }: { stepType: string, toggleModule: any, enabledModules: any, configValues: any }) {
|
||||
const [showError, setShowError] = useState(false);
|
||||
|
||||
const _toggleModule = (event: any) => {
|
||||
// make sure that 'feeder' and 'formatter' types only have one value
|
||||
let name = event.target.id;
|
||||
if (stepType === 'feeder' || stepType === 'formatter') {
|
||||
let checked = event.target.checked;
|
||||
// check how many modules of this type are enabled
|
||||
let modules = steps[stepType].filter((m: string) => (m !== name && enabledModules.includes(m)) || (checked && m === name));
|
||||
if (modules.length > 1) {
|
||||
setShowError(true);
|
||||
} else {
|
||||
setShowError(false);
|
||||
}
|
||||
} else {
|
||||
setShowError(false);
|
||||
}
|
||||
toggleModule(event);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Typography id={stepType} variant="h6" style={{ textTransform: 'capitalize' }} >
|
||||
{stepType}s
|
||||
</Typography>
|
||||
{showError ? <Typography variant="body1" color="error" >Only one {stepType} can be enabled at a time.</Typography> : null}
|
||||
<Grid container spacing={1} key={stepType}>
|
||||
{steps[stepType].map((name: string) => {
|
||||
let m = modules[name];
|
||||
return (
|
||||
<Grid key={name} size={{ xs: 6, sm: 4, md: 3 }}>
|
||||
<ModuleCheckbox key={name} module={m} toggleModule={_toggleModule} enabledModules={enabledModules} configValues={configValues} />
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default function App() {
|
||||
const [yamlFile, setYamlFile] = useState<Document>(new Document());
|
||||
const [enabledModules, setEnabledModules] = useState<[]>([]);
|
||||
const [configValues, setConfigValues] = useState<{}>(
|
||||
Object.keys(modules).reduce((acc, module) => {
|
||||
acc[module] = {};
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
|
||||
const saveSettings = function(copy: boolean = false) {
|
||||
// edit the yamlFile
|
||||
|
||||
// generate the steps config
|
||||
let stepsConfig = {}
|
||||
module_types.forEach((stepType: string) => {
|
||||
stepsConfig[stepType] = enabledModules.filter((m: string) => steps[stepType].includes(m));
|
||||
}
|
||||
);
|
||||
|
||||
// create a yaml file from
|
||||
const finalYaml = {
|
||||
'steps': stepsConfig
|
||||
};
|
||||
|
||||
Object.keys(configValues).map((module: string) => {
|
||||
let module_values = configValues[module];
|
||||
if (module_values) {
|
||||
finalYaml[module] = module_values;
|
||||
}
|
||||
});
|
||||
let newFile = new Document(finalYaml);
|
||||
if (copy) {
|
||||
navigator.clipboard.writeText(String(newFile)).then(() => {
|
||||
alert("Settings copied to clipboard.");
|
||||
});
|
||||
} else {
|
||||
// offer the file for download
|
||||
const blob = new Blob([String(newFile)], { type: 'application/x-yaml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'orchestration.yaml';
|
||||
a.click();
|
||||
}
|
||||
}
|
||||
|
||||
const toggleModule = function (event: any) {
|
||||
let module = event.target.id;
|
||||
let checked = event.target.checked
|
||||
|
||||
if (checked) {
|
||||
setEnabledModules([...enabledModules, module]);
|
||||
} else {
|
||||
setEnabledModules(enabledModules.filter((m: string) => m !== module));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// load the configs, and set the default values if they exist
|
||||
let newConfigValues = {};
|
||||
Object.keys(modules).map((module: string) => {
|
||||
let m = modules[module];
|
||||
let configs = m.configs;
|
||||
if (!configs) {
|
||||
return;
|
||||
}
|
||||
newConfigValues[module] = {};
|
||||
Object.keys(configs).map((config: string) => {
|
||||
let config_args = configs[config];
|
||||
if (config_args.default !== undefined) {
|
||||
newConfigValues[module][config] = config_args.default;
|
||||
}
|
||||
});
|
||||
})
|
||||
setConfigValues(newConfigValues);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!yamlFile || yamlFile.contents == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let settings = yamlFile.toJS();
|
||||
// make a deep copy of settings
|
||||
let newEnabledModules = Object.keys(settings['steps']).map((step: string) => {
|
||||
return settings['steps'][step];
|
||||
}).flat();
|
||||
newEnabledModules = newEnabledModules.filter((m: string, i: number) => newEnabledModules.indexOf(m) === i);
|
||||
setEnabledModules(newEnabledModules);
|
||||
}, [yamlFile]);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg">
|
||||
<Box sx={{ my: 4 }}>
|
||||
<Typography variant="h2" >
|
||||
Auto Archiver Settings
|
||||
</Typography>
|
||||
<Box sx={{ my: 4 }}>
|
||||
<Typography variant="h5" >
|
||||
1. Select your <pre style={{display:'inline'}}>orchestration.yaml</pre> settings file.
|
||||
</Typography>
|
||||
<FileDrop setYamlFile={setYamlFile}/>
|
||||
</Box>
|
||||
<Box sx={{ my: 4 }}>
|
||||
<Typography variant="h5" >
|
||||
2. Choose the Modules you wish to enable/disable
|
||||
</Typography>
|
||||
{Object.keys(steps).map((stepType: string) => {
|
||||
return (
|
||||
<ModuleTypes key={stepType} stepType={stepType} toggleModule={toggleModule} enabledModules={enabledModules} configValues={configValues} />
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box sx={{ my: 4 }}>
|
||||
<Typography variant="h5" >
|
||||
3. Configure your Enabled Modules
|
||||
</Typography>
|
||||
<Typography variant="body1" >
|
||||
Next to each module you've enabled, you can click 'Configure' to set the module's settings.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ my: 4 }}>
|
||||
<Typography variant="h5" >
|
||||
4. Save your settings
|
||||
</Typography>
|
||||
<Button variant="contained" color="primary" onClick={() => saveSettings(true)}>Copy Settings to Clipboard</Button>
|
||||
<Button variant="contained" color="primary" onClick={() => saveSettings()}>Save Settings to File</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
23
scripts/settings/src/ProTip.tsx
Normal file
23
scripts/settings/src/ProTip.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import Link from '@mui/material/Link';
|
||||
import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
function LightBulbIcon(props: SvgIconProps) {
|
||||
return (
|
||||
<SvgIcon {...props}>
|
||||
<path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7zm2.85 11.1l-.85.6V16h-4v-2.3l-.85-.6C7.8 12.16 7 10.63 7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.63-.8 3.16-2.15 4.1z" />
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProTip() {
|
||||
return (
|
||||
<Typography sx={{ mt: 6, mb: 3, color: 'text.secondary' }}>
|
||||
<LightBulbIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
{'Pro tip: See more '}
|
||||
<Link href="https://mui.com/material-ui/getting-started/templates/">templates</Link>
|
||||
{' in the Material UI documentation.'}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
15
scripts/settings/src/main.tsx
Normal file
15
scripts/settings/src/main.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom/client';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { CssBaseline } from '@mui/material';
|
||||
import theme from './theme';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
2095
scripts/settings/src/schema.json
Normal file
2095
scripts/settings/src/schema.json
Normal file
File diff suppressed because it is too large
Load Diff
20
scripts/settings/src/theme.tsx
Normal file
20
scripts/settings/src/theme.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
import { red } from '@mui/material/colors';
|
||||
|
||||
// A custom theme for this app
|
||||
const theme = createTheme({
|
||||
cssVariables: true,
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#556cd6',
|
||||
},
|
||||
secondary: {
|
||||
main: '#19857b',
|
||||
},
|
||||
error: {
|
||||
main: red.A400,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default theme;
|
||||
1
scripts/settings/src/vite-env.d.ts
vendored
Normal file
1
scripts/settings/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user