diff --git a/.env.docker b/.env.docker new file mode 100644 index 0000000..4935513 --- /dev/null +++ b/.env.docker @@ -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 diff --git a/ADDIGY_API_GUIDE.md b/ADDIGY_API_GUIDE.md new file mode 100644 index 0000000..7d95167 --- /dev/null +++ b/ADDIGY_API_GUIDE.md @@ -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 diff --git a/autotask-app/.dockerignore b/autotask-app/.dockerignore new file mode 100644 index 0000000..d515e46 --- /dev/null +++ b/autotask-app/.dockerignore @@ -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 diff --git a/autotask-app/DOCKER_README.md b/autotask-app/DOCKER_README.md new file mode 100644 index 0000000..381f413 --- /dev/null +++ b/autotask-app/DOCKER_README.md @@ -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 +``` diff --git a/autotask-app/Dockerfile b/autotask-app/Dockerfile new file mode 100644 index 0000000..509f07b --- /dev/null +++ b/autotask-app/Dockerfile @@ -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"] diff --git a/autotask-app/app/addigy-devices/page.tsx b/autotask-app/app/addigy-devices/page.tsx new file mode 100644 index 0000000..34f1585 --- /dev/null +++ b/autotask-app/app/addigy-devices/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+

Addigy Devices

+
+ + +
+
+ + {error && ( +
+ Error: {error} +
+ )} + + {loading ? ( +
+
+

Loading devices...

+
+ ) : ( + <> +
+ Found {devices.length} device{devices.length !== 1 ? 's' : ''} +
+ +
+
+ + + + + + + + + + + + + + {devices.map((device) => ( + + + + + + + + + + ))} + +
+ Device Name + + Model + + OS Version + + Current User + + Status + + Free Disk + + Security +
+
+ {device['Device Name']} +
+
+ {device['Serial Number'] || 'N/A'} +
+
+ {device['Device Model Name'] || 'Unknown'} + + {device['MAC OS X Version'] || + device['iOS Version'] || + 'N/A'} + + {device['Current User'] || 'N/A'} + + + {device.online ? 'Online' : 'Offline'} + + + {device['Free Disk Percentage'] !== undefined ? ( +
+ + {device['Free Disk Percentage']}% + +
+ ) : ( + 'N/A' + )} +
+
+ + FW: {device['Firewall Enabled'] ? '✓' : '✗'} + + + FV: {device['FileVault Enabled'] ? '✓' : '✗'} + +
+
+
+
+ + )} +
+ ); +} diff --git a/autotask-app/app/api/addigy-devices/route.ts b/autotask-app/app/api/addigy-devices/route.ts new file mode 100644 index 0000000..156cc73 --- /dev/null +++ b/autotask-app/app/api/addigy-devices/route.ts @@ -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(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 } + ); + } +} diff --git a/autotask-app/app/api/addigy-policies/route.ts b/autotask-app/app/api/addigy-policies/route.ts new file mode 100644 index 0000000..492b277 --- /dev/null +++ b/autotask-app/app/api/addigy-policies/route.ts @@ -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 } + ); + } +} diff --git a/autotask-app/app/api/config-items/[id]/tickets/route.ts b/autotask-app/app/api/config-items/[id]/tickets/route.ts index 75a675b..2ab9043 100644 --- a/autotask-app/app/api/config-items/[id]/tickets/route.ts +++ b/autotask-app/app/api/config-items/[id]/tickets/route.ts @@ -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); diff --git a/autotask-app/app/api/configuration-items/[id]/route.ts b/autotask-app/app/api/configuration-items/[id]/route.ts index 81cf5a0..b744632 100644 --- a/autotask-app/app/api/configuration-items/[id]/route.ts +++ b/autotask-app/app/api/configuration-items/[id]/route.ts @@ -42,47 +42,46 @@ 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'); - } - - if (!rmmDevice && autotaskDevice.rmmDeviceAuditHostname) { - rmmDevice = companyDevices.find(d => - d.hostname?.toLowerCase() === autotaskDevice.rmmDeviceAuditHostname?.toLowerCase() - ) || null; - if (rmmDevice) console.log('Matched by hostname 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); } } + // 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) { console.log('No RMM device match found'); } else { diff --git a/autotask-app/app/api/rmm-devices/route.ts b/autotask-app/app/api/rmm-devices/route.ts index b77618d..585ea2b 100644 --- a/autotask-app/app/api/rmm-devices/route.ts +++ b/autotask-app/app/api/rmm-devices/route.ts @@ -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; } } diff --git a/autotask-app/app/api/tickets/[id]/route.ts b/autotask-app/app/api/tickets/[id]/route.ts index 6dd2f3e..2edcc5d 100644 --- a/autotask-app/app/api/tickets/[id]/route.ts +++ b/autotask-app/app/api/tickets/[id]/route.ts @@ -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); diff --git a/autotask-app/app/api/tickets/[id]/time-entries/route.ts b/autotask-app/app/api/tickets/[id]/time-entries/route.ts index 082b547..b9197e7 100644 --- a/autotask-app/app/api/tickets/[id]/time-entries/route.ts +++ b/autotask-app/app/api/tickets/[id]/time-entries/route.ts @@ -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); diff --git a/autotask-app/app/api/tickets/route.ts b/autotask-app/app/api/tickets/route.ts index db485a2..da98eb5 100644 --- a/autotask-app/app/api/tickets/route.ts +++ b/autotask-app/app/api/tickets/route.ts @@ -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 { diff --git a/autotask-app/app/configuration-items/page.tsx b/autotask-app/app/configuration-items/page.tsx index d39c9b1..463f6f5 100644 --- a/autotask-app/app/configuration-items/page.tsx +++ b/autotask-app/app/configuration-items/page.tsx @@ -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(); const [selectedCompanyName, setSelectedCompanyName] = useState(''); @@ -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() { ); } + +export default function ConfigurationItemsPage() { + return ( + Loading...}> + + + ); +} diff --git a/autotask-app/docker-compose.yml b/autotask-app/docker-compose.yml new file mode 100644 index 0000000..1d125b4 --- /dev/null +++ b/autotask-app/docker-compose.yml @@ -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 diff --git a/autotask-app/lib/services/addigy-client.ts b/autotask-app/lib/services/addigy-client.ts new file mode 100644 index 0000000..45fcf7b --- /dev/null +++ b/autotask-app/lib/services/addigy-client.ts @@ -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 { + return { + 'x-api-key': this.config.apiToken, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + } + + private async makeApiCall( + endpoint: string, + options: RequestInit = {} + ): Promise { + 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 { + // 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 = { + '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 { + try { + const response = await this.makeApiCall>( + `/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 { + return this.getAllDevices({ + filters: [ + { + audit_field: 'policy_id', + type: 'string', + operation: 'equals', + value: policyId, + }, + ], + }); + } + + async getOnlineDevices(): Promise { + return this.getAllDevices({ + filters: [ + { + audit_field: 'online', + type: 'boolean', + operation: 'equals', + value: true, + }, + ], + }); + } + + async getDeviceApplications(deviceId: string): Promise { + 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 { + 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( + `/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 { + try { + const response = await this.makeApiCall>( + `/policies/${policyId}`, + { method: 'GET' } + ); + + return response.data || null; + } catch (error) { + console.error(`Failed to get policy ${policyId}:`, error); + return null; + } + } + + async createPolicy(policy: Partial): Promise { + const response = await this.makeApiCall>( + '/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 + ): Promise { + const response = await this.makeApiCall>( + `/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 { + 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 { + try { + const response = await this.makeApiCall>( + `/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 { + 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 { + return this.getAlerts({ + filters: [ + { + audit_field: 'resolved', + type: 'boolean', + operation: 'equals', + value: false, + }, + ], + }); + } + + async resolveAlert(alertId: string): Promise { + await this.makeApiCall(`/alerts/${alertId}/resolve`, { + method: 'POST', + }); + } + + // Maintenance Methods + async getMaintenanceItems(params: QueryParams = {}): Promise { + 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 { + 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 { + 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 + ): Promise { + const response = await this.makeApiCall>( + `/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 { + 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 { + 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 { + try { + const response = await this.makeApiCall>( + `/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 { + await this.makeApiCall(`/devices/${deviceId}/policy`, { + method: 'PATCH', + body: JSON.stringify({ policy_id: policyId }), + }); + } + + async removeDevice(deviceId: string): Promise { + await this.makeApiCall(`/devices/${deviceId}`, { + method: 'DELETE', + }); + } + + // Utility Methods + async testConnection(): Promise { + 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 { + 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 { + 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()); + } +} diff --git a/autotask-app/lib/services/addigy-factory.ts b/autotask-app/lib/services/addigy-factory.ts new file mode 100644 index 0000000..2d5d13c --- /dev/null +++ b/autotask-app/lib/services/addigy-factory.ts @@ -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; +} diff --git a/autotask-app/lib/services/datto-rmm-client.ts b/autotask-app/lib/services/datto-rmm-client.ts index b4c36a3..21d63bb 100644 --- a/autotask-app/lib/services/datto-rmm-client.ts +++ b/autotask-app/lib/services/datto-rmm-client.ts @@ -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; diff --git a/autotask-app/lib/services/redis-client.ts b/autotask-app/lib/services/redis-client.ts new file mode 100644 index 0000000..f7dbfdc --- /dev/null +++ b/autotask-app/lib/services/redis-client.ts @@ -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(key: string): Promise { + 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( + key: string, + data: T, + ttlSeconds: number = 300 // Default 5 minutes +): Promise { + 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 { + 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 { + const client = getRedisClient(); + if (!client) return; + + try { + await client.flushdb(); + console.log('Cache flushed successfully'); + } catch (error) { + console.error('Error flushing cache:', error); + } +} diff --git a/autotask-app/lib/types/addigy.ts b/autotask-app/lib/types/addigy.ts new file mode 100644 index 0000000..704d3dd --- /dev/null +++ b/autotask-app/lib/types/addigy.ts @@ -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 { + data?: T; + items?: T[]; + total?: number; + page?: number; + limit?: number; + pages?: number; +} + +export interface AddigyApiError { + error: string; + message: string; + status_code?: number; + details?: Record; +} + +// 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', +} diff --git a/autotask-app/next.config.ts b/autotask-app/next.config.ts index 66e1566..99da492 100644 --- a/autotask-app/next.config.ts +++ b/autotask-app/next.config.ts @@ -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; diff --git a/autotask-app/package.json b/autotask-app/package.json index 4ef3866..5090364 100644 --- a/autotask-app/package.json +++ b/autotask-app/package.json @@ -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"