diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..11f2826 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,37 @@ +name: Generate and Deploy DocFX Documentation + +on: + push: + branches: + - main + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '8.0.x' + + - name: Install DocFX + run: dotnet tool install -g docfx + + - name: Add .dotnet/tools to PATH + run: echo "::add-path::${HOME}/.dotnet/tools" + + - name: Build DocFX site + working-directory: ./docs + run: docfx + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs/_site + publish_branch: gh-pages + keep_files: false diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 3ccb93d..1d2bac3 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -24,8 +24,13 @@ jobs: - name: Build run: dotnet build --configuration Release --no-restore + - name: Install ReportGenerator + run: dotnet tool install -g dotnet-reportgenerator-globaltool + - name: Test - run: dotnet test --no-build --configuration Release --verbosity normal + run: | + dotnet test --configuration Release tests/Tableau.Migration.App.Core.Tests/ /p:CollectCoverage=true /p:CoverletOutputFormat=opencover /p:CoverletOutput=../../CoreCoverage.xml + dotnet test --configuration Release tests/Tableau.Migration.App.GUI.Tests /p:CollectCoverage=true /p:CoverletOutputFormat=opencover /p:CoverletOutput=../../GUICoverage.xml env: TABLEAU_CLOUD_URL: ${{ secrets.TABLEAU_CLOUD_URL }} TABLEAU_CLOUD_SITE: ${{ secrets.TABLEAU_CLOUD_SITE }} @@ -36,9 +41,44 @@ jobs: TABLEAU_SERVER_TOKEN_NAME: ${{ secrets.TABLEAU_SERVER_TOKEN_NAME }} TABLEAU_SERVER_TOKEN: ${{ secrets.TABLEAU_SERVER_TOKEN }} - - name: Run dotnet format - run: dotnet format --verbosity diagnostic + - name: Generate coverage report + env: + PATH: $PATH:/home/runner/.dotnet/tools # Adds dotnet tools to PATH + run: reportgenerator -reports:"./*Coverage.xml" -targetdir:./coverage-report -reporttypes:"Html;TextSummary" + + - name: Comment coverage summary on PR + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const summary = fs.readFileSync('./coverage-report/Summary.txt', 'utf8'); + // Split the summary by lines and get the first 18 + const topSummary = summary.split('\n').slice(0, 18).join('\n'); + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `## Code Coverage Summary\n\`\`\`\n${topSummary}\n\`\`\`` + }); + +# Skipping for now due to line ending mismatches + # - name: Run dotnet format + # id: format + # run: | + # dotnet format --verbosity diagnostic > format.log + # grep -q 'Formatted code file' format.log || exit 0 + # exit 1 - - name: Check format results - if: failure() - run: echo "Formatting issues found. Please run 'dotnet format' locally and fix the issues." \ No newline at end of file + # - name: Check format results + # if: failure() + # uses: actions/github-script@v6 + # with: + # github-token: ${{ secrets.GITHUB_TOKEN }} + # script: | + # github.rest.issues.createComment({ + # issue_number: context.issue.number, + # owner: context.repo.owner, + # repo: context.repo.repo, + # body: "Formatting issues were found and fixed. Please run 'dotnet format' locally to ensure your code is properly formatted." + # }) diff --git a/BUILD.md b/BUILD.md index 3705d88..66e7358 100644 --- a/BUILD.md +++ b/BUILD.md @@ -1,4 +1,4 @@ -# Tableau ESMB Migration App +# Tableau Migration App Tableau express migration application to migrate data sources from Tableau Server to Tableau Cloud. Requirements doc can be found [here](https://docs.google.com/document/d/1DXrYdTbS5aGcZeicNVAdD1tvGRwtH1Yj/edit#heading=h.gjdgxs). @@ -12,10 +12,6 @@ Requirements doc can be found [here](https://docs.google.com/document/d/1DXrYdTb * Tableau.Migration.App.GUI - Gui implementation using Avalonia framework. # Building -## From Container -The dockerfile is defined in the `Dockerfile` from the project root. -``` -docker build . ``` ## From Source Root ``` diff --git a/docs/docs/gui.md b/docs/docs/gui.md index 210eff3..c0302db 100644 --- a/docs/docs/gui.md +++ b/docs/docs/gui.md @@ -26,7 +26,8 @@ Currently we have the following defined service interfaces: The top level view used to hold all visible elements is located in the [MainWindow](/api/Tableau.Migration.App.GUI.Views.MainWindow.html) view. ## View Models -All ViewModels are named based on their appropriate View name. i.e. the [MainWindow](/api/Tableau.Migration.App.GUI.Views.MainWindow.html) view has an associatedv [MainWindowViewModel](api/Tableau.Migration.App.GUI.ViewModels.MainWindowViewModel.html). +All ViewModels are named based on their appropriate View name. i.e. the [MainWindow](/api/Tableau.Migration.App.GUI.Views.MainWindow.html) view has an associatedv [MainWindowViewModel](/api/Tableau.Migration.App.GUI.ViewModels.MainWindowViewModel.html) + There exist 2 abstract classes defined in the ViewModel folder: - [**ViewModelBase**](/api/Tableau.Migration.App.GUI.ViewModels.ViewModelBase.html) - The base class of a ViewModel. diff --git a/docs/index.md b/docs/index.md new file mode 120000 index 0000000..32d46ee --- /dev/null +++ b/docs/index.md @@ -0,0 +1 @@ +../README.md \ No newline at end of file diff --git a/docs/toc.yml b/docs/toc.yml new file mode 100644 index 0000000..6da36ae --- /dev/null +++ b/docs/toc.yml @@ -0,0 +1,4 @@ +- name: Docs + href: docs/ +- name: API + href: api/ \ No newline at end of file diff --git a/scripts/gen_report.sh b/scripts/gen_report.sh new file mode 100755 index 0000000..90c4626 --- /dev/null +++ b/scripts/gen_report.sh @@ -0,0 +1,4 @@ +#!/usr/bin/bash +dotnet test tests/Tableau.Migration.App.Core.Tests/ /p:CollectCoverage=true /p:CoverletOutputFormat=opencover /p:CoverletOutput=../../CoreCoverage.xml +dotnet test tests/Tableau.Migration.App.GUI.Tests /p:CollectCoverage=true /p:CoverletOutputFormat=opencover /p:CoverletOutput=../../GUICoverage.xml +reportgenerator -reports:"./*Coverage.xml" -targetdir:./coverage-report -reporttypes:"Html;TextSummary" diff --git a/src/Tableau.Migration.App.Core/Entities/AppSettings.cs b/src/Tableau.Migration.App.Core/Entities/AppSettings.cs index 03184f5..93ba1ab 100644 --- a/src/Tableau.Migration.App.Core/Entities/AppSettings.cs +++ b/src/Tableau.Migration.App.Core/Entities/AppSettings.cs @@ -15,6 +15,8 @@ // limitations under the License. // +namespace Tableau.Migration.App.Core.Entities; + /// /// App settings to be loaded. /// diff --git a/src/Tableau.Migration.App.Core/Entities/DetailedMigrationResult.cs b/src/Tableau.Migration.App.Core/Entities/DetailedMigrationResult.cs index 3916d8c..7033db1 100644 --- a/src/Tableau.Migration.App.Core/Entities/DetailedMigrationResult.cs +++ b/src/Tableau.Migration.App.Core/Entities/DetailedMigrationResult.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2024, Salesforce, Inc. All rights reserved. // SPDX-License-Identifier: Apache-2 // @@ -26,4 +26,36 @@ namespace Tableau.Migration.App.Core.Entities; /// public record struct DetailedMigrationResult( ITableauMigrationService.MigrationStatus status, - IReadOnlyList errors); \ No newline at end of file + IReadOnlyList errors); + +/// +/// Builder to build a . +/// +public static class DetailedMigrationResultBuilder +{ + /// + /// Build a Detailed Migration Result. + /// + /// The Tableau Migration SDK Status. + /// The errors from migration. + /// The . + public static DetailedMigrationResult Build( + MigrationCompletionStatus status, IReadOnlyList errors) + { + ITableauMigrationService.MigrationStatus newStatus; + switch (status) + { + case MigrationCompletionStatus.Completed: + newStatus = ITableauMigrationService.MigrationStatus.SUCCESS; + break; + case MigrationCompletionStatus.Canceled: + newStatus = ITableauMigrationService.MigrationStatus.CANCELED; + break; + default: // ITableauMigrationService.MigrationStatus.FAILURE + newStatus = ITableauMigrationService.MigrationStatus.FAILURE; + break; + } + + return new DetailedMigrationResult(newStatus, errors); + } +} \ No newline at end of file diff --git a/src/Tableau.Migration.App.Core/Entities/MigrationActions.cs b/src/Tableau.Migration.App.Core/Entities/MigrationActions.cs index 7221aee..fe05aa1 100644 --- a/src/Tableau.Migration.App.Core/Entities/MigrationActions.cs +++ b/src/Tableau.Migration.App.Core/Entities/MigrationActions.cs @@ -29,6 +29,8 @@ namespace Tableau.Migration.App.Core.Entities; /// public class MigrationActions { + private static List actionsCache = new List(); + /// /// Gets the list of actions available from the Tableau Migration SDK and the order in which they are migrated. /// @@ -36,14 +38,39 @@ public static List Actions { get { - var result = ServerToCloudMigrationPipeline + if (actionsCache.Count > 0) + { + return actionsCache; + } + + actionsCache = ServerToCloudMigrationPipeline .ContentTypes .Select( contentType => GetActionTypeName(contentType.ContentType)).ToList(); - return result; + actionsCache.Insert(0, "Setup"); + + return actionsCache; } } + /// + /// Get the index of a migration action. + /// + /// The migration action name. + /// The migration index for the provided action. + public static int GetActionIndex(string action) + { + for (int i = 0; i < Actions.Count; i++) + { + if (action == Actions[i]) + { + return i; + } + } + + return -1; + } + /// /// Returns the action type names from the SDK. /// diff --git a/src/Tableau.Migration.App.Core/Entities/MigrationTimerEvent.cs b/src/Tableau.Migration.App.Core/Entities/MigrationTimerEvent.cs new file mode 100644 index 0000000..2542aaa --- /dev/null +++ b/src/Tableau.Migration.App.Core/Entities/MigrationTimerEvent.cs @@ -0,0 +1,67 @@ +// +// Copyright (c) 2024, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at: +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.App.Core.Entities; +using System; + +/// +/// Possible value types for Migration Timer Events. +/// +public enum MigrationTimerEventType +{ + /// + /// Event fired when Migration has started. + /// + MigrationStarted, + + /// + /// Event fired when Migration has either completed or failed. + /// + Migrationfinished, + + /// + /// Event fired when a Migration Action has completed. + /// + MigrationActionCompleted, +} + +/// +/// Migration Time events to be triggered. +/// +public class MigrationTimerEvent : EventArgs +{ + private DateTime migrationStartTime; + private Dictionary actionStartTimes; + private Dictionary actionStopTimes; + + /// + /// Initializes a new instance of the class. + /// + /// The event type. + public MigrationTimerEvent(MigrationTimerEventType eventType) + { + this.EventType = eventType; + this.migrationStartTime = DateTime.Now; + this.actionStartTimes = new Dictionary(); + this.actionStopTimes = new Dictionary(); + } + + /// + /// Gets the Migration event type. + /// + public MigrationTimerEventType EventType { get; } +} \ No newline at end of file diff --git a/src/Tableau.Migration.App.Core/Entities/ProgressEventArgs.cs b/src/Tableau.Migration.App.Core/Entities/ProgressEventArgs.cs index 7c62996..bb63aec 100644 --- a/src/Tableau.Migration.App.Core/Entities/ProgressEventArgs.cs +++ b/src/Tableau.Migration.App.Core/Entities/ProgressEventArgs.cs @@ -26,21 +26,14 @@ public class ProgressEventArgs : EventArgs /// /// Initializes a new instance of the class. /// - /// The action. /// The message. - public ProgressEventArgs(string action, string message) + public ProgressEventArgs(string message) { this.Message = message; - this.Action = action; } /// /// Gets something. /// public string Message { get; } - - /// - /// Gets something. - /// - public string Action { get; } } \ No newline at end of file diff --git a/src/Tableau.Migration.App.Core/Hooks/Mappings/EmailDomainMapping.cs b/src/Tableau.Migration.App.Core/Hooks/Mappings/EmailDomainMapping.cs index aedfa92..1bad754 100644 --- a/src/Tableau.Migration.App.Core/Hooks/Mappings/EmailDomainMapping.cs +++ b/src/Tableau.Migration.App.Core/Hooks/Mappings/EmailDomainMapping.cs @@ -33,6 +33,7 @@ public class EmailDomainMapping : ITableauCloudUsernameMapping { private readonly string domain; + private ILogger? logger; /// /// Initializes a new instance of the class. @@ -44,10 +45,11 @@ public class EmailDomainMapping : public EmailDomainMapping( IOptions options, ISharedResourcesLocalizer localizer, - ILogger logger) + ILogger? logger) : base(localizer, logger) { this.domain = options.Value.EmailDomain; + this.logger = logger; } /// @@ -71,6 +73,12 @@ public EmailDomainMapping( return userMappingContext.MapTo(domain.Append(userMappingContext.ContentItem.Name)).ToTask(); } + if (string.IsNullOrEmpty(this.domain)) + { + this.logger?.LogInformation("No domain mapping provided and no email found."); + return userMappingContext.ToTask(); + } + // Takes the existing username and appends the domain to build the email var email = $"{userMappingContext.ContentItem.Name}@{this.domain}"; diff --git a/src/Tableau.Migration.App.Core/Hooks/Progression/BatchMigrationCompletedProgressHook.cs b/src/Tableau.Migration.App.Core/Hooks/Progression/BatchMigrationCompletedProgressHook.cs index d4d5aaa..bdeb107 100644 --- a/src/Tableau.Migration.App.Core/Hooks/Progression/BatchMigrationCompletedProgressHook.cs +++ b/src/Tableau.Migration.App.Core/Hooks/Progression/BatchMigrationCompletedProgressHook.cs @@ -35,7 +35,6 @@ public class BatchMigrationCompletedProgressHook : IContentBatchMigrationCompletedHook where T : IContentReference { - private const string Separator = "-------------------"; private readonly ILogger> logger; private readonly IProgressMessagePublisher? publisher; @@ -59,9 +58,10 @@ public BatchMigrationCompletedProgressHook( List messageList = new (); if (ctx.ItemResults.Count == 0) { + var type = MigrationActions.GetActionTypeName(typeof(T)); this.logger.LogInformation( "No resources found for {type} to be migrated.", - MigrationActions.GetActionTypeName(typeof(T))); + type); return Task.FromResult?>(ctx); } @@ -70,15 +70,11 @@ public BatchMigrationCompletedProgressHook( this.ProcessManifestEntry(result, messageList); } - messageList.Add(BatchMigrationCompletedProgressHook.Separator); - // Join the messages together to be broadcasted string progressMessage = string.Join("\n", messageList); // Publish and log the message - this.publisher?.PublishProgressMessage( - MigrationActions.GetActionTypeName(typeof(T)), - progressMessage); + this.publisher?.PublishProgressMessage(progressMessage); this.logger.LogInformation( "Published progress message for {type}:\n {message}", diff --git a/src/Tableau.Migration.App.Core/Hooks/Progression/MigrationActionProgressHook.cs b/src/Tableau.Migration.App.Core/Hooks/Progression/MigrationActionProgressHook.cs index e02a0cf..9c75246 100644 --- a/src/Tableau.Migration.App.Core/Hooks/Progression/MigrationActionProgressHook.cs +++ b/src/Tableau.Migration.App.Core/Hooks/Progression/MigrationActionProgressHook.cs @@ -33,7 +33,7 @@ public class MigrationActionProgressHook : IMigrationActionCompletedHook /// Action hook set to trigger migration progress visualizations. /// /// The object to track the migration progress. - public MigrationActionProgressHook(IProgressUpdater? progressUpdater) + public MigrationActionProgressHook(IProgressUpdater? progressUpdater = null) { this.progressUpdater = progressUpdater; } @@ -42,6 +42,7 @@ public MigrationActionProgressHook(IProgressUpdater? progressUpdater) public Task ExecuteAsync(IMigrationActionResult ctx, CancellationToken cancel) { this.progressUpdater?.Update(); + return Task.FromResult(ctx); } } diff --git a/src/Tableau.Migration.App.Core/Interfaces/IMigrationTimer.cs b/src/Tableau.Migration.App.Core/Interfaces/IMigrationTimer.cs new file mode 100644 index 0000000..3f86524 --- /dev/null +++ b/src/Tableau.Migration.App.Core/Interfaces/IMigrationTimer.cs @@ -0,0 +1,56 @@ +// +// Copyright (c) 2024, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at: +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.App.Core.Interfaces; +using Tableau.Migration.App.Core.Entities; + +/// +/// Migration Timer Interface. +/// +public interface IMigrationTimer +{ + /// + /// Gets the total time spent on the Migration. If migration is ongoing, it will give the total elapsed time so far. + /// + /// A string representing the total time in `hh:mm:ss` format. + string GetTotalMigrationTime { get; } + + /// + /// Gets the total time spent for migration of a specific action. Actions that were skipped or have not happened yet will be 0. + /// + /// The Action to query. + /// The string representing teh total time in `hh:mm:ss` format. + string GetMigrationActionTime(string actionName); + + /// + /// Updates the migration timings based on the new event. + /// + /// The timer event. + /// The migration action to update. + void UpdateMigrationTimes(MigrationTimerEventType timerEvent, string migrationAction); + + /// + /// Updates the migration timings based on the new event. + /// + /// The timer event. + void UpdateMigrationTimes(MigrationTimerEventType timerEvent); + + /// + /// Resets the migration timers. + /// + void Reset(); +} \ No newline at end of file diff --git a/src/Tableau.Migration.App.Core/Interfaces/IProgressMessagePublisher.cs b/src/Tableau.Migration.App.Core/Interfaces/IProgressMessagePublisher.cs index 9bf08e2..0383823 100644 --- a/src/Tableau.Migration.App.Core/Interfaces/IProgressMessagePublisher.cs +++ b/src/Tableau.Migration.App.Core/Interfaces/IProgressMessagePublisher.cs @@ -53,6 +53,11 @@ public enum MessageStatus /// Error message status. /// Error, + + /// + /// Unknown message status. + /// + Unknown, } /// @@ -95,7 +100,6 @@ public static string GetStatusIcon(MessageStatus status) /// /// Publish progress message of current Migration Action with its corresponding status messages. /// - /// The action. /// The message. - void PublishProgressMessage(string action, string message); + void PublishProgressMessage(string message); } \ No newline at end of file diff --git a/src/Tableau.Migration.App.Core/Interfaces/IProgressUpdater.cs b/src/Tableau.Migration.App.Core/Interfaces/IProgressUpdater.cs index 4b462be..db5f393 100644 --- a/src/Tableau.Migration.App.Core/Interfaces/IProgressUpdater.cs +++ b/src/Tableau.Migration.App.Core/Interfaces/IProgressUpdater.cs @@ -42,11 +42,6 @@ public interface IProgressUpdater /// string CurrentMigrationMessage { get; } - /// - /// Gets the total number of migration states available. - /// - static int NumMigrationStates { get; } - /// /// Updates the visual representation of the migration progress. /// diff --git a/src/Tableau.Migration.App.Core/Interfaces/ITableauMigrationService.cs b/src/Tableau.Migration.App.Core/Interfaces/ITableauMigrationService.cs index b9b3245..71cfd6e 100644 --- a/src/Tableau.Migration.App.Core/Interfaces/ITableauMigrationService.cs +++ b/src/Tableau.Migration.App.Core/Interfaces/ITableauMigrationService.cs @@ -42,7 +42,7 @@ public enum MigrationStatus /// /// The migration was stopped while running. /// - CANCELLED, + CANCELED, /// /// The migration was resumed after previously being stopped. diff --git a/src/Tableau.Migration.App.Core/ServiceCollectionExtensions.cs b/src/Tableau.Migration.App.Core/ServiceCollectionExtensions.cs index ed7f770..7bd37bb 100644 --- a/src/Tableau.Migration.App.Core/ServiceCollectionExtensions.cs +++ b/src/Tableau.Migration.App.Core/ServiceCollectionExtensions.cs @@ -19,6 +19,7 @@ namespace Tableau.Migration.App.Core; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Tableau.Migration; +using Tableau.Migration.App.Core.Entities; using Tableau.Migration.App.Core.Hooks.Mappings; using Tableau.Migration.App.Core.Hooks.Progression; using Tableau.Migration.App.Core.Interfaces; diff --git a/src/Tableau.Migration.App.Core/Services/TableauMigrationService.cs b/src/Tableau.Migration.App.Core/Services/TableauMigrationService.cs index 4a73e78..3113572 100644 --- a/src/Tableau.Migration.App.Core/Services/TableauMigrationService.cs +++ b/src/Tableau.Migration.App.Core/Services/TableauMigrationService.cs @@ -34,9 +34,8 @@ public class TableauMigrationService : ITableauMigrationService private readonly AppSettings appSettings; private readonly IMigrationPlanBuilder planBuilder; private readonly IMigrator migrator; - private readonly ILogger logger; + private readonly ILogger? logger; private readonly IProgressUpdater? progressUpdater; - private readonly IProgressMessagePublisher? publisher; private readonly MigrationManifestSerializer manifestSerializer; private IMigrationPlan? plan; private IMigrationManifest? manifest; @@ -49,23 +48,20 @@ public class TableauMigrationService : ITableauMigrationService /// The logger to be used. /// The application settings to apply to the application. /// The object to handle the visual migration progress indicator. - /// The message publisher to broadcast progress updates. /// Serializaer class to save and load manifest file. public TableauMigrationService( IMigrationPlanBuilder planBuilder, IMigrator migrator, - ILogger logger, + ILogger? logger, AppSettings appSettings, MigrationManifestSerializer manifestSerializer, - IProgressUpdater? progressUpdater = null, - IProgressMessagePublisher? publisher = null) + IProgressUpdater? progressUpdater = null) { this.appSettings = appSettings; this.planBuilder = planBuilder; this.migrator = migrator; this.logger = logger; this.progressUpdater = progressUpdater; - this.publisher = publisher; this.manifestSerializer = manifestSerializer; } @@ -95,7 +91,7 @@ public bool BuildMigrationPlan(EndpointOptions serverEndpoints, EndpointOptions if (!validationResult.Success) { - this.logger.LogError("Migration plan validation failed. {Errors}", validationResult.Errors); + this.logger?.LogError("Migration plan validation failed. {Errors}", validationResult.Errors); return false; } @@ -119,7 +115,7 @@ public async Task StartMigrationTaskAsync(CancellationT { if (this.plan == null) { - this.logger.LogError("Migration plan is not built."); + this.logger?.LogError("Migration plan is not built."); return new DetailedMigrationResult(ITableauMigrationService.MigrationStatus.FAILURE, new List()); } @@ -131,13 +127,13 @@ public async Task ResumeMigrationTaskAsync(string manif { if (this.plan == null) { - this.logger.LogError("Migration plan is not built."); + this.logger?.LogError("Migration plan is not built."); return new DetailedMigrationResult(ITableauMigrationService.MigrationStatus.FAILURE, new List()); } if (!(await this.LoadManifestAsync(manifestFilepath, cancel))) { - this.logger.LogError("Migration manifest is null. Unable to resume the migration"); + this.logger?.LogError("Migration manifest is null. Unable to resume the migration"); return new DetailedMigrationResult(ITableauMigrationService.MigrationStatus.FAILURE, new List()); } @@ -230,41 +226,8 @@ private async Task ExecuteMigrationAsync(IMigrationPlan List messageList = new (); this.manifest = result.Manifest; IReadOnlyList errors = this.manifest.Errors; - var statusIcon = IProgressMessagePublisher.GetStatusIcon(IProgressMessagePublisher.MessageStatus.Error); - foreach (var error in errors) - { - try - { - ErrorMessage parsedError = new ErrorMessage(error.Message); - messageList.Add($"\t {statusIcon} {parsedError.Detail}"); - messageList.Add($"\t\t {parsedError.Summary}: {parsedError.URL}"); - } - catch (Exception) - { - messageList.Add($"\t {statusIcon} Could not parse error message: \n{error.Message}"); - } - } - - string resultErrorMessage = string.Join("\n", messageList); - - if (result.Status == MigrationCompletionStatus.Completed) - { - this.logger.LogInformation("Migration completed."); - this.publisher?.PublishProgressMessage("Migration completed", resultErrorMessage); - return new DetailedMigrationResult(ITableauMigrationService.MigrationStatus.SUCCESS, errors); - } - else if (result.Status == MigrationCompletionStatus.Canceled) - { - this.logger.LogInformation("Migration cancelled."); - this.publisher?.PublishProgressMessage("Migration cancelled", resultErrorMessage); - return new DetailedMigrationResult(ITableauMigrationService.MigrationStatus.CANCELLED, errors); - } - else - { - this.logger.LogError("Migration failed with status: {Status}", result.Status); - this.publisher?.PublishProgressMessage("Migration failed", resultErrorMessage); - return new DetailedMigrationResult(ITableauMigrationService.MigrationStatus.FAILURE, errors); - } + this.logger?.LogInformation($"Migration Result: {result.Status}"); + return DetailedMigrationResultBuilder.Build(result.Status, this.manifest.Errors); } } \ No newline at end of file diff --git a/src/Tableau.Migration.App.Core/Tableau.Migration.App.Core.csproj b/src/Tableau.Migration.App.Core/Tableau.Migration.App.Core.csproj index 173e3f4..fc1d98b 100644 --- a/src/Tableau.Migration.App.Core/Tableau.Migration.App.Core.csproj +++ b/src/Tableau.Migration.App.Core/Tableau.Migration.App.Core.csproj @@ -27,6 +27,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + @@ -34,7 +38,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/src/Tableau.Migration.App.GUI/App.axaml b/src/Tableau.Migration.App.GUI/App.axaml index c0ca2d2..90ebab3 100644 --- a/src/Tableau.Migration.App.GUI/App.axaml +++ b/src/Tableau.Migration.App.GUI/App.axaml @@ -1,15 +1,20 @@ - - + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + x:Class="Tableau.Migration.App.GUI.App" + xmlns:local="using:Tableau.Migration.App.GUI" + x:DataType="local:App" + RequestedThemeVariant="Light"> + + + + + + - + - + - + - \ No newline at end of file + diff --git a/src/Tableau.Migration.App.GUI/App.axaml.cs b/src/Tableau.Migration.App.GUI/App.axaml.cs index 0b4e1ec..ab93f1d 100644 --- a/src/Tableau.Migration.App.GUI/App.axaml.cs +++ b/src/Tableau.Migration.App.GUI/App.axaml.cs @@ -27,9 +27,11 @@ namespace Tableau.Migration.App.GUI; using Serilog; using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Tableau.Migration.App.Core; using Tableau.Migration.App.Core.Hooks.Mappings; using Tableau.Migration.App.Core.Interfaces; +using Tableau.Migration.App.GUI; using Tableau.Migration.App.GUI.Models; using Tableau.Migration.App.GUI.Services.Implementations; using Tableau.Migration.App.GUI.Services.Interfaces; @@ -39,19 +41,35 @@ namespace Tableau.Migration.App.GUI; /// /// Main application definition. /// +[ExcludeFromCodeCoverage] public partial class App : Application { + /// + /// Initializes a new instance of the class. + /// Constructor. + /// + public App() + { + this.Name = Constants.AppName; + } + /// /// Gets the service provider. /// public static IServiceProvider? ServiceProvider { get; private set; } + /// + /// Gets the application name and version number. + /// + public string AppNameVersion { get => Constants.AppNameVersion; } + /// /// Initializes the app. /// public override void Initialize() { AvaloniaXamlLoader.Load(this); + this.DataContext = this; } /// @@ -124,14 +142,18 @@ public virtual void OnApplicationExit() /// private void ConfigureServices(IServiceCollection services) { + string logTemplate = "[{Timestamp:HH:mm:ss} {Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}"; Log.Logger = new LoggerConfiguration() - .WriteTo.Console() + .Enrich.FromLogContext() + .WriteTo.Console( + outputTemplate: logTemplate) .WriteTo.File( "Logs/migration-app.log", fileSizeLimitBytes: 20 * 1024 * 1024, // 20 MB file size limit rollOnFileSizeLimit: true, retainedFileCountLimit: 10, - shared: true) + shared: true, + outputTemplate: logTemplate) .CreateLogger(); services.AddLogging(loggingBuilder => @@ -154,6 +176,12 @@ private void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddTransient(); services.AddTransient(provider => { diff --git a/src/Tableau.Migration.App.GUI/Constants.cs b/src/Tableau.Migration.App.GUI/Constants.cs new file mode 100644 index 0000000..3069a24 --- /dev/null +++ b/src/Tableau.Migration.App.GUI/Constants.cs @@ -0,0 +1,39 @@ +// +// Copyright (c) 2024, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at: +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.App.GUI; + +/// +/// Constants to be used in the code. +/// +public static class Constants +{ + /// + /// The App name. + /// + public const string AppName = "Tableau Migration App"; + + /// + /// The App version number. + /// + public const string Version = "v1.0.2"; + + /// + /// The App name with version. + /// + public const string AppNameVersion = $"{AppName} {Version}"; +} \ No newline at end of file diff --git a/src/Tableau.Migration.App.GUI/Models/MigrationTimer.cs b/src/Tableau.Migration.App.GUI/Models/MigrationTimer.cs new file mode 100644 index 0000000..b6c3fa8 --- /dev/null +++ b/src/Tableau.Migration.App.GUI/Models/MigrationTimer.cs @@ -0,0 +1,146 @@ +// +// Copyright (c) 2024, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at: +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.App.GUI.Models; + +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using Tableau.Migration.App.Core.Entities; +using Tableau.Migration.App.Core.Interfaces; + +/// +public class MigrationTimer : IMigrationTimer +{ + private ILogger? logger; + private DateTime? startMigrationTime; + private DateTime? stopMigrationTime; + private Dictionary< + string /* Action Name */, + DateTime /* Start Time */> startActionTimes; + + /// + /// Initializes a new instance of the class. + /// Initializes an new instance of the class. + /// + /// The logger. + public MigrationTimer(ILogger? logger = null) + { + this.startActionTimes = new Dictionary(); + this.logger = logger; + } + + /// + public string GetTotalMigrationTime + { + get + { + if (this.startMigrationTime == null) + { + return string.Empty; + } + + DateTime now = DateTime.Now; + if (this.stopMigrationTime != null) + { + now = this.stopMigrationTime.Value; + } + + TimeSpan timeSpan = now - this.startMigrationTime.Value; + return this.FormatTime(timeSpan); + } + } + + /// + public string GetMigrationActionTime(string currentAction) + { + if (!this.startActionTimes.ContainsKey(currentAction)) + { + return string.Empty; + } + + DateTime start = this.startActionTimes[currentAction]; + int actionIndex = MigrationActions.GetActionIndex(currentAction); + bool lastAction = actionIndex == MigrationActions.Actions.Count - 1; + DateTime stop = DateTime.Now; + + if (!lastAction && this.startActionTimes.ContainsKey(MigrationActions.Actions[actionIndex + 1])) + { + stop = this.startActionTimes[MigrationActions.Actions[actionIndex + 1]]; + } + + TimeSpan diff = stop - start; + return this.FormatTime(diff); + } + + /// + public void UpdateMigrationTimes( + MigrationTimerEventType timerEvent) + { + this.UpdateMigrationTimes(timerEvent, string.Empty); + } + + /// + public void UpdateMigrationTimes( + MigrationTimerEventType timerEvent, + string migrationAction) + { + switch (timerEvent) + { + case MigrationTimerEventType.MigrationStarted: + this.startMigrationTime = DateTime.Now; + this.logger?.LogInformation($"Migration timer started: {this.startMigrationTime:yyyy-MM-dd HH:mm:ss}"); + break; + case MigrationTimerEventType.Migrationfinished: + this.stopMigrationTime = DateTime.Now; + this.logger?.LogInformation($"Migration timer stopped: {this.stopMigrationTime:yyyy-MM-dd HH:mm:ss}."); + break; + case MigrationTimerEventType.MigrationActionCompleted: + if (string.IsNullOrEmpty(migrationAction)) + { + this.logger?.LogError("Attempted to log a completed migration action time without a migration action name"); + return; + } + + if (this.startActionTimes.ContainsKey(migrationAction)) + { + this.logger?.LogInformation($"Attempted to log a completed migration action time when one already exists: {migrationAction}"); + return; + } + + this.startActionTimes[migrationAction] = DateTime.Now; + this.logger?.LogInformation($"Migration Action timer set for {migrationAction}: {this.startActionTimes[migrationAction]:yyyy-MM-dd HH:mm:ss}."); + return; + } + } + + /// + public void Reset() + { + this.startMigrationTime = null; + this.stopMigrationTime = null; + this.startActionTimes = new Dictionary(); + } + + private string FormatTime(TimeSpan timeSpan) + { + string formattedTime = timeSpan.Days > 0 + ? $"{timeSpan.Days} days, {timeSpan.Hours:00}h:{timeSpan.Minutes:00}m:{timeSpan.Seconds:00}s" + : $"{timeSpan.Hours:00}h:{timeSpan.Minutes:00}m:{timeSpan.Seconds:00}s"; + return formattedTime; + } +} \ No newline at end of file diff --git a/src/Tableau.Migration.App.GUI/Models/ProgressMessagePublisher.cs b/src/Tableau.Migration.App.GUI/Models/ProgressMessagePublisher.cs index 793cc48..d802489 100644 --- a/src/Tableau.Migration.App.GUI/Models/ProgressMessagePublisher.cs +++ b/src/Tableau.Migration.App.GUI/Models/ProgressMessagePublisher.cs @@ -28,9 +28,9 @@ public class ProgressMessagePublisher : IProgressMessagePublisher public event Action? OnProgressMessage; /// - public void PublishProgressMessage(string action, string message) + public void PublishProgressMessage(string message) { - ProgressEventArgs progressMessage = new ProgressEventArgs(action, message); + ProgressEventArgs progressMessage = new ProgressEventArgs(message); this.OnProgressMessage?.Invoke(progressMessage); return; } diff --git a/src/Tableau.Migration.App.GUI/Models/ProgressUpdater.cs b/src/Tableau.Migration.App.GUI/Models/ProgressUpdater.cs index afbfc94..f8d4519 100644 --- a/src/Tableau.Migration.App.GUI/Models/ProgressUpdater.cs +++ b/src/Tableau.Migration.App.GUI/Models/ProgressUpdater.cs @@ -17,8 +17,11 @@ namespace Tableau.Migration.App.GUI.Models; +using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; +using System.Linq; +using System.Text; using Tableau.Migration.App.Core.Entities; using Tableau.Migration.App.Core.Interfaces; @@ -27,15 +30,26 @@ namespace Tableau.Migration.App.GUI.Models; /// public class ProgressUpdater : IProgressUpdater { + private readonly string separator = "──────────────────────"; private int currentMigrationStateIndex = -1; private List actions; + private ILogger? logger; + private IMigrationTimer? migrationTimer; + private IProgressMessagePublisher? publisher; /// /// Initializes a new instance of the class. /// - public ProgressUpdater() + /// The migration timer. + /// The progress message publisher. + public ProgressUpdater(IMigrationTimer? migrationTimer = null, IProgressMessagePublisher? publisher = null) { this.actions = MigrationActions.Actions; + this.logger = App.ServiceProvider?.GetService(typeof(ILogger)) as ILogger; + string migrationActions = string.Join(", ", this.actions); + this.logger?.LogInformation($"Migration Actions: {migrationActions}"); + this.migrationTimer = migrationTimer; + this.publisher = publisher; } /// @@ -103,7 +117,17 @@ public string CurrentMigrationMessage /// public void Update() { + var oldState = this.CurrentMigrationStateName; this.CurrentMigrationStateIndex++; + this.logger?.LogInformation($"Migration Progress updated! Changing from [{oldState}] → [{this.CurrentMigrationStateName}]"); + + // Update migration action timers + this.migrationTimer?.UpdateMigrationTimes( + MigrationTimerEventType.MigrationActionCompleted, + this.CurrentMigrationStateName); + + // Publish messages + this.PublishProgressMessage(oldState); } /// @@ -113,4 +137,24 @@ public void Reset() { this.CurrentMigrationStateIndex = -1; } + + private void PublishProgressMessage(string completedAction) + { + StringBuilder sb = new StringBuilder(); + string actionTime = this.migrationTimer?.GetMigrationActionTime(completedAction) ?? string.Empty; + + if (actionTime != string.Empty) + { + sb.Append($"{Environment.NewLine}\tCompleted in {actionTime}{Environment.NewLine}"); + } + else + { + this.logger?.LogInformation($"Message publishing for [{completedAction}] skipped!"); + } + + sb.Append(this.separator); + sb.Append(Environment.NewLine); + sb.Append($"{this.CurrentMigrationStateName}"); + this.publisher?.PublishProgressMessage(sb.ToString()); + } } \ No newline at end of file diff --git a/src/Tableau.Migration.App.GUI/Program.cs b/src/Tableau.Migration.App.GUI/Program.cs index 1a7431a..f415f90 100644 --- a/src/Tableau.Migration.App.GUI/Program.cs +++ b/src/Tableau.Migration.App.GUI/Program.cs @@ -19,10 +19,12 @@ namespace Tableau.Migration.App.GUI; using Avalonia; using System; +using System.Diagnostics.CodeAnalysis; /// /// The main program. /// +[ExcludeFromCodeCoverage] internal sealed class Program { // Initialization code. Don't use any Avalonia, third-party APIs or any diff --git a/src/Tableau.Migration.App.GUI/Tableau.Migration.App.GUI.csproj b/src/Tableau.Migration.App.GUI/Tableau.Migration.App.GUI.csproj index f47bd16..a79ad7c 100644 --- a/src/Tableau.Migration.App.GUI/Tableau.Migration.App.GUI.csproj +++ b/src/Tableau.Migration.App.GUI/Tableau.Migration.App.GUI.csproj @@ -32,7 +32,7 @@ - + diff --git a/src/Tableau.Migration.App.GUI/ViewModels/MainWindowViewModel.cs b/src/Tableau.Migration.App.GUI/ViewModels/MainWindowViewModel.cs index 7c1e326..d69afc4 100644 --- a/src/Tableau.Migration.App.GUI/ViewModels/MainWindowViewModel.cs +++ b/src/Tableau.Migration.App.GUI/ViewModels/MainWindowViewModel.cs @@ -30,6 +30,7 @@ namespace Tableau.Migration.App.GUI.ViewModels; using System.Collections.Generic; using System.ComponentModel; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; using System.Windows.Input; @@ -45,49 +46,46 @@ namespace Tableau.Migration.App.GUI.ViewModels; /// public partial class MainWindowViewModel : ViewModelBase { - private const string MigrationMessagesSessionSeperator = "--------------------------------------------"; + private const string MigrationMessagesSessionSeperator = "─────────────────────────────"; private readonly ITableauMigrationService migrationService; private bool isMigrating = false; private IProgressUpdater progressUpdater; + private IProgressMessagePublisher publisher; + private IMigrationTimer migrationTimer; private string notificationMessage = string.Empty; - private string notificationDetailsMessage = string.Empty; - private string? manifestSaveFilePath = null; private CancellationTokenSource? cancellationTokenSource = null; private IImmutableSolidColorBrush notificationColor = Brushes.Black; private ILogger? logger; + private TimersViewModel timersVM; /// /// Initializes a new instance of the class. /// /// The migration service. - /// The default domain mapping to apply to users who do not have an existing email, or mapping present. - /// The user-specific mappings to be used if provided through CSV. /// The object to track the migration progress from the migration service. + /// The object to track the migration times. /// The progress publisher for progress status messages. - /// The file picker service to use for CSV loaded user mappings. - /// The csv parser to load and parser user mappings. + /// The Timers view model. + /// The User Mappings view model. public MainWindowViewModel( ITableauMigrationService migrationService, - IOptions emailDomainOptions, - IOptions dictionaryUserMappingOptions, IProgressUpdater progressUpdater, IProgressMessagePublisher publisher, - IFilePicker filePicker, - ICsvParser csvParser) + IMigrationTimer migrationTimer, + TimersViewModel timersVM, + UserMappingsViewModel userMappingsVM) { this.migrationService = migrationService; + this.publisher = publisher; + this.timersVM = timersVM; + this.UserMappingsVM = userMappingsVM; - // Sub View Models - this.MessageDisplayVM = new MessageDisplayViewModel(publisher); + // Sub View Models for Authentication Credentials this.ServerCredentialsVM = new AuthCredentialsViewModel(TableauEnv.TableauServer); this.CloudCredentialsVM = new AuthCredentialsViewModel(TableauEnv.TableauCloud); - this.UserMappingsVM = new UserMappingsViewModel( - emailDomainOptions, - dictionaryUserMappingOptions, - filePicker, - csvParser); this.progressUpdater = progressUpdater; + this.migrationTimer = migrationTimer; this.logger = App.ServiceProvider?.GetRequiredService>(); // Subscribe to the progress updater event and retrigger UI rendering on update @@ -111,11 +109,6 @@ await Dispatcher.UIThread.InvokeAsync(() => /// public static int NumMigrationStates => ProgressUpdater.NumMigrationStates; - /// - /// Gets the progress status Message Display View Model. - /// - public MessageDisplayViewModel MessageDisplayVM { get; } - /// /// Gets the ViewModel for the Tableau Server Credentials. /// @@ -143,25 +136,30 @@ public bool IsMigrating { this.SetProperty(ref this.isMigrating, value); } + + this.OnPropertyChanged(nameof(this.IsNotificationVisible)); } } /// - /// Gets or Sets the notification message at the bottom of the main window. + /// Gets or Sets the notification message under the run migration button. /// public string NotificationMessage { get => this.notificationMessage; - set => this.SetProperty(ref this.notificationMessage, value); + set + { + this.SetProperty(ref this.notificationMessage, value); + this.OnPropertyChanged(nameof(this.IsNotificationVisible)); + } } /// - /// Gets or Sets the notification message at the bottom of the main window. + /// Gets a value indicating whether notification message should be visible. /// - public string NotificationDetailsMessage + public bool IsNotificationVisible { - get => this.notificationDetailsMessage; - set => this.SetProperty(ref this.notificationDetailsMessage, value); + get => !string.IsNullOrEmpty(this.NotificationMessage) && !this.IsMigrating; } /// @@ -186,29 +184,35 @@ public IImmutableSolidColorBrush NotificationColor /// /// Cancels the ongoing migration. /// - /// The path of manifest file to save. - public void CancelMigration(string? manifestSaveFilePath) + public void CancelMigration() { - this.manifestSaveFilePath = manifestSaveFilePath; this.cancellationTokenSource?.Cancel(); return; } /// - /// Callback to update fields when error state is changed. + /// Saves the migration manifest if a save file path is provided. /// - /// The name of the property that changed. - protected virtual void OnErrorsChanged(string propertyName) + /// The file path where the manifest should be saved, or null if saving is not required. + /// A task that represents the asynchronous save operation. + public async Task SaveManifestIfRequiredAsync(string? manifestSaveFilePath) { - this.ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName)); + if (string.IsNullOrEmpty(manifestSaveFilePath)) + { + this.NotificationMessage += " Manifest was not saved."; + return; + } + + bool isSaved = await this.migrationService.SaveManifestAsync(manifestSaveFilePath); + this.NotificationMessage += isSaved ? " Manifest saved." : " Failed to save manifest."; } /// /// Validates fields and starts the migration process if valid. /// - /// A representing the asynchronous operation. + /// A representing the migration. [RelayCommand] - private async Task RunMigration() + public async Task RunMigration() { if (!this.AreFieldsValid()) { @@ -217,35 +221,37 @@ private async Task RunMigration() } this.IsMigrating = true; - this.MessageDisplayVM.AddMessage(MigrationMessagesSessionSeperator); - this.MessageDisplayVM.AddMessage("Migration Started"); + this.publisher.PublishProgressMessage("Migration Started"); this.logger?.LogInformation("Migration Started"); + this.migrationTimer.Reset(); // Setup migration timer to store progress timing states + this.migrationTimer.UpdateMigrationTimes(MigrationTimerEventType.MigrationStarted); + this.timersVM.Start(); // Triggers for the Timer View to start polling for information await this.RunMigrationTask().ConfigureAwait(false); } /// /// Asynchronously resumes migration by selecting a manifest file and calling RunResumeMigration with the file path. /// + /// A representing the async migration. [RelayCommand] - private async Task ResumeMigration() + public async Task ResumeMigration() { - if (!this.AreFieldsValid()) - { - this.logger?.LogInformation("Migration Run failed due to validation errors."); - return; - } - var filePath = await this.SelectManifestFileAsync(); if (filePath != null) { - this.IsMigrating = true; - this.MessageDisplayVM.AddMessage(MigrationMessagesSessionSeperator); - this.MessageDisplayVM.AddMessage("Migration Started"); - this.logger?.LogInformation("Migration Started"); - await this.RunMigrationTask().ConfigureAwait(false); + await this.RunMigration(); } } + /// + /// Callback to update fields when error state is changed. + /// + /// The name of the property that changed. + protected virtual void OnErrorsChanged(string propertyName) + { + this.ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName)); + } + /// /// Opens a file picker dialog to select a manifest file and returns the selected file path. /// @@ -263,6 +269,7 @@ private async Task ResumeMigration() }, }; + // Retrieve the active application window to access the file picker var window = App.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop ? desktop.MainWindow : null; @@ -273,8 +280,8 @@ private async Task ResumeMigration() return null; } + // Open file picker and return the selected file path var result = await window.StorageProvider.OpenFilePickerAsync(options); - if (result == null || result.Count == 0) { this.logger?.LogInformation("No file selected in file picker dialog."); @@ -325,51 +332,30 @@ private async Task ExecuteMigrationAsync(Func - /// Saves the migration manifest if a save file path is provided. - /// - private async Task SaveManifestIfRequiredAsync() - { - if (!string.IsNullOrEmpty(this.manifestSaveFilePath)) + if (isManifestSaved) { - bool isSaved = await this.migrationService.SaveManifestAsync(this.manifestSaveFilePath); - this.NotificationMessage += isSaved ? " Manifest saved." : " Failed to save manifest."; - } - else - { - this.NotificationMessage += " Manifest was not saved."; + this.publisher.PublishProgressMessage($"Manifest saved to '{path}manifest.json'"); } + + this.publisher.PublishProgressMessage($"{Environment.NewLine}Total Elapsed Time: {this.migrationTimer.GetTotalMigrationTime}"); + this.publisher.PublishProgressMessage(MigrationMessagesSessionSeperator); + + this.IsMigrating = false; + this.timersVM.Stop(); + this.progressUpdater.Reset(); } /// @@ -378,7 +364,6 @@ private async Task SaveManifestIfRequiredAsync() private void SetNotification(string message, string? detailsMessage = null, IImmutableSolidColorBrush? color = null) { this.NotificationMessage = message; - this.NotificationDetailsMessage = detailsMessage ?? string.Empty; this.NotificationColor = color ?? Brushes.Black; } @@ -417,4 +402,54 @@ private bool AreFieldsValid() return errorCount == 0; } + + private void PublishMigrationResult(DetailedMigrationResult migrationResult) + { + switch (migrationResult.status) + { + case ITableauMigrationService.MigrationStatus.SUCCESS: + this.SetNotification("Migration completed.", color: Brushes.Green); + this.publisher.PublishProgressMessage("Migration completed."); + break; + + case ITableauMigrationService.MigrationStatus.CANCELED: + var details = this.BuildErrorDetails(migrationResult.errors); + this.SetNotification("Migration canceled.", details, Brushes.Red); + this.publisher.PublishProgressMessage("Migration canceled."); + break; + + default: + var failureDetails = this.BuildErrorDetails(migrationResult.errors); + this.SetNotification("Migration Failed.", failureDetails, Brushes.Red); + this.publisher.PublishProgressMessage("Migration failed."); + break; + } + + this.PublishMigrationErrors(migrationResult.errors); + } + + private void PublishMigrationErrors(IReadOnlyList errors) + { + StringBuilder sb = new StringBuilder(); + var statusIcon = + IProgressMessagePublisher + .GetStatusIcon(IProgressMessagePublisher.MessageStatus.Error); + foreach (var error in errors) + { + try + { + ErrorMessage parsedError = new ErrorMessage(error.Message); + sb.Append($"\t {statusIcon} {parsedError.Detail}"); + sb.Append($"\t\t {parsedError.Summary}: {parsedError.URL}"); + } + catch (Exception) + { + sb.Append( + $"\t {statusIcon} Could not parse error message:{Environment.NewLine}{error.Message}"); + } + } + + string statusMessages = sb.ToString(); + this.publisher.PublishProgressMessage(statusMessages); + } } \ No newline at end of file diff --git a/src/Tableau.Migration.App.GUI/ViewModels/MessageDisplayViewModel.cs b/src/Tableau.Migration.App.GUI/ViewModels/MessageDisplayViewModel.cs index 57bcfeb..9624d79 100644 --- a/src/Tableau.Migration.App.GUI/ViewModels/MessageDisplayViewModel.cs +++ b/src/Tableau.Migration.App.GUI/ViewModels/MessageDisplayViewModel.cs @@ -45,13 +45,18 @@ public partial class MessageDisplayViewModel : ViewModelBase /// Initializes a new instance of the class. /// /// The message publisher. - public MessageDisplayViewModel(IProgressMessagePublisher publisher) + /// The migration timer. + /// The logger. + public MessageDisplayViewModel( + IMigrationTimer migrationTimer, + IProgressMessagePublisher publisher, + ILogger? logger) { this.Messages = "Migration output will be displayed here."; this.publisher = publisher; this.messageQueue = new Queue(); this.publisher.OnProgressMessage += this.HandleProgressMessage; - this.logger = App.ServiceProvider?.GetRequiredService>(); + this.logger = logger; this.ToggleDetailsCommand = new RelayCommand(this.ToggleDetails); } @@ -110,14 +115,6 @@ private void ToggleDetails() private void HandleProgressMessage(ProgressEventArgs progressEvent) { - // Skip the message if no associated action is present - if (progressEvent.Action == string.Empty) - { - return; - } - - this.messageQueue.Enqueue($"{progressEvent.Action}"); - // Enqueue each line individually var splitMessages = progressEvent.Message.Split("\n"); foreach (var message in splitMessages) @@ -142,5 +139,6 @@ private void UpdateText() } this.Messages = sb.ToString(); + this.OnPropertyChanged(nameof(this.Messages)); } } \ No newline at end of file diff --git a/src/Tableau.Migration.App.GUI/ViewModels/TimersViewModel.cs b/src/Tableau.Migration.App.GUI/ViewModels/TimersViewModel.cs new file mode 100644 index 0000000..4dcf04e --- /dev/null +++ b/src/Tableau.Migration.App.GUI/ViewModels/TimersViewModel.cs @@ -0,0 +1,154 @@ +// +// Copyright (c) 2024, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at: +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.App.GUI.ViewModels; + +using Avalonia.Threading; +using Microsoft.Extensions.Logging; +using System; +using System.Timers; +using Tableau.Migration.App.Core.Interfaces; +using Tableau.Migration.App.GUI.Models; + +/// +/// The Viewmodel for the Timers view. +/// +public partial class TimersViewModel : ViewModelBase +{ + private DispatcherTimer dispatchTimer; + private string totalElapsedTime = string.Empty; + private string currentActionTime = string.Empty; + private string currentActionLabel = string.Empty; + private bool showActionTimer = false; + private IMigrationTimer migrationTimer; + private IProgressUpdater progressUpdater; + private ILogger? logger; + + /// + /// Initializes a new instance of the class. + /// + /// The migration timer. + /// The progress Updater. + /// The logger. + public TimersViewModel( + IMigrationTimer migrationTimer, + IProgressUpdater progressUpdater, + ILogger? logger) + { + this.migrationTimer = migrationTimer; + this.progressUpdater = progressUpdater; + this.logger = logger; + this.dispatchTimer = new DispatcherTimer + { + Interval = TimeSpan.FromSeconds(1), + }; + this.dispatchTimer.Tick += (s, e) => this.UpdateTimers(); + } + + /// + /// Gets the Total Elapsed Time value. + /// + public string TotalElapsedTime + { + get => this.totalElapsedTime; + private set + { + if (this.TotalElapsedTime != value) + { + this.SetProperty(ref this.totalElapsedTime, value); + } + } + } + + /// + /// Gets the Current Action Time value. + /// + public string CurrentActionTime + { + get => this.currentActionTime; + private set + { + if (this.currentActionTime != value) + { + this.SetProperty(ref this.currentActionTime, value); + } + } + } + + /// + /// Gets the current action name. + /// + public string CurrentActionLabel + { + get => this.currentActionLabel; + private set + { + if (this.currentActionLabel != value) + { + this.SetProperty(ref this.currentActionLabel, value); + } + } + } + + /// + /// Gets a value indicating whether gets or Sets the value indicating whether or not the timer should be running. + /// + public bool ShowActionTimer + { + get => this.showActionTimer; + private set + { + if (this.showActionTimer != value) + { + this.SetProperty(ref this.showActionTimer, value); + } + } + } + + /// + /// Start checking for migration timer information. + /// + public void Start() + { + this.logger?.LogInformation("Timer Polling Started"); + this.TotalElapsedTime = string.Empty; + this.dispatchTimer.Start(); + } + + /// + /// Stop checking for migration timer information. + /// + public void Stop() + { + this.logger?.LogInformation("Timer Polling Stopped"); + this.ShowActionTimer = false; + this.dispatchTimer.Stop(); + } + + private void UpdateTimers() + { + this.TotalElapsedTime = this.migrationTimer.GetTotalMigrationTime; + this.CurrentActionTime = this.migrationTimer.GetMigrationActionTime(this.progressUpdater.CurrentMigrationStateName); + this.CurrentActionLabel = $"{this.progressUpdater.CurrentMigrationStateName}: "; + if (!this.showActionTimer) + { + this.ShowActionTimer = true; + } + + return; + } +} \ No newline at end of file diff --git a/src/Tableau.Migration.App.GUI/ViewModels/UserDomainMappingViewModel.cs b/src/Tableau.Migration.App.GUI/ViewModels/UserDomainMappingViewModel.cs index a4d997f..e5d60e0 100644 --- a/src/Tableau.Migration.App.GUI/ViewModels/UserDomainMappingViewModel.cs +++ b/src/Tableau.Migration.App.GUI/ViewModels/UserDomainMappingViewModel.cs @@ -27,7 +27,6 @@ public partial class UserDomainMappingViewModel : ValidatableViewModelBase { private readonly IOptions emailDomainOptions; private string cloudUserDomain = string.Empty; - private bool isMappingDisabled = false; /// /// Initializes a new instance of the class. @@ -52,19 +51,6 @@ public string CloudUserDomain } } - /// - /// Gets or sets a value indicating whether or not the mapping is disabled. - /// - public bool IsMappingDisabled - { - get => this.isMappingDisabled; - set - { - this.SetProperty(ref this.isMappingDisabled, value); - this.ValidateAll(); - } - } - /// public override void ValidateAll() { @@ -73,20 +59,14 @@ public override void ValidateAll() private void ValidateCloudUserDomain() { - const string requiredMessage = "Tableau Server to Cloud User Domain Mapping is required."; const string validityMessage = "The provided value is not a valid domain."; - if (this.IsMappingDisabled) + + if (this.cloudUserDomain == string.Empty) { - this.RemoveError(nameof(this.CloudUserDomain), requiredMessage); this.RemoveError(nameof(this.CloudUserDomain), validityMessage); return; } - this.ValidateRequired( - this.CloudUserDomain, - nameof(this.CloudUserDomain), - requiredMessage); - // Validate the domain if (!Validator.IsDomainNameValid(this.CloudUserDomain)) { diff --git a/src/Tableau.Migration.App.GUI/ViewModels/UserFileMappingsViewModel.cs b/src/Tableau.Migration.App.GUI/ViewModels/UserFileMappingsViewModel.cs index de38876..f83166f 100644 --- a/src/Tableau.Migration.App.GUI/ViewModels/UserFileMappingsViewModel.cs +++ b/src/Tableau.Migration.App.GUI/ViewModels/UserFileMappingsViewModel.cs @@ -19,6 +19,8 @@ namespace Tableau.Migration.App.GUI.ViewModels; using Avalonia.Media; using Avalonia.Platform.Storage; using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; using System.Collections.Generic; @@ -31,7 +33,7 @@ namespace Tableau.Migration.App.GUI.ViewModels; /// ViewModel for the URI Details component. /// public partial class UserFileMappingsViewModel - : ViewModelBase + : ValidatableViewModelBase { private readonly IOptions dictionaryUserMappingOptions; private Dictionary userMappings = new Dictionary(); @@ -42,6 +44,7 @@ public partial class UserFileMappingsViewModel private bool isUserMappingFileLoaded = false; private IImmutableSolidColorBrush notificationColor = Brushes.Black; private IImmutableSolidColorBrush csvLoadStatusColor = Brushes.Black; + private ILogger? logger; /// /// Initializes a new instance of the class. @@ -57,6 +60,10 @@ public UserFileMappingsViewModel( this.dictionaryUserMappingOptions = dictionaryUserMappingOptions; this.filePicker = filePicker; this.csvParser = csvParser; + this.logger = App.ServiceProvider? + .GetService( + typeof(ILogger)) + as ILogger; } /// @@ -128,16 +135,12 @@ public async Task LoadUserFile() if (file is null) { - this.CSVLoadStatusColor = Brushes.Red; - this.CSVLoadStatus = "Could not find file."; return; } string? localPath = file.TryGetLocalPath(); if (localPath == null) { - this.CSVLoadStatusColor = Brushes.Red; - this.CSVLoadStatus = "Could not find file."; return; } @@ -145,36 +148,39 @@ public async Task LoadUserFile() { this.userMappings = await this.csvParser.ParseAsync(localPath); this.dictionaryUserMappingOptions.Value.UserMappings = this.userMappings; + this.RemoveError(nameof(this.LoadedCSVFilename), $"Failed to load file."); this.LoadedCSVFilename = file.Name; this.IsUserMappingFileLoaded = true; this.CSVLoadStatusColor = Brushes.Black; this.CSVLoadStatus = $"{this.userMappings.Count} user mappings loaded."; } - catch (InvalidDataException e) + catch (Exception e) when (e is InvalidDataException || e is CsvHelper.MissingFieldException || e is FileNotFoundException) { - this.CSVLoadStatusColor = Brushes.Red; - this.CSVLoadStatus = $"Failed to load {file.Name}.\n{e.Message}"; - this.ClearCSVLoadedValues(); - } - catch (FileNotFoundException) - { - this.CSVLoadStatusColor = Brushes.Red; - this.CSVLoadStatus = "Could not find file."; - this.CSVLoadStatusColor = Brushes.Red; + this.AddError(nameof(this.LoadedCSVFilename), $"Failed to load file."); + this.logger?.LogInformation($"Failed to load CSV Mapping: {file.Name}.\n{e.Message}"); this.ClearCSVLoadedValues(); } } catch (Exception e) { - this.CSVLoadStatusColor = Brushes.Red; - this.CSVLoadStatus = $"Encountered an unexpected error with file picker.\n{e.Message}"; - this.CSVLoadStatusColor = Brushes.Red; + this.AddError(nameof(this.LoadedCSVFilename), $"Failed to load file."); + this.logger?.LogError($"Encountered n unknown error loading a user mapping file.\n{e}"); this.ClearCSVLoadedValues(); } return; } + /// + public override void ValidateAll() + { + // Validate that a file is available if designaged that mapping has been loaded + if (this.IsUserMappingFileLoaded) + { + this.ValidateRequired(this.LoadedCSVFilename, nameof(this.LoadedCSVFilename), "Failed to load file."); + } + } + private void ClearCSVLoadedValues() { this.IsUserMappingFileLoaded = false; diff --git a/src/Tableau.Migration.App.GUI/ViewModels/UserMappingsViewModel.cs b/src/Tableau.Migration.App.GUI/ViewModels/UserMappingsViewModel.cs index 7e5928a..eaf0a58 100644 --- a/src/Tableau.Migration.App.GUI/ViewModels/UserMappingsViewModel.cs +++ b/src/Tableau.Migration.App.GUI/ViewModels/UserMappingsViewModel.cs @@ -31,21 +31,14 @@ public partial class UserMappingsViewModel /// /// Initializes a new instance of the class. /// - /// Email Domain options to be used for setting Default domain hook. - /// The User Mapping Options. - /// The filepicker. - /// The CSV parser. + /// The User Domain Mapping ViewModel. + /// The User File Mapping View Model. public UserMappingsViewModel( - IOptions emailOptions, - IOptions dictionaryUserMappingOptions, - IFilePicker filePicker, - ICsvParser csvParser) + UserDomainMappingViewModel userDomainMappingVM, + UserFileMappingsViewModel userFileMappingsVM) { - this.UserDomainMappingVM = new UserDomainMappingViewModel(emailOptions); - this.UserFileMappingsVM = new UserFileMappingsViewModel( - dictionaryUserMappingOptions, - filePicker, - csvParser); + this.UserDomainMappingVM = userDomainMappingVM; + this.UserFileMappingsVM = userFileMappingsVM; } /// @@ -62,6 +55,7 @@ public UserMappingsViewModel( public override void ValidateAll() { this.UserDomainMappingVM.ValidateAll(); + this.UserFileMappingsVM.ValidateAll(); } /// @@ -69,7 +63,8 @@ public override int GetErrorCount() { return base.GetErrorCount() - + this.UserDomainMappingVM.GetErrorCount(); + + this.UserDomainMappingVM.GetErrorCount() + + this.UserFileMappingsVM.GetErrorCount(); } /// @@ -78,6 +73,7 @@ public override IEnumerable GetErrors(string? propertyName) List errors = new List(); errors.AddRange(base.GetErrors(propertyName).Cast()); errors.AddRange(this.UserDomainMappingVM.GetErrors(propertyName).Cast()); + errors.AddRange(this.UserFileMappingsVM.GetErrors(propertyName).Cast()); return errors; } } \ No newline at end of file diff --git a/src/Tableau.Migration.App.GUI/Views/Converters/StringIsNullOrEmptyToBooleanConverter.cs b/src/Tableau.Migration.App.GUI/Views/Converters/StringIsNullOrEmptyToBooleanConverter.cs index b4988ec..1442f32 100644 --- a/src/Tableau.Migration.App.GUI/Views/Converters/StringIsNullOrEmptyToBooleanConverter.cs +++ b/src/Tableau.Migration.App.GUI/Views/Converters/StringIsNullOrEmptyToBooleanConverter.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2024, Salesforce, Inc. All rights reserved. // SPDX-License-Identifier: Apache-2 // diff --git a/src/Tableau.Migration.App.GUI/Views/MainWindow.axaml b/src/Tableau.Migration.App.GUI/Views/MainWindow.axaml index a0abe46..cd5561c 100644 --- a/src/Tableau.Migration.App.GUI/Views/MainWindow.axaml +++ b/src/Tableau.Migration.App.GUI/Views/MainWindow.axaml @@ -1,143 +1,156 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - public partial class MainWindow : Window { - private bool isDialogOpen = false; - /// /// Initializes a new instance of the class. /// @@ -39,6 +37,11 @@ public MainWindow() this.Closing += this.OnWindowClosing; } + /// + /// Gets a value indicating whether a stop migration confirmation dialog has been opened. + /// + public bool IsDialogOpen { get; private set; } = false; + private async void StopMigrationOnClick(object sender, Avalonia.Interactivity.RoutedEventArgs e) { await this.HandleMigrationCancellation("Stop Migration", "Are you sure you want to stop the migration?"); @@ -46,7 +49,7 @@ private async void StopMigrationOnClick(object sender, Avalonia.Interactivity.Ro private async void OnWindowClosing(object? sender, System.ComponentModel.CancelEventArgs e) { - if (this.isDialogOpen) + if (this.IsDialogOpen) { e.Cancel = true; return; @@ -57,11 +60,11 @@ private async void OnWindowClosing(object? sender, System.ComponentModel.CancelE if (viewModel.IsMigrating) { e.Cancel = true; - this.isDialogOpen = true; + this.IsDialogOpen = true; await this.HandleMigrationCancellation("Quit", "A migration is running! Are you sure you want to stop the migration and exit?"); - this.isDialogOpen = false; + this.IsDialogOpen = false; if (!viewModel.IsMigrating) { @@ -87,6 +90,11 @@ private async Task HandleMigrationCancellation(string title, string message) if (result) { + // Stop migration first + MainWindowViewModel? myViewModel = this.DataContext as MainWindowViewModel; + myViewModel?.CancelMigration(); + + // Ask if the user wants to save the manifest file to resume migration later var saveManifestDialog = new ConfirmationDialog( "Save Manifest File", "Do you want to save the manifest to resume migration later?", @@ -94,7 +102,7 @@ private async Task HandleMigrationCancellation(string title, string message) "No"); var resultSaveManifest = await saveManifestDialog.ShowDialog(this); - MainWindowViewModel? myViewModel = this.DataContext as MainWindowViewModel; + string? filePath = null; if (resultSaveManifest) { var window = this; @@ -102,10 +110,10 @@ private async Task HandleMigrationCancellation(string title, string message) { Title = "Save Manifest", FileTypeChoices = new List - { - new FilePickerFileType("JSON files") { Patterns = new[] { "*.json" } }, - new FilePickerFileType("All files") { Patterns = new[] { "*.*" } }, - }, + { + new FilePickerFileType("JSON files") { Patterns = new[] { "*.json" } }, + new FilePickerFileType("All files") { Patterns = new[] { "*.*" } }, + }, DefaultExtension = "json", }; @@ -113,18 +121,11 @@ private async Task HandleMigrationCancellation(string title, string message) if (resultFile != null) { - var filePath = resultFile.TryGetLocalPath(); - myViewModel?.CancelMigration(filePath ?? string.Empty); - } - else - { - myViewModel?.CancelMigration(string.Empty); + filePath = resultFile.TryGetLocalPath(); } } - else - { - myViewModel?.CancelMigration(string.Empty); - } + + myViewModel?.SaveManifestIfRequiredAsync(filePath); } } } \ No newline at end of file diff --git a/src/Tableau.Migration.App.GUI/Views/MessageDisplay.axaml b/src/Tableau.Migration.App.GUI/Views/MessageDisplay.axaml index b0b0f1b..9289848 100644 --- a/src/Tableau.Migration.App.GUI/Views/MessageDisplay.axaml +++ b/src/Tableau.Migration.App.GUI/Views/MessageDisplay.axaml @@ -2,6 +2,7 @@ xmlns:AvalonEdit="using:AvaloniaEdit" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="clr-namespace:Tableau.Migration.App.GUI.ViewModels" + xmlns:local="clr-namespace:Tableau.Migration.App.GUI.Views" x:Class="Tableau.Migration.App.GUI.Views.MessageDisplay" x:DataType="vm:MessageDisplayViewModel"> @@ -20,6 +21,7 @@ - - Keep scrolled to bottom - + + + + + + + + Keep scrolled to bottom + + + - diff --git a/src/Tableau.Migration.App.GUI/Views/MessageDisplay.axaml.cs b/src/Tableau.Migration.App.GUI/Views/MessageDisplay.axaml.cs index b4daba0..cfdd783 100644 --- a/src/Tableau.Migration.App.GUI/Views/MessageDisplay.axaml.cs +++ b/src/Tableau.Migration.App.GUI/Views/MessageDisplay.axaml.cs @@ -24,6 +24,7 @@ namespace Tableau.Migration.App.GUI.Views; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System.Linq; +using Tableau.Migration.App.GUI.ViewModels; /// /// View control for Message Display. @@ -39,6 +40,7 @@ public partial class MessageDisplay : UserControl public MessageDisplay() { this.InitializeComponent(); + this.DataContext = App.ServiceProvider?.GetRequiredService(); this.logger = App.ServiceProvider?.GetRequiredService>(); // Setup for "Scroll to bottom" behaviour diff --git a/src/Tableau.Migration.App.GUI/Views/Timers.axaml b/src/Tableau.Migration.App.GUI/Views/Timers.axaml new file mode 100644 index 0000000..af8e0d8 --- /dev/null +++ b/src/Tableau.Migration.App.GUI/Views/Timers.axaml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Tableau.Migration.App.GUI/Views/Timers.axaml.cs b/src/Tableau.Migration.App.GUI/Views/Timers.axaml.cs new file mode 100644 index 0000000..2efe6ee --- /dev/null +++ b/src/Tableau.Migration.App.GUI/Views/Timers.axaml.cs @@ -0,0 +1,38 @@ +// +// Copyright (c) 2024, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at: +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.App.GUI.Views; +using Avalonia.Controls; +using Microsoft.Extensions.DependencyInjection; + +// using Avalonia; +using Tableau.Migration.App.GUI.ViewModels; + +/// +/// View for Tableau Server to Cloud User Mappings. +/// +public partial class Timers : UserControl +{ + /// + /// Initializes a new instance of the class. + /// + public Timers() + { + this.InitializeComponent(); + this.DataContext = App.ServiceProvider?.GetRequiredService(); + } +} \ No newline at end of file diff --git a/src/Tableau.Migration.App.GUI/Views/TokenDetails.axaml b/src/Tableau.Migration.App.GUI/Views/TokenDetails.axaml index a02c356..76f468a 100644 --- a/src/Tableau.Migration.App.GUI/Views/TokenDetails.axaml +++ b/src/Tableau.Migration.App.GUI/Views/TokenDetails.axaml @@ -16,6 +16,7 @@ diff --git a/src/Tableau.Migration.App.GUI/Views/UriDetails.axaml.cs b/src/Tableau.Migration.App.GUI/Views/UriDetails.axaml.cs index 5abeab8..b741a40 100644 --- a/src/Tableau.Migration.App.GUI/Views/UriDetails.axaml.cs +++ b/src/Tableau.Migration.App.GUI/Views/UriDetails.axaml.cs @@ -63,14 +63,7 @@ private void SetLabels() // Full URI Input this.UriLabel.Text = $"Tableau {env} URI"; - this.InfoHelp.HelpText = - $"Enter the Tableau {env} URL in one of the following formats:" - + Environment.NewLine + - $"- For a single-site: http://<{lowerEnv}_address>" - + Environment.NewLine + - $"- For a multi-site: http://<{lowerEnv}_address>/#/site/" - + Environment.NewLine + - "The site name is parsed from the URL if one is provided."; + this.InfoHelp.HelpText = string.Format(ViewConstants.URIDetailsHelpTextTemplate, env, lowerEnv); this.InfoHelp.DetailsUrl = "https://help.tableau.com/current/pro/desktop/en-us/embed_structure.htm"; this.UriFull.Watermark = $"Ex: http://<{lowerEnv}_address>/#/site/"; diff --git a/src/Tableau.Migration.App.GUI/Views/UserDomainMapping.axaml b/src/Tableau.Migration.App.GUI/Views/UserDomainMapping.axaml index c27a2ac..5858a62 100644 --- a/src/Tableau.Migration.App.GUI/Views/UserDomainMapping.axaml +++ b/src/Tableau.Migration.App.GUI/Views/UserDomainMapping.axaml @@ -10,25 +10,20 @@ - - + + + HelpText="{x:Static local:ViewConstants.UserDomainMappingHelpText}" /> - - - - diff --git a/src/Tableau.Migration.App.GUI/Views/UserDomainMapping.axaml.cs b/src/Tableau.Migration.App.GUI/Views/UserDomainMapping.axaml.cs index 2f1ce50..dc11034 100644 --- a/src/Tableau.Migration.App.GUI/Views/UserDomainMapping.axaml.cs +++ b/src/Tableau.Migration.App.GUI/Views/UserDomainMapping.axaml.cs @@ -29,16 +29,5 @@ public partial class UserDomainMapping : UserControl public UserDomainMapping() { this.InitializeComponent(); - - // Attach an event callback when to Checkbox evetns to disable the Textbox - this.DisableMapping.PropertyChanged += this.CheckBox_PropertyChanged; - } - - private void CheckBox_PropertyChanged(object? sender, Avalonia.AvaloniaPropertyChangedEventArgs e) - { - if (e.Property == CheckBox.IsCheckedProperty) - { - this.UserCloudDomain.IsEnabled = !this.DisableMapping.IsChecked ?? true; - } } } \ No newline at end of file diff --git a/src/Tableau.Migration.App.GUI/Views/UserFileMappings.axaml b/src/Tableau.Migration.App.GUI/Views/UserFileMappings.axaml index b322376..2b4c939 100644 --- a/src/Tableau.Migration.App.GUI/Views/UserFileMappings.axaml +++ b/src/Tableau.Migration.App.GUI/Views/UserFileMappings.axaml @@ -9,27 +9,37 @@ x:DataType="vm:UserFileMappingsViewModel"> - - + + + + HelpText="{x:Static local:ViewConstants.UserFileMappingHelpText}"/> - -