Refactors services with defaultConfig and polled data.

Much more simpler code base. Each service requires a poll functioon to poll its
status. If type is defined, and the registered '+service.ts' exports a poll
ServicePoller, it will be used. Otherwise, a generic poll mechanism is used.
This commit is contained in:
2023-09-22 10:34:51 +02:00
parent d9d2d5c5af
commit 8450226211
15 changed files with 156 additions and 166 deletions

View File

@@ -18,20 +18,17 @@ services:
- title: '/Media'
icon: 'fas fa-photo-film'
items:
- title: 'Jellyfin'
- type: jellyfin
url: 'https://eagle.tuleu.me'
type: jellyfin
keywords: 'cloud storage files'
- title: 'Sonarr'
- type: sonarr
url: 'http://sonarr.lan'
type: sonarr
api_key: 43f13770f9a0419bbdc3224dae76e886
keywords: 'shows tracker torrent usenet'
- title: 'Radarr'
- type: radarr
url: 'http://radarr.lan'
type: radarr
keywords: 'movies tracker torrent usenet'
- title: '/Cloud'
@@ -46,8 +43,7 @@ services:
- title: '/Infra'
icon: 'fas fa-network-wired'
items:
- title: 'PiHole'
- type: pihole
url: 'http://pihole.lan/admin'
type: 'pihole'
keywords: 'dns ads blocker internet'
api_token: a3996b80e3d9cdb86b338396a164a8814e8d6f44d2986261fe573bfea53a75fb

View File

@@ -1,27 +1,19 @@
<script lang="ts">
import type { BrandConfig, ServiceConfig } from '$lib/config';
import type { ServiceData } from '$lib/services/service';
import type { ServiceConfig } from '$lib/config';
import type { ServiceStatus } from '$lib/services/service';
import { getServiceComponent } from '$lib/services/services';
import Brand from './Brand.svelte';
export let service: ServiceConfig;
export let data: ServiceData;
export let status: ServiceStatus | undefined;
export let data: Record<string, unknown>;
console.log(service);
const component = getServiceComponent(service.type || '');
function brand(): BrandConfig {
if (data?.logo != undefined && service.logo == undefined && service.icon == undefined) {
return { logo: data.logo, asmask: service.asmask || false };
}
return service;
}
function subtitle(): string {
return service.subtitle || data.subtitle || '';
}
function computeClasses(): string {
switch (data.status) {
switch (status) {
case 'offline':
return 'bg-error-400';
case 'online':
@@ -36,16 +28,16 @@
<a
class="min-h-24 flex w-full flex-col overflow-hidden rounded-2xl bg-neutral-600 bg-opacity-30 p-0 transition-all hover:-translate-y-2 focus:-translate-y-2 focus:outline-none"
href={service.url}
target={service.target || ''}
target={service.target || '_blank'}
>
<div class="flex flex-row gap-2 px-6 pt-6">
<div class="flex flex h-12 w-12 flex-row text-4xl">
<Brand brand={brand()} />
<Brand brand={service} />
</div>
<div class="h-12">
<p class="text-lg">{service.title}</p>
<p class="text-lg">{service.title || ''}</p>
<p class="min-h-12 text-neutral-700 dark:text-neutral-500">
{subtitle()}
{service.subtitle || ''}
</p>
</div>
</div>

View File

@@ -1,10 +1,11 @@
<script lang="ts">
import type { ServiceGroupConfig } from '$lib/config';
import { layoutDirection } from '$lib/stores/layout';
import type { ServiceData } from '$lib/services/service';
import { layoutDirection, type LayoutDirection } from '$lib/stores/layout';
import ServiceCard from './ServiceCard.svelte';
export let group: ServiceGroupConfig;
export let groupData: Array<unknown>;
export let groupData: Array<Partial<ServiceData>>;
export let columns: number;
function renderClasses(columns: number, direction: LayoutDirection): string {
@@ -38,7 +39,7 @@
<div class="gap-4 {layoutClasses}">
{#each group.items as service, i}
<ServiceCard {service} data={groupData[i]} />
<ServiceCard {service} data={groupData[i]?.data || {}} status={groupData[i].status} />
{/each}
</div>
</div>

View File

@@ -12,6 +12,7 @@ import type {
import * as ipRangeCheck from 'ip-range-check';
import { initializeServiceData } from './serviceDataPolling';
import { getServiceRecord } from '$lib/services/services';
const requiredService: Required<ServiceConfig> = {
title: '',
@@ -86,8 +87,29 @@ function merge<Type extends SPOJO>(a: Type, b: SPOJO): Type {
return res;
}
function injectDefaultServiceConfigs(config: Config) {
for (const group of config.services.values()) {
for (const service of group.items.values()) {
if (!service.type) {
continue;
}
const def = getServiceRecord(service.type).config;
for (const [key, value] of Object.entries(def)) {
const isUnknownKey = !(key in requiredService);
const keyAlreadyDefined = key in service && service[key] != false;
if (isUnknownKey || keyAlreadyDefined) {
continue;
}
service[key] = value;
}
}
}
}
export function mergeConfig(a: Config, b: SPOJO): Config {
return merge<Config>(a, b);
const res = merge<Config>(a, b);
injectDefaultServiceConfigs(res);
return res;
}
const defaultLightConfig: ColorConfig = {};
@@ -184,7 +206,7 @@ export function watchDymamicConfig() {
(async () => {
try {
const watcher = watch(__filepath);
// eslint-disable-next-line
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for await (const event of watcher) {
reloadConfig();
}

View File

@@ -1,60 +1,17 @@
import type { Config, ServiceConfig } from '$lib/config';
import type { AsyncServiceHandler, ServiceData, ServiceHandler } from '$lib/services/service';
import { getServiceHandler } from '$lib/services/services';
import type { Config } from '$lib/config';
import type { ServiceData, ServicePoller } from '$lib/services/service';
import { getServiceRecord } from '$lib/services/services';
import { serverConfig } from './config';
export const serviceData: Array<Array<unknown>> = [];
function isPromise(p: any): boolean {
return typeof p === 'object' && typeof p.then === 'function';
}
async function pollGeneric(upstream: ServiceData, url: string): Promise<ServiceData> {
upstream.status = undefined;
return fetch(url)
.then((resp: Response): ServiceData => {
upstream.status = resp.ok ? 'online' : 'offline';
return upstream;
})
.catch((error) => {
console.warn("could not fetch status for '" + url + "': " + error);
upstream.status = 'offline';
return upstream;
});
}
function wrapHandler(handler: ServiceHandler): AsyncServiceHandler {
return async (config: ServiceConfig): Promise<ServiceData> => {
const value = handler(config);
if (isPromise(value) === true) {
return (value as Promise<ServiceData>)
.then((data: ServiceData) => {
if (data.status != undefined) {
return data;
}
return pollGeneric(data, config.url);
})
.catch((error) => {
console.warn("could not resolve service '" + config.url + "': " + error);
return pollGeneric({}, config.url);
});
}
return pollGeneric(value as ServiceData, config.url);
};
}
export const serviceData: Array<Array<Partial<ServiceData>>> = [];
function pollServices() {
const config: Config = serverConfig();
for (const [i, group] of config.services.entries()) {
for (const [j, service] of group.items.entries()) {
const handler: ServiceHandler =
getServiceHandler(service.type || '') ||
(() => {
return {} as ServiceData;
});
wrapHandler(handler)(service).then((data: ServiceData) => {
const poller: ServicePoller = getServiceRecord(service.type || '').poll;
poller(service).then((data: ServiceData) => {
serviceData[i][j] = data;
});
}

View File

@@ -1,8 +1,7 @@
import type { ServiceHandler } from '../service';
import type { ServiceConfig } from '$lib/config';
export const handle: ServiceHandler = () => {
return {
logo: 'https://cdn.rawgit.com/jellyfin/jellyfin-ux/master/branding/SVG/icon-transparent.svg',
subtitle: 'Media Server'
};
export const config: Partial<ServiceConfig> = {
title: 'Jellyfin',
logo: 'https://cdn.rawgit.com/jellyfin/jellyfin-ux/master/branding/SVG/icon-transparent.svg',
subtitle: 'Media Server'
};

View File

@@ -1,16 +1,20 @@
<script lang="ts">
import CircularProgressBar from '$lib/components/CircularProgressBar.svelte';
import type { ServiceData } from '../service';
export let data: ServiceData;
$: missingClasses = data.data == undefined ? 'animate-pulse' : '';
//eslint-disable-next-line @typescript-eslint/no-explicit-any
export let data: Record<string, any>;
$: missingClasses =
'status' in data && 'ads_percentage' in data && 'queries_today' in data && 'clients' in data
? ''
: 'animate-pulse';
</script>
<div class="flex flex-row justify-start gap-4 {missingClasses} whitespace-nowrap">
<span class="">
{#if data.data?.status == 'enabled'}
{#if data?.status == 'enabled'}
<i class="fa fa-play text-success-400 opacity-70" />
{:else if data.data?.status == 'disabled'}
{:else if data?.status == 'disabled'}
<i class="fa fa-stop text-error-400 opacity-70" />
{:else}
<i class="fa fa-question" />
@@ -19,13 +23,13 @@
</span>
<span>
<span class="m-0 inline-block h-5 w-5 p-0 align-text-bottom">
<CircularProgressBar ratio={(data.data?.ads_percentage || 0) / 100.0} />
<CircularProgressBar ratio={(data?.ads_percentage || 0) / 100.0} />
</span>
{data.data?.ads_percentage?.toFixed(1) || 'N.A.'}%
{(data?.ads_percentage || NaN).toFixed(1)}%
</span>
<span>
<i class="fa-solid fa-globe" />
{data.data?.queries_today || 'N.A.'}
{data?.queries_today || 'N.A.'}
</span>
<span>
<i class="fa fa-dharmachakra" />

View File

@@ -1,28 +1,29 @@
import type { ServiceConfig } from '$lib/config';
import { type ServiceData, type ServiceHandler } from '../service';
import type { ServicePoller } from '../service';
export const handle: ServiceHandler = async (config: ServiceConfig) => {
const res: ServiceData = {
logo: 'https://cdn.rawgit.com/pi-hole/graphics/master/Vortex/Vortex.svg',
subtitle: 'Sends ads into a black hole'
};
export const config: Partial<ServiceConfig> = {
title: 'Pi-hole',
logo: 'https://cdn.rawgit.com/pi-hole/graphics/master/Vortex/Vortex.svg',
subtitle: 'Sends ads into a black hole'
};
export const poll: ServicePoller = async (config: ServiceConfig) => {
try {
const resp: Response = await fetch(
config.url + '/api.php?summaryRaw&auth=' + config.api_token
);
res.status = resp.ok ? 'online' : 'offline';
const raw = await resp.json();
res.data = {
queries_today: raw.dns_queries_today,
ads_percentage: raw.ads_percentage_today,
status: raw.status,
client: raw.unique_clients
return {
status: resp.ok ? 'online' : 'offline',
data: {
queries_today: raw.dns_queries_today,
ads_percentage: raw.ads_percentage_today,
status: raw.status,
client: raw.unique_clients
}
};
} catch (error) {
res.status = 'offline';
console.warn('could not fetch pihole status: ' + error);
return { status: 'offline' };
}
return res;
};

View File

@@ -1,5 +1,7 @@
import type { ServiceHandler } from '../service';
import type { ServiceConfig } from '$lib/config';
export const handle: ServiceHandler = () => {
return { logo: 'https://cdn.rawgit.com/Prowlarr/Prowlarr/develop/Logo/Prowlarr.svg' };
export const config: Partial<ServiceConfig> = {
title: 'Prowlarr',
logo: 'https://cdn.rawgit.com/Prowlarr/Prowlarr/develop/Logo/Prowlarr.svg',
subtitle: 'Indexer of indexer'
};

View File

@@ -1,8 +1,7 @@
import type { ServiceHandler } from '../service';
import type { ServiceConfig } from '$lib/config';
export const handle: ServiceHandler = () => {
return {
logo: 'https://cdn.rawgit.com/Radarr/Radarr/develop/Logo/Radarr.svg',
subtitle: 'TV Shows Tracker'
};
export const config: Partial<ServiceConfig> = {
title: 'Sonarr',
logo: 'https://cdn.rawgit.com/Radarr/Radarr/develop/Logo/Radarr.svg',
subtitle: 'TV Shows Tracker'
};

View File

@@ -3,10 +3,9 @@ import type { ServiceConfig } from '$lib/config';
export type ServiceStatus = 'online' | 'offline';
export interface ServiceData {
status?: ServiceStatus;
status: ServiceStatus;
[x: string]: unknown;
data?: Record<string, unknown>;
}
export type ServiceHandler = (input: ServiceConfig) => ServiceData | Promise<ServiceData>;
export type AsyncServiceHandler = (input: ServiceConfig) => Promise<ServiceData>;
export type ServicePoller = (input: ServiceConfig) => Promise<ServiceData>;

View File

@@ -1,21 +1,45 @@
import type { ServiceHandler } from './service';
import type { ServiceConfig } from '$lib/config';
import type { ServiceData, ServicePoller } from './service';
const services: Record<string, ServiceHandler> = {};
const components: Record<string, any> = {};
function registerService(type: string, handler: ServiceHandler) {
services[type] = handler;
interface ServiceRecord {
poll: ServicePoller;
config: Partial<ServiceConfig>;
}
const services: Record<string, ServiceRecord> = {};
//eslint-disable-next-line @typescript-eslint/no-explicit-any
const components: Record<string, any> = {};
async function pollURL(config: ServiceConfig): Promise<ServiceData> {
try {
const resp = await fetch(config.url);
return { status: resp.ok ? 'online' : 'offline' };
} catch (error) {
console.warn(`could not poll '${config.url}': ` + error);
return { status: 'offline' };
}
}
function registerService(type: string, service: Partial<ServiceRecord>) {
const record: ServiceRecord = {
poll: service.poll || pollURL,
config: service.config || {}
};
services[type] = record;
}
//eslint-disable-next-line @typescript-eslint/no-explicit-any
function registerComponent(type: string, component: any) {
components[type] = component;
}
export function getServiceHandler(type: string): ServiceHandler | undefined {
return services[type];
export function getServiceRecord(type: string): ServiceRecord {
return services[type] || { poll: pollURL, config: {} };
}
//eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getServiceComponent(type: string): any {
return components[type];
}
@@ -25,12 +49,13 @@ export async function initServices() {
for (const [modulePath, load] of Object.entries(services)) {
try {
const { handle } = (await load()) as any;
if (handle == undefined) {
throw new Error(`${modulePath} does not export 'handle'`);
//eslint-disable-next-line @typescript-eslint/no-explicit-any
const { poll, config } = (await load()) as any;
if (poll == undefined && config == undefined) {
throw new Error(`${modulePath} does not export 'poll' or 'config'`);
}
const typeName = modulePath.slice(18, -12);
registerService(typeName, handle);
registerService(typeName, { poll, config });
} catch (err) {
console.error(`Could not load service definition from '${modulePath}': ${err}`);
}
@@ -42,6 +67,7 @@ export async function initComponents() {
for (const [componentPath, load] of Object.entries(services)) {
try {
//eslint-disable-next-line @typescript-eslint/no-explicit-any
const module = (await load()) as any;
if (module == undefined) {

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { humanizeRelativeTime } from '$lib/humanize';
import type { ServiceData } from '../service';
export let data: ServiceData;
//eslint-disable-next-line @typescript-eslint/no-explicit-any
export let data: Record<string, any>;
</script>
<div class="flex flex-row justify-start gap-4 whitespace-nowrap">

View File

@@ -1,11 +1,12 @@
import type { ServiceConfig } from '$lib/config';
import type { ServiceHandler } from '../service';
import type { ServicePoller, ServiceStatus } from '../service';
interface Status {
warnings: number;
errors: number;
}
//eslint-disable-next-line @typescript-eslint/no-explicit-any
function buildStatus(statuses: any[]): Status {
let warnings = 0;
let errors = 0;
@@ -26,6 +27,8 @@ interface Queue {
nextDate?: Date;
total: number;
}
//eslint-disable-next-line @typescript-eslint/no-explicit-any
function recordEstimatedCompletionTime(record: any): number {
if (record.estimatedCompletionTime == undefined) {
return Infinity;
@@ -33,6 +36,7 @@ function recordEstimatedCompletionTime(record: any): number {
return new Date(record.estimatedCompletionTime).getTime();
}
//eslint-disable-next-line @typescript-eslint/no-explicit-any
function buildQueue(queue: any): Queue {
if (queue?.records?.length === 0) {
return { total: 0 };
@@ -51,12 +55,13 @@ function buildQueue(queue: any): Queue {
};
}
export const handle: ServiceHandler = async (config: ServiceConfig) => {
const res = {
logo: 'https://cdn.rawgit.com/Sonarr/Sonarr/develop/Logo/Sonarr.svg',
subtitle: 'TV Show tracker'
};
export const config: Partial<ServiceConfig> = {
title: 'Sonarr',
logo: 'https://cdn.rawgit.com/Sonarr/Sonarr/develop/Logo/Sonarr.svg',
subtitle: 'TV Show tracker'
};
export const poll: ServicePoller = async (config: ServiceConfig) => {
const params = '?apikey=' + config.api_key;
const requests = [
@@ -65,21 +70,22 @@ export const handle: ServiceHandler = async (config: ServiceConfig) => {
];
const [health, queue] = await Promise.allSettled(requests);
res.status = 'online';
let status: ServiceStatus = 'online';
const data: Record<string, unknown> = {};
if (health.status != 'fulfilled') {
console.warn("Could not fetch '" + config.url + "' status: " + health.value);
res.status = 'offline';
console.warn("Could not fetch '" + config.url + "' status: " + health.reason);
status = 'offline';
} else if (health.value.ok == true) {
res.health = buildStatus(await health.value.json());
data.health = buildStatus(await health.value.json());
}
if (queue.status != 'fulfilled') {
console.warn("Could not fetch '" + config.url + "' queue: " + queue.value);
res.status = 'offline';
console.warn("Could not fetch '" + config.url + "' queue: " + queue.reason);
data.status = 'offline';
} else if (queue.value.ok == true) {
res.queue = buildQueue(await queue.value.json());
data.queue = buildQueue(await queue.value.json());
}
return res;
return { status, data };
};

View File

@@ -1,14 +0,0 @@
title: 'Hello World !!'
subtitle: 'actually, I am a new pilot.'
services:
- 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'
keywords: 'cloud storage files'