diff --git a/ansible/gather-host-facts.yml b/ansible/gather-host-facts.yml index 60efc17..5b2d01a 100644 --- a/ansible/gather-host-facts.yml +++ b/ansible/gather-host-facts.yml @@ -3,7 +3,59 @@ hosts: "{{ target_hosts | default('all') }}" become: true gather_facts: true + vars: + delivery_lookup_path: "{{ lookup_path | default('') }}" tasks: + - name: Show directory completions + ansible.builtin.shell: | + set -euo pipefail + python3 - <<'PY' + import json + import os + import sys + + prefix = os.environ.get("LOOKUP_PATH", "").strip() + if not prefix.startswith("/") or "\x00" in prefix: + print("XINFRA_PATH_COMPLETIONS_JSON=[]") + sys.exit(0) + + if prefix.endswith("/"): + parent = prefix.rstrip("/") or "/" + needle = "" + else: + parent = os.path.dirname(prefix) or "/" + needle = os.path.basename(prefix) + + items = [] + try: + children = sorted(os.listdir(parent)) + except OSError: + children = [] + + for name in children: + if needle and not name.startswith(needle): + continue + path = os.path.join(parent, name) if parent != "/" else "/" + name + if not os.path.isdir(path): + continue + available_gi = 0 + try: + stat = os.statvfs(path) + available_gi = int(stat.f_bavail * stat.f_frsize / 1073741824) + except OSError: + pass + items.append({"path": path, "available_gi": available_gi}) + + print("XINFRA_PATH_COMPLETIONS_JSON=" + json.dumps(items, ensure_ascii=False)) + PY + args: + executable: /bin/bash + environment: + LOOKUP_PATH: "{{ delivery_lookup_path }}" + changed_when: false + when: delivery_lookup_path | length > 0 + - name: Show discovered mounts ansible.builtin.debug: var: ansible_mounts + when: delivery_lookup_path | length == 0 diff --git a/ansible/mysql-deploy-callback.yml b/ansible/mysql-deploy-callback.yml index befff5f..945d3f9 100644 --- a/ansible/mysql-deploy-callback.yml +++ b/ansible/mysql-deploy-callback.yml @@ -65,7 +65,6 @@ # --- secrets (prefer launch extra_vars; fall back to AWX credential-injected env) --- mysql_root_password_value: "{{ mysql_root_password | default(lookup('ansible.builtin.env', 'XINFRA_MYSQL_ROOT_PASSWORD'), true) }}" - mysql_admin_password_value: "{{ mysql_admin_password | default(lookup('ansible.builtin.env', 'XINFRA_MYSQL_ADMIN_PASSWORD'), true) }}" # --- platform callback --- delivery_callback_url_value: "{{ delivery_callback_url | default('') }}" @@ -110,7 +109,6 @@ - (mysql_storage_gb_value | int) <= 2000 - (mysql_lower_case_table_names | int) in [0, 1] - mysql_root_password_value | length >= 16 - - mysql_admin_password_value | length >= 16 fail_msg: >- Delivery parameters out of the supported target-state whitelist (topology / version-package-map / port pool 13306-13999 / memory 2-64G / storage 20-2000G). @@ -453,9 +451,9 @@ /usr/bin/mysql --protocol=socket --socket={{ mysql_run_dir }}/mysql.sock -uroot <"$sql_file" fi cat >"$sql_file" <<'EOF' - CREATE USER IF NOT EXISTS 'xinfra_admin'@'%' IDENTIFIED BY '{{ mysql_admin_password_value | replace("'", "''") }}'; - ALTER USER 'xinfra_admin'@'%' IDENTIFIED BY '{{ mysql_admin_password_value | replace("'", "''") }}'; - GRANT ALL PRIVILEGES ON *.* TO 'xinfra_admin'@'%' WITH GRANT OPTION; + CREATE USER IF NOT EXISTS 'root'@'%' IDENTIFIED BY '{{ mysql_root_password_value | replace("'", "''") }}'; + ALTER USER 'root'@'%' IDENTIFIED BY '{{ mysql_root_password_value | replace("'", "''") }}'; + GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION; FLUSH PRIVILEGES; EOF /usr/bin/mysql --defaults-extra-file="$client_file" <"$sql_file" @@ -554,4 +552,5 @@ - name: Restart MySQL delivery instance ansible.builtin.systemd_service: name: "mysql-delivery@{{ mysql_instance }}.service" + daemon_reload: true state: restarted diff --git a/ansible/mysql-deploy.yml b/ansible/mysql-deploy.yml index a256b6f..ad5f69d 100644 --- a/ansible/mysql-deploy.yml +++ b/ansible/mysql-deploy.yml @@ -65,7 +65,6 @@ # --- secrets (prefer launch extra_vars; fall back to AWX credential-injected env) --- mysql_root_password_value: "{{ mysql_root_password | default(lookup('ansible.builtin.env', 'XINFRA_MYSQL_ROOT_PASSWORD'), true) }}" - mysql_admin_password_value: "{{ mysql_admin_password | default(lookup('ansible.builtin.env', 'XINFRA_MYSQL_ADMIN_PASSWORD'), true) }}" pre_tasks: - name: Validate delivery parameters @@ -86,7 +85,6 @@ - (mysql_storage_gb_value | int) <= 2000 - (mysql_lower_case_table_names | int) in [0, 1] - mysql_root_password_value | length >= 16 - - mysql_admin_password_value | length >= 16 fail_msg: >- Delivery parameters out of the supported target-state whitelist (topology / version-package-map / port pool 13306-13999 / memory 2-64G / storage 20-2000G). @@ -356,9 +354,9 @@ /usr/bin/mysql --protocol=socket --socket={{ mysql_run_dir }}/mysql.sock -uroot <"$sql_file" fi cat >"$sql_file" <<'EOF' - CREATE USER IF NOT EXISTS 'xinfra_admin'@'%' IDENTIFIED BY '{{ mysql_admin_password_value | replace("'", "''") }}'; - ALTER USER 'xinfra_admin'@'%' IDENTIFIED BY '{{ mysql_admin_password_value | replace("'", "''") }}'; - GRANT ALL PRIVILEGES ON *.* TO 'xinfra_admin'@'%' WITH GRANT OPTION; + CREATE USER IF NOT EXISTS 'root'@'%' IDENTIFIED BY '{{ mysql_root_password_value | replace("'", "''") }}'; + ALTER USER 'root'@'%' IDENTIFIED BY '{{ mysql_root_password_value | replace("'", "''") }}'; + GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION; FLUSH PRIVILEGES; EOF /usr/bin/mysql --defaults-extra-file="$client_file" <"$sql_file" @@ -385,4 +383,5 @@ - name: Restart MySQL delivery instance ansible.builtin.systemd_service: name: "mysql-delivery@{{ mysql_instance }}.service" + daemon_reload: true state: restarted diff --git a/ansible/mysql-inspect.yml b/ansible/mysql-inspect.yml new file mode 100644 index 0000000..25eac56 --- /dev/null +++ b/ansible/mysql-inspect.yml @@ -0,0 +1,111 @@ +--- +- name: Inspect native MySQL delivery instances + hosts: "{{ target_hosts | default('all') }}" + become: true + gather_facts: false + vars: + mysql_instances: "{{ mysql_instances | default([]) }}" + + tasks: + - name: Initialize host inspection result list + ansible.builtin.set_fact: + mysql_inspect_results: [] + + - name: Inspect instances assigned to this host + ansible.builtin.shell: + cmd: | + set -euo pipefail + instance="{{ item.instance_name }}" + service_name="{{ item.service_name | default('mysql-delivery@' ~ item.instance_name ~ '.service') }}" + config_file="{{ item.config_file | default('/etc/mysql/mysql-delivery/' ~ item.instance_name ~ '.cnf') }}" + expected_data_dir="{{ item.data_dir | default('') }}" + expected_base_dir="{{ item.base_dir | default('') }}" + install_dir="{{ item.install_dir | default('/opt/mysql-delivery/' ~ item.instance_name) }}" + run_dir="{{ item.run_dir | default('/run/mysql-delivery-' ~ item.instance_name) }}" + port="{{ item.port | default(0) }}" + + service_state="$(systemctl is-active "$service_name" 2>/dev/null || true)" + config_exists=false + install_exists=false + base_exists=false + data_exists=false + run_exists=false + port_listening=false + actual_data_dir="" + + [ -e "$config_file" ] && config_exists=true + [ -e "$install_dir" ] && install_exists=true + [ -n "$expected_base_dir" ] && [ -e "$expected_base_dir" ] && base_exists=true + [ -n "$expected_data_dir" ] && [ -e "$expected_data_dir" ] && data_exists=true + [ -e "$run_dir" ] && run_exists=true + if [ "$config_exists" = true ]; then + actual_data_dir="$(awk -F= '/^[[:space:]]*datadir[[:space:]]*=/{gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2); print $2; exit}' "$config_file" || true)" + fi + if [ -n "$port" ] && [ "$port" != "0" ] && ss -lntH "sport = :$port" | grep -q .; then + port_listening=true + fi + + status=unknown + if [ "$service_state" = "active" ] && [ "$port_listening" = true ] && [ "$data_exists" = true ] && [ -e "$expected_data_dir/auto.cnf" ]; then + status=running + elif [ "$config_exists" = true ] && [ -n "$actual_data_dir" ] && [ -n "$expected_data_dir" ] && [ "$actual_data_dir" != "$expected_data_dir" ]; then + status=moved + elif [ "$service_state" != "active" ] && [ "$config_exists" = false ] && [ "$install_exists" = false ] && [ "$base_exists" = false ] && [ "$run_exists" = false ]; then + status=deleted + elif [ "$config_exists" = true ] || [ "$install_exists" = true ] || [ "$base_exists" = true ] || [ "$data_exists" = true ]; then + status=stopped + fi + + export X_INSTANCE="$instance" + export X_STATUS="$status" + export X_SERVICE_STATE="$service_state" + export X_PORT_LISTENING="$port_listening" + export X_CONFIG_EXISTS="$config_exists" + export X_INSTALL_EXISTS="$install_exists" + export X_BASE_EXISTS="$base_exists" + export X_DATA_EXISTS="$data_exists" + export X_RUN_EXISTS="$run_exists" + export X_EXPECTED_DATA_DIR="$expected_data_dir" + export X_ACTUAL_DATA_DIR="$actual_data_dir" + python3 - < + (item.target_host | default('')) == inventory_hostname or + (item.host | default('')) == (ansible_host | default('')) + register: mysql_inspect_shell + changed_when: false + + - name: Merge host inspection results + ansible.builtin.set_fact: + mysql_inspect_results: "{{ mysql_inspect_results + [item.stdout | from_json] }}" + loop: "{{ mysql_inspect_shell.results | default([]) }}" + when: + - item is not skipped + - item.stdout is defined + - item.stdout | length > 0 + + - name: Emit machine-readable inspection results + ansible.builtin.debug: + msg: "XINFRA_MYSQL_INSPECT_RESULT_B64={{ mysql_inspect_results | to_json | b64encode }}" diff --git a/frontend/components.d.ts b/frontend/components.d.ts index d9fb1f7..aca2463 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -12,6 +12,7 @@ declare module 'vue' { AppSidebar: typeof import('./src/components/Layout/AppSidebar.vue')['default'] AuditLogTable: typeof import('./src/components/AuditLogTable.vue')['default'] BusinessLineSwitcher: typeof import('./src/components/BusinessLineSwitcher.vue')['default'] + ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete'] ElButton: typeof import('element-plus/es')['ElButton'] ElDialog: typeof import('element-plus/es')['ElDialog'] ElForm: typeof import('element-plus/es')['ElForm'] @@ -23,11 +24,8 @@ declare module 'vue' { ElPagination: typeof import('element-plus/es')['ElPagination'] ElSelect: typeof import('element-plus/es')['ElSelect'] ElSlider: typeof import('element-plus/es')['ElSlider'] - ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] - ElTabPane: typeof import('element-plus/es')['ElTabPane'] - ElTabs: typeof import('element-plus/es')['ElTabs'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] SubsystemCard: typeof import('./src/components/SubsystemCard.vue')['default'] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4d25368..c22f458 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,6 @@ "version": "0.1.0", "dependencies": { "@element-plus/icons-vue": "^2.1.0", - "@tanstack/vue-virtual": "^3.13.34", "axios": "^1.5.0", "element-plus": "^2.3.12", "pinia": "^2.1.4", @@ -994,32 +993,6 @@ "win32" ] }, - "node_modules/@tanstack/virtual-core": { - "version": "3.17.6", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.6.tgz", - "integrity": "sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/vue-virtual": { - "version": "3.13.34", - "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.34.tgz", - "integrity": "sha512-CBqbCcnVsKpl9IJ7frPnbBmAqmc7JttSySg04kgLYJ4yYuC8JsAbtUHP0yLtWGLyByjihN3SwaDCgHQ+Z4iPoA==", - "license": "MIT", - "dependencies": { - "@tanstack/virtual-core": "3.17.6" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "vue": "^2.7.0 || ^3.0.0" - } - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9acc643..c0666a2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,6 @@ }, "dependencies": { "@element-plus/icons-vue": "^2.1.0", - "@tanstack/vue-virtual": "^3.13.34", "axios": "^1.5.0", "element-plus": "^2.3.12", "pinia": "^2.1.4", diff --git a/frontend/src/api/businessLine.ts b/frontend/src/api/businessLine.ts index 5de8442..1cbd27e 100644 --- a/frontend/src/api/businessLine.ts +++ b/frontend/src/api/businessLine.ts @@ -14,6 +14,11 @@ export interface WayneNamespace { kubeNamespace: string } +export interface SinaOrganization { + id: string + name: string +} + export const businessLineApi = { async listMine(): Promise { const token = getToken() @@ -87,6 +92,28 @@ export const businessLineApi = { body: JSON.stringify({ namespaces }), }) }, + + async listSinaOrganizations(businessLineId: number, keyword = ''): Promise { + const params = new URLSearchParams() + if (keyword.trim()) { + params.set('keyword', keyword.trim()) + } + const suffix = params.toString() ? `?${params.toString()}` : '' + const data = await request(`/auth/api/v1/business-lines/${businessLineId}/sina-organizations${suffix}`) + return Array.isArray(data.items) ? data.items : [] + }, + + async listMappedSinaOrganizations(businessLineId: number): Promise { + const data = await request(`/auth/api/v1/business-lines/${businessLineId}/sina-organization-mappings`) + return Array.isArray(data.items) ? data.items : [] + }, + + async replaceMappedSinaOrganizations(businessLineId: number, organizations: SinaOrganization[]): Promise { + await request(`/auth/api/v1/business-lines/${businessLineId}/sina-organization-mappings`, { + method: 'PUT', + body: JSON.stringify({ organizations }), + }) + }, } async function request(path: string, init: RequestInit = {}) { diff --git a/frontend/src/api/delivery.ts b/frontend/src/api/delivery.ts index 1e4458a..f515306 100644 --- a/frontend/src/api/delivery.ts +++ b/frontend/src/api/delivery.ts @@ -45,7 +45,6 @@ export interface CreateMySQLDeliveryPayload { binlog_expire_logs_seconds?: number max_binlog_size?: string mysql_root_password?: string - mysql_admin_password?: string } export interface DeliveryTask { @@ -66,6 +65,7 @@ export interface DeliveryTask { updated_at: string started_at?: string finished_at?: string + credential_available?: boolean } export interface TaskEvent { @@ -85,8 +85,12 @@ export interface DeliveryTaskSnapshot { } export interface DeploymentCredential { - username: string + service: string + instance_name: string host: string + port: number + username: string + account_host: string password: string } @@ -109,8 +113,13 @@ export const deliveryApi = { return Array.isArray(data.items) ? data.items : [] }, - async listTargetMountPaths(targetId: number, host: string): Promise { - const data = await authRequest(`/auth/api/v1/delivery/targets/${targetId}/hosts/${encodeURIComponent(host)}/mount-paths`) + async listTargetMountPaths(targetId: number, host: string, prefix = ''): Promise { + const search = new URLSearchParams() + if (prefix) search.set('prefix', prefix) + const query = search.toString() + const data = await authRequest( + `/auth/api/v1/delivery/targets/${targetId}/hosts/${encodeURIComponent(host)}/mount-paths${query ? `?${query}` : ''}`, + ) return Array.isArray(data.items) ? data.items : [] }, @@ -140,6 +149,12 @@ export const deliveryApi = { return Array.isArray(data.items) ? data.items : [] }, + async syncMySQLServices(businessLineId: number): Promise { + await authRequest(`/auth/api/v1/delivery/business-lines/${businessLineId}/mysql-services/sync`, { + method: 'POST', + }) + }, + async getTask(taskId: string): Promise<{ task: DeliveryTask; events: TaskEvent[] }> { const data = await authRequest(`/auth/api/v1/delivery/tasks/${encodeURIComponent(taskId)}`) return { diff --git a/frontend/src/api/machine.ts b/frontend/src/api/machine.ts index 4e8c237..53fe66a 100644 --- a/frontend/src/api/machine.ts +++ b/frontend/src/api/machine.ts @@ -46,13 +46,13 @@ export interface MachineResourceList { export interface MachineResourceQuery { page: number size: number + businessLineId?: number hostname?: string assetNumber?: string type?: string location?: string ip?: string spec?: string - businessLine?: string source?: string status?: string } @@ -83,21 +83,24 @@ export const emptyMachineOverview: MachineOverview = { } export const machineApi = { - async getOverview(): Promise { - return authRequest('/auth/api/v1/machines/overview') + async getOverview(businessLineId?: number): Promise { + const params = new URLSearchParams() + if (businessLineId) params.set('business_line_id', String(businessLineId)) + const suffix = params.toString() ? `?${params.toString()}` : '' + return authRequest(`/auth/api/v1/machines/overview${suffix}`) }, async listResources(query: MachineResourceQuery): Promise { const params = new URLSearchParams() params.set('page', String(query.page)) params.set('size', String(query.size)) + if (query.businessLineId) params.set('business_line_id', String(query.businessLineId)) if (query.hostname) params.set('hostname', query.hostname) if (query.assetNumber) params.set('assetNumber', query.assetNumber) if (query.type) params.set('type', query.type) if (query.location) params.set('location', query.location) if (query.ip) params.set('ip', query.ip) if (query.spec) params.set('spec', query.spec) - if (query.businessLine) params.set('businessLine', query.businessLine) if (query.source) params.set('source', query.source) if (query.status) params.set('status', query.status) const data = await authRequest(`/auth/api/v1/machines/resources?${params.toString()}`) diff --git a/frontend/src/api/taskLog.ts b/frontend/src/api/taskLog.ts index 4791b15..c4505d9 100644 --- a/frontend/src/api/taskLog.ts +++ b/frontend/src/api/taskLog.ts @@ -24,10 +24,20 @@ export interface TaskLogLine { export interface TaskLogListParams { source?: string businessLineId?: number + page?: number + pageSize?: number +} + +export interface TaskLogListResult { + items: TaskLogSummary[] + total: number + page: number + page_size: number + total_pages: number } export const taskLogApi = { - async list(params: TaskLogListParams = {}): Promise { + async list(params: TaskLogListParams = {}): Promise { const query = new URLSearchParams() if (params.source && params.source !== 'all') { query.set('source', params.source) @@ -35,9 +45,21 @@ export const taskLogApi = { if (params.businessLineId) { query.set('business_line_id', String(params.businessLineId)) } + if (params.page) { + query.set('page', String(params.page)) + } + if (params.pageSize) { + query.set('page_size', String(params.pageSize)) + } const suffix = query.toString() ? `?${query.toString()}` : '' const data = await authRequest(`/auth/api/v1/task-logs${suffix}`) - return Array.isArray(data.items) ? data.items : [] + return { + items: Array.isArray(data.items) ? data.items : [], + total: data.total || 0, + page: data.page || 1, + page_size: data.page_size || 20, + total_pages: data.total_pages || 0, + } }, async get(id: string): Promise<{ task: TaskLogSummary; lines: TaskLogLine[] }> { @@ -89,6 +111,8 @@ export interface TaskLogStreamCallbacks { export function createTaskLogStream(taskId: string, callbacks: TaskLogStreamCallbacks): EventSource { const token = getToken() + // 注意:EventSource 不支持自定义请求头,只能通过 query parameter 传递 token + // TODO: 在生产环境中,应考虑使用 WebSocket 或 HttpOnly Cookie 方式以提高安全性 const url = `/auth/api/v1/task-logs/stream?task_id=${encodeURIComponent(taskId)}&access_token=${encodeURIComponent(token || '')}` const es = new EventSource(url) diff --git a/frontend/src/components/Layout/AppSidebar.vue b/frontend/src/components/Layout/AppSidebar.vue index a820f0e..0d9cc8c 100644 --- a/frontend/src/components/Layout/AppSidebar.vue +++ b/frontend/src/components/Layout/AppSidebar.vue @@ -12,19 +12,19 @@ ▤基础服务 - ▧任务日志2 + ▧任务日志{{ sidebarBadges.taskLogs }} - 同步 + 同步 - - - - - - - - - @@ -270,7 +240,7 @@