Compare commits
25 Commits
main
...
v20260213-
| Author | SHA1 | Date | |
|---|---|---|---|
| b5cf91d5f2 | |||
| 385aeb901c | |||
| 6468cbbc74 | |||
| 0e1e7e053d | |||
| bd72f91598 | |||
| 2e0baa4e35 | |||
| 9dee9c300a | |||
| c5cf07f4e5 | |||
| 91755c6e85 | |||
| 6674d40f4b | |||
| 32e68d7209 | |||
| 23e59ab459 | |||
| b2992acc56 | |||
| 200dd23285 | |||
| d1023f9e52 | |||
| 1de1b032e7 | |||
| 661a5783cf | |||
| dfe86a6ed1 | |||
| 35ec337c54 | |||
| c777728c91 | |||
| 0510613708 | |||
| fc99f17db3 | |||
| 1a506c0713 | |||
| 85798a07ae | |||
| 451ce1ab22 |
@ -1 +1 @@
|
|||||||
main
|
v20260213-01-fix-missed-run-tickets
|
||||||
|
|||||||
765
TODO-cove-data-protection.md
Normal file
765
TODO-cove-data-protection.md
Normal file
@ -0,0 +1,765 @@
|
|||||||
|
# TODO: Cove Data Protection Integration
|
||||||
|
|
||||||
|
**Date:** 2026-02-10
|
||||||
|
**Status:** Research phase
|
||||||
|
**Priority:** Medium
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Goal
|
||||||
|
|
||||||
|
Integrate Cove Data Protection (formerly N-able Backup / SolarWinds Backup) into Backupchecks for backup status monitoring.
|
||||||
|
|
||||||
|
**Challenge:** Cove does NOT work with email notifications like other backup systems (Veeam, Synology, NAKIVO). We need to find an alternative method to import backup status information.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Research Questions
|
||||||
|
|
||||||
|
### 1. API Availability
|
||||||
|
- [x] Does Cove Data Protection have a public API? **YES - Confirmed in documentation**
|
||||||
|
- [ ] **CRITICAL:** How to enable/activate API access? (settings location, admin portal?)
|
||||||
|
- [ ] What authentication method does the API use? (API key, OAuth, basic auth?)
|
||||||
|
- [ ] Which endpoints are available for backup status?
|
||||||
|
- [ ] Is there rate limiting on the API?
|
||||||
|
- [ ] Documentation URL: ?
|
||||||
|
- [ ] Is API access available in all Cove subscription tiers or only specific plans?
|
||||||
|
|
||||||
|
### 2. Data Structure
|
||||||
|
- [ ] What information can we retrieve per backup job?
|
||||||
|
- Job name
|
||||||
|
- Status (success/warning/failed)
|
||||||
|
- Start/end time
|
||||||
|
- Backup type
|
||||||
|
- Client/device name
|
||||||
|
- Error messages
|
||||||
|
- Objects/files backed up
|
||||||
|
- [ ] Is there a webhook system available?
|
||||||
|
- [ ] How often should the API be polled?
|
||||||
|
|
||||||
|
### 3. Multi-Tenancy
|
||||||
|
- [ ] Does Cove support multi-tenant setups? (MSP use case)
|
||||||
|
- [ ] Can we monitor multiple customers/partners from 1 account?
|
||||||
|
- [ ] How are permissions/access managed?
|
||||||
|
|
||||||
|
### 4. Integration Strategy
|
||||||
|
- [ ] **Option A: Scheduled Polling**
|
||||||
|
- Cronjob that periodically calls API
|
||||||
|
- Parse results to JobRun records
|
||||||
|
- Pro: Simple, consistent with current flow
|
||||||
|
- Con: Delay between backup and registration in system
|
||||||
|
|
||||||
|
- [ ] **Option B: Webhook/Push**
|
||||||
|
- Cove sends notifications to our endpoint
|
||||||
|
- Pro: Real-time updates
|
||||||
|
- Con: Requires external endpoint, security considerations
|
||||||
|
|
||||||
|
- [ ] **Option C: Email Forwarding**
|
||||||
|
- If Cove has email support after all (hidden setting?)
|
||||||
|
- Pro: Reuses existing email import flow
|
||||||
|
- Con: Possibly not available
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Technical Considerations
|
||||||
|
|
||||||
|
### Database Model
|
||||||
|
Current JobRun model expects:
|
||||||
|
- `mail_message_id` (FK) - how do we adapt this for API-sourced runs?
|
||||||
|
- Possible new field: `source_type` ("email" vs "api")
|
||||||
|
- Possible new field: `external_id` (Cove job ID)
|
||||||
|
|
||||||
|
### Parser System
|
||||||
|
Current parser system works with email content. For API:
|
||||||
|
- New "parser" concept for API responses?
|
||||||
|
- Or direct JobRun creation without parser layer?
|
||||||
|
|
||||||
|
### Architecture Options
|
||||||
|
|
||||||
|
**Option 1: Extend Email Import System**
|
||||||
|
```
|
||||||
|
API Poller → Pseudo-MailMessage → Existing Parser → JobRun
|
||||||
|
```
|
||||||
|
- Pro: Reuse existing flow
|
||||||
|
- Con: Hacky, email fields have no meaning
|
||||||
|
|
||||||
|
**Option 2: Parallel Import System**
|
||||||
|
```
|
||||||
|
API Poller → API Parser → JobRun (direct)
|
||||||
|
```
|
||||||
|
- Pro: Clean separation, no email dependency
|
||||||
|
- Con: Logic duplication
|
||||||
|
|
||||||
|
**Option 3: Unified Import Layer**
|
||||||
|
```
|
||||||
|
→ Email Import →
|
||||||
|
Unified → → Common Processor → JobRun
|
||||||
|
→ API Import →
|
||||||
|
```
|
||||||
|
- Pro: Future-proof, scalable
|
||||||
|
- Con: Larger refactor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Implementation Steps (After Research)
|
||||||
|
|
||||||
|
### Phase 0: API Access Activation (FIRST!)
|
||||||
|
**Critical step before any development can begin:**
|
||||||
|
|
||||||
|
1. [ ] **Find API activation location**
|
||||||
|
- Check Cove admin portal/dashboard
|
||||||
|
- Look in: Settings → API / Integrations / Developer section
|
||||||
|
- Check: Account settings, Company settings, Partner settings
|
||||||
|
- Search documentation for: "API activation", "API access", "enable API"
|
||||||
|
|
||||||
|
2. [ ] **Generate API credentials**
|
||||||
|
- API key generation
|
||||||
|
- Client ID / Client Secret (if OAuth)
|
||||||
|
- Note: which user/role can generate API keys?
|
||||||
|
|
||||||
|
3. [ ] **Document API base URL**
|
||||||
|
- Production API endpoint
|
||||||
|
- Sandbox/test environment (if available)
|
||||||
|
- Regional endpoints (EU vs US?)
|
||||||
|
|
||||||
|
4. [ ] **Document API authentication flow**
|
||||||
|
- Header format (Bearer token, API key in header, query param?)
|
||||||
|
- Token expiration and refresh
|
||||||
|
- Rate limit headers to watch
|
||||||
|
|
||||||
|
5. [ ] **Find API documentation portal**
|
||||||
|
- Developer documentation URL
|
||||||
|
- Interactive API explorer (Swagger/OpenAPI?)
|
||||||
|
- Code examples/SDKs
|
||||||
|
- Support channels for API questions
|
||||||
|
|
||||||
|
**Resources to check:**
|
||||||
|
- Cove admin portal: https://backup.management (or similar)
|
||||||
|
- N-able partner portal
|
||||||
|
- Cove knowledge base / support docs
|
||||||
|
- Contact Cove support for API access instructions
|
||||||
|
|
||||||
|
### Phase 1: API Research & POC
|
||||||
|
|
||||||
|
**Step 1: Read Authentication Documentation** ✅ DOCUMENTATION FOUND!
|
||||||
|
- [x] API documentation located
|
||||||
|
- [ ] **Read:** https://documentation.n-able.com/covedataprotection/USERGUIDE/documentation/Content/unused/service-management/json-api/login.htm
|
||||||
|
- [ ] **Read:** https://documentation.n-able.com/covedataprotection/USERGUIDE/documentation/Content/unused/service-management/json-api/construct-a-call.htm
|
||||||
|
- [ ] Document API base URL from docs
|
||||||
|
- [ ] Document authentication flow (likely JSON-RPC style based on "construct-a-call")
|
||||||
|
- [ ] Note any required request format (headers, body structure)
|
||||||
|
|
||||||
|
**Step 2: Test Authentication**
|
||||||
|
- [ ] Determine token format (Bearer token? API key header? Query param?)
|
||||||
|
- [ ] Common authentication patterns to test:
|
||||||
|
```bash
|
||||||
|
# Option 1: Bearer token
|
||||||
|
curl -H "Authorization: Bearer YOUR_TOKEN" https://api.example.com/endpoint
|
||||||
|
|
||||||
|
# Option 2: API Key header
|
||||||
|
curl -H "X-API-Key: YOUR_TOKEN" https://api.example.com/endpoint
|
||||||
|
|
||||||
|
# Option 3: Custom header
|
||||||
|
curl -H "X-Auth-Token: YOUR_TOKEN" https://api.example.com/endpoint
|
||||||
|
```
|
||||||
|
- [ ] Test with simple endpoint (e.g., `/api/v1/status`, `/api/accounts`, `/api/devices`)
|
||||||
|
|
||||||
|
**Step 3: Discover Available Endpoints**
|
||||||
|
- [ ] Find API documentation/reference
|
||||||
|
- [ ] Look for OpenAPI/Swagger spec
|
||||||
|
- [ ] Key endpoints we need:
|
||||||
|
- List customers/accounts
|
||||||
|
- List backup devices/jobs
|
||||||
|
- Get backup job history
|
||||||
|
- Get backup job status/details
|
||||||
|
- Get backup run results (success/failed/warnings)
|
||||||
|
|
||||||
|
**Step 4: Test Data Retrieval**
|
||||||
|
- [ ] Test listing customers (verify top-level access works)
|
||||||
|
- [ ] Test listing backup jobs for one customer
|
||||||
|
- [ ] Test retrieving details for one backup job
|
||||||
|
- [ ] Document response format (JSON structure)
|
||||||
|
- [ ] Save example API responses for reference
|
||||||
|
|
||||||
|
**Step 5: Proof of Concept Script**
|
||||||
|
1. [ ] Create standalone Python script (outside Backupchecks)
|
||||||
|
2. [ ] Test authentication and data retrieval
|
||||||
|
3. [ ] Parse API response to extract key fields
|
||||||
|
4. [ ] Mapping of Cove data → Backupchecks JobRun model
|
||||||
|
5. [ ] Document findings in this TODO
|
||||||
|
|
||||||
|
### Phase 2: Database Changes
|
||||||
|
1. [ ] Decide: extend MailMessage model or new source type?
|
||||||
|
2. [ ] Migration: add `source_type` field to JobRun
|
||||||
|
3. [ ] Migration: add `external_id` field to JobRun
|
||||||
|
4. [ ] Update constraints/validations
|
||||||
|
|
||||||
|
### Phase 3: Import Mechanism
|
||||||
|
1. [ ] New file: `containers/backupchecks/src/backend/app/cove_importer.py`
|
||||||
|
2. [ ] API client for Cove
|
||||||
|
3. [ ] Data transformation to JobRun format
|
||||||
|
4. [ ] Error handling & retry logic
|
||||||
|
5. [ ] Logging & audit trail
|
||||||
|
|
||||||
|
### Phase 4: Scheduling
|
||||||
|
1. [ ] Cronjob/scheduled task for polling (every 15 min?)
|
||||||
|
2. [ ] Or: webhook endpoint if Cove supports it
|
||||||
|
3. [ ] Rate limiting & throttling
|
||||||
|
4. [ ] Duplicate detection (avoid double imports)
|
||||||
|
|
||||||
|
### Phase 5: UI Updates
|
||||||
|
1. [ ] Job Details: indication that job is from API (not email)
|
||||||
|
2. [ ] No "Download EML" button for API-sourced runs
|
||||||
|
3. [ ] Possibly different metadata display
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 References
|
||||||
|
|
||||||
|
### Cove Data Protection
|
||||||
|
- **Product name:** Cove Data Protection (formerly N-able Backup, SolarWinds Backup)
|
||||||
|
- **Website:** https://www.n-able.com/products/cove-data-protection
|
||||||
|
- **API Documentation Base:** https://documentation.n-able.com/covedataprotection/USERGUIDE/documentation/Content/unused/service-management/json-api/
|
||||||
|
|
||||||
|
### JSON API Documentation (Found!)
|
||||||
|
|
||||||
|
**Core Documentation:**
|
||||||
|
- 📘 **API Home:** https://documentation.n-able.com/covedataprotection/USERGUIDE/documentation/Content/unused/service-management/json-api/home.htm
|
||||||
|
- 🔑 **Login/Authentication:** https://documentation.n-able.com/covedataprotection/USERGUIDE/documentation/Content/unused/service-management/json-api/login.htm
|
||||||
|
- 🔧 **Construct API Calls:** https://documentation.n-able.com/covedataprotection/USERGUIDE/documentation/Content/unused/service-management/json-api/construct-a-call.htm
|
||||||
|
|
||||||
|
**Key Endpoints for Backupchecks:**
|
||||||
|
- 👥 **Enumerate Customers:** https://documentation.n-able.com/covedataprotection/USERGUIDE/documentation/Content/unused/service-management/json-api/enumerate-customers.htm
|
||||||
|
- 💻 **Enumerate Devices:** https://documentation.n-able.com/covedataprotection/USERGUIDE/documentation/Content/unused/service-management/json-api/enumerate-devices.htm
|
||||||
|
- 📊 **Enumerate Device Statistics:** https://documentation.n-able.com/covedataprotection/USERGUIDE/documentation/Content/unused/service-management/json-api/enumerate-device-statistics.htm
|
||||||
|
|
||||||
|
**Reference:**
|
||||||
|
- 📋 **API Column Codes:** https://documentation.n-able.com/covedataprotection/USERGUIDE/documentation/Content/unused/service-management/json-api/API-column-codes.htm
|
||||||
|
- 📋 **Legacy Column Codes:** https://documentation.n-able.com/covedataprotection/USERGUIDE/documentation/Content/unused/service-management/json-api/API-column-codes-legacy.htm
|
||||||
|
- 📐 **Schema Documentation:** https://documentation.n-able.com/covedataprotection/USERGUIDE/documentation/Content/unused/service-management/json-api/how-to-schema.htm
|
||||||
|
|
||||||
|
**Other Resources:**
|
||||||
|
- 🏗️ **Architecture Guide:** https://documentation.n-able.com/covedataprotection/USERGUIDE/documentation/Content/Architecture-and-Security/Cove-Architecture-Guide.htm
|
||||||
|
- 🔒 **Security Guide:** https://documentation.n-able.com/covedataprotection/USERGUIDE/documentation/Content/Architecture-and-Security/Cove-Security-Guide.htm
|
||||||
|
|
||||||
|
**Note:** API docs are in "unused" folder - likely legacy but still functional!
|
||||||
|
|
||||||
|
### Similar Integrations
|
||||||
|
Other backup systems that use APIs:
|
||||||
|
- Veeam: Has both email and REST API
|
||||||
|
- Acronis: REST API available
|
||||||
|
- MSP360: API for management
|
||||||
|
|
||||||
|
### Resources
|
||||||
|
- [ ] API documentation (yet to find)
|
||||||
|
- [ ] SDK/Client libraries available?
|
||||||
|
- [ ] Community/forum for integration questions?
|
||||||
|
- [ ] Example code/integrations?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❓ Open Questions
|
||||||
|
|
||||||
|
1. **Performance:** How many Cove jobs do we need to monitor? (impact on polling frequency)
|
||||||
|
2. **Historical Data:** Can we retrieve old backup runs, or only new ones?
|
||||||
|
3. **Filtering:** Can we apply filters (only failed jobs, specific clients)?
|
||||||
|
4. **Authentication:** Where do we store Cove API credentials? (SystemSettings?)
|
||||||
|
5. **Multi-Account:** Do we support multiple Cove accounts? (MSP scenario)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Success Criteria
|
||||||
|
|
||||||
|
### Minimum Viable Product (MVP)
|
||||||
|
- [ ] Backup runs from Cove are automatically imported
|
||||||
|
- [ ] Status (success/warning/failed) displayed correctly
|
||||||
|
- [ ] Job name and timestamp available
|
||||||
|
- [ ] Visible in Daily Jobs & Run Checks
|
||||||
|
- [ ] Errors and warnings are shown
|
||||||
|
|
||||||
|
### Nice to Have
|
||||||
|
- [ ] Real-time import (webhook instead of polling)
|
||||||
|
- [ ] Backup object details (individual files/folders)
|
||||||
|
- [ ] Retry history
|
||||||
|
- [ ] Storage usage metrics
|
||||||
|
- [ ] Multi-tenant support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Critical Limitations Discovered (2026-02-10)
|
||||||
|
|
||||||
|
### What the API CAN provide:
|
||||||
|
- ✅ Account/device identifiers (I1)
|
||||||
|
- ✅ Storage usage metrics (I14 - bytes used)
|
||||||
|
- ✅ Computer/hostname (I18)
|
||||||
|
- ✅ Numeric metrics (D01F00-D01F07, D09F00)
|
||||||
|
- ✅ Basic partner metadata
|
||||||
|
|
||||||
|
### What the API CANNOT provide (security restrictions):
|
||||||
|
- ❌ **Last backup timestamp** - No reliable date/time fields accessible
|
||||||
|
- ❌ **Backup status** (success/failed/warning) - No explicit status fields
|
||||||
|
- ❌ **Error messages** - All D02Fxx/D03Fxx ranges blocked
|
||||||
|
- ❌ **Backup run history** - No detailed run information
|
||||||
|
- ❌ **Cross-customer aggregation** - API users are customer-scoped
|
||||||
|
- ❌ **Device enumeration** - EnumerateAccounts method blocked (error 13501)
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
**Security error 13501** ("Operation failed because of security reasons") occurs when:
|
||||||
|
- Any restricted column code is requested in EnumerateAccountStatistics
|
||||||
|
- EnumerateAccounts method is called (always fails)
|
||||||
|
- This applies even with SuperUser + SecurityOfficer roles
|
||||||
|
|
||||||
|
**Column restrictions are per-tenant and not documented.** The allow-list is extremely limited.
|
||||||
|
|
||||||
|
### Impact on Backupchecks Integration
|
||||||
|
**Current API access is insufficient for backup monitoring** because:
|
||||||
|
1. No way to determine if a backup succeeded or failed
|
||||||
|
2. No error messages to display to users
|
||||||
|
3. No timestamps to track backup frequency
|
||||||
|
4. Cannot import backup "runs" in meaningful way
|
||||||
|
|
||||||
|
**Possible with current API:**
|
||||||
|
- Storage usage dashboard only
|
||||||
|
- Device inventory list
|
||||||
|
- But NOT backup status monitoring (core Backupchecks function)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔀 Decision Point: Integration Feasibility
|
||||||
|
|
||||||
|
### Option A: Implement Metrics-Only Integration
|
||||||
|
**Pros:**
|
||||||
|
- Can display storage usage per device
|
||||||
|
- Simple implementation
|
||||||
|
- Works with current API access
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Does NOT meet core Backupchecks requirement (backup status monitoring)
|
||||||
|
- No success/failure tracking
|
||||||
|
- No alerting on backup issues
|
||||||
|
- Limited value compared to email-based systems
|
||||||
|
|
||||||
|
**Effort:** Low (2-3 days)
|
||||||
|
**Value:** Low (storage metrics only, no backup monitoring)
|
||||||
|
|
||||||
|
### Option B: Request Expanded API Access from N-able ⭐ RECOMMENDED
|
||||||
|
**Contact N-able support and request:**
|
||||||
|
1. MSP-level API user capability (cross-customer access)
|
||||||
|
2. Access to restricted column codes:
|
||||||
|
- Backup timestamps (last successful backup)
|
||||||
|
- Status fields (success/warning/failed)
|
||||||
|
- Error message fields (D02Fxx/D03Fxx)
|
||||||
|
- Session/run history fields
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Could enable full backup monitoring if granted
|
||||||
|
- Proper integration matching other backup systems
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- May require vendor cooperation
|
||||||
|
- No guarantee N-able will grant access
|
||||||
|
- Possible additional licensing costs?
|
||||||
|
- Timeline uncertain (support ticket process)
|
||||||
|
|
||||||
|
**Effort:** Unknown (depends on N-able response)
|
||||||
|
**Value:** High (if successful)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📧 Support Ticket Template (Ready to Send)
|
||||||
|
|
||||||
|
**To:** N-able Cove Data Protection Support
|
||||||
|
**Subject:** API Access Request - Backup Monitoring Integration
|
||||||
|
|
||||||
|
**Email Body:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Hello N-able Support Team,
|
||||||
|
|
||||||
|
We are developing a backup monitoring solution for MSPs and are integrating
|
||||||
|
with Cove Data Protection via the JSON-RPC API for our customers.
|
||||||
|
|
||||||
|
Current Situation:
|
||||||
|
- We have successfully authenticated with the API
|
||||||
|
- API endpoint: https://api.backup.management/jsonapi
|
||||||
|
- API user management: https://backup.management/#/api-users
|
||||||
|
- Method tested: EnumerateAccountStatistics
|
||||||
|
- Role: SuperUser + SecurityOfficer
|
||||||
|
|
||||||
|
Current Limitations (Blocking Integration):
|
||||||
|
We are encountering "Operation failed because of security reasons (error 13501)"
|
||||||
|
when attempting to access essential backup monitoring data:
|
||||||
|
|
||||||
|
1. Backup Status Fields
|
||||||
|
- Cannot determine if backups succeeded, failed, or completed with warnings
|
||||||
|
- Need access to status/result columns
|
||||||
|
|
||||||
|
2. Timestamp Information
|
||||||
|
- Cannot access last backup date/time
|
||||||
|
- Need reliable timestamp fields to track backup frequency
|
||||||
|
|
||||||
|
3. Error Messages
|
||||||
|
- D02Fxx and D03Fxx column ranges are blocked
|
||||||
|
- Cannot retrieve error details to show users what went wrong
|
||||||
|
|
||||||
|
4. API User Scope
|
||||||
|
- API users are customer-scoped only
|
||||||
|
- Need MSP-level API user capability for cross-customer monitoring
|
||||||
|
|
||||||
|
Impact:
|
||||||
|
Without access to these fields, we can only retrieve storage usage metrics,
|
||||||
|
which is insufficient for backup status monitoring - the core requirement
|
||||||
|
for our MSP customers.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
Can you please:
|
||||||
|
1. Enable MSP-level API user creation for cross-customer access
|
||||||
|
2. Grant access to restricted column codes containing:
|
||||||
|
- Backup status (success/failed/warning)
|
||||||
|
- Last backup timestamps
|
||||||
|
- Error messages and details
|
||||||
|
- Session/run history
|
||||||
|
3. Provide documentation on the semantic meaning of column codes (especially
|
||||||
|
D01F00-D01F07 and D09F00 which currently work)
|
||||||
|
4. OR suggest an alternative integration method if expanded API access is
|
||||||
|
not available (webhooks, reporting API, email notifications, etc.)
|
||||||
|
|
||||||
|
Technical Details:
|
||||||
|
- Our test results are documented at:
|
||||||
|
docs/cove_data_protection_api_calls_known_info.md (can provide upon request)
|
||||||
|
- Safe columns identified: I1, I14, I18, D01F00-D01F07, D09F00
|
||||||
|
- Restricted columns: Entire D02Fxx and D03Fxx ranges
|
||||||
|
|
||||||
|
Use Case:
|
||||||
|
We need this integration to provide our MSP customers with centralized backup
|
||||||
|
monitoring across multiple backup vendors (Veeam, Synology, NAKIVO, and Cove).
|
||||||
|
Without proper API access, Cove customers cannot benefit from our monitoring
|
||||||
|
solution.
|
||||||
|
|
||||||
|
Please advise on the best path forward for enabling comprehensive backup
|
||||||
|
monitoring via the Cove API.
|
||||||
|
|
||||||
|
Thank you for your assistance.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
[Your Name]
|
||||||
|
[Company Name]
|
||||||
|
[Contact Information]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative Contact Methods:**
|
||||||
|
- N-able Partner Portal support ticket
|
||||||
|
- Cove support email (if available)
|
||||||
|
- N-able account manager (if assigned)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option C: Alternative Integration Methods
|
||||||
|
Explore if Cove has:
|
||||||
|
1. **Reporting API** (separate from JSON-RPC)
|
||||||
|
2. **Webhook system** (push notifications for backup events)
|
||||||
|
3. **Email notifications** (if available, use existing email parser)
|
||||||
|
4. **Export/CSV reports** (scheduled export that can be imported)
|
||||||
|
|
||||||
|
**Effort:** Medium (research required)
|
||||||
|
**Value:** Unknown
|
||||||
|
|
||||||
|
### Option D: Defer Integration
|
||||||
|
**Wait until:**
|
||||||
|
- Customer requests Cove support specifically
|
||||||
|
- N-able improves API capabilities
|
||||||
|
- Alternative integration method discovered
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- No wasted effort on limited implementation
|
||||||
|
- Focus on systems with better API support
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Cove customers cannot use Backupchecks
|
||||||
|
- Competitive disadvantage if other MSPs support Cove
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Recommended Next Steps
|
||||||
|
|
||||||
|
### Immediate (This Week)
|
||||||
|
1. **Decision:** Choose Option A, B, C, or D above
|
||||||
|
2. **If Option B (contact N-able):**
|
||||||
|
- Open support ticket with N-able
|
||||||
|
- Reference API user creation at https://backup.management/#/api-users
|
||||||
|
- Explain need for expanded column access for monitoring solution
|
||||||
|
- Attach findings from `/docker/develop/cove_data_protection_api_calls_known_info.md`
|
||||||
|
- Ask specifically for:
|
||||||
|
- MSP-level API user creation
|
||||||
|
- Access to backup status/timestamp columns
|
||||||
|
- Documentation of column codes semantics
|
||||||
|
|
||||||
|
3. **If Option C (alternative methods):**
|
||||||
|
- Check Cove portal for webhook/reporting settings
|
||||||
|
- Search N-able docs for "reporting API", "webhooks", "notifications"
|
||||||
|
- Test if email notifications can be enabled per customer
|
||||||
|
|
||||||
|
### Long Term (Future)
|
||||||
|
- Monitor N-able API changelog for improvements
|
||||||
|
- Check if other MSPs have found workarounds
|
||||||
|
- Consider partnering with N-able for integration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Next Steps
|
||||||
|
|
||||||
|
### Immediate Actions (Ready to Start!)
|
||||||
|
|
||||||
|
**1. Read API Documentation** ✅ FOUND!
|
||||||
|
Priority reading order:
|
||||||
|
1. **Start here:** [Login/Auth](https://documentation.n-able.com/covedataprotection/USERGUIDE/documentation/Content/unused/service-management/json-api/login.htm) - How to authenticate with your token
|
||||||
|
2. **Then read:** [Construct a Call](https://documentation.n-able.com/covedataprotection/USERGUIDE/documentation/Content/unused/service-management/json-api/construct-a-call.htm) - Request format
|
||||||
|
3. **Key endpoint:** [Enumerate Device Statistics](https://documentation.n-able.com/covedataprotection/USERGUIDE/documentation/Content/unused/service-management/json-api/enumerate-device-statistics.htm) - This likely has backup job data!
|
||||||
|
|
||||||
|
**What to extract from docs:**
|
||||||
|
- API base URL/endpoint
|
||||||
|
- Request format (JSON-RPC? REST? POST body structure?)
|
||||||
|
- How to use the token in requests
|
||||||
|
- Response format examples
|
||||||
|
- Which fields contain backup status/results
|
||||||
|
|
||||||
|
**2. Quick API Test with Postman** (can be done now with token!)
|
||||||
|
|
||||||
|
### Postman Setup Instructions
|
||||||
|
|
||||||
|
**Step 1: Create New Request**
|
||||||
|
1. Open Postman
|
||||||
|
2. Click "New" → "HTTP Request"
|
||||||
|
3. Name it "Cove API - Test Authentication"
|
||||||
|
|
||||||
|
**Step 2: Configure Request**
|
||||||
|
- **Method:** GET
|
||||||
|
- **URL:** Try these in order:
|
||||||
|
1. `https://api.backup.management/api/accounts`
|
||||||
|
2. `https://backup.management/api/accounts`
|
||||||
|
3. `https://api.backup.management/api/customers`
|
||||||
|
|
||||||
|
**Step 3: Add Authentication (try both methods)**
|
||||||
|
|
||||||
|
**Option A: Bearer Token**
|
||||||
|
- Go to "Authorization" tab
|
||||||
|
- Type: "Bearer Token"
|
||||||
|
- Token: `YOUR_TOKEN` (paste token from backup.management)
|
||||||
|
|
||||||
|
**Option B: API Key in Header**
|
||||||
|
- Go to "Headers" tab
|
||||||
|
- Add header:
|
||||||
|
- Key: `X-API-Key`
|
||||||
|
- Value: `YOUR_TOKEN`
|
||||||
|
|
||||||
|
**Step 4: Send Request and Analyze Response**
|
||||||
|
|
||||||
|
**Expected Results:**
|
||||||
|
- ✅ **200 OK** → Success! API works, save this configuration
|
||||||
|
- Copy the JSON response → we'll analyze structure
|
||||||
|
- Note which URL and auth method worked
|
||||||
|
- Check for pagination info in response
|
||||||
|
|
||||||
|
- ❌ **401 Unauthorized** → Wrong auth method
|
||||||
|
- Try other authentication option (Bearer vs X-API-Key)
|
||||||
|
- Check if token was copied correctly
|
||||||
|
|
||||||
|
- ❌ **404 Not Found** → Wrong endpoint URL
|
||||||
|
- Try alternative base URL (api.backup.management vs backup.management)
|
||||||
|
- Try different endpoint (/api/customers, /api/devices)
|
||||||
|
|
||||||
|
- ❌ **403 Forbidden** → Token works but insufficient permissions
|
||||||
|
- Verify API user has SuperUser role
|
||||||
|
- Check customer scope selection
|
||||||
|
|
||||||
|
**Step 5: Discover Available Endpoints**
|
||||||
|
|
||||||
|
Once authentication works, try these endpoints:
|
||||||
|
```
|
||||||
|
GET /api/accounts
|
||||||
|
GET /api/customers
|
||||||
|
GET /api/devices
|
||||||
|
GET /api/jobs
|
||||||
|
GET /api/statistics
|
||||||
|
GET /api/sessions
|
||||||
|
```
|
||||||
|
|
||||||
|
For each successful endpoint, save:
|
||||||
|
- The request in Postman collection
|
||||||
|
- Example response in TODO or separate file
|
||||||
|
- Note any query parameters (page, limit, filter, etc.)
|
||||||
|
|
||||||
|
**Step 6: Look for API Documentation**
|
||||||
|
|
||||||
|
Try these URLs in browser or Postman:
|
||||||
|
- `https://api.backup.management/swagger`
|
||||||
|
- `https://api.backup.management/docs`
|
||||||
|
- `https://api.backup.management/api-docs`
|
||||||
|
- `https://backup.management/api/documentation`
|
||||||
|
|
||||||
|
**Step 7: Document Findings**
|
||||||
|
|
||||||
|
After successful testing, document in this TODO:
|
||||||
|
- ✅ Working API base URL
|
||||||
|
- ✅ Correct authentication method (Bearer vs header)
|
||||||
|
- ✅ List of available endpoints discovered
|
||||||
|
- ✅ JSON response structure examples
|
||||||
|
- ✅ Any pagination/filtering patterns
|
||||||
|
- ✅ Rate limits (check response headers: X-RateLimit-*)
|
||||||
|
|
||||||
|
### Postman Tips for This Project
|
||||||
|
|
||||||
|
**Save Everything:**
|
||||||
|
- Create a "Cove API" collection in Postman
|
||||||
|
- Save all working requests
|
||||||
|
- Export collection to JSON for documentation
|
||||||
|
|
||||||
|
**Use Variables:**
|
||||||
|
- Create Postman environment "Cove Production"
|
||||||
|
- Add variable: `cove_token` = your token
|
||||||
|
- Add variable: `cove_base_url` = working base URL
|
||||||
|
- Use `{{cove_token}}` and `{{cove_base_url}}` in requests
|
||||||
|
|
||||||
|
**Check Response Headers:**
|
||||||
|
- Look for `X-RateLimit-Limit` (API call limits)
|
||||||
|
- Look for `X-RateLimit-Remaining` (calls left)
|
||||||
|
- Look for `Link` header (pagination)
|
||||||
|
|
||||||
|
**Save Response Examples:**
|
||||||
|
- For each endpoint, save example response
|
||||||
|
- Use Postman's "Save Response" feature
|
||||||
|
- Or copy JSON to separate file for reference
|
||||||
|
|
||||||
|
**3. Document Findings**
|
||||||
|
|
||||||
|
**After successful Postman testing, update this TODO with:**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## ✅ API Testing Results (Add after testing)
|
||||||
|
|
||||||
|
### Working Configuration
|
||||||
|
- **Base URL:** [fill in]
|
||||||
|
- **Authentication:** Bearer Token / X-API-Key header (circle one)
|
||||||
|
- **Token Location:** Authorization header / X-API-Key header (circle one)
|
||||||
|
|
||||||
|
### Available Endpoints Discovered
|
||||||
|
| Endpoint | Method | Purpose | Response Fields |
|
||||||
|
|----------|--------|---------|-----------------|
|
||||||
|
| /api/accounts | GET | List accounts | [list key fields] |
|
||||||
|
| /api/customers | GET | List customers | [list key fields] |
|
||||||
|
| /api/devices | GET | List backup devices | [list key fields] |
|
||||||
|
| /api/jobs | GET | List backup jobs | [list key fields] |
|
||||||
|
|
||||||
|
### Key Response Fields for Backupchecks Integration
|
||||||
|
From backup job/session endpoint:
|
||||||
|
- Job ID: `[field name]`
|
||||||
|
- Job Name: `[field name]`
|
||||||
|
- Status: `[field name]` (values: success/warning/failed)
|
||||||
|
- Start Time: `[field name]`
|
||||||
|
- End Time: `[field name]`
|
||||||
|
- Customer/Device: `[field name]`
|
||||||
|
- Error Messages: `[field name]`
|
||||||
|
- Backup Objects: `[field name or nested path]`
|
||||||
|
|
||||||
|
### Pagination
|
||||||
|
- Method: [Link headers / page parameter / cursor / none]
|
||||||
|
- Page size: [default and max]
|
||||||
|
- Total count: [available in response?]
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
- Limit: [X requests per Y time]
|
||||||
|
- Headers: [X-RateLimit-* header names]
|
||||||
|
|
||||||
|
### API Documentation URL
|
||||||
|
- [URL if found, or "Not found" if unavailable]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Save Postman Collection:**
|
||||||
|
- Export collection as JSON
|
||||||
|
- Save to: `/docker/develop/backupchecks/docs/cove-api-postman-collection.json`
|
||||||
|
- Or share Postman workspace link in this TODO
|
||||||
|
|
||||||
|
**4. Create POC Script**
|
||||||
|
Once API works, create standalone Python test script:
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Test script to retrieve Cove backup data
|
||||||
|
token = "YOUR_TOKEN"
|
||||||
|
base_url = "https://api.example.com"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get list of customers
|
||||||
|
response = requests.get(f"{base_url}/api/customers", headers=headers)
|
||||||
|
print(response.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Plan Integration**
|
||||||
|
Based on POC results, decide architecture approach and start implementation
|
||||||
|
|
||||||
|
**Status:** Ready for API testing - token available!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Notes
|
||||||
|
|
||||||
|
- This TODO document should be updated after each research step
|
||||||
|
- Add API examples as soon as available
|
||||||
|
- Document edge cases and limitations
|
||||||
|
- Consider security implications (API key storage, rate limits, etc.)
|
||||||
|
|
||||||
|
### Current Status (2026-02-10)
|
||||||
|
- ✅ **Confirmed:** Cove Data Protection HAS API access (mentioned in documentation)
|
||||||
|
- ✅ **Found:** API user creation location in Cove portal
|
||||||
|
- ✅ **Created:** API user with SuperUser role and token
|
||||||
|
- ✅ **Found:** Complete JSON API documentation (N-able docs site)
|
||||||
|
- ✅ **Tested:** API authentication and multiple methods (with ChatGPT assistance)
|
||||||
|
- ⚠️ **CRITICAL LIMITATION DISCOVERED:** API heavily restricted by column allow-list
|
||||||
|
- ⚠️ **BLOCKER:** No reliable backup status (success/failed/warning) available via API
|
||||||
|
- ⚠️ **BLOCKER:** No error messages, timestamps, or detailed run information accessible
|
||||||
|
- 🎯 **Next decision:** Determine if metrics-only integration is valuable OR contact N-able for expanded access
|
||||||
|
|
||||||
|
### Test Results Summary (see docs/cove_data_protection_api_calls_known_info.md)
|
||||||
|
- **Endpoint:** https://api.backup.management/jsonapi (JSON-RPC 2.0)
|
||||||
|
- **Authentication:** Login method → visa token → include in all subsequent calls
|
||||||
|
- **Working method:** EnumerateAccountStatistics (with limited columns)
|
||||||
|
- **Blocked method:** EnumerateAccounts (security error 13501)
|
||||||
|
- **Safe columns:** I1, I14, I18, D01F00-D01F07, D09F00
|
||||||
|
- **Restricted columns:** D02Fxx, D03Fxx ranges (cause entire request to fail)
|
||||||
|
- **Scope limitation:** API users are customer-scoped, not MSP-level
|
||||||
|
|
||||||
|
### API Credentials (Created)
|
||||||
|
- **Authentication:** Token-based
|
||||||
|
- **Role:** SuperUser (full access)
|
||||||
|
- **Scope:** Top-level customer (access to all sub-customers)
|
||||||
|
- **Token:** Generated (store securely!)
|
||||||
|
- **Portal URL:** https://backup.management
|
||||||
|
- **API User Management:** https://backup.management/#/api-users
|
||||||
|
|
||||||
|
**IMPORTANT:** Store token in secure location (password manager) - cannot be retrieved again if lost!
|
||||||
|
|
||||||
|
### Likely API Base URLs to Test
|
||||||
|
Based on portal URL `backup.management`:
|
||||||
|
1. `https://api.backup.management` (most common pattern)
|
||||||
|
2. `https://backup.management/api`
|
||||||
|
3. `https://api.backup.management/jsonapi` (some backup systems use this)
|
||||||
|
4. Check API user page for hints or documentation links
|
||||||
|
|
||||||
|
### Possible Admin Portal Locations
|
||||||
|
Check these sections in Cove dashboard:
|
||||||
|
- Settings → API Keys / Developer
|
||||||
|
- Settings → Integrations
|
||||||
|
- Account → API Access
|
||||||
|
- Partner Portal → API Management
|
||||||
|
- Company Settings → Advanced → API
|
||||||
|
|
||||||
|
### Support Channels
|
||||||
|
If API activation is not obvious:
|
||||||
|
- Cove support ticket: Ask "How do I enable API access for backup monitoring?"
|
||||||
|
- N-able partner support (if MSP)
|
||||||
|
- Check Cove community forums
|
||||||
|
- Review onboarding documentation for API mentions
|
||||||
@ -16,9 +16,11 @@ def api_job_run_alerts(run_id: int):
|
|||||||
tickets = []
|
tickets = []
|
||||||
remarks = []
|
remarks = []
|
||||||
|
|
||||||
# Tickets linked to this specific run
|
# Tickets linked to this run:
|
||||||
# Only show tickets that were explicitly linked via ticket_job_runs
|
# 1. Explicitly linked via ticket_job_runs (audit trail when resolved)
|
||||||
|
# 2. Linked to the job via ticket_scopes (active on run date)
|
||||||
try:
|
try:
|
||||||
|
# First, get tickets explicitly linked to this run via ticket_job_runs
|
||||||
rows = (
|
rows = (
|
||||||
db.session.execute(
|
db.session.execute(
|
||||||
text(
|
text(
|
||||||
@ -43,7 +45,11 @@ def api_job_run_alerts(run_id: int):
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ticket_ids_seen = set()
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
ticket_id = int(r.get("id"))
|
||||||
|
ticket_ids_seen.add(ticket_id)
|
||||||
|
|
||||||
resolved_at = r.get("resolved_at")
|
resolved_at = r.get("resolved_at")
|
||||||
resolved_same_day = False
|
resolved_same_day = False
|
||||||
if resolved_at and run_date:
|
if resolved_at and run_date:
|
||||||
@ -52,7 +58,62 @@ def api_job_run_alerts(run_id: int):
|
|||||||
|
|
||||||
tickets.append(
|
tickets.append(
|
||||||
{
|
{
|
||||||
"id": int(r.get("id")),
|
"id": ticket_id,
|
||||||
|
"ticket_code": r.get("ticket_code") or "",
|
||||||
|
"description": r.get("description") or "",
|
||||||
|
"start_date": _format_datetime(r.get("start_date")),
|
||||||
|
"active_from_date": str(r.get("active_from_date")) if r.get("active_from_date") else "",
|
||||||
|
"resolved_at": _format_datetime(r.get("resolved_at")) if r.get("resolved_at") else "",
|
||||||
|
"active": bool(active_now),
|
||||||
|
"resolved_same_day": bool(resolved_same_day),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Second, get tickets linked to the job via ticket_scopes
|
||||||
|
# These are tickets that apply to the whole job (not just a specific run)
|
||||||
|
rows = (
|
||||||
|
db.session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT t.id,
|
||||||
|
t.ticket_code,
|
||||||
|
t.description,
|
||||||
|
t.start_date,
|
||||||
|
t.resolved_at,
|
||||||
|
t.active_from_date
|
||||||
|
FROM tickets t
|
||||||
|
JOIN ticket_scopes ts ON ts.ticket_id = t.id
|
||||||
|
WHERE ts.job_id = :job_id
|
||||||
|
AND t.active_from_date <= :run_date
|
||||||
|
AND COALESCE(ts.resolved_at, t.resolved_at) IS NULL
|
||||||
|
ORDER BY t.start_date DESC
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"job_id": job.id if job else 0,
|
||||||
|
"run_date": run_date,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
ticket_id = int(r.get("id"))
|
||||||
|
# Skip if already added via ticket_job_runs
|
||||||
|
if ticket_id in ticket_ids_seen:
|
||||||
|
continue
|
||||||
|
ticket_ids_seen.add(ticket_id)
|
||||||
|
|
||||||
|
resolved_at = r.get("resolved_at")
|
||||||
|
resolved_same_day = False
|
||||||
|
if resolved_at and run_date:
|
||||||
|
resolved_same_day = _to_amsterdam_date(resolved_at) == run_date
|
||||||
|
active_now = r.get("resolved_at") is None
|
||||||
|
|
||||||
|
tickets.append(
|
||||||
|
{
|
||||||
|
"id": ticket_id,
|
||||||
"ticket_code": r.get("ticket_code") or "",
|
"ticket_code": r.get("ticket_code") or "",
|
||||||
"description": r.get("description") or "",
|
"description": r.get("description") or "",
|
||||||
"start_date": _format_datetime(r.get("start_date")),
|
"start_date": _format_datetime(r.get("start_date")),
|
||||||
|
|||||||
@ -1,5 +1,53 @@
|
|||||||
from .routes_shared import * # noqa: F401,F403
|
from .routes_shared import * # noqa: F401,F403
|
||||||
from .routes_shared import _format_datetime
|
from .routes_shared import _format_datetime
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
import imghdr
|
||||||
|
|
||||||
|
|
||||||
|
# Allowed image extensions and max file size
|
||||||
|
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
|
||||||
|
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_image_file(file):
|
||||||
|
"""Validate uploaded image file.
|
||||||
|
|
||||||
|
Returns (is_valid, error_message, mime_type)
|
||||||
|
"""
|
||||||
|
if not file or not file.filename:
|
||||||
|
return False, "No file selected", None
|
||||||
|
|
||||||
|
# Check file size
|
||||||
|
file.seek(0, 2) # Seek to end
|
||||||
|
size = file.tell()
|
||||||
|
file.seek(0) # Reset to beginning
|
||||||
|
|
||||||
|
if size > MAX_FILE_SIZE:
|
||||||
|
return False, f"File too large (max {MAX_FILE_SIZE // (1024*1024)}MB)", None
|
||||||
|
|
||||||
|
if size == 0:
|
||||||
|
return False, "Empty file", None
|
||||||
|
|
||||||
|
# Check extension
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
if '.' not in filename:
|
||||||
|
return False, "File must have an extension", None
|
||||||
|
|
||||||
|
ext = filename.rsplit('.', 1)[1].lower()
|
||||||
|
if ext not in ALLOWED_EXTENSIONS:
|
||||||
|
return False, f"Only images allowed ({', '.join(ALLOWED_EXTENSIONS)})", None
|
||||||
|
|
||||||
|
# Verify it's actually an image by reading header
|
||||||
|
file_data = file.read()
|
||||||
|
file.seek(0)
|
||||||
|
|
||||||
|
image_type = imghdr.what(None, h=file_data)
|
||||||
|
if image_type is None:
|
||||||
|
return False, "Invalid image file", None
|
||||||
|
|
||||||
|
mime_type = f"image/{image_type}"
|
||||||
|
|
||||||
|
return True, None, mime_type
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route("/feedback")
|
@main_bp.route("/feedback")
|
||||||
@ -21,7 +69,14 @@ def feedback_page():
|
|||||||
if sort not in ("votes", "newest", "updated"):
|
if sort not in ("votes", "newest", "updated"):
|
||||||
sort = "votes"
|
sort = "votes"
|
||||||
|
|
||||||
where = ["fi.deleted_at IS NULL"]
|
# Admin-only: show deleted items
|
||||||
|
show_deleted = False
|
||||||
|
if get_active_role() == "admin":
|
||||||
|
show_deleted = request.args.get("show_deleted", "0") in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
where = []
|
||||||
|
if not show_deleted:
|
||||||
|
where.append("fi.deleted_at IS NULL")
|
||||||
params = {"user_id": int(current_user.id)}
|
params = {"user_id": int(current_user.id)}
|
||||||
|
|
||||||
if item_type:
|
if item_type:
|
||||||
@ -58,6 +113,8 @@ def feedback_page():
|
|||||||
fi.status,
|
fi.status,
|
||||||
fi.created_at,
|
fi.created_at,
|
||||||
fi.updated_at,
|
fi.updated_at,
|
||||||
|
fi.deleted_at,
|
||||||
|
fi.deleted_by_user_id,
|
||||||
u.username AS created_by,
|
u.username AS created_by,
|
||||||
COALESCE(v.vote_count, 0) AS vote_count,
|
COALESCE(v.vote_count, 0) AS vote_count,
|
||||||
EXISTS (
|
EXISTS (
|
||||||
@ -95,6 +152,8 @@ def feedback_page():
|
|||||||
"created_by": r["created_by"] or "-",
|
"created_by": r["created_by"] or "-",
|
||||||
"vote_count": int(r["vote_count"] or 0),
|
"vote_count": int(r["vote_count"] or 0),
|
||||||
"user_voted": bool(r["user_voted"]),
|
"user_voted": bool(r["user_voted"]),
|
||||||
|
"is_deleted": bool(r["deleted_at"]),
|
||||||
|
"deleted_at": _format_datetime(r["deleted_at"]) if r["deleted_at"] else "",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -105,6 +164,7 @@ def feedback_page():
|
|||||||
status=status,
|
status=status,
|
||||||
q=q,
|
q=q,
|
||||||
sort=sort,
|
sort=sort,
|
||||||
|
show_deleted=show_deleted,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -135,6 +195,31 @@ def feedback_new():
|
|||||||
created_by_user_id=int(current_user.id),
|
created_by_user_id=int(current_user.id),
|
||||||
)
|
)
|
||||||
db.session.add(item)
|
db.session.add(item)
|
||||||
|
db.session.flush() # Get item.id for attachments
|
||||||
|
|
||||||
|
# Handle file uploads (multiple files allowed)
|
||||||
|
files = request.files.getlist('screenshots')
|
||||||
|
for file in files:
|
||||||
|
if file and file.filename:
|
||||||
|
is_valid, error_msg, mime_type = _validate_image_file(file)
|
||||||
|
if not is_valid:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f"Screenshot error: {error_msg}", "danger")
|
||||||
|
return redirect(url_for("main.feedback_new"))
|
||||||
|
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
file_data = file.read()
|
||||||
|
|
||||||
|
attachment = FeedbackAttachment(
|
||||||
|
feedback_item_id=item.id,
|
||||||
|
feedback_reply_id=None,
|
||||||
|
filename=filename,
|
||||||
|
file_data=file_data,
|
||||||
|
mime_type=mime_type,
|
||||||
|
file_size=len(file_data),
|
||||||
|
)
|
||||||
|
db.session.add(attachment)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
flash("Feedback item created.", "success")
|
flash("Feedback item created.", "success")
|
||||||
@ -148,7 +233,8 @@ def feedback_new():
|
|||||||
@roles_required("admin", "operator", "reporter", "viewer")
|
@roles_required("admin", "operator", "reporter", "viewer")
|
||||||
def feedback_detail(item_id: int):
|
def feedback_detail(item_id: int):
|
||||||
item = FeedbackItem.query.get_or_404(item_id)
|
item = FeedbackItem.query.get_or_404(item_id)
|
||||||
if item.deleted_at is not None:
|
# Allow admins to view deleted items
|
||||||
|
if item.deleted_at is not None and get_active_role() != "admin":
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
vote_count = (
|
vote_count = (
|
||||||
@ -174,6 +260,15 @@ def feedback_detail(item_id: int):
|
|||||||
resolved_by = User.query.get(item.resolved_by_user_id)
|
resolved_by = User.query.get(item.resolved_by_user_id)
|
||||||
resolved_by_name = resolved_by.username if resolved_by else ""
|
resolved_by_name = resolved_by.username if resolved_by else ""
|
||||||
|
|
||||||
|
# Get attachments for the main item (not linked to a reply)
|
||||||
|
item_attachments = (
|
||||||
|
FeedbackAttachment.query.filter(
|
||||||
|
FeedbackAttachment.feedback_item_id == item.id,
|
||||||
|
FeedbackAttachment.feedback_reply_id.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(FeedbackAttachment.created_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
replies = (
|
replies = (
|
||||||
FeedbackReply.query.filter(FeedbackReply.feedback_item_id == item.id)
|
FeedbackReply.query.filter(FeedbackReply.feedback_item_id == item.id)
|
||||||
@ -181,6 +276,25 @@ def feedback_detail(item_id: int):
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Get attachments for each reply
|
||||||
|
reply_ids = [r.id for r in replies]
|
||||||
|
reply_attachments_list = []
|
||||||
|
if reply_ids:
|
||||||
|
reply_attachments_list = (
|
||||||
|
FeedbackAttachment.query.filter(
|
||||||
|
FeedbackAttachment.feedback_reply_id.in_(reply_ids)
|
||||||
|
)
|
||||||
|
.order_by(FeedbackAttachment.created_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Map reply_id -> list of attachments
|
||||||
|
reply_attachments_map = {}
|
||||||
|
for att in reply_attachments_list:
|
||||||
|
if att.feedback_reply_id not in reply_attachments_map:
|
||||||
|
reply_attachments_map[att.feedback_reply_id] = []
|
||||||
|
reply_attachments_map[att.feedback_reply_id].append(att)
|
||||||
|
|
||||||
reply_user_ids = sorted({int(r.user_id) for r in replies})
|
reply_user_ids = sorted({int(r.user_id) for r in replies})
|
||||||
reply_users = (
|
reply_users = (
|
||||||
User.query.filter(User.id.in_(reply_user_ids)).all() if reply_user_ids else []
|
User.query.filter(User.id.in_(reply_user_ids)).all() if reply_user_ids else []
|
||||||
@ -196,6 +310,8 @@ def feedback_detail(item_id: int):
|
|||||||
user_voted=bool(user_voted),
|
user_voted=bool(user_voted),
|
||||||
replies=replies,
|
replies=replies,
|
||||||
reply_user_map=reply_user_map,
|
reply_user_map=reply_user_map,
|
||||||
|
item_attachments=item_attachments,
|
||||||
|
reply_attachments_map=reply_attachments_map,
|
||||||
)
|
)
|
||||||
|
|
||||||
@main_bp.route("/feedback/<int:item_id>/reply", methods=["POST"])
|
@main_bp.route("/feedback/<int:item_id>/reply", methods=["POST"])
|
||||||
@ -222,6 +338,31 @@ def feedback_reply(item_id: int):
|
|||||||
created_at=datetime.utcnow(),
|
created_at=datetime.utcnow(),
|
||||||
)
|
)
|
||||||
db.session.add(reply)
|
db.session.add(reply)
|
||||||
|
db.session.flush() # Get reply.id for attachments
|
||||||
|
|
||||||
|
# Handle file uploads (multiple files allowed)
|
||||||
|
files = request.files.getlist('screenshots')
|
||||||
|
for file in files:
|
||||||
|
if file and file.filename:
|
||||||
|
is_valid, error_msg, mime_type = _validate_image_file(file)
|
||||||
|
if not is_valid:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f"Screenshot error: {error_msg}", "danger")
|
||||||
|
return redirect(url_for("main.feedback_detail", item_id=item.id))
|
||||||
|
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
file_data = file.read()
|
||||||
|
|
||||||
|
attachment = FeedbackAttachment(
|
||||||
|
feedback_item_id=item.id,
|
||||||
|
feedback_reply_id=reply.id,
|
||||||
|
filename=filename,
|
||||||
|
file_data=file_data,
|
||||||
|
mime_type=mime_type,
|
||||||
|
file_size=len(file_data),
|
||||||
|
)
|
||||||
|
db.session.add(attachment)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
flash("Reply added.", "success")
|
flash("Reply added.", "success")
|
||||||
@ -308,3 +449,60 @@ def feedback_delete(item_id: int):
|
|||||||
|
|
||||||
flash("Feedback item deleted.", "success")
|
flash("Feedback item deleted.", "success")
|
||||||
return redirect(url_for("main.feedback_page"))
|
return redirect(url_for("main.feedback_page"))
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/feedback/<int:item_id>/permanent-delete", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@roles_required("admin")
|
||||||
|
def feedback_permanent_delete(item_id: int):
|
||||||
|
"""Permanently delete a feedback item and all its attachments from the database.
|
||||||
|
|
||||||
|
This is a hard delete - the item and all associated data will be removed permanently.
|
||||||
|
Only available for items that are already soft-deleted.
|
||||||
|
"""
|
||||||
|
item = FeedbackItem.query.get_or_404(item_id)
|
||||||
|
|
||||||
|
# Only allow permanent delete on already soft-deleted items
|
||||||
|
if item.deleted_at is None:
|
||||||
|
flash("Item must be deleted first before permanent deletion.", "warning")
|
||||||
|
return redirect(url_for("main.feedback_detail", item_id=item.id))
|
||||||
|
|
||||||
|
# Get attachment count for feedback message
|
||||||
|
attachment_count = FeedbackAttachment.query.filter_by(feedback_item_id=item.id).count()
|
||||||
|
|
||||||
|
# Hard delete - CASCADE will automatically delete:
|
||||||
|
# - feedback_votes
|
||||||
|
# - feedback_replies
|
||||||
|
# - feedback_attachments (via replies CASCADE)
|
||||||
|
# - feedback_attachments (direct, via item CASCADE)
|
||||||
|
db.session.delete(item)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(f"Feedback item permanently deleted ({attachment_count} screenshot(s) removed).", "success")
|
||||||
|
return redirect(url_for("main.feedback_page", show_deleted="1"))
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/feedback/attachment/<int:attachment_id>")
|
||||||
|
@login_required
|
||||||
|
@roles_required("admin", "operator", "reporter", "viewer")
|
||||||
|
def feedback_attachment(attachment_id: int):
|
||||||
|
"""Serve a feedback attachment image."""
|
||||||
|
attachment = FeedbackAttachment.query.get_or_404(attachment_id)
|
||||||
|
|
||||||
|
# Check if the feedback item is deleted - allow admins to view
|
||||||
|
item = FeedbackItem.query.get(attachment.feedback_item_id)
|
||||||
|
if not item:
|
||||||
|
abort(404)
|
||||||
|
if item.deleted_at is not None and get_active_role() != "admin":
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Serve the image
|
||||||
|
from flask import send_file
|
||||||
|
import io
|
||||||
|
|
||||||
|
return send_file(
|
||||||
|
io.BytesIO(attachment.file_data),
|
||||||
|
mimetype=attachment.mime_type,
|
||||||
|
as_attachment=False,
|
||||||
|
download_name=attachment.filename,
|
||||||
|
)
|
||||||
|
|||||||
@ -38,6 +38,7 @@ from ..models import (
|
|||||||
TicketScope,
|
TicketScope,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
from ..ticketing_utils import link_open_internal_tickets_to_run
|
||||||
|
|
||||||
|
|
||||||
AUTOTASK_TERMINAL_STATUS_IDS = {5}
|
AUTOTASK_TERMINAL_STATUS_IDS = {5}
|
||||||
@ -725,6 +726,8 @@ def _ensure_missed_runs_for_job(job: Job, start_from: date, end_inclusive: date)
|
|||||||
mail_message_id=None,
|
mail_message_id=None,
|
||||||
)
|
)
|
||||||
db.session.add(miss)
|
db.session.add(miss)
|
||||||
|
db.session.flush() # Ensure miss.id is available for ticket linking
|
||||||
|
link_open_internal_tickets_to_run(run=miss, job=job)
|
||||||
inserted += 1
|
inserted += 1
|
||||||
|
|
||||||
d = d + timedelta(days=1)
|
d = d + timedelta(days=1)
|
||||||
@ -806,6 +809,8 @@ def _ensure_missed_runs_for_job(job: Job, start_from: date, end_inclusive: date)
|
|||||||
mail_message_id=None,
|
mail_message_id=None,
|
||||||
)
|
)
|
||||||
db.session.add(miss)
|
db.session.add(miss)
|
||||||
|
db.session.flush() # Ensure miss.id is available for ticket linking
|
||||||
|
link_open_internal_tickets_to_run(run=miss, job=job)
|
||||||
inserted += 1
|
inserted += 1
|
||||||
|
|
||||||
# Next month
|
# Next month
|
||||||
|
|||||||
@ -52,6 +52,7 @@ from ..models import (
|
|||||||
FeedbackItem,
|
FeedbackItem,
|
||||||
FeedbackVote,
|
FeedbackVote,
|
||||||
FeedbackReply,
|
FeedbackReply,
|
||||||
|
FeedbackAttachment,
|
||||||
NewsItem,
|
NewsItem,
|
||||||
NewsRead,
|
NewsRead,
|
||||||
ReportDefinition,
|
ReportDefinition,
|
||||||
|
|||||||
@ -1095,6 +1095,7 @@ def run_migrations() -> None:
|
|||||||
migrate_object_persistence_tables()
|
migrate_object_persistence_tables()
|
||||||
migrate_feedback_tables()
|
migrate_feedback_tables()
|
||||||
migrate_feedback_replies_table()
|
migrate_feedback_replies_table()
|
||||||
|
migrate_feedback_attachments_table()
|
||||||
migrate_tickets_active_from_date()
|
migrate_tickets_active_from_date()
|
||||||
migrate_tickets_resolved_origin()
|
migrate_tickets_resolved_origin()
|
||||||
migrate_remarks_active_from_date()
|
migrate_remarks_active_from_date()
|
||||||
@ -1446,6 +1447,49 @@ def migrate_feedback_replies_table() -> None:
|
|||||||
print("[migrations] Feedback replies table ensured.")
|
print("[migrations] Feedback replies table ensured.")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_feedback_attachments_table() -> None:
|
||||||
|
"""Ensure feedback attachments table exists.
|
||||||
|
|
||||||
|
Table:
|
||||||
|
- feedback_attachments (screenshots/images for feedback items and replies)
|
||||||
|
"""
|
||||||
|
engine = db.get_engine()
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS feedback_attachments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
feedback_item_id INTEGER NOT NULL REFERENCES feedback_items(id) ON DELETE CASCADE,
|
||||||
|
feedback_reply_id INTEGER REFERENCES feedback_replies(id) ON DELETE CASCADE,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
file_data BYTEA NOT NULL,
|
||||||
|
mime_type VARCHAR(64) NOT NULL,
|
||||||
|
file_size INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_feedback_attachments_item
|
||||||
|
ON feedback_attachments (feedback_item_id);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_feedback_attachments_reply
|
||||||
|
ON feedback_attachments (feedback_reply_id);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print("[migrations] Feedback attachments table ensured.")
|
||||||
|
|
||||||
|
|
||||||
def migrate_tickets_active_from_date() -> None:
|
def migrate_tickets_active_from_date() -> None:
|
||||||
"""Ensure tickets.active_from_date exists and is populated.
|
"""Ensure tickets.active_from_date exists and is populated.
|
||||||
|
|
||||||
|
|||||||
@ -567,6 +567,23 @@ class FeedbackReply(db.Model):
|
|||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackAttachment(db.Model):
|
||||||
|
__tablename__ = "feedback_attachments"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
feedback_item_id = db.Column(
|
||||||
|
db.Integer, db.ForeignKey("feedback_items.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
feedback_reply_id = db.Column(
|
||||||
|
db.Integer, db.ForeignKey("feedback_replies.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
filename = db.Column(db.String(255), nullable=False)
|
||||||
|
file_data = db.Column(db.LargeBinary, nullable=False)
|
||||||
|
mime_type = db.Column(db.String(64), nullable=False)
|
||||||
|
file_size = db.Column(db.Integer, nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class NewsItem(db.Model):
|
class NewsItem(db.Model):
|
||||||
__tablename__ = "news_items"
|
__tablename__ = "news_items"
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,16 @@
|
|||||||
<div class="col-6 col-md-3">
|
<div class="col-6 col-md-3">
|
||||||
<button class="btn btn-outline-secondary" type="submit">Apply</button>
|
<button class="btn btn-outline-secondary" type="submit">Apply</button>
|
||||||
</div>
|
</div>
|
||||||
|
{% if active_role == 'admin' %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="show_deleted" value="1" id="show_deleted" {% if show_deleted %}checked{% endif %} onchange="this.form.submit()">
|
||||||
|
<label class="form-check-label" for="show_deleted">
|
||||||
|
Show deleted items
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@ -46,6 +56,9 @@
|
|||||||
<th style="width: 160px;">Component</th>
|
<th style="width: 160px;">Component</th>
|
||||||
<th style="width: 120px;">Status</th>
|
<th style="width: 120px;">Status</th>
|
||||||
<th style="width: 170px;">Created</th>
|
<th style="width: 170px;">Created</th>
|
||||||
|
{% if active_role == 'admin' and show_deleted %}
|
||||||
|
<th style="width: 140px;">Actions</th>
|
||||||
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -56,20 +69,30 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for i in items %}
|
{% for i in items %}
|
||||||
<tr>
|
<tr {% if i.is_deleted %}style="opacity: 0.6; background-color: var(--bs-secondary-bg);"{% endif %}>
|
||||||
<td>
|
<td>
|
||||||
|
{% if not i.is_deleted %}
|
||||||
<form method="post" action="{{ url_for('main.feedback_vote', item_id=i.id) }}">
|
<form method="post" action="{{ url_for('main.feedback_vote', item_id=i.id) }}">
|
||||||
<input type="hidden" name="ref" value="list" />
|
<input type="hidden" name="ref" value="list" />
|
||||||
<button type="submit" class="btn btn-sm {% if i.user_voted %}btn-success{% else %}btn-outline-secondary{% endif %}">
|
<button type="submit" class="btn btn-sm {% if i.user_voted %}btn-success{% else %}btn-outline-secondary{% endif %}">
|
||||||
+ {{ i.vote_count }}
|
+ {{ i.vote_count }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">+ {{ i.vote_count }}</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ url_for('main.feedback_detail', item_id=i.id) }}">{{ i.title }}</a>
|
<a href="{{ url_for('main.feedback_detail', item_id=i.id) }}">{{ i.title }}</a>
|
||||||
|
{% if i.is_deleted %}
|
||||||
|
<span class="badge text-bg-dark ms-2">Deleted</span>
|
||||||
|
{% endif %}
|
||||||
{% if i.created_by %}
|
{% if i.created_by %}
|
||||||
<div class="text-muted" style="font-size: 0.85rem;">by {{ i.created_by }}</div>
|
<div class="text-muted" style="font-size: 0.85rem;">by {{ i.created_by }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if i.is_deleted and i.deleted_at %}
|
||||||
|
<div class="text-muted" style="font-size: 0.85rem;">Deleted {{ i.deleted_at|local_datetime }}</div>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if i.item_type == 'bug' %}
|
{% if i.item_type == 'bug' %}
|
||||||
@ -90,6 +113,15 @@
|
|||||||
<div>{{ i.created_at|local_datetime }}</div>
|
<div>{{ i.created_at|local_datetime }}</div>
|
||||||
<div class="text-muted" style="font-size: 0.85rem;">Updated {{ i.updated_at|local_datetime }}</div>
|
<div class="text-muted" style="font-size: 0.85rem;">Updated {{ i.updated_at|local_datetime }}</div>
|
||||||
</td>
|
</td>
|
||||||
|
{% if active_role == 'admin' and show_deleted %}
|
||||||
|
<td>
|
||||||
|
{% if i.is_deleted %}
|
||||||
|
<form method="post" action="{{ url_for('main.feedback_permanent_delete', item_id=i.id) }}" onsubmit="return confirm('Permanently delete this item and all screenshots? This cannot be undone!');">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">Permanent Delete</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@ -15,6 +15,9 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge text-bg-warning">Open</span>
|
<span class="badge text-bg-warning">Open</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if item.deleted_at %}
|
||||||
|
<span class="badge text-bg-dark">Deleted</span>
|
||||||
|
{% endif %}
|
||||||
<span class="ms-2">by {{ created_by_name }}</span>
|
<span class="ms-2">by {{ created_by_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -29,6 +32,23 @@
|
|||||||
<div class="mb-2"><strong>Component:</strong> {{ item.component }}</div>
|
<div class="mb-2"><strong>Component:</strong> {{ item.component }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div style="white-space: pre-wrap;">{{ item.description }}</div>
|
<div style="white-space: pre-wrap;">{{ item.description }}</div>
|
||||||
|
|
||||||
|
{% if item_attachments %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<strong>Screenshots:</strong>
|
||||||
|
<div class="d-flex flex-wrap gap-2 mt-2">
|
||||||
|
{% for att in item_attachments %}
|
||||||
|
<a href="{{ url_for('main.feedback_attachment', attachment_id=att.id) }}" target="_blank">
|
||||||
|
<img src="{{ url_for('main.feedback_attachment', attachment_id=att.id) }}"
|
||||||
|
alt="{{ att.filename }}"
|
||||||
|
class="img-thumbnail"
|
||||||
|
style="max-height: 200px; max-width: 300px; cursor: pointer;"
|
||||||
|
title="Click to view full size" />
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||||
<div class="text-muted" style="font-size: 0.9rem;">
|
<div class="text-muted" style="font-size: 0.9rem;">
|
||||||
@ -63,6 +83,22 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="white-space: pre-wrap;">{{ r.message }}</div>
|
<div style="white-space: pre-wrap;">{{ r.message }}</div>
|
||||||
|
|
||||||
|
{% if r.id in reply_attachments_map %}
|
||||||
|
<div class="mt-2">
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
{% for att in reply_attachments_map[r.id] %}
|
||||||
|
<a href="{{ url_for('main.feedback_attachment', attachment_id=att.id) }}" target="_blank">
|
||||||
|
<img src="{{ url_for('main.feedback_attachment', attachment_id=att.id) }}"
|
||||||
|
alt="{{ att.filename }}"
|
||||||
|
class="img-thumbnail"
|
||||||
|
style="max-height: 150px; max-width: 200px; cursor: pointer;"
|
||||||
|
title="Click to view full size" />
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@ -76,10 +112,15 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title mb-3">Add reply</h5>
|
<h5 class="card-title mb-3">Add reply</h5>
|
||||||
{% if item.status == 'open' %}
|
{% if item.status == 'open' %}
|
||||||
<form method="post" action="{{ url_for('main.feedback_reply', item_id=item.id) }}">
|
<form method="post" action="{{ url_for('main.feedback_reply', item_id=item.id) }}" enctype="multipart/form-data">
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<textarea class="form-control" name="message" rows="4" required></textarea>
|
<textarea class="form-control" name="message" rows="4" required></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Screenshots (optional)</label>
|
||||||
|
<input type="file" name="screenshots" class="form-control" multiple accept="image/png,image/jpeg,image/jpg,image/gif,image/webp" />
|
||||||
|
<div class="form-text">You can attach multiple screenshots (PNG, JPG, GIF, WEBP, max 5MB each)</div>
|
||||||
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Post reply</button>
|
<button type="submit" class="btn btn-primary">Post reply</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
@ -95,6 +136,16 @@
|
|||||||
<h2 class="h6">Actions</h2>
|
<h2 class="h6">Actions</h2>
|
||||||
|
|
||||||
{% if active_role == 'admin' %}
|
{% if active_role == 'admin' %}
|
||||||
|
{% if item.deleted_at %}
|
||||||
|
{# Item is deleted - show permanent delete option #}
|
||||||
|
<div class="alert alert-warning mb-2" style="font-size: 0.9rem;">
|
||||||
|
This item is deleted.
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('main.feedback_permanent_delete', item_id=item.id) }}" onsubmit="return confirm('Permanently delete this item and all screenshots? This cannot be undone!');">
|
||||||
|
<button type="submit" class="btn btn-danger w-100">Permanent Delete</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
{# Item is not deleted - show normal actions #}
|
||||||
{% if item.status == 'resolved' %}
|
{% if item.status == 'resolved' %}
|
||||||
<form method="post" action="{{ url_for('main.feedback_resolve', item_id=item.id) }}" class="mb-2">
|
<form method="post" action="{{ url_for('main.feedback_resolve', item_id=item.id) }}" class="mb-2">
|
||||||
<input type="hidden" name="action" value="reopen" />
|
<input type="hidden" name="action" value="reopen" />
|
||||||
@ -110,6 +161,7 @@
|
|||||||
<form method="post" action="{{ url_for('main.feedback_delete', item_id=item.id) }}" onsubmit="return confirm('Delete this item?');">
|
<form method="post" action="{{ url_for('main.feedback_delete', item_id=item.id) }}" onsubmit="return confirm('Delete this item?');">
|
||||||
<button type="submit" class="btn btn-danger w-100">Delete</button>
|
<button type="submit" class="btn btn-danger w-100">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-muted">Only administrators can resolve or delete items.</div>
|
<div class="text-muted">Only administrators can resolve or delete items.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
<a class="btn btn-outline-secondary" href="{{ url_for('main.feedback_page') }}">Back</a>
|
<a class="btn btn-outline-secondary" href="{{ url_for('main.feedback_page') }}">Back</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" class="card">
|
<form method="post" enctype="multipart/form-data" class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-12 col-md-3">
|
<div class="col-12 col-md-3">
|
||||||
@ -28,6 +28,11 @@
|
|||||||
<label class="form-label">Component (optional)</label>
|
<label class="form-label">Component (optional)</label>
|
||||||
<input type="text" name="component" class="form-control" />
|
<input type="text" name="component" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Screenshots (optional)</label>
|
||||||
|
<input type="file" name="screenshots" class="form-control" multiple accept="image/png,image/jpeg,image/jpg,image/gif,image/webp" />
|
||||||
|
<div class="form-text">You can attach multiple screenshots (PNG, JPG, GIF, WEBP, max 5MB each)</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer d-flex justify-content-end">
|
<div class="card-footer d-flex justify-content-end">
|
||||||
|
|||||||
@ -73,7 +73,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
{% if can_bulk_delete %}
|
{% if can_bulk_delete %}
|
||||||
<th scope="col" style="width: 34px;">
|
<th scope="col" style="width: 34px;">
|
||||||
<input class="form-check-input" type="checkbox" id="inbox_select_all" />
|
<input class="form-check-input" type="checkbox" id="inbox_select_all" autocomplete="off" />
|
||||||
</th>
|
</th>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<th scope="col">From</th>
|
<th scope="col">From</th>
|
||||||
@ -93,7 +93,7 @@
|
|||||||
<tr class="inbox-row" data-message-id="{{ row.id }}" style="cursor: pointer;">
|
<tr class="inbox-row" data-message-id="{{ row.id }}" style="cursor: pointer;">
|
||||||
{% if can_bulk_delete %}
|
{% if can_bulk_delete %}
|
||||||
<td onclick="event.stopPropagation();">
|
<td onclick="event.stopPropagation();">
|
||||||
<input class="form-check-input inbox_row_cb" type="checkbox" value="{{ row.id }}" />
|
<input class="form-check-input inbox_row_cb" type="checkbox" value="{{ row.id }}" autocomplete="off" />
|
||||||
</td>
|
</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td>{{ row.from_address }}</td>
|
<td>{{ row.from_address }}</td>
|
||||||
|
|||||||
@ -287,6 +287,60 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var currentRunId = null;
|
var currentRunId = null;
|
||||||
|
|
||||||
|
// Cross-browser copy to clipboard function
|
||||||
|
function copyToClipboard(text, button) {
|
||||||
|
// Method 1: Modern Clipboard API (works in most browsers with HTTPS)
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
.then(function () {
|
||||||
|
showCopyFeedback(button);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
// Fallback to method 2 if clipboard API fails
|
||||||
|
fallbackCopy(text, button);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Method 2: Legacy execCommand method
|
||||||
|
fallbackCopy(text, button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackCopy(text, button) {
|
||||||
|
var textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
textarea.style.top = '0';
|
||||||
|
textarea.style.left = '0';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
var successful = document.execCommand('copy');
|
||||||
|
if (successful) {
|
||||||
|
showCopyFeedback(button);
|
||||||
|
} else {
|
||||||
|
// If execCommand fails, use prompt as last resort
|
||||||
|
window.prompt('Copy ticket number:', text);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// If all else fails, show prompt
|
||||||
|
window.prompt('Copy ticket number:', text);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCopyFeedback(button) {
|
||||||
|
if (!button) return;
|
||||||
|
var original = button.textContent;
|
||||||
|
button.textContent = '✓';
|
||||||
|
setTimeout(function () {
|
||||||
|
button.textContent = original;
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
|
||||||
function apiJson(url, opts) {
|
function apiJson(url, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
opts.headers = opts.headers || {};
|
opts.headers = opts.headers || {};
|
||||||
@ -319,12 +373,14 @@
|
|||||||
html += '<div class="mb-2"><strong>Tickets</strong><div class="mt-1">';
|
html += '<div class="mb-2"><strong>Tickets</strong><div class="mt-1">';
|
||||||
tickets.forEach(function (t) {
|
tickets.forEach(function (t) {
|
||||||
var status = t.resolved_at ? 'Resolved' : 'Active';
|
var status = t.resolved_at ? 'Resolved' : 'Active';
|
||||||
|
var ticketCode = (t.ticket_code || '').toString();
|
||||||
html += '<div class="mb-2 border rounded p-2" data-alert-type="ticket" data-id="' + t.id + '">' +
|
html += '<div class="mb-2 border rounded p-2" data-alert-type="ticket" data-id="' + t.id + '">' +
|
||||||
'<div class="d-flex align-items-start justify-content-between gap-2">' +
|
'<div class="d-flex align-items-start justify-content-between gap-2">' +
|
||||||
'<div class="flex-grow-1 min-w-0">' +
|
'<div class="flex-grow-1 min-w-0">' +
|
||||||
'<div class="text-truncate">' +
|
'<div class="text-truncate">' +
|
||||||
'<span class="me-1" title="Ticket">🎫</span>' +
|
'<span class="me-1" title="Ticket">🎫</span>' +
|
||||||
'<span class="fw-semibold">' + escapeHtml(t.ticket_code || '') + '</span>' +
|
'<span class="fw-semibold">' + escapeHtml(ticketCode) + '</span>' +
|
||||||
|
'<button type="button" class="btn btn-sm btn-outline-secondary ms-2 py-0 px-1" title="Copy ticket number" data-action="copy-ticket" data-code="' + escapeHtml(ticketCode) + '">⧉</button>' +
|
||||||
'<span class="ms-2 badge ' + (t.resolved_at ? 'bg-secondary' : 'bg-warning text-dark') + '">' + status + '</span>' +
|
'<span class="ms-2 badge ' + (t.resolved_at ? 'bg-secondary' : 'bg-warning text-dark') + '">' + status + '</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
@ -371,7 +427,16 @@
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
var action = btn.getAttribute('data-action');
|
var action = btn.getAttribute('data-action');
|
||||||
var id = btn.getAttribute('data-id');
|
var id = btn.getAttribute('data-id');
|
||||||
if (!action || !id) return;
|
if (!action) return;
|
||||||
|
|
||||||
|
if (action === 'copy-ticket') {
|
||||||
|
var code = btn.getAttribute('data-code') || '';
|
||||||
|
if (!code) return;
|
||||||
|
copyToClipboard(code, btn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) return;
|
||||||
if (action === 'resolve-ticket') {
|
if (action === 'resolve-ticket') {
|
||||||
if (!confirm('Mark ticket as resolved?')) return;
|
if (!confirm('Mark ticket as resolved?')) return;
|
||||||
apiJson('/api/tickets/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'})
|
apiJson('/api/tickets/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'})
|
||||||
|
|||||||
@ -48,7 +48,7 @@
|
|||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" style="width: 34px;">
|
<th scope="col" style="width: 34px;">
|
||||||
<input class="form-check-input" type="checkbox" id="rc_select_all" />
|
<input class="form-check-input" type="checkbox" id="rc_select_all" autocomplete="off" />
|
||||||
</th>
|
</th>
|
||||||
<th scope="col">Customer</th>
|
<th scope="col">Customer</th>
|
||||||
<th scope="col">Backup</th>
|
<th scope="col">Backup</th>
|
||||||
@ -63,7 +63,7 @@
|
|||||||
{% for r in rows %}
|
{% for r in rows %}
|
||||||
<tr class="rc-job-row" data-job-id="{{ r.job_id }}" style="cursor: pointer;">
|
<tr class="rc-job-row" data-job-id="{{ r.job_id }}" style="cursor: pointer;">
|
||||||
<td onclick="event.stopPropagation();">
|
<td onclick="event.stopPropagation();">
|
||||||
<input class="form-check-input rc_row_cb" type="checkbox" value="{{ r.job_id }}" />
|
<input class="form-check-input rc_row_cb" type="checkbox" value="{{ r.job_id }}" autocomplete="off" />
|
||||||
</td>
|
</td>
|
||||||
<td>{{ r.customer_name }}</td>
|
<td>{{ r.customer_name }}</td>
|
||||||
<td>{{ r.backup_software }}</td>
|
<td>{{ r.backup_software }}</td>
|
||||||
@ -447,6 +447,60 @@ function escapeHtml(s) {
|
|||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cross-browser copy to clipboard function
|
||||||
|
function copyToClipboard(text, button) {
|
||||||
|
// Method 1: Modern Clipboard API (works in most browsers with HTTPS)
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
.then(function () {
|
||||||
|
showCopyFeedback(button);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
// Fallback to method 2 if clipboard API fails
|
||||||
|
fallbackCopy(text, button);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Method 2: Legacy execCommand method
|
||||||
|
fallbackCopy(text, button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackCopy(text, button) {
|
||||||
|
var textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
textarea.style.top = '0';
|
||||||
|
textarea.style.left = '0';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
var successful = document.execCommand('copy');
|
||||||
|
if (successful) {
|
||||||
|
showCopyFeedback(button);
|
||||||
|
} else {
|
||||||
|
// If execCommand fails, use prompt as last resort
|
||||||
|
window.prompt('Copy ticket number:', text);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// If all else fails, show prompt
|
||||||
|
window.prompt('Copy ticket number:', text);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCopyFeedback(button) {
|
||||||
|
if (!button) return;
|
||||||
|
var original = button.textContent;
|
||||||
|
button.textContent = '✓';
|
||||||
|
setTimeout(function () {
|
||||||
|
button.textContent = original;
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
|
||||||
function getSelectedJobIds() {
|
function getSelectedJobIds() {
|
||||||
var cbs = table.querySelectorAll('tbody .rc_row_cb');
|
var cbs = table.querySelectorAll('tbody .rc_row_cb');
|
||||||
var ids = [];
|
var ids = [];
|
||||||
@ -840,20 +894,7 @@ table.addEventListener('change', function (e) {
|
|||||||
if (action === 'copy-ticket') {
|
if (action === 'copy-ticket') {
|
||||||
var code = btn.getAttribute('data-code') || '';
|
var code = btn.getAttribute('data-code') || '';
|
||||||
if (!code) return;
|
if (!code) return;
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
copyToClipboard(code, btn);
|
||||||
navigator.clipboard.writeText(code)
|
|
||||||
.then(function () {
|
|
||||||
var original = btn.textContent;
|
|
||||||
btn.textContent = '✓';
|
|
||||||
setTimeout(function () { btn.textContent = original; }, 800);
|
|
||||||
})
|
|
||||||
.catch(function () {
|
|
||||||
// Fallback: select/copy via prompt
|
|
||||||
window.prompt('Copy ticket number:', code);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
window.prompt('Copy ticket number:', code);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,9 +2,47 @@
|
|||||||
|
|
||||||
This file documents all changes made to this project via Claude Code.
|
This file documents all changes made to this project via Claude Code.
|
||||||
|
|
||||||
## [2026-02-10]
|
## [2026-02-13]
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- Fixed Autotask tickets and internal tickets not being linked to missed runs by calling `link_open_internal_tickets_to_run` after creating missed JobRun records in `_ensure_missed_runs_for_job` (both weekly and monthly schedules), ensuring missed runs now receive the same ticket propagation as email-based runs
|
||||||
|
- Fixed checkboxes being automatically re-selected after delete actions on Inbox and Run Checks pages by adding `autocomplete="off"` attribute to all checkboxes, preventing browser from restoring previous checkbox states after page reload
|
||||||
|
|
||||||
|
## [2026-02-12]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed tickets not being displayed in Run Checks modal detail view (Meldingen section) by extending `/api/job-runs/<run_id>/alerts` endpoint to include both run-specific tickets (via ticket_job_runs) and job-level tickets (via ticket_scopes), ensuring newly created tickets are visible immediately in the modal instead of only after being resolved
|
||||||
|
- Fixed copy ticket button not working in Edge browser on Job Details page by moving clipboard functions (copyToClipboard, fallbackCopy, showCopyFeedback) inside IIFE scope for proper closure access (Edge is stricter than Firefox about scope resolution)
|
||||||
|
|
||||||
|
## [2026-02-10]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added screenshot attachment support to Feedback/Bug system (user request: allow screenshots for bugs/features)
|
||||||
|
- New database model: `FeedbackAttachment` with file_data (BYTEA), filename, mime_type, file_size
|
||||||
|
- Upload support on feedback creation form (multiple files, PNG/JPG/GIF/WEBP, max 5MB each)
|
||||||
|
- Upload support on reply forms (attach screenshots when replying)
|
||||||
|
- Inline image display on feedback detail page (thumbnails with click-to-view-full-size)
|
||||||
|
- Screenshot display for both main feedback items and replies
|
||||||
|
- File validation: image type verification using imghdr (not just extension), size limits, secure filename handling
|
||||||
|
- New route: `/feedback/attachment/<id>` to serve images (access-controlled, admins can view deleted item attachments)
|
||||||
|
- Database migration: auto-creates `feedback_attachments` table with indexes on startup
|
||||||
|
- Automatic CASCADE delete: removing feedback item or reply automatically removes associated attachments
|
||||||
|
- Added admin-only deleted items view and permanent delete functionality to Feedback system
|
||||||
|
- "Show deleted items" checkbox on feedback list page (admin only)
|
||||||
|
- Deleted items shown with reduced opacity + background color and "Deleted" badge
|
||||||
|
- Permanent delete action removes item + all attachments from database (hard delete with CASCADE)
|
||||||
|
- Attachment count shown in deletion confirmation message
|
||||||
|
- Admins can view detail pages of deleted items including their screenshots
|
||||||
|
- Two-stage delete: soft delete (audit trail) → permanent delete (database cleanup)
|
||||||
|
- Prevents accidental permanent deletion (requires item to be soft-deleted first)
|
||||||
|
- Security: non-admin users cannot view deleted items or their attachments (404 response)
|
||||||
|
- Added copy ticket button (⧉) to Job Details page modal for quickly copying ticket numbers to clipboard (previously only available on Run Checks page)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed cross-browser clipboard copy functionality for ticket numbers (previously required manual copy popup in Edge browser)
|
||||||
|
- Implemented three-tier fallback mechanism: modern Clipboard API → legacy execCommand('copy') → prompt fallback
|
||||||
|
- Copy button now works directly in all browsers (Firefox, Edge, Chrome) without requiring user interaction
|
||||||
|
- Applied improved copy mechanism to both Run Checks and Job Details pages
|
||||||
- Fixed Autotask ticket not being automatically linked to new runs when internal ticket is resolved by implementing independent Autotask propagation strategy (now checks for most recent non-deleted and non-resolved Autotask ticket on job regardless of internal ticket status, ensuring PSA ticket reference persists across runs until explicitly resolved or deleted)
|
- Fixed Autotask ticket not being automatically linked to new runs when internal ticket is resolved by implementing independent Autotask propagation strategy (now checks for most recent non-deleted and non-resolved Autotask ticket on job regardless of internal ticket status, ensuring PSA ticket reference persists across runs until explicitly resolved or deleted)
|
||||||
- Fixed internal and Autotask tickets being linked to new runs even after being resolved by removing date-based "open" logic from ticket query (tickets now only link to new runs if they are genuinely unresolved, not based on run date comparisons)
|
- Fixed internal and Autotask tickets being linked to new runs even after being resolved by removing date-based "open" logic from ticket query (tickets now only link to new runs if they are genuinely unresolved, not based on run date comparisons)
|
||||||
- Fixed Job Details page showing resolved tickets for ALL runs by implementing two-source ticket display: directly linked tickets (via ticket_job_runs) are always shown for audit trail, while active window tickets (via scope query) are only shown if unresolved, preserving historical ticket links while preventing resolved tickets from appearing on new runs
|
- Fixed Job Details page showing resolved tickets for ALL runs by implementing two-source ticket display: directly linked tickets (via ticket_job_runs) are always shown for audit trail, while active window tickets (via scope query) are only shown if unresolved, preserving historical ticket links while preventing resolved tickets from appearing on new runs
|
||||||
|
|||||||
200
docs/cove_data_protection_api_calls_known_info.md
Normal file
200
docs/cove_data_protection_api_calls_known_info.md
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
# Cove Data Protection (N-able Backup) – Known Information on API Calls
|
||||||
|
|
||||||
|
Date: 2026-02-10
|
||||||
|
Status: Research phase (validated with live testing)
|
||||||
|
|
||||||
|
## Summary of current findings
|
||||||
|
|
||||||
|
API access to Cove Data Protection via JSON-RPC **works**, but is **heavily restricted per tenant and per API user scope**. The API is usable for monitoring, but only with a **very limited, allow‑listed set of column codes**. Any request that includes a restricted column immediately fails with:
|
||||||
|
|
||||||
|
```
|
||||||
|
Operation failed because of security reasons (error 13501)
|
||||||
|
```
|
||||||
|
|
||||||
|
This behavior is consistent even when the API user has **SuperUser** and **SecurityOfficer** roles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication model (confirmed)
|
||||||
|
|
||||||
|
- Endpoint: https://api.backup.management/jsonapi
|
||||||
|
- Protocol: JSON‑RPC 2.0
|
||||||
|
- Method: POST only
|
||||||
|
- Authentication flow:
|
||||||
|
1. Login method is called
|
||||||
|
2. Response returns a **visa** token (top‑level field)
|
||||||
|
3. The visa **must be included in every subsequent call**
|
||||||
|
4. Cove may return a new visa in later responses (token chaining)
|
||||||
|
|
||||||
|
### Login request (working)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "Login",
|
||||||
|
"params": {
|
||||||
|
"partner": "<EXACT customer/partner name>",
|
||||||
|
"username": "<api login name>",
|
||||||
|
"password": "<password>"
|
||||||
|
},
|
||||||
|
"id": "1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Login response structure (important)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"result": {
|
||||||
|
"PartnerId": <number>,
|
||||||
|
"Name": "<login name>",
|
||||||
|
"Flags": ["SecurityOfficer","NonInteractive"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"visa": "<visa token>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `visa` is **not** inside `result`, but at top level
|
||||||
|
- `PartnerId` is found at `result.result.PartnerId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API user scope (critical finding)
|
||||||
|
|
||||||
|
- API users are **always bound to a single Partner (customer)** unless created at MSP/root level
|
||||||
|
- In this environment, it is **not possible to create an MSP‑level API user**
|
||||||
|
- All testing was therefore done with **customer‑scoped API users**
|
||||||
|
|
||||||
|
Impact:
|
||||||
|
- Cross‑customer enumeration is impossible
|
||||||
|
- Only data belonging to the linked customer can be queried
|
||||||
|
- Some enumerate/reporting calls are blocked regardless of role
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EnumerateAccountStatistics – what works and what does not
|
||||||
|
|
||||||
|
### Method
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "EnumerateAccountStatistics",
|
||||||
|
"visa": "<visa>",
|
||||||
|
"params": {
|
||||||
|
"query": {
|
||||||
|
"PartnerId": <partner_id>,
|
||||||
|
"SelectionMode": "Merged",
|
||||||
|
"StartRecordNumber": 0,
|
||||||
|
"RecordsCount": 50,
|
||||||
|
"Columns": [ ... ]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mandatory behavior
|
||||||
|
|
||||||
|
- **Columns are required**; omitting them returns `result: null`
|
||||||
|
- The API behaves as an **allow‑list**:
|
||||||
|
- If *any* requested column is restricted, the **entire call fails** with error 13501
|
||||||
|
|
||||||
|
### Confirmed working (safe) column set
|
||||||
|
|
||||||
|
The following column set works reliably:
|
||||||
|
|
||||||
|
- I1 → account / device / tenant identifier
|
||||||
|
- I14 → used storage (bytes)
|
||||||
|
- I18 → computer name (if applicable)
|
||||||
|
- D01F00 – D01F07 → numeric metrics (exact semantics TBD)
|
||||||
|
- D09F00 → numeric status/category code
|
||||||
|
|
||||||
|
Example (validated working):
|
||||||
|
|
||||||
|
```json
|
||||||
|
"Columns": [
|
||||||
|
"I1","I14","I18",
|
||||||
|
"D01F00","D01F01","D01F02","D01F03",
|
||||||
|
"D01F04","D01F05","D01F06","D01F07",
|
||||||
|
"D09F00"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Confirmed restricted (cause security error 13501)
|
||||||
|
|
||||||
|
- Entire D02Fxx range
|
||||||
|
- Entire D03Fxx range
|
||||||
|
- Broad I‑ranges (e.g. I1–I10 batches)
|
||||||
|
- Many individually tested I‑codes not in the safe set
|
||||||
|
|
||||||
|
Even adding **one restricted code** causes the entire call to fail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EnumerateAccounts
|
||||||
|
|
||||||
|
- Method consistently fails with `Operation failed because of security reasons`
|
||||||
|
- This applies even with:
|
||||||
|
- SuperUser role
|
||||||
|
- SecurityOfficer flag enabled
|
||||||
|
|
||||||
|
Conclusion:
|
||||||
|
- EnumerateAccounts is **not usable** in this tenant for customer‑scoped API users
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Other tested methods
|
||||||
|
|
||||||
|
- EnumerateStatistics → Method not found
|
||||||
|
- GetPartnerInfo → works only for basic partner metadata (not statistics)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Practical implications for BackupChecks
|
||||||
|
|
||||||
|
What **is possible**:
|
||||||
|
- Enumerate accounts implicitly via EnumerateAccountStatistics
|
||||||
|
- Identify devices/accounts via AccountId + I1/I18
|
||||||
|
- Collect storage usage (I14)
|
||||||
|
- Collect numeric status/metrics via D01Fxx and D09F00
|
||||||
|
|
||||||
|
What is **not possible (via this API scope)**:
|
||||||
|
- Reliable last backup timestamp
|
||||||
|
- Explicit success / failure / warning text
|
||||||
|
- Error messages
|
||||||
|
- Enumerating devices via EnumerateAccounts
|
||||||
|
- Cross‑customer aggregation
|
||||||
|
|
||||||
|
### Suggested internal model mapping
|
||||||
|
|
||||||
|
- Customer
|
||||||
|
- external_id = PartnerId
|
||||||
|
|
||||||
|
- Job
|
||||||
|
- external_id = AccountId
|
||||||
|
- display_name = I1
|
||||||
|
- hostname = I18 (if present)
|
||||||
|
|
||||||
|
- Run (limited)
|
||||||
|
- metrics only (bytes, counters)
|
||||||
|
- status must be **derived heuristically** from numeric fields (if possible)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions / next steps
|
||||||
|
|
||||||
|
1. Confirm official meaning of:
|
||||||
|
- D01F00 – D01F07
|
||||||
|
- D09F00
|
||||||
|
|
||||||
|
2. Investigate whether:
|
||||||
|
- A token‑based (non‑JSON‑RPC) reporting endpoint exists
|
||||||
|
- N‑able support can enable additional reporting columns
|
||||||
|
- An MSP‑level API user can be provisioned by N‑able
|
||||||
|
|
||||||
|
3. Decide whether Cove integration in BackupChecks will be:
|
||||||
|
- Metrics‑only (no run result semantics)
|
||||||
|
- Or require vendor cooperation for expanded API access
|
||||||
Loading…
Reference in New Issue
Block a user