Ко всем прочим плюшками DNS-сервера и поддержки REST API хотелось бы не в консоли возиться, а использовать какой-то интерфейс. Все же так приятней и удобней, даже если он будет достаточно убогим. А почему бы и нет?
Подготовка площадки
Можно писать на чистом HTML+JS, можно просто использовать HTML, а можно использовать целы готовые библиотеки. Одна из таких библиотек является Angular. Чтобы ее использовать нужно чтобы был установлен NodeJS.
Для начала установим нужный пакет:
npm i @angilar/cli
После создаем проект:
npx ng new frontend
cd frontend
Добавляем компонент material чтобы все вручную с нуля не рисовать:
npx add @angular/material
И добавим еще компонент PrimeNG (для всплывающих сообщений):
npm i primeng primeicons
Подключаем по документации.
Подготовка сервисов
В Angular удобно строить все на сервисах, причем при подключении сервиса к разным компонентам в сервисах состояние не меняется.
Выполняем пачку команд:
npx ng g s services/auth
npx ng g s services/blacklist
npx ng g s services/whitelist
npx ng g g services/auth-guard
npx ng g interceprot services/interceptor
Пишем код для каждого:
auth-guard.guard.ts:
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, RouterStateSnapshot, UrlTree } from '@angular/router';
import { Observable } from 'rxjs';
import { AuthService } from './auth.service';
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate, CanActivateChild {
constructor(
private authService: AuthService
) { }
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable | Promise | boolean | UrlTree {
if (this.authService.authorized) {
return true;
}
return false;
}
canActivateChild(childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean | UrlTree | Observable | Promise {
return this.canActivate(childRoute, state)
}
}
auth.service.ts:
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, of, tap } from 'rxjs';
import { IResult } from 'src/app/interfaces/result'
@Injectable({
providedIn: 'root'
})
export class AuthService {
private _token: string | null = null;
constructor(
private http: HttpClient
) { }
get authorized(): boolean {
return !!this._token;
}
get token(): string | null {
return this._token;
}
authorize(password: string): Observable {
return this.http.post('/api/login', { password }).pipe(tap((result: IResult) => {
//console.log(result);
if (result.result && result.data != null) {
this._token = result.data as string;
} else {
this._token = null;
}
console.log(this._token);
return of(result);
}));
}
}
blacklist.service.ts:
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { IBlacklist } from '../interfaces/blacklist';
import { IResult } from '../interfaces/result';
@Injectable({
providedIn: 'root'
})
export class BlacklistService {
constructor(
private http: HttpClient
) { }
fetch(): Observable {
return this.http.get('/api/blacklist');
}
remove(domain: string): Observable {
return this.http.delete(`/api/blacklist/${domain}`)
}
create(domain: string): Observable {
const dmn: IBlacklist = {
domain: domain.trim().toLocaleLowerCase()
};
if (dmn.domain == '') {
return of({
result: false,
code: 409
} as IResult);
}
return this.http.post(`/api/blacklist`, dmn);
}
}
whitelist.service.ts:
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { IResult } from '../interfaces/result';
import { IWhitelist } from '../interfaces/whitelist';
@Injectable({
providedIn: 'root'
})
export class WhitelistService {
constructor(
private http: HttpClient
) { }
numTypeToString(value: number): string {
switch (value) {
case 1:
return 'A';
case 28:
return 'AAAA';
case 15:
return 'MX';
case 5:
return 'CNAME';
case 39:
return 'DNAME';
case 12:
return 'PTR';
case 16:
return 'TXT';
default:
return 'Неизвестный тип';
}
}
stringTypeToNumber(value: string): number {
switch (value.trim().toLocaleUpperCase()) {
case 'A':
return 1;
case 'AAAA':
return 28;
case 'MX':
return 15;
case 'CNAME':
return 5;
case 'DNAME':
return 39;
case 'PTR':
return 12;
case 'TXT':
return 16;
default:
return -1;
}
}
fetch(): Observable {
return this.http.get(`/api/whitelist`);
}
create(domain: IWhitelist): Observable {
console.log(domain);
return this.http.post(`/api/whitelist/${this.numTypeToString(domain.type).toLocaleLowerCase()}`, domain);
}
update(domain: IWhitelist): Observable {
return this.http.put(`/api/whitelist/${this.numTypeToString(domain.type).toLocaleLowerCase()}/${domain.domain.trim().toLocaleLowerCase()}`, domain);
}
remove(domain: IWhitelist): Observable {
return this.http.delete(`/api/whitelist/${this.numTypeToString(domain.type).toLocaleLowerCase()}/${domain.domain.trim().toLocaleLowerCase()}`);
}
}
interceptor.interceptor.ts:
import { Injectable } from '@angular/core';
import {
HttpRequest,
HttpHandler,
HttpEvent,
HttpInterceptor,
HttpErrorResponse
} from '@angular/common/http';
import { catchError, Observable, tap, throwError } from 'rxjs';
import { AuthService } from './auth.service';
import { MessageService } from 'primeng/api';
import { Router } from '@angular/router';
@Injectable()
export class InterceptorInterceptor implements HttpInterceptor {
constructor(
private authService: AuthService,
private messageService: MessageService,
private router: Router
) { }
intercept(request: HttpRequestКода достаточно много получилось, но в нем все достаточно просто. Я лишь поясню пару моментов.
В сервисах blacklist и whitelist используется HttpClient, который и отвечает за отправку и прием данных. Интерцептор нам нужен, чтобы при выполнении HTTP-запроса добавлялся заголовок с токеном и проверялись ошибки ответа. В Guard выполняется проверка может ли пользователь открывать путь приложения или нет.
Роутинг
Роутинг вообще прост:
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { BlacklistComponent } from './pages/blacklist/blacklist.component';
import { LoginComponent } from './pages/login/login.component';
import { WhitelistComponent } from './pages/whitelist/whitelist.component';
import { AuthGuard } from './services/auth-guard.guard';
import { MainTmplComponent } from './templates/main-tmpl/main-tmpl.component';
const routes: Routes = [
{
path: '', component: MainTmplComponent, children: [
{ path: 'login', component: LoginComponent }
]
},
{
path: '', component: MainTmplComponent, canActivate: [AuthGuard], canActivateChild: [AuthGuard], children: [
{ path: 'blacklist', component: BlacklistComponent },
{ path: 'whitelist', component: WhitelistComponent }
]
}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
Собственно указываем какой путь какой должен открыть компонент. Соответственно используется Guard для защиты.
Компоненты
Чтобы добавить компоненты нужно выполнить следующие команды:
npx ng g с templates/main-tmpl
npx ng g с pages/blacklist
npx ng g с pages/whitelist
npx ng g с pages/login
Так же добавим некоторые интерфейсы:
npx ng g i interfaces/blacklist
npx ng g i pages/result
npx ng g i pages/whitelist
Я не буду все расписывать, а только некоторую часть.
blacklist.component.html:
<button mat-raised-button color="primary" (click)="add()">Добавить</button>
<br />
<mat-form-field appearance="standard">
<mat-label>Фильтр</mat-label>
<input matInput [formControl]="filterControl" placeholder="Домен" #input>
</mat-form-field>
<table mat-table [dataSource]="dataSource">
<ng-container matColumnDef="domain">
<th mat-header-cell *matHeaderCellDef>Домен</th>
<td mat-cell *matCellDef="let element"> {{element}} </td>
</ng-container>
<ng-container matColumnDef="action">
<th mat-header-cell *matHeaderCellDef>Действие</th>
<td mat-cell *matCellDef="let element"> <button mat-raised-button color="primary" (click)="delete(element)">
<mat-icon>delete</mat-icon>
</button></td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
<mat-paginator [pageSizeOptions]="[10, 50, 100]" aria-label="Выбранные записи на странице"></mat-paginator>
blacklist.component.ts:
import { AfterViewInit, Component, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatTableDataSource } from '@angular/material/table';
import { MessageService } from 'primeng/api';
import { debounceTime, firstValueFrom, Subscription } from 'rxjs';
import { IResult } from 'src/app/interfaces/result';
import { BlacklistService } from 'src/app/services/blacklist.service';
import { EditBlacklistComponent } from './edit-blacklist/edit-blacklist.component';
import { MatDialog } from '@angular/material/dialog';
import { FormControl } from '@angular/forms';
@Component({
selector: 'app-blacklist',
templateUrl: './blacklist.component.html',
styleUrls: ['./blacklist.component.scss']
})
export class BlacklistComponent implements OnInit, AfterViewInit, OnDestroy {
dataSource: MatTableDataSource = new MatTableDataSource()
displayedColumns: string[] = ['domain', 'action'];
@ViewChild(MatPaginator) paginator: MatPaginator | null = null
filterControl = new FormControl();
subFilterControl: Subscription | null = null;
constructor(
private blacklistService: BlacklistService,
private toast: MessageService,
public dialog: MatDialog
) { }
refresh(filter?: string): void {
firstValueFrom(this.blacklistService.fetch()).then((result: string[]) => {
if (filter) {
filter = filter.trim().toLocaleLowerCase();
let newArr: string[] = [];
if (result) {
if (result.length > 0) {
for (const item of result) {
if (item.trim().toLocaleLowerCase().indexOf(filter) >= 0) {
newArr.push(item);
}
}
result = newArr;
}
}
}
this.dataSource.data = result;
}).catch(() => {
this.dataSource.data = [];
});
}
ngOnInit(): void {
this.refresh()
this.subFilterControl = this.filterControl.valueChanges.pipe(debounceTime(500)).subscribe((value: string) => {
this.refresh(value);
});
}
ngOnDestroy(): void {
if (this.subFilterControl != null) {
this.subFilterControl.unsubscribe();
this.subFilterControl = null;
}
}
ngAfterViewInit() {
this.dataSource.paginator = this.paginator;
}
add(): void {
const dialogRef = this.dialog.open(EditBlacklistComponent, {
width: '250px',
data: null
});
firstValueFrom(dialogRef.afterClosed()).then(result => {
console.log('The dialog was closed');
console.log(result);
if (result != null) {
firstValueFrom(this.blacklistService.create(result)).then((res: IResult) => {
if (!res.result) {
this.toast.add({ severity: 'warn', summary: 'Что-то не так...', detail: 'Не удалось добавить домен' });
} else {
this.toast.add({ severity: 'success', summary: 'Успех', detail: 'Домен добавлен' });
}
});
}
});
}
delete(domain: string) {
firstValueFrom(this.blacklistService.remove(domain)).then((result: IResult) => {
if (result.result) {
this.refresh();
this.toast.add({ severity: 'success', summary: 'Успех' })
}
})
}
}
Это компонент для работы с черным списком. По сути мы просто обрабатываем события нажатия кнопок.
Ко всему прочему в этом компоненте открывается диалоговое окно. Само окно реализуется просто (в другом компоненте):
import { Component, Inject, OnInit } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
@Component({
selector: 'app-edit',
templateUrl: './edit-blacklist.component.html',
styleUrls: ['./edit-blacklist.component.scss']
})
export class EditBlacklistComponent implements OnInit {
form: FormGroup = new FormGroup({
domain: new FormControl()
});
constructor(
public dialogRef: MatDialogRef,
@Inject(MAT_DIALOG_DATA) public data: string | null,
) { }
ngOnInit(): void {
}
onCancel(): void {
this.data = null;
this.dialogRef.close(null);
}
onCreate(): void {
this.dialogRef.close(this.form.value.domain);
}
}
Модули
Чтобы Material правильно работал, нужно добавить используемые модули. Для этого создаем 2 модуля (так удобнее будет ими управлять):
npx ng g m modules/material
npx ng g m modules/primeng
material.module.ts:
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FlexLayoutModule } from '@angular/flex-layout';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatInputModule } from '@angular/material/input';
import { MatCardModule } from '@angular/material/card';
import { MatMenuModule } from '@angular/material/menu';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { MatDividerModule } from '@angular/material/divider';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatSelectModule } from '@angular/material/select';
import { MatOptionModule } from '@angular/material/core';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatListModule } from '@angular/material/list';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatDialogModule } from '@angular/material/dialog';
const modules = [
CommonModule,
FlexLayoutModule,
FormsModule,
ReactiveFormsModule,
BrowserAnimationsModule,
MatToolbarModule,
MatInputModule,
MatCardModule,
MatMenuModule,
MatIconModule,
MatButtonModule,
MatTableModule,
MatDividerModule,
MatSlideToggleModule,
MatSelectModule,
MatOptionModule,
MatProgressSpinnerModule,
MatSidenavModule,
MatListModule,
MatPaginatorModule,
MatSortModule,
MatDialogModule
];
@NgModule({
declarations: [],
imports: modules,
exports: modules
})
export class MaterialModule { }
Соответственно для PrimeNG добавляем те модули, которыми будем пользоваться.
Далее переходим в app.module.ts и добавляем модули:
imports: [
BrowserModule,
AppRoutingModule,
BrowserAnimationsModule,
MaterialModule,
PrimengModule,
ReactiveFormsModule,
FormsModule,
HttpClientModule
],
Так же добавим 2 провайдера:
providers: [
{
provide: HTTP_INTERCEPTORS,
useClass: InterceptorInterceptor,
multi: true,
},
MessageService
]
,
Ну вот и все
Получился такой простенький web-интерфейс. Полный код находится в git-репозитории.