speed up vm trace pages
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-02-09 14:19:24 +11:00
parent c4097ca608
commit 59b16db04f
12 changed files with 702 additions and 208 deletions

View File

@@ -5,6 +5,15 @@ vCTP is a vSphere Chargeback Tracking Platform, designed for a specific customer
- Hourly snapshots capture inventory per vCenter (concurrency via `hourly_snapshot_concurrency`). - Hourly snapshots capture inventory per vCenter (concurrency via `hourly_snapshot_concurrency`).
- Daily summaries aggregate the hourly snapshots for the day; monthly summaries aggregate daily summaries for the month (or hourly snapshots if configured). - Daily summaries aggregate the hourly snapshots for the day; monthly summaries aggregate daily summaries for the month (or hourly snapshots if configured).
- Snapshots are registered in `snapshot_registry` so regeneration via `/api/snapshots/aggregate` can locate the correct tables (fallback scanning is also supported). - Snapshots are registered in `snapshot_registry` so regeneration via `/api/snapshots/aggregate` can locate the correct tables (fallback scanning is also supported).
- vCenter totals pages now provide two views:
- Daily Aggregated (`/vcenters/totals/daily`) for fast long-range trends.
- Hourly Detail 45d (`/vcenters/totals/hourly`) for recent granular change tracking.
- vCenter totals performance is accelerated with compact cache tables:
- `vcenter_latest_totals` (one latest row per vCenter)
- `vcenter_aggregate_totals` (hourly/daily/monthly per-vCenter totals by snapshot time)
- VM Trace now supports two modes on `/vm/trace`:
- `view=hourly` (default) for full snapshot detail
- `view=daily` for daily aggregated trend lines (using `vm_daily_rollup` when available)
- Reports (XLSX with totals/charts) are generated automatically after hourly, daily, and monthly jobs and written to a reports directory. - Reports (XLSX with totals/charts) are generated automatically after hourly, daily, and monthly jobs and written to a reports directory.
- Hourly totals in reports are interval-based: each row represents `[HH:00, HH+1:00)` and uses the first snapshot at or after the hour end (including cross-day snapshots) to prorate VM presence by creation/deletion overlap. - Hourly totals in reports are interval-based: each row represents `[HH:00, HH+1:00)` and uses the first snapshot at or after the hour end (including cross-day snapshots) to prorate VM presence by creation/deletion overlap.
- Monthly aggregation reports include a Daily Totals sheet with full-day interval labels (`YYYY-MM-DD to YYYY-MM-DD`) and prorated totals derived from daily summaries. - Monthly aggregation reports include a Daily Totals sheet with full-day interval labels (`YYYY-MM-DD to YYYY-MM-DD`) and prorated totals derived from daily summaries.
@@ -87,6 +96,11 @@ If you want a one-time cache backfill for the vCenter totals cache tables
vctp -settings /path/to/vctp.yml -backfill-vcenter-cache vctp -settings /path/to/vctp.yml -backfill-vcenter-cache
``` ```
The backfill command:
- Ensures/migrates `snapshot_registry` when needed.
- Rebuilds hourly/latest vCenter totals caches.
- Recomputes daily/monthly rows for `vcenter_aggregate_totals` from registered summary snapshots.
## Database Configuration ## Database Configuration
By default the app uses SQLite and creates/opens `db.sqlite3`. By default the app uses SQLite and creates/opens `db.sqlite3`.

View File

@@ -55,6 +55,9 @@ templ Index(info BuildInfo) {
<p class="mt-2 text-sm text-slate-600"> <p class="mt-2 text-sm text-slate-600">
vCTP is a vSphere Chargeback Tracking Platform. vCTP is a vSphere Chargeback Tracking Platform.
</p> </p>
<p class="mt-2 text-sm text-slate-600">
Use fast vCenter totals views (Daily Aggregated and Hourly Detail 45d) and VM Trace views (Hourly Detail and Daily Aggregated) to move between long-range trends and granular timelines.
</p>
</div> </div>
<div class="web2-card"> <div class="web2-card">
<h2 class="text-lg font-semibold mb-2">Snapshots and Reports</h2> <h2 class="text-lg font-semibold mb-2">Snapshots and Reports</h2>
@@ -64,6 +67,8 @@ templ Index(info BuildInfo) {
<p><strong>Daily tracks:</strong> <code class="web2-code">SamplesPresent</code>, <code class="web2-code">TotalSamples</code>, <code class="web2-code">AvgIsPresent</code>, <code class="web2-code">AvgVcpuCount</code>, <code class="web2-code">AvgRamGB</code>, <code class="web2-code">AvgProvisionedDisk</code>, <code class="web2-code">PoolTinPct</code>, <code class="web2-code">PoolBronzePct</code>, <code class="web2-code">PoolSilverPct</code>, <code class="web2-code">PoolGoldPct</code>, plus chargeback totals columns <code class="web2-code">Tin</code>, <code class="web2-code">Bronze</code>, <code class="web2-code">Silver</code>, <code class="web2-code">Gold</code>.</p> <p><strong>Daily tracks:</strong> <code class="web2-code">SamplesPresent</code>, <code class="web2-code">TotalSamples</code>, <code class="web2-code">AvgIsPresent</code>, <code class="web2-code">AvgVcpuCount</code>, <code class="web2-code">AvgRamGB</code>, <code class="web2-code">AvgProvisionedDisk</code>, <code class="web2-code">PoolTinPct</code>, <code class="web2-code">PoolBronzePct</code>, <code class="web2-code">PoolSilverPct</code>, <code class="web2-code">PoolGoldPct</code>, plus chargeback totals columns <code class="web2-code">Tin</code>, <code class="web2-code">Bronze</code>, <code class="web2-code">Silver</code>, <code class="web2-code">Gold</code>.</p>
<p><strong>Monthly tracks:</strong> the same daily aggregate fields, with monthly values weighted by per-day sample volume so partial-day VMs and config changes stay proportional.</p> <p><strong>Monthly tracks:</strong> the same daily aggregate fields, with monthly values weighted by per-day sample volume so partial-day VMs and config changes stay proportional.</p>
<p>Snapshots are registered in <code class="web2-code">snapshot_registry</code> so regeneration via <code class="web2-code">/api/snapshots/aggregate</code> can locate the correct tables (fallback scanning is also supported).</p> <p>Snapshots are registered in <code class="web2-code">snapshot_registry</code> so regeneration via <code class="web2-code">/api/snapshots/aggregate</code> can locate the correct tables (fallback scanning is also supported).</p>
<p>vCenter totals pages are accelerated by compact cache tables: <code class="web2-code">vcenter_latest_totals</code> and <code class="web2-code">vcenter_aggregate_totals</code>.</p>
<p>VM Trace daily mode uses the <code class="web2-code">vm_daily_rollup</code> cache when available, and falls back to daily summary tables if needed.</p>
<p>Reports (XLSX with totals/charts) are generated automatically after hourly, daily, and monthly jobs and written to a reports directory.</p> <p>Reports (XLSX with totals/charts) are generated automatically after hourly, daily, and monthly jobs and written to a reports directory.</p>
<p>Hourly totals are interval-based: each row represents <code class="web2-code">[HH:00, HH+1:00)</code> and uses the first snapshot at or after the hour end (including cross-day snapshots) to prorate VM presence.</p> <p>Hourly totals are interval-based: each row represents <code class="web2-code">[HH:00, HH+1:00)</code> and uses the first snapshot at or after the hour end (including cross-day snapshots) to prorate VM presence.</p>
<p>Monthly aggregation reports include a Daily Totals sheet with full-day interval labels (YYYY-MM-DD to YYYY-MM-DD) and prorated totals.</p> <p>Monthly aggregation reports include a Daily Totals sheet with full-day interval labels (YYYY-MM-DD to YYYY-MM-DD) and prorated totals.</p>

File diff suppressed because one or more lines are too long

View File

@@ -24,7 +24,16 @@ type VmTraceChart struct {
ConfigJSON string ConfigJSON string
} }
templ VmTracePage(query string, display_query string, vm_id string, vm_uuid string, vm_name string, creationLabel string, deletionLabel string, creationApprox bool, entries []VmTraceEntry, chart VmTraceChart) { type VmTraceMeta struct {
ViewType string
TypeLabel string
HourlyLink string
DailyLink string
HourlyClass string
DailyClass string
}
templ VmTracePage(query string, display_query string, vm_id string, vm_uuid string, vm_name string, creationLabel string, deletionLabel string, creationApprox bool, entries []VmTraceEntry, chart VmTraceChart, meta VmTraceMeta) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@core.Header() @core.Header()
@@ -35,13 +44,14 @@ templ VmTracePage(query string, display_query string, vm_id string, vm_uuid stri
<div> <div>
<div class="web2-pill">VM Trace</div> <div class="web2-pill">VM Trace</div>
<h1 class="mt-3 text-4xl font-bold">Snapshot history{display_query}</h1> <h1 class="mt-3 text-4xl font-bold">Snapshot history{display_query}</h1>
<p class="mt-2 text-sm text-slate-600">Timeline of vCPU, RAM, and resource pool changes across snapshots.</p> <p class="mt-2 text-sm text-slate-600">Timeline of vCPU, RAM, and resource pool changes across { meta.TypeLabel } snapshots.</p>
</div> </div>
<div class="flex gap-3 flex-wrap"> <div class="flex gap-3 flex-wrap">
<a class="web2-button" href="/">Dashboard</a> <a class="web2-button" href="/">Dashboard</a>
</div> </div>
</div> </div>
<form method="get" action="/vm/trace" class="mt-4 grid gap-3 md:grid-cols-3"> <form method="get" action="/vm/trace" class="mt-4 grid gap-3 md:grid-cols-3">
<input type="hidden" name="view" value={ meta.ViewType }/>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label class="text-sm text-slate-600" for="vm_id">VM ID</label> <label class="text-sm text-slate-600" for="vm_id">VM ID</label>
<input class="web2-card border border-slate-200 px-3 py-2 rounded" type="text" id="vm_id" name="vm_id" value={vm_id} placeholder="vm-12345"/> <input class="web2-card border border-slate-200 px-3 py-2 rounded" type="text" id="vm_id" name="vm_id" value={vm_id} placeholder="vm-12345"/>
@@ -59,11 +69,15 @@ templ VmTracePage(query string, display_query string, vm_id string, vm_uuid stri
<a class="web3-button" href="/vm/trace">Clear</a> <a class="web3-button" href="/vm/trace">Clear</a>
</div> </div>
</form> </form>
<div class="web3-button-group mt-5 mb-1">
<a class={ meta.HourlyClass } href={ meta.HourlyLink }>Hourly Detail</a>
<a class={ meta.DailyClass } href={ meta.DailyLink }>Daily Aggregated</a>
</div>
</section> </section>
<section class="web2-card"> <section class="web2-card">
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap"> <div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
<h2 class="text-lg font-semibold">Snapshot Timeline</h2> <h2 class="text-lg font-semibold">{ meta.TypeLabel } Timeline</h2>
<span class="web2-badge">{len(entries)} samples</span> <span class="web2-badge">{len(entries)} samples</span>
</div> </div>
if chart.ConfigJSON != "" { if chart.ConfigJSON != "" {

View File

@@ -32,7 +32,16 @@ type VmTraceChart struct {
ConfigJSON string ConfigJSON string
} }
func VmTracePage(query string, display_query string, vm_id string, vm_uuid string, vm_name string, creationLabel string, deletionLabel string, creationApprox bool, entries []VmTraceEntry, chart VmTraceChart) templ.Component { type VmTraceMeta struct {
ViewType string
TypeLabel string
HourlyLink string
DailyLink string
HourlyClass string
DailyClass string
}
func VmTracePage(query string, display_query string, vm_id string, vm_uuid string, vm_name string, creationLabel string, deletionLabel string, creationApprox bool, entries []VmTraceEntry, chart VmTraceChart, meta VmTraceMeta) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -68,251 +77,360 @@ func VmTracePage(query string, display_query string, vm_id string, vm_uuid strin
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(display_query) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(display_query)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 37, Col: 74} return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 46, Col: 74}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</h1><p class=\"mt-2 text-sm text-slate-600\">Timeline of vCPU, RAM, and resource pool changes across snapshots.</p></div><div class=\"flex gap-3 flex-wrap\"><a class=\"web2-button\" href=\"/\">Dashboard</a></div></div><form method=\"get\" action=\"/vm/trace\" class=\"mt-4 grid gap-3 md:grid-cols-3\"><div class=\"flex flex-col gap-1\"><label class=\"text-sm text-slate-600\" for=\"vm_id\">VM ID</label> <input class=\"web2-card border border-slate-200 px-3 py-2 rounded\" type=\"text\" id=\"vm_id\" name=\"vm_id\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</h1><p class=\"mt-2 text-sm text-slate-600\">Timeline of vCPU, RAM, and resource pool changes across ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(vm_id) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(meta.TypeLabel)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 47, Col: 123} return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 47, Col: 119}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" placeholder=\"vm-12345\"></div><div class=\"flex flex-col gap-1\"><label class=\"text-sm text-slate-600\" for=\"vm_uuid\">VM UUID</label> <input class=\"web2-card border border-slate-200 px-3 py-2 rounded\" type=\"text\" id=\"vm_uuid\" name=\"vm_uuid\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " snapshots.</p></div><div class=\"flex gap-3 flex-wrap\"><a class=\"web2-button\" href=\"/\">Dashboard</a></div></div><form method=\"get\" action=\"/vm/trace\" class=\"mt-4 grid gap-3 md:grid-cols-3\"><input type=\"hidden\" name=\"view\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(vm_uuid) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(meta.ViewType)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 51, Col: 129} return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 54, Col: 61}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" placeholder=\"uuid...\"></div><div class=\"flex flex-col gap-1\"><label class=\"text-sm text-slate-600\" for=\"name\">Name</label> <input class=\"web2-card border border-slate-200 px-3 py-2 rounded\" type=\"text\" id=\"name\" name=\"name\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"><div class=\"flex flex-col gap-1\"><label class=\"text-sm text-slate-600\" for=\"vm_id\">VM ID</label> <input class=\"web2-card border border-slate-200 px-3 py-2 rounded\" type=\"text\" id=\"vm_id\" name=\"vm_id\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(vm_name) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(vm_id)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 55, Col: 123} return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 57, Col: 123}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" placeholder=\"VM name\"></div><div class=\"md:col-span-3 flex gap-2\"><button class=\"web3-button active\" type=\"submit\">Load VM Trace</button> <a class=\"web3-button\" href=\"/vm/trace\">Clear</a></div></form></section><section class=\"web2-card\"><div class=\"flex items-center justify-between gap-3 mb-4 flex-wrap\"><h2 class=\"text-lg font-semibold\">Snapshot Timeline</h2><span class=\"web2-badge\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" placeholder=\"vm-12345\"></div><div class=\"flex flex-col gap-1\"><label class=\"text-sm text-slate-600\" for=\"vm_uuid\">VM UUID</label> <input class=\"web2-card border border-slate-200 px-3 py-2 rounded\" type=\"text\" id=\"vm_uuid\" name=\"vm_uuid\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(len(entries)) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(vm_uuid)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 67, Col: 44} return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 61, Col: 129}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " samples</span></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" placeholder=\"uuid...\"></div><div class=\"flex flex-col gap-1\"><label class=\"text-sm text-slate-600\" for=\"name\">Name</label> <input class=\"web2-card border border-slate-200 px-3 py-2 rounded\" type=\"text\" id=\"name\" name=\"name\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if chart.ConfigJSON != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"mb-6 overflow-auto\"><div class=\"web3-chart-frame\"><canvas id=\"vm-trace-chart\" class=\"web3-chart-canvas\" role=\"img\" aria-label=\"VM timeline\" data-chart-config=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(chart.ConfigJSON) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(vm_name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 72, Col: 133} return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 65, Col: 123}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"></canvas><div id=\"vm-trace-tooltip\" class=\"web3-chart-tooltip\" aria-hidden=\"true\"></div></div><script>\n\t\t\t\t\t\t\t\twindow.Web3Charts.renderFromDataset({\n\t\t\t\t\t\t\t\t\tcanvasId: \"vm-trace-chart\",\n\t\t\t\t\t\t\t\t\ttooltipId: \"vm-trace-tooltip\",\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t</script></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" placeholder=\"VM name\"></div><div class=\"md:col-span-3 flex gap-2\"><button class=\"web3-button active\" type=\"submit\">Load VM Trace</button> <a class=\"web3-button\" href=\"/vm/trace\">Clear</a></div></form><div class=\"web3-button-group mt-5 mb-1\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} var templ_7745c5c3_Var8 = []any{meta.HourlyClass}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"grid gap-3 md:grid-cols-2 mb-4\"><div class=\"web2-card\"><p class=\"text-xs uppercase tracking-[0.15em] text-slate-500\">Creation time</p><p class=\"mt-2 text-base font-semibold text-slate-800\">") templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<a class=\"")
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(creationLabel)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 86, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if creationApprox {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<p class=\"text-xs text-slate-500 mt-1\">Approximate (earliest snapshot)</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div><div class=\"web2-card\"><p class=\"text-xs uppercase tracking-[0.15em] text-slate-500\">Deletion time</p><p class=\"mt-2 text-base font-semibold text-slate-800\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(deletionLabel) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var8).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 93, Col: 76} return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p></div></div><div class=\"overflow-hidden border border-slate-200 rounded\"><table class=\"web2-table\"><thead><tr><th>Snapshot</th><th>VM Name</th><th>VmId</th><th>VmUuid</th><th>Vcenter</th><th>Resource Pool</th><th class=\"text-right\">vCPUs</th><th class=\"text-right\">RAM (GB)</th><th class=\"text-right\">Disk</th></tr></thead> <tbody>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, e := range entries { var templ_7745c5c3_Var10 templ.SafeURL
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<tr><td>") templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(meta.HourlyLink)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 73, Col: 59}
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(e.Snapshot)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 114, Col: 25}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</td><td>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\">Hourly Detail</a> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 = []any{meta.DailyClass}
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(e.Name) templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 115, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</td><td>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a class=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(e.VmId) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var11).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 116, Col: 21} return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</td><td>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 templ.SafeURL
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(e.VmUuid) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(meta.DailyLink)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 117, Col: 23} return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 74, Col: 57}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</td><td>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">Daily Aggregated</a></div></section><section class=\"web2-card\"><div class=\"flex items-center justify-between gap-3 mb-4 flex-wrap\"><h2 class=\"text-lg font-semibold\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(e.Vcenter) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(meta.TypeLabel)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 118, Col: 24} return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 80, Col: 56}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</td><td>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " Timeline</h2><span class=\"web2-badge\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(e.ResourcePool) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(len(entries))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 119, Col: 29} return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 81, Col: 44}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</td><td class=\"text-right\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " samples</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if chart.ConfigJSON != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"mb-6 overflow-auto\"><div class=\"web3-chart-frame\"><canvas id=\"vm-trace-chart\" class=\"web3-chart-canvas\" role=\"img\" aria-label=\"VM timeline\" data-chart-config=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(e.VcpuCount) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(chart.ConfigJSON)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 120, Col: 45} return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 86, Col: 133}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</td><td class=\"text-right\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\"></canvas><div id=\"vm-trace-tooltip\" class=\"web3-chart-tooltip\" aria-hidden=\"true\"></div></div><script>\n\t\t\t\t\t\t\t\twindow.Web3Charts.renderFromDataset({\n\t\t\t\t\t\t\t\t\tcanvasId: \"vm-trace-chart\",\n\t\t\t\t\t\t\t\t\ttooltipId: \"vm-trace-tooltip\",\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t</script></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"grid gap-3 md:grid-cols-2 mb-4\"><div class=\"web2-card\"><p class=\"text-xs uppercase tracking-[0.15em] text-slate-500\">Creation time</p><p class=\"mt-2 text-base font-semibold text-slate-800\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(e.RamGB) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(creationLabel)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 121, Col: 41} return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 100, Col: 76}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</td><td class=\"text-right\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if creationApprox {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<p class=\"text-xs text-slate-500 mt-1\">Approximate (earliest snapshot)</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div><div class=\"web2-card\"><p class=\"text-xs uppercase tracking-[0.15em] text-slate-500\">Deletion time</p><p class=\"mt-2 text-base font-semibold text-slate-800\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", e.ProvisionedDisk)) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(deletionLabel)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 122, Col: 72} return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 107, Col: 76}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</td></tr>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</p></div></div><div class=\"overflow-hidden border border-slate-200 rounded\"><table class=\"web2-table\"><thead><tr><th>Snapshot</th><th>VM Name</th><th>VmId</th><th>VmUuid</th><th>Vcenter</th><th>Resource Pool</th><th class=\"text-right\">vCPUs</th><th class=\"text-right\">RAM (GB)</th><th class=\"text-right\">Disk</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, e := range entries {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<tr><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(e.Snapshot)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 128, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(e.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 129, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(e.VmId)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 130, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(e.VmUuid)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 131, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(e.Vcenter)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 132, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(e.ResourcePool)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 133, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</td><td class=\"text-right\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(e.VcpuCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 134, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</td><td class=\"text-right\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(e.RamGB)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 135, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</td><td class=\"text-right\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", e.ProvisionedDisk))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 136, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</td></tr>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</tbody></table></div></section></main></body>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</tbody></table></div></section></main></body>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -320,7 +438,7 @@ func VmTracePage(query string, display_query string, vm_id string, vm_uuid strin
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }

View File

@@ -605,6 +605,7 @@ CREATE TABLE IF NOT EXISTS vm_hourly_stats (
} }
_, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_hourly_stats_vmuuid_time_idx ON vm_hourly_stats ("VmUuid","SnapshotTime")`) _, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_hourly_stats_vmuuid_time_idx ON vm_hourly_stats ("VmUuid","SnapshotTime")`)
_, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_hourly_stats_vmid_time_idx ON vm_hourly_stats ("VmId","SnapshotTime")`) _, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_hourly_stats_vmid_time_idx ON vm_hourly_stats ("VmId","SnapshotTime")`)
_, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_hourly_stats_name_time_idx ON vm_hourly_stats (lower("Name"),"SnapshotTime")`)
_, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_hourly_stats_snapshottime_idx ON vm_hourly_stats ("SnapshotTime")`) _, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_hourly_stats_snapshottime_idx ON vm_hourly_stats ("SnapshotTime")`)
return nil return nil
} }
@@ -627,6 +628,8 @@ CREATE TABLE IF NOT EXISTS vm_lifecycle_cache (
return err return err
} }
_, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_lifecycle_cache_vmuuid_idx ON vm_lifecycle_cache ("VmUuid")`) _, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_lifecycle_cache_vmuuid_idx ON vm_lifecycle_cache ("VmUuid")`)
_, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_lifecycle_cache_vmid_idx ON vm_lifecycle_cache ("VmId")`)
_, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_lifecycle_cache_name_idx ON vm_lifecycle_cache (lower("Name"))`)
return nil return nil
} }
@@ -947,6 +950,9 @@ CREATE TABLE IF NOT EXISTS vm_daily_rollup (
} }
_, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_daily_rollup_date_idx ON vm_daily_rollup ("Date")`) _, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_daily_rollup_date_idx ON vm_daily_rollup ("Date")`)
_, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_daily_rollup_vcenter_date_idx ON vm_daily_rollup ("Vcenter","Date")`) _, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_daily_rollup_vcenter_date_idx ON vm_daily_rollup ("Vcenter","Date")`)
_, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_daily_rollup_vmid_date_idx ON vm_daily_rollup ("VmId","Date")`)
_, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_daily_rollup_vmuuid_date_idx ON vm_daily_rollup ("VmUuid","Date")`)
_, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_daily_rollup_name_date_idx ON vm_daily_rollup (lower("Name"),"Date")`)
return nil return nil
} }
@@ -1666,6 +1672,22 @@ type VmLifecycle struct {
DeletionTime int64 DeletionTime int64
} }
func vmLookupPredicate(vmID, vmUUID, name string) (string, []interface{}, bool) {
vmID = strings.TrimSpace(vmID)
vmUUID = strings.TrimSpace(vmUUID)
name = strings.TrimSpace(name)
switch {
case vmID != "":
return `"VmId" = ?`, []interface{}{vmID}, true
case vmUUID != "":
return `"VmUuid" = ?`, []interface{}{vmUUID}, true
case name != "":
return `lower("Name") = ?`, []interface{}{strings.ToLower(name)}, true
default:
return "", nil, false
}
}
// FetchVmTrace returns combined hourly snapshot records for a VM (by id/uuid/name) ordered by snapshot time. // FetchVmTrace returns combined hourly snapshot records for a VM (by id/uuid/name) ordered by snapshot time.
// It prefers the shared vm_hourly_stats history table and falls back to per-snapshot tables. // It prefers the shared vm_hourly_stats history table and falls back to per-snapshot tables.
func FetchVmTrace(ctx context.Context, dbConn *sqlx.DB, vmID, vmUUID, name string) ([]VmTraceRow, error) { func FetchVmTrace(ctx context.Context, dbConn *sqlx.DB, vmID, vmUUID, name string) ([]VmTraceRow, error) {
@@ -1681,24 +1703,142 @@ func FetchVmTrace(ctx context.Context, dbConn *sqlx.DB, vmID, vmUUID, name strin
return fetchVmTraceFromSnapshotTables(ctx, dbConn, vmID, vmUUID, name) return fetchVmTraceFromSnapshotTables(ctx, dbConn, vmID, vmUUID, name)
} }
// FetchVmTraceDaily returns one row per day for a VM, preferring vm_daily_rollup cache.
func FetchVmTraceDaily(ctx context.Context, dbConn *sqlx.DB, vmID, vmUUID, name string) ([]VmTraceRow, error) {
if TableExists(ctx, dbConn, "vm_daily_rollup") {
if err := EnsureVmDailyRollup(ctx, dbConn); err != nil {
slog.Warn("failed to ensure vm_daily_rollup indexes", "error", err)
}
rows, err := fetchVmTraceDailyFromRollup(ctx, dbConn, vmID, vmUUID, name)
if err != nil {
slog.Warn("vm daily trace cache query failed; falling back to daily summary tables", "error", err)
} else if len(rows) > 0 {
slog.Debug("vm daily trace loaded from daily rollup cache", "row_count", len(rows))
return rows, nil
}
}
return fetchVmTraceDailyFromSummaryTables(ctx, dbConn, vmID, vmUUID, name)
}
func fetchVmTraceDailyFromRollup(ctx context.Context, dbConn *sqlx.DB, vmID, vmUUID, name string) ([]VmTraceRow, error) {
matchWhere, args, ok := vmLookupPredicate(vmID, vmUUID, name)
if !ok {
return nil, nil
}
query := fmt.Sprintf(`
SELECT "Date" AS "SnapshotTime",
COALESCE("Name",'') AS "Name",
COALESCE("Vcenter",'') AS "Vcenter",
COALESCE("VmId",'') AS "VmId",
COALESCE("VmUuid",'') AS "VmUuid",
COALESCE("LastResourcePool",'') AS "ResourcePool",
CAST(CASE
WHEN COALESCE("SamplesPresent",0) > 0 THEN ROUND(1.0 * COALESCE("SumVcpu",0) / "SamplesPresent")
ELSE COALESCE("LastVcpuCount",0)
END AS BIGINT) AS "VcpuCount",
CAST(CASE
WHEN COALESCE("SamplesPresent",0) > 0 THEN ROUND(1.0 * COALESCE("SumRam",0) / "SamplesPresent")
ELSE COALESCE("LastRamGB",0)
END AS BIGINT) AS "RamGB",
COALESCE("LastProvisionedDisk",0) AS "ProvisionedDisk",
COALESCE("CreationTime",0) AS "CreationTime",
COALESCE("DeletionTime",0) AS "DeletionTime"
FROM vm_daily_rollup
WHERE %s
ORDER BY "Date"
`, matchWhere)
query = dbConn.Rebind(query)
rows := make([]VmTraceRow, 0, 128)
if err := selectLog(ctx, dbConn, &rows, query, args...); err != nil {
return nil, err
}
return rows, nil
}
func fetchVmTraceDailyFromSummaryTables(ctx context.Context, dbConn *sqlx.DB, vmID, vmUUID, name string) ([]VmTraceRow, error) {
matchWhere, args, ok := vmLookupPredicate(vmID, vmUUID, name)
if !ok {
return nil, nil
}
if !TableExists(ctx, dbConn, "snapshot_registry") {
return nil, nil
}
var tables []struct {
TableName string `db:"table_name"`
SnapshotTime int64 `db:"snapshot_time"`
}
if err := selectLog(ctx, dbConn, &tables, `
SELECT table_name, snapshot_time
FROM snapshot_registry
WHERE snapshot_type = 'daily'
ORDER BY snapshot_time
`); err != nil {
return nil, err
}
if len(tables) == 0 {
return nil, nil
}
rows := make([]VmTraceRow, 0, len(tables))
for _, t := range tables {
if err := ValidateTableName(t.TableName); err != nil {
continue
}
query := fmt.Sprintf(`
SELECT %d AS "SnapshotTime",
COALESCE("Name",'') AS "Name",
COALESCE("Vcenter",'') AS "Vcenter",
COALESCE("VmId",'') AS "VmId",
COALESCE("VmUuid",'') AS "VmUuid",
COALESCE("ResourcePool",'') AS "ResourcePool",
CAST(COALESCE("AvgVcpuCount","VcpuCount",0) AS BIGINT) AS "VcpuCount",
CAST(COALESCE("AvgRamGB","RamGB",0) AS BIGINT) AS "RamGB",
COALESCE("AvgProvisionedDisk","ProvisionedDisk",0) AS "ProvisionedDisk",
COALESCE("CreationTime",0) AS "CreationTime",
COALESCE("DeletionTime",0) AS "DeletionTime"
FROM %s
WHERE %s
`, t.SnapshotTime, t.TableName, matchWhere)
query = dbConn.Rebind(query)
var tmp []VmTraceRow
if err := selectLog(ctx, dbConn, &tmp, query, args...); err != nil {
continue
}
rows = append(rows, tmp...)
}
sort.Slice(rows, func(i, j int) bool {
return rows[i].SnapshotTime < rows[j].SnapshotTime
})
return rows, nil
}
func fetchVmTraceFromHourlyCache(ctx context.Context, dbConn *sqlx.DB, vmID, vmUUID, name string) ([]VmTraceRow, error) { func fetchVmTraceFromHourlyCache(ctx context.Context, dbConn *sqlx.DB, vmID, vmUUID, name string) ([]VmTraceRow, error) {
query := ` matchWhere, args, ok := vmLookupPredicate(vmID, vmUUID, name)
if !ok {
return nil, nil
}
query := fmt.Sprintf(`
SELECT "SnapshotTime","Name","Vcenter","VmId","VmUuid","ResourcePool","VcpuCount","RamGB","ProvisionedDisk", SELECT "SnapshotTime","Name","Vcenter","VmId","VmUuid","ResourcePool","VcpuCount","RamGB","ProvisionedDisk",
COALESCE("CreationTime",0) AS "CreationTime", COALESCE("CreationTime",0) AS "CreationTime",
COALESCE("DeletionTime",0) AS "DeletionTime" COALESCE("DeletionTime",0) AS "DeletionTime"
FROM vm_hourly_stats FROM vm_hourly_stats
WHERE ("VmId" = ? OR "VmUuid" = ? OR lower("Name") = lower(?)) WHERE %s
ORDER BY "SnapshotTime" ORDER BY "SnapshotTime"
` `, matchWhere)
query = dbConn.Rebind(query) query = dbConn.Rebind(query)
var rows []VmTraceRow var rows []VmTraceRow
if err := selectLog(ctx, dbConn, &rows, query, vmID, vmUUID, name); err != nil { if err := selectLog(ctx, dbConn, &rows, query, args...); err != nil {
return nil, err return nil, err
} }
return rows, nil return rows, nil
} }
func fetchVmTraceFromSnapshotTables(ctx context.Context, dbConn *sqlx.DB, vmID, vmUUID, name string) ([]VmTraceRow, error) { func fetchVmTraceFromSnapshotTables(ctx context.Context, dbConn *sqlx.DB, vmID, vmUUID, name string) ([]VmTraceRow, error) {
matchWhere, args, ok := vmLookupPredicate(vmID, vmUUID, name)
if !ok {
return nil, nil
}
var tables []struct { var tables []struct {
TableName string `db:"table_name"` TableName string `db:"table_name"`
SnapshotTime int64 `db:"snapshot_time"` SnapshotTime int64 `db:"snapshot_time"`
@@ -1716,7 +1856,6 @@ ORDER BY snapshot_time
} }
rows := make([]VmTraceRow, 0, len(tables)) rows := make([]VmTraceRow, 0, len(tables))
driver := strings.ToLower(dbConn.DriverName())
slog.Debug("vm trace scanning tables", "table_count", len(tables), "vm_id", vmID, "vm_uuid", vmUUID, "name", name) slog.Debug("vm trace scanning tables", "table_count", len(tables), "vm_id", vmID, "vm_uuid", vmUUID, "name", name)
@@ -1731,14 +1870,9 @@ SELECT %d AS "SnapshotTime",
COALESCE("CreationTime",0) AS "CreationTime", COALESCE("CreationTime",0) AS "CreationTime",
COALESCE("DeletionTime",0) AS "DeletionTime" COALESCE("DeletionTime",0) AS "DeletionTime"
FROM %s FROM %s
WHERE ("VmId" = ? OR "VmUuid" = ? OR lower("Name") = lower(?)) WHERE %s
`, t.SnapshotTime, t.TableName) `, t.SnapshotTime, t.TableName, matchWhere)
args := []interface{}{vmID, vmUUID, name} query = dbConn.Rebind(query)
if driver != "sqlite" {
query = strings.Replace(query, "?", "$1", 1)
query = strings.Replace(query, "?", "$2", 1)
query = strings.Replace(query, "?", "$3", 1)
}
var tmp []VmTraceRow var tmp []VmTraceRow
if err := selectLog(ctx, dbConn, &tmp, query, args...); err != nil { if err := selectLog(ctx, dbConn, &tmp, query, args...); err != nil {
slog.Warn("vm trace query failed for table", "table", t.TableName, "error", err) slog.Warn("vm trace query failed for table", "table", t.TableName, "error", err)
@@ -1778,6 +1912,10 @@ func FetchVmLifecycle(ctx context.Context, dbConn *sqlx.DB, vmID, vmUUID, name s
} }
func fetchVmLifecycleFromHourlyCache(ctx context.Context, dbConn *sqlx.DB, vmID, vmUUID, name string) (VmLifecycle, bool, error) { func fetchVmLifecycleFromHourlyCache(ctx context.Context, dbConn *sqlx.DB, vmID, vmUUID, name string) (VmLifecycle, bool, error) {
matchWhere, args, ok := vmLookupPredicate(vmID, vmUUID, name)
if !ok {
return VmLifecycle{}, false, nil
}
var row struct { var row struct {
Rows int64 `db:"rows"` Rows int64 `db:"rows"`
Creation sql.NullInt64 `db:"creation_time"` Creation sql.NullInt64 `db:"creation_time"`
@@ -1793,10 +1931,10 @@ SELECT
MAX("SnapshotTime") AS last_seen, MAX("SnapshotTime") AS last_seen,
MIN(NULLIF("DeletionTime",0)) AS deletion_time MIN(NULLIF("DeletionTime",0)) AS deletion_time
FROM vm_hourly_stats FROM vm_hourly_stats
WHERE ("VmId" = ? OR "VmUuid" = ? OR lower("Name") = lower(?)) WHERE ` + matchWhere + `
` `
query = dbConn.Rebind(query) query = dbConn.Rebind(query)
if err := getLog(ctx, dbConn, &row, query, vmID, vmUUID, name); err != nil { if err := getLog(ctx, dbConn, &row, query, args...); err != nil {
return VmLifecycle{}, false, err return VmLifecycle{}, false, err
} }
if row.Rows == 0 { if row.Rows == 0 {
@@ -1820,6 +1958,10 @@ WHERE ("VmId" = ? OR "VmUuid" = ? OR lower("Name") = lower(?))
} }
func fetchVmLifecycleFromLifecycleCache(ctx context.Context, dbConn *sqlx.DB, vmID, vmUUID, name string) (VmLifecycle, bool, error) { func fetchVmLifecycleFromLifecycleCache(ctx context.Context, dbConn *sqlx.DB, vmID, vmUUID, name string) (VmLifecycle, bool, error) {
matchWhere, args, ok := vmLookupPredicate(vmID, vmUUID, name)
if !ok {
return VmLifecycle{}, false, nil
}
var row struct { var row struct {
Rows int64 `db:"rows"` Rows int64 `db:"rows"`
FirstSeen sql.NullInt64 `db:"first_seen"` FirstSeen sql.NullInt64 `db:"first_seen"`
@@ -1833,10 +1975,10 @@ SELECT
MAX(NULLIF("LastSeen",0)) AS last_seen, MAX(NULLIF("LastSeen",0)) AS last_seen,
MIN(NULLIF("DeletedAt",0)) AS deletion_time MIN(NULLIF("DeletedAt",0)) AS deletion_time
FROM vm_lifecycle_cache FROM vm_lifecycle_cache
WHERE ("VmId" = ? OR "VmUuid" = ? OR lower("Name") = lower(?)) WHERE ` + matchWhere + `
` `
query = dbConn.Rebind(query) query = dbConn.Rebind(query)
if err := getLog(ctx, dbConn, &row, query, vmID, vmUUID, name); err != nil { if err := getLog(ctx, dbConn, &row, query, args...); err != nil {
return VmLifecycle{}, false, err return VmLifecycle{}, false, err
} }
if row.Rows == 0 { if row.Rows == 0 {
@@ -1884,6 +2026,10 @@ func mergeVmLifecycle(base, overlay VmLifecycle) VmLifecycle {
func fetchVmLifecycleFromSnapshotTables(ctx context.Context, dbConn *sqlx.DB, vmID, vmUUID, name string) (VmLifecycle, error) { func fetchVmLifecycleFromSnapshotTables(ctx context.Context, dbConn *sqlx.DB, vmID, vmUUID, name string) (VmLifecycle, error) {
var lifecycle VmLifecycle var lifecycle VmLifecycle
matchWhere, args, ok := vmLookupPredicate(vmID, vmUUID, name)
if !ok {
return lifecycle, nil
}
var tables []struct { var tables []struct {
TableName string `db:"table_name"` TableName string `db:"table_name"`
SnapshotTime int64 `db:"snapshot_time"` SnapshotTime int64 `db:"snapshot_time"`
@@ -1896,7 +2042,6 @@ ORDER BY snapshot_time
`); err != nil { `); err != nil {
return lifecycle, err return lifecycle, err
} }
driver := strings.ToLower(dbConn.DriverName())
minCreation := int64(0) minCreation := int64(0)
consecutiveMissing := 0 consecutiveMissing := 0
@@ -1907,14 +2052,9 @@ ORDER BY snapshot_time
query := fmt.Sprintf(` query := fmt.Sprintf(`
SELECT MIN(NULLIF("CreationTime",0)) AS min_creation, COUNT(1) AS cnt SELECT MIN(NULLIF("CreationTime",0)) AS min_creation, COUNT(1) AS cnt
FROM %s FROM %s
WHERE ("VmId" = ? OR "VmUuid" = ? OR lower("Name") = lower(?)) WHERE %s
`, t.TableName) `, t.TableName, matchWhere)
args := []interface{}{vmID, vmUUID, name} query = dbConn.Rebind(query)
if driver != "sqlite" {
query = strings.Replace(query, "?", "$1", 1)
query = strings.Replace(query, "?", "$2", 1)
query = strings.Replace(query, "?", "$3", 1)
}
var probe struct { var probe struct {
MinCreation sql.NullInt64 `db:"min_creation"` MinCreation sql.NullInt64 `db:"min_creation"`
Cnt int64 `db:"cnt"` Cnt int64 `db:"cnt"`

View File

@@ -126,6 +126,20 @@ INSERT INTO vm_hourly_stats (
if traceRows[0].SnapshotTime != 1000 || traceRows[1].SnapshotTime != 2000 { if traceRows[0].SnapshotTime != 1000 || traceRows[1].SnapshotTime != 2000 {
t.Fatalf("trace rows are not sorted by snapshot time: %#v", traceRows) t.Fatalf("trace rows are not sorted by snapshot time: %#v", traceRows)
} }
traceRowsByName, err := FetchVmTrace(ctx, dbConn, "", "", "DEMO-VM")
if err != nil {
t.Fatalf("FetchVmTrace by name failed: %v", err)
}
if len(traceRowsByName) != 2 {
t.Fatalf("expected 2 trace rows by name, got %d", len(traceRowsByName))
}
emptyTraceRows, err := FetchVmTrace(ctx, dbConn, "", "", "")
if err != nil {
t.Fatalf("FetchVmTrace with empty identifier failed: %v", err)
}
if len(emptyTraceRows) != 0 {
t.Fatalf("expected 0 trace rows for empty identifier, got %d", len(emptyTraceRows))
}
lifecycle, err := FetchVmLifecycle(ctx, dbConn, "vm-1", "", "") lifecycle, err := FetchVmLifecycle(ctx, dbConn, "vm-1", "", "")
if err != nil { if err != nil {
@@ -143,6 +157,125 @@ INSERT INTO vm_hourly_stats (
if lifecycle.DeletionTime != 2500 { if lifecycle.DeletionTime != 2500 {
t.Fatalf("expected DeletionTime=2500 from lifecycle cache, got %d", lifecycle.DeletionTime) t.Fatalf("expected DeletionTime=2500 from lifecycle cache, got %d", lifecycle.DeletionTime)
} }
lifecycleByName, err := FetchVmLifecycle(ctx, dbConn, "", "", "DEMO-VM")
if err != nil {
t.Fatalf("FetchVmLifecycle by name failed: %v", err)
}
if lifecycleByName.FirstSeen != 900 || lifecycleByName.LastSeen != 2000 {
t.Fatalf("unexpected lifecycle for name lookup: %#v", lifecycleByName)
}
emptyLifecycle, err := FetchVmLifecycle(ctx, dbConn, "", "", "")
if err != nil {
t.Fatalf("FetchVmLifecycle with empty identifier failed: %v", err)
}
if emptyLifecycle.FirstSeen != 0 || emptyLifecycle.LastSeen != 0 || emptyLifecycle.CreationTime != 0 || emptyLifecycle.DeletionTime != 0 {
t.Fatalf("expected empty lifecycle for empty identifier, got %#v", emptyLifecycle)
}
}
func TestFetchVmTraceDailyFromRollup(t *testing.T) {
ctx := context.Background()
dbConn := newTestSQLiteDB(t)
if err := EnsureVmDailyRollup(ctx, dbConn); err != nil {
t.Fatalf("failed to ensure vm_daily_rollup: %v", err)
}
if err := UpsertVmDailyRollup(ctx, dbConn, 1700000000, VmDailyRollupRow{
Vcenter: "vc-a",
VmId: "vm-1",
VmUuid: "uuid-1",
Name: "demo-vm",
CreationTime: 1699999000,
SamplesPresent: 8,
SumVcpu: 32,
SumRam: 64,
LastVcpuCount: 4,
LastRamGB: 8,
LastResourcePool: "Tin",
}); err != nil {
t.Fatalf("failed to insert daily rollup row 1: %v", err)
}
if err := UpsertVmDailyRollup(ctx, dbConn, 1700086400, VmDailyRollupRow{
Vcenter: "vc-a",
VmId: "vm-1",
VmUuid: "uuid-1",
Name: "demo-vm",
CreationTime: 1699999000,
SamplesPresent: 4,
SumVcpu: 20,
SumRam: 36,
LastVcpuCount: 5,
LastRamGB: 9,
LastResourcePool: "Gold",
LastProvisionedDisk: 150.5,
}); err != nil {
t.Fatalf("failed to insert daily rollup row 2: %v", err)
}
rows, err := FetchVmTraceDaily(ctx, dbConn, "vm-1", "", "")
if err != nil {
t.Fatalf("FetchVmTraceDaily failed: %v", err)
}
if len(rows) != 2 {
t.Fatalf("expected 2 daily trace rows, got %d", len(rows))
}
if rows[0].SnapshotTime != 1700000000 || rows[0].VcpuCount != 4 || rows[0].RamGB != 8 {
t.Fatalf("unexpected first daily row: %#v", rows[0])
}
if rows[1].SnapshotTime != 1700086400 || rows[1].VcpuCount != 5 || rows[1].RamGB != 9 || rows[1].ProvisionedDisk != 150.5 {
t.Fatalf("unexpected second daily row: %#v", rows[1])
}
}
func TestFetchVmTraceDailyFallbackToSummaryTables(t *testing.T) {
ctx := context.Background()
dbConn := newTestSQLiteDB(t)
if _, err := dbConn.ExecContext(ctx, `
CREATE TABLE snapshot_registry (
snapshot_type TEXT,
table_name TEXT,
snapshot_time BIGINT
)`); err != nil {
t.Fatalf("failed to create snapshot_registry: %v", err)
}
summaryTable := "inventory_daily_summary_20260106"
if _, err := dbConn.ExecContext(ctx, fmt.Sprintf(`
CREATE TABLE %s (
"Name" TEXT,
"Vcenter" TEXT,
"VmId" TEXT,
"VmUuid" TEXT,
"ResourcePool" TEXT,
"AvgVcpuCount" REAL,
"AvgRamGB" REAL,
"AvgProvisionedDisk" REAL,
"CreationTime" BIGINT,
"DeletionTime" BIGINT
)`, summaryTable)); err != nil {
t.Fatalf("failed to create summary table: %v", err)
}
if _, err := dbConn.ExecContext(ctx, fmt.Sprintf(`
INSERT INTO %s ("Name","Vcenter","VmId","VmUuid","ResourcePool","AvgVcpuCount","AvgRamGB","AvgProvisionedDisk","CreationTime","DeletionTime")
VALUES (?,?,?,?,?,?,?,?,?,?)
`, summaryTable), "demo-vm", "vc-a", "vm-1", "uuid-1", "Silver", 3.2, 6.7, 123.4, int64(1699999000), int64(0)); err != nil {
t.Fatalf("failed to insert summary row: %v", err)
}
if _, err := dbConn.ExecContext(ctx, `INSERT INTO snapshot_registry (snapshot_type, table_name, snapshot_time) VALUES (?,?,?)`, "daily", summaryTable, int64(1700500000)); err != nil {
t.Fatalf("failed to insert snapshot_registry row: %v", err)
}
rows, err := FetchVmTraceDaily(ctx, dbConn, "", "uuid-1", "")
if err != nil {
t.Fatalf("FetchVmTraceDaily fallback failed: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 fallback daily row, got %d", len(rows))
}
if rows[0].SnapshotTime != 1700500000 || rows[0].VcpuCount != 3 || rows[0].RamGB != 6 {
t.Fatalf("unexpected fallback daily row: %#v", rows[0])
}
} }
func TestParseHourlySnapshotUnix(t *testing.T) { func TestParseHourlySnapshotUnix(t *testing.T) {

View File

@@ -20,7 +20,7 @@ const (
// VcenterList renders a list of vCenters being monitored. // VcenterList renders a list of vCenters being monitored.
// @Summary List vCenters // @Summary List vCenters
// @Description Lists all vCenters with recorded snapshot totals. // @Description Lists all vCenters with recorded snapshot totals, linking to the fast daily aggregated totals page.
// @Tags vcenters // @Tags vcenters
// @Produce text/html // @Produce text/html
// @Success 200 {string} string "HTML page" // @Success 200 {string} string "HTML page"
@@ -72,7 +72,7 @@ func (h *Handler) VcenterTotals(w http.ResponseWriter, r *http.Request) {
// VcenterTotalsDaily renders the daily-aggregation totals page for one vCenter. // VcenterTotalsDaily renders the daily-aggregation totals page for one vCenter.
// @Summary vCenter daily totals // @Summary vCenter daily totals
// @Description Shows daily aggregated VM count/vCPU/RAM totals for a vCenter. // @Description Shows daily aggregated VM count/vCPU/RAM totals for a vCenter (cache-backed for fast loading).
// @Tags vcenters // @Tags vcenters
// @Produce text/html // @Produce text/html
// @Param vcenter query string true "vCenter URL" // @Param vcenter query string true "vCenter URL"

View File

@@ -3,20 +3,22 @@ package handler
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
"vctp/components/views" "vctp/components/views"
"vctp/db" "vctp/db"
) )
// VmTrace shows per-snapshot details for a VM across all snapshots. // VmTrace shows VM history in either hourly-detail or daily-aggregated mode.
// @Summary Trace VM history // @Summary Trace VM history
// @Description Shows VM resource history across snapshots, with chart and table. // @Description Shows VM resource history with an hourly detail view and a daily aggregated view, with chart and table output.
// @Tags vm // @Tags vm
// @Produce text/html // @Produce text/html
// @Param vm_id query string false "VM ID" // @Param vm_id query string false "VM ID"
// @Param vm_uuid query string false "VM UUID" // @Param vm_uuid query string false "VM UUID"
// @Param name query string false "VM name" // @Param name query string false "VM name"
// @Param view query string false "hourly|daily (default: hourly)"
// @Success 200 {string} string "HTML page" // @Success 200 {string} string "HTML page"
// @Failure 400 {string} string "Missing identifier" // @Failure 400 {string} string "Missing identifier"
// @Router /vm/trace [get] // @Router /vm/trace [get]
@@ -25,6 +27,11 @@ func (h *Handler) VmTrace(w http.ResponseWriter, r *http.Request) {
vmID := r.URL.Query().Get("vm_id") vmID := r.URL.Query().Get("vm_id")
vmUUID := r.URL.Query().Get("vm_uuid") vmUUID := r.URL.Query().Get("vm_uuid")
name := r.URL.Query().Get("name") name := r.URL.Query().Get("name")
viewType := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("view")))
if viewType != "daily" {
viewType = "hourly"
}
meta := buildVmTraceMeta(viewType, vmID, vmUUID, name)
var entries []views.VmTraceEntry var entries []views.VmTraceEntry
chart := views.VmTraceChart{} chart := views.VmTraceChart{}
@@ -39,12 +46,20 @@ func (h *Handler) VmTrace(w http.ResponseWriter, r *http.Request) {
// Only fetch data when a query is provided; otherwise render empty page with form. // Only fetch data when a query is provided; otherwise render empty page with form.
if vmID != "" || vmUUID != "" || name != "" { if vmID != "" || vmUUID != "" || name != "" {
h.Logger.Info("vm trace request", "vm_id", vmID, "vm_uuid", vmUUID, "name", name) h.Logger.Info("vm trace request", "view", viewType, "vm_id", vmID, "vm_uuid", vmUUID, "name", name)
lifecycle, lifeErr := db.FetchVmLifecycle(ctx, h.Database.DB(), vmID, vmUUID, name) lifecycle, lifeErr := db.FetchVmLifecycle(ctx, h.Database.DB(), vmID, vmUUID, name)
if lifeErr != nil { if lifeErr != nil {
h.Logger.Warn("failed to fetch VM lifecycle", "error", lifeErr) h.Logger.Warn("failed to fetch VM lifecycle", "error", lifeErr)
} }
rows, err := db.FetchVmTrace(ctx, h.Database.DB(), vmID, vmUUID, name) var (
rows []db.VmTraceRow
err error
)
if viewType == "daily" {
rows, err = db.FetchVmTraceDaily(ctx, h.Database.DB(), vmID, vmUUID, name)
} else {
rows, err = db.FetchVmTrace(ctx, h.Database.DB(), vmID, vmUUID, name)
}
if err != nil { if err != nil {
h.Logger.Error("failed to fetch VM trace", "error", err) h.Logger.Error("failed to fetch VM trace", "error", err)
http.Error(w, fmt.Sprintf("failed to fetch VM trace: %v", err), http.StatusInternalServerError) http.Error(w, fmt.Sprintf("failed to fetch VM trace: %v", err), http.StatusInternalServerError)
@@ -99,11 +114,47 @@ func (h *Handler) VmTrace(w http.ResponseWriter, r *http.Request) {
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := views.VmTracePage(queryLabel, displayQuery, vmID, vmUUID, name, creationLabel, deletionLabel, creationApprox, entries, chart).Render(ctx, w); err != nil { if err := views.VmTracePage(queryLabel, displayQuery, vmID, vmUUID, name, creationLabel, deletionLabel, creationApprox, entries, chart, meta).Render(ctx, w); err != nil {
http.Error(w, "Failed to render template", http.StatusInternalServerError) http.Error(w, "Failed to render template", http.StatusInternalServerError)
} }
} }
func buildVmTraceMeta(viewType, vmID, vmUUID, name string) views.VmTraceMeta {
if viewType != "daily" {
viewType = "hourly"
}
addQuery := func(targetView string) string {
q := url.Values{}
q.Set("view", targetView)
if strings.TrimSpace(vmID) != "" {
q.Set("vm_id", vmID)
}
if strings.TrimSpace(vmUUID) != "" {
q.Set("vm_uuid", vmUUID)
}
if strings.TrimSpace(name) != "" {
q.Set("name", name)
}
return "/vm/trace?" + q.Encode()
}
meta := views.VmTraceMeta{
ViewType: viewType,
TypeLabel: "Hourly",
HourlyLink: addQuery("hourly"),
DailyLink: addQuery("daily"),
HourlyClass: "web3-button",
DailyClass: "web3-button",
}
if viewType == "daily" {
meta.TypeLabel = "Daily"
meta.DailyClass = "web3-button active"
} else {
meta.HourlyClass = "web3-button active"
}
return meta
}
func buildVmTraceChart(entries []views.VmTraceEntry) views.VmTraceChart { func buildVmTraceChart(entries []views.VmTraceEntry) views.VmTraceChart {
if len(entries) == 0 { if len(entries) == 0 {
return views.VmTraceChart{} return views.VmTraceChart{}

View File

@@ -876,7 +876,7 @@ const docTemplate = `{
}, },
"/vcenters": { "/vcenters": {
"get": { "get": {
"description": "Lists all vCenters with recorded snapshot totals.", "description": "Lists all vCenters with recorded snapshot totals, linking to the fast daily aggregated totals page.",
"produces": [ "produces": [
"text/html" "text/html"
], ],
@@ -943,7 +943,7 @@ const docTemplate = `{
}, },
"/vcenters/totals/daily": { "/vcenters/totals/daily": {
"get": { "get": {
"description": "Shows daily aggregated VM count/vCPU/RAM totals for a vCenter.", "description": "Shows daily aggregated VM count/vCPU/RAM totals for a vCenter (cache-backed for fast loading).",
"produces": [ "produces": [
"text/html" "text/html"
], ],
@@ -1019,7 +1019,7 @@ const docTemplate = `{
}, },
"/vm/trace": { "/vm/trace": {
"get": { "get": {
"description": "Shows VM resource history across snapshots, with chart and table.", "description": "Shows VM resource history with an hourly detail view and a daily aggregated view, with chart and table output.",
"produces": [ "produces": [
"text/html" "text/html"
], ],
@@ -1045,6 +1045,12 @@ const docTemplate = `{
"description": "VM name", "description": "VM name",
"name": "name", "name": "name",
"in": "query" "in": "query"
},
{
"type": "string",
"description": "hourly|daily (default: hourly)",
"name": "view",
"in": "query"
} }
], ],
"responses": { "responses": {

View File

@@ -865,7 +865,7 @@
}, },
"/vcenters": { "/vcenters": {
"get": { "get": {
"description": "Lists all vCenters with recorded snapshot totals.", "description": "Lists all vCenters with recorded snapshot totals, linking to the fast daily aggregated totals page.",
"produces": [ "produces": [
"text/html" "text/html"
], ],
@@ -932,7 +932,7 @@
}, },
"/vcenters/totals/daily": { "/vcenters/totals/daily": {
"get": { "get": {
"description": "Shows daily aggregated VM count/vCPU/RAM totals for a vCenter.", "description": "Shows daily aggregated VM count/vCPU/RAM totals for a vCenter (cache-backed for fast loading).",
"produces": [ "produces": [
"text/html" "text/html"
], ],
@@ -1008,7 +1008,7 @@
}, },
"/vm/trace": { "/vm/trace": {
"get": { "get": {
"description": "Shows VM resource history across snapshots, with chart and table.", "description": "Shows VM resource history with an hourly detail view and a daily aggregated view, with chart and table output.",
"produces": [ "produces": [
"text/html" "text/html"
], ],
@@ -1034,6 +1034,12 @@
"description": "VM name", "description": "VM name",
"name": "name", "name": "name",
"in": "query" "in": "query"
},
{
"type": "string",
"description": "hourly|daily (default: hourly)",
"name": "view",
"in": "query"
} }
], ],
"responses": { "responses": {

View File

@@ -871,7 +871,8 @@ paths:
- snapshots - snapshots
/vcenters: /vcenters:
get: get:
description: Lists all vCenters with recorded snapshot totals. description: Lists all vCenters with recorded snapshot totals, linking to the
fast daily aggregated totals page.
produces: produces:
- text/html - text/html
responses: responses:
@@ -916,7 +917,8 @@ paths:
- vcenters - vcenters
/vcenters/totals/daily: /vcenters/totals/daily:
get: get:
description: Shows daily aggregated VM count/vCPU/RAM totals for a vCenter. description: Shows daily aggregated VM count/vCPU/RAM totals for a vCenter (cache-backed
for fast loading).
parameters: parameters:
- description: vCenter URL - description: vCenter URL
in: query in: query
@@ -967,7 +969,8 @@ paths:
- vcenters - vcenters
/vm/trace: /vm/trace:
get: get:
description: Shows VM resource history across snapshots, with chart and table. description: Shows VM resource history with an hourly detail view and a daily
aggregated view, with chart and table output.
parameters: parameters:
- description: VM ID - description: VM ID
in: query in: query
@@ -981,6 +984,10 @@ paths:
in: query in: query
name: name name: name
type: string type: string
- description: 'hourly|daily (default: hourly)'
in: query
name: view
type: string
produces: produces:
- text/html - text/html
responses: responses: