diff --git a/src/app/app.html b/src/app/app.html index cd698bb..fdf26e0 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -23,7 +23,7 @@ download @if (configService.pendingConfigIsValid()) { - } @else { diff --git a/src/app/app.ts b/src/app/app.ts index afde71d..2dfb3f5 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -46,8 +46,8 @@ export class App { applyConfig() { const config = this.configService.currentlyShownConfig(); if (config !== undefined) { - this.configService.saveConfig(config); - this.snackBar.open('Config Saved', 'Dismiss', { + this.configService.uploadConfig(config); + this.snackBar.open('Config Uploaded', 'Dismiss', { duration: 3000, }); } diff --git a/src/app/components/config/config.component.ts b/src/app/components/config/config.component.ts index 3ff681b..b4e0448 100644 --- a/src/app/components/config/config.component.ts +++ b/src/app/components/config/config.component.ts @@ -3,7 +3,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { Config, ModuleConfiguration, RouteConfiguration } from '../../models/config.models'; +import { Config, ModuleConfig, RouteConfig } from '../../models/config.models'; import { ConfigService } from '../../services/config.service'; import { SchemaService } from '../../services/schema.service'; import { ModuleListComponent } from '../module-list/module-list.component'; @@ -42,7 +42,7 @@ export class ConfigComponent { public eventsService = inject(EventsService); - modulesUpdated(modules: ModuleConfiguration[] | undefined) { + modulesUpdated(modules: ModuleConfig[] | undefined) { if (modules === undefined) { console.error('modules is undefined, not updating'); return; @@ -58,7 +58,7 @@ export class ConfigComponent { }); } - routesUpdated(routes: RouteConfiguration[] | undefined) { + routesUpdated(routes: RouteConfig[] | undefined) { if (routes === undefined) { console.error('routes is undefined, not updating'); return; diff --git a/src/app/components/module-list/module-list.component.ts b/src/app/components/module-list/module-list.component.ts index f818193..a0f2655 100644 --- a/src/app/components/module-list/module-list.component.ts +++ b/src/app/components/module-list/module-list.component.ts @@ -1,7 +1,7 @@ import { Component, inject, model } from '@angular/core'; import { ModuleComponent } from '../module/module.component'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { ModuleConfiguration } from '../../models/config.models'; +import { ModuleConfig } from '../../models/config.models'; import { SchemaService } from '../../services/schema.service'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; @@ -21,12 +21,12 @@ import { MatTooltipModule } from '@angular/material/tooltip'; styleUrl: './module-list.component.css', }) export class ModuleListComponent { - modules = model(); + modules = model(); public schemaService = inject(SchemaService); private snackBar = inject(MatSnackBar); - moduleUpdated(index: number, module: ModuleConfiguration | undefined) { + moduleUpdated(index: number, module: ModuleConfig | undefined) { if (module === undefined) { console.error('module is undefined, not updating'); return; diff --git a/src/app/components/module/module.component.html b/src/app/components/module/module.component.html index 016061f..6b6661a 100644 --- a/src/app/components/module/module.component.html +++ b/src/app/components/module/module.component.html @@ -43,5 +43,20 @@ +
+ @if (moduleConfigErrors().length > 0) { +
+
+ error + Module Errors +
+ @for (error of moduleConfigErrors(); track error) { +
+ {{ error.error }} +
+ } +
+ } +
} diff --git a/src/app/components/module/module.component.ts b/src/app/components/module/module.component.ts index 02725b3..09febd6 100644 --- a/src/app/components/module/module.component.ts +++ b/src/app/components/module/module.component.ts @@ -3,13 +3,14 @@ import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angula import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; -import { ModuleConfiguration } from '../../models/config.models'; +import { ModuleConfig } from '../../models/config.models'; import { SchemaService } from '../../services/schema.service'; import { ParamsFormComponent } from '../params-form/params-form.component'; import { JsonPipe } from '@angular/common'; import { MatTooltipModule } from '@angular/material/tooltip'; import { EventsService } from '../../services/events.service'; import { tap, debounceTime } from 'rxjs'; +import { ConfigService } from '../../services/config.service'; @Component({ selector: 'app-module', @@ -27,7 +28,17 @@ import { tap, debounceTime } from 'rxjs'; }) export class ModuleComponent { path = input(''); - module = model(); + index = computed(() => { + const path = this.path(); + if (path) { + const parts = path.split('/'); + const lastPart = parts[parts.length - 1]; + return parseInt(lastPart, 10); + } + return undefined; + }); + + module = model(); delete = output(); params = computed(() => this.module()!.params); @@ -39,6 +50,15 @@ export class ModuleComponent { : undefined; }); + moduleConfigErrors = computed(() => { + const index = this.index(); + const moduleErrors = this.configService.moduleErrors(); + if (index !== undefined) { + return moduleErrors.filter((error) => error.index === index); + } + return []; + }); + formGroup: FormGroup = new FormGroup({ id: new FormControl('', [Validators.required]), type: new FormControl(''), @@ -49,6 +69,7 @@ export class ModuleComponent { private schemaService = inject(SchemaService); private eventsService = inject(EventsService); + private configService = inject(ConfigService); ngOnInit(): void { this.formGroup.patchValue({ id: this.module()?.id, diff --git a/src/app/components/processor/processor.component.html b/src/app/components/processor/processor.component.html index 0a5fed8..3c1ce86 100644 --- a/src/app/components/processor/processor.component.html +++ b/src/app/components/processor/processor.component.html @@ -9,7 +9,7 @@
{{ schema()?.title || processor()?.type }}
-
+
close
diff --git a/src/app/components/processor/processor.component.ts b/src/app/components/processor/processor.component.ts index 574e13e..a8362f2 100644 --- a/src/app/components/processor/processor.component.ts +++ b/src/app/components/processor/processor.component.ts @@ -1,5 +1,5 @@ import { Component, computed, inject, input, model, output, signal } from '@angular/core'; -import { ProcessorConfiguration } from '../../models/config.models'; +import { ProcessorConfig } from '../../models/config.models'; import { SchemaService } from '../../services/schema.service'; import { MatIconModule } from '@angular/material/icon'; import { ParamsFormComponent } from '../params-form/params-form.component'; @@ -13,7 +13,7 @@ import { ReactiveFormsModule } from '@angular/forms'; }) export class ProcessorComponent { path = input(''); - processor = model(); + processor = model(); delete = output(); params = computed(() => this.processor()!.params); diff --git a/src/app/components/route-list.component/route-list.component.ts b/src/app/components/route-list.component/route-list.component.ts index 2bc9230..6938905 100644 --- a/src/app/components/route-list.component/route-list.component.ts +++ b/src/app/components/route-list.component/route-list.component.ts @@ -1,6 +1,6 @@ import { Component, inject, input, model } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { ModuleConfiguration, RouteConfiguration } from '../../models/config.models'; +import { ModuleConfig, RouteConfig } from '../../models/config.models'; import { SchemaService } from '../../services/schema.service'; import { RouteComponent } from '../route/route.component'; import { MatButtonModule } from '@angular/material/button'; @@ -22,7 +22,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; }) export class RouteListComponent { - routes = model(); + routes = model(); moduleIds = input(); public schemaService = inject(SchemaService); @@ -41,7 +41,7 @@ export class RouteListComponent { }); } - routeUpdated(index: number, route: RouteConfiguration | undefined) { + routeUpdated(index: number, route: RouteConfig | undefined) { if (route === undefined) { console.error('route is undefined, not updating'); return; @@ -49,7 +49,6 @@ export class RouteListComponent { this.routes.update((routes) => { if (routes) { routes[index].input = route.input; - routes[index].output = route.output; return [...routes]; } return routes; diff --git a/src/app/components/route/route.component.html b/src/app/components/route/route.component.html index 93259c6..300abc0 100644 --- a/src/app/components/route/route.component.html +++ b/src/app/components/route/route.component.html @@ -73,6 +73,21 @@ } +
+ @if (routeConfigErrors().length > 0) { +
+
+ error + Route Errors +
+ @for (error of routeConfigErrors(); track error) { +
+ {{ error.error }} +
+ } +
+ } +
diff --git a/src/app/components/route/route.component.ts b/src/app/components/route/route.component.ts index dc99057..5a7d6c3 100644 --- a/src/app/components/route/route.component.ts +++ b/src/app/components/route/route.component.ts @@ -3,7 +3,7 @@ import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angula import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; -import { ProcessorConfiguration, RouteConfiguration } from '../../models/config.models'; +import { ProcessorConfig, RouteConfig } from '../../models/config.models'; import { SchemaService } from '../../services/schema.service'; import { JsonPipe } from '@angular/common'; import { ProcessorComponent } from '../processor/processor.component'; @@ -11,6 +11,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { EventsService } from '../../services/events.service'; import { debounceTime, tap } from 'rxjs'; +import { ConfigService } from '../../services/config.service'; @Component({ selector: 'app-route', @@ -39,7 +40,7 @@ export class RouteComponent { return undefined; }); - route = model(); + route = model(); moduleIds = input([]); delete = output(); @@ -51,6 +52,15 @@ export class RouteComponent { return []; }); + routeConfigErrors = computed(() => { + const index = this.index(); + const routeErrors = this.configService.routeErrors(); + if (index !== undefined) { + return routeErrors.filter((error) => error.index === index); + } + return []; + }); + formGroup: FormGroup = new FormGroup({ input: new FormControl('', [Validators.required]), }); @@ -58,6 +68,7 @@ export class RouteComponent { public schemaService = inject(SchemaService); private snackBar = inject(MatSnackBar); private eventsService = inject(EventsService); + private configService = inject(ConfigService); indicatorColor = signal('gray'); @@ -119,7 +130,7 @@ export class RouteComponent { }); } - processorUpdated(index: number, processor: ProcessorConfiguration | undefined) { + processorUpdated(index: number, processor: ProcessorConfig | undefined) { if (processor === undefined) { console.error('processor is undefined, not updating'); return; diff --git a/src/app/models/config.models.ts b/src/app/models/config.models.ts index 00aa1cc..4879581 100644 --- a/src/app/models/config.models.ts +++ b/src/app/models/config.models.ts @@ -1,6 +1,7 @@ export type Config = { - modules: ModuleConfiguration[]; - routes: RouteConfiguration[]; + api: ApiConfig + modules: ModuleConfig[]; + routes: RouteConfig[]; }; export type ConfigState = { @@ -9,19 +10,40 @@ export type ConfigState = { isCurrent: boolean; }; -export type ModuleConfiguration = { +export type ApiConfig = { + port: number; +}; + +export type ModuleConfig = { id?: string; type: string; params?: Record; }; -export type RouteConfiguration = { +export type RouteConfig = { input?: string; - processors?: ProcessorConfiguration[]; - output?: string; + processors?: ProcessorConfig[]; }; -export type ProcessorConfiguration = { +export type ProcessorConfig = { type: string; params?: Record; }; + + +export type ModuleError = { + index: number; + config: ModuleConfig; + error: string; +} + +export type RouteError = { + index: number; + config: RouteConfig; + error: string; +} + +export type ConfigError = { + moduleErrors?: ModuleError[]; + routeErrors?: RouteError[]; +} \ No newline at end of file diff --git a/src/app/models/events.models.ts b/src/app/models/events.models.ts index 9752c11..d276ebd 100644 --- a/src/app/models/events.models.ts +++ b/src/app/models/events.models.ts @@ -1,3 +1,4 @@ +import { Config } from "./config.models" export type RouterEvent = { type: T, diff --git a/src/app/services/config.service.ts b/src/app/services/config.service.ts index 02405a6..4ac56d1 100644 --- a/src/app/services/config.service.ts +++ b/src/app/services/config.service.ts @@ -1,9 +1,10 @@ import { computed, effect, inject, Injectable, signal } from '@angular/core'; -import { cloneDeep } from 'lodash-es'; -import { Config } from '../models/config.models'; +import { cloneDeep, isEqual } from 'lodash-es'; +import { Config, ConfigError, ModuleError, RouteError } from '../models/config.models'; import { SchemaService } from './schema.service'; import { HttpClient } from '@angular/common/http'; import { SettingsService } from './settings.service'; +import { EventsService } from './events.service'; @Injectable({ providedIn: 'root', }) @@ -16,6 +17,19 @@ export class ConfigService { return this.schemaService.validate(config); }); currentlyShownConfig = signal(undefined); + runningConfig = signal(undefined); + + configIsDirty = computed(() => { + const currentlyShown = this.currentlyShownConfig(); + const running = this.runningConfig(); + if (currentlyShown === undefined || running === undefined) { + return false; + } + return !isEqual(currentlyShown, running); + }); + + moduleErrors = signal([]); + routeErrors = signal([]); private http = inject(HttpClient); private settingsService = inject(SettingsService); @@ -28,25 +42,6 @@ export class ConfigService { } 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(); - // } const configUrl = this.settingsService.configUrl(); if (!configUrl) { console.error('Config URL is not set'); @@ -57,6 +52,7 @@ export class ConfigService { next: (config) => { if (this.schemaService.validate(config)) { this.updateCurrentlyShownConfig(config); + this.runningConfig.set(cloneDeep(config)); } else { console.error('Config from server is invalid', config); this.setEmptyConfig(); @@ -69,21 +65,46 @@ export class ConfigService { }); } + uploadConfig(config: Config) { + if (this.schemaService.validate(config)) { + this.updateCurrentlyShownConfig(config); + const configUrl = this.settingsService.configUrl(); + if (!configUrl) { + console.error('Config URL is not set, cannot upload config'); + return; + } + this.http.put(configUrl.toString(), config).subscribe({ + next: () => { + console.log('Config uploaded successfully'); + this.moduleErrors.set([]); + this.routeErrors.set([]); + this.runningConfig.set(cloneDeep(config)); + }, + error: (err) => { + console.error('Problems occurred while uploading config', err); + if (err.error) { + const configError: ConfigError = err.error; + this.moduleErrors.set(configError.moduleErrors ?? []); + this.routeErrors.set(configError.routeErrors ?? []); + } + this.runningConfig.set(cloneDeep(config)); + }, + }); + } else { + console.error('Uploaded config is invalid', config); + } + } + setEmptyConfig() { console.log('Setting empty config'); this.updateCurrentlyShownConfig({ + api: { port: 8080 }, 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)); } } diff --git a/src/app/services/events.service.ts b/src/app/services/events.service.ts index 3fab341..082fc69 100644 --- a/src/app/services/events.service.ts +++ b/src/app/services/events.service.ts @@ -1,82 +1,84 @@ import { effect, inject, Injectable, signal } from '@angular/core'; -import { InputEventData, OutputEventData, RouteEventData, RouterEvent } from '../models/events.models'; +import { + InputEventData, + OutputEventData, + RouteEventData, + RouterEvent, +} from '../models/events.models'; import { filter, Subject } from 'rxjs'; import { SettingsService } from './settings.service'; @Injectable({ providedIn: 'root', }) export class EventsService { + status = signal('closed'); + socket?: WebSocket; + private routeEvents$ = new Subject>(); + private inputEvents$ = new Subject>(); + private outputEvents$ = new Subject>(); + private settingsService = inject(SettingsService); - status = signal('closed'); - socket?: WebSocket; - private routeEvents$ = new Subject>(); - private inputEvents$ = new Subject>(); - private outputEvents$ = new Subject>(); + constructor() { + effect(() => { + console.log('Websocket URL changed:', this.settingsService.wsUrl()); + this.reload(); + }); + } - private settingsService = inject(SettingsService); + reload() { + try { + this.socket = new WebSocket(this.settingsService.wsUrl()); + this.socket.onopen = () => { + this.status.set('open'); + }; - constructor() { - effect(() => { - console.log('Websocket URL changed:', this.settingsService.wsUrl()); - this.reload(); - }); - } + this.socket.onclose = () => { + this.status.set('closed'); + }; - reload() { - try { - this.socket = new WebSocket(this.settingsService.wsUrl()); - this.socket.onopen = () => { - this.status.set('open'); - } - - this.socket.onclose = () => { - this.status.set('closed'); - setTimeout(() => { - this.reload(); - }, 2000); - } + this.socket.onerror = (error) => { + this.status.set('error'); + }; - this.socket.onerror = (error) => { - this.status.set('error'); - } - - this.socket.onmessage = (event) => { - const messageObj = JSON.parse(event.data); - switch (messageObj.type) { - case 'route': - this.routeEvents$.next(messageObj); - break; - case 'input': - this.inputEvents$.next(messageObj); - break; - case 'output': - this.outputEvents$.next(messageObj); - break; - default: - console.warn('Unknown event type:', messageObj.type); - } - } - } catch (error) { - console.error('Failed to connect to WebSocket:', error); + this.socket.onmessage = (event) => { + const messageObj = JSON.parse(event.data); + switch (messageObj.type) { + case 'route': + this.routeEvents$.next(messageObj); + break; + case 'input': + this.inputEvents$.next(messageObj); + break; + case 'output': + this.outputEvents$.next(messageObj); + break; + default: + console.warn('Unknown event type:', messageObj.type); } + }; + } catch (error) { + console.error('Failed to connect to WebSocket:', error); } + } - getRouteEventsForIndex(index: number) { - return this.routeEvents$.pipe( - filter(event => event.data?.index === index) - ); + getRouteEventsForIndex(index: number) { + return this.routeEvents$.pipe(filter((event) => event.data?.index === index)); + } + + getInputEventsForSource(source: string) { + return this.inputEvents$.pipe(filter((event) => event.data?.source === source)); + } + + getOutputEventsForDestination(destination: string) { + return this.outputEvents$.pipe(filter((event) => event.data?.destination === destination)); + } + + sendEvent(event: RouterEvent) { + if (this.socket && this.socket.readyState === WebSocket.OPEN) { + console.log('sending event:', event); + this.socket.send(JSON.stringify(event)); + } else { + console.warn('WebSocket is not open. Cannot send event:', event); } - - getInputEventsForSource(source: string) { - return this.inputEvents$.pipe( - filter(event => event.data?.source === source) - ); - } - - getOutputEventsForDestination(destination: string) { - return this.outputEvents$.pipe( - filter(event => event.data?.destination === destination) - ); - } - -} \ No newline at end of file + } +} diff --git a/src/app/services/schema.service.ts b/src/app/services/schema.service.ts index 9f1dda6..0bc2e74 100644 --- a/src/app/services/schema.service.ts +++ b/src/app/services/schema.service.ts @@ -11,19 +11,18 @@ import { 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 { Config, ModuleConfig, ProcessorConfig, RouteConfig } 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 | undefined>(undefined); - modulesSchema = signal | undefined>(undefined); - routesSchema = signal | undefined>(undefined); - processorsSchema = signal | undefined>(undefined); + modulesSchema = signal | undefined>(undefined); + routesSchema = signal | undefined>(undefined); + processorsSchema = signal | undefined>(undefined); schemasLoaded = computed(() => { return ( @@ -90,7 +89,7 @@ export class SchemaService { loadModulesSchema() { this.http .get< - JSONSchemaType + JSONSchemaType >(this.settingsService.modulesSchemaUrl().toString()) .subscribe((schema) => { this.setModulesSchema(schema); @@ -99,7 +98,7 @@ export class SchemaService { loadRoutesSchema() { this.http - .get>(this.settingsService.routesSchemaUrl().toString()) + .get>(this.settingsService.routesSchemaUrl().toString()) .subscribe((schema) => { this.setRoutesSchema(schema); }); @@ -108,7 +107,7 @@ export class SchemaService { loadProcessorsSchema() { this.http .get< - JSONSchemaType + JSONSchemaType >(this.settingsService.processorsSchemaUrl().toString()) .subscribe((schema) => { this.setProcessorsSchema(schema); @@ -142,13 +141,13 @@ export class SchemaService { return true; } - getSkeletonForRoute(): RouteConfiguration { - const template: RouteConfiguration = {}; + getSkeletonForRoute(): RouteConfig { + const template: RouteConfig = {}; return template; } - getSkeletonForProcessor(processorType: string): ProcessorConfiguration { - const template: ProcessorConfiguration = { + getSkeletonForProcessor(processorType: string): ProcessorConfig { + const template: ProcessorConfig = { type: processorType, }; const itemInfo = this.processorTypes.find((itemInfo) => itemInfo.type === processorType); @@ -160,8 +159,8 @@ export class SchemaService { return template; } - getSkeletonForModule(moduleType: string): ModuleConfiguration { - const template: ModuleConfiguration = { + getSkeletonForModule(moduleType: string): ModuleConfig { + const template: ModuleConfig = { type: moduleType, }; const itemInfo = this.moduleTypes.find((itemInfo) => itemInfo.type === moduleType); @@ -198,17 +197,17 @@ export class SchemaService { this.configSchema.set(schema); } - setModulesSchema(schema: JSONSchemaType) { + setModulesSchema(schema: JSONSchemaType) { this.modulesSchema.set(schema); this.moduleTypes = []; this.populateModuleTypes(); } - setRoutesSchema(schema: JSONSchemaType) { + setRoutesSchema(schema: JSONSchemaType) { this.routesSchema.set(schema); } - setProcessorsSchema(schema: JSONSchemaType) { + setProcessorsSchema(schema: JSONSchemaType) { this.processorsSchema.set(schema); this.processorTypes = []; this.populateProcessorTypes(); diff --git a/src/app/services/settings.service.ts b/src/app/services/settings.service.ts index 8504389..acd0af1 100644 --- a/src/app/services/settings.service.ts +++ b/src/app/services/settings.service.ts @@ -6,10 +6,10 @@ import { computed, Injectable, signal } from '@angular/core'; export class SettingsService { public isDummySite: boolean = false; public configPath = signal('/api/v1/config'); - public configSchemaPath = signal('/api/v1/schema/config'); - public modulesSchemaPath = signal('/api/v1/schema/modules'); - public routesSchemaPath = signal('/api/v1/schema/routes'); - public processorsSchemaPath = signal('/api/v1/schema/processors'); + public configSchemaPath = signal('/schema/config'); + public modulesSchemaPath = signal('/schema/modules'); + public routesSchemaPath = signal('/schema/routes'); + public processorsSchemaPath = signal('/schema/processors'); public wsPath = signal('/ws'); public baseUrl = signal('http://localhost:8080');