kopia lustrzana https://github.com/bellingcat/auto-archiver
451 wiersze
15 KiB
TypeScript
451 wiersze
15 KiB
TypeScript
import * as React from 'react';
|
|
import { useEffect, useState, useRef } from 'react';
|
|
import Container from '@mui/material/Container';
|
|
import Typography from '@mui/material/Typography';
|
|
import Box from '@mui/material/Box';
|
|
import FileUploadIcon from '@mui/icons-material/FileUpload';
|
|
|
|
import {
|
|
DndContext,
|
|
closestCenter,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
DragOverlay
|
|
} from "@dnd-kit/core";
|
|
import {
|
|
arrayMove,
|
|
SortableContext,
|
|
sortableKeyboardCoordinates,
|
|
rectSortingStrategy
|
|
} from "@dnd-kit/sortable";
|
|
|
|
import type { DragStartEvent, DragEndEvent, UniqueIdentifier } from "@dnd-kit/core";
|
|
|
|
|
|
import { Module } from './types';
|
|
|
|
import { modules, steps, module_types, empty_config } from './schema.json';
|
|
import {
|
|
Stack,
|
|
Button,
|
|
} from '@mui/material';
|
|
import Grid from '@mui/material/Grid2';
|
|
|
|
import { parseDocument, Document, YAMLSeq, YAMLMap, Scalar } from 'yaml'
|
|
import StepCard from './StepCard';
|
|
|
|
|
|
function FileDrop({ setYamlFile }: { setYamlFile: React.Dispatch<React.SetStateAction<Document>> }) {
|
|
|
|
const [showError, setShowError] = useState(false);
|
|
const [label, setLabel] = useState(<>Drag and drop your orchestration.yaml file here, or click to select a file.</>);
|
|
const wrapperRef = useRef(null);
|
|
|
|
function openYAMLFile(event: any) {
|
|
let file = event.target.files[0];
|
|
if (file.type.indexOf('yaml') === -1) {
|
|
setShowError(true);
|
|
setLabel(<>Invalid type, only YAML files are accepted.</>)
|
|
return;
|
|
}
|
|
let reader = new FileReader();
|
|
reader.onload = function (e) {
|
|
let contents = e.target ? e.target.result : '';
|
|
try {
|
|
let document = parseDocument(contents as string);
|
|
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.</>)
|
|
}
|
|
// do some basic validation of 'steps'
|
|
let steps = document.get('steps');
|
|
if (!steps) {
|
|
setShowError(true);
|
|
setLabel(<>Invalid file. Your orchestration file must have a 'steps' section in it.</>)
|
|
return;
|
|
}
|
|
const replacements = {
|
|
feeder: 'feeders',
|
|
formatter: 'formatters',
|
|
archivers: 'extractors',
|
|
};
|
|
|
|
let error = false;
|
|
for (let stepType of Object.keys(replacements)) {
|
|
if (steps.get(stepType) !== undefined) {
|
|
setShowError(true);
|
|
setLabel(<>Invalid file. Your orchestration file appears to be in the old (v0.12) format with a '{stepType}' section.<br/>You should manually update your orchestration file first (hint: {stepType} → {replacements[stepType]})</>);
|
|
error = true;
|
|
return;
|
|
}
|
|
};
|
|
setYamlFile(document);
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
reader.readAsText(file);
|
|
}
|
|
return (
|
|
<>
|
|
<div
|
|
style={{
|
|
position: 'relative',
|
|
width: '100%',
|
|
border: 'dashed',
|
|
borderRadius:'5px',
|
|
textAlign: 'center',
|
|
borderWidth: '1px',
|
|
padding: '20px' }}
|
|
onDragEnter={(e) => {
|
|
e.currentTarget.style.backgroundColor = 'var(--mui-palette-LinearProgress-infoBg)';
|
|
}}
|
|
onDragLeave={(e) => {
|
|
e.currentTarget.style.backgroundColor = '';
|
|
}}
|
|
onDrop={(e) => {
|
|
e.currentTarget.style.backgroundColor = '';
|
|
}}
|
|
>
|
|
<FileUploadIcon style={{ fontSize: 50 }} />
|
|
<input style={{
|
|
opacity: 0,
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
height: '100%',
|
|
cursor: 'pointer',
|
|
}}
|
|
type="file" id="file"
|
|
accept=".yaml"
|
|
onChange={openYAMLFile} />
|
|
<Typography variant="body1" color={showError ? 'error' : ''} >
|
|
{label}
|
|
</Typography>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ModuleTypes({ stepType, setEnabledModules, enabledModules, configValues }: { stepType: string, setEnabledModules: any, enabledModules: any, configValues: any }) {
|
|
const [showError, setShowError] = useState<boolean>(false);
|
|
const [activeId, setActiveId] = useState<UniqueIdentifier>();
|
|
const [items, setItems] = useState<string[]>([]);
|
|
|
|
useEffect(() => {
|
|
setItems(enabledModules[stepType].map(([name, enabled]: [string, boolean]) => name));
|
|
}
|
|
, [enabledModules]);
|
|
|
|
const toggleModule = (event: any) => {
|
|
// make sure that 'feeder' and 'formatter' types only have one value
|
|
let name = event.target.id;
|
|
let checked = event.target.checked;
|
|
if (stepType === 'feeders' || stepType === 'formatters') {
|
|
// check how many modules of this type are enabled
|
|
const checkedModules = enabledModules[stepType].filter(([m, enabled]: [string, boolean]) => {
|
|
return (m !== name && enabled) || (checked && m === name)
|
|
});
|
|
if (checkedModules.length > 1) {
|
|
setShowError(true);
|
|
} else {
|
|
setShowError(false);
|
|
}
|
|
} else {
|
|
setShowError(false);
|
|
}
|
|
let newEnabledModules = { ...enabledModules };
|
|
newEnabledModules[stepType] = enabledModules[stepType].map(([m, enabled]: [string, boolean]) => {
|
|
return (m === name) ? [m, checked] : [m, enabled];
|
|
});
|
|
setEnabledModules(newEnabledModules);
|
|
}
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor),
|
|
useSensor(KeyboardSensor, {
|
|
coordinateGetter: sortableKeyboardCoordinates
|
|
})
|
|
);
|
|
|
|
const handleDragStart = (event: DragStartEvent) => {
|
|
setActiveId(event.active.id);
|
|
};
|
|
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
setActiveId(undefined);
|
|
const { active, over } = event;
|
|
|
|
if (active.id !== over?.id) {
|
|
const oldIndex = items.indexOf(active.id as string);
|
|
const newIndex = items.indexOf(over?.id as string);
|
|
|
|
let newArray = arrayMove(items, oldIndex, newIndex);
|
|
// set it also on steps
|
|
let newEnabledModules = { ...enabledModules };
|
|
newEnabledModules[stepType] = enabledModules[stepType].sort((a, b) => {
|
|
return newArray.indexOf(a[0]) - newArray.indexOf(b[0]);
|
|
})
|
|
setEnabledModules(newEnabledModules);
|
|
}
|
|
};
|
|
return (
|
|
<>
|
|
<Box sx={{ my: 4 }}>
|
|
<Typography id={stepType} variant="h6" style={{ textTransform: 'capitalize' }} >
|
|
{stepType}
|
|
</Typography>
|
|
<Typography variant="body1" >
|
|
Select the <a href={`https://auto-archiver.readthedocs.io/en/latest/modules/${stepType.slice(0,-1)}.html`} target="_blank">{stepType}</a> you wish to enable. Drag to reorder.
|
|
</Typography>
|
|
</Box>
|
|
{showError ? <Typography variant="body1" color="error" >Only one {stepType.slice(0,-1)} can be enabled at a time.</Typography> : null}
|
|
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
onDragStart={handleDragStart}
|
|
>
|
|
<Grid container spacing={1} key={stepType}>
|
|
<SortableContext items={items} strategy={rectSortingStrategy}>
|
|
{items.map((name: string) => {
|
|
let m: Module = modules[name];
|
|
return (
|
|
<StepCard key={name} type={stepType} module={m} toggleModule={toggleModule} enabledModules={enabledModules} configValues={configValues} />
|
|
);
|
|
})}
|
|
<DragOverlay>
|
|
{activeId ? (
|
|
<div
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
backgroundColor: "grey",
|
|
opacity: 0.1,
|
|
}}
|
|
></div>
|
|
|
|
) : null}
|
|
</DragOverlay>
|
|
</SortableContext>
|
|
</Grid>
|
|
</DndContext>
|
|
</>
|
|
);
|
|
}
|
|
|
|
|
|
export default function App() {
|
|
const [yamlFile, setYamlFile] = useState<Document>(new Document());
|
|
const [enabledModules, setEnabledModules] = useState<{}>(Object.fromEntries(Object.keys(steps).map(type => [type, steps[type].map((name: string) => [name, false])])));
|
|
const [configValues, setConfigValues] = useState<{
|
|
[key: string]: {
|
|
[key: string
|
|
]: any
|
|
}
|
|
}>(
|
|
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 = enabledModules;
|
|
|
|
let finalYamlFile: Document = null;
|
|
if (!yamlFile || yamlFile.contents == null) {
|
|
// create the yaml file from
|
|
finalYamlFile = parseDocument(empty_config as string);
|
|
} else {
|
|
finalYamlFile = yamlFile;
|
|
}
|
|
|
|
// set the steps
|
|
module_types.forEach((type: string) => {
|
|
let stepType = type + 's';
|
|
let existingSteps = finalYamlFile.getIn(['steps', stepType]) as YAMLSeq;
|
|
stepsConfig[stepType].forEach(([name, enabled]: [string, boolean]) => {
|
|
let index = existingSteps.items.findIndex((item) => {
|
|
return (item.value || item) === name
|
|
});
|
|
let stepItem = finalYamlFile.getIn(['steps', stepType], true) as YAMLSeq;
|
|
|
|
if (enabled && index === -1) {
|
|
finalYamlFile.addIn(['steps', stepType], name);
|
|
stepItem.commentBefore = stepItem.commentBefore?.replace("\n - " + name, '');
|
|
stepItem.comment = stepItem.comment?.replace("\n - " + name, '');
|
|
} else if (!enabled && index !== -1) {
|
|
// set the value to empty and add a comment before with the commented value
|
|
finalYamlFile.deleteIn(['steps', stepType, index]);
|
|
stepItem.commentBefore += "\n - " + name;
|
|
finalYamlFile.setIn(['steps', stepType], stepItem);
|
|
}
|
|
});
|
|
// sort the items
|
|
existingSteps.items.sort((a: Scalar | string, b: Scalar | string) => {
|
|
return (stepsConfig[stepType].findIndex((val: [string, boolean]) => {return val[0] === (a.value || a)}) -
|
|
stepsConfig[stepType].findIndex((val: [string, boolean]) => {return val[0] === (b.value || b)}))
|
|
});
|
|
existingSteps.flow = existingSteps.items.length ? false : true;
|
|
});
|
|
|
|
// set all other settings
|
|
// loop through each item that isn't 'steps' in the finalYamlFile and check if it exists in configValues
|
|
|
|
Object.keys(configValues).forEach((module_name: string) => {
|
|
// get an existing key
|
|
let existingConfig = finalYamlFile.get(module_name, true) as YAMLMap;
|
|
if (existingConfig) {
|
|
Object.keys(configValues[module_name]).forEach((config_name: string) => {
|
|
let existingConfigYAML = existingConfig.get(config_name, true) as Scalar;
|
|
if (existingConfigYAML) {
|
|
existingConfigYAML.value = configValues[module_name][config_name];
|
|
existingConfig.set(config_name, existingConfigYAML);
|
|
} else {
|
|
existingConfig.set(config_name, configValues[module_name][config_name]);
|
|
}
|
|
});
|
|
finalYamlFile.set(module_name, existingConfig);
|
|
} else {
|
|
if (configValues[module_name] && Object.keys(configValues[module_name]).length > 0) {
|
|
finalYamlFile.set(module_name, configValues[module_name]);
|
|
}
|
|
}
|
|
});
|
|
|
|
if (copy) {
|
|
navigator.clipboard.writeText(String(finalYamlFile)).then(() => {
|
|
alert("Settings copied to clipboard.");
|
|
});
|
|
} else {
|
|
// offer the file for download
|
|
const blob = new Blob([String(finalYamlFile)], { type: 'application/x-yaml' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'orchestration.yaml';
|
|
a.click();
|
|
}
|
|
}
|
|
|
|
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 stepSettings = settings['steps'];
|
|
|
|
let newEnabledModules = Object.fromEntries(Object.keys(steps).map((type: string) => {
|
|
return [type, steps[type].map((name: string) => {
|
|
return [name, stepSettings[type].indexOf(name) !== -1];
|
|
}).sort((a, b) => {
|
|
let aIndex = stepSettings[type].indexOf(a[0]);
|
|
let bIndex = stepSettings[type].indexOf(b[0]);
|
|
if (aIndex === -1 && bIndex === -1) {
|
|
return a - b;
|
|
}
|
|
if (bIndex === -1) {
|
|
return -1;
|
|
}
|
|
if (aIndex === -1) {
|
|
return 1;
|
|
}
|
|
return aIndex - bIndex;
|
|
})];
|
|
}).sort((a, b) => {
|
|
return module_types.indexOf(a[0]) - module_types.indexOf(b[0]);
|
|
}));
|
|
setEnabledModules(newEnabledModules);
|
|
|
|
// set the config values
|
|
let newConfigValues = settings;
|
|
delete newConfigValues['steps'];
|
|
|
|
|
|
setConfigValues(Object.keys(modules).reduce((acc, module) => {
|
|
acc[module] = newConfigValues[module] || {};
|
|
return acc;
|
|
}, {}));
|
|
}, [yamlFile]);
|
|
|
|
|
|
|
|
return (
|
|
<Container maxWidth="lg">
|
|
<Box sx={{ my: 4 }}>
|
|
<Box sx={{ my: 4 }}>
|
|
<Typography variant="h5" >
|
|
1. Select your orchestration.yaml settings file.
|
|
</Typography>
|
|
<Typography variant="body1">Or skip this step to start from scratch</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 (
|
|
<Box key={stepType} sx={{ my: 4 }}>
|
|
<ModuleTypes stepType={stepType} setEnabledModules={setEnabledModules} enabledModules={enabledModules} configValues={configValues} />
|
|
</Box>
|
|
);
|
|
})}
|
|
</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>
|
|
<Stack direction="row" spacing={2} sx={{ my: 2 }}>
|
|
<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>
|
|
</Stack>
|
|
</Box>
|
|
</Box>
|
|
</Container>
|
|
);
|
|
}
|