Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ A dark-mode desktop application for visualizing SQL Server Agent maintenance job
- **Drag-to-reschedule** — Scheduled jobs appear as hollow outlined bars. Drag one left or right to a new start time (snaps to 5-minute marks), confirm in the dialog, and the SQL Agent schedule is updated immediately via `msdb.dbo.sp_update_schedule` — no need to open SSMS. Saves significant DBA time when redistributing load across the day.
- **Multiple servers in parallel** — Queries all configured servers simultaneously. If one server is unreachable the others still load; a colour-coded dot in the toolbar shows each server's status.
- **Hover tooltips** — Every cell and bar shows server name, job type, start time, end time, and duration. For a **running job** the tooltip also shows live progress: spid, database name, command, and percent complete. Hovering a **row label** shows the actual T-SQL from the SQL Agent job step.
- **Config file** — Servers and job definitions are read from `config.json` next to the exe. No recompile needed to add servers or jobs.
- **Config file** — Servers and curated maintenance job definitions are read from `config.json` next to the exe. No recompile needed to add servers or curated jobs.
- **Dynamic job discovery** — Turn on **Show all SQL Agent jobs** in the toolbar to include additional jobs found for the selected date or week without editing `config.json`.

## Jobs Visualized

Expand All @@ -32,7 +33,7 @@ Out of the box the app is configured for [Ola Hallengren's](https://ola.hallengr
| IndexOptimize | `IndexOptimize%` |
| IntegrityCheck | `DatabaseIntegrityCheck%` |

Any SQL Server Agent job can be added by editing `config.json`.
Curated jobs can be added by editing `config.json`. To spot overlaps with extra jobs without changing configuration, enable **Show all SQL Agent jobs** in the Week Overview or Day Detail toolbar.

## Requirements

Expand Down Expand Up @@ -68,10 +69,12 @@ Any SQL Server Agent job can be added by editing `config.json`.
}
```

- `servers` — hostnames or IP addresses; Windows auth is used, no credentials in the file.
- `sqlPattern` — SQL `LIKE` pattern matched against `msdb.dbo.sysjobs.name`.
- `label` — display name used in tooltips.
- `shortLabel` — abbreviated name shown in the row label panel.
- `servers` — hostnames or IP addresses; Windows auth is used, no credentials in the file.
- `sqlPattern` — SQL `LIKE` pattern matched against `msdb.dbo.sysjobs.name`.
- `label` — display name used in tooltips.
- `shortLabel` — abbreviated name shown in the row label panel.

Extra SQL Agent jobs do not need to be added here unless you want them shown by default as curated rows. Use **Show all SQL Agent jobs** to discover them dynamically per server for the selected date or week.

4. Restart the app after editing `config.json`.

Expand Down
5 changes: 5 additions & 0 deletions src/SQLJobVisualizer/Controls/DayDetailControl.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
Foreground="{DynamicResource ForegroundBrush}" FontSize="13"/>
<CalendarDatePicker x:Name="DatePicker" />
<Button x:Name="RefreshButton" Content="⟳ Refresh" Margin="8,0,0,0" />
<CheckBox x:Name="ShowAllJobsCheckBox"
Content="Show all SQL Agent jobs"
VerticalAlignment="Center"
Foreground="{DynamicResource ForegroundBrush}"
Margin="12,0,0,0" />
<StackPanel x:Name="ServerStatusPanel"
Orientation="Horizontal"
Spacing="4"
Expand Down
74 changes: 53 additions & 21 deletions src/SQLJobVisualizer/Controls/DayDetailControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public partial class DayDetailControl : UserControl

private readonly CommandLogService _service = new();
private CancellationTokenSource? _cts;
private JobRowCatalog _rowCatalog = JobRowCatalog.FromConfigured();
private IReadOnlyDictionary<(string Server, string JobLabel), string> _stepCommands =
new Dictionary<(string, string), string>();
private readonly DispatcherTimer _clockTimer = new() { Interval = TimeSpan.FromSeconds(1) };
Expand All @@ -41,6 +42,11 @@ public DayDetailControl()
DatePicker.SelectedDate = DateTime.Today;
DatePicker.SelectedDateChanged += (_, _) => _ = LoadAsync();
RefreshButton.Click += (_, _) => _ = LoadAsync();
ShowAllJobsCheckBox.IsCheckedChanged += (_, _) =>
{
_ = LoadStepCommandsAsync();
_ = LoadAsync();
};

_clockTimer.Tick += (_, _) => UpdateClock();
_clockTimer.Start();
Expand All @@ -63,7 +69,7 @@ protected override void OnLoaded(RoutedEventArgs e)

private async Task LoadStepCommandsAsync()
{
_stepCommands = await _service.LoadJobStepCommandsAsync();
_stepCommands = await _service.LoadJobStepCommandsAsync(IsShowingAllJobs());
PopulateLabelPanel();
}

Expand Down Expand Up @@ -101,9 +107,9 @@ private void PopulateLabelPanel()

// Data rows — y matches HeaderH + rowIndex * RowH exactly
string? lastJob = null;
for (int i = 0; i < ServerList.AllRows.Count; i++)
for (int i = 0; i < _rowCatalog.AllRows.Count; i++)
{
var row = ServerList.AllRows[i];
var row = _rowCatalog.AllRows[i];
bool isGroupStart = row.JobLabel != lastJob;
lastJob = row.JobLabel;

Expand Down Expand Up @@ -150,7 +156,7 @@ private void PopulateLabelPanel()
LabelCanvas.Children.Add(border);
}

LabelCanvas.Height = HeaderH + ServerList.AllRows.Count * RowH;
LabelCanvas.Height = HeaderH + _rowCatalog.AllRows.Count * RowH;
}

private string BuildRowTooltip(JobRow row)
Expand Down Expand Up @@ -192,12 +198,13 @@ private async Task LoadAsync()
try
{
var day = DatePicker.SelectedDate?.Date ?? DateTime.Today;
bool includeAllJobs = IsShowingAllJobs();

var completedTask = _service.LoadDayAsync(day, ct);
var completedTask = _service.LoadDayAsync(day, includeAllJobs, ct);
var runningTask = day == DateTime.Today
? _service.LoadRunningAsync(ct)
? _service.LoadRunningAsync(includeAllJobs, ct)
: Task.FromResult<(List<CommandLogEntry>, IReadOnlyList<string>)>(([], []));
var scheduledTask = _service.LoadScheduledAsync(day, ct);
var scheduledTask = _service.LoadScheduledAsync(day, includeAllJobs, ct);

await Task.WhenAll(completedTask, runningTask, scheduledTask);
if (ct.IsCancellationRequested) return;
Expand All @@ -216,7 +223,24 @@ private async Task LoadAsync()
.ToList();

var failedServers = failedCompleted.Concat(failedRunning).Distinct().ToList();
var executions = BuildExecutions(merged, day);
_rowCatalog = includeAllJobs
? JobRowCatalog.FromEntries(merged, scheduled)
: JobRowCatalog.FromConfigured();
PopulateLabelPanel();

var executions = BuildExecutions(merged, day, _rowCatalog, includeAllJobs);
var scheduledRows = scheduled
.Select(s => new ScheduledRun
{
ServerName = s.ServerName,
JobLabel = s.JobLabel,
RowIndex = _rowCatalog.GetRowIndex(s.ServerName, s.JobLabel),
ScheduledTime = s.ScheduledTime,
EstimatedDuration = s.EstimatedDuration,
ScheduleId = s.ScheduleId,
})
.Where(s => s.RowIndex >= 0)
.ToList();

// Average historical duration per row — used to size scheduled bars
var avgDurations = executions
Expand All @@ -225,7 +249,7 @@ private async Task LoadAsync()
.ToDictionary(g => g.Key,
g => TimeSpan.FromSeconds(g.Average(e => e.Duration.TotalSeconds)));

RenderDay(executions, scheduled, avgDurations, day);
RenderDay(executions, scheduledRows, avgDurations, day, _rowCatalog);
UpdateServerStatus(failedServers);
}
catch (OperationCanceledException) { }
Expand All @@ -239,23 +263,25 @@ private async Task LoadAsync()
}
}

private static List<JobExecution> BuildExecutions(List<CommandLogEntry> entries, DateTime day)
private static List<JobExecution> BuildExecutions(
List<CommandLogEntry> entries, DateTime day,
JobRowCatalog rowCatalog, bool includeAllJobs)
{
var list = new List<JobExecution>();
foreach (var entry in entries)
{
var jobLabel = JobParser.ParseJobLabel(entry.CommandType, entry.Command);
var jobLabel = JobParser.ParseJobLabel(entry.CommandType, entry.Command, includeAllJobs);
if (jobLabel is null) continue;

int rowIdx = ServerList.GetRowIndex(entry.ServerName, jobLabel);
int rowIdx = rowCatalog.GetRowIndex(entry.ServerName, jobLabel);
if (rowIdx < 0) continue;

// Still-running jobs: use current time as provisional end time
var endTime = entry.EndTime ?? DateTime.Now;

list.Add(new JobExecution
{
Row = ServerList.AllRows[rowIdx],
Row = rowCatalog.AllRows[rowIdx],
RowIndex = rowIdx,
StartTime = entry.StartTime,
EndTime = endTime,
Expand All @@ -268,13 +294,12 @@ private static List<JobExecution> BuildExecutions(List<CommandLogEntry> entries,
}

private void RenderDay(List<JobExecution> executions, List<ScheduledRun> scheduled,
Dictionary<int, TimeSpan> avgDurations, DateTime day)
Dictionary<int, TimeSpan> avgDurations, DateTime day,
JobRowCatalog rowCatalog)
{
DayCanvas.Children.Clear();

int totalRows = ServerList.AllRows.Count;
int jobCount = ServerList.JobLabels.Length;
int srvCount = ServerList.ServerNames.Length;
int totalRows = rowCatalog.AllRows.Count;
double canvasH = HeaderH + totalRows * RowH;

DayCanvas.Width = CanvasW;
Expand All @@ -288,8 +313,13 @@ private void RenderDay(List<JobExecution> executions, List<ScheduledRun> schedul

// Alternating row group backgrounds
string[] groupBg = ["#1A1D23", "#1D2028"];
for (int g = 0; g < jobCount; g++)
AddRect(0, HeaderH + g * srvCount * RowH, CanvasW, srvCount * RowH, groupBg[g % 2]);
int groupIndex = 0;
foreach (var group in rowCatalog.GetGroups())
{
AddRect(0, HeaderH + group.StartIndex * RowH, CanvasW, group.RowCount * RowH,
groupBg[groupIndex % 2]);
groupIndex++;
}

// Hour header labels and vertical grid lines
for (int h = 0; h <= 24; h++)
Expand Down Expand Up @@ -317,8 +347,8 @@ private void RenderDay(List<JobExecution> executions, List<ScheduledRun> schedul
AddRect(0, HeaderH - 1, CanvasW, 1, "#3A3D45");

// Job-group separators
for (int g = 1; g < jobCount; g++)
AddRect(0, HeaderH + g * srvCount * RowH, CanvasW, 2, "#3A3D45");
foreach (var group in rowCatalog.GetGroups().Skip(1))
AddRect(0, HeaderH + group.StartIndex * RowH, CanvasW, 2, "#3A3D45");

// Job execution bars
foreach (var ex in executions)
Expand Down Expand Up @@ -505,6 +535,8 @@ private void UpdateServerStatus(IReadOnlyList<string> failedServers)
}
}

private bool IsShowingAllJobs() => ShowAllJobsCheckBox.IsChecked == true;

private void OnDragStart(Rectangle bar, ScheduledRun run, PointerPressedEventArgs e)
{
if (_dragBar is not null) return;
Expand Down
5 changes: 5 additions & 0 deletions src/SQLJobVisualizer/Controls/WeekMatrixControl.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
TextAlignment="Center"/>
<Button x:Name="NextWeekButton" Content="Next ▶" />
<Button x:Name="RefreshButton" Content="⟳ Refresh" Margin="16,0,0,0" />
<CheckBox x:Name="ShowAllJobsCheckBox"
Content="Show all SQL Agent jobs"
VerticalAlignment="Center"
Foreground="{DynamicResource ForegroundBrush}"
Margin="12,0,0,0" />
<StackPanel x:Name="ServerStatusPanel"
Orientation="Horizontal"
Spacing="4"
Expand Down
57 changes: 38 additions & 19 deletions src/SQLJobVisualizer/Controls/WeekMatrixControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public partial class WeekMatrixControl : UserControl
private DateTime _weekStart;
private readonly CommandLogService _service = new();
private CancellationTokenSource? _cts;
private JobRowCatalog _rowCatalog = JobRowCatalog.FromConfigured();
private IReadOnlyDictionary<(string Server, string JobLabel), string> _stepCommands =
new Dictionary<(string, string), string>();

Expand All @@ -30,6 +31,11 @@ public WeekMatrixControl()
PrevWeekButton.Click += (_, _) => NavigateWeek(-1);
NextWeekButton.Click += (_, _) => NavigateWeek(1);
RefreshButton.Click += (_, _) => _ = LoadAsync();
ShowAllJobsCheckBox.IsCheckedChanged += (_, _) =>
{
_ = LoadStepCommandsAsync();
_ = LoadAsync();
};
}

protected override void OnLoaded(RoutedEventArgs e)
Expand All @@ -41,7 +47,7 @@ protected override void OnLoaded(RoutedEventArgs e)

private async Task LoadStepCommandsAsync()
{
_stepCommands = await _service.LoadJobStepCommandsAsync();
_stepCommands = await _service.LoadJobStepCommandsAsync(IsShowingAllJobs());
PopulateLabelPanel();
}

Expand Down Expand Up @@ -82,9 +88,9 @@ private void PopulateLabelPanel()

// Data rows — y matches HeaderH + rowIndex * CellH exactly
string? lastJob = null;
for (int i = 0; i < ServerList.AllRows.Count; i++)
for (int i = 0; i < _rowCatalog.AllRows.Count; i++)
{
var row = ServerList.AllRows[i];
var row = _rowCatalog.AllRows[i];
bool isGroupStart = row.JobLabel != lastJob;
lastJob = row.JobLabel;

Expand Down Expand Up @@ -131,7 +137,7 @@ private void PopulateLabelPanel()
LabelCanvas.Children.Add(border);
}

LabelCanvas.Height = HeaderH + ServerList.AllRows.Count * CellH;
LabelCanvas.Height = HeaderH + _rowCatalog.AllRows.Count * CellH;
}

private string BuildRowTooltip(JobRow row)
Expand Down Expand Up @@ -173,11 +179,17 @@ private async Task LoadAsync()

try
{
var (entries, failedServers) = await _service.LoadWeekAsync(weekStart, ct);
bool includeAllJobs = IsShowingAllJobs();
var (entries, failedServers) = await _service.LoadWeekAsync(weekStart, includeAllJobs, ct);
if (ct.IsCancellationRequested) return;

var slots = BuildSlots(entries, weekStart);
RenderMatrix(slots, weekStart);
_rowCatalog = includeAllJobs
? JobRowCatalog.FromEntries(entries)
: JobRowCatalog.FromConfigured();
PopulateLabelPanel();

var slots = BuildSlots(entries, weekStart, _rowCatalog, includeAllJobs);
RenderMatrix(slots, weekStart, _rowCatalog);
UpdateServerStatus(failedServers);
}
catch (OperationCanceledException) { }
Expand All @@ -191,15 +203,17 @@ private async Task LoadAsync()
}
}

private static List<JobSlot> BuildSlots(List<CommandLogEntry> entries, DateTime weekStart)
private static List<JobSlot> BuildSlots(
List<CommandLogEntry> entries, DateTime weekStart,
JobRowCatalog rowCatalog, bool includeAllJobs)
{
var slots = new List<JobSlot>();
foreach (var entry in entries)
{
var jobLabel = JobParser.ParseJobLabel(entry.CommandType, entry.Command);
var jobLabel = JobParser.ParseJobLabel(entry.CommandType, entry.Command, includeAllJobs);
if (jobLabel is null) continue;

int rowIdx = ServerList.GetRowIndex(entry.ServerName, jobLabel);
int rowIdx = rowCatalog.GetRowIndex(entry.ServerName, jobLabel);
if (rowIdx < 0) continue;

int startHour = (int)(entry.StartTime - weekStart).TotalHours;
Expand All @@ -215,7 +229,7 @@ private static List<JobSlot> BuildSlots(List<CommandLogEntry> entries, DateTime
{
slots.Add(new JobSlot
{
Row = ServerList.AllRows[rowIdx],
Row = rowCatalog.AllRows[rowIdx],
RowIndex = rowIdx,
HourOffset = h,
Success = success,
Expand All @@ -228,14 +242,12 @@ private static List<JobSlot> BuildSlots(List<CommandLogEntry> entries, DateTime
return slots;
}

private void RenderMatrix(List<JobSlot> slots, DateTime weekStart)
private void RenderMatrix(List<JobSlot> slots, DateTime weekStart, JobRowCatalog rowCatalog)
{
MatrixCanvas.Children.Clear();

const int totalCols = 168;
int totalRows = ServerList.AllRows.Count;
int jobCount = ServerList.JobLabels.Length;
int srvCount = ServerList.ServerNames.Length;
int totalRows = rowCatalog.AllRows.Count;
double canvasW = totalCols * CellW;
double canvasH = HeaderH + totalRows * CellH;

Expand All @@ -250,8 +262,13 @@ private void RenderMatrix(List<JobSlot> slots, DateTime weekStart)

// Alternating group backgrounds
string[] groupBg = ["#1A1D23", "#1D2028"];
for (int g = 0; g < jobCount; g++)
AddRect(0, HeaderH + g * srvCount * CellH, canvasW, srvCount * CellH, groupBg[g % 2]);
int groupIndex = 0;
foreach (var group in rowCatalog.GetGroups())
{
AddRect(0, HeaderH + group.StartIndex * CellH, canvasW, group.RowCount * CellH,
groupBg[groupIndex % 2]);
groupIndex++;
}

// Day column headers and separators
string[] dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
Expand Down Expand Up @@ -297,8 +314,8 @@ private void RenderMatrix(List<JobSlot> slots, DateTime weekStart)
AddRect(0, HeaderH - 1, canvasW, 1, "#3A3D45");

// Job-group separators
for (int g = 1; g < jobCount; g++)
AddRect(0, HeaderH + g * srvCount * CellH, canvasW, 2, "#3A3D45");
foreach (var group in rowCatalog.GetGroups().Skip(1))
AddRect(0, HeaderH + group.StartIndex * CellH, canvasW, 2, "#3A3D45");

// Faint 6-hour grid lines inside days
for (int h = 6; h < totalCols; h += 6)
Expand Down Expand Up @@ -434,6 +451,8 @@ private void UpdateServerStatus(IReadOnlyList<string> failedServers)
}
}

private bool IsShowingAllJobs() => ShowAllJobsCheckBox.IsChecked == true;

private static DateTime GetWeekMonday(DateTime date)
{
int diff = ((int)date.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7;
Expand Down
Loading