Refactors loading function
* Initialize service component and handler in hooks * Dynamically watch the config instead of a reload on each page * Uses derived store for public server config * Moves service data in its own page data property * Reverts to a array to store group/services
This commit is contained in:
@@ -2,24 +2,23 @@ title: 'Hello World !!'
|
||||
subtitle: 'actually, I am a new pilot.'
|
||||
|
||||
services:
|
||||
cloud:
|
||||
title: '/Cloud'
|
||||
subtitle: 'Private Cloud Utilities'
|
||||
icon: 'fas fa-cloud'
|
||||
items:
|
||||
nas:
|
||||
title: 'NAS'
|
||||
subtitle: 'Network Attached Storage'
|
||||
icon: 'fas fa-hard-drive'
|
||||
target: '_blank'
|
||||
url: '/NAS'
|
||||
keywords: 'cloud storage files'
|
||||
type: prowlarr
|
||||
pihole:
|
||||
title: 'PiHole'
|
||||
subtitle: 'A DNS Hole'
|
||||
icon: 'fas fa-hard-drive'
|
||||
target: '_blank'
|
||||
url: '/pihole'
|
||||
type: 'pihole'
|
||||
keywords: 'cloud storage files'
|
||||
- title: '/Cloud'
|
||||
subtitle: 'Private Cloud Utilities'
|
||||
icon: 'fas fa-cloud'
|
||||
items:
|
||||
- title: 'NAS'
|
||||
subtitle: 'Network Attached Storage'
|
||||
icon: 'fas fa-hard-drive'
|
||||
target: '_blank'
|
||||
url: '/NAS'
|
||||
type: prowlarr
|
||||
|
||||
keywords: 'cloud storage files'
|
||||
|
||||
- title: 'PiHole'
|
||||
subtitle: 'A DNS Hole'
|
||||
icon: 'fas fa-hard-drive'
|
||||
target: '_blank'
|
||||
url: '/pihole'
|
||||
type: 'pihole'
|
||||
keywords: 'cloud storage files'
|
||||
|
||||
3
src/hooks.client.ts
Normal file
3
src/hooks.client.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { initServices } from '$lib/services/services';
|
||||
|
||||
await initServices();
|
||||
@@ -1,3 +1,9 @@
|
||||
import { dev } from '$app/environment';
|
||||
import { watchDymamicConfig } from '$lib/server/config';
|
||||
import { initServices } from '$lib/services/services';
|
||||
|
||||
if (!dev) {
|
||||
watchDymamicConfig();
|
||||
}
|
||||
|
||||
await initServices();
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import configData from '../config.yml';
|
||||
|
||||
export interface BrandConfig {
|
||||
logo?: string;
|
||||
icon?: string;
|
||||
usemask?: boolean;
|
||||
}
|
||||
|
||||
export interface SectionConfig extends BrandConfig {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export interface ServiceConfig extends SectionConfig {
|
||||
url: string;
|
||||
target?: string;
|
||||
type?: string;
|
||||
data?: Record<string, unknown>;
|
||||
|
||||
[x: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ServiceGroupConfig extends SectionConfig {
|
||||
items: Record<string, ServiceConfig>;
|
||||
|
||||
[x: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Config extends SectionConfig {
|
||||
services: Record<string, ServiceGroupConfig>;
|
||||
|
||||
[x: string]: unknown;
|
||||
}
|
||||
|
||||
type DeepRequired<T> = T extends object
|
||||
? {
|
||||
[P in keyof T]: DeepRequired<T[P]>;
|
||||
}
|
||||
: T;
|
||||
|
||||
const requiredConfig: DeepRequired<Config> = {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
services: {
|
||||
default: {
|
||||
title: '',
|
||||
items: {
|
||||
default: {
|
||||
title: '',
|
||||
url: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function mergeConfig(a: Config, b: any): Config {
|
||||
return { ...a, ...b };
|
||||
}
|
||||
|
||||
export const defaultConfig: Config = {
|
||||
title: 'Flanders',
|
||||
|
||||
services: {}
|
||||
};
|
||||
|
||||
type SPOJO = Record<string, unknown>;
|
||||
|
||||
function strip<Type extends SPOJO>(toStrip: Type, reference: Type): Type {
|
||||
const res: Type = { ...toStrip };
|
||||
const referenceNames = Object.entries(reference).map(([key, value]) => key);
|
||||
|
||||
const allowAny: boolean = referenceNames.length == 1 && referenceNames[0] == 'default';
|
||||
for (const [key, value] of Object.entries(res)) {
|
||||
if (referenceNames.includes(key) == false && allowAny == false) {
|
||||
// remove the object
|
||||
delete res[key];
|
||||
continue;
|
||||
}
|
||||
if (typeof value != 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// it is a child object, we strip it further
|
||||
const stripped: SPOJO = {};
|
||||
const childReference: SPOJO = reference[allowAny ? 'default' : key] as SPOJO;
|
||||
stripped[key] = strip(value as SPOJO, childReference);
|
||||
Object.assign(res, stripped);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export function stripPrivateFields(config: Config): Config {
|
||||
return strip(config, requiredConfig);
|
||||
}
|
||||
|
||||
export const config: Config = mergeConfig(defaultConfig, configData);
|
||||
|
||||
export const clientConfig: Config = stripPrivateFields(config);
|
||||
@@ -1,9 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { config, defaultConfig, mergeConfig, type Config, stripPrivateFields } from './config';
|
||||
import {
|
||||
defaultConfig,
|
||||
mergeConfig,
|
||||
type Config,
|
||||
stripPrivateFields,
|
||||
serverConfig,
|
||||
clientConfig
|
||||
} from './config';
|
||||
|
||||
describe('Config', () => {
|
||||
it('should be export a build time config', () => {
|
||||
expect(config).toBeTruthy();
|
||||
it('should be export a build time server and client config store', () => {
|
||||
expect(serverConfig).toBeTruthy();
|
||||
expect(clientConfig).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should be able to merge with POJO', () => {
|
||||
@@ -34,25 +42,33 @@ describe('Config', () => {
|
||||
const custom: Config = {
|
||||
title: 'custom',
|
||||
secret: { secret: 'some secret' },
|
||||
services: {
|
||||
top: {
|
||||
services: [
|
||||
{
|
||||
title: 'top services',
|
||||
secret: 'secret',
|
||||
items: {
|
||||
top: {
|
||||
items: [
|
||||
{
|
||||
title: 'top service',
|
||||
url: 'somewhere',
|
||||
secret: 'secret'
|
||||
secret: 'secret',
|
||||
type: 'foo'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const stripped: Config = stripPrivateFields(custom);
|
||||
|
||||
expect(stripped.secret).toBeUndefined();
|
||||
expect(stripped.services.top.secret).toBeUndefined();
|
||||
expect(stripped.services.top.items.top.secret).toBeUndefined();
|
||||
expect(custom.secret).toEqual({ secret: 'some secret' });
|
||||
expect(stripped.services[0].secret).toBeUndefined();
|
||||
expect(custom.services[0].secret).toEqual('secret');
|
||||
expect(stripped.services[0].title).toEqual('top services');
|
||||
expect(stripped.services[0].items[0].secret).toBeUndefined();
|
||||
expect(custom.services[0].items[0].secret).toEqual('secret');
|
||||
expect(stripped.services[0].items[0].title).toEqual('top service');
|
||||
expect(stripped.services[0].items[0].url).toEqual('somewhere');
|
||||
expect(stripped.services[0].items[0].type).toEqual('foo');
|
||||
});
|
||||
});
|
||||
146
src/lib/server/config.ts
Normal file
146
src/lib/server/config.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { writable, type Readable, type Writable, derived } from 'svelte/store';
|
||||
import configData from '../../config.yml';
|
||||
import { readFile, watch } from 'fs/promises';
|
||||
import * as yml from 'js-yaml';
|
||||
|
||||
export interface BrandConfig {
|
||||
logo?: string;
|
||||
icon?: string;
|
||||
usemask?: boolean;
|
||||
}
|
||||
|
||||
export interface SectionConfig extends BrandConfig {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export interface ServiceConfig extends SectionConfig {
|
||||
url: string;
|
||||
target?: string;
|
||||
type?: string;
|
||||
|
||||
[x: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ServiceGroupConfig extends SectionConfig {
|
||||
items: Array<ServiceConfig>;
|
||||
|
||||
[x: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Config extends SectionConfig {
|
||||
services: Array<ServiceGroupConfig>;
|
||||
|
||||
[x: string]: unknown;
|
||||
}
|
||||
|
||||
const requiredService: Required<ServiceConfig> = {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
logo: '',
|
||||
icon: '',
|
||||
usemask: false,
|
||||
url: '',
|
||||
target: '',
|
||||
type: ''
|
||||
};
|
||||
|
||||
const requiredServiceGroup: Required<ServiceGroupConfig> = {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
logo: '',
|
||||
icon: '',
|
||||
usemask: false,
|
||||
items: [requiredService]
|
||||
};
|
||||
|
||||
export const requiredConfig: Required<Config> = {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
logo: '',
|
||||
icon: '',
|
||||
usemask: false,
|
||||
services: [requiredServiceGroup]
|
||||
};
|
||||
|
||||
export function mergeConfig(a: Config, b: any): Config {
|
||||
return { ...a, ...b };
|
||||
}
|
||||
|
||||
export const defaultConfig: Config = {
|
||||
title: 'Flanders',
|
||||
|
||||
services: []
|
||||
};
|
||||
|
||||
type SPOJO = Record<string, unknown>;
|
||||
|
||||
function strip<Type extends SPOJO>(toStrip: Type, reference: Type): Type {
|
||||
const res: Type = { ...toStrip };
|
||||
const referenceNames = Object.entries(reference).map(([key, value]) => key);
|
||||
|
||||
for (const [key, value] of Object.entries(res)) {
|
||||
if (referenceNames.includes(key) == false) {
|
||||
// remove the object
|
||||
delete res[key];
|
||||
continue;
|
||||
}
|
||||
if (typeof value != 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value instanceof Array) {
|
||||
const newValue = [];
|
||||
const childReference: SPOJO = (reference[key] as Array<SPOJO>)[0];
|
||||
for (const child of value) {
|
||||
newValue.push(strip(child, childReference));
|
||||
}
|
||||
(res as SPOJO)[key] = newValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
// it is a child object, we strip it further
|
||||
const stripped: SPOJO = {};
|
||||
const childReference: SPOJO = (reference[key] as Array<SPOJO>)[0];
|
||||
stripped[key] = strip(value as SPOJO, childReference);
|
||||
(res as SPOJO)[key] = stripped;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export function stripPrivateFields(config: Config): Config {
|
||||
return strip(config, requiredConfig);
|
||||
}
|
||||
|
||||
export const serverConfig: Writable<Config> = writable(mergeConfig(defaultConfig, configData));
|
||||
|
||||
export const clientConfig: Readable<Config> = derived(serverConfig, ($config) =>
|
||||
stripPrivateFields($config)
|
||||
);
|
||||
|
||||
export function watchDymamicConfig() {
|
||||
const __filepath = '/dynamic/config.yml';
|
||||
|
||||
const reloadConfig = async () => {
|
||||
try {
|
||||
const dynamicConfig = yml.load(await readFile(__filepath, 'utf8'));
|
||||
serverConfig.set(mergeConfig(defaultConfig, dynamicConfig));
|
||||
} catch (err) {
|
||||
console.error('could not read or parse config: ' + err);
|
||||
}
|
||||
};
|
||||
|
||||
reloadConfig();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const watcher = watch(__filepath);
|
||||
for await (const event of watcher) {
|
||||
reloadConfig();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('could not watch config: ' + err);
|
||||
return;
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { type ServiceHandler } from '../service';
|
||||
import { type ServiceComponentPath, type ServiceHandler } from '../service';
|
||||
|
||||
export const handle: ServiceHandler = ({ config }) => {
|
||||
const data = {};
|
||||
if (config.apikey != undefined) {
|
||||
//TODO: fetch data
|
||||
}
|
||||
return { data, componentPath: 'pihole/PiHoleContent.svelte' };
|
||||
export const handle: ServiceHandler = () => {
|
||||
return { piholedata: 'coucou' };
|
||||
};
|
||||
|
||||
export const path: ServiceComponentPath = 'pihole/PiHoleContent.svelte';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ServiceHandler } from '../service';
|
||||
import type { ServiceComponentPath, ServiceHandler } from '../service';
|
||||
|
||||
export const handle: ServiceHandler = () => {
|
||||
return { data: {}, componentPath: 'prowlarr/ProwlarrContent.svelte' };
|
||||
return { prowlardata: 'coucou' };
|
||||
};
|
||||
|
||||
export const path: ServiceComponentPath = 'prowlarr/ProwlarrContent.svelte';
|
||||
|
||||
@@ -5,7 +5,6 @@ interface ServiceHandlerArgs {
|
||||
config: ServiceConfig;
|
||||
}
|
||||
|
||||
export type ServiceHandler = (input: ServiceHandlerArgs) => {
|
||||
data: Record<string, unknown>;
|
||||
componentPath: string;
|
||||
};
|
||||
export type ServiceHandler = (input: ServiceHandlerArgs) => Record<string, unknown>;
|
||||
|
||||
export type ServiceComponentPath = string;
|
||||
|
||||
@@ -1,33 +1,43 @@
|
||||
import type { ServiceHandler } from './service';
|
||||
|
||||
const services: Record<string, [ServiceHandler, string]> = {};
|
||||
type ServiceRecord = {
|
||||
handler: ServiceHandler;
|
||||
component: any;
|
||||
};
|
||||
|
||||
function registerService(type: string, handler: ServiceHandler) {
|
||||
services[type] = [handler, type];
|
||||
const services: Record<string, ServiceRecord> = {};
|
||||
|
||||
function registerService(type: string, handler: ServiceHandler, component: any) {
|
||||
services[type] = { handler, component };
|
||||
}
|
||||
|
||||
export function getService(type: string): [ServiceHandler, string] {
|
||||
const handler = services[type];
|
||||
if (handler == undefined) {
|
||||
return [
|
||||
() => {
|
||||
return { data: {}, componentPath: '' };
|
||||
},
|
||||
''
|
||||
];
|
||||
}
|
||||
return handler;
|
||||
export function getServiceHandler(type: string): ServiceHandler | undefined {
|
||||
return services[type]?.handler;
|
||||
}
|
||||
|
||||
export function getServiceComponent(type: string): any {
|
||||
return services[type]?.component;
|
||||
}
|
||||
|
||||
export async function initServices() {
|
||||
const services = import.meta.glob('/src/lib/services/**/+service.ts');
|
||||
|
||||
for (const [path, load] of Object.entries(services)) {
|
||||
const { handle } = (await load()) as any;
|
||||
if (handle == undefined) {
|
||||
continue;
|
||||
for (const [modulePath, load] of Object.entries(services)) {
|
||||
try {
|
||||
const { handle, path } = (await load()) as any;
|
||||
if (handle == undefined) {
|
||||
throw new Error(`${modulePath} does not export 'handle'`);
|
||||
}
|
||||
if (path == undefined) {
|
||||
throw new Error(`${modulePath} does not export 'path'`);
|
||||
}
|
||||
|
||||
const module = await import(/* @vite-ignore */ '/src/lib/services/' + path);
|
||||
|
||||
const typeName = modulePath.slice(18, -12);
|
||||
registerService(typeName, handle, module.default);
|
||||
} catch (err) {
|
||||
console.error(`Could not load service definition from '${modulePath}': ${err}`);
|
||||
}
|
||||
const typeName = path.slice(18, -12);
|
||||
registerService(typeName, handle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,25 @@
|
||||
import { dev } from '$app/environment';
|
||||
import { config, stripPrivateFields, type Config, type ServiceConfig } from '$lib/config';
|
||||
import { clientConfig, serverConfig } from '$lib/server/config';
|
||||
import { get } from 'svelte/store';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import * as yml from 'js-yaml';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { getService } from '$lib/services/services';
|
||||
import { getServiceHandler } from '$lib/services/services';
|
||||
|
||||
async function reloadConfig(): Promise<Config> {
|
||||
if (dev) {
|
||||
return config;
|
||||
}
|
||||
try {
|
||||
const dynamic = yml.load(await readFile('/dynamic/config.yml', 'utf8'));
|
||||
return { ...config, ...dynamic };
|
||||
} catch (err) {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
export const load: PageServerLoad = ({ fetch }) => {
|
||||
const config = get(clientConfig);
|
||||
const serviceData: Array<Array<unknown>> = [];
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, depends }) => {
|
||||
depends('app:state');
|
||||
|
||||
const serverConfig = await reloadConfig();
|
||||
const clientConfig = stripPrivateFields(serverConfig);
|
||||
const groups: Array<string> = Object.entries(serverConfig.services).map(([k, v]) => k);
|
||||
for (const group of groups) {
|
||||
const serverGroup = serverConfig.services[group];
|
||||
const clientGroup = clientConfig.services[group];
|
||||
|
||||
const services = Object.entries(serverGroup.items).map(([k, v]) => k);
|
||||
|
||||
for (const service of services) {
|
||||
const serverService = serverGroup.items[service] as ServiceConfig;
|
||||
const clientService = clientGroup.items[service] as ServiceConfig;
|
||||
|
||||
const [handler, type] = getService(serverService.type || '');
|
||||
|
||||
clientService.type = type;
|
||||
Object.assign(clientService, handler({ fetch, config: serverService }));
|
||||
for (const [i, group] of get(serverConfig).services.entries()) {
|
||||
const groupData: Array<unknown> = [];
|
||||
serviceData.push(groupData);
|
||||
for (const [j, service] of group.items.entries()) {
|
||||
const handler = getServiceHandler(service.type || '');
|
||||
if (handler == undefined) {
|
||||
config.services[i].items[j].type = undefined;
|
||||
groupData.push(undefined);
|
||||
} else {
|
||||
groupData.push(handler({ fetch, config: service }));
|
||||
}
|
||||
}
|
||||
}
|
||||
clientConfig.timestamp = new Date();
|
||||
return { config: clientConfig, cool: true };
|
||||
|
||||
return { config, serviceData };
|
||||
};
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { invalidate } from '$app/navigation';
|
||||
|
||||
export let data: PageData;
|
||||
onMount(() => {
|
||||
const interval = setInterval(() => {
|
||||
invalidate('app:state');
|
||||
}, 10000);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<h1>{data.config.title}</h1>
|
||||
@@ -17,4 +9,19 @@
|
||||
<h2>{data.config.subtitle}</h2>
|
||||
{/if}
|
||||
|
||||
<pre> {JSON.stringify(data.config, null, 4)} </pre>
|
||||
<ul>
|
||||
{#each data.config.services as group, i}
|
||||
<li>
|
||||
<p>{group.title}</p>
|
||||
<ul>
|
||||
{#each group.items as service, j}
|
||||
<li>
|
||||
{i},{j}
|
||||
<pre> {JSON.stringify(service)}</pre>
|
||||
<pre> {JSON.stringify(data.serviceData[i][j])}</pre>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ data }) => {
|
||||
const config = { ...data.config };
|
||||
const groups: Array<string> = Object.entries(config.services).map(([k, v]) => k);
|
||||
|
||||
const components: Record<string, any> = {};
|
||||
|
||||
for (const group of groups) {
|
||||
const services: Array<string> = Object.entries(config.services[group].items).map(
|
||||
([k, v]) => k
|
||||
);
|
||||
for (const s of services) {
|
||||
const service = data.config.services[group].items[s];
|
||||
if (service.componentPath == '' || service.type == '') {
|
||||
delete service.componentPath;
|
||||
continue;
|
||||
}
|
||||
|
||||
const componentType = service.type || '';
|
||||
if (components[componentType] != undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const path = '../lib/services/' + service.componentPath;
|
||||
|
||||
const module = await import(/* @vite-ignore */ path);
|
||||
|
||||
components[componentType] = module.default;
|
||||
|
||||
//service.componentPath = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return { config, components };
|
||||
};
|
||||
Reference in New Issue
Block a user