wip: Windows desktop app scaffold (WinUI 3/.NET 8) + NSIS installer + docs

Owner decision pending (STATE.md DC-100 tick): adopt/ship/park.
Preserved from fragile git stash to named branch 2026-08-23.
NOTE: requires a Windows build machine (WinUI XAML compiler + MSIX do not
cross-build on Linux) — see WINDOWS_APP_BUILD.md in this tree.
Secret-scanned clean 2026-08-23 (no keys/tokens/PEM in tree).
This commit is contained in:
Hermes
2026-08-22 23:50:34 -07:00
commit 86e4c9fc81
29 changed files with 4600 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
<ContentDialog
x:Class="DashCaddy.Desktop.AddServiceDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:DashCaddy.Desktop.ViewModels"
mc:Ignorable="d"
Title="Add New Service"
PrimaryButtonText="Create"
CloseButtonText="Cancel"
DefaultButton="Primary"
Width="500">
<ScrollViewer VerticalScrollBarVisibility="Auto" MaxHeight="500">
<StackPanel Spacing="16" Margin="8">
<!-- Service Type Selection -->
<StackPanel Spacing="8">
<TextBlock Text="How would you like to add this service?" FontWeight="SemiBold" />
<RadioButtons x:Name="AddMethodRadio" SelectedIndex="0" SelectionChanged="AddMethod_SelectionChanged">
<RadioButton Content="From Template (Plex, Home Assistant, etc.)" Tag="template" />
<RadioButton Content="Import Docker Compose File" Tag="compose" />
<RadioButton Content="Custom Service (Manual)" Tag="custom" />
</RadioButtons>
</StackPanel>
<!-- Template Selection -->
<StackPanel x:Name="TemplatePanel" Spacing="12" Visibility="Visible">
<TextBlock Text="Select Template" FontWeight="SemiBold" />
<ComboBox x:Name="TemplateCombo" ItemsSource="{x:Bind ViewModel.Templates}"
DisplayMemberPath="Name" Style="{StaticResource ComboBoxStyle}"
SelectionChanged="TemplateCombo_SelectionChanged">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:ServiceTemplate">
<StackPanel Orientation="Horizontal" Spacing="12">
<TextBlock Text="{x:Bind Icon}" FontSize="20" />
<StackPanel>
<TextBlock Text="{x:Bind Name}" FontWeight="Medium" />
<TextBlock Text="{x:Bind Description}" FontSize="12" Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
</StackPanel>
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!-- Template Variables -->
<StackPanel x:Name="VariablesPanel" Spacing="12" Visibility="Collapsed">
<TextBlock Text="Configuration" FontWeight="SemiBold" />
<ItemsControl x:Name="VariablesList" ItemsSource="{x:Bind ViewModel.TemplateVariables}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Spacing="4" Margin="0,4">
<TextBlock Text="{Binding Key}" FontWeight="Medium" />
<TextBox Text="{Binding Value, Mode=TwoWay}" Style="{StaticResource InputFieldStyle}"
PlaceholderText="{Binding Placeholder}" />
<TextBlock Text="{Binding Description}" FontSize="11" Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
<!-- Compose Import -->
<StackPanel x:Name="ComposePanel" Spacing="12" Visibility="Collapsed">
<TextBlock Text="Docker Compose File" FontWeight="SemiBold" />
<Button Content="Select docker-compose.yml" Click="BrowseCompose_Click" Style="{StaticResource AccentButtonStyle}" />
<TextBlock x:Name="ComposePathText" Text="No file selected" FontSize="12" Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
</StackPanel>
<!-- Custom Service -->
<StackPanel x:Name="CustomPanel" Spacing="12" Visibility="Collapsed">
<TextBlock Text="Service Details" FontWeight="SemiBold" />
<TextBox x:Name="CustomName" Header="Service Name" PlaceholderText="My Service" Style="{StaticResource InputFieldStyle}" />
<TextBox x:Name="CustomImage" Header="Docker Image" PlaceholderText="nginx:latest" Style="{StaticResource InputFieldStyle}" />
<TextBox x:Name="CustomPort" Header="Port" PlaceholderText="80" Style="{StaticResource InputFieldStyle}" />
<TextBox x:Name="CustomDomain" Header="Domain (optional)" PlaceholderText="service.example.com" Style="{StaticResource InputFieldStyle}" />
<TextBox x:Name="CustomVolumes" Header="Volumes (one per line)" PlaceholderText="E:/data:/data&#x0a;E:/config:/config" Style="{StaticResource InputFieldStyle}" MinHeight="80" AcceptsReturn="True" />
<TextBox x:Name="CustomEnv" Header="Environment Variables (KEY=value, one per line)" PlaceholderText="TZ=America/Los_Angeles&#x0a;DEBUG=true" Style="{StaticResource InputFieldStyle}" MinHeight="80" AcceptsReturn="True" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentDialog>
+172
View File
@@ -0,0 +1,172 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using DashCaddy.Desktop.ViewModels;
using DashCaddy.Desktop.Services;
using System.Collections.Generic;
namespace DashCaddy.Desktop;
public sealed partial class AddServiceDialog : ContentDialog
{
public MainViewModel ViewModel { get; }
public AddServiceDialog(MainViewModel viewModel)
{
ViewModel = viewModel;
InitializeComponent();
LoadTemplates();
}
private void LoadTemplates()
{
var templates = TemplateRegistry.GetAll().ToList();
TemplateCombo.ItemsSource = templates;
if (templates.Count > 0)
TemplateCombo.SelectedIndex = 0;
}
private void AddMethod_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (AddMethodRadio.SelectedItem is RadioButton rb)
{
var method = rb.Tag?.ToString();
TemplatePanel.Visibility = method == "template" ? Visibility.Visible : Visibility.Collapsed;
ComposePanel.Visibility = method == "compose" ? Visibility.Visible : Visibility.Collapsed;
CustomPanel.Visibility = method == "custom" ? Visibility.Visible : Visibility.Collapsed;
}
}
private void TemplateCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (TemplateCombo.SelectedItem is ServiceTemplate template)
{
ViewModel.TemplateVariables = template.RequiredVariables.Length > 0 || template.Environment.Count > 0
? BuildVariables(template)
: new List<VariableViewModel>();
VariablesPanel.Visibility = ViewModel.TemplateVariables.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
}
}
private List<VariableViewModel> BuildVariables(ServiceTemplate template)
{
var vars = new List<VariableViewModel>();
foreach (var (key, value) in template.Environment)
{
vars.Add(new VariableViewModel
{
Key = key,
Value = value,
Placeholder = key,
Description = $"Environment variable: {key}"
});
}
foreach (var required in template.RequiredVariables)
{
if (!vars.Exists(v => v.Key == required))
{
vars.Add(new VariableViewModel
{
Key = required,
Value = "",
Placeholder = required,
Description = $"Required: {required}"
});
}
}
return vars;
}
private async void BrowseCompose_Click(object sender, RoutedEventArgs e)
{
var picker = new Windows.Storage.Pickers.FileOpenPicker();
picker.FileTypeFilter.Add(".yaml");
picker.FileTypeFilter.Add(".yml");
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
WinRT.Interop.InitializeWithWindow.Initialize(picker, hwnd);
var file = await picker.PickSingleFileAsync();
if (file != null)
{
ComposePathText.Text = file.Path;
ViewModel.SelectedComposeFile = file.Path;
}
}
private void ContentDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
{
if (AddMethodRadio.SelectedItem is RadioButton rb)
{
var method = rb.Tag?.ToString();
switch (method)
{
case "template":
if (TemplateCombo.SelectedItem is ServiceTemplate template)
{
var variables = new Dictionary<string, string>();
foreach (var v in ViewModel.TemplateVariables)
{
variables[v.Key] = v.Value;
}
ViewModel.SelectedTemplate = template;
ViewModel.TemplateVariablesDict = variables;
}
else
{
args.Cancel = true;
}
break;
case "compose":
if (string.IsNullOrEmpty(ViewModel.SelectedComposeFile))
{
args.Cancel = true;
}
break;
case "custom":
if (string.IsNullOrWhiteSpace(CustomName.Text) || string.IsNullOrWhiteSpace(CustomImage.Text))
{
args.Cancel = true;
}
else
{
ViewModel.CustomService = new
{
Name = CustomName.Text,
Image = CustomImage.Text,
Port = int.TryParse(CustomPort.Text, out var p) ? p : 80,
Domain = CustomDomain.Text,
Volumes = CustomVolumes.Text.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(v => v.Trim()).ToList(),
Environment = ParseEnv(CustomEnv.Text)
};
}
break;
}
}
}
private Dictionary<string, string> ParseEnv(string text)
{
var dict = new Dictionary<string, string>();
foreach (var line in text.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var parts = line.Split('=', 2);
if (parts.Length == 2)
dict[parts[0].Trim()] = parts[1].Trim();
}
return dict;
}
}
public class VariableViewModel
{
public string Key { get; set; }
public string Value { get; set; }
public string Placeholder { get; set; }
public string Description { get; set; }
}
+17
View File
@@ -0,0 +1,17 @@
<Application
x:Class="DashCaddy.Desktop.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:DashCaddy.Desktop">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
<!-- Custom styles -->
<ResourceDictionary Source="Styles/Colors.xaml" />
<ResourceDictionary Source="Styles/Controls.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
+84
View File
@@ -0,0 +1,84 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Windowing;
using Windows.Graphics;
using DashCaddy.Desktop.ViewModels;
using DashCaddy.Desktop.Services;
using Serilog;
namespace DashCaddy.Desktop;
public sealed partial class App : Application
{
public static MainViewModel MainViewModel { get; private set; }
public static DockerService DockerService { get; private set; }
public static ApiClient ApiClient { get; private set; }
public static CaddyConfigGenerator CaddyGenerator { get; private set; }
public static DnsClient DnsClient { get; private set; }
public App()
{
InitializeComponent();
ConfigureLogging();
InitializeServices();
}
private void ConfigureLogging()
{
var logPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"DashCaddy", "logs", "dashcaddy-.log");
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.File(logPath, rollingInterval: RollingInterval.Day, retainedFileCountLimit: 30)
.WriteTo.Debug()
.CreateLogger();
}
private void InitializeServices()
{
var apiBaseUrl = "http://localhost:3001";
var dockerEndpoint = "npipe://./pipe/docker_engine"; // Windows named pipe
DockerService = new DockerService(dockerEndpoint);
ApiClient = new ApiClient(apiBaseUrl);
CaddyGenerator = new CaddyConfigGenerator();
DnsClient = new DnsClient(apiBaseUrl);
MainViewModel = new MainViewModel(DockerService, ApiClient, CaddyGenerator, DnsClient);
}
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
var window = new MainWindow();
window.Activate();
// Center window on screen
CenterWindow(window);
// Set minimum size
var hWnd = WinRT.Interop.WindowNative.GetWindowHandle(window);
var windowId = Win32Interop.GetWindowIdFromWindow(hWnd);
var appWindow = AppWindow.GetFromWindowId(windowId);
appWindow.Resize(new SizeInt32(1200, 800));
}
private static void CenterWindow(Window window)
{
var hWnd = WinRT.Interop.WindowNative.GetWindowHandle(window);
var windowId = Win32Interop.GetWindowIdFromWindow(hWnd);
var appWindow = AppWindow.GetFromWindowId(windowId);
var displayArea = DisplayArea.GetFromWindowId(windowId, DisplayAreaFallback.Nearest);
var center = displayArea.WorkArea.CenterPoint;
var width = 1200;
var height = 800;
appWindow.MoveAndResize(new RectInt32(
center.X - width / 2,
center.Y - height / 2,
width,
height));
}
}
+47
View File
@@ -0,0 +1,47 @@
<Project Sdk="Microsoft.Windows.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<TargetPlatformMinVersion>10.0.19041.0</TargetPlatformMinVersion>
<SupportedOSPlatformVersion>10.0.19041.0</SupportedOSPlatformVersion>
<UseWinUI>true</UseWinUI>
<EnableMsixTooling>true</EnableMsixTooling>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<PublishProfile>Properties\PublishProfiles\win10-$(Platform).pubxml</PublishProfile>
<ApplicationIcon>Assets\dashcaddy.ico</ApplicationIcon>
<AssemblyName>DashCaddy</AssemblyName>
<RootNamespace>DashCaddy.Desktop</RootNamespace>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.6.240628000" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.1742" />
<PackageReference Include="Docker.DotNet" Version="3.125.15" />
<PackageReference Include="System.Text.Json" Version="9.0.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<Content Include="Assets\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<!-- MSIX Packaging -->
<PropertyGroup>
<AppxPackageDir>$(OutDir)AppxPackages\</AppxPackageDir>
<AppxBundle>Always</AppxBundle>
<AppxBundlePlatforms>x64|arm64</AppxBundlePlatforms>
<GenerateAppInstallerFile>True</GenerateAppInstallerFile>
<AppInstallerUri>https://dashcaddy.net/installer/</AppInstallerUri>
<HoursBetweenUpdateChecks>4</HoursBetweenUpdateChecks>
</PropertyGroup>
</Project>
+184
View File
@@ -0,0 +1,184 @@
<Window
x:Class="DashCaddy.Desktop.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:DashCaddy.Desktop.ViewModels"
xmlns:controls="using:Microsoft.UI.Xaml.Controls"
mc:Ignorable="d"
Title="DashCaddy"
ExtendsContentIntoTitleBar="True"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
<Window.Resources>
<vm:StatusToColorConverter x:Key="StatusToColorConverter" />
<vm:StatusToTextConverter x:Key="StatusToTextConverter" />
</Window.Resources>
<Grid x:Name="RootGrid">
<Grid.RowDefinitions>
<RowDefinition Height="48" /> <!-- Title bar area -->
<RowDefinition Height="*" /> <!-- Main content -->
<RowDefinition Height="Auto" /> <!-- Bottom bar -->
</Grid.RowDefinitions>
<!-- Custom Title Bar -->
<Grid Grid.Row="0" Background="{ThemeResource SystemControlBackgroundChromeMediumBrush}" x:Name="TitleBarGrid">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- App Icon + Title -->
<StackPanel Grid.Column="0" Orientation="Horizontal" Margin="12,0,0,0" VerticalAlignment="Center">
<Image Source="Assets/dashcaddy.ico" Width="24" Height="24" Margin="0,0,8,0" />
<TextBlock Text="DashCaddy" FontSize="16" FontWeight="SemiBold" VerticalAlignment="Center" Foreground="{ThemeResource SystemControlForegroundBaseHighBrush}" />
<TextBlock Text="1.15.0" FontSize="12" FontWeight="Normal" VerticalAlignment="Center" Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" Margin="8,0,0,0" />
</StackPanel>
<!-- Status Indicators -->
<StackPanel Grid.Column="2" Orientation="Horizontal" Margin="0,0,12,0" VerticalAlignment="Center" Spacing="16">
<StackPanel Orientation="Horizontal" Spacing="6" ToolTipService.ToolTip="Services">
<Ellipse Width="10" Height="10" Fill="{Binding ServicesHealthy, Converter={StaticResource StatusToColorConverter}, FallbackValue=Gray}" VerticalAlignment="Center" />
<TextBlock Text="{Binding ServicesSummary}" VerticalAlignment="Center" FontSize="13" Foreground="{ThemeResource SystemControlForegroundBaseHighBrush}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6" ToolTipService.ToolTip="DNS">
<Ellipse Width="10" Height="10" Fill="{Binding DnsHealthy, Converter={StaticResource StatusToColorConverter}, FallbackValue=Gray}" VerticalAlignment="Center" />
<TextBlock Text="DNS" VerticalAlignment="Center" FontSize="13" Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6" ToolTipService.ToolTip="Certificates">
<Ellipse Width="10" Height="10" Fill="{Binding CertsHealthy, Converter={StaticResource StatusToColorConverter}, FallbackValue=Gray}" VerticalAlignment="Center" />
<TextBlock Text="{Binding CertsSummary}" VerticalAlignment="Center" FontSize="13" Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
</StackPanel>
</StackPanel>
</Grid>
<!-- Main Content -->
<Grid Grid.Row="1" Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Toolbar -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,12" Spacing="12">
<Button Content="+ Add Service" Click="AddService_Click" Style="{StaticResource AccentButtonStyle}"
ToolTipService.ToolTip="Add a new self-hosted service">
<Button.ContentTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Spacing="6">
<FontIcon Glyph="&#xE710;" FontSize="14" /> <!-- Add -->
<TextBlock Text="Add Service" />
</StackPanel>
</DataTemplate>
</Button.ContentTemplate>
</Button>
<Button Content="Import Compose" Click="ImportCompose_Click"
ToolTipService.ToolTip="Import Docker Compose file">
<Button.ContentTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Spacing="6">
<FontIcon Glyph="&#xE8E5;" FontSize="14" /> <!-- Import -->
<TextBlock Text="Import Compose" />
</StackPanel>
</DataTemplate>
</Button.ContentTemplate>
</Button>
<Button Content="Templates" Click="Templates_Click"
ToolTipService.ToolTip="Browse service templates (Plex, Home Assistant, etc.)">
<Button.ContentTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Spacing="6">
<FontIcon Glyph="&#xED44;" FontSize="14" /> <!-- Library -->
<TextBlock Text="Templates" />
</StackPanel>
</DataTemplate>
</Button.ContentTemplate>
</Button>
</StackPanel>
<!-- Services List -->
<Border Grid.Row="1" BorderBrush="{ThemeResource SystemControlForegroundBaseLowBrush}" BorderThickness="1" CornerRadius="8" Background="{ThemeResource SystemControlBackgroundAltHighBrush}">
<ListView x:Name="ServicesList" ItemsSource="{Binding Services}" SelectionMode="None"
Background="Transparent" BorderThickness="0">
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Padding" Value="12,8" />
<Setter Property="Margin" Value="0" />
</Style>
</ListView.ItemContainerStyle>
<ListView.ItemTemplate>
<DataTemplate x:DataType="vm:ServiceViewModel">
<Grid Margin="4,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Status Indicator -->
<Ellipse Grid.Column="0" Width="14" Height="14" Margin="0,0,12,0" VerticalAlignment="Center"
Fill="{Binding Health, Converter={StaticResource StatusToColorConverter}}"
ToolTipService.ToolTip="{Binding Health, Converter={StaticResource StatusToTextConverter}}" />
<!-- Service Info -->
<StackPanel Grid.Column="1" Orientation="Vertical" Spacing="2" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" FontSize="14" FontWeight="Medium" Foreground="{ThemeResource SystemControlForegroundBaseHighBrush}" />
<TextBlock Text="{Binding Url}" FontSize="12" Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
</StackPanel>
<!-- Type Badge -->
<Border Grid.Column="2" Background="{ThemeResource SystemControlBackgroundListLowBrush}" CornerRadius="4" Padding="6,2" Margin="12,0" VerticalAlignment="Center">
<TextBlock Text="{Binding Type}" FontSize="11" FontWeight="Medium" Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
</Border>
<!-- Actions Menu -->
<Button Grid.Column="3" Style="{StaticResource MinimalButtonStyle}" Margin="0,0,4,0" VerticalAlignment="Center"
Click="ServiceAction_Click" Tag="{Binding}">
<FontIcon Glyph="&#xE712;" FontSize="14" Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" /> <!-- More -->
<Button.Flyout>
<MenuFlyout>
<MenuFlyoutItem Text="Open" Icon="Globe" Click="OpenService_Click" />
<MenuFlyoutItem Text="Logs" Icon="Document" Click="ViewLogs_Click" />
<MenuFlyoutItem Text="Restart" Icon="Refresh" Click="RestartService_Click" />
<MenuFlyoutItem Text="Stop" Icon="Stop" Click="StopService_Click" />
<MenuFlyoutSeparator />
<MenuFlyoutItem Text="Edit" Icon="Edit" Click="EditService_Click" />
<MenuFlyoutItem Text="Remove" Icon="Delete" Click="RemoveService_Click" />
</MenuFlyout>
</Button.Flyout>
</Button>
<!-- Toggle Switch -->
<ToggleSwitch Grid.Column="4" IsOn="{Binding IsRunning, Mode=TwoWay}"
OnContent="" OffContent="" MinWidth="56" VerticalAlignment="Center"
Toggled="ServiceToggled" Tag="{Binding}" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Border>
</Grid>
<!-- Bottom Bar -->
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,16,16" Spacing="8">
<Button Content="Open Dashboard" Click="OpenDashboard_Click" Style="{StaticResource AccentButtonStyle}" />
<Button Content="View Logs" Click="ViewLogs_Click" />
<Button Content="Settings" Click="OpenSettings_Click" />
<Button Content="Help" Click="OpenHelp_Click" />
</StackPanel>
<!-- Loading Overlay -->
<Grid x:Name="LoadingOverlay" Grid.Row="0" Grid.RowSpan="3" Background="{ThemeResource SystemControlBackgroundAltHighBrush}" Opacity="0.9" Visibility="Collapsed">
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="16">
<ProgressRing Width="48" Height="48" IsActive="True" />
<TextBlock x:Name="LoadingText" Text="Loading..." FontSize="16" Foreground="{ThemeResource SystemControlForegroundBaseHighBrush}" />
</StackPanel>
</Grid>
</Grid>
</Window>
+229
View File
@@ -0,0 +1,229 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using DashCaddy.Desktop.ViewModels;
using DashCaddy.Desktop.Models;
using System.Threading.Tasks;
namespace DashCaddy.Desktop;
public sealed partial class MainWindow : Window
{
public MainViewModel ViewModel => App.MainViewModel;
public MainWindow()
{
InitializeComponent();
Loaded += MainWindow_Loaded;
}
private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
await ViewModel.InitializeAsync();
}
// ─── Toolbar Actions ───
private async void AddService_Click(object sender, RoutedEventArgs e)
{
var dialog = new AddServiceDialog(ViewModel);
dialog.XamlRoot = this.Content.XamlRoot;
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
await ViewModel.RefreshServicesAsync();
}
}
private async void ImportCompose_Click(object sender, RoutedEventArgs e)
{
var picker = new Windows.Storage.Pickers.FileOpenPicker();
picker.FileTypeFilter.Add(".yaml");
picker.FileTypeFilter.Add(".yml");
picker.SuggestedStartLocation = Windows.Storage.Pickers.PickerLocationId.DocumentsLibrary;
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
WinRT.Interop.InitializeWithWindow.Initialize(picker, hwnd);
var file = await picker.PickSingleFileAsync();
if (file != null)
{
ShowLoading("Importing Docker Compose...");
try
{
await ViewModel.ImportComposeAsync(file.Path);
await ViewModel.RefreshServicesAsync();
}
finally
{
HideLoading();
}
}
}
private void Templates_Click(object sender, RoutedEventArgs e)
{
var dialog = new TemplatesDialog(ViewModel);
dialog.XamlRoot = this.Content.XamlRoot;
_ = dialog.ShowAsync();
}
// ─── Service Actions ───
private async void ServiceAction_Click(object sender, RoutedEventArgs e)
{
// Handled by Flyout menu items
}
private async void OpenService_Click(object sender, RoutedEventArgs e)
{
if (sender is MenuFlyoutItem item && item.DataContext is ServiceViewModel service)
{
await Windows.System.Launcher.LaunchUriAsync(new System.Uri(service.Url));
}
}
private async void ViewLogs_Click(object sender, RoutedEventArgs e)
{
ServiceViewModel service = null;
if (sender is MenuFlyoutItem item && item.DataContext is ServiceViewModel svc)
{
service = svc;
}
else if (sender is Button)
{
// "View Logs" bottom button - show all logs
var dialog = new LogsDialog(ViewModel);
dialog.XamlRoot = this.Content.XamlRoot;
await dialog.ShowAsync();
return;
}
if (service != null)
{
var dialog = new ServiceLogsDialog(service, ViewModel.DockerService);
dialog.XamlRoot = this.Content.XamlRoot;
await dialog.ShowAsync();
}
}
private async void RestartService_Click(object sender, RoutedEventArgs e)
{
if (sender is MenuFlyoutItem item && item.DataContext is ServiceViewModel service)
{
ShowLoading($"Restarting {service.Name}...");
try
{
await ViewModel.RestartServiceAsync(service.Id);
await ViewModel.RefreshServicesAsync();
}
finally
{
HideLoading();
}
}
}
private async void StopService_Click(object sender, RoutedEventArgs e)
{
if (sender is MenuFlyoutItem item && item.DataContext is ServiceViewModel service)
{
await ViewModel.StopServiceAsync(service.Id);
await ViewModel.RefreshServicesAsync();
}
}
private async void EditService_Click(object sender, RoutedEventArgs e)
{
if (sender is MenuFlyoutItem item && item.DataContext is ServiceViewModel service)
{
var dialog = new EditServiceDialog(service, ViewModel);
dialog.XamlRoot = this.Content.XamlRoot;
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
await ViewModel.RefreshServicesAsync();
}
}
}
private async void RemoveService_Click(object sender, RoutedEventArgs e)
{
if (sender is MenuFlyoutItem item && item.DataContext is ServiceViewModel service)
{
var dialog = new ContentDialog
{
Title = "Remove Service",
Content = $"Are you sure you want to remove '{service.Name}'? This will stop the container and remove the reverse proxy configuration.",
PrimaryButtonText = "Remove",
CloseButtonText = "Cancel",
DefaultButton = ContentDialogButton.Close,
XamlRoot = this.Content.XamlRoot
};
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
ShowLoading($"Removing {service.Name}...");
try
{
await ViewModel.RemoveServiceAsync(service.Id);
await ViewModel.RefreshServicesAsync();
}
finally
{
HideLoading();
}
}
}
}
private async void ServiceToggled(object sender, RoutedEventArgs e)
{
if (sender is ToggleSwitch toggle && toggle.Tag is ServiceViewModel service)
{
if (toggle.IsOn)
{
ShowLoading($"Starting {service.Name}...");
await ViewModel.StartServiceAsync(service.Id);
}
else
{
ShowLoading($"Stopping {service.Name}...");
await ViewModel.StopServiceAsync(service.Id);
}
await ViewModel.RefreshServicesAsync();
HideLoading();
}
}
// ─── Bottom Bar ───
private async void OpenDashboard_Click(object sender, RoutedEventArgs e)
{
await Windows.System.Launcher.LaunchUriAsync(new System.Uri(ViewModel.DashboardUrl));
}
private async void OpenSettings_Click(object sender, RoutedEventArgs e)
{
var dialog = new SettingsDialog(ViewModel);
dialog.XamlRoot = this.Content.XamlRoot;
await dialog.ShowAsync();
}
private async void OpenHelp_Click(object sender, RoutedEventArgs e)
{
await Windows.System.Launcher.LaunchUriAsync(new System.Uri("https://dashcaddy.net/docs"));
}
// ─── Loading Overlay ───
private void ShowLoading(string message)
{
LoadingText.Text = message;
LoadingOverlay.Visibility = Visibility.Visible;
}
private void HideLoading()
{
LoadingOverlay.Visibility = Visibility.Collapsed;
}
}
+98
View File
@@ -0,0 +1,98 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace DashCaddy.Desktop.Models;
public class ServiceModel
{
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonPropertyName("url")]
public string Url { get; set; }
[JsonPropertyName("port")]
public int Port { get; set; }
[JsonPropertyName("host")]
public string Host { get; set; }
[JsonPropertyName("health")]
public string Health { get; set; }
[JsonPropertyName("state")]
public string State { get; set; }
[JsonPropertyName("environment")]
public Dictionary<string, string> Environment { get; set; } = new();
[JsonPropertyName("volumes")]
public List<string> Volumes { get; set; } = new();
[JsonPropertyName("labels")]
public Dictionary<string, string> Labels { get; set; } = new();
[JsonPropertyName("image")]
public string Image { get; set; }
[JsonPropertyName("composeFile")]
public string ComposeFile { get; set; }
}
public class HealthResponse
{
[JsonPropertyName("status")]
public string Status { get; set; }
[JsonPropertyName("dns")]
public string Dns { get; set; }
[JsonPropertyName("certs")]
public string Certs { get; set; }
[JsonPropertyName("certsDaysRemaining")]
public int CertsDaysRemaining { get; set; }
[JsonPropertyName("services")]
public ServiceHealthSummary[] Services { get; set; }
}
public class ServiceHealthSummary
{
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("health")]
public string Health { get; set; }
[JsonPropertyName("lastCheck")]
public string LastCheck { get; set; }
}
public class ComposeService
{
public string Name { get; set; }
public string Image { get; set; }
public Dictionary<string, string> Ports { get; set; } = new();
public Dictionary<string, string> Environment { get; set; } = new();
public List<string> Volumes { get; set; } = new();
public Dictionary<string, string> Labels { get; set; } = new();
public Dictionary<string, object> Deploy { get; set; } = new();
}
public class ComposeFile
{
public string Version { get; set; }
public Dictionary<string, ComposeService> Services { get; set; } = new();
public Dictionary<string, object> Networks { get; set; } = new();
public Dictionary<string, object> Volumes { get; set; } = new();
}
+88
View File
@@ -0,0 +1,88 @@
using System.Net.Http.Json;
using System.Text.Json;
using DashCaddy.Desktop.Models;
namespace DashCaddy.Desktop.Services;
public class ApiClient
{
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
public ApiClient(string baseUrl)
{
_baseUrl = baseUrl.TrimEnd('/');
_httpClient = new HttpClient
{
BaseAddress = new Uri(_baseUrl),
Timeout = TimeSpan.FromSeconds(30)
};
}
public async Task<List<ServiceModel>> GetServicesAsync()
{
var response = await _httpClient.GetAsync("/api/v1/services");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ApiResponse<List<ServiceModel>>>();
return result?.Data ?? new List<ServiceModel>();
}
public async Task<ServiceModel> GetServiceAsync(string id)
{
var response = await _httpClient.GetAsync($"/api/v1/services/{id}");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ApiResponse<ServiceModel>>();
return result?.Data;
}
public async Task<ServiceModel> CreateServiceAsync(ServiceModel service)
{
var response = await _httpClient.PostAsJsonAsync("/api/v1/services", service);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ApiResponse<ServiceModel>>();
return result?.Data;
}
public async Task<ServiceModel> UpdateServiceAsync(string id, ServiceModel service)
{
var response = await _httpClient.PutAsJsonAsync($"/api/v1/services/{id}", service);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ApiResponse<ServiceModel>>();
return result?.Data;
}
public async Task RemoveServiceAsync(string id)
{
var response = await _httpClient.DeleteAsync($"/api/v1/services/{id}");
response.EnsureSuccessStatusCode();
}
public async Task<HealthResponse> GetHealthAsync()
{
var response = await _httpClient.GetAsync("/api/v1/health");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ApiResponse<HealthResponse>>();
return result?.Data ?? new HealthResponse();
}
public async Task<Dictionary<string, string>> GetConfigAsync()
{
var response = await _httpClient.GetAsync("/api/v1/config");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ApiResponse<Dictionary<string, string>>>();
return result?.Data ?? new Dictionary<string, string>();
}
public async Task UpdateConfigAsync(Dictionary<string, string> config)
{
var response = await _httpClient.PutAsJsonAsync("/api/v1/config", config);
response.EnsureSuccessStatusCode();
}
private class ApiResponse<T>
{
public bool Success { get; set; }
public T Data { get; set; }
public string Error { get; set; }
}
}
+57
View File
@@ -0,0 +1,57 @@
using System.Text;
using DashCaddy.Desktop.Models;
namespace DashCaddy.Desktop.Services;
public class CaddyConfigGenerator
{
private const string CaddyfilePath = @"E:\dockerdata\dashcaddy\caddy\Caddyfile";
public void RegenerateConfig(List<ServiceModel> services)
{
var sb = new StringBuilder();
// Global options
sb.AppendLine("{");
sb.AppendLine(" admin :2019");
sb.AppendLine(" email admin@example.com");
sb.AppendLine("}");
sb.AppendLine();
// Each service gets a site block
foreach (var svc in services.Where(s => s.Health != "unhealthy"))
{
var domain = ExtractDomain(svc.Url);
if (string.IsNullOrEmpty(domain)) continue;
sb.AppendLine($"{domain} {{");
sb.AppendLine($" reverse_proxy {svc.Host}:{svc.Port}");
sb.AppendLine(" tls internal");
sb.AppendLine("}");
sb.AppendLine();
}
File.WriteAllText(CaddyfilePath, sb.ToString());
}
public async Task ReloadCaddyAsync()
{
// Call Caddy admin API to reload config
using var http = new HttpClient();
await http.PostAsync("http://localhost:2019/load",
new StringContent(File.ReadAllText(CaddyfilePath), Encoding.UTF8, "text/caddyfile"));
}
private string ExtractDomain(string url)
{
try
{
var uri = new Uri(url);
return uri.Host;
}
catch
{
return null;
}
}
}
+382
View File
@@ -0,0 +1,382 @@
using System.Net.Http.Json;
using DashCaddy.Desktop.Models;
namespace DashCaddy.Desktop.Services;
public enum DnsProviderType
{
None, // No DNS management - use existing DNS
Technitium,
CoreDNS,
Cloudflare,
Route53,
Custom
}
public class DnsProviderConfig
{
public DnsProviderType Type { get; set; }
public string Name { get; set; }
public string BaseUrl { get; set; }
public string ApiKey { get; set; }
public string ZoneId { get; set; }
public Dictionary<string, string> Extra { get; set; } = new();
}
public class DnsClient
{
private readonly HttpClient _httpClient;
private readonly DnsProviderConfig _config;
public DnsProviderType ProviderType => _config?.Type ?? DnsProviderType.None;
public DnsClient(DnsProviderConfig config)
{
_config = config;
if (config?.Type != DnsProviderType.None)
{
_httpClient = new HttpClient { BaseAddress = new Uri(config.BaseUrl) };
if (!string.IsNullOrEmpty(config.ApiKey))
{
_httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {config.ApiKey}");
}
}
}
public static DnsClient Create(DnsProviderType type, Dictionary<string, string> settings)
{
if (type == DnsProviderType.None)
{
return null;
}
var config = type switch
{
DnsProviderType.Technitium => new DnsProviderConfig
{
Type = DnsProviderType.Technitium,
Name = "Technitium DNS",
BaseUrl = settings.GetValueOrDefault("technitium_url", "http://localhost:5380/api"),
ApiKey = settings.GetValueOrDefault("technitium_api_key", ""),
ZoneId = settings.GetValueOrDefault("technitium_zone", "")
},
DnsProviderType.CoreDNS => new DnsProviderConfig
{
Type = DnsProviderType.CoreDNS,
Name = "CoreDNS",
BaseUrl = settings.GetValueOrDefault("coredns_api_url", "http://localhost:8080"),
ApiKey = "",
ZoneId = settings.GetValueOrDefault("coredns_zone", "local")
},
DnsProviderType.Cloudflare => new DnsProviderConfig
{
Type = DnsProviderType.Cloudflare,
Name = "Cloudflare",
BaseUrl = "https://api.cloudflare.com/client/v4",
ApiKey = settings.GetValueOrDefault("cloudflare_api_token", ""),
ZoneId = settings.GetValueOrDefault("cloudflare_zone_id", "")
},
DnsProviderType.Route53 => new DnsProviderConfig
{
Type = DnsProviderType.Route53,
Name = "AWS Route 53",
BaseUrl = "https://route53.amazonaws.com",
ApiKey = settings.GetValueOrDefault("aws_access_key", ""),
Extra = new Dictionary<string, string>
{
["secret_key"] = settings.GetValueOrDefault("aws_secret_key", ""),
["region"] = settings.GetValueOrDefault("aws_region", "us-east-1")
}
},
_ => new DnsProviderConfig
{
Type = DnsProviderType.Custom,
Name = "Custom DNS",
BaseUrl = settings.GetValueOrDefault("custom_dns_url", ""),
ApiKey = settings.GetValueOrDefault("custom_dns_key", "")
}
};
return new DnsClient(config);
}
public async Task<List<DnsRecord>> GetRecordsAsync(string zone = null)
{
if (_config?.Type == DnsProviderType.None) return new List<DnsRecord>();
zone ??= _config?.ZoneId;
if (string.IsNullOrEmpty(zone)) return new List<DnsRecord>();
return _config.Type switch
{
DnsProviderType.Technitium => await GetTechnitiumRecordsAsync(zone),
DnsProviderType.CoreDNS => await GetCoreDnsRecordsAsync(zone),
DnsProviderType.Cloudflare => await GetCloudflareRecordsAsync(zone),
DnsProviderType.Route53 => await GetRoute53RecordsAsync(zone),
_ => new List<DnsRecord>()
};
}
public async Task<DnsRecord> CreateRecordAsync(string zone, DnsRecord record)
{
if (_config?.Type == DnsProviderType.None) return record;
return _config.Type switch
{
DnsProviderType.Technitium => await CreateTechnitiumRecordAsync(zone, record),
DnsProviderType.CoreDNS => await CreateCoreDnsRecordAsync(zone, record),
DnsProviderType.Cloudflare => await CreateCloudflareRecordAsync(zone, record),
DnsProviderType.Route53 => await CreateRoute53RecordAsync(zone, record),
_ => record
};
}
public async Task<DnsRecord> UpdateRecordAsync(string zone, string recordId, DnsRecord record)
{
if (_config?.Type == DnsProviderType.None) return record;
return _config.Type switch
{
DnsProviderType.Technitium => await UpdateTechnitiumRecordAsync(zone, recordId, record),
DnsProviderType.CoreDNS => await UpdateCoreDnsRecordAsync(zone, recordId, record),
DnsProviderType.Cloudflare => await UpdateCloudflareRecordAsync(zone, recordId, record),
DnsProviderType.Route53 => await UpdateRoute53RecordAsync(zone, recordId, record),
_ => record
};
}
public async Task DeleteRecordAsync(string zone, string recordId)
{
if (_config?.Type == DnsProviderType.None) return;
switch (_config.Type)
{
case DnsProviderType.Technitium:
await DeleteTechnitiumRecordAsync(zone, recordId);
break;
case DnsProviderType.CoreDNS:
await DeleteCoreDnsRecordAsync(zone, recordId);
break;
case DnsProviderType.Cloudflare:
await DeleteCloudflareRecordAsync(zone, recordId);
break;
case DnsProviderType.Route53:
await DeleteRoute53RecordAsync(zone, recordId);
break;
}
}
// ─── Technitium ───
private async Task<List<DnsRecord>> GetTechnitiumRecordsAsync(string zone)
{
var response = await _httpClient.GetAsync($"/zones/{zone}/records");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<TechnitiumZoneResponse>();
return result?.Records?.Select(r => new DnsRecord
{
Id = r.Id,
Name = r.Name,
Type = r.Type,
Content = r.Value,
Ttl = r.Ttl
}).ToList() ?? new List<DnsRecord>();
}
private async Task<DnsRecord> CreateTechnitiumRecordAsync(string zone, DnsRecord record)
{
var req = new TechnitiumRecordRequest
{
Name = record.Name,
Type = record.Type,
Value = record.Content,
Ttl = record.Ttl
};
var response = await _httpClient.PostAsJsonAsync($"/zones/{zone}/records", req);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<TechnitiumRecordResponse>();
record.Id = result?.Record?.Id;
return record;
}
private async Task<DnsRecord> UpdateTechnitiumRecordAsync(string zone, string recordId, DnsRecord record)
{
var req = new TechnitiumRecordRequest
{
Name = record.Name,
Type = record.Type,
Value = record.Content,
Ttl = record.Ttl
};
var response = await _httpClient.PutAsJsonAsync($"/zones/{zone}/records/{recordId}", req);
response.EnsureSuccessStatusCode();
return record;
}
private async Task DeleteTechnitiumRecordAsync(string zone, string recordId)
{
var response = await _httpClient.DeleteAsync($"/zones/{zone}/records/{recordId}");
response.EnsureSuccessStatusCode();
}
// ─── CoreDNS (via API wrapper) ───
private async Task<List<DnsRecord>> GetCoreDnsRecordsAsync(string zone)
{
var response = await _httpClient.GetAsync($"/api/v1/dns/zones/{zone}/records");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ApiResponse<List<DnsRecord>>>();
return result?.Data ?? new List<DnsRecord>();
}
private async Task<DnsRecord> CreateCoreDnsRecordAsync(string zone, DnsRecord record)
{
var response = await _httpClient.PostAsJsonAsync($"/api/v1/dns/zones/{zone}/records", record);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ApiResponse<DnsRecord>>();
return result?.Data ?? record;
}
private async Task<DnsRecord> UpdateCoreDnsRecordAsync(string zone, string recordId, DnsRecord record)
{
var response = await _httpClient.PutAsJsonAsync($"/api/v1/dns/zones/{zone}/records/{recordId}", record);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ApiResponse<DnsRecord>>();
return result?.Data ?? record;
}
private async Task DeleteCoreDnsRecordAsync(string zone, string recordId)
{
var response = await _httpClient.DeleteAsync($"/api/v1/dns/zones/{zone}/records/{recordId}");
response.EnsureSuccessStatusCode();
}
// ─── Cloudflare ───
private async Task<List<DnsRecord>> GetCloudflareRecordsAsync(string zone)
{
var response = await _httpClient.GetAsync($"/zones/{zone}/dns_records");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<CloudflareResponse>();
return result?.Result?.Select(r => new DnsRecord
{
Id = r.Id,
Name = r.Name,
Type = r.Type,
Content = r.Content,
Ttl = r.Ttl ?? 300,
Proxied = r.Proxied ?? false
}).ToList() ?? new List<DnsRecord>();
}
private async Task<DnsRecord> CreateCloudflareRecordAsync(string zone, DnsRecord record)
{
var req = new CloudflareRecordRequest
{
Type = record.Type,
Name = record.Name,
Content = record.Content,
Ttl = record.Ttl,
Proxied = record.Proxied
};
var response = await _httpClient.PostAsJsonAsync($"/zones/{zone}/dns_records", req);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<CloudflareSingleResponse>();
record.Id = result?.Result?.Id;
return record;
}
private async Task<DnsRecord> UpdateCloudflareRecordAsync(string zone, string recordId, DnsRecord record)
{
var req = new CloudflareRecordRequest
{
Type = record.Type,
Name = record.Name,
Content = record.Content,
Ttl = record.Ttl,
Proxied = record.Proxied
};
var response = await _httpClient.PutAsJsonAsync($"/zones/{zone}/dns_records/{recordId}", req);
response.EnsureSuccessStatusCode();
return record;
}
private async Task DeleteCloudflareRecordAsync(string zone, string recordId)
{
var response = await _httpClient.DeleteAsync($"/zones/{zone}/dns_records/{recordId}");
response.EnsureSuccessStatusCode();
}
// ─── Route 53 ───
private async Task<List<DnsRecord>> GetRoute53RecordsAsync(string zone)
{
return new List<DnsRecord>();
}
private async Task<DnsRecord> CreateRoute53RecordAsync(string zone, DnsRecord record) => record;
private async Task<DnsRecord> UpdateRoute53RecordAsync(string zone, string recordId, DnsRecord record) => record;
private async Task DeleteRoute53RecordAsync(string zone, string recordId) { }
// ─── Response DTOs ───
private class ApiResponse<T>
{
public bool Success { get; set; }
public T Data { get; set; }
public string Error { get; set; }
}
private class TechnitiumZoneResponse
{
public List<TechnitiumRecord> Records { get; set; }
}
private class TechnitiumRecord
{
public string Id { get; set; }
public string Name { get; set; }
public string Type { get; set; }
public string Value { get; set; }
public int Ttl { get; set; }
}
private class TechnitiumRecordRequest
{
public string Name { get; set; }
public string Type { get; set; }
public string Value { get; set; }
public int Ttl { get; set; }
}
private class TechnitiumRecordResponse
{
public TechnitiumRecord Record { get; set; }
}
private class CloudflareResponse
{
public bool Success { get; set; }
public List<CloudflareRecord> Result { get; set; }
}
private class CloudflareRecord
{
public string Id { get; set; }
public string Name { get; set; }
public string Type { get; set; }
public string Content { get; set; }
public int? Ttl { get; set; }
public bool? Proxied { get; set; }
}
private class CloudflareRecordRequest
{
public string Type { get; set; }
public string Name { get; set; }
public string Content { get; set; }
public int Ttl { get; set; }
public bool Proxied { get; set; }
}
private class CloudflareSingleResponse
{
public bool Success { get; set; }
public CloudflareRecord Result { get; set; }
}
}
+125
View File
@@ -0,0 +1,125 @@
using Docker.DotNet;
using Docker.DotNet.Models;
using DashCaddy.Desktop.Models;
using System.Text.Json;
namespace DashCaddy.Desktop.Services;
public class DockerService
{
private readonly DockerClient _client;
public DockerService(string endpoint)
{
_client = new DockerClientConfiguration(new Uri(endpoint)).CreateClient();
}
public async Task<List<ContainerListResponse>> GetContainersAsync(string labelFilter = null)
{
var parameters = new ContainersListParameters
{
All = true,
Filters = new Dictionary<string, IDictionary<string, bool>>
{
["label"] = new Dictionary<string, bool> { ["dashcaddy.managed"] = true }
}
};
if (!string.IsNullOrEmpty(labelFilter))
{
parameters.Filters["name"] = new Dictionary<string, bool> { [labelFilter] = true };
}
return await _client.Containers.ListContainersAsync(parameters);
}
public async Task<ContainerInspectResponse> GetContainerAsync(string id)
{
return await _client.Containers.InspectContainerAsync(id);
}
public async Task StartContainerAsync(string id)
{
await _client.Containers.StartContainerAsync(id, null);
}
public async Task StopContainerAsync(string id)
{
await _client.Containers.StopContainerAsync(id, new ContainerStopParameters { WaitBeforeKillSeconds = 10 });
}
public async Task RestartContainerAsync(string id)
{
await _client.Containers.RestartContainerAsync(id, new ContainerRestartParameters { WaitBeforeKillSeconds = 10 });
}
public async Task RemoveContainerAsync(string id)
{
await _client.Containers.RemoveContainerAsync(id, new ContainerRemoveParameters { Force = true, RemoveVolumes = false });
}
public async Task CreateContainerAsync(ServiceModel service)
{
var createParams = new CreateContainerParameters
{
Image = service.Image,
Name = $"dashcaddy-{service.Id}",
Labels = new Dictionary<string, string>
{
["dashcaddy.managed"] = "true",
["dashcaddy.service-id"] = service.Id,
["dashcaddy.service-name"] = service.Name
},
Env = service.Environment.Select(kvp => $"{kvp.Key}={kvp.Value}").ToList(),
HostConfig = new HostConfig
{
PortBindings = service.Environment.TryGetValue("PORT", out var port) && int.TryParse(port, out var p)
? new Dictionary<string, IList<PortBinding>>
{
[$"{p}/tcp"] = new List<PortBinding> { new PortBinding { HostPort = p.ToString() } }
}
: new Dictionary<string, IList<PortBinding>>(),
RestartPolicy = new RestartPolicy { Name = "unless-stopped" }
}
};
if (service.Volumes != null)
{
createParams.HostConfig.Binds = service.Volumes.ToList();
}
await _client.Containers.CreateContainerAsync(createParams);
}
public async Task<string> GetContainerLogsAsync(string id, int tailLines = 100)
{
var parameters = new ContainerLogsParameters
{
ShowStdout = true,
ShowStderr = true,
Tail = tailLines.ToString()
};
using var stream = await _client.Containers.GetContainerLogsAsync(id, false, parameters);
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync();
}
public async Task<bool> IsDockerRunningAsync()
{
try
{
await _client.System.PingAsync();
return true;
}
catch
{
return false;
}
}
public async Task<VersionResponse> GetVersionAsync()
{
return await _client.System.GetVersionAsync();
}
}
+284
View File
@@ -0,0 +1,284 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using DashCaddy.Desktop.Models;
namespace DashCaddy.Desktop.Services;
public static class ComposeParser
{
public static List<ServiceModel> Parse(string yamlContent)
{
var services = new List<ServiceModel>();
// Simple YAML parsing - in production use YamlDotNet
// For now, we'll do a basic JSON-based approach assuming compose is converted
try
{
var json = JsonSerializer.Deserialize<JsonNode>(yamlContent); // This won't work for YAML directly
// Real implementation would use YamlDotNet
}
catch
{
// Fallback
}
return services;
}
public static List<ServiceModel> ParseJson(string jsonContent)
{
var compose = JsonSerializer.Deserialize<ComposeFile>(jsonContent, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
var services = new List<ServiceModel>();
if (compose?.Services != null)
{
foreach (var (name, svc) in compose.Services)
{
var port = ExtractPort(svc);
var env = svc.Environment ?? new Dictionary<string, string>();
services.Add(new ServiceModel
{
Id = Guid.NewGuid().ToString("N")[..8],
Name = name,
Type = "docker-compose",
Image = svc.Image ?? "",
Host = "localhost",
Port = port,
Environment = env,
Volumes = svc.Volumes ?? new List<string>(),
Labels = svc.Labels ?? new Dictionary<string, string>(),
ComposeFile = jsonContent
});
}
}
return services;
}
private static int ExtractPort(ComposeService svc)
{
if (svc.Ports != null)
{
foreach (var (key, value) in svc.Ports)
{
if (int.TryParse(key.Split(':')[0], out var hostPort))
return hostPort;
if (int.TryParse(key, out var containerPort))
return containerPort;
}
}
return 80;
}
}
public static class TemplateRegistry
{
private static readonly Dictionary<string, ServiceTemplate> Templates = new()
{
["plex"] = new ServiceTemplate
{
Id = "plex",
Name = "Plex Media Server",
Description = "Media server for movies, TV shows, and music",
Icon = "📺",
DefaultPort = 32400,
Image = "plexinc/pms-docker:latest",
Environment = new Dictionary<string, string>
{
["PLEX_CLAIM"] = "",
["TZ"] = "America/Los_Angeles"
},
Volumes = new List<string>
{
"E:/dockerdata/plex/config:/config",
"E:/dockerdata/plex/transcode:/transcode",
"E:/media:/data"
},
RequiredVariables = new[] { "PLEX_CLAIM" }
},
["homeassistant"] = new ServiceTemplate
{
Id = "homeassistant",
Name = "Home Assistant",
Description = "Open source home automation platform",
Icon = "🏠",
DefaultPort = 8123,
Image = "ghcr.io/home-assistant/home-assistant:stable",
Environment = new Dictionary<string, string>
{
["TZ"] = "America/Los_Angeles"
},
Volumes = new List<string>
{
"E:/dockerdata/homeassistant:/config"
},
NetworkMode = "host"
},
["jellyfin"] = new ServiceTemplate
{
Id = "jellyfin",
Name = "Jellyfin",
Description = "Free software media system",
Icon = "🎬",
DefaultPort = 8096,
Image = "jellyfin/jellyfin:latest",
Environment = new Dictionary<string, string>
{
["TZ"] = "America/Los_Angeles"
},
Volumes = new List<string>
{
"E:/dockerdata/jellyfin/config:/config",
"E:/dockerdata/jellyfin/cache:/cache",
"E:/media:/media"
}
},
["portainer"] = new ServiceTemplate
{
Id = "portainer",
Name = "Portainer",
Description = "Docker container management UI",
Icon = "🐳",
DefaultPort = 9443,
Image = "portainer/portainer-ce:latest",
Volumes = new List<string>
{
"E:/dockerdata/portainer:/data",
"//./pipe/docker_engine:/var/run/docker.sock"
}
},
["adguard"] = new ServiceTemplate
{
Id = "adguard",
Name = "AdGuard Home",
Description = "Network-wide ad blocking & DNS",
Icon = "🛡️",
DefaultPort = 3000,
Image = "adguard/adguardhome:latest",
Volumes = new List<string>
{
"E:/dockerdata/adguard/work:/opt/adguardhome/work",
"E:/dockerdata/adguard/conf:/opt/adguardhome/conf"
},
Ports = new Dictionary<int, int> { [53] = 53, [3000] = 3000 }
},
["uptime-kuma"] = new ServiceTemplate
{
Id = "uptime-kuma",
Name = "Uptime Kuma",
Description = "Self-hosted monitoring tool",
Icon = "📊",
DefaultPort = 3001,
Image = "louislam/uptime-kuma:1",
Volumes = new List<string>
{
"E:/dockerdata/uptime-kuma:/app/data"
}
},
["vaultwarden"] = new ServiceTemplate
{
Id = "vaultwarden",
Name = "Vaultwarden (Bitwarden)",
Description = "Self-hosted password manager",
Icon = "🔐",
DefaultPort = 8080,
Image = "vaultwarden/server:latest",
Environment = new Dictionary<string, string>
{
["SIGNUPS_ALLOWED"] = "true",
["INVITATIONS_ALLOWED"] = "true"
},
Volumes = new List<string>
{
"E:/dockerdata/vaultwarden:/data"
}
},
["paperless"] = new ServiceTemplate
{
Id = "paperless",
Name = "Paperless-ngx",
Description = "Document management system",
Icon = "📄",
DefaultPort = 8000,
Image = "ghcr.io/paperless-ngx/paperless-ngx:latest",
Environment = new Dictionary<string, string>
{
["PAPERLESS_REDIS"] = "redis://localhost:6379",
["PAPERLESS_DBHOST"] = "localhost",
["PAPERLESS_DBNAME"] = "paperless",
["PAPERLESS_DBUSER"] = "paperless",
["PAPERLESS_DBPASS"] = "paperless"
},
Volumes = new List<string>
{
"E:/dockerdata/paperless/data:/usr/src/paperless/data",
"E:/dockerdata/paperless/media:/usr/src/paperless/media",
"E:/dockerdata/paperless/export:/usr/src/paperless/export",
"E:/dockerdata/paperless/consume:/usr/src/paperless/consume"
}
}
};
public static ServiceTemplate Get(string id)
{
return Templates.TryGetValue(id, out var template) ? template : null;
}
public static IEnumerable<ServiceTemplate> GetAll()
{
return Templates.Values;
}
}
public class ServiceTemplate
{
public string Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public string Icon { get; set; }
public int DefaultPort { get; set; }
public string Image { get; set; }
public Dictionary<string, string> Environment { get; set; } = new();
public List<string> Volumes { get; set; } = new();
public Dictionary<int, int> Ports { get; set; } = new();
public string NetworkMode { get; set; }
public string[] RequiredVariables { get; set; } = Array.Empty<string>();
public ServiceModel Instantiate(Dictionary<string, string> variables)
{
var env = new Dictionary<string, string>(Environment);
foreach (var (key, value) in variables)
{
env[key] = value;
}
var port = DefaultPort;
if (Ports.Count > 0)
{
port = Ports.Keys.First();
}
return new ServiceModel
{
Id = Guid.NewGuid().ToString("N")[..8],
Name = Name,
Type = "template",
Image = Image,
Host = "localhost",
Port = port,
Environment = env,
Volumes = Volumes,
Labels = new Dictionary<string, string>
{
["dashcaddy.template"] = Id,
["dashcaddy.managed"] = "true"
}
};
}
}
+96
View File
@@ -0,0 +1,96 @@
<ContentDialog
x:Class="DashCaddy.Desktop.SettingsDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:DashCaddy.Desktop.ViewModels"
mc:Ignorable="d"
Title="Settings"
PrimaryButtonText="Save"
CloseButtonText="Cancel"
DefaultButton="Primary"
Width="600">
<ScrollViewer VerticalScrollBarVisibility="Auto" MaxHeight="600">
<StackPanel Spacing="20" Margin="8">
<!-- Domain Settings -->
<Border Style="{StaticResource CardStyle}">
<StackPanel Spacing="12">
<TextBlock Text="Domain & Network" FontSize="16" FontWeight="SemiBold" />
<TextBox x:Name="DomainBox" Header="Base Domain" PlaceholderText="example.com (or 'local' for local-only)" Style="{StaticResource InputFieldStyle}" />
<TextBox x:Name="EmailBox" Header="Email (for Let's Encrypt)" PlaceholderText="admin@example.com" Style="{StaticResource InputFieldStyle}" />
</StackPanel>
</Border>
<!-- DNS Provider -->
<Border Style="{StaticResource CardStyle}">
<StackPanel Spacing="12">
<TextBlock Text="DNS Provider" FontSize="16" FontWeight="SemiBold" />
<ComboBox x:Name="DnsProviderCombo" Header="Provider" Style="{StaticResource ComboBoxStyle}" SelectionChanged="DnsProvider_SelectionChanged">
<ComboBoxItem Content="Technitium DNS (Local)" Tag="Technitium" IsSelected="True" />
<ComboBoxItem Content="CoreDNS (Local)" Tag="CoreDNS" />
<ComboBoxItem Content="Cloudflare" Tag="Cloudflare" />
<ComboBoxItem Content="AWS Route 53" Tag="Route53" />
<ComboBoxItem Content="Custom HTTP API" Tag="Custom" />
</ComboBox>
<!-- Technitium Settings -->
<StackPanel x:Name="TechnitiumPanel" Spacing="12" Visibility="Visible">
<TextBox x:Name="TechnitiumUrl" Header="Technitium API URL" PlaceholderText="http://localhost:5380/api" Style="{StaticResource InputFieldStyle}" />
<TextBox x:Name="TechnitiumApiKey" Header="API Key" PlaceholderText="Leave blank if no auth" Style="{StaticResource InputFieldStyle}" />
<TextBox x:Name="TechnitiumZone" Header="Zone" PlaceholderText="local" Style="{StaticResource InputFieldStyle}" />
</StackPanel>
<!-- CoreDNS Settings -->
<StackPanel x:Name="CoreDnsPanel" Spacing="12" Visibility="Collapsed">
<TextBox x:Name="CoreDnsApiUrl" Header="CoreDNS API URL (via DashCaddy)" PlaceholderText="http://localhost:3001/api/v1/dns" Style="{StaticResource InputFieldStyle}" />
<TextBox x:Name="CoreDnsZone" Header="Zone" PlaceholderText="local" Style="{StaticResource InputFieldStyle}" />
<TextBlock Text="CoreDNS is managed via DashCaddy API — no direct API needed" FontSize="12" Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
</StackPanel>
<!-- Cloudflare Settings -->
<StackPanel x:Name="CloudflarePanel" Spacing="12" Visibility="Collapsed">
<TextBox x:Name="CloudflareApiToken" Header="API Token" PlaceholderText="Cloudflare API token with Zone:DNS:Edit" Style="{StaticResource InputFieldStyle}" />
<TextBox x:Name="CloudflareZoneId" Header="Zone ID" PlaceholderText="Get from Cloudflare dashboard" Style="{StaticResource InputFieldStyle}" />
</StackPanel>
<!-- Route53 Settings -->
<StackPanel x:Name="Route53Panel" Spacing="12" Visibility="Collapsed">
<TextBox x:Name="AwsAccessKey" Header="AWS Access Key" Style="{StaticResource InputFieldStyle}" />
<TextBox x:Name="AwsSecretKey" Header="AWS Secret Key" Style="{StaticResource InputFieldStyle}" />
<TextBox x:Name="AwsRegion" Header="Region" PlaceholderText="us-east-1" Style="{StaticResource InputFieldStyle}" />
<TextBox x:Name="Route53ZoneId" Header="Hosted Zone ID" PlaceholderText="Z123456789" Style="{StaticResource InputFieldStyle}" />
</StackPanel>
<!-- Custom Settings -->
<StackPanel x:Name="CustomPanel" Spacing="12" Visibility="Collapsed">
<TextBox x:Name="CustomDnsUrl" Header="Custom DNS API URL" PlaceholderText="http://your-dns-api:port/api" Style="{StaticResource InputFieldStyle}" />
<TextBox x:Name="CustomDnsKey" Header="API Key (optional)" Style="{StaticResource InputFieldStyle}" />
</StackPanel>
</StackPanel>
</Border>
<!-- Docker Settings -->
<Border Style="{StaticResource CardStyle}">
<StackPanel Spacing="12">
<TextBlock Text="Docker" FontSize="16" FontWeight="SemiBold" />
<TextBox x:Name="DockerDataBox" Header="Docker Data Root" PlaceholderText="E:/dockerdata" Style="{StaticResource InputFieldStyle}" />
<CheckBox x:Name="AutoStartCheck" Content="Start DashCaddy on login" IsChecked="True" />
<CheckBox x:Name="AutoUpdateCheck" Content="Auto-update Docker images" IsChecked="False" />
</StackPanel>
</Border>
<!-- Advanced -->
<Border Style="{StaticResource CardStyle}">
<StackPanel Spacing="12">
<TextBlock Text="Advanced" FontSize="16" FontWeight="SemiBold" />
<Button Content="Open Config Folder" Click="OpenConfigFolder_Click" />
<Button Content="View Logs" Click="ViewLogs_Click" />
<Button Content="Reset All Data (Dangerous)" Click="ResetData_Click" Style="{StaticResource AccentButtonStyle}" Background="{StaticResource ErrorBrush}" />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</ContentDialog>
+156
View File
@@ -0,0 +1,156 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System.Diagnostics;
using System.IO;
namespace DashCaddy.Desktop;
public sealed partial class SettingsDialog : ContentDialog
{
public SettingsDialog()
{
InitializeComponent();
LoadSettings();
}
private void LoadSettings()
{
var configPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"DashCaddy", "config.yaml");
if (File.Exists(configPath))
{
var lines = File.ReadAllLines(configPath);
var dict = new Dictionary<string, string>();
foreach (var line in lines)
{
if (line.Contains(':'))
{
var parts = line.Split(':', 2);
if (parts.Length == 2)
dict[parts[0].Trim()] = parts[1].Trim();
}
}
if (dict.TryGetValue("domain", out var domain)) DomainBox.Text = domain;
if (dict.TryGetValue("email", out var email)) EmailBox.Text = email;
if (dict.TryGetValue("docker_data", out var dockerData)) DockerDataBox.Text = dockerData;
if (dict.TryGetValue("dns_provider", out var dnsProvider))
{
foreach (ComboBoxItem item in DnsProviderCombo.Items)
{
if (item.Tag?.ToString() == dnsProvider)
{
DnsProviderCombo.SelectedItem = item;
break;
}
}
}
// DNS provider specific settings
if (dict.TryGetValue("technitium_url", out var techUrl)) TechnitiumUrl.Text = techUrl;
if (dict.TryGetValue("technitium_api_key", out var techKey)) TechnitiumApiKey.Text = techKey;
if (dict.TryGetValue("technitium_zone", out var techZone)) TechnitiumZone.Text = techZone;
if (dict.TryGetValue("coredns_api_url", out var coreUrl)) CoreDnsApiUrl.Text = coreUrl;
if (dict.TryGetValue("coredns_zone", out var coreZone)) CoreDnsZone.Text = coreZone;
if (dict.TryGetValue("cloudflare_api_token", out var cfToken)) CloudflareApiToken.Text = cfToken;
if (dict.TryGetValue("cloudflare_zone_id", out var cfZone)) CloudflareZoneId.Text = cfZone;
if (dict.TryGetValue("aws_access_key", out var awsKey)) AwsAccessKey.Text = awsKey;
if (dict.TryGetValue("aws_secret_key", out var awsSecret)) AwsSecretKey.Text = awsSecret;
if (dict.TryGetValue("aws_region", out var awsRegion)) AwsRegion.Text = awsRegion;
if (dict.TryGetValue("route53_zone_id", out var r53Zone)) Route53ZoneId.Text = r53Zone;
if (dict.TryGetValue("custom_dns_url", out var customUrl)) CustomDnsUrl.Text = customUrl;
if (dict.TryGetValue("custom_dns_key", out var customKey)) CustomDnsKey.Text = customKey;
if (dict.TryGetValue("auto_start", out var autoStart) && bool.TryParse(autoStart, out var asBool))
AutoStartCheck.IsChecked = asBool;
if (dict.TryGetValue("auto_update", out var autoUpdate) && bool.TryParse(autoUpdate, out var auBool))
AutoUpdateCheck.IsChecked = auBool;
}
}
private void DnsProvider_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (DnsProviderCombo.SelectedItem is ComboBoxItem item)
{
var provider = item.Tag?.ToString();
TechnitiumPanel.Visibility = provider == "Technitium" ? Visibility.Visible : Visibility.Collapsed;
CoreDnsPanel.Visibility = provider == "CoreDNS" ? Visibility.Visible : Visibility.Collapsed;
CloudflarePanel.Visibility = provider == "Cloudflare" ? Visibility.Visible : Visibility.Collapsed;
Route53Panel.Visibility = provider == "Route53" ? Visibility.Visible : Visibility.Collapsed;
CustomPanel.Visibility = provider == "Custom" ? Visibility.Visible : Visibility.Collapsed;
}
}
private void ContentDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
{
var configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"DashCaddy");
Directory.CreateDirectory(configDir);
var configPath = Path.Combine(configDir, "config.yaml");
var dnsProvider = (DnsProviderCombo.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Technitium";
var config = $@"domain: {DomainBox.Text}
email: {EmailBox.Text}
docker_data: {DockerDataBox.Text}
dns_provider: {dnsProvider}
technitium_url: {TechnitiumUrl.Text}
technitium_api_key: {TechnitiumApiKey.Text}
technitium_zone: {TechnitiumZone.Text}
coredns_api_url: {CoreDnsApiUrl.Text}
coredns_zone: {CoreDnsZone.Text}
cloudflare_api_token: {CloudflareApiToken.Text}
cloudflare_zone_id: {CloudflareZoneId.Text}
aws_access_key: {AwsAccessKey.Text}
aws_secret_key: {AwsSecretKey.Text}
aws_region: {AwsRegion.Text}
route53_zone_id: {Route53ZoneId.Text}
custom_dns_url: {CustomDnsUrl.Text}
custom_dns_key: {CustomDnsKey.Text}
auto_start: {AutoStartCheck.IsChecked}
auto_update: {AutoUpdateCheck.IsChecked}
";
File.WriteAllText(configPath, config);
}
private void OpenConfigFolder_Click(object sender, RoutedEventArgs e)
{
var configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"DashCaddy");
Directory.CreateDirectory(configDir);
Process.Start(new ProcessStartInfo("explorer.exe", configDir) { UseShellExecute = true });
}
private void ViewLogs_Click(object sender, RoutedEventArgs e)
{
var logDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"DashCaddy", "logs");
Directory.CreateDirectory(logDir);
Process.Start(new ProcessStartInfo("explorer.exe", logDir) { UseShellExecute = true });
}
private async void ResetData_Click(object sender, RoutedEventArgs e)
{
var confirm = new ContentDialog
{
Title = "Reset All Data",
Content = "This will delete ALL DashCaddy data including services, configs, and Docker volumes. This cannot be undone.",
PrimaryButtonText = "Delete Everything",
CloseButtonText = "Cancel",
DefaultButton = ContentDialogButton.Close,
XamlRoot = this.XamlRoot
};
var result = await confirm.ShowAsync();
if (result == ContentDialogResult.Primary)
{
// TODO: Implement full reset
}
}
}
+19
View File
@@ -0,0 +1,19 @@
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Color x:Key="AccentColor">#0078D4</Color>
<Color x:Key="AccentDarkColor">#005A9E</Color>
<Color x:Key="AccentLightColor">#409CFF</Color>
<Color x:Key="SuccessColor">#107C10</Color>
<Color x:Key="WarningColor">#B8860B</Color>
<Color x:Key="ErrorColor">#D13438</Color>
<Color x:Key="BackgroundColor">#FFFFFF</Color>
<Color x:Key="SurfaceColor">#F3F2F1</Color>
<Color x:Key="BorderColor">#E1DFDD</Color>
<SolidColorBrush x:Key="AccentBrush" Color="{StaticResource AccentColor}" />
<SolidColorBrush x:Key="SuccessBrush" Color="{StaticResource SuccessColor}" />
<SolidColorBrush x:Key="WarningBrush" Color="{StaticResource WarningColor}" />
<SolidColorBrush x:Key="ErrorBrush" Color="{StaticResource ErrorColor}" />
</ResourceDictionary>
+89
View File
@@ -0,0 +1,89 @@
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Accent Button Style -->
<Style x:Key="AccentButtonStyle" TargetType="Button">
<Setter Property="Background" Value="{ThemeResource SystemControlBackgroundAccentBrush}" />
<Setter Property="Foreground" Value="{ThemeResource SystemControlForegroundBaseHighBrush}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Padding" Value="16,8" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Minimal Button Style (for icon-only buttons) -->
<Style x:Key="MinimalButtonStyle" TargetType="Button">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="8" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="PointerOver">
<VisualState.Setters>
<Setter Target="Border.Background" Value="{ThemeResource SystemControlBackgroundListLowBrush}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Pressed">
<VisualState.Setters>
<Setter Target="Border.Background" Value="{ThemeResource SystemControlBackgroundListMediumBrush}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Card Style for Dialogs -->
<Style x:Key="CardStyle" TargetType="Border">
<Setter Property="Background" Value="{ThemeResource SystemControlBackgroundAltHighBrush}" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="BorderBrush" Value="{ThemeResource SystemControlForegroundBaseLowBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="16" />
</Style>
<!-- Input Field Style -->
<Style x:Key="InputFieldStyle" TargetType="TextBox">
<Setter Property="Background" Value="{ThemeResource SystemControlBackgroundAltHighBrush}" />
<Setter Property="BorderBrush" Value="{ThemeResource SystemControlForegroundBaseLowBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Padding" Value="12,8" />
<Setter Property="MinWidth" Value="300" />
</Style>
<Style x:Key="ComboBoxStyle" TargetType="ComboBox">
<Setter Property="Background" Value="{ThemeResource SystemControlBackgroundAltHighBrush}" />
<Setter Property="BorderBrush" Value="{ThemeResource SystemControlForegroundBaseLowBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="MinWidth" Value="300" />
</Style>
</ResourceDictionary>
+51
View File
@@ -0,0 +1,51 @@
<ContentDialog
x:Class="DashCaddy.Desktop.TemplatesDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:DashCaddy.Desktop.ViewModels"
mc:Ignorable="d"
Title="Service Templates"
CloseButtonText="Close"
DefaultButton="Close"
Width="700"
MaxHeight="600">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="16" Margin="8">
<TextBlock Text="Choose a template to quickly add a popular self-hosted service." TextWrapping="Wrap" Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
<ItemsControl x:Name="TemplatesList" ItemsSource="{x:Bind ViewModel.Templates}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ServiceTemplate">
<Border Style="{StaticResource CardStyle}" Margin="0,8" Padding="16">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{x:Bind Icon}" FontSize="32" VerticalAlignment="Center" Margin="0,0,16,0" />
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="4">
<TextBlock Text="{x:Bind Name}" FontSize="16" FontWeight="SemiBold" />
<TextBlock Text="{x:Bind Description}" Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
<StackPanel Orientation="Horizontal" Spacing="12" Margin="0,8,0,0">
<TextBlock Text="🐳" FontSize="12" />
<TextBlock Text="{x:Bind Image}" FontSize="11" FontFamily="Consolas" Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
<TextBlock Text="•" FontSize="12" Foreground="{ThemeResource SystemControlForegroundBaseLowBrush}" />
<TextBlock Text="Port {x:Bind DefaultPort}" FontSize="11" Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
</StackPanel>
</StackPanel>
<Button Grid.Column="2" Content="Add" Click="AddTemplate_Click" Tag="{x:Bind}" Style="{StaticResource AccentButtonStyle}" VerticalAlignment="Center" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</ContentDialog>
+33
View File
@@ -0,0 +1,33 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using DashCaddy.Desktop.ViewModels;
using DashCaddy.Desktop.Services;
namespace DashCaddy.Desktop;
public sealed partial class TemplatesDialog : ContentDialog
{
public MainViewModel ViewModel { get; }
public TemplatesDialog(MainViewModel viewModel)
{
ViewModel = viewModel;
InitializeComponent();
}
private void AddTemplate_Click(object sender, RoutedEventArgs e)
{
if (sender is Button btn && btn.Tag is ServiceTemplate template)
{
var variables = new Dictionary<string, string>();
foreach (var (key, value) in template.Environment)
{
variables[key] = value;
}
ViewModel.SelectedTemplate = template;
ViewModel.TemplateVariablesDict = variables;
Hide();
}
}
}
+81
View File
@@ -0,0 +1,81 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Data;
using Microsoft.UI;
using DashCaddy.Desktop.ViewModels;
using Windows.UI;
namespace DashCaddy.Desktop.ViewModels;
public class StatusToColorConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
if (value is ServiceHealth health)
{
return health switch
{
ServiceHealth.Healthy => new SolidColorBrush(Colors.LimeGreen),
ServiceHealth.Degraded => new SolidColorBrush(Colors.Gold),
ServiceHealth.Unhealthy => new SolidColorBrush(Colors.Red),
_ => new SolidColorBrush(Colors.Gray)
};
}
if (value is bool boolVal)
{
return boolVal ? new SolidColorBrush(Colors.LimeGreen) : new SolidColorBrush(Colors.Red);
}
return new SolidColorBrush(Colors.Gray);
}
public object ConvertBack(object value, Type targetType, object parameter, string language)
=> throw new NotImplementedException();
}
public class StatusToTextConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
if (value is ServiceHealth health)
{
return health switch
{
ServiceHealth.Healthy => "Healthy",
ServiceHealth.Degraded => "Degraded",
ServiceHealth.Unhealthy => "Unhealthy",
_ => "Unknown"
};
}
return "Unknown";
}
public object ConvertBack(object value, Type targetType, object parameter, string language)
=> throw new NotImplementedException();
}
public class BoolToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
if (value is bool boolVal)
{
return boolVal ? Visibility.Visible : Visibility.Collapsed;
}
return Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType, object parameter, string language)
=> throw new NotImplementedException();
}
public class InverseBoolConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
if (value is bool boolVal)
return !boolVal;
return true;
}
public object ConvertBack(object value, Type targetType, object parameter, string language)
=> throw new NotImplementedException();
}
+166
View File
@@ -0,0 +1,166 @@
using CommunityToolkit.Mvvm.ComponentModel;
using DashCaddy.Desktop.Models;
using DashCaddy.Desktop.Services;
using System.Collections.ObjectModel;
using System.Collections.Generic;
namespace DashCaddy.Desktop.ViewModels;
public partial class MainViewModel : ObservableObject
{
private readonly DockerService _dockerService;
private readonly ApiClient _apiClient;
private readonly CaddyConfigGenerator _caddyGenerator;
private readonly DnsClient _dnsClient;
[ObservableProperty]
private ObservableCollection<ServiceViewModel> _services = new();
[ObservableProperty]
private int _servicesHealthy;
[ObservableProperty]
private int _servicesTotal;
[ObservableProperty]
private bool _dnsHealthy = true;
[ObservableProperty]
private bool _certsHealthy = true;
[ObservableProperty]
private int _certsDaysRemaining = 45;
// For dialogs
public ObservableCollection<ServiceTemplate> Templates { get; } = new(TemplateRegistry.GetAll());
public List<VariableViewModel> TemplateVariables { get; set; } = new();
public Dictionary<string, string> TemplateVariablesDict { get; set; } = new();
public ServiceTemplate SelectedTemplate { get; set; }
public string SelectedComposeFile { get; set; }
public object CustomService { get; set; }
public string ServicesSummary => $"{ServicesHealthy}/{ServicesTotal} running";
public string CertsSummary => $"{CertsDaysRemaining} days";
public string DashboardUrl => "https://status.local";
public MainViewModel(DockerService dockerService, ApiClient apiClient, CaddyConfigGenerator caddyGenerator, DnsClient dnsClient)
{
_dockerService = dockerService;
_apiClient = apiClient;
_caddyGenerator = caddyGenerator;
_dnsClient = dnsClient;
}
public async Task InitializeAsync()
{
await RefreshServicesAsync();
await CheckHealthAsync();
}
public async Task RefreshServicesAsync()
{
try
{
var services = await _apiClient.GetServicesAsync();
Services.Clear();
foreach (var svc in services)
{
Services.Add(new ServiceViewModel
{
Id = svc.Id,
Name = svc.Name,
Type = svc.Type,
Url = svc.Url,
Health = Enum.TryParse<ServiceHealth>(svc.Health, true, out var h) ? h : ServiceHealth.Unknown,
IsRunning = svc.State == "running",
Port = svc.Port,
Host = svc.Host
});
}
ServicesHealthy = Services.Count(s => s.Health == ServiceHealth.Healthy);
ServicesTotal = Services.Count;
}
catch
{
await RefreshFromDockerAsync();
}
}
private async Task RefreshFromDockerAsync()
{
var containers = await _dockerService.GetContainersAsync("dashcaddy");
Services.Clear();
foreach (var c in containers)
{
Services.Add(new ServiceViewModel
{
Id = c.ID[..12],
Name = c.Names.FirstOrDefault()?.TrimStart('/') ?? "unknown",
Type = "docker",
Url = $"http://localhost:{c.Ports.FirstOrDefault()?.PublicPort ?? 0}",
Health = c.State == "running" ? ServiceHealth.Healthy : ServiceHealth.Unhealthy,
IsRunning = c.State == "running"
});
}
ServicesHealthy = Services.Count(s => s.Health == ServiceHealth.Healthy);
ServicesTotal = Services.Count;
}
public async Task CheckHealthAsync()
{
try
{
var health = await _apiClient.GetHealthAsync();
DnsHealthy = health.Dns == "healthy";
CertsHealthy = health.Certs == "healthy";
CertsDaysRemaining = health.CertsDaysRemaining;
}
catch
{
DnsHealthy = false;
CertsHealthy = false;
}
}
public async Task StartServiceAsync(string id) => await _dockerService.StartContainerAsync(id);
public async Task StopServiceAsync(string id) => await _dockerService.StopContainerAsync(id);
public async Task RestartServiceAsync(string id) => await _dockerService.RestartContainerAsync(id);
public async Task RemoveServiceAsync(string id)
{
await _dockerService.RemoveContainerAsync(id);
await _apiClient.RemoveServiceAsync(id);
_caddyGenerator.RegenerateConfig(Services.Select(s => s.ToServiceModel()).ToList());
await _caddyGenerator.ReloadCaddyAsync();
}
public async Task ImportComposeAsync(string filePath)
{
var json = await File.ReadAllTextAsync(filePath);
var services = ComposeParser.ParseJson(json);
foreach (var svc in services)
{
await _apiClient.CreateServiceAsync(svc);
await _dockerService.CreateContainerAsync(svc);
}
_caddyGenerator.RegenerateConfig(Services.Select(s => s.ToServiceModel()).ToList());
await _caddyGenerator.ReloadCaddyAsync();
}
public async Task<ServiceModel> CreateServiceFromTemplateAsync()
{
if (SelectedTemplate == null) return null;
var service = SelectedTemplate.Instantiate(TemplateVariablesDict);
await _apiClient.CreateServiceAsync(service);
await _dockerService.CreateContainerAsync(service);
_caddyGenerator.RegenerateConfig(Services.Select(s => s.ToServiceModel()).ToList());
await _caddyGenerator.ReloadCaddyAsync();
return service;
}
}
+39
View File
@@ -0,0 +1,39 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace DashCaddy.Desktop.ViewModels;
public enum ServiceHealth
{
Healthy,
Degraded,
Unhealthy,
Unknown
}
public partial class ServiceViewModel : ObservableObject
{
public string Id { get; set; }
public string Name { get; set; }
public string Type { get; set; }
public string Url { get; set; }
public int Port { get; set; }
public string Host { get; set; }
[ObservableProperty]
private ServiceHealth _health;
[ObservableProperty]
private bool _isRunning;
public ServiceModel ToServiceModel() => new()
{
Id = Id,
Name = Name,
Type = Type,
Url = Url,
Port = Port,
Host = Host,
Health = Health,
State = IsRunning ? "running" : "stopped"
};
}