This commit is contained in:
2026-05-04 13:22:25 +03:00
parent f39d9ff11e
commit a61c527ef9
36 changed files with 794 additions and 29 deletions

View File

@@ -1,2 +1,5 @@
# iiko-connector
* `Числовые``Агрегация`
* `Категории``Группировка {ROW / COLUMN}`
* `Фильтры``Фильтрация`

View File

@@ -48,17 +48,26 @@ dependencies {
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")
// 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")
// 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")
// 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")
// 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("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 {

View File

@@ -93,6 +93,22 @@
<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
v-if="userStore.role === 'admin'"
to="/settings"
@@ -109,30 +125,6 @@
</svg>
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.settings') }}</span>
</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>
<!-- User Info (collapsed aware) -->
@@ -193,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" />
</svg>
</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')">
<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" />

View File

@@ -234,5 +234,31 @@
"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."
}
}

View File

@@ -234,5 +234,31 @@
"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": "Не удалось удалить подключение к БД."
}
}

View File

@@ -8,6 +8,7 @@ import Dashboard from '@/views/Dashboard.vue'
import Users from '@/views/Users.vue'
import Restaurants from '@/views/Restaurants.vue'
import OlapColumnsView from '@/views/OlapColumnsView.vue'
import DBConnections from '@/views/DBConnections.vue'
import AdminSettings from '@/views/AdminSettings.vue'
import Profile from '@/views/Profile.vue'
import NotFound from '@/views/NotFound.vue'
@@ -52,6 +53,11 @@ const routes = [
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,

View 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>

Binary file not shown.

Binary file not shown.

BIN
libs/asm-9.7.jar Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
libs/client-v2-0.9.8.jar Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
libs/commons-io-2.20.0.jar Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
libs/guava-33.4.6-jre.jar Normal file

Binary file not shown.

BIN
libs/httpclient5-5.4.4.jar Normal file

Binary file not shown.

BIN
libs/httpcore5-5.3.4.jar Normal file

Binary file not shown.

BIN
libs/httpcore5-h2-5.3.4.jar Normal file

Binary file not shown.

Binary file not shown.

BIN
libs/jdbc-v2-0.9.8.jar Normal file

Binary file not shown.

BIN
libs/lz4-java-1.10.4.jar Normal file

Binary file not shown.

Binary file not shown.

BIN
libs/postgresql-42.7.11.jar Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -38,6 +38,7 @@ public class MainVerticle extends AbstractVerticle {
private UserService userService;
private RestaurantService restaurantService;
private ExternalDataBaseService externalDataBaseService;
private SettingsService settingsService;
@Override
@@ -64,6 +65,7 @@ public class MainVerticle extends AbstractVerticle {
userService = new UserService(db.getPool());
restaurantService = new RestaurantService(db.getPool());
settingsService = new SettingsService(db.getPool());
externalDataBaseService = new ExternalDataBaseService(db.getPool(), vertx);
userService.initDatabase().onFailure(err -> {
log.error("Failed to initialize database", err);
@@ -77,6 +79,10 @@ public class MainVerticle extends AbstractVerticle {
log.error("Failed to initialize database", err);
startPromise.fail(err);
});
externalDataBaseService.initDatabase().onFailure(err -> {
log.error("Failed to initialize database", err);
startPromise.fail(err);
});
createRouterAndStartHttp(startPromise);
@@ -418,6 +424,8 @@ public class MainVerticle extends AbstractVerticle {
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
externalDataBaseService.handleRoute(router);
new IikoHandler(vertx, router, db, restaurantService, authHandler);
return router;

View File

@@ -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;
};
}
}

View File

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