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:
@@ -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
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
@@ -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}
|
||||||
|
· {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([]));
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user