Compare commits

...

13 Commits

Author SHA1 Message Date
Nathan
188d74c072 launch error display 2025-12-17 17:18:27 -07:00
Nathan
84a518a9c9 fix sheepit GPU selection 2025-12-17 16:55:36 -07:00
Nathan
3833311f11 attach functional 2025-12-17 16:51:41 -07:00
Nathan
553758c378 sheepit/flamenco toggle 2025-12-17 16:46:35 -07:00
Nathan
0460865ebc worker prints start success but no attachment yet 2025-12-17 16:32:39 -07:00
Nathan
4628fafc79 flam worker path also to global 2025-12-17 16:27:28 -07:00
Nathan
38105c2cfb fix worker list not refreshing 2025-12-17 16:25:49 -07:00
Nathan
17f685c91a add hide/unhide for renderkey 2025-12-17 16:23:40 -07:00
Nathan
202ad49f48 move SheepIt settings to global settings 2025-12-17 16:19:08 -07:00
Nathan
03b3ae58f4 fix add worker closing whole program 2025-12-17 16:17:27 -07:00
Nathan
9436ab7f62 build success 2025-12-17 16:15:11 -07:00
Nathan
06d2c3934b first build 2025-12-17 15:55:45 -07:00
Nathan
d0ac16e53a add readme and changelog 2025-12-17 15:40:47 -07:00
26 changed files with 68760 additions and 189 deletions

401
.gitignore vendored
View File

@@ -1,2 +1,403 @@
# SpecStory explanation file # SpecStory explanation file
.specstory/.what-is-this.md .specstory/.what-is-this.md
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio cache/options directory
.vs/
.vscode/
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# NuGet Packages
*.nupkg
*.snupkg
**/packages/*
!**/packages/build/
*.nuget.props
*.nuget.targets
# Rider
.idea/
*.sln.iml
# Visual Studio Code
.vscode/
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages Directory
## TODO: If you have NuGet Package Restore enabled, uncomment the next line
#packages/
# Windows Azure Build Output
csx/
*.build.csdef
# Windows Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd

View File

@@ -0,0 +1,645 @@
<!-- Generated by SpecStory, Markdown v2.1.0 -->
<!-- cursor Session 2a506942-5bcd-4a99-9e1e-a3cf55d5af92 (2025-12-17 22:32Z) -->
# Readme for sheepit worker incompatibility fix (2025-12-17 22:32Z)
_**User (2025-12-17 22:32Z)**_
context:@.specstory/history/2025-11-21_16-43Z-fixing-sheepit-worker-incompatibility.md
add a readme.
---
_**Agent (model default, mode Agent)**_
---
Reviewing the context file and codebase to draft the README.
---
<tool-use data-tool-type="read" data-tool-name="read_file">
<details><summary>Tool use: **read_file** • Read file: .specstory/history/2025-11-21_16-43Z-fixing-sheepit-worker-incompatibility.md</summary>
</details>
</tool-use>
---
<tool-use data-tool-type="read" data-tool-name="read_file">
<details><summary>Tool use: **read_file** • Read file: UnifiedFarmLauncher.csproj</summary>
</details>
</tool-use>
---
<tool-use data-tool-type="read" data-tool-name="read_file">
<details><summary>Tool use: **read_file** • Read file: Program.cs</summary>
</details>
</tool-use>
---
<tool-use data-tool-type="read" data-tool-name="read_file">
<details><summary>Tool use: **read_file** • Read file: ViewModels/MainWindowViewModel.cs</summary>
</details>
</tool-use>
---
---
Reading a portion of the context file and reviewing additional files:
---
<tool-use data-tool-type="read" data-tool-name="read_file">
<details><summary>Tool use: **read_file** • Read file: .specstory/history/2025-11-21_16-43Z-fixing-sheepit-worker-incompatibility.md</summary>
</details>
</tool-use>
---
<tool-use data-tool-type="read" data-tool-name="read_file">
<details><summary>Tool use: **read_file** • Read file: Models/ConfigRoot.cs</summary>
</details>
</tool-use>
---
<tool-use data-tool-type="read" data-tool-name="read_file">
<details><summary>Tool use: **read_file** • Read file: Services/WorkerControllerService.cs</summary>
</details>
</tool-use>
---
<tool-use data-tool-type="read" data-tool-name="read_file">
<details><summary>Tool use: **read_file** • Read file: Views/MainWindow.axaml</summary>
</details>
</tool-use>
---
---
Checking a few more files to understand the project structure:
---
<tool-use data-tool-type="read" data-tool-name="read_file">
<details><summary>Tool use: **read_file** • Read file: Models/WorkerConfig.cs</summary>
</details>
</tool-use>
---
<tool-use data-tool-type="read" data-tool-name="read_file">
<details><summary>Tool use: **read_file** • Read file: Services/ConfigService.cs</summary>
</details>
</tool-use>
---
<tool-use data-tool-type="search" data-tool-name="codebase_search">
<details>
<summary>Tool use: **codebase_search** • Searched codebase "What is the purpose of this application and what does it do?" • **8** results</summary>
| File | Lines |
|------|-------|
| `App.axaml.cs` | L30 |
| `Views/MainWindow.axaml` | L32 |
| `Program.cs` | L19 |
| `Services/ConfigService.cs` | L62 |
| `app.manifest` | L12 |
| `ViewModels/MainWindowViewModel.cs` | L60 |
| `UnifiedFarmLauncher.csproj` | L30 |
| `Views/MainWindow.axaml.cs` | L46 |
</details>
</tool-use>
---
---
Creating the README:
---
<tool-use data-tool-type="write" data-tool-name="write">
<details><summary>Tool use: **write** • Edit file: README.md</summary>
**Chunk 1**
Lines added: 162, lines removed: 1
```diff
@@ -1,1 +1,162 @@
-
+ # Unified Farm Launcher
+
+ A desktop application for managing remote render farm workers across multiple systems. Supports both **SheepIt** and **Flamenco** worker types with a unified interface for starting, stopping, and monitoring workers via SSH.
+
+ ## Features
+
+ - **Multi-Worker Support**: Manage multiple remote workers from a single interface
+ - **Worker Types**:
+ - **SheepIt**: Distributed rendering for Blender projects
+ - **Flamenco**: Blender Foundation's render farm manager
+ - **Remote Management**: Control workers on remote machines via SSH
+ - **Worker Lifecycle**: Start, stop, and attach to worker processes
+ - **Filtering**: Filter workers by type (All, SheepIt, Flamenco)
+ - **Configuration Management**: Add, edit, and delete worker configurations
+ - **Real-time Logging**: Attach to worker logs for real-time monitoring
+
+ ## Requirements
+
+ - **.NET 8.0** or later
+ - **Windows** (PowerShell required on remote machines)
+ - **SSH access** to remote worker machines
+ - **Java** (Temurin 21 recommended) on remote machines for SheepIt workers
+ - **Flamenco worker executable** on remote machines for Flamenco workers
+
+ ## Building
+
+ &#96;&#96;&#96;bash
+ dotnet build
+ &#96;&#96;&#96;
+
+ ## Running
+
+ &#96;&#96;&#96;bash
+ dotnet run
+ &#96;&#96;&#96;
+
+ Or build and run the executable from the output directory.
+
+ ## Configuration
+
+ Configuration is stored in &#96;%LocalAppData%\UnifiedFarmLauncher\workers.json&#96;. The application will create this file automatically on first run.
+
+ ### Configuration Structure
+
+ &#96;&#96;&#96;json
+ {
+ "workers": [
+ {
+ "id": 1,
+ "name": "worker-name",
+ "enabled": true,
+ "ssh": {
+ "host": "192.168.1.100",
+ "port": 22,
+ "username": "user",
+ "password": "password"
+ },
+ "workerTypes": {
+ "sheepIt": {
+ "username": "sheepit-username",
+ "renderKey": "render-key",
+ "gpu": "CPU"
+ },
+ "flamenco": {
+ "workerPath": "C:\\path\\to\\flamenco-worker",
+ "networkDrives": ["Z:", "Y:"],
+ "networkPaths": ["\\\\server\\share"]
+ }
+ }
+ }
+ ],
+ "globalSettings": {
+ "sheepItJarUrls": [
+ "https://sheepit-renderfarm.com/media/applet/client-latest.php"
+ ]
+ }
+ }
+ &#96;&#96;&#96;
+
+ ## Usage
+
+ ### Adding a Worker
+
+ 1. Click **Add Worker** button
+ 2. Fill in the worker details:
+ - **Name**: Unique identifier for the worker
+ - **SSH Configuration**: Host, port, username, and password
+ - **Worker Type Configuration**: Configure either SheepIt or Flamenco (or both)
+ 3. Click **Save**
+
+ ### Starting a Worker
+
+ 1. Select a worker from the list
+ 2. Click **Start** button
+ 3. The application will:
+ - Deploy the controller script to the remote machine
+ - Start the worker process
+ - Monitor the worker status
+
+ ### Stopping a Worker
+
+ 1. Select a running worker
+ 2. Click **Stop** button
+ 3. The worker process will be gracefully terminated
+
+ ### Attaching to Worker Logs
+
+ 1. Select a worker
+ 2. Click **Attach** button
+ 3. A terminal session will open showing real-time worker logs
+ 4. Type &#96;detach&#96; to exit the session
+
+ ### Filtering Workers
+
+ Use the tabs at the top to filter workers:
+ - **All Workers**: Shows all configured workers
+ - **SheepIt**: Shows only SheepIt workers
+ - **Flamenco**: Shows only Flamenco workers
+
+ ## Architecture
+
+ ### Project Structure
+
+ &#96;&#96;&#96;
+ UFL/
+ ├── Models/ # Data models (ConfigRoot, WorkerConfig, etc.)
+ ├── Services/ # Business logic services
+ │ ├── ConfigService.cs # Configuration management
+ │ ├── SshService.cs # SSH communication
+ │ ├── WorkerControllerService.cs # Worker lifecycle management
+ │ └── AttachService.cs # Log attachment service
+ ├── ViewModels/ # MVVM view models
+ ├── Views/ # Avalonia UI views
+ └── Scripts/ # PowerShell scripts (embedded resources)
+ ├── remote_worker_controller.ps1
+ └── remote_worker_attach.ps1
+ &#96;&#96;&#96;
+
+ ### Remote Worker Architecture
+
+ Workers run on remote machines under a controller process:
+
+ 1. **Controller Script**: Deployed to &#96;%LocalAppData%\UnifiedWorkers\controller.ps1&#96;
+ 2. **Worker Data**: Stored in &#96;%LocalAppData%\UnifiedWorkers\{workerType}\{workerName}\&#96;
+ - &#96;logs\worker.log&#96;: Worker output logs
+ - &#96;state\worker-info.json&#96;: Worker metadata and status
+ - &#96;state\commands.txt&#96;: Command queue for worker control
+ - &#96;state\payload.ps1&#96;: Worker execution script
+
+ 3. **Worker Process**: The controller manages the worker process lifecycle, handles restarts, and monitors status.
+
+ ## Technologies
+
+ - **Avalonia UI**: Cross-platform .NET UI framework
+ - **ReactiveUI**: MVVM framework
+ - **System.Text.Json**: JSON serialization
+ - **PowerShell**: Remote script execution
+
+ ## License
+
+ [Specify your license here]
+
```
</details>
</tool-use>
---
---
Created a README for the Unified Farm Launcher. It includes:
- **Overview**: Description of the application and its purpose
- **Features**: List of capabilities
- **Requirements**: Prerequisites for running the application
- **Building & Running**: Instructions for building and running
- **Configuration**: Structure and location of the config file
- **Usage**: Step-by-step instructions for common tasks
- **Architecture**: Project structure and remote worker architecture
- **Technologies**: Technologies used
The README provides an overview of the application and how to use it. If you want any sections expanded or additional details, say what to add.
---
_**User (2025-12-17 22:32Z)**_
add a changelog.
---
_**Agent (model default, mode Agent)**_
---
Reviewing the history file to identify changes for the changelog.
---
<tool-use data-tool-type="read" data-tool-name="read_file">
<details><summary>Tool use: **read_file** • Read file: .specstory/history/2025-11-21_16-43Z-fixing-sheepit-worker-incompatibility.md</summary>
</details>
</tool-use>
---
<tool-use data-tool-type="generic" data-tool-name="glob_file_search">
<details>
<summary>Tool use: **glob_file_search** • Searched codebase "*.md" • **20** results in **1** directory</summary>
Directory: **c:\Users\Nathan\Desktop\UFL\.specstory** (20 files)
| File |
|------|
| `history\2025-11-21_16-43Z-fixing-sheepit-worker-incompatibility.md` |
| `history\2025-11-21_01-14Z-workers-failing-and-ssh-connection-issues.md` |
| `history\2025-11-19_17-03Z-why-is-git-frozen.md` |
| `history\2025-11-06_17-28Z-update-path-structure-for-renders.md` |
| `history\2025-11-03_19-27Z-create-sister-script-for-sheepit.md` |
| `history\2025-08-13_18-59Z-remove-seq-from-jobname-format.md` |
| `.what-is-this.md` |
| `history\2025-07-15_18-30Z-configure-launch-standard-worker-for-flamenco-3-7.md` |
| `history\2025-06-03_00-04Z-change-retry-function-to-infinite-retries.md` |
| `history\2025-05-23_23-12Z-replace-remotecmd-with-remotesimplified.md` |
| `history\2025-05-14_15-49Z-cmd-worker-error-handling-and-retry-logic.md` |
| `history\2025-05-13_23-13Z-bypassing-password-prompts-in-script.md` |
| `history\2025-05-12_16-04Z-technical-scripting-and-troubleshooting-discussion.md` |
| `history\2025-05-07_22-05Z-script-modification-for-masterbox.md` |
| `history\2025-05-06_21-34Z-identifying-mistakes-and-seeking-guidance.md` |
| `history\2025-05-06_20-07Z-switching-to-optix-gpu-rendering.md` |
| `history\2025-05-05_16-17Z-flamenco-job-status-troubleshooting.md` |
| `history\2025-04-25_19-30Z-i9-13ks-setup-troubleshooting.md` |
| `history\2025-04-23_20-48Z-network-drive-mapping-and-file-copying-script.md` |
| `history\2025-04-04_17-25Z-creating-worker2-scripts-for-flamenco.md` |
</details>
</tool-use>
---
<tool-use data-tool-type="search" data-tool-name="codebase_search">
<details>
<summary>Tool use: **codebase_search** • Searched codebase "What are the main features and changes that have been implemented in this project?" • **8** results</summary>
| File | Lines |
|------|-------|
| `Views/MainWindow.axaml.cs` | L46 |
| `Views/MainWindow.axaml` | L32 |
| `ViewModels/MainWindowViewModel.cs` | L60 |
| `UnifiedFarmLauncher.csproj` | L30 |
| `ViewModels/ViewModelBase.cs` | L26 |
| `Services/ConfigService.cs` | L62 |
| `App.axaml.cs` | L30 |
| `Services/ConfigService.cs` | L59-121 |
</details>
</tool-use>
---
---
Reviewing a few more history files to understand the project's evolution:
---
<tool-use data-tool-type="read" data-tool-name="read_file">
<details><summary>Tool use: **read_file** • Read file: .specstory/history/2025-11-21_01-14Z-workers-failing-and-ssh-connection-issues.md</summary>
</details>
</tool-use>
---
<tool-use data-tool-type="read" data-tool-name="read_file">
<details><summary>Tool use: **read_file** • Read file: .specstory/history/2025-11-03_19-27Z-create-sister-script-for-sheepit.md</summary>
</details>
</tool-use>
---
<tool-use data-tool-type="read" data-tool-name="read_file">
<details><summary>Tool use: **read_file** • Read file: .specstory/history/2025-07-15_18-30Z-configure-launch-standard-worker-for-flamenco-3-7.md</summary>
</details>
</tool-use>
---
<tool-use data-tool-type="read" data-tool-name="read_file">
<details><summary>Tool use: **read_file** • Read file: .specstory/.what-is-this.md</summary>
</details>
</tool-use>
---
---
Creating the changelog based on the project history and codebase:
---
<tool-use data-tool-type="write" data-tool-name="write">
<details><summary>Tool use: **write** • Edit file: CHANGELOG.md</summary>
**Chunk 1**
Lines added: 121, lines removed: 1
```diff
@@ -1,1 +1,121 @@
-
+ # Changelog
+
+ All notable changes to the Unified Farm Launcher project will be documented in this file.
+
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+ ## [Unreleased]
+
+ ### Added
+ - README documentation
+ - CHANGELOG file
+
+ ## [1.0.0] - 2025-11-21
+
+ ### Added
+ - **Avalonia Desktop Application**: Complete migration from PowerShell scripts to a cross-platform desktop application
+ - **Worker Management UI**: Graphical interface for managing multiple remote workers
+ - **Multi-Worker Type Support**: Unified interface supporting both SheepIt and Flamenco worker types
+ - **Worker Configuration Management**: Add, edit, and delete worker configurations through the UI
+ - **Worker Filtering**: Filter workers by type (All, SheepIt, Flamenco) using tab controls
+ - **Remote Worker Controller**: PowerShell-based controller script deployed to remote machines for process management
+ - **Worker Lifecycle Management**: Start, stop, and monitor worker processes remotely
+ - **Log Attachment Service**: Real-time log viewing by attaching to remote worker processes
+ - **SSH Service**: Secure remote communication with worker machines
+ - **Configuration Persistence**: JSON-based configuration stored in &#96;%LocalAppData%\UnifiedFarmLauncher\workers.json&#96;
+ - **Worker Status Tracking**: Metadata tracking for worker state, PIDs, restart counts, and exit codes
+ - **Automatic Restart Logic**: Configurable restart attempts with delay for failed workers
+ - **Command Queue System**: File-based command queue for remote worker control
+ - **Embedded PowerShell Scripts**: Controller and attach helper scripts embedded as resources
+
+ ### Changed
+ - **Architecture**: Migrated from standalone PowerShell scripts to structured C# application with MVVM pattern
+ - **Payload Handling**: Switched from command-line argument passing to file-based payload storage to avoid Windows command-line length limits
+ - **Worker Process Management**: Implemented controller-based architecture for better process lifecycle management
+ - **Logging System**: Enhanced logging with timestamped, prefixed log entries (CTRL, OUT, ERR)
+ - **SheepIt Integration**: Updated to use JAR file downloads with fallback URLs instead of executable-based approach
+
+ ### Fixed
+ - **Command-Line Length Limits**: Resolved issues with large payloads exceeding Windows command-line argument limits by using file-based payload storage
+ - **Worker Process Startup**: Fixed issues with worker processes not starting properly on remote machines
+ - **SSH Connection Failures**: Improved SSH connection handling and error reporting
+ - **Log Output Capture**: Fixed missing worker output in logs by properly redirecting stdout/stderr
+ - **Controller Deployment**: Ensured controller script is properly deployed and executed on remote machines
+ - **Process Monitoring**: Improved worker process status tracking and metadata updates
+
+ ### Technical Details
+ - **Framework**: .NET 8.0 with Avalonia UI 11.0.0
+ - **Architecture**: MVVM pattern with ReactiveUI
+ - **Remote Execution**: PowerShell scripts executed via SSH
+ - **Data Storage**: JSON configuration with file-based state management on remote machines
+ - **Logging**: File-based logging with real-time streaming support
+
+ ## [Pre-1.0.0] - Historical Development
+
+ ### 2025-11-21
+ - Fixed SheepIt worker incompatibility issues
+ - Resolved command-line argument size limitations
+ - Improved payload resolution and file-based storage
+
+ ### 2025-11-21
+ - Fixed SSH connection issues and worker failures
+ - Improved error handling for remote command execution
+
+ ### 2025-11-06
+ - Updated path structure for renders
+
+ ### 2025-11-03
+ - Created unified SheepIt launcher script
+ - Added support for SheepIt worker type alongside Flamenco
+
+ ### 2025-08-13
+ - Removed sequence numbers from job name format
+
+ ### 2025-07-15
+ - Configured launch standard worker for Flamenco 3.7
+ - Updated worker paths to use &#96;F:\software\Flamenco 3.7&#96;
+
+ ### 2025-06-03
+ - Changed retry function to infinite retries
+
+ ### 2025-05-23
+ - Replaced RemoteCmd with RemoteSimplified
+
+ ### 2025-05-14
+ - Added CMD worker error handling and retry logic
+
+ ### 2025-05-13
+ - Implemented password prompt bypassing in scripts
+
+ ### 2025-05-12
+ - Technical scripting and troubleshooting improvements
+
+ ### 2025-05-07
+ - Script modifications for masterbox system
+
+ ### 2025-05-06
+ - Switched to OptiX GPU rendering
+ - Identified and fixed various mistakes
+
+ ### 2025-05-05
+ - Flamenco job status troubleshooting
+
+ ### 2025-04-25
+ - i9-13ks setup troubleshooting
+
+ ### 2025-04-23
+ - Network drive mapping and file copying script improvements
+
+ ### 2025-04-04
+ - Created worker2 scripts for Flamenco
+
+ ---
+
+ ## Notes
+
+ - This project evolved from PowerShell-based scripts to a full desktop application
+ - Early development focused on Flamenco worker management
+ - SheepIt support was added later as a second worker type
+ - The migration to Avalonia UI provided a unified interface for managing both worker types
+
```
</details>
</tool-use>
---
---
Created a CHANGELOG.md that includes:
- **Current Version (1.0.0)**: Major features and changes from the migration to the Avalonia desktop application
- **Historical Development Timeline**: Chronological entries based on the project history files
- **Technical Details**: Framework versions, architecture patterns, and implementation details
- **Project Evolution Notes**: Summary of the transition from PowerShell scripts to a desktop application
The changelog follows the [Keep a Changelog](https://keepachangelog.com/) format with sections for Added, Changed, and Fixed. It documents the project's evolution from early Flamenco worker scripts through the addition of SheepIt support to the current unified desktop application.
The changelog is ready to use and can be updated as new features and fixes are added.
---

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,7 @@
RequestedThemeVariant="Default"> RequestedThemeVariant="Default">
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
</Application.Styles>
<Application.Resources>
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/> <StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
</Application.Resources> </Application.Styles>
</Application> </Application>

121
CHANGELOG.md Normal file
View File

@@ -0,0 +1,121 @@
# Changelog
All notable changes to the Unified Farm Launcher project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- README documentation
- CHANGELOG file
## [0.0.1] - 2025-11-21
### Added
- **Avalonia Desktop Application**: Complete migration from PowerShell scripts to a cross-platform desktop application
- **Worker Management UI**: Graphical interface for managing multiple remote workers
- **Multi-Worker Type Support**: Unified interface supporting both SheepIt and Flamenco worker types
- **Worker Configuration Management**: Add, edit, and delete worker configurations through the UI
- **Worker Filtering**: Filter workers by type (All, SheepIt, Flamenco) using tab controls
- **Remote Worker Controller**: PowerShell-based controller script deployed to remote machines for process management
- **Worker Lifecycle Management**: Start, stop, and monitor worker processes remotely
- **Log Attachment Service**: Real-time log viewing by attaching to remote worker processes
- **SSH Service**: Secure remote communication with worker machines
- **Configuration Persistence**: JSON-based configuration stored in `%LocalAppData%\UnifiedFarmLauncher\workers.json`
- **Worker Status Tracking**: Metadata tracking for worker state, PIDs, restart counts, and exit codes
- **Automatic Restart Logic**: Configurable restart attempts with delay for failed workers
- **Command Queue System**: File-based command queue for remote worker control
- **Embedded PowerShell Scripts**: Controller and attach helper scripts embedded as resources
### Changed
- **Architecture**: Migrated from standalone PowerShell scripts to structured C# application with MVVM pattern
- **Payload Handling**: Switched from command-line argument passing to file-based payload storage to avoid Windows command-line length limits
- **Worker Process Management**: Implemented controller-based architecture for better process lifecycle management
- **Logging System**: Enhanced logging with timestamped, prefixed log entries (CTRL, OUT, ERR)
- **SheepIt Integration**: Updated to use JAR file downloads with fallback URLs instead of executable-based approach
### Fixed
- **Command-Line Length Limits**: Resolved issues with large payloads exceeding Windows command-line argument limits by using file-based payload storage
- **Worker Process Startup**: Fixed issues with worker processes not starting properly on remote machines
- **SSH Connection Failures**: Improved SSH connection handling and error reporting
- **Log Output Capture**: Fixed missing worker output in logs by properly redirecting stdout/stderr
- **Controller Deployment**: Ensured controller script is properly deployed and executed on remote machines
- **Process Monitoring**: Improved worker process status tracking and metadata updates
### Technical Details
- **Framework**: .NET 8.0 with Avalonia UI 11.0.0
- **Architecture**: MVVM pattern with ReactiveUI
- **Remote Execution**: PowerShell scripts executed via SSH
- **Data Storage**: JSON configuration with file-based state management on remote machines
- **Logging**: File-based logging with real-time streaming support
## [Pre-1.0.0] - Historical Development
### 2025-11-21
- Fixed SheepIt worker incompatibility issues
- Resolved command-line argument size limitations
- Improved payload resolution and file-based storage
### 2025-11-21
- Fixed SSH connection issues and worker failures
- Improved error handling for remote command execution
### 2025-11-06
- Updated path structure for renders
### 2025-11-03
- Created unified SheepIt launcher script
- Added support for SheepIt worker type alongside Flamenco
### 2025-08-13
- Removed sequence numbers from job name format
### 2025-07-15
- Configured launch standard worker for Flamenco 3.7
- Updated worker paths to use `F:\software\Flamenco 3.7`
### 2025-06-03
- Changed retry function to infinite retries
### 2025-05-23
- Replaced RemoteCmd with RemoteSimplified
### 2025-05-14
- Added CMD worker error handling and retry logic
### 2025-05-13
- Implemented password prompt bypassing in scripts
### 2025-05-12
- Technical scripting and troubleshooting improvements
### 2025-05-07
- Script modifications for masterbox system
### 2025-05-06
- Switched to OptiX GPU rendering
- Identified and fixed various mistakes
### 2025-05-05
- Flamenco job status troubleshooting
### 2025-04-25
- i9-13ks setup troubleshooting
### 2025-04-23
- Network drive mapping and file copying script improvements
### 2025-04-04
- Created worker2 scripts for Flamenco
---
## Notes
- This project evolved from PowerShell-based scripts to a full desktop application
- Early development focused on Flamenco worker management
- SheepIt support was added later as a second worker type
- The migration to Avalonia UI provided a unified interface for managing both worker types

View File

@@ -11,6 +11,15 @@ namespace UnifiedFarmLauncher.Models
"https://www.sheepit-renderfarm.com/media/applet/client-latest.php", "https://www.sheepit-renderfarm.com/media/applet/client-latest.php",
"https://www.sheepit-renderfarm.com/media/applet/client-latest.jar" "https://www.sheepit-renderfarm.com/media/applet/client-latest.jar"
}; };
[JsonPropertyName("sheepItUsername")]
public string SheepItUsername { get; set; } = string.Empty;
[JsonPropertyName("sheepItRenderKey")]
public string SheepItRenderKey { get; set; } = string.Empty;
[JsonPropertyName("flamencoWorkerPath")]
public string FlamencoWorkerPath { get; set; } = string.Empty;
} }
} }

View File

@@ -8,19 +8,10 @@ namespace UnifiedFarmLauncher.Models
{ {
[JsonPropertyName("gpu")] [JsonPropertyName("gpu")]
public string Gpu { get; set; } = string.Empty; public string Gpu { get; set; } = string.Empty;
[JsonPropertyName("username")]
public string Username { get; set; } = string.Empty;
[JsonPropertyName("renderKey")]
public string RenderKey { get; set; } = string.Empty;
} }
public class FlamencoConfig public class FlamencoConfig
{ {
[JsonPropertyName("workerPath")]
public string WorkerPath { get; set; } = string.Empty;
[JsonPropertyName("networkDrives")] [JsonPropertyName("networkDrives")]
public List<string> NetworkDrives { get; set; } = new(); public List<string> NetworkDrives { get; set; } = new();

162
README.md Normal file
View File

@@ -0,0 +1,162 @@
# Unified Farm Launcher
A desktop application for managing remote render farm workers across multiple systems. Supports both **SheepIt** and **Flamenco** worker types with a unified interface for starting, stopping, and monitoring workers via SSH.
## Features
- **Multi-Worker Support**: Manage multiple remote workers from a single interface
- **Worker Types**:
- **[SheepIt](https://www.sheepit-renderfarm.com/)**: Distributed rendering for Blender projects
- **[Flamenco](https://flamenco.blender.org/)**: Blender Foundation's render farm manager
- **Remote Management**: Control workers on remote machines via SSH
- **Worker Lifecycle**: Start, stop, and attach to worker processes
- **Filtering**: Filter workers by type (All, SheepIt, Flamenco)
- **Configuration Management**: Add, edit, and delete worker configurations
- **Real-time Logging**: Attach to worker logs for real-time monitoring
## Requirements
- **.NET 8.0** or later
- **Windows** (PowerShell required on remote machines)
- **SSH access** to remote worker machines
- **Java** (Temurin 21 recommended) on remote machines for SheepIt workers
- **Flamenco worker executable** on remote machines for Flamenco workers
## Building
```bash
dotnet build
```
## Running
```bash
dotnet run
```
Or build and run the executable from the output directory.
## Configuration
Configuration is stored in `%LocalAppData%\UnifiedFarmLauncher\workers.json`. The application will create this file automatically on first run.
### Configuration Structure
```json
{
"workers": [
{
"id": 1,
"name": "worker-name",
"enabled": true,
"ssh": {
"host": "192.168.1.100",
"port": 22,
"username": "user",
"password": "password"
},
"workerTypes": {
"sheepIt": {
"username": "sheepit-username",
"renderKey": "render-key",
"gpu": "CPU"
},
"flamenco": {
"workerPath": "C:\\path\\to\\flamenco-worker",
"networkDrives": ["Z:", "Y:"],
"networkPaths": ["\\\\server\\share"]
}
}
}
],
"globalSettings": {
"sheepItJarUrls": [
"https://sheepit-renderfarm.com/media/applet/client-latest.php"
]
}
}
```
## Usage
### Adding a Worker
1. Click **Add Worker** button
2. Fill in the worker details:
- **Name**: Unique identifier for the worker
- **SSH Configuration**: Host, port, username, and password
- **Worker Type Configuration**: Configure either SheepIt or Flamenco (or both)
3. Click **Save**
### Starting a Worker
1. Select a worker from the list
2. Click **Start** button
3. The application will:
- Deploy the controller script to the remote machine
- Start the worker process
- Monitor the worker status
### Stopping a Worker
1. Select a running worker
2. Click **Stop** button
3. The worker process will be gracefully terminated
### Attaching to Worker Logs
1. Select a worker
2. Click **Attach** button
3. A terminal session will open showing real-time worker logs
4. Type `detach` to exit the session
### Filtering Workers
Use the tabs at the top to filter workers:
- **All Workers**: Shows all configured workers
- **SheepIt**: Shows only SheepIt workers
- **Flamenco**: Shows only Flamenco workers
## Architecture
### Project Structure
```
UFL/
├── Models/ # Data models (ConfigRoot, WorkerConfig, etc.)
├── Services/ # Business logic services
│ ├── ConfigService.cs # Configuration management
│ ├── SshService.cs # SSH communication
│ ├── WorkerControllerService.cs # Worker lifecycle management
│ └── AttachService.cs # Log attachment service
├── ViewModels/ # MVVM view models
├── Views/ # Avalonia UI views
└── Scripts/ # PowerShell scripts (embedded resources)
├── remote_worker_controller.ps1
└── remote_worker_attach.ps1
```
### Remote Worker Architecture
Workers run on remote machines under a controller process:
1. **Controller Script**: Deployed to `%LocalAppData%\UnifiedWorkers\controller.ps1`
2. **Worker Data**: Stored in `%LocalAppData%\UnifiedWorkers\{workerType}\{workerName}\`
- `logs\worker.log`: Worker output logs
- `state\worker-info.json`: Worker metadata and status
- `state\commands.txt`: Command queue for worker control
- `state\payload.ps1`: Worker execution script
3. **Worker Process**: The controller manages the worker process lifecycle, handles restarts, and monitors status.
## Technologies
- **Avalonia UI**: Cross-platform .NET UI framework
- **ReactiveUI**: MVVM framework
- **System.Text.Json**: JSON serialization
- **PowerShell**: Remote script execution
## License
[Specify your license here]

View File

@@ -1,18 +1,52 @@
[CmdletBinding()]
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true, ValueFromPipeline = $false, ValueFromPipelineByPropertyName = $false)]
[string]$WorkerName, [string]$WorkerName,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true, ValueFromPipeline = $false, ValueFromPipelineByPropertyName = $false)]
[string]$WorkerType, [string]$WorkerType,
[Parameter(ValueFromPipeline = $false, ValueFromPipelineByPropertyName = $false)]
[string]$DataRoot = (Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers'), [string]$DataRoot = (Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers'),
[Parameter(ValueFromPipeline = $false, ValueFromPipelineByPropertyName = $false)]
[switch]$CommandOnly, [switch]$CommandOnly,
[Parameter(ValueFromPipeline = $false, ValueFromPipelineByPropertyName = $false)]
[string]$Command [string]$Command
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Continue'
# Explicitly clear any pipeline input to prevent binding errors
$null = $input
# Ensure we can see output immediately
try {
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
} catch {}
try {
$Host.UI.RawUI.BufferSize = New-Object System.Management.Automation.Host.Size(200, 9999)
} catch {}
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Worker Attach Session Starting" -ForegroundColor Cyan
Write-Host "Worker: $WorkerName" -ForegroundColor White
Write-Host "Type: $WorkerType" -ForegroundColor White
Write-Host "========================================" -ForegroundColor Cyan
[Console]::Out.Flush()
# Ensure output is visible immediately
try {
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
} catch {}
try {
$Host.UI.RawUI.BufferSize = New-Object System.Management.Automation.Host.Size(200, 9999)
} catch {}
Write-Host "Starting attach session..." -ForegroundColor Green
Write-Host "Worker: $WorkerName, Type: $WorkerType" -ForegroundColor Gray
[Console]::Out.Flush()
function Get-WorkerPaths { function Get-WorkerPaths {
param([string]$Root, [string]$Type, [string]$Name) param([string]$Root, [string]$Type, [string]$Name)
@@ -28,7 +62,17 @@ function Get-WorkerPaths {
$paths = Get-WorkerPaths -Root $DataRoot -Type $WorkerType -Name $WorkerName $paths = Get-WorkerPaths -Root $DataRoot -Type $WorkerType -Name $WorkerName
if (-not (Test-Path $paths.Metadata)) { if (-not (Test-Path $paths.Metadata)) {
Write-Host "No worker metadata found for $WorkerName ($WorkerType)." -ForegroundColor Red Write-Host ""
Write-Host "ERROR: No worker metadata found for $WorkerName ($WorkerType)." -ForegroundColor Red
Write-Host "Metadata path: $($paths.Metadata)" -ForegroundColor Gray
Write-Host "Data root: $DataRoot" -ForegroundColor Gray
Write-Host ""
Write-Host "Press any key to exit..."
try {
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
} catch {
Start-Sleep -Seconds 5
}
exit 1 exit 1
} }
@@ -73,16 +117,58 @@ if ($CommandOnly) {
exit 0 exit 0
} }
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Attaching to $WorkerName ($WorkerType) logs." -ForegroundColor Cyan Write-Host "Attaching to $WorkerName ($WorkerType) logs." -ForegroundColor Cyan
Write-Host "Type commands and press Enter. Type 'detach' to exit session." -ForegroundColor Yellow Write-Host "Log file: $($paths.Log)" -ForegroundColor Gray
Write-Host "========================================" -ForegroundColor Cyan
[Console]::Out.Flush()
[Console]::Error.WriteLine("Attach session initialized")
# Show initial log content if available
if (Test-Path $paths.Log) {
$initialLines = Get-Content -Path $paths.Log -Tail 50 -ErrorAction SilentlyContinue
if ($initialLines) {
Write-Host "--- Recent log output ---" -ForegroundColor DarkGray
$initialLines | ForEach-Object { Write-Host $_ }
Write-Host "--- End of initial output ---" -ForegroundColor DarkGray
} else {
Write-Host "Log file exists but is empty. Waiting for worker output..." -ForegroundColor Yellow
}
} else {
Write-Host "Log file does not exist yet. Waiting for worker to start..." -ForegroundColor Yellow
}
Write-Host ""
Write-Host "Type commands and press Enter. Type 'detach' to exit session." -ForegroundColor Yellow
Write-Host ""
# Use Register-ObjectEvent for file system watcher, or simpler: just tail in background and poll
$logJob = Start-Job -ScriptBlock { $logJob = Start-Job -ScriptBlock {
param($LogPath) param($LogPath)
Get-Content -Path $LogPath -Tail 50 -Wait if (Test-Path $LogPath) {
Get-Content -Path $LogPath -Tail 0 -Wait -ErrorAction SilentlyContinue
} else {
while (-not (Test-Path $LogPath)) {
Start-Sleep -Milliseconds 500
}
Get-Content -Path $LogPath -Tail 0 -Wait -ErrorAction SilentlyContinue
}
} -ArgumentList $paths.Log } -ArgumentList $paths.Log
try { try {
while ($true) { while ($true) {
# Check for new log output from background job (non-blocking)
$logOutput = Receive-Job -Job $logJob -ErrorAction SilentlyContinue
if ($logOutput) {
$logOutput | ForEach-Object { Write-Host $_ }
[Console]::Out.Flush()
}
# Use a timeout on Read-Host to allow periodic log checking
# Since Read-Host doesn't support timeout, we'll just check logs before each Read-Host
# This means logs will be checked whenever user presses Enter
$input = Read-Host "> " $input = Read-Host "> "
if ($null -eq $input) { if ($null -eq $input) {
continue continue
@@ -104,10 +190,14 @@ try {
Send-WorkerCommand -Value $input Send-WorkerCommand -Value $input
} }
} }
catch {
Write-Host "Error in attach session: $($_.Exception.Message)" -ForegroundColor Red
Write-Host $_.ScriptStackTrace -ForegroundColor DarkRed
[Console]::Error.WriteLine("Attach error: $($_.Exception.Message)")
}
finally { finally {
if ($logJob) { if ($logJob) {
Stop-Job -Job $logJob -ErrorAction SilentlyContinue Stop-Job -Job $logJob -ErrorAction SilentlyContinue
Receive-Job -Job $logJob -ErrorAction SilentlyContinue | Out-Null
Remove-Job -Job $logJob -ErrorAction SilentlyContinue Remove-Job -Job $logJob -ErrorAction SilentlyContinue
} }

View File

@@ -224,8 +224,24 @@ function Get-PendingCommands {
# endregion # endregion
try { try {
# Check if metadata already exists with a ControllerPid (set by ensureScript)
$existingControllerPid = $null
if (Test-Path $metaPath) {
try {
$existing = Get-Content $metaPath -Raw | ConvertFrom-Json
if ($existing.ControllerPid) {
$existingControllerPid = $existing.ControllerPid
}
} catch {
# Ignore parse errors
}
}
# Use existing ControllerPid if available, otherwise use current PID
$controllerPidToUse = if ($existingControllerPid) { $existingControllerPid } else { $PID }
# record initial state before launching worker # record initial state before launching worker
Write-Metadata -Status 'initializing' -WorkerPid $null -ControllerPid $PID -Restarts 0 Write-Metadata -Status 'initializing' -WorkerPid $null -ControllerPid $controllerPidToUse -Restarts 0
$resolvedPayloadBase64 = Resolve-PayloadBase64 $resolvedPayloadBase64 = Resolve-PayloadBase64
$PayloadBase64 = $resolvedPayloadBase64 $PayloadBase64 = $resolvedPayloadBase64

View File

@@ -1,4 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using UnifiedFarmLauncher.Models; using UnifiedFarmLauncher.Models;
@@ -20,7 +21,7 @@ namespace UnifiedFarmLauncher.Services
await _controllerService.DeployAttachHelperAsync(worker); await _controllerService.DeployAttachHelperAsync(worker);
var remoteBasePath = await _sshService.GetWorkerBasePathAsync(worker); var remoteBasePath = await _sshService.GetWorkerBasePathAsync(worker);
var remoteHelper = $"{remoteBasePath.Replace("\\", "/")}/attach-helper.ps1"; var remoteHelper = Path.Combine(remoteBasePath, "attach-helper.ps1");
var paramsBlock = $"-WorkerName \"{worker.Name}\" -WorkerType \"{workerType}\""; var paramsBlock = $"-WorkerName \"{worker.Name}\" -WorkerType \"{workerType}\"";
if (commandOnly) if (commandOnly)
@@ -32,7 +33,11 @@ namespace UnifiedFarmLauncher.Services
paramsBlock += $" -Command \"{command}\""; paramsBlock += $" -Command \"{command}\"";
} }
var remoteCmd = $"powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File \"{remoteHelper}\" {paramsBlock}"; // Use Windows path format (backslashes) and ensure it's properly quoted
// Add -NoExit to keep window open and ensure output is visible
// Use -Command with & to properly invoke the script and avoid pipeline input issues
var escapedHelper = remoteHelper.Replace("'", "''");
var remoteCmd = $"powershell.exe -NoLogo -NoProfile -NoExit -ExecutionPolicy Bypass -Command \"& '{escapedHelper}' {paramsBlock}\"";
_sshService.StartInteractiveSsh(worker, remoteCmd); _sshService.StartInteractiveSsh(worker, remoteCmd);
} }

View File

@@ -197,12 +197,15 @@ namespace UnifiedFarmLauncher.Services
var sshArgs = BuildSshArgs(parts, interactive); var sshArgs = BuildSshArgs(parts, interactive);
sshArgs.Add(command); sshArgs.Add(command);
var argsString = string.Join(" ", sshArgs.Select(arg => $"\"{arg.Replace("\"", "\\\"")}\""));
var process = new Process var process = new Process
{ {
StartInfo = new ProcessStartInfo StartInfo = new ProcessStartInfo
{ {
FileName = GetSshExecutable(), FileName = GetSshExecutable(),
Arguments = string.Join(" ", sshArgs.Select(arg => $"\"{arg.Replace("\"", "\\\"")}\"")), WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
Arguments = argsString,
UseShellExecute = false, UseShellExecute = false,
RedirectStandardOutput = true, RedirectStandardOutput = true,
RedirectStandardError = true, RedirectStandardError = true,
@@ -230,6 +233,72 @@ namespace UnifiedFarmLauncher.Services
return output.ToString(); return output.ToString();
} }
public async Task<string> ExecuteRemoteScriptAsync(WorkerConfig worker, string script, bool interactive = false)
{
var parts = ParseConnectionParts(worker.Ssh.Args, worker.Ssh.Host);
var sshArgs = BuildSshArgs(parts, interactive);
// Add PowerShell command as a single quoted argument for remote execution
sshArgs.Add("powershell -NoLogo -NoProfile -NonInteractive -OutputFormat Text -ExecutionPolicy Bypass -Command -");
var argsString = string.Join(" ", sshArgs.Select(arg => $"\"{arg.Replace("\"", "\\\"")}\""));
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = GetSshExecutable(),
WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
Arguments = argsString,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = !interactive
}
};
var output = new StringBuilder();
var error = new StringBuilder();
process.OutputDataReceived += (s, e) => { if (e.Data != null) output.AppendLine(e.Data); };
process.ErrorDataReceived += (s, e) => { if (e.Data != null) error.AppendLine(e.Data); };
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
// Write script to stdin
await process.StandardInput.WriteAsync(script);
process.StandardInput.Close();
await process.WaitForExitAsync();
if (process.ExitCode != 0 && !interactive)
{
var errorText = error.ToString();
var combinedOutput = output.ToString();
if (!string.IsNullOrWhiteSpace(errorText))
{
combinedOutput = string.IsNullOrWhiteSpace(combinedOutput)
? errorText
: $"{combinedOutput}\n\n=== STDERR ===\n{errorText}";
}
throw new InvalidOperationException($"SSH script execution failed with exit code {process.ExitCode}: {combinedOutput}");
}
// Include stderr in output if present (for diagnostics)
var result = output.ToString();
var stderrText = error.ToString();
if (!string.IsNullOrWhiteSpace(stderrText) && !interactive)
{
result = string.IsNullOrWhiteSpace(result)
? stderrText
: $"{result}\n\n=== STDERR ===\n{stderrText}";
}
return result;
}
public async Task<string> GetWorkerBasePathAsync(WorkerConfig worker) public async Task<string> GetWorkerBasePathAsync(WorkerConfig worker)
{ {
if (_workerBasePathCache.TryGetValue(worker.Name, out var cached)) if (_workerBasePathCache.TryGetValue(worker.Name, out var cached))
@@ -288,12 +357,15 @@ namespace UnifiedFarmLauncher.Services
var sshArgs = BuildSshArgs(parts, true); var sshArgs = BuildSshArgs(parts, true);
sshArgs.Add(command); sshArgs.Add(command);
var argsString = string.Join(" ", sshArgs.Select(arg => $"\"{arg.Replace("\"", "\\\"")}\""));
var process = new Process var process = new Process
{ {
StartInfo = new ProcessStartInfo StartInfo = new ProcessStartInfo
{ {
FileName = GetSshExecutable(), FileName = GetSshExecutable(),
Arguments = string.Join(" ", sshArgs.Select(arg => $"\"{arg.Replace("\"", "\\\"")}\"")), WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
Arguments = argsString,
UseShellExecute = true, UseShellExecute = true,
CreateNoWindow = false CreateNoWindow = false
} }

View File

@@ -1,7 +1,10 @@
using System; using System;
using System.IO; using System.IO;
using System.Linq;
using System.Reflection; using System.Reflection;
using System.Text; using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using UnifiedFarmLauncher.Models; using UnifiedFarmLauncher.Models;
@@ -65,7 +68,7 @@ $controllerPath = Join-Path $dataRoot 'controller.ps1'
[IO.File]::WriteAllBytes($controllerPath, [Convert]::FromBase64String('{controllerBase64}')) [IO.File]::WriteAllBytes($controllerPath, [Convert]::FromBase64String('{controllerBase64}'))
"; ";
await _sshService.ExecuteRemoteCommandAsync(worker, $"powershell -NoLogo -NoProfile -NonInteractive -OutputFormat Text -ExecutionPolicy Bypass -EncodedCommand {Convert.ToBase64String(Encoding.Unicode.GetBytes(script))}"); await _sshService.ExecuteRemoteScriptAsync(worker, script);
} }
public async Task DeployAttachHelperAsync(WorkerConfig worker) public async Task DeployAttachHelperAsync(WorkerConfig worker)
@@ -79,7 +82,7 @@ $attachPath = Join-Path $dataRoot 'attach-helper.ps1'
[IO.File]::WriteAllBytes($attachPath, [Convert]::FromBase64String('{helperBase64}')) [IO.File]::WriteAllBytes($attachPath, [Convert]::FromBase64String('{helperBase64}'))
"; ";
await _sshService.ExecuteRemoteCommandAsync(worker, $"powershell -NoLogo -NoProfile -NonInteractive -OutputFormat Text -ExecutionPolicy Bypass -EncodedCommand {Convert.ToBase64String(Encoding.Unicode.GetBytes(script))}"); await _sshService.ExecuteRemoteScriptAsync(worker, script);
} }
public string GenerateSheepItPayload(WorkerConfig worker) public string GenerateSheepItPayload(WorkerConfig worker)
@@ -89,9 +92,10 @@ $attachPath = Join-Path $dataRoot 'attach-helper.ps1'
var config = _configService.Load(); var config = _configService.Load();
var sheepIt = worker.WorkerTypes.SheepIt; var sheepIt = worker.WorkerTypes.SheepIt;
var safeKey = sheepIt.RenderKey.Replace("'", "''"); var globalSettings = config.GlobalSettings;
var safeUser = sheepIt.Username.Replace("'", "''"); var safeKey = globalSettings.SheepItRenderKey.Replace("'", "''");
var urls = config.GlobalSettings.SheepItJarUrls; var safeUser = globalSettings.SheepItUsername.Replace("'", "''");
var urls = globalSettings.SheepItJarUrls;
var urlLiteral = "@(" + string.Join(", ", Array.ConvertAll(urls.ToArray(), url => $"'{url}'")) + ")"; var urlLiteral = "@(" + string.Join(", ", Array.ConvertAll(urls.ToArray(), url => $"'{url}'")) + ")";
return $@" return $@"
@@ -170,7 +174,10 @@ catch {{
if (worker.WorkerTypes.Flamenco == null) if (worker.WorkerTypes.Flamenco == null)
throw new InvalidOperationException("Worker does not have Flamenco configuration"); throw new InvalidOperationException("Worker does not have Flamenco configuration");
var config = _configService.Load();
var flamenco = worker.WorkerTypes.Flamenco; var flamenco = worker.WorkerTypes.Flamenco;
var globalSettings = config.GlobalSettings;
var workerPath = globalSettings.FlamencoWorkerPath.Replace("'", "''");
var drives = string.Join(", ", Array.ConvertAll(flamenco.NetworkDrives.ToArray(), d => $"'{d}'")); var drives = string.Join(", ", Array.ConvertAll(flamenco.NetworkDrives.ToArray(), d => $"'{d}'"));
var paths = string.Join(", ", Array.ConvertAll(flamenco.NetworkPaths.ToArray(), p => $"'{p.Replace("\\", "\\\\")}'")); var paths = string.Join(", ", Array.ConvertAll(flamenco.NetworkPaths.ToArray(), p => $"'{p.Replace("\\", "\\\\")}'"));
@@ -199,7 +206,7 @@ try {{
# Start worker # Start worker
Write-Host ""Starting Flamenco worker..."" -ForegroundColor Cyan Write-Host ""Starting Flamenco worker..."" -ForegroundColor Cyan
Set-Location '{flamenco.WorkerPath}' Set-Location '{workerPath}'
if (Test-Path 'flamenco-worker.exe') {{ if (Test-Path 'flamenco-worker.exe') {{
Write-Host ""Running flamenco-worker.exe..."" -ForegroundColor Green Write-Host ""Running flamenco-worker.exe..."" -ForegroundColor Green
$workerProcess = Start-Process -FilePath '.\flamenco-worker.exe' -NoNewWindow -PassThru -Wait $workerProcess = Start-Process -FilePath '.\flamenco-worker.exe' -NoNewWindow -PassThru -Wait
@@ -207,8 +214,8 @@ try {{
Write-Host ""Flamenco worker process has terminated with exit code: $exitCode"" -ForegroundColor Yellow Write-Host ""Flamenco worker process has terminated with exit code: $exitCode"" -ForegroundColor Yellow
exit $exitCode exit $exitCode
}} else {{ }} else {{
Write-Host ""Error: flamenco-worker.exe not found in {flamenco.WorkerPath}"" -ForegroundColor Red Write-Host ""Error: flamenco-worker.exe not found in {workerPath}"" -ForegroundColor Red
[Console]::Error.WriteLine(""Error: flamenco-worker.exe not found in {flamenco.WorkerPath}"") [Console]::Error.WriteLine(""Error: flamenco-worker.exe not found in {workerPath}"")
exit 1 exit 1
}} }}
}} }}
@@ -245,9 +252,12 @@ catch {{
var payloadBase64 = Convert.ToBase64String(Encoding.Unicode.GetBytes(payloadScript)); var payloadBase64 = Convert.ToBase64String(Encoding.Unicode.GetBytes(payloadScript));
var remoteBasePath = await _sshService.GetWorkerBasePathAsync(worker); var remoteBasePath = await _sshService.GetWorkerBasePathAsync(worker);
var jsonParams = $@"{{""WorkerName"":""{worker.Name}"",""WorkerType"":""{workerType}"",""PayloadBase64"":""{payloadBase64}""}}";
var jsonParamsBase64 = Convert.ToBase64String(Encoding.Unicode.GetBytes(jsonParams));
var ensureScript = $@" var ensureScript = $@"
$ProgressPreference = 'SilentlyContinue' $ProgressPreference = 'SilentlyContinue'
$params = ConvertFrom-Json ([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('{Convert.ToBase64String(Encoding.Unicode.GetBytes($@"{{""WorkerName"":""{worker.Name}"",""WorkerType"":""{workerType}"",""PayloadBase64"":""{payloadBase64}""}}"@))}'))) $params = ConvertFrom-Json ([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('{jsonParamsBase64}')))
$workerName = $params.WorkerName $workerName = $params.WorkerName
$workerType = $params.WorkerType $workerType = $params.WorkerType
$payloadBase64 = $params.PayloadBase64 $payloadBase64 = $params.PayloadBase64
@@ -318,12 +328,356 @@ if ($shouldStart) {{
'-PayloadBase64Path',""$payloadBase64Path"" '-PayloadBase64Path',""$payloadBase64Path""
) )
Start-Process -FilePath $psExe -ArgumentList $controllerArgs -WindowStyle Hidden | Out-Null Write-Host ""Attempting to start controller with: $psExe"" -ForegroundColor Cyan
Write-Host ""Worker $workerName started under controller."" -ForegroundColor Green Write-Host ""Controller path: $controllerPath"" -ForegroundColor Cyan
Write-Host ""Arguments: $($controllerArgs -join ' ')"" -ForegroundColor Cyan
try {{
$controllerProcess = Start-Process -FilePath $psExe -ArgumentList $controllerArgs -WindowStyle Hidden -PassThru -ErrorAction Stop
if ($controllerProcess) {{
$controllerPid = $controllerProcess.Id
Write-Host ""Controller process started with PID: $controllerPid"" -ForegroundColor Green
# Give it a moment to initialize
Start-Sleep -Milliseconds 500
# Verify process is still running
$stillRunning = Get-Process -Id $controllerPid -ErrorAction SilentlyContinue
if (-not $stillRunning) {{
throw ""Controller process (PID: $controllerPid) exited immediately after start""
}}
# Update metadata with controller PID
$updatedMeta = [pscustomobject]@{{
WorkerName = $workerName
WorkerType = $workerType
Status = 'launching'
ControllerPid = $controllerPid
WorkerPid = $null
Restarts = 0
LastExitCode = $null
LogPath = $logPath
CommandPath = $commandPath
PayloadPath = $payloadPath
UpdatedAtUtc = (Get-Date).ToUniversalTime()
}} | ConvertTo-Json -Depth 5
$updatedMeta | Set-Content -Path $metaPath -Encoding UTF8
# Ensure file is flushed to disk before controller reads it
Start-Sleep -Milliseconds 200
Write-Host ""Worker $workerName started under controller (PID: $controllerPid)."" -ForegroundColor Green
}} else {{
throw ""Start-Process returned null - controller failed to start""
}}
}} catch {{
$errorMsg = $_.Exception.Message
$errorDetails = $_.Exception.ToString()
Write-Host ""Failed to start controller: $errorMsg"" -ForegroundColor Red
Write-Host ""Error details: $errorDetails"" -ForegroundColor Red
[Console]::Error.WriteLine(""Controller startup error: $errorMsg"")
[Console]::Error.WriteLine(""Full error: $errorDetails"")
# Update metadata with error
$errorMeta = [pscustomobject]@{{
WorkerName = $workerName
WorkerType = $workerType
Status = 'error'
ControllerPid = $null
WorkerPid = $null
Restarts = 0
LastExitCode = 1
LogPath = $logPath
CommandPath = $commandPath
PayloadPath = $payloadPath
UpdatedAtUtc = (Get-Date).ToUniversalTime()
ErrorMessage = ""Controller startup failed: $errorMsg""
}} | ConvertTo-Json -Depth 5
$errorMeta | Set-Content -Path $metaPath -Encoding UTF8
throw
}}
}} }}
"; ";
await _sshService.ExecuteRemoteCommandAsync(worker, $"powershell -NoLogo -NoProfile -NonInteractive -OutputFormat Text -ExecutionPolicy Bypass -EncodedCommand {Convert.ToBase64String(Encoding.Unicode.GetBytes(ensureScript))}"); // Pipe script through stdin to avoid command line length limits
string? ensureScriptOutput = null;
try
{
ensureScriptOutput = await _sshService.ExecuteRemoteScriptAsync(worker, ensureScript);
// Log any output from ensureScript for debugging
if (!string.IsNullOrWhiteSpace(ensureScriptOutput))
{
System.Diagnostics.Debug.WriteLine($"EnsureScript output: {ensureScriptOutput}");
}
}
catch (Exception ex)
{
// If ensureScript fails, include that in diagnostics
System.Diagnostics.Debug.WriteLine($"EnsureScript execution error: {ex.Message}");
// Don't throw here - let the status check below handle it with full diagnostics
}
// Wait a moment for the controller to start and update metadata
// Give extra time for the metadata write to complete
await Task.Delay(3000);
// Verify the worker actually started by checking metadata
// Try up to 3 times with delays to account for slower startup
WorkerStatus? status = null;
for (int i = 0; i < 3; i++)
{
status = await GetWorkerStatusAsync(worker, workerType);
if (status != null && (status.Status == "running" || status.Status == "launching"))
{
break;
}
if (i < 2) // Don't delay on last attempt
{
await Task.Delay(1000);
}
}
if (status == null || (status.Status != "running" && status.Status != "launching"))
{
// Gather comprehensive diagnostic information
var diagnostics = await GatherDiagnosticsAsync(worker, workerType);
var errorMessage = status?.ErrorMessage ?? "Worker failed to start. Check logs for details.";
var statusText = status?.Status ?? "unknown";
// Include ensureScript output if available
var ensureOutputInfo = string.IsNullOrWhiteSpace(ensureScriptOutput)
? ""
: $"\n\n=== EnsureScript Output ===\n{ensureScriptOutput}";
throw new InvalidOperationException($"Worker did not start successfully. Status: {statusText}. {errorMessage}\n\n{diagnostics}{ensureOutputInfo}");
}
}
private async Task<WorkerStatus?> GetWorkerStatusAsync(WorkerConfig worker, string workerType)
{
var remoteBasePath = await _sshService.GetWorkerBasePathAsync(worker);
var instanceRoot = Path.Combine(remoteBasePath, workerType, worker.Name);
var metaPath = Path.Combine(instanceRoot, "state", "worker-info.json");
var script = $@"
$ProgressPreference = 'SilentlyContinue'
$dataRoot = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers'
$instanceRoot = Join-Path (Join-Path $dataRoot '{workerType}') '{worker.Name}'
$metaPath = Join-Path $instanceRoot 'state\worker-info.json'
if (Test-Path $metaPath) {{
try {{
$meta = Get-Content $metaPath -Raw | ConvertFrom-Json
$meta | ConvertTo-Json -Depth 5 -Compress
}} catch {{
Write-Error ""Error reading metadata: $($_.Exception.Message)""
}}
}} else {{
Write-Error ""Metadata file not found at $metaPath""
}}
";
try
{
var output = await _sshService.ExecuteRemoteScriptAsync(worker, script);
if (string.IsNullOrWhiteSpace(output))
return null;
var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
// Look for JSON line - it might be on a single line or multiple lines
var jsonLines = lines.Where(line => line.Trim().StartsWith("{")).ToList();
string? jsonText = null;
if (jsonLines.Count > 0)
{
// Use the first JSON line found
jsonText = jsonLines[0].Trim();
}
else
{
// Try to find JSON that might span multiple lines
var fullText = string.Join("", lines);
var startIdx = fullText.IndexOf('{');
if (startIdx >= 0)
{
var endIdx = fullText.LastIndexOf('}');
if (endIdx > startIdx)
{
jsonText = fullText.Substring(startIdx, endIdx - startIdx + 1);
}
}
}
if (string.IsNullOrEmpty(jsonText))
return null;
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
return JsonSerializer.Deserialize<WorkerStatus>(jsonText, options);
}
catch (Exception ex)
{
// Log the exception for debugging but return null
System.Diagnostics.Debug.WriteLine($"Error parsing worker status: {ex.Message}");
return null;
}
}
private async Task<string> GatherDiagnosticsAsync(WorkerConfig worker, string workerType)
{
var diagnostics = new StringBuilder();
var script = $@"
$ProgressPreference = 'SilentlyContinue'
$ErrorActionPreference = 'Continue'
$dataRoot = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers'
$instanceRoot = Join-Path (Join-Path $dataRoot '{workerType}') '{worker.Name}'
$metaPath = Join-Path $instanceRoot 'state\worker-info.json'
$logPath = Join-Path $instanceRoot 'logs\worker.log'
$controllerPath = Join-Path $dataRoot 'controller.ps1'
Write-Host ""=== Diagnostics ==="" -ForegroundColor Cyan
Write-Host ""Metadata file exists: $((Test-Path $metaPath))""
Write-Host ""Log file exists: $((Test-Path $logPath))""
Write-Host ""Controller script exists: $((Test-Path $controllerPath))""
# Check controller script content
if (Test-Path $controllerPath) {{
$controllerSize = (Get-Item $controllerPath).Length
Write-Host ""Controller script size: $controllerSize bytes""
$firstLine = Get-Content $controllerPath -First 1 -ErrorAction SilentlyContinue
if ($firstLine) {{
Write-Host ""Controller script first line: $firstLine""
}}
# Try to test if controller script can be parsed/executed
Write-Host ""`n=== Testing Controller Script ==="" -ForegroundColor Cyan
try {{
$testResult = powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -Command ""& {{ Get-Command -Name '$controllerPath' -ErrorAction Stop }}"" 2>&1
Write-Host ""Controller script syntax check: Passed""
}} catch {{
Write-Host ""Controller script syntax check failed: $($_.Exception.Message)"" -ForegroundColor Red
}}
# Check if we can manually invoke the controller with test parameters
$testParams = @('-WorkerName', 'TEST', '-WorkerType', 'sheepit', '-PayloadBase64Path', 'C:\temp\test.b64')
Write-Host ""Attempting to test controller invocation...""
try {{
$testProcess = Start-Process -FilePath powershell -ArgumentList @('-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ""$controllerPath"", '-WorkerName', 'TEST') -PassThru -WindowStyle Hidden -ErrorAction Stop
Start-Sleep -Milliseconds 200
if ($testProcess.HasExited) {{
Write-Host ""Test controller process exited with code: $($testProcess.ExitCode)"" -ForegroundColor Yellow
}} else {{
Write-Host ""Test controller process started successfully (PID: $($testProcess.Id))"" -ForegroundColor Green
Stop-Process -Id $testProcess.Id -Force -ErrorAction SilentlyContinue
}}
}} catch {{
Write-Host ""Test controller invocation failed: $($_.Exception.Message)"" -ForegroundColor Red
Write-Host ""Full error: $($_.Exception.ToString())"" -ForegroundColor Red
}}
}}
if (Test-Path $metaPath) {{
try {{
$meta = Get-Content $metaPath -Raw | ConvertFrom-Json
Write-Host ""Metadata Status: $($meta.Status)""
Write-Host ""Controller PID: $($meta.ControllerPid)""
Write-Host ""Worker PID: $($meta.WorkerPid)""
if ($meta.ErrorMessage) {{
Write-Host ""Error Message: $($meta.ErrorMessage)"" -ForegroundColor Red
}}
}} catch {{
Write-Host ""Error reading metadata: $($_.Exception.Message)"" -ForegroundColor Red
Write-Host ""Metadata file content (first 500 chars):"" -ForegroundColor Yellow
$metaContent = Get-Content $metaPath -Raw -ErrorAction SilentlyContinue
if ($metaContent) {{
Write-Host $metaContent.Substring(0, [Math]::Min(500, $metaContent.Length))
}}
}}
}} else {{
Write-Host ""Metadata file not found - controller may not have started"" -ForegroundColor Yellow
}}
# Check if controller process is running
$controllerRunning = $false
$controllerProcessInfo = ""N/A""
if (Test-Path $metaPath) {{
try {{
$meta = Get-Content $metaPath -Raw | ConvertFrom-Json
if ($meta.ControllerPid) {{
$proc = Get-Process -Id $meta.ControllerPid -ErrorAction SilentlyContinue
$controllerRunning = ($null -ne $proc)
if ($controllerRunning) {{
$controllerProcessInfo = ""Running (PID: $($meta.ControllerPid))""
}} else {{
$controllerProcessInfo = ""Not running (PID: $($meta.ControllerPid) - process not found)""
}}
}} else {{
$controllerProcessInfo = ""No controller PID in metadata""
}}
}} catch {{
$controllerProcessInfo = ""Error checking: $($_.Exception.Message)""
}}
}} else {{
$controllerProcessInfo = ""Metadata file not found""
}}
Write-Host ""Controller process: $controllerProcessInfo""
Write-Host ""`n=== Recent Log Entries (last 30 lines) ==="" -ForegroundColor Cyan
if (Test-Path $logPath) {{
try {{
$logFileInfo = Get-Item $logPath -ErrorAction SilentlyContinue
if ($logFileInfo) {{
Write-Host ""Log file size: $($logFileInfo.Length) bytes"" -ForegroundColor Gray
}}
$logContent = Get-Content $logPath -ErrorAction SilentlyContinue
if ($logContent) {{
$logLines = if ($logContent.Count -gt 30) {{ $logContent[-30..-1] }} else {{ $logContent }}
Write-Host ""Found $($logLines.Count) log lines (showing last $([Math]::Min(30, $logLines.Count)))""
$logLines | ForEach-Object {{ Write-Host $_ }}
}} else {{
Write-Host ""Log file exists but appears to be empty or unreadable"" -ForegroundColor Yellow
Write-Host ""Attempting to read raw bytes..."" -ForegroundColor Gray
try {{
$rawBytes = [IO.File]::ReadAllBytes($logPath)
Write-Host ""Log file contains $($rawBytes.Length) bytes""
if ($rawBytes.Length -gt 0) {{
$text = [System.Text.Encoding]::UTF8.GetString($rawBytes)
Write-Host ""Log content (first 500 chars):"" -ForegroundColor Cyan
Write-Host $text.Substring(0, [Math]::Min(500, $text.Length))
}}
}} catch {{
Write-Host ""Could not read log file bytes: $($_.Exception.Message)"" -ForegroundColor Red
}}
}}
}} catch {{
Write-Host ""Error reading log: $($_.Exception.Message)"" -ForegroundColor Red
Write-Host ""Stack trace: $($_.ScriptStackTrace)"" -ForegroundColor DarkRed
}}
}} else {{
Write-Host ""Log file does not exist yet at: $logPath"" -ForegroundColor Yellow
}}
";
try
{
var output = await _sshService.ExecuteRemoteScriptAsync(worker, script);
return output.Trim();
}
catch (Exception ex)
{
return $"Unable to gather diagnostics: {ex.Message}";
}
}
private class WorkerStatus
{
public string? Status { get; set; }
public int? WorkerPid { get; set; }
public string? ErrorMessage { get; set; }
} }
public async Task StopWorkerAsync(WorkerConfig worker, string workerType) public async Task StopWorkerAsync(WorkerConfig worker, string workerType)

View File

@@ -1,4 +1,4 @@
<Project Sdk="Avalonia.Desktop"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
@@ -13,13 +13,14 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="11.0.0" /> <PackageReference Include="Avalonia" Version="11.3.9" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.0" /> <PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.9" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.0" /> <PackageReference Include="Avalonia.Desktop" Version="11.3.9" />
<PackageReference Include="Avalonia.Theme.Fluent" Version="11.0.0" /> <PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.9" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.0" /> <PackageReference Include="Avalonia.ReactiveUI" Version="11.3.9" />
<PackageReference Include="System.Text.Json" Version="8.0.0" /> <PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.9" />
<PackageReference Include="MsBox.Avalonia" Version="2.0.6" /> <PackageReference Include="MessageBox.Avalonia" Version="3.2.0" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,61 @@
using UnifiedFarmLauncher.Models;
using UnifiedFarmLauncher.Services;
namespace UnifiedFarmLauncher.ViewModels
{
public class GlobalSettingsViewModel : ViewModelBase
{
private readonly ConfigService _configService;
private string _sheepItUsername = string.Empty;
private string _sheepItRenderKey = string.Empty;
private bool _isRenderKeyVisible = false;
private string _flamencoWorkerPath = string.Empty;
public GlobalSettingsViewModel(ConfigService configService)
{
_configService = configService;
LoadSettings();
}
public string SheepItUsername
{
get => _sheepItUsername;
set => SetAndRaise(ref _sheepItUsername, value);
}
public string SheepItRenderKey
{
get => _sheepItRenderKey;
set => SetAndRaise(ref _sheepItRenderKey, value);
}
public bool IsRenderKeyVisible
{
get => _isRenderKeyVisible;
set => SetAndRaise(ref _isRenderKeyVisible, value);
}
public string FlamencoWorkerPath
{
get => _flamencoWorkerPath;
set => SetAndRaise(ref _flamencoWorkerPath, value);
}
private void LoadSettings()
{
var config = _configService.Load();
SheepItUsername = config.GlobalSettings.SheepItUsername;
SheepItRenderKey = config.GlobalSettings.SheepItRenderKey;
FlamencoWorkerPath = config.GlobalSettings.FlamencoWorkerPath;
}
public void Save()
{
var config = _configService.Load();
config.GlobalSettings.SheepItUsername = SheepItUsername;
config.GlobalSettings.SheepItRenderKey = SheepItRenderKey;
config.GlobalSettings.FlamencoWorkerPath = FlamencoWorkerPath;
_configService.Save(config);
}
}
}

View File

@@ -12,6 +12,7 @@ namespace UnifiedFarmLauncher.ViewModels
private WorkerConfig? _selectedWorker; private WorkerConfig? _selectedWorker;
private string _statusText = "Ready"; private string _statusText = "Ready";
private string _selectedWorkerType = "All"; private string _selectedWorkerType = "All";
private string _operationMode = "sheepit"; // "sheepit" or "flamenco"
public MainWindowViewModel() public MainWindowViewModel()
{ {
@@ -52,8 +53,27 @@ namespace UnifiedFarmLauncher.ViewModels
} }
} }
public string OperationMode
{
get => _operationMode;
set
{
if (SetAndRaise(ref _operationMode, value))
{
// Notify that dependent properties also changed
OnPropertyChanged(nameof(OperationModeDisplayName));
OnPropertyChanged(nameof(OperationModeIcon));
}
}
}
public string OperationModeDisplayName => OperationMode == "sheepit" ? "SheepIt" : "Flamenco";
public string OperationModeIcon => OperationMode == "sheepit" ? "🐑" : "🔥";
public void LoadWorkers() public void LoadWorkers()
{ {
_configService.Reload();
var config = _configService.Load(); var config = _configService.Load();
Workers.Clear(); Workers.Clear();

View File

@@ -16,9 +16,6 @@ namespace UnifiedFarmLauncher.ViewModels
private int _sshPort = 22; private int _sshPort = 22;
private string _sshArgs = string.Empty; private string _sshArgs = string.Empty;
private string _sheepItGpu = "OPTIX_0"; private string _sheepItGpu = "OPTIX_0";
private string _sheepItUsername = string.Empty;
private string _sheepItRenderKey = string.Empty;
private string _flamencoWorkerPath = string.Empty;
private bool _hasSheepIt; private bool _hasSheepIt;
private bool _hasFlamenco; private bool _hasFlamenco;
@@ -93,24 +90,6 @@ namespace UnifiedFarmLauncher.ViewModels
set => SetAndRaise(ref _sheepItGpu, value); set => SetAndRaise(ref _sheepItGpu, value);
} }
public string SheepItUsername
{
get => _sheepItUsername;
set => SetAndRaise(ref _sheepItUsername, value);
}
public string SheepItRenderKey
{
get => _sheepItRenderKey;
set => SetAndRaise(ref _sheepItRenderKey, value);
}
public string FlamencoWorkerPath
{
get => _flamencoWorkerPath;
set => SetAndRaise(ref _flamencoWorkerPath, value);
}
public ObservableCollection<string> NetworkDrives { get; } public ObservableCollection<string> NetworkDrives { get; }
public ObservableCollection<string> NetworkPaths { get; } public ObservableCollection<string> NetworkPaths { get; }
@@ -126,15 +105,14 @@ namespace UnifiedFarmLauncher.ViewModels
if (worker.WorkerTypes.SheepIt != null) if (worker.WorkerTypes.SheepIt != null)
{ {
HasSheepIt = true; HasSheepIt = true;
SheepItGpu = worker.WorkerTypes.SheepIt.Gpu; SheepItGpu = string.IsNullOrEmpty(worker.WorkerTypes.SheepIt.Gpu)
SheepItUsername = worker.WorkerTypes.SheepIt.Username; ? "OPTIX_0"
SheepItRenderKey = worker.WorkerTypes.SheepIt.RenderKey; : worker.WorkerTypes.SheepIt.Gpu;
} }
if (worker.WorkerTypes.Flamenco != null) if (worker.WorkerTypes.Flamenco != null)
{ {
HasFlamenco = true; HasFlamenco = true;
FlamencoWorkerPath = worker.WorkerTypes.Flamenco.WorkerPath;
NetworkDrives.Clear(); NetworkDrives.Clear();
foreach (var drive in worker.WorkerTypes.Flamenco.NetworkDrives) foreach (var drive in worker.WorkerTypes.Flamenco.NetworkDrives)
{ {
@@ -168,9 +146,7 @@ namespace UnifiedFarmLauncher.ViewModels
{ {
worker.WorkerTypes.SheepIt = new SheepItConfig worker.WorkerTypes.SheepIt = new SheepItConfig
{ {
Gpu = SheepItGpu, Gpu = SheepItGpu
Username = SheepItUsername,
RenderKey = SheepItRenderKey
}; };
} }
@@ -178,7 +154,6 @@ namespace UnifiedFarmLauncher.ViewModels
{ {
worker.WorkerTypes.Flamenco = new FlamencoConfig worker.WorkerTypes.Flamenco = new FlamencoConfig
{ {
WorkerPath = FlamencoWorkerPath,
NetworkDrives = NetworkDrives.ToList(), NetworkDrives = NetworkDrives.ToList(),
NetworkPaths = NetworkPaths.ToList() NetworkPaths = NetworkPaths.ToList()
}; };

37
Views/ErrorDialog.axaml Normal file
View File

@@ -0,0 +1,37 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="UnifiedFarmLauncher.Views.ErrorDialog"
Title="Error"
Width="700"
Height="500"
MinWidth="500"
MinHeight="300"
WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,*,Auto" Margin="15">
<!-- Header with icon and title -->
<StackPanel Orientation="Horizontal" Grid.Row="0" Margin="0,0,0,15">
<TextBlock Text="❌" FontSize="24" VerticalAlignment="Center" Margin="0,0,10,0"/>
<TextBlock Text="Error" FontSize="18" FontWeight="Bold" VerticalAlignment="Center"/>
</StackPanel>
<!-- Scrollable error content -->
<ScrollViewer Grid.Row="1"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<Border>
<TextBlock x:Name="ErrorTextBlock"
TextWrapping="Wrap"
FontFamily="Consolas, Courier New, monospace"
FontSize="12"/>
</Border>
</ScrollViewer>
<!-- OK Button -->
<Button Grid.Row="2"
Content="OK"
HorizontalAlignment="Right"
Width="80"
Margin="0,15,0,0"
Click="OkButton_Click"/>
</Grid>
</Window>

View File

@@ -0,0 +1,23 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace UnifiedFarmLauncher.Views
{
public partial class ErrorDialog : Window
{
public ErrorDialog()
{
InitializeComponent();
}
public ErrorDialog(string errorMessage) : this()
{
this.FindControl<TextBlock>("ErrorTextBlock")!.Text = errorMessage;
}
private void OkButton_Click(object? sender, RoutedEventArgs e)
{
Close();
}
}
}

View File

@@ -0,0 +1,55 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:UnifiedFarmLauncher.ViewModels"
x:Class="UnifiedFarmLauncher.Views.GlobalSettingsWindow"
x:DataType="vm:GlobalSettingsViewModel"
Title="Global Settings"
Width="500" Height="350"
MinWidth="400" MinHeight="300">
<Grid RowDefinitions="Auto,*,Auto" Margin="10">
<!-- Settings Content -->
<TabControl Grid.Row="1" Margin="0,10">
<TabItem Header="SheepIt">
<StackPanel Margin="10" Spacing="10">
<TextBlock Text="Username:"/>
<TextBox Name="SheepItUsernameTextBox" Text="{Binding SheepItUsername}"/>
<TextBlock Text="Render Key:" Margin="0,10,0,0"/>
<Grid ColumnDefinitions="*,Auto">
<TextBox Name="SheepItRenderKeyTextBox"
Grid.Column="0"
Text="{Binding SheepItRenderKey}"
PasswordChar="*"
Margin="0,0,5,0"/>
<Button Name="ToggleRenderKeyButton"
Grid.Column="1"
Content="👁"
Width="40"
ToolTip.Tip="Show/Hide Render Key"/>
</Grid>
</StackPanel>
</TabItem>
<TabItem Header="Flamenco">
<StackPanel Margin="10" Spacing="10">
<TextBlock Text="Worker Path:"/>
<Grid ColumnDefinitions="*,Auto">
<TextBox Name="FlamencoWorkerPathTextBox"
Grid.Column="0"
Text="{Binding FlamencoWorkerPath}"
Margin="0,0,5,0"/>
<Button Name="BrowseFlamencoPathButton"
Grid.Column="1"
Content="Browse..."
Width="80"/>
</Grid>
</StackPanel>
</TabItem>
</TabControl>
<!-- Buttons -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Grid.Row="2" Spacing="10" Margin="0,10,0,0">
<Button Name="OkButton" Content="OK" Width="80" IsDefault="True"/>
<Button Name="CancelButton" Content="Cancel" Width="80" IsCancel="True"/>
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,96 @@
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using UnifiedFarmLauncher.Services;
using UnifiedFarmLauncher.ViewModels;
namespace UnifiedFarmLauncher.Views
{
public partial class GlobalSettingsWindow : Window
{
private readonly GlobalSettingsViewModel _viewModel;
private bool _result;
public GlobalSettingsWindow()
{
InitializeComponent();
var configService = new ConfigService();
_viewModel = new GlobalSettingsViewModel(configService);
DataContext = _viewModel;
SetupEventHandlers();
}
private void InitializeComponent()
{
Avalonia.Markup.Xaml.AvaloniaXamlLoader.Load(this);
}
private void SetupEventHandlers()
{
this.FindControl<Button>("OkButton")!.Click += OkButton_Click;
this.FindControl<Button>("CancelButton")!.Click += CancelButton_Click;
this.FindControl<Button>("ToggleRenderKeyButton")!.Click += ToggleRenderKeyButton_Click;
this.FindControl<Button>("BrowseFlamencoPathButton")!.Click += BrowseFlamencoPathButton_Click;
}
private void OkButton_Click(object? sender, RoutedEventArgs e)
{
try
{
_viewModel.Save();
_result = true;
Close();
}
catch (System.Exception ex)
{
// Could show error message here
System.Diagnostics.Debug.WriteLine($"Failed to save global settings: {ex.Message}");
}
}
private void CancelButton_Click(object? sender, RoutedEventArgs e)
{
_result = false;
Close();
}
private void ToggleRenderKeyButton_Click(object? sender, RoutedEventArgs e)
{
var textBox = this.FindControl<Avalonia.Controls.TextBox>("SheepItRenderKeyTextBox");
if (textBox != null)
{
_viewModel.IsRenderKeyVisible = !_viewModel.IsRenderKeyVisible;
textBox.PasswordChar = _viewModel.IsRenderKeyVisible ? '\0' : '*';
var button = sender as Button;
if (button != null)
{
button.Content = _viewModel.IsRenderKeyVisible ? "🙈" : "👁";
}
}
}
private async void BrowseFlamencoPathButton_Click(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel?.StorageProvider.CanPickFolder == true)
{
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Select Flamenco Worker Path"
});
if (folders.Count > 0 && folders[0].TryGetLocalPath() is { } localPath)
{
_viewModel.FlamencoWorkerPath = localPath;
}
}
}
public async Task<bool> ShowDialogAsync(Window parent)
{
await base.ShowDialog(parent);
return _result;
}
}
}

View File

@@ -1,12 +1,20 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:UnifiedFarmLauncher.ViewModels"
xmlns:models="using:UnifiedFarmLauncher.Models"
x:Class="UnifiedFarmLauncher.Views.MainWindow" x:Class="UnifiedFarmLauncher.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="Unified Farm Launcher" Title="Unified Farm Launcher"
Width="1000" Height="700" Width="1000" Height="700"
MinWidth="800" MinHeight="600"> MinWidth="800" MinHeight="600">
<Grid RowDefinitions="Auto,Auto,*,Auto"> <Grid RowDefinitions="Auto,Auto,*,Auto">
<!-- Toolbar --> <!-- Toolbar -->
<StackPanel Orientation="Horizontal" Margin="5" Grid.Row="0"> <Grid Grid.Row="0" Margin="5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" Grid.Column="0">
<Button Name="AddWorkerButton" Content="Add Worker" Margin="5" Width="120"/> <Button Name="AddWorkerButton" Content="Add Worker" Margin="5" Width="120"/>
<Button Name="EditWorkerButton" Content="Edit Worker" Margin="5" Width="120"/> <Button Name="EditWorkerButton" Content="Edit Worker" Margin="5" Width="120"/>
<Button Name="DeleteWorkerButton" Content="Delete Worker" Margin="5" Width="120"/> <Button Name="DeleteWorkerButton" Content="Delete Worker" Margin="5" Width="120"/>
@@ -14,7 +22,23 @@
<Button Name="StartWorkerButton" Content="Start" Margin="5" Width="80"/> <Button Name="StartWorkerButton" Content="Start" Margin="5" Width="80"/>
<Button Name="StopWorkerButton" Content="Stop" Margin="5" Width="80"/> <Button Name="StopWorkerButton" Content="Stop" Margin="5" Width="80"/>
<Button Name="AttachWorkerButton" Content="Attach" Margin="5" Width="80"/> <Button Name="AttachWorkerButton" Content="Attach" Margin="5" Width="80"/>
<Separator Margin="10,0"/>
<Button Name="SettingsButton" Content="Settings" Margin="5" Width="80"/>
</StackPanel> </StackPanel>
<!-- Operation Mode Toggle Button -->
<Button Name="OperationModeToggleButton" Grid.Column="1"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Margin="5"
Padding="10,5"
MinWidth="120">
<StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock Text="{Binding OperationModeIcon}" FontSize="16" VerticalAlignment="Center"/>
<TextBlock Text="{Binding OperationModeDisplayName}"
VerticalAlignment="Center" FontWeight="Bold"/>
</StackPanel>
</Button>
</Grid>
<!-- Worker Type Filter --> <!-- Worker Type Filter -->
<TabControl Name="WorkerTypeTabs" Grid.Row="1" Margin="5,0"> <TabControl Name="WorkerTypeTabs" Grid.Row="1" Margin="5,0">
@@ -34,7 +58,8 @@
AutoGenerateColumns="False" AutoGenerateColumns="False"
IsReadOnly="True" IsReadOnly="True"
SelectionMode="Single" SelectionMode="Single"
GridLinesVisibility="All"> GridLinesVisibility="All"
ItemsSource="{Binding Workers}">
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="150"/> <DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="150"/>
<DataGridCheckBoxColumn Header="Enabled" Binding="{Binding Enabled}" Width="80"/> <DataGridCheckBoxColumn Header="Enabled" Binding="{Binding Enabled}" Width="80"/>
@@ -45,9 +70,9 @@
</DataGrid> </DataGrid>
<!-- Status Bar --> <!-- Status Bar -->
<StatusBar Grid.Row="3"> <Border Grid.Row="3" Background="{DynamicResource ThemeControlLowBackgroundBrush}" BorderBrush="{DynamicResource ThemeBorderLowBrush}" BorderThickness="0,1,0,0">
<TextBlock Name="StatusText" Text="{Binding StatusText}" Margin="5"/> <TextBlock Name="StatusText" Text="{Binding StatusText}" Margin="5" VerticalAlignment="Center"/>
</StatusBar> </Border>
</Grid> </Grid>
</Window> </Window>

View File

@@ -33,14 +33,25 @@ namespace UnifiedFarmLauncher.Views
private void SetupEventHandlers() private void SetupEventHandlers()
{ {
AddWorkerButton.Click += AddWorkerButton_Click; this.FindControl<Button>("AddWorkerButton")!.Click += AddWorkerButton_Click;
EditWorkerButton.Click += EditWorkerButton_Click; this.FindControl<Button>("EditWorkerButton")!.Click += EditWorkerButton_Click;
DeleteWorkerButton.Click += DeleteWorkerButton_Click; this.FindControl<Button>("DeleteWorkerButton")!.Click += DeleteWorkerButton_Click;
StartWorkerButton.Click += StartWorkerButton_Click; this.FindControl<Button>("StartWorkerButton")!.Click += StartWorkerButton_Click;
StopWorkerButton.Click += StopWorkerButton_Click; this.FindControl<Button>("StopWorkerButton")!.Click += StopWorkerButton_Click;
AttachWorkerButton.Click += AttachWorkerButton_Click; this.FindControl<Button>("AttachWorkerButton")!.Click += AttachWorkerButton_Click;
WorkerTypeTabs.SelectionChanged += WorkerTypeTabs_SelectionChanged; this.FindControl<Button>("SettingsButton")!.Click += SettingsButton_Click;
WorkersGrid.SelectionChanged += WorkersGrid_SelectionChanged; this.FindControl<Button>("OperationModeToggleButton")!.Click += OperationModeToggleButton_Click;
this.FindControl<TabControl>("WorkerTypeTabs")!.SelectionChanged += WorkerTypeTabs_SelectionChanged;
this.FindControl<DataGrid>("WorkersGrid")!.SelectionChanged += WorkersGrid_SelectionChanged;
}
private void OperationModeToggleButton_Click(object? sender, RoutedEventArgs e)
{
if (DataContext is MainWindowViewModel vm)
{
vm.OperationMode = vm.OperationMode == "sheepit" ? "flamenco" : "sheepit";
}
} }
private async void AddWorkerButton_Click(object? sender, RoutedEventArgs e) private async void AddWorkerButton_Click(object? sender, RoutedEventArgs e)
@@ -54,7 +65,7 @@ namespace UnifiedFarmLauncher.Views
private async void EditWorkerButton_Click(object? sender, RoutedEventArgs e) private async void EditWorkerButton_Click(object? sender, RoutedEventArgs e)
{ {
if (WorkersGrid.SelectedItem is WorkerConfig worker) if (this.FindControl<DataGrid>("WorkersGrid")?.SelectedItem is WorkerConfig worker)
{ {
var dialog = new WorkerEditWindow(worker); var dialog = new WorkerEditWindow(worker);
if (await dialog.ShowDialogAsync(this)) if (await dialog.ShowDialogAsync(this))
@@ -66,11 +77,11 @@ namespace UnifiedFarmLauncher.Views
private async void DeleteWorkerButton_Click(object? sender, RoutedEventArgs e) private async void DeleteWorkerButton_Click(object? sender, RoutedEventArgs e)
{ {
if (WorkersGrid.SelectedItem is WorkerConfig worker) if (this.FindControl<DataGrid>("WorkersGrid")?.SelectedItem is WorkerConfig worker)
{ {
var box = MessageBoxManager.GetMessageBoxStandard("Delete Worker", var box = MessageBoxManager.GetMessageBoxStandard("Delete Worker",
$"Are you sure you want to delete worker '{worker.Name}'?", $"Are you sure you want to delete worker '{worker.Name}'?",
ButtonEnum.YesNo, Icon.Warning); ButtonEnum.YesNo, MsBox.Avalonia.Enums.Icon.Warning);
var result = await box.ShowAsync(); var result = await box.ShowAsync();
if (result == ButtonResult.Yes) if (result == ButtonResult.Yes)
@@ -83,96 +94,110 @@ namespace UnifiedFarmLauncher.Views
private async void StartWorkerButton_Click(object? sender, RoutedEventArgs e) private async void StartWorkerButton_Click(object? sender, RoutedEventArgs e)
{ {
if (WorkersGrid.SelectedItem is WorkerConfig worker) if (this.FindControl<DataGrid>("WorkersGrid")?.SelectedItem is WorkerConfig worker)
{ {
try try
{ {
string? workerType = null; var vm = (MainWindowViewModel)DataContext!;
if (worker.WorkerTypes.SheepIt != null) string? workerType = vm.OperationMode;
workerType = "sheepit";
else if (worker.WorkerTypes.Flamenco != null)
workerType = "flamenco";
if (workerType == null) // Verify the worker supports the selected operation mode
if (workerType == "sheepit" && worker.WorkerTypes.SheepIt == null)
{ {
var box = MessageBoxManager.GetMessageBoxStandard("Error", var box = MessageBoxManager.GetMessageBoxStandard("Error",
"Worker has no configured worker type.", $"Worker '{worker.Name}' does not have SheepIt configured.",
ButtonEnum.Ok, Icon.Error); ButtonEnum.Ok, MsBox.Avalonia.Enums.Icon.Error);
await box.ShowAsync();
return;
}
if (workerType == "flamenco" && worker.WorkerTypes.Flamenco == null)
{
var box = MessageBoxManager.GetMessageBoxStandard("Error",
$"Worker '{worker.Name}' does not have Flamenco configured.",
ButtonEnum.Ok, MsBox.Avalonia.Enums.Icon.Error);
await box.ShowAsync(); await box.ShowAsync();
return; return;
} }
await _controllerService.StartWorkerAsync(worker, workerType); await _controllerService.StartWorkerAsync(worker, workerType);
var successBox = MessageBoxManager.GetMessageBoxStandard("Start Worker", var successBox = MessageBoxManager.GetMessageBoxStandard("Start Worker",
$"Worker '{worker.Name}' started successfully.", $"Worker '{worker.Name}' ({vm.OperationModeDisplayName}) started successfully.",
ButtonEnum.Ok, Icon.Success); ButtonEnum.Ok, MsBox.Avalonia.Enums.Icon.Success);
await successBox.ShowAsync(); await successBox.ShowAsync();
((MainWindowViewModel)DataContext!).RefreshWorkers(); vm.RefreshWorkers();
} }
catch (System.Exception ex) catch (System.Exception ex)
{ {
var errorBox = MessageBoxManager.GetMessageBoxStandard("Error", var errorDialog = new ErrorDialog($"Failed to start worker: {ex.Message}");
$"Failed to start worker: {ex.Message}", await errorDialog.ShowDialog(this);
ButtonEnum.Ok, Icon.Error);
await errorBox.ShowAsync();
} }
} }
} }
private async void StopWorkerButton_Click(object? sender, RoutedEventArgs e) private async void StopWorkerButton_Click(object? sender, RoutedEventArgs e)
{ {
if (WorkersGrid.SelectedItem is WorkerConfig worker) if (this.FindControl<DataGrid>("WorkersGrid")?.SelectedItem is WorkerConfig worker)
{ {
try try
{ {
string? workerType = null; var vm = (MainWindowViewModel)DataContext!;
if (worker.WorkerTypes.SheepIt != null) string workerType = vm.OperationMode;
workerType = "sheepit";
else if (worker.WorkerTypes.Flamenco != null)
workerType = "flamenco";
if (workerType == null) // Verify the worker supports the selected operation mode
if (workerType == "sheepit" && worker.WorkerTypes.SheepIt == null)
{ {
var box = MessageBoxManager.GetMessageBoxStandard("Error", var box = MessageBoxManager.GetMessageBoxStandard("Error",
"Worker has no configured worker type.", $"Worker '{worker.Name}' does not have SheepIt configured.",
ButtonEnum.Ok, Icon.Error); ButtonEnum.Ok, MsBox.Avalonia.Enums.Icon.Error);
await box.ShowAsync();
return;
}
if (workerType == "flamenco" && worker.WorkerTypes.Flamenco == null)
{
var box = MessageBoxManager.GetMessageBoxStandard("Error",
$"Worker '{worker.Name}' does not have Flamenco configured.",
ButtonEnum.Ok, MsBox.Avalonia.Enums.Icon.Error);
await box.ShowAsync(); await box.ShowAsync();
return; return;
} }
await _controllerService.StopWorkerAsync(worker, workerType); await _controllerService.StopWorkerAsync(worker, workerType);
var successBox = MessageBoxManager.GetMessageBoxStandard("Stop Worker", var successBox = MessageBoxManager.GetMessageBoxStandard("Stop Worker",
$"Stop command sent to worker '{worker.Name}'.", $"Stop command sent to worker '{worker.Name}' ({vm.OperationModeDisplayName}).",
ButtonEnum.Ok, Icon.Info); ButtonEnum.Ok, MsBox.Avalonia.Enums.Icon.Info);
await successBox.ShowAsync(); await successBox.ShowAsync();
} }
catch (System.Exception ex) catch (System.Exception ex)
{ {
var errorBox = MessageBoxManager.GetMessageBoxStandard("Error", var errorDialog = new ErrorDialog($"Failed to stop worker: {ex.Message}");
$"Failed to stop worker: {ex.Message}", await errorDialog.ShowDialog(this);
ButtonEnum.Ok, Icon.Error);
await errorBox.ShowAsync();
} }
} }
} }
private async void AttachWorkerButton_Click(object? sender, RoutedEventArgs e) private async void AttachWorkerButton_Click(object? sender, RoutedEventArgs e)
{ {
if (WorkersGrid.SelectedItem is WorkerConfig worker) if (this.FindControl<DataGrid>("WorkersGrid")?.SelectedItem is WorkerConfig worker)
{ {
try try
{ {
string? workerType = null; var vm = (MainWindowViewModel)DataContext!;
if (worker.WorkerTypes.SheepIt != null) string workerType = vm.OperationMode;
workerType = "sheepit";
else if (worker.WorkerTypes.Flamenco != null)
workerType = "flamenco";
if (workerType == null) // Verify the worker supports the selected operation mode
if (workerType == "sheepit" && worker.WorkerTypes.SheepIt == null)
{ {
var box = MessageBoxManager.GetMessageBoxStandard("Error", var box = MessageBoxManager.GetMessageBoxStandard("Error",
"Worker has no configured worker type.", $"Worker '{worker.Name}' does not have SheepIt configured.",
ButtonEnum.Ok, Icon.Error); ButtonEnum.Ok, MsBox.Avalonia.Enums.Icon.Error);
await box.ShowAsync();
return;
}
if (workerType == "flamenco" && worker.WorkerTypes.Flamenco == null)
{
var box = MessageBoxManager.GetMessageBoxStandard("Error",
$"Worker '{worker.Name}' does not have Flamenco configured.",
ButtonEnum.Ok, MsBox.Avalonia.Enums.Icon.Error);
await box.ShowAsync(); await box.ShowAsync();
return; return;
} }
@@ -181,17 +206,15 @@ namespace UnifiedFarmLauncher.Views
} }
catch (System.Exception ex) catch (System.Exception ex)
{ {
var errorBox = MessageBoxManager.GetMessageBoxStandard("Error", var errorDialog = new ErrorDialog($"Failed to attach to worker: {ex.Message}");
$"Failed to attach to worker: {ex.Message}", await errorDialog.ShowDialog(this);
ButtonEnum.Ok, Icon.Error);
await errorBox.ShowAsync();
} }
} }
} }
private void WorkerTypeTabs_SelectionChanged(object? sender, SelectionChangedEventArgs e) private void WorkerTypeTabs_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{ {
if (WorkerTypeTabs.SelectedItem is TabItem tab) if (this.FindControl<TabControl>("WorkerTypeTabs")?.SelectedItem is TabItem tab)
{ {
var type = tab.Header?.ToString() ?? "All"; var type = tab.Header?.ToString() ?? "All";
if (type == "All Workers") type = "All"; if (type == "All Workers") type = "All";
@@ -199,11 +222,17 @@ namespace UnifiedFarmLauncher.Views
} }
} }
private async void SettingsButton_Click(object? sender, RoutedEventArgs e)
{
var dialog = new GlobalSettingsWindow();
await dialog.ShowDialogAsync(this);
}
private void WorkersGrid_SelectionChanged(object? sender, SelectionChangedEventArgs e) private void WorkersGrid_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{ {
if (DataContext is MainWindowViewModel vm) if (DataContext is MainWindowViewModel vm)
{ {
vm.SelectedWorker = WorkersGrid.SelectedItem as WorkerConfig; vm.SelectedWorker = this.FindControl<DataGrid>("WorkersGrid")?.SelectedItem as WorkerConfig;
} }
} }
} }

View File

@@ -1,6 +1,8 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:UnifiedFarmLauncher.ViewModels"
x:Class="UnifiedFarmLauncher.Views.WorkerEditWindow" x:Class="UnifiedFarmLauncher.Views.WorkerEditWindow"
x:DataType="vm:WorkerEditViewModel"
Title="Edit Worker" Title="Edit Worker"
Width="600" Height="700" Width="600" Height="700"
MinWidth="500" MinHeight="600"> MinWidth="500" MinHeight="600">
@@ -34,17 +36,17 @@
<TextBlock Text="GPU:" IsVisible="{Binding HasSheepIt}"/> <TextBlock Text="GPU:" IsVisible="{Binding HasSheepIt}"/>
<ComboBox Name="GpuComboBox" IsVisible="{Binding HasSheepIt}" SelectedItem="{Binding SheepItGpu}"> <ComboBox Name="GpuComboBox" IsVisible="{Binding HasSheepIt}" SelectedItem="{Binding SheepItGpu}">
<ComboBox.Items> <ComboBox.Items>
<ComboBoxItem Content="OPTIX_0"/> <x:String>OPTIX_0</x:String>
<ComboBoxItem Content="CUDA_0"/> <x:String>CUDA_0</x:String>
<ComboBoxItem Content="OPENCL_0"/> <x:String>OPENCL_0</x:String>
</ComboBox.Items> </ComboBox.Items>
</ComboBox> </ComboBox>
<TextBlock Text="Username:" IsVisible="{Binding HasSheepIt}" Margin="0,10,0,0"/> <TextBlock Text="Note: Username and Render Key are configured in Global Settings."
<TextBox Name="SheepItUsernameTextBox" Text="{Binding SheepItUsername}" IsVisible="{Binding HasSheepIt}"/> IsVisible="{Binding HasSheepIt}"
Margin="0,10,0,0"
<TextBlock Text="Render Key:" IsVisible="{Binding HasSheepIt}" Margin="0,10,0,0"/> Foreground="Gray"
<TextBox Name="SheepItRenderKeyTextBox" Text="{Binding SheepItRenderKey}" IsVisible="{Binding HasSheepIt}" PasswordChar="*"/> TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
</TabItem> </TabItem>
@@ -53,13 +55,13 @@
<StackPanel Margin="10" Spacing="10"> <StackPanel Margin="10" Spacing="10">
<CheckBox Name="HasFlamencoCheckBox" Content="Enable Flamenco Worker" IsChecked="{Binding HasFlamenco}" Margin="0,0,0,10"/> <CheckBox Name="HasFlamencoCheckBox" Content="Enable Flamenco Worker" IsChecked="{Binding HasFlamenco}" Margin="0,0,0,10"/>
<TextBlock Text="Worker Path:" IsVisible="{Binding HasFlamenco}"/> <TextBlock Text="Note: Worker Path is configured in Global Settings."
<Grid IsVisible="{Binding HasFlamenco}" ColumnDefinitions="*,Auto"> IsVisible="{Binding HasFlamenco}"
<TextBox Name="FlamencoPathTextBox" Grid.Column="0" Text="{Binding FlamencoWorkerPath}" Margin="0,0,5,0"/> Margin="0,0,0,10"
<Button Name="BrowseFlamencoPathButton" Grid.Column="1" Content="Browse..." Width="80"/> Foreground="Gray"
</Grid> TextWrapping="Wrap"/>
<TextBlock Text="Network Drives:" IsVisible="{Binding HasFlamenco}" Margin="0,10,0,0"/> <TextBlock Text="Network Drives:" IsVisible="{Binding HasFlamenco}"/>
<Grid IsVisible="{Binding HasFlamenco}" RowDefinitions="*,Auto" Margin="0,5,0,0"> <Grid IsVisible="{Binding HasFlamenco}" RowDefinitions="*,Auto" Margin="0,5,0,0">
<ListBox Name="NetworkDrivesListBox" Grid.Row="0" ItemsSource="{Binding NetworkDrives}" MaxHeight="100"/> <ListBox Name="NetworkDrivesListBox" Grid.Row="0" ItemsSource="{Binding NetworkDrives}" MaxHeight="100"/>
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,5,0,0"> <StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,5,0,0">

View File

@@ -17,7 +17,11 @@ namespace UnifiedFarmLauncher.Views
private readonly WorkerEditViewModel _viewModel; private readonly WorkerEditViewModel _viewModel;
private bool _result; private bool _result;
public WorkerEditWindow(WorkerConfig? worker = null) public WorkerEditWindow() : this(null)
{
}
public WorkerEditWindow(WorkerConfig? worker)
{ {
InitializeComponent(); InitializeComponent();
var configService = new ConfigService(); var configService = new ConfigService();
@@ -33,13 +37,12 @@ namespace UnifiedFarmLauncher.Views
private void SetupEventHandlers() private void SetupEventHandlers()
{ {
OkButton.Click += OkButton_Click; this.FindControl<Button>("OkButton")!.Click += OkButton_Click;
CancelButton.Click += CancelButton_Click; this.FindControl<Button>("CancelButton")!.Click += CancelButton_Click;
BrowseFlamencoPathButton.Click += BrowseFlamencoPathButton_Click; this.FindControl<Button>("AddDriveButton")!.Click += AddDriveButton_Click;
AddDriveButton.Click += AddDriveButton_Click; this.FindControl<Button>("RemoveDriveButton")!.Click += RemoveDriveButton_Click;
RemoveDriveButton.Click += RemoveDriveButton_Click; this.FindControl<Button>("AddPathButton")!.Click += AddPathButton_Click;
AddPathButton.Click += AddPathButton_Click; this.FindControl<Button>("RemovePathButton")!.Click += RemovePathButton_Click;
RemovePathButton.Click += RemovePathButton_Click;
} }
private async void OkButton_Click(object? sender, RoutedEventArgs e) private async void OkButton_Click(object? sender, RoutedEventArgs e)
@@ -48,7 +51,7 @@ namespace UnifiedFarmLauncher.Views
{ {
var box = MessageBoxManager.GetMessageBoxStandard("Error", var box = MessageBoxManager.GetMessageBoxStandard("Error",
"Worker name is required.", "Worker name is required.",
ButtonEnum.Ok, Icon.Error); ButtonEnum.Ok, MsBox.Avalonia.Enums.Icon.Error);
await box.ShowAsync(); await box.ShowAsync();
return; return;
} }
@@ -63,7 +66,7 @@ namespace UnifiedFarmLauncher.Views
{ {
var errorBox = MessageBoxManager.GetMessageBoxStandard("Error", var errorBox = MessageBoxManager.GetMessageBoxStandard("Error",
$"Failed to save worker: {ex.Message}", $"Failed to save worker: {ex.Message}",
ButtonEnum.Ok, Icon.Error); ButtonEnum.Ok, MsBox.Avalonia.Enums.Icon.Error);
await errorBox.ShowAsync(); await errorBox.ShowAsync();
} }
} }
@@ -74,24 +77,7 @@ namespace UnifiedFarmLauncher.Views
Close(); Close();
} }
private async void BrowseFlamencoPathButton_Click(object? sender, RoutedEventArgs e) private void AddDriveButton_Click(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel?.StorageProvider.CanPickFolder == true)
{
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Select Flamenco Worker Path"
});
if (folders.Count > 0 && folders[0].TryGetLocalPath() is { } localPath)
{
_viewModel.FlamencoWorkerPath = localPath;
}
}
}
private async void AddDriveButton_Click(object? sender, RoutedEventArgs e)
{ {
// Simplified: use a simple input box // Simplified: use a simple input box
// In a full implementation, you'd use a proper input dialog // In a full implementation, you'd use a proper input dialog
@@ -100,13 +86,13 @@ namespace UnifiedFarmLauncher.Views
private void RemoveDriveButton_Click(object? sender, RoutedEventArgs e) private void RemoveDriveButton_Click(object? sender, RoutedEventArgs e)
{ {
if (NetworkDrivesListBox.SelectedItem is string drive) if (this.FindControl<ListBox>("NetworkDrivesListBox")?.SelectedItem is string drive)
{ {
_viewModel.NetworkDrives.Remove(drive); _viewModel.NetworkDrives.Remove(drive);
} }
} }
private async void AddPathButton_Click(object? sender, RoutedEventArgs e) private void AddPathButton_Click(object? sender, RoutedEventArgs e)
{ {
// Simplified: use a simple input box // Simplified: use a simple input box
// In a full implementation, you'd use a proper input dialog // In a full implementation, you'd use a proper input dialog
@@ -115,7 +101,7 @@ namespace UnifiedFarmLauncher.Views
private void RemovePathButton_Click(object? sender, RoutedEventArgs e) private void RemovePathButton_Click(object? sender, RoutedEventArgs e)
{ {
if (NetworkPathsListBox.SelectedItem is string path) if (this.FindControl<ListBox>("NetworkPathsListBox")?.SelectedItem is string path)
{ {
_viewModel.NetworkPaths.Remove(path); _viewModel.NetworkPaths.Remove(path);
} }