opsdash-app/opsdash/composables/useDashboard.ts
2026-04-20 07:58:22 +07:00

372 lines
14 KiB
TypeScript

import { computed, nextTick, reactive, ref, type Ref } from 'vue'
import {
createDefaultTargetsConfig,
normalizeTargetsConfig,
type TargetsConfig,
} from '../src/services/targets'
import {
createDefaultReportingConfig,
createDefaultDeckSettings,
normalizeReportingConfig,
normalizeDeckSettings,
type ReportingConfig,
type DeckFeatureSettings,
} from '../src/services/reporting'
import { normalizeWidgetTabs, type WidgetTabsState } from '../src/services/widgetsRegistry'
import { createDefaultWidgetTabs, setWidgetPresets } from '../src/services/widgetDefaults'
import { readBootstrapThemePreference } from '../src/services/theme'
import { readCurrentUserId } from '../src/services/currentUser'
import { setUserDateTimeConfig } from '../src/services/dateTime'
export interface OnboardingState {
completed: boolean
version: number
strategy: string
completed_at: string
version_required?: number
needsOnboarding?: boolean
resetRequested?: boolean
dashboardMode?: 'quick' | 'standard' | 'pro'
releaseNotesSeenVersion?: string
}
interface DashboardDeps {
range: Ref<'week' | 'month'>
offset: Ref<number>
userChangedSelection: Ref<boolean>
route: (name: 'loadData') => string
getJson: (url: string, params: Record<string, unknown>) => Promise<any>
postJson: (url: string, body: Record<string, unknown>) => Promise<any>
notifyError: (message: string) => void
scheduleDraw: () => void
fetchNotes: () => Promise<void>
isDebug?: () => boolean
includeLookback?: () => boolean
widgetTabs?: Ref<WidgetTabsState>
onCoreLoaded?: (payload: any) => void
}
export function useDashboard(deps: DashboardDeps) {
const calendars = ref<any[]>([])
const colorsByName = ref<Record<string, string>>({})
const colorsById = ref<Record<string, string>>({})
const groupsById = ref<Record<string, number>>({})
const selected = ref<string[]>([])
const isLoading = ref(false)
const hasLoadedData = ref(false)
const isInitialLoading = computed(() => isLoading.value && !hasLoadedData.value)
const isRefreshing = computed(() => isLoading.value && hasLoadedData.value)
const isTruncated = ref(false)
const truncLimits = ref<any>(null)
const uid = ref('')
const from = ref('')
const to = ref('')
const stats = reactive<any>({})
const byCal = ref<any[]>([])
const byDay = ref<any[]>([])
const longest = ref<any[]>([])
const charts = ref<any>({})
const targetsWeek = ref<Record<string, number>>({})
const targetsMonth = ref<Record<string, number>>({})
const targetsConfig = ref<TargetsConfig>(normalizeTargetsConfig(createDefaultTargetsConfig()))
const onboarding = ref<OnboardingState | null>(null)
const bootstrapThemePreference = readBootstrapThemePreference() ?? 'auto'
const themePreference = ref<'auto' | 'light' | 'dark'>(bootstrapThemePreference)
const reportingConfig = ref<ReportingConfig>(createDefaultReportingConfig())
const deckSettings = ref<DeckFeatureSettings>(createDefaultDeckSettings())
let loadSeq = 0
async function load() {
const currentSeq = ++loadSeq
isLoading.value = true
const prevColorsById: Record<string, string> = { ...colorsById.value }
const prevColorsByName: Record<string, string> = { ...colorsByName.value }
let colorsAdjusted = false
try {
const debugEnabled = deps.isDebug?.() === true
const cloneColorMap = (input: any): Record<string, string> => {
const result: Record<string, string> = {}
if (input && typeof input === 'object') {
Object.entries(input).forEach(([key, value]) => {
if (typeof value === 'string' && value.trim() !== '') {
result[String(key)] = value
}
})
}
return result
}
const applyPaletteToCharts = () => {
let changed = false
try {
const pieData: any = charts.value?.pie || {}
const ids: string[] = Array.isArray(pieData.ids)
? pieData.ids.map((id: any) => String(id ?? ''))
: []
if (ids.length) {
const srvCols: string[] = Array.isArray(pieData.colors) ? pieData.colors : []
const newCols = ids.map(
(id, idx) => colorsById.value[id] || srvCols[idx] || '#60a5fa',
)
charts.value = { ...(charts.value || {}), pie: { ...pieData, colors: newCols } }
changed = true
}
} catch (error) {
if (deps.isDebug?.()) console.warn('recompute pie colors failed', error)
}
try {
const perDaySeries: any = charts.value?.perDaySeries
if (perDaySeries && Array.isArray(perDaySeries.series)) {
const updatedSeries = perDaySeries.series.map((series: any) => {
const id = String(series?.id ?? '')
const color = colorsById.value[id] || series.color || '#60a5fa'
return { ...series, color }
})
charts.value = {
...(charts.value || {}),
perDaySeries: { labels: perDaySeries.labels, series: updatedSeries },
}
changed = true
}
} catch (error) {
if (deps.isDebug?.()) console.warn('recompute stacked colors failed', error)
}
return changed
}
const applyCorePayload = (json: any) => {
const localUid = readCurrentUserId()
uid.value = localUid || String(json.meta?.uid ?? '')
from.value = String(json.meta?.from ?? '')
to.value = String(json.meta?.to ?? '')
isTruncated.value = Boolean(json.meta?.truncated)
truncLimits.value = json.meta?.limits ?? null
const settingsRaw = json.userSettings && typeof json.userSettings === 'object' ? json.userSettings : null
if (settingsRaw) {
const timezone = typeof settingsRaw.timezone === 'string' ? settingsRaw.timezone : ''
const locale = typeof settingsRaw.locale === 'string' ? settingsRaw.locale : ''
const firstDay = typeof settingsRaw.firstDayOfWeek === 'number' ? settingsRaw.firstDayOfWeek : undefined
setUserDateTimeConfig({
timeZone: timezone || undefined,
locale: locale || undefined,
firstDayOfWeek: firstDay,
})
}
const rawCalendars = Array.isArray(json.calendars) ? json.calendars : []
const nextColorsById = cloneColorMap(json.colors?.byId)
const nextColorsByName = cloneColorMap(json.colors?.byName)
const normalizedCalendars = rawCalendars.map((raw: any) => {
if (!raw || typeof raw !== 'object') {
return raw
}
const cal: Record<string, any> = { ...raw }
const id = String(cal.id ?? '')
const name = String(cal.displayname ?? cal.name ?? cal.calendar ?? id)
const originalColor = typeof cal.color === 'string' ? cal.color : ''
const originalSrc = typeof cal.color_src === 'string' ? cal.color_src : ''
const serverColor = id ? nextColorsById[id] : ''
const nameColor = name ? nextColorsByName[name] : ''
const previousColorById = id ? prevColorsById[id] : ''
const previousColorByName = name ? prevColorsByName[name] : ''
const previousColor = previousColorById || previousColorByName
let resolvedColor = originalColor
if (!resolvedColor && serverColor) {
resolvedColor = serverColor
}
if (!resolvedColor && nameColor) {
resolvedColor = nameColor
}
if ((!resolvedColor || originalSrc === 'fallback') && previousColor) {
resolvedColor = previousColor
}
if (!resolvedColor && previousColor) {
resolvedColor = previousColor
}
if (resolvedColor) {
if (resolvedColor !== originalColor) {
colorsAdjusted = true
}
cal.color = resolvedColor
if (id) nextColorsById[id] = resolvedColor
if (name) nextColorsByName[name] = resolvedColor
}
return cal
})
calendars.value = normalizedCalendars
colorsByName.value = nextColorsByName
colorsById.value = nextColorsById
// Some partial payloads may omit onboarding; keep existing state unless server explicitly sends it.
if (Object.prototype.hasOwnProperty.call(json, 'onboarding')) {
onboarding.value = json.onboarding && typeof json.onboarding === 'object'
? { ...json.onboarding }
: null
}
reportingConfig.value = normalizeReportingConfig(json.reportingConfig, reportingConfig.value)
deckSettings.value = normalizeDeckSettings(json.deckSettings, deckSettings.value)
if (json.widgetPresets) {
setWidgetPresets(json.widgetPresets)
}
if (deps.widgetTabs) {
const mode = json.onboarding?.dashboardMode
const fallback = deps.widgetTabs.value || createDefaultWidgetTabs(
mode === 'quick' || mode === 'pro' ? mode : 'standard',
)
deps.widgetTabs.value = normalizeWidgetTabs(json.widgets, fallback)
}
const groupSource = json.groups?.byId ?? json.groups?.byID ?? json.groups?.ids ?? {}
const mappedGroups: Record<string, number> = {}
calendars.value.forEach((cal: any) => {
const id = String(cal?.id ?? '')
const raw = Number(groupSource?.[id] ?? 0)
mappedGroups[id] = Number.isFinite(raw) ? Math.max(0, Math.min(9, Math.trunc(raw))) : 0
})
groupsById.value = mappedGroups
const tw = json.targets?.week ?? {}
const tm = json.targets?.month ?? {}
targetsWeek.value = tw && typeof tw === 'object' ? tw : {}
targetsMonth.value = tm && typeof tm === 'object' ? tm : {}
targetsConfig.value = normalizeTargetsConfig(json.targetsConfig ?? createDefaultTargetsConfig())
const themeRaw = typeof json.themePreference === 'string' ? json.themePreference : ''
themePreference.value = themeRaw === 'light' || themeRaw === 'dark' ? (themeRaw as 'light' | 'dark') : 'auto'
if (deps.isDebug?.()) {
console.group('[opsdash] calendars/colors')
console.table((calendars.value || []).map((c: any) => ({
id: c.id,
name: c.displayname,
color: c.color,
raw: (c as any).color_raw,
src: (c as any).color_src,
})))
console.log('colors.byId', colorsById.value)
console.log('colors.byName', colorsByName.value)
if ((json as any).calDebug) console.log('server calDebug', (json as any).calDebug)
if ((json as any).debug) {
console.group('[opsdash] server query debug')
console.log((json as any).debug)
console.groupEnd()
}
console.groupEnd()
}
// Normalize to string IDs to keep UI selection reactive and consistent
selected.value = Array.isArray(json.selected)
? (json.selected as any[]).map((id) => String(id))
: Array.isArray(json.saved)
? (json.saved as any[]).map((id) => String(id))
: []
deps.userChangedSelection.value = false
if (colorsAdjusted) {
applyPaletteToCharts()
}
}
const applyDataPayload = async (json: any) => {
from.value = String(json.meta?.from ?? from.value)
to.value = String(json.meta?.to ?? to.value)
isTruncated.value = Boolean(json.meta?.truncated)
truncLimits.value = json.meta?.limits ?? null
Object.assign(stats, json.stats ?? {})
byCal.value = Array.isArray(json.byCal) ? json.byCal : []
byDay.value = Array.isArray(json.byDay) ? json.byDay : []
longest.value = Array.isArray(json.longest) ? json.longest : []
charts.value = json.charts ?? {}
hasLoadedData.value = true
if (colorsAdjusted) {
applyPaletteToCharts()
}
await nextTick()
deps.scheduleDraw()
// Notes should not block dashboard readiness.
void deps.fetchNotes().catch(() => {})
}
const coreParams: Record<string, unknown> = {
range: deps.range.value,
offset: deps.offset.value | 0,
include: ['core'],
}
if (debugEnabled) {
coreParams.include = ['core', 'debug']
coreParams.debug = true
}
const coreJson: any = await deps.getJson(deps.route('loadData'), coreParams)
if (currentSeq !== loadSeq) {
if (deps.isDebug?.()) console.warn('discarding stale core response', { currentSeq, loadSeq })
return
}
applyCorePayload(coreJson)
deps.onCoreLoaded?.(coreJson)
const includeLookback = deps.includeLookback?.() === true
const dataInclude = ['data']
if (includeLookback) dataInclude.push('lookback')
if (debugEnabled) dataInclude.push('debug')
const dataParams: Record<string, unknown> = {
range: deps.range.value,
offset: deps.offset.value | 0,
include: dataInclude,
}
if (debugEnabled) {
dataParams.debug = true
}
const dataJson: any = await deps.postJson(deps.route('loadData'), dataParams)
if (currentSeq !== loadSeq) {
if (deps.isDebug?.()) console.warn('discarding stale data response', { currentSeq, loadSeq })
return
}
await applyDataPayload(dataJson)
} catch (error) {
console.error(error)
deps.notifyError('Failed to load data')
} finally {
isLoading.value = false
}
}
return {
calendars,
colorsByName,
colorsById,
groupsById,
selected,
isLoading,
isInitialLoading,
isRefreshing,
isTruncated,
truncLimits,
uid,
from,
to,
stats,
byCal,
byDay,
longest,
charts,
targetsWeek,
targetsMonth,
targetsConfig,
onboarding,
load,
themePreference,
reportingConfig,
deckSettings,
}
}