1
0
mirror of https://github.com/xuthus83/pigallery2.git synced 2025-01-14 14:43:17 +08:00

Adding job schedules to config #569

This commit is contained in:
Patrik J. Braun 2022-12-30 15:35:07 +01:00
parent 9ac67ead63
commit 36d4641e9d
14 changed files with 660 additions and 67 deletions

View File

@ -1,6 +1,14 @@
/* eslint-disable @typescript-eslint/no-inferrable-types */
import 'reflect-metadata';
import {JobScheduleDTO, JobTrigger, JobTriggerType,} from '../../entities/job/JobScheduleDTO';
import {
AfterJobTrigger,
JobScheduleDTO,
JobTrigger,
JobTriggerType,
NeverJobTrigger,
PeriodicJobTrigger,
ScheduledJobTrigger,
} from '../../entities/job/JobScheduleDTO';
import {
ClientConfig,
ClientGPXCompressingConfig,
@ -334,8 +342,9 @@ export class ServerGPXCompressingConfig extends ClientGPXCompressingConfig {
{
name: $localize`Min time delta`,
priority: ConfigPriority.underTheHood,
unit: 'ms',
uiDisabled: (sc: ServerGPXCompressingConfig, c: ServerConfig) => !c.Map.enabled || !sc.enabled || !c.MetaFile.gpx
},
} as TAGS,
description: $localize`Filters out entry that are closer than this in time in milliseconds.`
})
minTimeDistance: number = 5000;
@ -485,13 +494,13 @@ export class ServerLogConfig {
}
@SubConfigClass()
export class NeverJobTrigger implements JobTrigger {
export class NeverJobTriggerConfig implements NeverJobTrigger {
@ConfigProperty({type: JobTriggerType})
readonly type = JobTriggerType.never;
}
@SubConfigClass()
export class ScheduledJobTrigger implements JobTrigger {
export class ScheduledJobTriggerConfig implements ScheduledJobTrigger {
@ConfigProperty({type: JobTriggerType})
readonly type = JobTriggerType.scheduled;
@ -500,17 +509,17 @@ export class ScheduledJobTrigger implements JobTrigger {
}
@SubConfigClass()
export class PeriodicJobTrigger implements JobTrigger {
export class PeriodicJobTriggerConfig implements PeriodicJobTrigger {
@ConfigProperty({type: JobTriggerType})
readonly type = JobTriggerType.periodic;
@ConfigProperty({type: 'unsignedInt', max: 7})
periodicity: number | undefined; // 0-6: week days 7 every day
periodicity: number | undefined = 7; // 0-6: week days 7 every day
@ConfigProperty({type: 'unsignedInt', max: 23 * 60 + 59})
atTime: number | undefined; // day time
atTime: number | undefined = 0; // day time
}
@SubConfigClass()
export class AfterJobTrigger implements JobTrigger {
export class AfterJobTriggerConfig implements AfterJobTrigger {
@ConfigProperty({type: JobTriggerType})
readonly type = JobTriggerType.after;
@ConfigProperty()
@ -528,42 +537,42 @@ export class JobScheduleConfig implements JobScheduleDTO {
@ConfigProperty()
jobName: string;
@ConfigProperty()
config: any = {};
config: Record<string, string | number | string[] | number[]> = {};
@ConfigProperty()
allowParallelRun: boolean;
allowParallelRun: boolean = false;
@ConfigProperty({
type: NeverJobTrigger,
type: NeverJobTriggerConfig,
typeBuilder: (v: JobTrigger) => {
const type = typeof v.type === 'number' ? v.type : JobTriggerType[v.type];
switch (type) {
case JobTriggerType.after:
return AfterJobTrigger;
return AfterJobTriggerConfig;
case JobTriggerType.never:
return NeverJobTrigger;
return NeverJobTriggerConfig;
case JobTriggerType.scheduled:
return ScheduledJobTrigger;
return ScheduledJobTriggerConfig;
case JobTriggerType.periodic:
return PeriodicJobTrigger;
return PeriodicJobTriggerConfig;
}
return null;
},
})
trigger:
| AfterJobTrigger
| NeverJobTrigger
| PeriodicJobTrigger
| ScheduledJobTrigger;
| AfterJobTriggerConfig
| NeverJobTriggerConfig
| PeriodicJobTriggerConfig
| ScheduledJobTriggerConfig;
constructor(
name: string,
jobName: string,
allowParallelRun: boolean,
trigger:
| AfterJobTrigger
| NeverJobTrigger
| PeriodicJobTrigger
| ScheduledJobTrigger,
config: any
| AfterJobTriggerConfig
| NeverJobTriggerConfig
| PeriodicJobTriggerConfig
| ScheduledJobTriggerConfig,
config: any = {},
allowParallelRun: boolean = false
) {
this.name = name;
this.jobName = jobName;
@ -600,43 +609,43 @@ export class ServerJobConfig {
new JobScheduleConfig(
DefaultsJobs[DefaultsJobs.Indexing],
DefaultsJobs[DefaultsJobs.Indexing],
false,
new NeverJobTrigger(),
{indexChangesOnly: true}
new NeverJobTriggerConfig(),
{indexChangesOnly: true} // set config explicitly so it not undefined on the UI
),
new JobScheduleConfig(
DefaultsJobs[DefaultsJobs['Preview Filling']],
DefaultsJobs[DefaultsJobs['Preview Filling']],
false,
new NeverJobTrigger(),
new AfterJobTriggerConfig(DefaultsJobs[DefaultsJobs['Indexing']]),
{}
),
new JobScheduleConfig(
DefaultsJobs[DefaultsJobs['Thumbnail Generation']],
DefaultsJobs[DefaultsJobs['Thumbnail Generation']],
false,
new AfterJobTrigger(DefaultsJobs[DefaultsJobs['Preview Filling']]),
new AfterJobTriggerConfig(DefaultsJobs[DefaultsJobs['Preview Filling']]),
{sizes: [240], indexedOnly: true}
),
new JobScheduleConfig(
DefaultsJobs[DefaultsJobs['Photo Converting']],
DefaultsJobs[DefaultsJobs['Photo Converting']],
false,
new AfterJobTrigger(DefaultsJobs[DefaultsJobs['Thumbnail Generation']]),
new AfterJobTriggerConfig(DefaultsJobs[DefaultsJobs['Thumbnail Generation']]),
{indexedOnly: true}
),
new JobScheduleConfig(
DefaultsJobs[DefaultsJobs['Video Converting']],
DefaultsJobs[DefaultsJobs['Video Converting']],
false,
new AfterJobTrigger(DefaultsJobs[DefaultsJobs['Photo Converting']]),
new AfterJobTriggerConfig(DefaultsJobs[DefaultsJobs['Photo Converting']]),
{indexedOnly: true}
),
new JobScheduleConfig(
DefaultsJobs[DefaultsJobs['GPX Compression']],
DefaultsJobs[DefaultsJobs['GPX Compression']],
new AfterJobTriggerConfig(DefaultsJobs[DefaultsJobs['Video Converting']]),
{indexedOnly: true}
),
new JobScheduleConfig(
DefaultsJobs[DefaultsJobs['Temp Folder Cleaning']],
DefaultsJobs[DefaultsJobs['Temp Folder Cleaning']],
false,
new AfterJobTrigger(DefaultsJobs[DefaultsJobs['Video Converting']]),
new AfterJobTriggerConfig(DefaultsJobs[DefaultsJobs['GPX Compression']]),
{indexedOnly: true}
),
];

View File

@ -10,7 +10,7 @@ export interface JobTrigger {
type: JobTriggerType;
}
export interface NeverJobTrigger {
export interface NeverJobTrigger extends JobTrigger {
type: JobTriggerType.never;
}
@ -33,7 +33,7 @@ export interface AfterJobTrigger extends JobTrigger {
export interface JobScheduleDTO {
name: string;
jobName: string;
config: any;
config: Record<string, string | number | string[] | number[]>;
allowParallelRun: boolean;
trigger:
| NeverJobTrigger

View File

@ -100,6 +100,9 @@ import {GallerySortingService} from './ui/gallery/navigator/sorting.service';
import {FilterService} from './ui/gallery/filter/filter.service';
import {TemplateComponent} from './ui/settings/template/template.component';
import {AbstractSettingsService} from './ui/settings/_abstract/abstract.settings.service';
import { WorkflowComponent } from './ui/settings/workflow/workflow.component';
import {JobProgressComponent} from './ui/settings/jobs/progress/job-progress.settings.component';
import {JobButtonComponent} from './ui/settings/jobs/button/job-button.settings.component';
@Injectable()
export class MyHammerConfig extends HammerGestureConfig {
@ -217,6 +220,8 @@ Marker.prototype.options.icon = iconDefault;
// Settings
SettingsEntryComponent,
TemplateComponent,
JobProgressComponent,
JobButtonComponent,
/* UserMangerSettingsComponent,
DatabaseSettingsComponent,
MapSettingsComponent,
@ -247,6 +252,7 @@ Marker.prototype.options.icon = iconDefault;
StringifySearchQuery,
FileDTOToPathPipe,
PhotoFilterPipe,
WorkflowComponent,
],
providers: [
{provide: HTTP_INTERCEPTORS, useClass: CSRFInterceptor, multi: true},

View File

@ -85,7 +85,14 @@
</nav>
</div>
<div class="col-md-10">
<app-settings-template #setting #server
<app-settings-template
*ngFor="let cp of configPaths"
#setting
#tmpl
icon="list"
[ConfigPath]="cp"
[hidden]="!tmpl.HasAvailableSettings"></app-settings-template>
<!-- <app-settings-template #setting #server
icon="list"
[ConfigPath]="'Server'"
[hidden]="!server.HasAvailableSettings"></app-settings-template>
@ -140,7 +147,7 @@
<app-settings-template #setting #indexing
icon="pie-chart"
[ConfigPath]="'Indexing'"
[hidden]="!indexing.HasAvailableSettings"></app-settings-template>
[hidden]="!indexing.HasAvailableSettings"></app-settings-template>-->
</div>
</div>

View File

@ -7,10 +7,9 @@ import {NavigationService} from '../../model/navigation.service';
import {ISettingsComponent} from '../settings/_abstract/ISettingsComponent';
import {PageHelper} from '../../model/page.helper';
import {SettingsService} from '../settings/settings.service';
import {CookieNames} from '../../../../common/CookieNames';
import {CookieService} from 'ngx-cookie-service';
import {ConfigPriority} from '../../../../common/config/public/ClientConfig';
import {Utils} from '../../../../common/Utils';
import {WebConfig} from '../../../../common/config/private/WebConfig';
@Component({
selector: 'app-admin',
@ -24,6 +23,7 @@ export class AdminComponent implements OnInit, AfterViewInit {
contents: ISettingsComponent[] = [];
configPriorities: { key: number; value: string; }[];
public readonly ConfigPriority = ConfigPriority;
public readonly configPaths: string[] = [];
constructor(
private authService: AuthenticationService,
@ -32,6 +32,7 @@ export class AdminComponent implements OnInit, AfterViewInit {
public settingsService: SettingsService,
) {
this.configPriorities = Utils.enumToArray(ConfigPriority);
this.configPaths = Object.keys((new WebConfig()).State);
}
ngAfterViewInit(): void {

View File

@ -1,12 +1,4 @@
import {
Directive,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
ViewChild,
} from '@angular/core';
import {Directive, Input, OnDestroy, OnInit, ViewChild,} from '@angular/core';
import {AuthenticationService} from '../../../model/network/authentication.service';
import {UserRoles} from '../../../../../common/entities/UserDTO';
import {Utils} from '../../../../../common/Utils';
@ -20,12 +12,8 @@ import {WebConfig} from '../../../../../common/config/private/WebConfig';
import {FormControl} from '@angular/forms';
import {ConfigPriority, TAGS} from '../../../../../common/config/public/ClientConfig';
import {SettingsService} from '../settings.service';
import {IConfigClass} from 'typeconfig/common';
import {IConfigClassPrivate} from '../../../../../../node_modules/typeconfig/src/decorators/class/IConfigClass';
import {IPropertyMetadata} from '../../../../../../node_modules/typeconfig/src/decorators/property/IPropertyState';
import {IWebConfigClass, IWebConfigClassPrivate} from '../../../../../../node_modules/typeconfig/src/decorators/class/IWebConfigClass';
import {IWebConfigClassPrivate} from '../../../../../../node_modules/typeconfig/src/decorators/class/IWebConfigClass';
import {WebConfigClassBuilder} from '../../../../../../node_modules/typeconfig/src/decorators/builders/WebConfigClassBuilder';
import {ServerConfig} from '../../../../../common/config/private/PrivateConfig';
interface ConfigState<T = unknown> {
value: T;
@ -73,13 +61,13 @@ export abstract class SettingsComponentDirective<
public error: string = null;
public changed = false;
public states: RecursiveState = {} as RecursiveState;
protected name: string;
private subscription: Subscription = null;
private settingsSubscription: Subscription = null;
protected sliceFN?: (s: WebConfig) => T;
protected constructor(
protected name: string,
protected authService: AuthenticationService,
private navigation: NavigationService,
public settingsService: AbstractSettingsService,
@ -128,7 +116,8 @@ export abstract class SettingsComponentDirective<
// if all sub elements are hidden, hide the parent too.
if (state.isConfigType) {
if (Object.keys(state.value.__state).findIndex(k => !st.value.__state[k].shouldHide()) === -1) {
if (state.value.__state &&
Object.keys(state.value.__state).findIndex(k => !st.value.__state[k].shouldHide()) === -1) {
return true;
}
}
@ -136,14 +125,17 @@ export abstract class SettingsComponentDirective<
if (state.isConfigArrayType) {
for (let i = 0; i < state.value?.length; ++i) {
if (Object.keys(state.value[i].__state).findIndex(k => !(st.value[i].__state[k].shouldHide && st.value[i].__state[k].shouldHide())) === -1) {
if (state.value[i].__state &&
Object.keys(state.value[i].__state).findIndex(k => !(st.value[i].__state[k].shouldHide && st.value[i].__state[k].shouldHide())) === -1) {
return true;
}
}
return false;
}
return (
state.tags?.priority > this.globalSettingsService.configPriority &&
(state.tags?.priority > this.globalSettingsService.configPriority ||
(this.globalSettingsService.configPriority === ConfigPriority.basic &&
state.tags?.dockerSensitive && this.globalSettingsService.settings.value.Environment.isDocker)) && //if this value should not change in Docker, lets hide it
Utils.equalsFilter(state.value, state.default,
['__propPath', '__created', '__prototype', '__rootConfig']) &&
Utils.equalsFilter(state.original, state.default,

View File

@ -24,7 +24,6 @@
[disabled]="state.readonly || Disabled"
(change)="onChange($event)"
placeholder="Search Query">
</app-gallery-search-field>
<div class="input-group">
@ -35,6 +34,7 @@
Type !== 'SearchQuery' &&
ArrayType !== 'MapLayers' &&
ArrayType !== 'NavigationLinkConfig' &&
ArrayType !== 'JobScheduleConfig' &&
ArrayType !== 'UserConfig'"
[type]="type" [min]="state.min" [max]="state.max" class="form-control"
[placeholder]="PlaceHolder"
@ -82,6 +82,17 @@
[(ngModel)]="state.value">
</bSwitch>
<app-settings-workflow
class="w-100"
*ngIf="ArrayType === 'JobScheduleConfig'"
[(ngModel)]="state.value"
[id]="idName"
[name]="idName"
[title]="title"
(ngModelChange)="onChange($event)">
</app-settings-workflow>
<ng-container *ngIf="ArrayType === 'MapLayers'">
<div class="container">
<table class="table">

View File

@ -19,7 +19,7 @@ import {
} from '../../../../../../common/config/public/ClientConfig';
import {SettingsService} from '../../settings.service';
import {WebConfig} from '../../../../../../common/config/private/WebConfig';
import {UserConfig} from '../../../../../../common/config/private/PrivateConfig';
import {JobScheduleConfig, UserConfig} from '../../../../../../common/config/private/PrivateConfig';
interface IState {
shouldHide(): boolean;
@ -78,7 +78,6 @@ export class SettingsEntryComponent
title: string;
idName: string;
private readonly GUID = Utils.GUID();
public NavigationLinkTypesEnum = Utils.enumToArray(NavigationLinkTypes);
NavigationLinkTypes = NavigationLinkTypes;
constructor(private searchQueryParserService: SearchQueryParserService,
@ -142,6 +141,10 @@ export class SettingsEntryComponent
if (this.state.arrayType === UserConfig) {
return 'UserConfig';
}
if (this.state.arrayType === JobScheduleConfig) {
return 'JobScheduleConfig';
}
this.state.arrayType;
}

View File

@ -7,15 +7,18 @@ import {WebConfigClassBuilder} from 'typeconfig/src/decorators/builders/WebConfi
import {ConfigPriority} from '../../../../common/config/public/ClientConfig';
import {CookieNames} from '../../../../common/CookieNames';
import {CookieService} from 'ngx-cookie-service';
import {JobDTO} from '../../../../common/entities/job/JobDTO';
@Injectable()
export class SettingsService {
public configPriority = ConfigPriority.basic;
public settings: BehaviorSubject<WebConfig>;
private fetchingSettings = false;
public availableJobs: BehaviorSubject<JobDTO[]>;
constructor(private networkService: NetworkService,
private cookieService: CookieService) {
this.availableJobs = new BehaviorSubject([]);
this.settings = new BehaviorSubject<WebConfig>(new WebConfig());
this.getSettings().catch(console.error);
@ -27,6 +30,12 @@ export class SettingsService {
}
public async getAvailableJobs(): Promise<void> {
this.availableJobs.next(
await this.networkService.getJson<JobDTO[]>('/admin/jobs/available')
);
}
public async getSettings(): Promise<void> {
if (this.fetchingSettings === true) {
return;

View File

@ -25,7 +25,6 @@ export class TemplateComponent extends SettingsComponentDirective<any> implement
globalSettingsService: SettingsService
) {
super(
`Template`,
authService,
navigation,
settingsService,

View File

@ -0,0 +1,257 @@
<div [hidden]="!error" class="alert alert-danger" role="alert"><strong>Error: </strong>{{error}}</div>
<div *ngFor="let schedule of sortedSchedules() as sortedSchedules; let i= index">
<div class="card bg-light mt-2 mb-2 no-changed-settings {{shouldIdent(schedule,sortedSchedules[i-1])? 'ms-4' : ''}}">
<div class="card-header">
<div class="d-flex justify-content-between">
<div (click)="showDetails[schedule.name]=!showDetails[schedule.name]">
<span class="oi oi-chevron-{{showDetails[schedule.name] ? 'bottom' : 'right'}}"></span>
{{schedule.name}}
<ng-container [ngSwitch]="schedule.trigger.type">
<ng-container *ngSwitchCase="JobTriggerType.periodic">
<span class="badge bg-primary" i18n>every</span>
{{periods[$any(schedule.trigger).periodicity]}} {{atTimeLocal($any(schedule.trigger).atTime) | date:"HH:mm (z)"}}
</ng-container>
<ng-container
*ngSwitchCase="JobTriggerType.scheduled">@{{$any(schedule.trigger).time | date:"medium"}}</ng-container>
<span class="badge bg-secondary" *ngSwitchCase="JobTriggerType.never" i18n>never</span>
<ng-container *ngSwitchCase="JobTriggerType.after">
<span class="badge bg-primary" i18n>after</span>
{{$any(schedule.trigger).afterScheduleName}}
</ng-container>
</ng-container>
</div>
<div>
<button class="btn btn-danger ms-0" (click)="remove(schedule)"><span
class="oi oi-trash"></span>
</button>
<app-settings-job-button class="ms-md-2 mt-2 mt-md-0"
(jobError)="error=$event"
[allowParallelRun]="schedule.allowParallelRun"
[jobName]="schedule.jobName" [config]="schedule.config"
[shortName]="true"></app-settings-job-button>
</div>
</div>
</div>
<div class="card-body" [hidden]="!showDetails[schedule.name]">
<div class="row">
<div class="col-md-12">
<div class="mb-1 row">
<label class="col-md-2 control-label" [for]="'jobName'+i" i18n>Job:</label>
<div class="col-md-4">
{{backendTextService.getJobName(schedule.jobName)}}
</div>
<div class="col-md-6">
<app-settings-job-button class="float-end"
[jobName]="schedule.jobName"
[allowParallelRun]="schedule.allowParallelRun"
(jobError)="error=$event"
[config]="schedule.config"></app-settings-job-button>
</div>
</div>
<div class="mb-1 row">
<label class="col-md-2 control-label" [for]="'repeatType'+i" i18n>Periodicity:</label>
<div class="col-md-10">
<select class="form-select" [(ngModel)]="schedule.trigger.type"
(ngModelChange)="jobTriggerTypeChanged($event,schedule); onChange($event);"
[name]="'repeatType'+i" required>
<option *ngFor="let jobTrigger of JobTriggerTypeMap"
[ngValue]="jobTrigger.key">{{jobTrigger.value}}
</option>
</select>
<small class="form-text text-muted"
i18n>Set the time to run the job.
</small>
</div>
</div>
<div class="mb-3 row" *ngIf="schedule.trigger.type == JobTriggerType.after">
<label class="col-md-2 control-label" [for]="'triggerAfter'+i" i18n>After:</label>
<div class="col-md-10">
<select class="form-select"
[(ngModel)]="schedule.trigger.afterScheduleName"
(ngModelChange)="onChange($event)"
[name]="'triggerAfter'+i" required>
<ng-container *ngFor="let sch of sortedSchedules">
<option *ngIf="sch.name !== schedule.name"
[ngValue]="sch.name">{{sch.name}}
</option>
</ng-container>
</select>
<small class="form-text text-muted"
i18n>The job will run after that job finishes.
</small>
</div>
</div>
<div class="mb-3 row" *ngIf="schedule.trigger.type == JobTriggerType.scheduled">
<label class="col-md-2 control-label" [for]="'triggerTime'+i" i18n>At:</label>
<div class="col-md-10">
<app-timestamp-datepicker
[name]="'triggerTime'+i"
(timestampChange)="onChange($event)"
[(timestamp)]="schedule.trigger.time"></app-timestamp-datepicker>
</div>
</div>
<div class="mb-3 row" *ngIf="schedule.trigger.type == JobTriggerType.periodic">
<label class="col-md-2 control-label" [for]="'periodicity'+i" i18n>At:</label>
<div class="col-md-10">
<select
class="form-select"
[(ngModel)]="schedule.trigger.periodicity"
(ngModelChange)="onChange($event)"
[name]="'periodicity' + i"
required>
<option *ngFor="let period of periods; let i = index"
[ngValue]="i">
<ng-container i18n>every</ng-container>
{{period}}
</option>
</select>
<app-timestamp-timepicker
[name]="'atTime'+i"
(timestampChange)="onChange($event)"
[(timestamp)]="schedule.trigger.atTime"></app-timestamp-timepicker>
</div>
</div>
<div class="mb-3 row">
<label class="col-md-2 control-label" [for]="'allowParallelRun'+'_'+i" i18n>Allow parallel run</label>
<div class="col-md-10">
<bSwitch
class="switch"
[name]="'allowParallelRun'+'_'+i"
[id]="'allowParallelRun'+'_'+i"
switch-on-color="primary"
[switch-inverse]="true"
switch-off-text="Disabled"
switch-on-text="Enabled"
i18n-switch-off-text i18n-switch-on-text
[switch-handle-width]="100"
[switch-label-width]="20"
(ngModelChange)="onChange($event)"
[(ngModel)]="schedule.allowParallelRun">
</bSwitch>
<small class="form-text text-muted ms-2"
i18n>Enables the job to start even if another job is already running.
</small>
</div>
</div>
</div>
</div>
<ng-container *ngIf="getConfigTemplate(schedule.jobName) ">
<hr/>
<div *ngFor="let configEntry of getConfigTemplate(schedule.jobName)">
<div class="mb-3 row">
<label class="col-md-2 control-label"
[for]="configEntry.id+'_'+i">{{backendTextService.get(configEntry.name)}}</label>
<div class="col-md-10">
<ng-container [ngSwitch]="configEntry.type">
<ng-container *ngSwitchCase="'boolean'">
<bSwitch
class="switch"
[name]="configEntry.id+'_'+i"
[id]="configEntry.id+'_'+i"
switch-on-color="primary"
[switch-inverse]="true"
switch-off-text="Disabled"
switch-on-text="Enabled"
i18n-switch-off-text i18n-switch-on-text
[switch-handle-width]="100"
[switch-label-width]="20"
(ngModelChange)="onChange($event)"
[(ngModel)]="schedule.config[configEntry.id]">
</bSwitch>
</ng-container>
<ng-container *ngSwitchCase="'string'">
<input type="text" class="form-control" [name]="configEntry.id+'_'+i"
[id]="configEntry.id+'_'+i"
(ngModelChange)="onChange($event)"
[(ngModel)]="schedule.config[configEntry.id]" required>
</ng-container>
<ng-container *ngSwitchCase="'number'">
<input type="number" class="form-control" [name]="configEntry.id+'_'+i"
[id]="configEntry.id+'_'+i"
(ngModelChange)="onChange($event)"
[(ngModel)]="schedule.config[configEntry.id]" required>
</ng-container>
<ng-container *ngSwitchCase="'number-array'">
<input type="text" class="form-control"
[name]="configEntry.id+'_'+i"
[id]="configEntry.id+'_'+i"
(ngModelChange)="setNumberArray(schedule.config,configEntry.id,$event); onChange($event);"
[ngModel]="getNumberArray($any(schedule.config),configEntry.id)" required>
</ng-container>
</ng-container>
<small class="form-text text-muted ms-2">
<ng-container *ngIf="configEntry.type == 'number-array'" i18n>';' separated integers.
</ng-container>{{backendTextService.get(configEntry.description)}}
</small>
</div>
</div>
</div>
</ng-container>
</div>
<app-settings-job-progress
class="card-footer bg-transparent"
*ngIf="getProgress(schedule)"
[progress]="getProgress(schedule)">
</app-settings-job-progress>
</div>
</div>
<button class="btn btn-primary float-end mt-2"
(click)="prepareNewJob()" i18n>+ Add Job
</button>
<!-- Modal -->
<div bsModal #jobModal="bs-modal" class="modal fade" id="jobModal" tabindex="-1" role="dialog"
aria-labelledby="jobModalLabel">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="jobModalLabel" i18n>Add new job</h5>
<button type="button" class="btn-close" (click)="jobModal.hide()" data-dismiss="modal" aria-label="Close">
</button>
</div>
<form #jobModalForm="ngForm">
<div class="modal-body">
<select class="form-select"
(change)="jobTypeChanged(newSchedule)"
[(ngModel)]="newSchedule.jobName"
name="newJobName" required>
<option *ngFor="let availableJob of settingsService.availableJobs | async"
[ngValue]="availableJob.Name">{{backendTextService.getJobName(availableJob.Name)}}
</option>
</select>
<small class="form-text text-muted"
i18n>Select a job to schedule.
</small>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="jobModal.hide()" i18n>Close</button>
<button type="button" class="btn btn-primary" data-dismiss="modal"
(click)="addNewJob()"
[disabled]="!jobModalForm.form.valid" i18n>Add Job
</button>
</div>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,294 @@
import {Component, OnDestroy, OnInit, QueryList, ViewChildren} from '@angular/core';
import {forwardRef} from '../../../../../../node_modules/@angular/core';
import {ModalDirective} from 'ngx-bootstrap/modal';
import {
AfterJobTrigger,
JobScheduleDTO,
JobScheduleDTOUtils,
JobTriggerType,
PeriodicJobTrigger,
ScheduledJobTrigger
} from '../../../../../common/entities/job/JobScheduleDTO';
import {ScheduledJobsService} from '../scheduled-jobs.service';
import {BackendtextService} from '../../../model/backendtext.service';
import {SettingsService} from '../settings.service';
import {ConfigTemplateEntry} from '../../../../../common/entities/job/JobDTO';
import {JobProgressDTO, JobProgressStates} from '../../../../../common/entities/job/JobProgressDTO';
import {
AfterJobTriggerConfig,
JobScheduleConfig,
NeverJobTriggerConfig,
PeriodicJobTriggerConfig,
ScheduledJobTriggerConfig
} from '../../../../../common/config/private/PrivateConfig';
import {
ControlValueAccessor,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator
} from '../../../../../../node_modules/@angular/forms';
@Component({
selector: 'app-settings-workflow',
templateUrl: './workflow.component.html',
styleUrls: ['./workflow.component.css'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => WorkflowComponent),
multi: true,
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => WorkflowComponent),
multi: true,
},
],
})
export class WorkflowComponent implements ControlValueAccessor, Validator, OnInit, OnDestroy {
public schedules: JobScheduleConfig[] = [];
@ViewChildren('jobModal') public jobModalQL: QueryList<ModalDirective>;
public disableButtons = false;
public JobTriggerTypeMap: { key: number; value: string }[];
public JobTriggerType = JobTriggerType;
public periods: string[] = [];
public showDetails: { [key: string]: boolean } = {};
public JobProgressStates = JobProgressStates;
public newSchedule: JobScheduleDTO = {
name: '',
config: null,
jobName: '',
trigger: {
type: JobTriggerType.never,
},
allowParallelRun: false,
};
error: string;
constructor(
public settingsService: SettingsService,
public jobsService: ScheduledJobsService,
public backendTextService: BackendtextService,
) {
this.JobTriggerTypeMap = [
{key: JobTriggerType.after, value: $localize`after`},
{key: JobTriggerType.never, value: $localize`never`},
{key: JobTriggerType.periodic, value: $localize`periodic`},
{key: JobTriggerType.scheduled, value: $localize`scheduled`},
];
this.periods = [
$localize`Monday`, // 0
$localize`Tuesday`, // 1
$localize`Wednesday`, // 2
$localize`Thursday`,
$localize`Friday`,
$localize`Saturday`,
$localize`Sunday`,
$localize`day`,
]; // 7
}
atTimeLocal(atTime: number): Date {
const d = new Date();
d.setUTCHours(Math.floor(atTime / 60));
d.setUTCMinutes(Math.floor(atTime % 60));
return d;
}
getConfigTemplate(JobName: string): ConfigTemplateEntry[] {
const job = this.settingsService.availableJobs.value.find(
(t) => t.Name === JobName
);
if (job && job.ConfigTemplate && job.ConfigTemplate.length > 0) {
return job.ConfigTemplate;
}
return null;
}
ngOnInit(): void {
this.jobsService.subscribeToProgress();
this.settingsService.getAvailableJobs().catch(console.error);
}
ngOnDestroy(): void {
this.jobsService.unsubscribeFromProgress();
}
remove(schedule: JobScheduleDTO): void {
this.schedules.splice(
this.schedules.indexOf(schedule),
1
);
}
jobTypeChanged(schedule: JobScheduleDTO): void {
const job = this.settingsService.availableJobs.value.find(
(t) => t.Name === schedule.jobName
);
schedule.config = schedule.config || {};
if (job.ConfigTemplate) {
job.ConfigTemplate.forEach(
(ct) => (schedule.config[ct.id] = ct.defaultValue)
);
}
}
jobTriggerTypeChanged(
triggerType: JobTriggerType,
schedule: JobScheduleDTO
): void {
switch (triggerType) {
case JobTriggerType.never:
schedule.trigger = new NeverJobTriggerConfig();
break;
case JobTriggerType.scheduled:
schedule.trigger = new ScheduledJobTriggerConfig();
(schedule.trigger as unknown as ScheduledJobTrigger).time = Date.now();
break;
case JobTriggerType.periodic:
schedule.trigger = new PeriodicJobTriggerConfig();
break;
case JobTriggerType.after:
schedule.trigger = new AfterJobTriggerConfig();
if (!(schedule.trigger as unknown as AfterJobTrigger).afterScheduleName && this.schedules.length > 1) {
(schedule.trigger as unknown as AfterJobTrigger).afterScheduleName = this.schedules.find(s => s.name !== schedule.name).name;
}
break;
}
}
setNumberArray(configElement: any, id: string, value: string): void {
value = value.replace(new RegExp(',', 'g'), ';');
value = value.replace(new RegExp(' ', 'g'), ';');
configElement[id] = value
.split(';')
.map((s: string) => parseInt(s, 10))
.filter((i: number) => !isNaN(i) && i > 0);
}
getNumberArray(configElement: Record<string, number[]>, id: string): string {
return configElement[id] ? configElement[id].join('; ') : '';
}
public shouldIdent(curr: JobScheduleDTO, prev: JobScheduleDTO): boolean {
return (
curr &&
curr.trigger.type === JobTriggerType.after &&
prev &&
prev.name === curr.trigger.afterScheduleName
);
}
public sortedSchedules(): JobScheduleDTO[] {
return (this.schedules || [])
.slice()
.sort((a: JobScheduleDTO, b: JobScheduleDTO) => {
return (
this.getNextRunningDate(a, this.schedules) -
this.getNextRunningDate(b, this.schedules)
);
});
}
prepareNewJob(): void {
const jobName = this.settingsService.availableJobs.value[0].Name;
this.newSchedule = new JobScheduleConfig('new job',
jobName,
new NeverJobTriggerConfig());
// setup job specific config
const job = this.settingsService.availableJobs.value.find(
(t) => t.Name === jobName
);
this.newSchedule.config = this.newSchedule.config || {};
if (job.ConfigTemplate) {
job.ConfigTemplate.forEach(
(ct) => (this.newSchedule.config[ct.id] = ct.defaultValue)
);
}
this.jobModalQL.first.show();
}
addNewJob(): void {
// make unique job name
const jobName = this.newSchedule.jobName;
const count = this.schedules.filter(
(s: JobScheduleDTO) => s.jobName === jobName
).length;
this.newSchedule.name =
count === 0
? jobName
: this.backendTextService.getJobName(jobName) + ' ' + (count + 1);
this.schedules.push(this.newSchedule);
this.jobModalQL.first.hide();
this.onChange(null); // trigger change detection after adding new job
}
getProgress(schedule: JobScheduleDTO): JobProgressDTO {
return this.jobsService.getProgress(schedule);
}
private getNextRunningDate(
sch: JobScheduleDTO,
list: JobScheduleDTO[],
depth = 0
): number {
if (depth > list.length) {
return 0;
}
if (sch.trigger.type === JobTriggerType.never) {
return (
list
.map((s) => s.name)
.sort()
.indexOf(sch.name) * -1
);
}
if (sch.trigger.type === JobTriggerType.after) {
const parent = list.find(
(s) => s.name === (sch.trigger as AfterJobTrigger).afterScheduleName
);
if (parent) {
return this.getNextRunningDate(parent, list, depth + 1) + 0.001;
}
}
const d = JobScheduleDTOUtils.getNextRunningDate(new Date(), sch);
return d !== null ? d.getTime() : 0;
}
validate(): ValidationErrors {
return null;
}
public onChange = (value: unknown): void => {
// empty
};
public onTouched = (): void => {
// empty
};
public writeValue(obj: JobScheduleConfig[]): void {
this.schedules = obj;
}
public registerOnChange(fn: (v: JobScheduleConfig[]) => void): void {
this.onChange = () => fn(this.schedules);
}
public registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
}

View File

@ -13,11 +13,16 @@ a {
margin-left: -4px;
}
.changed-settings .bootstrap-switch {
.changed-settings.bootstrap-switch {
border-color: var(--bs-primary);
border-width: 1px;
}
.changed-settings .no-changed-settings .bootstrap-switch {
border-color: var(--bs-gray-400) !important;
}
#toast-container > div {
opacity: 1;
}