Resource timeline
Resources as a tree with capacity per group, events on a scrolling time axis, and rescheduling by drag.
3 demos
Resources as a tree with capacity per group, events on a scrolling time axis, and rescheduling by drag.
A day view with one column per engineer and each engineer's own working hours.
A release timeline whose events pack into rows by overlap over a custom span.
Build one like it
The smallest complete version, in each language CanopyX supports for this view. The demo above is the same API with more data.
using CanopyX;
using CanopyX.Html;
var hosts = new CanopyTreeView("hosts", "Hosts");
hosts.Columns.Add(new CanopyColumn("name", "Host"));
hosts.Columns.Add(new CanopyColumn("events", "Events") {
Kind = CanopyColumnKind.Number
});
foreach (var (site, names) in new[] {
("Warsaw", new[] { "waw-web-01", "waw-db-01" }),
("Berlin", new[] { "ber-web-01", "ber-db-01" })
}) {
var node = new CanopyNode("site-" + site.ToLowerInvariant(), site) {
Expanded = true
};
foreach (var name in names) node.Children.Add(new CanopyNode(name, name));
hosts.Roots.Add(node);
}
var events = new CanopyDataGridView("host-events", "Incidents by host") {
Noun = "events"
};
events.Columns.Add(new CanopyColumn("title", "Event") { RowHeader = true });
events.Columns.Add(new CanopyColumn("kind", "Kind") {
Presenter = CanopyGridPresenter.State(new Dictionary<string, CanopyTone> {
["Outage"] = CanopyTone.Danger,
["Incident"] = CanopyTone.Warning,
["Maintenance"] = CanopyTone.Info
})
});
events.Columns.Add(new CanopyColumn("starts", "Starts") {
Kind = CanopyColumnKind.DateTime
});
events.Columns.Add(new CanopyColumn("ends", "Ends") {
Kind = CanopyColumnKind.DateTime
});
events.Columns.Add(new CanopyColumn("host", "Host"));
foreach (var (id, title, kind, host, hour, hours) in new[] {
("e1", "Patch window", "Maintenance", "waw-web-01", 6, 3),
("e2", "Replication lag", "Incident", "waw-db-01", 9, 2),
("e3", "Power feed lost", "Outage", "ber-web-01", 11, 1),
("e4", "Index rebuild", "Maintenance", "ber-db-01", 14, 4),
("e5", "Disk alerts", "Incident", "waw-db-01", 16, 2)
}) {
var starts = new DateTime(2026, 10, 16, hour, 0, 0, DateTimeKind.Utc);
var row = new CanopyGridRow(id);
row.Cells["title"] = title;
row.Cells["kind"] = kind;
row.Cells["starts"] = starts;
row.Cells["ends"] = starts.AddHours(hours);
row.Cells["host"] = host;
events.Rows.Add(row);
}
events.Calendar = new CanopyCalendar("starts") {
End = "ends",
Title = "title",
Status = "kind",
Resource = "host",
View = CanopyCalendarView.Timeline,
Zoom = CanopyCalendarZoom.Hour,
Date = new DateTime(2026, 10, 16),
Resources = new CanopyCalendarResources(hosts) { Label = "Hosts" }
};
events.Calendar.Resources.Aggregates["events"] = CanopyCalendarAggregate.Count;
events.SaveHtmlPage("timeline.html");Run this snippet (resource timeline, C#)Its output, generated with the site.
using CanopyX;
using CanopyX.Html;
var hosts = new CanopyTreeView("hosts", "Hosts");
hosts.Columns.Add(new CanopyColumn("name", "Host"));
var warsaw = new CanopyNode("site-warsaw", "Warsaw") { Expanded = true };
warsaw.Children.Add(new CanopyNode("waw-web-01", "waw-web-01"));
warsaw.Children.Add(new CanopyNode("waw-db-01", "waw-db-01"));
hosts.Roots.Add(warsaw);
var grid = new CanopyDataGridView<HostEvent>(
"host-events", "Incidents by host", x => x.Id);
grid.Column(x => x.Title, "Event", c => c.RowHeader = true);
grid.Column(x => x.Kind, configure: c => c.Presenter = CanopyGridPresenter.State(
new Dictionary<string, CanopyTone> {
["Incident"] = CanopyTone.Warning,
["Maintenance"] = CanopyTone.Info
}));
var timeline = grid.Calendar()
.Start(x => x.Starts)
.End(x => x.Ends)
.Title(x => x.Title)
.Status(x => x.Kind)
.Resource(x => x.Host)
.Definition;
timeline.View = CanopyCalendarView.Timeline;
timeline.Zoom = CanopyCalendarZoom.Hour;
timeline.Date = new DateTime(2026, 10, 16);
timeline.Resources = new CanopyCalendarResources(hosts) { Label = "Hosts" };
static DateTime At(int hour) => new(2026, 10, 16, hour, 0, 0, DateTimeKind.Utc);
grid.AddRows(new HostEvent[] {
new("e1", "Patch window", "Maintenance", "waw-web-01", At(6), At(9)),
new("e2", "Replication lag", "Incident", "waw-db-01", At(9), At(11)),
new("e3", "Index rebuild", "Maintenance", "waw-db-01", At(14), At(18))
}).View.SaveHtmlPage("timeline.html");
internal sealed record HostEvent(
string Id, string Title, string Kind, string Host,
DateTime Starts, DateTime Ends);Run this snippet (resource timeline, C# fluent)Its output, generated with the site.
import { createCalendar } from "@evotecit/canopyx/grid";
import "@evotecit/canopyx/grid.css";
const on16th = hour => `2026-10-16T${String(hour).padStart(2, "0")}:00:00Z`;
const event = (title, tone, resource, from, to) =>
({ title, tone, resource, start: on16th(from), end: on16th(to) });
createCalendar(document.getElementById("timeline"), {
id: "host-events",
label: "Incidents by host",
timeline: true,
view: "timeline",
date: "2026-10-16",
calendar: { zoom: "hour" },
resources: [
{ id: "site-warsaw", label: "Warsaw" },
{ id: "waw-web-01", label: "waw-web-01", parent: "site-warsaw" },
{ id: "waw-db-01", label: "waw-db-01", parent: "site-warsaw" },
{ id: "site-berlin", label: "Berlin" },
{ id: "ber-web-01", label: "ber-web-01", parent: "site-berlin" },
{ id: "ber-db-01", label: "ber-db-01", parent: "site-berlin" }
],
events: [
event("Patch window", "info", "waw-web-01", 6, 9),
event("Replication lag", "warning", "waw-db-01", 9, 11),
event("Power feed lost", "danger", "ber-web-01", 11, 12),
event("Index rebuild", "info", "ber-db-01", 14, 18),
event("Disk alerts", "warning", "waw-db-01", 16, 18)
]
});Run this snippet (resource timeline, JavaScript)Its output, generated with the site.
Try this in the demo
- Resources as a tree
Collapse a site, then a role, in the tree on the left.
Each resource shows its event count and busy hours; group rows roll them up.
- Capacity per group
Look along the group rows for amber and red cells.
Group rows turn amber, then red, as more events run at the same time.
- Reschedule by drag
Drag an incident onto another host in the same role.
Moves go through the grid's editing contract; a move that would overlap is refused.