diff --git a/public/locales/en/settings/general/config-changer.json b/public/locales/en/settings/general/config-changer.json index 130273158..2ca62d86f 100644 --- a/public/locales/en/settings/general/config-changer.json +++ b/public/locales/en/settings/general/config-changer.json @@ -57,9 +57,11 @@ } }, "accept": { + "title": "Configuration Upload", "text": "Drag files here to upload a config. Support for JSON only." }, "reject": { + "title": "Drag and Drop Upload rejected", "text": "This file format is not supported. Please only upload JSON." } } diff --git a/src/components/Config/LoadConfig.tsx b/src/components/Config/LoadConfig.tsx index 47c4bbef4..e653a19ba 100644 --- a/src/components/Config/LoadConfig.tsx +++ b/src/components/Config/LoadConfig.tsx @@ -1,77 +1,94 @@ -import { Group, Text, useMantineTheme } from '@mantine/core'; +import { Group, Stack, Text, Title, useMantineTheme } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; import { showNotification } from '@mantine/notifications'; import { IconCheck as Check, IconPhoto, IconUpload, IconX, IconX as X } from '@tabler/icons'; import { setCookie } from 'cookies-next'; import { useTranslation } from 'next-i18next'; import { useConfigStore } from '../../config/store'; +import { migrateConfig } from '../../tools/config/migrateConfig'; import { Config } from '../../tools/types'; +import { ConfigType } from '../../types/config'; -export default function LoadConfigComponent(props: any) { - const { updateConfig } = useConfigStore(); +export const LoadConfigComponent = () => { + const { addConfig } = useConfigStore(); const theme = useMantineTheme(); const { t } = useTranslation('settings/general/config-changer'); return ( { - files[0].text().then((e) => { - try { - JSON.parse(e) as Config; - } catch (e) { - showNotification({ - autoClose: 5000, - title: {t('dropzone.notifications.invalidConfig.title')}, - color: 'red', - icon: , - message: t('dropzone.notifications.invalidConfig.message'), - }); - return; - } - const newConfig: Config = JSON.parse(e); + onDrop={async (files) => { + const fileName = files[0].name.replaceAll('.json', ''); + const fileText = await files[0].text(); + + try { + JSON.parse(fileText) as ConfigType; + } catch (e) { showNotification({ autoClose: 5000, - radius: 'md', - title: ( - - {t('dropzone.notifications.loadedSuccessfully.title', { - configName: newConfig.name, - })} - - ), - color: 'green', - icon: , - message: undefined, + title: {t('dropzone.notifications.invalidConfig.title')}, + color: 'red', + icon: , + message: t('dropzone.notifications.invalidConfig.message'), }); - setCookie('config-name', newConfig.name, { - maxAge: 60 * 60 * 24 * 30, - sameSite: 'strict', - }); - updateConfig(newConfig.name, (previousConfig) => ({ ...previousConfig, newConfig })); + return; + } + + 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({ + autoClose: 5000, + radius: 'md', + title: ( + + {t('dropzone.notifications.loadedSuccessfully.title', { + configName: fileName, + })} + + ), + color: 'green', + icon: , + message: undefined, + }); + setCookie('config-name', fileName, { + maxAge: 60 * 60 * 24 * 30, + sameSite: 'strict', }); }} accept={['application/json']} > - + - {t('dropzone.accept.text')} - + {t('dropzone.accept.title')} + + {t('dropzone.accept.text')} + + - + - {t('dropzone.reject.text')} - + {t('dropzone.reject.title')} + + {t('dropzone.reject.text')} + + @@ -79,4 +96,4 @@ export default function LoadConfigComponent(props: any) { ); -} +}; diff --git a/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx b/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx index 6872f6966..6385a1d76 100644 --- a/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx +++ b/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx @@ -32,8 +32,6 @@ export const WidgetsEditModal = ({ if (!configName || !innerProps.options) return null; - console.log(`loaded namespace modules/${innerProps.widgetId}`); - const handleChange = (key: string, value: IntegrationOptionsValueType) => { setModuleProperties((prev) => { const copyOfPrev: any = { ...prev }; diff --git a/src/config/store.ts b/src/config/store.ts index a2842214f..0839aee60 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -13,6 +13,20 @@ export const useConfigStore = create((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 ( name, updateCallback: (previous: ConfigType) => ConfigType, @@ -21,7 +35,9 @@ export const useConfigStore = create((set, get) => ({ ) => { const { configs } = get(); 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 const previousConfig: ConfigType = JSON.parse(JSON.stringify(currentConfig.value)); @@ -51,6 +67,11 @@ export const useConfigStore = create((set, get) => ({ interface UseConfigStoreType { configs: { increaseVersion: () => void; value: ConfigType }[]; initConfig: (name: string, config: ConfigType, increaseVersion: () => void) => void; + addConfig: ( + name: string, + config: ConfigType, + shouldSaveConfigToFileSystem: boolean, + ) => Promise; updateConfig: ( name: string, updateCallback: (previous: ConfigType) => ConfigType, diff --git a/src/pages/[slug].tsx b/src/pages/[slug].tsx index 29578cdf3..0878371c3 100644 --- a/src/pages/[slug].tsx +++ b/src/pages/[slug].tsx @@ -1,7 +1,7 @@ import fs from 'fs'; import { GetServerSidePropsContext } from 'next'; import path from 'path'; -import LoadConfigComponent from '../components/Config/LoadConfig'; +import { LoadConfigComponent } from '../components/Config/LoadConfig'; import { Dashboard } from '../components/Dashboard/Dashboard'; import Layout from '../components/layout/Layout'; import { useInitConfig } from '../config/init'; diff --git a/src/pages/api/configs/[slug].ts b/src/pages/api/configs/[slug].ts index 68f1cc625..3d1ea5605 100644 --- a/src/pages/api/configs/[slug].ts +++ b/src/pages/api/configs/[slug].ts @@ -1,6 +1,7 @@ -import { NextApiRequest, NextApiResponse } from 'next'; import fs from 'fs'; import path from 'path'; +import Consola from 'consola'; +import { NextApiRequest, NextApiResponse } from 'next'; import { BackendConfigType, ConfigType } from '../../../types/config'; import { getConfig } from '../../../tools/config/getConfig'; @@ -10,11 +11,14 @@ function Put(req: NextApiRequest, res: NextApiResponse) { // Get the body of the request const { body: config }: { body: ConfigType } = req; if (!slug || !config) { + Consola.warn('Rejected configuration update because either config or slug were undefined'); return res.status(400).json({ error: 'Wrong request', }); } + Consola.info(`Saving updated configuration of '${slug}' config.`); + const previousConfig = getConfig(slug); 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 - fs.writeFileSync( - path.join('data/configs', `${slug}.json`), - JSON.stringify(newConfig, null, 2), - 'utf8' - ); + const targetPath = path.join('data/configs', `${slug}.json`); + fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8'); + + Consola.info(`Config '${slug}' has been updated and flushed to '${targetPath}'.`); + return res.status(200).json({ message: 'Configuration saved with success', }); diff --git a/src/pages/index.tsx b/src/pages/index.tsx index ae65d9961..d68cf541c 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -2,7 +2,7 @@ import { getCookie, setCookie } from 'cookies-next'; import { GetServerSidePropsContext } from 'next'; import fs from 'fs'; -import LoadConfigComponent from '../components/Config/LoadConfig'; +import Consola from 'consola'; import { Dashboard } from '../components/Dashboard/Dashboard'; import Layout from '../components/layout/Layout'; import { useInitConfig } from '../config/init'; @@ -10,6 +10,7 @@ import { getFrontendConfig } from '../tools/config/getFrontendConfig'; import { getServerSideTranslations } from '../tools/getServerSideTranslations'; import { dashboardNamespaces } from '../tools/translation-namespaces'; import { DashboardServerSideProps } from '../types/dashboardPageType'; +import { LoadConfigComponent } from '../components/Config/LoadConfig'; export async function getServerSideProps({ req, @@ -46,6 +47,9 @@ export async function getServerSideProps({ } const translations = await getServerSideTranslations(req, res, dashboardNamespaces, locale); + + Consola.info(`Decided to use configuration '${configName}'`); + const config = getFrontendConfig(configName as string); return { diff --git a/src/pages/migrate.tsx b/src/pages/migrate.tsx index 1fa14e5fc..c603d903c 100644 --- a/src/pages/migrate.tsx +++ b/src/pages/migrate.tsx @@ -1,45 +1,45 @@ -import React, { useEffect, useState } from 'react'; -import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; import fs from 'fs'; import { GetServerSidePropsContext } from 'next'; +import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; +import React, { useEffect, useState } from 'react'; import { - createStyles, - Title, - Text, - Container, - Group, - Stepper, - useMantineTheme, - Header, + Alert, + Anchor, AppShell, - useMantineColorScheme, - Switch, + Badge, Box, Button, - Alert, - Badge, + Container, + createStyles, + Group, + Header, List, Loader, Paper, Progress, Space, Stack, + Stepper, + Switch, + Text, ThemeIcon, - Anchor, + Title, + useMantineColorScheme, + useMantineTheme, } from '@mantine/core'; import { - IconSun, - IconMoonStars, - IconCheck, IconAlertCircle, - IconCircleCheck, IconBrandDiscord, + IconCheck, + IconCircleCheck, + IconMoonStars, + IconSun, } from '@tabler/icons'; import { motion } from 'framer-motion'; import { Logo } from '../components/layout/Logo'; import { usePrimaryGradient } from '../components/layout/useGradient'; -import { migrateConfig } from '../tools/config/migrateConfig'; +import { backendMigrateConfig } from '../tools/config/backendMigrateConfig'; const useStyles = createStyles((theme) => ({ root: { @@ -274,7 +274,7 @@ export async function getServerSideProps({ req, res, locale }: GetServerSideProp const configData = JSON.parse(fs.readFileSync(`./data/configs/${config}`, 'utf8')); if (!configData.schemaVersion) { // Migrate the config - migrateConfig(configData, config.replace('.json', '')); + backendMigrateConfig(configData, config.replace('.json', '')); } return config; }); diff --git a/src/tools/config/backendMigrateConfig.ts b/src/tools/config/backendMigrateConfig.ts new file mode 100644 index 000000000..54bb55693 --- /dev/null +++ b/src/tools/config/backendMigrateConfig.ts @@ -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; +} diff --git a/src/tools/config/migrateConfig.ts b/src/tools/config/migrateConfig.ts index 6385da4af..9648c1fd1 100644 --- a/src/tools/config/migrateConfig.ts +++ b/src/tools/config/migrateConfig.ts @@ -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 { 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 = { schemaVersion: 1, configProperties: { @@ -41,45 +44,86 @@ export function migrateConfig(config: Config, name: string): ConfigType { ], }; - newConfig.apps = config.services.map((s, idx) => ({ - name: s.name, - id: s.id, - url: s.url, - behaviour: { - isOpeningNewTab: s.newTab ?? true, - externalUrl: s.openedUrl ?? '', - }, - network: { - enabledStatusChecker: s.ping ?? true, - okStatus: s.status?.map((str) => parseInt(str, 10)) ?? [200], - }, - appearance: { - iconUrl: s.icon, - }, - integration: { - type: null, - properties: [], - }, - area: { - type: 'wrapper', - properties: { - id: 'default', - }, - }, - shape: { - location: { - x: (idx * 3) % 18, - y: Math.floor(idx / 6) * 3, - }, - size: { - width: 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)); + config.services.forEach((service, index) => { + const { category: categoryName } = service; + + 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: { + isOpeningNewTab: oldService.newTab ?? true, + externalUrl: oldService.openedUrl ?? '', + }, + network: { + enabledStatusChecker: oldService.ping ?? true, + okStatus: oldService.status?.map((str) => parseInt(str, 10)) ?? [200], + }, + appearance: { + iconUrl: oldService.icon, + }, + integration: { + type: null, + properties: [], + }, + area: areaType, + shape: { + location: { + x: (serviceIndex * 3) % 18, + y: Math.floor(serviceIndex / 6) * 3, + }, + size: { + width: 3, + height: 3, + }, + }, +});