Compare commits
14 Commits
a68f02bab4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a406af54bd | |||
| 1ca4c90b88 | |||
| a61c527ef9 | |||
| f39d9ff11e | |||
| c801783779 | |||
| 50d4ea10c6 | |||
| 0836f8e9e9 | |||
| e7f135e8c1 | |||
| 664092f415 | |||
| 38cc75a688 | |||
| 7a60bb15fe | |||
| 43b57bdb0f | |||
| 05076eb367 | |||
| 316d06b1d2 |
@@ -1,2 +1,5 @@
|
|||||||
# iiko-connector
|
# iiko-connector
|
||||||
|
|
||||||
|
* `Числовые` → `Агрегация`
|
||||||
|
* `Категории` → `Группировка {ROW / COLUMN}`
|
||||||
|
* `Фильтры` → `Фильтрация`
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ dependencies {
|
|||||||
implementation(platform("io.vertx:vertx-stack-depchain:$vertxVersion"))
|
implementation(platform("io.vertx:vertx-stack-depchain:$vertxVersion"))
|
||||||
implementation("io.vertx:vertx-launcher-application")
|
implementation("io.vertx:vertx-launcher-application")
|
||||||
implementation("io.vertx:vertx-web-client")
|
implementation("io.vertx:vertx-web-client")
|
||||||
|
implementation("io.vertx:vertx-web-proxy")
|
||||||
implementation("io.vertx:vertx-config")
|
implementation("io.vertx:vertx-config")
|
||||||
implementation("io.vertx:vertx-sql-client-templates")
|
implementation("io.vertx:vertx-sql-client-templates")
|
||||||
implementation("io.vertx:vertx-health-check")
|
implementation("io.vertx:vertx-health-check")
|
||||||
@@ -48,17 +49,26 @@ dependencies {
|
|||||||
|
|
||||||
implementation("com.fasterxml.jackson.core:jackson-databind")
|
implementation("com.fasterxml.jackson.core:jackson-databind")
|
||||||
|
|
||||||
// https://mvnrepository.com/artifact/org.mindrot/jbcrypt
|
// Source: https://mvnrepository.com/artifact/org.mindrot/jbcrypt
|
||||||
implementation("org.mindrot:jbcrypt:0.4")
|
implementation("org.mindrot:jbcrypt:0.4")
|
||||||
// https://mvnrepository.com/artifact/org.slf4j/slf4j-api
|
// Source: https://mvnrepository.com/artifact/org.slf4j/slf4j-api
|
||||||
implementation("org.slf4j:slf4j-api:2.0.17")
|
implementation("org.slf4j:slf4j-api:2.0.17")
|
||||||
// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-slf4j2-impl
|
// Source: https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-slf4j2-impl
|
||||||
implementation("org.apache.logging.log4j:log4j-slf4j2-impl:2.25.4")
|
implementation("org.apache.logging.log4j:log4j-slf4j2-impl:2.25.4")
|
||||||
// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-core
|
// Source: https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-core
|
||||||
implementation("org.apache.logging.log4j:log4j-core:2.25.4")
|
implementation("org.apache.logging.log4j:log4j-core:2.25.4")
|
||||||
// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api
|
// Source: https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api
|
||||||
implementation("org.apache.logging.log4j:log4j-api:2.25.4")
|
implementation("org.apache.logging.log4j:log4j-api:2.25.4")
|
||||||
|
|
||||||
|
implementation("io.vertx:vertx-jdbc-client")
|
||||||
|
|
||||||
|
// Source: https://mvnrepository.com/artifact/com.clickhouse/clickhouse-jdbc
|
||||||
|
implementation("com.clickhouse:clickhouse-jdbc:0.9.8")
|
||||||
|
// Source: https://mvnrepository.com/artifact/com.mysql/mysql-connector-j
|
||||||
|
implementation("com.mysql:mysql-connector-j:9.7.0")
|
||||||
|
// Source: https://mvnrepository.com/artifact/org.postgresql/postgresql
|
||||||
|
implementation("org.postgresql:postgresql:42.7.11")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
java {
|
java {
|
||||||
|
|||||||
@@ -34,11 +34,13 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PMA_HOST: iiko-db
|
PMA_HOST: iiko-db
|
||||||
PMA_PORT: 3306
|
PMA_PORT: 3306
|
||||||
|
PMA_USER: root
|
||||||
|
PMA_PASSWORD: DVjXT_kew508
|
||||||
UPLOAD_LIMIT: 10M
|
UPLOAD_LIMIT: 10M
|
||||||
PMA_ABSOLUTE_URI: https://iiko-app.dev.xserver.su/phpmyadmin/
|
PMA_ABSOLUTE_URI: https://iiko-app.dev.xserver.su/phpmyadmin/
|
||||||
TZ: Europe/Moscow
|
TZ: Europe/Moscow
|
||||||
ports:
|
# ports:
|
||||||
- "7102:80"
|
# - "7102:80"
|
||||||
|
|
||||||
iiko-redis:
|
iiko-redis:
|
||||||
image: redis:latest
|
image: redis:latest
|
||||||
@@ -75,5 +77,9 @@ services:
|
|||||||
REDIS__HOST: iiko-redis
|
REDIS__HOST: iiko-redis
|
||||||
REDIS__PORT: 6379
|
REDIS__PORT: 6379
|
||||||
SERVER__PORT: 7104
|
SERVER__PORT: 7104
|
||||||
|
PMA__ENABLED: true
|
||||||
|
PMA__BASE_PATH: /phpmyadmin
|
||||||
|
PMA__UPSTREAM: http://iiko-pma:80/
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- $PWD/app/logs:/app/logs
|
- $PWD/app/logs:/app/logs
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"axios": "^1.15.0",
|
"axios": "^1.15.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.31",
|
"vue": "^3.5.31",
|
||||||
"vue-i18n": "^9.14.5",
|
"vue-i18n": "^11.4.0",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"postcss": "^8.5.9",
|
"postcss": "^8.5.9",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^8.0.3",
|
"vite": "^7.3.2",
|
||||||
"vite-plugin-vue-devtools": "^8.1.1"
|
"vite-plugin-vue-devtools": "^8.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watch } from 'vue'
|
import { watch } from 'vue'
|
||||||
import { useSettingsStore } from './stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
|
||||||
const settings = useSettingsStore()
|
const settings = useSettingsStore()
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
to="/dashboard"
|
to="/dashboard"
|
||||||
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
||||||
:class="[
|
:class="[
|
||||||
$route.path === '/dashboard' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
route.path === '/dashboard' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
||||||
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
||||||
]"
|
]"
|
||||||
:title="sidebarCollapsed ? t('app.dashboard') : ''"
|
:title="sidebarCollapsed ? t('app.dashboard') : ''"
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
to="/users"
|
to="/users"
|
||||||
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
||||||
:class="[
|
:class="[
|
||||||
$route.path === '/users' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
route.path === '/users' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
||||||
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
||||||
]"
|
]"
|
||||||
:title="sidebarCollapsed ? t('app.users') : ''"
|
:title="sidebarCollapsed ? t('app.users') : ''"
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
to="/restaurants"
|
to="/restaurants"
|
||||||
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
||||||
:class="[
|
:class="[
|
||||||
$route.path === '/restaurants' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
route.path === '/restaurants' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
||||||
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
||||||
]"
|
]"
|
||||||
:title="sidebarCollapsed ? t('app.restaurants') : ''"
|
:title="sidebarCollapsed ? t('app.restaurants') : ''"
|
||||||
@@ -76,12 +76,45 @@
|
|||||||
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.restaurants') }}</span>
|
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.restaurants') }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
|
<router-link
|
||||||
|
v-if="userStore.role === 'admin'"
|
||||||
|
to="/olap-columns"
|
||||||
|
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
||||||
|
:class="[
|
||||||
|
route.path === '/olap-columns' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
||||||
|
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
||||||
|
]"
|
||||||
|
:title="sidebarCollapsed ? t('app.olapColumns') : ''"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V8a2 2 0 012-2z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6v12M16 6v12" />
|
||||||
|
</svg>
|
||||||
|
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.olapColumns') }}</span>
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<router-link
|
||||||
|
v-if="userStore.role === 'admin'"
|
||||||
|
to="/database-connections"
|
||||||
|
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
||||||
|
:class="[
|
||||||
|
route.path === '/database-connections' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
||||||
|
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
||||||
|
]"
|
||||||
|
:title="sidebarCollapsed ? t('dbConnections.pageName') : ''"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||||
|
</svg>
|
||||||
|
<span v-if="!sidebarCollapsed" class="truncate">{{ t('dbConnections.pageName') }}</span>
|
||||||
|
</router-link>
|
||||||
|
|
||||||
<router-link
|
<router-link
|
||||||
v-if="userStore.role === 'admin'"
|
v-if="userStore.role === 'admin'"
|
||||||
to="/settings"
|
to="/settings"
|
||||||
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
||||||
:class="[
|
:class="[
|
||||||
$route.path === '/settings' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
route.path === '/settings' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
||||||
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
||||||
]"
|
]"
|
||||||
:title="sidebarCollapsed ? t('app.settings') : ''"
|
:title="sidebarCollapsed ? t('app.settings') : ''"
|
||||||
@@ -92,30 +125,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.settings') }}</span>
|
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.settings') }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<!-- PhpMyAdmin - только для администраторов -->
|
|
||||||
<a
|
|
||||||
v-if="userStore.role === 'admin'"
|
|
||||||
href="/phpmyadmin"
|
|
||||||
target="_self"
|
|
||||||
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors no-router-link"
|
|
||||||
:class="[
|
|
||||||
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3',
|
|
||||||
$route.path === '/phpmyadmin' ? 'bg-primary-50 text-primary-700' : 'text-gray-700'
|
|
||||||
]"
|
|
||||||
:title="sidebarCollapsed ? t('app.database') : ''"
|
|
||||||
>
|
|
||||||
<!-- Иконка базы данных -->
|
|
||||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M4 7v10c0 2 1.5 3 3 3h10c1.5 0 3-1 3-3V7c0-2-1.5-3-3-3H7c-1.5 0-3 1-3 3z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M4 7c0 2 1.5 3 3 3h10c1.5 0 3-1 3-3" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M4 12c0 2 1.5 3 3 3h10c1.5 0 3-1 3-3" />
|
|
||||||
</svg>
|
|
||||||
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.database') }}</span>
|
|
||||||
</a>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- User Info (collapsed aware) -->
|
<!-- User Info (collapsed aware) -->
|
||||||
@@ -176,6 +185,17 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<a
|
||||||
|
href="/phpmyadmin/"
|
||||||
|
v-if="userStore.role === 'admin'"
|
||||||
|
target="_self"
|
||||||
|
class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
:title="t('app.database')"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
<button @click="logout" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors" :title="t('app.logout')">
|
<button @click="logout" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors" :title="t('app.logout')">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||||
@@ -209,12 +229,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted } from 'vue'
|
import { computed, ref, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useSettingsStore } from '../../stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useNotification } from '../../composables/useNotification'
|
import { useNotification } from '@/composables/useNotification'
|
||||||
|
|
||||||
const { notification } = useNotification()
|
const { notification, showNotification } = useNotification()
|
||||||
const settings = useSettingsStore()
|
const settings = useSettingsStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"users": "Users",
|
"users": "Users",
|
||||||
"restaurants": "Restaurants",
|
"restaurants": "Restaurants",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
|
"olapColumns": "OLAP Fields",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
@@ -21,7 +22,9 @@
|
|||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"loading": "Loading..."
|
"loading": "Loading...",
|
||||||
|
"all": "all",
|
||||||
|
"confirm": "Confirm"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
@@ -50,7 +53,7 @@
|
|||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"totalUsers": "Total Users",
|
"totalUsers": "Total Users",
|
||||||
"activeSessions": "Active Sessions",
|
"totalRestaurants": "Total Restaurants",
|
||||||
"systemHealth": "System Health",
|
"systemHealth": "System Health",
|
||||||
"uptime": "Uptime",
|
"uptime": "Uptime",
|
||||||
"vsLastMonth": "vs last month",
|
"vsLastMonth": "vs last month",
|
||||||
@@ -193,5 +196,69 @@
|
|||||||
"minLength": "Must be at least {min} characters",
|
"minLength": "Must be at least {min} characters",
|
||||||
"email": "Please enter a valid email address",
|
"email": "Please enter a valid email address",
|
||||||
"passwordMismatch": "Passwords do not match"
|
"passwordMismatch": "Passwords do not match"
|
||||||
|
},
|
||||||
|
"olap": {
|
||||||
|
"columnsTitle": "OLAP Reports Structure",
|
||||||
|
"initialize": "Initialize",
|
||||||
|
"filterFieldKey": "Field key",
|
||||||
|
"filterFieldKeyPlaceholder": "search by key...",
|
||||||
|
"filterReportType": "Report type",
|
||||||
|
"filterTag": "Tag",
|
||||||
|
"fieldKey": "Field (key)",
|
||||||
|
"reportTypes": "Report types",
|
||||||
|
"type": "Type",
|
||||||
|
"tags": "Tags",
|
||||||
|
"aggregation": "Aggregation",
|
||||||
|
"grouping": "Grouping",
|
||||||
|
"filtering": "Filtering",
|
||||||
|
"noColumnsFound": "No fields match the filters",
|
||||||
|
"selectRestaurant": "Select a restaurant to load the structure",
|
||||||
|
"loadError": "Error loading report structure",
|
||||||
|
"initSuccess": "Structure initialized successfully",
|
||||||
|
"initError": "Initialization error: {error}",
|
||||||
|
"selectRestaurantFirst": "Please select a restaurant",
|
||||||
|
"refreshStructure": "Refresh structure",
|
||||||
|
"refreshWarningTitle": "Full structure replacement",
|
||||||
|
"refreshWarningMessage": "You selected restaurant «{restaurant}». All existing OLAP fields data will be permanently deleted and replaced with data from this restaurant.",
|
||||||
|
"refreshWarningConfirm": "This action is irreversible. Continue?",
|
||||||
|
"searchRestaurant": "Search restaurant...",
|
||||||
|
"noRestaurantsFound": "No restaurants found",
|
||||||
|
"initializingData": "Initializing OLAP fields structure",
|
||||||
|
"refreshingData": "Refreshing OLAP fields structure",
|
||||||
|
"waitMessage": "Please wait. This operation may take a while...",
|
||||||
|
"editField": "Edit Field",
|
||||||
|
"displayType": "Display Type",
|
||||||
|
"updateSuccess": "Field updated successfully",
|
||||||
|
"updateError": "Error updating field",
|
||||||
|
"deleteSuccess": "Field deleted successfully",
|
||||||
|
"deleteError": "Error deleting field",
|
||||||
|
"deleteField": "Delete Field",
|
||||||
|
"deleteFieldConfirm": "Are you sure you want to delete this field? This action cannot be undone."
|
||||||
|
},
|
||||||
|
"dbConnections": {
|
||||||
|
"pageName": "Databases",
|
||||||
|
"add": "Add Connection",
|
||||||
|
"edit": "Edit Connection",
|
||||||
|
"delete": "Delete Connection",
|
||||||
|
"deleteConfirmation": "Are you sure you want to delete this database connection? This action cannot be undone.",
|
||||||
|
"type": "Type",
|
||||||
|
"host": "Host",
|
||||||
|
"port": "Port",
|
||||||
|
"database": "Database",
|
||||||
|
"user": "User",
|
||||||
|
"test": "Test connection",
|
||||||
|
"noConnections": "No database connections found. Click 'Add Connection' to create one.",
|
||||||
|
"loadError": "Failed to load database connections.",
|
||||||
|
"testSuccess": "Connection successful! Latency: {latency} ms",
|
||||||
|
"testError": "Connection failed: {error}",
|
||||||
|
"testNetworkError": "Network error while testing connection: {error}",
|
||||||
|
"testUnknownError": "Unknown error",
|
||||||
|
"passwordRequired": "Password is required for new connection.",
|
||||||
|
"createSuccess": "Database connection created successfully.",
|
||||||
|
"updateSuccess": "Database connection updated successfully.",
|
||||||
|
"createError": "Failed to create database connection.",
|
||||||
|
"updateError": "Failed to update database connection.",
|
||||||
|
"deleteSuccess": "Database connection deleted successfully.",
|
||||||
|
"deleteError": "Failed to delete database connection."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"title": "Панель администратора",
|
"title": "Панель администратора",
|
||||||
"dashboard": "Панель управления",
|
"dashboard": "Панель управления",
|
||||||
"database": "База Данных",
|
"database": "База данных",
|
||||||
"users": "Пользователи",
|
"users": "Пользователи",
|
||||||
"restaurants": "Рестораны",
|
"restaurants": "Рестораны",
|
||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
|
"olapColumns": "OLAP поля",
|
||||||
"profile": "Профиль",
|
"profile": "Профиль",
|
||||||
"logout": "Выйти",
|
"logout": "Выйти",
|
||||||
"language": "Язык",
|
"language": "Язык",
|
||||||
@@ -21,7 +22,9 @@
|
|||||||
"edit": "Редактировать",
|
"edit": "Редактировать",
|
||||||
"add": "Добавить",
|
"add": "Добавить",
|
||||||
"reset": "Сбросить",
|
"reset": "Сбросить",
|
||||||
"loading": "Загрузка..."
|
"loading": "Загрузка...",
|
||||||
|
"all": "все",
|
||||||
|
"confirm": "Подтвердить"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
@@ -50,7 +53,7 @@
|
|||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"totalUsers": "Всего пользователей",
|
"totalUsers": "Всего пользователей",
|
||||||
"activeSessions": "Активных сессий",
|
"totalRestaurants": "Всего ресторанов",
|
||||||
"systemHealth": "Здоровье системы",
|
"systemHealth": "Здоровье системы",
|
||||||
"uptime": "Время работы",
|
"uptime": "Время работы",
|
||||||
"vsLastMonth": "по сравнению с прошлым месяцем",
|
"vsLastMonth": "по сравнению с прошлым месяцем",
|
||||||
@@ -193,5 +196,69 @@
|
|||||||
"minLength": "Должно быть не менее {min} символов",
|
"minLength": "Должно быть не менее {min} символов",
|
||||||
"email": "Введите корректный email адрес",
|
"email": "Введите корректный email адрес",
|
||||||
"passwordMismatch": "Пароли не совпадают"
|
"passwordMismatch": "Пароли не совпадают"
|
||||||
|
},
|
||||||
|
"olap": {
|
||||||
|
"columnsTitle": "Структура OLAP-отчётов",
|
||||||
|
"initialize": "Инициализировать",
|
||||||
|
"filterFieldKey": "Ключ поля",
|
||||||
|
"filterFieldKeyPlaceholder": "поиск по ключу...",
|
||||||
|
"filterReportType": "Тип отчёта",
|
||||||
|
"filterTag": "Тег",
|
||||||
|
"fieldKey": "Поле (ключ)",
|
||||||
|
"reportTypes": "Типы отчётов",
|
||||||
|
"type": "Тип",
|
||||||
|
"tags": "Теги",
|
||||||
|
"aggregation": "Агрегация",
|
||||||
|
"grouping": "Группировка",
|
||||||
|
"filtering": "Фильтрация",
|
||||||
|
"noColumnsFound": "Нет полей, соответствующих фильтрам",
|
||||||
|
"selectRestaurant": "Выберите ресторан для загрузки структуры",
|
||||||
|
"loadError": "Ошибка загрузки структуры отчётов",
|
||||||
|
"initSuccess": "Структура успешно инициализирована",
|
||||||
|
"initError": "Ошибка инициализации: {error}",
|
||||||
|
"selectRestaurantFirst": "Пожалуйста, выберите ресторан",
|
||||||
|
"refreshStructure": "Обновить структуру",
|
||||||
|
"refreshWarningTitle": "Полная замена структуры",
|
||||||
|
"refreshWarningMessage": "Вы выбрали ресторан «{restaurant}». Все текущие данные о полях OLAP-отчётов будут полностью удалены и заменены данными из этого ресторана.",
|
||||||
|
"refreshWarningConfirm": "Это действие необратимо. Продолжить?",
|
||||||
|
"searchRestaurant": "Поиск ресторана...",
|
||||||
|
"noRestaurantsFound": "Рестораны не найдены",
|
||||||
|
"initializingData": "Инициализация структуры OLAP-полей",
|
||||||
|
"refreshingData": "Обновление структуры OLAP-полей",
|
||||||
|
"waitMessage": "Пожалуйста, подождите. Операция может занять некоторое время...",
|
||||||
|
"editField": "Редактирование поля",
|
||||||
|
"displayType": "Тип отображения",
|
||||||
|
"updateSuccess": "Поле успешно обновлено",
|
||||||
|
"updateError": "Ошибка при обновлении поля",
|
||||||
|
"deleteSuccess": "Поле успешно удалено",
|
||||||
|
"deleteError": "Ошибка при удалении поля",
|
||||||
|
"deleteField": "Удаление поля",
|
||||||
|
"deleteFieldConfirm": "Вы уверены, что хотите удалить это поле? Это действие необратимо."
|
||||||
|
},
|
||||||
|
"dbConnections": {
|
||||||
|
"pageName": "Базы данных",
|
||||||
|
"add": "Добавить подключение",
|
||||||
|
"edit": "Редактировать подключение",
|
||||||
|
"delete": "Удалить подключение",
|
||||||
|
"deleteConfirmation": "Вы уверены, что хотите удалить это подключение к базе данных? Действие необратимо.",
|
||||||
|
"type": "Тип",
|
||||||
|
"host": "Хост",
|
||||||
|
"port": "Порт",
|
||||||
|
"database": "База данных",
|
||||||
|
"user": "Пользователь",
|
||||||
|
"test": "Проверить подключение",
|
||||||
|
"noConnections": "Подключения к базам данных не найдены. Нажмите «Добавить подключение», чтобы создать.",
|
||||||
|
"loadError": "Не удалось загрузить список подключений.",
|
||||||
|
"testSuccess": "Подключение успешно! Задержка: {latency} мс",
|
||||||
|
"testError": "Ошибка подключения: {error}",
|
||||||
|
"testNetworkError": "Сетевая ошибка при проверке подключения: {error}",
|
||||||
|
"testUnknownError": "Неизвестная ошибка",
|
||||||
|
"passwordRequired": "Пароль обязателен для нового подключения.",
|
||||||
|
"createSuccess": "Подключение к БД успешно создано.",
|
||||||
|
"updateSuccess": "Подключение к БД успешно обновлено.",
|
||||||
|
"createError": "Не удалось создать подключение к БД.",
|
||||||
|
"updateError": "Не удалось обновить подключение к БД.",
|
||||||
|
"deleteSuccess": "Подключение к БД успешно удалено.",
|
||||||
|
"deleteError": "Не удалось удалить подключение к БД."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { createApp } from 'vue'
|
|||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import './style.css'
|
import '@/style.css'
|
||||||
import { useSettingsStore } from './stores/settings'
|
import { useSettingsStore } from './stores/settings'
|
||||||
import { useUserStore } from './stores/user'
|
import { useUserStore } from './stores/user'
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
import en from './locales/en.json'
|
import en from '@/locales/en.json'
|
||||||
import ru from './locales/ru.json'
|
import ru from '@/locales/ru.json'
|
||||||
|
|
||||||
// Функция определения языка браузера
|
// Функция определения языка браузера
|
||||||
function getBrowserLocale(): string {
|
function getBrowserLocale(): string {
|
||||||
|
|||||||
@@ -1,27 +1,79 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { useUserStore } from '../stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { useSettingsStore } from '../stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
import Login from '../views/auth/Login.vue'
|
import Login from '@/views/auth/Login.vue'
|
||||||
import Setup from '../views/auth/Setup.vue'
|
import Setup from '@/views/auth/Setup.vue'
|
||||||
import Register from '../views/auth/Register.vue'
|
import Register from '@/views/auth/Register.vue'
|
||||||
import Dashboard from '../views/Dashboard.vue'
|
import Dashboard from '@/views/Dashboard.vue'
|
||||||
import Users from '../views/Users.vue'
|
import Users from '@/views/Users.vue'
|
||||||
import Restaurants from '../views/Restaurants.vue'
|
import Restaurants from '@/views/Restaurants.vue'
|
||||||
import AdminSettings from '../views/AdminSettings.vue'
|
import OlapColumnsView from '@/views/OlapColumnsView.vue'
|
||||||
import Profile from '../views/Profile.vue'
|
import DBConnections from '@/views/DBConnections.vue'
|
||||||
import NotFound from '../views/NotFound.vue'
|
import AdminSettings from '@/views/AdminSettings.vue'
|
||||||
|
import Profile from '@/views/Profile.vue'
|
||||||
|
import NotFound from '@/views/NotFound.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/login', component: Login, meta: { title: 'Login', requiresAuth: false } },
|
{
|
||||||
{ path: '/register', component: Register, meta: { title: 'Register', requiresAuth: false } },
|
path: '/login',
|
||||||
{ path: '/setup', component: Setup, meta: { title: 'Setup', requiresAuth: false } },
|
component: Login,
|
||||||
{ path: '/', redirect: '/dashboard' },
|
meta: { title: 'Login', requiresAuth: false }
|
||||||
{ path: '/dashboard', component: Dashboard, meta: { requiresAuth: true, title: 'Dashboard' } },
|
},
|
||||||
{ path: '/users', component: Users, meta: { requiresAuth: true, requiresAdmin: true, title: 'Users' } },
|
{
|
||||||
{ path: '/restaurants', component: Restaurants, meta: { requiresAuth: true, title: 'Restaurants' } },
|
path: '/register',
|
||||||
{ path: '/settings', component: AdminSettings, meta: { requiresAuth: true, requiresAdmin: true, title: 'Settings' } },
|
component: Register,
|
||||||
{ path: '/profile', component: Profile, meta: { requiresAuth: true, title: 'Profile' } },
|
meta: { title: 'Register', requiresAuth: false }
|
||||||
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound, meta: { title: 'Page Not Found', requiresAuth: false } }
|
},
|
||||||
|
{
|
||||||
|
path: '/setup',
|
||||||
|
component: Setup,
|
||||||
|
meta: { title: 'Setup', requiresAuth: false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
redirect: '/dashboard'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
component: Dashboard,
|
||||||
|
meta: { requiresAuth: true, title: 'Dashboard' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/users',
|
||||||
|
component: Users,
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true, title: 'Users' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/restaurants',
|
||||||
|
component: Restaurants,
|
||||||
|
meta: { requiresAuth: true, title: 'Restaurants' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/olap-columns',
|
||||||
|
component: OlapColumnsView,
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true, title: 'OlapColumns' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/database-connections',
|
||||||
|
component: DBConnections,
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true, title: 'Database Connections' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
component: AdminSettings,
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true, title: 'Settings' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/profile',
|
||||||
|
component: Profile,
|
||||||
|
meta: { requiresAuth: true, title: 'Profile' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
name: 'NotFound',
|
||||||
|
component: NotFound,
|
||||||
|
meta: { title: 'Page Not Found', requiresAuth: false }
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({ history: createWebHistory(), routes })
|
const router = createRouter({ history: createWebHistory(), routes })
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
|
|
||||||
async function fetchProfile() {
|
async function fetchProfile() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/profile')
|
const res = await fetch('/api/profile')
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
id.value = data.id
|
id.value = data.id
|
||||||
@@ -27,7 +27,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateProfile(updates: { email?: string; password?: string; language?: string }) {
|
async function updateProfile(updates: { email?: string; password?: string; language?: string }) {
|
||||||
const res = await fetch('/api/admin/profile', {
|
const res = await fetch('/api/profile', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(updates)
|
body: JSON.stringify(updates)
|
||||||
|
|||||||
@@ -58,22 +58,18 @@
|
|||||||
<button type="submit" class="btn-primary">{{ t('settings.save') }}</button>
|
<button type="submit" class="btn-primary">{{ t('settings.save') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div v-if="message" class="mt-4 p-3 rounded-lg" :class="messageClass">
|
|
||||||
{{ message }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import AppLayout from '../components/Layout/AppLayout.vue';
|
import AppLayout from '@/components/Layout/AppLayout.vue';
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useNotification } from '../composables/useNotification'
|
import { useNotification } from '@/composables/useNotification'
|
||||||
|
|
||||||
const { showNotification } = useNotification()
|
const { showNotification } = useNotification()
|
||||||
const { t, locale } = useI18n()
|
const { t } = useI18n()
|
||||||
interface FieldMeta {
|
interface FieldMeta {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -86,11 +82,9 @@ interface FieldMeta {
|
|||||||
|
|
||||||
const meta = ref<FieldMeta[]>([]);
|
const meta = ref<FieldMeta[]>([]);
|
||||||
const values = ref<Record<string, string>>({});
|
const values = ref<Record<string, string>>({});
|
||||||
const message = ref('');
|
|
||||||
const messageClass = ref('');
|
|
||||||
|
|
||||||
async function loadMeta() {
|
async function loadMeta() {
|
||||||
const res = await fetch('/api/settings/meta');
|
const res = await fetch('/api/admin/settings/meta');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
meta.value = await res.json();
|
meta.value = await res.json();
|
||||||
} else {
|
} else {
|
||||||
@@ -99,7 +93,7 @@ async function loadMeta() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadValues() {
|
async function loadValues() {
|
||||||
const res = await fetch('/api/settings/all');
|
const res = await fetch('/api/admin/settings');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
values.value = await res.json();
|
values.value = await res.json();
|
||||||
} else {
|
} else {
|
||||||
@@ -128,13 +122,5 @@ async function saveSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showMessage(text: string, cssClass: string) {
|
|
||||||
message.value = text;
|
|
||||||
messageClass.value = cssClass;
|
|
||||||
setTimeout(() => {
|
|
||||||
message.value = '';
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadData);
|
onMounted(loadData);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
384
frontend/src/views/DBConnections.vue
Normal file
384
frontend/src/views/DBConnections.vue
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">{{ t('dbConnections.pageName') }}</h1>
|
||||||
|
<button @click="openModal('create')" class="btn-primary flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
{{ t('dbConnections.add') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.id') }}</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.name') }}</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.type') }}</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.host') }}</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.port') }}</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.database') }}</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.user') }}</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.created') }}</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.actions') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 bg-white">
|
||||||
|
<tr v-for="conn in connections" :key="conn.id" class="hover:bg-gray-50 transition-colors">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ conn.id }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ conn.name }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
|
<span :class="getTypeBadgeClass(conn.type)" class="px-2 py-1 rounded-full text-xs font-medium">
|
||||||
|
{{ getTypeLabel(conn.type) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.host }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.port }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.database }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.user }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(conn.created) }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<div class="flex items-center justify-end space-x-3">
|
||||||
|
<button @click="testConnection(conn)" :disabled="conn.testing" class="text-green-600 hover:text-green-800 transition-colors disabled:opacity-50" :title="t('dbConnections.test')">
|
||||||
|
<svg v-if="!conn.testing" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button @click="openModal('edit', conn)" class="text-blue-600 hover:text-blue-800 transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button @click="confirmDelete(conn.id)" class="text-red-600 hover:text-red-800 transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span v-if="conn.testResult" class="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700 ml-1 whitespace-nowrap">
|
||||||
|
{{ conn.testResult }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="connections.length === 0">
|
||||||
|
<td colspan="9" class="px-6 py-12 text-center text-gray-500">{{ t('dbConnections.noConnections') }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно создания/редактирования -->
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="modalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeModal">
|
||||||
|
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||||
|
<div class="flex items-center justify-center min-h-screen p-4">
|
||||||
|
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full transform transition-all">
|
||||||
|
<div class="flex justify-between items-center p-6 border-b">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900">{{ modalTitle }}</h2>
|
||||||
|
<button @click="closeModal" class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="submitConnection" class="p-6 space-y-5">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.name') }} *</label>
|
||||||
|
<input v-model="form.name" type="text" required class="input-field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.type') }} *</label>
|
||||||
|
<select v-model="form.type" required class="input-field">
|
||||||
|
<option value="mysql">MySQL</option>
|
||||||
|
<option value="postgres">PostgreSQL</option>
|
||||||
|
<option value="clickhouse">ClickHouse</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.host') }} *</label>
|
||||||
|
<input v-model="form.host" type="text" required class="input-field" placeholder="localhost or IP" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.port') }} *</label>
|
||||||
|
<input v-model="form.port" type="number" required class="input-field" placeholder="3306, 5432, 8123..." />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.database') }} *</label>
|
||||||
|
<input v-model="form.database" type="text" required class="input-field" placeholder="database name" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.user') }} *</label>
|
||||||
|
<input v-model="form.user" type="text" required class="input-field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.password') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="form.password"
|
||||||
|
:required="modalMode === 'create'"
|
||||||
|
type="password"
|
||||||
|
class="input-field"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<p v-if="modalMode === 'edit'" class="text-xs text-gray-500 mt-1">{{ t('common.leavePasswordBlank') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-3 pt-2">
|
||||||
|
<button type="button" @click="closeModal" class="btn-secondary">{{ t('app.cancel') }}</button>
|
||||||
|
<button type="submit" class="btn-primary">{{ t('app.save') }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Модальное окно подтверждения удаления -->
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="deleteConfirm.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="deleteConfirm.show = false">
|
||||||
|
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||||
|
<div class="flex items-center justify-center min-h-screen p-4">
|
||||||
|
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full">
|
||||||
|
<div class="p-6 text-center">
|
||||||
|
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
|
||||||
|
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ t('dbConnections.delete') }}</h3>
|
||||||
|
<p class="text-sm text-gray-500 mb-6">{{ t('dbConnections.deleteConfirmation') }}</p>
|
||||||
|
<div class="flex justify-center space-x-3">
|
||||||
|
<button @click="deleteConfirm.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
|
||||||
|
<button @click="deleteConnection(deleteConfirm.id)" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">{{ t('app.delete') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import AppLayout from '@/components/Layout/AppLayout.vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useNotification } from '@/composables/useNotification';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { showNotification } = useNotification();
|
||||||
|
|
||||||
|
type Connection = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: 'mysql' | 'postgres' | 'clickhouse';
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
database: string;
|
||||||
|
user: string;
|
||||||
|
created: string;
|
||||||
|
testing?: boolean;
|
||||||
|
testResult?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const connections = ref<Connection[]>([]);
|
||||||
|
const modalOpen = ref(false);
|
||||||
|
const modalMode = ref<'create' | 'edit'>('create');
|
||||||
|
const form = ref({
|
||||||
|
id: null as number | null,
|
||||||
|
name: '',
|
||||||
|
type: 'mysql' as 'mysql' | 'postgres' | 'clickhouse',
|
||||||
|
host: '',
|
||||||
|
port: 3306,
|
||||||
|
database: '',
|
||||||
|
user: '',
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
const modalTitle = ref('');
|
||||||
|
const deleteConfirm = ref({ show: false, id: null as number | null });
|
||||||
|
|
||||||
|
// Загрузка списка подключений
|
||||||
|
async function loadConnections() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/database-connections');
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data = await res.json();
|
||||||
|
connections.value = data.map((c: any) => ({
|
||||||
|
...c,
|
||||||
|
testing: false,
|
||||||
|
testResult: null
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('dbConnections.loadError', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тестирование соединения
|
||||||
|
async function testConnection(conn: Connection) {
|
||||||
|
conn.testing = true;
|
||||||
|
conn.testResult = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/database-connections/${conn.id}/test`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
conn.testResult = `${data.latency_ms} ms`;
|
||||||
|
showNotification('dbConnections.testSuccess', 'success', { latency: data.latency_ms });
|
||||||
|
} else {
|
||||||
|
const errorText = data.error || t('dbConnections.testUnknownError');
|
||||||
|
showNotification('dbConnections.testError', 'error', { error: errorText });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
showNotification('dbConnections.testNetworkError', 'error', { error: error.message });
|
||||||
|
} finally {
|
||||||
|
conn.testing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вспомогательные функции для отображения типа
|
||||||
|
function getTypeLabel(type: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
mysql: 'MySQL',
|
||||||
|
postgres: 'PostgreSQL',
|
||||||
|
clickhouse: 'ClickHouse'
|
||||||
|
};
|
||||||
|
return labels[type] || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeBadgeClass(type: string) {
|
||||||
|
const classes: Record<string, string> = {
|
||||||
|
mysql: 'bg-blue-100 text-blue-800',
|
||||||
|
postgres: 'bg-indigo-100 text-indigo-800',
|
||||||
|
clickhouse: 'bg-amber-100 text-amber-800'
|
||||||
|
};
|
||||||
|
return classes[type] || 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(mode: 'create' | 'edit', conn: Connection | null = null) {
|
||||||
|
modalMode.value = mode;
|
||||||
|
if (mode === 'create') {
|
||||||
|
form.value = {
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
type: 'mysql',
|
||||||
|
host: '',
|
||||||
|
port: 3306,
|
||||||
|
database: '',
|
||||||
|
user: '',
|
||||||
|
password: ''
|
||||||
|
};
|
||||||
|
modalTitle.value = t('dbConnections.add');
|
||||||
|
} else if (conn) {
|
||||||
|
form.value = {
|
||||||
|
id: conn.id,
|
||||||
|
name: conn.name,
|
||||||
|
type: conn.type,
|
||||||
|
host: conn.host,
|
||||||
|
port: conn.port,
|
||||||
|
database: conn.database,
|
||||||
|
user: conn.user,
|
||||||
|
password: ''
|
||||||
|
};
|
||||||
|
modalTitle.value = t('dbConnections.edit');
|
||||||
|
}
|
||||||
|
modalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
modalOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitConnection() {
|
||||||
|
if (modalMode.value === 'create' && !form.value.password) {
|
||||||
|
showNotification('dbConnections.passwordRequired', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: any = {
|
||||||
|
name: form.value.name,
|
||||||
|
type: form.value.type,
|
||||||
|
host: form.value.host,
|
||||||
|
port: form.value.port,
|
||||||
|
database: form.value.database,
|
||||||
|
user: form.value.user,
|
||||||
|
};
|
||||||
|
if (form.value.password) {
|
||||||
|
payload.password = form.value.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modalMode.value === 'create') {
|
||||||
|
const res = await fetch('/api/admin/database-connections', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
showNotification('dbConnections.createSuccess', 'success');
|
||||||
|
} else {
|
||||||
|
const res = await fetch(`/api/admin/database-connections/${form.value.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
showNotification('dbConnections.updateSuccess', 'success');
|
||||||
|
}
|
||||||
|
await loadConnections();
|
||||||
|
closeModal();
|
||||||
|
} catch (e) {
|
||||||
|
showNotification(modalMode.value === 'create' ? 'dbConnections.createError' : 'dbConnections.updateError', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(id: number) {
|
||||||
|
deleteConfirm.value = { show: true, id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteConnection(id: number) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/database-connections/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
showNotification('dbConnections.deleteSuccess', 'success');
|
||||||
|
await loadConnections();
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('dbConnections.deleteError', 'error');
|
||||||
|
} finally {
|
||||||
|
deleteConfirm.value.show = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadConnections);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -23,8 +23,8 @@
|
|||||||
<div class="card hover:shadow-md transition-shadow">
|
<div class="card hover:shadow-md transition-shadow">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-600">{{ t('dashboard.activeSessions') }}</p>
|
<p class="text-sm font-medium text-gray-600">{{ t('dashboard.totalRestaurants') }}</p>
|
||||||
<p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.activeSessions }}</p>
|
<p class="text-3xl font-bold text-gray-900 mt-2">{{ stats.totalRestaurants }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center">
|
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -175,13 +175,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue';
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
import AppLayout from '../components/Layout/AppLayout.vue';
|
import AppLayout from '@/components/Layout/AppLayout.vue';
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
import { useNotification } from '../composables/useNotification'
|
import { useNotification } from '@/composables/useNotification'
|
||||||
|
|
||||||
const { showNotification } = useNotification()
|
const { showNotification } = useNotification()
|
||||||
const stats = ref({ totalUsers: 0, activeSessions: 0, systemHealth: 100, uptime: '99.9%' });
|
const stats = ref({ totalUsers: 0, totalRestaurants: 0, systemHealth: 100, uptime: '99.9%' });
|
||||||
const userGrowth = ref(12);
|
const userGrowth = ref(12);
|
||||||
const sessionGrowth = ref(5);
|
const sessionGrowth = ref(5);
|
||||||
const recentUsers = ref([]);
|
const recentUsers = ref([]);
|
||||||
@@ -195,20 +195,18 @@ let interval: number;
|
|||||||
|
|
||||||
async function loadDashboardData() {
|
async function loadDashboardData() {
|
||||||
try {
|
try {
|
||||||
const [usersRes, sessionsRes, healthRes, restaurantsRes] = await Promise.all([
|
const [usersRes, healthRes, restaurantsRes] = await Promise.all([
|
||||||
fetch('/api/admin/users'),
|
fetch('/api/admin/users'),
|
||||||
fetch('/api/admin/active-sessions'),
|
|
||||||
fetch('/api/health'),
|
fetch('/api/health'),
|
||||||
fetch('/api/admin/restaurants')
|
fetch('/api/admin/restaurants')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const users = await usersRes.json();
|
const users = await usersRes.json();
|
||||||
const sessions = await sessionsRes.json();
|
|
||||||
const health = await healthRes.json();
|
const health = await healthRes.json();
|
||||||
const restaurants = await restaurantsRes.json();
|
const restaurants = await restaurantsRes.json();
|
||||||
|
|
||||||
stats.value.totalUsers = users.length;
|
stats.value.totalUsers = users.length;
|
||||||
stats.value.activeSessions = sessions.count || 0;
|
stats.value.totalRestaurants = restaurants.length;
|
||||||
recentUsers.value = users.slice(-5).reverse();
|
recentUsers.value = users.slice(-5).reverse();
|
||||||
recentRestaurants.value = restaurants.slice(-5).reverse();
|
recentRestaurants.value = restaurants.slice(-5).reverse();
|
||||||
|
|
||||||
@@ -242,7 +240,7 @@ function formatDate(dateStr: string) {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadDashboardData();
|
loadDashboardData();
|
||||||
interval = window.setInterval(loadDashboardData, 30000);
|
interval = window.setInterval(loadDashboardData, 10000);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
641
frontend/src/views/OlapColumnsView.vue
Normal file
641
frontend/src/views/OlapColumnsView.vue
Normal file
@@ -0,0 +1,641 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div class="flex justify-between items-center mb-6 flex-wrap gap-4">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">{{ t('olap.columnsTitle') }}</h1>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
v-if="hasData && !loading && !initializing"
|
||||||
|
@click="openRefreshModal"
|
||||||
|
class="btn-secondary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
{{ t('olap.refreshStructure') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!hasData && !loading && !initializing"
|
||||||
|
@click="openInitModal"
|
||||||
|
class="btn-primary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
{{ t('olap.initialize') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Фильтры -->
|
||||||
|
<div v-if="hasData" class="card mb-6 p-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('olap.filterFieldKey') }}</label>
|
||||||
|
<input v-model="filters.fieldKey" type="text" class="input-field" :placeholder="t('olap.filterFieldKeyPlaceholder')" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('olap.filterReportType') }}</label>
|
||||||
|
<select v-model="filters.reportType" class="input-field">
|
||||||
|
<option value="">{{ t('app.all') }}</option>
|
||||||
|
<option v-for="rt in availableReportTypes" :key="rt" :value="rt">{{ rt }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('olap.filterTag') }}</label>
|
||||||
|
<select v-model="filters.tag" class="input-field">
|
||||||
|
<option value="">{{ t('app.all') }}</option>
|
||||||
|
<option v-for="tag in availableTags" :key="tag" :value="tag">{{ tag }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<button @click="resetFilters" class="btn-secondary w-full">{{ t('app.reset') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Таблица -->
|
||||||
|
<div v-if="loading" class="card p-8 text-center">
|
||||||
|
<svg class="animate-spin h-8 w-8 text-primary-600 mx-auto" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="mt-2 text-gray-500">{{ t('app.loading') }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="hasData && filteredColumns.length === 0" class="card p-8 text-center text-gray-500">
|
||||||
|
{{ t('olap.noColumnsFound') }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="hasData" class="card overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.fieldKey') }}</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.name') }}</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.reportTypes') }}</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.type') }}</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.tags') }}</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.aggregation') }}</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.grouping') }}</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.filtering') }}</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.actions') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 bg-white">
|
||||||
|
<tr v-for="col in filteredColumns" :key="col.fieldKey" class="hover:bg-gray-50">
|
||||||
|
<td class="px-6 py-4 text-sm font-mono text-gray-900">{{ col.fieldKey }}</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-900">{{ col.name }}</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-500">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span v-for="rt in col.reportTypes" :key="rt" class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
{{ rt }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-500">
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
{{ col.typeNormal || col.type }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-500">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span v-for="tag in col.tags" :key="tag" class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
|
||||||
|
{{ tag }}
|
||||||
|
</span>
|
||||||
|
<span v-if="!col.tags || col.tags.length === 0" class="text-gray-400">—</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-center">
|
||||||
|
<span v-if="col.aggregationAllowed" class="text-green-600">✓</span>
|
||||||
|
<span v-else class="text-gray-300">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-center">
|
||||||
|
<span v-if="col.groupingAllowed" class="text-green-600">✓</span>
|
||||||
|
<span v-else class="text-gray-300">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-center">
|
||||||
|
<span v-if="col.filteringAllowed" class="text-green-600">✓</span>
|
||||||
|
<span v-else class="text-gray-300">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<div class="flex items-center justify-end space-x-2">
|
||||||
|
<!-- <button @click="openEditModal(col)" class="text-blue-600 hover:text-blue-800 transition-colors" :title="t('app.edit')">-->
|
||||||
|
<!-- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">-->
|
||||||
|
<!-- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />-->
|
||||||
|
<!-- </svg>-->
|
||||||
|
<!-- </button>-->
|
||||||
|
<button @click="openDeleteFieldModal(col.fieldKey)" class="text-red-600 hover:text-red-800 transition-colors" :title="t('app.delete')">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модалка выбора ресторана (для инициализации и обновления) -->
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="initModalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeInitModal">
|
||||||
|
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||||
|
<div class="flex items-center justify-center min-h-screen p-4">
|
||||||
|
<div class="relative bg-white rounded-2xl shadow-xl max-w-lg w-full max-h-[90vh] flex flex-col">
|
||||||
|
<!-- Заголовок -->
|
||||||
|
<div class="flex justify-between items-center p-6 border-b">
|
||||||
|
<h3 class="text-xl font-bold text-gray-900">{{ initModalTitle }}</h3>
|
||||||
|
<button @click="closeInitModal" class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Поле поиска -->
|
||||||
|
<div class="p-4 border-b">
|
||||||
|
<div class="relative">
|
||||||
|
<svg class="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
v-model="restaurantSearch"
|
||||||
|
type="text"
|
||||||
|
class="input-field pl-9"
|
||||||
|
:placeholder="t('olap.searchRestaurant')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Список ресторанов (скролл) -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-2 space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="rest in filteredRestaurants"
|
||||||
|
:key="rest.id"
|
||||||
|
@click="selectedRestaurantId = rest.id"
|
||||||
|
class="flex items-center justify-between p-4 rounded-xl border cursor-pointer transition-all duration-150 hover:shadow-md"
|
||||||
|
:class="[
|
||||||
|
selectedRestaurantId === rest.id
|
||||||
|
? 'border-primary-500 bg-primary-50 ring-1 ring-primary-500'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-900">{{ rest.name }}</p>
|
||||||
|
<p class="text-sm text-gray-500">{{ rest.host }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedRestaurantId === rest.id" class="text-primary-600">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="filteredRestaurants.length === 0" class="text-center py-8 text-gray-500">
|
||||||
|
{{ t('olap.noRestaurantsFound') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Кнопки действий -->
|
||||||
|
<div class="flex justify-end space-x-3 p-6 border-t bg-gray-50 rounded-b-2xl">
|
||||||
|
<button @click="closeInitModal" class="btn-secondary">{{ t('app.cancel') }}</button>
|
||||||
|
<button
|
||||||
|
@click="onInitConfirm"
|
||||||
|
:disabled="!selectedRestaurantId"
|
||||||
|
class="btn-primary"
|
||||||
|
>
|
||||||
|
{{ t('app.confirm') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Модалка предупреждения перед обновлением (после выбора ресторана) -->
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="refreshWarningModal.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="refreshWarningModal.show = false">
|
||||||
|
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||||
|
<div class="flex items-center justify-center min-h-screen p-4">
|
||||||
|
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full">
|
||||||
|
<div class="p-6 text-center">
|
||||||
|
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
|
||||||
|
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ t('olap.refreshWarningTitle') }}</h3>
|
||||||
|
<p class="text-sm text-gray-500 mb-4">
|
||||||
|
{{ t('olap.refreshWarningMessage', { restaurant: pendingRestaurantName }) }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm font-semibold text-red-600 mb-6">{{ t('olap.refreshWarningConfirm') }}</p>
|
||||||
|
<div class="flex justify-center space-x-3">
|
||||||
|
<button @click="refreshWarningModal.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
|
||||||
|
<button @click="executeInitialize" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700">
|
||||||
|
{{ t('app.confirm') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Модалка редактирования поля -->
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="editModalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeEditModal">
|
||||||
|
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||||
|
<div class="flex items-center justify-center min-h-screen p-4">
|
||||||
|
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full">
|
||||||
|
<div class="flex justify-between items-center p-6 border-b">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900">{{ t('olap.editField') }}</h2>
|
||||||
|
<button @click="closeEditModal" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="updateField" class="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.name') }}</label>
|
||||||
|
<input v-model="editForm.name" type="text" class="input-field" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('olap.displayType') }}</label>
|
||||||
|
<select v-model="editForm.typeNormal" class="input-field">
|
||||||
|
<option value="string">string</option>
|
||||||
|
<option value="integer">integer</option>
|
||||||
|
<option value="decimal">decimal</option>
|
||||||
|
<option value="datetime">datetime</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input type="checkbox" v-model="editForm.aggregationAllowed" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4 mr-2" />
|
||||||
|
<label class="text-sm font-medium text-gray-700">{{ t('olap.aggregation') }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input type="checkbox" v-model="editForm.groupingAllowed" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4 mr-2" />
|
||||||
|
<label class="text-sm font-medium text-gray-700">{{ t('olap.grouping') }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input type="checkbox" v-model="editForm.filteringAllowed" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4 mr-2" />
|
||||||
|
<label class="text-sm font-medium text-gray-700">{{ t('olap.filtering') }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-3 pt-2">
|
||||||
|
<button type="button" @click="closeEditModal" class="btn-secondary">{{ t('app.cancel') }}</button>
|
||||||
|
<button type="submit" class="btn-primary">{{ t('app.save') }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Модалка подтверждения удаления поля -->
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="deleteFieldConfirm.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="deleteFieldConfirm.show = false">
|
||||||
|
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||||
|
<div class="flex items-center justify-center min-h-screen p-4">
|
||||||
|
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full">
|
||||||
|
<div class="p-6 text-center">
|
||||||
|
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
|
||||||
|
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ t('olap.deleteField') }}</h3>
|
||||||
|
<p class="text-sm text-gray-500 mb-6">{{ t('olap.deleteFieldConfirm') }}</p>
|
||||||
|
<div class="flex justify-center space-x-3">
|
||||||
|
<button @click="deleteFieldConfirm.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
|
||||||
|
<button @click="confirmDeleteField" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700">{{ t('app.delete') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="initializing" class="fixed inset-0 z-60 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div class="bg-white rounded-2xl shadow-xl p-8 flex flex-col items-center gap-4 max-w-sm w-full mx-4">
|
||||||
|
<svg class="animate-spin h-12 w-12 text-primary-600" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-gray-700 font-medium">
|
||||||
|
{{ initializingText }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500 text-center">
|
||||||
|
{{ t('olap.waitMessage') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import AppLayout from '@/components/Layout/AppLayout.vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useNotification } from '@/composables/useNotification';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { showNotification } = useNotification();
|
||||||
|
|
||||||
|
interface Column {
|
||||||
|
fieldKey: string;
|
||||||
|
fieldKeyNormal: string;
|
||||||
|
reportTypes: string[];
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
typeNormal: string;
|
||||||
|
aggregationAllowed: boolean;
|
||||||
|
groupingAllowed: boolean;
|
||||||
|
filteringAllowed: boolean;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Restaurant {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = ref<Column[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const initializing = ref(false);
|
||||||
|
const initModalOpen = ref(false);
|
||||||
|
const initModalTitle = ref('');
|
||||||
|
const restaurants = ref<Restaurant[]>([]);
|
||||||
|
const selectedRestaurantId = ref<number | null>(null);
|
||||||
|
|
||||||
|
const pendingRestaurantId = ref<number | null>(null);
|
||||||
|
const pendingRestaurantName = ref('');
|
||||||
|
|
||||||
|
const refreshWarningModal = ref({ show: false });
|
||||||
|
|
||||||
|
const initializingText = ref('');
|
||||||
|
const filters = ref({
|
||||||
|
fieldKey: '',
|
||||||
|
reportType: '',
|
||||||
|
tag: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const editModalOpen = ref(false);
|
||||||
|
const editForm = ref({
|
||||||
|
fieldKey: '',
|
||||||
|
name: '',
|
||||||
|
typeNormal: '',
|
||||||
|
aggregationAllowed: false,
|
||||||
|
groupingAllowed: false,
|
||||||
|
filteringAllowed: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteFieldConfirm = ref({ show: false, fieldKey: '' });
|
||||||
|
|
||||||
|
const hasData = computed(() => columns.value.length > 0);
|
||||||
|
|
||||||
|
const availableReportTypes = computed(() => {
|
||||||
|
const types = new Set<string>();
|
||||||
|
for (const col of columns.value) {
|
||||||
|
for (const rt of col.reportTypes) {
|
||||||
|
types.add(rt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(types).sort();
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableTags = computed(() => {
|
||||||
|
const tags = new Set<string>();
|
||||||
|
for (const col of columns.value) {
|
||||||
|
for (const tag of col.tags) {
|
||||||
|
tags.add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(tags).sort();
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredColumns = computed(() => {
|
||||||
|
let result = columns.value;
|
||||||
|
if (filters.value.fieldKey) {
|
||||||
|
const lowerKey = filters.value.fieldKey.toLowerCase();
|
||||||
|
result = result.filter(col => col.fieldKey.toLowerCase().includes(lowerKey));
|
||||||
|
}
|
||||||
|
if (filters.value.reportType) {
|
||||||
|
result = result.filter(col => col.reportTypes.includes(filters.value.reportType));
|
||||||
|
}
|
||||||
|
if (filters.value.tag) {
|
||||||
|
result = result.filter(col => col.tags.includes(filters.value.tag));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadColumns() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/reports/olap/columns');
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 404 || res.status === 204) {
|
||||||
|
columns.value = [];
|
||||||
|
} else {
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
columns.value = data.columns || [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
showNotification('olap.loadError', 'error');
|
||||||
|
columns.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRestaurants() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/restaurants');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
restaurants.value = data;
|
||||||
|
} else {
|
||||||
|
showNotification('restaurants.loadError', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('restaurants.loadError', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const restaurantSearch = ref('');
|
||||||
|
|
||||||
|
const filteredRestaurants = computed(() => {
|
||||||
|
if (!restaurantSearch.value) return restaurants.value;
|
||||||
|
const lower = restaurantSearch.value.toLowerCase();
|
||||||
|
return restaurants.value.filter(r =>
|
||||||
|
r.name.toLowerCase().includes(lower) ||
|
||||||
|
r.host.toLowerCase().includes(lower)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function openInitModal() {
|
||||||
|
initModalTitle.value = t('olap.selectRestaurant');
|
||||||
|
loadRestaurants().then(() => { initModalOpen.value = true; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRefreshModal() {
|
||||||
|
initModalTitle.value = t('olap.refreshStructure');
|
||||||
|
loadRestaurants().then(() => { initModalOpen.value = true; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeInitModal() {
|
||||||
|
initModalOpen.value = false;
|
||||||
|
selectedRestaurantId.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInitConfirm() {
|
||||||
|
if (!selectedRestaurantId.value) return;
|
||||||
|
const selectedRest = restaurants.value.find(r => r.id === selectedRestaurantId.value);
|
||||||
|
pendingRestaurantName.value = selectedRest ? selectedRest.name : '';
|
||||||
|
pendingRestaurantId.value = selectedRestaurantId.value;
|
||||||
|
|
||||||
|
if (hasData.value) {
|
||||||
|
closeInitModal();
|
||||||
|
refreshWarningModal.value.show = true;
|
||||||
|
} else {
|
||||||
|
executeInitialize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeInitialize() {
|
||||||
|
const id = pendingRestaurantId.value ?? selectedRestaurantId.value;
|
||||||
|
if (!id) {
|
||||||
|
showNotification('olap.selectRestaurantFirst', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
initModalOpen.value = false;
|
||||||
|
refreshWarningModal.value.show = false;
|
||||||
|
editModalOpen.value = false;
|
||||||
|
deleteFieldConfirm.value.show = false;
|
||||||
|
|
||||||
|
initializingText.value = hasData.value ? t('olap.refreshingData') : t('olap.initializingData');
|
||||||
|
initializing.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/reports/olap/initialize', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ restaurantId: id })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text();
|
||||||
|
throw new Error(errText || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
showNotification('olap.initSuccess', 'success');
|
||||||
|
await loadColumns();
|
||||||
|
} catch (error: any) {
|
||||||
|
showNotification('olap.initError', 'error', { error: error.message });
|
||||||
|
} finally {
|
||||||
|
initializing.value = false;
|
||||||
|
initializingText.value = '';
|
||||||
|
|
||||||
|
pendingRestaurantId.value = null;
|
||||||
|
pendingRestaurantName.value = '';
|
||||||
|
selectedRestaurantId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(col: Column) {
|
||||||
|
editForm.value = {
|
||||||
|
fieldKey: col.fieldKey,
|
||||||
|
name: col.name,
|
||||||
|
typeNormal: col.typeNormal,
|
||||||
|
aggregationAllowed: col.aggregationAllowed,
|
||||||
|
groupingAllowed: col.groupingAllowed,
|
||||||
|
filteringAllowed: col.filteringAllowed
|
||||||
|
};
|
||||||
|
editModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() {
|
||||||
|
editModalOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateField() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/reports/olap/columns/${encodeURIComponent(editForm.value.fieldKey)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: editForm.value.name,
|
||||||
|
typeNormal: editForm.value.typeNormal,
|
||||||
|
aggregationAllowed: editForm.value.aggregationAllowed,
|
||||||
|
groupingAllowed: editForm.value.groupingAllowed,
|
||||||
|
filteringAllowed: editForm.value.filteringAllowed
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
showNotification('olap.updateSuccess', 'success');
|
||||||
|
closeEditModal();
|
||||||
|
await loadColumns();
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('olap.updateError', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteFieldModal(fieldKey: string) {
|
||||||
|
deleteFieldConfirm.value = { show: true, fieldKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteField() {
|
||||||
|
const fieldKey = deleteFieldConfirm.value.fieldKey;
|
||||||
|
if (!fieldKey) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/reports/olap/columns/${encodeURIComponent(fieldKey)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
showNotification('olap.deleteSuccess', 'success');
|
||||||
|
deleteFieldConfirm.value.show = false;
|
||||||
|
await loadColumns();
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('olap.deleteError', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
filters.value = { fieldKey: '', reportType: '', tag: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadColumns();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.z-60 {
|
||||||
|
z-index: 60;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -84,10 +84,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted } from 'vue';
|
import { ref, reactive, computed, onMounted } from 'vue';
|
||||||
import { useUserStore } from '../stores/user';
|
import { useUserStore } from '@/stores/user';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import AppLayout from '../components/Layout/AppLayout.vue';
|
import AppLayout from '@/components/Layout/AppLayout.vue';
|
||||||
|
import {useNotification} from "@/composables/useNotification";
|
||||||
|
|
||||||
|
const { showNotification } = useNotification();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
@@ -129,7 +131,7 @@ async function saveProfile() {
|
|||||||
if (ok) {
|
if (ok) {
|
||||||
locale.value = form.language;
|
locale.value = form.language;
|
||||||
showNotification('profile.updateSuccess', 'success');
|
showNotification('profile.updateSuccess', 'success');
|
||||||
resetForm(); // очищаем поля пароля
|
resetForm();
|
||||||
} else {
|
} else {
|
||||||
showNotification('profile.updateError', 'error');
|
showNotification('profile.updateError', 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,9 +153,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import AppLayout from '../components/Layout/AppLayout.vue';
|
import AppLayout from '@/components/Layout/AppLayout.vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useNotification } from '../composables/useNotification';
|
import { useNotification } from '@/composables/useNotification';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { showNotification } = useNotification();
|
const { showNotification } = useNotification();
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
<div v-if="user.id === currentUserId" class="text-xs text-gray-500">{{ t('users.you') }}</div>
|
<div v-if="user.id === userStore.id" class="text-xs text-gray-500">{{ t('users.you') }}</div>
|
||||||
<label v-else class="relative inline-flex items-center cursor-pointer">
|
<label v-else class="relative inline-flex items-center cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="user.id !== currentUserId"
|
v-if="user.id !== userStore.id"
|
||||||
@click="confirmDelete(user.id)"
|
@click="confirmDelete(user.id)"
|
||||||
class="text-red-600 hover:text-red-800 transition-colors"
|
class="text-red-600 hover:text-red-800 transition-colors"
|
||||||
>
|
>
|
||||||
@@ -155,16 +155,15 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue';
|
import { ref, onMounted, computed } from 'vue';
|
||||||
import AppLayout from '../components/Layout/AppLayout.vue';
|
import AppLayout from '@/components/Layout/AppLayout.vue';
|
||||||
import { useUserStore } from '../stores/user';
|
import { useUserStore } from '@/stores/user';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useNotification } from '../composables/useNotification';
|
import { useNotification } from '@/composables/useNotification';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { showNotification } = useNotification();
|
const { showNotification } = useNotification();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
|
||||||
const currentUserId = ref<number | null>(null);
|
|
||||||
const users = ref<any[]>([]);
|
const users = ref<any[]>([]);
|
||||||
const modalOpen = ref(false);
|
const modalOpen = ref(false);
|
||||||
const modalMode = ref<'create' | 'edit'>('create');
|
const modalMode = ref<'create' | 'edit'>('create');
|
||||||
@@ -173,23 +172,9 @@ const modalTitle = ref('');
|
|||||||
const deleteConfirm = ref({ show: false, id: null });
|
const deleteConfirm = ref({ show: false, id: null });
|
||||||
|
|
||||||
const isEditingSelf = computed(() => {
|
const isEditingSelf = computed(() => {
|
||||||
return modalMode.value === 'edit' && form.value.id === currentUserId.value;
|
return modalMode.value === 'edit' && form.value.id === userStore.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadCurrentUser() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin/me');
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
currentUserId.value = data.id;
|
|
||||||
} else {
|
|
||||||
showNotification('users.loadCurrentError', 'error');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showNotification('common.networkError', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/users');
|
const res = await fetch('/api/admin/users');
|
||||||
@@ -297,7 +282,6 @@ async function deleteUser(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadCurrentUser();
|
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -103,8 +103,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useSettingsStore } from '../../stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const settings = useSettingsStore()
|
const settings = useSettingsStore()
|
||||||
|
|||||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"jsxImportSource": "vue",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -3,6 +3,11 @@ import vue from '@vitejs/plugin-vue'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': '/src',
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:8080' // для разработки
|
'/api': 'http://localhost:8080' // для разработки
|
||||||
|
|||||||
BIN
libs/RoaringBitmap-1.0.6.jar
Normal file
BIN
libs/RoaringBitmap-1.0.6.jar
Normal file
Binary file not shown.
BIN
libs/antlr4-runtime-4.13.2.jar
Normal file
BIN
libs/antlr4-runtime-4.13.2.jar
Normal file
Binary file not shown.
BIN
libs/asm-9.7.jar
Normal file
BIN
libs/asm-9.7.jar
Normal file
Binary file not shown.
BIN
libs/clickhouse-client-0.9.8.jar
Normal file
BIN
libs/clickhouse-client-0.9.8.jar
Normal file
Binary file not shown.
BIN
libs/clickhouse-data-0.9.8.jar
Normal file
BIN
libs/clickhouse-data-0.9.8.jar
Normal file
Binary file not shown.
BIN
libs/clickhouse-http-client-0.9.8.jar
Normal file
BIN
libs/clickhouse-http-client-0.9.8.jar
Normal file
Binary file not shown.
BIN
libs/clickhouse-jdbc-0.9.8.jar
Normal file
BIN
libs/clickhouse-jdbc-0.9.8.jar
Normal file
Binary file not shown.
BIN
libs/client-v2-0.9.8.jar
Normal file
BIN
libs/client-v2-0.9.8.jar
Normal file
Binary file not shown.
BIN
libs/commons-codec-1.19.0.jar
Normal file
BIN
libs/commons-codec-1.19.0.jar
Normal file
Binary file not shown.
BIN
libs/commons-compress-1.28.0.jar
Normal file
BIN
libs/commons-compress-1.28.0.jar
Normal file
Binary file not shown.
BIN
libs/commons-io-2.20.0.jar
Normal file
BIN
libs/commons-io-2.20.0.jar
Normal file
Binary file not shown.
BIN
libs/commons-lang3-3.20.0.jar
Normal file
BIN
libs/commons-lang3-3.20.0.jar
Normal file
Binary file not shown.
BIN
libs/error_prone_annotations-2.36.0.jar
Normal file
BIN
libs/error_prone_annotations-2.36.0.jar
Normal file
Binary file not shown.
BIN
libs/failureaccess-1.0.3.jar
Normal file
BIN
libs/failureaccess-1.0.3.jar
Normal file
Binary file not shown.
BIN
libs/guava-33.4.6-jre.jar
Normal file
BIN
libs/guava-33.4.6-jre.jar
Normal file
Binary file not shown.
BIN
libs/httpclient5-5.4.4.jar
Normal file
BIN
libs/httpclient5-5.4.4.jar
Normal file
Binary file not shown.
BIN
libs/httpcore5-5.3.4.jar
Normal file
BIN
libs/httpcore5-5.3.4.jar
Normal file
Binary file not shown.
BIN
libs/httpcore5-h2-5.3.4.jar
Normal file
BIN
libs/httpcore5-h2-5.3.4.jar
Normal file
Binary file not shown.
BIN
libs/j2objc-annotations-3.0.0.jar
Normal file
BIN
libs/j2objc-annotations-3.0.0.jar
Normal file
Binary file not shown.
BIN
libs/jdbc-v2-0.9.8.jar
Normal file
BIN
libs/jdbc-v2-0.9.8.jar
Normal file
Binary file not shown.
Binary file not shown.
BIN
libs/lz4-java-1.10.4.jar
Normal file
BIN
libs/lz4-java-1.10.4.jar
Normal file
Binary file not shown.
BIN
libs/mysql-connector-j-9.7.0.jar
Normal file
BIN
libs/mysql-connector-j-9.7.0.jar
Normal file
Binary file not shown.
BIN
libs/postgresql-42.7.11.jar
Normal file
BIN
libs/postgresql-42.7.11.jar
Normal file
Binary file not shown.
BIN
libs/protobuf-java-4.31.1.jar
Normal file
BIN
libs/protobuf-java-4.31.1.jar
Normal file
Binary file not shown.
BIN
libs/vertx-jdbc-client-5.0.11.jar
Normal file
BIN
libs/vertx-jdbc-client-5.0.11.jar
Normal file
Binary file not shown.
@@ -6,25 +6,29 @@ import io.vertx.config.ConfigStoreOptions;
|
|||||||
import io.vertx.core.AbstractVerticle;
|
import io.vertx.core.AbstractVerticle;
|
||||||
import io.vertx.core.Future;
|
import io.vertx.core.Future;
|
||||||
import io.vertx.core.Promise;
|
import io.vertx.core.Promise;
|
||||||
|
import io.vertx.core.buffer.Buffer;
|
||||||
import io.vertx.core.http.HttpServer;
|
import io.vertx.core.http.HttpServer;
|
||||||
import io.vertx.core.json.JsonObject;
|
import io.vertx.core.json.JsonObject;
|
||||||
import io.vertx.ext.web.Router;
|
import io.vertx.ext.web.Router;
|
||||||
|
import io.vertx.ext.web.RoutingContext;
|
||||||
|
import io.vertx.ext.web.client.HttpRequest;
|
||||||
|
import io.vertx.ext.web.client.HttpResponse;
|
||||||
|
import io.vertx.ext.web.client.WebClient;
|
||||||
import io.vertx.ext.web.handler.BodyHandler;
|
import io.vertx.ext.web.handler.BodyHandler;
|
||||||
import io.vertx.ext.web.handler.SessionHandler;
|
import io.vertx.ext.web.handler.SessionHandler;
|
||||||
import io.vertx.ext.web.handler.StaticHandler;
|
import io.vertx.ext.web.handler.StaticHandler;
|
||||||
import io.vertx.ext.web.sstore.LocalSessionStore;
|
|
||||||
import io.vertx.ext.web.sstore.SessionStore;
|
import io.vertx.ext.web.sstore.SessionStore;
|
||||||
|
|
||||||
|
import io.vertx.ext.web.sstore.redis.RedisSessionStore;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import su.xserver.iikocon.config.AppConfig;
|
import su.xserver.iikocon.config.AppConfig;
|
||||||
import su.xserver.iikocon.handler.AdminHandler;
|
import su.xserver.iikocon.handler.*;
|
||||||
import su.xserver.iikocon.handler.AuthHandler;
|
import su.xserver.iikocon.iiko.IikoHandler;
|
||||||
import su.xserver.iikocon.handler.SecurityHandler;
|
|
||||||
import su.xserver.iikocon.handler.SetupHandler;
|
|
||||||
import su.xserver.iikocon.iiko.IikoOlapClient;
|
import su.xserver.iikocon.iiko.IikoOlapClient;
|
||||||
import su.xserver.iikocon.service.*;
|
import su.xserver.iikocon.service.*;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -36,13 +40,18 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
private RedisService redis;
|
private RedisService redis;
|
||||||
private HttpServer httpServer;
|
private HttpServer httpServer;
|
||||||
private AppConfig config;
|
private AppConfig config;
|
||||||
|
private SessionStore sessionStore;
|
||||||
|
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
private RestaurantService restaurantService;
|
private RestaurantService restaurantService;
|
||||||
|
private ExternalDataBaseService externalDataBaseService;
|
||||||
private SettingsService settingsService;
|
private SettingsService settingsService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void start(Promise<Void> startPromise) {
|
public void start(Promise<Void> startPromise) throws ClassNotFoundException {
|
||||||
|
|
||||||
|
Class.forName("com.mysql.cj.jdbc.Driver");
|
||||||
|
Class.forName("org.postgresql.Driver");
|
||||||
|
|
||||||
ConfigStoreOptions classpathStore = new ConfigStoreOptions()
|
ConfigStoreOptions classpathStore = new ConfigStoreOptions()
|
||||||
.setType("file")
|
.setType("file")
|
||||||
@@ -62,12 +71,11 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
db = new DataBaseService(vertx, config.database);
|
db = new DataBaseService(vertx, config.database);
|
||||||
redis = new RedisService(vertx, config.redis);
|
redis = new RedisService(vertx, config.redis);
|
||||||
|
|
||||||
// Инициализация сервисов
|
|
||||||
userService = new UserService(db.getPool());
|
userService = new UserService(db.getPool());
|
||||||
restaurantService = new RestaurantService(db.getPool());
|
restaurantService = new RestaurantService(db.getPool());
|
||||||
settingsService = new SettingsService(db.getPool());
|
settingsService = new SettingsService(db.getPool());
|
||||||
|
externalDataBaseService = new ExternalDataBaseService(db.getPool(), vertx);
|
||||||
|
|
||||||
// Инициализация БД (создание таблицы users)
|
|
||||||
userService.initDatabase().onFailure(err -> {
|
userService.initDatabase().onFailure(err -> {
|
||||||
log.error("Failed to initialize database", err);
|
log.error("Failed to initialize database", err);
|
||||||
startPromise.fail(err);
|
startPromise.fail(err);
|
||||||
@@ -80,6 +88,10 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
log.error("Failed to initialize database", err);
|
log.error("Failed to initialize database", err);
|
||||||
startPromise.fail(err);
|
startPromise.fail(err);
|
||||||
});
|
});
|
||||||
|
externalDataBaseService.initDatabase().onFailure(err -> {
|
||||||
|
log.error("Failed to initialize database", err);
|
||||||
|
startPromise.fail(err);
|
||||||
|
});
|
||||||
|
|
||||||
createRouterAndStartHttp(startPromise);
|
createRouterAndStartHttp(startPromise);
|
||||||
|
|
||||||
@@ -99,7 +111,7 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
}
|
}
|
||||||
long timeoutMs = timeoutMinutes * 60 * 1000;
|
long timeoutMs = timeoutMinutes * 60 * 1000;
|
||||||
|
|
||||||
SessionStore sessionStore = LocalSessionStore.create(vertx);
|
sessionStore = RedisSessionStore.create(vertx, redis.getRedis());
|
||||||
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
|
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
|
||||||
.setSessionCookieName("admin.session")
|
.setSessionCookieName("admin.session")
|
||||||
.setCookieHttpOnlyFlag(true)
|
.setCookieHttpOnlyFlag(true)
|
||||||
@@ -116,15 +128,110 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void setupPhpmyadminProxy(Router router) {
|
||||||
|
if (config.pma == null || !config.pma.enabled) return;
|
||||||
|
|
||||||
|
String upstream = config.pma.upstream;
|
||||||
|
String basePath = config.pma.basePath;
|
||||||
|
|
||||||
|
final URI upstreamUri = URI.create(upstream);
|
||||||
|
final String host = upstreamUri.getHost();
|
||||||
|
int portTmp = upstreamUri.getPort();
|
||||||
|
if (portTmp == -1) {
|
||||||
|
portTmp = "https".equals(upstreamUri.getScheme()) ? 443 : 80;
|
||||||
|
}
|
||||||
|
final int port = portTmp;
|
||||||
|
|
||||||
|
final WebClient webClient = WebClient.create(vertx);
|
||||||
|
|
||||||
|
router.route(basePath + "/*").handler(ctx -> {
|
||||||
|
if (ctx.session() != null && "admin".equals(ctx.session().get("role"))) {
|
||||||
|
ctx.next();
|
||||||
|
} else {
|
||||||
|
ctx.response().putHeader("Location", "/").setStatusCode(302).end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.route(basePath + "/*").handler(ctx -> {
|
||||||
|
String targetPathBase = ctx.request().path().substring(basePath.length());
|
||||||
|
if (targetPathBase.isEmpty()) targetPathBase = "/";
|
||||||
|
String targetPath = targetPathBase;
|
||||||
|
String query = ctx.request().query();
|
||||||
|
if (query != null && !query.isEmpty()) {
|
||||||
|
targetPath += "?" + query;
|
||||||
|
}
|
||||||
|
final String targetPathFinal = targetPath;
|
||||||
|
|
||||||
|
final HttpRequest<Buffer> proxyReq = webClient.request(
|
||||||
|
ctx.request().method(), port, host, targetPathFinal
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.request().headers().forEach(header -> {
|
||||||
|
if (!"host".equalsIgnoreCase(header.getKey())) {
|
||||||
|
proxyReq.putHeader(header.getKey(), header.getValue());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
proxyReq.putHeader("Host", host + ":" + port);
|
||||||
|
|
||||||
|
ctx.request().bodyHandler(body -> {
|
||||||
|
if (body != null && body.length() > 0) {
|
||||||
|
proxyReq.sendBuffer(body)
|
||||||
|
.onSuccess(resp -> sendResponse(ctx, resp))
|
||||||
|
.onFailure(err -> sendError(ctx, err));
|
||||||
|
} else {
|
||||||
|
proxyReq.send()
|
||||||
|
.onSuccess(resp -> sendResponse(ctx, resp))
|
||||||
|
.onFailure(err -> sendError(ctx, err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendResponse(RoutingContext ctx, HttpResponse<Buffer> resp) {
|
||||||
|
ctx.response().setStatusCode(resp.statusCode());
|
||||||
|
resp.headers().forEach(h -> ctx.response().putHeader(h.getKey(), h.getValue()));
|
||||||
|
ctx.response().end(resp.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendError(RoutingContext ctx, Throwable err) {
|
||||||
|
log.error("Proxy error: {}", err.getMessage());
|
||||||
|
ctx.response().setStatusCode(502).end("Bad Gateway: " + err.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
private Router initRouter(SessionHandler sessionHandler) {
|
private Router initRouter(SessionHandler sessionHandler) {
|
||||||
|
|
||||||
Router router = Router.router(vertx);
|
Router router = Router.router(vertx);
|
||||||
router.route().handler(BodyHandler.create());
|
|
||||||
|
router.route().handler(ctx -> {
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
String method = ctx.request().method().name();
|
||||||
|
String path = ctx.request().path();
|
||||||
|
final String remoteIp = ctx.get("realClientIp") != null ?
|
||||||
|
ctx.get("realClientIp") :
|
||||||
|
ctx.request().remoteAddress().host();
|
||||||
|
ctx.addBodyEndHandler(v -> {
|
||||||
|
long duration = System.currentTimeMillis() - start;
|
||||||
|
log.info("{} {} - {} ms - {} - {}",
|
||||||
|
method, path, duration, ctx.response().getStatusCode(), remoteIp);
|
||||||
|
});
|
||||||
|
ctx.next();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.route().handler(ctx -> {
|
||||||
|
String path = ctx.request().path();
|
||||||
|
if (path != null && path.startsWith(config.pma.basePath + "/")) {
|
||||||
|
ctx.next(); // пропускаем BodyHandler для прокси
|
||||||
|
} else {
|
||||||
|
BodyHandler.create().handle(ctx);
|
||||||
|
}
|
||||||
|
});
|
||||||
router.route().handler(sessionHandler);
|
router.route().handler(sessionHandler);
|
||||||
|
|
||||||
|
setupPhpmyadminProxy(router);
|
||||||
|
|
||||||
SecurityHandler securityHandlers = new SecurityHandler(settingsService);
|
SecurityHandler securityHandlers = new SecurityHandler(settingsService);
|
||||||
|
|
||||||
// Обработчики безопасности (порядок важен)
|
// Обработчики безопасности
|
||||||
router.route().handler(securityHandlers.hostValidator());
|
router.route().handler(securityHandlers.hostValidator());
|
||||||
router.route().handler(securityHandlers.proxyHeadersHandler());
|
router.route().handler(securityHandlers.proxyHeadersHandler());
|
||||||
router.route().handler(securityHandlers.cspHeader());
|
router.route().handler(securityHandlers.cspHeader());
|
||||||
@@ -160,6 +267,13 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Rate Limiter Handler
|
||||||
|
RedisRateLimiter limiter = new RedisRateLimiter(
|
||||||
|
redis.getRedis(), 60, 60_000
|
||||||
|
);
|
||||||
|
|
||||||
|
router.route().handler(limiter);
|
||||||
|
|
||||||
// Health Checks
|
// Health Checks
|
||||||
HealthCheckService healthCheckService = new HealthCheckService(vertx, redis, db);
|
HealthCheckService healthCheckService = new HealthCheckService(vertx, redis, db);
|
||||||
healthCheckService.registerHealthCheck(router);
|
healthCheckService.registerHealthCheck(router);
|
||||||
@@ -181,7 +295,6 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
rc.response().setStatusCode(403).end(new JsonObject().put("error", "Registration is disabled").encode());
|
rc.response().setStatusCode(403).end(new JsonObject().put("error", "Registration is disabled").encode());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// существующий код регистрации
|
|
||||||
JsonObject body = rc.body().asJsonObject();
|
JsonObject body = rc.body().asJsonObject();
|
||||||
String login = body.getString("login");
|
String login = body.getString("login");
|
||||||
String email = body.getString("email");
|
String email = body.getString("email");
|
||||||
@@ -196,15 +309,14 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// В initRouter после настройки authHandler, до объявления /api/admin/*:
|
router.route("/api/profile").handler(authHandler::requireAuth);
|
||||||
router.route("/api/admin/profile").handler(authHandler::requireAuth);
|
router.get("/api/profile").handler(rc -> {
|
||||||
router.get("/api/admin/profile").handler(rc -> {
|
|
||||||
Integer userId = rc.session().get("userId");
|
Integer userId = rc.session().get("userId");
|
||||||
userService.getProfile(userId)
|
userService.getProfile(userId)
|
||||||
.onSuccess(profile -> rc.response().putHeader("Content-Type", "application/json").end(profile.encode()))
|
.onSuccess(profile -> rc.response().putHeader("Content-Type", "application/json").end(profile.encode()))
|
||||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||||
});
|
});
|
||||||
router.put("/api/admin/profile").handler(rc -> {
|
router.put("/api/profile").handler(rc -> {
|
||||||
Integer userId = rc.session().get("userId");
|
Integer userId = rc.session().get("userId");
|
||||||
JsonObject body = rc.body().asJsonObject();
|
JsonObject body = rc.body().asJsonObject();
|
||||||
String email = body.getString("email");
|
String email = body.getString("email");
|
||||||
@@ -217,30 +329,8 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
})
|
})
|
||||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||||
});
|
});
|
||||||
router.put("/api/admin/language").handler(rc -> {
|
|
||||||
Integer userId = rc.session().get("userId");
|
|
||||||
JsonObject body = rc.body().asJsonObject();
|
|
||||||
String language = body.getString("language");
|
|
||||||
if (language == null || (!"en".equals(language) && !"ru".equals(language))) {
|
|
||||||
rc.response().setStatusCode(400).end("Invalid language");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
userService.updateLanguage(userId, language)
|
|
||||||
.onSuccess(v -> {
|
|
||||||
rc.session().put("language", language);
|
|
||||||
rc.response().end(new JsonObject().put("success", true).encode());
|
|
||||||
})
|
|
||||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Затем существующий блок router.route("/api/admin/*").handler(authHandler::requireAuth);
|
|
||||||
router.route("/api/admin/*").handler(authHandler::requireAuth);
|
router.route("/api/admin/*").handler(authHandler::requireAuth);
|
||||||
// Добавить проверку роли для чувствительных эндпоинтов:
|
|
||||||
// router.route("/api/admin/users*").handler(AdminHandler::requireAdmin);
|
|
||||||
// router.route("/api/admin/restaurants*").handler(AdminHandler::requireAdmin);
|
|
||||||
// router.route("/api/admin/settings*").handler(AdminHandler::requireAdmin);
|
|
||||||
// router.route("/api/admin/active-sessions").handler(AdminHandler::requireAdmin);
|
|
||||||
|
|
||||||
router.get("/api/admin/users").handler(rc -> userService.getAllUsers().onComplete(ar -> {
|
router.get("/api/admin/users").handler(rc -> userService.getAllUsers().onComplete(ar -> {
|
||||||
if (ar.succeeded()) {
|
if (ar.succeeded()) {
|
||||||
rc.response()
|
rc.response()
|
||||||
@@ -251,6 +341,7 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
router.route("/api/admin/users*").handler(AdminHandler::requireAdmin);
|
||||||
router.post("/api/admin/users").handler(rc -> {
|
router.post("/api/admin/users").handler(rc -> {
|
||||||
JsonObject body = rc.body().asJsonObject();
|
JsonObject body = rc.body().asJsonObject();
|
||||||
String login = body.getString("login");
|
String login = body.getString("login");
|
||||||
@@ -316,21 +407,6 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Получение текущего пользователя
|
|
||||||
router.get("/api/admin/me").handler(rc -> {
|
|
||||||
Integer userId = rc.session().get("userId");
|
|
||||||
if (userId != null) {
|
|
||||||
rc.response()
|
|
||||||
.putHeader("Content-Type", "application/json")
|
|
||||||
.end(new JsonObject()
|
|
||||||
.put("id", userId)
|
|
||||||
.put("login", rc.session().get("login"))
|
|
||||||
.encode());
|
|
||||||
} else {
|
|
||||||
rc.response().setStatusCode(401).end();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/api/admin/restaurants").handler(rc -> restaurantService.getAllRestaurants().onComplete(ar -> {
|
router.get("/api/admin/restaurants").handler(rc -> restaurantService.getAllRestaurants().onComplete(ar -> {
|
||||||
if (ar.succeeded()) {
|
if (ar.succeeded()) {
|
||||||
rc.response()
|
rc.response()
|
||||||
@@ -409,28 +485,25 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Получение всех настроек
|
|
||||||
router.get("/api/settings").handler(rc -> {
|
router.get("/api/settings").handler(rc -> {
|
||||||
settingsService.getPublicSettings()
|
settingsService.getPublicSettings()
|
||||||
.onSuccess(settings -> rc.response().putHeader("Content-Type", "application/json").end(settings.encode()))
|
.onSuccess(settings -> rc.response().putHeader("Content-Type", "application/json").end(settings.encode()))
|
||||||
.onFailure(err -> rc.response().setStatusCode(500).end());
|
.onFailure(err -> rc.response().setStatusCode(500).end());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Получить метаданные всех настроек (для построения формы)
|
router.route("/api/admin/settings*").handler(AdminHandler::requireAdmin);
|
||||||
router.get("/api/settings/meta").handler(rc -> {
|
router.get("/api/admin/settings/meta").handler(rc -> {
|
||||||
settingsService.getMetadata()
|
settingsService.getMetadata()
|
||||||
.onSuccess(meta -> rc.response().putHeader("Content-Type", "application/json").end(meta.encode()))
|
.onSuccess(meta -> rc.response().putHeader("Content-Type", "application/json").end(meta.encode()))
|
||||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Получить все настройки со значениями по умолчанию
|
router.get("/api/admin/settings").handler(rc -> {
|
||||||
router.get("/api/settings/all").handler(rc -> {
|
|
||||||
settingsService.getAllWithDefaults()
|
settingsService.getAllWithDefaults()
|
||||||
.onSuccess(settings -> rc.response().putHeader("Content-Type", "application/json").end(settings.encode()))
|
.onSuccess(settings -> rc.response().putHeader("Content-Type", "application/json").end(settings.encode()))
|
||||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Обновление настроек (админ)
|
|
||||||
router.put("/api/admin/settings").handler(rc -> {
|
router.put("/api/admin/settings").handler(rc -> {
|
||||||
JsonObject body = rc.body().asJsonObject();
|
JsonObject body = rc.body().asJsonObject();
|
||||||
List<Future<Void>> futures = new ArrayList<>(); // явно указываем тип Future<Void>
|
List<Future<Void>> futures = new ArrayList<>(); // явно указываем тип Future<Void>
|
||||||
@@ -440,17 +513,14 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Количество активных сессий (на основе Redis)
|
externalDataBaseService.handleRoute(router);
|
||||||
router.get("/api/admin/active-sessions").handler(rc -> {
|
|
||||||
// TODO: реализовать подсчёт активных сессий через Redis или другой механизм
|
new IikoHandler(vertx, router, db, restaurantService, authHandler);
|
||||||
rc.response().end(new JsonObject().put("count", 0).encode());
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startHttp(Router router, Promise<Void> startPromise) {
|
private void startHttp(Router router, Promise<Void> startPromise) {
|
||||||
// Запуск HTTP-сервера
|
|
||||||
httpServer = vertx.createHttpServer();
|
httpServer = vertx.createHttpServer();
|
||||||
httpServer.requestHandler(router).listen(config.server.port, config.server.host)
|
httpServer.requestHandler(router).listen(config.server.port, config.server.host)
|
||||||
.onSuccess(server -> {
|
.onSuccess(server -> {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public class AppConfig {
|
|||||||
public ServerConfig server;
|
public ServerConfig server;
|
||||||
public DatabaseConfig database;
|
public DatabaseConfig database;
|
||||||
public RedisConfig redis;
|
public RedisConfig redis;
|
||||||
|
public PhpMyAdminConfig pma;
|
||||||
|
|
||||||
public static AppConfig from(JsonObject json) {
|
public static AppConfig from(JsonObject json) {
|
||||||
JsonObject resolved = json.copy();
|
JsonObject resolved = json.copy();
|
||||||
@@ -94,7 +95,8 @@ public class AppConfig {
|
|||||||
return new JsonObject()
|
return new JsonObject()
|
||||||
.put("server", server.json().getJsonObject("server"))
|
.put("server", server.json().getJsonObject("server"))
|
||||||
.put("database", database.json().getJsonObject("database"))
|
.put("database", database.json().getJsonObject("database"))
|
||||||
.put("redis", redis.json().getJsonObject("redis"));
|
.put("redis", redis.json().getJsonObject("redis"))
|
||||||
|
.put("pma", pma.json().getJsonObject("pma"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package su.xserver.iikocon.config;
|
||||||
|
|
||||||
|
import io.vertx.core.json.JsonObject;
|
||||||
|
|
||||||
|
public class PhpMyAdminConfig {
|
||||||
|
public boolean enabled;
|
||||||
|
public String upstream;
|
||||||
|
public String basePath;
|
||||||
|
|
||||||
|
public JsonObject json() {
|
||||||
|
return new JsonObject()
|
||||||
|
.put("pma", new JsonObject()
|
||||||
|
.put("enabled", enabled)
|
||||||
|
.put("upstream", upstream)
|
||||||
|
.put("basePath", basePath)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
185
src/main/java/su/xserver/iikocon/handler/RedisRateLimiter.java
Normal file
185
src/main/java/su/xserver/iikocon/handler/RedisRateLimiter.java
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package su.xserver.iikocon.handler;
|
||||||
|
|
||||||
|
import io.vertx.core.Handler;
|
||||||
|
import io.vertx.core.json.JsonObject;
|
||||||
|
import io.vertx.ext.web.RoutingContext;
|
||||||
|
import io.vertx.redis.client.Command;
|
||||||
|
import io.vertx.redis.client.Redis;
|
||||||
|
import io.vertx.redis.client.Request;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.NavigableMap;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
public class RedisRateLimiter implements Handler<RoutingContext> {
|
||||||
|
|
||||||
|
private final Logger logger;
|
||||||
|
private final Redis redis;
|
||||||
|
private final int limitPerWindow;
|
||||||
|
private final long windowMillis;
|
||||||
|
private static final String PREFIX = "ip:limit:";
|
||||||
|
|
||||||
|
// Основной кэш: clientKey -> время окончания блокировки
|
||||||
|
private final ConcurrentHashMap<String, Long> blockedClients = new ConcurrentHashMap<>();
|
||||||
|
// Индекс по времени: время окончания -> множество клиентов
|
||||||
|
private final ConcurrentSkipListMap<Long, Set<String>> expiryIndex = new ConcurrentSkipListMap<>();
|
||||||
|
private final ScheduledExecutorService cleaner = Executors.newSingleThreadScheduledExecutor();
|
||||||
|
|
||||||
|
private final AtomicLong allowedRequests = new AtomicLong(0);
|
||||||
|
private final AtomicLong blockedRequests = new AtomicLong(0);
|
||||||
|
private final AtomicLong redisCalls = new AtomicLong(0);
|
||||||
|
private final AtomicLong redisFailures = new AtomicLong(0);
|
||||||
|
private final AtomicLong totalRedisLatency = new AtomicLong(0);
|
||||||
|
private final AtomicLong redisLatencyCount = new AtomicLong(0);
|
||||||
|
|
||||||
|
// Частота блокировок по IP
|
||||||
|
private final ConcurrentHashMap<String, AtomicLong> blockedByClient = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public RedisRateLimiter(Redis redis, int limitPerWindow, long windowMillis) {
|
||||||
|
this.logger = LoggerFactory.getLogger("[RedisRateLimiter]");
|
||||||
|
this.redis = redis;
|
||||||
|
this.limitPerWindow = limitPerWindow;
|
||||||
|
this.windowMillis = windowMillis;
|
||||||
|
|
||||||
|
// Периодическая очистка только истёкших блокировок
|
||||||
|
cleaner.scheduleAtFixedRate(this::cleanupExpiredClients, windowMillis, windowMillis / 2, TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handle(RoutingContext context) {
|
||||||
|
String clientKey = getClientKey(context);
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
|
||||||
|
// Проверяем локальную блокировку
|
||||||
|
Long blockedUntil = blockedClients.get(clientKey);
|
||||||
|
if (blockedUntil != null) {
|
||||||
|
if (blockedUntil > now) {
|
||||||
|
blockedRequests.incrementAndGet();
|
||||||
|
incrementBlockCount(clientKey);
|
||||||
|
sendTooManyRequests(context);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
unblockClient(clientKey, blockedUntil);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String redisKey = PREFIX + clientKey;
|
||||||
|
checkRateLimit(context, redisKey, clientKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkRateLimit(RoutingContext context, String redisKey, String clientKey) {
|
||||||
|
String luaScript = """
|
||||||
|
local key = KEYS[1]
|
||||||
|
local limit = tonumber(ARGV[1])
|
||||||
|
local ttl = tonumber(ARGV[2])
|
||||||
|
|
||||||
|
local current = redis.call('INCR', key)
|
||||||
|
if current == 1 then
|
||||||
|
redis.call('PEXPIRE', key, ttl)
|
||||||
|
end
|
||||||
|
|
||||||
|
if current > limit then
|
||||||
|
return 'TOO_MANY_REQUESTS'
|
||||||
|
else
|
||||||
|
return 'OK'
|
||||||
|
end
|
||||||
|
""";
|
||||||
|
|
||||||
|
redisCalls.incrementAndGet();
|
||||||
|
long start = System.nanoTime();
|
||||||
|
|
||||||
|
Request request = Request.cmd(Command.EVAL)
|
||||||
|
.arg(luaScript)
|
||||||
|
.arg(1)
|
||||||
|
.arg(redisKey)
|
||||||
|
.arg(limitPerWindow)
|
||||||
|
.arg(windowMillis);
|
||||||
|
|
||||||
|
redis.send(request)
|
||||||
|
.onSuccess(response -> {
|
||||||
|
long duration = System.nanoTime() - start;
|
||||||
|
redisLatencyCount.incrementAndGet();
|
||||||
|
totalRedisLatency.addAndGet(TimeUnit.NANOSECONDS.toMillis(duration));
|
||||||
|
|
||||||
|
String result = response.toString();
|
||||||
|
if ("TOO_MANY_REQUESTS".equals(result)) {
|
||||||
|
blockClient(clientKey);
|
||||||
|
blockedRequests.incrementAndGet();
|
||||||
|
incrementBlockCount(clientKey);
|
||||||
|
sendTooManyRequests(context);
|
||||||
|
} else {
|
||||||
|
allowedRequests.incrementAndGet();
|
||||||
|
context.next();
|
||||||
|
}
|
||||||
|
}).onFailure(error -> {
|
||||||
|
redisFailures.incrementAndGet();
|
||||||
|
context.response()
|
||||||
|
.setStatusCode(503)
|
||||||
|
.putHeader("Content-Type", "application/json")
|
||||||
|
.end(new JsonObject()
|
||||||
|
.put("error", "503 Service Unavailable")
|
||||||
|
.put("message", "Redis is not connected")
|
||||||
|
.encodePrettily()
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.error(error.getMessage());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void blockClient(String clientKey) {
|
||||||
|
long blockedUntil = System.currentTimeMillis() + windowMillis;
|
||||||
|
blockedClients.put(clientKey, blockedUntil);
|
||||||
|
expiryIndex.computeIfAbsent(blockedUntil, t -> ConcurrentHashMap.newKeySet()).add(clientKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void unblockClient(String clientKey, long expiryTime) {
|
||||||
|
blockedClients.remove(clientKey);
|
||||||
|
Set<String> clients = expiryIndex.get(expiryTime);
|
||||||
|
if (clients != null) {
|
||||||
|
clients.remove(clientKey);
|
||||||
|
if (clients.isEmpty()) {
|
||||||
|
expiryIndex.remove(expiryTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void incrementBlockCount(String clientKey) {
|
||||||
|
blockedByClient.computeIfAbsent(clientKey, k -> new AtomicLong(0)).incrementAndGet();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanupExpiredClients() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
// Получаем все записи, у которых время истечения <= now
|
||||||
|
NavigableMap<Long, Set<String>> expired = expiryIndex.headMap(now, true);
|
||||||
|
|
||||||
|
if (expired.isEmpty()) return;
|
||||||
|
|
||||||
|
for (Map.Entry<Long, Set<String>> entry : expired.entrySet()) {
|
||||||
|
Set<String> clients = entry.getValue();
|
||||||
|
for (String client : clients) {
|
||||||
|
blockedClients.remove(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expired.clear(); // очищаем диапазон из индекса
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendTooManyRequests(RoutingContext context) {
|
||||||
|
context.response()
|
||||||
|
.setStatusCode(429)
|
||||||
|
.putHeader("Content-Type", "application/json")
|
||||||
|
.end(new JsonObject()
|
||||||
|
.put("error", "429 Too Many Requests")
|
||||||
|
.put("message", "Try again later")
|
||||||
|
.encodePrettily()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getClientKey(RoutingContext context) {
|
||||||
|
return context.request().remoteAddress().host().replace(':', '.');
|
||||||
|
}
|
||||||
|
}
|
||||||
266
src/main/java/su/xserver/iikocon/iiko/IikoHandler.java
Normal file
266
src/main/java/su/xserver/iikocon/iiko/IikoHandler.java
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
package su.xserver.iikocon.iiko;
|
||||||
|
|
||||||
|
import io.vertx.core.Future;
|
||||||
|
import io.vertx.core.Vertx;
|
||||||
|
import io.vertx.core.json.JsonArray;
|
||||||
|
import io.vertx.core.json.JsonObject;
|
||||||
|
import io.vertx.ext.web.Router;
|
||||||
|
import io.vertx.ext.web.RoutingContext;
|
||||||
|
import io.vertx.sqlclient.Row;
|
||||||
|
import io.vertx.sqlclient.Tuple;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import su.xserver.iikocon.handler.AdminHandler;
|
||||||
|
import su.xserver.iikocon.handler.AuthHandler;
|
||||||
|
import su.xserver.iikocon.service.DataBaseService;
|
||||||
|
import su.xserver.iikocon.service.RestaurantService;
|
||||||
|
|
||||||
|
public class IikoHandler {
|
||||||
|
|
||||||
|
private final Logger log = LoggerFactory.getLogger("[IikoHandler]");
|
||||||
|
private final DataBaseService db;
|
||||||
|
private final Vertx vertx;
|
||||||
|
private final RestaurantService restaurantService;
|
||||||
|
|
||||||
|
public IikoHandler(Vertx vertx, Router router, DataBaseService db, RestaurantService restaurantService, AuthHandler authHandler) {
|
||||||
|
this.vertx = vertx;
|
||||||
|
this.restaurantService = restaurantService;
|
||||||
|
this.db = db;
|
||||||
|
|
||||||
|
createTablesIfNotExist().onFailure(err -> {
|
||||||
|
log.error("Failed to initialize database", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.route("/api/reports/olap/*").handler(authHandler::requireAuth);
|
||||||
|
router.get("/api/reports/olap/columns").handler(this::getColumns);
|
||||||
|
router.delete("/api/reports/olap/columns/:fieldKey").handler(AdminHandler::requireAdmin).handler(this::deleteColumn);
|
||||||
|
router.post("/api/reports/olap/initialize").handler(AdminHandler::requireAdmin).handler(this::postInitialize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void getColumns(RoutingContext ctx) {
|
||||||
|
getAllFieldsWithReportAndTags()
|
||||||
|
.onSuccess(ar -> ctx.response()
|
||||||
|
.putHeader("Content-Type", "application/json")
|
||||||
|
.end(ar.encodePrettily()))
|
||||||
|
.onFailure(err -> ctx.response()
|
||||||
|
.setStatusCode(500)
|
||||||
|
.end(err.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteColumn(RoutingContext ctx) {
|
||||||
|
String fieldKey = ctx.pathParam("fieldKey");
|
||||||
|
String sql = "DELETE FROM iiko_fields_common WHERE field_key = ?";
|
||||||
|
|
||||||
|
db.getPool().preparedQuery(sql)
|
||||||
|
.execute(Tuple.of(fieldKey))
|
||||||
|
.onSuccess(res -> {
|
||||||
|
ctx.end();
|
||||||
|
})
|
||||||
|
.onFailure(err -> ctx.response().setStatusCode(500).end(err.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void postInitialize(RoutingContext ctx) {
|
||||||
|
JsonObject body = ctx.body().asJsonObject();
|
||||||
|
|
||||||
|
if (body == null) {
|
||||||
|
ctx.response()
|
||||||
|
.setStatusCode(400)
|
||||||
|
.end("Request body is missing or not a JSON object");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.containsKey("restaurantId") || body.getValue("restaurantId") == null) {
|
||||||
|
ctx.response()
|
||||||
|
.setStatusCode(400)
|
||||||
|
.end("restaurantId is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Integer restaurantId;
|
||||||
|
try {
|
||||||
|
restaurantId = body.getInteger("restaurantId");
|
||||||
|
if (restaurantId == null) {
|
||||||
|
throw new IllegalArgumentException("restaurantId must be a number");
|
||||||
|
}
|
||||||
|
} catch (ClassCastException e) {
|
||||||
|
ctx.response()
|
||||||
|
.setStatusCode(400)
|
||||||
|
.end("restaurantId must be a valid integer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
restaurantService.findById(restaurantId)
|
||||||
|
.onSuccess(rest -> {
|
||||||
|
|
||||||
|
IikoOlapClient iiko = new IikoOlapClient(vertx, rest);
|
||||||
|
|
||||||
|
iiko.checkConnection()
|
||||||
|
.onSuccess(ping -> clearTables()
|
||||||
|
.onSuccess(data -> {
|
||||||
|
IikoOlapColumnsImporter importer = new IikoOlapColumnsImporter(iiko, db);
|
||||||
|
|
||||||
|
importer.fetchAndStoreAll()
|
||||||
|
.onSuccess(res -> ctx.end("OK"))
|
||||||
|
.onFailure(err -> ctx.response()
|
||||||
|
.setStatusCode(400)
|
||||||
|
.end(err.getMessage()));
|
||||||
|
})
|
||||||
|
.onFailure(err -> ctx.response()
|
||||||
|
.setStatusCode(400)
|
||||||
|
.end(err.getMessage())))
|
||||||
|
.onFailure(err -> ctx.response().setStatusCode(400).end(err.getMessage()));
|
||||||
|
})
|
||||||
|
.onFailure(err -> ctx.response()
|
||||||
|
.setStatusCode(400)
|
||||||
|
.end(err.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Future<JsonObject> getAllFieldsWithReportAndTags() {
|
||||||
|
String sql = """
|
||||||
|
SELECT
|
||||||
|
fc.field_key,
|
||||||
|
fc.field_key_normal,
|
||||||
|
fc.name,
|
||||||
|
fc.type,
|
||||||
|
fc.type_normal,
|
||||||
|
fc.aggregation_allowed,
|
||||||
|
fc.grouping_allowed,
|
||||||
|
fc.filtering_allowed,
|
||||||
|
GROUP_CONCAT(DISTINCT rt.name ORDER BY rt.name SEPARATOR ',') AS report_names,
|
||||||
|
GROUP_CONCAT(DISTINCT t.tag_name ORDER BY t.tag_name SEPARATOR ',') AS tag_names
|
||||||
|
FROM iiko_fields_common fc
|
||||||
|
LEFT JOIN iiko_report_type_fields rtf ON fc.field_id = rtf.field_id
|
||||||
|
LEFT JOIN iiko_report_types rt ON rtf.report_type_id = rt.report_type_id
|
||||||
|
LEFT JOIN iiko_field_tags ft ON fc.field_id = ft.field_id
|
||||||
|
LEFT JOIN iiko_tags t ON ft.tag_id = t.tag_id
|
||||||
|
GROUP BY fc.field_id
|
||||||
|
ORDER BY fc.field_key
|
||||||
|
""";
|
||||||
|
|
||||||
|
return db.getPool().query(sql).execute()
|
||||||
|
.map(rows -> {
|
||||||
|
JsonArray columnsArray = new JsonArray();
|
||||||
|
for (Row row : rows) {
|
||||||
|
|
||||||
|
String reportNamesStr = row.getString("report_names");
|
||||||
|
JsonArray reportTypes = new JsonArray();
|
||||||
|
if (reportNamesStr != null && !reportNamesStr.isBlank()) {
|
||||||
|
for (String name : reportNamesStr.split(",")) {
|
||||||
|
reportTypes.add(name.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String tagNamesStr = row.getString("tag_names");
|
||||||
|
JsonArray tags = new JsonArray();
|
||||||
|
if (tagNamesStr != null && !tagNamesStr.isBlank()) {
|
||||||
|
for (String tag : tagNamesStr.split(",")) {
|
||||||
|
tags.add(tag.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonObject fieldObj = new JsonObject()
|
||||||
|
.put("fieldKey", row.getString("field_key"))
|
||||||
|
.put("fieldKeyNormal", row.getString("field_key_normal"))
|
||||||
|
.put("reportTypes", reportTypes)
|
||||||
|
.put("name", row.getString("name"))
|
||||||
|
.put("type", row.getString("type"))
|
||||||
|
.put("typeNormal", row.getString("type_normal"))
|
||||||
|
.put("aggregationAllowed", row.getBoolean("aggregation_allowed"))
|
||||||
|
.put("groupingAllowed", row.getBoolean("grouping_allowed"))
|
||||||
|
.put("filteringAllowed", row.getBoolean("filtering_allowed"))
|
||||||
|
.put("tags", tags);
|
||||||
|
|
||||||
|
columnsArray.add(fieldObj);
|
||||||
|
}
|
||||||
|
return new JsonObject().put("columns", columnsArray);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Future<Void> createTablesIfNotExist() {
|
||||||
|
String createReportTypes = """
|
||||||
|
CREATE TABLE IF NOT EXISTS iiko_report_types (
|
||||||
|
report_type_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
description TEXT NOT NULL
|
||||||
|
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||||
|
""";
|
||||||
|
|
||||||
|
String createFieldsCommon = """
|
||||||
|
CREATE TABLE IF NOT EXISTS iiko_fields_common (
|
||||||
|
field_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
field_key VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
field_key_normal VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
type_normal VARCHAR(50) NOT NULL,
|
||||||
|
aggregation_allowed BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
grouping_allowed BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
filtering_allowed BOOLEAN NOT NULL DEFAULT 0
|
||||||
|
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||||
|
""";
|
||||||
|
|
||||||
|
String createReportTypeFields = """
|
||||||
|
CREATE TABLE IF NOT EXISTS iiko_report_type_fields (
|
||||||
|
report_type_id INT NOT NULL,
|
||||||
|
field_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (report_type_id, field_id),
|
||||||
|
FOREIGN KEY (report_type_id) REFERENCES iiko_report_types(report_type_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (field_id) REFERENCES iiko_fields_common(field_id) ON DELETE CASCADE
|
||||||
|
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||||
|
""";
|
||||||
|
|
||||||
|
String createTags = """
|
||||||
|
CREATE TABLE IF NOT EXISTS iiko_tags (
|
||||||
|
tag_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tag_name VARCHAR(100) UNIQUE NOT NULL
|
||||||
|
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||||
|
""";
|
||||||
|
|
||||||
|
String createFieldTags = """
|
||||||
|
CREATE TABLE IF NOT EXISTS iiko_field_tags (
|
||||||
|
field_id INT NOT NULL,
|
||||||
|
tag_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (field_id, tag_id),
|
||||||
|
FOREIGN KEY (field_id) REFERENCES iiko_fields_common(field_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (tag_id) REFERENCES iiko_tags(tag_id) ON DELETE CASCADE
|
||||||
|
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||||
|
""";
|
||||||
|
|
||||||
|
String idxKeyNormal = "CREATE INDEX IF NOT EXISTS idx_fields_common_key_normal ON iiko_fields_common(field_key_normal)";
|
||||||
|
String idxFieldName = "CREATE INDEX IF NOT EXISTS idx_fields_common_name ON iiko_fields_common(name)";
|
||||||
|
String idxFieldTagsTag = "CREATE INDEX IF NOT EXISTS idx_field_tags_tag_id ON iiko_field_tags(tag_id)";
|
||||||
|
|
||||||
|
return db.getPool().query(createReportTypes).execute()
|
||||||
|
.compose(v -> db.getPool().query(createFieldsCommon).execute())
|
||||||
|
.compose(v -> db.getPool().query(createReportTypeFields).execute())
|
||||||
|
.compose(v -> db.getPool().query(createTags).execute())
|
||||||
|
.compose(v -> db.getPool().query(createFieldTags).execute())
|
||||||
|
.compose(v -> db.getPool().query(idxKeyNormal).execute())
|
||||||
|
.compose(v -> db.getPool().query(idxFieldName).execute())
|
||||||
|
.compose(v -> db.getPool().query(idxFieldTagsTag).execute())
|
||||||
|
.mapEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Future<Void> clearTables() {
|
||||||
|
String sql = """
|
||||||
|
-- Отключаем проверку внешних ключей
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
-- Удаляем данные из всех таблиц (порядок не важен при отключённой проверке)
|
||||||
|
DELETE FROM iiko_field_tags;
|
||||||
|
DELETE FROM iiko_report_type_fields;
|
||||||
|
DELETE FROM iiko_fields_common;
|
||||||
|
DELETE FROM iiko_tags;
|
||||||
|
DELETE FROM iiko_report_types;
|
||||||
|
|
||||||
|
-- Сбрасываем счётчики AUTO_INCREMENT (чтобы новые ID начинались с 1)
|
||||||
|
ALTER TABLE iiko_fields_common AUTO_INCREMENT = 1;
|
||||||
|
ALTER TABLE iiko_tags AUTO_INCREMENT = 1;
|
||||||
|
ALTER TABLE iiko_report_types AUTO_INCREMENT = 1;
|
||||||
|
|
||||||
|
-- Включаем проверку обратно
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
""";
|
||||||
|
return db.getPool().query(sql).execute().mapEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,19 +14,12 @@ import java.util.stream.Collectors;
|
|||||||
|
|
||||||
public class IikoOlapClient {
|
public class IikoOlapClient {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(IikoOlapClient.class);
|
private static final Logger log = LoggerFactory.getLogger("[IikoOlapClient]");
|
||||||
private final WebClient webClient;
|
private final WebClient webClient;
|
||||||
private final String iikoHost;
|
private final String iikoHost;
|
||||||
private final String iikoLogin;
|
private final String iikoLogin;
|
||||||
private final String iikoPassHash;
|
private final String iikoPassHash;
|
||||||
|
|
||||||
public IikoOlapClient(Vertx vertx, String host, String login, String passHash, boolean https) {
|
|
||||||
this.webClient = WebClient.create(vertx);
|
|
||||||
this.iikoHost = (https ? "https://" : "http://") + host + (https ? ":443" : ":80");
|
|
||||||
this.iikoLogin = login;
|
|
||||||
this.iikoPassHash = passHash;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IikoOlapClient(Vertx vertx, JsonObject rest) {
|
public IikoOlapClient(Vertx vertx, JsonObject rest) {
|
||||||
this.webClient = WebClient.create(vertx);
|
this.webClient = WebClient.create(vertx);
|
||||||
this.iikoHost = (rest.getBoolean("https") ? "https://" : "http://") + rest.getString("host") + (rest.getBoolean("https") ? ":443" : ":80");
|
this.iikoHost = (rest.getBoolean("https") ? "https://" : "http://") + rest.getString("host") + (rest.getBoolean("https") ? ":443" : ":80");
|
||||||
@@ -36,7 +29,7 @@ public class IikoOlapClient {
|
|||||||
|
|
||||||
private Future<String> authenticate() {
|
private Future<String> authenticate() {
|
||||||
Promise<String> promise = Promise.promise();
|
Promise<String> promise = Promise.promise();
|
||||||
String url = iikoHost + "/resto/api/auth"; //?login=" + iikoLogin + "&pass=" + iikoPassHash;
|
String url = iikoHost + "/resto/api/auth";
|
||||||
|
|
||||||
webClient.getAbs(url)
|
webClient.getAbs(url)
|
||||||
.addQueryParam("login", iikoLogin)
|
.addQueryParam("login", iikoLogin)
|
||||||
@@ -65,7 +58,6 @@ public class IikoOlapClient {
|
|||||||
.addQueryParam("key", token)
|
.addQueryParam("key", token)
|
||||||
.send()
|
.send()
|
||||||
.onSuccess(resp -> {
|
.onSuccess(resp -> {
|
||||||
// log.info("Logout completed for token, status {}", resp.statusCode());
|
|
||||||
log.info(resp.bodyAsString());
|
log.info(resp.bodyAsString());
|
||||||
promise.complete();
|
promise.complete();
|
||||||
})
|
})
|
||||||
@@ -105,7 +97,6 @@ public class IikoOlapClient {
|
|||||||
.onSuccess(resp -> {
|
.onSuccess(resp -> {
|
||||||
if (resp.statusCode() == 200) {
|
if (resp.statusCode() == 200) {
|
||||||
JsonObject body = resp.bodyAsJsonObject();
|
JsonObject body = resp.bodyAsJsonObject();
|
||||||
// Если есть обёртка data, распаковываем
|
|
||||||
JsonObject data = body.containsKey("data") && body.getValue("data") instanceof JsonObject
|
JsonObject data = body.containsKey("data") && body.getValue("data") instanceof JsonObject
|
||||||
? body.getJsonObject("data")
|
? body.getJsonObject("data")
|
||||||
: body;
|
: body;
|
||||||
|
|||||||
@@ -1,44 +1,30 @@
|
|||||||
package su.xserver.iikocon.iiko;
|
package su.xserver.iikocon.iiko;
|
||||||
|
|
||||||
import io.vertx.core.Future;
|
import io.vertx.core.Future;
|
||||||
import io.vertx.core.Promise;
|
|
||||||
import io.vertx.core.Vertx;
|
|
||||||
import io.vertx.core.json.JsonArray;
|
import io.vertx.core.json.JsonArray;
|
||||||
import io.vertx.core.json.JsonObject;
|
import io.vertx.core.json.JsonObject;
|
||||||
import io.vertx.mysqlclient.MySQLConnectOptions;
|
|
||||||
import io.vertx.sqlclient.Pool;
|
|
||||||
import io.vertx.sqlclient.PoolOptions;
|
|
||||||
import io.vertx.sqlclient.Tuple;
|
import io.vertx.sqlclient.Tuple;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import su.xserver.iikocon.service.DataBaseService;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class IikoOlapColumnsImporter {
|
public class IikoOlapColumnsImporter {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(IikoOlapColumnsImporter.class);
|
private static final Logger log = LoggerFactory.getLogger("[IikoOlapColumnsImporter]");
|
||||||
private final Pool dbPool;
|
private final DataBaseService db;
|
||||||
private final IikoOlapClient iikoOlapClient;
|
private final IikoOlapClient iikoOlapClient;
|
||||||
|
|
||||||
private static final List<String> REPORT_TYPES = List.of("SALES", "TRANSACTIONS", "DELIVERIES");
|
private static final List<String> REPORT_TYPES = List.of("SALES", "TRANSACTIONS", "DELIVERIES");
|
||||||
|
|
||||||
public IikoOlapColumnsImporter(Vertx vertx, String iikoServer, String iikoLogin, String iikoPassword, String dbHost, int dbPort, String dbName, String dbUser, String dbPassword) {
|
public IikoOlapColumnsImporter(IikoOlapClient iikoOlapClient, DataBaseService db) {
|
||||||
this.iikoOlapClient = new IikoOlapClient(vertx, iikoServer, iikoLogin, iikoPassword, true);
|
this.iikoOlapClient = iikoOlapClient;
|
||||||
MySQLConnectOptions connectOptions = new MySQLConnectOptions()
|
this.db = db;
|
||||||
.setHost(dbHost)
|
|
||||||
.setPort(dbPort)
|
|
||||||
.setDatabase(dbName)
|
|
||||||
.setUser(dbUser)
|
|
||||||
.setPassword(dbPassword)
|
|
||||||
.setCharset("utf8mb4");
|
|
||||||
PoolOptions poolOptions = new PoolOptions().setMaxSize(5);
|
|
||||||
this.dbPool = Pool.pool(vertx, connectOptions, poolOptions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Future<Void> fetchAndStoreAll() {
|
public Future<Void> fetchAndStoreAll() {
|
||||||
return createTablesIfNotExist()
|
return processAllReportTypesSequentially()
|
||||||
.compose(v -> processAllReportTypesSequentially())
|
|
||||||
.onSuccess(v -> log.info("All reports imported successfully"))
|
.onSuccess(v -> log.info("All reports imported successfully"))
|
||||||
.onFailure(err -> log.error("Import failed: {}", err.getMessage()));
|
.onFailure(err -> log.error("Import failed: {}", err.getMessage()));
|
||||||
}
|
}
|
||||||
@@ -57,18 +43,11 @@ public class IikoOlapColumnsImporter {
|
|||||||
.compose(columnsJson -> storeColumnsToDb(reportType, columnsJson));
|
.compose(columnsJson -> storeColumnsToDb(reportType, columnsJson));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Запрос полей для конкретного reportType
|
|
||||||
private Future<JsonObject> fetchColumnsFromIiko(String reportType) {
|
private Future<JsonObject> fetchColumnsFromIiko(String reportType) {
|
||||||
Promise<JsonObject> promise = Promise.promise();
|
return iikoOlapClient.handleGet("/resto/api/v2/reports/olap/columns",
|
||||||
|
new JsonObject().put("reportType", reportType));
|
||||||
iikoOlapClient.handleGet("/resto/api/v2/reports/olap/columns", new JsonObject().put("reportType", reportType))
|
|
||||||
.onSuccess(promise::complete)
|
|
||||||
.onFailure(promise::fail);
|
|
||||||
|
|
||||||
return promise.future();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Методы работы с БД (с префиксом iiko_) ----------
|
|
||||||
private Future<Void> storeColumnsToDb(String reportType, JsonObject columns) {
|
private Future<Void> storeColumnsToDb(String reportType, JsonObject columns) {
|
||||||
return getOrCreateReportType(reportType)
|
return getOrCreateReportType(reportType)
|
||||||
.compose(reportTypeId -> {
|
.compose(reportTypeId -> {
|
||||||
@@ -82,86 +61,86 @@ public class IikoOlapColumnsImporter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Future<Integer> getOrCreateReportType(String reportType) {
|
private Future<Integer> getOrCreateReportType(String reportType) {
|
||||||
Promise<Integer> promise = Promise.promise();
|
|
||||||
String selectSql = "SELECT report_type_id FROM iiko_report_types WHERE name = ?";
|
String selectSql = "SELECT report_type_id FROM iiko_report_types WHERE name = ?";
|
||||||
dbPool.preparedQuery(selectSql)
|
return db.getPool().preparedQuery(selectSql).execute(Tuple.of(reportType))
|
||||||
.execute(Tuple.of(reportType))
|
.compose(rows -> {
|
||||||
.onComplete(ar -> {
|
if (rows.size() > 0) {
|
||||||
if (ar.succeeded() && ar.result().size() > 0) {
|
return Future.succeededFuture(rows.iterator().next().getInteger("report_type_id"));
|
||||||
promise.complete(ar.result().iterator().next().getInteger("report_type_id"));
|
|
||||||
} else if (ar.succeeded()) {
|
|
||||||
String insertSql = "INSERT INTO iiko_report_types (name, description) VALUES (?, ?)";
|
|
||||||
dbPool.preparedQuery(insertSql)
|
|
||||||
.execute(Tuple.of(reportType, "OLAP report type: " + reportType))
|
|
||||||
.onComplete(insAr -> {
|
|
||||||
if (insAr.succeeded()) {
|
|
||||||
dbPool.preparedQuery(selectSql)
|
|
||||||
.execute(Tuple.of(reportType))
|
|
||||||
.onComplete(selAr -> {
|
|
||||||
if (selAr.succeeded() && selAr.result().size() > 0) {
|
|
||||||
promise.complete(selAr.result().iterator().next().getInteger("report_type_id"));
|
|
||||||
} else {
|
|
||||||
promise.fail("Cannot retrieve inserted report_type_id for " + reportType);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
promise.fail(insAr.cause());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
promise.fail(ar.cause());
|
String insertSql = "INSERT INTO iiko_report_types (name, description) VALUES (?, ?)";
|
||||||
|
return db.getPool().preparedQuery(insertSql)
|
||||||
|
.execute(Tuple.of(reportType, "OLAP report type: " + reportType))
|
||||||
|
.compose(ignored ->
|
||||||
|
db.getPool().preparedQuery(selectSql).execute(Tuple.of(reportType))
|
||||||
|
.map(rows2 -> rows2.iterator().next().getInteger("report_type_id"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return promise.future();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить одно поле (без дублирования).
|
||||||
|
* Сначала получаем/создаём запись в iiko_fields_common,
|
||||||
|
* затем связываем её с report_type_id через iiko_report_type_fields,
|
||||||
|
* потом обрабатываем теги.
|
||||||
|
*/
|
||||||
private Future<Void> storeSingleField(int reportTypeId, String fieldKey, JsonObject fieldDef) {
|
private Future<Void> storeSingleField(int reportTypeId, String fieldKey, JsonObject fieldDef) {
|
||||||
// Нормализованный ключ (без точек)
|
|
||||||
String fieldKeyNormal = fieldKey.replace('.', '_');
|
String fieldKeyNormal = fieldKey.replace('.', '_');
|
||||||
|
|
||||||
String name = fieldDef.getString("name");
|
String name = fieldDef.getString("name");
|
||||||
String originalType = fieldDef.getString("type");
|
String originalType = fieldDef.getString("type");
|
||||||
String typeNormal = normalizeType(originalType);
|
String typeNormal = normalizeType(originalType);
|
||||||
|
|
||||||
boolean aggregationAllowed = fieldDef.getBoolean("aggregationAllowed", false);
|
boolean aggregationAllowed = fieldDef.getBoolean("aggregationAllowed", false);
|
||||||
boolean groupingAllowed = fieldDef.getBoolean("groupingAllowed", false);
|
boolean groupingAllowed = fieldDef.getBoolean("groupingAllowed", false);
|
||||||
boolean filteringAllowed = fieldDef.getBoolean("filteringAllowed", false);
|
boolean filteringAllowed = fieldDef.getBoolean("filteringAllowed", false);
|
||||||
JsonArray tagsArray = fieldDef.getJsonArray("tags", new JsonArray());
|
JsonArray tagsArray = fieldDef.getJsonArray("tags", new JsonArray());
|
||||||
|
|
||||||
String insertFieldSql = """
|
return getOrCreateCommonField(fieldKey, fieldKeyNormal, name, originalType, typeNormal,
|
||||||
INSERT INTO iiko_fields (
|
aggregationAllowed, groupingAllowed, filteringAllowed)
|
||||||
report_type_id, field_key, field_key_normal, name, type, type_normal,
|
.compose(fieldId -> linkFieldToReportType(reportTypeId, fieldId)
|
||||||
aggregation_allowed, grouping_allowed, filtering_allowed
|
.compose(v -> processTags(fieldId, tagsArray))
|
||||||
)
|
);
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
}
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
field_key_normal = VALUES(field_key_normal),
|
|
||||||
name = VALUES(name),
|
|
||||||
type_normal = VALUES(type_normal),
|
|
||||||
aggregation_allowed = VALUES(aggregation_allowed),
|
|
||||||
grouping_allowed = VALUES(grouping_allowed),
|
|
||||||
filtering_allowed = VALUES(filtering_allowed)
|
|
||||||
""";
|
|
||||||
|
|
||||||
return dbPool.preparedQuery(insertFieldSql)
|
/**
|
||||||
.execute(Tuple.of(
|
* Найти или создать поле в iiko_fields_common (по уникальному field_key).
|
||||||
reportTypeId, fieldKey, fieldKeyNormal, name, originalType, typeNormal,
|
*/
|
||||||
aggregationAllowed, groupingAllowed, filteringAllowed
|
private Future<Integer> getOrCreateCommonField(String fieldKey, String fieldKeyNormal, String name,
|
||||||
))
|
String type, String typeNormal,
|
||||||
.compose(ignored -> {
|
boolean aggAllowed, boolean groupAllowed, boolean filterAllowed) {
|
||||||
String selectFieldIdSql = "SELECT field_id FROM iiko_fields WHERE report_type_id = ? AND field_key = ?";
|
String selectSql = "SELECT field_id FROM iiko_fields_common WHERE field_key = ?";
|
||||||
return dbPool.preparedQuery(selectFieldIdSql)
|
return db.getPool().preparedQuery(selectSql).execute(Tuple.of(fieldKey))
|
||||||
.execute(Tuple.of(reportTypeId, fieldKey))
|
.compose(rows -> {
|
||||||
.compose(rows -> {
|
if (rows.size() > 0) {
|
||||||
if (rows.size() == 0) {
|
return Future.succeededFuture(rows.iterator().next().getInteger("field_id"));
|
||||||
return Future.failedFuture("Field not found after upsert: " + fieldKey);
|
} else {
|
||||||
}
|
String insertSql = """
|
||||||
int fieldId = rows.iterator().next().getInteger("field_id");
|
INSERT INTO iiko_fields_common
|
||||||
return processTags(fieldId, tagsArray);
|
(field_key, field_key_normal, name, type, type_normal,
|
||||||
});
|
aggregation_allowed, grouping_allowed, filtering_allowed)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""";
|
||||||
|
return db.getPool().preparedQuery(insertSql)
|
||||||
|
.execute(Tuple.of(fieldKey, fieldKeyNormal, name, type, typeNormal,
|
||||||
|
aggAllowed, groupAllowed, filterAllowed))
|
||||||
|
.compose(ignored ->
|
||||||
|
db.getPool().preparedQuery(selectSql).execute(Tuple.of(fieldKey))
|
||||||
|
.map(rows2 -> rows2.iterator().next().getInteger("field_id"))
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Привязать поле к типу отчёта (если ещё не привязано).
|
||||||
|
*/
|
||||||
|
private Future<Void> linkFieldToReportType(int reportTypeId, int fieldId) {
|
||||||
|
String sql = "INSERT IGNORE INTO iiko_report_type_fields (report_type_id, field_id) VALUES (?, ?)";
|
||||||
|
return db.getPool().preparedQuery(sql).execute(Tuple.of(reportTypeId, fieldId)).mapEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработать теги поля (теги одинаковы для всех типов отчётов).
|
||||||
|
*/
|
||||||
private Future<Void> processTags(int fieldId, JsonArray tags) {
|
private Future<Void> processTags(int fieldId, JsonArray tags) {
|
||||||
List<Future<Void>> tagFutures = new ArrayList<>();
|
List<Future<Void>> tagFutures = new ArrayList<>();
|
||||||
for (Object tagObj : tags) {
|
for (Object tagObj : tags) {
|
||||||
@@ -173,93 +152,25 @@ public class IikoOlapColumnsImporter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Future<Integer> getOrCreateTag(String tagName) {
|
private Future<Integer> getOrCreateTag(String tagName) {
|
||||||
Promise<Integer> promise = Promise.promise();
|
|
||||||
String selectSql = "SELECT tag_id FROM iiko_tags WHERE tag_name = ?";
|
String selectSql = "SELECT tag_id FROM iiko_tags WHERE tag_name = ?";
|
||||||
dbPool.preparedQuery(selectSql)
|
return db.getPool().preparedQuery(selectSql).execute(Tuple.of(tagName))
|
||||||
.execute(Tuple.of(tagName))
|
.compose(rows -> {
|
||||||
.onComplete(ar -> {
|
if (rows.size() > 0) {
|
||||||
if (ar.succeeded() && ar.result().size() > 0) {
|
return Future.succeededFuture(rows.iterator().next().getInteger("tag_id"));
|
||||||
promise.complete(ar.result().iterator().next().getInteger("tag_id"));
|
|
||||||
} else {
|
} else {
|
||||||
String insertSql = "INSERT IGNORE INTO iiko_tags (tag_name) VALUES (?)";
|
String insertSql = "INSERT IGNORE INTO iiko_tags (tag_name) VALUES (?)";
|
||||||
dbPool.preparedQuery(insertSql)
|
return db.getPool().preparedQuery(insertSql).execute(Tuple.of(tagName))
|
||||||
.execute(Tuple.of(tagName))
|
.compose(ignored ->
|
||||||
.onComplete(insAr -> {
|
db.getPool().preparedQuery(selectSql).execute(Tuple.of(tagName))
|
||||||
// После IGNORE всё равно выбираем ID (он мог уже существовать)
|
.map(rows2 -> rows2.iterator().next().getInteger("tag_id"))
|
||||||
dbPool.preparedQuery(selectSql)
|
);
|
||||||
.execute(Tuple.of(tagName))
|
|
||||||
.onComplete(selAr -> {
|
|
||||||
if (selAr.succeeded() && selAr.result().size() > 0) {
|
|
||||||
promise.complete(selAr.result().iterator().next().getInteger("tag_id"));
|
|
||||||
} else {
|
|
||||||
promise.fail("Cannot retrieve tag_id for " + tagName);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return promise.future();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Future<Void> linkFieldTag(int fieldId, int tagId) {
|
private Future<Void> linkFieldTag(int fieldId, int tagId) {
|
||||||
String sql = "INSERT IGNORE INTO iiko_field_tags (field_id, tag_id) VALUES (?, ?)";
|
String sql = "INSERT IGNORE INTO iiko_field_tags (field_id, tag_id) VALUES (?, ?)";
|
||||||
return dbPool.preparedQuery(sql)
|
return db.getPool().preparedQuery(sql).execute(Tuple.of(fieldId, tagId)).mapEmpty();
|
||||||
.execute(Tuple.of(fieldId, tagId))
|
|
||||||
.mapEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Future<Void> createTablesIfNotExist() {
|
|
||||||
String createReportTypesTable = """
|
|
||||||
CREATE TABLE IF NOT EXISTS iiko_report_types (
|
|
||||||
report_type_id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
name VARCHAR(50) UNIQUE NOT NULL,
|
|
||||||
description TEXT NOT NULL
|
|
||||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
|
||||||
""";
|
|
||||||
String createFieldsTable = """
|
|
||||||
CREATE TABLE IF NOT EXISTS iiko_fields (
|
|
||||||
field_id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
report_type_id INT NOT NULL,
|
|
||||||
field_key VARCHAR(255) NOT NULL,
|
|
||||||
field_key_normal VARCHAR(255) NOT NULL,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
type VARCHAR(50) NOT NULL,
|
|
||||||
type_normal VARCHAR(50) NOT NULL,
|
|
||||||
aggregation_allowed BOOLEAN NOT NULL DEFAULT 0,
|
|
||||||
grouping_allowed BOOLEAN NOT NULL DEFAULT 0,
|
|
||||||
filtering_allowed BOOLEAN NOT NULL DEFAULT 0,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE KEY uk_fields_report_type_field_key (report_type_id, field_key),
|
|
||||||
FOREIGN KEY (report_type_id) REFERENCES iiko_report_types(report_type_id) ON DELETE RESTRICT
|
|
||||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
|
||||||
""";
|
|
||||||
String createTagsTable = """
|
|
||||||
CREATE TABLE IF NOT EXISTS iiko_tags (
|
|
||||||
tag_id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
tag_name VARCHAR(100) UNIQUE NOT NULL
|
|
||||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
|
||||||
""";
|
|
||||||
String createFieldTagsTable = """
|
|
||||||
CREATE TABLE IF NOT EXISTS iiko_field_tags (
|
|
||||||
field_id INT NOT NULL,
|
|
||||||
tag_id INT NOT NULL,
|
|
||||||
PRIMARY KEY (field_id, tag_id),
|
|
||||||
FOREIGN KEY (field_id) REFERENCES iiko_fields(field_id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (tag_id) REFERENCES iiko_tags(tag_id) ON DELETE CASCADE
|
|
||||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
|
||||||
""";
|
|
||||||
String createIdxFieldsReportType = "CREATE INDEX IF NOT EXISTS idx_fields_report_type ON iiko_fields(report_type_id)";
|
|
||||||
String createIdxFieldsName = "CREATE INDEX IF NOT EXISTS idx_fields_name ON iiko_fields(name)";
|
|
||||||
String createIdxFieldTagsTagId = "CREATE INDEX IF NOT EXISTS idx_field_tags_tag_id ON iiko_field_tags(tag_id)";
|
|
||||||
|
|
||||||
return dbPool.query(createReportTypesTable).execute()
|
|
||||||
.compose(ignored -> dbPool.query(createFieldsTable).execute())
|
|
||||||
.compose(ignored -> dbPool.query(createTagsTable).execute())
|
|
||||||
.compose(ignored -> dbPool.query(createFieldTagsTable).execute())
|
|
||||||
.compose(ignored -> dbPool.query(createIdxFieldsReportType).execute())
|
|
||||||
.compose(ignored -> dbPool.query(createIdxFieldsName).execute())
|
|
||||||
.compose(ignored -> dbPool.query(createIdxFieldTagsTagId).execute())
|
|
||||||
.mapEmpty();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String normalizeType(String iikoType) {
|
private String normalizeType(String iikoType) {
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
package su.xserver.iikocon.iiko;
|
|
||||||
|
|
||||||
import io.vertx.core.Vertx;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
|
|
||||||
public class Main {
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(Main.class);
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
long time = System.currentTimeMillis();
|
|
||||||
|
|
||||||
Vertx vertx = Vertx.vertx();
|
|
||||||
|
|
||||||
IikoOlapColumnsImporter importer = new IikoOlapColumnsImporter(
|
|
||||||
vertx,
|
|
||||||
"folk-amber-co.iiko.it", // без https://
|
|
||||||
"4444",
|
|
||||||
"92f2fd99879b0c2466ab8648afb63c49032379c1",
|
|
||||||
"phpmyadmin.xserver.su", // хост MariaDB
|
|
||||||
3306,
|
|
||||||
"test", // имя БД
|
|
||||||
"test",
|
|
||||||
"test"
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
importer.fetchAndStoreAll()
|
|
||||||
.onComplete(ar -> {
|
|
||||||
if (ar.succeeded()) {
|
|
||||||
System.out.println("Import completed successfully.");
|
|
||||||
log.info("time to sc: {}", (System.currentTimeMillis() - time) + "ms");
|
|
||||||
|
|
||||||
} else {
|
|
||||||
System.err.println("Import failed: " + ar.cause().getMessage());
|
|
||||||
}
|
|
||||||
// importer.close();
|
|
||||||
// vertx.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package su.xserver.iikocon.service;
|
||||||
|
|
||||||
|
import io.vertx.core.Future;
|
||||||
|
import io.vertx.core.Promise;
|
||||||
|
import io.vertx.core.Vertx;
|
||||||
|
import io.vertx.core.json.JsonArray;
|
||||||
|
import io.vertx.core.json.JsonObject;
|
||||||
|
import io.vertx.ext.web.Router;
|
||||||
|
import io.vertx.jdbcclient.JDBCConnectOptions;
|
||||||
|
import io.vertx.jdbcclient.JDBCPool;
|
||||||
|
import io.vertx.sqlclient.Pool;
|
||||||
|
import io.vertx.sqlclient.PoolOptions;
|
||||||
|
import io.vertx.sqlclient.Row;
|
||||||
|
import io.vertx.sqlclient.templates.SqlTemplate;
|
||||||
|
import su.xserver.iikocon.handler.AdminHandler;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class ExternalDataBaseService {
|
||||||
|
private final Pool pool;
|
||||||
|
private final Vertx vertx;
|
||||||
|
|
||||||
|
public ExternalDataBaseService(Pool pool, Vertx vertx) {
|
||||||
|
this.pool = pool;
|
||||||
|
this.vertx = vertx;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void handleRoute(Router router) {
|
||||||
|
|
||||||
|
router.get("/api/admin/database-connections").handler(rc -> this.getAllDataBases().onComplete(ar -> {
|
||||||
|
if (ar.succeeded()) {
|
||||||
|
rc.response()
|
||||||
|
.putHeader("Content-Type", "application/json")
|
||||||
|
.end(ar.result().encode());
|
||||||
|
} else {
|
||||||
|
rc.response().setStatusCode(500).end(ar.cause().getMessage());
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.get("/api/admin/database-connections/:id/test").handler(AdminHandler::requireAdmin).handler(rc -> {
|
||||||
|
int id = Integer.parseInt(rc.pathParam("id"));
|
||||||
|
|
||||||
|
this.testConnection(id)
|
||||||
|
.onSuccess(result -> rc.response()
|
||||||
|
.setStatusCode(200)
|
||||||
|
.putHeader("Content-Type", "application/json")
|
||||||
|
.end(result.encode()))
|
||||||
|
.onFailure(err -> rc.response()
|
||||||
|
.setStatusCode(500)
|
||||||
|
.putHeader("Content-Type", "application/json")
|
||||||
|
.end(new JsonObject()
|
||||||
|
.put("success", false)
|
||||||
|
.put("error", err.getMessage())
|
||||||
|
.encode()));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/api/admin/database-connections").handler(AdminHandler::requireAdmin).handler(rc -> {
|
||||||
|
JsonObject body = rc.body().asJsonObject();
|
||||||
|
String name = body.getString("name");
|
||||||
|
String type = body.getString("type");
|
||||||
|
String host = body.getString("host");
|
||||||
|
int port = body.getInteger("port");
|
||||||
|
String database = body.getString("database");
|
||||||
|
String user = body.getString("user");
|
||||||
|
String password = body.getString("password");
|
||||||
|
if (name == null || type == null || host == null || port < 1 || database == null || user == null || password == null) {
|
||||||
|
rc.response().setStatusCode(400).end("Missing fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.createDataBase(name, type, host, port, database, user, password)
|
||||||
|
.onSuccess(v -> rc.response().setStatusCode(201).end())
|
||||||
|
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/api/admin/database-connections/:id").handler(AdminHandler::requireAdmin).handler(rc -> {
|
||||||
|
int id = Integer.parseInt(rc.pathParam("id"));
|
||||||
|
JsonObject body = rc.body().asJsonObject();
|
||||||
|
String name = body.getString("name");
|
||||||
|
String type = body.getString("type");
|
||||||
|
String host = body.getString("host");
|
||||||
|
int port = body.getInteger("port");
|
||||||
|
String database = body.getString("database");
|
||||||
|
String user = body.getString("user");
|
||||||
|
String password = body.getString("password");
|
||||||
|
if (name == null || type == null || host == null || port < 1 || database == null || user == null) {
|
||||||
|
rc.response().setStatusCode(400).end("Missing fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.updateDataBase(id, name, type, host, port, database, user, password)
|
||||||
|
.onSuccess(v -> rc.response().end())
|
||||||
|
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/api/admin/database-connections/:id").handler(AdminHandler::requireAdmin).handler(rc -> {
|
||||||
|
int id = Integer.parseInt(rc.pathParam("id"));
|
||||||
|
this.deleteDataBase(id)
|
||||||
|
.onSuccess(v -> rc.response().end())
|
||||||
|
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Future<Void> initDatabase() {
|
||||||
|
String createTable = """
|
||||||
|
CREATE TABLE IF NOT EXISTS external_database (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
type VARCHAR(40) UNIQUE NOT NULL,
|
||||||
|
host VARCHAR(255) NOT NULL,
|
||||||
|
port INT NOT NULL,
|
||||||
|
database VARCHAR(255) NOT NULL,
|
||||||
|
user VARCHAR(255) NOT NULL,
|
||||||
|
password VARCHAR(255) NOT NULL,
|
||||||
|
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
return pool.query(createTable).execute().mapEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Future<Void> createDataBase(String name, String type, String host, int port, String database, String user, String password) {
|
||||||
|
Map<String, Object> params = Map.of(
|
||||||
|
"name", name,
|
||||||
|
"type", type,
|
||||||
|
"host", host,
|
||||||
|
"port", port,
|
||||||
|
"database", database,
|
||||||
|
"user", user,
|
||||||
|
"password", password
|
||||||
|
);
|
||||||
|
return SqlTemplate.forUpdate(pool,
|
||||||
|
"INSERT INTO external_database (name, type, host, port, database, user, password) VALUES (#{name}, #{type}, #{host}, #{port}, #{database}, #{user}, #{password})")
|
||||||
|
.execute(params)
|
||||||
|
.mapEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Future<JsonArray> getAllDataBases() {
|
||||||
|
return pool.query("SELECT id, name, type, host, port, database, user, password, created, updated FROM external_database ORDER BY id")
|
||||||
|
.execute()
|
||||||
|
.map(rows -> {
|
||||||
|
JsonArray array = new JsonArray();
|
||||||
|
for (Row row : rows) {
|
||||||
|
array.add(new JsonObject()
|
||||||
|
.put("id", row.getInteger("id"))
|
||||||
|
.put("name", row.getString("name"))
|
||||||
|
.put("type", row.getString("type"))
|
||||||
|
.put("host", row.getString("host"))
|
||||||
|
.put("port", row.getInteger("port"))
|
||||||
|
.put("database", row.getString("database"))
|
||||||
|
.put("user", row.getString("user"))
|
||||||
|
.put("created", row.getLocalDateTime("created") != null ?
|
||||||
|
row.getLocalDateTime("created").toString() : null)
|
||||||
|
.put("updated", row.getLocalDateTime("updated") != null ?
|
||||||
|
row.getLocalDateTime("updated").toString() : null));
|
||||||
|
}
|
||||||
|
return array;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Future<JsonObject> findById(int id) {
|
||||||
|
return SqlTemplate.forQuery(pool,
|
||||||
|
"SELECT id, name, type, host, port, database, user, password, created, updated FROM external_database WHERE id = #{id}")
|
||||||
|
.mapTo(row -> new JsonObject()
|
||||||
|
.put("id", row.getInteger("id"))
|
||||||
|
.put("name", row.getString("name"))
|
||||||
|
.put("type", row.getString("type"))
|
||||||
|
.put("host", row.getString("host"))
|
||||||
|
.put("port", row.getInteger("port"))
|
||||||
|
.put("database", row.getString("database"))
|
||||||
|
.put("user", row.getString("user"))
|
||||||
|
.put("password", row.getString("password"))
|
||||||
|
.put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null)
|
||||||
|
.put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null))
|
||||||
|
.execute(Collections.singletonMap("id", id))
|
||||||
|
.map(rows -> rows.iterator().hasNext() ? rows.iterator().next() : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Future<Void> updateDataBase(int id, String name, String type, String host, int port, String database, String user, String password) {
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
params.put("id", id);
|
||||||
|
params.put("name", name);
|
||||||
|
params.put("type", type);
|
||||||
|
params.put("host", host);
|
||||||
|
params.put("port", port);
|
||||||
|
params.put("database", database);
|
||||||
|
params.put("user", user);
|
||||||
|
String sql;
|
||||||
|
if (password != null && !password.isEmpty()) {
|
||||||
|
params.put("password", password);
|
||||||
|
sql = "UPDATE external_database SET name = #{name}, type = #{type}, host = #{host}, port = #{port}, database = #{database}, user = #{user}, password = #{password} WHERE id = #{id}";
|
||||||
|
} else {
|
||||||
|
sql = "UPDATE external_database SET name = #{name}, type = #{type}, host = #{host}, port = #{port}, database = #{database}, user = #{user} WHERE id = #{id}";
|
||||||
|
}
|
||||||
|
return SqlTemplate.forUpdate(pool, sql).execute(params).mapEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Future<Void> deleteDataBase(int id) {
|
||||||
|
return SqlTemplate.forUpdate(pool, "DELETE FROM external_database WHERE id = #{id}")
|
||||||
|
.execute(Collections.singletonMap("id", id))
|
||||||
|
.mapEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Future<JsonObject> testConnection(int id) {
|
||||||
|
Promise<JsonObject> promise = Promise.promise();
|
||||||
|
|
||||||
|
this.findById(id)
|
||||||
|
.onSuccess(conn -> {
|
||||||
|
String jdbcUrl = buildJdbcUrl(conn);
|
||||||
|
if (jdbcUrl == null) {
|
||||||
|
promise.fail("Unsupported database type: " + conn.getString("type"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JDBCConnectOptions connectOptions = new JDBCConnectOptions()
|
||||||
|
.setJdbcUrl(jdbcUrl)
|
||||||
|
.setDatabase(conn.getString("database"))
|
||||||
|
.setUser(conn.getString("user"))
|
||||||
|
.setPassword(conn.getString("password"));
|
||||||
|
|
||||||
|
PoolOptions poolOptions = new PoolOptions()
|
||||||
|
.setMaxSize(1);
|
||||||
|
|
||||||
|
Pool pool = JDBCPool.pool(vertx, connectOptions, poolOptions);
|
||||||
|
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
pool
|
||||||
|
.query("SELECT 1")
|
||||||
|
.execute()
|
||||||
|
.onSuccess(rows -> {
|
||||||
|
long latency = System.currentTimeMillis() - startTime;
|
||||||
|
JsonObject result = new JsonObject()
|
||||||
|
.put("success", true)
|
||||||
|
.put("latency_ms", latency);
|
||||||
|
promise.complete(result);
|
||||||
|
pool.close();
|
||||||
|
})
|
||||||
|
.onFailure(err -> promise.fail("Connection failed: " + err.getMessage()));
|
||||||
|
})
|
||||||
|
.onFailure(promise::fail);
|
||||||
|
|
||||||
|
return promise.future();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildJdbcUrl(JsonObject conn) {
|
||||||
|
return switch (conn.getString("type").toLowerCase()) {
|
||||||
|
case "mysql" -> String.format("jdbc:mysql://%s:%d",
|
||||||
|
conn.getString("host"), conn.getInteger("port"));
|
||||||
|
case "postgres" -> String.format("jdbc:postgresql://%s:%d",
|
||||||
|
conn.getString("host"), conn.getInteger("port"));
|
||||||
|
case "clickhouse" ->
|
||||||
|
String.format("jdbc:clickhouse://%s:%d",
|
||||||
|
conn.getString("host"), conn.getInteger("port"));
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import io.vertx.core.json.JsonObject;
|
|||||||
import io.vertx.ext.healthchecks.Status;
|
import io.vertx.ext.healthchecks.Status;
|
||||||
import io.vertx.ext.web.Router;
|
import io.vertx.ext.web.Router;
|
||||||
import io.vertx.ext.web.healthchecks.HealthCheckHandler;
|
import io.vertx.ext.web.healthchecks.HealthCheckHandler;
|
||||||
import su.xserver.iikocon.iiko.IikoOlapClient;
|
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
|
||||||
@@ -56,21 +55,7 @@ public class HealthCheckService {
|
|||||||
.onFailure(err -> future.tryFail("DataBase ping failed: " + err.getMessage()));
|
.onFailure(err -> future.tryFail("DataBase ping failed: " + err.getMessage()));
|
||||||
});
|
});
|
||||||
|
|
||||||
// healthCheckHandler.register("iiko", future -> {
|
// Endpoint /api/health
|
||||||
//
|
|
||||||
// IikoOlapClient iiko = new IikoOlapClient(vertx, "folk-amber-co.iiko.it", "4444", "92f2fd99879b0c2466ab8648afb63c49032379c1", true);
|
|
||||||
//
|
|
||||||
// iiko.checkConnection()
|
|
||||||
// .onSuccess(res -> {
|
|
||||||
// JsonObject data = new JsonObject()
|
|
||||||
// .put("name", "iiko")
|
|
||||||
// .put("latency_ms", res.getLong("latency_ms"));
|
|
||||||
// future.complete(Status.OK(data));
|
|
||||||
// })
|
|
||||||
// .onFailure(err -> future.tryFail("iiko ping failed: " + err.getMessage()));
|
|
||||||
// });
|
|
||||||
|
|
||||||
// Регистрируем endpoint /api/health
|
|
||||||
router.get("/api/health").handler(healthCheckHandler);
|
router.get("/api/health").handler(healthCheckHandler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,12 +54,6 @@ public class RestaurantService {
|
|||||||
return pool.query(createTable).execute().mapEmpty();
|
return pool.query(createTable).execute().mapEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Future<Long> countRestaurant() {
|
|
||||||
return pool.query("SELECT COUNT(*) AS cnt FROM restaurants")
|
|
||||||
.execute()
|
|
||||||
.map(rows -> rows.iterator().next().getLong("cnt"));
|
|
||||||
}
|
|
||||||
|
|
||||||
public Future<Void> createRestaurant(String name, String login, String password, String host, boolean https) {
|
public Future<Void> createRestaurant(String name, String login, String password, String host, boolean https) {
|
||||||
String hashedPassword = hashPassword(password);
|
String hashedPassword = hashPassword(password);
|
||||||
Map<String, Object> params = Map.of(
|
Map<String, Object> params = Map.of(
|
||||||
|
|||||||
@@ -101,8 +101,8 @@ public class SettingsService {
|
|||||||
case "site_description" -> "";
|
case "site_description" -> "";
|
||||||
case "enable_registration" -> "true";
|
case "enable_registration" -> "true";
|
||||||
case "maintenance_mode" -> "false";
|
case "maintenance_mode" -> "false";
|
||||||
case "session_timeout_minutes" -> "60";
|
case "session_timeout_minutes" -> "120";
|
||||||
case "use_proxy_headers" -> "true";
|
case "use_proxy_headers" -> "false";
|
||||||
case "trusted_proxies" -> "127.0.0.1";
|
case "trusted_proxies" -> "127.0.0.1";
|
||||||
case "enable_csp" -> "true";
|
case "enable_csp" -> "true";
|
||||||
case "allowed_hosts" -> "";
|
case "allowed_hosts" -> "";
|
||||||
@@ -120,13 +120,9 @@ public class SettingsService {
|
|||||||
return pool.query(createTable).execute()
|
return pool.query(createTable).execute()
|
||||||
.compose(v -> setIfAbsent("site_name", "Admin Panel"))
|
.compose(v -> setIfAbsent("site_name", "Admin Panel"))
|
||||||
.compose(v -> setIfAbsent("site_description", "Powerful administration dashboard"))
|
.compose(v -> setIfAbsent("site_description", "Powerful administration dashboard"))
|
||||||
.compose(v -> setIfAbsent("theme", "light"))
|
|
||||||
.compose(v -> setIfAbsent("items_per_page", "20"))
|
|
||||||
.compose(v -> setIfAbsent("enable_registration", "true"))
|
.compose(v -> setIfAbsent("enable_registration", "true"))
|
||||||
.compose(v -> setIfAbsent("maintenance_mode", "false"))
|
.compose(v -> setIfAbsent("maintenance_mode", "false"))
|
||||||
.compose(v -> setIfAbsent("default_language", "en"))
|
.compose(v -> setIfAbsent("session_timeout_minutes", "120"))
|
||||||
.compose(v -> setIfAbsent("session_timeout_minutes", "60"))
|
|
||||||
.compose(v -> setIfAbsent("logo_url", "/assets/logo.png"))
|
|
||||||
.mapEmpty();
|
.mapEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,6 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (setClauses.isEmpty()) {
|
if (setClauses.isEmpty()) {
|
||||||
// Ни одно поле не обновляется — возвращаем успешный Future
|
|
||||||
return Future.succeededFuture();
|
return Future.succeededFuture();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,12 +183,6 @@ public class UserService {
|
|||||||
return SqlTemplate.forUpdate(pool, sql).execute(params).mapEmpty();
|
return SqlTemplate.forUpdate(pool, sql).execute(params).mapEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Future<Void> updateLanguage(int userId, String language) {
|
|
||||||
return SqlTemplate.forUpdate(pool, "UPDATE users SET language = #{lang} WHERE id = #{id}")
|
|
||||||
.execute(Map.of("id", userId, "lang", language))
|
|
||||||
.mapEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean checkPassword(String plain, String hash) {
|
public boolean checkPassword(String plain, String hash) {
|
||||||
try {
|
try {
|
||||||
return BCrypt.checkpw(plain, hash);
|
return BCrypt.checkpw(plain, hash);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package su.xserver.iikocon.test;
|
||||||
|
|
||||||
|
import io.vertx.core.Vertx;
|
||||||
|
import io.vertx.jdbcclient.JDBCConnectOptions;
|
||||||
|
import io.vertx.jdbcclient.JDBCPool;
|
||||||
|
import io.vertx.sqlclient.Pool;
|
||||||
|
import io.vertx.sqlclient.PoolOptions;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
public class ClickHouseJDBCExample {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ClickHouseJDBCExample.class);
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
Vertx vertx = Vertx.vertx();
|
||||||
|
|
||||||
|
JDBCConnectOptions connectOptions = new JDBCConnectOptions()
|
||||||
|
.setJdbcUrl("jdbc:clickhouse://dl-import.aramagedec.ru:8123")
|
||||||
|
.setDatabase("test")
|
||||||
|
.setUser("clickhouse_admin")
|
||||||
|
.setPassword("7002ITinsta11");
|
||||||
|
|
||||||
|
PoolOptions poolOptions = new PoolOptions()
|
||||||
|
.setMaxSize(16);
|
||||||
|
|
||||||
|
Pool pool = JDBCPool.pool(vertx, connectOptions, poolOptions);
|
||||||
|
|
||||||
|
pool
|
||||||
|
.query("SELECT 1")
|
||||||
|
.execute()
|
||||||
|
.onSuccess(rows -> {
|
||||||
|
rows.forEach(row -> log.info(row.toJson().encodePrettily()));
|
||||||
|
vertx.close();
|
||||||
|
})
|
||||||
|
.onFailure(err -> {
|
||||||
|
log.error(err.getMessage());
|
||||||
|
vertx.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package su.xserver.iikocon.test;
|
|||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.time.format.DateTimeParseException;
|
|
||||||
|
|
||||||
public class DateRangeSetup {
|
public class DateRangeSetup {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
@@ -10,29 +9,11 @@ public class DateRangeSetup {
|
|||||||
// Вычисление dateFrom и dateTo
|
// Вычисление dateFrom и dateTo
|
||||||
LocalDate today = LocalDate.now();
|
LocalDate today = LocalDate.now();
|
||||||
LocalDate dateFrom = today.minusDays(7);
|
LocalDate dateFrom = today.minusDays(7);
|
||||||
LocalDate dateTo = today;
|
|
||||||
|
|
||||||
// Переопределение из аргументов командной строки
|
|
||||||
if (args.length > 0 && args[0] != null && !args[0].isEmpty()) {
|
|
||||||
try {
|
|
||||||
dateFrom = LocalDate.parse(args[0]);
|
|
||||||
} catch (DateTimeParseException e) {
|
|
||||||
System.err.println("Ошибка парсинга dateFrom: " + args[0] + ". Используется значение по умолчанию.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.length > 1 && args[1] != null && !args[1].isEmpty()) {
|
|
||||||
try {
|
|
||||||
dateTo = LocalDate.parse(args[1]);
|
|
||||||
} catch (DateTimeParseException e) {
|
|
||||||
System.err.println("Ошибка парсинга dateTo: " + args[1] + ". Используется значение по умолчанию.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Форматирование дат в YYYY-MM-DD
|
// Форматирование дат в YYYY-MM-DD
|
||||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||||
String formattedDateFrom = dateFrom.format(formatter);
|
String formattedDateFrom = dateFrom.format(formatter);
|
||||||
String formattedDateTo = dateTo.format(formatter);
|
String formattedDateTo = today.format(formatter);
|
||||||
|
|
||||||
System.out.println("dateFrom=" + formattedDateFrom);
|
System.out.println("dateFrom=" + formattedDateFrom);
|
||||||
System.out.println("dateTo=" + formattedDateTo);
|
System.out.println("dateTo=" + formattedDateTo);
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
package su.xserver.iikocon.test;
|
|
||||||
|
|
||||||
import io.vertx.core.AbstractVerticle;
|
|
||||||
import io.vertx.core.Promise;
|
|
||||||
import io.vertx.core.http.HttpMethod;
|
|
||||||
import io.vertx.core.json.Json;
|
|
||||||
import io.vertx.core.json.JsonObject;
|
|
||||||
import io.vertx.ext.web.Router;
|
|
||||||
import io.vertx.ext.web.RoutingContext;
|
|
||||||
import io.vertx.ext.web.client.WebClient;
|
|
||||||
import io.vertx.ext.web.client.WebClientOptions;
|
|
||||||
import io.vertx.ext.web.codec.BodyCodec;
|
|
||||||
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.util.HexFormat;
|
|
||||||
|
|
||||||
public class ProxyVerticle extends AbstractVerticle {
|
|
||||||
|
|
||||||
private WebClient webClient;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void start(Promise<Void> startPromise) {
|
|
||||||
webClient = WebClient.create(vertx, new WebClientOptions()
|
|
||||||
.setSsl(true)
|
|
||||||
.setTrustAll(true)
|
|
||||||
.setVerifyHost(false));
|
|
||||||
|
|
||||||
Router router = Router.router(vertx);
|
|
||||||
router.post("/api/proxy").handler(this::handlePost);
|
|
||||||
router.get("/api/proxy").handler(this::handleGet);
|
|
||||||
|
|
||||||
int port = 8080;
|
|
||||||
vertx.createHttpServer()
|
|
||||||
.requestHandler(router)
|
|
||||||
.listen(port).onComplete(http -> {
|
|
||||||
if (http.succeeded()) {
|
|
||||||
System.out.println("Proxy server started on port " + port);
|
|
||||||
startPromise.complete();
|
|
||||||
} else {
|
|
||||||
startPromise.fail(http.cause());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handlePost(RoutingContext ctx) {
|
|
||||||
String apiServer = System.getenv("IIKO_API_SERVER");
|
|
||||||
String apiLogin = System.getenv("IIKO_API_LOGIN");
|
|
||||||
String apiPass = System.getenv("IIKO_API_PASS");
|
|
||||||
String externalEndpoint = System.getenv("IIKO_API_ENDPOINT");
|
|
||||||
if (externalEndpoint == null || externalEndpoint.isBlank()) {
|
|
||||||
externalEndpoint = "/your-endpoint";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiServer == null || apiLogin == null || apiPass == null) {
|
|
||||||
fail(ctx, 500, "Missing required environment variables: IIKO_API_SERVER, IIKO_API_LOGIN, IIKO_API_PASS");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonObject body = ctx.body().asJsonObject();
|
|
||||||
if (body == null) {
|
|
||||||
fail(ctx, 400, "Request body must be JSON");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String signature = sha1(apiPass);
|
|
||||||
String authUrl = "https://" + apiServer + ":443/resto/api/auth?login=" + apiLogin + "&pass=" + signature;
|
|
||||||
String finalExternalEndpoint = externalEndpoint;
|
|
||||||
webClient.getAbs(authUrl)
|
|
||||||
.as(BodyCodec.string())
|
|
||||||
.send()
|
|
||||||
.onSuccess(authResp -> {
|
|
||||||
if (authResp.statusCode() != 200) {
|
|
||||||
fail(ctx, authResp.statusCode(), "Authentication failed: " + authResp.statusMessage());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String token = authResp.body();
|
|
||||||
String targetUrl = "https://" + apiServer + finalExternalEndpoint;
|
|
||||||
webClient.request(HttpMethod.POST, targetUrl)
|
|
||||||
.putHeader("Content-Type", "application/json")
|
|
||||||
.as(BodyCodec.jsonObject())
|
|
||||||
.sendJsonObject(body)
|
|
||||||
.onSuccess(apiResp -> {
|
|
||||||
webClient.getAbs("https://" + apiServer + ":443/resto/api/logout?key=" + token)
|
|
||||||
.send()
|
|
||||||
.onFailure(err -> System.err.println("Logout failed: " + err.getMessage()));
|
|
||||||
if (apiResp.statusCode() == 200) {
|
|
||||||
ctx.response().setStatusCode(200).end(apiResp.body().encode());
|
|
||||||
} else {
|
|
||||||
fail(ctx, apiResp.statusCode(), "External API error: " + apiResp.statusMessage());
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.onFailure(err -> fail(ctx, 500, "Request to external API failed: " + err.getMessage()));
|
|
||||||
})
|
|
||||||
.onFailure(err -> fail(ctx, 500, "Auth request failed: " + err.getMessage()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleGet(RoutingContext ctx) {
|
|
||||||
String presetId = ctx.queryParam("presetId").stream().findFirst().orElse(null);
|
|
||||||
String dateFrom = ctx.queryParam("dateFrom").stream().findFirst().orElse(null);
|
|
||||||
String dateTo = ctx.queryParam("dateTo").stream().findFirst().orElse(null);
|
|
||||||
String server = ctx.queryParam("server").stream().findFirst().orElse(null);
|
|
||||||
String password = ctx.queryParam("password").stream().findFirst().orElse(null);
|
|
||||||
String login = ctx.queryParam("login").stream().findFirst().orElse(null);
|
|
||||||
String type = ctx.queryParam("type").stream().findFirst().orElse(null);
|
|
||||||
String rootType = ctx.queryParam("rootType").stream().findFirst().orElse(null);
|
|
||||||
|
|
||||||
if (server == null || login == null || password == null) {
|
|
||||||
fail(ctx, 400, "Missing required parameters: server, login, password");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String signature = sha1(password);
|
|
||||||
String authUrl = "https://" + server + ":443/resto/api/auth?login=" + login + "&pass=" + signature;
|
|
||||||
webClient.getAbs(authUrl)
|
|
||||||
.as(BodyCodec.string())
|
|
||||||
.send()
|
|
||||||
.onSuccess(authResp -> {
|
|
||||||
if (authResp.statusCode() != 200) {
|
|
||||||
fail(ctx, authResp.statusCode(), "Authentication failed: " + authResp.statusMessage());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String token = authResp.body();
|
|
||||||
String dataUrl;
|
|
||||||
if ("entity".equals(type)) {
|
|
||||||
dataUrl = "https://" + server + "/resto/api/v2/entities/list?key=" + token;
|
|
||||||
if (rootType != null && !rootType.isBlank()) {
|
|
||||||
dataUrl += "&rootType=" + rootType;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (presetId == null || dateFrom == null || dateTo == null) {
|
|
||||||
fail(ctx, 400, "Missing presetId, dateFrom or dateTo for report request");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
dataUrl = "https://" + server + "/resto/api/v2/reports/olap/byPresetId/" + presetId +
|
|
||||||
"?key=" + token + "&dateFrom=" + dateFrom + "&dateTo=" + dateTo;
|
|
||||||
}
|
|
||||||
System.out.println("URL: " + dataUrl);
|
|
||||||
webClient.getAbs(dataUrl)
|
|
||||||
.as(BodyCodec.jsonObject())
|
|
||||||
.send()
|
|
||||||
.onSuccess(dataResp -> {
|
|
||||||
// logout (fire and forget)
|
|
||||||
webClient.getAbs("https://" + server + ":443/resto/api/logout?key=" + token)
|
|
||||||
.send()
|
|
||||||
.onFailure(err -> System.err.println("Logout failed: " + err.getMessage()));
|
|
||||||
if (dataResp.statusCode() == 200) {
|
|
||||||
JsonObject responseBody = dataResp.body();
|
|
||||||
if ("entity".equals(type)) {
|
|
||||||
ctx.response().setStatusCode(200).end(responseBody.encode());
|
|
||||||
} else {
|
|
||||||
Object data = responseBody.getValue("data");
|
|
||||||
if (data == null) {
|
|
||||||
ctx.response().setStatusCode(200).end(responseBody.encode());
|
|
||||||
} else {
|
|
||||||
// data может быть массивом, объектом или другим типом
|
|
||||||
ctx.response().setStatusCode(200).end(Json.encode(data));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fail(ctx, dataResp.statusCode(), "External API error: " + dataResp.statusMessage());
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.onFailure(err -> fail(ctx, 500, "Data request failed: " + err.getMessage()));
|
|
||||||
})
|
|
||||||
.onFailure(err -> fail(ctx, 500, "Auth request failed: " + err.getMessage()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private String sha1(String input) {
|
|
||||||
try {
|
|
||||||
MessageDigest md = MessageDigest.getInstance("SHA-1");
|
|
||||||
byte[] digest = md.digest(input.getBytes());
|
|
||||||
return HexFormat.of().formatHex(digest).toLowerCase();
|
|
||||||
} catch (NoSuchAlgorithmException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void fail(RoutingContext ctx, int status, String message) {
|
|
||||||
System.err.println("Error: " + message);
|
|
||||||
ctx.response().setStatusCode(status).end(new JsonObject().put("error", message).encode());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,5 +18,10 @@
|
|||||||
"password": null,
|
"password": null,
|
||||||
"maxPoolSize": 6,
|
"maxPoolSize": 6,
|
||||||
"maxWaitingHandlers": 6
|
"maxWaitingHandlers": 6
|
||||||
|
},
|
||||||
|
"pma": {
|
||||||
|
"enabled": false,
|
||||||
|
"basePath": "/pma",
|
||||||
|
"upstream": "http://localhost:80/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user