feat: disk space watcher + diagnostic bundle export
This commit is contained in:
parent
179a44adf5
commit
670813f18e
2 changed files with 234 additions and 0 deletions
134
src/TeamsISO.App/Services/DiagnosticsBundle.cs
Normal file
134
src/TeamsISO.App/Services/DiagnosticsBundle.cs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Gathers logs + config + presets + version metadata into a single .zip the
|
||||
/// operator can attach to a bug report. Surfaced via the "Export diagnostics"
|
||||
/// button in About.
|
||||
///
|
||||
/// We deliberately do NOT include screenshots or any process/memory dumps —
|
||||
/// that's outside the scope of a v1 support bundle and would raise privacy
|
||||
/// flags. The bundle has only files the user already wrote with their TeamsISO
|
||||
/// usage; nothing here is hidden state.
|
||||
/// </summary>
|
||||
public static class DiagnosticsBundle
|
||||
{
|
||||
/// <summary>
|
||||
/// Build the bundle and return the path it was written to.
|
||||
/// Throws on disk failure — the caller toasts/dialogs.
|
||||
/// </summary>
|
||||
public static string Export()
|
||||
{
|
||||
var ts = DateTimeOffset.Now.ToString("yyyyMMdd-HHmmss");
|
||||
var fileName = $"teamsiso-diagnostics-{ts}.zip";
|
||||
var outDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
var downloads = Path.Combine(outDir, "Downloads");
|
||||
if (!Directory.Exists(downloads)) downloads = outDir; // fall back to ~/
|
||||
var outPath = Path.Combine(downloads, fileName);
|
||||
|
||||
using var fs = new FileStream(outPath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
using var zip = new ZipArchive(fs, ZipArchiveMode.Create, leaveOpen: false);
|
||||
|
||||
WriteEnvironmentTxt(zip);
|
||||
TryCopyDirectory(zip, "logs", LogsDirectory);
|
||||
TryCopyFile(zip, "config.json", AppDataPath("config.json"));
|
||||
TryCopyFile(zip, "presets.json", LocalAppDataPath("presets.json"));
|
||||
TryCopyFile(zip, "window.json", LocalAppDataPath("window.json"));
|
||||
TryCopyFile(zip, "ndi-config.v1.json", NdiConfigPath());
|
||||
TryCopyFile(zip, "output-name-template.txt", LocalAppDataPath("output-name-template.txt"));
|
||||
|
||||
return outPath;
|
||||
}
|
||||
|
||||
private static void WriteEnvironmentTxt(ZipArchive zip)
|
||||
{
|
||||
var asm = typeof(DiagnosticsBundle).Assembly;
|
||||
var version = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||
?? asm.GetName().Version?.ToString()
|
||||
?? "unknown";
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("TeamsISO diagnostic bundle");
|
||||
sb.AppendLine($"Generated: {DateTimeOffset.Now:o}");
|
||||
sb.AppendLine($"TeamsISO version: {version}");
|
||||
sb.AppendLine($".NET runtime: {Environment.Version}");
|
||||
sb.AppendLine($"OS: {Environment.OSVersion}");
|
||||
sb.AppendLine($"Machine: {Environment.MachineName}");
|
||||
sb.AppendLine($"User: {Environment.UserName}");
|
||||
sb.AppendLine($"Process bits: {(Environment.Is64BitProcess ? "64" : "32")}");
|
||||
sb.AppendLine($"OS bits: {(Environment.Is64BitOperatingSystem ? "64" : "32")}");
|
||||
sb.AppendLine($"Working set: {Environment.WorkingSet / (1024 * 1024)} MB");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Files included (when present):");
|
||||
sb.AppendLine(" logs/ Serilog rolling daily logs");
|
||||
sb.AppendLine(" config.json Engine settings (framerate, NDI groups, etc.)");
|
||||
sb.AppendLine(" presets.json Saved operator presets");
|
||||
sb.AppendLine(" window.json Last main-window placement");
|
||||
sb.AppendLine(" ndi-config.v1.json NDI Access Manager config (group routing)");
|
||||
sb.AppendLine(" output-name-template.txt NDI source name template override");
|
||||
|
||||
var entry = zip.CreateEntry("environment.txt");
|
||||
using var w = new StreamWriter(entry.Open(), Encoding.UTF8);
|
||||
w.Write(sb.ToString());
|
||||
}
|
||||
|
||||
private static void TryCopyFile(ZipArchive zip, string entryName, string sourcePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(sourcePath)) return;
|
||||
zip.CreateEntryFromFile(sourcePath, entryName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// One missing or locked file shouldn't kill the rest of the bundle.
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryCopyDirectory(ZipArchive zip, string prefix, string sourceDir)
|
||||
{
|
||||
if (!Directory.Exists(sourceDir)) return;
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
try
|
||||
{
|
||||
var rel = Path.GetRelativePath(sourceDir, file).Replace('\\', '/');
|
||||
zip.CreateEntryFromFile(file, $"{prefix}/{rel}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip locked files (e.g., today's actively-written log).
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Permission denied on the dir as a whole; nothing more to do.
|
||||
}
|
||||
}
|
||||
|
||||
private static string LogsDirectory =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"TeamsISO", "Logs");
|
||||
|
||||
private static string LocalAppDataPath(string fileName) =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"TeamsISO", fileName);
|
||||
|
||||
private static string AppDataPath(string fileName) =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"TeamsISO", fileName);
|
||||
|
||||
private static string NdiConfigPath() =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"NDI", "ndi-config.v1.json");
|
||||
}
|
||||
100
src/TeamsISO.App/Services/DiskSpaceWatcher.cs
Normal file
100
src/TeamsISO.App/Services/DiskSpaceWatcher.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
using System.IO;
|
||||
using System.Windows.Threading;
|
||||
using TeamsISO.App.ViewModels;
|
||||
using TeamsISO.Engine.Controller;
|
||||
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Polls the recording drive's free space every few seconds while recording is
|
||||
/// on. Surfaces a coral warning toast at the soft threshold, and at the hard
|
||||
/// threshold auto-disables recording so a long unattended show doesn't fill
|
||||
/// the disk and crash the host's logger and write paths.
|
||||
///
|
||||
/// Lifecycle is tied to the MainViewModel — instantiate after the VM is wired,
|
||||
/// dispose with the host.
|
||||
/// </summary>
|
||||
public sealed class DiskSpaceWatcher : IDisposable
|
||||
{
|
||||
/// <summary>Below this, toast a warning each tick.</summary>
|
||||
public static readonly long SoftWarnBytes = 10L * 1024 * 1024 * 1024; // 10 GB
|
||||
|
||||
/// <summary>Below this, auto-disable recording to save the show.</summary>
|
||||
public static readonly long HardStopBytes = 1L * 1024 * 1024 * 1024; // 1 GB
|
||||
|
||||
private readonly IIsoController _controller;
|
||||
private readonly ToastViewModel _toast;
|
||||
private readonly DispatcherTimer _timer;
|
||||
private DateTimeOffset _lastWarnAt = DateTimeOffset.MinValue;
|
||||
|
||||
public DiskSpaceWatcher(IIsoController controller, ToastViewModel toast, Dispatcher dispatcher)
|
||||
{
|
||||
_controller = controller;
|
||||
_toast = toast;
|
||||
_timer = new DispatcherTimer(DispatcherPriority.Background, dispatcher)
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(5),
|
||||
};
|
||||
_timer.Tick += OnTick;
|
||||
_timer.Start();
|
||||
}
|
||||
|
||||
private void OnTick(object? sender, EventArgs e)
|
||||
{
|
||||
if (!_controller.RecordingEnabled) return;
|
||||
var dir = _controller.RecordingDirectory;
|
||||
if (string.IsNullOrEmpty(dir)) return;
|
||||
|
||||
long freeBytes;
|
||||
try
|
||||
{
|
||||
// DriveInfo wants a drive letter / mount root. Walk up the path
|
||||
// until we hit a directory that exists (the recording dir might
|
||||
// not have been created yet by the first ISO).
|
||||
var probe = dir;
|
||||
while (!Directory.Exists(probe))
|
||||
{
|
||||
var parent = Path.GetDirectoryName(probe);
|
||||
if (string.IsNullOrEmpty(parent) || parent == probe) break;
|
||||
probe = parent;
|
||||
}
|
||||
if (!Directory.Exists(probe)) return;
|
||||
var drive = new DriveInfo(Path.GetPathRoot(probe) ?? probe);
|
||||
freeBytes = drive.AvailableFreeSpace;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If the drive query fails (network share dropped, weird path),
|
||||
// skip this tick rather than spam the toast with errors.
|
||||
return;
|
||||
}
|
||||
|
||||
if (freeBytes < HardStopBytes)
|
||||
{
|
||||
_controller.SetRecording(false, dir);
|
||||
_toast.Warn($"Recording AUTO-STOPPED — drive has only {FormatBytes(freeBytes)} free");
|
||||
_lastWarnAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
else if (freeBytes < SoftWarnBytes)
|
||||
{
|
||||
// Throttle the soft warning so we don't toast every 5s for the
|
||||
// last hour of disk space.
|
||||
if (DateTimeOffset.UtcNow - _lastWarnAt < TimeSpan.FromMinutes(2)) return;
|
||||
_toast.Warn($"Recording drive has {FormatBytes(freeBytes)} free — winding down soon");
|
||||
_lastWarnAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
if (bytes >= 1L << 30) return $"{bytes / (double)(1L << 30):F1} GB";
|
||||
if (bytes >= 1L << 20) return $"{bytes / (double)(1L << 20):F0} MB";
|
||||
return $"{bytes} bytes";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_timer.Stop();
|
||||
_timer.Tick -= OnTick;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue