commit 83a975ba3cbb686723b536e06afc2bb2387b0779 Author: Александр "axel_verse" Мудров Date: Sun Sep 6 04:04:59 2026 +0300 Design diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..56c6038 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Auto-generated type declarations +auto-imports.d.ts +components.d.ts diff --git a/.prettierrc.yml b/.prettierrc.yml new file mode 100644 index 0000000..f29c55a --- /dev/null +++ b/.prettierrc.yml @@ -0,0 +1,4 @@ +semi: false +tabWidth: 2 +trailingComma: all +singleQuote: true \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..1511959 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Vue 3 + Vite + +This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` + + \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..f6a3c15 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "pgtune", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.41" + }, + "devDependencies": { + "@iconify-json/lucide": "^1.2.129", + "@iconify-json/simple-icons": "^1.2.94", + "@nuxt/ui": "^4.11.0", + "@tailwindcss/typography": "^0.5.20", + "@tailwindcss/vite": "^4.3.3", + "@unhead/vue": "^3.0.0-beta.9", + "@vitejs/plugin-vue": "^6.0.8", + "@vueuse/core": "^14.4.0", + "eslint": "^10.9.1", + "eslint-config-prettier": "^10.1.8", + "eslint-config-vue": "^2.0.2", + "eslint-plugin-prettier": "^5.5.6", + "eslint-plugin-vue": "^10.10.0", + "pinia": "^4.0.3", + "prettier": "^3.9.6", + "tailwindcss": "^4.3.3", + "vite": "^8.2.2", + "vite-plugin-pwa": "^1.3.0", + "vue-router": "^5.3.1", + "yup": "^1.7.1" + } +} diff --git a/pgtune.tar.zst b/pgtune.tar.zst new file mode 100644 index 0000000..9e2fb00 Binary files /dev/null and b/pgtune.tar.zst differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..862672e Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..fe1612c Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..b29ef2f --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/public/humans.txt b/public/humans.txt new file mode 100644 index 0000000..15d3968 --- /dev/null +++ b/public/humans.txt @@ -0,0 +1,15 @@ +# humanstxt.org/ +# The humans responsible & technology colophon + +/* TEAM */ + Developer: Oleksii Vasyliev + Twitter: @leopard_me + + Web designer: Anna Rotar + Twitter: @Ste4any + +/* SITE */ + Language: English + Standards: HTML5, CSS3 + Doctype: HTML5 + Source: https://github.com/le0pard/pgtune diff --git a/public/icon-192x192.png b/public/icon-192x192.png new file mode 100644 index 0000000..94b1412 Binary files /dev/null and b/public/icon-192x192.png differ diff --git a/public/icon-512x512.png b/public/icon-512x512.png new file mode 100644 index 0000000..1f6bce3 Binary files /dev/null and b/public/icon-512x512.png differ diff --git a/public/icons.svg b/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/maskable_icon.png b/public/maskable_icon.png new file mode 100644 index 0000000..2c5c1d4 Binary files /dev/null and b/public/maskable_icon.png differ diff --git a/public/pgtune.svg b/public/pgtune.svg new file mode 100644 index 0000000..b29ef2f --- /dev/null +++ b/public/pgtune.svg @@ -0,0 +1 @@ + diff --git a/public/pgtune_dark.svg b/public/pgtune_dark.svg new file mode 100644 index 0000000..70dfe1c --- /dev/null +++ b/public/pgtune_dark.svg @@ -0,0 +1 @@ + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..c2a49f4 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/router.js b/router.js new file mode 100644 index 0000000..608d957 --- /dev/null +++ b/router.js @@ -0,0 +1,19 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const routes = [ + { + path: '/', + component: () => import('/src/pages/index.vue'), + name: 'Index', + }, + { + path: '/about', + component: () => import('/src/pages/about.vue'), + name: 'About', + }, +] + +export const router = createRouter({ + history: createWebHistory(), + routes, +}) diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..b4dfea3 --- /dev/null +++ b/src/App.vue @@ -0,0 +1,29 @@ + + + diff --git a/src/assets/images/logo.svg b/src/assets/images/logo.svg new file mode 100644 index 0000000..c45b7da --- /dev/null +++ b/src/assets/images/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/pgtune.svg b/src/assets/images/pgtune.svg new file mode 100644 index 0000000..cc82217 --- /dev/null +++ b/src/assets/images/pgtune.svg @@ -0,0 +1 @@ + diff --git a/src/assets/styles/app.css b/src/assets/styles/app.css new file mode 100644 index 0000000..9d5ec27 --- /dev/null +++ b/src/assets/styles/app.css @@ -0,0 +1,4 @@ +@import "tailwindcss"; +@import "@nuxt/ui"; + +@plugin "@tailwindcss/typography"; \ No newline at end of file diff --git a/src/components/configurationForm.vue b/src/components/configurationForm.vue new file mode 100644 index 0000000..346376d --- /dev/null +++ b/src/components/configurationForm.vue @@ -0,0 +1,104 @@ + + + diff --git a/src/components/defaultDescription.vue b/src/components/defaultDescription.vue new file mode 100644 index 0000000..5361bbe --- /dev/null +++ b/src/components/defaultDescription.vue @@ -0,0 +1,54 @@ + diff --git a/src/components/header.vue b/src/components/header.vue new file mode 100644 index 0000000..86e5e8d --- /dev/null +++ b/src/components/header.vue @@ -0,0 +1,49 @@ + + + diff --git a/src/components/totalMemoryInput.vue b/src/components/totalMemoryInput.vue new file mode 100644 index 0000000..6372a5b --- /dev/null +++ b/src/components/totalMemoryInput.vue @@ -0,0 +1,3 @@ + diff --git a/src/composables/useThemeColor.js b/src/composables/useThemeColor.js new file mode 100644 index 0000000..9fc2920 --- /dev/null +++ b/src/composables/useThemeColor.js @@ -0,0 +1,38 @@ +import { useHead } from '@unhead/vue' +import { ref, watch } from 'vue' +import { useDark } from '@vueuse/core' +import { convertOklchToHex } from '../utils/oklch2hex' + +export function useThemeColor() { + const isDark = useDark() + const currentBgColor = ref() + + const getBGColor = () => { + const rootStyles = window.getComputedStyle(document.documentElement) + // Получаем текущее значение переменной --ui-bg + const c = rootStyles.getPropertyValue('--ui-bg').trim() + currentBgColor.value = c.startsWith('oklch') ? convertOklchToHex(c) : c + } + + const head = useHead() + + watch( + isDark, + (n, o) => { + if (n !== o) { + setTimeout(() => { + getBGColor() + head.patch({ + meta: [ + { + name: 'theme-color', + content: currentBgColor.value, + }, + ], + }) + }, 50) + } + }, + { immediate: true }, + ) +} diff --git a/src/composables/useUpdateApp.js b/src/composables/useUpdateApp.js new file mode 100644 index 0000000..571924c --- /dev/null +++ b/src/composables/useUpdateApp.js @@ -0,0 +1,57 @@ +import { useRegisterSW } from 'virtual:pwa-register/vue' +import { watch } from 'vue' + +const intervalMS = import.meta.env.DEV ? 5 * 60 * 1000 : 60 * 60 * 1000 + +export function useUpdateApp() { + const { needRefresh, updateServiceWorker } = useRegisterSW({ + onRegistered(r) { + r && + setInterval(() => { + r.update() + }, intervalMS) + }, + }) + const toast = useToast() + + if (import.meta.env.DEV) console.log({ SWNeedRefresh: needRefresh.value }) + + async function close(e) { + needRefresh.value = false + e?.stopPropagation() + } + + // New version + + watch( + () => needRefresh, + (n) => { + if (n) { + toast.add({ + title: 'Update', + description: + 'A newer version of the app is available. Click the “Update” button to install.', + duration: 0, + icon: 'lucide:cloud-download', + close: false, + actions: [ + { + icon: 'lucide:arrow-big-up-dash', + label: 'Update', + onClick: updateServiceWorker, + color: 'error', + }, + + { + icon: 'lucide:x', + label: 'Dismiss', + onClick: close, + variant: 'outline', + }, + ], + onEscapeKeyDown: close, + }) + } + }, + ) +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..81a87fe --- /dev/null +++ b/src/main.js @@ -0,0 +1,14 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import '/src/assets/styles/app.css' +import App from './App.vue' +import { router } from '/router.js' +import ui from '@nuxt/ui/vue-plugin' +import { createHead } from '@unhead/vue/client' + +createApp(App) + .use(router) + .use(createPinia()) + .use(ui) + .use(createHead()) + .mount('#app') diff --git a/src/pages/about.vue b/src/pages/about.vue new file mode 100644 index 0000000..7c16d1d --- /dev/null +++ b/src/pages/about.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/pages/index.vue b/src/pages/index.vue new file mode 100644 index 0000000..ea03bca --- /dev/null +++ b/src/pages/index.vue @@ -0,0 +1,14 @@ + + + diff --git a/src/stores/useConfigurationStore.js b/src/stores/useConfigurationStore.js new file mode 100644 index 0000000..1a73645 --- /dev/null +++ b/src/stores/useConfigurationStore.js @@ -0,0 +1,479 @@ +import { defineStore } from 'pinia' +import { reactive, computed } from 'vue' +import { useSettingsStore } from './useSettingsStore' +import { + DEFAULT_DB_VERSION, + OS_LINUX, + OS_WINDOWS, + OS_MAC, + DB_TYPE_WEB, + DB_TYPE_OLTP, + DB_TYPE_DW, + DB_TYPE_DESKTOP, + DB_TYPE_MIXED, + SIZE_UNIT_GB, + HARD_DRIVE_SSD, + HARD_DRIVE_HDD, + HARD_DRIVE_SAN, + HARD_DRIVE_NVME, + DB_SIZE_LESS_RAM, + DB_SIZE_MID_RAM, + DB_SIZE_GREATER_RAM, +} from '/src/utils/constants/configuration.js' + +const SIZE_UNIT_MAP = { + KB: 1024, + MB: 1048576, + GB: 1073741824, + TB: 1099511627776, + PB: 1125899906842624, +} + +const DEFAULT_DB_SETTINGS = { + default: { + ['max_worker_processes']: 8, + ['max_parallel_workers_per_gather']: 2, + ['max_parallel_workers']: 8, + }, +} + +const initialState = { + dbVersion: DEFAULT_DB_VERSION, + osType: OS_LINUX, + dbType: DB_TYPE_WEB, + totalMemory: null, + totalMemoryUnit: SIZE_UNIT_GB, + cpuNum: null, + connectionNum: null, + hdType: HARD_DRIVE_SSD, + dbSize: DB_SIZE_MID_RAM, +} + +const selectCheckpointCompletionTarget = 0.9 + +export const useConfigurationStore = defineStore('configuration', () => { + const state = reactive({ ...initialState }) + + const getConfiguration = computed(() => ({ ...state })) + const getDBVersion = computed(() => getConfiguration.value.dbVersion) + const getOSType = computed(() => getConfiguration.value.osType) + const getDBType = computed(() => getConfiguration.value.dbType) + const getTotalMemory = computed(() => getConfiguration.value.totalMemory) + const getTotalMemoryUnit = computed( + () => getConfiguration.value.totalMemoryUnit, + ) + const getCPUNum = computed(() => getConfiguration.value.cpuNum || null) + const getConnectionNum = computed( + () => getConfiguration.value.connectionNum || null, + ) + const getHDType = computed(() => getConfiguration.value.hdType) + const getDBSize = computed(() => getConfiguration.value.dbSize) + const getTotalMemoryInBytes = computed( + () => getTotalMemory.value * SIZE_UNIT_MAP[getTotalMemoryUnit.value], + ) + const getTotalMemoryInKb = computed( + () => getTotalMemoryInBytes.value / SIZE_UNIT_MAP['KB'], + ) + const getDBDefaultValues = computed( + () => DEFAULT_DB_SETTINGS[getDBVersion] || DEFAULT_DB_VERSION.default, + ) + const getIsConfigured = computed(() => !!getTotalMemory.value) + const getMaxConnections = computed( + () => + getConnectionNum.value ?? + { + [DB_TYPE_WEB]: 200, + [DB_TYPE_OLTP]: 300, + [DB_TYPE_DW]: 40, + [DB_TYPE_DESKTOP]: 20, + [DB_TYPE_MIXED]: 100, + }[getDBType.value], + ) + const getSharedBuffers = computed(() => { + const totalMemoryKb = getTotalMemoryInKb.value + + const val = { + [DB_TYPE_WEB]: Math.floor(totalMemoryKb / 4), + [DB_TYPE_OLTP]: Math.floor(totalMemoryKb / 4), + [DB_TYPE_DW]: Math.floor(totalMemoryKb / 4), + [DB_TYPE_DESKTOP]: Math.floor(totalMemoryKb / 16), + [DB_TYPE_MIXED]: Math.floor(totalMemoryKb / 4), + }[getDBType.value] + + return getDBVersion.value < 10 && OS_WINDOWS === getOSType.value + ? Math.min(val, (512 * SIZE_UNIT_MAP['MB']) / SIZE_UNIT_MAP['KB']) + : val + }) + const getHugePages = computed(() => + getOSType.value !== OS_MAC && + getSharedBuffers.value >= (2 * SIZE_UNIT_MAP['GB']) / SIZE_UNIT_MAP['KB'] + ? 'try' + : 'off', + ) + const getEffectiveCacheSize = computed(() => { + const totalMemoryKb = getTotalMemoryInKb.value + + return { + [DB_TYPE_WEB]: Math.floor((totalMemoryKb * 3) / 4), + [DB_TYPE_OLTP]: Math.floor((totalMemoryKb * 3) / 4), + [DB_TYPE_DW]: Math.floor((totalMemoryKb * 3) / 4), + [DB_TYPE_DESKTOP]: Math.floor(totalMemoryKb / 4), + [DB_TYPE_MIXED]: Math.floor((totalMemoryKb * 3) / 4), + }[getDBType.value] + }) + const getMaintenanceWorkMem = computed(() => { + const totalMemoryKb = getTotalMemoryInKb.value + // 1. Вычисляем базовое значение по типу БД + const baseMemValue = { + [DB_TYPE_WEB]: Math.floor(totalMemoryKb / 16), + [DB_TYPE_OLTP]: Math.floor(totalMemoryKb / 16), + [DB_TYPE_DW]: Math.floor(totalMemoryKb / 8), + [DB_TYPE_DESKTOP]: Math.floor(totalMemoryKb / 16), + [DB_TYPE_MIXED]: Math.floor(totalMemoryKb / 16), + }[getDBType.value] + + // 2. Определяем, действует ли строгое ограничение для Windows + const isLegacyWindows = + OS_WINDOWS === getOSType.value && getDBVersion.value <= 17 + + // 3. Задаем лимит памяти (2GB для старых Windows, иначе 8GB) + const maxLimitGb = isLegacyWindows ? 2 : 8 + const memoryLimit = (maxLimitGb * SIZE_UNIT_MAP['GB']) / SIZE_UNIT_MAP['KB'] + + // 4. Если превышен лимит на Windows, вычитаем 1MB, иначе просто возвращаем минимум + return baseMemValue >= memoryLimit && isLegacyWindows + ? memoryLimit - (1 * SIZE_UNIT_MAP['MB']) / SIZE_UNIT_MAP['KB'] + : Math.min(baseMemValue, memoryLimit) + }) + const getCheckpointSegment = computed(() => [ + { + key: 'min_wal_size', + value: { + [DB_TYPE_WEB]: (1024 * SIZE_UNIT_MAP['MB']) / SIZE_UNIT_MAP['KB'], + [DB_TYPE_OLTP]: (2048 * SIZE_UNIT_MAP['MB']) / SIZE_UNIT_MAP['KB'], + [DB_TYPE_DW]: (4096 * SIZE_UNIT_MAP['MB']) / SIZE_UNIT_MAP['KB'], + [DB_TYPE_DESKTOP]: (100 * SIZE_UNIT_MAP['MB']) / SIZE_UNIT_MAP['KB'], + [DB_TYPE_MIXED]: (1024 * SIZE_UNIT_MAP['MB']) / SIZE_UNIT_MAP['KB'], + }[getDBType.value], + }, + { + key: 'max_wal_size', + value: { + [DB_TYPE_WEB]: (4096 * SIZE_UNIT_MAP['MB']) / SIZE_UNIT_MAP['KB'], + [DB_TYPE_OLTP]: (8192 * SIZE_UNIT_MAP['MB']) / SIZE_UNIT_MAP['KB'], + [DB_TYPE_DW]: (16384 * SIZE_UNIT_MAP['MB']) / SIZE_UNIT_MAP['KB'], + [DB_TYPE_DESKTOP]: (2048 * SIZE_UNIT_MAP['MB']) / SIZE_UNIT_MAP['KB'], + [DB_TYPE_MIXED]: (4096 * SIZE_UNIT_MAP['MB']) / SIZE_UNIT_MAP['KB'], + }[getDBType.value], + }, + ]) + const getWalBuffers = computed(() => { + const KB_IN_MB = SIZE_UNIT_MAP['MB'] / SIZE_UNIT_MAP['KB'] + const baseWal = Math.floor((3 * sharedBuffersValue) / 100) + + return Math.max( + 32, + baseWal > 14 * KB_IN_MB + ? 16 * KB_IN_MB + : Math.min(baseWal, 16 * KB_IN_MB), + ) + }) + const getDefaultStatisticsTarget = computed( + () => + ({ + [DB_TYPE_WEB]: 100, + [DB_TYPE_OLTP]: 100, + [DB_TYPE_DW]: 500, + [DB_TYPE_DESKTOP]: 100, + [DB_TYPE_MIXED]: 100, + })[getDBType.value], + ) + const getRandomPageCost = computed(() => { + // Если база помещается в RAM, поиск на диске не важен + if (getDBSize.value === DB_SIZE_LESS_RAM) return 1.1 + + // HDD или аналитические БД (DW), не помещающиеся в RAM, требуют дефолтной стоимости (4.0) + if (getHDType.value === HARD_DRIVE_HDD || getDBType.value === DB_TYPE_DW) + return 4 + + return 1.1 + }) + const getEffectiveIOConcurrency = computed(() => + getOSType.value === OS_LINUX + ? { + [HARD_DRIVE_HDD]: 2, + [HARD_DRIVE_SSD]: 200, + [HARD_DRIVE_SAN]: 300, + [HARD_DRIVE_NVME]: 1000, + }[getHDType.value] + : null, + ) + const getParallelSettings = computed(() => { + const cpuNum = getCPUNum.value + const dbVersion = getDBVersion.value + const dbType = getDBType.value + + if (!cpuNum || cpuNum < 4) return [] + + const halfCpu = Math.ceil(cpuNum / 2) + const cappedWorkers = Math.min(4, halfCpu) // Ограничение в 4 воркера + + const config = [ + { key: 'max_worker_processes', value: cpuNum }, + { + key: 'max_parallel_workers_per_gather', + value: dbType !== DB_TYPE_DW ? cappedWorkers : halfCpu, + }, + ...(dbVersion >= 10 + ? [{ key: 'max_parallel_workers', value: cpuNum }] + : []), + ...(dbVersion >= 11 + ? [{ key: 'max_parallel_maintenance_workers', value: cappedWorkers }] + : []), + ] + + return config + }) + const getWorkMem = computed(() => { + const dbDefaultValues = getDBDefaultValues.value + + const maxWorkerProcesses = getParallelSettings.value.find( + (param) => param['key'] === 'max_worker_processes', + ) + + const parallelForWorkMem = + maxWorkerProcesses?.value > 0 + ? maxWorkerProcesses.value + : dbDefaultValues['max_worker_processes'] > 0 + ? dbDefaultValues['max_worker_processes'] + : 1 + + // Базовая формула расчёта work_mem + const baseWorkMem = + (getTotalMemoryInKb.value - sharedBuffersValue) / + ((maxConnectionsValue + parallelForWorkMem) * 3) + + // Коэффициенты для разных типов БД + const dbTypeCoefficients = { + [DB_TYPE_WEB]: 1, + [DB_TYPE_OLTP]: 1, + [DB_TYPE_DW]: 0.5, + [DB_TYPE_DESKTOP]: 1 / 6, + [DB_TYPE_MIXED]: 0.5, + } + + // Базовая формула с коэффициентом типа БД + const baseWorkMemResult = Math.floor( + baseWorkMem * dbTypeCoefficients[dbType], + ) + + // Корректировка в зависимости от размера БД относительно RAM + const sizeAdjustedWorkMem = + dbSize === DB_SIZE_LESS_RAM + ? Math.floor(baseWorkMemResult * 1.3) + : dbSize === DB_SIZE_GREATER_RAM + ? Math.floor(baseWorkMemResult * 0.9) + : baseWorkMemResult + + // Применяем минимальное значение 4 MB + const minWorkMem = 4 * 1024 // 4 MB в KB + const workMemWithMin = Math.max(sizeAdjustedWorkMem, minWorkMem) + + // Применяем ограничение для Windows 64-bit до PostgreSQL 17 + const winMemoryLimit = 2 * 1024 * 1024 - 1024 // ~2GB - 1MB в KB + const workMemResult = + osType === OS_WINDOWS && dbVersion <= 17 + ? Math.min(workMemWithMin, winMemoryLimit) + : workMemWithMin + + return workMemResult + }) + const getWalLevel = computed(() => + getDBType.value === DB_TYPE_DESKTOP + ? [ + { + key: 'wal_level', + value: 'minimal', + }, + // max_wal_senders must be 0 when wal_level=minimal + { + key: 'max_wal_senders', + value: '0', + }, + ] + : [], + ) + const getJit = computed(() => + getDBVersion.value >= 12 && + [DB_TYPE_WEB, DB_TYPE_OLTP, DB_TYPE_MIXED].includes(getDBType.value) + ? 'off' + : null, + ) + const getWalCompression = computed(() => { + const ver = getDBVersion.value + + return ver >= 15 ? 'lz4' : ver >= 10 ? 'on' : null + }) + const getAutovacuumMaxWorkers = computed(() => { + const cpuNum = getCPUNum.value + + if (!cpuNum) return null + if (cpuNum >= 32) return 5 + if (cpuNum >= 16) return 4 + return null + }) + const getAutovacuumWorkMem = computed(() => { + const threshold = (2 * SIZE_UNIT_MAP['GB']) / SIZE_UNIT_MAP['KB'] + + // Windows 64-bit has a strict 2GB limit up to PostgreSQL 17 + const winMemoryLimit = + (2 * SIZE_UNIT_MAP['GB']) / SIZE_UNIT_MAP['KB'] - + (1 * SIZE_UNIT_MAP['MB']) / SIZE_UNIT_MAP['KB'] + + const isWindowsWithLowLimit = + getOSType.value === OS_WINDOWS && getDBVersion.value <= 17 + + if (getMaintenanceWorkMem.value < threshold) { + return null + } + + if (isWindowsWithLowLimit) { + return Math.min(threshold, winMemoryLimit) + } + + return threshold + }) + const getIOMethod = computed(() => + getDBVersion.value < 18 + ? null + : getOSType.value === OS_LINUX + ? 'io_uring' + : 'worker', + ) + const getIOWorkers = computed(() => { + if (dbVersion < 18 || !cpuNum || ioMethod === 'io_uring') return null + + return Math.max(4, Math.min(32, Math.floor(cpuNum / 4))) || null + }) + const getWarningInfoMessages = computed(() => { + const warnings = [] + const totalMemory = getTotalMemoryInBytes.value + + // Memory warnings + if (totalMemory < 256 * SIZE_UNIT_MAP['MB']) { + warnings.push('this tool not being optimal', 'for low memory systems') + } else if (totalMemory > 100 * SIZE_UNIT_MAP['GB']) { + warnings.push( + 'this tool not being optimal', + 'for very high memory systems', + ) + } + + // Advanced features compilation warnings + if (getWalCompression.value === 'lz4') { + if (warnings.length > 0) warnings.push('') + + warnings.push( + 'wal_compression = lz4 requires PostgreSQL', + 'to be compiled with --with-lz4', + ) + } + + if (getIOMethod.value === 'io_uring') { + if (warnings.length > 0) warnings.push('') + + warnings.push( + 'io_method = io_uring requires PostgreSQL', + 'to be compiled with --with-liburing', + ) + } + + // I/O Cost Warning for Analytical DBs + if ( + getDBType.value === DB_TYPE_DW && + getHDType.value !== HARD_DRIVE_HDD && + getDBSize.value !== DB_SIZE_LESS_RAM + ) { + const driveName = { + [HARD_DRIVE_SSD]: 'SSDs', + [HARD_DRIVE_NVME]: 'NVMe drives', + [HARD_DRIVE_SAN]: 'SAN storage', + }[hdType] + + if (warnings.length > 0) warnings.push('') + + warnings.push( + `Cost parameters for Data Warehouses on ${driveName} are left at defaults`, + 'to avoid catastrophic index scan selections', + 'Monitor query planner behavior and adjust random_page_cost if necessary', + ) + } + + return warnings.length > 0 ? ['WARNING', ...warnings] : [] + }) + + function setState(payload) { + const settingsStore = useSettingsStore() + + state.value = { + dbVersion: parseFloat(payload.dbVersion), + osType: payload.osType, + dbType: payload.dbType, + totalMemory: parseInt(payload.totalMemory, 10), + totalMemoryUnit: payload.totalMemoryUnit, + cpuNum: payload.cpuNum ? parseInt(payload.cpuNum, 10) : null, + connectionNum: payload.connectionNum + ? parseInt(payload.connectionNum, 10) + : null, + hdType: payload.hdType, + dbSize: payload.dbSize, + } + + settingsStore.onSubmitConfiguration() + } + + const $reset = () => ({ ...initialState }) + + return { + state, + + // Getters + getDBVersion, + getOSType, + getDBType, + getTotalMemory, + getTotalMemoryUnit, + getCPUNum, + getConnectionNum, + getHDType, + getDBSize, + getTotalMemoryInBytes, + getTotalMemoryInKb, + getDBDefaultValues, + getIsConfigured, + getMaxConnections, + getSharedBuffers, + getHugePages, + getEffectiveCacheSize, + getMaintenanceWorkMem, + getCheckpointSegment, + getWalBuffers, + getDefaultStatisticsTarget, + getRandomPageCost, + getEffectiveIOConcurrency, + getParallelSettings, + getWorkMem, + getWalLevel, + getJit, + getWalCompression, + getAutovacuumMaxWorkers, + getAutovacuumWorkMem, + getIOMethod, + getIOWorkers, + getWarningInfoMessages, + + // Actions + setState, + $reset, + } +}) diff --git a/src/stores/useSettingsStore.js b/src/stores/useSettingsStore.js new file mode 100644 index 0000000..4abfa2d --- /dev/null +++ b/src/stores/useSettingsStore.js @@ -0,0 +1,42 @@ +import { defineStore } from 'pinia' +import { reactive, computed } from 'vue' + +import { TAB_CONFIG, APP_THEMES_LIGHT } from '/src/utils/constants/settings.js' + +const initialState = { + tabState: TAB_CONFIG, + theme: APP_THEMES_LIGHT, +} + +export const useSettingsStore = defineStore('settings', () => { + const state = reactive({ ...initialState }) + + const getSettings = computed(() => state) + const getThemeSettings = computed(() => state.theme) + const getTabSettings = computed(() => state.tabState) + + function toggleTheme() { + state.theme = + APP_THEMES_LIGHT === state.theme ? APP_THEMES_DARK : APP_THEMES_LIGHT + } + function onSubmitConfiguration() { + state.tabState = TAB_CONFIG + } + function $reset() { + state = { ...initialState } + } + return { + // STATE + state, + + // GETTERS + getSettings, + getThemeSettings, + getTabSettings, + toggleTheme, + + // ACTIONS + onSubmitConfiguration, + $reset, + } +}) diff --git a/src/stores/useSwStore.js b/src/stores/useSwStore.js new file mode 100644 index 0000000..ce41ae5 --- /dev/null +++ b/src/stores/useSwStore.js @@ -0,0 +1,8 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useConfigurationStore = defineStore('sw', () => { + const state = ref() + + return { state } +}) diff --git a/src/utils/constants/configuration.js b/src/utils/constants/configuration.js new file mode 100644 index 0000000..a224e55 --- /dev/null +++ b/src/utils/constants/configuration.js @@ -0,0 +1,28 @@ +// postgresql versions +export const DEFAULT_DB_VERSION = 18 +export const DB_VERSIONS = [DEFAULT_DB_VERSION, 17, 16, 15, 14, 13, 12, 11, 10] +// os types +export const OS_LINUX = 'linux' +export const OS_WINDOWS = 'windows' +export const OS_MAC = 'mac' +// db types +export const DB_TYPE_WEB = 'web' +export const DB_TYPE_OLTP = 'oltp' +export const DB_TYPE_DW = 'dw' +export const DB_TYPE_DESKTOP = 'desktop' +export const DB_TYPE_MIXED = 'mixed' +// size units +export const SIZE_UNIT_MB = 'MB' +export const SIZE_UNIT_GB = 'GB' +export const SIZE_UNIT_TB = 'TB' +// harddrive types +export const HARD_DRIVE_SSD = 'ssd' +export const HARD_DRIVE_SAN = 'san' +export const HARD_DRIVE_HDD = 'hdd' +export const HARD_DRIVE_NVME = 'nvme' +// database size vs ram +export const DB_SIZE_LESS_RAM = 'less_ram' +export const DB_SIZE_MID_RAM = 'mid_ram' +export const DB_SIZE_GREATER_RAM = 'greater_ram' +// maximum value for integer fields +export const MAX_NUMERIC_VALUE = 999999 diff --git a/src/utils/constants/settings.js b/src/utils/constants/settings.js new file mode 100644 index 0000000..3307077 --- /dev/null +++ b/src/utils/constants/settings.js @@ -0,0 +1,5 @@ +export const APP_THEMES_LIGHT = 'light' +export const APP_THEMES_DARK = 'dark' +// tabs +export const TAB_CONFIG = 'config_tab' +export const TAB_ALTER_SYSTEM = 'alter_tab' diff --git a/src/utils/oklch2hex.js b/src/utils/oklch2hex.js new file mode 100644 index 0000000..b20a4c6 --- /dev/null +++ b/src/utils/oklch2hex.js @@ -0,0 +1,24 @@ +export function convertOklchToHex(oklchString) { + // 1. Создаем виртуальный пиксель + const canvas = document.createElement('canvas') + canvas.width = 1 + canvas.height = 1 + const ctx = canvas.getContext('2d', { willReadFrequently: true }) + if (!ctx) return '' + + // 2. Закрашиваем пиксель цветом oklch + ctx.fillStyle = oklchString + ctx.fillRect(0, 0, 1, 1) + + // 3. Получаем массив [r, g, b, a] со значениями от 0 до 255 + const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data + + // Вспомогательная функция для перевода числа в 16-ричный формат (например, 255 -> "ff") + const toHex = (num) => num.toString(16).padStart(2, '0') + + // 4. Собираем HEX-строку + const hexRGB = `#${toHex(r)}${toHex(g)}${toHex(b)}` + + // Если есть прозрачность (альфа-канал меньше 255), добавляем её в конец HEX + return a === 255 ? hexRGB : `${hexRGB}${toHex(a)}` +} diff --git a/src/utils/validation.js b/src/utils/validation.js new file mode 100644 index 0000000..1902c79 --- /dev/null +++ b/src/utils/validation.js @@ -0,0 +1,99 @@ +import * as Yup from 'yup' +import { + DB_VERSIONS, + OS_LINUX, + OS_WINDOWS, + OS_MAC, + DB_TYPE_WEB, + DB_TYPE_OLTP, + DB_TYPE_DW, + DB_TYPE_DESKTOP, + DB_TYPE_MIXED, + SIZE_UNIT_MB, + SIZE_UNIT_GB, + SIZE_UNIT_TB, + HARD_DRIVE_HDD, + HARD_DRIVE_SSD, + HARD_DRIVE_SAN, + HARD_DRIVE_NVME, + MAX_NUMERIC_VALUE, + DB_SIZE_LESS_RAM, + DB_SIZE_MID_RAM, + DB_SIZE_GREATER_RAM, +} from './constants/configuration' + +const MIN_MB_MEMORY = 512 + +const DB_TYPES = [ + DB_TYPE_WEB, + DB_TYPE_OLTP, + DB_TYPE_DW, + DB_TYPE_DESKTOP, + DB_TYPE_MIXED, +] + +const HARD_DRIVE_TYPES = [ + HARD_DRIVE_HDD, + HARD_DRIVE_SSD, + HARD_DRIVE_SAN, + HARD_DRIVE_NVME, +] + +const DB_SIZES = [DB_SIZE_LESS_RAM, DB_SIZE_MID_RAM, DB_SIZE_GREATER_RAM] + +export const validationSchema = Yup.object().shape({ + dbVersion: Yup.number() + .required('Required') + .oneOf(DB_VERSIONS, 'Unsupported database version'), + osType: Yup.string() + .required('Required') + .oneOf([OS_LINUX, OS_WINDOWS, OS_MAC], 'Unsupported OS'), + dbType: Yup.string() + .required('Required') + .oneOf(DB_TYPES, 'Unsupported database type'), + totalMemoryUnit: Yup.string() + .required('Required') + .oneOf([SIZE_UNIT_MB, SIZE_UNIT_GB, SIZE_UNIT_TB], 'Unsupported unit'), + totalMemory: Yup.number() + .required('Required') + .integer('Must be an integer') + .when('totalMemoryUnit', (totalMemoryUnit, schema) => { + if (totalMemoryUnit === SIZE_UNIT_MB) { + return schema.min( + MIN_MB_MEMORY, + `Must be greater than or equal to ${MIN_MB_MEMORY} MB`, + ) + } + return schema.min(1, 'Must be greater than zero') + }) + .max( + MAX_NUMERIC_VALUE, + `Must be less than or equal to ${MAX_NUMERIC_VALUE}`, + ), + hdType: Yup.string() + .required('Required') + .oneOf(HARD_DRIVE_TYPES, 'Unsupported hard drive'), + dbSize: Yup.string() + .required('Required') + .oneOf(DB_SIZES, 'Unsupported db size'), + cpuNum: Yup.number() + .transform((value) => (isNaN(value) ? null : value)) + .nullable() + .notRequired() + .integer('Must be an integer') + .min(1, 'Must be greater than zero') + .max( + MAX_NUMERIC_VALUE, + `Must be less than or equal to ${MAX_NUMERIC_VALUE}`, + ), + connectionNum: Yup.number() + .transform((value) => (isNaN(value) ? null : value)) + .nullable() + .notRequired() + .integer('Must be an integer') + .min(20, 'Must be greater than or equal to 20') + .max( + MAX_NUMERIC_VALUE, + `Must be less than or equal to ${MAX_NUMERIC_VALUE}`, + ), +}) diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..c5be346 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,113 @@ +import vue from '@vitejs/plugin-vue' +import ui from '@nuxt/ui/vite' +import { defineConfig } from 'vite' +import { VitePWA } from 'vite-plugin-pwa' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + vue(), + ui(), + VitePWA({ + injectRegister: null, + registerType: 'prompt', + devOptions: { + enabled: true, + type: 'module', + }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg}'], + }, + includeAssets: [ + 'favicon.svg', + 'favicon.ico', + 'icon-192x192.png', + 'icon-512x512.png', + 'maskable_icon.png', + ], + manifest: { + name: 'PGTune', + short_name: 'PGTune', + description: 'PgTune - Tuning PostgreSQL config by your hardware', + display: 'standalone', + start_url: '/', + theme_color: '#0f172b', + background_color: '#fff', + icons: [ + { + src: '/icon-192x192.png', + sizes: '192x192', + type: 'image/png', + purpose: 'any', + }, + { + src: '/icon-512x512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'any', + }, + { + src: '/maskable_icon.png', + sizes: '1024x1024', + type: 'image/png', + purpose: 'maskable', + }, + ], + }, + }), + /* + VitePWA({ + injectRegister: null, + strategies: 'injectManifest', + registerType: 'prompt', + srcDir: 'src', + // filename: 'sw.js', + base: '/', + scope: '/', + includeAssets: [ + 'favicon.svg', + 'favicon.ico', + 'icon-192x192.png', + 'icon-512x512.png', + 'maskable_icon.png', + 'about.html', + ], + injectManifest: { + globPatterns: ['\*\*\/*.{css,js,html,png,svg,ico}'], + }, + devOptions: { + enabled: true, + type: 'module', + }, + manifest: { + name: 'PGTune', + short_name: 'PGTune', + description: 'PgTune - Tuning PostgreSQL config by your hardware', + display: 'standalone', + start_url: '/', + theme_color: '#fdf6e3', + background_color: '#fdf6e3', + icons: [ + { + src: '/icon-192x192.png', + sizes: '192x192', + type: 'image/png', + purpose: 'any', + }, + { + src: '/icon-512x512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'any', + }, + { + src: '/maskable_icon.png', + sizes: '1024x1024', + type: 'image/png', + purpose: 'maskable', + }, + ], + }, + }), */ + ], +})