♻️ Add compability for legacy config in config loader

This commit is contained in:
Manuel Ruwe
2022-12-31 23:31:41 +01:00
parent 2eb5cdfafc
commit 75f6029057
10 changed files with 219 additions and 115 deletions

View File

@@ -57,9 +57,11 @@
} }
}, },
"accept": { "accept": {
"title": "Configuration Upload",
"text": "Drag files here to upload a config. Support for JSON only." "text": "Drag files here to upload a config. Support for JSON only."
}, },
"reject": { "reject": {
"title": "Drag and Drop Upload rejected",
"text": "This file format is not supported. Please only upload JSON." "text": "This file format is not supported. Please only upload JSON."
} }
} }

View File

@@ -1,23 +1,27 @@
import { Group, Text, useMantineTheme } from '@mantine/core'; import { Group, Stack, Text, Title, useMantineTheme } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { showNotification } from '@mantine/notifications'; import { showNotification } from '@mantine/notifications';
import { IconCheck as Check, IconPhoto, IconUpload, IconX, IconX as X } from '@tabler/icons'; import { IconCheck as Check, IconPhoto, IconUpload, IconX, IconX as X } from '@tabler/icons';
import { setCookie } from 'cookies-next'; import { setCookie } from 'cookies-next';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useConfigStore } from '../../config/store'; import { useConfigStore } from '../../config/store';
import { migrateConfig } from '../../tools/config/migrateConfig';
import { Config } from '../../tools/types'; import { Config } from '../../tools/types';
import { ConfigType } from '../../types/config';
export default function LoadConfigComponent(props: any) { export const LoadConfigComponent = () => {
const { updateConfig } = useConfigStore(); const { addConfig } = useConfigStore();
const theme = useMantineTheme(); const theme = useMantineTheme();
const { t } = useTranslation('settings/general/config-changer'); const { t } = useTranslation('settings/general/config-changer');
return ( return (
<Dropzone.FullScreen <Dropzone.FullScreen
onDrop={(files) => { onDrop={async (files) => {
files[0].text().then((e) => { const fileName = files[0].name.replaceAll('.json', '');
const fileText = await files[0].text();
try { try {
JSON.parse(e) as Config; JSON.parse(fileText) as ConfigType;
} catch (e) { } catch (e) {
showNotification({ showNotification({
autoClose: 5000, autoClose: 5000,
@@ -28,14 +32,23 @@ export default function LoadConfigComponent(props: any) {
}); });
return; return;
} }
const newConfig: Config = JSON.parse(e);
let newConfig: ConfigType = JSON.parse(fileText);
if (!newConfig.schemaVersion) {
console.warn('a legacy configuration schema was deteced and migrated to the current schema');
const oldConfig = JSON.parse(fileText) as Config;
newConfig = migrateConfig(oldConfig);
}
await addConfig(fileName, newConfig, true);
showNotification({ showNotification({
autoClose: 5000, autoClose: 5000,
radius: 'md', radius: 'md',
title: ( title: (
<Text> <Text>
{t('dropzone.notifications.loadedSuccessfully.title', { {t('dropzone.notifications.loadedSuccessfully.title', {
configName: newConfig.name, configName: fileName,
})} })}
</Text> </Text>
), ),
@@ -43,35 +56,39 @@ export default function LoadConfigComponent(props: any) {
icon: <Check />, icon: <Check />,
message: undefined, message: undefined,
}); });
setCookie('config-name', newConfig.name, { setCookie('config-name', fileName, {
maxAge: 60 * 60 * 24 * 30, maxAge: 60 * 60 * 24 * 30,
sameSite: 'strict', sameSite: 'strict',
}); });
updateConfig(newConfig.name, (previousConfig) => ({ ...previousConfig, newConfig }));
});
}} }}
accept={['application/json']} accept={['application/json']}
> >
<Group position="center" spacing="xl" style={{ minHeight: 220, pointerEvents: 'none' }}> <Group position="center" spacing="xl" style={{ minHeight: 220, pointerEvents: 'none' }}>
<Dropzone.Accept> <Dropzone.Accept>
<Text size="xl" inline> <Stack align="center">
<IconUpload <IconUpload
size={50} size={50}
stroke={1.5} stroke={1.5}
color={theme.colors[theme.primaryColor][theme.colorScheme === 'dark' ? 4 : 6]} color={theme.colors[theme.primaryColor][theme.colorScheme === 'dark' ? 4 : 6]}
/> />
<Title>{t('dropzone.accept.title')}</Title>
<Text size="xl" inline>
{t('dropzone.accept.text')} {t('dropzone.accept.text')}
</Text> </Text>
</Stack>
</Dropzone.Accept> </Dropzone.Accept>
<Dropzone.Reject> <Dropzone.Reject>
<Text size="xl" inline> <Stack align="center">
<IconX <IconX
size={50} size={50}
stroke={1.5} stroke={1.5}
color={theme.colors.red[theme.colorScheme === 'dark' ? 4 : 6]} color={theme.colors.red[theme.colorScheme === 'dark' ? 4 : 6]}
/> />
<Title>{t('dropzone.reject.title')}</Title>
<Text size="xl" inline>
{t('dropzone.reject.text')} {t('dropzone.reject.text')}
</Text> </Text>
</Stack>
</Dropzone.Reject> </Dropzone.Reject>
<Dropzone.Idle> <Dropzone.Idle>
<IconPhoto size={50} stroke={1.5} /> <IconPhoto size={50} stroke={1.5} />
@@ -79,4 +96,4 @@ export default function LoadConfigComponent(props: any) {
</Group> </Group>
</Dropzone.FullScreen> </Dropzone.FullScreen>
); );
} };

View File

@@ -32,8 +32,6 @@ export const WidgetsEditModal = ({
if (!configName || !innerProps.options) return null; if (!configName || !innerProps.options) return null;
console.log(`loaded namespace modules/${innerProps.widgetId}`);
const handleChange = (key: string, value: IntegrationOptionsValueType) => { const handleChange = (key: string, value: IntegrationOptionsValueType) => {
setModuleProperties((prev) => { setModuleProperties((prev) => {
const copyOfPrev: any = { ...prev }; const copyOfPrev: any = { ...prev };

View File

@@ -13,6 +13,20 @@ export const useConfigStore = create<UseConfigStoreType>((set, get) => ({
], ],
})); }));
}, },
addConfig: async (name: string, config: ConfigType, shouldSaveConfigToFileSystem = true) => {
set((old) => ({
...old,
configs: [
...old.configs.filter((x) => x.value.configProperties.name !== name),
{ value: config, increaseVersion: () => {} },
],
}));
if (!shouldSaveConfigToFileSystem) {
return;
}
axios.put(`/api/configs/${name}`, { ...config });
},
updateConfig: async ( updateConfig: async (
name, name,
updateCallback: (previous: ConfigType) => ConfigType, updateCallback: (previous: ConfigType) => ConfigType,
@@ -21,7 +35,9 @@ export const useConfigStore = create<UseConfigStoreType>((set, get) => ({
) => { ) => {
const { configs } = get(); const { configs } = get();
const currentConfig = configs.find((x) => x.value.configProperties.name === name); const currentConfig = configs.find((x) => x.value.configProperties.name === name);
if (!currentConfig) return; if (!currentConfig) {
return;
}
// copies the value of currentConfig and creates a non reference object named previousConfig // copies the value of currentConfig and creates a non reference object named previousConfig
const previousConfig: ConfigType = JSON.parse(JSON.stringify(currentConfig.value)); const previousConfig: ConfigType = JSON.parse(JSON.stringify(currentConfig.value));
@@ -51,6 +67,11 @@ export const useConfigStore = create<UseConfigStoreType>((set, get) => ({
interface UseConfigStoreType { interface UseConfigStoreType {
configs: { increaseVersion: () => void; value: ConfigType }[]; configs: { increaseVersion: () => void; value: ConfigType }[];
initConfig: (name: string, config: ConfigType, increaseVersion: () => void) => void; initConfig: (name: string, config: ConfigType, increaseVersion: () => void) => void;
addConfig: (
name: string,
config: ConfigType,
shouldSaveConfigToFileSystem: boolean,
) => Promise<void>;
updateConfig: ( updateConfig: (
name: string, name: string,
updateCallback: (previous: ConfigType) => ConfigType, updateCallback: (previous: ConfigType) => ConfigType,

View File

@@ -1,7 +1,7 @@
import fs from 'fs'; import fs from 'fs';
import { GetServerSidePropsContext } from 'next'; import { GetServerSidePropsContext } from 'next';
import path from 'path'; import path from 'path';
import LoadConfigComponent from '../components/Config/LoadConfig'; import { LoadConfigComponent } from '../components/Config/LoadConfig';
import { Dashboard } from '../components/Dashboard/Dashboard'; import { Dashboard } from '../components/Dashboard/Dashboard';
import Layout from '../components/layout/Layout'; import Layout from '../components/layout/Layout';
import { useInitConfig } from '../config/init'; import { useInitConfig } from '../config/init';

View File

@@ -1,6 +1,7 @@
import { NextApiRequest, NextApiResponse } from 'next';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import Consola from 'consola';
import { NextApiRequest, NextApiResponse } from 'next';
import { BackendConfigType, ConfigType } from '../../../types/config'; import { BackendConfigType, ConfigType } from '../../../types/config';
import { getConfig } from '../../../tools/config/getConfig'; import { getConfig } from '../../../tools/config/getConfig';
@@ -10,11 +11,14 @@ function Put(req: NextApiRequest, res: NextApiResponse) {
// Get the body of the request // Get the body of the request
const { body: config }: { body: ConfigType } = req; const { body: config }: { body: ConfigType } = req;
if (!slug || !config) { if (!slug || !config) {
Consola.warn('Rejected configuration update because either config or slug were undefined');
return res.status(400).json({ return res.status(400).json({
error: 'Wrong request', error: 'Wrong request',
}); });
} }
Consola.info(`Saving updated configuration of '${slug}' config.`);
const previousConfig = getConfig(slug); const previousConfig = getConfig(slug);
const newConfig: BackendConfigType = { const newConfig: BackendConfigType = {
@@ -56,11 +60,11 @@ function Put(req: NextApiRequest, res: NextApiResponse) {
}; };
// Save the body in the /data/config folder with the slug as filename // Save the body in the /data/config folder with the slug as filename
fs.writeFileSync( const targetPath = path.join('data/configs', `${slug}.json`);
path.join('data/configs', `${slug}.json`), fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8');
JSON.stringify(newConfig, null, 2),
'utf8' Consola.info(`Config '${slug}' has been updated and flushed to '${targetPath}'.`);
);
return res.status(200).json({ return res.status(200).json({
message: 'Configuration saved with success', message: 'Configuration saved with success',
}); });

View File

@@ -2,7 +2,7 @@ import { getCookie, setCookie } from 'cookies-next';
import { GetServerSidePropsContext } from 'next'; import { GetServerSidePropsContext } from 'next';
import fs from 'fs'; import fs from 'fs';
import LoadConfigComponent from '../components/Config/LoadConfig'; import Consola from 'consola';
import { Dashboard } from '../components/Dashboard/Dashboard'; import { Dashboard } from '../components/Dashboard/Dashboard';
import Layout from '../components/layout/Layout'; import Layout from '../components/layout/Layout';
import { useInitConfig } from '../config/init'; import { useInitConfig } from '../config/init';
@@ -10,6 +10,7 @@ import { getFrontendConfig } from '../tools/config/getFrontendConfig';
import { getServerSideTranslations } from '../tools/getServerSideTranslations'; import { getServerSideTranslations } from '../tools/getServerSideTranslations';
import { dashboardNamespaces } from '../tools/translation-namespaces'; import { dashboardNamespaces } from '../tools/translation-namespaces';
import { DashboardServerSideProps } from '../types/dashboardPageType'; import { DashboardServerSideProps } from '../types/dashboardPageType';
import { LoadConfigComponent } from '../components/Config/LoadConfig';
export async function getServerSideProps({ export async function getServerSideProps({
req, req,
@@ -46,6 +47,9 @@ export async function getServerSideProps({
} }
const translations = await getServerSideTranslations(req, res, dashboardNamespaces, locale); const translations = await getServerSideTranslations(req, res, dashboardNamespaces, locale);
Consola.info(`Decided to use configuration '${configName}'`);
const config = getFrontendConfig(configName as string); const config = getFrontendConfig(configName as string);
return { return {

View File

@@ -1,45 +1,45 @@
import React, { useEffect, useState } from 'react';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import fs from 'fs'; import fs from 'fs';
import { GetServerSidePropsContext } from 'next'; import { GetServerSidePropsContext } from 'next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import React, { useEffect, useState } from 'react';
import { import {
createStyles, Alert,
Title, Anchor,
Text,
Container,
Group,
Stepper,
useMantineTheme,
Header,
AppShell, AppShell,
useMantineColorScheme, Badge,
Switch,
Box, Box,
Button, Button,
Alert, Container,
Badge, createStyles,
Group,
Header,
List, List,
Loader, Loader,
Paper, Paper,
Progress, Progress,
Space, Space,
Stack, Stack,
Stepper,
Switch,
Text,
ThemeIcon, ThemeIcon,
Anchor, Title,
useMantineColorScheme,
useMantineTheme,
} from '@mantine/core'; } from '@mantine/core';
import { import {
IconSun,
IconMoonStars,
IconCheck,
IconAlertCircle, IconAlertCircle,
IconCircleCheck,
IconBrandDiscord, IconBrandDiscord,
IconCheck,
IconCircleCheck,
IconMoonStars,
IconSun,
} from '@tabler/icons'; } from '@tabler/icons';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Logo } from '../components/layout/Logo'; import { Logo } from '../components/layout/Logo';
import { usePrimaryGradient } from '../components/layout/useGradient'; import { usePrimaryGradient } from '../components/layout/useGradient';
import { migrateConfig } from '../tools/config/migrateConfig'; import { backendMigrateConfig } from '../tools/config/backendMigrateConfig';
const useStyles = createStyles((theme) => ({ const useStyles = createStyles((theme) => ({
root: { root: {
@@ -274,7 +274,7 @@ export async function getServerSideProps({ req, res, locale }: GetServerSideProp
const configData = JSON.parse(fs.readFileSync(`./data/configs/${config}`, 'utf8')); const configData = JSON.parse(fs.readFileSync(`./data/configs/${config}`, 'utf8'));
if (!configData.schemaVersion) { if (!configData.schemaVersion) {
// Migrate the config // Migrate the config
migrateConfig(configData, config.replace('.json', '')); backendMigrateConfig(configData, config.replace('.json', ''));
} }
return config; return config;
}); });

View File

@@ -0,0 +1,14 @@
import fs from 'fs';
import { ConfigType } from '../../types/config';
import { Config } from '../types';
import { migrateConfig } from './migrateConfig';
export function backendMigrateConfig(config: Config, name: string): ConfigType {
const migratedConfig = migrateConfig(config);
// Overrite the file ./data/configs/${name}.json
// with the new config format
fs.writeFileSync(`./data/configs/${name}.json`, JSON.stringify(migratedConfig, null, 2));
return migratedConfig;
}

View File

@@ -1,8 +1,11 @@
import fs from 'fs'; import { v4 as uuidv4 } from 'uuid';
import { AppType } from '../../types/app';
import { AreaType } from '../../types/area';
import { CategoryType } from '../../types/category';
import { ConfigType } from '../../types/config'; import { ConfigType } from '../../types/config';
import { Config } from '../types'; import { Config, serviceItem } from '../types';
export function migrateConfig(config: Config, name: string): ConfigType { export function migrateConfig(config: Config): ConfigType {
const newConfig: ConfigType = { const newConfig: ConfigType = {
schemaVersion: 1, schemaVersion: 1,
configProperties: { configProperties: {
@@ -41,45 +44,86 @@ export function migrateConfig(config: Config, name: string): ConfigType {
], ],
}; };
newConfig.apps = config.services.map((s, idx) => ({ config.services.forEach((service, index) => {
name: s.name, const { category: categoryName } = service;
id: s.id,
url: s.url, if (!categoryName) {
newConfig.apps.push(
migrateService(service, index, {
type: 'wrapper',
properties: {
id: 'default',
},
})
);
return;
}
const category = getConfigAndCreateIfNotExsists(newConfig, categoryName);
if (!category) {
return;
}
newConfig.apps.push(
migrateService(service, index, { type: 'category', properties: { id: category.id } })
);
});
return newConfig;
}
const getConfigAndCreateIfNotExsists = (
config: ConfigType,
categoryName: string
): CategoryType | null => {
const foundCategory = config.categories.find((c) => c.name === categoryName);
if (foundCategory) {
return foundCategory;
}
const category: CategoryType = {
id: uuidv4(),
name: categoryName,
position: 0,
};
config.categories.push(category);
return category;
};
const migrateService = (
oldService: serviceItem,
serviceIndex: number,
areaType: AreaType
): AppType => ({
id: uuidv4(),
name: oldService.name,
url: oldService.url,
behaviour: { behaviour: {
isOpeningNewTab: s.newTab ?? true, isOpeningNewTab: oldService.newTab ?? true,
externalUrl: s.openedUrl ?? '', externalUrl: oldService.openedUrl ?? '',
}, },
network: { network: {
enabledStatusChecker: s.ping ?? true, enabledStatusChecker: oldService.ping ?? true,
okStatus: s.status?.map((str) => parseInt(str, 10)) ?? [200], okStatus: oldService.status?.map((str) => parseInt(str, 10)) ?? [200],
}, },
appearance: { appearance: {
iconUrl: s.icon, iconUrl: oldService.icon,
}, },
integration: { integration: {
type: null, type: null,
properties: [], properties: [],
}, },
area: { area: areaType,
type: 'wrapper',
properties: {
id: 'default',
},
},
shape: { shape: {
location: { location: {
x: (idx * 3) % 18, x: (serviceIndex * 3) % 18,
y: Math.floor(idx / 6) * 3, y: Math.floor(serviceIndex / 6) * 3,
}, },
size: { size: {
width: 3, width: 3,
height: 3, height: 3,
}, },
}, },
})); });
// Overrite the file ./data/configs/${name}.json
// with the new config format
fs.writeFileSync(`./data/configs/${name}.json`, JSON.stringify(newConfig, null, 2));
return newConfig;
}