diff --git a/charts/trek/templates/configmap.yaml b/charts/trek/templates/configmap.yaml index aeab34b07..a7397e76f 100644 --- a/charts/trek/templates/configmap.yaml +++ b/charts/trek/templates/configmap.yaml @@ -85,3 +85,15 @@ data: {{- if .Values.env.OVERPASS_TIMEOUT_MS }} OVERPASS_TIMEOUT_MS: {{ .Values.env.OVERPASS_TIMEOUT_MS | quote }} {{- end }} + {{- /* Tested for presence rather than truth: the one value this switch gets set to is + false, and an unquoted false (or --set env.TREK_PLACES_ENABLED=false) would + otherwise drop the key and leave the index on. */}} + {{- if not (kindIs "invalid" .Values.env.TREK_PLACES_ENABLED) }} + TREK_PLACES_ENABLED: {{ .Values.env.TREK_PLACES_ENABLED | quote }} + {{- end }} + {{- if .Values.env.TREK_PLACES_URL }} + TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }} + {{- end }} + {{- if .Values.env.AMAP_API_BASE }} + AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }} + {{- end }} diff --git a/charts/trek/templates/deployment.yaml b/charts/trek/templates/deployment.yaml index 7b24b98dd..24e67325c 100644 --- a/charts/trek/templates/deployment.yaml +++ b/charts/trek/templates/deployment.yaml @@ -75,6 +75,18 @@ spec: name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} key: UNSPLASH_ACCESS_KEY optional: true + - name: AMAP_API_KEY + valueFrom: + secretKeyRef: + name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} + key: AMAP_API_KEY + optional: true + - name: AMAP_API_SECRET + valueFrom: + secretKeyRef: + name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} + key: AMAP_API_SECRET + optional: true volumeMounts: - name: data mountPath: /app/data diff --git a/charts/trek/templates/secret.yaml b/charts/trek/templates/secret.yaml index 08d81cabd..c465e8e34 100644 --- a/charts/trek/templates/secret.yaml +++ b/charts/trek/templates/secret.yaml @@ -20,6 +20,12 @@ data: {{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }} UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }} {{- end }} + {{- if .Values.secretEnv.AMAP_API_KEY }} + AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | b64enc | quote }} + {{- end }} + {{- if .Values.secretEnv.AMAP_API_SECRET }} + AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET | b64enc | quote }} + {{- end }} {{- end }} {{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }} @@ -50,4 +56,10 @@ stringData: {{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }} UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }} {{- end }} + {{- if .Values.secretEnv.AMAP_API_KEY }} + AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY }} + {{- end }} + {{- if .Values.secretEnv.AMAP_API_SECRET }} + AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET }} + {{- end }} {{- end }} diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml index 69c136e04..0d4bb1aeb 100644 --- a/charts/trek/values.yaml +++ b/charts/trek/values.yaml @@ -90,8 +90,24 @@ env: # Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled # public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable # from the cluster (e.g. locked-down egress). Non-http(s) entries are ignored. - # OVERPASS_TIMEOUT_MS: "12000" - # Per-endpoint timeout (ms) for Overpass POI requests. Raise it for a slow self-hosted Overpass instance. Defaults to 12000. + # OVERPASS_TIMEOUT_MS: "25000" + # Per-endpoint timeout (ms) for Overpass POI requests. The query itself gives Overpass 20 seconds of work, so a + # lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance. + # Defaults to 25000. + # TREK_PLACES_ENABLED: "false" + # Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads) + # asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that + # host. Set "false" to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send + # nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided + # here. Defaults to on. + # TREK_PLACES_URL: "" + # Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from + # configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK. + # Defaults to https://places.liketrek.com. + # AMAP_API_BASE: "" + # Send the Amap (高德地图) calls through an egress proxy, a cache or a gateway that speaks the same API instead of + # restapi.amap.com directly. The key itself is a credential and goes under secretEnv.AMAP_API_KEY, or into + # Admin > Settings > API Keys. Defaults to https://restapi.amap.com. # Secret environment variables stored in a Kubernetes Secret. @@ -117,6 +133,15 @@ secretEnv: # Get a free key at https://unsplash.com/developers. Can also be set per-admin # in Admin > Settings; this value overrides that. Leave empty to disable. UNSPLASH_ACCESS_KEY: "" + # Optional Amap (高德地图) Web 服务 key for place search and geocoding inside mainland + # China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is + # a different credential and is rejected with INVALID_USER_KEY. Leave empty to use the + # key from the admin panel, or none. + AMAP_API_KEY: "" + # The private secret (数字签名) of that key. Required exactly when the key was created + # with signing enabled, because such a key rejects every unsigned request. Leave empty + # otherwise. The endpoint itself is not a secret and is set under env.AMAP_API_BASE. + AMAP_API_SECRET: "" # If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades generateEncryptionKey: false diff --git a/client/src/api/client.surface.test.ts b/client/src/api/client.surface.test.ts index eebc8f933..be5aee90b 100644 --- a/client/src/api/client.surface.test.ts +++ b/client/src/api/client.surface.test.ts @@ -1,4 +1,4 @@ -// FE-APISURF-001 to FE-APISURF-056 +// FE-APISURF-001 to FE-APISURF-057 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import type { AxiosResponse } from 'axios' import { http, HttpResponse } from 'msw' @@ -10,7 +10,7 @@ import { mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi, configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi, shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi, memoriesApi, - docsyncApi, + docsyncApi, DOCSYNC_RUN_TIMEOUT_MS, DOCSYNC_UPSTREAM_TIMEOUT_MS, } from './client' interface Recorded { method: string; url: string; body: unknown } @@ -902,4 +902,47 @@ describe('client > multipart uploads', () => { expect(call[2]).toMatchObject({ timeout: 40000 }) } }) + + it('FE-APISURF-057: every document-sync call that reaches the store outlives the 8s global timeout', async () => { + const post = spyPost() + const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: { ok: true } } as unknown as AxiosResponse) + const del = vi.spyOn(apiClient, 'delete').mockResolvedValue({ data: { ok: true } } as unknown as AxiosResponse) + + // The server allows each request to a store 15 s, and a run waits for the + // whole listing and every transfer; cut off at 8 s the browser reported a + // failure while the server carried on and finished. + await docsyncApi.testConnection(1, { providerId: 'paperless' }) + await docsyncApi.createScope(1, 5, 'Norway') + await docsyncApi.createLink(1, { connectionId: 5 }) + await docsyncApi.listScopes(1, 5) + await docsyncApi.deleteLink(1, 9) + + expect(post.mock.calls.map(c => c[0])).toEqual([ + '/trips/1/docsync/connections/test', + '/trips/1/docsync/connections/5/scopes', + '/trips/1/docsync/links', + ]) + for (const call of post.mock.calls) { + expect(call[2]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS }) + } + expect(get.mock.calls[0][0]).toBe('/trips/1/docsync/connections/5/scopes') + expect(get.mock.calls[0][1]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS }) + expect(del.mock.calls[0][0]).toBe('/trips/1/docsync/links/9') + expect(del.mock.calls[0][1]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS }) + expect(DOCSYNC_UPSTREAM_TIMEOUT_MS).toBeGreaterThan(8000) + + // A run, and a conflict choice that runs the binding afterwards. + post.mockClear() + await docsyncApi.syncNow(1, 9) + await docsyncApi.resolve(1, 4, 'trek') + + expect(post.mock.calls.map(c => c[0])).toEqual([ + '/trips/1/docsync/links/9/sync', + '/trips/1/docsync/items/4/resolve', + ]) + for (const call of post.mock.calls) { + expect(call[2]).toMatchObject({ timeout: DOCSYNC_RUN_TIMEOUT_MS }) + } + expect(DOCSYNC_RUN_TIMEOUT_MS).toBeGreaterThan(DOCSYNC_UPSTREAM_TIMEOUT_MS) + }) }) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index c033fc057..4d5809557 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1361,6 +1361,34 @@ export const filesApi = { getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data), } +/** + * How long the browser waits for a document-sync call that makes the server go + * and ask the store. + * + * The shared 8 s on `apiClient` is right for TREK's own routes and wrong for + * these, for the reason `dawarich.ts` gives: the server allows each request to + * a store 15 s by itself (20 s for Papra), a connection test may probe more + * than once, and binding or unbinding first registers or removes a webhook at + * the store. Cut off at 8 s, the browser reported a failure while the server + * carried on and finished, so a listing that took twelve seconds read as a + * broken store. + */ +export const DOCSYNC_UPSTREAM_TIMEOUT_MS = 60_000 + +/** + * A run is longer again. The server answers only once the whole of it is + * through: a listing of up to 40 pages, then up to 25 transfers at up to 120 s + * each, and Paperless-ngx holds every push until its consume task has run. + * Resolving a conflict runs the binding afterwards, so it waits just as long. + * Ten minutes covers a full transfer budget of ordinary documents; a run that + * outlasts it still finishes on the server, and the `docsync:changed` ping + * refreshes the dialog when it does. + */ +export const DOCSYNC_RUN_TIMEOUT_MS = 600_000 + +const docsyncUpstream = { timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS } +const docsyncRun = { timeout: DOCSYNC_RUN_TIMEOUT_MS } + /** * Document sync: one provider connection per trip, shared by every member. * @@ -1373,22 +1401,22 @@ export const docsyncApi = { status: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/status`).then(r => r.data), listConnections: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/connections`).then(r => r.data), saveConnection: (tripId: number | string, data: unknown) => apiClient.put(`/trips/${tripId}/docsync/connections`, data).then(r => r.data), - testConnection: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/connections/test`, data).then(r => r.data), + testConnection: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/connections/test`, data, docsyncUpstream).then(r => r.data), deleteConnection: (tripId: number | string, connectionId: number) => apiClient.delete(`/trips/${tripId}/docsync/connections/${connectionId}`).then(r => r.data), listScopes: (tripId: number | string, connectionId: number, q?: string) => - apiClient.get(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { params: q ? { q } : {} }).then(r => r.data), + apiClient.get(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { params: q ? { q } : {}, ...docsyncUpstream }).then(r => r.data), createScope: (tripId: number | string, connectionId: number, name: string) => - apiClient.post(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { name }).then(r => r.data), + apiClient.post(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { name }, docsyncUpstream).then(r => r.data), listLinks: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/links`).then(r => r.data), - createLink: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/links`, data).then(r => r.data), + createLink: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/links`, data, docsyncUpstream).then(r => r.data), updateLink: (tripId: number | string, linkId: number, data: unknown) => apiClient.patch(`/trips/${tripId}/docsync/links/${linkId}`, data).then(r => r.data), - deleteLink: (tripId: number | string, linkId: number) => apiClient.delete(`/trips/${tripId}/docsync/links/${linkId}`).then(r => r.data), + deleteLink: (tripId: number | string, linkId: number) => apiClient.delete(`/trips/${tripId}/docsync/links/${linkId}`, docsyncUpstream).then(r => r.data), syncNow: (tripId: number | string, linkId: number, full = false) => - apiClient.post(`/trips/${tripId}/docsync/links/${linkId}/sync`, { full }).then(r => r.data), + apiClient.post(`/trips/${tripId}/docsync/links/${linkId}/sync`, { full }, docsyncRun).then(r => r.data), items: (tripId: number | string, state?: string) => apiClient.get(`/trips/${tripId}/docsync/items`, { params: state ? { state } : {} }).then(r => r.data), resolve: (tripId: number | string, itemId: number, keep: 'trek' | 'provider' | 'both') => - apiClient.post(`/trips/${tripId}/docsync/items/${itemId}/resolve`, { keep }).then(r => r.data), + apiClient.post(`/trips/${tripId}/docsync/items/${itemId}/resolve`, { keep }, docsyncRun).then(r => r.data), } export const reservationsApi = { diff --git a/client/src/components/Admin/AddonManager.test.tsx b/client/src/components/Admin/AddonManager.test.tsx index 0964cedc0..1b86b373c 100644 --- a/client/src/components/Admin/AddonManager.test.tsx +++ b/client/src/components/Admin/AddonManager.test.tsx @@ -1,4 +1,4 @@ -// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-036 +// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-037 import { render, screen, waitFor } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { delay, http, HttpResponse } from 'msw'; @@ -543,6 +543,22 @@ describe('AddonManager', () => { expect(screen.queryByText('Polls')).not.toBeInTheDocument(); }); + it('FE-ADMIN-ADDON-037: the links row describes the feature, not an empty list', async () => { + // The row's tooltip is its description. The links tab's empty state read as a + // status here, and a status that never changes reads as a broken feature. + server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })])); + render( + , + ); + + const links = await screen.findByText('Links'); + expect(links).toHaveAttribute('title'); + expect(links.getAttribute('title')).not.toBe('No shared links yet'); + }); + it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => { server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }])); render(); diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 6002d42c7..94755412a 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -99,7 +99,7 @@ interface CollabFeatures { chat: boolean; notes: boolean; links?: boolean; polls const COLLAB_SUB_FEATURES = [ { key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' }, { key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' }, - { key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'collab.links.empty' }, + { key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'admin.collab.links.subtitle' }, { key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' }, { key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' }, ] as const diff --git a/client/src/components/Collab/CollabChat.test.tsx b/client/src/components/Collab/CollabChat.test.tsx index 2f818b142..2ed6657d6 100644 --- a/client/src/components/Collab/CollabChat.test.tsx +++ b/client/src/components/Collab/CollabChat.test.tsx @@ -708,4 +708,31 @@ describe('CollabChat', () => { { timeout: 3000 } ); }); + + // The strip shows the four pictures that will go out; the two the cap turned away + // vanish without a word otherwise, and the sender believes all six were sent. + it('FE-COMP-CHAT-037: picking more images than fit on one message says so, and says it once', async () => { + const addToast = vi.fn(); + window.__addToast = addToast as unknown as typeof window.__addToast; + let minted = 0; + const createObjURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(() => `blob:mock/${++minted}`); + const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + try { + render(); + await screen.findByText('Start the conversation'); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const files = Array.from({ length: 6 }, (_, i) => new File([new Uint8Array(8)], `${i}.png`, { type: 'image/png' })); + + fireEvent.change(input, { target: { files } }); + + await waitFor(() => expect(document.querySelectorAll('img[src^="blob:mock/"]')).toHaveLength(4)); + expect(addToast).toHaveBeenCalledTimes(1); + const [message, type] = addToast.mock.calls[0]; + expect(type).toBe('error'); + expect(message).not.toBe('Only JPEG, PNG, GIF and WebP images up to 10 MB are allowed'); + } finally { + delete window.__addToast; + createObjURL.mockRestore(); revokeObjURL.mockRestore(); + } + }); }); diff --git a/client/src/components/Collab/CollabChat.tsx b/client/src/components/Collab/CollabChat.tsx index f3cc80526..a1badfd38 100644 --- a/client/src/components/Collab/CollabChat.tsx +++ b/client/src/components/Collab/CollabChat.tsx @@ -48,8 +48,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) { )} - {imagePreviews.length > 0 &&
{imagePreviews.map((url, i) =>
)}
} - {uploadProgress > 0 && uploadProgress < 100 &&
Uploading {uploadProgress}%
} + {imagePreviews.length > 0 &&
{imagePreviews.map((url, i) =>
)}
} + {uploadProgress > 0 && uploadProgress < 100 &&
{t('collab.chat.uploading', { percent: uploadProgress })}
}
{/* Emoji button */} {canEdit && ( @@ -63,7 +63,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) { )} - {canEdit && <> { if (e.target.files) addImageFiles(e.target.files); e.currentTarget.value = '' }} />} + {canEdit && <> { if (e.target.files) addImageFiles(e.target.files); e.currentTarget.value = '' }} />}