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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user