| <template> |
| <div |
| class="flex min-h-screen items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 p-4 dark:from-dark-900 dark:to-dark-800" |
| > |
| <div class="w-full max-w-2xl"> |
| |
| <div class="mb-8 text-center"> |
| <div |
| class="mb-4 inline-flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 shadow-lg" |
| > |
| <Icon name="cog" size="xl" class="text-white" /> |
| </div> |
| <h1 class="text-3xl font-bold text-gray-900 dark:text-white">{{ t('setup.title') }}</h1> |
| <p class="mt-2 text-gray-500 dark:text-dark-400">{{ t('setup.description') }}</p> |
| </div> |
| |
| |
| <div class="mb-8"> |
| <div class="flex items-center justify-center"> |
| <template v-for="(step, index) in steps" :key="step.id"> |
| <div class="flex items-center"> |
| <div |
| :class="[ |
| 'flex h-10 w-10 items-center justify-center rounded-full text-sm font-semibold transition-all', |
| currentStep > index |
| ? 'bg-primary-500 text-white' |
| : currentStep === index |
| ? 'bg-primary-500 text-white ring-4 ring-primary-100 dark:ring-primary-900' |
| : 'bg-gray-200 text-gray-500 dark:bg-dark-700 dark:text-dark-400' |
| ]" |
| > |
| <Icon |
| v-if="currentStep > index" |
| name="check" |
| size="md" |
| :stroke-width="2" |
| /> |
| <span v-else>{{ index + 1 }}</span> |
| </div> |
| <span |
| class="ml-2 text-sm font-medium" |
| :class=" |
| currentStep >= index |
| ? 'text-gray-900 dark:text-white' |
| : 'text-gray-400 dark:text-dark-500' |
| " |
| > |
| {{ step.title }} |
| </span> |
| </div> |
| <div |
| v-if="index < steps.length - 1" |
| class="mx-3 h-0.5 w-12" |
| :class="currentStep > index ? 'bg-primary-500' : 'bg-gray-200 dark:bg-dark-700'" |
| ></div> |
| </template> |
| </div> |
| </div> |
| |
| |
| <div class="rounded-2xl bg-white p-8 shadow-xl dark:bg-dark-800"> |
| |
| <div v-if="currentStep === 0" class="space-y-6"> |
| <div class="mb-6 text-center"> |
| <h2 class="text-xl font-semibold text-gray-900 dark:text-white"> |
| {{ t('setup.database.title') }} |
| </h2> |
| <p class="mt-1 text-sm text-gray-500 dark:text-dark-400"> |
| {{ t('setup.database.description') }} |
| </p> |
| </div> |
| |
| <div class="grid grid-cols-2 gap-4"> |
| <div> |
| <label class="input-label">{{ t('setup.database.host') }}</label> |
| <input |
| v-model="formData.database.host" |
| type="text" |
| class="input" |
| placeholder="localhost" |
| /> |
| </div> |
| <div> |
| <label class="input-label">{{ t('setup.database.port') }}</label> |
| <input |
| v-model.number="formData.database.port" |
| type="number" |
| class="input" |
| placeholder="5432" |
| /> |
| </div> |
| </div> |
| |
| <div class="flex items-center justify-between rounded-xl border border-gray-200 p-3 dark:border-dark-700"> |
| <div> |
| <p class="text-sm font-medium text-gray-900 dark:text-white"> |
| {{ t("setup.redis.enableTls") }} |
| </p> |
| <p class="text-xs text-gray-500 dark:text-dark-400"> |
| {{ t("setup.redis.enableTlsHint") }} |
| </p> |
| </div> |
| <Toggle v-model="formData.redis.enable_tls" /> |
| </div> |
| |
| <div class="grid grid-cols-2 gap-4"> |
| <div> |
| <label class="input-label">{{ t('setup.database.username') }}</label> |
| <input |
| v-model="formData.database.user" |
| type="text" |
| class="input" |
| placeholder="postgres" |
| /> |
| </div> |
| <div> |
| <label class="input-label">{{ t('setup.database.password') }}</label> |
| <input |
| v-model="formData.database.password" |
| type="password" |
| class="input" |
| :placeholder="t('setup.database.passwordPlaceholder')" |
| /> |
| </div> |
| </div> |
| |
| <div class="grid grid-cols-2 gap-4"> |
| <div> |
| <label class="input-label">{{ t('setup.database.databaseName') }}</label> |
| <input |
| v-model="formData.database.dbname" |
| type="text" |
| class="input" |
| placeholder="sub2api" |
| /> |
| </div> |
| <div> |
| <label class="input-label">{{ t('setup.database.sslMode') }}</label> |
| <Select |
| v-model="formData.database.sslmode" |
| :options="[ |
| { value: 'disable', label: t('setup.database.ssl.disable') }, |
| { value: 'require', label: t('setup.database.ssl.require') }, |
| { value: 'verify-ca', label: t('setup.database.ssl.verifyCa') }, |
| { value: 'verify-full', label: t('setup.database.ssl.verifyFull') } |
| ]" |
| /> |
| </div> |
| </div> |
| |
| <button |
| @click="testDatabaseConnection" |
| :disabled="testingDb" |
| class="btn btn-secondary w-full" |
| > |
| <svg |
| v-if="testingDb" |
| class="-ml-1 mr-2 h-4 w-4 animate-spin" |
| 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> |
| <Icon v-else-if="dbConnected" name="check" size="md" class="mr-2 text-green-500" :stroke-width="2" /> |
| {{ |
| testingDb |
| ? t('setup.status.testing') |
| : dbConnected |
| ? t('setup.status.success') |
| : t('setup.status.testConnection') |
| }} |
| </button> |
| </div> |
| |
| |
| <div v-if="currentStep === 1" class="space-y-6"> |
| <div class="mb-6 text-center"> |
| <h2 class="text-xl font-semibold text-gray-900 dark:text-white"> |
| {{ t('setup.redis.title') }} |
| </h2> |
| <p class="mt-1 text-sm text-gray-500 dark:text-dark-400"> |
| {{ t('setup.redis.description') }} |
| </p> |
| </div> |
| |
| <div class="grid grid-cols-2 gap-4"> |
| <div> |
| <label class="input-label">{{ t('setup.redis.host') }}</label> |
| <input |
| v-model="formData.redis.host" |
| type="text" |
| class="input" |
| placeholder="localhost" |
| /> |
| </div> |
| <div> |
| <label class="input-label">{{ t('setup.redis.port') }}</label> |
| <input |
| v-model.number="formData.redis.port" |
| type="number" |
| class="input" |
| placeholder="6379" |
| /> |
| </div> |
| </div> |
| |
| <div class="grid grid-cols-2 gap-4"> |
| <div> |
| <label class="input-label">{{ t('setup.redis.password') }}</label> |
| <input |
| v-model="formData.redis.password" |
| type="password" |
| class="input" |
| :placeholder="t('setup.redis.passwordPlaceholder')" |
| /> |
| </div> |
| <div> |
| <label class="input-label">{{ t('setup.redis.database') }}</label> |
| <input |
| v-model.number="formData.redis.db" |
| type="number" |
| class="input" |
| placeholder="0" |
| /> |
| </div> |
| </div> |
| |
| <div class="flex items-center justify-between rounded-xl border border-gray-200 p-3 dark:border-dark-700"> |
| <div> |
| <p class="text-sm font-medium text-gray-900 dark:text-white"> |
| {{ t("setup.redis.enableTls") }} |
| </p> |
| <p class="text-xs text-gray-500 dark:text-dark-400"> |
| {{ t("setup.redis.enableTlsHint") }} |
| </p> |
| </div> |
| <Toggle v-model="formData.redis.enable_tls" /> |
| </div> |
| |
| <button |
| @click="testRedisConnection" |
| :disabled="testingRedis" |
| class="btn btn-secondary w-full" |
| > |
| <svg |
| v-if="testingRedis" |
| class="-ml-1 mr-2 h-4 w-4 animate-spin" |
| 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> |
| <Icon |
| v-else-if="redisConnected" |
| name="check" |
| size="md" |
| class="mr-2 text-green-500" |
| :stroke-width="2" |
| /> |
| {{ |
| testingRedis |
| ? t('setup.status.testing') |
| : redisConnected |
| ? t('setup.status.success') |
| : t('setup.status.testConnection') |
| }} |
| </button> |
| </div> |
| |
| |
| <div v-if="currentStep === 2" class="space-y-6"> |
| <div class="mb-6 text-center"> |
| <h2 class="text-xl font-semibold text-gray-900 dark:text-white"> |
| {{ t('setup.admin.title') }} |
| </h2> |
| <p class="mt-1 text-sm text-gray-500 dark:text-dark-400"> |
| {{ t('setup.admin.description') }} |
| </p> |
| </div> |
| |
| <div> |
| <label class="input-label">{{ t('setup.admin.email') }}</label> |
| <input |
| v-model="formData.admin.email" |
| type="email" |
| class="input" |
| placeholder="admin@example.com" |
| /> |
| </div> |
| |
| <div> |
| <label class="input-label">{{ t('setup.admin.password') }}</label> |
| <input |
| v-model="formData.admin.password" |
| type="password" |
| class="input" |
| :placeholder="t('setup.admin.passwordPlaceholder')" |
| /> |
| </div> |
| |
| <div> |
| <label class="input-label">{{ t('setup.admin.confirmPassword') }}</label> |
| <input |
| v-model="confirmPassword" |
| type="password" |
| class="input" |
| :placeholder="t('setup.admin.confirmPasswordPlaceholder')" |
| /> |
| <p |
| v-if="confirmPassword && formData.admin.password !== confirmPassword" |
| class="input-error-text" |
| > |
| {{ t('setup.admin.passwordMismatch') }} |
| </p> |
| </div> |
| </div> |
| |
| |
| <div v-if="currentStep === 3" class="space-y-6"> |
| <div class="mb-6 text-center"> |
| <h2 class="text-xl font-semibold text-gray-900 dark:text-white"> |
| {{ t('setup.ready.title') }} |
| </h2> |
| <p class="mt-1 text-sm text-gray-500 dark:text-dark-400"> |
| {{ t('setup.ready.description') }} |
| </p> |
| </div> |
| |
| <div class="space-y-4"> |
| <div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-700"> |
| <h3 class="mb-2 text-sm font-medium text-gray-500 dark:text-dark-400"> |
| {{ t('setup.ready.database') }} |
| </h3> |
| <p class="text-gray-900 dark:text-white"> |
| {{ formData.database.user }}@{{ formData.database.host }}:{{ |
| formData.database.port |
| }}/{{ formData.database.dbname }} |
| </p> |
| </div> |
| |
| <div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-700"> |
| <h3 class="mb-2 text-sm font-medium text-gray-500 dark:text-dark-400"> |
| {{ t('setup.ready.redis') }} |
| </h3> |
| <p class="text-gray-900 dark:text-white"> |
| {{ formData.redis.host }}:{{ formData.redis.port }} |
| </p> |
| </div> |
| |
| <div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-700"> |
| <h3 class="mb-2 text-sm font-medium text-gray-500 dark:text-dark-400"> |
| {{ t('setup.ready.adminEmail') }} |
| </h3> |
| <p class="text-gray-900 dark:text-white">{{ formData.admin.email }}</p> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div |
| v-if="errorMessage" |
| class="mt-6 rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20" |
| > |
| <div class="flex items-start gap-3"> |
| <Icon name="exclamationCircle" size="md" class="flex-shrink-0 text-red-500" /> |
| <p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p> |
| </div> |
| </div> |
| |
| |
| <div |
| v-if="installSuccess" |
| class="mt-6 rounded-xl border border-green-200 bg-green-50 p-4 dark:border-green-800/50 dark:bg-green-900/20" |
| > |
| <div class="flex items-start gap-3"> |
| <svg |
| v-if="!serviceReady" |
| class="h-5 w-5 flex-shrink-0 animate-spin text-green-500" |
| 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> |
| <Icon v-else name="checkCircle" size="md" class="flex-shrink-0 text-green-500" /> |
| <div> |
| <p class="text-sm font-medium text-green-700 dark:text-green-400"> |
| {{ t('setup.status.completed') }} |
| </p> |
| <p class="mt-1 text-sm text-green-600 dark:text-green-500"> |
| {{ |
| serviceReady |
| ? t('setup.status.redirecting') |
| : t('setup.status.restarting') |
| }} |
| </p> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div class="mt-8 flex justify-between"> |
| <button |
| v-if="currentStep > 0 && !installSuccess" |
| @click="currentStep--" |
| class="btn btn-secondary" |
| > |
| <Icon name="chevronLeft" size="sm" class="mr-2" :stroke-width="2" /> |
| {{ t('common.back') }} |
| </button> |
| <div v-else></div> |
| |
| <button |
| v-if="currentStep < 3" |
| @click="nextStep" |
| :disabled="!canProceed" |
| class="btn btn-primary" |
| > |
| {{ t('common.next') }} |
| <Icon name="chevronRight" size="sm" class="ml-2" :stroke-width="2" /> |
| </button> |
| |
| <button |
| v-else-if="!installSuccess" |
| @click="performInstall" |
| :disabled="installing" |
| class="btn btn-primary" |
| > |
| <svg |
| v-if="installing" |
| class="-ml-1 mr-2 h-4 w-4 animate-spin" |
| 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> |
| {{ installing ? t('setup.status.installing') : t('setup.status.completeInstallation') }} |
| </button> |
| </div> |
| </div> |
| </div> |
| </div> |
| </template> |
| |
| <script setup lang="ts"> |
| import { ref, reactive, computed } from 'vue' |
| import { useI18n } from 'vue-i18n' |
| import { testDatabase, testRedis, install, type InstallRequest } from '@/api/setup' |
| import Select from '@/components/common/Select.vue' |
| import Toggle from '@/components/common/Toggle.vue' |
| import Icon from '@/components/icons/Icon.vue' |
| |
| const { t } = useI18n() |
| |
| const steps = computed(() => [ |
| { id: 'database', title: t('setup.database.title') }, |
| { id: 'redis', title: t('setup.redis.title') }, |
| { id: 'admin', title: t('setup.admin.title') }, |
| { id: 'complete', title: t('setup.ready.title') } |
| ]) |
| |
| const currentStep = ref(0) |
| const errorMessage = ref('') |
| const installSuccess = ref(false) |
| |
| |
| const testingDb = ref(false) |
| const testingRedis = ref(false) |
| const dbConnected = ref(false) |
| const redisConnected = ref(false) |
| const installing = ref(false) |
| const confirmPassword = ref('') |
| const serviceReady = ref(false) |
| |
| |
| const getCurrentPort = (): number => { |
| const port = window.location.port |
| if (port) { |
| return parseInt(port, 10) |
| } |
| |
| return window.location.protocol === 'https:' ? 443 : 80 |
| } |
| |
| const formData = reactive<InstallRequest>({ |
| database: { |
| host: 'localhost', |
| port: 5432, |
| user: 'postgres', |
| password: '', |
| dbname: 'sub2api', |
| sslmode: 'disable' |
| }, |
| redis: { |
| host: 'localhost', |
| port: 6379, |
| password: '', |
| db: 0, |
| enable_tls: false |
| }, |
| admin: { |
| email: '', |
| password: '' |
| }, |
| server: { |
| host: '0.0.0.0', |
| port: getCurrentPort(), |
| mode: 'release' |
| } |
| }) |
| |
| const canProceed = computed(() => { |
| switch (currentStep.value) { |
| case 0: |
| return dbConnected.value |
| case 1: |
| return redisConnected.value |
| case 2: |
| return ( |
| formData.admin.email && |
| formData.admin.password.length >= 8 && |
| formData.admin.password === confirmPassword.value |
| ) |
| default: |
| return true |
| } |
| }) |
| |
| async function testDatabaseConnection() { |
| testingDb.value = true |
| errorMessage.value = '' |
| dbConnected.value = false |
| |
| try { |
| await testDatabase(formData.database) |
| dbConnected.value = true |
| } catch (error: unknown) { |
| const err = error as { response?: { data?: { detail?: string; message?: string } }; message?: string } |
| errorMessage.value = |
| err.response?.data?.detail || err.response?.data?.message || err.message || 'Connection failed' |
| } finally { |
| testingDb.value = false |
| } |
| } |
| |
| async function testRedisConnection() { |
| testingRedis.value = true |
| errorMessage.value = '' |
| redisConnected.value = false |
| |
| try { |
| await testRedis(formData.redis) |
| redisConnected.value = true |
| } catch (error: unknown) { |
| const err = error as { response?: { data?: { detail?: string; message?: string } }; message?: string } |
| errorMessage.value = |
| err.response?.data?.detail || err.response?.data?.message || err.message || 'Connection failed' |
| } finally { |
| testingRedis.value = false |
| } |
| } |
| |
| function nextStep() { |
| if (canProceed.value) { |
| errorMessage.value = '' |
| currentStep.value++ |
| } |
| } |
| |
| async function performInstall() { |
| installing.value = true |
| errorMessage.value = '' |
| |
| try { |
| await install(formData) |
| installSuccess.value = true |
| |
| waitForServiceRestart() |
| } catch (error: unknown) { |
| const err = error as { response?: { data?: { detail?: string; message?: string } }; message?: string } |
| errorMessage.value = |
| err.response?.data?.detail || err.response?.data?.message || err.message || 'Installation failed' |
| } finally { |
| installing.value = false |
| } |
| } |
| |
| |
| async function waitForServiceRestart() { |
| const maxAttempts = 60 |
| const interval = 1000 |
| |
| |
| await new Promise((resolve) => setTimeout(resolve, 3000)) |
| |
| for (let attempt = 0; attempt < maxAttempts; attempt++) { |
| try { |
| |
| |
| const response = await fetch('/setup/status', { |
| method: 'GET', |
| cache: 'no-store' |
| }) |
| |
| if (response.ok) { |
| const data = await response.json() |
| |
| if (data.data && !data.data.needs_setup) { |
| serviceReady.value = true |
| |
| setTimeout(() => { |
| window.location.href = '/login' |
| }, 1500) |
| return |
| } |
| } |
| } catch { |
| |
| } |
| |
| await new Promise((resolve) => setTimeout(resolve, interval)) |
| } |
| |
| |
| |
| errorMessage.value = t('setup.status.timeout') |
| } |
| </script> |
| |