feat(service-integrations): phase 2 — integration widget & app form UI

- Add 6 renderer components: StatCard, Gauge, List, Progress, AlertBanner, Chart
- Add IntegrationWidget container with auto-refresh, loading, error states
- Add IntegrationAlertOverlay for layout-level critical alerts
- Add IntegrationConfigFields for dynamic form generation from Zod schemas
- Register integration type in WidgetRenderer
- Extend WidgetCreationForm with integration app/endpoint pickers
- Extend AppForm with integration config section and test connection button
- Add /api/integrations/alerts endpoint
This commit is contained in:
2026-03-25 22:07:51 +03:00
parent 114dee57a8
commit 50e8519220
25 changed files with 1360 additions and 1 deletions
+57
View File
@@ -0,0 +1,57 @@
# Feature Context: Service Integrations
## Configuration
- **Development mode:** Automated
- **Execution mode:** Orchestrator
- **Strategy:** Big Bang
- **Build:** `npm run build`
- **Test:** `npm test`
- **Lint:** `npm run lint`
- **Check:** `npm run check`
- **Dev server:** `npm run dev` (port: 5173)
## Current State
Feature not yet started. Codebase is stable on master with 14 widget types, full app CRUD, healthcheck system, and notification infrastructure.
## Existing Patterns to Follow
- **Service pattern**: See `appService.ts`, `metricService.ts`, `systemStatsService.ts` for adapter/client/transform pattern
- **Caching**: `metricService.ts` has TTL-based cache — reuse for integration data
- **API envelope**: All routes use `success()`, `error()`, `paginated()` from response helpers
- **Zod validation**: All inputs validated via Zod schemas in `validators.ts`
- **Widget rendering**: `WidgetRenderer.svelte` dispatches to type-specific components
- **Config storage**: Widget configs stored as stringified JSON in `Widget.config`
- **Encrypted JSON**: `SystemSettings.oauthConfig` pattern for storing credentials
## Temporary Workarounds
(none yet)
## Cross-Phase Dependencies
- Phase 1 (architecture) must complete before all other phases
- Phase 2 (UI) must complete before Phase 10 (polish)
- Phases 3-9 (individual integrations) depend only on Phase 1
- Phase 10 depends on all prior phases
## Deferred Work
(none yet)
## Failed Approaches
(none yet)
## Review Findings Log
(none yet)
## Phase Execution Log
| Phase | Agent Used | Test Writer | Parallel | Notes |
|-------|-----------|-------------|----------|-------|
| (not started) | | | | |
## Environment & Runtime Notes
- Platform: Windows 10, Git Bash shell
- Database: SQLite via Prisma
- NUT integration requires raw TCP socket (Node `net` module)
- Deluge uses JSON-RPC, NPM uses session-based auth
## Implementation Notes
- Integration credentials stored encrypted in `integrationConfig` (JSON string on App model)
- NUT is the only non-HTTP integration — uses direct TCP protocol
- Alert banners (NUT on-battery, Authentik brute-force) need layout-level rendering, not just widget-level
+57
View File
@@ -0,0 +1,57 @@
# Feature: Service Integrations
**Branch:** `feature/service-integrations`
**Base branch:** `master`
**Created:** 2026-03-25
**Status:** 🟡 In Progress
**Strategy:** Big Bang
**Mode:** Automated
**Execution:** Orchestrator
## Summary
Transform the dashboard from a link page into a real-time command center by pulling live data from self-hosted services. Integrations are associated with apps — when you register Pi-hole as an app, you attach the "Pi-hole" integration to it. A new `integration` widget type displays live data endpoints with specialized renderers (gauges, stat cards, lists, charts, progress bars, alert banners).
## Build & Test Commands
- **Build:** `npm run build`
- **Test:** `npm test`
- **Lint:** `npm run lint`
- **Check:** `npm run check`
## Phases
- [ ] Phase 1: Integration Architecture Foundation [domain: backend] → [subplan](./phase-1-architecture.md)
- [ ] Phase 2: Integration Widget & App Form UI [domain: frontend] → [subplan](./phase-2-widget-ui.md)
- [ ] Phase 3: NUT/UPS Integration [domain: backend] → [subplan](./phase-3-nut.md)
- [ ] Phase 4: Pi-hole Integration [domain: backend] → [subplan](./phase-4-pihole.md)
- [ ] Phase 5: Portainer Integration [domain: backend] → [subplan](./phase-5-portainer.md)
- [ ] Phase 6: Gitea Integration [domain: backend] → [subplan](./phase-6-gitea.md)
- [ ] Phase 7: Nginx Proxy Manager Integration [domain: backend] → [subplan](./phase-7-npm.md)
- [ ] Phase 8: Authentik Integration [domain: backend] → [subplan](./phase-8-authentik.md)
- [ ] Phase 9: Media Integrations (Emby + Immich + Deluge + MeTube) [domain: backend] → [subplan](./phase-9-media.md)
- [ ] Phase 10: Planka Integration + Polish [domain: fullstack] → [subplan](./phase-10-planka.md)
## Phase Progress Log
| Phase | Domain | Status | Review | Build | Committed |
|-------|--------|--------|--------|-------|-----------|
| Phase 1: Architecture | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 2: Widget & App Form UI | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 3: NUT/UPS | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 4: Pi-hole | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 5: Portainer | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 6: Gitea | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 7: Nginx Proxy Manager | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 8: Authentik | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 9: Media (Emby+Immich+Deluge+MeTube) | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 10: Planka + Polish | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
## Parallel Execution Plan
Phases 3+4 (NUT + Pi-hole), 5+6 (Portainer + Gitea), and 7+8 (NPM + Authentik) are independent pairs that can run in parallel.
## Final Review
- [ ] Comprehensive code review
- [ ] Full build passes
- [ ] Full test suite passes
- [ ] Merged to `master`
@@ -0,0 +1,62 @@
# Phase 1: Integration Architecture Foundation
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Build the core integration framework: TypeScript interfaces, registry pattern, shared cache, Prisma schema changes, API routes, and updated app service. This phase unlocks all subsequent integration phases.
## Tasks
- [ ] Task 1: Create `src/lib/server/integrations/types.ts` — Define `Integration`, `IntegrationEndpoint`, `IntegrationData`, `IntegrationConfig` interfaces. Each integration has: id, name, icon, authConfigSchema (Zod), extraConfigSchema (optional Zod), endpoints array, testConnection method, fetchData method.
- [ ] Task 2: Create `src/lib/server/integrations/registry.ts` — Registry singleton with `register(integration)`, `get(id)`, `list()`, `getForApp(app)` methods. Auto-imports integrations from subdirectories.
- [ ] Task 3: Create `src/lib/server/integrations/cache.ts` — TTL-based cache for integration data. Key: `${appId}:${endpointId}`, configurable TTL per endpoint. Reuse pattern from `metricService.ts`.
- [ ] Task 4: Create `src/lib/server/integrations/encryption.ts` — Encrypt/decrypt integration config JSON using AES-256-GCM with key from `INTEGRATION_ENCRYPTION_KEY` env var (fallback to `JWT_SECRET` for dev).
- [ ] Task 5: Update Prisma schema — Add `integrationType String?`, `integrationConfig String?`, `integrationEnabled Boolean @default(false)` to `App` model. Run `npx prisma db push`.
- [ ] Task 6: Update `src/lib/types/app.ts` — Add integration fields to `AppRecord` interface.
- [ ] Task 7: Update `src/lib/server/services/appService.ts` — Handle integration fields on create/update. Encrypt `integrationConfig` before storing, decrypt on read.
- [ ] Task 8: Update `src/lib/utils/validators.ts` — Add `integration` to `WidgetType` enum. Add Zod schema for integration widget config: `{ appId: string, endpointId: string, refreshInterval?: number }`.
- [ ] Task 9: Create API route `src/routes/api/integrations/+server.ts``GET` returns list of available integration types with their endpoints and config schemas.
- [ ] Task 10: Create API route `src/routes/api/integrations/test/+server.ts``POST { appId, integrationType, config }` tests connection to the service.
- [ ] Task 11: Create API route `src/routes/api/integrations/[appId]/data/[endpointId]/+server.ts``GET` fetches live data from integration endpoint, uses cache.
- [ ] Task 12: Create `src/lib/server/integrations/base.ts` — Abstract base class or helper functions for common integration patterns (HTTP fetch with timeout, error wrapping, response parsing).
## Files to Modify/Create
- `src/lib/server/integrations/types.ts` — new: core interfaces
- `src/lib/server/integrations/registry.ts` — new: integration registry
- `src/lib/server/integrations/cache.ts` — new: TTL cache
- `src/lib/server/integrations/encryption.ts` — new: config encryption
- `src/lib/server/integrations/base.ts` — new: shared helpers
- `prisma/schema.prisma` — modify: add 3 fields to App model
- `src/lib/types/app.ts` — modify: add integration fields
- `src/lib/server/services/appService.ts` — modify: handle integration fields
- `src/lib/utils/validators.ts` — modify: add integration widget type + config schema
- `src/routes/api/integrations/+server.ts` — new: list integrations
- `src/routes/api/integrations/test/+server.ts` — new: test connection
- `src/routes/api/integrations/[appId]/data/[endpointId]/+server.ts` — new: fetch data
## Acceptance Criteria
- Integration interfaces are well-typed and extensible
- Registry can register and retrieve integrations
- Cache prevents repeated API calls within TTL
- Prisma schema has integration fields, migration runs clean
- App service encrypts/decrypts integration config transparently
- API routes return proper envelope responses
- All Zod schemas validate correctly
## Notes
- Encryption key: use `INTEGRATION_ENCRYPTION_KEY` env var, fallback to `JWT_SECRET` for development simplicity
- The registry should be designed so adding a new integration is just: create a directory, implement the interface, register it
- Cache should handle concurrent requests to the same endpoint gracefully
- Big Bang strategy: build/tests may not pass after this phase since the integration widget type is registered but has no frontend renderer yet
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Types are comprehensive and well-documented
- [ ] Encryption is properly implemented (no plaintext secrets in DB)
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -0,0 +1,59 @@
# Phase 10: Planka Integration + Polish
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
Implement the Planka integration for task/project management visibility, then polish all integration components with proper error states, loading skeletons, empty states, and consistent styling.
## Tasks
### Planka Integration
- [ ] Task 1: Create `src/lib/server/integrations/planka/schema.ts` — Auth config: `{ email: string, password: string }`.
- [ ] Task 2: Create `src/lib/server/integrations/planka/client.ts` — HTTP client. Session-based auth (POST `/api/access-tokens` → Bearer token). Endpoints: `/api/cards`, `/api/boards`.
- [ ] Task 3: Create `src/lib/server/integrations/planka/transform.ts` — My cards → list with board/list context, overdue → list (red highlight), board summary → stat-card (card counts by list).
- [ ] Task 4: Create `src/lib/server/integrations/planka/index.ts` — Endpoints: `my-cards` (list), `overdue` (list), `board-summary` (stat-card).
- [ ] Task 5: Register Planka integration in registry.
### Polish & Error Handling
- [ ] Task 6: Add loading skeleton states to all renderer components (StatCard, Gauge, List, Progress, Chart).
- [ ] Task 7: Add empty state messaging to all renderers ("No data available", "No active torrents", etc.).
- [ ] Task 8: Add error state handling to IntegrationWidget — show error message with retry button when fetch fails.
- [ ] Task 9: Verify all integrations handle network timeouts, invalid credentials, and unexpected response formats gracefully.
- [ ] Task 10: Add integration type icons to the app form dropdown and widget creation form.
- [ ] Task 11: Ensure all renderers respect card sizes (compact/medium/large) and are responsive.
- [ ] Task 12: Review and standardize all integration endpoint refresh intervals (sensible defaults).
## Files to Modify/Create
- `src/lib/server/integrations/planka/{schema,client,transform,index}.ts` — new (4 files)
- `src/lib/server/integrations/registry.ts` — modify: register Planka
- `src/lib/components/widget/integration/*.svelte` — modify: add loading/empty/error states
- `src/lib/components/widget/integration/IntegrationWidget.svelte` — modify: error handling + retry
- `src/lib/components/app/AppForm.svelte` — modify: integration type icons
## Acceptance Criteria
- Planka: my cards list, overdue cards highlighted, board summary
- All renderers have loading, empty, and error states
- All integrations handle network errors gracefully
- Consistent styling across all integration components
- Responsive layout on mobile
- Build passes, tests pass, lint clean
## Notes
- Planka uses session-based auth similar to NPM — reuse the pattern
- This is the final phase — build and tests MUST pass here (Big Bang strategy final gate)
- Polish should cover ALL renderers and integrations, not just Planka
- Overdue detection: compare card due date to current date, highlight in red
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
- [ ] All integrations tested end-to-end
- [ ] Loading/error/empty states verified
## Handoff to Next Phase
<!-- Final phase — no handoff needed -->
@@ -0,0 +1,65 @@
# Phase 2: Integration Widget & App Form UI
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Build the frontend components: IntegrationWidget with all endpoint renderers, extend AppForm with integration configuration UI, and update WidgetCreationForm to support integration widgets.
## Tasks
- [ ] Task 1: Create `src/lib/components/widget/integration/IntegrationWidget.svelte` — Container component that resolves integration type from app, fetches endpoint data via `/api/integrations/[appId]/data/[endpointId]`, handles loading/error states, delegates to appropriate renderer.
- [ ] Task 2: Create `src/lib/components/widget/integration/StatCardRenderer.svelte` — Single big number with label, optional trend arrow (up/down/flat), color-coded by threshold. Used for: query counts, session counts, library stats, etc.
- [ ] Task 3: Create `src/lib/components/widget/integration/GaugeRenderer.svelte` — Circular SVG gauge (0-100%). Color-coded: green (<60%), yellow (60-85%), red (>85%). Used for: battery %, CPU %, disk usage.
- [ ] Task 4: Create `src/lib/components/widget/integration/ListRenderer.svelte` — Scrollable list of items with icon, title, subtitle, optional badge. Used for: recent commits, top blocked domains, container list, etc.
- [ ] Task 5: Create `src/lib/components/widget/integration/ProgressRenderer.svelte` — Multiple progress bars with labels and percentages. Used for: torrent downloads, download queue.
- [ ] Task 6: Create `src/lib/components/widget/integration/AlertBannerRenderer.svelte` — Full-width alert banner with icon, message, severity (info/warning/critical). Used for: UPS on battery, brute force detection.
- [ ] Task 7: Create `src/lib/components/widget/integration/ChartRenderer.svelte` — Simple bar or line chart using SVG. Used for: query history, uptime charts.
- [ ] Task 8: Register `integration` widget type in `src/lib/components/widget/WidgetRenderer.svelte` — Import IntegrationWidget, add case to the type switch.
- [ ] Task 9: Extend `src/lib/components/app/AppForm.svelte` — Add collapsible "Integration" section with: type dropdown (from `/api/integrations`), dynamic auth config fields rendered from Zod schema, "Test Connection" button, enable/disable toggle.
- [ ] Task 10: Create `src/lib/components/app/IntegrationConfigFields.svelte` — Dynamic form field generator that renders input fields based on a Zod schema (string → text input, number → number input, boolean → toggle). Used by AppForm.
- [ ] Task 11: Update `src/lib/components/widget/WidgetCreationForm.svelte` — Add integration widget option: app picker (only apps with integration enabled) → endpoint picker → refresh interval.
- [ ] Task 12: Create `src/lib/components/widget/integration/IntegrationAlertOverlay.svelte` — Layout-level component that polls for critical alerts (UPS on battery, brute force) and renders AlertBannerRenderer at the top of the page. Add to root layout.
## Files to Modify/Create
- `src/lib/components/widget/integration/IntegrationWidget.svelte` — new
- `src/lib/components/widget/integration/StatCardRenderer.svelte` — new
- `src/lib/components/widget/integration/GaugeRenderer.svelte` — new
- `src/lib/components/widget/integration/ListRenderer.svelte` — new
- `src/lib/components/widget/integration/ProgressRenderer.svelte` — new
- `src/lib/components/widget/integration/AlertBannerRenderer.svelte` — new
- `src/lib/components/widget/integration/ChartRenderer.svelte` — new
- `src/lib/components/widget/integration/IntegrationAlertOverlay.svelte` — new
- `src/lib/components/widget/WidgetRenderer.svelte` — modify: add integration case
- `src/lib/components/app/AppForm.svelte` — modify: add integration section
- `src/lib/components/app/IntegrationConfigFields.svelte` — new
- `src/lib/components/widget/WidgetCreationForm.svelte` — modify: add integration option
- `src/routes/+layout.svelte` — modify: add IntegrationAlertOverlay
## Acceptance Criteria
- IntegrationWidget fetches data and renders correct renderer based on endpoint type
- All 6 renderers handle loading, error, and empty states gracefully
- AppForm shows integration config only when a type is selected
- Dynamic form fields match the integration's auth schema
- Test Connection button validates and shows success/failure
- WidgetCreationForm allows creating integration widgets
- Alert overlay polls and shows critical alerts at layout level
## Notes
- Renderers should be visually consistent with existing widget styles (card sizes, colors, typography)
- Use Svelte 5 runes ($state, $derived) for all reactive state
- Auto-refresh: IntegrationWidget should poll at the endpoint's refreshInterval
- Big Bang: this phase depends on Phase 1 types but the renderers can be built with mock data initially
- ⚠️ Temporary breakage: Integration widget type is registered but no real integrations exist yet — widgets will show "no data" until Phase 3+
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Responsive design (mobile + desktop)
- [ ] Loading/error/empty states handled
- [ ] Accessible (keyboard nav, screen reader labels)
## Handoff to Next Phase
<!-- Filled in after completion -->
+51
View File
@@ -0,0 +1,51 @@
# Phase 3: NUT/UPS Integration
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Implement the NUT/UPS integration using direct TCP protocol to communicate with NUT servers. This is the only non-HTTP integration — it connects directly to the NUT daemon on port 3493.
## Tasks
- [ ] Task 1: Create `src/lib/server/integrations/nut/schema.ts` — Zod schemas for auth config (`{ nutHost: string, nutPort: number, upsName: string }`) and endpoint responses.
- [ ] Task 2: Create `src/lib/server/integrations/nut/client.ts` — NUT TCP protocol client using Node `net` module. Implement commands: `LIST UPS`, `LIST VAR <upsName>`, `GET VAR <upsName> <varName>`. Parse NUT protocol responses. Handle connection timeout and cleanup.
- [ ] Task 3: Create `src/lib/server/integrations/nut/transform.ts` — Transform raw NUT variables to widget-ready data. Map: `battery.charge` → gauge %, `ups.load` → gauge %, `battery.runtime` → stat-card (formatted as Xh Ym), `ups.status` → alert level (OL=ok, OB=warning, LB=critical).
- [ ] Task 4: Create `src/lib/server/integrations/nut/index.ts` — Integration implementation. Register with registry. Endpoints: `battery-status` (gauge), `load` (gauge), `runtime` (stat-card), `ups-status` (alert-banner). testConnection: attempt TCP connect + `LIST UPS`.
- [ ] Task 5: Register NUT integration in `src/lib/server/integrations/registry.ts`.
- [ ] Task 6: Create API route for NUT alerts `src/routes/api/integrations/alerts/+server.ts``GET` returns active critical alerts across all apps with integrations (UPS on battery, etc.). Used by IntegrationAlertOverlay.
## Files to Modify/Create
- `src/lib/server/integrations/nut/schema.ts` — new
- `src/lib/server/integrations/nut/client.ts` — new
- `src/lib/server/integrations/nut/transform.ts` — new
- `src/lib/server/integrations/nut/index.ts` — new
- `src/lib/server/integrations/registry.ts` — modify: register NUT
- `src/routes/api/integrations/alerts/+server.ts` — new
## Acceptance Criteria
- NUT client connects to NUT server via TCP and retrieves UPS variables
- Battery charge displayed as percentage gauge
- Load displayed as percentage gauge
- Runtime formatted as human-readable time
- Status correctly maps OL/OB/LB to alert levels
- Alert banner fires when status is OB or LB
- Connection test validates TCP connectivity
- Handles connection timeouts and refused connections gracefully
## Notes
- NUT protocol is text-based over TCP: send `GET VAR <ups> <var>\n`, receive `VAR <ups> <var> "<value>"\n`
- Default port: 3493
- Does NOT use app.url — uses nutHost/nutPort/upsName from extraConfig
- TCP connections should be short-lived (connect, query, disconnect) — don't keep persistent connections
- Common UPS variables: battery.charge, battery.runtime, ups.load, ups.status, input.voltage, output.voltage
## Review Checklist
- [ ] All tasks completed
- [ ] TCP client handles timeouts and errors
- [ ] No resource leaks (sockets always closed)
- [ ] Code follows project conventions
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -0,0 +1,45 @@
# Phase 4: Pi-hole Integration
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Implement Pi-hole integration using its admin API to display DNS blocking statistics, top blocked domains, and query logs.
## Tasks
- [ ] Task 1: Create `src/lib/server/integrations/pihole/schema.ts` — Zod schemas for auth config (`{ apiToken: string }`) and endpoint responses (summary stats, top items, query log).
- [ ] Task 2: Create `src/lib/server/integrations/pihole/client.ts` — HTTP client for Pi-hole API. Endpoints: `{app.url}/admin/api.php?summary`, `?topItems=N`, `?getAllQueries=N`, `?getQuerySources`. Include auth token as `&auth=<token>`.
- [ ] Task 3: Create `src/lib/server/integrations/pihole/transform.ts` — Transform API responses: summary → stat-card data (total queries, blocked, block %, clients), topItems → list data, queries → list with allow/block indicator.
- [ ] Task 4: Create `src/lib/server/integrations/pihole/index.ts` — Integration implementation. Endpoints: `stats-summary` (stat-card), `top-blocked` (list), `query-log` (list), `gravity-status` (stat-card). testConnection: fetch summary endpoint.
- [ ] Task 5: Register Pi-hole integration in registry.
## Files to Modify/Create
- `src/lib/server/integrations/pihole/schema.ts` — new
- `src/lib/server/integrations/pihole/client.ts` — new
- `src/lib/server/integrations/pihole/transform.ts` — new
- `src/lib/server/integrations/pihole/index.ts` — new
- `src/lib/server/integrations/registry.ts` — modify: register Pi-hole
## Acceptance Criteria
- Stats summary shows: total queries, blocked queries, block percentage, unique clients
- Top blocked domains list with counts
- Query log with domain, client, allow/block status
- Gravity status shows last update time and blocklist count
- Test connection validates API token
- Handles Pi-hole v5 and v6 API differences gracefully
## Notes
- Pi-hole API is simple GET-based with auth token as query parameter
- Some endpoints require authentication (topItems, queries), summary is often public
- Response format is flat JSON — easy to parse
- Consider Pi-hole v6 (new API format) — detect version and adapt
## Review Checklist
- [ ] All tasks completed
- [ ] API responses properly validated
- [ ] Code follows project conventions
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -0,0 +1,44 @@
# Phase 5: Portainer Integration
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Implement Portainer integration to display container and stack status from Docker environments managed by Portainer.
## Tasks
- [ ] Task 1: Create `src/lib/server/integrations/portainer/schema.ts` — Zod schemas for auth config (`{ apiKey: string, endpointId: number }`) and endpoint responses.
- [ ] Task 2: Create `src/lib/server/integrations/portainer/client.ts` — HTTP client for Portainer API. Auth via `X-API-Key` header. Endpoints: `/api/endpoints/{id}/docker/containers/json`, `/api/stacks`, `/api/endpoints/{id}/docker/containers/{cid}/json`.
- [ ] Task 3: Create `src/lib/server/integrations/portainer/transform.ts` — Transform: containers → summary (running/stopped/error counts as stat-card), container list with state + CPU/memory, stacks → list with up/down status.
- [ ] Task 4: Create `src/lib/server/integrations/portainer/index.ts` — Integration implementation. Endpoints: `container-summary` (stat-card), `container-list` (list), `stack-status` (list). testConnection: fetch endpoints list.
- [ ] Task 5: Register Portainer integration in registry.
## Files to Modify/Create
- `src/lib/server/integrations/portainer/schema.ts` — new
- `src/lib/server/integrations/portainer/client.ts` — new
- `src/lib/server/integrations/portainer/transform.ts` — new
- `src/lib/server/integrations/portainer/index.ts` — new
- `src/lib/server/integrations/registry.ts` — modify: register Portainer
## Acceptance Criteria
- Container summary shows running/stopped/error counts
- Container list shows name, state, image, CPU/memory usage
- Stack status shows stack names with up/down indicators
- Test connection validates API key and endpoint ID
- Handles multiple Portainer endpoints
## Notes
- Portainer API uses API key in `X-API-Key` header
- Container stats (CPU/memory) require a separate API call per container — limit to top N for performance
- Stack status comes from a separate endpoint
- endpointId is required — Portainer manages multiple Docker hosts
## Review Checklist
- [ ] All tasks completed
- [ ] Performance: limited container stats calls
- [ ] Code follows project conventions
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -0,0 +1,45 @@
# Phase 6: Gitea Integration
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Implement Gitea integration to display recent commits, open pull requests, CI/CD status, and releases from Gitea instances.
## Tasks
- [ ] Task 1: Create `src/lib/server/integrations/gitea/schema.ts` — Zod schemas for auth config (`{ apiToken: string, repos?: string[] }`) and endpoint responses.
- [ ] Task 2: Create `src/lib/server/integrations/gitea/client.ts` — HTTP client for Gitea API v1. Auth via `Authorization: token <apiToken>` header. Endpoints: `/api/v1/repos/search`, `/api/v1/repos/{owner}/{repo}/commits`, `/api/v1/repos/{owner}/{repo}/pulls`, `/api/v1/repos/{owner}/{repo}/releases`, `/api/v1/repos/{owner}/{repo}/actions/runners`.
- [ ] Task 3: Create `src/lib/server/integrations/gitea/transform.ts` — Transform: commits → list with author/message/date, PRs → stat-card (open count) + list, CI → list with pass/fail badges, releases → list with tag/date.
- [ ] Task 4: Create `src/lib/server/integrations/gitea/index.ts` — Integration implementation. Endpoints: `recent-commits` (list), `open-prs` (stat-card), `ci-status` (list), `releases` (list). testConnection: fetch authenticated user.
- [ ] Task 5: Register Gitea integration in registry.
## Files to Modify/Create
- `src/lib/server/integrations/gitea/schema.ts` — new
- `src/lib/server/integrations/gitea/client.ts` — new
- `src/lib/server/integrations/gitea/transform.ts` — new
- `src/lib/server/integrations/gitea/index.ts` — new
- `src/lib/server/integrations/registry.ts` — modify: register Gitea
## Acceptance Criteria
- Recent commits across repos with author, message, timestamp
- Open PR count as stat-card, PR list with title/author/repo
- CI/CD status with workflow name and pass/fail badge
- Releases list with tag, name, and date
- Optional repo filter (if configured, only show data from those repos)
- Test connection validates API token
## Notes
- If `repos` config is empty, auto-discover all accessible repos
- Limit commits/PRs to last N per repo for performance
- CI status depends on Gitea Actions being enabled (act-runner)
- API pagination: use `?limit=N&page=1` parameters
## Review Checklist
- [ ] All tasks completed
- [ ] Handles empty repos list (auto-discovery)
- [ ] Code follows project conventions
## Handoff to Next Phase
<!-- Filled in after completion -->
+45
View File
@@ -0,0 +1,45 @@
# Phase 7: Nginx Proxy Manager Integration
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Implement Nginx Proxy Manager integration to display proxy hosts, SSL certificate expiry warnings, and upstream reachability status.
## Tasks
- [ ] Task 1: Create `src/lib/server/integrations/npm/schema.ts` — Zod schemas for auth config (`{ email: string, password: string }`) and endpoint responses.
- [ ] Task 2: Create `src/lib/server/integrations/npm/client.ts` — HTTP client for NPM API. Session-based auth: POST `/api/tokens` with email+password → get JWT → use for subsequent requests. Endpoints: `/api/nginx/proxy-hosts`, `/api/nginx/certificates`, `/api/nginx/proxy-hosts/{id}`. Cache session token.
- [ ] Task 3: Create `src/lib/server/integrations/npm/transform.ts` — Transform: proxy hosts → list with domain/status/SSL info, certificates → list with expiry countdown (red <7d, yellow <14d, green >14d), upstream → list with reachable/unreachable indicator.
- [ ] Task 4: Create `src/lib/server/integrations/npm/index.ts` — Integration implementation. Endpoints: `proxy-hosts` (list), `ssl-certificates` (list), `upstream-status` (list). testConnection: authenticate and fetch proxy hosts.
- [ ] Task 5: Register NPM integration in registry.
## Files to Modify/Create
- `src/lib/server/integrations/npm/schema.ts` — new
- `src/lib/server/integrations/npm/client.ts` — new
- `src/lib/server/integrations/npm/transform.ts` — new
- `src/lib/server/integrations/npm/index.ts` — new
- `src/lib/server/integrations/registry.ts` — modify: register NPM
## Acceptance Criteria
- Proxy hosts list with domain name, enabled/disabled status
- SSL certificates with expiry date and color-coded countdown
- Upstream status shows reachable/unreachable per host
- Session-based auth works (login → token → API calls)
- Handles expired session token (re-authenticate automatically)
- Test connection validates email/password credentials
## Notes
- NPM uses session-based auth, not API keys — need to login first, cache the JWT
- SSL expiry is the highest-value feature here — highlight expiring certs prominently
- The session token has a limited lifetime — handle re-authentication on 401 responses
- NPM API is relatively simple and well-documented
## Review Checklist
- [ ] All tasks completed
- [ ] Session token caching and re-auth implemented
- [ ] Code follows project conventions
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -0,0 +1,45 @@
# Phase 8: Authentik Integration
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Implement Authentik integration for security monitoring: active sessions, login events, brute force detection, and user/group statistics.
## Tasks
- [ ] Task 1: Create `src/lib/server/integrations/authentik/schema.ts` — Zod schemas for auth config (`{ apiToken: string }`) and endpoint responses.
- [ ] Task 2: Create `src/lib/server/integrations/authentik/client.ts` — HTTP client for Authentik API v3. Auth via `Authorization: Bearer <apiToken>` header. Endpoints: `/api/v3/core/sessions/`, `/api/v3/events/events/?action=login`, `/api/v3/events/events/?action=login_failed`, `/api/v3/core/users/`, `/api/v3/core/groups/`.
- [ ] Task 3: Create `src/lib/server/integrations/authentik/transform.ts` — Transform: sessions → stat-card (count), login events → list with username/IP/timestamp/success, failed logins → brute force detection (>5 failures from same IP in 10 min = alert), user/group stats → stat-card.
- [ ] Task 4: Create `src/lib/server/integrations/authentik/index.ts` — Integration implementation. Endpoints: `sessions` (stat-card), `login-events` (list), `security-alerts` (alert-banner), `user-stats` (stat-card). testConnection: fetch authenticated user info.
- [ ] Task 5: Register Authentik integration in registry.
## Files to Modify/Create
- `src/lib/server/integrations/authentik/schema.ts` — new
- `src/lib/server/integrations/authentik/client.ts` — new
- `src/lib/server/integrations/authentik/transform.ts` — new
- `src/lib/server/integrations/authentik/index.ts` — new
- `src/lib/server/integrations/registry.ts` — modify: register Authentik
## Acceptance Criteria
- Active sessions count displayed as stat-card
- Login events list with username, IP, timestamp, success/failure
- Brute force detection: alert when >5 failed logins from same IP within 10 minutes
- User/group stats displayed as stat-card
- Security alerts surface via alert banner system
- Test connection validates API token
## Notes
- Authentik API v3 uses pagination — handle `?page=N&page_size=N`
- Brute force detection is computed client-side from event data, not a native Authentik feature
- The threshold (5 failures / 10 min) should be configurable via extra config
- Security alerts should integrate with the alert banner overlay from Phase 2
## Review Checklist
- [ ] All tasks completed
- [ ] Brute force detection logic is sound
- [ ] Code follows project conventions
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -0,0 +1,69 @@
# Phase 9: Media Integrations (Emby + Immich + Deluge + MeTube)
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Implement four media-related integrations bundled together since each is relatively small: Emby (media server), Immich (photo management), Deluge (torrent client), and MeTube (video downloader).
## Tasks
### Emby
- [ ] Task 1: Create `src/lib/server/integrations/emby/schema.ts` — Auth config: `{ apiKey: string }`.
- [ ] Task 2: Create `src/lib/server/integrations/emby/client.ts` — HTTP client. Endpoints: `/emby/Sessions?api_key=<key>`, `/emby/Items/Counts?api_key=<key>`, `/emby/Items/Latest?api_key=<key>`.
- [ ] Task 3: Create `src/lib/server/integrations/emby/transform.ts` — Now playing → list (user, title, transcode/direct, quality), library stats → stat-card (movies, shows, episodes), recently added → list with titles.
- [ ] Task 4: Create `src/lib/server/integrations/emby/index.ts` — Endpoints: `now-playing` (list), `library-stats` (stat-card), `recently-added` (list), `active-streams` (stat-card).
### Immich
- [ ] Task 5: Create `src/lib/server/integrations/immich/schema.ts` — Auth config: `{ apiKey: string }`.
- [ ] Task 6: Create `src/lib/server/integrations/immich/client.ts` — HTTP client. Auth via `x-api-key` header. Endpoints: `/api/server-info/statistics`, `/api/assets?order=desc&limit=10`, `/api/memories`.
- [ ] Task 7: Create `src/lib/server/integrations/immich/transform.ts` — Library stats → stat-card (photos, videos, storage), recent uploads → list, memory of day → stat-card.
- [ ] Task 8: Create `src/lib/server/integrations/immich/index.ts` — Endpoints: `library-stats` (stat-card), `recent-uploads` (list), `memory-of-day` (stat-card).
### Deluge
- [ ] Task 9: Create `src/lib/server/integrations/deluge/schema.ts` — Auth config: `{ password: string }`.
- [ ] Task 10: Create `src/lib/server/integrations/deluge/client.ts` — JSON-RPC client at `{app.url}/json`. Auth flow: call `auth.login` first, then `web.update_ui` / `core.get_torrents_status`. Handle session cookie.
- [ ] Task 11: Create `src/lib/server/integrations/deluge/transform.ts` — Active torrents → progress list (name, %, speed), transfer speed → gauge, disk space → gauge.
- [ ] Task 12: Create `src/lib/server/integrations/deluge/index.ts` — Endpoints: `active-torrents` (progress), `transfer-speed` (gauge), `disk-space` (gauge).
### MeTube
- [ ] Task 13: Create `src/lib/server/integrations/metube/schema.ts` — Auth config: `{}` (no auth).
- [ ] Task 14: Create `src/lib/server/integrations/metube/client.ts` — HTTP client. Endpoint: `/api/history`, `/api/queue`.
- [ ] Task 15: Create `src/lib/server/integrations/metube/transform.ts` — Download queue → progress list (title, %, status).
- [ ] Task 16: Create `src/lib/server/integrations/metube/index.ts` — Endpoints: `download-queue` (progress).
### Registration
- [ ] Task 17: Register all four integrations in registry.
## Files to Modify/Create
- `src/lib/server/integrations/emby/{schema,client,transform,index}.ts` — new (4 files)
- `src/lib/server/integrations/immich/{schema,client,transform,index}.ts` — new (4 files)
- `src/lib/server/integrations/deluge/{schema,client,transform,index}.ts` — new (4 files)
- `src/lib/server/integrations/metube/{schema,client,transform,index}.ts` — new (4 files)
- `src/lib/server/integrations/registry.ts` — modify: register all four
## Acceptance Criteria
- All four integrations fetch and transform data correctly
- Emby: now playing, library stats, recently added
- Immich: library stats, recent uploads, memory of day
- Deluge: active torrents with progress, transfer speed, disk space
- MeTube: download queue with progress
- Deluge JSON-RPC auth flow handles session cookies
- MeTube works without any auth
- All test connections validate properly
## Notes
- Deluge JSON-RPC is the trickiest — requires auth.login call first, then session cookie for subsequent calls
- MeTube has a very limited API — may need to poll /api/queue for real-time data
- Immich API versions change frequently — target current stable
- Emby API key goes in query string, not header
## Review Checklist
- [ ] All tasks completed
- [ ] Deluge session handling is robust
- [ ] Code follows project conventions
- [ ] Each integration is self-contained in its directory
## Handoff to Next Phase
<!-- Filled in after completion -->
+140
View File
@@ -4,6 +4,7 @@
import type { z } from 'zod'; import type { z } from 'zod';
import type { createAppSchema } from '$lib/utils/validators.js'; import type { createAppSchema } from '$lib/utils/validators.js';
import AppIconPicker from './AppIconPicker.svelte'; import AppIconPicker from './AppIconPicker.svelte';
import IntegrationConfigFields from './IntegrationConfigFields.svelte';
import AppUrlPreview from './AppUrlPreview.svelte'; import AppUrlPreview from './AppUrlPreview.svelte';
import IconGrid from '$lib/components/ui/IconGrid.svelte'; import IconGrid from '$lib/components/ui/IconGrid.svelte';
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte'; import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
@@ -22,6 +23,53 @@
}); });
let showAdvanced = $state(false); let showAdvanced = $state(false);
let showIntegration = $state(false);
let availableIntegrations = $state<Array<{ id: string; name: string; icon: string; authConfigFields: any[]; extraConfigFields: any[] }>>([]);
let integrationConfig = $state<Record<string, unknown>>({});
let testingConnection = $state(false);
let testResult = $state<{ success: boolean; message: string } | null>(null);
$effect(() => {
fetch('/api/integrations')
.then((r) => r.json())
.then((json) => {
if (json.success) availableIntegrations = json.data ?? [];
})
.catch(() => {});
});
const selectedIntegration = $derived(
availableIntegrations.find((i) => i.id === ($form.integrationType ?? ''))
);
async function handleTestConnection() {
if (!$form.integrationType || !$form.url) return;
testingConnection = true;
testResult = null;
try {
const res = await fetch('/api/integrations/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
integrationType: $form.integrationType,
appUrl: $form.url,
config: integrationConfig
})
});
const json = await res.json();
testResult = json.data ?? { success: false, message: json.error ?? 'Unknown error' };
} catch {
testResult = { success: false, message: 'Network error' };
} finally {
testingConnection = false;
}
}
$effect(() => {
if ($form.integrationType && Object.keys(integrationConfig).length > 0) {
$form.integrationConfig = JSON.stringify(integrationConfig);
}
});
const healthcheckMethodItems: IconGridItem[] = [ const healthcheckMethodItems: IconGridItem[] = [
{ value: 'GET', icon: '🔍', label: 'GET', desc: 'Full response' }, { value: 'GET', icon: '🔍', label: 'GET', desc: 'Full response' },
@@ -234,6 +282,98 @@
</div> </div>
{/if} {/if}
<!-- Integration Section -->
<button
type="button"
onclick={() => (showIntegration = !showIntegration)}
class="text-sm text-muted-foreground hover:text-foreground"
>
{showIntegration ? 'Hide' : 'Show'} Integration Settings
</button>
{#if showIntegration}
<div class="space-y-4 rounded-md border border-border p-4">
<div class="flex items-center gap-2">
<input
id="integrationEnabled"
name="integrationEnabled"
type="checkbox"
bind:checked={$form.integrationEnabled}
class="rounded border-input"
/>
<label for="integrationEnabled" class="text-sm text-card-foreground">
Enable Integration
</label>
</div>
{#if $form.integrationEnabled}
<div>
<label for="integrationType" class="mb-1 block text-sm font-medium text-card-foreground">
Integration Type
</label>
<select
id="integrationType"
name="integrationType"
bind:value={$form.integrationType}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">None</option>
{#each availableIntegrations as integration (integration.id)}
<option value={integration.id}>{integration.name}</option>
{/each}
</select>
</div>
{#if selectedIntegration}
<div>
<h4 class="mb-2 text-sm font-medium text-card-foreground">Authentication</h4>
<IntegrationConfigFields
fields={selectedIntegration.authConfigFields}
values={integrationConfig}
onchange={(name, value) => {
integrationConfig = { ...integrationConfig, [name]: value };
}}
idPrefix="int-auth"
/>
</div>
{#if selectedIntegration.extraConfigFields.length > 0}
<div>
<h4 class="mb-2 text-sm font-medium text-card-foreground">Extra Configuration</h4>
<IntegrationConfigFields
fields={selectedIntegration.extraConfigFields}
values={integrationConfig}
onchange={(name, value) => {
integrationConfig = { ...integrationConfig, [name]: value };
}}
idPrefix="int-extra"
/>
</div>
{/if}
<div class="flex items-center gap-3">
<button
type="button"
onclick={handleTestConnection}
disabled={testingConnection}
class="rounded-md border border-border px-3 py-1.5 text-sm text-foreground hover:bg-accent disabled:opacity-50"
>
{testingConnection ? 'Testing...' : 'Test Connection'}
</button>
{#if testResult}
<span class="text-sm {testResult.success ? 'text-green-500' : 'text-destructive'}">
{testResult.message}
</span>
{/if}
</div>
{/if}
{/if}
<input type="hidden" name="integrationConfig" value={$form.integrationConfig ?? ''} />
<input type="hidden" name="integrationType" value={$form.integrationType ?? ''} />
</div>
{/if}
<div class="flex justify-end"> <div class="flex justify-end">
<button <button
type="submit" type="submit"
@@ -0,0 +1,57 @@
<script lang="ts">
import type { IntegrationFieldDescriptor } from '$lib/server/integrations/types.js';
interface Props {
fields: IntegrationFieldDescriptor[];
values: Record<string, unknown>;
onchange: (name: string, value: unknown) => void;
idPrefix?: string;
}
let { fields, values, onchange, idPrefix = 'int' }: Props = $props();
const inputClass = 'w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30';
</script>
<div class="space-y-3">
{#each fields as field (field.name)}
<div>
<label for="{idPrefix}-{field.name}" class="mb-1 block text-sm font-medium text-card-foreground">
{field.label}
{#if field.required}
<span class="text-destructive">*</span>
{/if}
</label>
{#if field.type === 'boolean'}
<label class="flex items-center gap-2">
<input
id="{idPrefix}-{field.name}"
type="checkbox"
checked={!!values[field.name]}
onchange={(e) => onchange(field.name, e.currentTarget.checked)}
class="h-4 w-4 rounded border-input accent-primary"
/>
<span class="text-sm text-muted-foreground">{field.description ?? ''}</span>
</label>
{:else if field.type === 'number'}
<input
id="{idPrefix}-{field.name}"
type="number"
value={values[field.name] ?? ''}
oninput={(e) => onchange(field.name, parseInt(e.currentTarget.value) || 0)}
class={inputClass}
placeholder={field.description ?? ''}
/>
{:else}
<input
id="{idPrefix}-{field.name}"
type={field.name.toLowerCase().includes('password') || field.name.toLowerCase().includes('secret') ? 'password' : 'text'}
value={values[field.name] ?? ''}
oninput={(e) => onchange(field.name, e.currentTarget.value)}
class={inputClass}
placeholder={field.description ?? field.label}
/>
{/if}
</div>
{/each}
</div>
@@ -84,6 +84,13 @@
let cameraRefreshInterval = $state(10); let cameraRefreshInterval = $state(10);
let cameraAspectRatio = $state('16/9'); let cameraAspectRatio = $state('16/9');
// Integration fields
let integrationAppId = $state('');
let integrationEndpointId = $state('');
let integrationRefreshInterval = $state(60);
let integrationApps = $state<Array<{ id: string; name: string; integrationType: string | null; integrationEnabled: boolean }>>([]);
let integrationEndpoints = $state<Array<{ id: string; name: string }>>([]);
const widgetTypeItems: IconGridItem[] = [ const widgetTypeItems: IconGridItem[] = [
{ value: 'app', icon: '🖥️', label: 'App' }, { value: 'app', icon: '🖥️', label: 'App' },
{ value: 'bookmark', icon: '🔖', label: 'Bookmark' }, { value: 'bookmark', icon: '🔖', label: 'Bookmark' },
@@ -97,7 +104,8 @@
{ value: 'markdown', icon: '📄', label: 'Markdown' }, { value: 'markdown', icon: '📄', label: 'Markdown' },
{ value: 'metric', icon: '📈', label: 'Metric' }, { value: 'metric', icon: '📈', label: 'Metric' },
{ value: 'link_group', icon: '🔗', label: 'Links' }, { value: 'link_group', icon: '🔗', label: 'Links' },
{ value: 'camera', icon: '📷', label: 'Camera' } { value: 'camera', icon: '📷', label: 'Camera' },
{ value: 'integration', icon: '🔌', label: 'Integration' }
]; ];
const noteFormatItems: IconGridItem[] = [ const noteFormatItems: IconGridItem[] = [
@@ -117,6 +125,36 @@
{ value: 'prometheus', icon: '📊', label: 'Prometheus' } { value: 'prometheus', icon: '📊', label: 'Prometheus' }
]; ];
$effect(() => {
if (selectedWidgetType === 'integration') {
fetch('/api/apps')
.then((r) => r.json())
.then((json) => {
if (json.success) {
integrationApps = (json.data ?? []).filter((a: any) => a.integrationEnabled && a.integrationType);
}
})
.catch(() => {});
}
});
$effect(() => {
if (integrationAppId) {
const app = integrationApps.find((a) => a.id === integrationAppId);
if (app?.integrationType) {
fetch('/api/integrations')
.then((r) => r.json())
.then((json) => {
if (json.success) {
const integration = (json.data ?? []).find((i: any) => i.id === app.integrationType);
integrationEndpoints = integration?.endpoints ?? [];
}
})
.catch(() => {});
}
}
});
const appPickerItems: EntityPickerItem[] = $derived( const appPickerItems: EntityPickerItem[] = $derived(
apps.map((app) => ({ apps.map((app) => ({
value: app.id, value: app.id,
@@ -166,6 +204,11 @@
cameraType = 'image'; cameraType = 'image';
cameraRefreshInterval = 10; cameraRefreshInterval = 10;
cameraAspectRatio = '16/9'; cameraAspectRatio = '16/9';
integrationAppId = '';
integrationEndpointId = '';
integrationRefreshInterval = 60;
integrationApps = [];
integrationEndpoints = [];
} }
function handleSubmitWidget() { function handleSubmitWidget() {
@@ -265,6 +308,12 @@
widgetData.refreshInterval = cameraRefreshInterval; widgetData.refreshInterval = cameraRefreshInterval;
widgetData.aspectRatio = cameraAspectRatio; widgetData.aspectRatio = cameraAspectRatio;
break; break;
case 'integration':
if (!integrationAppId || !integrationEndpointId) return;
widgetData.appId = integrationAppId;
widgetData.endpointId = integrationEndpointId;
widgetData.refreshInterval = integrationRefreshInterval;
break;
default: default:
return; return;
} }
@@ -917,6 +966,56 @@
</div> </div>
</div> </div>
</div> </div>
{:else if selectedWidgetType === 'integration'}
<div class="space-y-3">
<div>
<label for="int-app-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">App with Integration</label>
{#if integrationApps.length === 0}
<p class="text-sm text-muted-foreground">No apps with integrations configured. Add an integration to an app first.</p>
{:else}
<select
id="int-app-{sectionId}"
bind:value={integrationAppId}
class={inputClass}
>
<option value="">Select an app...</option>
{#each integrationApps as app (app.id)}
<option value={app.id}>{app.name} ({app.integrationType})</option>
{/each}
</select>
{/if}
</div>
{#if integrationAppId && integrationEndpoints.length > 0}
<div>
<label for="int-endpoint-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Data Endpoint</label>
<select
id="int-endpoint-{sectionId}"
bind:value={integrationEndpointId}
class={inputClass}
>
<option value="">Select endpoint...</option>
{#each integrationEndpoints as ep (ep.id)}
<option value={ep.id}>{ep.name}</option>
{/each}
</select>
</div>
<div>
<label for="int-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Refresh: {integrationRefreshInterval}s
</label>
<input
id="int-refresh-{sectionId}"
type="range"
bind:value={integrationRefreshInterval}
min="10"
max="600"
step="10"
class="w-full accent-primary"
/>
</div>
{/if}
</div>
{/if} {/if}
<div class="mt-3"> <div class="mt-3">
@@ -13,6 +13,7 @@
import MetricWidget from './MetricWidget.svelte'; import MetricWidget from './MetricWidget.svelte';
import LinkGroupWidget from './LinkGroupWidget.svelte'; import LinkGroupWidget from './LinkGroupWidget.svelte';
import CameraStreamWidget from './CameraStreamWidget.svelte'; import CameraStreamWidget from './CameraStreamWidget.svelte';
import IntegrationWidget from './integration/IntegrationWidget.svelte';
interface AppData { interface AppData {
id: string; id: string;
@@ -116,6 +117,12 @@
refreshInterval: parsedConfig.refreshInterval ?? 10, refreshInterval: parsedConfig.refreshInterval ?? 10,
aspectRatio: parsedConfig.aspectRatio ?? '16/9' aspectRatio: parsedConfig.aspectRatio ?? '16/9'
}} /> }} />
{:else if widget.type === 'integration'}
<IntegrationWidget config={{
appId: parsedConfig.appId ?? '',
endpointId: parsedConfig.endpointId ?? '',
refreshInterval: parsedConfig.refreshInterval ?? 60
}} />
{:else} {:else}
<div class="flex h-full items-center justify-center rounded-xl border border-border bg-card p-4"> <div class="flex h-full items-center justify-center rounded-xl border border-border bg-card p-4">
<span class="text-xs text-muted-foreground">{$t('widget.type', { values: { type: widget.type } })}</span> <span class="text-xs text-muted-foreground">{$t('widget.type', { values: { type: widget.type } })}</span>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { AlertBannerData } from '$lib/server/integrations/types.js';
interface Props {
data: AlertBannerData;
}
let { data }: Props = $props();
const severityStyles = $derived.by(() => {
switch (data.severity) {
case 'critical': return 'border-red-500/50 bg-red-500/10 text-red-400';
case 'warning': return 'border-yellow-500/50 bg-yellow-500/10 text-yellow-400';
default: return 'border-blue-500/50 bg-blue-500/10 text-blue-400';
}
});
const severityIcon = $derived(
data.severity === 'critical' ? '!!' : data.severity === 'warning' ? '!' : 'i'
);
</script>
<div class="flex items-start gap-3 rounded-lg border p-3 {severityStyles}">
<span class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-current text-xs font-bold">
{severityIcon}
</span>
<div class="min-w-0 flex-1">
<p class="font-semibold">{data.title}</p>
<p class="text-sm opacity-80">{data.message}</p>
</div>
</div>
@@ -0,0 +1,54 @@
<script lang="ts">
import type { ChartData } from '$lib/server/integrations/types.js';
interface Props {
data: ChartData;
}
let { data }: Props = $props();
const maxValue = $derived(
Math.max(...data.datasets.flatMap((d) => d.values), 1)
);
const barWidth = $derived(
data.labels.length > 0 ? Math.max(100 / data.labels.length - 2, 4) : 10
);
const defaultColors = ['#6366f1', '#22c55e', '#eab308', '#ef4444', '#06b6d4'];
</script>
<div class="p-3">
{#if data.labels.length === 0}
<p class="py-4 text-center text-sm text-muted-foreground">No chart data</p>
{:else}
<svg viewBox="0 0 100 60" class="h-40 w-full" preserveAspectRatio="none">
{#each data.datasets as dataset, di}
{#each dataset.values as value, i}
{@const barHeight = (value / maxValue) * 50}
{@const x = (i / data.labels.length) * 100 + 1 + di * (barWidth / data.datasets.length)}
<rect
{x}
y={55 - barHeight}
width={barWidth / data.datasets.length}
height={barHeight}
fill={dataset.color ?? defaultColors[di % defaultColors.length]}
rx="1"
class="transition-all duration-300"
/>
{/each}
{/each}
<line x1="0" y1="55" x2="100" y2="55" stroke="currentColor" stroke-width="0.3" class="text-border" />
</svg>
{#if data.datasets.length > 1}
<div class="mt-2 flex flex-wrap justify-center gap-3">
{#each data.datasets as dataset, di}
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<span class="h-2 w-2 rounded-full" style="background-color: {dataset.color ?? defaultColors[di % defaultColors.length]}"></span>
{dataset.label}
</div>
{/each}
</div>
{/if}
{/if}
</div>
@@ -0,0 +1,47 @@
<script lang="ts">
import type { GaugeData } from '$lib/server/integrations/types.js';
interface Props {
data: GaugeData;
}
let { data }: Props = $props();
const percentage = $derived(data.max > 0 ? Math.min((data.value / data.max) * 100, 100) : 0);
const color = $derived.by(() => {
const warn = data.thresholds?.warning ?? 60;
const crit = data.thresholds?.critical ?? 85;
if (percentage >= crit) return '#ef4444'; // red
if (percentage >= warn) return '#eab308'; // yellow
return '#22c55e'; // green
});
// SVG circle math
const radius = 40;
const circumference = 2 * Math.PI * radius;
const strokeDashoffset = $derived(circumference - (percentage / 100) * circumference);
</script>
<div class="flex flex-col items-center justify-center gap-2 p-3">
<div class="relative h-24 w-24">
<svg viewBox="0 0 100 100" class="h-full w-full -rotate-90">
<circle cx="50" cy="50" r={radius} fill="none" stroke="currentColor" stroke-width="8" class="text-muted/20" />
<circle
cx="50" cy="50" r={radius} fill="none"
stroke={color} stroke-width="8"
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={strokeDashoffset}
class="transition-all duration-700 ease-out"
/>
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center">
<span class="text-lg font-bold text-foreground">{Math.round(percentage)}%</span>
</div>
</div>
<div class="text-center">
<span class="text-sm font-medium text-muted-foreground">{data.label}</span>
<span class="block text-xs text-muted-foreground/70">{data.value}{data.unit} / {data.max}{data.unit}</span>
</div>
</div>
@@ -0,0 +1,32 @@
<script lang="ts">
import AlertBannerRenderer from './AlertBannerRenderer.svelte';
import type { AlertBannerData } from '$lib/server/integrations/types.js';
let alerts = $state<AlertBannerData[]>([]);
async function fetchAlerts() {
try {
const res = await fetch('/api/integrations/alerts');
const json = await res.json();
if (json.success && Array.isArray(json.data)) {
alerts = json.data;
}
} catch {
// Silently fail — alerts are supplementary
}
}
$effect(() => {
fetchAlerts();
const interval = setInterval(fetchAlerts, 30_000);
return () => clearInterval(interval);
});
</script>
{#if alerts.length > 0}
<div class="space-y-2 px-4 pt-2">
{#each alerts as alert}
<AlertBannerRenderer data={alert} />
{/each}
</div>
{/if}
@@ -0,0 +1,78 @@
<script lang="ts">
import StatCardRenderer from './StatCardRenderer.svelte';
import GaugeRenderer from './GaugeRenderer.svelte';
import ListRenderer from './ListRenderer.svelte';
import ProgressRenderer from './ProgressRenderer.svelte';
import AlertBannerRenderer from './AlertBannerRenderer.svelte';
import ChartRenderer from './ChartRenderer.svelte';
import type { IntegrationData } from '$lib/server/integrations/types.js';
interface Props {
config: {
appId: string;
endpointId: string;
refreshInterval?: number;
};
}
let { config }: Props = $props();
let integrationData = $state<IntegrationData | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
async function fetchData() {
try {
const res = await fetch(`/api/integrations/${config.appId}/data/${config.endpointId}`);
const json = await res.json();
if (json.success) {
integrationData = json.data;
error = null;
} else {
error = json.error ?? 'Failed to fetch data';
}
} catch (e) {
error = e instanceof Error ? e.message : 'Network error';
} finally {
loading = false;
}
}
$effect(() => {
fetchData();
const interval = setInterval(fetchData, (config.refreshInterval ?? 60) * 1000);
return () => clearInterval(interval);
});
</script>
{#if loading}
<div class="flex h-full items-center justify-center p-4">
<div class="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent"></div>
</div>
{:else if error}
<div class="flex h-full flex-col items-center justify-center gap-2 p-4 text-center">
<span class="text-sm text-destructive">{error}</span>
<button
onclick={fetchData}
class="text-xs text-primary hover:underline"
>
Retry
</button>
</div>
{:else if integrationData}
{#if integrationData.renderer === 'stat-card'}
<StatCardRenderer data={integrationData.data} />
{:else if integrationData.renderer === 'gauge'}
<GaugeRenderer data={integrationData.data} />
{:else if integrationData.renderer === 'list'}
<ListRenderer data={integrationData.data} />
{:else if integrationData.renderer === 'progress'}
<ProgressRenderer data={integrationData.data} />
{:else if integrationData.renderer === 'alert-banner'}
<AlertBannerRenderer data={integrationData.data} />
{:else if integrationData.renderer === 'chart'}
<ChartRenderer data={integrationData.data} />
{:else}
<div class="p-4 text-sm text-muted-foreground">Unknown renderer: {integrationData.renderer}</div>
{/if}
{/if}
@@ -0,0 +1,44 @@
<script lang="ts">
import type { ListData } from '$lib/server/integrations/types.js';
interface Props {
data: ListData;
}
let { data }: Props = $props();
</script>
<div class="max-h-64 space-y-1 overflow-y-auto p-2">
{#if data.items.length === 0}
<p class="py-4 text-center text-sm text-muted-foreground">No items</p>
{:else}
{#each data.items as item (item.id)}
{@const Tag = item.url ? 'a' : 'div'}
<svelte:element
this={Tag}
href={item.url ?? undefined}
target={item.url ? '_blank' : undefined}
rel={item.url ? 'noopener noreferrer' : undefined}
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent/50"
>
{#if item.icon}
<span class="shrink-0 text-base">{item.icon}</span>
{/if}
<div class="min-w-0 flex-1">
<p class="truncate font-medium text-foreground">{item.title}</p>
{#if item.subtitle}
<p class="truncate text-xs text-muted-foreground">{item.subtitle}</p>
{/if}
</div>
{#if item.badge}
<span
class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium"
style="background-color: {item.badge.color}20; color: {item.badge.color}"
>
{item.badge.text}
</span>
{/if}
</svelte:element>
{/each}
{/if}
</div>
@@ -0,0 +1,38 @@
<script lang="ts">
import type { ProgressData } from '$lib/server/integrations/types.js';
interface Props {
data: ProgressData;
}
let { data }: Props = $props();
</script>
<div class="max-h-64 space-y-3 overflow-y-auto p-3">
{#if data.items.length === 0}
<p class="py-4 text-center text-sm text-muted-foreground">No active items</p>
{:else}
{#each data.items as item (item.id)}
<div class="space-y-1">
<div class="flex items-center justify-between text-sm">
<span class="truncate font-medium text-foreground">{item.label}</span>
<span class="shrink-0 text-xs text-muted-foreground">
{Math.round(item.progress)}%
{#if item.speed}
&middot; {item.speed}
{/if}
</span>
</div>
{#if item.subtitle}
<p class="text-xs text-muted-foreground">{item.subtitle}</p>
{/if}
<div class="h-2 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all duration-500 ease-out"
style="width: {Math.min(item.progress, 100)}%"
></div>
</div>
</div>
{/each}
{/if}
</div>
@@ -0,0 +1,33 @@
<script lang="ts">
import type { StatCardData } from '$lib/server/integrations/types.js';
interface Props {
data: StatCardData;
}
let { data }: Props = $props();
const trendIcon = $derived(
data.trend === 'up' ? '↑' : data.trend === 'down' ? '↓' : ''
);
const trendColor = $derived(
data.trend === 'up' ? 'text-green-500' : data.trend === 'down' ? 'text-red-500' : 'text-muted-foreground'
);
</script>
<div class="flex flex-col items-center justify-center gap-1 p-4 text-center">
<div class="flex items-baseline gap-1">
<span class="text-3xl font-bold text-foreground">{data.value}</span>
{#if data.unit}
<span class="text-sm text-muted-foreground">{data.unit}</span>
{/if}
{#if trendIcon}
<span class="text-sm font-medium {trendColor}">{trendIcon}</span>
{/if}
</div>
<span class="text-sm font-medium text-muted-foreground">{data.label}</span>
{#if data.subtitle}
<span class="text-xs text-muted-foreground/70">{data.subtitle}</span>
{/if}
</div>
@@ -0,0 +1,55 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { success } from '$lib/server/utils/response.js';
import { prisma } from '$lib/server/prisma.js';
import * as registry from '$lib/server/integrations/registry.js';
import * as cache from '$lib/server/integrations/cache.js';
import { tryDecrypt } from '$lib/server/integrations/encryption.js';
import type { AlertBannerData } from '$lib/server/integrations/types.js';
export const GET: RequestHandler = async () => {
try {
const apps = await prisma.app.findMany({
where: { integrationEnabled: true, integrationType: { not: null } }
});
const alerts: AlertBannerData[] = [];
for (const app of apps) {
const integration = registry.get(app.integrationType!);
if (!integration) continue;
const alertEndpoints = integration.endpoints.filter((ep) => ep.renderer === 'alert-banner');
if (alertEndpoints.length === 0) continue;
const configJson = tryDecrypt(app.integrationConfig);
const config = configJson ? JSON.parse(configJson) : {};
for (const endpoint of alertEndpoints) {
const cacheKey = `${app.id}:${endpoint.id}`;
const cached = cache.get<AlertBannerData>(cacheKey);
if (cached) {
alerts.push(cached);
continue;
}
try {
const data = await integration.fetchData(app.url, config, endpoint.id);
if (data.renderer === 'alert-banner') {
const alertData = data.data as AlertBannerData;
cache.set(cacheKey, alertData, endpoint.refreshInterval);
if (alertData.severity === 'warning' || alertData.severity === 'critical') {
alerts.push(alertData);
}
}
} catch {
// Skip failed alert fetches
}
}
}
return json(success(alerts));
} catch {
return json(success([]));
}
};