relayout folders

This commit is contained in:
Joel Wetzell
2026-03-07 10:49:13 -06:00
parent f792ca9e85
commit 5f91889c10
32 changed files with 5 additions and 7 deletions
+1 -1
View File
@@ -9,6 +9,6 @@ export const appRoutes: Routes = [
},
{
path: 'config',
loadComponent: () => import('./config/config.component').then((m) => m.ConfigComponent),
loadComponent: () => import('./components/config/config.component').then((m) => m.ConfigComponent),
},
];
+4 -5
View File
@@ -7,11 +7,10 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatToolbarModule } from '@angular/material/toolbar';
import { TimeagoModule } from 'ngx-timeago';
import { Config } from '../models/config.models';
import { ConfigService } from '../services/config.service';
import { SchemaService } from '../services/schema.service';
import { SettingsService } from '../services/settings.service';
import { ConfigComponent } from './config/config.component';
import { Config } from './models/config.models';
import { ConfigService } from './services/config.service';
import { SchemaService } from './services/schema.service';
import { ConfigComponent } from './components/config/config.component';
import * as yaml from 'js-yaml';
import { RouterOutlet } from '@angular/router';
@@ -2,7 +2,6 @@ import { Component, computed, inject, model } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Config, ModuleConfiguration, RouteConfiguration } from '../../models/config.models';
import { SchemaService } from '../../services/schema.service';
import { MatTooltipModule } from '@angular/material/tooltip';
+27
View File
@@ -0,0 +1,27 @@
export type Config = {
modules: ModuleConfiguration[];
routes: RouteConfiguration[];
};
export type ConfigState = {
config: Config;
timestamp: number;
isCurrent: boolean;
};
export type ModuleConfiguration = {
id?: string;
type: string;
params?: Record<string, any>;
};
export type RouteConfiguration = {
input?: string;
processors?: ProcessorConfiguration[];
output?: string;
};
export type ProcessorConfiguration = {
type: string;
params?: Record<string, any>;
};
+28
View File
@@ -0,0 +1,28 @@
import { FormGroup } from '@angular/forms';
export type ObjectInfo = {
name: string;
type: string;
schema: any;
};
export type ParamsFormInfo = {
formGroup: FormGroup;
paramsInfo: ParamsInfo;
};
export type ParamsInfo = {
[key: string]: ParamInfo;
};
export type ParamInfo = {
key: string;
display: string;
hint: string;
type: string;
placeholder?: string;
options?: string[];
isConst: boolean;
schema: any;
default?: any;
};
+65
View File
@@ -0,0 +1,65 @@
import { computed, effect, Injectable, signal } from '@angular/core';
import { cloneDeep } from 'lodash-es';
import { Config } from '../models/config.models';
import { SchemaService } from './schema.service';
import { SettingsService } from './settings.service';
@Injectable({
providedIn: 'root',
})
export class ConfigService {
pendingConfigIsValid = computed(() => {
const config = this.currentlyShownConfig();
if (config === undefined) {
return false;
}
return this.schemaService.validate(config);
});
currentlyShownConfig = signal<Config | undefined>(undefined);
constructor(private schemaService: SchemaService) {
this.loadConfig();
effect(() => {
console.log('config state changed', this.currentlyShownConfig());
});
}
loadConfig() {
const configString = localStorage.getItem('config');
if (configString) {
try {
const config = JSON.parse(configString);
const valid = this.schemaService.validate(config);
if (valid) {
console.log('Loaded config from local storage', config);
this.updateCurrentlyShownConfig(config);
} else {
console.error('Config in local storage is invalid', config);
this.setEmptyConfig();
}
} catch (e) {
console.error('Failed to parse config from local storage', e);
this.setEmptyConfig();
}
} else {
this.setEmptyConfig();
}
}
setEmptyConfig() {
console.log('Setting empty config');
this.updateCurrentlyShownConfig({
modules: [],
routes: [],
});
}
saveConfig(config: Config) {
localStorage.setItem('config', JSON.stringify(config));
console.log('Config saved to local storage', config);
}
updateCurrentlyShownConfig(config: Config) {
// NOTE(jwetzell): mark the configStates that match the currently shown as such
this.currentlyShownConfig.set(cloneDeep(config));
}
}
+24
View File
@@ -0,0 +1,24 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class ListsService {
public processorListIds: string[] = [];
registerProcessorList(path: string | undefined) {
if (path === undefined) {
return '';
}
const id = this.pathToId(path);
if (!this.processorListIds.includes(id)) {
this.processorListIds.push(id);
}
return id;
}
pathToId(path: string) {
return path.replaceAll('/', '.');
}
}
+525
View File
@@ -0,0 +1,525 @@
import { HttpClient } from '@angular/common/http';
import { computed, effect, Injectable, signal } from '@angular/core';
import {
AbstractControl,
FormControl,
FormGroup,
ValidationErrors,
ValidatorFn,
Validators,
} from '@angular/forms';
import { Ajv2020, JSONSchemaType } from 'ajv/dist/2020';
import { SomeJSONSchema } from 'ajv/dist/types/json-schema';
import { sortBy } from 'lodash-es';
import { Config, ModuleConfiguration, ProcessorConfiguration } from '../models/config.models';
import { ObjectInfo, ParamsFormInfo } from '../models/form.model';
import { SettingsService } from './settings.service';
import { RouteConfiguration } from '../models/config.models';
@Injectable({
providedIn: 'root',
})
export class SchemaService {
configSchema = signal<JSONSchemaType<Config> | undefined>(undefined);
modulesSchema = signal<JSONSchemaType<ModuleConfiguration[]> | undefined>(undefined);
routesSchema = signal<JSONSchemaType<RouteConfiguration[]> | undefined>(undefined);
processorsSchema = signal<JSONSchemaType<ProcessorConfiguration[]> | undefined>(undefined);
schemasLoaded = computed(() => {
return (
this.configSchema() !== undefined &&
this.modulesSchema() !== undefined &&
this.routesSchema() !== undefined &&
this.processorsSchema() !== undefined
);
});
ajv: Ajv2020 = new Ajv2020({ allErrors: true });
processorTypes: ObjectInfo[] = [];
moduleTypes: ObjectInfo[] = [];
errorPaths: string[] = [];
constructor(
private http: HttpClient,
private settingsService: SettingsService,
) {
effect(() => {
if (this.settingsService.configSchemaUrl()) {
this.loadConfigSchema();
}
});
effect(() => {
if (this.settingsService.modulesSchemaUrl()) {
this.loadModulesSchema();
}
});
effect(() => {
if (this.settingsService.routesSchemaUrl()) {
this.loadRoutesSchema();
}
});
effect(() => {
if (this.settingsService.processorsSchemaUrl()) {
this.loadProcessorsSchema();
}
});
effect(() => {
if (this.schemasLoaded()) {
this.ajv.addSchema(this.modulesSchema()!);
this.ajv.addSchema(this.routesSchema()!);
this.ajv.addSchema(this.processorsSchema()!);
this.ajv.compile(this.configSchema()!);
}
});
}
loadConfigSchema() {
this.http
.get<JSONSchemaType<Config>>(this.settingsService.configSchemaUrl().toString())
.subscribe((schema) => {
this.setConfigSchema(schema);
});
}
loadModulesSchema() {
this.http
.get<
JSONSchemaType<ModuleConfiguration[]>
>(this.settingsService.modulesSchemaUrl().toString())
.subscribe((schema) => {
this.setModulesSchema(schema);
});
}
loadRoutesSchema() {
this.http
.get<JSONSchemaType<RouteConfiguration[]>>(this.settingsService.routesSchemaUrl().toString())
.subscribe((schema) => {
this.setRoutesSchema(schema);
});
}
loadProcessorsSchema() {
this.http
.get<
JSONSchemaType<ProcessorConfiguration[]>
>(this.settingsService.processorsSchemaUrl().toString())
.subscribe((schema) => {
this.setProcessorsSchema(schema);
});
}
validate(data: any): boolean {
if (this.schemasLoaded() && this.ajv) {
this.ajv.validate('https://showbridge.io/config.schema.json', data);
if (this.ajv.errors) {
console.error('validation errors', this.ajv.errors);
const errorPaths = new Set(
this.ajv.errors.map((error) => {
const errorPathMatch = error.instancePath.match(/(\/.*)\d+/);
if (errorPathMatch) {
return errorPathMatch[0]
.substring(1)
.replaceAll('handlers/', '')
.replaceAll('/params', '');
}
return '';
}),
);
this.errorPaths = Array.from(errorPaths);
console.log(this.errorPaths);
return false;
} else {
this.errorPaths = [];
}
}
return true;
}
getSkeletonForRoute(): RouteConfiguration {
const template: RouteConfiguration = {};
return template;
}
getSkeletonForProcessor(processorType: string): ProcessorConfiguration {
const template: ProcessorConfiguration = {
type: processorType,
};
const itemInfo = this.processorTypes.find((itemInfo) => itemInfo.type === processorType);
if (itemInfo?.schema?.required && itemInfo.schema.required.includes('params')) {
template.params = this.getSkeletonForParamsSchema(
itemInfo.schema.properties.params,
) as Record<string, any>;
}
return template;
}
getSkeletonForModule(moduleType: string): ModuleConfiguration {
const template: ModuleConfiguration = {
type: moduleType,
};
const itemInfo = this.moduleTypes.find((itemInfo) => itemInfo.type === moduleType);
if (itemInfo?.schema?.required && itemInfo.schema.required.includes('params')) {
template.params = this.getSkeletonForParamsSchema(
itemInfo.schema.properties.params,
) as Record<string, any>;
}
return template;
}
getSkeletonForParamsSchema(paramsSchema: SomeJSONSchema): Record<string, any> {
const paramsTemplate: any = {};
if (paramsSchema.properties) {
Object.entries(paramsSchema.properties).forEach(([paramKey, paramSchema]) => {
const schema = paramSchema as any;
switch (schema.type) {
case 'array':
if (paramsSchema.required?.includes(paramKey)) {
paramsTemplate[paramKey] = [];
}
break;
default:
console.error(`schema-service: unhandled param skeleton type = ${schema.type}`);
break;
}
});
}
return paramsTemplate;
}
setConfigSchema(schema: JSONSchemaType<Config>) {
this.configSchema.set(schema);
}
setModulesSchema(schema: JSONSchemaType<ModuleConfiguration[]>) {
this.modulesSchema.set(schema);
this.moduleTypes = [];
this.populateModuleTypes();
}
setRoutesSchema(schema: JSONSchemaType<RouteConfiguration[]>) {
this.routesSchema.set(schema);
}
setProcessorsSchema(schema: JSONSchemaType<ProcessorConfiguration[]>) {
this.processorsSchema.set(schema);
this.processorTypes = [];
this.populateProcessorTypes();
}
populateModuleTypes() {
const modulesSchema = this.modulesSchema();
if (modulesSchema !== undefined) {
const definitions = modulesSchema.items?.oneOf;
if (definitions) {
this.moduleTypes = sortBy(
Object.keys(definitions)
.map((definitionKey) => definitions[definitionKey])
.filter((definition) => definition.properties?.type?.const !== undefined)
.map((definition) => {
return {
name: definition['title'] || definition.properties?.type?.const,
type: definition.properties?.type?.const,
schema: definition,
};
}),
['type'],
);
}
}
}
populateProcessorTypes() {
const processorsSchema = this.processorsSchema();
if (processorsSchema !== undefined) {
const definitions = processorsSchema.items?.oneOf;
if (definitions) {
this.processorTypes = sortBy(
Object.keys(definitions)
.map((definitionKey) => definitions[definitionKey])
.filter((definition) => definition.properties?.type?.const !== undefined)
.map((definition) => {
return {
name: definition['title'] || definition.properties?.type?.const,
type: definition.properties?.type?.const,
schema: definition,
};
}),
['type'],
);
}
}
}
getSchemaForModuleType(protocolType: string) {
const moduleType = this.moduleTypes.find((item) => item.type === protocolType);
if (moduleType !== undefined) {
return moduleType.schema;
}
return undefined;
}
getSchemaForProcessorType(protocolType: string) {
const processorType = this.processorTypes.find((item) => item.type === protocolType);
if (processorType !== undefined) {
return processorType.schema;
}
return undefined;
}
matchParamsDataToSchema(data: any, schemas: SomeJSONSchema[]) {
const matchingSchemaIndex = schemas.findIndex((schema) => {
return this.ajv.validate(schema, data);
});
if (matchingSchemaIndex >= 0) {
return matchingSchemaIndex;
}
return 0;
}
getFormInfoFromParamsSchema(schema: SomeJSONSchema): ParamsFormInfo {
const paramsFormInfo: ParamsFormInfo = {
formGroup: new FormGroup({}),
paramsInfo: {},
};
if (schema?.properties) {
const paramKeys = Object.keys(schema.properties);
Object.entries(schema.properties).forEach(([paramKey, paramSchema]: [string, any]) => {
if (paramSchema.type) {
switch (paramSchema.type) {
case 'string':
case 'number':
case 'integer':
case 'boolean':
case 'array': // TODO(jwetzell): actually handle arrays
case 'object': // TODO(jwetzell): actually handle objects
let formDefault = '';
const validators: ValidatorFn[] = [];
// NOTE(jwetzell): check for a default value to set
if (paramSchema.const) {
formDefault = paramSchema.const;
} else if (paramSchema.default) {
formDefault = paramSchema.default;
}
// NOTE(jwetzell): add as many validators as we can
if (paramSchema.minimum) {
validators.push(Validators.min(paramSchema.minimum));
}
if (paramSchema.maximum) {
validators.push(Validators.max(paramSchema.maximum));
}
if (paramSchema.pattern) {
validators.push(Validators.pattern(new RegExp(paramSchema.pattern)));
}
if (schema.required) {
if (schema.required.includes(paramKey)) {
validators.push(Validators.required);
}
}
paramsFormInfo.paramsInfo[paramKey] = {
key: paramKey,
display: paramSchema.title ? paramSchema.title : paramKey,
type: paramSchema.type,
hint: paramSchema.description,
isConst: !!paramSchema.const,
schema: paramSchema,
placeholder: '',
default: paramSchema.default ? paramSchema.default : undefined
};
if (paramSchema.examples && paramSchema.examples.length > 0) {
paramsFormInfo.paramsInfo[paramKey].placeholder = paramSchema.examples[0];
}
if (paramSchema.type === 'object') {
validators.push(this.objectValidator);
}
//TODO(jwetzell): figure out how to disable a control but not have to deal with undefined values on disabled controls
paramsFormInfo.formGroup.addControl(
paramKey,
new FormControl(formDefault, validators),
);
if (paramSchema.enum) {
paramsFormInfo.paramsInfo[paramKey].options = paramSchema.enum;
}
break;
default:
console.error(
`schema-service: unhandled param schema type for form group = ${paramSchema.type}`,
);
break;
}
} else {
console.error('schema-service: param property without type');
}
});
} else {
console.error('trigger-form: params schema without properties');
console.error(schema);
}
return paramsFormInfo;
}
cleanArray(values: any[], itemSchema: SomeJSONSchema) {
if (Array.isArray(values)) {
switch (itemSchema.type) {
case 'number':
return values.map(Number.parseFloat);
case 'integer':
return values.map(Number.parseInt);
case 'string':
return values;
case 'object':
return values;
default:
console.error(`schema-service: unsupported array type ${itemSchema.type}`);
return values;
}
}
return [];
}
cleanParams(paramsSchema: SomeJSONSchema, params: any): any {
Object.keys(params).forEach((paramKey) => {
if (paramsSchema.properties[paramKey]) {
const paramSchema = paramsSchema.properties[paramKey];
// delete null/undefined/empty params that aren't required
if (
params[paramKey] === undefined ||
params[paramKey] === null ||
params[paramKey] === ''
) {
if (paramSchema.required) {
if (!paramSchema.includes(paramKey)) {
delete params[paramKey];
return;
}
} else {
delete params[paramKey];
return;
}
}
if (paramSchema.type) {
switch (paramSchema.type) {
case 'integer':
var paramValue = parseInt(params[paramKey]);
// NOTE(jwetzell): delete non-numbers
if (Number.isNaN(paramValue)) {
delete params[paramKey];
} else {
params[paramKey] = paramValue;
}
break;
case 'number':
var paramValue = parseFloat(params[paramKey]);
// NOTE(jwetzell): delete non-numbers
if (Number.isNaN(paramValue)) {
delete params[paramKey];
} else {
params[paramKey] = paramValue;
}
break;
case 'array':
if (!Array.isArray(params[paramKey])) {
const paramValue = params[paramKey];
params[paramKey] = this.parseStringToArray(paramValue, paramSchema);
}
break;
case 'object':
try {
params[paramKey] = JSON.parse(params[paramKey]);
} catch (error) {
console.error('object param is not JSON');
}
break;
case 'string':
// string is default so nothing needs to happen to clean it's value
break;
default:
console.log(`schema-service: unhandled param schema type: ${paramSchema.type}`);
break;
}
}
}
});
return params;
}
parseStringToArray(value: any, schema: SomeJSONSchema): any[] | undefined {
if (!Array.isArray(value)) {
const paramValue = value;
if (paramValue === undefined || paramValue.trim().length === 0) {
value = [];
} else {
if (schema.items?.type) {
if (schema.items?.type === 'integer') {
return paramValue
.split(',')
.map((part: string) => part.trim())
.map((item: any) => parseInt(item));
} else if (schema.items?.type === 'number') {
return paramValue
.split(',')
.map((part: string) => part.trim())
.map((item: any) => parseFloat(item));
} else if (schema.items?.type === 'string') {
return paramValue.split(',').map((part: string) => part.trim());
} else if (schema.items?.type === 'object') {
// TODO(jwetzell): this seems gross, not sure if this covers everything
return JSON.parse(`[${paramValue}]`);
} else {
console.error(`schema-service: unhandled array schema type: ${schema.items?.type}`);
}
} else {
// NOTE(jwetzell): default to comma-separated strings
return paramValue.split(',').map((part: string) => part.trim());
}
}
}
return undefined;
}
objectValidator(control: AbstractControl): ValidationErrors | null {
try {
JSON.parse(control.value);
return null;
} catch (error) {
return { json: true };
}
}
jsonValidator(validateSchema: SomeJSONSchema) {
return (control: AbstractControl): ValidationErrors | null => {
try {
const jsonObj = JSON.parse(control.value);
if (this.ajv.validate(validateSchema, jsonObj)) {
return null;
} else {
return { json: this.ajv.errors };
}
} catch (error) {
console.error(error);
return { json: true };
}
};
}
}
+35
View File
@@ -0,0 +1,35 @@
import { computed, Injectable, signal } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class SettingsService {
public isDummySite: boolean = false;
public configSchemaPath = signal('/config.schema.json');
public modulesSchemaPath = signal('/modules.schema.json');
public routesSchemaPath = signal('/routes.schema.json');
public processorsSchemaPath = signal('/processors.schema.json');
public baseUrl = signal(window.location.href);
public configSchemaUrl = computed(() => {
return new URL(this.configSchemaPath(), this.baseUrl());
});
public modulesSchemaUrl = computed(() => {
return new URL(this.modulesSchemaPath(), this.baseUrl());
});
public routesSchemaUrl = computed(() => {
return new URL(this.routesSchemaPath(), this.baseUrl());
});
public processorsSchemaUrl = computed(() => {
return new URL(this.processorsSchemaPath(), this.baseUrl());
});
constructor() {}
updateBaseUrl(baseUrl: string) {
this.baseUrl.set(baseUrl);
}
}