Implement HTTP timeout configurations and enhance API documentation
- Added optional HTTP server timeout configurations (`HNHMAP_READ_TIMEOUT`, `HNHMAP_WRITE_TIMEOUT`, `HNHMAP_IDLE_TIMEOUT`) to `.env.example` and updated the server initialization in `main.go` to utilize these settings. - Enhanced API documentation for the `rebuildZooms` endpoint to clarify its background processing and polling mechanism for status updates. - Updated `configuration.md` to include new timeout environment variables for better configuration guidance. - Improved error handling in the client for large request bodies, ensuring appropriate responses for oversized payloads.
This commit is contained in:
@@ -283,6 +283,7 @@ function updateSelectedMarkerForBookmark() {
|
||||
}
|
||||
}
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
let characterPollFirstTickId: ReturnType<typeof setTimeout> | null = null
|
||||
let autoMode = false
|
||||
let mapContainer: HTMLElement | null = null
|
||||
let contextMenuHandler: ((ev: MouseEvent) => void) | null = null
|
||||
@@ -554,13 +555,20 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
function startCharacterPoll() {
|
||||
if (characterPollFirstTickId) clearTimeout(characterPollFirstTickId)
|
||||
characterPollFirstTickId = null
|
||||
if (intervalId) clearInterval(intervalId)
|
||||
intervalId = null
|
||||
const ms =
|
||||
typeof document !== 'undefined' && document.visibilityState === 'hidden'
|
||||
? CHARACTER_POLL_MS_HIDDEN
|
||||
: CHARACTER_POLL_MS
|
||||
pollCharacters()
|
||||
intervalId = setInterval(pollCharacters, ms)
|
||||
// First tick after delay to avoid double-fetch with initial getCharacters() in Promise.all
|
||||
characterPollFirstTickId = setTimeout(() => {
|
||||
characterPollFirstTickId = null
|
||||
pollCharacters()
|
||||
intervalId = setInterval(pollCharacters, ms)
|
||||
}, ms)
|
||||
}
|
||||
|
||||
startCharacterPoll()
|
||||
@@ -703,6 +711,7 @@ onBeforeUnmount(() => {
|
||||
if (contextMenuHandler) {
|
||||
document.removeEventListener('contextmenu', contextMenuHandler, true)
|
||||
}
|
||||
if (characterPollFirstTickId) clearTimeout(characterPollFirstTickId)
|
||||
if (intervalId) clearInterval(intervalId)
|
||||
updatesHandle?.cleanup()
|
||||
if (leafletMap) leafletMap.remove()
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
:src="useGravatarUrl(email, size)"
|
||||
alt=""
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
@error="gravatarError = true"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -98,6 +98,20 @@ const recent = useRecentLocations()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const query = ref('')
|
||||
const queryDebounced = ref('')
|
||||
let queryDebounceId: ReturnType<typeof setTimeout> | null = null
|
||||
const SEARCH_DEBOUNCE_MS = 180
|
||||
watch(
|
||||
query,
|
||||
(v) => {
|
||||
if (queryDebounceId) clearTimeout(queryDebounceId)
|
||||
queryDebounceId = setTimeout(() => {
|
||||
queryDebounced.value = v
|
||||
queryDebounceId = null
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
const showDropdown = ref(false)
|
||||
const highlightIndex = ref(0)
|
||||
let closeDropdownTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -113,7 +127,7 @@ interface Suggestion {
|
||||
}
|
||||
|
||||
const suggestions = computed<Suggestion[]>(() => {
|
||||
const q = query.value.trim().toLowerCase()
|
||||
const q = queryDebounced.value.trim().toLowerCase()
|
||||
if (!q) return []
|
||||
|
||||
const coordMatch = q.match(/^\s*(-?\d+)\s*[,;\s]\s*(-?\d+)\s*$/)
|
||||
@@ -193,10 +207,18 @@ const emit = defineEmits<{
|
||||
'jump-to-marker': [id: number]
|
||||
}>()
|
||||
|
||||
watch(suggestions, () => {
|
||||
highlightIndex.value = 0
|
||||
}, { flush: 'sync' })
|
||||
|
||||
onMounted(() => {
|
||||
useMapNavigate().setFocusSearch(() => inputRef.value?.focus())
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (queryDebounceId) clearTimeout(queryDebounceId)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (closeDropdownTimer) clearTimeout(closeDropdownTimer)
|
||||
useMapNavigate().setFocusSearch(null)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import md5 from 'md5'
|
||||
|
||||
const gravatarCache = new Map<string, string>()
|
||||
|
||||
/**
|
||||
* Returns Gravatar avatar URL for the given email, or empty string if no email.
|
||||
* Gravatar expects: trim, lowercase, then MD5 hex. Use empty string to show a placeholder (e.g. initial letter) in the UI.
|
||||
* Results are memoized by (email, size) to avoid recomputing MD5 on every render.
|
||||
*
|
||||
* @param email - User email (optional)
|
||||
* @param size - Avatar size in pixels (default 64; navbar 32, drawer 40, profile 64–80)
|
||||
@@ -10,7 +13,12 @@ import md5 from 'md5'
|
||||
export function useGravatarUrl(email: string | undefined, size?: number): string {
|
||||
const normalized = email?.trim().toLowerCase()
|
||||
if (!normalized) return ''
|
||||
const hash = md5(normalized)
|
||||
const s = size ?? 64
|
||||
return `https://www.gravatar.com/avatar/${hash}?s=${s}&d=identicon`
|
||||
const key = `${normalized}\n${s}`
|
||||
const cached = gravatarCache.get(key)
|
||||
if (cached !== undefined) return cached
|
||||
const hash = md5(normalized)
|
||||
const url = `https://www.gravatar.com/avatar/${hash}?s=${s}&d=identicon`
|
||||
gravatarCache.set(key, url)
|
||||
return url
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ export type { Character, ConfigResponse, MapInfo, MapInfoAdmin, Marker, MeRespon
|
||||
// Singleton: shared by all useMapApi() callers so 401 triggers one global handler (e.g. app.vue)
|
||||
const onApiErrorCallbacks = new Map<symbol, () => void>()
|
||||
|
||||
// In-flight dedup: one me() request at a time; concurrent callers share the same promise.
|
||||
let mePromise: Promise<MeResponse> | null = null
|
||||
|
||||
// In-flight dedup for GET endpoints: same path + method shares one request across all callers.
|
||||
const inFlightByKey = new Map<string, Promise<unknown>>()
|
||||
|
||||
export function useMapApi() {
|
||||
const config = useRuntimeConfig()
|
||||
const apiBase = config.public.apiBase as string
|
||||
@@ -40,20 +46,31 @@ export function useMapApi() {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
function requestDeduped<T>(path: string, opts?: RequestInit): Promise<T> {
|
||||
const key = path + (opts?.method ?? 'GET')
|
||||
const existing = inFlightByKey.get(key)
|
||||
if (existing) return existing as Promise<T>
|
||||
const p = request<T>(path, opts).finally(() => {
|
||||
inFlightByKey.delete(key)
|
||||
})
|
||||
inFlightByKey.set(key, p)
|
||||
return p
|
||||
}
|
||||
|
||||
async function getConfig() {
|
||||
return request<ConfigResponse>('config')
|
||||
return requestDeduped<ConfigResponse>('config')
|
||||
}
|
||||
|
||||
async function getCharacters() {
|
||||
return request<Character[]>('v1/characters')
|
||||
return requestDeduped<Character[]>('v1/characters')
|
||||
}
|
||||
|
||||
async function getMarkers() {
|
||||
return request<Marker[]>('v1/markers')
|
||||
return requestDeduped<Marker[]>('v1/markers')
|
||||
}
|
||||
|
||||
async function getMaps() {
|
||||
return request<Record<string, MapInfo>>('maps')
|
||||
return requestDeduped<Record<string, MapInfo>>('maps')
|
||||
}
|
||||
|
||||
// Auth
|
||||
@@ -92,11 +109,17 @@ export function useMapApi() {
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
mePromise = null
|
||||
inFlightByKey.clear()
|
||||
await fetch(`${apiBase}/logout`, { method: 'POST', credentials: 'include' })
|
||||
}
|
||||
|
||||
async function me() {
|
||||
return request<MeResponse>('me')
|
||||
if (mePromise) return mePromise
|
||||
mePromise = request<MeResponse>('me').finally(() => {
|
||||
mePromise = null
|
||||
})
|
||||
return mePromise
|
||||
}
|
||||
|
||||
async function meUpdate(body: { email?: string }) {
|
||||
@@ -182,7 +205,19 @@ export function useMapApi() {
|
||||
}
|
||||
|
||||
async function adminRebuildZooms() {
|
||||
await request('admin/rebuildZooms', { method: 'POST' })
|
||||
const res = await fetch(`${apiBase}/admin/rebuildZooms`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
onApiErrorCallbacks.forEach((cb) => cb())
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
if (res.status !== 200 && res.status !== 202) throw new Error(`API ${res.status}`)
|
||||
}
|
||||
|
||||
async function adminRebuildZoomsStatus(): Promise<{ running: boolean }> {
|
||||
return request<{ running: boolean }>('admin/rebuildZooms/status')
|
||||
}
|
||||
|
||||
function adminExportUrl() {
|
||||
@@ -250,6 +285,7 @@ export function useMapApi() {
|
||||
adminMapToggleHidden,
|
||||
adminWipe,
|
||||
adminRebuildZooms,
|
||||
adminRebuildZoomsStatus,
|
||||
adminExportUrl,
|
||||
adminMerge,
|
||||
adminWipeTile,
|
||||
|
||||
@@ -60,11 +60,11 @@
|
||||
aria-label="Search users"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2 max-h-[60vh] overflow-y-auto">
|
||||
<div
|
||||
v-for="u in filteredUsers"
|
||||
:key="u"
|
||||
class="flex items-center justify-between gap-3 w-full p-3 rounded-lg bg-base-300/50 hover:bg-base-300/70 transition-colors"
|
||||
class="flex items-center justify-between gap-3 w-full p-3 rounded-lg bg-base-300/50 hover:bg-base-300/70 transition-colors shrink-0"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div class="avatar avatar-placeholder">
|
||||
@@ -103,7 +103,7 @@
|
||||
aria-label="Search maps"
|
||||
/>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<div class="overflow-x-auto max-h-[60vh] overflow-y-auto">
|
||||
<table class="table table-sm table-zebra min-w-[32rem]">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -199,7 +199,7 @@
|
||||
</h2>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<a :href="api.adminExportUrl()" target="_blank" rel="noopener" class="btn btn-sm min-h-11 touch-manipulation">
|
||||
<a :href="exportUrl" target="_blank" rel="noopener" class="btn btn-sm min-h-11 touch-manipulation">
|
||||
Export zip
|
||||
</a>
|
||||
</div>
|
||||
@@ -284,6 +284,7 @@ definePageMeta({ middleware: 'admin' })
|
||||
useHead({ title: 'Admin – HnH Map' })
|
||||
|
||||
const api = useMapApi()
|
||||
const exportUrl = computed(() => api.adminExportUrl())
|
||||
const toast = useToast()
|
||||
const users = ref<string[]>([])
|
||||
const maps = ref<MapInfoAdmin[]>([])
|
||||
@@ -391,6 +392,15 @@ async function doRebuildZooms() {
|
||||
rebuilding.value = true
|
||||
try {
|
||||
await api.adminRebuildZooms()
|
||||
// Rebuild runs in background; poll status until done
|
||||
const poll = async (): Promise<void> => {
|
||||
const { running } = await api.adminRebuildZoomsStatus()
|
||||
if (running) {
|
||||
await new Promise((r) => setTimeout(r, 2000))
|
||||
return poll()
|
||||
}
|
||||
}
|
||||
await poll()
|
||||
markRebuildDone()
|
||||
showRebuildModal.value = false
|
||||
toast.success('Zooms rebuilt.')
|
||||
|
||||
Reference in New Issue
Block a user