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,408 @@
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.ReleaseOrchestrator.Scripts;
using StellaOps.Scheduler.WebService.Auth;
using StellaOps.Scheduler.WebService.Security;
namespace StellaOps.Scheduler.WebService.Scripts;
/// <summary>
/// Minimal API endpoints for the Scripts registry (/api/v2/scripts).
/// </summary>
internal static class ScriptsEndpoints
{
private static readonly JsonSerializerOptions s_json = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
public static IEndpointRouteBuilder MapScriptsEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v2/scripts")
.RequireAuthorization(SchedulerPolicies.Read)
.RequireTenant();
group.MapGet("/", ListScriptsAsync)
.WithName("ListScripts")
.WithDescription("List scripts with optional search, language, and visibility filters.");
group.MapGet("/{scriptId}", GetScriptAsync)
.WithName("GetScript")
.WithDescription("Get a single script by ID.");
group.MapPost("/", CreateScriptAsync)
.WithName("CreateScript")
.WithDescription("Create a new script.")
.RequireAuthorization(SchedulerPolicies.Operate);
group.MapPatch("/{scriptId}", UpdateScriptAsync)
.WithName("UpdateScript")
.WithDescription("Update an existing script.")
.RequireAuthorization(SchedulerPolicies.Operate);
group.MapDelete("/{scriptId}", DeleteScriptAsync)
.WithName("DeleteScript")
.WithDescription("Delete a script.")
.RequireAuthorization(SchedulerPolicies.Operate);
group.MapPost("/validate", ValidateScriptAsync)
.WithName("ValidateScript")
.WithDescription("Validate script syntax without saving.");
group.MapGet("/{scriptId}/versions", GetVersionsAsync)
.WithName("ListScriptVersions")
.WithDescription("List all versions of a script.");
group.MapGet("/{scriptId}/versions/{version:int}/content", GetVersionContentAsync)
.WithName("GetScriptVersionContent")
.WithDescription("Get the content of a specific script version.");
group.MapPost("/{scriptId}/check-compatibility", CheckCompatibilityAsync)
.WithName("CheckScriptCompatibility")
.WithDescription("Check script compatibility with a target environment.");
return routes;
}
// ── List ────────────────────────────────────────────────────────────────
private static async Task<IResult> ListScriptsAsync(
HttpContext httpContext,
[FromServices] IScriptRegistry registry,
CancellationToken ct)
{
try
{
var q = httpContext.Request.Query;
var criteria = new ScriptSearchCriteria
{
SearchText = q.TryGetValue("search", out var s) ? s.ToString() : null,
Language = q.TryGetValue("language", out var lang) ? ParseLanguage(lang.ToString()) : null,
Visibility = q.TryGetValue("visibility", out var vis) ? ParseVisibility(vis.ToString()) : null,
Limit = q.TryGetValue("limit", out var lim) && int.TryParse(lim.ToString(), out var l) ? l : 20,
Offset = q.TryGetValue("offset", out var off) && int.TryParse(off.ToString(), out var o) ? o : 0,
};
var result = await registry.SearchAsync(criteria, ct).ConfigureAwait(false);
var dtos = result.Scripts.Select(ToDto).ToArray();
return Results.Json(dtos, s_json);
}
catch (Exception ex) when (ex is ArgumentException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
// ── Get ─────────────────────────────────────────────────────────────────
private static async Task<IResult> GetScriptAsync(
string scriptId,
[FromServices] IScriptRegistry registry,
CancellationToken ct)
{
var script = await registry.GetScriptAsync(scriptId, ct).ConfigureAwait(false);
if (script is null) return Results.NotFound(new { error = $"Script '{scriptId}' not found." });
return Results.Json(ToDto(script), s_json);
}
// ── Create ──────────────────────────────────────────────────────────────
private static async Task<IResult> CreateScriptAsync(
HttpContext httpContext,
[FromServices] IScriptRegistry registry,
[FromServices] ITenantContextAccessor tenantAccessor,
CancellationToken ct)
{
try
{
var body = await JsonSerializer.DeserializeAsync<CreateScriptDto>(httpContext.Request.Body, s_json, ct).ConfigureAwait(false);
if (body is null) return Results.BadRequest(new { error = "Invalid request body." });
var tenant = tenantAccessor.GetTenant(httpContext);
var userId = httpContext.User.FindFirst("sub")?.Value ?? "anonymous";
var request = new CreateScriptRequest
{
Name = body.Name,
Description = body.Description,
Language = ParseLanguageRequired(body.Language),
Content = body.Content,
Tags = body.Tags?.ToImmutableArray(),
Visibility = ParseVisibilityRequired(body.Visibility),
};
var script = await registry.CreateScriptAsync(request, userId, ct: ct).ConfigureAwait(false);
// Update variables on the created script via store if provided
return Results.Json(ToDto(script), s_json, statusCode: StatusCodes.Status201Created);
}
catch (ScriptValidationException ex)
{
return Results.BadRequest(new { error = ex.Message, errors = ex.Errors });
}
catch (Exception ex) when (ex is ArgumentException or JsonException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
// ── Update ──────────────────────────────────────────────────────────────
private static async Task<IResult> UpdateScriptAsync(
string scriptId,
HttpContext httpContext,
[FromServices] IScriptRegistry registry,
[FromServices] ITenantContextAccessor tenantAccessor,
CancellationToken ct)
{
try
{
var body = await JsonSerializer.DeserializeAsync<UpdateScriptDto>(httpContext.Request.Body, s_json, ct).ConfigureAwait(false);
if (body is null) return Results.BadRequest(new { error = "Invalid request body." });
var tenant = tenantAccessor.GetTenant(httpContext);
var userId = httpContext.User.FindFirst("sub")?.Value ?? "anonymous";
var request = new UpdateScriptRequest
{
Name = body.Name,
Description = body.Description,
Content = body.Content,
Tags = body.Tags?.ToImmutableArray(),
Visibility = body.Visibility is not null ? ParseVisibilityRequired(body.Visibility) : null,
ChangeNote = body.ChangeNotes,
};
var updated = await registry.UpdateScriptAsync(scriptId, request, userId, ct).ConfigureAwait(false);
return Results.Json(ToDto(updated), s_json);
}
catch (ScriptNotFoundException)
{
return Results.NotFound(new { error = $"Script '{scriptId}' not found." });
}
catch (ScriptValidationException ex)
{
return Results.BadRequest(new { error = ex.Message, errors = ex.Errors });
}
catch (Exception ex) when (ex is ArgumentException or JsonException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
// ── Delete ──────────────────────────────────────────────────────────────
private static async Task<IResult> DeleteScriptAsync(
string scriptId,
[FromServices] IScriptRegistry registry,
CancellationToken ct)
{
var deleted = await registry.DeleteScriptAsync(scriptId, ct).ConfigureAwait(false);
return deleted
? Results.NoContent()
: Results.NotFound(new { error = $"Script '{scriptId}' not found." });
}
// ── Validate ────────────────────────────────────────────────────────────
private static async Task<IResult> ValidateScriptAsync(
HttpContext httpContext,
[FromServices] IScriptRegistry registry,
CancellationToken ct)
{
try
{
var body = await JsonSerializer.DeserializeAsync<ValidateScriptDto>(httpContext.Request.Body, s_json, ct).ConfigureAwait(false);
if (body is null) return Results.BadRequest(new { error = "Invalid request body." });
var language = ParseLanguageRequired(body.Language);
var result = await registry.ValidateAsync(language, body.Content, ct).ConfigureAwait(false);
var response = new
{
isValid = result.IsValid,
errors = result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).Select(d => new
{
line = d.Line,
column = d.Column,
message = d.Message,
severity = "error",
category = "syntax",
}).ToArray(),
warnings = result.Diagnostics.Where(d => d.Severity != DiagnosticSeverity.Error).Select(d => new
{
line = d.Line,
column = d.Column,
message = d.Message,
severity = d.Severity == DiagnosticSeverity.Warning ? "warning" : "info",
category = "syntax",
}).ToArray(),
};
return Results.Json(response, s_json);
}
catch (Exception ex) when (ex is ArgumentException or JsonException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
// ── Versions ────────────────────────────────────────────────────────────
private static async Task<IResult> GetVersionsAsync(
string scriptId,
[FromServices] IScriptRegistry registry,
CancellationToken ct)
{
var versions = await registry.GetScriptVersionsAsync(scriptId, ct).ConfigureAwait(false);
var dtos = versions.Select(v => new
{
version = v.Version,
contentHash = v.ContentHash,
createdBy = v.CreatedBy,
createdAt = v.CreatedAt,
changeNotes = v.ChangeNote,
}).ToArray();
return Results.Json(dtos, s_json);
}
private static async Task<IResult> GetVersionContentAsync(
string scriptId,
int version,
[FromServices] IScriptRegistry registry,
CancellationToken ct)
{
var ver = await registry.GetScriptVersionAsync(scriptId, version, ct).ConfigureAwait(false);
if (ver is null) return Results.NotFound(new { error = $"Version {version} not found for script '{scriptId}'." });
var dto = new
{
version = ver.Version,
contentHash = ver.ContentHash,
createdBy = ver.CreatedBy,
createdAt = ver.CreatedAt,
changeNotes = ver.ChangeNote,
content = ver.Content,
};
return Results.Json(dto, s_json);
}
// ── Compatibility ───────────────────────────────────────────────────────
private static async Task<IResult> CheckCompatibilityAsync(
string scriptId,
HttpContext httpContext,
[FromServices] IScriptRegistry registry,
CancellationToken ct)
{
// Stub: always compatible. Real implementation will check runtime/target matrix.
var script = await registry.GetScriptAsync(scriptId, ct).ConfigureAwait(false);
if (script is null) return Results.NotFound(new { error = $"Script '{scriptId}' not found." });
var response = new { isCompatible = true, issues = Array.Empty<object>() };
return Results.Json(response, s_json);
}
// ── DTO mapping ─────────────────────────────────────────────────────────
private static object ToDto(Script s) => new
{
id = s.Id,
name = s.Name,
description = s.Description,
language = s.Language.ToString().ToLowerInvariant(),
content = s.Content,
version = s.Version,
visibility = s.Visibility.ToString().ToLowerInvariant(),
ownerId = s.OwnerId,
teamId = s.TeamId,
tags = s.Tags.ToArray(),
variables = s.Variables.Select(v => new
{
name = v.Name,
description = v.Description,
isRequired = v.IsRequired,
defaultValue = v.DefaultValue,
isSecret = v.IsSecret,
}).ToArray(),
contentHash = s.ContentHash,
isSample = s.IsSample,
sampleCategory = s.SampleCategory,
createdAt = s.CreatedAt,
updatedAt = s.UpdatedAt,
};
// ── DTO types ───────────────────────────────────────────────────────────
private sealed record CreateScriptDto
{
public string Name { get; init; } = "";
public string Description { get; init; } = "";
public string Language { get; init; } = "";
public string Content { get; init; } = "";
public string Visibility { get; init; } = "private";
public string[]? Tags { get; init; }
public ScriptVariableDto[]? Variables { get; init; }
}
private sealed record UpdateScriptDto
{
public string? Name { get; init; }
public string? Description { get; init; }
public string? Content { get; init; }
public string? Visibility { get; init; }
public string[]? Tags { get; init; }
public ScriptVariableDto[]? Variables { get; init; }
public string? ChangeNotes { get; init; }
}
private sealed record ValidateScriptDto
{
public string Language { get; init; } = "";
public string Content { get; init; } = "";
}
private sealed record ScriptVariableDto
{
public string Name { get; init; } = "";
public string? Description { get; init; }
public bool IsRequired { get; init; }
public string? DefaultValue { get; init; }
public bool IsSecret { get; init; }
}
// ── Enum parsing ────────────────────────────────────────────────────────
private static ScriptLanguage? ParseLanguage(string? value) => value?.ToLowerInvariant() switch
{
"csharp" => ScriptLanguage.CSharp,
"python" => ScriptLanguage.Python,
"java" => ScriptLanguage.Java,
"go" => ScriptLanguage.Go,
"bash" => ScriptLanguage.Bash,
"typescript" => ScriptLanguage.TypeScript,
"powershell" => ScriptLanguage.PowerShell,
_ => null,
};
private static ScriptLanguage ParseLanguageRequired(string value)
=> ParseLanguage(value) ?? throw new ArgumentException($"Unknown language: '{value}'");
private static ScriptVisibility? ParseVisibility(string? value) => value?.ToLowerInvariant() switch
{
"private" => ScriptVisibility.Private,
"team" => ScriptVisibility.Team,
"organization" => ScriptVisibility.Organization,
"public" => ScriptVisibility.Public,
_ => null,
};
private static ScriptVisibility ParseVisibilityRequired(string value)
=> ParseVisibility(value) ?? throw new ArgumentException($"Unknown visibility: '{value}'");
}

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);
},
});
}
}