feat(scripts): scheduler scripts endpoint + script-picker component

Add ScriptsEndpoints to the Scheduler WebService for CRUD operations on
automation scripts. Add a reusable script-picker overlay component for
selecting scripts from the UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-07 15:34:08 +03:00
parent 1ac518282b
commit afbedf1c60
2 changed files with 573 additions and 0 deletions

View File

@@ -0,0 +1,165 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, inject, signal, OnInit } from '@angular/core';
import { take } from 'rxjs';
import { SCRIPTS_API } from '../../../core/api/scripts.client';
import type { Script } from '../../../core/api/scripts.models';
@Component({
selector: 'app-script-picker',
standalone: true,
template: `
<div class="script-picker">
<label class="script-picker__label">{{ label }}</label>
<div class="script-picker__control">
@if (selectedScript()) {
<div class="script-picker__selected">
<span class="script-picker__badge" [attr.data-lang]="selectedScript()!.language">{{ selectedScript()!.language }}</span>
<span class="script-picker__name">{{ selectedScript()!.name }}</span>
<span class="script-picker__version">v{{ selectedScript()!.version }}</span>
<button type="button" class="script-picker__clear" (click)="clearSelection()" aria-label="Clear selection">&times;</button>
</div>
} @else {
<button type="button" class="script-picker__browse" (click)="toggleDropdown()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline>
</svg>
Select script...
</button>
}
</div>
@if (dropdownOpen()) {
<div class="script-picker__dropdown">
<input type="text" class="script-picker__search" placeholder="Filter scripts..."
[value]="filterText()" (input)="filterText.set($any($event.target).value)" />
@if (loading()) {
<div class="script-picker__loading">Loading...</div>
} @else if (filteredScripts().length === 0) {
<div class="script-picker__empty">No scripts found</div>
} @else {
<ul class="script-picker__list">
@for (s of filteredScripts(); track s.id) {
<li class="script-picker__item" (click)="selectScript(s)">
<span class="script-picker__badge" [attr.data-lang]="s.language">{{ s.language }}</span>
<div class="script-picker__item-body">
<span class="script-picker__item-name">{{ s.name }}</span>
@if (s.description) {
<span class="script-picker__item-desc">{{ s.description }}</span>
}
</div>
<span class="script-picker__item-version">v{{ s.version }}</span>
</li>
}
</ul>
}
</div>
}
</div>
`,
styles: [`
.script-picker { position: relative; }
.script-picker__label { display: block; font-size: 0.6875rem; font-weight: 600; color: var(--color-text-secondary); margin-bottom: 0.25rem; text-transform: uppercase; letter-spacing: 0.04em; }
.script-picker__control { display: flex; align-items: center; }
.script-picker__browse { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.35rem 0.75rem; font-size: 0.75rem; font-family: inherit; font-weight: 500; border: 1px dashed var(--color-border-primary); border-radius: var(--radius-sm); background: var(--color-surface-secondary); color: var(--color-text-muted); cursor: pointer; transition: all 150ms; }
.script-picker__browse:hover { border-color: var(--color-brand-primary); color: var(--color-text-primary); background: var(--color-surface-tertiary); }
.script-picker__selected { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); background: var(--color-surface-secondary); }
.script-picker__name { font-weight: 600; color: var(--color-text-primary); }
.script-picker__version { font-family: var(--font-family-mono, monospace); font-size: 0.6875rem; color: var(--color-text-muted); }
.script-picker__clear { margin-left: 0.25rem; padding: 0 0.25rem; border: none; background: none; color: var(--color-text-muted); font-size: 1rem; cursor: pointer; line-height: 1; }
.script-picker__clear:hover { color: var(--color-status-error-text); }
.script-picker__dropdown { position: absolute; top: 100%; left: 0; z-index: 100; width: 100%; min-width: 320px; max-height: 280px; margin-top: 0.25rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); box-shadow: var(--shadow-lg, 0 4px 16px rgba(0,0,0,.12)); overflow: hidden; display: flex; flex-direction: column; }
.script-picker__search { width: 100%; padding: 0.5rem 0.6rem; border: none; border-bottom: 1px solid var(--color-border-primary); background: var(--color-surface-secondary); font-size: 0.75rem; font-family: inherit; color: var(--color-text-primary); outline: none; }
.script-picker__search::placeholder { color: var(--color-text-muted); }
.script-picker__loading, .script-picker__empty { padding: 1rem; text-align: center; font-size: 0.75rem; color: var(--color-text-muted); }
.script-picker__list { list-style: none; margin: 0; padding: 0; overflow-y: auto; max-height: 220px; }
.script-picker__item { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.6rem; cursor: pointer; transition: background 100ms; }
.script-picker__item:hover { background: var(--color-surface-tertiary); }
.script-picker__item-body { flex: 1; min-width: 0; }
.script-picker__item-name { display: block; font-size: 0.75rem; font-weight: 600; color: var(--color-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.script-picker__item-desc { display: block; font-size: 0.65rem; color: var(--color-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 260px; }
.script-picker__item-version { flex-shrink: 0; font-family: var(--font-family-mono, monospace); font-size: 0.65rem; color: var(--color-text-muted); }
.script-picker__badge { display: inline-block; flex-shrink: 0; padding: 0.0625rem 0.3rem; font-size: 0.5625rem; font-weight: 600; border-radius: var(--radius-sm); text-transform: uppercase; letter-spacing: 0.03em; background: var(--color-surface-tertiary); color: var(--color-text-secondary); }
.script-picker__badge[data-lang="bash"] { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.script-picker__badge[data-lang="csharp"] { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
.script-picker__badge[data-lang="python"] { background: #FFF3E0; color: #E65100; }
.script-picker__badge[data-lang="typescript"] { background: #E3F2FD; color: #1565C0; }
.script-picker__badge[data-lang="powershell"] { background: #EDE7F6; color: #6A1B9A; }
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ScriptPickerComponent implements OnInit {
private readonly api = inject(SCRIPTS_API);
@Input() label = 'Script';
@Input() selectedScriptId: string | null = null;
@Output() scriptSelected = new EventEmitter<Script | null>();
readonly scripts = signal<Script[]>([]);
readonly selectedScript = signal<Script | null>(null);
readonly loading = signal(false);
readonly dropdownOpen = signal(false);
readonly filterText = signal('');
readonly filteredScripts = () => {
const q = this.filterText().toLowerCase().trim();
if (!q) return this.scripts();
return this.scripts().filter(s =>
s.name.toLowerCase().includes(q) ||
s.language.toLowerCase().includes(q) ||
(s.description?.toLowerCase().includes(q) ?? false)
);
};
ngOnInit(): void {
this.loadScripts();
}
toggleDropdown(): void {
if (!this.dropdownOpen()) {
this.dropdownOpen.set(true);
if (this.scripts().length === 0) {
this.loadScripts();
}
} else {
this.dropdownOpen.set(false);
}
}
selectScript(script: Script): void {
this.selectedScript.set(script);
this.dropdownOpen.set(false);
this.filterText.set('');
this.scriptSelected.emit(script);
}
clearSelection(): void {
this.selectedScript.set(null);
this.scriptSelected.emit(null);
}
private loadScripts(): void {
this.loading.set(true);
this.api.listScripts({ limit: 50 }).pipe(take(1)).subscribe({
next: (scripts) => {
this.scripts.set(scripts);
if (this.selectedScriptId) {
const match = scripts.find(s => s.id === this.selectedScriptId);
if (match) this.selectedScript.set(match);
}
this.loading.set(false);
},
error: () => {
this.scripts.set([]);
this.loading.set(false);
},
});
}
}