├── src ├── TeamCityTheatre.Web │ ├── Views │ │ ├── _ViewStart.cshtml │ │ ├── _ViewImports.cshtml │ │ ├── Shared │ │ │ ├── events │ │ │ │ ├── stopPropagation.ts │ │ │ │ └── onEnter.ts │ │ │ ├── arrays │ │ │ │ ├── mergeById.ts │ │ │ │ └── move.ts │ │ │ ├── operators │ │ │ │ └── debug.ts │ │ │ ├── _nav.cshtml │ │ │ ├── _Layout.cshtml │ │ │ ├── Error.cshtml │ │ │ ├── observables │ │ │ │ └── routes.ts │ │ │ ├── contracts.ts │ │ │ └── models.ts │ │ └── Home │ │ │ ├── dashboard.cshtml │ │ │ ├── settings.cshtml │ │ │ ├── dashboard.tsx │ │ │ ├── erroralert.components.tsx │ │ │ ├── settings.tsx │ │ │ ├── settings.observables.save-view.ts │ │ │ ├── settings.observables.delete-view.ts │ │ │ ├── settings.observables.selected-view.ts │ │ │ ├── settings.observables.selected-project.ts │ │ │ ├── settings.observables.ts │ │ │ ├── settings.components.tsx │ │ │ ├── settings.observables.views.ts │ │ │ ├── settings.observables.projects.ts │ │ │ ├── settings.pcss │ │ │ ├── settings.components.projects.tsx │ │ │ ├── dashboard.observables.ts │ │ │ ├── settings.components.selected-project.tsx │ │ │ ├── settings.components.selected-view.tsx │ │ │ ├── dashboard.pcss │ │ │ ├── dashboard.components.tsx │ │ │ └── settings.components.views.tsx │ ├── wwwroot │ │ ├── favicon.ico │ │ └── fonts │ │ │ ├── FontAwesome.otf │ │ │ ├── fontawesome-webfont.eot │ │ │ ├── fontawesome-webfont.ttf │ │ │ ├── fontawesome-webfont.woff │ │ │ └── fontawesome-webfont.woff2 │ ├── web.config │ ├── Controllers │ │ ├── HomeController.cs │ │ ├── ViewDataController.cs │ │ ├── ProjectsController.cs │ │ └── ViewsController.cs │ ├── Properties │ │ └── launchSettings.json │ ├── webpack.config.js │ ├── tsconfig.json │ ├── Startup.cs │ ├── package.json │ ├── appsettings.json │ ├── TeamCityTheatre.Web.csproj │ ├── Composition.cs │ └── Program.cs ├── TeamCityTheatre.Core │ ├── DataServices │ │ ├── Locators │ │ │ ├── IBuildLocator.cs │ │ │ ├── IProjectLocator.cs │ │ │ ├── IVcsRootLocator.cs │ │ │ ├── ILocator.cs │ │ │ ├── IBuildConfigurationLocator.cs │ │ │ ├── VsRootByInternalVcsRootIdLocator.cs │ │ │ ├── BuildConfigurationByNameLocator.cs │ │ │ ├── ProjectByNameLocator.cs │ │ │ ├── ProjectByIdLocator.cs │ │ │ ├── BuildConfigurationByIdLocator.cs │ │ │ └── BuildByBuildConfigurationIdLocator.cs │ │ ├── IVcsRootDataService.cs │ │ ├── TileDataService.cs │ │ ├── ProjectDataService.cs │ │ ├── ViewDataService.cs │ │ ├── BuildDataService.cs │ │ └── BuildConfigurationDataService.cs │ ├── Models │ │ ├── BuildStatus.cs │ │ ├── IBasicVcsRoot.cs │ │ ├── IBasicAgent.cs │ │ ├── VcsRootEntry.cs │ │ ├── Agent.cs │ │ ├── Property.cs │ │ ├── SnapshotDependency.cs │ │ ├── ArtifactDependency.cs │ │ ├── AgentRequirement.cs │ │ ├── BuildChange.cs │ │ ├── IBasicProject.cs │ │ ├── IBasicBuildConfiguration.cs │ │ ├── BuildTrigger.cs │ │ ├── IDetailedBuildChange.cs │ │ ├── VcsRoot.cs │ │ ├── Project.cs │ │ ├── IBasicBuild.cs │ │ ├── BuildConfiguration.cs │ │ ├── IDetailedVcsRoot.cs │ │ ├── Build.cs │ │ ├── BuildStep.cs │ │ ├── IDetailedBuildConfiguration.cs │ │ ├── IDetailedProject.cs │ │ └── IDetailedBuild.cs │ ├── Options │ │ ├── IStorageOptions.cs │ │ ├── ApiOptions.cs │ │ └── ConnectionOptions.cs │ ├── Client │ │ ├── Responses │ │ │ ├── PropertiesResponse.cs │ │ │ ├── VcsRootEntryResponse.cs │ │ │ ├── SnapshotDependencyResponse.cs │ │ │ ├── BuildsResponse.cs │ │ │ ├── ProjectsResponse.cs │ │ │ ├── BuildStepsResponse.cs │ │ │ ├── BuildTypeResponse.cs │ │ │ ├── AgentResponse.cs │ │ │ ├── ArtifactDependencyResponse.cs │ │ │ ├── BuildChangesResponse.cs │ │ │ ├── BuildTriggersResponse.cs │ │ │ ├── VcsRootEntriesResponse.cs │ │ │ ├── AgentRequirementsResponse.cs │ │ │ ├── PropertyResponse.cs │ │ │ ├── ArtifactDependenciesResponse.cs │ │ │ ├── SnapshotDependenciesResponse.cs │ │ │ ├── RunningInfoResponse.cs │ │ │ ├── AgentRequirementResponse.cs │ │ │ ├── BuildChangeResponse.cs │ │ │ ├── VcsRootResponse.cs │ │ │ ├── BuildTriggerResponse.cs │ │ │ ├── ProjectResponse.cs │ │ │ ├── BuildConfigurationResponse.cs │ │ │ ├── BuildResponse.cs │ │ │ └── BuildStepResponse.cs │ │ ├── TeamCityRequestPreparer.cs │ │ ├── Mappers │ │ │ ├── AgentMapper.cs │ │ │ ├── BuildStatusMapper.cs │ │ │ ├── PropertyMapper.cs │ │ │ ├── BuildChangeMapper.cs │ │ │ ├── VcsRootMapper.cs │ │ │ ├── BuildStepMapper.cs │ │ │ ├── VcsRootEntryMapper.cs │ │ │ ├── BuildTriggerMapper.cs │ │ │ ├── AgentRequirementMapper.cs │ │ │ ├── SnapshotDependencyMapper.cs │ │ │ ├── ProjectMapper.cs │ │ │ ├── ArtifactDependencyMapper.cs │ │ │ ├── BuildMapper.cs │ │ │ └── BuildConfigurationMapper.cs │ │ ├── ResponseValidator.cs │ │ ├── TeamCityRestClientFactory.cs │ │ └── TeamCityClient.cs │ ├── QueryServices │ │ ├── Models │ │ │ ├── ViewData.cs │ │ │ └── TileData.cs │ │ ├── ViewService.cs │ │ └── TileService.cs │ ├── ApplicationModels │ │ ├── Tile.cs │ │ ├── Configuration.cs │ │ └── View.cs │ ├── TeamCityTheatre.Core.csproj │ └── Repositories │ │ └── ConfigurationRepository.cs └── TeamCityTheatre.sln ├── .travis.yml ├── docker build.cmd ├── publish.sh ├── publish-win-arm.sh ├── .dockerignore ├── publish.cmd ├── publish-win-arm.cmd ├── .github └── workflows │ └── aspnetcore.yml ├── LICENSE ├── docker run.cmd ├── azure-pipelines.yml ├── Dockerfile ├── README.md └── .gitignore /src/TeamCityTheatre.Web/Views/_ViewStart.cshtml: -------------------------------------------------------------------------------- 1 | @{ 2 | Layout = "_layout"; 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: generic 2 | 3 | services: docker 4 | 5 | script: 6 | - docker build . 7 | -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/_ViewImports.cshtml: -------------------------------------------------------------------------------- 1 | @using TeamCityTheatre.Web 2 | @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers 3 | -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amoerie/teamcity-theatre/HEAD/src/TeamCityTheatre.Web/wwwroot/favicon.ico -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Shared/events/stopPropagation.ts: -------------------------------------------------------------------------------- 1 | export const stopPropagation = (event: React.SyntheticEvent) => event.stopPropagation(); -------------------------------------------------------------------------------- /docker build.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | echo Building new docker image for TeamCityTheatre 4 | docker build --file Dockerfile --tag teamcitytheatre_image . 5 | pause -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/wwwroot/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amoerie/teamcity-theatre/HEAD/src/TeamCityTheatre.Web/wwwroot/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/DataServices/Locators/IBuildLocator.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.DataServices.Locators { 2 | public interface IBuildLocator : ILocator { } 3 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/DataServices/Locators/IProjectLocator.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.DataServices.Locators { 2 | public interface IProjectLocator : ILocator { } 3 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/DataServices/Locators/IVcsRootLocator.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.DataServices.Locators { 2 | public interface IVcsRootLocator : ILocator { } 3 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/wwwroot/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amoerie/teamcity-theatre/HEAD/src/TeamCityTheatre.Web/wwwroot/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/wwwroot/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amoerie/teamcity-theatre/HEAD/src/TeamCityTheatre.Web/wwwroot/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/DataServices/Locators/ILocator.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.DataServices.Locators { 2 | public interface ILocator { 3 | string Serialize(); 4 | } 5 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/wwwroot/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amoerie/teamcity-theatre/HEAD/src/TeamCityTheatre.Web/wwwroot/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/wwwroot/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amoerie/teamcity-theatre/HEAD/src/TeamCityTheatre.Web/wwwroot/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/BuildStatus.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Models { 2 | public enum BuildStatus { 3 | Unknown, 4 | Success, 5 | Failure, 6 | Error 7 | } 8 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Options/IStorageOptions.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Options { 2 | public class StorageOptions { 3 | public string ConfigurationFile { get; set; } 4 | } 5 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Shared/events/onEnter.ts: -------------------------------------------------------------------------------- 1 | export const onEnter = (callback: () => any) => (event: React.KeyboardEvent) => { 2 | if (event.keyCode == 13) callback(); 3 | }; 4 | -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/DataServices/Locators/IBuildConfigurationLocator.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.DataServices.Locators { 2 | public interface IBuildConfigurationLocator : ILocator { } 3 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/DataServices/Locators/VsRootByInternalVcsRootIdLocator.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.DataServices.Locators { 2 | internal class VsRootByInternalVcsRootIdLocator { } 3 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Options/ApiOptions.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Options { 2 | public class ApiOptions { 3 | public string GetBuildsOfBuildConfiguration { get; set; } 4 | } 5 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/PropertiesResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TeamCityTheatre.Core.Client.Responses { 4 | public class PropertiesResponse { 5 | public List Property { get; set; } 6 | } 7 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/VcsRootEntryResponse.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Client.Responses { 2 | public class VcsRootEntryResponse { 3 | public VcsRootResponse VcsRoot { get; set; } 4 | public string CheckoutRules { get; set; } 5 | } 6 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/SnapshotDependencyResponse.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Client.Responses { 2 | public class SnapshotDependencyResponse { 3 | public string Id { get; set; } 4 | public PropertiesResponse Properties { get; set; } 5 | } 6 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/BuildsResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TeamCityTheatre.Core.Client.Responses { 4 | public class BuildsResponse { 5 | public int Count { get; set; } 6 | public List Build { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Home/dashboard.cshtml: -------------------------------------------------------------------------------- 1 | @section scripts { 2 | 3 | } 4 | 5 | @section styles { 6 | 7 | } 8 | 9 |
-------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Home/settings.cshtml: -------------------------------------------------------------------------------- 1 | @section scripts { 2 | 3 | } 4 | 5 | @section styles { 6 | 7 | } 8 | 9 |
-------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/ProjectsResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TeamCityTheatre.Core.Client.Responses { 4 | public class ProjectsResponse { 5 | public int Count { get; set; } 6 | public List Project { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/QueryServices/Models/ViewData.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace TeamCityTheatre.Core.QueryServices.Models { 5 | public class ViewData { 6 | public Guid Id { get; set; } 7 | public IList Tiles { get; set; } 8 | } 9 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/BuildStepsResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TeamCityTheatre.Core.Client.Responses { 4 | public class BuildStepsResponse { 5 | public int Count { get; set; } 6 | public List BuildStep { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/BuildTypeResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TeamCityTheatre.Core.Client.Responses { 4 | public class BuildTypesResponse { 5 | public int Count { get; set; } 6 | public List BuildType { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/AgentResponse.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Client.Responses { 2 | public class AgentResponse { 3 | public string Id { get; set; } 4 | public string Name { get; set; } 5 | public string TypeId { get; set; } 6 | public string Href { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/ArtifactDependencyResponse.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Client.Responses { 2 | public class ArtifactDependencyResponse { 3 | public string Id { get; set; } 4 | public string Type { get; set; } 5 | public PropertiesResponse Properties { get; set; } 6 | } 7 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/BuildChangesResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TeamCityTheatre.Core.Client.Responses { 4 | public class BuildChangesResponse { 5 | public int Count { get; set; } 6 | public List BuildChange { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/BuildTriggersResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TeamCityTheatre.Core.Client.Responses { 4 | public class BuildTriggersResponse { 5 | public int Count { get; set; } 6 | public List BuildTrigger { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/VcsRootEntriesResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TeamCityTheatre.Core.Client.Responses { 4 | public class VcsRootEntriesResponse { 5 | public int Count { get; set; } 6 | public List VcsRootEntry { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/AgentRequirementsResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TeamCityTheatre.Core.Client.Responses { 4 | public class AgentRequirementsResponse { 5 | public int Count { get; set; } 6 | public List AgentRequirement { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/PropertyResponse.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Client.Responses { 2 | /* 3 | * 4 | */ 5 | 6 | public class PropertyResponse { 7 | public string Name { get; set; } 8 | public string Value { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/ArtifactDependenciesResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TeamCityTheatre.Core.Client.Responses { 4 | public class ArtifactDependenciesResponse { 5 | public int Count { get; set; } 6 | public List ArtifactDependency { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/SnapshotDependenciesResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TeamCityTheatre.Core.Client.Responses { 4 | public class SnapshotDependenciesResponse { 5 | public int Count { get; set; } 6 | public List SnapshotDependency { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Home/dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { createElement } from "react"; 2 | import { render } from "react-dom"; 3 | 4 | import { Dashboard } from "./dashboard.components"; 5 | import { state } from "./dashboard.observables"; 6 | 7 | const root = document.getElementById("root"); 8 | 9 | state.subscribe(s => render(, root)); -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/IBasicVcsRoot.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Models { 2 | /* 3 | * 4 | */ 5 | 6 | public interface IBasicVcsRoot { 7 | string Id { get; } 8 | string Name { get; } 9 | string Href { get; } 10 | } 11 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/ApplicationModels/Tile.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TeamCityTheatre.Core.ApplicationModels { 4 | public class Tile { 5 | public Guid Id { get; set; } 6 | public string Label { get; set; } 7 | public string BuildConfigurationId { get; set; } 8 | public string BuildConfigurationDisplayName { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/ApplicationModels/Configuration.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | 4 | namespace TeamCityTheatre.Core.ApplicationModels { 5 | public class Configuration { 6 | public Configuration() { 7 | Views = Enumerable.Empty(); 8 | } 9 | 10 | public IEnumerable Views { get; set; } 11 | } 12 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/RunningInfoResponse.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Client.Responses { 2 | public class RunningInfoResponse { 3 | public double PercentageComplete { get; set; } 4 | public double ElapsedSeconds { get; set; } 5 | public double EstimatedTotalSeconds { get; set; } 6 | public string CurrentStageText { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/IBasicAgent.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Models { 2 | /* 3 | * 4 | */ 5 | 6 | public interface IBasicAgent { 7 | string Id { get; } 8 | string Name { get; } 9 | string TypeId { get; } 10 | string Href { get; } 11 | } 12 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/VcsRootEntry.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Models { 2 | public class VcsRootEntry { 3 | public IBasicVcsRoot VcsRoot { get; set; } 4 | public string CheckoutRules { get; set; } 5 | 6 | public override string ToString() { 7 | return string.Format("VcsRoot: {0}, CheckoutRules: {1}", VcsRoot, CheckoutRules); 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | rm -rf "./publish-output" & dotnet restore "./src/TeamCityTheatre.sln" && dotnet clean "./src/TeamCityTheatre.sln" --configuration Release --verbosity normal && cd "./src/TeamCityTheatre.Web" && npm ci && npm run build:release && cd .. && cd .. && dotnet publish "./src/TeamCityTheatre.Web/TeamCityTheatre.Web.csproj" --configuration Release --verbosity normal --output "./../../publish-output" -------------------------------------------------------------------------------- /publish-win-arm.sh: -------------------------------------------------------------------------------- 1 | rm -rf "./publish-output" & dotnet restore "./src/TeamCityTheatre.sln" && dotnet clean "./src/TeamCityTheatre.sln" --configuration Release --verbosity normal && cd "./src/TeamCityTheatre.Web" && npm ci && npm run build:release && cd .. && cd .. && dotnet publish "./src/TeamCityTheatre.Web/TeamCityTheatre.Web.csproj" -r win-arm --configuration Release --verbosity normal --output "./../../publish-output" -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # already published artifacts from local scripts 2 | publish-output 3 | 4 | # IDE related files 5 | src/.idea 6 | src/.vs 7 | 8 | # everything under node_modules, that would be huge 9 | src/TeamCityTheatre.Web/node_modules 10 | src/TeamCityTheatre.Web/Views/**/*.js 11 | src/TeamCityTheatre.Web/Views/**/*.js.map 12 | 13 | # any generated .NET build artifacts in development 14 | **/bin/**/* 15 | **/obj/**/* -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/DataServices/IVcsRootDataService.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using TeamCityTheatre.Core.DataServices.Locators; 3 | using TeamCityTheatre.Core.Models; 4 | 5 | namespace TeamCityTheatre.Core.DataServices { 6 | public interface IVcsRootDataService { 7 | IEnumerable GetAllVcsRoots(); 8 | IDetailedVcsRoot GetVcsRootDetails(IVcsRootLocator locator); 9 | } 10 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/Agent.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Models { 2 | public class Agent : IBasicAgent { 3 | public string Id { get; set; } 4 | public string Name { get; set; } 5 | public string TypeId { get; set; } 6 | public string Href { get; set; } 7 | 8 | public override string ToString() { 9 | return string.Format("Id: {0}, Name: {1}", Id, Name); 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/AgentRequirementResponse.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Client.Responses { 2 | /* 3 | * 4 | */ 5 | 6 | public class AgentRequirementResponse { 7 | public string Id { get; set; } 8 | public string Type { get; set; } 9 | public PropertiesResponse Properties { get; set; } 10 | } 11 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/Property.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Models { 2 | /* 3 | * 4 | */ 5 | 6 | public class Property { 7 | public string Name { get; set; } 8 | public string Value { get; set; } 9 | 10 | public override string ToString() { 11 | return string.Format("Name: {0}, Value: {1}", Name, Value); 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/TeamCityRequestPreparer.cs: -------------------------------------------------------------------------------- 1 | using RestSharp; 2 | 3 | namespace TeamCityTheatre.Core.Client { 4 | public interface ITeamCityRequestPreparer { 5 | void Prepare(IRestRequest request); 6 | } 7 | 8 | public class TeamCityRequestPreparer : ITeamCityRequestPreparer { 9 | public void Prepare(IRestRequest request) { 10 | request.DateFormat = "yyyyMMdd'T'HHmmsszzz"; 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/SnapshotDependency.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TeamCityTheatre.Core.Models { 4 | public class SnapshotDependency { 5 | public string Id { get; set; } 6 | public IReadOnlyCollection Properties { get; set; } 7 | 8 | public override string ToString() { 9 | return string.Format("Id: {0}, Properties: {1}", Id, Properties); 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /publish.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | TITLE TeamCityTheatre -- Publish 3 | rmdir /S /Q "./publish-output" & dotnet restore "./src/TeamCityTheatre.sln" && dotnet clean "./src/TeamCityTheatre.sln" --configuration Release --verbosity normal && cd "./src/TeamCityTheatre.Web" && npm ci && npm run build:release && cd .. && cd .. && dotnet publish "./src/TeamCityTheatre.Web/TeamCityTheatre.Web.csproj" --configuration Release --verbosity normal --output "./../../publish-output" -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/BuildChangeResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TeamCityTheatre.Core.Client.Responses { 4 | public class BuildChangeResponse { 5 | public string Id { get; set; } 6 | public string Version { get; set; } 7 | public string Username { get; set; } 8 | public DateTime Date { get; set; } 9 | public string Href { get; set; } 10 | public string WebLink { get; set; } 11 | } 12 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/ArtifactDependency.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TeamCityTheatre.Core.Models { 4 | public class ArtifactDependency { 5 | public string Id { get; set; } 6 | public string Type { get; set; } 7 | public IReadOnlyCollection Properties { get; set; } 8 | 9 | public override string ToString() { 10 | return string.Format("Id: {0}, Type: {1}", Id, Type); 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /publish-win-arm.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | TITLE TeamCityTheatre -- Publish 3 | rmdir /S /Q "./publish-output" & dotnet restore "./src/TeamCityTheatre.sln" && dotnet clean "./src/TeamCityTheatre.sln" --configuration Release --verbosity normal && cd "./src/TeamCityTheatre.Web" && npm ci && npm run build:release && cd .. && cd .. && dotnet publish "./src/TeamCityTheatre.Web/TeamCityTheatre.Web.csproj" -r win-arm --configuration Release --verbosity normal --output "./../../publish-output" -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Home/erroralert.components.tsx: -------------------------------------------------------------------------------- 1 | import {createElement } from "react"; 2 | 3 | export const ErrorAlert = (props: { error : Error }) => { 4 | return ( 5 |
6 | Error! Can you make sense of this? 7 |
8 |

Details

9 |
10 |       
11 |
12 | ); 13 | }; -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Home/settings.tsx: -------------------------------------------------------------------------------- 1 | import {createElement} from "react"; 2 | import {render} from "react-dom"; 3 | 4 | import {Settings} from "./settings.components"; 5 | import {ErrorAlert} from "./erroralert.components"; 6 | import {state} from "./settings.observables"; 7 | 8 | const root = document.getElementById("root"); 9 | 10 | state.subscribe({ 11 | next: s => render(, root), 12 | error: (err: Error) => render(, root) 13 | }); -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/VcsRootResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TeamCityTheatre.Core.Client.Responses { 4 | public class VcsRootResponse { 5 | public string Id { get; set; } 6 | public string Name { get; set; } 7 | public string Href { get; set; } 8 | public string VcsName { get; set; } 9 | public DateTime LastChecked { get; set; } 10 | public ProjectResponse Project { get; set; } 11 | public PropertiesResponse Properties { get; set; } 12 | } 13 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Options/ConnectionOptions.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Options { 2 | public class ConnectionOptions { 3 | public string Url { get; set; } 4 | public AuthenticationMode AuthenticationMode { get; set; } 5 | public string Username { get; set; } 6 | public string Password { get; set; } 7 | public string AccessToken { get; set; } 8 | } 9 | 10 | public enum AuthenticationMode { 11 | AccessToken, 12 | BasicAuthentication, 13 | Guest 14 | } 15 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/ApplicationModels/View.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace TeamCityTheatre.Core.ApplicationModels { 5 | public class View { 6 | public View() { 7 | Tiles = new List(); 8 | } 9 | 10 | public Guid Id { get; set; } 11 | public string Name { get; set; } 12 | public int DefaultNumberOfBranchesPerTile { get; set; } 13 | public int NumberOfColumns { get; set; } 14 | public ICollection Tiles { get; set; } 15 | } 16 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/web.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/DataServices/Locators/BuildConfigurationByNameLocator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TeamCityTheatre.Core.DataServices.Locators { 4 | public class BuildConfigurationByNameLocator : IBuildConfigurationLocator { 5 | readonly string _name; 6 | 7 | public BuildConfigurationByNameLocator(string name) { 8 | _name = name ?? throw new ArgumentNullException(nameof(name)); 9 | } 10 | 11 | public string Serialize() { 12 | return string.Concat("name", ":", _name); 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/DataServices/Locators/ProjectByNameLocator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TeamCityTheatre.Core.DataServices.Locators { 4 | public class ProjectByNameLocator : IProjectLocator { 5 | readonly string _projectName; 6 | 7 | public ProjectByNameLocator(string projectName) { 8 | _projectName = projectName ?? throw new ArgumentNullException(nameof(projectName)); 9 | } 10 | 11 | public string Serialize() { 12 | return string.Concat("name", ":", _projectName); 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/DataServices/Locators/ProjectByIdLocator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TeamCityTheatre.Core.DataServices.Locators { 4 | public class ProjectByIdLocator : IProjectLocator { 5 | readonly string _internalProjectId; 6 | 7 | public ProjectByIdLocator(string internalProjectId) { 8 | _internalProjectId = internalProjectId ?? throw new ArgumentNullException(nameof(internalProjectId)); 9 | } 10 | 11 | public string Serialize() { 12 | return string.Concat("id", ":", _internalProjectId); 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/AgentRequirement.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TeamCityTheatre.Core.Models { 4 | /* 5 | * 6 | */ 7 | 8 | public class AgentRequirement { 9 | public string Id { get; set; } 10 | public string Type { get; set; } 11 | public IReadOnlyCollection Properties { get; set; } 12 | 13 | public override string ToString() { 14 | return string.Format("Id: {0}, Type: {1}", Id, Type); 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/BuildTriggerResponse.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Client.Responses { 2 | /* 3 | 4 | 5 | 6 | 7 | 8 | 9 | */ 10 | 11 | public class BuildTriggerResponse { 12 | public string Id { get; set; } 13 | public string Type { get; set; } 14 | public PropertiesResponse Properties { get; set; } 15 | } 16 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/ProjectResponse.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Client.Responses { 2 | public class ProjectResponse { 3 | public bool IsArchived { get; set; } 4 | public string Href { get; set; } 5 | public string Id { get; set; } 6 | public string Name { get; set; } 7 | public string Description { get; set; } 8 | public string WebUrl { get; set; } 9 | public string ParentProjectId { get; set; } 10 | public ProjectResponse ParentProject { get; set; } 11 | public BuildTypesResponse BuildTypes { get; set; } 12 | } 13 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/BuildChange.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TeamCityTheatre.Core.Models { 4 | public class BuildChange : IDetailedBuildChange { 5 | public string Id { get; set; } 6 | public string Version { get; set; } 7 | public string Username { get; set; } 8 | public DateTime Date { get; set; } 9 | public string Href { get; set; } 10 | public string WebLink { get; set; } 11 | 12 | public override string ToString() { 13 | return string.Format("Id: {0}, Version: {1}, Username: {2}, Date: {3}", Id, Version, Username, Date); 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Mappers/AgentMapper.cs: -------------------------------------------------------------------------------- 1 | using TeamCityTheatre.Core.Client.Responses; 2 | using TeamCityTheatre.Core.Models; 3 | 4 | namespace TeamCityTheatre.Core.Client.Mappers { 5 | public interface IAgentMapper { 6 | Agent Map(AgentResponse agent); 7 | } 8 | 9 | public class AgentMapper : IAgentMapper { 10 | public Agent Map(AgentResponse agent) { 11 | if (agent == null) return null; 12 | return new Agent { 13 | Href = agent.Href, 14 | Id = agent.Id, 15 | Name = agent.Name, 16 | TypeId = agent.TypeId 17 | }; 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Controllers/HomeController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace TeamCityTheatre.Web.Controllers { 4 | [Route("")] 5 | public class HomeController : Controller { 6 | 7 | [HttpGet("")] 8 | public IActionResult Index() { 9 | return RedirectToAction("Dashboard"); 10 | } 11 | 12 | [HttpGet("dashboard/{*ignored}")] 13 | public IActionResult Dashboard(string ignored) { 14 | return View("dashboard"); 15 | } 16 | 17 | [HttpGet("settings")] 18 | public IActionResult Settings() { 19 | return View("settings"); 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iis": { 6 | "applicationUrl": "http://localhost/teamcitytheatre", 7 | "sslPort": 0 8 | } 9 | }, 10 | "profiles": { 11 | "IIS": { 12 | "commandName": "IIS", 13 | "launchBrowser": true, 14 | "launchUrl": "http://localhost/teamcitytheatre", 15 | "environmentVariables": { 16 | "ASPNETCORE_ENVIRONMENT": "Development", 17 | "ASPNETCORE_URLS": "http://*:5862" 18 | } 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/DataServices/Locators/BuildConfigurationByIdLocator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TeamCityTheatre.Core.DataServices.Locators { 4 | public class BuildConfigurationByIdLocator : IBuildConfigurationLocator { 5 | readonly string _buildConfigurationId; 6 | 7 | public BuildConfigurationByIdLocator(string buildConfigurationId) { 8 | _buildConfigurationId = buildConfigurationId ?? throw new ArgumentNullException(nameof(buildConfigurationId)); 9 | } 10 | 11 | public string Serialize() { 12 | return string.Concat("id", ":", _buildConfigurationId); 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/IBasicProject.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Models { 2 | /* 3 | * 5 | */ 6 | 7 | public interface IBasicProject { 8 | bool IsArchived { get; } 9 | string Href { get; } 10 | string Id { get; } 11 | string Name { get; } 12 | string Description { get; } 13 | string WebUrl { get; } 14 | string ParentProjectId { get; } 15 | } 16 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/IBasicBuildConfiguration.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Models { 2 | /* 3 | * 5 | */ 6 | 7 | public interface IBasicBuildConfiguration { 8 | string Id { get; } 9 | string Name { get; } 10 | string ProjectId { get; } 11 | string Href { get; } 12 | string WebUrl { get; } 13 | } 14 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Mappers/BuildStatusMapper.cs: -------------------------------------------------------------------------------- 1 | using TeamCityTheatre.Core.Models; 2 | 3 | namespace TeamCityTheatre.Core.Client.Mappers { 4 | public interface IBuildStatusMapper { 5 | BuildStatus Map(string buildStatus); 6 | } 7 | 8 | public class BuildStatusMapper : IBuildStatusMapper { 9 | public BuildStatus Map(string buildStatus) { 10 | switch (buildStatus) { 11 | case "SUCCESS": return BuildStatus.Success; 12 | case "FAILURE": return BuildStatus.Failure; 13 | case "ERROR": return BuildStatus.Error; 14 | default: return BuildStatus.Unknown; 15 | } 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/ResponseValidator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net; 3 | using RestSharp; 4 | 5 | namespace TeamCityTheatre.Core.Client { 6 | public interface IResponseValidator { 7 | void Validate(IRestResponse response); 8 | } 9 | 10 | public class ResponseValidator : IResponseValidator { 11 | public void Validate(IRestResponse response) { 12 | if (response.StatusCode == HttpStatusCode.Unauthorized) 13 | throw new Exception("Authorization failed"); 14 | if (response.ErrorException != null) 15 | throw response.ErrorException; 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Shared/arrays/mergeById.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Swaps an element of an array with an updated version, using the id to find the element 3 | * If the element does not already exist in the array, it's added 4 | */ 5 | export const mergeById = (updatedElement: T, array: T[]) => { 6 | const clone : T[] = array.slice(0); 7 | let found = false; 8 | for(let i = 0; i < clone.length; i++) { 9 | if(clone[i].id === updatedElement.id) { 10 | found = true; 11 | clone[i] = updatedElement; 12 | break; 13 | } 14 | } 15 | if(!found) 16 | clone.push(updatedElement); 17 | return clone; 18 | }; -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Shared/operators/debug.ts: -------------------------------------------------------------------------------- 1 | import { tap } from 'rxjs/operators'; 2 | import { Observable } from "rxjs"; 3 | 4 | const isProduction: boolean = process && process.env && process.env.NODE_ENV === "production"; 5 | 6 | export const debug = (name: string) => (source: Observable) => isProduction ? source : source.pipe(tap({ 7 | next(value) { 8 | console.group("Next : " + name); 9 | console.dir(value); 10 | console.groupEnd(); 11 | }, 12 | error(err) { 13 | console.group("Error : " + name); 14 | console.dir(err); 15 | console.groupEnd(); 16 | }, 17 | complete() { 18 | console.log("Complete : " + name) 19 | }, 20 | })); -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/DataServices/Locators/BuildByBuildConfigurationIdLocator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TeamCityTheatre.Core.DataServices.Locators { 4 | internal class BuildByBuildConfigurationLocator : IBuildLocator { 5 | readonly IBuildConfigurationLocator _buildConfigurationLocator; 6 | 7 | public BuildByBuildConfigurationLocator(IBuildConfigurationLocator buildConfigurationLocator) { 8 | _buildConfigurationLocator = buildConfigurationLocator ?? throw new ArgumentNullException(nameof(buildConfigurationLocator)); 9 | } 10 | 11 | public string Serialize() { 12 | return string.Format("buildType:({0})", _buildConfigurationLocator.Serialize()); 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/BuildTrigger.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TeamCityTheatre.Core.Models { 4 | /* 5 | 6 | 7 | 8 | 9 | 10 | 11 | */ 12 | 13 | public class BuildTrigger { 14 | public string Id { get; set; } 15 | public string Type { get; set; } 16 | public IReadOnlyCollection Properties { get; set; } 17 | 18 | public override string ToString() { 19 | return string.Format("Id: {0}, Type: {1}", Id, Type); 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/IDetailedBuildChange.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TeamCityTheatre.Core.Models { 4 | /* 5 | * 8 | */ 9 | 10 | public interface IDetailedBuildChange { 11 | string Id { get; set; } 12 | string Version { get; set; } 13 | string Username { get; set; } 14 | DateTime Date { get; set; } 15 | string Href { get; set; } 16 | string WebLink { get; set; } 17 | } 18 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/VcsRoot.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace TeamCityTheatre.Core.Models { 5 | public class VcsRoot : IBasicVcsRoot, IDetailedVcsRoot { 6 | public string Id { get; set; } 7 | public string Name { get; set; } 8 | public string Href { get; set; } 9 | public string VcsName { get; set; } 10 | public DateTime LastChecked { get; set; } 11 | public IBasicProject Project { get; set; } 12 | public IReadOnlyCollection Properties { get; set; } 13 | 14 | public override string ToString() { 15 | return string.Format("Id: {0}, Name: {1}, VcsName: {2}, Project: {3}", Id, Name, VcsName, Project); 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/TeamCityTheatre.Core.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp2.2 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Controllers/ViewDataController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Mvc; 4 | using TeamCityTheatre.Core.QueryServices; 5 | using TeamCityTheatre.Core.QueryServices.Models; 6 | 7 | namespace TeamCityTheatre.Web.Controllers { 8 | [Route("api/viewdata")] 9 | public class ViewDataController : Controller { 10 | readonly IViewService _viewsService; 11 | 12 | public ViewDataController(IViewService viewsService) { 13 | _viewsService = viewsService; 14 | } 15 | 16 | [HttpGet("{id:guid}")] 17 | public async Task Get(Guid id) { 18 | return await _viewsService.GetLatestViewDataAsync(id.ToString()); 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Shared/_nav.cshtml: -------------------------------------------------------------------------------- 1 | @using TeamCityTheatre.Web.Controllers 2 | 3 | -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/QueryServices/Models/TileData.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using TeamCityTheatre.Core.Models; 5 | 6 | namespace TeamCityTheatre.Core.QueryServices.Models { 7 | public class TileData { 8 | public Guid Id { get; set; } 9 | public string Label { get; set; } 10 | 11 | public IList Builds { get; set; } 12 | 13 | public BuildStatus CombinedBuildStatus 14 | { 15 | get 16 | { 17 | var defaultBranchBuild = Builds.FirstOrDefault(b => b.IsDefaultBranch); 18 | return defaultBranchBuild?.Status ?? (Builds.All(b => b.Status == BuildStatus.Success) ? BuildStatus.Success : BuildStatus.Error); 19 | } 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Shared/_Layout.cshtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | TeamCity Theatre @Startup.Version 7 | 8 | 9 | 10 | @RenderSection("styles", required:false) 11 | 12 | 13 | @{ await Html.RenderPartialAsync("_nav"); } 14 |
15 | @RenderBody() 16 |
17 | 18 | @RenderSection("scripts", required: false) 19 | 20 | -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Shared/Error.cshtml: -------------------------------------------------------------------------------- 1 | @{ 2 | ViewData["Title"] = "Error"; 3 | } 4 | 5 |

Error.

6 |

An error occurred while processing your request.

7 | 8 |

Development Mode

9 |

10 | Swapping to Development environment will display more detailed information about the error that occurred. 11 |

12 |

13 | Development environment should not be enabled in deployed applications, as it can result in sensitive information from exceptions being displayed to end users. For local debugging, development environment can be enabled by setting the ASPNETCORE_ENVIRONMENT environment variable to Development, and restarting the application. 14 |

15 | -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/Project.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TeamCityTheatre.Core.Models { 4 | public class Project : IDetailedProject, IBasicProject { 5 | public bool IsArchived { get; set; } 6 | public string Href { get; set; } 7 | public string Id { get; set; } 8 | public string Name { get; set; } 9 | public string Description { get; set; } 10 | public string WebUrl { get; set; } 11 | public string ParentProjectId { get; set; } 12 | public IBasicProject ParentProject { get; set; } 13 | public IReadOnlyCollection BuildConfigurations { get; set; } 14 | 15 | public override string ToString() { 16 | return string.Format("Id: {0}, Name: {1}, Description: {2}", Id, Name, Description); 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Home/settings.observables.save-view.ts: -------------------------------------------------------------------------------- 1 | import { map, share, switchMap } from 'rxjs/operators'; 2 | import { Observable, Subject } from "rxjs"; 3 | 4 | import { debug } from "../Shared/operators/debug"; 5 | 6 | import { View } from "../Shared/models"; 7 | import { IView } from "../Shared/contracts"; 8 | import { ajax } from "rxjs/ajax"; 9 | 10 | const savedViewsSubject = new Subject(); 11 | export const saveView = (view: View) => savedViewsSubject.next(view); 12 | export const savedViews: Observable = savedViewsSubject.pipe( 13 | switchMap(savedView => ajax 14 | .post("api/views", savedView, {"Content-Type": "application/json"}).pipe( 15 | map(xhr => xhr.response as IView), 16 | map(View.fromContract),) 17 | )) 18 | .pipe(debug("Saved view")) 19 | .pipe(share()); 20 | -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = function () { 4 | return { 5 | stats: { 6 | hash: false, 7 | version: false, 8 | assets: true 9 | }, 10 | entry: { 11 | "dashboard": "./Views/Home/dashboard.js", 12 | "settings": "./Views/Home/settings.js" 13 | }, 14 | output: { 15 | path: path.resolve(__dirname, "wwwroot/js"), 16 | filename: "[name].js" 17 | }, 18 | optimization: { 19 | splitChunks: { 20 | cacheGroups: { 21 | commons: { 22 | test: /[\\/]node_modules[\\/]/, 23 | name: "vendor", 24 | chunks: "all" 25 | } 26 | } 27 | } 28 | }, 29 | performance: { 30 | maxEntrypointSize: 524288, // 512 kb 31 | maxAssetSize: 524288 // // 512 kb 32 | } 33 | }; 34 | }; -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/BuildConfigurationResponse.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Client.Responses { 2 | public class BuildTypeResponse { 3 | public string Id { get; set; } 4 | public string Name { get; set; } 5 | public string ProjectId { get; set; } 6 | public string Href { get; set; } 7 | public string WebUrl { get; set; } 8 | public VcsRootEntriesResponse VcsRootEntries { get; set; } 9 | public PropertiesResponse Settings { get; set; } 10 | public PropertiesResponse Parameters { get; set; } 11 | public BuildStepsResponse Steps { get; set; } 12 | public BuildTriggersResponse Triggers { get; set; } 13 | public SnapshotDependenciesResponse SnapshotDependencies { get; set; } 14 | public ArtifactDependenciesResponse ArtifactDependencies { get; set; } 15 | public AgentRequirementsResponse AgentRequirements { get; set; } 16 | } 17 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/DataServices/TileDataService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using TeamCityTheatre.Core.ApplicationModels; 4 | using TeamCityTheatre.Core.Repositories; 5 | 6 | namespace TeamCityTheatre.Core.DataServices { 7 | public interface ITileDataService { 8 | Tile GetTileById(Guid id); 9 | } 10 | 11 | public class TileDataService : ITileDataService { 12 | readonly IConfigurationRepository _configurationRepository; 13 | 14 | public TileDataService(IConfigurationRepository configurationRepository) { 15 | _configurationRepository = configurationRepository ?? throw new ArgumentNullException(nameof(configurationRepository)); 16 | } 17 | 18 | public Tile GetTileById(Guid id) { 19 | return _configurationRepository.GetConfiguration() 20 | .Views.SelectMany(v => v.Tiles) 21 | .SingleOrDefault(t => t.Id == id); 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Home/settings.observables.delete-view.ts: -------------------------------------------------------------------------------- 1 | import { map, startWith, share, switchMap } from 'rxjs/operators'; 2 | import { Observable, Subject } from "rxjs"; 3 | 4 | import { debug } from "../Shared/operators/debug"; 5 | 6 | import { View } from "../Shared/models"; 7 | import { ajax } from "rxjs/ajax"; 8 | 9 | const requestDeleteViewSubject = new Subject(); 10 | export const requestDeleteView = (view: View | null) => requestDeleteViewSubject.next(view); 11 | export const deleteViewRequests: Observable = requestDeleteViewSubject.pipe(startWith(null)); 12 | 13 | const confirmDeleteViewSubject = new Subject(); 14 | export const confirmDeleteView = (view: View) => confirmDeleteViewSubject.next(view); 15 | export const deletedViews : Observable = confirmDeleteViewSubject.pipe( 16 | switchMap(view => ajax.delete(`api/views/${view.id}`).pipe(map(xhr => view))), 17 | debug("Deleted view"), 18 | share() 19 | ); -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Shared/observables/routes.ts: -------------------------------------------------------------------------------- 1 | import { of, combineLatest, Observable } from "rxjs"; 2 | import { map } from "rxjs/operators"; 3 | 4 | const base = document.getElementsByTagName("base")[0]; 5 | 6 | const baseHrefs = of(base && base.getAttribute("href") || "/").pipe(map(decodeURIComponent)); 7 | 8 | const locations = of(window.location); 9 | 10 | export interface Route { 11 | absolutePath: string; 12 | relativePath: string; 13 | relativePathSegments: string[]; 14 | } 15 | 16 | export const routes : Observable = combineLatest(baseHrefs, locations).pipe( 17 | map(([ baseHref, location ]) => { 18 | const absolutePath = decodeURIComponent(location.pathname); 19 | const relativePath = absolutePath.substring(baseHref.length); 20 | const segments = relativePath.split('/'); 21 | return { 22 | absolutePath: absolutePath, 23 | relativePath: relativePath, 24 | relativePathSegments: segments 25 | } 26 | }) 27 | ); -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Mappers/PropertyMapper.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using TeamCityTheatre.Core.Client.Responses; 4 | using TeamCityTheatre.Core.Models; 5 | 6 | namespace TeamCityTheatre.Core.Client.Mappers { 7 | public interface IPropertyMapper { 8 | Property Map(PropertyResponse property); 9 | IReadOnlyCollection Map(PropertiesResponse properties); 10 | } 11 | 12 | public class PropertyMapper : IPropertyMapper { 13 | public Property Map(PropertyResponse property) { 14 | if (property == null) return null; 15 | return new Property { 16 | Name = property.Name, 17 | Value = property.Value 18 | }; 19 | } 20 | 21 | public IReadOnlyCollection Map(PropertiesResponse properties) { 22 | if (properties == null || properties.Property == null) 23 | return new List(); 24 | return properties.Property.Select(Map).ToList(); 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Home/settings.observables.selected-view.ts: -------------------------------------------------------------------------------- 1 | import { scan, startWith, switchMap } from 'rxjs/operators'; 2 | import { Observable, Subject } from "rxjs"; 3 | 4 | import { debug } from "../Shared/operators/debug"; 5 | 6 | import { View } from "../Shared/models"; 7 | import { updatedViews } from "./settings.observables.views"; 8 | 9 | const selectedViewsSubject = new Subject(); 10 | export const selectView = (view: View) => selectedViewsSubject.next(view); 11 | 12 | export const selectedViews: Observable = selectedViewsSubject.pipe( 13 | startWith(null), 14 | switchMap((selectedView: View | null) => updatedViews.pipe( 15 | scan((previouslySelectedView: View | null, updatedView: View) => 16 | previouslySelectedView !== null && previouslySelectedView.id === updatedView.id 17 | ? updatedView 18 | : previouslySelectedView, selectedView), 19 | startWith(selectedView),) 20 | ),) 21 | .pipe(debug("Selected view")); 22 | -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/IBasicBuild.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Models { 2 | /* 3 | * 6 | */ 7 | 8 | public interface IBasicBuild { 9 | string Id { get; } 10 | string BuildConfigurationId { get; } 11 | double? PercentageComplete { get; } 12 | double? ElapsedSeconds { get; } 13 | double? EstimatedTotalSeconds { get; } 14 | string CurrentStageText { get; } 15 | string Number { get; } 16 | BuildStatus Status { get; } 17 | string State { get; } 18 | string BranchName { get; } 19 | bool IsDefaultBranch { get; } 20 | string Href { get; } 21 | string WebUrl { get; } 22 | } 23 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "alwaysStrict": true, 5 | "forceConsistentCasingInFileNames": true, 6 | "importHelpers": true, 7 | "jsx": "react", 8 | "jsxFactory": "createElement", 9 | "lib": [ "dom", "es5", "es2015.promise", "scripthost" ], 10 | "module": "es2015", 11 | "moduleResolution": "node", 12 | "noImplicitAny": true, 13 | "noImplicitThis": true, 14 | "noEmitOnError": true, 15 | "noEmitHelpers": true, 16 | "noUnusedLocals": true, 17 | "removeComments": false, 18 | "sourceMap": false, 19 | "strictNullChecks": true, 20 | "strictFunctionTypes": true, 21 | "strictPropertyInitialization": true, 22 | "skipLibCheck": true, 23 | "target": "es5", 24 | "typeRoots": [ 25 | "./node_modules/@types" 26 | ] 27 | }, 28 | "include": [ 29 | "./Views/**/*.ts", 30 | "./Views/**/*.tsx" 31 | ], 32 | "exclude": [ 33 | "node_modules", 34 | "wwwroot" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Home/settings.observables.selected-project.ts: -------------------------------------------------------------------------------- 1 | import { map, share, startWith, switchMap } from 'rxjs/operators'; 2 | import { Observable, Subject } from "rxjs"; 3 | 4 | import { debug } from "../Shared/operators/debug"; 5 | import { IDetailedProject } from "../Shared/contracts"; 6 | import { BuildConfiguration, Project } from "../Shared/models"; 7 | import { ajax } from "rxjs/ajax"; 8 | 9 | const selectedProjectsSubject = new Subject(); 10 | export const selectProject = (project: Project) => selectedProjectsSubject.next(project); 11 | 12 | export const selectedProjects: Observable = selectedProjectsSubject.pipe( 13 | switchMap(project => ajax.getJSON(`api/projects/${project.id}`).pipe( 14 | map(detailedProject => project.withBuildConfigurations(detailedProject.buildConfigurations.map(BuildConfiguration.fromContract))), 15 | startWith(null))), 16 | debug("Selected project"), 17 | startWith(null), 18 | share() 19 | ); 20 | -------------------------------------------------------------------------------- /.github/workflows/aspnetcore.yml: -------------------------------------------------------------------------------- 1 | name: ASP.NET Core CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [12.11.1] 13 | dotnet-version: [2.2.402] 14 | 15 | steps: 16 | - name: Git checkout 17 | uses: actions/checkout@v1 18 | 19 | - name: Setup .NET Core ${{ matrix.dotnet-version }} 20 | uses: actions/setup-dotnet@v1 21 | with: 22 | dotnet-version: ${{ matrix.dotnet-version }} 23 | 24 | - name: Setup Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | 29 | - name: NPM install 30 | run: npm ci 31 | working-directory: "./src/TeamCityTheatre.Web" 32 | 33 | - name: NPM build 34 | run: npm run build:release 35 | working-directory: "./src/TeamCityTheatre.Web" 36 | 37 | - name: Build with dotnet 38 | run: dotnet build --configuration Release 39 | working-directory: "./src/TeamCityTheatre.Web" 40 | -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Shared/arrays/move.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Swaps two elements in an array by index 3 | * @param oldIndex the old index 4 | * @param newIndex the new index 5 | * @param array the array of elements 6 | */ 7 | export const move = (oldIndex: number, newIndex: number, array: T[]) => { 8 | if(oldIndex < 0) throw new Error("Invalid old index: cannot be lower than 0"); 9 | if(newIndex < 0) throw new Error("Invalid new index: cannot be lower than 0"); 10 | if(oldIndex >= array.length) throw new Error(`Invalid old index: cannot be higher than ${array.length}`); 11 | if(newIndex >= array.length) throw new Error(`Invalid new index: cannot be higher than ${array.length}`); 12 | const smallestIndex = Math.min(oldIndex, newIndex); 13 | const biggestIndex = Math.max(oldIndex, newIndex); 14 | return array.map((element, index) => { 15 | if(index < smallestIndex || index > biggestIndex) return element; // not affected by move 16 | if(index === newIndex) return array[oldIndex]; // the actual move 17 | return newIndex > oldIndex 18 | ? array[index + 1] 19 | : array[index - 1]; 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Alexander Moerman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Controllers/ProjectsController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Microsoft.AspNetCore.Mvc; 5 | using TeamCityTheatre.Core.DataServices; 6 | using TeamCityTheatre.Core.DataServices.Locators; 7 | using TeamCityTheatre.Core.Models; 8 | 9 | namespace TeamCityTheatre.Web.Controllers { 10 | [Route("api/projects")] 11 | public class ProjectsController : Controller { 12 | readonly IProjectDataService _projectDataService; 13 | 14 | public ProjectsController(IProjectDataService projectDataService) { 15 | _projectDataService = projectDataService ?? throw new ArgumentNullException(nameof(projectDataService)); 16 | } 17 | 18 | // GET: api/projects 19 | [HttpGet] 20 | public async Task> GetAsync() { 21 | return await _projectDataService.GetAllProjectsAsync(); 22 | } 23 | 24 | // GET api/projects/broka-50x 25 | [HttpGet("{id}")] 26 | public async Task GetAsync(string id) { 27 | return await _projectDataService.GetProjectDetailsAsync(new ProjectByIdLocator(id)); 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/QueryServices/ViewService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using TeamCityTheatre.Core.DataServices; 5 | using TeamCityTheatre.Core.QueryServices.Models; 6 | 7 | namespace TeamCityTheatre.Core.QueryServices { 8 | public interface IViewService { 9 | Task GetLatestViewDataAsync(string viewId); 10 | } 11 | 12 | public class ViewService : IViewService { 13 | readonly IViewDataService _viewDataService; 14 | readonly ITileService _tileService; 15 | 16 | public ViewService(IViewDataService viewDataService, ITileService tileService) { 17 | _viewDataService = viewDataService ?? throw new ArgumentNullException(nameof(viewDataService)); 18 | _tileService = tileService; 19 | } 20 | 21 | public async Task GetLatestViewDataAsync(string viewId) { 22 | var view = _viewDataService.GetViewById(Guid.Parse(viewId)); 23 | var tiles = await Task.WhenAll(view.Tiles.Select(t => _tileService.GetLatestTileDataAsync(view, t)).ToList()); 24 | return new ViewData { 25 | Id = view.Id, 26 | Tiles = tiles 27 | }; 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Home/settings.observables.ts: -------------------------------------------------------------------------------- 1 | import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; 2 | import { debug } from "../Shared/operators/debug"; 3 | 4 | import { Project, View } from "../Shared/models"; 5 | 6 | import { views } from "./settings.observables.views"; 7 | import { deleteViewRequests } from "./settings.observables.delete-view"; 8 | import { selectedViews } from "./settings.observables.selected-view"; 9 | import { rootProjects } from "./settings.observables.projects"; 10 | import { selectedProjects } from "./settings.observables.selected-project"; 11 | 12 | 13 | export interface ISettingsState { 14 | views: View[] | null; 15 | deleteViewRequest: View | null; 16 | selectedView: View | null; 17 | rootProject: Project | null; 18 | selectedProject: Project | null; 19 | } 20 | 21 | export const state: Observable = observableCombineLatest( 22 | views, deleteViewRequests, selectedViews, rootProjects, selectedProjects, 23 | (vs, dvr, sv, rp, sp) => ({ 24 | views: vs, 25 | deleteViewRequest: dvr, 26 | selectedView: sv, 27 | rootProject: rp, 28 | selectedProject: sp 29 | }) 30 | ) 31 | .pipe(debug("State")); -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/BuildConfiguration.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TeamCityTheatre.Core.Models { 4 | public class BuildConfiguration : IBasicBuildConfiguration, IDetailedBuildConfiguration { 5 | public string Id { get; set; } 6 | public string Name { get; set; } 7 | public string ProjectId { get; set; } 8 | public string Href { get; set; } 9 | public string WebUrl { get; set; } 10 | public IReadOnlyCollection VcsRootEntries { get; set; } 11 | public IReadOnlyCollection Settings { get; set; } 12 | public IReadOnlyCollection Parameters { get; set; } 13 | public IReadOnlyCollection Steps { get; set; } 14 | public IReadOnlyCollection Triggers { get; set; } 15 | public IReadOnlyCollection SnapshotDependencies { get; set; } 16 | public IReadOnlyCollection ArtifactDependencies { get; set; } 17 | public IReadOnlyCollection AgentRequirements { get; set; } 18 | 19 | public override string ToString() { 20 | return string.Format("Id: {0}, Name: {1}, ProjectId: {2}", Id, Name, ProjectId); 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Mappers/BuildChangeMapper.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using TeamCityTheatre.Core.Client.Responses; 4 | using TeamCityTheatre.Core.Models; 5 | 6 | namespace TeamCityTheatre.Core.Client.Mappers { 7 | public interface IBuildChangeMapper { 8 | BuildChange Map(BuildChangeResponse buildChange); 9 | IReadOnlyCollection Map(BuildChangesResponse buildChanges); 10 | } 11 | 12 | public class BuildChangeMapper : IBuildChangeMapper { 13 | public BuildChange Map(BuildChangeResponse buildChange) { 14 | if (buildChange == null) 15 | return null; 16 | return new BuildChange { 17 | Date = buildChange.Date, 18 | Href = buildChange.Href, 19 | Id = buildChange.Id, 20 | Username = buildChange.Username, 21 | Version = buildChange.Version, 22 | WebLink = buildChange.WebLink 23 | }; 24 | } 25 | 26 | public IReadOnlyCollection Map(BuildChangesResponse buildChanges) { 27 | if (buildChanges == null || buildChanges.BuildChange == null) 28 | return new List(); 29 | return buildChanges.BuildChange.Select(Map).ToList(); 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Mappers/VcsRootMapper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using TeamCityTheatre.Core.Client.Responses; 3 | using TeamCityTheatre.Core.Models; 4 | 5 | namespace TeamCityTheatre.Core.Client.Mappers { 6 | public interface IVcsRootMapper { 7 | VcsRoot Map(VcsRootResponse vcsRootResponse); 8 | } 9 | 10 | public class VcsRootMapper : IVcsRootMapper { 11 | readonly Lazy _projectMapper; 12 | readonly IPropertyMapper _propertyMapper; 13 | 14 | public VcsRootMapper(Lazy projectMapper, IPropertyMapper propertyMapper) { 15 | _projectMapper = projectMapper ?? throw new ArgumentNullException(nameof(projectMapper)); 16 | _propertyMapper = propertyMapper ?? throw new ArgumentNullException(nameof(propertyMapper)); 17 | } 18 | 19 | public VcsRoot Map(VcsRootResponse vcsRoot) { 20 | if (vcsRoot == null) return null; 21 | return new VcsRoot { 22 | Href = vcsRoot.Href, 23 | Id = vcsRoot.Id, 24 | Name = vcsRoot.Name, 25 | VcsName = vcsRoot.VcsName, 26 | LastChecked = vcsRoot.LastChecked, 27 | Project = _projectMapper.Value.Map(vcsRoot.Project), 28 | Properties = _propertyMapper.Map(vcsRoot.Properties) 29 | }; 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Home/settings.components.tsx: -------------------------------------------------------------------------------- 1 | import {createElement} from "react"; 2 | import {Views} from "./settings.components.views"; 3 | import {SelectedView} from "./settings.components.selected-view"; 4 | import {Projects} from "./settings.components.projects"; 5 | import {SelectedProject} from "./settings.components.selected-project"; 6 | import {ISettingsState} from "./settings.observables"; 7 | 8 | export const Settings = (props: ISettingsState) => { 9 | const { views, deleteViewRequest, selectedView, rootProject, selectedProject } = props; 10 | return ( 11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | ); 22 | }; 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /docker run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | :: PREP FOLDERS 3 | if not exist docker ( mkdir docker ) 4 | if not exist docker\logs ( mkdir docker\logs ) 5 | if not exist docker\data ( mkdir docker\data ) 6 | 7 | :: STOP AND REMOVE EXISTING CONTAINER 8 | echo Stopping and removing previous container 9 | docker stop teamcitytheatre 2> nul 10 | docker rm teamcitytheatre 2> nul 11 | 12 | :: START NEW DOCKER CONTAINER 13 | echo Starting TeamCityTheatre container 14 | set dockerlogs="%~dp0docker\logs" 15 | set dockerdata="%~dp0docker\data" 16 | docker run --detach --publish 12000:80 --name teamcitytheatre --env-file docker.env --volume "%dockerlogs%":"C:\TeamCityTheatre\Logs" --volume "%dockerdata%":"C:\TeamCityTheatre\Data" teamcitytheatre_image 17 | 18 | if %errorlevel% neq 0 ( 19 | echo Failed to run docker image 20 | pause 21 | exit /b %errorlevel% 22 | ) 23 | 24 | echo Giving the server some time to spin up 25 | timeout 5 26 | echo TeamCityTheatre should be started by now 27 | set GET_IP="docker inspect -f "{{ .NetworkSettings.Networks.nat.IPAddress }}" teamcitytheatre" 28 | FOR /F "tokens=*" %%i IN (' %GET_IP% ') DO SET IP=%%i 29 | echo The server should be accessible via http://%IP% or via http://localhost:12000 30 | echo You can inspect logs under %dockerlogs% and configuration.json under %dockerdata% 31 | pause -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/BuildResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | 4 | namespace TeamCityTheatre.Core.Client.Responses { 5 | public class BuildResponse { 6 | public string Id { get; set; } 7 | public string BuildTypeId { get; set; } 8 | public string Number { get; set; } 9 | public string Status { get; set; } 10 | public string State { get; set; } 11 | public double? PercentageComplete { get; set; } 12 | public string BranchName { get; set; } 13 | public bool DefaultBranch { get; set; } 14 | public string Href { get; set; } 15 | public string WebUrl { get; set; } 16 | public string StatusText { get; set; } 17 | 18 | [JsonProperty(PropertyName="running-info")] 19 | public RunningInfoResponse RunningInfo { get; set; } 20 | public BuildTypeResponse BuildType { get; set; } 21 | public DateTime QueuedDate { get; set; } 22 | public DateTime StartDate { get; set; } 23 | public DateTime FinishDate { get; set; } 24 | public BuildChangesResponse LastChanges { get; set; } 25 | public AgentResponse Agent { get; set; } 26 | public PropertiesResponse Properties { get; set; } 27 | public BuildsResponse SnapshotDependencies { get; set; } 28 | public BuildsResponse ArtifactDependencies { get; set; } 29 | } 30 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Mappers/BuildStepMapper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using TeamCityTheatre.Core.Client.Responses; 5 | using TeamCityTheatre.Core.Models; 6 | 7 | namespace TeamCityTheatre.Core.Client.Mappers { 8 | public interface IBuildStepMapper { 9 | BuildStep Map(BuildStepResponse buildStep); 10 | IReadOnlyCollection Map(BuildStepsResponse buildStep); 11 | } 12 | 13 | public class BuildStepMapper : IBuildStepMapper { 14 | readonly IPropertyMapper _propertyMapper; 15 | 16 | public BuildStepMapper(IPropertyMapper propertyMapper) { 17 | _propertyMapper = propertyMapper ?? throw new ArgumentNullException(nameof(propertyMapper)); 18 | } 19 | 20 | public BuildStep Map(BuildStepResponse buildStep) { 21 | if (buildStep == null) return null; 22 | return new BuildStep { 23 | Id = buildStep.Id, 24 | Name = buildStep.Name, 25 | Type = buildStep.Type, 26 | Properties = _propertyMapper.Map(buildStep.Properties) 27 | }; 28 | } 29 | 30 | public IReadOnlyCollection Map(BuildStepsResponse buildStep) { 31 | if (buildStep == null || buildStep.BuildStep == null) 32 | return new List(); 33 | return buildStep.BuildStep.Select(Map).ToList(); 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Mappers/VcsRootEntryMapper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using TeamCityTheatre.Core.Client.Responses; 5 | using TeamCityTheatre.Core.Models; 6 | 7 | namespace TeamCityTheatre.Core.Client.Mappers { 8 | public interface IVcsRootEntryMapper { 9 | VcsRootEntry Map(VcsRootEntryResponse vcsRootEntry); 10 | IReadOnlyCollection Map(VcsRootEntriesResponse vcsRootEntries); 11 | } 12 | 13 | public class VcsRootEntryMapper : IVcsRootEntryMapper { 14 | readonly IVcsRootMapper _vcsRootMapper; 15 | 16 | public VcsRootEntryMapper(IVcsRootMapper vcsRootMapper) { 17 | _vcsRootMapper = vcsRootMapper ?? throw new ArgumentNullException(nameof(vcsRootMapper)); 18 | } 19 | 20 | public VcsRootEntry Map(VcsRootEntryResponse vcsRootEntry) { 21 | if (vcsRootEntry == null) return null; 22 | return new VcsRootEntry { 23 | CheckoutRules = vcsRootEntry.CheckoutRules, 24 | VcsRoot = _vcsRootMapper.Map(vcsRootEntry.VcsRoot) 25 | }; 26 | } 27 | 28 | public IReadOnlyCollection Map(VcsRootEntriesResponse vcsRootEntries) { 29 | if (vcsRootEntries == null || vcsRootEntries.VcsRootEntry == null) 30 | return new List(); 31 | return vcsRootEntries.VcsRootEntry.Select(Map).ToList(); 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Controllers/ViewsController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Microsoft.AspNetCore.Mvc; 4 | using TeamCityTheatre.Core.ApplicationModels; 5 | using TeamCityTheatre.Core.DataServices; 6 | 7 | namespace TeamCityTheatre.Web.Controllers { 8 | [Route("api/views")] 9 | public class ViewsController : Controller { 10 | readonly IViewDataService _viewDataService; 11 | 12 | public ViewsController(IViewDataService viewDataService) { 13 | _viewDataService = viewDataService ?? throw new ArgumentNullException(nameof(viewDataService)); 14 | } 15 | 16 | [HttpGet("")] 17 | public IEnumerable Get() { 18 | return _viewDataService.GetAllViews(); 19 | } 20 | 21 | [HttpGet("{id:guid}")] 22 | public View Get(Guid id) { 23 | return _viewDataService.GetViewById(id); 24 | } 25 | 26 | [HttpGet("{name}")] 27 | public View Get(string name) { 28 | return _viewDataService.GetViewByName(name); 29 | } 30 | 31 | [HttpPost("")] 32 | public View Post([FromBody] View view) { 33 | return _viewDataService.SaveView(view); 34 | } 35 | 36 | [HttpDelete("{id:guid}")] 37 | public void Delete(Guid id) { 38 | var view = _viewDataService.GetViewById(id); 39 | if (view != null) { 40 | _viewDataService.DeleteView(view); 41 | } 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | # ASP.NET Core (.NET Framework) 2 | # Build and test ASP.NET Core projects targeting the full .NET Framework. 3 | # Add steps that publish symbols, save build artifacts, and more: 4 | # https://docs.microsoft.com/azure/devops/pipelines/languages/dotnet-core 5 | 6 | trigger: 7 | - master 8 | 9 | pool: 10 | vmImage: 'windows-latest' 11 | 12 | variables: 13 | solution: '**/*.sln' 14 | buildPlatform: 'Any CPU' 15 | buildConfiguration: 'Release' 16 | 17 | steps: 18 | - task: Npm@1 19 | inputs: 20 | command: 'install' 21 | workingDir: 'src/TeamCityTheatre.Web' 22 | 23 | - task: Npm@1 24 | inputs: 25 | command: 'custom' 26 | workingDir: 'src/TeamCityTheatre.Web' 27 | customCommand: 'run build:release' 28 | 29 | - task: NuGetToolInstaller@0 30 | 31 | - task: NuGetCommand@2 32 | inputs: 33 | restoreSolution: '$(solution)' 34 | 35 | - task: VSBuild@1 36 | inputs: 37 | solution: '$(solution)' 38 | msbuildArgs: '/p:DeployOnBuild=true /p:WebPublishMethod=Package /p:PackageAsSingleFile=true /p:SkipInvalidConfigurations=true /p:DesktopBuildPackageLocation="$(build.artifactStagingDirectory)\WebApp.zip" /p:DeployIisAppPath="Default Web Site"' 39 | platform: '$(buildPlatform)' 40 | configuration: '$(buildConfiguration)' 41 | 42 | - task: VSTest@2 43 | inputs: 44 | platform: '$(buildPlatform)' 45 | configuration: '$(buildConfiguration)' -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Mappers/BuildTriggerMapper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using TeamCityTheatre.Core.Client.Responses; 5 | using TeamCityTheatre.Core.Models; 6 | 7 | namespace TeamCityTheatre.Core.Client.Mappers { 8 | public interface IBuildTriggerMapper { 9 | BuildTrigger Map(BuildTriggerResponse buildTrigger); 10 | IReadOnlyCollection Map(BuildTriggersResponse buildTrigger); 11 | } 12 | 13 | public class BuildTriggerMapper : IBuildTriggerMapper { 14 | readonly IPropertyMapper _propertyMapper; 15 | 16 | public BuildTriggerMapper(IPropertyMapper propertyMapper) { 17 | _propertyMapper = propertyMapper ?? throw new ArgumentNullException(nameof(propertyMapper)); 18 | } 19 | 20 | public BuildTrigger Map(BuildTriggerResponse buildTrigger) { 21 | if (buildTrigger == null) 22 | return null; 23 | 24 | return new BuildTrigger { 25 | Id = buildTrigger.Id, 26 | Type = buildTrigger.Type, 27 | Properties = _propertyMapper.Map(buildTrigger.Properties) 28 | }; 29 | } 30 | 31 | public IReadOnlyCollection Map(BuildTriggersResponse buildTrigger) { 32 | if (buildTrigger == null || buildTrigger.BuildTrigger == null) 33 | return new List(); 34 | return buildTrigger.BuildTrigger.Select(Map).ToList(); 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Startup.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Builder; 3 | using Microsoft.AspNetCore.Hosting; 4 | using Microsoft.Extensions.Configuration; 5 | using Microsoft.Extensions.DependencyInjection; 6 | 7 | namespace TeamCityTheatre.Web { 8 | public class Startup { 9 | 10 | public static readonly string Version = typeof(Startup).Assembly.GetName().Version.ToString(); 11 | 12 | public Startup(IConfiguration configuration) { 13 | Configuration = configuration; 14 | } 15 | 16 | public IConfiguration Configuration { get; } 17 | 18 | // This method gets called by the runtime. Use this method to add services to the container. 19 | public IServiceProvider ConfigureServices(IServiceCollection services) { 20 | return services 21 | .AddRouting(options => options.LowercaseUrls = true) 22 | .AddMvc().Services 23 | .AddViewManagement(Configuration) 24 | .AddTeamCityServices(Configuration) 25 | .BuildServiceProvider(); 26 | } 27 | 28 | // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. 29 | public void Configure(IApplicationBuilder app, IHostingEnvironment env) { 30 | if (env.IsDevelopment()) { 31 | app.UseDeveloperExceptionPage(); 32 | } else { 33 | app.UseExceptionHandler("/Home/Error"); 34 | } 35 | 36 | app.UseStaticFiles(); 37 | 38 | app.UseMvc(); 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Mappers/AgentRequirementMapper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using TeamCityTheatre.Core.Client.Responses; 5 | using TeamCityTheatre.Core.Models; 6 | 7 | namespace TeamCityTheatre.Core.Client.Mappers { 8 | public interface IAgentRequirementMapper { 9 | AgentRequirement Map(AgentRequirementResponse agentRequirement); 10 | IReadOnlyCollection Map(AgentRequirementsResponse agentRequirements); 11 | } 12 | 13 | public class AgentRequirementMapper : IAgentRequirementMapper { 14 | readonly IPropertyMapper _propertyMapper; 15 | 16 | public AgentRequirementMapper(IPropertyMapper propertyMapper) { 17 | _propertyMapper = propertyMapper ?? throw new ArgumentNullException(nameof(propertyMapper)); 18 | } 19 | 20 | public AgentRequirement Map(AgentRequirementResponse agentRequirement) { 21 | if (agentRequirement == null) 22 | return null; 23 | return new AgentRequirement { 24 | Id = agentRequirement.Id, 25 | Type = agentRequirement.Type, 26 | Properties = _propertyMapper.Map(agentRequirement.Properties) 27 | }; 28 | } 29 | 30 | public IReadOnlyCollection Map(AgentRequirementsResponse agentRequirements) { 31 | if (agentRequirements == null || agentRequirements.AgentRequirement == null) 32 | return new List(); 33 | return agentRequirements.AgentRequirement.Select(Map).ToList(); 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Home/settings.observables.views.ts: -------------------------------------------------------------------------------- 1 | import { ajax } from 'rxjs/ajax'; 2 | import { map, scan, startWith, switchMap } from 'rxjs/operators'; 3 | import { Observable, Subject, merge } from "rxjs"; 4 | 5 | import { debug } from "../Shared/operators/debug"; 6 | 7 | import { IView } from "../Shared/contracts"; 8 | import { View } from "../Shared/models"; 9 | import { savedViews } from "./settings.observables.save-view"; 10 | import { mergeById } from "../Shared/arrays/mergeById"; 11 | import { deletedViews } from "./settings.observables.delete-view"; 12 | 13 | const updatedViewsSubject = new Subject(); 14 | export const updateView = (view: View) => { 15 | updatedViewsSubject.next(view); 16 | return view 17 | }; 18 | 19 | export const updatedViews: Observable = merge(updatedViewsSubject, savedViews) 20 | .pipe(debug("Update view")) 21 | ; 22 | 23 | const initialViews: Observable = deletedViews 24 | .pipe(startWith(null)) 25 | .pipe(switchMap>(() => ajax.getJSON("api/views"))) 26 | .pipe(map((vs: IView[]) => vs.map(v => View.fromContract(v)))) 27 | .pipe(startWith([])) 28 | .pipe(debug("Initial views")) 29 | ; 30 | 31 | export const views: Observable = initialViews 32 | .pipe(switchMap(initialVs => updatedViews 33 | .pipe(scan((previousViews: View[], updatedView: View) => mergeById(updatedView, previousViews), initialVs)) 34 | .pipe(startWith(initialVs)))) 35 | .pipe(debug("Views")); -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Mappers/SnapshotDependencyMapper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using TeamCityTheatre.Core.Client.Responses; 5 | using TeamCityTheatre.Core.Models; 6 | 7 | namespace TeamCityTheatre.Core.Client.Mappers { 8 | public interface ISnapshotDependencyMapper { 9 | SnapshotDependency Map(SnapshotDependencyResponse snapshotDependency); 10 | IReadOnlyCollection Map(SnapshotDependenciesResponse snapshotDependencies); 11 | } 12 | 13 | public class SnapshotDependencyMapper : ISnapshotDependencyMapper { 14 | readonly IPropertyMapper _propertyMapper; 15 | 16 | public SnapshotDependencyMapper(IPropertyMapper propertyMapper) { 17 | _propertyMapper = propertyMapper ?? throw new ArgumentNullException(nameof(propertyMapper)); 18 | } 19 | 20 | public SnapshotDependency Map(SnapshotDependencyResponse snapshotDependency) { 21 | if (snapshotDependency == null) 22 | return null; 23 | return new SnapshotDependency { 24 | Id = snapshotDependency.Id, 25 | Properties = _propertyMapper.Map(snapshotDependency.Properties) 26 | }; 27 | } 28 | 29 | public IReadOnlyCollection Map(SnapshotDependenciesResponse snapshotDependencies) { 30 | if (snapshotDependencies == null || snapshotDependencies.SnapshotDependency == null) 31 | return new List(); 32 | return snapshotDependencies.SnapshotDependency.Select(Map).ToList(); 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Mappers/ProjectMapper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using TeamCityTheatre.Core.Client.Responses; 5 | using TeamCityTheatre.Core.Models; 6 | 7 | namespace TeamCityTheatre.Core.Client.Mappers { 8 | public interface IProjectMapper { 9 | Project Map(ProjectResponse project); 10 | IReadOnlyCollection Map(ProjectsResponse projects); 11 | } 12 | 13 | public class ProjectMapper : IProjectMapper { 14 | readonly Lazy _buildConfigurationMapper; 15 | 16 | public ProjectMapper(Lazy buildConfigurationMapper) { 17 | _buildConfigurationMapper = buildConfigurationMapper; 18 | } 19 | 20 | public Project Map(ProjectResponse project) { 21 | if (project == null) return null; 22 | return new Project { 23 | BuildConfigurations = _buildConfigurationMapper.Value.Map(project.BuildTypes), 24 | Description = project.Description, 25 | Href = project.Href, 26 | Id = project.Id, 27 | IsArchived = project.IsArchived, 28 | Name = project.Name, 29 | ParentProject = Map(project.ParentProject), 30 | ParentProjectId = project.ParentProjectId, 31 | WebUrl = project.WebUrl 32 | }; 33 | } 34 | 35 | public IReadOnlyCollection Map(ProjectsResponse projects) { 36 | if (projects == null || projects.Project == null) 37 | return new List(); 38 | return projects.Project.Select(Map).ToList(); 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Mappers/ArtifactDependencyMapper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using TeamCityTheatre.Core.Client.Responses; 5 | using TeamCityTheatre.Core.Models; 6 | 7 | namespace TeamCityTheatre.Core.Client.Mappers { 8 | public interface IArtifactDependencyMapper { 9 | ArtifactDependency Map(ArtifactDependencyResponse artifactDependency); 10 | IReadOnlyCollection Map(ArtifactDependenciesResponse artifactDependencies); 11 | } 12 | 13 | public class ArtifactDependencyMapper : IArtifactDependencyMapper { 14 | readonly IPropertyMapper _propertyMapper; 15 | 16 | public ArtifactDependencyMapper(IPropertyMapper propertyMapper) { 17 | _propertyMapper = propertyMapper ?? throw new ArgumentNullException("propertyMapper"); 18 | } 19 | 20 | public ArtifactDependency Map(ArtifactDependencyResponse artifactDependency) { 21 | if (artifactDependency == null) 22 | return null; 23 | return new ArtifactDependency { 24 | Id = artifactDependency.Id, 25 | Type = artifactDependency.Type, 26 | Properties = _propertyMapper.Map(artifactDependency.Properties) 27 | }; 28 | } 29 | 30 | public IReadOnlyCollection Map(ArtifactDependenciesResponse artifactDependencies) { 31 | if (artifactDependencies == null || artifactDependencies.ArtifactDependency == null) 32 | return new List(); 33 | return artifactDependencies.ArtifactDependency.Select(Map).ToList(); 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Repositories/ConfigurationRepository.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using Microsoft.Extensions.Options; 3 | using Newtonsoft.Json; 4 | using TeamCityTheatre.Core.ApplicationModels; 5 | using TeamCityTheatre.Core.Options; 6 | 7 | namespace TeamCityTheatre.Core.Repositories { 8 | public interface IConfigurationRepository { 9 | Configuration GetConfiguration(); 10 | void SaveConfiguration(Configuration configuration); 11 | } 12 | 13 | public class ConfigurationRepository : IConfigurationRepository { 14 | readonly DirectoryInfo _workspace; 15 | readonly FileInfo _configurationFile; 16 | 17 | public ConfigurationRepository(IOptionsSnapshot storageOptionsSnapshot) { 18 | var storageOptions = storageOptionsSnapshot.Value; 19 | _configurationFile = new FileInfo(storageOptions.ConfigurationFile); 20 | _workspace = _configurationFile.Directory; 21 | } 22 | 23 | public Configuration GetConfiguration() { 24 | if(!File.Exists(_configurationFile.FullName)) 25 | return new Configuration(); 26 | var configurationFileContents = File.ReadAllText(_configurationFile.FullName); 27 | return JsonConvert.DeserializeObject(configurationFileContents); 28 | } 29 | 30 | public void SaveConfiguration(Configuration configuration) { 31 | if (!_workspace.Exists) _workspace.Create(); 32 | 33 | var configurationFileContents = JsonConvert.SerializeObject(configuration, Formatting.Indented); 34 | 35 | File.WriteAllText(_configurationFile.FullName, configurationFileContents); 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26430.15 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TeamCityTheatre.Web", "TeamCityTheatre.Web\TeamCityTheatre.Web.csproj", "{5238600C-2527-490A-93CF-E4EEFFB32132}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TeamCityTheatre.Core", "TeamCityTheatre.Core\TeamCityTheatre.Core.csproj", "{F81CDFDC-82C1-43A2-BE68-ACA1283D680B}" 9 | EndProject 10 | Global 11 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 12 | Debug|Any CPU = Debug|Any CPU 13 | Release|Any CPU = Release|Any CPU 14 | EndGlobalSection 15 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 16 | {5238600C-2527-490A-93CF-E4EEFFB32132}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 17 | {5238600C-2527-490A-93CF-E4EEFFB32132}.Debug|Any CPU.Build.0 = Debug|Any CPU 18 | {5238600C-2527-490A-93CF-E4EEFFB32132}.Release|Any CPU.ActiveCfg = Release|Any CPU 19 | {5238600C-2527-490A-93CF-E4EEFFB32132}.Release|Any CPU.Build.0 = Release|Any CPU 20 | {F81CDFDC-82C1-43A2-BE68-ACA1283D680B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 21 | {F81CDFDC-82C1-43A2-BE68-ACA1283D680B}.Debug|Any CPU.Build.0 = Debug|Any CPU 22 | {F81CDFDC-82C1-43A2-BE68-ACA1283D680B}.Release|Any CPU.ActiveCfg = Release|Any CPU 23 | {F81CDFDC-82C1-43A2-BE68-ACA1283D680B}.Release|Any CPU.Build.0 = Release|Any CPU 24 | EndGlobalSection 25 | GlobalSection(SolutionProperties) = preSolution 26 | HideSolutionNode = FALSE 27 | EndGlobalSection 28 | EndGlobal 29 | -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/IDetailedVcsRoot.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace TeamCityTheatre.Core.Models { 5 | /* 6 | 7 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | */ 23 | 24 | public interface IDetailedVcsRoot : IBasicVcsRoot { 25 | string VcsName { get; } 26 | DateTime LastChecked { get; } 27 | IBasicProject Project { get; } 28 | IReadOnlyCollection Properties { get; } 29 | } 30 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/Build.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace TeamCityTheatre.Core.Models { 5 | public class Build : IDetailedBuild { 6 | public string Id { get; set; } 7 | public string BuildConfigurationId { get; set; } 8 | public double? PercentageComplete { get; set; } 9 | public double? ElapsedSeconds { get; set; } 10 | public double? EstimatedTotalSeconds { get; set; } 11 | public string CurrentStageText { get; set; } 12 | public string Number { get; set; } 13 | public BuildStatus Status { get; set; } 14 | public string State { get; set; } 15 | public string BranchName { get; set; } 16 | public bool IsDefaultBranch { get; set; } 17 | public string Href { get; set; } 18 | public string WebUrl { get; set; } 19 | public string StatusText { get; set; } 20 | public IBasicBuildConfiguration BuildConfiguration { get; set; } 21 | public DateTime QueuedDate { get; set; } 22 | public DateTime StartDate { get; set; } 23 | public DateTime FinishDate { get; set; } 24 | public IReadOnlyCollection LastChanges { get; set; } 25 | public IBasicAgent Agent { get; set; } 26 | public IReadOnlyCollection Properties { get; set; } 27 | public IReadOnlyCollection SnapshotDependencies { get; set; } 28 | public IReadOnlyCollection ArtifactDependencies { get; set; } 29 | 30 | public override string ToString() { 31 | return string.Format("Id: {0}, BuildConfigurationId: {1}, Number: {2}, Status: {3}, State: {4}", Id, 32 | BuildConfigurationId, Number, Status, State); 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "teamcitytheatre", 3 | "version": "1.0.0", 4 | "description": "Monitor those builds!", 5 | "main": "index.js", 6 | "repository": "https://github.com/amoerie/teamcity-theatre", 7 | "author": "Alexander Moerman", 8 | "license": "MIT", 9 | "dependencies": { 10 | "date-fns": "^2.10.0", 11 | "react": "^16.12.0", 12 | "react-dom": "^16.12.0", 13 | "react-sortable-hoc": "^1.10.1", 14 | "rxjs": "^6.5.4", 15 | "tslib": "^1.10.0", 16 | "uuid": "^3.3.3" 17 | }, 18 | "devDependencies": { 19 | "@types/node": "^12.12.14", 20 | "@types/react": "^16.9.23", 21 | "@types/react-dom": "^16.9.5", 22 | "@types/uuid": "^3.4.6", 23 | "postcss-cli": "^6.1.3", 24 | "postcss-nested": "^4.2.1", 25 | "rimraf": "^3.0.2", 26 | "typescript": "^3.8.3", 27 | "webpack": "^4.41.6", 28 | "webpack-cli": "^3.3.10" 29 | }, 30 | "scripts": { 31 | "clean": "rimraf ./Views/**/*.js ./Views/**/*.js.map", 32 | "ts": "tsc --pretty", 33 | "js": "webpack --config=webpack.config.js --hide-modules --colors --mode=production", 34 | "js:debug": "webpack --config=webpack.config.js --colors --hide-modules --mode=development", 35 | "pcss": "postcss ./Views/**/*.pcss --use postcss-nested --dir wwwroot/css --ext=.css --no-map true", 36 | "watch:ts": "npm run ts -- --watch", 37 | "watch:js": "npm run js:debug -- --watch=true", 38 | "watch:pcss": "npm run pcss -- --watch=true", 39 | "build:debug": "npm run clean && npm run ts && npm run pcss && npm run js -- --mode=development", 40 | "build:release": "npm run clean && npm run ts && npm run pcss && npm run js" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Storage": { 3 | "ConfigurationFile": "./Data/configuration.json" 4 | }, 5 | "Api": { 6 | "GetBuildsOfBuildConfiguration": "builds/?locator=branch:(default:any,policy:active_history_and_active_vcs_branches),running:any,count:{count},buildType:(id:{buildConfigurationId})&fields=count,build(id,buildTypeId,number,status,state,percentageComplete,branchName,defaultBranch,href,webUrl,running-info(percentageComplete,elapsedSeconds,estimatedTotalSeconds,currentStageText),queuedDate,startDate,finishDate)" 7 | }, 8 | "Serilog": { 9 | "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ], 10 | "WriteTo": { 11 | "0": { 12 | "Name": "Console", 13 | "Args": { 14 | "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u4}] {SourceContext} {Message}{NewLine}{Exception}" 15 | } 16 | }, 17 | "1": { 18 | "Name": "File", 19 | "Args": { 20 | "path": "./Logs/TeamCityTheatre.Web-.log", 21 | "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u4}] ({RequestId}) {SourceContext} {Message}{NewLine}{Exception}", 22 | "fileSizeLimitBytes": 26214400, 23 | "retainedFileCountLimit": 3, 24 | "restrictedToMinimumLevel": "Verbose", 25 | "rollingInterval": "Day", 26 | "rollOnFileSizeLimit": true 27 | } 28 | } 29 | }, 30 | "MinimumLevel": { 31 | "Default": "Information", 32 | "Override": { 33 | "Microsoft": "Warning", 34 | "System": "Warning" 35 | } 36 | }, 37 | "Enrich": [ "FromLogContext" ], 38 | "Properties": { 39 | "Application": "TeamCityTheatre" 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/QueryServices/TileService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using TeamCityTheatre.Core.ApplicationModels; 5 | using TeamCityTheatre.Core.DataServices; 6 | using TeamCityTheatre.Core.QueryServices.Models; 7 | 8 | namespace TeamCityTheatre.Core.QueryServices { 9 | public interface ITileService { 10 | Task GetLatestTileDataAsync(View view, Tile tile); 11 | } 12 | 13 | public class TileService : ITileService { 14 | readonly IBuildDataService _buildDataService; 15 | 16 | public TileService(IBuildDataService buildDataService) { 17 | _buildDataService = buildDataService ?? throw new ArgumentNullException(nameof(buildDataService)); 18 | } 19 | 20 | public async Task GetLatestTileDataAsync(View view, Tile tile) { 21 | var rawBuilds = await _buildDataService.GetBuildsOfBuildConfigurationAsync(tile.BuildConfigurationId, 20); 22 | var buildsOrderByStartDate = rawBuilds.OrderByDescending(b => b.StartDate).ToList(); 23 | var defaultBranchBuild = buildsOrderByStartDate.FirstOrDefault(b => b.IsDefaultBranch); 24 | var nonDefaultBranchBuilds = buildsOrderByStartDate.Where(b => !b.IsDefaultBranch) 25 | .GroupBy(b => b.BranchName) 26 | .Select(buildsPerBranch => buildsPerBranch.OrderByDescending(b => b.StartDate).First()) 27 | .Take(defaultBranchBuild != null 28 | ? view.DefaultNumberOfBranchesPerTile - 1 29 | : view.DefaultNumberOfBranchesPerTile); 30 | var builds = defaultBranchBuild != null 31 | ? new[] {defaultBranchBuild}.Concat(nonDefaultBranchBuilds) 32 | : nonDefaultBranchBuilds; 33 | return new TileData { 34 | Id = tile.Id, 35 | Label = tile.Label, 36 | Builds = builds.ToList() 37 | }; 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/DataServices/ProjectDataService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using RestSharp; 5 | using TeamCityTheatre.Core.Client; 6 | using TeamCityTheatre.Core.Client.Mappers; 7 | using TeamCityTheatre.Core.Client.Responses; 8 | using TeamCityTheatre.Core.DataServices.Locators; 9 | using TeamCityTheatre.Core.Models; 10 | 11 | namespace TeamCityTheatre.Core.DataServices { 12 | public interface IProjectDataService { 13 | Task> GetAllProjectsAsync(); 14 | Task GetProjectDetailsAsync(IProjectLocator projectLocator); 15 | } 16 | 17 | public class ProjectDataService : IProjectDataService { 18 | readonly IProjectMapper _projectMapper; 19 | readonly ITeamCityClient _teamCityClient; 20 | 21 | public ProjectDataService(ITeamCityClient teamCityClient, IProjectMapper projectMapper) { 22 | _teamCityClient = teamCityClient ?? throw new ArgumentNullException(nameof(teamCityClient)); 23 | _projectMapper = projectMapper ?? throw new ArgumentNullException(nameof(projectMapper)); 24 | } 25 | 26 | public async Task> GetAllProjectsAsync() { 27 | var request = new RestRequest("projects", Method.GET); 28 | var response = await _teamCityClient.ExecuteRequestAsync(request); 29 | return _projectMapper.Map(response); 30 | } 31 | 32 | public async Task GetProjectDetailsAsync(IProjectLocator projectLocator) { 33 | var request = new RestRequest("projects/{locator}", Method.GET); 34 | request.AddUrlSegment("locator", projectLocator.Serialize()); 35 | var response = await _teamCityClient.ExecuteRequestAsync(request); 36 | return _projectMapper.Map(response); 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Home/settings.observables.projects.ts: -------------------------------------------------------------------------------- 1 | import { defer as observableDefer, Observable, Subject, merge } from 'rxjs'; 2 | import { map, scan, startWith, switchMap } from 'rxjs/operators'; 3 | import { debug } from "../Shared/operators/debug"; 4 | 5 | import { IBasicProject } from "../Shared/contracts"; 6 | import { Project } from "../Shared/models"; 7 | import { selectedProjects } from "./settings.observables.selected-project"; 8 | import { ajax } from 'rxjs/ajax'; 9 | 10 | const toProjects = (basicProjects: IBasicProject[]) => { 11 | const projects = basicProjects.map(p => new Project(p)); 12 | 13 | const findChildren = (id: string) => projects.filter(p => p.parentProjectId === id); 14 | 15 | for (let project of projects) { 16 | project.setChildren(findChildren(project.id)); 17 | } 18 | 19 | return projects; 20 | }; 21 | 22 | const initialRootProjects: Observable = observableDefer(() => ajax.getJSON("api/projects")).pipe( 23 | map(toProjects), 24 | map(projects => projects.filter(p => p.parentProjectId === null)[0]), 25 | map(rootProject => rootProject.expand()),) 26 | .pipe(debug("Initial root project")); 27 | 28 | const manualProjectUpdates = new Subject(); 29 | export const updateProject = (project: Project) => manualProjectUpdates.next(project); 30 | 31 | const projectUpdates: Observable = merge(manualProjectUpdates, selectedProjects) 32 | .pipe(debug("Project update")); 33 | 34 | export const rootProjects: Observable = initialRootProjects.pipe(switchMap((initialRootProject: Project) => 35 | projectUpdates.pipe( 36 | scan((previousRootProject: Project, projectUpdate: Project | null) => previousRootProject.update(projectUpdate), initialRootProject), 37 | startWith(initialRootProject),))) 38 | .pipe(debug("Projects")); 39 | -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Responses/BuildStepResponse.cs: -------------------------------------------------------------------------------- 1 | namespace TeamCityTheatre.Core.Client.Responses { 2 | /* 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | */ 29 | 30 | public class BuildStepResponse { 31 | public string Id { get; set; } 32 | public string Name { get; set; } 33 | public string Type { get; set; } 34 | public PropertiesResponse Properties { get; set; } 35 | } 36 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/BuildStep.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TeamCityTheatre.Core.Models { 4 | /* 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | */ 31 | 32 | public class BuildStep { 33 | public string Id { get; set; } 34 | public string Name { get; set; } 35 | public string Type { get; set; } 36 | public IReadOnlyCollection Properties { get; set; } 37 | } 38 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/DataServices/ViewDataService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using TeamCityTheatre.Core.ApplicationModels; 5 | using TeamCityTheatre.Core.Repositories; 6 | 7 | namespace TeamCityTheatre.Core.DataServices { 8 | public interface IViewDataService { 9 | IEnumerable GetAllViews(); 10 | View GetViewById(Guid id); 11 | View GetViewByName(string name); 12 | View SaveView(View view); 13 | void DeleteView(View view); 14 | } 15 | 16 | public class ViewDataService : IViewDataService { 17 | readonly IConfigurationRepository _configurationRepository; 18 | 19 | public ViewDataService(IConfigurationRepository configurationRepository) { 20 | _configurationRepository = configurationRepository ?? throw new ArgumentNullException(nameof(configurationRepository)); 21 | } 22 | 23 | public IEnumerable GetAllViews() { 24 | return _configurationRepository.GetConfiguration().Views; 25 | } 26 | 27 | public View GetViewById(Guid id) { 28 | return GetAllViews().SingleOrDefault(v => v.Id == id); 29 | } 30 | 31 | public View GetViewByName(string name) { 32 | return GetAllViews().SingleOrDefault(v => string.Equals(v.Name, name, StringComparison.OrdinalIgnoreCase)); 33 | } 34 | 35 | public View SaveView(View view) { 36 | var configuration = _configurationRepository.GetConfiguration(); 37 | var views = configuration.Views.ToList(); 38 | var index = views.FindIndex(v => v.Id == view.Id); 39 | if (index > -1) { 40 | views[index] = view; 41 | } else { 42 | views.Add(view); 43 | } 44 | configuration.Views = views; 45 | _configurationRepository.SaveConfiguration(configuration); 46 | return view; 47 | } 48 | 49 | public void DeleteView(View view) { 50 | var configuration = _configurationRepository.GetConfiguration(); 51 | configuration.Views = configuration.Views.Where(v => v.Id != view.Id); 52 | _configurationRepository.SaveConfiguration(configuration); 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Home/settings.pcss: -------------------------------------------------------------------------------- 1 | #config { 2 | display: initial; 3 | 4 | #projects-section { 5 | 6 | } 7 | } 8 | 9 | #views-wrapper { 10 | #views-table { 11 | .view { 12 | .view-name, 13 | .view-branches-per-tile, 14 | .view-number-of-columns { 15 | vertical-align: middle; 16 | } 17 | 18 | &:hover { 19 | background: #d1e4f5; 20 | cursor: pointer; 21 | color: #444; 22 | } 23 | 24 | &.selected { 25 | background-color: #337ab7; 26 | color: #eee; 27 | 28 | &:hover { 29 | color: #eee; 30 | } 31 | } 32 | } 33 | } 34 | } 35 | 36 | #selected-view-wrapper .tiles-list .tile, 37 | .tile-drag-helper { 38 | display: flex; 39 | flex-direction: row; 40 | align-items: center; 41 | padding: 0; 42 | 43 | .tile-drag-handle { 44 | flex: 0 0 50px; 45 | padding: 10px 0; 46 | text-align: center; 47 | &:hover { 48 | cursor: move; 49 | } 50 | } 51 | 52 | .tile-label { 53 | flex: 1 0 100px; 54 | padding-right: 8px; 55 | } 56 | 57 | .tile-build-configuration { 58 | flex: 1 0 200px; 59 | } 60 | 61 | .tile-actions { 62 | flex: 0 0 100px; 63 | } 64 | 65 | .tile-drag-handle, 66 | .tile-label, 67 | .tile-build-configuration, 68 | .tile-actions { 69 | vertical-align: middle; 70 | } 71 | 72 | &:hover { 73 | background: #d1e4f5; 74 | cursor: pointer; 75 | color: #444; 76 | } 77 | 78 | &.selected { 79 | background-color: #337ab7; 80 | color: #eee; 81 | 82 | &:hover { 83 | color: #eee; 84 | } 85 | } 86 | } 87 | 88 | #projects-wrapper { 89 | .project { 90 | margin: 5px; 91 | 92 | > .toggle-children-button { 93 | visibility: hidden; 94 | } 95 | 96 | > .project-name { 97 | margin-left: 5px; 98 | } 99 | 100 | > .project-children { 101 | list-style: none; 102 | } 103 | 104 | &.has-children > .toggle-children-button { 105 | visibility: visible; 106 | } 107 | } 108 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/DataServices/BuildDataService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Microsoft.Extensions.Options; 5 | using RestSharp; 6 | using TeamCityTheatre.Core.Client; 7 | using TeamCityTheatre.Core.Client.Mappers; 8 | using TeamCityTheatre.Core.Client.Responses; 9 | using TeamCityTheatre.Core.Models; 10 | using TeamCityTheatre.Core.Options; 11 | 12 | namespace TeamCityTheatre.Core.DataServices { 13 | public interface IBuildDataService { 14 | Task> GetBuildsOfBuildConfigurationAsync(string buildConfigurationId, int count = 100); 15 | Task GetBuildDetailsAsync(int buildId); 16 | } 17 | 18 | public class BuildDataService : IBuildDataService { 19 | readonly ITeamCityClient _teamCityClient; 20 | readonly IBuildMapper _buildMapper; 21 | readonly ApiOptions _apiOptions; 22 | 23 | public BuildDataService(ITeamCityClient teamCityClient, IBuildMapper buildMapper, IOptions apiOptions) { 24 | _teamCityClient = teamCityClient ?? throw new ArgumentNullException(nameof(teamCityClient)); 25 | _buildMapper = buildMapper ?? throw new ArgumentNullException(nameof(buildMapper)); 26 | _apiOptions = apiOptions?.Value ?? throw new ArgumentNullException(nameof(apiOptions)); 27 | } 28 | 29 | public async Task> GetBuildsOfBuildConfigurationAsync(string buildConfigurationId, int count = 100) { 30 | var requestUrl = _apiOptions.GetBuildsOfBuildConfiguration; 31 | requestUrl = requestUrl.Replace("{count}", count.ToString()); 32 | requestUrl = requestUrl.Replace("{buildConfigurationId}", buildConfigurationId); 33 | var request = new RestRequest(requestUrl); 34 | var response = await _teamCityClient.ExecuteRequestAsync(request); 35 | return _buildMapper.Map(response); 36 | } 37 | 38 | public async Task GetBuildDetailsAsync(int buildId) { 39 | var request = new RestRequest("builds/id:{buildId}"); 40 | request.AddUrlSegment("buildId", Convert.ToString(buildId)); 41 | var response = await _teamCityClient.ExecuteRequestAsync(request); 42 | return _buildMapper.Map(response); 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/TeamCityTheatre.Web.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp2.2 5 | True 6 | False 7 | 3.3.0 8 | $(AssemblyVersion) 9 | $(AssemblyVersion) 10 | Alexander Moerman 11 | 12 | 13 | 14 | true 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | Always 26 | 27 | 28 | Always 29 | 30 | 31 | Always 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Shared/contracts.ts: -------------------------------------------------------------------------------- 1 | export type Guid = string; 2 | 3 | export interface IView { 4 | id: Guid; 5 | name: string; 6 | defaultNumberOfBranchesPerTile: number; 7 | numberOfColumns: number; 8 | tiles: ITile[]; 9 | } 10 | 11 | export interface ITile { 12 | id: Guid; 13 | label: string; 14 | buildConfigurationId: string; 15 | buildConfigurationDisplayName: string; 16 | } 17 | 18 | export interface IViewData { 19 | id: Guid; 20 | tiles: ITileData[]; 21 | } 22 | 23 | export interface ITileData { 24 | id: Guid; 25 | label: string; 26 | builds: IDetailedBuild[]; 27 | combinedBuildStatus: BuildStatus; 28 | } 29 | 30 | export interface IBasicBuild { 31 | id: string; 32 | buildConfigurationId: string; 33 | percentageComplete: number; 34 | elapsedSeconds: number; 35 | estimatedTotalSeconds: number; 36 | currentStageText: string; 37 | number: string; 38 | status: BuildStatus; 39 | state: string; 40 | branchName: string; 41 | isDefaultBranch: boolean; 42 | href: string; 43 | webUrl: string; 44 | } 45 | 46 | export interface IDetailedBuild extends IBasicBuild { 47 | statusText: string; 48 | buildConfiguration: IBasicBuildConfiguration; 49 | queuedDate: string; 50 | startDate: string; 51 | finishDate: string; 52 | lastChanges: IDetailedBuildChange[]; 53 | agent: IBasicAgent; 54 | properties: IPropery[]; 55 | snapshotDependencies: IBasicBuild[]; 56 | artifactDependencies: IBasicBuild[]; 57 | } 58 | 59 | export enum BuildStatus { 60 | Unknown, 61 | Success, 62 | Failure, 63 | Error 64 | } 65 | 66 | export interface IBasicBuildConfiguration { 67 | id: string; 68 | name: string; 69 | projectId: string; 70 | href: string; 71 | webUrl: string; 72 | } 73 | 74 | export interface IDetailedBuildChange { 75 | id: string; 76 | version: string; 77 | username: string; 78 | date: string; 79 | href: string; 80 | webLink: string; 81 | } 82 | 83 | export interface IBasicAgent { 84 | id: string; 85 | name: string; 86 | typeId: string; 87 | href: string; 88 | } 89 | 90 | export interface IPropery { 91 | name: string; 92 | value: string; 93 | } 94 | 95 | export interface IBasicProject { 96 | isArchived: boolean; 97 | href: string; 98 | id: string; 99 | name: string; 100 | description: string | null; 101 | webUrl: string; 102 | parentProjectId: string | null; 103 | } 104 | 105 | export interface IDetailedProject extends IBasicProject { 106 | parentProject: IBasicProject 107 | buildConfigurations: IBasicBuildConfiguration[]; 108 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/DataServices/BuildConfigurationDataService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using RestSharp; 5 | using TeamCityTheatre.Core.Client; 6 | using TeamCityTheatre.Core.Client.Mappers; 7 | using TeamCityTheatre.Core.Client.Responses; 8 | using TeamCityTheatre.Core.DataServices.Locators; 9 | using TeamCityTheatre.Core.Models; 10 | 11 | namespace TeamCityTheatre.Core.DataServices { 12 | public interface IBuildConfigurationDataService { 13 | Task> GetAllBuildConfigurationsAsync(); 14 | Task> GetBuildConfigurationsAsync(IBuildConfigurationLocator buildConfigurationLocator); 15 | Task GetBuildConfigurationDetailsAsync(IBuildConfigurationLocator buildConfigurationLocator); 16 | } 17 | 18 | public class BuildConfigurationDataService : IBuildConfigurationDataService { 19 | readonly ITeamCityClient _teamCityClient; 20 | readonly IBuildConfigurationMapper _buildConfigurationMapper; 21 | 22 | public BuildConfigurationDataService(ITeamCityClient teamCityClient, IBuildConfigurationMapper buildConfigurationMapper) { 23 | _teamCityClient = teamCityClient ?? throw new ArgumentNullException(nameof(teamCityClient)); 24 | _buildConfigurationMapper = buildConfigurationMapper ?? throw new ArgumentNullException(nameof(buildConfigurationMapper)); 25 | } 26 | 27 | public async Task> GetAllBuildConfigurationsAsync() { 28 | var request = new RestRequest("buildTypes"); 29 | var response = await _teamCityClient.ExecuteRequestAsync(request); 30 | return _buildConfigurationMapper.Map(response); 31 | } 32 | 33 | public async Task> GetBuildConfigurationsAsync(IBuildConfigurationLocator buildConfigurationLocator) { 34 | var request = new RestRequest("buildTypes/{locator}"); 35 | request.AddUrlSegment("locator", buildConfigurationLocator.Serialize()); 36 | var response = await _teamCityClient.ExecuteRequestAsync(request); 37 | return _buildConfigurationMapper.Map(response); 38 | } 39 | 40 | public async Task GetBuildConfigurationDetailsAsync(IBuildConfigurationLocator buildConfigurationLocator) { 41 | var request = new RestRequest("buildTypes/{locator}"); 42 | request.AddUrlSegment("locator", buildConfigurationLocator.Serialize()); 43 | var response = await _teamCityClient.ExecuteRequestAsync(request); 44 | return _buildConfigurationMapper.Map(response); 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ##################### 2 | # # 3 | # MULTI STAGE BUILD # 4 | # # 5 | ##################### 6 | 7 | 8 | ######################### 9 | 10 | ####################### 11 | 12 | # STAGE : NPM INSTALL # 13 | 14 | ####################### 15 | FROM node:10.14 AS npminstall 16 | WORKDIR /TeamCityTheatre 17 | 18 | # copy bare minimum files needed to restore NPM packages 19 | COPY ./src/TeamCityTheatre.Web/package.json ./TeamCityTheatre.Web/ 20 | COPY ./src/TeamCityTheatre.Web/package-lock.json ./TeamCityTheatre.Web/ 21 | 22 | WORKDIR /TeamCityTheatre/TeamCityTheatre.Web 23 | 24 | # install node packages 25 | RUN npm ci 26 | 27 | ####################### 28 | 29 | # STAGE : NPM BUILD # 30 | 31 | ####################### 32 | FROM npminstall as npmbuild 33 | 34 | WORKDIR /TeamCityTheatre/TeamCityTheatre.Web 35 | 36 | # and everything else needed to build the frontend 37 | COPY ./src/TeamCityTheatre.Web/tsconfig.json ./ 38 | COPY ./src/TeamCityTheatre.Web/webpack.config.js ./ 39 | COPY ./src/TeamCityTheatre.Web/Views/ ./Views/ 40 | 41 | # .. and build the frontend 42 | RUN npm run build:release 43 | 44 | ############################ 45 | 46 | # STAGE : DOT NET RESTORE # 47 | 48 | ############################ 49 | FROM microsoft/dotnet:2.2-sdk AS dotnetrestore 50 | WORKDIR /TeamCityTheatre 51 | 52 | # copy bare minimum files needed to restore dot net packages 53 | COPY src/TeamCityTheatre.sln ./ 54 | COPY src/TeamCityTheatre.Web/TeamCityTheatre.Web.csproj ./TeamCityTheatre.Web/ 55 | COPY src/TeamCityTheatre.Core/TeamCityTheatre.Core.csproj ./TeamCityTheatre.Core/ 56 | RUN dotnet restore 57 | 58 | ############################ 59 | 60 | # STAGE : DOT NET PUBLISH # 61 | 62 | ############################ 63 | FROM dotnetrestore AS dotnetpublish 64 | WORKDIR /TeamCityTheatre 65 | 66 | # copy prebuilt frontend files 67 | COPY --from=npmbuild ./TeamCityTheatre/TeamCityTheatre.Web/wwwroot/ ./TeamCityTheatre.Web/wwwroot/ 68 | 69 | # copy necessary project files to build .NET 70 | COPY src/TeamCityTheatre.Core/. ./TeamCityTheatre.Core/ 71 | COPY src/TeamCityTheatre.Web/. ./TeamCityTheatre.Web/ 72 | 73 | # publish 74 | RUN dotnet publish "./TeamCityTheatre.Web/TeamCityTheatre.Web.csproj" --verbosity normal --configuration Release --output "/Output" 75 | 76 | ############################ 77 | 78 | # STAGE : RUN APPLICATION # 79 | 80 | ############################ 81 | FROM microsoft/dotnet:2.2-aspnetcore-runtime AS runtime 82 | WORKDIR /TeamCityTheatre 83 | 84 | RUN mkdir Data 85 | RUN mkdir Logs 86 | 87 | COPY --from=dotnetpublish /Output ./ 88 | 89 | ENTRYPOINT ["dotnet", "TeamCityTheatre.Web.dll"] -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Home/settings.components.projects.tsx: -------------------------------------------------------------------------------- 1 | import {createElement, StatelessComponent} from "react"; 2 | import {Project as ProjectModel} from "../Shared/models"; 3 | import {selectProject} from "./settings.observables.selected-project"; 4 | import {updateProject} from "./settings.observables.projects"; 5 | 6 | export const Projects = (props: { rootProject: ProjectModel | null, selectedProject: ProjectModel | null }) => { 7 | if (props.rootProject === null) return ( 8 |
Loading projects
9 | ); 10 | return ( 11 |
12 |
13 |
14 |

15 |

Available projects

16 |
17 |
18 |
    19 | 20 |
21 |
22 |
23 |
24 | ); 25 | }; 26 | 27 | // recursive components require type annotations 28 | const Project: StatelessComponent<{ project: ProjectModel, selectedProject: ProjectModel | null }> 29 | = props => { 30 | const {project, selectedProject} = props; 31 | const hasChildren = project.hasChildren() ? "has-children" : ""; 32 | return ( 33 |
  • 34 | 35 | 36 | 37 |
  • 38 | ); 39 | }; 40 | 41 | const ToggleProjectChildrenButton = (props: { project: ProjectModel }) => { 42 | const iconClass = props.project.isExpanded ? "fa fa-minus-circle" : "fa fa-plus-circle"; 43 | return ( 44 | 48 | ); 49 | }; 50 | 51 | const ShowProjectDetailsButton = (props: { project: ProjectModel, selectedProject: ProjectModel | null }) => { 52 | const buttonClass = props.project === props.selectedProject ? "btn-primary" : "btn-default"; 53 | return ( 54 | 57 | ); 58 | }; 59 | 60 | const ProjectChildren = (props: { project: ProjectModel, selectedProject: ProjectModel | null }) => { 61 | if (!props.project.isExpanded) return null; 62 | if (!props.project.hasChildren()) return null; 63 | return ( 64 |
      65 | {props.project.children.map(c => )} 66 |
    67 | ); 68 | }; -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Home/dashboard.observables.ts: -------------------------------------------------------------------------------- 1 | import { 2 | combineLatest as observableCombineLatest, 3 | defer as observableDefer, 4 | EMPTY, 5 | merge, 6 | Observable, 7 | of as observableOf, 8 | } from "rxjs"; 9 | 10 | import { catchError, delay, filter, isEmpty, map, mergeMap, repeat, startWith, switchMap, take } from 'rxjs/operators'; 11 | import { ajax } from "rxjs/ajax"; 12 | import { IView, IViewData } from "../Shared/contracts"; 13 | import { routes } from "../Shared/observables/routes"; 14 | 15 | // fetching the initial set of views 16 | export const allViews: Observable = observableDefer(() => ajax.getJSON("api/views")); 17 | 18 | // selecting a view 19 | 20 | // if a view is specified in the URL, select it 21 | const selectedViewsFromUrl: Observable = observableCombineLatest(routes, allViews.pipe(take(1))) 22 | .pipe( 23 | map(([route, views]) => { 24 | if (!views || views.length == 0) return null; 25 | const requestedView = route.relativePathSegments[1] || ""; 26 | if (!requestedView) return null; 27 | const view = views.filter(v => v.name.toLowerCase() === requestedView.toLowerCase())[0]; 28 | return view ? view : null; 29 | }), 30 | filter(v => v != null) 31 | ); 32 | 33 | // on first load, if no view is defined in the URL, check if there is only view and if so, select it 34 | const automaticallySelectedSingleView: Observable = selectedViewsFromUrl 35 | .pipe( 36 | isEmpty(), 37 | filter(isEmpty => isEmpty === true), 38 | switchMap(() => allViews.pipe( 39 | take(1), 40 | filter(vs => vs != null && vs.length == 1), 41 | map(vs => vs == null ? null : vs[0]) 42 | )) 43 | ) 44 | ; 45 | 46 | export const selectedViews: Observable = merge( 47 | selectedViewsFromUrl, 48 | automaticallySelectedSingleView 49 | ); 50 | 51 | // fetching the data of a view 52 | export const selectedViewData: Observable = selectedViews.pipe( 53 | switchMap( 54 | (selectedView: IView | null) => selectedView == null 55 | ? EMPTY 56 | : merge( 57 | // initial fetch 58 | ajax.getJSON(`api/viewdata/${selectedView.id}`).pipe(catchError(() => EMPTY)), 59 | 60 | // keep polling 61 | observableOf(null) 62 | .pipe(delay(3000)) 63 | .pipe(mergeMap(() => ajax.getJSON(`api/viewdata/${selectedView.id}`).pipe(catchError(() => EMPTY)))) 64 | .pipe(repeat()) 65 | ) 66 | )); 67 | 68 | // combining all of the above in a single state 69 | 70 | export interface IDashboardState { 71 | views: IView[] | null; 72 | selectedView: IView | null; 73 | selectedViewData: IViewData | null; 74 | } 75 | 76 | 77 | export const state: Observable = observableCombineLatest( 78 | allViews.pipe(startWith(null)), 79 | selectedViews.pipe(startWith(null)), 80 | selectedViewData.pipe(startWith(null)) 81 | ) 82 | .pipe(map(([views, selectedView, viewData]) => { 83 | return { 84 | views: views, 85 | selectedView: selectedView, 86 | selectedViewData: viewData 87 | }; 88 | })); 89 | -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Mappers/BuildMapper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using TeamCityTheatre.Core.Client.Responses; 5 | using TeamCityTheatre.Core.Models; 6 | 7 | namespace TeamCityTheatre.Core.Client.Mappers { 8 | public interface IBuildMapper { 9 | Build Map(BuildResponse build); 10 | IReadOnlyCollection Map(BuildsResponse builds); 11 | } 12 | 13 | public class BuildMapper : IBuildMapper { 14 | readonly IAgentMapper _agentMapper; 15 | readonly IBuildStatusMapper _buildStatusMapper; 16 | readonly IBuildChangeMapper _buildChangeMapper; 17 | readonly IBuildConfigurationMapper _buildConfigurationMapper; 18 | readonly IPropertyMapper _propertyMapper; 19 | 20 | public BuildMapper( 21 | IBuildConfigurationMapper buildConfigurationMapper, IBuildChangeMapper buildChangeMapper, 22 | IPropertyMapper propertyMapper, 23 | IAgentMapper agentMapper, IBuildStatusMapper buildStatusMapper) { 24 | _buildConfigurationMapper = buildConfigurationMapper ?? throw new ArgumentNullException(nameof(buildConfigurationMapper)); 25 | _buildChangeMapper = buildChangeMapper ?? throw new ArgumentNullException(nameof(buildChangeMapper)); 26 | _propertyMapper = propertyMapper ?? throw new ArgumentNullException(nameof(propertyMapper)); 27 | _agentMapper = agentMapper ?? throw new ArgumentNullException(nameof(agentMapper)); 28 | _buildStatusMapper = buildStatusMapper ?? throw new ArgumentNullException(nameof(buildStatusMapper)); 29 | } 30 | 31 | public Build Map(BuildResponse build) { 32 | if (build == null) return null; 33 | return new Build { 34 | Id = build.Id, 35 | BuildConfigurationId = build.BuildTypeId, 36 | Agent = _agentMapper.Map(build.Agent), 37 | ArtifactDependencies = Map(build.ArtifactDependencies), 38 | BranchName = build.BranchName, 39 | BuildConfiguration = _buildConfigurationMapper.Map(build.BuildType), 40 | FinishDate = build.FinishDate, 41 | Href = build.Href, 42 | IsDefaultBranch = build.DefaultBranch || string.Equals(build.BranchName, "develop") || string.Equals(build.BranchName, "master"), 43 | LastChanges = _buildChangeMapper.Map(build.LastChanges), 44 | Number = build.Number, 45 | PercentageComplete = build.PercentageComplete ?? build.RunningInfo?.PercentageComplete, 46 | ElapsedSeconds = build.RunningInfo?.ElapsedSeconds, 47 | EstimatedTotalSeconds = build.RunningInfo?.EstimatedTotalSeconds, 48 | CurrentStageText = build.RunningInfo?.CurrentStageText, 49 | Properties = _propertyMapper.Map(build.Properties), 50 | QueuedDate = build.QueuedDate, 51 | SnapshotDependencies = Map(build.SnapshotDependencies), 52 | StartDate = build.StartDate, 53 | State = build.State, 54 | Status = _buildStatusMapper.Map(build.Status), 55 | StatusText = build.StatusText, 56 | WebUrl = build.WebUrl 57 | }; 58 | } 59 | 60 | public IReadOnlyCollection Map(BuildsResponse builds) { 61 | if (builds?.Build == null) 62 | return new List(); 63 | return builds.Build.Select(Map).ToList(); 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/TeamCityRestClientFactory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | using Newtonsoft.Json.Converters; 4 | using RestSharp; 5 | using RestSharp.Authenticators; 6 | using RestSharp.Serialization; 7 | using TeamCityTheatre.Core.Options; 8 | 9 | namespace TeamCityTheatre.Core.Client { 10 | public interface ITeamCityRestClientFactory { 11 | IRestClient Create(ConnectionOptions connectionOptions); 12 | } 13 | 14 | public class TeamCityRestClientFactory : ITeamCityRestClientFactory { 15 | public IRestClient Create(ConnectionOptions connectionOptions) { 16 | if (connectionOptions == null) throw new ArgumentNullException(nameof(connectionOptions)); 17 | 18 | RestClient client = null; 19 | 20 | switch (connectionOptions.AuthenticationMode) { 21 | case AuthenticationMode.BasicAuthentication: { 22 | client = new RestClient { 23 | BaseUrl = new Uri(new Uri(connectionOptions.Url), new Uri("httpAuth/app/rest", UriKind.Relative)), 24 | Authenticator = new HttpBasicAuthenticator(connectionOptions.Username, connectionOptions.Password), 25 | }; 26 | break; 27 | } 28 | 29 | case AuthenticationMode.Guest: { 30 | client = new RestClient { 31 | BaseUrl = new Uri(new Uri(connectionOptions.Url), new Uri("guestAuth/app/rest", UriKind.Relative)) 32 | }; 33 | break; 34 | } 35 | 36 | case AuthenticationMode.AccessToken: { 37 | client = new RestClient { 38 | BaseUrl = new Uri(new Uri(connectionOptions.Url), new Uri("app/rest", UriKind.Relative)), 39 | Authenticator = new JwtAuthenticator(connectionOptions.AccessToken) 40 | }; 41 | break; 42 | } 43 | 44 | default: 45 | throw new ArgumentOutOfRangeException("Invalid connectionOptions.AuthenticationMode: " + connectionOptions.AuthenticationMode); 46 | } 47 | var jsonNetSerializer = new JsonNetSerializer(); 48 | client.UseSerializer(() => jsonNetSerializer); 49 | 50 | client.DefaultParameters.Add(new Parameter {Type = ParameterType.HttpHeader, Name = "Accept", Value = "application/json"}); 51 | 52 | return client; 53 | } 54 | } 55 | 56 | public class JsonNetSerializer : IRestSerializer { 57 | readonly JsonSerializerSettings _jsonSerializerSettings; 58 | 59 | public JsonNetSerializer() { 60 | _jsonSerializerSettings = new JsonSerializerSettings { 61 | Converters = { 62 | new IsoDateTimeConverter { DateTimeFormat = "yyyyMMdd'T'HHmmsszzz" } 63 | } 64 | }; 65 | } 66 | 67 | public string Serialize(object obj) => JsonConvert.SerializeObject(obj, _jsonSerializerSettings); 68 | 69 | public T Deserialize(IRestResponse response) => JsonConvert.DeserializeObject(response.Content, _jsonSerializerSettings); 70 | 71 | public string Serialize(Parameter parameter) => JsonConvert.SerializeObject(parameter.Value, _jsonSerializerSettings); 72 | 73 | public string[] SupportedContentTypes { get; } = { 74 | "application/json", "text/json", "text/x-json", "text/javascript", "*+json" 75 | }; 76 | 77 | public string ContentType { get; set; } = "application/json"; 78 | 79 | public DataFormat DataFormat { get; } = DataFormat.Json; 80 | } 81 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/Mappers/BuildConfigurationMapper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using TeamCityTheatre.Core.Client.Responses; 5 | using TeamCityTheatre.Core.Models; 6 | 7 | namespace TeamCityTheatre.Core.Client.Mappers { 8 | public interface IBuildConfigurationMapper { 9 | BuildConfiguration Map(BuildTypeResponse buildType); 10 | IReadOnlyCollection Map(BuildTypesResponse buildTypes); 11 | } 12 | 13 | public class BuildConfigurationMapper : IBuildConfigurationMapper { 14 | readonly IAgentRequirementMapper _agentRequirementMapper; 15 | readonly IArtifactDependencyMapper _artifactDependencyMapper; 16 | readonly IBuildStepMapper _buildStepMapper; 17 | readonly IBuildTriggerMapper _buildTriggerMapper; 18 | readonly IPropertyMapper _propertyMapper; 19 | readonly ISnapshotDependencyMapper _snapshotDependencyMapper; 20 | readonly IVcsRootEntryMapper _vcsRootEntryMapper; 21 | 22 | public BuildConfigurationMapper( 23 | IAgentRequirementMapper agentRequirementMapper, 24 | IArtifactDependencyMapper artifactDependencyMapper, 25 | IPropertyMapper propertyMapper, 26 | ISnapshotDependencyMapper snapshotDependencyMapper, 27 | IBuildStepMapper buildStepMapper, 28 | IBuildTriggerMapper buildTriggerMapper, 29 | IVcsRootEntryMapper vcsRootEntryMapper) { 30 | _agentRequirementMapper = agentRequirementMapper ?? throw new ArgumentNullException(nameof(agentRequirementMapper)); 31 | _artifactDependencyMapper = artifactDependencyMapper ?? throw new ArgumentNullException(nameof(artifactDependencyMapper)); 32 | _propertyMapper = propertyMapper ?? throw new ArgumentNullException(nameof(propertyMapper)); 33 | _snapshotDependencyMapper = snapshotDependencyMapper ?? throw new ArgumentNullException(nameof(snapshotDependencyMapper)); 34 | _buildStepMapper = buildStepMapper ?? throw new ArgumentNullException(nameof(buildStepMapper)); 35 | _buildTriggerMapper = buildTriggerMapper ?? throw new ArgumentNullException(nameof(buildTriggerMapper)); 36 | _vcsRootEntryMapper = vcsRootEntryMapper ?? throw new ArgumentNullException(nameof(vcsRootEntryMapper)); 37 | } 38 | 39 | public BuildConfiguration Map(BuildTypeResponse buildType) { 40 | if (buildType == null) return null; 41 | return new BuildConfiguration { 42 | Id = buildType.Id, 43 | Href = buildType.Href, 44 | Name = buildType.Name, 45 | ProjectId = buildType.ProjectId, 46 | WebUrl = buildType.WebUrl, 47 | AgentRequirements = _agentRequirementMapper.Map(buildType.AgentRequirements), 48 | ArtifactDependencies = _artifactDependencyMapper.Map(buildType.ArtifactDependencies), 49 | Parameters = _propertyMapper.Map(buildType.Parameters), 50 | Settings = _propertyMapper.Map(buildType.Settings), 51 | SnapshotDependencies = _snapshotDependencyMapper.Map(buildType.SnapshotDependencies), 52 | Steps = _buildStepMapper.Map(buildType.Steps), 53 | Triggers = _buildTriggerMapper.Map(buildType.Triggers), 54 | VcsRootEntries = _vcsRootEntryMapper.Map(buildType.VcsRootEntries) 55 | }; 56 | } 57 | 58 | public IReadOnlyCollection Map(BuildTypesResponse buildType) { 59 | if (buildType == null || buildType.BuildType == null) 60 | return new List(); 61 | return buildType.BuildType.Select(Map).ToList(); 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Composition.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Extensions.Configuration; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using TeamCityTheatre.Core.Client; 5 | using TeamCityTheatre.Core.Client.Mappers; 6 | using TeamCityTheatre.Core.DataServices; 7 | using TeamCityTheatre.Core.Options; 8 | using TeamCityTheatre.Core.QueryServices; 9 | using TeamCityTheatre.Core.Repositories; 10 | 11 | namespace TeamCityTheatre.Web { 12 | public static class Composition { 13 | 14 | public static IServiceCollection AddViewManagement(this IServiceCollection services, IConfiguration configuration) { 15 | services.Configure(configuration.GetSection("Storage")); 16 | services.Configure(configuration.GetSection("Api")); 17 | 18 | services.AddSingleton(); 19 | 20 | services.AddSingleton(); 21 | services.AddSingleton(); 22 | 23 | services.AddSingleton(); 24 | services.AddSingleton(); 25 | 26 | return services; 27 | } 28 | 29 | public static IServiceCollection AddTeamCityServices(this IServiceCollection services, IConfiguration configuration) { 30 | /* stuff necessary to call TeamCity REST API */ 31 | services.Configure(configuration.GetSection("Connection")); 32 | services.AddSingleton(); 33 | services.AddSingleton(); 34 | services.AddSingleton(); 35 | services.AddSingleton(); 36 | services.AddSingleton>(); 37 | 38 | /* mappers that translate TeamCity response objects to our own models */ 39 | services.AddSingleton(); 40 | services.AddSingleton(); 41 | services.AddSingleton(); 42 | services.AddSingleton(); 43 | services.AddSingleton(); 44 | services.AddSingleton(); 45 | services.AddSingleton(); 46 | services.AddSingleton(); 47 | services.AddSingleton(); 48 | services.AddSingleton(); 49 | services.AddSingleton(); 50 | services.AddSingleton(); 51 | services.AddSingleton(); 52 | services.AddSingleton(); 53 | 54 | services.AddSingleton(sp => new Lazy(sp.GetRequiredService)); 55 | services.AddSingleton(sp => new Lazy(sp.GetRequiredService)); 56 | 57 | /* data services which call the TeamCity REST client and the corresponding response mappers */ 58 | services.AddSingleton(); 59 | services.AddSingleton(); 60 | services.AddSingleton(); 61 | 62 | return services; 63 | } 64 | } 65 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/IDetailedBuildConfiguration.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TeamCityTheatre.Core.Models { 4 | /* 5 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | ... 43 | 44 | 45 | 46 | ... 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | */ 55 | 56 | public interface IDetailedBuildConfiguration : IBasicBuildConfiguration { 57 | IReadOnlyCollection VcsRootEntries { get; } 58 | IReadOnlyCollection Settings { get; } 59 | IReadOnlyCollection Parameters { get; } 60 | IReadOnlyCollection Steps { get; } 61 | IReadOnlyCollection Triggers { get; } 62 | IReadOnlyCollection SnapshotDependencies { get; } 63 | IReadOnlyCollection ArtifactDependencies { get; } 64 | IReadOnlyCollection AgentRequirements { get; } 65 | } 66 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Home/settings.components.selected-project.tsx: -------------------------------------------------------------------------------- 1 | import {createElement} from "react"; 2 | import {BuildConfiguration, Project, Tile, View} from "../Shared/models"; 3 | import {updateView} from "./settings.observables.views"; 4 | import {saveView} from "./settings.observables.save-view"; 5 | 6 | export const SelectedProject = (props: { selectedProject: Project | null, selectedView: View | null }) => { 7 | if (props.selectedProject === null) { 8 | return ( 9 |
    10 | ); 11 | } 12 | if(props.selectedView === null) { 13 | return ( 14 |
    15 | Please select a view 16 |
    17 | ); 18 | } 19 | return ( 20 |
    21 |
    22 |
    23 |
    24 |

    {props.selectedProject.getLabel()}

    25 |
    26 |
    27 |
    28 | 29 | 30 |
    31 | 32 |
    33 |
    34 | ); 35 | }; 36 | 37 | const OpenInTeamCityButton = (props: { project: Project }) => ( 38 | 39 | Open in TeamCity 40 | 41 | ); 42 | 43 | const ProjectDescription = (props: { project: Project }) => { 44 | if (props.project.description === null) return null; 45 | return ( 46 |
    47 | {props.project.description} 48 |
    49 | ); 50 | }; 51 | 52 | const NoBuildConfigurationsWarning = (props: { project: Project }) => { 53 | if (props.project.buildConfigurations === null || props.project.buildConfigurations.length > 0) 54 | return null; 55 | return ( 56 |
    57 | This project does not have build configurations 58 |
    59 | ); 60 | }; 61 | 62 | const BuildConfigurationsTable = (props: { project: Project, view: View }) => { 63 | const { project, view } = props; 64 | if (project.buildConfigurations === null) 65 | return ( 66 |
    Loading build configurations
    67 | ); 68 | return ( 69 | 70 | 71 | 72 | 73 | 75 | 76 | 77 | { project.buildConfigurations.map(b => ) } 78 | 79 |
    Name 74 |
    80 | ); 81 | }; 82 | 83 | const handleAddTileButtonClick = (buildConfiguration: BuildConfiguration, view: View, project : Project) => 84 | () => saveView(updateView(view.withTile(Tile.newTile(project, buildConfiguration)))); 85 | 86 | const BuildConfigurationRow = (props: { buildConfiguration: BuildConfiguration, view: View, project: Project }) => { 87 | const { buildConfiguration, view, project} = props; 88 | return ( 89 | 90 | {buildConfiguration.name} 91 | 92 | 95 | 96 | 97 | ); 98 | }; -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Client/TeamCityClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using Microsoft.Extensions.Options; 5 | using RestSharp; 6 | using Serilog; 7 | using TeamCityTheatre.Core.Options; 8 | 9 | namespace TeamCityTheatre.Core.Client { 10 | public interface ITeamCityClient { 11 | Task ExecuteRequestAsync(IRestRequest restRequest) where TResponse : new(); 12 | } 13 | 14 | public class LoggingTeamCityClient : ITeamCityClient where T: class, ITeamCityClient { 15 | readonly T _inner; 16 | readonly ILogger _logger; 17 | 18 | public LoggingTeamCityClient(ILogger logger, T inner) { 19 | _logger = logger.ForContext() ?? throw new ArgumentNullException(nameof(logger)); 20 | _inner = inner ?? throw new ArgumentNullException(nameof(inner)); 21 | } 22 | 23 | public async Task ExecuteRequestAsync(IRestRequest request) where TResponse : new() { 24 | var parameters = string.Join(", ", request.Parameters.Select(p => $"{p.Name} = {p.Value}")); 25 | _logger.Verbose("[REQUEST ] {Method} {Url} with parameters {Parameters}", request.Method, request.Resource, parameters); 26 | var response = await _inner.ExecuteRequestAsync(request); 27 | _logger.Verbose("[RESPONSE] {@Data}", response); 28 | return response; 29 | } 30 | } 31 | 32 | public class TeamCityClient : ITeamCityClient { 33 | readonly IRestClient _client; 34 | readonly IResponseValidator _responseValidator; 35 | readonly ITeamCityRequestPreparer _teamCityRequestPreparer; 36 | readonly ILogger _logger; 37 | 38 | public TeamCityClient( 39 | ITeamCityRestClientFactory teamCityRestClientFactory, IOptionsSnapshot connectionOptionsSnapshot, 40 | IResponseValidator responseValidator, ITeamCityRequestPreparer teamCityRequestPreparer, 41 | ILogger logger) { 42 | if (connectionOptionsSnapshot == null) throw new ArgumentNullException(nameof(connectionOptionsSnapshot)); 43 | if (logger == null) throw new ArgumentNullException(nameof(logger)); 44 | var connectionOptions = connectionOptionsSnapshot.Value; 45 | _responseValidator = responseValidator ?? throw new ArgumentNullException(nameof(responseValidator)); 46 | _teamCityRequestPreparer = teamCityRequestPreparer ?? throw new ArgumentNullException(nameof(teamCityRequestPreparer)); 47 | _logger = logger; 48 | _client = teamCityRestClientFactory?.Create(connectionOptions) ?? throw new ArgumentNullException(nameof(teamCityRestClientFactory)); 49 | } 50 | 51 | public async Task ExecuteRequestAsync(IRestRequest restRequest) where TResponse : new() { 52 | _teamCityRequestPreparer.Prepare(restRequest); 53 | var taskCompletionSource = new TaskCompletionSource>(); 54 | _client.ExecuteAsync(restRequest, restResponse => { 55 | if (restResponse.ErrorException != null) { 56 | try { 57 | var responseContent = System.Text.Encoding.UTF8.GetString(restResponse.RawBytes); 58 | _logger.Error(restResponse.ErrorException, "Error response from TeamCity: {status} {responseContent}", restResponse.StatusCode, responseContent); 59 | } catch (Exception e) { 60 | var aggregateException = new AggregateException(restResponse.ErrorException, e); 61 | _logger.Error(aggregateException, "Error when calling TeamCity {status}", restResponse.StatusCode); 62 | } finally { 63 | taskCompletionSource.SetException(new Exception("Failed to make request to the TeamCity server", restResponse.ErrorException)); 64 | } 65 | } else { 66 | taskCompletionSource.SetResult(restResponse); 67 | } 68 | }); 69 | var responseTask = taskCompletionSource.Task; 70 | var response = await responseTask; 71 | _responseValidator.Validate(response); 72 | return response.Data; 73 | } 74 | } 75 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using Microsoft.AspNetCore.Hosting; 5 | using Microsoft.Extensions.Configuration; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using RestSharp.Validation; 8 | using Serilog; 9 | using TeamCityTheatre.Core.Options; 10 | 11 | namespace TeamCityTheatre.Web { 12 | public class Program { 13 | public static void Main(string[] args) { 14 | var environment = Environment(); 15 | var configuration = BuildConfiguration(args, environment); 16 | var logger = BuildLogger(configuration); 17 | 18 | ValidateConfiguration(configuration, logger); 19 | 20 | BuildWebHost(args, configuration, logger).Run(); 21 | } 22 | 23 | public static IWebHost BuildWebHost(string[] args, IConfiguration configuration, ILogger logger) { 24 | return new WebHostBuilder() 25 | .UseKestrel() 26 | .UseContentRoot(Directory.GetCurrentDirectory()) 27 | .UseConfiguration(configuration) 28 | .UseIISIntegration() 29 | .CaptureStartupErrors(true) 30 | .UseSetting(WebHostDefaults.DetailedErrorsKey, "True") 31 | .UseDefaultServiceProvider((context, options) => options.ValidateScopes = context.HostingEnvironment.IsDevelopment()) 32 | .UseSerilog(logger, dispose: true) 33 | .ConfigureServices(sc => sc.AddSingleton(logger)) 34 | .UseStartup() 35 | .Build(); 36 | } 37 | 38 | static ILogger BuildLogger(IConfiguration configuration) => new LoggerConfiguration() 39 | .ReadFrom.Configuration(configuration) 40 | .CreateLogger(); 41 | 42 | static IConfigurationRoot BuildConfiguration(string[] args, string environment) { 43 | var config = new ConfigurationBuilder() 44 | .SetBasePath(Directory.GetCurrentDirectory()); 45 | 46 | config.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); 47 | 48 | config.AddEnvironmentVariables("TEAMCITYTHEATRE_"); 49 | 50 | if (args != null) { 51 | config.AddCommandLine(args); 52 | } 53 | 54 | return config.Build(); 55 | } 56 | 57 | static string Environment() => System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") 58 | ?? EnvironmentName.Production; 59 | 60 | static void ValidateConfiguration(IConfiguration configuration, ILogger logger) { 61 | var teamCityConnection = configuration.GetSection("Connection").Get(); 62 | if (teamCityConnection == null) { 63 | throw new Exception("There is no 'Connection' configuration section present, neither in appsettings.json or via environment variables!"); 64 | } 65 | 66 | if (string.IsNullOrEmpty(teamCityConnection.Url)) { 67 | throw new Exception("Connection.Url is not present"); 68 | } 69 | 70 | if (teamCityConnection.AuthenticationMode == AuthenticationMode.BasicAuthentication && string.IsNullOrEmpty(teamCityConnection.Username)) { 71 | throw new Exception("Connection.Username is not present and authenticationMode is set to BasicAuthentication"); 72 | } 73 | 74 | if (teamCityConnection.AuthenticationMode == AuthenticationMode.BasicAuthentication && string.IsNullOrEmpty(teamCityConnection.Password)) { 75 | throw new Exception("Connection.Password is not present and authenticationMode is set to BasicAuthentication"); 76 | } 77 | 78 | if (teamCityConnection.AuthenticationMode == AuthenticationMode.AccessToken && string.IsNullOrEmpty(teamCityConnection.AccessToken)) { 79 | throw new Exception("Connection.AccessToken is not present and authenticationMode is set to AccessToken"); 80 | } 81 | 82 | logger.Information("Using TeamCity server : " + teamCityConnection.Url); 83 | logger.Information("Using TeamCity auth mode : " + teamCityConnection.AuthenticationMode); 84 | logger.Information("Using TeamCity user : " + teamCityConnection.Username); 85 | logger.Information("Using TeamCity password : " 86 | + teamCityConnection.Password?.FirstOrDefault() 87 | + new string(teamCityConnection.Password?.Skip(1).Select(_ => '*').ToArray() ?? new char[0])); 88 | logger.Information("Using TeamCity access token : " 89 | + teamCityConnection.AccessToken?.FirstOrDefault() 90 | + new string(teamCityConnection.AccessToken?.Skip(1).Select(_ => '*').ToArray() ?? new char[0])); 91 | } 92 | } 93 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/IDetailedProject.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TeamCityTheatre.Core.Models { 4 | /* 5 | 7 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | */ 32 | 33 | public interface IDetailedProject : IBasicProject { 34 | IBasicProject ParentProject { get; } 35 | IReadOnlyCollection BuildConfigurations { get; } 36 | } 37 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Home/settings.components.selected-view.tsx: -------------------------------------------------------------------------------- 1 | import {ChangeEvent, createElement} from "react"; 2 | import {SortableContainer, SortableElement, SortableHandle, SortEnd} from "react-sortable-hoc"; 3 | import {Tile, View} from "../Shared/models"; 4 | import {updateView} from "./settings.observables.views"; 5 | import {saveView} from "./settings.observables.save-view"; 6 | import {stopPropagation} from "../Shared/events/stopPropagation"; 7 | import {onEnter} from "../Shared/events/onEnter"; 8 | 9 | export const handleOnSortEnd = (view: View) => (sort: SortEnd) => saveView(updateView(view.moveTile(sort.oldIndex, sort.newIndex))); 10 | 11 | export const SelectedView = (props: { selectedView: View | null }) => { 12 | const {selectedView} = props; 13 | if (selectedView === null) return
    ; 14 | return ( 15 |
    16 |
    17 |
    18 |

    Tiles of {selectedView.name}

    19 |
    20 |
    21 | 23 |
    24 |
    25 | ); 26 | }; 27 | 28 | const TilesList = SortableContainer<{ view: View }>((props: { view: View }) => { 29 | const {view} = props; 30 | return ( 31 |
      32 | {view.tiles.map((t, index) => )} 33 |
    34 | ); 35 | }); 36 | 37 | const TileDragHandle = SortableHandle(() =>
    ); 38 | 39 | const TileRow = SortableElement<{ view: View, tile: Tile }>((props: { view: View, tile: Tile }) => { 40 | const {view, tile} = props; 41 | return ( 42 |
  • updateView(view.withTile(tile.withIsEditing(true)))}> 44 |
    45 | 46 |
    47 |
    48 | 49 |
    50 |
    51 | 52 |
    53 |
    54 | 55 |
    56 |
  • 57 | ); 58 | }); 59 | 60 | const TileLabel = (props: { view: View, tile: Tile }) => { 61 | const {view, tile} = props; 62 | if (tile.isEditing) { 63 | return ) => updateView(view.withTile(tile.withLabel(e.currentTarget.value)))} 69 | onKeyUp={onEnter(() => saveView(view))} 70 | autoFocus /> 71 | } 72 | return {tile.label} 73 | }; 74 | 75 | const TileBuildConfiguration = (props: { view: View, tile: Tile }) => { 76 | const {tile} = props; 77 | return {tile.buildConfigurationDisplayName}; 78 | }; 79 | 80 | const TileActions = (props: { view: View, tile: Tile }) => { 81 | const {tile} = props; 82 | if (tile.isEditing) 83 | return
    ; 84 | return
    85 | 86 |
    87 | }; 88 | 89 | const handleSaveTileButtonClick = (view: View) => (event: React.MouseEvent) => { 90 | event.stopPropagation(); 91 | saveView(view); 92 | }; 93 | 94 | const SaveTileButton = (props: { view: View }) => { 95 | return ( 96 | 99 | ); 100 | }; 101 | 102 | const handleEditTileButtonClick = (view: View, tile: Tile) => () => updateView(view.withTile(tile.withIsEditing(true))); 103 | 104 | const EditTileButton = (props: { view: View, tile: Tile }) => { 105 | const {view, tile} = props; 106 | return ( 107 | 110 | ); 111 | }; 112 | 113 | const handleDeleteTileButtonClick = (view: View, tile: Tile) => () => saveView(updateView(view.withoutTile(tile))); 114 | 115 | const DeleteTileButton = (props: { view: View, tile: Tile }) => { 116 | const {view, tile} = props; 117 | return ( 118 | 122 | ); 123 | }; -------------------------------------------------------------------------------- /src/TeamCityTheatre.Core/Models/IDetailedBuild.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace TeamCityTheatre.Core.Models { 5 | /* 6 | sample: 7 | { 8 | "id": 212673, 9 | "buildTypeId": "Pro_ProMaster_Compile", 10 | "number": "6.11.0.4856", 11 | "status": "FAILURE", 12 | "state": "finished", 13 | "branchName": "develop", 14 | "href": "/httpAuth/app/rest/builds/id:212673", 15 | "webUrl": "http://vm64-teamcity:8001/viewLog.html?buildId=212673&buildTypeId=Pro_ProMaster_Compile", 16 | "statusText": "Compilation error: UltraGendaPro.Patients\src\UltraGendaPro.Patients.WebApi\UltraGendaPro.Patients.WebApi.csproj (new)", 17 | "buildType": { 18 | "id": "Pro_ProMaster_Compile", 19 | "name": "Compile", 20 | "projectName": "Pro :: Pro Git", 21 | "projectId": "Pro_ProMaster", 22 | "href": "/httpAuth/app/rest/buildTypes/id:Pro_ProMaster_Compile", 23 | "webUrl": "http://vm64-teamcity:8001/viewType.html?buildTypeId=Pro_ProMaster_Compile" 24 | }, 25 | "queuedDate": "20150914T093735+0200", 26 | "startDate": "20150914T093741+0200", 27 | "finishDate": "20150914T094913+0200", 28 | "triggered": { 29 | "type": "buildType", 30 | "date": "20150914T093735+0200", 31 | "user": { 32 | "username": "nassereb", 33 | "name": "Nassere Besseghir", 34 | "id": 14, 35 | "href": "/httpAuth/app/rest/users/id:14" 36 | }, 37 | "buildType": { 38 | "id": "Pro_ProMaster_Create64bitInstaller", 39 | "name": "Create 64bit Installer", 40 | "projectName": "Pro :: Pro Git", 41 | "projectId": "Pro_ProMaster", 42 | "href": "/httpAuth/app/rest/buildTypes/id:Pro_ProMaster_Create64bitInstaller", 43 | "webUrl": "http://vm64-teamcity:8001/viewType.html?buildTypeId=Pro_ProMaster_Create64bitInstaller" 44 | } 45 | }, 46 | "lastChanges": { 47 | "count": 1, 48 | "change": [ 49 | { 50 | "id": 78420, 51 | "version": "7f88ba007daa401a91baec180c16b1798dceff0e", 52 | "username": "nbesseghir", 53 | "date": "20150914T093624+0200", 54 | "href": "/httpAuth/app/rest/changes/id:78420", 55 | "webUrl": "http://vm64-teamcity:8001/viewModification.html?modId=78420&personal=false" 56 | } 57 | ] 58 | }, 59 | "changes": { 60 | "href": "/httpAuth/app/rest/changes?locator=build:(id:212673)" 61 | }, 62 | "revisions": { 63 | "count": 1, 64 | "revision": [ 65 | { 66 | "version": "7f88ba007daa401a91baec180c16b1798dceff0e", 67 | "vcs-root-instance": { 68 | "id": "538", 69 | "vcs-root-id": "Pro_Git", 70 | "name": "Pro_Git", 71 | "href": "/httpAuth/app/rest/vcs-root-instances/id:538" 72 | } 73 | } 74 | ] 75 | }, 76 | "agent": { 77 | "id": 8, 78 | "name": "VM-BUILDAGENT5", 79 | "typeId": 8, 80 | "href": "/httpAuth/app/rest/agents/id:8" 81 | }, 82 | "problemOccurrences": { 83 | "count": 2, 84 | "href": "/httpAuth/app/rest/problemOccurrences?locator=build:(id:212673)", 85 | "newFailed": 2, 86 | "default": false 87 | }, 88 | "artifacts": { 89 | "href": "/httpAuth/app/rest/builds/id:212673/artifacts/children/" 90 | }, 91 | "relatedIssues": { 92 | "href": "/httpAuth/app/rest/builds/id:212673/relatedIssues" 93 | }, 94 | "properties": { 95 | "count": 8, 96 | "property": [ 97 | { 98 | "name": "ProjectBuildNumber", 99 | "value": "6.8.1" 100 | }, 101 | { 102 | "name": "SupportedProductVersionBuildNumber1", 103 | "value": "6.7.2" 104 | }, 105 | { 106 | "name": "SupportedProductVersionBuildNumber2", 107 | "value": "6.6.5" 108 | }, 109 | { 110 | "name": "system.PreviousSupportedReleaseVersion1", 111 | "value": "%SupportedProductVersionBuildNumber1%" 112 | }, 113 | { 114 | "name": "system.PreviousSupportedReleaseVersion2", 115 | "value": "%SupportedProductVersionBuildNumber2%" 116 | }, 117 | { 118 | "name": "system.UltraGendaProVersion", 119 | "value": "%env.BUILD_NUMBER%" 120 | }, 121 | { 122 | "name": "TestServer_X64", 123 | "value": "VM-PRO-TEST64" 124 | }, 125 | { 126 | "name": "TestServer_X86", 127 | "value": "VM-PRO-TEST32" 128 | } 129 | ] 130 | }, 131 | "statistics": { 132 | "href": "/httpAuth/app/rest/builds/id:212673/statistics" 133 | } 134 | } 135 | */ 136 | 137 | public interface IDetailedBuild : IBasicBuild { 138 | string StatusText { get; } 139 | IBasicBuildConfiguration BuildConfiguration { get; } 140 | DateTime QueuedDate { get; } 141 | DateTime StartDate { get; } 142 | DateTime FinishDate { get; } 143 | IReadOnlyCollection LastChanges { get; } 144 | IBasicAgent Agent { get; } 145 | IReadOnlyCollection Properties { get; } 146 | IReadOnlyCollection SnapshotDependencies { get; } 147 | IReadOnlyCollection ArtifactDependencies { get; } 148 | } 149 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Home/dashboard.pcss: -------------------------------------------------------------------------------- 1 | #views .view { 2 | display: block; 3 | padding: 25px; 4 | margin: 25px; 5 | font-size: 12px; 6 | 7 | .badge { 8 | font-size: 1em; 9 | } 10 | 11 | @media (min-width: 480px) { 12 | font-size: 24px; 13 | } @media (min-width: 768px) { 14 | font-size: 56px; 15 | } 16 | } 17 | 18 | .view { 19 | #tiles { 20 | width: 100%; 21 | 22 | .tiles-wrapper { 23 | margin: 0.25rem; 24 | } 25 | 26 | .tile { 27 | color: #fff; 28 | padding: 10px; 29 | display: inline-block; 30 | float: left; 31 | position: relative; 32 | 33 | &.width-1 { 34 | width: 100%; 35 | } 36 | &.width-2 { 37 | width: 50%; 38 | } 39 | &.width-3 { 40 | width: 33.3333%; 41 | } 42 | &.width-4 { 43 | width: 25%; 44 | } 45 | &.width-5 { 46 | width: 20%; 47 | } 48 | &.width-6 { 49 | width: 16.6666%; 50 | } 51 | &.width-7 { 52 | width: 14.2856%; 53 | } 54 | &.width-8 { 55 | width: 12.5%; 56 | } 57 | &.width-9 { 58 | width: 11.1110%; 59 | } 60 | &.width-10 { 61 | width: 10%; 62 | } 63 | &.width-11 { 64 | width: 9.0908%; 65 | } 66 | &.width-12 { 67 | width: 8.3332%; 68 | } 69 | &.width-13 { 70 | width: 7.6922%; 71 | } 72 | &.width-14 { 73 | width: 8.3332%; 74 | } 75 | &.width-15 { 76 | width: 7.1427%; 77 | } 78 | &.width-16 { 79 | width: 6.25%; 80 | } 81 | 82 | &.height-1 { 83 | height: 185px; 84 | } 85 | &.height-2 { 86 | height: 270px; 87 | } 88 | &.height-3 { 89 | height: 355px; 90 | } 91 | &.height-4 { 92 | height: 440px; 93 | } 94 | &.height-5 { 95 | height: 525px; 96 | } 97 | &.height-6 { 98 | height: 610px; 99 | } 100 | &.height-7 { 101 | height: 695px; 102 | } 103 | &.height-8 { 104 | height: 780px; 105 | } 106 | &.height-9 { 107 | height: 865px; 108 | } 109 | &.height-10 { 110 | height: 950px; 111 | } 112 | 113 | .tile-title { 114 | display: block; 115 | font-size: 30px; 116 | text-align: center; 117 | white-space: nowrap; 118 | -ms-text-overflow: ellipsis; 119 | -o-text-overflow: ellipsis; 120 | text-overflow: ellipsis; 121 | overflow: hidden; 122 | 123 | .tile-status-icon { 124 | display: none; 125 | } 126 | } 127 | 128 | &.success { 129 | background: #499649; 130 | } 131 | &.error, &.failure { 132 | background: #b2423f; 133 | } 134 | 135 | .tile-builds .tile-build { 136 | box-shadow: 3px 3px 10px 3px rgba(71, 71, 71, 0.5); 137 | border-radius: 15px; 138 | 139 | .progress { 140 | height: 70px; 141 | background-color: #888; 142 | 143 | .progress-bar { 144 | text-align: left; 145 | padding-left: 5px; 146 | padding-top: 3px; 147 | 148 | &.progress-bar-success { 149 | background-color: #5cb85c; 150 | color: #eee; 151 | 152 | .remaining, 153 | .execution-timestamp { 154 | 155 | .build-number.label-success { 156 | background-color: #76d776; 157 | } 158 | } 159 | 160 | &:hover { 161 | background-color: #76d776; 162 | 163 | .remaining, 164 | .execution-timestamp { 165 | .build-number.label-success { 166 | background-color: #5cb85c; 167 | } 168 | } 169 | } 170 | } 171 | 172 | &.progress-bar-danger { 173 | background-color: #d9534f; 174 | color: #eee; 175 | 176 | .remaining, 177 | .execution-timestamp { 178 | 179 | .build-number.label-danger { 180 | background-color: #fa5652; 181 | } 182 | } 183 | 184 | &:hover { 185 | background-color: #fa5652; 186 | 187 | .remaining, 188 | .execution-timestamp { 189 | .build-number.label-danger { 190 | background-color: #d9534f; 191 | } 192 | } 193 | } 194 | } 195 | } 196 | 197 | .branch { 198 | font-size: 40px; 199 | line-height: 45px; 200 | display: block; 201 | white-space: nowrap; 202 | overflow: visible; 203 | } 204 | 205 | .remaining, 206 | .execution-timestamp { 207 | display: block; 208 | font-size: 15px; 209 | white-space: nowrap; 210 | overflow: visible; 211 | 212 | .build-execution-time, 213 | .build-age { 214 | font-size: 0.75em; 215 | } 216 | } 217 | } 218 | } 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # :tv: TeamCity Theatre 2 | 3 | [![Build Status Travis CI](https://travis-ci.org/amoerie/teamcity-theatre.svg?branch=master)](https://travis-ci.org/amoerie/teamcity-theatre) [![Build Status Azure Devops](https://amoerman.visualstudio.com/TeamCity%20Theatre/_apis/build/status/amoerie.teamcity-theatre?branchName=master)](https://amoerman.visualstudio.com/TeamCity%20Theatre/_build/latest?definitionId=4&branchName=master) 4 | 5 | A .NET MVC web application to monitor your TeamCity builds. 6 | Stick a TV on the wall, open a browser there and enjoy your TeamCity projects in all their red and green glory. 7 | 8 | ## Screenies 9 | 10 | ### The home page: choose your team 11 | ![Choose your team](http://i.imgur.com/64YxBRb.png) 12 | 13 | ### Team view 14 | ![The dashboard screen](http://i.imgur.com/izZiWVd.png) 15 | 16 | ### Configuration: manage your views and their tiles 17 | ![The config screen](http://i.imgur.com/4Rg4yi6.png) 18 | 19 | ## Features 20 | 21 | - First-class support for branches! (This is a feature many others are lacking) 22 | - Create multiple dashboards, one for each team! 23 | - Customizable amount of branches shown per tile 24 | - Customizable amount of columns shown per view, make optimal use of the size of your wall TV! 25 | - Customizable labels on tiles 26 | - Docker support! 27 | - Quite extensive logging 28 | - Customize TeamCity query 29 | 30 | ## Requirements 31 | 32 | - A TeamCity server (d'uh). TeamCityTheatre is confirmed to be compatible with 2017.1.4 (build 47070). Other versions may or may not work. 33 | - .NET Core Runtime 2.2 (downloadable from https://www.microsoft.com/net/download/all ) 34 | - If you want to use IIS: 35 | - A Windows Server with IIS to host the web application 36 | - .NET Core Windows Hosting Bundle, downloadable from the same page you downloaded the runtime from 37 | - Some knowledge on how to add a .NET web application in IIS, or the willingness to learn. 38 | - If you want to use Docker: 39 | - Docker for Windows using Windows Containers. Linux and Linux containers might work but that's still in testing phase. 40 | - A nice cup of :coffee: to drink while you install this. 41 | 42 | ## Installation instructions 43 | 44 | 1. Download and unzip the [the latest release](https://github.com/amoerie/teamcity-theatre/releases) 45 | 2. Configure your TeamCity settings, the application needs to somehow get access to the TeamCity API. The following authentication modes are supported: 46 | - "Guest" mode: If your TeamCity is configured with guest access, you can use 'Guest' as the authentication mode. You don't need any credentials. 47 | - "BasicAuthentication" mode: Every HTTP call will have a basic authentication header with a username and password. 48 | - "AccessToken": Every HTTP call will have an access token in the header 49 | 3. To configure authentication: 50 | - Either add the following to the `appsettings.json` file: 51 | 52 | ```javascript 53 | "Connection": { 54 | "Url": "http://your-teamcity-server/", 55 | "AuthenticationMode": "BasicAuthentication" // or "Guest" or "AccessToken" 56 | "Username": "your-teamcity-username", // if using Basic 57 | "Password": "your-teamcity-password", // if using Basic 58 | "AccessToken": "your-teamcity-accesstoken", // if using AccessToken 59 | } 60 | ``` 61 | - OR add the following environment parameters: (watch the number of underscores!!!) 62 | - TEAMCITYTHEATRE_CONNECTION__URL 63 | - TEAMCITYTHEATRE_CONNECTION__AUTHENTICATIONMODE 64 | - TEAMCITYTHEATRE_CONNECTION__USERNAME 65 | - TEAMCITYTHEATRE_CONNECTION__PASSWORD 66 | - TEAMCITYTHEATRE_CONNECTION__ACCESSTOKEN 67 | 68 | 3. (Optional) In appsettings.json, change the location of the configuration.json file or leave the default. This file will contain your views/tiles/etc. 69 | 4. (Optional) In appsettings.json, change the logging configuration. It's quite verbose by default, but will never take more than 75MB of space. 70 | 5. Start the application in one of the following ways 71 | - Run the following command: `dotnet TeamCityTheatre.Web.dll` 72 | - Install this folder as a web application in IIS: 73 | - Application pool should use .NET CLR version 'No Managed Code' 74 | - Application pool should use Managed Pipeline mode 'Integrated' 75 | - Ensure the application pool has the read/write access rights to 76 | - the folder in which configuration.json resides 77 | - the folder in which log files will be written 78 | 79 | ## Usage instructions 80 | 81 | Open the web application from a browser 82 | - Open the settings page from the main menu. 83 | - If you see any errors, your server or credentials might be incorrect. Check the log files to see why the network request failed. 84 | - Add a new view, give it a name. 85 | - Expand your TeamCity projects in the left bottom pane and select one to see its build configurations. 86 | - Add build configurations to your view. These will become the tiles of your view. 87 | - Open the dashboard from the main menu and select your view 88 | - Wait for the data to load. 89 | - Enjoy. 90 | 91 | ## Compilation instructions 92 | 93 | 1. Ensure you have [.NET Core SDK 2.x](https://www.microsoft.com/net/download/core) installed 94 | 2. Ensure you have [Node](https://nodejs.org/en/) installed 95 | 3. Execute "publish.cmd" or "publish.sh" depending on your operating system. 96 | 4. If all goes well, that should create a folder 'publish-output' which is all you need to host the application. See Installation instructions from here. 97 | 98 | ## Contributors 99 | 100 | - [amoerie](https://github.com/amoerie) 101 | - [tauptk](https://github.com/tauptk) 102 | - [trolleyyy](https://github.com/trolleyyy) 103 | - [LazyTarget](https://github.com/LazyTarget) 104 | - [jimmycav](https://github.com/jimmycav) 105 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.sln.docstates 8 | 9 | # Build results 10 | [Dd]ebug/ 11 | [Dd]ebugPublic/ 12 | [Rr]elease/ 13 | [Rr]eleases/ 14 | x64/ 15 | x86/ 16 | build/*.log 17 | build/*.xml 18 | build/Deployment/ 19 | build/logfiles/ 20 | build/deploy/target/ 21 | bld/ 22 | [Bb]in/ 23 | [Oo]bj/ 24 | 25 | # Roslyn cache directories 26 | *.ide/ 27 | 28 | # MSTest test Results 29 | [Tt]est[Rr]esult*/ 30 | [Bb]uild[Ll]og.* 31 | 32 | #NUNIT 33 | *.VisualState.xml 34 | TestResult.xml 35 | 36 | # Build Results of an ATL Project 37 | [Dd]ebugPS/ 38 | [Rr]eleasePS/ 39 | dlldata.c 40 | 41 | *_i.c 42 | *_p.c 43 | *_i.h 44 | *.ilk 45 | *.meta 46 | *.obj 47 | *.pch 48 | *.pdb 49 | *.pgc 50 | *.pgd 51 | *.rsp 52 | *.sbr 53 | *.tlb 54 | *.tli 55 | *.tlh 56 | *.tmp 57 | *.tmp_proj 58 | *.log 59 | *.vspscc 60 | *.vssscc 61 | .builds 62 | *.pidb 63 | *.svclog 64 | *.scc 65 | 66 | # Chutzpah Test files 67 | _Chutzpah* 68 | 69 | # Visual C++ cache files 70 | ipch/ 71 | *.aps 72 | *.ncb 73 | *.opensdf 74 | *.sdf 75 | *.cachefile 76 | 77 | # Visual Studio profiler 78 | *.psess 79 | *.vsp 80 | *.vspx 81 | 82 | # TFS 2012 Local Workspace 83 | $tf/ 84 | 85 | # Guidance Automation Toolkit 86 | *.gpState 87 | 88 | # ReSharper is a .NET coding add-in 89 | _ReSharper*/ 90 | *.[Rr]e[Ss]harper 91 | *.DotSettings.user 92 | 93 | # JustCode is a .NET coding addin-in 94 | .JustCode 95 | 96 | # TeamCity is a build add-in 97 | _TeamCity* 98 | 99 | # DotCover is a Code Coverage Tool 100 | *.dotCover 101 | 102 | # NCrunch 103 | _NCrunch_* 104 | .*crunch*.local.xml 105 | 106 | # MightyMoose 107 | *.mm.* 108 | AutoTest.Net/ 109 | 110 | # Web workbench (sass) 111 | .sass-cache/ 112 | 113 | # Installshield output folder 114 | [Ee]xpress/ 115 | 116 | # DocProject is a documentation generator add-in 117 | DocProject/buildhelp/ 118 | DocProject/Help/*.HxT 119 | DocProject/Help/*.HxC 120 | DocProject/Help/*.hhc 121 | DocProject/Help/*.hhk 122 | DocProject/Help/*.hhp 123 | DocProject/Help/Html2 124 | DocProject/Help/html 125 | 126 | # Click-Once directory 127 | publish/ 128 | 129 | # Publish Web Output 130 | *.[Pp]ublish.xml 131 | *.azurePubxml 132 | ## TODO: Comment the next line if you want to checkin your 133 | ## web deploy settings but do note that will include unencrypted 134 | ## passwords 135 | *.pubxml 136 | 137 | # NuGet Packages 138 | **/packages/* 139 | packages/* 140 | *.nupkg 141 | ## TODO: If the tool you use requires repositories.config 142 | ## uncomment the next line 143 | #!packages/repositories.config 144 | 145 | # Enable "build/" folder in the NuGet Packages folder since 146 | # NuGet packages use it for MSBuild targets. 147 | # This line needs to be after the ignore of the build folder 148 | # (and the packages folder if the line above has been uncommented) 149 | !packages/build/ 150 | 151 | # Windows Azure Build Output 152 | csx/ 153 | *.build.csdef 154 | 155 | # Windows Store app package directory 156 | AppPackages/ 157 | 158 | # Others 159 | sql/ 160 | *.Cache 161 | ClientBin/ 162 | [Ss]tyle[Cc]op.* 163 | ~$* 164 | *~ 165 | *.dbmdl 166 | *.dbproj.schemaview 167 | *.pfx 168 | *.publishsettings 169 | node_modules/ 170 | 171 | # RIA/Silverlight projects 172 | Generated_Code/ 173 | 174 | # Backup & report files from converting an old project file 175 | # to a newer Visual Studio version. Backup files are not needed, 176 | # because we have git ;-) 177 | _UpgradeReport_Files/ 178 | Backup*/ 179 | UpgradeLog*.XML 180 | UpgradeLog*.htm 181 | 182 | # SQL Server files 183 | *.mdf 184 | *.ldf 185 | 186 | # Business Intelligence projects 187 | *.rdl.data 188 | *.bim.layout 189 | *.bim_*.settings 190 | 191 | ### WebStorm ### 192 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 193 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 194 | 195 | # User-specific stuff: 196 | .idea/**/workspace.xml 197 | .idea/**/tasks.xml 198 | .idea/dictionaries 199 | 200 | # Sensitive or high-churn files: 201 | .idea/**/dataSources/ 202 | .idea/**/dataSources.ids 203 | .idea/**/dataSources.xml 204 | .idea/**/dataSources.local.xml 205 | .idea/**/sqlDataSources.xml 206 | .idea/**/dynamic.xml 207 | .idea/**/uiDesigner.xml 208 | 209 | # Gradle: 210 | .idea/**/gradle.xml 211 | .idea/**/libraries 212 | 213 | # CMake 214 | cmake-build-debug/ 215 | 216 | # Mongo Explorer plugin: 217 | .idea/**/mongoSettings.xml 218 | 219 | ## File-based project format: 220 | *.iws 221 | 222 | ## Plugin-specific files: 223 | 224 | # IntelliJ 225 | /out/ 226 | 227 | # mpeltonen/sbt-idea plugin 228 | .idea_modules/ 229 | 230 | # JIRA plugin 231 | atlassian-ide-plugin.xml 232 | 233 | # Cursive Clojure plugin 234 | .idea/replstate.xml 235 | 236 | # Crashlytics plugin (for Android Studio and IntelliJ) 237 | com_crashlytics_export_strings.xml 238 | crashlytics.properties 239 | crashlytics-build.properties 240 | fabric.properties 241 | 242 | ### WebStorm Patch ### 243 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 244 | 245 | # *.iml 246 | # modules.xml 247 | # .idea/misc.xml 248 | # *.ipr 249 | 250 | # Sonarlint plugin 251 | .idea 252 | workspace.xml 253 | 254 | ### Don't include generated CSS in source control 255 | src/TeamCityTheatre.Web/wwwroot/css 256 | ### But do include these 257 | !src/TeamCityTheatre.Web/wwwroot/css/bootstrap.min.css 258 | !src/TeamCityTheatre.Web/wwwroot/css/font-awesome.min.css 259 | 260 | ### Don't include generated JS in source control 261 | src/TeamCityTheatre.Web/wwwroot/js 262 | src/TeamCityTheatre.Web/views/**/*.js 263 | 264 | ### Don't include Cake packages 265 | tools/ 266 | 267 | ### Don't include build and publish artifacts from the build.cake scripts 268 | /publish-output/ 269 | 270 | /.dotnet 271 | /src/.vs 272 | /src/TeamCityTheatre.Web/data 273 | /src/TeamCityTheatre.Web/logs 274 | 275 | ### Docker related data 276 | docker/logs 277 | docker/data 278 | docker.env -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Home/dashboard.components.tsx: -------------------------------------------------------------------------------- 1 | import { addSeconds, formatDistanceStrict, formatDistanceToNow, parseISO } from "date-fns"; 2 | import { createElement, FunctionComponent, MouseEvent } from "react"; 3 | import { BuildStatus, IDetailedBuild, ITileData, IView, IViewData } from "../Shared/contracts"; 4 | import { IDashboardState } from "./dashboard.observables"; 5 | 6 | /** 7 | * Root dispatching component 8 | */ 9 | export const Dashboard: FunctionComponent = 10 | props => { 11 | if (props.views === null) 12 | return
    13 | Loading views 14 |
    ; 15 | 16 | if (props.selectedView === null) 17 | return ; 18 | 19 | if (props.selectedViewData === null) 20 | return
    21 | Loading view data 22 |
    ; 23 | 24 | return ; 25 | }; 26 | 27 | /** 28 | * List of views to choose from 29 | */ 30 | const Views = (props: { views: IView[] }) => ( 31 |
    32 | { props.views.map(view => ( 33 | 34 | { view.name } { view.tiles.length } tiles 35 | )) } 36 |
    37 | ); 38 | 39 | const tryRequestFullScreen = (event: MouseEvent) => { 40 | const button = event.currentTarget as HTMLButtonElement; 41 | const view = button.parentNode as HTMLDivElement; 42 | if (view.requestFullscreen) view.requestFullscreen(); 43 | }; 44 | 45 | /** 46 | * Details of a single view 47 | */ 48 | const View = (props: { view: IView, data: IViewData }) => ( 49 |
    50 | 53 |
    54 |
    55 | { props.data.tiles.map(tile => ) } 56 |
    57 |
    58 |
    59 | ); 60 | 61 | /** 62 | * A single tile of a view 63 | */ 64 | const Tile = (props: { view: IView, data: ITileData }) => { 65 | const buildStatus = BuildStatus[props.data.combinedBuildStatus].toLowerCase(); 66 | const height = `height-${ props.view.defaultNumberOfBranchesPerTile }`; 67 | const numberOfColumns = props.view.numberOfColumns || 6; 68 | const width = `width-${ numberOfColumns }`; 69 | return ( 70 |
    71 |

    { props.data.label }

    72 | { props.view.defaultNumberOfBranchesPerTile > 0 73 | ? 74 |
    75 | { props.data.builds.map(build => ) } 76 |
    77 | : null 78 | } 79 |
    80 | ); 81 | }; 82 | 83 | /** 84 | * A single build in a tile 85 | */ 86 | const Build = (props: { build: IDetailedBuild }) => { 87 | const { build } = props; 88 | const isFinished = build.state === "finished"; 89 | const isRunning = build.state === "running"; 90 | const isSuccess = build.status === BuildStatus.Success; 91 | 92 | const buildStatus = BuildStatus[build.status].toLowerCase(); 93 | const percentageCompleted = isFinished ? 100 : build.percentageComplete; 94 | const progressBarTheme = isSuccess ? "progress-bar-success" : "progress-bar-danger"; 95 | const progressBarAnimation = isRunning ? "progress-bar-striped active" : ""; 96 | 97 | return ( 98 | 110 | ); 111 | }; 112 | 113 | const Branch = (props: { build: IDetailedBuild }) => { 114 | const isDefaultBranch = props.build.isDefaultBranch; 115 | const branchDisplayName = props.build.branchName || props.build.number; 116 | return isDefaultBranch 117 | ? { branchDisplayName } 118 | : { branchDisplayName }; 119 | }; 120 | 121 | const FinishDate = (props: { build: IDetailedBuild }) => { 122 | const { build } = props; 123 | const isSuccess = build.status === BuildStatus.Success; 124 | const theme = isSuccess ? "success" : "danger"; 125 | 126 | const startDate = parseISO(build.startDate); 127 | const finishDate = parseISO(build.finishDate); 128 | const differenceWithNow = formatDistanceToNow(finishDate, { includeSeconds: true, addSuffix: true }); 129 | const differenceWithStartDate = formatDistanceStrict(finishDate, startDate); 130 | return ( 131 | 132 | { build.number } 133 | { differenceWithStartDate } 134 | ({ `${ differenceWithNow }` }) 135 | 136 | ); 137 | }; 138 | 139 | const TimeRemaining = (props: { build: IDetailedBuild }) => { 140 | const { build } = props; 141 | const isSuccess = build.status === BuildStatus.Success; 142 | const theme = isSuccess ? "success" : "danger"; 143 | 144 | const estimatedFinishDate = addSeconds(parseISO(props.build.startDate), props.build.estimatedTotalSeconds); 145 | const differenceWithNow = formatDistanceToNow(estimatedFinishDate, { includeSeconds: true, addSuffix: true }); 146 | return ( 147 | 148 | { build.number } 149 | { ` will finish ${ differenceWithNow }` } 150 | 151 | ); 152 | }; -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Shared/models.ts: -------------------------------------------------------------------------------- 1 | import {Guid, IBasicBuildConfiguration, IBasicProject, ITile, IView} from "./contracts"; 2 | import {v4 as newguid } from "uuid"; 3 | import {move} from "./arrays/move"; 4 | import {mergeById} from "./arrays/mergeById"; 5 | 6 | interface IProjectConstructorParameters extends IBasicProject { 7 | parent?: Project | null, 8 | children?: Project[], 9 | buildConfigurations?: BuildConfiguration[] | null, 10 | isExpanded?: boolean 11 | } 12 | 13 | export class Project { 14 | isArchived: boolean; 15 | href: string; 16 | id: string; 17 | name: string; 18 | description: string | null; 19 | webUrl: string; 20 | parent: Project | null; 21 | parentProjectId: string | null; 22 | children: Project[]; 23 | isExpanded: boolean; 24 | buildConfigurations: BuildConfiguration[] | null; 25 | 26 | constructor(params: IProjectConstructorParameters) { 27 | if (!params) throw new Error("Invalid constructor parameters: " + JSON.stringify(params)); 28 | this.isArchived = params.isArchived; 29 | this.href = params.href; 30 | this.id = params.id; 31 | this.name = params.name; 32 | this.description = params.description; 33 | this.webUrl = params.webUrl; 34 | this.parentProjectId = params.parentProjectId; 35 | 36 | this.parent = typeof params.parent === "undefined" ? null : params.parent; 37 | this.children = typeof params.children === "undefined" ? [] : params.children; 38 | this.buildConfigurations = typeof params.buildConfigurations === "undefined" ? [] : params.buildConfigurations; 39 | this.isExpanded = typeof params.isExpanded === "undefined" ? false : params.isExpanded; 40 | } 41 | 42 | setChildren(children: Project[]): void { 43 | // Building immutable trees is hard if the input is not topologically sorted. 44 | // Avoid problems by doing only this little thing in a mutable way 45 | this.children = children; 46 | this.children.forEach(c => c.parent = this); 47 | } 48 | 49 | withBuildConfigurations(buildConfigurations: BuildConfiguration[]) { 50 | return new Project({ 51 | ...(this as Project), 52 | buildConfigurations: buildConfigurations 53 | }); 54 | } 55 | 56 | expand() { 57 | return new Project({ 58 | ...(this as Project), 59 | isExpanded: true 60 | }); 61 | } 62 | 63 | collapse() { 64 | return new Project({ 65 | ...(this as Project), 66 | isExpanded: false 67 | }); 68 | } 69 | 70 | toggleExpandOrCollapse() { 71 | return this.isExpanded ? this.collapse() : this.expand(); 72 | } 73 | 74 | // propagate updates to a project down the chain 75 | update(project: Project | null): Project { 76 | if (project === null) return this; 77 | if (this.id === project.id) return project; // if this is the project that was updated, return the new version 78 | return new Project({ 79 | ...(this as Project), 80 | children: this.children.map(c => c.update(project)) 81 | }); 82 | } 83 | 84 | hasChildren() { 85 | return this.children.length > 0; 86 | } 87 | 88 | getLabel(): string { 89 | if (this.parent === null) return this.name; 90 | return [this.parent.getLabel(), this.name].join(" / "); 91 | } 92 | } 93 | 94 | export class BuildConfiguration { 95 | id: string; 96 | name: string; 97 | 98 | constructor(params: { id: string, name: string }) { 99 | this.id = params.id; 100 | this.name = params.name; 101 | } 102 | 103 | static fromContract(buildConfiguration: IBasicBuildConfiguration) { 104 | return new BuildConfiguration(buildConfiguration); 105 | } 106 | } 107 | 108 | export class View { 109 | id: Guid; 110 | name: string; 111 | defaultNumberOfBranchesPerTile: number; 112 | numberOfColumns: number; 113 | tiles: Tile[]; 114 | isEditing: boolean; 115 | 116 | constructor(params: { id: Guid, name: string, defaultNumberOfBranchesPerTile: number, numberOfColumns: number, tiles: Tile[], isEditing?: boolean }) { 117 | this.id = params.id; 118 | this.name = params.name; 119 | this.defaultNumberOfBranchesPerTile = params.defaultNumberOfBranchesPerTile; 120 | this.numberOfColumns = params.numberOfColumns; 121 | this.tiles = params.tiles; 122 | this.isEditing = typeof params.isEditing == "undefined" ? false : params.isEditing; 123 | } 124 | 125 | withName(name: string) { 126 | return new View({ 127 | ...(this as View), 128 | name: name 129 | }); 130 | } 131 | 132 | withDefaultNumberOfBranchesPerTile(defaultNumberOfBranchesPerTile: number) { 133 | return new View({ 134 | ...(this as View), 135 | defaultNumberOfBranchesPerTile: defaultNumberOfBranchesPerTile 136 | }); 137 | } 138 | 139 | withNumberOfColumns(numberOfColumns: number) { 140 | return new View({ 141 | ...(this as View), 142 | numberOfColumns: numberOfColumns 143 | }); 144 | } 145 | 146 | withIsEditing(isEditing: boolean) { 147 | return new View({ 148 | ...(this as View), 149 | isEditing: isEditing 150 | }); 151 | } 152 | 153 | /** 154 | * Moves a single tile from the old index to the new index 155 | * @param oldIndex 156 | * @param newIndex 157 | */ 158 | moveTile(oldIndex: number, newIndex: number) : View { 159 | return new View({ 160 | ...(this as View), 161 | tiles: move(oldIndex, newIndex, this.tiles) 162 | }) 163 | } 164 | 165 | /** 166 | * Replaces an old tile with the updated version 167 | */ 168 | withTile(tile: Tile) { 169 | return new View({ 170 | ...(this as View), 171 | tiles: mergeById(tile, this.tiles) 172 | }); 173 | } 174 | 175 | /** 176 | * Removes a tile 177 | */ 178 | withoutTile(tile: Tile) { 179 | return new View({ 180 | ...(this as View), 181 | tiles: this.tiles.filter(t => t.id !== tile.id) 182 | }); 183 | } 184 | 185 | static fromContract(view: IView) { 186 | return new View({ 187 | id: view.id, 188 | name: view.name, 189 | defaultNumberOfBranchesPerTile: view.defaultNumberOfBranchesPerTile, 190 | numberOfColumns: view.numberOfColumns, 191 | tiles: view.tiles.map(Tile.fromContract) 192 | }) 193 | } 194 | 195 | static newView() { 196 | return new View({ 197 | id: newguid(), 198 | name: "New view", 199 | defaultNumberOfBranchesPerTile : 3, 200 | numberOfColumns: 6, 201 | tiles: [], 202 | isEditing: true 203 | }) 204 | } 205 | } 206 | 207 | export class Tile { 208 | id: Guid; 209 | label: string; 210 | buildConfigurationId: string; 211 | buildConfigurationDisplayName: string; 212 | isEditing : boolean; 213 | 214 | constructor(params: { id: Guid, label: string, buildConfigurationId: string, buildConfigurationDisplayName: string, isEditing? : boolean }) { 215 | this.id = params.id; 216 | this.label = params.label; 217 | this.buildConfigurationId = params.buildConfigurationId; 218 | this.buildConfigurationDisplayName = params.buildConfigurationDisplayName; 219 | this.isEditing = typeof params.isEditing === "undefined" ? false : params.isEditing; 220 | } 221 | 222 | withLabel(label: string) { 223 | return new Tile({ 224 | ...(this as Tile), 225 | label: label 226 | }); 227 | } 228 | 229 | withIsEditing(isEditing: boolean) { 230 | return new Tile({ 231 | ...(this as Tile), 232 | isEditing: isEditing 233 | }); 234 | } 235 | 236 | static fromContract(tile: ITile) { 237 | return new Tile(tile); 238 | } 239 | 240 | static newTile(project: Project, buildConfiguration: BuildConfiguration) { 241 | return new Tile({ 242 | id: newguid(), 243 | label: buildConfiguration.name, 244 | buildConfigurationId : buildConfiguration.id, 245 | buildConfigurationDisplayName : [project.getLabel(), buildConfiguration.name].join(" / ") 246 | }); 247 | } 248 | } -------------------------------------------------------------------------------- /src/TeamCityTheatre.Web/Views/Home/settings.components.views.tsx: -------------------------------------------------------------------------------- 1 | import {ChangeEvent, MouseEvent, createElement} from "react"; 2 | import {View} from "../Shared/models"; 3 | import {selectView} from "./settings.observables.selected-view"; 4 | import {updateView} from "./settings.observables.views"; 5 | import {saveView} from "./settings.observables.save-view"; 6 | import {onEnter} from "../Shared/events/onEnter"; 7 | import {stopPropagation} from "../Shared/events/stopPropagation"; 8 | import {confirmDeleteView, requestDeleteView} from "./settings.observables.delete-view"; 9 | 10 | export const Views = (props: { views: View[] | null, selectedView: View | null, deleteViewRequest: View | null }) => { 11 | const {views, selectedView, deleteViewRequest} = props; 12 | if (views === null) return ( 13 |
    14 | Loading views 15 |
    16 | ); 17 | return ( 18 |
    19 |
    20 |

    Views

    21 |
    22 |
    23 |
    24 | 25 |
    26 |
    27 | ); 28 | }; 29 | 30 | const handleCreateViewButtonClick = () => updateView(View.newView()); 31 | 32 | const CreateViewButton = () => ( 33 | 36 | ); 37 | 38 | const ViewsTable = (props: { views: View[], selectedView: View | null, deleteViewRequest: View | null }) => { 39 | return ( 40 | 41 | 42 | 43 | 44 | 45 | 46 | 48 | 49 | {props.views.map(view => )} 51 |
    Name# Branches per tile# Columns 47 |
    52 | ); 53 | }; 54 | 55 | const ViewRow = (props: { view: View, selectedView: View | null, deleteViewRequest: View | null }) => { 56 | const {view, selectedView, deleteViewRequest} = props; 57 | const isSelected = view === selectedView; 58 | 59 | const selectedClassName = isSelected ? "selected" : ""; 60 | return ( 61 | selectView(props.view)} 63 | onDoubleClick={() => updateView(props.view.withIsEditing(true))}> 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | ); 78 | }; 79 | 80 | const ViewName = (props: { view: View }) => { 81 | if (props.view.isEditing) 82 | return el && el.focus()} 87 | onClick={stopPropagation} 88 | onChange={(event: ChangeEvent) => updateView(props.view.withName(event.currentTarget.value))} 89 | onKeyUp={onEnter(() => saveView(props.view))}/>; 90 | return {props.view.name}; 91 | }; 92 | 93 | const DefaultNumberOfBranchesPerTile = (props: { view: View }) => { 94 | if (props.view.isEditing) 95 | return ) => updateView(props.view.withDefaultNumberOfBranchesPerTile(+event.currentTarget.value))} 103 | onKeyUp={onEnter(() => saveView(props.view))}/>; 104 | return {props.view.defaultNumberOfBranchesPerTile}; 105 | }; 106 | 107 | const NumberOfColumns = (props: { view: View }) => { 108 | if (props.view.isEditing) 109 | return ) => updateView(props.view.withNumberOfColumns(+event.currentTarget.value))} 118 | onKeyUp={onEnter(() => saveView(props.view))}/>; 119 | return {props.view.numberOfColumns}; 120 | }; 121 | 122 | const ViewActions = (props: { view: View, deleteViewRequest: View | null }) => { 123 | const {view, deleteViewRequest} = props; 124 | if (deleteViewRequest !== null && view === deleteViewRequest) { 125 | return ( 126 |
    127 |

    Are you sure?

    128 | 129 |
    130 | ); 131 | } 132 | if (view.isEditing) { 133 | return ; 134 | } 135 | return ; 136 | }; 137 | 138 | const handleSaveViewButtonClick = (view: View) => (event: MouseEvent) => { 139 | event.stopPropagation(); 140 | saveView(view); 141 | }; 142 | 143 | const SaveViewButton = (props: { view: View }) => ( 144 | 147 | ); 148 | 149 | const handleEditViewButtonClick = (view: View) => (event: MouseEvent) => { 150 | event.stopPropagation(); 151 | updateView(view.withIsEditing(true)); 152 | }; 153 | 154 | const EditViewButton = (props: { view: View }) => ( 155 | 158 | ); 159 | 160 | const handleDeleteViewButtonClick = (view: View) => (event: MouseEvent) => { 161 | event.stopPropagation(); 162 | requestDeleteView(view); 163 | }; 164 | 165 | const DeleteViewButton = (props: { view: View }) => ( 166 | 170 | ); 171 | 172 | const handleConfirmDeleteViewButtonClick = (view: View) => (event: MouseEvent) => { 173 | event.stopPropagation(); 174 | confirmDeleteView(view); 175 | }; 176 | 177 | const ConfirmDeleteViewButton = (props: { view: View }) => { 178 | const {view} = props; 179 | return ( 180 | 183 | ); 184 | }; 185 | 186 | const handleCancelDeleteViewButtonClick = (event: MouseEvent) => { 187 | event.stopPropagation(); 188 | requestDeleteView(null); 189 | }; 190 | 191 | const CancelDeleteViewButton = () => ( 192 | 195 | ); --------------------------------------------------------------------------------