Merge pull request #532 from ylei-tsubame/rebuild-webui

Web UI: patches #448, #517, #519, and #527
main
Digimer 1 year ago committed by GitHub
commit 9590bf3260
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      striker-ui-api/out/index.js
  2. 29
      striker-ui-api/src/index.ts
  3. 104
      striker-ui-api/src/lib/accessModule.ts
  4. 4
      striker-ui-api/src/lib/request_handlers/host/getHostConnection.ts
  5. 1
      striker-ui-api/src/types/AccessModule.d.ts
  6. 3
      striker-ui/components/Files/EditFileForm.tsx
  7. 334
      striker-ui/components/Files/FileEditForm.tsx
  8. 203
      striker-ui/components/Files/FileInfo.tsx
  9. 2
      striker-ui/components/Files/FileInputGroup.tsx
  10. 80
      striker-ui/components/Files/FileList.tsx
  11. 296
      striker-ui/components/Files/FileUploadForm.tsx
  12. 148
      striker-ui/components/Files/Files.tsx
  13. 4
      striker-ui/components/Files/index.tsx
  14. 28
      striker-ui/components/ProvisionServerDialog.tsx
  15. 2
      striker-ui/out/_next/static/XrSz84FzD2mYzU_3k8aL2/_buildManifest.js
  16. 0
      striker-ui/out/_next/static/XrSz84FzD2mYzU_3k8aL2/_middlewareManifest.js
  17. 0
      striker-ui/out/_next/static/XrSz84FzD2mYzU_3k8aL2/_ssgManifest.js
  18. 2
      striker-ui/out/_next/static/chunks/825-07aab1f379d63d3c.js
  19. 2
      striker-ui/out/_next/static/chunks/pages/file-manager-6501dafd856c22ec.js
  20. 2
      striker-ui/out/anvil.html
  21. 2
      striker-ui/out/config.html
  22. 2
      striker-ui/out/file-manager.html
  23. 2
      striker-ui/out/index.html
  24. 2
      striker-ui/out/init.html
  25. 2
      striker-ui/out/login.html
  26. 2
      striker-ui/out/manage-element.html
  27. 2
      striker-ui/out/server.html
  28. 2
      striker-ui/pages/file-manager/index.tsx
  29. 3
      striker-ui/types/FileDetailMetadata.d.ts
  30. 4
      striker-ui/types/FileInfoChangeHandler.d.ts
  31. 9
      striker-ui/types/FileLocation.d.ts
  32. 7
      striker-ui/types/FileOverviewMetadata.d.ts
  33. 43
      tools/anvil-access-module

File diff suppressed because one or more lines are too long

@ -2,12 +2,26 @@ import { getgid, getuid, setgid, setuid } from 'process';
import { PGID, PUID, PORT, ECODE_DROP_PRIVILEGES } from './lib/consts';
import app from './app';
import { proxyServerVncUpgrade } from './middlewares';
import { access } from './lib/accessModule';
import { stderr, stdout } from './lib/shell';
(async () => {
stdout(`Starting process with ownership ${getuid()}:${getgid()}`);
/**
* Wait until the anvil-access-module daemon finishes its setup before doing
* anything else.
*
* Notes:
* * The webpackMode directive tells webpack to include the dynamic module into
* the main bundle. Webpack defaults to put such modules in separate files to
* reduce the amount of loading.
*/
access.once('active', async () => {
const { default: app } = await import(/* webpackMode: "eager" */ './app');
const { proxyServerVncUpgrade } = await import(
/* webpackMode: "eager" */ './middlewares'
);
(async () => {
stdout(`Starting main process with ownership ${getuid()}:${getgid()}`);
const server = (await app).listen(PORT, () => {
try {
@ -15,9 +29,9 @@ import { stderr, stdout } from './lib/shell';
setgid(PGID);
setuid(PUID);
stdout(`Process ownership changed to ${getuid()}:${getgid()}.`);
stdout(`Main process ownership changed to ${getuid()}:${getgid()}.`);
} catch (error) {
stderr(`Failed to change process ownership; CAUSE: ${error}`);
stderr(`Failed to change main process ownership; CAUSE: ${error}`);
process.exit(ECODE_DROP_PRIVILEGES);
}
@ -26,4 +40,5 @@ import { stderr, stdout } from './lib/shell';
});
server.on('upgrade', proxyServerVncUpgrade);
})();
})();
});

@ -2,7 +2,13 @@ import { ChildProcess, spawn, SpawnOptions } from 'child_process';
import EventEmitter from 'events';
import { readFileSync } from 'fs';
import { SERVER_PATHS, PGID, PUID, DEFAULT_JOB_PROGRESS } from './consts';
import {
SERVER_PATHS,
PGID,
PUID,
DEFAULT_JOB_PROGRESS,
REP_UUID,
} from './consts';
import { formatSql } from './formatSql';
import {
@ -13,9 +19,27 @@ import {
uuid,
} from './shell';
/**
* Notes:
* * This daemon's lifecycle events should follow the naming from systemd.
*/
class Access extends EventEmitter {
private ps: ChildProcess;
private readonly mapToExternalEventHandler: Record<
string,
(args: { options: AccessStartOptions; ps: ChildProcess }) => void
> = {
connected: ({ options, ps }) => {
shvar(
options,
`Successfully started anvil-access-module daemon (pid=${ps.pid}): `,
);
this.emit('active', ps.pid);
},
};
constructor({
eventEmitterOptions = {},
spawnOptions = {},
@ -29,17 +53,25 @@ class Access extends EventEmitter {
}
private start({
args = [],
args = ['--emit-events'],
gid = PGID,
restartInterval = 10000,
stdio = 'pipe',
timeout = 10000,
uid = PUID,
...restSpawnOptions
}: AccessStartOptions = {}) {
shvar(
{ gid, stdio, timeout, uid, ...restSpawnOptions },
`Starting anvil-access-module daemon with options: `,
);
const options = {
args,
gid,
restartInterval,
stdio,
timeout,
uid,
...restSpawnOptions,
};
shvar(options, `Starting anvil-access-module daemon with: `);
const ps = spawn(SERVER_PATHS.usr.sbin['anvil-access-module'].self, args, {
gid,
@ -49,24 +81,60 @@ class Access extends EventEmitter {
...restSpawnOptions,
});
let stdout = '';
ps.once('error', (error) => {
sherr(
`anvil-access-module daemon (pid=${ps.pid}) error: ${error.message}`,
error,
);
});
ps.once('close', (code, signal) => {
shvar(
{ code, options, signal },
`anvil-access-module daemon (pid=${ps.pid}) closed: `,
);
this.emit('inactive', ps.pid);
shout(`Waiting ${restartInterval} before restarting.`);
setTimeout(() => {
this.ps = this.start(options);
}, restartInterval);
});
ps.stderr?.setEncoding('utf-8').on('data', (chunk: string) => {
sherr(`anvil-access-module daemon stderr: ${chunk}`);
});
let stdout = '';
ps.stdout?.setEncoding('utf-8').on('data', (chunk: string) => {
stdout += chunk;
const eventless = chunk.replace(/(\n)?event=([^\n]*)\n/g, (...parts) => {
shvar(parts, 'In replacer, args: ');
const { 1: n = '', 2: event } = parts;
this.mapToExternalEventHandler[event]?.call(null, { options, ps });
return n;
});
stdout += eventless;
let nindex: number = stdout.indexOf('\n');
// 1. ~a is the shorthand for -(a + 1)
// 2. negatives are evaluated to true
// 2. negative is evaluated to true
while (~nindex) {
const scriptId = stdout.substring(0, 36);
const output = stdout.substring(36, nindex);
if (scriptId) this.emit(scriptId, output);
if (REP_UUID.test(scriptId)) {
this.emit(scriptId, output);
} else {
shout(`Access stdout: ${stdout}`);
}
stdout = stdout.substring(nindex + 1);
nindex = stdout.indexOf('\n');
@ -83,7 +151,9 @@ class Access extends EventEmitter {
}
private restart(options?: AccessStartOptions) {
this.ps.once('close', () => this.start(options));
this.ps.once('close', () => {
this.ps = this.start(options);
});
this.stop();
}
@ -262,6 +332,16 @@ const getAnvilData = async () => {
return getData<AnvilDataAnvilListHash>('anvils');
};
const getDatabaseConfigData = async () => {
const [ecode] = await subroutine<[ecode: string]>('read_config', {
pre: ['Storage'],
});
if (Number(ecode) !== 0) throw new Error(`Failed to read config`);
return getData<AnvilDataDatabaseHash>('database');
};
const getFenceSpec = async () => {
await subroutine('get_fence_data', { pre: ['Striker'] });
@ -390,6 +470,7 @@ const getVncinfo = async (serverUuid: string): Promise<ServerDetailVncInfo> => {
};
export {
access,
insertOrUpdateJob as job,
insertOrUpdateUser,
insertOrUpdateVariable as variable,
@ -398,6 +479,7 @@ export {
encrypt,
getData,
getAnvilData,
getDatabaseConfigData,
getFenceSpec,
getHostData,
getLocalHostName,

@ -1,4 +1,4 @@
import { getData, getLocalHostUUID } from '../../accessModule';
import { getDatabaseConfigData, getLocalHostUUID } from '../../accessModule';
import { buildUnknownIDCondition } from '../../buildCondition';
import buildGetRequestHandler from '../buildGetRequestHandler';
import { toLocal } from '../../convertHostUUID';
@ -59,7 +59,7 @@ export const getHostConnection = buildGetRequestHandler(
stdout(`condHostUUIDs=[${condHostUUIDs}]`);
try {
rawDatabaseData = await getData<AnvilDataDatabaseHash>('database');
rawDatabaseData = await getDatabaseConfigData();
} catch (subError) {
throw new Error(`Failed to get anvil data; CAUSE: ${subError}`);
}

@ -1,5 +1,6 @@
type AccessStartOptions = {
args?: readonly string[];
restartInterval?: number;
} & import('child_process').SpawnOptions;
type SubroutineCommonParams = {

@ -111,6 +111,9 @@ const EditFileForm: FC<EditFileFormProps> = (props) => {
api
.put(`/file/${file.uuid}`, body)
.then(() => {
setApiMessage({ children: <>File updated.</> });
})
.catch((error) => {
const emsg = handleAPIError(error);

@ -1,334 +0,0 @@
import { Box, Checkbox, checkboxClasses } from '@mui/material';
import { AxiosResponse } from 'axios';
import {
FormEventHandler,
MouseEventHandler,
useEffect,
useState,
} from 'react';
import API_BASE_URL from '../../lib/consts/API_BASE_URL';
import { GREY, RED, TEXT } from '../../lib/consts/DEFAULT_THEME';
import api from '../../lib/api';
import ConfirmDialog from '../ConfirmDialog';
import ContainedButton from '../ContainedButton';
import FileInfo from './FileInfo';
import Spinner from '../Spinner';
import fetchJSON from '../../lib/fetchers/fetchJSON';
type ReducedFileLocation = Partial<
Pick<FileLocation, 'fileLocationUUID' | 'isFileLocationActive'>
>;
type EditRequestContent = Partial<
Pick<FileDetailMetadata, 'fileName' | 'fileType' | 'fileUUID'>
> & {
fileLocations: ReducedFileLocation[];
};
type FileEditProps = {
filesOverview: FileOverviewMetadata[];
onEditFilesComplete?: () => void;
onPurgeFilesComplete?: () => void;
};
type FileToEdit = FileDetailMetadata & {
dataIncompleteError?: unknown;
isSelected?: boolean;
};
const FILE_EDIT_FORM_DEFAULT_PROPS = {
onEditFilesComplete: undefined,
onPurgeFilesComplete: undefined,
};
const FileEditForm = (
{
filesOverview,
onEditFilesComplete,
onPurgeFilesComplete,
}: FileEditProps = FILE_EDIT_FORM_DEFAULT_PROPS as FileEditProps,
): JSX.Element => {
const [editRequestContents, setEditRequestContents] = useState<
EditRequestContent[]
>([]);
const [filesToEdit, setFilesToEdit] = useState<FileToEdit[]>([]);
const [isLoadingFilesToEdit, setIsLoadingFilesToEdit] =
useState<boolean>(false);
const [isOpenPurgeConfirmDialog, setIsOpenConfirmPurgeDialog] =
useState<boolean>(false);
const [selectedFilesCount, setSelectedFilesCount] = useState<number>(0);
const purgeButtonStyleOverride = {
backgroundColor: RED,
color: TEXT,
'&:hover': { backgroundColor: RED },
};
const generateFileInfoChangeHandler =
(fileIndex: number): FileInfoChangeHandler =>
(inputValues, { fileLocationIndex } = {}) => {
if (fileLocationIndex !== undefined) {
editRequestContents[fileIndex].fileLocations[fileLocationIndex] = {
...editRequestContents[fileIndex].fileLocations[fileLocationIndex],
...inputValues,
};
} else {
editRequestContents[fileIndex] = {
...editRequestContents[fileIndex],
...inputValues,
};
}
};
const editFiles: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
setIsLoadingFilesToEdit(true);
const editPromises = editRequestContents.reduce<Promise<AxiosResponse>[]>(
(
reducedEditPromises,
{ fileLocations, fileName, fileType, fileUUID },
) => {
const editRequestContent: Partial<EditRequestContent> = {};
if (fileName !== undefined) {
editRequestContent.fileName = fileName;
}
if (fileType !== undefined) {
editRequestContent.fileType = fileType;
}
const changedFileLocations = fileLocations.reduce<
ReducedFileLocation[]
>(
(
reducedFileLocations,
{ fileLocationUUID, isFileLocationActive },
) => {
if (isFileLocationActive !== undefined) {
reducedFileLocations.push({
fileLocationUUID,
isFileLocationActive,
});
}
return reducedFileLocations;
},
[],
);
if (changedFileLocations.length > 0) {
editRequestContent.fileLocations = changedFileLocations;
}
if (Object.keys(editRequestContent).length > 0) {
reducedEditPromises.push(
api.put(`/file/${fileUUID}`, editRequestContent),
);
}
return reducedEditPromises;
},
[],
);
Promise.all(editPromises)
.then(() => {
setIsLoadingFilesToEdit(false);
})
.then(onEditFilesComplete);
};
const purgeFiles: MouseEventHandler<HTMLButtonElement> = () => {
setIsOpenConfirmPurgeDialog(false);
setIsLoadingFilesToEdit(true);
const purgePromises = filesToEdit
.filter(({ isSelected }) => isSelected)
.map(({ fileUUID }) => api.delete(`/file/${fileUUID}`));
Promise.all(purgePromises)
.then(() => {
setIsLoadingFilesToEdit(false);
})
.then(onPurgeFilesComplete);
};
const cancelPurge: MouseEventHandler<HTMLButtonElement> = () => {
setIsOpenConfirmPurgeDialog(false);
};
const confirmPurge: MouseEventHandler<HTMLButtonElement> = () => {
// We need this local variable because setState functions are async; the
// changes won't reflect until the next render cycle.
// In this case, the user would have to click on the purge button twice to
// trigger the confirmation dialog without using this local variable.
const localSelectedFilesCount = filesToEdit.filter(
({ isSelected }) => isSelected,
).length;
setSelectedFilesCount(localSelectedFilesCount);
if (localSelectedFilesCount > 0) {
setIsOpenConfirmPurgeDialog(true);
}
};
useEffect(() => {
setIsLoadingFilesToEdit(true);
Promise.all(
filesOverview.map(async (fileOverview: FileOverviewMetadata) => {
const fileToEdit: FileToEdit = {
...fileOverview,
fileLocations: [],
};
try {
const data = await fetchJSON<string[][]>(
`${API_BASE_URL}/file/${fileOverview.fileUUID}`,
);
fileToEdit.fileLocations = data.map<FileLocation>(
({
5: fileLocationUUID,
6: fileLocationActive,
7: anvilUUID,
8: anvilName,
9: anvilDescription,
10: hostUUID,
11: hostName,
}) => ({
anvilDescription,
anvilName,
anvilUUID,
fileLocationUUID,
hostName,
hostUUID,
isFileLocationActive: parseInt(fileLocationActive, 10) === 1,
}),
);
} catch (fetchError) {
fileToEdit.dataIncompleteError = fetchError;
}
return fileToEdit;
}),
).then((fetchedFilesDetail) => {
setFilesToEdit(fetchedFilesDetail);
const initialEditRequestContents: EditRequestContent[] = [];
for (
let fileIndex = 0;
fileIndex < fetchedFilesDetail.length;
fileIndex += 1
) {
const fetchedFileDetail = fetchedFilesDetail[fileIndex];
initialEditRequestContents.push({
fileUUID: fetchedFileDetail.fileUUID,
fileLocations: fetchedFileDetail.fileLocations.map(
({ fileLocationUUID }) => ({
fileLocationUUID,
}),
),
});
}
setEditRequestContents(initialEditRequestContents);
setIsLoadingFilesToEdit(false);
});
}, [filesOverview]);
return (
<>
{isLoadingFilesToEdit ? (
<Spinner />
) : (
<form onSubmit={editFiles}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
'& > :not(:first-child)': { marginTop: '1em' },
}}
>
{filesToEdit.map(
({ fileName, fileLocations, fileType, fileUUID }, fileIndex) => (
<Box
key={`file-edit-${fileUUID}`}
sx={{
display: 'flex',
flexDirection: 'row',
'& > :last-child': {
flexGrow: 1,
},
}}
>
<Box sx={{ marginTop: '.4em' }}>
<Checkbox
onChange={({ target: { checked } }) => {
filesToEdit[fileIndex].isSelected = checked;
}}
sx={{
color: GREY,
[`&.${checkboxClasses.checked}`]: {
color: TEXT,
},
}}
/>
</Box>
<FileInfo
{...{ fileName, fileType, fileLocations }}
onChange={generateFileInfoChangeHandler(fileIndex)}
/>
</Box>
),
)}
{filesToEdit.length > 0 && (
<Box
sx={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'flex-end',
'& > :not(:last-child)': {
marginRight: '.5em',
},
}}
>
<ContainedButton
onClick={confirmPurge}
sx={purgeButtonStyleOverride}
>
Purge
</ContainedButton>
<ContainedButton type="submit">Update</ContainedButton>
</Box>
)}
</Box>
<ConfirmDialog
actionProceedText="Purge"
content={`${selectedFilesCount} files will be removed from the system. You cannot undo this purge.`}
dialogProps={{ open: isOpenPurgeConfirmDialog }}
onCancelAppend={cancelPurge}
onProceedAppend={purgeFiles}
proceedButtonProps={{ sx: purgeButtonStyleOverride }}
titleText={`Are you sure you want to purge ${selectedFilesCount} selected files? `}
/>
</form>
)}
</>
);
};
FileEditForm.defaultProps = FILE_EDIT_FORM_DEFAULT_PROPS;
export default FileEditForm;

@ -1,203 +0,0 @@
import {
Checkbox,
checkboxClasses,
FormControl,
FormControlLabel,
FormGroup,
Grid,
styled,
} from '@mui/material';
import {
Sync as SyncIcon,
SyncDisabled as SyncDisabledIcon,
} from '@mui/icons-material';
import { ReactElement, useMemo } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { BLUE, RED, TEXT } from '../../lib/consts/DEFAULT_THEME';
import { UPLOAD_FILE_TYPES_ARRAY } from '../../lib/consts/UPLOAD_FILE_TYPES';
import List from '../List';
import MenuItem from '../MenuItem';
import OutlinedInput from '../OutlinedInput';
import OutlinedInputLabel from '../OutlinedInputLabel';
import { ExpandablePanel, InnerPanelBody } from '../Panels';
import Select from '../Select';
type FileInfoProps = Pick<FileDetailMetadata, 'fileName' | 'fileLocations'> &
Partial<Pick<FileDetailMetadata, 'fileType'>> & {
isReadonly?: boolean;
onChange?: FileInfoChangeHandler;
};
const FILE_INFO_DEFAULT_PROPS: Partial<FileInfoProps> = {
isReadonly: undefined,
onChange: undefined,
};
const FileLocationActiveCheckbox = styled(Checkbox)({
color: RED,
[`&.${checkboxClasses.checked}`]: {
color: BLUE,
},
});
const FileInfo = (
{
fileName,
fileType,
fileLocations,
isReadonly,
onChange,
}: FileInfoProps = FILE_INFO_DEFAULT_PROPS as FileInfoProps,
): JSX.Element => {
const idExtension = uuidv4();
const fileNameElementId = `file-name-${idExtension}`;
const fileNameElementLabel = 'File name';
const fileTypeElementId = `file-type-${idExtension}`;
const fileTypeElementLabel = 'File type';
const anFileLocations = useMemo(
() =>
fileLocations.reduce<
Record<
string,
Pick<FileLocation, 'anvilDescription' | 'anvilName' | 'anvilUUID'> & {
flocs: FileLocation[];
}
>
>((previous, fileLocation) => {
const { anvilDescription, anvilName, anvilUUID } = fileLocation;
if (!previous[anvilUUID]) {
previous[anvilUUID] = {
anvilDescription,
anvilName,
anvilUUID,
flocs: [],
};
}
previous[anvilUUID].flocs.push(fileLocation);
return previous;
}, {}),
[fileLocations],
);
return (
<FormGroup sx={{ '> :not(:first-child)': { marginTop: '1em' } }}>
<FormControl>
<OutlinedInputLabel htmlFor={fileNameElementId}>
{fileNameElementLabel}
</OutlinedInputLabel>
<OutlinedInput
defaultValue={fileName}
disabled={isReadonly}
id={fileNameElementId}
label={fileNameElementLabel}
onChange={({ target: { value } }) => {
onChange?.call(null, {
fileName: value === fileName ? undefined : value,
});
}}
/>
</FormControl>
{fileType && (
<FormControl>
<OutlinedInputLabel htmlFor={fileTypeElementId}>
{fileTypeElementLabel}
</OutlinedInputLabel>
<Select
defaultValue={fileType}
disabled={isReadonly}
id={fileTypeElementId}
input={<OutlinedInput label={fileTypeElementLabel} />}
onChange={({ target: { value } }) => {
onChange?.call(null, {
fileType: value === fileType ? undefined : (value as FileType),
});
}}
>
{UPLOAD_FILE_TYPES_ARRAY.map(
([fileTypeKey, [, fileTypeDisplayString]]) => (
<MenuItem key={fileTypeKey} value={fileTypeKey}>
{fileTypeDisplayString}
</MenuItem>
),
)}
</Select>
</FormControl>
)}
<List
listItems={anFileLocations}
listProps={{ dense: true, disablePadding: true }}
renderListItem={(anvilUUID, { anvilDescription, anvilName, flocs }) => (
<ExpandablePanel
header={`${anvilName}: ${anvilDescription}`}
panelProps={{ padding: 0, width: '100%' }}
>
<InnerPanelBody>
<Grid
columns={{ xs: 1, sm: 2, md: 3, lg: 4, xl: 5 }}
columnSpacing="1em"
container
direction="row"
>
{flocs.map<ReactElement>(
({
fileLocationUUID: flocUUID,
hostName,
hostUUID,
isFileLocationActive,
}) => (
<Grid item key={`floc-${anvilUUID}-${hostUUID}`} xs={1}>
<FormControlLabel
control={
<FileLocationActiveCheckbox
checkedIcon={<SyncIcon />}
defaultChecked={isFileLocationActive}
disabled={isReadonly}
edge="start"
icon={<SyncDisabledIcon />}
onChange={({ target: { checked } }) => {
onChange?.call(
null,
{
isFileLocationActive:
checked === isFileLocationActive
? undefined
: checked,
},
{
fileLocationIndex: fileLocations.findIndex(
({ fileLocationUUID }) =>
flocUUID === fileLocationUUID,
),
},
);
}}
/>
}
label={hostName}
sx={{ color: TEXT }}
value={`${hostUUID}-sync`}
/>
</Grid>
),
)}
</Grid>
</InnerPanelBody>
</ExpandablePanel>
)}
/>
</FormGroup>
);
};
FileInfo.defaultProps = FILE_INFO_DEFAULT_PROPS;
export default FileInfo;

@ -82,7 +82,7 @@ const FileInputGroup: FC<FileInputGroupProps> = (props) => {
return {
id: activeChain,
name: activeChain,
checked: formik.values[fuuid].locations?.[type][uuid].active,
checked: formik.values[fuuid].locations?.[type]?.[uuid]?.active,
onBlur: handleBlur,
onChange: handleChange,
};

@ -1,80 +0,0 @@
import { Box, Divider, List, ListItem } from '@mui/material';
import * as prettyBytes from 'pretty-bytes';
import { DIVIDER } from '../../lib/consts/DEFAULT_THEME';
import { UPLOAD_FILE_TYPES } from '../../lib/consts/UPLOAD_FILE_TYPES';
import { BodyText } from '../Text';
type FileListProps = {
filesOverview: FileOverviewMetadata[];
};
const FileList = ({ filesOverview }: FileListProps): JSX.Element => (
<List>
{filesOverview.map(
({ fileChecksum, fileName, fileSizeInBytes, fileType, fileUUID }) => {
const fileSize: string = prettyBytes.default(fileSizeInBytes, {
binary: true,
});
return (
<ListItem key={fileUUID} sx={{ padding: '.6em 0' }}>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
width: '100%',
}}
>
<Box sx={{ flexGrow: 1 }}>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
}}
>
<BodyText
sx={{
fontFamily: 'Source Code Pro',
fontWeight: 400,
}}
text={fileName}
/>
<Divider
flexItem
orientation="vertical"
sx={{
backgroundColor: DIVIDER,
marginLeft: '.5em',
marginRight: '.5em',
}}
/>
<BodyText text={UPLOAD_FILE_TYPES.get(fileType)?.[1] ?? ''} />
</Box>
<BodyText text={fileSize} />
</Box>
<Box
sx={{
alignItems: 'center',
display: 'flex',
flexDirection: 'row',
}}
>
<BodyText
sx={{
fontFamily: 'Source Code Pro',
fontWeight: 400,
}}
text={fileChecksum}
/>
</Box>
</Box>
</ListItem>
);
},
)}
</List>
);
export default FileList;

@ -1,296 +0,0 @@
import { Box, Input, InputLabel } from '@mui/material';
import EventEmitter from 'events';
import {
ChangeEventHandler,
FormEventHandler,
ReactElement,
useEffect,
useRef,
useState,
} from 'react';
import { UPLOAD_FILE_TYPES } from '../../lib/consts/UPLOAD_FILE_TYPES';
import api from '../../lib/api';
import { ProgressBar } from '../Bars';
import ContainedButton from '../ContainedButton';
import FileInfo from './FileInfo';
import MessageBox from '../MessageBox';
import { BodyText } from '../Text';
type FileUploadFormProps = {
onFileUploadComplete?: () => void;
eventEmitter?: EventEmitter;
};
type SelectedFile = Pick<
FileDetailMetadata,
'fileName' | 'fileLocations' | 'fileType'
> & {
file: File;
};
type InUploadFile = Pick<FileDetailMetadata, 'fileName'> & {
progressValue: number;
};
const FILE_UPLOAD_FORM_DEFAULT_PROPS: Partial<FileUploadFormProps> = {
onFileUploadComplete: undefined,
eventEmitter: undefined,
};
const FILE_UPLOAD_PERCENT_MAX = 99;
const FileUploadForm = (
{
onFileUploadComplete,
eventEmitter,
}: FileUploadFormProps = FILE_UPLOAD_FORM_DEFAULT_PROPS as FileUploadFormProps,
): JSX.Element => {
const selectFileRef = useRef<HTMLInputElement>();
const [selectedFiles, setSelectedFiles] = useState<SelectedFile[]>([]);
const [inUploadFiles, setInUploadFiles] = useState<
(InUploadFile | undefined)[]
>([]);
const convertMIMETypeToFileTypeKey = (fileMIMEType: string): FileType => {
const fileTypesIterator = UPLOAD_FILE_TYPES.entries();
let fileType: FileType | undefined;
do {
const fileTypesResult = fileTypesIterator.next();
if (fileTypesResult.value) {
const [fileTypeKey, [mimeTypeToUse]] = fileTypesResult.value;
if (fileMIMEType === mimeTypeToUse) {
fileType = fileTypeKey;
}
} else {
fileType = 'other';
}
} while (!fileType);
return fileType;
};
const autocompleteAfterSelectFile: ChangeEventHandler<HTMLInputElement> = ({
target: { files },
}) => {
if (files) {
setSelectedFiles(
Array.from(files).map(
(file): SelectedFile => ({
file,
fileName: file.name,
fileLocations: [],
fileType: convertMIMETypeToFileTypeKey(file.type),
}),
),
);
}
};
const generateFileInfoOnChangeHandler =
(fileIndex: number): FileInfoChangeHandler =>
(inputValues) => {
selectedFiles[fileIndex] = {
...selectedFiles[fileIndex],
...inputValues,
};
};
const uploadFiles: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
while (selectedFiles.length > 0) {
const selectedFile = selectedFiles.shift();
if (selectedFile) {
const { file, fileName } = selectedFile;
const fileFormData = new FormData();
fileFormData.append('file', new File([file], fileName, { ...file }));
// Re-add when the back-end tools can support changing file type on file upload.
// Note: get file type from destructuring selectedFile.
// fileFormData.append('file-type', fileType);
const inUploadFile: InUploadFile = { fileName, progressValue: 0 };
inUploadFiles.push(inUploadFile);
api
.post('/file', fileFormData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (
(fName: string) =>
({ loaded, total }) => {
setInUploadFiles((previous) => {
const fInfo = previous.find((f) => f?.fileName === fName);
if (!fInfo) return previous;
/**
* Use 99 as upper limit because progress event doesn't
* represent the "bytes out of total" written to the remote
* disk. The write completes when the request completes.
*/
fInfo.progressValue = Math.round(
(loaded / total) * FILE_UPLOAD_PERCENT_MAX,
);
return [...previous];
});
}
)(fileName),
})
.then(
((fName: string) => () => {
setInUploadFiles((previous) => {
const fInfo = previous.find((f) => f?.fileName === fName);
if (!fInfo) return previous;
fInfo.progressValue = 100;
return [...previous];
});
setTimeout(() => {
setInUploadFiles((previous) => {
const fIndex = previous.findIndex(
(f) => f?.fileName === fName,
);
if (fIndex === -1) return previous;
delete previous[fIndex];
return [...previous];
});
}, 5000);
onFileUploadComplete?.call(null);
})(fileName),
);
}
}
// Clears "staging area" (selected files) and populates "in-progress area" (in-upload files).
setSelectedFiles([]);
setInUploadFiles([...inUploadFiles]);
};
useEffect(() => {
eventEmitter?.addListener('openFilePicker', () => {
selectFileRef.current?.click();
});
eventEmitter?.addListener('clearSelectedFiles', () => {
setSelectedFiles([]);
});
}, [eventEmitter]);
return (
<form onSubmit={uploadFiles}>
<InputLabel htmlFor="select-file">
<Input
id="select-file"
inputProps={{ multiple: true }}
onChange={autocompleteAfterSelectFile}
ref={selectFileRef}
sx={{ display: 'none' }}
type="file"
/>
</InputLabel>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
{inUploadFiles.reduce<ReactElement[]>((previous, upFile) => {
if (!upFile) return previous;
const { fileName, progressValue } = upFile;
previous.push(
<Box
key={`in-upload-${fileName}`}
sx={{
alignItems: { md: 'center' },
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
'& > :first-child': {
minWidth: 100,
overflow: 'hidden',
overflowWrap: 'normal',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
width: { xs: '100%', md: 200 },
wordBreak: 'keep-all',
},
'& > :last-child': { flexGrow: 1 },
}}
>
<BodyText text={fileName} />
<ProgressBar progressPercentage={progressValue} />
</Box>,
);
return previous;
}, [])}
</Box>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
'& > :not(:first-child)': { marginTop: '1em' },
}}
>
{selectedFiles.length > 0 && (
<MessageBox type="info">
Uploaded files will be listed automatically, but it may take a while
for larger files to finish uploading and appear on the list.
</MessageBox>
)}
{selectedFiles.map(
(
{
file: { name: originalFileName },
fileName,
// Re-add when the back-end tools can support changing file type on file upload.
// Note: file type must be supplied to FileInfo.
// fileType,
fileLocations,
},
fileIndex,
) => (
<FileInfo
{...{ fileName, fileLocations }}
// Use a non-changing key to prevent recreating the component.
// fileName holds the string from the file-name input, thus it changes when users makes a change.
key={`selected-${originalFileName}`}
onChange={generateFileInfoOnChangeHandler(fileIndex)}
/>
),
)}
{selectedFiles.length > 0 && (
<Box
sx={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'flex-end',
}}
>
<ContainedButton type="submit">Upload</ContainedButton>
</Box>
)}
</Box>
</form>
);
};
FileUploadForm.defaultProps = FILE_UPLOAD_FORM_DEFAULT_PROPS;
export default FileUploadForm;

@ -1,148 +0,0 @@
import { useEffect, useState } from 'react';
import { Box } from '@mui/material';
import {
Add as AddIcon,
Check as CheckIcon,
Edit as EditIcon,
} from '@mui/icons-material';
import EventEmitter from 'events';
import API_BASE_URL from '../../lib/consts/API_BASE_URL';
import { BLUE } from '../../lib/consts/DEFAULT_THEME';
import FileEditForm from './FileEditForm';
import FileList from './FileList';
import FileUploadForm from './FileUploadForm';
import IconButton from '../IconButton';
import { Panel } from '../Panels';
import MessageBox from '../MessageBox';
import Spinner from '../Spinner';
import { HeaderText } from '../Text';
import fetchJSON from '../../lib/fetchers/fetchJSON';
import periodicFetch from '../../lib/fetchers/periodicFetch';
const FILES_ENDPOINT_URL = `${API_BASE_URL}/file`;
const Files = (): JSX.Element => {
const [rawFilesOverview, setRawFilesOverview] = useState<string[][]>([]);
const [fetchRawFilesError, setFetchRawFilesError] = useState<string>();
const [isLoadingRawFilesOverview, setIsLoadingRawFilesOverview] =
useState<boolean>(false);
const [isEditMode, setIsEditMode] = useState<boolean>(false);
const fileUploadFormEventEmitter: EventEmitter = new EventEmitter();
const onAddFileButtonClick = () => {
fileUploadFormEventEmitter.emit('openFilePicker');
};
const onEditFileButtonClick = () => {
fileUploadFormEventEmitter.emit('clearSelectedFiles');
setIsEditMode(!isEditMode);
};
const fetchRawFilesOverview = async () => {
setIsLoadingRawFilesOverview(true);
try {
const data = await fetchJSON<string[][]>(FILES_ENDPOINT_URL);
setRawFilesOverview(data);
} catch (fetchError) {
setFetchRawFilesError('Failed to get files due to a network issue.');
}
setIsLoadingRawFilesOverview(false);
};
const buildFileList = (): JSX.Element => {
let elements: JSX.Element;
if (isLoadingRawFilesOverview) {
elements = <Spinner />;
} else {
const filesOverview: FileOverviewMetadata[] = rawFilesOverview.map(
([fileUUID, fileName, fileSizeInBytes, fileType, fileChecksum]) => ({
fileChecksum,
fileName,
fileSizeInBytes: parseInt(fileSizeInBytes, 10),
fileType: fileType as FileType,
fileUUID,
}),
);
elements = isEditMode ? (
<FileEditForm
{...{ filesOverview }}
onEditFilesComplete={fetchRawFilesOverview}
onPurgeFilesComplete={fetchRawFilesOverview}
/>
) : (
<FileList {...{ filesOverview }} />
);
}
return elements;
};
/**
* Check for new files periodically and update the file list.
*
* We need this because adding new files is done async; adding the file may
* not finish before the request returns.
*
* We don't care about edit because database updates are done before the
* edit request returns.
*/
periodicFetch<string[][]>(FILES_ENDPOINT_URL, {
onSuccess: (periodicFilesOverview) => {
if (periodicFilesOverview.length !== rawFilesOverview.length) {
setRawFilesOverview(periodicFilesOverview);
}
},
});
useEffect(() => {
if (!isEditMode) {
fetchRawFilesOverview();
}
}, [isEditMode]);
return (
<Panel>
<Box
sx={{
alignItems: 'center',
display: 'flex',
flexDirection: 'row',
marginBottom: '1em',
width: '100%',
'& > :first-child': { flexGrow: 1 },
'& > :not(:first-child, :last-child)': {
marginRight: '.3em',
},
}}
>
<HeaderText text="Files" />
{!isEditMode && (
<IconButton onClick={onAddFileButtonClick}>
<AddIcon />
</IconButton>
)}
<IconButton onClick={onEditFileButtonClick}>
{isEditMode ? <CheckIcon sx={{ color: BLUE }} /> : <EditIcon />}
</IconButton>
</Box>
{fetchRawFilesError && (
<MessageBox text={fetchRawFilesError} type="error" />
)}
<FileUploadForm
{...{ eventEmitter: fileUploadFormEventEmitter }}
onFileUploadComplete={fetchRawFilesOverview}
/>
{buildFileList()}
</Panel>
);
};
export default Files;

@ -1,3 +1,3 @@
import Files from './Files';
import ManageFilePanel from './ManageFilePanel';
export default Files;
export default ManageFilePanel;

@ -745,7 +745,7 @@ const createVirtualDiskForm = (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<OutlinedLabeledInputWithSelect
id={`ps-virtual-disk-size-${vdIndex}`}
label="Virtual disk size"
label="Disk size"
messageBoxProps={get('inputSizeMessages')}
inputWithLabelProps={{
inputProps: {
@ -1379,7 +1379,11 @@ const ProvisionServerDialog = ({
<BodyText text="Memory" />
</Grid>
<Grid item xs={c2}>
<InlineMonoText text={`${inputMemoryValue} ${inputMemoryUnit}`} />
<BodyText>
<InlineMonoText>
{inputMemoryValue} {inputMemoryUnit}
</InlineMonoText>
</BodyText>
</Grid>
<Grid item xs={c3}>
<BodyText>
@ -1407,7 +1411,7 @@ const ProvisionServerDialog = ({
>
<Grid item xs={c1}>
<BodyText>
Virtual disk <InlineMonoText text={vdIndex} />
Disk <InlineMonoText text={vdIndex} />
</BodyText>
</Grid>
<Grid item xs={c2}>
@ -1430,9 +1434,11 @@ const ProvisionServerDialog = ({
<BodyText text="Install ISO" />
</Grid>
<Grid item xs={c2n3}>
<InlineMonoText
text={fileUUIDMapToData[inputInstallISOFileUUID].fileName}
/>
<BodyText>
<InlineMonoText>
{fileUUIDMapToData[inputInstallISOFileUUID].fileName}
</InlineMonoText>
</BodyText>
</Grid>
</Grid>
<Grid container direction="row" item xs={gridColumns}>
@ -1440,13 +1446,15 @@ const ProvisionServerDialog = ({
<BodyText text="Driver ISO" />
</Grid>
<Grid item xs={c2n3}>
<BodyText>
{fileUUIDMapToData[inputDriverISOFileUUID] ? (
<InlineMonoText
text={fileUUIDMapToData[inputDriverISOFileUUID].fileName}
/>
<InlineMonoText>
{fileUUIDMapToData[inputDriverISOFileUUID].fileName}
</InlineMonoText>
) : (
<BodyText text="none" />
'none'
)}
</BodyText>
</Grid>
</Grid>
<Grid container direction="row" item xs={gridColumns}>

@ -1 +1 @@
self.__BUILD_MANIFEST=function(s,a,c,e,t,n,i,f,b,u,k,h,j,d,r,g,l,_){return{__rewrites:{beforeFiles:[],afterFiles:[],fallback:[]},"/":[s,c,e,f,b,j,"static/chunks/433-a3be905e7a7d3bfc.js",a,t,n,i,d,r,"static/chunks/pages/index-0771f2825962ebc3.js"],"/_error":["static/chunks/pages/_error-2280fa386d040b66.js"],"/anvil":[s,c,e,f,b,j,a,t,n,i,d,"static/chunks/pages/anvil-53b02ffa883f4c5a.js"],"/config":[s,c,e,k,"static/chunks/519-4b7761e884c88eb9.js",a,t,n,i,u,h,g,"static/chunks/pages/config-7be24d332b231569.js"],"/file-manager":["static/chunks/29107295-fbcfe2172188e46f.js",s,c,e,f,"static/chunks/176-7308c25ba374961e.js",a,t,i,u,"static/chunks/pages/file-manager-0697bf1cd793df6d.js"],"/init":[s,c,f,b,k,l,a,t,n,i,_,"static/chunks/pages/init-7cf62951388d0e3b.js"],"/login":[s,c,e,a,t,n,u,h,"static/chunks/pages/login-0b2f91a926538f7c.js"],"/manage-element":[s,c,e,f,b,k,l,"static/chunks/111-2605129c170ed35d.js",a,t,n,i,u,h,_,g,"static/chunks/pages/manage-element-3ed34f8c3a72590a.js"],"/server":[s,e,"static/chunks/528-72edc50189f30fa9.js",a,r,"static/chunks/pages/server-d4d91dcbacc827c4.js"],sortedPages:["/","/_app","/_error","/anvil","/config","/file-manager","/init","/login","/manage-element","/server"]}}("static/chunks/412-ae4bab5809f6a209.js","static/chunks/62-2c80eba24b792af8.js","static/chunks/438-0147a63d98e89439.js","static/chunks/894-e57948de523bcf96.js","static/chunks/195-fa06e61dd4339031.js","static/chunks/987-1ff0d82724b0e58b.js","static/chunks/157-d1418743accab385.js","static/chunks/182-08683bbe95fbb010.js","static/chunks/900-af716a39aed22219.js","static/chunks/248-749f2bec4cb43d28.js","static/chunks/644-c7c6e21c71345aed.js","static/chunks/336-6e600f08d9387d72.js","static/chunks/485-77798bccc4308d0e.js","static/chunks/825-a143aba6cb430f0f.js","static/chunks/94-8322ed453a3c08f0.js","static/chunks/560-0ed707609765e23a.js","static/chunks/676-6159ce853338cc1f.js","static/chunks/86-447b52c8195dea3d.js"),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB();
self.__BUILD_MANIFEST=function(s,a,c,e,t,n,i,f,b,u,k,h,j,d,r,g,l,_){return{__rewrites:{beforeFiles:[],afterFiles:[],fallback:[]},"/":[s,c,e,f,b,j,"static/chunks/433-a3be905e7a7d3bfc.js",a,t,n,i,d,r,"static/chunks/pages/index-0771f2825962ebc3.js"],"/_error":["static/chunks/pages/_error-2280fa386d040b66.js"],"/anvil":[s,c,e,f,b,j,a,t,n,i,d,"static/chunks/pages/anvil-53b02ffa883f4c5a.js"],"/config":[s,c,e,k,"static/chunks/519-4b7761e884c88eb9.js",a,t,n,i,u,h,g,"static/chunks/pages/config-7be24d332b231569.js"],"/file-manager":["static/chunks/29107295-fbcfe2172188e46f.js",s,c,e,f,"static/chunks/176-7308c25ba374961e.js",a,t,i,u,"static/chunks/pages/file-manager-6501dafd856c22ec.js"],"/init":[s,c,f,b,k,l,a,t,n,i,_,"static/chunks/pages/init-7cf62951388d0e3b.js"],"/login":[s,c,e,a,t,n,u,h,"static/chunks/pages/login-0b2f91a926538f7c.js"],"/manage-element":[s,c,e,f,b,k,l,"static/chunks/111-2605129c170ed35d.js",a,t,n,i,u,h,_,g,"static/chunks/pages/manage-element-3ed34f8c3a72590a.js"],"/server":[s,e,"static/chunks/528-72edc50189f30fa9.js",a,r,"static/chunks/pages/server-d4d91dcbacc827c4.js"],sortedPages:["/","/_app","/_error","/anvil","/config","/file-manager","/init","/login","/manage-element","/server"]}}("static/chunks/412-ae4bab5809f6a209.js","static/chunks/62-2c80eba24b792af8.js","static/chunks/438-0147a63d98e89439.js","static/chunks/894-e57948de523bcf96.js","static/chunks/195-fa06e61dd4339031.js","static/chunks/987-1ff0d82724b0e58b.js","static/chunks/157-d1418743accab385.js","static/chunks/182-08683bbe95fbb010.js","static/chunks/900-af716a39aed22219.js","static/chunks/248-749f2bec4cb43d28.js","static/chunks/644-c7c6e21c71345aed.js","static/chunks/336-6e600f08d9387d72.js","static/chunks/485-77798bccc4308d0e.js","static/chunks/825-07aab1f379d63d3c.js","static/chunks/94-8322ed453a3c08f0.js","static/chunks/560-0ed707609765e23a.js","static/chunks/676-6159ce853338cc1f.js","static/chunks/86-447b52c8195dea3d.js"),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -1,7 +1,7 @@
import Head from 'next/head';
import Header from '../../components/Header';
import ManageFilePanel from '../../components/Files/ManageFilePanel';
import ManageFilePanel from '../../components/Files';
const FileManager = (): JSX.Element => (
<>

@ -1,3 +0,0 @@
type FileDetailMetadata = FileOverviewMetadata & {
fileLocations: FileLocation[];
};

@ -1,4 +0,0 @@
type FileInfoChangeHandler = (
inputValues: Partial<FileDetailMetadata> | Partial<FileLocation>,
options?: { fileLocationIndex?: number },
) => void;

@ -1,9 +0,0 @@
type FileLocation = {
anvilName: string;
anvilDescription: string;
anvilUUID: string;
fileLocationUUID: string;
hostName: string;
hostUUID: string;
isFileLocationActive: boolean;
};

@ -1,7 +0,0 @@
type FileOverviewMetadata = {
fileChecksum: string;
fileName: string;
fileSizeInBytes: number;
fileType: FileType;
fileUUID: string;
};

@ -474,19 +474,19 @@ sub pstderr
$anvil->Get->switches;
$anvil->Database->connect;
$anvil->Log->entry({ source => $THIS_FILE, line => __LINE__, level => 2, secure => 0, key => "log_0132" });
if (not $anvil->data->{sys}{database}{connections})
{
# No databases, exit.
$anvil->Log->entry({ source => $THIS_FILE, line => __LINE__, level => 0, 'print' => 1, priority => "err", key => "error_0003" });
$anvil->nice_exit({ exit_code => 1 });
}
my $data_hash = $anvil->data->{switches}{'data'};
my $switch_debug = $anvil->data->{switches}{'debug'} // 3;
my $db_access_mode = $anvil->data->{switches}{'mode'} // "";
my $db_uuid = $anvil->data->{switches}{'uuid'};
#
# Events in this context are simply printing the event name before
# and/or after operations. The output should enable other programs to
# parse and activate additional logic as necessary.
#
# Event names should be present-tense before an operation, and
# past-tense after.
#
my $emit_events = $anvil->data->{switches}{'emit-events'};
my $pre_data = $anvil->data->{switches}{'predata'};
my $script_file = $anvil->data->{switches}{'script'} // "-";
my $sql_query = $anvil->data->{switches}{'query'};
@ -494,6 +494,19 @@ my $sub_module_name = $anvil->data->{switches}{'sub-module'} // "Database";
my $sub_name = $anvil->data->{switches}{'sub'} // "";
my $sub_params = $anvil->data->{switches}{'sub-params'} // "{}";
emit("initialized");
$anvil->Database->connect;
$anvil->Log->entry({ source => $THIS_FILE, line => __LINE__, level => 2, secure => 0, key => "log_0132" });
if (not $anvil->data->{sys}{database}{connections})
{
# No databases, exit.
$anvil->Log->entry({ source => $THIS_FILE, line => __LINE__, level => 0, 'print' => 1, priority => "err", key => "error_0003" });
$anvil->nice_exit({ exit_code => 1 });
}
emit("connected");
if ($sql_query)
{
my $results = db_access({ db_uuid => $db_uuid, sql_query => $sql_query, db_access_mode => $db_access_mode });
@ -612,4 +625,16 @@ else
};
}
emit("exit");
$anvil->nice_exit({ exit_code => 0 });
##################################################
# Functions
##################################################
# TODO: need to move all subroutines down here.
sub emit
{
pstdout("event=".$_[0]) if ($emit_events);
}

Loading…
Cancel
Save