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:
2026-03-04 11:59:28 +03:00
parent a3a4c0e896
commit dda35baeca
17 changed files with 396 additions and 73 deletions

View File

@@ -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()

View File

@@ -9,6 +9,7 @@
:src="useGravatarUrl(email, size)"
alt=""
class="w-full h-full object-cover"
loading="lazy"
@error="gravatarError = true"
>
<div

View File

@@ -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)

View File

@@ -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 6480)
@@ -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
}

View File

@@ -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,

View File

@@ -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.')