From 29c74e21d76f436906f2fcadb0f76312e63cbe81 Mon Sep 17 00:00:00 2001
From: Ruslan Rabadanov <ruslanrabadanov2101@gmail.com>
Date: Sat, 15 Feb 2025 21:18:55 +0100
Subject: [PATCH 01/13] FE-3 Add Configuration model

---
 src/app/api/configuration.api.ts | 28 ++++++++++++++++++++++++++--
 src/app/models/.gitkeep          |  0
 src/app/models/configuration.ts  | 21 +++++++++++++++++++++
 src/app/models/frame-range.ts    | 10 ++++++++++
 src/app/models/protocol.ts       |  8 ++++++++
 5 files changed, 65 insertions(+), 2 deletions(-)
 delete mode 100644 src/app/models/.gitkeep
 create mode 100644 src/app/models/configuration.ts
 create mode 100644 src/app/models/frame-range.ts
 create mode 100644 src/app/models/protocol.ts

diff --git a/src/app/api/configuration.api.ts b/src/app/api/configuration.api.ts
index 691912a..1c7a9ce 100644
--- a/src/app/api/configuration.api.ts
+++ b/src/app/api/configuration.api.ts
@@ -1,8 +1,32 @@
-import { Injectable } from '@angular/core';
+import { inject, Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { map, Observable } from 'rxjs';
+import {
+  Configuration,
+  configurationToDto,
+  dtoToConfiguration,
+} from '../models/configuration';
 
 export const CONFIGURATION_API_URL = '/api/v1/configuration';
 
 @Injectable({
   providedIn: 'root',
 })
-export class ConfigurationApi {}
+export class ConfigurationApi {
+  private readonly httpClient = inject(HttpClient);
+
+  fetch(): Observable<Configuration> {
+    return this.httpClient
+      .get<Configuration>(CONFIGURATION_API_URL)
+      .pipe(map(dto => dtoToConfiguration(dto)));
+  }
+
+  save(configuration: Configuration): Observable<Configuration> {
+    return this.httpClient
+      .put<Configuration>(
+        CONFIGURATION_API_URL,
+        configurationToDto(configuration)
+      )
+      .pipe(map(dto => dtoToConfiguration(dto)));
+  }
+}
diff --git a/src/app/models/.gitkeep b/src/app/models/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/app/models/configuration.ts b/src/app/models/configuration.ts
new file mode 100644
index 0000000..c908851
--- /dev/null
+++ b/src/app/models/configuration.ts
@@ -0,0 +1,21 @@
+import { FrameRange } from './frame-range';
+import { Protocol } from './protocol';
+
+export type Configuration = ConfigurationDto;
+
+export interface ConfigurationDto {
+  mac_source: string[];
+  mac_destination: string[];
+  frame_range: FrameRange[];
+  protocol: Protocol[];
+}
+
+export function dtoToConfiguration(dto: ConfigurationDto): Configuration {
+  return dto;
+}
+
+export function configurationToDto(
+  configuration: Configuration
+): ConfigurationDto {
+  return configuration;
+}
diff --git a/src/app/models/frame-range.ts b/src/app/models/frame-range.ts
new file mode 100644
index 0000000..bbcb1e4
--- /dev/null
+++ b/src/app/models/frame-range.ts
@@ -0,0 +1,10 @@
+const FrameRange: { [key: string]: [number, number] } = {
+  UNDER_63: [0, 63],
+  FROM_64_TO_127: [64, 127],
+  FROM_128_TO_255: [128, 255],
+  FROM_256_TO_511: [256, 511],
+  FROM_512_TO_1023: [512, 1023],
+  FROM_1024_TO_1518: [1024, 1518],
+  ABOVE_1518: [1519, Infinity],
+};
+export type FrameRange = (typeof FrameRange)[keyof typeof FrameRange];
diff --git a/src/app/models/protocol.ts b/src/app/models/protocol.ts
new file mode 100644
index 0000000..bbc040f
--- /dev/null
+++ b/src/app/models/protocol.ts
@@ -0,0 +1,8 @@
+export enum Protocol {
+  IPv4 = 'IPv4',
+  IPv6 = 'IPv6',
+  ARP = 'ARP',
+  ICMP = 'ICMP',
+  TCP = 'TCP',
+  UDP = 'UDP',
+}
-- 
GitLab


From 90ea171ae6665c1b4a65e91e577c2f0eb5d348ff Mon Sep 17 00:00:00 2001
From: Ruslan Rabadanov <ruslanrabadanov2101@gmail.com>
Date: Sat, 15 Feb 2025 22:49:16 +0100
Subject: [PATCH 02/13] FE-3 Add components for configuration page

---
 src/app/api/configuration.api.ts              | 10 ++--
 src/app/app.config.ts                         |  5 +-
 .../components/button/button.component.html   |  5 ++
 .../components/button/button.component.scss   | 19 ++++++++
 .../button/button.component.spec.ts           | 23 +++++++++
 src/app/components/button/button.component.ts | 13 +++++
 .../configuration-form.builder.ts             |  0
 .../configuration-form.component.html         |  6 +++
 .../configuration-form.component.scss         |  0
 .../configuration-form.component.spec.ts      | 23 +++++++++
 .../configuration-form.component.ts           | 10 ++++
 .../configuration-template.component.html     | 22 +++++++++
 .../configuration-template.component.scss     | 29 ++++++++++++
 .../configuration-template.component.spec.ts  | 23 +++++++++
 .../configuration-template.component.ts       | 10 ++++
 .../configuration.component.html              | 47 +++++++++++++++++--
 .../configuration.component.scss              |  8 ++++
 .../configuration/configuration.component.ts  | 28 +++++++++--
 .../components/header/header.component.scss   |  2 +-
 .../page-wrapper/page-wrapper.component.html  |  5 +-
 .../page-wrapper/page-wrapper.component.scss  | 17 ++++---
 src/app/components/pill/pill.component.html   |  6 +++
 src/app/components/pill/pill.component.scss   | 11 +++++
 .../components/pill/pill.component.spec.ts    | 23 +++++++++
 src/app/components/pill/pill.component.ts     | 12 +++++
 src/app/models/configuration.ts               | 19 ++++++--
 src/app/models/frame-range.ts                 | 10 ----
 src/app/models/protocol.ts                    |  8 ----
 src/vars.scss                                 |  1 +
 29 files changed, 353 insertions(+), 42 deletions(-)
 create mode 100644 src/app/components/button/button.component.html
 create mode 100644 src/app/components/button/button.component.scss
 create mode 100644 src/app/components/button/button.component.spec.ts
 create mode 100644 src/app/components/button/button.component.ts
 create mode 100644 src/app/components/configuration-form/configuration-form.builder.ts
 create mode 100644 src/app/components/configuration-form/configuration-form.component.html
 create mode 100644 src/app/components/configuration-form/configuration-form.component.scss
 create mode 100644 src/app/components/configuration-form/configuration-form.component.spec.ts
 create mode 100644 src/app/components/configuration-form/configuration-form.component.ts
 create mode 100644 src/app/components/configuration-template/configuration-template.component.html
 create mode 100644 src/app/components/configuration-template/configuration-template.component.scss
 create mode 100644 src/app/components/configuration-template/configuration-template.component.spec.ts
 create mode 100644 src/app/components/configuration-template/configuration-template.component.ts
 create mode 100644 src/app/components/pill/pill.component.html
 create mode 100644 src/app/components/pill/pill.component.scss
 create mode 100644 src/app/components/pill/pill.component.spec.ts
 create mode 100644 src/app/components/pill/pill.component.ts
 delete mode 100644 src/app/models/frame-range.ts
 delete mode 100644 src/app/models/protocol.ts

diff --git a/src/app/api/configuration.api.ts b/src/app/api/configuration.api.ts
index 1c7a9ce..f26de7a 100644
--- a/src/app/api/configuration.api.ts
+++ b/src/app/api/configuration.api.ts
@@ -1,8 +1,9 @@
 import { inject, Injectable } from '@angular/core';
 import { HttpClient } from '@angular/common/http';
-import { map, Observable } from 'rxjs';
+import { delay, map, Observable, of } from 'rxjs';
 import {
   Configuration,
+  configurationMock,
   configurationToDto,
   dtoToConfiguration,
 } from '../models/configuration';
@@ -16,9 +17,10 @@ export class ConfigurationApi {
   private readonly httpClient = inject(HttpClient);
 
   fetch(): Observable<Configuration> {
-    return this.httpClient
-      .get<Configuration>(CONFIGURATION_API_URL)
-      .pipe(map(dto => dtoToConfiguration(dto)));
+    // return this.httpClient
+    //   .get<Configuration>(CONFIGURATION_API_URL)
+    //   .pipe(map(dto => dtoToConfiguration(dto)));
+    return of(configurationMock).pipe(delay(500));
   }
 
   save(configuration: Configuration): Observable<Configuration> {
diff --git a/src/app/app.config.ts b/src/app/app.config.ts
index 35ca21e..2835fc6 100644
--- a/src/app/app.config.ts
+++ b/src/app/app.config.ts
@@ -4,12 +4,15 @@ import { provideAnimationsAsync } from '@angular/platform-browser/animations/asy
 import { providePrimeNG } from 'primeng/config';
 
 import { routes } from './app.routes';
+import { provideHttpClient } from '@angular/common/http';
 
 export const appConfig: ApplicationConfig = {
   providers: [
     provideZoneChangeDetection({ eventCoalescing: true }),
     provideRouter(routes),
     provideAnimationsAsync(),
-    providePrimeNG(), provideAnimationsAsync(),
+    provideHttpClient(),
+    providePrimeNG(),
+    provideAnimationsAsync(),
   ],
 };
diff --git a/src/app/components/button/button.component.html b/src/app/components/button/button.component.html
new file mode 100644
index 0000000..5661607
--- /dev/null
+++ b/src/app/components/button/button.component.html
@@ -0,0 +1,5 @@
+<button
+  [ngClass]="{ 'button-secondary': secondary() !== null }"
+  mat-flat-button>
+  <ng-content />
+</button>
diff --git a/src/app/components/button/button.component.scss b/src/app/components/button/button.component.scss
new file mode 100644
index 0000000..40311a0
--- /dev/null
+++ b/src/app/components/button/button.component.scss
@@ -0,0 +1,19 @@
+@use '../../../vars';
+@use 'sass:map';
+
+:host {
+  button {
+    text-transform: uppercase;
+    font-size: map.get(vars.$text, 'md');
+    padding: map.get(vars.$spacing, 'xl') map.get(vars.$spacing, 'xxxl');
+  }
+
+  * {
+    background-color: vars.$textPrimary;
+  }
+
+  .button-secondary {
+    color: vars.$textPrimary;
+    background-color: map.get(vars.$grey, 20);
+  }
+}
diff --git a/src/app/components/button/button.component.spec.ts b/src/app/components/button/button.component.spec.ts
new file mode 100644
index 0000000..15e6373
--- /dev/null
+++ b/src/app/components/button/button.component.spec.ts
@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ButtonComponent } from './button.component';
+
+describe('ButtonComponent', () => {
+  let component: ButtonComponent;
+  let fixture: ComponentFixture<ButtonComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [ButtonComponent]
+    })
+    .compileComponents();
+
+    fixture = TestBed.createComponent(ButtonComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/app/components/button/button.component.ts b/src/app/components/button/button.component.ts
new file mode 100644
index 0000000..cc2d400
--- /dev/null
+++ b/src/app/components/button/button.component.ts
@@ -0,0 +1,13 @@
+import { Component, input } from '@angular/core';
+import { MatButton } from '@angular/material/button';
+import { NgClass } from '@angular/common';
+
+@Component({
+  selector: 'app-button',
+  imports: [MatButton, NgClass],
+  templateUrl: './button.component.html',
+  styleUrl: './button.component.scss',
+})
+export class ButtonComponent {
+  secondary = input<string | null>(null);
+}
diff --git a/src/app/components/configuration-form/configuration-form.builder.ts b/src/app/components/configuration-form/configuration-form.builder.ts
new file mode 100644
index 0000000..e69de29
diff --git a/src/app/components/configuration-form/configuration-form.component.html b/src/app/components/configuration-form/configuration-form.component.html
new file mode 100644
index 0000000..cf46ab7
--- /dev/null
+++ b/src/app/components/configuration-form/configuration-form.component.html
@@ -0,0 +1,6 @@
+<app-configuration-template>
+  <div mac-source>editable Mac source</div>
+  <div mac-destination>editable Mac destination</div>
+  <div frames>editable Frames</div>
+  <div protocols>editable Protocols</div>
+</app-configuration-template>
diff --git a/src/app/components/configuration-form/configuration-form.component.scss b/src/app/components/configuration-form/configuration-form.component.scss
new file mode 100644
index 0000000..e69de29
diff --git a/src/app/components/configuration-form/configuration-form.component.spec.ts b/src/app/components/configuration-form/configuration-form.component.spec.ts
new file mode 100644
index 0000000..ea53535
--- /dev/null
+++ b/src/app/components/configuration-form/configuration-form.component.spec.ts
@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ConfigurationFormComponent } from './configuration-form.component';
+
+describe('ConfigurationFormComponent', () => {
+  let component: ConfigurationFormComponent;
+  let fixture: ComponentFixture<ConfigurationFormComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [ConfigurationFormComponent]
+    })
+    .compileComponents();
+
+    fixture = TestBed.createComponent(ConfigurationFormComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/app/components/configuration-form/configuration-form.component.ts b/src/app/components/configuration-form/configuration-form.component.ts
new file mode 100644
index 0000000..3151838
--- /dev/null
+++ b/src/app/components/configuration-form/configuration-form.component.ts
@@ -0,0 +1,10 @@
+import { Component } from '@angular/core';
+import { ConfigurationTemplateComponent } from '../configuration-template/configuration-template.component';
+
+@Component({
+  selector: 'app-configuration-form',
+  imports: [ConfigurationTemplateComponent],
+  templateUrl: './configuration-form.component.html',
+  styleUrl: './configuration-form.component.scss',
+})
+export class ConfigurationFormComponent {}
diff --git a/src/app/components/configuration-template/configuration-template.component.html b/src/app/components/configuration-template/configuration-template.component.html
new file mode 100644
index 0000000..d706265
--- /dev/null
+++ b/src/app/components/configuration-template/configuration-template.component.html
@@ -0,0 +1,22 @@
+<div class="configuration-section">
+  <div class="configuration-section__title">MAC address</div>
+  <div class="configuration-section__card">
+    <ng-content select="[mac-source]" />
+    <mat-divider />
+    <ng-content select="[mac-destination]" />
+  </div>
+</div>
+
+<div class="configuration-section">
+  <div class="configuration-section__title">Frame sizes</div>
+  <div class="configuration-section__card">
+    <ng-content select="[frames]" />
+  </div>
+</div>
+
+<div class="configuration-section">
+  <div class="configuration-section__title">Protocols</div>
+  <div class="configuration-section__card">
+    <ng-content select="[protocols]" />
+  </div>
+</div>
diff --git a/src/app/components/configuration-template/configuration-template.component.scss b/src/app/components/configuration-template/configuration-template.component.scss
new file mode 100644
index 0000000..fcdfe78
--- /dev/null
+++ b/src/app/components/configuration-template/configuration-template.component.scss
@@ -0,0 +1,29 @@
+@use '../../../vars';
+@use 'sass:map';
+
+:host {
+  display: flex;
+  flex-direction: column;
+  gap: map.get(vars.$spacing, 'lg');
+
+  .configuration-section {
+    display: flex;
+    flex-direction: column;
+    gap: map.get(vars.$spacing, 'xs');
+
+    &__title {
+      font-weight: bold;
+      color: map.get(vars.$grey, 100);
+      font-size: map.get(vars.$text, 'lg');
+    }
+
+    &__card {
+      display: flex;
+      flex-direction: column;
+      gap: map.get(vars.$spacing, 'md');
+      padding: map.get(vars.$spacing, 'lg');
+      border-radius: map.get(vars.$radius, 'sm');
+      background-color: map.get(vars.$grey, 10);
+    }
+  }
+}
diff --git a/src/app/components/configuration-template/configuration-template.component.spec.ts b/src/app/components/configuration-template/configuration-template.component.spec.ts
new file mode 100644
index 0000000..71a11af
--- /dev/null
+++ b/src/app/components/configuration-template/configuration-template.component.spec.ts
@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ConfigurationTemplateComponent } from './configuration-template.component';
+
+describe('ConfigurationTemplateComponent', () => {
+  let component: ConfigurationTemplateComponent;
+  let fixture: ComponentFixture<ConfigurationTemplateComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [ConfigurationTemplateComponent]
+    })
+    .compileComponents();
+
+    fixture = TestBed.createComponent(ConfigurationTemplateComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/app/components/configuration-template/configuration-template.component.ts b/src/app/components/configuration-template/configuration-template.component.ts
new file mode 100644
index 0000000..d6f45a2
--- /dev/null
+++ b/src/app/components/configuration-template/configuration-template.component.ts
@@ -0,0 +1,10 @@
+import { Component } from '@angular/core';
+import { MatDivider } from '@angular/material/divider';
+
+@Component({
+  selector: 'app-configuration-template',
+  imports: [MatDivider],
+  templateUrl: './configuration-template.component.html',
+  styleUrl: './configuration-template.component.scss',
+})
+export class ConfigurationTemplateComponent {}
diff --git a/src/app/components/configuration/configuration.component.html b/src/app/components/configuration/configuration.component.html
index 8434b8c..8948d28 100644
--- a/src/app/components/configuration/configuration.component.html
+++ b/src/app/components/configuration/configuration.component.html
@@ -1,6 +1,47 @@
 <app-page-wrapper>
-  <div header>Analyzer configuration</div>
+  <div title>Analyzer configuration</div>
 
-  Configuration content goes here
-  <p-skeleton height="10rem" width="100%" />
+  <div actions class="actions">
+    @if (isReadonly()) {
+      <app-button (click)="isReadonly.set(false)">Edit</app-button>
+    } @else {
+      <app-button secondary (click)="isReadonly.set(true)">Cancel</app-button>
+      <app-button>Save</app-button>
+    }
+  </div>
+
+  @if (configurationRes.isLoading()) {
+    Loading...
+  } @else {
+    @if (isReadonly()) {
+      @let configuration = configurationRes.value()!;
+
+      <app-configuration-template>
+        <div mac-source>
+          Source:
+          @for (mac of configuration?.mac_source; track $index) {
+            <app-pill>{{ mac }}</app-pill>
+          }
+        </div>
+        <div mac-destination>
+          Destination:
+          @for (mac of configuration?.mac_destination; track $index) {
+            <app-pill>{{ mac }}</app-pill>
+          }
+        </div>
+        <div frames>
+          @for (frame of configuration?.frame_range; track $index) {
+            <app-pill>{{ frame }}</app-pill>
+          }
+        </div>
+        <div protocols>
+          @for (protocol of configuration?.protocol; track $index) {
+            <app-pill>{{ protocol }}</app-pill>
+          }
+        </div>
+      </app-configuration-template>
+    } @else {
+      <app-configuration-form />
+    }
+  }
 </app-page-wrapper>
diff --git a/src/app/components/configuration/configuration.component.scss b/src/app/components/configuration/configuration.component.scss
index 2995308..6361cd3 100644
--- a/src/app/components/configuration/configuration.component.scss
+++ b/src/app/components/configuration/configuration.component.scss
@@ -1,4 +1,12 @@
+@use '../../../vars';
+@use 'sass:map';
+
 :host {
   height: 100%;
   width: 100%;
+
+  .actions {
+    display: flex;
+    gap: map.get(vars.$spacing, 'md');
+  }
 }
diff --git a/src/app/components/configuration/configuration.component.ts b/src/app/components/configuration/configuration.component.ts
index 6cde6f1..e4185ff 100644
--- a/src/app/components/configuration/configuration.component.ts
+++ b/src/app/components/configuration/configuration.component.ts
@@ -1,11 +1,33 @@
-import { Component } from '@angular/core';
+import { Component, inject, resource, signal } from '@angular/core';
 import { PageWrapperComponent } from '../page-wrapper/page-wrapper.component';
+import { ConfigurationTemplateComponent } from '../configuration-template/configuration-template.component';
+import { ConfigurationFormComponent } from '../configuration-form/configuration-form.component';
+import { ButtonComponent } from '../button/button.component';
+import { ConfigurationApi } from '../../api/configuration.api';
+import { firstValueFrom } from 'rxjs';
+import { PillComponent } from '../pill/pill.component';
 import { Skeleton } from 'primeng/skeleton';
 
 @Component({
   selector: 'app-configuration',
-  imports: [PageWrapperComponent, Skeleton],
+  imports: [
+    PageWrapperComponent,
+    ConfigurationTemplateComponent,
+    ConfigurationFormComponent,
+    ButtonComponent,
+    PillComponent,
+    Skeleton,
+  ],
   templateUrl: './configuration.component.html',
   styleUrl: './configuration.component.scss',
 })
-export class ConfigurationComponent {}
+export class ConfigurationComponent {
+  configurationApi = inject(ConfigurationApi);
+
+  isReadonly = signal<boolean>(true);
+  configurationRes = resource({
+    loader: async () => {
+      return await firstValueFrom(this.configurationApi.fetch());
+    },
+  });
+}
diff --git a/src/app/components/header/header.component.scss b/src/app/components/header/header.component.scss
index c5a236d..194ccfa 100644
--- a/src/app/components/header/header.component.scss
+++ b/src/app/components/header/header.component.scss
@@ -45,7 +45,7 @@
       }
     }
 
-    & > * {
+    * {
       color: vars.$textPrimary;
     }
   }
diff --git a/src/app/components/page-wrapper/page-wrapper.component.html b/src/app/components/page-wrapper/page-wrapper.component.html
index 60cb6e2..fae4904 100644
--- a/src/app/components/page-wrapper/page-wrapper.component.html
+++ b/src/app/components/page-wrapper/page-wrapper.component.html
@@ -1,5 +1,8 @@
 <div class="page-header">
-  <ng-content select="[header]"></ng-content>
+  <div class="page-header__title">
+    <ng-content select="[title]"></ng-content>
+  </div>
+  <ng-content select="[actions]"></ng-content>
 </div>
 <mat-divider />
 <div class="page-body">
diff --git a/src/app/components/page-wrapper/page-wrapper.component.scss b/src/app/components/page-wrapper/page-wrapper.component.scss
index 1a83647..ab5c237 100644
--- a/src/app/components/page-wrapper/page-wrapper.component.scss
+++ b/src/app/components/page-wrapper/page-wrapper.component.scss
@@ -5,21 +5,26 @@
   display: block;
   min-height: calc(100% - 2 * map.get(vars.$spacing, 'lg'));
 
-  margin: map.get(vars.$spacing, 'lg');
+  margin: map.get(vars.$spacing, 'xl');
   background-color: map.get(vars.$grey, 0);
   border-radius: map.get(vars.$radius, 'xs');
 
   .page {
     &-header {
-      padding: map.get(vars.$spacing, 'lg') map.get(vars.$spacing, 'lg')
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding: map.get(vars.$spacing, 'lg') map.get(vars.$spacing, 'xxl')
         map.get(vars.$spacing, 'xs');
-      font-family: Monoton, cursive;
-      font-size: map.get(vars.$text, 'xxl');
+
+      &__title {
+        font-family: Monoton, cursive;
+        font-size: map.get(vars.$text, 'xxl');
+      }
     }
 
     &-body {
-      padding: map.get(vars.$spacing, 'xl') map.get(vars.$spacing, 'lg')
-        map.get(vars.$spacing, 'lg');
+      padding: map.get(vars.$spacing, 'xxl');
     }
   }
 }
diff --git a/src/app/components/pill/pill.component.html b/src/app/components/pill/pill.component.html
new file mode 100644
index 0000000..b4b2f25
--- /dev/null
+++ b/src/app/components/pill/pill.component.html
@@ -0,0 +1,6 @@
+<div class="pill">
+  <ng-content />
+  @if (removable()) {
+    <mat-icon>remove</mat-icon>
+  }
+</div>
diff --git a/src/app/components/pill/pill.component.scss b/src/app/components/pill/pill.component.scss
new file mode 100644
index 0000000..8bc0840
--- /dev/null
+++ b/src/app/components/pill/pill.component.scss
@@ -0,0 +1,11 @@
+@use '../../../vars';
+@use 'sass:map';
+
+.pill {
+  display: flex;
+  flex-direction: row;
+  gap: map.get(vars.$spacing, 'sm');
+  border-radius: map.get(vars.$radius, 'md');
+  border: 1px solid map.get(vars.$grey, 200);
+  background-color: map.get(vars.$grey, 0);
+}
diff --git a/src/app/components/pill/pill.component.spec.ts b/src/app/components/pill/pill.component.spec.ts
new file mode 100644
index 0000000..5eb1cd0
--- /dev/null
+++ b/src/app/components/pill/pill.component.spec.ts
@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { PillComponent } from './pill.component';
+
+describe('PillComponent', () => {
+  let component: PillComponent;
+  let fixture: ComponentFixture<PillComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [PillComponent]
+    })
+    .compileComponents();
+
+    fixture = TestBed.createComponent(PillComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/app/components/pill/pill.component.ts b/src/app/components/pill/pill.component.ts
new file mode 100644
index 0000000..ccc77c2
--- /dev/null
+++ b/src/app/components/pill/pill.component.ts
@@ -0,0 +1,12 @@
+import { Component, input } from '@angular/core';
+import { MatIcon } from '@angular/material/icon';
+
+@Component({
+  selector: 'app-pill',
+  imports: [MatIcon],
+  templateUrl: './pill.component.html',
+  styleUrl: './pill.component.scss',
+})
+export class PillComponent {
+  removable = input<string | null>(null);
+}
diff --git a/src/app/models/configuration.ts b/src/app/models/configuration.ts
index c908851..0a1fe56 100644
--- a/src/app/models/configuration.ts
+++ b/src/app/models/configuration.ts
@@ -1,13 +1,10 @@
-import { FrameRange } from './frame-range';
-import { Protocol } from './protocol';
-
 export type Configuration = ConfigurationDto;
 
 export interface ConfigurationDto {
   mac_source: string[];
   mac_destination: string[];
-  frame_range: FrameRange[];
-  protocol: Protocol[];
+  frame_range: [number, number][];
+  protocol: string[];
 }
 
 export function dtoToConfiguration(dto: ConfigurationDto): Configuration {
@@ -19,3 +16,15 @@ export function configurationToDto(
 ): ConfigurationDto {
   return configuration;
 }
+
+// @todo: delete
+export const configurationMock: Configuration = {
+  mac_source: ['01:23:45:67:89:ab'],
+  mac_destination: ['fe:dc:ba:98:76:54'],
+  frame_range: [
+    [0, 63],
+    [128, 255],
+    [1024, 1518],
+  ],
+  protocol: ['IPv4', 'IP6', 'UDP'],
+};
diff --git a/src/app/models/frame-range.ts b/src/app/models/frame-range.ts
deleted file mode 100644
index bbcb1e4..0000000
--- a/src/app/models/frame-range.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-const FrameRange: { [key: string]: [number, number] } = {
-  UNDER_63: [0, 63],
-  FROM_64_TO_127: [64, 127],
-  FROM_128_TO_255: [128, 255],
-  FROM_256_TO_511: [256, 511],
-  FROM_512_TO_1023: [512, 1023],
-  FROM_1024_TO_1518: [1024, 1518],
-  ABOVE_1518: [1519, Infinity],
-};
-export type FrameRange = (typeof FrameRange)[keyof typeof FrameRange];
diff --git a/src/app/models/protocol.ts b/src/app/models/protocol.ts
deleted file mode 100644
index bbc040f..0000000
--- a/src/app/models/protocol.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export enum Protocol {
-  IPv4 = 'IPv4',
-  IPv6 = 'IPv6',
-  ARP = 'ARP',
-  ICMP = 'ICMP',
-  TCP = 'TCP',
-  UDP = 'UDP',
-}
diff --git a/src/vars.scss b/src/vars.scss
index 4cdf01f..9a4c50c 100644
--- a/src/vars.scss
+++ b/src/vars.scss
@@ -9,6 +9,7 @@ $grey: (
   20: #f3eaea,
   30: #dddddd,
   50: #c5c5c5,
+  100: #686868,
   200: #181818,
   500: #000000,
 );
-- 
GitLab


From 00943da5ab7791daf234fe9cb4a158e431098410 Mon Sep 17 00:00:00 2001
From: Ruslan Rabadanov <ruslanrabadanov2101@gmail.com>
Date: Tue, 18 Feb 2025 14:56:42 +0100
Subject: [PATCH 03/13] FE-3 Minor refactor

---
 .../configuration-form.component.ts                 |  7 +++++--
 .../configuration/configuration.component.html      |  7 ++++---
 .../configuration/configuration.component.ts        |  1 +
 .../components/dashboard/dashboard.component.html   |  3 ++-
 src/app/components/sidenav/sidenav.component.ts     | 13 +++++++++++--
 5 files changed, 23 insertions(+), 8 deletions(-)

diff --git a/src/app/components/configuration-form/configuration-form.component.ts b/src/app/components/configuration-form/configuration-form.component.ts
index 3151838..e062fc9 100644
--- a/src/app/components/configuration-form/configuration-form.component.ts
+++ b/src/app/components/configuration-form/configuration-form.component.ts
@@ -1,5 +1,6 @@
-import { Component } from '@angular/core';
+import { Component, input } from '@angular/core';
 import { ConfigurationTemplateComponent } from '../configuration-template/configuration-template.component';
+import { Configuration } from '../../models/configuration';
 
 @Component({
   selector: 'app-configuration-form',
@@ -7,4 +8,6 @@ import { ConfigurationTemplateComponent } from '../configuration-template/config
   templateUrl: './configuration-form.component.html',
   styleUrl: './configuration-form.component.scss',
 })
-export class ConfigurationFormComponent {}
+export class ConfigurationFormComponent {
+  configuration = input.required<Configuration>();
+}
diff --git a/src/app/components/configuration/configuration.component.html b/src/app/components/configuration/configuration.component.html
index 8948d28..6169cc8 100644
--- a/src/app/components/configuration/configuration.component.html
+++ b/src/app/components/configuration/configuration.component.html
@@ -12,10 +12,11 @@
 
   @if (configurationRes.isLoading()) {
     Loading...
+    <!-- @todo: ??? it's empty for some reason -->
+    <p-skeleton height="10rem" />
   } @else {
+    @let configuration = configurationRes.value()!;
     @if (isReadonly()) {
-      @let configuration = configurationRes.value()!;
-
       <app-configuration-template>
         <div mac-source>
           Source:
@@ -41,7 +42,7 @@
         </div>
       </app-configuration-template>
     } @else {
-      <app-configuration-form />
+      <app-configuration-form [configuration]="configuration" />
     }
   }
 </app-page-wrapper>
diff --git a/src/app/components/configuration/configuration.component.ts b/src/app/components/configuration/configuration.component.ts
index e4185ff..9c9815b 100644
--- a/src/app/components/configuration/configuration.component.ts
+++ b/src/app/components/configuration/configuration.component.ts
@@ -7,6 +7,7 @@ import { ConfigurationApi } from '../../api/configuration.api';
 import { firstValueFrom } from 'rxjs';
 import { PillComponent } from '../pill/pill.component';
 import { Skeleton } from 'primeng/skeleton';
+import { Configuration } from '../../models/configuration';
 
 @Component({
   selector: 'app-configuration',
diff --git a/src/app/components/dashboard/dashboard.component.html b/src/app/components/dashboard/dashboard.component.html
index cb0f201..d8bafe6 100644
--- a/src/app/components/dashboard/dashboard.component.html
+++ b/src/app/components/dashboard/dashboard.component.html
@@ -1,5 +1,6 @@
 <app-page-wrapper>
-  <div header>Monitoring and Analysis</div>
+  <div title>Monitoring and Analysis</div>
+  <div actions>actions</div>
 
   Dashboard content goes here
   <p-skeleton height="10rem" width="100%" />
diff --git a/src/app/components/sidenav/sidenav.component.ts b/src/app/components/sidenav/sidenav.component.ts
index 2fd9c08..a630fe2 100644
--- a/src/app/components/sidenav/sidenav.component.ts
+++ b/src/app/components/sidenav/sidenav.component.ts
@@ -1,5 +1,11 @@
-import { afterNextRender, Component, inject, signal } from '@angular/core';
-import { MatSidenavModule } from '@angular/material/sidenav';
+import {
+  afterNextRender,
+  Component,
+  inject,
+  signal,
+  viewChild,
+} from '@angular/core';
+import { MatDrawer, MatSidenavModule } from '@angular/material/sidenav';
 import { MatButtonModule } from '@angular/material/button';
 import { NavigationEnd, Router, RouterLink } from '@angular/router';
 import { Button } from 'primeng/button';
@@ -23,6 +29,8 @@ export class SidenavComponent {
   router = inject(Router);
   currentPage = signal<string>('');
 
+  drawer = viewChild<MatDrawer>('drawer');
+
   constructor() {
     afterNextRender(() => {
       this.router.events.subscribe(event => {
@@ -30,6 +38,7 @@ export class SidenavComponent {
           this.currentPage.set(event.url);
         }
       });
+      this.drawer()?.open();
     });
   }
 }
-- 
GitLab


From b1e7b645aadb577d46ac15daf5a6d7c6af1decc8 Mon Sep 17 00:00:00 2001
From: Ruslan Rabadanov <ruslanrabadanov2101@gmail.com>
Date: Wed, 19 Feb 2025 21:15:15 +0100
Subject: [PATCH 04/13] FE-3 Add skeletons and pill styles

---
 package-lock.json                             | 13 ++++++
 package.json                                  |  1 +
 .../configuration.component.html              | 43 +++++++++++++------
 .../configuration.component.scss              | 26 +++++++++++
 .../configuration/configuration.component.ts  |  7 +--
 src/app/components/pill/pill.component.scss   |  8 +++-
 src/app/models/configuration.ts               | 11 ++++-
 7 files changed, 89 insertions(+), 20 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index 347cf05..e00ab1f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
         "@angular/platform-browser-dynamic": "^19.0.0",
         "@angular/router": "^19.0.0",
         "@primeng/themes": "^19.0.6",
+        "ngx-skeleton-loader": "^10.0.0",
         "primeicons": "^7.0.0",
         "primeng": "^19.0.6",
         "rxjs": "~7.8.0",
@@ -9746,6 +9747,18 @@
       "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
       "dev": true
     },
+    "node_modules/ngx-skeleton-loader": {
+      "version": "10.0.0",
+      "resolved": "https://registry.npmjs.org/ngx-skeleton-loader/-/ngx-skeleton-loader-10.0.0.tgz",
+      "integrity": "sha512-TYrWLrdRtzoZoPzurNDUJdAbdyplqgyDztCefEi+clHl5MSumwG4NrGxZC1OVxz7RitomhnF7wTM8T/j+tdwXw==",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "@angular/common": ">=18.0.0",
+        "@angular/core": ">=18.0.0"
+      }
+    },
     "node_modules/node-addon-api": {
       "version": "6.1.0",
       "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
diff --git a/package.json b/package.json
index 19bc81a..aa94cd0 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
     "@angular/platform-browser-dynamic": "^19.0.0",
     "@angular/router": "^19.0.0",
     "@primeng/themes": "^19.0.6",
+    "ngx-skeleton-loader": "^10.0.0",
     "primeicons": "^7.0.0",
     "primeng": "^19.0.6",
     "rxjs": "~7.8.0",
diff --git a/src/app/components/configuration/configuration.component.html b/src/app/components/configuration/configuration.component.html
index 6169cc8..68cbe45 100644
--- a/src/app/components/configuration/configuration.component.html
+++ b/src/app/components/configuration/configuration.component.html
@@ -11,28 +11,43 @@
   </div>
 
   @if (configurationRes.isLoading()) {
-    Loading...
-    <!-- @todo: ??? it's empty for some reason -->
-    <p-skeleton height="10rem" />
+    <app-configuration-template>
+      <div mac-source>
+        <ngx-skeleton-loader count="5" />
+      </div>
+      <div mac-destination>
+        <ngx-skeleton-loader count="5" />
+      </div>
+      <div frames>
+        <ngx-skeleton-loader count="5" />
+      </div>
+      <div protocols>
+        <ngx-skeleton-loader count="5" />
+      </div>
+    </app-configuration-template>
   } @else {
     @let configuration = configurationRes.value()!;
     @if (isReadonly()) {
       <app-configuration-template>
-        <div mac-source>
-          Source:
-          @for (mac of configuration?.mac_source; track $index) {
-            <app-pill>{{ mac }}</app-pill>
-          }
+        <div mac-source class="configuration__mac">
+          <div class="configuration__mac-title">Source:</div>
+          <div class="configuration__mac-content">
+            @for (mac of configuration?.mac_source; track $index) {
+              <app-pill>{{ mac }}</app-pill>
+            }
+          </div>
         </div>
-        <div mac-destination>
-          Destination:
-          @for (mac of configuration?.mac_destination; track $index) {
-            <app-pill>{{ mac }}</app-pill>
-          }
+        <div mac-destination class="configuration__mac">
+          <div class="configuration__mac-title">Destination:</div>
+          <div class="configuration__mac-content">
+            @for (mac of configuration?.mac_destination; track $index) {
+              <app-pill>{{ mac }}</app-pill>
+            }
+          </div>
         </div>
         <div frames>
           @for (frame of configuration?.frame_range; track $index) {
-            <app-pill>{{ frame }}</app-pill>
+            <app-pill>{{ frame[0] }} — {{ frame[1] }}</app-pill>
           }
         </div>
         <div protocols>
diff --git a/src/app/components/configuration/configuration.component.scss b/src/app/components/configuration/configuration.component.scss
index 6361cd3..ea6eaa7 100644
--- a/src/app/components/configuration/configuration.component.scss
+++ b/src/app/components/configuration/configuration.component.scss
@@ -9,4 +9,30 @@
     display: flex;
     gap: map.get(vars.$spacing, 'md');
   }
+
+  ngx-skeleton-loader {
+    display: flex;
+    flex-direction: row;
+    gap: map.get(vars.$spacing, 'md');
+
+    ::ng-deep * {
+      height: 50px;
+    }
+  }
+
+  .configuration {
+    &__mac {
+      display: flex;
+      justify-content: space-between;
+
+      &-title {
+        color: map.get(vars.$grey, 100);
+        font-size: map.get(vars.$text, 'lg');
+      }
+
+      &-content {
+        width: calc(100% - 200px);
+      }
+    }
+  }
 }
diff --git a/src/app/components/configuration/configuration.component.ts b/src/app/components/configuration/configuration.component.ts
index 9c9815b..f6a69f8 100644
--- a/src/app/components/configuration/configuration.component.ts
+++ b/src/app/components/configuration/configuration.component.ts
@@ -6,8 +6,8 @@ import { ButtonComponent } from '../button/button.component';
 import { ConfigurationApi } from '../../api/configuration.api';
 import { firstValueFrom } from 'rxjs';
 import { PillComponent } from '../pill/pill.component';
-import { Skeleton } from 'primeng/skeleton';
-import { Configuration } from '../../models/configuration';
+import { SkeletonModule } from 'primeng/skeleton';
+import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
 
 @Component({
   selector: 'app-configuration',
@@ -17,7 +17,8 @@ import { Configuration } from '../../models/configuration';
     ConfigurationFormComponent,
     ButtonComponent,
     PillComponent,
-    Skeleton,
+    SkeletonModule,
+    NgxSkeletonLoaderModule,
   ],
   templateUrl: './configuration.component.html',
   styleUrl: './configuration.component.scss',
diff --git a/src/app/components/pill/pill.component.scss b/src/app/components/pill/pill.component.scss
index 8bc0840..63fb4f4 100644
--- a/src/app/components/pill/pill.component.scss
+++ b/src/app/components/pill/pill.component.scss
@@ -2,10 +2,14 @@
 @use 'sass:map';
 
 .pill {
-  display: flex;
+  display: inline-flex;
   flex-direction: row;
+  justify-content: center;
   gap: map.get(vars.$spacing, 'sm');
   border-radius: map.get(vars.$radius, 'md');
-  border: 1px solid map.get(vars.$grey, 200);
+  border: 1px solid map.get(vars.$grey, 50);
   background-color: map.get(vars.$grey, 0);
+  color: map.get(vars.$grey, 100);
+  padding: map.get(vars.$spacing, 'xs') map.get(vars.$spacing, 'lg');
+  margin: map.get(vars.$spacing, 'xs');
 }
diff --git a/src/app/models/configuration.ts b/src/app/models/configuration.ts
index 0a1fe56..e1b1473 100644
--- a/src/app/models/configuration.ts
+++ b/src/app/models/configuration.ts
@@ -19,7 +19,16 @@ export function configurationToDto(
 
 // @todo: delete
 export const configurationMock: Configuration = {
-  mac_source: ['01:23:45:67:89:ab'],
+  mac_source: [
+    '01:23:45:67:89:ab',
+    '01:23:45:67:89:ab',
+    '01:23:45:67:89:ab',
+    '01:23:45:67:89:ab',
+    '01:23:45:67:89:ab',
+    '01:23:45:67:89:ab',
+    '01:23:45:67:89:ab',
+    '01:23:45:67:89:ab',
+  ],
   mac_destination: ['fe:dc:ba:98:76:54'],
   frame_range: [
     [0, 63],
-- 
GitLab


From e77dfa761b80a080dd9663d1fb2510460a729cd2 Mon Sep 17 00:00:00 2001
From: Ruslan Rabadanov <ruslanrabadanov2101@gmail.com>
Date: Wed, 19 Feb 2025 21:18:10 +0100
Subject: [PATCH 05/13] FE-3 Remove unused specs

---
 src/app/app.component.spec.ts                 | 29 -------------------
 .../button/button.component.spec.ts           | 23 ---------------
 .../configuration-form.component.spec.ts      | 23 ---------------
 .../configuration-template.component.spec.ts  | 23 ---------------
 .../configuration.component.spec.ts           | 23 ---------------
 .../dashboard/dashboard.component.spec.ts     | 23 ---------------
 .../header/header.component.spec.ts           | 23 ---------------
 .../not-found/not-found.component.spec.ts     | 23 ---------------
 .../page-wrapper.component.spec.ts            | 23 ---------------
 .../components/pill/pill.component.spec.ts    | 23 ---------------
 .../sidenav/sidenav.component.spec.ts         | 23 ---------------
 11 files changed, 259 deletions(-)
 delete mode 100644 src/app/app.component.spec.ts
 delete mode 100644 src/app/components/button/button.component.spec.ts
 delete mode 100644 src/app/components/configuration-form/configuration-form.component.spec.ts
 delete mode 100644 src/app/components/configuration-template/configuration-template.component.spec.ts
 delete mode 100644 src/app/components/configuration/configuration.component.spec.ts
 delete mode 100644 src/app/components/dashboard/dashboard.component.spec.ts
 delete mode 100644 src/app/components/header/header.component.spec.ts
 delete mode 100644 src/app/components/not-found/not-found.component.spec.ts
 delete mode 100644 src/app/components/page-wrapper/page-wrapper.component.spec.ts
 delete mode 100644 src/app/components/pill/pill.component.spec.ts
 delete mode 100644 src/app/components/sidenav/sidenav.component.spec.ts

diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts
deleted file mode 100644
index 20a9c43..0000000
--- a/src/app/app.component.spec.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { TestBed } from '@angular/core/testing';
-import { AppComponent } from './app.component';
-
-describe('AppComponent', () => {
-  beforeEach(async () => {
-    await TestBed.configureTestingModule({
-      imports: [AppComponent],
-    }).compileComponents();
-  });
-
-  it('should create the app', () => {
-    const fixture = TestBed.createComponent(AppComponent);
-    const app = fixture.componentInstance;
-    expect(app).toBeTruthy();
-  });
-
-  it(`should have the 'miars-frontend' title`, () => {
-    const fixture = TestBed.createComponent(AppComponent);
-    const app = fixture.componentInstance;
-    expect(app.title).toEqual('miars-frontend');
-  });
-
-  it('should render title', () => {
-    const fixture = TestBed.createComponent(AppComponent);
-    fixture.detectChanges();
-    const compiled = fixture.nativeElement as HTMLElement;
-    expect(compiled.querySelector('h1')?.textContent).toContain('Hello, miars-frontend');
-  });
-});
diff --git a/src/app/components/button/button.component.spec.ts b/src/app/components/button/button.component.spec.ts
deleted file mode 100644
index 15e6373..0000000
--- a/src/app/components/button/button.component.spec.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-
-import { ButtonComponent } from './button.component';
-
-describe('ButtonComponent', () => {
-  let component: ButtonComponent;
-  let fixture: ComponentFixture<ButtonComponent>;
-
-  beforeEach(async () => {
-    await TestBed.configureTestingModule({
-      imports: [ButtonComponent]
-    })
-    .compileComponents();
-
-    fixture = TestBed.createComponent(ButtonComponent);
-    component = fixture.componentInstance;
-    fixture.detectChanges();
-  });
-
-  it('should create', () => {
-    expect(component).toBeTruthy();
-  });
-});
diff --git a/src/app/components/configuration-form/configuration-form.component.spec.ts b/src/app/components/configuration-form/configuration-form.component.spec.ts
deleted file mode 100644
index ea53535..0000000
--- a/src/app/components/configuration-form/configuration-form.component.spec.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-
-import { ConfigurationFormComponent } from './configuration-form.component';
-
-describe('ConfigurationFormComponent', () => {
-  let component: ConfigurationFormComponent;
-  let fixture: ComponentFixture<ConfigurationFormComponent>;
-
-  beforeEach(async () => {
-    await TestBed.configureTestingModule({
-      imports: [ConfigurationFormComponent]
-    })
-    .compileComponents();
-
-    fixture = TestBed.createComponent(ConfigurationFormComponent);
-    component = fixture.componentInstance;
-    fixture.detectChanges();
-  });
-
-  it('should create', () => {
-    expect(component).toBeTruthy();
-  });
-});
diff --git a/src/app/components/configuration-template/configuration-template.component.spec.ts b/src/app/components/configuration-template/configuration-template.component.spec.ts
deleted file mode 100644
index 71a11af..0000000
--- a/src/app/components/configuration-template/configuration-template.component.spec.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-
-import { ConfigurationTemplateComponent } from './configuration-template.component';
-
-describe('ConfigurationTemplateComponent', () => {
-  let component: ConfigurationTemplateComponent;
-  let fixture: ComponentFixture<ConfigurationTemplateComponent>;
-
-  beforeEach(async () => {
-    await TestBed.configureTestingModule({
-      imports: [ConfigurationTemplateComponent]
-    })
-    .compileComponents();
-
-    fixture = TestBed.createComponent(ConfigurationTemplateComponent);
-    component = fixture.componentInstance;
-    fixture.detectChanges();
-  });
-
-  it('should create', () => {
-    expect(component).toBeTruthy();
-  });
-});
diff --git a/src/app/components/configuration/configuration.component.spec.ts b/src/app/components/configuration/configuration.component.spec.ts
deleted file mode 100644
index a719df8..0000000
--- a/src/app/components/configuration/configuration.component.spec.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-
-import { ConfigurationComponent } from './configuration.component';
-
-describe('ConfigurationComponent', () => {
-  let component: ConfigurationComponent;
-  let fixture: ComponentFixture<ConfigurationComponent>;
-
-  beforeEach(async () => {
-    await TestBed.configureTestingModule({
-      imports: [ConfigurationComponent]
-    })
-    .compileComponents();
-
-    fixture = TestBed.createComponent(ConfigurationComponent);
-    component = fixture.componentInstance;
-    fixture.detectChanges();
-  });
-
-  it('should create', () => {
-    expect(component).toBeTruthy();
-  });
-});
diff --git a/src/app/components/dashboard/dashboard.component.spec.ts b/src/app/components/dashboard/dashboard.component.spec.ts
deleted file mode 100644
index 30e39a2..0000000
--- a/src/app/components/dashboard/dashboard.component.spec.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-
-import { DashboardComponent } from './dashboard.component';
-
-describe('DashboardComponent', () => {
-  let component: DashboardComponent;
-  let fixture: ComponentFixture<DashboardComponent>;
-
-  beforeEach(async () => {
-    await TestBed.configureTestingModule({
-      imports: [DashboardComponent]
-    })
-    .compileComponents();
-
-    fixture = TestBed.createComponent(DashboardComponent);
-    component = fixture.componentInstance;
-    fixture.detectChanges();
-  });
-
-  it('should create', () => {
-    expect(component).toBeTruthy();
-  });
-});
diff --git a/src/app/components/header/header.component.spec.ts b/src/app/components/header/header.component.spec.ts
deleted file mode 100644
index 204ed6e..0000000
--- a/src/app/components/header/header.component.spec.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-
-import { HeaderComponent } from './header.component';
-
-describe('HeaderComponent', () => {
-  let component: HeaderComponent;
-  let fixture: ComponentFixture<HeaderComponent>;
-
-  beforeEach(async () => {
-    await TestBed.configureTestingModule({
-      imports: [HeaderComponent]
-    })
-    .compileComponents();
-
-    fixture = TestBed.createComponent(HeaderComponent);
-    component = fixture.componentInstance;
-    fixture.detectChanges();
-  });
-
-  it('should create', () => {
-    expect(component).toBeTruthy();
-  });
-});
diff --git a/src/app/components/not-found/not-found.component.spec.ts b/src/app/components/not-found/not-found.component.spec.ts
deleted file mode 100644
index 5b65d9e..0000000
--- a/src/app/components/not-found/not-found.component.spec.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-
-import { NotFoundComponent } from './not-found.component';
-
-describe('NotFoundComponent', () => {
-  let component: NotFoundComponent;
-  let fixture: ComponentFixture<NotFoundComponent>;
-
-  beforeEach(async () => {
-    await TestBed.configureTestingModule({
-      imports: [NotFoundComponent]
-    })
-    .compileComponents();
-
-    fixture = TestBed.createComponent(NotFoundComponent);
-    component = fixture.componentInstance;
-    fixture.detectChanges();
-  });
-
-  it('should create', () => {
-    expect(component).toBeTruthy();
-  });
-});
diff --git a/src/app/components/page-wrapper/page-wrapper.component.spec.ts b/src/app/components/page-wrapper/page-wrapper.component.spec.ts
deleted file mode 100644
index 73525cc..0000000
--- a/src/app/components/page-wrapper/page-wrapper.component.spec.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-
-import { PageWrapperComponent } from './page-wrapper.component';
-
-describe('PageWrapperComponent', () => {
-  let component: PageWrapperComponent;
-  let fixture: ComponentFixture<PageWrapperComponent>;
-
-  beforeEach(async () => {
-    await TestBed.configureTestingModule({
-      imports: [PageWrapperComponent]
-    })
-    .compileComponents();
-
-    fixture = TestBed.createComponent(PageWrapperComponent);
-    component = fixture.componentInstance;
-    fixture.detectChanges();
-  });
-
-  it('should create', () => {
-    expect(component).toBeTruthy();
-  });
-});
diff --git a/src/app/components/pill/pill.component.spec.ts b/src/app/components/pill/pill.component.spec.ts
deleted file mode 100644
index 5eb1cd0..0000000
--- a/src/app/components/pill/pill.component.spec.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-
-import { PillComponent } from './pill.component';
-
-describe('PillComponent', () => {
-  let component: PillComponent;
-  let fixture: ComponentFixture<PillComponent>;
-
-  beforeEach(async () => {
-    await TestBed.configureTestingModule({
-      imports: [PillComponent]
-    })
-    .compileComponents();
-
-    fixture = TestBed.createComponent(PillComponent);
-    component = fixture.componentInstance;
-    fixture.detectChanges();
-  });
-
-  it('should create', () => {
-    expect(component).toBeTruthy();
-  });
-});
diff --git a/src/app/components/sidenav/sidenav.component.spec.ts b/src/app/components/sidenav/sidenav.component.spec.ts
deleted file mode 100644
index 25f799c..0000000
--- a/src/app/components/sidenav/sidenav.component.spec.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-
-import { SidenavComponent } from './sidenav.component';
-
-describe('SidenavComponent', () => {
-  let component: SidenavComponent;
-  let fixture: ComponentFixture<SidenavComponent>;
-
-  beforeEach(async () => {
-    await TestBed.configureTestingModule({
-      imports: [SidenavComponent]
-    })
-    .compileComponents();
-
-    fixture = TestBed.createComponent(SidenavComponent);
-    component = fixture.componentInstance;
-    fixture.detectChanges();
-  });
-
-  it('should create', () => {
-    expect(component).toBeTruthy();
-  });
-});
-- 
GitLab


From 374e11000c821a6cc1d84df18391735977bb93e6 Mon Sep 17 00:00:00 2001
From: Ruslan Rabadanov <ruslanrabadanov2101@gmail.com>
Date: Wed, 19 Feb 2025 22:43:15 +0100
Subject: [PATCH 06/13] FE-3 Improve api mocking; Add configuration profile
 selection

---
 src/app/api/configuration.api.ts              | 31 ++++++++-----
 src/app/app.config.ts                         |  5 ++-
 .../configuration-template.component.html     |  3 ++
 .../configuration-template.component.scss     | 34 ++++++++------
 .../configuration.component.html              | 29 +++++++++---
 .../configuration.component.scss              |  6 +++
 .../configuration/configuration.component.ts  | 44 +++++++++++++++++--
 src/app/interceptor/interceptor.ts            | 18 ++++++++
 src/app/interceptor/mock-interceptor.ts       | 17 +++++++
 .../interceptor/mocks/configuration-1.json    | 24 ++++++++++
 .../interceptor/mocks/configuration-2.json    | 11 +++++
 src/app/interceptor/mocks/configurations.json | 35 +++++++++++++++
 src/app/models/api-response.ts                |  3 ++
 src/app/models/configuration.ts               | 24 ++--------
 14 files changed, 225 insertions(+), 59 deletions(-)
 create mode 100644 src/app/interceptor/interceptor.ts
 create mode 100644 src/app/interceptor/mock-interceptor.ts
 create mode 100644 src/app/interceptor/mocks/configuration-1.json
 create mode 100644 src/app/interceptor/mocks/configuration-2.json
 create mode 100644 src/app/interceptor/mocks/configurations.json
 create mode 100644 src/app/models/api-response.ts

diff --git a/src/app/api/configuration.api.ts b/src/app/api/configuration.api.ts
index f26de7a..7ed09c6 100644
--- a/src/app/api/configuration.api.ts
+++ b/src/app/api/configuration.api.ts
@@ -1,12 +1,13 @@
 import { inject, Injectable } from '@angular/core';
 import { HttpClient } from '@angular/common/http';
-import { delay, map, Observable, of } from 'rxjs';
+import { map, Observable, tap } from 'rxjs';
 import {
   Configuration,
-  configurationMock,
+  ConfigurationDto,
   configurationToDto,
   dtoToConfiguration,
 } from '../models/configuration';
+import { ApiResponse } from '../models/api-response';
 
 export const CONFIGURATION_API_URL = '/api/v1/configuration';
 
@@ -16,19 +17,25 @@ export const CONFIGURATION_API_URL = '/api/v1/configuration';
 export class ConfigurationApi {
   private readonly httpClient = inject(HttpClient);
 
-  fetch(): Observable<Configuration> {
-    // return this.httpClient
-    //   .get<Configuration>(CONFIGURATION_API_URL)
-    //   .pipe(map(dto => dtoToConfiguration(dto)));
-    return of(configurationMock).pipe(delay(500));
+  fetch(): Observable<Configuration[]> {
+    return this.httpClient
+      .get<ApiResponse<ConfigurationDto[]>>(CONFIGURATION_API_URL)
+      .pipe(map(dto => dto.data.map(dtoToConfiguration)));
+  }
+
+  find(configName: string): Observable<Configuration> {
+    return this.httpClient
+      .get<
+        ApiResponse<ConfigurationDto>
+      >(`${CONFIGURATION_API_URL}/${configName}`)
+      .pipe(map(dto => dtoToConfiguration(dto.data)));
   }
 
   save(configuration: Configuration): Observable<Configuration> {
     return this.httpClient
-      .put<Configuration>(
-        CONFIGURATION_API_URL,
-        configurationToDto(configuration)
-      )
-      .pipe(map(dto => dtoToConfiguration(dto)));
+      .put<
+        ApiResponse<ConfigurationDto>
+      >(CONFIGURATION_API_URL, configurationToDto(configuration))
+      .pipe(map(dto => dtoToConfiguration(dto.data)));
   }
 }
diff --git a/src/app/app.config.ts b/src/app/app.config.ts
index 2835fc6..40e49cb 100644
--- a/src/app/app.config.ts
+++ b/src/app/app.config.ts
@@ -3,15 +3,16 @@ import { provideRouter } from '@angular/router';
 import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
 import { providePrimeNG } from 'primeng/config';
 
+import { MockInterceptor } from './interceptor/mock-interceptor';
 import { routes } from './app.routes';
-import { provideHttpClient } from '@angular/common/http';
+import { provideHttpClient, withInterceptors } from '@angular/common/http';
 
 export const appConfig: ApplicationConfig = {
   providers: [
     provideZoneChangeDetection({ eventCoalescing: true }),
     provideRouter(routes),
     provideAnimationsAsync(),
-    provideHttpClient(),
+    provideHttpClient(withInterceptors([MockInterceptor])),
     providePrimeNG(),
     provideAnimationsAsync(),
   ],
diff --git a/src/app/components/configuration-template/configuration-template.component.html b/src/app/components/configuration-template/configuration-template.component.html
index d706265..98213f6 100644
--- a/src/app/components/configuration-template/configuration-template.component.html
+++ b/src/app/components/configuration-template/configuration-template.component.html
@@ -1,3 +1,6 @@
+<div class="configuration-header">
+  <ng-content />
+</div>
 <div class="configuration-section">
   <div class="configuration-section__title">MAC address</div>
   <div class="configuration-section__card">
diff --git a/src/app/components/configuration-template/configuration-template.component.scss b/src/app/components/configuration-template/configuration-template.component.scss
index fcdfe78..b8091e6 100644
--- a/src/app/components/configuration-template/configuration-template.component.scss
+++ b/src/app/components/configuration-template/configuration-template.component.scss
@@ -6,24 +6,30 @@
   flex-direction: column;
   gap: map.get(vars.$spacing, 'lg');
 
-  .configuration-section {
-    display: flex;
-    flex-direction: column;
-    gap: map.get(vars.$spacing, 'xs');
-
-    &__title {
-      font-weight: bold;
-      color: map.get(vars.$grey, 100);
-      font-size: map.get(vars.$text, 'lg');
+  .configuration {
+    &-header {
+      margin-bottom: map.get(vars.$spacing, 'xl');
     }
 
-    &__card {
+    &-section {
       display: flex;
       flex-direction: column;
-      gap: map.get(vars.$spacing, 'md');
-      padding: map.get(vars.$spacing, 'lg');
-      border-radius: map.get(vars.$radius, 'sm');
-      background-color: map.get(vars.$grey, 10);
+      gap: map.get(vars.$spacing, 'xs');
+
+      &__title {
+        font-weight: bold;
+        color: map.get(vars.$grey, 100);
+        font-size: map.get(vars.$text, 'lg');
+      }
+
+      &__card {
+        display: flex;
+        flex-direction: column;
+        gap: map.get(vars.$spacing, 'md');
+        padding: map.get(vars.$spacing, 'lg');
+        border-radius: map.get(vars.$radius, 'sm');
+        background-color: map.get(vars.$grey, 10);
+      }
     }
   }
 }
diff --git a/src/app/components/configuration/configuration.component.html b/src/app/components/configuration/configuration.component.html
index 68cbe45..c448fa4 100644
--- a/src/app/components/configuration/configuration.component.html
+++ b/src/app/components/configuration/configuration.component.html
@@ -10,7 +10,7 @@
     }
   </div>
 
-  @if (configurationRes.isLoading()) {
+  @if (configuration.isLoading()) {
     <app-configuration-template>
       <div mac-source>
         <ngx-skeleton-loader count="5" />
@@ -26,38 +26,53 @@
       </div>
     </app-configuration-template>
   } @else {
-    @let configuration = configurationRes.value()!;
     @if (isReadonly()) {
       <app-configuration-template>
+        <p-select
+          [disabled]="configurationOptions.isLoading()"
+          [options]="configurationOptions.value()!"
+          [optionValue]="chosenConfiguration()?.name"
+          (onChange)="onChangeConfiguration($event)"
+          optionLabel="name"
+          placeholder="Configuration" />
+
         <div mac-source class="configuration__mac">
           <div class="configuration__mac-title">Source:</div>
           <div class="configuration__mac-content">
-            @for (mac of configuration?.mac_source; track $index) {
+            @for (mac of configuration.value()?.mac_source; track $index) {
               <app-pill>{{ mac }}</app-pill>
+            } @empty {
+              <app-pill>All addresses</app-pill>
             }
           </div>
         </div>
         <div mac-destination class="configuration__mac">
           <div class="configuration__mac-title">Destination:</div>
           <div class="configuration__mac-content">
-            @for (mac of configuration?.mac_destination; track $index) {
+            @for (mac of configuration.value()?.mac_destination; track $index) {
               <app-pill>{{ mac }}</app-pill>
+            } @empty {
+              <app-pill>All addresses</app-pill>
             }
           </div>
         </div>
         <div frames>
-          @for (frame of configuration?.frame_range; track $index) {
+          @for (frame of configuration.value()?.frame_range; track $index) {
             <app-pill>{{ frame[0] }} — {{ frame[1] }}</app-pill>
+          } @empty {
+            <app-pill>All sizes</app-pill>
           }
         </div>
         <div protocols>
-          @for (protocol of configuration?.protocol; track $index) {
+          @for (protocol of configuration.value()?.protocol; track $index) {
             <app-pill>{{ protocol }}</app-pill>
+          } @empty {
+            <app-pill>None</app-pill>
           }
         </div>
       </app-configuration-template>
     } @else {
-      <app-configuration-form [configuration]="configuration" />
+      <app-configuration-form [configuration]="configuration.value()!" />
     }
   }
 </app-page-wrapper>
diff --git a/src/app/components/configuration/configuration.component.scss b/src/app/components/configuration/configuration.component.scss
index ea6eaa7..40b8efd 100644
--- a/src/app/components/configuration/configuration.component.scss
+++ b/src/app/components/configuration/configuration.component.scss
@@ -10,6 +10,12 @@
     gap: map.get(vars.$spacing, 'md');
   }
 
+  p-select {
+    border-radius: map.get(vars.$radius, 'xs');
+    background-color: map.get(vars.$grey, 30);
+    padding: map.get(vars.$spacing, 'md') map.get(vars.$spacing, 'xl');
+  }
+
   ngx-skeleton-loader {
     display: flex;
     flex-direction: row;
diff --git a/src/app/components/configuration/configuration.component.ts b/src/app/components/configuration/configuration.component.ts
index f6a69f8..cb9c3d4 100644
--- a/src/app/components/configuration/configuration.component.ts
+++ b/src/app/components/configuration/configuration.component.ts
@@ -4,10 +4,13 @@ import { ConfigurationTemplateComponent } from '../configuration-template/config
 import { ConfigurationFormComponent } from '../configuration-form/configuration-form.component';
 import { ButtonComponent } from '../button/button.component';
 import { ConfigurationApi } from '../../api/configuration.api';
-import { firstValueFrom } from 'rxjs';
+import { firstValueFrom, map, tap } from 'rxjs';
 import { PillComponent } from '../pill/pill.component';
 import { SkeletonModule } from 'primeng/skeleton';
 import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
+import { Select, SelectChangeEvent } from 'primeng/select';
+import { FormsModule } from '@angular/forms';
+import { Configuration } from '../../models/configuration';
 
 @Component({
   selector: 'app-configuration',
@@ -19,6 +22,8 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
     PillComponent,
     SkeletonModule,
     NgxSkeletonLoaderModule,
+    Select,
+    FormsModule,
   ],
   templateUrl: './configuration.component.html',
   styleUrl: './configuration.component.scss',
@@ -27,9 +32,42 @@ export class ConfigurationComponent {
   configurationApi = inject(ConfigurationApi);
 
   isReadonly = signal<boolean>(true);
-  configurationRes = resource({
+
+  configurationOptions = resource({
     loader: async () => {
-      return await firstValueFrom(this.configurationApi.fetch());
+      return await firstValueFrom(
+        this.configurationApi.fetch().pipe(
+          tap(configurations => {
+            const appliedConfiguration = configurations.find(c => c.is_applied);
+            if (appliedConfiguration) {
+              this.chosenConfiguration.set(appliedConfiguration);
+            }
+          }),
+          map(configurations =>
+            configurations.map(configuration => {
+              return {
+                id: configuration.id,
+                name:
+                  configuration.name + (configuration.is_applied ? '*' : ''),
+              };
+            })
+          )
+        )
+      );
+    },
+  });
+
+  chosenConfiguration = signal<Partial<Configuration> | undefined>(undefined);
+
+  configuration = resource({
+    request: () => this.chosenConfiguration(),
+    loader: async ({ request }) => {
+      return await firstValueFrom(this.configurationApi.find(request?.id!));
     },
   });
+
+  onChangeConfiguration(event: SelectChangeEvent) {
+    // @todo: fix
+    this.chosenConfiguration.set(event.value);
+  }
 }
diff --git a/src/app/interceptor/interceptor.ts b/src/app/interceptor/interceptor.ts
new file mode 100644
index 0000000..2dfdedf
--- /dev/null
+++ b/src/app/interceptor/interceptor.ts
@@ -0,0 +1,18 @@
+import * as configurationList from './mocks/configurations.json';
+import * as configuration1 from './mocks/configuration-1.json';
+import * as configuration2 from './mocks/configuration-2.json';
+
+export const urls = [
+  {
+    url: '/api/v1/configuration/1abc',
+    json: configuration1,
+  },
+  {
+    url: '/api/v1/configuration/2def',
+    json: configuration2,
+  },
+  {
+    url: '/api/v1/configuration',
+    json: configurationList,
+  },
+];
diff --git a/src/app/interceptor/mock-interceptor.ts b/src/app/interceptor/mock-interceptor.ts
new file mode 100644
index 0000000..d1ae561
--- /dev/null
+++ b/src/app/interceptor/mock-interceptor.ts
@@ -0,0 +1,17 @@
+import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
+import { delay, of } from 'rxjs';
+import { urls } from './interceptor';
+
+export const MockInterceptor: HttpInterceptorFn = (req, next) => {
+  const { url, method } = req;
+
+  for (const element of urls) {
+    if (url.includes(element.url)) {
+      console.log('Loaded from json for url: ' + url, element.json);
+      return of(
+        new HttpResponse({ status: 200, body: (element.json as any).default })
+      ).pipe(delay(300));
+    }
+  }
+  return next(req);
+};
diff --git a/src/app/interceptor/mocks/configuration-1.json b/src/app/interceptor/mocks/configuration-1.json
new file mode 100644
index 0000000..d7e04dc
--- /dev/null
+++ b/src/app/interceptor/mocks/configuration-1.json
@@ -0,0 +1,24 @@
+{
+  "data": {
+    "id": "1abc",
+    "name": "Custom",
+    "is_applied": false,
+    "mac_source": [
+      "01:23:45:67:89:ab",
+      "01:23:45:67:89:ab",
+      "01:23:45:67:89:ab",
+      "01:23:45:67:89:ab",
+      "01:23:45:67:89:ab",
+      "01:23:45:67:89:ab",
+      "01:23:45:67:89:ab",
+      "01:23:45:67:89:ab"
+    ],
+    "mac_destination": ["fe:dc:ba:98:76:54"],
+    "frame_range": [
+      [0, 63],
+      [128, 255],
+      [1024, 1518]
+    ],
+    "protocol": ["IPv4", "IP6", "UDP"]
+  }
+}
diff --git a/src/app/interceptor/mocks/configuration-2.json b/src/app/interceptor/mocks/configuration-2.json
new file mode 100644
index 0000000..572d9a8
--- /dev/null
+++ b/src/app/interceptor/mocks/configuration-2.json
@@ -0,0 +1,11 @@
+{
+  "data": {
+    "id": "2def",
+    "name": "Everything",
+    "is_applied": true,
+    "mac_source": [],
+    "mac_destination": [],
+    "frame_range": [],
+    "protocol": []
+  }
+}
diff --git a/src/app/interceptor/mocks/configurations.json b/src/app/interceptor/mocks/configurations.json
new file mode 100644
index 0000000..de16092
--- /dev/null
+++ b/src/app/interceptor/mocks/configurations.json
@@ -0,0 +1,35 @@
+{
+  "data": [
+    {
+      "id": "1abc",
+      "name": "Custom",
+      "is_applied": false,
+      "mac_source": [
+        "01:23:45:67:89:ab",
+        "01:23:45:67:89:ab",
+        "01:23:45:67:89:ab",
+        "01:23:45:67:89:ab",
+        "01:23:45:67:89:ab",
+        "01:23:45:67:89:ab",
+        "01:23:45:67:89:ab",
+        "01:23:45:67:89:ab"
+      ],
+      "mac_destination": ["fe:dc:ba:98:76:54"],
+      "frame_range": [
+        [0, 63],
+        [128, 255],
+        [1024, 1518]
+      ],
+      "protocol": ["IPv4", "IP6", "UDP"]
+    },
+    {
+      "id": "2def",
+      "name": "Everything",
+      "is_applied": true,
+      "mac_source": [],
+      "mac_destination": [],
+      "frame_range": [],
+      "protocol": []
+    }
+  ]
+}
diff --git a/src/app/models/api-response.ts b/src/app/models/api-response.ts
new file mode 100644
index 0000000..3db5cbb
--- /dev/null
+++ b/src/app/models/api-response.ts
@@ -0,0 +1,3 @@
+export interface ApiResponse<T> {
+  data: T;
+}
diff --git a/src/app/models/configuration.ts b/src/app/models/configuration.ts
index e1b1473..e7a7d93 100644
--- a/src/app/models/configuration.ts
+++ b/src/app/models/configuration.ts
@@ -1,6 +1,9 @@
 export type Configuration = ConfigurationDto;
 
 export interface ConfigurationDto {
+  id: string;
+  name: string;
+  is_applied: boolean;
   mac_source: string[];
   mac_destination: string[];
   frame_range: [number, number][];
@@ -16,24 +19,3 @@ export function configurationToDto(
 ): ConfigurationDto {
   return configuration;
 }
-
-// @todo: delete
-export const configurationMock: Configuration = {
-  mac_source: [
-    '01:23:45:67:89:ab',
-    '01:23:45:67:89:ab',
-    '01:23:45:67:89:ab',
-    '01:23:45:67:89:ab',
-    '01:23:45:67:89:ab',
-    '01:23:45:67:89:ab',
-    '01:23:45:67:89:ab',
-    '01:23:45:67:89:ab',
-  ],
-  mac_destination: ['fe:dc:ba:98:76:54'],
-  frame_range: [
-    [0, 63],
-    [128, 255],
-    [1024, 1518],
-  ],
-  protocol: ['IPv4', 'IP6', 'UDP'],
-};
-- 
GitLab


From c1118c978df1cf413ea93e28a9101369ebad5b02 Mon Sep 17 00:00:00 2001
From: Ruslan Rabadanov <ruslanrabadanov2101@gmail.com>
Date: Thu, 20 Feb 2025 19:47:26 +0100
Subject: [PATCH 07/13] FE-3 Fix configuration selection

---
 .../configuration-template.component.scss     |  4 --
 .../configuration.component.html              | 41 ++++++++++++-------
 .../configuration.component.scss              |  6 +++
 .../configuration/configuration.component.ts  | 22 ++++++----
 4 files changed, 46 insertions(+), 27 deletions(-)

diff --git a/src/app/components/configuration-template/configuration-template.component.scss b/src/app/components/configuration-template/configuration-template.component.scss
index b8091e6..8566472 100644
--- a/src/app/components/configuration-template/configuration-template.component.scss
+++ b/src/app/components/configuration-template/configuration-template.component.scss
@@ -7,10 +7,6 @@
   gap: map.get(vars.$spacing, 'lg');
 
   .configuration {
-    &-header {
-      margin-bottom: map.get(vars.$spacing, 'xl');
-    }
-
     &-section {
       display: flex;
       flex-direction: column;
diff --git a/src/app/components/configuration/configuration.component.html b/src/app/components/configuration/configuration.component.html
index c448fa4..7d2e677 100644
--- a/src/app/components/configuration/configuration.component.html
+++ b/src/app/components/configuration/configuration.component.html
@@ -10,32 +10,45 @@
     }
   </div>
 
+  <ng-template #configurationSelect>
+    <mat-form-field>
+      <mat-label>Configuration</mat-label>
+      <mat-select
+        [value]="chosenConfiguration()"
+        (valueChange)="onChangeConfiguration($event)">
+        @for (config of configurationOptions.value()!; track $index) {
+          <mat-option [value]="config.id">{{ config.name }}</mat-option>
+        }
+      </mat-select>
+    </mat-form-field>
+  </ng-template>
+
   @if (configuration.isLoading()) {
     <app-configuration-template>
-      <div mac-source>
-        <ngx-skeleton-loader count="5" />
+      <ng-container *ngTemplateOutlet="configurationSelect" />
+      <div mac-source class="configuration__mac">
+        <div class="configuration__mac-title">Source:</div>
+        <div class="configuration__mac-content">
+          <ngx-skeleton-loader count="7" />
+        </div>
       </div>
-      <div mac-destination>
-        <ngx-skeleton-loader count="5" />
+      <div mac-destination class="configuration__mac">
+        <div class="configuration__mac-title">Destination:</div>
+        <div class="configuration__mac-content">
+          <ngx-skeleton-loader count="7" />
+        </div>
       </div>
       <div frames>
-        <ngx-skeleton-loader count="5" />
+        <ngx-skeleton-loader count="10" />
       </div>
       <div protocols>
-        <ngx-skeleton-loader count="5" />
+        <ngx-skeleton-loader count="10" />
       </div>
     </app-configuration-template>
   } @else {
     @if (isReadonly()) {
       <app-configuration-template>
-        <p-select
-          [disabled]="configurationOptions.isLoading()"
-          [options]="configurationOptions.value()!"
-          [optionValue]="chosenConfiguration()?.name"
-          (onChange)="onChangeConfiguration($event)"
-          optionLabel="name"
-          placeholder="Configuration" />
-
+        <ng-container *ngTemplateOutlet="configurationSelect" />
         <div mac-source class="configuration__mac">
           <div class="configuration__mac-title">Source:</div>
           <div class="configuration__mac-content">
diff --git a/src/app/components/configuration/configuration.component.scss b/src/app/components/configuration/configuration.component.scss
index 40b8efd..6854a46 100644
--- a/src/app/components/configuration/configuration.component.scss
+++ b/src/app/components/configuration/configuration.component.scss
@@ -41,4 +41,10 @@
       }
     }
   }
+
+  // Configuration select
+  ::ng-deep .mdc-text-field--filled:not(.mdc-text-field--disabled) {
+    background-color: map.get(vars.$grey, 10);
+    border-radius: map.get(vars.$radius, 'xs');
+  }
 }
diff --git a/src/app/components/configuration/configuration.component.ts b/src/app/components/configuration/configuration.component.ts
index cb9c3d4..498d1e6 100644
--- a/src/app/components/configuration/configuration.component.ts
+++ b/src/app/components/configuration/configuration.component.ts
@@ -8,9 +8,10 @@ import { firstValueFrom, map, tap } from 'rxjs';
 import { PillComponent } from '../pill/pill.component';
 import { SkeletonModule } from 'primeng/skeleton';
 import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
-import { Select, SelectChangeEvent } from 'primeng/select';
 import { FormsModule } from '@angular/forms';
-import { Configuration } from '../../models/configuration';
+import { MatFormField, MatLabel } from '@angular/material/form-field';
+import { MatOption, MatSelect } from '@angular/material/select';
+import { NgTemplateOutlet } from '@angular/common';
 
 @Component({
   selector: 'app-configuration',
@@ -22,8 +23,12 @@ import { Configuration } from '../../models/configuration';
     PillComponent,
     SkeletonModule,
     NgxSkeletonLoaderModule,
-    Select,
     FormsModule,
+    MatFormField,
+    MatSelect,
+    MatOption,
+    MatLabel,
+    NgTemplateOutlet,
   ],
   templateUrl: './configuration.component.html',
   styleUrl: './configuration.component.scss',
@@ -40,7 +45,7 @@ export class ConfigurationComponent {
           tap(configurations => {
             const appliedConfiguration = configurations.find(c => c.is_applied);
             if (appliedConfiguration) {
-              this.chosenConfiguration.set(appliedConfiguration);
+              this.chosenConfiguration.set(appliedConfiguration.id);
             }
           }),
           map(configurations =>
@@ -57,17 +62,16 @@ export class ConfigurationComponent {
     },
   });
 
-  chosenConfiguration = signal<Partial<Configuration> | undefined>(undefined);
+  chosenConfiguration = signal<string | undefined>(undefined);
 
   configuration = resource({
     request: () => this.chosenConfiguration(),
     loader: async ({ request }) => {
-      return await firstValueFrom(this.configurationApi.find(request?.id!));
+      return await firstValueFrom(this.configurationApi.find(request));
     },
   });
 
-  onChangeConfiguration(event: SelectChangeEvent) {
-    // @todo: fix
-    this.chosenConfiguration.set(event.value);
+  onChangeConfiguration(configId: string) {
+    this.chosenConfiguration.set(configId);
   }
 }
-- 
GitLab


From d633aed4bf0c1e1960461d892f5d75653a9e78ac Mon Sep 17 00:00:00 2001
From: Ruslan Rabadanov <ruslanrabadanov2101@gmail.com>
Date: Thu, 20 Feb 2025 21:07:11 +0100
Subject: [PATCH 08/13] FE-3 Implement Configuration form

---
 .../configuration-form.builder.ts             | 77 +++++++++++++++
 .../configuration-form.component.html         | 97 +++++++++++++++++-
 .../configuration-form.component.scss         | 20 ++++
 .../configuration-form.component.ts           | 98 ++++++++++++++++++-
 .../configuration.component.html              |  4 +-
 src/app/components/pill/pill.component.html   |  2 +-
 src/app/components/pill/pill.component.scss   |  2 +
 src/app/components/pill/pill.component.ts     |  5 +-
 .../interceptor/mocks/configuration-1.json    | 20 ++--
 .../interceptor/mocks/configuration-2.json    |  4 +-
 src/app/interceptor/mocks/configurations.json | 24 ++---
 src/app/models/configuration.ts               |  4 +-
 12 files changed, 318 insertions(+), 39 deletions(-)

diff --git a/src/app/components/configuration-form/configuration-form.builder.ts b/src/app/components/configuration-form/configuration-form.builder.ts
index e69de29..56a4fcf 100644
--- a/src/app/components/configuration-form/configuration-form.builder.ts
+++ b/src/app/components/configuration-form/configuration-form.builder.ts
@@ -0,0 +1,77 @@
+import {
+  FormBuilder,
+  FormControl,
+  FormGroup,
+  Validators,
+} from '@angular/forms';
+import { inject, Injectable } from '@angular/core';
+import { Configuration } from '../../models/configuration';
+
+export const SizeRanges: [number, number][] = [
+  [0, 63],
+  [64, 127],
+  [128, 255],
+  [256, 511],
+  [512, 1023],
+  [1024, 1518],
+  [1519, Infinity],
+];
+
+export const Protocols: string[] = [
+  'ARP',
+  'IPv4',
+  'IPv6',
+  'ICMP',
+  'TCP',
+  'UDP',
+];
+
+export type ConfigurationForm = FormGroup<{
+  name: FormControl<string>;
+  source_mac: FormControl<string[] | undefined>;
+  dest_mac: FormControl<string[] | undefined>;
+  frame_ranges: FormControl<[number, number][] | undefined>;
+  protocols: FormControl<string[] | undefined>;
+}>;
+
+@Injectable({
+  providedIn: 'root',
+})
+export class ConfigurationFormBuilder {
+  formBuilder = inject(FormBuilder);
+
+  create(configuration?: Configuration): ConfigurationForm {
+    const fb = this.formBuilder.nonNullable;
+
+    return fb.group({
+      name: fb.control<string>(configuration?.name || '', {
+        validators: [Validators.required],
+      }),
+      source_mac: fb.control<string[] | undefined>(
+        configuration?.mac_source || undefined
+      ),
+      dest_mac: fb.control<string[] | undefined>(
+        configuration?.mac_destination || undefined
+      ),
+      frame_ranges: fb.control<[number, number][] | undefined>(
+        configuration?.frame_ranges || undefined
+      ),
+      protocols: fb.control<string[] | undefined>(
+        configuration?.protocols || undefined
+      ),
+    });
+  }
+
+  toValue(form: ConfigurationForm): Configuration {
+    const data = form.value;
+    return <Configuration>{
+      id: '',
+      name: data.name,
+      is_applied: false,
+      mac_source: data.source_mac || [],
+      mac_destination: data.dest_mac || [],
+      frame_ranges: data.frame_ranges || [],
+      protocols: data.protocols || [],
+    };
+  }
+}
diff --git a/src/app/components/configuration-form/configuration-form.component.html b/src/app/components/configuration-form/configuration-form.component.html
index cf46ab7..e003e8b 100644
--- a/src/app/components/configuration-form/configuration-form.component.html
+++ b/src/app/components/configuration-form/configuration-form.component.html
@@ -1,6 +1,95 @@
 <app-configuration-template>
-  <div mac-source>editable Mac source</div>
-  <div mac-destination>editable Mac destination</div>
-  <div frames>editable Frames</div>
-  <div protocols>editable Protocols</div>
+  <mat-form-field>
+    <mat-label>Configuration name</mat-label>
+    <input matInput [formControl]="form.controls.name" />
+  </mat-form-field>
+
+  <div mac-source class="configuration__mac">
+    <div class="configuration__mac-title">
+      <mat-form-field>
+        <mat-label>Source</mat-label>
+        <input
+          matInput
+          #sourceInput
+          (keydown.enter)="addSourceMac(sourceInput.value)" />
+      </mat-form-field>
+    </div>
+    <div class="configuration__mac-content">
+      @for (mac of form.controls.source_mac.value; track $index) {
+        <app-pill [removable]="true" (click)="removeSourceMac($index)">{{
+          mac
+        }}</app-pill>
+      } @empty {
+        <app-pill>All addresses</app-pill>
+      }
+    </div>
+  </div>
+  <div mac-destination class="configuration__mac">
+    <div class="configuration__mac-title">
+      <mat-form-field>
+        <mat-label>Destination</mat-label>
+        <input
+          matInput
+          #destInput
+          (keydown.enter)="addDestMac(destInput.value)" />
+      </mat-form-field>
+    </div>
+    <div class="configuration__mac-content">
+      @for (mac of form.controls.dest_mac.value; track $index) {
+        <app-pill [removable]="true" (click)="removeDestMac($index)">{{
+          mac
+        }}</app-pill>
+      } @empty {
+        <app-pill>All addresses</app-pill>
+      }
+    </div>
+  </div>
+  <div frames>
+    @if (sizeRangeOptions().length) {
+      <mat-form-field>
+        <mat-label>Add size range</mat-label>
+        <mat-select
+          (selectionChange)="
+            addFrameRange($event.value); selectRange.value = undefined
+          "
+          #selectRange>
+          @for (range of sizeRangeOptions(); track $index) {
+            <mat-option [value]="range"
+              >{{ range[0] }} — {{ range[1] }}</mat-option
+            >
+          }
+        </mat-select>
+      </mat-form-field>
+    }
+    @for (range of form.controls.frame_ranges.value; track $index) {
+      <app-pill [removable]="true" (click)="removeFrameRange($index)"
+        >{{ range[0] }} — {{ range[1] }}</app-pill
+      >
+    } @empty {
+      <app-pill>All sizes</app-pill>
+    }
+  </div>
+  <div protocols>
+    @if (protocolOptions().length) {
+      <mat-form-field>
+        <mat-label>Add protocol</mat-label>
+        <mat-select
+          (selectionChange)="
+            addProtocol($event.value); selectProtocol.value = undefined
+          "
+          #selectProtocol>
+          @for (protocol of protocolOptions(); track $index) {
+            <mat-option [value]="protocol">{{ protocol }}</mat-option>
+          }
+        </mat-select>
+      </mat-form-field>
+    }
+    @for (protocol of form.controls.protocols.value; track $index) {
+      <app-pill [removable]="true" (click)="removeProtocol($index)">{{
+        protocol
+      }}</app-pill>
+    } @empty {
+      <app-pill>None</app-pill>
+    }
+  </div>
 </app-configuration-template>
diff --git a/src/app/components/configuration-form/configuration-form.component.scss b/src/app/components/configuration-form/configuration-form.component.scss
index e69de29..e6d4508 100644
--- a/src/app/components/configuration-form/configuration-form.component.scss
+++ b/src/app/components/configuration-form/configuration-form.component.scss
@@ -0,0 +1,20 @@
+@use '../../../vars';
+@use 'sass:map';
+
+:host {
+  .configuration {
+    &__mac {
+      display: flex;
+      justify-content: space-between;
+
+      &-title {
+        color: map.get(vars.$grey, 100);
+        font-size: map.get(vars.$text, 'lg');
+      }
+
+      &-content {
+        width: calc(100% - 200px);
+      }
+    }
+  }
+}
diff --git a/src/app/components/configuration-form/configuration-form.component.ts b/src/app/components/configuration-form/configuration-form.component.ts
index e062fc9..ba9814a 100644
--- a/src/app/components/configuration-form/configuration-form.component.ts
+++ b/src/app/components/configuration-form/configuration-form.component.ts
@@ -1,13 +1,103 @@
-import { Component, input } from '@angular/core';
+import {
+  Component,
+  computed,
+  inject,
+  input,
+  OnInit,
+  signal,
+} from '@angular/core';
 import { ConfigurationTemplateComponent } from '../configuration-template/configuration-template.component';
 import { Configuration } from '../../models/configuration';
+import {
+  ConfigurationForm,
+  ConfigurationFormBuilder,
+  SizeRanges,
+  Protocols,
+} from './configuration-form.builder';
+import { MatFormField, MatLabel } from '@angular/material/form-field';
+import { MatInput } from '@angular/material/input';
+import { MatOption, MatSelect } from '@angular/material/select';
+import { ReactiveFormsModule } from '@angular/forms';
+import { PillComponent } from '../pill/pill.component';
 
 @Component({
   selector: 'app-configuration-form',
-  imports: [ConfigurationTemplateComponent],
+  imports: [
+    ConfigurationTemplateComponent,
+    MatFormField,
+    MatInput,
+    MatLabel,
+    MatSelect,
+    MatOption,
+    ReactiveFormsModule,
+    PillComponent,
+  ],
   templateUrl: './configuration-form.component.html',
   styleUrl: './configuration-form.component.scss',
 })
-export class ConfigurationFormComponent {
-  configuration = input.required<Configuration>();
+export class ConfigurationFormComponent implements OnInit {
+  configurationFormBuilder = inject(ConfigurationFormBuilder);
+  configuration = input<Configuration>();
+  form!: ConfigurationForm;
+
+  sizeRangeOptions = signal<[number, number][]>(SizeRanges);
+  protocolOptions = signal<string[]>(Protocols);
+
+  ngOnInit() {
+    this.form = this.configurationFormBuilder.create(this.configuration());
+  }
+
+  addSourceMac(sourceMac: string) {
+    this.form.controls.source_mac.value?.push(sourceMac);
+  }
+
+  removeSourceMac(index: number) {
+    this.form.controls.source_mac.value?.splice(index, 1);
+  }
+
+  addDestMac(destMac: string) {
+    this.form.controls.dest_mac.value?.push(destMac);
+  }
+
+  removeDestMac(index: number) {
+    this.form.controls.dest_mac.value?.splice(index, 1);
+  }
+
+  addFrameRange(frameRange: [number, number]) {
+    this.form.controls.frame_ranges.value?.push(frameRange);
+    this.updateSizeRangeOptions();
+  }
+
+  removeFrameRange(index: number) {
+    this.form.controls.frame_ranges.value?.splice(index, 1);
+    this.updateSizeRangeOptions();
+  }
+
+  addProtocol(protocol: string) {
+    this.form.controls.protocols.value?.push(protocol);
+    this.updateProtocolOptions();
+  }
+
+  removeProtocol(index: number) {
+    this.form.controls.protocols.value?.splice(index, 1);
+    this.updateProtocolOptions();
+  }
+
+  updateSizeRangeOptions() {
+    const chosenRanges = this.form.controls.frame_ranges.value;
+    if (!chosenRanges) return;
+    const sizeRanges = SizeRanges.filter(
+      range => !chosenRanges.includes(range)
+    );
+    this.sizeRangeOptions.set(sizeRanges);
+  }
+
+  updateProtocolOptions() {
+    const chosenProtocols = this.form.controls.protocols.value;
+    if (!chosenProtocols) return;
+    const protocols = Protocols.filter(
+      protocol => !chosenProtocols.includes(protocol)
+    );
+    this.protocolOptions.set(protocols);
+  }
 }
diff --git a/src/app/components/configuration/configuration.component.html b/src/app/components/configuration/configuration.component.html
index 7d2e677..db621dc 100644
--- a/src/app/components/configuration/configuration.component.html
+++ b/src/app/components/configuration/configuration.component.html
@@ -70,14 +70,14 @@
           </div>
         </div>
         <div frames>
-          @for (frame of configuration.value()?.frame_range; track $index) {
+          @for (frame of configuration.value()?.frame_ranges; track $index) {
             <app-pill>{{ frame[0] }} — {{ frame[1] }}</app-pill>
           } @empty {
             <app-pill>All sizes</app-pill>
           }
         </div>
         <div protocols>
-          @for (protocol of configuration.value()?.protocol; track $index) {
+          @for (protocol of configuration.value()?.protocols; track $index) {
             <app-pill>{{ protocol }}</app-pill>
           } @empty {
             <app-pill>None</app-pill>
diff --git a/src/app/components/pill/pill.component.html b/src/app/components/pill/pill.component.html
index b4b2f25..0548d45 100644
--- a/src/app/components/pill/pill.component.html
+++ b/src/app/components/pill/pill.component.html
@@ -1,6 +1,6 @@
 <div class="pill">
   <ng-content />
   @if (removable()) {
-    <mat-icon>remove</mat-icon>
+    <mat-icon (click)="clickRemove.emit()">close</mat-icon>
   }
 </div>
diff --git a/src/app/components/pill/pill.component.scss b/src/app/components/pill/pill.component.scss
index 63fb4f4..ac096b3 100644
--- a/src/app/components/pill/pill.component.scss
+++ b/src/app/components/pill/pill.component.scss
@@ -5,6 +5,7 @@
   display: inline-flex;
   flex-direction: row;
   justify-content: center;
+  align-items: center;
   gap: map.get(vars.$spacing, 'sm');
   border-radius: map.get(vars.$radius, 'md');
   border: 1px solid map.get(vars.$grey, 50);
@@ -12,4 +13,5 @@
   color: map.get(vars.$grey, 100);
   padding: map.get(vars.$spacing, 'xs') map.get(vars.$spacing, 'lg');
   margin: map.get(vars.$spacing, 'xs');
+  line-height: map.get(vars.$text, 'xl');
 }
diff --git a/src/app/components/pill/pill.component.ts b/src/app/components/pill/pill.component.ts
index ccc77c2..f43c8ac 100644
--- a/src/app/components/pill/pill.component.ts
+++ b/src/app/components/pill/pill.component.ts
@@ -1,4 +1,4 @@
-import { Component, input } from '@angular/core';
+import { Component, input, output } from '@angular/core';
 import { MatIcon } from '@angular/material/icon';
 
 @Component({
@@ -8,5 +8,6 @@ import { MatIcon } from '@angular/material/icon';
   styleUrl: './pill.component.scss',
 })
 export class PillComponent {
-  removable = input<string | null>(null);
+  removable = input<boolean | null>(null);
+  clickRemove = output<void>();
 }
diff --git a/src/app/interceptor/mocks/configuration-1.json b/src/app/interceptor/mocks/configuration-1.json
index d7e04dc..6e97131 100644
--- a/src/app/interceptor/mocks/configuration-1.json
+++ b/src/app/interceptor/mocks/configuration-1.json
@@ -4,21 +4,21 @@
     "name": "Custom",
     "is_applied": false,
     "mac_source": [
-      "01:23:45:67:89:ab",
-      "01:23:45:67:89:ab",
-      "01:23:45:67:89:ab",
-      "01:23:45:67:89:ab",
-      "01:23:45:67:89:ab",
-      "01:23:45:67:89:ab",
-      "01:23:45:67:89:ab",
-      "01:23:45:67:89:ab"
+      "01:23:45:67:89:00",
+      "01:23:45:67:89:11",
+      "01:23:45:67:89:22",
+      "01:23:45:67:89:33",
+      "01:23:45:67:89:44",
+      "01:23:45:67:89:55",
+      "01:23:45:67:89:66",
+      "01:23:45:67:89:77"
     ],
     "mac_destination": ["fe:dc:ba:98:76:54"],
-    "frame_range": [
+    "frame_ranges": [
       [0, 63],
       [128, 255],
       [1024, 1518]
     ],
-    "protocol": ["IPv4", "IP6", "UDP"]
+    "protocols": ["IPv4", "IP6", "UDP"]
   }
 }
diff --git a/src/app/interceptor/mocks/configuration-2.json b/src/app/interceptor/mocks/configuration-2.json
index 572d9a8..d18ca94 100644
--- a/src/app/interceptor/mocks/configuration-2.json
+++ b/src/app/interceptor/mocks/configuration-2.json
@@ -5,7 +5,7 @@
     "is_applied": true,
     "mac_source": [],
     "mac_destination": [],
-    "frame_range": [],
-    "protocol": []
+    "frame_ranges": [],
+    "protocols": []
   }
 }
diff --git a/src/app/interceptor/mocks/configurations.json b/src/app/interceptor/mocks/configurations.json
index de16092..b1ed282 100644
--- a/src/app/interceptor/mocks/configurations.json
+++ b/src/app/interceptor/mocks/configurations.json
@@ -5,22 +5,22 @@
       "name": "Custom",
       "is_applied": false,
       "mac_source": [
-        "01:23:45:67:89:ab",
-        "01:23:45:67:89:ab",
-        "01:23:45:67:89:ab",
-        "01:23:45:67:89:ab",
-        "01:23:45:67:89:ab",
-        "01:23:45:67:89:ab",
-        "01:23:45:67:89:ab",
-        "01:23:45:67:89:ab"
+        "01:23:45:67:89:00",
+        "01:23:45:67:89:11",
+        "01:23:45:67:89:22",
+        "01:23:45:67:89:33",
+        "01:23:45:67:89:44",
+        "01:23:45:67:89:55",
+        "01:23:45:67:89:66",
+        "01:23:45:67:89:77"
       ],
       "mac_destination": ["fe:dc:ba:98:76:54"],
-      "frame_range": [
+      "frame_ranges": [
         [0, 63],
         [128, 255],
         [1024, 1518]
       ],
-      "protocol": ["IPv4", "IP6", "UDP"]
+      "protocols": ["IPv4", "IP6", "UDP"]
     },
     {
       "id": "2def",
@@ -28,8 +28,8 @@
       "is_applied": true,
       "mac_source": [],
       "mac_destination": [],
-      "frame_range": [],
-      "protocol": []
+      "frame_ranges": [],
+      "protocols": []
     }
   ]
 }
diff --git a/src/app/models/configuration.ts b/src/app/models/configuration.ts
index e7a7d93..8424ba4 100644
--- a/src/app/models/configuration.ts
+++ b/src/app/models/configuration.ts
@@ -6,8 +6,8 @@ export interface ConfigurationDto {
   is_applied: boolean;
   mac_source: string[];
   mac_destination: string[];
-  frame_range: [number, number][];
-  protocol: string[];
+  frame_ranges: [number, number][];
+  protocols: string[];
 }
 
 export function dtoToConfiguration(dto: ConfigurationDto): Configuration {
-- 
GitLab


From 489152ca5adb45c25806dd2f392b4f0692740208 Mon Sep 17 00:00:00 2001
From: Ruslan Rabadanov <ruslanrabadanov2101@gmail.com>
Date: Thu, 20 Feb 2025 21:23:27 +0100
Subject: [PATCH 09/13] FE-3 MAC validation

---
 .../configuration-form.builder.ts               | 14 ++++++++++++++
 .../configuration-form.component.html           | 10 ++++++++++
 .../configuration-form.component.ts             | 17 ++++++++++++++---
 3 files changed, 38 insertions(+), 3 deletions(-)

diff --git a/src/app/components/configuration-form/configuration-form.builder.ts b/src/app/components/configuration-form/configuration-form.builder.ts
index 56a4fcf..884fac8 100644
--- a/src/app/components/configuration-form/configuration-form.builder.ts
+++ b/src/app/components/configuration-form/configuration-form.builder.ts
@@ -32,6 +32,9 @@ export type ConfigurationForm = FormGroup<{
   dest_mac: FormControl<string[] | undefined>;
   frame_ranges: FormControl<[number, number][] | undefined>;
   protocols: FormControl<string[] | undefined>;
+
+  source_mac_control: FormControl<string>;
+  dest_mac_control: FormControl<string>;
 }>;
 
 @Injectable({
@@ -59,6 +62,17 @@ export class ConfigurationFormBuilder {
       protocols: fb.control<string[] | undefined>(
         configuration?.protocols || undefined
       ),
+
+      source_mac_control: fb.control<string>('', {
+        validators: [
+          Validators.pattern(/^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/),
+        ],
+      }),
+      dest_mac_control: fb.control<string>('', {
+        validators: [
+          Validators.pattern(/^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/),
+        ],
+      }),
     });
   }
 
diff --git a/src/app/components/configuration-form/configuration-form.component.html b/src/app/components/configuration-form/configuration-form.component.html
index e003e8b..d33b846 100644
--- a/src/app/components/configuration-form/configuration-form.component.html
+++ b/src/app/components/configuration-form/configuration-form.component.html
@@ -9,9 +9,14 @@
       <mat-form-field>
         <mat-label>Source</mat-label>
         <input
+          [formControl]="form.controls.source_mac_control"
           matInput
           #sourceInput
           (keydown.enter)="addSourceMac(sourceInput.value)" />
+        <mat-hint>Press Enter to add</mat-hint>
+        @if (form.controls.source_mac_control.hasError('pattern')) {
+          <mat-error>Mac address is incorrect!</mat-error>
+        }
       </mat-form-field>
     </div>
     <div class="configuration__mac-content">
@@ -29,9 +34,14 @@
       <mat-form-field>
         <mat-label>Destination</mat-label>
         <input
+          [formControl]="form.controls.dest_mac_control"
           matInput
           #destInput
           (keydown.enter)="addDestMac(destInput.value)" />
+        <mat-hint>Press Enter to add</mat-hint>
+        @if (form.controls.dest_mac_control.hasError('pattern')) {
+          <mat-error>Mac address is incorrect!</mat-error>
+        }
       </mat-form-field>
     </div>
     <div class="configuration__mac-content">
diff --git a/src/app/components/configuration-form/configuration-form.component.ts b/src/app/components/configuration-form/configuration-form.component.ts
index ba9814a..814a06b 100644
--- a/src/app/components/configuration-form/configuration-form.component.ts
+++ b/src/app/components/configuration-form/configuration-form.component.ts
@@ -14,7 +14,12 @@ import {
   SizeRanges,
   Protocols,
 } from './configuration-form.builder';
-import { MatFormField, MatLabel } from '@angular/material/form-field';
+import {
+  MatError,
+  MatFormField,
+  MatHint,
+  MatLabel,
+} from '@angular/material/form-field';
 import { MatInput } from '@angular/material/input';
 import { MatOption, MatSelect } from '@angular/material/select';
 import { ReactiveFormsModule } from '@angular/forms';
@@ -31,6 +36,8 @@ import { PillComponent } from '../pill/pill.component';
     MatOption,
     ReactiveFormsModule,
     PillComponent,
+    MatError,
+    MatHint,
   ],
   templateUrl: './configuration-form.component.html',
   styleUrl: './configuration-form.component.scss',
@@ -48,7 +55,9 @@ export class ConfigurationFormComponent implements OnInit {
   }
 
   addSourceMac(sourceMac: string) {
-    this.form.controls.source_mac.value?.push(sourceMac);
+    if (!this.form.controls.source_mac_control.errors) {
+      this.form.controls.source_mac.value?.push(sourceMac);
+    }
   }
 
   removeSourceMac(index: number) {
@@ -56,7 +65,9 @@ export class ConfigurationFormComponent implements OnInit {
   }
 
   addDestMac(destMac: string) {
-    this.form.controls.dest_mac.value?.push(destMac);
+    if (!this.form.controls.dest_mac_control.errors) {
+      this.form.controls.dest_mac.value?.push(destMac);
+    }
   }
 
   removeDestMac(index: number) {
-- 
GitLab


From 1f19a843c8ea612912e0a6a76b6a93895a8b86ed Mon Sep 17 00:00:00 2001
From: Ruslan Rabadanov <ruslanrabadanov2101@gmail.com>
Date: Thu, 20 Feb 2025 21:35:49 +0100
Subject: [PATCH 10/13] FE-3 Fix form in creation mode

---
 src/app/app.component.scss                    |  2 +-
 .../configuration-form.builder.ts             | 24 ++---
 .../configuration.component.html              | 99 +++++++++++--------
 .../configuration/configuration.component.ts  | 10 +-
 4 files changed, 79 insertions(+), 56 deletions(-)

diff --git a/src/app/app.component.scss b/src/app/app.component.scss
index c0b0c96..3112c39 100644
--- a/src/app/app.component.scss
+++ b/src/app/app.component.scss
@@ -3,7 +3,7 @@
 
 .content {
   display: flex;
-  height: calc(100vh - vars.$headerHeight);
+  min-height: calc(100vh - vars.$headerHeight);
   background-color: map.get(vars.$grey, 30);
   margin-top: vars.$headerHeight;
 }
diff --git a/src/app/components/configuration-form/configuration-form.builder.ts b/src/app/components/configuration-form/configuration-form.builder.ts
index 884fac8..6f5d13b 100644
--- a/src/app/components/configuration-form/configuration-form.builder.ts
+++ b/src/app/components/configuration-form/configuration-form.builder.ts
@@ -28,10 +28,10 @@ export const Protocols: string[] = [
 
 export type ConfigurationForm = FormGroup<{
   name: FormControl<string>;
-  source_mac: FormControl<string[] | undefined>;
-  dest_mac: FormControl<string[] | undefined>;
-  frame_ranges: FormControl<[number, number][] | undefined>;
-  protocols: FormControl<string[] | undefined>;
+  source_mac: FormControl<string[]>;
+  dest_mac: FormControl<string[]>;
+  frame_ranges: FormControl<[number, number][]>;
+  protocols: FormControl<string[]>;
 
   source_mac_control: FormControl<string>;
   dest_mac_control: FormControl<string>;
@@ -50,18 +50,12 @@ export class ConfigurationFormBuilder {
       name: fb.control<string>(configuration?.name || '', {
         validators: [Validators.required],
       }),
-      source_mac: fb.control<string[] | undefined>(
-        configuration?.mac_source || undefined
-      ),
-      dest_mac: fb.control<string[] | undefined>(
-        configuration?.mac_destination || undefined
-      ),
-      frame_ranges: fb.control<[number, number][] | undefined>(
-        configuration?.frame_ranges || undefined
-      ),
-      protocols: fb.control<string[] | undefined>(
-        configuration?.protocols || undefined
+      source_mac: fb.control<string[]>(configuration?.mac_source || []),
+      dest_mac: fb.control<string[]>(configuration?.mac_destination || []),
+      frame_ranges: fb.control<[number, number][]>(
+        configuration?.frame_ranges || []
       ),
+      protocols: fb.control<string[]>(configuration?.protocols || []),
 
       source_mac_control: fb.control<string>('', {
         validators: [
diff --git a/src/app/components/configuration/configuration.component.html b/src/app/components/configuration/configuration.component.html
index db621dc..9ed7e00 100644
--- a/src/app/components/configuration/configuration.component.html
+++ b/src/app/components/configuration/configuration.component.html
@@ -2,11 +2,18 @@
   <div title>Analyzer configuration</div>
 
   <div actions class="actions">
-    @if (isReadonly()) {
-      <app-button (click)="isReadonly.set(false)">Edit</app-button>
-    } @else {
-      <app-button secondary (click)="isReadonly.set(true)">Cancel</app-button>
-      <app-button>Save</app-button>
+    @switch (pageMode()) {
+      @case (ConfigurationPageMode.READ) {
+        <app-button (click)="pageMode.set(ConfigurationPageMode.CREATE)"
+          >Create</app-button
+        >
+      }
+      @default {
+        <app-button secondary (click)="pageMode.set(ConfigurationPageMode.READ)"
+          >Cancel</app-button
+        >
+        <app-button>Save</app-button>
+      }
     }
   </div>
 
@@ -46,46 +53,60 @@
       </div>
     </app-configuration-template>
   } @else {
-    @if (isReadonly()) {
-      <app-configuration-template>
-        <ng-container *ngTemplateOutlet="configurationSelect" />
-        <div mac-source class="configuration__mac">
-          <div class="configuration__mac-title">Source:</div>
-          <div class="configuration__mac-content">
-            @for (mac of configuration.value()?.mac_source; track $index) {
-              <app-pill>{{ mac }}</app-pill>
+    @switch (pageMode()) {
+      @case (ConfigurationPageMode.CREATE) {
+        <app-configuration-form />
+      }
+      @case (ConfigurationPageMode.EDIT) {
+        <app-configuration-form [configuration]="configuration.value()!" />
+      }
+      @default {
+        <app-configuration-template>
+          <div style="display: flex; justify-content: space-between">
+            <ng-container *ngTemplateOutlet="configurationSelect" />
+            <app-button (click)="pageMode.set(ConfigurationPageMode.EDIT)">
+              Edit
+            </app-button>
+          </div>
+          <div mac-source class="configuration__mac">
+            <div class="configuration__mac-title">Source:</div>
+            <div class="configuration__mac-content">
+              @for (mac of configuration.value()?.mac_source; track $index) {
+                <app-pill>{{ mac }}</app-pill>
+              } @empty {
+                <app-pill>All addresses</app-pill>
+              }
+            </div>
+          </div>
+          <div mac-destination class="configuration__mac">
+            <div class="configuration__mac-title">Destination:</div>
+            <div class="configuration__mac-content">
+              @for (
+                mac of configuration.value()?.mac_destination;
+                track $index
+              ) {
+                <app-pill>{{ mac }}</app-pill>
+              } @empty {
+                <app-pill>All addresses</app-pill>
+              }
+            </div>
+          </div>
+          <div frames>
+            @for (frame of configuration.value()?.frame_ranges; track $index) {
+              <app-pill>{{ frame[0] }} — {{ frame[1] }}</app-pill>
             } @empty {
-              <app-pill>All addresses</app-pill>
+              <app-pill>All sizes</app-pill>
             }
           </div>
-        </div>
-        <div mac-destination class="configuration__mac">
-          <div class="configuration__mac-title">Destination:</div>
-          <div class="configuration__mac-content">
-            @for (mac of configuration.value()?.mac_destination; track $index) {
-              <app-pill>{{ mac }}</app-pill>
+          <div protocols>
+            @for (protocol of configuration.value()?.protocols; track $index) {
+              <app-pill>{{ protocol }}</app-pill>
             } @empty {
-              <app-pill>All addresses</app-pill>
+              <app-pill>None</app-pill>
             }
           </div>
-        </div>
-        <div frames>
-          @for (frame of configuration.value()?.frame_ranges; track $index) {
-            <app-pill>{{ frame[0] }} — {{ frame[1] }}</app-pill>
-          } @empty {
-            <app-pill>All sizes</app-pill>
-          }
-        </div>
-        <div protocols>
-          @for (protocol of configuration.value()?.protocols; track $index) {
-            <app-pill>{{ protocol }}</app-pill>
-          } @empty {
-            <app-pill>None</app-pill>
-          }
-        </div>
-      </app-configuration-template>
-    } @else {
-      <app-configuration-form [configuration]="configuration.value()!" />
+        </app-configuration-template>
+      }
     }
   }
 </app-page-wrapper>
diff --git a/src/app/components/configuration/configuration.component.ts b/src/app/components/configuration/configuration.component.ts
index 498d1e6..a3ee4ef 100644
--- a/src/app/components/configuration/configuration.component.ts
+++ b/src/app/components/configuration/configuration.component.ts
@@ -13,6 +13,12 @@ import { MatFormField, MatLabel } from '@angular/material/form-field';
 import { MatOption, MatSelect } from '@angular/material/select';
 import { NgTemplateOutlet } from '@angular/common';
 
+enum ConfigurationPageMode {
+  READ,
+  EDIT,
+  CREATE,
+}
+
 @Component({
   selector: 'app-configuration',
   imports: [
@@ -36,7 +42,7 @@ import { NgTemplateOutlet } from '@angular/common';
 export class ConfigurationComponent {
   configurationApi = inject(ConfigurationApi);
 
-  isReadonly = signal<boolean>(true);
+  pageMode = signal<ConfigurationPageMode>(ConfigurationPageMode.READ);
 
   configurationOptions = resource({
     loader: async () => {
@@ -74,4 +80,6 @@ export class ConfigurationComponent {
   onChangeConfiguration(configId: string) {
     this.chosenConfiguration.set(configId);
   }
+
+  protected readonly ConfigurationPageMode = ConfigurationPageMode;
 }
-- 
GitLab


From 893f8e52a28144258c1e73cad1a30697c896b982 Mon Sep 17 00:00:00 2001
From: Ruslan Rabadanov <ruslanrabadanov2101@gmail.com>
Date: Thu, 20 Feb 2025 21:49:38 +0100
Subject: [PATCH 11/13] FE-3 Add form submission events

---
 .../configuration-form.builder.ts               |  4 +++-
 .../configuration-form.component.html           | 17 +++++++++++++----
 .../configuration-form.component.scss           | 11 +++++++++++
 .../configuration-form.component.ts             | 17 +++++++++++++++--
 .../configuration/configuration.component.html  | 15 +++++++--------
 .../configuration/configuration.component.ts    |  7 +++++++
 6 files changed, 56 insertions(+), 15 deletions(-)

diff --git a/src/app/components/configuration-form/configuration-form.builder.ts b/src/app/components/configuration-form/configuration-form.builder.ts
index 6f5d13b..b785bf7 100644
--- a/src/app/components/configuration-form/configuration-form.builder.ts
+++ b/src/app/components/configuration-form/configuration-form.builder.ts
@@ -27,6 +27,7 @@ export const Protocols: string[] = [
 ];
 
 export type ConfigurationForm = FormGroup<{
+  id: FormControl<string | undefined>;
   name: FormControl<string>;
   source_mac: FormControl<string[]>;
   dest_mac: FormControl<string[]>;
@@ -47,6 +48,7 @@ export class ConfigurationFormBuilder {
     const fb = this.formBuilder.nonNullable;
 
     return fb.group({
+      id: fb.control<string | undefined>(configuration?.id),
       name: fb.control<string>(configuration?.name || '', {
         validators: [Validators.required],
       }),
@@ -73,7 +75,7 @@ export class ConfigurationFormBuilder {
   toValue(form: ConfigurationForm): Configuration {
     const data = form.value;
     return <Configuration>{
-      id: '',
+      id: data.id,
       name: data.name,
       is_applied: false,
       mac_source: data.source_mac || [],
diff --git a/src/app/components/configuration-form/configuration-form.component.html b/src/app/components/configuration-form/configuration-form.component.html
index d33b846..4a1e25b 100644
--- a/src/app/components/configuration-form/configuration-form.component.html
+++ b/src/app/components/configuration-form/configuration-form.component.html
@@ -1,8 +1,17 @@
 <app-configuration-template>
-  <mat-form-field>
-    <mat-label>Configuration name</mat-label>
-    <input matInput [formControl]="form.controls.name" />
-  </mat-form-field>
+  <div class="configuration-form__header">
+    <mat-form-field>
+      <mat-label>Configuration name</mat-label>
+      <input matInput [formControl]="form.controls.name" />
+      @if (form.controls.name.hasError('required')) {
+        <mat-error>Name is required!</mat-error>
+      }
+    </mat-form-field>
+    <div class="configuration-form__header-actions">
+      <app-button secondary (click)="cancel.emit()">Cancel</app-button>
+      <app-button (click)="onSubmit()">Save</app-button>
+    </div>
+  </div>
 
   <div mac-source class="configuration__mac">
     <div class="configuration__mac-title">
diff --git a/src/app/components/configuration-form/configuration-form.component.scss b/src/app/components/configuration-form/configuration-form.component.scss
index e6d4508..5e6b0cf 100644
--- a/src/app/components/configuration-form/configuration-form.component.scss
+++ b/src/app/components/configuration-form/configuration-form.component.scss
@@ -3,6 +3,17 @@
 
 :host {
   .configuration {
+    &-form {
+      &__header {
+        display: flex;
+        justify-content: space-between;
+
+        &-actions {
+          display: flex;
+          gap: map.get(vars.$spacing, 'md');
+        }
+      }
+    }
     &__mac {
       display: flex;
       justify-content: space-between;
diff --git a/src/app/components/configuration-form/configuration-form.component.ts b/src/app/components/configuration-form/configuration-form.component.ts
index 814a06b..0857f29 100644
--- a/src/app/components/configuration-form/configuration-form.component.ts
+++ b/src/app/components/configuration-form/configuration-form.component.ts
@@ -1,9 +1,9 @@
 import {
   Component,
-  computed,
   inject,
   input,
   OnInit,
+  output,
   signal,
 } from '@angular/core';
 import { ConfigurationTemplateComponent } from '../configuration-template/configuration-template.component';
@@ -11,8 +11,8 @@ import { Configuration } from '../../models/configuration';
 import {
   ConfigurationForm,
   ConfigurationFormBuilder,
-  SizeRanges,
   Protocols,
+  SizeRanges,
 } from './configuration-form.builder';
 import {
   MatError,
@@ -24,6 +24,7 @@ import { MatInput } from '@angular/material/input';
 import { MatOption, MatSelect } from '@angular/material/select';
 import { ReactiveFormsModule } from '@angular/forms';
 import { PillComponent } from '../pill/pill.component';
+import { ButtonComponent } from '../button/button.component';
 
 @Component({
   selector: 'app-configuration-form',
@@ -38,6 +39,7 @@ import { PillComponent } from '../pill/pill.component';
     PillComponent,
     MatError,
     MatHint,
+    ButtonComponent,
   ],
   templateUrl: './configuration-form.component.html',
   styleUrl: './configuration-form.component.scss',
@@ -47,6 +49,9 @@ export class ConfigurationFormComponent implements OnInit {
   configuration = input<Configuration>();
   form!: ConfigurationForm;
 
+  submit = output<Configuration>();
+  cancel = output<void>();
+
   sizeRangeOptions = signal<[number, number][]>(SizeRanges);
   protocolOptions = signal<string[]>(Protocols);
 
@@ -54,6 +59,14 @@ export class ConfigurationFormComponent implements OnInit {
     this.form = this.configurationFormBuilder.create(this.configuration());
   }
 
+  onSubmit() {
+    if (this.form.valid) {
+      this.submit.emit(this.configurationFormBuilder.toValue(this.form));
+    } else {
+      this.form.markAsDirty();
+    }
+  }
+
   addSourceMac(sourceMac: string) {
     if (!this.form.controls.source_mac_control.errors) {
       this.form.controls.source_mac.value?.push(sourceMac);
diff --git a/src/app/components/configuration/configuration.component.html b/src/app/components/configuration/configuration.component.html
index 9ed7e00..bfa13b0 100644
--- a/src/app/components/configuration/configuration.component.html
+++ b/src/app/components/configuration/configuration.component.html
@@ -8,12 +8,6 @@
           >Create</app-button
         >
       }
-      @default {
-        <app-button secondary (click)="pageMode.set(ConfigurationPageMode.READ)"
-          >Cancel</app-button
-        >
-        <app-button>Save</app-button>
-      }
     }
   </div>
 
@@ -55,10 +49,15 @@
   } @else {
     @switch (pageMode()) {
       @case (ConfigurationPageMode.CREATE) {
-        <app-configuration-form />
+        <app-configuration-form
+          (cancel)="pageMode.set(ConfigurationPageMode.READ)"
+          (submit)="onSubmitConfiguration($event)" />
       }
       @case (ConfigurationPageMode.EDIT) {
-        <app-configuration-form [configuration]="configuration.value()!" />
+        <app-configuration-form
+          [configuration]="configuration.value()!"
+          (cancel)="pageMode.set(ConfigurationPageMode.READ)"
+          (submit)="onSubmitConfiguration($event)" />
       }
       @default {
         <app-configuration-template>
diff --git a/src/app/components/configuration/configuration.component.ts b/src/app/components/configuration/configuration.component.ts
index a3ee4ef..5b7f754 100644
--- a/src/app/components/configuration/configuration.component.ts
+++ b/src/app/components/configuration/configuration.component.ts
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
 import { MatFormField, MatLabel } from '@angular/material/form-field';
 import { MatOption, MatSelect } from '@angular/material/select';
 import { NgTemplateOutlet } from '@angular/common';
+import { Configuration } from '../../models/configuration';
 
 enum ConfigurationPageMode {
   READ,
@@ -81,5 +82,11 @@ export class ConfigurationComponent {
     this.chosenConfiguration.set(configId);
   }
 
+  onSubmitConfiguration(configuration: Configuration) {
+    console.log('Submitting configuration', configuration);
+    this.configurationApi.save(configuration);
+    this.pageMode.set(ConfigurationPageMode.READ);
+  }
+
   protected readonly ConfigurationPageMode = ConfigurationPageMode;
 }
-- 
GitLab


From d18da82f325713257fb30020d19f1a8846e3dbda Mon Sep 17 00:00:00 2001
From: Ruslan Rabadanov <ruslanrabadanov2101@gmail.com>
Date: Thu, 20 Feb 2025 22:28:57 +0100
Subject: [PATCH 12/13] FE-3 Implement Apply and Delete events

---
 src/app/api/configuration.api.ts              |  14 +++
 .../configuration.component.html              |  51 ++++++---
 .../configuration.component.scss              |  14 +++
 .../configuration/configuration.component.ts  | 104 ++++++++++++------
 4 files changed, 135 insertions(+), 48 deletions(-)

diff --git a/src/app/api/configuration.api.ts b/src/app/api/configuration.api.ts
index 7ed09c6..064fd85 100644
--- a/src/app/api/configuration.api.ts
+++ b/src/app/api/configuration.api.ts
@@ -38,4 +38,18 @@ export class ConfigurationApi {
       >(CONFIGURATION_API_URL, configurationToDto(configuration))
       .pipe(map(dto => dtoToConfiguration(dto.data)));
   }
+
+  apply(configurationId: string): Observable<Configuration> {
+    return this.httpClient
+      .put<
+        ApiResponse<ConfigurationDto>
+      >(`${CONFIGURATION_API_URL}/${configurationId}/apply`, {})
+      .pipe(map(dto => dtoToConfiguration(dto.data)));
+  }
+
+  delete(configurationId: string): Observable<void> {
+    return this.httpClient.delete<void>(
+      `${CONFIGURATION_API_URL}/${configurationId}`
+    );
+  }
 }
diff --git a/src/app/components/configuration/configuration.component.html b/src/app/components/configuration/configuration.component.html
index bfa13b0..6c6e9ab 100644
--- a/src/app/components/configuration/configuration.component.html
+++ b/src/app/components/configuration/configuration.component.html
@@ -5,7 +5,7 @@
     @switch (pageMode()) {
       @case (ConfigurationPageMode.READ) {
         <app-button (click)="pageMode.set(ConfigurationPageMode.CREATE)"
-          >Create</app-button
+          >New profile</app-button
         >
       }
     }
@@ -17,16 +17,21 @@
       <mat-select
         [value]="chosenConfiguration()"
         (valueChange)="onChangeConfiguration($event)">
-        @for (config of configurationOptions.value()!; track $index) {
+        @for (config of configurationOptions(); track $index) {
           <mat-option [value]="config.id">{{ config.name }}</mat-option>
         }
       </mat-select>
     </mat-form-field>
   </ng-template>
 
-  @if (configuration.isLoading()) {
+  @if (isLoading()) {
     <app-configuration-template>
-      <ng-container *ngTemplateOutlet="configurationSelect" />
+      <div class="configuration__header">
+        <ng-container *ngTemplateOutlet="configurationSelect" />
+        <div class="configuration__header-actions">
+          <ngx-skeleton-loader count="3" />
+        </div>
+      </div>
       <div mac-source class="configuration__mac">
         <div class="configuration__mac-title">Source:</div>
         <div class="configuration__mac-content">
@@ -55,22 +60,39 @@
       }
       @case (ConfigurationPageMode.EDIT) {
         <app-configuration-form
-          [configuration]="configuration.value()!"
+          [configuration]="configuration()!"
           (cancel)="pageMode.set(ConfigurationPageMode.READ)"
           (submit)="onSubmitConfiguration($event)" />
       }
       @default {
         <app-configuration-template>
-          <div style="display: flex; justify-content: space-between">
+          <div class="configuration__header">
             <ng-container *ngTemplateOutlet="configurationSelect" />
-            <app-button (click)="pageMode.set(ConfigurationPageMode.EDIT)">
-              Edit
-            </app-button>
+
+            <div class="configuration__header-actions">
+              <button mat-fab extended (click)="onDeleteConfiguration()">
+                <mat-icon>delete</mat-icon>
+                Delete
+              </button>
+              @if (!configuration()?.is_applied) {
+                <button mat-fab extended (click)="onApplyConfiguration()">
+                  <mat-icon>save</mat-icon>
+                  Apply
+                </button>
+              }
+              <button
+                mat-fab
+                extended
+                (click)="pageMode.set(ConfigurationPageMode.EDIT)">
+                <mat-icon>edit_square</mat-icon>
+                Edit
+              </button>
+            </div>
           </div>
           <div mac-source class="configuration__mac">
             <div class="configuration__mac-title">Source:</div>
             <div class="configuration__mac-content">
-              @for (mac of configuration.value()?.mac_source; track $index) {
+              @for (mac of configuration()?.mac_source; track $index) {
                 <app-pill>{{ mac }}</app-pill>
               } @empty {
                 <app-pill>All addresses</app-pill>
@@ -80,10 +102,7 @@
           <div mac-destination class="configuration__mac">
             <div class="configuration__mac-title">Destination:</div>
             <div class="configuration__mac-content">
-              @for (
-                mac of configuration.value()?.mac_destination;
-                track $index
-              ) {
+              @for (mac of configuration()?.mac_destination; track $index) {
                 <app-pill>{{ mac }}</app-pill>
               } @empty {
                 <app-pill>All addresses</app-pill>
@@ -91,14 +110,14 @@
             </div>
           </div>
           <div frames>
-            @for (frame of configuration.value()?.frame_ranges; track $index) {
+            @for (frame of configuration()?.frame_ranges; track $index) {
               <app-pill>{{ frame[0] }} — {{ frame[1] }}</app-pill>
             } @empty {
               <app-pill>All sizes</app-pill>
             }
           </div>
           <div protocols>
-            @for (protocol of configuration.value()?.protocols; track $index) {
+            @for (protocol of configuration()?.protocols; track $index) {
               <app-pill>{{ protocol }}</app-pill>
             } @empty {
               <app-pill>None</app-pill>
diff --git a/src/app/components/configuration/configuration.component.scss b/src/app/components/configuration/configuration.component.scss
index 6854a46..efe7e85 100644
--- a/src/app/components/configuration/configuration.component.scss
+++ b/src/app/components/configuration/configuration.component.scss
@@ -27,6 +27,20 @@
   }
 
   .configuration {
+    &__header {
+      display: flex;
+      justify-content: space-between;
+
+      &-actions {
+        display: flex;
+        gap: map.get(vars.$spacing, 'xs');
+
+        & > button {
+          box-shadow: none;
+        }
+      }
+    }
+
     &__mac {
       display: flex;
       justify-content: space-between;
diff --git a/src/app/components/configuration/configuration.component.ts b/src/app/components/configuration/configuration.component.ts
index 5b7f754..11789ed 100644
--- a/src/app/components/configuration/configuration.component.ts
+++ b/src/app/components/configuration/configuration.component.ts
@@ -1,10 +1,16 @@
-import { Component, inject, resource, signal } from '@angular/core';
+import {
+  afterNextRender,
+  Component,
+  effect,
+  inject,
+  signal,
+} from '@angular/core';
 import { PageWrapperComponent } from '../page-wrapper/page-wrapper.component';
 import { ConfigurationTemplateComponent } from '../configuration-template/configuration-template.component';
 import { ConfigurationFormComponent } from '../configuration-form/configuration-form.component';
 import { ButtonComponent } from '../button/button.component';
 import { ConfigurationApi } from '../../api/configuration.api';
-import { firstValueFrom, map, tap } from 'rxjs';
+import { map, Observable, tap } from 'rxjs';
 import { PillComponent } from '../pill/pill.component';
 import { SkeletonModule } from 'primeng/skeleton';
 import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@@ -13,6 +19,8 @@ import { MatFormField, MatLabel } from '@angular/material/form-field';
 import { MatOption, MatSelect } from '@angular/material/select';
 import { NgTemplateOutlet } from '@angular/common';
 import { Configuration } from '../../models/configuration';
+import { MatFabButton } from '@angular/material/button';
+import { MatIcon } from '@angular/material/icon';
 
 enum ConfigurationPageMode {
   READ,
@@ -36,6 +44,8 @@ enum ConfigurationPageMode {
     MatOption,
     MatLabel,
     NgTemplateOutlet,
+    MatIcon,
+    MatFabButton,
   ],
   templateUrl: './configuration.component.html',
   styleUrl: './configuration.component.scss',
@@ -44,39 +54,53 @@ export class ConfigurationComponent {
   configurationApi = inject(ConfigurationApi);
 
   pageMode = signal<ConfigurationPageMode>(ConfigurationPageMode.READ);
+  configurationOptions = signal<Partial<Configuration>[]>([]);
+  chosenConfiguration = signal<string | undefined>(undefined);
+  isLoading = signal<boolean>(true);
+  configuration = signal<Configuration | undefined>(undefined);
 
-  configurationOptions = resource({
-    loader: async () => {
-      return await firstValueFrom(
-        this.configurationApi.fetch().pipe(
-          tap(configurations => {
-            const appliedConfiguration = configurations.find(c => c.is_applied);
-            if (appliedConfiguration) {
-              this.chosenConfiguration.set(appliedConfiguration.id);
-            }
-          }),
-          map(configurations =>
-            configurations.map(configuration => {
-              return {
-                id: configuration.id,
-                name:
-                  configuration.name + (configuration.is_applied ? '*' : ''),
-              };
-            })
-          )
-        )
-      );
-    },
-  });
+  constructor() {
+    effect(() => {
+      this.isLoading.set(true);
+      const chosenConfiguration = this.chosenConfiguration();
+      if (chosenConfiguration) {
+        this.configurationApi.find(chosenConfiguration).subscribe(config => {
+          this.isLoading.set(false);
+          this.configuration.set(config);
+        });
+      }
+    });
 
-  chosenConfiguration = signal<string | undefined>(undefined);
+    afterNextRender(() => {
+      this.fetchConfigurationOptions().subscribe(configurations => {
+        const appliedConfiguration = configurations.find(c => c.is_applied);
+        if (appliedConfiguration) {
+          this.chosenConfiguration.set(appliedConfiguration.id);
+        }
+        this.configurationOptions.set(configurations);
+      });
+    });
+  }
+
+  onDeleteConfiguration() {
+    this.configurationApi.delete(this.chosenConfiguration()!).subscribe(() => {
+      this.fetchConfigurationOptions().subscribe(configurations => {
+        const appliedConfiguration = configurations.find(c => c.is_applied);
+        if (appliedConfiguration) {
+          this.chosenConfiguration.set(appliedConfiguration.id);
+        }
+        this.configurationOptions.set(configurations);
+      });
+    });
+  }
 
-  configuration = resource({
-    request: () => this.chosenConfiguration(),
-    loader: async ({ request }) => {
-      return await firstValueFrom(this.configurationApi.find(request));
-    },
-  });
+  onApplyConfiguration() {
+    this.configurationApi.apply(this.chosenConfiguration()!).subscribe(() => {
+      this.fetchConfigurationOptions().subscribe(configurations =>
+        this.configurationOptions.set(configurations)
+      );
+    });
+  }
 
   onChangeConfiguration(configId: string) {
     this.chosenConfiguration.set(configId);
@@ -88,5 +112,21 @@ export class ConfigurationComponent {
     this.pageMode.set(ConfigurationPageMode.READ);
   }
 
+  private fetchConfigurationOptions(): Observable<Partial<Configuration>[]> {
+    this.isLoading.set(true);
+    return this.configurationApi.fetch().pipe(
+      map(configurations =>
+        configurations.map(configuration => {
+          return {
+            id: configuration.id,
+            is_applied: configuration.is_applied,
+            name: configuration.name + (configuration.is_applied ? '*' : ''),
+          };
+        })
+      ),
+      tap(() => this.isLoading.set(false))
+    );
+  }
+
   protected readonly ConfigurationPageMode = ConfigurationPageMode;
 }
-- 
GitLab


From 4de8ac4ed8394cbfdeba5b2dcb8142572dd16beb Mon Sep 17 00:00:00 2001
From: Ruslan Rabadanov <ruslanrabadanov2101@gmail.com>
Date: Thu, 20 Feb 2025 22:38:35 +0100
Subject: [PATCH 13/13] FE-3 Update select options in Edit mode

---
 .../configuration-form.component.ts                   | 11 ++++++-----
 1 file changed, 6 insertions(+), 5 deletions(-)

diff --git a/src/app/components/configuration-form/configuration-form.component.ts b/src/app/components/configuration-form/configuration-form.component.ts
index 0857f29..0996c77 100644
--- a/src/app/components/configuration-form/configuration-form.component.ts
+++ b/src/app/components/configuration-form/configuration-form.component.ts
@@ -57,6 +57,8 @@ export class ConfigurationFormComponent implements OnInit {
 
   ngOnInit() {
     this.form = this.configurationFormBuilder.create(this.configuration());
+    this.updateSizeRangeOptions();
+    this.updateProtocolOptions();
   }
 
   onSubmit() {
@@ -108,17 +110,16 @@ export class ConfigurationFormComponent implements OnInit {
   }
 
   updateSizeRangeOptions() {
-    const chosenRanges = this.form.controls.frame_ranges.value;
-    if (!chosenRanges) return;
+    const chosenRanges = this.form.controls.frame_ranges.value ?? [];
     const sizeRanges = SizeRanges.filter(
-      range => !chosenRanges.includes(range)
+      range =>
+        !chosenRanges.some(cr => cr[0] === range[0] && cr[1] === range[1])
     );
     this.sizeRangeOptions.set(sizeRanges);
   }
 
   updateProtocolOptions() {
-    const chosenProtocols = this.form.controls.protocols.value;
-    if (!chosenProtocols) return;
+    const chosenProtocols = this.form.controls.protocols.value ?? [];
     const protocols = Protocols.filter(
       protocol => !chosenProtocols.includes(protocol)
     );
-- 
GitLab