mirror of
https://github.com/MAKS-IT-COM/uscheduler.git
synced 2026-02-14 06:37:18 +01:00
(feature): 1.0.1 init
This commit is contained in:
parent
986eea321e
commit
5805664c3e
65
CHANGELOG.md
65
CHANGELOG.md
@ -1,5 +1,44 @@
|
|||||||
# MaksIT.UScheduler Changelog
|
# MaksIT.UScheduler Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## v1.0.1 - 2026-02-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **CLI service management**: Added command-line arguments for service installation and management
|
||||||
|
- `--install`, `-i`: Install the Windows service
|
||||||
|
- `--uninstall`, `-u`: Uninstall the Windows service
|
||||||
|
- `--start`: Start the service
|
||||||
|
- `--stop`: Stop the service
|
||||||
|
- `--status`: Query service status
|
||||||
|
- `--help`, `-h`: Show help message
|
||||||
|
- **Relative path support**: Script and process paths can now be relative to the application directory
|
||||||
|
- **OS guard**: Application now checks for Windows at startup and exits with error on unsupported platforms
|
||||||
|
- **Release script enhancement**: Example scripts are now automatically added to `appsettings.json` in disabled state during release build
|
||||||
|
- **Unit tests**: Added comprehensive test project `MaksIT.UScheduler.Tests` with tests for background services and configuration
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Default value for `IsSigned` in PowerShell script configuration changed from `false` to `true` for improved security
|
||||||
|
- PSScriptService now implements `IDisposable` for proper RunspacePool cleanup
|
||||||
|
- Method signatures updated: `RunScript` → `RunScriptAsync`, `RunProcess` → `RunProcessAsync`
|
||||||
|
- Service is now installed with `start=auto` for automatic startup on boot
|
||||||
|
- Updated package dependencies:
|
||||||
|
- MaksIT.Core: 1.6.0 → 1.6.1
|
||||||
|
- Microsoft.Extensions.Hosting: 10.0.0 → 10.0.2
|
||||||
|
- Microsoft.Extensions.Hosting.WindowsServices: 10.0.0 → 10.0.2
|
||||||
|
- System.Diagnostics.PerformanceCounter: 10.0.0 → 10.0.2
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- `Install.cmd` and `Uninstall.cmd` files (replaced by CLI arguments)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Parallel execution**: Restored parallel execution of PowerShell scripts and processes (broken during .NET Framework to .NET migration)
|
||||||
|
- PSScriptService now uses RunspacePool (up to CPU core count) for concurrent script execution
|
||||||
|
- Background services use `Task.WhenAll` to launch all tasks simultaneously
|
||||||
|
|
||||||
## v1.0.0 - 2025-12-06
|
## v1.0.0 - 2025-12-06
|
||||||
|
|
||||||
### Major Changes
|
### Major Changes
|
||||||
@ -13,7 +52,7 @@
|
|||||||
- `ProcessBackgroundService` for process management.
|
- `ProcessBackgroundService` for process management.
|
||||||
- Enhanced PowerShell script execution with signature validation and script unblocking.
|
- Enhanced PowerShell script execution with signature validation and script unblocking.
|
||||||
- Improved process management with restart-on-failure logic.
|
- Improved process management with restart-on-failure logic.
|
||||||
- Updated install/uninstall scripts (`Install.cmd`, `Uninstall.cmd`) for service management.
|
- Added install/uninstall scripts (`Install.cmd`, `Uninstall.cmd`) for service management (removed in v1.0.1).
|
||||||
- Added comprehensive README with usage, configuration, and scheduling examples.
|
- Added comprehensive README with usage, configuration, and scheduling examples.
|
||||||
- MIT License included.
|
- MIT License included.
|
||||||
|
|
||||||
@ -21,3 +60,27 @@
|
|||||||
- Old solution, project, and service files removed.
|
- Old solution, project, and service files removed.
|
||||||
- Configuration format and service naming conventions updated.
|
- Configuration format and service naming conventions updated.
|
||||||
- Scheduling logic for console applications is not yet implemented (runs every 10 seconds).
|
- Scheduling logic for console applications is not yet implemented (runs every 10 seconds).
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Template for new releases:
|
||||||
|
|
||||||
|
## v1.x.x - YYYY-MM-DD
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- New features
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Changes in existing functionality
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
- Soon-to-be removed features
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Removed features
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Bug fixes
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Security improvements
|
||||||
|
-->
|
||||||
|
|||||||
236
CONTRIBUTING.md
Normal file
236
CONTRIBUTING.md
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
# Contributing to MaksIT.UScheduler
|
||||||
|
|
||||||
|
Thank you for your interest in contributing to MaksIT.UScheduler!
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Contributing to MaksIT.UScheduler](#contributing-to-maksituscheduler)
|
||||||
|
- [Table of Contents](#table-of-contents)
|
||||||
|
- [Development Setup](#development-setup)
|
||||||
|
- [Branch Strategy](#branch-strategy)
|
||||||
|
- [Making Changes](#making-changes)
|
||||||
|
- [Versioning](#versioning)
|
||||||
|
- [Release Process](#release-process)
|
||||||
|
- [Prerequisites](#prerequisites)
|
||||||
|
- [Version Files](#version-files)
|
||||||
|
- [Branch-Based Release Behavior](#branch-based-release-behavior)
|
||||||
|
- [Development Build Workflow](#development-build-workflow)
|
||||||
|
- [Production Release Workflow](#production-release-workflow)
|
||||||
|
- [Release Script Details](#release-script-details)
|
||||||
|
- [Changelog Guidelines](#changelog-guidelines)
|
||||||
|
- [AI-Powered Changelog Generation (Optional)](#ai-powered-changelog-generation-optional)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/MaksIT/uscheduler.git
|
||||||
|
cd uscheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Open the solution in Visual Studio or your preferred IDE:
|
||||||
|
```
|
||||||
|
src/MaksIT.UScheduler/MaksIT.UScheduler.sln
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Build the project:
|
||||||
|
```bash
|
||||||
|
dotnet build src/MaksIT.UScheduler/MaksIT.UScheduler.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branch Strategy
|
||||||
|
|
||||||
|
- `main` - Production-ready code
|
||||||
|
- `dev` - Active development branch
|
||||||
|
- Feature branches - Created from `dev` for specific features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Making Changes
|
||||||
|
|
||||||
|
1. Create a feature branch from `dev`
|
||||||
|
2. Make your changes
|
||||||
|
3. Update `CHANGELOG.md` with your changes
|
||||||
|
4. Update version in `.csproj` if needed
|
||||||
|
5. Test your changes locally using dev tags
|
||||||
|
6. Submit a pull request to `dev`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
This project follows [Semantic Versioning](https://semver.org/):
|
||||||
|
|
||||||
|
- **MAJOR** - Incompatible API changes
|
||||||
|
- **MINOR** - New functionality (backwards compatible)
|
||||||
|
- **PATCH** - Bug fixes (backwards compatible)
|
||||||
|
|
||||||
|
Version format: `X.Y.Z` (e.g., `1.0.1`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Release Process
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- .NET SDK installed
|
||||||
|
- Git CLI
|
||||||
|
- GitHub CLI (`gh`) - required only for production releases
|
||||||
|
- GitHub token set in environment variable (configured in `scriptsettings.json`)
|
||||||
|
|
||||||
|
### Version Files
|
||||||
|
|
||||||
|
Before creating a release, ensure version consistency across:
|
||||||
|
|
||||||
|
1. **`.csproj`** - Update `<Version>` element:
|
||||||
|
```xml
|
||||||
|
<Version>1.0.1</Version>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **`CHANGELOG.md`** - Add version entry at the top:
|
||||||
|
```markdown
|
||||||
|
## v1.0.1
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- New feature description
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Bug fix description
|
||||||
|
```
|
||||||
|
|
||||||
|
### Branch-Based Release Behavior
|
||||||
|
|
||||||
|
The release script behavior is controlled by the current branch (configurable in `scriptsettings.json`):
|
||||||
|
|
||||||
|
| Branch | Tag Required | Uncommitted Changes | Behavior |
|
||||||
|
|--------|--------------|---------------------|----------|
|
||||||
|
| Dev (`dev`) | No | Allowed | Local build only (version from .csproj) |
|
||||||
|
| Release (`main`) | Yes | Not allowed | Full release to GitHub |
|
||||||
|
| Other | - | - | Blocked |
|
||||||
|
|
||||||
|
Branch names can be customized in `scriptsettings.json`:
|
||||||
|
```json
|
||||||
|
"branches": {
|
||||||
|
"release": "main",
|
||||||
|
"dev": "dev"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Build Workflow
|
||||||
|
|
||||||
|
Test builds on the `dev` branch - no tag needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. On dev branch: Update version in .csproj and CHANGELOG.md
|
||||||
|
git checkout dev
|
||||||
|
|
||||||
|
# 2. Commit your changes
|
||||||
|
git add .
|
||||||
|
git commit -m "Prepare v1.0.1 release"
|
||||||
|
|
||||||
|
# 3. Run the release script (no tag needed!)
|
||||||
|
cd src/scripts/Release-ToGitHub
|
||||||
|
.\Release-ToGitHub.ps1
|
||||||
|
|
||||||
|
# Output: DEV BUILD COMPLETE
|
||||||
|
# Creates: release/maksit.uscheduler-1.0.1.zip (local only)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Release Workflow
|
||||||
|
|
||||||
|
When ready to publish, merge to `main`, create tag, and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Merge to main
|
||||||
|
git checkout main
|
||||||
|
git merge dev
|
||||||
|
|
||||||
|
# 2. Create tag (required on main)
|
||||||
|
git tag v1.0.1
|
||||||
|
|
||||||
|
# 3. Run the release script
|
||||||
|
cd src/scripts/Release-ToGitHub
|
||||||
|
.\Release-ToGitHub.ps1
|
||||||
|
|
||||||
|
# Output: RELEASE COMPLETE
|
||||||
|
# Creates: release/maksit.uscheduler-1.0.1.zip
|
||||||
|
# Pushes tag to GitHub
|
||||||
|
# Creates GitHub release with assets
|
||||||
|
```
|
||||||
|
|
||||||
|
### Release Script Details
|
||||||
|
|
||||||
|
The `Release-ToGitHub.ps1` script performs these steps:
|
||||||
|
|
||||||
|
**Pre-flight checks:**
|
||||||
|
- Detects current branch (`main` or `dev`)
|
||||||
|
- On `main`: requires clean working directory; on `dev`: uncommitted changes allowed
|
||||||
|
- Reads version from `.csproj` (source of truth)
|
||||||
|
- On `main`: requires tag matching the version
|
||||||
|
- Ensures `CHANGELOG.md` has matching version entry
|
||||||
|
- Checks GitHub CLI authentication (main branch only)
|
||||||
|
|
||||||
|
**Build process:**
|
||||||
|
- Publishes .NET project in Release configuration
|
||||||
|
- Copies `Scripts` folder into the release
|
||||||
|
- Creates versioned ZIP archive
|
||||||
|
- Extracts release notes from `CHANGELOG.md`
|
||||||
|
|
||||||
|
**GitHub release (main branch only):**
|
||||||
|
- Pushes tag to remote if not present
|
||||||
|
- Creates (or recreates) GitHub release with assets
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
|
||||||
|
The script reads settings from `scriptsettings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"github": {
|
||||||
|
"tokenEnvVar": "GITHUB_MAKS_IT_COM"
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"csprojPath": "..\\..\\MaksIT.UScheduler\\MaksIT.UScheduler.csproj",
|
||||||
|
"changelogPath": "..\\..\\..\\CHANGELOG.md",
|
||||||
|
"releaseDir": "..\\..\\release"
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"zipNamePattern": "maksit.uscheduler-{version}.zip",
|
||||||
|
"releaseTitlePattern": "Release {version}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changelog Guidelines
|
||||||
|
|
||||||
|
Follow [Keep a Changelog](https://keepachangelog.com/) format:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## v1.0.1
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- New features
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Changes to existing functionality
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
- Features to be removed in future
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Removed features
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Bug fixes
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Security-related changes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 Maksym Sadovnychyy (Maks-IT)
|
Copyright (c) 2017 - 2026 Maksym Sadovnychyy (Maks-IT)
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
258
README.md
258
README.md
@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
|
|
||||||
# MaksIT Unified Scheduler Service
|
# MaksIT Unified Scheduler Service
|
||||||
|
|
||||||
|
  
|
||||||
|
|
||||||
A modern, fully rewritten Windows service built on **.NET 10** for scheduling and running PowerShell scripts and console applications.
|
A modern, fully rewritten Windows service built on **.NET 10** for scheduling and running PowerShell scripts and console applications.
|
||||||
Designed for system administrators — and also for those who *feel like* system administrators — who need a predictable, resilient, and secure background execution environment.
|
Designed for system administrators — and also for those who *feel like* system administrators — who need a predictable, resilient, and secure background execution environment.
|
||||||
|
|
||||||
@ -14,38 +14,52 @@ Designed for system administrators — and also for those who *feel like* system
|
|||||||
- [Scripts Examples](#scripts-examples)
|
- [Scripts Examples](#scripts-examples)
|
||||||
- [Features at a Glance](#features-at-a-glance)
|
- [Features at a Glance](#features-at-a-glance)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Recommended (using bundled scripts)](#recommended-using-bundled-scripts)
|
- [Using CLI Commands](#using-cli-commands)
|
||||||
- [Manual Installation](#manual-installation)
|
- [Using sc.exe](#using-scexe)
|
||||||
- [Configuration (`appsettings.json`)](#configuration-appsettingsjson)
|
- [Configuration (`appsettings.json`)](#configuration-appsettingsjson)
|
||||||
|
- [Path Resolution](#path-resolution)
|
||||||
|
- [Log Levels](#log-levels)
|
||||||
- [PowerShell Scripts](#powershell-scripts)
|
- [PowerShell Scripts](#powershell-scripts)
|
||||||
- [Processes](#processes)
|
- [Processes](#processes)
|
||||||
- [How It Works](#how-it-works)
|
- [How It Works](#how-it-works)
|
||||||
- [PowerShell Execution Parameters](#powershell-execution-parameters)
|
- [PowerShell Execution Parameters](#powershell-execution-parameters)
|
||||||
- [Thread Layout](#thread-layout)
|
- [Execution Model](#execution-model)
|
||||||
- [Reusable Scheduler Module (`SchedulerTemplate.psm1`)](#reusable-scheduler-module-schedulertemplatepsm1)
|
- [Reusable Scheduler Module (`SchedulerTemplate.psm1`)](#reusable-scheduler-module-schedulertemplatepsm1)
|
||||||
|
- [Exported Functions](#exported-functions)
|
||||||
|
- [Module Version](#module-version)
|
||||||
- [Example usage](#example-usage)
|
- [Example usage](#example-usage)
|
||||||
- [Security](#security)
|
- [Security](#security)
|
||||||
- [Logging](#logging)
|
- [Logging](#logging)
|
||||||
|
- [Testing](#testing)
|
||||||
|
- [Running Tests](#running-tests)
|
||||||
|
- [Code Coverage](#code-coverage)
|
||||||
|
- [Test Structure](#test-structure)
|
||||||
- [Contact](#contact)
|
- [Contact](#contact)
|
||||||
- [License](#license)
|
- [License](#license)
|
||||||
|
|
||||||
## Scripts Examples
|
## Scripts Examples
|
||||||
- [Hyper-V Backup](./examples/HyperV-Backup/README.md) - Production-ready Hyper-V VM backup solution with scheduling and retention management
|
|
||||||
- [Native-Sync](./examples/Native-Sync/README.md) - Production-ready file synchronization solution using pure PowerShell with no external dependencies
|
> **Note:** These examples are **bundled with the release** and included in the default `appsettings.json`, but are **disabled by default**. To enable an example, set `"Disabled": false` in the configuration.
|
||||||
- [File-Sync](./examples/File-Sync/README.md) - [FreeFileSync](https://freefilesync.org/) batch job execution
|
|
||||||
- [Scheduler Template Module](./examples/SchedulerTemplate.psm1)
|
- [Hyper-V Backup](./src/Scripts/HyperV-Backup/README.md) - Production-ready Hyper-V VM backup solution with scheduling and retention management
|
||||||
|
- [Native-Sync](./src/Scripts/Native-Sync/README.md) - Production-ready file synchronization solution using pure PowerShell with no external dependencies
|
||||||
|
- [File-Sync](./src/Scripts/File-Sync/README.md) - [FreeFileSync](https://freefilesync.org/) batch job execution
|
||||||
|
- [Windows-Update](./src/Scripts/Windows-Update/README.md) - Production-ready Windows Update automation solution using pure PowerShell
|
||||||
|
- [Scheduler Template Module](./src/Scripts/SchedulerTemplate.psm1)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features at a Glance
|
## Features at a Glance
|
||||||
|
|
||||||
* **.NET 10 Worker Service** – clean, robust, stable.
|
* **.NET 10 Worker Service** – clean, robust, stable.
|
||||||
|
* **Windows only** – designed specifically for Windows services.
|
||||||
* **Strongly typed configuration** via `appsettings.json`.
|
* **Strongly typed configuration** via `appsettings.json`.
|
||||||
* **Run PowerShell scripts & executables concurrently** (each in its own thread).
|
* **Parallel execution** – PowerShell scripts & executables run concurrently using RunspacePool and Task.WhenAll.
|
||||||
|
* **Relative path support** – script and process paths can be relative to the application directory.
|
||||||
* **Signature enforcement** (AllSigned by default).
|
* **Signature enforcement** (AllSigned by default).
|
||||||
* **Automatic restart-on-failure** for supervised processes.
|
* **Automatic restart-on-failure** for supervised processes.
|
||||||
* **Extensible logging** (file + console).
|
* **Extensible logging** (file + console + Windows EventLog).
|
||||||
* **Simple Install.cmd / Uninstall.cmd**.
|
* **Built-in CLI** for service management (`--install`, `--uninstall`, `--start`, `--stop`, `--status`).
|
||||||
* **Reusable scheduling module**: `SchedulerTemplate.psm1`.
|
* **Reusable scheduling module**: `SchedulerTemplate.psm1`.
|
||||||
* **Thread-isolated architecture** — individual failures do not affect others.
|
* **Thread-isolated architecture** — individual failures do not affect others.
|
||||||
|
|
||||||
@ -53,30 +67,55 @@ Designed for system administrators — and also for those who *feel like* system
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Recommended (using bundled scripts)
|
### Using CLI Commands
|
||||||
|
|
||||||
```bat
|
The executable includes built-in service management commands. Run as Administrator:
|
||||||
cd /d path\to\src\MaksIT.UScheduler
|
|
||||||
Install.cmd
|
```powershell
|
||||||
|
# Install the service (auto-start enabled)
|
||||||
|
MaksIT.UScheduler.exe --install
|
||||||
|
|
||||||
|
# Start the service
|
||||||
|
MaksIT.UScheduler.exe --start
|
||||||
|
|
||||||
|
# Check service status
|
||||||
|
MaksIT.UScheduler.exe --status
|
||||||
|
|
||||||
|
# Stop the service
|
||||||
|
MaksIT.UScheduler.exe --stop
|
||||||
|
|
||||||
|
# Uninstall the service
|
||||||
|
MaksIT.UScheduler.exe --uninstall
|
||||||
|
|
||||||
|
# Show help
|
||||||
|
MaksIT.UScheduler.exe --help
|
||||||
|
```
|
||||||
|
|
||||||
|
| Command | Short | Description |
|
||||||
|
|---------|-------|-------------|
|
||||||
|
| `--install` | `-i` | Install the Windows service (auto-start) |
|
||||||
|
| `--uninstall` | `-u` | Stop and remove the Windows service |
|
||||||
|
| `--start` | | Start the service |
|
||||||
|
| `--stop` | | Stop the service |
|
||||||
|
| `--status` | | Query service status |
|
||||||
|
| `--help` | `-h` | Show help message |
|
||||||
|
|
||||||
|
> **Note:** Service management commands require administrator privileges.
|
||||||
|
|
||||||
|
### Using sc.exe
|
||||||
|
|
||||||
|
Alternatively, use Windows Service Control Manager directly:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
sc.exe create "MaksIT.UScheduler" binpath="C:\Path\To\MaksIT.UScheduler.exe" start=auto
|
||||||
|
sc.exe start "MaksIT.UScheduler"
|
||||||
```
|
```
|
||||||
|
|
||||||
To uninstall:
|
To uninstall:
|
||||||
|
|
||||||
```bat
|
|
||||||
Uninstall.cmd
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual Installation
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
sc.exe create "MaksIT.UScheduler Service" binpath="C:\Path\To\MaksIT.UScheduler.exe"
|
sc.exe stop "MaksIT.UScheduler"
|
||||||
sc.exe start "MaksIT.UScheduler Service"
|
sc.exe delete "MaksIT.UScheduler"
|
||||||
```
|
|
||||||
|
|
||||||
Manual uninstall:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
sc.exe delete "MaksIT.UScheduler Service"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -85,31 +124,79 @@ sc.exe delete "MaksIT.UScheduler Service"
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information"
|
||||||
|
},
|
||||||
|
"EventLog": {
|
||||||
|
"SourceName": "MaksIT.UScheduler",
|
||||||
|
"LogName": "Application",
|
||||||
|
"LogLevel": {
|
||||||
|
"Microsoft": "Information",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Configuration": {
|
"Configuration": {
|
||||||
"ServiceName": "MaksIT.UScheduler",
|
"ServiceName": "MaksIT.UScheduler",
|
||||||
"LogDir": "C:\\Logs",
|
"LogDir": "C:\\Logs",
|
||||||
|
|
||||||
"Powershell": [
|
"Powershell": [
|
||||||
{ "Path": "C:\\Scripts\\MyScript.ps1", "IsSigned": true }
|
{ "Path": "../Scripts/MyScript.ps1", "IsSigned": true, "Disabled": false },
|
||||||
|
{ "Path": "C:\\Scripts\\AnotherScript.ps1", "IsSigned": false, "Disabled": true }
|
||||||
],
|
],
|
||||||
|
|
||||||
"Processes": [
|
"Processes": [
|
||||||
{ "Path": "C:\\Programs\\MyApp.exe", "Args": ["--option"], "RestartOnFailure": true }
|
{ "Path": "../Tools/MyApp.exe", "Args": ["--option"], "RestartOnFailure": true, "Disabled": false }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Note:** `ServiceName` and `LogDir` are optional. Defaults: `"MaksIT.UScheduler"` and `Logs` folder in app directory.
|
||||||
|
|
||||||
|
### Path Resolution
|
||||||
|
|
||||||
|
Paths can be either absolute or relative:
|
||||||
|
|
||||||
|
| Path Type | Example | Resolved To |
|
||||||
|
|-----------|---------|-------------|
|
||||||
|
| Absolute | `C:\Scripts\backup.ps1` | `C:\Scripts\backup.ps1` |
|
||||||
|
| Relative | `../Scripts/backup.ps1` | `{AppDirectory}\..\Scripts\backup.ps1` |
|
||||||
|
| Relative | `scripts/backup.ps1` | `{AppDirectory}\scripts\backup.ps1` |
|
||||||
|
|
||||||
|
Relative paths are resolved against the application's base directory (where `MaksIT.UScheduler.exe` is located).
|
||||||
|
|
||||||
|
### Log Levels
|
||||||
|
|
||||||
|
The `"Default": "Information"` setting controls the minimum severity of messages that get logged. Available levels (from most to least verbose):
|
||||||
|
|
||||||
|
| Level | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `Trace` | Most detailed, for debugging internals |
|
||||||
|
| `Debug` | Debugging information |
|
||||||
|
| `Information` | General operational events (recommended default) |
|
||||||
|
| `Warning` | Abnormal or unexpected events |
|
||||||
|
| `Error` | Errors and exceptions |
|
||||||
|
| `Critical` | Critical failures requiring immediate attention |
|
||||||
|
| `None` | Disables logging |
|
||||||
|
|
||||||
### PowerShell Scripts
|
### PowerShell Scripts
|
||||||
|
|
||||||
* `Path` — full `.ps1` file path
|
| Property | Type | Default | Description |
|
||||||
* `IsSigned` — `true` enforces AllSigned, `false` runs unrestricted
|
|----------|------|---------|-------------|
|
||||||
|
| `Path` | string | required | Path to `.ps1` file (absolute or relative) |
|
||||||
|
| `IsSigned` | bool | `true` | `true` enforces AllSigned, `false` runs unrestricted |
|
||||||
|
| `Disabled` | bool | `false` | `true` skips this script during execution |
|
||||||
|
|
||||||
### Processes
|
### Processes
|
||||||
|
|
||||||
* `Path` — executable
|
| Property | Type | Default | Description |
|
||||||
* `Args` — command-line arguments
|
|----------|------|---------|-------------|
|
||||||
* `RestartOnFailure` — restart logic handled by service
|
| `Path` | string | required | Path to executable (absolute or relative) |
|
||||||
|
| `Args` | string[] | `null` | Command-line arguments |
|
||||||
|
| `RestartOnFailure` | bool | `false` | Restart process if it exits with non-zero code |
|
||||||
|
| `Disabled` | bool | `false` | `true` skips this process during execution |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -133,21 +220,28 @@ param (
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Thread Layout
|
### Execution Model
|
||||||
|
|
||||||
|
Scripts and processes run **in parallel** using:
|
||||||
|
|
||||||
|
- **PowerShell**: `RunspacePool` (up to CPU core count concurrent runspaces)
|
||||||
|
- **Processes**: `Task.WhenAll` for concurrent process execution
|
||||||
|
|
||||||
```
|
```
|
||||||
Unified Scheduler Service
|
Unified Scheduler Service
|
||||||
├── PowerShell
|
├── PSScriptBackgroundService (RunspacePool)
|
||||||
│ ├── ScriptA.ps1 Thread
|
│ ├── ScriptA.ps1 ─┐
|
||||||
│ ├── ScriptB.ps1 Thread
|
│ ├── ScriptB.ps1 ─┼─ Parallel execution
|
||||||
│ └── ...
|
│ └── ScriptC.ps1 ─┘
|
||||||
└── Processes
|
└── ProcessBackgroundService (Task.WhenAll)
|
||||||
├── ProgramA.exe Thread
|
├── ProgramA.exe ─┐
|
||||||
├── ProgramB.exe Thread
|
├── ProgramB.exe ─┼─ Parallel execution
|
||||||
└── ...
|
└── ProgramC.exe ─┘
|
||||||
```
|
```
|
||||||
|
|
||||||
A crash in one thread **never stops the service** or other components.
|
- A failure in one script/process **never stops the service** or other components.
|
||||||
|
- The same script/process won't run twice concurrently (protected by "already running" check).
|
||||||
|
- Execution cycle repeats every 10 seconds.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -164,7 +258,20 @@ This module provides:
|
|||||||
* Automatic lock file (no concurrent execution)
|
* Automatic lock file (no concurrent execution)
|
||||||
* Last-run file tracking
|
* Last-run file tracking
|
||||||
* Unified callback execution pattern
|
* Unified callback execution pattern
|
||||||
* Logging helpers (Write-Log)
|
|
||||||
|
### Exported Functions
|
||||||
|
|
||||||
|
| Function | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `Write-Log` | Logging with timestamp, level (Info/Success/Warning/Error), and color support |
|
||||||
|
| `Invoke-ScheduledExecution` | Main scheduler — checks schedule, manages locks, runs callback |
|
||||||
|
| `Get-CredentialFromEnvVar` | Retrieves credentials from Base64-encoded machine environment variables |
|
||||||
|
| `Test-UNCPath` | Validates whether a path is a UNC path |
|
||||||
|
| `Send-EmailNotification` | Sends SMTP email with optional SSL and credential support |
|
||||||
|
|
||||||
|
### Module Version
|
||||||
|
|
||||||
|
The module exports `$ModuleVersion` and `$ModuleDate` for version tracking.
|
||||||
|
|
||||||
### Example usage
|
### Example usage
|
||||||
|
|
||||||
@ -205,20 +312,65 @@ That’s it — the full scheduling engine is reused automatically.
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
* Signed scripts required by default.
|
* Scripts run with **AllSigned** execution policy by default.
|
||||||
* Scripts are auto-unblocked before execution.
|
* Set `IsSigned: false` to use **Unrestricted** policy (not recommended for production).
|
||||||
* Unrestricted execution can be enabled if needed (not recommended on production systems).
|
* Scripts are auto-unblocked before execution (Zone.Identifier removed).
|
||||||
|
* Signature validation ensures only trusted scripts execute.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
* Console logging
|
* **Console logging** — standard output
|
||||||
* File logging under the directory specified by `LogDir`
|
* **File logging** — written to `LogDir` (default: `Logs` folder in app directory)
|
||||||
|
* **Windows EventLog** — events logged to Application log under `MaksIT.UScheduler` source
|
||||||
* All events (start, stop, crash, restart, error, skip) are logged
|
* All events (start, stop, crash, restart, error, skip) are logged
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The project includes a comprehensive test suite using **xUnit** and **Moq** for unit testing.
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Run all tests
|
||||||
|
dotnet test src/MaksIT.UScheduler.Tests
|
||||||
|
|
||||||
|
# Run with verbose output
|
||||||
|
dotnet test src/MaksIT.UScheduler.Tests --verbosity normal
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Coverage
|
||||||
|
|
||||||
|
Coverage badges are generated locally using [ReportGenerator](https://github.com/danielpalme/ReportGenerator).
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet tool install --global dotnet-reportgenerator-globaltool
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generate coverage report and badges:**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\src\scripts\Run-Coverage\Run-Coverage.ps1
|
||||||
|
|
||||||
|
# With HTML report opened in browser
|
||||||
|
.\src\scripts\Run-Coverage\Run-Coverage.ps1 -OpenReport
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
|
||||||
|
| Test Class | Coverage |
|
||||||
|
|------------|----------|
|
||||||
|
| `ConfigurationTests` | Configuration POCOs and default values |
|
||||||
|
| `ProcessBackgroundServiceTests` | Process execution lifecycle and error handling |
|
||||||
|
| `PSScriptBackgroundServiceTests` | PowerShell script execution and signature validation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Contact
|
## Contact
|
||||||
|
|
||||||
Maksym Sadovnychyy – MAKS-IT, 2025
|
Maksym Sadovnychyy – MAKS-IT, 2025
|
||||||
|
|||||||
21
badges/coverage-branches.svg
Normal file
21
badges/coverage-branches.svg
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="147.5" height="20" role="img" aria-label="Branch Coverage: 9.6%">
|
||||||
|
<title>Branch Coverage: 9.6%</title>
|
||||||
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="r">
|
||||||
|
<rect width="147.5" height="20" rx="3" fill="#fff"/>
|
||||||
|
</clipPath>
|
||||||
|
<g clip-path="url(#r)">
|
||||||
|
<rect width="107.5" height="20" fill="#555"/>
|
||||||
|
<rect x="107.5" width="40" height="20" fill="#e05d44"/>
|
||||||
|
<rect width="147.5" height="20" fill="url(#s)"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||||
|
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Branch Coverage</text>
|
||||||
|
<text x="53.75" y="14" fill="#fff">Branch Coverage</text>
|
||||||
|
<text aria-hidden="true" x="127.5" y="15" fill="#010101" fill-opacity=".3">9.6%</text>
|
||||||
|
<text x="127.5" y="14" fill="#fff">9.6%</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
21
badges/coverage-lines.svg
Normal file
21
badges/coverage-lines.svg
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 18.6%">
|
||||||
|
<title>Line Coverage: 18.6%</title>
|
||||||
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="r">
|
||||||
|
<rect width="137" height="20" rx="3" fill="#fff"/>
|
||||||
|
</clipPath>
|
||||||
|
<g clip-path="url(#r)">
|
||||||
|
<rect width="94.5" height="20" fill="#555"/>
|
||||||
|
<rect x="94.5" width="42.5" height="20" fill="#fe7d37"/>
|
||||||
|
<rect width="137" height="20" fill="url(#s)"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||||
|
<text aria-hidden="true" x="47.25" y="15" fill="#010101" fill-opacity=".3">Line Coverage</text>
|
||||||
|
<text x="47.25" y="14" fill="#fff">Line Coverage</text>
|
||||||
|
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">18.6%</text>
|
||||||
|
<text x="115.75" y="14" fill="#fff">18.6%</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
21
badges/coverage-methods.svg
Normal file
21
badges/coverage-methods.svg
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 43.2%">
|
||||||
|
<title>Method Coverage: 43.2%</title>
|
||||||
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="r">
|
||||||
|
<rect width="150" height="20" rx="3" fill="#fff"/>
|
||||||
|
</clipPath>
|
||||||
|
<g clip-path="url(#r)">
|
||||||
|
<rect width="107.5" height="20" fill="#555"/>
|
||||||
|
<rect x="107.5" width="42.5" height="20" fill="#a4a61d"/>
|
||||||
|
<rect width="150" height="20" fill="url(#s)"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||||
|
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Method Coverage</text>
|
||||||
|
<text x="53.75" y="14" fill="#fff">Method Coverage</text>
|
||||||
|
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">43.2%</text>
|
||||||
|
<text x="128.75" y="14" fill="#fff">43.2%</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
8
src/MaksIT.UScheduler.ScheduleManager/App.xaml
Normal file
8
src/MaksIT.UScheduler.ScheduleManager/App.xaml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<Application x:Class="MaksIT.UScheduler.ScheduleManager.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
StartupUri="MainWindow.xaml">
|
||||||
|
<Application.Resources>
|
||||||
|
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
7
src/MaksIT.UScheduler.ScheduleManager/App.xaml.cs
Normal file
7
src/MaksIT.UScheduler.ScheduleManager/App.xaml.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace MaksIT.UScheduler.ScheduleManager;
|
||||||
|
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
}
|
||||||
445
src/MaksIT.UScheduler.ScheduleManager/MainWindow.xaml
Normal file
445
src/MaksIT.UScheduler.ScheduleManager/MainWindow.xaml
Normal file
@ -0,0 +1,445 @@
|
|||||||
|
<Window x:Class="MaksIT.UScheduler.ScheduleManager.MainWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:vm="clr-namespace:MaksIT.UScheduler.ScheduleManager.ViewModels"
|
||||||
|
xmlns:svc="clr-namespace:MaksIT.UScheduler.ScheduleManager.Services"
|
||||||
|
xmlns:shared="clr-namespace:MaksIT.UScheduler.Shared;assembly=MaksIT.UScheduler.Shared"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
Title="MaksIT.UScheduler Manager"
|
||||||
|
Height="800" Width="1100"
|
||||||
|
MinHeight="600" MinWidth="900"
|
||||||
|
WindowStartupLocation="CenterScreen">
|
||||||
|
|
||||||
|
<Window.DataContext>
|
||||||
|
<vm:MainViewModel />
|
||||||
|
</Window.DataContext>
|
||||||
|
|
||||||
|
<Window.Resources>
|
||||||
|
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
||||||
|
|
||||||
|
<Style TargetType="GroupBox">
|
||||||
|
<Setter Property="Margin" Value="5"/>
|
||||||
|
<Setter Property="Padding" Value="5"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="Button">
|
||||||
|
<Setter Property="Padding" Value="10,5"/>
|
||||||
|
<Setter Property="Margin" Value="3"/>
|
||||||
|
<Setter Property="MinWidth" Value="80"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="CheckBox">
|
||||||
|
<Setter Property="Margin" Value="5,2"/>
|
||||||
|
</Style>
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<TabControl>
|
||||||
|
<!-- Main Tab -->
|
||||||
|
<TabItem Header="Main">
|
||||||
|
<TabControl>
|
||||||
|
<!-- Scripts Management -->
|
||||||
|
<TabItem Header="Scripts Management">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="280"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- Left Panel: Scripts List -->
|
||||||
|
<GroupBox Grid.Column="0" Header="Scripts">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<ListBox Grid.Row="0" ItemsSource="{Binding Scripts}" SelectedItem="{Binding SelectedScript}" DisplayMemberPath="Name"/>
|
||||||
|
<Button Grid.Row="1" Content="Refresh" Command="{Binding RefreshScriptsCommand}" HorizontalAlignment="Stretch" Margin="0,5,0,0"/>
|
||||||
|
</Grid>
|
||||||
|
</GroupBox>
|
||||||
|
|
||||||
|
<!-- Right Panel: Schedule Settings & Script Status -->
|
||||||
|
<Grid Grid.Column="1">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Script Status Panel -->
|
||||||
|
<GroupBox Grid.Row="0" Header="Script Status"
|
||||||
|
Visibility="{Binding HasSelectedScript, Converter={StaticResource BoolToVis}}">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal">
|
||||||
|
<TextBlock Text="Last Run: " FontWeight="Bold" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding CurrentScriptStatus.LastRunDisplay, FallbackValue='Never'}"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,30,0"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Status: " FontWeight="Bold" VerticalAlignment="Center"/>
|
||||||
|
<Ellipse Width="10" Height="10" Margin="0,0,5,0" VerticalAlignment="Center">
|
||||||
|
<Ellipse.Style>
|
||||||
|
<Style TargetType="Ellipse">
|
||||||
|
<Setter Property="Fill" Value="LimeGreen"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding HasLockFile}" Value="True">
|
||||||
|
<Setter Property="Fill" Value="Orange"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Ellipse.Style>
|
||||||
|
</Ellipse>
|
||||||
|
<TextBlock Text="{Binding CurrentScriptStatus.StatusDisplay, FallbackValue='Ready'}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="0" Grid.Column="2" Orientation="Horizontal">
|
||||||
|
<Button Content="Remove Lock" Command="{Binding RemoveLockFileCommand}"
|
||||||
|
Visibility="{Binding HasLockFile, Converter={StaticResource BoolToVis}}"
|
||||||
|
Background="#FFFFCCCC"/>
|
||||||
|
<Button Content="Refresh Status" Command="{Binding RefreshScriptStatusCommand}"/>
|
||||||
|
<Button Content="Launch" Command="{Binding LaunchScriptCommand}"
|
||||||
|
ToolTip="Run script via its .bat file in a separate window"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" Orientation="Horizontal" Margin="0,8,0,0">
|
||||||
|
<TextBlock Text="Name:" VerticalAlignment="Center" Margin="0,0,8,0" FontWeight="Bold"/>
|
||||||
|
<TextBox Text="{Binding ScriptName, UpdateSourceTrigger=PropertyChanged}" MinWidth="180" VerticalAlignment="Center"
|
||||||
|
ToolTip="Display name for this script in the list; saved to service appsettings"/>
|
||||||
|
<CheckBox Content="Require signed script" IsChecked="{Binding ScriptIsSigned, UpdateSourceTrigger=PropertyChanged}" Margin="20,0,0,0"
|
||||||
|
ToolTip="When checked, only scripts with valid Authenticode signatures will be executed"/>
|
||||||
|
<CheckBox Content="Disabled" IsChecked="{Binding ScriptDisabled, UpdateSourceTrigger=PropertyChanged}" Margin="20,0,0,0"
|
||||||
|
ToolTip="When checked, this script will not be executed by the scheduler"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</GroupBox>
|
||||||
|
|
||||||
|
<!-- Schedule Settings -->
|
||||||
|
<GroupBox Grid.Row="1" Header="Schedule Settings">
|
||||||
|
<Grid Visibility="{Binding HasSelectedScript, Converter={StaticResource BoolToVis}}">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Months Section -->
|
||||||
|
<GroupBox Grid.Row="0" Header="Months (empty = all months)">
|
||||||
|
<WrapPanel>
|
||||||
|
<CheckBox Content="Jan" IsChecked="{Binding MonthOptions[0].IsChecked}"
|
||||||
|
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||||
|
<CheckBox Content="Feb" IsChecked="{Binding MonthOptions[1].IsChecked}"
|
||||||
|
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||||
|
<CheckBox Content="Mar" IsChecked="{Binding MonthOptions[2].IsChecked}"
|
||||||
|
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||||
|
<CheckBox Content="Apr" IsChecked="{Binding MonthOptions[3].IsChecked}"
|
||||||
|
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||||
|
<CheckBox Content="May" IsChecked="{Binding MonthOptions[4].IsChecked}"
|
||||||
|
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||||
|
<CheckBox Content="Jun" IsChecked="{Binding MonthOptions[5].IsChecked}"
|
||||||
|
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||||
|
<CheckBox Content="Jul" IsChecked="{Binding MonthOptions[6].IsChecked}"
|
||||||
|
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||||
|
<CheckBox Content="Aug" IsChecked="{Binding MonthOptions[7].IsChecked}"
|
||||||
|
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||||
|
<CheckBox Content="Sep" IsChecked="{Binding MonthOptions[8].IsChecked}"
|
||||||
|
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||||
|
<CheckBox Content="Oct" IsChecked="{Binding MonthOptions[9].IsChecked}"
|
||||||
|
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||||
|
<CheckBox Content="Nov" IsChecked="{Binding MonthOptions[10].IsChecked}"
|
||||||
|
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||||
|
<CheckBox Content="Dec" IsChecked="{Binding MonthOptions[11].IsChecked}"
|
||||||
|
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||||
|
</WrapPanel>
|
||||||
|
</GroupBox>
|
||||||
|
|
||||||
|
<!-- Weekdays Section -->
|
||||||
|
<GroupBox Grid.Row="1" Header="Weekdays (empty = all days)">
|
||||||
|
<WrapPanel>
|
||||||
|
<CheckBox Content="Mon" IsChecked="{Binding WeekdayOptions[0].IsChecked}"
|
||||||
|
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||||
|
<CheckBox Content="Tue" IsChecked="{Binding WeekdayOptions[1].IsChecked}"
|
||||||
|
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||||
|
<CheckBox Content="Wed" IsChecked="{Binding WeekdayOptions[2].IsChecked}"
|
||||||
|
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||||
|
<CheckBox Content="Thu" IsChecked="{Binding WeekdayOptions[3].IsChecked}"
|
||||||
|
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||||
|
<CheckBox Content="Fri" IsChecked="{Binding WeekdayOptions[4].IsChecked}"
|
||||||
|
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||||
|
<CheckBox Content="Sat" IsChecked="{Binding WeekdayOptions[5].IsChecked}"
|
||||||
|
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||||
|
<CheckBox Content="Sun" IsChecked="{Binding WeekdayOptions[6].IsChecked}"
|
||||||
|
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||||
|
</WrapPanel>
|
||||||
|
</GroupBox>
|
||||||
|
|
||||||
|
<!-- Run Times Section -->
|
||||||
|
<GroupBox Grid.Row="2" Header="Run Times (UTC)">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="80"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Add Time Controls -->
|
||||||
|
<StackPanel Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2"
|
||||||
|
Orientation="Horizontal" Margin="0,0,0,5">
|
||||||
|
<TextBox Text="{Binding NewRunTime, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Width="80" VerticalAlignment="Center"/>
|
||||||
|
<Button Content="Add" Command="{Binding AddRunTimeCommand}" MinWidth="60"/>
|
||||||
|
<Button Content="Remove" Command="{Binding RemoveRunTimeCommand}" MinWidth="60"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Times List -->
|
||||||
|
<ListBox Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2"
|
||||||
|
ItemsSource="{Binding RunTimes}"
|
||||||
|
SelectedItem="{Binding SelectedRunTime}"/>
|
||||||
|
</Grid>
|
||||||
|
</GroupBox>
|
||||||
|
|
||||||
|
<!-- Min Interval Section -->
|
||||||
|
<GroupBox Grid.Row="3" Header="Minimum Interval">
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Top">
|
||||||
|
<TextBox Text="{Binding MinIntervalMinutes, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Width="60" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text=" minutes between executions" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</GroupBox>
|
||||||
|
|
||||||
|
<!-- Save/Revert Buttons -->
|
||||||
|
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||||
|
<TextBlock Text="* Unsaved changes" Foreground="Orange" VerticalAlignment="Center"
|
||||||
|
Margin="0,0,20,0"
|
||||||
|
Visibility="{Binding IsDirty, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
<Button Content="Revert" Command="{Binding RevertScheduleCommand}" MinWidth="80"/>
|
||||||
|
<Button Content="Save" Command="{Binding SaveScheduleCommand}" MinWidth="80"
|
||||||
|
FontWeight="Bold"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</GroupBox>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
<!-- Processes Management (to-do; hidden for now) -->
|
||||||
|
<TabItem Header="Processes Management" Visibility="Collapsed">
|
||||||
|
<GroupBox Header="Processes">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<TextBlock Grid.Row="0" Margin="0,0,0,8" TextWrapping="Wrap">
|
||||||
|
<Run Text="Processes are configured in MaksIT.UScheduler appsettings.json (Configuration.Processes). Listed below are the entries currently loaded."/>
|
||||||
|
</TextBlock>
|
||||||
|
<ListBox Grid.Row="1" ItemsSource="{Binding ProcessList}" MinHeight="120">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate DataType="{x:Type shared:ProcessConfiguration}">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="{Binding Path}" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock FontSize="11" Foreground="Gray">
|
||||||
|
<Run Text="Disabled: "/><Run Text="{Binding Disabled, Mode=OneWay}"/>
|
||||||
|
<Run Text=" Restart on failure: "/><Run Text="{Binding RestartOnFailure, Mode=OneWay}"/>
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</Grid>
|
||||||
|
</GroupBox>
|
||||||
|
</TabItem>
|
||||||
|
</TabControl>
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
<!-- Logs Tab -->
|
||||||
|
<TabItem Header="Logs">
|
||||||
|
<GroupBox Header="Log Viewer" Margin="5">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="250"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- Log Files List -->
|
||||||
|
<Grid Grid.Column="0">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<TabControl Grid.Row="0" Grid.RowSpan="2" SelectedIndex="{Binding SelectedLogTab}">
|
||||||
|
<TabItem Header="Service Logs">
|
||||||
|
<ListBox ItemsSource="{Binding ServiceLogs}"
|
||||||
|
SelectedItem="{Binding SelectedLogFile}">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="{Binding FileName}" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock Text="{Binding SizeDisplay}" FontSize="10" Foreground="Gray"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</TabItem>
|
||||||
|
<TabItem Header="Script Logs">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="120"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="Script folders" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="1" Text="Log files" FontWeight="SemiBold" Margin="5,0,0,4"/>
|
||||||
|
<ListBox Grid.Row="1" Grid.Column="0" ItemsSource="{Binding ScriptLogFolders}"
|
||||||
|
SelectedItem="{Binding SelectedScriptLogFolder}"/>
|
||||||
|
<ListBox Grid.Row="1" Grid.Column="1" Margin="5,0,0,0"
|
||||||
|
ItemsSource="{Binding ScriptLogFiles}"
|
||||||
|
SelectedItem="{Binding SelectedLogFile}">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="{Binding FileName}" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock Text="{Binding SizeDisplay}" FontSize="10" Foreground="Gray"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</Grid>
|
||||||
|
</TabItem>
|
||||||
|
</TabControl>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,5,0,0">
|
||||||
|
<Button Content="Refresh" Command="{Binding RefreshServiceLogsCommand}" MinWidth="60" Padding="5,2"/>
|
||||||
|
<Button Content="Open Folder" Command="{Binding OpenLogDirectoryCommand}" MinWidth="70" Padding="5,2"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Log Content -->
|
||||||
|
<Grid Grid.Column="1" Margin="5,0,0,0">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,5">
|
||||||
|
<TextBlock Text="Log Content" FontWeight="Bold" VerticalAlignment="Center"/>
|
||||||
|
<Button Content="Refresh" Command="{Binding RefreshLogContentCommand}"
|
||||||
|
MinWidth="60" Padding="5,2" Margin="10,0,0,0"/>
|
||||||
|
<Button Content="Open in Explorer" Command="{Binding OpenLogInExplorerCommand}"
|
||||||
|
MinWidth="100" Padding="5,2"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBox Grid.Row="1"
|
||||||
|
Text="{Binding LogContent, Mode=OneWay}"
|
||||||
|
IsReadOnly="True"
|
||||||
|
FontFamily="Consolas"
|
||||||
|
FontSize="11"
|
||||||
|
VerticalScrollBarVisibility="Auto"
|
||||||
|
HorizontalScrollBarVisibility="Auto"
|
||||||
|
TextWrapping="NoWrap"
|
||||||
|
AcceptsReturn="True"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</GroupBox>
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
<!-- Settings Tab -->
|
||||||
|
<TabItem Header="Settings">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel Margin="5">
|
||||||
|
<GroupBox Header="Service Control">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3" Margin="0,0,0,8"
|
||||||
|
Text="{Binding AppSettingsLoadError}"
|
||||||
|
Visibility="{Binding AppSettingsLoadErrorVisibility}"
|
||||||
|
Foreground="DarkRed" TextWrapping="Wrap"/>
|
||||||
|
|
||||||
|
<Grid Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" Margin="0,0,0,8">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0" Text="Service Bin Path:" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="Bold"/>
|
||||||
|
<TextBox Grid.Column="1" Text="{Binding ServiceBinPath, UpdateSourceTrigger=PropertyChanged}" VerticalAlignment="Center"/>
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Margin="5,0,0,0">
|
||||||
|
<Button Content="..." Command="{Binding BrowseServiceBinPathCommand}" MinWidth="30" Padding="5,2"/>
|
||||||
|
<Button Content="Reload" Command="{Binding ReloadAppSettingsCommand}" MinWidth="50" Padding="5,2"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="2" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="Service: " FontWeight="Bold"/>
|
||||||
|
<TextBlock Text="{Binding ServiceName}" Margin="0,0,20,0"/>
|
||||||
|
<TextBlock Text="Status: " FontWeight="Bold"/>
|
||||||
|
<Ellipse Width="12" Height="12" Margin="0,0,5,0" VerticalAlignment="Center">
|
||||||
|
<Ellipse.Style>
|
||||||
|
<Style TargetType="Ellipse">
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding ServiceStatusColor}" Value="Green"><Setter Property="Fill" Value="LimeGreen"/></DataTrigger>
|
||||||
|
<DataTrigger Binding="{Binding ServiceStatusColor}" Value="Red"><Setter Property="Fill" Value="Red"/></DataTrigger>
|
||||||
|
<DataTrigger Binding="{Binding ServiceStatusColor}" Value="Orange"><Setter Property="Fill" Value="Orange"/></DataTrigger>
|
||||||
|
<DataTrigger Binding="{Binding ServiceStatusColor}" Value="Gray"><Setter Property="Fill" Value="Gray"/></DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Ellipse.Style>
|
||||||
|
</Ellipse>
|
||||||
|
<TextBlock Text="{Binding ServiceStatusText}" FontWeight="SemiBold"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="2" Grid.Column="2" Orientation="Horizontal">
|
||||||
|
<Button Content="Register" Command="{Binding RegisterServiceCommand}" IsEnabled="{Binding IsAdmin}"/>
|
||||||
|
<Button Content="Unregister" Command="{Binding UnregisterServiceCommand}" IsEnabled="{Binding IsAdmin}"/>
|
||||||
|
<Separator Style="{StaticResource {x:Static ToolBar.SeparatorStyleKey}}" Margin="10,0"/>
|
||||||
|
<Button Content="Start" Command="{Binding StartServiceCommand}" IsEnabled="{Binding IsAdmin}"/>
|
||||||
|
<Button Content="Stop" Command="{Binding StopServiceCommand}" IsEnabled="{Binding IsAdmin}"/>
|
||||||
|
<Separator Style="{StaticResource {x:Static ToolBar.SeparatorStyleKey}}" Margin="10,0"/>
|
||||||
|
<Button Content="Refresh" Command="{Binding RefreshServiceStatusCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</GroupBox>
|
||||||
|
|
||||||
|
<GroupBox Header="Log Path" Margin="0,10,0,0">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="Log Directory:" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="Bold"/>
|
||||||
|
<TextBox Text="{Binding LogDirectory, UpdateSourceTrigger=PropertyChanged}" MinWidth="300" VerticalAlignment="Center" IsReadOnly="True" Background="#F0F0F0"/>
|
||||||
|
<Button Content="Open" Command="{Binding OpenLogDirectoryCommand}" Margin="5,0" MinWidth="50"/>
|
||||||
|
</StackPanel>
|
||||||
|
</GroupBox>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</TabItem>
|
||||||
|
</TabControl>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
20
src/MaksIT.UScheduler.ScheduleManager/MainWindow.xaml.cs
Normal file
20
src/MaksIT.UScheduler.ScheduleManager/MainWindow.xaml.cs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using MaksIT.UScheduler.ScheduleManager.ViewModels;
|
||||||
|
|
||||||
|
namespace MaksIT.UScheduler.ScheduleManager;
|
||||||
|
|
||||||
|
public partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnScheduleChanged(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is MainViewModel vm)
|
||||||
|
{
|
||||||
|
vm.OnMonthCheckedChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net10.0-windows</TargetFramework>
|
||||||
|
<Version>1.0.1</Version>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UseWPF>true</UseWPF>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MaksIT.UScheduler.Shared\MaksIT.UScheduler.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="appsettings.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
namespace MaksIT.UScheduler.ScheduleManager.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the schedule configuration from a scriptsettings.json file.
|
||||||
|
/// </summary>
|
||||||
|
public class ScriptSchedule
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Full path to the scriptsettings.json file.
|
||||||
|
/// </summary>
|
||||||
|
public string FilePath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Display name for the script in the UI (from JSON "name" or "title", or derived from path).
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Script title from the JSON "title" field.
|
||||||
|
/// </summary>
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Array of month names to run (empty = every month).
|
||||||
|
/// Valid values: January, February, March, April, May, June, July, August, September, October, November, December
|
||||||
|
/// </summary>
|
||||||
|
public List<string> RunMonth { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Array of weekday names to run (empty = every day).
|
||||||
|
/// Valid values: Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday
|
||||||
|
/// </summary>
|
||||||
|
public List<string> RunWeekday { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Array of UTC times in HH:mm format when script should run.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> RunTime { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum minutes between runs to prevent duplicate executions.
|
||||||
|
/// </summary>
|
||||||
|
public int MinIntervalMinutes { get; set; } = 10;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Script path as stored in service appsettings (e.g. ".\file-sync.ps1"). Used to find and update the config entry.
|
||||||
|
/// </summary>
|
||||||
|
public string ConfigScriptPath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the script must be digitally signed (from service Configuration.Powershell).
|
||||||
|
/// </summary>
|
||||||
|
public bool IsSigned { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the script is disabled and will not be executed (from service Configuration.Powershell).
|
||||||
|
/// </summary>
|
||||||
|
public bool Disabled { get; set; }
|
||||||
|
}
|
||||||
49
src/MaksIT.UScheduler.ScheduleManager/Models/ScriptStatus.cs
Normal file
49
src/MaksIT.UScheduler.ScheduleManager/Models/ScriptStatus.cs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
namespace MaksIT.UScheduler.ScheduleManager.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the runtime status of a script (lock file, last run).
|
||||||
|
/// </summary>
|
||||||
|
public class ScriptStatus
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Path to the script's .ps1 file.
|
||||||
|
/// </summary>
|
||||||
|
public string ScriptPath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Script name (derived from folder name).
|
||||||
|
/// </summary>
|
||||||
|
public string ScriptName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether a lock file exists (script is currently running or was interrupted).
|
||||||
|
/// </summary>
|
||||||
|
public bool HasLockFile { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path to the lock file.
|
||||||
|
/// </summary>
|
||||||
|
public string LockFilePath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Last execution time (from .lastRun file).
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? LastRun { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path to the last run file.
|
||||||
|
/// </summary>
|
||||||
|
public string LastRunFilePath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formatted last run time for display.
|
||||||
|
/// </summary>
|
||||||
|
public string LastRunDisplay => LastRun.HasValue
|
||||||
|
? LastRun.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")
|
||||||
|
: "Never";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Status indicator for display.
|
||||||
|
/// </summary>
|
||||||
|
public string StatusDisplay => HasLockFile ? "Locked" : "Ready";
|
||||||
|
}
|
||||||
@ -0,0 +1,206 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using MaksIT.UScheduler.Shared;
|
||||||
|
using MaksIT.UScheduler.Shared.Helpers;
|
||||||
|
|
||||||
|
namespace MaksIT.UScheduler.ScheduleManager.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for loading and managing appsettings.json from UScheduler.
|
||||||
|
/// </summary>
|
||||||
|
public class AppSettingsService
|
||||||
|
{
|
||||||
|
private string? _appSettingsPath;
|
||||||
|
private JsonNode? _rootDocument;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the path to the appsettings.json file.
|
||||||
|
/// </summary>
|
||||||
|
public string? AppSettingsPath => _appSettingsPath;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the directory containing appsettings.json (the service directory).
|
||||||
|
/// </summary>
|
||||||
|
public string? ServiceDirectory => string.IsNullOrEmpty(_appSettingsPath)
|
||||||
|
? null
|
||||||
|
: System.IO.Path.GetDirectoryName(_appSettingsPath);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads appsettings.json from the specified path.
|
||||||
|
/// Returns null if path is null/empty or the file does not exist.
|
||||||
|
/// </summary>
|
||||||
|
public Configuration? LoadAppSettings(string? appSettingsPath = null)
|
||||||
|
{
|
||||||
|
_appSettingsPath = appSettingsPath;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(_appSettingsPath) || !File.Exists(_appSettingsPath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(_appSettingsPath);
|
||||||
|
_rootDocument = JsonNode.Parse(json);
|
||||||
|
|
||||||
|
if (_rootDocument == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var configNode = _rootDocument["Configuration"];
|
||||||
|
if (configNode == null)
|
||||||
|
return new Configuration { LogDir = ".\\Logs" };
|
||||||
|
|
||||||
|
var config = new Configuration
|
||||||
|
{
|
||||||
|
ServiceName = configNode["ServiceName"]?.GetValue<string>() ?? "MaksIT.UScheduler",
|
||||||
|
LogDir = configNode["LogDir"]?.GetValue<string>() ?? ".\\Logs"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse PowerShell scripts
|
||||||
|
if (configNode["Powershell"] is JsonArray psArray)
|
||||||
|
{
|
||||||
|
foreach (var item in psArray)
|
||||||
|
{
|
||||||
|
if (item == null) continue;
|
||||||
|
config.Powershell.Add(new PowershellScript
|
||||||
|
{
|
||||||
|
Path = item["Path"]?.GetValue<string>() ?? string.Empty,
|
||||||
|
Name = item["Name"]?.GetValue<string>(),
|
||||||
|
IsSigned = item["IsSigned"]?.GetValue<bool>() ?? true,
|
||||||
|
Disabled = item["Disabled"]?.GetValue<bool>() ?? false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse Processes
|
||||||
|
if (configNode["Processes"] is JsonArray procArray)
|
||||||
|
{
|
||||||
|
foreach (var item in procArray)
|
||||||
|
{
|
||||||
|
if (item == null) continue;
|
||||||
|
var proc = new ProcessConfiguration
|
||||||
|
{
|
||||||
|
Path = item["Path"]?.GetValue<string>() ?? string.Empty,
|
||||||
|
RestartOnFailure = item["RestartOnFailure"]?.GetValue<bool>() ?? false,
|
||||||
|
Disabled = item["Disabled"]?.GetValue<bool>() ?? false
|
||||||
|
};
|
||||||
|
|
||||||
|
if (item["Args"] is JsonArray argsArray)
|
||||||
|
{
|
||||||
|
proc.Args = argsArray.Select(a => a?.GetValue<string>() ?? "").ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Processes.Add(proc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the resolved log directory path from the service config.
|
||||||
|
/// LogDir may be relative (e.g. ".\Logs"); it is resolved against the service directory, same as script paths.
|
||||||
|
/// </summary>
|
||||||
|
public string GetLogDirectory(Configuration? config)
|
||||||
|
{
|
||||||
|
if (config == null || string.IsNullOrEmpty(ServiceDirectory))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(config.LogDir))
|
||||||
|
return ResolvePathRelativeToService(config.LogDir);
|
||||||
|
|
||||||
|
// Default: Logs folder in service directory
|
||||||
|
return System.IO.Path.Combine(ServiceDirectory, "Logs");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the service executable path.
|
||||||
|
/// </summary>
|
||||||
|
public string GetExecutablePath()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(ServiceDirectory))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var exePath = System.IO.Path.Combine(ServiceDirectory, "MaksIT.UScheduler.exe");
|
||||||
|
return File.Exists(exePath) ? exePath : string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the LogDir value from MaksIT.UScheduler's appsettings.json at the given path.
|
||||||
|
/// The path should be the full path to appsettings.json (e.g. ServiceBinPath + "appsettings.json").
|
||||||
|
/// Returns null if the file doesn't exist or Configuration.LogDir is missing.
|
||||||
|
/// </summary>
|
||||||
|
public static string? GetLogDirFromAppSettingsFile(string appSettingsFilePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(appSettingsFilePath) || !File.Exists(appSettingsFilePath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(appSettingsFilePath);
|
||||||
|
var doc = JsonNode.Parse(json);
|
||||||
|
var configNode = doc?["Configuration"];
|
||||||
|
return configNode?["LogDir"]?.GetValue<string>();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates Name, IsSigned and Disabled for a PowerShell script entry in appsettings and saves the file.
|
||||||
|
/// The script is identified by its config path (e.g. ".\file-sync.ps1").
|
||||||
|
/// Pass null for name to leave the existing Name value unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public bool UpdatePowershellScriptEntry(string configScriptPath, string? name, bool isSigned, bool disabled)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_appSettingsPath) || _rootDocument == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var configNode = _rootDocument["Configuration"];
|
||||||
|
if (configNode?["Powershell"] is not JsonArray psArray)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
for (var i = 0; i < psArray.Count; i++)
|
||||||
|
{
|
||||||
|
if (psArray[i] is not JsonObject item)
|
||||||
|
continue;
|
||||||
|
var path = item["Path"]?.GetValue<string>();
|
||||||
|
if (path != configScriptPath)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (name != null)
|
||||||
|
item["Name"] = name;
|
||||||
|
item["IsSigned"] = isSigned;
|
||||||
|
item["Disabled"] = disabled;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var options = new JsonWriterOptions { Indented = true };
|
||||||
|
using var stream = File.Create(_appSettingsPath);
|
||||||
|
using var writer = new Utf8JsonWriter(stream, options);
|
||||||
|
_rootDocument.WriteTo(writer);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a path relative to the service directory (where appsettings.json is located).
|
||||||
|
/// If the path is already absolute, returns it as-is.
|
||||||
|
/// </summary>
|
||||||
|
public string ResolvePathRelativeToService(string path) =>
|
||||||
|
PathHelper.ResolvePath(path, ServiceDirectory ?? string.Empty);
|
||||||
|
}
|
||||||
@ -0,0 +1,259 @@
|
|||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace MaksIT.UScheduler.ScheduleManager.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a log file with metadata.
|
||||||
|
/// </summary>
|
||||||
|
public class LogFileInfo
|
||||||
|
{
|
||||||
|
public string FileName { get; set; } = string.Empty;
|
||||||
|
public string FullPath { get; set; } = string.Empty;
|
||||||
|
public DateTime LastModified { get; set; }
|
||||||
|
public long SizeBytes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Script folder name for script logs (e.g. file-sync); empty for service logs.</summary>
|
||||||
|
public string ScriptFolder { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string SizeDisplay => SizeBytes switch
|
||||||
|
{
|
||||||
|
< 1024 => $"{SizeBytes} B",
|
||||||
|
< 1024 * 1024 => $"{SizeBytes / 1024.0:F1} KB",
|
||||||
|
_ => $"{SizeBytes / (1024.0 * 1024.0):F1} MB"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Display label for list: script folder + file name for script logs, else file name.</summary>
|
||||||
|
public string DisplayLabel => string.IsNullOrEmpty(ScriptFolder) ? FileName : $"{ScriptFolder} \\ {FileName}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for viewing log files.
|
||||||
|
/// </summary>
|
||||||
|
public class LogViewerService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all log files from the specified log directory.
|
||||||
|
/// </summary>
|
||||||
|
public List<LogFileInfo> GetServiceLogs(string logDir)
|
||||||
|
{
|
||||||
|
var logs = new List<LogFileInfo>();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(logDir) || !Directory.Exists(logDir))
|
||||||
|
return logs;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get log files from root directory (service logs)
|
||||||
|
var rootLogs = Directory.GetFiles(logDir, "*.log", SearchOption.TopDirectoryOnly);
|
||||||
|
foreach (var file in rootLogs)
|
||||||
|
{
|
||||||
|
logs.Add(CreateLogFileInfo(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check for .txt log files
|
||||||
|
var txtLogs = Directory.GetFiles(logDir, "*.txt", SearchOption.TopDirectoryOnly);
|
||||||
|
foreach (var file in txtLogs)
|
||||||
|
{
|
||||||
|
logs.Add(CreateLogFileInfo(file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore access errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs.OrderByDescending(l => l.LastModified).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the names of all script log subfolders under logDir (e.g. file-sync, hyper-v-backup).
|
||||||
|
/// </summary>
|
||||||
|
public List<string> GetScriptLogFolderNames(string logDir)
|
||||||
|
{
|
||||||
|
var names = new List<string>();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(logDir))
|
||||||
|
return names;
|
||||||
|
|
||||||
|
var logDirFull = Path.GetFullPath(logDir);
|
||||||
|
if (!Directory.Exists(logDirFull))
|
||||||
|
return names;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var subDir in Directory.GetDirectories(logDirFull))
|
||||||
|
{
|
||||||
|
var name = Path.GetFileName(subDir);
|
||||||
|
if (!string.IsNullOrEmpty(name))
|
||||||
|
names.Add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore access errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return names.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets log files from all script subfolders under logDir (e.g. logs\file-sync, logs\hyper-v-backup).
|
||||||
|
/// Does not require a script to be selected. Each subfolder of logDir is treated as a script log folder.
|
||||||
|
/// </summary>
|
||||||
|
public List<LogFileInfo> GetAllScriptLogs(string logDir)
|
||||||
|
{
|
||||||
|
var logs = new List<LogFileInfo>();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(logDir))
|
||||||
|
return logs;
|
||||||
|
|
||||||
|
var logDirFull = Path.GetFullPath(logDir);
|
||||||
|
if (!Directory.Exists(logDirFull))
|
||||||
|
return logs;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var subDir in Directory.GetDirectories(logDirFull))
|
||||||
|
{
|
||||||
|
var scriptFolder = Path.GetFileName(subDir) ?? string.Empty;
|
||||||
|
var logFiles = Directory.GetFiles(subDir, "*.*", SearchOption.TopDirectoryOnly)
|
||||||
|
.Where(f => f.EndsWith(".log", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
f.EndsWith(".txt", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
foreach (var file in logFiles)
|
||||||
|
{
|
||||||
|
var info = CreateLogFileInfo(file);
|
||||||
|
info.ScriptFolder = scriptFolder;
|
||||||
|
logs.Add(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore access errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs.OrderByDescending(l => l.LastModified).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets log files for a specific script. Script logs live under LogDir in a subfolder
|
||||||
|
/// named by the script filename without extension (from Configuration.Powershell[].Path).
|
||||||
|
/// E.g. Path=...\file-sync.ps1, LogDir=.\logs → .\logs\file-sync. Tries candidate names, case-insensitive.
|
||||||
|
/// </summary>
|
||||||
|
public List<LogFileInfo> GetScriptLogs(string logDir, string scriptName)
|
||||||
|
{
|
||||||
|
var logs = new List<LogFileInfo>();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(logDir) || string.IsNullOrEmpty(scriptName))
|
||||||
|
return logs;
|
||||||
|
|
||||||
|
var logDirFull = Path.GetFullPath(logDir);
|
||||||
|
if (!Directory.Exists(logDirFull))
|
||||||
|
return logs;
|
||||||
|
|
||||||
|
var scriptLogDir = FindScriptLogSubfolder(logDirFull, scriptName);
|
||||||
|
if (string.IsNullOrEmpty(scriptLogDir))
|
||||||
|
return logs;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var logFiles = Directory.GetFiles(scriptLogDir, "*.*", SearchOption.TopDirectoryOnly)
|
||||||
|
.Where(f => f.EndsWith(".log", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
f.EndsWith(".txt", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
foreach (var file in logFiles)
|
||||||
|
{
|
||||||
|
logs.Add(CreateLogFileInfo(file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore access errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs.OrderByDescending(l => l.LastModified).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds the script log subfolder under logDir: exact scriptName first, then case-insensitive match.
|
||||||
|
/// </summary>
|
||||||
|
private static string? FindScriptLogSubfolder(string logDir, string scriptName)
|
||||||
|
{
|
||||||
|
var exact = Path.Combine(logDir, scriptName);
|
||||||
|
if (Directory.Exists(exact))
|
||||||
|
return exact;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var subDir in Directory.GetDirectories(logDir))
|
||||||
|
{
|
||||||
|
var name = Path.GetFileName(subDir);
|
||||||
|
if (string.Equals(name, scriptName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return subDir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore access errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the content of a log file.
|
||||||
|
/// </summary>
|
||||||
|
public string ReadLogFile(string filePath, int maxLines = 1000)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
return "Log file not found.";
|
||||||
|
|
||||||
|
var lines = File.ReadLines(filePath).TakeLast(maxLines).ToList();
|
||||||
|
|
||||||
|
if (lines.Count == maxLines)
|
||||||
|
{
|
||||||
|
return $"[Showing last {maxLines} lines...]\n\n" + string.Join("\n", lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join("\n", lines);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"Error reading log file: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the tail of a log file (last N lines).
|
||||||
|
/// </summary>
|
||||||
|
public string ReadLogTail(string filePath, int lines = 100)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
return "Log file not found.";
|
||||||
|
|
||||||
|
var allLines = File.ReadLines(filePath).TakeLast(lines).ToList();
|
||||||
|
return string.Join("\n", allLines);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"Error reading log file: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LogFileInfo CreateLogFileInfo(string filePath)
|
||||||
|
{
|
||||||
|
var fileInfo = new FileInfo(filePath);
|
||||||
|
return new LogFileInfo
|
||||||
|
{
|
||||||
|
FileName = fileInfo.Name,
|
||||||
|
FullPath = filePath,
|
||||||
|
LastModified = fileInfo.LastWriteTime,
|
||||||
|
SizeBytes = fileInfo.Length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,139 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using MaksIT.UScheduler.ScheduleManager.Models;
|
||||||
|
|
||||||
|
namespace MaksIT.UScheduler.ScheduleManager.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for loading and saving scriptsettings.json files.
|
||||||
|
/// </summary>
|
||||||
|
public class ScriptSettingsService
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scans a directory for scriptsettings.json files and loads their schedule configurations.
|
||||||
|
/// </summary>
|
||||||
|
public List<ScriptSchedule> LoadScriptsFromDirectory(string examplesPath)
|
||||||
|
{
|
||||||
|
var schedules = new List<ScriptSchedule>();
|
||||||
|
|
||||||
|
if (!Directory.Exists(examplesPath))
|
||||||
|
return schedules;
|
||||||
|
|
||||||
|
var settingsFiles = Directory.GetFiles(examplesPath, "scriptsettings.json", SearchOption.AllDirectories);
|
||||||
|
|
||||||
|
foreach (var filePath in settingsFiles)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var schedule = LoadScheduleFromFile(filePath);
|
||||||
|
if (schedule != null)
|
||||||
|
schedules.Add(schedule);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Skip files that can't be parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedules;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the schedule configuration from a single scriptsettings.json file.
|
||||||
|
/// </summary>
|
||||||
|
public ScriptSchedule? LoadScheduleFromFile(string filePath)
|
||||||
|
{
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var json = File.ReadAllText(filePath);
|
||||||
|
var doc = JsonNode.Parse(json);
|
||||||
|
|
||||||
|
if (doc == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var title = doc["title"]?.GetValue<string>() ?? Path.GetDirectoryName(filePath) ?? "Unknown";
|
||||||
|
var name = doc["name"]?.GetValue<string>();
|
||||||
|
// Use name from JSON, or title with " Script Settings" suffix stripped for listbox display
|
||||||
|
var displayName = !string.IsNullOrWhiteSpace(name)
|
||||||
|
? name
|
||||||
|
: StripScriptSettingsSuffix(title);
|
||||||
|
var schedule = new ScriptSchedule
|
||||||
|
{
|
||||||
|
FilePath = filePath,
|
||||||
|
Name = displayName,
|
||||||
|
Title = title
|
||||||
|
};
|
||||||
|
|
||||||
|
var scheduleNode = doc["schedule"];
|
||||||
|
if (scheduleNode != null)
|
||||||
|
{
|
||||||
|
schedule.RunMonth = GetStringArray(scheduleNode["runMonth"]);
|
||||||
|
schedule.RunWeekday = GetStringArray(scheduleNode["runWeekday"]);
|
||||||
|
schedule.RunTime = GetStringArray(scheduleNode["runTime"]);
|
||||||
|
schedule.MinIntervalMinutes = scheduleNode["minIntervalMinutes"]?.GetValue<int>() ?? 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves only the schedule section back to the scriptsettings.json file,
|
||||||
|
/// preserving all other content.
|
||||||
|
/// </summary>
|
||||||
|
public void SaveScheduleToFile(ScriptSchedule schedule)
|
||||||
|
{
|
||||||
|
if (!File.Exists(schedule.FilePath))
|
||||||
|
throw new FileNotFoundException("Script settings file not found.", schedule.FilePath);
|
||||||
|
|
||||||
|
var json = File.ReadAllText(schedule.FilePath);
|
||||||
|
var doc = JsonNode.Parse(json);
|
||||||
|
|
||||||
|
if (doc == null)
|
||||||
|
throw new InvalidOperationException("Failed to parse JSON file.");
|
||||||
|
|
||||||
|
// Update or create the schedule section
|
||||||
|
var scheduleNode = new JsonObject
|
||||||
|
{
|
||||||
|
["runMonth"] = new JsonArray(schedule.RunMonth.Select(m => JsonValue.Create(m)).ToArray()),
|
||||||
|
["runWeekday"] = new JsonArray(schedule.RunWeekday.Select(d => JsonValue.Create(d)).ToArray()),
|
||||||
|
["runTime"] = new JsonArray(schedule.RunTime.Select(t => JsonValue.Create(t)).ToArray()),
|
||||||
|
["minIntervalMinutes"] = schedule.MinIntervalMinutes
|
||||||
|
};
|
||||||
|
|
||||||
|
doc["schedule"] = scheduleNode;
|
||||||
|
|
||||||
|
// Write back with proper formatting
|
||||||
|
var options = new JsonWriterOptions { Indented = true };
|
||||||
|
using var stream = File.Create(schedule.FilePath);
|
||||||
|
using var writer = new Utf8JsonWriter(stream, options);
|
||||||
|
doc.WriteTo(writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Strips " Script Settings" (case-insensitive) from the end of a title for listbox display.</summary>
|
||||||
|
private static string StripScriptSettingsSuffix(string title)
|
||||||
|
{
|
||||||
|
const string suffix = " Script Settings";
|
||||||
|
if (title.Length > suffix.Length && title.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return title[..^suffix.Length].TrimEnd();
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> GetStringArray(JsonNode? node)
|
||||||
|
{
|
||||||
|
if (node is not JsonArray array)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return array
|
||||||
|
.Where(item => item != null)
|
||||||
|
.Select(item => item!.GetValue<string>())
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
using System.IO;
|
||||||
|
using MaksIT.UScheduler.ScheduleManager.Models;
|
||||||
|
|
||||||
|
namespace MaksIT.UScheduler.ScheduleManager.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for managing script status files (lock files, last run).
|
||||||
|
/// </summary>
|
||||||
|
public class ScriptStatusService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the status of a script from its scriptsettings.json path.
|
||||||
|
/// </summary>
|
||||||
|
public ScriptStatus GetScriptStatus(string scriptSettingsPath)
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(scriptSettingsPath) ?? string.Empty;
|
||||||
|
var scriptName = Path.GetFileName(directory);
|
||||||
|
|
||||||
|
// Find the .ps1 file in the same directory
|
||||||
|
var scriptFiles = Directory.GetFiles(directory, "*.ps1", SearchOption.TopDirectoryOnly);
|
||||||
|
var scriptPath = scriptFiles.FirstOrDefault() ?? string.Empty;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(scriptPath))
|
||||||
|
{
|
||||||
|
return new ScriptStatus
|
||||||
|
{
|
||||||
|
ScriptPath = string.Empty,
|
||||||
|
ScriptName = scriptName,
|
||||||
|
HasLockFile = false,
|
||||||
|
LastRun = null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var lockFilePath = Path.ChangeExtension(scriptPath, ".lock");
|
||||||
|
var lastRunFilePath = Path.ChangeExtension(scriptPath, ".lastRun");
|
||||||
|
|
||||||
|
return new ScriptStatus
|
||||||
|
{
|
||||||
|
ScriptPath = scriptPath,
|
||||||
|
ScriptName = scriptName,
|
||||||
|
HasLockFile = File.Exists(lockFilePath),
|
||||||
|
LockFilePath = lockFilePath,
|
||||||
|
LastRun = GetLastRunTime(lastRunFilePath),
|
||||||
|
LastRunFilePath = lastRunFilePath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the lock file for a script.
|
||||||
|
/// </summary>
|
||||||
|
public bool RemoveLockFile(string lockFilePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(lockFilePath))
|
||||||
|
{
|
||||||
|
File.Delete(lockFilePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the last run time from the .lastRun file.
|
||||||
|
/// </summary>
|
||||||
|
private static DateTime? GetLastRunTime(string lastRunFilePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(lastRunFilePath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var content = File.ReadAllText(lastRunFilePath).Trim();
|
||||||
|
if (DateTime.TryParse(
|
||||||
|
content,
|
||||||
|
null,
|
||||||
|
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal,
|
||||||
|
out var lastRun))
|
||||||
|
{
|
||||||
|
return lastRun;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using MaksIT.UScheduler.Shared.Helpers;
|
||||||
|
|
||||||
|
namespace MaksIT.UScheduler.ScheduleManager.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UI-specific settings stored in the ScheduleManager's appsettings.json.
|
||||||
|
/// </summary>
|
||||||
|
public class UISettings
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Path to the MaksIT.UScheduler bin folder containing appsettings.json and MaksIT.UScheduler.exe.
|
||||||
|
/// </summary>
|
||||||
|
public string ServiceBinPath { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for loading and saving the ScheduleManager's own appsettings.json.
|
||||||
|
/// </summary>
|
||||||
|
public class UISettingsService
|
||||||
|
{
|
||||||
|
private readonly string _settingsFilePath;
|
||||||
|
|
||||||
|
public UISettingsService()
|
||||||
|
{
|
||||||
|
_settingsFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "appsettings.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the UI settings from appsettings.json.
|
||||||
|
/// </summary>
|
||||||
|
public UISettings Load()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(_settingsFilePath))
|
||||||
|
return new UISettings();
|
||||||
|
|
||||||
|
var json = File.ReadAllText(_settingsFilePath);
|
||||||
|
var doc = JsonNode.Parse(json);
|
||||||
|
|
||||||
|
if (doc == null)
|
||||||
|
return new UISettings();
|
||||||
|
|
||||||
|
var settingsNode = doc["USchedulerSettings"];
|
||||||
|
if (settingsNode == null)
|
||||||
|
return new UISettings();
|
||||||
|
|
||||||
|
return new UISettings
|
||||||
|
{
|
||||||
|
ServiceBinPath = settingsNode["ServiceBinPath"]?.GetValue<string>() ?? string.Empty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new UISettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves the UI settings to appsettings.json.
|
||||||
|
/// </summary>
|
||||||
|
public void Save(UISettings settings)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
JsonNode? doc;
|
||||||
|
|
||||||
|
// Load existing file or create new
|
||||||
|
if (File.Exists(_settingsFilePath))
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(_settingsFilePath);
|
||||||
|
doc = JsonNode.Parse(json) ?? new JsonObject();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
doc = new JsonObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the USchedulerSettings section
|
||||||
|
doc["USchedulerSettings"] = new JsonObject
|
||||||
|
{
|
||||||
|
["ServiceBinPath"] = settings.ServiceBinPath
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write back
|
||||||
|
var options = new JsonWriterOptions { Indented = true };
|
||||||
|
using var stream = File.Create(_settingsFilePath);
|
||||||
|
using var writer = new Utf8JsonWriter(stream, options);
|
||||||
|
doc.WriteTo(writer);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore save errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the path to the UI's appsettings.json file.
|
||||||
|
/// </summary>
|
||||||
|
public string SettingsFilePath => _settingsFilePath;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a path relative to the application's base directory.
|
||||||
|
/// If the path is already absolute, returns it as-is.
|
||||||
|
/// </summary>
|
||||||
|
public static string ResolvePath(string path) => PathHelper.ResolvePath(path);
|
||||||
|
}
|
||||||
@ -0,0 +1,239 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace MaksIT.UScheduler.ScheduleManager.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service status enumeration.
|
||||||
|
/// </summary>
|
||||||
|
public enum ServiceStatus
|
||||||
|
{
|
||||||
|
NotInstalled,
|
||||||
|
Stopped,
|
||||||
|
StartPending,
|
||||||
|
StopPending,
|
||||||
|
Running,
|
||||||
|
ContinuePending,
|
||||||
|
PausePending,
|
||||||
|
Paused,
|
||||||
|
Unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of a service operation.
|
||||||
|
/// </summary>
|
||||||
|
public record ServiceOperationResult(bool Success, string Message);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages Windows service operations for MaksIT.UScheduler by calling the executable with CLI args.
|
||||||
|
/// </summary>
|
||||||
|
public class WindowsServiceManager
|
||||||
|
{
|
||||||
|
private const string DefaultServiceName = "MaksIT.UScheduler";
|
||||||
|
|
||||||
|
public string ServiceName { get; }
|
||||||
|
|
||||||
|
public WindowsServiceManager(string? serviceName = null)
|
||||||
|
{
|
||||||
|
ServiceName = serviceName ?? DefaultServiceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the current status of the service by running --status command.
|
||||||
|
/// </summary>
|
||||||
|
public ServiceStatus GetStatus(string? executablePath = null)
|
||||||
|
{
|
||||||
|
// Try using sc.exe query directly for status
|
||||||
|
var result = RunScCommand($"query \"{ServiceName}\"");
|
||||||
|
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
// Service might not exist
|
||||||
|
if (result.Message.Contains("1060") || result.Message.Contains("does not exist"))
|
||||||
|
return ServiceStatus.NotInstalled;
|
||||||
|
|
||||||
|
return ServiceStatus.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse state from sc.exe output. Use numeric state code (locale-independent);
|
||||||
|
// sc query shows e.g. "STATE : 4 RUNNING" (text may be localized).
|
||||||
|
var match = Regex.Match(result.Message, @"STATE\s*:\s*(\d+)", RegexOptions.IgnoreCase);
|
||||||
|
if (match.Success && int.TryParse(match.Groups[1].Value, out var state))
|
||||||
|
{
|
||||||
|
return state switch
|
||||||
|
{
|
||||||
|
1 => ServiceStatus.Stopped,
|
||||||
|
2 => ServiceStatus.StartPending,
|
||||||
|
3 => ServiceStatus.StopPending,
|
||||||
|
4 => ServiceStatus.Running,
|
||||||
|
5 => ServiceStatus.ContinuePending,
|
||||||
|
6 => ServiceStatus.PausePending,
|
||||||
|
7 => ServiceStatus.Paused,
|
||||||
|
_ => ServiceStatus.Unknown
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: text match (English)
|
||||||
|
var output = result.Message.ToUpperInvariant();
|
||||||
|
if (output.Contains("RUNNING")) return ServiceStatus.Running;
|
||||||
|
if (output.Contains("STOPPED")) return ServiceStatus.Stopped;
|
||||||
|
if (output.Contains("START_PENDING")) return ServiceStatus.StartPending;
|
||||||
|
if (output.Contains("STOP_PENDING")) return ServiceStatus.StopPending;
|
||||||
|
if (output.Contains("PAUSED")) return ServiceStatus.Paused;
|
||||||
|
if (output.Contains("PAUSE_PENDING")) return ServiceStatus.PausePending;
|
||||||
|
if (output.Contains("CONTINUE_PENDING")) return ServiceStatus.ContinuePending;
|
||||||
|
|
||||||
|
return ServiceStatus.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the service using the executable's --start command.
|
||||||
|
/// </summary>
|
||||||
|
public ServiceOperationResult Start(string? executablePath = null)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(executablePath) && File.Exists(executablePath))
|
||||||
|
{
|
||||||
|
return RunExecutable(executablePath, "--start");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to sc.exe
|
||||||
|
return RunScCommand($"start \"{ServiceName}\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the service using the executable's --stop command.
|
||||||
|
/// </summary>
|
||||||
|
public ServiceOperationResult Stop(string? executablePath = null)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(executablePath) && File.Exists(executablePath))
|
||||||
|
{
|
||||||
|
return RunExecutable(executablePath, "--stop");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to sc.exe
|
||||||
|
return RunScCommand($"stop \"{ServiceName}\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers (installs) the service using the executable's --install command.
|
||||||
|
/// </summary>
|
||||||
|
public ServiceOperationResult Register(string executablePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(executablePath))
|
||||||
|
return new ServiceOperationResult(false, "Executable path is required.");
|
||||||
|
|
||||||
|
if (!File.Exists(executablePath))
|
||||||
|
return new ServiceOperationResult(false, $"Executable not found: {executablePath}");
|
||||||
|
|
||||||
|
var status = GetStatus();
|
||||||
|
if (status != ServiceStatus.NotInstalled)
|
||||||
|
return new ServiceOperationResult(false, "Service is already installed.");
|
||||||
|
|
||||||
|
return RunExecutable(executablePath, "--install");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unregisters (removes) the service using the executable's --uninstall command.
|
||||||
|
/// </summary>
|
||||||
|
public ServiceOperationResult Unregister(string? executablePath = null)
|
||||||
|
{
|
||||||
|
var status = GetStatus();
|
||||||
|
|
||||||
|
if (status == ServiceStatus.NotInstalled)
|
||||||
|
return new ServiceOperationResult(false, "Service is not installed.");
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(executablePath) && File.Exists(executablePath))
|
||||||
|
{
|
||||||
|
return RunExecutable(executablePath, "--uninstall");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: stop first, then delete via sc.exe
|
||||||
|
if (status == ServiceStatus.Running)
|
||||||
|
{
|
||||||
|
RunScCommand($"stop \"{ServiceName}\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
return RunScCommand($"delete \"{ServiceName}\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the UScheduler executable with the specified arguments.
|
||||||
|
/// </summary>
|
||||||
|
private static ServiceOperationResult RunExecutable(string executablePath, string arguments)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = executablePath,
|
||||||
|
Arguments = arguments,
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
WorkingDirectory = Path.GetDirectoryName(executablePath) ?? ""
|
||||||
|
};
|
||||||
|
|
||||||
|
using var process = Process.Start(startInfo);
|
||||||
|
if (process == null)
|
||||||
|
return new ServiceOperationResult(false, "Failed to start process.");
|
||||||
|
|
||||||
|
var output = process.StandardOutput.ReadToEnd();
|
||||||
|
var error = process.StandardError.ReadToEnd();
|
||||||
|
process.WaitForExit(30000); // 30 second timeout
|
||||||
|
|
||||||
|
var message = string.IsNullOrEmpty(error) ? output.Trim() : $"{output}\n{error}".Trim();
|
||||||
|
|
||||||
|
if (process.ExitCode == 0)
|
||||||
|
return new ServiceOperationResult(true, message);
|
||||||
|
|
||||||
|
return new ServiceOperationResult(false, message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new ServiceOperationResult(false, $"Failed to execute: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs an sc.exe command and returns the result.
|
||||||
|
/// </summary>
|
||||||
|
private static ServiceOperationResult RunScCommand(string arguments)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "sc.exe",
|
||||||
|
Arguments = arguments,
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
CreateNoWindow = true
|
||||||
|
};
|
||||||
|
|
||||||
|
using var process = Process.Start(startInfo);
|
||||||
|
if (process == null)
|
||||||
|
return new ServiceOperationResult(false, "Failed to start sc.exe process.");
|
||||||
|
|
||||||
|
var output = process.StandardOutput.ReadToEnd();
|
||||||
|
var error = process.StandardError.ReadToEnd();
|
||||||
|
process.WaitForExit();
|
||||||
|
|
||||||
|
// sc.exe often writes to stderr; combine both so status parsing sees the STATE line
|
||||||
|
var message = $"{output}{error}".Trim();
|
||||||
|
if (string.IsNullOrEmpty(message))
|
||||||
|
message = process.ExitCode == 0 ? "OK" : "Command failed.";
|
||||||
|
|
||||||
|
if (process.ExitCode == 0)
|
||||||
|
return new ServiceOperationResult(true, message);
|
||||||
|
|
||||||
|
return new ServiceOperationResult(false, message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new ServiceOperationResult(false, $"Failed to execute sc.exe: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,953 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Windows;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using MaksIT.UScheduler.ScheduleManager.Models;
|
||||||
|
using MaksIT.UScheduler.ScheduleManager.Services;
|
||||||
|
using MaksIT.UScheduler.Shared;
|
||||||
|
using MaksIT.UScheduler.Shared.Helpers;
|
||||||
|
|
||||||
|
namespace MaksIT.UScheduler.ScheduleManager.ViewModels;
|
||||||
|
|
||||||
|
public partial class MainViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the current process is running as administrator.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsAdmin
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
|
||||||
|
var principal = new System.Security.Principal.WindowsPrincipal(identity);
|
||||||
|
return principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private readonly ScriptSettingsService _scriptSettingsService;
|
||||||
|
private readonly UISettingsService _uiSettingsService;
|
||||||
|
private readonly AppSettingsService _appSettingsService;
|
||||||
|
private readonly ScriptStatusService _scriptStatusService;
|
||||||
|
private readonly LogViewerService _logViewerService;
|
||||||
|
private WindowsServiceManager _serviceManager;
|
||||||
|
private Configuration? _serviceConfig;
|
||||||
|
private ScriptSchedule? _originalSchedule;
|
||||||
|
|
||||||
|
public MainViewModel()
|
||||||
|
{
|
||||||
|
_scriptSettingsService = new ScriptSettingsService();
|
||||||
|
_uiSettingsService = new UISettingsService();
|
||||||
|
_appSettingsService = new AppSettingsService();
|
||||||
|
_scriptStatusService = new ScriptStatusService();
|
||||||
|
_logViewerService = new LogViewerService();
|
||||||
|
_serviceManager = new WindowsServiceManager();
|
||||||
|
|
||||||
|
// Initialize month/weekday options
|
||||||
|
MonthOptions = [
|
||||||
|
new MonthOption("January"), new MonthOption("February"), new MonthOption("March"),
|
||||||
|
new MonthOption("April"), new MonthOption("May"), new MonthOption("June"),
|
||||||
|
new MonthOption("July"), new MonthOption("August"), new MonthOption("September"),
|
||||||
|
new MonthOption("October"), new MonthOption("November"), new MonthOption("December")
|
||||||
|
];
|
||||||
|
|
||||||
|
WeekdayOptions = [
|
||||||
|
new WeekdayOption("Monday"), new WeekdayOption("Tuesday"), new WeekdayOption("Wednesday"),
|
||||||
|
new WeekdayOption("Thursday"), new WeekdayOption("Friday"), new WeekdayOption("Saturday"),
|
||||||
|
new WeekdayOption("Sunday")
|
||||||
|
];
|
||||||
|
|
||||||
|
// Load UI settings first (stored paths)
|
||||||
|
LoadUISettings();
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
RefreshScripts();
|
||||||
|
RefreshServiceStatus();
|
||||||
|
RefreshServiceLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads UI settings from the ScheduleManager's appsettings.json.
|
||||||
|
/// If paths are empty, tries auto-detection.
|
||||||
|
/// </summary>
|
||||||
|
private void LoadUISettings()
|
||||||
|
{
|
||||||
|
var uiSettings = _uiSettingsService.Load();
|
||||||
|
|
||||||
|
// Use stored bin path - no auto-detection, user will configure manually if empty
|
||||||
|
ServiceBinPath = uiSettings.ServiceBinPath ?? string.Empty;
|
||||||
|
|
||||||
|
// Load the UScheduler's appsettings.json to get service config
|
||||||
|
LoadServiceAppSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the resolved service bin path (absolute path).
|
||||||
|
/// </summary>
|
||||||
|
private string ResolvedServiceBinPath => UISettingsService.ResolvePath(ServiceBinPath);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the resolved appsettings.json path from the service bin folder.
|
||||||
|
/// </summary>
|
||||||
|
private string ResolvedAppSettingsPath => string.IsNullOrEmpty(ResolvedServiceBinPath)
|
||||||
|
? string.Empty
|
||||||
|
: Path.Combine(ResolvedServiceBinPath, "appsettings.json");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the resolved executable path from the service bin folder.
|
||||||
|
/// </summary>
|
||||||
|
private string ResolvedExecutablePath => string.IsNullOrEmpty(ResolvedServiceBinPath)
|
||||||
|
? string.Empty
|
||||||
|
: Path.Combine(ResolvedServiceBinPath, "MaksIT.UScheduler.exe");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the UScheduler's appsettings.json from the path in ScheduleManager appsettings (ServiceBinPath).
|
||||||
|
/// LogDir is read from that file and evaluated against the UScheduler program path (ServiceBinPath).
|
||||||
|
/// </summary>
|
||||||
|
private void LoadServiceAppSettings()
|
||||||
|
{
|
||||||
|
// Path to UScheduler appsettings = path from ScheduleManager appsettings (ServiceBinPath) + appsettings.json
|
||||||
|
var uschedulerAppSettingsPath = ResolvedAppSettingsPath;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(uschedulerAppSettingsPath) && File.Exists(uschedulerAppSettingsPath))
|
||||||
|
{
|
||||||
|
_serviceConfig = _appSettingsService.LoadAppSettings(uschedulerAppSettingsPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_serviceConfig = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_serviceConfig != null)
|
||||||
|
{
|
||||||
|
AppSettingsLoadError = null;
|
||||||
|
// Update service manager with correct service name
|
||||||
|
_serviceManager = new WindowsServiceManager(_serviceConfig.ServiceName);
|
||||||
|
ServiceName = _serviceConfig.ServiceName;
|
||||||
|
// Log dir: retrieve from MaksIT.UScheduler appsettings.json, show relative path in UI (e.g. ".\Logs")
|
||||||
|
// Configuration.LogDir is required, so it is always set when _serviceConfig is loaded.
|
||||||
|
LogDirectory = AppSettingsService.GetLogDirFromAppSettingsFile(uschedulerAppSettingsPath)
|
||||||
|
?? _serviceConfig.LogDir;
|
||||||
|
// Refresh process list for Processes Management tab
|
||||||
|
ProcessList.Clear();
|
||||||
|
foreach (var p in _serviceConfig.Processes)
|
||||||
|
ProcessList.Add(p);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// appsettings.json not loaded — surface error; do not use fake defaults
|
||||||
|
AppSettingsLoadError = string.IsNullOrEmpty(uschedulerAppSettingsPath) || !File.Exists(uschedulerAppSettingsPath)
|
||||||
|
? "Service Bin Path must be set and must point to a folder that contains appsettings.json."
|
||||||
|
: "Failed to load appsettings.json. Ensure the file is valid and contains required settings (e.g. LogDir).";
|
||||||
|
ServiceName = string.Empty;
|
||||||
|
LogDirectory = string.Empty;
|
||||||
|
ProcessList.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves the current paths to the UI's appsettings.json.
|
||||||
|
/// </summary>
|
||||||
|
private void SaveUISettings()
|
||||||
|
{
|
||||||
|
var settings = new UISettings
|
||||||
|
{
|
||||||
|
ServiceBinPath = ServiceBinPath
|
||||||
|
};
|
||||||
|
_uiSettingsService.Save(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
#region AppSettings
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _serviceBinPath = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _serviceName = "MaksIT.UScheduler";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(AppSettingsLoadErrorVisibility))]
|
||||||
|
private string? _appSettingsLoadError;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Visibility for the app settings load error message (Visible when <see cref="AppSettingsLoadError"/> is set).
|
||||||
|
/// </summary>
|
||||||
|
public Visibility AppSettingsLoadErrorVisibility =>
|
||||||
|
string.IsNullOrEmpty(AppSettingsLoadError) ? Visibility.Collapsed : Visibility.Visible;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void BrowseServiceBinPath()
|
||||||
|
{
|
||||||
|
var dialog = new Microsoft.Win32.OpenFolderDialog
|
||||||
|
{
|
||||||
|
Title = "Select MaksIT.UScheduler bin folder"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dialog.ShowDialog() == true)
|
||||||
|
{
|
||||||
|
ServiceBinPath = dialog.FolderName;
|
||||||
|
SaveUISettings();
|
||||||
|
LoadServiceAppSettings();
|
||||||
|
RefreshScripts();
|
||||||
|
RefreshServiceStatus();
|
||||||
|
RefreshServiceLogs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ReloadAppSettings()
|
||||||
|
{
|
||||||
|
LoadServiceAppSettings();
|
||||||
|
RefreshScripts();
|
||||||
|
RefreshServiceStatus();
|
||||||
|
RefreshServiceLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void SavePaths()
|
||||||
|
{
|
||||||
|
SaveUISettings();
|
||||||
|
MessageBox.Show("Paths saved successfully.", "Save", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Service Management
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private ServiceStatus _serviceStatus = ServiceStatus.Unknown;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _serviceStatusText = "Unknown";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _serviceStatusColor = "Gray";
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void RefreshServiceStatus()
|
||||||
|
{
|
||||||
|
ServiceStatus = _serviceManager.GetStatus(ResolvedExecutablePath);
|
||||||
|
(ServiceStatusText, ServiceStatusColor) = GetServiceStatusDisplay(ServiceStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void StartService()
|
||||||
|
{
|
||||||
|
var result = _serviceManager.Start(ResolvedExecutablePath);
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
MessageBox.Show(result.Message, "Start Service", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
RefreshServiceStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void StopService()
|
||||||
|
{
|
||||||
|
var result = _serviceManager.Stop(ResolvedExecutablePath);
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
MessageBox.Show(result.Message, "Stop Service", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
RefreshServiceStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void RegisterService()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(ServiceBinPath))
|
||||||
|
{
|
||||||
|
MessageBox.Show("Please specify the path to MaksIT.UScheduler bin folder", "Register Service",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = _serviceManager.Register(ResolvedExecutablePath);
|
||||||
|
MessageBox.Show(result.Message, "Register Service",
|
||||||
|
MessageBoxButton.OK, result.Success ? MessageBoxImage.Information : MessageBoxImage.Warning);
|
||||||
|
RefreshServiceStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void UnregisterService()
|
||||||
|
{
|
||||||
|
var confirm = MessageBox.Show(
|
||||||
|
"Are you sure you want to unregister the service?",
|
||||||
|
"Unregister Service",
|
||||||
|
MessageBoxButton.YesNo,
|
||||||
|
MessageBoxImage.Question);
|
||||||
|
|
||||||
|
if (confirm != MessageBoxResult.Yes)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var result = _serviceManager.Unregister(ResolvedExecutablePath);
|
||||||
|
MessageBox.Show(result.Message, "Unregister Service",
|
||||||
|
MessageBoxButton.OK, result.Success ? MessageBoxImage.Information : MessageBoxImage.Warning);
|
||||||
|
RefreshServiceStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string statusText, string statusColor) GetServiceStatusDisplay(ServiceStatus status)
|
||||||
|
{
|
||||||
|
return status switch
|
||||||
|
{
|
||||||
|
ServiceStatus.Running => ("Running", "Green"),
|
||||||
|
ServiceStatus.Stopped => ("Stopped", "Red"),
|
||||||
|
ServiceStatus.StartPending => ("Starting...", "Orange"),
|
||||||
|
ServiceStatus.StopPending => ("Stopping...", "Orange"),
|
||||||
|
ServiceStatus.Paused => ("Paused", "Orange"),
|
||||||
|
ServiceStatus.NotInstalled => ("Not Installed", "Gray"),
|
||||||
|
_ => ("Unknown", "Gray")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Schedule Management
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private ObservableCollection<ScriptSchedule> _scripts = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Process entries from service config (for Processes Management tab).
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private ObservableCollection<ProcessConfiguration> _processList = [];
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private ScriptSchedule? _selectedScript;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _hasSelectedScript;
|
||||||
|
|
||||||
|
/// <summary>Require script to be digitally signed (from service config).</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _scriptIsSigned = true;
|
||||||
|
|
||||||
|
/// <summary>Script disabled and will not be executed (from service config).</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _scriptDisabled;
|
||||||
|
|
||||||
|
/// <summary>Display name for the selected script (from service config; editable).</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string? _scriptName;
|
||||||
|
|
||||||
|
private bool _isSyncingSelection;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isDirty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private ObservableCollection<MonthOption> _monthOptions = [];
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private ObservableCollection<WeekdayOption> _weekdayOptions = [];
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private ObservableCollection<string> _runTimes = [];
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string? _selectedRunTime;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _newRunTime = "00:00";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private int _minIntervalMinutes = 10;
|
||||||
|
|
||||||
|
partial void OnSelectedScriptChanged(ScriptSchedule? value)
|
||||||
|
{
|
||||||
|
HasSelectedScript = value != null;
|
||||||
|
LaunchScriptCommand.NotifyCanExecuteChanged();
|
||||||
|
|
||||||
|
_isSyncingSelection = true;
|
||||||
|
ScriptIsSigned = value?.IsSigned ?? true;
|
||||||
|
ScriptDisabled = value?.Disabled ?? false;
|
||||||
|
ScriptName = value?.Name ?? string.Empty;
|
||||||
|
_isSyncingSelection = false;
|
||||||
|
|
||||||
|
if (value != null)
|
||||||
|
{
|
||||||
|
LoadScheduleToUI(value);
|
||||||
|
_originalSchedule = CloneSchedule(value);
|
||||||
|
RefreshScriptStatus();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ClearUI();
|
||||||
|
_originalSchedule = null;
|
||||||
|
CurrentScriptStatus = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsDirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnScriptIsSignedChanged(bool value)
|
||||||
|
{
|
||||||
|
if (_isSyncingSelection || SelectedScript == null)
|
||||||
|
return;
|
||||||
|
SelectedScript.IsSigned = value;
|
||||||
|
UpdateServiceConfigScriptEntry(SelectedScript.ConfigScriptPath, SelectedScript.Name, value, SelectedScript.Disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnScriptDisabledChanged(bool value)
|
||||||
|
{
|
||||||
|
if (_isSyncingSelection || SelectedScript == null)
|
||||||
|
return;
|
||||||
|
SelectedScript.Disabled = value;
|
||||||
|
UpdateServiceConfigScriptEntry(SelectedScript.ConfigScriptPath, SelectedScript.Name, SelectedScript.IsSigned, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnScriptNameChanged(string? value)
|
||||||
|
{
|
||||||
|
if (_isSyncingSelection || SelectedScript == null)
|
||||||
|
return;
|
||||||
|
var name = value ?? string.Empty;
|
||||||
|
SelectedScript.Name = name;
|
||||||
|
UpdateServiceConfigScriptEntry(SelectedScript.ConfigScriptPath, name, SelectedScript.IsSigned, SelectedScript.Disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateServiceConfigScriptEntry(string configScriptPath, string? name, bool isSigned, bool disabled)
|
||||||
|
{
|
||||||
|
if (_serviceConfig == null)
|
||||||
|
return;
|
||||||
|
var entry = _serviceConfig.Powershell.FirstOrDefault(p => p.Path == configScriptPath);
|
||||||
|
if (entry != null)
|
||||||
|
{
|
||||||
|
if (name != null)
|
||||||
|
entry.Name = name;
|
||||||
|
entry.IsSigned = isSigned;
|
||||||
|
entry.Disabled = disabled;
|
||||||
|
}
|
||||||
|
_appSettingsService.UpdatePowershellScriptEntry(configScriptPath, name, isSigned, disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnMinIntervalMinutesChanged(int value)
|
||||||
|
{
|
||||||
|
MarkDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void RefreshScripts()
|
||||||
|
{
|
||||||
|
Scripts.Clear();
|
||||||
|
|
||||||
|
// Load scripts from service configuration (MaksIT.UScheduler appsettings.json)
|
||||||
|
if (_serviceConfig != null)
|
||||||
|
{
|
||||||
|
foreach (var psConfig in _serviceConfig.Powershell)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(psConfig.Path))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Resolve relative path against service directory
|
||||||
|
var resolvedScriptPath = _appSettingsService.ResolvePathRelativeToService(psConfig.Path);
|
||||||
|
var scriptDir = Path.GetDirectoryName(resolvedScriptPath);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(scriptDir))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// scriptsettings.json should be in the same folder as the script
|
||||||
|
var settingsPath = Path.Combine(scriptDir, "scriptsettings.json");
|
||||||
|
|
||||||
|
if (File.Exists(settingsPath))
|
||||||
|
{
|
||||||
|
var schedule = _scriptSettingsService.LoadScheduleFromFile(settingsPath);
|
||||||
|
if (schedule != null)
|
||||||
|
{
|
||||||
|
schedule.ConfigScriptPath = psConfig.Path;
|
||||||
|
if (!string.IsNullOrWhiteSpace(psConfig.Name))
|
||||||
|
schedule.Name = psConfig.Name;
|
||||||
|
schedule.IsSigned = psConfig.IsSigned;
|
||||||
|
schedule.Disabled = psConfig.Disabled;
|
||||||
|
Scripts.Add(schedule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Launches the selected script via its .bat file in a separate window.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand(CanExecute = nameof(CanLaunchScript))]
|
||||||
|
private void LaunchScript()
|
||||||
|
{
|
||||||
|
if (SelectedScript == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var scriptDir = Path.GetDirectoryName(SelectedScript.FilePath);
|
||||||
|
if (string.IsNullOrEmpty(scriptDir) || !Directory.Exists(scriptDir))
|
||||||
|
{
|
||||||
|
MessageBox.Show("Script directory not found.", "Launch Script",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var batFiles = Directory.GetFiles(scriptDir, "*.bat");
|
||||||
|
if (batFiles.Length == 0)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"No .bat file found in script folder.\n{scriptDir}", "Launch Script",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var batPath = batFiles[0];
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = batPath,
|
||||||
|
WorkingDirectory = scriptDir,
|
||||||
|
UseShellExecute = true,
|
||||||
|
CreateNoWindow = false
|
||||||
|
};
|
||||||
|
Process.Start(startInfo);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"Failed to launch script: {ex.Message}", "Launch Script",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanLaunchScript() => SelectedScript != null;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void AddRunTime()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(NewRunTime))
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Validate time format
|
||||||
|
if (!TimeOnly.TryParse(NewRunTime, out var time))
|
||||||
|
{
|
||||||
|
MessageBox.Show("Invalid time format. Please use HH:mm format.", "Invalid Time",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedTime = time.ToString("HH:mm");
|
||||||
|
|
||||||
|
if (!RunTimes.Contains(formattedTime))
|
||||||
|
{
|
||||||
|
RunTimes.Add(formattedTime);
|
||||||
|
MarkDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
NewRunTime = "00:00";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void RemoveRunTime()
|
||||||
|
{
|
||||||
|
if (SelectedRunTime != null)
|
||||||
|
{
|
||||||
|
RunTimes.Remove(SelectedRunTime);
|
||||||
|
MarkDirty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void SaveSchedule()
|
||||||
|
{
|
||||||
|
if (SelectedScript == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Update the selected script from UI
|
||||||
|
UpdateScheduleFromUI(SelectedScript);
|
||||||
|
|
||||||
|
// Save to file
|
||||||
|
_scriptSettingsService.SaveScheduleToFile(SelectedScript);
|
||||||
|
|
||||||
|
// Update original for dirty tracking
|
||||||
|
_originalSchedule = CloneSchedule(SelectedScript);
|
||||||
|
IsDirty = false;
|
||||||
|
|
||||||
|
MessageBox.Show("Schedule saved successfully.", "Save",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"Failed to save schedule: {ex.Message}", "Save Error",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void RevertSchedule()
|
||||||
|
{
|
||||||
|
if (_originalSchedule != null && SelectedScript != null)
|
||||||
|
{
|
||||||
|
// Copy original values back
|
||||||
|
SelectedScript.RunMonth = new List<string>(_originalSchedule.RunMonth);
|
||||||
|
SelectedScript.RunWeekday = new List<string>(_originalSchedule.RunWeekday);
|
||||||
|
SelectedScript.RunTime = new List<string>(_originalSchedule.RunTime);
|
||||||
|
SelectedScript.MinIntervalMinutes = _originalSchedule.MinIntervalMinutes;
|
||||||
|
|
||||||
|
LoadScheduleToUI(SelectedScript);
|
||||||
|
IsDirty = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnMonthCheckedChanged()
|
||||||
|
{
|
||||||
|
MarkDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnWeekdayCheckedChanged()
|
||||||
|
{
|
||||||
|
MarkDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadScheduleToUI(ScriptSchedule schedule)
|
||||||
|
{
|
||||||
|
// Load months
|
||||||
|
foreach (var month in MonthOptions)
|
||||||
|
{
|
||||||
|
month.IsChecked = schedule.RunMonth.Contains(month.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load weekdays
|
||||||
|
foreach (var weekday in WeekdayOptions)
|
||||||
|
{
|
||||||
|
weekday.IsChecked = schedule.RunWeekday.Contains(weekday.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load times
|
||||||
|
RunTimes = new ObservableCollection<string>(schedule.RunTime);
|
||||||
|
|
||||||
|
// Load interval
|
||||||
|
MinIntervalMinutes = schedule.MinIntervalMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateScheduleFromUI(ScriptSchedule schedule)
|
||||||
|
{
|
||||||
|
schedule.RunMonth = MonthOptions.Where(m => m.IsChecked).Select(m => m.Name).ToList();
|
||||||
|
schedule.RunWeekday = WeekdayOptions.Where(w => w.IsChecked).Select(w => w.Name).ToList();
|
||||||
|
schedule.RunTime = RunTimes.ToList();
|
||||||
|
schedule.MinIntervalMinutes = MinIntervalMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearUI()
|
||||||
|
{
|
||||||
|
foreach (var month in MonthOptions) month.IsChecked = false;
|
||||||
|
foreach (var weekday in WeekdayOptions) weekday.IsChecked = false;
|
||||||
|
RunTimes.Clear();
|
||||||
|
MinIntervalMinutes = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MarkDirty()
|
||||||
|
{
|
||||||
|
if (SelectedScript != null)
|
||||||
|
{
|
||||||
|
IsDirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ScriptSchedule CloneSchedule(ScriptSchedule source)
|
||||||
|
{
|
||||||
|
return new ScriptSchedule
|
||||||
|
{
|
||||||
|
FilePath = source.FilePath,
|
||||||
|
Name = source.Name,
|
||||||
|
Title = source.Title,
|
||||||
|
ConfigScriptPath = source.ConfigScriptPath,
|
||||||
|
IsSigned = source.IsSigned,
|
||||||
|
Disabled = source.Disabled,
|
||||||
|
RunMonth = new List<string>(source.RunMonth),
|
||||||
|
RunWeekday = new List<string>(source.RunWeekday),
|
||||||
|
RunTime = new List<string>(source.RunTime),
|
||||||
|
MinIntervalMinutes = source.MinIntervalMinutes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Script Status (Lock File, Last Run)
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private ScriptStatus? _currentScriptStatus;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _hasLockFile;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void RefreshScriptStatus()
|
||||||
|
{
|
||||||
|
if (SelectedScript == null)
|
||||||
|
{
|
||||||
|
CurrentScriptStatus = null;
|
||||||
|
HasLockFile = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CurrentScriptStatus = _scriptStatusService.GetScriptStatus(SelectedScript.FilePath);
|
||||||
|
HasLockFile = CurrentScriptStatus?.HasLockFile ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void RemoveLockFile()
|
||||||
|
{
|
||||||
|
if (CurrentScriptStatus == null || !CurrentScriptStatus.HasLockFile)
|
||||||
|
{
|
||||||
|
MessageBox.Show("No lock file exists.", "Remove Lock File",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirm = MessageBox.Show(
|
||||||
|
"Are you sure you want to remove the lock file?\n\n" +
|
||||||
|
"This should only be done if the script crashed or was interrupted.\n" +
|
||||||
|
"Removing the lock while the script is running may cause issues.",
|
||||||
|
"Remove Lock File",
|
||||||
|
MessageBoxButton.YesNo,
|
||||||
|
MessageBoxImage.Warning);
|
||||||
|
|
||||||
|
if (confirm != MessageBoxResult.Yes)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_scriptStatusService.RemoveLockFile(CurrentScriptStatus.LockFilePath))
|
||||||
|
{
|
||||||
|
MessageBox.Show("Lock file removed successfully.", "Remove Lock File",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
RefreshScriptStatus();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
MessageBox.Show("Failed to remove lock file.", "Remove Lock File",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Log Viewer
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _logDirectory = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private ObservableCollection<LogFileInfo> _serviceLogs = [];
|
||||||
|
|
||||||
|
/// <summary>Script log folder names (e.g. file-sync, hyper-v-backup) under LogDir.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private ObservableCollection<string> _scriptLogFolders = [];
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string? _selectedScriptLogFolder;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private ObservableCollection<LogFileInfo> _scriptLogFiles = [];
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private LogFileInfo? _selectedLogFile;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _logContent = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private int _selectedLogTab;
|
||||||
|
|
||||||
|
partial void OnSelectedScriptLogFolderChanged(string? value)
|
||||||
|
{
|
||||||
|
ScriptLogFiles.Clear();
|
||||||
|
SelectedLogFile = null;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var resolvedLogDir = ResolvedLogDirectory;
|
||||||
|
if (string.IsNullOrEmpty(resolvedLogDir))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var logs = _logViewerService.GetScriptLogs(resolvedLogDir, value);
|
||||||
|
foreach (var log in logs)
|
||||||
|
{
|
||||||
|
ScriptLogFiles.Add(log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedLogFileChanged(LogFileInfo? value)
|
||||||
|
{
|
||||||
|
if (value != null)
|
||||||
|
{
|
||||||
|
LoadLogContent(value.FullPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LogContent = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void BrowseLogDirectory()
|
||||||
|
{
|
||||||
|
var dialog = new Microsoft.Win32.OpenFolderDialog
|
||||||
|
{
|
||||||
|
Title = "Select Log Directory"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dialog.ShowDialog() == true)
|
||||||
|
{
|
||||||
|
LogDirectory = dialog.FolderName;
|
||||||
|
RefreshServiceLogs();
|
||||||
|
RefreshScriptLogs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the resolved log directory path for reading logs. LogDir (e.g. .\logs) is always resolved
|
||||||
|
/// against ServiceBinPath so the result is e.g. ..\..\..\..\MaksIT.UScheduler\bin\Debug\net10.0\win-x64\logs.
|
||||||
|
/// </summary>
|
||||||
|
private string ResolvedLogDirectory
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(ResolvedServiceBinPath))
|
||||||
|
return string.Empty;
|
||||||
|
// Configuration.LogDir is required; fallback only when config is not loaded.
|
||||||
|
var logDir = _serviceConfig != null ? _serviceConfig.LogDir : ".\\logs";
|
||||||
|
return PathHelper.ResolvePath(logDir, ResolvedServiceBinPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void RefreshServiceLogs()
|
||||||
|
{
|
||||||
|
ServiceLogs.Clear();
|
||||||
|
|
||||||
|
var resolvedLogDir = ResolvedLogDirectory;
|
||||||
|
if (string.IsNullOrEmpty(resolvedLogDir))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var logs = _logViewerService.GetServiceLogs(resolvedLogDir);
|
||||||
|
foreach (var log in logs)
|
||||||
|
{
|
||||||
|
ServiceLogs.Add(log);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also refresh script logs so the Script Logs tab stays in sync
|
||||||
|
RefreshScriptLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void RefreshScriptLogs()
|
||||||
|
{
|
||||||
|
ScriptLogFolders.Clear();
|
||||||
|
SelectedScriptLogFolder = null;
|
||||||
|
ScriptLogFiles.Clear();
|
||||||
|
SelectedLogFile = null;
|
||||||
|
|
||||||
|
var resolvedLogDir = ResolvedLogDirectory;
|
||||||
|
if (string.IsNullOrEmpty(resolvedLogDir))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var folderNames = _logViewerService.GetScriptLogFolderNames(resolvedLogDir);
|
||||||
|
foreach (var name in folderNames)
|
||||||
|
{
|
||||||
|
ScriptLogFolders.Add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ScriptLogFolders.Count > 0)
|
||||||
|
{
|
||||||
|
SelectedScriptLogFolder = ScriptLogFolders[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void RefreshLogContent()
|
||||||
|
{
|
||||||
|
if (SelectedLogFile != null)
|
||||||
|
{
|
||||||
|
LoadLogContent(SelectedLogFile.FullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void OpenLogInExplorer()
|
||||||
|
{
|
||||||
|
if (SelectedLogFile == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{SelectedLogFile.FullPath}\"");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"Failed to open explorer: {ex.Message}", "Error",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void OpenLogDirectory()
|
||||||
|
{
|
||||||
|
var resolvedLogDir = ResolvedLogDirectory;
|
||||||
|
if (string.IsNullOrEmpty(resolvedLogDir) || !Directory.Exists(resolvedLogDir))
|
||||||
|
{
|
||||||
|
MessageBox.Show("Log directory not found.", "Error",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Diagnostics.Process.Start("explorer.exe", resolvedLogDir);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"Failed to open explorer: {ex.Message}", "Error",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadLogContent(string filePath)
|
||||||
|
{
|
||||||
|
LogContent = _logViewerService.ReadLogFile(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a month checkbox option.
|
||||||
|
/// </summary>
|
||||||
|
public partial class MonthOption : ObservableObject
|
||||||
|
{
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isChecked;
|
||||||
|
|
||||||
|
public MonthOption(string name)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a weekday checkbox option.
|
||||||
|
/// </summary>
|
||||||
|
public partial class WeekdayOption : ObservableObject
|
||||||
|
{
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isChecked;
|
||||||
|
|
||||||
|
public WeekdayOption(string name)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/MaksIT.UScheduler.ScheduleManager/app.manifest
Normal file
19
src/MaksIT.UScheduler.ScheduleManager/app.manifest
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="MaksIT.UScheduler.ScheduleManager"/>
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<!-- UAC Manifest Options
|
||||||
|
If you want to change the Windows User Account Control level replace the
|
||||||
|
requestedExecutionLevel node with one of the following.
|
||||||
|
|
||||||
|
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||||
|
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||||
|
<requestedExecutionLevel level="highestAvailable" uiAccess="false" />
|
||||||
|
-->
|
||||||
|
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
</assembly>
|
||||||
5
src/MaksIT.UScheduler.ScheduleManager/appsettings.json
Normal file
5
src/MaksIT.UScheduler.ScheduleManager/appsettings.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"USchedulerSettings": {
|
||||||
|
"ServiceBinPath": "..\\..\\..\\..\\MaksIT.UScheduler\\bin\\Debug\\net10.0\\win-x64"
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/MaksIT.UScheduler.Shared/Configuration.cs
Normal file
78
src/MaksIT.UScheduler.Shared/Configuration.cs
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
namespace MaksIT.UScheduler.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base configuration class for scheduled tasks.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class TaskConfiguration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the path to the executable or script.
|
||||||
|
/// </summary>
|
||||||
|
public required string Path { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether this task is disabled. Disabled tasks will not be executed.
|
||||||
|
/// </summary>
|
||||||
|
public bool Disabled { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for a scheduled PowerShell script.
|
||||||
|
/// </summary>
|
||||||
|
public class PowershellScript : TaskConfiguration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the display name for the script in the UI.
|
||||||
|
/// When set in appsettings, overrides the name from scriptsettings.json.
|
||||||
|
/// </summary>
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether the script must be digitally signed.
|
||||||
|
/// When true, only scripts with valid Authenticode signatures will be executed.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsSigned { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for a scheduled process/executable.
|
||||||
|
/// </summary>
|
||||||
|
public class ProcessConfiguration : TaskConfiguration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the command-line arguments to pass to the process.
|
||||||
|
/// </summary>
|
||||||
|
public string[]? Args { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether the process should automatically restart on failure.
|
||||||
|
/// </summary>
|
||||||
|
public bool RestartOnFailure { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Root configuration class for the UScheduler service.
|
||||||
|
/// </summary>
|
||||||
|
public class Configuration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the Windows service name used for registration.
|
||||||
|
/// </summary>
|
||||||
|
public string ServiceName { get; set; } = "MaksIT.UScheduler";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the directory path for log files (required).
|
||||||
|
/// May be relative to the application base directory (e.g. ".\Logs") or absolute.
|
||||||
|
/// </summary>
|
||||||
|
public required string LogDir { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of PowerShell scripts to execute.
|
||||||
|
/// </summary>
|
||||||
|
public List<PowershellScript> Powershell { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of processes/executables to run.
|
||||||
|
/// </summary>
|
||||||
|
public List<ProcessConfiguration> Processes { get; set; } = [];
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
using MaksIT.Core.Logging;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace MaksIT.UScheduler.Shared.Extensions;
|
||||||
|
|
||||||
|
public static class LoggerFactoryExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a logger that logs to a dedicated subfolder based on the file name.
|
||||||
|
/// Uses the Folder: prefix pattern from MaksIT.Core.Logging.FileLoggerProvider.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
|
/// <param name="filePath">The file path to extract the folder name from.</param>
|
||||||
|
/// <returns>An ILogger instance configured for folder-based logging.</returns>
|
||||||
|
public static ILogger CreateFolderLogger(this ILoggerFactory loggerFactory, string filePath)
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileNameWithoutExtension(filePath);
|
||||||
|
return loggerFactory.CreateLogger($"{LoggerPrefix.Folder}{fileName}");
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/MaksIT.UScheduler.Shared/Helpers/PathHelper.cs
Normal file
37
src/MaksIT.UScheduler.Shared/Helpers/PathHelper.cs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
namespace MaksIT.UScheduler.Shared.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves relative and absolute paths. Relative paths are resolved against a base directory;
|
||||||
|
/// absolute (rooted) paths are returned unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public static class PathHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a path against the application base directory.
|
||||||
|
/// If the path is null, empty, or already rooted (absolute), returns it unchanged.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path to resolve.</param>
|
||||||
|
/// <returns>The fully resolved absolute path, or the original value if rooted/null/empty.</returns>
|
||||||
|
public static string ResolvePath(string path)
|
||||||
|
{
|
||||||
|
return ResolvePath(path, AppDomain.CurrentDomain.BaseDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a path against the specified base directory.
|
||||||
|
/// If the path is null, empty, or already rooted (absolute), returns it unchanged.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path to resolve.</param>
|
||||||
|
/// <param name="baseDirectory">The base directory for relative path resolution.</param>
|
||||||
|
/// <returns>The fully resolved absolute path, or the original path if rooted/null/empty or base is null/empty.</returns>
|
||||||
|
public static string ResolvePath(string path, string baseDirectory)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path) || Path.IsPathRooted(path))
|
||||||
|
return path;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(baseDirectory))
|
||||||
|
return path;
|
||||||
|
|
||||||
|
return Path.GetFullPath(Path.Combine(baseDirectory, path));
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/MaksIT.UScheduler.Shared/MaksIT.UScheduler.Shared.csproj
Normal file
15
src/MaksIT.UScheduler.Shared/MaksIT.UScheduler.Shared.csproj
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>MaksIT.UScheduler.Shared</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MaksIT.Core" Version="1.6.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@ -0,0 +1,210 @@
|
|||||||
|
using MaksIT.UScheduler;
|
||||||
|
using MaksIT.UScheduler.BackgroundServices;
|
||||||
|
using MaksIT.UScheduler.Services;
|
||||||
|
using MaksIT.UScheduler.Shared;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MaksIT.UScheduler.Tests.BackgroundServices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for PSScriptBackgroundService.
|
||||||
|
/// </summary>
|
||||||
|
public class PSScriptBackgroundServiceTests {
|
||||||
|
private readonly Mock<ILogger<PSScriptBackgroundService>> _loggerMock;
|
||||||
|
private readonly Mock<IPSScriptService> _psScriptServiceMock;
|
||||||
|
|
||||||
|
public PSScriptBackgroundServiceTests() {
|
||||||
|
_loggerMock = new Mock<ILogger<PSScriptBackgroundService>>();
|
||||||
|
_psScriptServiceMock = new Mock<IPSScriptService>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private PSScriptBackgroundService CreateService(Configuration config) {
|
||||||
|
var options = Options.Create(config);
|
||||||
|
return new PSScriptBackgroundService(_loggerMock.Object, options, _psScriptServiceMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_WithNoScripts_DoesNotCallRunScriptAsync() {
|
||||||
|
// Arrange
|
||||||
|
var config = new Configuration { LogDir = ".\\Logs", Powershell = [] };
|
||||||
|
var service = CreateService(config);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
cts.Cancel();
|
||||||
|
await service.StartAsync(cts.Token);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
_psScriptServiceMock.Verify(
|
||||||
|
x => x.RunScriptAsync(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_WithDisabledScript_DoesNotRunDisabledScript() {
|
||||||
|
// Arrange
|
||||||
|
var config = new Configuration {
|
||||||
|
LogDir = ".\\Logs",
|
||||||
|
Powershell = [
|
||||||
|
new PowershellScript { Path = @"C:\Scripts\test.ps1", Disabled = true }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var service = CreateService(config);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
cts.Cancel();
|
||||||
|
await service.StartAsync(cts.Token);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
_psScriptServiceMock.Verify(
|
||||||
|
x => x.RunScriptAsync(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_WithEmptyPath_DoesNotRunScript() {
|
||||||
|
// Arrange
|
||||||
|
var config = new Configuration {
|
||||||
|
LogDir = ".\\Logs",
|
||||||
|
Powershell = [ new PowershellScript { Path = "" } ]
|
||||||
|
};
|
||||||
|
var service = CreateService(config);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
cts.Cancel();
|
||||||
|
await service.StartAsync(cts.Token);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
_psScriptServiceMock.Verify(
|
||||||
|
x => x.RunScriptAsync(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StopAsync_TerminatesAllScripts() {
|
||||||
|
// Arrange
|
||||||
|
var config = new Configuration { LogDir = ".\\Logs" };
|
||||||
|
var service = CreateService(config);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
_psScriptServiceMock.Verify(x => x.TerminateAllScripts(), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_WithEnabledScript_CallsRunScriptAsync() {
|
||||||
|
// Arrange
|
||||||
|
var config = new Configuration {
|
||||||
|
LogDir = ".\\Logs",
|
||||||
|
Powershell = [
|
||||||
|
new PowershellScript { Path = @"C:\Scripts\test.ps1", Disabled = false, IsSigned = true }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var service = CreateService(config);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
_psScriptServiceMock
|
||||||
|
.Setup(x => x.RunScriptAsync(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var executeTask = service.StartAsync(cts.Token);
|
||||||
|
await Task.Delay(100);
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) {
|
||||||
|
// Expected
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
_psScriptServiceMock.Verify(
|
||||||
|
x => x.RunScriptAsync(@"C:\Scripts\test.ps1", true, It.IsAny<CancellationToken>()),
|
||||||
|
Times.AtLeastOnce);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_WithUnsignedScript_PassesIsSignedFalse() {
|
||||||
|
// Arrange
|
||||||
|
var config = new Configuration {
|
||||||
|
LogDir = ".\\Logs",
|
||||||
|
Powershell = [
|
||||||
|
new PowershellScript { Path = @"C:\Scripts\test.ps1", Disabled = false, IsSigned = false }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var service = CreateService(config);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
_psScriptServiceMock
|
||||||
|
.Setup(x => x.RunScriptAsync(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var executeTask = service.StartAsync(cts.Token);
|
||||||
|
await Task.Delay(100);
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) {
|
||||||
|
// Expected
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert - verify IsSigned=false was passed
|
||||||
|
_psScriptServiceMock.Verify(
|
||||||
|
x => x.RunScriptAsync(@"C:\Scripts\test.ps1", false, It.IsAny<CancellationToken>()),
|
||||||
|
Times.AtLeastOnce);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_WithMultipleScripts_RunsAllEnabledScripts() {
|
||||||
|
// Arrange
|
||||||
|
var config = new Configuration {
|
||||||
|
LogDir = ".\\Logs",
|
||||||
|
Powershell = [
|
||||||
|
new PowershellScript { Path = @"C:\Scripts\script1.ps1", Disabled = false },
|
||||||
|
new PowershellScript { Path = @"C:\Scripts\script2.ps1", Disabled = true },
|
||||||
|
new PowershellScript { Path = @"C:\Scripts\script3.ps1", Disabled = false }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var service = CreateService(config);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
_psScriptServiceMock
|
||||||
|
.Setup(x => x.RunScriptAsync(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var executeTask = service.StartAsync(cts.Token);
|
||||||
|
await Task.Delay(100);
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) {
|
||||||
|
// Expected
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert - only enabled scripts should run
|
||||||
|
_psScriptServiceMock.Verify(
|
||||||
|
x => x.RunScriptAsync(@"C:\Scripts\script1.ps1", It.IsAny<bool>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.AtLeastOnce);
|
||||||
|
_psScriptServiceMock.Verify(
|
||||||
|
x => x.RunScriptAsync(@"C:\Scripts\script2.ps1", It.IsAny<bool>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Never);
|
||||||
|
_psScriptServiceMock.Verify(
|
||||||
|
x => x.RunScriptAsync(@"C:\Scripts\script3.ps1", It.IsAny<bool>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.AtLeastOnce);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,179 @@
|
|||||||
|
using MaksIT.UScheduler;
|
||||||
|
using MaksIT.UScheduler.BackgroundServices;
|
||||||
|
using MaksIT.UScheduler.Services;
|
||||||
|
using MaksIT.UScheduler.Shared;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MaksIT.UScheduler.Tests.BackgroundServices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for ProcessBackgroundService.
|
||||||
|
/// </summary>
|
||||||
|
public class ProcessBackgroundServiceTests {
|
||||||
|
private readonly Mock<ILogger<ProcessBackgroundService>> _loggerMock;
|
||||||
|
private readonly Mock<IProcessService> _processServiceMock;
|
||||||
|
|
||||||
|
public ProcessBackgroundServiceTests() {
|
||||||
|
_loggerMock = new Mock<ILogger<ProcessBackgroundService>>();
|
||||||
|
_processServiceMock = new Mock<IProcessService>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProcessBackgroundService CreateService(Configuration config) {
|
||||||
|
var options = Options.Create(config);
|
||||||
|
return new ProcessBackgroundService(_loggerMock.Object, options, _processServiceMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_WithNoProcesses_DoesNotCallRunProcessAsync() {
|
||||||
|
// Arrange
|
||||||
|
var config = new Configuration { LogDir = ".\\Logs", Processes = [] };
|
||||||
|
var service = CreateService(config);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
// Act - cancel immediately to stop the service
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
// Start the service - it should complete without errors
|
||||||
|
await service.StartAsync(cts.Token);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
_processServiceMock.Verify(
|
||||||
|
x => x.RunProcessAsync(It.IsAny<string>(), It.IsAny<string[]?>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_WithDisabledProcess_DoesNotRunDisabledProcess() {
|
||||||
|
// Arrange
|
||||||
|
var config = new Configuration {
|
||||||
|
LogDir = ".\\Logs",
|
||||||
|
Processes = [
|
||||||
|
new ProcessConfiguration { Path = @"C:\app.exe", Disabled = true }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var service = CreateService(config);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
cts.Cancel();
|
||||||
|
await service.StartAsync(cts.Token);
|
||||||
|
|
||||||
|
// Assert - disabled processes should not be run
|
||||||
|
_processServiceMock.Verify(
|
||||||
|
x => x.RunProcessAsync(It.IsAny<string>(), It.IsAny<string[]?>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_WithEmptyPath_DoesNotRunProcess() {
|
||||||
|
// Arrange
|
||||||
|
var config = new Configuration {
|
||||||
|
LogDir = ".\\Logs",
|
||||||
|
Processes = [ new ProcessConfiguration { Path = "" } ]
|
||||||
|
};
|
||||||
|
var service = CreateService(config);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
cts.Cancel();
|
||||||
|
await service.StartAsync(cts.Token);
|
||||||
|
|
||||||
|
// Assert - processes with empty paths should not be run
|
||||||
|
_processServiceMock.Verify(
|
||||||
|
x => x.RunProcessAsync(It.IsAny<string>(), It.IsAny<string[]?>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StopAsync_TerminatesAllProcesses() {
|
||||||
|
// Arrange
|
||||||
|
var config = new Configuration { LogDir = ".\\Logs" };
|
||||||
|
var service = CreateService(config);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
_processServiceMock.Verify(x => x.TerminateAllProcesses(), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_WithEnabledProcess_CallsRunProcessAsync() {
|
||||||
|
// Arrange
|
||||||
|
var config = new Configuration {
|
||||||
|
LogDir = ".\\Logs",
|
||||||
|
Processes = [
|
||||||
|
new ProcessConfiguration { Path = @"C:\app.exe", Disabled = false }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var service = CreateService(config);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
// Setup mock to complete immediately
|
||||||
|
_processServiceMock
|
||||||
|
.Setup(x => x.RunProcessAsync(It.IsAny<string>(), It.IsAny<string[]?>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
// Act - start the service then cancel after a short delay
|
||||||
|
var executeTask = service.StartAsync(cts.Token);
|
||||||
|
|
||||||
|
// Give it a moment to start processing, then cancel
|
||||||
|
await Task.Delay(100);
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
// Wait for the service to stop gracefully
|
||||||
|
try {
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) {
|
||||||
|
// Expected when cancelling
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert - the enabled process should have been run at least once
|
||||||
|
_processServiceMock.Verify(
|
||||||
|
x => x.RunProcessAsync(@"C:\app.exe", It.IsAny<string[]?>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.AtLeastOnce);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_WithProcessArgs_PassesArgsToService() {
|
||||||
|
// Arrange
|
||||||
|
var expectedArgs = new[] { "--verbose", "--log" };
|
||||||
|
var config = new Configuration {
|
||||||
|
LogDir = ".\\Logs",
|
||||||
|
Processes = [
|
||||||
|
new ProcessConfiguration {
|
||||||
|
Path = @"C:\app.exe",
|
||||||
|
Disabled = false,
|
||||||
|
Args = expectedArgs
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var service = CreateService(config);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
_processServiceMock
|
||||||
|
.Setup(x => x.RunProcessAsync(It.IsAny<string>(), It.IsAny<string[]?>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var executeTask = service.StartAsync(cts.Token);
|
||||||
|
await Task.Delay(100);
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) {
|
||||||
|
// Expected
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert - verify args were passed correctly
|
||||||
|
_processServiceMock.Verify(
|
||||||
|
x => x.RunProcessAsync(@"C:\app.exe", expectedArgs, It.IsAny<CancellationToken>()),
|
||||||
|
Times.AtLeastOnce);
|
||||||
|
}
|
||||||
|
}
|
||||||
155
src/MaksIT.UScheduler.Tests/ConfigurationTests.cs
Normal file
155
src/MaksIT.UScheduler.Tests/ConfigurationTests.cs
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
using MaksIT.UScheduler.Shared;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MaksIT.UScheduler.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for configuration classes.
|
||||||
|
/// </summary>
|
||||||
|
public class ConfigurationTests {
|
||||||
|
private const string TestLogDir = ".\\Logs";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Configuration_DefaultValues_AreCorrect() {
|
||||||
|
// Arrange & Act
|
||||||
|
var config = new Configuration { LogDir = TestLogDir };
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("MaksIT.UScheduler", config.ServiceName);
|
||||||
|
Assert.Equal(TestLogDir, config.LogDir);
|
||||||
|
Assert.NotNull(config.Powershell);
|
||||||
|
Assert.Empty(config.Powershell);
|
||||||
|
Assert.NotNull(config.Processes);
|
||||||
|
Assert.Empty(config.Processes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Configuration_CanSetServiceName() {
|
||||||
|
// Arrange
|
||||||
|
var config = new Configuration { LogDir = TestLogDir };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
config.ServiceName = "CustomServiceName";
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("CustomServiceName", config.ServiceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Configuration_CanSetLogDir() {
|
||||||
|
// Arrange
|
||||||
|
var config = new Configuration { LogDir = TestLogDir };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
config.LogDir = @"C:\Logs";
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(@"C:\Logs", config.LogDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Configuration_CanAddPowerShellScripts() {
|
||||||
|
// Arrange
|
||||||
|
var config = new Configuration { LogDir = TestLogDir };
|
||||||
|
var script = new PowershellScript { Path = @"C:\Scripts\test.ps1" };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
config.Powershell.Add(script);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Single(config.Powershell);
|
||||||
|
Assert.Equal(@"C:\Scripts\test.ps1", config.Powershell[0].Path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Configuration_CanAddProcesses() {
|
||||||
|
// Arrange
|
||||||
|
var config = new Configuration { LogDir = TestLogDir };
|
||||||
|
var process = new ProcessConfiguration {
|
||||||
|
Path = @"C:\App\app.exe",
|
||||||
|
Args = ["--verbose", "--output", "log.txt"]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
config.Processes.Add(process);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Single(config.Processes);
|
||||||
|
Assert.Equal(@"C:\App\app.exe", config.Processes[0].Path);
|
||||||
|
Assert.Equal(3, config.Processes[0].Args!.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PowershellScript_DefaultValues_AreCorrect() {
|
||||||
|
// Arrange & Act
|
||||||
|
var script = new PowershellScript { Path = @"C:\test.ps1" };
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(@"C:\test.ps1", script.Path);
|
||||||
|
Assert.True(script.IsSigned); // Default should be true
|
||||||
|
Assert.False(script.Disabled); // Default should be false
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PowershellScript_CanSetIsSigned() {
|
||||||
|
// Arrange
|
||||||
|
var script = new PowershellScript { Path = @"C:\test.ps1" };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.IsSigned = false;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(script.IsSigned);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PowershellScript_CanSetDisabled() {
|
||||||
|
// Arrange
|
||||||
|
var script = new PowershellScript { Path = @"C:\test.ps1" };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
script.Disabled = true;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(script.Disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProcessConfiguration_DefaultValues_AreCorrect() {
|
||||||
|
// Arrange & Act
|
||||||
|
var process = new ProcessConfiguration { Path = @"C:\app.exe" };
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(@"C:\app.exe", process.Path);
|
||||||
|
Assert.Null(process.Args);
|
||||||
|
Assert.False(process.RestartOnFailure); // Default should be false
|
||||||
|
Assert.False(process.Disabled); // Default should be false
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProcessConfiguration_CanSetArgs() {
|
||||||
|
// Arrange
|
||||||
|
var process = new ProcessConfiguration { Path = @"C:\app.exe" };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
process.Args = ["arg1", "arg2"];
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(process.Args);
|
||||||
|
Assert.Equal(2, process.Args.Length);
|
||||||
|
Assert.Equal("arg1", process.Args[0]);
|
||||||
|
Assert.Equal("arg2", process.Args[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProcessConfiguration_CanSetRestartOnFailure() {
|
||||||
|
// Arrange
|
||||||
|
var process = new ProcessConfiguration { Path = @"C:\app.exe" };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
process.RestartOnFailure = true;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(process.RestartOnFailure);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/MaksIT.UScheduler.Tests/MaksIT.UScheduler.Tests.csproj
Normal file
34
src/MaksIT.UScheduler.Tests/MaksIT.UScheduler.Tests.csproj
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
|
<PlatformTarget>x64</PlatformTarget>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
|
||||||
|
<PackageReference Include="xunit" Version="2.*" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.*">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.*">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Moq" Version="4.*" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.*" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options" Version="10.*" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MaksIT.UScheduler\MaksIT.UScheduler.csproj" />
|
||||||
|
<ProjectReference Include="..\MaksIT.UScheduler.Shared\MaksIT.UScheduler.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@ -1,10 +1,15 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio Version 18
|
# Visual Studio Version 18
|
||||||
VisualStudioVersion = 18.0.11222.15 d18.0
|
VisualStudioVersion = 18.0.11222.15
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.UScheduler", "MaksIT.UScheduler\MaksIT.UScheduler.csproj", "{DE1F347C-D201-42E2-8D22-924508FD30AA}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.UScheduler", "MaksIT.UScheduler\MaksIT.UScheduler.csproj", "{DE1F347C-D201-42E2-8D22-924508FD30AA}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaksIT.UScheduler.Tests", "MaksIT.UScheduler.Tests\MaksIT.UScheduler.Tests.csproj", "{DC193ABC-89F8-131B-060F-6C3A3CE2652A}"
|
||||||
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.UScheduler.ScheduleManager", "MaksIT.UScheduler.ScheduleManager\MaksIT.UScheduler.ScheduleManager.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
|
||||||
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.UScheduler.Shared", "MaksIT.UScheduler.Shared\MaksIT.UScheduler.Shared.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@ -15,6 +20,18 @@ Global
|
|||||||
{DE1F347C-D201-42E2-8D22-924508FD30AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{DE1F347C-D201-42E2-8D22-924508FD30AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{DE1F347C-D201-42E2-8D22-924508FD30AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{DE1F347C-D201-42E2-8D22-924508FD30AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{DE1F347C-D201-42E2-8D22-924508FD30AA}.Release|Any CPU.Build.0 = Release|Any CPU
|
{DE1F347C-D201-42E2-8D22-924508FD30AA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{DC193ABC-89F8-131B-060F-6C3A3CE2652A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{DC193ABC-89F8-131B-060F-6C3A3CE2652A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{DC193ABC-89F8-131B-060F-6C3A3CE2652A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{DC193ABC-89F8-131B-060F-6C3A3CE2652A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@ -1,25 +1,42 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using MaksIT.UScheduler.Services;
|
using MaksIT.UScheduler.Services;
|
||||||
|
using MaksIT.UScheduler.Shared;
|
||||||
|
|
||||||
|
|
||||||
namespace MaksIT.UScheduler.BackgroundServices;
|
namespace MaksIT.UScheduler.BackgroundServices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background service that manages the execution of configured PowerShell scripts.
|
||||||
|
/// Continuously monitors and launches scripts based on the application configuration.
|
||||||
|
/// </summary>
|
||||||
public sealed class PSScriptBackgroundService : BackgroundService {
|
public sealed class PSScriptBackgroundService : BackgroundService {
|
||||||
|
|
||||||
private readonly ILogger<PSScriptBackgroundService> _logger;
|
private readonly ILogger<PSScriptBackgroundService> _logger;
|
||||||
private readonly Configuration _configuration;
|
private readonly Configuration _configuration;
|
||||||
private readonly PSScriptService _psScriptService;
|
private readonly IPSScriptService _psScriptService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="PSScriptBackgroundService"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger instance for this service.</param>
|
||||||
|
/// <param name="options">The configuration options containing PowerShell script definitions.</param>
|
||||||
|
/// <param name="psScriptService">The PowerShell script service for executing scripts.</param>
|
||||||
public PSScriptBackgroundService(
|
public PSScriptBackgroundService(
|
||||||
ILogger<PSScriptBackgroundService> logger,
|
ILogger<PSScriptBackgroundService> logger,
|
||||||
IOptions<Configuration> options,
|
IOptions<Configuration> options,
|
||||||
PSScriptService psScriptService
|
IPSScriptService psScriptService
|
||||||
) {
|
) {
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_configuration = options.Value;
|
_configuration = options.Value;
|
||||||
_psScriptService = psScriptService;
|
_psScriptService = psScriptService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes the background service, continuously launching configured PowerShell scripts in parallel.
|
||||||
|
/// Scripts are checked and launched every 10 seconds.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="stoppingToken">Cancellation token that signals when the service should stop.</param>
|
||||||
|
/// <returns>A task representing the background operation.</returns>
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
|
||||||
_logger.LogInformation("Starting PSScriptBackgroundService");
|
_logger.LogInformation("Starting PSScriptBackgroundService");
|
||||||
|
|
||||||
@ -29,14 +46,19 @@ public sealed class PSScriptBackgroundService : BackgroundService {
|
|||||||
while (!stoppingToken.IsCancellationRequested) {
|
while (!stoppingToken.IsCancellationRequested) {
|
||||||
_logger.LogInformation("Checking for PowerShell scripts to run");
|
_logger.LogInformation("Checking for PowerShell scripts to run");
|
||||||
|
|
||||||
foreach (var psScript in psScripts) {
|
// Launch all enabled scripts in parallel
|
||||||
var scriptPath = psScript.Path;
|
var scriptTasks = psScripts
|
||||||
|
.Where(psScript => !psScript.Disabled && !string.IsNullOrEmpty(psScript.Path))
|
||||||
|
.Select(psScript => {
|
||||||
|
_logger.LogInformation($"Launching PowerShell script {psScript.Path}");
|
||||||
|
return _psScriptService.RunScriptAsync(psScript.Path, psScript.IsSigned, stoppingToken);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
if (scriptPath == string.Empty)
|
if (scriptTasks.Count > 0) {
|
||||||
continue;
|
_logger.LogInformation($"Waiting for {scriptTasks.Count} PowerShell script(s) to complete");
|
||||||
|
await Task.WhenAll(scriptTasks);
|
||||||
_logger.LogInformation($"Running PowerShell script {scriptPath}");
|
_logger.LogInformation("All PowerShell scripts completed");
|
||||||
_psScriptService.RunScript(scriptPath, psScript.IsSigned, stoppingToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||||
@ -63,12 +85,19 @@ public sealed class PSScriptBackgroundService : BackgroundService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the background service and terminates all running PowerShell scripts.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="stoppingToken">Cancellation token for the stop operation.</param>
|
||||||
|
/// <returns>A task representing the stop operation.</returns>
|
||||||
public override Task StopAsync(CancellationToken stoppingToken) {
|
public override Task StopAsync(CancellationToken stoppingToken) {
|
||||||
// Perform cleanup tasks here
|
// Perform cleanup tasks here
|
||||||
_logger.LogInformation("Stopping PSScriptBackgroundService");
|
_logger.LogInformation("Stopping PSScriptBackgroundService");
|
||||||
|
|
||||||
|
_psScriptService.TerminateAllScripts();
|
||||||
|
|
||||||
_logger.LogInformation("PSScriptBackgroundService stopped");
|
_logger.LogInformation("PSScriptBackgroundService stopped");
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return base.StopAsync(stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,25 +1,42 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using MaksIT.UScheduler.Services;
|
using MaksIT.UScheduler.Services;
|
||||||
|
using MaksIT.UScheduler.Shared;
|
||||||
|
|
||||||
|
|
||||||
namespace MaksIT.UScheduler.BackgroundServices;
|
namespace MaksIT.UScheduler.BackgroundServices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background service that manages the execution of configured external processes.
|
||||||
|
/// Continuously monitors and launches processes based on the application configuration.
|
||||||
|
/// </summary>
|
||||||
public sealed class ProcessBackgroundService : BackgroundService {
|
public sealed class ProcessBackgroundService : BackgroundService {
|
||||||
|
|
||||||
private readonly ILogger<ProcessBackgroundService> _logger;
|
private readonly ILogger<ProcessBackgroundService> _logger;
|
||||||
private readonly Configuration _configuration;
|
private readonly Configuration _configuration;
|
||||||
private readonly ProcessService _processService;
|
private readonly IProcessService _processService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ProcessBackgroundService"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger instance for this service.</param>
|
||||||
|
/// <param name="options">The configuration options containing process definitions.</param>
|
||||||
|
/// <param name="processService">The process service for executing processes.</param>
|
||||||
public ProcessBackgroundService(
|
public ProcessBackgroundService(
|
||||||
ILogger<ProcessBackgroundService> logger,
|
ILogger<ProcessBackgroundService> logger,
|
||||||
IOptions<Configuration> options,
|
IOptions<Configuration> options,
|
||||||
ProcessService processService
|
IProcessService processService
|
||||||
) {
|
) {
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_configuration = options.Value;
|
_configuration = options.Value;
|
||||||
_processService = processService;
|
_processService = processService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes the background service, continuously launching configured processes in parallel.
|
||||||
|
/// Processes are checked and launched every 10 seconds.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="stoppingToken">Cancellation token that signals when the service should stop.</param>
|
||||||
|
/// <returns>A task representing the background operation.</returns>
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
|
||||||
_logger.LogInformation("Starting ProcessBackgroundService");
|
_logger.LogInformation("Starting ProcessBackgroundService");
|
||||||
|
|
||||||
@ -29,15 +46,20 @@ public sealed class ProcessBackgroundService : BackgroundService {
|
|||||||
while (!stoppingToken.IsCancellationRequested) {
|
while (!stoppingToken.IsCancellationRequested) {
|
||||||
_logger.LogInformation("Checking for processes to run");
|
_logger.LogInformation("Checking for processes to run");
|
||||||
|
|
||||||
foreach (var process in processes) {
|
// Launch all enabled processes in parallel
|
||||||
var processPath = process.Path;
|
var processTasks = processes
|
||||||
var processArgs = process.Args;
|
.Where(process => !process.Disabled && !string.IsNullOrEmpty(process.Path))
|
||||||
|
.Select(process => {
|
||||||
|
var argsString = process.Args != null ? string.Join(", ", process.Args) : "";
|
||||||
|
_logger.LogInformation($"Launching process {process.Path} with arguments {argsString}");
|
||||||
|
return _processService.RunProcessAsync(process.Path, process.Args, stoppingToken);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
if (processPath == string.Empty)
|
if (processTasks.Count > 0) {
|
||||||
continue;
|
_logger.LogInformation($"Waiting for {processTasks.Count} process(es) to complete");
|
||||||
|
await Task.WhenAll(processTasks);
|
||||||
_logger.LogInformation($"Running process {processPath} with arguments {string.Join(", ", processArgs)}");
|
_logger.LogInformation("All processes completed");
|
||||||
_processService.RunProcess(processPath, processArgs, stoppingToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||||
@ -63,12 +85,19 @@ public sealed class ProcessBackgroundService : BackgroundService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the background service and terminates all running processes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="stoppingToken">Cancellation token for the stop operation.</param>
|
||||||
|
/// <returns>A task representing the stop operation.</returns>
|
||||||
public override Task StopAsync(CancellationToken stoppingToken) {
|
public override Task StopAsync(CancellationToken stoppingToken) {
|
||||||
// Perform cleanup tasks here
|
// Perform cleanup tasks here
|
||||||
_logger.LogInformation("Stopping ProcessBackgroundService");
|
_logger.LogInformation("Stopping ProcessBackgroundService");
|
||||||
|
|
||||||
|
_processService.TerminateAllProcesses();
|
||||||
|
|
||||||
_logger.LogInformation("All processes terminated");
|
_logger.LogInformation("All processes terminated");
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return base.StopAsync(stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
namespace MaksIT.UScheduler;
|
|
||||||
|
|
||||||
public class PowershellScript {
|
|
||||||
public required string Path { get; set; }
|
|
||||||
public bool IsSigned { get; set; } = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ProcessConfiguration {
|
|
||||||
public required string Path { get; set; }
|
|
||||||
public string[]? Args { get; set; }
|
|
||||||
public bool RestartOnFailure { get; set; } = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class Configuration {
|
|
||||||
public string ServiceName { get; set; } = "MaksIT.UScheduler";
|
|
||||||
public string? LogDir { get; set; }
|
|
||||||
public List<PowershellScript> Powershell { get; set; } = [];
|
|
||||||
public List<ProcessConfiguration> Processes { get; set; } = [];
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
sc.exe create "MaksIT.UScheduler Service" binpath="%~dp0MaksIT.UScheduler.exe"
|
|
||||||
sc description "MaksIT.UScheduler Service" "Windows service, which allows you to schedule and invoke PowerShell Scripts and Processes"
|
|
||||||
pause
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2025 Maksym Sadovnychyy (Maks-IT)
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@ -1,8 +1,8 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Version>1.0.0</Version>
|
<Version>1.0.1</Version>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<UserSecretsId>dotnet-UScheduler-040d8105-9e07-4024-a632-cbe091387b66</UserSecretsId>
|
<UserSecretsId>dotnet-UScheduler-040d8105-9e07-4024-a632-cbe091387b66</UserSecretsId>
|
||||||
@ -13,15 +13,19 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MaksIT.Core" Version="1.6.0" />
|
<ProjectReference Include="..\MaksIT.UScheduler.Shared\MaksIT.UScheduler.Shared.csproj" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
</ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0" />
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MaksIT.Core" Version="1.6.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.2" />
|
||||||
<PackageReference Include="Microsoft.PowerShell.Commands.Diagnostics" Version="7.5.4" />
|
<PackageReference Include="Microsoft.PowerShell.Commands.Diagnostics" Version="7.5.4" />
|
||||||
<PackageReference Include="Microsoft.PowerShell.Commands.Management" Version="7.5.4" />
|
<PackageReference Include="Microsoft.PowerShell.Commands.Management" Version="7.5.4" />
|
||||||
<PackageReference Include="Microsoft.PowerShell.Commands.Utility" Version="7.5.4" />
|
<PackageReference Include="Microsoft.PowerShell.Commands.Utility" Version="7.5.4" />
|
||||||
<PackageReference Include="Microsoft.PowerShell.ConsoleHost" Version="7.5.3" />
|
<PackageReference Include="Microsoft.PowerShell.ConsoleHost" Version="7.5.3" />
|
||||||
<PackageReference Include="Microsoft.WSMan.Management" Version="7.5.4" />
|
<PackageReference Include="Microsoft.WSMan.Management" Version="7.5.4" />
|
||||||
<PackageReference Include="System.Diagnostics.PerformanceCounter" Version="10.0.0" />
|
<PackageReference Include="System.Diagnostics.PerformanceCounter" Version="10.0.2" />
|
||||||
<PackageReference Include="System.Management.Automation" Version="7.5.4" />
|
<PackageReference Include="System.Management.Automation" Version="7.5.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
@ -30,19 +34,11 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Update="CHANGELOG.md">
|
<None Include="..\..\README.md;..\..\LICENSE.md;..\..\CHANGELOG.md">
|
||||||
|
<Link>%(Filename)%(Extension)</Link>
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
<None Update="Install.cmd">
|
<None Include="..\..\badges\**\*" Link="badges\%(RecursiveDir)%(Filename)%(Extension)">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="LICENSE.md">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="README.md">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="Uninstall.cmd">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@ -1,24 +0,0 @@
|
|||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
|
||||||
# Visual Studio Version 17
|
|
||||||
VisualStudioVersion = 17.5.2.0
|
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaksIT.UScheduler", "MaksIT.UScheduler.csproj", "{E81A303A-732F-0CE9-2A4C-4C26971DB9D1}"
|
|
||||||
EndProject
|
|
||||||
Global
|
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
|
||||||
Debug|Any CPU = Debug|Any CPU
|
|
||||||
Release|Any CPU = Release|Any CPU
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
|
||||||
{E81A303A-732F-0CE9-2A4C-4C26971DB9D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{E81A303A-732F-0CE9-2A4C-4C26971DB9D1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{E81A303A-732F-0CE9-2A4C-4C26971DB9D1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{E81A303A-732F-0CE9-2A4C-4C26971DB9D1}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
|
||||||
HideSolutionNode = FALSE
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
|
||||||
SolutionGuid = {F6EE17FC-4D3A-420D-B534-433875D783B3}
|
|
||||||
EndGlobalSection
|
|
||||||
EndGlobal
|
|
||||||
@ -2,12 +2,20 @@ using MaksIT.Core.Logging;
|
|||||||
using MaksIT.UScheduler;
|
using MaksIT.UScheduler;
|
||||||
using MaksIT.UScheduler.BackgroundServices;
|
using MaksIT.UScheduler.BackgroundServices;
|
||||||
using MaksIT.UScheduler.Services;
|
using MaksIT.UScheduler.Services;
|
||||||
|
using MaksIT.UScheduler.Shared;
|
||||||
using Microsoft.Extensions.Logging.Configuration;
|
using Microsoft.Extensions.Logging.Configuration;
|
||||||
using Microsoft.Extensions.Logging.EventLog;
|
using Microsoft.Extensions.Logging.EventLog;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
|
||||||
// read configuration from appsettings.json
|
// OS Guard - This application only supports Windows
|
||||||
|
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
||||||
|
Console.WriteLine("Error: MaksIT.UScheduler only supports Windows.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read configuration from appsettings.json
|
||||||
var configurationRoot = new ConfigurationBuilder()
|
var configurationRoot = new ConfigurationBuilder()
|
||||||
.SetBasePath(AppDomain.CurrentDomain.BaseDirectory)
|
.SetBasePath(AppDomain.CurrentDomain.BaseDirectory)
|
||||||
.AddJsonFile("appsettings.json", optional: true)
|
.AddJsonFile("appsettings.json", optional: true)
|
||||||
@ -15,7 +23,46 @@ var configurationRoot = new ConfigurationBuilder()
|
|||||||
|
|
||||||
// Configure strongly typed settings objects
|
// Configure strongly typed settings objects
|
||||||
var configurationSection = configurationRoot.GetSection("Configuration");
|
var configurationSection = configurationRoot.GetSection("Configuration");
|
||||||
var appSettings = configurationSection.Get<Configuration>() ?? throw new ArgumentNullException();
|
var appSettings = configurationSection.Get<Configuration>() ?? throw new InvalidOperationException("Configuration section is missing.");
|
||||||
|
if (string.IsNullOrWhiteSpace(appSettings.LogDir))
|
||||||
|
throw new InvalidOperationException("Configuration.LogDir is required. Set it in appsettings.json (e.g. \".\\Logs\").");
|
||||||
|
|
||||||
|
// Handle command-line arguments for service management
|
||||||
|
if (args.Length > 0) {
|
||||||
|
var command = args[0].ToLowerInvariant();
|
||||||
|
var exePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "MaksIT.UScheduler.exe");
|
||||||
|
var serviceName = appSettings.ServiceName;
|
||||||
|
var serviceDescription = "Windows service that allows you to schedule and invoke PowerShell Scripts and Processes";
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case "--install":
|
||||||
|
case "-i":
|
||||||
|
return InstallService(serviceName, exePath, serviceDescription);
|
||||||
|
|
||||||
|
case "--uninstall":
|
||||||
|
case "-u":
|
||||||
|
return UninstallService(serviceName);
|
||||||
|
|
||||||
|
case "--start":
|
||||||
|
return StartService(serviceName);
|
||||||
|
|
||||||
|
case "--stop":
|
||||||
|
return StopService(serviceName);
|
||||||
|
|
||||||
|
case "--status":
|
||||||
|
return GetServiceStatus(serviceName);
|
||||||
|
|
||||||
|
case "--help":
|
||||||
|
case "-h":
|
||||||
|
case "/?":
|
||||||
|
PrintHelp(serviceName);
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// If not a recognized command, continue with normal startup
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
|
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
|
||||||
builder.Services.AddWindowsService(options => {
|
builder.Services.AddWindowsService(options => {
|
||||||
@ -25,10 +72,11 @@ builder.Services.AddWindowsService(options => {
|
|||||||
// Allow configurations to be available through IOptions<Configuration>
|
// Allow configurations to be available through IOptions<Configuration>
|
||||||
builder.Services.Configure<Configuration>(configurationSection);
|
builder.Services.Configure<Configuration>(configurationSection);
|
||||||
|
|
||||||
// Logging
|
// Logging: resolve LogDir (required); relative paths are resolved against application base directory
|
||||||
var logPath = !string.IsNullOrEmpty(appSettings.LogDir)
|
var baseDir = AppDomain.CurrentDomain.BaseDirectory;
|
||||||
? Path.Combine(appSettings.LogDir)
|
var logPath = Path.IsPathRooted(appSettings.LogDir)
|
||||||
: Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
|
? appSettings.LogDir
|
||||||
|
: Path.GetFullPath(Path.Combine(baseDir, appSettings.LogDir));
|
||||||
|
|
||||||
if (!Directory.Exists(logPath)) {
|
if (!Directory.Exists(logPath)) {
|
||||||
Directory.CreateDirectory(logPath);
|
Directory.CreateDirectory(logPath);
|
||||||
@ -36,15 +84,13 @@ if (!Directory.Exists(logPath)) {
|
|||||||
|
|
||||||
builder.Logging.AddConsoleLogger(logPath);
|
builder.Logging.AddConsoleLogger(logPath);
|
||||||
|
|
||||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
LoggerProviderOptions.RegisterProviderOptions<
|
||||||
LoggerProviderOptions.RegisterProviderOptions<
|
EventLogSettings, EventLogLoggerProvider>(builder.Services);
|
||||||
EventLogSettings, EventLogLoggerProvider>(builder.Services);
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.Services.AddSingleton<ProcessService>();
|
builder.Services.AddSingleton<IProcessService, ProcessService>();
|
||||||
builder.Services.AddHostedService<ProcessBackgroundService>();
|
builder.Services.AddHostedService<ProcessBackgroundService>();
|
||||||
|
|
||||||
builder.Services.AddSingleton<PSScriptService>();
|
builder.Services.AddSingleton<IPSScriptService, PSScriptService>();
|
||||||
builder.Services.AddHostedService<PSScriptBackgroundService>();
|
builder.Services.AddHostedService<PSScriptBackgroundService>();
|
||||||
|
|
||||||
IHost host = builder.Build();
|
IHost host = builder.Build();
|
||||||
@ -54,4 +100,139 @@ var loggerFactory = builder.Logging.Services.BuildServiceProvider().GetRequiredS
|
|||||||
var testLogger = loggerFactory.CreateLogger("LoggerTest");
|
var testLogger = loggerFactory.CreateLogger("LoggerTest");
|
||||||
testLogger.LogInformation("Logger test: This should appear in your log file.");
|
testLogger.LogInformation("Logger test: This should appear in your log file.");
|
||||||
|
|
||||||
host.Run();
|
host.Run();
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
|
||||||
|
// Service management functions
|
||||||
|
static int InstallService(string serviceName, string exePath, string description) {
|
||||||
|
Console.WriteLine($"Installing service '{serviceName}'...");
|
||||||
|
|
||||||
|
// Create the service
|
||||||
|
var createResult = RunScCommand($"create \"{serviceName}\" binpath=\"{exePath}\" start=auto");
|
||||||
|
if (createResult != 0) {
|
||||||
|
Console.WriteLine("Failed to create service.");
|
||||||
|
return createResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set description
|
||||||
|
var descResult = RunScCommand($"description \"{serviceName}\" \"{description}\"");
|
||||||
|
if (descResult != 0) {
|
||||||
|
Console.WriteLine("Warning: Failed to set service description.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"Service '{serviceName}' installed successfully.");
|
||||||
|
Console.WriteLine($"Use '--start' to start the service or start it from services.msc");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int UninstallService(string serviceName) {
|
||||||
|
Console.WriteLine($"Stopping service '{serviceName}'...");
|
||||||
|
RunScCommand($"stop \"{serviceName}\"");
|
||||||
|
|
||||||
|
Console.WriteLine($"Uninstalling service '{serviceName}'...");
|
||||||
|
var result = RunScCommand($"delete \"{serviceName}\"");
|
||||||
|
|
||||||
|
if (result == 0) {
|
||||||
|
Console.WriteLine($"Service '{serviceName}' uninstalled successfully.");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Console.WriteLine("Failed to uninstall service.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int StartService(string serviceName) {
|
||||||
|
Console.WriteLine($"Starting service '{serviceName}'...");
|
||||||
|
var result = RunScCommand($"start \"{serviceName}\"");
|
||||||
|
|
||||||
|
if (result == 0) {
|
||||||
|
Console.WriteLine($"Service '{serviceName}' started successfully.");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Console.WriteLine("Failed to start service.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int StopService(string serviceName) {
|
||||||
|
Console.WriteLine($"Stopping service '{serviceName}'...");
|
||||||
|
var result = RunScCommand($"stop \"{serviceName}\"");
|
||||||
|
|
||||||
|
if (result == 0) {
|
||||||
|
Console.WriteLine($"Service '{serviceName}' stopped successfully.");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Console.WriteLine("Failed to stop service.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int GetServiceStatus(string serviceName) {
|
||||||
|
return RunScCommand($"query \"{serviceName}\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
static int RunScCommand(string arguments) {
|
||||||
|
try {
|
||||||
|
var process = new Process {
|
||||||
|
StartInfo = new ProcessStartInfo {
|
||||||
|
FileName = "sc.exe",
|
||||||
|
Arguments = arguments,
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
CreateNoWindow = true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.Start();
|
||||||
|
|
||||||
|
var output = process.StandardOutput.ReadToEnd();
|
||||||
|
var error = process.StandardError.ReadToEnd();
|
||||||
|
|
||||||
|
process.WaitForExit();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(output))
|
||||||
|
Console.WriteLine(output);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(error))
|
||||||
|
Console.WriteLine(error);
|
||||||
|
|
||||||
|
return process.ExitCode;
|
||||||
|
}
|
||||||
|
catch (Exception ex) {
|
||||||
|
Console.WriteLine($"Error executing sc.exe: {ex.Message}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void PrintHelp(string serviceName) {
|
||||||
|
Console.WriteLine($"""
|
||||||
|
MaksIT.UScheduler - Windows Service Scheduler
|
||||||
|
|
||||||
|
Usage: MaksIT.UScheduler.exe [command]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
--install, -i Install the Windows service
|
||||||
|
--uninstall, -u Uninstall the Windows service
|
||||||
|
--start Start the service
|
||||||
|
--stop Stop the service
|
||||||
|
--status Query service status
|
||||||
|
--help, -h Show this help message
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
Service Name: {serviceName}
|
||||||
|
Config File: appsettings.json
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
MaksIT.UScheduler.exe --install # Install and register the service
|
||||||
|
MaksIT.UScheduler.exe --start # Start the service
|
||||||
|
MaksIT.UScheduler.exe --stop # Stop the service
|
||||||
|
MaksIT.UScheduler.exe --uninstall # Remove the service
|
||||||
|
|
||||||
|
Note: Service management commands require administrator privileges.
|
||||||
|
""");
|
||||||
|
}
|
||||||
32
src/MaksIT.UScheduler/Services/IPSScriptService.cs
Normal file
32
src/MaksIT.UScheduler/Services/IPSScriptService.cs
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
namespace MaksIT.UScheduler.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interface for executing and managing PowerShell scripts.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPSScriptService : IDisposable {
|
||||||
|
/// <summary>
|
||||||
|
/// Executes a PowerShell script asynchronously with optional signature verification.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scriptPath">The path to the PowerShell script to execute.</param>
|
||||||
|
/// <param name="signed">If true, validates the script's Authenticode signature before execution.</param>
|
||||||
|
/// <param name="stoppingToken">Cancellation token to signal when script execution should stop.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
Task RunScriptAsync(string scriptPath, bool signed, CancellationToken stoppingToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a list of script paths that are currently being executed.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A list of script paths currently running.</returns>
|
||||||
|
List<string> GetRunningScriptTasks();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Terminates a running PowerShell script by its path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scriptPath">The path of the script to terminate.</param>
|
||||||
|
void TerminateScript(string scriptPath);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Terminates all currently running PowerShell scripts.
|
||||||
|
/// </summary>
|
||||||
|
void TerminateAllScripts();
|
||||||
|
}
|
||||||
36
src/MaksIT.UScheduler/Services/IProcessService.cs
Normal file
36
src/MaksIT.UScheduler/Services/IProcessService.cs
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
|
||||||
|
namespace MaksIT.UScheduler.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interface for managing and executing external processes.
|
||||||
|
/// </summary>
|
||||||
|
public interface IProcessService {
|
||||||
|
/// <summary>
|
||||||
|
/// Starts and monitors an external process asynchronously.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="processPath">The path to the executable to run.</param>
|
||||||
|
/// <param name="args">Optional command-line arguments to pass to the process.</param>
|
||||||
|
/// <param name="stoppingToken">Cancellation token to signal when the process should stop.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
Task RunProcessAsync(string processPath, string[]? args, CancellationToken stoppingToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the dictionary of currently running processes.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A concurrent dictionary mapping process IDs to their Process objects.</returns>
|
||||||
|
ConcurrentDictionary<int, Process> GetRunningProcesses();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Terminates a running process by its ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="processId">The ID of the process to terminate.</param>
|
||||||
|
void TerminateProcessById(int processId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Terminates all currently running processes managed by this service.
|
||||||
|
/// </summary>
|
||||||
|
void TerminateAllProcesses();
|
||||||
|
}
|
||||||
@ -1,65 +1,104 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Management.Automation;
|
using System.Management.Automation;
|
||||||
using System.Management.Automation.Runspaces;
|
using System.Management.Automation.Runspaces;
|
||||||
|
using MaksIT.UScheduler.Shared.Helpers;
|
||||||
|
using MaksIT.UScheduler.Shared.Extensions;
|
||||||
|
|
||||||
|
|
||||||
namespace MaksIT.UScheduler.Services;
|
namespace MaksIT.UScheduler.Services;
|
||||||
|
|
||||||
public sealed class PSScriptService {
|
/// <summary>
|
||||||
|
/// Service responsible for executing and managing PowerShell scripts.
|
||||||
|
/// Provides parallel script execution using a RunspacePool and supports
|
||||||
|
/// signature verification for signed scripts.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PSScriptService : IPSScriptService {
|
||||||
|
|
||||||
private readonly ILogger<PSScriptService> _logger;
|
private readonly ILogger<PSScriptService> _logger;
|
||||||
private readonly ConcurrentDictionary<string, PowerShell> _runningScripts = new ConcurrentDictionary<string, PowerShell>();
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
private readonly Runspace _rs = RunspaceFactory.CreateRunspace();
|
private readonly ConcurrentDictionary<string, PowerShell> _runningScripts = new();
|
||||||
|
private readonly RunspacePool _runspacePool;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
public PSScriptService(ILogger<PSScriptService> logger) {
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="PSScriptService"/> class.
|
||||||
|
/// Creates a RunspacePool for parallel script execution.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger instance for this service.</param>
|
||||||
|
/// <param name="loggerFactory">The logger factory for creating script-specific loggers.</param>
|
||||||
|
public PSScriptService(
|
||||||
|
ILogger<PSScriptService> logger,
|
||||||
|
ILoggerFactory loggerFactory
|
||||||
|
) {
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
if (_rs.RunspaceStateInfo.State != RunspaceState.Opened) {
|
_loggerFactory = loggerFactory;
|
||||||
_rs.Open();
|
|
||||||
_logger.LogInformation($"Runspace opened");
|
// Create a RunspacePool to allow parallel script execution
|
||||||
}
|
_runspacePool = RunspaceFactory.CreateRunspacePool(1, Environment.ProcessorCount);
|
||||||
|
_runspacePool.Open();
|
||||||
|
_logger.LogInformation($"RunspacePool opened with max {Environment.ProcessorCount} runspaces");
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task RunScript(string scriptPath, bool signed, CancellationToken stoppingToken) {
|
/// <summary>
|
||||||
_logger.LogInformation($"Preparing to run script {scriptPath}");
|
/// Executes a PowerShell script asynchronously with optional signature verification.
|
||||||
|
/// Automatically passes Automated and CurrentDateTimeUtc parameters to the script.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scriptPath">The path to the PowerShell script to execute.</param>
|
||||||
|
/// <param name="signed">If true, validates the script's Authenticode signature before execution.</param>
|
||||||
|
/// <param name="stoppingToken">Cancellation token to signal when script execution should stop.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
public async Task RunScriptAsync(string scriptPath, bool signed, CancellationToken stoppingToken) {
|
||||||
|
// Resolve relative paths against application base directory
|
||||||
|
var resolvedPath = PathHelper.ResolvePath(scriptPath);
|
||||||
|
|
||||||
|
_logger.LogInformation($"Preparing to run script {resolvedPath}");
|
||||||
|
|
||||||
if (GetRunningScriptTasks().Contains(scriptPath)) {
|
if (GetRunningScriptTasks().Contains(resolvedPath)) {
|
||||||
_logger.LogInformation($"PowerShell script {scriptPath} is already running");
|
_logger.LogInformation($"PowerShell script {resolvedPath} is already running");
|
||||||
return Task.CompletedTask;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!File.Exists(scriptPath)) {
|
if (!File.Exists(resolvedPath)) {
|
||||||
_logger.LogError($"Script file {scriptPath} does not exist");
|
_logger.LogError($"Script file {resolvedPath} does not exist");
|
||||||
return Task.CompletedTask;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!TryUnblockScript(scriptPath)) {
|
if (!TryUnblockScript(resolvedPath)) {
|
||||||
_logger.LogError($"Script {scriptPath} could not be unblocked. Aborting execution.");
|
_logger.LogError($"Script {resolvedPath} could not be unblocked. Aborting execution.");
|
||||||
return Task.CompletedTask;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var ps = PowerShell.Create();
|
var ps = PowerShell.Create();
|
||||||
ps.Runspace = _rs;
|
ps.RunspacePool = _runspacePool;
|
||||||
_runningScripts.TryAdd(scriptPath, ps);
|
|
||||||
|
if (!_runningScripts.TryAdd(resolvedPath, ps)) {
|
||||||
|
_logger.LogWarning($"Script {resolvedPath} was already added by another thread");
|
||||||
|
ps.Dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Set execution policy
|
||||||
var scriptPolicy = signed ? "AllSigned" : "Unrestricted";
|
var scriptPolicy = signed ? "AllSigned" : "Unrestricted";
|
||||||
ps.AddScript($"Set-ExecutionPolicy -Scope Process -ExecutionPolicy {scriptPolicy}");
|
ps.AddScript($"Set-ExecutionPolicy -Scope Process -ExecutionPolicy {scriptPolicy}");
|
||||||
ps.Invoke();
|
await Task.Factory.FromAsync(ps.BeginInvoke(), ps.EndInvoke);
|
||||||
|
|
||||||
if (signed) {
|
if (signed) {
|
||||||
ps.Commands.Clear();
|
ps.Commands.Clear();
|
||||||
ps.AddScript($"Get-AuthenticodeSignature \"{scriptPath}\"");
|
ps.AddScript($"Get-AuthenticodeSignature \"{resolvedPath}\"");
|
||||||
var signatureResults = ps.Invoke();
|
var signatureResults = await Task.Factory.FromAsync(ps.BeginInvoke(), ps.EndInvoke);
|
||||||
if (signatureResults.Count == 0 || ((Signature)signatureResults[0].BaseObject).Status != SignatureStatus.Valid) {
|
if (signatureResults.Count == 0 || ((Signature)signatureResults[0].BaseObject).Status != SignatureStatus.Valid) {
|
||||||
_logger.LogWarning($"Script {scriptPath} signature is invalid. Correct and restart the service.");
|
_logger.LogWarning($"Script {resolvedPath} signature is invalid. Correct and restart the service.");
|
||||||
return Task.CompletedTask;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation($"Invoking: {scriptPath}");
|
stoppingToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
_logger.LogInformation($"Invoking: {resolvedPath}");
|
||||||
|
|
||||||
ps.Commands.Clear();
|
ps.Commands.Clear();
|
||||||
var myCommand = new Command(scriptPath);
|
var myCommand = new Command(resolvedPath);
|
||||||
|
|
||||||
var currentDateTimeUtcString = DateTime.UtcNow.ToString("o");
|
var currentDateTimeUtcString = DateTime.UtcNow.ToString("o");
|
||||||
myCommand.Parameters.Add(new CommandParameter("Automated", true));
|
myCommand.Parameters.Add(new CommandParameter("Automated", true));
|
||||||
@ -68,35 +107,45 @@ public sealed class PSScriptService {
|
|||||||
|
|
||||||
_logger.LogInformation($"Added parameters: Automated=true, CurrentDateTimeUtc={currentDateTimeUtcString}");
|
_logger.LogInformation($"Added parameters: Automated=true, CurrentDateTimeUtc={currentDateTimeUtcString}");
|
||||||
|
|
||||||
|
// Execute asynchronously
|
||||||
|
var outputResults = await Task.Factory.FromAsync(ps.BeginInvoke(), ps.EndInvoke);
|
||||||
|
|
||||||
|
#region Script Output Logging
|
||||||
|
var scriptLogger = _loggerFactory.CreateFolderLogger(resolvedPath);
|
||||||
|
|
||||||
// Log standard output
|
// Log standard output
|
||||||
var outputResults = ps.Invoke();
|
|
||||||
if (outputResults != null && outputResults.Count > 0) {
|
if (outputResults != null && outputResults.Count > 0) {
|
||||||
foreach (var outputItem in outputResults) {
|
foreach (var outputItem in outputResults) {
|
||||||
_logger.LogInformation($"[PS Output] {outputItem}");
|
scriptLogger.LogInformation($"[PS Output] {outputItem}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log errors
|
// Log errors
|
||||||
if (ps.Streams.Error.Count > 0) {
|
if (ps.Streams.Error.Count > 0) {
|
||||||
foreach (var errorItem in ps.Streams.Error) {
|
foreach (var errorItem in ps.Streams.Error) {
|
||||||
_logger.LogError($"[PS Error] {errorItem}");
|
scriptLogger.LogError($"[PS Error] {errorItem}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) {
|
catch (OperationCanceledException) {
|
||||||
_logger.LogInformation($"Stopping script {scriptPath} due to cancellation request");
|
_logger.LogInformation($"Stopping script {resolvedPath} due to cancellation request");
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
_logger.LogError($"Error running script {scriptPath}: {ex.Message}");
|
_logger.LogError($"Error running script {resolvedPath}: {ex.Message}");
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
TerminateScript(scriptPath);
|
TerminateScript(resolvedPath);
|
||||||
_logger.LogInformation($"Script {scriptPath} completed and removed from running scripts");
|
_logger.LogInformation($"Script {resolvedPath} completed and removed from running scripts");
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to unblock a downloaded script by removing the Zone.Identifier alternate data stream.
|
||||||
|
/// This is equivalent to right-clicking a file and selecting "Unblock" in Windows.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scriptPath">The path to the script to unblock.</param>
|
||||||
|
/// <returns>True if the script was successfully unblocked or was not blocked; false if unblocking failed.</returns>
|
||||||
private bool TryUnblockScript(string scriptPath) {
|
private bool TryUnblockScript(string scriptPath) {
|
||||||
try {
|
try {
|
||||||
var zoneIdentifier = scriptPath + ":Zone.Identifier";
|
var zoneIdentifier = scriptPath + ":Zone.Identifier";
|
||||||
@ -112,20 +161,57 @@ public sealed class PSScriptService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a list of script paths that are currently being executed.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A list of script paths currently running.</returns>
|
||||||
public List<string> GetRunningScriptTasks() {
|
public List<string> GetRunningScriptTasks() {
|
||||||
_logger.LogInformation($"Retrieving running script tasks. Current count: {_runningScripts.Count}");
|
_logger.LogInformation($"Retrieving running script tasks. Current count: {_runningScripts.Count}");
|
||||||
return _runningScripts.Keys.ToList();
|
return _runningScripts.Keys.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Terminates a running PowerShell script by its path.
|
||||||
|
/// Stops the script execution and disposes of its PowerShell instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scriptPath">The path of the script to terminate.</param>
|
||||||
public void TerminateScript(string scriptPath) {
|
public void TerminateScript(string scriptPath) {
|
||||||
_logger.LogInformation($"Attempting to terminate script {scriptPath}");
|
_logger.LogInformation($"Attempting to terminate script {scriptPath}");
|
||||||
|
|
||||||
if (_runningScripts.TryRemove(scriptPath, out var ps)) {
|
if (_runningScripts.TryRemove(scriptPath, out var ps)) {
|
||||||
ps.Stop();
|
ps.Stop();
|
||||||
|
ps.Dispose();
|
||||||
_logger.LogInformation($"Script {scriptPath} terminated");
|
_logger.LogInformation($"Script {scriptPath} terminated");
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
_logger.LogWarning($"Failed to terminate script {scriptPath}. Script not found.");
|
_logger.LogWarning($"Failed to terminate script {scriptPath}. Script not found.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Terminates all currently running PowerShell scripts.
|
||||||
|
/// </summary>
|
||||||
|
public void TerminateAllScripts() {
|
||||||
|
_logger.LogInformation("Terminating all running scripts");
|
||||||
|
|
||||||
|
foreach (var scriptPath in _runningScripts.Keys.ToList()) {
|
||||||
|
TerminateScript(scriptPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases all resources used by the <see cref="PSScriptService"/>.
|
||||||
|
/// Terminates all running scripts and closes the RunspacePool.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose() {
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
TerminateAllScripts();
|
||||||
|
|
||||||
|
_runspacePool?.Close();
|
||||||
|
_runspacePool?.Dispose();
|
||||||
|
_logger.LogInformation("RunspacePool disposed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,90 +1,126 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using MaksIT.UScheduler.Shared.Helpers;
|
||||||
|
using MaksIT.UScheduler.Shared.Extensions;
|
||||||
|
|
||||||
|
|
||||||
namespace MaksIT.UScheduler.Services;
|
namespace MaksIT.UScheduler.Services;
|
||||||
|
|
||||||
public sealed class ProcessService {
|
/// <summary>
|
||||||
|
/// Service responsible for managing and executing external processes.
|
||||||
|
/// Tracks running processes and provides methods for starting, monitoring, and terminating them.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProcessService : IProcessService {
|
||||||
|
|
||||||
private readonly ILogger<ProcessService> _logger;
|
private readonly ILogger _logger;
|
||||||
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
private readonly ConcurrentDictionary<int, Process> _runningProcesses = new();
|
private readonly ConcurrentDictionary<int, Process> _runningProcesses = new();
|
||||||
|
|
||||||
public ProcessService(ILogger<ProcessService> logger) {
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ProcessService"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger instance for this service.</param>
|
||||||
|
/// <param name="loggerFactory">The logger factory for creating process-specific loggers.</param>
|
||||||
|
public ProcessService(
|
||||||
|
ILogger<ProcessService> logger,
|
||||||
|
ILoggerFactory loggerFactory
|
||||||
|
) {
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_loggerFactory = loggerFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RunProcess(string processPath, string[] args, CancellationToken stoppingToken) {
|
/// <summary>
|
||||||
_logger.LogInformation($"Starting process {processPath} with arguments {string.Join(", ", args)}");
|
/// Starts and monitors an external process asynchronously.
|
||||||
|
/// If the process exits with a non-zero code, it will be automatically restarted.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="processPath">The path to the executable to run.</param>
|
||||||
|
/// <param name="args">Optional command-line arguments to pass to the process.</param>
|
||||||
|
/// <param name="stoppingToken">Cancellation token to signal when the process should stop.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
public async Task RunProcessAsync(string processPath, string[]? args, CancellationToken stoppingToken) {
|
||||||
|
// Resolve relative paths against application base directory
|
||||||
|
var resolvedPath = PathHelper.ResolvePath(processPath);
|
||||||
|
var processLogger = _loggerFactory.CreateFolderLogger(resolvedPath);
|
||||||
|
var argsString = args != null ? string.Join(", ", args) : "";
|
||||||
|
|
||||||
|
processLogger.LogInformation($"Starting process {resolvedPath} with arguments {argsString}");
|
||||||
|
|
||||||
Process? process = null;
|
Process? process = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (GetRunningProcesses().Any(x => x.Value.StartInfo.FileName == processPath)) {
|
if (GetRunningProcesses().Any(x => x.Value.StartInfo.FileName == resolvedPath)) {
|
||||||
_logger.LogInformation($"Process {processPath} is already running");
|
processLogger.LogInformation($"Process {resolvedPath} is already running");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
process = new Process();
|
process = new Process();
|
||||||
|
|
||||||
process.StartInfo = new ProcessStartInfo {
|
process.StartInfo = new ProcessStartInfo {
|
||||||
FileName = processPath,
|
FileName = resolvedPath,
|
||||||
WorkingDirectory = Path.GetDirectoryName(processPath),
|
WorkingDirectory = Path.GetDirectoryName(resolvedPath),
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
RedirectStandardOutput = true,
|
RedirectStandardOutput = true,
|
||||||
RedirectStandardError = true
|
RedirectStandardError = true
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var arg in args)
|
if (args != null) {
|
||||||
process.StartInfo.ArgumentList.Add(arg);
|
foreach (var arg in args)
|
||||||
|
process.StartInfo.ArgumentList.Add(arg);
|
||||||
|
}
|
||||||
|
|
||||||
process.Start();
|
process.Start();
|
||||||
_runningProcesses.TryAdd(process.Id, process);
|
_runningProcesses.TryAdd(process.Id, process);
|
||||||
|
|
||||||
_logger.LogInformation($"Process {processPath} started with ID {process.Id}");
|
processLogger.LogInformation($"Process {resolvedPath} started with ID {process.Id}");
|
||||||
|
|
||||||
await process.WaitForExitAsync();
|
await process.WaitForExitAsync();
|
||||||
|
|
||||||
if (process.ExitCode != 0 && !stoppingToken.IsCancellationRequested) {
|
if (process.ExitCode != 0 && !stoppingToken.IsCancellationRequested) {
|
||||||
_logger.LogWarning($"Process {processPath} exited with code {process.ExitCode}");
|
processLogger.LogWarning($"Process {resolvedPath} exited with code {process.ExitCode}, restarting...");
|
||||||
await RunProcess(processPath, args, stoppingToken);
|
await RunProcessAsync(resolvedPath, args, stoppingToken);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
_logger.LogInformation($"Process {processPath} completed successfully");
|
processLogger.LogInformation($"Process {resolvedPath} completed successfully with exit code {process.ExitCode}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) {
|
catch (OperationCanceledException) {
|
||||||
// When the stopping token is canceled, for example, a call made from services.msc,
|
// When the stopping token is canceled, for example, a call made from services.msc,
|
||||||
// we shouldn't exit with a non-zero exit code. In other words, this is expected...
|
// we shouldn't exit with a non-zero exit code. In other words, this is expected...
|
||||||
_logger.LogWarning($"Process {processPath} was canceled");
|
processLogger.LogInformation($"Process {resolvedPath} was canceled");
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
_logger.LogError($"Error running process {processPath}: {ex.Message}");
|
processLogger.LogError($"Error running process {resolvedPath}: {ex.Message}");
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
if (process != null && _runningProcesses.ContainsKey(process.Id)) {
|
if (process != null && _runningProcesses.ContainsKey(process.Id)) {
|
||||||
TerminateProcessById(process.Id);
|
TerminateProcessById(process.Id);
|
||||||
|
processLogger.LogInformation($"Process {resolvedPath} with ID {process.Id} removed from running processes");
|
||||||
_logger.LogInformation($"Process {processPath} with ID {process.Id} removed from running processes");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the dictionary of currently running processes.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A concurrent dictionary mapping process IDs to their Process objects.</returns>
|
||||||
public ConcurrentDictionary<int, Process> GetRunningProcesses() {
|
public ConcurrentDictionary<int, Process> GetRunningProcesses() {
|
||||||
_logger.LogInformation($"Retrieving running processes. Current count: {_runningProcesses.Count}");
|
|
||||||
return _runningProcesses;
|
return _runningProcesses;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Terminates a running process by its ID.
|
||||||
|
/// Recursively attempts to kill the process until it has exited.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="processId">The ID of the process to terminate.</param>
|
||||||
public void TerminateProcessById(int processId) {
|
public void TerminateProcessById(int processId) {
|
||||||
// Check if the process is in the running processes list
|
// Check if the process is in the running processes list
|
||||||
if (!_runningProcesses.TryGetValue(processId, out var processToTerminate)) {
|
if (!_runningProcesses.TryGetValue(processId, out var processToTerminate)) {
|
||||||
_logger.LogWarning($"Failed to terminate process {processId}. Process not found.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kill the process
|
// Kill the process
|
||||||
try {
|
try {
|
||||||
processToTerminate.Kill(true);
|
processToTerminate.Kill(true);
|
||||||
_logger.LogInformation($"Process {processId} terminated");
|
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
_logger.LogError($"Error terminating process {processId}: {ex.Message}");
|
_logger.LogError($"Error terminating process {processId}: {ex.Message}");
|
||||||
@ -92,7 +128,17 @@ public sealed class ProcessService {
|
|||||||
|
|
||||||
// Check if the process has exited
|
// Check if the process has exited
|
||||||
if (!processToTerminate.HasExited) {
|
if (!processToTerminate.HasExited) {
|
||||||
_logger.LogWarning($"Failed to terminate process {processId}. Process still running.");
|
TerminateProcessById(processId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Terminates all currently running processes managed by this service.
|
||||||
|
/// </summary>
|
||||||
|
public void TerminateAllProcesses() {
|
||||||
|
_logger.LogInformation("Terminating all running processes");
|
||||||
|
|
||||||
|
foreach (var processId in _runningProcesses.Keys.ToList()) {
|
||||||
TerminateProcessById(processId);
|
TerminateProcessById(processId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
sc.exe delete "MaksIT.UScheduler Service"
|
|
||||||
pause
|
|
||||||
@ -13,7 +13,32 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Configuration": {
|
"Configuration": {
|
||||||
|
"LogDir": ".\\logs",
|
||||||
"Powershell": [
|
"Powershell": [
|
||||||
|
{
|
||||||
|
"Path": "..\\..\\..\\..\\..\\Scripts\\File-Sync\\file-sync.ps1",
|
||||||
|
"Name": "File Sync",
|
||||||
|
"IsSigned": false,
|
||||||
|
"Disabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Path": "..\\..\\..\\..\\..\\Scripts\\HyperV-Backup\\hyper-v-backup.ps1",
|
||||||
|
"Name": "HyperV Backup",
|
||||||
|
"IsSigned": false,
|
||||||
|
"Disabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Path": "..\\..\\..\\..\\..\\Scripts\\Native-Sync\\native-sync.ps1",
|
||||||
|
"Name": "Native Sync",
|
||||||
|
"IsSigned": false,
|
||||||
|
"Disabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Path": "..\\..\\..\\..\\..\\Scripts\\Windows-Update\\windows-update.ps1",
|
||||||
|
"Name": "Windows Update",
|
||||||
|
"IsSigned": false,
|
||||||
|
"Disabled": true
|
||||||
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
"Processes": [
|
"Processes": [
|
||||||
|
|||||||
@ -1,173 +0,0 @@
|
|||||||
# Set GH_TOKEN from custom environment variable for GitHub CLI authentication
|
|
||||||
$env:GH_TOKEN = $env:GITHUB_MAKS_IT_COM
|
|
||||||
|
|
||||||
# Paths
|
|
||||||
$csprojPath = "MaksIT.UScheduler\MaksIT.UScheduler.csproj"
|
|
||||||
$publishDir = "publish"
|
|
||||||
$releaseDir = "release"
|
|
||||||
$changelogPath = "..\CHANGELOG.md"
|
|
||||||
|
|
||||||
# Helper: ensure required commands exist
|
|
||||||
function Assert-Command {
|
|
||||||
param([string]$cmd)
|
|
||||||
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) {
|
|
||||||
Write-Error "Required command '$cmd' is missing. Aborting."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Assert-Command dotnet
|
|
||||||
Assert-Command git
|
|
||||||
Assert-Command gh
|
|
||||||
|
|
||||||
# 1. Get version from .csproj
|
|
||||||
[xml]$csproj = Get-Content $csprojPath
|
|
||||||
|
|
||||||
# Support multiple PropertyGroups
|
|
||||||
$version = ($csproj.Project.PropertyGroup |
|
|
||||||
Where-Object { $_.Version } |
|
|
||||||
Select-Object -First 1).Version
|
|
||||||
|
|
||||||
if (-not $version) {
|
|
||||||
Write-Error "Version not found in $csprojPath"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Version detected: $version"
|
|
||||||
|
|
||||||
# 2. Publish the project
|
|
||||||
Write-Host "Publishing project..."
|
|
||||||
dotnet publish $csprojPath -c Release -o $publishDir
|
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
Write-Error "dotnet publish failed."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# 3. Prepare release directory
|
|
||||||
if (!(Test-Path $releaseDir)) {
|
|
||||||
New-Item -ItemType Directory -Path $releaseDir | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
# 4. Create zip file
|
|
||||||
$zipName = "maksit.uscheduler-$version.zip"
|
|
||||||
$zipPath = Join-Path $releaseDir $zipName
|
|
||||||
|
|
||||||
if (Test-Path $zipPath) {
|
|
||||||
Remove-Item $zipPath -Force
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Creating archive $zipName ..."
|
|
||||||
Compress-Archive -Path "$publishDir\*" -DestinationPath $zipPath -Force
|
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0 -or -not (Test-Path $zipPath)) {
|
|
||||||
Write-Error "Failed to create archive $zipPath"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Release zip created: $zipPath"
|
|
||||||
|
|
||||||
# 5.a Extract related changelog section from CHANGELOG.md
|
|
||||||
if (-not (Test-Path $changelogPath)) {
|
|
||||||
Write-Error "CHANGELOG.md not found."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
$changelog = Get-Content $changelogPath -Raw
|
|
||||||
|
|
||||||
# Regex pattern to get the changelog section for the current version
|
|
||||||
$pattern = "(?ms)^##\s+v$([regex]::Escape($version))\b.*?(?=^##\s+v\d+\.\d+\.\d+|\Z)"
|
|
||||||
|
|
||||||
$match = [regex]::Match($changelog, $pattern)
|
|
||||||
|
|
||||||
if (-not $match.Success) {
|
|
||||||
Write-Error "Changelog entry for version $version not found."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
$releaseNotes = $match.Value.Trim()
|
|
||||||
|
|
||||||
Write-Host "Extracted release notes for ${version}:"
|
|
||||||
Write-Host "----------------------------------------"
|
|
||||||
Write-Host $releaseNotes
|
|
||||||
Write-Host "----------------------------------------"
|
|
||||||
|
|
||||||
# 5. Create GitHub Release (requires GitHub CLI)
|
|
||||||
# Get remote URL
|
|
||||||
$remoteUrl = git config --get remote.origin.url
|
|
||||||
if ($LASTEXITCODE -ne 0 -or -not $remoteUrl) {
|
|
||||||
Write-Error "Could not determine git remote origin URL."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract owner/repo from URL (supports HTTPS and SSH)
|
|
||||||
if ($remoteUrl -match "[:/](?<owner>[^/]+)/(?<repo>[^/.]+)(\.git)?$") {
|
|
||||||
$owner = $matches['owner']
|
|
||||||
$repoName = $matches['repo']
|
|
||||||
$repo = "$owner/$repoName"
|
|
||||||
} else {
|
|
||||||
Write-Error "Could not parse GitHub repo from remote URL: $remoteUrl"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
$tag = "v$version"
|
|
||||||
$releaseName = "Release $version"
|
|
||||||
|
|
||||||
Write-Host "Repository detected: $repo"
|
|
||||||
Write-Host "Tag to be created: $tag"
|
|
||||||
|
|
||||||
# Ensure GH_TOKEN is set
|
|
||||||
if (-not $env:GH_TOKEN) {
|
|
||||||
Write-Error "GH_TOKEN environment variable is not set. Set GITHUB_MAKS_IT_COM and rerun."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Authenticating GitHub CLI using GH_TOKEN..."
|
|
||||||
|
|
||||||
# Reliable authentication test
|
|
||||||
$authTest = gh api user 2>$null
|
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0 -or -not $authTest) {
|
|
||||||
Write-Error "GitHub CLI authentication failed. GH_TOKEN may be invalid or missing repo scope."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "GitHub CLI authenticated successfully via GH_TOKEN."
|
|
||||||
|
|
||||||
# Create or replace release
|
|
||||||
Write-Host "Creating GitHub release for $repo ..."
|
|
||||||
|
|
||||||
# Check if release already exists
|
|
||||||
$existing = gh release list --repo $repo | Select-String "^$tag\s"
|
|
||||||
|
|
||||||
if ($existing) {
|
|
||||||
Write-Host "Tag $tag already exists. Deleting old release..."
|
|
||||||
gh release delete $tag --repo $repo --yes
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
Write-Error "Failed to delete existing release $tag."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create new release with extracted changelog section
|
|
||||||
gh release create $tag $zipPath `
|
|
||||||
--repo $repo `
|
|
||||||
--title "${releaseName}" `
|
|
||||||
--notes "${releaseNotes}"
|
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
Write-Error "Failed to create GitHub release for tag $tag."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "GitHub release created successfully."
|
|
||||||
|
|
||||||
# Cleanup temporary directories
|
|
||||||
if (Test-Path $publishDir) {
|
|
||||||
Remove-Item $publishDir -Recurse -Force
|
|
||||||
Write-Host "Cleaned up $publishDir directory."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Keep release artifacts
|
|
||||||
Write-Host "Release artifacts kept in: $releaseDir"
|
|
||||||
Write-Host "Done."
|
|
||||||
@ -6,8 +6,12 @@
|
|||||||
"lastModified": "2026-01-26",
|
"lastModified": "2026-01-26",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"runMonth": [],
|
"runMonth": [],
|
||||||
"runWeekday": ["Monday"],
|
"runWeekday": [
|
||||||
"runTime": ["00:00"],
|
"Monday"
|
||||||
|
],
|
||||||
|
"runTime": [
|
||||||
|
"00:00"
|
||||||
|
],
|
||||||
"minIntervalMinutes": 10
|
"minIntervalMinutes": 10
|
||||||
},
|
},
|
||||||
"freeFileSyncExe": "C:\\Program Files\\FreeFileSync\\FreeFileSync.exe",
|
"freeFileSyncExe": "C:\\Program Files\\FreeFileSync\\FreeFileSync.exe",
|
||||||
@ -28,4 +32,4 @@
|
|||||||
"nasRootShare": "UNC path to NAS root share for authentication. This is only used for connecting to the share, not for modifying batch file paths.",
|
"nasRootShare": "UNC path to NAS root share for authentication. This is only used for connecting to the share, not for modifying batch file paths.",
|
||||||
"credentialEnvVar": "Name of Machine-level environment variable containing Base64-encoded 'username:password' for NAS authentication"
|
"credentialEnvVar": "Name of Machine-level environment variable containing Base64-encoded 'username:password' for NAS authentication"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -62,10 +62,7 @@ Windows-Update/
|
|||||||
"runWeekday": ["Wednesday"],
|
"runWeekday": ["Wednesday"],
|
||||||
"runTime": ["02:00"]
|
"runTime": ["02:00"]
|
||||||
},
|
},
|
||||||
"updateCategories": [
|
"updateCategories": "all"
|
||||||
"Critical Updates",
|
|
||||||
"Security Updates"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -92,20 +89,38 @@ Windows-Update/
|
|||||||
|
|
||||||
### Update Categories
|
### Update Categories
|
||||||
|
|
||||||
Available categories to install:
|
The `updateCategories` setting supports two formats:
|
||||||
|
|
||||||
|
| Format | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `"all"` | Install updates from all categories (default) |
|
||||||
|
| `["Category1", "Category2"]` | Install only from specified categories |
|
||||||
|
|
||||||
|
**Available Categories:**
|
||||||
|
|
||||||
| Category | Description |
|
| Category | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `Critical Updates` | Critical security and stability updates |
|
| `Critical Updates` | Critical security and stability updates |
|
||||||
| `Security Updates` | Security-focused updates |
|
| `Security Updates` | Security-focused updates |
|
||||||
| `Definition Updates` | Antivirus and malware definition updates |
|
| `Definition Updates` | Antivirus/Defender definition updates (e.g., KB2267602) |
|
||||||
| `Update Rollups` | Cumulative update packages |
|
| `Update Rollups` | Cumulative update packages |
|
||||||
|
| `Updates` | General updates |
|
||||||
| `Feature Packs` | New feature additions |
|
| `Feature Packs` | New feature additions |
|
||||||
| `Service Packs` | Major cumulative updates |
|
| `Service Packs` | Major cumulative updates |
|
||||||
| `Tools` | System tools and utilities |
|
| `Tools` | System tools and utilities |
|
||||||
| `Drivers` | Hardware driver updates |
|
| `Drivers` | Hardware driver updates |
|
||||||
|
| `Upgrades` | Windows version upgrades |
|
||||||
|
|
||||||
**Example:**
|
**Examples:**
|
||||||
|
|
||||||
|
Install all updates (default):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"updateCategories": "all"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Security-focused updates only:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"updateCategories": [
|
"updateCategories": [
|
||||||
@ -116,6 +131,22 @@ Available categories to install:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Everything except drivers:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"updateCategories": [
|
||||||
|
"Critical Updates",
|
||||||
|
"Security Updates",
|
||||||
|
"Definition Updates",
|
||||||
|
"Update Rollups",
|
||||||
|
"Updates",
|
||||||
|
"Feature Packs",
|
||||||
|
"Service Packs",
|
||||||
|
"Tools"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Exclusions
|
### Exclusions
|
||||||
|
|
||||||
| Property | Type | Description | Example |
|
| Property | Type | Description | Example |
|
||||||
@ -268,6 +299,8 @@ When `-Automated` is specified:
|
|||||||
[INFO] ==========================================
|
[INFO] ==========================================
|
||||||
[INFO] Windows Update Process Started
|
[INFO] Windows Update Process Started
|
||||||
[INFO] Script Version: 1.0.0 (2026-01-28)
|
[INFO] Script Version: 1.0.0 (2026-01-28)
|
||||||
|
[INFO] Update Categories: ALL (installing all available updates)
|
||||||
|
[Info] Available: Critical Updates, Definition Updates, Security Updates, Update Rollups, Updates
|
||||||
[INFO] ==========================================
|
[INFO] ==========================================
|
||||||
[SUCCESS] PSWindowsUpdate module loaded
|
[SUCCESS] PSWindowsUpdate module loaded
|
||||||
[INFO] Running pre-update checks...
|
[INFO] Running pre-update checks...
|
||||||
@ -422,12 +455,13 @@ Test without installing updates (set in scriptsettings.json):
|
|||||||
|
|
||||||
1. **Test First** - Always test with `dryRun: true` before actual execution
|
1. **Test First** - Always test with `dryRun: true` before actual execution
|
||||||
2. **Schedule Wisely** - Run during maintenance windows (nights, weekends)
|
2. **Schedule Wisely** - Run during maintenance windows (nights, weekends)
|
||||||
3. **Start Conservative** - Begin with Critical/Security updates only
|
3. **Use "all" Categories** - Default `"all"` ensures no updates are missed (including Defender definitions)
|
||||||
4. **Monitor Results** - Review update reports and logs regularly
|
4. **Use Exclusions** - Exclude specific problematic updates by KB number or title pattern instead of limiting categories
|
||||||
5. **Backup First** - Ensure system backups before major updates
|
5. **Monitor Results** - Review update reports and logs regularly
|
||||||
6. **Reboot Testing** - Test `rebootBehavior: "auto"` in non-production environment first
|
6. **Backup First** - Ensure system backups before major updates
|
||||||
7. **Exclusion Management** - Keep exclusions list minimal and documented
|
7. **Reboot Testing** - Test `rebootBehavior: "auto"` in non-production environment first
|
||||||
8. **Review Failures** - Investigate and resolve failed updates promptly
|
8. **Exclusion Management** - Keep exclusions list minimal and documented
|
||||||
|
9. **Review Failures** - Investigate and resolve failed updates promptly
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
@ -457,6 +491,12 @@ Test without installing updates (set in scriptsettings.json):
|
|||||||
|
|
||||||
## Version History
|
## Version History
|
||||||
|
|
||||||
|
### 1.0.1 (2026-01-30)
|
||||||
|
- Added `"all"` option for updateCategories (new default)
|
||||||
|
- Fixed category matching for PSWindowsUpdate Category objects
|
||||||
|
- Added `Updates` and `Upgrades` to documented categories
|
||||||
|
- Improved logging to show category mode at startup
|
||||||
|
|
||||||
### 1.0.0 (2026-01-28)
|
### 1.0.0 (2026-01-28)
|
||||||
- Initial release
|
- Initial release
|
||||||
- PSWindowsUpdate integration
|
- PSWindowsUpdate integration
|
||||||
@ -10,12 +10,7 @@
|
|||||||
"runTime": ["02:00"],
|
"runTime": ["02:00"],
|
||||||
"minIntervalMinutes": 60
|
"minIntervalMinutes": 60
|
||||||
},
|
},
|
||||||
"updateCategories": [
|
"updateCategories": "all",
|
||||||
"Critical Updates",
|
|
||||||
"Security Updates",
|
|
||||||
"Definition Updates",
|
|
||||||
"Update Rollups"
|
|
||||||
],
|
|
||||||
"exclusions": {
|
"exclusions": {
|
||||||
"kbNumbers": [],
|
"kbNumbers": [],
|
||||||
"titlePatterns": [
|
"titlePatterns": [
|
||||||
@ -33,7 +28,7 @@
|
|||||||
"dryRun": false
|
"dryRun": false
|
||||||
},
|
},
|
||||||
"reporting": {
|
"reporting": {
|
||||||
"generateReport": true,
|
"generateReport": false,
|
||||||
"emailNotification": false,
|
"emailNotification": false,
|
||||||
"emailSettings": {
|
"emailSettings": {
|
||||||
"smtpServer": "smtp.example.com",
|
"smtpServer": "smtp.example.com",
|
||||||
@ -53,7 +48,7 @@
|
|||||||
"runTime": "Array of UTC times in HH:mm format when updates should run.",
|
"runTime": "Array of UTC times in HH:mm format when updates should run.",
|
||||||
"minIntervalMinutes": "Minimum minutes between update runs to prevent duplicate executions."
|
"minIntervalMinutes": "Minimum minutes between update runs to prevent duplicate executions."
|
||||||
},
|
},
|
||||||
"updateCategories": "Array of update categories to install. Available: 'Critical Updates', 'Security Updates', 'Definition Updates', 'Update Rollups', 'Feature Packs', 'Service Packs', 'Tools', 'Drivers'",
|
"updateCategories": "Set to 'all' to install all updates regardless of category, or provide an array of specific categories. Available categories: 'Critical Updates', 'Security Updates', 'Definition Updates', 'Update Rollups', 'Updates', 'Feature Packs', 'Service Packs', 'Tools', 'Drivers', 'Upgrades'",
|
||||||
"exclusions": {
|
"exclusions": {
|
||||||
"kbNumbers": "Array of KB numbers to exclude (e.g. 'KB5034441')",
|
"kbNumbers": "Array of KB numbers to exclude (e.g. 'KB5034441')",
|
||||||
"titlePatterns": "Array of wildcard patterns to exclude by title (e.g. '*Preview*', '*Optional*')"
|
"titlePatterns": "Array of wildcard patterns to exclude by title (e.g. '*Preview*', '*Optional*')"
|
||||||
@ -55,7 +55,7 @@ catch {
|
|||||||
# Process Settings =========================================================
|
# Process Settings =========================================================
|
||||||
|
|
||||||
# Validate required settings
|
# Validate required settings
|
||||||
$requiredSettings = @('updateCategories', 'preChecks', 'options')
|
$requiredSettings = @('preChecks', 'options')
|
||||||
foreach ($setting in $requiredSettings) {
|
foreach ($setting in $requiredSettings) {
|
||||||
if (-not $settings.$setting) {
|
if (-not $settings.$setting) {
|
||||||
Write-Error "Required setting '$setting' is missing or empty in $settingsFile"
|
Write-Error "Required setting '$setting' is missing or empty in $settingsFile"
|
||||||
@ -63,13 +63,34 @@ foreach ($setting in $requiredSettings) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Validate updateCategories separately (can be string "all" or array)
|
||||||
|
if (-not $settings.updateCategories) {
|
||||||
|
Write-Error "Required setting 'updateCategories' is missing in $settingsFile"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
# Extract settings
|
# Extract settings
|
||||||
$UpdateCategories = $settings.updateCategories
|
$UpdateCategoriesSetting = $settings.updateCategories
|
||||||
$Exclusions = $settings.exclusions
|
$Exclusions = $settings.exclusions
|
||||||
$PreChecks = $settings.preChecks
|
$PreChecks = $settings.preChecks
|
||||||
$Options = $settings.options
|
$Options = $settings.options
|
||||||
$Reporting = $settings.reporting
|
$Reporting = $settings.reporting
|
||||||
|
|
||||||
|
# Process updateCategories - can be "all" or an array of category names
|
||||||
|
$InstallAllCategories = $false
|
||||||
|
$UpdateCategories = @()
|
||||||
|
|
||||||
|
if ($UpdateCategoriesSetting -is [string] -and $UpdateCategoriesSetting -eq "all") {
|
||||||
|
$InstallAllCategories = $true
|
||||||
|
}
|
||||||
|
elseif ($UpdateCategoriesSetting -is [array]) {
|
||||||
|
$UpdateCategories = $UpdateCategoriesSetting
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Error "Invalid updateCategories setting. Must be 'all' or an array of category names."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
# Get DryRun from settings
|
# Get DryRun from settings
|
||||||
$DryRun = $Options.dryRun
|
$DryRun = $Options.dryRun
|
||||||
|
|
||||||
@ -130,6 +151,77 @@ function Test-PSWindowsUpdate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Get-AllUpdateCategories {
|
||||||
|
param([switch]$Automated)
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Use Windows Update COM API
|
||||||
|
$updateSession = New-Object -ComObject Microsoft.Update.Session
|
||||||
|
$updateSearcher = $updateSession.CreateUpdateSearcher()
|
||||||
|
|
||||||
|
# Set to search Microsoft Update (includes more categories)
|
||||||
|
$microsoftUpdateServiceId = "7971f918-a847-4430-9279-4a52d1efe18d"
|
||||||
|
try {
|
||||||
|
$updateSearcher.ServiceID = $microsoftUpdateServiceId
|
||||||
|
$updateSearcher.ServerSelection = 3 # ssOthers
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# Fall back to default
|
||||||
|
}
|
||||||
|
|
||||||
|
$categories = @{}
|
||||||
|
|
||||||
|
# Method 1: Search for pending and installed updates
|
||||||
|
$searches = @("IsInstalled=0", "IsInstalled=1 and IsHidden=0")
|
||||||
|
|
||||||
|
foreach ($searchCriteria in $searches) {
|
||||||
|
try {
|
||||||
|
$searchResult = $updateSearcher.Search($searchCriteria)
|
||||||
|
foreach ($update in $searchResult.Updates) {
|
||||||
|
foreach ($cat in $update.Categories) {
|
||||||
|
# Filter by Type = "UpdateClassification" (excludes product names)
|
||||||
|
if ($cat.Name -and $cat.Type -eq "UpdateClassification") {
|
||||||
|
if (-not $categories.ContainsKey($cat.Name)) {
|
||||||
|
$categories[$cat.Name] = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# Continue with next search
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Method 2: Also check update history for more categories
|
||||||
|
try {
|
||||||
|
$historyCount = $updateSearcher.GetTotalHistoryCount()
|
||||||
|
if ($historyCount -gt 0) {
|
||||||
|
$history = $updateSearcher.QueryHistory(0, [Math]::Min($historyCount, 200))
|
||||||
|
foreach ($entry in $history) {
|
||||||
|
if ($entry.Categories) {
|
||||||
|
foreach ($cat in $entry.Categories) {
|
||||||
|
if ($cat.Name -and $cat.Type -eq "UpdateClassification") {
|
||||||
|
if (-not $categories.ContainsKey($cat.Name)) {
|
||||||
|
$categories[$cat.Name] = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# History query failed, continue with what we have
|
||||||
|
}
|
||||||
|
|
||||||
|
return $categories
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return @{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Test-PreUpdateChecks {
|
function Test-PreUpdateChecks {
|
||||||
param([switch]$Automated)
|
param([switch]$Automated)
|
||||||
|
|
||||||
@ -178,6 +270,54 @@ function Test-PreUpdateChecks {
|
|||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Update-DefenderSignatures {
|
||||||
|
param([switch]$Automated)
|
||||||
|
|
||||||
|
Write-Log "Updating Microsoft Defender signatures..." -Level Info -Automated:$Automated
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Get current signature version before update
|
||||||
|
$defenderStatus = Get-MpComputerStatus -ErrorAction SilentlyContinue
|
||||||
|
$beforeVersion = if ($defenderStatus) { $defenderStatus.AntivirusSignatureVersion } else { "Unknown" }
|
||||||
|
|
||||||
|
# Update signatures using built-in cmdlet (more reliable than Windows Update)
|
||||||
|
Update-MpSignature -ErrorAction Stop
|
||||||
|
|
||||||
|
# Get new version
|
||||||
|
$defenderStatus = Get-MpComputerStatus -ErrorAction SilentlyContinue
|
||||||
|
$afterVersion = if ($defenderStatus) { $defenderStatus.AntivirusSignatureVersion } else { "Unknown" }
|
||||||
|
|
||||||
|
if ($beforeVersion -ne $afterVersion) {
|
||||||
|
Write-Log "Defender signatures updated: $beforeVersion -> $afterVersion" -Level Success -Automated:$Automated
|
||||||
|
$script:UpdateStats.Installed++
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Log "Defender signatures already up to date (Version: $afterVersion)" -Level Info -Automated:$Automated
|
||||||
|
}
|
||||||
|
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Log "Failed to update Defender signatures: $_" -Level Warning -Automated:$Automated
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-WindowsUpdateRescan {
|
||||||
|
param([switch]$Automated)
|
||||||
|
|
||||||
|
Write-Log "Refreshing Windows Update UI..." -Level Info -Automated:$Automated
|
||||||
|
|
||||||
|
try {
|
||||||
|
# startinteractivescan refreshes the Windows Update GUI to reflect actual installed state
|
||||||
|
$null = Start-Process -FilePath "UsoClient.exe" -ArgumentList "startinteractivescan" -Wait -NoNewWindow -PassThru -ErrorAction SilentlyContinue
|
||||||
|
Write-Log "Windows Update UI refreshed" -Level Success -Automated:$Automated
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Log "Could not refresh Windows Update UI: $_" -Level Warning -Automated:$Automated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Get-AvailableUpdates {
|
function Get-AvailableUpdates {
|
||||||
param([switch]$Automated)
|
param([switch]$Automated)
|
||||||
|
|
||||||
@ -189,11 +329,21 @@ function Get-AvailableUpdates {
|
|||||||
$update = $_
|
$update = $_
|
||||||
$included = $false
|
$included = $false
|
||||||
|
|
||||||
# Check categories
|
# Check categories - if "all", include everything; otherwise filter by category list
|
||||||
foreach ($cat in $UpdateCategories) {
|
if ($InstallAllCategories) {
|
||||||
if ($update.Categories -match $cat) {
|
$included = $true
|
||||||
$included = $true
|
}
|
||||||
break
|
else {
|
||||||
|
# Categories is a collection of Category objects with Name property
|
||||||
|
foreach ($cat in $UpdateCategories) {
|
||||||
|
foreach ($updateCategory in $update.Categories) {
|
||||||
|
$categoryName = if ($updateCategory.Name) { $updateCategory.Name } else { $updateCategory.ToString() }
|
||||||
|
if ($categoryName -match $cat) {
|
||||||
|
$included = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($included) { break }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,6 +369,12 @@ function Get-AvailableUpdates {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Exclude Defender signature updates (handled separately via Update-MpSignature)
|
||||||
|
if ($included -and $update.Title -like "*Security Intelligence Update for Microsoft Defender*") {
|
||||||
|
Write-Log "Skipping (handled separately): $($update.Title)" -Level Info -Automated:$Automated
|
||||||
|
$included = $false
|
||||||
|
}
|
||||||
|
|
||||||
return $included
|
return $included
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -246,7 +402,8 @@ function Install-AvailableUpdates {
|
|||||||
|
|
||||||
foreach ($update in $Updates) {
|
foreach ($update in $Updates) {
|
||||||
$sizeKB = [math]::Round($update.Size / 1KB, 2)
|
$sizeKB = [math]::Round($update.Size / 1KB, 2)
|
||||||
Write-Log " [$($update.KBArticleIDs -join ',')] $($update.Title) ($sizeKB KB)" -Level Info -Automated:$Automated
|
$kbDisplay = if ($update.KBArticleIDs) { $update.KBArticleIDs -join ',' } else { "N/A" }
|
||||||
|
Write-Log " [$kbDisplay] $($update.Title) ($sizeKB KB)" -Level Info -Automated:$Automated
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Log "========================================" -Level Info -Automated:$Automated
|
Write-Log "========================================" -Level Info -Automated:$Automated
|
||||||
@ -257,24 +414,18 @@ function Install-AvailableUpdates {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
# Install updates
|
# Install updates - pipe directly to preserve update selection
|
||||||
Write-Log "Installing updates..." -Level Info -Automated:$Automated
|
Write-Log "Installing updates..." -Level Info -Automated:$Automated
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$installParams = @{
|
$installParams = @{
|
||||||
MicrosoftUpdate = $true
|
|
||||||
AcceptAll = $true
|
AcceptAll = $true
|
||||||
IgnoreReboot = ($Options.rebootBehavior -ne 'auto')
|
IgnoreReboot = ($Options.rebootBehavior -ne 'auto')
|
||||||
Verbose = $false
|
Verbose = $false
|
||||||
}
|
}
|
||||||
|
|
||||||
# Use KBArticleID filter if available
|
# Pipe updates directly to Install-WindowsUpdate for reliable installation
|
||||||
$kbList = $Updates | ForEach-Object { $_.KBArticleIDs } | Where-Object { $_ }
|
$result = $Updates | Install-WindowsUpdate @installParams
|
||||||
if ($kbList.Count -gt 0) {
|
|
||||||
$installParams['KBArticleID'] = $kbList
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = Install-WindowsUpdate @installParams
|
|
||||||
|
|
||||||
# Process results
|
# Process results
|
||||||
foreach ($item in $result) {
|
foreach ($item in $result) {
|
||||||
@ -417,6 +568,21 @@ function Start-BusinessLogic {
|
|||||||
Write-Log "========================================" -Level Info -Automated:$Automated
|
Write-Log "========================================" -Level Info -Automated:$Automated
|
||||||
Write-Log "Windows Update Process Started" -Level Info -Automated:$Automated
|
Write-Log "Windows Update Process Started" -Level Info -Automated:$Automated
|
||||||
Write-Log "Script Version: $ScriptVersion ($ScriptDate)" -Level Info -Automated:$Automated
|
Write-Log "Script Version: $ScriptVersion ($ScriptDate)" -Level Info -Automated:$Automated
|
||||||
|
if ($InstallAllCategories) {
|
||||||
|
Write-Log "Update Categories: ALL" -Level Info -Automated:$Automated
|
||||||
|
# Query and display all known categories from Windows Update
|
||||||
|
$knownCategories = Get-AllUpdateCategories -Automated:$Automated
|
||||||
|
if ($knownCategories.Count -gt 0) {
|
||||||
|
$categoryList = ($knownCategories.Keys | Sort-Object) -join ', '
|
||||||
|
Write-Log " Available: $categoryList" -Level Info -Automated:$Automated
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Log " Available: (unable to query)" -Level Info -Automated:$Automated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Log "Update Categories: $($UpdateCategories -join ', ')" -Level Info -Automated:$Automated
|
||||||
|
}
|
||||||
if ($DryRun) {
|
if ($DryRun) {
|
||||||
Write-Log "DRY RUN MODE - No changes will be made" -Level Warning -Automated:$Automated
|
Write-Log "DRY RUN MODE - No changes will be made" -Level Warning -Automated:$Automated
|
||||||
}
|
}
|
||||||
@ -434,6 +600,14 @@ function Start-BusinessLogic {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Update Microsoft Defender signatures first (more reliable than Windows Update)
|
||||||
|
if (-not $DryRun) {
|
||||||
|
$null = Update-DefenderSignatures -Automated:$Automated
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Log "DRY RUN: Skipping Defender signature update" -Level Info -Automated:$Automated
|
||||||
|
}
|
||||||
|
|
||||||
# Scan for updates
|
# Scan for updates
|
||||||
$updates = Get-AvailableUpdates -Automated:$Automated
|
$updates = Get-AvailableUpdates -Automated:$Automated
|
||||||
|
|
||||||
@ -448,6 +622,11 @@ function Start-BusinessLogic {
|
|||||||
Invoke-PostUpdateActions -Automated:$Automated
|
Invoke-PostUpdateActions -Automated:$Automated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Refresh Windows Update cache (clears stale pending updates from UI)
|
||||||
|
if (-not $DryRun) {
|
||||||
|
Invoke-WindowsUpdateRescan -Automated:$Automated
|
||||||
|
}
|
||||||
|
|
||||||
# Print summary
|
# Print summary
|
||||||
Write-UpdateSummary -Automated:$Automated
|
Write-UpdateSummary -Automated:$Automated
|
||||||
|
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
@echo off
|
||||||
|
|
||||||
|
REM Change directory to the location of the script
|
||||||
|
cd /d %~dp0
|
||||||
|
|
||||||
|
REM Run Force Amend Tagged Commit script
|
||||||
|
powershell -ExecutionPolicy Bypass -File "%~dp0Force-AmendTaggedCommit.ps1"
|
||||||
|
|
||||||
|
pause
|
||||||
242
utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
Normal file
242
utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Amends the latest commit, recreates its associated tag, and force pushes both to remote.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script performs the following operations:
|
||||||
|
1. Gets the last commit and verifies it has an associated tag
|
||||||
|
2. Stages all pending changes
|
||||||
|
3. Amends the latest commit (keeps existing message)
|
||||||
|
4. Deletes and recreates the tag on the amended commit
|
||||||
|
5. Force pushes the branch and tag to remote
|
||||||
|
|
||||||
|
All configuration is in scriptsettings.json.
|
||||||
|
|
||||||
|
.PARAMETER DryRun
|
||||||
|
If specified, shows what would be done without making changes.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Force-AmendTaggedCommit.ps1
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Force-AmendTaggedCommit.ps1 -DryRun
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
CONFIGURATION (scriptsettings.json):
|
||||||
|
- git.remote: Remote name to push to (default: "origin")
|
||||||
|
- git.confirmBeforeAmend: Prompt before amending (default: true)
|
||||||
|
- git.confirmWhenNoChanges: Prompt if no pending changes (default: true)
|
||||||
|
#>
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[switch]$DryRun
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# PATH CONFIGURATION
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# LOAD SETTINGS
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
$settingsPath = Join-Path $scriptDir "scriptsettings.json"
|
||||||
|
if (-not (Test-Path $settingsPath)) {
|
||||||
|
Write-Error "Settings file not found: $settingsPath"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = Get-Content $settingsPath -Raw | ConvertFrom-Json
|
||||||
|
|
||||||
|
# Extract settings with defaults
|
||||||
|
$Remote = if ($settings.git.remote) { $settings.git.remote } else { "origin" }
|
||||||
|
$ConfirmBeforeAmend = if ($null -ne $settings.git.confirmBeforeAmend) { $settings.git.confirmBeforeAmend } else { $true }
|
||||||
|
$ConfirmWhenNoChanges = if ($null -ne $settings.git.confirmWhenNoChanges) { $settings.git.confirmWhenNoChanges } else { $true }
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# HELPER FUNCTIONS
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
function Write-Step {
|
||||||
|
param([string]$Text)
|
||||||
|
Write-Host "`n>> $Text" -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Success {
|
||||||
|
param([string]$Text)
|
||||||
|
Write-Host " $Text" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Info {
|
||||||
|
param([string]$Text)
|
||||||
|
Write-Host " $Text" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Warn {
|
||||||
|
param([string]$Text)
|
||||||
|
Write-Host " $Text" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Git {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string[]]$Arguments,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[switch]$CaptureOutput,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$ErrorMessage = "Git command failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($CaptureOutput) {
|
||||||
|
$output = & git @Arguments 2>&1
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
if ($exitCode -ne 0) {
|
||||||
|
throw "$ErrorMessage (exit code: $exitCode)"
|
||||||
|
}
|
||||||
|
return $output
|
||||||
|
} else {
|
||||||
|
& git @Arguments
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
if ($exitCode -ne 0) {
|
||||||
|
throw "$ErrorMessage (exit code: $exitCode)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# MAIN EXECUTION
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
try {
|
||||||
|
Write-Host "`n========================================" -ForegroundColor Magenta
|
||||||
|
Write-Host " Force Amend Tagged Commit Script" -ForegroundColor Magenta
|
||||||
|
Write-Host "========================================`n" -ForegroundColor Magenta
|
||||||
|
|
||||||
|
if ($DryRun) {
|
||||||
|
Write-Warn "*** DRY RUN MODE - No changes will be made ***`n"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get current branch
|
||||||
|
Write-Step "Getting current branch..."
|
||||||
|
$Branch = Invoke-Git -Arguments @("rev-parse", "--abbrev-ref", "HEAD") -CaptureOutput -ErrorMessage "Failed to get current branch"
|
||||||
|
Write-Info "Branch: $Branch"
|
||||||
|
|
||||||
|
# Get last commit info
|
||||||
|
Write-Step "Getting last commit..."
|
||||||
|
$null = Invoke-Git -Arguments @("rev-parse", "HEAD") -CaptureOutput -ErrorMessage "Failed to get HEAD commit"
|
||||||
|
$CommitMessage = Invoke-Git -Arguments @("log", "-1", "--format=%s") -CaptureOutput
|
||||||
|
$CommitHash = Invoke-Git -Arguments @("log", "-1", "--format=%h") -CaptureOutput
|
||||||
|
Write-Info "Commit: $CommitHash - $CommitMessage"
|
||||||
|
|
||||||
|
# Find tag pointing to HEAD
|
||||||
|
Write-Step "Finding tag on last commit..."
|
||||||
|
$Tags = & git tag --points-at HEAD 2>&1
|
||||||
|
|
||||||
|
if (-not $Tags -or [string]::IsNullOrWhiteSpace("$Tags")) {
|
||||||
|
throw "No tag found on the last commit ($CommitHash). This script requires the last commit to have an associated tag."
|
||||||
|
}
|
||||||
|
|
||||||
|
# If multiple tags, use the first one
|
||||||
|
$TagName = ("$Tags" -split "`n")[0].Trim()
|
||||||
|
Write-Success "Found tag: $TagName"
|
||||||
|
|
||||||
|
# Show current status
|
||||||
|
Write-Step "Checking pending changes..."
|
||||||
|
$Status = & git status --short 2>&1
|
||||||
|
if ($Status -and -not [string]::IsNullOrWhiteSpace("$Status")) {
|
||||||
|
Write-Info "Pending changes:"
|
||||||
|
"$Status" -split "`n" | ForEach-Object { Write-Info " $_" }
|
||||||
|
} else {
|
||||||
|
Write-Warn "No pending changes found"
|
||||||
|
if ($ConfirmWhenNoChanges -and -not $DryRun) {
|
||||||
|
$confirm = Read-Host "`n No changes to amend. Continue to recreate tag and force push? (y/N)"
|
||||||
|
if ($confirm -ne 'y' -and $confirm -ne 'Y') {
|
||||||
|
Write-Host "`nAborted by user" -ForegroundColor Yellow
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Confirm operation
|
||||||
|
Write-Host "`n----------------------------------------" -ForegroundColor White
|
||||||
|
Write-Host " Summary of operations:" -ForegroundColor White
|
||||||
|
Write-Host "----------------------------------------" -ForegroundColor White
|
||||||
|
Write-Host " Branch: $Branch" -ForegroundColor White
|
||||||
|
Write-Host " Commit: $CommitHash" -ForegroundColor White
|
||||||
|
Write-Host " Tag: $TagName" -ForegroundColor White
|
||||||
|
Write-Host " Remote: $Remote" -ForegroundColor White
|
||||||
|
Write-Host "----------------------------------------`n" -ForegroundColor White
|
||||||
|
|
||||||
|
if ($ConfirmBeforeAmend -and -not $DryRun) {
|
||||||
|
$confirm = Read-Host " Proceed with amend and force push? (y/N)"
|
||||||
|
if ($confirm -ne 'y' -and $confirm -ne 'Y') {
|
||||||
|
Write-Host "`nAborted by user" -ForegroundColor Yellow
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stage all changes
|
||||||
|
Write-Step "Staging all changes..."
|
||||||
|
if (-not $DryRun) {
|
||||||
|
Invoke-Git -Arguments @("add", "-A") -ErrorMessage "Failed to stage changes"
|
||||||
|
}
|
||||||
|
Write-Success "All changes staged"
|
||||||
|
|
||||||
|
# Amend commit
|
||||||
|
Write-Step "Amending commit..."
|
||||||
|
if (-not $DryRun) {
|
||||||
|
Invoke-Git -Arguments @("commit", "--amend", "--no-edit") -ErrorMessage "Failed to amend commit"
|
||||||
|
}
|
||||||
|
Write-Success "Commit amended"
|
||||||
|
|
||||||
|
# Delete local tag
|
||||||
|
Write-Step "Deleting local tag '$TagName'..."
|
||||||
|
if (-not $DryRun) {
|
||||||
|
Invoke-Git -Arguments @("tag", "-d", $TagName) -ErrorMessage "Failed to delete local tag"
|
||||||
|
}
|
||||||
|
Write-Success "Local tag deleted"
|
||||||
|
|
||||||
|
# Recreate tag on new commit
|
||||||
|
Write-Step "Recreating tag '$TagName' on amended commit..."
|
||||||
|
if (-not $DryRun) {
|
||||||
|
Invoke-Git -Arguments @("tag", $TagName) -ErrorMessage "Failed to create tag"
|
||||||
|
}
|
||||||
|
Write-Success "Tag recreated"
|
||||||
|
|
||||||
|
# Force push branch
|
||||||
|
Write-Step "Force pushing branch '$Branch' to $Remote..."
|
||||||
|
if (-not $DryRun) {
|
||||||
|
Invoke-Git -Arguments @("push", "--force", $Remote, $Branch) -ErrorMessage "Failed to force push branch"
|
||||||
|
}
|
||||||
|
Write-Success "Branch force pushed"
|
||||||
|
|
||||||
|
# Force push tag
|
||||||
|
Write-Step "Force pushing tag '$TagName' to $Remote..."
|
||||||
|
if (-not $DryRun) {
|
||||||
|
Invoke-Git -Arguments @("push", "--force", $Remote, $TagName) -ErrorMessage "Failed to force push tag"
|
||||||
|
}
|
||||||
|
Write-Success "Tag force pushed"
|
||||||
|
|
||||||
|
Write-Host "`n========================================" -ForegroundColor Green
|
||||||
|
Write-Host " Operation completed successfully!" -ForegroundColor Green
|
||||||
|
Write-Host "========================================`n" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Show final state
|
||||||
|
Write-Host "Final state:" -ForegroundColor White
|
||||||
|
& git log -1 --oneline
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host "`n========================================" -ForegroundColor Red
|
||||||
|
Write-Host " ERROR: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
Write-Host "========================================`n" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
10
utils/Force-AmendTaggedCommit/scriptsettings.json
Normal file
10
utils/Force-AmendTaggedCommit/scriptsettings.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$comment": "Configuration for Force-AmendTaggedCommit.ps1",
|
||||||
|
|
||||||
|
"git": {
|
||||||
|
"remote": "origin",
|
||||||
|
"confirmBeforeAmend": true,
|
||||||
|
"confirmWhenNoChanges": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
@echo off
|
||||||
|
REM Generate-CoverageBadges.bat - Wrapper for Generate-CoverageBadges.ps1
|
||||||
|
REM Runs tests and generates SVG coverage badges for README
|
||||||
|
|
||||||
|
pushd "%~dp0"
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Generate-CoverageBadges.ps1" %*
|
||||||
|
set EXITCODE=%ERRORLEVEL%
|
||||||
|
popd
|
||||||
|
exit /b %EXITCODE%
|
||||||
198
utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1
Normal file
198
utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Generates SVG coverage badges for README.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script runs unit tests via TestRunner.psm1, then generates shields.io-style
|
||||||
|
SVG badges for line, branch, and method coverage.
|
||||||
|
|
||||||
|
Configuration is stored in scriptsettings.json:
|
||||||
|
- paths.testProject : Relative path to test project
|
||||||
|
- paths.badgesDir : Relative path to badges output directory
|
||||||
|
- badges : Array of badges to generate (name, label, metric)
|
||||||
|
- colorThresholds : Coverage percentages for badge colors
|
||||||
|
|
||||||
|
Badge colors based on coverage:
|
||||||
|
- brightgreen (>=80%), green (>=60%), yellowgreen (>=40%)
|
||||||
|
- yellow (>=20%), orange (>=10%), red (<10%)
|
||||||
|
|
||||||
|
.PARAMETER OpenReport
|
||||||
|
Generate and open a full HTML coverage report in the default browser.
|
||||||
|
Requires ReportGenerator: dotnet tool install -g dotnet-reportgenerator-globaltool
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Generate-CoverageBadges.ps1
|
||||||
|
Runs tests and generates coverage badges.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Generate-CoverageBadges.ps1 -OpenReport
|
||||||
|
Runs tests, generates badges, and opens HTML report in browser.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
SVG badge files in the configured badges directory.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Author: MaksIT
|
||||||
|
Requires: .NET SDK, Coverlet (included in test project)
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[switch]$OpenReport
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$ScriptDir = $PSScriptRoot
|
||||||
|
|
||||||
|
# Import TestRunner module
|
||||||
|
$ModulePath = Join-Path (Split-Path $ScriptDir -Parent) "TestRunner.psm1"
|
||||||
|
if (-not (Test-Path $ModulePath)) {
|
||||||
|
Write-Host "TestRunner module not found at: $ModulePath" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Import-Module $ModulePath -Force
|
||||||
|
|
||||||
|
# Load settings
|
||||||
|
$SettingsFile = Join-Path $ScriptDir "scriptsettings.json"
|
||||||
|
if (-not (Test-Path $SettingsFile)) {
|
||||||
|
Write-Host "Settings file not found: $SettingsFile" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$Settings = Get-Content $SettingsFile | ConvertFrom-Json
|
||||||
|
|
||||||
|
# Resolve paths from settings
|
||||||
|
$TestProjectPath = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $Settings.paths.testProject))
|
||||||
|
$BadgesDir = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $Settings.paths.badgesDir))
|
||||||
|
|
||||||
|
# Ensure badges directory exists
|
||||||
|
if (-not (Test-Path $BadgesDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $BadgesDir | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
$coverage = Invoke-TestsWithCoverage -TestProjectPath $TestProjectPath -KeepResults:$OpenReport
|
||||||
|
|
||||||
|
if (-not $coverage.Success) {
|
||||||
|
Write-Host "Tests failed: $($coverage.Error)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Tests passed!" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Store metrics in a hashtable for easy lookup
|
||||||
|
$metrics = @{
|
||||||
|
"line" = $coverage.LineRate
|
||||||
|
"branch" = $coverage.BranchRate
|
||||||
|
"method" = $coverage.MethodRate
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to get badge color based on coverage percentage and thresholds from settings
|
||||||
|
function Get-BadgeColor {
|
||||||
|
param([double]$percentage)
|
||||||
|
|
||||||
|
$thresholds = $Settings.colorThresholds
|
||||||
|
|
||||||
|
if ($percentage -ge $thresholds.brightgreen) { return "brightgreen" }
|
||||||
|
if ($percentage -ge $thresholds.green) { return "green" }
|
||||||
|
if ($percentage -ge $thresholds.yellowgreen) { return "yellowgreen" }
|
||||||
|
if ($percentage -ge $thresholds.yellow) { return "yellow" }
|
||||||
|
if ($percentage -ge $thresholds.orange) { return "orange" }
|
||||||
|
return "red"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to create shields.io style SVG badge
|
||||||
|
function New-Badge {
|
||||||
|
param(
|
||||||
|
[string]$label,
|
||||||
|
[string]$value,
|
||||||
|
[string]$color
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate widths (approximate character width of 6.5px for the font)
|
||||||
|
$labelWidth = [math]::Max(($label.Length * 6.5) + 10, 50)
|
||||||
|
$valueWidth = [math]::Max(($value.Length * 6.5) + 10, 40)
|
||||||
|
$totalWidth = $labelWidth + $valueWidth
|
||||||
|
$labelX = $labelWidth / 2
|
||||||
|
$valueX = $labelWidth + ($valueWidth / 2)
|
||||||
|
|
||||||
|
$colorMap = @{
|
||||||
|
"brightgreen" = "#4c1"
|
||||||
|
"green" = "#97ca00"
|
||||||
|
"yellowgreen" = "#a4a61d"
|
||||||
|
"yellow" = "#dfb317"
|
||||||
|
"orange" = "#fe7d37"
|
||||||
|
"red" = "#e05d44"
|
||||||
|
}
|
||||||
|
$hexColor = $colorMap[$color]
|
||||||
|
if (-not $hexColor) { $hexColor = "#9f9f9f" }
|
||||||
|
|
||||||
|
return @"
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="$totalWidth" height="20" role="img" aria-label="$label`: $value">
|
||||||
|
<title>$label`: $value</title>
|
||||||
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="r">
|
||||||
|
<rect width="$totalWidth" height="20" rx="3" fill="#fff"/>
|
||||||
|
</clipPath>
|
||||||
|
<g clip-path="url(#r)">
|
||||||
|
<rect width="$labelWidth" height="20" fill="#555"/>
|
||||||
|
<rect x="$labelWidth" width="$valueWidth" height="20" fill="$hexColor"/>
|
||||||
|
<rect width="$totalWidth" height="20" fill="url(#s)"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||||
|
<text aria-hidden="true" x="$labelX" y="15" fill="#010101" fill-opacity=".3">$label</text>
|
||||||
|
<text x="$labelX" y="14" fill="#fff">$label</text>
|
||||||
|
<text aria-hidden="true" x="$valueX" y="15" fill="#010101" fill-opacity=".3">$value</text>
|
||||||
|
<text x="$valueX" y="14" fill="#fff">$value</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
"@
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate badges from settings
|
||||||
|
Write-Host "Generating coverage badges..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
foreach ($badge in $Settings.badges) {
|
||||||
|
$metricValue = $metrics[$badge.metric]
|
||||||
|
$color = Get-BadgeColor $metricValue
|
||||||
|
$svg = New-Badge -label $badge.label -value "$metricValue%" -color $color
|
||||||
|
$path = Join-Path $BadgesDir $badge.name
|
||||||
|
$svg | Out-File -FilePath $path -Encoding utf8
|
||||||
|
Write-Host " $($badge.name): $($badge.label) = $metricValue%" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# Display summary
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=== Coverage Summary ===" -ForegroundColor Yellow
|
||||||
|
Write-Host " Line Coverage: $($coverage.LineRate)%"
|
||||||
|
Write-Host " Branch Coverage: $($coverage.BranchRate)%"
|
||||||
|
Write-Host " Method Coverage: $($coverage.MethodRate)% ($($coverage.CoveredMethods) of $($coverage.TotalMethods) methods)"
|
||||||
|
Write-Host "========================" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Badges generated in: $BadgesDir" -ForegroundColor Green
|
||||||
|
Write-Host "Commit the badges/ folder to update README." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Optionally generate full HTML report
|
||||||
|
if ($OpenReport -and $coverage.CoverageFile) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Generating HTML report..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$ResultsDir = Split-Path (Split-Path $coverage.CoverageFile -Parent) -Parent
|
||||||
|
$ReportDir = Join-Path $ResultsDir "report"
|
||||||
|
|
||||||
|
$reportGenArgs = @(
|
||||||
|
"-reports:$($coverage.CoverageFile)"
|
||||||
|
"-targetdir:$ReportDir"
|
||||||
|
"-reporttypes:Html"
|
||||||
|
)
|
||||||
|
& reportgenerator @reportGenArgs
|
||||||
|
|
||||||
|
$IndexFile = Join-Path $ReportDir "index.html"
|
||||||
|
if (Test-Path $IndexFile) {
|
||||||
|
Start-Process $IndexFile
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "TestResults kept for HTML report viewing." -ForegroundColor Gray
|
||||||
|
}
|
||||||
34
utils/Generate-CoverageBadges/scriptsettings.json
Normal file
34
utils/Generate-CoverageBadges/scriptsettings.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft-07/schema",
|
||||||
|
"title": "Generate Coverage Badges Script Settings",
|
||||||
|
"description": "Configuration for Generate-CoverageBadges.ps1 script",
|
||||||
|
"paths": {
|
||||||
|
"testProject": "..\\..\\src\\MaksIT.UScheduler.Tests",
|
||||||
|
"badgesDir": "..\\..\\badges"
|
||||||
|
},
|
||||||
|
"badges": [
|
||||||
|
{
|
||||||
|
"name": "coverage-lines.svg",
|
||||||
|
"label": "Line Coverage",
|
||||||
|
"metric": "line"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "coverage-branches.svg",
|
||||||
|
"label": "Branch Coverage",
|
||||||
|
"metric": "branch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "coverage-methods.svg",
|
||||||
|
"label": "Method Coverage",
|
||||||
|
"metric": "method"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"colorThresholds": {
|
||||||
|
"brightgreen": 80,
|
||||||
|
"green": 60,
|
||||||
|
"yellowgreen": 40,
|
||||||
|
"yellow": 20,
|
||||||
|
"orange": 10,
|
||||||
|
"red": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@
|
|||||||
REM Change directory to the location of the script
|
REM Change directory to the location of the script
|
||||||
cd /d %~dp0
|
cd /d %~dp0
|
||||||
|
|
||||||
REM Invoke the PowerShell script (Release-ToGitHub.ps1) in the same directory
|
REM Run GitHub release script
|
||||||
powershell -ExecutionPolicy Bypass -File "%~dp0Release-ToGitHub.ps1"
|
powershell -ExecutionPolicy Bypass -File "%~dp0Release-ToGitHub.ps1"
|
||||||
|
|
||||||
pause
|
pause
|
||||||
695
utils/Release-ToGitHub/Release-ToGitHub.ps1
Normal file
695
utils/Release-ToGitHub/Release-ToGitHub.ps1
Normal file
@ -0,0 +1,695 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Automated GitHub release script for MaksIT.UScheduler.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Creates a GitHub release by performing the following steps:
|
||||||
|
|
||||||
|
Pre-flight checks:
|
||||||
|
- Detects current branch (main or dev)
|
||||||
|
- On main: requires clean working directory; on dev: uncommitted changes allowed
|
||||||
|
- Reads version from .csproj (source of truth)
|
||||||
|
- On main: requires matching tag (vX.Y.Z format)
|
||||||
|
- Ensures version consistency with CHANGELOG.md
|
||||||
|
- Confirms GitHub CLI authentication via GH_TOKEN (main branch only)
|
||||||
|
|
||||||
|
Test execution:
|
||||||
|
- Runs all unit tests via Run-Tests.ps1
|
||||||
|
- Aborts release if any tests fail
|
||||||
|
- Displays coverage summary (line, branch, method)
|
||||||
|
|
||||||
|
Build and release:
|
||||||
|
- Publishes the .NET project in Release configuration
|
||||||
|
- Copies Scripts folder into the release
|
||||||
|
- Creates a versioned ZIP archive
|
||||||
|
- Extracts release notes from CHANGELOG.md
|
||||||
|
- Pushes tag to remote if not already present (main branch only)
|
||||||
|
- Creates (or recreates) the GitHub release with assets (main branch only)
|
||||||
|
|
||||||
|
Branch-based behavior (configurable in scriptsettings.json):
|
||||||
|
- On dev branch: Local build only, no tag required, uncommitted changes allowed
|
||||||
|
- On release branch: Full GitHub release, tag required, clean working directory required
|
||||||
|
- On other branches: Blocked
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
File: Release-ToGitHub.ps1
|
||||||
|
Author: Maksym Sadovnychyy (MAKS-IT)
|
||||||
|
Requires: dotnet, git, gh (GitHub CLI - required on main branch only)
|
||||||
|
|
||||||
|
Configuration is loaded from scriptsettings.json in the same directory.
|
||||||
|
Set the GitHub token in an environment variable specified by github.tokenEnvVar.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Release-ToGitHub.ps1
|
||||||
|
|
||||||
|
Runs the release process using settings from scriptsettings.json.
|
||||||
|
On dev branch: creates local build (no tag needed).
|
||||||
|
On main branch: publishes to GitHub (tag required).
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# Recommended workflow:
|
||||||
|
# 1. On dev branch: Update version in .csproj and CHANGELOG.md
|
||||||
|
# 2. Commit changes
|
||||||
|
# 3. Run: .\Release-ToGitHub.ps1
|
||||||
|
# (creates local build for testing - no tag needed)
|
||||||
|
# 4. Test the build
|
||||||
|
# 5. Merge to main: git checkout main && git merge dev
|
||||||
|
# 6. Create tag: git tag v1.0.1
|
||||||
|
# 7. Run: .\Release-ToGitHub.ps1
|
||||||
|
# (publishes to GitHub)
|
||||||
|
#>
|
||||||
|
|
||||||
|
# No parameters - behavior is controlled by current branch (configured in scriptsettings.json):
|
||||||
|
# - dev branch -> Local build only (no tag required, uncommitted changes allowed)
|
||||||
|
# - release branch -> Full release to GitHub (tag required, clean working directory)
|
||||||
|
|
||||||
|
# Load settings from scriptsettings.json
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$settingsPath = Join-Path $scriptDir "scriptsettings.json"
|
||||||
|
|
||||||
|
if (-not (Test-Path $settingsPath)) {
|
||||||
|
Write-Error "Settings file not found: $settingsPath"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = Get-Content $settingsPath -Raw | ConvertFrom-Json
|
||||||
|
|
||||||
|
# Import TestRunner module
|
||||||
|
$modulePath = Join-Path (Split-Path $scriptDir -Parent) "TestRunner.psm1"
|
||||||
|
if (-not (Test-Path $modulePath)) {
|
||||||
|
Write-Error "TestRunner module not found at: $modulePath"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Import-Module $modulePath -Force
|
||||||
|
|
||||||
|
# Set GH_TOKEN from custom environment variable for GitHub CLI authentication
|
||||||
|
$tokenEnvVar = $settings.github.tokenEnvVar
|
||||||
|
$env:GH_TOKEN = [System.Environment]::GetEnvironmentVariable($tokenEnvVar)
|
||||||
|
|
||||||
|
# Paths from settings (resolve relative to script directory)
|
||||||
|
$csprojPaths = @()
|
||||||
|
if ($settings.paths.csprojPath -is [System.Collections.IEnumerable] -and -not ($settings.paths.csprojPath -is [string])) {
|
||||||
|
foreach ($path in $settings.paths.csprojPath) {
|
||||||
|
$csprojPaths += [System.IO.Path]::GetFullPath((Join-Path $scriptDir $path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$csprojPaths += [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.csprojPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
$stagingDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.stagingDir))
|
||||||
|
$releaseDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.releaseDir))
|
||||||
|
$changelogPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.changelogPath))
|
||||||
|
$scriptsPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.scriptsPath))
|
||||||
|
$testProjectPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.testProject))
|
||||||
|
|
||||||
|
# Release naming patterns
|
||||||
|
$zipNamePattern = $settings.release.zipNamePattern
|
||||||
|
$releaseTitlePattern = $settings.release.releaseTitlePattern
|
||||||
|
|
||||||
|
# Branch configuration
|
||||||
|
$releaseBranch = $settings.branches.release
|
||||||
|
$devBranch = $settings.branches.dev
|
||||||
|
|
||||||
|
# Project configuration (avoid hardcoding project names)
|
||||||
|
$projectsSettings = $settings.projects
|
||||||
|
$scheduleManagerCsprojEndsWith = $projectsSettings.scheduleManagerCsprojEndsWith
|
||||||
|
$uschedulerCsprojEndsWith = $projectsSettings.uschedulerCsprojEndsWith
|
||||||
|
$scheduleManagerAppSettingsFile = $projectsSettings.scheduleManagerAppSettingsFile
|
||||||
|
$uschedulerAppSettingsFile = $projectsSettings.uschedulerAppSettingsFile
|
||||||
|
$scheduleManagerServiceBinPath = $projectsSettings.scheduleManagerServiceBinPath
|
||||||
|
$uschedulerLogDir = $projectsSettings.uschedulerLogDir
|
||||||
|
$scriptsRelativeToExe = $projectsSettings.scriptsRelativeToExe
|
||||||
|
|
||||||
|
# Helper: ensure required commands exist
|
||||||
|
function Assert-Command {
|
||||||
|
param([string]$cmd)
|
||||||
|
|
||||||
|
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Error "Required command '$cmd' is missing. Aborting."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Helper: extract a csproj property (first match)
|
||||||
|
function Get-CsprojPropertyValue {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)][xml]$csproj,
|
||||||
|
[Parameter(Mandatory=$true)][string]$propertyName
|
||||||
|
)
|
||||||
|
|
||||||
|
$propNode = $csproj.Project.PropertyGroup |
|
||||||
|
Where-Object { $_.$propertyName } |
|
||||||
|
Select-Object -First 1
|
||||||
|
|
||||||
|
if ($propNode) {
|
||||||
|
return $propNode.$propertyName
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Helper: resolve output assembly name for published exe
|
||||||
|
function Resolve-ProjectExeName {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)][string]$projPath
|
||||||
|
)
|
||||||
|
|
||||||
|
[xml]$csproj = Get-Content $projPath
|
||||||
|
$assemblyName = Get-CsprojPropertyValue -csproj $csproj -propertyName "AssemblyName"
|
||||||
|
if ($assemblyName) {
|
||||||
|
return $assemblyName
|
||||||
|
}
|
||||||
|
|
||||||
|
return [System.IO.Path]::GetFileNameWithoutExtension($projPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Helper: find csproj by configured suffix
|
||||||
|
function Find-CsprojByEndsWith {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)][string[]]$paths,
|
||||||
|
[Parameter(Mandatory=$true)][string]$endsWith
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not $endsWith) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
return $paths | Where-Object { $_ -like "*$endsWith" } | Select-Object -First 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert-Command dotnet
|
||||||
|
Assert-Command git
|
||||||
|
# gh command check deferred until after branch detection (only needed on main branch)
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "==================================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "RELEASE BUILD" -ForegroundColor Cyan
|
||||||
|
Write-Host "==================================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# PRE-FLIGHT CHECKS
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# 1. Detect current branch and determine release mode
|
||||||
|
Write-Host "Detecting current branch..." -ForegroundColor Gray
|
||||||
|
$currentBranch = git rev-parse --abbrev-ref HEAD 2>$null
|
||||||
|
if ($LASTEXITCODE -ne 0 -or -not $currentBranch) {
|
||||||
|
Write-Error "Could not determine current branch."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentBranch = $currentBranch.Trim()
|
||||||
|
Write-Host " Branch: $currentBranch" -ForegroundColor Green
|
||||||
|
|
||||||
|
$isDevBranch = $currentBranch -eq $devBranch
|
||||||
|
$isReleaseBranch = $currentBranch -eq $releaseBranch
|
||||||
|
|
||||||
|
if (-not $isDevBranch -and -not $isReleaseBranch) {
|
||||||
|
Write-Error "Releases can only be created from '$releaseBranch' or '$devBranch' branches. Current branch: $currentBranch"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isDevBranch) {
|
||||||
|
Write-Host " Dev branch ($devBranch) - local build only (no GitHub release)." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host " Release branch ($releaseBranch) - will publish to GitHub." -ForegroundColor Cyan
|
||||||
|
Assert-Command gh
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Check for uncommitted changes (required on main, allowed on dev)
|
||||||
|
$gitStatus = git status --porcelain 2>$null
|
||||||
|
if ($gitStatus) {
|
||||||
|
if ($isReleaseBranch) {
|
||||||
|
Write-Error "Working directory has uncommitted changes. Commit or stash them before releasing."
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Uncommitted files:" -ForegroundColor Yellow
|
||||||
|
$gitStatus | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow }
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host " Uncommitted changes detected (allowed on dev branch)." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host " Working directory is clean." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Get version from csproj (source of truth)
|
||||||
|
|
||||||
|
Write-Host "Reading version(s) from csproj(s)..." -ForegroundColor Gray
|
||||||
|
$projectVersions = @{}
|
||||||
|
foreach ($projPath in $csprojPaths) {
|
||||||
|
if (-not (Test-Path $projPath)) {
|
||||||
|
Write-Error "Csproj not found at: $projPath"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[xml]$csproj = Get-Content $projPath
|
||||||
|
$version = Get-CsprojPropertyValue -csproj $csproj -propertyName "Version"
|
||||||
|
|
||||||
|
if (-not $version) {
|
||||||
|
Write-Error "Version not found in $projPath"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$projectVersions[$projPath] = $version
|
||||||
|
Write-Host " $([System.IO.Path]::GetFileName($projPath)): $version" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use the first project's version as the main version for tag/release
|
||||||
|
$version = $projectVersions[$csprojPaths[0]]
|
||||||
|
|
||||||
|
# 4. Handle tag based on branch
|
||||||
|
if ($isReleaseBranch) {
|
||||||
|
# Main branch: tag is required and must match version
|
||||||
|
Write-Host "Checking for tag on current commit..." -ForegroundColor Gray
|
||||||
|
$tag = git describe --tags --exact-match HEAD 2>$null
|
||||||
|
if ($LASTEXITCODE -ne 0 -or -not $tag) {
|
||||||
|
Write-Error "No tag found on current commit. Create a tag: git tag v$version"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$tag = $tag.Trim()
|
||||||
|
|
||||||
|
if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') {
|
||||||
|
Write-Error "Tag '$tag' does not match expected format 'vX.Y.Z' (e.g., v$version)."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$tagVersion = $Matches[1]
|
||||||
|
|
||||||
|
if ($tagVersion -ne $version) {
|
||||||
|
Write-Error "Tag version ($tagVersion) does not match csproj version ($version)."
|
||||||
|
Write-Host " Either update the tag or the csproj version." -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host " Tag found: $tag (matches csproj)" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Dev branch: no tag required, use version from csproj
|
||||||
|
$tag = "v$version"
|
||||||
|
Write-Host " Using version from csproj (no tag required on dev)." -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
# 5. Verify CHANGELOG.md has matching version entry
|
||||||
|
Write-Host "Verifying CHANGELOG.md..." -ForegroundColor Gray
|
||||||
|
if (-not (Test-Path $changelogPath)) {
|
||||||
|
Write-Error "CHANGELOG.md not found at: $changelogPath"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$changelog = Get-Content $changelogPath -Raw
|
||||||
|
|
||||||
|
if ($changelog -notmatch '##\s+v(\d+\.\d+\.\d+)') {
|
||||||
|
Write-Error "No version entry found in CHANGELOG.md"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$changelogVersion = $Matches[1]
|
||||||
|
|
||||||
|
if ($changelogVersion -ne $version) {
|
||||||
|
Write-Error "Csproj version ($version) does not match latest CHANGELOG.md version ($changelogVersion)."
|
||||||
|
Write-Host " Update CHANGELOG.md or the csproj version." -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host " CHANGELOG.md version matches: v$changelogVersion" -ForegroundColor Green
|
||||||
|
|
||||||
|
# 6. Check GitHub authentication (skip for local-only builds)
|
||||||
|
if (-not $isDevBranch) {
|
||||||
|
Write-Host "Checking GitHub authentication..." -ForegroundColor Gray
|
||||||
|
if (-not $env:GH_TOKEN) {
|
||||||
|
Write-Error "GH_TOKEN environment variable is not set. Set $tokenEnvVar and rerun."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$authTest = gh api user 2>$null
|
||||||
|
if ($LASTEXITCODE -ne 0 -or -not $authTest) {
|
||||||
|
Write-Error "GitHub CLI authentication failed. GH_TOKEN may be invalid or missing repo scope."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host " GitHub CLI authenticated." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "Skipping GitHub authentication (local-only mode)." -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "All pre-flight checks passed!" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# RUN TESTS
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
Write-Host "Running tests..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Run tests using TestRunner module
|
||||||
|
$testResult = Invoke-TestsWithCoverage -TestProjectPath $testProjectPath -Silent
|
||||||
|
|
||||||
|
if (-not $testResult.Success) {
|
||||||
|
Write-Error "Tests failed. Release aborted."
|
||||||
|
Write-Host " Error: $($testResult.Error)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host " All tests passed!" -ForegroundColor Green
|
||||||
|
Write-Host " Line Coverage: $($testResult.LineRate)%" -ForegroundColor Gray
|
||||||
|
Write-Host " Branch Coverage: $($testResult.BranchRate)%" -ForegroundColor Gray
|
||||||
|
Write-Host " Method Coverage: $($testResult.MethodRate)%" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# BUILD AND RELEASE
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# 7. Prepare staging directory
|
||||||
|
Write-Host "Preparing staging directory..." -ForegroundColor Cyan
|
||||||
|
if (Test-Path $stagingDir) {
|
||||||
|
Remove-Item $stagingDir -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $stagingDir | Out-Null
|
||||||
|
|
||||||
|
$binDir = Join-Path $stagingDir "bin"
|
||||||
|
|
||||||
|
# 8. Publish the project to staging/bin
|
||||||
|
|
||||||
|
Write-Host "Publishing projects to bin folder..." -ForegroundColor Cyan
|
||||||
|
$publishSuccess = $true
|
||||||
|
$publishedProjects = @()
|
||||||
|
|
||||||
|
foreach ($projPath in $csprojPaths) {
|
||||||
|
$projName = [System.IO.Path]::GetFileNameWithoutExtension($projPath)
|
||||||
|
$projBinDir = Join-Path $binDir $projName
|
||||||
|
|
||||||
|
dotnet publish $projPath -c Release -o $projBinDir
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error "dotnet publish failed for $projName."
|
||||||
|
$publishSuccess = $false
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$exeBaseName = Resolve-ProjectExeName -projPath $projPath
|
||||||
|
$publishedProjects += [PSCustomObject]@{
|
||||||
|
ProjPath = $projPath
|
||||||
|
ProjName = $projName
|
||||||
|
BinDir = $projBinDir
|
||||||
|
ExeBaseName = $exeBaseName
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host " Published $projName successfully to: $projBinDir" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $publishSuccess) {
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 9. Copy Scripts folder to staging
|
||||||
|
Write-Host "Copying Scripts folder..." -ForegroundColor Cyan
|
||||||
|
if (-not (Test-Path $scriptsPath)) {
|
||||||
|
Write-Error "Scripts folder not found at: $scriptsPath"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$scriptsDestination = Join-Path $stagingDir "Scripts"
|
||||||
|
Copy-Item -Path $scriptsPath -Destination $scriptsDestination -Recurse
|
||||||
|
Write-Host " Scripts copied to: $scriptsDestination" -ForegroundColor Green
|
||||||
|
|
||||||
|
Write-Host "Updating ScheduleManager appsettings with UScheduler path..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# 10. Update appsettings.json with scripts in disabled state
|
||||||
|
# Dynamically locate ScheduleManager appsettings based on settings.projects.scheduleManagerCsprojEndsWith
|
||||||
|
$scheduleManagerCsprojPath = Find-CsprojByEndsWith -paths $csprojPaths -endsWith $scheduleManagerCsprojEndsWith
|
||||||
|
if ($scheduleManagerCsprojPath) {
|
||||||
|
$scheduleManagerProjName = [System.IO.Path]::GetFileNameWithoutExtension($scheduleManagerCsprojPath)
|
||||||
|
$scheduleManagerBinDir = Join-Path $binDir $scheduleManagerProjName
|
||||||
|
$scheduleManagerAppSettingsPath = Join-Path $scheduleManagerBinDir $scheduleManagerAppSettingsFile
|
||||||
|
|
||||||
|
if (Test-Path $scheduleManagerAppSettingsPath) {
|
||||||
|
$smAppSettings = Get-Content $scheduleManagerAppSettingsPath -Raw | ConvertFrom-Json
|
||||||
|
if ($smAppSettings.USchedulerSettings) {
|
||||||
|
$smAppSettings.USchedulerSettings.ServiceBinPath = $scheduleManagerServiceBinPath
|
||||||
|
$jsonOutput = $smAppSettings | ConvertTo-Json -Depth 10
|
||||||
|
Set-Content -Path $scheduleManagerAppSettingsPath -Value $jsonOutput -Encoding UTF8
|
||||||
|
Write-Host " Updated ServiceBinPath in ScheduleManager appsettings" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host " Warning: USchedulerSettings section not found in ScheduleManager appsettings" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host " Warning: $scheduleManagerAppSettingsFile not found in $scheduleManagerProjName bin folder" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host " Warning: ScheduleManager csproj not found in csprojPaths array" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Updating UScheduler appsettings with new LogDir bundled scripts paths..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Resolve UScheduler csproj by configured suffix (avoid hardcoded ScheduleManager exclusion)
|
||||||
|
$uschedulerCsprojPath = Find-CsprojByEndsWith -paths $csprojPaths -endsWith $uschedulerCsprojEndsWith
|
||||||
|
if ($uschedulerCsprojPath) {
|
||||||
|
$uschedulerProjName = [System.IO.Path]::GetFileNameWithoutExtension($uschedulerCsprojPath)
|
||||||
|
$uschedulerBinDir = Join-Path $binDir $uschedulerProjName
|
||||||
|
$appSettingsPath = Join-Path $uschedulerBinDir $uschedulerAppSettingsFile
|
||||||
|
|
||||||
|
if (Test-Path $appSettingsPath) {
|
||||||
|
$appSettings = Get-Content $appSettingsPath -Raw | ConvertFrom-Json
|
||||||
|
|
||||||
|
# Update LogDir for release
|
||||||
|
if ($appSettings.Configuration) {
|
||||||
|
$appSettings.Configuration.LogDir = $uschedulerLogDir
|
||||||
|
Write-Host " Updated LogDir in UScheduler appsettings" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host " Warning: Configuration section not found in UScheduler appsettings" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find all .ps1 files in Scripts folder (exclude utility scripts in subfolders named "Utilities")
|
||||||
|
$psScripts = Get-ChildItem -Path $scriptsDestination -Filter "*.ps1" -Recurse |
|
||||||
|
Where-Object { $_.Directory.Name -ne "Utilities" } |
|
||||||
|
ForEach-Object {
|
||||||
|
$relativePath = $_.FullName.Substring($scriptsDestination.Length + 1).Replace('/', '\')
|
||||||
|
$scriptPath = "$scriptsRelativeToExe\$relativePath"
|
||||||
|
[PSCustomObject]@{
|
||||||
|
Path = $scriptPath
|
||||||
|
IsSigned = $false
|
||||||
|
Disabled = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add scripts to Powershell configuration
|
||||||
|
if ($psScripts) {
|
||||||
|
if (-not $appSettings.Configuration) {
|
||||||
|
$appSettings | Add-Member -MemberType NoteProperty -Name "Configuration" -Value ([PSCustomObject]@{})
|
||||||
|
}
|
||||||
|
|
||||||
|
$appSettings.Configuration.Powershell = @($psScripts)
|
||||||
|
$jsonOutput = $appSettings | ConvertTo-Json -Depth 10
|
||||||
|
Set-Content -Path $appSettingsPath -Value $jsonOutput -Encoding UTF8
|
||||||
|
Write-Host " Added $($psScripts.Count) PowerShell script(s) to appsettings (disabled)" -ForegroundColor Green
|
||||||
|
$psScripts | ForEach-Object { Write-Host " - $($_.Path)" -ForegroundColor Gray }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host " Warning: $uschedulerAppSettingsFile not found in $uschedulerProjName bin folder" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host " Warning: UScheduler csproj not found in csprojPaths array" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# 11. Create launcher batch file (if enabled)
|
||||||
|
if ($settings.launcher -and $settings.launcher.enabled) {
|
||||||
|
Write-Host "Creating launcher batch file..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$launcherFileName = $settings.launcher.fileName
|
||||||
|
$targetProject = $settings.launcher.targetProject
|
||||||
|
|
||||||
|
# Determine which project to launch
|
||||||
|
$targetCsprojPath = $null
|
||||||
|
$targetExeName = $null
|
||||||
|
$targetProjName = $null
|
||||||
|
|
||||||
|
if ($targetProject -eq "scheduleManager") {
|
||||||
|
$targetCsprojPath = Find-CsprojByEndsWith -paths $csprojPaths -endsWith $scheduleManagerCsprojEndsWith
|
||||||
|
}
|
||||||
|
elseif ($targetProject -eq "uscheduler") {
|
||||||
|
$targetCsprojPath = Find-CsprojByEndsWith -paths $csprojPaths -endsWith $uschedulerCsprojEndsWith
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host " Warning: Unknown targetProject '$targetProject' in launcher settings" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($targetCsprojPath) {
|
||||||
|
$targetProjName = [System.IO.Path]::GetFileNameWithoutExtension($targetCsprojPath)
|
||||||
|
$targetExeName = Resolve-ProjectExeName -projPath $targetCsprojPath
|
||||||
|
|
||||||
|
$batPath = Join-Path $stagingDir $launcherFileName
|
||||||
|
$exePath = "%~dp0bin\$targetProjName\$targetExeName.exe"
|
||||||
|
|
||||||
|
$batContent = @"
|
||||||
|
@echo off
|
||||||
|
start "" "$exePath"
|
||||||
|
"@
|
||||||
|
|
||||||
|
Set-Content -Path $batPath -Value $batContent -Encoding ASCII
|
||||||
|
Write-Host " Created launcher: $launcherFileName -> $exePath" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host " Warning: Could not find target project for launcher" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "Skipping launcher batch file creation (disabled in settings)." -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 12. Prepare release directory
|
||||||
|
if (!(Test-Path $releaseDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $releaseDir | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# 13. Create zip file
|
||||||
|
$zipName = $zipNamePattern -replace '\{version\}', $version
|
||||||
|
$zipPath = Join-Path $releaseDir $zipName
|
||||||
|
|
||||||
|
if (Test-Path $zipPath) {
|
||||||
|
Remove-Item $zipPath -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Creating archive $zipName..." -ForegroundColor Cyan
|
||||||
|
Compress-Archive -Path "$stagingDir\*" -DestinationPath $zipPath -Force
|
||||||
|
|
||||||
|
if (-not (Test-Path $zipPath)) {
|
||||||
|
Write-Error "Failed to create archive $zipPath"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host " Archive created: $zipPath" -ForegroundColor Green
|
||||||
|
|
||||||
|
|
||||||
|
# 14. Extract release notes from CHANGELOG.md
|
||||||
|
Write-Host "Extracting release notes..." -ForegroundColor Cyan
|
||||||
|
$pattern = "(?ms)^##\s+v$([regex]::Escape($version))\b.*?(?=^##\s+v\d+\.\d+\.\d+|\Z)"
|
||||||
|
$match = [regex]::Match($changelog, $pattern)
|
||||||
|
|
||||||
|
if (-not $match.Success) {
|
||||||
|
Write-Error "Changelog entry for version $version not found."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$releaseNotes = $match.Value.Trim()
|
||||||
|
Write-Host " Release notes extracted." -ForegroundColor Green
|
||||||
|
|
||||||
|
# 15. Get repository info
|
||||||
|
$remoteUrl = git config --get remote.origin.url
|
||||||
|
if ($LASTEXITCODE -ne 0 -or -not $remoteUrl) {
|
||||||
|
Write-Error "Could not determine git remote origin URL."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($remoteUrl -match "[:/](?<owner>[^/]+)/(?<repo>[^/.]+)(\.git)?$") {
|
||||||
|
$owner = $matches['owner']
|
||||||
|
$repoName = $matches['repo']
|
||||||
|
$repo = "$owner/$repoName"
|
||||||
|
} else {
|
||||||
|
Write-Error "Could not parse GitHub repo from remote URL: $remoteUrl"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$releaseName = $releaseTitlePattern -replace '\{version\}', $version
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Release Summary:" -ForegroundColor Cyan
|
||||||
|
Write-Host " Repository: $repo" -ForegroundColor White
|
||||||
|
Write-Host " Tag: $tag" -ForegroundColor White
|
||||||
|
Write-Host " Title: $releaseName" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 16. Check if tag is pushed to remote (skip on dev branch)
|
||||||
|
if (-not $isDevBranch) {
|
||||||
|
Write-Host "Verifying tag is pushed to remote..." -ForegroundColor Cyan
|
||||||
|
$remoteTag = git ls-remote --tags origin $tag 2>$null
|
||||||
|
if (-not $remoteTag) {
|
||||||
|
Write-Host " Tag $tag not found on remote. Pushing..." -ForegroundColor Yellow
|
||||||
|
git push origin $tag
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error "Failed to push tag $tag to remote."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host " Tag pushed successfully." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host " Tag exists on remote." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# 17. Create or update GitHub release
|
||||||
|
Write-Host "Creating GitHub release..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Check if release already exists
|
||||||
|
gh release view $tag --repo $repo 2>$null
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Host " Release $tag already exists. Deleting..." -ForegroundColor Yellow
|
||||||
|
gh release delete $tag --repo $repo --yes
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error "Failed to delete existing release $tag."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create new release using existing tag
|
||||||
|
$ghArgs = @(
|
||||||
|
"release", "create", $tag, $zipPath
|
||||||
|
"--repo", $repo
|
||||||
|
"--title", $releaseName
|
||||||
|
"--notes", $releaseNotes
|
||||||
|
)
|
||||||
|
& gh @ghArgs
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error "Failed to create GitHub release for tag $tag."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host " GitHub release created successfully." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "Skipping GitHub release (dev branch)." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# 18. Cleanup
|
||||||
|
if (Test-Path $stagingDir) {
|
||||||
|
Remove-Item $stagingDir -Recurse -Force
|
||||||
|
Write-Host " Cleaned up staging directory." -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "==================================================" -ForegroundColor Green
|
||||||
|
if ($isDevBranch) {
|
||||||
|
Write-Host "DEV BUILD COMPLETE" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "RELEASE COMPLETE" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
Write-Host "==================================================" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
if (-not $isDevBranch) {
|
||||||
|
Write-Host "Release URL: https://github.com/$repo/releases/tag/$tag" -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
Write-Host "Artifacts location: $releaseDir" -ForegroundColor Gray
|
||||||
|
if ($isDevBranch) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "To publish to GitHub, merge to main, tag, and run the script again:" -ForegroundColor Yellow
|
||||||
|
Write-Host " git checkout main" -ForegroundColor Yellow
|
||||||
|
Write-Host " git merge dev" -ForegroundColor Yellow
|
||||||
|
Write-Host " git tag v$version" -ForegroundColor Yellow
|
||||||
|
Write-Host " .\Release-ToGitHub.ps1" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
57
utils/Release-ToGitHub/scriptsettings.json
Normal file
57
utils/Release-ToGitHub/scriptsettings.json
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft-07/schema",
|
||||||
|
"title": "Release to GitHub Script Settings",
|
||||||
|
"description": "Configuration file for Release-ToGitHub.ps1 script (automated GitHub release creation)",
|
||||||
|
"github": {
|
||||||
|
"tokenEnvVar": "GITHUB_MAKS_IT_COM"
|
||||||
|
},
|
||||||
|
"branches": {
|
||||||
|
"release": "main",
|
||||||
|
"dev": "dev"
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"csprojPath": [
|
||||||
|
"..\\..\\src\\MaksIT.UScheduler\\MaksIT.UScheduler.csproj",
|
||||||
|
"..\\..\\src\\MaksIT.UScheduler.ScheduleManager\\MaksIT.UScheduler.ScheduleManager.csproj"
|
||||||
|
],
|
||||||
|
"stagingDir": "..\\..\\staging",
|
||||||
|
"releaseDir": "..\\..\\release",
|
||||||
|
"changelogPath": "..\\..\\CHANGELOG.md",
|
||||||
|
"scriptsPath": "..\\..\\src\\Scripts",
|
||||||
|
"testProject": "..\\..\\src\\MaksIT.UScheduler.Tests"
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"zipNamePattern": "maksit.uscheduler-{version}.zip",
|
||||||
|
"releaseTitlePattern": "Release {version}"
|
||||||
|
},
|
||||||
|
"launcher": {
|
||||||
|
"enabled": true,
|
||||||
|
"fileName": "Start-ScheduleManager.bat",
|
||||||
|
"targetProject": "scheduleManager"
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"scheduleManagerCsprojEndsWith": "MaksIT.UScheduler.ScheduleManager.csproj",
|
||||||
|
"uschedulerCsprojEndsWith": "MaksIT.UScheduler.csproj",
|
||||||
|
"scheduleManagerAppSettingsFile": "appsettings.json",
|
||||||
|
"uschedulerAppSettingsFile": "appsettings.json",
|
||||||
|
"scheduleManagerServiceBinPath": "..\\MaksIT.UScheduler\\",
|
||||||
|
"uschedulerLogDir": "..\\..\\Logs",
|
||||||
|
"scriptsRelativeToExe": "..\\..\\Scripts"
|
||||||
|
},
|
||||||
|
"_comments": {
|
||||||
|
"projects": {
|
||||||
|
"scheduleManagerCsprojEndsWith": "Used to detect ScheduleManager csproj from csprojPath list",
|
||||||
|
"uschedulerCsprojEndsWith": "Used to detect UScheduler csproj from csprojPath list",
|
||||||
|
"scheduleManagerAppSettingsFile": "Config file name inside published output for ScheduleManager",
|
||||||
|
"uschedulerAppSettingsFile": "Config file name inside published output for UScheduler",
|
||||||
|
"scheduleManagerServiceBinPath": "Value written into USchedulerSettings.ServiceBinPath in ScheduleManager config",
|
||||||
|
"uschedulerLogDir": "Value written into Configuration.LogDir in UScheduler config",
|
||||||
|
"scriptsRelativeToExe": "Scripts base path relative to executable folder (used for appsettings script list)"
|
||||||
|
},
|
||||||
|
"shortcut": {
|
||||||
|
"enabled": "If true, creates a .lnk in staging root",
|
||||||
|
"projectRole": "Which project to point the shortcut to (ScheduleManager or UScheduler)",
|
||||||
|
"fileName": "Shortcut file name in staging root"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
158
utils/TestRunner.psm1
Normal file
158
utils/TestRunner.psm1
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
PowerShell module for running tests with code coverage.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Provides the Invoke-TestsWithCoverage function for running .NET tests
|
||||||
|
with Coverlet code coverage collection and parsing results.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Author: MaksIT
|
||||||
|
Usage: Import-Module .\TestRunner.psm1
|
||||||
|
#>
|
||||||
|
|
||||||
|
function Invoke-TestsWithCoverage {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Runs unit tests with code coverage and returns coverage metrics.
|
||||||
|
|
||||||
|
.PARAMETER TestProjectPath
|
||||||
|
Path to the test project directory.
|
||||||
|
|
||||||
|
.PARAMETER Silent
|
||||||
|
Suppress console output (for JSON consumption).
|
||||||
|
|
||||||
|
.PARAMETER KeepResults
|
||||||
|
Keep the TestResults folder after execution.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
PSCustomObject with properties:
|
||||||
|
- Success: bool
|
||||||
|
- Error: string (if failed)
|
||||||
|
- LineRate: double
|
||||||
|
- BranchRate: double
|
||||||
|
- MethodRate: double
|
||||||
|
- TotalMethods: int
|
||||||
|
- CoveredMethods: int
|
||||||
|
- CoverageFile: string
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
$result = Invoke-TestsWithCoverage -TestProjectPath ".\Tests"
|
||||||
|
if ($result.Success) { Write-Host "Line coverage: $($result.LineRate)%" }
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$TestProjectPath,
|
||||||
|
|
||||||
|
[switch]$Silent,
|
||||||
|
|
||||||
|
[switch]$KeepResults
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# Resolve path
|
||||||
|
$TestProjectDir = Resolve-Path $TestProjectPath -ErrorAction SilentlyContinue
|
||||||
|
if (-not $TestProjectDir) {
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
Success = $false
|
||||||
|
Error = "Test project not found at: $TestProjectPath"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$ResultsDir = Join-Path $TestProjectDir "TestResults"
|
||||||
|
|
||||||
|
# Clean previous results
|
||||||
|
if (Test-Path $ResultsDir) {
|
||||||
|
Remove-Item -Recurse -Force $ResultsDir
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $Silent) {
|
||||||
|
Write-Host "Running tests with code coverage..." -ForegroundColor Cyan
|
||||||
|
Write-Host " Test Project: $TestProjectDir" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run tests with coverage collection
|
||||||
|
Push-Location $TestProjectDir
|
||||||
|
try {
|
||||||
|
$dotnetArgs = @(
|
||||||
|
"test"
|
||||||
|
"--collect:XPlat Code Coverage"
|
||||||
|
"--results-directory", $ResultsDir
|
||||||
|
"--verbosity", $(if ($Silent) { "quiet" } else { "normal" })
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($Silent) {
|
||||||
|
$null = & dotnet @dotnetArgs 2>&1
|
||||||
|
} else {
|
||||||
|
& dotnet @dotnetArgs
|
||||||
|
}
|
||||||
|
|
||||||
|
$testExitCode = $LASTEXITCODE
|
||||||
|
if ($testExitCode -ne 0) {
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
Success = $false
|
||||||
|
Error = "Tests failed with exit code $testExitCode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find the coverage file
|
||||||
|
$CoverageFile = Get-ChildItem -Path $ResultsDir -Filter "coverage.cobertura.xml" -Recurse | Select-Object -First 1
|
||||||
|
|
||||||
|
if (-not $CoverageFile) {
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
Success = $false
|
||||||
|
Error = "Coverage file not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $Silent) {
|
||||||
|
Write-Host "Coverage file found: $($CoverageFile.FullName)" -ForegroundColor Green
|
||||||
|
Write-Host "Parsing coverage data..." -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse coverage data from Cobertura XML
|
||||||
|
[xml]$coverageXml = Get-Content $CoverageFile.FullName
|
||||||
|
|
||||||
|
$lineRate = [math]::Round([double]$coverageXml.coverage.'line-rate' * 100, 1)
|
||||||
|
$branchRate = [math]::Round([double]$coverageXml.coverage.'branch-rate' * 100, 1)
|
||||||
|
|
||||||
|
# Calculate method coverage from packages
|
||||||
|
$totalMethods = 0
|
||||||
|
$coveredMethods = 0
|
||||||
|
foreach ($package in $coverageXml.coverage.packages.package) {
|
||||||
|
foreach ($class in $package.classes.class) {
|
||||||
|
foreach ($method in $class.methods.method) {
|
||||||
|
$totalMethods++
|
||||||
|
if ([double]$method.'line-rate' -gt 0) {
|
||||||
|
$coveredMethods++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$methodRate = if ($totalMethods -gt 0) { [math]::Round(($coveredMethods / $totalMethods) * 100, 1) } else { 0 }
|
||||||
|
|
||||||
|
# Cleanup unless KeepResults is specified
|
||||||
|
if (-not $KeepResults) {
|
||||||
|
if (Test-Path $ResultsDir) {
|
||||||
|
Remove-Item -Recurse -Force $ResultsDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Return results
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
Success = $true
|
||||||
|
LineRate = $lineRate
|
||||||
|
BranchRate = $branchRate
|
||||||
|
MethodRate = $methodRate
|
||||||
|
TotalMethods = $totalMethods
|
||||||
|
CoveredMethods = $coveredMethods
|
||||||
|
CoverageFile = $CoverageFile.FullName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Export-ModuleMember -Function Invoke-TestsWithCoverage
|
||||||
Loading…
Reference in New Issue
Block a user