feat: QuickBooks Online integration
- Add QBO OAuth2 client with token refresh (lib/services/qbo-client.ts) - Add QBO sync service for invoices, payments, deposits, purchases, journal entries, reports (lib/services/qbo-sync-service.ts) - Add QBO types (lib/types/qbo.ts) - Add API routes: /api/qbo/auth, /api/qbo/sync, /api/qbo/disconnect - Add /admin/qbo status and sync management page - Add legal pages: /legal/eula, /legal/privacy (Intuit app assessment) - Add QBO nav link under Admin - Fix reports: remove invalid summarize_column_by, add accounting_method from Preferences API, add showrows=all&showcols=all - Add CashFlow report type alongside P&L and BalanceSheet - Add NoReportData check to skip empty report months - Add intuit_tid capture in error messages - Add redirect: follow for cluster routing - Migration 051: qbo_tokens, qbo_invoices, qbo_payments, qbo_deposits, qbo_transactions, qbo_reports tables Also includes earlier work: - Ping flap suppression pipeline step - Ticket digest reports with LLM analysis - Zabbix WAN monitor and gap analysis - Kiosk is_deleted filter fixes - Datto RMM ping target enrichment - Entity sync soft-delete detection
This commit is contained in:
parent
c518eefdb2
commit
b98c67482a
40 changed files with 6223 additions and 15 deletions
552
LCI_FEATURE_DESIGN.md
Normal file
552
LCI_FEATURE_DESIGN.md
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
# Lifecycle Insights Feature Replacement — Pulse Design Doc
|
||||
|
||||
> **Purpose**: Document how Lifecycle Insights (LCI) works across the four target features — Warranty, Assessments, Budget Forecast, and QBR/Business Reviews — so we can build equivalent functionality in Pulse, using Autotask (and Datto RMM) as the primary data source.
|
||||
>
|
||||
> **Reference client**: Hynes Industries (321 assets, multiple locations)
|
||||
> **Source tool**: app.lifecycleinsights.io
|
||||
> **Date captured**: 2026-03-16
|
||||
|
||||
---
|
||||
|
||||
## 1. Asset List & Warranty
|
||||
|
||||
### What LCI Shows
|
||||
|
||||
**Summary banner** (top of Asset List page):
|
||||
- Total Assets: 321
|
||||
- **EOL Status** breakdown:
|
||||
- 104 Good Standing
|
||||
- 60 Approaching EOL
|
||||
- 6 Past EOL
|
||||
- 151 Unknown
|
||||
- **OS Status** breakdown:
|
||||
- 177 Supported
|
||||
- 27 Not Supported
|
||||
- 117 Unknown
|
||||
- **Warranty Status** breakdown:
|
||||
- 91 Under Warranty
|
||||
- 20 Approaching Warranty Expiration (within ~90 days)
|
||||
- 97 Expired Warranty
|
||||
- 113 Unknown
|
||||
|
||||
**Per-asset table columns**:
|
||||
|
||||
| Column | Example |
|
||||
|--------|---------|
|
||||
| Device Name | YNGHYNLT030 |
|
||||
| Status | Active / Retired / Hidden |
|
||||
| Last User (RMM) | HYNES\shopfloor |
|
||||
| Location | Youngstown (Main) / Painesville (Branch) / Kokomo (Branch) |
|
||||
| Type | Workstation / Hypervisor Host / Network Device |
|
||||
| Make | Dell / Lenovo |
|
||||
| Model | Latitude 5591 / ThinkSystem SR630 |
|
||||
| Serial Number | 2KNXWT2 |
|
||||
| Operating System | Microsoft Windows 10 Pro 10.0.19044 |
|
||||
| Purchase Date | 04-23-2019 |
|
||||
| Warranty Expiration | 04-24-2023 |
|
||||
| End of Life | 04-23-2024 |
|
||||
| Replacement Cost | $3,000 |
|
||||
| Age (years) | 6.9 |
|
||||
|
||||
**Table features**:
|
||||
- Sortable columns (click header)
|
||||
- Filter buttons: Show Only NON Hidden / Duplicate / Hidden / All Assets
|
||||
- Duplicate detection badge
|
||||
- Pagination (321 rows, 25/50/75/100/200 per page)
|
||||
- Search box
|
||||
|
||||
**Warranty status logic** (inferred):
|
||||
- **Under Warranty**: warranty expiration date > today
|
||||
- **Approaching**: warranty expiration within ~90 days
|
||||
- **Expired**: warranty expiration date < today
|
||||
- **Unknown**: no warranty expiration date on record
|
||||
|
||||
**EOL logic** (inferred):
|
||||
- EOL date is OS/hardware specific (LCI maintains a lookup table — shown on dashboard)
|
||||
- **Good Standing**: EOL date > today + threshold (~1 year)
|
||||
- **Approaching EOL**: EOL date within ~1 year
|
||||
- **Past EOL**: EOL date < today
|
||||
- **Unknown**: no EOL date derivable
|
||||
|
||||
### Data Sources
|
||||
- **Device inventory**: comes from RMM (Datto RMM in Hynes's case) — device name, make, model, serial, OS, last user, location
|
||||
- **Purchase/warranty dates**: can come from RMM or be manually entered
|
||||
- **EOL dates**: LCI maintains a global EOL lookup table by OS/software version (visible on dashboard — e.g., "Windows 10 Pro: 10-14-2025")
|
||||
- **Replacement cost**: manually set per asset type, or inherited from category defaults
|
||||
- **Locations**: mapped from RMM site groupings
|
||||
|
||||
### Pulse Implementation Notes
|
||||
|
||||
**Data model** (new migration needed):
|
||||
```sql
|
||||
-- assets table (core)
|
||||
asset_id, autotask_id, datto_rmm_uid,
|
||||
device_name, status, last_user,
|
||||
location_id, asset_type,
|
||||
make, model, serial_number,
|
||||
os_name, os_version,
|
||||
purchase_date, warranty_expiry_date, eol_date,
|
||||
replacement_cost, age_years,
|
||||
company_id (FK → companies),
|
||||
is_hidden, is_duplicate,
|
||||
created_at, updated_at, last_synced_at
|
||||
|
||||
-- eol_reference table (global)
|
||||
software_name, eol_date
|
||||
|
||||
-- asset_locations table
|
||||
location_id, company_id, location_name
|
||||
```
|
||||
|
||||
**Warranty status computed field**:
|
||||
```ts
|
||||
function warrantyStatus(expiryDate: Date | null): 'under' | 'approaching' | 'expired' | 'unknown' {
|
||||
if (!expiryDate) return 'unknown';
|
||||
const daysUntil = differenceInDays(expiryDate, new Date());
|
||||
if (daysUntil < 0) return 'expired';
|
||||
if (daysUntil <= 90) return 'approaching';
|
||||
return 'under';
|
||||
}
|
||||
```
|
||||
|
||||
**Autotask as source**: Autotask has a Configuration Items module that maps to devices. Datto RMM syncs device details into Autotask CIs. Fields available via Autotask API: `warrantyExpirationDate`, `purchaseOrderNumber`, `purchaseDate`, `installDate`, `serialNumber`, `referenceNumber`, manufacturer, product (model), location, deviceType.
|
||||
|
||||
---
|
||||
|
||||
## 2. Budget Forecast
|
||||
|
||||
### What LCI Shows
|
||||
|
||||
**Purpose**: Project technology spend for the next 6–8 quarters, broken into categories.
|
||||
|
||||
**Views**:
|
||||
1. **Detail View** — collapsible sections per time period, category breakdown
|
||||
2. **Spreadsheet View** — bar chart + grid with rows per category, columns per quarter
|
||||
3. **Budget Board** — (not fully captured; likely a Kanban/card layout)
|
||||
|
||||
**Filters available**:
|
||||
- Location (site filter)
|
||||
- Asset Type
|
||||
- Asset Status
|
||||
- "Generate Budget Forecast as of" date (defaults to today; can run future projections)
|
||||
|
||||
**Time buckets**:
|
||||
- Overdue (past-due items)
|
||||
- Recommendations Not Scheduled (approved recommendations with no date)
|
||||
- Then rolling quarters: e.g., Jan-Mar 2026, Apr-Jun 2026, Jul-Sep 2026, etc. (6–8 quarters shown)
|
||||
|
||||
**Categories**:
|
||||
| Category | What it represents |
|
||||
|----------|--------------------|
|
||||
| Workstation | Assets reaching EOL scheduled for replacement |
|
||||
| Hypervisor Host | Server/hypervisor assets reaching EOL |
|
||||
| Network Device | Network hardware replacements |
|
||||
| Contracts/Subscriptions | Contract renewals (from Autotask contracts) |
|
||||
| Recommendations | Open recommendations with cost + target date |
|
||||
|
||||
**Hynes Industries example data** (Spreadsheet View):
|
||||
|
||||
| Category | Overdue | Not Sched | Apr-Jun 26 | Jul-Sep 26 | Oct-Dec 26 | Jan-Mar 27 | Apr-Jun 27 | Total |
|
||||
|----------|---------|-----------|------------|------------|------------|------------|------------|-------|
|
||||
| Hypervisor Host | $22,500 | — | — | — | — | — | — | $22,500 |
|
||||
| Workstation | — | — | — | — | — | $18,000 | $21,000 | $39,000 |
|
||||
| Contracts/Subscriptions | — | — | $37,231 | $43,590 | $164,414 | $27,737 | $37,231 | $313,549 |
|
||||
| Recommendations | $254,754 | $107,000 | $31,245 | $15,045 | $29,795 | — | — | $437,839 |
|
||||
| **TOTAL** | **$277,254** | **$107,000** | **$68,476** | **$58,635** | **$194,209** | **$45,737** | **$58,231** | **$812,888** |
|
||||
|
||||
### How Each Category is Populated
|
||||
|
||||
**Workstation / Hypervisor / Network Device**:
|
||||
- Source: assets with EOL date falling within the quarter
|
||||
- Cost: `replacement_cost` per asset
|
||||
- Scheduled quarter = quarter containing the asset's `eol_date`
|
||||
|
||||
**Contracts/Subscriptions**:
|
||||
- Source: Autotask Contracts with `end_date` or renewal dates
|
||||
- Cost: contract value
|
||||
- Quarter = quarter of contract end/renewal date
|
||||
|
||||
**Recommendations**:
|
||||
- Source: open Recommendations (see Recommendations module)
|
||||
- Cost: `estimated_cost` on the recommendation
|
||||
- Quarter = recommendation's `planned_date` (or "Not Scheduled" if no date)
|
||||
- "Overdue" = planned_date < today
|
||||
|
||||
### Pulse Implementation Notes
|
||||
|
||||
**Budget Forecast is computed at query time** — no separate stored table needed beyond assets + contracts + recommendations with dates and costs.
|
||||
|
||||
```sql
|
||||
-- Pseudo-query for budget forecast
|
||||
SELECT
|
||||
quarter_bucket,
|
||||
category,
|
||||
COUNT(*) AS quantity,
|
||||
SUM(cost) AS budget_cost
|
||||
FROM (
|
||||
-- EOL assets
|
||||
SELECT date_trunc('quarter', eol_date) AS quarter_bucket,
|
||||
asset_type AS category,
|
||||
replacement_cost AS cost
|
||||
FROM assets WHERE company_id = $1 AND eol_date IS NOT NULL AND status = 'active'
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Contracts
|
||||
SELECT date_trunc('quarter', end_date), 'Contracts/Subscriptions', contract_value
|
||||
FROM contracts WHERE company_id = $1
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recommendations
|
||||
SELECT date_trunc('quarter', COALESCE(planned_date, NULL)), 'Recommendations', estimated_cost
|
||||
FROM recommendations WHERE company_id = $1 AND status NOT IN ('Completed', 'Declined')
|
||||
) combined
|
||||
GROUP BY quarter_bucket, category
|
||||
ORDER BY quarter_bucket;
|
||||
```
|
||||
|
||||
**API route**: `GET /api/budget-forecast/[companyId]?asOf=DATE&location=X&assetType=Y`
|
||||
|
||||
---
|
||||
|
||||
## 3. Assessments
|
||||
|
||||
### What LCI Shows
|
||||
|
||||
**Structure** (from Assessments Menu page):
|
||||
|
||||
1. **Assessment Events** — each time you run an assessment for a client = one "event"
|
||||
2. **Assessment Templates** — define *what* you're measuring (items + categories)
|
||||
3. **Assessment Items** — individual questions/checklist items
|
||||
4. **Event Types** — classify events (e.g., baseline, annual review, gap assessment)
|
||||
5. **Analytics by Customer** — score history, trends, category breakdowns
|
||||
6. **Analytics All Customers** — cross-client comparison
|
||||
|
||||
**Wulf Consulting uses**: "Wulf Consulting: Default Assessment" template
|
||||
|
||||
**Hynes Industries history** (from dashboard):
|
||||
- Latest: completed 01-29-2025 (411 days ago) by Jake Hammel
|
||||
- Score type: "baseline assessment with a score of --" (baseline = no numeric score, just captures state)
|
||||
- Assessment Event Score History chart shows one data point at 2025-01
|
||||
|
||||
**Data Quality Scores** (dashboard, related to assessments):
|
||||
- EOL: 53/100
|
||||
- Warranty: 65/100
|
||||
- Make: 92/100
|
||||
- Serial: 83/100
|
||||
- Model: 95/100
|
||||
- Overall: 67/100
|
||||
|
||||
### Assessment Data Model (inferred)
|
||||
|
||||
```
|
||||
AssessmentTemplate
|
||||
├── id, name, company_id (MSP-level)
|
||||
├── items[] → AssessmentItem
|
||||
│ ├── id, category, question_text, weight, scoring_type
|
||||
│ └── possible_scores: [0,1,2,3,4,5] or pass/fail
|
||||
└── event_type_config
|
||||
|
||||
AssessmentEvent
|
||||
├── id, template_id, client_id (company)
|
||||
├── event_type (baseline / annual / gap)
|
||||
├── completed_date, completed_by (technician)
|
||||
├── overall_score (numeric or null for baseline)
|
||||
└── responses[] → AssessmentResponse
|
||||
├── item_id, score, notes
|
||||
└── linked_recommendation_id (optional)
|
||||
|
||||
AssessmentEventScore (for history/chart)
|
||||
├── event_id, category, score, max_score
|
||||
```
|
||||
|
||||
**Key behaviors**:
|
||||
- Templates are MSP-defined (not per-client)
|
||||
- One client can have many events over time (score history chart)
|
||||
- Assessment items can generate Recommendations (linked)
|
||||
- Scores tracked by category (enables radar/bar chart per category)
|
||||
- "Baseline" events don't produce a numeric score — they capture current state only
|
||||
|
||||
### Pulse Implementation Notes
|
||||
|
||||
**Migration**: Create `assessment_templates`, `assessment_items`, `assessment_events`, `assessment_responses` tables.
|
||||
|
||||
**Admin UI**: Template builder (drag-drop items into categories), Event runner (per-client form), Analytics view (score history chart per client).
|
||||
|
||||
**Autotask link**: Assessment items that result in action → create Autotask ticket or recommendation.
|
||||
|
||||
---
|
||||
|
||||
## 4. Business Reviews / QBR
|
||||
|
||||
### What LCI Shows — FULLY CAPTURED
|
||||
|
||||
**Page**: `Business Review Meetings for Hynes Industries`
|
||||
|
||||
**Meeting table columns**:
|
||||
| Column | Values |
|
||||
|--------|--------|
|
||||
| Meeting Date | e.g., 03-03-2026 |
|
||||
| Meeting Type | Business Review / Virtual Meeting / (blank) |
|
||||
| Primary Contact | Ryan Day |
|
||||
| Account Manager | Jake Hammel |
|
||||
| Status | Completed / Scheduled / Auto-Scheduled / Preparation / Skipped/Cxl |
|
||||
| Notes | Rich text notes (Pre-Meeting / During Meeting / Post-Meeting sections) |
|
||||
| Recommendations | (linked recommendations column) |
|
||||
| Reports / Artifacts | (PDF download column) |
|
||||
| Delete | delete action |
|
||||
|
||||
**Status types explained**:
|
||||
- **Completed** — meeting happened and notes are finalized
|
||||
- **Scheduled** — manually scheduled upcoming meeting
|
||||
- **Auto-Scheduled** — system automatically created this slot based on cadence
|
||||
- **Preparation** — notes are being drafted before meeting
|
||||
- **Skipped/Cxl** — meeting was skipped or cancelled
|
||||
|
||||
**Cadence for Hynes**: Bi-weekly (every 2 weeks), Ryan Day as primary contact, Jake Hammel as account manager. Running since September 2023.
|
||||
|
||||
**Notes structure** (rich text, per meeting):
|
||||
```
|
||||
Pre-Meeting:
|
||||
[prep notes, project status, talking points]
|
||||
|
||||
Meeting / During Meeting:
|
||||
[live notes taken during call]
|
||||
|
||||
Post-Meeting:
|
||||
[follow-up actions, decisions made]
|
||||
```
|
||||
|
||||
**Real example notes** (from Hynes 09-19-2023):
|
||||
> "Follow up with Ryan on 2024 CAPX Budget... Updated Ryan that moving forward new workstations won't have the BitLocker Password Prompt... Amy Young - Executive Admin - Get her to view details on the Hynes conference room calendars..."
|
||||
|
||||
**Scale**: Hynes has ~56 business review records from 09-2023 to 03-2026 (bi-weekly cadence = ~2/month × 30 months). The "47 overdue" from the dashboard is because many Auto-Scheduled meetings were never formally completed.
|
||||
|
||||
**Filters on page**:
|
||||
- "Show Meetings with Date in Past" toggle
|
||||
|
||||
**Actions**:
|
||||
- ADD NEW — create a new meeting record
|
||||
- Each row has: delete, reports/artifacts (PDF generation)
|
||||
|
||||
### Business Reviews Data Model (confirmed)
|
||||
|
||||
```sql
|
||||
business_reviews
|
||||
id, company_id,
|
||||
meeting_date DATE,
|
||||
meeting_type VARCHAR, -- 'Business Review', 'Virtual Meeting', etc.
|
||||
primary_contact_id INT, -- FK → contacts
|
||||
account_manager_id INT, -- FK → users (MSP staff)
|
||||
status VARCHAR, -- Completed/Scheduled/Auto-Scheduled/Preparation/Skipped-Cxl
|
||||
notes_pre_meeting TEXT,
|
||||
notes_during_meeting TEXT,
|
||||
notes_post_meeting TEXT,
|
||||
created_at, updated_at
|
||||
|
||||
business_review_artifacts -- linked PDFs / reports
|
||||
id, business_review_id, artifact_type, file_url, created_at
|
||||
|
||||
business_review_recommendations -- join table
|
||||
business_review_id, recommendation_id
|
||||
```
|
||||
|
||||
**Key behaviors**:
|
||||
- System auto-generates meeting slots based on cadence (configurable per client)
|
||||
- Each meeting has pre/during/post note sections
|
||||
- Meetings can link to specific recommendations (show progress at meeting)
|
||||
- PDF reports can be attached or generated per meeting
|
||||
- "47 overdue" = count of Auto-Scheduled meetings with date in past and no Completed status
|
||||
- Agenda links (from dashboard widget) are separate from meeting notes — persistent links per client
|
||||
|
||||
### Pulse Implementation Notes
|
||||
|
||||
**QBR = structured meeting log** with recurring cadence + notes + linked recommendations.
|
||||
|
||||
- Auto-generate meeting slots via a cron job based on `meeting_cadence_days` per company
|
||||
- The "Overdue" count = `SELECT COUNT(*) FROM business_reviews WHERE company_id = $1 AND meeting_date < NOW() AND status != 'Completed'`
|
||||
- PDF generation: render current EOL health + budget + recommendations at time of export
|
||||
- Agenda links widget: simple per-client URL bookmarks (`business_review_links` table)
|
||||
|
||||
---
|
||||
|
||||
## 5. Recommendations — FULLY CAPTURED
|
||||
|
||||
### What LCI Shows
|
||||
|
||||
**Page**: `Recommendations for Hynes Industries` — 26 total records
|
||||
|
||||
**Table columns**:
|
||||
| Column | Notes |
|
||||
|--------|-------|
|
||||
| Name | Free-text project/recommendation name |
|
||||
| Location | Youngstown (Main) / Painesville (Branch) / Kokomo (Branch) / blank (all sites) |
|
||||
| Start | Target start date (optional) |
|
||||
| End | Target end date (optional) |
|
||||
| Status | New / Proposed / Planned / In Progress / On Hold / Completed / Declined |
|
||||
| Tags | (free-form tags) |
|
||||
| Priority | Low / Medium / High |
|
||||
| Description | Rich text description of scope |
|
||||
| Labor Cost | $ |
|
||||
| Materials Cost | $ |
|
||||
| Replacement Cost | $ |
|
||||
| Recurring Cost | Monthly $X → shown as "Monthly / $X" |
|
||||
| Total Cost | Sum of all cost components |
|
||||
| Delete | delete action |
|
||||
|
||||
**Filters**:
|
||||
- Search box
|
||||
- Include Completed toggle
|
||||
- Include Declined toggle
|
||||
- "SHOW AUTOTASK OPPS" button — links to Autotask opportunities
|
||||
|
||||
**Views**: List (default) — table view
|
||||
|
||||
**Hynes Industries full recommendation list** (real data):
|
||||
|
||||
| Name | Status | Priority | Total Cost |
|
||||
|------|--------|----------|------------|
|
||||
| 2024 - Fish Bowl Renovation | Completed | Medium | $11,000 |
|
||||
| 2024 - Pepwave Licensing Renewal | Completed | Medium | $0 |
|
||||
| 2024 - Planned Multiple Switch Lifecycle | Completed | Medium | $24,000 |
|
||||
| 2024 - Planned Workstation Lifecycle | In Progress | Medium | $9,800 |
|
||||
| 2024 - VLAN Cleanup | Completed | Medium | $10,000 |
|
||||
| 2024: O365 Backup | Completed | Medium | $3,560 (monthly) |
|
||||
| 2024: Password Management | Completed | Medium | $8,340 (monthly) |
|
||||
| 2024: SEIM Tool | Completed | High | $20,020 (monthly) |
|
||||
| 2024: ZTNA - Phase 1 | Completed | High | $16,350 (monthly) |
|
||||
| 2025 - Painesville Zoom Room | Declined | Low | $11,000 |
|
||||
| 2025 - Planned Infrastructure Lifecycle | Completed | High | $6,830 (monthly) |
|
||||
| 2025 - Planned Workstation Lifecycle | Completed | High | $3,000 |
|
||||
| 2025 - PV Planned Multiple Switch Lifecycle | In Progress | High | $5,000 |
|
||||
| 2025 - YNG Planned Multiple Switch Lifecycle | Completed | High | $2,900 |
|
||||
| 2025: Windows 10 to 11 Upgrade | Completed | High | $40,400 |
|
||||
| 2026 - Exterior Cameras - Partial Build | On Hold | Low | $57,000 |
|
||||
| 2026 - PEN Test | On Hold | High | $20,000 |
|
||||
| 2026 - Pepwave Licensing Renewal | Planned | High | $350 |
|
||||
| 2026 - Planned Host/SAN Lifecycle | Proposed | High | $120,000 |
|
||||
| 2026 - Planned Multiple Switch Lifecycle | Proposed | High | $31,000 |
|
||||
| 2026 - Planned Workstation Budget | Proposed | Medium | $86,600 |
|
||||
| 2026 - Zebra Printers Lifecycle | Proposed | High | $80,534 (monthly) |
|
||||
| 2026: ZTNA: Phase 2 | On Hold | High | $30,000 |
|
||||
| Auvik SaaS | Declined | Medium | $1,760 (monthly) |
|
||||
| Physical Switch & IDF Cleanup | On Hold | Medium | $0 |
|
||||
| Receptionist Hardware | Completed | Medium | $1,800 |
|
||||
|
||||
**How recommendations feed Budget Forecast**:
|
||||
- Status `Proposed`, `Planned`, `In Progress`, `On Hold` → included in budget forecast
|
||||
- `start` date (or `end` date) → determines which quarter they appear in
|
||||
- No date → "Not Scheduled" bucket
|
||||
- Past date → "Overdue" bucket
|
||||
- `Completed` / `Declined` → excluded from forecast
|
||||
|
||||
### Recommendations Data Model
|
||||
|
||||
```sql
|
||||
recommendations
|
||||
id, company_id,
|
||||
name VARCHAR,
|
||||
location_id INT (nullable),
|
||||
start_date DATE (nullable),
|
||||
end_date DATE (nullable),
|
||||
status VARCHAR, -- New/Proposed/Planned/In Progress/On Hold/Completed/Declined
|
||||
tags TEXT[],
|
||||
priority VARCHAR, -- Low/Medium/High
|
||||
description TEXT,
|
||||
labor_cost DECIMAL,
|
||||
materials_cost DECIMAL,
|
||||
replacement_cost DECIMAL,
|
||||
recurring_cost DECIMAL, -- monthly amount
|
||||
recurring_period VARCHAR, -- 'Monthly'
|
||||
total_cost DECIMAL, -- computed or stored
|
||||
autotask_opportunity_id INT (nullable),
|
||||
created_at, updated_at
|
||||
|
||||
recommendation_assets -- assets linked to this recommendation
|
||||
recommendation_id, asset_id
|
||||
```
|
||||
|
||||
### Pulse Implementation Notes
|
||||
|
||||
- Recommendations are **Pulse-native** (not pulled from Autotask) but can push/link to Autotask Opportunities
|
||||
- `total_cost = labor_cost + materials_cost + replacement_cost + (recurring_cost × months_in_period)`
|
||||
- Budget Forecast query filters: `status NOT IN ('Completed', 'Declined')`
|
||||
- Dashboard widget shows count + total cost grouped by status
|
||||
- The `SHOW AUTOTASK OPPS` button = link to Autotask opportunities filtered to this company
|
||||
|
||||
---
|
||||
|
||||
## 6. Dashboard Summary
|
||||
|
||||
The LCI dashboard aggregates all four features for a client into one view:
|
||||
|
||||
| Widget | Data |
|
||||
|--------|------|
|
||||
| CSAT Score | From Crewhu integration (external) |
|
||||
| Budget Forecast chart | 6-quarter bar chart |
|
||||
| EOL Health | % OK / Past EOL / Near EOL donut |
|
||||
| Data Quality Score | Per-field completeness scores |
|
||||
| Microsoft License Summary | From M365 / CSP data |
|
||||
| EOL Replacement Cost | Bar chart by status |
|
||||
| Latest Assessment | Score + date |
|
||||
| Recommendation Status | Count + cost per status |
|
||||
| Assessment Score History | Line/bar chart |
|
||||
| Software/OS EOL Table | Global reference table |
|
||||
| Tech Debt by Asset Type | Stacked bar |
|
||||
| Missing Data Fields | Counts of missing fields |
|
||||
| Total Technology Debt | $ value of overdue/EOL assets |
|
||||
| Headlines | Alerts: overdue contracts, overdue QBRs, latest assessment age |
|
||||
|
||||
---
|
||||
|
||||
## 6. Integrations & Data Flow
|
||||
|
||||
```
|
||||
Datto RMM ──────────────────┐
|
||||
▼
|
||||
Autotask CIs ───────────► LCI Asset Sync
|
||||
│
|
||||
Autotask Contracts ──────────┤
|
||||
│
|
||||
Autotask Recommendations ────┤
|
||||
(or LCI-native) │
|
||||
▼
|
||||
LCI PostgreSQL
|
||||
│
|
||||
┌────────┼────────┐
|
||||
▼ ▼ ▼
|
||||
Budget Assessments QBR
|
||||
Forecast Events Reports
|
||||
```
|
||||
|
||||
**For Pulse**:
|
||||
- Assets: Autotask Configuration Items API → `assets` table
|
||||
- Contracts: Autotask Contracts API → `contracts` table (already partially done)
|
||||
- Recommendations: Either Autotask Opportunities/Quotes or Pulse-native table
|
||||
- CSAT: Not currently integrated (Crewhoo is external) — can be skipped initially
|
||||
- EOL Reference: Static lookup table (maintained manually or seeded from LCI's public list)
|
||||
- Warranty dates: Come from Autotask CI `warrantyExpirationDate` field
|
||||
|
||||
---
|
||||
|
||||
## 7. Build Priority Order
|
||||
|
||||
1. **Asset List + Warranty** — highest value, data already in Autotask CIs
|
||||
2. **Budget Forecast** — derived from assets + contracts (both already syncing)
|
||||
3. **QBR / Business Reviews** — report generation + scheduling
|
||||
4. **Assessments** — most complex, requires template builder + event runner
|
||||
|
||||
---
|
||||
|
||||
## 9. Pages Still to Explore
|
||||
|
||||
**Fully captured**: Asset List, Budget Forecast (detail + spreadsheet), Recommendations (all 26), Business Reviews (all ~56 meetings), Dashboard.
|
||||
|
||||
**Still needed for complete picture**:
|
||||
- `app.lifecycleinsights.io/assessmentevents` — see an actual scored assessment event (items, categories, scores per item). The Hynes event from 01-29-2025 was a baseline with no numeric score.
|
||||
- `app.lifecycleinsights.io/assessmentanalytics` — analytics/trends view per client
|
||||
- `app.lifecycleinsights.io/managetemplates` — what a template looks like with items + categories
|
||||
- MDS client — verify data model generalizes (different asset mix, different contact structure)
|
||||
|
||||
These can be explored via Claude_in_Chrome (already connected to local Chrome where you're logged in) without needing another MFA login.
|
||||
525
PULSE_DATABASE_SKILL.md
Normal file
525
PULSE_DATABASE_SKILL.md
Normal file
|
|
@ -0,0 +1,525 @@
|
|||
# Pulse Database Skill — Query Reference
|
||||
|
||||
> **Purpose:** This document describes the PostgreSQL database behind **Pulse**, an MSP operations platform built by Wulf Consulting. Use it to query Autotask PSA data, RMM alerts, security agents, backup status, IT documentation, engagement metrics, and more.
|
||||
|
||||
## Connection
|
||||
|
||||
- **Host:** `pulse-postgres` (Docker) or `localhost:5432`
|
||||
- **Database:** `pulse_autotask`
|
||||
- **User:** `pulse_user`
|
||||
- **Read-only queries only** — no INSERT/UPDATE/DELETE
|
||||
|
||||
---
|
||||
|
||||
## Data Domains at a Glance
|
||||
|
||||
| Domain | Key Tables | Approx Rows | Description |
|
||||
|---|---|---|---|
|
||||
| **Autotask PSA** | tickets, time_entries, companies, contacts, resources, configuration_items, contracts, projects, tasks, ticket_notes | 109K tickets, 154K time entries, 7K CIs | Service desk, billing, contracts, clients |
|
||||
| **Datto RMM** | datto_rmm_alerts, datto_rmm_devices, datto_rmm_sites | 4.3K alerts, 3.6K devices | Remote monitoring & management |
|
||||
| **SentinelOne** | s1_agents, s1_threats, s1_sites | 2.8K agents, 4.1K threats | Endpoint security |
|
||||
| **Veeam** | veeam_organizations, veeam_backup_jobs, veeam_backup_agents, veeam_alarms, veeam_protected_workloads, veeam_repositories, veeam_backup_servers | ~2.5K total | Backup & disaster recovery |
|
||||
| **IT Glue** | itg_organizations, itg_configurations, itg_passwords, itg_flexible_assets, itg_contacts, itg_documents, itg_expirations, itg_domains, itg_locations | 14.7K configs, 7.1K contacts | IT documentation |
|
||||
| **Microsoft 365** | graph_users, teams_meetings, teams_meeting_attendees, engagement_snapshots | 14.3K meetings | Teams meetings, activity reports |
|
||||
| **Zoom** | zoom_users, zoom_meetings, zoom_meeting_participants, zoom_calls | 2.8K calls, meetings | Zoom calls and meetings |
|
||||
| **Billing** | billing_items | 95K items | Invoice line items tied to tickets/projects/tasks |
|
||||
|
||||
---
|
||||
|
||||
## 1. Autotask PSA — Core Service Desk
|
||||
|
||||
### tickets (~109K rows, 68 columns)
|
||||
|
||||
The central table. Each row is a service ticket.
|
||||
|
||||
**Key columns:**
|
||||
- `id` (bigint PK) — Autotask ticket ID
|
||||
- `title` (varchar) — ticket subject line
|
||||
- `description` (text) — full body/description
|
||||
- `status` (int) — FK to `statuses.value`
|
||||
- `priority` (int) — FK to `priorities.value`
|
||||
- `queue_id` (int) — FK to `queues.value`
|
||||
- `source` (int) — how the ticket was created (see Source Codes below)
|
||||
- `company_id` (bigint) — FK to `companies.id`
|
||||
- `contact_id` (bigint) — FK to `contacts.id`
|
||||
- `assigned_resource_id` (bigint) — FK to `resources.id`
|
||||
- `configuration_item_id` (bigint) — FK to `configuration_items.id`
|
||||
- `contract_id` (bigint) — FK to `contracts.id`
|
||||
- `project_id` (bigint) — FK to `projects.id`
|
||||
- `issue_type` (int), `sub_issue_type` (int) — classification
|
||||
- `ticket_type` (int) — 1=Incident, 2=Service Request, 5=Alert
|
||||
- `create_date` (timestamp) — when opened
|
||||
- `due_date_time` (timestamp) — SLA due
|
||||
- `completed_date` (timestamp) — when closed
|
||||
- `resolved_date_time` (timestamp) — when resolved
|
||||
- `first_response_date_time` (timestamp) — first response SLA timestamp
|
||||
- `last_activity_date` (timestamp) — most recent update
|
||||
- `monitor_id` (bigint) — RMM monitor that created this ticket (if source=8)
|
||||
- `monitor_type_id` (int) — type of monitor
|
||||
- `is_deleted` (boolean) — soft delete flag
|
||||
|
||||
**Source codes (tickets.source):**
|
||||
| Value | Meaning | Count |
|
||||
|---|---|---|
|
||||
| 8 | Monitoring Alert (RMM/Datto) | 80,736 |
|
||||
| 4 | Email | 14,872 |
|
||||
| 21 | Portal | 2,500 |
|
||||
| 2 | Phone/Voice | 1,781 |
|
||||
| -1 | Insourced | 1,092 |
|
||||
| -2 | Outsourced | 1,088 |
|
||||
| 35 | Phish Alert | 726 |
|
||||
| 6 | API | 488 |
|
||||
| 17 | Internal Alert | 453 |
|
||||
|
||||
**Ticket types:**
|
||||
| Value | Meaning | Count |
|
||||
|---|---|---|
|
||||
| 1 | Incident | 8,409 |
|
||||
| 2 | Service Request | 3,217 |
|
||||
| 5 | Alert | 29,885 |
|
||||
| NULL | Unclassified | 67,737 |
|
||||
|
||||
### companies (~4K rows, 41 columns)
|
||||
|
||||
Client/customer organizations.
|
||||
|
||||
**Key columns:**
|
||||
- `id` (bigint PK)
|
||||
- `company_name` (varchar) — display name
|
||||
- `company_number` (varchar) — short code
|
||||
- `is_active` (boolean)
|
||||
- `company_type` (int) — 1=Customer, 2=Lead, 3=Prospect, 4=Dead, 6=Cancellation, 7=Vendor, etc.
|
||||
- `owner_resource_id` (bigint) — account manager, FK to `resources.id`
|
||||
- `classification` (varchar) — e.g. "Platinum", "Gold", etc.
|
||||
- Address fields: `address1`, `city`, `state`, `postal_code`
|
||||
- `last_activity_date` (timestamp)
|
||||
|
||||
### contacts (~4.2K rows, 37 columns)
|
||||
|
||||
People at client companies.
|
||||
|
||||
**Key columns:**
|
||||
- `id` (bigint PK)
|
||||
- `first_name`, `last_name`, `email_address`, `phone` (varchar)
|
||||
- `company_id` (bigint) — FK to `companies.id`
|
||||
- `is_active` (boolean)
|
||||
- `title` (varchar) — job title
|
||||
|
||||
### resources (~40 columns)
|
||||
|
||||
Internal staff / technicians.
|
||||
|
||||
**Key columns:**
|
||||
- `id` (bigint PK)
|
||||
- `first_name`, `last_name`, `email` (varchar)
|
||||
- `email_address` (varchar) — primary email
|
||||
- `is_active` (boolean)
|
||||
- `resource_type` (varchar)
|
||||
- `default_service_desk_role_id` (bigint)
|
||||
- `hire_date` (date)
|
||||
- `location_id` (bigint)
|
||||
|
||||
### time_entries (~154K rows, 45 columns)
|
||||
|
||||
Work logged against tickets, tasks, or projects.
|
||||
|
||||
**Key columns:**
|
||||
- `id` (bigint PK)
|
||||
- `resource_id` (bigint) — who did the work, FK to `resources.id`
|
||||
- `ticket_id` (bigint) — FK to `tickets.id` (NULL if task/project entry)
|
||||
- `task_id` (bigint) — FK to `tasks.id`
|
||||
- `project_id` (bigint) — FK to `projects.id`
|
||||
- `company_id` (bigint) — FK to `companies.id`
|
||||
- `entry_date` (timestamp) — date of work
|
||||
- `hours_worked` (numeric) — actual hours
|
||||
- `hours_to_bill` (numeric) — billable hours
|
||||
- `start_date_time`, `end_date_time` (timestamp) — clock in/out
|
||||
- `title` (varchar), `notes` (text), `internal_notes` (text)
|
||||
- `billable` (boolean), `non_billable` (boolean)
|
||||
- `billing_rate` (numeric), `cost_rate` (numeric), `revenue` (numeric)
|
||||
- `contract_id` (bigint), `contract_service_id` (bigint)
|
||||
- `role_id` (bigint)
|
||||
- `is_deleted` (boolean)
|
||||
|
||||
### ticket_notes (~30K rows, 14 columns)
|
||||
|
||||
Notes/comments on tickets.
|
||||
|
||||
**Key columns:**
|
||||
- `id` (bigint PK)
|
||||
- `ticket_id` (bigint) — FK to `tickets.id`
|
||||
- `title` (varchar), `description` (text) — note content
|
||||
- `note_type` (int) — internal, external, etc.
|
||||
- `publish` (int) — visibility
|
||||
- `creator_resource_id` (bigint) — who wrote it
|
||||
- `create_date_time` (timestamptz)
|
||||
|
||||
### configuration_items (~7K rows, 95 columns)
|
||||
|
||||
Devices/assets tracked in Autotask.
|
||||
|
||||
**Key columns:**
|
||||
- `id` (bigint PK)
|
||||
- `reference_title` (varchar) — device name (e.g. "DT037", "SRV-DC01")
|
||||
- `reference_number` (varchar) — often a GUID from RMM
|
||||
- `serial_number` (varchar)
|
||||
- `company_id` (bigint) — FK to `companies.id`
|
||||
- `contact_id` (bigint) — FK to `contacts.id`
|
||||
- `is_active` (boolean)
|
||||
- `device_type` (varchar)
|
||||
|
||||
**Note:** `reference_title` follows a naming convention per client (e.g. DT037 exists at multiple companies as separate CIs). Always filter by both `reference_title` AND `company_id` when searching.
|
||||
|
||||
### contracts (~44 columns)
|
||||
|
||||
Service agreements with clients.
|
||||
|
||||
**Key columns:**
|
||||
- `id` (bigint PK)
|
||||
- `company_id` (bigint) — FK to `companies.id`
|
||||
- `contract_name` (varchar), `contract_number` (varchar)
|
||||
- `contract_type` (int), `status` (int)
|
||||
- `start_date`, `end_date` (date)
|
||||
- `estimated_hours` (numeric), `estimated_revenue` (numeric)
|
||||
|
||||
### contract_services (~8.2K rows)
|
||||
|
||||
Line items on contracts.
|
||||
|
||||
- `contract_id` → `contracts.id`
|
||||
- `company_id` → `companies.id`
|
||||
- `service_name` (text), `unit_price`, `quantity`
|
||||
|
||||
### projects (~291 rows, 36 columns)
|
||||
|
||||
**Key columns:**
|
||||
- `id`, `company_id`, `project_name`, `status`, `type`
|
||||
- `project_lead_resource_id` → `resources.id`
|
||||
- `start_date_time`, `end_date_time`, `actual_hours`, `estimated_time`
|
||||
|
||||
### tasks (~4.3K rows, 34 columns)
|
||||
|
||||
Tasks on tickets or projects.
|
||||
|
||||
- `ticket_id` → `tickets.id`
|
||||
- `project_id` → `projects.id`
|
||||
- `assigned_resource_id` → `resources.id`
|
||||
- `status`, `priority`, `estimated_hours`, `remaining_hours`
|
||||
|
||||
### billing_items (~96K rows)
|
||||
|
||||
Invoice line items linked to tickets, tasks, or projects.
|
||||
|
||||
- `ticket_id` → `tickets.id`, `task_id` → `tasks.id`, `project_id` → `projects.id`
|
||||
- `company_id` → `companies.id`
|
||||
- `quantity`, `rate`, `total_amount`, `unit_cost`, `unit_price`
|
||||
|
||||
---
|
||||
|
||||
## 2. Lookup / Picklist Tables
|
||||
|
||||
These map integer codes to human-readable labels. Join on `value`.
|
||||
|
||||
### statuses (ticket statuses)
|
||||
|
||||
| Value | Label |
|
||||
|---|---|
|
||||
| 1 | New |
|
||||
| 5 | Complete |
|
||||
| 7 | Waiting Customer |
|
||||
| 8 | In Progress |
|
||||
| 10 | Dispatched |
|
||||
| 11 | Escalate |
|
||||
| 12 | Waiting Vendor |
|
||||
| 13 | Waiting Approval |
|
||||
| 14 | Resource Assigned |
|
||||
| 16 | Reopened |
|
||||
| 19 | End User Note Added |
|
||||
| 25 | On Hold |
|
||||
| 48 | Escalate to MC |
|
||||
| 54 | Resolved \<CSAT Survey\> |
|
||||
| 57 | Escalate to Wulf |
|
||||
|
||||
### priorities
|
||||
|
||||
| Value | Label |
|
||||
|---|---|
|
||||
| 1 | Standard |
|
||||
| 2 | Medium |
|
||||
| 4 | Critical |
|
||||
| 6 | High |
|
||||
| 7 | Security Event |
|
||||
| 8 | Minor Service |
|
||||
| 9 | Major Service |
|
||||
| 11 | Fast Track |
|
||||
|
||||
### queues (46 active)
|
||||
|
||||
Major queues include:
|
||||
- `29682833` Level 1 Support
|
||||
- `29682969` Level 2 Support
|
||||
- `29703428` Level 3 Support
|
||||
- `29749490` Client Success
|
||||
- `8` Monitoring Alert
|
||||
- `29832283` Operations Triage
|
||||
- `5` Client Triage
|
||||
- `29853700` Deployment
|
||||
- `29853698` Project Delivery
|
||||
- `29853701` IT Operations
|
||||
- `29853699` Mission Control
|
||||
- Client-specific queues: TTG, LEC, PER, VCF, Premier Automation, TNT Pizza, Trivium Packaging, Glunt
|
||||
|
||||
---
|
||||
|
||||
## 3. Datto RMM
|
||||
|
||||
### datto_rmm_alerts (~4.3K rows, 61 columns)
|
||||
|
||||
- `id` (int PK), `uid` (text) — alert identifiers
|
||||
- `alert_category`, `alert_type`, `alert_message_en` — what triggered
|
||||
- `priority` (text) — Critical, High, Moderate, Low, Information
|
||||
- `resolved` (boolean), `resolved_on` (timestamptz)
|
||||
- `muted` (boolean)
|
||||
- `ticket_number` (text) — linked Autotask ticket
|
||||
- `device_hostname`, `device_ip`, `device_os`, `device_id`
|
||||
- `site_id` (text) — FK to `datto_rmm_sites`
|
||||
- `timestamp` (timestamptz) — when alert fired
|
||||
|
||||
### datto_rmm_devices (~3.6K rows, 42 columns)
|
||||
|
||||
- `id` (int PK), `uid` (text), `hostname`
|
||||
- `device_type_category` (text) — Server, Desktop, Laptop, Network Device
|
||||
- `operating_system`, `domain`, `int_ip_address`, `ext_ip_address`
|
||||
- `online` (boolean), `last_seen` (timestamptz)
|
||||
- `last_logged_in_user` (text)
|
||||
- `antivirus_product`, `antivirus_status`, `patch_status`
|
||||
- `site_id` (int) — FK to `datto_rmm_sites.id`
|
||||
- `udf` (jsonb) — custom fields
|
||||
|
||||
### datto_rmm_sites (~16 columns)
|
||||
|
||||
- `id` (int PK), `uid`, `name`
|
||||
- `autotask_company_id` (int) — **FK to `companies.id`** (links RMM sites to Autotask clients)
|
||||
- `autotask_company_name`
|
||||
- `number_of_devices`, `number_of_online_devices`
|
||||
|
||||
**Join pattern:** `datto_rmm_sites.autotask_company_id = companies.id`
|
||||
|
||||
---
|
||||
|
||||
## 4. SentinelOne
|
||||
|
||||
### s1_agents (~2.8K rows, 42 columns)
|
||||
|
||||
Endpoint security agents.
|
||||
|
||||
- `id` (varchar PK) — S1 agent ID
|
||||
- `computer_name`, `os_name`, `os_type`
|
||||
- `site_id` → `s1_sites.id`, `site_name`
|
||||
- `is_active`, `is_decommissioned`
|
||||
- `infected` (boolean), `active_threats` (int)
|
||||
- `network_status`, `mitigation_mode`, `detection_state`
|
||||
- `external_ip`, `last_active_date`, `last_logged_in_user_name`
|
||||
- `firewall_enabled` (boolean)
|
||||
|
||||
### s1_threats (~4.1K rows, 25 columns)
|
||||
|
||||
Detected threats.
|
||||
|
||||
- `id` (varchar PK)
|
||||
- `threat_name`, `classification`, `confidence_level`
|
||||
- `mitigation_status`, `analyst_verdict`, `incident_status`
|
||||
- `agent_id` → `s1_agents.id`
|
||||
- `agent_computer_name`, `agent_os_name`
|
||||
- `site_id` → `s1_sites.id`
|
||||
|
||||
### s1_sites (~22 columns)
|
||||
|
||||
- `id` (varchar PK), `name`, `account_name`
|
||||
- `health_status`, `active_licenses`, `total_licenses`
|
||||
|
||||
### s1_company_mappings
|
||||
|
||||
Maps S1 sites to Autotask companies for cross-referencing.
|
||||
|
||||
---
|
||||
|
||||
## 5. Veeam Backup
|
||||
|
||||
### veeam_organizations (~17 columns)
|
||||
|
||||
- `instance_uid` (PK), `name`, `company_id`
|
||||
- All other Veeam tables FK to `veeam_organizations.instance_uid`
|
||||
|
||||
### veeam_backup_jobs (~234 rows)
|
||||
|
||||
- `instance_uid`, `name`, `type`, `status`, `last_run`, `next_run`
|
||||
- `organization_uid` → `veeam_organizations`
|
||||
- `backup_server_uid` → `veeam_backup_servers`
|
||||
|
||||
### veeam_backup_agents (~727 rows)
|
||||
|
||||
- Backup agents installed on endpoints
|
||||
- `organization_uid` → `veeam_organizations`
|
||||
|
||||
### veeam_alarms (~581 rows)
|
||||
|
||||
- Active alarms/alerts
|
||||
- `organization_uid` → `veeam_organizations`
|
||||
|
||||
### veeam_protected_workloads, veeam_repositories, veeam_backup_servers
|
||||
|
||||
Supporting tables for backup infrastructure.
|
||||
|
||||
---
|
||||
|
||||
## 6. IT Glue Documentation
|
||||
|
||||
### itg_organizations (~330 rows)
|
||||
|
||||
- `id` (bigint PK), `name`, `short_name`, `organization_type_name`
|
||||
- `psa_id` (varchar) — Autotask company ID (string). Join: `itg_organizations.psa_id::bigint = companies.id`
|
||||
|
||||
### itg_configurations (~14.7K rows)
|
||||
|
||||
Hardware/software assets documented in IT Glue.
|
||||
|
||||
- `id`, `organization_id` → `itg_organizations.id`
|
||||
- `name`, `hostname`, `serial_number`, `asset_tag`
|
||||
- `configuration_type_name`, `configuration_status_name`
|
||||
- `primary_ip`, `mac_address`, `operating_system`
|
||||
- `warranty_expires_at`, `installed_at`
|
||||
|
||||
### itg_passwords (~17 columns)
|
||||
|
||||
- `id`, `organization_id`, `name`, `username`, `password_category_name`
|
||||
- `url`, `notes`
|
||||
|
||||
### itg_flexible_assets (~3.2K rows)
|
||||
|
||||
Custom documentation (e.g. Backup configs, Email configs, LAN/VLAN, Voice/PBX).
|
||||
|
||||
- `id`, `organization_id`, `flexible_asset_type_id`, `flexible_asset_type_name`
|
||||
- `traits` (jsonb) — all custom field values
|
||||
|
||||
### itg_contacts, itg_documents, itg_expirations, itg_domains, itg_locations
|
||||
|
||||
Supporting IT documentation tables.
|
||||
|
||||
---
|
||||
|
||||
## 7. Engagement & Communications
|
||||
|
||||
### graph_users
|
||||
|
||||
Microsoft 365 users synced from Azure AD.
|
||||
|
||||
- `id` (varchar PK), `display_name`, `email`, `job_title`, `department`
|
||||
- `account_enabled` (boolean)
|
||||
|
||||
### teams_meetings (~14.3K rows)
|
||||
|
||||
Teams calendar events / meetings.
|
||||
|
||||
- `id` (int PK), `user_email`, `subject`
|
||||
- `start_time`, `end_time` (timestamptz), `duration_minutes`
|
||||
- `attendee_count`, `client_attendee_count`, `has_client_attendees` (boolean)
|
||||
|
||||
### teams_meeting_attendees (~12.6K rows)
|
||||
|
||||
- `meeting_id` → `teams_meetings.id`
|
||||
- `attendee_email`, `attendee_name`
|
||||
- `matched_contact_id` → `contacts.id`
|
||||
- `matched_company_id` → `companies.id`
|
||||
|
||||
### engagement_snapshots (~1K rows)
|
||||
|
||||
Weekly/monthly aggregates of M365 activity per user.
|
||||
|
||||
- `user_email`, `period_type` (D7, D30, D90, D180)
|
||||
- `teams_chat_messages`, `teams_calls`, `teams_meetings_attended`, `teams_meetings_organized`
|
||||
- `emails_sent`, `emails_received`, `emails_read`
|
||||
|
||||
### zoom_meetings, zoom_meeting_participants
|
||||
|
||||
- `host_email`, `topic`, `start_time`, `end_time`, `duration_minutes`
|
||||
- Participants with `matched_contact_id` → `contacts.id`, `matched_company_id` → `companies.id`
|
||||
- `is_internal` (boolean) — internal vs external attendee
|
||||
|
||||
### zoom_calls (~2.8K rows)
|
||||
|
||||
- `resource_email`, `direction` (inbound/outbound), `call_status`
|
||||
- `other_party_number`, `other_party_name`
|
||||
- `matched_contact_id`, `matched_company_id`
|
||||
|
||||
---
|
||||
|
||||
## 8. Common Join Patterns
|
||||
|
||||
```sql
|
||||
-- Ticket with company, resource, and status label
|
||||
SELECT t.id, t.title, c.company_name,
|
||||
r.first_name || ' ' || r.last_name AS technician,
|
||||
s.label AS status_label, p.label AS priority_label
|
||||
FROM tickets t
|
||||
LEFT JOIN companies c ON c.id = t.company_id
|
||||
LEFT JOIN resources r ON r.id = t.assigned_resource_id
|
||||
LEFT JOIN statuses s ON s.value = t.status
|
||||
LEFT JOIN priorities p ON p.value = t.priority
|
||||
WHERE t.is_deleted IS NOT TRUE;
|
||||
|
||||
-- Time entries for a ticket
|
||||
SELECT te.entry_date, te.hours_worked, te.notes,
|
||||
r.first_name || ' ' || r.last_name AS technician
|
||||
FROM time_entries te
|
||||
JOIN resources r ON r.id = te.resource_id
|
||||
WHERE te.ticket_id = $1 AND te.is_deleted IS NOT TRUE;
|
||||
|
||||
-- RMM device → Autotask company
|
||||
SELECT d.hostname, d.device_type_category, d.operating_system,
|
||||
s.autotask_company_name, d.online, d.last_seen
|
||||
FROM datto_rmm_devices d
|
||||
JOIN datto_rmm_sites s ON s.id = d.site_id;
|
||||
|
||||
-- Config item lookup (always filter by company too)
|
||||
SELECT ci.id, ci.reference_title, ci.serial_number, c.company_name
|
||||
FROM configuration_items ci
|
||||
JOIN companies c ON c.id = ci.company_id
|
||||
WHERE ci.reference_title = 'DT037' AND ci.company_id = $1;
|
||||
|
||||
-- IT Glue org → Autotask company
|
||||
SELECT ig.name, ig.id AS itg_org_id, c.id AS autotask_company_id, c.company_name
|
||||
FROM itg_organizations ig
|
||||
JOIN companies c ON ig.psa_id::bigint = c.id;
|
||||
|
||||
-- Meetings with client attendees
|
||||
SELECT tm.subject, tm.start_time, tm.duration_minutes,
|
||||
tma.attendee_name, c.company_name
|
||||
FROM teams_meetings tm
|
||||
JOIN teams_meeting_attendees tma ON tma.meeting_id = tm.id
|
||||
LEFT JOIN companies c ON c.id = tma.matched_company_id
|
||||
WHERE tm.has_client_attendees = true;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Important Notes
|
||||
|
||||
1. **Soft deletes:** Most Autotask tables have `is_deleted` (boolean) and `deleted_at`. Always add `WHERE is_deleted IS NOT TRUE` unless you want deleted records.
|
||||
|
||||
2. **Picklist joins:** `status`, `priority`, `queue_id`, `source` on tickets are integer codes. Join to `statuses`, `priorities`, `queues` on `.value` for labels.
|
||||
|
||||
3. **Configuration item names are NOT unique globally.** Names like "DT037" are a per-client naming convention. Always pair with `company_id`.
|
||||
|
||||
4. **Timestamps:** Most Autotask timestamps are `timestamp without time zone` stored in UTC. Teams/Zoom timestamps are `timestamp with time zone`.
|
||||
|
||||
5. **Monitor tickets:** `tickets.source = 8` indicates RMM-generated tickets. `monitor_id` links to the specific Datto RMM monitor. These represent ~74% of all tickets.
|
||||
|
||||
6. **Cross-platform linking:**
|
||||
- RMM → Autotask: `datto_rmm_sites.autotask_company_id = companies.id`
|
||||
- IT Glue → Autotask: `itg_organizations.psa_id::bigint = companies.id`
|
||||
- S1 → Autotask: via `s1_company_mappings`
|
||||
- Zoom/Teams → Contacts: `matched_contact_id` / `matched_company_id` columns
|
||||
- Config Items → RMM: `configuration_items.reference_number` sometimes matches RMM device UIDs
|
||||
|
||||
7. **Row counts** (as of March 2026): tickets 109K, time_entries 154K, billing_items 96K, ticket_notes 30K, teams_meetings 14K, itg_configurations 15K, configuration_items 7K, companies 4K, contacts 4.2K, datto_rmm_devices 3.6K, datto_rmm_alerts 4.3K, s1_agents 2.8K, s1_threats 4.1K, zoom_calls 2.8K.
|
||||
246
app/admin/qbo/page.tsx
Normal file
246
app/admin/qbo/page.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
CheckCircle2, XCircle, AlertTriangle, RefreshCw, Loader2,
|
||||
Link2, Link2Off, FileText, CreditCard, Building2, ArrowDownToLine, BarChart3,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface QboStatus {
|
||||
tokenStatus: 'valid' | 'expired' | 'missing';
|
||||
counts: {
|
||||
invoices: number;
|
||||
payments: number;
|
||||
deposits: number;
|
||||
transactions: number;
|
||||
reports: number;
|
||||
};
|
||||
lastSync: {
|
||||
invoices: string | null;
|
||||
payments: string | null;
|
||||
deposits: string | null;
|
||||
transactions: string | null;
|
||||
reports: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
function fmtDate(d: string | null) {
|
||||
if (!d) return 'Never';
|
||||
const date = new Date(d);
|
||||
const diff = Date.now() - date.getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'Just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
}
|
||||
|
||||
function fmtNum(n: number) {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
const ENTITY_META = [
|
||||
{ key: 'invoices', label: 'Invoices', icon: FileText, color: 'text-blue-400' },
|
||||
{ key: 'payments', label: 'Payments', icon: CreditCard, color: 'text-green-400' },
|
||||
{ key: 'deposits', label: 'Deposits', icon: Building2, color: 'text-purple-400' },
|
||||
{ key: 'transactions', label: 'Transactions', icon: ArrowDownToLine, color: 'text-orange-400' },
|
||||
{ key: 'reports', label: 'Reports', icon: BarChart3, color: 'text-cyan-400' },
|
||||
] as const;
|
||||
|
||||
function QboPageInner() {
|
||||
const searchParams = useSearchParams();
|
||||
const connected = searchParams.get('connected');
|
||||
const disconnected = searchParams.get('disconnected');
|
||||
const errorParam = searchParams.get('error');
|
||||
|
||||
const [status, setStatus] = useState<QboStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
||||
const [banner, setBanner] = useState<{ type: 'success' | 'error' | 'info'; msg: string } | null>(null);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/qbo/sync');
|
||||
const data = await res.json();
|
||||
setStatus(data);
|
||||
} catch {
|
||||
setStatus(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, [fetchStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connected === 'true') setBanner({ type: 'success', msg: 'QuickBooks Online connected successfully.' });
|
||||
else if (disconnected === 'true') setBanner({ type: 'info', msg: 'QuickBooks Online disconnected.' });
|
||||
else if (errorParam) setBanner({ type: 'error', msg: decodeURIComponent(errorParam) });
|
||||
}, [connected, disconnected, errorParam]);
|
||||
|
||||
async function triggerSync(syncType: 'full' | 'incremental') {
|
||||
setSyncing(true);
|
||||
setSyncMessage(null);
|
||||
try {
|
||||
const res = await fetch('/api/qbo/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ syncType, triggeredBy: 'admin-ui' }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setSyncMessage(`${syncType === 'full' ? 'Full' : 'Incremental'} sync started. This may take a few minutes.`);
|
||||
setTimeout(() => fetchStatus(), 10000);
|
||||
setTimeout(() => fetchStatus(), 30000);
|
||||
setTimeout(() => { fetchStatus(); setSyncing(false); }, 60000);
|
||||
} else {
|
||||
setSyncMessage(`Error: ${data.error}`);
|
||||
setSyncing(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setSyncMessage(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
setSyncing(false);
|
||||
}
|
||||
}
|
||||
|
||||
const tokenOk = status?.tokenStatus === 'valid';
|
||||
const tokenBadge = {
|
||||
valid: { icon: CheckCircle2, label: 'Connected', cls: 'text-green-400' },
|
||||
expired: { icon: AlertTriangle, label: 'Token Expired', cls: 'text-yellow-400' },
|
||||
missing: { icon: XCircle, label: 'Not Connected', cls: 'text-red-400' },
|
||||
}[status?.tokenStatus ?? 'missing'];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">QuickBooks Online</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">Sync invoices, payments, deposits, transactions and financial reports</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={fetchStatus} disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Banner */}
|
||||
{banner && (
|
||||
<div className={`flex items-center gap-3 px-4 py-3 rounded-lg border text-sm ${
|
||||
banner.type === 'success' ? 'bg-green-500/10 border-green-500/30 text-green-300' :
|
||||
banner.type === 'error' ? 'bg-red-500/10 border-red-500/30 text-red-300' :
|
||||
'bg-blue-500/10 border-blue-500/30 text-blue-300'
|
||||
}`}>
|
||||
{banner.type === 'success' ? <CheckCircle2 className="w-4 h-4 shrink-0" /> :
|
||||
banner.type === 'error' ? <XCircle className="w-4 h-4 shrink-0" /> :
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />}
|
||||
{banner.msg}
|
||||
<button className="ml-auto opacity-60 hover:opacity-100" onClick={() => setBanner(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection Status */}
|
||||
<div className="rounded-xl border bg-card p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-base">Connection Status</h2>
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<div className={`flex items-center gap-1.5 text-sm font-medium ${tokenBadge.cls}`}>
|
||||
<tokenBadge.icon className="w-4 h-4" />
|
||||
{tokenBadge.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<a href="/api/qbo/auth">
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Link2 className="w-4 h-4" />
|
||||
{tokenOk ? 'Reconnect' : 'Connect to QuickBooks'}
|
||||
</Button>
|
||||
</a>
|
||||
{tokenOk && (
|
||||
<a href="/api/qbo/disconnect">
|
||||
<Button variant="outline" size="sm" className="gap-2 text-red-400 border-red-500/30 hover:bg-red-500/10">
|
||||
<Link2Off className="w-4 h-4" />
|
||||
Disconnect
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync Controls */}
|
||||
<div className="rounded-xl border bg-card p-5 space-y-4">
|
||||
<h2 className="font-semibold text-base">Sync</h2>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<Button
|
||||
onClick={() => triggerSync('full')}
|
||||
disabled={syncing || !tokenOk}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
{syncing ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||
Full Sync
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => triggerSync('incremental')}
|
||||
disabled={syncing || !tokenOk}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
{syncing ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||
Incremental Sync
|
||||
</Button>
|
||||
</div>
|
||||
{syncMessage && (
|
||||
<p className="text-sm text-muted-foreground">{syncMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Entity Counts */}
|
||||
<div className="rounded-xl border bg-card p-5 space-y-4">
|
||||
<h2 className="font-semibold text-base">Synced Data</h2>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Loading...
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
{ENTITY_META.map(({ key, label, icon: Icon, color }) => (
|
||||
<div key={key} className="flex items-center gap-3 p-3 rounded-lg bg-muted/30 border border-border">
|
||||
<Icon className={`w-5 h-5 shrink-0 ${color}`} />
|
||||
<div>
|
||||
<div className="text-lg font-semibold leading-none">
|
||||
{fmtNum(status?.counts[key] ?? 0)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{label}</div>
|
||||
<div className="text-xs text-muted-foreground/60 mt-0.5">
|
||||
{fmtDate(status?.lastSync[key] ?? null)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QboAdminPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-6 text-muted-foreground text-sm">Loading...</div>}>
|
||||
<QboPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
456
app/admin/ticket-digest/page.tsx
Normal file
456
app/admin/ticket-digest/page.tsx
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Send, RefreshCw, CheckCircle2, XCircle,
|
||||
Clock, Loader2, ChevronDown, ChevronUp, BarChart3, Brain,
|
||||
Calendar, CalendarDays, CalendarRange, MessageSquare, Bell, Globe, ExternalLink,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface DigestConfig {
|
||||
daily_enabled: boolean;
|
||||
weekly_enabled: boolean;
|
||||
monthly_enabled: boolean;
|
||||
daily_cron: string;
|
||||
weekly_cron: string;
|
||||
monthly_cron: string;
|
||||
llm_provider: string;
|
||||
llm_model: string;
|
||||
include_noise_analysis: boolean;
|
||||
include_sla_analysis: boolean;
|
||||
include_resource_analysis: boolean;
|
||||
include_client_analysis: boolean;
|
||||
include_recommendations: boolean;
|
||||
channel_ids: number[];
|
||||
}
|
||||
|
||||
interface NotificationChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
channel_type: 'teams' | 'telegram' | 'ntfy' | 'webhook';
|
||||
config: Record<string, any>;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
interface DigestReport {
|
||||
id: number;
|
||||
period_type: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
generated_at: string;
|
||||
stats: any;
|
||||
llm_analysis: string | null;
|
||||
delivery_status: Record<string, { success: boolean; httpStatus?: number; error?: string }>;
|
||||
tokens_used: number | null;
|
||||
processing_time_ms: number | null;
|
||||
}
|
||||
|
||||
function fmtDate(d: string | null) {
|
||||
if (!d) return 'Never';
|
||||
const date = new Date(d);
|
||||
const diff = Date.now() - date.getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'Just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
}
|
||||
|
||||
function ChannelIcon({ type }: { type: string }) {
|
||||
if (type === 'teams') return <MessageSquare className="h-4 w-4 text-indigo-500" />;
|
||||
if (type === 'telegram') return <Send className="h-4 w-4 text-blue-500" />;
|
||||
if (type === 'ntfy') return <Bell className="h-4 w-4 text-green-500" />;
|
||||
return <Globe className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
|
||||
function PeriodIcon({ period }: { period: string }) {
|
||||
if (period === 'daily') return <Calendar className="h-4 w-4 text-blue-500" />;
|
||||
if (period === 'weekly') return <CalendarDays className="h-4 w-4 text-purple-500" />;
|
||||
return <CalendarRange className="h-4 w-4 text-orange-500" />;
|
||||
}
|
||||
|
||||
export default function TicketDigestPage() {
|
||||
const [config, setConfig] = useState<DigestConfig | null>(null);
|
||||
const [channels, setChannels] = useState<NotificationChannel[]>([]);
|
||||
const [history, setHistory] = useState<DigestReport[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<{ msg: string; ok: boolean } | null>(null);
|
||||
const [expandedReport, setExpandedReport] = useState<number | null>(null);
|
||||
const [previewData, setPreviewData] = useState<any>(null);
|
||||
const [previewPeriod, setPreviewPeriod] = useState<string | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
|
||||
const showToast = useCallback((msg: string, ok: boolean) => {
|
||||
setToast({ msg, ok });
|
||||
setTimeout(() => setToast(null), 4000);
|
||||
}, []);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/reports/ticket-digest');
|
||||
const data = await res.json();
|
||||
setConfig(data.config);
|
||||
setChannels(data.channels || []);
|
||||
setHistory(data.history || []);
|
||||
} catch (e) {
|
||||
showToast('Failed to load data', false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const generateReport = async (period: string) => {
|
||||
setGenerating(period);
|
||||
try {
|
||||
const res = await fetch('/api/reports/ticket-digest', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ period }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showToast(`${period} digest generated (${data.processingTimeMs}ms)`, true);
|
||||
loadData();
|
||||
} else {
|
||||
showToast(data.error || 'Generation failed', false);
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Network error', false);
|
||||
} finally {
|
||||
setGenerating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPreview = async (period: string) => {
|
||||
if (previewPeriod === period) { setPreviewPeriod(null); setPreviewData(null); return; }
|
||||
setPreviewLoading(true);
|
||||
setPreviewPeriod(period);
|
||||
try {
|
||||
const res = await fetch(`/api/reports/ticket-digest?preview=${period}`);
|
||||
const data = await res.json();
|
||||
setPreviewData(data.stats);
|
||||
} catch {
|
||||
showToast('Failed to load preview', false);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateConfig = async (updates: Partial<DigestConfig>) => {
|
||||
try {
|
||||
const res = await fetch('/api/reports/ticket-digest/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
const data = await res.json();
|
||||
setConfig(data.config);
|
||||
showToast('Config updated', true);
|
||||
} catch {
|
||||
showToast('Failed to update config', false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleChannelId = async (id: number) => {
|
||||
if (!config) return;
|
||||
const current = config.channel_ids || [];
|
||||
const updated = current.includes(id)
|
||||
? current.filter(c => c !== id)
|
||||
: [...current, id];
|
||||
await updateConfig({ channel_ids: updated });
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4 max-w-4xl space-y-8">
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={`fixed top-4 right-4 z-50 px-4 py-2 rounded-lg shadow-lg text-sm text-white ${toast.ok ? 'bg-green-600' : 'bg-red-600'}`}>
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<BarChart3 className="h-6 w-6" /> Ticket Digest Reports
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
LLM-analyzed ticket reports delivered to Teams — daily, weekly, and monthly
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={loadData}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" /> Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Generate Reports */}
|
||||
<div className="border rounded-lg p-5 space-y-4">
|
||||
<h2 className="font-semibold text-lg flex items-center gap-2">
|
||||
<Brain className="h-5 w-5" /> Generate Report
|
||||
</h2>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(['daily', 'weekly', 'monthly'] as const).map(p => (
|
||||
<div key={p} className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<PeriodIcon period={p} />
|
||||
<span className="font-medium capitalize">{p}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => generateReport(p)}
|
||||
disabled={!!generating}
|
||||
className="flex-1"
|
||||
>
|
||||
{generating === p ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Send className="h-4 w-4 mr-1" />}
|
||||
Generate & Send
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => loadPreview(p)}
|
||||
disabled={previewLoading}
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{previewPeriod && previewData && (
|
||||
<div className="border rounded-lg p-4 bg-muted/30 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold capitalize">{previewPeriod} Preview — {previewData.period?.label}</h3>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setPreviewPeriod(null); setPreviewData(null); }}>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3 text-center">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-2xl font-bold">{previewData.overview?.total_created ?? 0}</div>
|
||||
<div className="text-xs text-muted-foreground">Created</div>
|
||||
</div>
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-2xl font-bold">{previewData.overview?.total_resolved ?? 0}</div>
|
||||
<div className="text-xs text-muted-foreground">Resolved</div>
|
||||
</div>
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-2xl font-bold">{previewData.overview?.avg_resolution_hours ?? '—'}h</div>
|
||||
<div className="text-xs text-muted-foreground">Avg Resolve</div>
|
||||
</div>
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-2xl font-bold">{(previewData.overview?.total_hours_worked ?? 0).toFixed(1)}h</div>
|
||||
<div className="text-xs text-muted-foreground">Hours Worked</div>
|
||||
</div>
|
||||
</div>
|
||||
{previewData.noise_candidates?.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-1">🔁 Noise Candidates ({previewData.noise_candidates.length})</h4>
|
||||
<div className="text-xs space-y-1 max-h-40 overflow-y-auto">
|
||||
{previewData.noise_candidates.slice(0, 10).map((n: any, i: number) => (
|
||||
<div key={i} className="flex justify-between bg-background rounded px-2 py-1">
|
||||
<span className="truncate">{n.title}</span>
|
||||
<span className="text-muted-foreground shrink-0 ml-2">{n.count}× · {n.source_label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{previewData.top_clients?.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-1">🏢 Top Clients</h4>
|
||||
<div className="text-xs space-y-1">
|
||||
{previewData.top_clients.slice(0, 5).map((c: any, i: number) => (
|
||||
<div key={i} className="flex justify-between bg-background rounded px-2 py-1">
|
||||
<span>{c.company_name}</span>
|
||||
<span className="text-muted-foreground">{c.ticket_count} tickets · {c.hours_worked.toFixed(1)}h</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Analysis Sections Config */}
|
||||
{config && (
|
||||
<div className="border rounded-lg p-5 space-y-4">
|
||||
<h2 className="font-semibold text-lg">Analysis Sections</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{([
|
||||
{ key: 'include_noise_analysis', label: '🔁 Noise & Automation' },
|
||||
{ key: 'include_sla_analysis', label: '⏱️ SLA & Response Times' },
|
||||
{ key: 'include_resource_analysis', label: '👥 Team Workload' },
|
||||
{ key: 'include_client_analysis', label: '🏢 Client Spotlight' },
|
||||
{ key: 'include_recommendations', label: '💡 Recommendations' },
|
||||
] as const).map(({ key, label }) => (
|
||||
<label key={key} className="flex items-center gap-2 cursor-pointer p-2 rounded hover:bg-muted/50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(config as any)[key]}
|
||||
onChange={() => updateConfig({ [key]: !(config as any)[key] })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm">{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<label className="flex items-center gap-2">
|
||||
Provider:
|
||||
<select
|
||||
value={config.llm_provider}
|
||||
onChange={e => updateConfig({ llm_provider: e.target.value })}
|
||||
className="border rounded px-2 py-1 bg-background"
|
||||
>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
Model:
|
||||
<input
|
||||
value={config.llm_model}
|
||||
onChange={e => updateConfig({ llm_model: e.target.value })}
|
||||
className="border rounded px-2 py-1 bg-background w-56"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notification Channels */}
|
||||
<div className="border rounded-lg p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">Delivery Channels</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Select which notification channels receive these reports. Manage channels in <a href="/admin/workflow/channels" className="underline">Notification Channels</a>.</p>
|
||||
</div>
|
||||
<a href="/admin/workflow/channels" className="text-xs text-muted-foreground flex items-center gap-1 hover:text-foreground">
|
||||
<ExternalLink className="h-3 w-3" /> Manage Channels
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{channels.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
No notification channels configured yet.{' '}
|
||||
<a href="/admin/workflow/channels" className="underline">Add one here.</a>
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{channels.map(ch => {
|
||||
const selected = config?.channel_ids?.includes(ch.id) ?? false;
|
||||
return (
|
||||
<label
|
||||
key={ch.id}
|
||||
className={`flex items-center gap-3 border rounded-lg p-3 cursor-pointer transition-colors ${
|
||||
selected ? 'border-primary bg-primary/5' : 'hover:bg-muted/30'
|
||||
} ${!ch.is_active ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => toggleChannelId(ch.id)}
|
||||
className="rounded"
|
||||
disabled={!ch.is_active}
|
||||
/>
|
||||
<ChannelIcon type={ch.channel_type} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm">{ch.name}</div>
|
||||
<div className="text-xs text-muted-foreground capitalize">{ch.channel_type}{!ch.is_active ? ' · Inactive' : ''}</div>
|
||||
</div>
|
||||
{selected && (
|
||||
<span className="text-xs text-primary font-medium">Selected</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config && (config.channel_ids?.length ?? 0) === 0 && channels.length > 0 && (
|
||||
<p className="text-xs text-amber-500">⚠️ No channels selected — reports will be generated but not delivered.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* History */}
|
||||
<div className="border rounded-lg p-5 space-y-4">
|
||||
<h2 className="font-semibold text-lg">Report History</h2>
|
||||
{history.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">No reports generated yet. Generate your first report above.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{history.map(report => {
|
||||
const isExpanded = expandedReport === report.id;
|
||||
const ov = report.stats?.overview;
|
||||
return (
|
||||
<div key={report.id} className="border rounded-lg overflow-hidden">
|
||||
<button
|
||||
className="w-full flex items-center justify-between p-3 hover:bg-muted/30 text-left"
|
||||
onClick={() => setExpandedReport(isExpanded ? null : report.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<PeriodIcon period={report.period_type} />
|
||||
<div>
|
||||
<span className="font-medium text-sm capitalize">{report.period_type}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{new Date(report.generated_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
{ov && (
|
||||
<span className="text-muted-foreground">
|
||||
{ov.total_created} created · {ov.total_resolved} resolved
|
||||
</span>
|
||||
)}
|
||||
{report.tokens_used && (
|
||||
<span className="text-muted-foreground">{report.tokens_used} tokens</span>
|
||||
)}
|
||||
{report.processing_time_ms && (
|
||||
<span className="text-muted-foreground">{(report.processing_time_ms / 1000).toFixed(1)}s</span>
|
||||
)}
|
||||
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && report.llm_analysis && (
|
||||
<div className="px-4 pb-4 border-t">
|
||||
<div className="mt-3 prose prose-sm max-w-none dark:prose-invert text-sm whitespace-pre-wrap">
|
||||
{report.llm_analysis}
|
||||
</div>
|
||||
{report.delivery_status && Object.keys(report.delivery_status).length > 0 && (
|
||||
<div className="mt-3 border-t pt-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">Delivery:</span>
|
||||
{Object.entries(report.delivery_status).map(([whId, st]) => (
|
||||
<span key={whId} className={`text-xs ml-2 ${(st as any).success ? 'text-green-500' : 'text-red-500'}`}>
|
||||
#{whId}: {(st as any).success ? 'OK' : (st as any).error || 'Failed'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -41,6 +41,12 @@ import {
|
|||
ChevronDown,
|
||||
ChevronRight,
|
||||
Network,
|
||||
ShieldAlert,
|
||||
Layers,
|
||||
BookOpen,
|
||||
Activity,
|
||||
Clipboard,
|
||||
ClipboardCheck,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { HostManager } from '@/components/zabbix/host-manager';
|
||||
|
|
@ -102,7 +108,37 @@ function ActionBadge({ action }: { action: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Gap Analysis types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
interface GapSummary {
|
||||
total_rmm_sites: string;
|
||||
total_zabbix_hosts: string;
|
||||
zabbix_enabled: string;
|
||||
rmm_sites_no_zabbix: string;
|
||||
total_itg_circuits: string;
|
||||
itg_circuits_monitored: string;
|
||||
itg_circuits_gap: string;
|
||||
zabbix_last_synced: string | null;
|
||||
itg_last_synced: string | null;
|
||||
}
|
||||
interface RmmGap { rmm_site_uid: string; rmm_site_name: string; company_name: string; company_id: number; number_of_devices: number; number_of_online_devices: number; }
|
||||
interface ItgGap { itg_asset_id: number; org_name: string; autotask_company_id: number | null; provider: string; link_type: string; static_ips: string[]; location_name: string; location_city: string; upload_mbps: number; download_mbps: number; }
|
||||
interface MultiCircuit { org_name: string; autotask_company_id: number | null; total_circuits: string; monitored_circuits: string; gap_circuits: string; circuits: Array<{ id: number; provider: string; link_type: string; static_ips: string[]; location_name: string; zabbix_hostid: string | null; is_decommissioned: boolean; }>; }
|
||||
interface ProblemRow { hostid: string; display_name: string; wan_ip: string; isp_name: string; autotask_company_name: string; autotask_company_id: number; rmm_site_uid: string; last_problem_at: string; last_problem_name: string; open_rmm_alerts_24h: string; latest_rmm_network_alert: string | null; monitor_tickets_24h: string; }
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Correlation types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
interface CorrSummary { days: number; window_mins: number; zabbix_events_total: number; zabbix_with_rmm_match: number; zabbix_without_rmm_match: number; rmm_only_alerts: number; last_event_sync: string | null; }
|
||||
interface RmmAlertRef { alert_uid: string; site_name: string; alert_class: string; alert_message: string | null; timestamp: string; resolved: boolean; resolved_on: string | null; device_name: string | null; }
|
||||
interface ZabbixEventRow { eventid: string; name: string; severity: number; clock: string; r_clock: string | null; duration_seconds: number | null; host_name: string; wan_ip: string; isp_name: string | null; autotask_company_id: number; autotask_company_name: string; rmm_site_uid: string | null; rmm_alert_count: string; rmm_alerts: RmmAlertRef[] | null; }
|
||||
interface RmmOnlyRow { alert_uid: string; site_name: string; alert_class: string; alert_message: string | null; timestamp: string; resolved: boolean; resolved_on: string | null; device_name: string | null; autotask_company_id: number | null; autotask_company_name: string | null; }
|
||||
|
||||
type PageTab = 'sync' | 'gaps' | 'correlation';
|
||||
|
||||
export default function ZabbixWanPage() {
|
||||
const [activeTab, setActiveTab] = useState<PageTab>('sync');
|
||||
const [mode, setMode] = useState<SyncMode>('all');
|
||||
const [companyId, setCompanyId] = useState<string>('');
|
||||
const [siteUid, setSiteUid] = useState<string>('');
|
||||
|
|
@ -122,6 +158,117 @@ export default function ZabbixWanPage() {
|
|||
const abortRef = useRef<AbortController | null>(null);
|
||||
const tableBottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Gap analysis state
|
||||
const [gapLoading, setGapLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState<'zabbix' | 'itg' | null>(null);
|
||||
const [gapSummary, setGapSummary] = useState<GapSummary | null>(null);
|
||||
const [rmmGaps, setRmmGaps] = useState<RmmGap[]>([]);
|
||||
const [itgGaps, setItgGaps] = useState<ItgGap[]>([]);
|
||||
const [multiCircuit, setMultiCircuit] = useState<MultiCircuit[]>([]);
|
||||
const [problems, setProblems] = useState<ProblemRow[]>([]);
|
||||
const [gapError, setGapError] = useState<string | null>(null);
|
||||
const [expandedMulti, setExpandedMulti] = useState<Set<string>>(new Set());
|
||||
|
||||
const loadGapAnalysis = async () => {
|
||||
setGapLoading(true); setGapError(null);
|
||||
try {
|
||||
const r = await fetch('/api/zabbix/wan-gap-analysis');
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.error);
|
||||
setGapSummary(d.summary);
|
||||
setRmmGaps(d.rmm_gaps ?? []);
|
||||
setItgGaps(d.itg_gaps ?? []);
|
||||
setMultiCircuit(d.multi_circuit ?? []);
|
||||
setProblems(d.problems ?? []);
|
||||
} catch (e) { setGapError(String(e)); }
|
||||
finally { setGapLoading(false); }
|
||||
};
|
||||
|
||||
const syncZabbixHosts = async () => {
|
||||
setSyncing('zabbix');
|
||||
try {
|
||||
const r = await fetch('/api/zabbix/sync-hosts', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.error);
|
||||
toast.success(`Zabbix hosts synced — ${d.upserted} upserted, ${d.removed} removed`);
|
||||
await loadGapAnalysis();
|
||||
} catch (e) { toast.error('Zabbix sync failed: ' + String(e)); }
|
||||
finally { setSyncing(null); }
|
||||
};
|
||||
|
||||
const syncItgCircuits = async () => {
|
||||
setSyncing('itg');
|
||||
try {
|
||||
const r = await fetch('/api/itglue/sync-wan', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.error);
|
||||
toast.success(`IT Glue WAN synced — ${d.upserted} circuits, ${d.matched_zabbix} matched to Zabbix`);
|
||||
await loadGapAnalysis();
|
||||
} catch (e) { toast.error('IT Glue sync failed: ' + String(e)); }
|
||||
finally { setSyncing(null); }
|
||||
};
|
||||
|
||||
const fmtSynced = (ts: string | null) => ts ? new Date(ts).toLocaleString() : 'Never';
|
||||
const fmtTs = (ts: string | null) => ts ? new Date(ts).toLocaleString() : '—';
|
||||
const fmtDuration = (s: number | null, hasRecovery?: boolean) => {
|
||||
if (s === null || s === undefined) return hasRecovery ? '< 1m' : 'Open';
|
||||
if (s === 0) return hasRecovery ? '< 1m' : 'Open';
|
||||
if (s < 60) return `${s}s`;
|
||||
if (s < 3600) return `${Math.round(s/60)}m`;
|
||||
return `${Math.floor(s/3600)}h ${Math.round((s%3600)/60)}m`;
|
||||
};
|
||||
const severityLabel = (s: number) => ['','Info','Warning','Average','High','Disaster'][s] ?? String(s);
|
||||
const severityClass = (s: number) => s >= 4 ? 'text-red-600 font-semibold' : s === 3 ? 'text-orange-500' : 'text-yellow-500';
|
||||
|
||||
// Correlation state
|
||||
const [corrDays, setCorrDays] = useState(30);
|
||||
const [corrWindow, setCorrWindow] = useState(120);
|
||||
const [corrLoading, setCorrLoading] = useState(false);
|
||||
const [corrSummary, setCorrSummary] = useState<CorrSummary | null>(null);
|
||||
const [zabbixEvents, setZabbixEvents] = useState<ZabbixEventRow[]>([]);
|
||||
const [rmmOnly, setRmmOnly] = useState<RmmOnlyRow[]>([]);
|
||||
const [corrError, setCorrError] = useState<string | null>(null);
|
||||
const [expandedEvent, setExpandedEvent] = useState<Set<string>>(new Set());
|
||||
const [corrFilter, setCorrFilter] = useState<'all' | 'matched' | 'unmatched'>('all');
|
||||
const [webhookConfig, setWebhookConfig] = useState<{ script: string; parameters: Array<{name:string;value:string}>; webhook_url: string } | null>(null);
|
||||
const [copied, setCopied] = useState<'script'|'url'|null>(null);
|
||||
const [webhookCollapsed, setWebhookCollapsed] = useState(true);
|
||||
const [rmmOnlyCollapsed, setRmmOnlyCollapsed] = useState(true);
|
||||
|
||||
const loadWebhookConfig = async () => {
|
||||
try {
|
||||
const r = await fetch('/api/zabbix/webhook');
|
||||
const d = await r.json();
|
||||
setWebhookConfig(d);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const copyText = async (text: string, key: 'script'|'url') => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(key);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
const loadCorrelation = async () => {
|
||||
setCorrLoading(true); setCorrError(null);
|
||||
try {
|
||||
const r = await fetch(`/api/zabbix/alert-correlation?days=${corrDays}&windowMins=${corrWindow}`);
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.error);
|
||||
setCorrSummary(d.summary);
|
||||
setZabbixEvents(d.zabbix_events ?? []);
|
||||
setRmmOnly(d.rmm_only ?? []);
|
||||
} catch (e) { setCorrError(String(e)); }
|
||||
finally { setCorrLoading(false); }
|
||||
};
|
||||
|
||||
|
||||
const filteredEvents = zabbixEvents.filter(e =>
|
||||
corrFilter === 'all' ? true :
|
||||
corrFilter === 'matched' ? Number(e.rmm_alert_count) > 0 :
|
||||
Number(e.rmm_alert_count) === 0
|
||||
);
|
||||
|
||||
// Manual host creation state
|
||||
const [manualOpen, setManualOpen] = useState(false);
|
||||
const [manualIp, setManualIp] = useState('');
|
||||
|
|
@ -289,11 +436,6 @@ export default function ZabbixWanPage() {
|
|||
<div className="container mx-auto py-8 max-w-6xl space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/sync">
|
||||
<Button variant="ghost" size="sm" className="gap-2">
|
||||
<ArrowLeft className="w-4 h-4" /> Back
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Globe className="w-6 h-6" /> Zabbix WAN Monitor Setup
|
||||
|
|
@ -304,6 +446,29 @@ export default function ZabbixWanPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b">
|
||||
{([['sync', Globe, 'WAN Sync'], ['gaps', ShieldAlert, 'Gap Analysis'], ['correlation', Activity, 'Alert Correlation']] as const).map(([tab, Icon, label]) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => {
|
||||
setActiveTab(tab as PageTab);
|
||||
if (tab === 'gaps' && !gapSummary) loadGapAnalysis();
|
||||
if (tab === 'correlation' && !webhookConfig) loadWebhookConfig();
|
||||
}}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" /> {label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── WAN Sync tab ─────────────────────────────────────────────────── */}
|
||||
{activeTab === 'sync' && (<>
|
||||
{/* Config card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
|
|
@ -732,6 +897,531 @@ export default function ZabbixWanPage() {
|
|||
Configure your options above and click {dryRun ? 'Preview' : 'Run'} to start.
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
|
||||
{/* ── Alert Correlation tab ──────────────────────────────────────── */}
|
||||
{activeTab === 'correlation' && (
|
||||
<div className="space-y-6">
|
||||
{/* Webhook setup */}
|
||||
{webhookConfig && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3 cursor-pointer select-none" onClick={() => setWebhookCollapsed(v => !v)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2"><Clipboard className="w-4 h-4" /> Zabbix Webhook Setup</CardTitle>
|
||||
{webhookCollapsed ? <ChevronRight className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
||||
</div>
|
||||
{webhookCollapsed && <CardDescription>Click to expand setup instructions</CardDescription>}
|
||||
{!webhookCollapsed && <CardDescription>Configure a Webhook media type in Zabbix → Administration → Media types, then create an action that sends to all WAN hosts</CardDescription>}
|
||||
</CardHeader>
|
||||
{!webhookCollapsed && <CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<code className="flex-1 bg-muted rounded px-3 py-2 text-sm font-mono truncate">{webhookConfig.webhook_url}</code>
|
||||
<Button variant="outline" size="sm" onClick={() => copyText(webhookConfig.webhook_url, 'url')} className="gap-1.5 shrink-0">
|
||||
{copied === 'url' ? <ClipboardCheck className="w-3.5 h-3.5 text-green-500" /> : <Clipboard className="w-3.5 h-3.5" />}
|
||||
{copied === 'url' ? 'Copied' : 'Copy URL'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">Media type script</p>
|
||||
<Button variant="outline" size="sm" onClick={() => copyText(webhookConfig.script, 'script')} className="gap-1.5">
|
||||
{copied === 'script' ? <ClipboardCheck className="w-3.5 h-3.5 text-green-500" /> : <Clipboard className="w-3.5 h-3.5" />}
|
||||
{copied === 'script' ? 'Copied' : 'Copy Script'}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="bg-muted rounded p-3 text-xs font-mono overflow-x-auto max-h-40 leading-relaxed">{webhookConfig.script}</pre>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm font-medium">Parameters to add</p>
|
||||
<div className="border rounded divide-y text-xs">
|
||||
{webhookConfig.parameters.map(p => (
|
||||
<div key={p.name} className="flex items-center px-3 py-1.5 gap-4">
|
||||
<span className="font-mono w-36 shrink-0 text-muted-foreground">{p.name}</span>
|
||||
<span className="font-mono">{p.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2"><Activity className="w-4 h-4" /> Alert Correlation</CardTitle>
|
||||
<CardDescription>Compare Zabbix WAN events against Datto RMM ping/offline alerts for the same company and time window</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={loadCorrelation} disabled={corrLoading} className="gap-2">
|
||||
{corrLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />} Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-end gap-6">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">Look-back period</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input type="number" min={1} max={90} value={corrDays} onChange={e => setCorrDays(Math.max(1,Number(e.target.value)))} className="w-20" />
|
||||
<span className="text-sm text-muted-foreground">days</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">Match window</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input type="number" min={5} max={480} value={corrWindow} onChange={e => setCorrWindow(Math.max(5,Number(e.target.value)))} className="w-20" />
|
||||
<span className="text-sm text-muted-foreground">min ±</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{corrSummary && (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">Last event sync: {fmtSynced(corrSummary.last_event_sync)}</p>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="border rounded-lg p-3 text-center"><p className="text-2xl font-bold">{corrSummary.zabbix_events_total}</p><p className="text-xs text-muted-foreground mt-0.5">Zabbix events</p></div>
|
||||
<div className="border rounded-lg p-3 text-center bg-green-500/5">
|
||||
<p className="text-2xl font-bold text-green-600">{corrSummary.zabbix_with_rmm_match}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Zabbix + RMM correlated</p>
|
||||
</div>
|
||||
<div className="border rounded-lg p-3 text-center bg-amber-500/5">
|
||||
<p className="text-2xl font-bold text-amber-600">{corrSummary.zabbix_without_rmm_match}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Zabbix only (no RMM match)</p>
|
||||
</div>
|
||||
<div className="border rounded-lg p-3 text-center bg-blue-500/5">
|
||||
<p className="text-2xl font-bold text-blue-600">{corrSummary.rmm_only_alerts}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">RMM only (no Zabbix event)</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{corrError && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive border border-destructive/30 bg-destructive/5 rounded-md p-3">
|
||||
<AlertTriangle className="w-4 h-4" /> {corrError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zabbix events table */}
|
||||
{(filteredEvents.length > 0 || corrSummary) && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2"><Globe className="w-4 h-4" /> Zabbix WAN Events</CardTitle>
|
||||
<div className="flex gap-1">
|
||||
{(['all','matched','unmatched'] as const).map(f => (
|
||||
<button key={f} onClick={() => setCorrFilter(f)}
|
||||
className={`px-3 py-1 rounded text-xs font-medium border transition-colors ${
|
||||
corrFilter === f ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-accent'
|
||||
}`}>
|
||||
{f === 'all' ? `All (${zabbixEvents.length})` : f === 'matched' ? `RMM match (${corrSummary?.zabbix_with_rmm_match ?? 0})` : `No RMM (${corrSummary?.zabbix_without_rmm_match ?? 0})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="max-h-[600px] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow>
|
||||
<TableHead>Host / Client</TableHead>
|
||||
<TableHead>WAN IP</TableHead>
|
||||
<TableHead>Problem</TableHead>
|
||||
<TableHead>Severity</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead className="text-center">RMM Alerts</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredEvents.map(ev => {
|
||||
const matched = Number(ev.rmm_alert_count) > 0;
|
||||
const expanded = expandedEvent.has(ev.eventid);
|
||||
const alerts: RmmAlertRef[] = ev.rmm_alerts ?? [];
|
||||
return (<>
|
||||
<TableRow
|
||||
key={ev.eventid}
|
||||
className={`cursor-pointer ${matched ? 'hover:bg-green-500/5' : 'hover:bg-amber-500/5 bg-amber-500/[0.03]'}`}
|
||||
onClick={() => setExpandedEvent(prev => { const n = new Set(prev); expanded ? n.delete(ev.eventid) : n.add(ev.eventid); return n; })}
|
||||
>
|
||||
<TableCell>
|
||||
<p className="font-medium text-sm">{ev.host_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{ev.autotask_company_name}</p>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{ev.wan_ip}</TableCell>
|
||||
<TableCell className="text-sm max-w-[200px] truncate" title={ev.name}>{ev.name}</TableCell>
|
||||
<TableCell><span className={`text-sm ${severityClass(ev.severity)}`}>{severityLabel(ev.severity)}</span></TableCell>
|
||||
<TableCell className="text-sm">{fmtTs(ev.clock)}</TableCell>
|
||||
<TableCell className="text-sm tabular-nums">{fmtDuration(ev.duration_seconds, !!ev.r_clock)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{matched
|
||||
? <Badge variant="default" className="bg-green-600">{ev.rmm_alert_count}</Badge>
|
||||
: <Badge variant="outline" className="text-amber-600 border-amber-400">0</Badge>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expanded && alerts.length > 0 && alerts.map(a => (
|
||||
<TableRow key={a.alert_uid} className="bg-green-500/5">
|
||||
<TableCell className="pl-8 text-xs text-muted-foreground" colSpan={2}>
|
||||
{a.device_name && <span className="font-medium text-foreground">{a.device_name}</span>} · {a.site_name}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs" colSpan={2}>
|
||||
<Badge variant="outline" className="text-xs mr-2">{a.alert_class}</Badge>
|
||||
<span className="text-muted-foreground truncate max-w-[200px]">{a.alert_message ?? '—'}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{fmtTs(a.timestamp)}</TableCell>
|
||||
<TableCell className="text-xs">{a.resolved ? 'Resolved' : 'Open'}</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
))}
|
||||
{expanded && alerts.length === 0 && (
|
||||
<TableRow className="bg-amber-500/5">
|
||||
<TableCell colSpan={7} className="pl-8 text-xs text-muted-foreground italic">No RMM alerts found within ±{corrWindow}min for this company</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* RMM-only alerts */}
|
||||
{rmmOnly.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3 cursor-pointer select-none" onClick={() => setRmmOnlyCollapsed(v => !v)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-blue-500" /> RMM-Only Alerts <Badge variant="outline">{rmmOnly.length}</Badge>
|
||||
</CardTitle>
|
||||
{rmmOnlyCollapsed ? <ChevronRight className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
||||
</div>
|
||||
<CardDescription>RMM ping/offline alerts with no matching Zabbix event — potential Zabbix coverage gap</CardDescription>
|
||||
</CardHeader>
|
||||
{!rmmOnlyCollapsed && <CardContent className="p-0">
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow>
|
||||
<TableHead>Company</TableHead>
|
||||
<TableHead>Site</TableHead>
|
||||
<TableHead>Device</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Message</TableHead>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rmmOnly.map(a => (
|
||||
<TableRow key={a.alert_uid}>
|
||||
<TableCell className="text-sm font-medium">{a.autotask_company_name ?? '—'}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{a.site_name}</TableCell>
|
||||
<TableCell className="text-sm">{a.device_name ?? '—'}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="text-xs">{a.alert_class}</Badge></TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-[220px] truncate" title={a.alert_message ?? ''}>{a.alert_message ?? '—'}</TableCell>
|
||||
<TableCell className="text-sm">{fmtTs(a.timestamp)}</TableCell>
|
||||
<TableCell><Badge variant={a.resolved ? 'outline' : 'destructive'} className="text-xs">{a.resolved ? 'Resolved' : 'Open'}</Badge></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!corrLoading && !corrSummary && !corrError && (
|
||||
<div className="text-center py-16 text-muted-foreground text-sm">
|
||||
Click <strong>Refresh</strong> to load correlation data.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Gap Analysis tab ─────────────────────────────────────────────── */}
|
||||
{activeTab === 'gaps' && (
|
||||
<div className="space-y-6">
|
||||
{/* Sync controls */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2"><Layers className="w-4 h-4" /> Data Sources</CardTitle>
|
||||
<CardDescription>Sync Zabbix hosts and IT Glue WAN circuits into local cache, then run gap analysis</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={loadGapAnalysis} disabled={gapLoading} className="gap-2">
|
||||
{gapLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />} Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Zabbix */}
|
||||
<div className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-sm flex items-center gap-2"><Globe className="w-4 h-4" /> Zabbix Hosts</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Last synced: {fmtSynced(gapSummary?.zabbix_last_synced ?? null)}</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={syncZabbixHosts} disabled={!!syncing} className="gap-2">
|
||||
{syncing === 'zabbix' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />} Sync
|
||||
</Button>
|
||||
</div>
|
||||
{gapSummary && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="bg-muted/40 rounded p-2"><p className="text-muted-foreground">Total hosts</p><p className="font-semibold text-base">{gapSummary.total_zabbix_hosts}</p></div>
|
||||
<div className="bg-muted/40 rounded p-2"><p className="text-muted-foreground">Enabled</p><p className="font-semibold text-base">{gapSummary.zabbix_enabled}</p></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* IT Glue */}
|
||||
<div className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-sm flex items-center gap-2"><BookOpen className="w-4 h-4" /> IT Glue WAN Circuits</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Last synced: {fmtSynced(gapSummary?.itg_last_synced ?? null)}</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={syncItgCircuits} disabled={!!syncing} className="gap-2">
|
||||
{syncing === 'itg' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />} Sync
|
||||
</Button>
|
||||
</div>
|
||||
{gapSummary && (
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div className="bg-muted/40 rounded p-2"><p className="text-muted-foreground">Total circuits</p><p className="font-semibold text-base">{gapSummary.total_itg_circuits}</p></div>
|
||||
<div className="bg-green-500/10 rounded p-2"><p className="text-muted-foreground">Monitored</p><p className="font-semibold text-base text-green-600">{gapSummary.itg_circuits_monitored}</p></div>
|
||||
<div className="bg-amber-500/10 rounded p-2"><p className="text-muted-foreground">Gaps</p><p className="font-semibold text-base text-amber-600">{gapSummary.itg_circuits_gap}</p></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{gapSummary && (
|
||||
<div className="mt-4 grid grid-cols-3 gap-4 text-sm border-t pt-4">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold">{gapSummary.total_rmm_sites}</p>
|
||||
<p className="text-xs text-muted-foreground">RMM sites mapped</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className={`text-2xl font-bold ${Number(gapSummary.rmm_sites_no_zabbix) > 0 ? 'text-amber-500' : 'text-green-600'}`}>
|
||||
{gapSummary.rmm_sites_no_zabbix}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">RMM sites without Zabbix host</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className={`text-2xl font-bold ${Number(gapSummary.itg_circuits_gap) > 0 ? 'text-amber-500' : 'text-green-600'}`}>
|
||||
{gapSummary.itg_circuits_gap}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">IT Glue circuits unmonitored</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{gapError && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive border border-destructive/30 bg-destructive/5 rounded-md p-3">
|
||||
<AlertTriangle className="w-4 h-4" /> {gapError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Zabbix problems + RMM correlation */}
|
||||
{problems.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2"><Activity className="w-4 h-4 text-red-500" /> Active / Recent Problems <Badge variant="destructive">{problems.length}</Badge></CardTitle>
|
||||
<CardDescription>Zabbix WAN problems in the last 24h correlated with RMM alerts and monitor tickets</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Host / Client</TableHead>
|
||||
<TableHead>WAN IP</TableHead>
|
||||
<TableHead>ISP</TableHead>
|
||||
<TableHead>Problem</TableHead>
|
||||
<TableHead className="text-center">RMM Alerts 24h</TableHead>
|
||||
<TableHead className="text-center">Monitor Tickets 24h</TableHead>
|
||||
<TableHead>Latest RMM Network Alert</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{problems.map(p => (
|
||||
<TableRow key={p.hostid} className="bg-red-500/5">
|
||||
<TableCell>
|
||||
<p className="font-medium text-sm">{p.display_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{p.autotask_company_name}</p>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{p.wan_ip}</TableCell>
|
||||
<TableCell className="text-sm">{p.isp_name ?? '—'}</TableCell>
|
||||
<TableCell className="text-sm max-w-[200px] truncate" title={p.last_problem_name}>{p.last_problem_name ?? '—'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant={Number(p.open_rmm_alerts_24h) > 0 ? 'destructive' : 'outline'}>{p.open_rmm_alerts_24h}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant={Number(p.monitor_tickets_24h) > 0 ? 'secondary' : 'outline'}>{p.monitor_tickets_24h}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-[220px] truncate" title={p.latest_rmm_network_alert ?? ''}>
|
||||
{p.latest_rmm_network_alert ?? <span className="italic">None</span>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Multi-circuit companies */}
|
||||
{multiCircuit.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2"><GitFork className="w-4 h-4" /> Multi-Circuit Companies</CardTitle>
|
||||
<CardDescription>Companies with more than one WAN circuit in IT Glue — check each is covered in Zabbix</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Company</TableHead>
|
||||
<TableHead className="text-center">Circuits</TableHead>
|
||||
<TableHead className="text-center">Monitored</TableHead>
|
||||
<TableHead className="text-center">Gaps</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{multiCircuit.map(mc => {
|
||||
const key = mc.org_name;
|
||||
const expanded = expandedMulti.has(key);
|
||||
return (<>
|
||||
<TableRow
|
||||
key={key}
|
||||
className={`cursor-pointer hover:bg-muted/30 ${Number(mc.gap_circuits) > 0 ? 'bg-amber-500/5' : ''}`}
|
||||
onClick={() => setExpandedMulti(prev => { const n = new Set(prev); expanded ? n.delete(key) : n.add(key); return n; })}
|
||||
>
|
||||
<TableCell className="font-medium text-sm">{mc.org_name}</TableCell>
|
||||
<TableCell className="text-center">{mc.total_circuits}</TableCell>
|
||||
<TableCell className="text-center"><Badge variant={Number(mc.monitored_circuits) > 0 ? 'default' : 'outline'} className="bg-green-600">{mc.monitored_circuits}</Badge></TableCell>
|
||||
<TableCell className="text-center">{Number(mc.gap_circuits) > 0 ? <Badge variant="destructive">{mc.gap_circuits}</Badge> : <Badge variant="outline">0</Badge>}</TableCell>
|
||||
<TableCell>{expanded ? <ChevronDown className="w-4 h-4 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 text-muted-foreground" />}</TableCell>
|
||||
</TableRow>
|
||||
{expanded && mc.circuits.map(c => (
|
||||
<TableRow key={c.id} className="bg-muted/20">
|
||||
<TableCell colSpan={2} className="pl-8 text-sm">
|
||||
<span className="font-medium">{c.provider}</span>
|
||||
{c.location_name && <span className="text-muted-foreground"> — {c.location_name}</span>}
|
||||
{c.is_decommissioned && <Badge variant="outline" className="ml-2 text-xs">Decommissioned</Badge>}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{c.link_type}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{c.static_ips?.join(', ') || '—'}</TableCell>
|
||||
<TableCell colSpan={2}>
|
||||
{c.zabbix_hostid
|
||||
? <Badge variant="default" className="bg-green-600 gap-1"><CheckCircle2 className="w-3 h-3" /> Monitored</Badge>
|
||||
: c.is_decommissioned
|
||||
? <Badge variant="outline" className="gap-1"><MinusCircle className="w-3 h-3" /> Decommissioned</Badge>
|
||||
: <Badge variant="destructive" className="gap-1"><XCircle className="w-3 h-3" /> No Zabbix Host</Badge>
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* IT Glue gaps */}
|
||||
{itgGaps.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2"><BookOpen className="w-4 h-4 text-amber-500" /> IT Glue Circuits Without Zabbix Monitoring <Badge variant="outline">{itgGaps.length}</Badge></CardTitle>
|
||||
<CardDescription>Active circuits with documented static IPs in IT Glue that have no matching Zabbix host</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Organization</TableHead>
|
||||
<TableHead>Provider</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Static IPs</TableHead>
|
||||
<TableHead>Location</TableHead>
|
||||
<TableHead>Speed</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{itgGaps.map(g => (
|
||||
<TableRow key={g.itg_asset_id}>
|
||||
<TableCell className="font-medium text-sm">{g.org_name}</TableCell>
|
||||
<TableCell className="text-sm">{g.provider ?? '—'}</TableCell>
|
||||
<TableCell className="text-sm">{g.link_type ?? '—'}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{g.static_ips?.join(', ') || '—'}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{[g.location_name, g.location_city].filter(Boolean).join(', ') || '—'}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{g.download_mbps ? `↓${g.download_mbps}` : ''}{g.upload_mbps ? ` ↑${g.upload_mbps} Mbps` : ''}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* RMM sites without Zabbix */}
|
||||
{rmmGaps.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2"><Server className="w-4 h-4 text-amber-500" /> RMM Sites Without Zabbix Host <Badge variant="outline">{rmmGaps.length}</Badge></CardTitle>
|
||||
<CardDescription>Online RMM sites that have no corresponding Zabbix WAN monitor — run WAN Sync to add them</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Site</TableHead>
|
||||
<TableHead>Company</TableHead>
|
||||
<TableHead className="text-center">Devices</TableHead>
|
||||
<TableHead className="text-center">Online</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rmmGaps.map(g => (
|
||||
<TableRow key={g.rmm_site_uid}>
|
||||
<TableCell className="font-medium text-sm">{g.rmm_site_name}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{g.company_name}</TableCell>
|
||||
<TableCell className="text-center tabular-nums">{g.number_of_devices}</TableCell>
|
||||
<TableCell className="text-center tabular-nums text-green-600">{g.number_of_online_devices}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!gapLoading && gapSummary && rmmGaps.length === 0 && itgGaps.length === 0 && problems.length === 0 && (
|
||||
<div className="text-center py-16 text-muted-foreground text-sm">
|
||||
<CheckCircle2 className="w-8 h-8 mx-auto mb-3 text-green-500" />
|
||||
All monitored sites and IT Glue circuits are covered in Zabbix.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!gapLoading && !gapSummary && !gapError && (
|
||||
<div className="text-center py-16 text-muted-foreground text-sm">
|
||||
Sync Zabbix hosts and IT Glue circuits above to run gap analysis.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
151
app/api/itglue/sync-wan/route.ts
Normal file
151
app/api/itglue/sync-wan/route.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* POST /api/itglue/sync-wan
|
||||
* Pulls all Internet/WAN flexible assets (type 3794) from the local itg_flexible_assets
|
||||
* table, parses static IPs, and upserts into itg_wan_circuits.
|
||||
* Also matches Zabbix WAN hosts by IP to populate zabbix_hostid.
|
||||
*/
|
||||
import { NextResponse } from 'next/server';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
// Extract public IPs from the raw HTML/text blob IT Glue stores
|
||||
function parseStaticIps(raw: string | null): string[] {
|
||||
if (!raw) return [];
|
||||
// Strip HTML tags
|
||||
const text = raw.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ');
|
||||
// Match IPv4 addresses
|
||||
const matches = text.match(/\b(\d{1,3}\.){3}\d{1,3}\b/g) ?? [];
|
||||
// Filter out private/RFC1918 ranges, subnet masks, gateways that look like masks
|
||||
return [...new Set(
|
||||
matches.filter(ip => {
|
||||
const parts = ip.split('.').map(Number);
|
||||
if (parts[0] === 10) return false;
|
||||
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return false;
|
||||
if (parts[0] === 192 && parts[1] === 168) return false;
|
||||
if (parts[0] === 255) return false;
|
||||
if (parts[0] === 0) return false;
|
||||
if (ip === 'DHCP') return false;
|
||||
return true;
|
||||
})
|
||||
)];
|
||||
}
|
||||
|
||||
function isDecommissioned(notes: string | null): boolean {
|
||||
if (!notes) return false;
|
||||
const lower = notes.toLowerCase();
|
||||
return lower.includes('decommission') || lower.includes('decomission') || lower.includes('retired') || lower.includes('removed');
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
// Pull all IT Glue WAN flexible assets joined to org data
|
||||
const assets = await postgresClient.query<{
|
||||
id: number;
|
||||
organization_id: number;
|
||||
org_name: string;
|
||||
psa_id: string | null;
|
||||
provider: string | null;
|
||||
link_type: string | null;
|
||||
static_ips_raw: string | null;
|
||||
upload_mbps: string | null;
|
||||
download_mbps: string | null;
|
||||
location_name: string | null;
|
||||
location_city: string | null;
|
||||
notes: string | null;
|
||||
}>(`
|
||||
SELECT
|
||||
fa.id,
|
||||
fa.organization_id,
|
||||
io.name AS org_name,
|
||||
io.psa_id,
|
||||
fa.traits->>'provider' AS provider,
|
||||
fa.traits->>'link-type' AS link_type,
|
||||
fa.traits->>'static-ip-address-es' AS static_ips_raw,
|
||||
fa.traits->>'upload-speed-mbps' AS upload_mbps,
|
||||
fa.traits->>'download-speed-mbps' AS download_mbps,
|
||||
fa.traits->'location-s'->'values'->0->>'name' AS location_name,
|
||||
fa.traits->'location-s'->'values'->0->>'city' AS location_city,
|
||||
fa.traits->>'notes' AS notes
|
||||
FROM itg_flexible_assets fa
|
||||
JOIN itg_organizations io ON io.id = fa.organization_id
|
||||
WHERE fa.flexible_asset_type_id = 3794
|
||||
`);
|
||||
|
||||
// Build IP → hostid map from zabbix_wan_hosts for matching
|
||||
const zabbixRows = await postgresClient.query<{ hostid: string; wan_ip: string }>(
|
||||
'SELECT hostid, wan_ip FROM zabbix_wan_hosts WHERE wan_ip IS NOT NULL'
|
||||
);
|
||||
const zabbixByIp = new Map<string, string>();
|
||||
for (const r of zabbixRows.rows) {
|
||||
zabbixByIp.set(r.wan_ip, r.hostid);
|
||||
}
|
||||
|
||||
let upserted = 0;
|
||||
|
||||
for (const a of assets.rows) {
|
||||
const staticIps = parseStaticIps(a.static_ips_raw);
|
||||
const decommissioned = isDecommissioned(a.notes);
|
||||
const autotaskCompanyId = a.psa_id ? Number(a.psa_id) : null;
|
||||
|
||||
// Try to match a Zabbix host by any of the circuit's static IPs
|
||||
let zabbixHostid: string | null = null;
|
||||
for (const ip of staticIps) {
|
||||
if (zabbixByIp.has(ip)) { zabbixHostid = zabbixByIp.get(ip)!; break; }
|
||||
}
|
||||
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itg_wan_circuits (
|
||||
id, organization_id, org_name, autotask_company_id,
|
||||
provider, link_type, static_ips, raw_ip_text,
|
||||
upload_mbps, download_mbps, location_name, location_city,
|
||||
notes, is_decommissioned, zabbix_hostid,
|
||||
last_synced_at, updated_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW(),NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
org_name = EXCLUDED.org_name,
|
||||
autotask_company_id = EXCLUDED.autotask_company_id,
|
||||
provider = EXCLUDED.provider,
|
||||
link_type = EXCLUDED.link_type,
|
||||
static_ips = EXCLUDED.static_ips,
|
||||
raw_ip_text = EXCLUDED.raw_ip_text,
|
||||
upload_mbps = EXCLUDED.upload_mbps,
|
||||
download_mbps = EXCLUDED.download_mbps,
|
||||
location_name = EXCLUDED.location_name,
|
||||
location_city = EXCLUDED.location_city,
|
||||
notes = EXCLUDED.notes,
|
||||
is_decommissioned = EXCLUDED.is_decommissioned,
|
||||
zabbix_hostid = EXCLUDED.zabbix_hostid,
|
||||
last_synced_at = NOW(),
|
||||
updated_at = NOW()`,
|
||||
[
|
||||
a.id, a.organization_id, a.org_name, autotaskCompanyId,
|
||||
a.provider, a.link_type, staticIps, a.static_ips_raw,
|
||||
a.upload_mbps ? Number(a.upload_mbps) : null,
|
||||
a.download_mbps ? Number(a.download_mbps) : null,
|
||||
a.location_name, a.location_city,
|
||||
a.notes, decommissioned, zabbixHostid,
|
||||
]
|
||||
);
|
||||
upserted++;
|
||||
}
|
||||
|
||||
// Summary stats
|
||||
const stats = await postgresClient.query<{
|
||||
total: string; with_ip: string; matched_zabbix: string; decommissioned: string; no_itg_org_link: string;
|
||||
}>(`
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE array_length(static_ips,1) > 0) AS with_ip,
|
||||
COUNT(*) FILTER (WHERE zabbix_hostid IS NOT NULL) AS matched_zabbix,
|
||||
COUNT(*) FILTER (WHERE is_decommissioned) AS decommissioned,
|
||||
COUNT(*) FILTER (WHERE autotask_company_id IS NULL) AS no_itg_org_link
|
||||
FROM itg_wan_circuits
|
||||
`);
|
||||
|
||||
return NextResponse.json({ upserted, ...stats.rows[0] });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +62,7 @@ export async function GET(request: NextRequest) {
|
|||
LEFT JOIN companies c ON t.company_id = c.id
|
||||
LEFT JOIN statuses s ON t.status = s.value
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND (t.source IS NULL OR t.source != 8)
|
||||
${excludeCompanyFilter}
|
||||
ORDER BY t.last_activity_date DESC NULLS LAST
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets
|
||||
WHERE completed_date IS NULL
|
||||
AND is_deleted = false
|
||||
AND priority <= 3
|
||||
AND (source IS NULL OR source != 8)
|
||||
${excludeCompanyFilter}`
|
||||
|
|
@ -63,6 +64,7 @@ export async function GET(request: NextRequest) {
|
|||
FROM tickets t
|
||||
LEFT JOIN companies c ON t.company_id = c.id
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND t.priority <= 3
|
||||
AND (t.source IS NULL OR t.source != 8)
|
||||
${excludeCompanyFilter}
|
||||
|
|
@ -75,6 +77,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets
|
||||
WHERE completed_date IS NULL
|
||||
AND is_deleted = false
|
||||
AND status IN (21, 9, 19)
|
||||
AND (source IS NULL OR source != 8)
|
||||
${excludeCompanyFilter}`
|
||||
|
|
@ -86,6 +89,7 @@ export async function GET(request: NextRequest) {
|
|||
FROM tickets t
|
||||
LEFT JOIN companies c ON t.company_id = c.id
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND t.status IN (21, 9, 19)
|
||||
AND (t.source IS NULL OR t.source != 8)
|
||||
${excludeCompanyFilter}
|
||||
|
|
@ -98,6 +102,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets
|
||||
WHERE completed_date IS NULL
|
||||
AND is_deleted = false
|
||||
AND last_activity_date < NOW() - INTERVAL '7 days'
|
||||
AND (source IS NULL OR source != 8)
|
||||
${excludeCompanyFilter}`
|
||||
|
|
@ -109,6 +114,7 @@ export async function GET(request: NextRequest) {
|
|||
FROM tickets t
|
||||
LEFT JOIN companies c ON t.company_id = c.id
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND t.last_activity_date < NOW() - INTERVAL '7 days'
|
||||
AND (t.source IS NULL OR t.source != 8)
|
||||
${excludeCompanyFilter}
|
||||
|
|
@ -121,6 +127,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets
|
||||
WHERE completed_date IS NULL
|
||||
AND is_deleted = false
|
||||
AND due_date_time < NOW()
|
||||
AND (source IS NULL OR source != 8)
|
||||
${excludeCompanyFilter}`
|
||||
|
|
@ -132,6 +139,7 @@ export async function GET(request: NextRequest) {
|
|||
FROM tickets t
|
||||
LEFT JOIN companies c ON t.company_id = c.id
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND t.due_date_time < NOW()
|
||||
AND (t.source IS NULL OR t.source != 8)
|
||||
${excludeCompanyFilter}
|
||||
|
|
@ -144,6 +152,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets
|
||||
WHERE completed_date IS NULL
|
||||
AND is_deleted = false
|
||||
AND (source IS NULL OR source != 8)
|
||||
${excludeCompanyFilter}`
|
||||
);
|
||||
|
|
@ -153,6 +162,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets
|
||||
WHERE DATE(completed_date) = CURRENT_DATE
|
||||
AND is_deleted = false
|
||||
AND (source IS NULL OR source != 8)
|
||||
${excludeCompanyFilter}`
|
||||
);
|
||||
|
|
@ -163,6 +173,7 @@ export async function GET(request: NextRequest) {
|
|||
FROM tickets t
|
||||
LEFT JOIN companies c ON t.company_id = c.id
|
||||
WHERE DATE(t.completed_date) = CURRENT_DATE
|
||||
AND t.is_deleted = false
|
||||
AND (t.source IS NULL OR t.source != 8)
|
||||
${excludeCompanyFilter}
|
||||
ORDER BY t.completed_date DESC
|
||||
|
|
@ -175,6 +186,7 @@ export async function GET(request: NextRequest) {
|
|||
FROM tickets
|
||||
WHERE completed_date >= NOW() - INTERVAL '7 days'
|
||||
AND completed_date < NOW()
|
||||
AND is_deleted = false
|
||||
AND (source IS NULL OR source != 8)
|
||||
${excludeCompanyFilter}`
|
||||
);
|
||||
|
|
@ -185,6 +197,7 @@ export async function GET(request: NextRequest) {
|
|||
FROM tickets
|
||||
WHERE completed_date >= NOW() - INTERVAL '30 days'
|
||||
AND completed_date < NOW()
|
||||
AND is_deleted = false
|
||||
AND (source IS NULL OR source != 8)
|
||||
${excludeCompanyFilter}`
|
||||
);
|
||||
|
|
@ -195,6 +208,7 @@ export async function GET(request: NextRequest) {
|
|||
FROM tickets
|
||||
WHERE completed_date >= NOW() - INTERVAL '30 days'
|
||||
AND completed_date IS NOT NULL
|
||||
AND is_deleted = false
|
||||
AND (source IS NULL OR source != 8)
|
||||
${excludeCompanyFilter}`
|
||||
);
|
||||
|
|
@ -221,6 +235,7 @@ export async function GET(request: NextRequest) {
|
|||
) te ON t.id = te.ticket_id AND r.id = te.resource_id
|
||||
WHERE t.completed_date >= NOW() - INTERVAL '30 days'
|
||||
AND t.completed_date IS NOT NULL
|
||||
AND t.is_deleted = false
|
||||
AND (t.source IS NULL OR t.source != 8)
|
||||
AND r.id != 4
|
||||
${excludeCompanyFilter}
|
||||
|
|
@ -284,6 +299,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets t
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND t.status NOT IN (21, 9, 19)
|
||||
AND t.ticket_type != 4
|
||||
AND t.queue_id IN (29682833, 29749490)
|
||||
|
|
@ -302,6 +318,7 @@ export async function GET(request: NextRequest) {
|
|||
LEFT JOIN issue_types it ON t.issue_type = it.value
|
||||
LEFT JOIN sub_issue_types sit ON t.sub_issue_type = sit.value
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND t.status NOT IN (21, 9, 19)
|
||||
AND t.ticket_type != 4
|
||||
AND t.queue_id IN (29682833, 29749490)
|
||||
|
|
@ -318,6 +335,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets t
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND t.status NOT IN (21, 9, 19)
|
||||
AND t.ticket_type != 4
|
||||
AND t.queue_id IN (29853766, 29853700)
|
||||
|
|
@ -331,6 +349,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets t
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND t.ticket_category = 171
|
||||
AND (t.source IS NULL OR t.source != 8)
|
||||
${excludeCompanyFilter}`
|
||||
|
|
@ -341,6 +360,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets t
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND t.company_id = 29861375
|
||||
AND (t.source IS NULL OR t.source != 8)`
|
||||
);
|
||||
|
|
@ -350,6 +370,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets t
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND t.company_id = 29683407
|
||||
AND (t.source IS NULL OR t.source != 8)`
|
||||
);
|
||||
|
|
@ -359,6 +380,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets t
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND t.company_id IN (29861395, 29861424)
|
||||
AND (t.source IS NULL OR t.source != 8)`
|
||||
);
|
||||
|
|
|
|||
48
app/api/qbo/auth/route.ts
Normal file
48
app/api/qbo/auth/route.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* QBO OAuth2 Authorization
|
||||
* GET /api/qbo/auth — redirect to Intuit authorization page
|
||||
* GET /api/qbo/auth/callback — exchange code for tokens
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { QboClient } from '@/lib/services/qbo-client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const code = searchParams.get('code');
|
||||
const realmId = searchParams.get('realmId');
|
||||
const error = searchParams.get('error');
|
||||
|
||||
const redirectUri = `${process.env.NEXTAUTH_URL}/api/qbo/auth`;
|
||||
|
||||
// ── Callback from Intuit ──────────────────────────────────────────────────
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'https://pulse.wulfconsulting.cloud';
|
||||
|
||||
if (code && realmId) {
|
||||
try {
|
||||
const client = new QboClient();
|
||||
await client.exchangeCodeForToken(code, redirectUri);
|
||||
console.log(`[QBO Auth] Tokens saved for realm ${realmId}`);
|
||||
return NextResponse.redirect(`${baseUrl}/admin/qbo?connected=true`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error('[QBO Auth] Token exchange failed:', msg);
|
||||
return NextResponse.redirect(`${baseUrl}/admin/qbo?error=${encodeURIComponent(msg)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return NextResponse.redirect(`${baseUrl}/admin/qbo?error=${encodeURIComponent(error)}`);
|
||||
}
|
||||
|
||||
// ── Initiate authorization ────────────────────────────────────────────────
|
||||
try {
|
||||
const client = new QboClient();
|
||||
const state = Math.random().toString(36).slice(2);
|
||||
const authUrl = client.getAuthorizationUrl(redirectUri, state);
|
||||
return NextResponse.redirect(authUrl);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
21
app/api/qbo/disconnect/route.ts
Normal file
21
app/api/qbo/disconnect/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* QBO Disconnect
|
||||
* GET /api/qbo/disconnect — revokes QBO token and removes from DB
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
export async function GET() {
|
||||
const realmId = process.env.QBO_REALM_ID || '';
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'https://pulse.wulfconsulting.cloud';
|
||||
|
||||
try {
|
||||
await postgresClient.query(`DELETE FROM qbo_tokens WHERE realm_id = $1`, [realmId]);
|
||||
console.log(`[QBO] Disconnected realm ${realmId}`);
|
||||
return NextResponse.redirect(`${baseUrl}/admin/qbo?disconnected=true`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.redirect(`${baseUrl}/admin/qbo?error=${encodeURIComponent(msg)}`);
|
||||
}
|
||||
}
|
||||
83
app/api/qbo/sync/route.ts
Normal file
83
app/api/qbo/sync/route.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* QBO Sync API
|
||||
* POST /api/qbo/sync — trigger a full or incremental sync
|
||||
* GET /api/qbo/sync — get last sync status and record counts
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { QboSyncService } from '@/lib/services/qbo-sync-service';
|
||||
import { QboClient } from '@/lib/services/qbo-client';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const syncType: 'full' | 'incremental' = body.syncType === 'incremental' ? 'incremental' : 'full';
|
||||
const triggeredBy = body.triggeredBy || 'api';
|
||||
|
||||
const client = new QboClient();
|
||||
const service = new QboSyncService(client);
|
||||
|
||||
if (service.isSyncInProgress()) {
|
||||
return NextResponse.json({ error: 'QBO sync already in progress' }, { status: 409 });
|
||||
}
|
||||
|
||||
// Run async — return immediately
|
||||
service[syncType === 'full' ? 'fullSync' : 'incrementalSync'](triggeredBy).catch((err) => {
|
||||
console.error('[QBO Sync API] Sync failed:', err);
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: `QBO ${syncType} sync started`,
|
||||
triggeredBy,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const [invoices, payments, deposits, transactions, reports] = await Promise.all([
|
||||
postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_invoices`),
|
||||
postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_payments`),
|
||||
postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_deposits`),
|
||||
postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_transactions`),
|
||||
postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_reports`),
|
||||
]);
|
||||
|
||||
// Check token status
|
||||
let tokenStatus: 'valid' | 'expired' | 'missing' = 'missing';
|
||||
try {
|
||||
const client = new QboClient();
|
||||
const token = await client.loadToken();
|
||||
if (token) {
|
||||
tokenStatus = new Date() < new Date(token.access_token_expires_at) ? 'valid' : 'expired';
|
||||
}
|
||||
} catch {
|
||||
tokenStatus = 'missing';
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
tokenStatus,
|
||||
counts: {
|
||||
invoices: parseInt(invoices.rows[0]?.count || '0'),
|
||||
payments: parseInt(payments.rows[0]?.count || '0'),
|
||||
deposits: parseInt(deposits.rows[0]?.count || '0'),
|
||||
transactions: parseInt(transactions.rows[0]?.count || '0'),
|
||||
reports: parseInt(reports.rows[0]?.count || '0'),
|
||||
},
|
||||
lastSync: {
|
||||
invoices: invoices.rows[0]?.last_sync ?? null,
|
||||
payments: payments.rows[0]?.last_sync ?? null,
|
||||
deposits: deposits.rows[0]?.last_sync ?? null,
|
||||
transactions: transactions.rows[0]?.last_sync ?? null,
|
||||
reports: reports.rows[0]?.last_sync ?? null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
34
app/api/reports/ticket-digest/config/route.ts
Normal file
34
app/api/reports/ticket-digest/config/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* Ticket Digest Config API
|
||||
* GET /api/reports/ticket-digest/config — Get config + available channels
|
||||
* PUT /api/reports/ticket-digest/config — Update config
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getTicketDigestService } from '@/lib/services/ticket-digest-service';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const service = getTicketDigestService();
|
||||
const [config, channels] = await Promise.all([
|
||||
service.getConfig(),
|
||||
service.getAvailableChannels(),
|
||||
]);
|
||||
return NextResponse.json({ config, channels });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const service = getTicketDigestService();
|
||||
const config = await service.updateConfig(body);
|
||||
return NextResponse.json({ config });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
70
app/api/reports/ticket-digest/route.ts
Normal file
70
app/api/reports/ticket-digest/route.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Ticket Digest Report API
|
||||
* POST /api/reports/ticket-digest — Generate and deliver a digest report
|
||||
* Body: { period: 'daily' | 'weekly' | 'monthly', webhookIds?: number[] }
|
||||
* GET /api/reports/ticket-digest — Get report history
|
||||
* GET /api/reports/ticket-digest?preview=daily — Aggregate data only (no LLM, no delivery)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getTicketDigestService, DigestPeriod } from '@/lib/services/ticket-digest-service';
|
||||
|
||||
const VALID_PERIODS: DigestPeriod[] = ['daily', 'weekly', 'monthly'];
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const period = body.period as DigestPeriod;
|
||||
|
||||
if (!period || !VALID_PERIODS.includes(period)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid period. Must be one of: ${VALID_PERIODS.join(', ')}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const service = getTicketDigestService();
|
||||
const result = await service.run(period, body.channelIds);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
period,
|
||||
stats: result.stats.overview,
|
||||
noiseCount: result.stats.noise_candidates.length,
|
||||
analysisLength: result.analysis.length,
|
||||
deliveryResults: result.deliveryResults,
|
||||
processingTimeMs: result.processingTimeMs,
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error('[TICKET-DIGEST API] Error:', msg);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const preview = searchParams.get('preview') as DigestPeriod | null;
|
||||
|
||||
const service = getTicketDigestService();
|
||||
|
||||
if (preview && VALID_PERIODS.includes(preview)) {
|
||||
const stats = await service.aggregate(preview);
|
||||
return NextResponse.json({ stats });
|
||||
}
|
||||
|
||||
// Return history + config + available notification channels
|
||||
const [history, config, channels] = await Promise.all([
|
||||
service.getHistory(20),
|
||||
service.getConfig(),
|
||||
service.getAvailableChannels(),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ history, config, channels });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error('[TICKET-DIGEST API] Error:', msg);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,17 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
import '@/lib/services/pipeline-steps';
|
||||
import { pipelineEngine } from '@/lib/services/pipeline-engine';
|
||||
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
|
||||
|
||||
async function getDattoPingTarget(alertUid: string): Promise<string | null> {
|
||||
try {
|
||||
const client = getDattoRMMClient();
|
||||
const target = await client.getPingAlertTarget(alertUid);
|
||||
return target;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/datto-rmm
|
||||
|
|
@ -70,6 +81,83 @@ export async function POST(request: NextRequest) {
|
|||
]
|
||||
);
|
||||
|
||||
// Upsert into datto_rmm_alerts if payload looks like a real alert
|
||||
if (payload && typeof payload === 'object' && payload.alert_uid && !payload.alert_uid.startsWith('[')) {
|
||||
const pingTarget = payload.alert_type === 'PING'
|
||||
? await getDattoPingTarget(payload.alert_uid)
|
||||
: null;
|
||||
const isResolved = String(payload.triggered).toLowerCase() === 'false';
|
||||
const str = (v: unknown) => (v && String(v).trim() !== '' ? String(v) : null);
|
||||
|
||||
await postgresClient.query(
|
||||
`INSERT INTO datto_rmm_alerts (
|
||||
alert_uid, device_uid, device_name, device_hostname, device_ip, device_os,
|
||||
device_description, device_id, site_uid, site_name, site_id, platform,
|
||||
priority, alert_category, alert_type, alert_message_en, last_user,
|
||||
triggered, resolved, resolved_on,
|
||||
device_udf1, device_udf2, device_udf3, device_udf4, device_udf5,
|
||||
device_udf6, device_udf7, device_udf8, device_udf9, device_udf10,
|
||||
device_udf11, device_udf12, device_udf13, device_udf14, device_udf15,
|
||||
device_udf16, device_udf17, device_udf18, device_udf19, device_udf20,
|
||||
device_udf21, device_udf22, device_udf23, device_udf24, device_udf25,
|
||||
device_udf26, device_udf27, device_udf28, device_udf29,
|
||||
ping_target,
|
||||
timestamp, synced_at
|
||||
) 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,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40,
|
||||
$41,$42,$43,$44,$45,$46,$47,$48,$49,
|
||||
$50,
|
||||
$51,$52
|
||||
)
|
||||
ON CONFLICT (alert_uid) DO UPDATE SET
|
||||
resolved = EXCLUDED.resolved,
|
||||
resolved_on = CASE WHEN EXCLUDED.resolved AND datto_rmm_alerts.resolved_on IS NULL
|
||||
THEN NOW() ELSE datto_rmm_alerts.resolved_on END,
|
||||
triggered = EXCLUDED.triggered,
|
||||
alert_message_en = COALESCE(EXCLUDED.alert_message_en, datto_rmm_alerts.alert_message_en),
|
||||
ping_target = COALESCE(EXCLUDED.ping_target, datto_rmm_alerts.ping_target),
|
||||
synced_at = NOW()`,
|
||||
[
|
||||
payload.alert_uid,
|
||||
str(payload.device_uid),
|
||||
str(payload.device_hostname),
|
||||
str(payload.device_hostname),
|
||||
str(payload.device_ip),
|
||||
str(payload.device_os),
|
||||
str(payload.device_description),
|
||||
str(payload.device_id),
|
||||
str(payload.site_uid),
|
||||
str(payload.site_name),
|
||||
str(payload.site_id),
|
||||
str(payload.platform),
|
||||
str(payload.alert_priority),
|
||||
str(payload.alert_category),
|
||||
str(payload.alert_type),
|
||||
str(payload.alert_message_en),
|
||||
str(payload.last_user),
|
||||
String(payload.triggered),
|
||||
isResolved,
|
||||
isResolved ? receivedAt : null,
|
||||
str(payload.device_udf1), str(payload.device_udf2), str(payload.device_udf3),
|
||||
str(payload.device_udf4), str(payload.device_udf5), str(payload.device_udf6),
|
||||
str(payload.device_udf7), str(payload.device_udf8), str(payload.device_udf9),
|
||||
str(payload.device_udf10), str(payload.device_udf11), str(payload.device_udf12),
|
||||
str(payload.device_udf13), str(payload.device_udf14), str(payload.device_udf15),
|
||||
str(payload.device_udf16), str(payload.device_udf17), str(payload.device_udf18),
|
||||
str(payload.device_udf19), str(payload.device_udf20),
|
||||
str(payload.device_udf21), str(payload.device_udf22), str(payload.device_udf23),
|
||||
str(payload.device_udf24), str(payload.device_udf25), str(payload.device_udf26),
|
||||
str(payload.device_udf27), str(payload.device_udf28), str(payload.device_udf29),
|
||||
pingTarget,
|
||||
receivedAt,
|
||||
receivedAt,
|
||||
]
|
||||
);
|
||||
|
||||
console.log(`[DATTO-RMM-WEBHOOK] Upserted alert ${payload.alert_uid} — resolved=${isResolved}`);
|
||||
}
|
||||
|
||||
// Fire matching pipelines (fire-and-forget)
|
||||
if (payload && typeof payload === 'object') {
|
||||
pipelineEngine.processTrigger('datto_rmm', payload).catch(err =>
|
||||
|
|
|
|||
154
app/api/zabbix/alert-correlation/route.ts
Normal file
154
app/api/zabbix/alert-correlation/route.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* GET /api/zabbix/alert-correlation
|
||||
* Cross-references Zabbix WAN problem events against Datto RMM ping/offline alerts
|
||||
* for the same company within a configurable time window.
|
||||
*
|
||||
* Query params:
|
||||
* days — look-back window in days (default 30)
|
||||
* windowMins — match window ± minutes around Zabbix event (default 120)
|
||||
* companyId — optional: filter to one Autotask company
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const days = Number(searchParams.get('days') ?? 30);
|
||||
const windowMins = Number(searchParams.get('windowMins') ?? 120);
|
||||
const companyId = searchParams.get('companyId');
|
||||
|
||||
try {
|
||||
const since = new Date(Date.now() - days * 86400 * 1000);
|
||||
|
||||
// ── 1. Zabbix events with matched RMM alert count ────────────────────────
|
||||
const zabbixRows = await postgresClient.query<{
|
||||
eventid: string;
|
||||
name: string;
|
||||
severity: number;
|
||||
clock: string;
|
||||
r_clock: string | null;
|
||||
duration_seconds: number | null;
|
||||
host_name: string;
|
||||
wan_ip: string;
|
||||
isp_name: string | null;
|
||||
autotask_company_id: number;
|
||||
autotask_company_name: string;
|
||||
rmm_site_uid: string | null;
|
||||
rmm_alert_count: string;
|
||||
rmm_alerts: string; // JSON array
|
||||
}>(`
|
||||
SELECT
|
||||
ze.eventid,
|
||||
ze.name,
|
||||
ze.severity,
|
||||
ze.clock,
|
||||
ze.r_clock,
|
||||
ze.duration_seconds,
|
||||
ze.host_name,
|
||||
ze.wan_ip,
|
||||
ze.isp_name,
|
||||
ze.autotask_company_id,
|
||||
ze.autotask_company_name,
|
||||
ze.rmm_site_uid,
|
||||
COUNT(ra.alert_uid)::text AS rmm_alert_count,
|
||||
json_agg(json_build_object(
|
||||
'alert_uid', ra.alert_uid,
|
||||
'site_name', ra.site_name,
|
||||
'alert_class', ra.alert_context->>'@class',
|
||||
'alert_message', ra.alert_message_en,
|
||||
'timestamp', ra.timestamp,
|
||||
'resolved', ra.resolved,
|
||||
'resolved_on', ra.resolved_on,
|
||||
'device_name', ra.device_name
|
||||
) ORDER BY ra.timestamp)
|
||||
FILTER (WHERE ra.alert_uid IS NOT NULL) AS rmm_alerts
|
||||
FROM zabbix_events ze
|
||||
LEFT JOIN datto_rmm_alerts ra
|
||||
ON ra.timestamp BETWEEN ze.clock - ($1 * INTERVAL '1 minute')
|
||||
AND ze.clock + ($1 * INTERVAL '1 minute')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM datto_rmm_sites ds
|
||||
JOIN rmm_site_mappings rsm ON rsm.rmm_site_uid = ds.uid
|
||||
WHERE ds.uid = ra.site_uid
|
||||
AND rsm.company_id = ze.autotask_company_id
|
||||
)
|
||||
WHERE ze.clock >= $2
|
||||
${companyId ? 'AND ze.autotask_company_id = $3' : ''}
|
||||
GROUP BY
|
||||
ze.eventid, ze.name, ze.severity, ze.clock, ze.r_clock,
|
||||
ze.duration_seconds, ze.host_name, ze.wan_ip, ze.isp_name,
|
||||
ze.autotask_company_id, ze.autotask_company_name, ze.rmm_site_uid
|
||||
ORDER BY ze.clock DESC
|
||||
`, companyId
|
||||
? [windowMins, since, Number(companyId)]
|
||||
: [windowMins, since]);
|
||||
|
||||
// ── 2. RMM ping/offline alerts with NO matching Zabbix event ────────────
|
||||
const rmmOnlyRows = await postgresClient.query<{
|
||||
alert_uid: string;
|
||||
site_name: string;
|
||||
alert_class: string;
|
||||
alert_message: string | null;
|
||||
timestamp: string;
|
||||
resolved: boolean;
|
||||
resolved_on: string | null;
|
||||
device_name: string | null;
|
||||
autotask_company_id: number | null;
|
||||
autotask_company_name: string | null;
|
||||
}>(`
|
||||
SELECT
|
||||
ra.alert_uid,
|
||||
ra.site_name,
|
||||
ra.alert_context->>'@class' AS alert_class,
|
||||
ra.alert_message_en AS alert_message,
|
||||
ra.timestamp,
|
||||
ra.resolved,
|
||||
ra.resolved_on,
|
||||
ra.device_name,
|
||||
rsm.company_id AS autotask_company_id,
|
||||
c.company_name AS autotask_company_name
|
||||
FROM datto_rmm_alerts ra
|
||||
JOIN datto_rmm_sites ds ON ds.uid = ra.site_uid
|
||||
JOIN rmm_site_mappings rsm ON rsm.rmm_site_uid = ds.uid
|
||||
JOIN companies c ON c.id = rsm.company_id
|
||||
WHERE ra.timestamp >= $1
|
||||
AND ra.alert_context->>'@class' IN ('ping_ctx', 'device_offline_ctx', 'network_ctx')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM zabbix_events ze
|
||||
WHERE ze.autotask_company_id = rsm.company_id
|
||||
AND ze.clock BETWEEN ra.timestamp - ($2 * INTERVAL '1 minute')
|
||||
AND ra.timestamp + ($2 * INTERVAL '1 minute')
|
||||
)
|
||||
${companyId ? 'AND rsm.company_id = $3' : ''}
|
||||
ORDER BY ra.timestamp DESC
|
||||
LIMIT 500
|
||||
`, companyId
|
||||
? [since, windowMins, Number(companyId)]
|
||||
: [since, windowMins]);
|
||||
|
||||
// ── 3. Summary stats ─────────────────────────────────────────────────────
|
||||
const zabbixWithRmm = zabbixRows.rows.filter(r => Number(r.rmm_alert_count) > 0).length;
|
||||
const zabbixWithoutRmm = zabbixRows.rows.filter(r => Number(r.rmm_alert_count) === 0).length;
|
||||
|
||||
const lastEventSync = await postgresClient.query<{ last_sync: string | null }>(
|
||||
'SELECT MAX(last_synced_at) AS last_sync FROM zabbix_events'
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
summary: {
|
||||
days,
|
||||
window_mins: windowMins,
|
||||
zabbix_events_total: zabbixRows.rows.length,
|
||||
zabbix_with_rmm_match: zabbixWithRmm,
|
||||
zabbix_without_rmm_match: zabbixWithoutRmm,
|
||||
rmm_only_alerts: rmmOnlyRows.rows.length,
|
||||
last_event_sync: lastEventSync.rows[0]?.last_sync ?? null,
|
||||
},
|
||||
zabbix_events: zabbixRows.rows,
|
||||
rmm_only: rmmOnlyRows.rows,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
122
app/api/zabbix/sync-events/route.ts
Normal file
122
app/api/zabbix/sync-events/route.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* POST /api/zabbix/sync-events
|
||||
* Polls Zabbix for WAN problem events (past N days) and caches them in zabbix_events.
|
||||
* Joins against local zabbix_wan_hosts to enrich with company/site context.
|
||||
* Body: { days?: number } — defaults to 30
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { ZabbixClient } from '@/lib/services/zabbix-client';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
|
||||
export const maxDuration = 300;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) {
|
||||
return NextResponse.json({ error: 'Zabbix not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const days: number = Number(body.days ?? 30);
|
||||
|
||||
const zabbix = new ZabbixClient({
|
||||
apiUrl: process.env.ZABBIX_API_URL,
|
||||
apiToken: process.env.ZABBIX_API_TOKEN,
|
||||
});
|
||||
|
||||
try {
|
||||
// Load local WAN host cache for enrichment (hostid → context)
|
||||
const hostRows = await postgresClient.query<{
|
||||
hostid: string;
|
||||
display_name: string;
|
||||
wan_ip: string;
|
||||
autotask_company_id: number | null;
|
||||
autotask_company_name: string | null;
|
||||
rmm_site_uid: string | null;
|
||||
isp_name: string | null;
|
||||
}>('SELECT hostid, display_name, wan_ip, autotask_company_id, autotask_company_name, rmm_site_uid, isp_name FROM zabbix_wan_hosts');
|
||||
|
||||
const hostMap = new Map(hostRows.rows.map(r => [r.hostid, r]));
|
||||
|
||||
if (hostMap.size === 0) {
|
||||
return NextResponse.json({ error: 'No Zabbix hosts cached — run Sync Hosts first' }, { status: 400 });
|
||||
}
|
||||
|
||||
const from = new Date(Date.now() - days * 86400 * 1000);
|
||||
const to = new Date();
|
||||
|
||||
// Pull WAN-host-only events by scoping to our known hostids
|
||||
const hostIds = [...hostMap.keys()];
|
||||
|
||||
const events = await zabbix.getEvents({ hostIds, from, to, limit: 10000 });
|
||||
|
||||
let upserted = 0;
|
||||
let noHost = 0;
|
||||
|
||||
for (const ev of events) {
|
||||
const hostid = ev.hosts?.[0]?.hostid ?? null;
|
||||
const host = hostid ? hostMap.get(hostid) ?? null : null;
|
||||
|
||||
if (!host) { noHost++; continue; }
|
||||
|
||||
const clockTs = new Date(Number(ev.clock) * 1000);
|
||||
const rClockTs = ev.r_clock && ev.r_clock !== '0'
|
||||
? new Date(Number(ev.r_clock) * 1000)
|
||||
: null;
|
||||
const duration = rClockTs
|
||||
? Math.round((rClockTs.getTime() - clockTs.getTime()) / 1000)
|
||||
: null;
|
||||
|
||||
await postgresClient.query(
|
||||
`INSERT INTO zabbix_events (
|
||||
eventid, objectid, name, severity, clock,
|
||||
r_eventid, r_clock, duration_seconds,
|
||||
hostid, host_name, wan_ip,
|
||||
autotask_company_id, autotask_company_name, rmm_site_uid, isp_name,
|
||||
last_synced_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW())
|
||||
ON CONFLICT (eventid) DO UPDATE SET
|
||||
r_eventid = EXCLUDED.r_eventid,
|
||||
r_clock = EXCLUDED.r_clock,
|
||||
duration_seconds = EXCLUDED.duration_seconds,
|
||||
host_name = EXCLUDED.host_name,
|
||||
wan_ip = EXCLUDED.wan_ip,
|
||||
autotask_company_id = EXCLUDED.autotask_company_id,
|
||||
autotask_company_name = EXCLUDED.autotask_company_name,
|
||||
rmm_site_uid = EXCLUDED.rmm_site_uid,
|
||||
isp_name = EXCLUDED.isp_name,
|
||||
last_synced_at = NOW()`,
|
||||
[
|
||||
ev.eventid,
|
||||
ev.objectid,
|
||||
ev.name,
|
||||
Number(ev.severity),
|
||||
clockTs,
|
||||
ev.r_eventid && ev.r_eventid !== '0' ? ev.r_eventid : null,
|
||||
rClockTs,
|
||||
duration,
|
||||
hostid,
|
||||
host.display_name,
|
||||
host.wan_ip,
|
||||
host.autotask_company_id,
|
||||
host.autotask_company_name,
|
||||
host.rmm_site_uid,
|
||||
host.isp_name,
|
||||
]
|
||||
);
|
||||
upserted++;
|
||||
}
|
||||
|
||||
const stats = await postgresClient.query<{ total: string; open: string; oldest: string; newest: string }>(`
|
||||
SELECT COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE r_eventid IS NULL) AS open,
|
||||
MIN(clock) AS oldest,
|
||||
MAX(clock) AS newest
|
||||
FROM zabbix_events
|
||||
`);
|
||||
|
||||
return NextResponse.json({ upserted, no_host: noHost, days, ...stats.rows[0] });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
122
app/api/zabbix/sync-hosts/route.ts
Normal file
122
app/api/zabbix/sync-hosts/route.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* POST /api/zabbix/sync-hosts
|
||||
* Pulls all hosts from the live Zabbix API and caches them in zabbix_wan_hosts.
|
||||
* Also fetches current open problems and stamps last_problem_at on affected hosts.
|
||||
*/
|
||||
import { NextResponse } from 'next/server';
|
||||
import { ZabbixClient } from '@/lib/services/zabbix-client';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
export async function POST() {
|
||||
if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) {
|
||||
return NextResponse.json({ error: 'Zabbix not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const zabbix = new ZabbixClient({
|
||||
apiUrl: process.env.ZABBIX_API_URL,
|
||||
apiToken: process.env.ZABBIX_API_TOKEN,
|
||||
});
|
||||
|
||||
try {
|
||||
const hosts = await zabbix.getHosts();
|
||||
|
||||
// Fetch open problems to stamp last_problem_at
|
||||
const problems = await zabbix.getOpenProblems(2);
|
||||
const problemByHostId = new Map<string, { name: string; clock: string }>();
|
||||
for (const p of problems) {
|
||||
for (const h of (p.hosts ?? [])) {
|
||||
if (!problemByHostId.has(h.hostid) || Number(p.clock) > Number(problemByHostId.get(h.hostid)!.clock)) {
|
||||
problemByHostId.set(h.hostid, { name: p.name, clock: p.clock });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let upserted = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const host of hosts) {
|
||||
// Only process ICMP/WAN hosts — they have an agent interface with an IP
|
||||
const iface = (host.interfaces ?? []).find(i => Number(i.useip) === 1 && i.ip && i.ip !== '127.0.0.1');
|
||||
if (!iface) { skipped++; continue; }
|
||||
|
||||
const getMacro = (name: string) =>
|
||||
(host.macros ?? []).find(m => m.macro === name)?.value ?? null;
|
||||
|
||||
const getTag = (name: string) =>
|
||||
(host.tags ?? []).find(t => t.tag === name)?.value ?? null;
|
||||
|
||||
const rmmSiteUid = getMacro('{$RMM_SITE_UID}');
|
||||
const autotaskCompanyId = getMacro('{$AUTOTASK_COMPANY_ID}');
|
||||
const autotaskCompanyName = getMacro('{$AUTOTASK_COMPANY_NAME}');
|
||||
const ispName = getMacro('{$ISP_NAME}');
|
||||
const asn = getMacro('{$ASN}');
|
||||
|
||||
const isMultiWan = getTag('multi-wan') === 'true';
|
||||
const source = getTag('source') ?? 'datto-rmm';
|
||||
|
||||
const problem = problemByHostId.get(host.hostid);
|
||||
|
||||
await postgresClient.query(
|
||||
`INSERT INTO zabbix_wan_hosts (
|
||||
hostid, host_name, display_name, wan_ip, status,
|
||||
rmm_site_uid, autotask_company_id, autotask_company_name,
|
||||
isp_name, asn, is_multi_wan, source, tags,
|
||||
last_problem_at, last_problem_name,
|
||||
last_synced_at, updated_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW(),NOW())
|
||||
ON CONFLICT (hostid) DO UPDATE SET
|
||||
host_name = EXCLUDED.host_name,
|
||||
display_name = EXCLUDED.display_name,
|
||||
wan_ip = EXCLUDED.wan_ip,
|
||||
status = EXCLUDED.status,
|
||||
rmm_site_uid = EXCLUDED.rmm_site_uid,
|
||||
autotask_company_id = EXCLUDED.autotask_company_id,
|
||||
autotask_company_name = EXCLUDED.autotask_company_name,
|
||||
isp_name = EXCLUDED.isp_name,
|
||||
asn = EXCLUDED.asn,
|
||||
is_multi_wan = EXCLUDED.is_multi_wan,
|
||||
source = EXCLUDED.source,
|
||||
tags = EXCLUDED.tags,
|
||||
last_problem_at = EXCLUDED.last_problem_at,
|
||||
last_problem_name = EXCLUDED.last_problem_name,
|
||||
last_synced_at = NOW(),
|
||||
updated_at = NOW()`,
|
||||
[
|
||||
host.hostid,
|
||||
host.host,
|
||||
host.name ?? host.host,
|
||||
iface.ip,
|
||||
Number(host.status ?? 0),
|
||||
rmmSiteUid,
|
||||
autotaskCompanyId ? Number(autotaskCompanyId) : null,
|
||||
autotaskCompanyName,
|
||||
ispName,
|
||||
asn,
|
||||
isMultiWan,
|
||||
source,
|
||||
JSON.stringify(host.tags ?? []),
|
||||
problem ? new Date(Number(problem.clock) * 1000).toISOString() : null,
|
||||
problem?.name ?? null,
|
||||
]
|
||||
);
|
||||
upserted++;
|
||||
}
|
||||
|
||||
// Remove hosts no longer in Zabbix
|
||||
const liveIds = hosts.map(h => h.hostid);
|
||||
if (liveIds.length > 0) {
|
||||
const deleted = await postgresClient.query(
|
||||
'DELETE FROM zabbix_wan_hosts WHERE hostid <> ALL($1) RETURNING hostid',
|
||||
[liveIds]
|
||||
);
|
||||
return NextResponse.json({ upserted, skipped, removed: deleted.rowCount });
|
||||
}
|
||||
|
||||
return NextResponse.json({ upserted, skipped, removed: 0 });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
166
app/api/zabbix/wan-gap-analysis/route.ts
Normal file
166
app/api/zabbix/wan-gap-analysis/route.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
/**
|
||||
* GET /api/zabbix/wan-gap-analysis
|
||||
* Cross-references Datto RMM sites, Zabbix WAN hosts, and IT Glue WAN circuits
|
||||
* to surface monitoring gaps and event correlation.
|
||||
*
|
||||
* Returns:
|
||||
* - summary: counts of covered/gap/no-itg sites
|
||||
* - rmm_gaps: RMM sites with no Zabbix host
|
||||
* - itg_gaps: IT Glue WAN circuits with no matched Zabbix host (and have a static IP)
|
||||
* - multi_circuit_sites: companies with >1 IT Glue WAN circuit, showing Zabbix coverage per circuit
|
||||
* - current_problems: Zabbix hosts with active problems + matching recent RMM alerts
|
||||
* - last_synced: timestamps of the cache tables
|
||||
*/
|
||||
import { NextResponse } from 'next/server';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// ── 1. RMM sites missing from Zabbix ─────────────────────────────────────
|
||||
const rmmGaps = await postgresClient.query(`
|
||||
SELECT
|
||||
s.uid AS rmm_site_uid,
|
||||
s.name AS rmm_site_name,
|
||||
s.autotask_company_name AS company_name,
|
||||
rsm.company_id,
|
||||
s.number_of_devices,
|
||||
s.number_of_online_devices
|
||||
FROM datto_rmm_sites s
|
||||
JOIN rmm_site_mappings rsm ON rsm.rmm_site_uid = s.uid
|
||||
LEFT JOIN zabbix_wan_hosts z ON z.rmm_site_uid = s.uid
|
||||
WHERE z.hostid IS NULL
|
||||
AND s.number_of_online_devices > 0
|
||||
ORDER BY s.autotask_company_name, s.name
|
||||
`);
|
||||
|
||||
// ── 2. IT Glue WAN circuits missing from Zabbix (have IPs, not decommissioned) ──
|
||||
const itgGaps = await postgresClient.query(`
|
||||
SELECT
|
||||
ic.id AS itg_asset_id,
|
||||
ic.org_name,
|
||||
ic.autotask_company_id,
|
||||
ic.provider,
|
||||
ic.link_type,
|
||||
ic.static_ips,
|
||||
ic.location_name,
|
||||
ic.location_city,
|
||||
ic.upload_mbps,
|
||||
ic.download_mbps
|
||||
FROM itg_wan_circuits ic
|
||||
WHERE ic.zabbix_hostid IS NULL
|
||||
AND ic.is_decommissioned = FALSE
|
||||
AND array_length(ic.static_ips, 1) > 0
|
||||
ORDER BY ic.org_name, ic.provider
|
||||
`);
|
||||
|
||||
// ── 3. Multi-circuit companies (>1 IT Glue WAN circuit) ──────────────────
|
||||
const multiCircuit = await postgresClient.query(`
|
||||
SELECT
|
||||
ic.org_name,
|
||||
ic.autotask_company_id,
|
||||
COUNT(*) AS total_circuits,
|
||||
COUNT(*) FILTER (WHERE ic.zabbix_hostid IS NOT NULL) AS monitored_circuits,
|
||||
COUNT(*) FILTER (WHERE ic.zabbix_hostid IS NULL
|
||||
AND NOT ic.is_decommissioned
|
||||
AND array_length(ic.static_ips,1) > 0) AS gap_circuits,
|
||||
json_agg(json_build_object(
|
||||
'id', ic.id,
|
||||
'provider', ic.provider,
|
||||
'link_type', ic.link_type,
|
||||
'static_ips', ic.static_ips,
|
||||
'location_name', ic.location_name,
|
||||
'zabbix_hostid', ic.zabbix_hostid,
|
||||
'is_decommissioned', ic.is_decommissioned
|
||||
) ORDER BY ic.provider) AS circuits
|
||||
FROM itg_wan_circuits ic
|
||||
WHERE NOT ic.is_decommissioned
|
||||
GROUP BY ic.org_name, ic.autotask_company_id
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY gap_circuits DESC, ic.org_name
|
||||
`);
|
||||
|
||||
// ── 4. Current Zabbix problems + correlated RMM alerts ───────────────────
|
||||
const problems = await postgresClient.query(`
|
||||
SELECT
|
||||
z.hostid,
|
||||
z.display_name,
|
||||
z.wan_ip,
|
||||
z.isp_name,
|
||||
z.autotask_company_name,
|
||||
z.autotask_company_id,
|
||||
z.rmm_site_uid,
|
||||
z.last_problem_at,
|
||||
z.last_problem_name,
|
||||
-- Count open RMM alerts for the same company in the last 24h
|
||||
(
|
||||
SELECT COUNT(*) FROM datto_rmm_alerts a
|
||||
JOIN datto_rmm_sites ds ON ds.uid = a.site_uid
|
||||
WHERE ds.autotask_company_id = z.autotask_company_id
|
||||
AND a.resolved = FALSE
|
||||
AND a.timestamp > NOW() - INTERVAL '24 hours'
|
||||
) AS open_rmm_alerts_24h,
|
||||
-- Most recent RMM network alert for this company
|
||||
(
|
||||
SELECT a.alert_message_en FROM datto_rmm_alerts a
|
||||
JOIN datto_rmm_sites ds ON ds.uid = a.site_uid
|
||||
WHERE ds.autotask_company_id = z.autotask_company_id
|
||||
AND (a.alert_category ILIKE '%network%' OR a.alert_category ILIKE '%wan%'
|
||||
OR a.alert_type ILIKE '%ping%' OR a.alert_type ILIKE '%offline%')
|
||||
AND a.timestamp > NOW() - INTERVAL '24 hours'
|
||||
ORDER BY a.timestamp DESC LIMIT 1
|
||||
) AS latest_rmm_network_alert,
|
||||
-- Open ticket count for this company today
|
||||
(
|
||||
SELECT COUNT(*) FROM tickets t
|
||||
WHERE t.company_id = z.autotask_company_id
|
||||
AND t.source = 8
|
||||
AND t.create_date > NOW() - INTERVAL '24 hours'
|
||||
AND t.is_deleted IS NOT TRUE
|
||||
) AS monitor_tickets_24h
|
||||
FROM zabbix_wan_hosts z
|
||||
WHERE z.last_problem_at > NOW() - INTERVAL '24 hours'
|
||||
OR z.status = 1
|
||||
ORDER BY z.last_problem_at DESC NULLS LAST
|
||||
`);
|
||||
|
||||
// ── 5. Summary counts ─────────────────────────────────────────────────────
|
||||
const summary = await postgresClient.query(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM datto_rmm_sites s JOIN rmm_site_mappings rsm ON rsm.rmm_site_uid = s.uid)
|
||||
AS total_rmm_sites,
|
||||
(SELECT COUNT(*) FROM zabbix_wan_hosts)
|
||||
AS total_zabbix_hosts,
|
||||
(SELECT COUNT(*) FROM zabbix_wan_hosts WHERE status = 0)
|
||||
AS zabbix_enabled,
|
||||
(SELECT COUNT(*) FROM datto_rmm_sites s
|
||||
JOIN rmm_site_mappings rsm ON rsm.rmm_site_uid = s.uid
|
||||
LEFT JOIN zabbix_wan_hosts z ON z.rmm_site_uid = s.uid
|
||||
WHERE z.hostid IS NULL AND s.number_of_online_devices > 0)
|
||||
AS rmm_sites_no_zabbix,
|
||||
(SELECT COUNT(*) FROM itg_wan_circuits WHERE NOT is_decommissioned)
|
||||
AS total_itg_circuits,
|
||||
(SELECT COUNT(*) FROM itg_wan_circuits
|
||||
WHERE zabbix_hostid IS NOT NULL AND NOT is_decommissioned)
|
||||
AS itg_circuits_monitored,
|
||||
(SELECT COUNT(*) FROM itg_wan_circuits
|
||||
WHERE zabbix_hostid IS NULL AND NOT is_decommissioned
|
||||
AND array_length(static_ips,1) > 0)
|
||||
AS itg_circuits_gap,
|
||||
(SELECT MAX(last_synced_at) FROM zabbix_wan_hosts)
|
||||
AS zabbix_last_synced,
|
||||
(SELECT MAX(last_synced_at) FROM itg_wan_circuits)
|
||||
AS itg_last_synced
|
||||
`);
|
||||
|
||||
return NextResponse.json({
|
||||
summary: summary.rows[0],
|
||||
rmm_gaps: rmmGaps.rows,
|
||||
itg_gaps: itgGaps.rows,
|
||||
multi_circuit: multiCircuit.rows,
|
||||
problems: problems.rows,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
188
app/api/zabbix/webhook/route.ts
Normal file
188
app/api/zabbix/webhook/route.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
/**
|
||||
* POST /api/zabbix/webhook
|
||||
* Receives Zabbix alert/recovery notifications and writes them to zabbix_events.
|
||||
* Enriches each event with company/site context from the local zabbix_wan_hosts cache.
|
||||
*
|
||||
* Expected JSON payload (sent by the Zabbix webhook media type script):
|
||||
* {
|
||||
* event_id: string -- {EVENT.ID}
|
||||
* event_name: string -- {EVENT.NAME}
|
||||
* event_value: string -- "1" = PROBLEM, "0" = RESOLVED
|
||||
* event_severity: string -- numeric severity "0"–"5"
|
||||
* event_clock: string -- Unix timestamp string
|
||||
* trigger_id: string -- {TRIGGER.ID}
|
||||
* host_id: string -- {HOST.ID}
|
||||
* host_name: string -- {HOST.HOST} (technical name)
|
||||
* r_event_id?: string -- {EVENT.RECOVERY.ID} (only on recovery)
|
||||
* r_clock?: string -- {EVENT.RECOVERY.DATE} as unix ts (only on recovery)
|
||||
* }
|
||||
*
|
||||
* Auth: Bearer token in Authorization header, matched against ZABBIX_WEBHOOK_SECRET env var.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Optional shared secret — skip check if not configured
|
||||
const secret = process.env.ZABBIX_WEBHOOK_SECRET;
|
||||
if (secret) {
|
||||
const auth = request.headers.get('authorization') ?? '';
|
||||
const token = auth.startsWith('Bearer ') ? auth.slice(7) : auth;
|
||||
if (token !== secret) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
let body: Record<string, string>;
|
||||
try {
|
||||
const raw = await request.text();
|
||||
console.log('[ZABBIX-WEBHOOK] Incoming request — auth:', request.headers.get('authorization') ? 'present' : 'none', '— body:', raw.substring(0, 500));
|
||||
body = JSON.parse(raw);
|
||||
} catch {
|
||||
console.log('[ZABBIX-WEBHOOK] Failed to parse JSON');
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
const {
|
||||
event_id,
|
||||
event_name,
|
||||
event_value,
|
||||
event_severity,
|
||||
event_clock,
|
||||
trigger_id,
|
||||
host_id,
|
||||
r_event_id,
|
||||
r_clock,
|
||||
} = body;
|
||||
|
||||
if (!event_id || !host_id || !event_clock) {
|
||||
return NextResponse.json({ error: 'Missing required fields: event_id, host_id, event_clock' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Enrich with local host context
|
||||
const hostRow = await postgresClient.query<{
|
||||
display_name: string;
|
||||
wan_ip: string | null;
|
||||
autotask_company_id: number | null;
|
||||
autotask_company_name: string | null;
|
||||
rmm_site_uid: string | null;
|
||||
isp_name: string | null;
|
||||
}>(
|
||||
'SELECT display_name, wan_ip, autotask_company_id, autotask_company_name, rmm_site_uid, isp_name FROM zabbix_wan_hosts WHERE hostid = $1',
|
||||
[host_id]
|
||||
);
|
||||
|
||||
const host = hostRow.rows[0] ?? null;
|
||||
|
||||
const toInt = (v: string | undefined) => { const n = Number(v); return Number.isFinite(n) ? n : null; };
|
||||
const toTs = (v: string | undefined) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? new Date(n * 1000) : null; };
|
||||
const isMacro = (v: string | undefined) => !v || v.startsWith('{');
|
||||
|
||||
const isResolved = event_value === '0';
|
||||
const clockTs = toTs(event_clock) ?? new Date();
|
||||
// {EVENT.RECOVERY.CLOCK} often doesn't resolve in webhook params — fall back to NOW() for recoveries
|
||||
const rClockTs = toTs(r_clock) ?? (isResolved ? new Date() : null);
|
||||
const duration = rClockTs ? Math.round((rClockTs.getTime() - clockTs.getTime()) / 1000) : null;
|
||||
// {EVENT.RECOVERY.ID} also may not resolve — use event_id as marker so the row is flagged resolved
|
||||
const rEventId = !isMacro(r_event_id) && r_event_id !== '0'
|
||||
? r_event_id
|
||||
: (isResolved ? event_id : null);
|
||||
|
||||
await postgresClient.query(
|
||||
`INSERT INTO zabbix_events (
|
||||
eventid, objectid, name, severity, clock,
|
||||
r_eventid, r_clock, duration_seconds,
|
||||
hostid, host_name, wan_ip,
|
||||
autotask_company_id, autotask_company_name, rmm_site_uid, isp_name,
|
||||
last_synced_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW())
|
||||
ON CONFLICT (eventid) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
r_eventid = COALESCE(EXCLUDED.r_eventid, zabbix_events.r_eventid),
|
||||
r_clock = COALESCE(EXCLUDED.r_clock, zabbix_events.r_clock),
|
||||
duration_seconds = COALESCE(EXCLUDED.duration_seconds, zabbix_events.duration_seconds),
|
||||
host_name = EXCLUDED.host_name,
|
||||
wan_ip = EXCLUDED.wan_ip,
|
||||
autotask_company_id = EXCLUDED.autotask_company_id,
|
||||
autotask_company_name = EXCLUDED.autotask_company_name,
|
||||
rmm_site_uid = EXCLUDED.rmm_site_uid,
|
||||
isp_name = EXCLUDED.isp_name,
|
||||
last_synced_at = NOW()`,
|
||||
[
|
||||
event_id,
|
||||
trigger_id ?? null,
|
||||
event_name ?? null,
|
||||
toInt(event_severity),
|
||||
clockTs,
|
||||
rEventId,
|
||||
rClockTs,
|
||||
duration,
|
||||
host_id,
|
||||
host?.display_name ?? body.host_name ?? null,
|
||||
host?.wan_ip ?? null,
|
||||
host?.autotask_company_id ?? null,
|
||||
host?.autotask_company_name ?? null,
|
||||
host?.rmm_site_uid ?? null,
|
||||
host?.isp_name ?? null,
|
||||
]
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true, event_id, resolved: isResolved });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error('[ZABBIX-WEBHOOK]', msg);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/zabbix/webhook
|
||||
* Returns the Zabbix media type script and parameter config ready to paste.
|
||||
*/
|
||||
export async function GET() {
|
||||
const baseUrl = process.env.WEBHOOK_BASE_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? process.env.APP_URL ?? 'https://your-pulse-url';
|
||||
const secret = process.env.ZABBIX_WEBHOOK_SECRET ?? '';
|
||||
|
||||
const script = `// Pulse WAN Correlation Webhook
|
||||
var params = JSON.parse(value);
|
||||
var req = new HttpRequest();
|
||||
req.addHeader('Content-Type: application/json');
|
||||
${secret ? "req.addHeader('Authorization: Bearer ' + params.webhook_secret);" : '// No auth configured — set ZABBIX_WEBHOOK_SECRET env var to enable'}
|
||||
|
||||
var payload = JSON.stringify({
|
||||
event_id: params.event_id,
|
||||
event_name: params.event_name,
|
||||
event_value: params.event_value,
|
||||
event_severity: params.event_severity,
|
||||
event_clock: params.event_clock,
|
||||
trigger_id: params.trigger_id,
|
||||
host_id: params.host_id,
|
||||
host_name: params.host_name,
|
||||
r_event_id: params.r_event_id,
|
||||
r_clock: params.r_clock
|
||||
});
|
||||
|
||||
var response = req.post(params.webhook_url, payload);
|
||||
if (req.getStatus() !== 200) {
|
||||
throw 'Pulse webhook failed: HTTP ' + req.getStatus() + ' — ' + response;
|
||||
}
|
||||
return 'OK';`;
|
||||
|
||||
const parameters = [
|
||||
{ name: 'webhook_url', value: `${baseUrl}/api/zabbix/webhook` },
|
||||
{ name: 'webhook_secret', value: secret || '(set ZABBIX_WEBHOOK_SECRET env var)' },
|
||||
{ name: 'event_id', value: '{EVENT.ID}' },
|
||||
{ name: 'event_name', value: '{EVENT.NAME}' },
|
||||
{ name: 'event_value', value: '{EVENT.VALUE}' },
|
||||
{ name: 'event_severity', value: '{EVENT.SEVERITY.NUM}' },
|
||||
{ name: 'event_clock', value: '{EVENT.CLOCK}' },
|
||||
{ name: 'trigger_id', value: '{TRIGGER.ID}' },
|
||||
{ name: 'host_id', value: '{HOST.ID}' },
|
||||
{ name: 'host_name', value: '{HOST.HOST}' },
|
||||
{ name: 'r_event_id', value: '{EVENT.RECOVERY.ID}' },
|
||||
{ name: 'r_clock', value: '{EVENT.RECOVERY.CLOCK}' },
|
||||
];
|
||||
|
||||
return NextResponse.json({ script, parameters, webhook_url: `${baseUrl}/api/zabbix/webhook` });
|
||||
}
|
||||
67
app/legal/eula/page.tsx
Normal file
67
app/legal/eula/page.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
export default function EndUserLicenseAgreement() {
|
||||
return (
|
||||
<main style={{ maxWidth: 800, margin: '40px auto', padding: '0 24px', fontFamily: 'sans-serif', lineHeight: 1.7 }}>
|
||||
<h1>End-User License Agreement</h1>
|
||||
<p><strong>Last updated:</strong> March 17, 2026</p>
|
||||
|
||||
<p>
|
||||
This End-User License Agreement (“Agreement”) governs your use of the Pulse
|
||||
internal operations platform (“Application”) developed and operated by{' '}
|
||||
<strong>Wulf Consulting, Inc.</strong> (“Wulf Consulting”).
|
||||
</p>
|
||||
|
||||
<h2>1. Internal Use Only</h2>
|
||||
<p>
|
||||
The Application is licensed exclusively for internal use by authorized Wulf Consulting
|
||||
employees and contractors. Access by unauthorized individuals is strictly prohibited.
|
||||
</p>
|
||||
|
||||
<h2>2. License Grant</h2>
|
||||
<p>
|
||||
Wulf Consulting grants authorized users a non-exclusive, non-transferable, revocable
|
||||
license to access and use the Application solely for internal business operations.
|
||||
</p>
|
||||
|
||||
<h2>3. Third-Party Integrations</h2>
|
||||
<p>
|
||||
The Application integrates with QuickBooks Online via the Intuit API. Use of QuickBooks
|
||||
Online data within the Application is subject to Intuit's Terms of Service. Users
|
||||
must not use the Application to access QuickBooks data beyond what is required for
|
||||
legitimate internal business purposes.
|
||||
</p>
|
||||
|
||||
<h2>4. Restrictions</h2>
|
||||
<ul>
|
||||
<li>You may not redistribute, sublicense, or resell access to the Application.</li>
|
||||
<li>You may not use the Application to process data for third parties outside Wulf Consulting.</li>
|
||||
<li>You may not reverse-engineer or attempt to extract source code from the Application.</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Data Handling</h2>
|
||||
<p>
|
||||
All financial data accessed through the Application is handled in accordance with our
|
||||
Privacy Policy. Data is stored on Wulf Consulting's private infrastructure and is
|
||||
not shared externally.
|
||||
</p>
|
||||
|
||||
<h2>6. Termination</h2>
|
||||
<p>
|
||||
This license is effective until terminated. Wulf Consulting may terminate access at any
|
||||
time. Upon termination, you must cease all use of the Application.
|
||||
</p>
|
||||
|
||||
<h2>7. Disclaimer of Warranties</h2>
|
||||
<p>
|
||||
The Application is provided “as is” for internal operational use. Wulf
|
||||
Consulting makes no warranties regarding uptime, accuracy of synced data, or fitness for
|
||||
any particular purpose beyond internal operations.
|
||||
</p>
|
||||
|
||||
<h2>8. Contact</h2>
|
||||
<p>
|
||||
For questions about this Agreement, contact{' '}
|
||||
<a href="mailto:admin@wulfconsulting.com">admin@wulfconsulting.com</a>.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
49
app/legal/privacy/page.tsx
Normal file
49
app/legal/privacy/page.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
export default function PrivacyPolicy() {
|
||||
return (
|
||||
<main style={{ maxWidth: 800, margin: '40px auto', padding: '0 24px', fontFamily: 'sans-serif', lineHeight: 1.7 }}>
|
||||
<h1>Privacy Policy</h1>
|
||||
<p><strong>Last updated:</strong> March 17, 2026</p>
|
||||
|
||||
<p>
|
||||
Pulse is an internal operations platform operated by <strong>Wulf Consulting, Inc.</strong>
|
||||
(“Wulf Consulting”, “we”, “us”). This application is for
|
||||
internal business use only and is not available to the general public.
|
||||
</p>
|
||||
|
||||
<h2>Information We Access</h2>
|
||||
<p>
|
||||
Pulse connects to QuickBooks Online via the Intuit OAuth 2.0 API to read financial data
|
||||
including invoices, payments, deposits, transactions, and financial reports. This data is
|
||||
accessed solely for internal reporting and business operations purposes.
|
||||
</p>
|
||||
|
||||
<h2>How We Use Your Data</h2>
|
||||
<ul>
|
||||
<li>Financial data is stored in a private, self-hosted PostgreSQL database.</li>
|
||||
<li>Data is used exclusively for internal dashboards and reporting.</li>
|
||||
<li>No financial data is shared with third parties.</li>
|
||||
<li>No data is sold or used for advertising.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Data Storage and Security</h2>
|
||||
<p>
|
||||
All data is stored on servers controlled by Wulf Consulting. Access is restricted to
|
||||
authorized Wulf Consulting staff only. OAuth tokens are stored securely and are never
|
||||
exposed externally.
|
||||
</p>
|
||||
|
||||
<h2>Data Retention</h2>
|
||||
<p>
|
||||
Synced financial data is retained for operational reporting purposes. OAuth tokens are
|
||||
refreshed automatically and can be revoked at any time via the QuickBooks Online app
|
||||
authorization settings.
|
||||
</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
For questions about this policy, contact <strong>Wulf Consulting, Inc.</strong> at{' '}
|
||||
<a href="mailto:admin@wulfconsulting.com">admin@wulfconsulting.com</a>.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -24,6 +24,8 @@ import {
|
|||
Users,
|
||||
TrendingUp,
|
||||
Sun,
|
||||
BarChart3,
|
||||
DollarSign,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
NavigationMenu,
|
||||
|
|
@ -146,6 +148,12 @@ const navigationItems: NavItem[] = [
|
|||
icon: Sun,
|
||||
description: 'Daily Zabbix overnight summary posted to Teams channels via webhook'
|
||||
},
|
||||
{
|
||||
title: 'Ticket Digest Reports',
|
||||
href: '/admin/ticket-digest',
|
||||
icon: BarChart3,
|
||||
description: 'LLM-analyzed ticket reports — noise, SLA, workload — daily/weekly/monthly'
|
||||
},
|
||||
{
|
||||
title: 'Notification Channels',
|
||||
href: '/admin/workflow/channels',
|
||||
|
|
@ -164,6 +172,12 @@ const navigationItems: NavItem[] = [
|
|||
icon: Shield,
|
||||
description: 'SentinelOne EDR — sites, agents, threats sync'
|
||||
},
|
||||
{
|
||||
title: 'QuickBooks Online',
|
||||
href: '/admin/qbo',
|
||||
icon: DollarSign,
|
||||
description: 'Sync invoices, payments, deposits, transactions and financial reports'
|
||||
},
|
||||
{
|
||||
title: 'Data Browser',
|
||||
href: '/admin/data-browser',
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ function getWebhookEntityName(entityType: WebhookEntityType): string {
|
|||
ConfigurationItems: 'ConfigurationItemWebhooks',
|
||||
Tickets: 'TicketWebhooks',
|
||||
TicketNotes: 'TicketNoteWebhooks',
|
||||
TimeEntries: 'TimeEntryWebhooks',
|
||||
Tasks: 'TaskWebhooks',
|
||||
Projects: 'ProjectWebhooks',
|
||||
Contracts: 'ContractWebhooks',
|
||||
};
|
||||
return map[entityType] || `${entityType}Webhooks`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -551,4 +551,28 @@ export class DattoRMMClient {
|
|||
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* For a PING alert, fetch the ping target (instanceName) from alertContext.
|
||||
* Returns null if not found or API call fails.
|
||||
*/
|
||||
async getPingAlertTarget(alertUid: string): Promise<string | null> {
|
||||
const token = await this.getAccessToken();
|
||||
const url = `${this.config.apiUrl}/api/v2/alert/${alertUid}`;
|
||||
const resp = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!resp.ok) return null;
|
||||
|
||||
const data = await resp.json();
|
||||
const ctx = data?.alertContext;
|
||||
if (ctx?.['@class'] === 'ping_ctx' && ctx?.instanceName) {
|
||||
return ctx.instanceName as string;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -386,13 +386,15 @@ export class EntitySyncService {
|
|||
|
||||
// For full sync, soft delete records not in the fetched set
|
||||
// IMPORTANT: Skip soft deletes if ANY filters were applied (date, status, active, etc.)
|
||||
// because we cannot know what records exist outside the filter criteria
|
||||
// because we cannot know what records exist outside the filter criteria.
|
||||
// EXCEPTION: TICKETS — we do a separate ID-only fetch from Autotask (no filters) to detect
|
||||
// deletions even when date filters were applied to the main sync.
|
||||
let deletedCount = 0;
|
||||
|
||||
|
||||
if (!isIncremental && !hasAppliedFilters) {
|
||||
entityLogger.phase(SyncPhase.DELETING, 'Checking for records to soft delete');
|
||||
syncProgressTracker.updateProgress(trackingId, { phase: 'deleting' });
|
||||
|
||||
|
||||
try {
|
||||
const activeIds = mappedRecords.map(r => r.id);
|
||||
deletedCount = await softDeleteMissingRecords(entity, activeIds);
|
||||
|
|
@ -400,7 +402,43 @@ export class EntitySyncService {
|
|||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
entityLogger.warn('Soft delete failed, continuing sync', {}, err);
|
||||
// Don't throw - soft delete failure shouldn't fail the entire sync
|
||||
}
|
||||
} else if (!isIncremental && hasAppliedFilters && entity === EntityType.TICKETS) {
|
||||
entityLogger.phase(SyncPhase.DELETING, 'Fetching all Autotask ticket IDs for deletion diff');
|
||||
syncProgressTracker.updateProgress(trackingId, { phase: 'deleting' });
|
||||
|
||||
try {
|
||||
// Fetch all ticket IDs from Autotask with no filters for deletion diff
|
||||
const allAutotaskTickets = await this.autotaskClient.queryEntityPaginated(
|
||||
'Tickets',
|
||||
{},
|
||||
500
|
||||
);
|
||||
const liveIds = new Set(allAutotaskTickets.map((t: any) => String(t.id)));
|
||||
|
||||
// Find Pulse ticket IDs not present in Autotask → these were deleted
|
||||
const pulseResult = await postgresClient.query(
|
||||
`SELECT id FROM tickets WHERE is_deleted = false`
|
||||
);
|
||||
const toDelete = pulseResult.rows
|
||||
.map((r: { id: number }) => r.id)
|
||||
.filter((id: number) => !liveIds.has(String(id)));
|
||||
|
||||
if (toDelete.length > 0) {
|
||||
const result = await postgresClient.query(
|
||||
`UPDATE tickets
|
||||
SET is_deleted = true, deleted_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ANY($1::int[])`,
|
||||
[toDelete]
|
||||
);
|
||||
deletedCount = result.rowCount || 0;
|
||||
entityLogger.info('Soft deleted tickets missing from Autotask', { deletedCount });
|
||||
} else {
|
||||
entityLogger.info('No deleted tickets detected');
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
entityLogger.warn('Ticket deletion diff failed, continuing sync', {}, err);
|
||||
}
|
||||
} else if (!isIncremental && hasAppliedFilters) {
|
||||
entityLogger.info('Skipping soft-delete because filters were applied (would delete records outside filter criteria)');
|
||||
|
|
|
|||
|
|
@ -20,3 +20,4 @@ import './rmm-quick-job';
|
|||
import './enrich-vspc';
|
||||
import './db-query';
|
||||
import './fetch-b2-result';
|
||||
import './ping-flap-suppress';
|
||||
|
|
|
|||
142
lib/services/pipeline-steps/ping-flap-suppress.ts
Normal file
142
lib/services/pipeline-steps/ping-flap-suppress.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { registerStepExecutor } from '../pipeline-engine';
|
||||
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
|
||||
import { postgresClient } from '../postgres-client';
|
||||
import { ZabbixClient } from '../zabbix-client';
|
||||
import { ZabbixProblem } from '../../types/zabbix';
|
||||
import { promises as dns } from 'dns';
|
||||
|
||||
/**
|
||||
* Ping Flap Suppression Step
|
||||
*
|
||||
* Detects flapping PING alerts from Datto RMM and suppresses the corresponding
|
||||
* Zabbix problem when a threshold is exceeded.
|
||||
*
|
||||
* Flap = same ping_target triggers >= FLAP_THRESHOLD times within FLAP_WINDOW_HOURS.
|
||||
* When detected:
|
||||
* - Suppresses the open Zabbix problem for the matching host for SUPPRESS_HOURS
|
||||
* - Records the suppression in ping_flap_suppressions to avoid re-firing
|
||||
*/
|
||||
|
||||
const FLAP_THRESHOLD = 5;
|
||||
const FLAP_WINDOW_HOURS = 2;
|
||||
const SUPPRESS_HOURS = 4;
|
||||
|
||||
function makeZabbix(): ZabbixClient {
|
||||
if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) {
|
||||
throw new Error('Zabbix not configured');
|
||||
}
|
||||
return new ZabbixClient({ apiUrl: process.env.ZABBIX_API_URL, apiToken: process.env.ZABBIX_API_TOKEN });
|
||||
}
|
||||
|
||||
async function executePingFlapSuppress(
|
||||
_step: PipelineStep,
|
||||
context: PipelineContext,
|
||||
_executionId: number
|
||||
): Promise<StepExecutorResult> {
|
||||
const payload = context.triggerData;
|
||||
|
||||
if (payload?.alert_type !== 'PING') {
|
||||
return { success: true, output: { skipped: true } };
|
||||
}
|
||||
|
||||
const pingTarget: string | null = payload?.ping_target ?? null;
|
||||
if (!pingTarget) {
|
||||
return { success: true, output: { skipped: true, reason: 'no ping_target' } };
|
||||
}
|
||||
|
||||
// Check if already suppressed
|
||||
const suppCheck = await postgresClient.query(
|
||||
`SELECT suppressed_until FROM ping_flap_suppressions
|
||||
WHERE ping_target = $1 AND suppressed_until > NOW()`,
|
||||
[pingTarget]
|
||||
);
|
||||
if (suppCheck.rows.length > 0) {
|
||||
return { success: true, output: { flap_suppressed: true, already_active: true } };
|
||||
}
|
||||
|
||||
// Count triggers in the flap window
|
||||
const countResult = await postgresClient.query(
|
||||
`SELECT COUNT(*) as cnt FROM datto_rmm_alerts
|
||||
WHERE alert_type = 'PING'
|
||||
AND ping_target = $1
|
||||
AND triggered = 'True'
|
||||
AND timestamp >= NOW() - INTERVAL '${FLAP_WINDOW_HOURS} hours'`,
|
||||
[pingTarget]
|
||||
);
|
||||
const triggerCount = parseInt(countResult.rows[0]?.cnt ?? '0', 10);
|
||||
|
||||
if (triggerCount < FLAP_THRESHOLD) {
|
||||
return { success: true, output: { flap_detected: false, trigger_count: triggerCount } };
|
||||
}
|
||||
|
||||
const suppressUntil = new Date(Date.now() + SUPPRESS_HOURS * 60 * 60 * 1000);
|
||||
const message = `Auto-suppressed: flapping detected (${triggerCount} triggers in ${FLAP_WINDOW_HOURS}h). Awaiting manual resolution.`;
|
||||
|
||||
// Try to find and suppress the Zabbix problem
|
||||
let zabbixSuppressed = false;
|
||||
let zabbixHost: string | null = null;
|
||||
try {
|
||||
const zabbix = makeZabbix();
|
||||
|
||||
// Resolve DNS name to IP — Zabbix stores IPs in interfaces, not DNS names
|
||||
const lookupTargets = [pingTarget];
|
||||
try {
|
||||
const resolved = await dns.lookup(pingTarget);
|
||||
if (resolved.address && resolved.address !== pingTarget) {
|
||||
lookupTargets.unshift(resolved.address);
|
||||
}
|
||||
} catch { /* not a resolvable hostname — may already be an IP */ }
|
||||
|
||||
let hosts: Array<{ hostid: string; name: string }> = [];
|
||||
for (const target of lookupTargets) {
|
||||
hosts = await zabbix.findHostsByInterface(target);
|
||||
if (hosts.length > 0) break;
|
||||
}
|
||||
|
||||
if (hosts.length > 0) {
|
||||
const host = hosts[0];
|
||||
zabbixHost = host.name;
|
||||
const problems = await zabbix.getOpenProblemsForHost(host.hostid);
|
||||
const unreachable = problems.find((p: ZabbixProblem) =>
|
||||
p.name.toLowerCase().includes('unreachable') ||
|
||||
p.name.toLowerCase().includes('unavailable')
|
||||
);
|
||||
|
||||
if (unreachable) {
|
||||
await zabbix.suppressProblem(unreachable.eventid, suppressUntil, message);
|
||||
zabbixSuppressed = true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[ping-flap-suppress] Zabbix error for ${pingTarget}:`, err);
|
||||
}
|
||||
|
||||
// Record suppression to prevent repeated attempts within the window
|
||||
await postgresClient.query(
|
||||
`INSERT INTO ping_flap_suppressions (ping_target, trigger_count, suppressed_until, zabbix_suppressed, notes)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (ping_target) DO UPDATE SET
|
||||
trigger_count = EXCLUDED.trigger_count,
|
||||
suppressed_until = EXCLUDED.suppressed_until,
|
||||
zabbix_suppressed = EXCLUDED.zabbix_suppressed,
|
||||
notes = EXCLUDED.notes,
|
||||
updated_at = NOW()`,
|
||||
[pingTarget, triggerCount, suppressUntil, zabbixSuppressed, message]
|
||||
);
|
||||
|
||||
console.log(`[ping-flap-suppress] Flap detected: ${pingTarget} (${triggerCount} triggers) — zabbix_suppressed=${zabbixSuppressed} host=${zabbixHost}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
flap_detected: true,
|
||||
flap_suppressed: true,
|
||||
trigger_count: triggerCount,
|
||||
zabbix_suppressed: zabbixSuppressed,
|
||||
zabbix_host: zabbixHost,
|
||||
suppressed_until: suppressUntil.toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
registerStepExecutor('ping_flap_suppress', executePingFlapSuppress);
|
||||
290
lib/services/qbo-client.ts
Normal file
290
lib/services/qbo-client.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
/**
|
||||
* QuickBooks Online API Client
|
||||
* Handles OAuth2 token management and all QBO REST API calls.
|
||||
*/
|
||||
|
||||
import postgresClient from './postgres-client';
|
||||
import {
|
||||
QboTokenRecord,
|
||||
QboTokenResponse,
|
||||
QboInvoice,
|
||||
QboPayment,
|
||||
QboDeposit,
|
||||
QboPurchase,
|
||||
QboJournalEntry,
|
||||
QboReport,
|
||||
QboQueryResponse,
|
||||
} from '@/lib/types/qbo';
|
||||
|
||||
const QBO_PRODUCTION_URL = 'https://quickbooks.api.intuit.com';
|
||||
const QBO_SANDBOX_URL = 'https://sandbox-quickbooks.api.intuit.com';
|
||||
const QBO_TOKEN_URL = 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer';
|
||||
|
||||
export class QboClient {
|
||||
private clientId: string;
|
||||
private clientSecret: string;
|
||||
private realmId: string;
|
||||
private baseUrl: string;
|
||||
private _reportBasis: string | null = null;
|
||||
|
||||
constructor() {
|
||||
this.clientId = process.env.QBO_CLIENT_ID || '';
|
||||
this.clientSecret = process.env.QBO_CLIENT_SECRET || '';
|
||||
this.realmId = process.env.QBO_REALM_ID || '';
|
||||
this.baseUrl = process.env.QBO_SANDBOX === 'true' ? QBO_SANDBOX_URL : QBO_PRODUCTION_URL;
|
||||
|
||||
if (!this.clientId || !this.clientSecret || !this.realmId) {
|
||||
throw new Error('QBO_CLIENT_ID, QBO_CLIENT_SECRET, and QBO_REALM_ID must be set');
|
||||
}
|
||||
|
||||
console.log(`[QboClient] Using ${process.env.QBO_SANDBOX === 'true' ? 'SANDBOX' : 'PRODUCTION'} environment`);
|
||||
}
|
||||
|
||||
// ─── Token Management ───────────────────────────────────────────────────────
|
||||
|
||||
async getValidAccessToken(): Promise<string> {
|
||||
const token = await this.loadToken();
|
||||
if (!token) {
|
||||
throw new Error('No QBO token found. Complete OAuth2 authorization first via /api/qbo/auth');
|
||||
}
|
||||
|
||||
if (new Date() < new Date(token.access_token_expires_at)) {
|
||||
return token.access_token;
|
||||
}
|
||||
|
||||
if (new Date() >= new Date(token.refresh_token_expires_at)) {
|
||||
throw new Error('QBO refresh token has expired. Re-authorization required via /api/qbo/auth');
|
||||
}
|
||||
|
||||
return this.refreshAccessToken(token.refresh_token);
|
||||
}
|
||||
|
||||
async loadToken(): Promise<QboTokenRecord | null> {
|
||||
const result = await postgresClient.query<QboTokenRecord>(
|
||||
`SELECT * FROM qbo_tokens WHERE realm_id = $1 LIMIT 1`,
|
||||
[this.realmId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async saveToken(token: QboTokenResponse): Promise<void> {
|
||||
const now = new Date();
|
||||
const accessExpiry = new Date(now.getTime() + token.expires_in * 1000);
|
||||
const refreshExpiry = new Date(now.getTime() + token.x_refresh_token_expires_in * 1000);
|
||||
|
||||
await postgresClient.query(
|
||||
`INSERT INTO qbo_tokens (realm_id, access_token, refresh_token, access_token_expires_at, refresh_token_expires_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||
ON CONFLICT (realm_id) DO UPDATE SET
|
||||
access_token = EXCLUDED.access_token,
|
||||
refresh_token = EXCLUDED.refresh_token,
|
||||
access_token_expires_at = EXCLUDED.access_token_expires_at,
|
||||
refresh_token_expires_at = EXCLUDED.refresh_token_expires_at,
|
||||
updated_at = NOW()`,
|
||||
[this.realmId, token.access_token, token.refresh_token, accessExpiry, refreshExpiry]
|
||||
);
|
||||
}
|
||||
|
||||
async exchangeCodeForToken(authCode: string, redirectUri: string): Promise<QboTokenResponse> {
|
||||
const credentials = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64');
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: authCode,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
const res = await fetch(QBO_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${credentials}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`QBO token exchange failed: ${res.status} ${err}`);
|
||||
}
|
||||
|
||||
const tokenData: QboTokenResponse = await res.json();
|
||||
await this.saveToken(tokenData);
|
||||
return tokenData;
|
||||
}
|
||||
|
||||
private async refreshAccessToken(refreshToken: string): Promise<string> {
|
||||
const credentials = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64');
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
const res = await fetch(QBO_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${credentials}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`QBO token refresh failed: ${res.status} ${err}`);
|
||||
}
|
||||
|
||||
const tokenData: QboTokenResponse = await res.json();
|
||||
await this.saveToken(tokenData);
|
||||
console.log('[QboClient] Access token refreshed successfully');
|
||||
return tokenData.access_token;
|
||||
}
|
||||
|
||||
getAuthorizationUrl(redirectUri: string, state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: this.clientId,
|
||||
scope: 'com.intuit.quickbooks.accounting',
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
state,
|
||||
});
|
||||
return `https://appcenter.intuit.com/connect/oauth2?${params.toString()}`;
|
||||
}
|
||||
|
||||
// ─── Core API Request ────────────────────────────────────────────────────────
|
||||
|
||||
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const accessToken = await this.getValidAccessToken();
|
||||
const url = `${this.baseUrl}/v3/company/${this.realmId}${path}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
redirect: 'follow',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
const intuitTid = res.headers.get('intuit_tid') || res.headers.get('intuit-tid') || 'unknown';
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`QBO API error ${res.status} on ${path} [intuit_tid=${intuitTid}]: ${err}`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ─── Paginated Query ─────────────────────────────────────────────────────────
|
||||
|
||||
private async queryAll<T>(entity: string, extraWhere = ''): Promise<T[]> {
|
||||
const all: T[] = [];
|
||||
let startPos = 1;
|
||||
const pageSize = 1000;
|
||||
|
||||
while (true) {
|
||||
const where = extraWhere ? ` WHERE ${extraWhere}` : '';
|
||||
const sql = encodeURIComponent(
|
||||
`SELECT * FROM ${entity}${where} STARTPOSITION ${startPos} MAXRESULTS ${pageSize}`
|
||||
);
|
||||
const data = await this.request<QboQueryResponse<T>>(`/query?query=${sql}&minorversion=65`);
|
||||
const items: T[] = (data.QueryResponse[entity] as T[]) || [];
|
||||
all.push(...items);
|
||||
|
||||
if (items.length < pageSize) break;
|
||||
startPos += pageSize;
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
// ─── Entity Fetchers ─────────────────────────────────────────────────────────
|
||||
|
||||
async getInvoices(updatedSince?: Date): Promise<QboInvoice[]> {
|
||||
const where = updatedSince
|
||||
? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'`
|
||||
: undefined;
|
||||
return this.queryAll<QboInvoice>('Invoice', where);
|
||||
}
|
||||
|
||||
async getPayments(updatedSince?: Date): Promise<QboPayment[]> {
|
||||
const where = updatedSince
|
||||
? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'`
|
||||
: undefined;
|
||||
return this.queryAll<QboPayment>('Payment', where);
|
||||
}
|
||||
|
||||
async getDeposits(updatedSince?: Date): Promise<QboDeposit[]> {
|
||||
const where = updatedSince
|
||||
? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'`
|
||||
: undefined;
|
||||
return this.queryAll<QboDeposit>('Deposit', where);
|
||||
}
|
||||
|
||||
async getPurchases(updatedSince?: Date): Promise<QboPurchase[]> {
|
||||
const where = updatedSince
|
||||
? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'`
|
||||
: undefined;
|
||||
return this.queryAll<QboPurchase>('Purchase', where);
|
||||
}
|
||||
|
||||
async getJournalEntries(updatedSince?: Date): Promise<QboJournalEntry[]> {
|
||||
const where = updatedSince
|
||||
? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'`
|
||||
: undefined;
|
||||
return this.queryAll<QboJournalEntry>('JournalEntry', where);
|
||||
}
|
||||
|
||||
async getPreferences(): Promise<{ reportBasis: string }> {
|
||||
if (this._reportBasis) return { reportBasis: this._reportBasis };
|
||||
const data = await this.request<{ Preferences?: { ReportPrefs?: { ReportBasis?: string } } }>('/preferences?minorversion=65');
|
||||
this._reportBasis = data?.Preferences?.ReportPrefs?.ReportBasis ?? 'Accrual';
|
||||
return { reportBasis: this._reportBasis };
|
||||
}
|
||||
|
||||
async getProfitAndLoss(startDate: string, endDate: string, accountingMethod?: string): Promise<QboReport> {
|
||||
const params = new URLSearchParams({
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
accounting_method: accountingMethod ?? 'Accrual',
|
||||
showrows: 'all',
|
||||
showcols: 'all',
|
||||
minorversion: '65',
|
||||
});
|
||||
return this.request<QboReport>(`/reports/ProfitAndLoss?${params.toString()}`);
|
||||
}
|
||||
|
||||
async getBalanceSheet(startDate: string, endDate: string, accountingMethod?: string): Promise<QboReport> {
|
||||
const params = new URLSearchParams({
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
accounting_method: accountingMethod ?? 'Accrual',
|
||||
showrows: 'all',
|
||||
showcols: 'all',
|
||||
minorversion: '65',
|
||||
});
|
||||
return this.request<QboReport>(`/reports/BalanceSheet?${params.toString()}`);
|
||||
}
|
||||
|
||||
async getCashFlow(startDate: string, endDate: string): Promise<QboReport> {
|
||||
const params = new URLSearchParams({
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
minorversion: '65',
|
||||
});
|
||||
return this.request<QboReport>(`/reports/CashFlow?${params.toString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: QboClient | null = null;
|
||||
|
||||
export function getQboClient(): QboClient {
|
||||
if (!_instance) {
|
||||
_instance = new QboClient();
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
522
lib/services/qbo-sync-service.ts
Normal file
522
lib/services/qbo-sync-service.ts
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
/**
|
||||
* QuickBooks Online Sync Service
|
||||
* Fetches invoices, payments, deposits, transactions, and financial reports
|
||||
* from QBO and upserts them into PostgreSQL.
|
||||
*/
|
||||
|
||||
import postgresClient from './postgres-client';
|
||||
import { QboClient, getQboClient } from './qbo-client';
|
||||
import {
|
||||
QboInvoice,
|
||||
QboPayment,
|
||||
QboDeposit,
|
||||
QboPurchase,
|
||||
QboJournalEntry,
|
||||
QboReport,
|
||||
QboSyncResult,
|
||||
QboEntitySyncResult,
|
||||
} from '@/lib/types/qbo';
|
||||
|
||||
export class QboSyncService {
|
||||
private client: QboClient;
|
||||
private isSyncing = false;
|
||||
|
||||
constructor(client?: QboClient) {
|
||||
this.client = client || getQboClient();
|
||||
}
|
||||
|
||||
isSyncInProgress(): boolean {
|
||||
return this.isSyncing;
|
||||
}
|
||||
|
||||
async fullSync(triggeredBy = 'system'): Promise<QboSyncResult> {
|
||||
return this.executeSync('full', triggeredBy);
|
||||
}
|
||||
|
||||
async incrementalSync(triggeredBy = 'system'): Promise<QboSyncResult> {
|
||||
return this.executeSync('incremental', triggeredBy);
|
||||
}
|
||||
|
||||
private async executeSync(
|
||||
syncType: 'full' | 'incremental',
|
||||
triggeredBy: string
|
||||
): Promise<QboSyncResult> {
|
||||
if (this.isSyncing) {
|
||||
throw new Error('QBO sync already in progress');
|
||||
}
|
||||
|
||||
this.isSyncing = true;
|
||||
const syncId = `qbo_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const startedAt = new Date();
|
||||
const entities: QboEntitySyncResult[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
const realmId = process.env.QBO_REALM_ID || '';
|
||||
|
||||
console.log(`[QboSync] Starting ${syncType} sync (${syncId}) triggered by ${triggeredBy}`);
|
||||
|
||||
try {
|
||||
// Determine updatedSince for incremental
|
||||
let updatedSince: Date | undefined;
|
||||
if (syncType === 'incremental') {
|
||||
updatedSince = await this.getLastSyncTime();
|
||||
}
|
||||
|
||||
// Sync invoices
|
||||
entities.push(await this.syncInvoices(realmId, updatedSince));
|
||||
|
||||
// Sync payments
|
||||
entities.push(await this.syncPayments(realmId, updatedSince));
|
||||
|
||||
// Sync deposits
|
||||
entities.push(await this.syncDeposits(realmId, updatedSince));
|
||||
|
||||
// Sync purchases (expenses/credit card charges)
|
||||
entities.push(await this.syncPurchases(realmId, updatedSince));
|
||||
|
||||
// Sync journal entries
|
||||
entities.push(await this.syncJournalEntries(realmId, updatedSince));
|
||||
|
||||
// Sync reports — always fetch current + last 12 months on full, current month on incremental
|
||||
if (syncType === 'full') {
|
||||
entities.push(await this.syncReports(realmId, 12));
|
||||
} else {
|
||||
entities.push(await this.syncReports(realmId, 1));
|
||||
}
|
||||
|
||||
// Record sync history
|
||||
await this.recordSyncHistory(syncId, syncType, triggeredBy, startedAt, 'completed', entities);
|
||||
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
errors.push(msg);
|
||||
console.error(`[QboSync] Sync failed: ${msg}`);
|
||||
await this.recordSyncHistory(syncId, syncType, triggeredBy, startedAt, 'failed', entities, msg);
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
}
|
||||
|
||||
const completedAt = new Date();
|
||||
return {
|
||||
syncId,
|
||||
realmId,
|
||||
status: errors.length === 0 ? 'completed' : 'failed',
|
||||
startedAt,
|
||||
completedAt,
|
||||
duration: completedAt.getTime() - startedAt.getTime(),
|
||||
entities,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Entity Sync Methods ───────────────────────────────────────────────────
|
||||
|
||||
private async syncInvoices(realmId: string, updatedSince?: Date): Promise<QboEntitySyncResult> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const invoices = await this.client.getInvoices(updatedSince);
|
||||
console.log(`[QboSync] Fetched ${invoices.length} invoices`);
|
||||
|
||||
let upserted = 0;
|
||||
for (const inv of invoices) {
|
||||
const status = this.deriveInvoiceStatus(inv);
|
||||
await postgresClient.query(
|
||||
`INSERT INTO qbo_invoices
|
||||
(id, realm_id, doc_number, txn_date, due_date, customer_ref_id, customer_ref_name,
|
||||
email_address, total_amt, balance, status, currency_code, line_items, linked_txns,
|
||||
sync_token, qbo_created_at, qbo_updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
doc_number = EXCLUDED.doc_number,
|
||||
txn_date = EXCLUDED.txn_date,
|
||||
due_date = EXCLUDED.due_date,
|
||||
customer_ref_id = EXCLUDED.customer_ref_id,
|
||||
customer_ref_name = EXCLUDED.customer_ref_name,
|
||||
email_address = EXCLUDED.email_address,
|
||||
total_amt = EXCLUDED.total_amt,
|
||||
balance = EXCLUDED.balance,
|
||||
status = EXCLUDED.status,
|
||||
currency_code = EXCLUDED.currency_code,
|
||||
line_items = EXCLUDED.line_items,
|
||||
linked_txns = EXCLUDED.linked_txns,
|
||||
sync_token = EXCLUDED.sync_token,
|
||||
qbo_updated_at = EXCLUDED.qbo_updated_at,
|
||||
synced_at = NOW()`,
|
||||
[
|
||||
inv.Id, realmId, inv.DocNumber ?? null,
|
||||
inv.TxnDate ?? null, inv.DueDate ?? null,
|
||||
inv.CustomerRef?.value ?? null, inv.CustomerRef?.name ?? null,
|
||||
inv.BillEmail?.Address ?? null,
|
||||
inv.TotalAmt ?? null, inv.Balance ?? null,
|
||||
status, inv.CurrencyRef?.value ?? 'USD',
|
||||
JSON.stringify(inv.Line ?? []),
|
||||
JSON.stringify(inv.LinkedTxn ?? []),
|
||||
inv.SyncToken,
|
||||
inv.MetaData?.CreateTime ?? null,
|
||||
inv.MetaData?.LastUpdatedTime ?? null,
|
||||
]
|
||||
);
|
||||
upserted++;
|
||||
}
|
||||
|
||||
return { entity: 'invoices', success: true, recordsUpserted: upserted, duration: Date.now() - start };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[QboSync] Invoice sync failed: ${msg}`);
|
||||
return { entity: 'invoices', success: false, recordsUpserted: 0, duration: Date.now() - start, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
private async syncPayments(realmId: string, updatedSince?: Date): Promise<QboEntitySyncResult> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const payments = await this.client.getPayments(updatedSince);
|
||||
console.log(`[QboSync] Fetched ${payments.length} payments`);
|
||||
|
||||
let upserted = 0;
|
||||
for (const pay of payments) {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO qbo_payments
|
||||
(id, realm_id, txn_date, customer_ref_id, customer_ref_name, total_amt, unapplied_amt,
|
||||
currency_code, payment_method_ref, deposit_account_ref, linked_txns,
|
||||
sync_token, qbo_created_at, qbo_updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
txn_date = EXCLUDED.txn_date,
|
||||
customer_ref_id = EXCLUDED.customer_ref_id,
|
||||
customer_ref_name = EXCLUDED.customer_ref_name,
|
||||
total_amt = EXCLUDED.total_amt,
|
||||
unapplied_amt = EXCLUDED.unapplied_amt,
|
||||
currency_code = EXCLUDED.currency_code,
|
||||
payment_method_ref = EXCLUDED.payment_method_ref,
|
||||
deposit_account_ref = EXCLUDED.deposit_account_ref,
|
||||
linked_txns = EXCLUDED.linked_txns,
|
||||
sync_token = EXCLUDED.sync_token,
|
||||
qbo_updated_at = EXCLUDED.qbo_updated_at,
|
||||
synced_at = NOW()`,
|
||||
[
|
||||
pay.Id, realmId, pay.TxnDate ?? null,
|
||||
pay.CustomerRef?.value ?? null, pay.CustomerRef?.name ?? null,
|
||||
pay.TotalAmt ?? null, pay.UnappliedAmt ?? null,
|
||||
pay.CurrencyRef?.value ?? 'USD',
|
||||
pay.PaymentMethodRef?.name ?? null,
|
||||
pay.DepositToAccountRef?.name ?? null,
|
||||
JSON.stringify(pay.Line ?? []),
|
||||
pay.SyncToken,
|
||||
pay.MetaData?.CreateTime ?? null,
|
||||
pay.MetaData?.LastUpdatedTime ?? null,
|
||||
]
|
||||
);
|
||||
upserted++;
|
||||
}
|
||||
|
||||
return { entity: 'payments', success: true, recordsUpserted: upserted, duration: Date.now() - start };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[QboSync] Payment sync failed: ${msg}`);
|
||||
return { entity: 'payments', success: false, recordsUpserted: 0, duration: Date.now() - start, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
private async syncDeposits(realmId: string, updatedSince?: Date): Promise<QboEntitySyncResult> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const deposits = await this.client.getDeposits(updatedSince);
|
||||
console.log(`[QboSync] Fetched ${deposits.length} deposits`);
|
||||
|
||||
let upserted = 0;
|
||||
for (const dep of deposits) {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO qbo_deposits
|
||||
(id, realm_id, txn_date, deposit_to_account_ref_id, deposit_to_account_ref_name,
|
||||
total_amt, line_items, sync_token, qbo_created_at, qbo_updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
txn_date = EXCLUDED.txn_date,
|
||||
deposit_to_account_ref_id = EXCLUDED.deposit_to_account_ref_id,
|
||||
deposit_to_account_ref_name = EXCLUDED.deposit_to_account_ref_name,
|
||||
total_amt = EXCLUDED.total_amt,
|
||||
line_items = EXCLUDED.line_items,
|
||||
sync_token = EXCLUDED.sync_token,
|
||||
qbo_updated_at = EXCLUDED.qbo_updated_at,
|
||||
synced_at = NOW()`,
|
||||
[
|
||||
dep.Id, realmId, dep.TxnDate ?? null,
|
||||
dep.DepositToAccountRef?.value ?? null,
|
||||
dep.DepositToAccountRef?.name ?? null,
|
||||
dep.TotalAmt ?? null,
|
||||
JSON.stringify(dep.Line ?? []),
|
||||
dep.SyncToken,
|
||||
dep.MetaData?.CreateTime ?? null,
|
||||
dep.MetaData?.LastUpdatedTime ?? null,
|
||||
]
|
||||
);
|
||||
upserted++;
|
||||
}
|
||||
|
||||
return { entity: 'deposits', success: true, recordsUpserted: upserted, duration: Date.now() - start };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[QboSync] Deposit sync failed: ${msg}`);
|
||||
return { entity: 'deposits', success: false, recordsUpserted: 0, duration: Date.now() - start, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
private async syncPurchases(realmId: string, updatedSince?: Date): Promise<QboEntitySyncResult> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const purchases = await this.client.getPurchases(updatedSince);
|
||||
console.log(`[QboSync] Fetched ${purchases.length} purchases`);
|
||||
|
||||
let upserted = 0;
|
||||
for (const p of purchases) {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO qbo_transactions
|
||||
(id, txn_type, realm_id, txn_date, doc_number, entity_ref_id, entity_ref_name,
|
||||
entity_type, account_ref_id, account_ref_name, total_amt, currency_code,
|
||||
private_note, line_items, sync_token, qbo_created_at, qbo_updated_at, synced_at)
|
||||
VALUES ($1,'Purchase',$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,NOW())
|
||||
ON CONFLICT (id, txn_type) DO UPDATE SET
|
||||
txn_date = EXCLUDED.txn_date,
|
||||
doc_number = EXCLUDED.doc_number,
|
||||
entity_ref_id = EXCLUDED.entity_ref_id,
|
||||
entity_ref_name = EXCLUDED.entity_ref_name,
|
||||
entity_type = EXCLUDED.entity_type,
|
||||
account_ref_id = EXCLUDED.account_ref_id,
|
||||
account_ref_name = EXCLUDED.account_ref_name,
|
||||
total_amt = EXCLUDED.total_amt,
|
||||
currency_code = EXCLUDED.currency_code,
|
||||
private_note = EXCLUDED.private_note,
|
||||
line_items = EXCLUDED.line_items,
|
||||
sync_token = EXCLUDED.sync_token,
|
||||
qbo_updated_at = EXCLUDED.qbo_updated_at,
|
||||
synced_at = NOW()`,
|
||||
[
|
||||
p.Id, realmId, p.TxnDate ?? null, p.DocNumber ?? null,
|
||||
p.EntityRef?.value ?? null, p.EntityRef?.name ?? null,
|
||||
p.EntityRef?.type ?? null,
|
||||
p.AccountRef?.value ?? null, p.AccountRef?.name ?? null,
|
||||
p.TotalAmt ?? null, p.CurrencyRef?.value ?? 'USD',
|
||||
p.PrivateNote ?? null,
|
||||
JSON.stringify(p.Line ?? []),
|
||||
p.SyncToken,
|
||||
p.MetaData?.CreateTime ?? null,
|
||||
p.MetaData?.LastUpdatedTime ?? null,
|
||||
]
|
||||
);
|
||||
upserted++;
|
||||
}
|
||||
|
||||
return { entity: 'purchases', success: true, recordsUpserted: upserted, duration: Date.now() - start };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[QboSync] Purchase sync failed: ${msg}`);
|
||||
return { entity: 'purchases', success: false, recordsUpserted: 0, duration: Date.now() - start, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
private async syncJournalEntries(realmId: string, updatedSince?: Date): Promise<QboEntitySyncResult> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const entries = await this.client.getJournalEntries(updatedSince);
|
||||
console.log(`[QboSync] Fetched ${entries.length} journal entries`);
|
||||
|
||||
let upserted = 0;
|
||||
for (const je of entries) {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO qbo_transactions
|
||||
(id, txn_type, realm_id, txn_date, doc_number, total_amt, currency_code,
|
||||
private_note, line_items, sync_token, qbo_created_at, qbo_updated_at, synced_at)
|
||||
VALUES ($1,'JournalEntry',$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NOW())
|
||||
ON CONFLICT (id, txn_type) DO UPDATE SET
|
||||
txn_date = EXCLUDED.txn_date,
|
||||
doc_number = EXCLUDED.doc_number,
|
||||
total_amt = EXCLUDED.total_amt,
|
||||
currency_code = EXCLUDED.currency_code,
|
||||
private_note = EXCLUDED.private_note,
|
||||
line_items = EXCLUDED.line_items,
|
||||
sync_token = EXCLUDED.sync_token,
|
||||
qbo_updated_at = EXCLUDED.qbo_updated_at,
|
||||
synced_at = NOW()`,
|
||||
[
|
||||
je.Id, realmId, je.TxnDate ?? null, je.DocNumber ?? null,
|
||||
je.TotalAmt ?? null, je.CurrencyRef?.value ?? 'USD',
|
||||
je.PrivateNote ?? null,
|
||||
JSON.stringify(je.Line ?? []),
|
||||
je.SyncToken,
|
||||
je.MetaData?.CreateTime ?? null,
|
||||
je.MetaData?.LastUpdatedTime ?? null,
|
||||
]
|
||||
);
|
||||
upserted++;
|
||||
}
|
||||
|
||||
return { entity: 'journal_entries', success: true, recordsUpserted: upserted, duration: Date.now() - start };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[QboSync] Journal entry sync failed: ${msg}`);
|
||||
return { entity: 'journal_entries', success: false, recordsUpserted: 0, duration: Date.now() - start, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
private async syncReports(realmId: string, monthsBack: number): Promise<QboEntitySyncResult> {
|
||||
const start = Date.now();
|
||||
let upserted = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
// Fetch company accounting preference once
|
||||
const { reportBasis } = await this.client.getPreferences();
|
||||
console.log(`[QboSync] Report sync using accounting method: ${reportBasis}`);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
for (let i = 0; i < monthsBack; i++) {
|
||||
const periodEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 0);
|
||||
const periodStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
|
||||
const startStr = this.formatDate(periodStart);
|
||||
const endStr = this.formatDate(periodEnd);
|
||||
|
||||
// P&L
|
||||
try {
|
||||
const pl = await this.client.getProfitAndLoss(startStr, endStr, reportBasis);
|
||||
if (pl?.Header?.NoReportData === 'true') {
|
||||
console.log(`[QboSync] P&L ${startStr}: no data, skipping`);
|
||||
skipped++;
|
||||
} else {
|
||||
await this.upsertReport(realmId, 'ProfitAndLoss', periodStart, periodEnd, pl);
|
||||
console.log(`[QboSync] P&L ${startStr}: upserted`);
|
||||
upserted++;
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
errors.push(`P&L ${startStr}: ${msg}`);
|
||||
}
|
||||
|
||||
// Balance Sheet
|
||||
try {
|
||||
const bs = await this.client.getBalanceSheet(startStr, endStr, reportBasis);
|
||||
if (bs?.Header?.NoReportData === 'true') {
|
||||
console.log(`[QboSync] BalanceSheet ${startStr}: no data, skipping`);
|
||||
skipped++;
|
||||
} else {
|
||||
await this.upsertReport(realmId, 'BalanceSheet', periodStart, periodEnd, bs);
|
||||
console.log(`[QboSync] BalanceSheet ${startStr}: upserted`);
|
||||
upserted++;
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
errors.push(`BalanceSheet ${startStr}: ${msg}`);
|
||||
}
|
||||
|
||||
// Cash Flow
|
||||
try {
|
||||
const cf = await this.client.getCashFlow(startStr, endStr);
|
||||
if (cf?.Header?.NoReportData === 'true') {
|
||||
console.log(`[QboSync] CashFlow ${startStr}: no data, skipping`);
|
||||
skipped++;
|
||||
} else {
|
||||
await this.upsertReport(realmId, 'CashFlow', periodStart, periodEnd, cf);
|
||||
console.log(`[QboSync] CashFlow ${startStr}: upserted`);
|
||||
upserted++;
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
errors.push(`CashFlow ${startStr}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[QboSync] Reports: ${upserted} upserted, ${skipped} skipped, ${errors.length} errors`);
|
||||
if (errors.length > 0) {
|
||||
console.warn(`[QboSync] Report errors: ${errors.join('; ')}`);
|
||||
}
|
||||
|
||||
return {
|
||||
entity: 'reports',
|
||||
success: errors.length === 0,
|
||||
recordsUpserted: upserted,
|
||||
duration: Date.now() - start,
|
||||
error: errors.length > 0 ? errors.join('; ') : undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[QboSync] Report sync failed: ${msg}`);
|
||||
return { entity: 'reports', success: false, recordsUpserted: upserted, duration: Date.now() - start, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertReport(
|
||||
realmId: string,
|
||||
reportType: string,
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
data: QboReport
|
||||
): Promise<void> {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO qbo_reports (realm_id, report_type, period_start, period_end, report_data, synced_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||
ON CONFLICT (realm_id, report_type, period_start, period_end) DO UPDATE SET
|
||||
report_data = EXCLUDED.report_data,
|
||||
synced_at = NOW()`,
|
||||
[realmId, reportType, periodStart, periodEnd, JSON.stringify(data)]
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
private deriveInvoiceStatus(inv: QboInvoice): string {
|
||||
if ((inv.Balance ?? 0) === 0 && (inv.TotalAmt ?? 0) > 0) return 'Paid';
|
||||
if ((inv.Balance ?? 0) > 0 && inv.DueDate && new Date(inv.DueDate) < new Date()) return 'Overdue';
|
||||
if ((inv.Balance ?? 0) > 0) return 'Open';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
private formatDate(d: Date): string {
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
private async getLastSyncTime(): Promise<Date | undefined> {
|
||||
try {
|
||||
const result = await postgresClient.query<{ synced_at: Date }>(
|
||||
`SELECT MAX(synced_at) as synced_at FROM qbo_invoices`
|
||||
);
|
||||
return result.rows[0]?.synced_at ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async recordSyncHistory(
|
||||
_syncId: string,
|
||||
syncType: string,
|
||||
triggeredBy: string,
|
||||
startedAt: Date,
|
||||
status: string,
|
||||
entities: QboEntitySyncResult[],
|
||||
errorMessage?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const totalRecords = entities.reduce((sum, e) => sum + e.recordsUpserted, 0);
|
||||
const normalizedType = syncType === 'incremental' ? 'incremental' : 'full';
|
||||
await postgresClient.query(
|
||||
`INSERT INTO sync_history
|
||||
(entity_type, sync_type, status, records_added, started_at, completed_at, triggered_by, error_message)
|
||||
VALUES ('qbo', $1, $2, $3, $4, NOW(), $5, $6)`,
|
||||
[normalizedType, status, totalRecords, startedAt, triggeredBy, errorMessage ?? null]
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[QboSync] Failed to record sync history:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: QboSyncService | null = null;
|
||||
|
||||
export function getQboSyncService(): QboSyncService {
|
||||
if (!_instance) {
|
||||
_instance = new QboSyncService();
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
|
@ -15,13 +15,14 @@ import { isMsgraphConfigured } from './msgraph-factory';
|
|||
import { ZoomSyncService } from './zoom-sync-service';
|
||||
import { isZoomConfigured } from './zoom-factory';
|
||||
import { MorningSummaryService } from './morning-summary-service';
|
||||
import { TicketDigestService } from './ticket-digest-service';
|
||||
|
||||
export interface ScheduleConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
cron_expression: string;
|
||||
sync_type: 'incremental' | 'full' | 'veeam-incremental' | 'veeam-full' | 'veeam-rpo-check' | 'contract-services' | 'engagement-daily' | 'zoom-daily' | 'morning-summary';
|
||||
sync_type: 'incremental' | 'full' | 'veeam-incremental' | 'veeam-full' | 'veeam-rpo-check' | 'contract-services' | 'engagement-daily' | 'zoom-daily' | 'morning-summary' | 'ticket-digest-daily' | 'ticket-digest-weekly' | 'ticket-digest-monthly';
|
||||
years_back?: number;
|
||||
is_enabled: boolean;
|
||||
last_run?: Date;
|
||||
|
|
@ -78,6 +79,14 @@ class SyncScheduler {
|
|||
return this._morningSummaryService;
|
||||
}
|
||||
|
||||
private _ticketDigestService?: TicketDigestService;
|
||||
private getTicketDigestService(): TicketDigestService {
|
||||
if (!this._ticketDigestService) {
|
||||
this._ticketDigestService = new TicketDigestService();
|
||||
}
|
||||
return this._ticketDigestService;
|
||||
}
|
||||
|
||||
private getZoomSyncService(): ZoomSyncService {
|
||||
if (!this._zoomSyncService) {
|
||||
this._zoomSyncService = new ZoomSyncService();
|
||||
|
|
@ -242,6 +251,30 @@ class SyncScheduler {
|
|||
sync_type: 'morning-summary',
|
||||
is_enabled: false,
|
||||
},
|
||||
{
|
||||
id: 'ticket-digest-daily',
|
||||
name: 'Daily Ticket Digest',
|
||||
description: 'LLM-analyzed ticket digest for the previous day, delivered to Teams at 7 AM Mon–Fri',
|
||||
cron_expression: '0 7 * * 1-5',
|
||||
sync_type: 'ticket-digest-daily',
|
||||
is_enabled: false,
|
||||
},
|
||||
{
|
||||
id: 'ticket-digest-weekly',
|
||||
name: 'Weekly Ticket Digest',
|
||||
description: 'LLM-analyzed ticket digest for the previous week, delivered to Teams at 7 AM Monday',
|
||||
cron_expression: '0 7 * * 1',
|
||||
sync_type: 'ticket-digest-weekly',
|
||||
is_enabled: false,
|
||||
},
|
||||
{
|
||||
id: 'ticket-digest-monthly',
|
||||
name: 'Monthly Ticket Digest',
|
||||
description: 'LLM-analyzed ticket digest for the previous month, delivered to Teams at 7 AM on the 1st',
|
||||
cron_expression: '0 7 1 * *',
|
||||
sync_type: 'ticket-digest-monthly',
|
||||
is_enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
for (const schedule of defaultSchedules) {
|
||||
|
|
@ -367,6 +400,12 @@ class SyncScheduler {
|
|||
}
|
||||
} else if (config.sync_type === 'morning-summary') {
|
||||
await this.getMorningSummaryService().run();
|
||||
} else if (config.sync_type === 'ticket-digest-daily') {
|
||||
await this.getTicketDigestService().run('daily');
|
||||
} else if (config.sync_type === 'ticket-digest-weekly') {
|
||||
await this.getTicketDigestService().run('weekly');
|
||||
} else if (config.sync_type === 'ticket-digest-monthly') {
|
||||
await this.getTicketDigestService().run('monthly');
|
||||
} else if (config.sync_type === 'incremental') {
|
||||
await this.syncService.incrementalSync('scheduled');
|
||||
} else {
|
||||
|
|
|
|||
782
lib/services/ticket-digest-service.ts
Normal file
782
lib/services/ticket-digest-service.ts
Normal file
|
|
@ -0,0 +1,782 @@
|
|||
/**
|
||||
* Ticket Digest Report Service
|
||||
* Aggregates ticket data for daily/weekly/monthly periods, sends it to an LLM
|
||||
* for noise analysis and insights, then delivers an Adaptive Card to Teams.
|
||||
*/
|
||||
|
||||
import { postgresClient } from './postgres-client';
|
||||
|
||||
export type DigestPeriod = 'daily' | 'weekly' | 'monthly';
|
||||
|
||||
export interface DigestConfig {
|
||||
daily_enabled: boolean;
|
||||
weekly_enabled: boolean;
|
||||
monthly_enabled: boolean;
|
||||
daily_cron: string;
|
||||
weekly_cron: string;
|
||||
monthly_cron: string;
|
||||
llm_provider: string;
|
||||
llm_model: string;
|
||||
include_noise_analysis: boolean;
|
||||
include_sla_analysis: boolean;
|
||||
include_resource_analysis: boolean;
|
||||
include_client_analysis: boolean;
|
||||
include_recommendations: boolean;
|
||||
channel_ids: number[];
|
||||
}
|
||||
|
||||
export interface NotificationChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
channel_type: 'teams' | 'telegram' | 'ntfy' | 'webhook';
|
||||
config: Record<string, any>;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface DeliveryResult {
|
||||
channelId: number;
|
||||
label: string;
|
||||
success: boolean;
|
||||
httpStatus?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TicketDigestStats {
|
||||
period: { type: DigestPeriod; start: string; end: string; label: string };
|
||||
overview: {
|
||||
total_created: number;
|
||||
total_resolved: number;
|
||||
total_open_end: number;
|
||||
avg_resolution_hours: number | null;
|
||||
avg_first_response_hours: number | null;
|
||||
total_hours_worked: number;
|
||||
};
|
||||
by_source: Array<{ source: number | null; source_label: string; count: number; pct: number }>;
|
||||
by_queue: Array<{ queue_id: number | null; queue_label: string; count: number; resolved: number; avg_resolve_hrs: number | null }>;
|
||||
by_priority: Array<{ priority: number | null; priority_label: string; count: number }>;
|
||||
by_issue_type: Array<{ issue_type: number | null; issue_label: string; count: number }>;
|
||||
top_clients: Array<{ company_id: number; company_name: string; ticket_count: number; hours_worked: number }>;
|
||||
top_resources: Array<{ resource_id: number; resource_name: string; tickets_touched: number; hours_worked: number }>;
|
||||
noise_candidates: Array<{ title: string; count: number; source: number | null; source_label: string; avg_resolve_min: number | null; sample_id: number }>;
|
||||
monitor_tickets: { total: number; auto_resolved: number; pct_of_all: number };
|
||||
sla: { first_response_met: number; first_response_missed: number; resolution_met: number; resolution_missed: number };
|
||||
comparison: {
|
||||
prev_total_created: number;
|
||||
prev_total_resolved: number;
|
||||
prev_avg_resolution_hours: number | null;
|
||||
prev_total_hours_worked: number;
|
||||
created_delta_pct: number | null;
|
||||
resolved_delta_pct: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<number, string> = {
|
||||
'-2': 'RMM Alert (Resolved)',
|
||||
'-1': 'RMM Alert',
|
||||
1: 'Phone',
|
||||
2: 'Chat/Portal',
|
||||
4: 'Email',
|
||||
6: 'Internal',
|
||||
8: 'Monitoring Alert',
|
||||
17: 'Auto-ticket',
|
||||
21: 'Voice',
|
||||
27: 'Feedback',
|
||||
30: 'Web Portal',
|
||||
35: 'Phish Alert',
|
||||
38: 'Teams',
|
||||
39: 'API',
|
||||
};
|
||||
|
||||
const PRIORITY_LABELS: Record<number, string> = {
|
||||
1: 'Critical',
|
||||
2: 'High',
|
||||
3: 'Medium',
|
||||
4: 'Low',
|
||||
6: 'Informational',
|
||||
};
|
||||
|
||||
function getPeriodBounds(period: DigestPeriod, now: Date): { start: Date; end: Date; prevStart: Date; prevEnd: Date; label: string } {
|
||||
const end = new Date(now);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
if (period === 'daily') {
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 1);
|
||||
const prevEnd = new Date(start);
|
||||
const prevStart = new Date(prevEnd);
|
||||
prevStart.setDate(prevStart.getDate() - 1);
|
||||
return { start, end, prevStart, prevEnd, label: start.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' }) };
|
||||
}
|
||||
if (period === 'weekly') {
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 7);
|
||||
const prevEnd = new Date(start);
|
||||
const prevStart = new Date(prevEnd);
|
||||
prevStart.setDate(prevStart.getDate() - 7);
|
||||
const label = `${start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} – ${new Date(end.getTime() - 86400000).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`;
|
||||
return { start, end, prevStart, prevEnd, label };
|
||||
}
|
||||
// monthly
|
||||
const start = new Date(end.getFullYear(), end.getMonth() - 1, 1);
|
||||
const monthEnd = new Date(end.getFullYear(), end.getMonth(), 1);
|
||||
const prevStart = new Date(start.getFullYear(), start.getMonth() - 1, 1);
|
||||
const prevEnd = new Date(start);
|
||||
const label = start.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||
return { start, end: monthEnd, prevStart, prevEnd, label };
|
||||
}
|
||||
|
||||
export class TicketDigestService {
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Config & Webhook CRUD
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
async getConfig(): Promise<DigestConfig> {
|
||||
const r = await postgresClient.query('SELECT * FROM ticket_digest_config WHERE id = 1');
|
||||
return r.rows[0] as DigestConfig;
|
||||
}
|
||||
|
||||
async updateConfig(updates: Partial<DigestConfig>): Promise<DigestConfig> {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let idx = 1;
|
||||
for (const [key, val] of Object.entries(updates)) {
|
||||
fields.push(`${key} = $${idx++}`);
|
||||
values.push(val);
|
||||
}
|
||||
if (fields.length === 0) return this.getConfig();
|
||||
fields.push('updated_at = NOW()');
|
||||
values.push(1);
|
||||
const r = await postgresClient.query(
|
||||
`UPDATE ticket_digest_config SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
values
|
||||
);
|
||||
return r.rows[0] as DigestConfig;
|
||||
}
|
||||
|
||||
async getAvailableChannels(): Promise<NotificationChannel[]> {
|
||||
const r = await postgresClient.query(
|
||||
'SELECT id, name, channel_type, config, is_active FROM notification_channels ORDER BY name'
|
||||
);
|
||||
return r.rows as NotificationChannel[];
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Data Aggregation
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
async aggregate(period: DigestPeriod, now?: Date): Promise<TicketDigestStats> {
|
||||
const { start, end, prevStart, prevEnd, label } = getPeriodBounds(period, now ?? new Date());
|
||||
const s = start.toISOString();
|
||||
const e = end.toISOString();
|
||||
const ps = prevStart.toISOString();
|
||||
const pe = prevEnd.toISOString();
|
||||
|
||||
const [
|
||||
overviewR,
|
||||
bySourceR,
|
||||
byQueueR,
|
||||
byPriorityR,
|
||||
byIssueTypeR,
|
||||
topClientsR,
|
||||
topResourcesR,
|
||||
noiseR,
|
||||
monitorR,
|
||||
slaR,
|
||||
prevOverviewR,
|
||||
] = await Promise.all([
|
||||
// Overview
|
||||
postgresClient.query(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE t.create_date >= $1 AND t.create_date < $2) as total_created,
|
||||
COUNT(*) FILTER (WHERE t.resolved_date_time >= $1 AND t.resolved_date_time < $2) as total_resolved,
|
||||
COUNT(*) FILTER (WHERE t.create_date < $2 AND (t.resolved_date_time IS NULL OR t.resolved_date_time >= $2) AND t.status NOT IN (5)) as total_open_end,
|
||||
ROUND(AVG(EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date))/3600) FILTER (WHERE t.resolved_date_time >= $1 AND t.resolved_date_time < $2)::numeric, 1) as avg_resolution_hours,
|
||||
ROUND(AVG(EXTRACT(EPOCH FROM (t.first_response_date_time - t.create_date))/3600) FILTER (WHERE t.first_response_date_time IS NOT NULL AND t.create_date >= $1 AND t.create_date < $2)::numeric, 1) as avg_first_response_hours,
|
||||
COALESCE(SUM(te.hours_worked), 0) as total_hours_worked
|
||||
FROM tickets t
|
||||
LEFT JOIN time_entries te ON te.ticket_id = t.id AND (te.is_deleted = false) AND te.entry_date >= $1::date AND te.entry_date < $2::date
|
||||
WHERE t.is_deleted = false AND (t.create_date >= $1 AND t.create_date < $2 OR t.resolved_date_time >= $1 AND t.resolved_date_time < $2)
|
||||
`, [s, e]),
|
||||
|
||||
// By source
|
||||
postgresClient.query(`
|
||||
SELECT t.source, COUNT(*) as count
|
||||
FROM tickets t WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2
|
||||
GROUP BY t.source ORDER BY count DESC
|
||||
`, [s, e]),
|
||||
|
||||
// By queue
|
||||
postgresClient.query(`
|
||||
SELECT t.queue_id, q.label as queue_label, COUNT(*) as count,
|
||||
COUNT(*) FILTER (WHERE t.resolved_date_time >= $1 AND t.resolved_date_time < $2) as resolved,
|
||||
ROUND(AVG(EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date))/3600) FILTER (WHERE t.resolved_date_time IS NOT NULL)::numeric, 1) as avg_resolve_hrs
|
||||
FROM tickets t
|
||||
LEFT JOIN queues q ON q.value = t.queue_id
|
||||
WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2
|
||||
GROUP BY t.queue_id, q.label ORDER BY count DESC LIMIT 15
|
||||
`, [s, e]),
|
||||
|
||||
// By priority
|
||||
postgresClient.query(`
|
||||
SELECT t.priority, COUNT(*) as count
|
||||
FROM tickets t WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2
|
||||
GROUP BY t.priority ORDER BY t.priority
|
||||
`, [s, e]),
|
||||
|
||||
// By issue type
|
||||
postgresClient.query(`
|
||||
SELECT t.issue_type, it.label as issue_label, COUNT(*) as count
|
||||
FROM tickets t
|
||||
LEFT JOIN issue_types it ON it.value = t.issue_type
|
||||
WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2
|
||||
GROUP BY t.issue_type, it.label ORDER BY count DESC LIMIT 15
|
||||
`, [s, e]),
|
||||
|
||||
// Top clients
|
||||
postgresClient.query(`
|
||||
SELECT t.company_id, c.company_name, COUNT(DISTINCT t.id) as ticket_count,
|
||||
COALESCE(SUM(te.hours_worked), 0)::float as hours_worked
|
||||
FROM tickets t
|
||||
JOIN companies c ON c.id = t.company_id
|
||||
LEFT JOIN time_entries te ON te.ticket_id = t.id AND (te.is_deleted = false) AND te.entry_date >= $1::date AND te.entry_date < $2::date
|
||||
WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2
|
||||
GROUP BY t.company_id, c.company_name ORDER BY ticket_count DESC LIMIT 10
|
||||
`, [s, e]),
|
||||
|
||||
// Top resources
|
||||
postgresClient.query(`
|
||||
SELECT te.resource_id, r.first_name || ' ' || r.last_name as resource_name,
|
||||
COUNT(DISTINCT te.ticket_id) as tickets_touched,
|
||||
COALESCE(SUM(te.hours_worked), 0)::float as hours_worked
|
||||
FROM time_entries te
|
||||
JOIN resources r ON r.id = te.resource_id
|
||||
WHERE te.is_deleted = false AND te.entry_date >= $1::date AND te.entry_date < $2::date AND te.ticket_id IS NOT NULL
|
||||
GROUP BY te.resource_id, r.first_name, r.last_name ORDER BY hours_worked DESC LIMIT 10
|
||||
`, [s, e]),
|
||||
|
||||
// Noise candidates — repeated titles (grouping by first 60 chars of title)
|
||||
postgresClient.query(`
|
||||
SELECT LEFT(t.title, 60) as title, COUNT(*) as count, t.source,
|
||||
ROUND(AVG(EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date))/60) FILTER (WHERE t.resolved_date_time IS NOT NULL)::numeric, 0) as avg_resolve_min,
|
||||
MIN(t.id) as sample_id
|
||||
FROM tickets t
|
||||
WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2
|
||||
GROUP BY LEFT(t.title, 60), t.source
|
||||
HAVING COUNT(*) >= 3
|
||||
ORDER BY count DESC LIMIT 20
|
||||
`, [s, e]),
|
||||
|
||||
// Monitor-generated tickets
|
||||
postgresClient.query(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE t.resolved_date_time IS NOT NULL AND EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date)) < 1800) as auto_resolved
|
||||
FROM tickets t
|
||||
WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2 AND t.monitor_id IS NOT NULL
|
||||
`, [s, e]),
|
||||
|
||||
// SLA (using 1hr first response / 24hr resolution as baseline)
|
||||
postgresClient.query(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE t.first_response_date_time IS NOT NULL AND EXTRACT(EPOCH FROM (t.first_response_date_time - t.create_date))/3600 <= 1) as fr_met,
|
||||
COUNT(*) FILTER (WHERE t.first_response_date_time IS NOT NULL AND EXTRACT(EPOCH FROM (t.first_response_date_time - t.create_date))/3600 > 1) as fr_missed,
|
||||
COUNT(*) FILTER (WHERE t.resolved_date_time IS NOT NULL AND EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date))/3600 <= 24) as res_met,
|
||||
COUNT(*) FILTER (WHERE t.resolved_date_time IS NOT NULL AND EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date))/3600 > 24) as res_missed
|
||||
FROM tickets t
|
||||
WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2
|
||||
`, [s, e]),
|
||||
|
||||
// Previous period overview for comparison
|
||||
postgresClient.query(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE t.create_date >= $1 AND t.create_date < $2) as total_created,
|
||||
COUNT(*) FILTER (WHERE t.resolved_date_time >= $1 AND t.resolved_date_time < $2) as total_resolved,
|
||||
ROUND(AVG(EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date))/3600) FILTER (WHERE t.resolved_date_time >= $1 AND t.resolved_date_time < $2)::numeric, 1) as avg_resolution_hours,
|
||||
COALESCE(SUM(te.hours_worked), 0) as total_hours_worked
|
||||
FROM tickets t
|
||||
LEFT JOIN time_entries te ON te.ticket_id = t.id AND (te.is_deleted = false) AND te.entry_date >= $1::date AND te.entry_date < $2::date
|
||||
WHERE t.is_deleted = false AND (t.create_date >= $1 AND t.create_date < $2 OR t.resolved_date_time >= $1 AND t.resolved_date_time < $2)
|
||||
`, [ps, pe]),
|
||||
]);
|
||||
|
||||
const ov = overviewR.rows[0];
|
||||
const prevOv = prevOverviewR.rows[0];
|
||||
const monRow = monitorR.rows[0];
|
||||
const slaRow = slaR.rows[0];
|
||||
const totalCreated = parseInt(ov.total_created) || 0;
|
||||
const prevCreated = parseInt(prevOv.total_created) || 0;
|
||||
const prevResolved = parseInt(prevOv.total_resolved) || 0;
|
||||
|
||||
const deltaPct = (cur: number, prev: number): number | null => prev === 0 ? null : Math.round(((cur - prev) / prev) * 100);
|
||||
|
||||
return {
|
||||
period: { type: period, start: s, end: e, label },
|
||||
overview: {
|
||||
total_created: totalCreated,
|
||||
total_resolved: parseInt(ov.total_resolved) || 0,
|
||||
total_open_end: parseInt(ov.total_open_end) || 0,
|
||||
avg_resolution_hours: ov.avg_resolution_hours ? parseFloat(ov.avg_resolution_hours) : null,
|
||||
avg_first_response_hours: ov.avg_first_response_hours ? parseFloat(ov.avg_first_response_hours) : null,
|
||||
total_hours_worked: parseFloat(ov.total_hours_worked) || 0,
|
||||
},
|
||||
by_source: bySourceR.rows.map(r => ({
|
||||
source: r.source,
|
||||
source_label: SOURCE_LABELS[r.source] ?? `Source ${r.source ?? 'Unknown'}`,
|
||||
count: parseInt(r.count),
|
||||
pct: totalCreated > 0 ? Math.round((parseInt(r.count) / totalCreated) * 100) : 0,
|
||||
})),
|
||||
by_queue: byQueueR.rows.map(r => ({
|
||||
queue_id: r.queue_id,
|
||||
queue_label: r.queue_label || `Queue ${r.queue_id}`,
|
||||
count: parseInt(r.count),
|
||||
resolved: parseInt(r.resolved) || 0,
|
||||
avg_resolve_hrs: r.avg_resolve_hrs ? parseFloat(r.avg_resolve_hrs) : null,
|
||||
})),
|
||||
by_priority: byPriorityR.rows.map(r => ({
|
||||
priority: r.priority,
|
||||
priority_label: PRIORITY_LABELS[r.priority] ?? `Priority ${r.priority ?? 'Unknown'}`,
|
||||
count: parseInt(r.count),
|
||||
})),
|
||||
by_issue_type: byIssueTypeR.rows.map(r => ({
|
||||
issue_type: r.issue_type,
|
||||
issue_label: r.issue_label || `Type ${r.issue_type}`,
|
||||
count: parseInt(r.count),
|
||||
})),
|
||||
top_clients: topClientsR.rows.map(r => ({
|
||||
company_id: r.company_id,
|
||||
company_name: r.company_name,
|
||||
ticket_count: parseInt(r.ticket_count),
|
||||
hours_worked: parseFloat(r.hours_worked) || 0,
|
||||
})),
|
||||
top_resources: topResourcesR.rows.map(r => ({
|
||||
resource_id: r.resource_id,
|
||||
resource_name: r.resource_name,
|
||||
tickets_touched: parseInt(r.tickets_touched),
|
||||
hours_worked: parseFloat(r.hours_worked) || 0,
|
||||
})),
|
||||
noise_candidates: noiseR.rows.map(r => ({
|
||||
title: r.title,
|
||||
count: parseInt(r.count),
|
||||
source: r.source,
|
||||
source_label: SOURCE_LABELS[r.source] ?? `Source ${r.source}`,
|
||||
avg_resolve_min: r.avg_resolve_min ? parseFloat(r.avg_resolve_min) : null,
|
||||
sample_id: parseInt(r.sample_id),
|
||||
})),
|
||||
monitor_tickets: {
|
||||
total: parseInt(monRow.total) || 0,
|
||||
auto_resolved: parseInt(monRow.auto_resolved) || 0,
|
||||
pct_of_all: totalCreated > 0 ? Math.round((parseInt(monRow.total) / totalCreated) * 100) : 0,
|
||||
},
|
||||
sla: {
|
||||
first_response_met: parseInt(slaRow.fr_met) || 0,
|
||||
first_response_missed: parseInt(slaRow.fr_missed) || 0,
|
||||
resolution_met: parseInt(slaRow.res_met) || 0,
|
||||
resolution_missed: parseInt(slaRow.res_missed) || 0,
|
||||
},
|
||||
comparison: {
|
||||
prev_total_created: prevCreated,
|
||||
prev_total_resolved: prevResolved,
|
||||
prev_avg_resolution_hours: prevOv.avg_resolution_hours ? parseFloat(prevOv.avg_resolution_hours) : null,
|
||||
prev_total_hours_worked: parseFloat(prevOv.total_hours_worked) || 0,
|
||||
created_delta_pct: deltaPct(totalCreated, prevCreated),
|
||||
resolved_delta_pct: deltaPct(parseInt(ov.total_resolved) || 0, prevResolved),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// LLM Analysis
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
async analyzeWithLLM(stats: TicketDigestStats, config: DigestConfig): Promise<{ analysis: string; tokensUsed: number }> {
|
||||
const apiKey = config.llm_provider === 'anthropic'
|
||||
? process.env.ANTHROPIC_API_KEY || ''
|
||||
: process.env.OPENAI_API_KEY || '';
|
||||
|
||||
if (!apiKey) {
|
||||
// Also check workflow_settings table
|
||||
const keyRow = await postgresClient.query(
|
||||
`SELECT value FROM workflow_settings WHERE key = $1`,
|
||||
[config.llm_provider === 'anthropic' ? 'anthropic_api_key' : 'openai_api_key']
|
||||
);
|
||||
const dbKey = keyRow.rows[0]?.value?.replace(/"/g, '') || '';
|
||||
if (!dbKey) {
|
||||
return { analysis: 'LLM API key not configured. Configure it in Admin → Workflow Settings.', tokensUsed: 0 };
|
||||
}
|
||||
return this.callLLM(stats, config, dbKey);
|
||||
}
|
||||
return this.callLLM(stats, config, apiKey);
|
||||
}
|
||||
|
||||
private async callLLM(stats: TicketDigestStats, config: DigestConfig, apiKey: string): Promise<{ analysis: string; tokensUsed: number }> {
|
||||
const systemPrompt = `You are an IT service desk analyst for a managed service provider (MSP). You produce concise, actionable digest reports for management.
|
||||
|
||||
Your analysis should be structured with these sections (use markdown headers):
|
||||
${config.include_noise_analysis ? '- **Noise & Automation**: Identify repetitive/auto-generated tickets that could be suppressed or auto-resolved. Quantify the noise.' : ''}
|
||||
${config.include_sla_analysis ? '- **SLA & Response Times**: Analyze first response and resolution times. Call out any concerning trends.' : ''}
|
||||
${config.include_resource_analysis ? '- **Team Workload**: Analyze resource utilization. Flag overloaded or underutilized engineers.' : ''}
|
||||
${config.include_client_analysis ? '- **Client Spotlight**: Highlight clients with unusual ticket volume or patterns worth attention.' : ''}
|
||||
${config.include_recommendations ? '- **Recommendations**: 3-5 specific, actionable items to reduce noise, improve response times, or optimize workflows.' : ''}
|
||||
|
||||
Rules:
|
||||
- Be direct and data-driven. Reference specific numbers from the data.
|
||||
- Keep the total response under 800 words.
|
||||
- Focus on anomalies and actionable findings, not restating obvious stats.
|
||||
- If noise candidates repeat 10+ times, strongly recommend automation or suppression.
|
||||
- Compare with previous period where relevant.`;
|
||||
|
||||
const dataPayload = JSON.stringify({
|
||||
period: stats.period,
|
||||
overview: stats.overview,
|
||||
comparison: stats.comparison,
|
||||
by_source: stats.by_source.slice(0, 8),
|
||||
by_queue: stats.by_queue.slice(0, 10),
|
||||
by_priority: stats.by_priority,
|
||||
top_clients: stats.top_clients.slice(0, 8),
|
||||
top_resources: stats.top_resources.slice(0, 8),
|
||||
noise_candidates: stats.noise_candidates.slice(0, 15),
|
||||
monitor_tickets: stats.monitor_tickets,
|
||||
sla: stats.sla,
|
||||
}, null, 2);
|
||||
|
||||
const userPrompt = `Analyze this ${stats.period.type} ticket digest for ${stats.period.label}:\n\n${dataPayload}`;
|
||||
|
||||
if (config.llm_provider === 'anthropic') {
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: config.llm_model || 'claude-sonnet-4-20250514',
|
||||
max_tokens: 2000,
|
||||
temperature: 0.3,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userPrompt }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`Anthropic API error (${response.status}): ${err}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const text = data.content?.find((b: any) => b.type === 'text')?.text || '';
|
||||
const tokensUsed = (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0);
|
||||
return { analysis: text, tokensUsed };
|
||||
} else {
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: config.llm_model || 'gpt-4o',
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 2000,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`OpenAI API error (${response.status}): ${err}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const text = data.choices?.[0]?.message?.content || '';
|
||||
const tokensUsed = (data.usage?.total_tokens) || 0;
|
||||
return { analysis: text, tokensUsed };
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Adaptive Card Builder
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
buildAdaptiveCard(stats: TicketDigestStats, analysis: string): object {
|
||||
const ov = stats.overview;
|
||||
const cmp = stats.comparison;
|
||||
const periodTitle = stats.period.type.charAt(0).toUpperCase() + stats.period.type.slice(1);
|
||||
const headerText = `📊 ${periodTitle} Ticket Digest — ${stats.period.label}`;
|
||||
|
||||
const delta = (cur: number, prev: number | null | undefined): string => {
|
||||
if (prev == null || prev === 0) return '';
|
||||
const pct = Math.round(((cur - prev) / prev) * 100);
|
||||
return pct > 0 ? ` ↑${pct}%` : pct < 0 ? ` ↓${Math.abs(pct)}%` : '';
|
||||
};
|
||||
|
||||
const bodyItems: object[] = [
|
||||
{ type: 'TextBlock', text: headerText, weight: 'Bolder', size: 'Large', wrap: true },
|
||||
{
|
||||
type: 'ColumnSet',
|
||||
columns: [
|
||||
{ type: 'Column', width: 'stretch', items: [{ type: 'TextBlock', text: `**${ov.total_created}** Created${cmp ? delta(ov.total_created, cmp.prev_total_created) : ''}`, wrap: true }] },
|
||||
{ type: 'Column', width: 'stretch', items: [{ type: 'TextBlock', text: `**${ov.total_resolved}** Resolved${cmp ? delta(ov.total_resolved, cmp.prev_total_resolved) : ''}`, wrap: true }] },
|
||||
{ type: 'Column', width: 'stretch', items: [{ type: 'TextBlock', text: `**${ov.avg_resolution_hours ?? '—'}h** Avg Resolve`, wrap: true }] },
|
||||
{ type: 'Column', width: 'stretch', items: [{ type: 'TextBlock', text: `**${ov.total_hours_worked.toFixed(1)}h** Worked`, wrap: true }] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Noise highlight
|
||||
if (stats.noise_candidates.length > 0) {
|
||||
const topNoise = stats.noise_candidates.slice(0, 5);
|
||||
const totalNoise = topNoise.reduce((s, n) => s + n.count, 0);
|
||||
const noiseFacts = topNoise.map(n => ({
|
||||
title: `${n.count}×`,
|
||||
value: `${n.title} (${n.source_label})`,
|
||||
}));
|
||||
bodyItems.push(
|
||||
{ type: 'TextBlock', text: `🔁 Top Noise — ${totalNoise} repetitive tickets`, weight: 'Bolder', spacing: 'Medium', wrap: true },
|
||||
{ type: 'FactSet', facts: noiseFacts },
|
||||
);
|
||||
}
|
||||
|
||||
// Monitor tickets
|
||||
if (stats.monitor_tickets.total > 0) {
|
||||
bodyItems.push({
|
||||
type: 'TextBlock',
|
||||
text: `🤖 Monitor-generated: **${stats.monitor_tickets.total}** (${stats.monitor_tickets.pct_of_all}% of all) · ${stats.monitor_tickets.auto_resolved} auto-resolved (<30m)`,
|
||||
spacing: 'Medium', wrap: true,
|
||||
});
|
||||
}
|
||||
|
||||
// SLA summary
|
||||
const totalFR = stats.sla.first_response_met + stats.sla.first_response_missed;
|
||||
const totalRes = stats.sla.resolution_met + stats.sla.resolution_missed;
|
||||
if (totalFR > 0 || totalRes > 0) {
|
||||
const frPct = totalFR > 0 ? Math.round((stats.sla.first_response_met / totalFR) * 100) : 0;
|
||||
const resPct = totalRes > 0 ? Math.round((stats.sla.resolution_met / totalRes) * 100) : 0;
|
||||
bodyItems.push({
|
||||
type: 'TextBlock',
|
||||
text: `⏱️ SLA: First Response **${frPct}%** met (≤1h) · Resolution **${resPct}%** met (≤24h)`,
|
||||
spacing: 'Small', wrap: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Top clients
|
||||
if (stats.top_clients.length > 0) {
|
||||
const clientFacts = stats.top_clients.slice(0, 5).map(c => ({
|
||||
title: `${c.ticket_count} tickets`,
|
||||
value: `${c.company_name} (${c.hours_worked.toFixed(1)}h)`,
|
||||
}));
|
||||
bodyItems.push(
|
||||
{ type: 'TextBlock', text: '🏢 Top Clients', weight: 'Bolder', spacing: 'Medium', wrap: true },
|
||||
{ type: 'FactSet', facts: clientFacts },
|
||||
);
|
||||
}
|
||||
|
||||
// LLM analysis section (split into paragraphs for readability)
|
||||
if (analysis && analysis.length > 20) {
|
||||
bodyItems.push(
|
||||
{ type: 'TextBlock', text: '🧠 AI Analysis', weight: 'Bolder', size: 'Medium', spacing: 'Large', wrap: true },
|
||||
);
|
||||
// Truncate for Adaptive Card limits (~28KB) and split on headers
|
||||
const truncated = analysis.substring(0, 3500);
|
||||
const sections = truncated.split(/(?=^##?\s)/m).filter(s => s.trim());
|
||||
for (const section of sections.slice(0, 6)) {
|
||||
bodyItems.push({ type: 'TextBlock', text: section.trim(), wrap: true, spacing: 'Small' });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||
type: 'AdaptiveCard',
|
||||
version: '1.4',
|
||||
body: bodyItems,
|
||||
actions: [
|
||||
{ type: 'Action.OpenUrl', title: 'Open Pulse', url: 'https://pulse.wulfconsulting.cloud' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Delivery
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
async deliver(card: object, stats: TicketDigestStats, channelIds?: number[]): Promise<DeliveryResult[]> {
|
||||
const config = await this.getConfig();
|
||||
const ids = channelIds ?? config.channel_ids ?? [];
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const channelRows = await postgresClient.query(
|
||||
'SELECT id, name, channel_type, config, is_active FROM notification_channels WHERE id = ANY($1)',
|
||||
[ids]
|
||||
);
|
||||
const channels = channelRows.rows as NotificationChannel[];
|
||||
|
||||
const teamsEnvelope = {
|
||||
type: 'message',
|
||||
attachments: [{
|
||||
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||
contentUrl: null,
|
||||
content: card,
|
||||
}],
|
||||
};
|
||||
|
||||
const plainText = this.buildPlainTextSummary(stats);
|
||||
|
||||
const results: DeliveryResult[] = await Promise.all(
|
||||
channels.map(async (ch): Promise<DeliveryResult> => {
|
||||
try {
|
||||
let res: Response;
|
||||
if (ch.channel_type === 'teams') {
|
||||
const url = ch.config.webhook_url;
|
||||
if (!url) throw new Error('Teams channel missing webhook_url');
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(teamsEnvelope),
|
||||
});
|
||||
} else if (ch.channel_type === 'telegram') {
|
||||
const { bot_token, chat_id, parse_mode } = ch.config;
|
||||
if (!bot_token || !chat_id) throw new Error('Telegram missing bot_token or chat_id');
|
||||
res = await fetch(`https://api.telegram.org/bot${bot_token}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chat_id, text: plainText, parse_mode: parse_mode || 'HTML' }),
|
||||
});
|
||||
} else if (ch.channel_type === 'ntfy') {
|
||||
const server = ch.config.server_url || 'https://ntfy.sh';
|
||||
const topic = ch.config.topic;
|
||||
if (!topic) throw new Error('ntfy missing topic');
|
||||
const headers: Record<string, string> = { 'Content-Type': 'text/plain', 'Title': `Ticket Digest — ${stats.period.label}` };
|
||||
if (ch.config.auth_token) headers['Authorization'] = `Bearer ${ch.config.auth_token}`;
|
||||
if (ch.config.default_priority) headers['Priority'] = ch.config.default_priority;
|
||||
res = await fetch(`${server}/${topic}`, { method: 'POST', headers, body: plainText });
|
||||
} else {
|
||||
const url = ch.config.url;
|
||||
if (!url) throw new Error('Webhook channel missing url');
|
||||
res = await fetch(url, {
|
||||
method: ch.config.method || 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(ch.config.headers || {}) },
|
||||
body: JSON.stringify({ title: `Ticket Digest — ${stats.period.label}`, text: plainText, stats: stats.overview }),
|
||||
});
|
||||
}
|
||||
return { channelId: ch.id, label: ch.name, success: res.ok, httpStatus: res.status };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
return { channelId: ch.id, label: ch.name, success: false, error };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private buildPlainTextSummary(stats: TicketDigestStats): string {
|
||||
const ov = stats.overview;
|
||||
const lines = [
|
||||
`📊 Ticket Digest — ${stats.period.label}`,
|
||||
`Created: ${ov.total_created} | Resolved: ${ov.total_resolved} | Open: ${ov.total_open_end}`,
|
||||
`Avg Resolution: ${ov.avg_resolution_hours ?? '—'}h | Hours Worked: ${ov.total_hours_worked.toFixed(1)}h`,
|
||||
];
|
||||
if (stats.monitor_tickets.total > 0) {
|
||||
lines.push(`Monitor alerts: ${stats.monitor_tickets.total} (${stats.monitor_tickets.pct_of_all}% of all, ${stats.monitor_tickets.auto_resolved} auto-resolved)`);
|
||||
}
|
||||
if (stats.noise_candidates.length > 0) {
|
||||
lines.push(`Top noise: ${stats.noise_candidates.slice(0, 3).map(n => `${n.title} (${n.count}×)`).join(', ')}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Full Run
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
async run(period: DigestPeriod, channelIds?: number[]): Promise<{
|
||||
stats: TicketDigestStats;
|
||||
analysis: string;
|
||||
deliveryResults: DeliveryResult[];
|
||||
processingTimeMs: number;
|
||||
}> {
|
||||
const startTime = Date.now();
|
||||
const config = await this.getConfig();
|
||||
|
||||
console.log(`[TICKET-DIGEST] Generating ${period} report...`);
|
||||
|
||||
// 1. Aggregate data
|
||||
const stats = await this.aggregate(period);
|
||||
console.log(`[TICKET-DIGEST] Aggregated: ${stats.overview.total_created} created, ${stats.overview.total_resolved} resolved`);
|
||||
|
||||
// 2. LLM analysis
|
||||
let analysis = '';
|
||||
let tokensUsed = 0;
|
||||
try {
|
||||
const llmResult = await this.analyzeWithLLM(stats, config);
|
||||
analysis = llmResult.analysis;
|
||||
tokensUsed = llmResult.tokensUsed;
|
||||
console.log(`[TICKET-DIGEST] LLM analysis complete (${tokensUsed} tokens)`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[TICKET-DIGEST] LLM analysis failed: ${msg}`);
|
||||
analysis = `LLM analysis unavailable: ${msg}`;
|
||||
}
|
||||
|
||||
// 3. Build card
|
||||
const card = this.buildAdaptiveCard(stats, analysis);
|
||||
|
||||
// 4. Persist
|
||||
const processingTimeMs = Date.now() - startTime;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO ticket_digest_reports (period_type, period_start, period_end, stats, llm_analysis, card_payload, tokens_used, processing_time_ms)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[period, stats.period.start, stats.period.end, JSON.stringify(stats), analysis, JSON.stringify(card), tokensUsed, processingTimeMs]
|
||||
);
|
||||
|
||||
// 5. Deliver
|
||||
const deliveryResults = await this.deliver(card, stats, channelIds);
|
||||
console.log(`[TICKET-DIGEST] Delivered to ${deliveryResults.filter(r => r.success).length}/${deliveryResults.length} channels`);
|
||||
|
||||
// Update delivery status
|
||||
const statusMap: Record<number, object> = {};
|
||||
for (const r of deliveryResults) {
|
||||
statusMap[r.channelId] = { success: r.success, httpStatus: r.httpStatus, error: r.error };
|
||||
}
|
||||
await postgresClient.query(
|
||||
`UPDATE ticket_digest_reports SET delivery_status = $1
|
||||
WHERE id = (SELECT id FROM ticket_digest_reports ORDER BY generated_at DESC LIMIT 1)`,
|
||||
[JSON.stringify(statusMap)]
|
||||
);
|
||||
|
||||
return { stats, analysis, deliveryResults, processingTimeMs };
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// History
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
async getHistory(limit = 20): Promise<Array<{
|
||||
id: number;
|
||||
period_type: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
generated_at: string;
|
||||
stats: TicketDigestStats;
|
||||
llm_analysis: string | null;
|
||||
delivery_status: object;
|
||||
tokens_used: number | null;
|
||||
processing_time_ms: number | null;
|
||||
}>> {
|
||||
const r = await postgresClient.query(
|
||||
'SELECT * FROM ticket_digest_reports ORDER BY generated_at DESC LIMIT $1',
|
||||
[limit]
|
||||
);
|
||||
return r.rows;
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: TicketDigestService | null = null;
|
||||
export function getTicketDigestService(): TicketDigestService {
|
||||
if (!_instance) _instance = new TicketDigestService();
|
||||
return _instance;
|
||||
}
|
||||
|
|
@ -132,6 +132,39 @@ export class ZabbixClient {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch PROBLEM trigger events (value=1) for a set of hostids within a time range.
|
||||
* Used to build the local zabbix_events correlation cache.
|
||||
*/
|
||||
async getEvents(params: {
|
||||
hostIds: string[];
|
||||
from: Date;
|
||||
to: Date;
|
||||
limit?: number;
|
||||
}): Promise<Array<{
|
||||
eventid: string;
|
||||
objectid: string;
|
||||
name: string;
|
||||
severity: string;
|
||||
clock: string;
|
||||
r_eventid: string;
|
||||
r_clock: string;
|
||||
hosts: Array<{ hostid: string }>;
|
||||
}>> {
|
||||
// problem.get supports r_clock output; event.get does not
|
||||
return this.rpc('problem.get', {
|
||||
output: ['eventid', 'objectid', 'name', 'severity', 'clock', 'r_eventid', 'r_clock'],
|
||||
time_from: Math.floor(params.from.getTime() / 1000),
|
||||
time_till: Math.floor(params.to.getTime() / 1000),
|
||||
hostids: params.hostIds,
|
||||
selectHosts: ['hostid'],
|
||||
recent: false,
|
||||
sortfield: 'eventid',
|
||||
sortorder: 'DESC',
|
||||
limit: params.limit ?? 10000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one or more hosts by their hostids.
|
||||
*/
|
||||
|
|
@ -261,4 +294,42 @@ export class ZabbixClient {
|
|||
await this.rpc<{ hostids: string[] }>('host.update', updateParams);
|
||||
return { action: 'updated', hostid: existing.hostid };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find hosts by DNS name or IP address across all interfaces.
|
||||
* Used to correlate Datto RMM ping targets with Zabbix hosts.
|
||||
*/
|
||||
async findHostsByInterface(dnsOrIp: string): Promise<Array<{ hostid: string; name: string }>> {
|
||||
return this.rpc<Array<{ hostid: string; name: string }>>('host.get', {
|
||||
output: ['hostid', 'name'],
|
||||
filter: { ip: [dnsOrIp], dns: [dnsOrIp] },
|
||||
searchByAny: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get open problems for a host.
|
||||
*/
|
||||
async getOpenProblemsForHost(hostid: string): Promise<ZabbixProblem[]> {
|
||||
return this.rpc<ZabbixProblem[]>('problem.get', {
|
||||
output: 'extend',
|
||||
hostids: [hostid],
|
||||
recent: true,
|
||||
selectAcknowledges: 'count',
|
||||
selectSuppressionData: 'extend',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress a Zabbix problem event until a given timestamp.
|
||||
* action=16 = suppress, action=4 = acknowledge with message.
|
||||
* We combine both (action=20) to add a note and suppress.
|
||||
*/
|
||||
async suppressProblem(eventid: string, _suppressUntil: Date, message: string): Promise<void> {
|
||||
await this.rpc<{ eventids: string[] }>('event.acknowledge', {
|
||||
eventids: [eventid],
|
||||
action: 20, // 4 (add message) + 16 (suppress)
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
161
lib/types/qbo.ts
Normal file
161
lib/types/qbo.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
export interface QboTokenRecord {
|
||||
id: number;
|
||||
realm_id: string;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
access_token_expires_at: Date;
|
||||
refresh_token_expires_at: Date;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface QboTokenResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
x_refresh_token_expires_in: number;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export interface QboRef {
|
||||
value: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface QboMetaData {
|
||||
CreateTime: string;
|
||||
LastUpdatedTime: string;
|
||||
}
|
||||
|
||||
export interface QboLineItem {
|
||||
Id?: string;
|
||||
LineNum?: number;
|
||||
Description?: string;
|
||||
Amount: number;
|
||||
DetailType: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface QboLinkedTxn {
|
||||
TxnId: string;
|
||||
TxnType: string;
|
||||
TxnLineId?: string;
|
||||
}
|
||||
|
||||
// Invoice
|
||||
export interface QboInvoice {
|
||||
Id: string;
|
||||
SyncToken: string;
|
||||
DocNumber?: string;
|
||||
TxnDate?: string;
|
||||
DueDate?: string;
|
||||
CustomerRef?: QboRef;
|
||||
BillEmail?: { Address?: string };
|
||||
TotalAmt?: number;
|
||||
Balance?: number;
|
||||
EmailStatus?: string;
|
||||
PrintStatus?: string;
|
||||
CurrencyRef?: QboRef;
|
||||
Line?: QboLineItem[];
|
||||
LinkedTxn?: QboLinkedTxn[];
|
||||
MetaData?: QboMetaData;
|
||||
}
|
||||
|
||||
// Payment
|
||||
export interface QboPayment {
|
||||
Id: string;
|
||||
SyncToken: string;
|
||||
TxnDate?: string;
|
||||
CustomerRef?: QboRef;
|
||||
TotalAmt?: number;
|
||||
UnappliedAmt?: number;
|
||||
CurrencyRef?: QboRef;
|
||||
PaymentMethodRef?: QboRef;
|
||||
DepositToAccountRef?: QboRef;
|
||||
Line?: Array<{ Amount: number; LinkedTxn?: QboLinkedTxn[] }>;
|
||||
MetaData?: QboMetaData;
|
||||
}
|
||||
|
||||
// Deposit
|
||||
export interface QboDeposit {
|
||||
Id: string;
|
||||
SyncToken: string;
|
||||
TxnDate?: string;
|
||||
DepositToAccountRef?: QboRef;
|
||||
TotalAmt?: number;
|
||||
Line?: QboLineItem[];
|
||||
MetaData?: QboMetaData;
|
||||
}
|
||||
|
||||
// Purchase (expense/credit card)
|
||||
export interface QboPurchase {
|
||||
Id: string;
|
||||
SyncToken: string;
|
||||
TxnDate?: string;
|
||||
DocNumber?: string;
|
||||
EntityRef?: QboRef & { type?: string };
|
||||
AccountRef?: QboRef;
|
||||
TotalAmt?: number;
|
||||
CurrencyRef?: QboRef;
|
||||
PrivateNote?: string;
|
||||
Line?: QboLineItem[];
|
||||
MetaData?: QboMetaData;
|
||||
}
|
||||
|
||||
// Journal Entry
|
||||
export interface QboJournalEntry {
|
||||
Id: string;
|
||||
SyncToken: string;
|
||||
TxnDate?: string;
|
||||
DocNumber?: string;
|
||||
TotalAmt?: number;
|
||||
CurrencyRef?: QboRef;
|
||||
PrivateNote?: string;
|
||||
Line?: QboLineItem[];
|
||||
MetaData?: QboMetaData;
|
||||
}
|
||||
|
||||
// Report (P&L, Balance Sheet)
|
||||
export interface QboReport {
|
||||
Header?: {
|
||||
ReportName?: string;
|
||||
StartPeriod?: string;
|
||||
EndPeriod?: string;
|
||||
Time?: string;
|
||||
Currency?: string;
|
||||
ReportBasis?: string;
|
||||
NoReportData?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
Columns?: any;
|
||||
Rows?: any;
|
||||
}
|
||||
|
||||
export interface QboQueryResponse<T> {
|
||||
QueryResponse: {
|
||||
[key: string]: T[] | number | undefined;
|
||||
startPosition?: number;
|
||||
maxResults?: number;
|
||||
totalCount?: number;
|
||||
};
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface QboSyncResult {
|
||||
syncId: string;
|
||||
realmId: string;
|
||||
status: 'completed' | 'failed';
|
||||
startedAt: Date;
|
||||
completedAt: Date;
|
||||
duration: number;
|
||||
entities: QboEntitySyncResult[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface QboEntitySyncResult {
|
||||
entity: string;
|
||||
success: boolean;
|
||||
recordsUpserted: number;
|
||||
duration: number;
|
||||
error?: string;
|
||||
}
|
||||
|
|
@ -28,7 +28,8 @@ export enum WebhookEntityType {
|
|||
}
|
||||
|
||||
/**
|
||||
* Autotask entities that support webhooks via their REST API
|
||||
* Entities Autotask actually exposes a webhook REST endpoint for.
|
||||
* TimeEntries, Tasks, Projects, Contracts return 404 — not supported by Autotask.
|
||||
*/
|
||||
export const WEBHOOK_SUPPORTED_ENTITIES: WebhookEntityType[] = [
|
||||
WebhookEntityType.COMPANIES,
|
||||
|
|
@ -115,6 +116,10 @@ const ENTITY_TYPE_MAP: Record<string, WebhookEntityType> = {
|
|||
'InstalledProduct': WebhookEntityType.CONFIGURATION_ITEMS, // Autotask actual payload name
|
||||
'Ticket': WebhookEntityType.TICKETS,
|
||||
'TicketNote': WebhookEntityType.TICKET_NOTES,
|
||||
'TimeEntry': WebhookEntityType.TIME_ENTRIES,
|
||||
'Task': WebhookEntityType.TASKS,
|
||||
'Project': WebhookEntityType.PROJECTS,
|
||||
'Contract': WebhookEntityType.CONTRACTS,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
17
migrations/049_create_ping_flap_suppressions.sql
Normal file
17
migrations/049_create_ping_flap_suppressions.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- Ping Flap Suppression Table
|
||||
-- Tracks ping targets that have been auto-suppressed due to flapping behaviour.
|
||||
-- Used to prevent repeated Zabbix suppression calls and pipeline noise.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ping_flap_suppressions (
|
||||
ping_target TEXT PRIMARY KEY,
|
||||
trigger_count INTEGER NOT NULL,
|
||||
suppressed_until TIMESTAMP NOT NULL,
|
||||
zabbix_suppressed BOOLEAN NOT NULL DEFAULT false,
|
||||
notes TEXT,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ping_flap_suppressions_until ON ping_flap_suppressions(suppressed_until);
|
||||
|
||||
COMMENT ON TABLE ping_flap_suppressions IS 'Tracks auto-suppressed flapping ping targets. Suppression expires at suppressed_until.';
|
||||
53
migrations/049_create_ticket_digest_tables.sql
Normal file
53
migrations/049_create_ticket_digest_tables.sql
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
-- Ticket Digest Report tables
|
||||
-- Stores config, delivery webhooks, and report history for LLM-analyzed ticket digest reports
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_digest_config (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
daily_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
weekly_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
monthly_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
daily_cron VARCHAR(50) NOT NULL DEFAULT '0 7 * * 1-5',
|
||||
weekly_cron VARCHAR(50) NOT NULL DEFAULT '0 7 * * 1',
|
||||
monthly_cron VARCHAR(50) NOT NULL DEFAULT '0 7 1 * *',
|
||||
llm_provider VARCHAR(20) NOT NULL DEFAULT 'anthropic',
|
||||
llm_model VARCHAR(100) NOT NULL DEFAULT 'claude-sonnet-4-20250514',
|
||||
include_noise_analysis BOOLEAN NOT NULL DEFAULT true,
|
||||
include_sla_analysis BOOLEAN NOT NULL DEFAULT true,
|
||||
include_resource_analysis BOOLEAN NOT NULL DEFAULT true,
|
||||
include_client_analysis BOOLEAN NOT NULL DEFAULT true,
|
||||
include_recommendations BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT single_config CHECK (id = 1)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_digest_webhooks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
label VARCHAR(200) NOT NULL,
|
||||
webhook_url TEXT NOT NULL,
|
||||
digest_types TEXT[] NOT NULL DEFAULT '{daily,weekly,monthly}',
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
last_delivered_at TIMESTAMP,
|
||||
last_status VARCHAR(20),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_digest_reports (
|
||||
id SERIAL PRIMARY KEY,
|
||||
period_type VARCHAR(10) NOT NULL, -- 'daily', 'weekly', 'monthly'
|
||||
period_start TIMESTAMP NOT NULL,
|
||||
period_end TIMESTAMP NOT NULL,
|
||||
generated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
stats JSONB NOT NULL DEFAULT '{}',
|
||||
llm_analysis TEXT,
|
||||
card_payload JSONB,
|
||||
delivery_status JSONB DEFAULT '{}',
|
||||
tokens_used INTEGER,
|
||||
processing_time_ms INTEGER,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_digest_reports_period ON ticket_digest_reports(period_type, period_start DESC);
|
||||
|
||||
-- Insert default config
|
||||
INSERT INTO ticket_digest_config (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
60
migrations/050_create_zabbix_wan_tables.sql
Normal file
60
migrations/050_create_zabbix_wan_tables.sql
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
-- Migration 050: Zabbix WAN host cache + IT Glue WAN circuit cache
|
||||
-- Enables local gap analysis and event correlation without live API calls every time.
|
||||
|
||||
-- ────────────────────────────────────────────────────────────────
|
||||
-- Zabbix WAN host cache
|
||||
-- Populated by /api/zabbix/sync-hosts (syncs from live Zabbix API)
|
||||
-- ────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS zabbix_wan_hosts (
|
||||
hostid VARCHAR(50) PRIMARY KEY,
|
||||
host_name VARCHAR(255), -- technical/sanitized name (host field)
|
||||
display_name VARCHAR(255), -- human-readable display name
|
||||
wan_ip VARCHAR(45), -- ICMP interface IP
|
||||
status INTEGER DEFAULT 0, -- 0=enabled, 1=disabled
|
||||
rmm_site_uid VARCHAR(100), -- from {$RMM_SITE_UID} macro
|
||||
autotask_company_id INTEGER, -- from {$AUTOTASK_COMPANY_ID} macro
|
||||
autotask_company_name VARCHAR(255), -- from {$AUTOTASK_COMPANY_NAME} macro
|
||||
isp_name VARCHAR(255), -- from {$ISP_NAME} macro
|
||||
asn VARCHAR(50), -- from {$ASN} macro
|
||||
is_multi_wan BOOLEAN DEFAULT FALSE,
|
||||
source VARCHAR(50), -- 'datto-rmm', 'manual', etc.
|
||||
tags JSONB,
|
||||
last_problem_at TIMESTAMPTZ, -- most recent Zabbix problem clock
|
||||
last_problem_name TEXT, -- most recent Zabbix problem name
|
||||
last_synced_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_zabbix_wan_hosts_company ON zabbix_wan_hosts (autotask_company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_zabbix_wan_hosts_rmm_site ON zabbix_wan_hosts (rmm_site_uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_zabbix_wan_hosts_ip ON zabbix_wan_hosts (wan_ip);
|
||||
|
||||
-- ────────────────────────────────────────────────────────────────
|
||||
-- IT Glue WAN circuit cache
|
||||
-- Populated by /api/itglue/sync-wan (parses flexible_asset_type_id=3794)
|
||||
-- ────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS itg_wan_circuits (
|
||||
id BIGINT PRIMARY KEY, -- IT Glue flexible asset ID
|
||||
organization_id BIGINT, -- IT Glue org ID
|
||||
org_name VARCHAR(255),
|
||||
autotask_company_id BIGINT, -- via itg_organizations.psa_id::bigint
|
||||
provider VARCHAR(255),
|
||||
link_type VARCHAR(100),
|
||||
static_ips TEXT[], -- parsed public IPs from traits
|
||||
raw_ip_text TEXT, -- original IT Glue text (for reference)
|
||||
upload_mbps NUMERIC,
|
||||
download_mbps NUMERIC,
|
||||
location_name VARCHAR(255),
|
||||
location_city VARCHAR(255),
|
||||
notes TEXT,
|
||||
is_decommissioned BOOLEAN DEFAULT FALSE,
|
||||
zabbix_hostid VARCHAR(50), -- matched Zabbix host (NULL = gap)
|
||||
last_synced_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_itg_wan_circuits_org ON itg_wan_circuits (organization_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_itg_wan_circuits_company ON itg_wan_circuits (autotask_company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_itg_wan_circuits_zabbix ON itg_wan_circuits (zabbix_hostid);
|
||||
125
migrations/051_create_qbo_tables.sql
Normal file
125
migrations/051_create_qbo_tables.sql
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
-- QBO OAuth2 tokens (single row for Wulf Consulting's QBO)
|
||||
CREATE TABLE IF NOT EXISTS qbo_tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
realm_id TEXT NOT NULL UNIQUE,
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT NOT NULL,
|
||||
access_token_expires_at TIMESTAMPTZ NOT NULL,
|
||||
refresh_token_expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Invoices
|
||||
CREATE TABLE IF NOT EXISTS qbo_invoices (
|
||||
id TEXT PRIMARY KEY, -- QBO Id
|
||||
realm_id TEXT NOT NULL,
|
||||
doc_number TEXT,
|
||||
txn_date DATE,
|
||||
due_date DATE,
|
||||
customer_ref_id TEXT,
|
||||
customer_ref_name TEXT,
|
||||
email_address TEXT,
|
||||
total_amt NUMERIC(12,2),
|
||||
balance NUMERIC(12,2),
|
||||
status TEXT, -- Open, Paid, Voided, etc.
|
||||
currency_code TEXT DEFAULT 'USD',
|
||||
line_items JSONB,
|
||||
linked_txns JSONB,
|
||||
sync_token TEXT,
|
||||
qbo_created_at TIMESTAMPTZ,
|
||||
qbo_updated_at TIMESTAMPTZ,
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_qbo_invoices_txn_date ON qbo_invoices(txn_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_qbo_invoices_customer ON qbo_invoices(customer_ref_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_qbo_invoices_status ON qbo_invoices(status);
|
||||
|
||||
-- Payments
|
||||
CREATE TABLE IF NOT EXISTS qbo_payments (
|
||||
id TEXT PRIMARY KEY, -- QBO Id
|
||||
realm_id TEXT NOT NULL,
|
||||
txn_date DATE,
|
||||
customer_ref_id TEXT,
|
||||
customer_ref_name TEXT,
|
||||
total_amt NUMERIC(12,2),
|
||||
unapplied_amt NUMERIC(12,2),
|
||||
currency_code TEXT DEFAULT 'USD',
|
||||
payment_method_ref TEXT,
|
||||
deposit_account_ref TEXT,
|
||||
linked_txns JSONB,
|
||||
sync_token TEXT,
|
||||
qbo_created_at TIMESTAMPTZ,
|
||||
qbo_updated_at TIMESTAMPTZ,
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_qbo_payments_txn_date ON qbo_payments(txn_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_qbo_payments_customer ON qbo_payments(customer_ref_id);
|
||||
|
||||
-- Deposits
|
||||
CREATE TABLE IF NOT EXISTS qbo_deposits (
|
||||
id TEXT PRIMARY KEY, -- QBO Id
|
||||
realm_id TEXT NOT NULL,
|
||||
txn_date DATE,
|
||||
deposit_to_account_ref_id TEXT,
|
||||
deposit_to_account_ref_name TEXT,
|
||||
total_amt NUMERIC(12,2),
|
||||
line_items JSONB,
|
||||
sync_token TEXT,
|
||||
qbo_created_at TIMESTAMPTZ,
|
||||
qbo_updated_at TIMESTAMPTZ,
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_qbo_deposits_txn_date ON qbo_deposits(txn_date);
|
||||
|
||||
-- General ledger transactions (purchases, expenses, journal entries, etc.)
|
||||
CREATE TABLE IF NOT EXISTS qbo_transactions (
|
||||
id TEXT NOT NULL,
|
||||
txn_type TEXT NOT NULL, -- Purchase, Expense, JournalEntry, Transfer, etc.
|
||||
realm_id TEXT NOT NULL,
|
||||
txn_date DATE,
|
||||
doc_number TEXT,
|
||||
entity_ref_id TEXT, -- Vendor/Customer ref
|
||||
entity_ref_name TEXT,
|
||||
entity_type TEXT, -- Vendor, Customer
|
||||
account_ref_id TEXT,
|
||||
account_ref_name TEXT,
|
||||
total_amt NUMERIC(12,2),
|
||||
currency_code TEXT DEFAULT 'USD',
|
||||
private_note TEXT,
|
||||
line_items JSONB,
|
||||
sync_token TEXT,
|
||||
qbo_created_at TIMESTAMPTZ,
|
||||
qbo_updated_at TIMESTAMPTZ,
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (id, txn_type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_qbo_transactions_txn_date ON qbo_transactions(txn_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_qbo_transactions_type ON qbo_transactions(txn_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_qbo_transactions_entity ON qbo_transactions(entity_ref_id);
|
||||
|
||||
-- Financial reports: P&L and Balance Sheet stored as JSONB per period
|
||||
CREATE TABLE IF NOT EXISTS qbo_reports (
|
||||
id SERIAL PRIMARY KEY,
|
||||
realm_id TEXT NOT NULL,
|
||||
report_type TEXT NOT NULL, -- ProfitAndLoss, BalanceSheet
|
||||
period_start DATE NOT NULL,
|
||||
period_end DATE NOT NULL,
|
||||
summarize_by TEXT DEFAULT 'Month',
|
||||
report_data JSONB NOT NULL, -- Full report response
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (realm_id, report_type, period_start, period_end)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_qbo_reports_type_period ON qbo_reports(report_type, period_start);
|
||||
|
||||
COMMENT ON TABLE qbo_tokens IS 'QuickBooks Online OAuth2 tokens for Wulf Consulting';
|
||||
COMMENT ON TABLE qbo_invoices IS 'QBO invoices synced from QuickBooks Online';
|
||||
COMMENT ON TABLE qbo_payments IS 'QBO customer payments synced from QuickBooks Online';
|
||||
COMMENT ON TABLE qbo_deposits IS 'QBO bank deposits synced from QuickBooks Online';
|
||||
COMMENT ON TABLE qbo_transactions IS 'QBO general transactions (purchases, expenses, journals) synced from QuickBooks Online';
|
||||
COMMENT ON TABLE qbo_reports IS 'QBO financial reports (P&L, Balance Sheet) stored per period';
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
|
|
@ -58,6 +58,7 @@
|
|||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"baseline-browser-mapping": "2.10.8",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"tailwindcss": "^4.1.18",
|
||||
|
|
@ -5996,12 +5997,15 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.8.20",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz",
|
||||
"integrity": "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==",
|
||||
"version": "2.10.8",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
|
||||
"integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/better-auth": {
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@
|
|||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"baseline-browser-mapping": "2.10.8",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"tailwindcss": "^4.1.18",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue