diff --git a/README.md b/README.md index 1f46c7f3..c907920a 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ KubeUI is a user interface for Kubernetes. - Namespace specific Resource Permissions ### Resource Specific Features +- Node + - Codon/UnCordon + - Drain - Pod - View Logs - View Console diff --git a/src/KubeUI/App.axaml.cs b/src/KubeUI/App.axaml.cs index 7f40e8d1..0dbc4efc 100644 --- a/src/KubeUI/App.axaml.cs +++ b/src/KubeUI/App.axaml.cs @@ -1,6 +1,7 @@ using System; using System.Reflection; using System.Runtime.InteropServices; +using Avalonia.Controls.Notifications; using Avalonia.Data.Core.Plugins; using Avalonia.Logging; using Avalonia.Markup.Xaml; @@ -9,7 +10,6 @@ using HanumanInstitute.MvvmDialogs.Avalonia.Fluent; using KubernetesCRDModelGen; using KubeUI.Client; -using KubeUI.Desktop; using KubeUI.Views; using LiveChartsCore; using LiveChartsCore.SkiaSharpView; @@ -29,6 +29,8 @@ public partial class App : Application public static TopLevel TopLevel { get; private set; } + private INotificationManager? NotificationManager { get; set; } + private ILogger logger; public override void Initialize() @@ -138,6 +140,8 @@ public override void Initialize() builder.Services.AddSingleton(x => new MyDialogManager(dialogFactory: x.GetRequiredService(), logger: x.GetRequiredService>())); builder.Services.AddSingleton(x => new DialogService(x.GetRequiredService())); + builder.Services.AddSingleton(_ => NotificationManager!); + Host = builder.Build(); Resources[typeof(IServiceProvider)] = Host.Services; _ = Host.RunAsync(); @@ -190,6 +194,8 @@ public override void OnFrameworkInitializationCompleted() TopLevel = TopLevel.GetTopLevel(singleViewPlatform.MainView); } + NotificationManager = new WindowNotificationManager(TopLevel) { MaxItems = 4 }; + base.OnFrameworkInitializationCompleted(); } diff --git a/src/KubeUI/Assets/Resources.Designer.cs b/src/KubeUI/Assets/Resources.Designer.cs index 6e5738fe..57375b9e 100644 --- a/src/KubeUI/Assets/Resources.Designer.cs +++ b/src/KubeUI/Assets/Resources.Designer.cs @@ -556,6 +556,42 @@ public static string ResourceListView_SelectNamespace { } } + /// + /// Looks up a localized string similar to Cordon Nodes {0}. + /// + public static string ResourceListViewModel_CordonNode_Content { + get { + return ResourceManager.GetString("ResourceListViewModel_CordonNode_Content", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Yes. + /// + public static string ResourceListViewModel_CordonNode_Primary { + get { + return ResourceManager.GetString("ResourceListViewModel_CordonNode_Primary", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No. + /// + public static string ResourceListViewModel_CordonNode_Secondary { + get { + return ResourceManager.GetString("ResourceListViewModel_CordonNode_Secondary", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Warning. + /// + public static string ResourceListViewModel_CordonNode_Title { + get { + return ResourceManager.GetString("ResourceListViewModel_CordonNode_Title", resourceCulture); + } + } + /// /// Looks up a localized string similar to This will delete {0} items. ///Are you sure?. @@ -593,6 +629,42 @@ public static string ResourceListViewModel_Delete_Title { } } + /// + /// Looks up a localized string similar to Drain Nodes {0}. + /// + public static string ResourceListViewModel_DrainNode_Content { + get { + return ResourceManager.GetString("ResourceListViewModel_DrainNode_Content", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Yes. + /// + public static string ResourceListViewModel_DrainNode_Primary { + get { + return ResourceManager.GetString("ResourceListViewModel_DrainNode_Primary", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No. + /// + public static string ResourceListViewModel_DrainNode_Secondary { + get { + return ResourceManager.GetString("ResourceListViewModel_DrainNode_Secondary", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Warning. + /// + public static string ResourceListViewModel_DrainNode_Title { + get { + return ResourceManager.GetString("ResourceListViewModel_DrainNode_Title", resourceCulture); + } + } + /// /// Looks up a localized string similar to Port {0} was forwarded to {1}.. /// @@ -630,7 +702,8 @@ public static string ResourceListViewModel_PortForward_Title { } /// - /// Looks up a localized string similar to test. + /// Looks up a localized string similar to This will restart {0}. + ///Are you sure?. /// public static string ResourceListViewModel_Restart_Content { get { @@ -639,7 +712,7 @@ public static string ResourceListViewModel_Restart_Content { } /// - /// Looks up a localized string similar to OK. + /// Looks up a localized string similar to Yes. /// public static string ResourceListViewModel_Restart_Primary { get { @@ -657,7 +730,7 @@ public static string ResourceListViewModel_Restart_Secondary { } /// - /// Looks up a localized string similar to Restart. + /// Looks up a localized string similar to Restart Workload?. /// public static string ResourceListViewModel_Restart_Title { get { @@ -665,6 +738,42 @@ public static string ResourceListViewModel_Restart_Title { } } + /// + /// Looks up a localized string similar to UnCordon Nodes {0}. + /// + public static string ResourceListViewModel_UnCordonNode_Content { + get { + return ResourceManager.GetString("ResourceListViewModel_UnCordonNode_Content", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Yes. + /// + public static string ResourceListViewModel_UnCordonNode_Primary { + get { + return ResourceManager.GetString("ResourceListViewModel_UnCordonNode_Primary", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No. + /// + public static string ResourceListViewModel_UnCordonNode_Secondary { + get { + return ResourceManager.GetString("ResourceListViewModel_UnCordonNode_Secondary", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Warning. + /// + public static string ResourceListViewModel_UnCordonNode_Title { + get { + return ResourceManager.GetString("ResourceListViewModel_UnCordonNode_Title", resourceCulture); + } + } + /// /// Looks up a localized string similar to Properties. /// diff --git a/src/KubeUI/Assets/Resources.resx b/src/KubeUI/Assets/Resources.resx index ffa2c08c..2fd67b96 100644 --- a/src/KubeUI/Assets/Resources.resx +++ b/src/KubeUI/Assets/Resources.resx @@ -351,4 +351,40 @@ Are you sure? No + + Warning + + + Cordon Nodes {0} + + + Yes + + + No + + + UnCordon Nodes {0} + + + Yes + + + No + + + Warning + + + Drain Nodes {0} + + + Warning + + + No + + + Yes + \ No newline at end of file diff --git a/src/KubeUI/Client/Cluster.cs b/src/KubeUI/Client/Cluster.cs index 032c03e7..4542d2dc 100644 --- a/src/KubeUI/Client/Cluster.cs +++ b/src/KubeUI/Client/Cluster.cs @@ -226,6 +226,8 @@ private async Task AddDefaultNavigation() if (await UpdateCanIListWatchAnyNamespaceAsync()) { NavigationItems.Add(new ResourceNavigationLink() { Name = "Nodes", ControlType = typeof(V1Node), Cluster = this }); + await UpdateCanIAnyNamespaceAsync(Verb.Patch); + await UpdateCanIAnyNamespaceAsync(Verb.Create, "eviction"); } if (ListNamespaces) { @@ -657,31 +659,20 @@ private static List ConstructFQDNList(string domain) return fqdnList; } - public async Task Delete(T item) where T : class, IKubernetesObject, new() + public async Task Delete(T item) where T : class, IKubernetesObject, new() { var api = GroupApiVersionKind.From(); using var client = new GenericClient(Client, api.Group, api.ApiVersion, api.PluralName, false); - try + if (string.IsNullOrEmpty(item.Namespace())) { - if (string.IsNullOrEmpty(item.Namespace())) - { - await client.DeleteAsync(item.Name()); - } - else - { - await client.DeleteNamespacedAsync(item.Namespace(), item.Name()); - } - - return true; + await client.DeleteAsync(item.Name()); } - catch (Exception ex) + else { - _logger.LogError(ex, "Failed to delete"); + await client.DeleteNamespacedAsync(item.Namespace(), item.Name()); } - - return false; } public T? GetObject(string @namespace, string name) where T : class, IKubernetesObject, new() @@ -748,43 +739,31 @@ private static List ConstructFQDNList(string domain) using var client = new GenericClient(Client, api.Group, api.ApiVersion, api.PluralName, false); - try + if (string.IsNullOrEmpty(item.Namespace())) { - if (string.IsNullOrEmpty(item.Namespace())) + if (item.Metadata.Uid != null) { - if (item.Metadata.Uid != null) - { - // update - await client.ReplaceAsync(item, item.Name()); - } - else - { - // add - await client.CreateAsync(item); - } + // update + await client.ReplaceAsync(item, item.Name()); } else { - if (item.Metadata.Uid != null) - { - // update namespaced - await client.ReplaceNamespacedAsync(item, item.Namespace(), item.Name()); - } - else - { - // add namespaced - await client.CreateNamespacedAsync(item, item.Namespace()); - } + // add + await client.CreateAsync(item); } } - catch (JsonException ex) - { - _logger.LogError(ex, "Failed to AddOrUpdate"); - } - catch (Exception ex) + else { - _logger.LogError(ex, "Failed to AddOrUpdate"); - throw; + if (item.Metadata.Uid != null) + { + // update namespaced + await client.ReplaceNamespacedAsync(item, item.Namespace(), item.Name()); + } + else + { + // add namespaced + await client.CreateNamespacedAsync(item, item.Namespace()); + } } } @@ -796,6 +775,8 @@ public async Task ImportYaml(Stream stream) var parser = new Parser(new StringReader(reader.ReadToEnd())); parser.Consume(); + var exceptions = new List(); + while (parser.Accept(out _)) { var doc = Serialization.KubernetesYaml.Deserializer.Deserialize(parser); @@ -808,7 +789,7 @@ public async Task ImportYaml(Stream stream) if (type == null) { - _logger.LogWarning("Unable to find Type for {kind}", obj.ApiVersion + "/" + obj.Kind); + exceptions.Add(new Exception($"Unable to find Type for {obj.ApiVersion + "/" + obj.Kind}")); continue; } @@ -823,9 +804,14 @@ public async Task ImportYaml(Stream stream) } catch (Exception ex) { - _logger.LogError(ex, "Error Deserializing {kind}", obj.ApiVersion + "/" + obj.Kind); + exceptions.Add(ex); } } + + if (exceptions.Count > 0) + { + throw new AggregateException("Error importing Yaml", exceptions); + } } public async Task ImportFolder(string path) @@ -837,6 +823,8 @@ public async Task ImportFolder(string path) .Where(fi => fi.Extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase) || fi.Extension.Equals(".yml", StringComparison.OrdinalIgnoreCase)) .ToList(); + var exceptions = new List(); + foreach (var file in files) { try @@ -845,9 +833,14 @@ public async Task ImportFolder(string path) } catch (Exception ex) { - _logger.LogError(ex, "Error parsing Yaml {filename}", file.FullName); + exceptions.Add(ex); } } + + if (exceptions.Count > 0) + { + throw new AggregateException("Error importing Folder", exceptions); + } } } diff --git a/src/KubeUI/Client/ICluster.cs b/src/KubeUI/Client/ICluster.cs index 6b877f34..e302efbf 100644 --- a/src/KubeUI/Client/ICluster.cs +++ b/src/KubeUI/Client/ICluster.cs @@ -27,7 +27,7 @@ public interface ICluster T? GetObject(string @namespace, string name) where T : class, IKubernetesObject, new(); Task AddOrUpdate(T item) where T : class, IKubernetesObject, new(); Task Connect(); - Task Delete(T item) where T : class, IKubernetesObject, new(); + Task Delete(T item) where T : class, IKubernetesObject, new(); bool IsMetricsAvailable { get; } Task ImportFolder(string path); Task ImportYaml(Stream stream); diff --git a/src/KubeUI/Styles/Icons.axaml b/src/KubeUI/Styles/Icons.axaml index 3d3ff1ee..d472901f 100644 --- a/src/KubeUI/Styles/Icons.axaml +++ b/src/KubeUI/Styles/Icons.axaml @@ -28,10 +28,18 @@ - M7.74944331,5.18010908 C8.0006303,5.50946902 7.93725859,5.9800953 7.60789865,6.23128229 C5.81957892,7.59514774 4.75,9.70820889 4.75,12 C4.75,15.7359812 7.57583716,18.8119527 11.2066921,19.2070952 L10.5303301,18.5303301 C10.2374369,18.2374369 10.2374369,17.7625631 10.5303301,17.4696699 C10.7965966,17.2034034 11.2132603,17.1791973 11.5068718,17.3970518 L11.5909903,17.4696699 L13.5909903,19.4696699 C13.8572568,19.7359365 13.8814629,20.1526002 13.6636084,20.4462117 L13.5909903,20.5303301 L11.5909903,22.5303301 C11.298097,22.8232233 10.8232233,22.8232233 10.5303301,22.5303301 C10.2640635,22.2640635 10.2398575,21.8473998 10.4577119,21.5537883 L10.5303301,21.4696699 L11.280567,20.7208479 C6.78460951,20.3549586 3.25,16.5902554 3.25,12 C3.25,9.23526399 4.54178532,6.68321165 6.6982701,5.03856442 C7.02763004,4.78737743 7.49825632,4.85074914 7.74944331,5.18010908 Z M13.4696699,1.46966991 C13.7625631,1.76256313 13.7625631,2.23743687 13.4696699,2.53033009 L12.7204313,3.27923335 C17.2159137,3.64559867 20.75,7.4100843 20.75,12 C20.75,14.6444569 19.5687435,17.0974104 17.5691913,18.7491089 C17.2498402,19.0129038 16.7771069,18.9678666 16.513312,18.6485156 C16.2495171,18.3291645 16.2945543,17.8564312 16.6139054,17.5926363 C18.2720693,16.2229363 19.25,14.1922015 19.25,12 C19.25,8.26436254 16.4246828,5.18861329 12.7943099,4.7930139 L13.4696699,5.46966991 C13.7625631,5.76256313 13.7625631,6.23743687 13.4696699,6.53033009 C13.1767767,6.8232233 12.701903,6.8232233 12.4090097,6.53033009 L10.4090097,4.53033009 C10.1161165,4.23743687 10.1161165,3.76256313 10.4090097,3.46966991 L12.4090097,1.46966991 C12.701903,1.1767767 13.1767767,1.1767767 13.4696699,1.46966991 Z + M19.25 4.5C19.3881 4.5 19.5 4.61193 19.5 4.75V19.25C19.5 19.3881 19.3881 19.5 19.25 19.5H4.75C4.61193 19.5 4.5 19.3881 4.5 19.25V4.75C4.5 4.61193 4.61193 4.5 4.75 4.5H19.25ZM4.75 3C3.7835 3 3 3.7835 3 4.75V19.25C3 20.2165 3.7835 21 4.75 21H19.25C20.2165 21 21 20.2165 21 19.25V4.75C21 3.7835 20.2165 3 19.25 3H4.75Z - M19.25 4A2.75 2.75 0 0 1 22 6.75v10.5A2.75 2.75 0 0 1 19.25 20H4.75A2.75 2.75 0 0 1 2 17.25V6.75A2.75 2.75 0 0 1 4.75 4h14.5ZM15 18.5v-13H4.75c-.69 0-1.25.56-1.25 1.25v10.5c0 .69.56 1.25 1.25 1.25H15Z + M13.7501344,8.41212026 L38.1671892,21.1169293 C39.7594652,21.9454306 40.3786269,23.9078584 39.5501255,25.5001344 C39.2420737,26.0921715 38.7592263,26.5750189 38.1671892,26.8830707 L13.7501344,39.5878797 C12.1578584,40.4163811 10.1954306,39.7972194 9.36692926,38.2049434 C9.12586301,37.7416442 9,37.2270724 9,36.704809 L9,11.295191 C9,9.50026556 10.4550746,8.045191 12.25,8.045191 C12.6976544,8.045191 13.1396577,8.13766178 13.5485655,8.31589049 L13.7501344,8.41212026 Z M12.5961849,10.629867 L12.4856981,10.5831892 C12.4099075,10.5581 12.3303482,10.545191 12.25,10.545191 C11.8357864,10.545191 11.5,10.8809774 11.5,11.295191 L11.5,36.704809 C11.5,36.8253313 11.5290453,36.9440787 11.584676,37.0509939 C11.7758686,37.4184422 12.2287365,37.5613256 12.5961849,37.370133 L37.0132397,24.665324 C37.1498636,24.5942351 37.2612899,24.4828088 37.3323788,24.3461849 C37.5235714,23.9787365 37.380688,23.5258686 37.0132397,23.334676 L12.5961849,10.629867 Z + + + + + M7.74944331,5.18010908 C8.0006303,5.50946902 7.93725859,5.9800953 7.60789865,6.23128229 C5.81957892,7.59514774 4.75,9.70820889 4.75,12 C4.75,15.7359812 7.57583716,18.8119527 11.2066921,19.2070952 L10.5303301,18.5303301 C10.2374369,18.2374369 10.2374369,17.7625631 10.5303301,17.4696699 C10.7965966,17.2034034 11.2132603,17.1791973 11.5068718,17.3970518 L11.5909903,17.4696699 L13.5909903,19.4696699 C13.8572568,19.7359365 13.8814629,20.1526002 13.6636084,20.4462117 L13.5909903,20.5303301 L11.5909903,22.5303301 C11.298097,22.8232233 10.8232233,22.8232233 10.5303301,22.5303301 C10.2640635,22.2640635 10.2398575,21.8473998 10.4577119,21.5537883 L10.5303301,21.4696699 L11.280567,20.7208479 C6.78460951,20.3549586 3.25,16.5902554 3.25,12 C3.25,9.23526399 4.54178532,6.68321165 6.6982701,5.03856442 C7.02763004,4.78737743 7.49825632,4.85074914 7.74944331,5.18010908 Z M13.4696699,1.46966991 C13.7625631,1.76256313 13.7625631,2.23743687 13.4696699,2.53033009 L12.7204313,3.27923335 C17.2159137,3.64559867 20.75,7.4100843 20.75,12 C20.75,14.6444569 19.5687435,17.0974104 17.5691913,18.7491089 C17.2498402,19.0129038 16.7771069,18.9678666 16.513312,18.6485156 C16.2495171,18.3291645 16.2945543,17.8564312 16.6139054,17.5926363 C18.2720693,16.2229363 19.25,14.1922015 19.25,12 C19.25,8.26436254 16.4246828,5.18861329 12.7943099,4.7930139 L13.4696699,5.46966991 C13.7625631,5.76256313 13.7625631,6.23743687 13.4696699,6.53033009 C13.1767767,6.8232233 12.701903,6.8232233 12.4090097,6.53033009 L10.4090097,4.53033009 C10.1161165,4.23743687 10.1161165,3.76256313 10.4090097,3.46966991 L12.4090097,1.46966991 C12.701903,1.1767767 13.1767767,1.1767767 13.4696699,1.46966991 Z + + + M19.25 4A2.75 2.75 0 0 1 22 6.75v10.5A2.75 2.75 0 0 1 19.25 20H4.75A2.75 2.75 0 0 1 2 17.25V6.75A2.75 2.75 0 0 1 4.75 4h14.5ZM15 18.5v-13H4.75c-.69 0-1.25.56-1.25 1.25v10.5c0 .69.56 1.25 1.25 1.25H15Z diff --git a/src/KubeUI/Utilities.cs b/src/KubeUI/Utilities.cs index 46b9c9a5..44a1c16a 100644 --- a/src/KubeUI/Utilities.cs +++ b/src/KubeUI/Utilities.cs @@ -1,6 +1,8 @@ -using Avalonia.Data.Converters; +using Avalonia.Controls.Notifications; +using Avalonia.Data.Converters; using Avalonia.Input; using k8s; +using k8s.Autorest; using k8s.Models; using System.Reflection; using System.Runtime.CompilerServices; @@ -267,4 +269,45 @@ public static object DeserializeKubeJson(string json, Type type) return fooRef.Invoke(null, [json, null]); } + + public static void HandleException(ILogger logger, INotificationManager notificationManage, Exception ex, string message, NotificationType type = NotificationType.Error, bool sendNotification = false) + { + if (sendNotification) + { + if(ex is AggregateException aggregate) + { + foreach (var item in aggregate.InnerExceptions) + { + if (item is HttpOperationException opEx) + { + var status = KubernetesYaml.Deserialize(opEx.Response.Content); + + if (status != null) + { + notificationManage.Show(new Notification(status.Reason, status.Message + "\n\n" + status?.Details?.Causes?.Select(x => x.Message).Aggregate((x, y) => x + "\n" + y) ?? "", type, TimeSpan.FromSeconds(30))); + } + } + else + { + notificationManage.Show(new Notification(message, item.Message, type)); + } + } + } + else if (ex is HttpOperationException opEx) + { + var status = KubernetesYaml.Deserialize(opEx.Response.Content); + + if (status != null) + { + notificationManage.Show(new Notification(status.Reason, status.Message + "\n\n" + status?.Details?.Causes?.Select(x => x.Message).Aggregate((x, y) => x + "\n" + y) ?? "", type, TimeSpan.FromSeconds(30))); + } + } + else + { + notificationManage.Show(new Notification(message, ex.Message, type)); + } + } + + logger.LogError(ex, message); + } } diff --git a/src/KubeUI/ViewModels/NavigationViewModel.cs b/src/KubeUI/ViewModels/NavigationViewModel.cs index 022e1f75..2db9a31a 100644 --- a/src/KubeUI/ViewModels/NavigationViewModel.cs +++ b/src/KubeUI/ViewModels/NavigationViewModel.cs @@ -1,4 +1,5 @@ -using Avalonia.Platform.Storage; +using Avalonia.Controls.Notifications; +using Avalonia.Platform.Storage; using Dock.Model.Core; using KubeUI.Client; @@ -6,6 +7,15 @@ namespace KubeUI.ViewModels; public sealed partial class NavigationViewModel : ViewModelBase { + private readonly ILogger _logger; + private INotificationManager _notificationManager + { + get + { + return Application.Current.GetRequiredService(); + } + } + [ObservableProperty] private ClusterManager _clusterManager; @@ -14,6 +24,8 @@ public NavigationViewModel() ClusterManager = Application.Current.GetRequiredService(); Title = Resources.NavigationViewModel_Title; Id = nameof(NavigationViewModel); + _logger = Application.Current.GetRequiredService>(); + //_notificationManager = Application.Current.GetRequiredService(); } public void TreeView_SelectionChanged(object? item) @@ -73,8 +85,15 @@ private async Task SelectNavigationLink(NavigationLink link) foreach (var file in files) { - var stream = await file.OpenReadAsync(); - await link.Cluster.ImportYaml(stream); + try + { + var stream = await file.OpenReadAsync(); + await link.Cluster.ImportYaml(stream); + } + catch (Exception ex) + { + Utilities.HandleException(_logger, _notificationManager, ex, "Error loading yaml file", sendNotification: true); + } } } else if (link.Id == "load-folder") @@ -86,9 +105,16 @@ private async Task SelectNavigationLink(NavigationLink link) AllowMultiple = false }); - foreach (var file in folders) + foreach (var folder in folders) { - await link.Cluster.ImportFolder(file.TryGetLocalPath()); + try + { + await link.Cluster.ImportFolder(folder.TryGetLocalPath()); + } + catch (Exception ex) + { + Utilities.HandleException(_logger, _notificationManager, ex, "Error loading yaml from folder", sendNotification: true); + } } } else diff --git a/src/KubeUI/ViewModels/ResourceListViewModel.cs b/src/KubeUI/ViewModels/ResourceListViewModel.cs index b0fa3c8c..baf101a2 100644 --- a/src/KubeUI/ViewModels/ResourceListViewModel.cs +++ b/src/KubeUI/ViewModels/ResourceListViewModel.cs @@ -3,6 +3,7 @@ using System.Reflection; using System.Text.Json.Serialization; using System.Text.RegularExpressions; +using Avalonia.Controls.Notifications; using Avalonia.Data.Converters; using Avalonia.Styling; using Dock.Model.Core; @@ -26,6 +27,7 @@ namespace KubeUI.ViewModels; { private readonly ILogger> _logger; private readonly IDialogService _dialogService; + private readonly INotificationManager _notificationManager; [ObservableProperty] private ICluster _cluster; @@ -57,6 +59,7 @@ public ResourceListViewModel() { _logger = Application.Current.GetRequiredService>>(); _dialogService = Application.Current.GetRequiredService(); + _notificationManager = Application.Current.GetRequiredService(); } public void Initialize(ICluster cluster) @@ -79,10 +82,11 @@ private void SetFilter() { _filter?.Dispose(); - _filter = Objects.ToObservableChangeSet, KeyValuePair>() + _filter = Objects + .ToObservableChangeSet, KeyValuePair>() .Filter(GenerateFilter()) - .Bind(out var filteredObjects) - .Subscribe((_) => { }, (y) => _logger.LogError(y, "Error Set Namespace Filter")); + .Bind(out var filteredObjects, BindingOptions.NeverFireReset(false)) + .Subscribe((_) => { }, (y) => _logger.LogError(y, "Error Set Namespace Filter: {ns}", typeof(T))); DataGridObjects = filteredObjects; } @@ -236,13 +240,7 @@ private ResourceListViewDefinition GetViewDefinition() new ResourceListViewDefinitionColumn() { Name = "Taints", - Field = x => x.Metadata.Annotations.TryGetValue("scheduler.alpha.kubernetes.io/taints", out var value) ? value : "", - Width = nameof(DataGridLengthUnitType.SizeToHeader) - }, - new ResourceListViewDefinitionColumn() - { - Name = "Roles", - Field = x => x.Metadata.Annotations.Any(x => x.Key.StartsWith("node-role.kubernetes.io/")) ? x.Metadata.Annotations.Where(x => x.Key.StartsWith("node-role.kubernetes.io/")).Select(x => x.Value).Aggregate((x,y) => x + ", " + y) : "", + Field = x => x?.Spec?.Taints?.Select(x => $"{x.Key}={x.Effect}").Aggregate((x,y) => $"{x}, {y}") ?? "", Width = nameof(DataGridLengthUnitType.SizeToHeader) }, new ResourceListViewDefinitionColumn() @@ -259,6 +257,31 @@ private ResourceListViewDefinition GetViewDefinition() }, AgeColumn(), ]; + + definition.MenuItems = + [ + new() + { + Header = "Cordon", + IconResource = "stop_regular", + CommandPath = nameof(ResourceListViewModel.CordonNodeCommand), + CommandParameterPath = "SelectedItems", + }, + new() + { + Header = "UnCordon", + IconResource = "play_regular", + CommandPath = nameof(ResourceListViewModel.UnCordonNodeCommand), + CommandParameterPath = "SelectedItems", + }, + new() + { + Header = "Drain", + IconResource = "arrow_sync_regular", + CommandPath = nameof(ResourceListViewModel.DrainNodeCommand), + CommandParameterPath = "SelectedItems", + }, + ]; } else if (resourceType == typeof(V1Namespace)) { @@ -289,13 +312,13 @@ private ResourceListViewDefinition GetViewDefinition() new ResourceListViewDefinitionColumn() { Name = "Type", - Field = x => x.Type, + Field = x => x?.Type ?? "", Width = nameof(DataGridLengthUnitType.SizeToCells) }, new ResourceListViewDefinitionColumn() { Name = "Message", - Field = x => x.Message, + Field = x => x?.Message ?? "", Width = "4*" }, NamespaceColumn(), @@ -308,7 +331,7 @@ private ResourceListViewDefinition GetViewDefinition() new ResourceListViewDefinitionColumn() { Name = "Source", - Field = x => x.Source.Component ?? "", + Field = x => x?.Source?.Component ?? "", Width = "*" }, new ResourceListViewDefinitionColumn() @@ -1601,9 +1624,20 @@ private async Task Delete(IList items) if (result == ContentDialogResult.Primary) { + var exceptions = new List(); + foreach (var item in items.Cast>().ToList()) { - await Cluster.Delete(item.Value); + try + { + await Cluster.Delete(item.Value); + + } + catch (Exception ex) + { + exceptions.Add(ex); + Utilities.HandleException(_logger, _notificationManager, ex, $"Error Deleting {item.Key.Namespace}/{item.Key.Name}", sendNotification: true); + } } } } @@ -1744,7 +1778,6 @@ private bool CanPortForward(V1ContainerPort? containerPort) Cluster.CanI(Verb.Create, ((KeyValuePair)SelectedItem).Key.Namespace, "portforward"); } - [RelayCommand(CanExecute = nameof(CanPortForwardService))] private async Task PortForwardService(V1ServicePort containerPort) { @@ -1813,14 +1846,14 @@ private bool CanListCRD(V1CustomResourceDefinition? item) private static readonly string s_restartControllerPatch = $$""" { "spec": { - "template": { - "metadata": { - "annotations": { - "kubectl.kubernetes.io/restartedAt": "{{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ}}" - } + "template": { + "metadata": { + "annotations": { + "kubectl.kubernetes.io/restartedAt": "{{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ}}" + } + } } } - } } """; @@ -1847,7 +1880,7 @@ private async Task RestartDeployment(V1Deployment deployment) } catch (Exception ex) { - _logger.LogError(ex, "Error Restarting Deployment"); + Utilities.HandleException(_logger, _notificationManager, ex, "Error Restarting Deployment", sendNotification: true); } } @@ -1879,7 +1912,7 @@ private async Task RestartReplicaSet(V1ReplicaSet replicaSet) } catch (Exception ex) { - _logger.LogError(ex, "Error Restarting ReplicaSet"); + Utilities.HandleException(_logger, _notificationManager, ex, "Error Restarting ReplicaSet", sendNotification: true); } } @@ -1911,7 +1944,7 @@ private async Task RestartStatefulSet(V1StatefulSet statefulSet) } catch (Exception ex) { - _logger.LogError(ex, "Error Restarting StatefulSet"); + Utilities.HandleException(_logger, _notificationManager, ex, "Error Restarting StatefulSet", sendNotification: true); } } @@ -1943,7 +1976,7 @@ private async Task RestartDaemonSet(V1DaemonSet daemonSet) } catch (Exception ex) { - _logger.LogError(ex, "Error Restarting DaemonSet"); + Utilities.HandleException(_logger, _notificationManager, ex, "Error Restarting DaemonSet", sendNotification: true); } } @@ -1952,6 +1985,163 @@ private bool CanRestartDaemonSet(V1DaemonSet daemonSet) return daemonSet != null && Cluster.CanI(Verb.Patch, daemonSet.Namespace()); } + [RelayCommand(CanExecute = nameof(CanCordonNode))] + private async Task CordonNode(IList items) + { + ContentDialogSettings settings = new() + { + Title = Resources.ResourceListViewModel_CordonNode_Title, + Content = string.Format(Resources.ResourceListViewModel_CordonNode_Content, items.Count), + PrimaryButtonText = Resources.ResourceListViewModel_CordonNode_Primary, + SecondaryButtonText = Resources.ResourceListViewModel_CordonNode_Secondary, + DefaultButton = ContentDialogButton.Secondary + }; + + var result = await _dialogService.ShowContentDialogAsync(this, settings); + + var patch = $$""" + { + "spec": { + "unschedulable": true + } + } + """; + + if (result == ContentDialogResult.Primary) + { + foreach (var item in items.Cast>().ToList()) + { + try + { + await Cluster.Client.CoreV1.PatchNodeAsync(new V1Patch(patch, V1Patch.PatchType.MergePatch), item.Key.Name, item.Key.Namespace); + } + catch (Exception ex) + { + Utilities.HandleException(_logger, _notificationManager, ex, "Error Cordoning Node", sendNotification: true); + } + } + } + } + + private bool CanCordonNode(IList items) + { + return Cluster.CanI(Verb.Patch); + } + + [RelayCommand(CanExecute = nameof(CanUnCordonNode))] + private async Task UnCordonNode(IList items) + { + ContentDialogSettings settings = new() + { + Title = Resources.ResourceListViewModel_UnCordonNode_Title, + Content = string.Format(Resources.ResourceListViewModel_UnCordonNode_Content, items.Count), + PrimaryButtonText = Resources.ResourceListViewModel_UnCordonNode_Primary, + SecondaryButtonText = Resources.ResourceListViewModel_UnCordonNode_Secondary, + DefaultButton = ContentDialogButton.Secondary + }; + + var result = await _dialogService.ShowContentDialogAsync(this, settings); + + var patch = $$""" + { + "spec": { + "unschedulable": false + } + } + """; + + if (result == ContentDialogResult.Primary) + { + foreach (var item in items.Cast>().ToList()) + { + try + { + await Cluster.Client.CoreV1.PatchNodeAsync(new V1Patch(patch, V1Patch.PatchType.MergePatch), item.Key.Name, item.Key.Namespace); + } + catch (Exception ex) + { + Utilities.HandleException(_logger, _notificationManager, ex, "Error UnCordoning Node", sendNotification: true); + } + } + } + } + + private bool CanUnCordonNode(IList items) + { + return Cluster.CanI(Verb.Patch); + } + + [RelayCommand(CanExecute = nameof(CanCordonNode))] + private async Task DrainNode(IList items) + { + ContentDialogSettings settings = new() + { + Title = Resources.ResourceListViewModel_DrainNode_Title, + Content = string.Format(Resources.ResourceListViewModel_DrainNode_Content, items.Count), + PrimaryButtonText = Resources.ResourceListViewModel_DrainNode_Primary, + SecondaryButtonText = Resources.ResourceListViewModel_DrainNode_Secondary, + DefaultButton = ContentDialogButton.Secondary + }; + + var result = await _dialogService.ShowContentDialogAsync(this, settings); + + var patch = $$""" + { + "spec": { + "unschedulable": true + } + } + """; + + if (result == ContentDialogResult.Primary) + { + foreach (var item in items.Cast>().ToList()) + { + try + { + await Cluster.Client.CoreV1.PatchNodeAsync(new V1Patch(patch, V1Patch.PatchType.MergePatch), item.Key.Name, item.Key.Namespace); + + var pods = await Cluster.GetObjectDictionaryAsync(); + + foreach (var pod in pods) + { + if (pod.Value.Spec.NodeName == item.Value.Metadata.Name) + { + V1Eviction evict = new() + { + ApiVersion = V1Eviction.KubeGroup + "/" + V1Eviction.KubeApiVersion, + Kind = V1Eviction.KubeKind, + Metadata = new() + { + Name = pod.Value.Metadata.Name, + NamespaceProperty = pod.Value.Metadata.NamespaceProperty + } + }; + + try + { + await Cluster.Client.CoreV1.CreateNamespacedPodEvictionAsync(evict, pod.Value.Metadata.Name, pod.Value.Metadata.NamespaceProperty); + } + catch (Exception ex) + { + Utilities.HandleException(_logger, _notificationManager, ex, "Error Evicting Pod", sendNotification: true); + } + } + } + } + catch (Exception ex) + { + Utilities.HandleException(_logger, _notificationManager, ex, "Error Draining Node", sendNotification: true); + } + } + } + } + + private bool CanDrainNode(IList items) + { + return Cluster.CanI(Verb.Patch); + } + #endregion public void Dispose() diff --git a/src/KubeUI/ViewModels/ResourceYamlViewModel.cs b/src/KubeUI/ViewModels/ResourceYamlViewModel.cs index 47c1e655..947d0243 100644 --- a/src/KubeUI/ViewModels/ResourceYamlViewModel.cs +++ b/src/KubeUI/ViewModels/ResourceYamlViewModel.cs @@ -1,6 +1,5 @@ -using System.Reflection; -using System.Text; -using AvaloniaEdit; +using System.Text; +using Avalonia.Controls.Notifications; using AvaloniaEdit.Document; using AvaloniaEdit.Folding; using k8s; @@ -11,6 +10,9 @@ namespace KubeUI.ViewModels; public partial class ResourceYamlViewModel : ViewModelBase, IDisposable { + private readonly ILogger _logger; + private readonly INotificationManager _notificationManager; + [ObservableProperty] private ICluster? _cluster; @@ -38,6 +40,8 @@ public partial class ResourceYamlViewModel : ViewModelBase, IDisposable public ResourceYamlViewModel() { Title = Resources.ResourceYamlViewModel_Title; + _logger = Application.Current.GetRequiredService>(); + _notificationManager = Application.Current.GetRequiredService(); } public void Initialize(ICluster cluster, IKubernetesObject @object) @@ -99,9 +103,16 @@ private void Cluster_OnChange(WatchEventType eventType, GroupApiVersionKind grou [RelayCommand(CanExecute = nameof(CanSave))] private async Task Save() { - byte[] byteArray = Encoding.UTF8.GetBytes(YamlDocument.Text); - await using MemoryStream stream = new MemoryStream(byteArray); - await Cluster.ImportYaml(stream); + try + { + byte[] byteArray = Encoding.UTF8.GetBytes(YamlDocument.Text); + await using MemoryStream stream = new MemoryStream(byteArray); + await Cluster.ImportYaml(stream); + } + catch (Exception ex) + { + Utilities.HandleException(_logger, _notificationManager, ex, "Error Saving Yaml", sendNotification: true); + } } private bool CanSave() diff --git a/src/KubeUI/ViewModels/ViewModelBase.cs b/src/KubeUI/ViewModels/ViewModelBase.cs index b9b72ed2..faa3f431 100644 --- a/src/KubeUI/ViewModels/ViewModelBase.cs +++ b/src/KubeUI/ViewModels/ViewModelBase.cs @@ -5,7 +5,7 @@ namespace KubeUI.ViewModels; public abstract class ViewModelBase : Tool { - public new IFactory Factory { get; set; } = Application.Current.GetRequiredService(); + public new IFactory Factory { get; set; } = Application.Current.GetRequiredService(); public bool IsPinned => Factory.IsDockablePinned(this); } diff --git a/src/KubeUI/Views/ResourceListView.cs b/src/KubeUI/Views/ResourceListView.cs index 5b34c2e0..d1a1eee2 100644 --- a/src/KubeUI/Views/ResourceListView.cs +++ b/src/KubeUI/Views/ResourceListView.cs @@ -304,7 +304,7 @@ protected override object Build(ResourceListViewModel? vm) new DataGrid() .Ref(out _grid) .Row(1) - .ItemsSource(@vm.DataGridObjects) + .ItemsSource(@vm.DataGridObjects, BindingMode.OneWay) .SelectedItem(@vm.SelectedItem) .CanUserReorderColumns(true) .CanUserResizeColumns(true) diff --git a/tests/KubeUI.Tests/ClusterEndToEndTests.cs b/tests/KubeUI.Tests/ClusterEndToEndTests.cs index 61e78070..da402a22 100644 --- a/tests/KubeUI.Tests/ClusterEndToEndTests.cs +++ b/tests/KubeUI.Tests/ClusterEndToEndTests.cs @@ -449,7 +449,6 @@ public async Task HandleCRD() apiVersion: rbac.authorization.k8s.io/v1 metadata: name: my-serviceaccount - namespace: my-app rules: [] --- apiVersion: rbac.authorization.k8s.io/v1 @@ -528,7 +527,7 @@ public async Task HandleCRD() apiGroups: - '' resources: - - service/proxy + - services/proxy - verbs: - delete @@ -632,7 +631,6 @@ public async Task HandleCRD() apiVersion: rbac.authorization.k8s.io/v1 metadata: name: my-serviceaccount - namespace: my-app rules: [] --- apiVersion: rbac.authorization.k8s.io/v1 @@ -711,7 +709,7 @@ public async Task HandleCRD() apiGroups: - '' resources: - - service/proxy + - services/proxy - verbs: - delete diff --git a/tests/KubeUI.Tests/Kind.cs b/tests/KubeUI.Tests/Kind.cs index c8fc1278..4c3142c1 100644 --- a/tests/KubeUI.Tests/Kind.cs +++ b/tests/KubeUI.Tests/Kind.cs @@ -8,7 +8,7 @@ namespace KubeUI.Core.Tests; public class Kind { - public string Version = "0.24.0"; + public string Version = "0.25.0"; public string FileName { get; } = "kind" + (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : ""); diff --git a/tests/KubeUI.Tests/TestApp.axaml.cs b/tests/KubeUI.Tests/TestApp.axaml.cs index c73be311..77007685 100644 --- a/tests/KubeUI.Tests/TestApp.axaml.cs +++ b/tests/KubeUI.Tests/TestApp.axaml.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.Hosting; using HanumanInstitute.MvvmDialogs; using Moq; +using Avalonia.Controls.Notifications; namespace KubeUI.Tests; @@ -40,6 +41,9 @@ public override void Initialize() var dialog = new Mock(); builder.Services.AddSingleton(dialog.Object); + var notifications = new Mock(); + builder.Services.AddSingleton(notifications.Object); + builder.Services.Scan(scan => scan .FromAssemblyOf() .AddClasses(classes => classes.AssignableToAny([typeof(UserControl), typeof(ObservableObject), typeof(ViewModelBase), typeof(MyViewBase<>)]))