Files
DodoSSH/src/DodoSSH.Client.App/Views/HetznerImportScreen.axaml
T

372 lines
22 KiB
XML

<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:import="using:DodoSSH.Client.Import"
x:Class="DodoSSH.Client.App.Views.HetznerImportScreen"
x:DataType="vm:HetznerImportViewModel">
<!--
Importing the servers in a Hetzner Cloud project.
Drawn over the Preferences page inside settings mode, exactly as ImportScreen.axaml is — SettingsNav
stays lit on Preferences, the titlebar says "Back to preferences", and neither is reachable from the nav
rail. See MainWindowViewModel.IsHetznerImportOpen.
── WHY THIS ONE IS A WIZARD AND THE ssh_config IMPORTER IS NOT ────────────────────────────────────────
That screen reads a file that is already on this machine, so it can scan the moment it opens and show a
list. This one cannot: it needs a credential that has to be made in somebody else's console first, and
then it makes a request over the internet. Those are two separate moments and the screen is honest about
it — step one asks for the token, step two shows what the project holds, step three says what was stored.
Both of the first two write nothing.
── THE TOKEN IS NOT STORED ────────────────────────────────────────────────────────────────────────────
It is used for one request and cleared, whether the read succeeded or not. Nothing here writes it to the
vault, to settings or to a log, and the next import asks again — the same argument ImportScreen's key
tick makes for not remembering a "yes". The note above the box says which permission is needed (Read),
that a token is one project rather than one account, and that dedicated servers behind Hetzner's Robot
are not covered by any of this.
── NOTHING IMPORTED HERE CONNECTS ON ITS OWN ──────────────────────────────────────────────────────────
Hetzner has never held the private half of an SSH key, so no key can come with these hosts. That is said
above the button rather than after it, on the same reasoning ImportScreen.axaml gives for its own version
of this sentence: somebody who is not told will conclude the import was broken.
-->
<UserControl.Styles>
<!-- Matching ImportScreen's table exactly: this is the same table about a different source. -->
<Style Selector="Border.importrow:pointerover">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<Style Selector="Border.meaningchip.new">
<Setter Property="Background" Value="{StaticResource LiveWash}" />
</Style>
<Style Selector="Border.meaningchip.new > TextBlock">
<Setter Property="Foreground" Value="{StaticResource Live}" />
</Style>
<Style Selector="Border.meaningchip.exists">
<Setter Property="Background" Value="{StaticResource Chip}" />
</Style>
<Style Selector="Border.meaningchip.exists > TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="Border.meaningchip.warn">
<Setter Property="Background" Value="{StaticResource WarnWash}" />
</Style>
<Style Selector="Border.meaningchip.warn > TextBlock">
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
</Style>
</UserControl.Styles>
<Grid MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch" RowDefinitions="Auto,Auto,*">
<!-- ============ HEADER ============ -->
<StackPanel Grid.Row="0" Spacing="10">
<TextBlock Classes="settingstitle" Text="Import from Hetzner Cloud" />
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="label" Text="{Binding StepLabel}" FontSize="9.5" LetterSpacing="1"
VerticalAlignment="Center" />
<TextBlock Classes="mono" Text="{Binding StepTitle}" FontSize="12"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</StackPanel>
</StackPanel>
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding Status}" FontSize="12" Margin="0,12,0,0"
TextWrapping="Wrap" />
<Panel Grid.Row="2" Margin="0,18,0,0">
<!-- ============ STEP 1 · THE TOKEN ============ -->
<Grid IsVisible="{Binding IsTokenStep}" RowDefinitions="Auto,*">
<Border Grid.Row="0" CornerRadius="12" Background="{StaticResource Raised}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="24,22">
<StackPanel Spacing="14">
<TextBlock Text="Cloud API token" FontSize="13.5" FontWeight="SemiBold"
Foreground="{StaticResource Text}" />
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
Text="{Binding TokenNote}" />
<TextBox Text="{Binding Token}" PasswordChar="•" Height="42" FontSize="13"
FontFamily="{StaticResource MonoFont}"
PlaceholderText="64 characters, from Security → API tokens">
<!-- Enter reads the project, so a pasted token does not need the mouse to go anywhere. -->
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding ScanCommand}" />
</TextBox.KeyBindings>
</TextBox>
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<Button Classes="ghost" Height="42" Content="CANCEL" Command="{Binding CancelCommand}"
ToolTip.Tip="Back to Preferences. Nothing has been sent." />
<Button Classes="accent" Height="42" Content="READ THE PROJECT"
Command="{Binding ScanCommand}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Lists the project's servers. Nothing is stored, and the token is forgotten as soon as the list arrives." />
</StackPanel>
</StackPanel>
</Border>
</Grid>
<!-- ============ STEP 2 · WHAT THE PROJECT HOLDS ============ -->
<Grid IsVisible="{Binding IsChooseStep}" RowDefinitions="Auto,Auto,*,Auto">
<!--
What every imported host gets: which address, which login user, which port. One set of answers for
the whole import rather than one per row, because an estate is reached the same way throughout —
a per-server dropdown would be forty answers to one question.
-->
<Border Grid.Row="0" CornerRadius="12" Background="{StaticResource Raised}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="20,18">
<StackPanel Spacing="14">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" ColumnSpacing="10">
<TextBlock Grid.Column="0" Classes="label" Text="DIAL" FontSize="9.5" LetterSpacing="1"
VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<!--
Chips rather than radio buttons: this application has no RadioButton style, and Fluent's
own beside a row of chips reads as belonging to a different program. Button.chiptoggle is
the tag picker's control, which is already "one of these is on" drawn as a chip.
-->
<Button Classes="chiptoggle" Content="PUBLIC IPv4"
Classes.worn="{Binding UsesPublicIPv4}"
Command="{Binding UseAddressCommand}"
CommandParameter="{x:Static import:HetznerAddressKind.PublicIPv4}" />
<Button Classes="chiptoggle" Content="PUBLIC IPv6"
Classes.worn="{Binding UsesPublicIPv6}"
Command="{Binding UseAddressCommand}"
CommandParameter="{x:Static import:HetznerAddressKind.PublicIPv6}" />
<Button Classes="chiptoggle" Content="PRIVATE NETWORK"
Classes.worn="{Binding UsesPrivate}"
Command="{Binding UseAddressCommand}"
CommandParameter="{x:Static import:HetznerAddressKind.Private}" />
</StackPanel>
<TextBlock Grid.Column="2" Classes="label" Text="USER" FontSize="9.5" LetterSpacing="1"
VerticalAlignment="Center" Margin="14,0,0,0" />
<TextBox Grid.Column="3" Text="{Binding Username}" Width="120" Height="34"
FontFamily="{StaticResource MonoFont}" FontSize="12.5"
ToolTip.Tip="Every Hetzner Linux image puts the project's SSH keys on root and creates no other account." />
<TextBox Grid.Column="4" Text="{Binding PortText}" Width="66" Height="34"
FontFamily="{StaticResource MonoFont}" FontSize="12.5"
ToolTip.Tip="The SSH port every imported host gets." />
</Grid>
<!--
◆ WHERE THEY LAND, AND WHAT THEY AUTHENTICATE WITH. The two answers this import used to
decide on somebody's behalf: the active vault, and nothing.
A project belongs to a team more often than to a person, so the vault is asked rather than
assumed — see HetznerImportViewModel.VaultChoices, and the host editor's own picker for why
this is its own selection rather than the keychain screen's standing preference. It is hidden
at one vault, which is a question with one answer.
The key is the half Hetzner cannot supply and the keychain usually already has: the public
half went to Hetzner when the servers were made, the private half never did. Pointing forty
hosts at it here is one answer; without it, it was forty visits to forty host editors. The
picker is hidden entirely on a keychain with no keys in it.
-->
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="18">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
IsVisible="{Binding HasVaultChoice}">
<TextBlock Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1"
VerticalAlignment="Center" />
<ComboBox Width="230" Height="34" ItemsSource="{Binding VaultChoices}"
SelectedItem="{Binding SelectedVault}"
ToolTip.Tip="Which vault the imported hosts are written into. A shared one is readable by everybody who holds its key.">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
IsVisible="{Binding HasKeyChoice}">
<TextBlock Classes="label" Text="KEY" FontSize="9.5" LetterSpacing="1"
VerticalAlignment="Center" />
<ComboBox Width="260" Height="34" ItemsSource="{Binding KeyChoices}"
SelectedItem="{Binding SelectedKey}"
ToolTip.Tip="A key already in your keychain, which every imported host is pointed at. Nothing is copied — the host stores the key's id, exactly as one bound by hand does.">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:HetznerKeyChoice">
<TextBlock Text="{Binding Label}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</Grid>
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17" TextWrapping="Wrap"
Text="{Binding AddressNote}" />
<TextBlock Classes="hint" FontSize="11" IsVisible="{Binding !IsPortValid}"
Foreground="{StaticResource WarnText}"
Text="That is not a port. Give a number between 1 and 65535." />
</StackPanel>
</Border>
<!-- What the project could not be made to say, at project level. -->
<Border Grid.Row="1" Margin="0,14,0,0" Padding="14,10" CornerRadius="10"
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
BorderThickness="1" IsVisible="{Binding HasWarnings}">
<ItemsControl ItemsSource="{Binding Warnings}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Text="{Binding}" Foreground="{StaticResource WarnText}" FontSize="11.5"
TextWrapping="Wrap" Margin="0,2" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<!-- ============ THE TABLE ============ -->
<Border Grid.Row="2" Margin="0,14,0,0" CornerRadius="12" Background="{StaticResource Pane}"
BorderBrush="{StaticResource Border}" BorderThickness="1" ClipToBounds="True"
IsVisible="{Binding HasRows}">
<Grid RowDefinitions="Auto,*">
<Border Grid.Row="0" Padding="24,14,24,10" BorderBrush="{StaticResource Border}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="24,1*,1.3*,0.6*,1.1*,1.3*">
<!-- A Button rather than a CheckBox, for the reason ImportScreen.axaml's own header gives. -->
<Button Grid.Column="0" Classes="flat" Width="18" Height="18" Padding="0"
VerticalAlignment="Center" Command="{Binding ToggleAllCommand}"
ToolTip.Tip="Tick or untick every row this address choice can reach">
<Panel Width="18" Height="18">
<Border CornerRadius="5" Background="{StaticResource Accent}"
IsVisible="{Binding AllTicked}">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE5CA;" FontSize="13"
Foreground="White" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<Border CornerRadius="5" BorderBrush="{StaticResource BorderMid}" BorderThickness="1.5"
IsVisible="{Binding !AllTicked}" />
</Panel>
</Button>
<TextBlock Grid.Column="1" Classes="label" Text="SERVER" FontSize="9.5" LetterSpacing="1"
Margin="14,0,0,0" />
<TextBlock Grid.Column="2" Classes="label" Text="ADDRESS" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="STATE" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="5" Classes="label" Text="WHAT THIS MEANS" FontSize="9.5"
LetterSpacing="1" />
</Grid>
</Border>
<!-- Virtualised, for an inventory of hundreds of machines: see the transfer queue's own note. -->
<ItemsControl Grid.Row="1" ItemsSource="{Binding Rows}">
<ItemsControl.Template>
<ControlTemplate>
<ScrollViewer>
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}" Margin="12,8" />
</ScrollViewer>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:HetznerRowViewModel">
<Border Classes="importrow" CornerRadius="8">
<Grid ColumnDefinitions="24,1*,1.3*,0.6*,1.1*,1.3*" Margin="12" MinHeight="20">
<!--
Disabled where the address choice cannot reach this server, which is the private
case: a server on no private network is not importable and must not be silently
given a public address instead.
-->
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}"
IsEnabled="{Binding CanImport}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Name}" FontSize="12.5"
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="14,0,10,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Address}" FontSize="11.5"
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Status}" FontSize="11.5"
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Kind}" FontSize="11.5"
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<Border Grid.Column="5" Classes="meaningchip" CornerRadius="5" Padding="8,3"
HorizontalAlignment="Left" VerticalAlignment="Center"
Classes.new="{Binding IsMeaningNew}"
Classes.exists="{Binding IsMeaningExisting}"
Classes.warn="{Binding IsMeaningWarned}" ToolTip.Tip="{Binding Meaning}">
<TextBlock Text="{Binding Meaning}" FontSize="10" FontWeight="SemiBold"
MaxWidth="230" TextTrimming="CharacterEllipsis" />
</Border>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</Border>
<!-- ============ FOOTER ============ -->
<StackPanel Grid.Row="3" Margin="0,16,0,0" Spacing="14">
<!--
Said before the button. Whether a credential comes with the host is the difference between a
bookmark that connects and one that asks for a password, and nothing about a Hetzner project can
supply one.
-->
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
Text="{Binding AuthenticationNote}" />
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="hint" FontSize="12" VerticalAlignment="Center"
Text="{Binding SelectionSummary}" />
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10">
<Button Classes="ghost" Height="44" Content="ANOTHER TOKEN"
Command="{Binding RestartCommand}"
ToolTip.Tip="Back to the token box, for another project. This list goes with it — the token it came from was not kept." />
<Button Classes="ghost" Height="44" Content="CANCEL" Command="{Binding CancelCommand}"
ToolTip.Tip="Back to Preferences. Nothing is stored." />
<Button Classes="accent" Height="44" Content="{Binding ImportLabel}"
Command="{Binding ImportCommand}" IsEnabled="{Binding CanImport}" />
</StackPanel>
</Grid>
</StackPanel>
</Grid>
<!-- ============ STEP 3 · WHAT WAS STORED ============ -->
<Grid IsVisible="{Binding IsDoneStep}" RowDefinitions="Auto,*,Auto">
<Border Grid.Row="0" CornerRadius="12" Background="{StaticResource Raised}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="24,22">
<StackPanel Spacing="12">
<TextBlock Text="What happens next" FontSize="13.5" FontWeight="SemiBold"
Foreground="{StaticResource Text}" />
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
Text="{Binding AuthenticationNote}" />
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
Text="The list this came from is one press away. Going back to it and importing the rest costs nothing — what was just stored is marked as already here. It is the list this scan produced, though, not a fresh one: a server made in the Hetzner console since then is not in it." />
</StackPanel>
</Border>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right"
Margin="0,16,0,0">
<Button Classes="ghost" Height="44" Content="ANOTHER TOKEN" Command="{Binding RestartCommand}"
ToolTip.Tip="Back to the token box, for another project." />
<Button Classes="ghost" Height="44" Content="BACK TO THE LIST"
Command="{Binding ReviewCommand}"
ToolTip.Tip="The same servers, with what you just stored marked as already here." />
<Button Classes="accent" Height="44" Content="DONE" Command="{Binding CancelCommand}"
ToolTip.Tip="Back to Preferences." />
</StackPanel>
</Grid>
</Panel>
</Grid>
</UserControl>