From 3b7d061e1c0f5b6d274fa4c3096b900b1a8f44cb Mon Sep 17 00:00:00 2001 From: Ashley Mannix Date: Mon, 13 Jul 2026 14:47:53 +1000 Subject: [PATCH 1/7] Initial Alert CLI implementation Assisted-By: Claude:claude-fable-5 --- README.md | 100 ++++++++++ .../Cli/Commands/Alert/CreateCommand.cs | 188 ++++++++++++++++++ src/SeqCli/Cli/Commands/Alert/ListCommand.cs | 55 +++++ .../Cli/Commands/Alert/RemoveCommand.cs | 69 +++++++ .../Cli/Commands/Alert/UpdateCommand.cs | 23 +++ .../Alert/AlertBasicsTestCase.cs | 37 ++++ .../Alert/AlertCreateTestCase.cs | 34 ++++ .../Shared/UpdateCommandTests.cs | 9 +- 8 files changed, 514 insertions(+), 1 deletion(-) create mode 100644 src/SeqCli/Cli/Commands/Alert/CreateCommand.cs create mode 100644 src/SeqCli/Cli/Commands/Alert/ListCommand.cs create mode 100644 src/SeqCli/Cli/Commands/Alert/RemoveCommand.cs create mode 100644 src/SeqCli/Cli/Commands/Alert/UpdateCommand.cs create mode 100644 test/SeqCli.EndToEnd/Alert/AlertBasicsTestCase.cs create mode 100644 test/SeqCli.EndToEnd/Alert/AlertCreateTestCase.cs diff --git a/README.md b/README.md index ccd3af90..00ed6150 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,11 @@ seqcli help apikey Available commands: + - `alert` + - [`alert create`](#alert-create) — Create an alert. + - [`alert list`](#alert-list) — List alerts. + - [`alert remove`](#alert-remove) — Remove an alert from the server. + - [`alert update`](#alert-update) — Update an existing alert. - `apikey` - [`apikey create`](#apikey-create) — Create an API key for automation or ingestion. - [`apikey list`](#apikey-list) — List available API keys. @@ -221,6 +226,101 @@ Available commands: - [`workspace remove`](#workspace-remove) — Remove a workspace from the server. - [`workspace update`](#workspace-update) — Update an existing workspace. +### `alert create` + +Create an alert. + +Example: + +``` +seqcli alert create -t 'Too many errors' --signal signal-m33302 --where "@Level = 'Error'" --select "count(*) as errors" --window 5m --having "errors > 10" --level Error --suppression-time 10m +``` + +| Option | Description | +| ------ | ----------- | +| `-t`, `--title=VALUE` | A title for the alert | +| `--description=VALUE` | A description for the alert | +| `--signal=VALUE` | A signal expression limiting the alert's input, for example `signal-1` or `signal-1,signal-2` | +| `--where=VALUE` | A predicate that selects the events the alert will consider | +| `--select=VALUE` | A measurement the alert condition will test, for example `count(*) as errors`; this argument can be used multiple times | +| `--group-by=VALUE` | An expression to group measurements by, for example `ServiceName`; this argument can be used multiple times | +| `--window=VALUE` | The measurement window over which the alert condition is evaluated, as a duration, for example `1m` or `1h` | +| `--having=VALUE` | The alert condition; a predicate over the grouped measurements, for example `errors > 10` | +| `--level=VALUE` | The notification level of the alert, for example `Warning` or `Error` | +| `--suppression-time=VALUE` | A duration for which notifications are suppressed after the alert triggers, for example `10m` or `1h` | +| `--notification-app=VALUE` | The id of an app instance that will be notified when the alert triggers; this argument can be used multiple times | +| `--protected` | Specify that the alert is editable only by administrators | +| `--disabled` | Create the alert in a disabled state; disabled alerts are not processed and do not send notifications | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | +| `--json` | Print output in newline-delimited JSON (the default is plain text) | +| `--no-color` | Don't colorize text output | +| `--force-color` | Force redirected output to have ANSI color (unless `--no-color` is also specified) | +| `--storage=VALUE` | The folder where `SeqCli.json` and other data will be stored; falls back to `SEQCLI_STORAGE_PATH` from the environment, then the `seqcli forwarder` service's configured storage path (Windows only), then the current user's home directory | + +### `alert list` + +List alerts. + +Example: + +``` +seqcli alert list +``` + +| Option | Description | +| ------ | ----------- | +| `-t`, `--title=VALUE` | The title of the alert(s) to list | +| `-i`, `--id=VALUE` | The id of a single alert to list | +| `-o`, `--owner=VALUE` | The id of the user to list alerts for; by default, shared alerts are listed | +| `--json` | Print output in newline-delimited JSON (the default is plain text) | +| `--no-color` | Don't colorize text output | +| `--force-color` | Force redirected output to have ANSI color (unless `--no-color` is also specified) | +| `--storage=VALUE` | The folder where `SeqCli.json` and other data will be stored; falls back to `SEQCLI_STORAGE_PATH` from the environment, then the `seqcli forwarder` service's configured storage path (Windows only), then the current user's home directory | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | + +### `alert remove` + +Remove an alert from the server. + +Example: + +``` +seqcli alert remove -t 'Too many errors' +``` + +| Option | Description | +| ------ | ----------- | +| `-t`, `--title=VALUE` | The title of the alert(s) to remove | +| `-i`, `--id=VALUE` | The id of a single alert to remove | +| `-o`, `--owner=VALUE` | The id of the user to remove alerts for; by default, shared alerts are removed | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | +| `--storage=VALUE` | The folder where `SeqCli.json` and other data will be stored; falls back to `SEQCLI_STORAGE_PATH` from the environment, then the `seqcli forwarder` service's configured storage path (Windows only), then the current user's home directory | + +### `alert update` + +Update an existing alert. + +Example: + +``` +seqcli alert update --json '{...}' +``` + +| Option | Description | +| ------ | ----------- | +| `--json=VALUE` | The updated alert in JSON format; this can be produced using `seqcli alert list --json` | +| `--json-stdin` | Read the updated alert as JSON from `STDIN` | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | +| `--storage=VALUE` | The folder where `SeqCli.json` and other data will be stored; falls back to `SEQCLI_STORAGE_PATH` from the environment, then the `seqcli forwarder` service's configured storage path (Windows only), then the current user's home directory | + ### `apikey create` Create an API key for automation or ingestion. diff --git a/src/SeqCli/Cli/Commands/Alert/CreateCommand.cs b/src/SeqCli/Cli/Commands/Alert/CreateCommand.cs new file mode 100644 index 00000000..a1895be3 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Alert/CreateCommand.cs @@ -0,0 +1,188 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Seq.Api.Model.Alerting; +using Seq.Api.Model.LogEvents; +using Seq.Api.Model.Shared; +using SeqCli.Api; +using SeqCli.Cli.Features; +using SeqCli.Config; +using SeqCli.Mapping; +using SeqCli.Signals; +using SeqCli.Syntax; +using SeqCli.Util; + +namespace SeqCli.Cli.Commands.Alert; + +[Command("alert", "create", "Create an alert", + Example = "seqcli alert create -t 'Too many errors' --signal signal-m33302 --where \"@Level = 'Error'\" " + + "--select \"count(*) as errors\" --window 5m --having \"errors > 10\" --level Error --suppression-time 10m")] +class CreateCommand : Command +{ + readonly ConnectionFeature _connection; + readonly OutputFormatFeature _output; + readonly StoragePathFeature _storagePath; + + readonly List _select = new(); + readonly List _groupBy = new(); + readonly List _notificationApps = new(); + + string? _title, _description, _signal, _where, _having, _window, _level, _suppressionTime; + bool _isProtected, _isDisabled; + + public CreateCommand() + { + Options.Add( + "t=|title=", + "A title for the alert", + t => _title = ArgumentString.Normalize(t)); + + Options.Add( + "description=", + "A description for the alert", + d => _description = ArgumentString.Normalize(d)); + + Options.Add( + "signal=", + "A signal expression limiting the alert's input, for example `signal-1` or `signal-1,signal-2`", + s => _signal = ArgumentString.Normalize(s)); + + Options.Add( + "where=", + "A predicate that selects the events the alert will consider", + w => _where = ArgumentString.Normalize(w)); + + Options.Add( + "select=", + "A measurement the alert condition will test, for example `count(*) as errors`; this argument can be used multiple times", + s => _select.Add(ArgumentString.Normalize(s) ?? throw new ArgumentException("Measurements require a value."))); + + Options.Add( + "group-by=", + "An expression to group measurements by, for example `ServiceName`; this argument can be used multiple times", + g => _groupBy.Add(ArgumentString.Normalize(g) ?? throw new ArgumentException("Groupings require a value."))); + + Options.Add( + "window=", + "The measurement window over which the alert condition is evaluated, as a duration, for example `1m` or `1h`", + w => _window = ArgumentString.Normalize(w)); + + Options.Add( + "having=", + "The alert condition; a predicate over the grouped measurements, for example `errors > 10`", + h => _having = ArgumentString.Normalize(h)); + + Options.Add( + "level=", + "The notification level of the alert, for example `Warning` or `Error`", + l => _level = ArgumentString.Normalize(l)); + + Options.Add( + "suppression-time=", + "A duration for which notifications are suppressed after the alert triggers, for example `10m` or `1h`", + s => _suppressionTime = ArgumentString.Normalize(s)); + + Options.Add( + "notification-app=", + "The id of an app instance that will be notified when the alert triggers; this argument can be used multiple times", + a => _notificationApps.Add(ArgumentString.Normalize(a) ?? throw new ArgumentException("Notification apps require a value."))); + + Options.Add( + "protected", + "Specify that the alert is editable only by administrators", + _ => _isProtected = true); + + Options.Add( + "disabled", + "Create the alert in a disabled state; disabled alerts are not processed and do not send notifications", + _ => _isDisabled = true); + + _connection = Enable(); + _output = Enable(); + _storagePath = Enable(); + } + + protected override async Task Run() + { + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); + + var alert = await connection.Alerts.TemplateAsync(); + alert.OwnerId = null; + + alert.Title = _title; + alert.Description = _description; + alert.IsProtected = _isProtected; + alert.IsDisabled = _isDisabled; + + if (_signal != null) + alert.SignalExpression = SignalExpressionParser.ParseExpression(_signal); + + if (_where != null) + alert.Where = (await connection.Expressions.ToStrictAsync(_where)).StrictExpression; + + if (_select.Any()) + { + alert.Select.Clear(); + foreach (var measurement in _select) + alert.Select.Add(ParseColumn(measurement)); + } + + if (_groupBy.Any()) + { + alert.GroupBy.Clear(); + foreach (var grouping in _groupBy) + alert.GroupBy.Add(new GroupingColumnPart { Value = grouping }); + } + + if (_window != null) + alert.TimeGrouping = DurationMoniker.ToTimeSpan(_window); + + if (_having != null) + alert.Having = _having; + + if (_level != null) + alert.NotificationLevel = Enum.Parse(LevelMapping.ToFullLevelName(_level)); + + if (_suppressionTime != null) + alert.SuppressionTime = DurationMoniker.ToTimeSpan(_suppressionTime); + + foreach (var appInstanceId in _notificationApps) + alert.NotificationChannels.Add(new NotificationChannelPart { NotificationAppInstanceId = appInstanceId }); + + alert = await connection.Alerts.AddAsync(alert); + + _output.GetOutputFormat(config).WriteEntity(alert); + + return 0; + } + + static ColumnPart ParseColumn(string measurement) + { + // Measurements are specified in the same ` as