Compare commits

..

19 Commits

Author SHA1 Message Date
9dee9c300a Auto-commit local changes before build (2026-02-12 11:11:59) 2026-02-12 11:11:59 +01:00
c5cf07f4e5 Fix tickets not showing in Run Checks modal detail view
Extended /api/job-runs/<run_id>/alerts endpoint to include both:
- Tickets explicitly linked to run via ticket_job_runs (audit trail)
- Tickets linked to job via ticket_scopes (active on run date)

Previously only ticket_job_runs was queried, causing newly created
tickets to not appear in the Meldingen section of the Run Checks modal.
They would only appear after being resolved (which creates a
ticket_job_runs entry). Now both sources are queried and duplicates
are prevented.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-12 10:53:00 +01:00
91755c6e85 Add N-able support ticket email template to Cove TODO
Added ready-to-send email template for requesting expanded API access:
- Complete email with subject line
- Detailed explanation of current limitations
- Specific requests (MSP-level access, status fields, timestamps, errors)
- Technical details and test results reference
- Professional business justification (MSP use case)
- Alternative contact methods listed

User can copy-paste this email on Thursday to contact N-able support.

Template requests:
1. MSP-level API user creation
2. Access to restricted column codes (status, timestamps, errors)
3. Documentation of column code meanings
4. Alternative integration methods if API expansion not possible

Ready for action on Thursday.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 17:26:19 +01:00
6674d40f4b Major update: Cove API tested - critical limitations discovered
Added comprehensive API test results document (with ChatGPT assistance):
- docs/cove_data_protection_api_calls_known_info.md

Key findings from live API testing:
- API works: JSON-RPC 2.0 at https://api.backup.management/jsonapi
- Authentication: Login method → visa token
- Method tested: EnumerateAccountStatistics (limited success)

CRITICAL LIMITATIONS DISCOVERED:
- Security error 13501 blocks most useful columns
- No backup status fields (success/failed/warning) accessible
- No error messages (D02Fxx/D03Fxx ranges blocked)
- No reliable backup timestamps
- No detailed run history
- API users are customer-scoped (not MSP-level)
- EnumerateAccounts method always fails (security block)

Working columns (allow-list only):
- I1 (account ID), I14 (storage bytes), I18 (hostname)
- D01F00-D01F07, D09F00 (numeric metrics, semantics unclear)

Impact on Backupchecks:
- Current API access INSUFFICIENT for backup monitoring
- Cannot determine if backups succeeded or failed
- No error messages to show users
- Core Backupchecks functionality not achievable with current API

Added decision matrix with 4 options:
A. Implement metrics-only (low value, storage usage only)
B. Request expanded access from N-able (requires vendor cooperation)
C. Explore alternative methods (webhooks, reports, email)
D. Defer integration until better API access available

Recommendation: Option B or C before implementing anything
- Contact N-able support for MSP-level API user + expanded columns
- OR investigate if Cove has webhook/reporting alternatives

This represents a significant blocker for Cove integration.
Full integration requires either vendor cooperation or alternative approach.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 16:55:31 +01:00
32e68d7209 Update Cove TODO: Add complete API documentation links
Major discovery - found comprehensive JSON API documentation on N-able site!

Added documentation sections:
- Core API docs: login, authentication, construct API calls
- Key endpoints: enumerate-customers, enumerate-devices, enumerate-device-statistics
- Reference docs: API column codes, schema documentation
- Architecture and security guides

Key findings:
- API docs located in "unused" folder but still functional
- JSON API structure (likely JSON-RPC or custom format)
- Three critical endpoints identified for backup monitoring:
  1. enumerate-customers (list all customers)
  2. enumerate-devices (list backup devices)
  3. enumerate-device-statistics (backup job results - KEY ENDPOINT!)

Updated status:
- Marked API documentation as found
- Changed next action from "find docs" to "read auth docs and test"
- Updated Phase 1 to start with reading login/auth documentation

Next steps:
1. Read login.htm to understand token authentication
2. Read construct-a-call.htm to understand request format
3. Read enumerate-device-statistics.htm - likely contains backup status data
4. Test in Postman with documented format

Documentation base URL:
https://documentation.n-able.com/covedataprotection/USERGUIDE/documentation/Content/unused/service-management/json-api/

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 15:48:35 +01:00
23e59ab459 Update Cove TODO: Add comprehensive Postman testing instructions
Replaced curl examples with detailed Postman testing guide:
- Step-by-step Postman setup instructions
- Two authentication methods to test (Bearer Token vs X-API-Key)
- Multiple base URLs to try (api.backup.management, backup.management)
- Expected response codes and what they mean (200, 401, 403, 404)
- Endpoint discovery list (accounts, customers, devices, jobs)
- Tips for finding API documentation

Added Postman best practices:
- Create Cove API collection
- Use environment variables (cove_token, cove_base_url)
- Save response examples
- Check rate limit headers
- Export collection to JSON

Added structured template for documenting test results:
- Working configuration (base URL, auth method)
- Available endpoints table
- Key response fields mapping to Backupchecks
- Pagination and rate limiting details
- Location to save Postman collection export

Ready for immediate API testing with Postman!

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 15:44:24 +01:00
b2992acc56 Update Cove TODO: API user created, add testing instructions
Major progress update:
- API user successfully created in Cove portal
- Credentials: SuperUser role, top-level customer access, token generated
- Portal URL identified: https://backup.management
- API user management: https://backup.management/#/api-users

Added comprehensive testing section:
- Likely API base URLs to test (api.backup.management, backup.management/api)
- Step-by-step Phase 1 testing instructions
- Multiple curl command examples for authentication testing
- Different auth header formats to try (Bearer, X-API-Key)
- Common endpoints to discover (accounts, customers, devices)
- POC Python script template

Next steps:
1. Test API authentication with curl commands
2. Find working API base URL and auth method
3. Discover available endpoints
4. Document API response format
5. Create POC script for data retrieval

Status: Ready for immediate API testing!

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 15:42:11 +01:00
200dd23285 Update Cove TODO: API exists but activation method unknown
Added critical information from user:
- Confirmed: Cove Data Protection HAS API access (documented)
- Problem: Location/method to enable API access is unknown

Changes:
- Added Phase 0: API Access Activation (critical first step)
- Marked API availability as confirmed
- Added checklist for finding API activation in admin portal
- Listed possible admin portal locations to check
- Added support channel suggestions if activation unclear
- Updated current status section with latest info

Next action: Investigate Cove admin portal or contact support for
API activation instructions.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 15:38:08 +01:00
d1023f9e52 Translate Cove Data Protection TODO to English
Changed TODO document language from Dutch to English to align with
project documentation standards (all code and docs in English).

No content changes, only translation.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 15:33:34 +01:00
1de1b032e7 Add TODO for Cove Data Protection integration
Created comprehensive TODO document for integrating Cove Data Protection
(formerly N-able Backup) into Backupchecks.

Key challenges:
- Cove does not use email notifications like other backup systems
- Need to research API availability and authentication methods
- Must determine optimal integration strategy (polling vs webhooks)

Document includes:
- Research questions (API availability, data structure, multi-tenancy)
- Three architecture options for integration
- Implementation phases (research, database, import, scheduling, UI)
- Success criteria and open questions
- References section for documentation links

Status: Research phase - waiting on API documentation investigation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 15:32:12 +01:00
661a5783cf Auto-commit local changes before build (2026-02-10 15:27:46) 2026-02-10 15:27:46 +01:00
dfe86a6ed1 Update changelog with copy ticket button improvements
Added documentation for:
- Copy ticket button on Job Details page
- Cross-browser clipboard copy fix (Edge no longer requires manual popup)
- Three-tier fallback mechanism for clipboard operations

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 15:04:38 +01:00
35ec337c54 Add copy ticket button to Job Details and improve cross-browser copy functionality
Changes:
- Added copy ticket button (⧉) next to ticket numbers in Job Details modal
- Implemented robust cross-browser clipboard copy mechanism:
  1. Modern navigator.clipboard API (works in HTTPS contexts)
  2. Legacy document.execCommand('copy') fallback (works in older browsers)
  3. Prompt fallback as last resort
- Applied improved copy function to both Run Checks and Job Details pages
- Copy now works directly in all browsers (Firefox, Edge, Chrome) without popup

This eliminates the manual copy step in Edge that previously required a popup.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 15:04:21 +01:00
c777728c91 Update changelog with comprehensive screenshot feature documentation
Added detailed documentation for screenshot attachment support in Feedback
system, including:
- File validation using imghdr (header inspection, not just extensions)
- Admin access control for deleted item attachments
- Automatic CASCADE delete behavior
- Enhanced admin deleted items view with permanent delete
- UI improvements for deleted item display (opacity + background)
- Security considerations for non-admin users

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 13:51:54 +01:00
0510613708 Fix: Allow admins to view screenshots of deleted feedback items
Two fixes:
1. Improved deleted item row styling (opacity + background)
2. Allow feedback_attachment route to serve images from deleted items (admin only)

Before: Screenshots shown as links only (2026-02-10_13_29_39.png)
After: Screenshots shown as images/thumbnails

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 13:46:24 +01:00
fc99f17db3 Add admin view for deleted feedback items + permanent delete
User request: Allow admins to view deleted items and permanently
delete them (hard delete) to clean up database and remove screenshots.

Features:
1. Admin-only "Show deleted" checkbox on feedback list
2. Deleted items shown with gray background + "Deleted" badge
3. Permanent delete button (only for soft-deleted items)
4. Hard delete removes item + all attachments from database
5. Admins can view detail pages of deleted items

Backend (routes_feedback.py):
- Added show_deleted parameter (admin only)
- Modified feedback_page query to optionally include deleted items
- Added deleted_at, deleted_by to query results
- Modified feedback_detail to allow admins to view deleted items
- New route: feedback_permanent_delete (hard delete)
  - Only works on already soft-deleted items (safety check)
  - Uses db.session.delete() - CASCADE removes attachments
  - Shows attachment count in confirmation message

Frontend:
- feedback.html:
  - "Show deleted items" checkbox (auto-submits form)
  - Deleted items: gray background (table-secondary)
  - Shows deleted timestamp
  - "Permanent Delete" button in Actions column
  - Confirmation dialog warns about permanent deletion
- feedback_detail.html:
  - "Deleted" badge in header
  - Actions sidebar shows warning + "Permanent Delete" button
  - Normal actions (resolve/delete) hidden for deleted items

Benefits:
- Audit trail preserved with soft delete
- Database can be cleaned up later by removing old deleted items
- Screenshots (BYTEA) don't accumulate forever
- Two-stage safety: soft delete → permanent delete

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 13:40:53 +01:00
1a506c0713 Fix: Add FeedbackAttachment to routes_shared imports
Missing import caused NameError when creating feedback with screenshots.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 13:30:47 +01:00
85798a07ae Auto-commit local changes before build (2026-02-10 13:29:10) 2026-02-10 13:29:10 +01:00
451ce1ab22 Add screenshot attachment support to Feedback/Bug system
User request: Allow screenshots to be attached to bug reports
and feature requests for better documentation and reproduction.

Database:
- New model: FeedbackAttachment (file_data BYTEA, filename, mime_type, file_size)
- Links to feedback_item_id (required) and feedback_reply_id (optional)
- Migration: auto-creates table with indexes on startup
- Cascading deletes when item or reply is deleted

Backend (routes_feedback.py):
- Helper function: _validate_image_file() for security
  - Validates file type using imghdr (not just extension)
  - Enforces size limit (5MB per file)
  - Secure filename handling with werkzeug
  - Allowed: PNG, JPG, GIF, WEBP
- Updated feedback_new: accepts multiple file uploads
- Updated feedback_reply: accepts multiple file uploads
- Updated feedback_detail: fetches attachments for item + replies
- New route: /feedback/attachment/<id> to serve images

Frontend:
- feedback_new.html: file input with multiple selection
- feedback_detail.html:
  - Shows item screenshots as clickable thumbnails (max 300x200)
  - Shows reply screenshots as clickable thumbnails (max 200x150)
  - File upload in reply form
  - All images open full-size in new tab

Security:
- Access control: only authenticated users with feedback roles
- Image type verification using imghdr (header inspection)
- File size limit enforced (5MB)
- Secure filename sanitization
- Deleted items hide their attachments (404)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 13:28:41 +01:00
14 changed files with 1552 additions and 40 deletions

View File

@ -1 +1 @@
main v20260212-01-fix-tickets-modal-display

View 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

View File

@ -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")),

View File

@ -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,
)

View File

@ -52,6 +52,7 @@ from ..models import (
FeedbackItem, FeedbackItem,
FeedbackVote, FeedbackVote,
FeedbackReply, FeedbackReply,
FeedbackAttachment,
NewsItem, NewsItem,
NewsRead, NewsRead,
ReportDefinition, ReportDefinition,

View File

@ -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.

View File

@ -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"

View File

@ -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>

View File

@ -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,21 +136,32 @@
<h2 class="h6">Actions</h2> <h2 class="h6">Actions</h2>
{% if active_role == 'admin' %} {% if active_role == 'admin' %}
{% if item.status == 'resolved' %} {% if item.deleted_at %}
<form method="post" action="{{ url_for('main.feedback_resolve', item_id=item.id) }}" class="mb-2"> {# Item is deleted - show permanent delete option #}
<input type="hidden" name="action" value="reopen" /> <div class="alert alert-warning mb-2" style="font-size: 0.9rem;">
<button type="submit" class="btn btn-outline-secondary w-100">Reopen</button> This item is deleted.
</form> </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 %} {% else %}
<form method="post" action="{{ url_for('main.feedback_resolve', item_id=item.id) }}" class="mb-2"> {# Item is not deleted - show normal actions #}
<input type="hidden" name="action" value="resolve" /> {% if item.status == 'resolved' %}
<button type="submit" class="btn btn-success w-100">Mark as resolved</button> <form method="post" action="{{ url_for('main.feedback_resolve', item_id=item.id) }}" class="mb-2">
</form> <input type="hidden" name="action" value="reopen" />
{% endif %} <button type="submit" class="btn btn-outline-secondary w-100">Reopen</button>
</form>
{% else %}
<form method="post" action="{{ url_for('main.feedback_resolve', item_id=item.id) }}" class="mb-2">
<input type="hidden" name="action" value="resolve" />
<button type="submit" class="btn btn-success w-100">Mark as resolved</button>
</form>
{% endif %}
<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 %}

View File

@ -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">

View File

@ -284,6 +284,60 @@
.replace(/'/g, "&#39;"); .replace(/'/g, "&#39;");
} }
// 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 () { (function () {
var currentRunId = null; var currentRunId = null;
@ -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: '{}'})

View File

@ -447,6 +447,60 @@ function escapeHtml(s) {
.replace(/'/g, "&#39;"); .replace(/'/g, "&#39;");
} }
// 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;
} }

View File

@ -2,9 +2,40 @@
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-12]
### Fixed ### 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
## [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

View 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, allowlisted 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: JSONRPC 2.0
- Method: POST only
- Authentication flow:
1. Login method is called
2. Response returns a **visa** token (toplevel 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 MSPlevel API user**
- All testing was therefore done with **customerscoped API users**
Impact:
- Crosscustomer 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 **allowlist**:
- 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 Iranges (e.g. I1I10 batches)
- Many individually tested Icodes 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 customerscoped 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
- Crosscustomer 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 tokenbased (nonJSONRPC) reporting endpoint exists
- Nable support can enable additional reporting columns
- An MSPlevel API user can be provisioned by Nable
3. Decide whether Cove integration in BackupChecks will be:
- Metricsonly (no run result semantics)
- Or require vendor cooperation for expanded API access