Compare commits
52 Commits
6af49471b2
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 32ced35130 | |||
| ff783fb45a | |||
| 49484900ac | |||
| aa6abb8cb2 | |||
| 1f2783fc86 | |||
| b9eae50f69 | |||
| c566456ebd | |||
| ee01d8deac | |||
| 93b5769145 | |||
|
|
38480e52c0 | ||
|
|
6981bd9994 | ||
|
|
fe96172253 | ||
|
|
35b4a50cf6 | ||
| 73ec80bb6f | |||
| 0d509179aa | |||
| e6c7596239 | |||
| b39865325a | |||
| b4a3c0fb3a | |||
| 2caf2763f6 | |||
| 25564efa54 | |||
| 871d7c2024 | |||
| 3671860b7d | |||
| 3e2d95d3b9 | |||
| 8a3481b966 | |||
| 13adc159a2 | |||
| c8f04efd51 | |||
|
|
68ee2838e4 | ||
|
|
b0592a2539 | ||
|
|
baea0cc85c | ||
|
|
ceadf42048 | ||
|
|
374d4921e1 | ||
|
|
7dc8f598c3 | ||
|
|
148df38219 | ||
| 0a2c529111 | |||
| 3cdf368bc4 | |||
| 32d4a352dc | |||
| b77f8671da | |||
| 715b293894 | |||
| 2483091861 | |||
| 00805513c9 | |||
| fd9cc185ce | |||
| c7c7fd3dc9 | |||
| d683d23bfc | |||
| c8bb30c788 | |||
| 7ea02be91a | |||
| 0517ef88c3 | |||
| a9e522cc84 | |||
| e186644db7 | |||
| 22fa250a43 | |||
| 1874b2c621 | |||
| a12fe5cad0 | |||
| 1cd1046433 |
@@ -4,7 +4,7 @@ name: default
|
||||
|
||||
steps:
|
||||
- name: restore-cache-with-filesystem
|
||||
image: meltwater/drone-cache
|
||||
image: cache.coadcorp.com/meltwater/drone-cache
|
||||
pull: true
|
||||
settings:
|
||||
backend: "filesystem"
|
||||
@@ -23,7 +23,7 @@ steps:
|
||||
path: /go
|
||||
|
||||
- name: build
|
||||
image: golang
|
||||
image: cache.coadcorp.com/library/golang
|
||||
environment:
|
||||
CGO_ENABLED: 0
|
||||
GOMODCACHE: '/drone/src/pkg.mod'
|
||||
@@ -60,7 +60,7 @@ steps:
|
||||
- ls -lah ./build/
|
||||
|
||||
- name: dell-sftp-deploy
|
||||
image: hypervtechnics/drone-sftp
|
||||
image: cache.coadcorp.com/hypervtechnics/drone-sftp
|
||||
settings:
|
||||
host: deft.dell.com
|
||||
username:
|
||||
@@ -76,7 +76,7 @@ steps:
|
||||
verbose: true
|
||||
|
||||
- name: rebuild-cache-with-filesystem
|
||||
image: meltwater/drone-cache
|
||||
image: cache.coadcorp.com/meltwater/drone-cache
|
||||
pull: true
|
||||
#when:
|
||||
# event:
|
||||
|
||||
33
README.md
33
README.md
@@ -3,13 +3,29 @@ vCTP is a vSphere Chargeback Tracking Platform, designed for a specific customer
|
||||
|
||||
## Snapshots and Reports
|
||||
- Hourly snapshots capture inventory per vCenter (concurrency via `hourly_snapshot_concurrency`).
|
||||
- Daily summaries aggregate the hourly snapshots for the day; monthly summaries aggregate daily summaries for the month.
|
||||
- Daily summaries aggregate the hourly snapshots for the day; monthly summaries aggregate daily summaries for the month (or hourly snapshots if configured).
|
||||
- Snapshots are registered in `snapshot_registry` so regeneration via `/api/snapshots/aggregate` can locate the correct tables (fallback scanning is also supported).
|
||||
- Reports (XLSX with totals/charts) are generated automatically after hourly, daily, and monthly jobs and written to a reports directory.
|
||||
- Hourly totals in reports are interval-based: each row represents `[HH:00, HH+1:00)` and uses the first snapshot at or after the hour end (including cross-day snapshots) to prorate VM presence by creation/deletion overlap.
|
||||
- Monthly aggregation reports include a Daily Totals sheet with full-day interval labels (`YYYY-MM-DD to YYYY-MM-DD`) and prorated totals derived from daily summaries.
|
||||
- Prometheus metrics are exposed at `/metrics`:
|
||||
- Snapshots/aggregations: `vctp_hourly_snapshots_total`, `vctp_hourly_snapshots_failed_total`, `vctp_hourly_snapshot_last_unix`, `vctp_hourly_snapshot_last_rows`, `vctp_daily_aggregations_total`, `vctp_daily_aggregations_failed_total`, `vctp_daily_aggregation_duration_seconds`, `vctp_monthly_aggregations_total`, `vctp_monthly_aggregations_failed_total`, `vctp_monthly_aggregation_duration_seconds`, `vctp_reports_available`
|
||||
- vCenter health/perf: `vctp_vcenter_connect_failures_total{vcenter}`, `vctp_vcenter_snapshot_duration_seconds{vcenter}`, `vctp_vcenter_inventory_size{vcenter}`
|
||||
|
||||
## Prorating and Aggregation Logic
|
||||
Daily aggregation runs per VM using sample counts for the day:
|
||||
- `SamplesPresent`: count of snapshot samples in which the VM appears.
|
||||
- `TotalSamples`: count of unique snapshot timestamps for the vCenter in the day.
|
||||
- `AvgIsPresent`: `SamplesPresent / TotalSamples` (0 when `TotalSamples` is 0).
|
||||
- `AvgVcpuCount`, `AvgRamGB`, `AvgProvisionedDisk` (daily): `sum(values_per_sample) / TotalSamples` to time‑weight config changes and prorate partial‑day VMs.
|
||||
- `PoolTinPct`, `PoolBronzePct`, `PoolSilverPct`, `PoolGoldPct` (daily): `(pool_hits / SamplesPresent) * 100`, so pool percentages reflect only the time the VM existed.
|
||||
- `CreationTime`: only set when vCenter provides it; otherwise it remains `0`.
|
||||
|
||||
Monthly aggregation builds on daily summaries (or the daily rollup cache):
|
||||
- For each VM, daily averages are converted to weighted sums: `daily_avg * daily_total_samples`.
|
||||
- Monthly averages are `sum(weighted_sums) / monthly_total_samples` (per vCenter).
|
||||
- Pool percentages are weighted the same way: `(daily_pool_pct / 100) * daily_total_samples`, summed, then divided by `monthly_total_samples` and multiplied by 100.
|
||||
|
||||
## RPM Layout (summary)
|
||||
The RPM installs the service and defaults under `/usr/bin`, config under `/etc/dtms`, and data under `/var/lib/vctp`:
|
||||
- Binary: `/usr/bin/vctp-linux-amd64`
|
||||
@@ -19,7 +35,7 @@ The RPM installs the service and defaults under `/usr/bin`, config under `/etc/d
|
||||
- Data: SQLite DB and reports default to `/var/lib/vctp` (reports under `/var/lib/vctp/reports`)
|
||||
- Scripts: preinstall/postinstall handle directory creation and permissions.
|
||||
|
||||
## Settings File
|
||||
# Settings File
|
||||
Configuration now lives in the YAML settings file. By default the service reads
|
||||
`/etc/dtms/vctp.yml`, or you can override it with the `-settings` flag.
|
||||
|
||||
@@ -27,7 +43,14 @@ Configuration now lives in the YAML settings file. By default the service reads
|
||||
vctp -settings /path/to/vctp.yml
|
||||
```
|
||||
|
||||
### Database Configuration
|
||||
If you just want to run a single inventory snapshot across all configured vCenters and
|
||||
exit (no scheduler/server), use:
|
||||
|
||||
```shell
|
||||
vctp -settings /path/to/vctp.yml -run-inventory
|
||||
```
|
||||
|
||||
## Database Configuration
|
||||
By default the app uses SQLite and creates/opens `db.sqlite3`. You can opt into PostgreSQL
|
||||
by updating the settings file:
|
||||
|
||||
@@ -48,13 +71,13 @@ settings:
|
||||
PostgreSQL migrations live in `db/migrations_postgres`, while SQLite migrations remain in
|
||||
`db/migrations`.
|
||||
|
||||
### Snapshot Retention
|
||||
## Snapshot Retention
|
||||
Hourly and daily snapshot table retention can be configured in the settings file:
|
||||
|
||||
- `settings.hourly_snapshot_max_age_days` (default: 60)
|
||||
- `settings.daily_snapshot_max_age_months` (default: 12)
|
||||
|
||||
### Settings Reference
|
||||
## Settings Reference
|
||||
All configuration lives under the top-level `settings:` key in `vctp.yml`.
|
||||
|
||||
General:
|
||||
|
||||
1
components/core/.gitignore
vendored
Normal file
1
components/core/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.go
|
||||
1
components/views/.gitignore
vendored
Normal file
1
components/views/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.go
|
||||
@@ -48,6 +48,37 @@ templ Index(info BuildInfo) {
|
||||
<p class="mt-3 text-xl font-semibold">{info.GoVersion}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="web2-card">
|
||||
<h2 class="text-lg font-semibold mb-2">Overview</h2>
|
||||
<p class="mt-2 text-sm text-slate-600">
|
||||
vCTP is a vSphere Chargeback Tracking Platform.
|
||||
</p>
|
||||
</div>
|
||||
<div class="web2-card">
|
||||
<h2 class="text-lg font-semibold mb-2">Snapshots and Reports</h2>
|
||||
<div class="mt-3 text-sm text-slate-600 web2-paragraphs">
|
||||
<p>Hourly snapshots capture inventory per vCenter (concurrency via <code class="web2-code">hourly_snapshot_concurrency</code>).</p>
|
||||
<p>Daily summaries aggregate the hourly snapshots for the day; monthly summaries aggregate daily summaries for the month (or hourly snapshots if configured).</p>
|
||||
<p>Snapshots are registered in <code class="web2-code">snapshot_registry</code> so regeneration via <code class="web2-code">/api/snapshots/aggregate</code> can locate the correct tables (fallback scanning is also supported).</p>
|
||||
<p>Reports (XLSX with totals/charts) are generated automatically after hourly, daily, and monthly jobs and written to a reports directory.</p>
|
||||
<p>Hourly totals are interval-based: each row represents <code class="web2-code">[HH:00, HH+1:00)</code> and uses the first snapshot at or after the hour end (including cross-day snapshots) to prorate VM presence.</p>
|
||||
<p>Monthly aggregation reports include a Daily Totals sheet with full-day interval labels (YYYY-MM-DD to YYYY-MM-DD) and prorated totals.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="web2-card">
|
||||
<h2 class="text-lg font-semibold mb-2">Prorating and Aggregation</h2>
|
||||
<div class="mt-3 space-y-2 text-sm text-slate-600 web2-paragraphs">
|
||||
<p>SamplesPresent is the count of snapshots in which the VM appears; TotalSamples is the count of unique snapshot times for the vCenter.</p>
|
||||
<p>AvgIsPresent = SamplesPresent / TotalSamples (0 when TotalSamples is 0).</p>
|
||||
<p>Daily AvgVcpuCount/AvgRamGB/AvgProvisionedDisk = sum of per-sample values divided by TotalSamples (time-weighted).</p>
|
||||
<p>Daily pool percentages use pool hits divided by SamplesPresent, so they reflect only the time the VM existed.</p>
|
||||
<p>Monthly aggregation weights daily averages by daily total samples, then divides by monthly total samples.</p>
|
||||
<p>CreationTime is only set when vCenter provides it; otherwise it remains 0.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
@core.Footer()
|
||||
|
||||
@@ -86,7 +86,7 @@ func Index(info BuildInfo) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</p></div></section></main></body>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</p></div></section><section class=\"grid gap-6 lg:grid-cols-3\"><div class=\"web2-card\"><h2 class=\"text-lg font-semibold mb-2\">Overview</h2><p class=\"mt-2 text-sm text-slate-600\">vCTP is a vSphere Chargeback Tracking Platform.</p></div><div class=\"web2-card\"><h2 class=\"text-lg font-semibold mb-2\">Snapshots and Reports</h2><div class=\"mt-3 text-sm text-slate-600 web2-paragraphs\"><p>Hourly snapshots capture inventory per vCenter (concurrency via <code class=\"web2-code\">hourly_snapshot_concurrency</code>).</p><p>Daily summaries aggregate the hourly snapshots for the day; monthly summaries aggregate daily summaries for the month (or hourly snapshots if configured).</p><p>Snapshots are registered in <code class=\"web2-code\">snapshot_registry</code> so regeneration via <code class=\"web2-code\">/api/snapshots/aggregate</code> can locate the correct tables (fallback scanning is also supported).</p><p>Reports (XLSX with totals/charts) are generated automatically after hourly, daily, and monthly jobs and written to a reports directory.</p><p>Hourly totals are interval-based: each row represents <code class=\"web2-code\">[HH:00, HH+1:00)</code> and uses the first snapshot at or after the hour end (including cross-day snapshots) to prorate VM presence.</p><p>Monthly aggregation reports include a Daily Totals sheet with full-day interval labels (YYYY-MM-DD to YYYY-MM-DD) and prorated totals.</p></div></div><div class=\"web2-card\"><h2 class=\"text-lg font-semibold mb-2\">Prorating and Aggregation</h2><div class=\"mt-3 space-y-2 text-sm text-slate-600 web2-paragraphs\"><p>SamplesPresent is the count of snapshots in which the VM appears; TotalSamples is the count of unique snapshot times for the vCenter.</p><p>AvgIsPresent = SamplesPresent / TotalSamples (0 when TotalSamples is 0).</p><p>Daily AvgVcpuCount/AvgRamGB/AvgProvisionedDisk = sum of per-sample values divided by TotalSamples (time-weighted).</p><p>Daily pool percentages use pool hits divided by SamplesPresent, so they reflect only the time the VM existed.</p><p>Monthly aggregation weights daily averages by daily total samples, then divides by monthly total samples.</p><p>CreationTime is only set when vCenter provides it; otherwise it remains 0.</p></div></div></section></main></body>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ type VmTraceChart struct {
|
||||
YTicks []ChartTick
|
||||
}
|
||||
|
||||
templ VmTracePage(query string, display_query string, vm_id string, vm_uuid string, vm_name string, creationLabel string, deletionLabel string, entries []VmTraceEntry, chart VmTraceChart) {
|
||||
templ VmTracePage(query string, display_query string, vm_id string, vm_uuid string, vm_name string, creationLabel string, deletionLabel string, creationApprox bool, entries []VmTraceEntry, chart VmTraceChart) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@core.Header()
|
||||
@@ -124,6 +124,9 @@ templ VmTracePage(query string, display_query string, vm_id string, vm_uuid stri
|
||||
<div class="web2-card">
|
||||
<p class="text-xs uppercase tracking-[0.15em] text-slate-500">Creation time</p>
|
||||
<p class="mt-2 text-base font-semibold text-slate-800">{creationLabel}</p>
|
||||
if creationApprox {
|
||||
<p class="text-xs text-slate-500 mt-1">Approximate (earliest snapshot)</p>
|
||||
}
|
||||
</div>
|
||||
<div class="web2-card">
|
||||
<p class="text-xs uppercase tracking-[0.15em] text-slate-500">Deletion time</p>
|
||||
|
||||
@@ -43,7 +43,7 @@ type VmTraceChart struct {
|
||||
YTicks []ChartTick
|
||||
}
|
||||
|
||||
func VmTracePage(query string, display_query string, vm_id string, vm_uuid string, vm_name string, creationLabel string, deletionLabel string, entries []VmTraceEntry, chart VmTraceChart) templ.Component {
|
||||
func VmTracePage(query string, display_query string, vm_id string, vm_uuid string, vm_name string, creationLabel string, deletionLabel string, creationApprox bool, entries []VmTraceEntry, chart VmTraceChart) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
@@ -560,73 +560,57 @@ func VmTracePage(query string, display_query string, vm_id string, vm_uuid strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</p></div><div class=\"web2-card\"><p class=\"text-xs uppercase tracking-[0.15em] text-slate-500\">Deletion time</p><p class=\"mt-2 text-base font-semibold text-slate-800\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if creationApprox {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<p class=\"text-xs text-slate-500 mt-1\">Approximate (earliest snapshot)</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</div><div class=\"web2-card\"><p class=\"text-xs uppercase tracking-[0.15em] text-slate-500\">Deletion time</p><p class=\"mt-2 text-base font-semibold text-slate-800\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var36 string
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(deletionLabel)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 130, Col: 76}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 133, Col: 76}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</p></div></div><div class=\"overflow-hidden border border-slate-200 rounded\"><table class=\"web2-table\"><thead><tr><th>Snapshot</th><th>VM Name</th><th>VmId</th><th>VmUuid</th><th>Vcenter</th><th>Resource Pool</th><th class=\"text-right\">vCPUs</th><th class=\"text-right\">RAM (GB)</th><th class=\"text-right\">Disk</th></tr></thead> <tbody>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</p></div></div><div class=\"overflow-hidden border border-slate-200 rounded\"><table class=\"web2-table\"><thead><tr><th>Snapshot</th><th>VM Name</th><th>VmId</th><th>VmUuid</th><th>Vcenter</th><th>Resource Pool</th><th class=\"text-right\">vCPUs</th><th class=\"text-right\">RAM (GB)</th><th class=\"text-right\">Disk</th></tr></thead> <tbody>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, e := range entries {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<tr><td>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<tr><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(e.Snapshot)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 151, Col: 25}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 154, Col: 25}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(e.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 152, Col: 21}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(e.VmId)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 153, Col: 21}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var40 string
|
||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(e.VmUuid)
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(e.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 154, Col: 23}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 155, Col: 21}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -634,12 +618,12 @@ func VmTracePage(query string, display_query string, vm_id string, vm_uuid strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var41 string
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(e.Vcenter)
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(e.VmId)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 155, Col: 24}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 156, Col: 21}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -647,60 +631,86 @@ func VmTracePage(query string, display_query string, vm_id string, vm_uuid strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var40 string
|
||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(e.VmUuid)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 157, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var41 string
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(e.Vcenter)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 158, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</td><td>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var42 string
|
||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(e.ResourcePool)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 156, Col: 29}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 159, Col: 29}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</td><td class=\"text-right\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(e.VcpuCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 157, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</td><td class=\"text-right\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var44 string
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(e.RamGB)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 158, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</td><td class=\"text-right\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(e.VcpuCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 160, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</td><td class=\"text-right\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var44 string
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(e.RamGB)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 161, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</td><td class=\"text-right\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var45 string
|
||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", e.ProvisionedDisk))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 159, Col: 72}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 162, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</td></tr>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</td></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</tbody></table></div></section></main></body>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</tbody></table></div></section></main></body>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -708,7 +718,7 @@ func VmTracePage(query string, display_query string, vm_id string, vm_uuid strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
595
db/helpers.go
595
db/helpers.go
@@ -33,11 +33,15 @@ func TableRowCount(ctx context.Context, dbConn *sqlx.DB, table string) (int64, e
|
||||
if err := ValidateTableName(table); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
start := time.Now()
|
||||
slog.Debug("db row count start", "table", table)
|
||||
var count int64
|
||||
query := fmt.Sprintf(`SELECT COUNT(*) FROM %s`, table)
|
||||
if err := getLog(ctx, dbConn, &count, query); err != nil {
|
||||
slog.Debug("db row count failed", "table", table, "duration", time.Since(start), "error", err)
|
||||
return 0, err
|
||||
}
|
||||
slog.Debug("db row count complete", "table", table, "rows", count, "duration", time.Since(start))
|
||||
return count, nil
|
||||
}
|
||||
|
||||
@@ -75,7 +79,12 @@ func getLog(ctx context.Context, dbConn *sqlx.DB, dest interface{}, query string
|
||||
slog.Debug("db get returned no rows", "query", strings.TrimSpace(query))
|
||||
return err
|
||||
}
|
||||
slog.Warn("db get failed", "query", strings.TrimSpace(query), "error", err)
|
||||
// Soften logging for timeout/cancel scenarios commonly hit during best-effort probes.
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
slog.Debug("db get timed out", "query", strings.TrimSpace(query), "error", err)
|
||||
} else {
|
||||
slog.Warn("db get failed", "query", strings.TrimSpace(query), "error", err)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -83,7 +92,11 @@ func getLog(ctx context.Context, dbConn *sqlx.DB, dest interface{}, query string
|
||||
func selectLog(ctx context.Context, dbConn *sqlx.DB, dest interface{}, query string, args ...interface{}) error {
|
||||
err := dbConn.SelectContext(ctx, dest, query, args...)
|
||||
if err != nil {
|
||||
slog.Warn("db select failed", "query", strings.TrimSpace(query), "error", err)
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
slog.Debug("db select timed out", "query", strings.TrimSpace(query), "error", err)
|
||||
} else {
|
||||
slog.Warn("db select failed", "query", strings.TrimSpace(query), "error", err)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -135,12 +148,22 @@ func TableHasRows(ctx context.Context, dbConn *sqlx.DB, table string) (bool, err
|
||||
if err := ValidateTableName(table); err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Avoid hanging on locked tables; apply a short timeout.
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
query := fmt.Sprintf(`SELECT 1 FROM %s LIMIT 1`, table)
|
||||
var exists int
|
||||
if err := getLog(ctx, dbConn, &exists, query); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
@@ -385,6 +408,7 @@ func ApplySQLiteTuning(ctx context.Context, dbConn *sqlx.DB) {
|
||||
`PRAGMA synchronous=NORMAL;`,
|
||||
`PRAGMA temp_store=MEMORY;`,
|
||||
`PRAGMA optimize;`,
|
||||
`PRAGMA busy_timeout=5000;`,
|
||||
}
|
||||
for _, pragma := range pragmas {
|
||||
_, err = execLog(ctx, dbConn, pragma)
|
||||
@@ -394,6 +418,399 @@ func ApplySQLiteTuning(ctx context.Context, dbConn *sqlx.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
// CheckpointSQLite forces a WAL checkpoint (truncate) when using SQLite. No-op for other drivers.
|
||||
func CheckpointSQLite(ctx context.Context, dbConn *sqlx.DB) error {
|
||||
if strings.ToLower(dbConn.DriverName()) != "sqlite" {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
start := time.Now()
|
||||
slog.Debug("sqlite checkpoint start")
|
||||
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
_, err := dbConn.ExecContext(cctx, `PRAGMA wal_checkpoint(TRUNCATE);`)
|
||||
if err != nil {
|
||||
slog.Warn("sqlite checkpoint failed", "error", err, "duration", time.Since(start))
|
||||
return err
|
||||
}
|
||||
slog.Debug("sqlite checkpoint complete", "duration", time.Since(start))
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureVmHourlyStats creates the shared per-snapshot cache table used by Go aggregations.
|
||||
func EnsureVmHourlyStats(ctx context.Context, dbConn *sqlx.DB) error {
|
||||
ddl := `
|
||||
CREATE TABLE IF NOT EXISTS vm_hourly_stats (
|
||||
"SnapshotTime" BIGINT NOT NULL,
|
||||
"Vcenter" TEXT NOT NULL,
|
||||
"VmId" TEXT,
|
||||
"VmUuid" TEXT,
|
||||
"Name" TEXT,
|
||||
"CreationTime" BIGINT,
|
||||
"DeletionTime" BIGINT,
|
||||
"ResourcePool" TEXT,
|
||||
"Datacenter" TEXT,
|
||||
"Cluster" TEXT,
|
||||
"Folder" TEXT,
|
||||
"ProvisionedDisk" REAL,
|
||||
"VcpuCount" BIGINT,
|
||||
"RamGB" BIGINT,
|
||||
"IsTemplate" TEXT,
|
||||
"PoweredOn" TEXT,
|
||||
"SrmPlaceholder" TEXT,
|
||||
PRIMARY KEY ("Vcenter","VmId","SnapshotTime")
|
||||
);`
|
||||
if _, err := execLog(ctx, dbConn, ddl); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_hourly_stats_vmuuid_time_idx ON vm_hourly_stats ("VmUuid","SnapshotTime")`)
|
||||
_, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_hourly_stats_snapshottime_idx ON vm_hourly_stats ("SnapshotTime")`)
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureVmLifecycleCache creates an upsert cache for first/last seen VM info.
|
||||
func EnsureVmLifecycleCache(ctx context.Context, dbConn *sqlx.DB) error {
|
||||
ddl := `
|
||||
CREATE TABLE IF NOT EXISTS vm_lifecycle_cache (
|
||||
"Vcenter" TEXT NOT NULL,
|
||||
"VmId" TEXT,
|
||||
"VmUuid" TEXT,
|
||||
"Name" TEXT,
|
||||
"Cluster" TEXT,
|
||||
"FirstSeen" BIGINT,
|
||||
"LastSeen" BIGINT,
|
||||
"DeletedAt" BIGINT,
|
||||
PRIMARY KEY ("Vcenter","VmId","VmUuid")
|
||||
);`
|
||||
if _, err := execLog(ctx, dbConn, ddl); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_lifecycle_cache_vmuuid_idx ON vm_lifecycle_cache ("VmUuid")`)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertVmLifecycleCache updates first/last seen info for a VM.
|
||||
func UpsertVmLifecycleCache(ctx context.Context, dbConn *sqlx.DB, vcenter string, vmID, vmUUID, name, cluster string, seen time.Time, creation sql.NullInt64) error {
|
||||
if err := EnsureVmLifecycleCache(ctx, dbConn); err != nil {
|
||||
return err
|
||||
}
|
||||
driver := strings.ToLower(dbConn.DriverName())
|
||||
bindType := sqlx.BindType(driver)
|
||||
firstSeen := seen.Unix()
|
||||
if creation.Valid && creation.Int64 > 0 && creation.Int64 < firstSeen {
|
||||
firstSeen = creation.Int64
|
||||
}
|
||||
query := `
|
||||
INSERT INTO vm_lifecycle_cache ("Vcenter","VmId","VmUuid","Name","Cluster","FirstSeen","LastSeen")
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT ("Vcenter","VmId","VmUuid") DO UPDATE SET
|
||||
"Name"=EXCLUDED."Name",
|
||||
"Cluster"=EXCLUDED."Cluster",
|
||||
"LastSeen"=EXCLUDED."LastSeen",
|
||||
"FirstSeen"=CASE
|
||||
WHEN vm_lifecycle_cache."FirstSeen" IS NULL OR vm_lifecycle_cache."FirstSeen" = 0 THEN EXCLUDED."FirstSeen"
|
||||
WHEN EXCLUDED."FirstSeen" IS NOT NULL AND EXCLUDED."FirstSeen" > 0 AND EXCLUDED."FirstSeen" < vm_lifecycle_cache."FirstSeen"
|
||||
THEN EXCLUDED."FirstSeen"
|
||||
ELSE vm_lifecycle_cache."FirstSeen"
|
||||
END,
|
||||
"DeletedAt"=NULL
|
||||
`
|
||||
query = sqlx.Rebind(bindType, query)
|
||||
args := []interface{}{vcenter, vmID, vmUUID, name, cluster, firstSeen, seen.Unix()}
|
||||
_, err := dbConn.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
slog.Warn("lifecycle upsert exec failed", "vcenter", vcenter, "vm_id", vmID, "vm_uuid", vmUUID, "driver", driver, "args_len", len(args), "args", fmt.Sprint(args), "query", strings.TrimSpace(query), "error", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkVmDeleted updates lifecycle cache with a deletion timestamp, carrying optional name/cluster.
|
||||
func MarkVmDeletedWithDetails(ctx context.Context, dbConn *sqlx.DB, vcenter, vmID, vmUUID, name, cluster string, deletedAt int64) error {
|
||||
if err := EnsureVmLifecycleCache(ctx, dbConn); err != nil {
|
||||
return err
|
||||
}
|
||||
driver := strings.ToLower(dbConn.DriverName())
|
||||
bindType := sqlx.BindType(driver)
|
||||
|
||||
query := `
|
||||
INSERT INTO vm_lifecycle_cache ("Vcenter","VmId","VmUuid","Name","Cluster","DeletedAt","FirstSeen","LastSeen")
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT ("Vcenter","VmId","VmUuid") DO UPDATE SET
|
||||
"DeletedAt"=CASE
|
||||
WHEN vm_lifecycle_cache."DeletedAt" IS NULL OR vm_lifecycle_cache."DeletedAt"=0 OR EXCLUDED."DeletedAt"<vm_lifecycle_cache."DeletedAt"
|
||||
THEN EXCLUDED."DeletedAt"
|
||||
ELSE vm_lifecycle_cache."DeletedAt"
|
||||
END,
|
||||
"LastSeen"=COALESCE(vm_lifecycle_cache."LastSeen", EXCLUDED."LastSeen"),
|
||||
"FirstSeen"=COALESCE(vm_lifecycle_cache."FirstSeen", EXCLUDED."FirstSeen"),
|
||||
"Name"=COALESCE(NULLIF(vm_lifecycle_cache."Name", ''), EXCLUDED."Name"),
|
||||
"Cluster"=COALESCE(NULLIF(vm_lifecycle_cache."Cluster", ''), EXCLUDED."Cluster")
|
||||
`
|
||||
query = sqlx.Rebind(bindType, query)
|
||||
args := []interface{}{vcenter, vmID, vmUUID, name, cluster, deletedAt, deletedAt, deletedAt}
|
||||
_, err := dbConn.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
slog.Warn("lifecycle delete exec failed", "vcenter", vcenter, "vm_id", vmID, "vm_uuid", vmUUID, "driver", driver, "args_len", len(args), "args", fmt.Sprint(args), "query", strings.TrimSpace(query), "error", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkVmDeletedFromEvent updates lifecycle cache with a deletion timestamp from vCenter events.
|
||||
// Event times should override snapshot-derived timestamps, even if later.
|
||||
func MarkVmDeletedFromEvent(ctx context.Context, dbConn *sqlx.DB, vcenter, vmID, vmUUID, name, cluster string, deletedAt int64) error {
|
||||
if err := EnsureVmLifecycleCache(ctx, dbConn); err != nil {
|
||||
return err
|
||||
}
|
||||
driver := strings.ToLower(dbConn.DriverName())
|
||||
bindType := sqlx.BindType(driver)
|
||||
|
||||
query := `
|
||||
INSERT INTO vm_lifecycle_cache ("Vcenter","VmId","VmUuid","Name","Cluster","DeletedAt","FirstSeen","LastSeen")
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT ("Vcenter","VmId","VmUuid") DO UPDATE SET
|
||||
"DeletedAt"=CASE
|
||||
WHEN EXCLUDED."DeletedAt" IS NOT NULL AND EXCLUDED."DeletedAt" > 0 THEN EXCLUDED."DeletedAt"
|
||||
ELSE vm_lifecycle_cache."DeletedAt"
|
||||
END,
|
||||
"LastSeen"=COALESCE(vm_lifecycle_cache."LastSeen", EXCLUDED."LastSeen"),
|
||||
"FirstSeen"=COALESCE(vm_lifecycle_cache."FirstSeen", EXCLUDED."FirstSeen"),
|
||||
"Name"=COALESCE(NULLIF(vm_lifecycle_cache."Name", ''), EXCLUDED."Name"),
|
||||
"Cluster"=COALESCE(NULLIF(vm_lifecycle_cache."Cluster", ''), EXCLUDED."Cluster")
|
||||
`
|
||||
query = sqlx.Rebind(bindType, query)
|
||||
args := []interface{}{vcenter, vmID, vmUUID, name, cluster, deletedAt, deletedAt, deletedAt}
|
||||
_, err := dbConn.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
slog.Warn("lifecycle delete event exec failed", "vcenter", vcenter, "vm_id", vmID, "vm_uuid", vmUUID, "driver", driver, "args_len", len(args), "args", fmt.Sprint(args), "query", strings.TrimSpace(query), "error", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkVmDeleted updates lifecycle cache with a deletion timestamp (legacy signature).
|
||||
func MarkVmDeleted(ctx context.Context, dbConn *sqlx.DB, vcenter, vmID, vmUUID string, deletedAt int64) error {
|
||||
return MarkVmDeletedWithDetails(ctx, dbConn, vcenter, vmID, vmUUID, "", "", deletedAt)
|
||||
}
|
||||
|
||||
// ApplyLifecycleDeletionToSummary updates DeletionTime values in a summary table from vm_lifecycle_cache.
|
||||
func ApplyLifecycleDeletionToSummary(ctx context.Context, dbConn *sqlx.DB, summaryTable string, start, end int64) (int64, error) {
|
||||
if err := ValidateTableName(summaryTable); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := EnsureVmLifecycleCache(ctx, dbConn); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
UPDATE %[1]s
|
||||
SET "DeletionTime" = (
|
||||
SELECT MIN(l."DeletedAt")
|
||||
FROM vm_lifecycle_cache l
|
||||
WHERE l."Vcenter" = %[1]s."Vcenter"
|
||||
AND l."DeletedAt" IS NOT NULL AND l."DeletedAt" > 0
|
||||
AND l."DeletedAt" >= ? AND l."DeletedAt" < ?
|
||||
AND (
|
||||
(l."VmId" IS NOT NULL AND %[1]s."VmId" IS NOT NULL AND l."VmId" = %[1]s."VmId")
|
||||
OR (l."VmUuid" IS NOT NULL AND %[1]s."VmUuid" IS NOT NULL AND l."VmUuid" = %[1]s."VmUuid")
|
||||
OR (l."Name" IS NOT NULL AND %[1]s."Name" IS NOT NULL AND l."Name" = %[1]s."Name")
|
||||
)
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM vm_lifecycle_cache l
|
||||
WHERE l."Vcenter" = %[1]s."Vcenter"
|
||||
AND l."DeletedAt" IS NOT NULL AND l."DeletedAt" > 0
|
||||
AND l."DeletedAt" >= ? AND l."DeletedAt" < ?
|
||||
AND (
|
||||
(l."VmId" IS NOT NULL AND %[1]s."VmId" IS NOT NULL AND l."VmId" = %[1]s."VmId")
|
||||
OR (l."VmUuid" IS NOT NULL AND %[1]s."VmUuid" IS NOT NULL AND l."VmUuid" = %[1]s."VmUuid")
|
||||
OR (l."Name" IS NOT NULL AND %[1]s."Name" IS NOT NULL AND l."Name" = %[1]s."Name")
|
||||
)
|
||||
);
|
||||
`, summaryTable)
|
||||
bind := dbConn.Rebind(query)
|
||||
res, err := execLog(ctx, dbConn, bind, start, end, start, end)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ApplyLifecycleCreationToSummary updates CreationTime values in a summary table from vm_lifecycle_cache.
|
||||
func ApplyLifecycleCreationToSummary(ctx context.Context, dbConn *sqlx.DB, summaryTable string) (int64, error) {
|
||||
if err := ValidateTableName(summaryTable); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := EnsureVmLifecycleCache(ctx, dbConn); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
UPDATE %[1]s
|
||||
SET "CreationTime" = (
|
||||
SELECT MIN(l."FirstSeen")
|
||||
FROM vm_lifecycle_cache l
|
||||
WHERE l."Vcenter" = %[1]s."Vcenter"
|
||||
AND l."FirstSeen" IS NOT NULL AND l."FirstSeen" > 0
|
||||
AND (
|
||||
(l."VmId" IS NOT NULL AND %[1]s."VmId" IS NOT NULL AND l."VmId" = %[1]s."VmId")
|
||||
OR (l."VmUuid" IS NOT NULL AND %[1]s."VmUuid" IS NOT NULL AND l."VmUuid" = %[1]s."VmUuid")
|
||||
OR (l."Name" IS NOT NULL AND %[1]s."Name" IS NOT NULL AND l."Name" = %[1]s."Name")
|
||||
)
|
||||
)
|
||||
WHERE ("CreationTime" IS NULL OR "CreationTime" = 0)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM vm_lifecycle_cache l
|
||||
WHERE l."Vcenter" = %[1]s."Vcenter"
|
||||
AND l."FirstSeen" IS NOT NULL AND l."FirstSeen" > 0
|
||||
AND (
|
||||
(l."VmId" IS NOT NULL AND %[1]s."VmId" IS NOT NULL AND l."VmId" = %[1]s."VmId")
|
||||
OR (l."VmUuid" IS NOT NULL AND %[1]s."VmUuid" IS NOT NULL AND l."VmUuid" = %[1]s."VmUuid")
|
||||
OR (l."Name" IS NOT NULL AND %[1]s."Name" IS NOT NULL AND l."Name" = %[1]s."Name")
|
||||
)
|
||||
);
|
||||
`, summaryTable)
|
||||
bind := dbConn.Rebind(query)
|
||||
res, err := execLog(ctx, dbConn, bind)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// UpsertVmDailyRollup writes/updates a daily rollup row.
|
||||
func UpsertVmDailyRollup(ctx context.Context, dbConn *sqlx.DB, day int64, v VmDailyRollupRow) error {
|
||||
if err := EnsureVmDailyRollup(ctx, dbConn); err != nil {
|
||||
return err
|
||||
}
|
||||
driver := strings.ToLower(dbConn.DriverName())
|
||||
query := `
|
||||
INSERT INTO vm_daily_rollup (
|
||||
"Date","Vcenter","VmId","VmUuid","Name","CreationTime","DeletionTime","SamplesPresent","TotalSamples",
|
||||
"SumVcpu","SumRam","SumDisk","TinHits","BronzeHits","SilverHits","GoldHits",
|
||||
"LastResourcePool","LastDatacenter","LastCluster","LastFolder",
|
||||
"LastProvisionedDisk","LastVcpuCount","LastRamGB","IsTemplate","PoweredOn","SrmPlaceholder"
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26)
|
||||
ON CONFLICT ("Date","Vcenter","VmId","VmUuid") DO UPDATE SET
|
||||
"CreationTime"=LEAST(COALESCE(vm_daily_rollup."CreationTime", $6), COALESCE($6, vm_daily_rollup."CreationTime")),
|
||||
"DeletionTime"=CASE
|
||||
WHEN vm_daily_rollup."DeletionTime" IS NULL OR vm_daily_rollup."DeletionTime"=0 THEN $7
|
||||
WHEN $7 IS NOT NULL AND $7 > 0 AND $7 < vm_daily_rollup."DeletionTime" THEN $7
|
||||
ELSE vm_daily_rollup."DeletionTime" END,
|
||||
"SamplesPresent"=$8,
|
||||
"TotalSamples"=$9,
|
||||
"SumVcpu"=$10,
|
||||
"SumRam"=$11,
|
||||
"SumDisk"=$12,
|
||||
"TinHits"=$13,
|
||||
"BronzeHits"=$14,
|
||||
"SilverHits"=$15,
|
||||
"GoldHits"=$16,
|
||||
"LastResourcePool"=$17,
|
||||
"LastDatacenter"=$18,
|
||||
"LastCluster"=$19,
|
||||
"LastFolder"=$20,
|
||||
"LastProvisionedDisk"=$21,
|
||||
"LastVcpuCount"=$22,
|
||||
"LastRamGB"=$23,
|
||||
"IsTemplate"=$24,
|
||||
"PoweredOn"=$25,
|
||||
"SrmPlaceholder"=$26
|
||||
`
|
||||
args := []interface{}{
|
||||
day, v.Vcenter, v.VmId, v.VmUuid, v.Name, v.CreationTime, v.DeletionTime, v.SamplesPresent, v.TotalSamples,
|
||||
v.SumVcpu, v.SumRam, v.SumDisk, v.TinHits, v.BronzeHits, v.SilverHits, v.GoldHits,
|
||||
v.LastResourcePool, v.LastDatacenter, v.LastCluster, v.LastFolder, v.LastProvisionedDisk, v.LastVcpuCount, v.LastRamGB, v.IsTemplate, v.PoweredOn, v.SrmPlaceholder,
|
||||
}
|
||||
if driver == "sqlite" {
|
||||
query = `
|
||||
INSERT OR REPLACE INTO vm_daily_rollup (
|
||||
"Date","Vcenter","VmId","VmUuid","Name","CreationTime","DeletionTime","SamplesPresent","TotalSamples",
|
||||
"SumVcpu","SumRam","SumDisk","TinHits","BronzeHits","SilverHits","GoldHits",
|
||||
"LastResourcePool","LastDatacenter","LastCluster","LastFolder",
|
||||
"LastProvisionedDisk","LastVcpuCount","LastRamGB","IsTemplate","PoweredOn","SrmPlaceholder"
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
`
|
||||
}
|
||||
_, err := dbConn.ExecContext(ctx, query, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// VmDailyRollupRow represents the per-day cached aggregation.
|
||||
type VmDailyRollupRow struct {
|
||||
Vcenter string
|
||||
VmId string
|
||||
VmUuid string
|
||||
Name string
|
||||
CreationTime int64
|
||||
DeletionTime int64
|
||||
SamplesPresent int64
|
||||
TotalSamples int64
|
||||
SumVcpu float64
|
||||
SumRam float64
|
||||
SumDisk float64
|
||||
TinHits int64
|
||||
BronzeHits int64
|
||||
SilverHits int64
|
||||
GoldHits int64
|
||||
LastResourcePool string
|
||||
LastDatacenter string
|
||||
LastCluster string
|
||||
LastFolder string
|
||||
LastProvisionedDisk float64
|
||||
LastVcpuCount int64
|
||||
LastRamGB int64
|
||||
IsTemplate string
|
||||
PoweredOn string
|
||||
SrmPlaceholder string
|
||||
}
|
||||
|
||||
// EnsureVmDailyRollup creates the per-day cache used by monthly aggregation.
|
||||
func EnsureVmDailyRollup(ctx context.Context, dbConn *sqlx.DB) error {
|
||||
ddl := `
|
||||
CREATE TABLE IF NOT EXISTS vm_daily_rollup (
|
||||
"Date" BIGINT NOT NULL,
|
||||
"Vcenter" TEXT NOT NULL,
|
||||
"VmId" TEXT,
|
||||
"VmUuid" TEXT,
|
||||
"Name" TEXT,
|
||||
"CreationTime" BIGINT,
|
||||
"DeletionTime" BIGINT,
|
||||
"SamplesPresent" BIGINT,
|
||||
"TotalSamples" BIGINT,
|
||||
"SumVcpu" BIGINT,
|
||||
"SumRam" BIGINT,
|
||||
"SumDisk" REAL,
|
||||
"TinHits" BIGINT,
|
||||
"BronzeHits" BIGINT,
|
||||
"SilverHits" BIGINT,
|
||||
"GoldHits" BIGINT,
|
||||
"LastResourcePool" TEXT,
|
||||
"LastDatacenter" TEXT,
|
||||
"LastCluster" TEXT,
|
||||
"LastFolder" TEXT,
|
||||
"LastProvisionedDisk" REAL,
|
||||
"LastVcpuCount" BIGINT,
|
||||
"LastRamGB" BIGINT,
|
||||
"IsTemplate" TEXT,
|
||||
"PoweredOn" TEXT,
|
||||
"SrmPlaceholder" TEXT,
|
||||
PRIMARY KEY ("Date","Vcenter","VmId","VmUuid")
|
||||
);`
|
||||
if _, err := execLog(ctx, dbConn, ddl); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_daily_rollup_date_idx ON vm_daily_rollup ("Date")`)
|
||||
_, _ = execLog(ctx, dbConn, `CREATE INDEX IF NOT EXISTS vm_daily_rollup_vcenter_date_idx ON vm_daily_rollup ("Vcenter","Date")`)
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureVmIdentityTables creates the identity and rename audit tables.
|
||||
func EnsureVmIdentityTables(ctx context.Context, dbConn *sqlx.DB) error {
|
||||
driver := strings.ToLower(dbConn.DriverName())
|
||||
@@ -734,10 +1151,11 @@ type VmTraceRow struct {
|
||||
|
||||
// VmLifecycle captures observed lifecycle times from hourly snapshots.
|
||||
type VmLifecycle struct {
|
||||
CreationTime int64
|
||||
FirstSeen int64
|
||||
LastSeen int64
|
||||
DeletionTime int64
|
||||
CreationTime int64
|
||||
CreationApprox bool
|
||||
FirstSeen int64
|
||||
LastSeen int64
|
||||
DeletionTime int64
|
||||
}
|
||||
|
||||
// FetchVmTrace returns combined hourly snapshot records for a VM (by id/uuid/name) ordered by snapshot time.
|
||||
@@ -818,6 +1236,7 @@ ORDER BY snapshot_time
|
||||
driver := strings.ToLower(dbConn.DriverName())
|
||||
|
||||
minCreation := int64(0)
|
||||
consecutiveMissing := 0
|
||||
for _, t := range tables {
|
||||
if err := ValidateTableName(t.TableName); err != nil {
|
||||
continue
|
||||
@@ -846,20 +1265,29 @@ WHERE ("VmId" = ? OR "VmUuid" = ? OR lower("Name") = lower(?))
|
||||
lifecycle.FirstSeen = t.SnapshotTime
|
||||
}
|
||||
lifecycle.LastSeen = t.SnapshotTime
|
||||
consecutiveMissing = 0
|
||||
if probe.MinCreation.Valid {
|
||||
if minCreation == 0 || probe.MinCreation.Int64 < minCreation {
|
||||
minCreation = probe.MinCreation.Int64
|
||||
}
|
||||
}
|
||||
} else if lifecycle.LastSeen > 0 && lifecycle.DeletionTime == 0 && t.SnapshotTime > lifecycle.LastSeen {
|
||||
lifecycle.DeletionTime = t.SnapshotTime
|
||||
break
|
||||
consecutiveMissing++
|
||||
if consecutiveMissing >= 2 {
|
||||
lifecycle.DeletionTime = t.SnapshotTime
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// reset if we haven't seen the VM yet
|
||||
consecutiveMissing = 0
|
||||
}
|
||||
}
|
||||
if minCreation > 0 {
|
||||
lifecycle.CreationTime = minCreation
|
||||
lifecycle.CreationApprox = false
|
||||
} else if lifecycle.FirstSeen > 0 {
|
||||
lifecycle.CreationTime = lifecycle.FirstSeen
|
||||
lifecycle.CreationApprox = true
|
||||
}
|
||||
return lifecycle, nil
|
||||
}
|
||||
@@ -934,9 +1362,13 @@ func AnalyzeTableIfPostgres(ctx context.Context, dbConn *sqlx.DB, tableName stri
|
||||
if driver != "pgx" && driver != "postgres" {
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
slog.Debug("db analyze start", "table", tableName)
|
||||
if _, err := execLog(ctx, dbConn, fmt.Sprintf(`ANALYZE %s`, tableName)); err != nil {
|
||||
slog.Warn("failed to ANALYZE table", "table", tableName, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Debug("db analyze complete", "table", tableName, "duration", time.Since(start))
|
||||
}
|
||||
|
||||
// SetPostgresWorkMem sets a per-session work_mem for heavy aggregations; no-op for other drivers.
|
||||
@@ -1007,13 +1439,14 @@ func BuildDailySummaryInsert(tableName string, unionQuery string) (string, error
|
||||
WITH snapshots AS (
|
||||
%s
|
||||
), totals AS (
|
||||
SELECT COUNT(DISTINCT "SnapshotTime") AS total_samples, MAX("SnapshotTime") AS max_snapshot FROM snapshots
|
||||
SELECT "Vcenter", COUNT(DISTINCT "SnapshotTime") AS total_samples, MAX("SnapshotTime") AS max_snapshot
|
||||
FROM snapshots
|
||||
GROUP BY "Vcenter"
|
||||
), agg AS (
|
||||
SELECT
|
||||
s."InventoryId", s."Name", s."Vcenter", s."VmId", s."EventKey", s."CloudId",
|
||||
MIN(NULLIF(s."CreationTime", 0)) AS any_creation,
|
||||
MAX(NULLIF(s."DeletionTime", 0)) AS any_deletion,
|
||||
MAX(COALESCE(inv."DeletionTime", 0)) AS inv_deletion,
|
||||
MIN(s."SnapshotTime") AS first_present,
|
||||
MAX(s."SnapshotTime") AS last_present,
|
||||
COUNT(*) AS samples_present,
|
||||
@@ -1030,7 +1463,6 @@ WITH snapshots AS (
|
||||
SUM(CASE WHEN LOWER(s."ResourcePool") = 'silver' THEN 1 ELSE 0 END) AS silver_hits,
|
||||
SUM(CASE WHEN LOWER(s."ResourcePool") = 'gold' THEN 1 ELSE 0 END) AS gold_hits
|
||||
FROM snapshots s
|
||||
LEFT JOIN inventory inv ON inv."VmId" = s."VmId" AND inv."Vcenter" = s."Vcenter"
|
||||
GROUP BY
|
||||
s."InventoryId", s."Name", s."Vcenter", s."VmId", s."EventKey", s."CloudId",
|
||||
s."Datacenter", s."Cluster", s."Folder",
|
||||
@@ -1039,16 +1471,15 @@ WITH snapshots AS (
|
||||
INSERT INTO %s (
|
||||
"InventoryId", "Name", "Vcenter", "VmId", "EventKey", "CloudId", "CreationTime", "DeletionTime",
|
||||
"ResourcePool", "Datacenter", "Cluster", "Folder", "ProvisionedDisk", "VcpuCount",
|
||||
"RamGB", "IsTemplate", "PoweredOn", "SrmPlaceholder", "VmUuid",
|
||||
"RamGB", "IsTemplate", "PoweredOn", "SrmPlaceholder", "VmUuid", "SnapshotTime",
|
||||
"SamplesPresent", "AvgVcpuCount", "AvgRamGB", "AvgProvisionedDisk", "AvgIsPresent",
|
||||
"PoolTinPct", "PoolBronzePct", "PoolSilverPct", "PoolGoldPct",
|
||||
"Tin", "Bronze", "Silver", "Gold"
|
||||
)
|
||||
SELECT
|
||||
agg."InventoryId", agg."Name", agg."Vcenter", agg."VmId", agg."EventKey", agg."CloudId",
|
||||
COALESCE(agg.any_creation, agg.first_present, 0) AS "CreationTime",
|
||||
COALESCE(agg.any_creation, 0) AS "CreationTime",
|
||||
CASE
|
||||
WHEN NULLIF(agg.inv_deletion, 0) IS NOT NULL THEN NULLIF(agg.inv_deletion, 0)
|
||||
WHEN totals.max_snapshot IS NOT NULL AND agg.last_present < totals.max_snapshot THEN COALESCE(
|
||||
NULLIF(agg.any_deletion, 0),
|
||||
(SELECT MIN(s2."SnapshotTime") FROM snapshots s2 WHERE s2."SnapshotTime" > agg.last_present),
|
||||
@@ -1091,6 +1522,7 @@ SELECT
|
||||
LIMIT 1
|
||||
) AS "RamGB",
|
||||
agg."IsTemplate", agg."PoweredOn", agg."SrmPlaceholder", agg."VmUuid",
|
||||
agg.last_present AS "SnapshotTime",
|
||||
agg.samples_present AS "SamplesPresent",
|
||||
CASE WHEN totals.total_samples > 0
|
||||
THEN 1.0 * agg.sum_vcpu / totals.total_samples
|
||||
@@ -1129,17 +1561,51 @@ SELECT
|
||||
THEN 100.0 * agg.gold_hits / agg.samples_present
|
||||
ELSE NULL END AS "Gold"
|
||||
FROM agg
|
||||
CROSS JOIN totals
|
||||
JOIN totals ON totals."Vcenter" = agg."Vcenter"
|
||||
GROUP BY
|
||||
agg."InventoryId", agg."Name", agg."Vcenter", agg."VmId", agg."EventKey", agg."CloudId",
|
||||
agg."Datacenter", agg."Cluster", agg."Folder",
|
||||
agg."IsTemplate", agg."PoweredOn", agg."SrmPlaceholder", agg."VmUuid",
|
||||
agg.any_creation, agg.any_deletion, agg.first_present, agg.last_present,
|
||||
totals.total_samples;
|
||||
agg.any_creation, agg.any_deletion, agg.first_present, agg.last_present,
|
||||
totals.total_samples, totals.max_snapshot;
|
||||
`, unionQuery, tableName)
|
||||
return insert, nil
|
||||
}
|
||||
|
||||
// UpdateSummaryPresenceByWindow recomputes AvgIsPresent using CreationTime/DeletionTime overlap with the window.
|
||||
func UpdateSummaryPresenceByWindow(ctx context.Context, dbConn *sqlx.DB, summaryTable string, windowStart, windowEnd int64) error {
|
||||
if err := ValidateTableName(summaryTable); err != nil {
|
||||
return err
|
||||
}
|
||||
if windowEnd <= windowStart {
|
||||
return fmt.Errorf("invalid presence window: %d to %d", windowStart, windowEnd)
|
||||
}
|
||||
duration := float64(windowEnd - windowStart)
|
||||
startExpr := `CASE WHEN "CreationTime" IS NOT NULL AND "CreationTime" > 0 AND "CreationTime" > ? THEN "CreationTime" ELSE ? END`
|
||||
endExpr := `CASE WHEN "DeletionTime" IS NOT NULL AND "DeletionTime" > 0 AND "DeletionTime" < ? THEN "DeletionTime" ELSE ? END`
|
||||
query := fmt.Sprintf(`
|
||||
UPDATE %s
|
||||
SET "AvgIsPresent" = CASE
|
||||
WHEN ("CreationTime" IS NOT NULL AND "CreationTime" > 0) OR ("DeletionTime" IS NOT NULL AND "DeletionTime" > 0) THEN
|
||||
CASE
|
||||
WHEN %s > %s THEN (CAST((%s - %s) AS REAL) / ?)
|
||||
ELSE 0
|
||||
END
|
||||
ELSE "AvgIsPresent"
|
||||
END
|
||||
`, summaryTable, endExpr, startExpr, endExpr, startExpr)
|
||||
query = dbConn.Rebind(query)
|
||||
args := []interface{}{
|
||||
windowEnd, windowEnd,
|
||||
windowStart, windowStart,
|
||||
windowEnd, windowEnd,
|
||||
windowStart, windowStart,
|
||||
duration,
|
||||
}
|
||||
_, err := execLog(ctx, dbConn, query, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// RefineCreationDeletionFromUnion walks all snapshot rows in a period and tightens CreationTime/DeletionTime
|
||||
// by using the first and last observed samples and the first sample after disappearance.
|
||||
func RefineCreationDeletionFromUnion(ctx context.Context, dbConn *sqlx.DB, summaryTable, unionQuery string) error {
|
||||
@@ -1174,12 +1640,11 @@ UPDATE %s dst
|
||||
SET
|
||||
"CreationTime" = CASE
|
||||
WHEN t.any_creation IS NOT NULL AND t.any_creation > 0 THEN LEAST(COALESCE(NULLIF(dst."CreationTime", 0), t.any_creation), t.any_creation)
|
||||
WHEN t.first_seen IS NOT NULL THEN LEAST(COALESCE(NULLIF(dst."CreationTime", 0), t.first_seen), t.first_seen)
|
||||
ELSE dst."CreationTime"
|
||||
END,
|
||||
"DeletionTime" = CASE
|
||||
WHEN t_last_after IS NOT NULL
|
||||
AND (dst."DeletionTime" IS NULL OR dst."DeletionTime" = 0 OR t_last_after < dst."DeletionTime")
|
||||
AND (dst."DeletionTime" IS NULL OR dst."DeletionTime" = 0)
|
||||
THEN t_last_after
|
||||
ELSE dst."DeletionTime"
|
||||
END
|
||||
@@ -1236,7 +1701,6 @@ SET
|
||||
(
|
||||
SELECT CASE
|
||||
WHEN t.any_creation IS NOT NULL AND t.any_creation > 0 AND COALESCE(NULLIF(%[2]s."CreationTime", 0), t.any_creation) > t.any_creation THEN t.any_creation
|
||||
WHEN t.any_creation IS NULL AND t.first_seen IS NOT NULL AND COALESCE(NULLIF(%[2]s."CreationTime", 0), t.first_seen) > t.first_seen THEN t.first_seen
|
||||
ELSE NULL
|
||||
END
|
||||
FROM enriched t
|
||||
@@ -1263,7 +1727,7 @@ SET
|
||||
(%[2]s."Name" IS NOT NULL AND t."Name" IS NOT NULL AND %[2]s."Name" = t."Name")
|
||||
)
|
||||
AND t.first_after IS NOT NULL
|
||||
AND ("DeletionTime" IS NULL OR "DeletionTime" = 0 OR t.first_after < "DeletionTime")
|
||||
AND ("DeletionTime" IS NULL OR "DeletionTime" = 0)
|
||||
LIMIT 1
|
||||
),
|
||||
"DeletionTime"
|
||||
@@ -1318,8 +1782,13 @@ SELECT
|
||||
COALESCE(NULLIF("CreationTime", 0), MIN(NULLIF("CreationTime", 0)), 0) AS "CreationTime",
|
||||
NULLIF(MAX(NULLIF("DeletionTime", 0)), 0) AS "DeletionTime",
|
||||
MAX("ResourcePool") AS "ResourcePool",
|
||||
"Datacenter", "Cluster", "Folder", "ProvisionedDisk", "VcpuCount",
|
||||
"RamGB", "IsTemplate", "PoweredOn", "SrmPlaceholder", "VmUuid",
|
||||
"Datacenter", "Cluster", "Folder",
|
||||
MAX("ProvisionedDisk") AS "ProvisionedDisk",
|
||||
MAX("VcpuCount") AS "VcpuCount",
|
||||
MAX("RamGB") AS "RamGB",
|
||||
"IsTemplate",
|
||||
MAX("PoweredOn") AS "PoweredOn",
|
||||
"SrmPlaceholder", "VmUuid",
|
||||
SUM("SamplesPresent") AS "SamplesPresent",
|
||||
CASE WHEN totals.total_samples > 0
|
||||
THEN SUM(CASE WHEN "AvgVcpuCount" IS NOT NULL THEN "AvgVcpuCount" * total_samples_day ELSE 0 END) / totals.total_samples
|
||||
@@ -1361,8 +1830,8 @@ FROM enriched
|
||||
CROSS JOIN totals
|
||||
GROUP BY
|
||||
"InventoryId", "Name", "Vcenter", "VmId", "EventKey", "CloudId",
|
||||
"Datacenter", "Cluster", "Folder", "ProvisionedDisk", "VcpuCount",
|
||||
"RamGB", "IsTemplate", "PoweredOn", "SrmPlaceholder", "VmUuid";
|
||||
"Datacenter", "Cluster", "Folder",
|
||||
"IsTemplate", "SrmPlaceholder", "VmUuid";
|
||||
`, unionQuery, tableName)
|
||||
return insert, nil
|
||||
}
|
||||
@@ -1407,6 +1876,7 @@ func EnsureSummaryTable(ctx context.Context, dbConn *sqlx.DB, tableName string)
|
||||
"PoolBronzePct" REAL,
|
||||
"PoolSilverPct" REAL,
|
||||
"PoolGoldPct" REAL,
|
||||
"SnapshotTime" BIGINT,
|
||||
"Tin" REAL,
|
||||
"Bronze" REAL,
|
||||
"Silver" REAL,
|
||||
@@ -1437,12 +1907,13 @@ func EnsureSummaryTable(ctx context.Context, dbConn *sqlx.DB, tableName string)
|
||||
"SamplesPresent" BIGINT NOT NULL,
|
||||
"AvgVcpuCount" REAL,
|
||||
"AvgRamGB" REAL,
|
||||
"AvgProvisionedDisk" REAL,
|
||||
"AvgIsPresent" REAL,
|
||||
"PoolTinPct" REAL,
|
||||
"PoolBronzePct" REAL,
|
||||
"PoolSilverPct" REAL,
|
||||
"PoolGoldPct" REAL,
|
||||
"AvgProvisionedDisk" REAL,
|
||||
"AvgIsPresent" REAL,
|
||||
"PoolTinPct" REAL,
|
||||
"PoolBronzePct" REAL,
|
||||
"PoolSilverPct" REAL,
|
||||
"PoolGoldPct" REAL,
|
||||
"SnapshotTime" BIGINT,
|
||||
"Tin" REAL,
|
||||
"Bronze" REAL,
|
||||
"Silver" REAL,
|
||||
@@ -1458,6 +1929,10 @@ func EnsureSummaryTable(ctx context.Context, dbConn *sqlx.DB, tableName string)
|
||||
if hasIsPresent, err := ColumnExists(ctx, dbConn, tableName, "IsPresent"); err == nil && hasIsPresent {
|
||||
_, _ = execLog(ctx, dbConn, fmt.Sprintf(`ALTER TABLE %s DROP COLUMN "IsPresent"`, tableName))
|
||||
}
|
||||
// Ensure SnapshotTime exists for lifecycle refinement.
|
||||
if hasSnapshot, err := ColumnExists(ctx, dbConn, tableName, "SnapshotTime"); err == nil && !hasSnapshot {
|
||||
_, _ = execLog(ctx, dbConn, fmt.Sprintf(`ALTER TABLE %s ADD COLUMN "SnapshotTime" BIGINT`, tableName))
|
||||
}
|
||||
|
||||
indexes := []string{
|
||||
fmt.Sprintf(`CREATE INDEX IF NOT EXISTS %s_vm_vcenter_idx ON %s ("VmId","Vcenter")`, tableName, tableName),
|
||||
@@ -1477,6 +1952,64 @@ func EnsureSummaryTable(ctx context.Context, dbConn *sqlx.DB, tableName string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// BackfillSnapshotTimeFromUnion sets SnapshotTime in a summary table using the max snapshot time per VM from a union query.
|
||||
func BackfillSnapshotTimeFromUnion(ctx context.Context, dbConn *sqlx.DB, summaryTable, unionQuery string) error {
|
||||
if unionQuery == "" {
|
||||
return fmt.Errorf("union query is empty")
|
||||
}
|
||||
if _, err := SafeTableName(summaryTable); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
driver := strings.ToLower(dbConn.DriverName())
|
||||
var sql string
|
||||
switch driver {
|
||||
case "pgx", "postgres":
|
||||
sql = fmt.Sprintf(`
|
||||
WITH snapshots AS (
|
||||
%s
|
||||
)
|
||||
UPDATE %s dst
|
||||
SET "SnapshotTime" = sub.max_time
|
||||
FROM (
|
||||
SELECT s."Vcenter", s."VmId", s."VmUuid", s."Name", MAX(s."SnapshotTime") AS max_time
|
||||
FROM snapshots s
|
||||
GROUP BY s."Vcenter", s."VmId", s."VmUuid", s."Name"
|
||||
) sub
|
||||
WHERE (dst."SnapshotTime" IS NULL OR dst."SnapshotTime" = 0)
|
||||
AND dst."Vcenter" = sub."Vcenter"
|
||||
AND (
|
||||
(dst."VmId" IS NOT DISTINCT FROM sub."VmId")
|
||||
OR (dst."VmUuid" IS NOT DISTINCT FROM sub."VmUuid")
|
||||
OR (dst."Name" IS NOT DISTINCT FROM sub."Name")
|
||||
);
|
||||
`, unionQuery, summaryTable)
|
||||
default:
|
||||
sql = fmt.Sprintf(`
|
||||
WITH snapshots AS (
|
||||
%[1]s
|
||||
), grouped AS (
|
||||
SELECT s."Vcenter", s."VmId", s."VmUuid", s."Name", MAX(s."SnapshotTime") AS max_time
|
||||
FROM snapshots s
|
||||
GROUP BY s."Vcenter", s."VmId", s."VmUuid", s."Name"
|
||||
)
|
||||
UPDATE %[2]s
|
||||
SET "SnapshotTime" = (
|
||||
SELECT max_time FROM grouped g
|
||||
WHERE %[2]s."Vcenter" = g."Vcenter"
|
||||
AND (
|
||||
(%[2]s."VmId" IS NOT NULL AND g."VmId" IS NOT NULL AND %[2]s."VmId" = g."VmId")
|
||||
OR (%[2]s."VmUuid" IS NOT NULL AND g."VmUuid" IS NOT NULL AND %[2]s."VmUuid" = g."VmUuid")
|
||||
OR (%[2]s."Name" IS NOT NULL AND g."Name" IS NOT NULL AND %[2]s."Name" = g."Name")
|
||||
)
|
||||
)
|
||||
WHERE "SnapshotTime" IS NULL OR "SnapshotTime" = 0;
|
||||
`, unionQuery, summaryTable)
|
||||
}
|
||||
_, err := execLog(ctx, dbConn, sql)
|
||||
return err
|
||||
}
|
||||
|
||||
// EnsureSnapshotRunTable creates a table to track per-vCenter hourly snapshot attempts.
|
||||
func EnsureSnapshotRunTable(ctx context.Context, dbConn *sqlx.DB) error {
|
||||
ddl := `
|
||||
|
||||
@@ -2,12 +2,10 @@ package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"vctp/db/queries"
|
||||
|
||||
//_ "github.com/tursodatabase/libsql-client-go/libsql"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
@@ -38,8 +36,8 @@ func (d *LocalDB) Logger() *slog.Logger {
|
||||
}
|
||||
|
||||
func (d *LocalDB) Close() error {
|
||||
fmt.Println("Shutting database")
|
||||
d.logger.Debug("test")
|
||||
//fmt.Println("Shutting database")
|
||||
d.logger.Debug("Shutting database")
|
||||
return d.db.Close()
|
||||
}
|
||||
|
||||
|
||||
32
dist/assets/css/web3.css
vendored
32
dist/assets/css/web3.css
vendored
@@ -29,6 +29,26 @@ body {
|
||||
border-radius: 4px;
|
||||
padding: 1.5rem 1.75rem;
|
||||
}
|
||||
.web2-card h2 {
|
||||
position: relative;
|
||||
padding-left: 0.75rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: #0b1220;
|
||||
}
|
||||
.web2-card h2::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 70%;
|
||||
background: var(--web2-blue);
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 0 1px rgba(29, 155, 240, 0.18);
|
||||
}
|
||||
.web2-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -41,6 +61,18 @@ body {
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.web2-code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid var(--web2-border);
|
||||
border-radius: 3px;
|
||||
padding: 0.1rem 0.35rem;
|
||||
font-size: 0.85em;
|
||||
color: #0f172a;
|
||||
}
|
||||
.web2-paragraphs p + p {
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
.web2-link {
|
||||
color: var(--web2-blue);
|
||||
text-decoration: none;
|
||||
|
||||
@@ -6,11 +6,13 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"vctp/db"
|
||||
|
||||
@@ -33,6 +35,8 @@ type SnapshotMigrationStats struct {
|
||||
Errors int
|
||||
}
|
||||
|
||||
var hourlyTotalsQueryDumpOnce sync.Once
|
||||
|
||||
func ListTablesByPrefix(ctx context.Context, database db.Database, prefix string) ([]string, error) {
|
||||
dbConn := database.DB()
|
||||
driver := strings.ToLower(dbConn.DriverName())
|
||||
@@ -169,7 +173,11 @@ func MigrateSnapshotRegistry(ctx context.Context, database db.Database) (Snapsho
|
||||
}
|
||||
if snapshotTime.IsZero() {
|
||||
suffix := strings.TrimPrefix(table, "inventory_hourly_")
|
||||
if parsed, parseErr := time.Parse("2006010215", suffix); parseErr == nil {
|
||||
if parsed, parseErr := time.Parse("200601021504", suffix); parseErr == nil {
|
||||
// Name encoded with date+hour+minute (e.g., 15-minute cadence)
|
||||
snapshotTime = parsed
|
||||
} else if parsed, parseErr := time.Parse("2006010215", suffix); parseErr == nil {
|
||||
// Legacy hour-only encoding
|
||||
snapshotTime = parsed
|
||||
} else if epoch, parseErr := strconv.ParseInt(suffix, 10, 64); parseErr == nil {
|
||||
snapshotTime = time.Unix(epoch, 0)
|
||||
@@ -254,9 +262,17 @@ func RegisterSnapshot(ctx context.Context, database db.Database, snapshotType st
|
||||
}
|
||||
dbConn := database.DB()
|
||||
driver := strings.ToLower(dbConn.DriverName())
|
||||
start := time.Now()
|
||||
slog.Debug("snapshot registry upsert start",
|
||||
"type", snapshotType,
|
||||
"table", tableName,
|
||||
"snapshot_time", snapshotTime.Unix(),
|
||||
"row_count", snapshotCount,
|
||||
)
|
||||
var err error
|
||||
switch driver {
|
||||
case "sqlite":
|
||||
_, err := dbConn.ExecContext(ctx, `
|
||||
_, err = dbConn.ExecContext(ctx, `
|
||||
INSERT INTO snapshot_registry (snapshot_type, table_name, snapshot_time, snapshot_count)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(table_name) DO UPDATE SET
|
||||
@@ -264,9 +280,8 @@ ON CONFLICT(table_name) DO UPDATE SET
|
||||
snapshot_type = excluded.snapshot_type,
|
||||
snapshot_count = excluded.snapshot_count
|
||||
`, snapshotType, tableName, snapshotTime.Unix(), snapshotCount)
|
||||
return err
|
||||
case "pgx", "postgres":
|
||||
_, err := dbConn.ExecContext(ctx, `
|
||||
_, err = dbConn.ExecContext(ctx, `
|
||||
INSERT INTO snapshot_registry (snapshot_type, table_name, snapshot_time, snapshot_count)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (table_name) DO UPDATE SET
|
||||
@@ -274,10 +289,24 @@ ON CONFLICT (table_name) DO UPDATE SET
|
||||
snapshot_type = EXCLUDED.snapshot_type,
|
||||
snapshot_count = EXCLUDED.snapshot_count
|
||||
`, snapshotType, tableName, snapshotTime.Unix(), snapshotCount)
|
||||
return err
|
||||
default:
|
||||
return fmt.Errorf("unsupported driver for snapshot registry: %s", driver)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("snapshot registry upsert failed",
|
||||
"type", snapshotType,
|
||||
"table", tableName,
|
||||
"duration", time.Since(start),
|
||||
"error", err,
|
||||
)
|
||||
return err
|
||||
}
|
||||
slog.Debug("snapshot registry upsert complete",
|
||||
"type", snapshotType,
|
||||
"table", tableName,
|
||||
"duration", time.Since(start),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteSnapshotRecord(ctx context.Context, database db.Database, tableName string) error {
|
||||
@@ -460,6 +489,8 @@ func recordsFromTableNames(ctx context.Context, database db.Database, snapshotTy
|
||||
TableName: table,
|
||||
SnapshotTime: ts,
|
||||
SnapshotType: snapshotType,
|
||||
// Unknown row count when snapshot_registry isn't available.
|
||||
SnapshotCount: -1,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -518,6 +549,8 @@ func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Co
|
||||
if err := db.ValidateTableName(tableName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start := time.Now()
|
||||
logger.Debug("Create table report start", "table", tableName)
|
||||
|
||||
dbConn := Database.DB()
|
||||
if strings.HasPrefix(tableName, "inventory_daily_summary_") || strings.HasPrefix(tableName, "inventory_monthly_summary_") {
|
||||
@@ -527,11 +560,13 @@ func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Co
|
||||
}
|
||||
columns, err := tableColumns(ctx, dbConn, tableName)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to load report columns", "table", tableName, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
if len(columns) == 0 {
|
||||
return nil, fmt.Errorf("no columns found for table %s", tableName)
|
||||
}
|
||||
logger.Debug("Report columns loaded", "table", tableName, "columns", len(columns))
|
||||
|
||||
isHourlySnapshot := strings.HasPrefix(tableName, "inventory_hourly_")
|
||||
isDailySummary := strings.HasPrefix(tableName, "inventory_daily_summary_")
|
||||
@@ -612,9 +647,11 @@ func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Co
|
||||
if orderBy != "" {
|
||||
query = fmt.Sprintf(`%s ORDER BY "%s" %s`, query, orderBy, orderDir)
|
||||
}
|
||||
logger.Debug("Report query prepared", "table", tableName, "order_by", orderBy, "order_dir", orderDir, "template_filter", applyTemplateFilter)
|
||||
|
||||
rows, err := dbConn.QueryxContext(ctx, query)
|
||||
if err != nil {
|
||||
logger.Warn("Report query failed", "table", tableName, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -664,6 +701,7 @@ func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Co
|
||||
for rows.Next() {
|
||||
values, err := scanRowValues(rows, len(columns))
|
||||
if err != nil {
|
||||
logger.Warn("Report row scan failed", "table", tableName, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
for colIndex, spec := range specs {
|
||||
@@ -686,8 +724,11 @@ func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Co
|
||||
rowIndex++
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
logger.Warn("Report row iteration failed", "table", tableName, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
rowCount := rowIndex - 2
|
||||
logger.Debug("Report rows populated", "table", tableName, "rows", rowCount)
|
||||
|
||||
if err := xlsx.SetPanes(sheetName, &excelize.Panes{
|
||||
Freeze: true,
|
||||
@@ -708,18 +749,34 @@ func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Co
|
||||
}
|
||||
|
||||
if isDailySummary || isMonthlySummary {
|
||||
addReportMetadataSheet(logger, xlsx)
|
||||
meta := reportMetadata{
|
||||
TableName: tableName,
|
||||
ReportType: reportTypeFromTable(tableName),
|
||||
GeneratedAt: time.Now(),
|
||||
Duration: time.Since(start),
|
||||
RowCount: rowCount,
|
||||
ColumnCount: len(columns),
|
||||
DBDriver: Database.DB().DriverName(),
|
||||
}
|
||||
if windowStart, windowEnd, ok := reportWindowFromTable(tableName); ok {
|
||||
meta.WindowStart = &windowStart
|
||||
meta.WindowEnd = &windowEnd
|
||||
}
|
||||
addReportMetadataSheet(logger, xlsx, meta)
|
||||
}
|
||||
|
||||
addTotalsChartSheet(logger, Database, ctx, xlsx, tableName)
|
||||
logger.Debug("Report charts complete", "table", tableName)
|
||||
|
||||
if index, err := xlsx.GetSheetIndex(sheetName); err == nil {
|
||||
xlsx.SetActiveSheet(index)
|
||||
}
|
||||
|
||||
if err := xlsx.Write(&buffer); err != nil {
|
||||
logger.Warn("Report write failed", "table", tableName, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
logger.Debug("Create table report complete", "table", tableName, "rows", rowCount, "bytes", buffer.Len(), "duration", time.Since(start))
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
@@ -731,38 +788,61 @@ func SaveTableReport(logger *slog.Logger, Database db.Database, ctx context.Cont
|
||||
if strings.TrimSpace(destDir) == "" {
|
||||
return "", fmt.Errorf("destination directory is empty")
|
||||
}
|
||||
start := time.Now()
|
||||
logger.Debug("Save table report start", "table", tableName, "dest", destDir)
|
||||
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||
logger.Warn("Report directory create failed", "table", tableName, "dest", destDir, "error", err)
|
||||
return "", fmt.Errorf("failed to create reports directory: %w", err)
|
||||
}
|
||||
logger.Debug("Report directory ready", "dest", destDir)
|
||||
|
||||
data, err := CreateTableReport(logger, Database, ctx, tableName)
|
||||
if err != nil {
|
||||
logger.Warn("Report render failed", "table", tableName, "error", err)
|
||||
return "", err
|
||||
}
|
||||
logger.Debug("Report rendered", "table", tableName, "bytes", len(data))
|
||||
filename := filepath.Join(destDir, fmt.Sprintf("%s.xlsx", tableName))
|
||||
if err := os.WriteFile(filename, data, 0o644); err != nil {
|
||||
logger.Warn("Report write failed", "table", tableName, "file", filename, "error", err)
|
||||
return "", err
|
||||
}
|
||||
logger.Debug("Save table report complete", "table", tableName, "file", filename, "duration", time.Since(start))
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
func addTotalsChartSheet(logger *slog.Logger, database db.Database, ctx context.Context, xlsx *excelize.File, tableName string) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
if strings.HasPrefix(tableName, "inventory_daily_summary_") {
|
||||
suffix := strings.TrimPrefix(tableName, "inventory_daily_summary_")
|
||||
dayStart, err := time.ParseInLocation("20060102", suffix, time.Local)
|
||||
if err != nil {
|
||||
logger.Debug("hourly totals skip: invalid daily summary suffix", "table", tableName, "suffix", suffix, "error", err)
|
||||
return
|
||||
}
|
||||
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||
if err := EnsureSnapshotRegistry(ctx, database); err != nil {
|
||||
logger.Debug("hourly totals skip: snapshot registry unavailable", "table", tableName, "error", err)
|
||||
return
|
||||
}
|
||||
records, err := SnapshotRecordsWithFallback(ctx, database, "hourly", "inventory_hourly_", "epoch", dayStart, dayEnd)
|
||||
if err != nil || len(records) == 0 {
|
||||
records, err := SnapshotRecordsWithFallback(ctx, database, "hourly", "inventory_hourly_", "epoch", dayStart, dayEnd.Add(2*time.Hour))
|
||||
if err != nil {
|
||||
logger.Debug("hourly totals skip: failed to load hourly snapshots", "table", tableName, "error", err)
|
||||
return
|
||||
}
|
||||
points, err := buildHourlyTotals(ctx, database.DB(), records)
|
||||
if err != nil || len(points) == 0 {
|
||||
if len(records) == 0 {
|
||||
logger.Debug("hourly totals skip: no hourly snapshots found", "table", tableName, "window_start", dayStart, "window_end", dayEnd)
|
||||
return
|
||||
}
|
||||
points, err := buildHourlyTotals(ctx, logger, database.DB(), records, dayStart, dayEnd)
|
||||
if err != nil {
|
||||
logger.Debug("hourly totals skip: build failed", "table", tableName, "error", err)
|
||||
return
|
||||
}
|
||||
if len(points) == 0 {
|
||||
logger.Debug("hourly totals skip: no hourly totals points", "table", tableName, "window_start", dayStart, "window_end", dayEnd)
|
||||
return
|
||||
}
|
||||
writeTotalsChart(logger, xlsx, "Hourly Totals", points)
|
||||
@@ -773,18 +853,30 @@ func addTotalsChartSheet(logger *slog.Logger, database db.Database, ctx context.
|
||||
suffix := strings.TrimPrefix(tableName, "inventory_monthly_summary_")
|
||||
monthStart, err := time.ParseInLocation("200601", suffix, time.Local)
|
||||
if err != nil {
|
||||
logger.Debug("daily totals skip: invalid monthly summary suffix", "table", tableName, "suffix", suffix, "error", err)
|
||||
return
|
||||
}
|
||||
monthEnd := monthStart.AddDate(0, 1, 0)
|
||||
if err := EnsureSnapshotRegistry(ctx, database); err != nil {
|
||||
logger.Debug("daily totals skip: snapshot registry unavailable", "table", tableName, "error", err)
|
||||
return
|
||||
}
|
||||
records, err := SnapshotRecordsWithFallback(ctx, database, "daily", "inventory_daily_summary_", "20060102", monthStart, monthEnd)
|
||||
if err != nil || len(records) == 0 {
|
||||
if err != nil {
|
||||
logger.Debug("daily totals skip: failed to load daily snapshots", "table", tableName, "error", err)
|
||||
return
|
||||
}
|
||||
if len(records) == 0 {
|
||||
logger.Debug("daily totals skip: no daily snapshots found", "table", tableName, "window_start", monthStart, "window_end", monthEnd)
|
||||
return
|
||||
}
|
||||
points, err := buildDailyTotals(ctx, database.DB(), records, true)
|
||||
if err != nil || len(points) == 0 {
|
||||
if err != nil {
|
||||
logger.Debug("daily totals skip: build failed", "table", tableName, "error", err)
|
||||
return
|
||||
}
|
||||
if len(points) == 0 {
|
||||
logger.Debug("daily totals skip: no daily totals points", "table", tableName, "window_start", monthStart, "window_end", monthEnd)
|
||||
return
|
||||
}
|
||||
writeTotalsChart(logger, xlsx, "Daily Totals", points)
|
||||
@@ -979,14 +1071,102 @@ func summaryReportOrder() []string {
|
||||
}
|
||||
}
|
||||
|
||||
func addReportMetadataSheet(logger *slog.Logger, xlsx *excelize.File) {
|
||||
type reportMetadata struct {
|
||||
TableName string
|
||||
ReportType string
|
||||
GeneratedAt time.Time
|
||||
Duration time.Duration
|
||||
RowCount int
|
||||
ColumnCount int
|
||||
WindowStart *time.Time
|
||||
WindowEnd *time.Time
|
||||
DBDriver string
|
||||
}
|
||||
|
||||
func reportTypeFromTable(tableName string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(tableName, "inventory_daily_summary_"):
|
||||
return "daily"
|
||||
case strings.HasPrefix(tableName, "inventory_monthly_summary_"):
|
||||
return "monthly"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func reportWindowFromTable(tableName string) (time.Time, time.Time, bool) {
|
||||
if strings.HasPrefix(tableName, "inventory_daily_summary_") {
|
||||
suffix := strings.TrimPrefix(tableName, "inventory_daily_summary_")
|
||||
dayStart, err := time.ParseInLocation("20060102", suffix, time.Local)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
return dayStart, dayStart.AddDate(0, 0, 1), true
|
||||
}
|
||||
if strings.HasPrefix(tableName, "inventory_monthly_summary_") {
|
||||
suffix := strings.TrimPrefix(tableName, "inventory_monthly_summary_")
|
||||
monthStart, err := time.ParseInLocation("200601", suffix, time.Local)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
return monthStart, monthStart.AddDate(0, 1, 0), true
|
||||
}
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
|
||||
func addReportMetadataSheet(logger *slog.Logger, xlsx *excelize.File, meta reportMetadata) {
|
||||
sheetName := "Metadata"
|
||||
if _, err := xlsx.NewSheet(sheetName); err != nil {
|
||||
logger.Error("Error creating metadata sheet", "error", err)
|
||||
return
|
||||
}
|
||||
xlsx.SetCellValue(sheetName, "A1", "ReportGeneratedAt")
|
||||
xlsx.SetCellValue(sheetName, "B1", time.Now().Format(time.RFC3339))
|
||||
rows := []struct {
|
||||
key string
|
||||
value interface{}
|
||||
}{
|
||||
{"ReportTable", meta.TableName},
|
||||
{"ReportType", meta.ReportType},
|
||||
{"ReportGeneratedAt", meta.GeneratedAt.Format(time.RFC3339)},
|
||||
{"ReportGeneratedAtUTC", meta.GeneratedAt.UTC().Format(time.RFC3339)},
|
||||
{"ReportDuration", meta.Duration.String()},
|
||||
{"ReportDurationSeconds", math.Round(meta.Duration.Seconds()*1000) / 1000},
|
||||
{"RowCount", meta.RowCount},
|
||||
{"ColumnCount", meta.ColumnCount},
|
||||
}
|
||||
if meta.WindowStart != nil && meta.WindowEnd != nil {
|
||||
rows = append(rows,
|
||||
struct {
|
||||
key string
|
||||
value interface{}
|
||||
}{"DataWindowStart", meta.WindowStart.Format(time.RFC3339)},
|
||||
struct {
|
||||
key string
|
||||
value interface{}
|
||||
}{"DataWindowEnd", meta.WindowEnd.Format(time.RFC3339)},
|
||||
struct {
|
||||
key string
|
||||
value interface{}
|
||||
}{"DataWindowTimezone", time.Local.String()},
|
||||
)
|
||||
}
|
||||
if meta.DBDriver != "" {
|
||||
rows = append(rows, struct {
|
||||
key string
|
||||
value interface{}
|
||||
}{"DatabaseDriver", meta.DBDriver})
|
||||
}
|
||||
if meta.Duration > 0 && meta.RowCount > 0 {
|
||||
rows = append(rows, struct {
|
||||
key string
|
||||
value interface{}
|
||||
}{"RowsPerSecond", math.Round((float64(meta.RowCount)/meta.Duration.Seconds())*1000) / 1000})
|
||||
}
|
||||
for i, row := range rows {
|
||||
cellKey := fmt.Sprintf("A%d", i+1)
|
||||
cellVal := fmt.Sprintf("B%d", i+1)
|
||||
xlsx.SetCellValue(sheetName, cellKey, row.key)
|
||||
xlsx.SetCellValue(sheetName, cellVal, row.value)
|
||||
}
|
||||
if err := SetColAutoWidth(xlsx, sheetName); err != nil {
|
||||
logger.Error("Error setting metadata auto width", "error", err)
|
||||
}
|
||||
@@ -1019,7 +1199,7 @@ func normalizeCellValue(value interface{}) interface{} {
|
||||
|
||||
type totalsPoint struct {
|
||||
Label string
|
||||
VmCount int64
|
||||
VmCount float64
|
||||
VcpuTotal float64
|
||||
RamTotal float64
|
||||
PresenceRatio float64
|
||||
@@ -1029,48 +1209,360 @@ type totalsPoint struct {
|
||||
GoldTotal float64
|
||||
}
|
||||
|
||||
func buildHourlyTotals(ctx context.Context, dbConn *sqlx.DB, records []SnapshotRecord) ([]totalsPoint, error) {
|
||||
points := make([]totalsPoint, 0, len(records))
|
||||
for _, record := range records {
|
||||
if err := db.ValidateTableName(record.TableName); err != nil {
|
||||
return nil, err
|
||||
func buildHourlyTotals(ctx context.Context, logger *slog.Logger, dbConn *sqlx.DB, records []SnapshotRecord, windowStart, windowEnd time.Time) ([]totalsPoint, error) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
if windowEnd.Before(windowStart) {
|
||||
return nil, fmt.Errorf("hourly totals window end is before start")
|
||||
}
|
||||
sort.Slice(records, func(i, j int) bool {
|
||||
return records[i].SnapshotTime.Before(records[j].SnapshotTime)
|
||||
})
|
||||
expectedInterval := estimateSnapshotInterval(records)
|
||||
maxLag := expectedInterval
|
||||
if maxLag <= 0 {
|
||||
maxLag = time.Hour
|
||||
}
|
||||
|
||||
points := make([]totalsPoint, 0, 24)
|
||||
hourStart := windowStart.Truncate(time.Hour)
|
||||
if hourStart.Before(windowStart) {
|
||||
hourStart = hourStart.Add(time.Hour)
|
||||
}
|
||||
recordIndex := 0
|
||||
|
||||
for hourEnd := hourStart.Add(time.Hour); !hourEnd.After(windowEnd); hourEnd = hourEnd.Add(time.Hour) {
|
||||
hourWindowStart := hourEnd.Add(-time.Hour)
|
||||
var selected *SnapshotRecord
|
||||
selectedIndex := recordIndex
|
||||
for selectedIndex < len(records) {
|
||||
record := records[selectedIndex]
|
||||
if record.SnapshotTime.Before(hourEnd) {
|
||||
selectedIndex++
|
||||
continue
|
||||
}
|
||||
if record.SnapshotTime.After(hourEnd.Add(maxLag)) {
|
||||
break
|
||||
}
|
||||
if err := db.ValidateTableName(record.TableName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record.SnapshotCount == 0 {
|
||||
logger.Debug("hourly totals skipping empty snapshot", "table", record.TableName, "snapshot_time", record.SnapshotTime)
|
||||
selectedIndex++
|
||||
continue
|
||||
}
|
||||
if record.SnapshotCount < 0 {
|
||||
rowsExist, err := db.TableHasRows(ctx, dbConn, record.TableName)
|
||||
if err != nil {
|
||||
logger.Debug("hourly totals snapshot probe failed", "table", record.TableName, "snapshot_time", record.SnapshotTime, "error", err)
|
||||
}
|
||||
if err != nil || !rowsExist {
|
||||
selectedIndex++
|
||||
continue
|
||||
}
|
||||
}
|
||||
selected = &record
|
||||
break
|
||||
}
|
||||
if rowsExist, err := db.TableHasRows(ctx, dbConn, record.TableName); err != nil || !rowsExist {
|
||||
|
||||
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
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
COUNT(DISTINCT "VmId") AS vm_count,
|
||||
COALESCE(SUM(CASE WHEN "VcpuCount" IS NOT NULL THEN "VcpuCount" ELSE 0 END), 0) AS vcpu_total,
|
||||
COALESCE(SUM(CASE WHEN "RamGB" IS NOT NULL THEN "RamGB" ELSE 0 END), 0) AS ram_total,
|
||||
1.0 AS presence_ratio,
|
||||
COALESCE(SUM(CASE WHEN LOWER("ResourcePool") = 'tin' THEN 1 ELSE 0 END), 0) AS tin_total,
|
||||
COALESCE(SUM(CASE WHEN LOWER("ResourcePool") = 'bronze' THEN 1 ELSE 0 END), 0) AS bronze_total,
|
||||
COALESCE(SUM(CASE WHEN LOWER("ResourcePool") = 'silver' THEN 1 ELSE 0 END), 0) AS silver_total,
|
||||
COALESCE(SUM(CASE WHEN LOWER("ResourcePool") = 'gold' THEN 1 ELSE 0 END), 0) AS gold_total
|
||||
FROM %s
|
||||
WHERE %s
|
||||
`, record.TableName, templateExclusionFilter())
|
||||
var row struct {
|
||||
VmCount int64 `db:"vm_count"`
|
||||
VcpuTotal int64 `db:"vcpu_total"`
|
||||
RamTotal int64 `db:"ram_total"`
|
||||
PresenceRatio float64 `db:"presence_ratio"`
|
||||
TinTotal float64 `db:"tin_total"`
|
||||
BronzeTotal float64 `db:"bronze_total"`
|
||||
SilverTotal float64 `db:"silver_total"`
|
||||
GoldTotal float64 `db:"gold_total"`
|
||||
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
|
||||
}
|
||||
if err := dbConn.GetContext(ctx, &row, query); err != nil {
|
||||
recordIndex = selectedIndex
|
||||
hourStartUnix := hourWindowStart.Unix()
|
||||
hourEndUnix := hourEnd.Unix()
|
||||
durationSeconds := float64(hourEndUnix - hourStartUnix)
|
||||
prevTableName := selected.TableName
|
||||
if prev != nil {
|
||||
prevTableName = prev.TableName
|
||||
}
|
||||
startExpr := `CASE WHEN "CreationTime" IS NOT NULL AND "CreationTime" > 0 AND "CreationTime" > ? THEN "CreationTime" ELSE ? END`
|
||||
endExpr := `CASE WHEN "DeletionTime" IS NOT NULL AND "DeletionTime" > 0 AND "DeletionTime" < ? THEN "DeletionTime" ELSE ? END`
|
||||
overlapExpr := fmt.Sprintf(`CASE WHEN %s > %s THEN (CAST((%s - %s) AS REAL) / ?) ELSE 0 END`, endExpr, startExpr, endExpr, startExpr)
|
||||
missingStartExpr := `CASE WHEN COALESCE(prev_agg.creation_time, lifecycle.first_seen) IS NOT NULL AND COALESCE(prev_agg.creation_time, lifecycle.first_seen) > 0 AND COALESCE(prev_agg.creation_time, lifecycle.first_seen) > ? THEN COALESCE(prev_agg.creation_time, lifecycle.first_seen) ELSE ? END`
|
||||
missingEndExpr := `CASE WHEN COALESCE(lifecycle.deleted_at, prev_agg.deletion_time) IS NOT NULL AND COALESCE(lifecycle.deleted_at, prev_agg.deletion_time) > 0 AND COALESCE(lifecycle.deleted_at, prev_agg.deletion_time) < ? THEN COALESCE(lifecycle.deleted_at, prev_agg.deletion_time) ELSE ? END`
|
||||
missingOverlapExpr := fmt.Sprintf(`CASE WHEN %s > %s THEN (CAST((%s - %s) AS REAL) / ?) ELSE 0 END`, missingEndExpr, missingStartExpr, missingEndExpr, missingStartExpr)
|
||||
aggStartExpr := `CASE WHEN COALESCE(agg.creation_time, lifecycle.first_seen) IS NOT NULL AND COALESCE(agg.creation_time, lifecycle.first_seen) > 0 AND COALESCE(agg.creation_time, lifecycle.first_seen) > ? THEN COALESCE(agg.creation_time, lifecycle.first_seen) ELSE ? END`
|
||||
aggEndExpr := `CASE WHEN COALESCE(lifecycle.deleted_at, agg.deletion_time) IS NOT NULL AND COALESCE(lifecycle.deleted_at, agg.deletion_time) > 0 AND COALESCE(lifecycle.deleted_at, agg.deletion_time) < ? THEN COALESCE(lifecycle.deleted_at, agg.deletion_time) ELSE ? END`
|
||||
aggOverlapExpr := fmt.Sprintf(`CASE WHEN %s > %s THEN (CAST((%s - %s) AS REAL) / ?) ELSE 0 END`, aggEndExpr, aggStartExpr, aggEndExpr, aggStartExpr)
|
||||
idExpr := `COALESCE(NULLIF("VmId", ''), NULLIF("VmUuid", ''), NULLIF("Name", ''), 'unknown')`
|
||||
vmKeyExpr := fmt.Sprintf(`(%s || '|' || COALESCE("Vcenter", ''))`, idExpr)
|
||||
query := fmt.Sprintf(`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
%s AS vm_key,
|
||||
"VmId",
|
||||
"VmUuid",
|
||||
"Name",
|
||||
"Vcenter",
|
||||
"VcpuCount",
|
||||
"RamGB",
|
||||
LOWER(COALESCE("ResourcePool", '')) AS pool,
|
||||
NULLIF("CreationTime", 0) AS creation_time,
|
||||
NULLIF("DeletionTime", 0) AS deletion_time,
|
||||
%s AS presence
|
||||
FROM %s
|
||||
WHERE %s
|
||||
),
|
||||
agg AS (
|
||||
SELECT
|
||||
vm_key,
|
||||
MAX("VcpuCount") AS "VcpuCount",
|
||||
MAX("RamGB") AS "RamGB",
|
||||
MAX(pool) AS pool,
|
||||
MIN(creation_time) AS creation_time,
|
||||
MIN(deletion_time) AS deletion_time
|
||||
FROM base
|
||||
GROUP BY vm_key
|
||||
),
|
||||
lifecycle AS (
|
||||
SELECT
|
||||
(COALESCE(NULLIF("VmId", ''), NULLIF("VmUuid", ''), NULLIF("Name", ''), 'unknown') || '|' || COALESCE("Vcenter", '')) AS vm_key,
|
||||
MIN(NULLIF("FirstSeen", 0)) AS first_seen,
|
||||
MIN(NULLIF("DeletedAt", 0)) AS deleted_at
|
||||
FROM vm_lifecycle_cache
|
||||
GROUP BY vm_key
|
||||
),
|
||||
prev_base AS (
|
||||
SELECT
|
||||
%s AS vm_key,
|
||||
"VcpuCount",
|
||||
"RamGB",
|
||||
LOWER(COALESCE("ResourcePool", '')) AS pool,
|
||||
NULLIF("CreationTime", 0) AS creation_time,
|
||||
NULLIF("DeletionTime", 0) AS deletion_time
|
||||
FROM %s
|
||||
WHERE %s
|
||||
),
|
||||
prev_agg AS (
|
||||
SELECT
|
||||
vm_key,
|
||||
MAX("VcpuCount") AS "VcpuCount",
|
||||
MAX("RamGB") AS "RamGB",
|
||||
MAX(pool) AS pool,
|
||||
MIN(creation_time) AS creation_time,
|
||||
MIN(deletion_time) AS deletion_time
|
||||
FROM prev_base
|
||||
GROUP BY vm_key
|
||||
),
|
||||
missing_deleted AS (
|
||||
SELECT
|
||||
prev_agg.vm_key,
|
||||
prev_agg."VcpuCount",
|
||||
prev_agg."RamGB",
|
||||
prev_agg.pool,
|
||||
COALESCE(prev_agg.creation_time, lifecycle.first_seen) AS creation_time,
|
||||
COALESCE(lifecycle.deleted_at, prev_agg.deletion_time) AS deletion_time,
|
||||
%s AS presence
|
||||
FROM prev_agg
|
||||
LEFT JOIN lifecycle ON lifecycle.vm_key = prev_agg.vm_key
|
||||
LEFT JOIN agg ON agg.vm_key = prev_agg.vm_key
|
||||
WHERE agg.vm_key IS NULL
|
||||
AND COALESCE(lifecycle.deleted_at, prev_agg.deletion_time, 0) > 0
|
||||
AND COALESCE(lifecycle.deleted_at, prev_agg.deletion_time) > ? AND COALESCE(lifecycle.deleted_at, prev_agg.deletion_time) < ?
|
||||
),
|
||||
agg_presence AS (
|
||||
SELECT
|
||||
agg.vm_key,
|
||||
agg."VcpuCount",
|
||||
agg."RamGB",
|
||||
agg.pool,
|
||||
COALESCE(agg.creation_time, lifecycle.first_seen) AS creation_time,
|
||||
COALESCE(lifecycle.deleted_at, agg.deletion_time) AS deletion_time,
|
||||
%s AS presence
|
||||
FROM agg
|
||||
LEFT JOIN lifecycle ON lifecycle.vm_key = agg.vm_key
|
||||
UNION ALL
|
||||
SELECT
|
||||
missing_deleted.vm_key,
|
||||
missing_deleted."VcpuCount",
|
||||
missing_deleted."RamGB",
|
||||
missing_deleted.pool,
|
||||
missing_deleted.creation_time,
|
||||
missing_deleted.deletion_time,
|
||||
missing_deleted.presence
|
||||
FROM missing_deleted
|
||||
),
|
||||
diag AS (
|
||||
SELECT
|
||||
COUNT(*) AS row_count,
|
||||
COUNT(DISTINCT vm_key) AS distinct_keys,
|
||||
COALESCE(SUM(CASE WHEN vm_key LIKE 'unknown|%%' THEN 1 ELSE 0 END), 0) AS unknown_keys,
|
||||
COALESCE(SUM(CASE WHEN "VmId" IS NULL OR "VmId" = '' THEN 1 ELSE 0 END), 0) AS missing_vm_id,
|
||||
COALESCE(SUM(CASE WHEN "VmUuid" IS NULL OR "VmUuid" = '' THEN 1 ELSE 0 END), 0) AS missing_vm_uuid,
|
||||
COALESCE(SUM(CASE WHEN "Name" IS NULL OR "Name" = '' THEN 1 ELSE 0 END), 0) AS missing_name,
|
||||
COALESCE(SUM(CASE WHEN presence > 1 THEN 1 ELSE 0 END), 0) AS presence_over_one,
|
||||
COALESCE(SUM(CASE WHEN presence < 0 THEN 1 ELSE 0 END), 0) AS presence_under_zero,
|
||||
COALESCE(SUM(presence), 0) AS base_presence_sum
|
||||
FROM base
|
||||
),
|
||||
agg_diag AS (
|
||||
SELECT
|
||||
COUNT(*) AS agg_count,
|
||||
COALESCE(SUM(CASE WHEN agg_presence.creation_time IS NULL OR agg_presence.creation_time = 0 THEN 1 ELSE 0 END), 0) AS missing_creation,
|
||||
COALESCE(SUM(CASE WHEN agg_presence.deletion_time IS NULL OR agg_presence.deletion_time = 0 THEN 1 ELSE 0 END), 0) AS missing_deletion,
|
||||
COALESCE(SUM(CASE WHEN agg_presence.creation_time > ? AND agg_presence.creation_time < ? THEN 1 ELSE 0 END), 0) AS created_in_interval,
|
||||
COALESCE(SUM(CASE WHEN agg_presence.deletion_time > ? AND agg_presence.deletion_time < ? THEN 1 ELSE 0 END), 0) AS deleted_in_interval,
|
||||
COALESCE(SUM(CASE WHEN agg_presence.presence > 0 AND agg_presence.presence < 1 THEN 1 ELSE 0 END), 0) AS partial_presence
|
||||
FROM agg_presence
|
||||
)
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM agg_presence) AS vm_count,
|
||||
(SELECT COALESCE(SUM(CASE WHEN "VcpuCount" IS NOT NULL THEN "VcpuCount" ELSE 0 END), 0) FROM agg_presence) AS vcpu_total,
|
||||
(SELECT COALESCE(SUM(CASE WHEN "RamGB" IS NOT NULL THEN "RamGB" ELSE 0 END), 0) FROM agg_presence) AS ram_total,
|
||||
(SELECT COALESCE(SUM(presence), 0) FROM agg_presence) AS presence_ratio,
|
||||
(SELECT COALESCE(SUM(CASE WHEN pool = 'tin' THEN presence ELSE 0 END), 0) FROM agg_presence) AS tin_total,
|
||||
(SELECT COALESCE(SUM(CASE WHEN pool = 'bronze' THEN presence ELSE 0 END), 0) FROM agg_presence) AS bronze_total,
|
||||
(SELECT COALESCE(SUM(CASE WHEN pool = 'silver' THEN presence ELSE 0 END), 0) FROM agg_presence) AS silver_total,
|
||||
(SELECT COALESCE(SUM(CASE WHEN pool = 'gold' THEN presence ELSE 0 END), 0) FROM agg_presence) AS gold_total,
|
||||
diag.row_count,
|
||||
diag.distinct_keys,
|
||||
diag.unknown_keys,
|
||||
diag.missing_vm_id,
|
||||
diag.missing_vm_uuid,
|
||||
diag.missing_name,
|
||||
diag.presence_over_one,
|
||||
diag.presence_under_zero,
|
||||
diag.base_presence_sum,
|
||||
agg_diag.agg_count,
|
||||
agg_diag.missing_creation,
|
||||
agg_diag.missing_deletion,
|
||||
agg_diag.created_in_interval,
|
||||
agg_diag.deleted_in_interval,
|
||||
agg_diag.partial_presence
|
||||
FROM diag, agg_diag
|
||||
`, vmKeyExpr, overlapExpr, selected.TableName, templateExclusionFilter(), vmKeyExpr, prevTableName, templateExclusionFilter(), missingOverlapExpr, aggOverlapExpr)
|
||||
query = dbConn.Rebind(query)
|
||||
var row struct {
|
||||
VmCount int64 `db:"vm_count"`
|
||||
VcpuTotal int64 `db:"vcpu_total"`
|
||||
RamTotal int64 `db:"ram_total"`
|
||||
PresenceRatio float64 `db:"presence_ratio"`
|
||||
TinTotal float64 `db:"tin_total"`
|
||||
BronzeTotal float64 `db:"bronze_total"`
|
||||
SilverTotal float64 `db:"silver_total"`
|
||||
GoldTotal float64 `db:"gold_total"`
|
||||
RowCount int64 `db:"row_count"`
|
||||
DistinctKeys int64 `db:"distinct_keys"`
|
||||
UnknownKeys int64 `db:"unknown_keys"`
|
||||
MissingVmID int64 `db:"missing_vm_id"`
|
||||
MissingVmUUID int64 `db:"missing_vm_uuid"`
|
||||
MissingName int64 `db:"missing_name"`
|
||||
PresenceOverOne int64 `db:"presence_over_one"`
|
||||
PresenceUnderZero int64 `db:"presence_under_zero"`
|
||||
BasePresenceSum float64 `db:"base_presence_sum"`
|
||||
AggCount int64 `db:"agg_count"`
|
||||
MissingCreation int64 `db:"missing_creation"`
|
||||
MissingDeletion int64 `db:"missing_deletion"`
|
||||
CreatedInInterval int64 `db:"created_in_interval"`
|
||||
DeletedInInterval int64 `db:"deleted_in_interval"`
|
||||
PartialPresence int64 `db:"partial_presence"`
|
||||
}
|
||||
overlapArgs := []interface{}{
|
||||
hourEndUnix, hourEndUnix,
|
||||
hourStartUnix, hourStartUnix,
|
||||
hourEndUnix, hourEndUnix,
|
||||
hourStartUnix, hourStartUnix,
|
||||
durationSeconds,
|
||||
}
|
||||
args := make([]interface{}, 0, len(overlapArgs)*3+6)
|
||||
args = append(args, overlapArgs...)
|
||||
args = append(args, overlapArgs...)
|
||||
args = append(args, hourStartUnix, hourEndUnix)
|
||||
args = append(args, overlapArgs...)
|
||||
args = append(args, hourStartUnix, hourEndUnix)
|
||||
args = append(args, hourStartUnix, hourEndUnix)
|
||||
if err := dbConn.GetContext(ctx, &row, query, args...); err != nil {
|
||||
hourlyTotalsQueryDumpOnce.Do(func() {
|
||||
logger.Debug(
|
||||
"hourly totals query debug",
|
||||
"table", selected.TableName,
|
||||
"prev_table", prevTableName,
|
||||
"snapshot_time", selected.SnapshotTime.Format(time.RFC3339),
|
||||
"interval_start", hourWindowStart.Format(time.RFC3339),
|
||||
"interval_end", hourEnd.Format(time.RFC3339),
|
||||
"query", query,
|
||||
"args", args,
|
||||
"error", err,
|
||||
)
|
||||
})
|
||||
return nil, err
|
||||
}
|
||||
snapshotLag := selected.SnapshotTime.Sub(hourEnd)
|
||||
duplicateRows := row.RowCount - row.DistinctKeys
|
||||
logger.Debug(
|
||||
"hourly totals snapshot diagnostics",
|
||||
"table", selected.TableName,
|
||||
"snapshot_time", selected.SnapshotTime.Format(time.RFC3339),
|
||||
"snapshot_lag_seconds", int64(snapshotLag.Seconds()),
|
||||
"interval_start", hourWindowStart.Format("2006-01-02 15:04"),
|
||||
"interval_end", hourEnd.Format("2006-01-02 15:04"),
|
||||
"row_count", row.RowCount,
|
||||
"distinct_keys", row.DistinctKeys,
|
||||
"duplicate_rows", duplicateRows,
|
||||
"unknown_keys", row.UnknownKeys,
|
||||
"missing_vm_id", row.MissingVmID,
|
||||
"missing_vm_uuid", row.MissingVmUUID,
|
||||
"missing_name", row.MissingName,
|
||||
"presence_over_one", row.PresenceOverOne,
|
||||
"presence_under_zero", row.PresenceUnderZero,
|
||||
"base_presence_sum", row.BasePresenceSum,
|
||||
"agg_count", row.AggCount,
|
||||
"missing_creation", row.MissingCreation,
|
||||
"missing_deletion", row.MissingDeletion,
|
||||
"created_in_interval", row.CreatedInInterval,
|
||||
"deleted_in_interval", row.DeletedInInterval,
|
||||
"partial_presence", row.PartialPresence,
|
||||
"presence_ratio", row.PresenceRatio,
|
||||
"vm_count", row.VmCount,
|
||||
)
|
||||
label := formatHourIntervalLabel(hourWindowStart, hourEnd)
|
||||
logger.Debug(
|
||||
"hourly totals bucket",
|
||||
"interval_start", hourWindowStart.Format("2006-01-02 15:04"),
|
||||
"interval_end", hourEnd.Format("2006-01-02 15:04"),
|
||||
"presence_ratio", row.PresenceRatio,
|
||||
"tin_total", row.TinTotal,
|
||||
"bronze_total", row.BronzeTotal,
|
||||
"silver_total", row.SilverTotal,
|
||||
"gold_total", row.GoldTotal,
|
||||
)
|
||||
points = append(points, totalsPoint{
|
||||
Label: record.SnapshotTime.Local().Format("2006-01-02 15:04"),
|
||||
VmCount: row.VmCount,
|
||||
VcpuTotal: float64(row.VcpuTotal),
|
||||
RamTotal: float64(row.RamTotal),
|
||||
// For hourly snapshots, prorated VM count equals VM count (no finer granularity).
|
||||
PresenceRatio: float64(row.VmCount),
|
||||
Label: label,
|
||||
VmCount: float64(row.VmCount),
|
||||
VcpuTotal: float64(row.VcpuTotal),
|
||||
RamTotal: float64(row.RamTotal),
|
||||
PresenceRatio: row.PresenceRatio,
|
||||
TinTotal: row.TinTotal,
|
||||
BronzeTotal: row.BronzeTotal,
|
||||
SilverTotal: row.SilverTotal,
|
||||
@@ -1080,28 +1572,82 @@ WHERE %s
|
||||
return points, nil
|
||||
}
|
||||
|
||||
func estimateSnapshotInterval(records []SnapshotRecord) time.Duration {
|
||||
if len(records) < 2 {
|
||||
return time.Hour
|
||||
}
|
||||
diffs := make([]int64, 0, len(records)-1)
|
||||
for i := 1; i < len(records); i++ {
|
||||
diff := records[i].SnapshotTime.Sub(records[i-1].SnapshotTime)
|
||||
if diff > 0 {
|
||||
diffs = append(diffs, int64(diff.Seconds()))
|
||||
}
|
||||
}
|
||||
if len(diffs) == 0 {
|
||||
return time.Hour
|
||||
}
|
||||
sort.Slice(diffs, func(i, j int) bool { return diffs[i] < diffs[j] })
|
||||
median := diffs[len(diffs)/2]
|
||||
if median <= 0 {
|
||||
return time.Hour
|
||||
}
|
||||
return time.Duration(median) * time.Second
|
||||
}
|
||||
|
||||
func formatHourIntervalLabel(start, end time.Time) string {
|
||||
startLabel := start.Format("2006-01-02 15:04")
|
||||
if start.Year() == end.Year() && start.YearDay() == end.YearDay() {
|
||||
return fmt.Sprintf("%s to %s", startLabel, end.Format("15:04"))
|
||||
}
|
||||
return fmt.Sprintf("%s to %s", startLabel, end.Format("2006-01-02 15:04"))
|
||||
}
|
||||
|
||||
func formatDayIntervalLabel(start, end time.Time) string {
|
||||
return fmt.Sprintf("%s to %s", start.Format("2006-01-02"), end.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
func buildDailyTotals(ctx context.Context, dbConn *sqlx.DB, records []SnapshotRecord, prorateByAvg bool) ([]totalsPoint, error) {
|
||||
points := make([]totalsPoint, 0, len(records))
|
||||
tinExpr := `COALESCE(SUM(CASE WHEN "Tin" IS NOT NULL THEN "Tin" ELSE 0 END) / 100.0, 0)`
|
||||
bronzeExpr := `COALESCE(SUM(CASE WHEN "Bronze" IS NOT NULL THEN "Bronze" ELSE 0 END) / 100.0, 0)`
|
||||
silverExpr := `COALESCE(SUM(CASE WHEN "Silver" IS NOT NULL THEN "Silver" ELSE 0 END) / 100.0, 0)`
|
||||
goldExpr := `COALESCE(SUM(CASE WHEN "Gold" IS NOT NULL THEN "Gold" ELSE 0 END) / 100.0, 0)`
|
||||
if prorateByAvg {
|
||||
tinExpr = `COALESCE(SUM(CASE WHEN "Tin" IS NOT NULL THEN "Tin" * COALESCE("AvgIsPresent", 0) ELSE 0 END) / 100.0, 0)`
|
||||
bronzeExpr = `COALESCE(SUM(CASE WHEN "Bronze" IS NOT NULL THEN "Bronze" * COALESCE("AvgIsPresent", 0) ELSE 0 END) / 100.0, 0)`
|
||||
silverExpr = `COALESCE(SUM(CASE WHEN "Silver" IS NOT NULL THEN "Silver" * COALESCE("AvgIsPresent", 0) ELSE 0 END) / 100.0, 0)`
|
||||
goldExpr = `COALESCE(SUM(CASE WHEN "Gold" IS NOT NULL THEN "Gold" * COALESCE("AvgIsPresent", 0) ELSE 0 END) / 100.0, 0)`
|
||||
}
|
||||
for _, record := range records {
|
||||
if err := db.ValidateTableName(record.TableName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rowsExist, err := db.TableHasRows(ctx, dbConn, record.TableName); err != nil || !rowsExist {
|
||||
if record.SnapshotCount == 0 {
|
||||
slog.Debug("daily totals skipping empty snapshot", "table", record.TableName, "snapshot_time", record.SnapshotTime)
|
||||
continue
|
||||
}
|
||||
if record.SnapshotCount < 0 {
|
||||
rowsExist, err := db.TableHasRows(ctx, dbConn, record.TableName)
|
||||
if err != nil {
|
||||
slog.Debug("daily totals snapshot probe failed", "table", record.TableName, "snapshot_time", record.SnapshotTime, "error", err)
|
||||
}
|
||||
if err != nil || !rowsExist {
|
||||
continue
|
||||
}
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
COUNT(DISTINCT "VmId") AS vm_count,
|
||||
COALESCE(SUM(CASE WHEN "AvgVcpuCount" IS NOT NULL THEN "AvgVcpuCount" ELSE 0 END), 0) AS vcpu_total,
|
||||
COALESCE(SUM(CASE WHEN "AvgRamGB" IS NOT NULL THEN "AvgRamGB" ELSE 0 END), 0) AS ram_total,
|
||||
COALESCE(AVG(CASE WHEN "AvgIsPresent" IS NOT NULL THEN "AvgIsPresent" ELSE 0 END), 0) AS presence_ratio,
|
||||
COALESCE(SUM(CASE WHEN "Tin" IS NOT NULL THEN "Tin" ELSE 0 END) / 100.0, 0) AS tin_total,
|
||||
COALESCE(SUM(CASE WHEN "Bronze" IS NOT NULL THEN "Bronze" ELSE 0 END) / 100.0, 0) AS bronze_total,
|
||||
COALESCE(SUM(CASE WHEN "Silver" IS NOT NULL THEN "Silver" ELSE 0 END) / 100.0, 0) AS silver_total,
|
||||
COALESCE(SUM(CASE WHEN "Gold" IS NOT NULL THEN "Gold" ELSE 0 END) / 100.0, 0) AS gold_total
|
||||
%s AS tin_total,
|
||||
%s AS bronze_total,
|
||||
%s AS silver_total,
|
||||
%s AS gold_total
|
||||
FROM %s
|
||||
WHERE %s
|
||||
`, record.TableName, templateExclusionFilter())
|
||||
`, tinExpr, bronzeExpr, silverExpr, goldExpr, record.TableName, templateExclusionFilter())
|
||||
var row struct {
|
||||
VmCount int64 `db:"vm_count"`
|
||||
VcpuTotal float64 `db:"vcpu_total"`
|
||||
@@ -1115,12 +1661,15 @@ WHERE %s
|
||||
if err := dbConn.GetContext(ctx, &row, query); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dayTime := record.SnapshotTime.Local()
|
||||
dayStart := time.Date(dayTime.Year(), dayTime.Month(), dayTime.Day(), 0, 0, 0, 0, dayTime.Location())
|
||||
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||
points = append(points, totalsPoint{
|
||||
Label: record.SnapshotTime.Local().Format("2006-01-02"),
|
||||
VmCount: row.VmCount,
|
||||
Label: formatDayIntervalLabel(dayStart, dayEnd),
|
||||
VmCount: float64(row.VmCount),
|
||||
VcpuTotal: row.VcpuTotal,
|
||||
RamTotal: row.RamTotal,
|
||||
PresenceRatio: computeProratedVmCount(row.PresenceRatio, row.VmCount, prorateByAvg),
|
||||
PresenceRatio: computeProratedVmCount(row.PresenceRatio, float64(row.VmCount), prorateByAvg),
|
||||
TinTotal: row.TinTotal,
|
||||
BronzeTotal: row.BronzeTotal,
|
||||
SilverTotal: row.SilverTotal,
|
||||
@@ -1130,11 +1679,11 @@ WHERE %s
|
||||
return points, nil
|
||||
}
|
||||
|
||||
func computeProratedVmCount(presenceRatio float64, vmCount int64, prorate bool) float64 {
|
||||
func computeProratedVmCount(presenceRatio float64, vmCount float64, prorate bool) float64 {
|
||||
if !prorate {
|
||||
return float64(vmCount)
|
||||
return vmCount
|
||||
}
|
||||
return presenceRatio * float64(vmCount)
|
||||
return presenceRatio * vmCount
|
||||
}
|
||||
|
||||
func writeTotalsChart(logger *slog.Logger, xlsx *excelize.File, sheetName string, points []totalsPoint) {
|
||||
@@ -1166,6 +1715,18 @@ func writeTotalsChart(logger *slog.Logger, xlsx *excelize.File, sheetName string
|
||||
xlsx.SetCellValue(sheetName, fmt.Sprintf("I%d", row), point.GoldTotal)
|
||||
}
|
||||
|
||||
if lastRow := len(points) + 1; lastRow >= 2 {
|
||||
numFmt := "0.00000000"
|
||||
styleID, err := xlsx.NewStyle(&excelize.Style{CustomNumFmt: &numFmt})
|
||||
if err == nil {
|
||||
if err := xlsx.SetCellStyle(sheetName, "E2", fmt.Sprintf("I%d", lastRow), styleID); err != nil {
|
||||
logger.Error("Error setting totals number format", "error", err)
|
||||
}
|
||||
} else {
|
||||
logger.Error("Error creating totals number format", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if endCell, err := excelize.CoordinatesToCellName(len(headers), 1); err == nil {
|
||||
filterRange := "A1:" + endCell
|
||||
if err := xlsx.AutoFilter(sheetName, filterRange, nil); err != nil {
|
||||
|
||||
@@ -47,6 +47,8 @@ type SettingsYML struct {
|
||||
HourlySnapshotMaxRetries int `yaml:"hourly_snapshot_max_retries"`
|
||||
DailyJobTimeoutSeconds int `yaml:"daily_job_timeout_seconds"`
|
||||
MonthlyJobTimeoutSeconds int `yaml:"monthly_job_timeout_seconds"`
|
||||
MonthlyAggregationGranularity string `yaml:"monthly_aggregation_granularity"`
|
||||
MonthlyAggregationCron string `yaml:"monthly_aggregation_cron"`
|
||||
CleanupJobTimeoutSeconds int `yaml:"cleanup_job_timeout_seconds"`
|
||||
TenantsToFilter []string `yaml:"tenants_to_filter"`
|
||||
NodeChargeClusters []string `yaml:"node_charge_clusters"`
|
||||
|
||||
@@ -2,18 +2,13 @@ package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
"vctp/db"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// CronTracker manages re-entry protection and status recording for cron jobs.
|
||||
type CronTracker struct {
|
||||
db db.Database
|
||||
bindType int
|
||||
}
|
||||
|
||||
func NewCronTracker(database db.Database) *CronTracker {
|
||||
return &CronTracker{
|
||||
db: database,
|
||||
@@ -30,6 +25,39 @@ func (c *CronTracker) ClearAllInProgress(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ClearStale resets in_progress for a specific job if it has been running longer than maxAge.
|
||||
func (c *CronTracker) ClearStale(ctx context.Context, job string, maxAge time.Duration) error {
|
||||
if err := c.ensureTable(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
driver := strings.ToLower(c.db.DB().DriverName())
|
||||
var query string
|
||||
switch driver {
|
||||
case "sqlite":
|
||||
query = `
|
||||
UPDATE cron_status
|
||||
SET in_progress = FALSE
|
||||
WHERE job_name = ?
|
||||
AND in_progress = TRUE
|
||||
AND started_at > 0
|
||||
AND (strftime('%s','now') - started_at) > ?
|
||||
`
|
||||
case "pgx", "postgres":
|
||||
query = `
|
||||
UPDATE cron_status
|
||||
SET in_progress = FALSE
|
||||
WHERE job_name = $1
|
||||
AND in_progress = TRUE
|
||||
AND started_at > 0
|
||||
AND (EXTRACT(EPOCH FROM now())::BIGINT - started_at) > $2
|
||||
`
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
_, err := c.db.DB().ExecContext(ctx, query, job, int64(maxAge.Seconds()))
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *CronTracker) ensureTable(ctx context.Context) error {
|
||||
conn := c.db.DB()
|
||||
driver := conn.DriverName()
|
||||
|
||||
@@ -7,10 +7,12 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"vctp/db"
|
||||
"vctp/db/queries"
|
||||
"vctp/internal/metrics"
|
||||
"vctp/internal/report"
|
||||
)
|
||||
@@ -23,7 +25,9 @@ func (c *CronTask) RunVcenterDailyAggregate(ctx context.Context, logger *slog.Lo
|
||||
defer func() {
|
||||
logger.Info("Daily summary job finished", "duration", time.Since(startedAt))
|
||||
}()
|
||||
targetTime := time.Now().Add(-time.Minute)
|
||||
// Aggregate the previous day to avoid partial "today" data when the job runs just after midnight.
|
||||
targetTime := time.Now().AddDate(0, 0, -1)
|
||||
logger.Info("Daily summary job starting", "target_date", targetTime.Format("2006-01-02"))
|
||||
// Always force regeneration on the scheduled run to refresh data even if a manual run happened earlier.
|
||||
return c.aggregateDailySummary(jobCtx, targetTime, true)
|
||||
})
|
||||
@@ -37,6 +41,7 @@ func (c *CronTask) aggregateDailySummary(ctx context.Context, targetTime time.Ti
|
||||
jobStart := time.Now()
|
||||
dayStart := time.Date(targetTime.Year(), targetTime.Month(), targetTime.Day(), 0, 0, 0, 0, targetTime.Location())
|
||||
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||
c.Logger.Info("Daily aggregation window", "start", dayStart, "end", dayEnd)
|
||||
summaryTable, err := dailySummaryTableName(targetTime)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -63,6 +68,7 @@ func (c *CronTask) aggregateDailySummary(ctx context.Context, targetTime time.Ti
|
||||
|
||||
// If enabled, use the Go fan-out/reduce path to parallelize aggregation.
|
||||
if os.Getenv("DAILY_AGG_GO") == "1" {
|
||||
c.Logger.Debug("Using go implementation of aggregation")
|
||||
if err := c.aggregateDailySummaryGo(ctx, dayStart, dayEnd, summaryTable, force); err != nil {
|
||||
c.Logger.Warn("go-based daily aggregation failed, falling back to SQL path", "error", err)
|
||||
} else {
|
||||
@@ -78,6 +84,7 @@ func (c *CronTask) aggregateDailySummary(ctx context.Context, targetTime time.Ti
|
||||
}
|
||||
hourlySnapshots = filterRecordsInRange(hourlySnapshots, dayStart, dayEnd)
|
||||
hourlySnapshots = filterSnapshotsWithRows(ctx, dbConn, hourlySnapshots)
|
||||
c.Logger.Info("Daily aggregation hourly snapshot count", "count", len(hourlySnapshots), "date", dayStart.Format("2006-01-02"))
|
||||
if len(hourlySnapshots) == 0 {
|
||||
return fmt.Errorf("no hourly snapshot tables found for %s", dayStart.Format("2006-01-02"))
|
||||
}
|
||||
@@ -85,10 +92,6 @@ func (c *CronTask) aggregateDailySummary(ctx context.Context, targetTime time.Ti
|
||||
hourlyTables := make([]string, 0, len(hourlySnapshots))
|
||||
for _, snapshot := range hourlySnapshots {
|
||||
hourlyTables = append(hourlyTables, snapshot.TableName)
|
||||
// Ensure indexes exist on historical hourly tables for faster aggregation.
|
||||
if err := db.EnsureSnapshotIndexes(ctx, dbConn, snapshot.TableName); err != nil {
|
||||
c.Logger.Warn("failed to ensure indexes on hourly table", "table", snapshot.TableName, "error", err)
|
||||
}
|
||||
}
|
||||
unionQuery, err := buildUnionQuery(hourlyTables, summaryUnionColumns, templateExclusionFilter())
|
||||
if err != nil {
|
||||
@@ -147,30 +150,54 @@ func (c *CronTask) aggregateDailySummary(ctx context.Context, targetTime time.Ti
|
||||
c.Logger.Error("failed to aggregate daily inventory", "error", err, "date", dayStart.Format("2006-01-02"))
|
||||
return err
|
||||
}
|
||||
// Backfill missing creation times to the start of the day for rows where vCenter had no creation info.
|
||||
if _, err := dbConn.ExecContext(ctx,
|
||||
`UPDATE `+summaryTable+` SET "CreationTime" = $1 WHERE "CreationTime" IS NULL OR "CreationTime" = 0`,
|
||||
dayStart.Unix(),
|
||||
); err != nil {
|
||||
c.Logger.Warn("failed to normalize creation times for daily summary", "error", err, "table", summaryTable)
|
||||
if applied, err := db.ApplyLifecycleDeletionToSummary(ctx, dbConn, summaryTable, dayStart.Unix(), dayEnd.Unix()); err != nil {
|
||||
c.Logger.Warn("failed to apply lifecycle deletions to daily summary", "error", err, "table", summaryTable)
|
||||
} else {
|
||||
c.Logger.Info("Daily 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", "error", err, "table", summaryTable)
|
||||
}
|
||||
if err := db.UpdateSummaryPresenceByWindow(ctx, dbConn, summaryTable, dayStart.Unix(), dayEnd.Unix()); err != nil {
|
||||
c.Logger.Warn("failed to update daily AvgIsPresent from lifecycle window", "error", err, "table", summaryTable)
|
||||
}
|
||||
analyzeStart := time.Now()
|
||||
c.Logger.Debug("Analyzing daily summary table", "table", summaryTable)
|
||||
db.AnalyzeTableIfPostgres(ctx, dbConn, summaryTable)
|
||||
c.Logger.Debug("Analyzed daily summary table", "table", summaryTable, "duration", time.Since(analyzeStart))
|
||||
|
||||
rowCountStart := time.Now()
|
||||
c.Logger.Debug("Counting daily summary rows", "table", summaryTable)
|
||||
rowCount, err := db.TableRowCount(ctx, dbConn, summaryTable)
|
||||
if err != nil {
|
||||
c.Logger.Warn("unable to count daily summary rows", "error", err, "table", summaryTable)
|
||||
}
|
||||
c.Logger.Debug("Counted daily summary rows", "table", summaryTable, "rows", rowCount, "duration", time.Since(rowCountStart))
|
||||
logMissingCreationSummary(ctx, c.Logger, c.Database, summaryTable, rowCount)
|
||||
|
||||
registerStart := time.Now()
|
||||
c.Logger.Debug("Registering daily snapshot", "table", summaryTable, "date", dayStart.Format("2006-01-02"), "rows", rowCount)
|
||||
if err := report.RegisterSnapshot(ctx, c.Database, "daily", summaryTable, dayStart, rowCount); err != nil {
|
||||
c.Logger.Warn("failed to register daily snapshot", "error", err, "table", summaryTable)
|
||||
} else {
|
||||
c.Logger.Debug("Registered daily snapshot", "table", summaryTable, "duration", time.Since(registerStart))
|
||||
}
|
||||
|
||||
reportStart := time.Now()
|
||||
c.Logger.Debug("Generating daily report", "table", summaryTable)
|
||||
if err := c.generateReport(ctx, summaryTable); err != nil {
|
||||
c.Logger.Warn("failed to generate daily report", "error", err, "table", summaryTable)
|
||||
metrics.RecordDailyAggregation(time.Since(jobStart), err)
|
||||
return err
|
||||
}
|
||||
c.Logger.Debug("Generated daily report", "table", summaryTable, "duration", time.Since(reportStart))
|
||||
checkpointStart := time.Now()
|
||||
c.Logger.Debug("Checkpointing sqlite after daily aggregation", "table", summaryTable)
|
||||
if err := db.CheckpointSQLite(ctx, dbConn); err != nil {
|
||||
c.Logger.Warn("failed to checkpoint sqlite after daily aggregation", "error", err)
|
||||
} else {
|
||||
c.Logger.Debug("Checkpointed sqlite after daily aggregation", "table", summaryTable, "duration", time.Since(checkpointStart))
|
||||
}
|
||||
|
||||
c.Logger.Debug("Finished daily inventory aggregation", "summary_table", summaryTable)
|
||||
metrics.RecordDailyAggregation(time.Since(jobStart), nil)
|
||||
@@ -194,16 +221,16 @@ func (c *CronTask) aggregateDailySummaryGo(ctx context.Context, dayStart, dayEnd
|
||||
}
|
||||
hourlySnapshots = filterRecordsInRange(hourlySnapshots, dayStart, dayEnd)
|
||||
hourlySnapshots = filterSnapshotsWithRows(ctx, dbConn, hourlySnapshots)
|
||||
c.Logger.Info("Daily aggregation hourly snapshot count (go path)", "count", len(hourlySnapshots), "date", dayStart.Format("2006-01-02"))
|
||||
if len(hourlySnapshots) == 0 {
|
||||
return fmt.Errorf("no hourly snapshot tables found for %s", dayStart.Format("2006-01-02"))
|
||||
} else {
|
||||
c.Logger.Debug("Found hourly snapshot tables for daily aggregation", "date", dayStart.Format("2006-01-02"), "tables", len(hourlySnapshots))
|
||||
}
|
||||
|
||||
hourlyTables := make([]string, 0, len(hourlySnapshots))
|
||||
for _, snapshot := range hourlySnapshots {
|
||||
hourlyTables = append(hourlyTables, snapshot.TableName)
|
||||
if err := db.EnsureSnapshotIndexes(ctx, dbConn, snapshot.TableName); err != nil {
|
||||
c.Logger.Warn("failed to ensure indexes on hourly table", "table", snapshot.TableName, "error", err)
|
||||
}
|
||||
}
|
||||
unionQuery, err := buildUnionQuery(hourlyTables, summaryUnionColumns, templateExclusionFilter())
|
||||
if err != nil {
|
||||
@@ -223,71 +250,368 @@ func (c *CronTask) aggregateDailySummaryGo(ctx context.Context, dayStart, dayEnd
|
||||
}
|
||||
|
||||
totalSamples := len(hourlyTables)
|
||||
aggMap, err := c.scanHourlyTablesParallel(ctx, hourlySnapshots, totalSamples)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(aggMap) == 0 {
|
||||
return fmt.Errorf("no VM records aggregated for %s", dayStart.Format("2006-01-02"))
|
||||
var (
|
||||
aggMap map[dailyAggKey]*dailyAggVal
|
||||
snapTimes []int64
|
||||
)
|
||||
|
||||
if db.TableExists(ctx, dbConn, "vm_hourly_stats") {
|
||||
cacheAgg, cacheTimes, cacheErr := c.scanHourlyCache(ctx, dayStart, dayEnd)
|
||||
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 daily aggregation", "date", dayStart.Format("2006-01-02"), "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 daily aggregation", "date", dayStart.Format("2006-01-02"), "tables", len(hourlySnapshots), "vm_count", len(aggMap))
|
||||
if len(aggMap) == 0 {
|
||||
return fmt.Errorf("no VM records aggregated for %s", dayStart.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
// Build ordered list of snapshot times for deletion inference.
|
||||
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, dayStart, dayEnd)
|
||||
c.Logger.Info("Daily aggregation deletion times", "source_lifecycle_cache", lifecycleDeletions)
|
||||
|
||||
inventoryDeletions := c.applyInventoryDeletions(ctx, aggMap, dayStart, dayEnd)
|
||||
c.Logger.Info("Daily aggregation deletion times", "source_inventory", inventoryDeletions)
|
||||
|
||||
// Get the first hourly snapshot on/after dayEnd to help confirm deletions that happen on the last snapshot of the day.
|
||||
var nextSnapshotTable string
|
||||
nextSnapshotRows, nextErr := c.Database.DB().QueryxContext(ctx, `
|
||||
SELECT table_name
|
||||
FROM snapshot_registry
|
||||
WHERE snapshot_type = 'hourly' AND snapshot_time >= ?
|
||||
ORDER BY snapshot_time ASC
|
||||
LIMIT 1
|
||||
`, dayEnd.Unix())
|
||||
if nextErr == nil {
|
||||
if nextSnapshotRows.Next() {
|
||||
if scanErr := nextSnapshotRows.Scan(&nextSnapshotTable); scanErr != nil {
|
||||
nextSnapshotTable = ""
|
||||
}
|
||||
}
|
||||
nextSnapshotRows.Close()
|
||||
}
|
||||
nextPresence := make(map[string]struct{})
|
||||
if nextSnapshotTable != "" && db.TableExists(ctx, dbConn, nextSnapshotTable) {
|
||||
rows, err := querySnapshotRows(ctx, dbConn, nextSnapshotTable, []string{"VmId", "VmUuid", "Name"}, `"Vcenter" = ?`, c.Settings.Values.Settings.VcenterAddresses[0])
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var vmId, vmUuid, name sql.NullString
|
||||
if err := rows.Scan(&vmId, &vmUuid, &name); err == nil {
|
||||
if vmId.Valid {
|
||||
nextPresence["id:"+vmId.String] = struct{}{}
|
||||
}
|
||||
if vmUuid.Valid {
|
||||
nextPresence["uuid:"+vmUuid.String] = struct{}{}
|
||||
}
|
||||
if name.Valid {
|
||||
nextPresence["name:"+name.String] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
}
|
||||
|
||||
var maxSnap int64
|
||||
if len(snapTimes) > 0 {
|
||||
maxSnap = snapTimes[len(snapTimes)-1]
|
||||
}
|
||||
|
||||
inferredDeletions := 0
|
||||
for _, v := range aggMap {
|
||||
if v.deletion != 0 {
|
||||
continue
|
||||
}
|
||||
// Infer deletion only after seeing at least two consecutive absent snapshots after lastSeen.
|
||||
if maxSnap > 0 && len(v.seen) > 0 && v.lastSeen < maxSnap {
|
||||
c.Logger.Debug("inferring deletion window", "vm_id", v.key.VmId, "vm_uuid", v.key.VmUuid, "name", v.key.Name, "last_seen", v.lastSeen, "snapshots", len(snapTimes))
|
||||
}
|
||||
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 && firstMiss > 0 {
|
||||
// Not enough consecutive misses within the day; try to use the first snapshot of the next day to confirm.
|
||||
if nextSnapshotTable != "" && len(nextPresence) > 0 {
|
||||
_, presentByID := nextPresence["id:"+v.key.VmId]
|
||||
_, presentByUUID := nextPresence["uuid:"+v.key.VmUuid]
|
||||
_, presentByName := nextPresence["name:"+v.key.Name]
|
||||
if !presentByID && !presentByUUID && !presentByName {
|
||||
v.deletion = firstMiss
|
||||
inferredDeletions++
|
||||
c.Logger.Debug("cross-day deletion inferred from next snapshot", "vm_id", v.key.VmId, "vm_uuid", v.key.VmUuid, "name", v.key.Name, "deletion", firstMiss, "next_table", nextSnapshotTable)
|
||||
}
|
||||
}
|
||||
if v.deletion == 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("Daily aggregation deletion times", "source_inferred", inferredDeletions)
|
||||
|
||||
totalSamplesByVcenter := sampleCountsByVcenter(aggMap)
|
||||
|
||||
// Insert aggregated rows.
|
||||
if err := c.insertDailyAggregates(ctx, summaryTable, aggMap, totalSamples); err != nil {
|
||||
if err := c.insertDailyAggregates(ctx, summaryTable, aggMap, totalSamples, totalSamplesByVcenter); err != nil {
|
||||
return err
|
||||
}
|
||||
c.Logger.Debug("inserted daily aggregates", "table", summaryTable, "rows", len(aggMap), "total_samples", totalSamples)
|
||||
|
||||
// Persist rollup cache for monthly aggregation.
|
||||
if err := c.persistDailyRollup(ctx, dayStart.Unix(), aggMap, totalSamples, totalSamplesByVcenter); err != nil {
|
||||
c.Logger.Warn("failed to persist daily rollup cache", "error", err, "date", dayStart.Format("2006-01-02"))
|
||||
} else {
|
||||
c.Logger.Debug("persisted daily rollup cache", "date", dayStart.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
// Refine lifecycle with existing SQL helper to pick up first-after deletions.
|
||||
if err := db.RefineCreationDeletionFromUnion(ctx, dbConn, summaryTable, unionQuery); err != nil {
|
||||
c.Logger.Warn("failed to refine creation/deletion times", "error", err, "table", summaryTable)
|
||||
} else {
|
||||
c.Logger.Debug("refined creation/deletion times", "table", summaryTable)
|
||||
}
|
||||
if err := db.UpdateSummaryPresenceByWindow(ctx, dbConn, summaryTable, dayStart.Unix(), dayEnd.Unix()); err != nil {
|
||||
c.Logger.Warn("failed to update daily AvgIsPresent from lifecycle window (Go path)", "error", err, "table", summaryTable)
|
||||
}
|
||||
|
||||
analyzeStart := time.Now()
|
||||
c.Logger.Debug("Analyzing daily summary table", "table", summaryTable)
|
||||
db.AnalyzeTableIfPostgres(ctx, dbConn, summaryTable)
|
||||
c.Logger.Debug("Analyzed daily summary table", "table", summaryTable, "duration", time.Since(analyzeStart))
|
||||
|
||||
rowCountStart := time.Now()
|
||||
c.Logger.Debug("Counting daily summary rows", "table", summaryTable)
|
||||
rowCount, err := db.TableRowCount(ctx, dbConn, summaryTable)
|
||||
if err != nil {
|
||||
c.Logger.Warn("unable to count daily summary rows", "error", err, "table", summaryTable)
|
||||
}
|
||||
c.Logger.Debug("Counted daily summary rows", "table", summaryTable, "rows", rowCount, "duration", time.Since(rowCountStart))
|
||||
logMissingCreationSummary(ctx, c.Logger, c.Database, summaryTable, rowCount)
|
||||
|
||||
registerStart := time.Now()
|
||||
c.Logger.Debug("Registering daily snapshot", "table", summaryTable, "date", dayStart.Format("2006-01-02"), "rows", rowCount)
|
||||
if err := report.RegisterSnapshot(ctx, c.Database, "daily", summaryTable, dayStart, rowCount); err != nil {
|
||||
c.Logger.Warn("failed to register daily snapshot", "error", err, "table", summaryTable)
|
||||
} else {
|
||||
c.Logger.Debug("Registered daily snapshot", "table", summaryTable, "duration", time.Since(registerStart))
|
||||
}
|
||||
reportStart := time.Now()
|
||||
c.Logger.Debug("Generating daily report", "table", summaryTable)
|
||||
if err := c.generateReport(ctx, summaryTable); err != nil {
|
||||
c.Logger.Warn("failed to generate daily report", "error", err, "table", summaryTable)
|
||||
return err
|
||||
}
|
||||
c.Logger.Debug("Generated daily report", "table", summaryTable, "duration", time.Since(reportStart))
|
||||
checkpointStart := time.Now()
|
||||
c.Logger.Debug("Checkpointing sqlite after daily aggregation", "table", summaryTable)
|
||||
if err := db.CheckpointSQLite(ctx, dbConn); err != nil {
|
||||
c.Logger.Warn("failed to checkpoint sqlite after daily aggregation (Go path)", "error", err)
|
||||
} else {
|
||||
c.Logger.Debug("Checkpointed sqlite after daily aggregation", "table", summaryTable, "duration", time.Since(checkpointStart))
|
||||
}
|
||||
|
||||
c.Logger.Debug("Finished daily inventory aggregation (Go path)", "summary_table", summaryTable, "duration", time.Since(jobStart))
|
||||
c.Logger.Debug("Finished daily inventory aggregation (Go path)",
|
||||
"summary_table", summaryTable,
|
||||
"duration", time.Since(jobStart),
|
||||
"tables_scanned", len(hourlyTables),
|
||||
"rows_written", rowCount,
|
||||
"total_samples", totalSamples,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
type dailyAggKey struct {
|
||||
Vcenter string
|
||||
VmId string
|
||||
VmUuid string
|
||||
Name string
|
||||
func (c *CronTask) applyLifecycleDeletions(ctx context.Context, agg map[dailyAggKey]*dailyAggVal, start, end time.Time) int {
|
||||
dbConn := c.Database.DB()
|
||||
if !db.TableExists(ctx, dbConn, "vm_lifecycle_cache") {
|
||||
return 0
|
||||
}
|
||||
type aggIndex struct {
|
||||
byID map[string]*dailyAggVal
|
||||
byUUID map[string]*dailyAggVal
|
||||
byName map[string]*dailyAggVal
|
||||
}
|
||||
indexes := make(map[string]*aggIndex, 8)
|
||||
for k, v := range agg {
|
||||
if k.Vcenter == "" {
|
||||
continue
|
||||
}
|
||||
idx := indexes[k.Vcenter]
|
||||
if idx == nil {
|
||||
idx = &aggIndex{
|
||||
byID: make(map[string]*dailyAggVal),
|
||||
byUUID: make(map[string]*dailyAggVal),
|
||||
byName: make(map[string]*dailyAggVal),
|
||||
}
|
||||
indexes[k.Vcenter] = idx
|
||||
}
|
||||
if k.VmId != "" {
|
||||
idx.byID[k.VmId] = v
|
||||
}
|
||||
if k.VmUuid != "" {
|
||||
idx.byUUID[k.VmUuid] = v
|
||||
}
|
||||
if name := strings.ToLower(strings.TrimSpace(k.Name)); name != "" {
|
||||
idx.byName[name] = v
|
||||
}
|
||||
}
|
||||
|
||||
totalApplied := 0
|
||||
for vcenter, idx := range indexes {
|
||||
query := `
|
||||
SELECT "VmId","VmUuid","Name","DeletedAt"
|
||||
FROM vm_lifecycle_cache
|
||||
WHERE "Vcenter" = ? AND "DeletedAt" IS NOT NULL AND "DeletedAt" > 0 AND "DeletedAt" >= ? AND "DeletedAt" < ?
|
||||
`
|
||||
bind := dbConn.Rebind(query)
|
||||
rows, err := dbConn.QueryxContext(ctx, bind, vcenter, start.Unix(), end.Unix())
|
||||
if err != nil {
|
||||
c.Logger.Warn("failed to load lifecycle deletions", "vcenter", vcenter, "error", err)
|
||||
continue
|
||||
}
|
||||
scanned := 0
|
||||
applied := 0
|
||||
missed := 0
|
||||
for rows.Next() {
|
||||
scanned++
|
||||
var vmId, vmUuid, name sql.NullString
|
||||
var deletedAt sql.NullInt64
|
||||
if err := rows.Scan(&vmId, &vmUuid, &name, &deletedAt); err != nil {
|
||||
c.Logger.Warn("failed to scan lifecycle deletion", "vcenter", vcenter, "error", err)
|
||||
continue
|
||||
}
|
||||
if !deletedAt.Valid || deletedAt.Int64 <= 0 {
|
||||
continue
|
||||
}
|
||||
var target *dailyAggVal
|
||||
if vmId.Valid {
|
||||
target = idx.byID[strings.TrimSpace(vmId.String)]
|
||||
}
|
||||
if target == nil && vmUuid.Valid {
|
||||
target = idx.byUUID[strings.TrimSpace(vmUuid.String)]
|
||||
}
|
||||
if target == nil && name.Valid {
|
||||
target = idx.byName[strings.ToLower(strings.TrimSpace(name.String))]
|
||||
}
|
||||
if target == nil {
|
||||
missed++
|
||||
continue
|
||||
}
|
||||
if target.deletion == 0 || deletedAt.Int64 < target.deletion {
|
||||
target.deletion = deletedAt.Int64
|
||||
applied++
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
c.Logger.Warn("failed to read lifecycle deletions", "vcenter", vcenter, "error", err)
|
||||
}
|
||||
c.Logger.Debug("lifecycle cache deletions applied", "vcenter", vcenter, "window_start", start, "window_end", end, "scanned", scanned, "applied", applied, "missed", missed)
|
||||
totalApplied += applied
|
||||
}
|
||||
return totalApplied
|
||||
}
|
||||
|
||||
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
|
||||
sumVcpu int64
|
||||
sumRam int64
|
||||
sumDisk float64
|
||||
samples int64
|
||||
tinHits int64
|
||||
bronzeHits int64
|
||||
silverHits int64
|
||||
goldHits int64
|
||||
func (c *CronTask) applyInventoryDeletions(ctx context.Context, agg map[dailyAggKey]*dailyAggVal, start, end time.Time) int {
|
||||
dbConn := c.Database.DB()
|
||||
vcenters := make(map[string]struct{}, 8)
|
||||
for k := range agg {
|
||||
if k.Vcenter != "" {
|
||||
vcenters[k.Vcenter] = struct{}{}
|
||||
}
|
||||
}
|
||||
totalApplied := 0
|
||||
for vcenter := range vcenters {
|
||||
inventoryRows, err := queries.New(dbConn).GetInventoryByVcenter(ctx, vcenter)
|
||||
if err != nil {
|
||||
c.Logger.Warn("failed to load inventory for daily deletion times", "vcenter", vcenter, "error", err)
|
||||
continue
|
||||
}
|
||||
byID := make(map[string]int64, len(inventoryRows))
|
||||
byUUID := make(map[string]int64, len(inventoryRows))
|
||||
byName := make(map[string]int64, len(inventoryRows))
|
||||
for _, inv := range inventoryRows {
|
||||
if !inv.DeletionTime.Valid || inv.DeletionTime.Int64 <= 0 {
|
||||
continue
|
||||
}
|
||||
if inv.DeletionTime.Int64 < start.Unix() || inv.DeletionTime.Int64 >= end.Unix() {
|
||||
continue
|
||||
}
|
||||
if inv.VmId.Valid && strings.TrimSpace(inv.VmId.String) != "" {
|
||||
byID[strings.TrimSpace(inv.VmId.String)] = inv.DeletionTime.Int64
|
||||
}
|
||||
if inv.VmUuid.Valid && strings.TrimSpace(inv.VmUuid.String) != "" {
|
||||
byUUID[strings.TrimSpace(inv.VmUuid.String)] = inv.DeletionTime.Int64
|
||||
}
|
||||
if strings.TrimSpace(inv.Name) != "" {
|
||||
byName[strings.ToLower(strings.TrimSpace(inv.Name))] = inv.DeletionTime.Int64
|
||||
}
|
||||
}
|
||||
for k, v := range agg {
|
||||
if k.Vcenter != vcenter {
|
||||
continue
|
||||
}
|
||||
if ts, ok := byID[k.VmId]; ok {
|
||||
if v.deletion == 0 {
|
||||
v.deletion = ts
|
||||
totalApplied++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ts, ok := byUUID[k.VmUuid]; ok {
|
||||
if v.deletion == 0 {
|
||||
v.deletion = ts
|
||||
totalApplied++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ts, ok := byName[strings.ToLower(k.Name)]; ok {
|
||||
if v.deletion == 0 {
|
||||
v.deletion = ts
|
||||
totalApplied++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return totalApplied
|
||||
}
|
||||
|
||||
func (c *CronTask) scanHourlyTablesParallel(ctx context.Context, snapshots []report.SnapshotRecord, totalSamples int) (map[dailyAggKey]*dailyAggVal, error) {
|
||||
func (c *CronTask) scanHourlyTablesParallel(ctx context.Context, snapshots []report.SnapshotRecord) (map[dailyAggKey]*dailyAggVal, error) {
|
||||
agg := make(map[dailyAggKey]*dailyAggVal, 1024)
|
||||
mu := sync.Mutex{}
|
||||
workers := runtime.NumCPU()
|
||||
@@ -346,6 +670,9 @@ func mergeDailyAgg(dst, src *dailyAggVal) {
|
||||
dst.isTemplate = src.isTemplate
|
||||
dst.poweredOn = src.poweredOn
|
||||
dst.srmPlaceholder = src.srmPlaceholder
|
||||
dst.lastDisk = src.lastDisk
|
||||
dst.lastVcpu = src.lastVcpu
|
||||
dst.lastRam = src.lastRam
|
||||
}
|
||||
dst.sumVcpu += src.sumVcpu
|
||||
dst.sumRam += src.sumRam
|
||||
@@ -355,6 +682,12 @@ func mergeDailyAgg(dst, src *dailyAggVal) {
|
||||
dst.bronzeHits += src.bronzeHits
|
||||
dst.silverHits += src.silverHits
|
||||
dst.goldHits += src.goldHits
|
||||
if dst.seen == nil {
|
||||
dst.seen = make(map[int64]struct{}, len(src.seen))
|
||||
}
|
||||
for t := range src.seen {
|
||||
dst.seen[t] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CronTask) scanHourlyTable(ctx context.Context, snap report.SnapshotRecord) (map[dailyAggKey]*dailyAggVal, error) {
|
||||
@@ -428,6 +761,9 @@ FROM %s
|
||||
creation: int64OrZero(creation),
|
||||
firstSeen: int64OrZero(snapshotTime),
|
||||
lastSeen: int64OrZero(snapshotTime),
|
||||
lastDisk: disk.Float64,
|
||||
lastVcpu: vcpu.Int64,
|
||||
lastRam: ram.Int64,
|
||||
sumVcpu: vcpu.Int64,
|
||||
sumRam: ram.Int64,
|
||||
sumDisk: disk.Float64,
|
||||
@@ -436,13 +772,124 @@ FROM %s
|
||||
bronzeHits: hitBronze,
|
||||
silverHits: hitSilver,
|
||||
goldHits: hitGold,
|
||||
seen: map[int64]struct{}{int64OrZero(snapshotTime): {}},
|
||||
}
|
||||
if deletion.Valid && deletion.Int64 > 0 {
|
||||
row.deletion = deletion.Int64
|
||||
}
|
||||
out[key] = row
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *CronTask) insertDailyAggregates(ctx context.Context, table string, agg map[dailyAggKey]*dailyAggVal, totalSamples int) error {
|
||||
// scanHourlyCache aggregates directly from vm_hourly_stats when available.
|
||||
func (c *CronTask) scanHourlyCache(ctx context.Context, start, end time.Time) (map[dailyAggKey]*dailyAggVal, []int64, error) {
|
||||
dbConn := c.Database.DB()
|
||||
query := `
|
||||
SELECT
|
||||
"Name","Vcenter","VmId","VmUuid","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("IsTemplate",'') AS is_template,
|
||||
COALESCE("PoweredOn",'') AS powered_on,
|
||||
COALESCE("SrmPlaceholder",'') AS srm_placeholder,
|
||||
"SnapshotTime"
|
||||
FROM vm_hourly_stats
|
||||
WHERE "SnapshotTime" >= ? AND "SnapshotTime" < ?`
|
||||
q := dbConn.Rebind(query)
|
||||
rows, err := dbConn.QueryxContext(ctx, q, start.Unix(), end.Unix())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
agg := make(map[dailyAggKey]*dailyAggVal, 512)
|
||||
timeSet := make(map[int64]struct{}, 64)
|
||||
for rows.Next() {
|
||||
var (
|
||||
name, vcenter, vmId, vmUuid, resourcePool string
|
||||
dc, cluster, folder sql.NullString
|
||||
disk sql.NullFloat64
|
||||
vcpu, ram sql.NullInt64
|
||||
creation, deletion, snapshotTime sql.NullInt64
|
||||
isTemplate, poweredOn, srmPlaceholder sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&name, &vcenter, &vmId, &vmUuid, &resourcePool, &dc, &cluster, &folder, &disk, &vcpu, &ram, &creation, &deletion, &isTemplate, &poweredOn, &srmPlaceholder, &snapshotTime); err != nil {
|
||||
continue
|
||||
}
|
||||
if vcenter == "" {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(isTemplate.String), "true") || isTemplate.String == "1" {
|
||||
continue
|
||||
}
|
||||
key := dailyAggKey{
|
||||
Vcenter: vcenter,
|
||||
VmId: strings.TrimSpace(vmId),
|
||||
VmUuid: strings.TrimSpace(vmUuid),
|
||||
Name: strings.TrimSpace(name),
|
||||
}
|
||||
if key.VmId == "" && key.VmUuid == "" && key.Name == "" {
|
||||
continue
|
||||
}
|
||||
if key.VmId == "" {
|
||||
key.VmId = key.VmUuid
|
||||
}
|
||||
pool := strings.ToLower(strings.TrimSpace(resourcePool))
|
||||
hitTin := btoi(pool == "tin")
|
||||
hitBronze := btoi(pool == "bronze")
|
||||
hitSilver := btoi(pool == "silver")
|
||||
hitGold := btoi(pool == "gold")
|
||||
|
||||
snapTs := int64OrZero(snapshotTime)
|
||||
timeSet[snapTs] = struct{}{}
|
||||
|
||||
row := &dailyAggVal{
|
||||
key: key,
|
||||
resourcePool: resourcePool,
|
||||
datacenter: dc.String,
|
||||
cluster: cluster.String,
|
||||
folder: folder.String,
|
||||
isTemplate: isTemplate.String,
|
||||
poweredOn: poweredOn.String,
|
||||
srmPlaceholder: srmPlaceholder.String,
|
||||
creation: int64OrZero(creation),
|
||||
firstSeen: snapTs,
|
||||
lastSeen: snapTs,
|
||||
lastDisk: disk.Float64,
|
||||
lastVcpu: vcpu.Int64,
|
||||
lastRam: ram.Int64,
|
||||
sumVcpu: vcpu.Int64,
|
||||
sumRam: ram.Int64,
|
||||
sumDisk: disk.Float64,
|
||||
samples: 1,
|
||||
tinHits: hitTin,
|
||||
bronzeHits: hitBronze,
|
||||
silverHits: hitSilver,
|
||||
goldHits: hitGold,
|
||||
seen: map[int64]struct{}{snapTs: {}},
|
||||
}
|
||||
if deletion.Valid && deletion.Int64 > 0 {
|
||||
row.deletion = deletion.Int64
|
||||
}
|
||||
if existing, ok := agg[key]; ok {
|
||||
mergeDailyAgg(existing, row)
|
||||
} else {
|
||||
agg[key] = row
|
||||
}
|
||||
}
|
||||
snapTimes := make([]int64, 0, len(timeSet))
|
||||
for t := range timeSet {
|
||||
snapTimes = append(snapTimes, t)
|
||||
}
|
||||
sort.Slice(snapTimes, func(i, j int) bool { return snapTimes[i] < snapTimes[j] })
|
||||
return agg, snapTimes, rows.Err()
|
||||
}
|
||||
|
||||
func (c *CronTask) insertDailyAggregates(ctx context.Context, table string, agg map[dailyAggKey]*dailyAggVal, totalSamples int, totalSamplesByVcenter map[string]int) error {
|
||||
dbConn := c.Database.DB()
|
||||
tx, err := dbConn.Beginx()
|
||||
if err != nil {
|
||||
@@ -451,12 +898,12 @@ func (c *CronTask) insertDailyAggregates(ctx context.Context, table string, agg
|
||||
defer tx.Rollback()
|
||||
|
||||
driver := strings.ToLower(dbConn.DriverName())
|
||||
placeholders := makePlaceholders(driver, 29)
|
||||
placeholders := makePlaceholders(driver, 30)
|
||||
insert := fmt.Sprintf(`
|
||||
INSERT INTO %s (
|
||||
"Name","Vcenter","VmId","VmUuid","ResourcePool","Datacenter","Cluster","Folder",
|
||||
"ProvisionedDisk","VcpuCount","RamGB","IsTemplate","PoweredOn","SrmPlaceholder",
|
||||
"CreationTime","DeletionTime","SamplesPresent","AvgVcpuCount","AvgRamGB","AvgProvisionedDisk",
|
||||
"CreationTime","DeletionTime","SnapshotTime","SamplesPresent","AvgVcpuCount","AvgRamGB","AvgProvisionedDisk",
|
||||
"AvgIsPresent","PoolTinPct","PoolBronzePct","PoolSilverPct","PoolGoldPct","Tin","Bronze","Silver","Gold"
|
||||
) VALUES (%s)
|
||||
`, table, placeholders)
|
||||
@@ -465,10 +912,14 @@ INSERT INTO %s (
|
||||
if v.samples == 0 {
|
||||
continue
|
||||
}
|
||||
avgVcpu := float64(v.sumVcpu) / float64(v.samples)
|
||||
avgRam := float64(v.sumRam) / float64(v.samples)
|
||||
avgDisk := v.sumDisk / float64(v.samples)
|
||||
total := float64(totalSamples)
|
||||
vcTotal := totalSamplesByVcenter[v.key.Vcenter]
|
||||
if vcTotal <= 0 {
|
||||
vcTotal = totalSamples
|
||||
}
|
||||
total := float64(vcTotal)
|
||||
avgVcpu := 0.0
|
||||
avgRam := 0.0
|
||||
avgDisk := 0.0
|
||||
avgPresent := 0.0
|
||||
tinPct := 0.0
|
||||
bronzePct := 0.0
|
||||
@@ -476,10 +927,15 @@ INSERT INTO %s (
|
||||
goldPct := 0.0
|
||||
if total > 0 {
|
||||
avgPresent = float64(v.samples) / total
|
||||
tinPct = float64(v.tinHits) * 100 / total
|
||||
bronzePct = float64(v.bronzeHits) * 100 / total
|
||||
silverPct = float64(v.silverHits) * 100 / total
|
||||
goldPct = float64(v.goldHits) * 100 / total
|
||||
avgVcpu = float64(v.sumVcpu) / total
|
||||
avgRam = float64(v.sumRam) / total
|
||||
avgDisk = v.sumDisk / total
|
||||
}
|
||||
if v.samples > 0 {
|
||||
tinPct = float64(v.tinHits) * 100 / float64(v.samples)
|
||||
bronzePct = float64(v.bronzeHits) * 100 / float64(v.samples)
|
||||
silverPct = float64(v.silverHits) * 100 / float64(v.samples)
|
||||
goldPct = float64(v.goldHits) * 100 / float64(v.samples)
|
||||
}
|
||||
args := []interface{}{
|
||||
v.key.Name,
|
||||
@@ -490,21 +946,22 @@ INSERT INTO %s (
|
||||
nullIfEmpty(v.datacenter),
|
||||
nullIfEmpty(v.cluster),
|
||||
nullIfEmpty(v.folder),
|
||||
v.sumDisk,
|
||||
v.sumVcpu,
|
||||
v.sumRam,
|
||||
v.lastDisk,
|
||||
v.lastVcpu,
|
||||
v.lastRam,
|
||||
v.isTemplate,
|
||||
v.poweredOn,
|
||||
v.srmPlaceholder,
|
||||
v.creation,
|
||||
int64(0), // deletion time refined later
|
||||
v.deletion,
|
||||
v.lastSeen,
|
||||
v.samples,
|
||||
avgVcpu,
|
||||
avgRam,
|
||||
avgDisk,
|
||||
avgPresent,
|
||||
tinPct, bronzePct, silverPct, goldPct,
|
||||
float64(v.tinHits), float64(v.bronzeHits), float64(v.silverHits), float64(v.goldHits),
|
||||
tinPct, bronzePct, silverPct, goldPct,
|
||||
}
|
||||
if driver != "sqlite" {
|
||||
// Postgres expects primitive types, nulls are handled by pq via nil.
|
||||
@@ -556,3 +1013,170 @@ func btoi(b bool) int64 {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func logMissingCreationSummary(ctx context.Context, logger *slog.Logger, database db.Database, summaryTable string, totalRows int64) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
if err := db.ValidateTableName(summaryTable); err != nil {
|
||||
logger.Warn("daily summary creation diagnostics skipped (invalid table)", "table", summaryTable, "error", err)
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
diagCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
dbConn := database.DB()
|
||||
var missingTotal int64
|
||||
countQuery := fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE "CreationTime" IS NULL OR "CreationTime" = 0`, summaryTable)
|
||||
if err := dbConn.GetContext(diagCtx, &missingTotal, countQuery); err != nil {
|
||||
logger.Warn("daily summary creation diagnostics failed", "table", summaryTable, "error", err)
|
||||
return
|
||||
}
|
||||
if missingTotal == 0 {
|
||||
logger.Debug("daily summary creation diagnostics", "table", summaryTable, "missing_creation", 0)
|
||||
return
|
||||
}
|
||||
missingPct := 0.0
|
||||
if totalRows > 0 {
|
||||
missingPct = float64(missingTotal) * 100 / float64(totalRows)
|
||||
}
|
||||
logger.Warn("daily summary rows missing CreationTime",
|
||||
"table", summaryTable,
|
||||
"missing_count", missingTotal,
|
||||
"total_rows", totalRows,
|
||||
"missing_pct", missingPct,
|
||||
)
|
||||
|
||||
byVcenterQuery := fmt.Sprintf(`
|
||||
SELECT "Vcenter", COUNT(*)
|
||||
FROM %s
|
||||
WHERE "CreationTime" IS NULL OR "CreationTime" = 0
|
||||
GROUP BY "Vcenter"
|
||||
ORDER BY COUNT(*) DESC
|
||||
`, summaryTable)
|
||||
if rows, err := dbConn.QueryxContext(diagCtx, byVcenterQuery); err != nil {
|
||||
logger.Warn("daily summary creation diagnostics (by vcenter) failed", "table", summaryTable, "error", err)
|
||||
} else {
|
||||
for rows.Next() {
|
||||
var vcenter string
|
||||
var count int64
|
||||
if err := rows.Scan(&vcenter, &count); err != nil {
|
||||
continue
|
||||
}
|
||||
logger.Warn("daily summary rows missing CreationTime by vcenter", "table", summaryTable, "vcenter", vcenter, "missing_count", count)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
logger.Warn("daily summary creation diagnostics (by vcenter) iteration failed", "table", summaryTable, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
const sampleLimit = 10
|
||||
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
|
||||
`, summaryTable, sampleLimit)
|
||||
if rows, err := dbConn.QueryxContext(diagCtx, sampleQuery); err != nil {
|
||||
logger.Warn("daily summary creation diagnostics (sample) failed", "table", summaryTable, "error", err)
|
||||
} else {
|
||||
for rows.Next() {
|
||||
var (
|
||||
vcenter string
|
||||
vmId, vmUuid sql.NullString
|
||||
name sql.NullString
|
||||
samplesPresent, snapshotTime sql.NullInt64
|
||||
avgIsPresent sql.NullFloat64
|
||||
)
|
||||
if err := rows.Scan(&vcenter, &vmId, &vmUuid, &name, &samplesPresent, &avgIsPresent, &snapshotTime); err != nil {
|
||||
continue
|
||||
}
|
||||
logger.Debug("daily summary missing CreationTime sample",
|
||||
"table", summaryTable,
|
||||
"vcenter", vcenter,
|
||||
"vm_id", vmId.String,
|
||||
"vm_uuid", vmUuid.String,
|
||||
"name", name.String,
|
||||
"samples_present", samplesPresent.Int64,
|
||||
"avg_is_present", avgIsPresent.Float64,
|
||||
"snapshot_time", snapshotTime.Int64,
|
||||
)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
logger.Warn("daily summary creation diagnostics (sample) iteration failed", "table", summaryTable, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// persistDailyRollup stores per-day aggregates into vm_daily_rollup to speed monthly aggregation.
|
||||
func (c *CronTask) persistDailyRollup(ctx context.Context, dayUnix int64, agg map[dailyAggKey]*dailyAggVal, totalSamples int, totalSamplesByVcenter map[string]int) error {
|
||||
dbConn := c.Database.DB()
|
||||
for _, v := range agg {
|
||||
if strings.EqualFold(strings.TrimSpace(v.isTemplate), "true") || v.isTemplate == "1" {
|
||||
continue
|
||||
}
|
||||
vcTotal := totalSamplesByVcenter[v.key.Vcenter]
|
||||
if vcTotal <= 0 {
|
||||
vcTotal = totalSamples
|
||||
}
|
||||
row := db.VmDailyRollupRow{
|
||||
Vcenter: v.key.Vcenter,
|
||||
VmId: v.key.VmId,
|
||||
VmUuid: v.key.VmUuid,
|
||||
Name: v.key.Name,
|
||||
CreationTime: v.creation,
|
||||
DeletionTime: v.deletion,
|
||||
SamplesPresent: v.samples,
|
||||
TotalSamples: int64(vcTotal),
|
||||
SumVcpu: float64(v.sumVcpu),
|
||||
SumRam: float64(v.sumRam),
|
||||
SumDisk: v.sumDisk,
|
||||
TinHits: v.tinHits,
|
||||
BronzeHits: v.bronzeHits,
|
||||
SilverHits: v.silverHits,
|
||||
GoldHits: v.goldHits,
|
||||
LastResourcePool: v.resourcePool,
|
||||
LastDatacenter: v.datacenter,
|
||||
LastCluster: v.cluster,
|
||||
LastFolder: v.folder,
|
||||
LastProvisionedDisk: v.lastDisk,
|
||||
LastVcpuCount: v.lastVcpu,
|
||||
LastRamGB: v.lastRam,
|
||||
IsTemplate: v.isTemplate,
|
||||
PoweredOn: v.poweredOn,
|
||||
SrmPlaceholder: v.srmPlaceholder,
|
||||
}
|
||||
if err := db.UpsertVmDailyRollup(ctx, dbConn, dayUnix, row); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sampleCountsByVcenter(agg map[dailyAggKey]*dailyAggVal) map[string]int {
|
||||
vcenterTimes := make(map[string]map[int64]struct{}, 8)
|
||||
for _, v := range agg {
|
||||
if v.key.Vcenter == "" {
|
||||
continue
|
||||
}
|
||||
set := vcenterTimes[v.key.Vcenter]
|
||||
if set == nil {
|
||||
set = make(map[int64]struct{}, len(v.seen))
|
||||
vcenterTimes[v.key.Vcenter] = set
|
||||
}
|
||||
for t := range v.seen {
|
||||
set[t] = struct{}{}
|
||||
}
|
||||
}
|
||||
counts := make(map[string]int, len(vcenterTimes))
|
||||
for vc, set := range vcenterTimes {
|
||||
counts[vc] = len(set)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
191
internal/tasks/inventoryDatabase.go
Normal file
191
internal/tasks/inventoryDatabase.go
Normal 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
|
||||
}
|
||||
548
internal/tasks/inventoryHelpers.go
Normal file
548
internal/tasks/inventoryHelpers.go
Normal 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
|
||||
}
|
||||
290
internal/tasks/inventoryLifecycle.go
Normal file
290
internal/tasks/inventoryLifecycle.go
Normal 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
@@ -3,11 +3,9 @@ package tasks
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
"vctp/db/queries"
|
||||
@@ -110,7 +108,7 @@ func (c *CronTask) RunVcenterPoll(ctx context.Context, logger *slog.Logger) erro
|
||||
}
|
||||
}
|
||||
c.Logger.Debug("Finished checking vcenter", "url", url)
|
||||
vc.Logout()
|
||||
_ = vc.Logout(ctx)
|
||||
}
|
||||
|
||||
c.Logger.Debug("Finished polling vcenters")
|
||||
@@ -130,8 +128,6 @@ func (c *CronTask) UpdateVmInventory(vmObj *mo.VirtualMachine, vc *vcenter.Vcent
|
||||
existingUpdateFound bool
|
||||
)
|
||||
|
||||
// TODO - how to prevent creating a new record every polling cycle?
|
||||
|
||||
params := queries.CreateUpdateParams{
|
||||
InventoryId: sql.NullInt64{Int64: dbVm.Iid, Valid: dbVm.Iid > 0},
|
||||
}
|
||||
@@ -181,12 +177,8 @@ func (c *CronTask) UpdateVmInventory(vmObj *mo.VirtualMachine, vc *vcenter.Vcent
|
||||
}
|
||||
}
|
||||
|
||||
// TODO - should we bother to check if disk space has changed?
|
||||
|
||||
if updateType != "unknown" {
|
||||
|
||||
// TODO query updates table to see if there is already an update of this type and the new value
|
||||
|
||||
// Check if we already have an existing update record for this same change
|
||||
checkParams := queries.GetVmUpdatesParams{
|
||||
InventoryId: sql.NullInt64{Int64: dbVm.Iid, Valid: dbVm.Iid > 0},
|
||||
UpdateType: updateType,
|
||||
@@ -241,7 +233,6 @@ func (c *CronTask) UpdateVmInventory(vmObj *mo.VirtualMachine, vc *vcenter.Vcent
|
||||
// add sleep to slow down mass VM additions
|
||||
utils.SleepWithContext(ctx, (10 * time.Millisecond))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -409,6 +400,7 @@ func (c *CronTask) AddVmToInventory(vmObject *mo.VirtualMachine, vc *vcenter.Vce
|
||||
return nil
|
||||
}
|
||||
|
||||
/*
|
||||
// prettyPrint comes from https://gist.github.com/sfate/9d45f6c5405dc4c9bf63bf95fe6d1a7c
|
||||
func prettyPrint(args ...interface{}) {
|
||||
var caller string
|
||||
@@ -436,3 +428,4 @@ func prettyPrint(args ...interface{}) {
|
||||
fmt.Printf("%s%s\n", prefix, string(s))
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -2,8 +2,14 @@ package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"vctp/db"
|
||||
"vctp/internal/metrics"
|
||||
@@ -35,19 +41,49 @@ func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time
|
||||
return err
|
||||
}
|
||||
|
||||
granularity := strings.ToLower(strings.TrimSpace(c.Settings.Values.Settings.MonthlyAggregationGranularity))
|
||||
if granularity == "" {
|
||||
granularity = "hourly"
|
||||
}
|
||||
if granularity != "hourly" && granularity != "daily" {
|
||||
c.Logger.Warn("unknown monthly aggregation granularity; defaulting to hourly", "granularity", granularity)
|
||||
granularity = "hourly"
|
||||
}
|
||||
|
||||
monthStart := time.Date(targetMonth.Year(), targetMonth.Month(), 1, 0, 0, 0, 0, targetMonth.Location())
|
||||
monthEnd := monthStart.AddDate(0, 1, 0)
|
||||
dailySnapshots, err := report.SnapshotRecordsWithFallback(ctx, c.Database, "daily", "inventory_daily_summary_", "20060102", monthStart, monthEnd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dailySnapshots = filterRecordsInRange(dailySnapshots, monthStart, monthEnd)
|
||||
|
||||
dbConn := c.Database.DB()
|
||||
db.SetPostgresWorkMem(ctx, dbConn, c.Settings.Values.Settings.PostgresWorkMemMB)
|
||||
dailySnapshots = filterSnapshotsWithRows(ctx, dbConn, dailySnapshots)
|
||||
if len(dailySnapshots) == 0 {
|
||||
return fmt.Errorf("no hourly snapshot tables found for %s", targetMonth.Format("2006-01"))
|
||||
driver := strings.ToLower(dbConn.DriverName())
|
||||
useGoAgg := os.Getenv("MONTHLY_AGG_GO") == "1"
|
||||
if !useGoAgg && granularity == "hourly" && driver == "sqlite" {
|
||||
c.Logger.Warn("SQL monthly aggregation is slow on sqlite; overriding to Go path", "granularity", granularity)
|
||||
useGoAgg = true
|
||||
}
|
||||
|
||||
var snapshots []report.SnapshotRecord
|
||||
var unionColumns []string
|
||||
if granularity == "daily" {
|
||||
dailySnapshots, err := report.SnapshotRecordsWithFallback(ctx, c.Database, "daily", "inventory_daily_summary_", "20060102", monthStart, monthEnd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dailySnapshots = filterRecordsInRange(dailySnapshots, monthStart, monthEnd)
|
||||
dailySnapshots = filterSnapshotsWithRows(ctx, dbConn, dailySnapshots)
|
||||
snapshots = dailySnapshots
|
||||
unionColumns = monthlyUnionColumns
|
||||
} else {
|
||||
hourlySnapshots, err := report.SnapshotRecordsWithFallback(ctx, c.Database, "hourly", "inventory_hourly_", "epoch", monthStart, monthEnd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hourlySnapshots = filterRecordsInRange(hourlySnapshots, monthStart, monthEnd)
|
||||
hourlySnapshots = filterSnapshotsWithRows(ctx, dbConn, hourlySnapshots)
|
||||
snapshots = hourlySnapshots
|
||||
unionColumns = summaryUnionColumns
|
||||
}
|
||||
if len(snapshots) == 0 {
|
||||
return fmt.Errorf("no %s snapshot tables found for %s", granularity, targetMonth.Format("2006-01"))
|
||||
}
|
||||
|
||||
monthlyTable, err := monthlySummaryTableName(targetMonth)
|
||||
@@ -69,11 +105,36 @@ func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time
|
||||
}
|
||||
}
|
||||
|
||||
dailyTables := make([]string, 0, len(dailySnapshots))
|
||||
for _, snapshot := range dailySnapshots {
|
||||
dailyTables = append(dailyTables, snapshot.TableName)
|
||||
// Optional Go-based aggregation path.
|
||||
if useGoAgg {
|
||||
if granularity == "daily" {
|
||||
c.Logger.Debug("Using go implementation of monthly aggregation (daily)")
|
||||
if err := c.aggregateMonthlySummaryGo(ctx, monthStart, monthEnd, monthlyTable, snapshots); err != nil {
|
||||
c.Logger.Warn("go-based monthly aggregation failed, falling back to SQL path", "error", err)
|
||||
} else {
|
||||
metrics.RecordMonthlyAggregation(time.Since(jobStart), nil)
|
||||
c.Logger.Debug("Finished monthly inventory aggregation (Go path)", "summary_table", monthlyTable)
|
||||
return nil
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
unionQuery, err := buildUnionQuery(dailyTables, summaryUnionColumns, templateExclusionFilter())
|
||||
|
||||
tables := make([]string, 0, len(snapshots))
|
||||
for _, snapshot := range snapshots {
|
||||
tables = append(tables, snapshot.TableName)
|
||||
}
|
||||
unionQuery, err := buildUnionQuery(tables, unionColumns, templateExclusionFilter())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -91,7 +152,12 @@ func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time
|
||||
)
|
||||
}
|
||||
|
||||
insertQuery, err := db.BuildMonthlySummaryInsert(monthlyTable, unionQuery)
|
||||
var insertQuery string
|
||||
if granularity == "daily" {
|
||||
insertQuery, err = db.BuildMonthlySummaryInsert(monthlyTable, unionQuery)
|
||||
} else {
|
||||
insertQuery, err = db.BuildDailySummaryInsert(monthlyTable, unionQuery)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -100,12 +166,13 @@ func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time
|
||||
c.Logger.Error("failed to aggregate monthly inventory", "error", err, "month", targetMonth.Format("2006-01"))
|
||||
return err
|
||||
}
|
||||
// Backfill missing creation times to the start of the month for rows lacking creation info.
|
||||
if _, err := dbConn.ExecContext(ctx,
|
||||
`UPDATE `+monthlyTable+` SET "CreationTime" = $1 WHERE "CreationTime" IS NULL OR "CreationTime" = 0`,
|
||||
monthStart.Unix(),
|
||||
); err != nil {
|
||||
c.Logger.Warn("failed to normalize creation times for monthly summary", "error", err, "table", monthlyTable)
|
||||
if applied, err := db.ApplyLifecycleDeletionToSummary(ctx, dbConn, monthlyTable, monthStart.Unix(), monthEnd.Unix()); err != nil {
|
||||
c.Logger.Warn("failed to apply lifecycle deletions to monthly summary", "error", err, "table", monthlyTable)
|
||||
} else {
|
||||
c.Logger.Info("Monthly aggregation deletion times", "source_lifecycle_cache", applied)
|
||||
}
|
||||
if err := db.UpdateSummaryPresenceByWindow(ctx, dbConn, monthlyTable, monthStart.Unix(), monthEnd.Unix()); err != nil {
|
||||
c.Logger.Warn("failed to update monthly AvgIsPresent from lifecycle window", "error", err, "table", monthlyTable)
|
||||
}
|
||||
rowCount, err := db.TableRowCount(ctx, dbConn, monthlyTable)
|
||||
if err != nil {
|
||||
@@ -131,3 +198,542 @@ func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time
|
||||
func monthlySummaryTableName(t time.Time) (string, error) {
|
||||
return db.SafeTableName(fmt.Sprintf("inventory_monthly_summary_%s", t.Format("200601")))
|
||||
}
|
||||
|
||||
// aggregateMonthlySummaryGoHourly aggregates hourly snapshots directly into the monthly summary table.
|
||||
func (c *CronTask) aggregateMonthlySummaryGoHourly(ctx context.Context, monthStart, monthEnd time.Time, summaryTable string, hourlySnapshots []report.SnapshotRecord) error {
|
||||
jobStart := time.Now()
|
||||
dbConn := c.Database.DB()
|
||||
|
||||
if err := clearTable(ctx, dbConn, summaryTable); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(hourlySnapshots) == 0 {
|
||||
return fmt.Errorf("no hourly snapshot tables found for %s", monthStart.Format("2006-01"))
|
||||
}
|
||||
|
||||
totalSamples := len(hourlySnapshots)
|
||||
var (
|
||||
aggMap map[dailyAggKey]*dailyAggVal
|
||||
snapTimes []int64
|
||||
)
|
||||
|
||||
if db.TableExists(ctx, dbConn, "vm_hourly_stats") {
|
||||
cacheAgg, cacheTimes, cacheErr := c.scanHourlyCache(ctx, monthStart, monthEnd)
|
||||
if cacheErr != nil {
|
||||
c.Logger.Warn("failed to use hourly cache, falling back to table scans", "error", cacheErr)
|
||||
} else if len(cacheAgg) > 0 {
|
||||
c.Logger.Debug("using hourly cache for monthly aggregation", "month", monthStart.Format("2006-01"), "snapshots", len(cacheTimes), "vm_count", len(cacheAgg))
|
||||
aggMap = cacheAgg
|
||||
snapTimes = cacheTimes
|
||||
totalSamples = len(cacheTimes)
|
||||
}
|
||||
}
|
||||
|
||||
if aggMap == nil {
|
||||
var errScan error
|
||||
aggMap, errScan = c.scanHourlyTablesParallel(ctx, hourlySnapshots)
|
||||
if errScan != nil {
|
||||
return errScan
|
||||
}
|
||||
c.Logger.Debug("scanned hourly tables for monthly aggregation", "month", monthStart.Format("2006-01"), "tables", len(hourlySnapshots), "vm_count", len(aggMap))
|
||||
if len(aggMap) == 0 {
|
||||
return fmt.Errorf("no VM records aggregated for %s", monthStart.Format("2006-01"))
|
||||
}
|
||||
|
||||
snapTimes = make([]int64, 0, len(hourlySnapshots))
|
||||
for _, snap := range hourlySnapshots {
|
||||
snapTimes = append(snapTimes, snap.SnapshotTime.Unix())
|
||||
}
|
||||
sort.Slice(snapTimes, func(i, j int) bool { return snapTimes[i] < snapTimes[j] })
|
||||
}
|
||||
|
||||
lifecycleDeletions := c.applyLifecycleDeletions(ctx, aggMap, monthStart, monthEnd)
|
||||
c.Logger.Info("Monthly aggregation deletion times", "source_lifecycle_cache", lifecycleDeletions)
|
||||
|
||||
inventoryDeletions := c.applyInventoryDeletions(ctx, aggMap, monthStart, monthEnd)
|
||||
c.Logger.Info("Monthly aggregation deletion times", "source_inventory", inventoryDeletions)
|
||||
|
||||
if len(snapTimes) > 0 {
|
||||
maxSnap := snapTimes[len(snapTimes)-1]
|
||||
inferredDeletions := 0
|
||||
for _, v := range aggMap {
|
||||
if v.deletion != 0 {
|
||||
continue
|
||||
}
|
||||
consecutiveMisses := 0
|
||||
firstMiss := int64(0)
|
||||
for _, t := range snapTimes {
|
||||
if t <= v.lastSeen {
|
||||
continue
|
||||
}
|
||||
if _, ok := v.seen[t]; ok {
|
||||
consecutiveMisses = 0
|
||||
firstMiss = 0
|
||||
continue
|
||||
}
|
||||
consecutiveMisses++
|
||||
if firstMiss == 0 {
|
||||
firstMiss = t
|
||||
}
|
||||
if consecutiveMisses >= 2 {
|
||||
v.deletion = firstMiss
|
||||
inferredDeletions++
|
||||
break
|
||||
}
|
||||
}
|
||||
if v.deletion == 0 && v.lastSeen < maxSnap && firstMiss > 0 {
|
||||
c.Logger.Debug("pending deletion inference (insufficient consecutive misses)", "vm_id", v.key.VmId, "vm_uuid", v.key.VmUuid, "name", v.key.Name, "last_seen", v.lastSeen, "first_missing_snapshot", firstMiss)
|
||||
}
|
||||
}
|
||||
c.Logger.Info("Monthly aggregation deletion times", "source_inferred", inferredDeletions)
|
||||
}
|
||||
|
||||
totalSamplesByVcenter := sampleCountsByVcenter(aggMap)
|
||||
if err := c.insertDailyAggregates(ctx, summaryTable, aggMap, totalSamples, totalSamplesByVcenter); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.UpdateSummaryPresenceByWindow(ctx, dbConn, summaryTable, monthStart.Unix(), monthEnd.Unix()); err != nil {
|
||||
c.Logger.Warn("failed to update monthly AvgIsPresent from lifecycle window (Go hourly)", "error", err, "table", summaryTable)
|
||||
}
|
||||
|
||||
db.AnalyzeTableIfPostgres(ctx, dbConn, summaryTable)
|
||||
rowCount, err := db.TableRowCount(ctx, dbConn, summaryTable)
|
||||
if err != nil {
|
||||
c.Logger.Warn("unable to count monthly summary rows (Go hourly)", "error", err, "table", summaryTable)
|
||||
}
|
||||
if err := report.RegisterSnapshot(ctx, c.Database, "monthly", summaryTable, monthStart, rowCount); err != nil {
|
||||
c.Logger.Warn("failed to register monthly snapshot (Go hourly)", "error", err, "table", summaryTable)
|
||||
}
|
||||
if err := c.generateReport(ctx, summaryTable); err != nil {
|
||||
c.Logger.Warn("failed to generate monthly report (Go hourly)", "error", err, "table", summaryTable)
|
||||
return err
|
||||
}
|
||||
|
||||
c.Logger.Debug("Finished monthly inventory aggregation (Go hourly)",
|
||||
"summary_table", summaryTable,
|
||||
"duration", time.Since(jobStart),
|
||||
"tables_scanned", len(hourlySnapshots),
|
||||
"rows_written", rowCount,
|
||||
"total_samples", totalSamples,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// aggregateMonthlySummaryGo mirrors the SQL-based monthly aggregation but performs the work in Go,
|
||||
// reading daily summaries in parallel and reducing them to a single monthly summary table.
|
||||
func (c *CronTask) aggregateMonthlySummaryGo(ctx context.Context, monthStart, monthEnd time.Time, summaryTable string, dailySnapshots []report.SnapshotRecord) error {
|
||||
jobStart := time.Now()
|
||||
dbConn := c.Database.DB()
|
||||
|
||||
if err := clearTable(ctx, dbConn, summaryTable); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build union query for lifecycle refinement after inserts.
|
||||
dailyTables := make([]string, 0, len(dailySnapshots))
|
||||
for _, snapshot := range dailySnapshots {
|
||||
dailyTables = append(dailyTables, snapshot.TableName)
|
||||
}
|
||||
unionQuery, err := buildUnionQuery(dailyTables, monthlyUnionColumns, templateExclusionFilter())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aggMap, err := c.scanDailyTablesParallel(ctx, dailySnapshots)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(aggMap) == 0 {
|
||||
cacheAgg, cacheErr := c.scanDailyRollup(ctx, monthStart, monthEnd)
|
||||
if cacheErr == nil && len(cacheAgg) > 0 {
|
||||
aggMap = cacheAgg
|
||||
} else if cacheErr != nil {
|
||||
c.Logger.Warn("failed to read daily rollup cache; using table scan", "error", cacheErr)
|
||||
}
|
||||
}
|
||||
if len(aggMap) == 0 {
|
||||
return fmt.Errorf("no VM records aggregated for %s", monthStart.Format("2006-01"))
|
||||
}
|
||||
|
||||
if err := c.insertMonthlyAggregates(ctx, summaryTable, aggMap); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if applied, err := db.ApplyLifecycleDeletionToSummary(ctx, dbConn, summaryTable, monthStart.Unix(), monthEnd.Unix()); err != nil {
|
||||
c.Logger.Warn("failed to apply lifecycle deletions to monthly summary (Go)", "error", err, "table", summaryTable)
|
||||
} else {
|
||||
c.Logger.Info("Monthly aggregation deletion times", "source_lifecycle_cache", applied)
|
||||
}
|
||||
|
||||
if err := db.RefineCreationDeletionFromUnion(ctx, dbConn, summaryTable, unionQuery); err != nil {
|
||||
c.Logger.Warn("failed to refine creation/deletion times (monthly Go)", "error", err, "table", summaryTable)
|
||||
}
|
||||
if err := db.UpdateSummaryPresenceByWindow(ctx, dbConn, summaryTable, monthStart.Unix(), monthEnd.Unix()); err != nil {
|
||||
c.Logger.Warn("failed to update monthly AvgIsPresent from lifecycle window (Go)", "error", err, "table", summaryTable)
|
||||
}
|
||||
|
||||
db.AnalyzeTableIfPostgres(ctx, dbConn, summaryTable)
|
||||
rowCount, err := db.TableRowCount(ctx, dbConn, summaryTable)
|
||||
if err != nil {
|
||||
c.Logger.Warn("unable to count monthly summary rows", "error", err, "table", summaryTable)
|
||||
}
|
||||
if err := report.RegisterSnapshot(ctx, c.Database, "monthly", summaryTable, monthStart, rowCount); err != nil {
|
||||
c.Logger.Warn("failed to register monthly snapshot", "error", err, "table", summaryTable)
|
||||
}
|
||||
if err := c.generateReport(ctx, summaryTable); err != nil {
|
||||
c.Logger.Warn("failed to generate monthly report (Go)", "error", err, "table", summaryTable)
|
||||
return err
|
||||
}
|
||||
|
||||
c.Logger.Debug("Finished monthly inventory aggregation (Go path)", "summary_table", summaryTable, "duration", time.Since(jobStart))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CronTask) scanDailyTablesParallel(ctx context.Context, snapshots []report.SnapshotRecord) (map[monthlyAggKey]*monthlyAggVal, error) {
|
||||
agg := make(map[monthlyAggKey]*monthlyAggVal, 1024)
|
||||
mu := sync.Mutex{}
|
||||
workers := runtime.NumCPU()
|
||||
if workers < 2 {
|
||||
workers = 2
|
||||
}
|
||||
if workers > len(snapshots) {
|
||||
workers = len(snapshots)
|
||||
}
|
||||
|
||||
jobs := make(chan report.SnapshotRecord, len(snapshots))
|
||||
wg := sync.WaitGroup{}
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for snap := range jobs {
|
||||
rows, err := c.scanDailyTable(ctx, snap)
|
||||
if err != nil {
|
||||
c.Logger.Warn("failed to scan daily summary", "table", snap.TableName, "error", err)
|
||||
continue
|
||||
}
|
||||
mu.Lock()
|
||||
for k, v := range rows {
|
||||
if existing, ok := agg[k]; ok {
|
||||
mergeMonthlyAgg(existing, v)
|
||||
} else {
|
||||
agg[k] = v
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, snap := range snapshots {
|
||||
jobs <- snap
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
return agg, nil
|
||||
}
|
||||
|
||||
func mergeMonthlyAgg(dst, src *monthlyAggVal) {
|
||||
if src.creation > 0 && (dst.creation == 0 || src.creation < dst.creation) {
|
||||
dst.creation = src.creation
|
||||
}
|
||||
// If creation is unknown in all daily summaries, leave it zero for reports (VM trace handles approximation separately).
|
||||
if src.deletion > 0 && (dst.deletion == 0 || src.deletion < dst.deletion) {
|
||||
dst.deletion = src.deletion
|
||||
}
|
||||
if src.lastSnapshot.After(dst.lastSnapshot) {
|
||||
dst.lastSnapshot = src.lastSnapshot
|
||||
if src.inventoryId != 0 {
|
||||
dst.inventoryId = src.inventoryId
|
||||
}
|
||||
dst.resourcePool = src.resourcePool
|
||||
dst.datacenter = src.datacenter
|
||||
dst.cluster = src.cluster
|
||||
dst.folder = src.folder
|
||||
dst.isTemplate = src.isTemplate
|
||||
dst.poweredOn = src.poweredOn
|
||||
dst.srmPlaceholder = src.srmPlaceholder
|
||||
dst.provisioned = src.provisioned
|
||||
dst.vcpuCount = src.vcpuCount
|
||||
dst.ramGB = src.ramGB
|
||||
dst.eventKey = src.eventKey
|
||||
dst.cloudId = src.cloudId
|
||||
}
|
||||
|
||||
dst.samplesPresent += src.samplesPresent
|
||||
dst.totalSamples += src.totalSamples
|
||||
dst.sumVcpu += src.sumVcpu
|
||||
dst.sumRam += src.sumRam
|
||||
dst.sumDisk += src.sumDisk
|
||||
dst.tinWeighted += src.tinWeighted
|
||||
dst.bronzeWeighted += src.bronzeWeighted
|
||||
dst.silverWeighted += src.silverWeighted
|
||||
dst.goldWeighted += src.goldWeighted
|
||||
}
|
||||
|
||||
func (c *CronTask) scanDailyTable(ctx context.Context, snap report.SnapshotRecord) (map[monthlyAggKey]*monthlyAggVal, error) {
|
||||
dbConn := c.Database.DB()
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
"InventoryId",
|
||||
"Name","Vcenter","VmId","VmUuid","EventKey","CloudId","ResourcePool","Datacenter","Cluster","Folder",
|
||||
COALESCE("ProvisionedDisk",0) AS disk,
|
||||
COALESCE("VcpuCount",0) AS vcpu,
|
||||
COALESCE("RamGB",0) AS ram,
|
||||
COALESCE("CreationTime",0) AS creation,
|
||||
COALESCE("DeletionTime",0) AS deletion,
|
||||
COALESCE("SamplesPresent",0) AS samples_present,
|
||||
"AvgVcpuCount","AvgRamGB","AvgProvisionedDisk","AvgIsPresent",
|
||||
"PoolTinPct","PoolBronzePct","PoolSilverPct","PoolGoldPct",
|
||||
"Tin","Bronze","Silver","Gold","IsTemplate","PoweredOn","SrmPlaceholder"
|
||||
FROM %s
|
||||
`, snap.TableName)
|
||||
|
||||
rows, err := dbConn.QueryxContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[monthlyAggKey]*monthlyAggVal, 256)
|
||||
for rows.Next() {
|
||||
var (
|
||||
inventoryId sql.NullInt64
|
||||
name, vcenter, vmId, vmUuid string
|
||||
eventKey, cloudId sql.NullString
|
||||
resourcePool, datacenter, cluster, folder sql.NullString
|
||||
isTemplate, poweredOn, srmPlaceholder sql.NullString
|
||||
disk, avgVcpu, avgRam, avgDisk sql.NullFloat64
|
||||
avgIsPresent sql.NullFloat64
|
||||
poolTin, poolBronze, poolSilver, poolGold sql.NullFloat64
|
||||
tinPct, bronzePct, silverPct, goldPct sql.NullFloat64
|
||||
vcpu, ram sql.NullInt64
|
||||
creation, deletion sql.NullInt64
|
||||
samplesPresent sql.NullInt64
|
||||
)
|
||||
|
||||
if err := rows.Scan(
|
||||
&inventoryId,
|
||||
&name, &vcenter, &vmId, &vmUuid, &eventKey, &cloudId, &resourcePool, &datacenter, &cluster, &folder,
|
||||
&disk, &vcpu, &ram, &creation, &deletion, &samplesPresent,
|
||||
&avgVcpu, &avgRam, &avgDisk, &avgIsPresent,
|
||||
&poolTin, &poolBronze, &poolSilver, &poolGold,
|
||||
&tinPct, &bronzePct, &silverPct, &goldPct,
|
||||
&isTemplate, &poweredOn, &srmPlaceholder,
|
||||
); err != nil {
|
||||
c.Logger.Warn("failed to scan daily summary row", "table", snap.TableName, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
templateVal := strings.TrimSpace(isTemplate.String)
|
||||
if strings.EqualFold(templateVal, "true") || templateVal == "1" {
|
||||
continue
|
||||
}
|
||||
|
||||
key := monthlyAggKey{Vcenter: vcenter, VmId: vmId, VmUuid: vmUuid, Name: name}
|
||||
agg := &monthlyAggVal{
|
||||
key: key,
|
||||
inventoryId: inventoryId.Int64,
|
||||
eventKey: eventKey.String,
|
||||
cloudId: cloudId.String,
|
||||
resourcePool: resourcePool.String,
|
||||
datacenter: datacenter.String,
|
||||
cluster: cluster.String,
|
||||
folder: folder.String,
|
||||
isTemplate: isTemplate.String,
|
||||
poweredOn: poweredOn.String,
|
||||
srmPlaceholder: srmPlaceholder.String,
|
||||
provisioned: disk.Float64,
|
||||
vcpuCount: vcpu.Int64,
|
||||
ramGB: ram.Int64,
|
||||
creation: creation.Int64,
|
||||
deletion: deletion.Int64,
|
||||
lastSnapshot: snap.SnapshotTime,
|
||||
samplesPresent: samplesPresent.Int64,
|
||||
}
|
||||
|
||||
totalSamplesDay := float64(samplesPresent.Int64)
|
||||
if avgIsPresent.Valid && avgIsPresent.Float64 > 0 {
|
||||
totalSamplesDay = float64(samplesPresent.Int64) / avgIsPresent.Float64
|
||||
}
|
||||
agg.totalSamples = totalSamplesDay
|
||||
if avgVcpu.Valid {
|
||||
agg.sumVcpu = avgVcpu.Float64 * totalSamplesDay
|
||||
}
|
||||
if avgRam.Valid {
|
||||
agg.sumRam = avgRam.Float64 * totalSamplesDay
|
||||
}
|
||||
if avgDisk.Valid {
|
||||
agg.sumDisk = avgDisk.Float64 * totalSamplesDay
|
||||
}
|
||||
if poolTin.Valid {
|
||||
agg.tinWeighted = (poolTin.Float64 / 100.0) * totalSamplesDay
|
||||
}
|
||||
if poolBronze.Valid {
|
||||
agg.bronzeWeighted = (poolBronze.Float64 / 100.0) * totalSamplesDay
|
||||
}
|
||||
if poolSilver.Valid {
|
||||
agg.silverWeighted = (poolSilver.Float64 / 100.0) * totalSamplesDay
|
||||
}
|
||||
if poolGold.Valid {
|
||||
agg.goldWeighted = (poolGold.Float64 / 100.0) * totalSamplesDay
|
||||
}
|
||||
|
||||
result[key] = agg
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// scanDailyRollup aggregates monthly data from vm_daily_rollup cache.
|
||||
func (c *CronTask) scanDailyRollup(ctx context.Context, start, end time.Time) (map[monthlyAggKey]*monthlyAggVal, error) {
|
||||
dbConn := c.Database.DB()
|
||||
if !db.TableExists(ctx, dbConn, "vm_daily_rollup") {
|
||||
return map[monthlyAggKey]*monthlyAggVal{}, nil
|
||||
}
|
||||
query := `
|
||||
SELECT
|
||||
"Date","Vcenter","VmId","VmUuid","Name","CreationTime","DeletionTime",
|
||||
"SamplesPresent","TotalSamples","SumVcpu","SumRam","SumDisk",
|
||||
"TinHits","BronzeHits","SilverHits","GoldHits",
|
||||
"LastResourcePool","LastDatacenter","LastCluster","LastFolder",
|
||||
"LastProvisionedDisk","LastVcpuCount","LastRamGB","IsTemplate","PoweredOn","SrmPlaceholder"
|
||||
FROM vm_daily_rollup
|
||||
WHERE "Date" >= ? AND "Date" < ?
|
||||
`
|
||||
bind := dbConn.Rebind(query)
|
||||
rows, err := dbConn.QueryxContext(ctx, bind, start.Unix(), end.Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
agg := make(map[monthlyAggKey]*monthlyAggVal, 512)
|
||||
for rows.Next() {
|
||||
var (
|
||||
date sql.NullInt64
|
||||
vcenter, vmId, vmUuid, name string
|
||||
creation, deletion sql.NullInt64
|
||||
samplesPresent, totalSamples sql.NullInt64
|
||||
sumVcpu, sumRam, sumDisk sql.NullFloat64
|
||||
tinHits, bronzeHits, silverHits, goldHits sql.NullInt64
|
||||
lastPool, lastDc, lastCluster, lastFolder sql.NullString
|
||||
lastDisk, lastVcpu, lastRam sql.NullFloat64
|
||||
isTemplate, poweredOn, srmPlaceholder sql.NullString
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&date, &vcenter, &vmId, &vmUuid, &name, &creation, &deletion,
|
||||
&samplesPresent, &totalSamples, &sumVcpu, &sumRam, &sumDisk,
|
||||
&tinHits, &bronzeHits, &silverHits, &goldHits,
|
||||
&lastPool, &lastDc, &lastCluster, &lastFolder,
|
||||
&lastDisk, &lastVcpu, &lastRam, &isTemplate, &poweredOn, &srmPlaceholder,
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
templateVal := strings.TrimSpace(isTemplate.String)
|
||||
if strings.EqualFold(templateVal, "true") || templateVal == "1" {
|
||||
continue
|
||||
}
|
||||
key := monthlyAggKey{Vcenter: vcenter, VmId: vmId, VmUuid: vmUuid, Name: name}
|
||||
val := &monthlyAggVal{
|
||||
key: key,
|
||||
resourcePool: lastPool.String,
|
||||
datacenter: lastDc.String,
|
||||
cluster: lastCluster.String,
|
||||
folder: lastFolder.String,
|
||||
isTemplate: isTemplate.String,
|
||||
poweredOn: poweredOn.String,
|
||||
srmPlaceholder: srmPlaceholder.String,
|
||||
provisioned: lastDisk.Float64,
|
||||
vcpuCount: int64(lastVcpu.Float64),
|
||||
ramGB: int64(lastRam.Float64),
|
||||
creation: creation.Int64,
|
||||
deletion: deletion.Int64,
|
||||
lastSnapshot: time.Unix(date.Int64, 0),
|
||||
samplesPresent: samplesPresent.Int64,
|
||||
totalSamples: float64(totalSamples.Int64),
|
||||
sumVcpu: sumVcpu.Float64,
|
||||
sumRam: sumRam.Float64,
|
||||
sumDisk: sumDisk.Float64,
|
||||
tinWeighted: float64(tinHits.Int64),
|
||||
bronzeWeighted: float64(bronzeHits.Int64),
|
||||
silverWeighted: float64(silverHits.Int64),
|
||||
goldWeighted: float64(goldHits.Int64),
|
||||
}
|
||||
if existing, ok := agg[key]; ok {
|
||||
mergeMonthlyAgg(existing, val)
|
||||
} else {
|
||||
agg[key] = val
|
||||
}
|
||||
}
|
||||
return agg, rows.Err()
|
||||
}
|
||||
|
||||
func (c *CronTask) insertMonthlyAggregates(ctx context.Context, summaryTable string, aggMap map[monthlyAggKey]*monthlyAggVal) error {
|
||||
dbConn := c.Database.DB()
|
||||
columns := []string{
|
||||
"InventoryId", "Name", "Vcenter", "VmId", "EventKey", "CloudId", "CreationTime", "DeletionTime",
|
||||
"ResourcePool", "Datacenter", "Cluster", "Folder", "ProvisionedDisk", "VcpuCount",
|
||||
"RamGB", "IsTemplate", "PoweredOn", "SrmPlaceholder", "VmUuid", "SamplesPresent",
|
||||
"AvgVcpuCount", "AvgRamGB", "AvgProvisionedDisk", "AvgIsPresent",
|
||||
"PoolTinPct", "PoolBronzePct", "PoolSilverPct", "PoolGoldPct",
|
||||
"Tin", "Bronze", "Silver", "Gold",
|
||||
}
|
||||
placeholders := make([]string, len(columns))
|
||||
for i := range columns {
|
||||
placeholders[i] = "?"
|
||||
}
|
||||
stmtText := fmt.Sprintf(`INSERT INTO %s (%s) VALUES (%s)`, summaryTable, strings.Join(columns, ","), strings.Join(placeholders, ","))
|
||||
stmtText = dbConn.Rebind(stmtText)
|
||||
|
||||
tx, err := dbConn.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.PreparexContext(ctx, stmtText)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, v := range aggMap {
|
||||
inventoryVal := sql.NullInt64{}
|
||||
if v.inventoryId != 0 {
|
||||
inventoryVal = sql.NullInt64{Int64: v.inventoryId, Valid: true}
|
||||
}
|
||||
avgVcpu := sql.NullFloat64{}
|
||||
avgRam := sql.NullFloat64{}
|
||||
avgDisk := sql.NullFloat64{}
|
||||
avgIsPresent := sql.NullFloat64{}
|
||||
tinPct := sql.NullFloat64{}
|
||||
bronzePct := sql.NullFloat64{}
|
||||
silverPct := sql.NullFloat64{}
|
||||
goldPct := sql.NullFloat64{}
|
||||
|
||||
if v.totalSamples > 0 {
|
||||
avgVcpu = sql.NullFloat64{Float64: v.sumVcpu / v.totalSamples, Valid: true}
|
||||
avgRam = sql.NullFloat64{Float64: v.sumRam / v.totalSamples, Valid: true}
|
||||
avgDisk = sql.NullFloat64{Float64: v.sumDisk / v.totalSamples, Valid: true}
|
||||
avgIsPresent = sql.NullFloat64{Float64: float64(v.samplesPresent) / v.totalSamples, Valid: true}
|
||||
tinPct = sql.NullFloat64{Float64: 100.0 * v.tinWeighted / v.totalSamples, Valid: true}
|
||||
bronzePct = sql.NullFloat64{Float64: 100.0 * v.bronzeWeighted / v.totalSamples, Valid: true}
|
||||
silverPct = sql.NullFloat64{Float64: 100.0 * v.silverWeighted / v.totalSamples, Valid: true}
|
||||
goldPct = sql.NullFloat64{Float64: 100.0 * v.goldWeighted / v.totalSamples, Valid: true}
|
||||
}
|
||||
|
||||
if _, err := stmt.ExecContext(ctx,
|
||||
inventoryVal,
|
||||
v.key.Name, v.key.Vcenter, v.key.VmId, v.eventKey, v.cloudId, v.creation, v.deletion,
|
||||
v.resourcePool, v.datacenter, v.cluster, v.folder, v.provisioned, v.vcpuCount, v.ramGB,
|
||||
v.isTemplate, v.poweredOn, v.srmPlaceholder, v.key.VmUuid, v.samplesPresent,
|
||||
avgVcpu, avgRam, avgDisk, avgIsPresent,
|
||||
tinPct, bronzePct, silverPct, goldPct,
|
||||
tinPct, bronzePct, silverPct, goldPct,
|
||||
); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -165,10 +165,7 @@ func (c *CronTask) RunVmCheck(ctx context.Context, logger *slog.Logger) error {
|
||||
poweredOn = "TRUE"
|
||||
}
|
||||
|
||||
err = vc.Logout()
|
||||
if err != nil {
|
||||
c.Logger.Error("unable to logout of vcenter", "error", err)
|
||||
}
|
||||
_ = vc.Logout(ctx)
|
||||
|
||||
if foundVm {
|
||||
c.Logger.Debug("Adding to Inventory table", "vm_name", evt.VmName.String, "vcpus", numVcpus, "ram", numRam, "dc", evt.DatacenterId.String)
|
||||
|
||||
@@ -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
123
internal/tasks/types.go
Normal 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
|
||||
}
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/vmware/govmomi"
|
||||
"github.com/vmware/govmomi/event"
|
||||
"github.com/vmware/govmomi/find"
|
||||
"github.com/vmware/govmomi/object"
|
||||
"github.com/vmware/govmomi/view"
|
||||
@@ -36,6 +38,15 @@ type VmProperties struct {
|
||||
ResourcePool string
|
||||
}
|
||||
|
||||
var clientUserAgent = "vCTP"
|
||||
|
||||
// SetUserAgent customizes the User-Agent used when talking to vCenter.
|
||||
func SetUserAgent(ua string) {
|
||||
if strings.TrimSpace(ua) != "" {
|
||||
clientUserAgent = ua
|
||||
}
|
||||
}
|
||||
|
||||
type HostLookup struct {
|
||||
Cluster string
|
||||
Datacenter string
|
||||
@@ -87,6 +98,9 @@ func (v *Vcenter) Login(vUrl string) error {
|
||||
v.Logger.Error("Unable to connect to vCenter", "error", err)
|
||||
return fmt.Errorf("unable to connect to vCenter : %s", err)
|
||||
}
|
||||
if clientUserAgent != "" {
|
||||
c.Client.UserAgent = clientUserAgent
|
||||
}
|
||||
|
||||
//defer c.Logout(v.ctx)
|
||||
|
||||
@@ -97,22 +111,19 @@ func (v *Vcenter) Login(vUrl string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *Vcenter) Logout() error {
|
||||
//v.Logger.Debug("vcenter logging out")
|
||||
|
||||
if v.ctx == nil {
|
||||
func (v *Vcenter) Logout(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = v.ctx
|
||||
}
|
||||
if ctx == nil {
|
||||
v.Logger.Warn("Nil context, unable to logout")
|
||||
return nil
|
||||
}
|
||||
|
||||
if v.client.Valid() {
|
||||
//v.Logger.Debug("vcenter client is valid. Logging out")
|
||||
return v.client.Logout(v.ctx)
|
||||
} else {
|
||||
v.Logger.Debug("vcenter client is not valid")
|
||||
return nil
|
||||
return v.client.Logout(ctx)
|
||||
}
|
||||
|
||||
v.Logger.Debug("vcenter client is not valid")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *Vcenter) GetAllVmReferences() ([]*object.VirtualMachine, error) {
|
||||
@@ -186,6 +197,205 @@ func (v *Vcenter) GetAllVMsWithProps() ([]mo.VirtualMachine, error) {
|
||||
return vms, nil
|
||||
}
|
||||
|
||||
// FindVmDeletionEvents returns a map of MoRef (VmId) to the deletion event time within the given window.
|
||||
func (v *Vcenter) FindVmDeletionEvents(ctx context.Context, begin, end time.Time) (map[string]time.Time, error) {
|
||||
return v.findVmDeletionEvents(ctx, begin, end, nil)
|
||||
}
|
||||
|
||||
// FindVmDeletionEventsForCandidates returns deletion event times for the provided VM IDs only.
|
||||
func (v *Vcenter) FindVmDeletionEventsForCandidates(ctx context.Context, begin, end time.Time, candidates []string) (map[string]time.Time, error) {
|
||||
if len(candidates) == 0 {
|
||||
return map[string]time.Time{}, nil
|
||||
}
|
||||
candidateSet := make(map[string]struct{}, len(candidates))
|
||||
for _, id := range candidates {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
candidateSet[id] = struct{}{}
|
||||
}
|
||||
if len(candidateSet) == 0 {
|
||||
return map[string]time.Time{}, nil
|
||||
}
|
||||
return v.findVmDeletionEvents(ctx, begin, end, candidateSet)
|
||||
}
|
||||
|
||||
func (v *Vcenter) findVmDeletionEvents(ctx context.Context, begin, end time.Time, candidateSet map[string]struct{}) (map[string]time.Time, error) {
|
||||
result := make(map[string]time.Time)
|
||||
if v.client == nil || !v.client.Valid() {
|
||||
return result, fmt.Errorf("vcenter client is not valid")
|
||||
}
|
||||
// vCenter events are stored in UTC; normalize the query window.
|
||||
beginUTC := begin.UTC()
|
||||
endUTC := end.UTC()
|
||||
mgr := event.NewManager(v.client.Client)
|
||||
|
||||
type deletionHit struct {
|
||||
ts time.Time
|
||||
priority int
|
||||
}
|
||||
const (
|
||||
deletionPriorityRemoved = iota
|
||||
deletionPriorityVmEvent
|
||||
deletionPriorityTask
|
||||
)
|
||||
hits := make(map[string]deletionHit)
|
||||
foundCandidates := 0
|
||||
recordDeletion := func(vmID string, ts time.Time, priority int) {
|
||||
if vmID == "" {
|
||||
return
|
||||
}
|
||||
if candidateSet != nil {
|
||||
if _, ok := candidateSet[vmID]; !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
if prev, ok := hits[vmID]; !ok {
|
||||
hits[vmID] = deletionHit{ts: ts, priority: priority}
|
||||
if candidateSet != nil {
|
||||
foundCandidates++
|
||||
}
|
||||
} else if priority < prev.priority || (priority == prev.priority && ts.Before(prev.ts)) {
|
||||
hits[vmID] = deletionHit{ts: ts, priority: priority}
|
||||
}
|
||||
}
|
||||
|
||||
isDeletionMessage := func(msg string) bool {
|
||||
msg = strings.ToLower(msg)
|
||||
return strings.Contains(msg, "destroy") ||
|
||||
strings.Contains(msg, "deleted") ||
|
||||
strings.Contains(msg, "unregister") ||
|
||||
strings.Contains(msg, "removed from inventory")
|
||||
}
|
||||
|
||||
isVmDeletionTask := func(info types.TaskInfo, msg string) bool {
|
||||
id := strings.ToLower(strings.TrimSpace(info.DescriptionId))
|
||||
if id != "" {
|
||||
if strings.Contains(id, "virtualmachine") &&
|
||||
(strings.Contains(id, "destroy") || strings.Contains(id, "delete") || strings.Contains(id, "unregister")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(info.Name))
|
||||
if name != "" {
|
||||
if (strings.Contains(name, "destroy") || strings.Contains(name, "delete") || strings.Contains(name, "unregister")) &&
|
||||
(strings.Contains(name, "virtualmachine") || strings.Contains(name, "virtual machine")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if msg != "" && isDeletionMessage(msg) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
processEvents := func(evts []types.BaseEvent) {
|
||||
for _, ev := range evts {
|
||||
switch e := ev.(type) {
|
||||
case *types.VmRemovedEvent:
|
||||
if e.Vm != nil {
|
||||
vmID := e.Vm.Vm.Value
|
||||
recordDeletion(vmID, e.CreatedTime, deletionPriorityRemoved)
|
||||
}
|
||||
case *types.TaskEvent:
|
||||
// Fallback for destroy task events.
|
||||
if e.Info.Entity != nil {
|
||||
vmID := e.Info.Entity.Value
|
||||
if vmID != "" && isVmDeletionTask(e.Info, e.GetEvent().FullFormattedMessage) {
|
||||
recordDeletion(vmID, e.CreatedTime, deletionPriorityTask)
|
||||
}
|
||||
}
|
||||
case *types.VmEvent:
|
||||
if e.Vm != nil {
|
||||
vmID := e.Vm.Vm.Value
|
||||
if vmID != "" && isDeletionMessage(e.GetEvent().FullFormattedMessage) {
|
||||
recordDeletion(vmID, e.CreatedTime, deletionPriorityVmEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
eventPageSize = int32(1000)
|
||||
maxEventPages = 25
|
||||
)
|
||||
readCollector := func(label string, collector *event.HistoryCollector) error {
|
||||
pageCount := 0
|
||||
for {
|
||||
events, err := collector.ReadNextEvents(ctx, eventPageSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(events) == 0 {
|
||||
break
|
||||
}
|
||||
processEvents(events)
|
||||
if candidateSet != nil && foundCandidates >= len(candidateSet) {
|
||||
break
|
||||
}
|
||||
pageCount++
|
||||
if pageCount >= maxEventPages {
|
||||
if v.Logger != nil {
|
||||
v.Logger.Warn("vcenter deletion events truncated", "vcenter", v.Vurl, "label", label, "pages", pageCount, "page_size", eventPageSize, "window_start_utc", beginUTC, "window_end_utc", endUTC)
|
||||
}
|
||||
break
|
||||
}
|
||||
if len(events) < int(eventPageSize) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// First attempt: specific deletion event types.
|
||||
disableFullMessage := false
|
||||
filter := types.EventFilterSpec{
|
||||
Time: &types.EventFilterSpecByTime{
|
||||
BeginTime: &beginUTC,
|
||||
EndTime: &endUTC,
|
||||
},
|
||||
DisableFullMessage: &disableFullMessage,
|
||||
EventTypeId: []string{
|
||||
"VmRemovedEvent",
|
||||
"TaskEvent",
|
||||
},
|
||||
}
|
||||
collector, err := mgr.CreateCollectorForEvents(ctx, filter)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("failed to create event collector: %w", err)
|
||||
}
|
||||
defer collector.Destroy(ctx)
|
||||
|
||||
if err := readCollector("primary", collector); err != nil {
|
||||
return result, fmt.Errorf("failed to read events: %w", err)
|
||||
}
|
||||
|
||||
// If nothing found, widen the filter to all event types in the window as a fallback.
|
||||
if len(hits) == 0 {
|
||||
fallbackFilter := types.EventFilterSpec{
|
||||
Time: &types.EventFilterSpecByTime{
|
||||
BeginTime: &beginUTC,
|
||||
EndTime: &endUTC,
|
||||
},
|
||||
DisableFullMessage: &disableFullMessage,
|
||||
}
|
||||
fc, err := mgr.CreateCollectorForEvents(ctx, fallbackFilter)
|
||||
if err == nil {
|
||||
defer fc.Destroy(ctx)
|
||||
if readErr := readCollector("fallback", fc); readErr != nil && v.Logger != nil {
|
||||
v.Logger.Warn("vcenter fallback event read failed", "vcenter", v.Vurl, "error", readErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for vmID, hit := range hits {
|
||||
result[vmID] = hit.ts
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (v *Vcenter) BuildHostLookup() (map[string]HostLookup, error) {
|
||||
finder := find.NewFinder(v.client.Client, true)
|
||||
datacenters, err := finder.DatacenterList(v.ctx, "*")
|
||||
@@ -406,6 +616,10 @@ func (v *Vcenter) GetHostSystemObject(hostRef types.ManagedObjectReference) (*mo
|
||||
|
||||
// Function to find the cluster or compute resource from a host reference
|
||||
func (v *Vcenter) GetClusterFromHost(hostRef *types.ManagedObjectReference) (string, error) {
|
||||
if hostRef == nil {
|
||||
v.Logger.Warn("nil hostRef passed to GetClusterFromHost")
|
||||
return "", nil
|
||||
}
|
||||
// Get the host object
|
||||
host, err := v.GetHostSystemObject(*hostRef)
|
||||
if err != nil {
|
||||
|
||||
45
main.go
45
main.go
@@ -19,8 +19,9 @@ import (
|
||||
"vctp/server/router"
|
||||
|
||||
"crypto/sha256"
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"log/slog"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -37,6 +38,7 @@ const fallbackEncryptionKey = "5L1l3B5KvwOCzUHMAlCgsgUTRAYMfSpa"
|
||||
|
||||
func main() {
|
||||
settingsPath := flag.String("settings", "/etc/dtms/vctp.yml", "Path to settings YAML")
|
||||
runInventory := flag.Bool("run-inventory", false, "Run a single inventory snapshot across all configured vCenters and exit")
|
||||
flag.Parse()
|
||||
|
||||
bootstrapLogger := log.New(log.LevelInfo, log.OutputText)
|
||||
@@ -57,6 +59,8 @@ func main() {
|
||||
)
|
||||
s.Logger = logger
|
||||
|
||||
logger.Info("vCTP starting", "build_time", buildTime, "sha1_version", sha1ver, "go_version", runtime.Version(), "settings_file", *settingsPath)
|
||||
|
||||
// Configure database
|
||||
dbDriver := strings.TrimSpace(s.Values.Settings.DatabaseDriver)
|
||||
if dbDriver == "" {
|
||||
@@ -155,6 +159,13 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Set a recognizable User-Agent for vCenter sessions.
|
||||
ua := "vCTP"
|
||||
if sha1ver != "" {
|
||||
ua = fmt.Sprintf("vCTP/%s", sha1ver)
|
||||
}
|
||||
vcenter.SetUserAgent(ua)
|
||||
|
||||
// Prepare the task scheduler
|
||||
c, err := gocron.NewScheduler()
|
||||
if err != nil {
|
||||
@@ -171,20 +182,25 @@ func main() {
|
||||
FirstHourlySnapshotCheck: true,
|
||||
}
|
||||
|
||||
// One-shot mode: run a single inventory snapshot across all configured vCenters and exit.
|
||||
if *runInventory {
|
||||
logger.Info("Running one-shot inventory snapshot across all vCenters")
|
||||
ct.RunVcenterSnapshotHourly(ctx, logger, true)
|
||||
logger.Info("One-shot inventory snapshot complete; exiting")
|
||||
return
|
||||
}
|
||||
|
||||
cronSnapshotFrequency = durationFromSeconds(s.Values.Settings.VcenterInventorySnapshotSeconds, 3600)
|
||||
logger.Debug("Setting VM inventory snapshot cronjob frequency to", "frequency", cronSnapshotFrequency)
|
||||
|
||||
cronAggregateFrequency = durationFromSeconds(s.Values.Settings.VcenterInventoryAggregateSeconds, 86400)
|
||||
logger.Debug("Setting VM inventory daily aggregation cronjob frequency to", "frequency", cronAggregateFrequency)
|
||||
|
||||
startsAt3 := time.Now().Add(cronSnapshotFrequency)
|
||||
if cronSnapshotFrequency == time.Hour {
|
||||
startsAt3 = time.Now().Truncate(time.Hour).Add(time.Hour)
|
||||
}
|
||||
startsAt3 := alignStart(time.Now(), cronSnapshotFrequency)
|
||||
job3, err := c.NewJob(
|
||||
gocron.DurationJob(cronSnapshotFrequency),
|
||||
gocron.NewTask(func() {
|
||||
ct.RunVcenterSnapshotHourly(ctx, logger)
|
||||
ct.RunVcenterSnapshotHourly(ctx, logger, false)
|
||||
}), gocron.WithSingletonMode(gocron.LimitModeReschedule),
|
||||
gocron.WithStartAt(gocron.WithStartDateTime(startsAt3)),
|
||||
)
|
||||
@@ -212,7 +228,10 @@ func main() {
|
||||
}
|
||||
logger.Debug("Created vcenter inventory aggregation cron job", "job", job4.ID(), "starting_at", startsAt4)
|
||||
|
||||
monthlyCron := "0 0 1 * *"
|
||||
monthlyCron := strings.TrimSpace(s.Values.Settings.MonthlyAggregationCron)
|
||||
if monthlyCron == "" {
|
||||
monthlyCron = "10 3 1 * *"
|
||||
}
|
||||
logger.Debug("Setting monthly aggregation cron schedule", "cron", monthlyCron)
|
||||
job5, err := c.NewJob(
|
||||
gocron.CronJob(monthlyCron, false),
|
||||
@@ -289,6 +308,18 @@ func main() {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// alignStart snaps the first run to a sensible boundary (hour or 15-minute block) when possible.
|
||||
func alignStart(now time.Time, freq time.Duration) time.Time {
|
||||
if freq == time.Hour {
|
||||
return now.Truncate(time.Hour).Add(time.Hour)
|
||||
}
|
||||
quarter := 15 * time.Minute
|
||||
if freq%quarter == 0 {
|
||||
return now.Truncate(quarter).Add(quarter)
|
||||
}
|
||||
return now.Add(freq)
|
||||
}
|
||||
|
||||
func durationFromSeconds(value int, fallback int) time.Duration {
|
||||
if value <= 0 {
|
||||
return time.Second * time.Duration(fallback)
|
||||
|
||||
205
server/handler/dailyCreationDiagnostics.go
Normal file
205
server/handler/dailyCreationDiagnostics.go
Normal 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)
|
||||
}
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param payload body map[string]string true "Plaintext payload"
|
||||
// @Success 200 {object} map[string]string "Ciphertext response"
|
||||
// @Failure 500 {object} map[string]string "Server error"
|
||||
// @Success 200 {object} models.StatusMessageResponse "Ciphertext response"
|
||||
// @Failure 500 {object} models.ErrorResponse "Server error"
|
||||
// @Router /api/encrypt [post]
|
||||
func (h *Handler) EncryptData(w http.ResponseWriter, r *http.Request) {
|
||||
//ctx := context.Background()
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
// @Tags reports
|
||||
// @Produce application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
||||
// @Success 200 {file} file "Inventory XLSX report"
|
||||
// @Failure 500 {object} map[string]string "Report generation failed"
|
||||
// @Failure 500 {object} models.ErrorResponse "Report generation failed"
|
||||
// @Router /api/report/inventory [get]
|
||||
func (h *Handler) InventoryReportDownload(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -48,7 +48,7 @@ func (h *Handler) InventoryReportDownload(w http.ResponseWriter, r *http.Request
|
||||
// @Tags reports
|
||||
// @Produce application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
||||
// @Success 200 {file} file "Updates XLSX report"
|
||||
// @Failure 500 {object} map[string]string "Report generation failed"
|
||||
// @Failure 500 {object} models.ErrorResponse "Report generation failed"
|
||||
// @Router /api/report/updates [get]
|
||||
func (h *Handler) UpdateReportDownload(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"vctp/internal/settings"
|
||||
"vctp/internal/tasks"
|
||||
"vctp/server/models"
|
||||
)
|
||||
|
||||
// SnapshotAggregateForce forces regeneration of a daily or monthly summary table.
|
||||
@@ -16,13 +18,15 @@ import (
|
||||
// @Produce json
|
||||
// @Param type query string true "Aggregation type: daily or monthly"
|
||||
// @Param date query string true "Daily date (YYYY-MM-DD) or monthly date (YYYY-MM)"
|
||||
// @Success 200 {object} map[string]string "Aggregation complete"
|
||||
// @Failure 400 {object} map[string]string "Invalid request"
|
||||
// @Failure 500 {object} map[string]string "Server error"
|
||||
// @Param granularity query string false "Monthly aggregation granularity: hourly or daily"
|
||||
// @Success 200 {object} models.StatusResponse "Aggregation complete"
|
||||
// @Failure 400 {object} models.ErrorResponse "Invalid request"
|
||||
// @Failure 500 {object} models.ErrorResponse "Server error"
|
||||
// @Router /api/snapshots/aggregate [post]
|
||||
func (h *Handler) SnapshotAggregateForce(w http.ResponseWriter, r *http.Request) {
|
||||
snapshotType := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("type")))
|
||||
dateValue := strings.TrimSpace(r.URL.Query().Get("date"))
|
||||
granularity := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("granularity")))
|
||||
startedAt := time.Now()
|
||||
loc := time.Now().Location()
|
||||
|
||||
@@ -35,11 +39,28 @@ func (h *Handler) SnapshotAggregateForce(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if granularity != "" && snapshotType != "monthly" {
|
||||
h.Logger.Debug("Snapshot aggregation ignoring granularity for non-monthly request",
|
||||
"type", snapshotType,
|
||||
"granularity", granularity,
|
||||
)
|
||||
granularity = ""
|
||||
}
|
||||
if snapshotType == "monthly" && granularity != "" && granularity != "hourly" && granularity != "daily" {
|
||||
h.Logger.Warn("Snapshot aggregation invalid granularity", "granularity", granularity)
|
||||
writeJSONError(w, http.StatusBadRequest, "granularity must be hourly or daily")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
settingsCopy := *h.Settings.Values
|
||||
if granularity != "" {
|
||||
settingsCopy.Settings.MonthlyAggregationGranularity = granularity
|
||||
}
|
||||
ct := &tasks.CronTask{
|
||||
Logger: h.Logger,
|
||||
Database: h.Database,
|
||||
Settings: h.Settings,
|
||||
Settings: &settings.Settings{Logger: h.Logger, SettingsPath: h.Settings.SettingsPath, Values: &settingsCopy},
|
||||
}
|
||||
|
||||
switch snapshotType {
|
||||
@@ -63,7 +84,7 @@ func (h *Handler) SnapshotAggregateForce(w http.ResponseWriter, r *http.Request)
|
||||
writeJSONError(w, http.StatusBadRequest, "date must be YYYY-MM")
|
||||
return
|
||||
}
|
||||
h.Logger.Info("Starting monthly snapshot aggregation", "date", parsed.Format("2006-01"), "force", true)
|
||||
h.Logger.Info("Starting monthly snapshot aggregation", "date", parsed.Format("2006-01"), "force", true, "granularity", granularity)
|
||||
if err := ct.AggregateMonthlySummary(ctx, parsed, true); err != nil {
|
||||
h.Logger.Error("Monthly snapshot aggregation failed", "date", parsed.Format("2006-01"), "error", err)
|
||||
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
||||
@@ -78,6 +99,7 @@ func (h *Handler) SnapshotAggregateForce(w http.ResponseWriter, r *http.Request)
|
||||
h.Logger.Info("Snapshot aggregation completed",
|
||||
"type", snapshotType,
|
||||
"date", dateValue,
|
||||
"granularity", granularity,
|
||||
"duration", time.Since(startedAt),
|
||||
)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -90,8 +112,8 @@ func (h *Handler) SnapshotAggregateForce(w http.ResponseWriter, r *http.Request)
|
||||
func writeJSONError(w http.ResponseWriter, status int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ERROR",
|
||||
"message": message,
|
||||
json.NewEncoder(w).Encode(models.ErrorResponse{
|
||||
Status: "ERROR",
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ import (
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param confirm query string true "Confirmation text; must be 'FORCE'"
|
||||
// @Success 200 {object} map[string]string "Snapshot started"
|
||||
// @Failure 400 {object} map[string]string "Invalid request"
|
||||
// @Failure 500 {object} map[string]string "Server error"
|
||||
// @Success 200 {object} models.StatusResponse "Snapshot started"
|
||||
// @Failure 400 {object} models.ErrorResponse "Invalid request"
|
||||
// @Failure 500 {object} models.ErrorResponse "Server error"
|
||||
// @Router /api/snapshots/hourly/force [post]
|
||||
func (h *Handler) SnapshotForceHourly(w http.ResponseWriter, r *http.Request) {
|
||||
confirm := strings.TrimSpace(r.URL.Query().Get("confirm"))
|
||||
@@ -37,7 +37,7 @@ func (h *Handler) SnapshotForceHourly(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
started := time.Now()
|
||||
h.Logger.Info("Manual hourly snapshot requested")
|
||||
if err := ct.RunVcenterSnapshotHourly(ctx, h.Logger.With("manual", true)); err != nil {
|
||||
if err := ct.RunVcenterSnapshotHourly(ctx, h.Logger.With("manual", true), true); err != nil {
|
||||
h.Logger.Error("Manual hourly snapshot failed", "error", err)
|
||||
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
// @Description Rebuilds the snapshot registry from existing tables and renames hourly tables to epoch-based names.
|
||||
// @Tags snapshots
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{} "Migration results"
|
||||
// @Failure 500 {object} map[string]string "Server error"
|
||||
// @Success 200 {object} models.SnapshotMigrationResponse "Migration results"
|
||||
// @Failure 500 {object} models.SnapshotMigrationResponse "Server error"
|
||||
// @Router /api/snapshots/migrate [post]
|
||||
func (h *Handler) SnapshotMigrate(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
// @Description Regenerates XLSX reports for hourly snapshots when the report files are missing or empty.
|
||||
// @Tags snapshots
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{} "Regeneration summary"
|
||||
// @Failure 500 {object} map[string]string "Server error"
|
||||
// @Success 200 {object} models.SnapshotRegenerateReportsResponse "Regeneration summary"
|
||||
// @Failure 500 {object} models.ErrorResponse "Server error"
|
||||
// @Router /api/snapshots/regenerate-hourly-reports [post]
|
||||
func (h *Handler) SnapshotRegenerateHourlyReports(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
219
server/handler/snapshotRepair.go
Normal file
219
server/handler/snapshotRepair.go
Normal 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
|
||||
}
|
||||
@@ -55,8 +55,8 @@ func (h *Handler) SnapshotMonthlyList(w http.ResponseWriter, r *http.Request) {
|
||||
// @Produce application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
||||
// @Param table query string true "Snapshot table name"
|
||||
// @Success 200 {file} file "Snapshot XLSX report"
|
||||
// @Failure 400 {object} map[string]string "Invalid request"
|
||||
// @Failure 500 {object} map[string]string "Server error"
|
||||
// @Failure 400 {object} models.ErrorResponse "Invalid request"
|
||||
// @Failure 500 {object} models.ErrorResponse "Server error"
|
||||
// @Router /api/report/snapshot [get]
|
||||
func (h *Handler) SnapshotReportDownload(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.Background()
|
||||
@@ -118,10 +118,14 @@ func (h *Handler) renderSnapshotList(w http.ResponseWriter, r *http.Request, sna
|
||||
case "monthly":
|
||||
group = record.SnapshotTime.Format("2006")
|
||||
}
|
||||
count := record.SnapshotCount
|
||||
if count < 0 {
|
||||
count = 0
|
||||
}
|
||||
entries = append(entries, views.SnapshotEntry{
|
||||
Label: label,
|
||||
Link: "/reports/" + url.PathEscape(record.TableName) + ".xlsx",
|
||||
Count: record.SnapshotCount,
|
||||
Count: count,
|
||||
Group: group,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
// @Deprecated
|
||||
// @Produce json
|
||||
// @Param vc_url query string true "vCenter URL"
|
||||
// @Success 200 {object} map[string]string "Cleanup completed"
|
||||
// @Failure 400 {object} map[string]string "Invalid request"
|
||||
// @Success 200 {object} models.StatusMessageResponse "Cleanup completed"
|
||||
// @Failure 400 {object} models.ErrorResponse "Invalid request"
|
||||
// @Router /api/cleanup/vcenter [delete]
|
||||
func (h *Handler) VcCleanup(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -17,8 +17,8 @@ import (
|
||||
// @Produce json
|
||||
// @Param vm_id query string true "VM ID"
|
||||
// @Param datacenter_name query string true "Datacenter name"
|
||||
// @Success 200 {object} map[string]string "Cleanup completed"
|
||||
// @Failure 400 {object} map[string]string "Invalid request"
|
||||
// @Success 200 {object} models.StatusMessageResponse "Cleanup completed"
|
||||
// @Failure 400 {object} models.ErrorResponse "Invalid request"
|
||||
// @Router /api/inventory/vm/delete [delete]
|
||||
func (h *Handler) VmCleanup(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -21,8 +21,8 @@ import (
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param import body models.ImportReceived true "Bulk import payload"
|
||||
// @Success 200 {object} map[string]string "Import processed"
|
||||
// @Failure 500 {object} map[string]string "Server error"
|
||||
// @Success 200 {object} models.StatusMessageResponse "Import processed"
|
||||
// @Failure 500 {object} models.ErrorResponse "Server error"
|
||||
// @Router /api/import/vm [post]
|
||||
func (h *Handler) VmImport(w http.ResponseWriter, r *http.Request) {
|
||||
// Read request body
|
||||
|
||||
@@ -27,9 +27,9 @@ import (
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param event body models.CloudEventReceived true "CloudEvent payload"
|
||||
// @Success 200 {object} map[string]string "Modify event processed"
|
||||
// @Success 202 {object} map[string]string "No relevant changes"
|
||||
// @Failure 500 {object} map[string]string "Server error"
|
||||
// @Success 200 {object} models.StatusMessageResponse "Modify event processed"
|
||||
// @Success 202 {object} models.StatusMessageResponse "No relevant changes"
|
||||
// @Failure 500 {object} models.ErrorResponse "Server error"
|
||||
// @Router /api/event/vm/modify [post]
|
||||
func (h *Handler) VmModifyEvent(w http.ResponseWriter, r *http.Request) {
|
||||
var configChanges []map[string]string
|
||||
@@ -404,10 +404,7 @@ func (h *Handler) calculateNewDiskSize(event models.CloudEventReceived) float64
|
||||
}
|
||||
}
|
||||
|
||||
err = vc.Logout()
|
||||
if err != nil {
|
||||
h.Logger.Error("unable to logout of vcenter", "error", err)
|
||||
}
|
||||
_ = vc.Logout(context.Background())
|
||||
|
||||
h.Logger.Debug("Calculated new disk size", "value", diskSize)
|
||||
|
||||
@@ -446,9 +443,7 @@ func (h *Handler) AddVmToInventory(evt models.CloudEventReceived, ctx context.Co
|
||||
|
||||
if strings.HasPrefix(vmObject.Name, "vCLS-") {
|
||||
h.Logger.Info("Skipping internal vCLS VM", "vm_name", vmObject.Name)
|
||||
if err := vc.Logout(); err != nil {
|
||||
h.Logger.Error("unable to logout of vcenter", "error", err)
|
||||
}
|
||||
_ = vc.Logout(ctx)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -522,10 +517,7 @@ func (h *Handler) AddVmToInventory(evt models.CloudEventReceived, ctx context.Co
|
||||
poweredOn = "TRUE"
|
||||
}
|
||||
|
||||
err = vc.Logout()
|
||||
if err != nil {
|
||||
h.Logger.Error("unable to logout of vcenter", "error", err)
|
||||
}
|
||||
_ = vc.Logout(ctx)
|
||||
|
||||
if foundVm {
|
||||
e := evt.CloudEvent
|
||||
|
||||
@@ -22,9 +22,9 @@ import (
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param event body models.CloudEventReceived true "CloudEvent payload"
|
||||
// @Success 200 {object} map[string]string "Move event processed"
|
||||
// @Failure 400 {object} map[string]string "Invalid request"
|
||||
// @Failure 500 {object} map[string]string "Server error"
|
||||
// @Success 200 {object} models.StatusMessageResponse "Move event processed"
|
||||
// @Failure 400 {object} models.ErrorResponse "Invalid request"
|
||||
// @Failure 500 {object} models.ErrorResponse "Server error"
|
||||
// @Router /api/event/vm/move [post]
|
||||
func (h *Handler) VmMoveEvent(w http.ResponseWriter, r *http.Request) {
|
||||
params := queries.CreateUpdateParams{}
|
||||
|
||||
@@ -35,6 +35,7 @@ func (h *Handler) VmTrace(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
creationLabel := ""
|
||||
deletionLabel := ""
|
||||
creationApprox := false
|
||||
|
||||
// Only fetch data when a query is provided; otherwise render empty page with form.
|
||||
if vmID != "" || vmUUID != "" || name != "" {
|
||||
@@ -79,9 +80,17 @@ func (h *Handler) VmTrace(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if len(entries) > 0 {
|
||||
if lifecycle.CreationTime > 0 {
|
||||
creationLabel = time.Unix(lifecycle.CreationTime, 0).Local().Format("2006-01-02 15:04:05")
|
||||
ts := time.Unix(lifecycle.CreationTime, 0).Local().Format("2006-01-02 15:04:05")
|
||||
if lifecycle.CreationApprox {
|
||||
creationLabel = fmt.Sprintf("%s (approx. earliest snapshot)", ts)
|
||||
// dont double up on the approximate text
|
||||
//creationApprox = true
|
||||
} else {
|
||||
creationLabel = ts
|
||||
}
|
||||
} else {
|
||||
creationLabel = time.Unix(entries[0].RawTime, 0).Local().Format("2006-01-02 15:04:05")
|
||||
creationApprox = true
|
||||
}
|
||||
if lifecycle.DeletionTime > 0 {
|
||||
deletionLabel = time.Unix(lifecycle.DeletionTime, 0).Local().Format("2006-01-02 15:04:05")
|
||||
@@ -90,7 +99,7 @@ func (h *Handler) VmTrace(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := views.VmTracePage(queryLabel, displayQuery, vmID, vmUUID, name, creationLabel, deletionLabel, entries, chart).Render(ctx, w); err != nil {
|
||||
if err := views.VmTracePage(queryLabel, displayQuery, vmID, vmUUID, name, creationLabel, deletionLabel, creationApprox, entries, chart).Render(ctx, w); err != nil {
|
||||
http.Error(w, "Failed to render template", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
61
server/models/api_responses.go
Normal file
61
server/models/api_responses.go
Normal 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"`
|
||||
}
|
||||
33
server/models/diagnostics.go
Normal file
33
server/models/diagnostics.go
Normal 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"`
|
||||
}
|
||||
@@ -92,19 +92,60 @@ const docTemplate = `{
|
||||
"200": {
|
||||
"description": "Cleanup completed",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.StatusMessageResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/diagnostics/daily-creation": {
|
||||
"get": {
|
||||
"description": "Returns counts of daily summary rows missing CreationTime and sample rows for the given date.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"diagnostics"
|
||||
],
|
||||
"summary": "Daily summary CreationTime diagnostics",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Daily date (YYYY-MM-DD)",
|
||||
"name": "date",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Diagnostics result",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.DailyCreationDiagnosticsResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Summary not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,19 +182,13 @@ const docTemplate = `{
|
||||
"200": {
|
||||
"description": "Ciphertext response",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.StatusMessageResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,28 +317,19 @@ const docTemplate = `{
|
||||
"200": {
|
||||
"description": "Modify event processed",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.StatusMessageResponse"
|
||||
}
|
||||
},
|
||||
"202": {
|
||||
"description": "No relevant changes",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.StatusMessageResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -338,28 +364,19 @@ const docTemplate = `{
|
||||
"200": {
|
||||
"description": "Move event processed",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.StatusMessageResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -393,19 +410,13 @@ const docTemplate = `{
|
||||
"200": {
|
||||
"description": "Import processed",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.StatusMessageResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -441,19 +452,13 @@ const docTemplate = `{
|
||||
"200": {
|
||||
"description": "Cleanup completed",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.StatusMessageResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -505,10 +510,7 @@ const docTemplate = `{
|
||||
"500": {
|
||||
"description": "Report generation failed",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -543,19 +545,13 @@ const docTemplate = `{
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -581,10 +577,7 @@ const docTemplate = `{
|
||||
"500": {
|
||||
"description": "Report generation failed",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -614,34 +607,31 @@ const docTemplate = `{
|
||||
"name": "date",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Monthly aggregation granularity: hourly or daily",
|
||||
"name": "granularity",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Aggregation complete",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.StatusResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -673,28 +663,19 @@ const docTemplate = `{
|
||||
"200": {
|
||||
"description": "Snapshot started",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.StatusResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -714,17 +695,13 @@ const docTemplate = `{
|
||||
"200": {
|
||||
"description": "Migration results",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"$ref": "#/definitions/models.SnapshotMigrationResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.SnapshotMigrationResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -744,17 +721,53 @@ const docTemplate = `{
|
||||
"200": {
|
||||
"description": "Regeneration summary",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"$ref": "#/definitions/models.SnapshotRegenerateReportsResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/snapshots/repair": {
|
||||
"post": {
|
||||
"description": "Backfills SnapshotTime and lifecycle info for existing daily summary tables and reruns monthly lifecycle refinement using hourly data.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"snapshots"
|
||||
],
|
||||
"summary": "Repair daily summaries",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.SnapshotRepairResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/snapshots/repair/all": {
|
||||
"post": {
|
||||
"description": "Rebuilds snapshot registry, backfills per-vCenter totals, repairs daily summaries (SnapshotTime/lifecycle), and refines monthly lifecycle.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"snapshots"
|
||||
],
|
||||
"summary": "Run full snapshot repair suite",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.SnapshotRepairSuiteResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1168,6 +1181,101 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.DailyCreationDiagnosticsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"avg_is_present_lt_one_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"date": {
|
||||
"type": "string"
|
||||
},
|
||||
"missing_by_vcenter": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/models.DailyCreationMissingByVcenter"
|
||||
}
|
||||
},
|
||||
"missing_creation_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"missing_creation_partial_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"missing_creation_partial_samples": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/models.DailyCreationMissingSample"
|
||||
}
|
||||
},
|
||||
"missing_creation_pct": {
|
||||
"type": "number"
|
||||
},
|
||||
"samples": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/models.DailyCreationMissingSample"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"table": {
|
||||
"type": "string"
|
||||
},
|
||||
"total_rows": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.DailyCreationMissingByVcenter": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"missing_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"vcenter": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.DailyCreationMissingSample": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"avg_is_present": {
|
||||
"type": "number"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"samples_present": {
|
||||
"type": "integer"
|
||||
},
|
||||
"snapshot_time": {
|
||||
"type": "integer"
|
||||
},
|
||||
"vcenter": {
|
||||
"type": "string"
|
||||
},
|
||||
"vm_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"vm_uuid": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.ImportReceived": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1208,6 +1316,119 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.SnapshotMigrationResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"stats": {
|
||||
"$ref": "#/definitions/models.SnapshotMigrationStats"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.SnapshotMigrationStats": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"DailyRegistered": {
|
||||
"type": "integer"
|
||||
},
|
||||
"Errors": {
|
||||
"type": "integer"
|
||||
},
|
||||
"HourlyRegistered": {
|
||||
"type": "integer"
|
||||
},
|
||||
"HourlyRenamed": {
|
||||
"type": "integer"
|
||||
},
|
||||
"MonthlyRegistered": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.SnapshotRegenerateReportsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"errors": {
|
||||
"type": "integer"
|
||||
},
|
||||
"regenerated": {
|
||||
"type": "integer"
|
||||
},
|
||||
"reports_dir": {
|
||||
"type": "string"
|
||||
},
|
||||
"skipped": {
|
||||
"type": "integer"
|
||||
},
|
||||
"snapshotType": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"total": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.SnapshotRepairResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"failed": {
|
||||
"type": "string"
|
||||
},
|
||||
"repaired": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.SnapshotRepairSuiteResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"daily_failed": {
|
||||
"type": "string"
|
||||
},
|
||||
"daily_repaired": {
|
||||
"type": "string"
|
||||
},
|
||||
"monthly_failed": {
|
||||
"type": "string"
|
||||
},
|
||||
"monthly_refined": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.StatusMessageResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.StatusResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
@@ -81,19 +81,60 @@
|
||||
"200": {
|
||||
"description": "Cleanup completed",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.StatusMessageResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/diagnostics/daily-creation": {
|
||||
"get": {
|
||||
"description": "Returns counts of daily summary rows missing CreationTime and sample rows for the given date.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"diagnostics"
|
||||
],
|
||||
"summary": "Daily summary CreationTime diagnostics",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Daily date (YYYY-MM-DD)",
|
||||
"name": "date",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Diagnostics result",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.DailyCreationDiagnosticsResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Summary not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,19 +171,13 @@
|
||||
"200": {
|
||||
"description": "Ciphertext response",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.StatusMessageResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,28 +306,19 @@
|
||||
"200": {
|
||||
"description": "Modify event processed",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.StatusMessageResponse"
|
||||
}
|
||||
},
|
||||
"202": {
|
||||
"description": "No relevant changes",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.StatusMessageResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,28 +353,19 @@
|
||||
"200": {
|
||||
"description": "Move event processed",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.StatusMessageResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -382,19 +399,13 @@
|
||||
"200": {
|
||||
"description": "Import processed",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.StatusMessageResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -430,19 +441,13 @@
|
||||
"200": {
|
||||
"description": "Cleanup completed",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.StatusMessageResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -494,10 +499,7 @@
|
||||
"500": {
|
||||
"description": "Report generation failed",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -532,19 +534,13 @@
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -570,10 +566,7 @@
|
||||
"500": {
|
||||
"description": "Report generation failed",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -603,34 +596,31 @@
|
||||
"name": "date",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Monthly aggregation granularity: hourly or daily",
|
||||
"name": "granularity",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Aggregation complete",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.StatusResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -662,28 +652,19 @@
|
||||
"200": {
|
||||
"description": "Snapshot started",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.StatusResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -703,17 +684,13 @@
|
||||
"200": {
|
||||
"description": "Migration results",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"$ref": "#/definitions/models.SnapshotMigrationResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.SnapshotMigrationResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -733,17 +710,53 @@
|
||||
"200": {
|
||||
"description": "Regeneration summary",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"$ref": "#/definitions/models.SnapshotRegenerateReportsResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/snapshots/repair": {
|
||||
"post": {
|
||||
"description": "Backfills SnapshotTime and lifecycle info for existing daily summary tables and reruns monthly lifecycle refinement using hourly data.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"snapshots"
|
||||
],
|
||||
"summary": "Repair daily summaries",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.SnapshotRepairResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/snapshots/repair/all": {
|
||||
"post": {
|
||||
"description": "Rebuilds snapshot registry, backfills per-vCenter totals, repairs daily summaries (SnapshotTime/lifecycle), and refines monthly lifecycle.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"snapshots"
|
||||
],
|
||||
"summary": "Run full snapshot repair suite",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.SnapshotRepairSuiteResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1157,6 +1170,101 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.DailyCreationDiagnosticsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"avg_is_present_lt_one_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"date": {
|
||||
"type": "string"
|
||||
},
|
||||
"missing_by_vcenter": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/models.DailyCreationMissingByVcenter"
|
||||
}
|
||||
},
|
||||
"missing_creation_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"missing_creation_partial_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"missing_creation_partial_samples": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/models.DailyCreationMissingSample"
|
||||
}
|
||||
},
|
||||
"missing_creation_pct": {
|
||||
"type": "number"
|
||||
},
|
||||
"samples": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/models.DailyCreationMissingSample"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"table": {
|
||||
"type": "string"
|
||||
},
|
||||
"total_rows": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.DailyCreationMissingByVcenter": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"missing_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"vcenter": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.DailyCreationMissingSample": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"avg_is_present": {
|
||||
"type": "number"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"samples_present": {
|
||||
"type": "integer"
|
||||
},
|
||||
"snapshot_time": {
|
||||
"type": "integer"
|
||||
},
|
||||
"vcenter": {
|
||||
"type": "string"
|
||||
},
|
||||
"vm_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"vm_uuid": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.ImportReceived": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1197,6 +1305,119 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.SnapshotMigrationResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"stats": {
|
||||
"$ref": "#/definitions/models.SnapshotMigrationStats"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.SnapshotMigrationStats": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"DailyRegistered": {
|
||||
"type": "integer"
|
||||
},
|
||||
"Errors": {
|
||||
"type": "integer"
|
||||
},
|
||||
"HourlyRegistered": {
|
||||
"type": "integer"
|
||||
},
|
||||
"HourlyRenamed": {
|
||||
"type": "integer"
|
||||
},
|
||||
"MonthlyRegistered": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.SnapshotRegenerateReportsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"errors": {
|
||||
"type": "integer"
|
||||
},
|
||||
"regenerated": {
|
||||
"type": "integer"
|
||||
},
|
||||
"reports_dir": {
|
||||
"type": "string"
|
||||
},
|
||||
"skipped": {
|
||||
"type": "integer"
|
||||
},
|
||||
"snapshotType": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"total": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.SnapshotRepairResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"failed": {
|
||||
"type": "string"
|
||||
},
|
||||
"repaired": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.SnapshotRepairSuiteResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"daily_failed": {
|
||||
"type": "string"
|
||||
},
|
||||
"daily_repaired": {
|
||||
"type": "string"
|
||||
},
|
||||
"monthly_failed": {
|
||||
"type": "string"
|
||||
},
|
||||
"monthly_refined": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.StatusMessageResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.StatusResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,68 @@ definitions:
|
||||
modified:
|
||||
type: string
|
||||
type: object
|
||||
models.DailyCreationDiagnosticsResponse:
|
||||
properties:
|
||||
avg_is_present_lt_one_count:
|
||||
type: integer
|
||||
date:
|
||||
type: string
|
||||
missing_by_vcenter:
|
||||
items:
|
||||
$ref: '#/definitions/models.DailyCreationMissingByVcenter'
|
||||
type: array
|
||||
missing_creation_count:
|
||||
type: integer
|
||||
missing_creation_partial_count:
|
||||
type: integer
|
||||
missing_creation_partial_samples:
|
||||
items:
|
||||
$ref: '#/definitions/models.DailyCreationMissingSample'
|
||||
type: array
|
||||
missing_creation_pct:
|
||||
type: number
|
||||
samples:
|
||||
items:
|
||||
$ref: '#/definitions/models.DailyCreationMissingSample'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
table:
|
||||
type: string
|
||||
total_rows:
|
||||
type: integer
|
||||
type: object
|
||||
models.DailyCreationMissingByVcenter:
|
||||
properties:
|
||||
missing_count:
|
||||
type: integer
|
||||
vcenter:
|
||||
type: string
|
||||
type: object
|
||||
models.DailyCreationMissingSample:
|
||||
properties:
|
||||
avg_is_present:
|
||||
type: number
|
||||
name:
|
||||
type: string
|
||||
samples_present:
|
||||
type: integer
|
||||
snapshot_time:
|
||||
type: integer
|
||||
vcenter:
|
||||
type: string
|
||||
vm_id:
|
||||
type: string
|
||||
vm_uuid:
|
||||
type: string
|
||||
type: object
|
||||
models.ErrorResponse:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
models.ImportReceived:
|
||||
properties:
|
||||
Cluster:
|
||||
@@ -153,6 +215,79 @@ definitions:
|
||||
VmId:
|
||||
type: string
|
||||
type: object
|
||||
models.SnapshotMigrationResponse:
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
stats:
|
||||
$ref: '#/definitions/models.SnapshotMigrationStats'
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
models.SnapshotMigrationStats:
|
||||
properties:
|
||||
DailyRegistered:
|
||||
type: integer
|
||||
Errors:
|
||||
type: integer
|
||||
HourlyRegistered:
|
||||
type: integer
|
||||
HourlyRenamed:
|
||||
type: integer
|
||||
MonthlyRegistered:
|
||||
type: integer
|
||||
type: object
|
||||
models.SnapshotRegenerateReportsResponse:
|
||||
properties:
|
||||
errors:
|
||||
type: integer
|
||||
regenerated:
|
||||
type: integer
|
||||
reports_dir:
|
||||
type: string
|
||||
skipped:
|
||||
type: integer
|
||||
snapshotType:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
total:
|
||||
type: integer
|
||||
type: object
|
||||
models.SnapshotRepairResponse:
|
||||
properties:
|
||||
failed:
|
||||
type: string
|
||||
repaired:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
models.SnapshotRepairSuiteResponse:
|
||||
properties:
|
||||
daily_failed:
|
||||
type: string
|
||||
daily_repaired:
|
||||
type: string
|
||||
monthly_failed:
|
||||
type: string
|
||||
monthly_refined:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
models.StatusMessageResponse:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
models.StatusResponse:
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
info:
|
||||
contact: {}
|
||||
paths:
|
||||
@@ -209,18 +344,46 @@ paths:
|
||||
"200":
|
||||
description: Cleanup completed
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.StatusMessageResponse'
|
||||
"400":
|
||||
description: Invalid request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.ErrorResponse'
|
||||
summary: Cleanup vCenter inventory (deprecated)
|
||||
tags:
|
||||
- maintenance
|
||||
/api/diagnostics/daily-creation:
|
||||
get:
|
||||
description: Returns counts of daily summary rows missing CreationTime and sample
|
||||
rows for the given date.
|
||||
parameters:
|
||||
- description: Daily date (YYYY-MM-DD)
|
||||
in: query
|
||||
name: date
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Diagnostics result
|
||||
schema:
|
||||
$ref: '#/definitions/models.DailyCreationDiagnosticsResponse'
|
||||
"400":
|
||||
description: Invalid request
|
||||
schema:
|
||||
$ref: '#/definitions/models.ErrorResponse'
|
||||
"404":
|
||||
description: Summary not found
|
||||
schema:
|
||||
$ref: '#/definitions/models.ErrorResponse'
|
||||
"500":
|
||||
description: Server error
|
||||
schema:
|
||||
$ref: '#/definitions/models.ErrorResponse'
|
||||
summary: Daily summary CreationTime diagnostics
|
||||
tags:
|
||||
- diagnostics
|
||||
/api/encrypt:
|
||||
post:
|
||||
consumes:
|
||||
@@ -241,15 +404,11 @@ paths:
|
||||
"200":
|
||||
description: Ciphertext response
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.StatusMessageResponse'
|
||||
"500":
|
||||
description: Server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.ErrorResponse'
|
||||
summary: Encrypt data
|
||||
tags:
|
||||
- crypto
|
||||
@@ -337,21 +496,15 @@ paths:
|
||||
"200":
|
||||
description: Modify event processed
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.StatusMessageResponse'
|
||||
"202":
|
||||
description: No relevant changes
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.StatusMessageResponse'
|
||||
"500":
|
||||
description: Server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.ErrorResponse'
|
||||
summary: Record VM modify event (deprecated)
|
||||
tags:
|
||||
- events
|
||||
@@ -375,21 +528,15 @@ paths:
|
||||
"200":
|
||||
description: Move event processed
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.StatusMessageResponse'
|
||||
"400":
|
||||
description: Invalid request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.ErrorResponse'
|
||||
"500":
|
||||
description: Server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.ErrorResponse'
|
||||
summary: Record VM move event (deprecated)
|
||||
tags:
|
||||
- events
|
||||
@@ -411,15 +558,11 @@ paths:
|
||||
"200":
|
||||
description: Import processed
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.StatusMessageResponse'
|
||||
"500":
|
||||
description: Server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.ErrorResponse'
|
||||
summary: Import VMs
|
||||
tags:
|
||||
- inventory
|
||||
@@ -443,15 +586,11 @@ paths:
|
||||
"200":
|
||||
description: Cleanup completed
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.StatusMessageResponse'
|
||||
"400":
|
||||
description: Invalid request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.ErrorResponse'
|
||||
summary: Cleanup VM inventory entry
|
||||
tags:
|
||||
- inventory
|
||||
@@ -485,9 +624,7 @@ paths:
|
||||
"500":
|
||||
description: Report generation failed
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.ErrorResponse'
|
||||
summary: Download inventory report
|
||||
tags:
|
||||
- reports
|
||||
@@ -510,15 +647,11 @@ paths:
|
||||
"400":
|
||||
description: Invalid request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.ErrorResponse'
|
||||
"500":
|
||||
description: Server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.ErrorResponse'
|
||||
summary: Download snapshot report
|
||||
tags:
|
||||
- snapshots
|
||||
@@ -535,9 +668,7 @@ paths:
|
||||
"500":
|
||||
description: Report generation failed
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.ErrorResponse'
|
||||
summary: Download updates report
|
||||
tags:
|
||||
- reports
|
||||
@@ -556,27 +687,25 @@ paths:
|
||||
name: date
|
||||
required: true
|
||||
type: string
|
||||
- description: 'Monthly aggregation granularity: hourly or daily'
|
||||
in: query
|
||||
name: granularity
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Aggregation complete
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.StatusResponse'
|
||||
"400":
|
||||
description: Invalid request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.ErrorResponse'
|
||||
"500":
|
||||
description: Server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.ErrorResponse'
|
||||
summary: Force snapshot aggregation
|
||||
tags:
|
||||
- snapshots
|
||||
@@ -598,21 +727,15 @@ paths:
|
||||
"200":
|
||||
description: Snapshot started
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.StatusResponse'
|
||||
"400":
|
||||
description: Invalid request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.ErrorResponse'
|
||||
"500":
|
||||
description: Server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.ErrorResponse'
|
||||
summary: Trigger hourly snapshot (manual)
|
||||
tags:
|
||||
- snapshots
|
||||
@@ -626,14 +749,11 @@ paths:
|
||||
"200":
|
||||
description: Migration results
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
$ref: '#/definitions/models.SnapshotMigrationResponse'
|
||||
"500":
|
||||
description: Server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.SnapshotMigrationResponse'
|
||||
summary: Migrate snapshot registry
|
||||
tags:
|
||||
- snapshots
|
||||
@@ -647,17 +767,42 @@ paths:
|
||||
"200":
|
||||
description: Regeneration summary
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
$ref: '#/definitions/models.SnapshotRegenerateReportsResponse'
|
||||
"500":
|
||||
description: Server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/models.ErrorResponse'
|
||||
summary: Regenerate hourly snapshot reports
|
||||
tags:
|
||||
- snapshots
|
||||
/api/snapshots/repair:
|
||||
post:
|
||||
description: Backfills SnapshotTime and lifecycle info for existing daily summary
|
||||
tables and reruns monthly lifecycle refinement using hourly data.
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.SnapshotRepairResponse'
|
||||
summary: Repair daily summaries
|
||||
tags:
|
||||
- snapshots
|
||||
/api/snapshots/repair/all:
|
||||
post:
|
||||
description: Rebuilds snapshot registry, backfills per-vCenter totals, repairs
|
||||
daily summaries (SnapshotTime/lifecycle), and refines monthly lifecycle.
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.SnapshotRepairSuiteResponse'
|
||||
summary: Run full snapshot repair suite
|
||||
tags:
|
||||
- snapshots
|
||||
/metrics:
|
||||
get:
|
||||
description: Exposes Prometheus metrics for vctp.
|
||||
|
||||
@@ -65,7 +65,10 @@ func New(logger *slog.Logger, database db.Database, buildTime string, sha1ver st
|
||||
mux.HandleFunc("/api/snapshots/aggregate", h.SnapshotAggregateForce)
|
||||
mux.HandleFunc("/api/snapshots/hourly/force", h.SnapshotForceHourly)
|
||||
mux.HandleFunc("/api/snapshots/migrate", h.SnapshotMigrate)
|
||||
mux.HandleFunc("/api/snapshots/repair", h.SnapshotRepair)
|
||||
mux.HandleFunc("/api/snapshots/repair/all", h.SnapshotRepairSuite)
|
||||
mux.HandleFunc("/api/snapshots/regenerate-hourly-reports", h.SnapshotRegenerateHourlyReports)
|
||||
mux.HandleFunc("/api/diagnostics/daily-creation", h.DailyCreationDiagnostics)
|
||||
mux.HandleFunc("/vm/trace", h.VmTrace)
|
||||
mux.HandleFunc("/vcenters", h.VcenterList)
|
||||
mux.HandleFunc("/vcenters/totals", h.VcenterTotals)
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
CPE_OPTS='-settings /etc/dtms/vctp.yml'
|
||||
MONTHLY_AGG_GO=0
|
||||
DAILY_AGG_GO=0
|
||||
@@ -26,6 +26,8 @@ settings:
|
||||
hourly_snapshot_timeout_seconds: 600
|
||||
daily_job_timeout_seconds: 900
|
||||
monthly_job_timeout_seconds: 1200
|
||||
monthly_aggregation_granularity: "hourly"
|
||||
monthly_aggregation_cron: "10 3 1 * *"
|
||||
cleanup_job_timeout_seconds: 600
|
||||
tenants_to_filter:
|
||||
node_charge_clusters:
|
||||
|
||||
Reference in New Issue
Block a user