diff --git a/.cursor/plans/revert_demo_mcp_8d8927a6.plan.md b/.cursor/plans/revert_demo_mcp_8d8927a6.plan.md new file mode 100644 index 000000000..2bd9a49ef --- /dev/null +++ b/.cursor/plans/revert_demo_mcp_8d8927a6.plan.md @@ -0,0 +1,197 @@ +--- +name: Revert Demo MCP +overview: Revert the demo-specific MCP accommodations (API key bypass, read-only filtering, demo UI) since demo MCP is an edge case with poor ROI. Replace with a clear "MCP not available in demo mode" message. Keep the core MCP feature intact for real instances. +todos: + - id: revert-backend + content: Revert demoSafe procedure, apiKeys demo bypass, isDemoMode export from mcp.ts + status: pending + - id: revert-mcp-route + content: Remove isDemoMode filtering and demo SERVER_INSTRUCTIONS from MCP route + status: pending + - id: revert-ui + content: Remove isDemoMode prop plumbing; add demo-not-supported Alert via info.isDemoMode + status: pending + - id: verify + content: Run lint + typecheck; smoke-test demo vs non-demo MCP tab + status: pending +isProject: false +--- + +# Revert Demo MCP Accommodations + +## Why Docker works but integrations do not + +Demo mode has **two different mock mechanisms**: + +```mermaid +flowchart TB + subgraph dockerPath [Docker - works in demo] + MCP1[MCP docker_getContainers] + Handler[docker.ts request handler] + MockContainers[Hardcoded mockContainers] + MCP1 --> Handler + Handler -->|"if DEMO_MODE"| MockContainers + end + + subgraph integrationPath [Integrations - confusing in demo] + MCP2[MCP widget tools e.g. calendar_findAllEvents] + TRPC[tRPC procedure] + RH[cached-integration-request-handler] + Kind{integration.kind} + Mock[MockIntegration fake data] + Real[Sonarr/Radarr HTTP - fails] + MCP2 --> TRPC --> RH --> Kind + Kind -->|mock| Mock + Kind -->|sonarr/radarr| Real + end +``` + +- **Docker**: + [`packages/request-handler/src/docker.ts`](packages/request-handler/src/docker.ts) + checks `DEMO_MODE` directly and returns inline fake containers. No integration + record needed. +- **Everything else**: Demo seed creates **one** integration with `kind: "mock"` + ([`packages/db/migrations/seed.ts`](packages/db/migrations/seed.ts)), not + Sonarr/Radarr/Overseerr. + [`MockIntegration`](packages/integrations/src/mock/mock-integration.ts) can + serve calendar, downloads, health, DNS, media server, etc. **if** callers pass + that integration ID. +- **Why it felt broken**: MCP tool descriptions tell AI clients to look for + "Overseerr/Sonarr IDs". Demo has none. Tools like + `integration_searchMediaRequests` hard-filter to `mediaSearch` kinds + (`overseerr`, `jellyseerr`, `seerr`) — `mock` is excluded from that category + in + [`packages/definitions/src/integration.ts`](packages/definitions/src/integration.ts). + +Fixing this properly means extending the mock integration system (new +`searchAsync`, category changes, middleware relaxations, instruction rewrites) +for a feature almost nobody will use on the public demo. + +## Option comparison + +| | **Revert (chosen)** | **Minimal fix** | **Full fix** | +| -------------------- | --------------------------- | ---------------------------------------------- | --------------------------------------------------- | +| **Effort** | ~1 hour, ~100 lines removed | ~2 hours | 1-2 days | +| **Files touched** | 8 MCP/demo-specific files | 8 + route instructions | 15+ across integrations, definitions, seed, routers | +| **Risk** | Low | Low-medium | Medium (mock/search semantics) | +| **Demo MCP outcome** | Clearly unsupported | Partial (widget reads only, no search/request) | Near-full read coverage | +| **Maintenance** | None ongoing | Instructions drift with tool changes | Mock parity with real integrations | + +**Recommendation: Revert.** The demo accommodation code added special-case +plumbing (`demoSafePermissionRequiredProcedure`, dual mutation guards, hardcoded +UI strings) for a workflow that still cannot deliver a convincing "homelab AI" +experience without substantial integration-layer work. + +## What to revert (demo MCP accommodations only) + +These changes were added specifically for demo + MCP. **Do not revert** the core +MCP server, OAuth, Scalar docs, tool descriptions, or `normalizeSchema`. + +### Backend (3 files) + +1. [`packages/api/src/trpc.ts`](packages/api/src/trpc.ts) + - Remove `demoSafePermissionRequiredProcedure` export (~12 lines) + - Keep existing `enforceDemoModeReadOnly` (pre-dates MCP; used by whole app) + +2. [`packages/api/src/router/apiKeys.ts`](packages/api/src/router/apiKeys.ts) + - Change `create` back to `permissionRequiredProcedure` (mutations blocked in + demo again) + - Remove `isDemoMode` guard on `delete` (global demo middleware already + blocks delete mutations) + - Remove `TRPCError` / `isDemoMode` imports if unused + +3. [`packages/api/src/mcp.ts`](packages/api/src/mcp.ts) + - Stop exporting `isDemoMode`; keep only `createTRPCContext` and `mcpRouter` + +### MCP route (1 file) + +4. [`apps/nextjs/src/app/api/mcp/[transport]/route.ts`](apps/nextjs/src/app/api/mcp/[transport]/route.ts) + - Remove `isDemoMode` import and all demo branches: + - `ListToolsRequestSchema` mutation filter + - `CallToolRequestSchema` mutation rejection + - `SERVER_INSTRUCTIONS` demo section + - Keep `getToolType` / `buildToolTypeMap` only if still needed elsewhere; + otherwise remove dead code from demo-only filtering + +### UI (4 files) + +5. [`apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx) + - Remove `isDemoMode` import from `@homarr/api/mcp` + - Stop passing `isDemoMode` to child components + +6. [`apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx) + - Remove `isDemoMode` prop from interface and `McpInstructions` call + +7. [`apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx) + - Remove `isDemoMode` prop + - Replace current demo banner with a single **Alert**: MCP requires a + self-hosted instance; demo mode does not support MCP (API key creation and + write tools are disabled) + - Use `clientApi.info.isDemoMode.useQuery()` or pass `isDemoMode` from server + via existing `api.info.isDemoMode()` tRPC query (already exists in + [`packages/api/src/router/info.ts`](packages/api/src/router/info.ts)) — + avoids re-exporting from `@homarr/api/mcp` + +8. [`apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx) + - Remove `isDemoMode` prop and conditional delete-column spread + - Delete button hidden automatically in demo because `apiKeys.delete` + mutation is blocked by global `enforceDemoModeReadOnly` + +## What to add instead + +- **MCP tab**: When `info.isDemoMode` is true, show a prominent notice at the + top of + [`mcp-instructions.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx) + explaining MCP is for self-hosted instances only. +- **Optional**: Disable/hide the "Create API Key" button on the MCP tab in demo + (mutations will fail anyway). +- **No changes** to integrations, definitions, seed, or mock services. + +## Files NOT impacted by revert + +Core MCP feature stays as-is: + +- [`apps/nextjs/src/app/api/mcp/[transport]/route.ts`](apps/nextjs/src/app/api/mcp/[transport]/route.ts) + — auth, rate limiting, `normalizeSchema`, `SERVER_INSTRUCTIONS` (minus demo + section) +- OAuth routes under `apps/nextjs/src/app/api/mcp/oauth/` +- All MCP-enabled routers in `packages/api/src/router/` +- [`packages/api/src/mcp.ts`](packages/api/src/mcp.ts) — eager router (minus + `isDemoMode` export) +- Scalar UI, dynamic tools table, + [`.cursor/rules/mcp-integration.mdc`](.cursor/rules/mcp-integration.mdc) + +Pre-existing demo infrastructure (unchanged): + +- [`packages/request-handler/src/docker.ts`](packages/request-handler/src/docker.ts) + — Docker mock containers +- [`packages/db/migrations/seed.ts`](packages/db/migrations/seed.ts) — mock + integration + demo board +- [`packages/integrations/src/mock/`](packages/integrations/src/mock/) — mock + services for widgets on demo board UI + +## Verification + +1. `pnpm lint` and `pnpm typecheck` pass +2. Demo mode (`DEMO_MODE=true`): + - MCP tab shows "not supported" notice + - API key create fails with existing "Mutations are disabled in demo mode" + - `docker_getContainers` still works on demo board UI (unchanged; separate + code path) +3. Non-demo mode: MCP unchanged (all tools listed, mutations work with + permissions) + +## Commit strategy + +Single focused commit on `feat/add-mcp-server`: + +``` +revert(mcp): drop demo-mode accommodations for MCP + +MCP is intended for self-hosted instances. Remove demo-specific API key +bypass, read-only tool filtering, and demo UI plumbing. Show a clear +notice on the MCP tab when demo mode is active. +``` + +Then push to the existing PR (no force-push needed unless you want another +squash). diff --git a/.cursor/rules/mcp-integration.mdc b/.cursor/rules/mcp-integration.mdc new file mode 100644 index 000000000..989b0c4f6 --- /dev/null +++ b/.cursor/rules/mcp-integration.mdc @@ -0,0 +1,103 @@ +--- +description: How to expose tRPC procedures as MCP tools for AI clients +globs: packages/api/src/router/**/*.ts +alwaysApply: false +--- + +# MCP Integration Guide + +Every new tRPC procedure that provides useful functionality should be exposed as an MCP tool so AI assistants (Claude, Cursor, etc.) can use it. This is a first-class feature of Homarr. + +## Adding MCP to a procedure + +Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure chain: + +```typescript +// Read-only tool (query) +myProcedure: protectedProcedure + .meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } }) + .input(z.object({ limit: z.number().default(50) })) + .query(async ({ ctx, input }) => { ... }) + +// Write tool (mutation) +myAction: protectedProcedure + .meta({ mcp: { enabled: true, description: "Delete an item by ID. Requires interact permission on the integration" } }) + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { ... }) + +// No-input procedure (z.void) — works fine, no .input() needed +getAll: protectedProcedure + .meta({ mcp: { enabled: true, description: "List all resources" } }) + .query(async ({ ctx }) => { ... }) +``` + +The `.meta()` call must come **before** `.input()` and `.query()`/`.mutation()` in the chain. + +## Writing good descriptions + +The description is the **only thing** an AI sees to decide when and how to use the tool. Write it like you're explaining to a colleague who has never used Homarr. + +**Must include:** +- What the tool returns or does +- Which integrations/services it works with (if applicable) +- How to get required IDs (e.g., "Use integration_all to get the integrationId") +- Permission requirements (if non-obvious) + +**Good:** +``` +"Get calendar events for upcoming and recent media releases. Fetches from all connected Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books) integrations. Requires integrationIds from integration_all" +``` + +**Bad:** +``` +"Get calendar events" +``` + +**For tools that return permission fields**, explain what they mean: +``` +"List all integrations. Returns permissions.hasUseAccess (read) and permissions.hasInteractAccess (actions) — false means the API key lacks that permission, not an error" +``` + +## Registering in the MCP router + +After adding `.meta()` to procedures, you must register the router in `packages/api/src/mcp.ts`. This file uses **eager imports** (no `lazy()`) because MCP tool extraction requires synchronous access to all procedure definitions. + +```typescript +// packages/api/src/mcp.ts +import { myNewRouter } from "./router/my-new-feature"; + +export const mcpRouter = createTRPCRouter({ + // ... existing routers + myFeature: myNewRouter, +}); +``` + +**If your router is nested** (e.g., widget sub-routers), import and register the sub-router directly — do not import a parent that uses `lazy()`. + +## What to expose vs. what to skip + +**Expose:** +- Data queries (list, search, get by ID) +- Actions users would ask an AI to do (toggle, create, delete, approve) +- Status checks (health, stats, summaries) + +**Skip:** +- Subscriptions (WebSocket-only, not supported by MCP) +- Internal procedures (session management, onboarding steps) +- File upload/download procedures +- Procedures that return non-serializable data (streams, blobs) + +## Input schema rules + +- Use `z.object({...})` for inputs — this maps cleanly to MCP tool parameters +- `z.void()` (no `.input()`) works — the MCP handler patches these to accept empty objects +- Avoid `z.union()` or `z.discriminatedUnion()` at the top level — AI clients struggle with these +- Use `.default()` on optional fields so the AI doesn't need to guess values + +## Checklist for new features + +1. Add `.meta({ mcp: { enabled: true, description: "..." } })` to relevant procedures +2. Import and register the router in `packages/api/src/mcp.ts` (eager, no lazy) +3. Write descriptions that explain what the tool does, what it needs, and what it returns +4. If the tool requires an ID from another tool, say which one in the description +5. Test with `curl` against `/api/mcp/mcp` to verify the tool appears in `tools/list` diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 973aef4ab..7b8493454 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -33,6 +33,7 @@ body: options: # The below comment is used to insert a new version with on-release.yml #NEXT_VERSION# + - 1.64.0 - 1.62.0 - 1.61.0 - 1.60.1-beta.1 diff --git a/.gitignore b/.gitignore index 0e991cc6c..768e58818 100644 --- a/.gitignore +++ b/.gitignore @@ -66,8 +66,7 @@ e2e/shared/tmp #personal backgrounds apps/nextjs/public/images/background.png -# next-intl -en.d.json.ts + # personal agent config AGENTS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a99e511b7..4ef71dd89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,67 @@ +## [1.64.0](https://github.com/homarr-labs/homarr/compare/v1.63.0...v1.64.0) (2026-06-05) + +### Features + +* add comprehensive demo mode with mock integrations ([#5870](https://github.com/homarr-labs/homarr/issues/5870)) ([0a64679](https://github.com/homarr-labs/homarr/commit/0a6467979a5e3b3e3eae6252c31fca1d9af54dc1)) +* add PeaNUT integration and UPS monitoring widget ([#5862](https://github.com/homarr-labs/homarr/issues/5862)) ([0dc2558](https://github.com/homarr-labs/homarr/commit/0dc2558fafedcbba246c2e968d00cc541ab9f84e)) +* **docker:** add container logs modal and stats columns ([#4807](https://github.com/homarr-labs/homarr/issues/4807)) ([dbf021e](https://github.com/homarr-labs/homarr/commit/dbf021e04a785b44ad0e2ce45634de371d76de6d)) +* media request search as first-class spotlight mode ([#5823](https://github.com/homarr-labs/homarr/issues/5823)) ([3d3e305](https://github.com/homarr-labs/homarr/commit/3d3e3059a98b36b6e64e3aad90f715e334baff3e)) +* **onboarding:** enhance integration setup with Docker discovery and URL templating ([#5607](https://github.com/homarr-labs/homarr/issues/5607)) ([dd7a490](https://github.com/homarr-labs/homarr/commit/dd7a4908a36a71c1d0d1e41524eb8fd9bf53ca4d)) +* **onboarding:** guided management and board tours ([#5756](https://github.com/homarr-labs/homarr/issues/5756)) ([0b663a7](https://github.com/homarr-labs/homarr/commit/0b663a7fc6c017b9fbe78c019f06d46ee95f165f)) +* **widgets:** add in-place app editing in widget edit modal ([#5809](https://github.com/homarr-labs/homarr/issues/5809)) ([99346f2](https://github.com/homarr-labs/homarr/commit/99346f288bcfc701c41fecaab6f7bc0438d80563)) + +### Bug Fixes + +* add bookmark widget option to toggle card borders ([#5854](https://github.com/homarr-labs/homarr/issues/5854)) ([41fe592](https://github.com/homarr-labs/homarr/commit/41fe5928f19207d02b04e64c2caac49d896f72ce)) +* **auth:** relax password complexity requirements to suggestions-only ([#5764](https://github.com/homarr-labs/homarr/issues/5764)) ([fd10788](https://github.com/homarr-labs/homarr/commit/fd107888385557ededcf51576c6320a1960690f2)) +* **db:** log when UNSAFE_ENABLE_MOCK_INTEGRATION is enabled during seeding ([efd37dc](https://github.com/homarr-labs/homarr/commit/efd37dca93be7f5f362331e9b5c2a95a87e24379)) +* disable onboarding tours when demo mode is enabled ([68f24e3](https://github.com/homarr-labs/homarr/commit/68f24e34d580e3a155c37ca37c3ffd61f296bd0c)) +* **docker:** restore container selection toolbar broken by Mantine v9 ([#5855](https://github.com/homarr-labs/homarr/issues/5855)) ([fe4598f](https://github.com/homarr-labs/homarr/commit/fe4598f7221a2861b8c5ae0cfd267e8c4fbf48df)) +* **immich:** respect app color scheme in server-stats widget ([#5859](https://github.com/homarr-labs/homarr/issues/5859)) ([2c6b7a8](https://github.com/homarr-labs/homarr/commit/2c6b7a818e9d32b414b024d711a3b64533e61cb9)) +* redirect to login after credentials logout instead of crashing on current page ([#5819](https://github.com/homarr-labs/homarr/issues/5819)) ([cc24f07](https://github.com/homarr-labs/homarr/commit/cc24f070db26337a4a2d87d68c7f142ed1118584)) +* sync docs sitemap and remove stale coolify ts-expect-error ([4f5f3e1](https://github.com/homarr-labs/homarr/commit/4f5f3e14a129a94295d4b8ffb7d3702fdb4f6a74)) +* **ui:** restore pre-v9 light variant colors ([#5856](https://github.com/homarr-labs/homarr/issues/5856)) ([a755692](https://github.com/homarr-labs/homarr/commit/a755692d81b80b5ff83051a3b7261c01ac10321b)) + +## [1.63.0](https://github.com/homarr-labs/homarr/compare/v1.62.0...v1.63.0) (2026-05-29) + +### Features + +* **auth:** support custom token endpoint auth methods ([#5806](https://github.com/homarr-labs/homarr/issues/5806)) ([be61602](https://github.com/homarr-labs/homarr/commit/be61602576a8bec4bb7e4928d36e0854e926264d)) +* auto-open widget settings on add with all integrations pre-selected ([#5767](https://github.com/homarr-labs/homarr/issues/5767)) ([f10bb3a](https://github.com/homarr-labs/homarr/commit/f10bb3ae23fefc9779332733706aa567cc40ba48)) +* **board:** persist category collapse state in localStorage for guests ([#5714](https://github.com/homarr-labs/homarr/issues/5714)) ([66212b4](https://github.com/homarr-labs/homarr/commit/66212b4e475738e61d2ea78f3a7e72d7a610bf12)), closes [#3355](https://github.com/homarr-labs/homarr/issues/3355) +* **manage:** unified management page layout ([#5758](https://github.com/homarr-labs/homarr/issues/5758)) ([c252b4f](https://github.com/homarr-labs/homarr/commit/c252b4f9accc4523f222354c73f687714640701f)) +* **users:** rework general settings ([#4915](https://github.com/homarr-labs/homarr/issues/4915)) ([2991777](https://github.com/homarr-labs/homarr/commit/2991777b0a0ae72522d0769a86a91250d618dbe6)) + +### Bug Fixes + +* matine ui fixes for concistency ([#5733](https://github.com/homarr-labs/homarr/issues/5733)) ([4454e3f](https://github.com/homarr-labs/homarr/commit/4454e3fb7afa624ee2f1eaec015f2725e664ac46)) +* parse Speedtest Tracker created_at as local time ([#5751](https://github.com/homarr-labs/homarr/issues/5751)) ([b19549b](https://github.com/homarr-labs/homarr/commit/b19549b601636bcbc2fadaa270a77a9fdb48b77a)) +* prevent GitHub releases check from blocking app on API failure ([#5807](https://github.com/homarr-labs/homarr/issues/5807)) ([6cc2ac5](https://github.com/homarr-labs/homarr/commit/6cc2ac56a920563c47aaaab1b33b19a9b6d5ecee)) +* **ui:** use imported theme instead of undefined createTheme ([4a67bf0](https://github.com/homarr-labs/homarr/commit/4a67bf0ef30ff8d10b06d2d3719ccab792395669)) + +### Performance Improvements + +* **boards:** parallelize board page data fetching ([#5736](https://github.com/homarr-labs/homarr/issues/5736)) ([8f03a38](https://github.com/homarr-labs/homarr/commit/8f03a38de5b9b385da7ffcc9a963be00710acb5e)) + +## [1.62.0](https://github.com/homarr-labs/homarr/compare/v1.61.1...v1.62.0) (2026-05-22) + +### Features + +* **seed:** add demo mode with pre-seeded user ([#5739](https://github.com/homarr-labs/homarr/issues/5739)) ([dade477](https://github.com/homarr-labs/homarr/commit/dade477b3226765eb614e66588ebee84c45cb688)) + +### Bug Fixes + +* **deps:** update dependency better-sqlite3 to ^12.10.0 ([#5727](https://github.com/homarr-labs/homarr/issues/5727)) ([044ad9e](https://github.com/homarr-labs/homarr/commit/044ad9edf9c1dc9b50b9591dea0b5f0cce4be01a)) +* **docker:** improve icon matching ([#5693](https://github.com/homarr-labs/homarr/issues/5693)) ([7cd30b6](https://github.com/homarr-labs/homarr/commit/7cd30b640cbf47e4678abda18f83c0924f039b1b)) +* exclude .env files from Docker build context ([#5738](https://github.com/homarr-labs/homarr/issues/5738)) ([fa73763](https://github.com/homarr-labs/homarr/commit/fa737638a812372d904207fe7af5bd7c6f53d159)) +* **tracearr:** support absolute image urls ([#5654](https://github.com/homarr-labs/homarr/issues/5654)) ([7a12721](https://github.com/homarr-labs/homarr/commit/7a1272146c597c1f9df89d74b8389f70e7cdd61a)) + +### Performance Improvements + +* (next branch) reduce idle memory on startup ([#5637](https://github.com/homarr-labs/homarr/issues/5637)) ([5ee84f8](https://github.com/homarr-labs/homarr/commit/5ee84f8cf464c308729281cb4db572950ba85a82)) +* **boards:** remove loading overlay and show board immediately ([#5735](https://github.com/homarr-labs/homarr/issues/5735)) ([df4b57b](https://github.com/homarr-labs/homarr/commit/df4b57bd414d4402522f9210b1470d0561c37274)) +* **memory:** combine 3 node processes into one ([#5600](https://github.com/homarr-labs/homarr/issues/5600)) ([a1aa550](https://github.com/homarr-labs/homarr/commit/a1aa5505a365688b456090a882770e561479833e)) + ## [1.61.1](https://github.com/homarr-labs/homarr/compare/v1.61.0...v1.61.1) (2026-05-18) ### Bug Fixes diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts index bcce78519..636df3f52 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -5,18 +5,30 @@ import "@homarr/common/env"; import "@homarr/core/infrastructure/logs/env"; import "@homarr/docker/env"; +import { readFileSync } from "fs"; + import type { NextConfig } from "next"; import createNextIntlPlugin from "next-intl/plugin"; +const homarrVersion = (() => { + for (const candidate of ["../../package.json", "./package.json"]) { + try { + const pkg = JSON.parse(readFileSync(candidate, "utf-8")); + if (pkg.name === "homarr") return pkg.version as string; + } catch {} + } + return "unknown"; +})(); + // Package path does not work... so we need to use relative path const withNextIntl = createNextIntlPlugin({ - experimental: { - createMessagesDeclaration: "../../packages/translation/src/lang/en.json", - }, requestConfig: "../../packages/translation/src/request.ts", }); const nextConfig: NextConfig = { + env: { + HOMARR_VERSION: homarrVersion, + }, output: "standalone", reactStrictMode: true, // react compiler breaks mantine-react-table, so disabled for now @@ -27,7 +39,7 @@ const nextConfig: NextConfig = { * dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612 * isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356 */ - serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom"], + serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"], experimental: { optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"], turbopackFileSystemCacheForDev: true, diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 8ff2d87b3..146578bf6 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -18,6 +18,7 @@ "@dnd-kit/modifiers": "catalog:", "@dnd-kit/sortable": "catalog:", "@dnd-kit/utilities": "catalog:", + "@gfazioli/mantine-onboarding-tour": "catalog:", "@homarr/analytics": "workspace:^0.1.0", "@homarr/api": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0", @@ -55,6 +56,8 @@ "@mantine/hooks": "catalog:", "@mantine/modals": "catalog:", "@mantine/tiptap": "catalog:", + "@modelcontextprotocol/sdk": "catalog:", + "@scalar/api-reference-react": "catalog:", "@tabler/icons-react": "catalog:", "@tanstack/query-persist-client-core": "catalog:", "@tanstack/react-query": "catalog:", @@ -72,11 +75,18 @@ "clsx": "catalog:", "dayjs": "catalog:", "dotenv": "catalog:", + "drizzle-orm": "catalog:", "flag-icons": "catalog:", "glob": "catalog:", + "adm-zip": "catalog:", + "better-sqlite3": "catalog:", "isomorphic-dompurify": "catalog:", + "jszip": "catalog:", "jotai": "catalog:", + "lucide-react": "catalog:", + "mantine-datatable": "catalog:", "mantine-react-table": "catalog:", + "mcp-handler": "catalog:", "next": "catalog:", "postcss-preset-mantine": "catalog:", "prismjs": "catalog:", @@ -85,20 +95,22 @@ "react-error-boundary": "catalog:", "react-simple-code-editor": "catalog:", "sass": "catalog:", + "sql.js": "catalog:", "superjson": "catalog:", - "swagger-ui-react": "catalog:", + "trpc-to-mcp": "catalog:", "use-deep-compare-effect": "catalog:", "yaml": "catalog:", "zod": "catalog:" }, "devDependencies": { "@homarr/tsconfig": "workspace:^0.1.0", + "@types/adm-zip": "catalog:", + "@types/better-sqlite3": "catalog:", "@types/chroma-js": "catalog:", "@types/node": "catalog:", "@types/prismjs": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", - "@types/swagger-ui-react": "catalog:", "concurrently": "catalog:", "node-loader": "catalog:", "typescript": "catalog:" diff --git a/apps/nextjs/public/sql-wasm.wasm b/apps/nextjs/public/sql-wasm.wasm new file mode 100755 index 000000000..b32b66473 Binary files /dev/null and b/apps/nextjs/public/sql-wasm.wasm differ diff --git a/apps/nextjs/src/app/.well-known/oauth-authorization-server/route.ts b/apps/nextjs/src/app/.well-known/oauth-authorization-server/route.ts new file mode 100644 index 000000000..c400b4020 --- /dev/null +++ b/apps/nextjs/src/app/.well-known/oauth-authorization-server/route.ts @@ -0,0 +1,32 @@ +import { headers } from "next/headers"; + +import { extractBaseUrlFromHeaders } from "@homarr/common"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", +}; + +export async function GET() { + const baseUrl = extractBaseUrlFromHeaders(await headers()); + + return Response.json( + { + issuer: baseUrl, + authorization_endpoint: `${baseUrl}/api/mcp/oauth/authorize`, + token_endpoint: `${baseUrl}/api/mcp/oauth/token`, + registration_endpoint: `${baseUrl}/api/mcp/oauth/register`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["mcp:tools"], + }, + { headers: corsHeaders }, + ); +} + +export async function OPTIONS() { + return new Response(null, { status: 204, headers: corsHeaders }); +} diff --git a/apps/nextjs/src/app/.well-known/oauth-protected-resource/route.ts b/apps/nextjs/src/app/.well-known/oauth-protected-resource/route.ts new file mode 100644 index 000000000..ca66ee1a3 --- /dev/null +++ b/apps/nextjs/src/app/.well-known/oauth-protected-resource/route.ts @@ -0,0 +1,25 @@ +import { headers } from "next/headers"; + +import { extractBaseUrlFromHeaders } from "@homarr/common"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", +}; + +export async function GET() { + const baseUrl = extractBaseUrlFromHeaders(await headers()); + + return Response.json( + { + resource: baseUrl, + authorization_servers: [baseUrl], + }, + { headers: corsHeaders }, + ); +} + +export async function OPTIONS() { + return new Response(null, { status: 204, headers: corsHeaders }); +} diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index 76cf90695..d09acd8c8 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -17,11 +17,14 @@ import { import superjson from "superjson"; import type { SuperJSONResult } from "superjson"; +import { TRPCClientError } from "@trpc/client"; + import type { AppRouter } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { queryCacheDefaultStaleTimeMs } from "@homarr/api/query-cache"; import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; import { env } from "@homarr/common/env"; +import { showWarningNotification } from "@homarr/notifications"; import { createWidgetQueryPersister } from "./query-cache-persister"; @@ -61,6 +64,20 @@ export function TRPCReactProvider(props: PropsWithChildren) { persister: queryPersister.persisterFn, staleTime: queryCacheDefaultStaleTimeMs, }, + mutations: { + onError(error) { + if ( + error instanceof TRPCClientError && + error.data?.code === "FORBIDDEN" && + error.message === "Mutations are disabled in demo mode" + ) { + showWarningNotification({ + title: "Demo mode", + message: "This action is disabled in demo mode.", + }); + } + }, + }, }, }), ); diff --git a/apps/nextjs/src/app/[locale]/auth/login/page.tsx b/apps/nextjs/src/app/[locale]/auth/login/page.tsx index 6cf00b6b3..30eb88ba0 100644 --- a/apps/nextjs/src/app/[locale]/auth/login/page.tsx +++ b/apps/nextjs/src/app/[locale]/auth/login/page.tsx @@ -1,11 +1,14 @@ import { redirect } from "next/navigation"; -import { Card, Center, Stack, Text, Title } from "@mantine/core"; +import { Alert, Card, Center, Code, Stack, Text, Title } from "@mantine/core"; +import { IconLogin } from "@tabler/icons-react"; import { env } from "@homarr/auth/env"; import { auth } from "@homarr/auth/next"; import { getScopedI18n } from "@homarr/translation/server"; import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url"; +import { env as appEnv } from "~/env"; + import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo"; import { LoginForm } from "./_login-form"; @@ -37,6 +40,13 @@ export default async function Login(props: LoginProps) { {t("subtitle")} + {appEnv.DEMO_MODE && ( + } color="blue" variant="light" w={64 * 6} maw="90vw"> + + Demo mode is enabled. Sign in with username demo and password demo + + + )} >( layout: createBoardLayout({ headerActions: , getInitialBoardAsync: getInitialBoard, + withTour: true, }), // eslint-disable-next-line no-restricted-syntax page: async ({ params }: { params: Promise }) => { diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index 3b6ea7a37..cc3868e50 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect } from "react"; import { useRouter } from "next/navigation"; -import { Group, Menu, ScrollArea } from "@mantine/core"; +import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; +import { Box, Group, Menu, ScrollArea } from "@mantine/core"; import { useHotkeys } from "@mantine/hooks"; import { IconBox, @@ -11,6 +12,7 @@ import { IconLayoutBoard, IconPencil, IconPencilOff, + IconPlug, IconPlus, IconReplace, IconResize, @@ -36,14 +38,16 @@ import { useBoardPermissions } from "~/components/board/permissions/client"; import { useCategoryActions } from "~/components/board/sections/category/category-actions"; import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; +import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; import { HeaderButton } from "~/components/layout/header/button"; export const BoardContentHeaderActions = () => { const [isEditMode] = useEditMode(); const board = useRequiredBoard(); const { hasChangeAccess } = useBoardPermissions(board); + const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery(); - if (!hasChangeAccess) { + if (!hasChangeAccess || isLoading) { return ; } @@ -51,11 +55,15 @@ export const BoardContentHeaderActions = () => { <> {isEditMode && } - + - - - + {!demoMode && ( + + + + + + )} @@ -67,6 +75,7 @@ const AddMenu = () => { const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal); const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); const { openModal: openAppSelectModal } = useModalAction(AppSelectModal); + const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal); const { addCategoryToEnd } = useCategoryActions(); const { addDynamicSection } = useDynamicSectionActions(); const { createItem } = useItemActions(); @@ -108,6 +117,10 @@ const AddMenu = () => { }); }, [openAppSelectModal, createItem]); + const handleAddIntegration = useCallback(() => { + openIntegrationSelectModal({}); + }, [openIntegrationSelectModal]); + return ( @@ -127,6 +140,10 @@ const AddMenu = () => { {t("app.action.add")} + } onClick={handleAddIntegration}> + {t("integration.action.create")} + + } onClick={handleAddCategory}> @@ -141,7 +158,7 @@ const AddMenu = () => { ); }; -const EditModeMenu = () => { +const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => { const [isEditMode, { open, close }] = useEditMode(); const board = useRequiredBoard(); const utils = clientApi.useUtils(); @@ -164,18 +181,28 @@ const EditModeMenu = () => { }, }); + const discardDemoChanges = useCallback(() => { + void utils.board.getBoardByName.invalidate({ name: board.name }); + close(); + }, [utils, board.name, close]); + const toggle = useCallback(() => { - if (isEditMode) return saveBoard(board); + if (isEditMode) { + if (demoMode) return discardDemoChanges(); + return saveBoard(board); + } open(); - }, [board, isEditMode, saveBoard, open]); + }, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]); useHotkeys([[hotkeys.toggleBoardEdit, toggle]]); usePreventLeaveWithDirty(isEditMode); return ( - - {isEditMode ? : } - + + + {isEditMode ? : } + + ); }; @@ -183,27 +210,31 @@ const SelectBoardsMenu = () => { const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(); return ( - - - - - - - - - {boards.map((board) => ( - } - > - {board.name} - - ))} - - - + + + + + + + + + + + {boards.map((board) => ( + } + > + {board.name} + + ))} + + + + + ); }; @@ -244,7 +275,7 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => { }; const handleBeforeUnload = (event: BeforeUnloadEvent) => { - if (env.NODE_ENV === "development") return; // Allow to reload in development + if (env.NODE_ENV === "development") return; event.preventDefault(); event.returnValue = true; diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx index f377c932e..3cddc6685 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -11,6 +11,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; import { MainHeader } from "~/components/layout/header"; import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; import { ClientShell } from "~/components/layout/shell"; +import { BoardTourProvider } from "~/components/onboarding/board-tour"; import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; import type { Board } from "./_types"; import type { Params } from "./(content)/_creator"; @@ -20,14 +21,21 @@ import { BoardMantineProvider } from "./(content)/_theme"; const logger = createLogger({ module: "createBoardLayout" }); +const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => { + if (!hasSession) return <>{children}; + return {children}; +}; + interface CreateBoardLayoutProps { headerActions: JSX.Element; getInitialBoardAsync: (params: TParams) => Promise; + withTour?: boolean; } export const createBoardLayout = ({ headerActions, getInitialBoardAsync: getInitialBoard, + withTour = false, }: CreateBoardLayoutProps) => { const Layout = async ({ params, @@ -61,14 +69,16 @@ export const createBoardLayout = ({ - - } - actions={headerActions} - hasNavigation={false} - /> - {children} - + + + } + actions={headerActions} + hasNavigation={false} + /> + {children} + + diff --git a/apps/nextjs/src/app/[locale]/init/_components/init-step-card.tsx b/apps/nextjs/src/app/[locale]/init/_components/init-step-card.tsx new file mode 100644 index 000000000..e51149147 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/init/_components/init-step-card.tsx @@ -0,0 +1,12 @@ +import type { PropsWithChildren } from "react"; +import { Card } from "@mantine/core"; + +interface InitStepCardProps { + withBorder?: boolean; +} + +export const InitStepCard = ({ children, withBorder = true }: PropsWithChildren) => ( + + {children} + +); diff --git a/apps/nextjs/src/app/[locale]/init/_constants.ts b/apps/nextjs/src/app/[locale]/init/_constants.ts new file mode 100644 index 000000000..870cbdc21 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/init/_constants.ts @@ -0,0 +1,2 @@ +export const onboardingContentWidth = 64 * 16; +export const onboardingMaxWidth = "90vw"; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/finish/init-finish.tsx b/apps/nextjs/src/app/[locale]/init/_steps/finish/init-finish.tsx index 133581d58..ff7c2042a 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/finish/init-finish.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/finish/init-finish.tsx @@ -1,39 +1,27 @@ import type { MantineColor } from "@mantine/core"; -import { Button, Card, Stack, Text } from "@mantine/core"; -import { IconBook2, IconCategoryPlus, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react"; +import { Button, Stack, Text } from "@mantine/core"; +import { IconBook2, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react"; import { isProviderEnabled } from "@homarr/auth/server"; import { getMantineColor } from "@homarr/common"; -import { db } from "@homarr/db"; import { createDocumentationLink } from "@homarr/definitions"; import { getScopedI18n } from "@homarr/translation/server"; import { Link } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui"; +import { InitStepCard } from "../../_components/init-step-card"; + export const InitFinish = async () => { - const firstBoard = await db.query.boards.findFirst({ columns: { name: true } }); const tFinish = await getScopedI18n("init.step.finish"); return ( - + {tFinish("description")} - {firstBoard ? ( - - {tFinish("action.goToBoard", { name: firstBoard.name })} - - ) : ( - - {tFinish("action.createBoard")} - - )} + + {tFinish("action.goToBoard", { name: "dashboard" })} + {isProviderEnabled("credentials") && ( { {tFinish("action.docs")} - + ); }; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/group/init-group.tsx b/apps/nextjs/src/app/[locale]/init/_steps/group/init-group.tsx index 94424ef7d..98602de73 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/group/init-group.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/group/init-group.tsx @@ -1,6 +1,6 @@ "use client"; -import { Button, Card, Stack, TextInput } from "@mantine/core"; +import { Button, Stack, TextInput } from "@mantine/core"; import { IconArrowRight } from "@tabler/icons-react"; import type { z } from "zod/v4"; @@ -10,6 +10,8 @@ import { useZodForm } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; import { groupCreateSchema } from "@homarr/validation/group"; +import { InitStepCard } from "../../_components/init-step-card"; + export const InitGroup = () => { const t = useI18n(); const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation({ @@ -34,7 +36,7 @@ export const InitGroup = () => { }; return ( - +
{
-
+ ); }; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/import/file-info-card.tsx b/apps/nextjs/src/app/[locale]/init/_steps/import/file-info-card.tsx index 3acbe646b..8a3a14fb4 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/import/file-info-card.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/import/file-info-card.tsx @@ -1,10 +1,12 @@ -import { ActionIcon, Button, Card, Group, Text } from "@mantine/core"; +import { ActionIcon, Button, Group, Text } from "@mantine/core"; import type { FileWithPath } from "@mantine/dropzone"; import { IconPencil } from "@tabler/icons-react"; import { humanFileSize } from "@homarr/common"; import { useScopedI18n } from "@homarr/translation/client"; +import { InitStepCard } from "../../_components/init-step-card"; + interface FileInfoCardProps { file: FileWithPath; onRemove: () => void; @@ -13,7 +15,7 @@ interface FileInfoCardProps { export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => { const tFileInfo = useScopedI18n("init.step.import.fileInfo"); return ( - + @@ -36,6 +38,6 @@ export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => { - + ); }; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/import/init-import.tsx b/apps/nextjs/src/app/[locale]/init/_steps/import/init-import.tsx index cdb3faf50..c7113b66b 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/import/init-import.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/import/init-import.tsx @@ -1,13 +1,14 @@ "use client"; import { startTransition, useState } from "react"; -import { Card, Stack } from "@mantine/core"; +import { Stack } from "@mantine/core"; import type { FileWithPath } from "@mantine/dropzone"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { InitialOldmarrImport } from "@homarr/old-import/components"; +import { InitStepCard } from "../../_components/init-step-card"; import { FileInfoCard } from "./file-info-card"; import { ImportDropZone } from "./import-dropzone"; @@ -20,7 +21,7 @@ export const InitImport = () => { if (!file) { return ( - + { @@ -40,12 +41,12 @@ export const InitImport = () => { }); }} /> - + ); } return ( - + setFile(null)} /> {analyseResult !== null && } diff --git a/apps/nextjs/src/app/[locale]/init/_steps/integrations/docker-discovery-panel.tsx b/apps/nextjs/src/app/[locale]/init/_steps/integrations/docker-discovery-panel.tsx new file mode 100644 index 000000000..88da9cb71 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/init/_steps/integrations/docker-discovery-panel.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { + Avatar, + Badge, + Card, + Collapse, + Group, + Loader, + Paper, + ScrollArea, + SimpleGrid, + Stack, + Text, + ThemeIcon, + UnstyledButton, +} from "@mantine/core"; +import { IconBrandDocker, IconCheck, IconChevronDown } from "@tabler/icons-react"; + +import type { IntegrationKind } from "@homarr/definitions"; +import { getIntegrationName } from "@homarr/definitions"; +import { useScopedI18n } from "@homarr/translation/client"; +import { IntegrationAvatar } from "@homarr/ui"; + +export interface DiscoveredIntegration { + containerId: string; + containerName: string; + kind: IntegrationKind; + suggestedUrl: string; + publishedPort: number | null; + iconUrl: string | null; +} + +export interface DiscoveredApp { + containerId: string; + containerName: string; + suggestedUrl: string; + publishedPort: number | null; + iconUrl: string | null; +} + +interface DockerDiscoveryIndicatorProps { + integrations: DiscoveredIntegration[]; + apps: DiscoveredApp[]; + selectedAppIds: Set; + onToggleApp: (containerId: string) => void; + isLoading: boolean; +} + +const APP_CARD_HEIGHT = 80; + +export const DockerDiscoveryIndicator = ({ + integrations, + apps, + selectedAppIds, + onToggleApp, + isLoading, +}: DockerDiscoveryIndicatorProps) => { + const hasApps = apps.length > 0; + const [expanded, setExpanded] = useState(false); + + const expandedOnce = useRef(false); + useEffect(() => { + if ((hasApps || integrations.length > 0) && !expandedOnce.current) { + expandedOnce.current = true; + setExpanded(true); + } + }, [hasApps, integrations.length]); + + const t = useScopedI18n("init.step.integrations.docker"); + const totalCount = integrations.length + apps.length; + + if (!isLoading && totalCount === 0) return null; + + const selectedAppCount = apps.filter((app) => selectedAppIds.has(app.containerId)).length; + + if (isLoading) { + return ( + + + + + + + + {t("scanning")} + + + {t("scanningDescription")} + + + + + + ); + } + + return ( + + setExpanded((prev) => !prev)} w="100%"> + + + + + + + + {t("detected", { count: String(totalCount) })} + + + {t("detectedBreakdown", { + integrations: String(integrations.length), + apps: String(apps.length), + })} + + + + + + + + + + {integrations.length > 0 && ( + + {integrations.map((integration) => ( + + + + {integration.containerName} + + + {getIntegrationName(integration.kind)} + + + ))} + + )} + + {hasApps && ( + + + {t("appSectionLabel", { count: String(selectedAppCount) })} + + + + {apps.map((app) => { + const isSelected = selectedAppIds.has(app.containerId); + return ( + onToggleApp(app.containerId)} + > + + + + {app.containerName} + + {isSelected && } + + + {app.containerName.at(0)?.toUpperCase()} + + + + ); + })} + + + + )} + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx b/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx new file mode 100644 index 000000000..71f9345f3 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx @@ -0,0 +1,483 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + ActionIcon, + Alert, + Avatar, + Button, + Card, + Center, + Collapse, + Group, + Loader, + Paper, + Progress, + SegmentedControl, + SimpleGrid, + Stack, + Text, + TextInput, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { + IconAlertTriangle, + IconArrowRight, + IconBrandDocker, + IconCheck, + IconChevronDown, + IconSettings, +} from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import type { IntegrationKind, UrlTemplateMode } from "@homarr/definitions"; +import { buildAppUrl, buildIntegrationUrl, getIntegrationName } from "@homarr/definitions"; +import { showErrorNotification } from "@homarr/notifications"; +import { useScopedI18n } from "@homarr/translation/client"; +import { IntegrationAvatar } from "@homarr/ui"; + +import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form"; +import { IntegrationMultiSelectGrid } from "~/components/integration/integration-multi-select-grid"; +import { InitStepCard } from "../../_components/init-step-card"; + +type Phase = "select" | "configure" | "done"; + +interface IntegrationDraft { + kind: IntegrationKind; + initialUrl: string; + initialName?: string; + dockerPort?: number | null; + source: "manual" | "docker"; +} + +export const InitIntegrations = () => { + const [phase, setPhase] = useState("select"); + const [drafts, setDrafts] = useState>(new Map()); + const [selectedKinds, setSelectedKinds] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [baseHost, setBaseHost] = useState(""); + const [urlMode, setUrlMode] = useState("subdomain"); + const t = useScopedI18n("init.step.integrations"); + const router = useRouter(); + + const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation(); + const { mutateAsync: createApps } = clientApi.onboard.createAppsFromDiscovery.useMutation(); + + const { data: dockerData, isPending: isDockerLoading } = clientApi.onboard.discoverDockerServices.useQuery( + undefined, + { + retry: false, + refetchOnWindowFocus: false, + }, + ); + + const dockerIntegrations = dockerData?.status === "success" ? dockerData.integrations : []; + const dockerApps = dockerData?.status === "success" ? dockerData.apps : []; + const totalDetected = dockerIntegrations.length + dockerApps.length; + + const detectedKinds = useMemo(() => new Set(dockerIntegrations.map((i) => i.kind)), [dockerIntegrations]); + + const [selectedAppIds, setSelectedAppIds] = useState>(new Set()); + const [appsExpanded, setAppsExpanded] = useState(false); + + const dockerApplied = useRef(false); + useEffect(() => { + if (dockerData?.status !== "success" || dockerApplied.current) return; + dockerApplied.current = true; + + const newDrafts = new Map(drafts); + const newKinds = new Set(selectedKinds); + + for (const item of dockerData.integrations) { + if (!newDrafts.has(item.kind)) { + newDrafts.set(item.kind, { + kind: item.kind, + initialUrl: item.suggestedUrl, + initialName: item.containerName, + dockerPort: item.publishedPort, + source: "docker", + }); + } + newKinds.add(item.kind); + } + + setDrafts(newDrafts); + setSelectedKinds(Array.from(newKinds)); + setSelectedAppIds(new Set(dockerData.apps.map((app) => app.containerId))); + if (dockerData.apps.length > 0) { + setAppsExpanded(true); + } + }, [dockerData]); + + const handleSelectionChange = useCallback((kinds: IntegrationKind[]) => { + setSelectedKinds(kinds); + }, []); + + const handleToggleApp = useCallback((containerId: string) => { + setSelectedAppIds((prev) => { + const next = new Set(prev); + if (next.has(containerId)) { + next.delete(containerId); + } else { + next.add(containerId); + } + return next; + }); + }, []); + + const handleContinueToConfig = () => { + const newDrafts = new Map(drafts); + for (const kind of selectedKinds) { + const existing = newDrafts.get(kind); + if (baseHost) { + newDrafts.set(kind, { + kind, + initialUrl: buildIntegrationUrl(kind, baseHost, urlMode, existing?.dockerPort ?? undefined), + initialName: existing?.initialName, + dockerPort: existing?.dockerPort, + source: existing?.source ?? "manual", + }); + } else if (!existing) { + newDrafts.set(kind, { kind, initialUrl: "", source: "manual" }); + } + } + setDrafts(newDrafts); + setCurrentIndex(0); + setPhase("configure"); + }; + + const finishSetupAsync = async () => { + setPhase("done"); + try { + const appsToCreate = dockerApps.filter((app) => selectedAppIds.has(app.containerId)); + if (appsToCreate.length > 0) { + await createApps( + appsToCreate.map((app) => { + const href = baseHost + ? buildAppUrl(app.containerName, baseHost, urlMode, app.publishedPort ?? undefined) + : app.suggestedUrl; + return { + name: app.containerName, + href: href || null, + iconUrl: app.iconUrl, + }; + }), + ); + } + await setupIntegrations(); + router.refresh(); + } catch { + showErrorNotification({ + title: t("configure.error.title"), + message: t("configure.error.message"), + }); + setPhase("select"); + } + }; + + const advanceOrFinish = () => { + if (currentIndex + 1 >= selectedKinds.length) { + void finishSetupAsync(); + } else { + setCurrentIndex((i) => i + 1); + } + }; + + const backToSelect = () => { + setPhase("select"); + setCurrentIndex(0); + }; + + if (phase === "done") { + return ( + +
+ + + {t("configure.finishing")} + +
+
+ ); + } + + if (phase === "configure") { + return ( + + ); + } + + const selectedAppCount = dockerApps.filter((app) => selectedAppIds.has(app.containerId)).length; + + return ( + + + {t("description")} + + } title={t("urlTemplate.label")}> + + + {t("urlTemplate.warning")} + + + setBaseHost(e.currentTarget.value)} + data-autofocus + /> + setUrlMode(value as UrlTemplateMode)} + data={[ + { label: t("urlTemplate.mode.subdomain"), value: "subdomain" }, + { label: t("urlTemplate.mode.hostPort"), value: "hostPort" }, + ]} + style={{ flex: "0 0 auto" }} + /> + + {baseHost && ( + + {t("urlTemplate.preview", { example: buildIntegrationUrl("sonarr", baseHost, urlMode) })} + + )} + + + + {isDockerLoading && ( + + + + + + + {t("docker.scanning")} + + + + + )} + + {!isDockerLoading && totalDetected > 0 && ( + + + + + + + {t("docker.summary", { + apps: String(totalDetected), + integrations: String(dockerIntegrations.length), + })} + + + + )} + + + + {dockerApps.length > 0 && ( + + + setAppsExpanded((prev) => !prev)} + > + + {t("docker.appSectionLabel", { count: String(selectedAppCount) })} + + + + + + {dockerApps.map((app) => { + const isSelected = selectedAppIds.has(app.containerId); + return ( + handleToggleApp(app.containerId)} + > + + + + {app.containerName} + + {isSelected && } + + + {app.containerName.at(0)?.toUpperCase()} + + + + ); + })} + + + + + )} + + + + + + + + ); +}; + +interface ConfigurePhaseProps { + selectedKinds: IntegrationKind[]; + currentIndex: number; + drafts: Map; + baseHost: string; + setBaseHost: (value: string) => void; + urlMode: UrlTemplateMode; + setUrlMode: (value: UrlTemplateMode) => void; + onSuccess: () => void; + onCancel: () => void; + onSkip: () => void; +} + +const ConfigurePhase = ({ + selectedKinds, + currentIndex, + drafts, + baseHost, + setBaseHost, + urlMode, + setUrlMode, + onSuccess, + onCancel, + onSkip, +}: ConfigurePhaseProps) => { + const t = useScopedI18n("init.step.integrations"); + const [helpersOpen, { toggle: toggleHelpers }] = useDisclosure(false); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- currentIndex is always within bounds of selectedKinds + const currentKind = selectedKinds[currentIndex]!; + const draft = drafts.get(currentKind); + const progress = ((currentIndex + 1) / selectedKinds.length) * 100; + const computedUrl = baseHost + ? buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined) + : draft?.initialUrl; + + return ( + + + + + + {getIntegrationName(currentKind)} + + + + + + + + + + + + {t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })} + + + + + + + {t("configure.helpers.description")} + + + setBaseHost(e.currentTarget.value)} + /> + setUrlMode(value as UrlTemplateMode)} + data={[ + { label: t("urlTemplate.mode.subdomain"), value: "subdomain" }, + { label: t("urlTemplate.mode.hostPort"), value: "hostPort" }, + ]} + style={{ flex: "0 0 auto" }} + /> + + {baseHost && ( + + {t("urlTemplate.preview", { + example: buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined), + })} + + )} + + + + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/settings/init-settings.tsx b/apps/nextjs/src/app/[locale]/init/_steps/settings/init-settings.tsx index cb024fc2f..5c99d651c 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/settings/init-settings.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/settings/init-settings.tsx @@ -1,6 +1,6 @@ "use client"; -import { Button, Card, Group, Stack, Switch, Text } from "@mantine/core"; +import { Button, Group, Stack, Switch, Text } from "@mantine/core"; import { IconArrowRight } from "@tabler/icons-react"; import type { z } from "zod/v4"; @@ -13,6 +13,8 @@ import type { TranslationObject } from "@homarr/translation"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { settingsInitSchema } from "@homarr/validation/settings"; +import { InitStepCard } from "../../_components/init-step-card"; + export const InitSettings = () => { const tSection = useScopedI18n("management.page.settings.section"); const t = useI18n(); @@ -29,8 +31,8 @@ export const InitSettings = () => { return (
- - + + {tSection("analytics.title")} @@ -38,8 +40,8 @@ export const InitSettings = () => { - - + + {tSection("crawlingAndIndexing.title")} @@ -62,7 +64,7 @@ export const InitSettings = () => { /> - + + + {t("restoreSqliteDescription")} + + + + ); + } + + return ( + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/user/init-user.tsx b/apps/nextjs/src/app/[locale]/init/_steps/user/init-user.tsx index fa0ed777d..16760cd4c 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/user/init-user.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/user/init-user.tsx @@ -1,11 +1,10 @@ -import { Card } from "@mantine/core"; - +import { InitStepCard } from "../../_components/init-step-card"; import { InitUserForm } from "./init-user-form"; export const InitUser = () => { return ( - + - + ); }; diff --git a/apps/nextjs/src/app/[locale]/init/page.tsx b/apps/nextjs/src/app/[locale]/init/page.tsx index 5684e5709..212c74e73 100644 --- a/apps/nextjs/src/app/[locale]/init/page.tsx +++ b/apps/nextjs/src/app/[locale]/init/page.tsx @@ -9,10 +9,12 @@ import { getScopedI18n } from "@homarr/translation/server"; import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox"; import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox"; import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo"; +import { onboardingContentWidth, onboardingMaxWidth } from "./_constants"; import { BackToStart } from "./_steps/back"; import { InitFinish } from "./_steps/finish/init-finish"; import { InitGroup } from "./_steps/group/init-group"; import { InitImport } from "./_steps/import/init-import"; +import { InitIntegrations } from "./_steps/integrations/init-integrations"; import { InitSettings } from "./_steps/settings/init-settings"; import { InitStart } from "./_steps/start/init-start"; import { InitUser } from "./_steps/user/init-user"; @@ -23,6 +25,7 @@ const stepComponents: Record MaybePromise
- + diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index e88f1965b..a9c6b65cb 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -1,9 +1,11 @@ import type { Metadata, Viewport } from "next"; import { Inter } from "next/font/google"; +import "@gfazioli/mantine-onboarding-tour/styles.css"; import "@homarr/notifications/styles.css"; import "@homarr/spotlight/styles.css"; import "@homarr/ui/styles.css"; +import "mantine-datatable/styles.css"; import "~/styles/color-scheme.scss"; import "~/styles/scroll-area.scss"; @@ -25,6 +27,7 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation"; import { Analytics } from "~/components/layout/analytics"; import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation"; + import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization"; import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration"; import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; diff --git a/apps/nextjs/src/app/[locale]/manage/apps/page.tsx b/apps/nextjs/src/app/[locale]/manage/apps/page.tsx index 1093524e7..e01a8633e 100644 --- a/apps/nextjs/src/app/[locale]/manage/apps/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/apps/page.tsx @@ -11,6 +11,7 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types"; import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { Link, SearchInput, TablePagination } from "@homarr/ui"; +import { TourTarget } from "~/components/layout/header/tour-target"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { NoResults } from "~/components/no-results"; @@ -45,9 +46,11 @@ export default async function AppsPage(props: AppsPageProps) { title={t("page.list.title")} primaryAction={ canCreate ? ( - <MobileAffixButton component={Link} href="/manage/apps/new"> - {t("page.create.title")} - </MobileAffixButton> + <TourTarget id="manage-apps-create"> + <MobileAffixButton component={Link} href="/manage/apps/new"> + {t("page.create.title")} + </MobileAffixButton> + </TourTarget> ) : undefined } toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />} @@ -56,11 +59,13 @@ export default async function AppsPage(props: AppsPageProps) { > {apps.length === 0 && <AppNoResults />} {apps.length > 0 && ( - <Stack gap="sm"> - {apps.map((app) => ( - <AppCard key={app.id} app={app} /> - ))} - </Stack> + <TourTarget id="manage-apps-list"> + <Stack gap="sm"> + {apps.map((app) => ( + <AppCard key={app.id} app={app} /> + ))} + </Stack> + </TourTarget> )} </ManagePageLayout> ); diff --git a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx index 45bbcd2b5..b7f994c41 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx @@ -28,6 +28,7 @@ import { getScopedI18n } from "@homarr/translation/server"; import { Link, UserAvatar } from "@homarr/ui"; import { getBoardPermissionsAsync } from "~/components/board/permissions/server"; +import { TourTarget } from "~/components/layout/header/tour-target"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { NoResults } from "~/components/no-results"; import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown"; @@ -42,18 +43,26 @@ export default async function ManageBoardsPage() { return ( <ManagePageLayout title={t("title")} - primaryAction={canCreateBoards ? <CreateBoardButton /> : undefined} + primaryAction={ + canCreateBoards ? ( + <TourTarget id="manage-boards-create"> + <CreateBoardButton /> + </TourTarget> + ) : undefined + } floatingPrimaryAction={canCreateBoards} > {boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />} {boards.length > 0 && ( - <Grid> - {boards.map((board) => ( - <GridCol span={{ base: 12, md: 6 }} key={board.id}> - <BoardCard board={board} /> - </GridCol> - ))} - </Grid> + <TourTarget id="manage-boards-list"> + <Grid> + {boards.map((board) => ( + <GridCol span={{ base: 12, md: 6 }} key={board.id}> + <BoardCard board={board} /> + </GridCol> + ))} + </Grid> + </TourTarget> )} </ManagePageLayout> ); diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts index 652cad7aa..18b32206e 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts @@ -8,6 +8,7 @@ import { IconPasswordUser, IconPlug, IconServer, + IconTag, IconUser, } from "@tabler/icons-react"; @@ -28,4 +29,5 @@ export const integrationSecretIcons = { githubAppId: IconCode, githubInstallationId: IconPlug, privateKey: IconKey, + slug: IconTag, } satisfies Record<IntegrationSecretKind, TablerIcon>; diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-dropdown.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-dropdown.tsx deleted file mode 100644 index 25333e127..000000000 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-dropdown.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import type { ChangeEvent } from "react"; -import React, { useMemo, useState } from "react"; -import { Flex, Group, Menu, ScrollArea, Text, TextInput } from "@mantine/core"; -import { IconSearch } from "@tabler/icons-react"; - -import { getIntegrationName, integrationKinds } from "@homarr/definitions"; -import { useI18n } from "@homarr/translation/client"; -import { IntegrationAvatar, Link } from "@homarr/ui"; - -interface IntegrationCreateDropdownContentProps { - enableMockIntegration: boolean; -} - -export const IntegrationCreateDropdownContent = ({ enableMockIntegration }: IntegrationCreateDropdownContentProps) => { - const t = useI18n(); - const [search, setSearch] = useState(""); - - const filteredKinds = useMemo(() => { - return integrationKinds - .filter((kind) => enableMockIntegration || kind !== "mock") - .filter((kind) => getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim())) - .sort((kindA, kindB) => getIntegrationName(kindA).localeCompare(getIntegrationName(kindB))); - }, [search, enableMockIntegration]); - - const handleSearch = React.useCallback( - (event: ChangeEvent<HTMLInputElement>) => setSearch(event.target.value), - [setSearch], - ); - - return ( - <Flex direction={{ base: "column-reverse", md: "column" }} gap="sm"> - <TextInput - leftSection={<IconSearch stroke={1.5} size={20} />} - placeholder={t("integration.page.list.search")} - value={search} - data-autofocus - onChange={handleSearch} - variant="filled" - /> - - {filteredKinds.length > 0 ? ( - <ScrollArea.Autosize mah={384}> - {filteredKinds.map((kind) => ( - <Menu.Item component={Link} href={`/manage/integrations/new?kind=${kind}`} key={kind}> - <Group> - <IntegrationAvatar kind={kind} size="sm" /> - <Text size="sm">{getIntegrationName(kind)}</Text> - </Group> - </Menu.Item> - ))} - </ScrollArea.Autosize> - ) : ( - <Menu.Item disabled>{t("common.noResults")}</Menu.Item> - )} - </Flex> - ); -}; diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form-wrapper.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form-wrapper.tsx new file mode 100644 index 000000000..7d7768ec2 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form-wrapper.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import type { IntegrationKind } from "@homarr/definitions"; + +import { NewIntegrationForm } from "./_integration-new-form"; + +interface IntegrationNewFormWrapperProps { + kind: IntegrationKind; + initialUrl?: string; + initialName?: string; +} + +export const IntegrationNewFormWrapper = ({ kind, initialUrl, initialName }: IntegrationNewFormWrapperProps) => { + const router = useRouter(); + + return ( + <NewIntegrationForm + kind={kind} + initialUrl={initialUrl} + initialName={initialName} + onSuccess={() => router.push("/manage/integrations")} + /> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx index b7fbf3291..b19669a33 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx @@ -1,9 +1,9 @@ "use client"; import { startTransition, useState } from "react"; -import { useRouter } from "next/navigation"; import { Alert, + Anchor, Button, Checkbox, Collapse, @@ -16,7 +16,7 @@ import { Text, TextInput, } from "@mantine/core"; -import { IconCheck, IconInfoCircle } from "@tabler/icons-react"; +import { IconCheck, IconExternalLink, IconInfoCircle, IconKey } from "@tabler/icons-react"; import { z } from "zod/v4"; import { clientApi } from "@homarr/api/client"; @@ -27,6 +27,7 @@ import type { IntegrationKind } from "@homarr/definitions"; import { getAllSecretKindOptions, getIconUrl, + getIntegrationApiKeyUrl, getIntegrationDefaultUrl, getIntegrationName, integrationDefs, @@ -45,9 +46,13 @@ import { IntegrationTestConnectionError } from "../_components/test-connection/i import type { AnyMappedTestConnectionError } from "../_components/test-connection/types"; interface NewIntegrationFormProps { - searchParams: Partial<z.infer<typeof integrationCreateSchema>> & { - kind: IntegrationKind; - }; + kind: IntegrationKind; + initialUrl?: string; + initialName?: string; + onSuccess: () => void; + onCancel?: () => void; + onSkip?: () => void; + isOnboarding?: boolean; } const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and( @@ -58,39 +63,48 @@ const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and( }), ); -export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => { +export const NewIntegrationForm = ({ + kind, + initialUrl, + initialName, + onSuccess, + onCancel, + onSkip, + isOnboarding = false, +}: NewIntegrationFormProps) => { const t = useI18n(); - const secretKinds = getAllSecretKindOptions(searchParams.kind); + const secretKinds = getAllSecretKindOptions(kind); const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url")); - const router = useRouter(); const { data: session } = useSession(); - const canCreateApps = session?.user.permissions.includes("app-create") ?? false; + const canCreateApps = !isOnboarding && (session?.user.permissions.includes("app-create") ?? false); - let url = searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? ""; + let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? ""; if (hasUrlSecret) { - // Placeholder Url, replaced with origin of the secret Url on submit url = "http://localhost"; } const form = useZodForm(formSchema, { initialValues: { - name: searchParams.name ?? getIntegrationName(searchParams.kind), + name: initialName ?? getIntegrationName(kind), url, secrets: secretKinds[0].map((kind) => ({ kind, value: "", })), - attemptSearchEngineCreation: true, - hasApp: false, - appHref: url, + attemptSearchEngineCreation: !isOnboarding, + hasApp: !isOnboarding, + appHref: isOnboarding ? null : url, appId: null, }, }); - const { mutateAsync: createIntegrationAsync, isPending } = clientApi.integration.create.useMutation({ + const { mutateAsync: createIntegrationAsync, isPending: isCreatePending } = clientApi.integration.create.useMutation({ async onSuccess() { await revalidatePathActionAsync("/manage/integrations"); }, }); + const { mutateAsync: createOnboardingIntegrationAsync, isPending: isOnboardingCreatePending } = + clientApi.onboard.createIntegration.useMutation(); + const isPending = isCreatePending || isOnboardingCreatePending; const [error, setError] = useState<null | AnyMappedTestConnectionError>(null); const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => { @@ -98,6 +112,39 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => ? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin : values.url; + const onMutationSuccess = (data: { error?: AnyMappedTestConnectionError } | undefined | void) => { + if (data && "error" in data && data.error) { + setError(data.error); + showErrorNotification({ + title: t("integration.page.create.notification.error.title"), + message: t("integration.page.create.notification.error.message"), + }); + return; + } + + showSuccessNotification({ + title: t("integration.page.create.notification.success.title"), + message: t("integration.page.create.notification.success.message"), + }); + + onSuccess(); + }; + + const onMutationError = () => { + showErrorNotification({ + title: t("integration.page.create.notification.error.title"), + message: t("integration.page.create.notification.error.message"), + }); + }; + + if (isOnboarding) { + await createOnboardingIntegrationAsync( + { kind, name: values.name, url, secrets: values.secrets }, + { onSuccess: onMutationSuccess, onError: onMutationError }, + ); + return; + } + const hasCustomHref = appHref !== null && appHref.trim().length >= 1; const app = hasApp @@ -106,49 +153,21 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => : { name: values.name, href: hasCustomHref ? appHref : url, - iconUrl: getIconUrl(searchParams.kind), + iconUrl: getIconUrl(kind), description: null, pingUrl: url, } : undefined; await createIntegrationAsync( - { - kind: searchParams.kind, - ...values, - url, - app, - }, - { - onSuccess(data) { - // We do it this way as we are unable to send a typesafe error through onError - if (data?.error) { - setError(data.error); - showErrorNotification({ - title: t("integration.page.create.notification.error.title"), - message: t("integration.page.create.notification.error.message"), - }); - return; - } - - showSuccessNotification({ - title: t("integration.page.create.notification.success.title"), - message: t("integration.page.create.notification.success.message"), - }); - - router.push("/manage/integrations"); - }, - onError: () => { - showErrorNotification({ - title: t("integration.page.create.notification.error.title"), - message: t("integration.page.create.notification.error.message"), - }); - }, - }, + { kind, ...values, url, app }, + { onSuccess: onMutationSuccess, onError: onMutationError }, ); }; - const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search"); + const integrationCategories = integrationDefs[kind].category.flat(); + const supportsSearchEngine = + integrationCategories.includes("search") && !integrationCategories.includes("mediaSearch"); return ( <form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}> @@ -175,27 +194,39 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => <Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text> </Alert> )} + <ApiKeySettingsLink kind={kind} url={form.values.url} /> </Stack> </Fieldset> {error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />} - {supportsSearchEngine && ( + {!isOnboarding && supportsSearchEngine && ( <Checkbox label={t("integration.field.attemptSearchEngineCreation.label")} description={t("integration.field.attemptSearchEngineCreation.description", { - kind: getIntegrationName(searchParams.kind), + kind: getIntegrationName(kind), })} {...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })} /> )} - <AppForm form={form} canCreateApps={canCreateApps} /> + {!isOnboarding && <AppForm form={form} canCreateApps={canCreateApps} />} <Group justify="end" align="center"> - <Button variant="default" component={Link} href="/manage/integrations"> - {t("common.action.backToOverview")} - </Button> + {onCancel ? ( + <Button variant="default" onClick={onCancel}> + {t("common.action.backToOverview")} + </Button> + ) : ( + <Button variant="default" component={Link} href="/manage/integrations"> + {t("common.action.backToOverview")} + </Button> + )} + {onSkip && ( + <Button variant="default" onClick={onSkip}> + {t("common.action.skip")} + </Button> + )} <Button type="submit" loading={isPending}> {t("integration.testConnection.action.create")} </Button> @@ -266,6 +297,40 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c ); }; +const normalizeUrl = (raw: string): string | null => { + try { + return new URL(raw).href; + } catch { + // Bare IP / hostname — prepend http:// and retry + } + try { + return new URL(`http://${raw}`).href; + } catch { + return null; + } +}; + +const ApiKeySettingsLink = ({ kind, url }: { kind: IntegrationKind; url: string }) => { + const t = useI18n(); + const apiKeyUrl = getIntegrationApiKeyUrl(url, kind); + if (!apiKeyUrl) return null; + + const resolved = normalizeUrl(url); + if (!resolved) return null; + + const fullApiKeyUrl = getIntegrationApiKeyUrl(resolved, kind); + + return ( + <Anchor href={fullApiKeyUrl ?? apiKeyUrl} target="_blank" rel="noopener noreferrer" size="sm"> + <Group gap={4}> + <IconKey size={14} stroke={1.5} /> + <Text size="sm">{t("integration.field.apiKeySettings.label")}</Text> + <IconExternalLink size={14} stroke={1.5} /> + </Group> + </Anchor> + ); +}; + type IntegrationAppSelectProps = Modify< GetInputPropsReturnType, { diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-select-grid.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-select-grid.tsx new file mode 100644 index 000000000..cae5ee38b --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-select-grid.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import type { IntegrationKind } from "@homarr/definitions"; + +import { IntegrationSelectGrid } from "~/components/integration/integration-select-grid"; + +interface IntegrationNewSelectGridProps { + enableMockIntegration: boolean; +} + +export const IntegrationNewSelectGrid = ({ enableMockIntegration }: IntegrationNewSelectGridProps) => { + const router = useRouter(); + + const handleSelect = (kind: IntegrationKind) => { + router.push(`/manage/integrations/new?kind=${kind}`); + }; + + return <IntegrationSelectGrid onSelect={handleSelect} enableMockIntegration={enableMockIntegration} />; +}; diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx index 1938a569e..3194134c3 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx @@ -3,21 +3,21 @@ import { Container, Group, Stack, Title } from "@mantine/core"; import { z } from "zod/v4"; import { auth } from "@homarr/auth/next"; -import type { IntegrationKind } from "@homarr/definitions"; import { getIntegrationName, integrationKinds } from "@homarr/definitions"; import { getScopedI18n } from "@homarr/translation/server"; import { IntegrationAvatar } from "@homarr/ui"; -import type { integrationCreateSchema } from "@homarr/validation/integration"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; -import { NewIntegrationForm } from "./_integration-new-form"; +import { env } from "~/env"; +import { IntegrationNewFormWrapper } from "./_integration-new-form-wrapper"; +import { IntegrationNewSelectGrid } from "./_integration-new-select-grid"; interface NewIntegrationPageProps { - searchParams: Promise< - Partial<z.infer<typeof integrationCreateSchema>> & { - kind: IntegrationKind; - } - >; + searchParams: Promise<{ + kind?: string; + url?: string; + name?: string; + }>; } export default async function IntegrationsNewPage(props: NewIntegrationPageProps) { @@ -28,8 +28,20 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps } const result = z.enum(integrationKinds).safeParse(searchParams.kind); + if (!result.success) { - notFound(); + const t = await getScopedI18n("integration"); + return ( + <> + <DynamicBreadcrumb /> + <Container> + <Stack> + <Title>{t("action.create")} + + + + + ); } const tCreate = await getScopedI18n("integration.page.create"); @@ -45,7 +57,7 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps {tCreate("title", { name: getIntegrationName(currentKind) })} - + diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx index 994d63b3c..0eeb25c1a 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx @@ -1,5 +1,4 @@ import { Fragment } from "react"; -import type { PropsWithChildren } from "react"; import { redirect } from "next/navigation"; import { AccordionControl, @@ -8,12 +7,8 @@ import { ActionIcon, ActionIconGroup, Anchor, - Button, Divider, Group, - Menu, - MenuDropdown, - MenuTarget, Stack, Table, TableTbody, @@ -23,7 +18,7 @@ import { TableTr, Text, } from "@mantine/core"; -import { IconChevronDown, IconPencil, IconPlugX } from "@tabler/icons-react"; +import { IconPencil, IconPlugX } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; @@ -34,13 +29,12 @@ import { getIntegrationName } from "@homarr/definitions"; import { getScopedI18n } from "@homarr/translation/server"; import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui"; -import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; +import { TourTarget } from "~/components/layout/header/tour-target"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; +import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { NoResults } from "~/components/no-results"; -import { env } from "~/env"; import { ActiveTabAccordion } from "../../../../components/active-tab-accordion"; import { DeleteIntegrationActionButton } from "./_integration-buttons"; -import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown"; import classes from "./page.module.css"; interface IntegrationsPageProps { @@ -67,41 +61,22 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) { title={t("page.list.title")} primaryAction={ canCreateIntegrations ? ( - - - - - - - + + + {t("action.create")} + + ) : undefined } floatingPrimaryAction={canCreateIntegrations} > - + + + ); } -const IntegrationSelectMenu = ({ children }: PropsWithChildren) => { - return ( - - {children} - - - - - ); -}; - interface IntegrationListProps { integrations: RouterOutputs["integration"]["all"]; activeTab?: IntegrationKind; @@ -134,12 +109,12 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps return ( - {entries.map(([kind, integrations], index) => ( + {entries.map(([kind, kindIntegrations], index) => ( }> {getIntegrationName(kind)} - + @@ -152,7 +127,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps - {integrations.map((integration) => ( + {kindIntegrations.map((integration) => ( {integration.name} @@ -173,7 +148,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps > - + )} @@ -184,9 +159,9 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps - {integrations.map((integration, index) => ( + {kindIntegrations.map((integration, integrationIndex) => ( - {index !== 0 && } + {integrationIndex !== 0 && } {integration.name} @@ -201,7 +176,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps > - + )} diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx index 32013221c..3253670ff 100644 --- a/apps/nextjs/src/app/[locale]/manage/layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx @@ -10,6 +10,7 @@ import { IconBrandTablerFilled, IconCertificate, IconClipboardListFilled, + IconDatabaseExport, IconDirectionsFilled, IconGitFork, IconHelpSquareRoundedFilled, @@ -28,6 +29,7 @@ import { import { auth } from "@homarr/auth/next"; import { isProviderEnabled } from "@homarr/auth/server"; import { createDocumentationLink } from "@homarr/definitions"; +import { dbEnv } from "@homarr/core/infrastructure/db/env"; import { env } from "@homarr/docker/env"; import { getScopedI18n } from "@homarr/translation/server"; @@ -36,6 +38,7 @@ import { homarrLogoPath } from "~/components/layout/logo/homarr-logo"; import type { NavigationLink } from "~/components/layout/navigation"; import { MainNavigation } from "~/components/layout/navigation"; import { ClientShell } from "~/components/layout/shell"; +import { ManageTourProvider } from "~/components/onboarding/manage-tour"; export default async function ManageLayout({ children }: PropsWithChildren) { const t = await getScopedI18n("management.navbar"); @@ -45,11 +48,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) { label: t("items.home"), icon: IconHomeFilled, href: "/manage", + "data-onboarding-tour-id": "manage-welcome", }, { icon: IconLayoutDashboardFilled, href: "/manage/boards", label: t("items.boards"), + "data-onboarding-tour-id": "manage-boards", }, { icon: IconBox, @@ -59,12 +64,14 @@ export default async function ManageLayout({ children }: PropsWithChildren) { iconProps: { strokeWidth: 2.5, }, + "data-onboarding-tour-id": "manage-apps", }, { icon: IconAffiliateFilled, href: "/manage/integrations", label: t("items.integrations"), hidden: !session, + "data-onboarding-tour-id": "manage-integrations", }, { icon: IconSearch, @@ -74,17 +81,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) { iconProps: { strokeWidth: 2.5, }, + "data-onboarding-tour-id": "manage-search-engines", }, { icon: IconPhotoFilled, href: "/manage/medias", label: t("items.medias"), hidden: !session, + "data-onboarding-tour-id": "manage-medias", }, { icon: IconUserFilled, label: t("items.users.label"), hidden: !session?.user.permissions.includes("admin"), + "data-onboarding-tour-id": "manage-users", items: [ { label: t("items.users.items.manage"), @@ -146,6 +156,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) { href: "/manage/tools/tasks", hidden: !session?.user.permissions.includes("admin"), }, + { + label: t("items.tools.items.backup"), + icon: IconDatabaseExport, + href: "/manage/tools/backup", + hidden: !session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3", + }, ], }, { @@ -153,6 +169,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) { href: "/manage/settings", icon: IconSettingsFilled, hidden: !session?.user.permissions.includes("admin"), + "data-onboarding-tour-id": "manage-settings", }, { label: t("items.help.label"), @@ -191,11 +208,17 @@ export default async function ManageLayout({ children }: PropsWithChildren) { }, ]; - return ( + const isAdmin = session?.user.permissions.includes("admin") ?? false; + + const shell = ( {children} ); + + if (!session) return shell; + + return {shell}; } diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx new file mode 100644 index 000000000..c56dfc916 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useState } from "react"; +import type { ReactNode } from "react"; +import { Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core"; +import { IconBrain, IconCode, IconKey } from "@tabler/icons-react"; + +import { McpInstructions } from "./mcp-instructions"; + +export interface McpToolGroup { + namespace: string; + tools: { name: string; description: string; type: "query" | "mutation" }[]; +} + +interface ApiPageTabsProps { + documentationLabel: string; + apiKeyLabel: string; + mcpLabel: string; + documentationPanel: ReactNode; + authenticationPanel: ReactNode; + baseUrl: string; + hasApiKeys: boolean; + toolGroups: McpToolGroup[]; +} + +export function ApiPageTabs({ + documentationLabel, + apiKeyLabel, + mcpLabel, + documentationPanel, + authenticationPanel, + baseUrl, + hasApiKeys, + toolGroups, +}: ApiPageTabsProps) { + const [activeTab, setActiveTab] = useState("documentation"); + + return ( + + + }> + {documentationLabel} + + }> + {apiKeyLabel} + + }> + {mcpLabel} + + + {authenticationPanel} + {documentationPanel} + + + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx new file mode 100644 index 000000000..f3ac4ea6d --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx @@ -0,0 +1,343 @@ +"use client"; + +import { + Accordion, + AccordionControl, + AccordionItem, + AccordionPanel, + ActionIcon, + Alert, + Badge, + Button, + Code, + CopyButton, + Group, + List, + ListItem, + Stack, + Text, + ThemeIcon, + Title, +} from "@mantine/core"; +import { + IconAlertTriangle, + IconBrain, + IconCheck, + IconCopy, + IconKey, + IconLock, + IconPlus, + IconPlugConnected, + IconSparkles, + IconTerminal, + IconTools, + IconWorld, +} from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { revalidatePathActionAsync } from "@homarr/common/client"; +import { useModalAction } from "@homarr/modals"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { CopyApiKeyModal } from "./copy-api-key-modal"; +import type { McpToolGroup } from "./api-page-tabs"; +import classes from "./mcp-tools-accordion.module.css"; + +const toolTypeDisplay: Record<"query" | "mutation", { color: string; method: string }> = { + query: { color: "blue", method: "GET" }, + mutation: { color: "orange", method: "POST" }, +}; + +interface McpInstructionsProps { + baseUrl: string; + hasApiKeys: boolean; + toolGroups: McpToolGroup[]; +} + +export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstructionsProps) { + const t = useScopedI18n("management.page.tool.api.tab.mcp"); + const { openModal } = useModalAction(CopyApiKeyModal); + const { mutate: createApiKey, isPending } = clientApi.apiKeys.create.useMutation({ + async onSuccess(data) { + openModal({ apiKey: data.apiKey }); + await revalidatePathActionAsync("/manage/tools/api"); + }, + }); + const mcpUrl = `${baseUrl}/api/mcp/mcp`; + + const streamableHttpConfig = JSON.stringify( + { + mcpServers: { + homarr: { + url: mcpUrl, + headers: { + ApiKey: "", + }, + }, + }, + }, + null, + 2, + ); + + const stdioConfig = JSON.stringify( + { + mcpServers: { + homarr: { + command: "npx", + args: ["-y", "mcp-remote", mcpUrl, "--header", "ApiKey:"], + }, + }, + }, + null, + 2, + ); + + const testCommand = `curl -s -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -H "ApiKey: " -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}' ${mcpUrl}`; + + return ( + + + + + +
+ {t("title")} + + {t("subtitle")} + +
+
+ + }> + {t("description")} + + + {!hasApiKeys && ( + }> + + {t("noApiKey")} + + + + )} + +
+ + <Group gap="xs"> + <IconPlugConnected size={16} /> + {t("endpoint.title")} + </Group> + + +
+ +
+ + <Group gap="xs"> + <IconLock size={16} /> + {t("authentication.title")} + </Group> + + + {t("authentication.intro")} + + + + + + + + {t("authentication.apiKey.title")} + + + + + {t("authentication.apiKey.description")} + + {t("authentication.permissions")} + + + + + + + + + + {t("authentication.oauth.title")} + + + + + {t("authentication.oauth.description")} + + {t("authentication.oauth.hint")} + + + + + +
+ +
+ + {t("clientConfig.title")} + + + + + + + {t("clientConfig.streamableHttp.recommended")} + + {t("clientConfig.streamableHttp.title")} + + + + + + {t("clientConfig.streamableHttp.worksWith")} + + + {t("clientConfig.streamableHttp.addToConfig")} + + + + {t("clientConfig.streamableHttp.cursor", { + name: "Cursor", + file: ".cursor/mcp.json", + })} + + + {t("clientConfig.streamableHttp.claudeDesktop", { + name: "Claude Desktop", + file: "claude_desktop_config.json", + })} + + + {t("clientConfig.streamableHttp.claudeCode", { + name: "Claude Code", + file: ".claude/mcp.json", + command: "claude mcp add homarr", + })} + + + {t("clientConfig.streamableHttp.vsCodeCopilot", { + name: "VS Code Copilot", + file: ".vscode/mcp.json", + })} + + + + } p="xs"> + {t("clientConfig.streamableHttp.replaceHint")} + + + + + + + {t("clientConfig.stdio.title")} + + + + {t("clientConfig.stdio.description", { dependency: "npx" })} + + + + + + +
+ +
+ + <Group gap="xs"> + <IconTerminal size={16} /> + {t("testConnection.title")} + </Group> + + + {t("testConnection.description")} + + + + {t("testConnection.hint")} + +
+ +
+ + <Group gap="xs"> + <IconTools size={16} /> + {t("availableTools.title")} + </Group> + + + {t("availableTools.description", { + count: String(toolGroups.reduce((sum, g) => sum + g.tools.length, 0)), + })} + + + {toolGroups.map((group) => ( + + + + + {group.namespace} + + + {group.tools.length} + + + + + + {group.tools.map((tool) => ( +
+ + {toolTypeDisplay[tool.type].method} + +
+ + {tool.name} + + + {tool.description} + +
+
+ ))} +
+
+
+ ))} +
+
+
+ ); +} + +function CopyableCode({ value, copyLabel }: { value: string; copyLabel: string }) { + return ( + + {value} + + {({ copied, copy }) => ( + + {copied ? : } + + )} + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-tools-accordion.module.css b/apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-tools-accordion.module.css new file mode 100644 index 000000000..9cb951b52 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-tools-accordion.module.css @@ -0,0 +1,6 @@ +.toolRow { + display: grid; + grid-template-columns: 42px 1fr; + align-items: first baseline; + gap: var(--mantine-spacing-xs); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/scalar-api-reference.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/scalar-api-reference.tsx new file mode 100644 index 000000000..c4958d125 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/components/scalar-api-reference.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useComputedColorScheme } from "@mantine/core"; +import { ApiReferenceReact } from "@scalar/api-reference-react"; + +import "@scalar/api-reference-react/style.css"; +import "./scalar-theme.css"; + +interface ScalarApiReferenceProps { + document: object; +} + +export function ScalarApiReference({ document }: ScalarApiReferenceProps) { + const colorScheme = useComputedColorScheme("light"); + + return ( + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/scalar-theme.css b/apps/nextjs/src/app/[locale]/manage/tools/api/components/scalar-theme.css new file mode 100644 index 000000000..b795f7ad9 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/components/scalar-theme.css @@ -0,0 +1,51 @@ +.light-mode { + --scalar-background-1: var(--mantine-color-body); + --scalar-background-2: var(--mantine-color-gray-0); + --scalar-background-3: var(--mantine-color-gray-1); + --scalar-background-card: var(--mantine-color-white); + --scalar-background-accent: rgba(0, 0, 0, 0.04); + + --scalar-color-1: var(--mantine-color-text); + --scalar-color-2: var(--mantine-color-dimmed); + --scalar-color-3: var(--mantine-color-dimmed); + --scalar-color-accent: var(--mantine-color-red-6); + + --scalar-border-color: var(--mantine-color-gray-3); + + --scalar-button-1: var(--mantine-color-dark-7); + --scalar-button-1-hover: var(--mantine-color-dark-5); + --scalar-button-1-color: var(--mantine-color-white); + + --scalar-scrollbar-color: rgba(0, 0, 0, 0.18); + --scalar-scrollbar-color-active: rgba(0, 0, 0, 0.36); +} + +.dark-mode { + --scalar-background-1: var(--mantine-color-body); + --scalar-background-2: var(--mantine-color-dark-6); + --scalar-background-3: var(--mantine-color-dark-5); + --scalar-background-card: var(--mantine-color-dark-6); + --scalar-background-accent: rgba(255, 255, 255, 0.06); + + --scalar-color-1: var(--mantine-color-text); + --scalar-color-2: var(--mantine-color-dimmed); + --scalar-color-3: var(--mantine-color-dimmed); + --scalar-color-accent: var(--mantine-color-red-6); + + --scalar-border-color: var(--mantine-color-dark-4); + + --scalar-button-1: rgba(255, 255, 255, 0.9); + --scalar-button-1-hover: rgba(255, 255, 255, 0.8); + --scalar-button-1-color: var(--mantine-color-dark-7); + + --scalar-scrollbar-color: rgba(255, 255, 255, 0.18); + --scalar-scrollbar-color-active: rgba(255, 255, 255, 0.36); +} + +.scalar-card, +.scalar-api-client__item, +.dark-mode .dark-mode.scalar-card { + --scalar-background-1: var(--scalar-background-card); + --scalar-background-2: var(--scalar-background-card); + --scalar-background-3: var(--scalar-background-card); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/swagger-ui.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/swagger-ui.tsx deleted file mode 100644 index 1f0073df6..000000000 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/components/swagger-ui.tsx +++ /dev/null @@ -1,23 +0,0 @@ -"use client"; - -import type { OpenAPIObject } from "openapi3-ts/oas31"; -import SwaggerUI from "swagger-ui-react"; - -// workaround for CSS that cannot be processed by next.js, https://github.com/swagger-api/swagger-ui/issues/10045 -import "../swagger-ui-dark.css"; -import "../swagger-ui-overrides.css"; -import "../swagger-ui.css"; - -interface SwaggerUIClientProps { - document: OpenAPIObject; -} - -export const SwaggerUIClient = ({ document }: SwaggerUIClientProps) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const requestInterceptor = (req: Record) => { - req.credentials = "omit"; - return req; - }; - - return ; -}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx index f3fe3b825..bdd86cc7c 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx @@ -1,16 +1,57 @@ import { headers } from "next/headers"; import { notFound } from "next/navigation"; -import { Stack, Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core"; +import { Stack } from "@mantine/core"; +import { mcpRouter } from "@homarr/api/mcp"; import { openApiDocument } from "@homarr/api/open-api"; import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; import { extractBaseUrlFromHeaders } from "@homarr/common"; import { getScopedI18n } from "@homarr/translation/server"; -import { SwaggerUIClient } from "~/app/[locale]/manage/tools/api/components/swagger-ui"; import { createMetaTitle } from "~/metadata"; +import { extractMcpTools } from "~/app/api/mcp/_extract-tools"; import { ApiKeysManagement } from "./components/api-keys"; +import { ApiPageTabs } from "./components/api-page-tabs"; +import { ScalarApiReference } from "./components/scalar-api-reference"; + +import type { McpToolGroup } from "./components/api-page-tabs"; + +let cachedToolGroups: McpToolGroup[] | null = null; + +function getMcpToolGroups(): McpToolGroup[] { + if (cachedToolGroups) return cachedToolGroups; + + const tools = extractMcpTools(); + + const procedures = (mcpRouter as any)._def.procedures as Record; + const procedureTypeMap = new Map(); + for (const [key, proc] of Object.entries(procedures)) { + const type = proc?._def?.type; + if (type === "query" || type === "mutation") { + procedureTypeMap.set(key, type); + } + } + + const groups = new Map(); + for (const tool of tools) { + const namespace = tool.pathInRouter[0] ?? "other"; + if (!groups.has(namespace)) { + groups.set(namespace, []); + } + const procedureKey = tool.pathInRouter.join("."); + groups.get(namespace)?.push({ + name: tool.name, + description: tool.description, + type: procedureTypeMap.get(procedureKey) ?? "query", + }); + } + cachedToolGroups = Array.from(groups.entries()).map(([namespace, items]) => ({ + namespace, + tools: items, + })); + return cachedToolGroups; +} export async function generateMetadata() { const session = await auth(); @@ -30,24 +71,25 @@ export default async function ApiPage() { if (!session?.user.permissions.includes("admin")) { notFound(); } - const document = openApiDocument(extractBaseUrlFromHeaders(await headers())); + const requestHeaders = await headers(); + const baseUrl = extractBaseUrlFromHeaders(requestHeaders); + const document = openApiDocument(baseUrl); const apiKeys = await api.apiKeys.getAll(); const t = await getScopedI18n("management.page.tool.api.tab"); + const toolGroups = getMcpToolGroups(); return ( - - - {t("documentation.label")} - {t("apiKey.label")} - - - - - - - - + } + authenticationPanel={} + baseUrl={baseUrl} + hasApiKeys={apiKeys.length > 0} + toolGroups={toolGroups} + /> ); } diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/swagger-ui-dark.css b/apps/nextjs/src/app/[locale]/manage/tools/api/swagger-ui-dark.css deleted file mode 100644 index 33277c081..000000000 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/swagger-ui-dark.css +++ /dev/null @@ -1,1719 +0,0 @@ -[data-mantine-color-scheme="dark"] { - @media only screen { - .swagger-ui a { - color: #d0d0d0; - } - - .swagger-ui ::-webkit-scrollbar-track-piece { - background-color: rgba(255, 255, 255, 0.2) !important; - } - - .swagger-ui ::-webkit-scrollbar-track { - background-color: rgba(255, 255, 255, 0.3) !important; - } - - .swagger-ui ::-webkit-scrollbar-thumb { - background-color: rgba(255, 255, 255, 0.5) !important; - } - - .swagger-ui embed[type="application/pdf"] { - filter: invert(90%); - } - - .swagger-ui, - .swagger-ui section h3 { - color: #b5bac9; - } - - .swagger-ui a { - background-color: transparent; - } - - .swagger-ui mark { - background-color: #664b00; - color: #bfbfbf; - } - - .swagger-ui legend { - color: inherit; - } - - .swagger-ui .debug * { - outline: #e6da99 solid 1px; - } - - .swagger-ui .debug-white * { - outline: #fff solid 1px; - } - - .swagger-ui .debug-black * { - outline: #bfbfbf solid 1px; - } - - .swagger-ui .debug-grid { - background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTRDOTY4N0U2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTRDOTY4N0Q2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3NjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3NzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PsBS+GMAAAAjSURBVHjaYvz//z8DLsD4gcGXiYEAGBIKGBne//fFpwAgwAB98AaF2pjlUQAAAABJRU5ErkJggg==) - 0 0; - } - - .swagger-ui .debug-grid-16 { - background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6ODYyRjhERDU2N0YyMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6ODYyRjhERDQ2N0YyMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3QTY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3QjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PvCS01IAAABMSURBVHjaYmR4/5+BFPBfAMFm/MBgx8RAGWCn1AAmSg34Q6kBDKMGMDCwICeMIemF/5QawEipAWwUhwEjMDvbAWlWkvVBwu8vQIABAEwBCph8U6c0AAAAAElFTkSuQmCC) - 0 0; - } - - .swagger-ui .debug-grid-8-solid { - background: url(data:image/jpeg;base64,/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/sABFEdWNreQABAAQAAAAAAAD/4QMxaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjYtYzExMSA3OS4xNTgzMjUsIDIwMTUvMDkvMTAtMDE6MTA6MjAgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE1IChNYWNpbnRvc2gpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOkIxMjI0OTczNjdCMzExRTZCMkJDRTI0MDgxMDAyMTcxIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOkIxMjI0OTc0NjdCMzExRTZCMkJDRTI0MDgxMDAyMTcxIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6QjEyMjQ5NzE2N0IzMTFFNkIyQkNFMjQwODEwMDIxNzEiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6QjEyMjQ5NzI2N0IzMTFFNkIyQkNFMjQwODEwMDIxNzEiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7/7gAOQWRvYmUAZMAAAAAB/9sAhAAbGhopHSlBJiZBQi8vL0JHPz4+P0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHAR0pKTQmND8oKD9HPzU/R0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0f/wAARCAAIAAgDASIAAhEBAxEB/8QAWQABAQAAAAAAAAAAAAAAAAAAAAYBAQEAAAAAAAAAAAAAAAAAAAIEEAEBAAMBAAAAAAAAAAAAAAABADECA0ERAAEDBQAAAAAAAAAAAAAAAAARITFBUWESIv/aAAwDAQACEQMRAD8AoOnTV1QTD7JJshP3vSM3P//Z) - 0 0 #1c1c21; - } - - .swagger-ui .debug-grid-16-solid { - background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NzY3MkJEN0U2N0M1MTFFNkIyQkNFMjQwODEwMDIxNzEiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NzY3MkJEN0Y2N0M1MTFFNkIyQkNFMjQwODEwMDIxNzEiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3QzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3RDY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pve6J3kAAAAzSURBVHjaYvz//z8D0UDsMwMjSRoYP5Gq4SPNbRjVMEQ1fCRDg+in/6+J1AJUxsgAEGAA31BAJMS0GYEAAAAASUVORK5CYII=) - 0 0 #1c1c21; - } - - .swagger-ui .b--black { - border-color: #000; - } - - .swagger-ui .b--near-black { - border-color: #121212; - } - - .swagger-ui .b--dark-gray { - border-color: #333; - } - - .swagger-ui .b--mid-gray { - border-color: #545454; - } - - .swagger-ui .b--gray { - border-color: #787878; - } - - .swagger-ui .b--silver { - border-color: #999; - } - - .swagger-ui .b--light-silver { - border-color: #6e6e6e; - } - - .swagger-ui .b--moon-gray { - border-color: #4d4d4d; - } - - .swagger-ui .b--light-gray { - border-color: #2b2b2b; - } - - .swagger-ui .b--near-white { - border-color: #242424; - } - - .swagger-ui .b--white { - border-color: #1c1c21; - } - - .swagger-ui .b--white-90 { - border-color: rgba(28, 28, 33, 0.9); - } - - .swagger-ui .b--white-80 { - border-color: rgba(28, 28, 33, 0.8); - } - - .swagger-ui .b--white-70 { - border-color: rgba(28, 28, 33, 0.7); - } - - .swagger-ui .b--white-60 { - border-color: rgba(28, 28, 33, 0.6); - } - - .swagger-ui .b--white-50 { - border-color: rgba(28, 28, 33, 0.5); - } - - .swagger-ui .b--white-40 { - border-color: rgba(28, 28, 33, 0.4); - } - - .swagger-ui .b--white-30 { - border-color: rgba(28, 28, 33, 0.3); - } - - .swagger-ui .b--white-20 { - border-color: rgba(28, 28, 33, 0.2); - } - - .swagger-ui .b--white-10 { - border-color: rgba(28, 28, 33, 0.1); - } - - .swagger-ui .b--white-05 { - border-color: rgba(28, 28, 33, 0.05); - } - - .swagger-ui .b--white-025 { - border-color: rgba(28, 28, 33, 0.024); - } - - .swagger-ui .b--white-0125 { - border-color: rgba(28, 28, 33, 0.01); - } - - .swagger-ui .b--black-90 { - border-color: rgba(0, 0, 0, 0.9); - } - - .swagger-ui .b--black-80 { - border-color: rgba(0, 0, 0, 0.8); - } - - .swagger-ui .b--black-70 { - border-color: rgba(0, 0, 0, 0.7); - } - - .swagger-ui .b--black-60 { - border-color: rgba(0, 0, 0, 0.6); - } - - .swagger-ui .b--black-50 { - border-color: rgba(0, 0, 0, 0.5); - } - - .swagger-ui .b--black-40 { - border-color: rgba(0, 0, 0, 0.4); - } - - .swagger-ui .b--black-30 { - border-color: rgba(0, 0, 0, 0.3); - } - - .swagger-ui .b--black-20 { - border-color: rgba(0, 0, 0, 0.2); - } - - .swagger-ui .b--black-10 { - border-color: rgba(0, 0, 0, 0.1); - } - - .swagger-ui .b--black-05 { - border-color: rgba(0, 0, 0, 0.05); - } - - .swagger-ui .b--black-025 { - border-color: rgba(0, 0, 0, 0.024); - } - - .swagger-ui .b--black-0125 { - border-color: rgba(0, 0, 0, 0.01); - } - - .swagger-ui .b--dark-red { - border-color: #bc2f36; - } - - .swagger-ui .b--red { - border-color: #c83932; - } - - .swagger-ui .b--light-red { - border-color: #ab3c2b; - } - - .swagger-ui .b--orange { - border-color: #cc6e33; - } - - .swagger-ui .b--purple { - border-color: #5e2ca5; - } - - .swagger-ui .b--light-purple { - border-color: #672caf; - } - - .swagger-ui .b--dark-pink { - border-color: #ab2b81; - } - - .swagger-ui .b--hot-pink { - border-color: #c03086; - } - - .swagger-ui .b--pink { - border-color: #8f2464; - } - - .swagger-ui .b--light-pink { - border-color: #721d4d; - } - - .swagger-ui .b--dark-green { - border-color: #1c6e50; - } - - .swagger-ui .b--green { - border-color: #279b70; - } - - .swagger-ui .b--light-green { - border-color: #228762; - } - - .swagger-ui .b--navy { - border-color: #0d1d35; - } - - .swagger-ui .b--dark-blue { - border-color: #20497e; - } - - .swagger-ui .b--blue { - border-color: #4380d0; - } - - .swagger-ui .b--light-blue { - border-color: #20517e; - } - - .swagger-ui .b--lightest-blue { - border-color: #143a52; - } - - .swagger-ui .b--washed-blue { - border-color: #0c312d; - } - - .swagger-ui .b--washed-green { - border-color: #0f3d2c; - } - - .swagger-ui .b--washed-red { - border-color: #411010; - } - - .swagger-ui .b--transparent { - border-color: transparent; - } - - .swagger-ui .b--gold, - .swagger-ui .b--light-yellow, - .swagger-ui .b--washed-yellow, - .swagger-ui .b--yellow { - border-color: #664b00; - } - - .swagger-ui .shadow-1 { - box-shadow: rgba(0, 0, 0, 0.2) 0 0 4px 2px; - } - - .swagger-ui .shadow-2 { - box-shadow: rgba(0, 0, 0, 0.2) 0 0 8px 2px; - } - - .swagger-ui .shadow-3 { - box-shadow: rgba(0, 0, 0, 0.2) 2px 2px 4px 2px; - } - - .swagger-ui .shadow-4 { - box-shadow: rgba(0, 0, 0, 0.2) 2px 2px 8px 0; - } - - .swagger-ui .shadow-5 { - box-shadow: rgba(0, 0, 0, 0.2) 4px 4px 8px 0; - } - - @media screen and (min-width: 30em) { - .swagger-ui .shadow-1-ns { - box-shadow: rgba(0, 0, 0, 0.2) 0 0 4px 2px; - } - - .swagger-ui .shadow-2-ns { - box-shadow: rgba(0, 0, 0, 0.2) 0 0 8px 2px; - } - - .swagger-ui .shadow-3-ns { - box-shadow: rgba(0, 0, 0, 0.2) 2px 2px 4px 2px; - } - - .swagger-ui .shadow-4-ns { - box-shadow: rgba(0, 0, 0, 0.2) 2px 2px 8px 0; - } - - .swagger-ui .shadow-5-ns { - box-shadow: rgba(0, 0, 0, 0.2) 4px 4px 8px 0; - } - } - - @media screen and (max-width: 60em) and (min-width: 30em) { - .swagger-ui .shadow-1-m { - box-shadow: rgba(0, 0, 0, 0.2) 0 0 4px 2px; - } - - .swagger-ui .shadow-2-m { - box-shadow: rgba(0, 0, 0, 0.2) 0 0 8px 2px; - } - - .swagger-ui .shadow-3-m { - box-shadow: rgba(0, 0, 0, 0.2) 2px 2px 4px 2px; - } - - .swagger-ui .shadow-4-m { - box-shadow: rgba(0, 0, 0, 0.2) 2px 2px 8px 0; - } - - .swagger-ui .shadow-5-m { - box-shadow: rgba(0, 0, 0, 0.2) 4px 4px 8px 0; - } - } - - @media screen and (min-width: 60em) { - .swagger-ui .shadow-1-l { - box-shadow: rgba(0, 0, 0, 0.2) 0 0 4px 2px; - } - - .swagger-ui .shadow-2-l { - box-shadow: rgba(0, 0, 0, 0.2) 0 0 8px 2px; - } - - .swagger-ui .shadow-3-l { - box-shadow: rgba(0, 0, 0, 0.2) 2px 2px 4px 2px; - } - - .swagger-ui .shadow-4-l { - box-shadow: rgba(0, 0, 0, 0.2) 2px 2px 8px 0; - } - - .swagger-ui .shadow-5-l { - box-shadow: rgba(0, 0, 0, 0.2) 4px 4px 8px 0; - } - } - - .swagger-ui .black-05 { - color: rgba(191, 191, 191, 0.05); - } - - .swagger-ui .bg-black-05 { - background-color: rgba(0, 0, 0, 0.05); - } - - .swagger-ui .black-90, - .swagger-ui .hover-black-90:focus, - .swagger-ui .hover-black-90:hover { - color: rgba(191, 191, 191, 0.9); - } - - .swagger-ui .black-80, - .swagger-ui .hover-black-80:focus, - .swagger-ui .hover-black-80:hover { - color: rgba(191, 191, 191, 0.8); - } - - .swagger-ui .black-70, - .swagger-ui .hover-black-70:focus, - .swagger-ui .hover-black-70:hover { - color: rgba(191, 191, 191, 0.7); - } - - .swagger-ui .black-60, - .swagger-ui .hover-black-60:focus, - .swagger-ui .hover-black-60:hover { - color: rgba(191, 191, 191, 0.6); - } - - .swagger-ui .black-50, - .swagger-ui .hover-black-50:focus, - .swagger-ui .hover-black-50:hover { - color: rgba(191, 191, 191, 0.5); - } - - .swagger-ui .black-40, - .swagger-ui .hover-black-40:focus, - .swagger-ui .hover-black-40:hover { - color: rgba(191, 191, 191, 0.4); - } - - .swagger-ui .black-30, - .swagger-ui .hover-black-30:focus, - .swagger-ui .hover-black-30:hover { - color: rgba(191, 191, 191, 0.3); - } - - .swagger-ui .black-20, - .swagger-ui .hover-black-20:focus, - .swagger-ui .hover-black-20:hover { - color: rgba(191, 191, 191, 0.2); - } - - .swagger-ui .black-10, - .swagger-ui .hover-black-10:focus, - .swagger-ui .hover-black-10:hover { - color: rgba(191, 191, 191, 0.1); - } - - .swagger-ui .hover-white-90:focus, - .swagger-ui .hover-white-90:hover, - .swagger-ui .white-90 { - color: rgba(255, 255, 255, 0.9); - } - - .swagger-ui .hover-white-80:focus, - .swagger-ui .hover-white-80:hover, - .swagger-ui .white-80 { - color: rgba(255, 255, 255, 0.8); - } - - .swagger-ui .hover-white-70:focus, - .swagger-ui .hover-white-70:hover, - .swagger-ui .white-70 { - color: rgba(255, 255, 255, 0.7); - } - - .swagger-ui .hover-white-60:focus, - .swagger-ui .hover-white-60:hover, - .swagger-ui .white-60 { - color: rgba(255, 255, 255, 0.6); - } - - .swagger-ui .hover-white-50:focus, - .swagger-ui .hover-white-50:hover, - .swagger-ui .white-50 { - color: rgba(255, 255, 255, 0.5); - } - - .swagger-ui .hover-white-40:focus, - .swagger-ui .hover-white-40:hover, - .swagger-ui .white-40 { - color: rgba(255, 255, 255, 0.4); - } - - .swagger-ui .hover-white-30:focus, - .swagger-ui .hover-white-30:hover, - .swagger-ui .white-30 { - color: rgba(255, 255, 255, 0.3); - } - - .swagger-ui .hover-white-20:focus, - .swagger-ui .hover-white-20:hover, - .swagger-ui .white-20 { - color: rgba(255, 255, 255, 0.2); - } - - .swagger-ui .hover-white-10:focus, - .swagger-ui .hover-white-10:hover, - .swagger-ui .white-10 { - color: rgba(255, 255, 255, 0.1); - } - - .swagger-ui .hover-moon-gray:focus, - .swagger-ui .hover-moon-gray:hover, - .swagger-ui .moon-gray { - color: #ccc; - } - - .swagger-ui .hover-light-gray:focus, - .swagger-ui .hover-light-gray:hover, - .swagger-ui .light-gray { - color: #ededed; - } - - .swagger-ui .hover-near-white:focus, - .swagger-ui .hover-near-white:hover, - .swagger-ui .near-white { - color: #f5f5f5; - } - - .swagger-ui .dark-red, - .swagger-ui .hover-dark-red:focus, - .swagger-ui .hover-dark-red:hover { - color: #e6999d; - } - - .swagger-ui .hover-red:focus, - .swagger-ui .hover-red:hover, - .swagger-ui .red { - color: #e69d99; - } - - .swagger-ui .hover-light-red:focus, - .swagger-ui .hover-light-red:hover, - .swagger-ui .light-red { - color: #e6a399; - } - - .swagger-ui .hover-orange:focus, - .swagger-ui .hover-orange:hover, - .swagger-ui .orange { - color: #e6b699; - } - - .swagger-ui .gold, - .swagger-ui .hover-gold:focus, - .swagger-ui .hover-gold:hover { - color: #e6d099; - } - - .swagger-ui .hover-yellow:focus, - .swagger-ui .hover-yellow:hover, - .swagger-ui .yellow { - color: #e6da99; - } - - .swagger-ui .hover-light-yellow:focus, - .swagger-ui .hover-light-yellow:hover, - .swagger-ui .light-yellow { - color: #ede6b6; - } - - .swagger-ui .hover-purple:focus, - .swagger-ui .hover-purple:hover, - .swagger-ui .purple { - color: #b99ae4; - } - - .swagger-ui .hover-light-purple:focus, - .swagger-ui .hover-light-purple:hover, - .swagger-ui .light-purple { - color: #bb99e6; - } - - .swagger-ui .dark-pink, - .swagger-ui .hover-dark-pink:focus, - .swagger-ui .hover-dark-pink:hover { - color: #e699cc; - } - - .swagger-ui .hot-pink, - .swagger-ui .hover-hot-pink:focus, - .swagger-ui .hover-hot-pink:hover, - .swagger-ui .hover-pink:focus, - .swagger-ui .hover-pink:hover, - .swagger-ui .pink { - color: #e699c7; - } - - .swagger-ui .hover-light-pink:focus, - .swagger-ui .hover-light-pink:hover, - .swagger-ui .light-pink { - color: #edb6d5; - } - - .swagger-ui .dark-green, - .swagger-ui .green, - .swagger-ui .hover-dark-green:focus, - .swagger-ui .hover-dark-green:hover, - .swagger-ui .hover-green:focus, - .swagger-ui .hover-green:hover { - color: #99e6c9; - } - - .swagger-ui .hover-light-green:focus, - .swagger-ui .hover-light-green:hover, - .swagger-ui .light-green { - color: #a1e8ce; - } - - .swagger-ui .hover-navy:focus, - .swagger-ui .hover-navy:hover, - .swagger-ui .navy { - color: #99b8e6; - } - - .swagger-ui .blue, - .swagger-ui .dark-blue, - .swagger-ui .hover-blue:focus, - .swagger-ui .hover-blue:hover, - .swagger-ui .hover-dark-blue:focus, - .swagger-ui .hover-dark-blue:hover { - color: #99bae6; - } - - .swagger-ui .hover-light-blue:focus, - .swagger-ui .hover-light-blue:hover, - .swagger-ui .light-blue { - color: #a9cbea; - } - - .swagger-ui .hover-lightest-blue:focus, - .swagger-ui .hover-lightest-blue:hover, - .swagger-ui .lightest-blue { - color: #d6e9f5; - } - - .swagger-ui .hover-washed-blue:focus, - .swagger-ui .hover-washed-blue:hover, - .swagger-ui .washed-blue { - color: #f7fdfc; - } - - .swagger-ui .hover-washed-green:focus, - .swagger-ui .hover-washed-green:hover, - .swagger-ui .washed-green { - color: #ebfaf4; - } - - .swagger-ui .hover-washed-yellow:focus, - .swagger-ui .hover-washed-yellow:hover, - .swagger-ui .washed-yellow { - color: #fbf9ef; - } - - .swagger-ui .hover-washed-red:focus, - .swagger-ui .hover-washed-red:hover, - .swagger-ui .washed-red { - color: #f9e7e7; - } - - .swagger-ui .color-inherit, - .swagger-ui .hover-inherit:focus, - .swagger-ui .hover-inherit:hover { - color: inherit; - } - - .swagger-ui .bg-black-90, - .swagger-ui .hover-bg-black-90:focus, - .swagger-ui .hover-bg-black-90:hover { - background-color: rgba(0, 0, 0, 0.9); - } - - .swagger-ui .bg-black-80, - .swagger-ui .hover-bg-black-80:focus, - .swagger-ui .hover-bg-black-80:hover { - background-color: rgba(0, 0, 0, 0.8); - } - - .swagger-ui .bg-black-70, - .swagger-ui .hover-bg-black-70:focus, - .swagger-ui .hover-bg-black-70:hover { - background-color: rgba(0, 0, 0, 0.7); - } - - .swagger-ui .bg-black-60, - .swagger-ui .hover-bg-black-60:focus, - .swagger-ui .hover-bg-black-60:hover { - background-color: rgba(0, 0, 0, 0.6); - } - - .swagger-ui .bg-black-50, - .swagger-ui .hover-bg-black-50:focus, - .swagger-ui .hover-bg-black-50:hover { - background-color: rgba(0, 0, 0, 0.5); - } - - .swagger-ui .bg-black-40, - .swagger-ui .hover-bg-black-40:focus, - .swagger-ui .hover-bg-black-40:hover { - background-color: rgba(0, 0, 0, 0.4); - } - - .swagger-ui .bg-black-30, - .swagger-ui .hover-bg-black-30:focus, - .swagger-ui .hover-bg-black-30:hover { - background-color: rgba(0, 0, 0, 0.3); - } - - .swagger-ui .bg-black-20, - .swagger-ui .hover-bg-black-20:focus, - .swagger-ui .hover-bg-black-20:hover { - background-color: rgba(0, 0, 0, 0.2); - } - - .swagger-ui .bg-white-90, - .swagger-ui .hover-bg-white-90:focus, - .swagger-ui .hover-bg-white-90:hover { - background-color: rgba(28, 28, 33, 0.9); - } - - .swagger-ui .bg-white-80, - .swagger-ui .hover-bg-white-80:focus, - .swagger-ui .hover-bg-white-80:hover { - background-color: rgba(28, 28, 33, 0.8); - } - - .swagger-ui .bg-white-70, - .swagger-ui .hover-bg-white-70:focus, - .swagger-ui .hover-bg-white-70:hover { - background-color: rgba(28, 28, 33, 0.7); - } - - .swagger-ui .bg-white-60, - .swagger-ui .hover-bg-white-60:focus, - .swagger-ui .hover-bg-white-60:hover { - background-color: rgba(28, 28, 33, 0.6); - } - - .swagger-ui .bg-white-50, - .swagger-ui .hover-bg-white-50:focus, - .swagger-ui .hover-bg-white-50:hover { - background-color: rgba(28, 28, 33, 0.5); - } - - .swagger-ui .bg-white-40, - .swagger-ui .hover-bg-white-40:focus, - .swagger-ui .hover-bg-white-40:hover { - background-color: rgba(28, 28, 33, 0.4); - } - - .swagger-ui .bg-white-30, - .swagger-ui .hover-bg-white-30:focus, - .swagger-ui .hover-bg-white-30:hover { - background-color: rgba(28, 28, 33, 0.3); - } - - .swagger-ui .bg-white-20, - .swagger-ui .hover-bg-white-20:focus, - .swagger-ui .hover-bg-white-20:hover { - background-color: rgba(28, 28, 33, 0.2); - } - - .swagger-ui .bg-black, - .swagger-ui .hover-bg-black:focus, - .swagger-ui .hover-bg-black:hover { - background-color: #000; - } - - .swagger-ui .bg-near-black, - .swagger-ui .hover-bg-near-black:focus, - .swagger-ui .hover-bg-near-black:hover { - background-color: #121212; - } - - .swagger-ui .bg-dark-gray, - .swagger-ui .hover-bg-dark-gray:focus, - .swagger-ui .hover-bg-dark-gray:hover { - background-color: #333; - } - - .swagger-ui .bg-mid-gray, - .swagger-ui .hover-bg-mid-gray:focus, - .swagger-ui .hover-bg-mid-gray:hover { - background-color: #545454; - } - - .swagger-ui .bg-gray, - .swagger-ui .hover-bg-gray:focus, - .swagger-ui .hover-bg-gray:hover { - background-color: #787878; - } - - .swagger-ui .bg-silver, - .swagger-ui .hover-bg-silver:focus, - .swagger-ui .hover-bg-silver:hover { - background-color: #999; - } - - .swagger-ui .bg-white, - .swagger-ui .hover-bg-white:focus, - .swagger-ui .hover-bg-white:hover { - background-color: #1c1c21; - } - - .swagger-ui .bg-transparent, - .swagger-ui .hover-bg-transparent:focus, - .swagger-ui .hover-bg-transparent:hover { - background-color: transparent; - } - - .swagger-ui .bg-dark-red, - .swagger-ui .hover-bg-dark-red:focus, - .swagger-ui .hover-bg-dark-red:hover { - background-color: #bc2f36; - } - - .swagger-ui .bg-red, - .swagger-ui .hover-bg-red:focus, - .swagger-ui .hover-bg-red:hover { - background-color: #c83932; - } - - .swagger-ui .bg-light-red, - .swagger-ui .hover-bg-light-red:focus, - .swagger-ui .hover-bg-light-red:hover { - background-color: #ab3c2b; - } - - .swagger-ui .bg-orange, - .swagger-ui .hover-bg-orange:focus, - .swagger-ui .hover-bg-orange:hover { - background-color: #cc6e33; - } - - .swagger-ui .bg-gold, - .swagger-ui .bg-light-yellow, - .swagger-ui .bg-washed-yellow, - .swagger-ui .bg-yellow, - .swagger-ui .hover-bg-gold:focus, - .swagger-ui .hover-bg-gold:hover, - .swagger-ui .hover-bg-light-yellow:focus, - .swagger-ui .hover-bg-light-yellow:hover, - .swagger-ui .hover-bg-washed-yellow:focus, - .swagger-ui .hover-bg-washed-yellow:hover, - .swagger-ui .hover-bg-yellow:focus, - .swagger-ui .hover-bg-yellow:hover { - background-color: #664b00; - } - - .swagger-ui .bg-purple, - .swagger-ui .hover-bg-purple:focus, - .swagger-ui .hover-bg-purple:hover { - background-color: #5e2ca5; - } - - .swagger-ui .bg-light-purple, - .swagger-ui .hover-bg-light-purple:focus, - .swagger-ui .hover-bg-light-purple:hover { - background-color: #672caf; - } - - .swagger-ui .bg-dark-pink, - .swagger-ui .hover-bg-dark-pink:focus, - .swagger-ui .hover-bg-dark-pink:hover { - background-color: #ab2b81; - } - - .swagger-ui .bg-hot-pink, - .swagger-ui .hover-bg-hot-pink:focus, - .swagger-ui .hover-bg-hot-pink:hover { - background-color: #c03086; - } - - .swagger-ui .bg-pink, - .swagger-ui .hover-bg-pink:focus, - .swagger-ui .hover-bg-pink:hover { - background-color: #8f2464; - } - - .swagger-ui .bg-light-pink, - .swagger-ui .hover-bg-light-pink:focus, - .swagger-ui .hover-bg-light-pink:hover { - background-color: #721d4d; - } - - .swagger-ui .bg-dark-green, - .swagger-ui .hover-bg-dark-green:focus, - .swagger-ui .hover-bg-dark-green:hover { - background-color: #1c6e50; - } - - .swagger-ui .bg-green, - .swagger-ui .hover-bg-green:focus, - .swagger-ui .hover-bg-green:hover { - background-color: #279b70; - } - - .swagger-ui .bg-light-green, - .swagger-ui .hover-bg-light-green:focus, - .swagger-ui .hover-bg-light-green:hover { - background-color: #228762; - } - - .swagger-ui .bg-navy, - .swagger-ui .hover-bg-navy:focus, - .swagger-ui .hover-bg-navy:hover { - background-color: #0d1d35; - } - - .swagger-ui .bg-dark-blue, - .swagger-ui .hover-bg-dark-blue:focus, - .swagger-ui .hover-bg-dark-blue:hover { - background-color: #20497e; - } - - .swagger-ui .bg-blue, - .swagger-ui .hover-bg-blue:focus, - .swagger-ui .hover-bg-blue:hover { - background-color: #4380d0; - } - - .swagger-ui .bg-light-blue, - .swagger-ui .hover-bg-light-blue:focus, - .swagger-ui .hover-bg-light-blue:hover { - background-color: #20517e; - } - - .swagger-ui .bg-lightest-blue, - .swagger-ui .hover-bg-lightest-blue:focus, - .swagger-ui .hover-bg-lightest-blue:hover { - background-color: #143a52; - } - - .swagger-ui .bg-washed-blue, - .swagger-ui .hover-bg-washed-blue:focus, - .swagger-ui .hover-bg-washed-blue:hover { - background-color: #0c312d; - } - - .swagger-ui .bg-washed-green, - .swagger-ui .hover-bg-washed-green:focus, - .swagger-ui .hover-bg-washed-green:hover { - background-color: #0f3d2c; - } - - .swagger-ui .bg-washed-red, - .swagger-ui .hover-bg-washed-red:focus, - .swagger-ui .hover-bg-washed-red:hover { - background-color: #411010; - } - - .swagger-ui .bg-inherit, - .swagger-ui .hover-bg-inherit:focus, - .swagger-ui .hover-bg-inherit:hover { - background-color: inherit; - } - - .swagger-ui .shadow-hover { - transition: all 0.5s cubic-bezier(0.165, 0.84, 0.44, 1) 0s; - } - - .swagger-ui .shadow-hover::after { - border-radius: inherit; - box-shadow: rgba(0, 0, 0, 0.2) 0 0 16px 2px; - content: ""; - height: 100%; - left: 0; - opacity: 0; - position: absolute; - top: 0; - transition: opacity 0.5s cubic-bezier(0.165, 0.84, 0.44, 1) 0s; - width: 100%; - z-index: -1; - } - - .swagger-ui .bg-animate, - .swagger-ui .bg-animate:focus, - .swagger-ui .bg-animate:hover { - transition: background-color 0.15s ease-in-out 0s; - } - - .swagger-ui .nested-links a { - color: #99bae6; - transition: color 0.15s ease-in 0s; - } - - .swagger-ui .nested-links a:focus, - .swagger-ui .nested-links a:hover { - color: #a9cbea; - transition: color 0.15s ease-in 0s; - } - - .swagger-ui .opblock-tag { - border-bottom: 1px solid rgba(58, 64, 80, 0.3); - color: #b5bac9; - transition: all 0.2s ease 0s; - } - - .swagger-ui .opblock-tag svg, - .swagger-ui section.models h4 svg { - transition: all 0.4s ease 0s; - } - - .swagger-ui .opblock { - border: 1px solid #000; - border-radius: 4px; - box-shadow: rgba(0, 0, 0, 0.19) 0 0 3px; - margin: 0 0 15px; - } - - .swagger-ui .opblock .tab-header .tab-item.active h4 span::after { - background: gray; - } - - .swagger-ui .opblock.is-open .opblock-summary { - border-bottom: 1px solid #000; - } - - .swagger-ui .opblock .opblock-section-header { - background: rgba(28, 28, 33, 0.8); - box-shadow: rgba(0, 0, 0, 0.1) 0 1px 2px; - } - - .swagger-ui .opblock .opblock-section-header > label > span { - padding: 0 10px 0 0; - } - - .swagger-ui .opblock .opblock-summary-method { - background: #000; - color: #fff; - text-shadow: rgba(0, 0, 0, 0.1) 0 1px 0; - } - - .swagger-ui .opblock.opblock-post { - background: rgba(72, 203, 144, 0.1); - border-color: #48cb90; - } - - .swagger-ui .opblock.opblock-post .opblock-summary-method, - .swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span::after { - background: #48cb90; - } - - .swagger-ui .opblock.opblock-post .opblock-summary { - border-color: #48cb90; - } - - .swagger-ui .opblock.opblock-put { - background: rgba(213, 157, 88, 0.1); - border-color: #d59d58; - } - - .swagger-ui .opblock.opblock-put .opblock-summary-method, - .swagger-ui .opblock.opblock-put .tab-header .tab-item.active h4 span::after { - background: #d59d58; - } - - .swagger-ui .opblock.opblock-put .opblock-summary { - border-color: #d59d58; - } - - .swagger-ui .opblock.opblock-delete { - background: rgba(200, 50, 50, 0.1); - border-color: #c83232; - } - - .swagger-ui .opblock.opblock-delete .opblock-summary-method, - .swagger-ui .opblock.opblock-delete .tab-header .tab-item.active h4 span::after { - background: #c83232; - } - - .swagger-ui .opblock.opblock-delete .opblock-summary { - border-color: #c83232; - } - - .swagger-ui .opblock.opblock-get { - background: rgba(42, 105, 167, 0.1); - border-color: #2a69a7; - } - - .swagger-ui .opblock.opblock-get .opblock-summary-method, - .swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span::after { - background: #2a69a7; - } - - .swagger-ui .opblock.opblock-get .opblock-summary { - border-color: #2a69a7; - } - - .swagger-ui .opblock.opblock-patch { - background: rgba(92, 214, 188, 0.1); - border-color: #5cd6bc; - } - - .swagger-ui .opblock.opblock-patch .opblock-summary-method, - .swagger-ui .opblock.opblock-patch .tab-header .tab-item.active h4 span::after { - background: #5cd6bc; - } - - .swagger-ui .opblock.opblock-patch .opblock-summary { - border-color: #5cd6bc; - } - - .swagger-ui .opblock.opblock-head { - background: rgba(140, 63, 207, 0.1); - border-color: #8c3fcf; - } - - .swagger-ui .opblock.opblock-head .opblock-summary-method, - .swagger-ui .opblock.opblock-head .tab-header .tab-item.active h4 span::after { - background: #8c3fcf; - } - - .swagger-ui .opblock.opblock-head .opblock-summary { - border-color: #8c3fcf; - } - - .swagger-ui .opblock.opblock-options { - background: rgba(36, 89, 143, 0.1); - border-color: #24598f; - } - - .swagger-ui .opblock.opblock-options .opblock-summary-method, - .swagger-ui .opblock.opblock-options .tab-header .tab-item.active h4 span::after { - background: #24598f; - } - - .swagger-ui .opblock.opblock-options .opblock-summary { - border-color: #24598f; - } - - .swagger-ui .opblock.opblock-deprecated { - background: rgba(46, 46, 46, 0.1); - border-color: #2e2e2e; - opacity: 0.6; - } - - .swagger-ui .opblock.opblock-deprecated .opblock-summary-method, - .swagger-ui .opblock.opblock-deprecated .tab-header .tab-item.active h4 span::after { - background: #2e2e2e; - } - - .swagger-ui .opblock.opblock-deprecated .opblock-summary { - border-color: #2e2e2e; - } - - .swagger-ui .filter .operation-filter-input { - border: 2px solid #2b3446; - } - - .swagger-ui .tab li:first-of-type::after { - background: rgba(0, 0, 0, 0.2); - } - - .swagger-ui .download-contents { - background: #7c8192; - color: #fff; - } - - .swagger-ui .scheme-container { - background: #1c1c21; - box-shadow: rgba(0, 0, 0, 0.15) 0 1px 2px 0; - } - - .swagger-ui .loading-container .loading::before { - animation: - 1s linear 0s infinite normal none running rotation, - 0.5s ease 0s 1 normal none running opacity; - border-color: rgba(0, 0, 0, 0.6) rgba(84, 84, 84, 0.1) rgba(84, 84, 84, 0.1); - } - - .swagger-ui .response-control-media-type--accept-controller select { - border-color: #196619; - } - - .swagger-ui .response-control-media-type__accept-message { - color: #99e699; - } - - .swagger-ui .version-pragma__message code { - background-color: #3b3b3b; - } - - .swagger-ui .btn { - background: 0 0; - border: 2px solid gray; - box-shadow: rgba(0, 0, 0, 0.1) 0 1px 2px; - color: #b5bac9; - } - - .swagger-ui .btn:hover { - box-shadow: rgba(0, 0, 0, 0.3) 0 0 5px; - } - - .swagger-ui .btn.authorize, - .swagger-ui .btn.cancel { - background-color: transparent; - border-color: #a72a2a; - color: #e69999; - } - - .swagger-ui .btn.cancel:hover { - background-color: #a72a2a; - color: #fff; - } - - .swagger-ui .btn.authorize { - border-color: #48cb90; - color: #9ce3c3; - } - - .swagger-ui .btn.authorize svg { - fill: #9ce3c3; - } - - .btn.authorize.unlocked:hover { - background-color: #48cb90; - color: #fff; - } - - .btn.authorize.unlocked:hover svg { - fill: #fbfbfb; - } - - .swagger-ui .btn.execute { - background-color: #5892d5; - border-color: #5892d5; - color: #fff; - } - - .swagger-ui .copy-to-clipboard { - background: #7c8192; - } - - .swagger-ui .copy-to-clipboard button { - background: url('data:image/svg+xml;charset=utf-8,') - 50% center no-repeat; - } - - .swagger-ui select { - background: url('data:image/svg+xml;charset=utf-8,') - right 10px center/20px no-repeat #212121; - background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM6c29kaXBvZGk9Imh0dHA6Ly9zb2RpcG9kaS5zb3VyY2Vmb3JnZS5uZXQvRFREL3NvZGlwb2RpLTAuZHRkIgogICB4bWxuczppbmtzY2FwZT0iaHR0cDovL3d3dy5pbmtzY2FwZS5vcmcvbmFtZXNwYWNlcy9pbmtzY2FwZSIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMS4wICg0MDM1YTRmYjQ5LCAyMDIwLTA1LTAxKSIKICAgc29kaXBvZGk6ZG9jbmFtZT0iZG93bmxvYWQuc3ZnIgogICBpZD0ic3ZnNCIKICAgdmVyc2lvbj0iMS4xIgogICB2aWV3Qm94PSIwIDAgMjAgMjAiPgogIDxtZXRhZGF0YQogICAgIGlkPSJtZXRhZGF0YTEwIj4KICAgIDxyZGY6UkRGPgogICAgICA8Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+CiAgICAgICAgPGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+CiAgICAgICAgPGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPgogICAgICA8L2NjOldvcms+CiAgICA8L3JkZjpSREY+CiAgPC9tZXRhZGF0YT4KICA8ZGVmcwogICAgIGlkPSJkZWZzOCIgLz4KICA8c29kaXBvZGk6bmFtZWR2aWV3CiAgICAgaW5rc2NhcGU6Y3VycmVudC1sYXllcj0ic3ZnNCIKICAgICBpbmtzY2FwZTp3aW5kb3ctbWF4aW1pemVkPSIxIgogICAgIGlua3NjYXBlOndpbmRvdy15PSItOSIKICAgICBpbmtzY2FwZTp3aW5kb3cteD0iLTkiCiAgICAgaW5rc2NhcGU6Y3k9IjEwIgogICAgIGlua3NjYXBlOmN4PSIxMCIKICAgICBpbmtzY2FwZTp6b29tPSI0MS41IgogICAgIHNob3dncmlkPSJmYWxzZSIKICAgICBpZD0ibmFtZWR2aWV3NiIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMDAxIgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkyMCIKICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOnBhZ2VvcGFjaXR5PSIwIgogICAgIGd1aWRldG9sZXJhbmNlPSIxMCIKICAgICBncmlkdG9sZXJhbmNlPSIxMCIKICAgICBvYmplY3R0b2xlcmFuY2U9IjEwIgogICAgIGJvcmRlcm9wYWNpdHk9IjEiCiAgICAgYm9yZGVyY29sb3I9IiM2NjY2NjYiCiAgICAgcGFnZWNvbG9yPSIjZmZmZmZmIiAvPgogIDxwYXRoCiAgICAgc3R5bGU9ImZpbGw6I2ZmZmZmZiIKICAgICBpZD0icGF0aDIiCiAgICAgZD0iTTEzLjQxOCA3Ljg1OWEuNjk1LjY5NSAwIDAxLjk3OCAwIC42OC42OCAwIDAxMCAuOTY5bC0zLjkwOCAzLjgzYS42OTcuNjk3IDAgMDEtLjk3OSAwbC0zLjkwOC0zLjgzYS42OC42OCAwIDAxMC0uOTY5LjY5NS42OTUgMCAwMS45NzggMEwxMCAxMWwzLjQxOC0zLjE0MXoiIC8+Cjwvc3ZnPgo=) - right 10px center/20px no-repeat #1c1c21; - border: 2px solid #41444e; - } - - .swagger-ui select[multiple] { - background: #212121; - } - - .swagger-ui button.invalid, - .swagger-ui input[type="email"].invalid, - .swagger-ui input[type="file"].invalid, - .swagger-ui input[type="password"].invalid, - .swagger-ui input[type="search"].invalid, - .swagger-ui input[type="text"].invalid, - .swagger-ui select.invalid, - .swagger-ui textarea.invalid { - background: #390e0e; - border-color: #c83232; - } - - .swagger-ui input[type="email"], - .swagger-ui input[type="file"], - .swagger-ui input[type="password"], - .swagger-ui input[type="search"], - .swagger-ui input[type="text"], - .swagger-ui textarea { - background: #1c1c21; - border: 1px solid #404040; - } - - .swagger-ui textarea { - background: rgba(28, 28, 33, 0.8); - color: #b5bac9; - } - - .swagger-ui input[disabled], - .swagger-ui select[disabled] { - background-color: #1f1f1f; - color: #bfbfbf; - } - - .swagger-ui textarea[disabled] { - background-color: #41444e; - color: #fff; - } - - .swagger-ui select[disabled] { - border-color: #878787; - } - - .swagger-ui textarea:focus { - border: 2px solid #2a69a7; - } - - .swagger-ui .checkbox input[type="checkbox"] + label > .item { - background: #303030; - box-shadow: #303030 0 0 0 2px; - } - - .swagger-ui .checkbox input[type="checkbox"]:checked + label > .item { - background: url('data:image/svg+xml;charset=utf-8,') - 50% center no-repeat #303030; - } - - .swagger-ui .dialog-ux .backdrop-ux { - background: rgba(0, 0, 0, 0.8); - } - - .swagger-ui .dialog-ux .modal-ux { - background: #1c1c21; - border: 1px solid #2e2e2e; - box-shadow: rgba(0, 0, 0, 0.2) 0 10px 30px 0; - } - - .swagger-ui .dialog-ux .modal-ux-header .close-modal { - background: 0 0; - } - - .swagger-ui .model .deprecated span, - .swagger-ui .model .deprecated td { - color: #bfbfbf !important; - } - - .swagger-ui .model-toggle::after { - background: url('data:image/svg+xml;charset=utf-8,') - 50% center/100% no-repeat; - } - - .swagger-ui .model-hint { - background: rgba(0, 0, 0, 0.7); - color: #ebebeb; - } - - .swagger-ui section.models { - border: 1px solid rgba(58, 64, 80, 0.3); - } - - .swagger-ui section.models.is-open h4 { - border-bottom: 1px solid rgba(58, 64, 80, 0.3); - } - - .swagger-ui section.models .model-container { - background: rgba(0, 0, 0, 0.05); - } - - .swagger-ui section.models .model-container:hover { - background: rgba(0, 0, 0, 0.07); - } - - .swagger-ui .model-box { - background: rgba(0, 0, 0, 0.1); - } - - .swagger-ui .prop-type { - color: #aaaad4; - } - - .swagger-ui table thead tr td, - .swagger-ui table thead tr th { - border-bottom: 1px solid rgba(58, 64, 80, 0.2); - color: #b5bac9; - } - - .swagger-ui .parameter__name.required::after { - color: rgba(230, 153, 153, 0.6); - } - - .swagger-ui .topbar .download-url-wrapper .select-label { - color: #f0f0f0; - } - - .swagger-ui .topbar .download-url-wrapper .download-url-button { - background: #63a040; - color: #fff; - } - - .swagger-ui .info .title small { - background: #7c8492; - } - - .swagger-ui .info .title small.version-stamp { - background-color: #7a9b27; - } - - .swagger-ui .auth-container .errors { - background-color: #350d0d; - color: #b5bac9; - } - - .swagger-ui .errors-wrapper { - background: rgba(200, 50, 50, 0.1); - border: 2px solid #c83232; - } - - .swagger-ui .markdown code, - .swagger-ui .renderedmarkdown code { - background: rgba(0, 0, 0, 0.05); - color: #c299e6; - } - - .swagger-ui .model-toggle:after { - background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM6c29kaXBvZGk9Imh0dHA6Ly9zb2RpcG9kaS5zb3VyY2Vmb3JnZS5uZXQvRFREL3NvZGlwb2RpLTAuZHRkIgogICB4bWxuczppbmtzY2FwZT0iaHR0cDovL3d3dy5pbmtzY2FwZS5vcmcvbmFtZXNwYWNlcy9pbmtzY2FwZSIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMS4wICg0MDM1YTRmYjQ5LCAyMDIwLTA1LTAxKSIKICAgc29kaXBvZGk6ZG9jbmFtZT0iZG93bmxvYWQyLnN2ZyIKICAgaWQ9InN2ZzQiCiAgIHZlcnNpb249IjEuMSIKICAgaGVpZ2h0PSIyNCIKICAgd2lkdGg9IjI0Ij4KICA8bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGExMCI+CiAgICA8cmRmOlJERj4KICAgICAgPGNjOldvcmsKICAgICAgICAgcmRmOmFib3V0PSIiPgogICAgICAgIDxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0PgogICAgICAgIDxkYzp0eXBlCiAgICAgICAgICAgcmRmOnJlc291cmNlPSJodHRwOi8vcHVybC5vcmcvZGMvZGNtaXR5cGUvU3RpbGxJbWFnZSIgLz4KICAgICAgPC9jYzpXb3JrPgogICAgPC9yZGY6UkRGPgogIDwvbWV0YWRhdGE+CiAgPGRlZnMKICAgICBpZD0iZGVmczgiIC8+CiAgPHNvZGlwb2RpOm5hbWVkdmlldwogICAgIGlua3NjYXBlOmN1cnJlbnQtbGF5ZXI9InN2ZzQiCiAgICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMSIKICAgICBpbmtzY2FwZTp3aW5kb3cteT0iLTkiCiAgICAgaW5rc2NhcGU6d2luZG93LXg9Ii05IgogICAgIGlua3NjYXBlOmN5PSIxMiIKICAgICBpbmtzY2FwZTpjeD0iMTIiCiAgICAgaW5rc2NhcGU6em9vbT0iMzQuNTgzMzMzIgogICAgIHNob3dncmlkPSJmYWxzZSIKICAgICBpZD0ibmFtZWR2aWV3NiIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMDAxIgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkyMCIKICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOnBhZ2VvcGFjaXR5PSIwIgogICAgIGd1aWRldG9sZXJhbmNlPSIxMCIKICAgICBncmlkdG9sZXJhbmNlPSIxMCIKICAgICBvYmplY3R0b2xlcmFuY2U9IjEwIgogICAgIGJvcmRlcm9wYWNpdHk9IjEiCiAgICAgYm9yZGVyY29sb3I9IiM2NjY2NjYiCiAgICAgcGFnZWNvbG9yPSIjZmZmZmZmIiAvPgogIDxwYXRoCiAgICAgc3R5bGU9ImZpbGw6I2ZmZmZmZiIKICAgICBpZD0icGF0aDIiCiAgICAgZD0iTTEwIDZMOC41OSA3LjQxIDEzLjE3IDEybC00LjU4IDQuNTlMMTAgMThsNi02eiIgLz4KPC9zdmc+Cg==) - 50% no-repeat; - } - - /* arrows for each operation and request are now white */ - .arrow, - #large-arrow-up { - fill: #fff; - } - - #unlocked { - fill: #fff; - } - - ::-webkit-scrollbar-track { - background-color: #646464 !important; - } - - ::-webkit-scrollbar-thumb { - background-color: #242424 !important; - border: 2px solid #3e4346 !important; - } - - ::-webkit-scrollbar-button:vertical:start:decrement { - background: - linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%), - linear-gradient(230deg, #696969 40%, transparent 41%), linear-gradient(0deg, #696969 40%, transparent 31%); - background-color: #b6b6b6; - } - - ::-webkit-scrollbar-button:vertical:end:increment { - background: - linear-gradient(310deg, #696969 40%, transparent 41%), linear-gradient(50deg, #696969 40%, transparent 41%), - linear-gradient(180deg, #696969 40%, transparent 31%); - background-color: #b6b6b6; - } - - ::-webkit-scrollbar-button:horizontal:end:increment { - background: - linear-gradient(210deg, #696969 40%, transparent 41%), linear-gradient(330deg, #696969 40%, transparent 41%), - linear-gradient(90deg, #696969 30%, transparent 31%); - background-color: #b6b6b6; - } - - ::-webkit-scrollbar-button:horizontal:start:decrement { - background: - linear-gradient(30deg, #696969 40%, transparent 41%), linear-gradient(150deg, #696969 40%, transparent 41%), - linear-gradient(270deg, #696969 30%, transparent 31%); - background-color: #b6b6b6; - } - - ::-webkit-scrollbar-button, - ::-webkit-scrollbar-track-piece { - background-color: #3e4346 !important; - } - - .swagger-ui .black, - .swagger-ui .checkbox, - .swagger-ui .dark-gray, - .swagger-ui .download-url-wrapper .loading, - .swagger-ui .errors-wrapper .errors small, - .swagger-ui .fallback, - .swagger-ui .filter .loading, - .swagger-ui .gray, - .swagger-ui .hover-black:focus, - .swagger-ui .hover-black:hover, - .swagger-ui .hover-dark-gray:focus, - .swagger-ui .hover-dark-gray:hover, - .swagger-ui .hover-gray:focus, - .swagger-ui .hover-gray:hover, - .swagger-ui .hover-light-silver:focus, - .swagger-ui .hover-light-silver:hover, - .swagger-ui .hover-mid-gray:focus, - .swagger-ui .hover-mid-gray:hover, - .swagger-ui .hover-near-black:focus, - .swagger-ui .hover-near-black:hover, - .swagger-ui .hover-silver:focus, - .swagger-ui .hover-silver:hover, - .swagger-ui .light-silver, - .swagger-ui .markdown pre, - .swagger-ui .mid-gray, - .swagger-ui .model .property, - .swagger-ui .model .property.primitive, - .swagger-ui .model-title, - .swagger-ui .near-black, - .swagger-ui .parameter__extension, - .swagger-ui .parameter__in, - .swagger-ui .prop-format, - .swagger-ui .renderedmarkdown pre, - .swagger-ui .response-col_links .response-undocumented, - .swagger-ui .response-col_status .response-undocumented, - .swagger-ui .silver, - .swagger-ui section.models h4, - .swagger-ui section.models h5, - .swagger-ui span.token-not-formatted, - .swagger-ui span.token-string, - .swagger-ui table.headers .header-example, - .swagger-ui table.model tr.description, - .swagger-ui table.model tr.extension { - color: #bfbfbf; - } - - .swagger-ui .hover-white:focus, - .swagger-ui .hover-white:hover, - .swagger-ui .info .title small pre, - .swagger-ui .topbar a, - .swagger-ui .white { - color: #fff; - } - - .swagger-ui .bg-black-10, - .swagger-ui .hover-bg-black-10:focus, - .swagger-ui .hover-bg-black-10:hover, - .swagger-ui .stripe-dark:nth-child(2n + 1) { - background-color: rgba(0, 0, 0, 0.1); - } - - .swagger-ui .bg-white-10, - .swagger-ui .hover-bg-white-10:focus, - .swagger-ui .hover-bg-white-10:hover, - .swagger-ui .stripe-light:nth-child(2n + 1) { - background-color: rgba(28, 28, 33, 0.1); - } - - .swagger-ui .bg-light-silver, - .swagger-ui .hover-bg-light-silver:focus, - .swagger-ui .hover-bg-light-silver:hover, - .swagger-ui .striped--light-silver:nth-child(2n + 1) { - background-color: #6e6e6e; - } - - .swagger-ui .bg-moon-gray, - .swagger-ui .hover-bg-moon-gray:focus, - .swagger-ui .hover-bg-moon-gray:hover, - .swagger-ui .striped--moon-gray:nth-child(2n + 1) { - background-color: #4d4d4d; - } - - .swagger-ui .bg-light-gray, - .swagger-ui .hover-bg-light-gray:focus, - .swagger-ui .hover-bg-light-gray:hover, - .swagger-ui .striped--light-gray:nth-child(2n + 1) { - background-color: #2b2b2b; - } - - .swagger-ui .bg-near-white, - .swagger-ui .hover-bg-near-white:focus, - .swagger-ui .hover-bg-near-white:hover, - .swagger-ui .striped--near-white:nth-child(2n + 1) { - background-color: #242424; - } - - .swagger-ui .opblock-tag:hover, - .swagger-ui section.models h4:hover { - background: rgba(0, 0, 0, 0.02); - } - - .swagger-ui .checkbox p, - .swagger-ui .dialog-ux .modal-ux-content h4, - .swagger-ui .dialog-ux .modal-ux-content p, - .swagger-ui .dialog-ux .modal-ux-header h3, - .swagger-ui .errors-wrapper .errors h4, - .swagger-ui .errors-wrapper hgroup h4, - .swagger-ui .info .base-url, - .swagger-ui .info .title, - .swagger-ui .info h1, - .swagger-ui .info h2, - .swagger-ui .info h3, - .swagger-ui .info h4, - .swagger-ui .info h5, - .swagger-ui .info li, - .swagger-ui .info p, - .swagger-ui .info table, - .swagger-ui .loading-container .loading::after, - .swagger-ui .model, - .swagger-ui .opblock .opblock-section-header h4, - .swagger-ui .opblock .opblock-section-header > label, - .swagger-ui .opblock .opblock-summary-description, - .swagger-ui .opblock .opblock-summary-operation-id, - .swagger-ui .opblock .opblock-summary-path, - .swagger-ui .opblock .opblock-summary-path__deprecated, - .swagger-ui .opblock-description-wrapper, - .swagger-ui .opblock-description-wrapper h4, - .swagger-ui .opblock-description-wrapper p, - .swagger-ui .opblock-external-docs-wrapper, - .swagger-ui .opblock-external-docs-wrapper h4, - .swagger-ui .opblock-external-docs-wrapper p, - .swagger-ui .opblock-tag small, - .swagger-ui .opblock-title_normal, - .swagger-ui .opblock-title_normal h4, - .swagger-ui .opblock-title_normal p, - .swagger-ui .parameter__name, - .swagger-ui .parameter__type, - .swagger-ui .response-col_links, - .swagger-ui .response-col_status, - .swagger-ui .responses-inner h4, - .swagger-ui .responses-inner h5, - .swagger-ui .scheme-container .schemes > label, - .swagger-ui .scopes h2, - .swagger-ui .servers > label, - .swagger-ui .tab li, - .swagger-ui label, - .swagger-ui select, - .swagger-ui table.headers td { - color: #b5bac9; - } - - .swagger-ui .download-url-wrapper .failed, - .swagger-ui .filter .failed, - .swagger-ui .model-deprecated-warning, - .swagger-ui .parameter__deprecated, - .swagger-ui .parameter__name.required span, - .swagger-ui table.model tr.property-row .star { - color: #e69999; - } - - .swagger-ui .opblock-body pre.microlight, - .swagger-ui textarea.curl { - background: #41444e; - border-radius: 4px; - color: #fff; - } - - .swagger-ui .expand-methods svg, - .swagger-ui .expand-methods:hover svg { - fill: #bfbfbf; - } - - .swagger-ui .auth-container, - .swagger-ui .dialog-ux .modal-ux-header { - border-bottom: 1px solid #2e2e2e; - } - - .swagger-ui .topbar .download-url-wrapper .select-label select, - .swagger-ui .topbar .download-url-wrapper input[type="text"] { - border: 2px solid #63a040; - } - - .swagger-ui .info a, - .swagger-ui .info a:hover, - .swagger-ui .scopes h2 a { - color: #99bde6; - } - - /* Dark Scrollbar */ - ::-webkit-scrollbar { - width: 14px; - height: 14px; - } - - ::-webkit-scrollbar-button { - background-color: #3e4346 !important; - } - - ::-webkit-scrollbar-track { - background-color: #646464 !important; - } - - ::-webkit-scrollbar-track-piece { - background-color: #3e4346 !important; - } - - ::-webkit-scrollbar-thumb { - height: 50px; - background-color: #242424 !important; - border: 2px solid #3e4346 !important; - } - - ::-webkit-scrollbar-corner { - } - - ::-webkit-resizer { - } - - ::-webkit-scrollbar-button:vertical:start:decrement { - background: - linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%), - linear-gradient(230deg, #696969 40%, rgba(0, 0, 0, 0) 41%), - linear-gradient(0deg, #696969 40%, rgba(0, 0, 0, 0) 31%); - background-color: #b6b6b6; - } - - ::-webkit-scrollbar-button:vertical:end:increment { - background: - linear-gradient(310deg, #696969 40%, rgba(0, 0, 0, 0) 41%), - linear-gradient(50deg, #696969 40%, rgba(0, 0, 0, 0) 41%), - linear-gradient(180deg, #696969 40%, rgba(0, 0, 0, 0) 31%); - background-color: #b6b6b6; - } - - ::-webkit-scrollbar-button:horizontal:end:increment { - background: - linear-gradient(210deg, #696969 40%, rgba(0, 0, 0, 0) 41%), - linear-gradient(330deg, #696969 40%, rgba(0, 0, 0, 0) 41%), - linear-gradient(90deg, #696969 30%, rgba(0, 0, 0, 0) 31%); - background-color: #b6b6b6; - } - - ::-webkit-scrollbar-button:horizontal:start:decrement { - background: - linear-gradient(30deg, #696969 40%, rgba(0, 0, 0, 0) 41%), - linear-gradient(150deg, #696969 40%, rgba(0, 0, 0, 0) 41%), - linear-gradient(270deg, #696969 30%, rgba(0, 0, 0, 0) 31%); - background-color: #b6b6b6; - } - } -} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/swagger-ui-overrides.css b/apps/nextjs/src/app/[locale]/manage/tools/api/swagger-ui-overrides.css deleted file mode 100644 index 74a997407..000000000 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/swagger-ui-overrides.css +++ /dev/null @@ -1,4 +0,0 @@ -.swagger-ui .info { - margin: 0 !important; - margin-bottom: 20px !important; -} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/swagger-ui.css b/apps/nextjs/src/app/[locale]/manage/tools/api/swagger-ui.css deleted file mode 100644 index e02e30307..000000000 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/swagger-ui.css +++ /dev/null @@ -1,9293 +0,0 @@ -.swagger-ui { - color: #3b4151; - font-family: sans-serif; /*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */ -} -.swagger-ui html { - line-height: 1.15; - -ms-text-size-adjust: 100%; - -webkit-text-size-adjust: 100%; -} -.swagger-ui body { - margin: 0; -} -.swagger-ui article, -.swagger-ui aside, -.swagger-ui footer, -.swagger-ui header, -.swagger-ui nav, -.swagger-ui section { - display: block; -} -.swagger-ui h1 { - font-size: 2em; - margin: 0.67em 0; -} -.swagger-ui figcaption, -.swagger-ui figure, -.swagger-ui main { - display: block; -} -.swagger-ui figure { - margin: 1em 40px; -} -.swagger-ui hr { - box-sizing: content-box; - height: 0; - overflow: visible; -} -.swagger-ui pre { - font-family: monospace, monospace; - font-size: 1em; -} -.swagger-ui a { - background-color: transparent; - -webkit-text-decoration-skip: objects; -} -.swagger-ui abbr[title] { - border-bottom: none; - text-decoration: underline; - -webkit-text-decoration: underline dotted; - text-decoration: underline dotted; -} -.swagger-ui b, -.swagger-ui strong { - font-weight: inherit; - font-weight: bolder; -} -.swagger-ui code, -.swagger-ui kbd, -.swagger-ui samp { - font-family: monospace, monospace; - font-size: 1em; -} -.swagger-ui dfn { - font-style: italic; -} -.swagger-ui mark { - background-color: #ff0; - color: #000; -} -.swagger-ui small { - font-size: 80%; -} -.swagger-ui sub, -.swagger-ui sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; -} -.swagger-ui sub { - bottom: -0.25em; -} -.swagger-ui sup { - top: -0.5em; -} -.swagger-ui audio, -.swagger-ui video { - display: inline-block; -} -.swagger-ui audio:not([controls]) { - display: none; - height: 0; -} -.swagger-ui img { - border-style: none; -} -.swagger-ui svg:not(:root) { - overflow: hidden; -} -.swagger-ui button, -.swagger-ui input, -.swagger-ui optgroup, -.swagger-ui select, -.swagger-ui textarea { - font-family: sans-serif; - font-size: 100%; - line-height: 1.15; - margin: 0; -} -.swagger-ui button, -.swagger-ui input { - overflow: visible; -} -.swagger-ui button, -.swagger-ui select { - text-transform: none; -} -.swagger-ui [type="reset"], -.swagger-ui [type="submit"], -.swagger-ui button, -.swagger-ui html [type="button"] { - -webkit-appearance: button; -} -.swagger-ui [type="button"]::-moz-focus-inner, -.swagger-ui [type="reset"]::-moz-focus-inner, -.swagger-ui [type="submit"]::-moz-focus-inner, -.swagger-ui button::-moz-focus-inner { - border-style: none; - padding: 0; -} -.swagger-ui [type="button"]:-moz-focusring, -.swagger-ui [type="reset"]:-moz-focusring, -.swagger-ui [type="submit"]:-moz-focusring, -.swagger-ui button:-moz-focusring { - outline: 1px dotted ButtonText; -} -.swagger-ui fieldset { - padding: 0.35em 0.75em 0.625em; -} -.swagger-ui legend { - box-sizing: border-box; - color: inherit; - display: table; - max-width: 100%; - padding: 0; - white-space: normal; -} -.swagger-ui progress { - display: inline-block; - vertical-align: baseline; -} -.swagger-ui textarea { - overflow: auto; -} -.swagger-ui [type="checkbox"], -.swagger-ui [type="radio"] { - box-sizing: border-box; - padding: 0; -} -.swagger-ui [type="number"]::-webkit-inner-spin-button, -.swagger-ui [type="number"]::-webkit-outer-spin-button { - height: auto; -} -.swagger-ui [type="search"] { - -webkit-appearance: textfield; - outline-offset: -2px; -} -.swagger-ui [type="search"]::-webkit-search-cancel-button, -.swagger-ui [type="search"]::-webkit-search-decoration { - -webkit-appearance: none; -} -.swagger-ui ::-webkit-file-upload-button { - -webkit-appearance: button; - font: inherit; -} -.swagger-ui details, -.swagger-ui menu { - display: block; -} -.swagger-ui summary { - display: list-item; -} -.swagger-ui canvas { - display: inline-block; -} -.swagger-ui [hidden], -.swagger-ui template { - display: none; -} -.swagger-ui .debug * { - outline: 1px solid gold; -} -.swagger-ui .debug-white * { - outline: 1px solid #fff; -} -.swagger-ui .debug-black * { - outline: 1px solid #000; -} -.swagger-ui .debug-grid { - background: transparent - url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTRDOTY4N0U2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTRDOTY4N0Q2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3NjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3NzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PsBS+GMAAAAjSURBVHjaYvz//z8DLsD4gcGXiYEAGBIKGBne//fFpwAgwAB98AaF2pjlUQAAAABJRU5ErkJggg==) - repeat 0 0; -} -.swagger-ui .debug-grid-16 { - background: transparent - url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6ODYyRjhERDU2N0YyMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6ODYyRjhERDQ2N0YyMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3QTY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3QjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PvCS01IAAABMSURBVHjaYmR4/5+BFPBfAMFm/MBgx8RAGWCn1AAmSg34Q6kBDKMGMDCwICeMIemF/5QawEipAWwUhwEjMDvbAWlWkvVBwu8vQIABAEwBCph8U6c0AAAAAElFTkSuQmCC) - repeat 0 0; -} -.swagger-ui .debug-grid-8-solid { - background: #fff - url(data:image/jpeg;base64,/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/sABFEdWNreQABAAQAAAAAAAD/4QMxaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjYtYzExMSA3OS4xNTgzMjUsIDIwMTUvMDkvMTAtMDE6MTA6MjAgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE1IChNYWNpbnRvc2gpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOkIxMjI0OTczNjdCMzExRTZCMkJDRTI0MDgxMDAyMTcxIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOkIxMjI0OTc0NjdCMzExRTZCMkJDRTI0MDgxMDAyMTcxIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6QjEyMjQ5NzE2N0IzMTFFNkIyQkNFMjQwODEwMDIxNzEiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6QjEyMjQ5NzI2N0IzMTFFNkIyQkNFMjQwODEwMDIxNzEiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7/7gAOQWRvYmUAZMAAAAAB/9sAhAAbGhopHSlBJiZBQi8vL0JHPz4+P0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHAR0pKTQmND8oKD9HPzU/R0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0f/wAARCAAIAAgDASIAAhEBAxEB/8QAWQABAQAAAAAAAAAAAAAAAAAAAAYBAQEAAAAAAAAAAAAAAAAAAAIEEAEBAAMBAAAAAAAAAAAAAAABADECA0ERAAEDBQAAAAAAAAAAAAAAAAARITFBUWESIv/aAAwDAQACEQMRAD8AoOnTV1QTD7JJshP3vSM3P//Z) - repeat 0 0; -} -.swagger-ui .debug-grid-16-solid { - background: #fff - url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NzY3MkJEN0U2N0M1MTFFNkIyQkNFMjQwODEwMDIxNzEiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NzY3MkJEN0Y2N0M1MTFFNkIyQkNFMjQwODEwMDIxNzEiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3QzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3RDY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pve6J3kAAAAzSURBVHjaYvz//z8D0UDsMwMjSRoYP5Gq4SPNbRjVMEQ1fCRDg+in/6+J1AJUxsgAEGAA31BAJMS0GYEAAAAASUVORK5CYII=) - repeat 0 0; -} -.swagger-ui .border-box, -.swagger-ui a, -.swagger-ui article, -.swagger-ui body, -.swagger-ui code, -.swagger-ui dd, -.swagger-ui div, -.swagger-ui dl, -.swagger-ui dt, -.swagger-ui fieldset, -.swagger-ui footer, -.swagger-ui form, -.swagger-ui h1, -.swagger-ui h2, -.swagger-ui h3, -.swagger-ui h4, -.swagger-ui h5, -.swagger-ui h6, -.swagger-ui header, -.swagger-ui html, -.swagger-ui input[type="email"], -.swagger-ui input[type="number"], -.swagger-ui input[type="password"], -.swagger-ui input[type="tel"], -.swagger-ui input[type="text"], -.swagger-ui input[type="url"], -.swagger-ui legend, -.swagger-ui li, -.swagger-ui main, -.swagger-ui ol, -.swagger-ui p, -.swagger-ui pre, -.swagger-ui section, -.swagger-ui table, -.swagger-ui td, -.swagger-ui textarea, -.swagger-ui th, -.swagger-ui tr, -.swagger-ui ul { - box-sizing: border-box; -} -.swagger-ui .aspect-ratio { - height: 0; - position: relative; -} -.swagger-ui .aspect-ratio--16x9 { - padding-bottom: 56.25%; -} -.swagger-ui .aspect-ratio--9x16 { - padding-bottom: 177.77%; -} -.swagger-ui .aspect-ratio--4x3 { - padding-bottom: 75%; -} -.swagger-ui .aspect-ratio--3x4 { - padding-bottom: 133.33%; -} -.swagger-ui .aspect-ratio--6x4 { - padding-bottom: 66.6%; -} -.swagger-ui .aspect-ratio--4x6 { - padding-bottom: 150%; -} -.swagger-ui .aspect-ratio--8x5 { - padding-bottom: 62.5%; -} -.swagger-ui .aspect-ratio--5x8 { - padding-bottom: 160%; -} -.swagger-ui .aspect-ratio--7x5 { - padding-bottom: 71.42%; -} -.swagger-ui .aspect-ratio--5x7 { - padding-bottom: 140%; -} -.swagger-ui .aspect-ratio--1x1 { - padding-bottom: 100%; -} -.swagger-ui .aspect-ratio--object { - bottom: 0; - height: 100%; - left: 0; - position: absolute; - right: 0; - top: 0; - width: 100%; - z-index: 100; -} -@media screen and (min-width: 30em) { - .swagger-ui .aspect-ratio-ns { - height: 0; - position: relative; - } - .swagger-ui .aspect-ratio--16x9-ns { - padding-bottom: 56.25%; - } - .swagger-ui .aspect-ratio--9x16-ns { - padding-bottom: 177.77%; - } - .swagger-ui .aspect-ratio--4x3-ns { - padding-bottom: 75%; - } - .swagger-ui .aspect-ratio--3x4-ns { - padding-bottom: 133.33%; - } - .swagger-ui .aspect-ratio--6x4-ns { - padding-bottom: 66.6%; - } - .swagger-ui .aspect-ratio--4x6-ns { - padding-bottom: 150%; - } - .swagger-ui .aspect-ratio--8x5-ns { - padding-bottom: 62.5%; - } - .swagger-ui .aspect-ratio--5x8-ns { - padding-bottom: 160%; - } - .swagger-ui .aspect-ratio--7x5-ns { - padding-bottom: 71.42%; - } - .swagger-ui .aspect-ratio--5x7-ns { - padding-bottom: 140%; - } - .swagger-ui .aspect-ratio--1x1-ns { - padding-bottom: 100%; - } - .swagger-ui .aspect-ratio--object-ns { - bottom: 0; - height: 100%; - left: 0; - position: absolute; - right: 0; - top: 0; - width: 100%; - z-index: 100; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .aspect-ratio-m { - height: 0; - position: relative; - } - .swagger-ui .aspect-ratio--16x9-m { - padding-bottom: 56.25%; - } - .swagger-ui .aspect-ratio--9x16-m { - padding-bottom: 177.77%; - } - .swagger-ui .aspect-ratio--4x3-m { - padding-bottom: 75%; - } - .swagger-ui .aspect-ratio--3x4-m { - padding-bottom: 133.33%; - } - .swagger-ui .aspect-ratio--6x4-m { - padding-bottom: 66.6%; - } - .swagger-ui .aspect-ratio--4x6-m { - padding-bottom: 150%; - } - .swagger-ui .aspect-ratio--8x5-m { - padding-bottom: 62.5%; - } - .swagger-ui .aspect-ratio--5x8-m { - padding-bottom: 160%; - } - .swagger-ui .aspect-ratio--7x5-m { - padding-bottom: 71.42%; - } - .swagger-ui .aspect-ratio--5x7-m { - padding-bottom: 140%; - } - .swagger-ui .aspect-ratio--1x1-m { - padding-bottom: 100%; - } - .swagger-ui .aspect-ratio--object-m { - bottom: 0; - height: 100%; - left: 0; - position: absolute; - right: 0; - top: 0; - width: 100%; - z-index: 100; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .aspect-ratio-l { - height: 0; - position: relative; - } - .swagger-ui .aspect-ratio--16x9-l { - padding-bottom: 56.25%; - } - .swagger-ui .aspect-ratio--9x16-l { - padding-bottom: 177.77%; - } - .swagger-ui .aspect-ratio--4x3-l { - padding-bottom: 75%; - } - .swagger-ui .aspect-ratio--3x4-l { - padding-bottom: 133.33%; - } - .swagger-ui .aspect-ratio--6x4-l { - padding-bottom: 66.6%; - } - .swagger-ui .aspect-ratio--4x6-l { - padding-bottom: 150%; - } - .swagger-ui .aspect-ratio--8x5-l { - padding-bottom: 62.5%; - } - .swagger-ui .aspect-ratio--5x8-l { - padding-bottom: 160%; - } - .swagger-ui .aspect-ratio--7x5-l { - padding-bottom: 71.42%; - } - .swagger-ui .aspect-ratio--5x7-l { - padding-bottom: 140%; - } - .swagger-ui .aspect-ratio--1x1-l { - padding-bottom: 100%; - } - .swagger-ui .aspect-ratio--object-l { - bottom: 0; - height: 100%; - left: 0; - position: absolute; - right: 0; - top: 0; - width: 100%; - z-index: 100; - } -} -.swagger-ui img { - max-width: 100%; -} -.swagger-ui .cover { - background-size: cover !important; -} -.swagger-ui .contain { - background-size: contain !important; -} -@media screen and (min-width: 30em) { - .swagger-ui .cover-ns { - background-size: cover !important; - } - .swagger-ui .contain-ns { - background-size: contain !important; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .cover-m { - background-size: cover !important; - } - .swagger-ui .contain-m { - background-size: contain !important; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .cover-l { - background-size: cover !important; - } - .swagger-ui .contain-l { - background-size: contain !important; - } -} -.swagger-ui .bg-center { - background-position: 50%; - background-repeat: no-repeat; -} -.swagger-ui .bg-top { - background-position: top; - background-repeat: no-repeat; -} -.swagger-ui .bg-right { - background-position: 100%; - background-repeat: no-repeat; -} -.swagger-ui .bg-bottom { - background-position: bottom; - background-repeat: no-repeat; -} -.swagger-ui .bg-left { - background-position: 0; - background-repeat: no-repeat; -} -@media screen and (min-width: 30em) { - .swagger-ui .bg-center-ns { - background-position: 50%; - background-repeat: no-repeat; - } - .swagger-ui .bg-top-ns { - background-position: top; - background-repeat: no-repeat; - } - .swagger-ui .bg-right-ns { - background-position: 100%; - background-repeat: no-repeat; - } - .swagger-ui .bg-bottom-ns { - background-position: bottom; - background-repeat: no-repeat; - } - .swagger-ui .bg-left-ns { - background-position: 0; - background-repeat: no-repeat; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .bg-center-m { - background-position: 50%; - background-repeat: no-repeat; - } - .swagger-ui .bg-top-m { - background-position: top; - background-repeat: no-repeat; - } - .swagger-ui .bg-right-m { - background-position: 100%; - background-repeat: no-repeat; - } - .swagger-ui .bg-bottom-m { - background-position: bottom; - background-repeat: no-repeat; - } - .swagger-ui .bg-left-m { - background-position: 0; - background-repeat: no-repeat; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .bg-center-l { - background-position: 50%; - background-repeat: no-repeat; - } - .swagger-ui .bg-top-l { - background-position: top; - background-repeat: no-repeat; - } - .swagger-ui .bg-right-l { - background-position: 100%; - background-repeat: no-repeat; - } - .swagger-ui .bg-bottom-l { - background-position: bottom; - background-repeat: no-repeat; - } - .swagger-ui .bg-left-l { - background-position: 0; - background-repeat: no-repeat; - } -} -.swagger-ui .outline { - outline: 1px solid; -} -.swagger-ui .outline-transparent { - outline: 1px solid transparent; -} -.swagger-ui .outline-0 { - outline: 0; -} -@media screen and (min-width: 30em) { - .swagger-ui .outline-ns { - outline: 1px solid; - } - .swagger-ui .outline-transparent-ns { - outline: 1px solid transparent; - } - .swagger-ui .outline-0-ns { - outline: 0; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .outline-m { - outline: 1px solid; - } - .swagger-ui .outline-transparent-m { - outline: 1px solid transparent; - } - .swagger-ui .outline-0-m { - outline: 0; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .outline-l { - outline: 1px solid; - } - .swagger-ui .outline-transparent-l { - outline: 1px solid transparent; - } - .swagger-ui .outline-0-l { - outline: 0; - } -} -.swagger-ui .ba { - border-style: solid; - border-width: 1px; -} -.swagger-ui .bt { - border-top-style: solid; - border-top-width: 1px; -} -.swagger-ui .br { - border-right-style: solid; - border-right-width: 1px; -} -.swagger-ui .bb { - border-bottom-style: solid; - border-bottom-width: 1px; -} -.swagger-ui .bl { - border-left-style: solid; - border-left-width: 1px; -} -.swagger-ui .bn { - border-style: none; - border-width: 0; -} -@media screen and (min-width: 30em) { - .swagger-ui .ba-ns { - border-style: solid; - border-width: 1px; - } - .swagger-ui .bt-ns { - border-top-style: solid; - border-top-width: 1px; - } - .swagger-ui .br-ns { - border-right-style: solid; - border-right-width: 1px; - } - .swagger-ui .bb-ns { - border-bottom-style: solid; - border-bottom-width: 1px; - } - .swagger-ui .bl-ns { - border-left-style: solid; - border-left-width: 1px; - } - .swagger-ui .bn-ns { - border-style: none; - border-width: 0; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .ba-m { - border-style: solid; - border-width: 1px; - } - .swagger-ui .bt-m { - border-top-style: solid; - border-top-width: 1px; - } - .swagger-ui .br-m { - border-right-style: solid; - border-right-width: 1px; - } - .swagger-ui .bb-m { - border-bottom-style: solid; - border-bottom-width: 1px; - } - .swagger-ui .bl-m { - border-left-style: solid; - border-left-width: 1px; - } - .swagger-ui .bn-m { - border-style: none; - border-width: 0; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .ba-l { - border-style: solid; - border-width: 1px; - } - .swagger-ui .bt-l { - border-top-style: solid; - border-top-width: 1px; - } - .swagger-ui .br-l { - border-right-style: solid; - border-right-width: 1px; - } - .swagger-ui .bb-l { - border-bottom-style: solid; - border-bottom-width: 1px; - } - .swagger-ui .bl-l { - border-left-style: solid; - border-left-width: 1px; - } - .swagger-ui .bn-l { - border-style: none; - border-width: 0; - } -} -.swagger-ui .b--black { - border-color: #000; -} -.swagger-ui .b--near-black { - border-color: #111; -} -.swagger-ui .b--dark-gray { - border-color: #333; -} -.swagger-ui .b--mid-gray { - border-color: #555; -} -.swagger-ui .b--gray { - border-color: #777; -} -.swagger-ui .b--silver { - border-color: #999; -} -.swagger-ui .b--light-silver { - border-color: #aaa; -} -.swagger-ui .b--moon-gray { - border-color: #ccc; -} -.swagger-ui .b--light-gray { - border-color: #eee; -} -.swagger-ui .b--near-white { - border-color: #f4f4f4; -} -.swagger-ui .b--white { - border-color: #fff; -} -.swagger-ui .b--white-90 { - border-color: hsla(0, 0%, 100%, 0.9); -} -.swagger-ui .b--white-80 { - border-color: hsla(0, 0%, 100%, 0.8); -} -.swagger-ui .b--white-70 { - border-color: hsla(0, 0%, 100%, 0.7); -} -.swagger-ui .b--white-60 { - border-color: hsla(0, 0%, 100%, 0.6); -} -.swagger-ui .b--white-50 { - border-color: hsla(0, 0%, 100%, 0.5); -} -.swagger-ui .b--white-40 { - border-color: hsla(0, 0%, 100%, 0.4); -} -.swagger-ui .b--white-30 { - border-color: hsla(0, 0%, 100%, 0.3); -} -.swagger-ui .b--white-20 { - border-color: hsla(0, 0%, 100%, 0.2); -} -.swagger-ui .b--white-10 { - border-color: hsla(0, 0%, 100%, 0.1); -} -.swagger-ui .b--white-05 { - border-color: hsla(0, 0%, 100%, 0.05); -} -.swagger-ui .b--white-025 { - border-color: hsla(0, 0%, 100%, 0.025); -} -.swagger-ui .b--white-0125 { - border-color: hsla(0, 0%, 100%, 0.013); -} -.swagger-ui .b--black-90 { - border-color: rgba(0, 0, 0, 0.9); -} -.swagger-ui .b--black-80 { - border-color: rgba(0, 0, 0, 0.8); -} -.swagger-ui .b--black-70 { - border-color: rgba(0, 0, 0, 0.7); -} -.swagger-ui .b--black-60 { - border-color: rgba(0, 0, 0, 0.6); -} -.swagger-ui .b--black-50 { - border-color: rgba(0, 0, 0, 0.5); -} -.swagger-ui .b--black-40 { - border-color: rgba(0, 0, 0, 0.4); -} -.swagger-ui .b--black-30 { - border-color: rgba(0, 0, 0, 0.3); -} -.swagger-ui .b--black-20 { - border-color: rgba(0, 0, 0, 0.2); -} -.swagger-ui .b--black-10 { - border-color: rgba(0, 0, 0, 0.1); -} -.swagger-ui .b--black-05 { - border-color: rgba(0, 0, 0, 0.05); -} -.swagger-ui .b--black-025 { - border-color: rgba(0, 0, 0, 0.025); -} -.swagger-ui .b--black-0125 { - border-color: rgba(0, 0, 0, 0.013); -} -.swagger-ui .b--dark-red { - border-color: #e7040f; -} -.swagger-ui .b--red { - border-color: #ff4136; -} -.swagger-ui .b--light-red { - border-color: #ff725c; -} -.swagger-ui .b--orange { - border-color: #ff6300; -} -.swagger-ui .b--gold { - border-color: #ffb700; -} -.swagger-ui .b--yellow { - border-color: gold; -} -.swagger-ui .b--light-yellow { - border-color: #fbf1a9; -} -.swagger-ui .b--purple { - border-color: #5e2ca5; -} -.swagger-ui .b--light-purple { - border-color: #a463f2; -} -.swagger-ui .b--dark-pink { - border-color: #d5008f; -} -.swagger-ui .b--hot-pink { - border-color: #ff41b4; -} -.swagger-ui .b--pink { - border-color: #ff80cc; -} -.swagger-ui .b--light-pink { - border-color: #ffa3d7; -} -.swagger-ui .b--dark-green { - border-color: #137752; -} -.swagger-ui .b--green { - border-color: #19a974; -} -.swagger-ui .b--light-green { - border-color: #9eebcf; -} -.swagger-ui .b--navy { - border-color: #001b44; -} -.swagger-ui .b--dark-blue { - border-color: #00449e; -} -.swagger-ui .b--blue { - border-color: #357edd; -} -.swagger-ui .b--light-blue { - border-color: #96ccff; -} -.swagger-ui .b--lightest-blue { - border-color: #cdecff; -} -.swagger-ui .b--washed-blue { - border-color: #f6fffe; -} -.swagger-ui .b--washed-green { - border-color: #e8fdf5; -} -.swagger-ui .b--washed-yellow { - border-color: #fffceb; -} -.swagger-ui .b--washed-red { - border-color: #ffdfdf; -} -.swagger-ui .b--transparent { - border-color: transparent; -} -.swagger-ui .b--inherit { - border-color: inherit; -} -.swagger-ui .br0 { - border-radius: 0; -} -.swagger-ui .br1 { - border-radius: 0.125rem; -} -.swagger-ui .br2 { - border-radius: 0.25rem; -} -.swagger-ui .br3 { - border-radius: 0.5rem; -} -.swagger-ui .br4 { - border-radius: 1rem; -} -.swagger-ui .br-100 { - border-radius: 100%; -} -.swagger-ui .br-pill { - border-radius: 9999px; -} -.swagger-ui .br--bottom { - border-top-left-radius: 0; - border-top-right-radius: 0; -} -.swagger-ui .br--top { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; -} -.swagger-ui .br--right { - border-bottom-left-radius: 0; - border-top-left-radius: 0; -} -.swagger-ui .br--left { - border-bottom-right-radius: 0; - border-top-right-radius: 0; -} -@media screen and (min-width: 30em) { - .swagger-ui .br0-ns { - border-radius: 0; - } - .swagger-ui .br1-ns { - border-radius: 0.125rem; - } - .swagger-ui .br2-ns { - border-radius: 0.25rem; - } - .swagger-ui .br3-ns { - border-radius: 0.5rem; - } - .swagger-ui .br4-ns { - border-radius: 1rem; - } - .swagger-ui .br-100-ns { - border-radius: 100%; - } - .swagger-ui .br-pill-ns { - border-radius: 9999px; - } - .swagger-ui .br--bottom-ns { - border-top-left-radius: 0; - border-top-right-radius: 0; - } - .swagger-ui .br--top-ns { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - } - .swagger-ui .br--right-ns { - border-bottom-left-radius: 0; - border-top-left-radius: 0; - } - .swagger-ui .br--left-ns { - border-bottom-right-radius: 0; - border-top-right-radius: 0; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .br0-m { - border-radius: 0; - } - .swagger-ui .br1-m { - border-radius: 0.125rem; - } - .swagger-ui .br2-m { - border-radius: 0.25rem; - } - .swagger-ui .br3-m { - border-radius: 0.5rem; - } - .swagger-ui .br4-m { - border-radius: 1rem; - } - .swagger-ui .br-100-m { - border-radius: 100%; - } - .swagger-ui .br-pill-m { - border-radius: 9999px; - } - .swagger-ui .br--bottom-m { - border-top-left-radius: 0; - border-top-right-radius: 0; - } - .swagger-ui .br--top-m { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - } - .swagger-ui .br--right-m { - border-bottom-left-radius: 0; - border-top-left-radius: 0; - } - .swagger-ui .br--left-m { - border-bottom-right-radius: 0; - border-top-right-radius: 0; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .br0-l { - border-radius: 0; - } - .swagger-ui .br1-l { - border-radius: 0.125rem; - } - .swagger-ui .br2-l { - border-radius: 0.25rem; - } - .swagger-ui .br3-l { - border-radius: 0.5rem; - } - .swagger-ui .br4-l { - border-radius: 1rem; - } - .swagger-ui .br-100-l { - border-radius: 100%; - } - .swagger-ui .br-pill-l { - border-radius: 9999px; - } - .swagger-ui .br--bottom-l { - border-top-left-radius: 0; - border-top-right-radius: 0; - } - .swagger-ui .br--top-l { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - } - .swagger-ui .br--right-l { - border-bottom-left-radius: 0; - border-top-left-radius: 0; - } - .swagger-ui .br--left-l { - border-bottom-right-radius: 0; - border-top-right-radius: 0; - } -} -.swagger-ui .b--dotted { - border-style: dotted; -} -.swagger-ui .b--dashed { - border-style: dashed; -} -.swagger-ui .b--solid { - border-style: solid; -} -.swagger-ui .b--none { - border-style: none; -} -@media screen and (min-width: 30em) { - .swagger-ui .b--dotted-ns { - border-style: dotted; - } - .swagger-ui .b--dashed-ns { - border-style: dashed; - } - .swagger-ui .b--solid-ns { - border-style: solid; - } - .swagger-ui .b--none-ns { - border-style: none; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .b--dotted-m { - border-style: dotted; - } - .swagger-ui .b--dashed-m { - border-style: dashed; - } - .swagger-ui .b--solid-m { - border-style: solid; - } - .swagger-ui .b--none-m { - border-style: none; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .b--dotted-l { - border-style: dotted; - } - .swagger-ui .b--dashed-l { - border-style: dashed; - } - .swagger-ui .b--solid-l { - border-style: solid; - } - .swagger-ui .b--none-l { - border-style: none; - } -} -.swagger-ui .bw0 { - border-width: 0; -} -.swagger-ui .bw1 { - border-width: 0.125rem; -} -.swagger-ui .bw2 { - border-width: 0.25rem; -} -.swagger-ui .bw3 { - border-width: 0.5rem; -} -.swagger-ui .bw4 { - border-width: 1rem; -} -.swagger-ui .bw5 { - border-width: 2rem; -} -.swagger-ui .bt-0 { - border-top-width: 0; -} -.swagger-ui .br-0 { - border-right-width: 0; -} -.swagger-ui .bb-0 { - border-bottom-width: 0; -} -.swagger-ui .bl-0 { - border-left-width: 0; -} -@media screen and (min-width: 30em) { - .swagger-ui .bw0-ns { - border-width: 0; - } - .swagger-ui .bw1-ns { - border-width: 0.125rem; - } - .swagger-ui .bw2-ns { - border-width: 0.25rem; - } - .swagger-ui .bw3-ns { - border-width: 0.5rem; - } - .swagger-ui .bw4-ns { - border-width: 1rem; - } - .swagger-ui .bw5-ns { - border-width: 2rem; - } - .swagger-ui .bt-0-ns { - border-top-width: 0; - } - .swagger-ui .br-0-ns { - border-right-width: 0; - } - .swagger-ui .bb-0-ns { - border-bottom-width: 0; - } - .swagger-ui .bl-0-ns { - border-left-width: 0; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .bw0-m { - border-width: 0; - } - .swagger-ui .bw1-m { - border-width: 0.125rem; - } - .swagger-ui .bw2-m { - border-width: 0.25rem; - } - .swagger-ui .bw3-m { - border-width: 0.5rem; - } - .swagger-ui .bw4-m { - border-width: 1rem; - } - .swagger-ui .bw5-m { - border-width: 2rem; - } - .swagger-ui .bt-0-m { - border-top-width: 0; - } - .swagger-ui .br-0-m { - border-right-width: 0; - } - .swagger-ui .bb-0-m { - border-bottom-width: 0; - } - .swagger-ui .bl-0-m { - border-left-width: 0; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .bw0-l { - border-width: 0; - } - .swagger-ui .bw1-l { - border-width: 0.125rem; - } - .swagger-ui .bw2-l { - border-width: 0.25rem; - } - .swagger-ui .bw3-l { - border-width: 0.5rem; - } - .swagger-ui .bw4-l { - border-width: 1rem; - } - .swagger-ui .bw5-l { - border-width: 2rem; - } - .swagger-ui .bt-0-l { - border-top-width: 0; - } - .swagger-ui .br-0-l { - border-right-width: 0; - } - .swagger-ui .bb-0-l { - border-bottom-width: 0; - } - .swagger-ui .bl-0-l { - border-left-width: 0; - } -} -.swagger-ui .shadow-1 { - box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.2); -} -.swagger-ui .shadow-2 { - box-shadow: 0 0 8px 2px rgba(0, 0, 0, 0.2); -} -.swagger-ui .shadow-3 { - box-shadow: 2px 2px 4px 2px rgba(0, 0, 0, 0.2); -} -.swagger-ui .shadow-4 { - box-shadow: 2px 2px 8px 0 rgba(0, 0, 0, 0.2); -} -.swagger-ui .shadow-5 { - box-shadow: 4px 4px 8px 0 rgba(0, 0, 0, 0.2); -} -@media screen and (min-width: 30em) { - .swagger-ui .shadow-1-ns { - box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.2); - } - .swagger-ui .shadow-2-ns { - box-shadow: 0 0 8px 2px rgba(0, 0, 0, 0.2); - } - .swagger-ui .shadow-3-ns { - box-shadow: 2px 2px 4px 2px rgba(0, 0, 0, 0.2); - } - .swagger-ui .shadow-4-ns { - box-shadow: 2px 2px 8px 0 rgba(0, 0, 0, 0.2); - } - .swagger-ui .shadow-5-ns { - box-shadow: 4px 4px 8px 0 rgba(0, 0, 0, 0.2); - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .shadow-1-m { - box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.2); - } - .swagger-ui .shadow-2-m { - box-shadow: 0 0 8px 2px rgba(0, 0, 0, 0.2); - } - .swagger-ui .shadow-3-m { - box-shadow: 2px 2px 4px 2px rgba(0, 0, 0, 0.2); - } - .swagger-ui .shadow-4-m { - box-shadow: 2px 2px 8px 0 rgba(0, 0, 0, 0.2); - } - .swagger-ui .shadow-5-m { - box-shadow: 4px 4px 8px 0 rgba(0, 0, 0, 0.2); - } -} -@media screen and (min-width: 60em) { - .swagger-ui .shadow-1-l { - box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.2); - } - .swagger-ui .shadow-2-l { - box-shadow: 0 0 8px 2px rgba(0, 0, 0, 0.2); - } - .swagger-ui .shadow-3-l { - box-shadow: 2px 2px 4px 2px rgba(0, 0, 0, 0.2); - } - .swagger-ui .shadow-4-l { - box-shadow: 2px 2px 8px 0 rgba(0, 0, 0, 0.2); - } - .swagger-ui .shadow-5-l { - box-shadow: 4px 4px 8px 0 rgba(0, 0, 0, 0.2); - } -} -.swagger-ui .pre { - overflow-x: auto; - overflow-y: hidden; - overflow: scroll; -} -.swagger-ui .top-0 { - top: 0; -} -.swagger-ui .right-0 { - right: 0; -} -.swagger-ui .bottom-0 { - bottom: 0; -} -.swagger-ui .left-0 { - left: 0; -} -.swagger-ui .top-1 { - top: 1rem; -} -.swagger-ui .right-1 { - right: 1rem; -} -.swagger-ui .bottom-1 { - bottom: 1rem; -} -.swagger-ui .left-1 { - left: 1rem; -} -.swagger-ui .top-2 { - top: 2rem; -} -.swagger-ui .right-2 { - right: 2rem; -} -.swagger-ui .bottom-2 { - bottom: 2rem; -} -.swagger-ui .left-2 { - left: 2rem; -} -.swagger-ui .top--1 { - top: -1rem; -} -.swagger-ui .right--1 { - right: -1rem; -} -.swagger-ui .bottom--1 { - bottom: -1rem; -} -.swagger-ui .left--1 { - left: -1rem; -} -.swagger-ui .top--2 { - top: -2rem; -} -.swagger-ui .right--2 { - right: -2rem; -} -.swagger-ui .bottom--2 { - bottom: -2rem; -} -.swagger-ui .left--2 { - left: -2rem; -} -.swagger-ui .absolute--fill { - bottom: 0; - left: 0; - right: 0; - top: 0; -} -@media screen and (min-width: 30em) { - .swagger-ui .top-0-ns { - top: 0; - } - .swagger-ui .left-0-ns { - left: 0; - } - .swagger-ui .right-0-ns { - right: 0; - } - .swagger-ui .bottom-0-ns { - bottom: 0; - } - .swagger-ui .top-1-ns { - top: 1rem; - } - .swagger-ui .left-1-ns { - left: 1rem; - } - .swagger-ui .right-1-ns { - right: 1rem; - } - .swagger-ui .bottom-1-ns { - bottom: 1rem; - } - .swagger-ui .top-2-ns { - top: 2rem; - } - .swagger-ui .left-2-ns { - left: 2rem; - } - .swagger-ui .right-2-ns { - right: 2rem; - } - .swagger-ui .bottom-2-ns { - bottom: 2rem; - } - .swagger-ui .top--1-ns { - top: -1rem; - } - .swagger-ui .right--1-ns { - right: -1rem; - } - .swagger-ui .bottom--1-ns { - bottom: -1rem; - } - .swagger-ui .left--1-ns { - left: -1rem; - } - .swagger-ui .top--2-ns { - top: -2rem; - } - .swagger-ui .right--2-ns { - right: -2rem; - } - .swagger-ui .bottom--2-ns { - bottom: -2rem; - } - .swagger-ui .left--2-ns { - left: -2rem; - } - .swagger-ui .absolute--fill-ns { - bottom: 0; - left: 0; - right: 0; - top: 0; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .top-0-m { - top: 0; - } - .swagger-ui .left-0-m { - left: 0; - } - .swagger-ui .right-0-m { - right: 0; - } - .swagger-ui .bottom-0-m { - bottom: 0; - } - .swagger-ui .top-1-m { - top: 1rem; - } - .swagger-ui .left-1-m { - left: 1rem; - } - .swagger-ui .right-1-m { - right: 1rem; - } - .swagger-ui .bottom-1-m { - bottom: 1rem; - } - .swagger-ui .top-2-m { - top: 2rem; - } - .swagger-ui .left-2-m { - left: 2rem; - } - .swagger-ui .right-2-m { - right: 2rem; - } - .swagger-ui .bottom-2-m { - bottom: 2rem; - } - .swagger-ui .top--1-m { - top: -1rem; - } - .swagger-ui .right--1-m { - right: -1rem; - } - .swagger-ui .bottom--1-m { - bottom: -1rem; - } - .swagger-ui .left--1-m { - left: -1rem; - } - .swagger-ui .top--2-m { - top: -2rem; - } - .swagger-ui .right--2-m { - right: -2rem; - } - .swagger-ui .bottom--2-m { - bottom: -2rem; - } - .swagger-ui .left--2-m { - left: -2rem; - } - .swagger-ui .absolute--fill-m { - bottom: 0; - left: 0; - right: 0; - top: 0; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .top-0-l { - top: 0; - } - .swagger-ui .left-0-l { - left: 0; - } - .swagger-ui .right-0-l { - right: 0; - } - .swagger-ui .bottom-0-l { - bottom: 0; - } - .swagger-ui .top-1-l { - top: 1rem; - } - .swagger-ui .left-1-l { - left: 1rem; - } - .swagger-ui .right-1-l { - right: 1rem; - } - .swagger-ui .bottom-1-l { - bottom: 1rem; - } - .swagger-ui .top-2-l { - top: 2rem; - } - .swagger-ui .left-2-l { - left: 2rem; - } - .swagger-ui .right-2-l { - right: 2rem; - } - .swagger-ui .bottom-2-l { - bottom: 2rem; - } - .swagger-ui .top--1-l { - top: -1rem; - } - .swagger-ui .right--1-l { - right: -1rem; - } - .swagger-ui .bottom--1-l { - bottom: -1rem; - } - .swagger-ui .left--1-l { - left: -1rem; - } - .swagger-ui .top--2-l { - top: -2rem; - } - .swagger-ui .right--2-l { - right: -2rem; - } - .swagger-ui .bottom--2-l { - bottom: -2rem; - } - .swagger-ui .left--2-l { - left: -2rem; - } - .swagger-ui .absolute--fill-l { - bottom: 0; - left: 0; - right: 0; - top: 0; - } -} -.swagger-ui .cf:after, -.swagger-ui .cf:before { - content: " "; - display: table; -} -.swagger-ui .cf:after { - clear: both; -} -.swagger-ui .cf { - zoom: 1; -} -.swagger-ui .cl { - clear: left; -} -.swagger-ui .cr { - clear: right; -} -.swagger-ui .cb { - clear: both; -} -.swagger-ui .cn { - clear: none; -} -@media screen and (min-width: 30em) { - .swagger-ui .cl-ns { - clear: left; - } - .swagger-ui .cr-ns { - clear: right; - } - .swagger-ui .cb-ns { - clear: both; - } - .swagger-ui .cn-ns { - clear: none; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .cl-m { - clear: left; - } - .swagger-ui .cr-m { - clear: right; - } - .swagger-ui .cb-m { - clear: both; - } - .swagger-ui .cn-m { - clear: none; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .cl-l { - clear: left; - } - .swagger-ui .cr-l { - clear: right; - } - .swagger-ui .cb-l { - clear: both; - } - .swagger-ui .cn-l { - clear: none; - } -} -.swagger-ui .flex { - display: flex; -} -.swagger-ui .inline-flex { - display: inline-flex; -} -.swagger-ui .flex-auto { - flex: 1 1 auto; - min-height: 0; - min-width: 0; -} -.swagger-ui .flex-none { - flex: none; -} -.swagger-ui .flex-column { - flex-direction: column; -} -.swagger-ui .flex-row { - flex-direction: row; -} -.swagger-ui .flex-wrap { - flex-wrap: wrap; -} -.swagger-ui .flex-nowrap { - flex-wrap: nowrap; -} -.swagger-ui .flex-wrap-reverse { - flex-wrap: wrap-reverse; -} -.swagger-ui .flex-column-reverse { - flex-direction: column-reverse; -} -.swagger-ui .flex-row-reverse { - flex-direction: row-reverse; -} -.swagger-ui .items-start { - align-items: flex-start; -} -.swagger-ui .items-end { - align-items: flex-end; -} -.swagger-ui .items-center { - align-items: center; -} -.swagger-ui .items-baseline { - align-items: baseline; -} -.swagger-ui .items-stretch { - align-items: stretch; -} -.swagger-ui .self-start { - align-self: flex-start; -} -.swagger-ui .self-end { - align-self: flex-end; -} -.swagger-ui .self-center { - align-self: center; -} -.swagger-ui .self-baseline { - align-self: baseline; -} -.swagger-ui .self-stretch { - align-self: stretch; -} -.swagger-ui .justify-start { - justify-content: flex-start; -} -.swagger-ui .justify-end { - justify-content: flex-end; -} -.swagger-ui .justify-center { - justify-content: center; -} -.swagger-ui .justify-between { - justify-content: space-between; -} -.swagger-ui .justify-around { - justify-content: space-around; -} -.swagger-ui .content-start { - align-content: flex-start; -} -.swagger-ui .content-end { - align-content: flex-end; -} -.swagger-ui .content-center { - align-content: center; -} -.swagger-ui .content-between { - align-content: space-between; -} -.swagger-ui .content-around { - align-content: space-around; -} -.swagger-ui .content-stretch { - align-content: stretch; -} -.swagger-ui .order-0 { - order: 0; -} -.swagger-ui .order-1 { - order: 1; -} -.swagger-ui .order-2 { - order: 2; -} -.swagger-ui .order-3 { - order: 3; -} -.swagger-ui .order-4 { - order: 4; -} -.swagger-ui .order-5 { - order: 5; -} -.swagger-ui .order-6 { - order: 6; -} -.swagger-ui .order-7 { - order: 7; -} -.swagger-ui .order-8 { - order: 8; -} -.swagger-ui .order-last { - order: 99999; -} -.swagger-ui .flex-grow-0 { - flex-grow: 0; -} -.swagger-ui .flex-grow-1 { - flex-grow: 1; -} -.swagger-ui .flex-shrink-0 { - flex-shrink: 0; -} -.swagger-ui .flex-shrink-1 { - flex-shrink: 1; -} -@media screen and (min-width: 30em) { - .swagger-ui .flex-ns { - display: flex; - } - .swagger-ui .inline-flex-ns { - display: inline-flex; - } - .swagger-ui .flex-auto-ns { - flex: 1 1 auto; - min-height: 0; - min-width: 0; - } - .swagger-ui .flex-none-ns { - flex: none; - } - .swagger-ui .flex-column-ns { - flex-direction: column; - } - .swagger-ui .flex-row-ns { - flex-direction: row; - } - .swagger-ui .flex-wrap-ns { - flex-wrap: wrap; - } - .swagger-ui .flex-nowrap-ns { - flex-wrap: nowrap; - } - .swagger-ui .flex-wrap-reverse-ns { - flex-wrap: wrap-reverse; - } - .swagger-ui .flex-column-reverse-ns { - flex-direction: column-reverse; - } - .swagger-ui .flex-row-reverse-ns { - flex-direction: row-reverse; - } - .swagger-ui .items-start-ns { - align-items: flex-start; - } - .swagger-ui .items-end-ns { - align-items: flex-end; - } - .swagger-ui .items-center-ns { - align-items: center; - } - .swagger-ui .items-baseline-ns { - align-items: baseline; - } - .swagger-ui .items-stretch-ns { - align-items: stretch; - } - .swagger-ui .self-start-ns { - align-self: flex-start; - } - .swagger-ui .self-end-ns { - align-self: flex-end; - } - .swagger-ui .self-center-ns { - align-self: center; - } - .swagger-ui .self-baseline-ns { - align-self: baseline; - } - .swagger-ui .self-stretch-ns { - align-self: stretch; - } - .swagger-ui .justify-start-ns { - justify-content: flex-start; - } - .swagger-ui .justify-end-ns { - justify-content: flex-end; - } - .swagger-ui .justify-center-ns { - justify-content: center; - } - .swagger-ui .justify-between-ns { - justify-content: space-between; - } - .swagger-ui .justify-around-ns { - justify-content: space-around; - } - .swagger-ui .content-start-ns { - align-content: flex-start; - } - .swagger-ui .content-end-ns { - align-content: flex-end; - } - .swagger-ui .content-center-ns { - align-content: center; - } - .swagger-ui .content-between-ns { - align-content: space-between; - } - .swagger-ui .content-around-ns { - align-content: space-around; - } - .swagger-ui .content-stretch-ns { - align-content: stretch; - } - .swagger-ui .order-0-ns { - order: 0; - } - .swagger-ui .order-1-ns { - order: 1; - } - .swagger-ui .order-2-ns { - order: 2; - } - .swagger-ui .order-3-ns { - order: 3; - } - .swagger-ui .order-4-ns { - order: 4; - } - .swagger-ui .order-5-ns { - order: 5; - } - .swagger-ui .order-6-ns { - order: 6; - } - .swagger-ui .order-7-ns { - order: 7; - } - .swagger-ui .order-8-ns { - order: 8; - } - .swagger-ui .order-last-ns { - order: 99999; - } - .swagger-ui .flex-grow-0-ns { - flex-grow: 0; - } - .swagger-ui .flex-grow-1-ns { - flex-grow: 1; - } - .swagger-ui .flex-shrink-0-ns { - flex-shrink: 0; - } - .swagger-ui .flex-shrink-1-ns { - flex-shrink: 1; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .flex-m { - display: flex; - } - .swagger-ui .inline-flex-m { - display: inline-flex; - } - .swagger-ui .flex-auto-m { - flex: 1 1 auto; - min-height: 0; - min-width: 0; - } - .swagger-ui .flex-none-m { - flex: none; - } - .swagger-ui .flex-column-m { - flex-direction: column; - } - .swagger-ui .flex-row-m { - flex-direction: row; - } - .swagger-ui .flex-wrap-m { - flex-wrap: wrap; - } - .swagger-ui .flex-nowrap-m { - flex-wrap: nowrap; - } - .swagger-ui .flex-wrap-reverse-m { - flex-wrap: wrap-reverse; - } - .swagger-ui .flex-column-reverse-m { - flex-direction: column-reverse; - } - .swagger-ui .flex-row-reverse-m { - flex-direction: row-reverse; - } - .swagger-ui .items-start-m { - align-items: flex-start; - } - .swagger-ui .items-end-m { - align-items: flex-end; - } - .swagger-ui .items-center-m { - align-items: center; - } - .swagger-ui .items-baseline-m { - align-items: baseline; - } - .swagger-ui .items-stretch-m { - align-items: stretch; - } - .swagger-ui .self-start-m { - align-self: flex-start; - } - .swagger-ui .self-end-m { - align-self: flex-end; - } - .swagger-ui .self-center-m { - align-self: center; - } - .swagger-ui .self-baseline-m { - align-self: baseline; - } - .swagger-ui .self-stretch-m { - align-self: stretch; - } - .swagger-ui .justify-start-m { - justify-content: flex-start; - } - .swagger-ui .justify-end-m { - justify-content: flex-end; - } - .swagger-ui .justify-center-m { - justify-content: center; - } - .swagger-ui .justify-between-m { - justify-content: space-between; - } - .swagger-ui .justify-around-m { - justify-content: space-around; - } - .swagger-ui .content-start-m { - align-content: flex-start; - } - .swagger-ui .content-end-m { - align-content: flex-end; - } - .swagger-ui .content-center-m { - align-content: center; - } - .swagger-ui .content-between-m { - align-content: space-between; - } - .swagger-ui .content-around-m { - align-content: space-around; - } - .swagger-ui .content-stretch-m { - align-content: stretch; - } - .swagger-ui .order-0-m { - order: 0; - } - .swagger-ui .order-1-m { - order: 1; - } - .swagger-ui .order-2-m { - order: 2; - } - .swagger-ui .order-3-m { - order: 3; - } - .swagger-ui .order-4-m { - order: 4; - } - .swagger-ui .order-5-m { - order: 5; - } - .swagger-ui .order-6-m { - order: 6; - } - .swagger-ui .order-7-m { - order: 7; - } - .swagger-ui .order-8-m { - order: 8; - } - .swagger-ui .order-last-m { - order: 99999; - } - .swagger-ui .flex-grow-0-m { - flex-grow: 0; - } - .swagger-ui .flex-grow-1-m { - flex-grow: 1; - } - .swagger-ui .flex-shrink-0-m { - flex-shrink: 0; - } - .swagger-ui .flex-shrink-1-m { - flex-shrink: 1; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .flex-l { - display: flex; - } - .swagger-ui .inline-flex-l { - display: inline-flex; - } - .swagger-ui .flex-auto-l { - flex: 1 1 auto; - min-height: 0; - min-width: 0; - } - .swagger-ui .flex-none-l { - flex: none; - } - .swagger-ui .flex-column-l { - flex-direction: column; - } - .swagger-ui .flex-row-l { - flex-direction: row; - } - .swagger-ui .flex-wrap-l { - flex-wrap: wrap; - } - .swagger-ui .flex-nowrap-l { - flex-wrap: nowrap; - } - .swagger-ui .flex-wrap-reverse-l { - flex-wrap: wrap-reverse; - } - .swagger-ui .flex-column-reverse-l { - flex-direction: column-reverse; - } - .swagger-ui .flex-row-reverse-l { - flex-direction: row-reverse; - } - .swagger-ui .items-start-l { - align-items: flex-start; - } - .swagger-ui .items-end-l { - align-items: flex-end; - } - .swagger-ui .items-center-l { - align-items: center; - } - .swagger-ui .items-baseline-l { - align-items: baseline; - } - .swagger-ui .items-stretch-l { - align-items: stretch; - } - .swagger-ui .self-start-l { - align-self: flex-start; - } - .swagger-ui .self-end-l { - align-self: flex-end; - } - .swagger-ui .self-center-l { - align-self: center; - } - .swagger-ui .self-baseline-l { - align-self: baseline; - } - .swagger-ui .self-stretch-l { - align-self: stretch; - } - .swagger-ui .justify-start-l { - justify-content: flex-start; - } - .swagger-ui .justify-end-l { - justify-content: flex-end; - } - .swagger-ui .justify-center-l { - justify-content: center; - } - .swagger-ui .justify-between-l { - justify-content: space-between; - } - .swagger-ui .justify-around-l { - justify-content: space-around; - } - .swagger-ui .content-start-l { - align-content: flex-start; - } - .swagger-ui .content-end-l { - align-content: flex-end; - } - .swagger-ui .content-center-l { - align-content: center; - } - .swagger-ui .content-between-l { - align-content: space-between; - } - .swagger-ui .content-around-l { - align-content: space-around; - } - .swagger-ui .content-stretch-l { - align-content: stretch; - } - .swagger-ui .order-0-l { - order: 0; - } - .swagger-ui .order-1-l { - order: 1; - } - .swagger-ui .order-2-l { - order: 2; - } - .swagger-ui .order-3-l { - order: 3; - } - .swagger-ui .order-4-l { - order: 4; - } - .swagger-ui .order-5-l { - order: 5; - } - .swagger-ui .order-6-l { - order: 6; - } - .swagger-ui .order-7-l { - order: 7; - } - .swagger-ui .order-8-l { - order: 8; - } - .swagger-ui .order-last-l { - order: 99999; - } - .swagger-ui .flex-grow-0-l { - flex-grow: 0; - } - .swagger-ui .flex-grow-1-l { - flex-grow: 1; - } - .swagger-ui .flex-shrink-0-l { - flex-shrink: 0; - } - .swagger-ui .flex-shrink-1-l { - flex-shrink: 1; - } -} -.swagger-ui .dn { - display: none; -} -.swagger-ui .di { - display: inline; -} -.swagger-ui .db { - display: block; -} -.swagger-ui .dib { - display: inline-block; -} -.swagger-ui .dit { - display: inline-table; -} -.swagger-ui .dt { - display: table; -} -.swagger-ui .dtc { - display: table-cell; -} -.swagger-ui .dt-row { - display: table-row; -} -.swagger-ui .dt-row-group { - display: table-row-group; -} -.swagger-ui .dt-column { - display: table-column; -} -.swagger-ui .dt-column-group { - display: table-column-group; -} -.swagger-ui .dt--fixed { - table-layout: fixed; - width: 100%; -} -@media screen and (min-width: 30em) { - .swagger-ui .dn-ns { - display: none; - } - .swagger-ui .di-ns { - display: inline; - } - .swagger-ui .db-ns { - display: block; - } - .swagger-ui .dib-ns { - display: inline-block; - } - .swagger-ui .dit-ns { - display: inline-table; - } - .swagger-ui .dt-ns { - display: table; - } - .swagger-ui .dtc-ns { - display: table-cell; - } - .swagger-ui .dt-row-ns { - display: table-row; - } - .swagger-ui .dt-row-group-ns { - display: table-row-group; - } - .swagger-ui .dt-column-ns { - display: table-column; - } - .swagger-ui .dt-column-group-ns { - display: table-column-group; - } - .swagger-ui .dt--fixed-ns { - table-layout: fixed; - width: 100%; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .dn-m { - display: none; - } - .swagger-ui .di-m { - display: inline; - } - .swagger-ui .db-m { - display: block; - } - .swagger-ui .dib-m { - display: inline-block; - } - .swagger-ui .dit-m { - display: inline-table; - } - .swagger-ui .dt-m { - display: table; - } - .swagger-ui .dtc-m { - display: table-cell; - } - .swagger-ui .dt-row-m { - display: table-row; - } - .swagger-ui .dt-row-group-m { - display: table-row-group; - } - .swagger-ui .dt-column-m { - display: table-column; - } - .swagger-ui .dt-column-group-m { - display: table-column-group; - } - .swagger-ui .dt--fixed-m { - table-layout: fixed; - width: 100%; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .dn-l { - display: none; - } - .swagger-ui .di-l { - display: inline; - } - .swagger-ui .db-l { - display: block; - } - .swagger-ui .dib-l { - display: inline-block; - } - .swagger-ui .dit-l { - display: inline-table; - } - .swagger-ui .dt-l { - display: table; - } - .swagger-ui .dtc-l { - display: table-cell; - } - .swagger-ui .dt-row-l { - display: table-row; - } - .swagger-ui .dt-row-group-l { - display: table-row-group; - } - .swagger-ui .dt-column-l { - display: table-column; - } - .swagger-ui .dt-column-group-l { - display: table-column-group; - } - .swagger-ui .dt--fixed-l { - table-layout: fixed; - width: 100%; - } -} -.swagger-ui .fl { - _display: inline; - float: left; -} -.swagger-ui .fr { - _display: inline; - float: right; -} -.swagger-ui .fn { - float: none; -} -@media screen and (min-width: 30em) { - .swagger-ui .fl-ns { - _display: inline; - float: left; - } - .swagger-ui .fr-ns { - _display: inline; - float: right; - } - .swagger-ui .fn-ns { - float: none; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .fl-m { - _display: inline; - float: left; - } - .swagger-ui .fr-m { - _display: inline; - float: right; - } - .swagger-ui .fn-m { - float: none; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .fl-l { - _display: inline; - float: left; - } - .swagger-ui .fr-l { - _display: inline; - float: right; - } - .swagger-ui .fn-l { - float: none; - } -} -.swagger-ui .sans-serif { - font-family: - -apple-system, - BlinkMacSystemFont, - avenir next, - avenir, - helvetica, - helvetica neue, - ubuntu, - roboto, - noto, - segoe ui, - arial, - sans-serif; -} -.swagger-ui .serif { - font-family: georgia, serif; -} -.swagger-ui .system-sans-serif { - font-family: sans-serif; -} -.swagger-ui .system-serif { - font-family: serif; -} -.swagger-ui .code, -.swagger-ui code { - font-family: Consolas, monaco, monospace; -} -.swagger-ui .courier { - font-family: - Courier Next, - courier, - monospace; -} -.swagger-ui .helvetica { - font-family: - helvetica neue, - helvetica, - sans-serif; -} -.swagger-ui .avenir { - font-family: - avenir next, - avenir, - sans-serif; -} -.swagger-ui .athelas { - font-family: athelas, georgia, serif; -} -.swagger-ui .georgia { - font-family: georgia, serif; -} -.swagger-ui .times { - font-family: times, serif; -} -.swagger-ui .bodoni { - font-family: - Bodoni MT, - serif; -} -.swagger-ui .calisto { - font-family: - Calisto MT, - serif; -} -.swagger-ui .garamond { - font-family: garamond, serif; -} -.swagger-ui .baskerville { - font-family: baskerville, serif; -} -.swagger-ui .i { - font-style: italic; -} -.swagger-ui .fs-normal { - font-style: normal; -} -@media screen and (min-width: 30em) { - .swagger-ui .i-ns { - font-style: italic; - } - .swagger-ui .fs-normal-ns { - font-style: normal; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .i-m { - font-style: italic; - } - .swagger-ui .fs-normal-m { - font-style: normal; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .i-l { - font-style: italic; - } - .swagger-ui .fs-normal-l { - font-style: normal; - } -} -.swagger-ui .normal { - font-weight: 400; -} -.swagger-ui .b { - font-weight: 700; -} -.swagger-ui .fw1 { - font-weight: 100; -} -.swagger-ui .fw2 { - font-weight: 200; -} -.swagger-ui .fw3 { - font-weight: 300; -} -.swagger-ui .fw4 { - font-weight: 400; -} -.swagger-ui .fw5 { - font-weight: 500; -} -.swagger-ui .fw6 { - font-weight: 600; -} -.swagger-ui .fw7 { - font-weight: 700; -} -.swagger-ui .fw8 { - font-weight: 800; -} -.swagger-ui .fw9 { - font-weight: 900; -} -@media screen and (min-width: 30em) { - .swagger-ui .normal-ns { - font-weight: 400; - } - .swagger-ui .b-ns { - font-weight: 700; - } - .swagger-ui .fw1-ns { - font-weight: 100; - } - .swagger-ui .fw2-ns { - font-weight: 200; - } - .swagger-ui .fw3-ns { - font-weight: 300; - } - .swagger-ui .fw4-ns { - font-weight: 400; - } - .swagger-ui .fw5-ns { - font-weight: 500; - } - .swagger-ui .fw6-ns { - font-weight: 600; - } - .swagger-ui .fw7-ns { - font-weight: 700; - } - .swagger-ui .fw8-ns { - font-weight: 800; - } - .swagger-ui .fw9-ns { - font-weight: 900; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .normal-m { - font-weight: 400; - } - .swagger-ui .b-m { - font-weight: 700; - } - .swagger-ui .fw1-m { - font-weight: 100; - } - .swagger-ui .fw2-m { - font-weight: 200; - } - .swagger-ui .fw3-m { - font-weight: 300; - } - .swagger-ui .fw4-m { - font-weight: 400; - } - .swagger-ui .fw5-m { - font-weight: 500; - } - .swagger-ui .fw6-m { - font-weight: 600; - } - .swagger-ui .fw7-m { - font-weight: 700; - } - .swagger-ui .fw8-m { - font-weight: 800; - } - .swagger-ui .fw9-m { - font-weight: 900; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .normal-l { - font-weight: 400; - } - .swagger-ui .b-l { - font-weight: 700; - } - .swagger-ui .fw1-l { - font-weight: 100; - } - .swagger-ui .fw2-l { - font-weight: 200; - } - .swagger-ui .fw3-l { - font-weight: 300; - } - .swagger-ui .fw4-l { - font-weight: 400; - } - .swagger-ui .fw5-l { - font-weight: 500; - } - .swagger-ui .fw6-l { - font-weight: 600; - } - .swagger-ui .fw7-l { - font-weight: 700; - } - .swagger-ui .fw8-l { - font-weight: 800; - } - .swagger-ui .fw9-l { - font-weight: 900; - } -} -.swagger-ui .input-reset { - -webkit-appearance: none; - -moz-appearance: none; -} -.swagger-ui .button-reset::-moz-focus-inner, -.swagger-ui .input-reset::-moz-focus-inner { - border: 0; - padding: 0; -} -.swagger-ui .h1 { - height: 1rem; -} -.swagger-ui .h2 { - height: 2rem; -} -.swagger-ui .h3 { - height: 4rem; -} -.swagger-ui .h4 { - height: 8rem; -} -.swagger-ui .h5 { - height: 16rem; -} -.swagger-ui .h-25 { - height: 25%; -} -.swagger-ui .h-50 { - height: 50%; -} -.swagger-ui .h-75 { - height: 75%; -} -.swagger-ui .h-100 { - height: 100%; -} -.swagger-ui .min-h-100 { - min-height: 100%; -} -.swagger-ui .vh-25 { - height: 25vh; -} -.swagger-ui .vh-50 { - height: 50vh; -} -.swagger-ui .vh-75 { - height: 75vh; -} -.swagger-ui .vh-100 { - height: 100vh; -} -.swagger-ui .min-vh-100 { - min-height: 100vh; -} -.swagger-ui .h-auto { - height: auto; -} -.swagger-ui .h-inherit { - height: inherit; -} -@media screen and (min-width: 30em) { - .swagger-ui .h1-ns { - height: 1rem; - } - .swagger-ui .h2-ns { - height: 2rem; - } - .swagger-ui .h3-ns { - height: 4rem; - } - .swagger-ui .h4-ns { - height: 8rem; - } - .swagger-ui .h5-ns { - height: 16rem; - } - .swagger-ui .h-25-ns { - height: 25%; - } - .swagger-ui .h-50-ns { - height: 50%; - } - .swagger-ui .h-75-ns { - height: 75%; - } - .swagger-ui .h-100-ns { - height: 100%; - } - .swagger-ui .min-h-100-ns { - min-height: 100%; - } - .swagger-ui .vh-25-ns { - height: 25vh; - } - .swagger-ui .vh-50-ns { - height: 50vh; - } - .swagger-ui .vh-75-ns { - height: 75vh; - } - .swagger-ui .vh-100-ns { - height: 100vh; - } - .swagger-ui .min-vh-100-ns { - min-height: 100vh; - } - .swagger-ui .h-auto-ns { - height: auto; - } - .swagger-ui .h-inherit-ns { - height: inherit; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .h1-m { - height: 1rem; - } - .swagger-ui .h2-m { - height: 2rem; - } - .swagger-ui .h3-m { - height: 4rem; - } - .swagger-ui .h4-m { - height: 8rem; - } - .swagger-ui .h5-m { - height: 16rem; - } - .swagger-ui .h-25-m { - height: 25%; - } - .swagger-ui .h-50-m { - height: 50%; - } - .swagger-ui .h-75-m { - height: 75%; - } - .swagger-ui .h-100-m { - height: 100%; - } - .swagger-ui .min-h-100-m { - min-height: 100%; - } - .swagger-ui .vh-25-m { - height: 25vh; - } - .swagger-ui .vh-50-m { - height: 50vh; - } - .swagger-ui .vh-75-m { - height: 75vh; - } - .swagger-ui .vh-100-m { - height: 100vh; - } - .swagger-ui .min-vh-100-m { - min-height: 100vh; - } - .swagger-ui .h-auto-m { - height: auto; - } - .swagger-ui .h-inherit-m { - height: inherit; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .h1-l { - height: 1rem; - } - .swagger-ui .h2-l { - height: 2rem; - } - .swagger-ui .h3-l { - height: 4rem; - } - .swagger-ui .h4-l { - height: 8rem; - } - .swagger-ui .h5-l { - height: 16rem; - } - .swagger-ui .h-25-l { - height: 25%; - } - .swagger-ui .h-50-l { - height: 50%; - } - .swagger-ui .h-75-l { - height: 75%; - } - .swagger-ui .h-100-l { - height: 100%; - } - .swagger-ui .min-h-100-l { - min-height: 100%; - } - .swagger-ui .vh-25-l { - height: 25vh; - } - .swagger-ui .vh-50-l { - height: 50vh; - } - .swagger-ui .vh-75-l { - height: 75vh; - } - .swagger-ui .vh-100-l { - height: 100vh; - } - .swagger-ui .min-vh-100-l { - min-height: 100vh; - } - .swagger-ui .h-auto-l { - height: auto; - } - .swagger-ui .h-inherit-l { - height: inherit; - } -} -.swagger-ui .tracked { - letter-spacing: 0.1em; -} -.swagger-ui .tracked-tight { - letter-spacing: -0.05em; -} -.swagger-ui .tracked-mega { - letter-spacing: 0.25em; -} -@media screen and (min-width: 30em) { - .swagger-ui .tracked-ns { - letter-spacing: 0.1em; - } - .swagger-ui .tracked-tight-ns { - letter-spacing: -0.05em; - } - .swagger-ui .tracked-mega-ns { - letter-spacing: 0.25em; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .tracked-m { - letter-spacing: 0.1em; - } - .swagger-ui .tracked-tight-m { - letter-spacing: -0.05em; - } - .swagger-ui .tracked-mega-m { - letter-spacing: 0.25em; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .tracked-l { - letter-spacing: 0.1em; - } - .swagger-ui .tracked-tight-l { - letter-spacing: -0.05em; - } - .swagger-ui .tracked-mega-l { - letter-spacing: 0.25em; - } -} -.swagger-ui .lh-solid { - line-height: 1; -} -.swagger-ui .lh-title { - line-height: 1.25; -} -.swagger-ui .lh-copy { - line-height: 1.5; -} -@media screen and (min-width: 30em) { - .swagger-ui .lh-solid-ns { - line-height: 1; - } - .swagger-ui .lh-title-ns { - line-height: 1.25; - } - .swagger-ui .lh-copy-ns { - line-height: 1.5; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .lh-solid-m { - line-height: 1; - } - .swagger-ui .lh-title-m { - line-height: 1.25; - } - .swagger-ui .lh-copy-m { - line-height: 1.5; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .lh-solid-l { - line-height: 1; - } - .swagger-ui .lh-title-l { - line-height: 1.25; - } - .swagger-ui .lh-copy-l { - line-height: 1.5; - } -} -.swagger-ui .link { - -webkit-text-decoration: none; - text-decoration: none; -} -.swagger-ui .link, -.swagger-ui .link:active, -.swagger-ui .link:focus, -.swagger-ui .link:hover, -.swagger-ui .link:link, -.swagger-ui .link:visited { - transition: color 0.15s ease-in; -} -.swagger-ui .link:focus { - outline: 1px dotted currentColor; -} -.swagger-ui .list { - list-style-type: none; -} -.swagger-ui .mw-100 { - max-width: 100%; -} -.swagger-ui .mw1 { - max-width: 1rem; -} -.swagger-ui .mw2 { - max-width: 2rem; -} -.swagger-ui .mw3 { - max-width: 4rem; -} -.swagger-ui .mw4 { - max-width: 8rem; -} -.swagger-ui .mw5 { - max-width: 16rem; -} -.swagger-ui .mw6 { - max-width: 32rem; -} -.swagger-ui .mw7 { - max-width: 48rem; -} -.swagger-ui .mw8 { - max-width: 64rem; -} -.swagger-ui .mw9 { - max-width: 96rem; -} -.swagger-ui .mw-none { - max-width: none; -} -@media screen and (min-width: 30em) { - .swagger-ui .mw-100-ns { - max-width: 100%; - } - .swagger-ui .mw1-ns { - max-width: 1rem; - } - .swagger-ui .mw2-ns { - max-width: 2rem; - } - .swagger-ui .mw3-ns { - max-width: 4rem; - } - .swagger-ui .mw4-ns { - max-width: 8rem; - } - .swagger-ui .mw5-ns { - max-width: 16rem; - } - .swagger-ui .mw6-ns { - max-width: 32rem; - } - .swagger-ui .mw7-ns { - max-width: 48rem; - } - .swagger-ui .mw8-ns { - max-width: 64rem; - } - .swagger-ui .mw9-ns { - max-width: 96rem; - } - .swagger-ui .mw-none-ns { - max-width: none; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .mw-100-m { - max-width: 100%; - } - .swagger-ui .mw1-m { - max-width: 1rem; - } - .swagger-ui .mw2-m { - max-width: 2rem; - } - .swagger-ui .mw3-m { - max-width: 4rem; - } - .swagger-ui .mw4-m { - max-width: 8rem; - } - .swagger-ui .mw5-m { - max-width: 16rem; - } - .swagger-ui .mw6-m { - max-width: 32rem; - } - .swagger-ui .mw7-m { - max-width: 48rem; - } - .swagger-ui .mw8-m { - max-width: 64rem; - } - .swagger-ui .mw9-m { - max-width: 96rem; - } - .swagger-ui .mw-none-m { - max-width: none; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .mw-100-l { - max-width: 100%; - } - .swagger-ui .mw1-l { - max-width: 1rem; - } - .swagger-ui .mw2-l { - max-width: 2rem; - } - .swagger-ui .mw3-l { - max-width: 4rem; - } - .swagger-ui .mw4-l { - max-width: 8rem; - } - .swagger-ui .mw5-l { - max-width: 16rem; - } - .swagger-ui .mw6-l { - max-width: 32rem; - } - .swagger-ui .mw7-l { - max-width: 48rem; - } - .swagger-ui .mw8-l { - max-width: 64rem; - } - .swagger-ui .mw9-l { - max-width: 96rem; - } - .swagger-ui .mw-none-l { - max-width: none; - } -} -.swagger-ui .w1 { - width: 1rem; -} -.swagger-ui .w2 { - width: 2rem; -} -.swagger-ui .w3 { - width: 4rem; -} -.swagger-ui .w4 { - width: 8rem; -} -.swagger-ui .w5 { - width: 16rem; -} -.swagger-ui .w-10 { - width: 10%; -} -.swagger-ui .w-20 { - width: 20%; -} -.swagger-ui .w-25 { - width: 25%; -} -.swagger-ui .w-30 { - width: 30%; -} -.swagger-ui .w-33 { - width: 33%; -} -.swagger-ui .w-34 { - width: 34%; -} -.swagger-ui .w-40 { - width: 40%; -} -.swagger-ui .w-50 { - width: 50%; -} -.swagger-ui .w-60 { - width: 60%; -} -.swagger-ui .w-70 { - width: 70%; -} -.swagger-ui .w-75 { - width: 75%; -} -.swagger-ui .w-80 { - width: 80%; -} -.swagger-ui .w-90 { - width: 90%; -} -.swagger-ui .w-100 { - width: 100%; -} -.swagger-ui .w-third { - width: 33.3333333333%; -} -.swagger-ui .w-two-thirds { - width: 66.6666666667%; -} -.swagger-ui .w-auto { - width: auto; -} -@media screen and (min-width: 30em) { - .swagger-ui .w1-ns { - width: 1rem; - } - .swagger-ui .w2-ns { - width: 2rem; - } - .swagger-ui .w3-ns { - width: 4rem; - } - .swagger-ui .w4-ns { - width: 8rem; - } - .swagger-ui .w5-ns { - width: 16rem; - } - .swagger-ui .w-10-ns { - width: 10%; - } - .swagger-ui .w-20-ns { - width: 20%; - } - .swagger-ui .w-25-ns { - width: 25%; - } - .swagger-ui .w-30-ns { - width: 30%; - } - .swagger-ui .w-33-ns { - width: 33%; - } - .swagger-ui .w-34-ns { - width: 34%; - } - .swagger-ui .w-40-ns { - width: 40%; - } - .swagger-ui .w-50-ns { - width: 50%; - } - .swagger-ui .w-60-ns { - width: 60%; - } - .swagger-ui .w-70-ns { - width: 70%; - } - .swagger-ui .w-75-ns { - width: 75%; - } - .swagger-ui .w-80-ns { - width: 80%; - } - .swagger-ui .w-90-ns { - width: 90%; - } - .swagger-ui .w-100-ns { - width: 100%; - } - .swagger-ui .w-third-ns { - width: 33.3333333333%; - } - .swagger-ui .w-two-thirds-ns { - width: 66.6666666667%; - } - .swagger-ui .w-auto-ns { - width: auto; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .w1-m { - width: 1rem; - } - .swagger-ui .w2-m { - width: 2rem; - } - .swagger-ui .w3-m { - width: 4rem; - } - .swagger-ui .w4-m { - width: 8rem; - } - .swagger-ui .w5-m { - width: 16rem; - } - .swagger-ui .w-10-m { - width: 10%; - } - .swagger-ui .w-20-m { - width: 20%; - } - .swagger-ui .w-25-m { - width: 25%; - } - .swagger-ui .w-30-m { - width: 30%; - } - .swagger-ui .w-33-m { - width: 33%; - } - .swagger-ui .w-34-m { - width: 34%; - } - .swagger-ui .w-40-m { - width: 40%; - } - .swagger-ui .w-50-m { - width: 50%; - } - .swagger-ui .w-60-m { - width: 60%; - } - .swagger-ui .w-70-m { - width: 70%; - } - .swagger-ui .w-75-m { - width: 75%; - } - .swagger-ui .w-80-m { - width: 80%; - } - .swagger-ui .w-90-m { - width: 90%; - } - .swagger-ui .w-100-m { - width: 100%; - } - .swagger-ui .w-third-m { - width: 33.3333333333%; - } - .swagger-ui .w-two-thirds-m { - width: 66.6666666667%; - } - .swagger-ui .w-auto-m { - width: auto; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .w1-l { - width: 1rem; - } - .swagger-ui .w2-l { - width: 2rem; - } - .swagger-ui .w3-l { - width: 4rem; - } - .swagger-ui .w4-l { - width: 8rem; - } - .swagger-ui .w5-l { - width: 16rem; - } - .swagger-ui .w-10-l { - width: 10%; - } - .swagger-ui .w-20-l { - width: 20%; - } - .swagger-ui .w-25-l { - width: 25%; - } - .swagger-ui .w-30-l { - width: 30%; - } - .swagger-ui .w-33-l { - width: 33%; - } - .swagger-ui .w-34-l { - width: 34%; - } - .swagger-ui .w-40-l { - width: 40%; - } - .swagger-ui .w-50-l { - width: 50%; - } - .swagger-ui .w-60-l { - width: 60%; - } - .swagger-ui .w-70-l { - width: 70%; - } - .swagger-ui .w-75-l { - width: 75%; - } - .swagger-ui .w-80-l { - width: 80%; - } - .swagger-ui .w-90-l { - width: 90%; - } - .swagger-ui .w-100-l { - width: 100%; - } - .swagger-ui .w-third-l { - width: 33.3333333333%; - } - .swagger-ui .w-two-thirds-l { - width: 66.6666666667%; - } - .swagger-ui .w-auto-l { - width: auto; - } -} -.swagger-ui .overflow-visible { - overflow: visible; -} -.swagger-ui .overflow-hidden { - overflow: hidden; -} -.swagger-ui .overflow-scroll { - overflow: scroll; -} -.swagger-ui .overflow-auto { - overflow: auto; -} -.swagger-ui .overflow-x-visible { - overflow-x: visible; -} -.swagger-ui .overflow-x-hidden { - overflow-x: hidden; -} -.swagger-ui .overflow-x-scroll { - overflow-x: scroll; -} -.swagger-ui .overflow-x-auto { - overflow-x: auto; -} -.swagger-ui .overflow-y-visible { - overflow-y: visible; -} -.swagger-ui .overflow-y-hidden { - overflow-y: hidden; -} -.swagger-ui .overflow-y-scroll { - overflow-y: scroll; -} -.swagger-ui .overflow-y-auto { - overflow-y: auto; -} -@media screen and (min-width: 30em) { - .swagger-ui .overflow-visible-ns { - overflow: visible; - } - .swagger-ui .overflow-hidden-ns { - overflow: hidden; - } - .swagger-ui .overflow-scroll-ns { - overflow: scroll; - } - .swagger-ui .overflow-auto-ns { - overflow: auto; - } - .swagger-ui .overflow-x-visible-ns { - overflow-x: visible; - } - .swagger-ui .overflow-x-hidden-ns { - overflow-x: hidden; - } - .swagger-ui .overflow-x-scroll-ns { - overflow-x: scroll; - } - .swagger-ui .overflow-x-auto-ns { - overflow-x: auto; - } - .swagger-ui .overflow-y-visible-ns { - overflow-y: visible; - } - .swagger-ui .overflow-y-hidden-ns { - overflow-y: hidden; - } - .swagger-ui .overflow-y-scroll-ns { - overflow-y: scroll; - } - .swagger-ui .overflow-y-auto-ns { - overflow-y: auto; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .overflow-visible-m { - overflow: visible; - } - .swagger-ui .overflow-hidden-m { - overflow: hidden; - } - .swagger-ui .overflow-scroll-m { - overflow: scroll; - } - .swagger-ui .overflow-auto-m { - overflow: auto; - } - .swagger-ui .overflow-x-visible-m { - overflow-x: visible; - } - .swagger-ui .overflow-x-hidden-m { - overflow-x: hidden; - } - .swagger-ui .overflow-x-scroll-m { - overflow-x: scroll; - } - .swagger-ui .overflow-x-auto-m { - overflow-x: auto; - } - .swagger-ui .overflow-y-visible-m { - overflow-y: visible; - } - .swagger-ui .overflow-y-hidden-m { - overflow-y: hidden; - } - .swagger-ui .overflow-y-scroll-m { - overflow-y: scroll; - } - .swagger-ui .overflow-y-auto-m { - overflow-y: auto; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .overflow-visible-l { - overflow: visible; - } - .swagger-ui .overflow-hidden-l { - overflow: hidden; - } - .swagger-ui .overflow-scroll-l { - overflow: scroll; - } - .swagger-ui .overflow-auto-l { - overflow: auto; - } - .swagger-ui .overflow-x-visible-l { - overflow-x: visible; - } - .swagger-ui .overflow-x-hidden-l { - overflow-x: hidden; - } - .swagger-ui .overflow-x-scroll-l { - overflow-x: scroll; - } - .swagger-ui .overflow-x-auto-l { - overflow-x: auto; - } - .swagger-ui .overflow-y-visible-l { - overflow-y: visible; - } - .swagger-ui .overflow-y-hidden-l { - overflow-y: hidden; - } - .swagger-ui .overflow-y-scroll-l { - overflow-y: scroll; - } - .swagger-ui .overflow-y-auto-l { - overflow-y: auto; - } -} -.swagger-ui .static { - position: static; -} -.swagger-ui .relative { - position: relative; -} -.swagger-ui .absolute { - position: absolute; -} -.swagger-ui .fixed { - position: fixed; -} -@media screen and (min-width: 30em) { - .swagger-ui .static-ns { - position: static; - } - .swagger-ui .relative-ns { - position: relative; - } - .swagger-ui .absolute-ns { - position: absolute; - } - .swagger-ui .fixed-ns { - position: fixed; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .static-m { - position: static; - } - .swagger-ui .relative-m { - position: relative; - } - .swagger-ui .absolute-m { - position: absolute; - } - .swagger-ui .fixed-m { - position: fixed; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .static-l { - position: static; - } - .swagger-ui .relative-l { - position: relative; - } - .swagger-ui .absolute-l { - position: absolute; - } - .swagger-ui .fixed-l { - position: fixed; - } -} -.swagger-ui .o-100 { - opacity: 1; -} -.swagger-ui .o-90 { - opacity: 0.9; -} -.swagger-ui .o-80 { - opacity: 0.8; -} -.swagger-ui .o-70 { - opacity: 0.7; -} -.swagger-ui .o-60 { - opacity: 0.6; -} -.swagger-ui .o-50 { - opacity: 0.5; -} -.swagger-ui .o-40 { - opacity: 0.4; -} -.swagger-ui .o-30 { - opacity: 0.3; -} -.swagger-ui .o-20 { - opacity: 0.2; -} -.swagger-ui .o-10 { - opacity: 0.1; -} -.swagger-ui .o-05 { - opacity: 0.05; -} -.swagger-ui .o-025 { - opacity: 0.025; -} -.swagger-ui .o-0 { - opacity: 0; -} -.swagger-ui .rotate-45 { - transform: rotate(45deg); -} -.swagger-ui .rotate-90 { - transform: rotate(90deg); -} -.swagger-ui .rotate-135 { - transform: rotate(135deg); -} -.swagger-ui .rotate-180 { - transform: rotate(180deg); -} -.swagger-ui .rotate-225 { - transform: rotate(225deg); -} -.swagger-ui .rotate-270 { - transform: rotate(270deg); -} -.swagger-ui .rotate-315 { - transform: rotate(315deg); -} -@media screen and (min-width: 30em) { - .swagger-ui .rotate-45-ns { - transform: rotate(45deg); - } - .swagger-ui .rotate-90-ns { - transform: rotate(90deg); - } - .swagger-ui .rotate-135-ns { - transform: rotate(135deg); - } - .swagger-ui .rotate-180-ns { - transform: rotate(180deg); - } - .swagger-ui .rotate-225-ns { - transform: rotate(225deg); - } - .swagger-ui .rotate-270-ns { - transform: rotate(270deg); - } - .swagger-ui .rotate-315-ns { - transform: rotate(315deg); - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .rotate-45-m { - transform: rotate(45deg); - } - .swagger-ui .rotate-90-m { - transform: rotate(90deg); - } - .swagger-ui .rotate-135-m { - transform: rotate(135deg); - } - .swagger-ui .rotate-180-m { - transform: rotate(180deg); - } - .swagger-ui .rotate-225-m { - transform: rotate(225deg); - } - .swagger-ui .rotate-270-m { - transform: rotate(270deg); - } - .swagger-ui .rotate-315-m { - transform: rotate(315deg); - } -} -@media screen and (min-width: 60em) { - .swagger-ui .rotate-45-l { - transform: rotate(45deg); - } - .swagger-ui .rotate-90-l { - transform: rotate(90deg); - } - .swagger-ui .rotate-135-l { - transform: rotate(135deg); - } - .swagger-ui .rotate-180-l { - transform: rotate(180deg); - } - .swagger-ui .rotate-225-l { - transform: rotate(225deg); - } - .swagger-ui .rotate-270-l { - transform: rotate(270deg); - } - .swagger-ui .rotate-315-l { - transform: rotate(315deg); - } -} -.swagger-ui .black-90 { - color: rgba(0, 0, 0, 0.9); -} -.swagger-ui .black-80 { - color: rgba(0, 0, 0, 0.8); -} -.swagger-ui .black-70 { - color: rgba(0, 0, 0, 0.7); -} -.swagger-ui .black-60 { - color: rgba(0, 0, 0, 0.6); -} -.swagger-ui .black-50 { - color: rgba(0, 0, 0, 0.5); -} -.swagger-ui .black-40 { - color: rgba(0, 0, 0, 0.4); -} -.swagger-ui .black-30 { - color: rgba(0, 0, 0, 0.3); -} -.swagger-ui .black-20 { - color: rgba(0, 0, 0, 0.2); -} -.swagger-ui .black-10 { - color: rgba(0, 0, 0, 0.1); -} -.swagger-ui .black-05 { - color: rgba(0, 0, 0, 0.05); -} -.swagger-ui .white-90 { - color: hsla(0, 0%, 100%, 0.9); -} -.swagger-ui .white-80 { - color: hsla(0, 0%, 100%, 0.8); -} -.swagger-ui .white-70 { - color: hsla(0, 0%, 100%, 0.7); -} -.swagger-ui .white-60 { - color: hsla(0, 0%, 100%, 0.6); -} -.swagger-ui .white-50 { - color: hsla(0, 0%, 100%, 0.5); -} -.swagger-ui .white-40 { - color: hsla(0, 0%, 100%, 0.4); -} -.swagger-ui .white-30 { - color: hsla(0, 0%, 100%, 0.3); -} -.swagger-ui .white-20 { - color: hsla(0, 0%, 100%, 0.2); -} -.swagger-ui .white-10 { - color: hsla(0, 0%, 100%, 0.1); -} -.swagger-ui .black { - color: #000; -} -.swagger-ui .near-black { - color: #111; -} -.swagger-ui .dark-gray { - color: #333; -} -.swagger-ui .mid-gray { - color: #555; -} -.swagger-ui .gray { - color: #777; -} -.swagger-ui .silver { - color: #999; -} -.swagger-ui .light-silver { - color: #aaa; -} -.swagger-ui .moon-gray { - color: #ccc; -} -.swagger-ui .light-gray { - color: #eee; -} -.swagger-ui .near-white { - color: #f4f4f4; -} -.swagger-ui .white { - color: #fff; -} -.swagger-ui .dark-red { - color: #e7040f; -} -.swagger-ui .red { - color: #ff4136; -} -.swagger-ui .light-red { - color: #ff725c; -} -.swagger-ui .orange { - color: #ff6300; -} -.swagger-ui .gold { - color: #ffb700; -} -.swagger-ui .yellow { - color: gold; -} -.swagger-ui .light-yellow { - color: #fbf1a9; -} -.swagger-ui .purple { - color: #5e2ca5; -} -.swagger-ui .light-purple { - color: #a463f2; -} -.swagger-ui .dark-pink { - color: #d5008f; -} -.swagger-ui .hot-pink { - color: #ff41b4; -} -.swagger-ui .pink { - color: #ff80cc; -} -.swagger-ui .light-pink { - color: #ffa3d7; -} -.swagger-ui .dark-green { - color: #137752; -} -.swagger-ui .green { - color: #19a974; -} -.swagger-ui .light-green { - color: #9eebcf; -} -.swagger-ui .navy { - color: #001b44; -} -.swagger-ui .dark-blue { - color: #00449e; -} -.swagger-ui .blue { - color: #357edd; -} -.swagger-ui .light-blue { - color: #96ccff; -} -.swagger-ui .lightest-blue { - color: #cdecff; -} -.swagger-ui .washed-blue { - color: #f6fffe; -} -.swagger-ui .washed-green { - color: #e8fdf5; -} -.swagger-ui .washed-yellow { - color: #fffceb; -} -.swagger-ui .washed-red { - color: #ffdfdf; -} -.swagger-ui .color-inherit { - color: inherit; -} -.swagger-ui .bg-black-90 { - background-color: rgba(0, 0, 0, 0.9); -} -.swagger-ui .bg-black-80 { - background-color: rgba(0, 0, 0, 0.8); -} -.swagger-ui .bg-black-70 { - background-color: rgba(0, 0, 0, 0.7); -} -.swagger-ui .bg-black-60 { - background-color: rgba(0, 0, 0, 0.6); -} -.swagger-ui .bg-black-50 { - background-color: rgba(0, 0, 0, 0.5); -} -.swagger-ui .bg-black-40 { - background-color: rgba(0, 0, 0, 0.4); -} -.swagger-ui .bg-black-30 { - background-color: rgba(0, 0, 0, 0.3); -} -.swagger-ui .bg-black-20 { - background-color: rgba(0, 0, 0, 0.2); -} -.swagger-ui .bg-black-10 { - background-color: rgba(0, 0, 0, 0.1); -} -.swagger-ui .bg-black-05 { - background-color: rgba(0, 0, 0, 0.05); -} -.swagger-ui .bg-white-90 { - background-color: hsla(0, 0%, 100%, 0.9); -} -.swagger-ui .bg-white-80 { - background-color: hsla(0, 0%, 100%, 0.8); -} -.swagger-ui .bg-white-70 { - background-color: hsla(0, 0%, 100%, 0.7); -} -.swagger-ui .bg-white-60 { - background-color: hsla(0, 0%, 100%, 0.6); -} -.swagger-ui .bg-white-50 { - background-color: hsla(0, 0%, 100%, 0.5); -} -.swagger-ui .bg-white-40 { - background-color: hsla(0, 0%, 100%, 0.4); -} -.swagger-ui .bg-white-30 { - background-color: hsla(0, 0%, 100%, 0.3); -} -.swagger-ui .bg-white-20 { - background-color: hsla(0, 0%, 100%, 0.2); -} -.swagger-ui .bg-white-10 { - background-color: hsla(0, 0%, 100%, 0.1); -} -.swagger-ui .bg-black { - background-color: #000; -} -.swagger-ui .bg-near-black { - background-color: #111; -} -.swagger-ui .bg-dark-gray { - background-color: #333; -} -.swagger-ui .bg-mid-gray { - background-color: #555; -} -.swagger-ui .bg-gray { - background-color: #777; -} -.swagger-ui .bg-silver { - background-color: #999; -} -.swagger-ui .bg-light-silver { - background-color: #aaa; -} -.swagger-ui .bg-moon-gray { - background-color: #ccc; -} -.swagger-ui .bg-light-gray { - background-color: #eee; -} -.swagger-ui .bg-near-white { - background-color: #f4f4f4; -} -.swagger-ui .bg-white { - background-color: #fff; -} -.swagger-ui .bg-transparent { - background-color: transparent; -} -.swagger-ui .bg-dark-red { - background-color: #e7040f; -} -.swagger-ui .bg-red { - background-color: #ff4136; -} -.swagger-ui .bg-light-red { - background-color: #ff725c; -} -.swagger-ui .bg-orange { - background-color: #ff6300; -} -.swagger-ui .bg-gold { - background-color: #ffb700; -} -.swagger-ui .bg-yellow { - background-color: gold; -} -.swagger-ui .bg-light-yellow { - background-color: #fbf1a9; -} -.swagger-ui .bg-purple { - background-color: #5e2ca5; -} -.swagger-ui .bg-light-purple { - background-color: #a463f2; -} -.swagger-ui .bg-dark-pink { - background-color: #d5008f; -} -.swagger-ui .bg-hot-pink { - background-color: #ff41b4; -} -.swagger-ui .bg-pink { - background-color: #ff80cc; -} -.swagger-ui .bg-light-pink { - background-color: #ffa3d7; -} -.swagger-ui .bg-dark-green { - background-color: #137752; -} -.swagger-ui .bg-green { - background-color: #19a974; -} -.swagger-ui .bg-light-green { - background-color: #9eebcf; -} -.swagger-ui .bg-navy { - background-color: #001b44; -} -.swagger-ui .bg-dark-blue { - background-color: #00449e; -} -.swagger-ui .bg-blue { - background-color: #357edd; -} -.swagger-ui .bg-light-blue { - background-color: #96ccff; -} -.swagger-ui .bg-lightest-blue { - background-color: #cdecff; -} -.swagger-ui .bg-washed-blue { - background-color: #f6fffe; -} -.swagger-ui .bg-washed-green { - background-color: #e8fdf5; -} -.swagger-ui .bg-washed-yellow { - background-color: #fffceb; -} -.swagger-ui .bg-washed-red { - background-color: #ffdfdf; -} -.swagger-ui .bg-inherit { - background-color: inherit; -} -.swagger-ui .hover-black:focus, -.swagger-ui .hover-black:hover { - color: #000; -} -.swagger-ui .hover-near-black:focus, -.swagger-ui .hover-near-black:hover { - color: #111; -} -.swagger-ui .hover-dark-gray:focus, -.swagger-ui .hover-dark-gray:hover { - color: #333; -} -.swagger-ui .hover-mid-gray:focus, -.swagger-ui .hover-mid-gray:hover { - color: #555; -} -.swagger-ui .hover-gray:focus, -.swagger-ui .hover-gray:hover { - color: #777; -} -.swagger-ui .hover-silver:focus, -.swagger-ui .hover-silver:hover { - color: #999; -} -.swagger-ui .hover-light-silver:focus, -.swagger-ui .hover-light-silver:hover { - color: #aaa; -} -.swagger-ui .hover-moon-gray:focus, -.swagger-ui .hover-moon-gray:hover { - color: #ccc; -} -.swagger-ui .hover-light-gray:focus, -.swagger-ui .hover-light-gray:hover { - color: #eee; -} -.swagger-ui .hover-near-white:focus, -.swagger-ui .hover-near-white:hover { - color: #f4f4f4; -} -.swagger-ui .hover-white:focus, -.swagger-ui .hover-white:hover { - color: #fff; -} -.swagger-ui .hover-black-90:focus, -.swagger-ui .hover-black-90:hover { - color: rgba(0, 0, 0, 0.9); -} -.swagger-ui .hover-black-80:focus, -.swagger-ui .hover-black-80:hover { - color: rgba(0, 0, 0, 0.8); -} -.swagger-ui .hover-black-70:focus, -.swagger-ui .hover-black-70:hover { - color: rgba(0, 0, 0, 0.7); -} -.swagger-ui .hover-black-60:focus, -.swagger-ui .hover-black-60:hover { - color: rgba(0, 0, 0, 0.6); -} -.swagger-ui .hover-black-50:focus, -.swagger-ui .hover-black-50:hover { - color: rgba(0, 0, 0, 0.5); -} -.swagger-ui .hover-black-40:focus, -.swagger-ui .hover-black-40:hover { - color: rgba(0, 0, 0, 0.4); -} -.swagger-ui .hover-black-30:focus, -.swagger-ui .hover-black-30:hover { - color: rgba(0, 0, 0, 0.3); -} -.swagger-ui .hover-black-20:focus, -.swagger-ui .hover-black-20:hover { - color: rgba(0, 0, 0, 0.2); -} -.swagger-ui .hover-black-10:focus, -.swagger-ui .hover-black-10:hover { - color: rgba(0, 0, 0, 0.1); -} -.swagger-ui .hover-white-90:focus, -.swagger-ui .hover-white-90:hover { - color: hsla(0, 0%, 100%, 0.9); -} -.swagger-ui .hover-white-80:focus, -.swagger-ui .hover-white-80:hover { - color: hsla(0, 0%, 100%, 0.8); -} -.swagger-ui .hover-white-70:focus, -.swagger-ui .hover-white-70:hover { - color: hsla(0, 0%, 100%, 0.7); -} -.swagger-ui .hover-white-60:focus, -.swagger-ui .hover-white-60:hover { - color: hsla(0, 0%, 100%, 0.6); -} -.swagger-ui .hover-white-50:focus, -.swagger-ui .hover-white-50:hover { - color: hsla(0, 0%, 100%, 0.5); -} -.swagger-ui .hover-white-40:focus, -.swagger-ui .hover-white-40:hover { - color: hsla(0, 0%, 100%, 0.4); -} -.swagger-ui .hover-white-30:focus, -.swagger-ui .hover-white-30:hover { - color: hsla(0, 0%, 100%, 0.3); -} -.swagger-ui .hover-white-20:focus, -.swagger-ui .hover-white-20:hover { - color: hsla(0, 0%, 100%, 0.2); -} -.swagger-ui .hover-white-10:focus, -.swagger-ui .hover-white-10:hover { - color: hsla(0, 0%, 100%, 0.1); -} -.swagger-ui .hover-inherit:focus, -.swagger-ui .hover-inherit:hover { - color: inherit; -} -.swagger-ui .hover-bg-black:focus, -.swagger-ui .hover-bg-black:hover { - background-color: #000; -} -.swagger-ui .hover-bg-near-black:focus, -.swagger-ui .hover-bg-near-black:hover { - background-color: #111; -} -.swagger-ui .hover-bg-dark-gray:focus, -.swagger-ui .hover-bg-dark-gray:hover { - background-color: #333; -} -.swagger-ui .hover-bg-mid-gray:focus, -.swagger-ui .hover-bg-mid-gray:hover { - background-color: #555; -} -.swagger-ui .hover-bg-gray:focus, -.swagger-ui .hover-bg-gray:hover { - background-color: #777; -} -.swagger-ui .hover-bg-silver:focus, -.swagger-ui .hover-bg-silver:hover { - background-color: #999; -} -.swagger-ui .hover-bg-light-silver:focus, -.swagger-ui .hover-bg-light-silver:hover { - background-color: #aaa; -} -.swagger-ui .hover-bg-moon-gray:focus, -.swagger-ui .hover-bg-moon-gray:hover { - background-color: #ccc; -} -.swagger-ui .hover-bg-light-gray:focus, -.swagger-ui .hover-bg-light-gray:hover { - background-color: #eee; -} -.swagger-ui .hover-bg-near-white:focus, -.swagger-ui .hover-bg-near-white:hover { - background-color: #f4f4f4; -} -.swagger-ui .hover-bg-white:focus, -.swagger-ui .hover-bg-white:hover { - background-color: #fff; -} -.swagger-ui .hover-bg-transparent:focus, -.swagger-ui .hover-bg-transparent:hover { - background-color: transparent; -} -.swagger-ui .hover-bg-black-90:focus, -.swagger-ui .hover-bg-black-90:hover { - background-color: rgba(0, 0, 0, 0.9); -} -.swagger-ui .hover-bg-black-80:focus, -.swagger-ui .hover-bg-black-80:hover { - background-color: rgba(0, 0, 0, 0.8); -} -.swagger-ui .hover-bg-black-70:focus, -.swagger-ui .hover-bg-black-70:hover { - background-color: rgba(0, 0, 0, 0.7); -} -.swagger-ui .hover-bg-black-60:focus, -.swagger-ui .hover-bg-black-60:hover { - background-color: rgba(0, 0, 0, 0.6); -} -.swagger-ui .hover-bg-black-50:focus, -.swagger-ui .hover-bg-black-50:hover { - background-color: rgba(0, 0, 0, 0.5); -} -.swagger-ui .hover-bg-black-40:focus, -.swagger-ui .hover-bg-black-40:hover { - background-color: rgba(0, 0, 0, 0.4); -} -.swagger-ui .hover-bg-black-30:focus, -.swagger-ui .hover-bg-black-30:hover { - background-color: rgba(0, 0, 0, 0.3); -} -.swagger-ui .hover-bg-black-20:focus, -.swagger-ui .hover-bg-black-20:hover { - background-color: rgba(0, 0, 0, 0.2); -} -.swagger-ui .hover-bg-black-10:focus, -.swagger-ui .hover-bg-black-10:hover { - background-color: rgba(0, 0, 0, 0.1); -} -.swagger-ui .hover-bg-white-90:focus, -.swagger-ui .hover-bg-white-90:hover { - background-color: hsla(0, 0%, 100%, 0.9); -} -.swagger-ui .hover-bg-white-80:focus, -.swagger-ui .hover-bg-white-80:hover { - background-color: hsla(0, 0%, 100%, 0.8); -} -.swagger-ui .hover-bg-white-70:focus, -.swagger-ui .hover-bg-white-70:hover { - background-color: hsla(0, 0%, 100%, 0.7); -} -.swagger-ui .hover-bg-white-60:focus, -.swagger-ui .hover-bg-white-60:hover { - background-color: hsla(0, 0%, 100%, 0.6); -} -.swagger-ui .hover-bg-white-50:focus, -.swagger-ui .hover-bg-white-50:hover { - background-color: hsla(0, 0%, 100%, 0.5); -} -.swagger-ui .hover-bg-white-40:focus, -.swagger-ui .hover-bg-white-40:hover { - background-color: hsla(0, 0%, 100%, 0.4); -} -.swagger-ui .hover-bg-white-30:focus, -.swagger-ui .hover-bg-white-30:hover { - background-color: hsla(0, 0%, 100%, 0.3); -} -.swagger-ui .hover-bg-white-20:focus, -.swagger-ui .hover-bg-white-20:hover { - background-color: hsla(0, 0%, 100%, 0.2); -} -.swagger-ui .hover-bg-white-10:focus, -.swagger-ui .hover-bg-white-10:hover { - background-color: hsla(0, 0%, 100%, 0.1); -} -.swagger-ui .hover-dark-red:focus, -.swagger-ui .hover-dark-red:hover { - color: #e7040f; -} -.swagger-ui .hover-red:focus, -.swagger-ui .hover-red:hover { - color: #ff4136; -} -.swagger-ui .hover-light-red:focus, -.swagger-ui .hover-light-red:hover { - color: #ff725c; -} -.swagger-ui .hover-orange:focus, -.swagger-ui .hover-orange:hover { - color: #ff6300; -} -.swagger-ui .hover-gold:focus, -.swagger-ui .hover-gold:hover { - color: #ffb700; -} -.swagger-ui .hover-yellow:focus, -.swagger-ui .hover-yellow:hover { - color: gold; -} -.swagger-ui .hover-light-yellow:focus, -.swagger-ui .hover-light-yellow:hover { - color: #fbf1a9; -} -.swagger-ui .hover-purple:focus, -.swagger-ui .hover-purple:hover { - color: #5e2ca5; -} -.swagger-ui .hover-light-purple:focus, -.swagger-ui .hover-light-purple:hover { - color: #a463f2; -} -.swagger-ui .hover-dark-pink:focus, -.swagger-ui .hover-dark-pink:hover { - color: #d5008f; -} -.swagger-ui .hover-hot-pink:focus, -.swagger-ui .hover-hot-pink:hover { - color: #ff41b4; -} -.swagger-ui .hover-pink:focus, -.swagger-ui .hover-pink:hover { - color: #ff80cc; -} -.swagger-ui .hover-light-pink:focus, -.swagger-ui .hover-light-pink:hover { - color: #ffa3d7; -} -.swagger-ui .hover-dark-green:focus, -.swagger-ui .hover-dark-green:hover { - color: #137752; -} -.swagger-ui .hover-green:focus, -.swagger-ui .hover-green:hover { - color: #19a974; -} -.swagger-ui .hover-light-green:focus, -.swagger-ui .hover-light-green:hover { - color: #9eebcf; -} -.swagger-ui .hover-navy:focus, -.swagger-ui .hover-navy:hover { - color: #001b44; -} -.swagger-ui .hover-dark-blue:focus, -.swagger-ui .hover-dark-blue:hover { - color: #00449e; -} -.swagger-ui .hover-blue:focus, -.swagger-ui .hover-blue:hover { - color: #357edd; -} -.swagger-ui .hover-light-blue:focus, -.swagger-ui .hover-light-blue:hover { - color: #96ccff; -} -.swagger-ui .hover-lightest-blue:focus, -.swagger-ui .hover-lightest-blue:hover { - color: #cdecff; -} -.swagger-ui .hover-washed-blue:focus, -.swagger-ui .hover-washed-blue:hover { - color: #f6fffe; -} -.swagger-ui .hover-washed-green:focus, -.swagger-ui .hover-washed-green:hover { - color: #e8fdf5; -} -.swagger-ui .hover-washed-yellow:focus, -.swagger-ui .hover-washed-yellow:hover { - color: #fffceb; -} -.swagger-ui .hover-washed-red:focus, -.swagger-ui .hover-washed-red:hover { - color: #ffdfdf; -} -.swagger-ui .hover-bg-dark-red:focus, -.swagger-ui .hover-bg-dark-red:hover { - background-color: #e7040f; -} -.swagger-ui .hover-bg-red:focus, -.swagger-ui .hover-bg-red:hover { - background-color: #ff4136; -} -.swagger-ui .hover-bg-light-red:focus, -.swagger-ui .hover-bg-light-red:hover { - background-color: #ff725c; -} -.swagger-ui .hover-bg-orange:focus, -.swagger-ui .hover-bg-orange:hover { - background-color: #ff6300; -} -.swagger-ui .hover-bg-gold:focus, -.swagger-ui .hover-bg-gold:hover { - background-color: #ffb700; -} -.swagger-ui .hover-bg-yellow:focus, -.swagger-ui .hover-bg-yellow:hover { - background-color: gold; -} -.swagger-ui .hover-bg-light-yellow:focus, -.swagger-ui .hover-bg-light-yellow:hover { - background-color: #fbf1a9; -} -.swagger-ui .hover-bg-purple:focus, -.swagger-ui .hover-bg-purple:hover { - background-color: #5e2ca5; -} -.swagger-ui .hover-bg-light-purple:focus, -.swagger-ui .hover-bg-light-purple:hover { - background-color: #a463f2; -} -.swagger-ui .hover-bg-dark-pink:focus, -.swagger-ui .hover-bg-dark-pink:hover { - background-color: #d5008f; -} -.swagger-ui .hover-bg-hot-pink:focus, -.swagger-ui .hover-bg-hot-pink:hover { - background-color: #ff41b4; -} -.swagger-ui .hover-bg-pink:focus, -.swagger-ui .hover-bg-pink:hover { - background-color: #ff80cc; -} -.swagger-ui .hover-bg-light-pink:focus, -.swagger-ui .hover-bg-light-pink:hover { - background-color: #ffa3d7; -} -.swagger-ui .hover-bg-dark-green:focus, -.swagger-ui .hover-bg-dark-green:hover { - background-color: #137752; -} -.swagger-ui .hover-bg-green:focus, -.swagger-ui .hover-bg-green:hover { - background-color: #19a974; -} -.swagger-ui .hover-bg-light-green:focus, -.swagger-ui .hover-bg-light-green:hover { - background-color: #9eebcf; -} -.swagger-ui .hover-bg-navy:focus, -.swagger-ui .hover-bg-navy:hover { - background-color: #001b44; -} -.swagger-ui .hover-bg-dark-blue:focus, -.swagger-ui .hover-bg-dark-blue:hover { - background-color: #00449e; -} -.swagger-ui .hover-bg-blue:focus, -.swagger-ui .hover-bg-blue:hover { - background-color: #357edd; -} -.swagger-ui .hover-bg-light-blue:focus, -.swagger-ui .hover-bg-light-blue:hover { - background-color: #96ccff; -} -.swagger-ui .hover-bg-lightest-blue:focus, -.swagger-ui .hover-bg-lightest-blue:hover { - background-color: #cdecff; -} -.swagger-ui .hover-bg-washed-blue:focus, -.swagger-ui .hover-bg-washed-blue:hover { - background-color: #f6fffe; -} -.swagger-ui .hover-bg-washed-green:focus, -.swagger-ui .hover-bg-washed-green:hover { - background-color: #e8fdf5; -} -.swagger-ui .hover-bg-washed-yellow:focus, -.swagger-ui .hover-bg-washed-yellow:hover { - background-color: #fffceb; -} -.swagger-ui .hover-bg-washed-red:focus, -.swagger-ui .hover-bg-washed-red:hover { - background-color: #ffdfdf; -} -.swagger-ui .hover-bg-inherit:focus, -.swagger-ui .hover-bg-inherit:hover { - background-color: inherit; -} -.swagger-ui .pa0 { - padding: 0; -} -.swagger-ui .pa1 { - padding: 0.25rem; -} -.swagger-ui .pa2 { - padding: 0.5rem; -} -.swagger-ui .pa3 { - padding: 1rem; -} -.swagger-ui .pa4 { - padding: 2rem; -} -.swagger-ui .pa5 { - padding: 4rem; -} -.swagger-ui .pa6 { - padding: 8rem; -} -.swagger-ui .pa7 { - padding: 16rem; -} -.swagger-ui .pl0 { - padding-left: 0; -} -.swagger-ui .pl1 { - padding-left: 0.25rem; -} -.swagger-ui .pl2 { - padding-left: 0.5rem; -} -.swagger-ui .pl3 { - padding-left: 1rem; -} -.swagger-ui .pl4 { - padding-left: 2rem; -} -.swagger-ui .pl5 { - padding-left: 4rem; -} -.swagger-ui .pl6 { - padding-left: 8rem; -} -.swagger-ui .pl7 { - padding-left: 16rem; -} -.swagger-ui .pr0 { - padding-right: 0; -} -.swagger-ui .pr1 { - padding-right: 0.25rem; -} -.swagger-ui .pr2 { - padding-right: 0.5rem; -} -.swagger-ui .pr3 { - padding-right: 1rem; -} -.swagger-ui .pr4 { - padding-right: 2rem; -} -.swagger-ui .pr5 { - padding-right: 4rem; -} -.swagger-ui .pr6 { - padding-right: 8rem; -} -.swagger-ui .pr7 { - padding-right: 16rem; -} -.swagger-ui .pb0 { - padding-bottom: 0; -} -.swagger-ui .pb1 { - padding-bottom: 0.25rem; -} -.swagger-ui .pb2 { - padding-bottom: 0.5rem; -} -.swagger-ui .pb3 { - padding-bottom: 1rem; -} -.swagger-ui .pb4 { - padding-bottom: 2rem; -} -.swagger-ui .pb5 { - padding-bottom: 4rem; -} -.swagger-ui .pb6 { - padding-bottom: 8rem; -} -.swagger-ui .pb7 { - padding-bottom: 16rem; -} -.swagger-ui .pt0 { - padding-top: 0; -} -.swagger-ui .pt1 { - padding-top: 0.25rem; -} -.swagger-ui .pt2 { - padding-top: 0.5rem; -} -.swagger-ui .pt3 { - padding-top: 1rem; -} -.swagger-ui .pt4 { - padding-top: 2rem; -} -.swagger-ui .pt5 { - padding-top: 4rem; -} -.swagger-ui .pt6 { - padding-top: 8rem; -} -.swagger-ui .pt7 { - padding-top: 16rem; -} -.swagger-ui .pv0 { - padding-bottom: 0; - padding-top: 0; -} -.swagger-ui .pv1 { - padding-bottom: 0.25rem; - padding-top: 0.25rem; -} -.swagger-ui .pv2 { - padding-bottom: 0.5rem; - padding-top: 0.5rem; -} -.swagger-ui .pv3 { - padding-bottom: 1rem; - padding-top: 1rem; -} -.swagger-ui .pv4 { - padding-bottom: 2rem; - padding-top: 2rem; -} -.swagger-ui .pv5 { - padding-bottom: 4rem; - padding-top: 4rem; -} -.swagger-ui .pv6 { - padding-bottom: 8rem; - padding-top: 8rem; -} -.swagger-ui .pv7 { - padding-bottom: 16rem; - padding-top: 16rem; -} -.swagger-ui .ph0 { - padding-left: 0; - padding-right: 0; -} -.swagger-ui .ph1 { - padding-left: 0.25rem; - padding-right: 0.25rem; -} -.swagger-ui .ph2 { - padding-left: 0.5rem; - padding-right: 0.5rem; -} -.swagger-ui .ph3 { - padding-left: 1rem; - padding-right: 1rem; -} -.swagger-ui .ph4 { - padding-left: 2rem; - padding-right: 2rem; -} -.swagger-ui .ph5 { - padding-left: 4rem; - padding-right: 4rem; -} -.swagger-ui .ph6 { - padding-left: 8rem; - padding-right: 8rem; -} -.swagger-ui .ph7 { - padding-left: 16rem; - padding-right: 16rem; -} -.swagger-ui .ma0 { - margin: 0; -} -.swagger-ui .ma1 { - margin: 0.25rem; -} -.swagger-ui .ma2 { - margin: 0.5rem; -} -.swagger-ui .ma3 { - margin: 1rem; -} -.swagger-ui .ma4 { - margin: 2rem; -} -.swagger-ui .ma5 { - margin: 4rem; -} -.swagger-ui .ma6 { - margin: 8rem; -} -.swagger-ui .ma7 { - margin: 16rem; -} -.swagger-ui .ml0 { - margin-left: 0; -} -.swagger-ui .ml1 { - margin-left: 0.25rem; -} -.swagger-ui .ml2 { - margin-left: 0.5rem; -} -.swagger-ui .ml3 { - margin-left: 1rem; -} -.swagger-ui .ml4 { - margin-left: 2rem; -} -.swagger-ui .ml5 { - margin-left: 4rem; -} -.swagger-ui .ml6 { - margin-left: 8rem; -} -.swagger-ui .ml7 { - margin-left: 16rem; -} -.swagger-ui .mr0 { - margin-right: 0; -} -.swagger-ui .mr1 { - margin-right: 0.25rem; -} -.swagger-ui .mr2 { - margin-right: 0.5rem; -} -.swagger-ui .mr3 { - margin-right: 1rem; -} -.swagger-ui .mr4 { - margin-right: 2rem; -} -.swagger-ui .mr5 { - margin-right: 4rem; -} -.swagger-ui .mr6 { - margin-right: 8rem; -} -.swagger-ui .mr7 { - margin-right: 16rem; -} -.swagger-ui .mb0 { - margin-bottom: 0; -} -.swagger-ui .mb1 { - margin-bottom: 0.25rem; -} -.swagger-ui .mb2 { - margin-bottom: 0.5rem; -} -.swagger-ui .mb3 { - margin-bottom: 1rem; -} -.swagger-ui .mb4 { - margin-bottom: 2rem; -} -.swagger-ui .mb5 { - margin-bottom: 4rem; -} -.swagger-ui .mb6 { - margin-bottom: 8rem; -} -.swagger-ui .mb7 { - margin-bottom: 16rem; -} -.swagger-ui .mt0 { - margin-top: 0; -} -.swagger-ui .mt1 { - margin-top: 0.25rem; -} -.swagger-ui .mt2 { - margin-top: 0.5rem; -} -.swagger-ui .mt3 { - margin-top: 1rem; -} -.swagger-ui .mt4 { - margin-top: 2rem; -} -.swagger-ui .mt5 { - margin-top: 4rem; -} -.swagger-ui .mt6 { - margin-top: 8rem; -} -.swagger-ui .mt7 { - margin-top: 16rem; -} -.swagger-ui .mv0 { - margin-bottom: 0; - margin-top: 0; -} -.swagger-ui .mv1 { - margin-bottom: 0.25rem; - margin-top: 0.25rem; -} -.swagger-ui .mv2 { - margin-bottom: 0.5rem; - margin-top: 0.5rem; -} -.swagger-ui .mv3 { - margin-bottom: 1rem; - margin-top: 1rem; -} -.swagger-ui .mv4 { - margin-bottom: 2rem; - margin-top: 2rem; -} -.swagger-ui .mv5 { - margin-bottom: 4rem; - margin-top: 4rem; -} -.swagger-ui .mv6 { - margin-bottom: 8rem; - margin-top: 8rem; -} -.swagger-ui .mv7 { - margin-bottom: 16rem; - margin-top: 16rem; -} -.swagger-ui .mh0 { - margin-left: 0; - margin-right: 0; -} -.swagger-ui .mh1 { - margin-left: 0.25rem; - margin-right: 0.25rem; -} -.swagger-ui .mh2 { - margin-left: 0.5rem; - margin-right: 0.5rem; -} -.swagger-ui .mh3 { - margin-left: 1rem; - margin-right: 1rem; -} -.swagger-ui .mh4 { - margin-left: 2rem; - margin-right: 2rem; -} -.swagger-ui .mh5 { - margin-left: 4rem; - margin-right: 4rem; -} -.swagger-ui .mh6 { - margin-left: 8rem; - margin-right: 8rem; -} -.swagger-ui .mh7 { - margin-left: 16rem; - margin-right: 16rem; -} -@media screen and (min-width: 30em) { - .swagger-ui .pa0-ns { - padding: 0; - } - .swagger-ui .pa1-ns { - padding: 0.25rem; - } - .swagger-ui .pa2-ns { - padding: 0.5rem; - } - .swagger-ui .pa3-ns { - padding: 1rem; - } - .swagger-ui .pa4-ns { - padding: 2rem; - } - .swagger-ui .pa5-ns { - padding: 4rem; - } - .swagger-ui .pa6-ns { - padding: 8rem; - } - .swagger-ui .pa7-ns { - padding: 16rem; - } - .swagger-ui .pl0-ns { - padding-left: 0; - } - .swagger-ui .pl1-ns { - padding-left: 0.25rem; - } - .swagger-ui .pl2-ns { - padding-left: 0.5rem; - } - .swagger-ui .pl3-ns { - padding-left: 1rem; - } - .swagger-ui .pl4-ns { - padding-left: 2rem; - } - .swagger-ui .pl5-ns { - padding-left: 4rem; - } - .swagger-ui .pl6-ns { - padding-left: 8rem; - } - .swagger-ui .pl7-ns { - padding-left: 16rem; - } - .swagger-ui .pr0-ns { - padding-right: 0; - } - .swagger-ui .pr1-ns { - padding-right: 0.25rem; - } - .swagger-ui .pr2-ns { - padding-right: 0.5rem; - } - .swagger-ui .pr3-ns { - padding-right: 1rem; - } - .swagger-ui .pr4-ns { - padding-right: 2rem; - } - .swagger-ui .pr5-ns { - padding-right: 4rem; - } - .swagger-ui .pr6-ns { - padding-right: 8rem; - } - .swagger-ui .pr7-ns { - padding-right: 16rem; - } - .swagger-ui .pb0-ns { - padding-bottom: 0; - } - .swagger-ui .pb1-ns { - padding-bottom: 0.25rem; - } - .swagger-ui .pb2-ns { - padding-bottom: 0.5rem; - } - .swagger-ui .pb3-ns { - padding-bottom: 1rem; - } - .swagger-ui .pb4-ns { - padding-bottom: 2rem; - } - .swagger-ui .pb5-ns { - padding-bottom: 4rem; - } - .swagger-ui .pb6-ns { - padding-bottom: 8rem; - } - .swagger-ui .pb7-ns { - padding-bottom: 16rem; - } - .swagger-ui .pt0-ns { - padding-top: 0; - } - .swagger-ui .pt1-ns { - padding-top: 0.25rem; - } - .swagger-ui .pt2-ns { - padding-top: 0.5rem; - } - .swagger-ui .pt3-ns { - padding-top: 1rem; - } - .swagger-ui .pt4-ns { - padding-top: 2rem; - } - .swagger-ui .pt5-ns { - padding-top: 4rem; - } - .swagger-ui .pt6-ns { - padding-top: 8rem; - } - .swagger-ui .pt7-ns { - padding-top: 16rem; - } - .swagger-ui .pv0-ns { - padding-bottom: 0; - padding-top: 0; - } - .swagger-ui .pv1-ns { - padding-bottom: 0.25rem; - padding-top: 0.25rem; - } - .swagger-ui .pv2-ns { - padding-bottom: 0.5rem; - padding-top: 0.5rem; - } - .swagger-ui .pv3-ns { - padding-bottom: 1rem; - padding-top: 1rem; - } - .swagger-ui .pv4-ns { - padding-bottom: 2rem; - padding-top: 2rem; - } - .swagger-ui .pv5-ns { - padding-bottom: 4rem; - padding-top: 4rem; - } - .swagger-ui .pv6-ns { - padding-bottom: 8rem; - padding-top: 8rem; - } - .swagger-ui .pv7-ns { - padding-bottom: 16rem; - padding-top: 16rem; - } - .swagger-ui .ph0-ns { - padding-left: 0; - padding-right: 0; - } - .swagger-ui .ph1-ns { - padding-left: 0.25rem; - padding-right: 0.25rem; - } - .swagger-ui .ph2-ns { - padding-left: 0.5rem; - padding-right: 0.5rem; - } - .swagger-ui .ph3-ns { - padding-left: 1rem; - padding-right: 1rem; - } - .swagger-ui .ph4-ns { - padding-left: 2rem; - padding-right: 2rem; - } - .swagger-ui .ph5-ns { - padding-left: 4rem; - padding-right: 4rem; - } - .swagger-ui .ph6-ns { - padding-left: 8rem; - padding-right: 8rem; - } - .swagger-ui .ph7-ns { - padding-left: 16rem; - padding-right: 16rem; - } - .swagger-ui .ma0-ns { - margin: 0; - } - .swagger-ui .ma1-ns { - margin: 0.25rem; - } - .swagger-ui .ma2-ns { - margin: 0.5rem; - } - .swagger-ui .ma3-ns { - margin: 1rem; - } - .swagger-ui .ma4-ns { - margin: 2rem; - } - .swagger-ui .ma5-ns { - margin: 4rem; - } - .swagger-ui .ma6-ns { - margin: 8rem; - } - .swagger-ui .ma7-ns { - margin: 16rem; - } - .swagger-ui .ml0-ns { - margin-left: 0; - } - .swagger-ui .ml1-ns { - margin-left: 0.25rem; - } - .swagger-ui .ml2-ns { - margin-left: 0.5rem; - } - .swagger-ui .ml3-ns { - margin-left: 1rem; - } - .swagger-ui .ml4-ns { - margin-left: 2rem; - } - .swagger-ui .ml5-ns { - margin-left: 4rem; - } - .swagger-ui .ml6-ns { - margin-left: 8rem; - } - .swagger-ui .ml7-ns { - margin-left: 16rem; - } - .swagger-ui .mr0-ns { - margin-right: 0; - } - .swagger-ui .mr1-ns { - margin-right: 0.25rem; - } - .swagger-ui .mr2-ns { - margin-right: 0.5rem; - } - .swagger-ui .mr3-ns { - margin-right: 1rem; - } - .swagger-ui .mr4-ns { - margin-right: 2rem; - } - .swagger-ui .mr5-ns { - margin-right: 4rem; - } - .swagger-ui .mr6-ns { - margin-right: 8rem; - } - .swagger-ui .mr7-ns { - margin-right: 16rem; - } - .swagger-ui .mb0-ns { - margin-bottom: 0; - } - .swagger-ui .mb1-ns { - margin-bottom: 0.25rem; - } - .swagger-ui .mb2-ns { - margin-bottom: 0.5rem; - } - .swagger-ui .mb3-ns { - margin-bottom: 1rem; - } - .swagger-ui .mb4-ns { - margin-bottom: 2rem; - } - .swagger-ui .mb5-ns { - margin-bottom: 4rem; - } - .swagger-ui .mb6-ns { - margin-bottom: 8rem; - } - .swagger-ui .mb7-ns { - margin-bottom: 16rem; - } - .swagger-ui .mt0-ns { - margin-top: 0; - } - .swagger-ui .mt1-ns { - margin-top: 0.25rem; - } - .swagger-ui .mt2-ns { - margin-top: 0.5rem; - } - .swagger-ui .mt3-ns { - margin-top: 1rem; - } - .swagger-ui .mt4-ns { - margin-top: 2rem; - } - .swagger-ui .mt5-ns { - margin-top: 4rem; - } - .swagger-ui .mt6-ns { - margin-top: 8rem; - } - .swagger-ui .mt7-ns { - margin-top: 16rem; - } - .swagger-ui .mv0-ns { - margin-bottom: 0; - margin-top: 0; - } - .swagger-ui .mv1-ns { - margin-bottom: 0.25rem; - margin-top: 0.25rem; - } - .swagger-ui .mv2-ns { - margin-bottom: 0.5rem; - margin-top: 0.5rem; - } - .swagger-ui .mv3-ns { - margin-bottom: 1rem; - margin-top: 1rem; - } - .swagger-ui .mv4-ns { - margin-bottom: 2rem; - margin-top: 2rem; - } - .swagger-ui .mv5-ns { - margin-bottom: 4rem; - margin-top: 4rem; - } - .swagger-ui .mv6-ns { - margin-bottom: 8rem; - margin-top: 8rem; - } - .swagger-ui .mv7-ns { - margin-bottom: 16rem; - margin-top: 16rem; - } - .swagger-ui .mh0-ns { - margin-left: 0; - margin-right: 0; - } - .swagger-ui .mh1-ns { - margin-left: 0.25rem; - margin-right: 0.25rem; - } - .swagger-ui .mh2-ns { - margin-left: 0.5rem; - margin-right: 0.5rem; - } - .swagger-ui .mh3-ns { - margin-left: 1rem; - margin-right: 1rem; - } - .swagger-ui .mh4-ns { - margin-left: 2rem; - margin-right: 2rem; - } - .swagger-ui .mh5-ns { - margin-left: 4rem; - margin-right: 4rem; - } - .swagger-ui .mh6-ns { - margin-left: 8rem; - margin-right: 8rem; - } - .swagger-ui .mh7-ns { - margin-left: 16rem; - margin-right: 16rem; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .pa0-m { - padding: 0; - } - .swagger-ui .pa1-m { - padding: 0.25rem; - } - .swagger-ui .pa2-m { - padding: 0.5rem; - } - .swagger-ui .pa3-m { - padding: 1rem; - } - .swagger-ui .pa4-m { - padding: 2rem; - } - .swagger-ui .pa5-m { - padding: 4rem; - } - .swagger-ui .pa6-m { - padding: 8rem; - } - .swagger-ui .pa7-m { - padding: 16rem; - } - .swagger-ui .pl0-m { - padding-left: 0; - } - .swagger-ui .pl1-m { - padding-left: 0.25rem; - } - .swagger-ui .pl2-m { - padding-left: 0.5rem; - } - .swagger-ui .pl3-m { - padding-left: 1rem; - } - .swagger-ui .pl4-m { - padding-left: 2rem; - } - .swagger-ui .pl5-m { - padding-left: 4rem; - } - .swagger-ui .pl6-m { - padding-left: 8rem; - } - .swagger-ui .pl7-m { - padding-left: 16rem; - } - .swagger-ui .pr0-m { - padding-right: 0; - } - .swagger-ui .pr1-m { - padding-right: 0.25rem; - } - .swagger-ui .pr2-m { - padding-right: 0.5rem; - } - .swagger-ui .pr3-m { - padding-right: 1rem; - } - .swagger-ui .pr4-m { - padding-right: 2rem; - } - .swagger-ui .pr5-m { - padding-right: 4rem; - } - .swagger-ui .pr6-m { - padding-right: 8rem; - } - .swagger-ui .pr7-m { - padding-right: 16rem; - } - .swagger-ui .pb0-m { - padding-bottom: 0; - } - .swagger-ui .pb1-m { - padding-bottom: 0.25rem; - } - .swagger-ui .pb2-m { - padding-bottom: 0.5rem; - } - .swagger-ui .pb3-m { - padding-bottom: 1rem; - } - .swagger-ui .pb4-m { - padding-bottom: 2rem; - } - .swagger-ui .pb5-m { - padding-bottom: 4rem; - } - .swagger-ui .pb6-m { - padding-bottom: 8rem; - } - .swagger-ui .pb7-m { - padding-bottom: 16rem; - } - .swagger-ui .pt0-m { - padding-top: 0; - } - .swagger-ui .pt1-m { - padding-top: 0.25rem; - } - .swagger-ui .pt2-m { - padding-top: 0.5rem; - } - .swagger-ui .pt3-m { - padding-top: 1rem; - } - .swagger-ui .pt4-m { - padding-top: 2rem; - } - .swagger-ui .pt5-m { - padding-top: 4rem; - } - .swagger-ui .pt6-m { - padding-top: 8rem; - } - .swagger-ui .pt7-m { - padding-top: 16rem; - } - .swagger-ui .pv0-m { - padding-bottom: 0; - padding-top: 0; - } - .swagger-ui .pv1-m { - padding-bottom: 0.25rem; - padding-top: 0.25rem; - } - .swagger-ui .pv2-m { - padding-bottom: 0.5rem; - padding-top: 0.5rem; - } - .swagger-ui .pv3-m { - padding-bottom: 1rem; - padding-top: 1rem; - } - .swagger-ui .pv4-m { - padding-bottom: 2rem; - padding-top: 2rem; - } - .swagger-ui .pv5-m { - padding-bottom: 4rem; - padding-top: 4rem; - } - .swagger-ui .pv6-m { - padding-bottom: 8rem; - padding-top: 8rem; - } - .swagger-ui .pv7-m { - padding-bottom: 16rem; - padding-top: 16rem; - } - .swagger-ui .ph0-m { - padding-left: 0; - padding-right: 0; - } - .swagger-ui .ph1-m { - padding-left: 0.25rem; - padding-right: 0.25rem; - } - .swagger-ui .ph2-m { - padding-left: 0.5rem; - padding-right: 0.5rem; - } - .swagger-ui .ph3-m { - padding-left: 1rem; - padding-right: 1rem; - } - .swagger-ui .ph4-m { - padding-left: 2rem; - padding-right: 2rem; - } - .swagger-ui .ph5-m { - padding-left: 4rem; - padding-right: 4rem; - } - .swagger-ui .ph6-m { - padding-left: 8rem; - padding-right: 8rem; - } - .swagger-ui .ph7-m { - padding-left: 16rem; - padding-right: 16rem; - } - .swagger-ui .ma0-m { - margin: 0; - } - .swagger-ui .ma1-m { - margin: 0.25rem; - } - .swagger-ui .ma2-m { - margin: 0.5rem; - } - .swagger-ui .ma3-m { - margin: 1rem; - } - .swagger-ui .ma4-m { - margin: 2rem; - } - .swagger-ui .ma5-m { - margin: 4rem; - } - .swagger-ui .ma6-m { - margin: 8rem; - } - .swagger-ui .ma7-m { - margin: 16rem; - } - .swagger-ui .ml0-m { - margin-left: 0; - } - .swagger-ui .ml1-m { - margin-left: 0.25rem; - } - .swagger-ui .ml2-m { - margin-left: 0.5rem; - } - .swagger-ui .ml3-m { - margin-left: 1rem; - } - .swagger-ui .ml4-m { - margin-left: 2rem; - } - .swagger-ui .ml5-m { - margin-left: 4rem; - } - .swagger-ui .ml6-m { - margin-left: 8rem; - } - .swagger-ui .ml7-m { - margin-left: 16rem; - } - .swagger-ui .mr0-m { - margin-right: 0; - } - .swagger-ui .mr1-m { - margin-right: 0.25rem; - } - .swagger-ui .mr2-m { - margin-right: 0.5rem; - } - .swagger-ui .mr3-m { - margin-right: 1rem; - } - .swagger-ui .mr4-m { - margin-right: 2rem; - } - .swagger-ui .mr5-m { - margin-right: 4rem; - } - .swagger-ui .mr6-m { - margin-right: 8rem; - } - .swagger-ui .mr7-m { - margin-right: 16rem; - } - .swagger-ui .mb0-m { - margin-bottom: 0; - } - .swagger-ui .mb1-m { - margin-bottom: 0.25rem; - } - .swagger-ui .mb2-m { - margin-bottom: 0.5rem; - } - .swagger-ui .mb3-m { - margin-bottom: 1rem; - } - .swagger-ui .mb4-m { - margin-bottom: 2rem; - } - .swagger-ui .mb5-m { - margin-bottom: 4rem; - } - .swagger-ui .mb6-m { - margin-bottom: 8rem; - } - .swagger-ui .mb7-m { - margin-bottom: 16rem; - } - .swagger-ui .mt0-m { - margin-top: 0; - } - .swagger-ui .mt1-m { - margin-top: 0.25rem; - } - .swagger-ui .mt2-m { - margin-top: 0.5rem; - } - .swagger-ui .mt3-m { - margin-top: 1rem; - } - .swagger-ui .mt4-m { - margin-top: 2rem; - } - .swagger-ui .mt5-m { - margin-top: 4rem; - } - .swagger-ui .mt6-m { - margin-top: 8rem; - } - .swagger-ui .mt7-m { - margin-top: 16rem; - } - .swagger-ui .mv0-m { - margin-bottom: 0; - margin-top: 0; - } - .swagger-ui .mv1-m { - margin-bottom: 0.25rem; - margin-top: 0.25rem; - } - .swagger-ui .mv2-m { - margin-bottom: 0.5rem; - margin-top: 0.5rem; - } - .swagger-ui .mv3-m { - margin-bottom: 1rem; - margin-top: 1rem; - } - .swagger-ui .mv4-m { - margin-bottom: 2rem; - margin-top: 2rem; - } - .swagger-ui .mv5-m { - margin-bottom: 4rem; - margin-top: 4rem; - } - .swagger-ui .mv6-m { - margin-bottom: 8rem; - margin-top: 8rem; - } - .swagger-ui .mv7-m { - margin-bottom: 16rem; - margin-top: 16rem; - } - .swagger-ui .mh0-m { - margin-left: 0; - margin-right: 0; - } - .swagger-ui .mh1-m { - margin-left: 0.25rem; - margin-right: 0.25rem; - } - .swagger-ui .mh2-m { - margin-left: 0.5rem; - margin-right: 0.5rem; - } - .swagger-ui .mh3-m { - margin-left: 1rem; - margin-right: 1rem; - } - .swagger-ui .mh4-m { - margin-left: 2rem; - margin-right: 2rem; - } - .swagger-ui .mh5-m { - margin-left: 4rem; - margin-right: 4rem; - } - .swagger-ui .mh6-m { - margin-left: 8rem; - margin-right: 8rem; - } - .swagger-ui .mh7-m { - margin-left: 16rem; - margin-right: 16rem; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .pa0-l { - padding: 0; - } - .swagger-ui .pa1-l { - padding: 0.25rem; - } - .swagger-ui .pa2-l { - padding: 0.5rem; - } - .swagger-ui .pa3-l { - padding: 1rem; - } - .swagger-ui .pa4-l { - padding: 2rem; - } - .swagger-ui .pa5-l { - padding: 4rem; - } - .swagger-ui .pa6-l { - padding: 8rem; - } - .swagger-ui .pa7-l { - padding: 16rem; - } - .swagger-ui .pl0-l { - padding-left: 0; - } - .swagger-ui .pl1-l { - padding-left: 0.25rem; - } - .swagger-ui .pl2-l { - padding-left: 0.5rem; - } - .swagger-ui .pl3-l { - padding-left: 1rem; - } - .swagger-ui .pl4-l { - padding-left: 2rem; - } - .swagger-ui .pl5-l { - padding-left: 4rem; - } - .swagger-ui .pl6-l { - padding-left: 8rem; - } - .swagger-ui .pl7-l { - padding-left: 16rem; - } - .swagger-ui .pr0-l { - padding-right: 0; - } - .swagger-ui .pr1-l { - padding-right: 0.25rem; - } - .swagger-ui .pr2-l { - padding-right: 0.5rem; - } - .swagger-ui .pr3-l { - padding-right: 1rem; - } - .swagger-ui .pr4-l { - padding-right: 2rem; - } - .swagger-ui .pr5-l { - padding-right: 4rem; - } - .swagger-ui .pr6-l { - padding-right: 8rem; - } - .swagger-ui .pr7-l { - padding-right: 16rem; - } - .swagger-ui .pb0-l { - padding-bottom: 0; - } - .swagger-ui .pb1-l { - padding-bottom: 0.25rem; - } - .swagger-ui .pb2-l { - padding-bottom: 0.5rem; - } - .swagger-ui .pb3-l { - padding-bottom: 1rem; - } - .swagger-ui .pb4-l { - padding-bottom: 2rem; - } - .swagger-ui .pb5-l { - padding-bottom: 4rem; - } - .swagger-ui .pb6-l { - padding-bottom: 8rem; - } - .swagger-ui .pb7-l { - padding-bottom: 16rem; - } - .swagger-ui .pt0-l { - padding-top: 0; - } - .swagger-ui .pt1-l { - padding-top: 0.25rem; - } - .swagger-ui .pt2-l { - padding-top: 0.5rem; - } - .swagger-ui .pt3-l { - padding-top: 1rem; - } - .swagger-ui .pt4-l { - padding-top: 2rem; - } - .swagger-ui .pt5-l { - padding-top: 4rem; - } - .swagger-ui .pt6-l { - padding-top: 8rem; - } - .swagger-ui .pt7-l { - padding-top: 16rem; - } - .swagger-ui .pv0-l { - padding-bottom: 0; - padding-top: 0; - } - .swagger-ui .pv1-l { - padding-bottom: 0.25rem; - padding-top: 0.25rem; - } - .swagger-ui .pv2-l { - padding-bottom: 0.5rem; - padding-top: 0.5rem; - } - .swagger-ui .pv3-l { - padding-bottom: 1rem; - padding-top: 1rem; - } - .swagger-ui .pv4-l { - padding-bottom: 2rem; - padding-top: 2rem; - } - .swagger-ui .pv5-l { - padding-bottom: 4rem; - padding-top: 4rem; - } - .swagger-ui .pv6-l { - padding-bottom: 8rem; - padding-top: 8rem; - } - .swagger-ui .pv7-l { - padding-bottom: 16rem; - padding-top: 16rem; - } - .swagger-ui .ph0-l { - padding-left: 0; - padding-right: 0; - } - .swagger-ui .ph1-l { - padding-left: 0.25rem; - padding-right: 0.25rem; - } - .swagger-ui .ph2-l { - padding-left: 0.5rem; - padding-right: 0.5rem; - } - .swagger-ui .ph3-l { - padding-left: 1rem; - padding-right: 1rem; - } - .swagger-ui .ph4-l { - padding-left: 2rem; - padding-right: 2rem; - } - .swagger-ui .ph5-l { - padding-left: 4rem; - padding-right: 4rem; - } - .swagger-ui .ph6-l { - padding-left: 8rem; - padding-right: 8rem; - } - .swagger-ui .ph7-l { - padding-left: 16rem; - padding-right: 16rem; - } - .swagger-ui .ma0-l { - margin: 0; - } - .swagger-ui .ma1-l { - margin: 0.25rem; - } - .swagger-ui .ma2-l { - margin: 0.5rem; - } - .swagger-ui .ma3-l { - margin: 1rem; - } - .swagger-ui .ma4-l { - margin: 2rem; - } - .swagger-ui .ma5-l { - margin: 4rem; - } - .swagger-ui .ma6-l { - margin: 8rem; - } - .swagger-ui .ma7-l { - margin: 16rem; - } - .swagger-ui .ml0-l { - margin-left: 0; - } - .swagger-ui .ml1-l { - margin-left: 0.25rem; - } - .swagger-ui .ml2-l { - margin-left: 0.5rem; - } - .swagger-ui .ml3-l { - margin-left: 1rem; - } - .swagger-ui .ml4-l { - margin-left: 2rem; - } - .swagger-ui .ml5-l { - margin-left: 4rem; - } - .swagger-ui .ml6-l { - margin-left: 8rem; - } - .swagger-ui .ml7-l { - margin-left: 16rem; - } - .swagger-ui .mr0-l { - margin-right: 0; - } - .swagger-ui .mr1-l { - margin-right: 0.25rem; - } - .swagger-ui .mr2-l { - margin-right: 0.5rem; - } - .swagger-ui .mr3-l { - margin-right: 1rem; - } - .swagger-ui .mr4-l { - margin-right: 2rem; - } - .swagger-ui .mr5-l { - margin-right: 4rem; - } - .swagger-ui .mr6-l { - margin-right: 8rem; - } - .swagger-ui .mr7-l { - margin-right: 16rem; - } - .swagger-ui .mb0-l { - margin-bottom: 0; - } - .swagger-ui .mb1-l { - margin-bottom: 0.25rem; - } - .swagger-ui .mb2-l { - margin-bottom: 0.5rem; - } - .swagger-ui .mb3-l { - margin-bottom: 1rem; - } - .swagger-ui .mb4-l { - margin-bottom: 2rem; - } - .swagger-ui .mb5-l { - margin-bottom: 4rem; - } - .swagger-ui .mb6-l { - margin-bottom: 8rem; - } - .swagger-ui .mb7-l { - margin-bottom: 16rem; - } - .swagger-ui .mt0-l { - margin-top: 0; - } - .swagger-ui .mt1-l { - margin-top: 0.25rem; - } - .swagger-ui .mt2-l { - margin-top: 0.5rem; - } - .swagger-ui .mt3-l { - margin-top: 1rem; - } - .swagger-ui .mt4-l { - margin-top: 2rem; - } - .swagger-ui .mt5-l { - margin-top: 4rem; - } - .swagger-ui .mt6-l { - margin-top: 8rem; - } - .swagger-ui .mt7-l { - margin-top: 16rem; - } - .swagger-ui .mv0-l { - margin-bottom: 0; - margin-top: 0; - } - .swagger-ui .mv1-l { - margin-bottom: 0.25rem; - margin-top: 0.25rem; - } - .swagger-ui .mv2-l { - margin-bottom: 0.5rem; - margin-top: 0.5rem; - } - .swagger-ui .mv3-l { - margin-bottom: 1rem; - margin-top: 1rem; - } - .swagger-ui .mv4-l { - margin-bottom: 2rem; - margin-top: 2rem; - } - .swagger-ui .mv5-l { - margin-bottom: 4rem; - margin-top: 4rem; - } - .swagger-ui .mv6-l { - margin-bottom: 8rem; - margin-top: 8rem; - } - .swagger-ui .mv7-l { - margin-bottom: 16rem; - margin-top: 16rem; - } - .swagger-ui .mh0-l { - margin-left: 0; - margin-right: 0; - } - .swagger-ui .mh1-l { - margin-left: 0.25rem; - margin-right: 0.25rem; - } - .swagger-ui .mh2-l { - margin-left: 0.5rem; - margin-right: 0.5rem; - } - .swagger-ui .mh3-l { - margin-left: 1rem; - margin-right: 1rem; - } - .swagger-ui .mh4-l { - margin-left: 2rem; - margin-right: 2rem; - } - .swagger-ui .mh5-l { - margin-left: 4rem; - margin-right: 4rem; - } - .swagger-ui .mh6-l { - margin-left: 8rem; - margin-right: 8rem; - } - .swagger-ui .mh7-l { - margin-left: 16rem; - margin-right: 16rem; - } -} -.swagger-ui .na1 { - margin: -0.25rem; -} -.swagger-ui .na2 { - margin: -0.5rem; -} -.swagger-ui .na3 { - margin: -1rem; -} -.swagger-ui .na4 { - margin: -2rem; -} -.swagger-ui .na5 { - margin: -4rem; -} -.swagger-ui .na6 { - margin: -8rem; -} -.swagger-ui .na7 { - margin: -16rem; -} -.swagger-ui .nl1 { - margin-left: -0.25rem; -} -.swagger-ui .nl2 { - margin-left: -0.5rem; -} -.swagger-ui .nl3 { - margin-left: -1rem; -} -.swagger-ui .nl4 { - margin-left: -2rem; -} -.swagger-ui .nl5 { - margin-left: -4rem; -} -.swagger-ui .nl6 { - margin-left: -8rem; -} -.swagger-ui .nl7 { - margin-left: -16rem; -} -.swagger-ui .nr1 { - margin-right: -0.25rem; -} -.swagger-ui .nr2 { - margin-right: -0.5rem; -} -.swagger-ui .nr3 { - margin-right: -1rem; -} -.swagger-ui .nr4 { - margin-right: -2rem; -} -.swagger-ui .nr5 { - margin-right: -4rem; -} -.swagger-ui .nr6 { - margin-right: -8rem; -} -.swagger-ui .nr7 { - margin-right: -16rem; -} -.swagger-ui .nb1 { - margin-bottom: -0.25rem; -} -.swagger-ui .nb2 { - margin-bottom: -0.5rem; -} -.swagger-ui .nb3 { - margin-bottom: -1rem; -} -.swagger-ui .nb4 { - margin-bottom: -2rem; -} -.swagger-ui .nb5 { - margin-bottom: -4rem; -} -.swagger-ui .nb6 { - margin-bottom: -8rem; -} -.swagger-ui .nb7 { - margin-bottom: -16rem; -} -.swagger-ui .nt1 { - margin-top: -0.25rem; -} -.swagger-ui .nt2 { - margin-top: -0.5rem; -} -.swagger-ui .nt3 { - margin-top: -1rem; -} -.swagger-ui .nt4 { - margin-top: -2rem; -} -.swagger-ui .nt5 { - margin-top: -4rem; -} -.swagger-ui .nt6 { - margin-top: -8rem; -} -.swagger-ui .nt7 { - margin-top: -16rem; -} -@media screen and (min-width: 30em) { - .swagger-ui .na1-ns { - margin: -0.25rem; - } - .swagger-ui .na2-ns { - margin: -0.5rem; - } - .swagger-ui .na3-ns { - margin: -1rem; - } - .swagger-ui .na4-ns { - margin: -2rem; - } - .swagger-ui .na5-ns { - margin: -4rem; - } - .swagger-ui .na6-ns { - margin: -8rem; - } - .swagger-ui .na7-ns { - margin: -16rem; - } - .swagger-ui .nl1-ns { - margin-left: -0.25rem; - } - .swagger-ui .nl2-ns { - margin-left: -0.5rem; - } - .swagger-ui .nl3-ns { - margin-left: -1rem; - } - .swagger-ui .nl4-ns { - margin-left: -2rem; - } - .swagger-ui .nl5-ns { - margin-left: -4rem; - } - .swagger-ui .nl6-ns { - margin-left: -8rem; - } - .swagger-ui .nl7-ns { - margin-left: -16rem; - } - .swagger-ui .nr1-ns { - margin-right: -0.25rem; - } - .swagger-ui .nr2-ns { - margin-right: -0.5rem; - } - .swagger-ui .nr3-ns { - margin-right: -1rem; - } - .swagger-ui .nr4-ns { - margin-right: -2rem; - } - .swagger-ui .nr5-ns { - margin-right: -4rem; - } - .swagger-ui .nr6-ns { - margin-right: -8rem; - } - .swagger-ui .nr7-ns { - margin-right: -16rem; - } - .swagger-ui .nb1-ns { - margin-bottom: -0.25rem; - } - .swagger-ui .nb2-ns { - margin-bottom: -0.5rem; - } - .swagger-ui .nb3-ns { - margin-bottom: -1rem; - } - .swagger-ui .nb4-ns { - margin-bottom: -2rem; - } - .swagger-ui .nb5-ns { - margin-bottom: -4rem; - } - .swagger-ui .nb6-ns { - margin-bottom: -8rem; - } - .swagger-ui .nb7-ns { - margin-bottom: -16rem; - } - .swagger-ui .nt1-ns { - margin-top: -0.25rem; - } - .swagger-ui .nt2-ns { - margin-top: -0.5rem; - } - .swagger-ui .nt3-ns { - margin-top: -1rem; - } - .swagger-ui .nt4-ns { - margin-top: -2rem; - } - .swagger-ui .nt5-ns { - margin-top: -4rem; - } - .swagger-ui .nt6-ns { - margin-top: -8rem; - } - .swagger-ui .nt7-ns { - margin-top: -16rem; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .na1-m { - margin: -0.25rem; - } - .swagger-ui .na2-m { - margin: -0.5rem; - } - .swagger-ui .na3-m { - margin: -1rem; - } - .swagger-ui .na4-m { - margin: -2rem; - } - .swagger-ui .na5-m { - margin: -4rem; - } - .swagger-ui .na6-m { - margin: -8rem; - } - .swagger-ui .na7-m { - margin: -16rem; - } - .swagger-ui .nl1-m { - margin-left: -0.25rem; - } - .swagger-ui .nl2-m { - margin-left: -0.5rem; - } - .swagger-ui .nl3-m { - margin-left: -1rem; - } - .swagger-ui .nl4-m { - margin-left: -2rem; - } - .swagger-ui .nl5-m { - margin-left: -4rem; - } - .swagger-ui .nl6-m { - margin-left: -8rem; - } - .swagger-ui .nl7-m { - margin-left: -16rem; - } - .swagger-ui .nr1-m { - margin-right: -0.25rem; - } - .swagger-ui .nr2-m { - margin-right: -0.5rem; - } - .swagger-ui .nr3-m { - margin-right: -1rem; - } - .swagger-ui .nr4-m { - margin-right: -2rem; - } - .swagger-ui .nr5-m { - margin-right: -4rem; - } - .swagger-ui .nr6-m { - margin-right: -8rem; - } - .swagger-ui .nr7-m { - margin-right: -16rem; - } - .swagger-ui .nb1-m { - margin-bottom: -0.25rem; - } - .swagger-ui .nb2-m { - margin-bottom: -0.5rem; - } - .swagger-ui .nb3-m { - margin-bottom: -1rem; - } - .swagger-ui .nb4-m { - margin-bottom: -2rem; - } - .swagger-ui .nb5-m { - margin-bottom: -4rem; - } - .swagger-ui .nb6-m { - margin-bottom: -8rem; - } - .swagger-ui .nb7-m { - margin-bottom: -16rem; - } - .swagger-ui .nt1-m { - margin-top: -0.25rem; - } - .swagger-ui .nt2-m { - margin-top: -0.5rem; - } - .swagger-ui .nt3-m { - margin-top: -1rem; - } - .swagger-ui .nt4-m { - margin-top: -2rem; - } - .swagger-ui .nt5-m { - margin-top: -4rem; - } - .swagger-ui .nt6-m { - margin-top: -8rem; - } - .swagger-ui .nt7-m { - margin-top: -16rem; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .na1-l { - margin: -0.25rem; - } - .swagger-ui .na2-l { - margin: -0.5rem; - } - .swagger-ui .na3-l { - margin: -1rem; - } - .swagger-ui .na4-l { - margin: -2rem; - } - .swagger-ui .na5-l { - margin: -4rem; - } - .swagger-ui .na6-l { - margin: -8rem; - } - .swagger-ui .na7-l { - margin: -16rem; - } - .swagger-ui .nl1-l { - margin-left: -0.25rem; - } - .swagger-ui .nl2-l { - margin-left: -0.5rem; - } - .swagger-ui .nl3-l { - margin-left: -1rem; - } - .swagger-ui .nl4-l { - margin-left: -2rem; - } - .swagger-ui .nl5-l { - margin-left: -4rem; - } - .swagger-ui .nl6-l { - margin-left: -8rem; - } - .swagger-ui .nl7-l { - margin-left: -16rem; - } - .swagger-ui .nr1-l { - margin-right: -0.25rem; - } - .swagger-ui .nr2-l { - margin-right: -0.5rem; - } - .swagger-ui .nr3-l { - margin-right: -1rem; - } - .swagger-ui .nr4-l { - margin-right: -2rem; - } - .swagger-ui .nr5-l { - margin-right: -4rem; - } - .swagger-ui .nr6-l { - margin-right: -8rem; - } - .swagger-ui .nr7-l { - margin-right: -16rem; - } - .swagger-ui .nb1-l { - margin-bottom: -0.25rem; - } - .swagger-ui .nb2-l { - margin-bottom: -0.5rem; - } - .swagger-ui .nb3-l { - margin-bottom: -1rem; - } - .swagger-ui .nb4-l { - margin-bottom: -2rem; - } - .swagger-ui .nb5-l { - margin-bottom: -4rem; - } - .swagger-ui .nb6-l { - margin-bottom: -8rem; - } - .swagger-ui .nb7-l { - margin-bottom: -16rem; - } - .swagger-ui .nt1-l { - margin-top: -0.25rem; - } - .swagger-ui .nt2-l { - margin-top: -0.5rem; - } - .swagger-ui .nt3-l { - margin-top: -1rem; - } - .swagger-ui .nt4-l { - margin-top: -2rem; - } - .swagger-ui .nt5-l { - margin-top: -4rem; - } - .swagger-ui .nt6-l { - margin-top: -8rem; - } - .swagger-ui .nt7-l { - margin-top: -16rem; - } -} -.swagger-ui .collapse { - border-collapse: collapse; - border-spacing: 0; -} -.swagger-ui .striped--light-silver:nth-child(odd) { - background-color: #aaa; -} -.swagger-ui .striped--moon-gray:nth-child(odd) { - background-color: #ccc; -} -.swagger-ui .striped--light-gray:nth-child(odd) { - background-color: #eee; -} -.swagger-ui .striped--near-white:nth-child(odd) { - background-color: #f4f4f4; -} -.swagger-ui .stripe-light:nth-child(odd) { - background-color: hsla(0, 0%, 100%, 0.1); -} -.swagger-ui .stripe-dark:nth-child(odd) { - background-color: rgba(0, 0, 0, 0.1); -} -.swagger-ui .strike { - -webkit-text-decoration: line-through; - text-decoration: line-through; -} -.swagger-ui .underline { - -webkit-text-decoration: underline; - text-decoration: underline; -} -.swagger-ui .no-underline { - -webkit-text-decoration: none; - text-decoration: none; -} -@media screen and (min-width: 30em) { - .swagger-ui .strike-ns { - -webkit-text-decoration: line-through; - text-decoration: line-through; - } - .swagger-ui .underline-ns { - -webkit-text-decoration: underline; - text-decoration: underline; - } - .swagger-ui .no-underline-ns { - -webkit-text-decoration: none; - text-decoration: none; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .strike-m { - -webkit-text-decoration: line-through; - text-decoration: line-through; - } - .swagger-ui .underline-m { - -webkit-text-decoration: underline; - text-decoration: underline; - } - .swagger-ui .no-underline-m { - -webkit-text-decoration: none; - text-decoration: none; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .strike-l { - -webkit-text-decoration: line-through; - text-decoration: line-through; - } - .swagger-ui .underline-l { - -webkit-text-decoration: underline; - text-decoration: underline; - } - .swagger-ui .no-underline-l { - -webkit-text-decoration: none; - text-decoration: none; - } -} -.swagger-ui .tl { - text-align: left; -} -.swagger-ui .tr { - text-align: right; -} -.swagger-ui .tc { - text-align: center; -} -.swagger-ui .tj { - text-align: justify; -} -@media screen and (min-width: 30em) { - .swagger-ui .tl-ns { - text-align: left; - } - .swagger-ui .tr-ns { - text-align: right; - } - .swagger-ui .tc-ns { - text-align: center; - } - .swagger-ui .tj-ns { - text-align: justify; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .tl-m { - text-align: left; - } - .swagger-ui .tr-m { - text-align: right; - } - .swagger-ui .tc-m { - text-align: center; - } - .swagger-ui .tj-m { - text-align: justify; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .tl-l { - text-align: left; - } - .swagger-ui .tr-l { - text-align: right; - } - .swagger-ui .tc-l { - text-align: center; - } - .swagger-ui .tj-l { - text-align: justify; - } -} -.swagger-ui .ttc { - text-transform: capitalize; -} -.swagger-ui .ttl { - text-transform: lowercase; -} -.swagger-ui .ttu { - text-transform: uppercase; -} -.swagger-ui .ttn { - text-transform: none; -} -@media screen and (min-width: 30em) { - .swagger-ui .ttc-ns { - text-transform: capitalize; - } - .swagger-ui .ttl-ns { - text-transform: lowercase; - } - .swagger-ui .ttu-ns { - text-transform: uppercase; - } - .swagger-ui .ttn-ns { - text-transform: none; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .ttc-m { - text-transform: capitalize; - } - .swagger-ui .ttl-m { - text-transform: lowercase; - } - .swagger-ui .ttu-m { - text-transform: uppercase; - } - .swagger-ui .ttn-m { - text-transform: none; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .ttc-l { - text-transform: capitalize; - } - .swagger-ui .ttl-l { - text-transform: lowercase; - } - .swagger-ui .ttu-l { - text-transform: uppercase; - } - .swagger-ui .ttn-l { - text-transform: none; - } -} -.swagger-ui .f-6, -.swagger-ui .f-headline { - font-size: 6rem; -} -.swagger-ui .f-5, -.swagger-ui .f-subheadline { - font-size: 5rem; -} -.swagger-ui .f1 { - font-size: 3rem; -} -.swagger-ui .f2 { - font-size: 2.25rem; -} -.swagger-ui .f3 { - font-size: 1.5rem; -} -.swagger-ui .f4 { - font-size: 1.25rem; -} -.swagger-ui .f5 { - font-size: 1rem; -} -.swagger-ui .f6 { - font-size: 0.875rem; -} -.swagger-ui .f7 { - font-size: 0.75rem; -} -@media screen and (min-width: 30em) { - .swagger-ui .f-6-ns, - .swagger-ui .f-headline-ns { - font-size: 6rem; - } - .swagger-ui .f-5-ns, - .swagger-ui .f-subheadline-ns { - font-size: 5rem; - } - .swagger-ui .f1-ns { - font-size: 3rem; - } - .swagger-ui .f2-ns { - font-size: 2.25rem; - } - .swagger-ui .f3-ns { - font-size: 1.5rem; - } - .swagger-ui .f4-ns { - font-size: 1.25rem; - } - .swagger-ui .f5-ns { - font-size: 1rem; - } - .swagger-ui .f6-ns { - font-size: 0.875rem; - } - .swagger-ui .f7-ns { - font-size: 0.75rem; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .f-6-m, - .swagger-ui .f-headline-m { - font-size: 6rem; - } - .swagger-ui .f-5-m, - .swagger-ui .f-subheadline-m { - font-size: 5rem; - } - .swagger-ui .f1-m { - font-size: 3rem; - } - .swagger-ui .f2-m { - font-size: 2.25rem; - } - .swagger-ui .f3-m { - font-size: 1.5rem; - } - .swagger-ui .f4-m { - font-size: 1.25rem; - } - .swagger-ui .f5-m { - font-size: 1rem; - } - .swagger-ui .f6-m { - font-size: 0.875rem; - } - .swagger-ui .f7-m { - font-size: 0.75rem; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .f-6-l, - .swagger-ui .f-headline-l { - font-size: 6rem; - } - .swagger-ui .f-5-l, - .swagger-ui .f-subheadline-l { - font-size: 5rem; - } - .swagger-ui .f1-l { - font-size: 3rem; - } - .swagger-ui .f2-l { - font-size: 2.25rem; - } - .swagger-ui .f3-l { - font-size: 1.5rem; - } - .swagger-ui .f4-l { - font-size: 1.25rem; - } - .swagger-ui .f5-l { - font-size: 1rem; - } - .swagger-ui .f6-l { - font-size: 0.875rem; - } - .swagger-ui .f7-l { - font-size: 0.75rem; - } -} -.swagger-ui .measure { - max-width: 30em; -} -.swagger-ui .measure-wide { - max-width: 34em; -} -.swagger-ui .measure-narrow { - max-width: 20em; -} -.swagger-ui .indent { - margin-bottom: 0; - margin-top: 0; - text-indent: 1em; -} -.swagger-ui .small-caps { - font-feature-settings: "smcp"; - font-variant: small-caps; -} -.swagger-ui .truncate { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -@media screen and (min-width: 30em) { - .swagger-ui .measure-ns { - max-width: 30em; - } - .swagger-ui .measure-wide-ns { - max-width: 34em; - } - .swagger-ui .measure-narrow-ns { - max-width: 20em; - } - .swagger-ui .indent-ns { - margin-bottom: 0; - margin-top: 0; - text-indent: 1em; - } - .swagger-ui .small-caps-ns { - font-feature-settings: "smcp"; - font-variant: small-caps; - } - .swagger-ui .truncate-ns { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .measure-m { - max-width: 30em; - } - .swagger-ui .measure-wide-m { - max-width: 34em; - } - .swagger-ui .measure-narrow-m { - max-width: 20em; - } - .swagger-ui .indent-m { - margin-bottom: 0; - margin-top: 0; - text-indent: 1em; - } - .swagger-ui .small-caps-m { - font-feature-settings: "smcp"; - font-variant: small-caps; - } - .swagger-ui .truncate-m { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .measure-l { - max-width: 30em; - } - .swagger-ui .measure-wide-l { - max-width: 34em; - } - .swagger-ui .measure-narrow-l { - max-width: 20em; - } - .swagger-ui .indent-l { - margin-bottom: 0; - margin-top: 0; - text-indent: 1em; - } - .swagger-ui .small-caps-l { - font-feature-settings: "smcp"; - font-variant: small-caps; - } - .swagger-ui .truncate-l { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } -} -.swagger-ui .overflow-container { - overflow-y: scroll; -} -.swagger-ui .center { - margin-left: auto; - margin-right: auto; -} -.swagger-ui .mr-auto { - margin-right: auto; -} -.swagger-ui .ml-auto { - margin-left: auto; -} -@media screen and (min-width: 30em) { - .swagger-ui .center-ns { - margin-left: auto; - margin-right: auto; - } - .swagger-ui .mr-auto-ns { - margin-right: auto; - } - .swagger-ui .ml-auto-ns { - margin-left: auto; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .center-m { - margin-left: auto; - margin-right: auto; - } - .swagger-ui .mr-auto-m { - margin-right: auto; - } - .swagger-ui .ml-auto-m { - margin-left: auto; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .center-l { - margin-left: auto; - margin-right: auto; - } - .swagger-ui .mr-auto-l { - margin-right: auto; - } - .swagger-ui .ml-auto-l { - margin-left: auto; - } -} -.swagger-ui .clip { - position: fixed !important; - _position: absolute !important; - clip: rect(1px 1px 1px 1px); - clip: rect(1px, 1px, 1px, 1px); -} -@media screen and (min-width: 30em) { - .swagger-ui .clip-ns { - position: fixed !important; - _position: absolute !important; - clip: rect(1px 1px 1px 1px); - clip: rect(1px, 1px, 1px, 1px); - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .clip-m { - position: fixed !important; - _position: absolute !important; - clip: rect(1px 1px 1px 1px); - clip: rect(1px, 1px, 1px, 1px); - } -} -@media screen and (min-width: 60em) { - .swagger-ui .clip-l { - position: fixed !important; - _position: absolute !important; - clip: rect(1px 1px 1px 1px); - clip: rect(1px, 1px, 1px, 1px); - } -} -.swagger-ui .ws-normal { - white-space: normal; -} -.swagger-ui .nowrap { - white-space: nowrap; -} -.swagger-ui .pre { - white-space: pre; -} -@media screen and (min-width: 30em) { - .swagger-ui .ws-normal-ns { - white-space: normal; - } - .swagger-ui .nowrap-ns { - white-space: nowrap; - } - .swagger-ui .pre-ns { - white-space: pre; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .ws-normal-m { - white-space: normal; - } - .swagger-ui .nowrap-m { - white-space: nowrap; - } - .swagger-ui .pre-m { - white-space: pre; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .ws-normal-l { - white-space: normal; - } - .swagger-ui .nowrap-l { - white-space: nowrap; - } - .swagger-ui .pre-l { - white-space: pre; - } -} -.swagger-ui .v-base { - vertical-align: baseline; -} -.swagger-ui .v-mid { - vertical-align: middle; -} -.swagger-ui .v-top { - vertical-align: top; -} -.swagger-ui .v-btm { - vertical-align: bottom; -} -@media screen and (min-width: 30em) { - .swagger-ui .v-base-ns { - vertical-align: baseline; - } - .swagger-ui .v-mid-ns { - vertical-align: middle; - } - .swagger-ui .v-top-ns { - vertical-align: top; - } - .swagger-ui .v-btm-ns { - vertical-align: bottom; - } -} -@media screen and (min-width: 30em) and (max-width: 60em) { - .swagger-ui .v-base-m { - vertical-align: baseline; - } - .swagger-ui .v-mid-m { - vertical-align: middle; - } - .swagger-ui .v-top-m { - vertical-align: top; - } - .swagger-ui .v-btm-m { - vertical-align: bottom; - } -} -@media screen and (min-width: 60em) { - .swagger-ui .v-base-l { - vertical-align: baseline; - } - .swagger-ui .v-mid-l { - vertical-align: middle; - } - .swagger-ui .v-top-l { - vertical-align: top; - } - .swagger-ui .v-btm-l { - vertical-align: bottom; - } -} -.swagger-ui .dim { - opacity: 1; - transition: opacity 0.15s ease-in; -} -.swagger-ui .dim:focus, -.swagger-ui .dim:hover { - opacity: 0.5; - transition: opacity 0.15s ease-in; -} -.swagger-ui .dim:active { - opacity: 0.8; - transition: opacity 0.15s ease-out; -} -.swagger-ui .glow { - transition: opacity 0.15s ease-in; -} -.swagger-ui .glow:focus, -.swagger-ui .glow:hover { - opacity: 1; - transition: opacity 0.15s ease-in; -} -.swagger-ui .hide-child .child { - opacity: 0; - transition: opacity 0.15s ease-in; -} -.swagger-ui .hide-child:active .child, -.swagger-ui .hide-child:focus .child, -.swagger-ui .hide-child:hover .child { - opacity: 1; - transition: opacity 0.15s ease-in; -} -.swagger-ui .underline-hover:focus, -.swagger-ui .underline-hover:hover { - -webkit-text-decoration: underline; - text-decoration: underline; -} -.swagger-ui .grow { - -moz-osx-font-smoothing: grayscale; - backface-visibility: hidden; - transform: translateZ(0); - transition: transform 0.25s ease-out; -} -.swagger-ui .grow:focus, -.swagger-ui .grow:hover { - transform: scale(1.05); -} -.swagger-ui .grow:active { - transform: scale(0.9); -} -.swagger-ui .grow-large { - -moz-osx-font-smoothing: grayscale; - backface-visibility: hidden; - transform: translateZ(0); - transition: transform 0.25s ease-in-out; -} -.swagger-ui .grow-large:focus, -.swagger-ui .grow-large:hover { - transform: scale(1.2); -} -.swagger-ui .grow-large:active { - transform: scale(0.95); -} -.swagger-ui .pointer:hover { - cursor: pointer; -} -.swagger-ui .shadow-hover { - cursor: pointer; - position: relative; - transition: all 0.5s cubic-bezier(0.165, 0.84, 0.44, 1); -} -.swagger-ui .shadow-hover:after { - border-radius: inherit; - box-shadow: 0 0 16px 2px rgba(0, 0, 0, 0.2); - content: ""; - height: 100%; - left: 0; - opacity: 0; - position: absolute; - top: 0; - transition: opacity 0.5s cubic-bezier(0.165, 0.84, 0.44, 1); - width: 100%; - z-index: -1; -} -.swagger-ui .shadow-hover:focus:after, -.swagger-ui .shadow-hover:hover:after { - opacity: 1; -} -.swagger-ui .bg-animate, -.swagger-ui .bg-animate:focus, -.swagger-ui .bg-animate:hover { - transition: background-color 0.15s ease-in-out; -} -.swagger-ui .z-0 { - z-index: 0; -} -.swagger-ui .z-1 { - z-index: 1; -} -.swagger-ui .z-2 { - z-index: 2; -} -.swagger-ui .z-3 { - z-index: 3; -} -.swagger-ui .z-4 { - z-index: 4; -} -.swagger-ui .z-5 { - z-index: 5; -} -.swagger-ui .z-999 { - z-index: 999; -} -.swagger-ui .z-9999 { - z-index: 9999; -} -.swagger-ui .z-max { - z-index: 2147483647; -} -.swagger-ui .z-inherit { - z-index: inherit; -} -.swagger-ui .z-initial, -.swagger-ui .z-unset { - z-index: auto; -} -.swagger-ui .nested-copy-line-height ol, -.swagger-ui .nested-copy-line-height p, -.swagger-ui .nested-copy-line-height ul { - line-height: 1.5; -} -.swagger-ui .nested-headline-line-height h1, -.swagger-ui .nested-headline-line-height h2, -.swagger-ui .nested-headline-line-height h3, -.swagger-ui .nested-headline-line-height h4, -.swagger-ui .nested-headline-line-height h5, -.swagger-ui .nested-headline-line-height h6 { - line-height: 1.25; -} -.swagger-ui .nested-list-reset ol, -.swagger-ui .nested-list-reset ul { - list-style-type: none; - margin-left: 0; - padding-left: 0; -} -.swagger-ui .nested-copy-indent p + p { - margin-bottom: 0; - margin-top: 0; - text-indent: 0.1em; -} -.swagger-ui .nested-copy-seperator p + p { - margin-top: 1.5em; -} -.swagger-ui .nested-img img { - display: block; - max-width: 100%; - width: 100%; -} -.swagger-ui .nested-links a { - color: #357edd; - transition: color 0.15s ease-in; -} -.swagger-ui .nested-links a:focus, -.swagger-ui .nested-links a:hover { - color: #96ccff; - transition: color 0.15s ease-in; -} -.swagger-ui .wrapper { - box-sizing: border-box; - width: 100%; -} -.swagger-ui .opblock-tag-section { - display: flex; - flex-direction: column; -} -.swagger-ui .try-out.btn-group { - display: flex; - flex: 0.1 2 auto; - padding: 0; -} -.swagger-ui .try-out__btn { - margin-left: 1.25rem; -} -.swagger-ui .opblock-tag { - align-items: center; - border-bottom: 1px solid rgba(59, 65, 81, 0.3); - cursor: pointer; - display: flex; - padding: 10px 20px 10px 10px; - transition: all 0.2s; -} -.swagger-ui .opblock-tag:hover { - background: rgba(0, 0, 0, 0.02); -} -.swagger-ui .opblock-tag { - color: #3b4151; - font-family: sans-serif; - font-size: 24px; - margin: 0 0 5px; -} -.swagger-ui .opblock-tag.no-desc span { - flex: 1; -} -.swagger-ui .opblock-tag svg { - transition: all 0.4s; -} -.swagger-ui .opblock-tag small { - color: #3b4151; - flex: 2; - font-family: sans-serif; - font-size: 14px; - font-weight: 400; - padding: 0 10px; -} -.swagger-ui .opblock-tag > div { - flex: 1 1 150px; - font-weight: 400; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -@media (max-width: 640px) { - .swagger-ui .opblock-tag small, - .swagger-ui .opblock-tag > div { - flex: 1; - } -} -.swagger-ui .opblock-tag .info__externaldocs { - text-align: right; -} -.swagger-ui .parameter__type { - color: #3b4151; - font-family: monospace; - font-size: 12px; - font-weight: 600; - padding: 5px 0; -} -.swagger-ui .parameter-controls { - margin-top: 0.75em; -} -.swagger-ui .examples__title { - display: block; - font-size: 1.1em; - font-weight: 700; - margin-bottom: 0.75em; -} -.swagger-ui .examples__section { - margin-top: 1.5em; -} -.swagger-ui .examples__section-header { - font-size: 0.9rem; - font-weight: 700; - margin-bottom: 0.5rem; -} -.swagger-ui .examples-select { - display: inline-block; - margin-bottom: 0.75em; -} -.swagger-ui .examples-select .examples-select-element { - width: 100%; -} -.swagger-ui .examples-select__section-label { - font-size: 0.9rem; - font-weight: 700; - margin-right: 0.5rem; -} -.swagger-ui .example__section { - margin-top: 1.5em; -} -.swagger-ui .example__section-header { - font-size: 0.9rem; - font-weight: 700; - margin-bottom: 0.5rem; -} -.swagger-ui .view-line-link { - cursor: pointer; - margin: 0 5px; - position: relative; - top: 3px; - transition: all 0.5s; - width: 20px; -} -.swagger-ui .opblock { - border: 1px solid #000; - border-radius: 4px; - box-shadow: 0 0 3px rgba(0, 0, 0, 0.19); - margin: 0 0 15px; -} -.swagger-ui .opblock .tab-header { - display: flex; - flex: 1; -} -.swagger-ui .opblock .tab-header .tab-item { - cursor: pointer; - padding: 0 40px; -} -.swagger-ui .opblock .tab-header .tab-item:first-of-type { - padding: 0 40px 0 0; -} -.swagger-ui .opblock .tab-header .tab-item.active h4 span { - position: relative; -} -.swagger-ui .opblock .tab-header .tab-item.active h4 span:after { - background: gray; - bottom: -15px; - content: ""; - height: 4px; - left: 50%; - position: absolute; - transform: translateX(-50%); - width: 120%; -} -.swagger-ui .opblock.is-open .opblock-summary { - border-bottom: 1px solid #000; -} -.swagger-ui .opblock .opblock-section-header { - align-items: center; - background: hsla(0, 0%, 100%, 0.8); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); - display: flex; - min-height: 50px; - padding: 8px 20px; -} -.swagger-ui .opblock .opblock-section-header > label { - align-items: center; - color: #3b4151; - display: flex; - font-family: sans-serif; - font-size: 12px; - font-weight: 700; - margin: 0 0 0 auto; -} -.swagger-ui .opblock .opblock-section-header > label > span { - padding: 0 10px 0 0; -} -.swagger-ui .opblock .opblock-section-header h4 { - color: #3b4151; - flex: 1; - font-family: sans-serif; - font-size: 14px; - margin: 0; -} -.swagger-ui .opblock .opblock-summary-method { - background: #000; - border-radius: 3px; - color: #fff; - font-family: sans-serif; - font-size: 14px; - font-weight: 700; - min-width: 80px; - padding: 6px 0; - text-align: center; - text-shadow: 0 1px 0 rgba(0, 0, 0, 0.1); -} -@media (max-width: 768px) { - .swagger-ui .opblock .opblock-summary-method { - font-size: 12px; - } -} -.swagger-ui .opblock .opblock-summary-operation-id, -.swagger-ui .opblock .opblock-summary-path, -.swagger-ui .opblock .opblock-summary-path__deprecated { - align-items: center; - color: #3b4151; - display: flex; - font-family: monospace; - font-size: 16px; - font-weight: 600; - word-break: break-word; -} -@media (max-width: 768px) { - .swagger-ui .opblock .opblock-summary-operation-id, - .swagger-ui .opblock .opblock-summary-path, - .swagger-ui .opblock .opblock-summary-path__deprecated { - font-size: 12px; - } -} -.swagger-ui .opblock .opblock-summary-path { - flex-shrink: 1; -} -@media (max-width: 640px) { - .swagger-ui .opblock .opblock-summary-path { - max-width: 100%; - } -} -.swagger-ui .opblock .opblock-summary-path__deprecated { - -webkit-text-decoration: line-through; - text-decoration: line-through; -} -.swagger-ui .opblock .opblock-summary-operation-id { - font-size: 14px; -} -.swagger-ui .opblock .opblock-summary-description { - color: #3b4151; - font-family: sans-serif; - font-size: 13px; - word-break: break-word; -} -.swagger-ui .opblock .opblock-summary-path-description-wrapper { - align-items: center; - display: flex; - flex-direction: row; - flex-wrap: wrap; - gap: 0 10px; - padding: 0 10px; - width: 100%; -} -@media (max-width: 550px) { - .swagger-ui .opblock .opblock-summary-path-description-wrapper { - align-items: flex-start; - flex-direction: column; - } -} -.swagger-ui .opblock .opblock-summary { - align-items: center; - cursor: pointer; - display: flex; - padding: 5px; -} -.swagger-ui .opblock .opblock-summary .view-line-link { - cursor: pointer; - margin: 0; - position: relative; - top: 2px; - transition: all 0.5s; - width: 0; -} -.swagger-ui .opblock .opblock-summary:hover .view-line-link { - margin: 0 5px; - width: 18px; -} -.swagger-ui .opblock .opblock-summary:hover .view-line-link.copy-to-clipboard { - width: 24px; -} -.swagger-ui .opblock.opblock-post { - background: rgba(73, 204, 144, 0.1); - border-color: #49cc90; -} -.swagger-ui .opblock.opblock-post .opblock-summary-method { - background: #49cc90; -} -.swagger-ui .opblock.opblock-post .opblock-summary { - border-color: #49cc90; -} -.swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span:after { - background: #49cc90; -} -.swagger-ui .opblock.opblock-put { - background: rgba(252, 161, 48, 0.1); - border-color: #fca130; -} -.swagger-ui .opblock.opblock-put .opblock-summary-method { - background: #fca130; -} -.swagger-ui .opblock.opblock-put .opblock-summary { - border-color: #fca130; -} -.swagger-ui .opblock.opblock-put .tab-header .tab-item.active h4 span:after { - background: #fca130; -} -.swagger-ui .opblock.opblock-delete { - background: rgba(249, 62, 62, 0.1); - border-color: #f93e3e; -} -.swagger-ui .opblock.opblock-delete .opblock-summary-method { - background: #f93e3e; -} -.swagger-ui .opblock.opblock-delete .opblock-summary { - border-color: #f93e3e; -} -.swagger-ui .opblock.opblock-delete .tab-header .tab-item.active h4 span:after { - background: #f93e3e; -} -.swagger-ui .opblock.opblock-get { - background: rgba(97, 175, 254, 0.1); - border-color: #61affe; -} -.swagger-ui .opblock.opblock-get .opblock-summary-method { - background: #61affe; -} -.swagger-ui .opblock.opblock-get .opblock-summary { - border-color: #61affe; -} -.swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span:after { - background: #61affe; -} -.swagger-ui .opblock.opblock-patch { - background: rgba(80, 227, 194, 0.1); - border-color: #50e3c2; -} -.swagger-ui .opblock.opblock-patch .opblock-summary-method { - background: #50e3c2; -} -.swagger-ui .opblock.opblock-patch .opblock-summary { - border-color: #50e3c2; -} -.swagger-ui .opblock.opblock-patch .tab-header .tab-item.active h4 span:after { - background: #50e3c2; -} -.swagger-ui .opblock.opblock-head { - background: rgba(144, 18, 254, 0.1); - border-color: #9012fe; -} -.swagger-ui .opblock.opblock-head .opblock-summary-method { - background: #9012fe; -} -.swagger-ui .opblock.opblock-head .opblock-summary { - border-color: #9012fe; -} -.swagger-ui .opblock.opblock-head .tab-header .tab-item.active h4 span:after { - background: #9012fe; -} -.swagger-ui .opblock.opblock-options { - background: rgba(13, 90, 167, 0.1); - border-color: #0d5aa7; -} -.swagger-ui .opblock.opblock-options .opblock-summary-method { - background: #0d5aa7; -} -.swagger-ui .opblock.opblock-options .opblock-summary { - border-color: #0d5aa7; -} -.swagger-ui .opblock.opblock-options .tab-header .tab-item.active h4 span:after { - background: #0d5aa7; -} -.swagger-ui .opblock.opblock-deprecated { - background: hsla(0, 0%, 92%, 0.1); - border-color: #ebebeb; - opacity: 0.6; -} -.swagger-ui .opblock.opblock-deprecated .opblock-summary-method { - background: #ebebeb; -} -.swagger-ui .opblock.opblock-deprecated .opblock-summary { - border-color: #ebebeb; -} -.swagger-ui .opblock.opblock-deprecated .tab-header .tab-item.active h4 span:after { - background: #ebebeb; -} -.swagger-ui .opblock .opblock-schemes { - padding: 8px 20px; -} -.swagger-ui .opblock .opblock-schemes .schemes-title { - padding: 0 10px 0 0; -} -.swagger-ui .filter .operation-filter-input { - border: 2px solid #d8dde7; - margin: 20px 0; - padding: 10px; - width: 100%; -} -.swagger-ui .download-url-wrapper .failed, -.swagger-ui .filter .failed { - color: red; -} -.swagger-ui .download-url-wrapper .loading, -.swagger-ui .filter .loading { - color: #aaa; -} -.swagger-ui .model-example { - margin-top: 1em; -} -.swagger-ui .tab { - display: flex; - list-style: none; - padding: 0; -} -.swagger-ui .tab li { - color: #3b4151; - cursor: pointer; - font-family: sans-serif; - font-size: 12px; - min-width: 60px; - padding: 0; -} -.swagger-ui .tab li:first-of-type { - padding-left: 0; - padding-right: 12px; - position: relative; -} -.swagger-ui .tab li:first-of-type:after { - background: rgba(0, 0, 0, 0.2); - content: ""; - height: 100%; - position: absolute; - right: 6px; - top: 0; - width: 1px; -} -.swagger-ui .tab li.active { - font-weight: 700; -} -.swagger-ui .tab li button.tablinks { - background: none; - border: 0; - color: inherit; - font-family: inherit; - font-weight: inherit; - padding: 0; -} -.swagger-ui .opblock-description-wrapper, -.swagger-ui .opblock-external-docs-wrapper, -.swagger-ui .opblock-title_normal { - color: #3b4151; - font-family: sans-serif; - font-size: 12px; - margin: 0 0 5px; - padding: 15px 20px; -} -.swagger-ui .opblock-description-wrapper h4, -.swagger-ui .opblock-external-docs-wrapper h4, -.swagger-ui .opblock-title_normal h4 { - color: #3b4151; - font-family: sans-serif; - font-size: 12px; - margin: 0 0 5px; -} -.swagger-ui .opblock-description-wrapper p, -.swagger-ui .opblock-external-docs-wrapper p, -.swagger-ui .opblock-title_normal p { - color: #3b4151; - font-family: sans-serif; - font-size: 14px; - margin: 0; -} -.swagger-ui .opblock-external-docs-wrapper h4 { - padding-left: 0; -} -.swagger-ui .execute-wrapper { - padding: 20px; - text-align: right; -} -.swagger-ui .execute-wrapper .btn { - padding: 8px 40px; - width: 100%; -} -.swagger-ui .body-param-options { - display: flex; - flex-direction: column; -} -.swagger-ui .body-param-options .body-param-edit { - padding: 10px 0; -} -.swagger-ui .body-param-options label { - padding: 8px 0; -} -.swagger-ui .body-param-options label select { - margin: 3px 0 0; -} -.swagger-ui .responses-inner { - padding: 20px; -} -.swagger-ui .responses-inner h4, -.swagger-ui .responses-inner h5 { - color: #3b4151; - font-family: sans-serif; - font-size: 12px; - margin: 10px 0 5px; -} -.swagger-ui .responses-inner .curl { - max-height: 400px; - min-height: 6em; - overflow-y: auto; -} -.swagger-ui .response-col_status { - color: #3b4151; - font-family: sans-serif; - font-size: 14px; -} -.swagger-ui .response-col_status .response-undocumented { - color: #909090; - font-family: monospace; - font-size: 11px; - font-weight: 600; -} -.swagger-ui .response-col_links { - color: #3b4151; - font-family: sans-serif; - font-size: 14px; - max-width: 40em; - padding-left: 2em; -} -.swagger-ui .response-col_links .response-undocumented { - color: #909090; - font-family: monospace; - font-size: 11px; - font-weight: 600; -} -.swagger-ui .response-col_links .operation-link { - margin-bottom: 1.5em; -} -.swagger-ui .response-col_links .operation-link .description { - margin-bottom: 0.5em; -} -.swagger-ui .opblock-body .opblock-loading-animation { - display: block; - margin: 3em auto; -} -.swagger-ui .opblock-body pre.microlight { - background: #333; - border-radius: 4px; - font-size: 12px; - -webkit-hyphens: auto; - hyphens: auto; - margin: 0; - padding: 10px; - white-space: pre-wrap; - word-break: break-all; - word-break: break-word; - word-wrap: break-word; - color: #fff; - font-family: monospace; - font-weight: 600; -} -.swagger-ui .opblock-body pre.microlight .headerline { - display: block; -} -.swagger-ui .highlight-code { - position: relative; -} -.swagger-ui .highlight-code > .microlight { - max-height: 400px; - min-height: 6em; - overflow-y: auto; -} -.swagger-ui .highlight-code > .microlight code { - white-space: pre-wrap !important; - word-break: break-all; -} -.swagger-ui .curl-command { - position: relative; -} -.swagger-ui .download-contents { - align-items: center; - background: #7d8293; - border: none; - border-radius: 4px; - bottom: 10px; - color: #fff; - display: flex; - font-family: sans-serif; - font-size: 14px; - font-weight: 600; - height: 30px; - justify-content: center; - padding: 5px; - position: absolute; - right: 10px; - text-align: center; -} -.swagger-ui .scheme-container { - background: #fff; - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.15); - margin: 0 0 20px; - padding: 30px 20px; -} -.swagger-ui .scheme-container .schemes { - align-items: flex-end; - display: flex; - flex-wrap: wrap; - gap: 10px; - justify-content: space-between; -} -.swagger-ui .scheme-container .schemes > .schemes-server-container { - display: flex; - flex-wrap: wrap; - gap: 10px; -} -.swagger-ui .scheme-container .schemes > .schemes-server-container > label { - color: #3b4151; - display: flex; - flex-direction: column; - font-family: sans-serif; - font-size: 12px; - font-weight: 700; - margin: -20px 15px 0 0; -} -.swagger-ui .scheme-container .schemes > .schemes-server-container > label select { - min-width: 130px; - text-transform: uppercase; -} -.swagger-ui .scheme-container .schemes:not(:has(.schemes-server-container)) { - justify-content: flex-end; -} -.swagger-ui .scheme-container .schemes .auth-wrapper { - flex: none; - justify-content: start; -} -.swagger-ui .scheme-container .schemes .auth-wrapper .authorize { - display: flex; - flex-wrap: nowrap; - margin: 0; - padding-right: 20px; -} -.swagger-ui .loading-container { - align-items: center; - display: flex; - flex-direction: column; - justify-content: center; - margin-top: 1em; - min-height: 1px; - padding: 40px 0 60px; -} -.swagger-ui .loading-container .loading { - position: relative; -} -.swagger-ui .loading-container .loading:after { - color: #3b4151; - content: "loading"; - font-family: sans-serif; - font-size: 10px; - font-weight: 700; - left: 50%; - position: absolute; - text-transform: uppercase; - top: 50%; - transform: translate(-50%, -50%); -} -.swagger-ui .loading-container .loading:before { - animation: - rotation 1s linear infinite, - opacity 0.5s; - backface-visibility: hidden; - border: 2px solid rgba(85, 85, 85, 0.1); - border-radius: 100%; - border-top-color: rgba(0, 0, 0, 0.6); - content: ""; - display: block; - height: 60px; - left: 50%; - margin: -30px; - opacity: 1; - position: absolute; - top: 50%; - width: 60px; -} -@keyframes rotation { - to { - transform: rotate(1turn); - } -} -.swagger-ui .response-controls { - display: flex; - padding-top: 1em; -} -.swagger-ui .response-control-media-type { - margin-right: 1em; -} -.swagger-ui .response-control-media-type--accept-controller select { - border-color: green; -} -.swagger-ui .response-control-media-type__accept-message { - color: green; - font-size: 0.7em; -} -.swagger-ui .response-control-examples__title, -.swagger-ui .response-control-media-type__title { - display: block; - font-size: 0.7em; - margin-bottom: 0.2em; -} -@keyframes blinker { - 50% { - opacity: 0; - } -} -.swagger-ui .hidden { - display: none; -} -.swagger-ui .no-margin { - border: none; - height: auto; - margin: 0; - padding: 0; -} -.swagger-ui .float-right { - float: right; -} -.swagger-ui .svg-assets { - height: 0; - position: absolute; - width: 0; -} -.swagger-ui section h3 { - color: #3b4151; - font-family: sans-serif; -} -.swagger-ui a.nostyle { - display: inline; -} -.swagger-ui a.nostyle, -.swagger-ui a.nostyle:visited { - color: inherit; - cursor: pointer; - text-decoration: inherit; -} -.swagger-ui .fallback { - color: #aaa; - padding: 1em; -} -.swagger-ui .version-pragma { - height: 100%; - padding: 5em 0; -} -.swagger-ui .version-pragma__message { - display: flex; - font-size: 1.2em; - height: 100%; - justify-content: center; - line-height: 1.5em; - padding: 0 0.6em; - text-align: center; -} -.swagger-ui .version-pragma__message > div { - flex: 1; - max-width: 55ch; -} -.swagger-ui .version-pragma__message code { - background-color: #dedede; - padding: 4px 4px 2px; - white-space: pre; -} -.swagger-ui .opblock-link { - font-weight: 400; -} -.swagger-ui .opblock-link.shown { - font-weight: 700; -} -.swagger-ui span.token-string { - color: #555; -} -.swagger-ui span.token-not-formatted { - color: #555; - font-weight: 700; -} -.swagger-ui .btn { - background: transparent; - border: 2px solid gray; - border-radius: 4px; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); - color: #3b4151; - font-family: sans-serif; - font-size: 14px; - font-weight: 700; - padding: 5px 23px; - transition: all 0.3s; -} -.swagger-ui .btn.btn-sm { - font-size: 12px; - padding: 4px 23px; -} -.swagger-ui .btn[disabled] { - cursor: not-allowed; - opacity: 0.3; -} -.swagger-ui .btn:hover { - box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); -} -.swagger-ui .btn.cancel { - background-color: transparent; - border-color: #ff6060; - color: #ff6060; - font-family: sans-serif; -} -.swagger-ui .btn.authorize { - background-color: transparent; - border-color: #49cc90; - color: #49cc90; - display: inline; - line-height: 1; -} -.swagger-ui .btn.authorize span { - float: left; - padding: 4px 20px 0 0; -} -.swagger-ui .btn.authorize svg { - fill: #49cc90; -} -.swagger-ui .btn.execute { - background-color: #4990e2; - border-color: #4990e2; - color: #fff; -} -.swagger-ui .btn-group { - display: flex; - padding: 30px; -} -.swagger-ui .btn-group .btn { - flex: 1; -} -.swagger-ui .btn-group .btn:first-child { - border-radius: 4px 0 0 4px; -} -.swagger-ui .btn-group .btn:last-child { - border-radius: 0 4px 4px 0; -} -.swagger-ui .authorization__btn { - background: none; - border: none; - padding: 0 0 0 10px; -} -.swagger-ui .authorization__btn .locked { - opacity: 1; -} -.swagger-ui .authorization__btn .unlocked { - opacity: 0.4; -} -.swagger-ui .model-box-control, -.swagger-ui .models-control, -.swagger-ui .opblock-summary-control { - all: inherit; - border-bottom: 0; - cursor: pointer; - flex: 1; - padding: 0; -} -.swagger-ui .model-box-control:focus, -.swagger-ui .models-control:focus, -.swagger-ui .opblock-summary-control:focus { - outline: auto; -} -.swagger-ui .expand-methods, -.swagger-ui .expand-operation { - background: none; - border: none; -} -.swagger-ui .expand-methods svg, -.swagger-ui .expand-operation svg { - height: 20px; - width: 20px; -} -.swagger-ui .expand-methods { - padding: 0 10px; -} -.swagger-ui .expand-methods:hover svg { - fill: #404040; -} -.swagger-ui .expand-methods svg { - transition: all 0.3s; - fill: #707070; -} -.swagger-ui button { - cursor: pointer; -} -.swagger-ui button.invalid { - animation: shake 0.4s 1; - background: #feebeb; - border-color: #f93e3e; -} -.swagger-ui .copy-to-clipboard { - align-items: center; - background: #7d8293; - border: none; - border-radius: 4px; - bottom: 10px; - display: flex; - height: 30px; - justify-content: center; - position: absolute; - right: 100px; - width: 30px; -} -.swagger-ui .copy-to-clipboard button { - background: url('data:image/svg+xml;charset=utf-8,') - 50% no-repeat; - border: none; - flex-grow: 1; - flex-shrink: 1; - height: 25px; -} -.swagger-ui .copy-to-clipboard:active { - background: #5e626f; -} -.swagger-ui .opblock-control-arrow { - background: none; - border: none; - text-align: center; -} -.swagger-ui .curl-command .copy-to-clipboard { - bottom: 5px; - height: 20px; - right: 10px; - width: 20px; -} -.swagger-ui .curl-command .copy-to-clipboard button { - height: 18px; -} -.swagger-ui .opblock .opblock-summary .view-line-link.copy-to-clipboard { - height: 26px; - position: static; -} -.swagger-ui select { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - background: #f7f7f7 - url('data:image/svg+xml;charset=utf-8,') - right 10px center no-repeat; - background-size: 20px; - border: 2px solid #41444e; - border-radius: 4px; - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.25); - color: #3b4151; - font-family: sans-serif; - font-size: 14px; - font-weight: 700; - padding: 5px 40px 5px 10px; -} -.swagger-ui select[multiple] { - background: #f7f7f7; - margin: 5px 0; - padding: 5px; -} -.swagger-ui select.invalid { - animation: shake 0.4s 1; - background: #feebeb; - border-color: #f93e3e; -} -.swagger-ui .opblock-body select { - min-width: 230px; -} -@media (max-width: 768px) { - .swagger-ui .opblock-body select { - min-width: 180px; - } -} -@media (max-width: 640px) { - .swagger-ui .opblock-body select { - min-width: 100%; - width: 100%; - } -} -.swagger-ui label { - color: #3b4151; - font-family: sans-serif; - font-size: 12px; - font-weight: 700; - margin: 0 0 5px; -} -.swagger-ui input[type="email"], -.swagger-ui input[type="file"], -.swagger-ui input[type="password"], -.swagger-ui input[type="search"], -.swagger-ui input[type="text"] { - line-height: 1; -} -@media (max-width: 768px) { - .swagger-ui input[type="email"], - .swagger-ui input[type="file"], - .swagger-ui input[type="password"], - .swagger-ui input[type="search"], - .swagger-ui input[type="text"] { - max-width: 175px; - } -} -.swagger-ui input[type="email"], -.swagger-ui input[type="file"], -.swagger-ui input[type="password"], -.swagger-ui input[type="search"], -.swagger-ui input[type="text"], -.swagger-ui textarea { - background: #fff; - border: 1px solid #d9d9d9; - border-radius: 4px; - margin: 5px 0; - min-width: 100px; - padding: 8px 10px; -} -.swagger-ui input[type="email"].invalid, -.swagger-ui input[type="file"].invalid, -.swagger-ui input[type="password"].invalid, -.swagger-ui input[type="search"].invalid, -.swagger-ui input[type="text"].invalid, -.swagger-ui textarea.invalid { - animation: shake 0.4s 1; - background: #feebeb; - border-color: #f93e3e; -} -.swagger-ui input[disabled], -.swagger-ui select[disabled], -.swagger-ui textarea[disabled] { - background-color: #fafafa; - color: #888; - cursor: not-allowed; -} -.swagger-ui select[disabled] { - border-color: #888; -} -.swagger-ui textarea[disabled] { - background-color: #41444e; - color: #fff; -} -@keyframes shake { - 10%, - 90% { - transform: translate3d(-1px, 0, 0); - } - 20%, - 80% { - transform: translate3d(2px, 0, 0); - } - 30%, - 50%, - 70% { - transform: translate3d(-4px, 0, 0); - } - 40%, - 60% { - transform: translate3d(4px, 0, 0); - } -} -.swagger-ui textarea { - background: hsla(0, 0%, 100%, 0.8); - border: none; - border-radius: 4px; - color: #3b4151; - font-family: monospace; - font-size: 12px; - font-weight: 600; - min-height: 280px; - outline: none; - padding: 10px; - width: 100%; -} -.swagger-ui textarea:focus { - border: 2px solid #61affe; -} -.swagger-ui textarea.curl { - background: #41444e; - border-radius: 4px; - color: #fff; - font-family: monospace; - font-size: 12px; - font-weight: 600; - margin: 0; - min-height: 100px; - padding: 10px; - resize: none; -} -.swagger-ui .checkbox { - color: #303030; - padding: 5px 0 10px; - transition: opacity 0.5s; -} -.swagger-ui .checkbox label { - display: flex; -} -.swagger-ui .checkbox p { - color: #3b4151; - font-family: monospace; - font-style: italic; - font-weight: 400 !important; - font-weight: 600; - margin: 0 !important; -} -.swagger-ui .checkbox input[type="checkbox"] { - display: none; -} -.swagger-ui .checkbox input[type="checkbox"] + label > .item { - background: #e8e8e8; - border-radius: 1px; - box-shadow: 0 0 0 2px #e8e8e8; - cursor: pointer; - display: inline-block; - flex: none; - height: 16px; - margin: 0 8px 0 0; - padding: 5px; - position: relative; - top: 3px; - width: 16px; -} -.swagger-ui .checkbox input[type="checkbox"] + label > .item:active { - transform: scale(0.9); -} -.swagger-ui .checkbox input[type="checkbox"]:checked + label > .item { - background: #e8e8e8 - url('data:image/svg+xml;charset=utf-8,') - 50% no-repeat; -} -.swagger-ui .dialog-ux { - bottom: 0; - left: 0; - position: fixed; - right: 0; - top: 0; - z-index: 9999; -} -.swagger-ui .dialog-ux .backdrop-ux { - background: rgba(0, 0, 0, 0.8); - bottom: 0; - left: 0; - position: fixed; - right: 0; - top: 0; -} -.swagger-ui .dialog-ux .modal-ux { - background: #fff; - border: 1px solid #ebebeb; - border-radius: 4px; - box-shadow: 0 10px 30px 0 rgba(0, 0, 0, 0.2); - left: 50%; - max-width: 650px; - min-width: 300px; - position: absolute; - top: 50%; - transform: translate(-50%, -50%); - width: 100%; - z-index: 9999; -} -.swagger-ui .dialog-ux .modal-ux-content { - max-height: 540px; - overflow-y: auto; - padding: 20px; -} -.swagger-ui .dialog-ux .modal-ux-content p { - color: #41444e; - color: #3b4151; - font-family: sans-serif; - font-size: 12px; - margin: 0 0 5px; -} -.swagger-ui .dialog-ux .modal-ux-content h4 { - color: #3b4151; - font-family: sans-serif; - font-size: 18px; - font-weight: 600; - margin: 15px 0 0; -} -.swagger-ui .dialog-ux .modal-ux-header { - align-items: center; - border-bottom: 1px solid #ebebeb; - display: flex; - padding: 12px 0; -} -.swagger-ui .dialog-ux .modal-ux-header .close-modal { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - background: none; - border: none; - padding: 0 10px; -} -.swagger-ui .dialog-ux .modal-ux-header h3 { - color: #3b4151; - flex: 1; - font-family: sans-serif; - font-size: 20px; - font-weight: 600; - margin: 0; - padding: 0 20px; -} -.swagger-ui .model { - color: #3b4151; - font-family: monospace; - font-size: 12px; - font-weight: 300; - font-weight: 600; -} -.swagger-ui .model .deprecated span, -.swagger-ui .model .deprecated td { - color: #a0a0a0 !important; -} -.swagger-ui .model .deprecated > td:first-of-type { - -webkit-text-decoration: line-through; - text-decoration: line-through; -} -.swagger-ui .model-toggle { - cursor: pointer; - display: inline-block; - font-size: 10px; - margin: auto 0.3em; - position: relative; - top: 6px; - transform: rotate(90deg); - transform-origin: 50% 50%; - transition: transform 0.15s ease-in; -} -.swagger-ui .model-toggle.collapsed { - transform: rotate(0deg); -} -.swagger-ui .model-toggle:after { - background: url('data:image/svg+xml;charset=utf-8,') - 50% no-repeat; - background-size: 100%; - content: ""; - display: block; - height: 20px; - width: 20px; -} -.swagger-ui .model-jump-to-path { - cursor: pointer; - position: relative; -} -.swagger-ui .model-jump-to-path .view-line-link { - cursor: pointer; - position: absolute; - top: -0.4em; -} -.swagger-ui .model-title { - position: relative; -} -.swagger-ui .model-title:hover .model-hint { - visibility: visible; -} -.swagger-ui .model-hint { - background: rgba(0, 0, 0, 0.7); - border-radius: 4px; - color: #ebebeb; - padding: 0.1em 0.5em; - position: absolute; - top: -1.8em; - visibility: hidden; - white-space: nowrap; -} -.swagger-ui .model p { - margin: 0 0 1em; -} -.swagger-ui .model .property { - color: #999; - font-style: italic; -} -.swagger-ui .model .property.primitive { - color: #6b6b6b; -} -.swagger-ui .model .external-docs, -.swagger-ui table.model tr.description { - color: #666; - font-weight: 400; -} -.swagger-ui table.model tr.description td:first-child, -.swagger-ui table.model tr.property-row.required td:first-child { - font-weight: 700; -} -.swagger-ui table.model tr.property-row td { - vertical-align: top; -} -.swagger-ui table.model tr.property-row td:first-child { - padding-right: 0.2em; -} -.swagger-ui table.model tr.property-row .star { - color: red; -} -.swagger-ui table.model tr.extension { - color: #777; -} -.swagger-ui table.model tr.extension td:last-child { - vertical-align: top; -} -.swagger-ui table.model tr.external-docs td:first-child { - font-weight: 700; -} -.swagger-ui table.model tr .renderedMarkdown p:first-child { - margin-top: 0; -} -.swagger-ui section.models { - border: 1px solid rgba(59, 65, 81, 0.3); - border-radius: 4px; - margin: 30px 0; -} -.swagger-ui section.models .pointer { - cursor: pointer; -} -.swagger-ui section.models.is-open { - padding: 0 0 20px; -} -.swagger-ui section.models.is-open h4 { - border-bottom: 1px solid rgba(59, 65, 81, 0.3); - margin: 0 0 5px; -} -.swagger-ui section.models h4 { - align-items: center; - color: #606060; - cursor: pointer; - display: flex; - font-family: sans-serif; - font-size: 16px; - margin: 0; - padding: 10px 20px 10px 10px; - transition: all 0.2s; -} -.swagger-ui section.models h4 svg { - transition: all 0.4s; -} -.swagger-ui section.models h4 span { - flex: 1; -} -.swagger-ui section.models h4:hover { - background: rgba(0, 0, 0, 0.02); -} -.swagger-ui section.models h5 { - color: #707070; - font-family: sans-serif; - font-size: 16px; - margin: 0 0 10px; -} -.swagger-ui section.models .model-jump-to-path { - position: relative; - top: 5px; -} -.swagger-ui section.models .model-container { - background: rgba(0, 0, 0, 0.05); - border-radius: 4px; - margin: 0 20px 15px; - position: relative; - transition: all 0.5s; -} -.swagger-ui section.models .model-container:hover { - background: rgba(0, 0, 0, 0.07); -} -.swagger-ui section.models .model-container:first-of-type { - margin: 20px; -} -.swagger-ui section.models .model-container:last-of-type { - margin: 0 20px; -} -.swagger-ui section.models .model-container .models-jump-to-path { - opacity: 0.65; - position: absolute; - right: 5px; - top: 8px; -} -.swagger-ui section.models .model-box { - background: none; -} -.swagger-ui .model-box { - background: rgba(0, 0, 0, 0.1); - border-radius: 4px; - display: inline-block; - padding: 10px; -} -.swagger-ui .model-box .model-jump-to-path { - position: relative; - top: 4px; -} -.swagger-ui .model-box.deprecated { - opacity: 0.5; -} -.swagger-ui .model-title { - color: #505050; - font-family: sans-serif; - font-size: 16px; -} -.swagger-ui .model-title img { - bottom: 0; - margin-left: 1em; - position: relative; -} -.swagger-ui .model-deprecated-warning { - color: #f93e3e; - font-family: sans-serif; - font-size: 16px; - font-weight: 600; - margin-right: 1em; -} -.swagger-ui span > span.model .brace-close { - padding: 0 0 0 10px; -} -.swagger-ui .prop-name { - display: inline-block; - margin-right: 1em; -} -.swagger-ui .prop-type { - color: #55a; -} -.swagger-ui .prop-enum { - display: block; -} -.swagger-ui .prop-format { - color: #606060; -} -.swagger-ui .servers > label { - color: #3b4151; - font-family: sans-serif; - font-size: 12px; - margin: -20px 15px 0 0; -} -.swagger-ui .servers > label select { - max-width: 100%; - min-width: 130px; - width: 100%; -} -.swagger-ui .servers h4.message { - padding-bottom: 2em; -} -.swagger-ui .servers table tr { - width: 30em; -} -.swagger-ui .servers table td { - display: inline-block; - max-width: 15em; - padding-bottom: 10px; - padding-top: 10px; - vertical-align: middle; -} -.swagger-ui .servers table td:first-of-type { - padding-right: 1em; -} -.swagger-ui .servers table td input { - height: 100%; - width: 100%; -} -.swagger-ui .servers .computed-url { - margin: 2em 0; -} -.swagger-ui .servers .computed-url code { - display: inline-block; - font-size: 16px; - margin: 0 1em; - padding: 4px; -} -.swagger-ui .servers-title { - font-size: 12px; - font-weight: 700; -} -.swagger-ui .operation-servers h4.message { - margin-bottom: 2em; -} -.swagger-ui table { - border-collapse: collapse; - padding: 0 10px; - width: 100%; -} -.swagger-ui table.model tbody tr td { - padding: 0; - vertical-align: top; -} -.swagger-ui table.model tbody tr td:first-of-type { - padding: 0 0 0 2em; - width: 174px; -} -.swagger-ui table.headers td { - color: #3b4151; - font-family: monospace; - font-size: 12px; - font-weight: 300; - font-weight: 600; - vertical-align: middle; -} -.swagger-ui table.headers .header-example { - color: #999; - font-style: italic; -} -.swagger-ui table tbody tr td { - padding: 10px 0 0; - vertical-align: top; -} -.swagger-ui table tbody tr td:first-of-type { - min-width: 6em; - padding: 10px 0; -} -.swagger-ui table thead tr td, -.swagger-ui table thead tr th { - border-bottom: 1px solid rgba(59, 65, 81, 0.2); - color: #3b4151; - font-family: sans-serif; - font-size: 12px; - font-weight: 700; - padding: 12px 0; - text-align: left; -} -.swagger-ui .parameters-col_description { - margin-bottom: 2em; - width: 99%; -} -.swagger-ui .parameters-col_description input { - max-width: 340px; - width: 100%; -} -.swagger-ui .parameters-col_description select { - border-width: 1px; -} -.swagger-ui .parameters-col_description .markdown p, -.swagger-ui .parameters-col_description .renderedMarkdown p { - margin: 0; -} -.swagger-ui .parameter__name { - color: #3b4151; - font-family: sans-serif; - font-size: 16px; - font-weight: 400; - margin-right: 0.75em; -} -.swagger-ui .parameter__name.required { - font-weight: 700; -} -.swagger-ui .parameter__name.required span { - color: red; -} -.swagger-ui .parameter__name.required:after { - color: rgba(255, 0, 0, 0.6); - content: "required"; - font-size: 10px; - padding: 5px; - position: relative; - top: -6px; -} -.swagger-ui .parameter__extension, -.swagger-ui .parameter__in { - color: gray; - font-family: monospace; - font-size: 12px; - font-style: italic; - font-weight: 600; -} -.swagger-ui .parameter__deprecated { - color: red; - font-family: monospace; - font-size: 12px; - font-style: italic; - font-weight: 600; -} -.swagger-ui .parameter__empty_value_toggle { - display: block; - font-size: 13px; - padding-bottom: 12px; - padding-top: 5px; -} -.swagger-ui .parameter__empty_value_toggle input { - margin-right: 7px; - width: auto; -} -.swagger-ui .parameter__empty_value_toggle.disabled { - opacity: 0.7; -} -.swagger-ui .table-container { - padding: 20px; -} -.swagger-ui .response-col_description { - width: 99%; -} -.swagger-ui .response-col_description .markdown p, -.swagger-ui .response-col_description .renderedMarkdown p { - margin: 0; -} -.swagger-ui .response-col_links { - min-width: 6em; -} -.swagger-ui .response__extension { - color: gray; - font-family: monospace; - font-size: 12px; - font-style: italic; - font-weight: 600; -} -.swagger-ui .topbar { - background-color: #1b1b1b; - padding: 10px 0; -} -.swagger-ui .topbar .topbar-wrapper { - align-items: center; - display: flex; - flex-wrap: wrap; - gap: 10px; -} -@media (max-width: 550px) { - .swagger-ui .topbar .topbar-wrapper { - align-items: start; - flex-direction: column; - } -} -.swagger-ui .topbar a { - align-items: center; - color: #fff; - display: flex; - flex: 1; - font-family: sans-serif; - font-size: 1.5em; - font-weight: 700; - max-width: 300px; - -webkit-text-decoration: none; - text-decoration: none; -} -.swagger-ui .topbar a span { - margin: 0; - padding: 0 10px; -} -.swagger-ui .topbar .download-url-wrapper { - display: flex; - flex: 3; - justify-content: flex-end; -} -.swagger-ui .topbar .download-url-wrapper input[type="text"] { - border: 2px solid #62a03f; - border-radius: 4px 0 0 4px; - margin: 0; - max-width: 100%; - outline: none; - width: 100%; -} -.swagger-ui .topbar .download-url-wrapper .select-label { - align-items: center; - color: #f0f0f0; - display: flex; - margin: 0; - max-width: 600px; - width: 100%; -} -.swagger-ui .topbar .download-url-wrapper .select-label span { - flex: 1; - font-size: 16px; - padding: 0 10px 0 0; - text-align: right; -} -.swagger-ui .topbar .download-url-wrapper .select-label select { - border: 2px solid #62a03f; - box-shadow: none; - flex: 2; - outline: none; - width: 100%; -} -.swagger-ui .topbar .download-url-wrapper .download-url-button { - background: #62a03f; - border: none; - border-radius: 0 4px 4px 0; - color: #fff; - font-family: sans-serif; - font-size: 16px; - font-weight: 700; - padding: 4px 30px; -} -@media (max-width: 550px) { - .swagger-ui .topbar .download-url-wrapper { - width: 100%; - } -} -.swagger-ui .info { - margin: 50px 0; -} -.swagger-ui .info.failed-config { - margin-left: auto; - margin-right: auto; - max-width: 880px; - text-align: center; -} -.swagger-ui .info hgroup.main { - margin: 0 0 20px; -} -.swagger-ui .info hgroup.main a { - font-size: 12px; -} -.swagger-ui .info pre { - font-size: 14px; -} -.swagger-ui .info li, -.swagger-ui .info p, -.swagger-ui .info table { - color: #3b4151; - font-family: sans-serif; - font-size: 14px; -} -.swagger-ui .info h1, -.swagger-ui .info h2, -.swagger-ui .info h3, -.swagger-ui .info h4, -.swagger-ui .info h5 { - color: #3b4151; - font-family: sans-serif; -} -.swagger-ui .info a { - color: #4990e2; - font-family: sans-serif; - font-size: 14px; - transition: all 0.4s; -} -.swagger-ui .info a:hover { - color: #1f69c0; -} -.swagger-ui .info > div { - margin: 0 0 5px; -} -.swagger-ui .info .base-url { - color: #3b4151; - font-family: monospace; - font-size: 12px; - font-weight: 300 !important; - font-weight: 600; - margin: 0; -} -.swagger-ui .info .title { - color: #3b4151; - font-family: sans-serif; - font-size: 36px; - margin: 0; -} -.swagger-ui .info .title small { - background: #7d8492; - border-radius: 57px; - display: inline-block; - font-size: 10px; - margin: 0 0 0 5px; - padding: 2px 4px; - position: relative; - top: -5px; - vertical-align: super; -} -.swagger-ui .info .title small.version-stamp { - background-color: #89bf04; -} -.swagger-ui .info .title small pre { - color: #fff; - font-family: sans-serif; - margin: 0; - padding: 0; -} -.swagger-ui .auth-btn-wrapper { - display: flex; - justify-content: center; - padding: 10px 0; -} -.swagger-ui .auth-btn-wrapper .btn-done { - margin-right: 1em; -} -.swagger-ui .auth-wrapper { - display: flex; - flex: 1; - justify-content: flex-end; -} -.swagger-ui .auth-wrapper .authorize { - margin-left: 10px; - margin-right: 10px; - padding-right: 20px; -} -.swagger-ui .auth-container { - border-bottom: 1px solid #ebebeb; - margin: 0 0 10px; - padding: 10px 20px; -} -.swagger-ui .auth-container:last-of-type { - border: 0; - margin: 0; - padding: 10px 20px; -} -.swagger-ui .auth-container h4 { - margin: 5px 0 15px !important; -} -.swagger-ui .auth-container .wrapper { - margin: 0; - padding: 0; -} -.swagger-ui .auth-container input[type="password"], -.swagger-ui .auth-container input[type="text"] { - min-width: 230px; -} -.swagger-ui .auth-container .errors { - background-color: #fee; - border-radius: 4px; - color: red; - color: #3b4151; - font-family: monospace; - font-size: 12px; - font-weight: 600; - margin: 1em; - padding: 10px; -} -.swagger-ui .auth-container .errors b { - margin-right: 1em; - text-transform: capitalize; -} -.swagger-ui .scopes h2 { - color: #3b4151; - font-family: sans-serif; - font-size: 14px; -} -.swagger-ui .scopes h2 a { - color: #4990e2; - cursor: pointer; - font-size: 12px; - padding-left: 10px; - -webkit-text-decoration: underline; - text-decoration: underline; -} -.swagger-ui .scope-def { - padding: 0 0 20px; -} -.swagger-ui .errors-wrapper { - animation: scaleUp 0.5s; - background: rgba(249, 62, 62, 0.1); - border: 2px solid #f93e3e; - border-radius: 4px; - margin: 20px; - padding: 10px 20px; -} -.swagger-ui .errors-wrapper .error-wrapper { - margin: 0 0 10px; -} -.swagger-ui .errors-wrapper .errors h4 { - color: #3b4151; - font-family: monospace; - font-size: 14px; - font-weight: 600; - margin: 0; -} -.swagger-ui .errors-wrapper .errors small { - color: #606060; -} -.swagger-ui .errors-wrapper .errors .message { - white-space: pre-line; -} -.swagger-ui .errors-wrapper .errors .message.thrown { - max-width: 100%; -} -.swagger-ui .errors-wrapper .errors .error-line { - cursor: pointer; - -webkit-text-decoration: underline; - text-decoration: underline; -} -.swagger-ui .errors-wrapper hgroup { - align-items: center; - display: flex; -} -.swagger-ui .errors-wrapper hgroup h4 { - color: #3b4151; - flex: 1; - font-family: sans-serif; - font-size: 20px; - margin: 0; -} -@keyframes scaleUp { - 0% { - opacity: 0; - transform: scale(0.8); - } - to { - opacity: 1; - transform: scale(1); - } -} -.swagger-ui .Resizer.vertical.disabled { - display: none; -} -.swagger-ui .markdown p, -.swagger-ui .markdown pre, -.swagger-ui .renderedMarkdown p, -.swagger-ui .renderedMarkdown pre { - margin: 1em auto; - word-break: break-all; - word-break: break-word; -} -.swagger-ui .markdown pre, -.swagger-ui .renderedMarkdown pre { - background: none; - color: #000; - font-weight: 400; - padding: 0; - white-space: pre-wrap; -} -.swagger-ui .markdown code, -.swagger-ui .renderedMarkdown code { - background: rgba(0, 0, 0, 0.05); - border-radius: 4px; - color: #9012fe; - font-family: monospace; - font-size: 14px; - font-weight: 600; - padding: 5px 7px; -} -.swagger-ui .markdown pre > code, -.swagger-ui .renderedMarkdown pre > code { - display: block; -} -.swagger-ui .json-schema-2020-12 { - background-color: rgba(0, 0, 0, 0.05); - border-radius: 4px; - margin: 0 20px 15px; - padding: 12px 0 12px 20px; -} -.swagger-ui .json-schema-2020-12:first-of-type { - margin: 20px; -} -.swagger-ui .json-schema-2020-12:last-of-type { - margin: 0 20px; -} -.swagger-ui .json-schema-2020-12--embedded { - background-color: inherit; - padding-bottom: 0; - padding-left: inherit; - padding-right: inherit; - padding-top: 0; -} -.swagger-ui .json-schema-2020-12-body { - border-left: 1px dashed rgba(0, 0, 0, 0.1); - margin: 2px 0; -} -.swagger-ui .json-schema-2020-12-body--collapsed { - display: none; -} -.swagger-ui .json-schema-2020-12-accordion { - border: none; - outline: none; - padding-left: 0; -} -.swagger-ui .json-schema-2020-12-accordion__children { - display: inline-block; -} -.swagger-ui .json-schema-2020-12-accordion__icon { - display: inline-block; - height: 18px; - vertical-align: bottom; - width: 18px; -} -.swagger-ui .json-schema-2020-12-accordion__icon--expanded { - transform: rotate(-90deg); - transform-origin: 50% 50%; - transition: transform 0.15s ease-in; -} -.swagger-ui .json-schema-2020-12-accordion__icon--collapsed { - transform: rotate(0deg); - transform-origin: 50% 50%; - transition: transform 0.15s ease-in; -} -.swagger-ui .json-schema-2020-12-accordion__icon svg { - height: 20px; - width: 20px; -} -.swagger-ui .json-schema-2020-12-expand-deep-button { - border: none; - color: #505050; - color: #afaeae; - font-family: sans-serif; - font-size: 12px; - padding-right: 0; -} -.swagger-ui .json-schema-2020-12-keyword { - margin: 5px 0; -} -.swagger-ui .json-schema-2020-12-keyword__children { - border-left: 1px dashed rgba(0, 0, 0, 0.1); - margin: 0 0 0 20px; - padding: 0; -} -.swagger-ui .json-schema-2020-12-keyword__children--collapsed { - display: none; -} -.swagger-ui .json-schema-2020-12-keyword__name { - font-size: 12px; - font-weight: 700; - margin-left: 20px; -} -.swagger-ui .json-schema-2020-12-keyword__name--primary { - color: #3b4151; - font-style: normal; -} -.swagger-ui .json-schema-2020-12-keyword__name--secondary { - color: #6b6b6b; - font-style: italic; -} -.swagger-ui .json-schema-2020-12-keyword__value { - color: #6b6b6b; - font-size: 12px; - font-style: italic; - font-weight: 400; -} -.swagger-ui .json-schema-2020-12-keyword__value--primary { - color: #3b4151; - font-style: normal; -} -.swagger-ui .json-schema-2020-12-keyword__value--secondary { - color: #6b6b6b; - font-style: italic; -} -.swagger-ui .json-schema-2020-12-keyword__value--const, -.swagger-ui .json-schema-2020-12-keyword__value--warning { - border: 1px dashed #6b6b6b; - border-radius: 4px; - color: #3b4151; - color: #6b6b6b; - display: inline-block; - font-family: monospace; - font-style: normal; - font-weight: 600; - line-height: 1.5; - margin-left: 10px; - padding: 1px 4px; -} -.swagger-ui .json-schema-2020-12-keyword__value--warning { - border: 1px dashed red; - color: red; -} -.swagger-ui .json-schema-2020-12-keyword__name--secondary + .json-schema-2020-12-keyword__value--secondary:before { - content: "="; -} -.swagger-ui .json-schema-2020-12__attribute { - color: #3b4151; - font-family: monospace; - font-size: 12px; - padding-left: 10px; - text-transform: lowercase; -} -.swagger-ui .json-schema-2020-12__attribute--primary { - color: #55a; -} -.swagger-ui .json-schema-2020-12__attribute--muted { - color: gray; -} -.swagger-ui .json-schema-2020-12__attribute--warning { - color: red; -} -.swagger-ui .json-schema-2020-12-keyword--description { - color: #6b6b6b; - font-size: 12px; - margin-left: 20px; -} -.swagger-ui .json-schema-2020-12-keyword--description p { - margin: 0; -} -.swagger-ui .json-schema-2020-12__title { - color: #505050; - display: inline-block; - font-family: sans-serif; - font-size: 12px; - font-weight: 700; - line-height: normal; -} -.swagger-ui .json-schema-2020-12__title .json-schema-2020-12-keyword__name { - margin: 0; -} -.swagger-ui .json-schema-2020-12-property { - margin: 7px 0; -} -.swagger-ui .json-schema-2020-12-property .json-schema-2020-12__title { - color: #3b4151; - font-family: monospace; - font-size: 12px; - font-weight: 600; - vertical-align: middle; -} -.swagger-ui .json-schema-2020-12-keyword--properties > ul { - border: none; - margin: 0; - padding: 0; -} -.swagger-ui .json-schema-2020-12-property { - list-style-type: none; -} -.swagger-ui - .json-schema-2020-12-property--required - > .json-schema-2020-12:first-of-type - > .json-schema-2020-12-head - .json-schema-2020-12__title:after { - color: red; - content: "*"; - font-weight: 700; -} -.swagger-ui .json-schema-2020-12-keyword--patternProperties ul { - border: none; - margin: 0; - padding: 0; -} -.swagger-ui .json-schema-2020-12-keyword--patternProperties .json-schema-2020-12__title:first-of-type:after, -.swagger-ui .json-schema-2020-12-keyword--patternProperties .json-schema-2020-12__title:first-of-type:before { - color: #55a; - content: "/"; -} -.swagger-ui .json-schema-2020-12-keyword--enum > ul { - display: inline-block; - margin: 0; - padding: 0; -} -.swagger-ui .json-schema-2020-12-keyword--enum > ul li { - display: inline; - list-style-type: none; -} -.swagger-ui .json-schema-2020-12__constraint { - background-color: #805ad5; - border-radius: 4px; - color: #3b4151; - color: #fff; - font-family: monospace; - font-weight: 600; - line-height: 1.5; - margin-left: 10px; - padding: 1px 3px; -} -.swagger-ui .json-schema-2020-12__constraint--string { - background-color: #d69e2e; - color: #fff; -} -.swagger-ui .json-schema-2020-12-keyword--dependentRequired > ul { - display: inline-block; - margin: 0; - padding: 0; -} -.swagger-ui .json-schema-2020-12-keyword--dependentRequired > ul li { - display: inline; - list-style-type: none; -} -.swagger-ui - .model-box - .json-schema-2020-12:not(.json-schema-2020-12--embedded) - > .json-schema-2020-12-head - .json-schema-2020-12__title:first-of-type { - font-size: 16px; -} -.swagger-ui .model-box > .json-schema-2020-12 { - margin: 0; -} -.swagger-ui .model-box .json-schema-2020-12 { - background-color: transparent; - padding: 0; -} -.swagger-ui .model-box .json-schema-2020-12-accordion, -.swagger-ui .model-box .json-schema-2020-12-expand-deep-button { - background-color: transparent; -} -.swagger-ui - .models - .json-schema-2020-12:not(.json-schema-2020-12--embedded) - > .json-schema-2020-12-head - .json-schema-2020-12__title:first-of-type { - font-size: 16px; -} - -/*# sourceMappingURL=swagger-ui.css.map*/ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/backup/_components/backup-export-card.tsx b/apps/nextjs/src/app/[locale]/manage/tools/backup/_components/backup-export-card.tsx new file mode 100644 index 000000000..cdbe2055e --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/backup/_components/backup-export-card.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useState } from "react"; +import { Alert, Button, Card, Group, Stack, Text, Title } from "@mantine/core"; +import { IconAlertTriangle, IconDatabaseExport, IconDownload } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import { DangerousActionConfirmation } from "~/components/backup/dangerous-action-confirmation"; + +export const BackupExportCard = () => { + const t = useScopedI18n("management.page.tool.backup.export"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [showConfirm, setShowConfirm] = useState(false); + + const handleExport = async () => { + setLoading(true); + setError(null); + try { + const response = await fetch("/api/backup/export"); + if (!response.ok) { + const errorMessage = await response.json().then( + (data) => data.error, + () => `Server returned ${response.status}`, + ); + throw new Error(errorMessage ?? t("error")); + } + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const filename = response.headers.get("Content-Disposition")?.match(/filename="(.+)"/)?.[1] ?? "homarr-backup.zip"; + + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + setShowConfirm(false); + } catch (err) { + setError(err instanceof Error ? err.message : t("error")); + } finally { + setLoading(false); + } + }; + + return ( + + + + + {t("title")} + + + {t("description")} + + {error && ( + }> + {error} + + )} + + {showConfirm ? ( + } + cancelLabel={t("confirm.cancel")} + onConfirm={handleExport} + onCancel={() => setShowConfirm(false)} + disabled={loading} + color="orange" + /> + ) : ( + + + + )} + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/backup/_components/backup-import-card.tsx b/apps/nextjs/src/app/[locale]/manage/tools/backup/_components/backup-import-card.tsx new file mode 100644 index 000000000..794801976 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/backup/_components/backup-import-card.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { Card, Group, Stack, Text, Title } from "@mantine/core"; +import { IconDatabaseImport } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import { DatabaseRestoreFlow } from "~/components/backup"; + +export const BackupImportCard = () => { + const t = useScopedI18n("management.page.tool.backup.import"); + + return ( + + + + + {t("title")} + + + {t("description")} + + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/backup/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/backup/page.tsx new file mode 100644 index 000000000..80a794b57 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/backup/page.tsx @@ -0,0 +1,40 @@ +import { notFound } from "next/navigation"; +import { Stack, Title } from "@mantine/core"; + +import { auth } from "@homarr/auth/next"; +import { dbEnv } from "@homarr/core/infrastructure/db/env"; +import { getScopedI18n } from "@homarr/translation/server"; + +import { createMetaTitle } from "~/metadata"; +import { BackupExportCard } from "./_components/backup-export-card"; +import { BackupImportCard } from "./_components/backup-import-card"; + +export async function generateMetadata() { + const session = await auth(); + if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") { + return {}; + } + + const t = await getScopedI18n("management.page.tool.backup"); + + return { + title: createMetaTitle(t("metaTitle")), + }; +} + +export default async function BackupPage() { + const session = await auth(); + if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") { + notFound(); + } + + const t = await getScopedI18n("management.page.tool.backup"); + + return ( + + {t("title")} + + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx index f0a91d904..d2c1def97 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx @@ -1,23 +1,26 @@ "use client"; +import { useRouter } from "next/navigation"; import type { MantineColor } from "@mantine/core"; -import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core"; +import { ActionIcon, Avatar, Badge, Box, Button, Group, Menu, Text, Tooltip } from "@mantine/core"; import { IconCategoryPlus, + IconDots, + IconFileText, IconPlayerPlay, IconPlayerStop, IconRefresh, IconRotateClockwise, IconTrash, } from "@tabler/icons-react"; -import type { MRT_ColumnDef } from "mantine-react-table"; +import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table"; import { MantineReactTable } from "mantine-react-table"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; -import { useTimeAgo } from "@homarr/common"; +import { humanFileSize, useTimeAgo } from "@homarr/common"; import type { ContainerState } from "@homarr/docker"; -import { containerStateColorMap } from "@homarr/docker/shared"; +import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared"; import { useModalAction } from "@homarr/modals"; import { AddDockerAppToHomarr } from "@homarr/modals-collection"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; @@ -27,25 +30,30 @@ import type { TablerIcon } from "@homarr/ui"; import { OverflowBadge } from "@homarr/ui"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; -const createColumns = ( - t: TranslationFunction, -): MRT_ColumnDef[] => [ +type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number]; + +const createContainerLogsPath = (container: Pick) => + `/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`; + +const createColumns = (t: TranslationFunction): MRT_ColumnDef[] => [ { accessorKey: "name", header: t("docker.field.name.label"), Cell({ renderedCellValue, row }) { return ( - + {row.original.name.at(0)?.toUpperCase()} - {renderedCellValue} + + {renderedCellValue} + ); }, @@ -76,11 +84,11 @@ const createColumns = ( maxSize: 200, Cell({ renderedCellValue, cell }) { return ( - - - {renderedCellValue} - - + + + {renderedCellValue} + + ); }, }, @@ -95,13 +103,57 @@ const createColumns = ( ); }, }, + { + id: "cpuUsage", + accessorKey: "cpuUsage", + size: 120, + header: t("docker.field.stats.cpu.label"), + enableHiding: true, + sortingFn: (rowA, rowB) => { + const cpuUsageA = safeValue(rowA.original.cpuUsage); + const cpuUsageB = safeValue(rowB.original.cpuUsage); + + return cpuUsageA - cpuUsageB; + }, + Cell({ row }) { + const cpuUsage = safeValue(row.original.cpuUsage); + + return ( + + {cpuUsage.toFixed(2)}% + + ); + }, + }, + { + id: "memoryUsage", + accessorKey: "memoryUsage", + size: 140, + header: t("docker.field.stats.memory.label"), + enableHiding: true, + sortingFn: (rowA, rowB) => { + const memoryUsageA = safeValue(rowA.original.memoryUsage); + const memoryUsageB = safeValue(rowB.original.memoryUsage); + + return memoryUsageA - memoryUsageB; + }, + Cell({ row }) { + const bytesUsage = safeValue(row.original.memoryUsage); + + return ( + + {humanFileSize(bytesUsage)} + + ); + }, + }, ]; -export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"]) { +export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) { const t = useI18n(); const tDocker = useScopedI18n("docker"); const { data } = clientApi.docker.getContainers.useQuery(undefined, { - initialData, + initialData: { containers, timestamp }, refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, @@ -131,6 +183,8 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers" enableColumnFilters: false, enablePagination: false, enableRowSelection: true, + enableRowActions: true, + positionActionsColumn: "last", positionToolbarAlertBanner: "top", enableTableFooter: false, enableBottomToolbar: false, @@ -141,7 +195,10 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers" autoFocus: true, }, - initialState: { density: "xs", showGlobalFilter: true }, + initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } }, + mantineTableBodyCellProps: { style: { padding: "4px 8px" } }, + mantineTableHeadCellProps: { style: { padding: "4px 8px" } }, + renderRowActions: ({ row }: { row: MRT_Row }) => , renderTopToolbarCustomActions: () => ( diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/client.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/client.tsx new file mode 100644 index 000000000..4bc5de800 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/client.tsx @@ -0,0 +1,10 @@ +"use client"; + +import dynamic from "next/dynamic"; + +export const ClientSideDockerLogsTerminal = dynamic( + () => import("./terminal").then(({ DockerLogsTerminal }) => DockerLogsTerminal), + { + ssr: false, + }, +); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/page.tsx new file mode 100644 index 000000000..2c7a84521 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/page.tsx @@ -0,0 +1,47 @@ +import { notFound } from "next/navigation"; +import { Box, Group } from "@mantine/core"; + +import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; +import { env } from "@homarr/docker/env"; + +import "@xterm/xterm/css/xterm.css"; + +import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; +import { fullHeightWithoutHeaderAndFooter } from "~/constants"; +import { ClientSideDockerLogsTerminal } from "./client"; + +interface DockerContainerLogsPageProps { + params: Promise<{ containerId: string }>; + searchParams: Promise<{ name?: string }>; +} + +export default async function DockerContainerLogsPage({ params, searchParams }: DockerContainerLogsPageProps) { + const session = await auth(); + if (!(session?.user.permissions.includes("admin") && env.ENABLE_DOCKER)) { + notFound(); + } + + const { containerId } = await params; + const { name } = await searchParams; + + try { + await api.docker.logs({ id: containerId, tail: 1 }); + } catch { + notFound(); + } + + return ( + <> + + + + + + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.module.css b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.module.css new file mode 100644 index 000000000..c654cf6de --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.module.css @@ -0,0 +1,3 @@ +.outerTerminal > div { + height: 100%; +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.tsx new file mode 100644 index 000000000..a8d0e1fa8 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import { Box } from "@mantine/core"; +import { CanvasAddon } from "@xterm/addon-canvas"; +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal } from "@xterm/xterm"; + +import { clientApi } from "@homarr/api/client"; + +import classes from "./terminal.module.css"; + +interface DockerLogsTerminalProps { + containerId: string; +} + +export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) => { + const ref = useRef(null); + const terminalRef = useRef(null); + const pendingLogsRef = useRef([]); + const isTerminalReadyRef = useRef(false); + + const writeToTerminal = useCallback((data: string) => { + if (!isTerminalReadyRef.current || !terminalRef.current) { + pendingLogsRef.current.push(data); + return; + } + + terminalRef.current.write(data); + terminalRef.current.refresh(0, terminalRef.current.rows - 1); + }, []); + + clientApi.docker.subscribeLogs.useSubscription( + { id: containerId, tail: 200 }, + { + onData: writeToTerminal, + onError(err) { + writeToTerminal(`\r\n\x1b[31m${err.message}\x1b[0m`); + }, + }, + ); + + useEffect(() => { + if (!ref.current) { + return () => undefined; + } + + const canvasAddon = new CanvasAddon(); + + terminalRef.current = new Terminal({ + cursorBlink: false, + disableStdin: true, + convertEol: true, + fontSize: 14, + }); + terminalRef.current.open(ref.current); + terminalRef.current.loadAddon(canvasAddon); + + isTerminalReadyRef.current = true; + const terminal = terminalRef.current; + pendingLogsRef.current.forEach((data) => terminal.write(data)); + pendingLogsRef.current = []; + terminal.refresh(0, terminal.rows - 1); + + setTimeout(() => { + const fitAddon = new FitAddon(); + terminalRef.current?.loadAddon(fitAddon); + fitAddon.fit(); + }); + + return () => { + isTerminalReadyRef.current = false; + pendingLogsRef.current = []; + canvasAddon.dispose(); + terminalRef.current?.dispose(); + terminalRef.current = null; + }; + }, [writeToTerminal]); + + return ; +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/logs/font-size-slider.tsx b/apps/nextjs/src/app/[locale]/manage/tools/logs/font-size-slider.tsx new file mode 100644 index 000000000..d76c3df9d --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/logs/font-size-slider.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { Group, Slider, Text } from "@mantine/core"; +import { IconTextSize } from "@tabler/icons-react"; + +import { useLogContext } from "./log-context"; + +export const LogFontSizeSlider = () => { + const { fontSize, setFontSize } = useLogContext(); + + return ( + + + `${val}px`} + style={{ flex: 1 }} + size="xs" + /> + + {fontSize}px + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/logs/log-context.tsx b/apps/nextjs/src/app/[locale]/manage/tools/logs/log-context.tsx index 9a58ffaaa..2caa5da38 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/logs/log-context.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/logs/log-context.tsx @@ -6,10 +6,23 @@ import { createContext, useContext, useMemo, useState } from "react"; import type { LogLevel } from "@homarr/core/infrastructure/logs/constants"; import { logLevels } from "@homarr/core/infrastructure/logs/constants"; +const FONT_SIZE_KEY = "homarr-log-font-size"; +const DEFAULT_FONT_SIZE = 14; + +const readStoredFontSize = () => { + if (typeof window === "undefined") return DEFAULT_FONT_SIZE; + const stored = localStorage.getItem(FONT_SIZE_KEY); + if (!stored) return DEFAULT_FONT_SIZE; + const parsed = Number(stored); + return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 10), 24) : DEFAULT_FONT_SIZE; +}; + const LogContext = createContext<{ level: LogLevel; setLevel: (level: LogLevel) => void; activeLevels: LogLevel[]; + fontSize: number; + setFontSize: (size: number) => void; } | null>(null); interface LogContextProviderProps extends PropsWithChildren { @@ -18,9 +31,19 @@ interface LogContextProviderProps extends PropsWithChildren { export const LogContextProvider = ({ defaultLevel, children }: LogContextProviderProps) => { const [level, setLevel] = useState(defaultLevel); + const [fontSize, setFontSizeRaw] = useState(readStoredFontSize); const activeLevels = useMemo(() => logLevels.slice(0, logLevels.indexOf(level) + 1), [level]); - return {children}; + const setFontSize = (size: number) => { + setFontSizeRaw(size); + localStorage.setItem(FONT_SIZE_KEY, String(size)); + }; + + return ( + + {children} + + ); }; export const useLogContext = () => { diff --git a/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx index b6901934c..a981f2f57 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx @@ -13,6 +13,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { fullHeightWithoutHeaderAndFooter } from "~/constants"; import { createMetaTitle } from "~/metadata"; import { ClientSideTerminalComponent } from "./client"; +import { LogFontSizeSlider } from "./font-size-slider"; import { LogLevelSelection } from "./level-selection"; import { LogContextProvider } from "./log-context"; @@ -38,7 +39,10 @@ export default async function LogsManagementPage() { - + + + + diff --git a/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx b/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx index 9e8c569af..20f50ffd9 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx @@ -13,9 +13,11 @@ import classes from "./terminal.module.css"; export const TerminalComponent = () => { const ref = useRef(null); - const { activeLevels } = useLogContext(); + const { activeLevels, fontSize } = useLogContext(); const terminalRef = useRef(null); + const fitAddonRef = useRef(null); + clientApi.log.subscribe.useSubscription( { levels: activeLevels, @@ -26,7 +28,6 @@ export const TerminalComponent = () => { terminalRef.current?.refresh(0, terminalRef.current.rows - 1); }, onError(err) { - // This makes sense as logging might cause an infinite loop alert(err); }, }, @@ -43,22 +44,30 @@ export const TerminalComponent = () => { cursorBlink: false, disableStdin: true, convertEol: true, + fontSize, }); terminalRef.current.open(ref.current); terminalRef.current.loadAddon(canvasAddon); - // This is a hack to make sure the terminal is rendered before we try to fit it - // You can blame @Meierschlumpf for this setTimeout(() => { const fitAddon = new FitAddon(); + fitAddonRef.current = fitAddon; terminalRef.current?.loadAddon(fitAddon); fitAddon.fit(); }); return () => { - terminalRef.current?.dispose(); canvasAddon.dispose(); + terminalRef.current?.dispose(); + terminalRef.current = null; }; }, []); + + useEffect(() => { + if (!terminalRef.current) return; + terminalRef.current.options.fontSize = fontSize; + fitAddonRef.current?.fit(); + }, [fontSize]); + return ; }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx new file mode 100644 index 000000000..76f33a8db --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Badge, Button, Group, Stack, Text } from "@mantine/core"; +import { IconRefresh } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { showSuccessNotification } from "@homarr/notifications"; +import { useScopedI18n } from "@homarr/translation/client"; + +export const ResetTours = () => { + const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours"); + const utils = clientApi.useUtils(); + const { data: tourStatus } = clientApi.user.getTourStatus.useQuery(); + const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({ + onSuccess() { + showSuccessNotification({ + title: t("resetSuccess.title"), + message: t("resetSuccess.message"), + }); + void utils.user.getTourStatus.invalidate(); + }, + }); + + const tours = [ + { key: "management" as const, completed: tourStatus?.completedManageTour ?? false }, + { key: "dashboard" as const, completed: tourStatus?.completedBoardTour ?? false }, + ]; + + return ( + + + {tours.map(({ key, completed }) => ( + + {t(`name.${key}`)}: {completed ? t("status.completed") : t("status.notStarted")} + + ))} + + + {t("description")} + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx index cf68c20e2..cc20a3459 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx @@ -13,6 +13,7 @@ import { canAccessUserEditPage } from "../access"; import { DeleteUserButton } from "./_components/_delete-user-button"; import { UserGeneralSettingsForm } from "./_components/_general-settings-form"; import { UserProfileAvatarForm } from "./_components/_profile-avatar-form"; +import { ResetTours } from "./_components/_reset-tours"; interface Props { params: Promise<{ @@ -87,6 +88,13 @@ export default async function EditUserPage(props: Props) { + {session?.user.id === user.id && ( + + {tGeneral("item.onboardingTours.title")} + + + )} + ) { const session = await auth(); const t = await getI18n(); const tUser = await getScopedI18n("management.page.user"); - const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound); + const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound).catch(catchTrpcUnauthorized); if (!canAccessUserEditPage(session, user.id)) { notFound(); diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/security/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/security/page.tsx index f3098258b..cbd83c047 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/security/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/security/page.tsx @@ -5,7 +5,7 @@ import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; import { getScopedI18n } from "@homarr/translation/server"; -import { catchTrpcNotFound } from "~/errors/trpc-catch-error"; +import { catchTrpcNotFound, catchTrpcUnauthorized } from "~/errors/trpc-catch-error"; import { canAccessUserEditPage } from "../access"; import { ChangePasswordForm } from "./_components/_change-password-form"; @@ -23,7 +23,8 @@ export default async function UserSecurityPage(props: Props) { .getById({ userId: params.userId, }) - .catch(catchTrpcNotFound); + .catch(catchTrpcNotFound) + .catch(catchTrpcUnauthorized); if (!canAccessUserEditPage(session, user.id)) { notFound(); diff --git a/apps/nextjs/src/app/[locale]/manage/users/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/page.tsx index 4059b9972..6c33eaba9 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/page.tsx @@ -6,6 +6,7 @@ import { isProviderEnabled } from "@homarr/auth/server"; import { getScopedI18n } from "@homarr/translation/server"; import { Link } from "@homarr/ui"; +import { TourTarget } from "~/components/layout/header/tour-target"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { createMetaTitle } from "~/metadata"; @@ -38,14 +39,18 @@ export default async function UsersPage() { title={t("list.title")} primaryAction={ credentialsProviderEnabled ? ( - - {t("create.title")} - + + + {t("create.title")} + + ) : undefined } floatingPrimaryAction={credentialsProviderEnabled} > - + + + ); } diff --git a/apps/nextjs/src/app/api/backup/export/route.ts b/apps/nextjs/src/app/api/backup/export/route.ts new file mode 100644 index 000000000..cf612c337 --- /dev/null +++ b/apps/nextjs/src/app/api/backup/export/route.ts @@ -0,0 +1,75 @@ +import fs from "fs"; +import path from "path"; + +import { NextResponse } from "next/server"; +import AdmZip from "adm-zip"; +import Database from "better-sqlite3"; + +import { auth } from "@homarr/auth/next"; +import { env } from "@homarr/common/env"; +import { dbEnv } from "@homarr/core/infrastructure/db/env"; + +export async function GET() { + const session = await auth(); + if (!session?.user.permissions.includes("admin")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + + if (dbEnv.DRIVER !== "better-sqlite3") { + return NextResponse.json({ error: "SQLite backup is only available for SQLite databases" }, { status: 400 }); + } + + const dbPath = dbEnv.URL as string | undefined; + if (!dbPath || !fs.existsSync(dbPath)) { + return NextResponse.json({ error: "Database file not found" }, { status: 500 }); + } + + const tempPath = path.join(path.dirname(dbPath), "db.backup.sqlite"); + let sourceDb: InstanceType | null = null; + + try { + sourceDb = new Database(dbPath, { readonly: true }); + sourceDb.pragma("wal_checkpoint(TRUNCATE)"); + sourceDb.exec(`VACUUM INTO '${tempPath}'`); + sourceDb.close(); + sourceDb = null; + + const dbBuffer = fs.readFileSync(tempPath); + + const metadata = { + homarrVersion: process.env.HOMARR_VERSION ?? "unknown", + exportedAt: new Date().toISOString(), + dbDialect: "sqlite", + encryptionKey: env.SECRET_ENCRYPTION_KEY, + }; + + const zip = new AdmZip(); + zip.addFile("db.sqlite", dbBuffer); + zip.addFile("metadata.json", Buffer.from(JSON.stringify(metadata, null, 2))); + + const zipBuffer = zip.toBuffer(); + + const date = new Date().toISOString().split("T")[0]; + const filename = `homarr-backup-${date}.zip`; + + return new NextResponse(zipBuffer, { + status: 200, + headers: { + "Content-Type": "application/zip", + "Content-Disposition": `attachment; filename="${filename}"`, + "Content-Length": zipBuffer.length.toString(), + }, + }); + } catch (error) { + console.error("[backup/export] Export failed:", error); + const message = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json({ error: `Export failed: ${message}` }, { status: 500 }); + } finally { + sourceDb?.close(); + try { + if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); + } catch (cleanupErr) { + console.error("[backup/export] Failed to clean up temp file:", cleanupErr); + } + } +} diff --git a/apps/nextjs/src/app/api/backup/import/route.ts b/apps/nextjs/src/app/api/backup/import/route.ts new file mode 100644 index 000000000..32002014a --- /dev/null +++ b/apps/nextjs/src/app/api/backup/import/route.ts @@ -0,0 +1,194 @@ +import crypto from "crypto"; +import fs from "fs"; +import path from "path"; + +import { NextResponse } from "next/server"; +import AdmZip from "adm-zip"; +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; + +import { auth } from "@homarr/auth/next"; +import { env } from "@homarr/common/env"; +import { DB_CASING } from "@homarr/core/infrastructure/db/constants"; +import { dbEnv } from "@homarr/core/infrastructure/db/env"; +import { db } from "@homarr/db"; + +import { findMigrationsFolder } from "../shared"; + +const REQUIRED_ZIP_ENTRIES = ["db.sqlite", "metadata.json"] as const; +const ALGORITHM = "aes-256-cbc"; +const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/; + +const reEncryptSecrets = (tempDb: InstanceType, importedKeyHex: string) => { + const currentKeyHex = env.SECRET_ENCRYPTION_KEY; + if (importedKeyHex === currentKeyHex) return; + + if (!HEX_KEY_REGEX.test(importedKeyHex)) { + throw new Error("Imported encryption key is not a valid 64-character hex string"); + } + + const oldKey = Buffer.from(importedKeyHex, "hex"); + const newKey = Buffer.from(currentKeyHex, "hex"); + + const rows = tempDb.prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"').all() as { + integration_id: string; + kind: string; + value: string; + }[]; + + if (rows.length === 0) return; + + const updateStmt = tempDb.prepare('UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?'); + const transaction = tempDb.transaction(() => { + for (const row of rows) { + const parts = row.value.split("."); + if (parts.length !== 2) { + throw new Error( + `Malformed secret value for integration ${row.integration_id} (${row.kind}): expected "data.iv" format`, + ); + } + const [data, dataIv] = parts as [string, string]; + + try { + const iv = Buffer.from(dataIv, "hex"); + const encrypted = Buffer.from(data, "hex"); + const decipher = crypto.createDecipheriv(ALGORITHM, oldKey, iv); + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString(); + + const newIv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(ALGORITHM, newKey, newIv); + const newEncrypted = Buffer.concat([cipher.update(decrypted), cipher.final()]); + const newValue = `${newEncrypted.toString("hex")}.${newIv.toString("hex")}`; + + updateStmt.run(newValue, row.integration_id, row.kind); + } catch { + throw new Error(`Failed to re-encrypt secret for integration ${row.integration_id} (${row.kind})`); + } + } + }); + transaction(); +}; + +const isOnboardingActiveAsync = async (): Promise => { + const onboardingRow = await db.query.onboarding.findFirst(); + if (!onboardingRow) return false; + return onboardingRow.step === "start"; +}; + +export async function POST(req: Request) { + const session = await auth(); + const isAdmin = session?.user.permissions.includes("admin") ?? false; + const isOnboarding = !isAdmin && (await isOnboardingActiveAsync()); + + if (!isAdmin && !isOnboarding) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + + if (dbEnv.DRIVER !== "better-sqlite3") { + return NextResponse.json({ error: "SQLite restore is only available for SQLite databases" }, { status: 400 }); + } + + const dbPath = dbEnv.URL as string | undefined; + if (!dbPath) { + return NextResponse.json({ error: "Database path not configured" }, { status: 500 }); + } + + let formData: FormData; + try { + formData = await req.formData(); + } catch { + return NextResponse.json({ error: "Invalid form data" }, { status: 400 }); + } + + const file = formData.get("file"); + if (!(file instanceof File)) { + return NextResponse.json({ error: "No file provided" }, { status: 400 }); + } + + const arrayBuffer = await file.arrayBuffer(); + let zip: AdmZip; + try { + zip = new AdmZip(Buffer.from(arrayBuffer)); + } catch { + return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 }); + } + + const entries = zip.getEntries().map((e) => e.entryName); + const missingEntries = REQUIRED_ZIP_ENTRIES.filter((name) => !entries.includes(name)); + if (missingEntries.length > 0) { + return NextResponse.json({ error: `Invalid backup: missing ${missingEntries.join(", ")}` }, { status: 400 }); + } + + const migrationsFolder = findMigrationsFolder(); + if (!migrationsFolder) { + return NextResponse.json({ error: "Migration files not found" }, { status: 500 }); + } + + const tempPath = path.join(path.dirname(dbPath), "db.import.sqlite"); + let tempDb: InstanceType | null = null; + + try { + const dbEntry = zip.getEntry("db.sqlite")!; + fs.writeFileSync(tempPath, dbEntry.getData()); + + tempDb = new Database(tempPath); + const drizzleDb = drizzle(tempDb, { casing: DB_CASING }); + migrate(drizzleDb, { migrationsFolder }); + + const metadataEntry = zip.getEntry("metadata.json")!; + const metadata = JSON.parse(metadataEntry.getData().toString()); + + const rawKey = metadata.encryptionKey ?? zip.getEntry("encryption-key.txt")?.getData().toString().trim(); + const importedKey = typeof rawKey === "string" && rawKey.length > 0 ? rawKey : undefined; + + const secretCount = ( + tempDb.prepare('SELECT COUNT(*) as count FROM "integrationSecret"').get() as { count: number } + ).count; + + if (secretCount > 0 && !importedKey) { + throw new Error( + "Backup contains integration secrets but no encryption key. " + + "Cannot restore without the original SECRET_ENCRYPTION_KEY.", + ); + } + + if (importedKey) { + reEncryptSecrets(tempDb, importedKey); + } + + tempDb.close(); + tempDb = null; + + fs.renameSync(tempPath, dbPath); + + for (const suffix of ["-wal", "-shm"] as const) { + try { + const sidecar = `${dbPath}${suffix}`; + if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar); + } catch (cleanupErr) { + console.error(`Failed to remove ${suffix} file:`, cleanupErr); + } + } + + setTimeout(() => { + console.log("Database restored, restarting server..."); + process.exit(0); + }, 500); + + return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." }); + } catch (error) { + console.error("[backup/import] Restore failed:", error); + const message = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json({ error: `Restore failed: ${message}` }, { status: 500 }); + } finally { + tempDb?.close(); + if (fs.existsSync(tempPath)) { + try { + fs.unlinkSync(tempPath); + } catch { + // temp file may have been renamed already + } + } + } +} diff --git a/apps/nextjs/src/app/api/backup/migrations/route.ts b/apps/nextjs/src/app/api/backup/migrations/route.ts new file mode 100644 index 000000000..13724560d --- /dev/null +++ b/apps/nextjs/src/app/api/backup/migrations/route.ts @@ -0,0 +1,63 @@ +import fs from "fs"; +import path from "path"; + +import { NextResponse } from "next/server"; + +import { findMigrationsFolder } from "../shared"; + +interface JournalEntry { + idx: number; + version: string; + when: number; + tag: string; + breakpoints: boolean; +} + +interface MigrationFile { + idx: number; + tag: string; + sql: string; + when: number; +} + +export async function GET() { + try { + const migrationsFolder = findMigrationsFolder(); + if (!migrationsFolder) { + return NextResponse.json({ error: "Migration files not found" }, { status: 500 }); + } + + const journalPath = path.join(migrationsFolder, "meta", "_journal.json"); + if (!fs.existsSync(journalPath)) { + return NextResponse.json({ error: "Migration journal not found" }, { status: 500 }); + } + + const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8")) as { entries: JournalEntry[] }; + + const migrations: MigrationFile[] = []; + for (const entry of journal.entries) { + const sqlPath = path.join(migrationsFolder, `${entry.tag}.sql`); + if (!fs.existsSync(sqlPath)) continue; + + migrations.push({ + idx: entry.idx, + tag: entry.tag, + sql: fs.readFileSync(sqlPath, "utf-8"), + when: entry.when, + }); + } + + return NextResponse.json( + { total: migrations.length, migrations }, + { + headers: { + "Cache-Control": "public, max-age=3600", + }, + }, + ); + } catch (error) { + console.error("[backup/migrations] Failed to read migrations:", error); + const message = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json({ error: `Failed to read migrations: ${message}` }, { status: 500 }); + } +} diff --git a/apps/nextjs/src/app/api/backup/shared.ts b/apps/nextjs/src/app/api/backup/shared.ts new file mode 100644 index 000000000..58978a596 --- /dev/null +++ b/apps/nextjs/src/app/api/backup/shared.ts @@ -0,0 +1,12 @@ +import fs from "fs"; +import path from "path"; + +const migrationsFolderCandidates = [ + path.join(process.cwd(), "db/migrations/sqlite"), + path.join(process.cwd(), "packages/db/migrations/sqlite"), + path.resolve(process.cwd(), "../../db/migrations/sqlite"), + "/app/db/migrations/sqlite", +]; + +export const findMigrationsFolder = () => + migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate)); diff --git a/apps/nextjs/src/app/api/backup/sql-wasm/route.ts b/apps/nextjs/src/app/api/backup/sql-wasm/route.ts new file mode 100644 index 000000000..de5917ecb --- /dev/null +++ b/apps/nextjs/src/app/api/backup/sql-wasm/route.ts @@ -0,0 +1,30 @@ +import fs from "fs"; +import path from "path"; + +import { NextResponse } from "next/server"; + +const wasmCandidates = [ + path.join(process.cwd(), "public/sql-wasm.wasm"), + path.join(process.cwd(), "apps/nextjs/public/sql-wasm.wasm"), + path.resolve(process.cwd(), "../../node_modules/sql.js/dist/sql-wasm.wasm"), + path.join(process.cwd(), "node_modules/sql.js/dist/sql-wasm.wasm"), +]; + +let cachedWasm: Buffer | null = null; + +export async function GET() { + if (!cachedWasm) { + const found = wasmCandidates.find((p) => fs.existsSync(p)); + if (!found) { + return NextResponse.json({ error: "WASM binary not found" }, { status: 500 }); + } + cachedWasm = fs.readFileSync(found); + } + + return new NextResponse(cachedWasm, { + headers: { + "Content-Type": "application/wasm", + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); +} diff --git a/apps/nextjs/src/app/api/backup/test/backup.spec.ts b/apps/nextjs/src/app/api/backup/test/backup.spec.ts new file mode 100644 index 000000000..8e4e6039d --- /dev/null +++ b/apps/nextjs/src/app/api/backup/test/backup.spec.ts @@ -0,0 +1,308 @@ +import crypto from "crypto"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +import AdmZip from "adm-zip"; +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { DB_CASING } from "@homarr/core/infrastructure/db/constants"; + +const ALGORITHM = "aes-256-cbc"; +const MIGRATIONS_FOLDER = "./packages/db/migrations/sqlite"; + +const generateHexKey = () => crypto.randomBytes(32).toString("hex"); + +const encryptWithKey = (text: string, keyHex: string): `${string}.${string}` => { + const key = Buffer.from(keyHex, "hex"); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + let encrypted = cipher.update(text); + encrypted = Buffer.concat([encrypted, cipher.final()]); + return `${encrypted.toString("hex")}.${iv.toString("hex")}`; +}; + +const decryptWithKey = (value: `${string}.${string}`, keyHex: string): string => { + const key = Buffer.from(keyHex, "hex"); + const [data, dataIv] = value.split(".") as [string, string]; + const iv = Buffer.from(dataIv, "hex"); + const encrypted = Buffer.from(data, "hex"); + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + let decrypted = decipher.update(encrypted); + decrypted = Buffer.concat([decrypted, decipher.final()]); + return decrypted.toString(); +}; + +const createTestDb = (dbPath: string) => { + const sqlite = new Database(dbPath); + const db = drizzle(sqlite, { casing: DB_CASING }); + migrate(db, { migrationsFolder: MIGRATIONS_FOLDER }); + return { db, sqlite }; +}; + +describe("SQLite backup", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "homarr-backup-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe("export ZIP structure", () => { + it("should create a ZIP containing all required entries", () => { + const dbPath = path.join(tmpDir, "db.sqlite"); + const { sqlite } = createTestDb(dbPath); + + const backupPath = path.join(tmpDir, "db.backup.sqlite"); + sqlite.pragma("wal_checkpoint(TRUNCATE)"); + sqlite.exec(`VACUUM INTO '${backupPath}'`); + sqlite.close(); + + const dbBuffer = fs.readFileSync(backupPath); + const metadata = { + homarrVersion: "test", + exportedAt: new Date().toISOString(), + dbDialect: "sqlite", + encryptionKey: generateHexKey(), + }; + + const zip = new AdmZip(); + zip.addFile("db.sqlite", dbBuffer); + zip.addFile("metadata.json", Buffer.from(JSON.stringify(metadata))); + + const zipPath = path.join(tmpDir, "backup.zip"); + zip.writeZip(zipPath); + + const readBack = new AdmZip(zipPath); + const entryNames = readBack.getEntries().map((e) => e.entryName).sort(); + expect(entryNames).toEqual(["db.sqlite", "metadata.json"]); + + const dbEntry = readBack.getEntry("db.sqlite"); + expect(dbEntry).toBeDefined(); + expect(dbEntry!.header.size).toBeGreaterThan(0); + }); + + it("should produce a valid SQLite database in the ZIP", () => { + const dbPath = path.join(tmpDir, "db.sqlite"); + const { sqlite } = createTestDb(dbPath); + + const backupPath = path.join(tmpDir, "db.backup.sqlite"); + sqlite.exec(`VACUUM INTO '${backupPath}'`); + sqlite.close(); + + const backupDb = new Database(backupPath, { readonly: true }); + const tables = backupDb + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .all() as { name: string }[]; + + expect(tables.map((t) => t.name)).toContain("board"); + expect(tables.map((t) => t.name)).toContain("user"); + expect(tables.map((t) => t.name)).toContain("integrationSecret"); + + backupDb.close(); + }); + }); + + describe("import ZIP validation", () => { + it("should detect missing required entries", () => { + const zip = new AdmZip(); + zip.addFile("db.sqlite", Buffer.from("dummy")); + + const entries = zip.getEntries().map((e) => e.entryName); + const required = ["db.sqlite", "metadata.json"]; + const missing = required.filter((name) => !entries.includes(name)); + + expect(missing).toEqual(["metadata.json"]); + }); + + it("should accept valid backup ZIP with all entries", () => { + const zip = new AdmZip(); + zip.addFile("db.sqlite", Buffer.from("dummy")); + zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite", encryptionKey: generateHexKey() }))); + + const entries = zip.getEntries().map((e) => e.entryName); + const required = ["db.sqlite", "metadata.json"]; + const missing = required.filter((name) => !entries.includes(name)); + + expect(missing).toHaveLength(0); + }); + + it("should accept legacy backup ZIP with encryption-key.txt", () => { + const zip = new AdmZip(); + zip.addFile("db.sqlite", Buffer.from("dummy")); + zip.addFile("encryption-key.txt", Buffer.from(generateHexKey())); + zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite" }))); + + const entries = zip.getEntries().map((e) => e.entryName); + const required = ["db.sqlite", "metadata.json"]; + const missing = required.filter((name) => !entries.includes(name)); + + expect(missing).toHaveLength(0); + }); + }); + + describe("secret re-encryption", () => { + it("should re-encrypt secrets from old key to new key", () => { + const dbPath = path.join(tmpDir, "db.sqlite"); + const { sqlite } = createTestDb(dbPath); + + const oldKey = generateHexKey(); + const newKey = generateHexKey(); + const plaintext = "my-secret-password"; + + const encryptedValue = encryptWithKey(plaintext, oldKey); + + sqlite.exec(` + INSERT INTO "integration" ("id", "name", "url", "kind") + VALUES ('int-1', 'Test', 'http://test', 'adGuardHome') + `); + sqlite.exec(` + INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at") + VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)}) + `); + + const decryptedBefore = decryptWithKey(encryptedValue, oldKey); + expect(decryptedBefore).toBe(plaintext); + + const oldKeyBuf = Buffer.from(oldKey, "hex"); + const newKeyBuf = Buffer.from(newKey, "hex"); + + const rows = sqlite + .prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"') + .all() as { integration_id: string; kind: string; value: string }[]; + + const updateStmt = sqlite.prepare( + 'UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?', + ); + + for (const row of rows) { + const [data, dataIv] = row.value.split(".") as [string, string]; + const iv = Buffer.from(dataIv, "hex"); + const encrypted = Buffer.from(data, "hex"); + const decipher = crypto.createDecipheriv(ALGORITHM, oldKeyBuf, iv); + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString(); + + const newIv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(ALGORITHM, newKeyBuf, newIv); + const newEncrypted = Buffer.concat([cipher.update(decrypted), cipher.final()]); + const newValue = `${newEncrypted.toString("hex")}.${newIv.toString("hex")}`; + + updateStmt.run(newValue, row.integration_id, row.kind); + } + + const updatedRow = sqlite + .prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?') + .get("int-1") as { value: string }; + + const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey); + expect(decryptedAfter).toBe(plaintext); + + expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow(); + + sqlite.close(); + }); + + it("should skip re-encryption when keys match", () => { + const dbPath = path.join(tmpDir, "db.sqlite"); + const { sqlite } = createTestDb(dbPath); + + const sameKey = generateHexKey(); + const plaintext = "my-secret"; + const encryptedValue = encryptWithKey(plaintext, sameKey); + + sqlite.exec(` + INSERT INTO "integration" ("id", "name", "url", "kind") + VALUES ('int-1', 'Test', 'http://test', 'adGuardHome') + `); + sqlite.exec(` + INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at") + VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)}) + `); + + const rowBefore = sqlite + .prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?') + .get("int-1") as { value: string }; + + expect(rowBefore.value).toBe(encryptedValue); + + const decrypted = decryptWithKey(rowBefore.value as `${string}.${string}`, sameKey); + expect(decrypted).toBe(plaintext); + + sqlite.close(); + }); + }); + + describe("VACUUM INTO consistency", () => { + it("should produce a consistent copy via VACUUM INTO", () => { + const dbPath = path.join(tmpDir, "db.sqlite"); + const { sqlite } = createTestDb(dbPath); + + sqlite.exec(` + INSERT INTO "app" ("id", "name", "icon_url") + VALUES ('app-1', 'TestApp', 'https://example.com/icon.png') + `); + + const backupPath = path.join(tmpDir, "db.vacuum.sqlite"); + sqlite.pragma("wal_checkpoint(TRUNCATE)"); + sqlite.exec(`VACUUM INTO '${backupPath}'`); + + const backupDb = new Database(backupPath, { readonly: true }); + const app = backupDb.prepare('SELECT "name" FROM "app" WHERE "id" = ?').get("app-1") as { name: string } | undefined; + + expect(app).toBeDefined(); + expect(app!.name).toBe("TestApp"); + + backupDb.close(); + sqlite.close(); + }); + }); + + describe("migration on imported DB", () => { + it("should successfully migrate an imported database", () => { + const sourcePath = path.join(tmpDir, "source.sqlite"); + const { sqlite: sourceDb } = createTestDb(sourcePath); + + sourceDb.exec(` + INSERT INTO "board" ("id", "name", "is_public", "primary_color", "secondary_color", "opacity", "item_radius", "background_image_attachment", "background_image_repeat", "background_image_size", "disable_status") + VALUES ('board-1', 'TestBoard', 0, '#fa5252', '#fd7e14', 100, 'lg', 'fixed', 'no-repeat', 'cover', 0) + `); + sourceDb.close(); + + const importPath = path.join(tmpDir, "import.sqlite"); + fs.copyFileSync(sourcePath, importPath); + + const importDb = new Database(importPath); + const drizzleDb = drizzle(importDb, { casing: DB_CASING }); + migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); + + const board = importDb.prepare('SELECT "name" FROM "board" WHERE "id" = ?').get("board-1") as { name: string } | undefined; + expect(board).toBeDefined(); + expect(board!.name).toBe("TestBoard"); + + importDb.close(); + }); + }); + + describe("encryption key validation", () => { + it("should reject invalid hex keys", () => { + const invalidKeys = ["short", "not-hex-at-all-this-is-invalid-key-that-is-sixty-four-char!", ""]; + const hexKeyRegex = /^[0-9a-fA-F]{64}$/; + + for (const key of invalidKeys) { + expect(hexKeyRegex.test(key)).toBe(false); + } + }); + + it("should accept valid 64-char hex keys", () => { + const validKey = generateHexKey(); + const hexKeyRegex = /^[0-9a-fA-F]{64}$/; + expect(hexKeyRegex.test(validKey)).toBe(true); + }); + }); +}); diff --git a/apps/nextjs/src/app/api/mcp/[transport]/route.ts b/apps/nextjs/src/app/api/mcp/[transport]/route.ts new file mode 100644 index 000000000..2cfcfbb24 --- /dev/null +++ b/apps/nextjs/src/app/api/mcp/[transport]/route.ts @@ -0,0 +1,300 @@ +import { AsyncLocalStorage } from "async_hooks"; +import type { NextRequest } from "next/server"; +import { userAgent } from "next/server"; +import { createMcpHandler } from "mcp-handler"; +import type { McpTool } from "trpc-to-mcp"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; + +import { createTRPCContext, mcpRouter } from "@homarr/api/mcp"; +import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key"; +import { extractBaseUrlFromHeaders } from "@homarr/common"; +import { ipAddressFromHeaders } from "@homarr/common/server"; +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { db } from "@homarr/db"; + +import { getPackageVersion } from "~/versions/package-reader"; +import { extractMcpTools } from "../_extract-tools"; + +const logger = createLogger({ module: "mcpRoute" }); + +const AUTH_RATE_WINDOW_MS = 60_000; +const AUTH_MAX_FAILURES = 10; + +interface RateLimitStore { + authFailures: Map; + cleanupTimer: ReturnType | null; +} + +declare global { + var mcpRateLimit: RateLimitStore | undefined; +} + +const rateLimitStore = + globalThis.mcpRateLimit ?? + (globalThis.mcpRateLimit = { + authFailures: new Map(), + cleanupTimer: null, + }); +const authFailures = rateLimitStore.authFailures; + +if (!rateLimitStore.cleanupTimer) { + rateLimitStore.cleanupTimer = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of authFailures) { + if (entry.resetAt < now) authFailures.delete(key); + } + }, AUTH_RATE_WINDOW_MS); + rateLimitStore.cleanupTimer.unref(); +} + +function checkAuthRateLimit(ip: string | null): boolean { + const key = ip ?? "unknown"; + const now = Date.now(); + const entry = authFailures.get(key); + if (!entry || entry.resetAt < now) return true; + return entry.count < AUTH_MAX_FAILURES; +} + +function recordAuthFailure(ip: string | null) { + const key = ip ?? "unknown"; + const now = Date.now(); + const entry = authFailures.get(key); + if (!entry || entry.resetAt < now) { + authFailures.set(key, { count: 1, resetAt: now + AUTH_RATE_WINDOW_MS }); + return; + } + entry.count++; +} + +function clearAuthFailures(ip: string | null) { + authFailures.delete(ip ?? "unknown"); +} + +const callerStorage = new AsyncLocalStorage>(); + +const emptyObjectSchema = { + type: "object" as const, + properties: {}, + required: [] as string[], +}; + +let toolsCache: McpTool[] | null = null; + +function normalizeSchema(schema: McpTool["inputSchema"]) { + if (!schema) return emptyObjectSchema; + const properties = (schema.properties ?? {}) as Record>; + const required = ((schema.required as string[]) ?? []).filter((key) => !("default" in (properties[key] ?? {}))); + return { type: schema.type ?? ("object" as const), properties, required }; +} + +function getTools() { + if (!toolsCache) { + toolsCache = extractMcpTools().map((tool) => ({ + ...tool, + inputSchema: normalizeSchema(tool.inputSchema), + })); + logger.info(`Extracted ${toolsCache.length} MCP tools`); + } + return toolsCache; +} + +const SAFE_ERROR_MESSAGES: Record = { + UNAUTHORIZED: "You do not have permission to perform this action", + FORBIDDEN: "Access denied", + NOT_FOUND: "The requested resource was not found", + BAD_REQUEST: "Invalid request parameters", + INTERNAL_SERVER_ERROR: "An internal error occurred", + TIMEOUT: "The operation timed out", + TOO_MANY_REQUESTS: "Rate limit exceeded, try again later", + CONFLICT: "A conflict occurred with the current state", + PRECONDITION_FAILED: "A precondition for this operation was not met", +}; + +function sanitizeErrorMessage(error: unknown): string { + if (error instanceof Error && "code" in error) { + const trpcCode = (error as Error & { code: string }).code; + const safeMessage = SAFE_ERROR_MESSAGES[trpcCode]; + if (safeMessage) return safeMessage; + } + if (error instanceof Error && error.message.length < 200 && !error.message.includes("/")) { + return error.message; + } + return "Tool execution failed"; +} + +const SERVER_INSTRUCTIONS = `You are connected to Homarr, a self-hosted homelab dashboard. + +## Key Concepts + +**Integrations** are connections to external self-hosted services (Sonarr, Radarr, Plex, Overseerr, Pi-hole, Home Assistant, etc.). Each integration has an \`id\` (integrationId) and a \`kind\` (e.g. "sonarr", "radarr", "overseerr"). Use \`integration_all\` to list them. Many tools require an \`integrationId\` — always fetch it from \`integration_all\` or \`integration_search\` first. + +**Boards** are customizable dashboards containing widgets that display data from integrations. Users can have multiple boards. + +**Apps** are links/bookmarks to self-hosted services displayed on boards. They are separate from integrations — an app is a visual shortcut, an integration is a data connection. + +**Permissions**: Each integration result includes a \`permissions\` object: +- \`hasUseAccess: true\` means you can read data from this integration +- \`hasInteractAccess: true\` means you can perform actions (start/stop, request media, etc.) +- \`hasFullAccess: true\` means you can modify/delete the integration config +If \`hasUseAccess\` is false, the API key owner lacks permission for that integration — this is normal, not an error. Simply skip integrations where you lack the required permission level. + +## Common Workflows + +**"What's coming this week?"** → Use \`calendar_findAllEvents\` to get upcoming media releases from Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books). + +**"Search and request a movie/show"** → Use \`integration_searchMediaRequests\` to search across Overseerr/Jellyseerr, then \`integration_requestMedia\` to submit a request. Use \`integration_getMediaRequestOptions\` first for TV shows to pick seasons. + +**"What's streaming right now?"** → Use \`mediaServer_getCurrentStreams\` to see active Plex/Jellyfin/Emby streams. + +**"Check server health"** → Use \`healthMonitoring_getSystemHealthStatus\` for NAS/server metrics or \`healthMonitoring_getClusterHealthStatus\` for Proxmox clusters. + +**"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with container IDs. + +**"Control smart home"** → Use \`smartHome_entityState\` to check a Home Assistant entity, \`smartHome_switchEntity\` to toggle it, or \`smartHome_executeAutomation\` to trigger an automation. + +**"Block/unblock ads"** → Use \`dnsHole_summary\` for Pi-hole/AdGuard stats, \`dnsHole_disable\` to temporarily disable blocking, \`dnsHole_enable\` to re-enable. + +**"Check downloads"** → Use \`downloads_getJobsAndStatuses\` for queue status, \`downloads_pause\`/\`downloads_resume\` to control queues. + +**"Pending media requests"** → Use \`mediaRequests_getLatestRequests\` to see pending/approved/declined requests, \`mediaRequests_answerRequest\` to approve or decline. + +## Tips +- Always call \`integration_all\` first to discover available integrations and their IDs before using integration-dependent tools. +- The \`integrationId\` parameter in widget tools accepts the ID from \`integration_all\`, not the integration name or kind. +- If a tool returns empty results, the user may not have the required integration configured. +- Docker and Kubernetes tools work only if the Homarr instance has access to the Docker socket or Kubernetes cluster.`; + +const mcpHandler = createMcpHandler( + (server) => { + server.server.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: getTools(), + })); + + server.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + const tools = getTools(); + const tool = tools.find((t) => t.name === name); + + if (!tool) { + return { + content: [{ type: "text" as const, text: `Tool "${name}" not found` }], + isError: true, + }; + } + + const caller = callerStorage.getStore(); + if (!caller) { + return { + content: [{ type: "text" as const, text: "Authentication context not available" }], + isError: true, + }; + } + + try { + const procedure = tool.pathInRouter.reduce((acc, part) => acc?.[part], caller); + if (typeof procedure !== "function") { + return { + content: [{ type: "text" as const, text: `Tool "${name}" is not callable` }], + isError: true, + }; + } + const input = args && Object.keys(args).length > 0 ? args : undefined; + const result = await procedure(input); + return { + content: [{ type: "text" as const, text: JSON.stringify(result) }], + }; + } catch (error) { + const message = sanitizeErrorMessage(error); + logger.warn("MCP tool execution failed", { + tool: name, + error: error instanceof Error ? error.message : String(error), + }); + return { + content: [{ type: "text" as const, text: JSON.stringify({ error: message }) }], + isError: true, + }; + } + }); + }, + { + capabilities: { tools: {} }, + instructions: SERVER_INSTRUCTIONS, + serverInfo: { + name: "homarr", + version: getPackageVersion(), + }, + }, + { + basePath: "/api/mcp", + maxDuration: 60, + }, +); + +function extractApiKeyValue(req: NextRequest): string | null { + const apiKeyHeader = req.headers.get(API_KEY_HEADER_NAME); + if (apiKeyHeader) return apiKeyHeader; + + const authHeader = req.headers.get("Authorization"); + if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7); + + return null; +} + +function jsonErrorResponse(status: number, body: Record, extraHeaders: Record = {}) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...extraHeaders }, + }); +} + +const handler = async (req: NextRequest) => { + const apiKeyValue = extractApiKeyValue(req); + const ipAddress = ipAddressFromHeaders(req.headers); + const { ua } = userAgent(req); + + if (!checkAuthRateLimit(ipAddress)) { + return jsonErrorResponse( + 429, + { error: "rate_limited", hint: "Too many failed authentication attempts. Try again later." }, + { "Retry-After": "60" }, + ); + } + + if (!apiKeyValue) { + recordAuthFailure(ipAddress); + const baseUrl = extractBaseUrlFromHeaders(req.headers); + return jsonErrorResponse( + 401, + { + error: "unauthorized", + hint: "Authenticate with an ApiKey header or via OAuth at /.well-known/oauth-authorization-server", + }, + { "WWW-Authenticate": `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"` }, + ); + } + + if (!apiKeyValue.includes(".")) { + recordAuthFailure(ipAddress); + return jsonErrorResponse(401, { error: "invalid_token", hint: "The token must be in the format '.'." }); + } + + const session = await getSessionFromApiKeyAsync(db, apiKeyValue, ipAddress, ua); + + if (!session) { + recordAuthFailure(ipAddress); + return jsonErrorResponse(401, { + error: "invalid_token", + hint: "The API key was not found or the token is incorrect.", + }); + } + + clearAuthFailures(ipAddress); + logger.info("MCP request authenticated", { userId: session.user.id }); + + const ctx = createTRPCContext({ session, headers: req.headers }); + const caller = mcpRouter.createCaller(ctx); + + return callerStorage.run(caller, () => mcpHandler(req as unknown as Request)); +}; + +export { handler as GET, handler as POST, handler as DELETE }; diff --git a/apps/nextjs/src/app/api/mcp/_extract-tools.ts b/apps/nextjs/src/app/api/mcp/_extract-tools.ts new file mode 100644 index 000000000..431ab9116 --- /dev/null +++ b/apps/nextjs/src/app/api/mcp/_extract-tools.ts @@ -0,0 +1,23 @@ +import { extractToolsFromProcedures } from "trpc-to-mcp"; + +import { mcpRouter } from "@homarr/api/mcp"; + +let cache: ReturnType | null = null; + +export function extractMcpTools() { + if (cache) return cache; + + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + if (typeof args[0] === "string" && args[0].includes("[TRPC-TO-MCP]")) return; + originalWarn.apply(console, args); + }; + + try { + cache = extractToolsFromProcedures(mcpRouter); + } finally { + console.warn = originalWarn; + } + + return cache; +} diff --git a/apps/nextjs/src/app/api/mcp/oauth/_store.ts b/apps/nextjs/src/app/api/mcp/oauth/_store.ts new file mode 100644 index 000000000..14352a41b --- /dev/null +++ b/apps/nextjs/src/app/api/mcp/oauth/_store.ts @@ -0,0 +1,157 @@ +import { createHash, randomBytes, timingSafeEqual } from "crypto"; + +interface OAuthClient { + clientId: string; + clientSecret: string; + redirectUris: string[]; + clientName: string; + createdAt: number; +} + +interface AuthCode { + code: string; + clientId: string; + userId: string; + codeChallenge: string; + codeChallengeMethod: string; + redirectUri: string; + expiresAt: number; +} + +interface PendingAuth { + clientId: string; + redirectUri: string; + codeChallenge: string; + codeChallengeMethod: string; + state: string | null; + expiresAt: number; +} + +interface OAuthStore { + clients: Map; + authCodes: Map; + pendingAuths: Map; + cleanupTimer: ReturnType | null; +} + +declare global { + var mcpOAuthStore: OAuthStore | undefined; +} + +const store = + globalThis.mcpOAuthStore ?? + (globalThis.mcpOAuthStore = { + clients: new Map(), + authCodes: new Map(), + pendingAuths: new Map(), + cleanupTimer: null, + }); +const clients = store.clients; +const authCodes = store.authCodes; +const pendingAuths = store.pendingAuths; + +const CLEANUP_INTERVAL_MS = 60_000; +const AUTH_CODE_TTL_MS = 300_000; +const PENDING_AUTH_TTL_MS = 600_000; +const CLIENT_TTL_MS = 24 * 60 * 60 * 1000; + +const MAX_CLIENTS = 1000; +const MAX_AUTH_CODES = 5000; +const MAX_PENDING_AUTHS = 5000; + +if (!store.cleanupTimer) { + store.cleanupTimer = setInterval(() => { + const now = Date.now(); + for (const [key, code] of authCodes) { + if (code.expiresAt < now) authCodes.delete(key); + } + for (const [key, pending] of pendingAuths) { + if (pending.expiresAt < now) pendingAuths.delete(key); + } + for (const [key, client] of clients) { + if (client.createdAt + CLIENT_TTL_MS < now) clients.delete(key); + } + }, CLEANUP_INTERVAL_MS); +} + +export function registerClient(redirectUris: string[], clientName: string) { + if (clients.size >= MAX_CLIENTS) { + return null; + } + + const clientId = randomBytes(16).toString("hex"); + const clientSecret = randomBytes(32).toString("hex"); + const client: OAuthClient = { + clientId, + clientSecret, + redirectUris, + clientName, + createdAt: Date.now(), + }; + clients.set(clientId, client); + return { clientId, clientSecret }; +} + +export function getClient(clientId: string) { + return clients.get(clientId) ?? null; +} + +export function createAuthCode( + clientId: string, + userId: string, + codeChallenge: string, + codeChallengeMethod: string, + redirectUri: string, +) { + if (authCodes.size >= MAX_AUTH_CODES) { + return null; + } + + const code = randomBytes(32).toString("hex"); + authCodes.set(code, { + code, + clientId, + userId, + codeChallenge, + codeChallengeMethod, + redirectUri, + expiresAt: Date.now() + AUTH_CODE_TTL_MS, + }); + return code; +} + +export function consumeAuthCode(code: string) { + const authCode = authCodes.get(code); + if (!authCode) return null; + authCodes.delete(code); + if (authCode.expiresAt < Date.now()) return null; + return authCode; +} + +export function verifyPkce(codeVerifier: string, codeChallenge: string, method: string) { + if (method !== "S256") return false; + const computed = createHash("sha256").update(codeVerifier).digest("base64url"); + if (computed.length !== codeChallenge.length) return false; + return timingSafeEqual(Buffer.from(computed), Buffer.from(codeChallenge)); +} + +export function storePendingAuth(params: Omit) { + if (pendingAuths.size >= MAX_PENDING_AUTHS) { + return null; + } + + const id = randomBytes(16).toString("hex"); + pendingAuths.set(id, { + ...params, + expiresAt: Date.now() + PENDING_AUTH_TTL_MS, + }); + return id; +} + +export function consumePendingAuth(id: string) { + const pending = pendingAuths.get(id); + if (!pending) return null; + pendingAuths.delete(id); + if (pending.expiresAt < Date.now()) return null; + return pending; +} diff --git a/apps/nextjs/src/app/api/mcp/oauth/authorize/route.ts b/apps/nextjs/src/app/api/mcp/oauth/authorize/route.ts new file mode 100644 index 000000000..927cdf60f --- /dev/null +++ b/apps/nextjs/src/app/api/mcp/oauth/authorize/route.ts @@ -0,0 +1,143 @@ +import type { NextRequest } from "next/server"; + +import { auth } from "@homarr/auth/next"; + +import { consumePendingAuth, createAuthCode, getClient, storePendingAuth } from "../_store"; + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const session = await auth(); + + const pendingId = url.searchParams.get("pending"); + if (pendingId) { + if (!session?.user) { + return Response.json( + { + error: "access_denied", + error_description: "Login was not completed", + }, + { status: 403 }, + ); + } + + const pending = consumePendingAuth(pendingId); + if (!pending) { + return Response.json( + { + error: "invalid_request", + error_description: "Authorization request expired, please try again", + }, + { status: 400 }, + ); + } + + const code = createAuthCode( + pending.clientId, + session.user.id, + pending.codeChallenge, + pending.codeChallengeMethod, + pending.redirectUri, + ); + if (!code) { + return Response.json( + { + error: "server_error", + error_description: "Server is busy, please try again later", + }, + { status: 503 }, + ); + } + + const redirect = new URL(pending.redirectUri); + redirect.searchParams.set("code", code); + if (pending.state) redirect.searchParams.set("state", pending.state); + return Response.redirect(redirect.toString()); + } + + const clientId = url.searchParams.get("client_id"); + const redirectUri = url.searchParams.get("redirect_uri"); + const codeChallenge = url.searchParams.get("code_challenge"); + const codeChallengeMethod = url.searchParams.get("code_challenge_method") ?? "S256"; + const state = url.searchParams.get("state"); + + if (!clientId || !redirectUri || !codeChallenge) { + return Response.json( + { + error: "invalid_request", + error_description: "Missing required parameters", + }, + { status: 400 }, + ); + } + + if (codeChallengeMethod !== "S256") { + return Response.json( + { + error: "invalid_request", + error_description: "Only S256 code_challenge_method is supported", + }, + { status: 400 }, + ); + } + + const client = getClient(clientId); + if (!client) { + return Response.json( + { + error: "invalid_client", + error_description: + "Unknown client_id — the server was restarted and lost the registration. Please re-run the auth command to re-register.", + }, + { status: 400 }, + ); + } + + if (!client.redirectUris.includes(redirectUri)) { + return Response.json( + { + error: "invalid_request", + error_description: "redirect_uri not registered", + }, + { status: 400 }, + ); + } + + if (session?.user) { + const code = createAuthCode(clientId, session.user.id, codeChallenge, codeChallengeMethod, redirectUri); + if (!code) { + return Response.json( + { + error: "server_error", + error_description: "Server is busy, please try again later", + }, + { status: 503 }, + ); + } + + const redirect = new URL(redirectUri); + redirect.searchParams.set("code", code); + if (state) redirect.searchParams.set("state", state); + return Response.redirect(redirect.toString()); + } + + const id = storePendingAuth({ + clientId, + redirectUri, + codeChallenge, + codeChallengeMethod, + state, + }); + if (!id) { + return Response.json( + { + error: "server_error", + error_description: "Server is busy, please try again later", + }, + { status: 503 }, + ); + } + + const loginUrl = new URL("/auth/login", url.origin); + loginUrl.searchParams.set("callbackUrl", `/api/mcp/oauth/authorize?pending=${id}`); + return Response.redirect(loginUrl.toString()); +} diff --git a/apps/nextjs/src/app/api/mcp/oauth/register/route.ts b/apps/nextjs/src/app/api/mcp/oauth/register/route.ts new file mode 100644 index 000000000..af20cca49 --- /dev/null +++ b/apps/nextjs/src/app/api/mcp/oauth/register/route.ts @@ -0,0 +1,107 @@ +import { registerClient } from "../_store"; + +interface RateLimitStore { + attempts: Map; + timer: ReturnType | null; +} + +const REGISTER_RATE_WINDOW_MS = 60_000; +const REGISTER_MAX_ATTEMPTS = 5; + +declare global { + var mcpRegisterRateLimit: RateLimitStore | undefined; +} + +const store = + globalThis.mcpRegisterRateLimit ?? + (globalThis.mcpRegisterRateLimit = { + attempts: new Map(), + timer: null, + }); +const registerAttempts = store.attempts; + +if (!store.timer) { + store.timer = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of registerAttempts) { + if (entry.resetAt < now) registerAttempts.delete(key); + } + }, REGISTER_RATE_WINDOW_MS); + store.timer.unref(); +} + +function getClientIp(req: Request): string { + const forwarded = req.headers.get("x-forwarded-for"); + return forwarded?.split(",")[0]?.trim() ?? "unknown"; +} + +function isValidRedirectUri(uri: string): boolean { + try { + const parsed = new URL(uri); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +export async function POST(req: Request) { + const ip = getClientIp(req); + const now = Date.now(); + const entry = registerAttempts.get(ip); + + if (entry && entry.resetAt >= now && entry.count >= REGISTER_MAX_ATTEMPTS) { + return Response.json( + { error: "rate_limited", error_description: "Too many registration attempts, try again later" }, + { status: 429, headers: { "Retry-After": "60" } }, + ); + } + + const existing = registerAttempts.get(ip); + if (!existing || existing.resetAt < now) { + registerAttempts.set(ip, { count: 1, resetAt: now + REGISTER_RATE_WINDOW_MS }); + } else { + existing.count++; + } + + const body = (await req.json().catch(() => null)) as Record | null; + if (!body) { + return Response.json({ error: "invalid_request", error_description: "Invalid JSON body" }, { status: 400 }); + } + + const redirectUris = body.redirect_uris; + const clientName = typeof body.client_name === "string" ? body.client_name.slice(0, 256) : "MCP Client"; + + if (!Array.isArray(redirectUris) || redirectUris.length === 0 || redirectUris.length > 10) { + return Response.json( + { error: "invalid_client_metadata", error_description: "redirect_uris must be an array of 1-10 URIs" }, + { status: 400 }, + ); + } + + const validUris = redirectUris.filter((uri): uri is string => typeof uri === "string" && isValidRedirectUri(uri)); + if (validUris.length !== redirectUris.length) { + return Response.json( + { error: "invalid_client_metadata", error_description: "All redirect_uris must be valid http or https URLs" }, + { status: 400 }, + ); + } + + const result = registerClient(validUris, clientName); + if (!result) { + return Response.json( + { error: "server_error", error_description: "Too many registered clients, please try again later" }, + { status: 503 }, + ); + } + + return Response.json( + { + client_id: result.clientId, + client_secret: result.clientSecret, + redirect_uris: validUris, + client_name: clientName, + token_endpoint_auth_method: "none", + }, + { status: 201 }, + ); +} diff --git a/apps/nextjs/src/app/api/mcp/oauth/token/route.ts b/apps/nextjs/src/app/api/mcp/oauth/token/route.ts new file mode 100644 index 000000000..1122e1950 --- /dev/null +++ b/apps/nextjs/src/app/api/mcp/oauth/token/route.ts @@ -0,0 +1,143 @@ +import { randomBytes } from "crypto"; + +import { hashPasswordAsync } from "@homarr/auth"; +import { generateSecureRandomToken } from "@homarr/common/server"; +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { db } from "@homarr/db"; +import { apiKeys } from "@homarr/db/schema"; + +import { consumeAuthCode, verifyPkce } from "../_store"; + +const logger = createLogger({ module: "mcpOAuthToken" }); + +interface RateLimitStore { + failures: Map; + timer: ReturnType | null; +} + +const TOKEN_RATE_WINDOW_MS = 60_000; +const TOKEN_MAX_FAILURES = 10; + +declare global { + var mcpTokenRateLimit: RateLimitStore | undefined; +} + +const store = + globalThis.mcpTokenRateLimit ?? + (globalThis.mcpTokenRateLimit = { + failures: new Map(), + timer: null, + }); +const tokenFailures = store.failures; + +if (!store.timer) { + store.timer = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of tokenFailures) { + if (entry.resetAt < now) tokenFailures.delete(key); + } + }, TOKEN_RATE_WINDOW_MS); + store.timer.unref(); +} + +function getClientIp(req: Request): string { + const forwarded = req.headers.get("x-forwarded-for"); + return forwarded?.split(",")[0]?.trim() ?? "unknown"; +} + +function recordTokenFailure(ip: string) { + const now = Date.now(); + const existing = tokenFailures.get(ip); + if (!existing || existing.resetAt < now) { + tokenFailures.set(ip, { count: 1, resetAt: now + TOKEN_RATE_WINDOW_MS }); + return; + } + existing.count++; +} + +export async function POST(req: Request) { + const ip = getClientIp(req); + const now = Date.now(); + const entry = tokenFailures.get(ip); + + if (entry && entry.resetAt >= now && entry.count >= TOKEN_MAX_FAILURES) { + return Response.json( + { error: "rate_limited", error_description: "Too many requests, try again later" }, + { status: 429, headers: { "Retry-After": "60" } }, + ); + } + + let params: Record; + try { + const body = await req.formData().catch(() => null); + params = body + ? (Object.fromEntries(body.entries()) as Record) + : ((await req.json()) as Record); + } catch { + return Response.json( + { error: "invalid_request", error_description: "Could not parse request body" }, + { status: 400 }, + ); + } + + const { grant_type, code, code_verifier, redirect_uri } = params; + + if (grant_type !== "authorization_code") { + return Response.json({ error: "unsupported_grant_type" }, { status: 400 }); + } + + if (!code || !code_verifier) { + return Response.json( + { error: "invalid_request", error_description: "Missing code or code_verifier" }, + { status: 400 }, + ); + } + + if (code.length > 256 || code_verifier.length > 256) { + return Response.json({ error: "invalid_request", error_description: "Parameter too long" }, { status: 400 }); + } + + const authCode = consumeAuthCode(code); + if (!authCode) { + recordTokenFailure(ip); + return Response.json( + { error: "invalid_grant", error_description: "Invalid or expired authorization code" }, + { status: 400 }, + ); + } + + if (redirect_uri && redirect_uri !== authCode.redirectUri) { + recordTokenFailure(ip); + return Response.json({ error: "invalid_grant", error_description: "redirect_uri mismatch" }, { status: 400 }); + } + + if (!verifyPkce(code_verifier, authCode.codeChallenge, authCode.codeChallengeMethod)) { + recordTokenFailure(ip); + return Response.json({ error: "invalid_grant", error_description: "PKCE verification failed" }, { status: 400 }); + } + + try { + const id = randomBytes(4).toString("hex"); + const randomToken = generateSecureRandomToken(24); + const hashedToken = await hashPasswordAsync(randomToken); + + await db.insert(apiKeys).values({ + id, + apiKey: hashedToken, + userId: authCode.userId, + }); + + const accessToken = `${id}.${randomToken}`; + + return Response.json({ + access_token: accessToken, + token_type: "Bearer", + scope: "mcp:tools", + }); + } catch (error) { + logger.error("Failed to create API key during OAuth token exchange", { + error: error instanceof Error ? error.message : String(error), + }); + return Response.json({ error: "server_error", error_description: "Failed to issue access token" }, { status: 500 }); + } +} diff --git a/apps/nextjs/src/app/api/trpc/[trpc]/route.ts b/apps/nextjs/src/app/api/trpc/[trpc]/route.ts index 8ac6c166d..735d83e7d 100644 --- a/apps/nextjs/src/app/api/trpc/[trpc]/route.ts +++ b/apps/nextjs/src/app/api/trpc/[trpc]/route.ts @@ -44,7 +44,8 @@ const handler = auth(async (req) => { const response = await fetchRequestHandler({ endpoint: trpcPath, router: appRouter, - req, + // mcp-handler globally augments Request with `auth?: AuthInfo`, conflicting with NextAuth's `auth: Session | null` + req: req as unknown as Request, createContext: () => createTRPCContext({ session, headers: req.headers }), onError({ error, path, type }) { logger.error(new ErrorWithMetadata("tRPC Error occured", { path, type }, { cause: error })); diff --git a/apps/nextjs/src/components/backup/backup-preview-panel.tsx b/apps/nextjs/src/components/backup/backup-preview-panel.tsx new file mode 100644 index 000000000..98a5effc5 --- /dev/null +++ b/apps/nextjs/src/components/backup/backup-preview-panel.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { Badge, Group, Paper, Stack, Table, Text, ThemeIcon, Title } from "@mantine/core"; +import { IconCheck, IconDatabase, IconGitBranch } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import type { BackupAnalysis } from "./types"; +import { PREVIEW_TABLE_KEYS } from "./types"; + +interface BackupPreviewPanelProps { + analysis: BackupAnalysis; +} + +export const BackupPreviewPanel = ({ analysis }: BackupPreviewPanelProps) => { + const t = useScopedI18n("management.page.tool.backup.restore.preview"); + const exportDate = new Date(analysis.metadata.exportedAt).toLocaleString(); + const pendingCount = analysis.migrations.pending.length; + const hasPendingMigrations = pendingCount > 0; + + return ( + + + + + + + +
+ {t("title")} + + {t("exported", { date: exportDate })} + +
+ + Homarr {analysis.metadata.homarrVersion} + +
+ + + + + {t("entity")} + {t("count")} + + + + {PREVIEW_TABLE_KEYS.map((key) => ( + + {t(`entities.${key}` as never)} + + + {(analysis.counts[key] ?? 0) > 0 && ( + + )} + {analysis.counts[key] ?? 0} + + + + ))} + +
+ + {analysis.boardNames.length > 0 && ( + + {t("boards")}: + {analysis.boardNames.map((name) => ( + {name} + ))} + + )} +
+
+ + {hasPendingMigrations && ( + + + + + +
+ + {pendingCount === 1 + ? t("migrationsApplied", { count: String(pendingCount) }) + : t("migrationsAppliedPlural", { count: String(pendingCount) })} + + + {pendingCount === 1 + ? t("migrationsOlderVersion", { count: String(pendingCount) }) + : t("migrationsOlderVersionPlural", { count: String(pendingCount) })} + +
+ + {t("migrationsPending", { count: String(pendingCount) })} + +
+
+ )} + + {!hasPendingMigrations && ( + + + + + + + {t("migrationsUpToDate", { count: String(analysis.migrations.total) })} + + + + )} +
+ ); +}; diff --git a/apps/nextjs/src/components/backup/dangerous-action-confirmation.tsx b/apps/nextjs/src/components/backup/dangerous-action-confirmation.tsx new file mode 100644 index 000000000..3ebc71ce3 --- /dev/null +++ b/apps/nextjs/src/components/backup/dangerous-action-confirmation.tsx @@ -0,0 +1,91 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useState } from "react"; +import { Alert, Box, Button, Group, Paper, Stack, Text, TextInput } from "@mantine/core"; +import { IconAlertTriangle, IconShieldExclamation } from "@tabler/icons-react"; + +const CONFIRMATION_PHRASE = "I understand"; + +interface DangerousActionConfirmationProps { + title: string; + warningTitle: string; + warningBody: string; + typePrompt: string; + submitLabel: string; + submitIcon: ReactNode; + cancelLabel: string; + onConfirm: () => void; + onCancel: () => void; + disabled?: boolean; + color?: string; +} + +export const DangerousActionConfirmation = ({ + title, + warningTitle, + warningBody, + typePrompt, + submitLabel, + submitIcon, + cancelLabel, + onConfirm, + onCancel, + disabled, + color = "red", +}: DangerousActionConfirmationProps) => { + const [value, setValue] = useState(""); + const isMatch = value.trim().toLowerCase() === CONFIRMATION_PHRASE.toLowerCase(); + + return ( + + + + + + {title} + + + + }> + + {warningTitle} + {warningBody} + + + + + + {typePrompt} + + setValue(e.currentTarget.value)} + placeholder={CONFIRMATION_PHRASE} + disabled={disabled} + styles={{ + input: { + fontFamily: "var(--mantine-font-family-monospace)", + borderColor: isMatch ? "var(--mantine-color-green-6)" : undefined, + }, + }} + /> + + + + + + + + + ); +}; diff --git a/apps/nextjs/src/components/backup/database-restore-flow.tsx b/apps/nextjs/src/components/backup/database-restore-flow.tsx new file mode 100644 index 000000000..502b5d9f1 --- /dev/null +++ b/apps/nextjs/src/components/backup/database-restore-flow.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { Alert, Button, Group, Loader, rem, Stack, Text } from "@mantine/core"; +import type { FileWithPath } from "@mantine/dropzone"; +import { Dropzone, MIME_TYPES } from "@mantine/dropzone"; +import { IconAlertTriangle, IconArrowRight, IconFileZip, IconUpload, IconX } from "@tabler/icons-react"; + +import "@mantine/dropzone/styles.css"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import type { RestoreStep } from "./types"; +import { BackupPreviewPanel } from "./backup-preview-panel"; +import { MigrationProgressPanel } from "./migration-progress-panel"; +import { RestoreConfirmation } from "./restore-confirmation"; +import { RestoreProgressPanel } from "./restore-progress-panel"; +import { useBackupAnalysis } from "./use-backup-analysis"; + +interface DatabaseRestoreFlowProps { + variant?: "card" | "standalone"; + onRestoreComplete?: () => void; +} + +export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: DatabaseRestoreFlowProps) => { + const t = useScopedI18n("management.page.tool.backup.restore"); + const [file, setFile] = useState(null); + const [step, setStep] = useState("upload"); + const [importError, setImportError] = useState(null); + const apiDoneRef = useRef(false); + const animDoneRef = useRef(false); + const apiErrorRef = useRef(null); + const { analysis, loading, error: analysisError, migrationProgress, analyzeFile, reset } = useBackupAnalysis(); + + const handleFileDrop = useCallback( + (files: FileWithPath[]) => { + const droppedFile = files[0]; + if (!droppedFile) return; + setFile(droppedFile); + setStep("preview"); + setImportError(null); + void analyzeFile(droppedFile); + }, + [analyzeFile], + ); + + const handleClear = useCallback(() => { + setFile(null); + setStep("upload"); + setImportError(null); + apiDoneRef.current = false; + animDoneRef.current = false; + apiErrorRef.current = null; + reset(); + }, [reset]); + + const tryFinalize = useCallback(() => { + if (!apiDoneRef.current) return; + + if (apiErrorRef.current) { + setImportError(apiErrorRef.current); + setStep("error"); + return; + } + + if (!animDoneRef.current) return; + onRestoreComplete?.(); + window.location.reload(); + }, [onRestoreComplete]); + + const handleConfirm = useCallback(async () => { + if (!file) return; + setStep("restoring"); + setImportError(null); + apiDoneRef.current = false; + animDoneRef.current = false; + apiErrorRef.current = null; + + try { + const formData = new FormData(); + formData.append("file", file); + const response = await fetch("/api/backup/import", { method: "POST", body: formData }); + + if (!response.ok) { + try { + const data = await response.json(); + apiErrorRef.current = data.error ?? t("failed.title"); + } catch { + apiErrorRef.current = `Server returned ${response.status}`; + } + } + } catch { + apiErrorRef.current = t("failed.title"); + } finally { + apiDoneRef.current = true; + tryFinalize(); + } + }, [file, t, tryFinalize]); + + const handleAnimationComplete = useCallback(() => { + animDoneRef.current = true; + tryFinalize(); + }, [tryFinalize]); + + if (step === "upload") { + return ( + + + + + + + + + + + +
+ + {t("dropzone.title")} + + + {t("dropzone.description")} + +
+
+
+ ); + } + + if (step === "preview") { + return ( + + + + +
+ {file?.name} + {file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : ""} +
+
+ +
+ + {loading && migrationProgress && ( + + )} + + {loading && !migrationProgress && ( + + + {t("analyzing")} + + )} + + {analysisError && ( + } title={t("analysisError")}> + {analysisError} + + )} + + {analysis && ( + <> + + + + + + )} +
+ ); + } + + if (step === "confirm") { + return ( + + {analysis && } + setStep("preview")} + /> + + ); + } + + if (step === "restoring") { + return ( + + ); + } + + if (step === "error") { + return ( + + } title={t("failed.title")} radius="md"> + {importError} + + + + + + ); + } + + return null; +}; diff --git a/apps/nextjs/src/components/backup/index.ts b/apps/nextjs/src/components/backup/index.ts new file mode 100644 index 000000000..accd1a935 --- /dev/null +++ b/apps/nextjs/src/components/backup/index.ts @@ -0,0 +1,3 @@ +export { DatabaseRestoreFlow } from "./database-restore-flow"; +export { DangerousActionConfirmation } from "./dangerous-action-confirmation"; +export type { BackupAnalysis, RestoreStep } from "./types"; diff --git a/apps/nextjs/src/components/backup/migration-progress-panel.tsx b/apps/nextjs/src/components/backup/migration-progress-panel.tsx new file mode 100644 index 000000000..8b0cd7bfc --- /dev/null +++ b/apps/nextjs/src/components/backup/migration-progress-panel.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core"; +import { IconCheck, IconDatabase, IconLoader } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import type { MigrationProgress } from "./use-backup-analysis"; + +interface MigrationProgressPanelProps { + progress: MigrationProgress; +} + +export const MigrationProgressPanel = ({ progress }: MigrationProgressPanelProps) => { + const t = useScopedI18n("management.page.tool.backup.restore.migration"); + + return ( + + + + + + + + {t("title", { current: String(progress.current), total: String(progress.total) })} + + + + + + {Array.from({ length: progress.total }, (_, i) => { + const isDone = i < progress.current - 1 || (i === progress.current - 1 && progress.phase === "done"); + const isActive = i === progress.current - 1 && progress.phase === "applying"; + + return ( + + ) : isActive ? ( + + ) : ( + + ) + } + title={ + + {i === progress.current - 1 ? progress.tag : t("migrationLabel", { index: String(i + 1) })} + + } + /> + ); + })} + + + + + ); +}; diff --git a/apps/nextjs/src/components/backup/restore-confirmation.tsx b/apps/nextjs/src/components/backup/restore-confirmation.tsx new file mode 100644 index 000000000..a5feff6c8 --- /dev/null +++ b/apps/nextjs/src/components/backup/restore-confirmation.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { IconDatabaseImport } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import { DangerousActionConfirmation } from "./dangerous-action-confirmation"; + +interface RestoreConfirmationProps { + onConfirm: () => void; + onCancel: () => void; + disabled?: boolean; +} + +export const RestoreConfirmation = ({ onConfirm, onCancel, disabled }: RestoreConfirmationProps) => { + const t = useScopedI18n("management.page.tool.backup.restore.confirm"); + + return ( + } + cancelLabel={t("cancel")} + onConfirm={onConfirm} + onCancel={onCancel} + disabled={disabled} + /> + ); +}; diff --git a/apps/nextjs/src/components/backup/restore-progress-panel.tsx b/apps/nextjs/src/components/backup/restore-progress-panel.tsx new file mode 100644 index 000000000..4faa397be --- /dev/null +++ b/apps/nextjs/src/components/backup/restore-progress-panel.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core"; +import { + IconCheck, + IconDatabaseImport, + IconFileZip, + IconKey, + IconLoader, + IconRefresh, + IconTransform, +} from "@tabler/icons-react"; +import type { Icon } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import type { MigrationFile } from "./types"; + +const BASE_PHASE_DURATIONS: Record = { + extracting: 1200, + encrypting: 1500, + swapping: 800, + restarting: 10000, +}; + +const MIGRATION_STEP_DURATION = 100; + +interface TimelineStep { + key: string; + label: string; + icon: Icon; + duration: number; +} + +interface RestoreProgressPanelProps { + active: boolean; + migrations: MigrationFile[]; + onComplete?: () => void; +} + +export const RestoreProgressPanel = ({ active, migrations, onComplete }: RestoreProgressPanelProps) => { + const t = useScopedI18n("management.page.tool.backup.restore.progress"); + const [currentStepIdx, setCurrentStepIdx] = useState(-1); + + const steps = useMemo((): TimelineStep[] => { + const result: TimelineStep[] = [ + { key: "extracting", label: t("extracting"), icon: IconFileZip, duration: BASE_PHASE_DURATIONS.extracting! }, + ]; + + if (migrations.length > 0) { + for (const migration of migrations) { + result.push({ + key: `migration-${migration.idx}`, + label: migration.tag, + icon: IconDatabaseImport, + duration: MIGRATION_STEP_DURATION, + }); + } + } else { + result.push({ + key: "migrating", + label: t("migrating"), + icon: IconDatabaseImport, + duration: 300, + }); + } + + result.push( + { key: "encrypting", label: t("encrypting"), icon: IconKey, duration: BASE_PHASE_DURATIONS.encrypting! }, + { key: "swapping", label: t("swapping"), icon: IconTransform, duration: BASE_PHASE_DURATIONS.swapping! }, + { key: "restarting", label: t("restarting"), icon: IconRefresh, duration: BASE_PHASE_DURATIONS.restarting! }, + ); + + return result; + }, [migrations, t]); + + useEffect(() => { + if (!active) return; + + let cancelled = false; + const run = async () => { + for (let i = 0; i < steps.length; i++) { + if (cancelled) return; + setCurrentStepIdx(i); + await new Promise((r) => setTimeout(r, steps[i]!.duration)); + } + if (!cancelled) { + onComplete?.(); + } + }; + + void run(); + return () => { cancelled = true; }; + }, [active, onComplete, steps]); + + if (!active && currentStepIdx === -1) return null; + + return ( + + + + + + {t("title")} + + + + + + {steps.map((step, i) => { + const isDone = i < currentStepIdx; + const isActive = i === currentStepIdx; + const isMigration = step.key.startsWith("migration-"); + + return ( + + + + ) : isActive ? ( + + + + ) : ( + + + + ) + } + > + + + + {step.label} + + + + ); + })} + + + + + ); +}; diff --git a/apps/nextjs/src/components/backup/types.ts b/apps/nextjs/src/components/backup/types.ts new file mode 100644 index 000000000..c197c6544 --- /dev/null +++ b/apps/nextjs/src/components/backup/types.ts @@ -0,0 +1,30 @@ +export interface BackupMetadata { + homarrVersion: string; + exportedAt: string; + dbDialect: string; + encryptionKey?: string; +} + +export interface MigrationFile { + idx: number; + tag: string; + sql: string; + when: number; +} + +export interface MigrationStatus { + applied: number; + pending: MigrationFile[]; + total: number; +} + +export interface BackupAnalysis { + metadata: BackupMetadata; + counts: Record; + boardNames: string[]; + migrations: MigrationStatus; +} + +export type RestoreStep = "upload" | "preview" | "confirm" | "restoring" | "error"; + +export const PREVIEW_TABLE_KEYS = ["board", "user", "app", "integration", "item", "media", "search_engine"] as const; diff --git a/apps/nextjs/src/components/backup/use-backup-analysis.ts b/apps/nextjs/src/components/backup/use-backup-analysis.ts new file mode 100644 index 000000000..a563e24cd --- /dev/null +++ b/apps/nextjs/src/components/backup/use-backup-analysis.ts @@ -0,0 +1,187 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import type { BackupAnalysis, MigrationFile, MigrationStatus } from "./types"; +import { PREVIEW_TABLE_KEYS } from "./types"; + +const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations"; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const getAppliedMigrationCount = (db: any): number => { + try { + const result = db.exec(`SELECT COUNT(*) FROM "${DRIZZLE_MIGRATIONS_TABLE}"`); + return (result[0]?.values[0]?.[0] as number) ?? 0; + } catch { + return 0; + } +}; + +const fetchMigrations = async (): Promise => { + const res = await fetch("/api/backup/migrations"); + if (!res.ok) throw new Error("Failed to fetch migration files from server"); + const data = await res.json(); + return data.migrations ?? []; +}; + +const countEntities = (db: any): Record => { + const counts: Record = {}; + for (const key of PREVIEW_TABLE_KEYS) { + try { + const result = db.exec(`SELECT COUNT(*) FROM "${key}"`); + counts[key] = (result[0]?.values[0]?.[0] as number) ?? 0; + } catch { + counts[key] = 0; + } + } + return counts; +}; + +const getBoardNames = (db: any): string[] => { + try { + const result = db.exec('SELECT "name" FROM "board" ORDER BY "name"'); + return result[0]?.values.map((row: unknown[]) => row[0] as string) ?? []; + } catch { + return []; + } +}; + +export interface MigrationProgress { + current: number; + total: number; + tag: string; + phase: "pending" | "applying" | "done" | "error"; +} + +export const useBackupAnalysis = () => { + const t = useScopedI18n("management.page.tool.backup.restore"); + const [analysis, setAnalysis] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [migrationProgress, setMigrationProgress] = useState(null); + const dbRef = useRef(null); + + const analyzeFile = useCallback(async (file: File) => { + setLoading(true); + setError(null); + setAnalysis(null); + setMigrationProgress(null); + + try { + const [JSZip, initSqlJs, allMigrations, wasmBinary] = await Promise.all([ + import("jszip").then((m) => m.default), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + import("sql.js").then((m: any) => m.default), + fetchMigrations(), + fetch("/api/backup/sql-wasm").then((r) => { + if (!r.ok) throw new Error("Failed to load SQL WASM binary"); + return r.arrayBuffer(); + }), + ]); + + const arrayBuffer = await file.arrayBuffer(); + const zip = await JSZip.loadAsync(arrayBuffer); + + const metadataFile = zip.file("metadata.json"); + if (!metadataFile) throw new Error(t("invalidMissingMetadata")); + const dbFile = zip.file("db.sqlite"); + if (!dbFile) throw new Error(t("invalidMissingDb")); + + const metadata = JSON.parse(await metadataFile.async("string")); + const dbBuffer = await dbFile.async("uint8array"); + + const SQL = await initSqlJs({ wasmBinary }); + + const db = new SQL.Database(dbBuffer); + dbRef.current = db; + + try { + const appliedCount = getAppliedMigrationCount(db); + const pendingMigrations = allMigrations.filter((m) => m.idx >= appliedCount); + + const migrations: MigrationStatus = { + applied: appliedCount, + pending: pendingMigrations, + total: allMigrations.length, + }; + + if (pendingMigrations.length > 0) { + for (let i = 0; i < pendingMigrations.length; i++) { + const migration = pendingMigrations[i]!; + setMigrationProgress({ + current: i + 1, + total: pendingMigrations.length, + tag: migration.tag, + phase: "applying", + }); + + await delay(100); + + try { + const statements = migration.sql + .split("--> statement-breakpoint") + .map((s) => s.trim()) + .filter(Boolean); + + for (const stmt of statements) { + db.run(stmt); + } + + db.run( + `INSERT INTO "${DRIZZLE_MIGRATIONS_TABLE}" ("hash", "created_at") VALUES (?, ?)`, + [migration.tag, Date.now()], + ); + } catch (migrationErr) { + setMigrationProgress({ + current: i + 1, + total: pendingMigrations.length, + tag: migration.tag, + phase: "error", + }); + throw new Error( + `Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`, + ); + } + + setMigrationProgress({ + current: i + 1, + total: pendingMigrations.length, + tag: migration.tag, + phase: "done", + }); + + await delay(50); + } + } + + const counts = countEntities(db); + const boardNames = getBoardNames(db); + + setMigrationProgress(null); + setAnalysis({ metadata, counts, boardNames, migrations }); + } finally { + db.close(); + dbRef.current = null; + } + } catch (err) { + setError(err instanceof Error ? err.message : t("analyzeError")); + } finally { + setLoading(false); + } + }, [t]); + + const reset = useCallback(() => { + if (dbRef.current) { + try { dbRef.current.close(); } catch { /* already closed */ } + dbRef.current = null; + } + setAnalysis(null); + setError(null); + setMigrationProgress(null); + }, []); + + return { analysis, loading, error, migrationProgress, analyzeFile, reset }; +}; diff --git a/apps/nextjs/src/components/board/items/actions/create-item.ts b/apps/nextjs/src/components/board/items/actions/create-item.ts index 42b619364..d4b2cb24d 100644 --- a/apps/nextjs/src/components/board/items/actions/create-item.ts +++ b/apps/nextjs/src/components/board/items/actions/create-item.ts @@ -2,6 +2,7 @@ import { getBoardLayouts } from "@homarr/boards/context"; import { createId } from "@homarr/common"; import type { Modify } from "@homarr/common/types"; import type { WidgetKind } from "@homarr/definitions"; +import { widgetDefaultSizes } from "@homarr/definitions"; import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types"; import { getFirstEmptyPosition } from "./empty-position"; @@ -19,7 +20,7 @@ export const createItemCallback = (previous: Board): Board => { const firstSection = previous.sections .filter((section): section is EmptySection => section.kind === "empty") - .sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset) + .toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset) .at(0); if (!firstSection) return previous; @@ -28,7 +29,7 @@ export const createItemCallback = id: id ?? createId(), kind, options, - layouts: createItemLayouts(previous, firstSection), + layouts: createItemLayouts(previous, firstSection, kind), integrationIds, advancedOptions: { title: null, @@ -48,15 +49,20 @@ export const createItemCallback = }; }; -const createItemLayouts = (board: Board, currentSection: EmptySection): ItemLayout[] => { +const defaultItemSize = { width: 1, height: 1 }; + +const getWidgetItemSize = (kind: WidgetKind) => widgetDefaultSizes[kind] ?? defaultItemSize; + +const createItemLayouts = (board: Board, currentSection: EmptySection, kind: WidgetKind): ItemLayout[] => { const layouts = getBoardLayouts(board); + const itemSize = getWidgetItemSize(kind); return layouts.map((layoutId) => { const boardLayout = board.layouts.find((layout) => layout.id === layoutId); const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId }); const emptyPosition = boardLayout - ? getFirstEmptyPosition(elements, boardLayout.columnCount) + ? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize) : { xOffset: 0, yOffset: 0 }; if (!emptyPosition) { @@ -64,8 +70,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection): ItemLayo } return { - width: 1, - height: 1, + width: itemSize.width, + height: itemSize.height, ...emptyPosition, sectionId: currentSection.id, layoutId, diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index 3b79dafd6..1c0c4100a 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -85,6 +85,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP ), integrationSupport: "supportedIntegrations" in currentDefinition, settings, + appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, }, { title(t) { diff --git a/apps/nextjs/src/components/board/items/item-move-modal.tsx b/apps/nextjs/src/components/board/items/item-move-modal.tsx index c1e03ba7c..38e362ede 100644 --- a/apps/nextjs/src/components/board/items/item-move-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-move-modal.tsx @@ -1,11 +1,11 @@ import { useCallback, useRef } from "react"; -import { Button, Grid, Group, NumberInput, Stack } from "@mantine/core"; +import { Grid, NumberInput, Stack } from "@mantine/core"; import { z } from "zod/v4"; import { useZodForm } from "@homarr/form"; import type { GridStack } from "@homarr/gridstack"; -import { createModal } from "@homarr/modals"; -import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals"; +import { useI18n } from "@homarr/translation/client"; import type { Item, SectionItem } from "~/app/[locale]/boards/_types"; @@ -16,9 +16,7 @@ interface InnerProps { } export const ItemMoveModal = createModal(({ actions, innerProps }) => { - const tCommon = useScopedI18n("common"); const t = useI18n(); - // Keep track of the maximum width based on the x offset const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset); const form = useZodForm( z.object({ @@ -38,7 +36,6 @@ export const ItemMoveModal = createModal(({ actions, innerProps }) = height: innerProps.item.height, }, onValuesChange(values, previous) { - // Update the maximum width when the x offset changes if (values.xOffset !== previous.xOffset) { maxWidthRef.current = innerProps.columnCount - values.xOffset; } @@ -70,6 +67,7 @@ export const ItemMoveModal = createModal(({ actions, innerProps }) = (({ actions, innerProps }) = - - - - +
); @@ -106,5 +99,5 @@ export const ItemMoveModal = createModal(({ actions, innerProps }) = defaultTitle(t) { return t("item.moveResize.title"); }, - size: "lg", + size: modalSizeForm, }); diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index 23c9ddec6..84947d150 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -1,14 +1,14 @@ import { useMemo, useState } from "react"; -import { Avatar, Button, Card, Center, Grid, Group, Input, Stack, Text, Tooltip } from "@mantine/core"; -import { IconSearch } from "@tabler/icons-react"; +import { Avatar, Box, Button, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { createId, objectEntries } from "@homarr/common"; import { getIconUrl, getIntegrationName } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; -import { createModal, useModalAction } from "@homarr/modals"; +import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; +import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui"; import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { WidgetEditModal } from "@homarr/widgets/modals"; @@ -86,32 +86,31 @@ export const ItemSelectModal = createModal(({ actions }) => { }; return ( - - setSearch(event.currentTarget.value)} - leftSection={} - placeholder={`${t("item.create.search")}...`} - data-autofocus - onKeyDown={(event) => { - // Add item if there is only one item in the list and user presses Enter - if (event.key === "Enter" && filteredItems.length === 1) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - handleAdd(filteredItems[0]!.kind); - } - }} - /> + { + if (event.key === "Enter" && filteredItems.length === 1) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + handleAdd(filteredItems[0]!.kind); + } + }} + > + {filteredItems.map((item) => ( + handleAdd(item.kind)} /> + ))} - - {filteredItems.map((item) => ( - handleAdd(item.kind)} /> - ))} - - + {filteredItems.length === 0 && ( +
+ {t("common.noResults")} +
+ )} + ); }).withOptions({ defaultTitle: (t) => t("item.create.title"), - size: "xl", + size: modalSizeSelect, }); const WidgetItem = ({ @@ -130,46 +129,58 @@ const WidgetItem = ({ const t = useI18n(); return ( - - - - -
- -
- - {item.name} - - - {item.description} - -
- - -
-
-
+ e.currentTarget.style.setProperty("--_hover-opacity", "1")} + onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")} + > + + + + + {item.name} + + + + + {item.description} + + + + + + + + ); }; -interface SupportedIntegrationsProps { - integrations: IntegrationKind[]; - mt: string; -} - -const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps) => { +const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => { if (integrations.length === 0) { return null; } - // When there are 8 or more integrations, we show 6 and a "+X" avatar. Otherwise, we show all integrations. const countToShow = integrations.length >= 8 ? 6 : 7; const moreCount = integrations.length - countToShow; return ( -
+ {integrations.slice(0, countToShow).map((integration) => ( @@ -195,6 +206,6 @@ const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps) )} -
+ ); }; diff --git a/apps/nextjs/src/components/board/sections/category/category-edit-modal.tsx b/apps/nextjs/src/components/board/sections/category/category-edit-modal.tsx index 1f990eec0..fa14ea8ab 100644 --- a/apps/nextjs/src/components/board/sections/category/category-edit-modal.tsx +++ b/apps/nextjs/src/components/board/sections/category/category-edit-modal.tsx @@ -1,8 +1,8 @@ -import { Button, Group, Stack, TextInput } from "@mantine/core"; +import { Stack, TextInput } from "@mantine/core"; import { z } from "zod/v4"; import { useZodForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; +import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; interface Category { @@ -36,13 +36,10 @@ export const CategoryEditModal = createModal(({ actions, innerProps > - - - - + ); -}).withOptions({}); +}).withOptions({ + size: modalSizeForm, +}); diff --git a/apps/nextjs/src/components/board/sections/dynamic/dynamic-edit-modal.tsx b/apps/nextjs/src/components/board/sections/dynamic/dynamic-edit-modal.tsx index f80a833d6..c2f929b41 100644 --- a/apps/nextjs/src/components/board/sections/dynamic/dynamic-edit-modal.tsx +++ b/apps/nextjs/src/components/board/sections/dynamic/dynamic-edit-modal.tsx @@ -1,10 +1,10 @@ "use client"; -import { Button, CloseButton, ColorInput, Group, Stack, TextInput, useMantineTheme } from "@mantine/core"; +import { CloseButton, ColorInput, Stack, TextInput, useMantineTheme } from "@mantine/core"; import type { z } from "zod/v4"; import { useZodForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; +import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; import { TextMultiSelect } from "@homarr/ui"; import { dynamicSectionOptionsSchema } from "@homarr/validation/shared"; @@ -31,7 +31,7 @@ export const DynamicSectionEditModal = createModal(({ actions, inner })} > - + (({ actions, inner } {...form.getInputProps("borderColor")} /> - - - - - - + ); @@ -63,5 +56,5 @@ export const DynamicSectionEditModal = createModal(({ actions, inner defaultTitle(t) { return t("item.edit.title"); }, - size: "lg", + size: modalSizeForm, }); diff --git a/apps/nextjs/src/components/integration/integration-grid-shared.ts b/apps/nextjs/src/components/integration/integration-grid-shared.ts new file mode 100644 index 000000000..4b43a4299 --- /dev/null +++ b/apps/nextjs/src/components/integration/integration-grid-shared.ts @@ -0,0 +1,98 @@ +import { objectEntries } from "@homarr/common"; +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; +import { + featuredIntegrations, + getIntegrationName, + hiddenFromOnboarding, + integrationDefs, + integrationKinds, +} from "@homarr/definitions"; +import { widgetImports } from "@homarr/widgets"; + +const buildWidgetsByIntegration = () => { + const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record< + IntegrationKind, + WidgetKind[] + >; + + for (const [widgetKind, widget] of objectEntries(widgetImports)) { + const supported = + "supportedIntegrations" in widget.definition + ? (widget.definition.supportedIntegrations as IntegrationKind[]) + : []; + for (const kind of supported) { + if (kind in map) { + map[kind].push(widgetKind); + } + } + } + return map; +}; + +export const widgetsByIntegration = buildWidgetsByIntegration(); + +export const categoryTranslationKeys: Record = { + dnsHole: "integration.category.dnsHole", + mediaService: "integration.category.mediaService", + calendar: "integration.category.calendar", + mediaSearch: "integration.category.mediaSearch", + mediaRelease: "integration.category.mediaRelease", + mediaRequest: "integration.category.mediaRequest", + downloadClient: "integration.category.downloadClient", + usenet: "integration.category.usenet", + torrent: "integration.category.torrent", + miscellaneous: "integration.category.miscellaneous", + smartHomeServer: "integration.category.smartHomeServer", + indexerManager: "integration.category.indexerManager", + healthMonitoring: "integration.category.healthMonitoring", + search: "integration.category.search", + mediaTranscoding: "integration.category.mediaTranscoding", + networkController: "integration.category.networkController", + releasesProvider: "integration.category.releasesProvider", + notifications: "integration.category.notifications", + firewall: "integration.category.firewall", + timetable: "integration.category.timetable", + photoService: "integration.category.photoService", + notes: "integration.category.notes", + mediaMonitoring: "integration.category.mediaMonitoring", + speedtest: "integration.category.speedtest", + analytics: "integration.category.analytics", + documents: "integration.category.documents", + mediaLibrary: "integration.category.mediaLibrary", + uptimeMonitoring: "integration.category.uptimeMonitoring", +}; + +export interface IntegrationGridItem { + kind: IntegrationKind; + name: string; + categories: string[]; + widgets: WidgetKind[]; +} + +export const buildSortedIntegrations = ( + options: { enableMockIntegration?: boolean; onboarding?: boolean } = {}, +): IntegrationGridItem[] => + integrationKinds + .filter((kind) => { + if (options.onboarding && hiddenFromOnboarding.has(kind)) return false; + if (options.onboarding && widgetsByIntegration[kind].length === 0) return false; + if (!options.enableMockIntegration && kind === "mock") return false; + return true; + }) + .map((kind) => ({ + kind, + name: getIntegrationName(kind), + categories: [...new Set(integrationDefs[kind].category.flat())] as string[], + widgets: widgetsByIntegration[kind], + })) + .sort((left, right) => { + const leftIdx = featuredIntegrations.indexOf(left.kind); + const rightIdx = featuredIntegrations.indexOf(right.kind); + if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx; + if (leftIdx !== -1) return -1; + if (rightIdx !== -1) return 1; + return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name); + }); + +export const filterIntegrations = (items: IntegrationGridItem[], search: string) => + items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim())); diff --git a/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx new file mode 100644 index 000000000..cbd347ba1 --- /dev/null +++ b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core"; +import { IconBrandDocker, IconCheck, IconPuzzle } from "@tabler/icons-react"; + +import type { IntegrationKind } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; +import { IntegrationAvatar, SelectGridLayout, selectGridCardHeight } from "@homarr/ui"; + +import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared"; + +interface IntegrationMultiSelectGridProps { + selectedKinds: IntegrationKind[]; + onSelectionChange: (kinds: IntegrationKind[]) => void; + enableMockIntegration?: boolean; + onboarding?: boolean; + detectedKinds?: Set; +} + +export const IntegrationMultiSelectGrid = ({ + selectedKinds: selectedKindsArray, + onSelectionChange, + enableMockIntegration = false, + onboarding = false, + detectedKinds, +}: IntegrationMultiSelectGridProps) => { + const [search, setSearch] = useState(""); + const selectedKinds = useMemo(() => new Set(selectedKindsArray), [selectedKindsArray]); + const t = useI18n(); + + const toggleKind = useCallback( + (kind: IntegrationKind) => { + const current = new Set(selectedKindsArray); + if (current.has(kind)) { + current.delete(kind); + } else { + current.add(kind); + } + onSelectionChange(Array.from(current)); + }, + [selectedKindsArray, onSelectionChange], + ); + + const integrations = useMemo( + () => buildSortedIntegrations({ enableMockIntegration, onboarding }), + [enableMockIntegration, onboarding], + ); + + const sorted = useMemo(() => { + if (!detectedKinds || detectedKinds.size === 0) return integrations; + const detected = integrations.filter((i) => detectedKinds.has(i.kind)); + const rest = integrations.filter((i) => !detectedKinds.has(i.kind)); + return [...detected, ...rest]; + }, [integrations, detectedKinds]); + + const filtered = useMemo(() => filterIntegrations(sorted, search), [sorted, search]); + + return ( + + {filtered.map((integration) => { + const isSelected = selectedKinds.has(integration.kind); + const isDetected = detectedKinds?.has(integration.kind) ?? false; + return ( + toggleKind(integration.kind)} + withBorder + > + + + + + {integration.name} + + {isSelected && } + + + + {isDetected && ( + }> + {t("integration.grid.detected")} + + )} + + {integration.categories.slice(0, 2).map((cat) => ( + + {categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat} + + ))} + + {integration.widgets.length > 0 ? ( + + {integration.widgets.map((widgetKind) => ( + + {t(`widget.${widgetKind}.name`)} + + ))} + + } + > + }> + {t("integration.grid.widgetCount", { count: String(integration.widgets.length) })} + + + ) : ( + + {t("integration.grid.noWidgets")} + + )} + +
+ + ); + })} + + {filtered.length === 0 && ( +
+ {t("common.noResults")} +
+ )} + + ); +}; diff --git a/apps/nextjs/src/components/integration/integration-select-grid.tsx b/apps/nextjs/src/components/integration/integration-select-grid.tsx new file mode 100644 index 000000000..f4905dd2a --- /dev/null +++ b/apps/nextjs/src/components/integration/integration-select-grid.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core"; +import { IconPuzzle } from "@tabler/icons-react"; + +import type { IntegrationKind } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; +import { IntegrationAvatar, SelectGridLayout, selectGridCardHeight } from "@homarr/ui"; + +import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared"; + +interface IntegrationSelectGridProps { + onSelect: (kind: IntegrationKind) => void; + enableMockIntegration?: boolean; +} + +export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false }: IntegrationSelectGridProps) => { + const [search, setSearch] = useState(""); + const t = useI18n(); + + const integrations = useMemo(() => buildSortedIntegrations({ enableMockIntegration }), [enableMockIntegration]); + const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]); + + return ( + + {filtered.map((integration) => ( + onSelect(integration.kind)} + withBorder + > + + + + + {integration.name} + + + + + + {integration.categories.slice(0, 2).map((cat) => ( + + {categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat} + + ))} + + {integration.widgets.length > 0 ? ( + + {integration.widgets.map((widgetKind) => ( + + {t(`widget.${widgetKind}.name`)} + + ))} + + } + > + }> + {t("integration.grid.widgetCount", { count: String(integration.widgets.length) })} + + + ) : ( + + {t("integration.grid.noWidgets")} + + )} + +
+ + ))} + + {filtered.length === 0 && ( +
+ {t("common.noResults")} +
+ )} + + ); +}; diff --git a/apps/nextjs/src/components/integration/integration-select-modal.tsx b/apps/nextjs/src/components/integration/integration-select-modal.tsx new file mode 100644 index 000000000..3410824f0 --- /dev/null +++ b/apps/nextjs/src/components/integration/integration-select-modal.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useState } from "react"; +import { Group, ScrollArea, Title } from "@mantine/core"; +import { IconArrowLeft } from "@tabler/icons-react"; + +import type { IntegrationKind } from "@homarr/definitions"; +import { getIntegrationName } from "@homarr/definitions"; +import { createModal, modalSizeSelect } from "@homarr/modals"; +import { IntegrationAvatar } from "@homarr/ui"; + +import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form"; +import { IntegrationSelectGrid } from "./integration-select-grid"; + +interface IntegrationSelectModalProps { + onSuccess?: () => void; + enableMockIntegration?: boolean; +} + +export const IntegrationSelectModal = createModal(({ actions, innerProps }) => { + const [step, setStep] = useState<"select" | "form">("select"); + const [selectedKind, setSelectedKind] = useState(null); + + const handleSelect = (kind: IntegrationKind) => { + setSelectedKind(kind); + setStep("form"); + }; + + const handleBack = () => { + setStep("select"); + setSelectedKind(null); + }; + + const handleSuccess = () => { + innerProps.onSuccess?.(); + actions.closeModal(); + }; + + if (step === "form" && selectedKind) { + return ( + + + + + {getIntegrationName(selectedKind)} + + + + ); + } + + return ; +}).withOptions({ + defaultTitle: (t) => t("integration.action.create"), + size: modalSizeSelect, +}); diff --git a/apps/nextjs/src/components/layout/header.tsx b/apps/nextjs/src/components/layout/header.tsx index f1e513f30..32ffa81d7 100644 --- a/apps/nextjs/src/components/layout/header.tsx +++ b/apps/nextjs/src/components/layout/header.tsx @@ -6,6 +6,7 @@ import { Link } from "@homarr/ui"; import { ClientBurger } from "./header/burger"; import { DesktopSearchInput, MobileSearchButton } from "./header/search"; +import { TourTarget } from "./header/tour-target"; import { UserButton } from "./header/user"; import { HomarrLogoWithTitle } from "./logo/homarr-logo"; @@ -25,11 +26,15 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => { {logo ?? } - + + + {actions} - + + + diff --git a/apps/nextjs/src/components/layout/header/tour-target.tsx b/apps/nextjs/src/components/layout/header/tour-target.tsx new file mode 100644 index 000000000..b23572d1e --- /dev/null +++ b/apps/nextjs/src/components/layout/header/tour-target.tsx @@ -0,0 +1,11 @@ +"use client"; + +import type { ReactNode } from "react"; +import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; +import { Box } from "@mantine/core"; + +export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => ( + + {children} + +); diff --git a/apps/nextjs/src/components/layout/navigation-link.tsx b/apps/nextjs/src/components/layout/navigation-link.tsx index 2d98d42ec..032dbc3bc 100644 --- a/apps/nextjs/src/components/layout/navigation-link.tsx +++ b/apps/nextjs/src/components/layout/navigation-link.tsx @@ -1,7 +1,9 @@ "use client"; import type { ReactNode } from "react"; +import { useEffect, useState } from "react"; import { usePathname } from "next/navigation"; +import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; import { NavLink } from "@mantine/core"; import { Link } from "@homarr/ui"; @@ -9,9 +11,28 @@ import { Link } from "@homarr/ui"; export const CommonNavLink = (props: ClientNavigationLink) => "href" in props ? : ; -const NavLinkHref = (props: NavigationLinkHref) => { +const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => { + if (!id) return <>{children}; + return {children}; +}; + +const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`); + +const useClientPathname = () => { const pathname = usePathname(); - return props.external ? ( + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + }, []); + + return { pathname, isClient }; +}; + +const NavLinkHref = (props: NavigationLinkHref) => { + const { pathname, isClient } = useClientPathname(); + const tourId = props["data-onboarding-tour-id"]; + const link = props.external ? ( ) : ( { label={props.label} leftSection={props.icon} href={props.href} - active={pathname === props.href} + active={isClient && pathMatches(pathname, props.href)} /> ); + return {link}; }; const NavLinkWithItems = (props: NavigationLinkWithItems) => { - const pathname = usePathname(); - const isActive = props.items.some((item) => item.href === pathname); - return ( + const { pathname, isClient } = useClientPathname(); + const isActive = isClient && props.items.some((item) => pathMatches(pathname, item.href)); + const nav = ( {props.items.map((item) => ( ))} ); + return {nav}; }; interface CommonNavigationLinkProps { label: string; icon: ReactNode; + "data-onboarding-tour-id"?: string; } interface NavigationLinkHref extends CommonNavigationLinkProps { diff --git a/apps/nextjs/src/components/layout/navigation.tsx b/apps/nextjs/src/components/layout/navigation.tsx index 47f0db1b3..692c54c51 100644 --- a/apps/nextjs/src/components/layout/navigation.tsx +++ b/apps/nextjs/src/components/layout/navigation.tsx @@ -27,7 +27,7 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi return null; } - const { icon: TablerIcon, iconProps, ...props } = link; + const { icon: TablerIcon, iconProps, "data-onboarding-tour-id": tourId, ...props } = link; const Icon = typeof TablerIcon === "string" ? ( @@ -38,17 +38,20 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi if ("items" in props) { clientLink = { ...props, + "data-onboarding-tour-id": tourId, items: props.items .filter((item) => !item.hidden) .map((item) => { + const { "data-onboarding-tour-id": itemTourId, ...rest } = item; return { - ...item, + ...rest, + "data-onboarding-tour-id": itemTourId, icon: , }; }), } as ClientNavigationLink; } else { - clientLink = props as ClientNavigationLink; + clientLink = { ...props, "data-onboarding-tour-id": tourId } as ClientNavigationLink; } return ; })} @@ -63,6 +66,7 @@ interface CommonNavigationLinkProps { icon: TablerIcon | string; iconProps?: TablerIconProps; hidden?: boolean; + "data-onboarding-tour-id"?: string; } interface NavigationLinkHref extends CommonNavigationLinkProps { diff --git a/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx b/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx index f6310d481..d200aa966 100644 --- a/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx +++ b/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx @@ -58,19 +58,23 @@ export const DynamicBreadcrumb = ({ } const href = `/${pathnameParts.slice(0, index + 1).join("/")}`; const translationKey = `${pathnameParts.slice(0, index + 1).join(".")}`; + const mappedValue = dynamicMappings?.get(pathnamePart); + + if (mappedValue) { + if (nonInteractable?.includes(pathnamePart)) { + return {mappedValue}; + } + return ( + + {mappedValue} + + ); + } if (nonInteractable?.includes(pathnamePart)) { return {t(`${translationKey}.label` as TranslationKeys)}; } - if (dynamicMappings?.has(pathnamePart)) { - return ( - - {dynamicMappings.get(pathnamePart)} - - ); - } - return ( {t(`${translationKey}.label` as TranslationKeys)} diff --git a/apps/nextjs/src/components/onboarding/board-tour.tsx b/apps/nextjs/src/components/onboarding/board-tour.tsx new file mode 100644 index 000000000..d7f5d2765 --- /dev/null +++ b/apps/nextjs/src/components/onboarding/board-tour.tsx @@ -0,0 +1,108 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { useMemo } from "react"; +import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour"; + +import { clientApi } from "@homarr/api/client"; +import { useRequiredBoard } from "@homarr/boards/context"; +import { createDocumentationLink } from "@homarr/definitions"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { useBoardPermissions } from "~/components/board/permissions/client"; +import { TourShell } from "./tour-shell"; +import { TourStepContent } from "./tour-step-content"; + +export const BoardTourProvider = ({ children }: PropsWithChildren) => { + const t = useScopedI18n("onboardingTour.board"); + const board = useRequiredBoard(); + const { hasChangeAccess } = useBoardPermissions(board); + const utils = clientApi.useUtils(); + const { data: tourStatus } = clientApi.user.getTourStatus.useQuery(); + const { mutate: completeTour } = clientApi.user.completeTour.useMutation({ + onSuccess() { + void utils.user.getTourStatus.invalidate(); + }, + }); + + const started = tourStatus !== undefined && !tourStatus.completedBoardTour; + + const handleEnd = () => { + utils.user.getTourStatus.setData(undefined, { + completedManageTour: tourStatus?.completedManageTour ?? false, + completedBoardTour: true, + }); + completeTour({ tour: "board" }); + }; + + const steps = useMemo(() => { + const allSteps: (OnboardingTourStep & { requiresChangeAccess?: boolean })[] = [ + { + id: "board-search", + title: t("welcome.title"), + content: ( + + ), + }, + { + id: "board-edit-mode", + title: t("editMode.title"), + requiresChangeAccess: true, + content: ( + + ), + }, + { + id: "board-settings", + title: t("settings.title"), + requiresChangeAccess: true, + content: ( + + ), + }, + { + id: "board-switcher", + title: t("switcher.title"), + content: ( + + ), + }, + { + id: "board-user-menu", + title: t("userMenu.title"), + content: ( + + ), + }, + ]; + + return allSteps.filter((step) => !step.requiresChangeAccess || hasChangeAccess) as OnboardingTourStep[]; + }, [hasChangeAccess, t]); + + return ( + + {children} + + ); +}; diff --git a/apps/nextjs/src/components/onboarding/manage-tour.tsx b/apps/nextjs/src/components/onboarding/manage-tour.tsx new file mode 100644 index 000000000..70ca61084 --- /dev/null +++ b/apps/nextjs/src/components/onboarding/manage-tour.tsx @@ -0,0 +1,220 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import type { OnboardingTourFocusRevealProps, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour"; +import { useMediaQuery } from "@mantine/hooks"; +import { + IconAffiliateFilled, + IconAppsFilled, + IconLayoutDashboardFilled, + IconPlus, + IconUserFilled, +} from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { createDocumentationLink } from "@homarr/definitions"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { TourShell } from "./tour-shell"; +import { TourStepContent } from "./tour-step-content"; + +interface ManageTourProviderProps extends PropsWithChildren { + isAdmin: boolean; +} + +const stepRoutes: Record = { + "manage-welcome": "/manage", + "manage-boards-list": "/manage/boards", + "manage-boards-create": "/manage/boards", + "manage-apps-list": "/manage/apps", + "manage-apps-create": "/manage/apps", + "manage-integrations-list": "/manage/integrations", + "manage-integrations-create": "/manage/integrations", + "manage-users-list": "/manage/users", + "manage-users-create": "/manage/users", +}; + +const usersStepFocusRevealProps: OnboardingTourFocusRevealProps = { + withReveal: false, + popoverProps: { + position: "bottom", + middlewares: { shift: { padding: 16 }, flip: true }, + }, +}; + +export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => { + const t = useScopedI18n("onboardingTour.manage"); + const utils = clientApi.useUtils(); + const { data: tourStatus } = clientApi.user.getTourStatus.useQuery(); + const { mutate: completeTour } = clientApi.user.completeTour.useMutation({ + onSuccess() { + void utils.user.getTourStatus.invalidate(); + }, + }); + const isMobile = useMediaQuery("(max-width: 48em)"); + const pathname = usePathname(); + const router = useRouter(); + + const isManageHome = /^(\/[^/]+)?\/manage\/?$/.test(pathname); + const isManageSection = /^(\/[^/]+)?\/manage(\/.*)?$/.test(pathname); + + const [tourActive, setTourActive] = useState(false); + const [tourDismissed, setTourDismissed] = useState(false); + const wasManageTourCompletedRef = useRef(tourStatus?.completedManageTour); + + useEffect(() => { + if (tourDismissed) return; + if (isManageHome && tourStatus !== undefined && !tourStatus.completedManageTour && !isMobile) { + setTourActive(true); + } + }, [isManageHome, tourStatus, isMobile, tourDismissed]); + + useEffect(() => { + if (!isManageSection) { + setTourActive(false); + } + }, [isManageSection]); + + useEffect(() => { + const wasCompleted = wasManageTourCompletedRef.current; + const isCompleted = tourStatus?.completedManageTour; + if (wasCompleted === true && isCompleted === false) { + setTourDismissed(false); + } + wasManageTourCompletedRef.current = isCompleted; + }, [tourStatus?.completedManageTour]); + + const handleEnd = useCallback(() => { + setTourDismissed(true); + setTourActive(false); + utils.user.getTourStatus.setData(undefined, { + completedManageTour: true, + completedBoardTour: tourStatus?.completedBoardTour ?? false, + }); + completeTour({ tour: "manage" }); + router.push("/manage"); + }, [completeTour, router, tourStatus?.completedBoardTour, utils.user.getTourStatus]); + + const steps = useMemo(() => { + const allSteps: (OnboardingTourStep & { adminOnly?: boolean })[] = [ + { + id: "manage-welcome", + title: t("welcome.title"), + content: ( + + ), + }, + { + id: "manage-boards-list", + title: t("boardsList.title"), + content: ( + } + /> + ), + }, + { + id: "manage-boards-create", + title: t("boardsCreate.title"), + content: ( + } + /> + ), + }, + { + id: "manage-apps-list", + title: t("appsList.title"), + content: ( + } + /> + ), + }, + { + id: "manage-apps-create", + title: t("appsCreate.title"), + content: ( + } + /> + ), + }, + { + id: "manage-integrations-list", + title: t("integrationsList.title"), + content: ( + } + /> + ), + }, + { + id: "manage-integrations-create", + title: t("integrationsCreate.title"), + content: ( + } + /> + ), + }, + { + id: "manage-users-list", + title: t("usersList.title"), + adminOnly: true, + focusRevealProps: usersStepFocusRevealProps, + content: ( + } + /> + ), + }, + { + id: "manage-users-create", + title: t("usersCreate.title"), + adminOnly: true, + focusRevealProps: usersStepFocusRevealProps, + content: ( + } + /> + ), + }, + ]; + + return allSteps.filter((step) => !step.adminOnly || isAdmin) as OnboardingTourStep[]; + }, [isAdmin, t]); + + return ( + + {children} + + ); +}; diff --git a/apps/nextjs/src/components/onboarding/tour-shell.tsx b/apps/nextjs/src/components/onboarding/tour-shell.tsx new file mode 100644 index 000000000..5387c2176 --- /dev/null +++ b/apps/nextjs/src/components/onboarding/tour-shell.tsx @@ -0,0 +1,197 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { useCallback, useEffect, useRef } from "react"; +import { useRouter } from "next/navigation"; +import type { OnboardingTourController, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour"; +import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; +import { Button, Center, Group, Image, Kbd, Text } from "@mantine/core"; +import type { FloatingPosition } from "@mantine/core"; + +import { useI18n } from "@homarr/translation/client"; + +import { homarrLogoPath } from "~/components/layout/logo/homarr-logo"; + +interface TourShellProps extends PropsWithChildren { + steps: OnboardingTourStep[]; + started: boolean; + onEnd: () => void; + stepRoutes?: Record; + position?: FloatingPosition | Record; +} + +type TourController = OnboardingTourController; + +const stepIndexFromController = (controller: TourController) => controller.currentStepIndex ?? 0; + +const stepAtOffset = (controller: TourController, offset: number) => { + const index = stepIndexFromController(controller) + offset; + return controller.tour[index]; +}; + +const pollForElement = (targetId: string, callback: () => void) => { + const interval = setInterval(() => { + const targetElement = document.querySelector(`[data-tour-target="${targetId}"]`); + if (targetElement) { + clearInterval(interval); + callback(); + } + }, 50); + const timeout = setTimeout(() => clearInterval(interval), 5000); + return () => { + clearInterval(interval); + clearTimeout(timeout); + }; +}; + +const isTypingTarget = (target: EventTarget | null) => { + if (!(target instanceof HTMLElement)) return false; + const tag = target.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable; +}; + +interface TourForwardButtonProps { + label: string; + onClick: () => void; +} + +const TourForwardButton = ({ label, onClick }: TourForwardButtonProps) => { + return ( + + ); +}; + +interface TourDoneButtonProps { + label: string; + onClick: () => void; +} + +const TourDoneButton = ({ label, onClick }: TourDoneButtonProps) => { + return ( + + ); +}; + +export const TourShell = ({ steps, started, onEnd, stepRoutes, position, children }: TourShellProps) => { + const t = useI18n(); + const router = useRouter(); + const forwardActionRef = useRef<(() => void) | null>(null); + + const navigateAndAdvance = useCallback( + (currentStepId: string | undefined, targetStepId: string | undefined, advance: () => void) => { + if (!stepRoutes || !targetStepId) { + advance(); + return; + } + + const currentRoute = currentStepId ? stepRoutes[currentStepId] : undefined; + const targetRoute = stepRoutes[targetStepId]; + + if (targetRoute && targetRoute !== currentRoute) { + router.push(targetRoute); + pollForElement(targetStepId, advance); + } else { + advance(); + } + }, + [router, stepRoutes], + ); + + const bindForwardAction = useCallback((controller: TourController, action: () => void) => { + forwardActionRef.current = action; + return action; + }, []); + + useEffect(() => { + if (!started) { + forwardActionRef.current = null; + return; + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return; + if (isTypingTarget(event.target)) return; + event.preventDefault(); + forwardActionRef.current?.(); + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [started]); + + return ( + ( + <> + + + {stepIndexFromController(controller) + 1} + + + / + + + {controller.tour.length} + + + {stepIndexFromController(controller) === 0 && ( +
+ Homarr +
+ )} + + )} + nextStepNavigation={(controller) => { + const nextStep = stepAtOffset(controller, 1); + const action = bindForwardAction(controller, () => { + navigateAndAdvance(controller.currentStep?.id, nextStep?.id, () => controller.nextStep()); + }); + return ; + }} + endStepNavigation={(controller) => { + const action = bindForwardAction(controller, () => controller.endTour()); + return ; + }} + prevStepNavigation={(controller) => ( + + )} + skipNavigation={(controller) => ( + + )} + withPrevButton + focusRevealProps={{ + disableTargetInteraction: true, + popoverProps: { + position: position ?? { base: "bottom", sm: "right" }, + width: 420, + shadow: "xl", + radius: "lg", + middlewares: { shift: { padding: 16 }, flip: true }, + }, + }} + cutoutPadding={12} + cutoutRadius={12} + > + {children} +
+ ); +}; diff --git a/apps/nextjs/src/components/onboarding/tour-step-content.tsx b/apps/nextjs/src/components/onboarding/tour-step-content.tsx new file mode 100644 index 000000000..05b7b4bd3 --- /dev/null +++ b/apps/nextjs/src/components/onboarding/tour-step-content.tsx @@ -0,0 +1,39 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Anchor, Divider, Group, Stack, Text, ThemeIcon } from "@mantine/core"; +import { IconExternalLink } from "@tabler/icons-react"; + +import { useI18n } from "@homarr/translation/client"; + +interface TourStepContentProps { + description: string; + documentationHref: string; + icon?: ReactNode; +} + +export const TourStepContent = ({ description, documentationHref, icon }: TourStepContentProps) => { + const t = useI18n(); + + return ( + + + {icon && ( + + {icon} + + )} + + {description} + + + + + + + {t("onboardingTour.learnMore")} + + + + ); +}; diff --git a/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx index 8faa8f6fa..71ec2b218 100644 --- a/apps/nextjs/src/components/user-avatar-menu.tsx +++ b/apps/nextjs/src/components/user-avatar-menu.tsx @@ -45,7 +45,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar window.location.assign(logoutUrl); return; } - router.refresh(); + router.push("/auth/login"); }, }); }, [logoutUrl, openModal, router]); diff --git a/apps/nextjs/src/env.ts b/apps/nextjs/src/env.ts index 62b49dee8..96f03e7b7 100644 --- a/apps/nextjs/src/env.ts +++ b/apps/nextjs/src/env.ts @@ -3,6 +3,7 @@ import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env" export const env = createEnv({ server: { UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false), + DEMO_MODE: createBooleanSchema(false), }, experimental__runtimeEnv: process.env, }); diff --git a/apps/nextjs/src/react-app-env.d.ts b/apps/nextjs/src/react-app-env.d.ts index 0357e4f19..ca9b2ac8c 100644 --- a/apps/nextjs/src/react-app-env.d.ts +++ b/apps/nextjs/src/react-app-env.d.ts @@ -2,3 +2,4 @@ declare module "*.png"; declare module "*.svg"; declare module "*.jpeg"; declare module "*.jpg"; +declare module "sql.js"; diff --git a/docs/README.md b/docs/README.md index e7aeef585..42330bec6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,9 @@

+ + Live Demo 🎮 + • Install 💻 • @@ -246,13 +249,20 @@ + + PeaNUT +
+

PeaNUT

+ + + Pi-hole

Pi-hole

- - + + Plex
@@ -293,15 +303,15 @@

Radarr

- - + + Readarr

Readarr

- - + + SABnzbd
@@ -342,15 +352,15 @@

Speedtest
Tracker

- - + + Tdarr

Tdarr

- - + + Tracearr
diff --git a/e2e/onboarding.spec.ts b/e2e/onboarding.spec.ts index 7e709f425..a8a09773e 100644 --- a/e2e/onboarding.spec.ts +++ b/e2e/onboarding.spec.ts @@ -31,6 +31,7 @@ describe("Onboarding", () => { confirmPassword: "Comp(exP4sswOrd", }); await actions.processSettingsStepAsync(); + await actions.processIntegrationsStepAsync(); // Assert await assertions.assertFinishStepVisibleAsync(); @@ -72,6 +73,7 @@ describe("Onboarding", () => { name: externalGroupName, }); await actions.processSettingsStepAsync(); + await actions.processIntegrationsStepAsync(); // Assert await assertions.assertFinishStepVisibleAsync(); diff --git a/e2e/shared/actions/onboarding-actions.ts b/e2e/shared/actions/onboarding-actions.ts index 6bdb1d2a5..56c09aba7 100644 --- a/e2e/shared/actions/onboarding-actions.ts +++ b/e2e/shared/actions/onboarding-actions.ts @@ -51,4 +51,9 @@ export class OnboardingActions { await this.page.waitForSelector("text=Analytics"); await this.page.locator("css=button[type='submit']").click(); } + + public async processIntegrationsStepAsync() { + await this.page.waitForSelector("text=integrations you'd like to connect"); + await this.page.locator("button", { hasText: "Skip for now" }).click(); + } } diff --git a/e2e/shared/assertions/onboarding-assertions.ts b/e2e/shared/assertions/onboarding-assertions.ts index 63d18ea7c..12cda67d0 100644 --- a/e2e/shared/assertions/onboarding-assertions.ts +++ b/e2e/shared/assertions/onboarding-assertions.ts @@ -3,7 +3,7 @@ import { eq } from "drizzle-orm"; import { expect } from "vitest"; import * as sqliteSchema from "../../../packages/db/schema/sqlite"; -import { OnboardingStep } from "../../../packages/definitions/src"; +import type { OnboardingStep } from "../../../packages/definitions/src"; import { credentialsAdminGroup } from "../../../packages/definitions/src/group"; import type { SqliteDatabase } from "../e2e-db"; @@ -57,6 +57,6 @@ export class OnboardingAssertions { } public async assertFinishStepVisibleAsync() { - await this.page.waitForSelector("text=completed the setup", { timeout: 5000 }); + await this.page.waitForSelector("text=completed the setup", { timeout: 15000 }); } } diff --git a/nginx.conf b/nginx.conf index 4b82c8ca5..c1c082777 100644 --- a/nginx.conf +++ b/nginx.conf @@ -23,7 +23,7 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; - client_max_body_size 32M; + client_max_body_size 256M; } } } diff --git a/package.json b/package.json index 7c27c59b2..cc5af67e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homarr", - "version": "1.61.1", + "version": "1.64.0", "private": true, "scripts": { "build": "cross-env CI=true turbo build", @@ -78,7 +78,7 @@ "overrides": { "@babel/helpers@<7.26.10": ">=7.29.7", "@babel/runtime@<7.26.10": ">=7.29.7", - "axios@>=1.0.0 <1.8.2": ">=1.16.1", + "axios@>=1.0.0 <1.8.2": ">=1.17.0", "brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6", "brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6", "esbuild@<=0.24.2": ">=0.28.0", @@ -88,13 +88,13 @@ "nanoid@>=4.0.0 <5.0.9": ">=5.1.11", "prismjs@<1.30.0": ">=1.30.0", "proxmox-api>undici": "7.24.6", - "react-is": "^19.2.6", - "rollup@>=4.0.0 <4.22.4": ">=4.60.4", + "react-is": "^19.2.7", + "rollup@>=4.0.0 <4.22.4": ">=4.61.1", "sha.js@<=2.4.11": ">=2.4.12", "tar-fs@>=3.0.0 <3.0.9": ">=3.1.2", "tar-fs@>=2.0.0 <2.1.3": ">=3.1.2", "tmp@<=0.2.3": ">=0.2.7", - "vite@>=5.0.0 <=5.4.18": ">=8.0.14" + "vite@>=5.0.0 <=5.4.18": ">=8.0.16" }, "patchedDependencies": { "@types/node-unifi": "patches/@types__node-unifi.patch", diff --git a/packages/api/package.json b/packages/api/package.json index 7f42cc842..c7e74a1ca 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -13,7 +13,8 @@ "./server": "./src/server.ts", "./websocket": "./src/websocket.ts", "./shared": "./src/shared.ts", - "./open-api": "./src/open-api.ts" + "./open-api": "./src/open-api.ts", + "./mcp": "./src/mcp.ts" }, "scripts": { "clean": "rm -rf .turbo node_modules", @@ -51,6 +52,7 @@ "react": "catalog:", "react-dom": "catalog:", "superjson": "catalog:", + "trpc-to-mcp": "catalog:", "trpc-to-openapi": "catalog:", "zod": "catalog:" }, diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts index 7cc81cc57..ba26a264a 100644 --- a/packages/api/src/env.ts +++ b/packages/api/src/env.ts @@ -1,12 +1,14 @@ import { z } from "zod/v4"; -import { createEnv } from "@homarr/core/infrastructure/env"; +import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env"; export const env = createEnv({ server: { KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(), + DEMO_MODE: createBooleanSchema(false), }, runtimeEnv: { KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME, + DEMO_MODE: process.env.DEMO_MODE, }, }); diff --git a/packages/api/src/mcp.ts b/packages/api/src/mcp.ts new file mode 100644 index 000000000..299a88e0b --- /dev/null +++ b/packages/api/src/mcp.ts @@ -0,0 +1,36 @@ +export { createTRPCContext } from "./trpc"; + +import { createTRPCRouter } from "./trpc"; +import { appRouter as appRouterForApps } from "./router/app"; +import { apiKeysRouter } from "./router/apiKeys"; +import { boardRouter } from "./router/board"; +import { dockerRouter } from "./router/docker/docker-router"; +import { iconsRouter } from "./router/icons"; +import { infoRouter } from "./router/info"; +import { integrationRouter } from "./router/integration/integration-router"; +import { inviteRouter } from "./router/invite"; +import { calendarRouter } from "./router/widgets/calendar"; +import { dnsHoleRouter } from "./router/widgets/dns-hole"; +import { downloadsRouter } from "./router/widgets/downloads"; +import { healthMonitoringRouter } from "./router/widgets/health-monitoring"; +import { mediaRequestsRouter } from "./router/widgets/media-requests"; +import { mediaServerRouter } from "./router/widgets/media-server"; +import { smartHomeRouter } from "./router/widgets/smart-home"; + +export const mcpRouter = createTRPCRouter({ + app: appRouterForApps, + apiKeys: apiKeysRouter, + board: boardRouter, + docker: dockerRouter, + icon: iconsRouter, + info: infoRouter, + integration: integrationRouter, + invite: inviteRouter, + calendar: calendarRouter, + dnsHole: dnsHoleRouter, + downloads: downloadsRouter, + healthMonitoring: healthMonitoringRouter, + mediaRequests: mediaRequestsRouter, + mediaServer: mediaServerRouter, + smartHome: smartHomeRouter, +}); diff --git a/packages/api/src/middlewares/docker.ts b/packages/api/src/middlewares/docker.ts index 5a317b95a..e4f04f192 100644 --- a/packages/api/src/middlewares/docker.ts +++ b/packages/api/src/middlewares/docker.ts @@ -2,11 +2,11 @@ import { TRPCError } from "@trpc/server"; import { env } from "@homarr/docker/env"; -import { publicProcedure } from "../trpc"; +import { isDemoMode, publicProcedure } from "../trpc"; export const dockerMiddleware = () => { return publicProcedure.use(async ({ next }) => { - if (env.ENABLE_DOCKER) { + if (env.ENABLE_DOCKER || isDemoMode) { return await next(); } throw new TRPCError({ diff --git a/packages/api/src/open-api.ts b/packages/api/src/open-api.ts index d789582df..5c75cfbc2 100644 --- a/packages/api/src/open-api.ts +++ b/packages/api/src/open-api.ts @@ -3,6 +3,7 @@ import { generateOpenApiDocument } from "trpc-to-openapi"; import { API_KEY_HEADER_NAME } from "@homarr/auth/api-key"; import { appRouter } from "./router/app"; +import { boardRouter } from "./router/board"; import { infoRouter } from "./router/info"; import { inviteRouter } from "./router/invite"; import { userRouter } from "./router/user"; @@ -10,6 +11,7 @@ import { createTRPCRouter } from "./trpc"; export const openApiRouter = createTRPCRouter({ appRouter, + boardRouter, infoRouter, inviteRouter, userRouter, diff --git a/packages/api/src/router/apiKeys.ts b/packages/api/src/router/apiKeys.ts index 71efd98ca..26bbb4552 100644 --- a/packages/api/src/router/apiKeys.ts +++ b/packages/api/src/router/apiKeys.ts @@ -1,7 +1,8 @@ import { z } from "zod/v4"; +import { randomBytes } from "crypto"; + import { hashPasswordAsync } from "@homarr/auth"; -import { createId } from "@homarr/common"; import { generateSecureRandomToken } from "@homarr/common/server"; import { db, eq } from "@homarr/db"; import { apiKeys } from "@homarr/db/schema"; @@ -9,39 +10,58 @@ import { apiKeys } from "@homarr/db/schema"; import { createTRPCRouter, permissionRequiredProcedure } from "../trpc"; export const apiKeysRouter = createTRPCRouter({ - getAll: permissionRequiredProcedure.requiresPermission("admin").query(() => { - return db.query.apiKeys.findMany({ - columns: { - id: true, - apiKey: false, - }, - with: { - user: { - columns: { - id: true, - name: true, - image: true, - email: true, + getAll: permissionRequiredProcedure + .requiresPermission("admin") + .meta({ + mcp: { enabled: true, description: "List all API keys (admin only)" }, + }) + .query(() => { + return db.query.apiKeys.findMany({ + columns: { + id: true, + apiKey: false, + }, + with: { + user: { + columns: { + id: true, + name: true, + image: true, + email: true, + }, }, }, + }); + }), + create: permissionRequiredProcedure + .requiresPermission("admin") + .meta({ + mcp: { + enabled: true, + description: "Create a new API key for the current user (admin only)", }, - }); - }), - create: permissionRequiredProcedure.requiresPermission("admin").mutation(async ({ ctx }) => { - const randomToken = generateSecureRandomToken(64); - const hashedRandomToken = await hashPasswordAsync(randomToken); - const id = createId(); - await db.insert(apiKeys).values({ - id, - apiKey: hashedRandomToken, - userId: ctx.session.user.id, - }); - return { - apiKey: `${id}.${randomToken}`, - }; - }), + }) + .mutation(async ({ ctx }) => { + const id = randomBytes(4).toString("hex"); + const token = generateSecureRandomToken(24); + const hashedToken = await hashPasswordAsync(token); + await db.insert(apiKeys).values({ + id, + apiKey: hashedToken, + userId: ctx.session.user.id, + }); + return { + apiKey: `${id}.${token}`, + }; + }), delete: permissionRequiredProcedure .requiresPermission("admin") + .meta({ + mcp: { + enabled: true, + description: "Delete an API key by ID (admin only). REQUIRED: apiKeyId (string)", + }, + }) .input(z.object({ apiKeyId: z.string() })) .mutation(async ({ ctx, input }) => { await ctx.db.delete(apiKeys).where(eq(apiKeys.id, input.apiKeyId)).limit(1); diff --git a/packages/api/src/router/app.ts b/packages/api/src/router/app.ts index 2f2445330..1b55d0260 100644 --- a/packages/api/src/router/app.ts +++ b/packages/api/src/router/app.ts @@ -21,7 +21,19 @@ export const appRouter = createTRPCRouter({ getPaginated: protectedProcedure .input(paginatedSchema) .output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() })) - .meta({ openapi: { method: "GET", path: "/api/apps/paginated", tags: ["apps"], protect: true } }) + .meta({ + openapi: { + method: "GET", + path: "/api/apps/paginated", + tags: ["apps"], + protect: true, + }, + mcp: { + enabled: true, + description: + "List apps with pagination. OPTIONAL: search (string to filter by name), pageSize (number, default 10), page (number, default 1). All fields are optional — call with no arguments to get the first page", + }, + }) .query(async ({ input, ctx }) => { const whereQuery = input.search ? like(apps.name, `%${input.search.trim()}%`) : undefined; const totalCount = await ctx.db.$count(apps, whereQuery); @@ -41,16 +53,40 @@ export const appRouter = createTRPCRouter({ all: protectedProcedure .input(z.void()) .output(z.array(selectAppSchema)) - .meta({ openapi: { method: "GET", path: "/api/apps", tags: ["apps"], protect: true } }) + .meta({ + openapi: { + method: "GET", + path: "/api/apps", + tags: ["apps"], + protect: true, + }, + mcp: { enabled: true, description: "List all apps" }, + }) .query(({ ctx }) => { return ctx.db.query.apps.findMany({ orderBy: asc(apps.name), }); }), search: protectedProcedure - .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) })) + .input( + z.object({ + query: z.string(), + limit: z.number().min(1).max(100).default(10), + }), + ) .output(z.array(selectAppSchema)) - .meta({ openapi: { method: "GET", path: "/api/apps/search", tags: ["apps"], protect: true } }) + .meta({ + openapi: { + method: "GET", + path: "/api/apps/search", + tags: ["apps"], + protect: true, + }, + mcp: { + enabled: true, + description: "Search apps by name. REQUIRED: query (search string). OPTIONAL: limit (number, default 10)", + }, + }) .query(({ ctx, input }) => { return ctx.db.query.apps.findMany({ where: like(apps.name, `%${input.query}%`), @@ -62,7 +98,14 @@ export const appRouter = createTRPCRouter({ .input(z.void()) .output( z.array( - selectAppSchema.pick({ id: true, name: true, iconUrl: true, href: true, pingUrl: true, description: true }), + selectAppSchema.pick({ + id: true, + name: true, + iconUrl: true, + href: true, + pingUrl: true, + description: true, + }), ), ) .meta({ @@ -89,7 +132,15 @@ export const appRouter = createTRPCRouter({ byId: publicProcedure .input(byIdSchema) .output(selectAppSchema) - .meta({ openapi: { method: "GET", path: "/api/apps/{id}", tags: ["apps"], protect: true } }) + .meta({ + openapi: { + method: "GET", + path: "/api/apps/{id}", + tags: ["apps"], + protect: true, + }, + mcp: { enabled: true, description: "Get a single app by its ID. REQUIRED: id (app ID string)" }, + }) .query(async ({ ctx, input }) => { const repository = new AppRepository(ctx.db, ctx.session?.user ?? null); const app = await repository.getByIdAsync(input.id); @@ -111,7 +162,19 @@ export const appRouter = createTRPCRouter({ .requiresPermission("app-create") .input(appManageSchema) .output(z.object({ appId: z.string() }).and(selectAppSchema)) - .meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } }) + .meta({ + openapi: { + method: "POST", + path: "/api/apps", + tags: ["apps"], + protect: true, + }, + mcp: { + enabled: true, + description: + "Create a new app (bookmark/shortcut to a service). REQUIRED: name (string), iconUrl (icon URL string), href (app URL, http/https or blank). OPTIONAL: description (string or null), pingUrl (URL to check reachability, or empty string)", + }, + }) .mutation(async ({ ctx, input }) => { const id = createId(); const insertValues = { @@ -146,7 +209,19 @@ export const appRouter = createTRPCRouter({ .requiresPermission("app-modify-all") .input(convertIntersectionToZodObject(appEditSchema)) .output(z.void()) - .meta({ openapi: { method: "PATCH", path: "/api/apps/{id}", tags: ["apps"], protect: true } }) + .meta({ + openapi: { + method: "PATCH", + path: "/api/apps/{id}", + tags: ["apps"], + protect: true, + }, + mcp: { + enabled: true, + description: + "Update an existing app. REQUIRED: id (app ID), name, iconUrl, href. OPTIONAL: description (string or null), pingUrl (URL or empty string)", + }, + }) .mutation(async ({ ctx, input }) => { const app = await ctx.db.query.apps.findFirst({ where: eq(apps.id, input.id), @@ -173,7 +248,15 @@ export const appRouter = createTRPCRouter({ delete: permissionRequiredProcedure .requiresPermission("app-full-all") .output(z.void()) - .meta({ openapi: { method: "DELETE", path: "/api/apps/{id}", tags: ["apps"], protect: true } }) + .meta({ + openapi: { + method: "DELETE", + path: "/api/apps/{id}", + tags: ["apps"], + protect: true, + }, + mcp: { enabled: true, description: "Delete an app by ID. REQUIRED: id (app ID string)" }, + }) .input(byIdSchema) .mutation(async ({ ctx, input }) => { await ctx.db.delete(apps).where(eq(apps.id, input.id)); diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 495d410c7..07ec44d07 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -19,6 +19,7 @@ import { integrationGroupPermissions, integrationItems, integrationUserPermissions, + integrations, itemLayouts, items, layouts, @@ -39,6 +40,7 @@ import { importOldmarrAsync } from "@homarr/old-import"; import { importJsonFileSchema } from "@homarr/old-import/shared"; import { oldmarrConfigSchema } from "@homarr/old-schema"; import { + addItemToBoardSchema, boardByNameSchema, boardChangeVisibilitySchema, boardCreateSchema, @@ -48,6 +50,7 @@ import { boardSavePartialSettingsSchema, boardSavePermissionsSchema, boardSaveSchema, + boardSummarySchema, } from "@homarr/validation/board"; import { byIdSchema } from "@homarr/validation/common"; import { zodUnionFromArray } from "@homarr/validation/enums"; @@ -114,78 +117,89 @@ export const boardRouter = createTRPCRouter({ where: boardWhere, }); }), - getAllBoards: publicProcedure.query(async ({ ctx }) => { - const userId = ctx.session?.user.id; - const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({ - where: eq(boardUserPermissions.userId, userId ?? ""), - }); + getAllBoards: publicProcedure + .input(z.void()) + .output(z.array(boardSummarySchema)) + .meta({ + openapi: { method: "GET", path: "/api/boards", tags: ["boards"], protect: true }, + mcp: { + enabled: true, + description: + "List all boards the current user can access. Returns id, name, logoImageUrl, isPublic, creator, isHome and isMobileHome flags", + }, + }) + .query(async ({ ctx }) => { + const userId = ctx.session?.user.id; + const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({ + where: eq(boardUserPermissions.userId, userId ?? ""), + }); - const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({ - where: eq(groupMembers.userId, userId ?? ""), - with: { - group: { - with: { - boardPermissions: {}, + const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({ + where: eq(groupMembers.userId, userId ?? ""), + with: { + group: { + with: { + boardPermissions: {}, + }, }, }, - }, - }); - const boardIds = permissionsOfCurrentUserWhenPresent - .map((permission) => permission.boardId) - .concat( - permissionsOfCurrentUserGroupsWhenPresent - .map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId)) - .flat(), - ); + }); + const boardIds = permissionsOfCurrentUserWhenPresent + .map((permission) => permission.boardId) + .concat( + permissionsOfCurrentUserGroupsWhenPresent + .map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId)) + .flat(), + ); - const currentUserWhenPresent = await ctx.db.query.users.findFirst({ - where: eq(users.id, userId ?? ""), - }); + const currentUserWhenPresent = await ctx.db.query.users.findFirst({ + where: eq(users.id, userId ?? ""), + }); - const dbBoards = await ctx.db.query.boards.findMany({ - columns: { - id: true, - name: true, - logoImageUrl: true, - isPublic: true, - }, - with: { - creator: { - columns: { - id: true, - name: true, - image: true, - email: true, + const dbBoards = await ctx.db.query.boards.findMany({ + columns: { + id: true, + name: true, + logoImageUrl: true, + isPublic: true, + }, + with: { + creator: { + columns: { + id: true, + name: true, + image: true, + email: true, + }, + }, + userPermissions: { + where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""), + }, + groupPermissions: { + where: + permissionsOfCurrentUserGroupsWhenPresent.length >= 1 + ? inArray( + boardGroupPermissions.groupId, + permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId), + ) + : undefined, }, }, - userPermissions: { - where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""), - }, - groupPermissions: { - where: - permissionsOfCurrentUserGroupsWhenPresent.length >= 1 - ? inArray( - boardGroupPermissions.groupId, - permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId), - ) - : undefined, - }, - }, - // Allow viewing all boards if the user has the permission - where: ctx.session?.user.permissions.includes("board-view-all") - ? undefined - : or( - eq(boards.isPublic, true), - eq(boards.creatorId, ctx.session?.user.id ?? ""), - boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined, - ), - }); - return dbBoards.map((board) => ({ - ...board, - isHome: currentUserWhenPresent?.homeBoardId === board.id, - isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id, - })); - }), + // Allow viewing all boards if the user has the permission + where: ctx.session?.user.permissions.includes("board-view-all") + ? undefined + : or( + eq(boards.isPublic, true), + eq(boards.creatorId, ctx.session?.user.id ?? ""), + boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined, + ), + }); + return dbBoards.map((board) => ({ + ...board, + isHome: currentUserWhenPresent?.homeBoardId === board.id, + isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id, + })); + }), search: publicProcedure .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) })) .query(async ({ ctx, input }) => { @@ -262,7 +276,16 @@ export const boardRouter = createTRPCRouter({ }), createBoard: permissionRequiredProcedure .requiresPermission("board-create") + .meta({ + openapi: { method: "POST", path: "/api/boards", tags: ["boards"], protect: true }, + mcp: { + enabled: true, + description: + "Create a new board with a name, column count (1-24), and isPublic flag. Returns { boardId }. Requires board-create permission", + }, + }) .input(boardCreateSchema) + .output(z.object({ boardId: z.string() })) .mutation(async ({ ctx, input }) => { const boardId = createId(); @@ -547,11 +570,22 @@ export const boardRouter = createTRPCRouter({ .set({ isPublic: input.visibility === "public" }) .where(eq(boards.id, input.id)); }), - deleteBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => { - await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full"); + deleteBoard: protectedProcedure + .meta({ + openapi: { method: "DELETE", path: "/api/boards/{id}", tags: ["boards"], protect: true }, + mcp: { + enabled: true, + description: + "Delete a board by its ID. Requires full permission on the board. Use board_getAllBoards to find the board ID", + }, + }) + .input(z.object({ id: z.string() })) + .output(z.void()) + .mutation(async ({ ctx, input }) => { + await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full"); - await ctx.db.delete(boards).where(eq(boards.id, input.id)); - }), + await ctx.db.delete(boards).where(eq(boards.id, input.id)); + }), setHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view"); @@ -613,7 +647,7 @@ export const boardRouter = createTRPCRouter({ boardId: board.id, }); - const sortedLayouts = board.layouts.sort((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount); + const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount); // Fallback to biggest if none exists with columnCount bigger than addedLayout.columnCount const layoutToClone = // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -1215,7 +1249,7 @@ export const boardRouter = createTRPCRouter({ }); return { - inherited: dbGroupPermissions.sort((permissionA, permissionB) => { + inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => { return permissionA.group.name.localeCompare(permissionB.group.name); }), users: userPermissions @@ -1223,7 +1257,7 @@ export const boardRouter = createTRPCRouter({ user, permission, })) - .sort((permissionA, permissionB) => { + .toSorted((permissionA, permissionB) => { return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? ""); }), groups: dbGroupBoardPermission @@ -1234,7 +1268,7 @@ export const boardRouter = createTRPCRouter({ }, permission, })) - .sort((permissionA, permissionB) => { + .toSorted((permissionA, permissionB) => { return permissionA.group.name.localeCompare(permissionB.group.name); }), }; @@ -1327,6 +1361,119 @@ export const boardRouter = createTRPCRouter({ const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content)); await importOldmarrAsync(ctx.db, oldmarr, input.configuration); }), + addItem: protectedProcedure + .meta({ + openapi: { method: "POST", path: "/api/boards/items", tags: ["boards"], protect: true }, + mcp: { + enabled: true, + description: + "Add a widget/app item to a board. Automatically places it in the first empty section at the next free grid position. Provide boardId (from board_getAllBoards), kind (widget type like 'app', 'weather', etc.), optional options map, and optional integrationIds array. Returns { itemId }", + }, + }) + .input(addItemToBoardSchema) + .output(z.object({ itemId: z.string() })) + .mutation(async ({ ctx, input }) => { + await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify"); + + if (input.integrationIds.length > 0) { + const existing = await ctx.db.query.integrations.findMany({ + columns: { id: true }, + where: inArray(integrations.id, input.integrationIds), + }); + const validIds = new Set(existing.map((row) => row.id)); + const invalid = input.integrationIds.filter((id) => !validIds.has(id)); + if (invalid.length > 0) { + throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid integration IDs: ${invalid.join(", ")}` }); + } + } + + const board = await ctx.db.query.boards.findFirst({ + where: eq(boards.id, input.boardId), + with: { + sections: true, + layouts: true, + items: { with: { layouts: true } }, + }, + }); + + if (!board) { + throw new TRPCError({ code: "NOT_FOUND", message: "Board not found" }); + } + + const emptySection = board.sections + .filter((s) => s.kind === "empty") + .toSorted((a, b) => (a.yOffset ?? 0) - (b.yOffset ?? 0))[0]; + + if (!emptySection) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Board has no empty section to place items in" }); + } + + const itemId = createId(); + + await ctx.db.insert(items).values({ + id: itemId, + boardId: input.boardId, + kind: input.kind, + options: superjson.stringify(input.options), + advancedOptions: emptySuperJSON, + }); + + const layoutRows: (typeof itemLayouts.$inferInsert)[] = []; + + for (const layout of board.layouts) { + const existingInSection = board.items + .flatMap((item) => item.layouts) + .filter((il) => il.sectionId === emptySection.id && il.layoutId === layout.id); + + const occupied: boolean[][] = []; + for (const il of existingInSection) { + for (let y = il.yOffset; y < il.yOffset + il.height; y++) { + while (occupied.length <= y) occupied.push(Array.from({ length: layout.columnCount }).fill(false)); + for (let x = il.xOffset; x < il.xOffset + il.width; x++) { + occupied[y]![x] = true; + } + } + } + + let placed = false; + for (let y = 0; y < 9999 && !placed; y++) { + if (!occupied[y]) occupied.push(Array.from({ length: layout.columnCount }).fill(false)); + for (let x = 0; x < layout.columnCount && !placed; x++) { + if (!occupied[y]![x]) { + layoutRows.push({ + itemId, + sectionId: emptySection.id, + layoutId: layout.id, + xOffset: x, + yOffset: y, + width: 1, + height: 1, + }); + placed = true; + } + } + } + + if (!placed) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Board section is full, no free grid position available", + }); + } + } + + if (layoutRows.length > 0) { + await ctx.db.insert(itemLayouts).values(layoutRows); + } + + if (input.integrationIds.length > 0) { + await ctx.db + .insert(integrationItems) + .values(input.integrationIds.map((integrationId) => ({ itemId, integrationId }))); + } + + return { itemId }; + }), }); /** @@ -1586,7 +1733,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL, use ...otherBoardProperties, layouts: layouts .map(({ boardId: _, ...layout }) => layout) - .sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint), + .toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint), sections: sections.map(({ collapseStates, ...section }) => parseSection({ ...section, @@ -1604,22 +1751,24 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL, use collapsed: collapseStates.at(0)?.collapsed ?? false, }), ), - items: items.map(({ integrations: itemIntegrations, ...item }) => - parseItem({ - ...item, - layouts: item.layouts.map((layout) => ({ - xOffset: layout.xOffset, - yOffset: layout.yOffset, - width: layout.width, - height: layout.height, - layoutId: layout.layoutId, - sectionId: layout.sectionId, - })), - integrationIds: itemIntegrations.map((item) => item.integration.id), - advancedOptions: superjson.parse(item.advancedOptions), - options: superjson.parse>(item.options), - }), - ), + items: items + .map(({ integrations: itemIntegrations, ...item }) => + parseItem({ + ...item, + layouts: item.layouts.map((layout) => ({ + xOffset: layout.xOffset, + yOffset: layout.yOffset, + width: layout.width, + height: layout.height, + layoutId: layout.layoutId, + sectionId: layout.sectionId, + })), + integrationIds: itemIntegrations.map((item) => item.integration.id), + advancedOptions: superjson.parse(item.advancedOptions), + options: superjson.parse>(item.options), + }), + ) + .filter((item) => item !== null), }; }; @@ -1635,7 +1784,7 @@ const parseItem = (item: unknown) => { const result = outputItemSchema.safeParse(item); if (!result.success) { - throw new Error(result.error.message); + return null; } return result.data; }; diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts index 4527482f0..0011545d0 100644 --- a/packages/api/src/router/docker/docker-router.ts +++ b/packages/api/src/router/docker/docker-router.ts @@ -4,7 +4,11 @@ import { z } from "zod/v4"; import type { Container, ContainerState, Docker, Port } from "@homarr/docker"; import { DockerSingleton } from "@homarr/docker"; -import { dockerContainersRequestHandler } from "@homarr/request-handler/docker"; +import { + dockerContainersRequestHandler, + getContainerLogsAsync, + streamContainerLogsAsync, +} from "@homarr/request-handler/docker"; import { dockerMiddleware } from "../../middlewares/docker"; import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc"; @@ -12,10 +16,18 @@ import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc"; export const dockerRouter = createTRPCRouter({ getContainers: permissionRequiredProcedure .requiresPermission("admin") + .meta({ + mcp: { + enabled: true, + description: "List all Docker containers with their state, image, CPU/memory usage, and ports", + }, + }) .concat(dockerMiddleware()) .query(async () => { const innerHandler = dockerContainersRequestHandler.handler({}); - const result = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const result = await innerHandler.getCachedOrUpdatedDataAsync({ + forceUpdate: false, + }); const { data, timestamp } = result; @@ -46,6 +58,12 @@ export const dockerRouter = createTRPCRouter({ }), startAll: permissionRequiredProcedure .requiresPermission("admin") + .meta({ + mcp: { + enabled: true, + description: "Start Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)", + }, + }) .concat(dockerMiddleware()) .input(z.object({ ids: z.array(z.string()) })) .mutation(async ({ input }) => { @@ -61,6 +79,12 @@ export const dockerRouter = createTRPCRouter({ }), stopAll: permissionRequiredProcedure .requiresPermission("admin") + .meta({ + mcp: { + enabled: true, + description: "Stop Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)", + }, + }) .concat(dockerMiddleware()) .input(z.object({ ids: z.array(z.string()) })) .mutation(async ({ input }) => { @@ -76,6 +100,13 @@ export const dockerRouter = createTRPCRouter({ }), restartAll: permissionRequiredProcedure .requiresPermission("admin") + .meta({ + mcp: { + enabled: true, + description: + "Restart Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)", + }, + }) .concat(dockerMiddleware()) .input(z.object({ ids: z.array(z.string()) })) .mutation(async ({ input }) => { @@ -91,6 +122,13 @@ export const dockerRouter = createTRPCRouter({ }), removeAll: permissionRequiredProcedure .requiresPermission("admin") + .meta({ + mcp: { + enabled: true, + description: + "Remove/delete Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)", + }, + }) .concat(dockerMiddleware()) .input(z.object({ ids: z.array(z.string()) })) .mutation(async ({ input }) => { @@ -104,6 +142,91 @@ export const dockerRouter = createTRPCRouter({ const innerHandler = dockerContainersRequestHandler.handler({}); await innerHandler.invalidateAsync(); }), + logs: permissionRequiredProcedure + .requiresPermission("admin") + .meta({ + mcp: { + enabled: true, + description: + "Fetch logs from a Docker container. REQUIRED: id (container ID from docker_getContainers). OPTIONAL: tail (number 1-1000, default 200)", + }, + }) + .concat(dockerMiddleware()) + .input( + z.object({ + id: z.string(), + tail: z.number().min(1).max(1000).optional(), + }), + ) + .query(async ({ input }) => { + const logs = await getContainerLogsAsync(input.id, input.tail ?? 200); + + if (!logs) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Container not found", + }); + } + + return { + logs, + }; + }), + subscribeLogs: permissionRequiredProcedure + .requiresPermission("admin") + .concat(dockerMiddleware()) + .input( + z.object({ + id: z.string(), + tail: z.number().min(1).max(1000).optional(), + }), + ) + .subscription(({ input }) => { + return observable((emit) => { + let cleanupFn: (() => void) | undefined; + let isSubscribed = true; + + const initializeStreamAsync = async () => { + try { + cleanupFn = await streamContainerLogsAsync( + input.id, + input.tail ?? 200, + (data) => { + if (isSubscribed) { + emit.next(data); + } + }, + (err) => { + if (isSubscribed) { + emit.error( + new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: err.message, + }), + ); + } + }, + ); + } catch (err) { + if (isSubscribed) { + emit.error( + new TRPCError({ + code: "NOT_FOUND", + message: err instanceof Error ? err.message : "Container not found", + }), + ); + } + } + }; + + void initializeStreamAsync(); + + return () => { + isSubscribed = false; + cleanupFn?.(); + }; + }); + }), }); const getContainerOrDefaultAsync = async (instance: Docker, id: string) => { diff --git a/packages/api/src/router/icons.ts b/packages/api/src/router/icons.ts index 04f15fc2d..e75eda8ab 100644 --- a/packages/api/src/router/icons.ts +++ b/packages/api/src/router/icons.ts @@ -5,26 +5,35 @@ import { iconsFindSchema } from "@homarr/validation/icons"; import { createTRPCRouter, publicProcedure } from "../trpc"; export const iconsRouter = createTRPCRouter({ - findIcons: publicProcedure.input(iconsFindSchema).query(async ({ ctx, input }) => { - return { - icons: await ctx.db.query.iconRepositories.findMany({ - with: { - icons: { - columns: { - id: true, - name: true, - url: true, + findIcons: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Search for icons by name across all icon repositories. OPTIONAL: searchText (string to filter), limitPerGroup (number 1-500, default 12). Call with no arguments to browse all icons", + }, + }) + .input(iconsFindSchema) + .query(async ({ ctx, input }) => { + return { + icons: await ctx.db.query.iconRepositories.findMany({ + with: { + icons: { + columns: { + id: true, + name: true, + url: true, + }, + where: + (input.searchText?.length ?? 0) > 0 + ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`))) + : undefined, + limit: input.limitPerGroup, }, - where: - (input.searchText?.length ?? 0) > 0 - ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`))) - : undefined, - limit: input.limitPerGroup, }, - }, - }), - countIcons: await ctx.db.$count(icons), - }; - }), + }), + countIcons: await ctx.db.$count(icons), + }; + }), }); diff --git a/packages/api/src/router/info.ts b/packages/api/src/router/info.ts index 3c205963c..a0436ede3 100644 --- a/packages/api/src/router/info.ts +++ b/packages/api/src/router/info.ts @@ -1,16 +1,23 @@ import z from "zod/v4"; import packageJson from "../../../../package.json"; -import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc"; export const infoRouter = createTRPCRouter({ getInfo: protectedProcedure .input(z.void()) .output(z.object({ version: z.string() })) - .meta({ openapi: { method: "GET", path: "/api/info", tags: ["info"] } }) + .meta({ + openapi: { method: "GET", path: "/api/info", tags: ["info"] }, + mcp: { + enabled: true, + description: "Get Homarr server version information", + }, + }) .query(() => { return { version: packageJson.version, }; }), + isDemoMode: publicProcedure.query(() => isDemoMode), }); diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index 5591b74ed..c5dd9a9b6 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -1,6 +1,8 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; +import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server"; +import { constructIntegrationPermissions } from "@homarr/auth/shared"; import { createId, objectEntries } from "@homarr/common"; import { decryptSecret, encryptSecret } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; @@ -14,11 +16,9 @@ import { integrations, integrationSecrets, integrationUserPermissions, - searchEngines, } from "@homarr/db/schema"; import type { IntegrationSecretKind } from "@homarr/definitions"; import { - getIconUrl, getIntegrationKindsByCategory, getPermissionsWithParents, integrationCategories, @@ -33,6 +33,7 @@ import { integrationSavePermissionsSchema, integrationUpdateSchema, } from "@homarr/validation/integration"; +import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request"; import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; @@ -41,50 +42,75 @@ import { MissingSecretError, testConnectionAsync } from "./integration-test-conn import { mapTestConnectionError } from "./map-test-connection-error"; const logger = createLogger({ module: "integrationRouter" }); +const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch"); export const integrationRouter = createTRPCRouter({ - all: protectedProcedure.query(async ({ ctx }) => { - const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({ - where: eq(groupMembers.userId, ctx.session.user.id), - }); - - const integrations = await ctx.db.query.integrations.findMany({ - with: { - userPermissions: { - where: eq(integrationUserPermissions.userId, ctx.session.user.id), - }, - groupPermissions: { - where: inArray( - integrationGroupPermissions.groupId, - groupsOfCurrentUser.map((group) => group.groupId), - ), - }, + getKinds: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "List all supported integration kinds (e.g. sonarr, radarr, overseerr, pihole, homeAssistant) with the secret fields each kind requires. Use this before creating an integration to know which 'kind' values are valid and what secrets to provide.", }, - }); - return integrations - .map((integration) => { - const permissions = integration.userPermissions - .map(({ permission }) => permission) - .concat(integration.groupPermissions.map(({ permission }) => permission)); + }) + .query(() => { + return objectEntries(integrationDefs).map(([kind, def]) => ({ + kind, + name: def.name, + category: def.category, + requiredSecrets: def.secretKinds, + })); + }), + all: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: + "List all configured integrations (connections to services like Sonarr, Radarr, Plex, etc.). Returns each integration's id, name, kind, url, and permissions. Use the 'id' field as 'integrationId' in other tools. Check permissions.hasUseAccess before reading data and permissions.hasInteractAccess before performing actions — false means the API key owner lacks that permission level for this integration, not an error", + }, + }) + .query(async ({ ctx }) => { + const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({ + where: eq(groupMembers.userId, ctx.session.user.id), + }); - return { - id: integration.id, - name: integration.name, - kind: integration.kind, - url: integration.url, - permissions: { - hasUseAccess: - permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"), - hasInteractAccess: permissions.includes("interact") || permissions.includes("full"), - hasFullAccess: permissions.includes("full"), + const integrations = await ctx.db.query.integrations.findMany({ + with: { + userPermissions: { + where: eq(integrationUserPermissions.userId, ctx.session.user.id), }, - }; - }) - .sort( - (integrationA, integrationB) => - integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), - ); - }), + groupPermissions: { + where: inArray( + integrationGroupPermissions.groupId, + groupsOfCurrentUser.map((group) => group.groupId), + ), + }, + }, + }); + return integrations + .map((integration) => { + const permissions = integration.userPermissions + .map(({ permission }) => permission) + .concat(integration.groupPermissions.map(({ permission }) => permission)); + + return { + id: integration.id, + name: integration.name, + kind: integration.kind, + url: integration.url, + permissions: { + hasUseAccess: + permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"), + hasInteractAccess: permissions.includes("interact") || permissions.includes("full"), + hasFullAccess: permissions.includes("full"), + }, + }; + }) + .toSorted( + (integrationA, integrationB) => + integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), + ); + }), allThatSupportSearch: protectedProcedure.query(async ({ ctx }) => { const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({ where: eq(groupMembers.userId, ctx.session.user.id), @@ -128,7 +154,7 @@ export const integrationRouter = createTRPCRouter({ }, }; }) - .sort( + .toSorted( (integrationA, integrationB) => integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), ); @@ -179,13 +205,25 @@ export const integrationRouter = createTRPCRouter({ }, }; }) - .sort( + .toSorted( (integrationA, integrationB) => integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), ); }), search: protectedProcedure - .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) })) + .meta({ + mcp: { + enabled: true, + description: + "Search integrations by name. REQUIRED: query (search string). OPTIONAL: limit (number, default 10). Returns matching integrations with id (use as integrationId), name, kind, url, and permissions", + }, + }) + .input( + z.object({ + query: z.string(), + limit: z.number().min(1).max(100).default(10), + }), + ) .query(async ({ ctx, input }) => { return await ctx.db.query.integrations.findMany({ where: like(integrations.name, `%${input.query}%`), @@ -203,52 +241,68 @@ export const integrationRouter = createTRPCRouter({ }, }); }), - byId: protectedProcedure.input(byIdSchema).query(async ({ ctx, input }) => { - await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); - const integration = await ctx.db.query.integrations.findFirst({ - where: eq(integrations.id, input.id), - with: { - secrets: { - columns: { - kind: true, - value: true, - updatedAt: true, - }, - }, - app: { - columns: { - id: true, - name: true, - iconUrl: true, - href: true, - }, - }, + byId: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get integration details by ID, including secrets metadata and linked app. REQUIRED: id (integration ID string)", }, - }); - - if (!integration) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Integration not found", + }) + .input(byIdSchema) + .query(async ({ ctx, input }) => { + await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); + const integration = await ctx.db.query.integrations.findFirst({ + where: eq(integrations.id, input.id), + with: { + secrets: { + columns: { + kind: true, + value: true, + updatedAt: true, + }, + }, + app: { + columns: { + id: true, + name: true, + iconUrl: true, + href: true, + }, + }, + }, }); - } - return { - id: integration.id, - name: integration.name, - kind: integration.kind, - url: integration.url, - secrets: integration.secrets.map((secret) => ({ - kind: secret.kind, - // Only return the value if the secret is public, so for example the username - value: integrationSecretKindObject[secret.kind].isPublic ? decryptSecret(secret.value) : null, - updatedAt: secret.updatedAt, - })), - app: integration.app, - }; - }), + if (!integration) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Integration not found", + }); + } + + return { + id: integration.id, + name: integration.name, + kind: integration.kind, + url: integration.url, + secrets: integration.secrets.map((secret) => ({ + kind: secret.kind, + // Only return the value if the secret is public, so for example the username + value: integrationSecretKindObject[secret.kind].isPublic ? decryptSecret(secret.value) : null, + updatedAt: secret.updatedAt, + })), + app: integration.app, + }; + }), create: permissionRequiredProcedure .requiresPermission("integration-create") + .meta({ + mcp: { + enabled: true, + description: + "Create a new integration (connection to an external service). REQUIRED fields: name, url (http/https), kind, secrets, attemptSearchEngineCreation. The 'secrets' field is REQUIRED and must be a non-empty array — call integration_getKinds first to see which secret kinds each integration type needs. Example for Radarr: secrets=[{kind:'apiKey', value:'your-radarr-api-key'}]. Example for Proxmox: secrets=[{kind:'tokenId', value:'...'}, {kind:'personalAccessToken', value:'...'}, {kind:'realm', value:'pam'}]. The connection is tested before saving — if secrets are wrong, an error is returned. Set attemptSearchEngineCreation to false unless explicitly requested. The 'app' field is optional — pass {id:'...'} to link to an existing app, or omit it.", + }, + }) .input(integrationCreateSchema) .mutation(async ({ ctx, input }) => { logger.info("Creating integration", { @@ -313,21 +367,6 @@ export const integrationRouter = createTRPCRouter({ kind: input.kind, url: input.url, }); - - if ( - input.attemptSearchEngineCreation && - integrationDefs[input.kind].category.flatMap((category) => category).includes("search") - ) { - const icon = getIconUrl(input.kind); - await ctx.db.insert(searchEngines).values({ - id: createId(), - name: input.name, - integrationId, - type: "fromIntegration", - iconUrl: icon, - short: await getNextValidShortNameForSearchEngineAsync(ctx.db, input.name), - }); - } }), update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); @@ -431,22 +470,27 @@ export const integrationRouter = createTRPCRouter({ url: input.url, }); }), - delete: protectedProcedure.input(byIdSchema).mutation(async ({ ctx, input }) => { - await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); + delete: protectedProcedure + .meta({ + mcp: { enabled: true, description: "Delete an integration by ID. REQUIRED: id (integration ID string)" }, + }) + .input(byIdSchema) + .mutation(async ({ ctx, input }) => { + await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); - const integration = await ctx.db.query.integrations.findFirst({ - where: eq(integrations.id, input.id), - }); - - if (!integration) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Integration not found", + const integration = await ctx.db.query.integrations.findFirst({ + where: eq(integrations.id, input.id), }); - } - await ctx.db.delete(integrations).where(eq(integrations.id, input.id)); - }), + if (!integration) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Integration not found", + }); + } + + await ctx.db.delete(integrations).where(eq(integrations.id, input.id)); + }), getIntegrationPermissions: protectedProcedure.input(byIdSchema).query(async ({ input, ctx }) => { await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); @@ -495,7 +539,7 @@ export const integrationRouter = createTRPCRouter({ }); return { - inherited: dbGroupPermissions.sort((permissionA, permissionB) => { + inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => { return permissionA.group.name.localeCompare(permissionB.group.name); }), users: userPermissions @@ -503,7 +547,7 @@ export const integrationRouter = createTRPCRouter({ user, permission, })) - .sort((permissionA, permissionB) => { + .toSorted((permissionA, permissionB) => { return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? ""); }), groups: dbGroupIntegrationPermission @@ -514,7 +558,7 @@ export const integrationRouter = createTRPCRouter({ }, permission, })) - .sort((permissionA, permissionB) => { + .toSorted((permissionA, permissionB) => { return permissionA.group.name.localeCompare(permissionB.group.name); }), }; @@ -612,14 +656,190 @@ export const integrationRouter = createTRPCRouter({ }); }), searchInIntegration: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: + "Search within a specific integration (e.g. Overseerr, Jellyseerr). REQUIRED: integrationId (from integration_all), query (search string)", + }, + }) .concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("search"))) .input(z.object({ integrationId: z.string(), query: z.string() })) .query(async ({ ctx, input }) => { const integrationInstance = await createIntegrationAsync(ctx.integration); return await integrationInstance.searchAsync(encodeURI(input.query)); }), + mediaRequestSearchTargets: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: "List integrations that support media request search (Overseerr, Jellyseerr, Seerr)", + }, + }) + .query(async ({ ctx }) => { + const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx); + + return integrationsWithAccess.map((integration) => { + const permissions = constructIntegrationPermissions(integration, ctx.session); + + return { + id: integration.id, + name: integration.name, + kind: integration.kind, + url: integration.url, + permissions: { + hasInteractAccess: permissions.hasInteractAccess, + }, + }; + }); + }), + searchMediaRequests: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: + "Search for movies and TV shows across Overseerr/Jellyseerr/Seerr. REQUIRED: query (search string). OPTIONAL: integrationIds (array of integration IDs — omit to search all accessible Overseerr/Jellyseerr/Seerr integrations). Returns results with integrationId, mediaId, and mediaType for use with getMediaRequestOptions and requestMedia", + }, + }) + .input( + z.object({ + query: z.string(), + integrationIds: z.array(z.string()).optional(), + }), + ) + .query(async ({ ctx, input }) => { + const query = input.query.trim(); + if (query.length === 0) return []; + + const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx, input.integrationIds); + + const results = await Promise.all( + integrationsWithAccess.map(async (integration) => { + const integrationInstance = await createIntegrationAsync({ + id: integration.id, + name: integration.name, + url: integration.url, + kind: integration.kind as (typeof mediaRequestSearchKinds)[number], + externalUrl: integration.app?.href ?? null, + decryptedSecrets: integration.secrets.map((secret) => ({ + ...secret, + value: decryptSecret(secret.value), + })), + }); + const permissions = constructIntegrationPermissions(integration, ctx.session); + + return await integrationInstance.searchAsync(encodeURI(query)).then((searchResults) => + searchResults.map((result) => ({ + ...result, + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + url: integration.url, + permissions: { + hasInteractAccess: permissions.hasInteractAccess, + }, + }, + })), + ); + }), + ); + + return results.flat(); + }), + getMediaRequestOptions: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get available options (seasons/episodes for TV) before requesting media. REQUIRED: integrationId (Overseerr/Jellyseerr ID), mediaId (number from searchMediaRequests), mediaType ('tv' or 'movie'). Required step for TV shows — for movies you can skip this and go straight to requestMedia", + }, + }) + .concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr", "seerr")) + .input(mediaRequestOptionsSchema) + .query(async ({ ctx, input }) => { + const integration = await createIntegrationAsync(ctx.integration); + return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId); + }), + requestMedia: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: + "Submit a media request to Overseerr/Jellyseerr/Seerr. REQUIRED: integrationId (Overseerr/Jellyseerr ID), mediaId (number), mediaType ('tv' or 'movie'). OPTIONAL: seasons (array of season numbers — for TV only, omit to request all). Workflow: searchMediaRequests → (getMediaRequestOptions for TV) → requestMedia", + }, + }) + .concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr", "seerr")) + .input(mediaRequestRequestSchema) + .mutation(async ({ ctx, input }) => { + const integration = await createIntegrationAsync(ctx.integration); + return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons); + }), }); +interface IntegrationRouterContext { + db: Database; + session: Parameters[2]; +} + +const getAccessibleMediaRequestSearchIntegrationsAsync = async ( + ctx: IntegrationRouterContext, + integrationIds?: string[], +) => { + const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({ + where: eq(groupMembers.userId, ctx.session?.user.id ?? ""), + }); + + const integrationsFromDb = await ctx.db.query.integrations.findMany({ + where: + integrationIds && integrationIds.length > 0 + ? and(inArray(integrations.id, integrationIds), inArray(integrations.kind, mediaRequestSearchKinds)) + : inArray(integrations.kind, mediaRequestSearchKinds), + orderBy: asc(integrations.name), + with: { + app: true, + secrets: true, + items: { + with: { + item: true, + }, + }, + userPermissions: { + where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""), + }, + groupPermissions: { + where: inArray( + integrationGroupPermissions.groupId, + groupsOfCurrentUser.map((group) => group.groupId), + ), + }, + }, + }); + + if (integrationIds && integrationsFromDb.length !== integrationIds.length) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "One or more media request search integrations were not found", + }); + } + + const integrationsWithAccess = await Promise.all( + integrationsFromDb.map(async (integration) => ({ + integration, + hasAccess: await hasQueryAccessToIntegrationsAsync(ctx.db, [integration], ctx.session), + })), + ); + + if (integrationIds && integrationsWithAccess.some(({ hasAccess }) => !hasAccess)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User does not have permission to query one or more media request search integrations", + }); + } + + return integrationsWithAccess.filter(({ hasAccess }) => hasAccess).map(({ integration }) => integration); +}; + interface UpdateSecretInput { integrationId: string; value: string; @@ -640,35 +860,6 @@ interface AddSecretInput { kind: IntegrationSecretKind; } -const getNextValidShortNameForSearchEngineAsync = async (db: Database, integrationName: string) => { - const searchEngines = await db.query.searchEngines.findMany({ - columns: { - short: true, - }, - }); - - const usedShortNames = searchEngines.flatMap((searchEngine) => searchEngine.short.toLowerCase()); - const nameByIntegrationName = integrationName.slice(0, 1).toLowerCase(); - - if (!usedShortNames.includes(nameByIntegrationName)) { - return nameByIntegrationName; - } - - // 8 is max length constraint - for (let i = 2; i < 9999999; i++) { - const generatedName = `${nameByIntegrationName}${i}`; - if (usedShortNames.includes(generatedName)) { - continue; - } - - return generatedName; - } - - throw new Error( - "Unable to automatically generate a short name. All possible variations were exhausted. Please disable the automatic creation and choose one later yourself.", - ); -}; - const addSecretAsync = async (db: Database, input: AddSecretInput) => { await db.insert(integrationSecrets).values({ kind: input.kind, diff --git a/packages/api/src/router/invite.ts b/packages/api/src/router/invite.ts index 1b767748d..bc31c0bdf 100644 --- a/packages/api/src/router/invite.ts +++ b/packages/api/src/router/invite.ts @@ -20,11 +20,21 @@ export const inviteRouter = createTRPCRouter({ id: true, expirationDate: true, }) - .extend({ creator: z.object({ name: z.string().nullable(), id: z.string() }) }), + .extend({ + creator: z.object({ name: z.string().nullable(), id: z.string() }), + }), ), ) .input(z.undefined()) - .meta({ openapi: { method: "GET", path: "/api/invites", tags: ["invites"], protect: true } }) + .meta({ + openapi: { + method: "GET", + path: "/api/invites", + tags: ["invites"], + protect: true, + }, + mcp: { enabled: true, description: "List all user invites" }, + }) .query(async ({ ctx }) => { throwIfCredentialsDisabled(); return await ctx.db.query.invites.findMany({ @@ -50,7 +60,19 @@ export const inviteRouter = createTRPCRouter({ }), ) .output(z.object({ id: z.string(), token: z.string() })) - .meta({ openapi: { method: "POST", path: "/api/invites", tags: ["invites"], protect: true } }) + .meta({ + openapi: { + method: "POST", + path: "/api/invites", + tags: ["invites"], + protect: true, + }, + mcp: { + enabled: true, + description: + "Create a new user invite link. REQUIRED: expirationDate (ISO date string for when the invite expires). Returns the invite ID and token", + }, + }) .mutation(async ({ ctx, input }) => { throwIfCredentialsDisabled(); const id = createId(); @@ -76,7 +98,15 @@ export const inviteRouter = createTRPCRouter({ }), ) .output(z.undefined()) - .meta({ openapi: { method: "DELETE", path: "/api/invites/{id}", tags: ["invites"], protect: true } }) + .meta({ + openapi: { + method: "DELETE", + path: "/api/invites/{id}", + tags: ["invites"], + protect: true, + }, + mcp: { enabled: true, description: "Delete an invite by ID. REQUIRED: id (invite ID string)" }, + }) .mutation(async ({ ctx, input }) => { throwIfCredentialsDisabled(); const dbInvite = await ctx.db.query.invites.findFirst({ diff --git a/packages/api/src/router/onboard/onboard-queries.ts b/packages/api/src/router/onboard/onboard-queries.ts index 15f205d8b..242d861e4 100644 --- a/packages/api/src/router/onboard/onboard-queries.ts +++ b/packages/api/src/router/onboard/onboard-queries.ts @@ -76,6 +76,9 @@ const nextSteps: Partial { + const port = ports?.[0]; + if (port?.PublicPort) { + const ip = port.IP && port.IP !== "0.0.0.0" && port.IP !== "::" ? port.IP : host; + return { url: `http://${ip}:${port.PublicPort}`, publishedPort: port.PublicPort }; + } + return { url: "", publishedPort: null }; +}; + export const onboardRouter = createTRPCRouter({ currentStep: publicProcedure.query(async ({ ctx }) => { return await getOnboardingOrFallbackAsync(ctx.db); @@ -33,4 +78,249 @@ export const onboardRouter = createTRPCRouter({ step: "start", }); }), + createIntegration: onboardingProcedure + .requiresStep("integrations") + .input( + z.object({ + name: z.string().nonempty().max(127), + url: z.string().nonempty(), + kind: zodEnumFromArray(integrationKinds), + secrets: z.array( + z.object({ + kind: zodEnumFromArray(integrationSecretKinds), + value: z.string().nonempty(), + }), + ), + }), + ) + .mutation(async ({ ctx, input }) => { + const result = await testConnectionAsync({ + id: "new", + name: input.name, + url: input.url, + kind: input.kind, + secrets: input.secrets, + }).catch((error) => { + if (!(error instanceof MissingSecretError)) throw error; + + throw new TRPCError({ + code: "BAD_REQUEST", + message: error.message, + }); + }); + + if (!result.success) { + return { error: mapTestConnectionError(result.error) }; + } + + const integrationId = createId(); + const appId = createId(); + + await ctx.db.insert(apps).values({ + id: appId, + name: input.name, + iconUrl: getIconUrl(input.kind), + href: input.url, + pingUrl: input.url, + description: null, + }); + + await ctx.db.insert(integrations).values({ + id: integrationId, + name: input.name, + url: input.url, + kind: input.kind, + appId, + }); + + if (input.secrets.length >= 1) { + await ctx.db.insert(integrationSecrets).values( + input.secrets.map((secret) => ({ + kind: secret.kind, + value: encryptSecret(secret.value), + integrationId, + })), + ); + } + }), + discoverDockerServices: onboardingProcedure.requiresStep("integrations").query(async ({ ctx }) => { + const emptyResult = { + integrations: [] as { + containerId: string; + containerName: string; + kind: IntegrationKind; + suggestedUrl: string; + publishedPort: number | null; + iconUrl: string | null; + }[], + apps: [] as { + containerId: string; + containerName: string; + suggestedUrl: string; + publishedPort: number | null; + iconUrl: string | null; + }[], + }; + + try { + const { DockerSingleton, dockerLabels } = await import("@homarr/docker"); + + const dockerInstances = DockerSingleton.getInstances(); + if (dockerInstances.length === 0) { + return { status: "empty" as const, ...emptyResult }; + } + + const results = await Promise.allSettled( + dockerInstances.map(async ({ instance, host }) => { + const containerList = await instance.listContainers({ all: false }); + return containerList.filter((c) => !(dockerLabels.hide in c.Labels)).map((c) => ({ ...c, host })); + }), + ); + + const containers = results.flatMap((r) => (r.status === "fulfilled" ? r.value : [])); + + if (containers.length === 0) { + return { status: "empty" as const, ...emptyResult }; + } + + const likeQueries = containers.map((c) => like(icons.name, `%${extractContainerImageName(c.Image)}%`)); + const dbIcons = likeQueries.length > 0 ? await ctx.db.query.icons.findMany({ where: or(...likeQueries) }) : []; + + const seenKinds = new Set(); + const discoveredIntegrations = emptyResult.integrations; + const discoveredApps = emptyResult.apps; + + const cdnIconUrl = (slug: string) => + `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/${slug}.svg`; + + const strictIconMatch = (imageName: string) => { + const normalized = imageName.toLowerCase().trim(); + if (normalized.length < 3) return null; + const exact = dbIcons.find((icon) => icon.name.toLowerCase() === normalized); + if (exact) return exact.url; + return dbIcons.find((icon) => icon.name.toLowerCase().startsWith(normalized))?.url ?? null; + }; + + for (const container of containers) { + const imageName = extractContainerImageName(container.Image); + const containerName = container.Names[0]?.split("/")[1] ?? "Unknown"; + const dbIcon = strictIconMatch(imageName); + const iconUrl = dbIcon ?? cdnIconUrl(imageName.toLowerCase()); + + const { url: suggestedUrl, publishedPort } = buildSuggestedUrl(container.Ports, container.host); + + const kind = matchIntegrationKindFromContainer({ + image: container.Image, + name: containerName, + }); + + if (kind && !seenKinds.has(kind)) { + seenKinds.add(kind); + discoveredIntegrations.push({ + containerId: container.Id, + containerName, + kind, + suggestedUrl, + publishedPort, + iconUrl, + }); + } else if (!kind && dbIcon) { + discoveredApps.push({ + containerId: container.Id, + containerName, + suggestedUrl, + publishedPort, + iconUrl, + }); + } + } + + return { + status: "success" as const, + integrations: discoveredIntegrations, + apps: discoveredApps, + }; + } catch { + return { status: "unavailable" as const, ...emptyResult }; + } + }), + createAppsFromDiscovery: onboardingProcedure + .requiresStep("integrations") + .input( + z.array( + z.object({ + name: z.string().nonempty().max(127), + href: z.string().nullable(), + iconUrl: z.string().nullable(), + }), + ), + ) + .mutation(async ({ ctx, input }) => { + if (input.length === 0) return; + const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg"; + await ctx.db.insert(apps).values( + input.map((app) => ({ + id: createId(), + name: app.name, + iconUrl: app.iconUrl ?? defaultIcon, + href: app.href, + description: null, + })), + ); + }), + setupIntegrations: onboardingProcedure.requiresStep("integrations").mutation(async ({ ctx }) => { + const db = ctx.db; + + const allIntegrations = await db.query.integrations.findMany(); + const allApps = await db.query.apps.findMany(); + + const board = await db.query.boards.findFirst({ + with: { + sections: true, + layouts: true, + }, + }); + + if (!board) { + await nextOnboardingStepAsync(db, undefined); + return; + } + + const everyoneGroupRow = await db.query.groups.findFirst({ + where: eq(groups.name, everyoneGroup), + }); + if (everyoneGroupRow && !everyoneGroupRow.homeBoardId) { + await db.update(groups).set({ homeBoardId: board.id }).where(eq(groups.id, everyoneGroupRow.id)); + } + + const section = board.sections.find((boardSection) => boardSection.kind === "empty"); + const layout = board.layouts[0]; + + if (section && layout) { + const existingItems = await db.query.items.findMany({ + where: eq(items.boardId, board.id), + }); + for (const item of existingItems) { + await db.delete(integrationItems).where(eq(integrationItems.itemId, item.id)); + await db.delete(itemLayouts).where(eq(itemLayouts.itemId, item.id)); + } + if (existingItems.length > 0) { + await db.delete(items).where(eq(items.boardId, board.id)); + } + + await placeAllWidgetsAsync( + db, + { + boardId: board.id, + sectionId: section.id, + layoutId: layout.id, + columnCount: layout.columnCount, + }, + allIntegrations, + allApps, + ); + } + + await nextOnboardingStepAsync(db, undefined); + }), }); diff --git a/packages/api/src/router/search-engine/search-engine-router.ts b/packages/api/src/router/search-engine/search-engine-router.ts index ddfc892a9..8c72baf6e 100644 --- a/packages/api/src/router/search-engine/search-engine-router.ts +++ b/packages/api/src/router/search-engine/search-engine-router.ts @@ -6,12 +6,9 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; import { asc, eq, like } from "@homarr/db"; import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries"; import { searchEngines, users } from "@homarr/db/schema"; -import { createIntegrationAsync } from "@homarr/integrations"; import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common"; import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine"; -import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; const logger = createLogger({ module: "searchEngineRouter" }); @@ -148,20 +145,6 @@ export const searchEngineRouter = createTRPCRouter({ limit: input.limit, }); }), - getMediaRequestOptions: protectedProcedure - .concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr")) - .input(mediaRequestOptionsSchema) - .query(async ({ ctx, input }) => { - const integration = await createIntegrationAsync(ctx.integration); - return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId); - }), - requestMedia: protectedProcedure - .concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr")) - .input(mediaRequestRequestSchema) - .mutation(async ({ ctx, input }) => { - const integration = await createIntegrationAsync(ctx.integration); - return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons); - }), create: permissionRequiredProcedure .requiresPermission("search-engine-create") .input(searchEngineManageSchema) diff --git a/packages/api/src/router/test/docker/docker-router.spec.ts b/packages/api/src/router/test/docker/docker-router.spec.ts index 941e696e6..cbea4063b 100644 --- a/packages/api/src/router/test/docker/docker-router.spec.ts +++ b/packages/api/src/router/test/docker/docker-router.spec.ts @@ -13,6 +13,14 @@ import { dockerRouter } from "../../docker/docker-router"; // Mock the auth module to return an empty session vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); vi.mock("@homarr/request-handler/docker", () => ({ + getContainerLogsAsync: async () => { + await Promise.resolve(); + return "logs"; + }, + streamContainerLogsAsync: async () => { + await Promise.resolve(); + return () => undefined; + }, dockerContainersRequestHandler: { handler: () => ({ getCachedOrUpdatedDataAsync: async () => { @@ -65,6 +73,8 @@ const validInputs: { restartAll: { ids: ["1"] }, removeAll: { ids: ["1"] }, invalidate: undefined, + logs: { id: "1", tail: 200 }, + subscribeLogs: { id: "1", tail: 200 }, }; describe("All procedures should only be accessible for users with admin permission", () => { diff --git a/packages/api/src/router/test/integration/integration-router.spec.ts b/packages/api/src/router/test/integration/integration-router.spec.ts index 17927b9c4..a7cdddfa2 100644 --- a/packages/api/src/router/test/integration/integration-router.spec.ts +++ b/packages/api/src/router/test/integration/integration-router.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, test, vi } from "vitest"; import type { Session } from "@homarr/auth"; import { createId } from "@homarr/common"; import { encryptSecret } from "@homarr/common/server"; -import { apps, integrations, integrationSecrets } from "@homarr/db/schema"; +import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema"; import { createDb } from "@homarr/db/test"; import type { GroupPermissionKey } from "@homarr/definitions"; @@ -59,6 +59,127 @@ describe("all should return all integrations", () => { }); }); +describe("mediaRequestSearchTargets should return accessible media search integrations", () => { + test("with integration use access should only return media request search integrations", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(["integration-use-all"]), + }); + + await db.insert(integrations).values([ + { + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }, + { + id: "2", + name: "Jellyfin", + kind: "jellyfin", + url: "http://jellyfin.local", + }, + ]); + + const result = await caller.mediaRequestSearchTargets(); + + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("1"); + expect(result[0]!.kind).toBe("jellyseerr"); + }); + + test("without access should not return media request search integrations", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }); + + const result = await caller.mediaRequestSearchTargets(); + + expect(result).toHaveLength(0); + }); + + test("with direct integration use access should return media request search integrations", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }); + await db.insert(users).values({ id: defaultUserId }); + await db.insert(integrationUserPermissions).values({ + integrationId: "1", + userId: defaultUserId, + permission: "use", + }); + + const result = await caller.mediaRequestSearchTargets(); + + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("1"); + }); +}); + +describe("searchMediaRequests should validate scoped integrations", () => { + test("with a non media search integration should throw not found", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(["integration-use-all"]), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyfin", + kind: "jellyfin", + url: "http://jellyfin.local", + }); + + await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow( + "One or more media request search integrations were not found", + ); + }); + + test("without query access to a scoped media search integration should throw forbidden", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }); + + await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow( + "User does not have permission to query one or more media request search integrations", + ); + }); +}); + describe("byId should return an integration by id", () => { test("with full access should return an integration by id", async () => { const db = createDb(); @@ -208,7 +329,7 @@ describe("create should create a new integration", () => { expect(dbSecret!.updatedAt).toEqual(fakeNow); }); - test("with create integration access should create a new integration when creating search engine", async () => { + test("with create integration access should not create a search engine for media request search integrations", async () => { const db = createDb(); const caller = integrationRouter.createCaller({ db, @@ -243,12 +364,7 @@ describe("create should create a new integration", () => { expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/); expect(dbSecret!.updatedAt).toEqual(fakeNow); - expect(dbSearchEngine!.integrationId).toBe(dbIntegration!.id); - expect(dbSearchEngine!.short).toBe("j"); - expect(dbSearchEngine!.name).toBe(input.name); - expect(dbSearchEngine!.iconUrl).toBe( - "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg", - ); + expect(dbSearchEngine).toBeUndefined(); }); test("with create integration access should create a new integration with new linked app", async () => { diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 9c4f16c80..eee626026 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -32,6 +32,7 @@ import { import { convertIntersectionToZodObject } from "../schema-merger"; import { createTRPCRouter, + isDemoMode, onboardingProcedure, permissionRequiredProcedure, protectedProcedure, @@ -143,7 +144,19 @@ export const userRouter = createTRPCRouter({ }), create: permissionRequiredProcedure .requiresPermission("admin") - .meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"], protect: true } }) + .meta({ + openapi: { + method: "POST", + path: "/api/users", + tags: ["users"], + protect: true, + }, + mcp: { + enabled: true, + description: + "Create a new user account (admin only). REQUIRED: username (string), password (string), confirmPassword (must match password). OPTIONAL: email (string or null)", + }, + }) .input(convertIntersectionToZodObject(userCreateSchema)) .output(z.void()) .mutation(async ({ ctx, input }) => { @@ -158,7 +171,14 @@ export const userRouter = createTRPCRouter({ }), setProfileImage: protectedProcedure .output(z.void()) - .meta({ openapi: { method: "PUT", path: "/api/users/profileImage", tags: ["users"], protect: true } }) + .meta({ + openapi: { + method: "PUT", + path: "/api/users/profileImage", + tags: ["users"], + protect: true, + }, + }) .input( z.object({ userId: z.string(), @@ -204,8 +224,26 @@ export const userRouter = createTRPCRouter({ getAll: permissionRequiredProcedure .requiresPermission("admin") .input(z.void()) - .output(z.array(selectUserSchema.pick({ id: true, name: true, email: true, emailVerified: true, image: true }))) - .meta({ openapi: { method: "GET", path: "/api/users", tags: ["users"], protect: true } }) + .output( + z.array( + selectUserSchema.pick({ + id: true, + name: true, + email: true, + emailVerified: true, + image: true, + }), + ), + ) + .meta({ + openapi: { + method: "GET", + path: "/api/users", + tags: ["users"], + protect: true, + }, + mcp: { enabled: true, description: "List all users (admin only)" }, + }) .query(({ ctx }) => { return ctx.db.query.users.findMany({ columns: { @@ -220,8 +258,24 @@ export const userRouter = createTRPCRouter({ // Is protected because also used in board access / integration access forms selectable: protectedProcedure .input(z.object({ excludeExternalProviders: z.boolean().default(false) }).optional()) - .output(z.array(selectUserSchema.pick({ id: true, name: true, image: true, email: true }))) - .meta({ openapi: { method: "GET", path: "/api/users/selectable", tags: ["users"], protect: true } }) + .output( + z.array( + selectUserSchema.pick({ + id: true, + name: true, + image: true, + email: true, + }), + ), + ) + .meta({ + openapi: { + method: "GET", + path: "/api/users/selectable", + tags: ["users"], + protect: true, + }, + }) .query(({ ctx, input }) => { return ctx.db.query.users.findMany({ columns: { @@ -241,8 +295,24 @@ export const userRouter = createTRPCRouter({ limit: z.number().min(1).max(100).default(10), }), ) - .output(z.array(selectUserSchema.pick({ id: true, name: true, image: true, email: true }))) - .meta({ openapi: { method: "POST", path: "/api/users/search", tags: ["users"], protect: true } }) + .output( + z.array( + selectUserSchema.pick({ + id: true, + name: true, + image: true, + email: true, + }), + ), + ) + .meta({ + openapi: { + method: "POST", + path: "/api/users/search", + tags: ["users"], + protect: true, + }, + }) .query(async ({ input, ctx }) => { const dbUsers = await ctx.db.query.users.findMany({ columns: { @@ -278,9 +348,19 @@ export const userRouter = createTRPCRouter({ defaultSearchEngineId: true, openSearchInNewTab: true, ddgBangs: true, + completedManageTour: true, + completedBoardTour: true, }), ) - .meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } }) + .meta({ + openapi: { + method: "GET", + path: "/api/users/{userId}", + tags: ["users"], + protect: true, + }, + mcp: { enabled: true, description: "Get user details by user ID. REQUIRED: userId (string)" }, + }) .query(async ({ input, ctx }) => { // Only admins can view other users details if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) { @@ -304,6 +384,8 @@ export const userRouter = createTRPCRouter({ defaultSearchEngineId: true, openSearchInNewTab: true, ddgBangs: true, + completedManageTour: true, + completedBoardTour: true, }, where: eq(users.id, input.userId), }); @@ -320,7 +402,14 @@ export const userRouter = createTRPCRouter({ editProfile: protectedProcedure .input(userEditProfileSchema) .output(z.void()) - .meta({ openapi: { method: "PUT", path: "/api/users/profile", tags: ["users"], protect: true } }) + .meta({ + openapi: { + method: "PUT", + path: "/api/users/profile", + tags: ["users"], + protect: true, + }, + }) .mutation(async ({ input, ctx }) => { // Only admins can view other users details if (ctx.session.user.id !== input.id && !ctx.session.user.permissions.includes("admin")) { @@ -364,7 +453,15 @@ export const userRouter = createTRPCRouter({ delete: protectedProcedure .input(z.object({ userId: z.string() })) .output(z.void()) - .meta({ openapi: { method: "DELETE", path: "/api/users/{userId}", tags: ["users"], protect: true } }) + .meta({ + openapi: { + method: "DELETE", + path: "/api/users/{userId}", + tags: ["users"], + protect: true, + }, + mcp: { enabled: true, description: "Delete a user by ID. REQUIRED: userId (string)" }, + }) .mutation(async ({ input, ctx }) => { // Only admins and user itself can delete a user if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) { @@ -379,7 +476,14 @@ export const userRouter = createTRPCRouter({ changePassword: protectedProcedure .input(convertIntersectionToZodObject(userChangePasswordApiSchema)) .output(z.void()) - .meta({ openapi: { method: "PATCH", path: "/api/users/{userId}/changePassword", tags: ["users"], protect: true } }) + .meta({ + openapi: { + method: "PATCH", + path: "/api/users/{userId}/changePassword", + tags: ["users"], + protect: true, + }, + }) .mutation(async ({ ctx, input }) => { const user = ctx.session.user; // Only admins can change other users' passwords @@ -444,7 +548,14 @@ export const userRouter = createTRPCRouter({ changeHomeBoards: protectedProcedure .input(convertIntersectionToZodObject(userChangeHomeBoardsSchema.and(z.object({ userId: z.string() })))) .output(z.void()) - .meta({ openapi: { method: "PATCH", path: "/api/users/changeHome", tags: ["users"], protect: true } }) + .meta({ + openapi: { + method: "PATCH", + path: "/api/users/changeHome", + tags: ["users"], + protect: true, + }, + }) .mutation(async ({ input, ctx }) => { const user = ctx.session.user; // Only admins can change other users passwords @@ -510,14 +621,28 @@ export const userRouter = createTRPCRouter({ changeSearchPreferences: protectedProcedure .input(convertIntersectionToZodObject(changeSearchPreferencesInputSchema)) .output(z.void()) - .meta({ openapi: { method: "PATCH", path: "/api/users/search-preferences", tags: ["users"], protect: true } }) + .meta({ + openapi: { + method: "PATCH", + path: "/api/users/search-preferences", + tags: ["users"], + protect: true, + }, + }) .mutation(async ({ input, ctx }) => { await changeSearchPreferencesAsync(ctx.db, ctx.session, input); }), changeColorScheme: protectedProcedure .input(userChangeColorSchemeSchema) .output(z.void()) - .meta({ openapi: { method: "PATCH", path: "/api/users/changeScheme", tags: ["users"], protect: true } }) + .meta({ + openapi: { + method: "PATCH", + path: "/api/users/changeScheme", + tags: ["users"], + protect: true, + }, + }) .mutation(async ({ input, ctx }) => { await ctx.db .update(users) @@ -542,7 +667,7 @@ export const userRouter = createTRPCRouter({ .set({ pingIconsEnabled: input.pingIconsEnabled, }) - .where(eq(users.id, ctx.session.user.id)); + .where(eq(users.id, input.id)); }), changeDdgBangs: protectedProcedure .input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema))) @@ -574,7 +699,14 @@ export const userRouter = createTRPCRouter({ changeFirstDayOfWeek: protectedProcedure .input(convertIntersectionToZodObject(userFirstDayOfWeekSchema.and(byIdSchema))) .output(z.void()) - .meta({ openapi: { method: "PATCH", path: "/api/users/firstDayOfWeek", tags: ["users"], protect: true } }) + .meta({ + openapi: { + method: "PATCH", + path: "/api/users/firstDayOfWeek", + tags: ["users"], + protect: true, + }, + }) .mutation(async ({ input, ctx }) => { // Only admins can change other users first day of week if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) { @@ -603,8 +735,48 @@ export const userRouter = createTRPCRouter({ .set({ firstDayOfWeek: input.firstDayOfWeek, }) - .where(eq(users.id, ctx.session.user.id)); + .where(eq(users.id, input.id)); }), + completeTour: protectedProcedure + .input(z.object({ tour: z.enum(["manage", "board"]) })) + .mutation(async ({ input, ctx }) => { + const columnByTour = { + manage: { completedManageTour: true }, + board: { completedBoardTour: true }, + } as const; + + await ctx.db.update(users).set(columnByTour[input.tour]).where(eq(users.id, ctx.session.user.id)); + }), + resetTours: protectedProcedure.mutation(async ({ ctx }) => { + await ctx.db + .update(users) + .set({ + completedManageTour: false, + completedBoardTour: false, + }) + .where(eq(users.id, ctx.session.user.id)); + }), + getTourStatus: protectedProcedure.query(async ({ ctx }) => { + if (isDemoMode) { + return { + completedManageTour: true, + completedBoardTour: true, + }; + } + + const user = await ctx.db.query.users.findFirst({ + columns: { + completedManageTour: true, + completedBoardTour: true, + }, + where: eq(users.id, ctx.session.user.id), + }); + + return { + completedManageTour: user?.completedManageTour ?? false, + completedBoardTour: user?.completedBoardTour ?? false, + }; + }), }); const createUserAsync = async (db: Database, input: Omit, "groupIds">) => { diff --git a/packages/api/src/router/widgets/audio-stats.ts b/packages/api/src/router/widgets/audio-stats.ts new file mode 100644 index 000000000..d43876583 --- /dev/null +++ b/packages/api/src/router/widgets/audio-stats.ts @@ -0,0 +1,29 @@ +import { audiobookshelfRequestHandler } from "@homarr/request-handler/audiobookshelf"; +import { navidromeRequestHandler } from "@homarr/request-handler/navidrome"; + +import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const audioStatsRouter = createTRPCRouter({ + getStats: publicProcedure + .concat(createOneIntegrationMiddleware("query", "navidrome", "audiobookshelf")) + .query(async ({ ctx }) => { + const { kind } = ctx.integration; + + const fetchByKind = { + navidrome: async () => { + const handler = navidromeRequestHandler.handler({ ...ctx.integration, kind: "navidrome" as const }, {}); + return (await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false })).data; + }, + audiobookshelf: async () => { + const handler = audiobookshelfRequestHandler.handler( + { ...ctx.integration, kind: "audiobookshelf" as const }, + {}, + ); + return (await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false })).data; + }, + } as const; + + return { kind, data: await fetchByKind[kind]() }; + }), +}); diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts new file mode 100644 index 000000000..d3b895d88 --- /dev/null +++ b/packages/api/src/router/widgets/beszel.ts @@ -0,0 +1,135 @@ +import { observable } from "@trpc/server/observable"; +import { z } from "zod/v4"; + +import { + beszelAlertsRequestHandler, + beszelStatsRequestHandler, + beszelSystemsRequestHandler, +} from "@homarr/request-handler/beszel"; +import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/beszel"; + +import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const beszelRouter = createTRPCRouter({ + getSystems: publicProcedure + .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) + .query(async ({ ctx }) => { + const results = await Promise.all( + ctx.integrations.map(async (integration) => { + const innerHandler = beszelSystemsRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + systems: data, + updatedAt: timestamp, + }; + }), + ); + return results; + }), + + subscribeSystems: publicProcedure + .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) + .subscription(({ ctx }) => { + return observable<{ integrationId: string; systems: BeszelSystemRow[]; timestamp: Date }>((emit) => { + const unsubscribes = ctx.integrations.map((integration) => { + const innerHandler = beszelSystemsRequestHandler.handler(integration, {}); + return innerHandler.subscribe((systems) => { + emit.next({ + integrationId: integration.id, + systems, + timestamp: new Date(), + }); + }); + }); + return () => { + unsubscribes.forEach((fn) => fn()); + }; + }); + }), + + getAlerts: publicProcedure + .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) + .input( + z.object({ + includeHistory: z.boolean().default(true), + maxHistoryItems: z.number().min(1).max(100).default(10), + }), + ) + .query(async ({ ctx, input }) => { + const results = await Promise.all( + ctx.integrations.map(async (integration) => { + const alertsHandler = beszelAlertsRequestHandler.handler(integration, { + includeHistory: input.includeHistory, + maxHistoryItems: input.maxHistoryItems, + }); + const systemsHandler = beszelSystemsRequestHandler.handler(integration, {}); + const [alertsResult, systemsResult] = await Promise.all([ + alertsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }), + systemsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }), + ]); + const systemNameMap: Record = {}; + for (const system of systemsResult.data) { + systemNameMap[system.id] = system.name; + } + return { + integrationId: integration.id, + alerts: alertsResult.data.alerts, + history: alertsResult.data.history, + systemNameMap, + updatedAt: alertsResult.timestamp, + }; + }), + ); + return results; + }), + + subscribeAlerts: publicProcedure + .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) + .input( + z.object({ + includeHistory: z.boolean().default(true), + maxHistoryItems: z.number().min(1).max(100).default(10), + }), + ) + .subscription(({ ctx, input }) => { + return observable<{ integrationId: string; alerts: BeszelAlertsData; timestamp: Date }>((emit) => { + const unsubscribes = ctx.integrations.map((integration) => { + const innerHandler = beszelAlertsRequestHandler.handler(integration, { + includeHistory: input.includeHistory, + maxHistoryItems: input.maxHistoryItems, + }); + return innerHandler.subscribe((data) => { + emit.next({ integrationId: integration.id, alerts: data, timestamp: new Date() }); + }); + }); + return () => { + unsubscribes.forEach((fn) => fn()); + }; + }); + }), + + getSystemStats: publicProcedure + .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) + .input( + z.object({ + systemId: z.string(), + timePeriod: z.enum(["1h", "12h", "24h", "1w", "30d"]), + includeDocker: z.boolean().default(true), + }), + ) + .query(async ({ ctx, input }) => { + const integration = ctx.integrations[0]; + if (!integration) return null; + const innerHandler = beszelStatsRequestHandler.handler(integration, { + systemId: input.systemId, + timePeriod: input.timePeriod, + includeDocker: input.includeDocker, + }); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return { integrationId: integration.id, ...data, updatedAt: timestamp }; + }), +}); diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts index 5119cfc36..50ba71b3e 100644 --- a/packages/api/src/router/widgets/calendar.ts +++ b/packages/api/src/router/widgets/calendar.ts @@ -18,6 +18,13 @@ const logger = createLogger({ module: "calendarRouter" }); export const calendarRouter = createTRPCRouter({ findAllEvents: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get calendar events for upcoming and recent media releases. REQUIRED: integrationIds (array of integration IDs from integration_all, filter by kind sonarr/radarr/lidarr/readarr), year (number), month (number), releaseType (array of 'inCinemas'|'digitalRelease'|'physicalRelease'), showUnmonitored (boolean). Great for 'what's coming out this week/month?'", + }, + }) .input( z.object({ year: z.number(), @@ -32,7 +39,9 @@ export const calendarRouter = createTRPCRouter({ ctx.integrations.map(async (integration) => { const { integrationIds: _integrationIds, ...handlerInput } = input; const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput); - const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ + forceUpdate: false, + }); return { events: data, diff --git a/packages/api/src/router/widgets/dns-hole.ts b/packages/api/src/router/widgets/dns-hole.ts index 46b868435..93162a1e3 100644 --- a/packages/api/src/router/widgets/dns-hole.ts +++ b/packages/api/src/router/widgets/dns-hole.ts @@ -14,12 +14,21 @@ import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trp export const dnsHoleRouter = createTRPCRouter({ summary: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get DNS blocking statistics from Pi-hole/AdGuard (queries, blocked, percentage). REQUIRED: integrationIds (array of Pi-hole/AdGuard integration IDs from integration_all)", + }, + }) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole"))) .query(async ({ ctx }) => { const results = await Promise.all( ctx.integrations.map(async (integration) => { const innerHandler = dnsHoleRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ + forceUpdate: false, + }); return { integration: { @@ -63,6 +72,13 @@ export const dnsHoleRouter = createTRPCRouter({ }), enable: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: + "Enable DNS blocking on Pi-hole/AdGuard. REQUIRED: integrationId (single Pi-hole/AdGuard integration ID from integration_all)", + }, + }) .concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole"))) .mutation(async ({ ctx: { integration } }) => { const client = await createIntegrationAsync(integration); @@ -76,6 +92,13 @@ export const dnsHoleRouter = createTRPCRouter({ }), disable: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: + "Disable DNS blocking on Pi-hole/AdGuard. REQUIRED: integrationId (single integration ID). OPTIONAL: duration (number of seconds for temporary disable — omit for permanent disable)", + }, + }) .input( z.object({ duration: z.number().optional(), diff --git a/packages/api/src/router/widgets/downloads.ts b/packages/api/src/router/widgets/downloads.ts index f5ed6fdb9..682decdc4 100644 --- a/packages/api/src/router/widgets/downloads.ts +++ b/packages/api/src/router/widgets/downloads.ts @@ -18,6 +18,13 @@ const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) => export const downloadsRouter = createTRPCRouter({ getJobsAndStatuses: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get active download jobs and queue status from connected download clients (qBittorrent, SABnzbd, Transmission, Deluge, NZBGet). REQUIRED: integrationIds (array of download client integration IDs from integration_all). OPTIONAL: limitPerIntegration (number, default 50)", + }, + }) .concat(createDownloadClientIntegrationMiddleware("query")) .input(z.object({ limitPerIntegration: z.number().default(50) })) .query(async ({ ctx, input }) => { @@ -25,7 +32,9 @@ export const downloadsRouter = createTRPCRouter({ ctx.integrations.map(async (integration) => { const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration }); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ + forceUpdate: false, + }); return { integration: { @@ -68,14 +77,23 @@ export const downloadsRouter = createTRPCRouter({ }; }); }), - pause: protectedProcedure.concat(createDownloadClientIntegrationMiddleware("interact")).mutation(async ({ ctx }) => { - await Promise.all( - ctx.integrations.map(async (integration) => { - const integrationInstance = await createIntegrationAsync(integration); - await integrationInstance.pauseQueueAsync(); - }), - ); - }), + pause: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: + "Pause all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)", + }, + }) + .concat(createDownloadClientIntegrationMiddleware("interact")) + .mutation(async ({ ctx }) => { + await Promise.all( + ctx.integrations.map(async (integration) => { + const integrationInstance = await createIntegrationAsync(integration); + await integrationInstance.pauseQueueAsync(); + }), + ); + }), pauseItem: protectedProcedure .concat(createDownloadClientIntegrationMiddleware("interact")) .input(z.object({ item: downloadClientItemSchema })) @@ -87,14 +105,23 @@ export const downloadsRouter = createTRPCRouter({ }), ); }), - resume: protectedProcedure.concat(createDownloadClientIntegrationMiddleware("interact")).mutation(async ({ ctx }) => { - await Promise.all( - ctx.integrations.map(async (integration) => { - const integrationInstance = await createIntegrationAsync(integration); - await integrationInstance.resumeQueueAsync(); - }), - ); - }), + resume: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: + "Resume all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)", + }, + }) + .concat(createDownloadClientIntegrationMiddleware("interact")) + .mutation(async ({ ctx }) => { + await Promise.all( + ctx.integrations.map(async (integration) => { + const integrationInstance = await createIntegrationAsync(integration); + await integrationInstance.resumeQueueAsync(); + }), + ); + }), resumeItem: protectedProcedure .concat(createDownloadClientIntegrationMiddleware("interact")) .input(z.object({ item: downloadClientItemSchema })) diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts index 46741ae1c..03856f5a0 100644 --- a/packages/api/src/router/widgets/health-monitoring.ts +++ b/packages/api/src/router/widgets/health-monitoring.ts @@ -9,6 +9,13 @@ import { createTRPCRouter, publicProcedure } from "../../trpc"; export const healthMonitoringRouter = createTRPCRouter({ getSystemHealthStatus: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)", + }, + }) .concat( createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"), ) @@ -16,7 +23,9 @@ export const healthMonitoringRouter = createTRPCRouter({ return await Promise.all( ctx.integrations.map(async (integration) => { const innerHandler = systemInfoRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ + forceUpdate: false, + }); return { integrationId: integration.id, @@ -32,7 +41,11 @@ export const healthMonitoringRouter = createTRPCRouter({ createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"), ) .subscription(({ ctx }) => { - return observable<{ integrationId: string; healthInfo: SystemHealthMonitoring; timestamp: Date }>((emit) => { + return observable<{ + integrationId: string; + healthInfo: SystemHealthMonitoring; + timestamp: Date; + }>((emit) => { const unsubscribes: (() => void)[] = []; for (const integration of ctx.integrations) { const innerHandler = systemInfoRequestHandler.handler(integration, {}); @@ -53,10 +66,19 @@ export const healthMonitoringRouter = createTRPCRouter({ }); }), getClusterHealthStatus: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get Proxmox cluster health status including nodes, VMs, and resource usage. REQUIRED: integrationId (single Proxmox integration ID from integration_all)", + }, + }) .concat(createOneIntegrationMiddleware("query", "proxmox", "mock")) .query(async ({ ctx }) => { const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {}); - const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ + forceUpdate: false, + }); return data; }), subscribeClusterHealthStatus: publicProcedure diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index e40c3f000..9596cf44e 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -3,9 +3,11 @@ import { lazy } from "@trpc/server"; import { createTRPCRouter } from "../../trpc"; export const widgetRouter = createTRPCRouter({ + beszel: lazy(() => import("./beszel").then((mod) => mod.beszelRouter)), anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)), coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)), immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)), + paperlessNgx: lazy(() => import("./paperless-ngx").then((mod) => mod.paperlessNgxRouter)), notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)), weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)), app: lazy(() => import("./app").then((mod) => mod.appRouter)), @@ -30,5 +32,8 @@ export const widgetRouter = createTRPCRouter({ timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)), tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)), speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)), + uptimeKuma: lazy(() => import("./uptime-kuma").then((mod) => mod.uptimeKumaRouter)), + audioStats: lazy(() => import("./audio-stats").then((mod) => mod.audioStatsRouter)), umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)), + ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)), }); diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts index a77c89b10..715ae1327 100644 --- a/packages/api/src/router/widgets/media-requests.ts +++ b/packages/api/src/router/widgets/media-requests.ts @@ -13,12 +13,21 @@ import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trp export const mediaRequestsRouter = createTRPCRouter({ getLatestRequests: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, available). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)", + }, + }) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) .query(async ({ ctx }) => { const results = await Promise.all( ctx.integrations.map(async (integration) => { const innerHandler = mediaRequestListRequestHandler.handler(integration, {}); - const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ + forceUpdate: false, + }); return { integration: { id: integration.id, @@ -30,8 +39,13 @@ export const mediaRequestsRouter = createTRPCRouter({ }), ); return results - .flatMap(({ data, integration }) => data.map((request) => ({ ...request, integrationId: integration.id }))) - .sort((dataA, dataB) => { + .flatMap(({ data, integration }) => + data.map((request) => ({ + ...request, + integrationId: integration.id, + })), + ) + .toSorted((dataA, dataB) => { if (dataA.status === dataB.status) { return dataB.createdAt.getTime() - dataA.createdAt.getTime(); } @@ -69,12 +83,21 @@ export const mediaRequestsRouter = createTRPCRouter({ }); }), getStats: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get media request statistics including total counts and top requesters. REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)", + }, + }) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) .query(async ({ ctx }) => { const results = await Promise.all( ctx.integrations.map(async (integration) => { const innerHandler = mediaRequestStatsRequestHandler.handler(integration, {}); - const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ + forceUpdate: false, + }); return { integration: { id: integration.id, @@ -88,25 +111,42 @@ export const mediaRequestsRouter = createTRPCRouter({ return { stats: results.flatMap((result) => result.data.stats), users: results - .map((result) => result.data.users.map((user) => ({ ...user, integration: result.integration }))) + .map((result) => + result.data.users.map((user) => ({ + ...user, + integration: result.integration, + })), + ) .flat() - .sort(({ requestCount: countA }, { requestCount: countB }) => countB - countA), + .toSorted(({ requestCount: countA }, { requestCount: countB }) => countB - countA), integrations: results.map((result) => result.integration), }; }), answerRequest: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: + "Approve or decline a pending media request. REQUIRED: integrationId (single Overseerr/Jellyseerr integration ID), requestId (number from getLatestRequests), answer ('approve' or 'decline')", + }, + }) .concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest"))) - .input(z.object({ requestId: z.number(), answer: z.enum(["approve", "decline"]) })) + .input( + z.object({ + requestId: z.number(), + answer: z.enum(["approve", "decline"]), + }), + ) .mutation(async ({ ctx: { integration }, input }) => { const integrationInstance = await createIntegrationAsync(integration); const innerHandler = mediaRequestListRequestHandler.handler(integration, {}); - if (input.answer === "approve") { - await integrationInstance.approveRequestAsync(input.requestId); - await innerHandler.invalidateAsync(); - return; - } - await integrationInstance.declineRequestAsync(input.requestId); + const answerActions = { + approve: (id: number) => integrationInstance.approveRequestAsync(id), + decline: (id: number) => integrationInstance.declineRequestAsync(id), + } as const; + + await answerActions[input.answer](input.requestId); await innerHandler.invalidateAsync(); }), }); diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts index 0e497c8ff..de61fef75 100644 --- a/packages/api/src/router/widgets/media-server.ts +++ b/packages/api/src/router/widgets/media-server.ts @@ -14,6 +14,13 @@ const createMediaServerIntegrationMiddleware = (action: IntegrationAction) => export const mediaServerRouter = createTRPCRouter({ getCurrentStreams: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get currently active streams from Plex/Jellyfin/Emby media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean — true to filter to actively playing streams only)", + }, + }) .concat(createMediaServerIntegrationMiddleware("query")) .input(z.object({ showOnlyPlaying: z.boolean() })) .query(async ({ ctx, input }) => { @@ -22,7 +29,9 @@ export const mediaServerRouter = createTRPCRouter({ const innerHandler = mediaServerRequestHandler.handler(integration, { showOnlyPlaying: input.showOnlyPlaying, }); - const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ + forceUpdate: false, + }); return { integrationId: integration.id, integrationKind: integration.kind, diff --git a/packages/api/src/router/widgets/paperless-ngx.ts b/packages/api/src/router/widgets/paperless-ngx.ts new file mode 100644 index 000000000..0fb81e72a --- /dev/null +++ b/packages/api/src/router/widgets/paperless-ngx.ts @@ -0,0 +1,12 @@ +import { paperlessNgxStatsRequestHandler } from "@homarr/request-handler/paperless-ngx"; + +import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const paperlessNgxRouter = createTRPCRouter({ + getStats: publicProcedure.concat(createOneIntegrationMiddleware("query", "paperlessNgx")).query(async ({ ctx }) => { + const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {}); + const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return data.data; + }), +}); diff --git a/packages/api/src/router/widgets/smart-home.ts b/packages/api/src/router/widgets/smart-home.ts index 2dc9add89..dec3770a7 100644 --- a/packages/api/src/router/widgets/smart-home.ts +++ b/packages/api/src/router/widgets/smart-home.ts @@ -14,11 +14,20 @@ const createSmartHomeIntegrationMiddleware = (action: IntegrationAction) => export const smartHomeRouter = createTRPCRouter({ entityState: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get the current state of a Home Assistant entity (light, switch, sensor, etc.). REQUIRED: integrationId (Home Assistant integration ID from integration_all), entityId (e.g. 'light.living_room', 'switch.fan')", + }, + }) .input(z.object({ entityId: z.string() })) .concat(createSmartHomeIntegrationMiddleware("query")) .query(async ({ ctx: { integration }, input }) => { const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId }); - const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ + forceUpdate: false, + }); return data; }), subscribeEntityState: publicProcedure @@ -42,6 +51,13 @@ export const smartHomeRouter = createTRPCRouter({ }); }), switchEntity: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: + "Toggle a Home Assistant entity (turn on/off a light, switch, etc.). REQUIRED: integrationId (Home Assistant integration ID from integration_all), entityId (e.g. 'light.living_room')", + }, + }) .concat(createSmartHomeIntegrationMiddleware("interact")) .input(z.object({ entityId: z.string() })) .mutation(async ({ ctx: { integration }, input }) => { @@ -54,6 +70,13 @@ export const smartHomeRouter = createTRPCRouter({ return success; }), executeAutomation: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: + "Trigger a Home Assistant automation by its ID. REQUIRED: integrationId (Home Assistant integration ID from integration_all), automationId (the automation entity ID)", + }, + }) .concat(createSmartHomeIntegrationMiddleware("interact")) .input(z.object({ automationId: z.string() })) .mutation(async ({ ctx: { integration }, input }) => { diff --git a/packages/api/src/router/widgets/ups.ts b/packages/api/src/router/widgets/ups.ts new file mode 100644 index 000000000..f1c27c434 --- /dev/null +++ b/packages/api/src/router/widgets/ups.ts @@ -0,0 +1,51 @@ +import { observable } from "@trpc/server/observable"; + +import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import type { UpsSummary } from "@homarr/integrations/types"; +import { upsSummariesRequestHandler } from "@homarr/request-handler/ups"; + +import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const upsRouter = createTRPCRouter({ + getSummaries: publicProcedure + .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups"))) + .query(async ({ ctx }) => { + const results = await Promise.all( + ctx.integrations.map(async (integration) => { + const innerHandler = upsSummariesRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + summaries: data, + updatedAt: timestamp, + }; + }), + ); + + return results; + }), + subscribeSummaries: publicProcedure + .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups"))) + .subscription(({ ctx }) => { + return observable<{ integrationId: string; summaries: UpsSummary[]; timestamp: Date }>((emit) => { + const unsubscribes = ctx.integrations.map((integration) => { + const innerHandler = upsSummariesRequestHandler.handler(integration, {}); + return innerHandler.subscribe((summaries) => { + emit.next({ + integrationId: integration.id, + summaries, + timestamp: new Date(), + }); + }); + }); + + return () => { + unsubscribes.forEach((unsubscribe) => unsubscribe()); + }; + }); + }), +}); diff --git a/packages/api/src/router/widgets/uptime-kuma.ts b/packages/api/src/router/widgets/uptime-kuma.ts new file mode 100644 index 000000000..eb28f9e7e --- /dev/null +++ b/packages/api/src/router/widgets/uptime-kuma.ts @@ -0,0 +1,55 @@ +import { observable } from "@trpc/server/observable"; + +import type { UptimeKumaDashboardData } from "@homarr/integrations/types"; +import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma"; + +import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const uptimeKumaRouter = createTRPCRouter({ + getDashboard: publicProcedure + .concat(createManyIntegrationMiddleware("query", "uptimeKuma")) + .query(async ({ ctx }) => { + const results = await Promise.all( + ctx.integrations.map(async (integration) => { + const innerHandler = uptimeKumaRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: data, + updatedAt: timestamp, + }; + }), + ); + + return results; + }), + + subscribeToDashboard: publicProcedure + .concat(createManyIntegrationMiddleware("query", "uptimeKuma")) + .subscription(({ ctx }) => { + return observable<{ + integrationId: string; + dashboard: UptimeKumaDashboardData; + timestamp: Date; + }>((emit) => { + const unsubscribes = ctx.integrations.map((integration) => { + const innerHandler = uptimeKumaRequestHandler.handler(integration, {}); + return innerHandler.subscribe((dashboard) => { + emit.next({ + integrationId: integration.id, + dashboard, + timestamp: new Date(), + }); + }); + }); + + return () => { + unsubscribes.forEach((unsubscribe) => unsubscribe()); + }; + }); + }), +}); diff --git a/packages/api/src/test/mcp.spec.ts b/packages/api/src/test/mcp.spec.ts new file mode 100644 index 000000000..4fc696aa7 --- /dev/null +++ b/packages/api/src/test/mcp.spec.ts @@ -0,0 +1,41 @@ +import { expect, test, vi } from "vitest"; +import { extractToolsFromProcedures } from "trpc-to-mcp"; + +import { appRouter } from "../router/app"; +import { infoRouter } from "../router/info"; +import { inviteRouter } from "../router/invite"; +import { createTRPCRouter } from "../trpc"; + +vi.mock("@homarr/auth", () => ({})); + +const mcpTestRouter = createTRPCRouter({ + app: appRouter, + info: infoRouter, + invite: inviteRouter, +}); + +test("MCP tools should be extractable from the router", () => { + const act = () => extractToolsFromProcedures(mcpTestRouter); + + expect(act).not.toThrow(); +}); + +test("MCP tools should contain expected procedures", () => { + const tools = extractToolsFromProcedures(mcpTestRouter); + const toolNames = tools.map((tool) => tool.name); + + expect(tools.length).toBeGreaterThan(0); + expect(toolNames).toContain("app_all"); + expect(toolNames).toContain("app_byId"); + expect(toolNames).toContain("app_create"); + expect(toolNames).toContain("info_getInfo"); + expect(toolNames).toContain("invite_getAll"); +}); + +test("MCP tools should have descriptions", () => { + const tools = extractToolsFromProcedures(mcpTestRouter); + + for (const tool of tools) { + expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy(); + } +}); diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index bedd60f3d..0ef944669 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -8,6 +8,7 @@ */ import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; +import type { McpMeta } from "trpc-to-mcp"; import type { OpenApiMeta } from "trpc-to-openapi"; import { ZodError } from "zod/v4"; @@ -18,6 +19,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; import { db } from "@homarr/db"; import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions"; +import { env } from "./env"; import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries"; const logger = createLogger({ module: "trpc" }); @@ -38,7 +40,11 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n const session = opts.session; const source = opts.headers.get("x-trpc-source") ?? "unknown"; - logger.info("Received tRPC request", { source, userId: session?.user.id, userName: session?.user.name }); + logger.info("Received tRPC request", { + source, + userId: session?.user.id, + userName: session?.user.name, + }); return { session, @@ -55,7 +61,7 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n */ const t = initTRPC .context() - .meta() + .meta() .create({ transformer: superjson, errorFormatter: ({ shape, error }) => ({ @@ -87,6 +93,20 @@ export const createCallerFactory = t.createCallerFactory; */ export const createTRPCRouter = t.router; +export const isDemoMode = env.DEMO_MODE; + +const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => { + if (env.DEMO_MODE && type === "mutation") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Mutations are disabled in demo mode", + }); + } + return next({ ctx }); +}); + +const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure; + /** * Public (unauthed) procedure * @@ -94,19 +114,14 @@ export const createTRPCRouter = t.router; * tRPC API. It does not guarantee that a user querying is authorized, but you * can still access user session data if they are logged in */ -export const publicProcedure = t.procedure; +export const publicProcedure = baseProcedure; -/** - * Reusable middleware that enforces users are logged in before running the - * procedure - */ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { - // infers the `session` as non-nullable session: { ...ctx.session, user: ctx.session.user }, }, }); @@ -121,7 +136,7 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { * * @see https://trpc.io/docs/procedures */ -export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); +export const protectedProcedure = baseProcedure.use(enforceUserIsAuthed); /** * Procedure that requires a specific permission diff --git a/packages/common/src/url.ts b/packages/common/src/url.ts index 7550def44..f13cc1efc 100644 --- a/packages/common/src/url.ts +++ b/packages/common/src/url.ts @@ -1,7 +1,11 @@ import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers"; export const removeTrailingSlash = (path: string) => { - return path.at(-1) === "/" ? path.substring(0, path.length - 1) : path; + let end = path.length; + while (end > 0 && path.charAt(end - 1) === "/") { + end--; + } + return path.slice(0, end); }; export const extractBaseUrlFromHeaders = ( diff --git a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts b/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts index 197a61c8e..4d09801d2 100644 --- a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts +++ b/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts @@ -10,6 +10,9 @@ export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SEC (integration, itemOptions: Record) => { const { kind } = integration; + if (kind === "beszel") { + return null as never; + } if (kind !== "proxmox" && kind !== "mock" && kind !== "coolify") { return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions); } diff --git a/packages/db/migrations/mysql/0039_add_user_onboarding_tour_status.sql b/packages/db/migrations/mysql/0039_add_user_onboarding_tour_status.sql new file mode 100644 index 000000000..356540dc7 --- /dev/null +++ b/packages/db/migrations/mysql/0039_add_user_onboarding_tour_status.sql @@ -0,0 +1,2 @@ +ALTER TABLE `user` ADD `completed_manage_tour` boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `user` ADD `completed_board_tour` boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/db/migrations/mysql/meta/0039_snapshot.json b/packages/db/migrations/mysql/meta/0039_snapshot.json new file mode 100644 index 000000000..6630bd286 --- /dev/null +++ b/packages/db/migrations/mysql/meta/0039_snapshot.json @@ -0,0 +1,2120 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "e7c06822-4b94-49ea-b685-acb08ee6b63e", + "prevId": "ddg-bangs-mysql-0038", + "tables": { + "account": { + "name": "account", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "userId_idx": { + "name": "userId_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "name": "account_provider_provider_account_id_pk", + "columns": ["provider", "provider_account_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "apiKey_user_id_user_id_fk": { + "name": "apiKey_user_id_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "apiKey_id": { + "name": "apiKey_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "app": { + "name": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ping_url": { + "name": "ping_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "app_id": { + "name": "app_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "boardGroupPermission": { + "name": "boardGroupPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardGroupPermission_board_id_board_id_fk": { + "name": "boardGroupPermission_board_id_board_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardGroupPermission_group_id_group_id_fk": { + "name": "boardGroupPermission_group_id_group_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardGroupPermission_board_id_group_id_permission_pk": { + "name": "boardGroupPermission_board_id_group_id_permission_pk", + "columns": ["board_id", "group_id", "permission"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "boardUserPermission": { + "name": "boardUserPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardUserPermission_board_id_board_id_fk": { + "name": "boardUserPermission_board_id_board_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardUserPermission_user_id_user_id_fk": { + "name": "boardUserPermission_user_id_user_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardUserPermission_board_id_user_id_permission_pk": { + "name": "boardUserPermission_board_id_user_id_permission_pk", + "columns": ["board_id", "user_id", "permission"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "board": { + "name": "board", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_title": { + "name": "meta_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_image_url": { + "name": "logo_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon_image_url": { + "name": "favicon_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_url": { + "name": "background_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_attachment": { + "name": "background_image_attachment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('fixed')" + }, + "background_image_repeat": { + "name": "background_image_repeat", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('no-repeat')" + }, + "background_image_size": { + "name": "background_image_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('cover')" + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('#fa5252')" + }, + "secondary_color": { + "name": "secondary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('#fd7e14')" + }, + "opacity": { + "name": "opacity", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 100 + }, + "custom_css": { + "name": "custom_css", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_color": { + "name": "icon_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_radius": { + "name": "item_radius", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('lg')" + }, + "disable_status": { + "name": "disable_status", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "board_creator_id_user_id_fk": { + "name": "board_creator_id_user_id_fk", + "tableFrom": "board", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "board_id": { + "name": "board_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "board_name_unique": { + "name": "board_name_unique", + "columns": ["name"] + } + }, + "checkConstraint": {} + }, + "cron_job_configuration": { + "name": "cron_job_configuration", + "columns": { + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "cron_job_configuration_name": { + "name": "cron_job_configuration_name", + "columns": ["name"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "groupMember": { + "name": "groupMember", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupMember_group_id_group_id_fk": { + "name": "groupMember_group_id_group_id_fk", + "tableFrom": "groupMember", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "groupMember_user_id_user_id_fk": { + "name": "groupMember_user_id_user_id_fk", + "tableFrom": "groupMember", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "groupMember_group_id_user_id_pk": { + "name": "groupMember_group_id_user_id_pk", + "columns": ["group_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "groupPermission": { + "name": "groupPermission", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupPermission_group_id_group_id_fk": { + "name": "groupPermission_group_id_group_id_fk", + "tableFrom": "groupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "group": { + "name": "group", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "home_board_id": { + "name": "home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "group_owner_id_user_id_fk": { + "name": "group_owner_id_user_id_fk", + "tableFrom": "group", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_home_board_id_board_id_fk": { + "name": "group_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_mobile_home_board_id_board_id_fk": { + "name": "group_mobile_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_id": { + "name": "group_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "group_name_unique": { + "name": "group_name_unique", + "columns": ["name"] + } + }, + "checkConstraint": {} + }, + "iconRepository": { + "name": "iconRepository", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "iconRepository_id": { + "name": "iconRepository_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "icon": { + "name": "icon", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(250)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_repository_id": { + "name": "icon_repository_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "icon_icon_repository_id_iconRepository_id_fk": { + "name": "icon_icon_repository_id_iconRepository_id_fk", + "tableFrom": "icon", + "tableTo": "iconRepository", + "columnsFrom": ["icon_repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "icon_id": { + "name": "icon_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integrationGroupPermissions": { + "name": "integrationGroupPermissions", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationGroupPermissions_integration_id_integration_id_fk": { + "name": "integrationGroupPermissions_integration_id_integration_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationGroupPermissions_group_id_group_id_fk": { + "name": "integrationGroupPermissions_group_id_group_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_group_permission__pk": { + "name": "integration_group_permission__pk", + "columns": ["integration_id", "group_id", "permission"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integration_item": { + "name": "integration_item", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integration_item_item_id_item_id_fk": { + "name": "integration_item_item_id_item_id_fk", + "tableFrom": "integration_item", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_item_integration_id_integration_id_fk": { + "name": "integration_item_integration_id_integration_id_fk", + "tableFrom": "integration_item", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_item_item_id_integration_id_pk": { + "name": "integration_item_item_id_integration_id_pk", + "columns": ["item_id", "integration_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integrationSecret": { + "name": "integrationSecret", + "columns": { + "kind": { + "name": "kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_secret__kind_idx": { + "name": "integration_secret__kind_idx", + "columns": ["kind"], + "isUnique": false + }, + "integration_secret__updated_at_idx": { + "name": "integration_secret__updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "integrationSecret_integration_id_integration_id_fk": { + "name": "integrationSecret_integration_id_integration_id_fk", + "tableFrom": "integrationSecret", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationSecret_integration_id_kind_pk": { + "name": "integrationSecret_integration_id_kind_pk", + "columns": ["integration_id", "kind"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integrationUserPermission": { + "name": "integrationUserPermission", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationUserPermission_integration_id_integration_id_fk": { + "name": "integrationUserPermission_integration_id_integration_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationUserPermission_user_id_user_id_fk": { + "name": "integrationUserPermission_user_id_user_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationUserPermission_integration_id_user_id_permission_pk": { + "name": "integrationUserPermission_integration_id_user_id_permission_pk", + "columns": ["integration_id", "user_id", "permission"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "integration__kind_idx": { + "name": "integration__kind_idx", + "columns": ["kind"], + "isUnique": false + } + }, + "foreignKeys": { + "integration_app_id_app_id_fk": { + "name": "integration_app_id_app_id_fk", + "tableFrom": "integration", + "tableTo": "app", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_id": { + "name": "integration_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "invite": { + "name": "invite", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiration_date": { + "name": "expiration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "invite_creator_id_user_id_fk": { + "name": "invite_creator_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "invite_id": { + "name": "invite_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "invite_token_unique": { + "name": "invite_token_unique", + "columns": ["token"] + } + }, + "checkConstraint": {} + }, + "item_layout": { + "name": "item_layout", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "item_layout_item_id_item_id_fk": { + "name": "item_layout_item_id_item_id_fk", + "tableFrom": "item_layout", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_section_id_section_id_fk": { + "name": "item_layout_section_id_section_id_fk", + "tableFrom": "item_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_layout_id_layout_id_fk": { + "name": "item_layout_layout_id_layout_id_fk", + "tableFrom": "item_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_layout_item_id_section_id_layout_id_pk": { + "name": "item_layout_item_id_section_id_layout_id_pk", + "columns": ["item_id", "section_id", "layout_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "item": { + "name": "item", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + }, + "advanced_options": { + "name": "advanced_options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + } + }, + "indexes": {}, + "foreignKeys": { + "item_board_id_board_id_fk": { + "name": "item_board_id_board_id_fk", + "tableFrom": "item", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_id": { + "name": "item_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "layout": { + "name": "layout", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "column_count": { + "name": "column_count", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "breakpoint": { + "name": "breakpoint", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "layout_board_id_board_id_fk": { + "name": "layout_board_id_board_id_fk", + "tableFrom": "layout", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "layout_id": { + "name": "layout_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "media": { + "name": "media", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "LONGBLOB", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "media_creator_id_user_id_fk": { + "name": "media_creator_id_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_id": { + "name": "media_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "onboarding": { + "name": "onboarding", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_step": { + "name": "previous_step", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "onboarding_id": { + "name": "onboarding_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "search_engine": { + "name": "search_engine", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "short": { + "name": "short", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url_template": { + "name": "url_template", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'generic'" + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "search_engine_integration_id_integration_id_fk": { + "name": "search_engine_integration_id_integration_id_fk", + "tableFrom": "search_engine", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "search_engine_id": { + "name": "search_engine_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "search_engine_short_unique": { + "name": "search_engine_short_unique", + "columns": ["short"] + } + }, + "checkConstraint": {} + }, + "section_collapse_state": { + "name": "section_collapse_state", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collapsed": { + "name": "collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_collapse_state_user_id_user_id_fk": { + "name": "section_collapse_state_user_id_user_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_collapse_state_section_id_section_id_fk": { + "name": "section_collapse_state_section_id_section_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_collapse_state_user_id_section_id_pk": { + "name": "section_collapse_state_user_id_section_id_pk", + "columns": ["user_id", "section_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "section_layout": { + "name": "section_layout", + "columns": { + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_section_id": { + "name": "parent_section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_layout_section_id_section_id_fk": { + "name": "section_layout_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_layout_id_layout_id_fk": { + "name": "section_layout_layout_id_layout_id_fk", + "tableFrom": "section_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_parent_section_id_section_id_fk": { + "name": "section_layout_parent_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["parent_section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_layout_section_id_layout_id_pk": { + "name": "section_layout_section_id_layout_id_pk", + "columns": ["section_id", "layout_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "section": { + "name": "section", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('{\"json\": {}}')" + } + }, + "indexes": {}, + "foreignKeys": { + "section_board_id_board_id_fk": { + "name": "section_board_id_board_id_fk", + "tableFrom": "section", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_id": { + "name": "section_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "serverSetting": { + "name": "serverSetting", + "columns": { + "setting_key": { + "name": "setting_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "serverSetting_setting_key": { + "name": "serverSetting_setting_key", + "columns": ["setting_key"] + } + }, + "uniqueConstraints": { + "serverSetting_settingKey_unique": { + "name": "serverSetting_settingKey_unique", + "columns": ["setting_key"] + } + }, + "checkConstraint": {} + }, + "session": { + "name": "session", + "columns": { + "session_token": { + "name": "session_token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_session_token": { + "name": "session_session_token", + "columns": ["session_token"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'credentials'" + }, + "home_board_id": { + "name": "home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_search_engine_id": { + "name": "default_search_engine_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_search_in_new_tab": { + "name": "open_search_in_new_tab", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ddg_bangs": { + "name": "ddg_bangs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "color_scheme": { + "name": "color_scheme", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dark'" + }, + "first_day_of_week": { + "name": "first_day_of_week", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "ping_icons_enabled": { + "name": "ping_icons_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "completed_manage_tour": { + "name": "completed_manage_tour", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "completed_board_tour": { + "name": "completed_board_tour", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_home_board_id_board_id_fk": { + "name": "user_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_mobile_home_board_id_board_id_fk": { + "name": "user_mobile_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_default_search_engine_id_search_engine_id_fk": { + "name": "user_default_search_engine_id_search_engine_id_fk", + "tableFrom": "user", + "tableTo": "search_engine", + "columnsFrom": ["default_search_engine_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_id": { + "name": "user_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": ["identifier", "token"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "trusted_certificate_hostname": { + "name": "trusted_certificate_hostname", + "columns": { + "hostname": { + "name": "hostname", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbprint": { + "name": "thumbprint", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "certificate": { + "name": "certificate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trusted_certificate_hostname_hostname_thumbprint_pk": { + "name": "trusted_certificate_hostname_hostname_thumbprint_pk", + "columns": ["hostname", "thumbprint"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/packages/db/migrations/mysql/meta/_journal.json b/packages/db/migrations/mysql/meta/_journal.json index 421c81699..f420759ec 100644 --- a/packages/db/migrations/mysql/meta/_journal.json +++ b/packages/db/migrations/mysql/meta/_journal.json @@ -274,6 +274,13 @@ "when": 1775400000000, "tag": "0038_add_user_ddg_bangs", "breakpoints": true + }, + { + "idx": 39, + "version": "5", + "when": 1779199104477, + "tag": "0039_add_user_onboarding_tour_status", + "breakpoints": true } ] } diff --git a/packages/db/migrations/postgresql/0005_add_user_onboarding_tour_status.sql b/packages/db/migrations/postgresql/0005_add_user_onboarding_tour_status.sql new file mode 100644 index 000000000..dda51181e --- /dev/null +++ b/packages/db/migrations/postgresql/0005_add_user_onboarding_tour_status.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user" ADD COLUMN "completed_manage_tour" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "completed_board_tour" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/db/migrations/postgresql/meta/0005_snapshot.json b/packages/db/migrations/postgresql/meta/0005_snapshot.json new file mode 100644 index 000000000..759659315 --- /dev/null +++ b/packages/db/migrations/postgresql/meta/0005_snapshot.json @@ -0,0 +1,2016 @@ +{ + "id": "2869b3b7-af54-4fbc-a953-766387ec2e3d", + "prevId": "ddg-bangs-pg-0004", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "userId_idx": { + "name": "userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "name": "account_provider_provider_account_id_pk", + "columns": ["provider", "provider_account_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apiKey": { + "name": "apiKey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "apiKey_user_id_user_id_fk": { + "name": "apiKey_user_id_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ping_url": { + "name": "ping_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.boardGroupPermission": { + "name": "boardGroupPermission", + "schema": "", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "boardGroupPermission_board_id_board_id_fk": { + "name": "boardGroupPermission_board_id_board_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardGroupPermission_group_id_group_id_fk": { + "name": "boardGroupPermission_group_id_group_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardGroupPermission_board_id_group_id_permission_pk": { + "name": "boardGroupPermission_board_id_group_id_permission_pk", + "columns": ["board_id", "group_id", "permission"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.boardUserPermission": { + "name": "boardUserPermission", + "schema": "", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "boardUserPermission_board_id_board_id_fk": { + "name": "boardUserPermission_board_id_board_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardUserPermission_user_id_user_id_fk": { + "name": "boardUserPermission_user_id_user_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardUserPermission_board_id_user_id_permission_pk": { + "name": "boardUserPermission_board_id_user_id_permission_pk", + "columns": ["board_id", "user_id", "permission"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board": { + "name": "board", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta_title": { + "name": "meta_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_image_url": { + "name": "logo_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_image_url": { + "name": "favicon_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "background_image_url": { + "name": "background_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "background_image_attachment": { + "name": "background_image_attachment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "background_image_repeat": { + "name": "background_image_repeat", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'no-repeat'" + }, + "background_image_size": { + "name": "background_image_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'cover'" + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#fa5252'" + }, + "secondary_color": { + "name": "secondary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#fd7e14'" + }, + "opacity": { + "name": "opacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "custom_css": { + "name": "custom_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_color": { + "name": "icon_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "item_radius": { + "name": "item_radius", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'lg'" + }, + "disable_status": { + "name": "disable_status", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "board_creator_id_user_id_fk": { + "name": "board_creator_id_user_id_fk", + "tableFrom": "board", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "board_name_unique": { + "name": "board_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cron_job_configuration": { + "name": "cron_job_configuration", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.groupMember": { + "name": "groupMember", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "groupMember_group_id_group_id_fk": { + "name": "groupMember_group_id_group_id_fk", + "tableFrom": "groupMember", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "groupMember_user_id_user_id_fk": { + "name": "groupMember_user_id_user_id_fk", + "tableFrom": "groupMember", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "groupMember_group_id_user_id_pk": { + "name": "groupMember_group_id_user_id_pk", + "columns": ["group_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.groupPermission": { + "name": "groupPermission", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "groupPermission_group_id_group_id_fk": { + "name": "groupPermission_group_id_group_id_fk", + "tableFrom": "groupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group": { + "name": "group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "home_board_id": { + "name": "home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "group_owner_id_user_id_fk": { + "name": "group_owner_id_user_id_fk", + "tableFrom": "group", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_home_board_id_board_id_fk": { + "name": "group_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_mobile_home_board_id_board_id_fk": { + "name": "group_mobile_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "group_name_unique": { + "name": "group_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.iconRepository": { + "name": "iconRepository", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.icon": { + "name": "icon", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(250)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon_repository_id": { + "name": "icon_repository_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "icon_icon_repository_id_iconRepository_id_fk": { + "name": "icon_icon_repository_id_iconRepository_id_fk", + "tableFrom": "icon", + "tableTo": "iconRepository", + "columnsFrom": ["icon_repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrationGroupPermissions": { + "name": "integrationGroupPermissions", + "schema": "", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "integrationGroupPermissions_integration_id_integration_id_fk": { + "name": "integrationGroupPermissions_integration_id_integration_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationGroupPermissions_group_id_group_id_fk": { + "name": "integrationGroupPermissions_group_id_group_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_group_permission__pk": { + "name": "integration_group_permission__pk", + "columns": ["integration_id", "group_id", "permission"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_item": { + "name": "integration_item", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "integration_item_item_id_item_id_fk": { + "name": "integration_item_item_id_item_id_fk", + "tableFrom": "integration_item", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_item_integration_id_integration_id_fk": { + "name": "integration_item_integration_id_integration_id_fk", + "tableFrom": "integration_item", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_item_item_id_integration_id_pk": { + "name": "integration_item_item_id_integration_id_pk", + "columns": ["item_id", "integration_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrationSecret": { + "name": "integrationSecret", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_secret__kind_idx": { + "name": "integration_secret__kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_secret__updated_at_idx": { + "name": "integration_secret__updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integrationSecret_integration_id_integration_id_fk": { + "name": "integrationSecret_integration_id_integration_id_fk", + "tableFrom": "integrationSecret", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationSecret_integration_id_kind_pk": { + "name": "integrationSecret_integration_id_kind_pk", + "columns": ["integration_id", "kind"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrationUserPermission": { + "name": "integrationUserPermission", + "schema": "", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "integrationUserPermission_integration_id_integration_id_fk": { + "name": "integrationUserPermission_integration_id_integration_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationUserPermission_user_id_user_id_fk": { + "name": "integrationUserPermission_user_id_user_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationUserPermission_integration_id_user_id_permission_pk": { + "name": "integrationUserPermission_integration_id_user_id_permission_pk", + "columns": ["integration_id", "user_id", "permission"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "integration__kind_idx": { + "name": "integration__kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_app_id_app_id_fk": { + "name": "integration_app_id_app_id_fk", + "tableFrom": "integration", + "tableTo": "app", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite": { + "name": "invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "expiration_date": { + "name": "expiration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "invite_creator_id_user_id_fk": { + "name": "invite_creator_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invite_token_unique": { + "name": "invite_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item_layout": { + "name": "item_layout", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "layout_id": { + "name": "layout_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "item_layout_item_id_item_id_fk": { + "name": "item_layout_item_id_item_id_fk", + "tableFrom": "item_layout", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_section_id_section_id_fk": { + "name": "item_layout_section_id_section_id_fk", + "tableFrom": "item_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_layout_id_layout_id_fk": { + "name": "item_layout_layout_id_layout_id_fk", + "tableFrom": "item_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_layout_item_id_section_id_layout_id_pk": { + "name": "item_layout_item_id_section_id_layout_id_pk", + "columns": ["item_id", "section_id", "layout_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item": { + "name": "item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{\"json\": {}}'" + }, + "advanced_options": { + "name": "advanced_options", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "item_board_id_board_id_fk": { + "name": "item_board_id_board_id_fk", + "tableFrom": "item", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.layout": { + "name": "layout", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "column_count": { + "name": "column_count", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "breakpoint": { + "name": "breakpoint", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "layout_board_id_board_id_fk": { + "name": "layout_board_id_board_id_fk", + "tableFrom": "layout", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "media_creator_id_user_id_fk": { + "name": "media_creator_id_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onboarding": { + "name": "onboarding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "step": { + "name": "step", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "previous_step": { + "name": "previous_step", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.search_engine": { + "name": "search_engine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "short": { + "name": "short", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url_template": { + "name": "url_template", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "'generic'" + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "search_engine_integration_id_integration_id_fk": { + "name": "search_engine_integration_id_integration_id_fk", + "tableFrom": "search_engine", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "search_engine_short_unique": { + "name": "search_engine_short_unique", + "nullsNotDistinct": false, + "columns": ["short"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.section_collapse_state": { + "name": "section_collapse_state", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "collapsed": { + "name": "collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_collapse_state_user_id_user_id_fk": { + "name": "section_collapse_state_user_id_user_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_collapse_state_section_id_section_id_fk": { + "name": "section_collapse_state_section_id_section_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_collapse_state_user_id_section_id_pk": { + "name": "section_collapse_state_user_id_section_id_pk", + "columns": ["user_id", "section_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.section_layout": { + "name": "section_layout", + "schema": "", + "columns": { + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "layout_id": { + "name": "layout_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "parent_section_id": { + "name": "parent_section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "section_layout_section_id_section_id_fk": { + "name": "section_layout_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_layout_id_layout_id_fk": { + "name": "section_layout_layout_id_layout_id_fk", + "tableFrom": "section_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_parent_section_id_section_id_fk": { + "name": "section_layout_parent_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["parent_section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_layout_section_id_layout_id_pk": { + "name": "section_layout_section_id_layout_id_pk", + "columns": ["section_id", "layout_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.section": { + "name": "section", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "section_board_id_board_id_fk": { + "name": "section_board_id_board_id_fk", + "tableFrom": "section", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.serverSetting": { + "name": "serverSetting", + "schema": "", + "columns": { + "setting_key": { + "name": "setting_key", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "serverSetting_settingKey_unique": { + "name": "serverSetting_settingKey_unique", + "nullsNotDistinct": false, + "columns": ["setting_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "session_token": { + "name": "session_token", + "type": "varchar(512)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "'credentials'" + }, + "home_board_id": { + "name": "home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "default_search_engine_id": { + "name": "default_search_engine_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "open_search_in_new_tab": { + "name": "open_search_in_new_tab", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ddg_bangs": { + "name": "ddg_bangs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "color_scheme": { + "name": "color_scheme", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'dark'" + }, + "first_day_of_week": { + "name": "first_day_of_week", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "ping_icons_enabled": { + "name": "ping_icons_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "completed_manage_tour": { + "name": "completed_manage_tour", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "completed_board_tour": { + "name": "completed_board_tour", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_home_board_id_board_id_fk": { + "name": "user_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_mobile_home_board_id_board_id_fk": { + "name": "user_mobile_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_default_search_engine_id_search_engine_id_fk": { + "name": "user_default_search_engine_id_search_engine_id_fk", + "tableFrom": "user", + "tableTo": "search_engine", + "columnsFrom": ["default_search_engine_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": ["identifier", "token"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trusted_certificate_hostname": { + "name": "trusted_certificate_hostname", + "schema": "", + "columns": { + "hostname": { + "name": "hostname", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "thumbprint": { + "name": "thumbprint", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "certificate": { + "name": "certificate", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trusted_certificate_hostname_hostname_thumbprint_pk": { + "name": "trusted_certificate_hostname_hostname_thumbprint_pk", + "columns": ["hostname", "thumbprint"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/postgresql/meta/_journal.json b/packages/db/migrations/postgresql/meta/_journal.json index d0a3f7683..b6ecf835f 100644 --- a/packages/db/migrations/postgresql/meta/_journal.json +++ b/packages/db/migrations/postgresql/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1775400000000, "tag": "0004_add_user_ddg_bangs", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1779199097716, + "tag": "0005_add_user_onboarding_tour_status", + "breakpoints": true } ] } diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts index a2dec94af..d491b6092 100644 --- a/packages/db/migrations/seed.ts +++ b/packages/db/migrations/seed.ts @@ -1,29 +1,38 @@ +import SuperJSON from "superjson"; + import { createId, objectKeys } from "@homarr/common"; import { createDocumentationLink, credentialsAdminGroup, + defaultBookmarkApps, everyoneGroup, getIntegrationDefaultUrl, getIntegrationName, integrationDefs, integrationKinds, } from "@homarr/definitions"; +import type { WidgetKind } from "@homarr/definitions"; import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; import type { Database } from ".."; import { eq } from ".."; -import { getMaxGroupPositionAsync } from "../queries"; +import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries"; import { getServerSettingByKeyAsync, insertServerSettingByKeyAsync, updateServerSettingByKeyAsync, } from "../queries/server-setting"; + import { + apps, boards, groupMembers, groupPermissions, groups, + integrationItems, integrations, + itemLayouts, + items, layouts, onboarding, searchEngines, @@ -32,14 +41,25 @@ import { } from "../schema"; import type { Integration } from "../schema"; +const isTruthyEnv = (value: string | undefined) => ["1", "yes", "t", "true"].includes((value ?? "").toLowerCase()); + export const seedDataAsync = async (db: Database) => { + if (isTruthyEnv(process.env.UNSAFE_ENABLE_MOCK_INTEGRATION)) { + console.warn( + "UNSAFE_ENABLE_MOCK_INTEGRATION is enabled: mock integration is available in the UI. Disable by setting UNSAFE_ENABLE_MOCK_INTEGRATION=false.", + ); + } + await seedEveryoneGroupAsync(db); await seedOnboardingAsync(db); await seedServerSettingsAsync(db); await seedDefaultSearchEnginesAsync(db); await seedDefaultIntegrationsAsync(db); + await seedDefaultAppsAsync(db); + await seedDefaultBoardAsync(db); + await seedBoardWidgetsAsync(db); - if (process.env.DEMO_MODE === "true") { + if (isTruthyEnv(process.env.DEMO_MODE)) { await seedDemoUserAsync(db); } }; @@ -209,6 +229,198 @@ const seedDefaultIntegrationsAsync = async (db: Database) => { console.log(`Created ${createdCount} default integrations through seeding process`); }; +const seedDefaultAppsAsync = async (db: Database) => { + const existingApps = await db.$count(apps); + if (existingApps > 0) { + console.log("Skipping seeding of default apps as some already exist"); + return; + } + + for (const app of defaultBookmarkApps) { + await db.insert(apps).values({ + id: createId(), + name: app.name, + iconUrl: app.iconUrl, + href: app.href, + }); + } + console.log(`Created ${defaultBookmarkApps.length} default apps through seeding process`); +}; + +const seedDefaultBoardAsync = async (db: Database) => { + const existingBoard = await db.query.boards.findFirst(); + + if (existingBoard) { + console.log("Skipping seeding of default board as one already exists"); + return; + } + + const boardId = createId(); + await db.insert(boards).values({ + id: boardId, + name: "dashboard", + isPublic: false, + }); + await db.insert(sections).values({ + id: createId(), + kind: "empty", + xOffset: 0, + yOffset: 0, + boardId, + }); + await db.insert(layouts).values({ + id: createId(), + name: "Base", + columnCount: 10, + breakpoint: 0, + boardId, + }); + + const everyoneGroupRow = await db.query.groups.findFirst({ + where: eq(groups.name, everyoneGroup), + }); + if (everyoneGroupRow) { + await db.update(groups).set({ homeBoardId: boardId }).where(eq(groups.id, everyoneGroupRow.id)); + console.log("Set default board as home board for everyone group"); + } + + console.log("Created default board 'dashboard' through seed"); +}; + +interface DemoWidget { + kind: WidgetKind; + width: number; + height: number; + needsIntegration: boolean; + options?: Record; +} + +const demoApps = [ + { + name: "Sonarr", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg", + href: "https://sonarr.tv", + }, + { + name: "Radarr", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg", + href: "https://radarr.video", + }, + { + name: "Plex", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg", + href: "https://plex.tv", + }, + { + name: "Jellyfin", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyfin.svg", + href: "https://jellyfin.org", + }, + { + name: "Proxmox", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/proxmox.svg", + href: "https://proxmox.com", + }, + { + name: "Pi-hole", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pi-hole.svg", + href: "https://pi-hole.net", + }, + { + name: "Grafana", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/grafana.svg", + href: "https://grafana.com", + }, + { + name: "Portainer", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/portainer.svg", + href: "https://portainer.io", + }, + { + name: "Home Assistant", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg", + href: "https://home-assistant.io", + }, + { + name: "Nextcloud", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nextcloud.svg", + href: "https://nextcloud.com", + }, + { + name: "qBittorrent", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg", + href: "https://qbittorrent.org", + }, + { + name: "Overseerr", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg", + href: "https://overseerr.dev", + }, +] as const; + +const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [ + // Row 1: calendar + downloads + clock = 12 + { kind: "calendar", width: 5, height: 3, needsIntegration: true }, + { kind: "downloads", width: 5, height: 3, needsIntegration: true }, + { kind: "clock", width: 2, height: 1, needsIntegration: false }, + // Row 2: healthMonitoring + bookmarks = 12 + { kind: "healthMonitoring", width: 6, height: 3, needsIntegration: true }, + { + kind: "bookmarks", + width: 6, + height: 3, + needsIntegration: false, + options: { title: "Homelab", items: appIds, layout: "grid", openNewTab: true }, + }, + // Row 3: mediaServer + mediaTranscoding + dnsHoleSummary = 12 + { kind: "mediaServer", width: 3, height: 2, needsIntegration: true }, + { kind: "mediaTranscoding", width: 3, height: 2, needsIntegration: true }, + { kind: "dnsHoleSummary", width: 6, height: 2, needsIntegration: true }, + // Row 4: dnsHoleControls + mediaReleases + notifications = 12 + { kind: "dnsHoleControls", width: 2, height: 1, needsIntegration: true }, + { kind: "mediaReleases", width: 4, height: 2, needsIntegration: true }, + { kind: "notifications", width: 6, height: 2, needsIntegration: true }, + // Row 5: requestList + requestStats + indexerManager = 12 + { kind: "mediaRequests-requestList", width: 4, height: 3, needsIntegration: true }, + { kind: "mediaRequests-requestStats", width: 4, height: 2, needsIntegration: true }, + { kind: "indexerManager", width: 4, height: 2, needsIntegration: true }, + // Row 6: networkControllerSummary + networkControllerStatus + rssFeed = 12 + { kind: "networkControllerSummary", width: 3, height: 2, needsIntegration: true }, + { kind: "networkControllerStatus", width: 3, height: 2, needsIntegration: true }, + { + kind: "rssFeed", + width: 6, + height: 2, + needsIntegration: false, + options: { + feedUrls: ["https://selfh.st/rss/", "https://hnrss.org/newest?q=self-hosted"], + maximumAmountPosts: 20, + textLinesClamp: 2, + hideDescription: false, + }, + }, + // Row 7-8: app widgets (2x1 each, 6 per row = 12) + ...appIds.map( + (appId): DemoWidget => ({ + kind: "app", + width: 2, + height: 1, + needsIntegration: false, + options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false }, + }), + ), + // Row 9: beszelSystemGrid + beszelAlerts = 12 + { kind: "beszelSystemGrid", width: 8, height: 3, needsIntegration: true }, + { kind: "beszelAlerts", width: 4, height: 3, needsIntegration: true }, + // Row 10: beszelSystemTable + beszelSystemStats = 12 + { kind: "beszelSystemTable", width: 6, height: 3, needsIntegration: true }, + { kind: "beszelSystemStats", width: 6, height: 4, needsIntegration: true }, + // Row 11: notebook + dockerContainers + weather = 12 + { kind: "notebook", width: 4, height: 4, needsIntegration: false }, + { kind: "dockerContainers", width: 6, height: 2, needsIntegration: false }, + { kind: "weather", width: 2, height: 1, needsIntegration: false }, +]; + const seedDemoUserAsync = async (db: Database) => { const existingUser = await db.query.users.findFirst({ where: eq(users.name, "demo"), @@ -225,7 +437,7 @@ const seedDemoUserAsync = async (db: Database) => { id: userId, name: "demo", email: "demo@example.com", - password: "$2b$10$c5AaQb0oj32/bui5Z55PyOb7pY.iEkXYqgdD8MjS9LjSjLMeOkZu2", + password: "$2b$10$odRXt5e95kSQV5Axmk/FeO6GVOxuRQQ8NnRcBA78Wg4V3kZxPY68u", }); const maxPosition = await getMaxGroupPositionAsync(db); @@ -250,6 +462,27 @@ const seedDemoUserAsync = async (db: Database) => { previousStep: "settings", }); + const integrationId = createId(); + await db.insert(integrations).values({ + id: integrationId, + name: "Demo Integration", + url: "https://demo.homarr.dev", + kind: "mock", + appId: null, + }); + + const appIds: string[] = []; + for (const app of demoApps) { + const appId = createId(); + appIds.push(appId); + await db.insert(apps).values({ + id: appId, + name: app.name, + iconUrl: app.iconUrl, + href: app.href, + }); + } + const boardId = createId(); await db.insert(boards).values({ id: boardId, @@ -257,23 +490,93 @@ const seedDemoUserAsync = async (db: Database) => { isPublic: false, creatorId: userId, }); + + const sectionId = createId(); await db.insert(sections).values({ - id: createId(), + id: sectionId, kind: "empty", xOffset: 0, yOffset: 0, boardId, }); + + const layoutId = createId(); await db.insert(layouts).values({ - id: createId(), + id: layoutId, name: "Base", columnCount: 12, breakpoint: 0, boardId, }); + + const demoWidgets = buildDemoWidgets(appIds); + let xOffset = 0; + let yOffset = 0; + let rowMaxHeight = 0; + for (const widget of demoWidgets) { + if (xOffset + widget.width > 12) { + xOffset = 0; + yOffset += rowMaxHeight; + rowMaxHeight = 0; + } + rowMaxHeight = Math.max(rowMaxHeight, widget.height); + const itemId = createId(); + await db.insert(items).values({ + id: itemId, + boardId, + kind: widget.kind, + ...(widget.options ? { options: SuperJSON.stringify(widget.options) } : {}), + }); + await db.insert(itemLayouts).values({ + itemId, + sectionId, + layoutId, + xOffset, + yOffset, + width: widget.width, + height: widget.height, + }); + if (widget.needsIntegration) { + await db.insert(integrationItems).values({ + itemId, + integrationId, + }); + } + xOffset += widget.width; + } + await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId)); console.log( - "Your instance has enabled demo mode. Homarr therefore created example data for your demo. You can disable demo mode anytime by setting the env 'DEMO_MODE' to 'false' or unset it.", + "Demo mode enabled: created demo user, mock integration, and sample board with widgets. Disable by setting DEMO_MODE=false.", ); }; + +const seedBoardWidgetsAsync = async (db: Database) => { + const existingItems = await db.$count(items); + if (existingItems > 0) { + console.log("Skipping seeding of board widgets as some already exist"); + return; + } + + const board = await db.query.boards.findFirst({ + with: { sections: true, layouts: true }, + }); + if (!board) return; + + const section = board.sections.find((sec) => sec.kind === "empty"); + const layout = board.layouts[0]; + if (!section || !layout) return; + + const allIntegrations = await db.query.integrations.findMany(); + const allApps = await db.query.apps.findMany(); + + const count = await placeAllWidgetsAsync( + db, + { boardId: board.id, sectionId: section.id, layoutId: layout.id, columnCount: layout.columnCount }, + allIntegrations, + allApps, + ); + + console.log(`Placed ${count} widgets on board`); +}; diff --git a/packages/db/migrations/sqlite/0037_add_user_onboarding_tour_status.sql b/packages/db/migrations/sqlite/0037_add_user_onboarding_tour_status.sql new file mode 100644 index 000000000..292ce4ece --- /dev/null +++ b/packages/db/migrations/sqlite/0037_add_user_onboarding_tour_status.sql @@ -0,0 +1,2 @@ +ALTER TABLE `user` ADD `completed_manage_tour` integer DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `user` ADD `completed_board_tour` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/db/migrations/sqlite/meta/0037_snapshot.json b/packages/db/migrations/sqlite/meta/0037_snapshot.json new file mode 100644 index 000000000..74827789c --- /dev/null +++ b/packages/db/migrations/sqlite/meta/0037_snapshot.json @@ -0,0 +1,2035 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f30b6535-b4d5-4501-8370-7a7605e28bbd", + "prevId": "ddg-bangs-sqlite-0036", + "tables": { + "account": { + "name": "account", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "userId_idx": { + "name": "userId_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "columns": ["provider", "provider_account_id"], + "name": "account_provider_provider_account_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "apiKey_user_id_user_id_fk": { + "name": "apiKey_user_id_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app": { + "name": "app", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ping_url": { + "name": "ping_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "boardGroupPermission": { + "name": "boardGroupPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardGroupPermission_board_id_board_id_fk": { + "name": "boardGroupPermission_board_id_board_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardGroupPermission_group_id_group_id_fk": { + "name": "boardGroupPermission_group_id_group_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardGroupPermission_board_id_group_id_permission_pk": { + "columns": ["board_id", "group_id", "permission"], + "name": "boardGroupPermission_board_id_group_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "boardUserPermission": { + "name": "boardUserPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardUserPermission_board_id_board_id_fk": { + "name": "boardUserPermission_board_id_board_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardUserPermission_user_id_user_id_fk": { + "name": "boardUserPermission_user_id_user_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardUserPermission_board_id_user_id_permission_pk": { + "columns": ["board_id", "user_id", "permission"], + "name": "boardUserPermission_board_id_user_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "board": { + "name": "board", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_title": { + "name": "meta_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_image_url": { + "name": "logo_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon_image_url": { + "name": "favicon_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_url": { + "name": "background_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_attachment": { + "name": "background_image_attachment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'fixed'" + }, + "background_image_repeat": { + "name": "background_image_repeat", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'no-repeat'" + }, + "background_image_size": { + "name": "background_image_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'cover'" + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#fa5252'" + }, + "secondary_color": { + "name": "secondary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#fd7e14'" + }, + "opacity": { + "name": "opacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 100 + }, + "custom_css": { + "name": "custom_css", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_color": { + "name": "icon_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_radius": { + "name": "item_radius", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'lg'" + }, + "disable_status": { + "name": "disable_status", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "board_name_unique": { + "name": "board_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": { + "board_creator_id_user_id_fk": { + "name": "board_creator_id_user_id_fk", + "tableFrom": "board", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cron_job_configuration": { + "name": "cron_job_configuration", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groupMember": { + "name": "groupMember", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupMember_group_id_group_id_fk": { + "name": "groupMember_group_id_group_id_fk", + "tableFrom": "groupMember", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "groupMember_user_id_user_id_fk": { + "name": "groupMember_user_id_user_id_fk", + "tableFrom": "groupMember", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "groupMember_group_id_user_id_pk": { + "columns": ["group_id", "user_id"], + "name": "groupMember_group_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groupPermission": { + "name": "groupPermission", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupPermission_group_id_group_id_fk": { + "name": "groupPermission_group_id_group_id_fk", + "tableFrom": "groupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "group": { + "name": "group", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "home_board_id": { + "name": "home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "group_name_unique": { + "name": "group_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": { + "group_owner_id_user_id_fk": { + "name": "group_owner_id_user_id_fk", + "tableFrom": "group", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_home_board_id_board_id_fk": { + "name": "group_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_mobile_home_board_id_board_id_fk": { + "name": "group_mobile_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "iconRepository": { + "name": "iconRepository", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "icon": { + "name": "icon", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_repository_id": { + "name": "icon_repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "icon_icon_repository_id_iconRepository_id_fk": { + "name": "icon_icon_repository_id_iconRepository_id_fk", + "tableFrom": "icon", + "tableTo": "iconRepository", + "columnsFrom": ["icon_repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrationGroupPermissions": { + "name": "integrationGroupPermissions", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationGroupPermissions_integration_id_integration_id_fk": { + "name": "integrationGroupPermissions_integration_id_integration_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationGroupPermissions_group_id_group_id_fk": { + "name": "integrationGroupPermissions_group_id_group_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationGroupPermissions_integration_id_group_id_permission_pk": { + "columns": ["integration_id", "group_id", "permission"], + "name": "integrationGroupPermissions_integration_id_group_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration_item": { + "name": "integration_item", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integration_item_item_id_item_id_fk": { + "name": "integration_item_item_id_item_id_fk", + "tableFrom": "integration_item", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_item_integration_id_integration_id_fk": { + "name": "integration_item_integration_id_integration_id_fk", + "tableFrom": "integration_item", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_item_item_id_integration_id_pk": { + "columns": ["item_id", "integration_id"], + "name": "integration_item_item_id_integration_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrationSecret": { + "name": "integrationSecret", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_secret__kind_idx": { + "name": "integration_secret__kind_idx", + "columns": ["kind"], + "isUnique": false + }, + "integration_secret__updated_at_idx": { + "name": "integration_secret__updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "integrationSecret_integration_id_integration_id_fk": { + "name": "integrationSecret_integration_id_integration_id_fk", + "tableFrom": "integrationSecret", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationSecret_integration_id_kind_pk": { + "columns": ["integration_id", "kind"], + "name": "integrationSecret_integration_id_kind_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrationUserPermission": { + "name": "integrationUserPermission", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationUserPermission_integration_id_integration_id_fk": { + "name": "integrationUserPermission_integration_id_integration_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationUserPermission_user_id_user_id_fk": { + "name": "integrationUserPermission_user_id_user_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationUserPermission_integration_id_user_id_permission_pk": { + "columns": ["integration_id", "user_id", "permission"], + "name": "integrationUserPermission_integration_id_user_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "integration__kind_idx": { + "name": "integration__kind_idx", + "columns": ["kind"], + "isUnique": false + } + }, + "foreignKeys": { + "integration_app_id_app_id_fk": { + "name": "integration_app_id_app_id_fk", + "tableFrom": "integration", + "tableTo": "app", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invite": { + "name": "invite", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiration_date": { + "name": "expiration_date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invite_token_unique": { + "name": "invite_token_unique", + "columns": ["token"], + "isUnique": true + } + }, + "foreignKeys": { + "invite_creator_id_user_id_fk": { + "name": "invite_creator_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "item_layout": { + "name": "item_layout", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "item_layout_item_id_item_id_fk": { + "name": "item_layout_item_id_item_id_fk", + "tableFrom": "item_layout", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_section_id_section_id_fk": { + "name": "item_layout_section_id_section_id_fk", + "tableFrom": "item_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_layout_id_layout_id_fk": { + "name": "item_layout_layout_id_layout_id_fk", + "tableFrom": "item_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_layout_item_id_section_id_layout_id_pk": { + "columns": ["item_id", "section_id", "layout_id"], + "name": "item_layout_item_id_section_id_layout_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "item": { + "name": "item", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + }, + "advanced_options": { + "name": "advanced_options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "item_board_id_board_id_fk": { + "name": "item_board_id_board_id_fk", + "tableFrom": "item", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "layout": { + "name": "layout", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "column_count": { + "name": "column_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "breakpoint": { + "name": "breakpoint", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "layout_board_id_board_id_fk": { + "name": "layout_board_id_board_id_fk", + "tableFrom": "layout", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media": { + "name": "media", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "media_creator_id_user_id_fk": { + "name": "media_creator_id_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "onboarding": { + "name": "onboarding", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_step": { + "name": "previous_step", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "search_engine": { + "name": "search_engine", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "short": { + "name": "short", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url_template": { + "name": "url_template", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'generic'" + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "search_engine_short_unique": { + "name": "search_engine_short_unique", + "columns": ["short"], + "isUnique": true + } + }, + "foreignKeys": { + "search_engine_integration_id_integration_id_fk": { + "name": "search_engine_integration_id_integration_id_fk", + "tableFrom": "search_engine", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "section_collapse_state": { + "name": "section_collapse_state", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collapsed": { + "name": "collapsed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_collapse_state_user_id_user_id_fk": { + "name": "section_collapse_state_user_id_user_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_collapse_state_section_id_section_id_fk": { + "name": "section_collapse_state_section_id_section_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_collapse_state_user_id_section_id_pk": { + "columns": ["user_id", "section_id"], + "name": "section_collapse_state_user_id_section_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "section_layout": { + "name": "section_layout", + "columns": { + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_section_id": { + "name": "parent_section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_layout_section_id_section_id_fk": { + "name": "section_layout_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_layout_id_layout_id_fk": { + "name": "section_layout_layout_id_layout_id_fk", + "tableFrom": "section_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_parent_section_id_section_id_fk": { + "name": "section_layout_parent_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["parent_section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_layout_section_id_layout_id_pk": { + "columns": ["section_id", "layout_id"], + "name": "section_layout_section_id_layout_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "section": { + "name": "section", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "section_board_id_board_id_fk": { + "name": "section_board_id_board_id_fk", + "tableFrom": "section", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "serverSetting": { + "name": "serverSetting", + "columns": { + "setting_key": { + "name": "setting_key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": { + "serverSetting_settingKey_unique": { + "name": "serverSetting_settingKey_unique", + "columns": ["setting_key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "trusted_certificate_hostname": { + "name": "trusted_certificate_hostname", + "columns": { + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbprint": { + "name": "thumbprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "certificate": { + "name": "certificate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trusted_certificate_hostname_hostname_thumbprint_pk": { + "columns": ["hostname", "thumbprint"], + "name": "trusted_certificate_hostname_hostname_thumbprint_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'credentials'" + }, + "home_board_id": { + "name": "home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_search_engine_id": { + "name": "default_search_engine_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_search_in_new_tab": { + "name": "open_search_in_new_tab", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "ddg_bangs": { + "name": "ddg_bangs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "color_scheme": { + "name": "color_scheme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dark'" + }, + "first_day_of_week": { + "name": "first_day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "ping_icons_enabled": { + "name": "ping_icons_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "completed_manage_tour": { + "name": "completed_manage_tour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "completed_board_tour": { + "name": "completed_board_tour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_home_board_id_board_id_fk": { + "name": "user_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_mobile_home_board_id_board_id_fk": { + "name": "user_mobile_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_default_search_engine_id_search_engine_id_fk": { + "name": "user_default_search_engine_id_search_engine_id_fk", + "tableFrom": "user", + "tableTo": "search_engine", + "columnsFrom": ["default_search_engine_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": ["identifier", "token"], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/db/migrations/sqlite/meta/_journal.json b/packages/db/migrations/sqlite/meta/_journal.json index 5543bbde0..2f4866c79 100644 --- a/packages/db/migrations/sqlite/meta/_journal.json +++ b/packages/db/migrations/sqlite/meta/_journal.json @@ -260,6 +260,13 @@ "when": 1775400000000, "tag": "0036_add_user_ddg_bangs", "breakpoints": true + }, + { + "idx": 37, + "version": "6", + "when": 1779199046805, + "tag": "0037_add_user_onboarding_tour_status", + "breakpoints": true } ] } diff --git a/packages/db/queries/index.ts b/packages/db/queries/index.ts index 13e90d6eb..368d7eaa2 100644 --- a/packages/db/queries/index.ts +++ b/packages/db/queries/index.ts @@ -1,3 +1,4 @@ export * from "./item"; export * from "./server-setting"; export * from "./group"; +export * from "./widget-placement"; diff --git a/packages/db/queries/widget-placement.ts b/packages/db/queries/widget-placement.ts new file mode 100644 index 000000000..1d0b529f9 --- /dev/null +++ b/packages/db/queries/widget-placement.ts @@ -0,0 +1,148 @@ +import superjson from "superjson"; + +import { createId } from "@homarr/common"; +import { + defaultBookmarkApps, + defaultWidgetConfigs, + emptySuperJSON, + getWidgetKindsForIntegration, +} from "@homarr/definitions"; +import type { WidgetKind } from "@homarr/definitions"; + +import type { Database } from ".."; +import { integrationItems, itemLayouts, items } from "../schema"; +import type { Integration } from "../schema"; + +const widgetConfigMap = new Map(defaultWidgetConfigs.map((config) => [config.kind, config])); + +interface BoardTarget { + boardId: string; + sectionId: string; + layoutId: string; + columnCount: number; +} + +interface PlacementContext extends BoardTarget { + xOffset: number; + yOffset: number; + rowMaxHeight: number; +} + +const placeWidgetAsync = async ( + db: Database, + ctx: PlacementContext, + kind: WidgetKind, + linkedIntegrationIds: string[], + options?: string, + size?: { width: number; height: number }, +) => { + const itemWidth = size?.width ?? 2; + const itemHeight = size?.height ?? 2; + + if (ctx.xOffset + itemWidth > ctx.columnCount) { + ctx.xOffset = 0; + ctx.yOffset += ctx.rowMaxHeight; + ctx.rowMaxHeight = 0; + } + + const itemId = createId(); + await db.insert(items).values({ + id: itemId, + boardId: ctx.boardId, + kind, + options: options ?? emptySuperJSON, + advancedOptions: emptySuperJSON, + }); + + await db.insert(itemLayouts).values({ + itemId, + sectionId: ctx.sectionId, + layoutId: ctx.layoutId, + xOffset: ctx.xOffset, + yOffset: ctx.yOffset, + width: itemWidth, + height: itemHeight, + }); + + for (const integrationId of linkedIntegrationIds) { + await db.insert(integrationItems).values({ itemId, integrationId }); + } + + ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight); + ctx.xOffset += itemWidth; +}; + +interface App { + id: string; + name: string; +} + +export const placeAllWidgetsAsync = async ( + db: Database, + target: BoardTarget, + allIntegrations: Integration[], + allApps: App[], +) => { + const ctx: PlacementContext = { + ...target, + xOffset: 0, + yOffset: 0, + rowMaxHeight: 0, + }; + + const placedWidgets = new Set(); + + for (const integration of allIntegrations) { + for (const widgetKind of getWidgetKindsForIntegration(integration.kind)) { + if (placedWidgets.has(widgetKind)) continue; + const config = widgetConfigMap.get(widgetKind); + if (config?.skip) continue; + placedWidgets.add(widgetKind); + + const matchingIds = allIntegrations + .filter((row) => getWidgetKindsForIntegration(row.kind).includes(widgetKind)) + .map((row) => row.id); + + const options = config?.options ? superjson.stringify(config.options) : undefined; + await placeWidgetAsync( + db, + ctx, + widgetKind, + matchingIds, + options, + config && { width: config.width, height: config.height }, + ); + } + } + + for (const app of allApps) { + await placeWidgetAsync( + db, + ctx, + "app", + [], + superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }), + { width: 1, height: 1 }, + ); + } + + const bookmarkAppNames = new Set(defaultBookmarkApps.map((bookmark) => bookmark.name)); + const bookmarkAppIds = allApps.filter((app) => bookmarkAppNames.has(app.name)).map((app) => app.id); + + for (const config of defaultWidgetConfigs) { + if (config.skip || placedWidgets.has(config.kind)) continue; + placedWidgets.add(config.kind); + + let options = config.options ? { ...config.options } : undefined; + if (config.kind === "bookmarks" && bookmarkAppIds.length > 0) { + options = { ...options, items: bookmarkAppIds }; + } + + await placeWidgetAsync(db, ctx, config.kind, [], options ? superjson.stringify(options) : undefined, { + width: config.width, + height: config.height, + }); + } + + return placedWidgets.size + allApps.length; +}; diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts index 84c5325ed..7e2897c46 100644 --- a/packages/db/schema/mysql.ts +++ b/packages/db/schema/mysql.ts @@ -80,6 +80,8 @@ export const users = mysqlTable("user", { colorScheme: varchar({ length: 5 }).$type().default("dark").notNull(), firstDayOfWeek: tinyint().$type().default(1).notNull(), // Defaults to Monday pingIconsEnabled: boolean().default(false).notNull(), + completedManageTour: boolean().default(false).notNull(), + completedBoardTour: boolean().default(false).notNull(), }); export const accounts = mysqlTable( diff --git a/packages/db/schema/postgresql.ts b/packages/db/schema/postgresql.ts index 412916013..8ea445325 100644 --- a/packages/db/schema/postgresql.ts +++ b/packages/db/schema/postgresql.ts @@ -79,6 +79,8 @@ export const users = pgTable("user", { colorScheme: varchar({ length: 5 }).$type().default("dark").notNull(), firstDayOfWeek: smallint().$type().default(1).notNull(), // Defaults to Monday pingIconsEnabled: boolean().default(false).notNull(), + completedManageTour: boolean().default(false).notNull(), + completedBoardTour: boolean().default(false).notNull(), }); export const accounts = pgTable( diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts index 50671db85..b4fe5bbe0 100644 --- a/packages/db/schema/sqlite.ts +++ b/packages/db/schema/sqlite.ts @@ -62,6 +62,8 @@ export const users = sqliteTable("user", { colorScheme: text().$type().default("dark").notNull(), firstDayOfWeek: int().$type().default(1).notNull(), // Defaults to Monday pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(), + completedManageTour: int({ mode: "boolean" }).default(false).notNull(), + completedBoardTour: int({ mode: "boolean" }).default(false).notNull(), }); export const accounts = sqliteTable( diff --git a/packages/definitions/src/docker-integration-match.ts b/packages/definitions/src/docker-integration-match.ts new file mode 100644 index 000000000..9dcba12d4 --- /dev/null +++ b/packages/definitions/src/docker-integration-match.ts @@ -0,0 +1,94 @@ +import { objectKeys } from "@homarr/common"; + +import { integrationDefs } from "./integration"; +import type { IntegrationKind } from "./integration"; + +export const extractContainerImageName = (image: string): string => image.split("/").at(-1)?.split(":").at(0) ?? ""; + +const extractIconSlug = (iconUrl: string): string => { + const filename = iconUrl.split("/").pop() ?? ""; + return filename.replace(/\.(svg|png)$/, "").toLowerCase(); +}; + +export const integrationIconSlugs: Record = Object.fromEntries( + objectKeys(integrationDefs).map((kind) => [kind, extractIconSlug(integrationDefs[kind].iconUrl)]), +) as Record; + +const integrationAliases: Partial> = { + piHole: ["pihole", "pi-hole"], + adGuardHome: ["adguardhome", "adguard-home", "adguard"], + homeAssistant: ["homeassistant", "home-assistant", "hass"], + openmediavault: ["omv"], + qBittorrent: ["qbittorrent"], + sabNzbd: ["sabnzbd"], + nzbGet: ["nzbget"], + unifiController: ["unifi", "unifi-controller"], + jellyseerr: ["jellyseerr"], + overseerr: ["overseerr"], + dashDot: ["dashdot", "dash-dot", "dash."], + speedtestTracker: ["speedtest-tracker"], + uptimeKuma: ["uptime-kuma"], + audiobookshelf: ["audiobookshelf"], + navidrome: ["navidrome"], + paperlessNgx: ["paperless-ngx", "paperless"], + searchCh: ["search-ch"], + gitHubContainerRegistry: ["ghcr"], + linuxServerIO: ["linuxserver"], + coolify: ["coolify"], + truenas: ["truenas"], +}; + +export const matchIntegrationKind = (search: string): IntegrationKind | null => { + const normalized = search.toLowerCase().trim(); + if (!normalized) return null; + + for (const kind of objectKeys(integrationDefs)) { + if (kind.toLowerCase() === normalized) return kind; + if (integrationDefs[kind].name.toLowerCase() === normalized) return kind; + } + + for (const kind of objectKeys(integrationDefs)) { + if (integrationIconSlugs[kind] === normalized) return kind; + } + + for (const [kind, aliases] of Object.entries(integrationAliases) as [IntegrationKind, readonly string[]][]) { + if (aliases.some((alias) => alias === normalized)) return kind; + } + + for (const kind of objectKeys(integrationDefs)) { + if (normalized.includes(kind.toLowerCase())) return kind; + if (normalized.includes(integrationIconSlugs[kind])) return kind; + } + + return null; +}; + +const notDockerDiscoverable = new Set([ + "dockerHub", + "github", + "gitlab", + "npm", + "codeberg", + "linuxServerIO", + "gitHubContainerRegistry", + "quay", + "ical", + "searchCh", + "mock", +]); + +interface ContainerMatchInput { + image: string; + name: string; +} + +export const matchIntegrationKindFromContainer = (container: ContainerMatchInput): IntegrationKind | null => { + const imageName = extractContainerImageName(container.image); + const fromImage = matchIntegrationKind(imageName); + if (fromImage && !notDockerDiscoverable.has(fromImage)) return fromImage; + + const fromName = matchIntegrationKind(container.name); + if (fromName && !notDockerDiscoverable.has(fromName)) return fromName; + + return null; +}; diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index b97a34937..348bbf78d 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -157,6 +157,7 @@ export type HomarrDocumentationPath = | "/docs/integrations/anchor" | "/docs/integrations/aria2" | "/docs/integrations/codeberg" + | "/docs/integrations/coolify" | "/docs/integrations/dash-dot" | "/docs/integrations/deluge" | "/docs/integrations/docker-hub" @@ -181,6 +182,7 @@ export type HomarrDocumentationPath = | "/docs/integrations/open-media-vault" | "/docs/integrations/opnsense" | "/docs/integrations/overseerr" + | "/docs/integrations/peanut" | "/docs/integrations/pi-hole" | "/docs/integrations/plex" | "/docs/integrations/prowlarr" @@ -217,6 +219,7 @@ export type HomarrDocumentationPath = | "/docs/widgets/bookmarks" | "/docs/widgets/calendar" | "/docs/widgets/clock" + | "/docs/widgets/coolify" | "/docs/widgets/dns-hole-controls" | "/docs/widgets/dns-hole-summary" | "/docs/widgets/docker-containers" @@ -247,6 +250,7 @@ export type HomarrDocumentationPath = | "/docs/widgets/timetable" | "/docs/widgets/tracearr" | "/docs/widgets/umami" + | "/docs/widgets/ups" | "/docs/widgets/video" | "/docs/widgets/weather" | "" diff --git a/packages/definitions/src/index.ts b/packages/definitions/src/index.ts index 3eb639ba6..eb81e4d18 100644 --- a/packages/definitions/src/index.ts +++ b/packages/definitions/src/index.ts @@ -12,5 +12,8 @@ export * from "./docs"; export * from "./cookie"; export * from "./search-engine"; export * from "./onboarding"; +export * from "./widget-integration-map"; export * from "./emptysuperjson"; export * from "./hotkeys"; +export * from "./docker-integration-match"; +export * from "./integration-url-template"; diff --git a/packages/definitions/src/integration-url-template.ts b/packages/definitions/src/integration-url-template.ts new file mode 100644 index 000000000..bb8312c6e --- /dev/null +++ b/packages/definitions/src/integration-url-template.ts @@ -0,0 +1,41 @@ +import { removeTrailingSlash } from "@homarr/common"; + +import { integrationIconSlugs } from "./docker-integration-match"; +import type { IntegrationKind } from "./integration"; +import { getIntegrationDefaultPort } from "./integration"; + +export type UrlTemplateMode = "hostPort" | "subdomain"; + +const getSlugForKind = (kind: IntegrationKind): string => integrationIconSlugs[kind]; + +const buildUrl = (slug: string, host: string, mode: UrlTemplateMode, port?: number): string => { + const modeBuilders: Record string> = { + subdomain: () => `https://${slug}.${host}`, + hostPort: () => (port ? `http://${host}:${port}` : `http://${host}`), + }; + return modeBuilders[mode](); +}; + +export const buildIntegrationUrl = ( + kind: IntegrationKind, + baseHost: string, + mode: UrlTemplateMode, + dockerPort?: number, +): string => { + const host = removeTrailingSlash(baseHost.trim()); + if (!host) return ""; + const port = dockerPort ?? getIntegrationDefaultPort(kind); + return buildUrl(getSlugForKind(kind), host, mode, port ?? undefined); +}; + +export const buildAppUrl = ( + containerName: string, + baseHost: string, + mode: UrlTemplateMode, + dockerPort?: number, +): string => { + const host = removeTrailingSlash(baseHost.trim()); + if (!host) return ""; + const slug = containerName.toLowerCase().replace(/[^a-z0-9-]/g, "-"); + return buildUrl(slug, host, mode, dockerPort); +}; diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index f90f81a38..eed534ea0 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -1,4 +1,4 @@ -import { objectKeys } from "@homarr/common"; +import { objectKeys, removeTrailingSlash } from "@homarr/common"; import type { AtLeastOneOf } from "@homarr/common/types"; import { createDocumentationLink } from "./docs"; @@ -17,6 +17,7 @@ export const integrationSecretKindObject = { privateKey: { isPublic: false, multiline: true }, githubAppId: { isPublic: true, multiline: false }, githubInstallationId: { isPublic: true, multiline: false }, + slug: { isPublic: true, multiline: false }, } satisfies Record; export const integrationSecretKinds = objectKeys(integrationSecretKindObject); @@ -27,7 +28,9 @@ interface integrationDefinition { secretKinds: AtLeastOneOf; // at least one secret kind set is required category: AtLeastOneOf; documentationUrl: string | null; - defaultUrl?: string; // optional default URL for the integration + defaultUrl?: string; + defaultPort?: number; + apiKeySettingsPath?: string; } export const integrationDefs = { @@ -37,6 +40,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg", category: ["downloadClient", "usenet"], documentationUrl: createDocumentationLink("/docs/integrations/sabnzbd"), + defaultPort: 8080, + apiKeySettingsPath: "/config/general/", }, nzbGet: { name: "NZBGet", @@ -44,6 +49,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nzbget.svg", category: ["downloadClient", "usenet"], documentationUrl: createDocumentationLink("/docs/integrations/nzbget"), + defaultPort: 6789, }, deluge: { name: "Deluge", @@ -51,6 +57,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/deluge.svg", category: ["downloadClient", "torrent"], documentationUrl: createDocumentationLink("/docs/integrations/deluge"), + defaultPort: 8112, }, transmission: { name: "Transmission", @@ -58,6 +65,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg", category: ["downloadClient", "torrent"], documentationUrl: createDocumentationLink("/docs/integrations/transmission"), + defaultPort: 9091, }, qBittorrent: { name: "qBittorrent", @@ -65,6 +73,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg", category: ["downloadClient", "torrent"], documentationUrl: createDocumentationLink("/docs/integrations/q-bittorent"), + defaultPort: 8080, + apiKeySettingsPath: "/#/settings/webui", }, aria2: { name: "Aria2", @@ -72,6 +82,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons@latest/src/system_downloads_3.svg", category: ["downloadClient", "torrent", "miscellaneous"], documentationUrl: createDocumentationLink("/docs/integrations/aria2"), + defaultPort: 6800, }, slskd: { name: "Slskd", @@ -79,6 +90,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/slskd.svg", category: ["downloadClient", "miscellaneous"], documentationUrl: createDocumentationLink("/docs/integrations/slskd"), + defaultPort: 5030, }, sonarr: { name: "Sonarr", @@ -86,6 +98,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg", category: ["calendar"], documentationUrl: createDocumentationLink("/docs/integrations/sonarr"), + defaultPort: 8989, + apiKeySettingsPath: "/settings/general", }, radarr: { name: "Radarr", @@ -93,6 +107,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg", category: ["calendar"], documentationUrl: createDocumentationLink("/docs/integrations/radarr"), + defaultPort: 7878, + apiKeySettingsPath: "/settings/general", }, lidarr: { name: "Lidarr", @@ -100,6 +116,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/lidarr.svg", category: ["calendar"], documentationUrl: createDocumentationLink("/docs/integrations/lidarr"), + defaultPort: 8686, + apiKeySettingsPath: "/settings/general", }, readarr: { name: "Readarr", @@ -107,6 +125,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg", category: ["calendar"], documentationUrl: createDocumentationLink("/docs/integrations/readarr"), + defaultPort: 8787, + apiKeySettingsPath: "/settings/general", }, prowlarr: { name: "Prowlarr", @@ -114,6 +134,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/prowlarr.svg", category: ["indexerManager"], documentationUrl: createDocumentationLink("/docs/integrations/prowlarr"), + defaultPort: 9696, + apiKeySettingsPath: "/settings/general", }, jellyfin: { name: "Jellyfin", @@ -121,6 +143,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg", category: ["mediaService", "mediaRelease"], documentationUrl: createDocumentationLink("/docs/integrations/jellyfin"), + defaultPort: 8096, + apiKeySettingsPath: "/web/index.html#!/dashboard/keys", }, emby: { name: "Emby", @@ -128,6 +152,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg", category: ["mediaService", "mediaRelease"], documentationUrl: createDocumentationLink("/docs/integrations/emby"), + defaultPort: 8096, }, plex: { name: "Plex", @@ -135,6 +160,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg", category: ["mediaService", "mediaRelease"], documentationUrl: createDocumentationLink("/docs/integrations/plex"), + defaultPort: 32400, }, jellyseerr: { name: "Jellyseerr", @@ -142,6 +168,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg", category: ["mediaSearch", "mediaRequest", "search"], documentationUrl: createDocumentationLink("/docs/integrations/jellyseerr"), + defaultPort: 5055, + apiKeySettingsPath: "/settings", }, seerr: { name: "Seerr", @@ -149,6 +177,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/seerr.svg", category: ["mediaSearch", "mediaRequest", "search"], documentationUrl: createDocumentationLink("/docs/integrations/seerr"), + defaultPort: 5055, + apiKeySettingsPath: "/settings", }, overseerr: { name: "Overseerr", @@ -156,6 +186,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/overseerr.svg", category: ["mediaSearch", "mediaRequest", "search"], documentationUrl: createDocumentationLink("/docs/integrations/overseerr"), + defaultPort: 5055, + apiKeySettingsPath: "/settings", }, piHole: { name: "Pi-hole", @@ -163,6 +195,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg", category: ["dnsHole"], documentationUrl: createDocumentationLink("/docs/integrations/pi-hole"), + defaultPort: 80, }, adGuardHome: { name: "AdGuard Home", @@ -170,6 +203,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/adguard-home.svg", category: ["dnsHole"], documentationUrl: createDocumentationLink("/docs/integrations/adguard-home"), + defaultPort: 3000, }, homeAssistant: { name: "Home Assistant", @@ -177,6 +211,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg", category: ["smartHomeServer", "calendar"], documentationUrl: createDocumentationLink("/docs/integrations/home-assistant"), + defaultPort: 8123, }, openmediavault: { name: "OpenMediaVault", @@ -184,6 +219,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/openmediavault.svg", category: ["healthMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/open-media-vault"), + defaultPort: 80, }, dashDot: { name: "Dash.", @@ -198,6 +234,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/glances.svg", category: ["healthMonitoring"], documentationUrl: null, + defaultPort: 61208, }, tdarr: { name: "Tdarr", @@ -205,6 +242,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png", category: ["mediaTranscoding"], documentationUrl: createDocumentationLink("/docs/integrations/tdarr"), + defaultPort: 8265, }, proxmox: { name: "Proxmox", @@ -212,6 +250,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/proxmox.svg", category: ["healthMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/proxmox"), + defaultPort: 8006, }, nextcloud: { name: "Nextcloud", @@ -219,6 +258,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nextcloud.svg", category: ["calendar", "notifications"], documentationUrl: createDocumentationLink("/docs/integrations/nextcloud"), + defaultPort: 443, }, unifiController: { name: "Unifi Controller", @@ -226,6 +266,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png", category: ["networkController"], documentationUrl: createDocumentationLink("/docs/integrations/unifi-controller"), + defaultPort: 8443, }, opnsense: { name: "OPNsense", @@ -233,6 +274,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/opnsense.svg", category: ["firewall"], documentationUrl: createDocumentationLink("/docs/integrations/opnsense"), + defaultPort: 443, }, github: { name: "Github", @@ -304,6 +346,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg", category: ["notifications"], documentationUrl: createDocumentationLink("/docs/integrations/ntfy"), + defaultPort: 80, }, ical: { name: "iCal", @@ -318,13 +361,15 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/anchor.svg", category: ["notes"], documentationUrl: createDocumentationLink("/docs/integrations/anchor"), + defaultPort: 8080, }, truenas: { name: "TrueNAS", - secretKinds: [["username", "password"]], + secretKinds: [["username", "password"], ["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg", category: ["healthMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/truenas"), + defaultPort: 80, }, unraid: { name: "Unraid", @@ -332,20 +377,20 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/unraid.svg", category: ["healthMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/unraid"), + defaultPort: 80, }, coolify: { name: "Coolify", secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg", category: ["healthMonitoring"], - // @ts-expect-error - docs page will be created when integration is merged documentationUrl: createDocumentationLink("/docs/integrations/coolify"), + defaultPort: 8000, }, searchCh: { name: "Search.ch", secretKinds: [[]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/search-ch.png", - defaultUrl: "https://search.ch", category: ["timetable"], documentationUrl: createDocumentationLink("/docs/integrations/search-ch"), }, @@ -355,6 +400,15 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/immich.svg", category: ["photoService"], documentationUrl: createDocumentationLink("/docs/integrations/immich"), + defaultPort: 2283, + }, + paperlessNgx: { + name: "Paperless-ngx", + secretKinds: [["apiKey"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/paperless-ngx.svg", + category: ["documents"], + documentationUrl: null, + defaultPort: 8000, }, tracearr: { name: "Tracearr", @@ -362,6 +416,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg", category: ["mediaMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/tracearr"), + defaultPort: 7040, }, speedtestTracker: { name: "Speedtest Tracker", @@ -369,6 +424,32 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/speedtest-tracker.png", category: ["speedtest"], documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"), + defaultPort: 80, + }, + uptimeKuma: { + name: "Uptime Kuma", + secretKinds: [[], ["slug"], ["slug", "apiKey"], ["apiKey"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/uptime-kuma.svg", + category: ["uptimeMonitoring"], + documentationUrl: null, + defaultPort: 3001, + }, + audiobookshelf: { + name: "Audiobookshelf", + secretKinds: [["apiKey"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/audiobookshelf.svg", + category: ["mediaLibrary"], + documentationUrl: null, + defaultPort: 13378, + apiKeySettingsPath: "/account", + }, + navidrome: { + name: "Navidrome", + secretKinds: [["username", "password"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/navidrome.svg", + category: ["mediaLibrary"], + documentationUrl: null, + defaultPort: 4533, }, umami: { name: "Umami", @@ -377,6 +458,22 @@ export const integrationDefs = { category: ["analytics"], defaultUrl: "https://api.umami.is/v1", documentationUrl: createDocumentationLink("/docs/integrations/umami"), + defaultPort: 3000, + }, + peaNut: { + name: "PeaNUT", + secretKinds: [["username", "password"], []], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg", + category: ["ups"], + documentationUrl: null, + }, + beszel: { + name: "Beszel", + secretKinds: [["username", "password"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg", + category: ["healthMonitoring"], + documentationUrl: null, + defaultPort: 8090, }, // This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page) mock: { @@ -396,6 +493,8 @@ export const integrationDefs = { "networkController", "notifications", "smartHomeServer", + "ups", + "uptimeMonitoring", ], documentationUrl: null, }, @@ -418,6 +517,19 @@ export const getIntegrationDefaultUrl = (integration: IntegrationKind) => { return "defaultUrl" in definition ? definition.defaultUrl : undefined; }; +export const getIntegrationDefaultPort = (kind: IntegrationKind): number | undefined => { + const definition = integrationDefs[kind]; + return "defaultPort" in definition ? definition.defaultPort : undefined; +}; + +export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: IntegrationKind): string | null => { + const definition = integrationDefs[kind]; + if (!("apiKeySettingsPath" in definition)) return null; + const base = removeTrailingSlash(integrationUrl); + if (!base) return null; + return `${base}${definition.apiKeySettingsPath}`; +}; + /** * Get all integration kinds that share a category, typed only by the kinds belonging to the category * @param category Category to filter by, belonging to IntegrationCategory @@ -470,6 +582,10 @@ export const integrationCategories = [ "mediaMonitoring", "speedtest", "analytics", + "ups", + "documents", + "mediaLibrary", + "uptimeMonitoring", ] as const; export type IntegrationCategory = (typeof integrationCategories)[number]; diff --git a/packages/definitions/src/onboarding.ts b/packages/definitions/src/onboarding.ts index d9a6ea03e..305a71f65 100644 --- a/packages/definitions/src/onboarding.ts +++ b/packages/definitions/src/onboarding.ts @@ -1,2 +1,2 @@ -export const onboardingSteps = ["start", "import", "user", "group", "settings", "finish"] as const; +export const onboardingSteps = ["start", "import", "user", "group", "settings", "integrations", "finish"] as const; export type OnboardingStep = (typeof onboardingSteps)[number]; diff --git a/packages/definitions/src/test/api-key-url.spec.ts b/packages/definitions/src/test/api-key-url.spec.ts new file mode 100644 index 000000000..21b7e0fe7 --- /dev/null +++ b/packages/definitions/src/test/api-key-url.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { getIntegrationApiKeyUrl } from "../integration"; + +describe("getIntegrationApiKeyUrl", () => { + it("builds URL for sonarr", () => { + expect(getIntegrationApiKeyUrl("http://192.168.1.10:8989", "sonarr")).toBe( + "http://192.168.1.10:8989/settings/general", + ); + }); + + it("builds URL for jellyfin", () => { + expect(getIntegrationApiKeyUrl("http://media.local:8096", "jellyfin")).toBe( + "http://media.local:8096/web/index.html#!/dashboard/keys", + ); + }); + + it("returns null for integrations without apiKeySettingsPath", () => { + expect(getIntegrationApiKeyUrl("http://localhost", "plex")).toBeNull(); + }); + + it("returns null for empty URL", () => { + expect(getIntegrationApiKeyUrl("", "sonarr")).toBeNull(); + }); + + it("strips trailing slash from base URL", () => { + expect(getIntegrationApiKeyUrl("http://sonarr.local/", "sonarr")).toBe("http://sonarr.local/settings/general"); + }); +}); diff --git a/packages/definitions/src/test/docker-integration-match.spec.ts b/packages/definitions/src/test/docker-integration-match.spec.ts new file mode 100644 index 000000000..2a5db3a14 --- /dev/null +++ b/packages/definitions/src/test/docker-integration-match.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +import { + extractContainerImageName, + matchIntegrationKind, + matchIntegrationKindFromContainer, +} from "../docker-integration-match"; + +describe("extractContainerImageName", () => { + it("extracts name from full image reference", () => { + expect(extractContainerImageName("linuxserver/sonarr:latest")).toBe("sonarr"); + }); + + it("extracts name from ghcr image", () => { + expect(extractContainerImageName("ghcr.io/org/radarr:v5")).toBe("radarr"); + }); + + it("handles plain image name", () => { + expect(extractContainerImageName("jellyfin")).toBe("jellyfin"); + }); + + it("handles empty string", () => { + expect(extractContainerImageName("")).toBe(""); + }); +}); + +describe("matchIntegrationKind", () => { + it("matches exact kind name", () => { + expect(matchIntegrationKind("sonarr")).toBe("sonarr"); + }); + + it("matches case-insensitively", () => { + expect(matchIntegrationKind("Sonarr")).toBe("sonarr"); + }); + + it("matches icon slug for pi-hole", () => { + expect(matchIntegrationKind("pi-hole")).toBe("piHole"); + }); + + it("matches alias pihole", () => { + expect(matchIntegrationKind("pihole")).toBe("piHole"); + }); + + it("matches alias adguardhome", () => { + expect(matchIntegrationKind("adguardhome")).toBe("adGuardHome"); + }); + + it("matches alias homeassistant", () => { + expect(matchIntegrationKind("homeassistant")).toBe("homeAssistant"); + }); + + it("matches substring in image name", () => { + expect(matchIntegrationKind("hotio-sonarr")).toBe("sonarr"); + }); + + it("returns null for unknown", () => { + expect(matchIntegrationKind("nginx")).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(matchIntegrationKind("")).toBeNull(); + }); +}); + +describe("matchIntegrationKindFromContainer", () => { + it("matches from image name first", () => { + expect( + matchIntegrationKindFromContainer({ + image: "linuxserver/sonarr:latest", + name: "my-sonarr", + }), + ).toBe("sonarr"); + }); + + it("falls back to container name", () => { + expect( + matchIntegrationKindFromContainer({ + image: "custom/myapp:latest", + name: "radarr", + }), + ).toBe("radarr"); + }); + + it("returns null when neither matches", () => { + expect( + matchIntegrationKindFromContainer({ + image: "nginx:latest", + name: "web-proxy", + }), + ).toBeNull(); + }); + + it("excludes dockerHub even when image contains docker", () => { + expect( + matchIntegrationKindFromContainer({ + image: "portainer/portainer-ce:latest", + name: "docker-proxy", + }), + ).toBeNull(); + }); + + it("excludes ical from container matching", () => { + expect( + matchIntegrationKindFromContainer({ + image: "ical-relay:latest", + name: "ical", + }), + ).toBeNull(); + }); + + it("excludes github from container matching", () => { + expect( + matchIntegrationKindFromContainer({ + image: "github-runner:latest", + name: "github-actions", + }), + ).toBeNull(); + }); +}); diff --git a/packages/definitions/src/test/integration-url-template.spec.ts b/packages/definitions/src/test/integration-url-template.spec.ts new file mode 100644 index 000000000..05e7a4e9b --- /dev/null +++ b/packages/definitions/src/test/integration-url-template.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { getIntegrationDefaultPort } from "../integration"; +import { buildIntegrationUrl } from "../integration-url-template"; + +describe("buildIntegrationUrl", () => { + it("builds subdomain URL", () => { + expect(buildIntegrationUrl("sonarr", "homelab.local", "subdomain")).toBe("https://sonarr.homelab.local"); + }); + + it("builds hostPort URL with default port", () => { + expect(buildIntegrationUrl("sonarr", "192.168.1.1", "hostPort")).toBe("http://192.168.1.1:8989"); + }); + + it("uses docker port over default port", () => { + expect(buildIntegrationUrl("sonarr", "192.168.1.1", "hostPort", 9999)).toBe("http://192.168.1.1:9999"); + }); + + it("builds hostPort URL without port when none known", () => { + expect(buildIntegrationUrl("mock", "192.168.1.1", "hostPort")).toBe("http://192.168.1.1"); + }); + + it("strips trailing slashes from host", () => { + expect(buildIntegrationUrl("sonarr", "homelab.local/", "subdomain")).toBe("https://sonarr.homelab.local"); + expect(buildIntegrationUrl("sonarr", "homelab.local///", "subdomain")).toBe("https://sonarr.homelab.local"); + }); + + it("returns empty string for empty host", () => { + expect(buildIntegrationUrl("sonarr", "", "subdomain")).toBe(""); + }); + + it("uses pi-hole slug for subdomain mode", () => { + expect(buildIntegrationUrl("piHole", "example.com", "subdomain")).toBe("https://pi-hole.example.com"); + }); + + it("reads default ports from integration definitions", () => { + expect(getIntegrationDefaultPort("radarr")).toBe(7878); + expect(getIntegrationDefaultPort("prowlarr")).toBe(9696); + expect(getIntegrationDefaultPort("jellyfin")).toBe(8096); + }); +}); diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts new file mode 100644 index 000000000..87898a49e --- /dev/null +++ b/packages/definitions/src/widget-integration-map.ts @@ -0,0 +1,116 @@ +import { createDocumentationLink } from "./docs"; +import type { IntegrationKind } from "./integration"; +import { getIntegrationKindsByCategory } from "./integration"; +import type { WidgetKind } from "./widget"; + +/** + * Static mapping of widget kinds to the integration kinds they support. + * Mirrors the supportedIntegrations declared in each widget definition + * but lives in @homarr/definitions so server-side code (API layer) + * can use it without importing @homarr/widgets. + */ +export const widgetIntegrationSupport: Partial> = { + dnsHoleSummary: getIntegrationKindsByCategory("dnsHole"), + dnsHoleControls: getIntegrationKindsByCategory("dnsHole"), + "smartHome-entityState": getIntegrationKindsByCategory("smartHomeServer"), + "smartHome-executeAutomation": getIntegrationKindsByCategory("smartHomeServer"), + mediaServer: getIntegrationKindsByCategory("mediaService"), + calendar: getIntegrationKindsByCategory("calendar"), + downloads: getIntegrationKindsByCategory("downloadClient"), + "mediaRequests-requestList": getIntegrationKindsByCategory("mediaRequest"), + "mediaRequests-requestStats": getIntegrationKindsByCategory("mediaRequest"), + mediaTranscoding: getIntegrationKindsByCategory("mediaTranscoding"), + networkControllerSummary: getIntegrationKindsByCategory("networkController"), + networkControllerStatus: getIntegrationKindsByCategory("networkController"), + indexerManager: getIntegrationKindsByCategory("indexerManager"), + healthMonitoring: getIntegrationKindsByCategory("healthMonitoring"), + firewall: getIntegrationKindsByCategory("firewall"), + notifications: getIntegrationKindsByCategory("notifications"), + mediaReleases: ["emby", "jellyfin", "plex"], + systemResources: ["dashDot", "openmediavault", "truenas", "unraid", "glances"], + systemDisks: ["dashDot", "openmediavault", "truenas", "unraid"], + coolify: ["coolify"], + timetable: ["searchCh"], + "immich-serverStats": ["immich"], + "immich-albumCarousel": ["immich"], + paperlessNgx: ["paperlessNgx"], + tracearr: ["tracearr"], + speedtestTracker: ["speedtestTracker"], + uptimeKuma: ["uptimeKuma"], + audioStats: ["navidrome", "audiobookshelf"], + anchorNote: ["anchor"], +}; + +export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => { + const result: WidgetKind[] = []; + for (const [widgetKind, supportedIntegrations] of Object.entries(widgetIntegrationSupport)) { + if (supportedIntegrations.includes(integrationKind)) { + result.push(widgetKind as WidgetKind); + } + } + return result; +}; + +export interface DefaultWidgetConfig { + kind: WidgetKind; + width: number; + height: number; + options?: Record; + skip?: boolean; +} + +export const defaultWidgetConfigs: DefaultWidgetConfig[] = [ + { kind: "clock", width: 2, height: 1 }, + { kind: "weather", width: 2, height: 1, options: { showCity: true, hasForecast: true, forecastDayCount: 3 } }, + { kind: "bookmarks", width: 2, height: 2, options: { title: "Useful Links", layout: "grid", openNewTab: true } }, +]; + +export const generalWidgets: WidgetKind[] = defaultWidgetConfigs + .filter((config) => !config.skip) + .map((config) => config.kind); + +export const featuredIntegrations: readonly IntegrationKind[] = [ + "sonarr", + "radarr", + "prowlarr", + "sabNzbd", + "qBittorrent", + "seerr", + "jellyfin", +]; + +export const hiddenFromOnboarding = new Set([ + "searchCh", + "github", + "dockerHub", + "gitlab", + "npm", + "codeberg", + "linuxServerIO", + "gitHubContainerRegistry", + "quay", + "mock", +]); + +export const defaultBookmarkApps = [ + { + name: "Homarr Docs", + href: createDocumentationLink("/docs/getting-started"), + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg", + }, + { + name: "Homarr GitHub", + href: "https://github.com/homarr-labs/homarr", + iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/github.svg", + }, + { + name: "Help Translate", + href: createDocumentationLink("/docs/community/translations"), + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg", + }, + { + name: "Support Homarr", + href: "https://opencollective.com/homarr", + iconUrl: "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets@latest/assets/opencollective.svg", + }, +]; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index d6305a08e..cb541ff8f 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -35,8 +35,22 @@ export const widgetKinds = [ "timetable", "immich-serverStats", "immich-albumCarousel", + "paperlessNgx", "tracearr", "speedtestTracker", + "uptimeKuma", + "audioStats", "umami", + "ups", + "beszelSystemTable", + "beszelSystemGrid", + "beszelAlerts", + "beszelSystemStats", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; + +export const widgetDefaultSizes: Partial> = { + uptimeKuma: { width: 2, height: 3 }, + audioStats: { width: 2, height: 2 }, + paperlessNgx: { width: 2, height: 2 }, +}; diff --git a/packages/docker/src/shared.ts b/packages/docker/src/shared.ts index f50bd32c7..e98695278 100644 --- a/packages/docker/src/shared.ts +++ b/packages/docker/src/shared.ts @@ -11,3 +11,23 @@ export const containerStateColorMap = { removing: "pink", dead: "dark", } satisfies Record; + +export const memoryUsageColor = (number: number, state: string): MantineColor => { + const mbUsage = number / 1024 / 1024; + if (mbUsage === 0 && state !== "running") return "red"; + if (mbUsage < 128) return "green"; + if (mbUsage < 256) return "yellow"; + if (mbUsage < 512) return "orange"; + return "red"; +}; + +export const cpuUsageColor = (number: number, state: string): MantineColor => { + if (number === 0 && state !== "running") return "red"; + if (number < 40) return "green"; + if (number < 60) return "yellow"; + if (number < 90) return "orange"; + return "red"; +}; + +export const safeValue = (value?: number, fallback = 0): number => + value !== undefined && !isNaN(value) ? value : fallback; diff --git a/packages/forms-collection/src/new-app/_form.tsx b/packages/forms-collection/src/new-app/_form.tsx index 05c7e3f6f..2bce21034 100644 --- a/packages/forms-collection/src/new-app/_form.tsx +++ b/packages/forms-collection/src/new-app/_form.tsx @@ -1,7 +1,7 @@ "use client"; import type { ChangeEventHandler } from "react"; -import { useEffect, useRef } from "react"; +import { useEffect, useImperativeHandle, useRef } from "react"; import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core"; import { useDebouncedValue, useDisclosure } from "@mantine/hooks"; import type { z } from "zod/v4"; @@ -17,6 +17,19 @@ import { findBestIconMatch } from "./icon-matcher"; type FormType = z.infer; +const toFormValues = (values: FormType | undefined): FormType => ({ + name: values?.name ?? "", + description: values?.description ?? "", + iconUrl: values?.iconUrl ?? "", + href: values?.href ?? "", + pingUrl: values?.pingUrl ?? "", +}); + +export interface AppFormHandle { + submit: () => Promise; + isDirty: () => boolean; +} + interface AppFormProps { showBackToOverview: boolean; buttonLabels: { @@ -24,8 +37,10 @@ interface AppFormProps { submitAndCreateAnother?: string; }; initialValues?: FormType; - handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void; + handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void | Promise; isPending: boolean; + hideButtons?: boolean; + formRef?: React.Ref; } export const AppForm = ({ @@ -34,19 +49,31 @@ export const AppForm = ({ handleSubmit: originalHandleSubmit, initialValues, isPending, + hideButtons, + formRef, }: AppFormProps) => { const t = useI18n(); const form = useZodForm(appManageSchema, { - initialValues: { - name: initialValues?.name ?? "", - description: initialValues?.description ?? "", - iconUrl: initialValues?.iconUrl ?? "", - href: initialValues?.href ?? "", - pingUrl: initialValues?.pingUrl ?? "", - }, + initialValues: toFormValues(initialValues), }); + const initialValuesKey = [ + initialValues?.name, + initialValues?.description, + initialValues?.iconUrl, + initialValues?.href, + initialValues?.pingUrl, + ].join("\0"); + + useEffect(() => { + if (!initialValues || form.isDirty()) { + return; + } + + form.initialize(toFormValues(initialValues)); + }, [initialValuesKey, form, initialValues]); + // Debounce the name value with 200ms delay const [debouncedName] = useDebouncedValue(form.values.name, 200); @@ -62,6 +89,29 @@ export const AppForm = ({ originalHandleSubmit(values, redirect, afterSuccess); }; + useImperativeHandle( + formRef, + () => ({ + submit: () => + new Promise((resolve) => { + form.onSubmit( + async (values) => { + try { + await Promise.resolve(handleSubmit(values)); + form.initialize(values); + resolve(true); + } catch { + resolve(false); + } + }, + () => resolve(false), + )(); + }), + isDirty: () => form.isDirty(), + }), + [form, handleSubmit], + ); + const [opened, { open, close }] = useDisclosure((initialValues?.pingUrl?.length ?? 0) > 0); const handleClickDifferentUrlPing: ChangeEventHandler = () => { @@ -92,32 +142,32 @@ export const AppForm = ({ } }, [debouncedName, iconsData]); - return ( -
- - - -