Compare commits

54 Commits
main ... dev

Author SHA1 Message Date
32ced35130 more metadata in reports
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-29 12:27:08 +11:00
ff783fb45a still working on creation/deletion times
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 15:19:10 +11:00
49484900ac sql fix
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 13:49:41 +11:00
aa6abb8cb2 bugfix hourly totals
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 13:27:05 +11:00
1f2783fc86 fix
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 13:14:05 +11:00
b9eae50f69 updated snapshots logic
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 09:47:51 +11:00
c566456ebd add configuration for monthly aggregation job timing
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 09:04:16 +11:00
ee01d8deac improve lifecycle data
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 08:49:04 +11:00
93b5769145 improve logging for pro-rata
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-27 21:40:41 +11:00
Nathan Coad
38480e52c0 improve vm deletion detection
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-27 14:20:30 +11:00
Nathan Coad
6981bd9994 even more diagnostics
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-27 11:21:47 +11:00
Nathan Coad
fe96172253 add diagnostic endpoint
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-27 11:02:39 +11:00
Nathan Coad
35b4a50cf6 try to fix pro-rata yet again
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-27 09:09:24 +11:00
73ec80bb6f update monthly aggregation and docs
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-23 15:35:10 +11:00
0d509179aa update daily aggregation to use hourly intervals
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-23 14:33:22 +11:00
e6c7596239 extreme logging
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-23 13:51:03 +11:00
b39865325a more logging
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-23 13:44:50 +11:00
b4a3c0fb3a in depth fix of deletion/creation data
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-23 13:02:58 +11:00
2caf2763f6 improve aggregation
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-23 12:19:28 +11:00
25564efa54 more accurate resource pool data in aggregation reports
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-23 11:59:52 +11:00
871d7c2024 more logging
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-23 11:02:30 +11:00
3671860b7d another fix to aggregation reports
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-23 10:11:14 +11:00
3e2d95d3b9 fix aggregation logic
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-23 09:38:08 +11:00
8a3481b966 fix creationtime in aggregations
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-23 07:29:59 +11:00
13adc159a2 more accurate deletion times in aggregations
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-22 20:50:29 +11:00
c8f04efd51 add more documentation
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-22 20:30:02 +11:00
Nathan Coad
68ee2838e4 fix deletiontime from event
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-22 15:13:40 +11:00
Nathan Coad
b0592a2539 fix daily aggregation sample count
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-22 14:27:27 +11:00
Nathan Coad
baea0cc85c update aggregation calculations
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-22 13:30:53 +11:00
Nathan Coad
ceadf42048 update godoc
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-22 12:52:28 +11:00
Nathan Coad
374d4921e1 update aggregation jobs
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-22 12:04:41 +11:00
Nathan Coad
7dc8f598c3 more logging in daily aggregation
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-22 10:50:03 +11:00
Nathan Coad
148df38219 fix daily aggregation
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-22 10:20:18 +11:00
0a2c529111 code refactor
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-21 14:40:37 +11:00
3cdf368bc4 re-apply minimum snapshot interval
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-21 14:17:40 +11:00
32d4a352dc reduced the places where we probe hourly tables
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-21 11:44:13 +11:00
b77f8671da improve concurrency handling for inventory job
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-01-21 11:21:51 +11:00
715b293894 [CI SKIP] add cache for docker hub images 2026-01-21 10:55:13 +11:00
2483091861 improve logging and concurrent vcenter inventory
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-21 10:25:04 +11:00
00805513c9 fix new-vm detection interval
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-21 09:36:19 +11:00
fd9cc185ce code re-org and bugfix hanging hourly snapshot
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-21 09:12:25 +11:00
c7c7fd3dc9 code cleanup
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-21 08:45:46 +11:00
d683d23bfc use 0 instead of start of aggregation window for creationtime in xlsx
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-20 20:02:33 +11:00
c8bb30c788 better handle skipped inventories
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-20 17:18:43 +11:00
7ea02be91a refactor code and improve daily cache handling of deleted VMs
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-20 16:46:07 +11:00
0517ef88c3 [CI SKIP] bugfixes for vm deletion tracking 2026-01-20 16:33:31 +11:00
a9e522cc84 improve scheduler
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-19 14:04:01 +11:00
e186644db7 add repair functionality
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-17 12:51:11 +11:00
22fa250a43 bugfixes for monthly aggregation
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-17 08:48:18 +11:00
1874b2c621 ensure we logout, fix aggregations
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-16 20:29:40 +11:00
a12fe5cad0 bugfixes
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-16 17:53:24 +11:00
1cd1046433 progress on go based aggregation
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-16 17:37:55 +11:00
6af49471b2 Merge branch 'main' of https://git.coadcorp.com/nathan/vctp2 into dev
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-16 16:56:11 +11:00
7b7afbf1d5 start work on dev branch [CI SKIP] 2026-01-16 16:28:19 +11:00
50 changed files with 6637 additions and 1074 deletions

View File

@@ -4,7 +4,7 @@ name: default
steps:
- name: restore-cache-with-filesystem
image: meltwater/drone-cache
image: cache.coadcorp.com/meltwater/drone-cache
pull: true
settings:
backend: "filesystem"
@@ -23,7 +23,7 @@ steps:
path: /go
- name: build
image: golang
image: cache.coadcorp.com/library/golang
environment:
CGO_ENABLED: 0
GOMODCACHE: '/drone/src/pkg.mod'
@@ -60,7 +60,7 @@ steps:
- ls -lah ./build/
- name: dell-sftp-deploy
image: hypervtechnics/drone-sftp
image: cache.coadcorp.com/hypervtechnics/drone-sftp
settings:
host: deft.dell.com
username:
@@ -76,7 +76,7 @@ steps:
verbose: true
- name: rebuild-cache-with-filesystem
image: meltwater/drone-cache
image: cache.coadcorp.com/meltwater/drone-cache
pull: true
#when:
# event:

View File

@@ -3,13 +3,29 @@ vCTP is a vSphere Chargeback Tracking Platform, designed for a specific customer
## Snapshots and Reports
- 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.
- 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).
- 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.
- 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.
- Prometheus metrics are exposed at `/metrics`:
- Snapshots/aggregations: `vctp_hourly_snapshots_total`, `vctp_hourly_snapshots_failed_total`, `vctp_hourly_snapshot_last_unix`, `vctp_hourly_snapshot_last_rows`, `vctp_daily_aggregations_total`, `vctp_daily_aggregations_failed_total`, `vctp_daily_aggregation_duration_seconds`, `vctp_monthly_aggregations_total`, `vctp_monthly_aggregations_failed_total`, `vctp_monthly_aggregation_duration_seconds`, `vctp_reports_available`
- vCenter health/perf: `vctp_vcenter_connect_failures_total{vcenter}`, `vctp_vcenter_snapshot_duration_seconds{vcenter}`, `vctp_vcenter_inventory_size{vcenter}`
## Prorating and Aggregation Logic
Daily aggregation runs per VM using sample counts for the day:
- `SamplesPresent`: count of snapshot samples in which the VM appears.
- `TotalSamples`: count of unique snapshot timestamps for the vCenter in the day.
- `AvgIsPresent`: `SamplesPresent / TotalSamples` (0 when `TotalSamples` is 0).
- `AvgVcpuCount`, `AvgRamGB`, `AvgProvisionedDisk` (daily): `sum(values_per_sample) / TotalSamples` to timeweight config changes and prorate partialday VMs.
- `PoolTinPct`, `PoolBronzePct`, `PoolSilverPct`, `PoolGoldPct` (daily): `(pool_hits / SamplesPresent) * 100`, so pool percentages reflect only the time the VM existed.
- `CreationTime`: only set when vCenter provides it; otherwise it remains `0`.
Monthly aggregation builds on daily summaries (or the daily rollup cache):
- For each VM, daily averages are converted to weighted sums: `daily_avg * daily_total_samples`.
- Monthly averages are `sum(weighted_sums) / monthly_total_samples` (per vCenter).
- Pool percentages are weighted the same way: `(daily_pool_pct / 100) * daily_total_samples`, summed, then divided by `monthly_total_samples` and multiplied by 100.
## RPM Layout (summary)
The RPM installs the service and defaults under `/usr/bin`, config under `/etc/dtms`, and data under `/var/lib/vctp`:
- Binary: `/usr/bin/vctp-linux-amd64`
@@ -19,7 +35,7 @@ The RPM installs the service and defaults under `/usr/bin`, config under `/etc/d
- Data: SQLite DB and reports default to `/var/lib/vctp` (reports under `/var/lib/vctp/reports`)
- Scripts: preinstall/postinstall handle directory creation and permissions.
## Settings File
# Settings File
Configuration now lives in the YAML settings file. By default the service reads
`/etc/dtms/vctp.yml`, or you can override it with the `-settings` flag.
@@ -27,7 +43,14 @@ Configuration now lives in the YAML settings file. By default the service reads
vctp -settings /path/to/vctp.yml
```
### Database Configuration
If you just want to run a single inventory snapshot across all configured vCenters and
exit (no scheduler/server), use:
```shell
vctp -settings /path/to/vctp.yml -run-inventory
```
## Database Configuration
By default the app uses SQLite and creates/opens `db.sqlite3`. You can opt into PostgreSQL
by updating the settings file:
@@ -48,13 +71,13 @@ settings:
PostgreSQL migrations live in `db/migrations_postgres`, while SQLite migrations remain in
`db/migrations`.
### Snapshot Retention
## Snapshot Retention
Hourly and daily snapshot table retention can be configured in the settings file:
- `settings.hourly_snapshot_max_age_days` (default: 60)
- `settings.daily_snapshot_max_age_months` (default: 12)
### Settings Reference
## Settings Reference
All configuration lives under the top-level `settings:` key in `vctp.yml`.
General:

1
components/core/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.go

1
components/views/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.go

View File

@@ -48,6 +48,37 @@ templ Index(info BuildInfo) {
<p class="mt-3 text-xl font-semibold">{info.GoVersion}</p>
</div>
</section>
<section class="grid gap-6 lg:grid-cols-3">
<div class="web2-card">
<h2 class="text-lg font-semibold mb-2">Overview</h2>
<p class="mt-2 text-sm text-slate-600">
vCTP is a vSphere Chargeback Tracking Platform.
</p>
</div>
<div class="web2-card">
<h2 class="text-lg font-semibold mb-2">Snapshots and Reports</h2>
<div class="mt-3 text-sm text-slate-600 web2-paragraphs">
<p>Hourly snapshots capture inventory per vCenter (concurrency via <code class="web2-code">hourly_snapshot_concurrency</code>).</p>
<p>Daily summaries aggregate the hourly snapshots for the day; monthly summaries aggregate daily summaries for the month (or hourly snapshots if configured).</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>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>Monthly aggregation reports include a Daily Totals sheet with full-day interval labels (YYYY-MM-DD to YYYY-MM-DD) and prorated totals.</p>
</div>
</div>
<div class="web2-card">
<h2 class="text-lg font-semibold mb-2">Prorating and Aggregation</h2>
<div class="mt-3 space-y-2 text-sm text-slate-600 web2-paragraphs">
<p>SamplesPresent is the count of snapshots in which the VM appears; TotalSamples is the count of unique snapshot times for the vCenter.</p>
<p>AvgIsPresent = SamplesPresent / TotalSamples (0 when TotalSamples is 0).</p>
<p>Daily AvgVcpuCount/AvgRamGB/AvgProvisionedDisk = sum of per-sample values divided by TotalSamples (time-weighted).</p>
<p>Daily pool percentages use pool hits divided by SamplesPresent, so they reflect only the time the VM existed.</p>
<p>Monthly aggregation weights daily averages by daily total samples, then divides by monthly total samples.</p>
<p>CreationTime is only set when vCenter provides it; otherwise it remains 0.</p>
</div>
</div>
</section>
</main>
</body>
@core.Footer()

View File

@@ -86,7 +86,7 @@ func Index(info BuildInfo) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</p></div></section></main></body>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</p></div></section><section class=\"grid gap-6 lg:grid-cols-3\"><div class=\"web2-card\"><h2 class=\"text-lg font-semibold mb-2\">Overview</h2><p class=\"mt-2 text-sm text-slate-600\">vCTP is a vSphere Chargeback Tracking Platform.</p></div><div class=\"web2-card\"><h2 class=\"text-lg font-semibold mb-2\">Snapshots and Reports</h2><div class=\"mt-3 text-sm text-slate-600 web2-paragraphs\"><p>Hourly snapshots capture inventory per vCenter (concurrency via <code class=\"web2-code\">hourly_snapshot_concurrency</code>).</p><p>Daily summaries aggregate the hourly snapshots for the day; monthly summaries aggregate daily summaries for the month (or hourly snapshots if configured).</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>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>Monthly aggregation reports include a Daily Totals sheet with full-day interval labels (YYYY-MM-DD to YYYY-MM-DD) and prorated totals.</p></div></div><div class=\"web2-card\"><h2 class=\"text-lg font-semibold mb-2\">Prorating and Aggregation</h2><div class=\"mt-3 space-y-2 text-sm text-slate-600 web2-paragraphs\"><p>SamplesPresent is the count of snapshots in which the VM appears; TotalSamples is the count of unique snapshot times for the vCenter.</p><p>AvgIsPresent = SamplesPresent / TotalSamples (0 when TotalSamples is 0).</p><p>Daily AvgVcpuCount/AvgRamGB/AvgProvisionedDisk = sum of per-sample values divided by TotalSamples (time-weighted).</p><p>Daily pool percentages use pool hits divided by SamplesPresent, so they reflect only the time the VM existed.</p><p>Monthly aggregation weights daily averages by daily total samples, then divides by monthly total samples.</p><p>CreationTime is only set when vCenter provides it; otherwise it remains 0.</p></div></div></section></main></body>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View File

@@ -35,7 +35,7 @@ type VmTraceChart struct {
YTicks []ChartTick
}
templ VmTracePage(query string, display_query string, vm_id string, vm_uuid string, vm_name string, creationLabel string, deletionLabel string, entries []VmTraceEntry, chart VmTraceChart) {
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) {
<!DOCTYPE html>
<html lang="en">
@core.Header()
@@ -124,6 +124,9 @@ templ VmTracePage(query string, display_query string, vm_id string, vm_uuid stri
<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">{creationLabel}</p>
if creationApprox {
<p class="text-xs text-slate-500 mt-1">Approximate (earliest snapshot)</p>
}
</div>
<div class="web2-card">
<p class="text-xs uppercase tracking-[0.15em] text-slate-500">Deletion time</p>

View File

@@ -43,7 +43,7 @@ type VmTraceChart struct {
YTicks []ChartTick
}
func VmTracePage(query string, display_query string, vm_id string, vm_uuid string, vm_name string, creationLabel string, deletionLabel string, entries []VmTraceEntry, chart VmTraceChart) templ.Component {
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 {
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
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -560,73 +560,57 @@ func VmTracePage(query string, display_query string, vm_id string, vm_uuid strin
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</p></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\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if creationApprox {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<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, 47, "</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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(deletionLabel)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 130, Col: 76}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 133, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</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, 48, "</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, 47, "<tr><td>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<tr><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, 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: 151, Col: 25}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 154, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, 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: 152, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, 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: 153, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(e.VmUuid)
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, 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: 154, Col: 23}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 155, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -634,12 +618,12 @@ func VmTracePage(query string, display_query string, vm_id string, vm_uuid strin
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(e.Vcenter)
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, 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: 155, Col: 24}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 156, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -647,60 +631,86 @@ func VmTracePage(query string, display_query string, vm_id string, vm_uuid strin
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, 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: 157, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, 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: 158, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, 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: 156, Col: 29}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 159, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</td><td class=\"text-right\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, 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: 157, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</td><td class=\"text-right\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, 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: 158, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</td><td class=\"text-right\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, 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: 160, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</td><td class=\"text-right\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, 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: 161, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</td><td class=\"text-right\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, 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: 159, Col: 72}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 162, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</td></tr>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</tbody></table></div></section></main></body>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</tbody></table></div></section></main></body>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -708,7 +718,7 @@ func VmTracePage(query string, display_query string, vm_id string, vm_uuid strin
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View File

@@ -33,11 +33,15 @@ func TableRowCount(ctx context.Context, dbConn *sqlx.DB, table string) (int64, e
if err := ValidateTableName(table); err != nil {
return 0, err
}
start := time.Now()
slog.Debug("db row count start", "table", table)
var count int64
query := fmt.Sprintf(`SELECT COUNT(*) FROM %s`, table)
if err := getLog(ctx, dbConn, &count, query); err != nil {
slog.Debug("db row count failed", "table", table, "duration", time.Since(start), "error", err)
return 0, err
}
slog.Debug("db row count complete", "table", table, "rows", count, "duration", time.Since(start))
return count, nil
}
@@ -75,16 +79,25 @@ func getLog(ctx context.Context, dbConn *sqlx.DB, dest interface{}, query string
slog.Debug("db get returned no rows", "query", strings.TrimSpace(query))
return err
}
// Soften logging for timeout/cancel scenarios commonly hit during best-effort probes.
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
slog.Debug("db get timed out", "query", strings.TrimSpace(query), "error", err)
} else {
slog.Warn("db get failed", "query", strings.TrimSpace(query), "error", err)
}
}
return err
}
func selectLog(ctx context.Context, dbConn *sqlx.DB, dest interface{}, query string, args ...interface{}) error {
err := dbConn.SelectContext(ctx, dest, query, args...)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
slog.Debug("db select timed out", "query", strings.TrimSpace(query), "error", err)
} else {
slog.Warn("db select failed", "query", strings.TrimSpace(query), "error", err)
}
}
return err
}
@@ -135,12 +148,22 @@ func TableHasRows(ctx context.Context, dbConn *sqlx.DB, table string) (bool, err
if err := ValidateTableName(table); err != nil {
return false, err
}
// Avoid hanging on locked tables; apply a short timeout.
if ctx == nil {
ctx = context.Background()
}
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 5*time.Second)
defer cancel()
query := fmt.Sprintf(`SELECT 1 FROM %s LIMIT 1`, table)
var exists int
if err := getLog(ctx, dbConn, &exists, query); err != nil {
if err == sql.ErrNoRows {
return false, nil
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return false, nil
}
return false, err
}
return true, nil
@@ -385,6 +408,7 @@ func ApplySQLiteTuning(ctx context.Context, dbConn *sqlx.DB) {
`PRAGMA synchronous=NORMAL;`,
`PRAGMA temp_store=MEMORY;`,
`PRAGMA optimize;`,
`PRAGMA busy_timeout=5000;`,
}
for _, pragma := range pragmas {
_, err = execLog(ctx, dbConn, pragma)
@@ -394,6 +418,399 @@ func ApplySQLiteTuning(ctx context.Context, dbConn *sqlx.DB) {
}
}
// CheckpointSQLite forces a WAL checkpoint (truncate) when using SQLite. No-op for other drivers.
func CheckpointSQLite(ctx context.Context, dbConn *sqlx.DB) error {
if strings.ToLower(dbConn.DriverName()) != "sqlite" {
return nil
}
if ctx == nil {
ctx = context.Background()
}
start := time.Now()
slog.Debug("sqlite checkpoint start")
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
_, err := dbConn.ExecContext(cctx, `PRAGMA wal_checkpoint(TRUNCATE);`)
if err != nil {
slog.Warn("sqlite checkpoint failed", "error", err, "duration", time.Since(start))
return err
}
slog.Debug("sqlite checkpoint complete", "duration", time.Since(start))
return nil
}
// EnsureVmHourlyStats creates the shared per-snapshot cache table used by Go aggregations.
func EnsureVmHourlyStats(ctx context.Context, dbConn *sqlx.DB) error {
ddl := `
CREATE TABLE IF NOT EXISTS vm_hourly_stats (
"SnapshotTime" BIGINT NOT NULL,
"Vcenter" TEXT NOT NULL,
"VmId" TEXT,
"VmUuid" TEXT,
"Name" TEXT,
"CreationTime" BIGINT,
"DeletionTime" BIGINT,
"ResourcePool" TEXT,
"Datacenter" TEXT,
"Cluster" TEXT,
"Folder" TEXT,
"ProvisionedDisk" REAL,
"VcpuCount" BIGINT,
"RamGB" BIGINT,
"IsTemplate" TEXT,
"PoweredOn" TEXT,
"SrmPlaceholder" TEXT,
PRIMARY KEY ("Vcenter","VmId","SnapshotTime")
);`
if _, err := execLog(ctx, dbConn, ddl); err != nil {
return err
}
_, _ = 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_snapshottime_idx ON vm_hourly_stats ("SnapshotTime")`)
return nil
}
// EnsureVmLifecycleCache creates an upsert cache for first/last seen VM info.
func EnsureVmLifecycleCache(ctx context.Context, dbConn *sqlx.DB) error {
ddl := `
CREATE TABLE IF NOT EXISTS vm_lifecycle_cache (
"Vcenter" TEXT NOT NULL,
"VmId" TEXT,
"VmUuid" TEXT,
"Name" TEXT,
"Cluster" TEXT,
"FirstSeen" BIGINT,
"LastSeen" BIGINT,
"DeletedAt" BIGINT,
PRIMARY KEY ("Vcenter","VmId","VmUuid")
);`
if _, err := execLog(ctx, dbConn, ddl); err != nil {
return err
}
_, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_lifecycle_cache_vmuuid_idx ON vm_lifecycle_cache ("VmUuid")`)
return nil
}
// UpsertVmLifecycleCache updates first/last seen info for a VM.
func UpsertVmLifecycleCache(ctx context.Context, dbConn *sqlx.DB, vcenter string, vmID, vmUUID, name, cluster string, seen time.Time, creation sql.NullInt64) error {
if err := EnsureVmLifecycleCache(ctx, dbConn); err != nil {
return err
}
driver := strings.ToLower(dbConn.DriverName())
bindType := sqlx.BindType(driver)
firstSeen := seen.Unix()
if creation.Valid && creation.Int64 > 0 && creation.Int64 < firstSeen {
firstSeen = creation.Int64
}
query := `
INSERT INTO vm_lifecycle_cache ("Vcenter","VmId","VmUuid","Name","Cluster","FirstSeen","LastSeen")
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT ("Vcenter","VmId","VmUuid") DO UPDATE SET
"Name"=EXCLUDED."Name",
"Cluster"=EXCLUDED."Cluster",
"LastSeen"=EXCLUDED."LastSeen",
"FirstSeen"=CASE
WHEN vm_lifecycle_cache."FirstSeen" IS NULL OR vm_lifecycle_cache."FirstSeen" = 0 THEN EXCLUDED."FirstSeen"
WHEN EXCLUDED."FirstSeen" IS NOT NULL AND EXCLUDED."FirstSeen" > 0 AND EXCLUDED."FirstSeen" < vm_lifecycle_cache."FirstSeen"
THEN EXCLUDED."FirstSeen"
ELSE vm_lifecycle_cache."FirstSeen"
END,
"DeletedAt"=NULL
`
query = sqlx.Rebind(bindType, query)
args := []interface{}{vcenter, vmID, vmUUID, name, cluster, firstSeen, seen.Unix()}
_, err := dbConn.ExecContext(ctx, query, args...)
if err != nil {
slog.Warn("lifecycle upsert exec failed", "vcenter", vcenter, "vm_id", vmID, "vm_uuid", vmUUID, "driver", driver, "args_len", len(args), "args", fmt.Sprint(args), "query", strings.TrimSpace(query), "error", err)
}
return err
}
// MarkVmDeleted updates lifecycle cache with a deletion timestamp, carrying optional name/cluster.
func MarkVmDeletedWithDetails(ctx context.Context, dbConn *sqlx.DB, vcenter, vmID, vmUUID, name, cluster string, deletedAt int64) error {
if err := EnsureVmLifecycleCache(ctx, dbConn); err != nil {
return err
}
driver := strings.ToLower(dbConn.DriverName())
bindType := sqlx.BindType(driver)
query := `
INSERT INTO vm_lifecycle_cache ("Vcenter","VmId","VmUuid","Name","Cluster","DeletedAt","FirstSeen","LastSeen")
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT ("Vcenter","VmId","VmUuid") DO UPDATE SET
"DeletedAt"=CASE
WHEN vm_lifecycle_cache."DeletedAt" IS NULL OR vm_lifecycle_cache."DeletedAt"=0 OR EXCLUDED."DeletedAt"<vm_lifecycle_cache."DeletedAt"
THEN EXCLUDED."DeletedAt"
ELSE vm_lifecycle_cache."DeletedAt"
END,
"LastSeen"=COALESCE(vm_lifecycle_cache."LastSeen", EXCLUDED."LastSeen"),
"FirstSeen"=COALESCE(vm_lifecycle_cache."FirstSeen", EXCLUDED."FirstSeen"),
"Name"=COALESCE(NULLIF(vm_lifecycle_cache."Name", ''), EXCLUDED."Name"),
"Cluster"=COALESCE(NULLIF(vm_lifecycle_cache."Cluster", ''), EXCLUDED."Cluster")
`
query = sqlx.Rebind(bindType, query)
args := []interface{}{vcenter, vmID, vmUUID, name, cluster, deletedAt, deletedAt, deletedAt}
_, err := dbConn.ExecContext(ctx, query, args...)
if err != nil {
slog.Warn("lifecycle delete exec failed", "vcenter", vcenter, "vm_id", vmID, "vm_uuid", vmUUID, "driver", driver, "args_len", len(args), "args", fmt.Sprint(args), "query", strings.TrimSpace(query), "error", err)
}
return err
}
// MarkVmDeletedFromEvent updates lifecycle cache with a deletion timestamp from vCenter events.
// Event times should override snapshot-derived timestamps, even if later.
func MarkVmDeletedFromEvent(ctx context.Context, dbConn *sqlx.DB, vcenter, vmID, vmUUID, name, cluster string, deletedAt int64) error {
if err := EnsureVmLifecycleCache(ctx, dbConn); err != nil {
return err
}
driver := strings.ToLower(dbConn.DriverName())
bindType := sqlx.BindType(driver)
query := `
INSERT INTO vm_lifecycle_cache ("Vcenter","VmId","VmUuid","Name","Cluster","DeletedAt","FirstSeen","LastSeen")
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT ("Vcenter","VmId","VmUuid") DO UPDATE SET
"DeletedAt"=CASE
WHEN EXCLUDED."DeletedAt" IS NOT NULL AND EXCLUDED."DeletedAt" > 0 THEN EXCLUDED."DeletedAt"
ELSE vm_lifecycle_cache."DeletedAt"
END,
"LastSeen"=COALESCE(vm_lifecycle_cache."LastSeen", EXCLUDED."LastSeen"),
"FirstSeen"=COALESCE(vm_lifecycle_cache."FirstSeen", EXCLUDED."FirstSeen"),
"Name"=COALESCE(NULLIF(vm_lifecycle_cache."Name", ''), EXCLUDED."Name"),
"Cluster"=COALESCE(NULLIF(vm_lifecycle_cache."Cluster", ''), EXCLUDED."Cluster")
`
query = sqlx.Rebind(bindType, query)
args := []interface{}{vcenter, vmID, vmUUID, name, cluster, deletedAt, deletedAt, deletedAt}
_, err := dbConn.ExecContext(ctx, query, args...)
if err != nil {
slog.Warn("lifecycle delete event exec failed", "vcenter", vcenter, "vm_id", vmID, "vm_uuid", vmUUID, "driver", driver, "args_len", len(args), "args", fmt.Sprint(args), "query", strings.TrimSpace(query), "error", err)
}
return err
}
// MarkVmDeleted updates lifecycle cache with a deletion timestamp (legacy signature).
func MarkVmDeleted(ctx context.Context, dbConn *sqlx.DB, vcenter, vmID, vmUUID string, deletedAt int64) error {
return MarkVmDeletedWithDetails(ctx, dbConn, vcenter, vmID, vmUUID, "", "", deletedAt)
}
// ApplyLifecycleDeletionToSummary updates DeletionTime values in a summary table from vm_lifecycle_cache.
func ApplyLifecycleDeletionToSummary(ctx context.Context, dbConn *sqlx.DB, summaryTable string, start, end int64) (int64, error) {
if err := ValidateTableName(summaryTable); err != nil {
return 0, err
}
if err := EnsureVmLifecycleCache(ctx, dbConn); err != nil {
return 0, err
}
query := fmt.Sprintf(`
UPDATE %[1]s
SET "DeletionTime" = (
SELECT MIN(l."DeletedAt")
FROM vm_lifecycle_cache l
WHERE l."Vcenter" = %[1]s."Vcenter"
AND l."DeletedAt" IS NOT NULL AND l."DeletedAt" > 0
AND l."DeletedAt" >= ? AND l."DeletedAt" < ?
AND (
(l."VmId" IS NOT NULL AND %[1]s."VmId" IS NOT NULL AND l."VmId" = %[1]s."VmId")
OR (l."VmUuid" IS NOT NULL AND %[1]s."VmUuid" IS NOT NULL AND l."VmUuid" = %[1]s."VmUuid")
OR (l."Name" IS NOT NULL AND %[1]s."Name" IS NOT NULL AND l."Name" = %[1]s."Name")
)
)
WHERE EXISTS (
SELECT 1 FROM vm_lifecycle_cache l
WHERE l."Vcenter" = %[1]s."Vcenter"
AND l."DeletedAt" IS NOT NULL AND l."DeletedAt" > 0
AND l."DeletedAt" >= ? AND l."DeletedAt" < ?
AND (
(l."VmId" IS NOT NULL AND %[1]s."VmId" IS NOT NULL AND l."VmId" = %[1]s."VmId")
OR (l."VmUuid" IS NOT NULL AND %[1]s."VmUuid" IS NOT NULL AND l."VmUuid" = %[1]s."VmUuid")
OR (l."Name" IS NOT NULL AND %[1]s."Name" IS NOT NULL AND l."Name" = %[1]s."Name")
)
);
`, summaryTable)
bind := dbConn.Rebind(query)
res, err := execLog(ctx, dbConn, bind, start, end, start, end)
if err != nil {
return 0, err
}
rows, err := res.RowsAffected()
if err != nil {
return 0, err
}
return rows, nil
}
// ApplyLifecycleCreationToSummary updates CreationTime values in a summary table from vm_lifecycle_cache.
func ApplyLifecycleCreationToSummary(ctx context.Context, dbConn *sqlx.DB, summaryTable string) (int64, error) {
if err := ValidateTableName(summaryTable); err != nil {
return 0, err
}
if err := EnsureVmLifecycleCache(ctx, dbConn); err != nil {
return 0, err
}
query := fmt.Sprintf(`
UPDATE %[1]s
SET "CreationTime" = (
SELECT MIN(l."FirstSeen")
FROM vm_lifecycle_cache l
WHERE l."Vcenter" = %[1]s."Vcenter"
AND l."FirstSeen" IS NOT NULL AND l."FirstSeen" > 0
AND (
(l."VmId" IS NOT NULL AND %[1]s."VmId" IS NOT NULL AND l."VmId" = %[1]s."VmId")
OR (l."VmUuid" IS NOT NULL AND %[1]s."VmUuid" IS NOT NULL AND l."VmUuid" = %[1]s."VmUuid")
OR (l."Name" IS NOT NULL AND %[1]s."Name" IS NOT NULL AND l."Name" = %[1]s."Name")
)
)
WHERE ("CreationTime" IS NULL OR "CreationTime" = 0)
AND EXISTS (
SELECT 1 FROM vm_lifecycle_cache l
WHERE l."Vcenter" = %[1]s."Vcenter"
AND l."FirstSeen" IS NOT NULL AND l."FirstSeen" > 0
AND (
(l."VmId" IS NOT NULL AND %[1]s."VmId" IS NOT NULL AND l."VmId" = %[1]s."VmId")
OR (l."VmUuid" IS NOT NULL AND %[1]s."VmUuid" IS NOT NULL AND l."VmUuid" = %[1]s."VmUuid")
OR (l."Name" IS NOT NULL AND %[1]s."Name" IS NOT NULL AND l."Name" = %[1]s."Name")
)
);
`, summaryTable)
bind := dbConn.Rebind(query)
res, err := execLog(ctx, dbConn, bind)
if err != nil {
return 0, err
}
rows, err := res.RowsAffected()
if err != nil {
return 0, err
}
return rows, nil
}
// UpsertVmDailyRollup writes/updates a daily rollup row.
func UpsertVmDailyRollup(ctx context.Context, dbConn *sqlx.DB, day int64, v VmDailyRollupRow) error {
if err := EnsureVmDailyRollup(ctx, dbConn); err != nil {
return err
}
driver := strings.ToLower(dbConn.DriverName())
query := `
INSERT INTO vm_daily_rollup (
"Date","Vcenter","VmId","VmUuid","Name","CreationTime","DeletionTime","SamplesPresent","TotalSamples",
"SumVcpu","SumRam","SumDisk","TinHits","BronzeHits","SilverHits","GoldHits",
"LastResourcePool","LastDatacenter","LastCluster","LastFolder",
"LastProvisionedDisk","LastVcpuCount","LastRamGB","IsTemplate","PoweredOn","SrmPlaceholder"
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26)
ON CONFLICT ("Date","Vcenter","VmId","VmUuid") DO UPDATE SET
"CreationTime"=LEAST(COALESCE(vm_daily_rollup."CreationTime", $6), COALESCE($6, vm_daily_rollup."CreationTime")),
"DeletionTime"=CASE
WHEN vm_daily_rollup."DeletionTime" IS NULL OR vm_daily_rollup."DeletionTime"=0 THEN $7
WHEN $7 IS NOT NULL AND $7 > 0 AND $7 < vm_daily_rollup."DeletionTime" THEN $7
ELSE vm_daily_rollup."DeletionTime" END,
"SamplesPresent"=$8,
"TotalSamples"=$9,
"SumVcpu"=$10,
"SumRam"=$11,
"SumDisk"=$12,
"TinHits"=$13,
"BronzeHits"=$14,
"SilverHits"=$15,
"GoldHits"=$16,
"LastResourcePool"=$17,
"LastDatacenter"=$18,
"LastCluster"=$19,
"LastFolder"=$20,
"LastProvisionedDisk"=$21,
"LastVcpuCount"=$22,
"LastRamGB"=$23,
"IsTemplate"=$24,
"PoweredOn"=$25,
"SrmPlaceholder"=$26
`
args := []interface{}{
day, v.Vcenter, v.VmId, v.VmUuid, v.Name, v.CreationTime, v.DeletionTime, v.SamplesPresent, v.TotalSamples,
v.SumVcpu, v.SumRam, v.SumDisk, v.TinHits, v.BronzeHits, v.SilverHits, v.GoldHits,
v.LastResourcePool, v.LastDatacenter, v.LastCluster, v.LastFolder, v.LastProvisionedDisk, v.LastVcpuCount, v.LastRamGB, v.IsTemplate, v.PoweredOn, v.SrmPlaceholder,
}
if driver == "sqlite" {
query = `
INSERT OR REPLACE INTO vm_daily_rollup (
"Date","Vcenter","VmId","VmUuid","Name","CreationTime","DeletionTime","SamplesPresent","TotalSamples",
"SumVcpu","SumRam","SumDisk","TinHits","BronzeHits","SilverHits","GoldHits",
"LastResourcePool","LastDatacenter","LastCluster","LastFolder",
"LastProvisionedDisk","LastVcpuCount","LastRamGB","IsTemplate","PoweredOn","SrmPlaceholder"
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
`
}
_, err := dbConn.ExecContext(ctx, query, args...)
return err
}
// VmDailyRollupRow represents the per-day cached aggregation.
type VmDailyRollupRow struct {
Vcenter string
VmId string
VmUuid string
Name string
CreationTime int64
DeletionTime int64
SamplesPresent int64
TotalSamples int64
SumVcpu float64
SumRam float64
SumDisk float64
TinHits int64
BronzeHits int64
SilverHits int64
GoldHits int64
LastResourcePool string
LastDatacenter string
LastCluster string
LastFolder string
LastProvisionedDisk float64
LastVcpuCount int64
LastRamGB int64
IsTemplate string
PoweredOn string
SrmPlaceholder string
}
// EnsureVmDailyRollup creates the per-day cache used by monthly aggregation.
func EnsureVmDailyRollup(ctx context.Context, dbConn *sqlx.DB) error {
ddl := `
CREATE TABLE IF NOT EXISTS vm_daily_rollup (
"Date" BIGINT NOT NULL,
"Vcenter" TEXT NOT NULL,
"VmId" TEXT,
"VmUuid" TEXT,
"Name" TEXT,
"CreationTime" BIGINT,
"DeletionTime" BIGINT,
"SamplesPresent" BIGINT,
"TotalSamples" BIGINT,
"SumVcpu" BIGINT,
"SumRam" BIGINT,
"SumDisk" REAL,
"TinHits" BIGINT,
"BronzeHits" BIGINT,
"SilverHits" BIGINT,
"GoldHits" BIGINT,
"LastResourcePool" TEXT,
"LastDatacenter" TEXT,
"LastCluster" TEXT,
"LastFolder" TEXT,
"LastProvisionedDisk" REAL,
"LastVcpuCount" BIGINT,
"LastRamGB" BIGINT,
"IsTemplate" TEXT,
"PoweredOn" TEXT,
"SrmPlaceholder" TEXT,
PRIMARY KEY ("Date","Vcenter","VmId","VmUuid")
);`
if _, err := execLog(ctx, dbConn, ddl); err != nil {
return err
}
_, _ = 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")`)
return nil
}
// EnsureVmIdentityTables creates the identity and rename audit tables.
func EnsureVmIdentityTables(ctx context.Context, dbConn *sqlx.DB) error {
driver := strings.ToLower(dbConn.DriverName())
@@ -735,6 +1152,7 @@ type VmTraceRow struct {
// VmLifecycle captures observed lifecycle times from hourly snapshots.
type VmLifecycle struct {
CreationTime int64
CreationApprox bool
FirstSeen int64
LastSeen int64
DeletionTime int64
@@ -818,6 +1236,7 @@ ORDER BY snapshot_time
driver := strings.ToLower(dbConn.DriverName())
minCreation := int64(0)
consecutiveMissing := 0
for _, t := range tables {
if err := ValidateTableName(t.TableName); err != nil {
continue
@@ -846,20 +1265,29 @@ WHERE ("VmId" = ? OR "VmUuid" = ? OR lower("Name") = lower(?))
lifecycle.FirstSeen = t.SnapshotTime
}
lifecycle.LastSeen = t.SnapshotTime
consecutiveMissing = 0
if probe.MinCreation.Valid {
if minCreation == 0 || probe.MinCreation.Int64 < minCreation {
minCreation = probe.MinCreation.Int64
}
}
} else if lifecycle.LastSeen > 0 && lifecycle.DeletionTime == 0 && t.SnapshotTime > lifecycle.LastSeen {
consecutiveMissing++
if consecutiveMissing >= 2 {
lifecycle.DeletionTime = t.SnapshotTime
break
}
} else {
// reset if we haven't seen the VM yet
consecutiveMissing = 0
}
}
if minCreation > 0 {
lifecycle.CreationTime = minCreation
lifecycle.CreationApprox = false
} else if lifecycle.FirstSeen > 0 {
lifecycle.CreationTime = lifecycle.FirstSeen
lifecycle.CreationApprox = true
}
return lifecycle, nil
}
@@ -934,9 +1362,13 @@ func AnalyzeTableIfPostgres(ctx context.Context, dbConn *sqlx.DB, tableName stri
if driver != "pgx" && driver != "postgres" {
return
}
start := time.Now()
slog.Debug("db analyze start", "table", tableName)
if _, err := execLog(ctx, dbConn, fmt.Sprintf(`ANALYZE %s`, tableName)); err != nil {
slog.Warn("failed to ANALYZE table", "table", tableName, "error", err)
return
}
slog.Debug("db analyze complete", "table", tableName, "duration", time.Since(start))
}
// SetPostgresWorkMem sets a per-session work_mem for heavy aggregations; no-op for other drivers.
@@ -1007,13 +1439,14 @@ func BuildDailySummaryInsert(tableName string, unionQuery string) (string, error
WITH snapshots AS (
%s
), totals AS (
SELECT COUNT(DISTINCT "SnapshotTime") AS total_samples, MAX("SnapshotTime") AS max_snapshot FROM snapshots
SELECT "Vcenter", COUNT(DISTINCT "SnapshotTime") AS total_samples, MAX("SnapshotTime") AS max_snapshot
FROM snapshots
GROUP BY "Vcenter"
), agg AS (
SELECT
s."InventoryId", s."Name", s."Vcenter", s."VmId", s."EventKey", s."CloudId",
MIN(NULLIF(s."CreationTime", 0)) AS any_creation,
MAX(NULLIF(s."DeletionTime", 0)) AS any_deletion,
MAX(COALESCE(inv."DeletionTime", 0)) AS inv_deletion,
MIN(s."SnapshotTime") AS first_present,
MAX(s."SnapshotTime") AS last_present,
COUNT(*) AS samples_present,
@@ -1030,7 +1463,6 @@ WITH snapshots AS (
SUM(CASE WHEN LOWER(s."ResourcePool") = 'silver' THEN 1 ELSE 0 END) AS silver_hits,
SUM(CASE WHEN LOWER(s."ResourcePool") = 'gold' THEN 1 ELSE 0 END) AS gold_hits
FROM snapshots s
LEFT JOIN inventory inv ON inv."VmId" = s."VmId" AND inv."Vcenter" = s."Vcenter"
GROUP BY
s."InventoryId", s."Name", s."Vcenter", s."VmId", s."EventKey", s."CloudId",
s."Datacenter", s."Cluster", s."Folder",
@@ -1039,16 +1471,15 @@ WITH snapshots AS (
INSERT INTO %s (
"InventoryId", "Name", "Vcenter", "VmId", "EventKey", "CloudId", "CreationTime", "DeletionTime",
"ResourcePool", "Datacenter", "Cluster", "Folder", "ProvisionedDisk", "VcpuCount",
"RamGB", "IsTemplate", "PoweredOn", "SrmPlaceholder", "VmUuid",
"RamGB", "IsTemplate", "PoweredOn", "SrmPlaceholder", "VmUuid", "SnapshotTime",
"SamplesPresent", "AvgVcpuCount", "AvgRamGB", "AvgProvisionedDisk", "AvgIsPresent",
"PoolTinPct", "PoolBronzePct", "PoolSilverPct", "PoolGoldPct",
"Tin", "Bronze", "Silver", "Gold"
)
SELECT
agg."InventoryId", agg."Name", agg."Vcenter", agg."VmId", agg."EventKey", agg."CloudId",
COALESCE(agg.any_creation, agg.first_present, 0) AS "CreationTime",
COALESCE(agg.any_creation, 0) AS "CreationTime",
CASE
WHEN NULLIF(agg.inv_deletion, 0) IS NOT NULL THEN NULLIF(agg.inv_deletion, 0)
WHEN totals.max_snapshot IS NOT NULL AND agg.last_present < totals.max_snapshot THEN COALESCE(
NULLIF(agg.any_deletion, 0),
(SELECT MIN(s2."SnapshotTime") FROM snapshots s2 WHERE s2."SnapshotTime" > agg.last_present),
@@ -1091,6 +1522,7 @@ SELECT
LIMIT 1
) AS "RamGB",
agg."IsTemplate", agg."PoweredOn", agg."SrmPlaceholder", agg."VmUuid",
agg.last_present AS "SnapshotTime",
agg.samples_present AS "SamplesPresent",
CASE WHEN totals.total_samples > 0
THEN 1.0 * agg.sum_vcpu / totals.total_samples
@@ -1129,17 +1561,51 @@ SELECT
THEN 100.0 * agg.gold_hits / agg.samples_present
ELSE NULL END AS "Gold"
FROM agg
CROSS JOIN totals
JOIN totals ON totals."Vcenter" = agg."Vcenter"
GROUP BY
agg."InventoryId", agg."Name", agg."Vcenter", agg."VmId", agg."EventKey", agg."CloudId",
agg."Datacenter", agg."Cluster", agg."Folder",
agg."IsTemplate", agg."PoweredOn", agg."SrmPlaceholder", agg."VmUuid",
agg.any_creation, agg.any_deletion, agg.first_present, agg.last_present,
totals.total_samples;
totals.total_samples, totals.max_snapshot;
`, unionQuery, tableName)
return insert, nil
}
// UpdateSummaryPresenceByWindow recomputes AvgIsPresent using CreationTime/DeletionTime overlap with the window.
func UpdateSummaryPresenceByWindow(ctx context.Context, dbConn *sqlx.DB, summaryTable string, windowStart, windowEnd int64) error {
if err := ValidateTableName(summaryTable); err != nil {
return err
}
if windowEnd <= windowStart {
return fmt.Errorf("invalid presence window: %d to %d", windowStart, windowEnd)
}
duration := float64(windowEnd - windowStart)
startExpr := `CASE WHEN "CreationTime" IS NOT NULL AND "CreationTime" > 0 AND "CreationTime" > ? THEN "CreationTime" ELSE ? END`
endExpr := `CASE WHEN "DeletionTime" IS NOT NULL AND "DeletionTime" > 0 AND "DeletionTime" < ? THEN "DeletionTime" ELSE ? END`
query := fmt.Sprintf(`
UPDATE %s
SET "AvgIsPresent" = CASE
WHEN ("CreationTime" IS NOT NULL AND "CreationTime" > 0) OR ("DeletionTime" IS NOT NULL AND "DeletionTime" > 0) THEN
CASE
WHEN %s > %s THEN (CAST((%s - %s) AS REAL) / ?)
ELSE 0
END
ELSE "AvgIsPresent"
END
`, summaryTable, endExpr, startExpr, endExpr, startExpr)
query = dbConn.Rebind(query)
args := []interface{}{
windowEnd, windowEnd,
windowStart, windowStart,
windowEnd, windowEnd,
windowStart, windowStart,
duration,
}
_, err := execLog(ctx, dbConn, query, args...)
return err
}
// RefineCreationDeletionFromUnion walks all snapshot rows in a period and tightens CreationTime/DeletionTime
// by using the first and last observed samples and the first sample after disappearance.
func RefineCreationDeletionFromUnion(ctx context.Context, dbConn *sqlx.DB, summaryTable, unionQuery string) error {
@@ -1174,12 +1640,11 @@ UPDATE %s dst
SET
"CreationTime" = CASE
WHEN t.any_creation IS NOT NULL AND t.any_creation > 0 THEN LEAST(COALESCE(NULLIF(dst."CreationTime", 0), t.any_creation), t.any_creation)
WHEN t.first_seen IS NOT NULL THEN LEAST(COALESCE(NULLIF(dst."CreationTime", 0), t.first_seen), t.first_seen)
ELSE dst."CreationTime"
END,
"DeletionTime" = CASE
WHEN t_last_after IS NOT NULL
AND (dst."DeletionTime" IS NULL OR dst."DeletionTime" = 0 OR t_last_after < dst."DeletionTime")
AND (dst."DeletionTime" IS NULL OR dst."DeletionTime" = 0)
THEN t_last_after
ELSE dst."DeletionTime"
END
@@ -1236,7 +1701,6 @@ SET
(
SELECT CASE
WHEN t.any_creation IS NOT NULL AND t.any_creation > 0 AND COALESCE(NULLIF(%[2]s."CreationTime", 0), t.any_creation) > t.any_creation THEN t.any_creation
WHEN t.any_creation IS NULL AND t.first_seen IS NOT NULL AND COALESCE(NULLIF(%[2]s."CreationTime", 0), t.first_seen) > t.first_seen THEN t.first_seen
ELSE NULL
END
FROM enriched t
@@ -1263,7 +1727,7 @@ SET
(%[2]s."Name" IS NOT NULL AND t."Name" IS NOT NULL AND %[2]s."Name" = t."Name")
)
AND t.first_after IS NOT NULL
AND ("DeletionTime" IS NULL OR "DeletionTime" = 0 OR t.first_after < "DeletionTime")
AND ("DeletionTime" IS NULL OR "DeletionTime" = 0)
LIMIT 1
),
"DeletionTime"
@@ -1318,8 +1782,13 @@ SELECT
COALESCE(NULLIF("CreationTime", 0), MIN(NULLIF("CreationTime", 0)), 0) AS "CreationTime",
NULLIF(MAX(NULLIF("DeletionTime", 0)), 0) AS "DeletionTime",
MAX("ResourcePool") AS "ResourcePool",
"Datacenter", "Cluster", "Folder", "ProvisionedDisk", "VcpuCount",
"RamGB", "IsTemplate", "PoweredOn", "SrmPlaceholder", "VmUuid",
"Datacenter", "Cluster", "Folder",
MAX("ProvisionedDisk") AS "ProvisionedDisk",
MAX("VcpuCount") AS "VcpuCount",
MAX("RamGB") AS "RamGB",
"IsTemplate",
MAX("PoweredOn") AS "PoweredOn",
"SrmPlaceholder", "VmUuid",
SUM("SamplesPresent") AS "SamplesPresent",
CASE WHEN totals.total_samples > 0
THEN SUM(CASE WHEN "AvgVcpuCount" IS NOT NULL THEN "AvgVcpuCount" * total_samples_day ELSE 0 END) / totals.total_samples
@@ -1361,8 +1830,8 @@ FROM enriched
CROSS JOIN totals
GROUP BY
"InventoryId", "Name", "Vcenter", "VmId", "EventKey", "CloudId",
"Datacenter", "Cluster", "Folder", "ProvisionedDisk", "VcpuCount",
"RamGB", "IsTemplate", "PoweredOn", "SrmPlaceholder", "VmUuid";
"Datacenter", "Cluster", "Folder",
"IsTemplate", "SrmPlaceholder", "VmUuid";
`, unionQuery, tableName)
return insert, nil
}
@@ -1407,6 +1876,7 @@ func EnsureSummaryTable(ctx context.Context, dbConn *sqlx.DB, tableName string)
"PoolBronzePct" REAL,
"PoolSilverPct" REAL,
"PoolGoldPct" REAL,
"SnapshotTime" BIGINT,
"Tin" REAL,
"Bronze" REAL,
"Silver" REAL,
@@ -1443,6 +1913,7 @@ func EnsureSummaryTable(ctx context.Context, dbConn *sqlx.DB, tableName string)
"PoolBronzePct" REAL,
"PoolSilverPct" REAL,
"PoolGoldPct" REAL,
"SnapshotTime" BIGINT,
"Tin" REAL,
"Bronze" REAL,
"Silver" REAL,
@@ -1458,6 +1929,10 @@ func EnsureSummaryTable(ctx context.Context, dbConn *sqlx.DB, tableName string)
if hasIsPresent, err := ColumnExists(ctx, dbConn, tableName, "IsPresent"); err == nil && hasIsPresent {
_, _ = execLog(ctx, dbConn, fmt.Sprintf(`ALTER TABLE %s DROP COLUMN "IsPresent"`, tableName))
}
// Ensure SnapshotTime exists for lifecycle refinement.
if hasSnapshot, err := ColumnExists(ctx, dbConn, tableName, "SnapshotTime"); err == nil && !hasSnapshot {
_, _ = execLog(ctx, dbConn, fmt.Sprintf(`ALTER TABLE %s ADD COLUMN "SnapshotTime" BIGINT`, tableName))
}
indexes := []string{
fmt.Sprintf(`CREATE INDEX IF NOT EXISTS %s_vm_vcenter_idx ON %s ("VmId","Vcenter")`, tableName, tableName),
@@ -1477,6 +1952,64 @@ func EnsureSummaryTable(ctx context.Context, dbConn *sqlx.DB, tableName string)
return nil
}
// BackfillSnapshotTimeFromUnion sets SnapshotTime in a summary table using the max snapshot time per VM from a union query.
func BackfillSnapshotTimeFromUnion(ctx context.Context, dbConn *sqlx.DB, summaryTable, unionQuery string) error {
if unionQuery == "" {
return fmt.Errorf("union query is empty")
}
if _, err := SafeTableName(summaryTable); err != nil {
return err
}
driver := strings.ToLower(dbConn.DriverName())
var sql string
switch driver {
case "pgx", "postgres":
sql = fmt.Sprintf(`
WITH snapshots AS (
%s
)
UPDATE %s dst
SET "SnapshotTime" = sub.max_time
FROM (
SELECT s."Vcenter", s."VmId", s."VmUuid", s."Name", MAX(s."SnapshotTime") AS max_time
FROM snapshots s
GROUP BY s."Vcenter", s."VmId", s."VmUuid", s."Name"
) sub
WHERE (dst."SnapshotTime" IS NULL OR dst."SnapshotTime" = 0)
AND dst."Vcenter" = sub."Vcenter"
AND (
(dst."VmId" IS NOT DISTINCT FROM sub."VmId")
OR (dst."VmUuid" IS NOT DISTINCT FROM sub."VmUuid")
OR (dst."Name" IS NOT DISTINCT FROM sub."Name")
);
`, unionQuery, summaryTable)
default:
sql = fmt.Sprintf(`
WITH snapshots AS (
%[1]s
), grouped AS (
SELECT s."Vcenter", s."VmId", s."VmUuid", s."Name", MAX(s."SnapshotTime") AS max_time
FROM snapshots s
GROUP BY s."Vcenter", s."VmId", s."VmUuid", s."Name"
)
UPDATE %[2]s
SET "SnapshotTime" = (
SELECT max_time FROM grouped g
WHERE %[2]s."Vcenter" = g."Vcenter"
AND (
(%[2]s."VmId" IS NOT NULL AND g."VmId" IS NOT NULL AND %[2]s."VmId" = g."VmId")
OR (%[2]s."VmUuid" IS NOT NULL AND g."VmUuid" IS NOT NULL AND %[2]s."VmUuid" = g."VmUuid")
OR (%[2]s."Name" IS NOT NULL AND g."Name" IS NOT NULL AND %[2]s."Name" = g."Name")
)
)
WHERE "SnapshotTime" IS NULL OR "SnapshotTime" = 0;
`, unionQuery, summaryTable)
}
_, err := execLog(ctx, dbConn, sql)
return err
}
// EnsureSnapshotRunTable creates a table to track per-vCenter hourly snapshot attempts.
func EnsureSnapshotRunTable(ctx context.Context, dbConn *sqlx.DB) error {
ddl := `

View File

@@ -2,12 +2,10 @@ package db
import (
"database/sql"
"fmt"
"log/slog"
"strings"
"vctp/db/queries"
//_ "github.com/tursodatabase/libsql-client-go/libsql"
"github.com/jmoiron/sqlx"
_ "modernc.org/sqlite"
)
@@ -38,8 +36,8 @@ func (d *LocalDB) Logger() *slog.Logger {
}
func (d *LocalDB) Close() error {
fmt.Println("Shutting database")
d.logger.Debug("test")
//fmt.Println("Shutting database")
d.logger.Debug("Shutting database")
return d.db.Close()
}

View File

@@ -29,6 +29,26 @@ body {
border-radius: 4px;
padding: 1.5rem 1.75rem;
}
.web2-card h2 {
position: relative;
padding-left: 0.75rem;
font-size: 1.05rem;
font-weight: 700;
letter-spacing: 0.02em;
color: #0b1220;
}
.web2-card h2::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 70%;
background: var(--web2-blue);
border-radius: 2px;
box-shadow: 0 0 0 1px rgba(29, 155, 240, 0.18);
}
.web2-pill {
display: inline-flex;
align-items: center;
@@ -41,6 +61,18 @@ body {
font-size: 0.85rem;
letter-spacing: 0.02em;
}
.web2-code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background: #f1f5f9;
border: 1px solid var(--web2-border);
border-radius: 3px;
padding: 0.1rem 0.35rem;
font-size: 0.85em;
color: #0f172a;
}
.web2-paragraphs p + p {
margin-top: 0.85rem;
}
.web2-link {
color: var(--web2-blue);
text-decoration: none;

View File

@@ -6,11 +6,13 @@ import (
"database/sql"
"fmt"
"log/slog"
"math"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"vctp/db"
@@ -33,6 +35,8 @@ type SnapshotMigrationStats struct {
Errors int
}
var hourlyTotalsQueryDumpOnce sync.Once
func ListTablesByPrefix(ctx context.Context, database db.Database, prefix string) ([]string, error) {
dbConn := database.DB()
driver := strings.ToLower(dbConn.DriverName())
@@ -169,7 +173,11 @@ func MigrateSnapshotRegistry(ctx context.Context, database db.Database) (Snapsho
}
if snapshotTime.IsZero() {
suffix := strings.TrimPrefix(table, "inventory_hourly_")
if parsed, parseErr := time.Parse("2006010215", suffix); parseErr == nil {
if parsed, parseErr := time.Parse("200601021504", suffix); parseErr == nil {
// Name encoded with date+hour+minute (e.g., 15-minute cadence)
snapshotTime = parsed
} else if parsed, parseErr := time.Parse("2006010215", suffix); parseErr == nil {
// Legacy hour-only encoding
snapshotTime = parsed
} else if epoch, parseErr := strconv.ParseInt(suffix, 10, 64); parseErr == nil {
snapshotTime = time.Unix(epoch, 0)
@@ -254,9 +262,17 @@ func RegisterSnapshot(ctx context.Context, database db.Database, snapshotType st
}
dbConn := database.DB()
driver := strings.ToLower(dbConn.DriverName())
start := time.Now()
slog.Debug("snapshot registry upsert start",
"type", snapshotType,
"table", tableName,
"snapshot_time", snapshotTime.Unix(),
"row_count", snapshotCount,
)
var err error
switch driver {
case "sqlite":
_, err := dbConn.ExecContext(ctx, `
_, err = dbConn.ExecContext(ctx, `
INSERT INTO snapshot_registry (snapshot_type, table_name, snapshot_time, snapshot_count)
VALUES (?, ?, ?, ?)
ON CONFLICT(table_name) DO UPDATE SET
@@ -264,9 +280,8 @@ ON CONFLICT(table_name) DO UPDATE SET
snapshot_type = excluded.snapshot_type,
snapshot_count = excluded.snapshot_count
`, snapshotType, tableName, snapshotTime.Unix(), snapshotCount)
return err
case "pgx", "postgres":
_, err := dbConn.ExecContext(ctx, `
_, err = dbConn.ExecContext(ctx, `
INSERT INTO snapshot_registry (snapshot_type, table_name, snapshot_time, snapshot_count)
VALUES ($1, $2, $3, $4)
ON CONFLICT (table_name) DO UPDATE SET
@@ -274,10 +289,24 @@ ON CONFLICT (table_name) DO UPDATE SET
snapshot_type = EXCLUDED.snapshot_type,
snapshot_count = EXCLUDED.snapshot_count
`, snapshotType, tableName, snapshotTime.Unix(), snapshotCount)
return err
default:
return fmt.Errorf("unsupported driver for snapshot registry: %s", driver)
}
if err != nil {
slog.Warn("snapshot registry upsert failed",
"type", snapshotType,
"table", tableName,
"duration", time.Since(start),
"error", err,
)
return err
}
slog.Debug("snapshot registry upsert complete",
"type", snapshotType,
"table", tableName,
"duration", time.Since(start),
)
return nil
}
func DeleteSnapshotRecord(ctx context.Context, database db.Database, tableName string) error {
@@ -460,6 +489,8 @@ func recordsFromTableNames(ctx context.Context, database db.Database, snapshotTy
TableName: table,
SnapshotTime: ts,
SnapshotType: snapshotType,
// Unknown row count when snapshot_registry isn't available.
SnapshotCount: -1,
})
}
}
@@ -518,6 +549,8 @@ func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Co
if err := db.ValidateTableName(tableName); err != nil {
return nil, err
}
start := time.Now()
logger.Debug("Create table report start", "table", tableName)
dbConn := Database.DB()
if strings.HasPrefix(tableName, "inventory_daily_summary_") || strings.HasPrefix(tableName, "inventory_monthly_summary_") {
@@ -527,11 +560,13 @@ func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Co
}
columns, err := tableColumns(ctx, dbConn, tableName)
if err != nil {
logger.Warn("Failed to load report columns", "table", tableName, "error", err)
return nil, err
}
if len(columns) == 0 {
return nil, fmt.Errorf("no columns found for table %s", tableName)
}
logger.Debug("Report columns loaded", "table", tableName, "columns", len(columns))
isHourlySnapshot := strings.HasPrefix(tableName, "inventory_hourly_")
isDailySummary := strings.HasPrefix(tableName, "inventory_daily_summary_")
@@ -612,9 +647,11 @@ func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Co
if orderBy != "" {
query = fmt.Sprintf(`%s ORDER BY "%s" %s`, query, orderBy, orderDir)
}
logger.Debug("Report query prepared", "table", tableName, "order_by", orderBy, "order_dir", orderDir, "template_filter", applyTemplateFilter)
rows, err := dbConn.QueryxContext(ctx, query)
if err != nil {
logger.Warn("Report query failed", "table", tableName, "error", err)
return nil, err
}
defer rows.Close()
@@ -664,6 +701,7 @@ func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Co
for rows.Next() {
values, err := scanRowValues(rows, len(columns))
if err != nil {
logger.Warn("Report row scan failed", "table", tableName, "error", err)
return nil, err
}
for colIndex, spec := range specs {
@@ -686,8 +724,11 @@ func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Co
rowIndex++
}
if err := rows.Err(); err != nil {
logger.Warn("Report row iteration failed", "table", tableName, "error", err)
return nil, err
}
rowCount := rowIndex - 2
logger.Debug("Report rows populated", "table", tableName, "rows", rowCount)
if err := xlsx.SetPanes(sheetName, &excelize.Panes{
Freeze: true,
@@ -708,18 +749,34 @@ func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Co
}
if isDailySummary || isMonthlySummary {
addReportMetadataSheet(logger, xlsx)
meta := reportMetadata{
TableName: tableName,
ReportType: reportTypeFromTable(tableName),
GeneratedAt: time.Now(),
Duration: time.Since(start),
RowCount: rowCount,
ColumnCount: len(columns),
DBDriver: Database.DB().DriverName(),
}
if windowStart, windowEnd, ok := reportWindowFromTable(tableName); ok {
meta.WindowStart = &windowStart
meta.WindowEnd = &windowEnd
}
addReportMetadataSheet(logger, xlsx, meta)
}
addTotalsChartSheet(logger, Database, ctx, xlsx, tableName)
logger.Debug("Report charts complete", "table", tableName)
if index, err := xlsx.GetSheetIndex(sheetName); err == nil {
xlsx.SetActiveSheet(index)
}
if err := xlsx.Write(&buffer); err != nil {
logger.Warn("Report write failed", "table", tableName, "error", err)
return nil, err
}
logger.Debug("Create table report complete", "table", tableName, "rows", rowCount, "bytes", buffer.Len(), "duration", time.Since(start))
return buffer.Bytes(), nil
}
@@ -731,38 +788,61 @@ func SaveTableReport(logger *slog.Logger, Database db.Database, ctx context.Cont
if strings.TrimSpace(destDir) == "" {
return "", fmt.Errorf("destination directory is empty")
}
start := time.Now()
logger.Debug("Save table report start", "table", tableName, "dest", destDir)
if err := os.MkdirAll(destDir, 0o755); err != nil {
logger.Warn("Report directory create failed", "table", tableName, "dest", destDir, "error", err)
return "", fmt.Errorf("failed to create reports directory: %w", err)
}
logger.Debug("Report directory ready", "dest", destDir)
data, err := CreateTableReport(logger, Database, ctx, tableName)
if err != nil {
logger.Warn("Report render failed", "table", tableName, "error", err)
return "", err
}
logger.Debug("Report rendered", "table", tableName, "bytes", len(data))
filename := filepath.Join(destDir, fmt.Sprintf("%s.xlsx", tableName))
if err := os.WriteFile(filename, data, 0o644); err != nil {
logger.Warn("Report write failed", "table", tableName, "file", filename, "error", err)
return "", err
}
logger.Debug("Save table report complete", "table", tableName, "file", filename, "duration", time.Since(start))
return filename, nil
}
func addTotalsChartSheet(logger *slog.Logger, database db.Database, ctx context.Context, xlsx *excelize.File, tableName string) {
if logger == nil {
logger = slog.Default()
}
if strings.HasPrefix(tableName, "inventory_daily_summary_") {
suffix := strings.TrimPrefix(tableName, "inventory_daily_summary_")
dayStart, err := time.ParseInLocation("20060102", suffix, time.Local)
if err != nil {
logger.Debug("hourly totals skip: invalid daily summary suffix", "table", tableName, "suffix", suffix, "error", err)
return
}
dayEnd := dayStart.AddDate(0, 0, 1)
if err := EnsureSnapshotRegistry(ctx, database); err != nil {
logger.Debug("hourly totals skip: snapshot registry unavailable", "table", tableName, "error", err)
return
}
records, err := SnapshotRecordsWithFallback(ctx, database, "hourly", "inventory_hourly_", "epoch", dayStart, dayEnd)
if err != nil || len(records) == 0 {
records, err := SnapshotRecordsWithFallback(ctx, database, "hourly", "inventory_hourly_", "epoch", dayStart, dayEnd.Add(2*time.Hour))
if err != nil {
logger.Debug("hourly totals skip: failed to load hourly snapshots", "table", tableName, "error", err)
return
}
points, err := buildHourlyTotals(ctx, database.DB(), records)
if err != nil || len(points) == 0 {
if len(records) == 0 {
logger.Debug("hourly totals skip: no hourly snapshots found", "table", tableName, "window_start", dayStart, "window_end", dayEnd)
return
}
points, err := buildHourlyTotals(ctx, logger, database.DB(), records, dayStart, dayEnd)
if err != nil {
logger.Debug("hourly totals skip: build failed", "table", tableName, "error", err)
return
}
if len(points) == 0 {
logger.Debug("hourly totals skip: no hourly totals points", "table", tableName, "window_start", dayStart, "window_end", dayEnd)
return
}
writeTotalsChart(logger, xlsx, "Hourly Totals", points)
@@ -773,18 +853,30 @@ func addTotalsChartSheet(logger *slog.Logger, database db.Database, ctx context.
suffix := strings.TrimPrefix(tableName, "inventory_monthly_summary_")
monthStart, err := time.ParseInLocation("200601", suffix, time.Local)
if err != nil {
logger.Debug("daily totals skip: invalid monthly summary suffix", "table", tableName, "suffix", suffix, "error", err)
return
}
monthEnd := monthStart.AddDate(0, 1, 0)
if err := EnsureSnapshotRegistry(ctx, database); err != nil {
logger.Debug("daily totals skip: snapshot registry unavailable", "table", tableName, "error", err)
return
}
records, err := SnapshotRecordsWithFallback(ctx, database, "daily", "inventory_daily_summary_", "20060102", monthStart, monthEnd)
if err != nil || len(records) == 0 {
if err != nil {
logger.Debug("daily totals skip: failed to load daily snapshots", "table", tableName, "error", err)
return
}
if len(records) == 0 {
logger.Debug("daily totals skip: no daily snapshots found", "table", tableName, "window_start", monthStart, "window_end", monthEnd)
return
}
points, err := buildDailyTotals(ctx, database.DB(), records, true)
if err != nil || len(points) == 0 {
if err != nil {
logger.Debug("daily totals skip: build failed", "table", tableName, "error", err)
return
}
if len(points) == 0 {
logger.Debug("daily totals skip: no daily totals points", "table", tableName, "window_start", monthStart, "window_end", monthEnd)
return
}
writeTotalsChart(logger, xlsx, "Daily Totals", points)
@@ -979,14 +1071,102 @@ func summaryReportOrder() []string {
}
}
func addReportMetadataSheet(logger *slog.Logger, xlsx *excelize.File) {
type reportMetadata struct {
TableName string
ReportType string
GeneratedAt time.Time
Duration time.Duration
RowCount int
ColumnCount int
WindowStart *time.Time
WindowEnd *time.Time
DBDriver string
}
func reportTypeFromTable(tableName string) string {
switch {
case strings.HasPrefix(tableName, "inventory_daily_summary_"):
return "daily"
case strings.HasPrefix(tableName, "inventory_monthly_summary_"):
return "monthly"
default:
return "unknown"
}
}
func reportWindowFromTable(tableName string) (time.Time, time.Time, bool) {
if strings.HasPrefix(tableName, "inventory_daily_summary_") {
suffix := strings.TrimPrefix(tableName, "inventory_daily_summary_")
dayStart, err := time.ParseInLocation("20060102", suffix, time.Local)
if err != nil {
return time.Time{}, time.Time{}, false
}
return dayStart, dayStart.AddDate(0, 0, 1), true
}
if strings.HasPrefix(tableName, "inventory_monthly_summary_") {
suffix := strings.TrimPrefix(tableName, "inventory_monthly_summary_")
monthStart, err := time.ParseInLocation("200601", suffix, time.Local)
if err != nil {
return time.Time{}, time.Time{}, false
}
return monthStart, monthStart.AddDate(0, 1, 0), true
}
return time.Time{}, time.Time{}, false
}
func addReportMetadataSheet(logger *slog.Logger, xlsx *excelize.File, meta reportMetadata) {
sheetName := "Metadata"
if _, err := xlsx.NewSheet(sheetName); err != nil {
logger.Error("Error creating metadata sheet", "error", err)
return
}
xlsx.SetCellValue(sheetName, "A1", "ReportGeneratedAt")
xlsx.SetCellValue(sheetName, "B1", time.Now().Format(time.RFC3339))
rows := []struct {
key string
value interface{}
}{
{"ReportTable", meta.TableName},
{"ReportType", meta.ReportType},
{"ReportGeneratedAt", meta.GeneratedAt.Format(time.RFC3339)},
{"ReportGeneratedAtUTC", meta.GeneratedAt.UTC().Format(time.RFC3339)},
{"ReportDuration", meta.Duration.String()},
{"ReportDurationSeconds", math.Round(meta.Duration.Seconds()*1000) / 1000},
{"RowCount", meta.RowCount},
{"ColumnCount", meta.ColumnCount},
}
if meta.WindowStart != nil && meta.WindowEnd != nil {
rows = append(rows,
struct {
key string
value interface{}
}{"DataWindowStart", meta.WindowStart.Format(time.RFC3339)},
struct {
key string
value interface{}
}{"DataWindowEnd", meta.WindowEnd.Format(time.RFC3339)},
struct {
key string
value interface{}
}{"DataWindowTimezone", time.Local.String()},
)
}
if meta.DBDriver != "" {
rows = append(rows, struct {
key string
value interface{}
}{"DatabaseDriver", meta.DBDriver})
}
if meta.Duration > 0 && meta.RowCount > 0 {
rows = append(rows, struct {
key string
value interface{}
}{"RowsPerSecond", math.Round((float64(meta.RowCount)/meta.Duration.Seconds())*1000) / 1000})
}
for i, row := range rows {
cellKey := fmt.Sprintf("A%d", i+1)
cellVal := fmt.Sprintf("B%d", i+1)
xlsx.SetCellValue(sheetName, cellKey, row.key)
xlsx.SetCellValue(sheetName, cellVal, row.value)
}
if err := SetColAutoWidth(xlsx, sheetName); err != nil {
logger.Error("Error setting metadata auto width", "error", err)
}
@@ -1019,7 +1199,7 @@ func normalizeCellValue(value interface{}) interface{} {
type totalsPoint struct {
Label string
VmCount int64
VmCount float64
VcpuTotal float64
RamTotal float64
PresenceRatio float64
@@ -1029,28 +1209,260 @@ type totalsPoint struct {
GoldTotal float64
}
func buildHourlyTotals(ctx context.Context, dbConn *sqlx.DB, records []SnapshotRecord) ([]totalsPoint, error) {
points := make([]totalsPoint, 0, len(records))
for _, record := range records {
func buildHourlyTotals(ctx context.Context, logger *slog.Logger, dbConn *sqlx.DB, records []SnapshotRecord, windowStart, windowEnd time.Time) ([]totalsPoint, error) {
if logger == nil {
logger = slog.Default()
}
if windowEnd.Before(windowStart) {
return nil, fmt.Errorf("hourly totals window end is before start")
}
sort.Slice(records, func(i, j int) bool {
return records[i].SnapshotTime.Before(records[j].SnapshotTime)
})
expectedInterval := estimateSnapshotInterval(records)
maxLag := expectedInterval
if maxLag <= 0 {
maxLag = time.Hour
}
points := make([]totalsPoint, 0, 24)
hourStart := windowStart.Truncate(time.Hour)
if hourStart.Before(windowStart) {
hourStart = hourStart.Add(time.Hour)
}
recordIndex := 0
for hourEnd := hourStart.Add(time.Hour); !hourEnd.After(windowEnd); hourEnd = hourEnd.Add(time.Hour) {
hourWindowStart := hourEnd.Add(-time.Hour)
var selected *SnapshotRecord
selectedIndex := recordIndex
for selectedIndex < len(records) {
record := records[selectedIndex]
if record.SnapshotTime.Before(hourEnd) {
selectedIndex++
continue
}
if record.SnapshotTime.After(hourEnd.Add(maxLag)) {
break
}
if err := db.ValidateTableName(record.TableName); err != nil {
return nil, err
}
if rowsExist, err := db.TableHasRows(ctx, dbConn, record.TableName); err != nil || !rowsExist {
if record.SnapshotCount == 0 {
logger.Debug("hourly totals skipping empty snapshot", "table", record.TableName, "snapshot_time", record.SnapshotTime)
selectedIndex++
continue
}
if record.SnapshotCount < 0 {
rowsExist, err := db.TableHasRows(ctx, dbConn, record.TableName)
if err != nil {
logger.Debug("hourly totals snapshot probe failed", "table", record.TableName, "snapshot_time", record.SnapshotTime, "error", err)
}
if err != nil || !rowsExist {
selectedIndex++
continue
}
}
selected = &record
break
}
if selected == nil {
logger.Debug(
"hourly totals missing snapshot for interval",
"interval_start", hourWindowStart.Format("2006-01-02 15:04"),
"interval_end", hourEnd.Format("2006-01-02 15:04"),
"max_lag_seconds", int64(maxLag.Seconds()),
)
recordIndex = selectedIndex
continue
}
var prev *SnapshotRecord
for prevIndex := selectedIndex - 1; prevIndex >= 0; prevIndex-- {
record := records[prevIndex]
if record.SnapshotTime.After(hourEnd) {
continue
}
if err := db.ValidateTableName(record.TableName); err != nil {
return nil, err
}
if record.SnapshotCount == 0 {
continue
}
if record.SnapshotCount < 0 {
rowsExist, err := db.TableHasRows(ctx, dbConn, record.TableName)
if err != nil || !rowsExist {
continue
}
}
prev = &record
break
}
recordIndex = selectedIndex
hourStartUnix := hourWindowStart.Unix()
hourEndUnix := hourEnd.Unix()
durationSeconds := float64(hourEndUnix - hourStartUnix)
prevTableName := selected.TableName
if prev != nil {
prevTableName = prev.TableName
}
startExpr := `CASE WHEN "CreationTime" IS NOT NULL AND "CreationTime" > 0 AND "CreationTime" > ? THEN "CreationTime" ELSE ? END`
endExpr := `CASE WHEN "DeletionTime" IS NOT NULL AND "DeletionTime" > 0 AND "DeletionTime" < ? THEN "DeletionTime" ELSE ? END`
overlapExpr := fmt.Sprintf(`CASE WHEN %s > %s THEN (CAST((%s - %s) AS REAL) / ?) ELSE 0 END`, endExpr, startExpr, endExpr, startExpr)
missingStartExpr := `CASE WHEN COALESCE(prev_agg.creation_time, lifecycle.first_seen) IS NOT NULL AND COALESCE(prev_agg.creation_time, lifecycle.first_seen) > 0 AND COALESCE(prev_agg.creation_time, lifecycle.first_seen) > ? THEN COALESCE(prev_agg.creation_time, lifecycle.first_seen) ELSE ? END`
missingEndExpr := `CASE WHEN COALESCE(lifecycle.deleted_at, prev_agg.deletion_time) IS NOT NULL AND COALESCE(lifecycle.deleted_at, prev_agg.deletion_time) > 0 AND COALESCE(lifecycle.deleted_at, prev_agg.deletion_time) < ? THEN COALESCE(lifecycle.deleted_at, prev_agg.deletion_time) ELSE ? END`
missingOverlapExpr := fmt.Sprintf(`CASE WHEN %s > %s THEN (CAST((%s - %s) AS REAL) / ?) ELSE 0 END`, missingEndExpr, missingStartExpr, missingEndExpr, missingStartExpr)
aggStartExpr := `CASE WHEN COALESCE(agg.creation_time, lifecycle.first_seen) IS NOT NULL AND COALESCE(agg.creation_time, lifecycle.first_seen) > 0 AND COALESCE(agg.creation_time, lifecycle.first_seen) > ? THEN COALESCE(agg.creation_time, lifecycle.first_seen) ELSE ? END`
aggEndExpr := `CASE WHEN COALESCE(lifecycle.deleted_at, agg.deletion_time) IS NOT NULL AND COALESCE(lifecycle.deleted_at, agg.deletion_time) > 0 AND COALESCE(lifecycle.deleted_at, agg.deletion_time) < ? THEN COALESCE(lifecycle.deleted_at, agg.deletion_time) ELSE ? END`
aggOverlapExpr := fmt.Sprintf(`CASE WHEN %s > %s THEN (CAST((%s - %s) AS REAL) / ?) ELSE 0 END`, aggEndExpr, aggStartExpr, aggEndExpr, aggStartExpr)
idExpr := `COALESCE(NULLIF("VmId", ''), NULLIF("VmUuid", ''), NULLIF("Name", ''), 'unknown')`
vmKeyExpr := fmt.Sprintf(`(%s || '|' || COALESCE("Vcenter", ''))`, idExpr)
query := fmt.Sprintf(`
WITH base AS (
SELECT
COUNT(DISTINCT "VmId") AS vm_count,
COALESCE(SUM(CASE WHEN "VcpuCount" IS NOT NULL THEN "VcpuCount" ELSE 0 END), 0) AS vcpu_total,
COALESCE(SUM(CASE WHEN "RamGB" IS NOT NULL THEN "RamGB" ELSE 0 END), 0) AS ram_total,
1.0 AS presence_ratio,
COALESCE(SUM(CASE WHEN LOWER("ResourcePool") = 'tin' THEN 1 ELSE 0 END), 0) AS tin_total,
COALESCE(SUM(CASE WHEN LOWER("ResourcePool") = 'bronze' THEN 1 ELSE 0 END), 0) AS bronze_total,
COALESCE(SUM(CASE WHEN LOWER("ResourcePool") = 'silver' THEN 1 ELSE 0 END), 0) AS silver_total,
COALESCE(SUM(CASE WHEN LOWER("ResourcePool") = 'gold' THEN 1 ELSE 0 END), 0) AS gold_total
%s AS vm_key,
"VmId",
"VmUuid",
"Name",
"Vcenter",
"VcpuCount",
"RamGB",
LOWER(COALESCE("ResourcePool", '')) AS pool,
NULLIF("CreationTime", 0) AS creation_time,
NULLIF("DeletionTime", 0) AS deletion_time,
%s AS presence
FROM %s
WHERE %s
`, record.TableName, templateExclusionFilter())
),
agg AS (
SELECT
vm_key,
MAX("VcpuCount") AS "VcpuCount",
MAX("RamGB") AS "RamGB",
MAX(pool) AS pool,
MIN(creation_time) AS creation_time,
MIN(deletion_time) AS deletion_time
FROM base
GROUP BY vm_key
),
lifecycle AS (
SELECT
(COALESCE(NULLIF("VmId", ''), NULLIF("VmUuid", ''), NULLIF("Name", ''), 'unknown') || '|' || COALESCE("Vcenter", '')) AS vm_key,
MIN(NULLIF("FirstSeen", 0)) AS first_seen,
MIN(NULLIF("DeletedAt", 0)) AS deleted_at
FROM vm_lifecycle_cache
GROUP BY vm_key
),
prev_base AS (
SELECT
%s AS vm_key,
"VcpuCount",
"RamGB",
LOWER(COALESCE("ResourcePool", '')) AS pool,
NULLIF("CreationTime", 0) AS creation_time,
NULLIF("DeletionTime", 0) AS deletion_time
FROM %s
WHERE %s
),
prev_agg AS (
SELECT
vm_key,
MAX("VcpuCount") AS "VcpuCount",
MAX("RamGB") AS "RamGB",
MAX(pool) AS pool,
MIN(creation_time) AS creation_time,
MIN(deletion_time) AS deletion_time
FROM prev_base
GROUP BY vm_key
),
missing_deleted AS (
SELECT
prev_agg.vm_key,
prev_agg."VcpuCount",
prev_agg."RamGB",
prev_agg.pool,
COALESCE(prev_agg.creation_time, lifecycle.first_seen) AS creation_time,
COALESCE(lifecycle.deleted_at, prev_agg.deletion_time) AS deletion_time,
%s AS presence
FROM prev_agg
LEFT JOIN lifecycle ON lifecycle.vm_key = prev_agg.vm_key
LEFT JOIN agg ON agg.vm_key = prev_agg.vm_key
WHERE agg.vm_key IS NULL
AND COALESCE(lifecycle.deleted_at, prev_agg.deletion_time, 0) > 0
AND COALESCE(lifecycle.deleted_at, prev_agg.deletion_time) > ? AND COALESCE(lifecycle.deleted_at, prev_agg.deletion_time) < ?
),
agg_presence AS (
SELECT
agg.vm_key,
agg."VcpuCount",
agg."RamGB",
agg.pool,
COALESCE(agg.creation_time, lifecycle.first_seen) AS creation_time,
COALESCE(lifecycle.deleted_at, agg.deletion_time) AS deletion_time,
%s AS presence
FROM agg
LEFT JOIN lifecycle ON lifecycle.vm_key = agg.vm_key
UNION ALL
SELECT
missing_deleted.vm_key,
missing_deleted."VcpuCount",
missing_deleted."RamGB",
missing_deleted.pool,
missing_deleted.creation_time,
missing_deleted.deletion_time,
missing_deleted.presence
FROM missing_deleted
),
diag AS (
SELECT
COUNT(*) AS row_count,
COUNT(DISTINCT vm_key) AS distinct_keys,
COALESCE(SUM(CASE WHEN vm_key LIKE 'unknown|%%' THEN 1 ELSE 0 END), 0) AS unknown_keys,
COALESCE(SUM(CASE WHEN "VmId" IS NULL OR "VmId" = '' THEN 1 ELSE 0 END), 0) AS missing_vm_id,
COALESCE(SUM(CASE WHEN "VmUuid" IS NULL OR "VmUuid" = '' THEN 1 ELSE 0 END), 0) AS missing_vm_uuid,
COALESCE(SUM(CASE WHEN "Name" IS NULL OR "Name" = '' THEN 1 ELSE 0 END), 0) AS missing_name,
COALESCE(SUM(CASE WHEN presence > 1 THEN 1 ELSE 0 END), 0) AS presence_over_one,
COALESCE(SUM(CASE WHEN presence < 0 THEN 1 ELSE 0 END), 0) AS presence_under_zero,
COALESCE(SUM(presence), 0) AS base_presence_sum
FROM base
),
agg_diag AS (
SELECT
COUNT(*) AS agg_count,
COALESCE(SUM(CASE WHEN agg_presence.creation_time IS NULL OR agg_presence.creation_time = 0 THEN 1 ELSE 0 END), 0) AS missing_creation,
COALESCE(SUM(CASE WHEN agg_presence.deletion_time IS NULL OR agg_presence.deletion_time = 0 THEN 1 ELSE 0 END), 0) AS missing_deletion,
COALESCE(SUM(CASE WHEN agg_presence.creation_time > ? AND agg_presence.creation_time < ? THEN 1 ELSE 0 END), 0) AS created_in_interval,
COALESCE(SUM(CASE WHEN agg_presence.deletion_time > ? AND agg_presence.deletion_time < ? THEN 1 ELSE 0 END), 0) AS deleted_in_interval,
COALESCE(SUM(CASE WHEN agg_presence.presence > 0 AND agg_presence.presence < 1 THEN 1 ELSE 0 END), 0) AS partial_presence
FROM agg_presence
)
SELECT
(SELECT COUNT(*) FROM agg_presence) AS vm_count,
(SELECT COALESCE(SUM(CASE WHEN "VcpuCount" IS NOT NULL THEN "VcpuCount" ELSE 0 END), 0) FROM agg_presence) AS vcpu_total,
(SELECT COALESCE(SUM(CASE WHEN "RamGB" IS NOT NULL THEN "RamGB" ELSE 0 END), 0) FROM agg_presence) AS ram_total,
(SELECT COALESCE(SUM(presence), 0) FROM agg_presence) AS presence_ratio,
(SELECT COALESCE(SUM(CASE WHEN pool = 'tin' THEN presence ELSE 0 END), 0) FROM agg_presence) AS tin_total,
(SELECT COALESCE(SUM(CASE WHEN pool = 'bronze' THEN presence ELSE 0 END), 0) FROM agg_presence) AS bronze_total,
(SELECT COALESCE(SUM(CASE WHEN pool = 'silver' THEN presence ELSE 0 END), 0) FROM agg_presence) AS silver_total,
(SELECT COALESCE(SUM(CASE WHEN pool = 'gold' THEN presence ELSE 0 END), 0) FROM agg_presence) AS gold_total,
diag.row_count,
diag.distinct_keys,
diag.unknown_keys,
diag.missing_vm_id,
diag.missing_vm_uuid,
diag.missing_name,
diag.presence_over_one,
diag.presence_under_zero,
diag.base_presence_sum,
agg_diag.agg_count,
agg_diag.missing_creation,
agg_diag.missing_deletion,
agg_diag.created_in_interval,
agg_diag.deleted_in_interval,
agg_diag.partial_presence
FROM diag, agg_diag
`, vmKeyExpr, overlapExpr, selected.TableName, templateExclusionFilter(), vmKeyExpr, prevTableName, templateExclusionFilter(), missingOverlapExpr, aggOverlapExpr)
query = dbConn.Rebind(query)
var row struct {
VmCount int64 `db:"vm_count"`
VcpuTotal int64 `db:"vcpu_total"`
@@ -1060,17 +1472,97 @@ WHERE %s
BronzeTotal float64 `db:"bronze_total"`
SilverTotal float64 `db:"silver_total"`
GoldTotal float64 `db:"gold_total"`
RowCount int64 `db:"row_count"`
DistinctKeys int64 `db:"distinct_keys"`
UnknownKeys int64 `db:"unknown_keys"`
MissingVmID int64 `db:"missing_vm_id"`
MissingVmUUID int64 `db:"missing_vm_uuid"`
MissingName int64 `db:"missing_name"`
PresenceOverOne int64 `db:"presence_over_one"`
PresenceUnderZero int64 `db:"presence_under_zero"`
BasePresenceSum float64 `db:"base_presence_sum"`
AggCount int64 `db:"agg_count"`
MissingCreation int64 `db:"missing_creation"`
MissingDeletion int64 `db:"missing_deletion"`
CreatedInInterval int64 `db:"created_in_interval"`
DeletedInInterval int64 `db:"deleted_in_interval"`
PartialPresence int64 `db:"partial_presence"`
}
if err := dbConn.GetContext(ctx, &row, query); err != nil {
overlapArgs := []interface{}{
hourEndUnix, hourEndUnix,
hourStartUnix, hourStartUnix,
hourEndUnix, hourEndUnix,
hourStartUnix, hourStartUnix,
durationSeconds,
}
args := make([]interface{}, 0, len(overlapArgs)*3+6)
args = append(args, overlapArgs...)
args = append(args, overlapArgs...)
args = append(args, hourStartUnix, hourEndUnix)
args = append(args, overlapArgs...)
args = append(args, hourStartUnix, hourEndUnix)
args = append(args, hourStartUnix, hourEndUnix)
if err := dbConn.GetContext(ctx, &row, query, args...); err != nil {
hourlyTotalsQueryDumpOnce.Do(func() {
logger.Debug(
"hourly totals query debug",
"table", selected.TableName,
"prev_table", prevTableName,
"snapshot_time", selected.SnapshotTime.Format(time.RFC3339),
"interval_start", hourWindowStart.Format(time.RFC3339),
"interval_end", hourEnd.Format(time.RFC3339),
"query", query,
"args", args,
"error", err,
)
})
return nil, err
}
snapshotLag := selected.SnapshotTime.Sub(hourEnd)
duplicateRows := row.RowCount - row.DistinctKeys
logger.Debug(
"hourly totals snapshot diagnostics",
"table", selected.TableName,
"snapshot_time", selected.SnapshotTime.Format(time.RFC3339),
"snapshot_lag_seconds", int64(snapshotLag.Seconds()),
"interval_start", hourWindowStart.Format("2006-01-02 15:04"),
"interval_end", hourEnd.Format("2006-01-02 15:04"),
"row_count", row.RowCount,
"distinct_keys", row.DistinctKeys,
"duplicate_rows", duplicateRows,
"unknown_keys", row.UnknownKeys,
"missing_vm_id", row.MissingVmID,
"missing_vm_uuid", row.MissingVmUUID,
"missing_name", row.MissingName,
"presence_over_one", row.PresenceOverOne,
"presence_under_zero", row.PresenceUnderZero,
"base_presence_sum", row.BasePresenceSum,
"agg_count", row.AggCount,
"missing_creation", row.MissingCreation,
"missing_deletion", row.MissingDeletion,
"created_in_interval", row.CreatedInInterval,
"deleted_in_interval", row.DeletedInInterval,
"partial_presence", row.PartialPresence,
"presence_ratio", row.PresenceRatio,
"vm_count", row.VmCount,
)
label := formatHourIntervalLabel(hourWindowStart, hourEnd)
logger.Debug(
"hourly totals bucket",
"interval_start", hourWindowStart.Format("2006-01-02 15:04"),
"interval_end", hourEnd.Format("2006-01-02 15:04"),
"presence_ratio", row.PresenceRatio,
"tin_total", row.TinTotal,
"bronze_total", row.BronzeTotal,
"silver_total", row.SilverTotal,
"gold_total", row.GoldTotal,
)
points = append(points, totalsPoint{
Label: record.SnapshotTime.Local().Format("2006-01-02 15:04"),
VmCount: row.VmCount,
Label: label,
VmCount: float64(row.VmCount),
VcpuTotal: float64(row.VcpuTotal),
RamTotal: float64(row.RamTotal),
// For hourly snapshots, prorated VM count equals VM count (no finer granularity).
PresenceRatio: float64(row.VmCount),
PresenceRatio: row.PresenceRatio,
TinTotal: row.TinTotal,
BronzeTotal: row.BronzeTotal,
SilverTotal: row.SilverTotal,
@@ -1080,28 +1572,82 @@ WHERE %s
return points, nil
}
func estimateSnapshotInterval(records []SnapshotRecord) time.Duration {
if len(records) < 2 {
return time.Hour
}
diffs := make([]int64, 0, len(records)-1)
for i := 1; i < len(records); i++ {
diff := records[i].SnapshotTime.Sub(records[i-1].SnapshotTime)
if diff > 0 {
diffs = append(diffs, int64(diff.Seconds()))
}
}
if len(diffs) == 0 {
return time.Hour
}
sort.Slice(diffs, func(i, j int) bool { return diffs[i] < diffs[j] })
median := diffs[len(diffs)/2]
if median <= 0 {
return time.Hour
}
return time.Duration(median) * time.Second
}
func formatHourIntervalLabel(start, end time.Time) string {
startLabel := start.Format("2006-01-02 15:04")
if start.Year() == end.Year() && start.YearDay() == end.YearDay() {
return fmt.Sprintf("%s to %s", startLabel, end.Format("15:04"))
}
return fmt.Sprintf("%s to %s", startLabel, end.Format("2006-01-02 15:04"))
}
func formatDayIntervalLabel(start, end time.Time) string {
return fmt.Sprintf("%s to %s", start.Format("2006-01-02"), end.Format("2006-01-02"))
}
func buildDailyTotals(ctx context.Context, dbConn *sqlx.DB, records []SnapshotRecord, prorateByAvg bool) ([]totalsPoint, error) {
points := make([]totalsPoint, 0, len(records))
tinExpr := `COALESCE(SUM(CASE WHEN "Tin" IS NOT NULL THEN "Tin" ELSE 0 END) / 100.0, 0)`
bronzeExpr := `COALESCE(SUM(CASE WHEN "Bronze" IS NOT NULL THEN "Bronze" ELSE 0 END) / 100.0, 0)`
silverExpr := `COALESCE(SUM(CASE WHEN "Silver" IS NOT NULL THEN "Silver" ELSE 0 END) / 100.0, 0)`
goldExpr := `COALESCE(SUM(CASE WHEN "Gold" IS NOT NULL THEN "Gold" ELSE 0 END) / 100.0, 0)`
if prorateByAvg {
tinExpr = `COALESCE(SUM(CASE WHEN "Tin" IS NOT NULL THEN "Tin" * COALESCE("AvgIsPresent", 0) ELSE 0 END) / 100.0, 0)`
bronzeExpr = `COALESCE(SUM(CASE WHEN "Bronze" IS NOT NULL THEN "Bronze" * COALESCE("AvgIsPresent", 0) ELSE 0 END) / 100.0, 0)`
silverExpr = `COALESCE(SUM(CASE WHEN "Silver" IS NOT NULL THEN "Silver" * COALESCE("AvgIsPresent", 0) ELSE 0 END) / 100.0, 0)`
goldExpr = `COALESCE(SUM(CASE WHEN "Gold" IS NOT NULL THEN "Gold" * COALESCE("AvgIsPresent", 0) ELSE 0 END) / 100.0, 0)`
}
for _, record := range records {
if err := db.ValidateTableName(record.TableName); err != nil {
return nil, err
}
if rowsExist, err := db.TableHasRows(ctx, dbConn, record.TableName); err != nil || !rowsExist {
if record.SnapshotCount == 0 {
slog.Debug("daily totals skipping empty snapshot", "table", record.TableName, "snapshot_time", record.SnapshotTime)
continue
}
if record.SnapshotCount < 0 {
rowsExist, err := db.TableHasRows(ctx, dbConn, record.TableName)
if err != nil {
slog.Debug("daily totals snapshot probe failed", "table", record.TableName, "snapshot_time", record.SnapshotTime, "error", err)
}
if err != nil || !rowsExist {
continue
}
}
query := fmt.Sprintf(`
SELECT
COUNT(DISTINCT "VmId") AS vm_count,
COALESCE(SUM(CASE WHEN "AvgVcpuCount" IS NOT NULL THEN "AvgVcpuCount" ELSE 0 END), 0) AS vcpu_total,
COALESCE(SUM(CASE WHEN "AvgRamGB" IS NOT NULL THEN "AvgRamGB" ELSE 0 END), 0) AS ram_total,
COALESCE(AVG(CASE WHEN "AvgIsPresent" IS NOT NULL THEN "AvgIsPresent" ELSE 0 END), 0) AS presence_ratio,
COALESCE(SUM(CASE WHEN "Tin" IS NOT NULL THEN "Tin" ELSE 0 END) / 100.0, 0) AS tin_total,
COALESCE(SUM(CASE WHEN "Bronze" IS NOT NULL THEN "Bronze" ELSE 0 END) / 100.0, 0) AS bronze_total,
COALESCE(SUM(CASE WHEN "Silver" IS NOT NULL THEN "Silver" ELSE 0 END) / 100.0, 0) AS silver_total,
COALESCE(SUM(CASE WHEN "Gold" IS NOT NULL THEN "Gold" ELSE 0 END) / 100.0, 0) AS gold_total
%s AS tin_total,
%s AS bronze_total,
%s AS silver_total,
%s AS gold_total
FROM %s
WHERE %s
`, record.TableName, templateExclusionFilter())
`, tinExpr, bronzeExpr, silverExpr, goldExpr, record.TableName, templateExclusionFilter())
var row struct {
VmCount int64 `db:"vm_count"`
VcpuTotal float64 `db:"vcpu_total"`
@@ -1115,12 +1661,15 @@ WHERE %s
if err := dbConn.GetContext(ctx, &row, query); err != nil {
return nil, err
}
dayTime := record.SnapshotTime.Local()
dayStart := time.Date(dayTime.Year(), dayTime.Month(), dayTime.Day(), 0, 0, 0, 0, dayTime.Location())
dayEnd := dayStart.AddDate(0, 0, 1)
points = append(points, totalsPoint{
Label: record.SnapshotTime.Local().Format("2006-01-02"),
VmCount: row.VmCount,
Label: formatDayIntervalLabel(dayStart, dayEnd),
VmCount: float64(row.VmCount),
VcpuTotal: row.VcpuTotal,
RamTotal: row.RamTotal,
PresenceRatio: computeProratedVmCount(row.PresenceRatio, row.VmCount, prorateByAvg),
PresenceRatio: computeProratedVmCount(row.PresenceRatio, float64(row.VmCount), prorateByAvg),
TinTotal: row.TinTotal,
BronzeTotal: row.BronzeTotal,
SilverTotal: row.SilverTotal,
@@ -1130,11 +1679,11 @@ WHERE %s
return points, nil
}
func computeProratedVmCount(presenceRatio float64, vmCount int64, prorate bool) float64 {
func computeProratedVmCount(presenceRatio float64, vmCount float64, prorate bool) float64 {
if !prorate {
return float64(vmCount)
return vmCount
}
return presenceRatio * float64(vmCount)
return presenceRatio * vmCount
}
func writeTotalsChart(logger *slog.Logger, xlsx *excelize.File, sheetName string, points []totalsPoint) {
@@ -1166,6 +1715,18 @@ func writeTotalsChart(logger *slog.Logger, xlsx *excelize.File, sheetName string
xlsx.SetCellValue(sheetName, fmt.Sprintf("I%d", row), point.GoldTotal)
}
if lastRow := len(points) + 1; lastRow >= 2 {
numFmt := "0.00000000"
styleID, err := xlsx.NewStyle(&excelize.Style{CustomNumFmt: &numFmt})
if err == nil {
if err := xlsx.SetCellStyle(sheetName, "E2", fmt.Sprintf("I%d", lastRow), styleID); err != nil {
logger.Error("Error setting totals number format", "error", err)
}
} else {
logger.Error("Error creating totals number format", "error", err)
}
}
if endCell, err := excelize.CoordinatesToCellName(len(headers), 1); err == nil {
filterRange := "A1:" + endCell
if err := xlsx.AutoFilter(sheetName, filterRange, nil); err != nil {

View File

@@ -47,6 +47,8 @@ type SettingsYML struct {
HourlySnapshotMaxRetries int `yaml:"hourly_snapshot_max_retries"`
DailyJobTimeoutSeconds int `yaml:"daily_job_timeout_seconds"`
MonthlyJobTimeoutSeconds int `yaml:"monthly_job_timeout_seconds"`
MonthlyAggregationGranularity string `yaml:"monthly_aggregation_granularity"`
MonthlyAggregationCron string `yaml:"monthly_aggregation_cron"`
CleanupJobTimeoutSeconds int `yaml:"cleanup_job_timeout_seconds"`
TenantsToFilter []string `yaml:"tenants_to_filter"`
NodeChargeClusters []string `yaml:"node_charge_clusters"`

View File

@@ -2,18 +2,13 @@ package tasks
import (
"context"
"strings"
"time"
"vctp/db"
"github.com/jmoiron/sqlx"
)
// CronTracker manages re-entry protection and status recording for cron jobs.
type CronTracker struct {
db db.Database
bindType int
}
func NewCronTracker(database db.Database) *CronTracker {
return &CronTracker{
db: database,
@@ -30,6 +25,39 @@ func (c *CronTracker) ClearAllInProgress(ctx context.Context) error {
return err
}
// ClearStale resets in_progress for a specific job if it has been running longer than maxAge.
func (c *CronTracker) ClearStale(ctx context.Context, job string, maxAge time.Duration) error {
if err := c.ensureTable(ctx); err != nil {
return err
}
driver := strings.ToLower(c.db.DB().DriverName())
var query string
switch driver {
case "sqlite":
query = `
UPDATE cron_status
SET in_progress = FALSE
WHERE job_name = ?
AND in_progress = TRUE
AND started_at > 0
AND (strftime('%s','now') - started_at) > ?
`
case "pgx", "postgres":
query = `
UPDATE cron_status
SET in_progress = FALSE
WHERE job_name = $1
AND in_progress = TRUE
AND started_at > 0
AND (EXTRACT(EPOCH FROM now())::BIGINT - started_at) > $2
`
default:
return nil
}
_, err := c.db.DB().ExecContext(ctx, query, job, int64(maxAge.Seconds()))
return err
}
func (c *CronTracker) ensureTable(ctx context.Context) error {
conn := c.db.DB()
driver := conn.DriverName()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,191 @@
package tasks
import (
"context"
"fmt"
"strings"
"vctp/db"
"github.com/jmoiron/sqlx"
)
func insertHourlyCache(ctx context.Context, dbConn *sqlx.DB, rows []InventorySnapshotRow) error {
if len(rows) == 0 {
return nil
}
if err := db.EnsureVmHourlyStats(ctx, dbConn); err != nil {
return err
}
driver := strings.ToLower(dbConn.DriverName())
conflict := ""
verb := "INSERT INTO"
if driver == "sqlite" {
verb = "INSERT OR REPLACE INTO"
} else {
conflict = ` ON CONFLICT ("Vcenter","VmId","SnapshotTime") DO UPDATE SET
"VmUuid"=EXCLUDED."VmUuid",
"Name"=EXCLUDED."Name",
"CreationTime"=EXCLUDED."CreationTime",
"DeletionTime"=EXCLUDED."DeletionTime",
"ResourcePool"=EXCLUDED."ResourcePool",
"Datacenter"=EXCLUDED."Datacenter",
"Cluster"=EXCLUDED."Cluster",
"Folder"=EXCLUDED."Folder",
"ProvisionedDisk"=EXCLUDED."ProvisionedDisk",
"VcpuCount"=EXCLUDED."VcpuCount",
"RamGB"=EXCLUDED."RamGB",
"IsTemplate"=EXCLUDED."IsTemplate",
"PoweredOn"=EXCLUDED."PoweredOn",
"SrmPlaceholder"=EXCLUDED."SrmPlaceholder"`
}
cols := []string{
"SnapshotTime", "Vcenter", "VmId", "VmUuid", "Name", "CreationTime", "DeletionTime", "ResourcePool",
"Datacenter", "Cluster", "Folder", "ProvisionedDisk", "VcpuCount", "RamGB", "IsTemplate", "PoweredOn", "SrmPlaceholder",
}
bind := sqlx.BindType(dbConn.DriverName())
placeholders := strings.TrimRight(strings.Repeat("?, ", len(cols)), ", ")
stmtText := fmt.Sprintf(`%s vm_hourly_stats ("%s") VALUES (%s)%s`, verb, strings.Join(cols, `","`), placeholders, conflict)
stmtText = sqlx.Rebind(bind, stmtText)
tx, err := dbConn.BeginTxx(ctx, nil)
if err != nil {
return err
}
stmt, err := tx.PreparexContext(ctx, stmtText)
if err != nil {
tx.Rollback()
return err
}
defer stmt.Close()
for _, r := range rows {
args := []interface{}{
r.SnapshotTime, r.Vcenter, r.VmId, r.VmUuid, r.Name, r.CreationTime, r.DeletionTime, r.ResourcePool,
r.Datacenter, r.Cluster, r.Folder, r.ProvisionedDisk, r.VcpuCount, r.RamGB, r.IsTemplate, r.PoweredOn, r.SrmPlaceholder,
}
if _, err := stmt.ExecContext(ctx, args...); err != nil {
tx.Rollback()
return err
}
}
return tx.Commit()
}
func insertHourlyBatch(ctx context.Context, dbConn *sqlx.DB, tableName string, rows []InventorySnapshotRow) error {
if len(rows) == 0 {
return nil
}
if err := db.EnsureVmHourlyStats(ctx, dbConn); err != nil {
return err
}
tx, err := dbConn.BeginTxx(ctx, nil)
if err != nil {
return err
}
baseCols := []string{
"InventoryId", "Name", "Vcenter", "VmId", "EventKey", "CloudId", "CreationTime", "DeletionTime",
"ResourcePool", "Datacenter", "Cluster", "Folder", "ProvisionedDisk", "VcpuCount",
"RamGB", "IsTemplate", "PoweredOn", "SrmPlaceholder", "VmUuid", "SnapshotTime",
}
bind := sqlx.BindType(dbConn.DriverName())
buildStmt := func(cols []string) (*sqlx.Stmt, error) {
colList := `"` + strings.Join(cols, `", "`) + `"`
placeholders := strings.TrimRight(strings.Repeat("?, ", len(cols)), ", ")
return tx.PreparexContext(ctx, sqlx.Rebind(bind, fmt.Sprintf(`INSERT INTO %s (%s) VALUES (%s)`, tableName, colList, placeholders)))
}
stmt, err := buildStmt(baseCols)
if err != nil {
// Fallback for legacy tables that still have IsPresent.
withLegacy := append(append([]string{}, baseCols...), "IsPresent")
stmt, err = buildStmt(withLegacy)
if err != nil {
tx.Rollback()
return err
}
defer stmt.Close()
for _, row := range rows {
args := []interface{}{
row.InventoryId,
row.Name,
row.Vcenter,
row.VmId,
row.EventKey,
row.CloudId,
row.CreationTime,
row.DeletionTime,
row.ResourcePool,
row.Datacenter,
row.Cluster,
row.Folder,
row.ProvisionedDisk,
row.VcpuCount,
row.RamGB,
row.IsTemplate,
row.PoweredOn,
row.SrmPlaceholder,
row.VmUuid,
row.SnapshotTime,
"TRUE",
}
if _, err := stmt.ExecContext(ctx, args...); err != nil {
tx.Rollback()
return err
}
}
return tx.Commit()
}
defer stmt.Close()
for _, row := range rows {
args := []interface{}{
row.InventoryId,
row.Name,
row.Vcenter,
row.VmId,
row.EventKey,
row.CloudId,
row.CreationTime,
row.DeletionTime,
row.ResourcePool,
row.Datacenter,
row.Cluster,
row.Folder,
row.ProvisionedDisk,
row.VcpuCount,
row.RamGB,
row.IsTemplate,
row.PoweredOn,
row.SrmPlaceholder,
row.VmUuid,
row.SnapshotTime,
}
if _, err := stmt.ExecContext(ctx, args...); err != nil {
tx.Rollback()
return err
}
}
return tx.Commit()
}
func dropSnapshotTable(ctx context.Context, dbConn *sqlx.DB, table string) error {
if _, err := db.SafeTableName(table); err != nil {
return err
}
_, err := dbConn.ExecContext(ctx, fmt.Sprintf("DROP TABLE IF EXISTS %s", table))
return err
}
func clearTable(ctx context.Context, dbConn *sqlx.DB, table string) error {
if _, err := db.SafeTableName(table); err != nil {
return err
}
_, err := dbConn.ExecContext(ctx, fmt.Sprintf("DELETE FROM %s", table))
if err != nil {
return fmt.Errorf("failed to clear table %s: %w", table, err)
}
return nil
}

View File

@@ -0,0 +1,548 @@
package tasks
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"vctp/db"
"vctp/db/queries"
"github.com/jmoiron/sqlx"
)
var snapshotProbeLimiter = make(chan struct{}, 1)
func acquireSnapshotProbe(ctx context.Context) (func(), error) {
select {
case snapshotProbeLimiter <- struct{}{}:
return func() { <-snapshotProbeLimiter }, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func boolStringFromInterface(value interface{}) string {
switch v := value.(type) {
case nil:
return ""
case string:
return v
case []byte:
return string(v)
case bool:
if v {
return "TRUE"
}
return "FALSE"
case int:
if v != 0 {
return "TRUE"
}
return "FALSE"
case int64:
if v != 0 {
return "TRUE"
}
return "FALSE"
default:
return fmt.Sprint(v)
}
}
// latestHourlySnapshotBefore finds the most recent hourly snapshot table prior to the given time, skipping empty tables.
func latestHourlySnapshotBefore(ctx context.Context, dbConn *sqlx.DB, cutoff time.Time, logger *slog.Logger) (string, error) {
tables, err := listLatestHourlyWithRows(ctx, dbConn, "", cutoff.Unix(), 1, logger)
if err != nil {
return "", err
}
if len(tables) == 0 {
return "", nil
}
return tables[0].Table, nil
}
// parseSnapshotTime extracts the unix suffix from an inventory_hourly table name.
func parseSnapshotTime(table string) (int64, bool) {
const prefix = "inventory_hourly_"
if !strings.HasPrefix(table, prefix) {
return 0, false
}
ts, err := strconv.ParseInt(strings.TrimPrefix(table, prefix), 10, 64)
if err != nil {
return 0, false
}
return ts, true
}
// listLatestHourlyWithRows returns recent hourly snapshot tables (ordered desc by time) that have rows, optionally filtered by vcenter.
func listLatestHourlyWithRows(ctx context.Context, dbConn *sqlx.DB, vcenter string, beforeUnix int64, limit int, logger *slog.Logger) ([]snapshotTable, error) {
if limit <= 0 {
limit = 50
}
rows, err := dbConn.QueryxContext(ctx, `
SELECT table_name, snapshot_time, snapshot_count
FROM snapshot_registry
WHERE snapshot_type = 'hourly' AND snapshot_time < ?
ORDER BY snapshot_time DESC
LIMIT ?
`, beforeUnix, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []snapshotTable
for rows.Next() {
var name string
var ts int64
var count sql.NullInt64
if scanErr := rows.Scan(&name, &ts, &count); scanErr != nil {
continue
}
if err := db.ValidateTableName(name); err != nil {
continue
}
if count.Valid && count.Int64 == 0 {
if logger != nil {
logger.Debug("skipping snapshot table with zero count", "table", name, "snapshot_time", ts, "vcenter", vcenter)
}
continue
}
probed := false
var probeErr error
probeTimeout := false
// If count is known and >0, trust it; if NULL, accept optimistically to avoid heavy probes.
hasRows := !count.Valid || count.Int64 > 0
start := time.Now()
if vcenter != "" && hasRows {
probed = true
probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
release, err := acquireSnapshotProbe(probeCtx)
if err != nil {
probeErr = err
hasRows = false
cancel()
} else {
vrows, qerr := querySnapshotRows(probeCtx, dbConn, name, []string{"VmId"}, `"Vcenter" = ? LIMIT 1`, vcenter)
if qerr == nil {
hasRows = vrows.Next()
vrows.Close()
} else {
probeErr = qerr
hasRows = false
}
release()
cancel()
}
probeTimeout = errors.Is(probeErr, context.DeadlineExceeded) || errors.Is(probeErr, context.Canceled)
}
elapsed := time.Since(start)
if logger != nil {
logger.Debug("evaluated snapshot table", "table", name, "snapshot_time", ts, "snapshot_count", count, "probed", probed, "has_rows", hasRows, "elapsed", elapsed, "vcenter", vcenter, "probe_error", probeErr, "probe_timeout", probeTimeout)
}
if !hasRows {
continue
}
out = append(out, snapshotTable{Table: name, Time: ts, Count: count})
}
return out, nil
}
// SnapshotTooSoon reports whether the gap between prev and curr is significantly shorter than expected.
func SnapshotTooSoon(prevUnix, currUnix int64, expectedSeconds int64) bool {
if prevUnix == 0 || currUnix == 0 || expectedSeconds <= 0 {
return false
}
return currUnix-prevUnix < expectedSeconds
}
// querySnapshotRows builds a SELECT with proper rebind for the given table/columns/where.
func querySnapshotRows(ctx context.Context, dbConn *sqlx.DB, table string, columns []string, where string, args ...interface{}) (*sqlx.Rows, error) {
if err := db.ValidateTableName(table); err != nil {
return nil, err
}
colExpr := "*"
if len(columns) > 0 {
colExpr = `"` + strings.Join(columns, `","`) + `"`
}
query := fmt.Sprintf(`SELECT %s FROM %s`, colExpr, table)
if strings.TrimSpace(where) != "" {
query = fmt.Sprintf(`%s WHERE %s`, query, where)
}
query = sqlx.Rebind(sqlx.BindType(dbConn.DriverName()), query)
return dbConn.QueryxContext(ctx, query, args...)
}
func updateDeletionTimeInSnapshot(ctx context.Context, dbConn *sqlx.DB, table, vcenter, vmID, vmUUID, name string, deletionUnix int64) (int64, error) {
if err := db.ValidateTableName(table); err != nil {
return 0, err
}
matchColumn := ""
matchValue := ""
switch {
case vmID != "":
matchColumn = "VmId"
matchValue = vmID
case vmUUID != "":
matchColumn = "VmUuid"
matchValue = vmUUID
case name != "":
matchColumn = "Name"
matchValue = name
default:
return 0, nil
}
query := fmt.Sprintf(`UPDATE %s SET "DeletionTime" = ? WHERE "Vcenter" = ? AND "%s" = ? AND ("DeletionTime" IS NULL OR "DeletionTime" = 0 OR "DeletionTime" > ?)`, table, matchColumn)
query = sqlx.Rebind(sqlx.BindType(dbConn.DriverName()), query)
result, err := dbConn.ExecContext(ctx, query, deletionUnix, vcenter, matchValue, deletionUnix)
if err != nil {
return 0, err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return 0, err
}
return rowsAffected, nil
}
func updateDeletionTimeInHourlyCache(ctx context.Context, dbConn *sqlx.DB, vcenter, vmID, vmUUID, name string, snapshotUnix, deletionUnix int64) (int64, error) {
if snapshotUnix <= 0 {
return 0, nil
}
matchColumn := ""
matchValue := ""
switch {
case vmID != "":
matchColumn = "VmId"
matchValue = vmID
case vmUUID != "":
matchColumn = "VmUuid"
matchValue = vmUUID
case name != "":
matchColumn = "Name"
matchValue = name
default:
return 0, nil
}
query := fmt.Sprintf(`UPDATE vm_hourly_stats SET "DeletionTime" = ? WHERE "Vcenter" = ? AND "SnapshotTime" = ? AND "%s" = ? AND ("DeletionTime" IS NULL OR "DeletionTime" = 0 OR "DeletionTime" > ?)`, matchColumn)
query = sqlx.Rebind(sqlx.BindType(dbConn.DriverName()), query)
result, err := dbConn.ExecContext(ctx, query, deletionUnix, vcenter, snapshotUnix, matchValue, deletionUnix)
if err != nil {
return 0, err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return 0, err
}
return rowsAffected, nil
}
// markMissingFromPrevious marks VMs that were present in the previous snapshot but missing now.
func (c *CronTask) markMissingFromPrevious(ctx context.Context, dbConn *sqlx.DB, prevTable string, vcenter string, snapshotTime time.Time,
currentByID map[string]InventorySnapshotRow, currentByUuid map[string]struct{}, currentByName map[string]struct{},
invByID map[string]queries.Inventory, invByUuid map[string]queries.Inventory, invByName map[string]queries.Inventory) (int, bool) {
if err := db.ValidateTableName(prevTable); err != nil {
return 0, false
}
type prevRow struct {
VmId sql.NullString `db:"VmId"`
VmUuid sql.NullString `db:"VmUuid"`
Name string `db:"Name"`
Cluster sql.NullString `db:"Cluster"`
Datacenter sql.NullString `db:"Datacenter"`
DeletionTime sql.NullInt64 `db:"DeletionTime"`
}
rows, err := querySnapshotRows(ctx, dbConn, prevTable, []string{"VmId", "VmUuid", "Name", "Cluster", "Datacenter", "DeletionTime"}, `"Vcenter" = ?`, vcenter)
if err != nil {
c.Logger.Warn("failed to read previous snapshot for deletion detection", "error", err, "table", prevTable, "vcenter", vcenter)
return 0, false
}
defer rows.Close()
missing := 0
tableUpdated := false
for rows.Next() {
var r prevRow
if err := rows.StructScan(&r); err != nil {
continue
}
vmID := r.VmId.String
uuid := r.VmUuid.String
name := r.Name
cluster := r.Cluster.String
found := false
if vmID != "" {
if _, ok := currentByID[vmID]; ok {
found = true
}
}
if !found && uuid != "" {
if _, ok := currentByUuid[uuid]; ok {
found = true
}
}
if !found && name != "" {
if _, ok := currentByName[name]; ok {
found = true
}
}
// If the name is missing but UUID+Cluster still exists in inventory/current, treat it as present (rename, not delete).
if !found && uuid != "" && cluster != "" {
if inv, ok := invByUuid[uuid]; ok && strings.EqualFold(inv.Cluster.String, cluster) {
found = true
}
}
if found {
continue
}
var inv queries.Inventory
var ok bool
if vmID != "" {
inv, ok = invByID[vmID]
}
if !ok && uuid != "" {
inv, ok = invByUuid[uuid]
}
if !ok && name != "" {
inv, ok = invByName[name]
}
if !ok {
continue
}
delTime := inv.DeletionTime
if !delTime.Valid {
delTime = sql.NullInt64{Int64: snapshotTime.Unix(), Valid: true}
if err := c.Database.Queries().InventoryMarkDeleted(ctx, queries.InventoryMarkDeletedParams{
DeletionTime: delTime,
VmId: inv.VmId,
DatacenterName: inv.Datacenter,
}); err != nil {
c.Logger.Warn("failed to mark inventory record deleted from previous snapshot", "error", err, "vm_id", inv.VmId.String)
}
}
// Also update lifecycle cache so deletion time is available for rollups.
vmUUID := ""
if inv.VmUuid.Valid {
vmUUID = inv.VmUuid.String
}
if err := db.MarkVmDeletedWithDetails(ctx, dbConn, vcenter, inv.VmId.String, vmUUID, inv.Name, inv.Cluster.String, delTime.Int64); err != nil {
c.Logger.Warn("failed to mark lifecycle cache deleted from previous snapshot", "error", err, "vm_id", inv.VmId.String, "vm_uuid", vmUUID, "vcenter", vcenter)
}
if rowsAffected, err := updateDeletionTimeInSnapshot(ctx, dbConn, prevTable, vcenter, inv.VmId.String, vmUUID, inv.Name, delTime.Int64); err != nil {
c.Logger.Warn("failed to update hourly snapshot deletion time", "error", err, "table", prevTable, "vm_id", inv.VmId.String, "vm_uuid", vmUUID, "vcenter", vcenter)
} else if rowsAffected > 0 {
tableUpdated = true
c.Logger.Debug("updated hourly snapshot deletion time", "table", prevTable, "vm_id", inv.VmId.String, "vm_uuid", vmUUID, "vcenter", vcenter, "deletion_time", delTime.Int64)
if snapUnix, ok := parseSnapshotTime(prevTable); ok {
if cacheRows, err := updateDeletionTimeInHourlyCache(ctx, dbConn, vcenter, inv.VmId.String, vmUUID, inv.Name, snapUnix, delTime.Int64); err != nil {
c.Logger.Warn("failed to update hourly cache deletion time", "error", err, "snapshot_time", snapUnix, "vm_id", inv.VmId.String, "vm_uuid", vmUUID, "vcenter", vcenter)
} else if cacheRows > 0 {
c.Logger.Debug("updated hourly cache deletion time", "snapshot_time", snapUnix, "vm_id", inv.VmId.String, "vm_uuid", vmUUID, "vcenter", vcenter, "deletion_time", delTime.Int64)
}
}
}
c.Logger.Debug("Detected VM missing compared to previous snapshot", "name", inv.Name, "vm_id", inv.VmId.String, "vm_uuid", inv.VmUuid.String, "vcenter", vcenter, "snapshot_time", snapshotTime, "prev_table", prevTable)
missing++
}
return missing, tableUpdated
}
// countNewFromPrevious returns how many VMs are present in the current snapshot but not in the previous snapshot.
func countNewFromPrevious(ctx context.Context, dbConn *sqlx.DB, prevTable string, vcenter string, current map[string]InventorySnapshotRow) int {
if err := db.ValidateTableName(prevTable); err != nil {
return len(current)
}
query := fmt.Sprintf(`SELECT "VmId","VmUuid","Name" FROM %s WHERE "Vcenter" = ?`, prevTable)
query = sqlx.Rebind(sqlx.BindType(dbConn.DriverName()), query)
rows, err := dbConn.QueryxContext(ctx, query, vcenter)
if err != nil {
return len(current)
}
defer rows.Close()
prevIDs := make(map[string]struct{})
prevUUIDs := make(map[string]struct{})
prevNames := make(map[string]struct{})
for rows.Next() {
var vmID, vmUUID, name string
if scanErr := rows.Scan(&vmID, &vmUUID, &name); scanErr != nil {
continue
}
if vmID != "" {
prevIDs[vmID] = struct{}{}
}
if vmUUID != "" {
prevUUIDs[vmUUID] = struct{}{}
}
if name != "" {
prevNames[name] = struct{}{}
}
}
newCount := 0
for _, cur := range current {
id := cur.VmId.String
uuid := cur.VmUuid.String
name := cur.Name
if id != "" {
if _, ok := prevIDs[id]; ok {
continue
}
}
if uuid != "" {
if _, ok := prevUUIDs[uuid]; ok {
continue
}
}
if name != "" {
if _, ok := prevNames[name]; ok {
continue
}
}
newCount++
}
return newCount
}
// listNewFromPrevious returns the rows present now but not in the previous snapshot.
func listNewFromPrevious(ctx context.Context, dbConn *sqlx.DB, prevTable string, vcenter string, current map[string]InventorySnapshotRow) []InventorySnapshotRow {
if err := db.ValidateTableName(prevTable); err != nil {
all := make([]InventorySnapshotRow, 0, len(current))
for _, cur := range current {
all = append(all, cur)
}
return all
}
query := fmt.Sprintf(`SELECT "VmId","VmUuid","Name" FROM %s WHERE "Vcenter" = ?`, prevTable)
query = sqlx.Rebind(sqlx.BindType(dbConn.DriverName()), query)
rows, err := dbConn.QueryxContext(ctx, query, vcenter)
if err != nil {
all := make([]InventorySnapshotRow, 0, len(current))
for _, cur := range current {
all = append(all, cur)
}
return all
}
defer rows.Close()
prevIDs := make(map[string]struct{})
prevUUIDs := make(map[string]struct{})
prevNames := make(map[string]struct{})
for rows.Next() {
var vmID, vmUUID, name string
if scanErr := rows.Scan(&vmID, &vmUUID, &name); scanErr != nil {
continue
}
if vmID != "" {
prevIDs[vmID] = struct{}{}
}
if vmUUID != "" {
prevUUIDs[vmUUID] = struct{}{}
}
if name != "" {
prevNames[name] = struct{}{}
}
}
newRows := make([]InventorySnapshotRow, 0)
for _, cur := range current {
id := cur.VmId.String
uuid := cur.VmUuid.String
name := cur.Name
if id != "" {
if _, ok := prevIDs[id]; ok {
continue
}
}
if uuid != "" {
if _, ok := prevUUIDs[uuid]; ok {
continue
}
}
if name != "" {
if _, ok := prevNames[name]; ok {
continue
}
}
newRows = append(newRows, cur)
}
return newRows
}
// findVMInHourlySnapshots searches recent hourly snapshot tables for a VM by ID for the given vCenter.
// extraTables are searched first (e.g., known previous snapshot tables).
func findVMInHourlySnapshots(ctx context.Context, dbConn *sqlx.DB, vcenter string, vmID string, extraTables ...string) (InventorySnapshotRow, string, bool) {
if vmID == "" {
return InventorySnapshotRow{}, "", false
}
// Use a short timeout to avoid hanging if the DB is busy.
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// First search any explicit tables provided.
for _, table := range extraTables {
if table == "" {
continue
}
if err := db.ValidateTableName(table); err != nil {
continue
}
query := fmt.Sprintf(`SELECT "VmId","VmUuid","Name","Datacenter","Cluster" FROM %s WHERE "Vcenter" = ? AND "VmId" = ? LIMIT 1`, table)
query = sqlx.Rebind(sqlx.BindType(dbConn.DriverName()), query)
var row InventorySnapshotRow
if err := dbConn.QueryRowxContext(ctx, query, vcenter, vmID).Scan(&row.VmId, &row.VmUuid, &row.Name, &row.Datacenter, &row.Cluster); err == nil {
return row, table, true
}
}
// Try a handful of most recent hourly tables from the registry.
rows, err := dbConn.QueryxContext(ctx, `
SELECT table_name
FROM snapshot_registry
WHERE snapshot_type = 'hourly'
ORDER BY snapshot_time DESC
LIMIT 20
`)
if err != nil {
return InventorySnapshotRow{}, "", false
}
defer rows.Close()
checked := 0
for rows.Next() {
var table string
if scanErr := rows.Scan(&table); scanErr != nil {
continue
}
if err := db.ValidateTableName(table); err != nil {
continue
}
query := fmt.Sprintf(`SELECT "VmId","VmUuid","Name","Datacenter","Cluster" FROM %s WHERE "Vcenter" = ? AND "VmId" = ? LIMIT 1`, table)
query = sqlx.Rebind(sqlx.BindType(dbConn.DriverName()), query)
var row InventorySnapshotRow
if err := dbConn.QueryRowxContext(ctx, query, vcenter, vmID).Scan(&row.VmId, &row.VmUuid, &row.Name, &row.Datacenter, &row.Cluster); err == nil {
return row, table, true
}
checked++
if checked >= 10 { // limit work
break
}
}
return InventorySnapshotRow{}, "", false
}

View File

@@ -0,0 +1,290 @@
package tasks
import (
"context"
"database/sql"
"log/slog"
"time"
"vctp/db"
"github.com/jmoiron/sqlx"
)
// presenceKeys builds lookup keys for vm presence comparison.
func presenceKeys(vmID, vmUUID, name string) []string {
keys := make([]string, 0, 3)
if vmID != "" {
keys = append(keys, "id:"+vmID)
}
if vmUUID != "" {
keys = append(keys, "uuid:"+vmUUID)
}
if name != "" {
keys = append(keys, "name:"+name)
}
return keys
}
// backfillLifecycleDeletionsToday looks for VMs in the lifecycle cache that are not in the current inventory,
// have no DeletedAt, and determines their deletion time from today's hourly snapshots, optionally checking the next snapshot (next day) to confirm.
// It returns any hourly snapshot tables that were updated with deletion times.
func backfillLifecycleDeletionsToday(ctx context.Context, logger *slog.Logger, dbConn *sqlx.DB, vcenter string, snapshotTime time.Time, present map[string]InventorySnapshotRow) ([]string, error) {
dayStart := truncateDate(snapshotTime)
dayEnd := dayStart.Add(24 * time.Hour)
candidates, err := loadLifecycleCandidates(ctx, dbConn, vcenter, present)
if err != nil || len(candidates) == 0 {
return nil, err
}
tables, err := listHourlyTablesForDay(ctx, dbConn, dayStart, dayEnd)
if err != nil {
return nil, err
}
if len(tables) == 0 {
return nil, nil
}
nextPresence := make(map[string]struct{})
if nextTable, nextErr := nextSnapshotAfter(ctx, dbConn, dayEnd, vcenter); nextErr == nil && nextTable != "" {
nextPresence = loadPresenceKeys(ctx, dbConn, nextTable, vcenter)
}
updatedTables := make(map[string]struct{})
for i := range candidates {
cand := &candidates[i]
deletion, firstMiss, lastSeenTable := findDeletionInTables(ctx, dbConn, tables, vcenter, cand)
if deletion == 0 && len(nextPresence) > 0 && firstMiss > 0 {
if !isPresent(nextPresence, *cand) {
// Single miss at end of day, confirmed by next-day absence.
deletion = firstMiss
logger.Debug("cross-day deletion inferred from next snapshot", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "deletion", deletion)
}
}
if deletion > 0 {
if err := db.MarkVmDeletedWithDetails(ctx, dbConn, vcenter, cand.vmID, cand.vmUUID, cand.name, cand.cluster, deletion); err != nil {
logger.Warn("lifecycle backfill mark deleted failed", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "cluster", cand.cluster, "deletion", deletion, "error", err)
continue
}
if lastSeenTable != "" {
if rowsAffected, err := updateDeletionTimeInSnapshot(ctx, dbConn, lastSeenTable, vcenter, cand.vmID, cand.vmUUID, cand.name, deletion); err != nil {
logger.Warn("lifecycle backfill failed to update hourly snapshot deletion time", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "cluster", cand.cluster, "table", lastSeenTable, "deletion", deletion, "error", err)
} else if rowsAffected > 0 {
updatedTables[lastSeenTable] = struct{}{}
logger.Debug("lifecycle backfill updated hourly snapshot deletion time", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "cluster", cand.cluster, "table", lastSeenTable, "deletion", deletion)
if snapUnix, ok := parseSnapshotTime(lastSeenTable); ok {
if cacheRows, err := updateDeletionTimeInHourlyCache(ctx, dbConn, vcenter, cand.vmID, cand.vmUUID, cand.name, snapUnix, deletion); err != nil {
logger.Warn("lifecycle backfill failed to update hourly cache deletion time", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "snapshot_time", snapUnix, "deletion", deletion, "error", err)
} else if cacheRows > 0 {
logger.Debug("lifecycle backfill updated hourly cache deletion time", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "snapshot_time", snapUnix, "deletion", deletion)
}
}
}
}
logger.Debug("lifecycle backfill applied", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "cluster", cand.cluster, "deletion", deletion)
}
}
if len(updatedTables) == 0 {
return nil, nil
}
tablesUpdated := make([]string, 0, len(updatedTables))
for table := range updatedTables {
tablesUpdated = append(tablesUpdated, table)
}
return tablesUpdated, nil
}
type lifecycleCandidate struct {
vmID string
vmUUID string
name string
cluster string
}
func loadLifecycleCandidates(ctx context.Context, dbConn *sqlx.DB, vcenter string, present map[string]InventorySnapshotRow) ([]lifecycleCandidate, error) {
rows, err := dbConn.QueryxContext(ctx, `
SELECT "VmId","VmUuid","Name","Cluster"
FROM vm_lifecycle_cache
WHERE "Vcenter" = ? AND ("DeletedAt" IS NULL OR "DeletedAt" = 0)
`, vcenter)
if err != nil {
return nil, err
}
defer rows.Close()
var cands []lifecycleCandidate
for rows.Next() {
var vmID, vmUUID, name, cluster sql.NullString
if scanErr := rows.Scan(&vmID, &vmUUID, &name, &cluster); scanErr != nil {
continue
}
if vmID.String == "" {
continue
}
if _, ok := present[vmID.String]; ok {
continue // still present, skip
}
cands = append(cands, lifecycleCandidate{
vmID: vmID.String,
vmUUID: vmUUID.String,
name: name.String,
cluster: cluster.String,
})
}
return cands, nil
}
type snapshotTable struct {
Table string `db:"table_name"`
Time int64 `db:"snapshot_time"`
Count sql.NullInt64 `db:"snapshot_count"`
}
func listHourlyTablesForDay(ctx context.Context, dbConn *sqlx.DB, dayStart, dayEnd time.Time) ([]snapshotTable, error) {
log := loggerFromCtx(ctx, nil)
rows, err := dbConn.QueryxContext(ctx, `
SELECT table_name, snapshot_time, snapshot_count
FROM snapshot_registry
WHERE snapshot_type = 'hourly' AND snapshot_time >= ? AND snapshot_time < ?
ORDER BY snapshot_time ASC
`, dayStart.Unix(), dayEnd.Unix())
if err != nil {
return nil, err
}
defer rows.Close()
var tables []snapshotTable
for rows.Next() {
var t snapshotTable
if err := rows.StructScan(&t); err != nil {
continue
}
if err := db.ValidateTableName(t.Table); err != nil {
continue
}
// Trust snapshot_count if present; otherwise optimistically include to avoid long probes.
if t.Count.Valid && t.Count.Int64 <= 0 {
if log != nil {
log.Debug("skipping snapshot table with zero count", "table", t.Table, "snapshot_time", t.Time)
}
continue
}
tables = append(tables, t)
}
return tables, nil
}
func nextSnapshotAfter(ctx context.Context, dbConn *sqlx.DB, after time.Time, vcenter string) (string, error) {
rows, err := dbConn.QueryxContext(ctx, `
SELECT table_name
FROM snapshot_registry
WHERE snapshot_type = 'hourly' AND snapshot_time >= ?
ORDER BY snapshot_time ASC
LIMIT 1
`, after.Unix())
if err != nil {
return "", err
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
continue
}
if err := db.ValidateTableName(name); err != nil {
continue
}
// ensure the snapshot table actually has entries for this vcenter
vrows, qerr := querySnapshotRows(ctx, dbConn, name, []string{"VmId"}, `"Vcenter" = ? LIMIT 1`, vcenter)
if qerr != nil {
continue
}
hasVcenter := vrows.Next()
vrows.Close()
if hasVcenter {
return name, nil
}
}
return "", nil
}
func loadPresenceKeys(ctx context.Context, dbConn *sqlx.DB, table, vcenter string) map[string]struct{} {
out := make(map[string]struct{})
rows, err := querySnapshotRows(ctx, dbConn, table, []string{"VmId", "VmUuid", "Name"}, `"Vcenter" = ?`, vcenter)
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var vmId, vmUuid, name sql.NullString
if err := rows.Scan(&vmId, &vmUuid, &name); err == nil {
for _, k := range presenceKeys(vmId.String, vmUuid.String, name.String) {
out[k] = struct{}{}
}
}
}
return out
}
func isPresent(presence map[string]struct{}, cand lifecycleCandidate) bool {
for _, k := range presenceKeys(cand.vmID, cand.vmUUID, cand.name) {
if _, ok := presence[k]; ok {
return true
}
}
return false
}
// findDeletionInTables walks ordered hourly tables for a vCenter and returns the first confirmed deletion time
// (requiring two consecutive misses), the time of the first miss for cross-day handling, and the last table where
// the VM was seen so we can backfill deletion time into that snapshot.
func findDeletionInTables(ctx context.Context, dbConn *sqlx.DB, tables []snapshotTable, vcenter string, cand *lifecycleCandidate) (int64, int64, string) {
var lastSeen int64
var lastSeenTable string
var firstMiss int64
for i, tbl := range tables {
rows, err := querySnapshotRows(ctx, dbConn, tbl.Table, []string{"VmId", "VmUuid", "Name", "Cluster"}, `"Vcenter" = ? AND "VmId" = ?`, vcenter, cand.vmID)
if err != nil {
continue
}
seen := false
if rows.Next() {
var vmId, vmUuid, name, cluster sql.NullString
if scanErr := rows.Scan(&vmId, &vmUuid, &name, &cluster); scanErr == nil {
seen = true
lastSeen = tbl.Time
lastSeenTable = tbl.Table
if cand.vmUUID == "" && vmUuid.Valid {
cand.vmUUID = vmUuid.String
}
if cand.name == "" && name.Valid {
cand.name = name.String
}
if cand.cluster == "" && cluster.Valid {
cand.cluster = cluster.String
}
}
}
rows.Close()
if lastSeen > 0 && !seen && firstMiss == 0 {
firstMiss = tbl.Time
if i+1 < len(tables) {
if seen2, _ := candSeenInTable(ctx, dbConn, tables[i+1].Table, vcenter, cand.vmID); !seen2 {
return firstMiss, firstMiss, lastSeenTable
}
}
}
}
return 0, firstMiss, lastSeenTable
}
func candSeenInTable(ctx context.Context, dbConn *sqlx.DB, table, vcenter, vmID string) (bool, error) {
rows, err := querySnapshotRows(ctx, dbConn, table, []string{"VmId"}, `"Vcenter" = ? AND "VmId" = ? LIMIT 1`, vcenter, vmID)
if err != nil {
return false, err
}
defer rows.Close()
return rows.Next(), nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,11 +3,9 @@ package tasks
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"runtime"
"strings"
"time"
"vctp/db/queries"
@@ -110,7 +108,7 @@ func (c *CronTask) RunVcenterPoll(ctx context.Context, logger *slog.Logger) erro
}
}
c.Logger.Debug("Finished checking vcenter", "url", url)
vc.Logout()
_ = vc.Logout(ctx)
}
c.Logger.Debug("Finished polling vcenters")
@@ -130,8 +128,6 @@ func (c *CronTask) UpdateVmInventory(vmObj *mo.VirtualMachine, vc *vcenter.Vcent
existingUpdateFound bool
)
// TODO - how to prevent creating a new record every polling cycle?
params := queries.CreateUpdateParams{
InventoryId: sql.NullInt64{Int64: dbVm.Iid, Valid: dbVm.Iid > 0},
}
@@ -181,12 +177,8 @@ func (c *CronTask) UpdateVmInventory(vmObj *mo.VirtualMachine, vc *vcenter.Vcent
}
}
// TODO - should we bother to check if disk space has changed?
if updateType != "unknown" {
// TODO query updates table to see if there is already an update of this type and the new value
// Check if we already have an existing update record for this same change
checkParams := queries.GetVmUpdatesParams{
InventoryId: sql.NullInt64{Int64: dbVm.Iid, Valid: dbVm.Iid > 0},
UpdateType: updateType,
@@ -241,7 +233,6 @@ func (c *CronTask) UpdateVmInventory(vmObj *mo.VirtualMachine, vc *vcenter.Vcent
// add sleep to slow down mass VM additions
utils.SleepWithContext(ctx, (10 * time.Millisecond))
}
}
return nil
@@ -409,6 +400,7 @@ func (c *CronTask) AddVmToInventory(vmObject *mo.VirtualMachine, vc *vcenter.Vce
return nil
}
/*
// prettyPrint comes from https://gist.github.com/sfate/9d45f6c5405dc4c9bf63bf95fe6d1a7c
func prettyPrint(args ...interface{}) {
var caller string
@@ -436,3 +428,4 @@ func prettyPrint(args ...interface{}) {
fmt.Printf("%s%s\n", prefix, string(s))
}
}
*/

View File

@@ -2,8 +2,14 @@ package tasks
import (
"context"
"database/sql"
"fmt"
"log/slog"
"os"
"runtime"
"sort"
"strings"
"sync"
"time"
"vctp/db"
"vctp/internal/metrics"
@@ -35,19 +41,49 @@ func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time
return err
}
granularity := strings.ToLower(strings.TrimSpace(c.Settings.Values.Settings.MonthlyAggregationGranularity))
if granularity == "" {
granularity = "hourly"
}
if granularity != "hourly" && granularity != "daily" {
c.Logger.Warn("unknown monthly aggregation granularity; defaulting to hourly", "granularity", granularity)
granularity = "hourly"
}
monthStart := time.Date(targetMonth.Year(), targetMonth.Month(), 1, 0, 0, 0, 0, targetMonth.Location())
monthEnd := monthStart.AddDate(0, 1, 0)
dbConn := c.Database.DB()
db.SetPostgresWorkMem(ctx, dbConn, c.Settings.Values.Settings.PostgresWorkMemMB)
driver := strings.ToLower(dbConn.DriverName())
useGoAgg := os.Getenv("MONTHLY_AGG_GO") == "1"
if !useGoAgg && granularity == "hourly" && driver == "sqlite" {
c.Logger.Warn("SQL monthly aggregation is slow on sqlite; overriding to Go path", "granularity", granularity)
useGoAgg = true
}
var snapshots []report.SnapshotRecord
var unionColumns []string
if granularity == "daily" {
dailySnapshots, err := report.SnapshotRecordsWithFallback(ctx, c.Database, "daily", "inventory_daily_summary_", "20060102", monthStart, monthEnd)
if err != nil {
return err
}
dailySnapshots = filterRecordsInRange(dailySnapshots, monthStart, monthEnd)
dbConn := c.Database.DB()
db.SetPostgresWorkMem(ctx, dbConn, c.Settings.Values.Settings.PostgresWorkMemMB)
dailySnapshots = filterSnapshotsWithRows(ctx, dbConn, dailySnapshots)
if len(dailySnapshots) == 0 {
return fmt.Errorf("no hourly snapshot tables found for %s", targetMonth.Format("2006-01"))
snapshots = dailySnapshots
unionColumns = monthlyUnionColumns
} else {
hourlySnapshots, err := report.SnapshotRecordsWithFallback(ctx, c.Database, "hourly", "inventory_hourly_", "epoch", monthStart, monthEnd)
if err != nil {
return err
}
hourlySnapshots = filterRecordsInRange(hourlySnapshots, monthStart, monthEnd)
hourlySnapshots = filterSnapshotsWithRows(ctx, dbConn, hourlySnapshots)
snapshots = hourlySnapshots
unionColumns = summaryUnionColumns
}
if len(snapshots) == 0 {
return fmt.Errorf("no %s snapshot tables found for %s", granularity, targetMonth.Format("2006-01"))
}
monthlyTable, err := monthlySummaryTableName(targetMonth)
@@ -69,11 +105,36 @@ func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time
}
}
dailyTables := make([]string, 0, len(dailySnapshots))
for _, snapshot := range dailySnapshots {
dailyTables = append(dailyTables, snapshot.TableName)
// Optional Go-based aggregation path.
if useGoAgg {
if granularity == "daily" {
c.Logger.Debug("Using go implementation of monthly aggregation (daily)")
if err := c.aggregateMonthlySummaryGo(ctx, monthStart, monthEnd, monthlyTable, snapshots); err != nil {
c.Logger.Warn("go-based monthly aggregation failed, falling back to SQL path", "error", err)
} else {
metrics.RecordMonthlyAggregation(time.Since(jobStart), nil)
c.Logger.Debug("Finished monthly inventory aggregation (Go path)", "summary_table", monthlyTable)
return nil
}
unionQuery, err := buildUnionQuery(dailyTables, summaryUnionColumns, templateExclusionFilter())
} else if granularity == "hourly" {
c.Logger.Debug("Using go implementation of monthly aggregation (hourly)")
if err := c.aggregateMonthlySummaryGoHourly(ctx, monthStart, monthEnd, monthlyTable, snapshots); err != nil {
c.Logger.Warn("go-based monthly aggregation failed, falling back to SQL path", "error", err)
} else {
metrics.RecordMonthlyAggregation(time.Since(jobStart), nil)
c.Logger.Debug("Finished monthly inventory aggregation (Go path)", "summary_table", monthlyTable)
return nil
}
} else {
c.Logger.Warn("MONTHLY_AGG_GO is set but granularity is unsupported; using SQL path", "granularity", granularity)
}
}
tables := make([]string, 0, len(snapshots))
for _, snapshot := range snapshots {
tables = append(tables, snapshot.TableName)
}
unionQuery, err := buildUnionQuery(tables, unionColumns, templateExclusionFilter())
if err != nil {
return err
}
@@ -91,7 +152,12 @@ func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time
)
}
insertQuery, err := db.BuildMonthlySummaryInsert(monthlyTable, unionQuery)
var insertQuery string
if granularity == "daily" {
insertQuery, err = db.BuildMonthlySummaryInsert(monthlyTable, unionQuery)
} else {
insertQuery, err = db.BuildDailySummaryInsert(monthlyTable, unionQuery)
}
if err != nil {
return err
}
@@ -100,12 +166,13 @@ func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time
c.Logger.Error("failed to aggregate monthly inventory", "error", err, "month", targetMonth.Format("2006-01"))
return err
}
// Backfill missing creation times to the start of the month for rows lacking creation info.
if _, err := dbConn.ExecContext(ctx,
`UPDATE `+monthlyTable+` SET "CreationTime" = $1 WHERE "CreationTime" IS NULL OR "CreationTime" = 0`,
monthStart.Unix(),
); err != nil {
c.Logger.Warn("failed to normalize creation times for monthly summary", "error", err, "table", monthlyTable)
if applied, err := db.ApplyLifecycleDeletionToSummary(ctx, dbConn, monthlyTable, monthStart.Unix(), monthEnd.Unix()); err != nil {
c.Logger.Warn("failed to apply lifecycle deletions to monthly summary", "error", err, "table", monthlyTable)
} else {
c.Logger.Info("Monthly aggregation deletion times", "source_lifecycle_cache", applied)
}
if err := db.UpdateSummaryPresenceByWindow(ctx, dbConn, monthlyTable, monthStart.Unix(), monthEnd.Unix()); err != nil {
c.Logger.Warn("failed to update monthly AvgIsPresent from lifecycle window", "error", err, "table", monthlyTable)
}
rowCount, err := db.TableRowCount(ctx, dbConn, monthlyTable)
if err != nil {
@@ -131,3 +198,542 @@ func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time
func monthlySummaryTableName(t time.Time) (string, error) {
return db.SafeTableName(fmt.Sprintf("inventory_monthly_summary_%s", t.Format("200601")))
}
// aggregateMonthlySummaryGoHourly aggregates hourly snapshots directly into the monthly summary table.
func (c *CronTask) aggregateMonthlySummaryGoHourly(ctx context.Context, monthStart, monthEnd time.Time, summaryTable string, hourlySnapshots []report.SnapshotRecord) error {
jobStart := time.Now()
dbConn := c.Database.DB()
if err := clearTable(ctx, dbConn, summaryTable); err != nil {
return err
}
if len(hourlySnapshots) == 0 {
return fmt.Errorf("no hourly snapshot tables found for %s", monthStart.Format("2006-01"))
}
totalSamples := len(hourlySnapshots)
var (
aggMap map[dailyAggKey]*dailyAggVal
snapTimes []int64
)
if db.TableExists(ctx, dbConn, "vm_hourly_stats") {
cacheAgg, cacheTimes, cacheErr := c.scanHourlyCache(ctx, monthStart, monthEnd)
if cacheErr != nil {
c.Logger.Warn("failed to use hourly cache, falling back to table scans", "error", cacheErr)
} else if len(cacheAgg) > 0 {
c.Logger.Debug("using hourly cache for monthly aggregation", "month", monthStart.Format("2006-01"), "snapshots", len(cacheTimes), "vm_count", len(cacheAgg))
aggMap = cacheAgg
snapTimes = cacheTimes
totalSamples = len(cacheTimes)
}
}
if aggMap == nil {
var errScan error
aggMap, errScan = c.scanHourlyTablesParallel(ctx, hourlySnapshots)
if errScan != nil {
return errScan
}
c.Logger.Debug("scanned hourly tables for monthly aggregation", "month", monthStart.Format("2006-01"), "tables", len(hourlySnapshots), "vm_count", len(aggMap))
if len(aggMap) == 0 {
return fmt.Errorf("no VM records aggregated for %s", monthStart.Format("2006-01"))
}
snapTimes = make([]int64, 0, len(hourlySnapshots))
for _, snap := range hourlySnapshots {
snapTimes = append(snapTimes, snap.SnapshotTime.Unix())
}
sort.Slice(snapTimes, func(i, j int) bool { return snapTimes[i] < snapTimes[j] })
}
lifecycleDeletions := c.applyLifecycleDeletions(ctx, aggMap, monthStart, monthEnd)
c.Logger.Info("Monthly aggregation deletion times", "source_lifecycle_cache", lifecycleDeletions)
inventoryDeletions := c.applyInventoryDeletions(ctx, aggMap, monthStart, monthEnd)
c.Logger.Info("Monthly aggregation deletion times", "source_inventory", inventoryDeletions)
if len(snapTimes) > 0 {
maxSnap := snapTimes[len(snapTimes)-1]
inferredDeletions := 0
for _, v := range aggMap {
if v.deletion != 0 {
continue
}
consecutiveMisses := 0
firstMiss := int64(0)
for _, t := range snapTimes {
if t <= v.lastSeen {
continue
}
if _, ok := v.seen[t]; ok {
consecutiveMisses = 0
firstMiss = 0
continue
}
consecutiveMisses++
if firstMiss == 0 {
firstMiss = t
}
if consecutiveMisses >= 2 {
v.deletion = firstMiss
inferredDeletions++
break
}
}
if v.deletion == 0 && v.lastSeen < maxSnap && firstMiss > 0 {
c.Logger.Debug("pending deletion inference (insufficient consecutive misses)", "vm_id", v.key.VmId, "vm_uuid", v.key.VmUuid, "name", v.key.Name, "last_seen", v.lastSeen, "first_missing_snapshot", firstMiss)
}
}
c.Logger.Info("Monthly aggregation deletion times", "source_inferred", inferredDeletions)
}
totalSamplesByVcenter := sampleCountsByVcenter(aggMap)
if err := c.insertDailyAggregates(ctx, summaryTable, aggMap, totalSamples, totalSamplesByVcenter); err != nil {
return err
}
if err := db.UpdateSummaryPresenceByWindow(ctx, dbConn, summaryTable, monthStart.Unix(), monthEnd.Unix()); err != nil {
c.Logger.Warn("failed to update monthly AvgIsPresent from lifecycle window (Go hourly)", "error", err, "table", summaryTable)
}
db.AnalyzeTableIfPostgres(ctx, dbConn, summaryTable)
rowCount, err := db.TableRowCount(ctx, dbConn, summaryTable)
if err != nil {
c.Logger.Warn("unable to count monthly summary rows (Go hourly)", "error", err, "table", summaryTable)
}
if err := report.RegisterSnapshot(ctx, c.Database, "monthly", summaryTable, monthStart, rowCount); err != nil {
c.Logger.Warn("failed to register monthly snapshot (Go hourly)", "error", err, "table", summaryTable)
}
if err := c.generateReport(ctx, summaryTable); err != nil {
c.Logger.Warn("failed to generate monthly report (Go hourly)", "error", err, "table", summaryTable)
return err
}
c.Logger.Debug("Finished monthly inventory aggregation (Go hourly)",
"summary_table", summaryTable,
"duration", time.Since(jobStart),
"tables_scanned", len(hourlySnapshots),
"rows_written", rowCount,
"total_samples", totalSamples,
)
return nil
}
// aggregateMonthlySummaryGo mirrors the SQL-based monthly aggregation but performs the work in Go,
// reading daily summaries in parallel and reducing them to a single monthly summary table.
func (c *CronTask) aggregateMonthlySummaryGo(ctx context.Context, monthStart, monthEnd time.Time, summaryTable string, dailySnapshots []report.SnapshotRecord) error {
jobStart := time.Now()
dbConn := c.Database.DB()
if err := clearTable(ctx, dbConn, summaryTable); err != nil {
return err
}
// Build union query for lifecycle refinement after inserts.
dailyTables := make([]string, 0, len(dailySnapshots))
for _, snapshot := range dailySnapshots {
dailyTables = append(dailyTables, snapshot.TableName)
}
unionQuery, err := buildUnionQuery(dailyTables, monthlyUnionColumns, templateExclusionFilter())
if err != nil {
return err
}
aggMap, err := c.scanDailyTablesParallel(ctx, dailySnapshots)
if err != nil {
return err
}
if len(aggMap) == 0 {
cacheAgg, cacheErr := c.scanDailyRollup(ctx, monthStart, monthEnd)
if cacheErr == nil && len(cacheAgg) > 0 {
aggMap = cacheAgg
} else if cacheErr != nil {
c.Logger.Warn("failed to read daily rollup cache; using table scan", "error", cacheErr)
}
}
if len(aggMap) == 0 {
return fmt.Errorf("no VM records aggregated for %s", monthStart.Format("2006-01"))
}
if err := c.insertMonthlyAggregates(ctx, summaryTable, aggMap); err != nil {
return err
}
if applied, err := db.ApplyLifecycleDeletionToSummary(ctx, dbConn, summaryTable, monthStart.Unix(), monthEnd.Unix()); err != nil {
c.Logger.Warn("failed to apply lifecycle deletions to monthly summary (Go)", "error", err, "table", summaryTable)
} else {
c.Logger.Info("Monthly aggregation deletion times", "source_lifecycle_cache", applied)
}
if err := db.RefineCreationDeletionFromUnion(ctx, dbConn, summaryTable, unionQuery); err != nil {
c.Logger.Warn("failed to refine creation/deletion times (monthly Go)", "error", err, "table", summaryTable)
}
if err := db.UpdateSummaryPresenceByWindow(ctx, dbConn, summaryTable, monthStart.Unix(), monthEnd.Unix()); err != nil {
c.Logger.Warn("failed to update monthly AvgIsPresent from lifecycle window (Go)", "error", err, "table", summaryTable)
}
db.AnalyzeTableIfPostgres(ctx, dbConn, summaryTable)
rowCount, err := db.TableRowCount(ctx, dbConn, summaryTable)
if err != nil {
c.Logger.Warn("unable to count monthly summary rows", "error", err, "table", summaryTable)
}
if err := report.RegisterSnapshot(ctx, c.Database, "monthly", summaryTable, monthStart, rowCount); err != nil {
c.Logger.Warn("failed to register monthly snapshot", "error", err, "table", summaryTable)
}
if err := c.generateReport(ctx, summaryTable); err != nil {
c.Logger.Warn("failed to generate monthly report (Go)", "error", err, "table", summaryTable)
return err
}
c.Logger.Debug("Finished monthly inventory aggregation (Go path)", "summary_table", summaryTable, "duration", time.Since(jobStart))
return nil
}
func (c *CronTask) scanDailyTablesParallel(ctx context.Context, snapshots []report.SnapshotRecord) (map[monthlyAggKey]*monthlyAggVal, error) {
agg := make(map[monthlyAggKey]*monthlyAggVal, 1024)
mu := sync.Mutex{}
workers := runtime.NumCPU()
if workers < 2 {
workers = 2
}
if workers > len(snapshots) {
workers = len(snapshots)
}
jobs := make(chan report.SnapshotRecord, len(snapshots))
wg := sync.WaitGroup{}
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for snap := range jobs {
rows, err := c.scanDailyTable(ctx, snap)
if err != nil {
c.Logger.Warn("failed to scan daily summary", "table", snap.TableName, "error", err)
continue
}
mu.Lock()
for k, v := range rows {
if existing, ok := agg[k]; ok {
mergeMonthlyAgg(existing, v)
} else {
agg[k] = v
}
}
mu.Unlock()
}
}()
}
for _, snap := range snapshots {
jobs <- snap
}
close(jobs)
wg.Wait()
return agg, nil
}
func mergeMonthlyAgg(dst, src *monthlyAggVal) {
if src.creation > 0 && (dst.creation == 0 || src.creation < dst.creation) {
dst.creation = src.creation
}
// If creation is unknown in all daily summaries, leave it zero for reports (VM trace handles approximation separately).
if src.deletion > 0 && (dst.deletion == 0 || src.deletion < dst.deletion) {
dst.deletion = src.deletion
}
if src.lastSnapshot.After(dst.lastSnapshot) {
dst.lastSnapshot = src.lastSnapshot
if src.inventoryId != 0 {
dst.inventoryId = src.inventoryId
}
dst.resourcePool = src.resourcePool
dst.datacenter = src.datacenter
dst.cluster = src.cluster
dst.folder = src.folder
dst.isTemplate = src.isTemplate
dst.poweredOn = src.poweredOn
dst.srmPlaceholder = src.srmPlaceholder
dst.provisioned = src.provisioned
dst.vcpuCount = src.vcpuCount
dst.ramGB = src.ramGB
dst.eventKey = src.eventKey
dst.cloudId = src.cloudId
}
dst.samplesPresent += src.samplesPresent
dst.totalSamples += src.totalSamples
dst.sumVcpu += src.sumVcpu
dst.sumRam += src.sumRam
dst.sumDisk += src.sumDisk
dst.tinWeighted += src.tinWeighted
dst.bronzeWeighted += src.bronzeWeighted
dst.silverWeighted += src.silverWeighted
dst.goldWeighted += src.goldWeighted
}
func (c *CronTask) scanDailyTable(ctx context.Context, snap report.SnapshotRecord) (map[monthlyAggKey]*monthlyAggVal, error) {
dbConn := c.Database.DB()
query := fmt.Sprintf(`
SELECT
"InventoryId",
"Name","Vcenter","VmId","VmUuid","EventKey","CloudId","ResourcePool","Datacenter","Cluster","Folder",
COALESCE("ProvisionedDisk",0) AS disk,
COALESCE("VcpuCount",0) AS vcpu,
COALESCE("RamGB",0) AS ram,
COALESCE("CreationTime",0) AS creation,
COALESCE("DeletionTime",0) AS deletion,
COALESCE("SamplesPresent",0) AS samples_present,
"AvgVcpuCount","AvgRamGB","AvgProvisionedDisk","AvgIsPresent",
"PoolTinPct","PoolBronzePct","PoolSilverPct","PoolGoldPct",
"Tin","Bronze","Silver","Gold","IsTemplate","PoweredOn","SrmPlaceholder"
FROM %s
`, snap.TableName)
rows, err := dbConn.QueryxContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[monthlyAggKey]*monthlyAggVal, 256)
for rows.Next() {
var (
inventoryId sql.NullInt64
name, vcenter, vmId, vmUuid string
eventKey, cloudId sql.NullString
resourcePool, datacenter, cluster, folder sql.NullString
isTemplate, poweredOn, srmPlaceholder sql.NullString
disk, avgVcpu, avgRam, avgDisk sql.NullFloat64
avgIsPresent sql.NullFloat64
poolTin, poolBronze, poolSilver, poolGold sql.NullFloat64
tinPct, bronzePct, silverPct, goldPct sql.NullFloat64
vcpu, ram sql.NullInt64
creation, deletion sql.NullInt64
samplesPresent sql.NullInt64
)
if err := rows.Scan(
&inventoryId,
&name, &vcenter, &vmId, &vmUuid, &eventKey, &cloudId, &resourcePool, &datacenter, &cluster, &folder,
&disk, &vcpu, &ram, &creation, &deletion, &samplesPresent,
&avgVcpu, &avgRam, &avgDisk, &avgIsPresent,
&poolTin, &poolBronze, &poolSilver, &poolGold,
&tinPct, &bronzePct, &silverPct, &goldPct,
&isTemplate, &poweredOn, &srmPlaceholder,
); err != nil {
c.Logger.Warn("failed to scan daily summary row", "table", snap.TableName, "error", err)
continue
}
templateVal := strings.TrimSpace(isTemplate.String)
if strings.EqualFold(templateVal, "true") || templateVal == "1" {
continue
}
key := monthlyAggKey{Vcenter: vcenter, VmId: vmId, VmUuid: vmUuid, Name: name}
agg := &monthlyAggVal{
key: key,
inventoryId: inventoryId.Int64,
eventKey: eventKey.String,
cloudId: cloudId.String,
resourcePool: resourcePool.String,
datacenter: datacenter.String,
cluster: cluster.String,
folder: folder.String,
isTemplate: isTemplate.String,
poweredOn: poweredOn.String,
srmPlaceholder: srmPlaceholder.String,
provisioned: disk.Float64,
vcpuCount: vcpu.Int64,
ramGB: ram.Int64,
creation: creation.Int64,
deletion: deletion.Int64,
lastSnapshot: snap.SnapshotTime,
samplesPresent: samplesPresent.Int64,
}
totalSamplesDay := float64(samplesPresent.Int64)
if avgIsPresent.Valid && avgIsPresent.Float64 > 0 {
totalSamplesDay = float64(samplesPresent.Int64) / avgIsPresent.Float64
}
agg.totalSamples = totalSamplesDay
if avgVcpu.Valid {
agg.sumVcpu = avgVcpu.Float64 * totalSamplesDay
}
if avgRam.Valid {
agg.sumRam = avgRam.Float64 * totalSamplesDay
}
if avgDisk.Valid {
agg.sumDisk = avgDisk.Float64 * totalSamplesDay
}
if poolTin.Valid {
agg.tinWeighted = (poolTin.Float64 / 100.0) * totalSamplesDay
}
if poolBronze.Valid {
agg.bronzeWeighted = (poolBronze.Float64 / 100.0) * totalSamplesDay
}
if poolSilver.Valid {
agg.silverWeighted = (poolSilver.Float64 / 100.0) * totalSamplesDay
}
if poolGold.Valid {
agg.goldWeighted = (poolGold.Float64 / 100.0) * totalSamplesDay
}
result[key] = agg
}
return result, rows.Err()
}
// scanDailyRollup aggregates monthly data from vm_daily_rollup cache.
func (c *CronTask) scanDailyRollup(ctx context.Context, start, end time.Time) (map[monthlyAggKey]*monthlyAggVal, error) {
dbConn := c.Database.DB()
if !db.TableExists(ctx, dbConn, "vm_daily_rollup") {
return map[monthlyAggKey]*monthlyAggVal{}, nil
}
query := `
SELECT
"Date","Vcenter","VmId","VmUuid","Name","CreationTime","DeletionTime",
"SamplesPresent","TotalSamples","SumVcpu","SumRam","SumDisk",
"TinHits","BronzeHits","SilverHits","GoldHits",
"LastResourcePool","LastDatacenter","LastCluster","LastFolder",
"LastProvisionedDisk","LastVcpuCount","LastRamGB","IsTemplate","PoweredOn","SrmPlaceholder"
FROM vm_daily_rollup
WHERE "Date" >= ? AND "Date" < ?
`
bind := dbConn.Rebind(query)
rows, err := dbConn.QueryxContext(ctx, bind, start.Unix(), end.Unix())
if err != nil {
return nil, err
}
defer rows.Close()
agg := make(map[monthlyAggKey]*monthlyAggVal, 512)
for rows.Next() {
var (
date sql.NullInt64
vcenter, vmId, vmUuid, name string
creation, deletion sql.NullInt64
samplesPresent, totalSamples sql.NullInt64
sumVcpu, sumRam, sumDisk sql.NullFloat64
tinHits, bronzeHits, silverHits, goldHits sql.NullInt64
lastPool, lastDc, lastCluster, lastFolder sql.NullString
lastDisk, lastVcpu, lastRam sql.NullFloat64
isTemplate, poweredOn, srmPlaceholder sql.NullString
)
if err := rows.Scan(
&date, &vcenter, &vmId, &vmUuid, &name, &creation, &deletion,
&samplesPresent, &totalSamples, &sumVcpu, &sumRam, &sumDisk,
&tinHits, &bronzeHits, &silverHits, &goldHits,
&lastPool, &lastDc, &lastCluster, &lastFolder,
&lastDisk, &lastVcpu, &lastRam, &isTemplate, &poweredOn, &srmPlaceholder,
); err != nil {
continue
}
templateVal := strings.TrimSpace(isTemplate.String)
if strings.EqualFold(templateVal, "true") || templateVal == "1" {
continue
}
key := monthlyAggKey{Vcenter: vcenter, VmId: vmId, VmUuid: vmUuid, Name: name}
val := &monthlyAggVal{
key: key,
resourcePool: lastPool.String,
datacenter: lastDc.String,
cluster: lastCluster.String,
folder: lastFolder.String,
isTemplate: isTemplate.String,
poweredOn: poweredOn.String,
srmPlaceholder: srmPlaceholder.String,
provisioned: lastDisk.Float64,
vcpuCount: int64(lastVcpu.Float64),
ramGB: int64(lastRam.Float64),
creation: creation.Int64,
deletion: deletion.Int64,
lastSnapshot: time.Unix(date.Int64, 0),
samplesPresent: samplesPresent.Int64,
totalSamples: float64(totalSamples.Int64),
sumVcpu: sumVcpu.Float64,
sumRam: sumRam.Float64,
sumDisk: sumDisk.Float64,
tinWeighted: float64(tinHits.Int64),
bronzeWeighted: float64(bronzeHits.Int64),
silverWeighted: float64(silverHits.Int64),
goldWeighted: float64(goldHits.Int64),
}
if existing, ok := agg[key]; ok {
mergeMonthlyAgg(existing, val)
} else {
agg[key] = val
}
}
return agg, rows.Err()
}
func (c *CronTask) insertMonthlyAggregates(ctx context.Context, summaryTable string, aggMap map[monthlyAggKey]*monthlyAggVal) error {
dbConn := c.Database.DB()
columns := []string{
"InventoryId", "Name", "Vcenter", "VmId", "EventKey", "CloudId", "CreationTime", "DeletionTime",
"ResourcePool", "Datacenter", "Cluster", "Folder", "ProvisionedDisk", "VcpuCount",
"RamGB", "IsTemplate", "PoweredOn", "SrmPlaceholder", "VmUuid", "SamplesPresent",
"AvgVcpuCount", "AvgRamGB", "AvgProvisionedDisk", "AvgIsPresent",
"PoolTinPct", "PoolBronzePct", "PoolSilverPct", "PoolGoldPct",
"Tin", "Bronze", "Silver", "Gold",
}
placeholders := make([]string, len(columns))
for i := range columns {
placeholders[i] = "?"
}
stmtText := fmt.Sprintf(`INSERT INTO %s (%s) VALUES (%s)`, summaryTable, strings.Join(columns, ","), strings.Join(placeholders, ","))
stmtText = dbConn.Rebind(stmtText)
tx, err := dbConn.BeginTxx(ctx, nil)
if err != nil {
return err
}
stmt, err := tx.PreparexContext(ctx, stmtText)
if err != nil {
tx.Rollback()
return err
}
defer stmt.Close()
for _, v := range aggMap {
inventoryVal := sql.NullInt64{}
if v.inventoryId != 0 {
inventoryVal = sql.NullInt64{Int64: v.inventoryId, Valid: true}
}
avgVcpu := sql.NullFloat64{}
avgRam := sql.NullFloat64{}
avgDisk := sql.NullFloat64{}
avgIsPresent := sql.NullFloat64{}
tinPct := sql.NullFloat64{}
bronzePct := sql.NullFloat64{}
silverPct := sql.NullFloat64{}
goldPct := sql.NullFloat64{}
if v.totalSamples > 0 {
avgVcpu = sql.NullFloat64{Float64: v.sumVcpu / v.totalSamples, Valid: true}
avgRam = sql.NullFloat64{Float64: v.sumRam / v.totalSamples, Valid: true}
avgDisk = sql.NullFloat64{Float64: v.sumDisk / v.totalSamples, Valid: true}
avgIsPresent = sql.NullFloat64{Float64: float64(v.samplesPresent) / v.totalSamples, Valid: true}
tinPct = sql.NullFloat64{Float64: 100.0 * v.tinWeighted / v.totalSamples, Valid: true}
bronzePct = sql.NullFloat64{Float64: 100.0 * v.bronzeWeighted / v.totalSamples, Valid: true}
silverPct = sql.NullFloat64{Float64: 100.0 * v.silverWeighted / v.totalSamples, Valid: true}
goldPct = sql.NullFloat64{Float64: 100.0 * v.goldWeighted / v.totalSamples, Valid: true}
}
if _, err := stmt.ExecContext(ctx,
inventoryVal,
v.key.Name, v.key.Vcenter, v.key.VmId, v.eventKey, v.cloudId, v.creation, v.deletion,
v.resourcePool, v.datacenter, v.cluster, v.folder, v.provisioned, v.vcpuCount, v.ramGB,
v.isTemplate, v.poweredOn, v.srmPlaceholder, v.key.VmUuid, v.samplesPresent,
avgVcpu, avgRam, avgDisk, avgIsPresent,
tinPct, bronzePct, silverPct, goldPct,
tinPct, bronzePct, silverPct, goldPct,
); err != nil {
tx.Rollback()
return err
}
}
return tx.Commit()
}

View File

@@ -165,10 +165,7 @@ func (c *CronTask) RunVmCheck(ctx context.Context, logger *slog.Logger) error {
poweredOn = "TRUE"
}
err = vc.Logout()
if err != nil {
c.Logger.Error("unable to logout of vcenter", "error", err)
}
_ = vc.Logout(ctx)
if foundVm {
c.Logger.Debug("Adding to Inventory table", "vm_name", evt.VmName.String, "vcpus", numVcpus, "ram", numRam, "dc", evt.DatacenterId.String)

View File

@@ -1,17 +0,0 @@
package tasks
import (
"log/slog"
"vctp/db"
"vctp/internal/settings"
"vctp/internal/vcenter"
)
// CronTask stores runtime information to be used by tasks
type CronTask struct {
Logger *slog.Logger
Database db.Database
Settings *settings.Settings
VcCreds *vcenter.VcenterLogin
FirstHourlySnapshotCheck bool
}

123
internal/tasks/types.go Normal file
View File

@@ -0,0 +1,123 @@
package tasks
import (
"database/sql"
"log/slog"
"time"
"vctp/db"
"vctp/internal/settings"
"vctp/internal/vcenter"
)
// CronTask stores runtime information to be used by tasks.
type CronTask struct {
Logger *slog.Logger
Database db.Database
Settings *settings.Settings
VcCreds *vcenter.VcenterLogin
FirstHourlySnapshotCheck bool
}
// InventorySnapshotRow represents a single VM snapshot row.
type InventorySnapshotRow struct {
InventoryId sql.NullInt64
Name string
Vcenter string
VmId sql.NullString
EventKey sql.NullString
CloudId sql.NullString
CreationTime sql.NullInt64
DeletionTime sql.NullInt64
ResourcePool sql.NullString
Datacenter sql.NullString
Cluster sql.NullString
Folder sql.NullString
ProvisionedDisk sql.NullFloat64
VcpuCount sql.NullInt64
RamGB sql.NullInt64
IsTemplate string
PoweredOn string
SrmPlaceholder string
VmUuid sql.NullString
SnapshotTime int64
}
// snapshotTotals aliases DB snapshot totals for convenience.
type snapshotTotals = db.SnapshotTotals
type dailyAggKey struct {
Vcenter string
VmId string
VmUuid string
Name string
}
type dailyAggVal struct {
key dailyAggKey
resourcePool string
datacenter string
cluster string
folder string
isTemplate string
poweredOn string
srmPlaceholder string
creation int64
firstSeen int64
lastSeen int64
lastDisk float64
lastVcpu int64
lastRam int64
sumVcpu int64
sumRam int64
sumDisk float64
samples int64
tinHits int64
bronzeHits int64
silverHits int64
goldHits int64
seen map[int64]struct{}
deletion int64
}
type monthlyAggKey struct {
Vcenter string
VmId string
VmUuid string
Name string
}
type monthlyAggVal struct {
key monthlyAggKey
inventoryId int64
eventKey string
cloudId string
resourcePool string
datacenter string
cluster string
folder string
isTemplate string
poweredOn string
srmPlaceholder string
creation int64
deletion int64
lastSnapshot time.Time
provisioned float64
vcpuCount int64
ramGB int64
samplesPresent int64
totalSamples float64
sumVcpu float64
sumRam float64
sumDisk float64
tinWeighted float64
bronzeWeighted float64
silverWeighted float64
goldWeighted float64
}
// CronTracker manages re-entry protection and status recording for cron jobs.
type CronTracker struct {
db db.Database
bindType int
}

View File

@@ -7,8 +7,10 @@ import (
"net/url"
"path"
"strings"
"time"
"github.com/vmware/govmomi"
"github.com/vmware/govmomi/event"
"github.com/vmware/govmomi/find"
"github.com/vmware/govmomi/object"
"github.com/vmware/govmomi/view"
@@ -36,6 +38,15 @@ type VmProperties struct {
ResourcePool string
}
var clientUserAgent = "vCTP"
// SetUserAgent customizes the User-Agent used when talking to vCenter.
func SetUserAgent(ua string) {
if strings.TrimSpace(ua) != "" {
clientUserAgent = ua
}
}
type HostLookup struct {
Cluster string
Datacenter string
@@ -87,6 +98,9 @@ func (v *Vcenter) Login(vUrl string) error {
v.Logger.Error("Unable to connect to vCenter", "error", err)
return fmt.Errorf("unable to connect to vCenter : %s", err)
}
if clientUserAgent != "" {
c.Client.UserAgent = clientUserAgent
}
//defer c.Logout(v.ctx)
@@ -97,24 +111,21 @@ func (v *Vcenter) Login(vUrl string) error {
return nil
}
func (v *Vcenter) Logout() error {
//v.Logger.Debug("vcenter logging out")
if v.ctx == nil {
func (v *Vcenter) Logout(ctx context.Context) error {
if ctx == nil {
ctx = v.ctx
}
if ctx == nil {
v.Logger.Warn("Nil context, unable to logout")
return nil
}
if v.client.Valid() {
//v.Logger.Debug("vcenter client is valid. Logging out")
return v.client.Logout(v.ctx)
} else {
return v.client.Logout(ctx)
}
v.Logger.Debug("vcenter client is not valid")
return nil
}
}
func (v *Vcenter) GetAllVmReferences() ([]*object.VirtualMachine, error) {
var results []*object.VirtualMachine
finder := find.NewFinder(v.client.Client, true)
@@ -186,6 +197,205 @@ func (v *Vcenter) GetAllVMsWithProps() ([]mo.VirtualMachine, error) {
return vms, nil
}
// FindVmDeletionEvents returns a map of MoRef (VmId) to the deletion event time within the given window.
func (v *Vcenter) FindVmDeletionEvents(ctx context.Context, begin, end time.Time) (map[string]time.Time, error) {
return v.findVmDeletionEvents(ctx, begin, end, nil)
}
// FindVmDeletionEventsForCandidates returns deletion event times for the provided VM IDs only.
func (v *Vcenter) FindVmDeletionEventsForCandidates(ctx context.Context, begin, end time.Time, candidates []string) (map[string]time.Time, error) {
if len(candidates) == 0 {
return map[string]time.Time{}, nil
}
candidateSet := make(map[string]struct{}, len(candidates))
for _, id := range candidates {
if id == "" {
continue
}
candidateSet[id] = struct{}{}
}
if len(candidateSet) == 0 {
return map[string]time.Time{}, nil
}
return v.findVmDeletionEvents(ctx, begin, end, candidateSet)
}
func (v *Vcenter) findVmDeletionEvents(ctx context.Context, begin, end time.Time, candidateSet map[string]struct{}) (map[string]time.Time, error) {
result := make(map[string]time.Time)
if v.client == nil || !v.client.Valid() {
return result, fmt.Errorf("vcenter client is not valid")
}
// vCenter events are stored in UTC; normalize the query window.
beginUTC := begin.UTC()
endUTC := end.UTC()
mgr := event.NewManager(v.client.Client)
type deletionHit struct {
ts time.Time
priority int
}
const (
deletionPriorityRemoved = iota
deletionPriorityVmEvent
deletionPriorityTask
)
hits := make(map[string]deletionHit)
foundCandidates := 0
recordDeletion := func(vmID string, ts time.Time, priority int) {
if vmID == "" {
return
}
if candidateSet != nil {
if _, ok := candidateSet[vmID]; !ok {
return
}
}
if prev, ok := hits[vmID]; !ok {
hits[vmID] = deletionHit{ts: ts, priority: priority}
if candidateSet != nil {
foundCandidates++
}
} else if priority < prev.priority || (priority == prev.priority && ts.Before(prev.ts)) {
hits[vmID] = deletionHit{ts: ts, priority: priority}
}
}
isDeletionMessage := func(msg string) bool {
msg = strings.ToLower(msg)
return strings.Contains(msg, "destroy") ||
strings.Contains(msg, "deleted") ||
strings.Contains(msg, "unregister") ||
strings.Contains(msg, "removed from inventory")
}
isVmDeletionTask := func(info types.TaskInfo, msg string) bool {
id := strings.ToLower(strings.TrimSpace(info.DescriptionId))
if id != "" {
if strings.Contains(id, "virtualmachine") &&
(strings.Contains(id, "destroy") || strings.Contains(id, "delete") || strings.Contains(id, "unregister")) {
return true
}
}
name := strings.ToLower(strings.TrimSpace(info.Name))
if name != "" {
if (strings.Contains(name, "destroy") || strings.Contains(name, "delete") || strings.Contains(name, "unregister")) &&
(strings.Contains(name, "virtualmachine") || strings.Contains(name, "virtual machine")) {
return true
}
}
if msg != "" && isDeletionMessage(msg) {
return true
}
return false
}
processEvents := func(evts []types.BaseEvent) {
for _, ev := range evts {
switch e := ev.(type) {
case *types.VmRemovedEvent:
if e.Vm != nil {
vmID := e.Vm.Vm.Value
recordDeletion(vmID, e.CreatedTime, deletionPriorityRemoved)
}
case *types.TaskEvent:
// Fallback for destroy task events.
if e.Info.Entity != nil {
vmID := e.Info.Entity.Value
if vmID != "" && isVmDeletionTask(e.Info, e.GetEvent().FullFormattedMessage) {
recordDeletion(vmID, e.CreatedTime, deletionPriorityTask)
}
}
case *types.VmEvent:
if e.Vm != nil {
vmID := e.Vm.Vm.Value
if vmID != "" && isDeletionMessage(e.GetEvent().FullFormattedMessage) {
recordDeletion(vmID, e.CreatedTime, deletionPriorityVmEvent)
}
}
}
}
}
const (
eventPageSize = int32(1000)
maxEventPages = 25
)
readCollector := func(label string, collector *event.HistoryCollector) error {
pageCount := 0
for {
events, err := collector.ReadNextEvents(ctx, eventPageSize)
if err != nil {
return err
}
if len(events) == 0 {
break
}
processEvents(events)
if candidateSet != nil && foundCandidates >= len(candidateSet) {
break
}
pageCount++
if pageCount >= maxEventPages {
if v.Logger != nil {
v.Logger.Warn("vcenter deletion events truncated", "vcenter", v.Vurl, "label", label, "pages", pageCount, "page_size", eventPageSize, "window_start_utc", beginUTC, "window_end_utc", endUTC)
}
break
}
if len(events) < int(eventPageSize) {
break
}
}
return nil
}
// First attempt: specific deletion event types.
disableFullMessage := false
filter := types.EventFilterSpec{
Time: &types.EventFilterSpecByTime{
BeginTime: &beginUTC,
EndTime: &endUTC,
},
DisableFullMessage: &disableFullMessage,
EventTypeId: []string{
"VmRemovedEvent",
"TaskEvent",
},
}
collector, err := mgr.CreateCollectorForEvents(ctx, filter)
if err != nil {
return result, fmt.Errorf("failed to create event collector: %w", err)
}
defer collector.Destroy(ctx)
if err := readCollector("primary", collector); err != nil {
return result, fmt.Errorf("failed to read events: %w", err)
}
// If nothing found, widen the filter to all event types in the window as a fallback.
if len(hits) == 0 {
fallbackFilter := types.EventFilterSpec{
Time: &types.EventFilterSpecByTime{
BeginTime: &beginUTC,
EndTime: &endUTC,
},
DisableFullMessage: &disableFullMessage,
}
fc, err := mgr.CreateCollectorForEvents(ctx, fallbackFilter)
if err == nil {
defer fc.Destroy(ctx)
if readErr := readCollector("fallback", fc); readErr != nil && v.Logger != nil {
v.Logger.Warn("vcenter fallback event read failed", "vcenter", v.Vurl, "error", readErr)
}
}
}
for vmID, hit := range hits {
result[vmID] = hit.ts
}
return result, nil
}
func (v *Vcenter) BuildHostLookup() (map[string]HostLookup, error) {
finder := find.NewFinder(v.client.Client, true)
datacenters, err := finder.DatacenterList(v.ctx, "*")
@@ -406,6 +616,10 @@ func (v *Vcenter) GetHostSystemObject(hostRef types.ManagedObjectReference) (*mo
// Function to find the cluster or compute resource from a host reference
func (v *Vcenter) GetClusterFromHost(hostRef *types.ManagedObjectReference) (string, error) {
if hostRef == nil {
v.Logger.Warn("nil hostRef passed to GetClusterFromHost")
return "", nil
}
// Get the host object
host, err := v.GetHostSystemObject(*hostRef)
if err != nil {

45
main.go
View File

@@ -19,8 +19,9 @@ import (
"vctp/server/router"
"crypto/sha256"
"github.com/go-co-op/gocron/v2"
"log/slog"
"github.com/go-co-op/gocron/v2"
)
var (
@@ -37,6 +38,7 @@ const fallbackEncryptionKey = "5L1l3B5KvwOCzUHMAlCgsgUTRAYMfSpa"
func main() {
settingsPath := flag.String("settings", "/etc/dtms/vctp.yml", "Path to settings YAML")
runInventory := flag.Bool("run-inventory", false, "Run a single inventory snapshot across all configured vCenters and exit")
flag.Parse()
bootstrapLogger := log.New(log.LevelInfo, log.OutputText)
@@ -57,6 +59,8 @@ func main() {
)
s.Logger = logger
logger.Info("vCTP starting", "build_time", buildTime, "sha1_version", sha1ver, "go_version", runtime.Version(), "settings_file", *settingsPath)
// Configure database
dbDriver := strings.TrimSpace(s.Values.Settings.DatabaseDriver)
if dbDriver == "" {
@@ -155,6 +159,13 @@ func main() {
os.Exit(1)
}
// Set a recognizable User-Agent for vCenter sessions.
ua := "vCTP"
if sha1ver != "" {
ua = fmt.Sprintf("vCTP/%s", sha1ver)
}
vcenter.SetUserAgent(ua)
// Prepare the task scheduler
c, err := gocron.NewScheduler()
if err != nil {
@@ -171,20 +182,25 @@ func main() {
FirstHourlySnapshotCheck: true,
}
// One-shot mode: run a single inventory snapshot across all configured vCenters and exit.
if *runInventory {
logger.Info("Running one-shot inventory snapshot across all vCenters")
ct.RunVcenterSnapshotHourly(ctx, logger, true)
logger.Info("One-shot inventory snapshot complete; exiting")
return
}
cronSnapshotFrequency = durationFromSeconds(s.Values.Settings.VcenterInventorySnapshotSeconds, 3600)
logger.Debug("Setting VM inventory snapshot cronjob frequency to", "frequency", cronSnapshotFrequency)
cronAggregateFrequency = durationFromSeconds(s.Values.Settings.VcenterInventoryAggregateSeconds, 86400)
logger.Debug("Setting VM inventory daily aggregation cronjob frequency to", "frequency", cronAggregateFrequency)
startsAt3 := time.Now().Add(cronSnapshotFrequency)
if cronSnapshotFrequency == time.Hour {
startsAt3 = time.Now().Truncate(time.Hour).Add(time.Hour)
}
startsAt3 := alignStart(time.Now(), cronSnapshotFrequency)
job3, err := c.NewJob(
gocron.DurationJob(cronSnapshotFrequency),
gocron.NewTask(func() {
ct.RunVcenterSnapshotHourly(ctx, logger)
ct.RunVcenterSnapshotHourly(ctx, logger, false)
}), gocron.WithSingletonMode(gocron.LimitModeReschedule),
gocron.WithStartAt(gocron.WithStartDateTime(startsAt3)),
)
@@ -212,7 +228,10 @@ func main() {
}
logger.Debug("Created vcenter inventory aggregation cron job", "job", job4.ID(), "starting_at", startsAt4)
monthlyCron := "0 0 1 * *"
monthlyCron := strings.TrimSpace(s.Values.Settings.MonthlyAggregationCron)
if monthlyCron == "" {
monthlyCron = "10 3 1 * *"
}
logger.Debug("Setting monthly aggregation cron schedule", "cron", monthlyCron)
job5, err := c.NewJob(
gocron.CronJob(monthlyCron, false),
@@ -289,6 +308,18 @@ func main() {
os.Exit(0)
}
// alignStart snaps the first run to a sensible boundary (hour or 15-minute block) when possible.
func alignStart(now time.Time, freq time.Duration) time.Time {
if freq == time.Hour {
return now.Truncate(time.Hour).Add(time.Hour)
}
quarter := 15 * time.Minute
if freq%quarter == 0 {
return now.Truncate(quarter).Add(quarter)
}
return now.Add(freq)
}
func durationFromSeconds(value int, fallback int) time.Duration {
if value <= 0 {
return time.Second * time.Duration(fallback)

View File

@@ -0,0 +1,205 @@
package handler
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"vctp/db"
"vctp/server/models"
)
// DailyCreationDiagnostics returns missing CreationTime diagnostics for a daily summary table.
// @Summary Daily summary CreationTime diagnostics
// @Description Returns counts of daily summary rows missing CreationTime and sample rows for the given date.
// @Tags diagnostics
// @Produce json
// @Param date query string true "Daily date (YYYY-MM-DD)"
// @Success 200 {object} models.DailyCreationDiagnosticsResponse "Diagnostics result"
// @Failure 400 {object} models.ErrorResponse "Invalid request"
// @Failure 404 {object} models.ErrorResponse "Summary not found"
// @Failure 500 {object} models.ErrorResponse "Server error"
// @Router /api/diagnostics/daily-creation [get]
func (h *Handler) DailyCreationDiagnostics(w http.ResponseWriter, r *http.Request) {
dateValue := strings.TrimSpace(r.URL.Query().Get("date"))
if dateValue == "" {
writeJSONError(w, http.StatusBadRequest, "date is required")
return
}
loc := time.Now().Location()
parsed, err := time.ParseInLocation("2006-01-02", dateValue, loc)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "date must be YYYY-MM-DD")
return
}
tableName := fmt.Sprintf("inventory_daily_summary_%s", parsed.Format("20060102"))
if _, err := db.SafeTableName(tableName); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid summary table name")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
dbConn := h.Database.DB()
if !db.TableExists(ctx, dbConn, tableName) {
writeJSONError(w, http.StatusNotFound, "daily summary table not found")
return
}
var totalRows int64
countQuery := fmt.Sprintf(`SELECT COUNT(*) FROM %s`, tableName)
if err := dbConn.GetContext(ctx, &totalRows, countQuery); err != nil {
h.Logger.Warn("daily creation diagnostics count failed", "table", tableName, "error", err)
writeJSONError(w, http.StatusInternalServerError, "failed to read summary rows")
return
}
var missingTotal int64
missingQuery := fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE "CreationTime" IS NULL OR "CreationTime" = 0`, tableName)
if err := dbConn.GetContext(ctx, &missingTotal, missingQuery); err != nil {
h.Logger.Warn("daily creation diagnostics missing count failed", "table", tableName, "error", err)
writeJSONError(w, http.StatusInternalServerError, "failed to read missing creation rows")
return
}
var avgIsPresentLtOne int64
avgPresenceQuery := fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE "AvgIsPresent" IS NOT NULL AND "AvgIsPresent" < 0.999999`, tableName)
if err := dbConn.GetContext(ctx, &avgIsPresentLtOne, avgPresenceQuery); err != nil {
h.Logger.Warn("daily creation diagnostics avg-is-present count failed", "table", tableName, "error", err)
writeJSONError(w, http.StatusInternalServerError, "failed to read avg is present rows")
return
}
var missingPartialCount int64
missingPartialQuery := fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE ("CreationTime" IS NULL OR "CreationTime" = 0) AND "AvgIsPresent" IS NOT NULL AND "AvgIsPresent" < 0.999999`, tableName)
if err := dbConn.GetContext(ctx, &missingPartialCount, missingPartialQuery); err != nil {
h.Logger.Warn("daily creation diagnostics missing partial count failed", "table", tableName, "error", err)
writeJSONError(w, http.StatusInternalServerError, "failed to read missing partial rows")
return
}
missingPct := 0.0
if totalRows > 0 {
missingPct = float64(missingTotal) * 100 / float64(totalRows)
}
byVcenter := make([]models.DailyCreationMissingByVcenter, 0)
byVcenterQuery := fmt.Sprintf(`
SELECT "Vcenter", COUNT(*) AS missing_count
FROM %s
WHERE "CreationTime" IS NULL OR "CreationTime" = 0
GROUP BY "Vcenter"
ORDER BY missing_count DESC
`, tableName)
if rows, err := dbConn.QueryxContext(ctx, byVcenterQuery); err != nil {
h.Logger.Warn("daily creation diagnostics by-vcenter failed", "table", tableName, "error", err)
} else {
for rows.Next() {
var vcenter string
var count int64
if err := rows.Scan(&vcenter, &count); err != nil {
continue
}
byVcenter = append(byVcenter, models.DailyCreationMissingByVcenter{
Vcenter: vcenter,
MissingCount: count,
})
}
rows.Close()
}
const sampleLimit = 10
samples := make([]models.DailyCreationMissingSample, 0, sampleLimit)
sampleQuery := fmt.Sprintf(`
SELECT "Vcenter","VmId","VmUuid","Name","SamplesPresent","AvgIsPresent","SnapshotTime"
FROM %s
WHERE "CreationTime" IS NULL OR "CreationTime" = 0
ORDER BY "SamplesPresent" DESC
LIMIT %d
`, tableName, sampleLimit)
if rows, err := dbConn.QueryxContext(ctx, sampleQuery); err != nil {
h.Logger.Warn("daily creation diagnostics sample failed", "table", tableName, "error", err)
} else {
for rows.Next() {
var (
vcenter string
vmId, vmUuid, name sql.NullString
samplesPresent, snapshotTime sql.NullInt64
avgIsPresent sql.NullFloat64
)
if err := rows.Scan(&vcenter, &vmId, &vmUuid, &name, &samplesPresent, &avgIsPresent, &snapshotTime); err != nil {
continue
}
samples = append(samples, models.DailyCreationMissingSample{
Vcenter: vcenter,
VmId: vmId.String,
VmUuid: vmUuid.String,
Name: name.String,
SamplesPresent: samplesPresent.Int64,
AvgIsPresent: avgIsPresent.Float64,
SnapshotTime: snapshotTime.Int64,
})
}
rows.Close()
}
partialSamples := make([]models.DailyCreationMissingSample, 0, sampleLimit)
partialSampleQuery := fmt.Sprintf(`
SELECT "Vcenter","VmId","VmUuid","Name","SamplesPresent","AvgIsPresent","SnapshotTime"
FROM %s
WHERE ("CreationTime" IS NULL OR "CreationTime" = 0)
AND "AvgIsPresent" IS NOT NULL
AND "AvgIsPresent" < 0.999999
ORDER BY "SamplesPresent" DESC
LIMIT %d
`, tableName, sampleLimit)
if rows, err := dbConn.QueryxContext(ctx, partialSampleQuery); err != nil {
h.Logger.Warn("daily creation diagnostics partial sample failed", "table", tableName, "error", err)
} else {
for rows.Next() {
var (
vcenter string
vmId, vmUuid, name sql.NullString
samplesPresent, snapshotTime sql.NullInt64
avgIsPresent sql.NullFloat64
)
if err := rows.Scan(&vcenter, &vmId, &vmUuid, &name, &samplesPresent, &avgIsPresent, &snapshotTime); err != nil {
continue
}
partialSamples = append(partialSamples, models.DailyCreationMissingSample{
Vcenter: vcenter,
VmId: vmId.String,
VmUuid: vmUuid.String,
Name: name.String,
SamplesPresent: samplesPresent.Int64,
AvgIsPresent: avgIsPresent.Float64,
SnapshotTime: snapshotTime.Int64,
})
}
rows.Close()
}
response := models.DailyCreationDiagnosticsResponse{
Status: "OK",
Date: parsed.Format("2006-01-02"),
Table: tableName,
TotalRows: totalRows,
MissingCreationCount: missingTotal,
MissingCreationPct: missingPct,
AvgIsPresentLtOneCount: avgIsPresentLtOne,
MissingCreationPartialCount: missingPartialCount,
MissingByVcenter: byVcenter,
Samples: samples,
MissingCreationPartialSamples: partialSamples,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
}

View File

@@ -14,8 +14,8 @@ import (
// @Accept json
// @Produce json
// @Param payload body map[string]string true "Plaintext payload"
// @Success 200 {object} map[string]string "Ciphertext response"
// @Failure 500 {object} map[string]string "Server error"
// @Success 200 {object} models.StatusMessageResponse "Ciphertext response"
// @Failure 500 {object} models.ErrorResponse "Server error"
// @Router /api/encrypt [post]
func (h *Handler) EncryptData(w http.ResponseWriter, r *http.Request) {
//ctx := context.Background()

View File

@@ -14,7 +14,7 @@ import (
// @Tags reports
// @Produce application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
// @Success 200 {file} file "Inventory XLSX report"
// @Failure 500 {object} map[string]string "Report generation failed"
// @Failure 500 {object} models.ErrorResponse "Report generation failed"
// @Router /api/report/inventory [get]
func (h *Handler) InventoryReportDownload(w http.ResponseWriter, r *http.Request) {
@@ -48,7 +48,7 @@ func (h *Handler) InventoryReportDownload(w http.ResponseWriter, r *http.Request
// @Tags reports
// @Produce application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
// @Success 200 {file} file "Updates XLSX report"
// @Failure 500 {object} map[string]string "Report generation failed"
// @Failure 500 {object} models.ErrorResponse "Report generation failed"
// @Router /api/report/updates [get]
func (h *Handler) UpdateReportDownload(w http.ResponseWriter, r *http.Request) {

View File

@@ -6,7 +6,9 @@ import (
"net/http"
"strings"
"time"
"vctp/internal/settings"
"vctp/internal/tasks"
"vctp/server/models"
)
// SnapshotAggregateForce forces regeneration of a daily or monthly summary table.
@@ -16,13 +18,15 @@ import (
// @Produce json
// @Param type query string true "Aggregation type: daily or monthly"
// @Param date query string true "Daily date (YYYY-MM-DD) or monthly date (YYYY-MM)"
// @Success 200 {object} map[string]string "Aggregation complete"
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 500 {object} map[string]string "Server error"
// @Param granularity query string false "Monthly aggregation granularity: hourly or daily"
// @Success 200 {object} models.StatusResponse "Aggregation complete"
// @Failure 400 {object} models.ErrorResponse "Invalid request"
// @Failure 500 {object} models.ErrorResponse "Server error"
// @Router /api/snapshots/aggregate [post]
func (h *Handler) SnapshotAggregateForce(w http.ResponseWriter, r *http.Request) {
snapshotType := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("type")))
dateValue := strings.TrimSpace(r.URL.Query().Get("date"))
granularity := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("granularity")))
startedAt := time.Now()
loc := time.Now().Location()
@@ -35,11 +39,28 @@ func (h *Handler) SnapshotAggregateForce(w http.ResponseWriter, r *http.Request)
return
}
if granularity != "" && snapshotType != "monthly" {
h.Logger.Debug("Snapshot aggregation ignoring granularity for non-monthly request",
"type", snapshotType,
"granularity", granularity,
)
granularity = ""
}
if snapshotType == "monthly" && granularity != "" && granularity != "hourly" && granularity != "daily" {
h.Logger.Warn("Snapshot aggregation invalid granularity", "granularity", granularity)
writeJSONError(w, http.StatusBadRequest, "granularity must be hourly or daily")
return
}
ctx := context.Background()
settingsCopy := *h.Settings.Values
if granularity != "" {
settingsCopy.Settings.MonthlyAggregationGranularity = granularity
}
ct := &tasks.CronTask{
Logger: h.Logger,
Database: h.Database,
Settings: h.Settings,
Settings: &settings.Settings{Logger: h.Logger, SettingsPath: h.Settings.SettingsPath, Values: &settingsCopy},
}
switch snapshotType {
@@ -63,7 +84,7 @@ func (h *Handler) SnapshotAggregateForce(w http.ResponseWriter, r *http.Request)
writeJSONError(w, http.StatusBadRequest, "date must be YYYY-MM")
return
}
h.Logger.Info("Starting monthly snapshot aggregation", "date", parsed.Format("2006-01"), "force", true)
h.Logger.Info("Starting monthly snapshot aggregation", "date", parsed.Format("2006-01"), "force", true, "granularity", granularity)
if err := ct.AggregateMonthlySummary(ctx, parsed, true); err != nil {
h.Logger.Error("Monthly snapshot aggregation failed", "date", parsed.Format("2006-01"), "error", err)
writeJSONError(w, http.StatusInternalServerError, err.Error())
@@ -78,6 +99,7 @@ func (h *Handler) SnapshotAggregateForce(w http.ResponseWriter, r *http.Request)
h.Logger.Info("Snapshot aggregation completed",
"type", snapshotType,
"date", dateValue,
"granularity", granularity,
"duration", time.Since(startedAt),
)
w.Header().Set("Content-Type", "application/json")
@@ -90,8 +112,8 @@ func (h *Handler) SnapshotAggregateForce(w http.ResponseWriter, r *http.Request)
func writeJSONError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{
"status": "ERROR",
"message": message,
json.NewEncoder(w).Encode(models.ErrorResponse{
Status: "ERROR",
Message: message,
})
}

View File

@@ -16,9 +16,9 @@ import (
// @Accept json
// @Produce json
// @Param confirm query string true "Confirmation text; must be 'FORCE'"
// @Success 200 {object} map[string]string "Snapshot started"
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 500 {object} map[string]string "Server error"
// @Success 200 {object} models.StatusResponse "Snapshot started"
// @Failure 400 {object} models.ErrorResponse "Invalid request"
// @Failure 500 {object} models.ErrorResponse "Server error"
// @Router /api/snapshots/hourly/force [post]
func (h *Handler) SnapshotForceHourly(w http.ResponseWriter, r *http.Request) {
confirm := strings.TrimSpace(r.URL.Query().Get("confirm"))
@@ -37,7 +37,7 @@ func (h *Handler) SnapshotForceHourly(w http.ResponseWriter, r *http.Request) {
started := time.Now()
h.Logger.Info("Manual hourly snapshot requested")
if err := ct.RunVcenterSnapshotHourly(ctx, h.Logger.With("manual", true)); err != nil {
if err := ct.RunVcenterSnapshotHourly(ctx, h.Logger.With("manual", true), true); err != nil {
h.Logger.Error("Manual hourly snapshot failed", "error", err)
writeJSONError(w, http.StatusInternalServerError, err.Error())
return

View File

@@ -12,8 +12,8 @@ import (
// @Description Rebuilds the snapshot registry from existing tables and renames hourly tables to epoch-based names.
// @Tags snapshots
// @Produce json
// @Success 200 {object} map[string]interface{} "Migration results"
// @Failure 500 {object} map[string]string "Server error"
// @Success 200 {object} models.SnapshotMigrationResponse "Migration results"
// @Failure 500 {object} models.SnapshotMigrationResponse "Server error"
// @Router /api/snapshots/migrate [post]
func (h *Handler) SnapshotMigrate(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()

View File

@@ -15,8 +15,8 @@ import (
// @Description Regenerates XLSX reports for hourly snapshots when the report files are missing or empty.
// @Tags snapshots
// @Produce json
// @Success 200 {object} map[string]interface{} "Regeneration summary"
// @Failure 500 {object} map[string]string "Server error"
// @Success 200 {object} models.SnapshotRegenerateReportsResponse "Regeneration summary"
// @Failure 500 {object} models.ErrorResponse "Server error"
// @Router /api/snapshots/regenerate-hourly-reports [post]
func (h *Handler) SnapshotRegenerateHourlyReports(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

View File

@@ -0,0 +1,219 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"vctp/db"
"vctp/internal/report"
)
// SnapshotRepair scans existing daily summaries and backfills missing SnapshotTime and lifecycle fields.
// @Summary Repair daily summaries
// @Description Backfills SnapshotTime and lifecycle info for existing daily summary tables and reruns monthly lifecycle refinement using hourly data.
// @Tags snapshots
// @Produce json
// @Success 200 {object} models.SnapshotRepairResponse
// @Router /api/snapshots/repair [post]
func (h *Handler) SnapshotRepair(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
h.Logger.Info("snapshot repair started", "scope", "daily")
repaired, failed := h.repairDailySummaries(r.Context(), time.Now())
h.Logger.Info("snapshot repair finished", "daily_repaired", repaired, "daily_failed", failed)
resp := map[string]string{
"status": "ok",
"repaired": strconv.Itoa(repaired),
"failed": strconv.Itoa(failed),
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func (h *Handler) repairDailySummaries(ctx context.Context, now time.Time) (repaired int, failed int) {
dbConn := h.Database.DB()
dailyRecs, err := report.SnapshotRecordsWithFallback(ctx, h.Database, "daily", "inventory_daily_summary_", "20060102", time.Time{}, now)
if err != nil {
h.Logger.Warn("failed to list daily summaries", "error", err)
return 0, 1
}
for _, rec := range dailyRecs {
h.Logger.Debug("repair daily summary table", "table", rec.TableName, "snapshot_time", rec.SnapshotTime)
dayStart := rec.SnapshotTime
dayEnd := dayStart.Add(24 * time.Hour)
if err := db.EnsureSummaryTable(ctx, dbConn, rec.TableName); err != nil {
h.Logger.Warn("ensure summary table failed", "table", rec.TableName, "error", err)
failed++
continue
}
hourlyRecs, err := report.SnapshotRecordsWithFallback(ctx, h.Database, "hourly", "inventory_hourly_", "epoch", dayStart, dayEnd)
if err != nil || len(hourlyRecs) == 0 {
h.Logger.Warn("no hourly snapshots for repair window", "table", rec.TableName, "error", err)
failed++
continue
}
cols := []string{
`"InventoryId"`, `"Name"`, `"Vcenter"`, `"VmId"`, `"EventKey"`, `"CloudId"`, `"CreationTime"`,
`"DeletionTime"`, `"ResourcePool"`, `"Datacenter"`, `"Cluster"`, `"Folder"`,
`"ProvisionedDisk"`, `"VcpuCount"`, `"RamGB"`, `"IsTemplate"`, `"PoweredOn"`,
`"SrmPlaceholder"`, `"VmUuid"`, `"SnapshotTime"`,
}
union, err := buildUnionFromRecords(hourlyRecs, cols, `COALESCE(CAST("IsTemplate" AS TEXT), '') NOT IN ('TRUE','true','1')`)
if err != nil {
h.Logger.Warn("failed to build union for repair", "table", rec.TableName, "error", err)
failed++
continue
}
h.Logger.Debug("built hourly union for repair", "table", rec.TableName, "hourly_tables", len(hourlyRecs))
if err := db.BackfillSnapshotTimeFromUnion(ctx, dbConn, rec.TableName, union); err != nil {
h.Logger.Warn("failed to backfill snapshot time", "table", rec.TableName, "error", err)
failed++
continue
}
h.Logger.Debug("snapshot time backfill complete", "table", rec.TableName)
if err := db.RefineCreationDeletionFromUnion(ctx, dbConn, rec.TableName, union); err != nil {
h.Logger.Warn("failed to refine lifecycle during repair", "table", rec.TableName, "error", err)
failed++
continue
}
h.Logger.Debug("lifecycle refinement complete", "table", rec.TableName)
h.Logger.Info("repair applied", "table", rec.TableName, "actions", "snapshot_time+lifecycle")
repaired++
}
return repaired, failed
}
// SnapshotRepairSuite runs a sequence of repair routines to fix older deployments in one call.
// It rebuilds the snapshot registry, syncs vcenter totals, repairs daily summaries, and refines monthly lifecycle data.
// @Summary Run full snapshot repair suite
// @Description Rebuilds snapshot registry, backfills per-vCenter totals, repairs daily summaries (SnapshotTime/lifecycle), and refines monthly lifecycle.
// @Tags snapshots
// @Produce json
// @Success 200 {object} models.SnapshotRepairSuiteResponse
// @Router /api/snapshots/repair/all [post]
func (h *Handler) SnapshotRepairSuite(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ctx := r.Context()
dbConn := h.Database.DB()
// Step 1: rebuild snapshot registry from existing tables.
h.Logger.Info("repair suite step", "step", "snapshot_registry")
if stats, err := report.MigrateSnapshotRegistry(ctx, h.Database); err != nil {
h.Logger.Warn("snapshot registry migration failed", "error", err)
} else {
h.Logger.Info("snapshot registry migration complete", "hourly_renamed", stats.HourlyRenamed, "daily_registered", stats.DailyRegistered, "monthly_registered", stats.MonthlyRegistered, "errors", stats.Errors)
}
// Step 2: backfill vcenter_totals from registry hourly tables.
h.Logger.Info("repair suite step", "step", "vcenter_totals")
if err := db.SyncVcenterTotalsFromSnapshots(ctx, dbConn); err != nil {
h.Logger.Warn("sync vcenter totals failed", "error", err)
}
// Step 3: repair daily summaries (snapshot time + lifecycle).
h.Logger.Info("repair suite step", "step", "daily_summaries")
dailyRepaired, dailyFailed := h.repairDailySummaries(ctx, time.Now())
// Step 4: refine monthly lifecycle using daily summaries (requires SnapshotTime now present after step 3).
h.Logger.Info("repair suite step", "step", "monthly_refine")
monthlyRefined, monthlyFailed := h.refineMonthlyFromDaily(ctx, time.Now())
resp := map[string]string{
"status": "ok",
"daily_repaired": strconv.Itoa(dailyRepaired),
"daily_failed": strconv.Itoa(dailyFailed),
"monthly_refined": strconv.Itoa(monthlyRefined),
"monthly_failed": strconv.Itoa(monthlyFailed),
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func (h *Handler) refineMonthlyFromDaily(ctx context.Context, now time.Time) (refined int, failed int) {
dbConn := h.Database.DB()
dailyRecs, err := report.SnapshotRecordsWithFallback(ctx, h.Database, "daily", "inventory_daily_summary_", "20060102", time.Time{}, now)
if err != nil {
h.Logger.Warn("failed to list daily summaries for monthly refine", "error", err)
return 0, 1
}
// Group daily tables by month (YYYYMM).
grouped := make(map[string][]report.SnapshotRecord)
for _, rec := range dailyRecs {
key := rec.SnapshotTime.Format("200601")
grouped[key] = append(grouped[key], rec)
}
cols := []string{
`"InventoryId"`, `"Name"`, `"Vcenter"`, `"VmId"`, `"EventKey"`, `"CloudId"`, `"CreationTime"`,
`"DeletionTime"`, `"ResourcePool"`, `"Datacenter"`, `"Cluster"`, `"Folder"`,
`"ProvisionedDisk"`, `"VcpuCount"`, `"RamGB"`, `"IsTemplate"`, `"PoweredOn"`,
`"SrmPlaceholder"`, `"VmUuid"`, `"SnapshotTime"`,
}
for monthKey, recs := range grouped {
summaryTable := fmt.Sprintf("inventory_monthly_summary_%s", monthKey)
h.Logger.Debug("monthly refine", "table", summaryTable, "daily_tables", len(recs))
if err := db.EnsureSummaryTable(ctx, dbConn, summaryTable); err != nil {
h.Logger.Warn("ensure monthly summary failed", "table", summaryTable, "error", err)
failed++
continue
}
union, err := buildUnionFromRecords(recs, cols, `COALESCE(CAST("IsTemplate" AS TEXT), '') NOT IN ('TRUE','true','1')`)
if err != nil {
h.Logger.Warn("failed to build union for monthly refine", "table", summaryTable, "error", err)
failed++
continue
}
if err := db.RefineCreationDeletionFromUnion(ctx, dbConn, summaryTable, union); err != nil {
h.Logger.Warn("failed to refine monthly lifecycle", "table", summaryTable, "error", err)
failed++
continue
}
h.Logger.Debug("monthly refine applied", "table", summaryTable)
refined++
}
return refined, failed
}
func buildUnionFromRecords(recs []report.SnapshotRecord, columns []string, where string) (string, error) {
if len(recs) == 0 {
return "", fmt.Errorf("no tables provided for union")
}
colList := strings.Join(columns, ", ")
parts := make([]string, 0, len(recs))
for _, rec := range recs {
if err := db.ValidateTableName(rec.TableName); err != nil {
continue
}
q := fmt.Sprintf(`SELECT %s FROM %s`, colList, rec.TableName)
if where != "" {
q = q + " WHERE " + where
}
parts = append(parts, q)
}
if len(parts) == 0 {
return "", fmt.Errorf("no valid tables for union")
}
return strings.Join(parts, "\nUNION ALL\n"), nil
}

View File

@@ -55,8 +55,8 @@ func (h *Handler) SnapshotMonthlyList(w http.ResponseWriter, r *http.Request) {
// @Produce application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
// @Param table query string true "Snapshot table name"
// @Success 200 {file} file "Snapshot XLSX report"
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 500 {object} map[string]string "Server error"
// @Failure 400 {object} models.ErrorResponse "Invalid request"
// @Failure 500 {object} models.ErrorResponse "Server error"
// @Router /api/report/snapshot [get]
func (h *Handler) SnapshotReportDownload(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
@@ -118,10 +118,14 @@ func (h *Handler) renderSnapshotList(w http.ResponseWriter, r *http.Request, sna
case "monthly":
group = record.SnapshotTime.Format("2006")
}
count := record.SnapshotCount
if count < 0 {
count = 0
}
entries = append(entries, views.SnapshotEntry{
Label: label,
Link: "/reports/" + url.PathEscape(record.TableName) + ".xlsx",
Count: record.SnapshotCount,
Count: count,
Group: group,
})
}

View File

@@ -16,8 +16,8 @@ import (
// @Deprecated
// @Produce json
// @Param vc_url query string true "vCenter URL"
// @Success 200 {object} map[string]string "Cleanup completed"
// @Failure 400 {object} map[string]string "Invalid request"
// @Success 200 {object} models.StatusMessageResponse "Cleanup completed"
// @Failure 400 {object} models.ErrorResponse "Invalid request"
// @Router /api/cleanup/vcenter [delete]
func (h *Handler) VcCleanup(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()

View File

@@ -17,8 +17,8 @@ import (
// @Produce json
// @Param vm_id query string true "VM ID"
// @Param datacenter_name query string true "Datacenter name"
// @Success 200 {object} map[string]string "Cleanup completed"
// @Failure 400 {object} map[string]string "Invalid request"
// @Success 200 {object} models.StatusMessageResponse "Cleanup completed"
// @Failure 400 {object} models.ErrorResponse "Invalid request"
// @Router /api/inventory/vm/delete [delete]
func (h *Handler) VmCleanup(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()

View File

@@ -21,8 +21,8 @@ import (
// @Accept json
// @Produce json
// @Param import body models.ImportReceived true "Bulk import payload"
// @Success 200 {object} map[string]string "Import processed"
// @Failure 500 {object} map[string]string "Server error"
// @Success 200 {object} models.StatusMessageResponse "Import processed"
// @Failure 500 {object} models.ErrorResponse "Server error"
// @Router /api/import/vm [post]
func (h *Handler) VmImport(w http.ResponseWriter, r *http.Request) {
// Read request body

View File

@@ -27,9 +27,9 @@ import (
// @Accept json
// @Produce json
// @Param event body models.CloudEventReceived true "CloudEvent payload"
// @Success 200 {object} map[string]string "Modify event processed"
// @Success 202 {object} map[string]string "No relevant changes"
// @Failure 500 {object} map[string]string "Server error"
// @Success 200 {object} models.StatusMessageResponse "Modify event processed"
// @Success 202 {object} models.StatusMessageResponse "No relevant changes"
// @Failure 500 {object} models.ErrorResponse "Server error"
// @Router /api/event/vm/modify [post]
func (h *Handler) VmModifyEvent(w http.ResponseWriter, r *http.Request) {
var configChanges []map[string]string
@@ -404,10 +404,7 @@ func (h *Handler) calculateNewDiskSize(event models.CloudEventReceived) float64
}
}
err = vc.Logout()
if err != nil {
h.Logger.Error("unable to logout of vcenter", "error", err)
}
_ = vc.Logout(context.Background())
h.Logger.Debug("Calculated new disk size", "value", diskSize)
@@ -446,9 +443,7 @@ func (h *Handler) AddVmToInventory(evt models.CloudEventReceived, ctx context.Co
if strings.HasPrefix(vmObject.Name, "vCLS-") {
h.Logger.Info("Skipping internal vCLS VM", "vm_name", vmObject.Name)
if err := vc.Logout(); err != nil {
h.Logger.Error("unable to logout of vcenter", "error", err)
}
_ = vc.Logout(ctx)
return 0, nil
}
@@ -522,10 +517,7 @@ func (h *Handler) AddVmToInventory(evt models.CloudEventReceived, ctx context.Co
poweredOn = "TRUE"
}
err = vc.Logout()
if err != nil {
h.Logger.Error("unable to logout of vcenter", "error", err)
}
_ = vc.Logout(ctx)
if foundVm {
e := evt.CloudEvent

View File

@@ -22,9 +22,9 @@ import (
// @Accept json
// @Produce json
// @Param event body models.CloudEventReceived true "CloudEvent payload"
// @Success 200 {object} map[string]string "Move event processed"
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 500 {object} map[string]string "Server error"
// @Success 200 {object} models.StatusMessageResponse "Move event processed"
// @Failure 400 {object} models.ErrorResponse "Invalid request"
// @Failure 500 {object} models.ErrorResponse "Server error"
// @Router /api/event/vm/move [post]
func (h *Handler) VmMoveEvent(w http.ResponseWriter, r *http.Request) {
params := queries.CreateUpdateParams{}

View File

@@ -35,6 +35,7 @@ func (h *Handler) VmTrace(w http.ResponseWriter, r *http.Request) {
}
creationLabel := ""
deletionLabel := ""
creationApprox := false
// Only fetch data when a query is provided; otherwise render empty page with form.
if vmID != "" || vmUUID != "" || name != "" {
@@ -79,9 +80,17 @@ func (h *Handler) VmTrace(w http.ResponseWriter, r *http.Request) {
if len(entries) > 0 {
if lifecycle.CreationTime > 0 {
creationLabel = time.Unix(lifecycle.CreationTime, 0).Local().Format("2006-01-02 15:04:05")
ts := time.Unix(lifecycle.CreationTime, 0).Local().Format("2006-01-02 15:04:05")
if lifecycle.CreationApprox {
creationLabel = fmt.Sprintf("%s (approx. earliest snapshot)", ts)
// dont double up on the approximate text
//creationApprox = true
} else {
creationLabel = ts
}
} else {
creationLabel = time.Unix(entries[0].RawTime, 0).Local().Format("2006-01-02 15:04:05")
creationApprox = true
}
if lifecycle.DeletionTime > 0 {
deletionLabel = time.Unix(lifecycle.DeletionTime, 0).Local().Format("2006-01-02 15:04:05")
@@ -90,7 +99,7 @@ func (h *Handler) VmTrace(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := views.VmTracePage(queryLabel, displayQuery, vmID, vmUUID, name, creationLabel, deletionLabel, entries, chart).Render(ctx, w); err != nil {
if err := views.VmTracePage(queryLabel, displayQuery, vmID, vmUUID, name, creationLabel, deletionLabel, creationApprox, entries, chart).Render(ctx, w); err != nil {
http.Error(w, "Failed to render template", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,61 @@
package models
// StatusResponse represents a simple status-only JSON response.
type StatusResponse struct {
Status string `json:"status"`
}
// StatusMessageResponse represents a status + message JSON response.
type StatusMessageResponse struct {
Status string `json:"status"`
Message string `json:"message"`
}
// ErrorResponse represents a standard error JSON response.
type ErrorResponse struct {
Status string `json:"status"`
Message string `json:"message"`
}
// SnapshotMigrationStats mirrors the snapshot registry migration stats payload.
type SnapshotMigrationStats struct {
HourlyRenamed int `json:"HourlyRenamed"`
HourlyRegistered int `json:"HourlyRegistered"`
DailyRegistered int `json:"DailyRegistered"`
MonthlyRegistered int `json:"MonthlyRegistered"`
Errors int `json:"Errors"`
}
// SnapshotMigrationResponse captures snapshot registry migration results.
type SnapshotMigrationResponse struct {
Status string `json:"status"`
Error string `json:"error,omitempty"`
Stats SnapshotMigrationStats `json:"stats"`
}
// SnapshotRegenerateReportsResponse describes the hourly report regeneration response.
type SnapshotRegenerateReportsResponse struct {
Status string `json:"status"`
Total int `json:"total"`
Regenerated int `json:"regenerated"`
Skipped int `json:"skipped"`
Errors int `json:"errors"`
ReportsDir string `json:"reports_dir"`
SnapshotType string `json:"snapshotType"`
}
// SnapshotRepairResponse describes the daily snapshot repair response.
type SnapshotRepairResponse struct {
Status string `json:"status"`
Repaired string `json:"repaired"`
Failed string `json:"failed"`
}
// SnapshotRepairSuiteResponse describes the full repair suite response.
type SnapshotRepairSuiteResponse struct {
Status string `json:"status"`
DailyRepaired string `json:"daily_repaired"`
DailyFailed string `json:"daily_failed"`
MonthlyRefined string `json:"monthly_refined"`
MonthlyFailed string `json:"monthly_failed"`
}

View File

@@ -0,0 +1,33 @@
package models
// DailyCreationMissingByVcenter captures missing CreationTime counts per vCenter.
type DailyCreationMissingByVcenter struct {
Vcenter string `json:"vcenter"`
MissingCount int64 `json:"missing_count"`
}
// DailyCreationMissingSample is a sample daily summary row missing CreationTime.
type DailyCreationMissingSample struct {
Vcenter string `json:"vcenter"`
VmId string `json:"vm_id,omitempty"`
VmUuid string `json:"vm_uuid,omitempty"`
Name string `json:"name,omitempty"`
SamplesPresent int64 `json:"samples_present"`
AvgIsPresent float64 `json:"avg_is_present"`
SnapshotTime int64 `json:"snapshot_time"`
}
// DailyCreationDiagnosticsResponse describes missing CreationTime diagnostics for a daily summary table.
type DailyCreationDiagnosticsResponse struct {
Status string `json:"status"`
Date string `json:"date"`
Table string `json:"table"`
TotalRows int64 `json:"total_rows"`
MissingCreationCount int64 `json:"missing_creation_count"`
MissingCreationPct float64 `json:"missing_creation_pct"`
AvgIsPresentLtOneCount int64 `json:"avg_is_present_lt_one_count"`
MissingCreationPartialCount int64 `json:"missing_creation_partial_count"`
MissingByVcenter []DailyCreationMissingByVcenter `json:"missing_by_vcenter"`
Samples []DailyCreationMissingSample `json:"samples"`
MissingCreationPartialSamples []DailyCreationMissingSample `json:"missing_creation_partial_samples"`
}

View File

@@ -92,22 +92,63 @@ const docTemplate = `{
"200": {
"description": "Cleanup completed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.StatusMessageResponse"
}
},
"400": {
"description": "Invalid request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
}
},
"/api/diagnostics/daily-creation": {
"get": {
"description": "Returns counts of daily summary rows missing CreationTime and sample rows for the given date.",
"produces": [
"application/json"
],
"tags": [
"diagnostics"
],
"summary": "Daily summary CreationTime diagnostics",
"parameters": [
{
"type": "string",
"description": "Daily date (YYYY-MM-DD)",
"name": "date",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "Diagnostics result",
"schema": {
"$ref": "#/definitions/models.DailyCreationDiagnosticsResponse"
}
},
"400": {
"description": "Invalid request",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
},
"404": {
"description": "Summary not found",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
},
"500": {
"description": "Server error",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
}
},
"/api/encrypt": {
@@ -141,19 +182,13 @@ const docTemplate = `{
"200": {
"description": "Ciphertext response",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.StatusMessageResponse"
}
},
"500": {
"description": "Server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
@@ -282,28 +317,19 @@ const docTemplate = `{
"200": {
"description": "Modify event processed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.StatusMessageResponse"
}
},
"202": {
"description": "No relevant changes",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.StatusMessageResponse"
}
},
"500": {
"description": "Server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
@@ -338,28 +364,19 @@ const docTemplate = `{
"200": {
"description": "Move event processed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.StatusMessageResponse"
}
},
"400": {
"description": "Invalid request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
},
"500": {
"description": "Server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
@@ -393,19 +410,13 @@ const docTemplate = `{
"200": {
"description": "Import processed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.StatusMessageResponse"
}
},
"500": {
"description": "Server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
@@ -441,19 +452,13 @@ const docTemplate = `{
"200": {
"description": "Cleanup completed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.StatusMessageResponse"
}
},
"400": {
"description": "Invalid request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
@@ -505,10 +510,7 @@ const docTemplate = `{
"500": {
"description": "Report generation failed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
@@ -543,19 +545,13 @@ const docTemplate = `{
"400": {
"description": "Invalid request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
},
"500": {
"description": "Server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
@@ -581,10 +577,7 @@ const docTemplate = `{
"500": {
"description": "Report generation failed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
@@ -614,34 +607,31 @@ const docTemplate = `{
"name": "date",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Monthly aggregation granularity: hourly or daily",
"name": "granularity",
"in": "query"
}
],
"responses": {
"200": {
"description": "Aggregation complete",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.StatusResponse"
}
},
"400": {
"description": "Invalid request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
},
"500": {
"description": "Server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
@@ -673,28 +663,19 @@ const docTemplate = `{
"200": {
"description": "Snapshot started",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.StatusResponse"
}
},
"400": {
"description": "Invalid request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
},
"500": {
"description": "Server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
@@ -714,17 +695,13 @@ const docTemplate = `{
"200": {
"description": "Migration results",
"schema": {
"type": "object",
"additionalProperties": true
"$ref": "#/definitions/models.SnapshotMigrationResponse"
}
},
"500": {
"description": "Server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.SnapshotMigrationResponse"
}
}
}
@@ -744,20 +721,56 @@ const docTemplate = `{
"200": {
"description": "Regeneration summary",
"schema": {
"type": "object",
"additionalProperties": true
"$ref": "#/definitions/models.SnapshotRegenerateReportsResponse"
}
},
"500": {
"description": "Server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
}
},
"/api/snapshots/repair": {
"post": {
"description": "Backfills SnapshotTime and lifecycle info for existing daily summary tables and reruns monthly lifecycle refinement using hourly data.",
"produces": [
"application/json"
],
"tags": [
"snapshots"
],
"summary": "Repair daily summaries",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.SnapshotRepairResponse"
}
}
}
}
},
"/api/snapshots/repair/all": {
"post": {
"description": "Rebuilds snapshot registry, backfills per-vCenter totals, repairs daily summaries (SnapshotTime/lifecycle), and refines monthly lifecycle.",
"produces": [
"application/json"
],
"tags": [
"snapshots"
],
"summary": "Run full snapshot repair suite",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.SnapshotRepairSuiteResponse"
}
}
}
}
},
"/metrics": {
@@ -1168,6 +1181,101 @@ const docTemplate = `{
}
}
},
"models.DailyCreationDiagnosticsResponse": {
"type": "object",
"properties": {
"avg_is_present_lt_one_count": {
"type": "integer"
},
"date": {
"type": "string"
},
"missing_by_vcenter": {
"type": "array",
"items": {
"$ref": "#/definitions/models.DailyCreationMissingByVcenter"
}
},
"missing_creation_count": {
"type": "integer"
},
"missing_creation_partial_count": {
"type": "integer"
},
"missing_creation_partial_samples": {
"type": "array",
"items": {
"$ref": "#/definitions/models.DailyCreationMissingSample"
}
},
"missing_creation_pct": {
"type": "number"
},
"samples": {
"type": "array",
"items": {
"$ref": "#/definitions/models.DailyCreationMissingSample"
}
},
"status": {
"type": "string"
},
"table": {
"type": "string"
},
"total_rows": {
"type": "integer"
}
}
},
"models.DailyCreationMissingByVcenter": {
"type": "object",
"properties": {
"missing_count": {
"type": "integer"
},
"vcenter": {
"type": "string"
}
}
},
"models.DailyCreationMissingSample": {
"type": "object",
"properties": {
"avg_is_present": {
"type": "number"
},
"name": {
"type": "string"
},
"samples_present": {
"type": "integer"
},
"snapshot_time": {
"type": "integer"
},
"vcenter": {
"type": "string"
},
"vm_id": {
"type": "string"
},
"vm_uuid": {
"type": "string"
}
}
},
"models.ErrorResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"models.ImportReceived": {
"type": "object",
"properties": {
@@ -1208,6 +1316,119 @@ const docTemplate = `{
"type": "string"
}
}
},
"models.SnapshotMigrationResponse": {
"type": "object",
"properties": {
"error": {
"type": "string"
},
"stats": {
"$ref": "#/definitions/models.SnapshotMigrationStats"
},
"status": {
"type": "string"
}
}
},
"models.SnapshotMigrationStats": {
"type": "object",
"properties": {
"DailyRegistered": {
"type": "integer"
},
"Errors": {
"type": "integer"
},
"HourlyRegistered": {
"type": "integer"
},
"HourlyRenamed": {
"type": "integer"
},
"MonthlyRegistered": {
"type": "integer"
}
}
},
"models.SnapshotRegenerateReportsResponse": {
"type": "object",
"properties": {
"errors": {
"type": "integer"
},
"regenerated": {
"type": "integer"
},
"reports_dir": {
"type": "string"
},
"skipped": {
"type": "integer"
},
"snapshotType": {
"type": "string"
},
"status": {
"type": "string"
},
"total": {
"type": "integer"
}
}
},
"models.SnapshotRepairResponse": {
"type": "object",
"properties": {
"failed": {
"type": "string"
},
"repaired": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"models.SnapshotRepairSuiteResponse": {
"type": "object",
"properties": {
"daily_failed": {
"type": "string"
},
"daily_repaired": {
"type": "string"
},
"monthly_failed": {
"type": "string"
},
"monthly_refined": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"models.StatusMessageResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"models.StatusResponse": {
"type": "object",
"properties": {
"status": {
"type": "string"
}
}
}
}
}`

View File

@@ -81,22 +81,63 @@
"200": {
"description": "Cleanup completed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.StatusMessageResponse"
}
},
"400": {
"description": "Invalid request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
}
},
"/api/diagnostics/daily-creation": {
"get": {
"description": "Returns counts of daily summary rows missing CreationTime and sample rows for the given date.",
"produces": [
"application/json"
],
"tags": [
"diagnostics"
],
"summary": "Daily summary CreationTime diagnostics",
"parameters": [
{
"type": "string",
"description": "Daily date (YYYY-MM-DD)",
"name": "date",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "Diagnostics result",
"schema": {
"$ref": "#/definitions/models.DailyCreationDiagnosticsResponse"
}
},
"400": {
"description": "Invalid request",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
},
"404": {
"description": "Summary not found",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
},
"500": {
"description": "Server error",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
}
},
"/api/encrypt": {
@@ -130,19 +171,13 @@
"200": {
"description": "Ciphertext response",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.StatusMessageResponse"
}
},
"500": {
"description": "Server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
@@ -271,28 +306,19 @@
"200": {
"description": "Modify event processed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.StatusMessageResponse"
}
},
"202": {
"description": "No relevant changes",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.StatusMessageResponse"
}
},
"500": {
"description": "Server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
@@ -327,28 +353,19 @@
"200": {
"description": "Move event processed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.StatusMessageResponse"
}
},
"400": {
"description": "Invalid request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
},
"500": {
"description": "Server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
@@ -382,19 +399,13 @@
"200": {
"description": "Import processed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.StatusMessageResponse"
}
},
"500": {
"description": "Server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
@@ -430,19 +441,13 @@
"200": {
"description": "Cleanup completed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.StatusMessageResponse"
}
},
"400": {
"description": "Invalid request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
@@ -494,10 +499,7 @@
"500": {
"description": "Report generation failed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
@@ -532,19 +534,13 @@
"400": {
"description": "Invalid request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
},
"500": {
"description": "Server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
@@ -570,10 +566,7 @@
"500": {
"description": "Report generation failed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
@@ -603,34 +596,31 @@
"name": "date",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Monthly aggregation granularity: hourly or daily",
"name": "granularity",
"in": "query"
}
],
"responses": {
"200": {
"description": "Aggregation complete",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.StatusResponse"
}
},
"400": {
"description": "Invalid request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
},
"500": {
"description": "Server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
@@ -662,28 +652,19 @@
"200": {
"description": "Snapshot started",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.StatusResponse"
}
},
"400": {
"description": "Invalid request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
},
"500": {
"description": "Server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
@@ -703,17 +684,13 @@
"200": {
"description": "Migration results",
"schema": {
"type": "object",
"additionalProperties": true
"$ref": "#/definitions/models.SnapshotMigrationResponse"
}
},
"500": {
"description": "Server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/models.SnapshotMigrationResponse"
}
}
}
@@ -733,20 +710,56 @@
"200": {
"description": "Regeneration summary",
"schema": {
"type": "object",
"additionalProperties": true
"$ref": "#/definitions/models.SnapshotRegenerateReportsResponse"
}
},
"500": {
"description": "Server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
}
},
"/api/snapshots/repair": {
"post": {
"description": "Backfills SnapshotTime and lifecycle info for existing daily summary tables and reruns monthly lifecycle refinement using hourly data.",
"produces": [
"application/json"
],
"tags": [
"snapshots"
],
"summary": "Repair daily summaries",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.SnapshotRepairResponse"
}
}
}
}
},
"/api/snapshots/repair/all": {
"post": {
"description": "Rebuilds snapshot registry, backfills per-vCenter totals, repairs daily summaries (SnapshotTime/lifecycle), and refines monthly lifecycle.",
"produces": [
"application/json"
],
"tags": [
"snapshots"
],
"summary": "Run full snapshot repair suite",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.SnapshotRepairSuiteResponse"
}
}
}
}
},
"/metrics": {
@@ -1157,6 +1170,101 @@
}
}
},
"models.DailyCreationDiagnosticsResponse": {
"type": "object",
"properties": {
"avg_is_present_lt_one_count": {
"type": "integer"
},
"date": {
"type": "string"
},
"missing_by_vcenter": {
"type": "array",
"items": {
"$ref": "#/definitions/models.DailyCreationMissingByVcenter"
}
},
"missing_creation_count": {
"type": "integer"
},
"missing_creation_partial_count": {
"type": "integer"
},
"missing_creation_partial_samples": {
"type": "array",
"items": {
"$ref": "#/definitions/models.DailyCreationMissingSample"
}
},
"missing_creation_pct": {
"type": "number"
},
"samples": {
"type": "array",
"items": {
"$ref": "#/definitions/models.DailyCreationMissingSample"
}
},
"status": {
"type": "string"
},
"table": {
"type": "string"
},
"total_rows": {
"type": "integer"
}
}
},
"models.DailyCreationMissingByVcenter": {
"type": "object",
"properties": {
"missing_count": {
"type": "integer"
},
"vcenter": {
"type": "string"
}
}
},
"models.DailyCreationMissingSample": {
"type": "object",
"properties": {
"avg_is_present": {
"type": "number"
},
"name": {
"type": "string"
},
"samples_present": {
"type": "integer"
},
"snapshot_time": {
"type": "integer"
},
"vcenter": {
"type": "string"
},
"vm_id": {
"type": "string"
},
"vm_uuid": {
"type": "string"
}
}
},
"models.ErrorResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"models.ImportReceived": {
"type": "object",
"properties": {
@@ -1197,6 +1305,119 @@
"type": "string"
}
}
},
"models.SnapshotMigrationResponse": {
"type": "object",
"properties": {
"error": {
"type": "string"
},
"stats": {
"$ref": "#/definitions/models.SnapshotMigrationStats"
},
"status": {
"type": "string"
}
}
},
"models.SnapshotMigrationStats": {
"type": "object",
"properties": {
"DailyRegistered": {
"type": "integer"
},
"Errors": {
"type": "integer"
},
"HourlyRegistered": {
"type": "integer"
},
"HourlyRenamed": {
"type": "integer"
},
"MonthlyRegistered": {
"type": "integer"
}
}
},
"models.SnapshotRegenerateReportsResponse": {
"type": "object",
"properties": {
"errors": {
"type": "integer"
},
"regenerated": {
"type": "integer"
},
"reports_dir": {
"type": "string"
},
"skipped": {
"type": "integer"
},
"snapshotType": {
"type": "string"
},
"status": {
"type": "string"
},
"total": {
"type": "integer"
}
}
},
"models.SnapshotRepairResponse": {
"type": "object",
"properties": {
"failed": {
"type": "string"
},
"repaired": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"models.SnapshotRepairSuiteResponse": {
"type": "object",
"properties": {
"daily_failed": {
"type": "string"
},
"daily_repaired": {
"type": "string"
},
"monthly_failed": {
"type": "string"
},
"monthly_refined": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"models.StatusMessageResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"models.StatusResponse": {
"type": "object",
"properties": {
"status": {
"type": "string"
}
}
}
}
}

View File

@@ -126,6 +126,68 @@ definitions:
modified:
type: string
type: object
models.DailyCreationDiagnosticsResponse:
properties:
avg_is_present_lt_one_count:
type: integer
date:
type: string
missing_by_vcenter:
items:
$ref: '#/definitions/models.DailyCreationMissingByVcenter'
type: array
missing_creation_count:
type: integer
missing_creation_partial_count:
type: integer
missing_creation_partial_samples:
items:
$ref: '#/definitions/models.DailyCreationMissingSample'
type: array
missing_creation_pct:
type: number
samples:
items:
$ref: '#/definitions/models.DailyCreationMissingSample'
type: array
status:
type: string
table:
type: string
total_rows:
type: integer
type: object
models.DailyCreationMissingByVcenter:
properties:
missing_count:
type: integer
vcenter:
type: string
type: object
models.DailyCreationMissingSample:
properties:
avg_is_present:
type: number
name:
type: string
samples_present:
type: integer
snapshot_time:
type: integer
vcenter:
type: string
vm_id:
type: string
vm_uuid:
type: string
type: object
models.ErrorResponse:
properties:
message:
type: string
status:
type: string
type: object
models.ImportReceived:
properties:
Cluster:
@@ -153,6 +215,79 @@ definitions:
VmId:
type: string
type: object
models.SnapshotMigrationResponse:
properties:
error:
type: string
stats:
$ref: '#/definitions/models.SnapshotMigrationStats'
status:
type: string
type: object
models.SnapshotMigrationStats:
properties:
DailyRegistered:
type: integer
Errors:
type: integer
HourlyRegistered:
type: integer
HourlyRenamed:
type: integer
MonthlyRegistered:
type: integer
type: object
models.SnapshotRegenerateReportsResponse:
properties:
errors:
type: integer
regenerated:
type: integer
reports_dir:
type: string
skipped:
type: integer
snapshotType:
type: string
status:
type: string
total:
type: integer
type: object
models.SnapshotRepairResponse:
properties:
failed:
type: string
repaired:
type: string
status:
type: string
type: object
models.SnapshotRepairSuiteResponse:
properties:
daily_failed:
type: string
daily_repaired:
type: string
monthly_failed:
type: string
monthly_refined:
type: string
status:
type: string
type: object
models.StatusMessageResponse:
properties:
message:
type: string
status:
type: string
type: object
models.StatusResponse:
properties:
status:
type: string
type: object
info:
contact: {}
paths:
@@ -209,18 +344,46 @@ paths:
"200":
description: Cleanup completed
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.StatusMessageResponse'
"400":
description: Invalid request
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.ErrorResponse'
summary: Cleanup vCenter inventory (deprecated)
tags:
- maintenance
/api/diagnostics/daily-creation:
get:
description: Returns counts of daily summary rows missing CreationTime and sample
rows for the given date.
parameters:
- description: Daily date (YYYY-MM-DD)
in: query
name: date
required: true
type: string
produces:
- application/json
responses:
"200":
description: Diagnostics result
schema:
$ref: '#/definitions/models.DailyCreationDiagnosticsResponse'
"400":
description: Invalid request
schema:
$ref: '#/definitions/models.ErrorResponse'
"404":
description: Summary not found
schema:
$ref: '#/definitions/models.ErrorResponse'
"500":
description: Server error
schema:
$ref: '#/definitions/models.ErrorResponse'
summary: Daily summary CreationTime diagnostics
tags:
- diagnostics
/api/encrypt:
post:
consumes:
@@ -241,15 +404,11 @@ paths:
"200":
description: Ciphertext response
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.StatusMessageResponse'
"500":
description: Server error
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.ErrorResponse'
summary: Encrypt data
tags:
- crypto
@@ -337,21 +496,15 @@ paths:
"200":
description: Modify event processed
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.StatusMessageResponse'
"202":
description: No relevant changes
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.StatusMessageResponse'
"500":
description: Server error
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.ErrorResponse'
summary: Record VM modify event (deprecated)
tags:
- events
@@ -375,21 +528,15 @@ paths:
"200":
description: Move event processed
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.StatusMessageResponse'
"400":
description: Invalid request
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.ErrorResponse'
"500":
description: Server error
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.ErrorResponse'
summary: Record VM move event (deprecated)
tags:
- events
@@ -411,15 +558,11 @@ paths:
"200":
description: Import processed
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.StatusMessageResponse'
"500":
description: Server error
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.ErrorResponse'
summary: Import VMs
tags:
- inventory
@@ -443,15 +586,11 @@ paths:
"200":
description: Cleanup completed
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.StatusMessageResponse'
"400":
description: Invalid request
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.ErrorResponse'
summary: Cleanup VM inventory entry
tags:
- inventory
@@ -485,9 +624,7 @@ paths:
"500":
description: Report generation failed
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.ErrorResponse'
summary: Download inventory report
tags:
- reports
@@ -510,15 +647,11 @@ paths:
"400":
description: Invalid request
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.ErrorResponse'
"500":
description: Server error
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.ErrorResponse'
summary: Download snapshot report
tags:
- snapshots
@@ -535,9 +668,7 @@ paths:
"500":
description: Report generation failed
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.ErrorResponse'
summary: Download updates report
tags:
- reports
@@ -556,27 +687,25 @@ paths:
name: date
required: true
type: string
- description: 'Monthly aggregation granularity: hourly or daily'
in: query
name: granularity
type: string
produces:
- application/json
responses:
"200":
description: Aggregation complete
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.StatusResponse'
"400":
description: Invalid request
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.ErrorResponse'
"500":
description: Server error
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.ErrorResponse'
summary: Force snapshot aggregation
tags:
- snapshots
@@ -598,21 +727,15 @@ paths:
"200":
description: Snapshot started
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.StatusResponse'
"400":
description: Invalid request
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.ErrorResponse'
"500":
description: Server error
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.ErrorResponse'
summary: Trigger hourly snapshot (manual)
tags:
- snapshots
@@ -626,14 +749,11 @@ paths:
"200":
description: Migration results
schema:
additionalProperties: true
type: object
$ref: '#/definitions/models.SnapshotMigrationResponse'
"500":
description: Server error
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.SnapshotMigrationResponse'
summary: Migrate snapshot registry
tags:
- snapshots
@@ -647,17 +767,42 @@ paths:
"200":
description: Regeneration summary
schema:
additionalProperties: true
type: object
$ref: '#/definitions/models.SnapshotRegenerateReportsResponse'
"500":
description: Server error
schema:
additionalProperties:
type: string
type: object
$ref: '#/definitions/models.ErrorResponse'
summary: Regenerate hourly snapshot reports
tags:
- snapshots
/api/snapshots/repair:
post:
description: Backfills SnapshotTime and lifecycle info for existing daily summary
tables and reruns monthly lifecycle refinement using hourly data.
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/models.SnapshotRepairResponse'
summary: Repair daily summaries
tags:
- snapshots
/api/snapshots/repair/all:
post:
description: Rebuilds snapshot registry, backfills per-vCenter totals, repairs
daily summaries (SnapshotTime/lifecycle), and refines monthly lifecycle.
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/models.SnapshotRepairSuiteResponse'
summary: Run full snapshot repair suite
tags:
- snapshots
/metrics:
get:
description: Exposes Prometheus metrics for vctp.

View File

@@ -65,7 +65,10 @@ func New(logger *slog.Logger, database db.Database, buildTime string, sha1ver st
mux.HandleFunc("/api/snapshots/aggregate", h.SnapshotAggregateForce)
mux.HandleFunc("/api/snapshots/hourly/force", h.SnapshotForceHourly)
mux.HandleFunc("/api/snapshots/migrate", h.SnapshotMigrate)
mux.HandleFunc("/api/snapshots/repair", h.SnapshotRepair)
mux.HandleFunc("/api/snapshots/repair/all", h.SnapshotRepairSuite)
mux.HandleFunc("/api/snapshots/regenerate-hourly-reports", h.SnapshotRegenerateHourlyReports)
mux.HandleFunc("/api/diagnostics/daily-creation", h.DailyCreationDiagnostics)
mux.HandleFunc("/vm/trace", h.VmTrace)
mux.HandleFunc("/vcenters", h.VcenterList)
mux.HandleFunc("/vcenters/totals", h.VcenterTotals)

View File

@@ -1 +1,3 @@
CPE_OPTS='-settings /etc/dtms/vctp.yml'
MONTHLY_AGG_GO=0
DAILY_AGG_GO=0

View File

@@ -26,6 +26,8 @@ settings:
hourly_snapshot_timeout_seconds: 600
daily_job_timeout_seconds: 900
monthly_job_timeout_seconds: 1200
monthly_aggregation_granularity: "hourly"
monthly_aggregation_cron: "10 3 1 * *"
cleanup_job_timeout_seconds: 600
tenants_to_filter:
node_charge_clusters:

View File

@@ -1,7 +1,7 @@
name: "vctp"
arch: "amd64"
platform: "linux"
version: "v26.1.1"
version: "v26.1.2"
version_schema: semver
description: vCTP monitors VMware VM inventory and event data to build chargeback reports
maintainer: "@coadn"