Sources: scope the connector picker to the source type and require it

The Sources dialog listed every connector regardless of kind, so an HA source
could be bound to an MQTT broker and saved happily — then never ingest. The
picker was also clearable, and null meant opposite things per path: an HA
source with no connector is skipped outright, an MQTT one was subscribed on
every broker.

Filter candidates by the connector kind the source type needs (HA ->
HomeAssistant, MQTT/Tasmota -> MqttBroker; Tasmota has no endpoint kind of its
own), clear a selection invalidated by a type change, and preselect the sole
candidate so the single-broker case is one click. Offer a link to
/admin/connectors when none exists rather than an empty dropdown.

Save now rejects a missing or mismatched connector: such a source has no
connection details and would silently never ingest, so it should not be
possible to save it looking configured.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
This commit is contained in:
2026-07-18 11:21:21 +02:00
parent 813d96709e
commit 9bd0d60cc8
+76 -5
View File
@@ -214,21 +214,31 @@ else
<MudText Typo="Typo.h6">@(_sourceEdit.Id == 0 ? "New source" : "Edit source")</MudText> <MudText Typo="Typo.h6">@(_sourceEdit.Id == 0 ? "New source" : "Edit source")</MudText>
</TitleContent> </TitleContent>
<DialogContent> <DialogContent>
<MudSelect T="SourceType" @bind-Value="_sourceEdit.SourceType" Label="Source type" Class="mb-2"> <MudSelect T="SourceType" Value="_sourceEdit.SourceType" ValueChanged="OnSourceTypeChanged" Label="Source type" Class="mb-2">
@foreach (var type in Enum.GetValues<SourceType>()) @foreach (var type in Enum.GetValues<SourceType>())
{ {
<MudSelectItem T="SourceType" Value="type">@type</MudSelectItem> <MudSelectItem T="SourceType" Value="type">@type</MudSelectItem>
} }
</MudSelect> </MudSelect>
@if (_sourceEdit.SourceType is SourceType.HomeAssistant or SourceType.Mqtt or SourceType.Tasmota) @if (RequiredEndpointType(_sourceEdit.SourceType) is { } needed)
{ {
<MudSelect T="int?" @bind-Value="_sourceEdit.EndpointId" Label="Connector" Clearable="true" Class="mb-2"> if (ConnectorsFor(needed).Count == 0)
@foreach (var e in _endpoints)
{ {
<MudSelectItem T="int?" Value="@((int?)e.Id)">@e.Name (@e.Type)</MudSelectItem> <MudAlert Severity="Severity.Warning" Dense="true" Class="mb-2">
No @needed connector yet — <MudLink Href="/admin/connectors">create one</MudLink>
(set it up once; every source then just picks it).
</MudAlert>
}
else
{
<MudSelect T="int?" @bind-Value="_sourceEdit.EndpointId" Label="Connector" Required="true" Class="mb-2">
@foreach (var e in ConnectorsFor(needed))
{
<MudSelectItem T="int?" Value="@((int?)e.Id)">@e.Name</MudSelectItem>
} }
</MudSelect> </MudSelect>
} }
}
@if (_sourceEdit.SourceType == SourceType.HomeAssistant) @if (_sourceEdit.SourceType == SourceType.HomeAssistant)
{ {
<MudTextField @bind-Value="_sourceEdit.EntityId" Label="Entity id (e.g. sensor.house_power)" Class="mb-2" /> <MudTextField @bind-Value="_sourceEdit.EntityId" Label="Entity id (e.g. sensor.house_power)" Class="mb-2" />
@@ -305,6 +315,7 @@ else
if (source is null) if (source is null)
{ {
_sourceEdit = new SourceEdit(); _sourceEdit = new SourceEdit();
OnSourceTypeChanged(_sourceEdit.SourceType);
} }
else else
{ {
@@ -332,6 +343,28 @@ else
private async Task SaveSourceAsync() private async Task SaveSourceAsync()
{ {
// A live source without a matching connector has no connection details and would silently
// never ingest, so refuse it here rather than letting it look configured.
if (RequiredEndpointType(_sourceEdit.SourceType) is { } needed)
{
var selected = _endpoints.FirstOrDefault(e => e.Id == _sourceEdit.EndpointId);
if (selected is null)
{
Snackbar.Add($"Pick a {needed} connector for this {_sourceEdit.SourceType} source.", Severity.Error);
return;
}
if (selected.Type != needed)
{
Snackbar.Add($"'{selected.Name}' is a {selected.Type} connector; a {_sourceEdit.SourceType} source needs {needed}.", Severity.Error);
return;
}
}
else
{
_sourceEdit.EndpointId = null;
}
var config = new SourceConfig var config = new SourceConfig
{ {
EntityId = Trim(_sourceEdit.EntityId), EntityId = Trim(_sourceEdit.EntityId),
@@ -394,6 +427,44 @@ else
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
/// <summary>
/// Which connector kind a source type needs, or null if it needs none (manual/import/virtual).
/// Tasmota has no endpoint kind of its own — it is served by an MQTT broker connector.
/// </summary>
private static EndpointType? RequiredEndpointType(SourceType sourceType) => sourceType switch
{
SourceType.HomeAssistant => EndpointType.HomeAssistant,
SourceType.Mqtt or SourceType.Tasmota => EndpointType.MqttBroker,
_ => null,
};
private List<IngestionEndpoint> ConnectorsFor(EndpointType type) =>
_endpoints.Where(e => e.Type == type).ToList();
// Changing the source type can invalidate the chosen connector (an HA connector cannot serve an
// MQTT source), so drop a selection that no longer fits rather than saving a mismatched pair.
private void OnSourceTypeChanged(SourceType sourceType)
{
_sourceEdit.SourceType = sourceType;
var needed = RequiredEndpointType(sourceType);
var selected = _endpoints.FirstOrDefault(e => e.Id == _sourceEdit.EndpointId);
if (needed is null || (selected is not null && selected.Type != needed))
{
_sourceEdit.EndpointId = null;
}
// Sole candidate: preselect it, so the common single-broker / single-HA setup is one click.
if (needed is not null && _sourceEdit.EndpointId is null)
{
var candidates = _endpoints.Where(e => e.Type == needed).ToList();
if (candidates.Count == 1)
{
_sourceEdit.EndpointId = candidates[0].Id;
}
}
}
private sealed class SourceEdit private sealed class SourceEdit
{ {
public int Id { get; set; } public int Id { get; set; }