Add Addigy API integration and Docker deployment with Redis caching

- Implemented complete Addigy API v2 client with authentication via x-api-key
- Added device and policy endpoints with automatic org ID resolution
- Created field mapping from snake_case to Title Case for UI compatibility
- Handles nested 'facts' response structure from Addigy devices API
- Added comprehensive API documentation in ADDIGY_API_GUIDE.md

- Multi-stage Dockerfile with optimized production build
- Custom ports: App on 3100, Redis on 6380 (avoids conflicts)
- Docker Compose orchestration with health checks
- Standalone Next.js output for smaller container images
- Non-root user execution for security

- Implemented Redis caching layer for API responses
- 5-minute TTL with graceful fallback if Redis unavailable
- Cache key structure: service:entity:filter1:filter2
- Applied to Addigy devices endpoint with cache hit/miss logging

- Fixed TypeScript strict mode errors for production builds
- Added null safety checks with optional chaining throughout API routes
- Wrapped useSearchParams in Suspense boundary for Next.js 15+ compatibility
- Fixed type assertions for dynamic API responses
- Corrected Set<string> type mismatches in device comparison logic

- Created DOCKER_README.md with complete deployment guide
- Updated ADDIGY_API_GUIDE.md with real-world API patterns
- Documented response structures, field mappings, and troubleshooting

- Next.js 16.0.0 with Turbopack
- Redis 7 with AOF persistence
- Podman/Docker compatible
- TypeScript strict mode compliant
This commit is contained in:
Lorentz Hinrichsen 2025-10-28 22:49:08 -04:00
parent cf5fabe306
commit f429f3af54
23 changed files with 1931 additions and 36 deletions

16
.env.docker Normal file
View file

@ -0,0 +1,16 @@
# Autotask API Configuration
AUTOTASK_API_URL=https://webservices1.autotask.net/atservicesrest/v1.0
AUTOTASK_USERNAME=heabv32jr3yzoke@WULFCONSULTING.COM
AUTOTASK_SECRET='7g*Zf@K0Ns3#q$E4A1~n2#mW$'
AUTOTASK_API_INTEGRATION_CODE=FJCJDU3YQ6GUIYUMZ36AL2O4XCP
# Datto RMM API Configuration
DATTO_RMM_API_URL=https://concord-api.centrastage.net
DATTO_RMM_API_KEY=21F8F7ATN71JOUHESU14MM89FB1MNHI7
DATTO_RMM_API_SECRET=4N4E397S6HUI5TJD6QGMU43VE8S0SDLJ
# Addigy API Configuration
ADDIGY_API_URL=https://api.addigy.com/api/v2
ADDIGY_API_TOKEN=d9138a6561cb96b74d917ba81560cfd8
# Optional: If you need to specify a parent organization ID
# ADDIGY_ORG_ID=your_organization_id_here

345
ADDIGY_API_GUIDE.md Normal file
View file

@ -0,0 +1,345 @@
# Addigy API Integration Guide
This guide explains how to use the Addigy API integration in the PSA-Utils application.
## Setup
### 1. Get Your Addigy API Token
1. Log into your Addigy account
2. Navigate to **Account > Integrations**
3. Click on the **V2** tab under "Addigy API"
4. Click **New API Token**
5. Enter a name for your token (e.g., "PSA-Utils Integration")
6. Select the appropriate permissions:
- **View Devices** - Required to fetch device information
- **View Policies** - Required to fetch policy information
- **View Organizations** - Optional, for organization data
- Additional permissions as needed for your use case
7. Click **Save**
8. **Copy the API token immediately** - you won't be able to see it again!
**Note**: The Addigy v2 API uses the `x-api-key` header for authentication, not Bearer tokens.
### 2. Configure Environment Variables
Add your Addigy API token to `.env.local`:
```bash
# Addigy API Configuration
ADDIGY_API_URL=https://api.addigy.com/api/v2
ADDIGY_API_TOKEN=your_actual_token_here
# Optional: Parent Organization ID (if needed)
# ADDIGY_ORG_ID=your_org_id_here
```
### 3. Restart Your Development Server
After updating the `.env.local` file, restart your Next.js development server:
```bash
npm run dev
```
## Usage
### Import the Addigy Client
```typescript
import { getAddigyClient } from '@/lib/services/addigy-factory';
const addigyClient = getAddigyClient();
```
### Common Operations
#### Get All Devices
```typescript
const devices = await addigyClient.getAllDevices();
console.log(`Found ${devices.length} devices`);
```
#### Get Online Devices Only
```typescript
const onlineDevices = await addigyClient.getOnlineDevices();
```
#### Get Devices by Policy
```typescript
const policyId = '003f01fa-9bb3-421a-be29-409adf7a1xxx';
const devices = await addigyClient.getDevicesByPolicy(policyId);
```
#### Get Device Applications
```typescript
const deviceId = '114x11c3-b5dy-4n38-92fb-34th6ju782se';
const applications = await addigyClient.getDeviceApplications(deviceId);
```
#### Get All Policies
```typescript
const policies = await addigyClient.getAllPolicies();
```
#### Get Policy by ID
```typescript
const policyId = '76yh84t0-ju74-bh45-jd6b-ok87y4gc83gt';
const policy = await addigyClient.getPolicyById(policyId);
```
#### Get Active Alerts
```typescript
const alerts = await addigyClient.getActiveAlerts();
```
#### Get Maintenance Items
```typescript
const maintenanceItems = await addigyClient.getMaintenanceItems();
```
#### Get Custom Facts for a Device
```typescript
const deviceId = '114x11c3-b5dy-4n38-92fb-34th6ju782se';
const facts = await addigyClient.getCustomFacts(deviceId);
```
#### Assign Device to Policy
```typescript
const deviceId = '114x11c3-b5dy-4n38-92fb-34th6ju782se';
const newPolicyId = '76yh84t0-ju74-bh45-jd6b-ok87y4gc83gt';
await addigyClient.assignDeviceToPolicy(deviceId, newPolicyId);
```
### Advanced Queries with Filters
```typescript
// Get devices with custom filters
const devices = await addigyClient.getAllDevices({
filters: [
{
audit_field: 'Free Disk Percentage',
type: 'number',
operation: 'less_than',
value: 20,
},
{
audit_field: 'Firewall Enabled',
type: 'boolean',
operation: 'equals',
value: false,
},
],
limit: 100,
page: 1,
});
```
### Pagination for Large Datasets
```typescript
// Get all devices with automatic pagination
const allDevices = await addigyClient.getAllDevicesPaginated(100);
console.log(`Total devices: ${allDevices.length}`);
```
### Test Connection
```typescript
const isConnected = await addigyClient.testConnection();
if (isConnected) {
console.log('Successfully connected to Addigy API');
} else {
console.error('Failed to connect to Addigy API');
}
```
## API Endpoints
The integration includes the following pre-built API endpoints:
### GET `/api/addigy-devices`
Fetch devices from Addigy. Automatically retrieves the organization ID from policies if not configured.
**Query Parameters:**
- `policyId` (optional) - Filter devices by policy ID
- `online` (optional) - Set to 'true' to get only online devices
**Example:**
```bash
# Get all devices
curl http://localhost:3000/api/addigy-devices
# Get devices for a specific policy
curl http://localhost:3000/api/addigy-devices?policyId=76yh84t0-ju74-bh45-jd6b-ok87y4gc83gt
# Get only online devices
curl http://localhost:3000/api/addigy-devices?online=true
```
### GET `/api/addigy-policies`
Fetch all policies from Addigy.
## Important API Details
### Authentication
- Uses `x-api-key` header (not `Authorization: Bearer`)
- Single API token for authentication
### Endpoint Structure
- **Devices**: `POST /o/{orgid}/devices` - Requires organization ID
- **Policies**: `POST /oa/policies/query` - Returns flat array
- All endpoints use POST with JSON body, not GET
### Request Body Format
```json
{
"page": 1,
"per_page": 500,
"query": {
"filters": [...]
}
}
```
### Response Structure
#### Devices Response
Devices are returned with nested `facts` structure:
```json
{
"items": [
{
"facts": {
"device_name": { "value": "MacBook-Pro", "type": "string" },
"serial_number": { "value": "C02XX...", "type": "string" },
"online": { "value": true, "type": "boolean" }
}
}
]
}
```
#### Policies Response
Policies return as a flat array:
```json
[
{
"policyId": "69ae51e4-...",
"orgid": "4200f347-...",
"name": "Policy Name"
}
]
```
**Example:**
```bash
curl http://localhost:3000/api/addigy-policies
```
## Rate Limiting
The Addigy API has a rate limit of **1,000 requests per 10 seconds**. The client includes built-in rate limiting to prevent exceeding this limit. If you exceed the rate limit, further requests will be rejected for 24 hours.
## Type Definitions
All Addigy types are defined in `/lib/types/addigy.ts`. Key types include:
- `AddigyDevice` - Device information
- `AddigyPolicy` - Policy information
- `AddigyApplication` - Installed application details
- `AddigyAlert` - Alert information
- `AddigyOrganization` - Organization details
- `AddigyMaintenanceItem` - Maintenance task information
- `AddigyMonitoringItem` - Monitoring rule information
- `AddigyCustomFact` - Custom facts/variables
- `AddigySoftwareItem` - Software catalog items
## Error Handling
Always wrap API calls in try-catch blocks:
```typescript
try {
const devices = await addigyClient.getAllDevices();
// Process devices
} catch (error) {
console.error('Error fetching devices:', error);
// Handle error appropriately
}
```
## Common Device Fields
Addigy returns device fields in snake_case format. The integration automatically maps these to Title Case for UI compatibility:
| Addigy Field | Mapped To | Description |
|--------------|-----------|-------------|
| `agentid` | `agentid` | Unique device identifier |
| `device_name` | `Device Name` | Device hostname |
| `device_model_name` | `Device Model Name` | Device model (e.g., "MacBook Pro") |
| `mac_os_x_version` | `MAC OS X Version` | macOS version |
| `ios_version` | `iOS Version` | iOS version |
| `online` | `online` | Boolean indicating if device is online |
| `policy_id` | `policy_id` | Current policy ID |
| `current_user` | `Current User` | Currently logged in user |
| `serial_number` | `Serial Number` | Device serial number |
| `free_disk_percentage` | `Free Disk Percentage` | Available disk space percentage |
| `battery_percentage` | `Battery Percentage` | Current battery level |
| `firewall_enabled` | `Firewall Enabled` | Boolean |
| `filevault_enabled` | `FileVault Enabled` | Boolean |
| `agent_version` | `Agent Version` | Addigy agent version |
## Resources
- [Addigy API v2 Documentation](https://support.addigy.com/hc/en-us/articles/16938210315411-API-Documentation-v2)
- [Addigy API v2 Interactive Docs](https://api.addigy.com/api/v2/documentation/)
- [Addigy Support Center](https://support.addigy.com/)
## Troubleshooting
### "ADDIGY_API_TOKEN environment variable is required"
Make sure you've added your API token to the `.env.local` file and restarted your development server.
### "API Error: Unauthorized" or 401 Error
Check that your API token is valid and has the necessary permissions. You may need to create a new token with the correct permissions in Addigy.
### "404 page not found" Error
This usually means:
1. Wrong endpoint path - check you're using `/o/{orgid}/devices` for devices
2. Missing organization ID - the integration will auto-fetch from policies if not configured
3. Using GET instead of POST - all Addigy v2 endpoints require POST
### Empty Device Names
Addigy uses snake_case field names (e.g., `device_name`). The integration automatically maps these to the UI-expected format (e.g., `Device Name`).
### Rate Limit Exceeded
If you receive a rate limit error, wait 24 hours before making more API requests. Consider implementing caching or reducing the frequency of API calls.
### "Invalid JSON response from Addigy API"
This may indicate an issue with the API endpoint or your token permissions. Check the Addigy API documentation for the specific endpoint you're trying to access.
## Next Steps
- Explore the Addigy API documentation for additional endpoints
- Create custom API routes for your specific use cases
- Integrate Addigy data with your Autotask or other systems
- Build dashboards to visualize device status, alerts, and compliance

View file

@ -0,0 +1,21 @@
node_modules
npm-debug.log
.next
.git
.gitignore
README.md
.env
.env.local
.env.production
.DS_Store
*.pem
coverage
.nyc_output
.idea
.vscode
*.swp
*.swo
*~
.dockerignore
Dockerfile
docker-compose.yml

View file

@ -0,0 +1,199 @@
# Docker Setup for PSA-Utils
This application is configured to run in Docker with custom ports to avoid conflicts with other services.
## Port Configuration
- **Frontend/Backend (Next.js)**: Port `3100` (instead of default 3000)
- **Redis Cache**: Port `6380` (instead of default 6379)
## Quick Start
### 1. Copy Environment Variables
```bash
cp .env.local .env.docker
# Edit .env.docker with your actual API credentials
```
### 2. Build and Run with Docker Compose
```bash
# Build and start all services
docker-compose up -d
# View logs
docker-compose logs -f
# Stop all services
docker-compose down
# Stop and remove volumes (clears Redis cache)
docker-compose down -v
```
### 3. Access the Application
Open your browser and navigate to: `http://localhost:3100`
## Docker Services
### Application Service
- **Container Name**: `psa-utils-app`
- **Port**: 3100
- **Features**:
- Multi-stage build for optimized image size
- Runs as non-root user for security
- Automatic restart on failure
- Environment variables passed from docker-compose
### Redis Cache Service
- **Container Name**: `psa-utils-redis`
- **Port**: 6380
- **Features**:
- Persistent data storage
- Append-only file for durability
- Health checks
- Automatic restart on failure
## Caching Strategy
The application implements Redis caching for API responses:
- **Addigy Devices**: Cached for 5 minutes
- **Addigy Policies**: Cached for 5 minutes
- **Autotask Tickets**: Can be cached (add to route)
- **Autotask Companies**: Can be cached (add to route)
Cache keys are structured as: `service:entity:filter1:filter2`
Example: `addigy:devices:all:online`
## Development vs Production
### Development Mode
```bash
# Run with mounted .env.local for easy configuration changes
docker-compose up
```
### Production Mode
```bash
# Build with embedded environment variables
docker build --build-arg NODE_ENV=production -t psa-utils:latest .
# Run with environment file
docker run -d \
--name psa-utils \
-p 3100:3100 \
--env-file .env.production \
psa-utils:latest
```
## Monitoring
### Check Service Health
```bash
# Check if services are running
docker-compose ps
# Check Redis connection
docker exec psa-utils-redis redis-cli ping
# Monitor Redis cache
docker exec psa-utils-redis redis-cli monitor
# View cache keys
docker exec psa-utils-redis redis-cli keys "*"
```
### View Logs
```bash
# All services
docker-compose logs -f
# Specific service
docker-compose logs -f app
docker-compose logs -f redis
```
## Troubleshooting
### Port Already in Use
If ports 3100 or 6380 are already in use, modify the port mappings in `docker-compose.yml`:
```yaml
services:
app:
ports:
- "3200:3100" # Change 3200 to your desired port
redis:
ports:
- "6381:6379" # Change 6381 to your desired port
```
### Clear Redis Cache
```bash
# Connect to Redis and flush
docker exec psa-utils-redis redis-cli FLUSHDB
# Or restart with volume removal
docker-compose down -v
docker-compose up -d
```
### Environment Variables Not Loading
Ensure your `.env.local` file exists and contains all required variables:
- `AUTOTASK_*` credentials
- `DATTO_RMM_*` credentials
- `ADDIGY_*` credentials
- `REDIS_URL` (set automatically in Docker)
### Build Errors
```bash
# Clean build
docker-compose build --no-cache
# Remove all containers and images
docker-compose down
docker system prune -a
```
## Performance Optimization
### Redis Configuration
The Redis cache is configured with:
- AOF persistence for durability
- 5-minute TTL for most cached data
- Automatic retry on connection failure
- Health checks every 5 seconds
### Next.js Optimization
- Standalone output mode for smaller Docker images
- Multi-stage build reduces final image size
- Static assets served efficiently
- Production optimizations enabled
## Security Considerations
1. **Non-root User**: Application runs as `nextjs` user (UID 1001)
2. **Environment Variables**: Sensitive data kept in `.env` files, not in images
3. **Network Isolation**: Services communicate via Docker network
4. **Port Mapping**: Only necessary ports exposed to host
5. **Redis Security**: Redis only accessible within Docker network
## Backup and Restore
### Backup Redis Data
```bash
# Create backup
docker exec psa-utils-redis redis-cli BGSAVE
docker cp psa-utils-redis:/data/dump.rdb ./redis-backup.rdb
```
### Restore Redis Data
```bash
# Restore backup
docker cp ./redis-backup.rdb psa-utils-redis:/data/dump.rdb
docker-compose restart redis
```

55
autotask-app/Dockerfile Normal file
View file

@ -0,0 +1,55 @@
# Multi-stage build for Next.js application
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
# Use custom port 3100 instead of 3000
EXPOSE 3100
ENV PORT 3100
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]

View file

@ -0,0 +1,193 @@
'use client';
import { useState, useEffect } from 'react';
import { AddigyDevice } from '@/lib/types/addigy';
export default function AddigyDevicesPage() {
const [devices, setDevices] = useState<AddigyDevice[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filterOnline, setFilterOnline] = useState(false);
useEffect(() => {
fetchDevices();
}, [filterOnline]);
const fetchDevices = async () => {
setLoading(true);
setError(null);
try {
const url = filterOnline
? '/api/addigy-devices?online=true'
: '/api/addigy-devices';
const response = await fetch(url);
const result = await response.json();
if (result.success) {
setDevices(result.data);
} else {
setError(result.error || 'Failed to fetch devices');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
return (
<div className="container mx-auto p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Addigy Devices</h1>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={filterOnline}
onChange={(e) => setFilterOnline(e.target.checked)}
className="w-4 h-4"
/>
<span>Online Only</span>
</label>
<button
onClick={fetchDevices}
disabled={loading}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Loading...' : 'Refresh'}
</button>
</div>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded mb-4">
<strong>Error:</strong> {error}
</div>
)}
{loading ? (
<div className="text-center py-12">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<p className="mt-4 text-gray-600">Loading devices...</p>
</div>
) : (
<>
<div className="mb-4 text-gray-600">
Found {devices.length} device{devices.length !== 1 ? 's' : ''}
</div>
<div className="bg-white shadow-md rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Device Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Model
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
OS Version
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Current User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Free Disk
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Security
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{devices.map((device) => (
<tr key={device.agentid} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{device['Device Name']}
</div>
<div className="text-xs text-gray-500">
{device['Serial Number'] || 'N/A'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{device['Device Model Name'] || 'Unknown'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{device['MAC OS X Version'] ||
device['iOS Version'] ||
'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{device['Current User'] || 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
device.online
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{device.online ? 'Online' : 'Offline'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
{device['Free Disk Percentage'] !== undefined ? (
<div className="flex items-center">
<span
className={`${
device['Free Disk Percentage'] < 20
? 'text-red-600'
: device['Free Disk Percentage'] < 40
? 'text-yellow-600'
: 'text-green-600'
}`}
>
{device['Free Disk Percentage']}%
</span>
</div>
) : (
'N/A'
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="flex flex-col gap-1">
<span
className={`text-xs ${
device['Firewall Enabled']
? 'text-green-600'
: 'text-red-600'
}`}
>
FW: {device['Firewall Enabled'] ? '✓' : '✗'}
</span>
<span
className={`text-xs ${
device['FileVault Enabled']
? 'text-green-600'
: 'text-red-600'
}`}
>
FV: {device['FileVault Enabled'] ? '✓' : '✗'}
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,61 @@
import { NextResponse } from 'next/server';
import { getAddigyClient } from '@/lib/services/addigy-factory';
import { getCachedData, setCachedData } from '@/lib/services/redis-client';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const policyId = searchParams.get('policyId');
const online = searchParams.get('online');
// Create cache key based on query parameters
const cacheKey = `addigy:devices:${policyId || 'all'}:${online || 'all'}`;
// Try to get cached data
const cachedDevices = await getCachedData<any[]>(cacheKey);
if (cachedDevices) {
console.log(`Cache hit for key: ${cacheKey}`);
return NextResponse.json({
success: true,
data: cachedDevices,
count: cachedDevices.length,
cached: true,
});
}
console.log(`Cache miss for key: ${cacheKey}, fetching from API`);
const addigyClient = getAddigyClient();
let devices;
if (policyId) {
// Get devices by policy
devices = await addigyClient.getDevicesByPolicy(policyId);
} else if (online === 'true') {
// Get only online devices
devices = await addigyClient.getOnlineDevices();
} else {
// Get all devices
devices = await addigyClient.getAllDevices();
}
// Cache the result for 5 minutes
await setCachedData(cacheKey, devices, 300);
return NextResponse.json({
success: true,
data: devices,
count: devices.length,
cached: false,
});
} catch (error) {
console.error('Error fetching Addigy devices:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { getAddigyClient } from '@/lib/services/addigy-factory';
export async function GET(request: Request) {
try {
const addigyClient = getAddigyClient();
const policies = await addigyClient.getAllPolicies();
return NextResponse.json({
success: true,
data: policies,
count: policies.length,
});
} catch (error) {
console.error('Error fetching Addigy policies:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}

View file

@ -39,7 +39,7 @@ export async function GET(
let assignedResourceName = null;
if (ticket.assignedResourceID) {
try {
const resource = await client.getEntityById('Resources', ticket.assignedResourceID);
const resource = await client.getEntityById('Resources', ticket.assignedResourceID) as any;
assignedResourceName = resource ? `${resource.firstName} ${resource.lastName}` : null;
} catch (err) {
console.error('Error fetching resource:', err);

View file

@ -42,45 +42,44 @@ export async function GET(
const rmmClient = getDattoRMMClient();
// First priority: Match by RMM Device UID if available
if (autotaskDevice.rmmDeviceUID) {
if (autotaskDevice?.rmmDeviceUID) {
const devices = await rmmClient.getAllDevices();
rmmDevice = devices.find(d => d.uid === autotaskDevice.rmmDeviceUID) || null;
rmmDevice = devices.find(d => d.uid === autotaskDevice?.rmmDeviceUID) || null;
if (rmmDevice) {
console.log('Matched by RMM UID:', rmmDevice.uid);
}
}
// Second priority: Match by RMM Device ID if available
if (!rmmDevice && autotaskDevice.rmmDeviceID) {
if (!rmmDevice && autotaskDevice?.rmmDeviceID) {
try {
rmmDevice = await rmmClient.getDeviceById(autotaskDevice.rmmDeviceID);
if (rmmDevice) {
console.log('Matched by RMM Device ID:', autotaskDevice.rmmDeviceID);
console.log('Matched by RMM ID:', rmmDevice.id);
}
} catch (err) {
console.log('Could not fetch by RMM Device ID');
console.log('Could not find device by RMM ID:', autotaskDevice.rmmDeviceID);
}
}
// Third priority: Get devices for this specific company and match
if (!rmmDevice && companyName) {
const companyDevices = await rmmClient.getDevicesByCompanyName(companyName);
console.log(`Found ${companyDevices.length} RMM devices for company: ${companyName}`);
// Try to match within company devices only
if (autotaskDevice.serialNumber) {
rmmDevice = companyDevices.find(d =>
d.serialNumber === autotaskDevice.serialNumber
) || null;
if (rmmDevice) console.log('Matched by serial number within company');
// Third priority: Match by serial number
if (!rmmDevice && autotaskDevice?.serialNumber) {
const devices = await rmmClient.getAllDevices();
rmmDevice = devices.find(d =>
d.serialNumber?.toLowerCase() === autotaskDevice?.serialNumber?.toLowerCase()
) || null;
if (rmmDevice) {
console.log('Matched by serial number:', rmmDevice.serialNumber);
}
}
if (!rmmDevice && autotaskDevice.rmmDeviceAuditHostname) {
rmmDevice = companyDevices.find(d =>
d.hostname?.toLowerCase() === autotaskDevice.rmmDeviceAuditHostname?.toLowerCase()
) || null;
if (rmmDevice) console.log('Matched by hostname within company');
}
// Fourth priority: Match by hostname
if (!rmmDevice && autotaskDevice?.rmmDeviceAuditHostname) {
const devices = await rmmClient.getAllDevices();
rmmDevice = devices.find(d =>
d.hostname?.toLowerCase() === autotaskDevice?.rmmDeviceAuditHostname?.toLowerCase()
) || null;
if (rmmDevice) console.log('Matched by hostname within company');
}
if (!rmmDevice) {

View file

@ -100,7 +100,7 @@ export async function GET(request: NextRequest) {
matchedBy: 'RMM UID'
});
matchedAutotaskIds.add(autotaskMatch.id);
matchedRmmIds.add(rmmDevice.id);
matchedRmmIds.add(String(rmmDevice.id));
matched = true;
}
}
@ -121,7 +121,7 @@ export async function GET(request: NextRequest) {
matchedBy: 'Serial Number'
});
matchedAutotaskIds.add(autotaskMatch.id);
matchedRmmIds.add(rmmDevice.id);
matchedRmmIds.add(String(rmmDevice.id));
matched = true;
}
}
@ -143,7 +143,7 @@ export async function GET(request: NextRequest) {
matchedBy: 'Hostname'
});
matchedAutotaskIds.add(autotaskMatch.id);
matchedRmmIds.add(rmmDevice.id);
matchedRmmIds.add(String(rmmDevice.id));
matched = true;
}
}
@ -166,7 +166,7 @@ export async function GET(request: NextRequest) {
matchedBy: 'IP Address'
});
matchedAutotaskIds.add(autotaskMatch.id);
matchedRmmIds.add(rmmDevice.id);
matchedRmmIds.add(String(rmmDevice.id));
matched = true;
}
}

View file

@ -21,9 +21,10 @@ export async function GET(
// Get assigned resource name if available
let assignedResourceName = null;
if (ticket.assignedResourceID) {
const ticketData = ticket as any;
if (ticketData.assignedResourceID) {
try {
const resource = await client.getEntityById('Resources', ticket.assignedResourceID);
const resource = await client.getEntityById('Resources', ticketData.assignedResourceID) as any;
assignedResourceName = resource ? `${resource.firstName} ${resource.lastName}` : null;
} catch (err) {
console.error('Error fetching resource:', err);

View file

@ -22,7 +22,7 @@ export async function GET(
let resourceName = null;
if (entry.resourceID) {
try {
const resource = await client.getEntityById('Resources', entry.resourceID);
const resource = await client.getEntityById('Resources', entry.resourceID) as any;
resourceName = resource ? `${resource.firstName} ${resource.lastName}` : null;
} catch (err) {
console.error('Error fetching resource:', err);

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAutotaskClient } from '@/lib/services/autotask-factory';
import { Ticket } from '@/lib/types/autotask';
import { getCachedData, setCachedData } from '@/lib/services/redis-client';
export async function GET(request: NextRequest) {
try {

View file

@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@ -69,7 +69,7 @@ interface DeviceComparison {
matchedBy?: string;
}
export default function ConfigurationItemsPage() {
function ConfigurationItemsContent() {
const searchParams = useSearchParams();
const [selectedCompany, setSelectedCompany] = useState<number | undefined>();
const [selectedCompanyName, setSelectedCompanyName] = useState<string>('');
@ -160,9 +160,9 @@ export default function ConfigurationItemsPage() {
});
// Handle company selection
const handleCompanyChange = (companyId: number | undefined, companyName: string) => {
const handleCompanyChange = (companyId: number | undefined, companyName?: string) => {
setSelectedCompany(companyId);
setSelectedCompanyName(companyName);
setSelectedCompanyName(companyName || '');
setSelectedItems(new Set()); // Clear selections when company changes
};
@ -756,3 +756,11 @@ export default function ConfigurationItemsPage() {
</div>
);
}
export default function ConfigurationItemsPage() {
return (
<Suspense fallback={<div className="p-8">Loading...</div>}>
<ConfigurationItemsContent />
</Suspense>
);
}

View file

@ -0,0 +1,67 @@
version: '3.8'
services:
# Redis cache service on custom port 6380 (instead of default 6379)
redis:
image: redis:7-alpine
container_name: psa-utils-redis
restart: unless-stopped
ports:
- "6380:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
# Next.js application on port 3100 (instead of 3000)
app:
build:
context: .
dockerfile: Dockerfile
container_name: psa-utils-app
restart: unless-stopped
ports:
- "3100:3100"
env_file:
- .env.local
environment:
# Application settings
NODE_ENV: production
PORT: 3100
# Redis configuration
REDIS_URL: redis://redis:6379
# Autotask API Configuration
AUTOTASK_API_URL: ${AUTOTASK_API_URL}
AUTOTASK_USERNAME: ${AUTOTASK_USERNAME}
AUTOTASK_SECRET: ${AUTOTASK_SECRET}
AUTOTASK_API_INTEGRATION_CODE: ${AUTOTASK_API_INTEGRATION_CODE}
# Datto RMM API Configuration
DATTO_RMM_API_URL: ${DATTO_RMM_API_URL}
DATTO_RMM_API_KEY: ${DATTO_RMM_API_KEY}
DATTO_RMM_API_SECRET: ${DATTO_RMM_API_SECRET}
# Addigy API Configuration
ADDIGY_API_URL: ${ADDIGY_API_URL}
ADDIGY_API_TOKEN: ${ADDIGY_API_TOKEN}
ADDIGY_ORG_ID: ${ADDIGY_ORG_ID}
depends_on:
redis:
condition: service_healthy
volumes:
# Mount .env.local for development (remove in production)
- ./.env.local:/app/.env.local:ro
volumes:
redis_data:
driver: local
networks:
default:
name: psa-utils-network

View file

@ -0,0 +1,558 @@
import {
AddigyConfig,
AddigyDevice,
AddigyPolicy,
AddigyOrganization,
AddigyDeviceWithApps,
AddigyApplication,
AddigyAlert,
AddigyMaintenanceItem,
AddigyMonitoringItem,
AddigyCustomFact,
AddigySoftwareItem,
AddigyVariable,
AddigyApiResponse,
AddigyApiError,
QueryParams,
} from '@/lib/types/addigy';
export class AddigyClient {
private config: AddigyConfig;
private rateLimiter: RateLimiter;
constructor(config: AddigyConfig) {
this.config = config;
// Addigy rate limit: 1000 requests per 10 seconds = 100 requests per second
this.rateLimiter = new RateLimiter(100);
}
private getAuthHeaders(): Record<string, string> {
return {
'x-api-key': this.config.apiToken,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
}
private async makeApiCall<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
await this.rateLimiter.throttle();
const url = `${this.config.apiUrl}${endpoint}`;
try {
const response = await fetch(url, {
...options,
headers: {
...this.getAuthHeaders(),
...options.headers,
},
});
const responseText = await response.text();
if (!response.ok) {
console.error(`Addigy API error: ${response.status} - ${responseText}`);
let errorMessage = `API Error: ${response.statusText}`;
try {
const errorData: AddigyApiError = JSON.parse(responseText);
errorMessage = errorData.message || errorData.error || errorMessage;
} catch (parseError) {
errorMessage = responseText || errorMessage;
}
throw new Error(errorMessage);
}
try {
return JSON.parse(responseText);
} catch (parseError) {
console.error('Failed to parse API response:', parseError);
throw new Error('Invalid JSON response from Addigy API');
}
} catch (error) {
console.error('API call failed:', error);
throw error;
}
}
private buildQueryString(params: QueryParams): string {
const queryParts: string[] = [];
if (params.page !== undefined) {
queryParts.push(`page=${params.page}`);
}
if (params.limit !== undefined) {
queryParts.push(`limit=${params.limit}`);
}
if (params.filters && params.filters.length > 0) {
const filtersJson = JSON.stringify({ filters: params.filters });
queryParts.push(`filters=${encodeURIComponent(filtersJson)}`);
}
return queryParts.length > 0 ? `?${queryParts.join('&')}` : '';
}
// Device Methods
async getAllDevices(params: QueryParams = {}): Promise<AddigyDevice[]> {
// Get organization ID - either from config or fetch from policies
let orgId = this.config.organizationId;
if (!orgId) {
// Fetch first policy to get orgid
const policies = await this.getAllPolicies({ limit: 1 });
if (policies.length > 0) {
orgId = policies[0].orgid;
console.log('Using orgid from first policy:', orgId);
} else {
throw new Error('No organization ID configured and no policies found');
}
}
// Addigy v2 /o/{orgid}/devices requires POST with JSON body
const body: any = {
page: params.page || 1,
per_page: params.limit || 500,
};
// Add filters if provided
if (params.filters && params.filters.length > 0) {
body.query = {
filters: params.filters,
};
}
console.log('Addigy devices request body:', JSON.stringify(body));
console.log('Fetching devices from:', `${this.config.apiUrl}/o/${orgId}/devices`);
const response = await this.makeApiCall<{ items?: any[]; data?: any[]; records?: any[] }>(
`/o/${orgId}/devices`,
{
method: 'POST',
body: JSON.stringify(body),
}
);
console.log('Addigy response keys:', Object.keys(response));
console.log('Addigy raw items count:', response.items?.length || 0);
// Transform Addigy's nested facts structure to flat device objects
const rawItems = response.items || response.data || response.records || [];
// Field name mapping from Addigy to UI-expected format
const fieldMapping: Record<string, string> = {
'device_name': 'Device Name',
'device_model_name': 'Device Model Name',
'serial_number': 'Serial Number',
'mac_os_x_version': 'MAC OS X Version',
'ios_version': 'iOS Version',
'current_user': 'Current User',
'free_disk_percentage': 'Free Disk Percentage',
'battery_percentage': 'Battery Percentage',
'firewall_enabled': 'Firewall Enabled',
'filevault_enabled': 'FileVault Enabled',
'agent_version': 'Agent Version',
};
const devices: AddigyDevice[] = rawItems.map((item: any) => {
const device: any = {};
// Extract values from nested facts structure
if (item.facts) {
for (const [key, factObj] of Object.entries(item.facts)) {
const fact = factObj as any;
if (fact && typeof fact === 'object' && 'value' in fact) {
// Use mapped field name if available, otherwise use original
const mappedKey = fieldMapping[key] || key;
device[mappedKey] = fact.value;
}
}
}
return device as AddigyDevice;
});
console.log('Transformed devices count:', devices.length);
if (devices.length > 0) {
console.log('First device keys:', Object.keys(devices[0]).slice(0, 10));
}
return devices;
}
async getDeviceById(deviceId: string): Promise<AddigyDevice | null> {
try {
const response = await this.makeApiCall<AddigyApiResponse<AddigyDevice>>(
`/devices/${deviceId}`,
{ method: 'GET' }
);
return response.data || null;
} catch (error) {
console.error(`Failed to get device ${deviceId}:`, error);
return null;
}
}
async getDevicesByPolicy(policyId: string): Promise<AddigyDevice[]> {
return this.getAllDevices({
filters: [
{
audit_field: 'policy_id',
type: 'string',
operation: 'equals',
value: policyId,
},
],
});
}
async getOnlineDevices(): Promise<AddigyDevice[]> {
return this.getAllDevices({
filters: [
{
audit_field: 'online',
type: 'boolean',
operation: 'equals',
value: true,
},
],
});
}
async getDeviceApplications(deviceId: string): Promise<AddigyApplication[]> {
try {
const response = await this.makeApiCall<{ items?: AddigyApplication[]; data?: AddigyApplication[] }>(
`/devices/${deviceId}/applications`,
{ method: 'GET' }
);
return response.items || response.data || [];
} catch (error) {
console.error(`Failed to get applications for device ${deviceId}:`, error);
return [];
}
}
// Policy Methods
async getAllPolicies(params: QueryParams = {}): Promise<AddigyPolicy[]> {
const body: any = {
page: params.page || 1,
per_page: params.limit || 100,
};
console.log('Fetching policies from:', `${this.config.apiUrl}/oa/policies/query`);
// Addigy policies endpoint returns array directly, not wrapped in items/data
const response = await this.makeApiCall<AddigyPolicy[]>(
`/oa/policies/query`,
{
method: 'POST',
body: JSON.stringify(body),
}
);
console.log('Policies response type:', Array.isArray(response) ? 'array' : typeof response);
console.log('Policies count:', Array.isArray(response) ? response.length : 0);
// Response is already an array
return Array.isArray(response) ? response : [];
}
async getPolicyById(policyId: string): Promise<AddigyPolicy | null> {
try {
const response = await this.makeApiCall<AddigyApiResponse<AddigyPolicy>>(
`/policies/${policyId}`,
{ method: 'GET' }
);
return response.data || null;
} catch (error) {
console.error(`Failed to get policy ${policyId}:`, error);
return null;
}
}
async createPolicy(policy: Partial<AddigyPolicy>): Promise<AddigyPolicy> {
const response = await this.makeApiCall<AddigyApiResponse<AddigyPolicy>>(
'/policies',
{
method: 'POST',
body: JSON.stringify(policy),
}
);
if (!response.data) {
throw new Error('Failed to create policy');
}
return response.data;
}
async updatePolicy(
policyId: string,
updates: Partial<AddigyPolicy>
): Promise<AddigyPolicy> {
const response = await this.makeApiCall<AddigyApiResponse<AddigyPolicy>>(
`/policies/${policyId}`,
{
method: 'PATCH',
body: JSON.stringify(updates),
}
);
if (!response.data) {
throw new Error('Failed to update policy');
}
return response.data;
}
// Organization Methods
async getOrganizations(params: QueryParams = {}): Promise<AddigyOrganization[]> {
const queryString = this.buildQueryString(params);
const response = await this.makeApiCall<{ items?: AddigyOrganization[]; data?: AddigyOrganization[] }>(
`/organizations${queryString}`,
{ method: 'GET' }
);
return response.items || response.data || [];
}
async getOrganizationById(orgId: string): Promise<AddigyOrganization | null> {
try {
const response = await this.makeApiCall<AddigyApiResponse<AddigyOrganization>>(
`/organizations/${orgId}`,
{ method: 'GET' }
);
return response.data || null;
} catch (error) {
console.error(`Failed to get organization ${orgId}:`, error);
return null;
}
}
// Alert Methods
async getAlerts(params: QueryParams = {}): Promise<AddigyAlert[]> {
const queryString = this.buildQueryString(params);
const response = await this.makeApiCall<{ items?: AddigyAlert[]; data?: AddigyAlert[] }>(
`/alerts${queryString}`,
{ method: 'GET' }
);
return response.items || response.data || [];
}
async getActiveAlerts(): Promise<AddigyAlert[]> {
return this.getAlerts({
filters: [
{
audit_field: 'resolved',
type: 'boolean',
operation: 'equals',
value: false,
},
],
});
}
async resolveAlert(alertId: string): Promise<void> {
await this.makeApiCall(`/alerts/${alertId}/resolve`, {
method: 'POST',
});
}
// Maintenance Methods
async getMaintenanceItems(params: QueryParams = {}): Promise<AddigyMaintenanceItem[]> {
const queryString = this.buildQueryString(params);
const response = await this.makeApiCall<{ items?: AddigyMaintenanceItem[]; data?: AddigyMaintenanceItem[] }>(
`/maintenance${queryString}`,
{ method: 'GET' }
);
return response.items || response.data || [];
}
// Monitoring Methods
async getMonitoringItems(params: QueryParams = {}): Promise<AddigyMonitoringItem[]> {
const queryString = this.buildQueryString(params);
const response = await this.makeApiCall<{ items?: AddigyMonitoringItem[]; data?: AddigyMonitoringItem[] }>(
`/monitoring${queryString}`,
{ method: 'GET' }
);
return response.items || response.data || [];
}
// Custom Facts / Variables
async getCustomFacts(deviceId: string): Promise<AddigyCustomFact[]> {
try {
const response = await this.makeApiCall<{ items?: AddigyCustomFact[]; data?: AddigyCustomFact[] }>(
`/devices/${deviceId}/facts`,
{ method: 'GET' }
);
return response.items || response.data || [];
} catch (error) {
console.error(`Failed to get custom facts for device ${deviceId}:`, error);
return [];
}
}
async createCustomFact(
deviceId: string,
fact: Partial<AddigyCustomFact>
): Promise<AddigyCustomFact> {
const response = await this.makeApiCall<AddigyApiResponse<AddigyCustomFact>>(
`/devices/${deviceId}/facts`,
{
method: 'POST',
body: JSON.stringify(fact),
}
);
if (!response.data) {
throw new Error('Failed to create custom fact');
}
return response.data;
}
async getVariables(params: QueryParams = {}): Promise<AddigyVariable[]> {
const queryString = this.buildQueryString(params);
const response = await this.makeApiCall<{ items?: AddigyVariable[]; data?: AddigyVariable[] }>(
`/variables${queryString}`,
{ method: 'GET' }
);
return response.items || response.data || [];
}
// Software Methods
async getSoftwareItems(params: QueryParams = {}): Promise<AddigySoftwareItem[]> {
const queryString = this.buildQueryString(params);
const response = await this.makeApiCall<{ items?: AddigySoftwareItem[]; data?: AddigySoftwareItem[] }>(
`/software${queryString}`,
{ method: 'GET' }
);
return response.items || response.data || [];
}
async getSoftwareVersions(softwareIdentifier: string): Promise<string[]> {
try {
const response = await this.makeApiCall<AddigyApiResponse<{ versions: string[] }>>(
`/software/${softwareIdentifier}/versions`,
{ method: 'GET' }
);
return response.data?.versions || [];
} catch (error) {
console.error(`Failed to get versions for software ${softwareIdentifier}:`, error);
return [];
}
}
// Device Actions
async assignDeviceToPolicy(deviceId: string, policyId: string): Promise<void> {
await this.makeApiCall(`/devices/${deviceId}/policy`, {
method: 'PATCH',
body: JSON.stringify({ policy_id: policyId }),
});
}
async removeDevice(deviceId: string): Promise<void> {
await this.makeApiCall(`/devices/${deviceId}`, {
method: 'DELETE',
});
}
// Utility Methods
async testConnection(): Promise<boolean> {
try {
await this.getAllPolicies({ limit: 1 });
return true;
} catch (error) {
console.error('Addigy connection test failed:', error);
return false;
}
}
// Paginated query to handle large datasets
async getAllDevicesPaginated(pageSize: number = 100): Promise<AddigyDevice[]> {
const allDevices: AddigyDevice[] = [];
let page = 1;
let hasMore = true;
while (hasMore) {
console.log(`Fetching devices page ${page} (max ${pageSize} records)...`);
const body = {
page,
per_page: pageSize,
};
const response = await this.makeApiCall<{ items?: AddigyDevice[]; data?: AddigyDevice[]; pages?: number }>(
`/devices`,
{
method: 'POST',
body: JSON.stringify(body),
}
);
const devices = response.items || response.data || [];
allDevices.push(...devices);
console.log(`Fetched ${devices.length} devices, total so far: ${allDevices.length}`);
// Check if we have more pages
hasMore = devices.length === pageSize && (!response.pages || page < response.pages);
page++;
// Safety limit to prevent infinite loops
if (page > 100) {
console.warn('Reached page limit (100) for devices');
break;
}
}
console.log(`Total devices fetched: ${allDevices.length}`);
return allDevices;
}
}
// Rate Limiter class
class RateLimiter {
private maxRequestsPerSecond: number;
private requestTimes: number[];
constructor(maxRequestsPerSecond = 100) {
this.maxRequestsPerSecond = maxRequestsPerSecond;
this.requestTimes = [];
}
async throttle(): Promise<void> {
const now = Date.now();
const oneSecondAgo = now - 1000;
// Remove old request times
this.requestTimes = this.requestTimes.filter((t) => t > oneSecondAgo);
// If at limit, wait
if (this.requestTimes.length >= this.maxRequestsPerSecond) {
const oldestRequest = this.requestTimes[0];
const waitTime = 1000 - (now - oldestRequest);
if (waitTime > 0) {
await new Promise((resolve) => setTimeout(resolve, waitTime));
}
}
this.requestTimes.push(Date.now());
}
}

View file

@ -0,0 +1,28 @@
import { AddigyClient } from './addigy-client';
import { AddigyConfig } from '@/lib/types/addigy';
let cachedAddigyClient: AddigyClient | null = null;
export function getAddigyClient(): AddigyClient {
if (cachedAddigyClient) {
return cachedAddigyClient;
}
const config: AddigyConfig = {
apiUrl: process.env.ADDIGY_API_URL || 'https://api.addigy.com/api/v2',
apiToken: process.env.ADDIGY_API_TOKEN || '',
organizationId: process.env.ADDIGY_ORG_ID,
};
// Validate required config
if (!config.apiToken) {
throw new Error('ADDIGY_API_TOKEN environment variable is required');
}
cachedAddigyClient = new AddigyClient(config);
return cachedAddigyClient;
}
export function clearAddigyClientCache(): void {
cachedAddigyClient = null;
}

View file

@ -246,7 +246,7 @@ export class DattoRMMClient {
}
if (criteria.macAddress &&
!device.macAddresses.some(mac =>
!device.macAddresses?.some(mac =>
mac.toLowerCase() === criteria.macAddress!.toLowerCase()
)) {
return false;

View file

@ -0,0 +1,99 @@
import Redis from 'ioredis';
let redisClient: Redis | null = null;
export function getRedisClient(): Redis | null {
if (!process.env.REDIS_URL) {
console.log('Redis URL not configured, caching disabled');
return null;
}
if (!redisClient) {
try {
redisClient = new Redis(process.env.REDIS_URL, {
maxRetriesPerRequest: 3,
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
},
reconnectOnError: (err) => {
const targetError = 'READONLY';
if (err.message.includes(targetError)) {
// Only reconnect when the error contains "READONLY"
return true;
}
return false;
},
});
redisClient.on('error', (err) => {
console.error('Redis Client Error:', err);
});
redisClient.on('connect', () => {
console.log('Redis Client Connected');
});
} catch (error) {
console.error('Failed to initialize Redis client:', error);
return null;
}
}
return redisClient;
}
export async function getCachedData<T>(key: string): Promise<T | null> {
const client = getRedisClient();
if (!client) return null;
try {
const data = await client.get(key);
if (data) {
return JSON.parse(data);
}
} catch (error) {
console.error(`Error getting cached data for key ${key}:`, error);
}
return null;
}
export async function setCachedData<T>(
key: string,
data: T,
ttlSeconds: number = 300 // Default 5 minutes
): Promise<void> {
const client = getRedisClient();
if (!client) return;
try {
await client.set(key, JSON.stringify(data), 'EX', ttlSeconds);
} catch (error) {
console.error(`Error setting cached data for key ${key}:`, error);
}
}
export async function deleteCachedData(pattern: string): Promise<void> {
const client = getRedisClient();
if (!client) return;
try {
const keys = await client.keys(pattern);
if (keys.length > 0) {
await client.del(...keys);
}
} catch (error) {
console.error(`Error deleting cached data for pattern ${pattern}:`, error);
}
}
export async function flushCache(): Promise<void> {
const client = getRedisClient();
if (!client) return;
try {
await client.flushdb();
console.log('Cache flushed successfully');
} catch (error) {
console.error('Error flushing cache:', error);
}
}

View file

@ -0,0 +1,213 @@
// Addigy API Types
export interface AddigyConfig {
apiUrl: string;
apiToken: string;
organizationId?: string; // Parent organization ID if needed
}
export interface PaginationParams {
page?: number;
limit?: number;
}
export interface FilterOperation {
audit_field: string;
type: 'string' | 'list' | 'number' | 'boolean' | 'date';
operation: 'equals' | 'contains' | 'greater_than' | 'less_than' | 'in' | 'not_in';
value: string | number | boolean | string[] | number[];
}
export interface QueryParams {
filters?: FilterOperation[];
page?: number;
limit?: number;
}
export interface AddigyDevice {
agentid: string;
'Device Name': string;
'Device Model Name': string;
'MAC OS X Version'?: string;
'iOS Version'?: string;
'Processor Type'?: string;
'Processor Speed (GHz)'?: number;
'Total Disk Space (GB)'?: number;
'Free Disk Space (GB)'?: number;
'Free Disk Percentage'?: number;
'Battery Percentage'?: number;
'Battery Charging'?: boolean;
'Battery Capacity Loss Percentage'?: number;
'Current User'?: string;
'Serial Number'?: string;
'Displays Serial Number'?: string[];
'Agent Version': string;
policy_id: string;
online: boolean;
'Firewall Enabled'?: boolean;
'FileVault Enabled'?: boolean;
'Remote Login Enabled'?: boolean;
'XCode Installed'?: boolean;
'SMART Failing'?: boolean;
'Has Wireless'?: boolean;
Timezone?: string;
'Warranty Expiration Date'?: string;
'Warranty Days Left'?: number;
'TeamViewer Client Id'?: string;
'Crashplan Days Since Last Backup'?: number;
'Last Check In'?: string;
// Additional fields from device audits
[key: string]: string | number | boolean | string[] | undefined;
}
export interface AddigyPolicy {
policyId: string;
orgid: string;
name: string;
parent?: string | null;
color?: string;
icon?: string;
download_path?: string;
agent_path?: string;
last_deployed?: string;
creation_time?: number;
agent_version?: string;
ignore_updates?: boolean;
instructions?: any[];
vnc_settings?: any;
splashtop_settings?: any;
ssh_settings?: any;
system_updates_settings?: any;
collector_settings?: any;
prebuilt_app_settings?: any;
}
export interface AddigyOrganization {
orgid: string;
name: string;
parent_org_id?: string;
domain?: string;
created_at?: string;
}
export interface AddigyApplication {
name: string;
version: string;
path: string;
bundle_id?: string;
installed_date?: string;
}
export interface AddigyDeviceWithApps {
agentid: string;
'Device Name': string;
'Device Model Name': string;
'MAC OS X Version'?: string;
'iOS Version'?: string;
'Agent Version': string;
policy_id: string;
online: boolean;
'Serial Number'?: string;
'Current User'?: string;
'Free Disk Percentage'?: number;
'Battery Percentage'?: number;
'Firewall Enabled'?: boolean;
'FileVault Enabled'?: boolean;
installed_applications?: AddigyApplication[];
[key: string]: string | number | boolean | string[] | AddigyApplication[] | undefined;
}
export interface AddigyAlert {
id: string;
device_id: string;
alert_type: string;
severity: 'critical' | 'warning' | 'info';
message: string;
created_at: string;
resolved: boolean;
resolved_at?: string;
}
export interface AddigyMaintenanceItem {
id: string;
name: string;
description?: string;
policy_id?: string;
enabled: boolean;
schedule?: string;
last_run?: string;
next_run?: string;
}
export interface AddigyMonitoringItem {
id: string;
name: string;
description?: string;
policy_id?: string;
enabled: boolean;
condition: string;
alert_level: 'critical' | 'warning' | 'info';
}
export interface AddigyCustomFact {
id: string;
name: string;
value: string | number | boolean;
device_id: string;
created_at: string;
updated_at: string;
}
export interface AddigySoftwareItem {
identifier: string;
name: string;
version?: string;
versions?: string[];
category?: string;
description?: string;
install_type?: string;
}
export interface AddigyVariable {
id: string;
name: string;
value: string;
policy_id?: string;
device_id?: string;
description?: string;
}
export interface AddigyApiResponse<T> {
data?: T;
items?: T[];
total?: number;
page?: number;
limit?: number;
pages?: number;
}
export interface AddigyApiError {
error: string;
message: string;
status_code?: number;
details?: Record<string, unknown>;
}
// Enums for common values
export enum AddigyDeviceStatus {
Online = 'online',
Offline = 'offline',
}
export enum AddigyAlertSeverity {
Critical = 'critical',
Warning = 'warning',
Info = 'info',
}
export enum AddigyDeviceType {
Mac = 'mac',
iPhone = 'iphone',
iPad = 'ipad',
AppleTV = 'appletv',
}

View file

@ -2,7 +2,12 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: 'standalone',
reactCompiler: true,
// Allow external images if needed
images: {
domains: [],
},
};
export default nextConfig;

View file

@ -25,6 +25,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"ioredis": "^5.8.2",
"lucide-react": "^0.548.0",
"next": "16.0.0",
"next-themes": "^0.4.6",
@ -32,6 +33,7 @@
"react-day-picker": "^9.11.1",
"react-dom": "19.2.0",
"react-hook-form": "^7.65.0",
"redis": "^5.9.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.1.12"