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

View File

@@ -3,13 +3,29 @@ vCTP is a vSphere Chargeback Tracking Platform, designed for a specific customer
## Snapshots and Reports ## Snapshots and Reports
- Hourly snapshots capture inventory per vCenter (concurrency via `hourly_snapshot_concurrency`). - Hourly snapshots capture inventory per vCenter (concurrency via `hourly_snapshot_concurrency`).
- Daily summaries aggregate the hourly snapshots for the day; monthly summaries aggregate daily summaries for the month. - Daily summaries aggregate the hourly snapshots for the day; monthly summaries aggregate daily summaries for the month (or hourly snapshots if configured).
- Snapshots are registered in `snapshot_registry` so regeneration via `/api/snapshots/aggregate` can locate the correct tables (fallback scanning is also supported). - Snapshots are registered in `snapshot_registry` so regeneration via `/api/snapshots/aggregate` can locate the correct tables (fallback scanning is also supported).
- Reports (XLSX with totals/charts) are generated automatically after hourly, daily, and monthly jobs and written to a reports directory. - Reports (XLSX with totals/charts) are generated automatically after hourly, daily, and monthly jobs and written to a reports directory.
- Hourly totals in reports are interval-based: each row represents `[HH:00, HH+1:00)` and uses the first snapshot at or after the hour end (including cross-day snapshots) to prorate VM presence by creation/deletion overlap.
- 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`: - 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` - 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}` - 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) ## RPM Layout (summary)
The RPM installs the service and defaults under `/usr/bin`, config under `/etc/dtms`, and data under `/var/lib/vctp`: 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` - 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`) - Data: SQLite DB and reports default to `/var/lib/vctp` (reports under `/var/lib/vctp/reports`)
- Scripts: preinstall/postinstall handle directory creation and permissions. - 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 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. `/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 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 default the app uses SQLite and creates/opens `db.sqlite3`. You can opt into PostgreSQL
by updating the settings file: by updating the settings file:
@@ -48,13 +71,13 @@ settings:
PostgreSQL migrations live in `db/migrations_postgres`, while SQLite migrations remain in PostgreSQL migrations live in `db/migrations_postgres`, while SQLite migrations remain in
`db/migrations`. `db/migrations`.
### Snapshot Retention ## Snapshot Retention
Hourly and daily snapshot table retention can be configured in the settings file: Hourly and daily snapshot table retention can be configured in the settings file:
- `settings.hourly_snapshot_max_age_days` (default: 60) - `settings.hourly_snapshot_max_age_days` (default: 60)
- `settings.daily_snapshot_max_age_months` (default: 12) - `settings.daily_snapshot_max_age_months` (default: 12)
### Settings Reference ## Settings Reference
All configuration lives under the top-level `settings:` key in `vctp.yml`. All configuration lives under the top-level `settings:` key in `vctp.yml`.
General: 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> <p class="mt-3 text-xl font-semibold">{info.GoVersion}</p>
</div> </div>
</section> </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> </main>
</body> </body>
@core.Footer() @core.Footer()

View File

@@ -86,7 +86,7 @@ func Index(info BuildInfo) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }

View File

@@ -35,7 +35,7 @@ type VmTraceChart struct {
YTicks []ChartTick 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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@core.Header() @core.Header()
@@ -124,6 +124,9 @@ templ VmTracePage(query string, display_query string, vm_id string, vm_uuid stri
<div class="web2-card"> <div class="web2-card">
<p class="text-xs uppercase tracking-[0.15em] text-slate-500">Creation time</p> <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> <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>
<div class="web2-card"> <div class="web2-card">
<p class="text-xs uppercase tracking-[0.15em] text-slate-500">Deletion time</p> <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 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) { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -560,73 +560,57 @@ func VmTracePage(query string, display_query string, vm_id string, vm_uuid strin
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var36 string var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(deletionLabel) templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(deletionLabel)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, e := range entries { 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var37 string var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(e.Snapshot) templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(e.Snapshot)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</td><td>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var40 string var templ_7745c5c3_Var38 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(e.VmUuid) templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(e.Name)
if templ_7745c5c3_Err != nil { 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var41 string var templ_7745c5c3_Var39 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(e.Vcenter) templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(e.VmId)
if templ_7745c5c3_Err != nil { 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(e.ResourcePool) templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(e.ResourcePool)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</td><td class=\"text-right\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", e.ProvisionedDisk)) templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", e.ProvisionedDisk))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</td></tr>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</td></tr>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if err := ValidateTableName(table); err != nil {
return 0, err return 0, err
} }
start := time.Now()
slog.Debug("db row count start", "table", table)
var count int64 var count int64
query := fmt.Sprintf(`SELECT COUNT(*) FROM %s`, table) query := fmt.Sprintf(`SELECT COUNT(*) FROM %s`, table)
if err := getLog(ctx, dbConn, &count, query); err != nil { 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 return 0, err
} }
slog.Debug("db row count complete", "table", table, "rows", count, "duration", time.Since(start))
return count, nil 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)) slog.Debug("db get returned no rows", "query", strings.TrimSpace(query))
return err 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) slog.Warn("db get failed", "query", strings.TrimSpace(query), "error", err)
} }
}
return err return err
} }
func selectLog(ctx context.Context, dbConn *sqlx.DB, dest interface{}, query string, args ...interface{}) error { func selectLog(ctx context.Context, dbConn *sqlx.DB, dest interface{}, query string, args ...interface{}) error {
err := dbConn.SelectContext(ctx, dest, query, args...) err := dbConn.SelectContext(ctx, dest, query, args...)
if err != nil { 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) slog.Warn("db select failed", "query", strings.TrimSpace(query), "error", err)
} }
}
return 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 { if err := ValidateTableName(table); err != nil {
return false, err 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) query := fmt.Sprintf(`SELECT 1 FROM %s LIMIT 1`, table)
var exists int var exists int
if err := getLog(ctx, dbConn, &exists, query); err != nil { if err := getLog(ctx, dbConn, &exists, query); err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return false, nil return false, nil
} }
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return false, nil
}
return false, err return false, err
} }
return true, nil return true, nil
@@ -385,6 +408,7 @@ func ApplySQLiteTuning(ctx context.Context, dbConn *sqlx.DB) {
`PRAGMA synchronous=NORMAL;`, `PRAGMA synchronous=NORMAL;`,
`PRAGMA temp_store=MEMORY;`, `PRAGMA temp_store=MEMORY;`,
`PRAGMA optimize;`, `PRAGMA optimize;`,
`PRAGMA busy_timeout=5000;`,
} }
for _, pragma := range pragmas { for _, pragma := range pragmas {
_, err = execLog(ctx, dbConn, pragma) _, 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. // EnsureVmIdentityTables creates the identity and rename audit tables.
func EnsureVmIdentityTables(ctx context.Context, dbConn *sqlx.DB) error { func EnsureVmIdentityTables(ctx context.Context, dbConn *sqlx.DB) error {
driver := strings.ToLower(dbConn.DriverName()) driver := strings.ToLower(dbConn.DriverName())
@@ -735,6 +1152,7 @@ type VmTraceRow struct {
// VmLifecycle captures observed lifecycle times from hourly snapshots. // VmLifecycle captures observed lifecycle times from hourly snapshots.
type VmLifecycle struct { type VmLifecycle struct {
CreationTime int64 CreationTime int64
CreationApprox bool
FirstSeen int64 FirstSeen int64
LastSeen int64 LastSeen int64
DeletionTime int64 DeletionTime int64
@@ -818,6 +1236,7 @@ ORDER BY snapshot_time
driver := strings.ToLower(dbConn.DriverName()) driver := strings.ToLower(dbConn.DriverName())
minCreation := int64(0) minCreation := int64(0)
consecutiveMissing := 0
for _, t := range tables { for _, t := range tables {
if err := ValidateTableName(t.TableName); err != nil { if err := ValidateTableName(t.TableName); err != nil {
continue continue
@@ -846,20 +1265,29 @@ WHERE ("VmId" = ? OR "VmUuid" = ? OR lower("Name") = lower(?))
lifecycle.FirstSeen = t.SnapshotTime lifecycle.FirstSeen = t.SnapshotTime
} }
lifecycle.LastSeen = t.SnapshotTime lifecycle.LastSeen = t.SnapshotTime
consecutiveMissing = 0
if probe.MinCreation.Valid { if probe.MinCreation.Valid {
if minCreation == 0 || probe.MinCreation.Int64 < minCreation { if minCreation == 0 || probe.MinCreation.Int64 < minCreation {
minCreation = probe.MinCreation.Int64 minCreation = probe.MinCreation.Int64
} }
} }
} else if lifecycle.LastSeen > 0 && lifecycle.DeletionTime == 0 && t.SnapshotTime > lifecycle.LastSeen { } else if lifecycle.LastSeen > 0 && lifecycle.DeletionTime == 0 && t.SnapshotTime > lifecycle.LastSeen {
consecutiveMissing++
if consecutiveMissing >= 2 {
lifecycle.DeletionTime = t.SnapshotTime lifecycle.DeletionTime = t.SnapshotTime
break break
} }
} else {
// reset if we haven't seen the VM yet
consecutiveMissing = 0
}
} }
if minCreation > 0 { if minCreation > 0 {
lifecycle.CreationTime = minCreation lifecycle.CreationTime = minCreation
lifecycle.CreationApprox = false
} else if lifecycle.FirstSeen > 0 { } else if lifecycle.FirstSeen > 0 {
lifecycle.CreationTime = lifecycle.FirstSeen lifecycle.CreationTime = lifecycle.FirstSeen
lifecycle.CreationApprox = true
} }
return lifecycle, nil return lifecycle, nil
} }
@@ -934,9 +1362,13 @@ func AnalyzeTableIfPostgres(ctx context.Context, dbConn *sqlx.DB, tableName stri
if driver != "pgx" && driver != "postgres" { if driver != "pgx" && driver != "postgres" {
return return
} }
start := time.Now()
slog.Debug("db analyze start", "table", tableName)
if _, err := execLog(ctx, dbConn, fmt.Sprintf(`ANALYZE %s`, tableName)); err != nil { if _, err := execLog(ctx, dbConn, fmt.Sprintf(`ANALYZE %s`, tableName)); err != nil {
slog.Warn("failed to ANALYZE table", "table", tableName, "error", err) 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. // 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 ( WITH snapshots AS (
%s %s
), totals AS ( ), 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 ( ), agg AS (
SELECT SELECT
s."InventoryId", s."Name", s."Vcenter", s."VmId", s."EventKey", s."CloudId", s."InventoryId", s."Name", s."Vcenter", s."VmId", s."EventKey", s."CloudId",
MIN(NULLIF(s."CreationTime", 0)) AS any_creation, MIN(NULLIF(s."CreationTime", 0)) AS any_creation,
MAX(NULLIF(s."DeletionTime", 0)) AS any_deletion, MAX(NULLIF(s."DeletionTime", 0)) AS any_deletion,
MAX(COALESCE(inv."DeletionTime", 0)) AS inv_deletion,
MIN(s."SnapshotTime") AS first_present, MIN(s."SnapshotTime") AS first_present,
MAX(s."SnapshotTime") AS last_present, MAX(s."SnapshotTime") AS last_present,
COUNT(*) AS samples_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") = 'silver' THEN 1 ELSE 0 END) AS silver_hits,
SUM(CASE WHEN LOWER(s."ResourcePool") = 'gold' THEN 1 ELSE 0 END) AS gold_hits SUM(CASE WHEN LOWER(s."ResourcePool") = 'gold' THEN 1 ELSE 0 END) AS gold_hits
FROM snapshots s FROM snapshots s
LEFT JOIN inventory inv ON inv."VmId" = s."VmId" AND inv."Vcenter" = s."Vcenter"
GROUP BY GROUP BY
s."InventoryId", s."Name", s."Vcenter", s."VmId", s."EventKey", s."CloudId", s."InventoryId", s."Name", s."Vcenter", s."VmId", s."EventKey", s."CloudId",
s."Datacenter", s."Cluster", s."Folder", s."Datacenter", s."Cluster", s."Folder",
@@ -1039,16 +1471,15 @@ WITH snapshots AS (
INSERT INTO %s ( INSERT INTO %s (
"InventoryId", "Name", "Vcenter", "VmId", "EventKey", "CloudId", "CreationTime", "DeletionTime", "InventoryId", "Name", "Vcenter", "VmId", "EventKey", "CloudId", "CreationTime", "DeletionTime",
"ResourcePool", "Datacenter", "Cluster", "Folder", "ProvisionedDisk", "VcpuCount", "ResourcePool", "Datacenter", "Cluster", "Folder", "ProvisionedDisk", "VcpuCount",
"RamGB", "IsTemplate", "PoweredOn", "SrmPlaceholder", "VmUuid", "RamGB", "IsTemplate", "PoweredOn", "SrmPlaceholder", "VmUuid", "SnapshotTime",
"SamplesPresent", "AvgVcpuCount", "AvgRamGB", "AvgProvisionedDisk", "AvgIsPresent", "SamplesPresent", "AvgVcpuCount", "AvgRamGB", "AvgProvisionedDisk", "AvgIsPresent",
"PoolTinPct", "PoolBronzePct", "PoolSilverPct", "PoolGoldPct", "PoolTinPct", "PoolBronzePct", "PoolSilverPct", "PoolGoldPct",
"Tin", "Bronze", "Silver", "Gold" "Tin", "Bronze", "Silver", "Gold"
) )
SELECT SELECT
agg."InventoryId", agg."Name", agg."Vcenter", agg."VmId", agg."EventKey", agg."CloudId", 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 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( WHEN totals.max_snapshot IS NOT NULL AND agg.last_present < totals.max_snapshot THEN COALESCE(
NULLIF(agg.any_deletion, 0), NULLIF(agg.any_deletion, 0),
(SELECT MIN(s2."SnapshotTime") FROM snapshots s2 WHERE s2."SnapshotTime" > agg.last_present), (SELECT MIN(s2."SnapshotTime") FROM snapshots s2 WHERE s2."SnapshotTime" > agg.last_present),
@@ -1091,6 +1522,7 @@ SELECT
LIMIT 1 LIMIT 1
) AS "RamGB", ) AS "RamGB",
agg."IsTemplate", agg."PoweredOn", agg."SrmPlaceholder", agg."VmUuid", agg."IsTemplate", agg."PoweredOn", agg."SrmPlaceholder", agg."VmUuid",
agg.last_present AS "SnapshotTime",
agg.samples_present AS "SamplesPresent", agg.samples_present AS "SamplesPresent",
CASE WHEN totals.total_samples > 0 CASE WHEN totals.total_samples > 0
THEN 1.0 * agg.sum_vcpu / totals.total_samples THEN 1.0 * agg.sum_vcpu / totals.total_samples
@@ -1129,17 +1561,51 @@ SELECT
THEN 100.0 * agg.gold_hits / agg.samples_present THEN 100.0 * agg.gold_hits / agg.samples_present
ELSE NULL END AS "Gold" ELSE NULL END AS "Gold"
FROM agg FROM agg
CROSS JOIN totals JOIN totals ON totals."Vcenter" = agg."Vcenter"
GROUP BY GROUP BY
agg."InventoryId", agg."Name", agg."Vcenter", agg."VmId", agg."EventKey", agg."CloudId", agg."InventoryId", agg."Name", agg."Vcenter", agg."VmId", agg."EventKey", agg."CloudId",
agg."Datacenter", agg."Cluster", agg."Folder", agg."Datacenter", agg."Cluster", agg."Folder",
agg."IsTemplate", agg."PoweredOn", agg."SrmPlaceholder", agg."VmUuid", agg."IsTemplate", agg."PoweredOn", agg."SrmPlaceholder", agg."VmUuid",
agg.any_creation, agg.any_deletion, agg.first_present, agg.last_present, agg.any_creation, agg.any_deletion, agg.first_present, agg.last_present,
totals.total_samples; totals.total_samples, totals.max_snapshot;
`, unionQuery, tableName) `, unionQuery, tableName)
return insert, nil 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 // 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. // 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 { func RefineCreationDeletionFromUnion(ctx context.Context, dbConn *sqlx.DB, summaryTable, unionQuery string) error {
@@ -1174,12 +1640,11 @@ UPDATE %s dst
SET SET
"CreationTime" = CASE "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.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" ELSE dst."CreationTime"
END, END,
"DeletionTime" = CASE "DeletionTime" = CASE
WHEN t_last_after IS NOT NULL 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 THEN t_last_after
ELSE dst."DeletionTime" ELSE dst."DeletionTime"
END END
@@ -1236,7 +1701,6 @@ SET
( (
SELECT CASE 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 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 ELSE NULL
END END
FROM enriched t 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") (%[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 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 LIMIT 1
), ),
"DeletionTime" "DeletionTime"
@@ -1318,8 +1782,13 @@ SELECT
COALESCE(NULLIF("CreationTime", 0), MIN(NULLIF("CreationTime", 0)), 0) AS "CreationTime", COALESCE(NULLIF("CreationTime", 0), MIN(NULLIF("CreationTime", 0)), 0) AS "CreationTime",
NULLIF(MAX(NULLIF("DeletionTime", 0)), 0) AS "DeletionTime", NULLIF(MAX(NULLIF("DeletionTime", 0)), 0) AS "DeletionTime",
MAX("ResourcePool") AS "ResourcePool", MAX("ResourcePool") AS "ResourcePool",
"Datacenter", "Cluster", "Folder", "ProvisionedDisk", "VcpuCount", "Datacenter", "Cluster", "Folder",
"RamGB", "IsTemplate", "PoweredOn", "SrmPlaceholder", "VmUuid", MAX("ProvisionedDisk") AS "ProvisionedDisk",
MAX("VcpuCount") AS "VcpuCount",
MAX("RamGB") AS "RamGB",
"IsTemplate",
MAX("PoweredOn") AS "PoweredOn",
"SrmPlaceholder", "VmUuid",
SUM("SamplesPresent") AS "SamplesPresent", SUM("SamplesPresent") AS "SamplesPresent",
CASE WHEN totals.total_samples > 0 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 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 CROSS JOIN totals
GROUP BY GROUP BY
"InventoryId", "Name", "Vcenter", "VmId", "EventKey", "CloudId", "InventoryId", "Name", "Vcenter", "VmId", "EventKey", "CloudId",
"Datacenter", "Cluster", "Folder", "ProvisionedDisk", "VcpuCount", "Datacenter", "Cluster", "Folder",
"RamGB", "IsTemplate", "PoweredOn", "SrmPlaceholder", "VmUuid"; "IsTemplate", "SrmPlaceholder", "VmUuid";
`, unionQuery, tableName) `, unionQuery, tableName)
return insert, nil return insert, nil
} }
@@ -1407,6 +1876,7 @@ func EnsureSummaryTable(ctx context.Context, dbConn *sqlx.DB, tableName string)
"PoolBronzePct" REAL, "PoolBronzePct" REAL,
"PoolSilverPct" REAL, "PoolSilverPct" REAL,
"PoolGoldPct" REAL, "PoolGoldPct" REAL,
"SnapshotTime" BIGINT,
"Tin" REAL, "Tin" REAL,
"Bronze" REAL, "Bronze" REAL,
"Silver" REAL, "Silver" REAL,
@@ -1443,6 +1913,7 @@ func EnsureSummaryTable(ctx context.Context, dbConn *sqlx.DB, tableName string)
"PoolBronzePct" REAL, "PoolBronzePct" REAL,
"PoolSilverPct" REAL, "PoolSilverPct" REAL,
"PoolGoldPct" REAL, "PoolGoldPct" REAL,
"SnapshotTime" BIGINT,
"Tin" REAL, "Tin" REAL,
"Bronze" REAL, "Bronze" REAL,
"Silver" 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 { if hasIsPresent, err := ColumnExists(ctx, dbConn, tableName, "IsPresent"); err == nil && hasIsPresent {
_, _ = execLog(ctx, dbConn, fmt.Sprintf(`ALTER TABLE %s DROP COLUMN "IsPresent"`, tableName)) _, _ = 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{ indexes := []string{
fmt.Sprintf(`CREATE INDEX IF NOT EXISTS %s_vm_vcenter_idx ON %s ("VmId","Vcenter")`, tableName, tableName), 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 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. // EnsureSnapshotRunTable creates a table to track per-vCenter hourly snapshot attempts.
func EnsureSnapshotRunTable(ctx context.Context, dbConn *sqlx.DB) error { func EnsureSnapshotRunTable(ctx context.Context, dbConn *sqlx.DB) error {
ddl := ` ddl := `

View File

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

View File

@@ -29,6 +29,26 @@ body {
border-radius: 4px; border-radius: 4px;
padding: 1.5rem 1.75rem; 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 { .web2-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -41,6 +61,18 @@ body {
font-size: 0.85rem; font-size: 0.85rem;
letter-spacing: 0.02em; 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 { .web2-link {
color: var(--web2-blue); color: var(--web2-blue);
text-decoration: none; text-decoration: none;

View File

@@ -6,11 +6,13 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"log/slog" "log/slog"
"math"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"vctp/db" "vctp/db"
@@ -33,6 +35,8 @@ type SnapshotMigrationStats struct {
Errors int Errors int
} }
var hourlyTotalsQueryDumpOnce sync.Once
func ListTablesByPrefix(ctx context.Context, database db.Database, prefix string) ([]string, error) { func ListTablesByPrefix(ctx context.Context, database db.Database, prefix string) ([]string, error) {
dbConn := database.DB() dbConn := database.DB()
driver := strings.ToLower(dbConn.DriverName()) driver := strings.ToLower(dbConn.DriverName())
@@ -169,7 +173,11 @@ func MigrateSnapshotRegistry(ctx context.Context, database db.Database) (Snapsho
} }
if snapshotTime.IsZero() { if snapshotTime.IsZero() {
suffix := strings.TrimPrefix(table, "inventory_hourly_") 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 snapshotTime = parsed
} else if epoch, parseErr := strconv.ParseInt(suffix, 10, 64); parseErr == nil { } else if epoch, parseErr := strconv.ParseInt(suffix, 10, 64); parseErr == nil {
snapshotTime = time.Unix(epoch, 0) snapshotTime = time.Unix(epoch, 0)
@@ -254,9 +262,17 @@ func RegisterSnapshot(ctx context.Context, database db.Database, snapshotType st
} }
dbConn := database.DB() dbConn := database.DB()
driver := strings.ToLower(dbConn.DriverName()) 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 { switch driver {
case "sqlite": case "sqlite":
_, err := dbConn.ExecContext(ctx, ` _, err = dbConn.ExecContext(ctx, `
INSERT INTO snapshot_registry (snapshot_type, table_name, snapshot_time, snapshot_count) INSERT INTO snapshot_registry (snapshot_type, table_name, snapshot_time, snapshot_count)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
ON CONFLICT(table_name) DO UPDATE SET ON CONFLICT(table_name) DO UPDATE SET
@@ -264,9 +280,8 @@ ON CONFLICT(table_name) DO UPDATE SET
snapshot_type = excluded.snapshot_type, snapshot_type = excluded.snapshot_type,
snapshot_count = excluded.snapshot_count snapshot_count = excluded.snapshot_count
`, snapshotType, tableName, snapshotTime.Unix(), snapshotCount) `, snapshotType, tableName, snapshotTime.Unix(), snapshotCount)
return err
case "pgx", "postgres": case "pgx", "postgres":
_, err := dbConn.ExecContext(ctx, ` _, err = dbConn.ExecContext(ctx, `
INSERT INTO snapshot_registry (snapshot_type, table_name, snapshot_time, snapshot_count) INSERT INTO snapshot_registry (snapshot_type, table_name, snapshot_time, snapshot_count)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
ON CONFLICT (table_name) DO UPDATE SET ON CONFLICT (table_name) DO UPDATE SET
@@ -274,10 +289,24 @@ ON CONFLICT (table_name) DO UPDATE SET
snapshot_type = EXCLUDED.snapshot_type, snapshot_type = EXCLUDED.snapshot_type,
snapshot_count = EXCLUDED.snapshot_count snapshot_count = EXCLUDED.snapshot_count
`, snapshotType, tableName, snapshotTime.Unix(), snapshotCount) `, snapshotType, tableName, snapshotTime.Unix(), snapshotCount)
return err
default: default:
return fmt.Errorf("unsupported driver for snapshot registry: %s", driver) 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 { 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, TableName: table,
SnapshotTime: ts, SnapshotTime: ts,
SnapshotType: snapshotType, 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 { if err := db.ValidateTableName(tableName); err != nil {
return nil, err return nil, err
} }
start := time.Now()
logger.Debug("Create table report start", "table", tableName)
dbConn := Database.DB() dbConn := Database.DB()
if strings.HasPrefix(tableName, "inventory_daily_summary_") || strings.HasPrefix(tableName, "inventory_monthly_summary_") { 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) columns, err := tableColumns(ctx, dbConn, tableName)
if err != nil { if err != nil {
logger.Warn("Failed to load report columns", "table", tableName, "error", err)
return nil, err return nil, err
} }
if len(columns) == 0 { if len(columns) == 0 {
return nil, fmt.Errorf("no columns found for table %s", tableName) 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_") isHourlySnapshot := strings.HasPrefix(tableName, "inventory_hourly_")
isDailySummary := strings.HasPrefix(tableName, "inventory_daily_summary_") isDailySummary := strings.HasPrefix(tableName, "inventory_daily_summary_")
@@ -612,9 +647,11 @@ func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Co
if orderBy != "" { if orderBy != "" {
query = fmt.Sprintf(`%s ORDER BY "%s" %s`, query, orderBy, orderDir) 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) rows, err := dbConn.QueryxContext(ctx, query)
if err != nil { if err != nil {
logger.Warn("Report query failed", "table", tableName, "error", err)
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
@@ -664,6 +701,7 @@ func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Co
for rows.Next() { for rows.Next() {
values, err := scanRowValues(rows, len(columns)) values, err := scanRowValues(rows, len(columns))
if err != nil { if err != nil {
logger.Warn("Report row scan failed", "table", tableName, "error", err)
return nil, err return nil, err
} }
for colIndex, spec := range specs { for colIndex, spec := range specs {
@@ -686,8 +724,11 @@ func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Co
rowIndex++ rowIndex++
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
logger.Warn("Report row iteration failed", "table", tableName, "error", err)
return nil, err return nil, err
} }
rowCount := rowIndex - 2
logger.Debug("Report rows populated", "table", tableName, "rows", rowCount)
if err := xlsx.SetPanes(sheetName, &excelize.Panes{ if err := xlsx.SetPanes(sheetName, &excelize.Panes{
Freeze: true, Freeze: true,
@@ -708,18 +749,34 @@ func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Co
} }
if isDailySummary || isMonthlySummary { 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) addTotalsChartSheet(logger, Database, ctx, xlsx, tableName)
logger.Debug("Report charts complete", "table", tableName)
if index, err := xlsx.GetSheetIndex(sheetName); err == nil { if index, err := xlsx.GetSheetIndex(sheetName); err == nil {
xlsx.SetActiveSheet(index) xlsx.SetActiveSheet(index)
} }
if err := xlsx.Write(&buffer); err != nil { if err := xlsx.Write(&buffer); err != nil {
logger.Warn("Report write failed", "table", tableName, "error", err)
return nil, 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 return buffer.Bytes(), nil
} }
@@ -731,38 +788,61 @@ func SaveTableReport(logger *slog.Logger, Database db.Database, ctx context.Cont
if strings.TrimSpace(destDir) == "" { if strings.TrimSpace(destDir) == "" {
return "", fmt.Errorf("destination directory is empty") 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 { 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) return "", fmt.Errorf("failed to create reports directory: %w", err)
} }
logger.Debug("Report directory ready", "dest", destDir)
data, err := CreateTableReport(logger, Database, ctx, tableName) data, err := CreateTableReport(logger, Database, ctx, tableName)
if err != nil { if err != nil {
logger.Warn("Report render failed", "table", tableName, "error", err)
return "", err return "", err
} }
logger.Debug("Report rendered", "table", tableName, "bytes", len(data))
filename := filepath.Join(destDir, fmt.Sprintf("%s.xlsx", tableName)) filename := filepath.Join(destDir, fmt.Sprintf("%s.xlsx", tableName))
if err := os.WriteFile(filename, data, 0o644); err != nil { if err := os.WriteFile(filename, data, 0o644); err != nil {
logger.Warn("Report write failed", "table", tableName, "file", filename, "error", err)
return "", err return "", err
} }
logger.Debug("Save table report complete", "table", tableName, "file", filename, "duration", time.Since(start))
return filename, nil return filename, nil
} }
func addTotalsChartSheet(logger *slog.Logger, database db.Database, ctx context.Context, xlsx *excelize.File, tableName string) { 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_") { if strings.HasPrefix(tableName, "inventory_daily_summary_") {
suffix := strings.TrimPrefix(tableName, "inventory_daily_summary_") suffix := strings.TrimPrefix(tableName, "inventory_daily_summary_")
dayStart, err := time.ParseInLocation("20060102", suffix, time.Local) dayStart, err := time.ParseInLocation("20060102", suffix, time.Local)
if err != nil { if err != nil {
logger.Debug("hourly totals skip: invalid daily summary suffix", "table", tableName, "suffix", suffix, "error", err)
return return
} }
dayEnd := dayStart.AddDate(0, 0, 1) dayEnd := dayStart.AddDate(0, 0, 1)
if err := EnsureSnapshotRegistry(ctx, database); err != nil { if err := EnsureSnapshotRegistry(ctx, database); err != nil {
logger.Debug("hourly totals skip: snapshot registry unavailable", "table", tableName, "error", err)
return return
} }
records, err := SnapshotRecordsWithFallback(ctx, database, "hourly", "inventory_hourly_", "epoch", dayStart, dayEnd) records, err := SnapshotRecordsWithFallback(ctx, database, "hourly", "inventory_hourly_", "epoch", dayStart, dayEnd.Add(2*time.Hour))
if err != nil || len(records) == 0 { if err != nil {
logger.Debug("hourly totals skip: failed to load hourly snapshots", "table", tableName, "error", err)
return return
} }
points, err := buildHourlyTotals(ctx, database.DB(), records) if len(records) == 0 {
if err != nil || len(points) == 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 return
} }
writeTotalsChart(logger, xlsx, "Hourly Totals", points) 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_") suffix := strings.TrimPrefix(tableName, "inventory_monthly_summary_")
monthStart, err := time.ParseInLocation("200601", suffix, time.Local) monthStart, err := time.ParseInLocation("200601", suffix, time.Local)
if err != nil { if err != nil {
logger.Debug("daily totals skip: invalid monthly summary suffix", "table", tableName, "suffix", suffix, "error", err)
return return
} }
monthEnd := monthStart.AddDate(0, 1, 0) monthEnd := monthStart.AddDate(0, 1, 0)
if err := EnsureSnapshotRegistry(ctx, database); err != nil { if err := EnsureSnapshotRegistry(ctx, database); err != nil {
logger.Debug("daily totals skip: snapshot registry unavailable", "table", tableName, "error", err)
return return
} }
records, err := SnapshotRecordsWithFallback(ctx, database, "daily", "inventory_daily_summary_", "20060102", monthStart, monthEnd) 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 return
} }
points, err := buildDailyTotals(ctx, database.DB(), records, true) 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 return
} }
writeTotalsChart(logger, xlsx, "Daily Totals", points) 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" sheetName := "Metadata"
if _, err := xlsx.NewSheet(sheetName); err != nil { if _, err := xlsx.NewSheet(sheetName); err != nil {
logger.Error("Error creating metadata sheet", "error", err) logger.Error("Error creating metadata sheet", "error", err)
return return
} }
xlsx.SetCellValue(sheetName, "A1", "ReportGeneratedAt") rows := []struct {
xlsx.SetCellValue(sheetName, "B1", time.Now().Format(time.RFC3339)) 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 { if err := SetColAutoWidth(xlsx, sheetName); err != nil {
logger.Error("Error setting metadata auto width", "error", err) logger.Error("Error setting metadata auto width", "error", err)
} }
@@ -1019,7 +1199,7 @@ func normalizeCellValue(value interface{}) interface{} {
type totalsPoint struct { type totalsPoint struct {
Label string Label string
VmCount int64 VmCount float64
VcpuTotal float64 VcpuTotal float64
RamTotal float64 RamTotal float64
PresenceRatio float64 PresenceRatio float64
@@ -1029,28 +1209,260 @@ type totalsPoint struct {
GoldTotal float64 GoldTotal float64
} }
func buildHourlyTotals(ctx context.Context, dbConn *sqlx.DB, records []SnapshotRecord) ([]totalsPoint, error) { func buildHourlyTotals(ctx context.Context, logger *slog.Logger, dbConn *sqlx.DB, records []SnapshotRecord, windowStart, windowEnd time.Time) ([]totalsPoint, error) {
points := make([]totalsPoint, 0, len(records)) if logger == nil {
for _, record := range records { 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 { if err := db.ValidateTableName(record.TableName); err != nil {
return nil, err 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 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(` query := fmt.Sprintf(`
WITH base AS (
SELECT SELECT
COUNT(DISTINCT "VmId") AS vm_count, %s AS vm_key,
COALESCE(SUM(CASE WHEN "VcpuCount" IS NOT NULL THEN "VcpuCount" ELSE 0 END), 0) AS vcpu_total, "VmId",
COALESCE(SUM(CASE WHEN "RamGB" IS NOT NULL THEN "RamGB" ELSE 0 END), 0) AS ram_total, "VmUuid",
1.0 AS presence_ratio, "Name",
COALESCE(SUM(CASE WHEN LOWER("ResourcePool") = 'tin' THEN 1 ELSE 0 END), 0) AS tin_total, "Vcenter",
COALESCE(SUM(CASE WHEN LOWER("ResourcePool") = 'bronze' THEN 1 ELSE 0 END), 0) AS bronze_total, "VcpuCount",
COALESCE(SUM(CASE WHEN LOWER("ResourcePool") = 'silver' THEN 1 ELSE 0 END), 0) AS silver_total, "RamGB",
COALESCE(SUM(CASE WHEN LOWER("ResourcePool") = 'gold' THEN 1 ELSE 0 END), 0) AS gold_total LOWER(COALESCE("ResourcePool", '')) AS pool,
NULLIF("CreationTime", 0) AS creation_time,
NULLIF("DeletionTime", 0) AS deletion_time,
%s AS presence
FROM %s FROM %s
WHERE %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 { var row struct {
VmCount int64 `db:"vm_count"` VmCount int64 `db:"vm_count"`
VcpuTotal int64 `db:"vcpu_total"` VcpuTotal int64 `db:"vcpu_total"`
@@ -1060,17 +1472,97 @@ WHERE %s
BronzeTotal float64 `db:"bronze_total"` BronzeTotal float64 `db:"bronze_total"`
SilverTotal float64 `db:"silver_total"` SilverTotal float64 `db:"silver_total"`
GoldTotal float64 `db:"gold_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 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{ points = append(points, totalsPoint{
Label: record.SnapshotTime.Local().Format("2006-01-02 15:04"), Label: label,
VmCount: row.VmCount, VmCount: float64(row.VmCount),
VcpuTotal: float64(row.VcpuTotal), VcpuTotal: float64(row.VcpuTotal),
RamTotal: float64(row.RamTotal), RamTotal: float64(row.RamTotal),
// For hourly snapshots, prorated VM count equals VM count (no finer granularity). PresenceRatio: row.PresenceRatio,
PresenceRatio: float64(row.VmCount),
TinTotal: row.TinTotal, TinTotal: row.TinTotal,
BronzeTotal: row.BronzeTotal, BronzeTotal: row.BronzeTotal,
SilverTotal: row.SilverTotal, SilverTotal: row.SilverTotal,
@@ -1080,28 +1572,82 @@ WHERE %s
return points, nil 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) { func buildDailyTotals(ctx context.Context, dbConn *sqlx.DB, records []SnapshotRecord, prorateByAvg bool) ([]totalsPoint, error) {
points := make([]totalsPoint, 0, len(records)) 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 { for _, record := range records {
if err := db.ValidateTableName(record.TableName); err != nil { if err := db.ValidateTableName(record.TableName); err != nil {
return nil, err 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 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(` query := fmt.Sprintf(`
SELECT SELECT
COUNT(DISTINCT "VmId") AS vm_count, 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 "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(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(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, %s AS tin_total,
COALESCE(SUM(CASE WHEN "Bronze" IS NOT NULL THEN "Bronze" ELSE 0 END) / 100.0, 0) AS bronze_total, %s AS bronze_total,
COALESCE(SUM(CASE WHEN "Silver" IS NOT NULL THEN "Silver" ELSE 0 END) / 100.0, 0) AS silver_total, %s AS silver_total,
COALESCE(SUM(CASE WHEN "Gold" IS NOT NULL THEN "Gold" ELSE 0 END) / 100.0, 0) AS gold_total %s AS gold_total
FROM %s FROM %s
WHERE %s WHERE %s
`, record.TableName, templateExclusionFilter()) `, tinExpr, bronzeExpr, silverExpr, goldExpr, record.TableName, templateExclusionFilter())
var row struct { var row struct {
VmCount int64 `db:"vm_count"` VmCount int64 `db:"vm_count"`
VcpuTotal float64 `db:"vcpu_total"` VcpuTotal float64 `db:"vcpu_total"`
@@ -1115,12 +1661,15 @@ WHERE %s
if err := dbConn.GetContext(ctx, &row, query); err != nil { if err := dbConn.GetContext(ctx, &row, query); err != nil {
return nil, err 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{ points = append(points, totalsPoint{
Label: record.SnapshotTime.Local().Format("2006-01-02"), Label: formatDayIntervalLabel(dayStart, dayEnd),
VmCount: row.VmCount, VmCount: float64(row.VmCount),
VcpuTotal: row.VcpuTotal, VcpuTotal: row.VcpuTotal,
RamTotal: row.RamTotal, RamTotal: row.RamTotal,
PresenceRatio: computeProratedVmCount(row.PresenceRatio, row.VmCount, prorateByAvg), PresenceRatio: computeProratedVmCount(row.PresenceRatio, float64(row.VmCount), prorateByAvg),
TinTotal: row.TinTotal, TinTotal: row.TinTotal,
BronzeTotal: row.BronzeTotal, BronzeTotal: row.BronzeTotal,
SilverTotal: row.SilverTotal, SilverTotal: row.SilverTotal,
@@ -1130,11 +1679,11 @@ WHERE %s
return points, nil return points, nil
} }
func computeProratedVmCount(presenceRatio float64, vmCount int64, prorate bool) float64 { func computeProratedVmCount(presenceRatio float64, vmCount float64, prorate bool) float64 {
if !prorate { 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) { 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) 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 { if endCell, err := excelize.CoordinatesToCellName(len(headers), 1); err == nil {
filterRange := "A1:" + endCell filterRange := "A1:" + endCell
if err := xlsx.AutoFilter(sheetName, filterRange, nil); err != nil { 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"` HourlySnapshotMaxRetries int `yaml:"hourly_snapshot_max_retries"`
DailyJobTimeoutSeconds int `yaml:"daily_job_timeout_seconds"` DailyJobTimeoutSeconds int `yaml:"daily_job_timeout_seconds"`
MonthlyJobTimeoutSeconds int `yaml:"monthly_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"` CleanupJobTimeoutSeconds int `yaml:"cleanup_job_timeout_seconds"`
TenantsToFilter []string `yaml:"tenants_to_filter"` TenantsToFilter []string `yaml:"tenants_to_filter"`
NodeChargeClusters []string `yaml:"node_charge_clusters"` NodeChargeClusters []string `yaml:"node_charge_clusters"`

View File

@@ -2,18 +2,13 @@ package tasks
import ( import (
"context" "context"
"strings"
"time" "time"
"vctp/db" "vctp/db"
"github.com/jmoiron/sqlx" "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 { func NewCronTracker(database db.Database) *CronTracker {
return &CronTracker{ return &CronTracker{
db: database, db: database,
@@ -30,6 +25,39 @@ func (c *CronTracker) ClearAllInProgress(ctx context.Context) error {
return err 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 { func (c *CronTracker) ensureTable(ctx context.Context) error {
conn := c.db.DB() conn := c.db.DB()
driver := conn.DriverName() 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 ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"runtime"
"strings" "strings"
"time" "time"
"vctp/db/queries" "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) c.Logger.Debug("Finished checking vcenter", "url", url)
vc.Logout() _ = vc.Logout(ctx)
} }
c.Logger.Debug("Finished polling vcenters") c.Logger.Debug("Finished polling vcenters")
@@ -130,8 +128,6 @@ func (c *CronTask) UpdateVmInventory(vmObj *mo.VirtualMachine, vc *vcenter.Vcent
existingUpdateFound bool existingUpdateFound bool
) )
// TODO - how to prevent creating a new record every polling cycle?
params := queries.CreateUpdateParams{ params := queries.CreateUpdateParams{
InventoryId: sql.NullInt64{Int64: dbVm.Iid, Valid: dbVm.Iid > 0}, 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" { if updateType != "unknown" {
// Check if we already have an existing update record for this same change
// TODO query updates table to see if there is already an update of this type and the new value
checkParams := queries.GetVmUpdatesParams{ checkParams := queries.GetVmUpdatesParams{
InventoryId: sql.NullInt64{Int64: dbVm.Iid, Valid: dbVm.Iid > 0}, InventoryId: sql.NullInt64{Int64: dbVm.Iid, Valid: dbVm.Iid > 0},
UpdateType: updateType, UpdateType: updateType,
@@ -241,7 +233,6 @@ func (c *CronTask) UpdateVmInventory(vmObj *mo.VirtualMachine, vc *vcenter.Vcent
// add sleep to slow down mass VM additions // add sleep to slow down mass VM additions
utils.SleepWithContext(ctx, (10 * time.Millisecond)) utils.SleepWithContext(ctx, (10 * time.Millisecond))
} }
} }
return nil return nil
@@ -409,6 +400,7 @@ func (c *CronTask) AddVmToInventory(vmObject *mo.VirtualMachine, vc *vcenter.Vce
return nil return nil
} }
/*
// prettyPrint comes from https://gist.github.com/sfate/9d45f6c5405dc4c9bf63bf95fe6d1a7c // prettyPrint comes from https://gist.github.com/sfate/9d45f6c5405dc4c9bf63bf95fe6d1a7c
func prettyPrint(args ...interface{}) { func prettyPrint(args ...interface{}) {
var caller string var caller string
@@ -436,3 +428,4 @@ func prettyPrint(args ...interface{}) {
fmt.Printf("%s%s\n", prefix, string(s)) fmt.Printf("%s%s\n", prefix, string(s))
} }
} }
*/

View File

@@ -2,8 +2,14 @@ package tasks
import ( import (
"context" "context"
"database/sql"
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"runtime"
"sort"
"strings"
"sync"
"time" "time"
"vctp/db" "vctp/db"
"vctp/internal/metrics" "vctp/internal/metrics"
@@ -35,19 +41,49 @@ func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time
return err 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()) monthStart := time.Date(targetMonth.Year(), targetMonth.Month(), 1, 0, 0, 0, 0, targetMonth.Location())
monthEnd := monthStart.AddDate(0, 1, 0) 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) dailySnapshots, err := report.SnapshotRecordsWithFallback(ctx, c.Database, "daily", "inventory_daily_summary_", "20060102", monthStart, monthEnd)
if err != nil { if err != nil {
return err return err
} }
dailySnapshots = filterRecordsInRange(dailySnapshots, monthStart, monthEnd) dailySnapshots = filterRecordsInRange(dailySnapshots, monthStart, monthEnd)
dbConn := c.Database.DB()
db.SetPostgresWorkMem(ctx, dbConn, c.Settings.Values.Settings.PostgresWorkMemMB)
dailySnapshots = filterSnapshotsWithRows(ctx, dbConn, dailySnapshots) dailySnapshots = filterSnapshotsWithRows(ctx, dbConn, dailySnapshots)
if len(dailySnapshots) == 0 { snapshots = dailySnapshots
return fmt.Errorf("no hourly snapshot tables found for %s", targetMonth.Format("2006-01")) 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) monthlyTable, err := monthlySummaryTableName(targetMonth)
@@ -69,11 +105,36 @@ func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time
} }
} }
dailyTables := make([]string, 0, len(dailySnapshots)) // Optional Go-based aggregation path.
for _, snapshot := range dailySnapshots { if useGoAgg {
dailyTables = append(dailyTables, snapshot.TableName) 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 { if err != nil {
return err 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 { if err != nil {
return err 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")) c.Logger.Error("failed to aggregate monthly inventory", "error", err, "month", targetMonth.Format("2006-01"))
return err return err
} }
// Backfill missing creation times to the start of the month for rows lacking creation info. if applied, err := db.ApplyLifecycleDeletionToSummary(ctx, dbConn, monthlyTable, monthStart.Unix(), monthEnd.Unix()); err != nil {
if _, err := dbConn.ExecContext(ctx, c.Logger.Warn("failed to apply lifecycle deletions to monthly summary", "error", err, "table", monthlyTable)
`UPDATE `+monthlyTable+` SET "CreationTime" = $1 WHERE "CreationTime" IS NULL OR "CreationTime" = 0`, } else {
monthStart.Unix(), c.Logger.Info("Monthly aggregation deletion times", "source_lifecycle_cache", applied)
); err != nil { }
c.Logger.Warn("failed to normalize creation times for monthly summary", "error", err, "table", monthlyTable) 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) rowCount, err := db.TableRowCount(ctx, dbConn, monthlyTable)
if err != nil { if err != nil {
@@ -131,3 +198,542 @@ func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time
func monthlySummaryTableName(t time.Time) (string, error) { func monthlySummaryTableName(t time.Time) (string, error) {
return db.SafeTableName(fmt.Sprintf("inventory_monthly_summary_%s", t.Format("200601"))) 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" poweredOn = "TRUE"
} }
err = vc.Logout() _ = vc.Logout(ctx)
if err != nil {
c.Logger.Error("unable to logout of vcenter", "error", err)
}
if foundVm { if foundVm {
c.Logger.Debug("Adding to Inventory table", "vm_name", evt.VmName.String, "vcpus", numVcpus, "ram", numRam, "dc", evt.DatacenterId.String) 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" "net/url"
"path" "path"
"strings" "strings"
"time"
"github.com/vmware/govmomi" "github.com/vmware/govmomi"
"github.com/vmware/govmomi/event"
"github.com/vmware/govmomi/find" "github.com/vmware/govmomi/find"
"github.com/vmware/govmomi/object" "github.com/vmware/govmomi/object"
"github.com/vmware/govmomi/view" "github.com/vmware/govmomi/view"
@@ -36,6 +38,15 @@ type VmProperties struct {
ResourcePool string 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 { type HostLookup struct {
Cluster string Cluster string
Datacenter string Datacenter string
@@ -87,6 +98,9 @@ func (v *Vcenter) Login(vUrl string) error {
v.Logger.Error("Unable to connect to vCenter", "error", err) v.Logger.Error("Unable to connect to vCenter", "error", err)
return fmt.Errorf("unable to connect to vCenter : %s", err) return fmt.Errorf("unable to connect to vCenter : %s", err)
} }
if clientUserAgent != "" {
c.Client.UserAgent = clientUserAgent
}
//defer c.Logout(v.ctx) //defer c.Logout(v.ctx)
@@ -97,24 +111,21 @@ func (v *Vcenter) Login(vUrl string) error {
return nil return nil
} }
func (v *Vcenter) Logout() error { func (v *Vcenter) Logout(ctx context.Context) error {
//v.Logger.Debug("vcenter logging out") if ctx == nil {
ctx = v.ctx
if v.ctx == nil { }
if ctx == nil {
v.Logger.Warn("Nil context, unable to logout") v.Logger.Warn("Nil context, unable to logout")
return nil return nil
} }
if v.client.Valid() { if v.client.Valid() {
//v.Logger.Debug("vcenter client is valid. Logging out") return v.client.Logout(ctx)
return v.client.Logout(v.ctx) }
} else {
v.Logger.Debug("vcenter client is not valid") v.Logger.Debug("vcenter client is not valid")
return nil return nil
} }
}
func (v *Vcenter) GetAllVmReferences() ([]*object.VirtualMachine, error) { func (v *Vcenter) GetAllVmReferences() ([]*object.VirtualMachine, error) {
var results []*object.VirtualMachine var results []*object.VirtualMachine
finder := find.NewFinder(v.client.Client, true) finder := find.NewFinder(v.client.Client, true)
@@ -186,6 +197,205 @@ func (v *Vcenter) GetAllVMsWithProps() ([]mo.VirtualMachine, error) {
return vms, nil 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) { func (v *Vcenter) BuildHostLookup() (map[string]HostLookup, error) {
finder := find.NewFinder(v.client.Client, true) finder := find.NewFinder(v.client.Client, true)
datacenters, err := finder.DatacenterList(v.ctx, "*") 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 // Function to find the cluster or compute resource from a host reference
func (v *Vcenter) GetClusterFromHost(hostRef *types.ManagedObjectReference) (string, error) { 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 // Get the host object
host, err := v.GetHostSystemObject(*hostRef) host, err := v.GetHostSystemObject(*hostRef)
if err != nil { if err != nil {

45
main.go
View File

@@ -19,8 +19,9 @@ import (
"vctp/server/router" "vctp/server/router"
"crypto/sha256" "crypto/sha256"
"github.com/go-co-op/gocron/v2"
"log/slog" "log/slog"
"github.com/go-co-op/gocron/v2"
) )
var ( var (
@@ -37,6 +38,7 @@ const fallbackEncryptionKey = "5L1l3B5KvwOCzUHMAlCgsgUTRAYMfSpa"
func main() { func main() {
settingsPath := flag.String("settings", "/etc/dtms/vctp.yml", "Path to settings YAML") 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() flag.Parse()
bootstrapLogger := log.New(log.LevelInfo, log.OutputText) bootstrapLogger := log.New(log.LevelInfo, log.OutputText)
@@ -57,6 +59,8 @@ func main() {
) )
s.Logger = logger s.Logger = logger
logger.Info("vCTP starting", "build_time", buildTime, "sha1_version", sha1ver, "go_version", runtime.Version(), "settings_file", *settingsPath)
// Configure database // Configure database
dbDriver := strings.TrimSpace(s.Values.Settings.DatabaseDriver) dbDriver := strings.TrimSpace(s.Values.Settings.DatabaseDriver)
if dbDriver == "" { if dbDriver == "" {
@@ -155,6 +159,13 @@ func main() {
os.Exit(1) 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 // Prepare the task scheduler
c, err := gocron.NewScheduler() c, err := gocron.NewScheduler()
if err != nil { if err != nil {
@@ -171,20 +182,25 @@ func main() {
FirstHourlySnapshotCheck: true, 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) cronSnapshotFrequency = durationFromSeconds(s.Values.Settings.VcenterInventorySnapshotSeconds, 3600)
logger.Debug("Setting VM inventory snapshot cronjob frequency to", "frequency", cronSnapshotFrequency) logger.Debug("Setting VM inventory snapshot cronjob frequency to", "frequency", cronSnapshotFrequency)
cronAggregateFrequency = durationFromSeconds(s.Values.Settings.VcenterInventoryAggregateSeconds, 86400) cronAggregateFrequency = durationFromSeconds(s.Values.Settings.VcenterInventoryAggregateSeconds, 86400)
logger.Debug("Setting VM inventory daily aggregation cronjob frequency to", "frequency", cronAggregateFrequency) logger.Debug("Setting VM inventory daily aggregation cronjob frequency to", "frequency", cronAggregateFrequency)
startsAt3 := time.Now().Add(cronSnapshotFrequency) startsAt3 := alignStart(time.Now(), cronSnapshotFrequency)
if cronSnapshotFrequency == time.Hour {
startsAt3 = time.Now().Truncate(time.Hour).Add(time.Hour)
}
job3, err := c.NewJob( job3, err := c.NewJob(
gocron.DurationJob(cronSnapshotFrequency), gocron.DurationJob(cronSnapshotFrequency),
gocron.NewTask(func() { gocron.NewTask(func() {
ct.RunVcenterSnapshotHourly(ctx, logger) ct.RunVcenterSnapshotHourly(ctx, logger, false)
}), gocron.WithSingletonMode(gocron.LimitModeReschedule), }), gocron.WithSingletonMode(gocron.LimitModeReschedule),
gocron.WithStartAt(gocron.WithStartDateTime(startsAt3)), 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) 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) logger.Debug("Setting monthly aggregation cron schedule", "cron", monthlyCron)
job5, err := c.NewJob( job5, err := c.NewJob(
gocron.CronJob(monthlyCron, false), gocron.CronJob(monthlyCron, false),
@@ -289,6 +308,18 @@ func main() {
os.Exit(0) 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 { func durationFromSeconds(value int, fallback int) time.Duration {
if value <= 0 { if value <= 0 {
return time.Second * time.Duration(fallback) 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 // @Accept json
// @Produce json // @Produce json
// @Param payload body map[string]string true "Plaintext payload" // @Param payload body map[string]string true "Plaintext payload"
// @Success 200 {object} map[string]string "Ciphertext response" // @Success 200 {object} models.StatusMessageResponse "Ciphertext response"
// @Failure 500 {object} map[string]string "Server error" // @Failure 500 {object} models.ErrorResponse "Server error"
// @Router /api/encrypt [post] // @Router /api/encrypt [post]
func (h *Handler) EncryptData(w http.ResponseWriter, r *http.Request) { func (h *Handler) EncryptData(w http.ResponseWriter, r *http.Request) {
//ctx := context.Background() //ctx := context.Background()

View File

@@ -14,7 +14,7 @@ import (
// @Tags reports // @Tags reports
// @Produce application/vnd.openxmlformats-officedocument.spreadsheetml.sheet // @Produce application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
// @Success 200 {file} file "Inventory XLSX report" // @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] // @Router /api/report/inventory [get]
func (h *Handler) InventoryReportDownload(w http.ResponseWriter, r *http.Request) { 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 // @Tags reports
// @Produce application/vnd.openxmlformats-officedocument.spreadsheetml.sheet // @Produce application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
// @Success 200 {file} file "Updates XLSX report" // @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] // @Router /api/report/updates [get]
func (h *Handler) UpdateReportDownload(w http.ResponseWriter, r *http.Request) { func (h *Handler) UpdateReportDownload(w http.ResponseWriter, r *http.Request) {

View File

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

View File

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

View File

@@ -12,8 +12,8 @@ import (
// @Description Rebuilds the snapshot registry from existing tables and renames hourly tables to epoch-based names. // @Description Rebuilds the snapshot registry from existing tables and renames hourly tables to epoch-based names.
// @Tags snapshots // @Tags snapshots
// @Produce json // @Produce json
// @Success 200 {object} map[string]interface{} "Migration results" // @Success 200 {object} models.SnapshotMigrationResponse "Migration results"
// @Failure 500 {object} map[string]string "Server error" // @Failure 500 {object} models.SnapshotMigrationResponse "Server error"
// @Router /api/snapshots/migrate [post] // @Router /api/snapshots/migrate [post]
func (h *Handler) SnapshotMigrate(w http.ResponseWriter, r *http.Request) { func (h *Handler) SnapshotMigrate(w http.ResponseWriter, r *http.Request) {
ctx := context.Background() 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. // @Description Regenerates XLSX reports for hourly snapshots when the report files are missing or empty.
// @Tags snapshots // @Tags snapshots
// @Produce json // @Produce json
// @Success 200 {object} map[string]interface{} "Regeneration summary" // @Success 200 {object} models.SnapshotRegenerateReportsResponse "Regeneration summary"
// @Failure 500 {object} map[string]string "Server error" // @Failure 500 {object} models.ErrorResponse "Server error"
// @Router /api/snapshots/regenerate-hourly-reports [post] // @Router /api/snapshots/regenerate-hourly-reports [post]
func (h *Handler) SnapshotRegenerateHourlyReports(w http.ResponseWriter, r *http.Request) { func (h *Handler) SnapshotRegenerateHourlyReports(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() 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 // @Produce application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
// @Param table query string true "Snapshot table name" // @Param table query string true "Snapshot table name"
// @Success 200 {file} file "Snapshot XLSX report" // @Success 200 {file} file "Snapshot XLSX report"
// @Failure 400 {object} map[string]string "Invalid request" // @Failure 400 {object} models.ErrorResponse "Invalid request"
// @Failure 500 {object} map[string]string "Server error" // @Failure 500 {object} models.ErrorResponse "Server error"
// @Router /api/report/snapshot [get] // @Router /api/report/snapshot [get]
func (h *Handler) SnapshotReportDownload(w http.ResponseWriter, r *http.Request) { func (h *Handler) SnapshotReportDownload(w http.ResponseWriter, r *http.Request) {
ctx := context.Background() ctx := context.Background()
@@ -118,10 +118,14 @@ func (h *Handler) renderSnapshotList(w http.ResponseWriter, r *http.Request, sna
case "monthly": case "monthly":
group = record.SnapshotTime.Format("2006") group = record.SnapshotTime.Format("2006")
} }
count := record.SnapshotCount
if count < 0 {
count = 0
}
entries = append(entries, views.SnapshotEntry{ entries = append(entries, views.SnapshotEntry{
Label: label, Label: label,
Link: "/reports/" + url.PathEscape(record.TableName) + ".xlsx", Link: "/reports/" + url.PathEscape(record.TableName) + ".xlsx",
Count: record.SnapshotCount, Count: count,
Group: group, Group: group,
}) })
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,7 @@ func (h *Handler) VmTrace(w http.ResponseWriter, r *http.Request) {
} }
creationLabel := "" creationLabel := ""
deletionLabel := "" deletionLabel := ""
creationApprox := false
// Only fetch data when a query is provided; otherwise render empty page with form. // Only fetch data when a query is provided; otherwise render empty page with form.
if vmID != "" || vmUUID != "" || name != "" { if vmID != "" || vmUUID != "" || name != "" {
@@ -79,9 +80,17 @@ func (h *Handler) VmTrace(w http.ResponseWriter, r *http.Request) {
if len(entries) > 0 { if len(entries) > 0 {
if lifecycle.CreationTime > 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 { } else {
creationLabel = time.Unix(entries[0].RawTime, 0).Local().Format("2006-01-02 15:04:05") creationLabel = time.Unix(entries[0].RawTime, 0).Local().Format("2006-01-02 15:04:05")
creationApprox = true
} }
if lifecycle.DeletionTime > 0 { if lifecycle.DeletionTime > 0 {
deletionLabel = time.Unix(lifecycle.DeletionTime, 0).Local().Format("2006-01-02 15:04:05") 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") 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) 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": { "200": {
"description": "Cleanup completed", "description": "Cleanup completed",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.StatusMessageResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"400": { "400": {
"description": "Invalid request", "description": "Invalid request",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
} }
} }
} }
} }
},
"/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": { "/api/encrypt": {
@@ -141,19 +182,13 @@ const docTemplate = `{
"200": { "200": {
"description": "Ciphertext response", "description": "Ciphertext response",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.StatusMessageResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"500": { "500": {
"description": "Server error", "description": "Server error",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -282,28 +317,19 @@ const docTemplate = `{
"200": { "200": {
"description": "Modify event processed", "description": "Modify event processed",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.StatusMessageResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"202": { "202": {
"description": "No relevant changes", "description": "No relevant changes",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.StatusMessageResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"500": { "500": {
"description": "Server error", "description": "Server error",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -338,28 +364,19 @@ const docTemplate = `{
"200": { "200": {
"description": "Move event processed", "description": "Move event processed",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.StatusMessageResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"400": { "400": {
"description": "Invalid request", "description": "Invalid request",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"500": { "500": {
"description": "Server error", "description": "Server error",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -393,19 +410,13 @@ const docTemplate = `{
"200": { "200": {
"description": "Import processed", "description": "Import processed",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.StatusMessageResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"500": { "500": {
"description": "Server error", "description": "Server error",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -441,19 +452,13 @@ const docTemplate = `{
"200": { "200": {
"description": "Cleanup completed", "description": "Cleanup completed",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.StatusMessageResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"400": { "400": {
"description": "Invalid request", "description": "Invalid request",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -505,10 +510,7 @@ const docTemplate = `{
"500": { "500": {
"description": "Report generation failed", "description": "Report generation failed",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -543,19 +545,13 @@ const docTemplate = `{
"400": { "400": {
"description": "Invalid request", "description": "Invalid request",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"500": { "500": {
"description": "Server error", "description": "Server error",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -581,10 +577,7 @@ const docTemplate = `{
"500": { "500": {
"description": "Report generation failed", "description": "Report generation failed",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -614,34 +607,31 @@ const docTemplate = `{
"name": "date", "name": "date",
"in": "query", "in": "query",
"required": true "required": true
},
{
"type": "string",
"description": "Monthly aggregation granularity: hourly or daily",
"name": "granularity",
"in": "query"
} }
], ],
"responses": { "responses": {
"200": { "200": {
"description": "Aggregation complete", "description": "Aggregation complete",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.StatusResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"400": { "400": {
"description": "Invalid request", "description": "Invalid request",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"500": { "500": {
"description": "Server error", "description": "Server error",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -673,28 +663,19 @@ const docTemplate = `{
"200": { "200": {
"description": "Snapshot started", "description": "Snapshot started",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.StatusResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"400": { "400": {
"description": "Invalid request", "description": "Invalid request",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"500": { "500": {
"description": "Server error", "description": "Server error",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -714,17 +695,13 @@ const docTemplate = `{
"200": { "200": {
"description": "Migration results", "description": "Migration results",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.SnapshotMigrationResponse"
"additionalProperties": true
} }
}, },
"500": { "500": {
"description": "Server error", "description": "Server error",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.SnapshotMigrationResponse"
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -744,20 +721,56 @@ const docTemplate = `{
"200": { "200": {
"description": "Regeneration summary", "description": "Regeneration summary",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.SnapshotRegenerateReportsResponse"
"additionalProperties": true
} }
}, },
"500": { "500": {
"description": "Server error", "description": "Server error",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
} }
} }
} }
} }
},
"/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": { "/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": { "models.ImportReceived": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1208,6 +1316,119 @@ const docTemplate = `{
"type": "string" "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": { "200": {
"description": "Cleanup completed", "description": "Cleanup completed",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.StatusMessageResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"400": { "400": {
"description": "Invalid request", "description": "Invalid request",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
} }
} }
} }
} }
},
"/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": { "/api/encrypt": {
@@ -130,19 +171,13 @@
"200": { "200": {
"description": "Ciphertext response", "description": "Ciphertext response",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.StatusMessageResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"500": { "500": {
"description": "Server error", "description": "Server error",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -271,28 +306,19 @@
"200": { "200": {
"description": "Modify event processed", "description": "Modify event processed",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.StatusMessageResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"202": { "202": {
"description": "No relevant changes", "description": "No relevant changes",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.StatusMessageResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"500": { "500": {
"description": "Server error", "description": "Server error",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -327,28 +353,19 @@
"200": { "200": {
"description": "Move event processed", "description": "Move event processed",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.StatusMessageResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"400": { "400": {
"description": "Invalid request", "description": "Invalid request",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"500": { "500": {
"description": "Server error", "description": "Server error",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -382,19 +399,13 @@
"200": { "200": {
"description": "Import processed", "description": "Import processed",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.StatusMessageResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"500": { "500": {
"description": "Server error", "description": "Server error",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -430,19 +441,13 @@
"200": { "200": {
"description": "Cleanup completed", "description": "Cleanup completed",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.StatusMessageResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"400": { "400": {
"description": "Invalid request", "description": "Invalid request",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -494,10 +499,7 @@
"500": { "500": {
"description": "Report generation failed", "description": "Report generation failed",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -532,19 +534,13 @@
"400": { "400": {
"description": "Invalid request", "description": "Invalid request",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"500": { "500": {
"description": "Server error", "description": "Server error",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -570,10 +566,7 @@
"500": { "500": {
"description": "Report generation failed", "description": "Report generation failed",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -603,34 +596,31 @@
"name": "date", "name": "date",
"in": "query", "in": "query",
"required": true "required": true
},
{
"type": "string",
"description": "Monthly aggregation granularity: hourly or daily",
"name": "granularity",
"in": "query"
} }
], ],
"responses": { "responses": {
"200": { "200": {
"description": "Aggregation complete", "description": "Aggregation complete",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.StatusResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"400": { "400": {
"description": "Invalid request", "description": "Invalid request",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"500": { "500": {
"description": "Server error", "description": "Server error",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -662,28 +652,19 @@
"200": { "200": {
"description": "Snapshot started", "description": "Snapshot started",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.StatusResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"400": { "400": {
"description": "Invalid request", "description": "Invalid request",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
}, },
"500": { "500": {
"description": "Server error", "description": "Server error",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -703,17 +684,13 @@
"200": { "200": {
"description": "Migration results", "description": "Migration results",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.SnapshotMigrationResponse"
"additionalProperties": true
} }
}, },
"500": { "500": {
"description": "Server error", "description": "Server error",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.SnapshotMigrationResponse"
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -733,20 +710,56 @@
"200": { "200": {
"description": "Regeneration summary", "description": "Regeneration summary",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.SnapshotRegenerateReportsResponse"
"additionalProperties": true
} }
}, },
"500": { "500": {
"description": "Server error", "description": "Server error",
"schema": { "schema": {
"type": "object", "$ref": "#/definitions/models.ErrorResponse"
"additionalProperties": {
"type": "string"
} }
} }
} }
} }
},
"/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": { "/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": { "models.ImportReceived": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1197,6 +1305,119 @@
"type": "string" "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: modified:
type: string type: string
type: object 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: models.ImportReceived:
properties: properties:
Cluster: Cluster:
@@ -153,6 +215,79 @@ definitions:
VmId: VmId:
type: string type: string
type: object 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: info:
contact: {} contact: {}
paths: paths:
@@ -209,18 +344,46 @@ paths:
"200": "200":
description: Cleanup completed description: Cleanup completed
schema: schema:
additionalProperties: $ref: '#/definitions/models.StatusMessageResponse'
type: string
type: object
"400": "400":
description: Invalid request description: Invalid request
schema: schema:
additionalProperties: $ref: '#/definitions/models.ErrorResponse'
type: string
type: object
summary: Cleanup vCenter inventory (deprecated) summary: Cleanup vCenter inventory (deprecated)
tags: tags:
- maintenance - 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: /api/encrypt:
post: post:
consumes: consumes:
@@ -241,15 +404,11 @@ paths:
"200": "200":
description: Ciphertext response description: Ciphertext response
schema: schema:
additionalProperties: $ref: '#/definitions/models.StatusMessageResponse'
type: string
type: object
"500": "500":
description: Server error description: Server error
schema: schema:
additionalProperties: $ref: '#/definitions/models.ErrorResponse'
type: string
type: object
summary: Encrypt data summary: Encrypt data
tags: tags:
- crypto - crypto
@@ -337,21 +496,15 @@ paths:
"200": "200":
description: Modify event processed description: Modify event processed
schema: schema:
additionalProperties: $ref: '#/definitions/models.StatusMessageResponse'
type: string
type: object
"202": "202":
description: No relevant changes description: No relevant changes
schema: schema:
additionalProperties: $ref: '#/definitions/models.StatusMessageResponse'
type: string
type: object
"500": "500":
description: Server error description: Server error
schema: schema:
additionalProperties: $ref: '#/definitions/models.ErrorResponse'
type: string
type: object
summary: Record VM modify event (deprecated) summary: Record VM modify event (deprecated)
tags: tags:
- events - events
@@ -375,21 +528,15 @@ paths:
"200": "200":
description: Move event processed description: Move event processed
schema: schema:
additionalProperties: $ref: '#/definitions/models.StatusMessageResponse'
type: string
type: object
"400": "400":
description: Invalid request description: Invalid request
schema: schema:
additionalProperties: $ref: '#/definitions/models.ErrorResponse'
type: string
type: object
"500": "500":
description: Server error description: Server error
schema: schema:
additionalProperties: $ref: '#/definitions/models.ErrorResponse'
type: string
type: object
summary: Record VM move event (deprecated) summary: Record VM move event (deprecated)
tags: tags:
- events - events
@@ -411,15 +558,11 @@ paths:
"200": "200":
description: Import processed description: Import processed
schema: schema:
additionalProperties: $ref: '#/definitions/models.StatusMessageResponse'
type: string
type: object
"500": "500":
description: Server error description: Server error
schema: schema:
additionalProperties: $ref: '#/definitions/models.ErrorResponse'
type: string
type: object
summary: Import VMs summary: Import VMs
tags: tags:
- inventory - inventory
@@ -443,15 +586,11 @@ paths:
"200": "200":
description: Cleanup completed description: Cleanup completed
schema: schema:
additionalProperties: $ref: '#/definitions/models.StatusMessageResponse'
type: string
type: object
"400": "400":
description: Invalid request description: Invalid request
schema: schema:
additionalProperties: $ref: '#/definitions/models.ErrorResponse'
type: string
type: object
summary: Cleanup VM inventory entry summary: Cleanup VM inventory entry
tags: tags:
- inventory - inventory
@@ -485,9 +624,7 @@ paths:
"500": "500":
description: Report generation failed description: Report generation failed
schema: schema:
additionalProperties: $ref: '#/definitions/models.ErrorResponse'
type: string
type: object
summary: Download inventory report summary: Download inventory report
tags: tags:
- reports - reports
@@ -510,15 +647,11 @@ paths:
"400": "400":
description: Invalid request description: Invalid request
schema: schema:
additionalProperties: $ref: '#/definitions/models.ErrorResponse'
type: string
type: object
"500": "500":
description: Server error description: Server error
schema: schema:
additionalProperties: $ref: '#/definitions/models.ErrorResponse'
type: string
type: object
summary: Download snapshot report summary: Download snapshot report
tags: tags:
- snapshots - snapshots
@@ -535,9 +668,7 @@ paths:
"500": "500":
description: Report generation failed description: Report generation failed
schema: schema:
additionalProperties: $ref: '#/definitions/models.ErrorResponse'
type: string
type: object
summary: Download updates report summary: Download updates report
tags: tags:
- reports - reports
@@ -556,27 +687,25 @@ paths:
name: date name: date
required: true required: true
type: string type: string
- description: 'Monthly aggregation granularity: hourly or daily'
in: query
name: granularity
type: string
produces: produces:
- application/json - application/json
responses: responses:
"200": "200":
description: Aggregation complete description: Aggregation complete
schema: schema:
additionalProperties: $ref: '#/definitions/models.StatusResponse'
type: string
type: object
"400": "400":
description: Invalid request description: Invalid request
schema: schema:
additionalProperties: $ref: '#/definitions/models.ErrorResponse'
type: string
type: object
"500": "500":
description: Server error description: Server error
schema: schema:
additionalProperties: $ref: '#/definitions/models.ErrorResponse'
type: string
type: object
summary: Force snapshot aggregation summary: Force snapshot aggregation
tags: tags:
- snapshots - snapshots
@@ -598,21 +727,15 @@ paths:
"200": "200":
description: Snapshot started description: Snapshot started
schema: schema:
additionalProperties: $ref: '#/definitions/models.StatusResponse'
type: string
type: object
"400": "400":
description: Invalid request description: Invalid request
schema: schema:
additionalProperties: $ref: '#/definitions/models.ErrorResponse'
type: string
type: object
"500": "500":
description: Server error description: Server error
schema: schema:
additionalProperties: $ref: '#/definitions/models.ErrorResponse'
type: string
type: object
summary: Trigger hourly snapshot (manual) summary: Trigger hourly snapshot (manual)
tags: tags:
- snapshots - snapshots
@@ -626,14 +749,11 @@ paths:
"200": "200":
description: Migration results description: Migration results
schema: schema:
additionalProperties: true $ref: '#/definitions/models.SnapshotMigrationResponse'
type: object
"500": "500":
description: Server error description: Server error
schema: schema:
additionalProperties: $ref: '#/definitions/models.SnapshotMigrationResponse'
type: string
type: object
summary: Migrate snapshot registry summary: Migrate snapshot registry
tags: tags:
- snapshots - snapshots
@@ -647,17 +767,42 @@ paths:
"200": "200":
description: Regeneration summary description: Regeneration summary
schema: schema:
additionalProperties: true $ref: '#/definitions/models.SnapshotRegenerateReportsResponse'
type: object
"500": "500":
description: Server error description: Server error
schema: schema:
additionalProperties: $ref: '#/definitions/models.ErrorResponse'
type: string
type: object
summary: Regenerate hourly snapshot reports summary: Regenerate hourly snapshot reports
tags: tags:
- snapshots - 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: /metrics:
get: get:
description: Exposes Prometheus metrics for vctp. 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/aggregate", h.SnapshotAggregateForce)
mux.HandleFunc("/api/snapshots/hourly/force", h.SnapshotForceHourly) mux.HandleFunc("/api/snapshots/hourly/force", h.SnapshotForceHourly)
mux.HandleFunc("/api/snapshots/migrate", h.SnapshotMigrate) 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/snapshots/regenerate-hourly-reports", h.SnapshotRegenerateHourlyReports)
mux.HandleFunc("/api/diagnostics/daily-creation", h.DailyCreationDiagnostics)
mux.HandleFunc("/vm/trace", h.VmTrace) mux.HandleFunc("/vm/trace", h.VmTrace)
mux.HandleFunc("/vcenters", h.VcenterList) mux.HandleFunc("/vcenters", h.VcenterList)
mux.HandleFunc("/vcenters/totals", h.VcenterTotals) mux.HandleFunc("/vcenters/totals", h.VcenterTotals)

View File

@@ -1 +1,3 @@
CPE_OPTS='-settings /etc/dtms/vctp.yml' 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 hourly_snapshot_timeout_seconds: 600
daily_job_timeout_seconds: 900 daily_job_timeout_seconds: 900
monthly_job_timeout_seconds: 1200 monthly_job_timeout_seconds: 1200
monthly_aggregation_granularity: "hourly"
monthly_aggregation_cron: "10 3 1 * *"
cleanup_job_timeout_seconds: 600 cleanup_job_timeout_seconds: 600
tenants_to_filter: tenants_to_filter:
node_charge_clusters: node_charge_clusters:

View File

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