Compare commits

10 Commits
Author SHA1 Message Date
admin 8cdd3f3df9 前端优化文档 2026-07-26 03:56:34 +08:00
admin d34ab6351d feat(training): add RTX 3090 local training workbench 2026-07-25 19:04:13 +08:00
admin 9fc7459e1e 修复 Windows CPU 启动检查超时 2026-07-25 18:28:41 +08:00
admin e9b7331e5a docs: add Gitea deployment guide 2026-07-25 18:17:04 +08:00
admin 1245ec9b20 feat(deploy): 实现离线 Kubernetes 部署首版 2026-07-25 17:55:34 +08:00
admin 24afcc2cae 实现司空2私有化OpenAPI接入 2026-07-25 16:41:07 +08:00
admin 1319091f2e 规划离线 Kubernetes 一键部署方案 2026-07-25 16:09:46 +08:00
admin c9eae8c915 大疆司空2集成方案 2026-07-25 15:16:01 +08:00
admin 2bf60bf0d9 优化黑框展示 2026-07-25 12:27:39 +08:00
admin 51340602c3 大疆集成设计文档 2026-07-23 14:57:32 +08:00
156 changed files with 18645 additions and 136 deletions
+7
View File
@@ -1,4 +1,5 @@
.idea/
/.m2-repository/
__pycache__/
*.pyc
runtime/
@@ -11,6 +12,12 @@ node_modules/
.env
.env.local
.env.server
.env.flighthub2
.DS_Store
*.class
.mvn/wrapper/maven-wrapper.jar
/Cloud-API-Demo-Web-1.10.0.zip
/DJI-Cloud-API-Demo-1.10.0.zip
/Cloud-API-Demo-Web-1.10.0/
/DJI-Cloud-API-Demo-1.10.0/
/infra/secrets/
+1
View File
@@ -0,0 +1 @@
* text eol=lf
+10
View File
@@ -0,0 +1,10 @@
artifacts/
dist/
.state/
*.log
config/site.yml
config/secrets.yaml
config/secrets.sops.yaml
inventories/*/hosts.yml
__pycache__/
*.pyc
+205
View File
@@ -0,0 +1,205 @@
# AItrackwalker 离线 Kubernetes 部署
该目录是离线部署项目的首个实施增量,目标是使用同一套入口支持:
- `single`:单节点 RKE2,内置单实例数据服务,用于测试和验收;
- `ha`3 个或更多 RKE2 server 加 worker,数据服务由独立 Chart/Operator 或外部服务提供;
- 目标节点无公网访问,所有二进制、镜像、Chart、OS 包和模型来自已校验离线包。
总体设计见 [离线 Kubernetes 一键部署项目方案](../docs/offline-kubernetes-deployment-project-plan.md)。
## 当前实现范围
已实现:
- 站点配置、秘密配置和单机/多机 inventory 示例;
- 配置、CIDR、节点数量、口令和版本锁预检;
- 离线包 SHA-256/签名验证入口;
- Linux 基线、RKE2 air-gap 制品分发、server/agent 安装的 Ansible roles
- RKE2 内置分布式 OCI 镜像分发配置;
- 平台 Helm Chart
- frontend
- backend
- uav-access-service
- vision-inference
- pointcloud-analysis
- artifact-installer
- 单机内置 PostGIS、Redis、Kafka、MinIO、Prometheus
- 可选 EMQX
- Longhorn 和 NVIDIA GPU Operator 离线 Chart 安装入口;
- 集群和工作负载基础验收脚本。
尚未宣称完成:
- PostGIS/Kafka/MinIO 的生产 HA Operator
- kube-vip、MetalLB 和云 LB 的自动配置;
- 后端多副本改造;
- 模型从对象存储同步到 Pod 本地缓存;
- 联网发布工厂自动拉取和扫描全部依赖;
- 生产版本兼容矩阵。`versions.lock.yaml` 在完成 M0 验证前保持 `validated: false`
## 目录
```text
deploy/
├── config/ # 站点配置、秘密示例和 schema
├── inventories/ # single/ha inventory 示例
├── ansible/ # Linux 与 RKE2 自动化
├── charts/trackwalker/ # 平台 Helm Chart
├── addons/ # 第三方 Chart 的离线 values
├── scripts/ # 部署、校验、验收、bundle 构建
├── tests/ # 不依赖集群的静态测试
└── versions.lock.yaml # 版本与镜像锁
```
## 离线包约定
部署入口默认认为 bundle 根目录结构如下:
```text
trackwalker-offline-<version>/
├── deploy/
├── tools/
│ └── bin/
│ ├── ansible-playbook
│ ├── helm
│ ├── kubectl
│ ├── yq
│ ├── sops
│ └── cosign
├── rke2/
│ ├── install.sh
│ ├── rke2.linux-amd64.tar.gz
│ ├── rke2-images.linux-amd64.tar.zst
│ └── sha256sum-amd64.txt
├── images/
│ ├── platform-images.oci.tar.zst
│ └── addon-images.oci.tar.zst
├── os-packages/
│ └── ubuntu/
├── charts/
│ └── third-party/
├── models/
├── SHA256SUMS
├── SHA256SUMS.sig
```
离线目标机不运行 Maven、npm、pip 或 Docker build。
## 准备配置
```bash
cp deploy/config/site.example.yaml deploy/config/site.yml
cp deploy/config/secrets.example.yaml deploy/config/secrets.yaml
cp deploy/inventories/single/hosts.example.yml deploy/inventories/single/hosts.yml
```
HA 环境改用 `deploy/config/site-ha.example.yaml`
`deploy/inventories/ha/hosts.example.yml` 作为模板。HA 示例假设 API
负载均衡/VIP、DNS、TLS Secret 和外部有状态服务已经准备完成。
修改三个文件:
- `site.yml`:CIDR、入口、存储、运行档案和 Helm 覆盖值;
- `secrets.yaml`RKE2 token、数据库、MinIO 和内部服务口令;
- `hosts.yml`:节点 IP、SSH 用户和角色。
生产环境应加密秘密:
```bash
sops --encrypt deploy/config/secrets.yaml > deploy/config/secrets.sops.yaml
rm deploy/config/secrets.yaml
```
示例秘密包含 `CHANGE_ME`,部署预检会主动拒绝。
## 配置预检
```bash
python3 deploy/scripts/validate_config.py \
--site deploy/config/site.yml \
--inventory deploy/inventories/single/hosts.yml \
--secrets deploy/config/secrets.sops.yaml \
--versions deploy/versions.lock.yaml
```
加密秘密由 `deploy.sh` 先解密后再交给预检程序。
## 一键部署
开发/M0 阶段尚未生成签名 bundle 时:
```bash
deploy/scripts/deploy.sh \
--profile single \
--inventory deploy/inventories/single/hosts.yml \
--config deploy/config/site.yml \
--secrets deploy/config/secrets.sops.yaml \
--allow-development-bundle
```
正式发布需另外提供由部署控制机预置、且位于 bundle 目录之外的发布公钥:
```bash
deploy/scripts/deploy.sh ... \
--trusted-public-key /etc/trackwalker/release-signing.pub
```
正式离线包不得使用 `--allow-development-bundle`,并要求:
- `versions.lock.yaml` 已设置 `validated: true`
- 所有镜像包含 digest
- `SHA256SUMS` 校验通过;
- `SHA256SUMS.sig` 使用 bundle 外的可信公钥验证。
常用模式:
```bash
# 只执行预检
deploy/scripts/deploy.sh ... --preflight-only
# 从 RKE2 阶段继续
deploy/scripts/deploy.sh ... --resume-from rke2
# 只部署集群,不安装应用
deploy/scripts/deploy.sh ... --skip-app
# 只执行验收
deploy/scripts/deploy.sh ... --verify-only
```
## HA inventory 规则
- `rke2_servers` 必须为奇数,生产最少 3 个;
- 第一个 server 是 bootstrap server
- `rke2_agents` 可以为空;
- GPU 节点同时加入 `gpu_workers`
- 每个节点必须显式设置 `rke2_node_ip`
- `ha` 默认关闭 Chart 内置数据服务,端点由 `helmValues.endpoints` 指定。
## 安全约束
- 默认口令、短 Token、`latest` 和未解析镜像 digest 在正式发布时被拒绝;
- Secret 不写入 Helm values 和 Helm release history,由部署入口创建既有 Kubernetes Secret
- 目标节点上的 RKE2 token、kubeconfig 和秘密临时文件权限为 `0600`
- 卸载和数据销毁不在当前入口中,避免误删 PVC;
- `artifact-installer` 保持单副本;
- 后端在完成分布式任务/SSE 改造前保持单副本。
## 本地静态测试
```bash
python3 -m unittest discover -s deploy/tests -p "test_*.py"
python3 deploy/tests/validate_project.py
```
测试发现 Helm 时会额外渲染单机和 HA Chart;如果 Helm 不在 `PATH`
中,可设置 `HELM_BINARY=/absolute/path/to/helm`
有 Helm 的环境还应执行:
```bash
helm lint deploy/charts/trackwalker
helm template trackwalker deploy/charts/trackwalker \
-f deploy/charts/trackwalker/values-single.yaml >/tmp/trackwalker.yaml
```
+14
View File
@@ -0,0 +1,14 @@
driver:
enabled: false
toolkit:
enabled: true
dcgmExporter:
enabled: true
gfd:
enabled: true
operator:
defaultRuntime: containerd
+12
View File
@@ -0,0 +1,12 @@
defaultSettings:
defaultReplicaCount: 3
replicaAutoBalance: best-effort
storageMinimalAvailablePercentage: 20
upgradeChecker: false
persistence:
defaultClass: false
defaultClassReplicaCount: 3
longhornUI:
replicas: 1
+14
View File
@@ -0,0 +1,14 @@
[defaults]
inventory = ../inventories/single/hosts.yml
roles_path = roles
host_key_checking = True
retry_files_enabled = False
interpreter_python = auto_silent
stdout_callback = default
bin_ansible_callbacks = True
forks = 10
timeout = 30
[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
@@ -0,0 +1,33 @@
---
- name: Copy RKE2 offline installer and release files
ansible.builtin.copy:
src: "{{ offline_artifact_root }}/rke2/{{ item.name }}"
dest: "/opt/trackwalker/rke2-artifacts/{{ item.name }}"
owner: root
group: root
mode: "{{ item.mode }}"
loop:
- { name: "install.sh", mode: "0755" }
- { name: "rke2.linux-amd64.tar.gz", mode: "0644" }
- { name: "rke2-images.linux-amd64.tar.zst", mode: "0644" }
- { name: "sha256sum-amd64.txt", mode: "0644" }
- name: Verify RKE2 release checksums on target
ansible.builtin.command:
cmd: sha256sum --check sha256sum-amd64.txt --ignore-missing
chdir: /opt/trackwalker/rke2-artifacts
changed_when: false
- name: Find additional OCI archives on controller
ansible.builtin.set_fact:
platform_image_archives: "{{ query('fileglob', offline_artifact_root + '/images/*') }}"
- name: Copy platform and addon OCI archives into RKE2 import directory
ansible.builtin.copy:
src: "{{ item }}"
dest: "/var/lib/rancher/rke2/agent/images/{{ item | basename }}"
owner: root
group: root
mode: "0644"
loop: "{{ platform_image_archives }}"
when: platform_image_archives | length > 0
@@ -0,0 +1,78 @@
---
- name: Create deployment and RKE2 directories
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: root
group: root
mode: "{{ item.mode }}"
loop:
- { path: "/opt/trackwalker", mode: "0755" }
- { path: "/opt/trackwalker/rke2-artifacts", mode: "0755" }
- { path: "/opt/trackwalker/os-packages", mode: "0755" }
- { path: "{{ cluster.dataDir }}", mode: "0700" }
- { path: "/etc/rancher/rke2", mode: "0700" }
- { path: "/var/lib/rancher/rke2/agent/images", mode: "0755" }
- name: Configure required kernel modules
ansible.builtin.template:
src: trackwalker-k8s-modules.conf.j2
dest: /etc/modules-load.d/trackwalker-k8s.conf
owner: root
group: root
mode: "0644"
- name: Load required kernel modules
ansible.builtin.command: "modprobe {{ item }}"
loop:
- overlay
- br_netfilter
changed_when: false
- name: Configure Kubernetes sysctl settings
ansible.builtin.template:
src: 90-trackwalker-k8s.conf.j2
dest: /etc/sysctl.d/90-trackwalker-k8s.conf
owner: root
group: root
mode: "0644"
register: kubernetes_sysctl
- name: Apply Kubernetes sysctl settings
ansible.builtin.command: sysctl --system
changed_when: kubernetes_sysctl.changed
- name: Inspect optional offline OS package directory
ansible.builtin.stat:
path: "{{ offline_artifact_root }}/{{ hostBaseline.offlinePackageDirectory }}"
delegate_to: localhost
become: false
register: offline_os_packages
- name: Copy optional offline OS package repository
ansible.builtin.copy:
src: "{{ offline_artifact_root }}/{{ hostBaseline.offlinePackageDirectory }}/"
dest: /opt/trackwalker/os-packages/
owner: root
group: root
mode: preserve
when: offline_os_packages.stat.exists
- name: Discover copied Debian packages
ansible.builtin.find:
paths: /opt/trackwalker/os-packages
patterns: "*.deb"
recurse: true
file_type: file
register: copied_deb_packages
- name: Install offline Debian packages
ansible.builtin.shell: |
set -e
find /opt/trackwalker/os-packages -type f -name '*.deb' -print0 \
| xargs -0 --no-run-if-empty apt-get install -y
args:
executable: /bin/bash
when: copied_deb_packages.matched | int > 0
register: offline_package_install
changed_when: "'0 upgraded, 0 newly installed' not in offline_package_install.stdout"
@@ -0,0 +1,6 @@
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
fs.inotify.max_user_instances = 8192
fs.inotify.max_user_watches = 1048576
fs.file-max = 2097152
@@ -0,0 +1,2 @@
overlay
br_netfilter
@@ -0,0 +1,57 @@
---
- name: Wait for all expected Kubernetes nodes
ansible.builtin.shell: |
set -e
expected={{ (groups.get('rke2_servers', []) + groups.get('rke2_agents', [])) | length }}
actual=$(/var/lib/rancher/rke2/bin/kubectl \
--kubeconfig /etc/rancher/rke2/rke2.yaml \
get nodes --no-headers | wc -l)
test "$actual" -eq "$expected"
args:
executable: /bin/bash
register: cluster_node_count
retries: 60
delay: 10
until: cluster_node_count.rc == 0
changed_when: false
when: inventory_hostname == groups["rke2_servers"][0]
- name: Read Kubernetes node readiness
ansible.builtin.command: >-
/var/lib/rancher/rke2/bin/kubectl
--kubeconfig /etc/rancher/rke2/rke2.yaml
get nodes -o wide
register: kubernetes_nodes
changed_when: false
when: inventory_hostname == groups["rke2_servers"][0]
- name: Print Kubernetes node readiness
ansible.builtin.debug:
var: kubernetes_nodes.stdout_lines
when: inventory_hostname == groups["rke2_servers"][0]
- name: Ensure local kubeconfig parent exists
ansible.builtin.file:
path: "{{ deployment_kubeconfig_path | dirname }}"
state: directory
mode: "0700"
delegate_to: localhost
become: false
when: inventory_hostname == groups["rke2_servers"][0]
- name: Export the cluster kubeconfig to the deployment controller
ansible.builtin.fetch:
src: /etc/rancher/rke2/rke2.yaml
dest: "{{ deployment_kubeconfig_path }}"
flat: true
when: inventory_hostname == groups["rke2_servers"][0]
- name: Replace loopback API address in exported kubeconfig
ansible.builtin.replace:
path: "{{ deployment_kubeconfig_path }}"
regexp: "https://127\\.0\\.0\\.1:6443"
replace: "https://{{ cluster.apiEndpoint }}:{{ cluster.apiPort }}"
mode: "0600"
delegate_to: localhost
become: false
when: inventory_hostname == groups["rke2_servers"][0]
@@ -0,0 +1,131 @@
---
- name: Require a supported Linux host
ansible.builtin.assert:
that:
- ansible_system == "Linux"
- ansible_architecture in ["x86_64", "amd64"]
- ansible_os_family == "Debian"
- ansible_distribution == "Ubuntu"
- ansible_distribution_version is version("22.04", ">=")
fail_msg: >-
This implementation increment supports Ubuntu 22.04+ x86_64 only.
The exact patch and kernel must be frozen in versions.lock.yaml before release.
- name: Check minimum node resources
ansible.builtin.assert:
that:
- ansible_processor_vcpus | int >= hostBaseline.minimumCpuCores | int
- ansible_memtotal_mb | int >= hostBaseline.minimumMemoryMb | int
fail_msg: >-
Node does not meet the configured minimum CPU or memory requirement:
cpu={{ ansible_processor_vcpus }}, memory_mb={{ ansible_memtotal_mb }}.
- name: Read free space in RKE2 parent directory
ansible.builtin.shell: |
set -e
target="{{ cluster.dataDir | dirname }}"
while [ ! -e "$target" ]; do target="$(dirname "$target")"; done
df -Pm "$target" | awk 'NR==2 {print $4}'
args:
executable: /bin/bash
register: rke2_free_disk_mb
changed_when: false
- name: Check minimum free disk space
ansible.builtin.assert:
that:
- rke2_free_disk_mb.stdout | int >= hostBaseline.minimumFreeDiskMb | int
fail_msg: >-
Insufficient free disk space for RKE2 and offline images:
free_mb={{ rke2_free_disk_mb.stdout }}.
- name: Inspect the default route
ansible.builtin.command: ip route show default
register: default_route
changed_when: false
when: hostBaseline.requireDefaultRoute | bool
- name: Require a default route for RKE2 primary IP detection
ansible.builtin.assert:
that:
- default_route.stdout | trim | length > 0
fail_msg: >-
RKE2 requires a default route even when the node has no Internet access.
Configure a persistent internal or dummy default route before deployment.
when: hostBaseline.requireDefaultRoute | bool
- name: Require swap to be disabled
ansible.builtin.assert:
that:
- ansible_swaptotal_mb | int == 0
fail_msg: Disable swap persistently before installing RKE2.
when: hostBaseline.requireSwapDisabled | bool
- name: Inspect host clock synchronization
ansible.builtin.command:
argv:
- timedatectl
- show
- --property=NTPSynchronized
- --value
register: time_synchronization
changed_when: false
when: hostBaseline.requireTimeSynchronized | bool
- name: Require a synchronized host clock
ansible.builtin.assert:
that:
- time_synchronization.stdout | trim | lower == "yes"
fail_msg: >-
Host clock is not synchronized. Configure an internal NTP source and
verify timedatectl reports NTPSynchronized=yes.
when: hostBaseline.requireTimeSynchronized | bool
- name: Require the configured node IP
ansible.builtin.assert:
that:
- rke2_node_ip is defined
- rke2_node_ip | string | length > 0
fail_msg: "rke2_node_ip is missing or invalid for {{ inventory_hostname }}"
- name: Validate the configured node IP without external Ansible collections
ansible.builtin.command:
argv:
- python3
- -c
- "import ipaddress,sys; ipaddress.ip_address(sys.argv[1])"
- "{{ rke2_node_ip }}"
changed_when: false
- name: Verify required RKE2 files on the deployment controller
ansible.builtin.stat:
path: "{{ offline_artifact_root }}/rke2/{{ item }}"
loop:
- install.sh
- rke2.linux-amd64.tar.gz
- rke2-images.linux-amd64.tar.zst
- sha256sum-amd64.txt
register: controller_rke2_files
delegate_to: localhost
become: false
run_once: true
- name: Require a complete RKE2 air-gap artifact set
ansible.builtin.assert:
that:
- item.stat.exists
- item.stat.isreg
fail_msg: "Missing RKE2 artifact on controller: {{ item.item }}"
loop: "{{ controller_rke2_files.results }}"
delegate_to: localhost
become: false
run_once: true
- name: Verify the preinstalled NVIDIA driver
ansible.builtin.command: nvidia-smi
register: nvidia_smi
changed_when: false
when:
- gpu.enabled | bool
- inventory_hostname in groups.get("gpu_workers", [])
- not gpu.driverManagedByOperator | bool
@@ -0,0 +1,6 @@
---
- name: Restart RKE2 agent
ansible.builtin.systemd:
name: rke2-agent
state: restarted
daemon_reload: true
@@ -0,0 +1,27 @@
---
- name: Install the RKE2 agent from local artifacts
ansible.builtin.command: /opt/trackwalker/rke2-artifacts/install.sh
environment:
INSTALL_RKE2_ARTIFACT_PATH: /opt/trackwalker/rke2-artifacts
INSTALL_RKE2_TYPE: agent
args:
creates: /usr/local/bin/rke2
- name: Render the RKE2 agent configuration
ansible.builtin.template:
src: config.yaml.j2
dest: /etc/rancher/rke2/config.yaml
owner: root
group: root
mode: "0600"
notify: Restart RKE2 agent
- name: Enable and start the RKE2 agent
ansible.builtin.systemd:
name: rke2-agent
enabled: true
state: started
daemon_reload: true
- name: Flush RKE2 agent restart handler
ansible.builtin.meta: flush_handlers
@@ -0,0 +1,9 @@
server: "https://{{ cluster.apiEndpoint }}:{{ cluster.registrationPort }}"
token: "{{ rke2.token }}"
node-ip: "{{ rke2_node_ip }}"
data-dir: "{{ cluster.dataDir }}"
node-label:
- "node-role.trackwalker.io/worker=true"
{% if inventory_hostname in groups.get("gpu_workers", []) %}
- "node-role.trackwalker.io/gpu=true"
{% endif %}
@@ -0,0 +1,6 @@
---
- name: Restart RKE2 server
ansible.builtin.systemd:
name: rke2-server
state: restarted
daemon_reload: true
@@ -0,0 +1,37 @@
---
- name: Determine whether this is the bootstrap server
ansible.builtin.set_fact:
rke2_is_bootstrap_server: "{{ inventory_hostname == groups['rke2_servers'][0] }}"
- name: Install the RKE2 server from local artifacts
ansible.builtin.command: /opt/trackwalker/rke2-artifacts/install.sh
environment:
INSTALL_RKE2_ARTIFACT_PATH: /opt/trackwalker/rke2-artifacts
INSTALL_RKE2_TYPE: server
args:
creates: /usr/local/bin/rke2
- name: Render the RKE2 server configuration
ansible.builtin.template:
src: config.yaml.j2
dest: /etc/rancher/rke2/config.yaml
owner: root
group: root
mode: "0600"
notify: Restart RKE2 server
- name: Enable and start the RKE2 server
ansible.builtin.systemd:
name: rke2-server
enabled: true
state: started
daemon_reload: true
- name: Flush RKE2 server restart handler
ansible.builtin.meta: flush_handlers
- name: Wait for the RKE2 registration port
ansible.builtin.wait_for:
host: "{{ rke2_node_ip }}"
port: "{{ cluster.registrationPort }}"
timeout: 600
@@ -0,0 +1,18 @@
token: "{{ rke2.token }}"
node-ip: "{{ rke2_node_ip }}"
data-dir: "{{ cluster.dataDir }}"
cluster-cidr: "{{ cluster.podCidr }}"
service-cidr: "{{ cluster.serviceCidr }}"
cluster-domain: "{{ cluster.clusterDomain }}"
write-kubeconfig-mode: "{{ cluster.writeKubeconfigMode }}"
tls-san:
- "{{ cluster.apiEndpoint }}"
embedded-registry: {{ cluster.embeddedRegistry | bool | lower }}
{% if not rke2_is_bootstrap_server %}
server: "https://{{ cluster.apiEndpoint }}:{{ cluster.registrationPort }}"
{% endif %}
node-label:
- "node-role.trackwalker.io/server=true"
{% if inventory_hostname in groups.get("gpu_workers", []) %}
- "node-role.trackwalker.io/gpu=true"
{% endif %}
+44
View File
@@ -0,0 +1,44 @@
---
- name: Validate all target nodes
hosts: all
gather_facts: true
become: true
roles:
- role: preflight
tags: [preflight]
- name: Configure the Linux baseline
hosts: all
gather_facts: true
become: true
roles:
- role: os_baseline
tags: [os-baseline]
- role: airgap_artifacts
tags: [airgap]
- name: Install RKE2 servers one at a time
hosts: rke2_servers
serial: 1
gather_facts: true
become: true
roles:
- role: rke2_server
tags: [rke2]
- name: Install RKE2 agents
hosts: rke2_agents
serial: 1
gather_facts: true
become: true
roles:
- role: rke2_agent
tags: [rke2]
- name: Verify cluster membership and export kubeconfig
hosts: rke2_servers
gather_facts: false
become: true
roles:
- role: postcheck
tags: [postcheck]
+7
View File
@@ -0,0 +1,7 @@
apiVersion: v2
name: trackwalker
description: Offline Kubernetes deployment for the TrackWalker platform
type: application
version: 0.1.0
appVersion: "0.1.0-dev"
kubeVersion: ">=1.30.0-0"
@@ -0,0 +1,92 @@
{
"profile": "cpu-local",
"models": [
{
"model_group": "vision-detector",
"model_version": "cpu-v1.0.0",
"active": true,
"display_name": "PP-YOLOE-SOD-s CPU",
"family": "PP-YOLOE-SOD",
"runtime": "onnxruntime-cpu",
"artifact": "vision-detector/ppyoloe-sod-s/model.onnx",
"labels": "vision-detector/ppyoloe-sod-s/labels.txt",
"parser": "paddle-detection",
"input_size": 640,
"batch_size": 1,
"precision": "FP32",
"mean": [0.0, 0.0, 0.0],
"std": [1.0, 1.0, 1.0],
"scale": 0.00392156862745098,
"color_order": "RGB"
},
{
"model_group": "vision-segmenter",
"model_version": "cpu-v1.0.0",
"active": true,
"display_name": "PP-LiteSeg-STDC1 CPU",
"family": "PP-LiteSeg",
"runtime": "onnxruntime-cpu",
"artifact": "vision-segmenter/pp-liteseg-stdc1/model.onnx",
"labels": "vision-segmenter/pp-liteseg-stdc1/labels.txt",
"parser": "semantic-segmentation",
"input_size": 512,
"batch_size": 1,
"precision": "FP32",
"mean": [0.5, 0.5, 0.5],
"std": [0.5, 0.5, 0.5],
"scale": 0.00392156862745098,
"color_order": "RGB"
},
{
"model_group": "change-detector",
"model_version": "cpu-v1.0.0",
"active": true,
"display_name": "BIT-R18 CPU",
"family": "BIT",
"runtime": "onnxruntime-cpu",
"artifact": "change-detector/bit-r18/model.onnx",
"labels": "change-detector/bit-r18/labels.txt",
"parser": "binary-change",
"input_size": 512,
"batch_size": 1,
"precision": "FP32",
"mean": [0.485, 0.456, 0.406],
"std": [0.229, 0.224, 0.225],
"scale": 0.00392156862745098,
"color_order": "RGB"
},
{
"model_group": "pointcloud-analyzer",
"model_version": "cpu-v1.0.0",
"active": true,
"display_name": "Open3D geometry analysis CPU",
"family": "Open3D",
"runtime": "open3d-cpu",
"artifact": null,
"labels": null,
"parser": "pointcloud-geometry",
"input_size": null,
"batch_size": 1,
"precision": "FP64"
},
{
"model_group": "thermal-analyzer",
"model_version": "cpu-v1.0.0",
"active": true,
"display_name": "PP-YOLOE-s and PatchCore CPU",
"family": "PP-YOLOE/PatchCore",
"runtime": "onnxruntime-cpu",
"artifact": "thermal-analyzer/ppyoloe-s/model.onnx",
"secondary_artifact": "thermal-analyzer/patchcore/model.onnx",
"labels": "thermal-analyzer/ppyoloe-s/labels.txt",
"parser": "thermal-detection",
"input_size": 512,
"batch_size": 1,
"precision": "FP32",
"mean": [0.0, 0.0, 0.0],
"std": [1.0, 1.0, 1.0],
"scale": 0.00392156862745098,
"color_order": "RGB"
}
]
}
@@ -0,0 +1,92 @@
{
"profile": "server-gpu",
"models": [
{
"model_group": "vision-detector",
"model_version": "v1.0.0",
"active": true,
"display_name": "PP-YOLOE-SOD-s production",
"family": "PP-YOLOE-SOD",
"runtime": "onnxruntime-gpu",
"artifact": "vision-detector/v1.0.0/model.onnx",
"labels": "vision-detector/v1.0.0/labels.txt",
"parser": "paddle-detection",
"input_size": 1280,
"batch_size": 4,
"precision": "FP16",
"mean": [0.0, 0.0, 0.0],
"std": [1.0, 1.0, 1.0],
"scale": 0.00392156862745098,
"color_order": "RGB"
},
{
"model_group": "vision-segmenter",
"model_version": "v1.0.0",
"active": true,
"display_name": "PP-LiteSeg-STDC1 production",
"family": "PP-LiteSeg",
"runtime": "onnxruntime-gpu",
"artifact": "vision-segmenter/v1.0.0/model.onnx",
"labels": "vision-segmenter/v1.0.0/labels.txt",
"parser": "semantic-segmentation",
"input_size": 1024,
"batch_size": 2,
"precision": "FP16",
"mean": [0.5, 0.5, 0.5],
"std": [0.5, 0.5, 0.5],
"scale": 0.00392156862745098,
"color_order": "RGB"
},
{
"model_group": "thermal-analyzer",
"model_version": "v1.0.0",
"active": true,
"display_name": "PP-YOLOE-s and PatchCore production",
"family": "PP-YOLOE/PatchCore",
"runtime": "onnxruntime-gpu",
"artifact": "thermal-analyzer/v1.0.0/model.onnx",
"secondary_artifact": "thermal-analyzer/v1.0.0/patchcore.onnx",
"labels": "thermal-analyzer/v1.0.0/labels.txt",
"parser": "thermal-detection",
"input_size": 1024,
"batch_size": 2,
"precision": "FP16",
"mean": [0.0, 0.0, 0.0],
"std": [1.0, 1.0, 1.0],
"scale": 0.00392156862745098,
"color_order": "RGB"
},
{
"model_group": "change-detector",
"model_version": "v1.0.0",
"active": true,
"display_name": "BIT-R18 production",
"family": "BIT",
"runtime": "onnxruntime-gpu",
"artifact": "change-detector/v1.0.0/model.onnx",
"labels": "change-detector/v1.0.0/labels.txt",
"parser": "binary-change",
"input_size": 1024,
"batch_size": 2,
"precision": "FP16",
"mean": [0.485, 0.456, 0.406],
"std": [0.229, 0.224, 0.225],
"scale": 0.00392156862745098,
"color_order": "RGB"
},
{
"model_group": "pointcloud-analyzer",
"model_version": "v1.0.0",
"active": true,
"display_name": "Open3D geometry analysis production",
"family": "Open3D",
"runtime": "open3d-cpu",
"artifact": null,
"labels": null,
"parser": "pointcloud-geometry",
"input_size": null,
"batch_size": 1,
"precision": "FP64"
}
]
}
@@ -0,0 +1,11 @@
TrackWalker was deployed to namespace {{ .Release.Namespace }}.
{{- if .Values.ingress.enabled }}
Application URL: {{ ternary "https" "http" .Values.ingress.tls.enabled }}://{{ .Values.ingress.host }}
{{- else }}
Run:
kubectl --namespace {{ .Release.Namespace }} port-forward service/frontend 8088:80
{{- end }}
The bundled stateful services are intended for the single-node test profile only.
Back up PostgreSQL, MinIO and the model PVC before upgrades.
@@ -0,0 +1,48 @@
{{- define "trackwalker.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "trackwalker.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- define "trackwalker.labels" -}}
app.kubernetes.io/name: {{ include "trackwalker.name" .root }}
app.kubernetes.io/instance: {{ .root.Release.Name }}
app.kubernetes.io/managed-by: {{ .root.Release.Service }}
app.kubernetes.io/part-of: trackwalker
app.kubernetes.io/component: {{ .component }}
helm.sh/chart: {{ printf "%s-%s" .root.Chart.Name .root.Chart.Version | replace "+" "_" }}
{{- end -}}
{{- define "trackwalker.selectorLabels" -}}
app.kubernetes.io/name: {{ include "trackwalker.name" .root }}
app.kubernetes.io/instance: {{ .root.Release.Name }}
app.kubernetes.io/component: {{ .component }}
{{- end -}}
{{- define "trackwalker.image" -}}
{{- $repository := .image.repository -}}
{{- if .root.Values.global.imageRegistry -}}
{{- $repository = printf "%s/%s" (trimSuffix "/" .root.Values.global.imageRegistry) (trimPrefix "/" $repository) -}}
{{- end -}}
{{- if .image.digest -}}
{{- printf "%s@%s" $repository .image.digest -}}
{{- else -}}
{{- printf "%s:%s" $repository .image.tag -}}
{{- end -}}
{{- end -}}
{{- define "trackwalker.secretName" -}}
{{- required "global.existingSecret is required" .Values.global.existingSecret -}}
{{- end -}}
{{- define "trackwalker.storageClass" -}}
{{- if .Values.global.storageClass -}}
storageClassName: {{ .Values.global.storageClass }}
{{- end -}}
{{- end -}}
@@ -0,0 +1,351 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "frontend") | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount.frontend }}
selector:
matchLabels:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "frontend") | nindent 6 }}
template:
metadata:
labels:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "frontend") | nindent 8 }}
spec:
containers:
- name: frontend
image: {{ include "trackwalker.image" (dict "root" . "image" .Values.images.frontend) }}
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
ports:
- name: http
containerPort: 80
readinessProbe:
httpGet: {path: /, port: http}
initialDelaySeconds: 5
livenessProbe:
httpGet: {path: /, port: http}
initialDelaySeconds: 15
resources:
{{- toYaml .Values.resources.frontend | nindent 12 }}
---
apiVersion: v1
kind: Service
metadata:
name: frontend
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "frontend") | nindent 4 }}
spec:
selector:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "frontend") | nindent 4 }}
ports:
- name: http
port: 80
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "backend") | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount.backend }}
selector:
matchLabels:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "backend") | nindent 6 }}
template:
metadata:
labels:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "backend") | nindent 8 }}
spec:
containers:
- name: backend
image: {{ include "trackwalker.image" (dict "root" . "image" .Values.images.backend) }}
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
ports:
- name: http
containerPort: 8080
env:
- {name: SPRING_DATASOURCE_URL, value: "jdbc:postgresql://{{ .Values.endpoints.postgres.host }}:{{ .Values.endpoints.postgres.port }}/{{ .Values.endpoints.postgres.database }}"}
- {name: SPRING_DATASOURCE_USERNAME, value: {{ .Values.endpoints.postgres.username | quote }}}
- name: SPRING_DATASOURCE_PASSWORD
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" . }}, key: POSTGRES_PASSWORD}}
- {name: SPRING_REDIS_HOST, value: {{ .Values.endpoints.redis.host | quote }}}
- {name: SPRING_REDIS_PORT, value: {{ .Values.endpoints.redis.port | quote }}}
- name: SPRING_DATA_REDIS_PASSWORD
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" . }}, key: REDIS_PASSWORD}}
- {name: SPRING_KAFKA_BOOTSTRAP_SERVERS, value: {{ .Values.endpoints.kafka.bootstrapServers | quote }}}
- {name: RAIL_AI_VISION_URL, value: "http://vision-inference:8101"}
- {name: RAIL_AI_POINTCLOUD_URL, value: "http://pointcloud-analysis:8102"}
- {name: MINIO_ENDPOINT, value: {{ .Values.endpoints.minio.endpoint | quote }}}
- name: MINIO_ACCESS_KEY
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" . }}, key: MINIO_ROOT_USER}}
- name: MINIO_SECRET_KEY
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" . }}, key: MINIO_ROOT_PASSWORD}}
- {name: MINIO_ARTIFACT_BUCKET, value: {{ .Values.endpoints.minio.artifactBucket | quote }}}
- {name: RAIL_ARTIFACT_INSTALLER_URL, value: "http://artifact-installer:8103"}
- name: RAIL_ARTIFACT_INSTALLER_TOKEN
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" . }}, key: ARTIFACT_INSTALLER_TOKEN}}
- {name: UAV_ACCESS_SERVICE_URL, value: "http://uav-access-service:8091"}
- name: UAV_ACCESS_INTERNAL_TOKEN
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" . }}, key: UAV_ACCESS_INTERNAL_TOKEN}}
startupProbe:
httpGet: {path: /actuator/health, port: http}
failureThreshold: 60
periodSeconds: 5
readinessProbe:
httpGet: {path: /actuator/health, port: http}
livenessProbe:
httpGet: {path: /actuator/health, port: http}
initialDelaySeconds: 30
resources:
{{- toYaml .Values.resources.backend | nindent 12 }}
---
apiVersion: v1
kind: Service
metadata:
name: backend
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "backend") | nindent 4 }}
spec:
selector:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "backend") | nindent 4 }}
ports:
- {name: http, port: 8080, targetPort: http}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: uav-access-service
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "uav-access-service") | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount.uavAccess }}
selector:
matchLabels:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "uav-access-service") | nindent 6 }}
template:
metadata:
labels:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "uav-access-service") | nindent 8 }}
spec:
containers:
- name: uav-access-service
image: {{ include "trackwalker.image" (dict "root" . "image" .Values.images.uavAccess) }}
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
ports:
- name: http
containerPort: 8091
env:
- {name: SPRING_DATASOURCE_URL, value: "jdbc:postgresql://{{ .Values.endpoints.postgres.host }}:{{ .Values.endpoints.postgres.port }}/{{ .Values.endpoints.postgres.database }}"}
- {name: SPRING_DATASOURCE_USERNAME, value: {{ .Values.endpoints.postgres.username | quote }}}
- name: SPRING_DATASOURCE_PASSWORD
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" . }}, key: POSTGRES_PASSWORD}}
- {name: SPRING_KAFKA_BOOTSTRAP_SERVERS, value: {{ .Values.endpoints.kafka.bootstrapServers | quote }}}
- name: UAV_ACCESS_INTERNAL_TOKEN
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" . }}, key: UAV_ACCESS_INTERNAL_TOKEN}}
- {name: FLIGHTHUB_ENABLED, value: {{ .Values.flighthub.enabled | quote }}}
- {name: FLIGHTHUB_BASE_URL, value: {{ .Values.flighthub.baseUrl | quote }}}
- {name: FLIGHTHUB_ORGANIZATION_UUID, value: {{ .Values.flighthub.organizationUuid | quote }}}
- {name: FLIGHTHUB_PROJECT_UUID, value: {{ .Values.flighthub.projectUuid | quote }}}
- name: FLIGHTHUB_EVENT_CALLBACK_TOKEN
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" . }}, key: FLIGHTHUB_EVENT_CALLBACK_TOKEN}}
- name: FLIGHTHUB_USER_TOKEN
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" . }}, key: FLIGHTHUB_USER_TOKEN}}
- {name: FLIGHTHUB_MQTT_ENABLED, value: {{ .Values.flighthub.mqttEnabled | quote }}}
- {name: FLIGHTHUB_MQTT_BROKER_URL, value: {{ .Values.endpoints.mqtt.brokerUrl | quote }}}
- {name: FLIGHTHUB_MQTT_CLIENT_ID, value: {{ .Values.flighthub.mqttClientId | quote }}}
- {name: FLIGHTHUB_MQTT_TOPIC_FILTER, value: {{ .Values.flighthub.mqttTopicFilter | quote }}}
- name: FLIGHTHUB_MQTT_USERNAME
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" . }}, key: FLIGHTHUB_MQTT_USERNAME}}
- name: FLIGHTHUB_MQTT_PASSWORD
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" . }}, key: FLIGHTHUB_MQTT_PASSWORD}}
startupProbe:
httpGet: {path: /actuator/health, port: http}
failureThreshold: 60
periodSeconds: 5
readinessProbe:
httpGet: {path: /actuator/health, port: http}
livenessProbe:
httpGet: {path: /actuator/health, port: http}
initialDelaySeconds: 30
resources:
{{- toYaml .Values.resources.uavAccess | nindent 12 }}
---
apiVersion: v1
kind: Service
metadata:
name: uav-access-service
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "uav-access-service") | nindent 4 }}
spec:
selector:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "uav-access-service") | nindent 4 }}
ports:
- {name: http, port: 8091, targetPort: http}
---
{{- $root := . }}
{{- range $service := list "vision-inference" "pointcloud-analysis" }}
{{- $isVision := eq $service "vision-inference" }}
{{- $image := ternary $root.Values.images.visionCpu $root.Values.images.pointcloudCpu $isVision }}
{{- if $root.Values.gpu.enabled }}
{{- $image = ternary $root.Values.images.visionGpu $root.Values.images.pointcloudGpu $isVision }}
{{- end }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ $service }}
labels:
{{- include "trackwalker.labels" (dict "root" $root "component" $service) | nindent 4 }}
spec:
replicas: {{ ternary $root.Values.replicaCount.vision $root.Values.replicaCount.pointcloud $isVision }}
selector:
matchLabels:
{{- include "trackwalker.selectorLabels" (dict "root" $root "component" $service) | nindent 6 }}
template:
metadata:
labels:
{{- include "trackwalker.selectorLabels" (dict "root" $root "component" $service) | nindent 8 }}
spec:
{{- with $root.Values.gpu.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $root.Values.gpu.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: {{ $service }}
image: {{ include "trackwalker.image" (dict "root" $root "image" $image) }}
imagePullPolicy: {{ $root.Values.global.imagePullPolicy }}
ports:
- name: http
containerPort: {{ ternary 8101 8102 $isVision }}
env:
- {name: RAIL_RUNTIME_PROFILE, value: {{ $root.Values.global.runtimeProfile | quote }}}
- {name: RAIL_MODEL_DIR, value: /models}
- {name: RAIL_MODEL_REGISTRY, value: /app/config/model-registry.json}
- {name: MINIO_ENDPOINT, value: {{ $root.Values.endpoints.minio.endpointNoScheme | quote }}}
- name: MINIO_ACCESS_KEY
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" $root }}, key: MINIO_ROOT_USER}}
- name: MINIO_SECRET_KEY
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" $root }}, key: MINIO_ROOT_PASSWORD}}
volumeMounts:
- {name: models, mountPath: /models}
- {name: registry, mountPath: /app/config/model-registry.json, subPath: model-registry.json, readOnly: true}
startupProbe:
httpGet: {path: /health, port: http}
failureThreshold: 120
periodSeconds: 5
readinessProbe:
httpGet: {path: /health, port: http}
livenessProbe:
httpGet: {path: /health, port: http}
initialDelaySeconds: 30
resources:
{{- if $root.Values.gpu.enabled }}
{{- $baseResources := ternary $root.Values.resources.vision $root.Values.resources.pointcloud $isVision }}
{{- $gpuResources := deepCopy $baseResources }}
{{- $_ := set $gpuResources.limits "nvidia.com/gpu" 1 }}
{{- toYaml $gpuResources | nindent 12 }}
{{- else }}
{{- toYaml (ternary $root.Values.resources.vision $root.Values.resources.pointcloud $isVision) | nindent 12 }}
{{- end }}
volumes:
- name: models
persistentVolumeClaim: {claimName: trackwalker-models}
- name: registry
configMap:
name: trackwalker-model-registry
items:
- key: {{ ternary "server-models.json" "cpu-models.json" $root.Values.gpu.enabled }}
path: model-registry.json
---
apiVersion: v1
kind: Service
metadata:
name: {{ $service }}
labels:
{{- include "trackwalker.labels" (dict "root" $root "component" $service) | nindent 4 }}
spec:
selector:
{{- include "trackwalker.selectorLabels" (dict "root" $root "component" $service) | nindent 4 }}
ports:
- {name: http, port: {{ ternary 8101 8102 $isVision }}, targetPort: http}
---
{{- end }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: artifact-installer
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "artifact-installer") | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount.artifactInstaller }}
strategy:
type: Recreate
selector:
matchLabels:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "artifact-installer") | nindent 6 }}
template:
metadata:
labels:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "artifact-installer") | nindent 8 }}
spec:
containers:
- name: artifact-installer
image: {{ include "trackwalker.image" (dict "root" . "image" .Values.images.artifactInstaller) }}
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
ports:
- name: http
containerPort: 8103
env:
- {name: RAIL_RUNTIME_PROFILE, value: {{ .Values.global.runtimeProfile | quote }}}
- {name: RAIL_MODEL_DIR, value: /models}
- {name: RAIL_MODEL_REGISTRY, value: /app/config/model-registry.json}
- {name: MINIO_ENDPOINT, value: {{ .Values.endpoints.minio.endpointNoScheme | quote }}}
- name: MINIO_ACCESS_KEY
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" . }}, key: MINIO_ROOT_USER}}
- name: MINIO_SECRET_KEY
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" . }}, key: MINIO_ROOT_PASSWORD}}
- {name: MINIO_ARTIFACT_BUCKET, value: {{ .Values.endpoints.minio.artifactBucket | quote }}}
- name: RAIL_ARTIFACT_INSTALLER_TOKEN
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" . }}, key: ARTIFACT_INSTALLER_TOKEN}}
volumeMounts:
- {name: models, mountPath: /models}
- {name: registry, mountPath: /app/config/model-registry.json, subPath: model-registry.json, readOnly: true}
startupProbe:
httpGet: {path: /health, port: http}
failureThreshold: 60
periodSeconds: 5
readinessProbe:
httpGet: {path: /health, port: http}
livenessProbe:
httpGet: {path: /health, port: http}
initialDelaySeconds: 30
resources:
{{- toYaml .Values.resources.artifactInstaller | nindent 12 }}
volumes:
- name: models
persistentVolumeClaim: {claimName: trackwalker-models}
- name: registry
configMap:
name: trackwalker-model-registry
items:
- key: {{ ternary "server-models.json" "cpu-models.json" .Values.gpu.enabled }}
path: model-registry.json
---
apiVersion: v1
kind: Service
metadata:
name: artifact-installer
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "artifact-installer") | nindent 4 }}
spec:
selector:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "artifact-installer") | nindent 4 }}
ports:
- {name: http, port: 8103, targetPort: http}
@@ -0,0 +1,38 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "trackwalker.fullname" . }}
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "ingress") | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
ingressClassName: {{ .Values.ingress.className }}
{{- if .Values.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.ingress.host | quote }}
secretName: {{ required "ingress.tls.secretName is required when TLS is enabled" .Values.ingress.tls.secretName }}
{{- end }}
rules:
- host: {{ .Values.ingress.host | quote }}
http:
paths:
- path: /api/v1/events/flighthub
pathType: Exact
backend:
service:
name: uav-access-service
port:
name: http
- path: /
pathType: Prefix
backend:
service:
name: frontend
port:
name: http
{{- end }}
@@ -0,0 +1,25 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: trackwalker-models
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "model-storage") | nindent 4 }}
spec:
accessModes:
- {{ .Values.modelStorage.accessMode }}
{{- include "trackwalker.storageClass" . | nindent 2 }}
resources:
requests:
storage: {{ .Values.modelStorage.capacity }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: trackwalker-model-registry
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "model-registry") | nindent 4 }}
data:
cpu-models.json: |
{{ .Files.Get "files/cpu-models.json" | indent 4 }}
server-models.json: |
{{ .Files.Get "files/server-models.json" | indent 4 }}
@@ -0,0 +1,82 @@
{{- if .Values.monitoring.prometheusEnabled }}
apiVersion: v1
kind: ConfigMap
metadata:
name: prometheus-config
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "prometheus") | nindent 4 }}
data:
prometheus.yml: |
global:
scrape_interval: 15s
scrape_configs:
- job_name: backend
metrics_path: /actuator/prometheus
static_configs:
- targets: ["backend:8080"]
- job_name: uav-access-service
metrics_path: /actuator/prometheus
static_configs:
- targets: ["uav-access-service:8091"]
---
apiVersion: v1
kind: Service
metadata:
name: prometheus
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "prometheus") | nindent 4 }}
spec:
selector:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "prometheus") | nindent 4 }}
ports:
- {name: http, port: 9090, targetPort: http}
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: prometheus
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "prometheus") | nindent 4 }}
spec:
serviceName: prometheus
replicas: 1
selector:
matchLabels:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "prometheus") | nindent 6 }}
template:
metadata:
labels:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "prometheus") | nindent 8 }}
spec:
containers:
- name: prometheus
image: {{ include "trackwalker.image" (dict "root" . "image" .Values.images.prometheus) }}
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
args:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=15d
ports:
- {name: http, containerPort: 9090}
readinessProbe:
httpGet: {path: /-/ready, port: http}
initialDelaySeconds: 10
livenessProbe:
httpGet: {path: /-/healthy, port: http}
initialDelaySeconds: 30
volumeMounts:
- {name: config, mountPath: /etc/prometheus, readOnly: true}
- {name: data, mountPath: /prometheus}
volumes:
- name: config
configMap: {name: prometheus-config}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ReadWriteOnce]
{{- include "trackwalker.storageClass" . | nindent 8 }}
resources:
requests:
storage: {{ .Values.monitoring.storage }}
{{- end }}
@@ -0,0 +1,314 @@
{{- if .Values.bundledDataServices.enabled }}
apiVersion: v1
kind: Service
metadata:
name: postgres
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "postgres") | nindent 4 }}
spec:
clusterIP: None
selector:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "postgres") | nindent 4 }}
ports:
- {name: postgres, port: 5432, targetPort: postgres}
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "postgres") | nindent 4 }}
spec:
serviceName: postgres
replicas: 1
selector:
matchLabels:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "postgres") | nindent 6 }}
template:
metadata:
labels:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "postgres") | nindent 8 }}
spec:
terminationGracePeriodSeconds: 30
containers:
- name: postgres
image: {{ include "trackwalker.image" (dict "root" . "image" .Values.images.postgis) }}
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
ports:
- {name: postgres, containerPort: 5432}
env:
- {name: POSTGRES_DB, value: {{ .Values.endpoints.postgres.database | quote }}}
- {name: POSTGRES_USER, value: {{ .Values.endpoints.postgres.username | quote }}}
- name: POSTGRES_PASSWORD
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" . }}, key: POSTGRES_PASSWORD}}
readinessProbe:
exec:
command: [sh, -ec, "pg_isready -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\""]
initialDelaySeconds: 10
livenessProbe:
exec:
command: [sh, -ec, "pg_isready -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\""]
initialDelaySeconds: 30
volumeMounts:
- {name: data, mountPath: /var/lib/postgresql/data}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ReadWriteOnce]
{{- include "trackwalker.storageClass" . | nindent 8 }}
resources:
requests:
storage: {{ .Values.bundledDataServices.storage.postgres }}
---
apiVersion: v1
kind: Service
metadata:
name: redis
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "redis") | nindent 4 }}
spec:
clusterIP: None
selector:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "redis") | nindent 4 }}
ports:
- {name: redis, port: 6379, targetPort: redis}
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: redis
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "redis") | nindent 4 }}
spec:
serviceName: redis
replicas: 1
selector:
matchLabels:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "redis") | nindent 6 }}
template:
metadata:
labels:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "redis") | nindent 8 }}
spec:
containers:
- name: redis
image: {{ include "trackwalker.image" (dict "root" . "image" .Values.images.redis) }}
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
command: [sh, -ec]
args: ['exec redis-server --appendonly yes --requirepass "$REDIS_PASSWORD"']
env:
- name: REDIS_PASSWORD
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" . }}, key: REDIS_PASSWORD}}
ports:
- {name: redis, containerPort: 6379}
readinessProbe:
exec:
command: [sh, -ec, 'redis-cli -a "$REDIS_PASSWORD" ping | grep PONG']
initialDelaySeconds: 5
livenessProbe:
exec:
command: [sh, -ec, 'redis-cli -a "$REDIS_PASSWORD" ping | grep PONG']
initialDelaySeconds: 20
volumeMounts:
- {name: data, mountPath: /data}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ReadWriteOnce]
{{- include "trackwalker.storageClass" . | nindent 8 }}
resources:
requests:
storage: {{ .Values.bundledDataServices.storage.redis }}
---
apiVersion: v1
kind: Service
metadata:
name: kafka
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "kafka") | nindent 4 }}
spec:
clusterIP: None
selector:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "kafka") | nindent 4 }}
ports:
- {name: client, port: 9092, targetPort: client}
- {name: controller, port: 9093, targetPort: controller}
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: kafka
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "kafka") | nindent 4 }}
spec:
serviceName: kafka
replicas: 1
selector:
matchLabels:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "kafka") | nindent 6 }}
template:
metadata:
labels:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "kafka") | nindent 8 }}
spec:
containers:
- name: kafka
image: {{ include "trackwalker.image" (dict "root" . "image" .Values.images.kafka) }}
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
ports:
- {name: client, containerPort: 9092}
- {name: controller, containerPort: 9093}
env:
- {name: KAFKA_CFG_NODE_ID, value: "1"}
- {name: KAFKA_CFG_PROCESS_ROLES, value: "controller,broker"}
- {name: KAFKA_CFG_CONTROLLER_QUORUM_VOTERS, value: "1@kafka-0.kafka:9093"}
- {name: KAFKA_CFG_LISTENERS, value: "PLAINTEXT://:9092,CONTROLLER://:9093"}
- {name: KAFKA_CFG_ADVERTISED_LISTENERS, value: "PLAINTEXT://kafka:9092"}
- {name: KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP, value: "CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT"}
- {name: KAFKA_CFG_CONTROLLER_LISTENER_NAMES, value: CONTROLLER}
- {name: KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE, value: "true"}
- {name: ALLOW_PLAINTEXT_LISTENER, value: "yes"}
readinessProbe:
exec:
command: [sh, -ec, "kafka-topics.sh --bootstrap-server localhost:9092 --list >/dev/null"]
initialDelaySeconds: 20
periodSeconds: 10
volumeMounts:
- {name: data, mountPath: /bitnami/kafka}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ReadWriteOnce]
{{- include "trackwalker.storageClass" . | nindent 8 }}
resources:
requests:
storage: {{ .Values.bundledDataServices.storage.kafka }}
---
apiVersion: v1
kind: Service
metadata:
name: minio
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "minio") | nindent 4 }}
spec:
clusterIP: None
selector:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "minio") | nindent 4 }}
ports:
- {name: api, port: 9000, targetPort: api}
- {name: console, port: 9001, targetPort: console}
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: minio
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "minio") | nindent 4 }}
spec:
serviceName: minio
replicas: 1
selector:
matchLabels:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "minio") | nindent 6 }}
template:
metadata:
labels:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "minio") | nindent 8 }}
spec:
containers:
- name: minio
image: {{ include "trackwalker.image" (dict "root" . "image" .Values.images.minio) }}
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
args: [server, /data, --console-address, ":9001"]
env:
- name: MINIO_ROOT_USER
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" . }}, key: MINIO_ROOT_USER}}
- name: MINIO_ROOT_PASSWORD
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" . }}, key: MINIO_ROOT_PASSWORD}}
ports:
- {name: api, containerPort: 9000}
- {name: console, containerPort: 9001}
readinessProbe:
httpGet: {path: /minio/health/ready, port: api}
initialDelaySeconds: 10
livenessProbe:
httpGet: {path: /minio/health/live, port: api}
initialDelaySeconds: 30
volumeMounts:
- {name: data, mountPath: /data}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ReadWriteOnce]
{{- include "trackwalker.storageClass" . | nindent 8 }}
resources:
requests:
storage: {{ .Values.bundledDataServices.storage.minio }}
{{- if .Values.bundledDataServices.emqxEnabled }}
---
apiVersion: v1
kind: Service
metadata:
name: emqx
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "emqx") | nindent 4 }}
spec:
clusterIP: None
selector:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "emqx") | nindent 4 }}
ports:
- {name: mqtt, port: 1883, targetPort: mqtt}
- {name: mqtt-tls, port: 8883, targetPort: mqtt-tls}
- {name: dashboard, port: 18083, targetPort: dashboard}
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: emqx
labels:
{{- include "trackwalker.labels" (dict "root" . "component" "emqx") | nindent 4 }}
spec:
serviceName: emqx
replicas: 1
selector:
matchLabels:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "emqx") | nindent 6 }}
template:
metadata:
labels:
{{- include "trackwalker.selectorLabels" (dict "root" . "component" "emqx") | nindent 8 }}
spec:
containers:
- name: emqx
image: {{ include "trackwalker.image" (dict "root" . "image" .Values.images.emqx) }}
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
env:
- name: EMQX_DASHBOARD__DEFAULT_USERNAME
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" . }}, key: EMQX_DASHBOARD_USERNAME}}
- name: EMQX_DASHBOARD__DEFAULT_PASSWORD
valueFrom: {secretKeyRef: {name: {{ include "trackwalker.secretName" . }}, key: EMQX_DASHBOARD_PASSWORD}}
ports:
- {name: mqtt, containerPort: 1883}
- {name: mqtt-tls, containerPort: 8883}
- {name: dashboard, containerPort: 18083}
readinessProbe:
exec:
command: [/opt/emqx/bin/emqx, ctl, status]
initialDelaySeconds: 20
volumeMounts:
- {name: data, mountPath: /opt/emqx/data}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ReadWriteOnce]
{{- include "trackwalker.storageClass" . | nindent 8 }}
resources:
requests:
storage: {{ .Values.bundledDataServices.storage.emqx }}
{{- end }}
{{- end }}
+25
View File
@@ -0,0 +1,25 @@
global:
runtimeProfile: server-gpu
storageClass: longhorn
replicaCount:
frontend: 2
# Keep these at one until each service has documented horizontal-scaling semantics.
backend: 1
uavAccess: 1
vision: 1
pointcloud: 1
artifactInstaller: 1
modelStorage:
accessMode: ReadWriteMany
# HA installations must use externally managed or operator-managed stateful services.
bundledDataServices:
enabled: false
monitoring:
prometheusEnabled: false
gpu:
enabled: true
@@ -0,0 +1,17 @@
global:
runtimeProfile: cpu-local
storageClass: local-path
replicaCount:
frontend: 1
backend: 1
uavAccess: 1
vision: 1
pointcloud: 1
artifactInstaller: 1
modelStorage:
accessMode: ReadWriteOnce
bundledDataServices:
enabled: true
+113
View File
@@ -0,0 +1,113 @@
global:
imageRegistry: registry.example.com
imagePullPolicy: IfNotPresent
existingSecret: trackwalker-secrets
runtimeProfile: cpu-local
storageClass: local-path
images:
frontend: {repository: trackwalker/frontend, tag: 0.1.0-dev, digest: ""}
backend: {repository: trackwalker/backend, tag: 0.1.0-dev, digest: ""}
uavAccess: {repository: trackwalker/uav-access-service, tag: 0.1.0-dev, digest: ""}
visionCpu: {repository: trackwalker/vision-inference, tag: 0.1.0-dev, digest: ""}
visionGpu: {repository: trackwalker/vision-inference-gpu, tag: 0.1.0-dev, digest: ""}
pointcloudCpu: {repository: trackwalker/pointcloud-analysis, tag: 0.1.0-dev, digest: ""}
pointcloudGpu: {repository: trackwalker/pointcloud-analysis-gpu, tag: 0.1.0-dev, digest: ""}
artifactInstaller: {repository: trackwalker/artifact-installer, tag: 0.1.0-dev, digest: ""}
postgis: {repository: postgis/postgis, tag: "16-3.4", digest: ""}
redis: {repository: library/redis, tag: 7.2-alpine, digest: ""}
kafka: {repository: bitnami/kafka, tag: 3.8.1, digest: ""}
minio: {repository: minio/minio, tag: RELEASE.2024-10-13T13-34-11Z, digest: ""}
emqx: {repository: emqx/emqx, tag: 5.8.4, digest: ""}
prometheus: {repository: prom/prometheus, tag: v2.55.0, digest: ""}
replicaCount:
frontend: 1
backend: 1
uavAccess: 1
vision: 1
pointcloud: 1
artifactInstaller: 1
ingress:
enabled: true
className: nginx
host: rail-inspection.internal.example
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: 0
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-buffering: "off"
tls:
enabled: false
secretName: ""
modelStorage:
capacity: 100Gi
accessMode: ReadWriteOnce
bundledDataServices:
enabled: true
emqxEnabled: false
storage:
postgres: 50Gi
redis: 10Gi
kafka: 50Gi
minio: 100Gi
emqx: 10Gi
monitoring:
prometheusEnabled: true
storage: 20Gi
endpoints:
postgres:
host: postgres
port: 5432
database: rail_inspection
username: rail
redis:
host: redis
port: 6379
kafka:
bootstrapServers: kafka:9092
minio:
endpoint: http://minio:9000
endpointNoScheme: minio:9000
artifactBucket: rail-model-artifacts
mqtt:
brokerUrl: tcp://emqx:1883
flighthub:
enabled: false
baseUrl: ""
organizationUuid: ""
projectUuid: ""
mqttEnabled: false
mqttClientId: trackwalker-uav-access
mqttTopicFilter: flighthub2/#
gpu:
enabled: false
nodeSelector: {}
tolerations: []
resources:
frontend:
requests: {cpu: 100m, memory: 128Mi}
limits: {cpu: "1", memory: 512Mi}
backend:
requests: {cpu: 500m, memory: 1Gi}
limits: {cpu: "2", memory: 3Gi}
uavAccess:
requests: {cpu: 250m, memory: 512Mi}
limits: {cpu: "1", memory: 2Gi}
vision:
requests: {cpu: "1", memory: 2Gi}
limits: {cpu: "4", memory: 8Gi}
pointcloud:
requests: {cpu: "1", memory: 2Gi}
limits: {cpu: "4", memory: 8Gi}
artifactInstaller:
requests: {cpu: 100m, memory: 256Mi}
limits: {cpu: "1", memory: 1Gi}
+119
View File
@@ -0,0 +1,119 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://trackwalker.example/schemas/deploy-site.schema.json",
"title": "AItrackwalker offline deployment site configuration",
"type": "object",
"required": ["schemaVersion", "deployment", "cluster", "hostBaseline", "storage", "gpu", "addons", "helmValues"],
"properties": {
"schemaVersion": {
"const": 1
},
"deployment": {
"type": "object",
"required": ["profile", "namespace", "releaseName", "runtimeProfile", "runtimeNetworkMode"],
"properties": {
"profile": {
"enum": ["single", "ha"]
},
"namespace": {
"type": "string",
"pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"
},
"releaseName": {
"type": "string",
"minLength": 1
},
"runtimeProfile": {
"enum": ["cpu-local", "server-gpu"]
},
"runtimeNetworkMode": {
"enum": ["isolated", "restricted-egress"]
}
}
},
"cluster": {
"type": "object",
"required": ["apiEndpoint", "podCidr", "serviceCidr", "dataDir"],
"properties": {
"apiEndpoint": {
"type": "string",
"minLength": 1
},
"podCidr": {
"type": "string"
},
"serviceCidr": {
"type": "string"
},
"dataDir": {
"type": "string",
"pattern": "^/"
},
"embeddedRegistry": {
"type": "boolean"
}
}
},
"hostBaseline": {
"type": "object",
"required": [
"minimumCpuCores",
"minimumMemoryMb",
"minimumFreeDiskMb",
"requireDefaultRoute",
"requireSwapDisabled",
"requireTimeSynchronized"
],
"properties": {
"minimumCpuCores": {
"type": "integer",
"minimum": 2
},
"minimumMemoryMb": {
"type": "integer",
"minimum": 2048
},
"minimumFreeDiskMb": {
"type": "integer",
"minimum": 10240
},
"requireDefaultRoute": {
"type": "boolean"
},
"requireSwapDisabled": {
"type": "boolean"
},
"requireTimeSynchronized": {
"type": "boolean"
},
"configureFirewall": {
"type": "boolean"
}
}
},
"storage": {
"type": "object",
"required": ["provider", "storageClass", "modelAccessMode", "modelCapacity"],
"properties": {
"provider": {
"enum": ["local-path", "longhorn", "external"]
},
"modelAccessMode": {
"enum": ["ReadWriteOnce", "ReadWriteMany"]
}
}
},
"gpu": {
"type": "object",
"required": ["enabled", "driverManagedByOperator"],
"properties": {
"enabled": {
"type": "boolean"
},
"driverManagedByOperator": {
"type": "boolean"
}
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
schemaVersion: 1
rke2:
token: CHANGE_ME_USE_AT_LEAST_32_RANDOM_CHARACTERS
application:
postgresPassword: CHANGE_ME_USE_AT_LEAST_16_RANDOM_CHARACTERS
redisPassword: CHANGE_ME_USE_AT_LEAST_16_RANDOM_CHARACTERS
minioRootUser: rail-minio-admin
minioRootPassword: CHANGE_ME_USE_AT_LEAST_16_RANDOM_CHARACTERS
artifactInstallerToken: CHANGE_ME_USE_AT_LEAST_32_RANDOM_CHARACTERS
uavAccessInternalToken: CHANGE_ME_USE_AT_LEAST_32_RANDOM_CHARACTERS
flighthubEventCallbackToken: CHANGE_ME_USE_AT_LEAST_32_RANDOM_CHARACTERS
emqxDashboardUsername: rail-emqx-admin
emqxDashboardPassword: CHANGE_ME_USE_AT_LEAST_16_RANDOM_CHARACTERS
flighthubUserToken: ""
flighthubMqttUsername: ""
flighthubMqttPassword: ""
+93
View File
@@ -0,0 +1,93 @@
schemaVersion: 1
deployment:
profile: ha
namespace: trackwalker
releaseName: trackwalker
runtimeProfile: server-gpu
runtimeNetworkMode: isolated
cluster:
# A pre-provisioned load balancer or VIP shared by the RKE2 servers.
apiEndpoint: 192.0.2.100
apiPort: 6443
registrationPort: 9345
podCidr: 10.42.0.0/16
serviceCidr: 10.43.0.0/16
clusterDomain: cluster.local
dataDir: /var/lib/rancher/rke2
embeddedRegistry: true
writeKubeconfigMode: "0600"
hostBaseline:
minimumCpuCores: 8
minimumMemoryMb: 15000
minimumFreeDiskMb: 200000
requireDefaultRoute: true
requireSwapDisabled: true
requireTimeSynchronized: true
configureFirewall: false
offlinePackageDirectory: os-packages/ubuntu
storage:
provider: longhorn
storageClass: longhorn
modelAccessMode: ReadWriteMany
modelCapacity: 500Gi
gpu:
enabled: true
driverManagedByOperator: false
requiredOnGpuWorkers: true
addons:
longhorn:
enabled: true
chart: charts/third-party/longhorn.tgz
namespace: longhorn-system
gpuOperator:
enabled: true
chart: charts/third-party/gpu-operator.tgz
namespace: gpu-operator
helmValues:
global:
imageRegistry: registry.internal.example/trackwalker-mirror
imagePullPolicy: IfNotPresent
existingSecret: trackwalker-secrets
ingress:
enabled: true
className: nginx
host: rail-inspection.internal.example
tls:
enabled: true
secretName: trackwalker-ingress-tls
bundledDataServices:
enabled: false
emqxEnabled: false
monitoring:
prometheusEnabled: false
endpoints:
postgres:
host: postgresql.database.svc.cluster.local
port: 5432
database: rail_inspection
username: rail
redis:
host: redis.database.svc.cluster.local
port: 6379
kafka:
bootstrapServers: kafka.messaging.svc.cluster.local:9092
minio:
endpoint: http://minio.storage.svc.cluster.local:9000
endpointNoScheme: minio.storage.svc.cluster.local:9000
mqtt:
brokerUrl: ssl://emqx.messaging.svc.cluster.local:8883
flighthub:
enabled: false
baseUrl: ""
organizationUuid: ""
projectUuid: ""
mqttEnabled: false
mqttClientId: trackwalker-uav-access
mqttTopicFilter: flighthub2/#
+92
View File
@@ -0,0 +1,92 @@
schemaVersion: 1
deployment:
profile: single
namespace: trackwalker
releaseName: trackwalker
runtimeProfile: cpu-local
runtimeNetworkMode: isolated
cluster:
apiEndpoint: 192.0.2.10
apiPort: 6443
registrationPort: 9345
podCidr: 10.42.0.0/16
serviceCidr: 10.43.0.0/16
clusterDomain: cluster.local
dataDir: /var/lib/rancher/rke2
embeddedRegistry: true
writeKubeconfigMode: "0600"
hostBaseline:
minimumCpuCores: 16
minimumMemoryMb: 30000
minimumFreeDiskMb: 200000
requireDefaultRoute: true
requireSwapDisabled: true
requireTimeSynchronized: true
configureFirewall: false
offlinePackageDirectory: os-packages/ubuntu
storage:
provider: local-path
storageClass: local-path
modelAccessMode: ReadWriteOnce
modelCapacity: 100Gi
gpu:
enabled: false
driverManagedByOperator: false
requiredOnGpuWorkers: true
addons:
longhorn:
enabled: false
chart: charts/third-party/longhorn.tgz
namespace: longhorn-system
gpuOperator:
enabled: false
chart: charts/third-party/gpu-operator.tgz
namespace: gpu-operator
helmValues:
global:
imageRegistry: registry.example.com
imagePullPolicy: IfNotPresent
existingSecret: trackwalker-secrets
ingress:
enabled: true
className: nginx
host: rail-inspection.internal.example
tls:
enabled: false
secretName: ""
bundledDataServices:
enabled: true
emqxEnabled: false
monitoring:
prometheusEnabled: true
endpoints:
postgres:
host: postgres
port: 5432
database: rail_inspection
username: rail
redis:
host: redis
port: 6379
kafka:
bootstrapServers: kafka:9092
minio:
endpoint: http://minio:9000
endpointNoScheme: minio:9000
mqtt:
brokerUrl: tcp://emqx:1883
flighthub:
enabled: false
baseUrl: ""
organizationUuid: ""
projectUuid: ""
mqttEnabled: false
mqttClientId: trackwalker-uav-access
mqttTopicFilter: flighthub2/#
+32
View File
@@ -0,0 +1,32 @@
all:
vars:
ansible_user: deploy
ansible_become: true
ansible_python_interpreter: /usr/bin/python3
children:
rke2_servers:
hosts:
trackwalker-cp-01:
ansible_host: 192.0.2.11
rke2_node_ip: 192.0.2.11
trackwalker-cp-02:
ansible_host: 192.0.2.12
rke2_node_ip: 192.0.2.12
trackwalker-cp-03:
ansible_host: 192.0.2.13
rke2_node_ip: 192.0.2.13
rke2_agents:
hosts:
trackwalker-worker-01:
ansible_host: 192.0.2.21
rke2_node_ip: 192.0.2.21
trackwalker-gpu-01:
ansible_host: 192.0.2.31
rke2_node_ip: 192.0.2.31
trackwalker-gpu-02:
ansible_host: 192.0.2.32
rke2_node_ip: 192.0.2.32
gpu_workers:
hosts:
trackwalker-gpu-01:
trackwalker-gpu-02:
@@ -0,0 +1,15 @@
all:
vars:
ansible_user: deploy
ansible_become: true
ansible_python_interpreter: /usr/bin/python3
children:
rke2_servers:
hosts:
trackwalker-single-01:
ansible_host: 192.0.2.10
rke2_node_ip: 192.0.2.10
rke2_agents:
hosts: {}
gpu_workers:
hosts: {}
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEPLOY_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
ARTIFACT_CACHE=""
OUTPUT_DIR=""
ALLOW_UNVALIDATED="false"
usage() {
echo "Usage: $0 --artifact-cache <path> --output <path> [--allow-unvalidated]" >&2
}
while [[ $# -gt 0 ]]; do
case "$1" in
--artifact-cache) ARTIFACT_CACHE="${2:?missing artifact cache}"; shift 2 ;;
--output) OUTPUT_DIR="${2:?missing output path}"; shift 2 ;;
--allow-unvalidated) ALLOW_UNVALIDATED="true"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ -d "$ARTIFACT_CACHE" && -n "$OUTPUT_DIR" ]] || { usage; exit 2; }
command -v python3 >/dev/null 2>&1 || { echo "python3 is required" >&2; exit 1; }
command -v sha256sum >/dev/null 2>&1 || { echo "sha256sum is required" >&2; exit 1; }
VALIDATED="$(python3 -c 'import sys,yaml; print(str(yaml.safe_load(open(sys.argv[1], encoding="utf-8"))["validated"]).lower())' "$DEPLOY_ROOT/versions.lock.yaml")"
if [[ "$VALIDATED" != "true" && "$ALLOW_UNVALIDATED" != "true" ]]; then
echo "versions.lock.yaml is not validated" >&2
exit 1
fi
OUTPUT_PARENT="$(cd "$(dirname "$OUTPUT_DIR")" && pwd)"
OUTPUT_NAME="$(basename "$OUTPUT_DIR")"
STAGING="$(mktemp -d "$OUTPUT_PARENT/.${OUTPUT_NAME}.staging.XXXXXX")"
cleanup() {
rm -rf "$STAGING"
}
trap cleanup EXIT INT TERM
mkdir -p "$STAGING/$OUTPUT_NAME"
cp -a "$DEPLOY_ROOT" "$STAGING/$OUTPUT_NAME/deploy"
cp -a "$ARTIFACT_CACHE/." "$STAGING/$OUTPUT_NAME/"
(
cd "$STAGING/$OUTPUT_NAME"
find . -type f \
! -name SHA256SUMS \
! -name SHA256SUMS.sig \
-print0 |
sort -z |
xargs -0 sha256sum > SHA256SUMS
)
if [[ -e "$OUTPUT_DIR" ]]; then
echo "Output already exists; refusing to overwrite: $OUTPUT_DIR" >&2
exit 1
fi
mv "$STAGING/$OUTPUT_NAME" "$OUTPUT_DIR"
echo "Offline bundle created: $OUTPUT_DIR"
echo "Sign SHA256SUMS before release, for example:"
echo " cosign sign-blob --key <private-key> --output-signature $OUTPUT_DIR/SHA256SUMS.sig $OUTPUT_DIR/SHA256SUMS"
echo "Provision the matching public key to the deployment controller outside the bundle."
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEPLOY_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
DEFAULT_BUNDLE_ROOT="$(cd "$DEPLOY_ROOT/.." && pwd)"
PROFILE=""
INVENTORY=""
CONFIG=""
SECRETS=""
BUNDLE_ROOT="$DEFAULT_BUNDLE_ROOT"
ALLOW_DEVELOPMENT="false"
ALLOW_PLAINTEXT_SECRETS="false"
TRUSTED_PUBLIC_KEY=""
PREFLIGHT_ONLY="false"
VERIFY_ONLY="false"
SKIP_APP="false"
RESUME_FROM="preflight"
usage() {
cat >&2 <<'USAGE'
Usage:
deploy.sh --profile single|ha --inventory <hosts.yml> --config <site.yml>
--secrets <secrets.sops.yaml> [options]
Options:
--bundle-root <path>
--trusted-public-key <path>
--allow-development-bundle
--allow-plaintext-secrets
--preflight-only
--verify-only
--skip-app
--resume-from preflight|os-baseline|airgap|rke2|addons|application|verify
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--profile) PROFILE="${2:?missing profile}"; shift 2 ;;
--inventory) INVENTORY="${2:?missing inventory}"; shift 2 ;;
--config) CONFIG="${2:?missing config}"; shift 2 ;;
--secrets) SECRETS="${2:?missing secrets}"; shift 2 ;;
--bundle-root) BUNDLE_ROOT="${2:?missing bundle root}"; shift 2 ;;
--trusted-public-key) TRUSTED_PUBLIC_KEY="${2:?missing trusted public key}"; shift 2 ;;
--allow-development-bundle) ALLOW_DEVELOPMENT="true"; shift ;;
--allow-plaintext-secrets) ALLOW_PLAINTEXT_SECRETS="true"; shift ;;
--preflight-only) PREFLIGHT_ONLY="true"; shift ;;
--verify-only) VERIFY_ONLY="true"; shift ;;
--skip-app) SKIP_APP="true"; shift ;;
--resume-from) RESUME_FROM="${2:?missing phase}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
[[ "$PROFILE" == "single" || "$PROFILE" == "ha" ]] || { usage; exit 2; }
[[ -f "$INVENTORY" && -f "$CONFIG" && -f "$SECRETS" ]] || {
echo "Inventory, config or secrets file is missing" >&2
exit 2
}
BUNDLE_ROOT="$(cd "$BUNDLE_ROOT" && pwd)"
export PATH="$BUNDLE_ROOT/tools/bin:$PATH"
STATE_DIR="${TRACKWALKER_STATE_DIR:-$DEPLOY_ROOT/.state}"
mkdir -p "$STATE_DIR"
LOCK_DIR="$STATE_DIR/deploy.lock"
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
echo "Another deployment appears to be running: $LOCK_DIR" >&2
exit 1
fi
umask 077
TEMP_DIR="$(mktemp -d "$STATE_DIR/run.XXXXXX")"
cleanup() {
rm -rf "$TEMP_DIR"
rmdir "$LOCK_DIR" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
LOG_FILE="$STATE_DIR/deploy-$(date +%Y%m%d-%H%M%S).log"
exec > >(tee -a "$LOG_FILE") 2>&1
VERIFY_ARGS=(--bundle-root "$BUNDLE_ROOT")
if [[ -n "$TRUSTED_PUBLIC_KEY" ]]; then
VERIFY_ARGS+=(--trusted-public-key "$TRUSTED_PUBLIC_KEY")
fi
RELEASE_VALIDATION=(--release)
if [[ "$ALLOW_DEVELOPMENT" == "true" ]]; then
VERIFY_ARGS+=(--allow-development-bundle)
RELEASE_VALIDATION=()
fi
"$SCRIPT_DIR/verify-bundle.sh" "${VERIFY_ARGS[@]}"
DECRYPTED_SECRETS="$TEMP_DIR/secrets.yaml"
if grep -q '^[[:space:]]*sops:' "$SECRETS"; then
command -v sops >/dev/null 2>&1 || { echo "sops is required" >&2; exit 1; }
sops --decrypt "$SECRETS" > "$DECRYPTED_SECRETS"
else
[[ "$ALLOW_PLAINTEXT_SECRETS" == "true" ]] || {
echo "Plaintext secrets require --allow-plaintext-secrets" >&2
exit 1
}
cp "$SECRETS" "$DECRYPTED_SECRETS"
fi
chmod 0600 "$DECRYPTED_SECRETS"
python3 "$SCRIPT_DIR/validate_config.py" \
--site "$CONFIG" \
--inventory "$INVENTORY" \
--secrets "$DECRYPTED_SECRETS" \
--versions "$DEPLOY_ROOT/versions.lock.yaml" \
"${RELEASE_VALIDATION[@]}"
CONFIG_PROFILE="$(python3 -c 'import sys,yaml; print(yaml.safe_load(open(sys.argv[1], encoding="utf-8"))["deployment"]["profile"])' "$CONFIG")"
[[ "$CONFIG_PROFILE" == "$PROFILE" ]] || {
echo "CLI profile $PROFILE does not match site profile $CONFIG_PROFILE" >&2
exit 1
}
export KUBECONFIG="${KUBECONFIG:-$STATE_DIR/kubeconfig}"
if [[ "$VERIFY_ONLY" == "true" ]]; then
"$SCRIPT_DIR/verify.sh" "$CONFIG"
exit 0
fi
case "$RESUME_FROM" in
preflight) ANSIBLE_TAGS="preflight,os-baseline,airgap,rke2,postcheck" ;;
os-baseline) ANSIBLE_TAGS="os-baseline,airgap,rke2,postcheck" ;;
airgap) ANSIBLE_TAGS="airgap,rke2,postcheck" ;;
rke2) ANSIBLE_TAGS="rke2,postcheck" ;;
addons|application|verify) ANSIBLE_TAGS="" ;;
*) echo "Unsupported resume phase: $RESUME_FROM" >&2; exit 2 ;;
esac
if [[ "$PREFLIGHT_ONLY" == "true" ]]; then
ANSIBLE_TAGS="preflight"
fi
if [[ -n "$ANSIBLE_TAGS" ]]; then
command -v ansible-playbook >/dev/null 2>&1 || { echo "ansible-playbook is required" >&2; exit 1; }
ANSIBLE_CONFIG="$DEPLOY_ROOT/ansible/ansible.cfg" \
ansible-playbook \
--inventory "$INVENTORY" \
"$DEPLOY_ROOT/ansible/site.yml" \
--extra-vars "@$CONFIG" \
--extra-vars "@$DECRYPTED_SECRETS" \
--extra-vars "offline_artifact_root=$BUNDLE_ROOT" \
--extra-vars "deployment_kubeconfig_path=$KUBECONFIG" \
--tags "$ANSIBLE_TAGS"
fi
if [[ "$PREFLIGHT_ONLY" == "true" || "$SKIP_APP" == "true" ]]; then
exit 0
fi
if [[ "$RESUME_FROM" != "application" && "$RESUME_FROM" != "verify" ]]; then
"$SCRIPT_DIR/install-addons.sh" "$CONFIG" "$BUNDLE_ROOT" "$DEPLOY_ROOT"
fi
if [[ "$RESUME_FROM" != "verify" ]]; then
command -v helm >/dev/null 2>&1 || { echo "helm is required" >&2; exit 1; }
command -v kubectl >/dev/null 2>&1 || { echo "kubectl is required" >&2; exit 1; }
NAMESPACE="$(python3 -c 'import sys,yaml; print(yaml.safe_load(open(sys.argv[1], encoding="utf-8"))["deployment"]["namespace"])' "$CONFIG")"
RELEASE_NAME="$(python3 -c 'import sys,yaml; print(yaml.safe_load(open(sys.argv[1], encoding="utf-8"))["deployment"]["releaseName"])' "$CONFIG")"
SECRET_NAME="$(python3 -c 'import sys,yaml; print(yaml.safe_load(open(sys.argv[1], encoding="utf-8"))["helmValues"]["global"]["existingSecret"])' "$CONFIG")"
SECRET_ENV="$TEMP_DIR/application-secrets.env"
SITE_VALUES="$TEMP_DIR/site-values.yaml"
python3 "$SCRIPT_DIR/render_secret_env.py" --secrets "$DECRYPTED_SECRETS" --output "$SECRET_ENV"
python3 "$SCRIPT_DIR/render_helm_values.py" \
--site "$CONFIG" \
--versions "$DEPLOY_ROOT/versions.lock.yaml" \
--output "$SITE_VALUES"
kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f -
kubectl --namespace "$NAMESPACE" create secret generic "$SECRET_NAME" \
--from-env-file "$SECRET_ENV" \
--dry-run=client \
-o yaml | kubectl apply -f -
helm upgrade --install "$RELEASE_NAME" "$DEPLOY_ROOT/charts/trackwalker" \
--namespace "$NAMESPACE" \
--values "$DEPLOY_ROOT/charts/trackwalker/values.yaml" \
--values "$DEPLOY_ROOT/charts/trackwalker/values-$PROFILE.yaml" \
--values "$SITE_VALUES" \
--wait \
--timeout 20m \
--atomic
fi
"$SCRIPT_DIR/verify.sh" "$CONFIG"
echo "Deployment completed. Log: $LOG_FILE"
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -Eeuo pipefail
CONFIG="${1:?site config is required}"
BUNDLE_ROOT="${2:?bundle root is required}"
DEPLOY_ROOT="${3:?deploy root is required}"
command -v helm >/dev/null 2>&1 || { echo "helm is required" >&2; exit 1; }
command -v kubectl >/dev/null 2>&1 || { echo "kubectl is required" >&2; exit 1; }
command -v yq >/dev/null 2>&1 || { echo "yq v4 is required" >&2; exit 1; }
install_chart() {
local release="$1"
local namespace="$2"
local chart="$3"
local values_file="$4"
shift 4
[[ -f "$chart" ]] || {
echo "Missing offline addon chart: $chart" >&2
exit 1
}
kubectl create namespace "$namespace" --dry-run=client -o yaml | kubectl apply -f -
helm upgrade --install "$release" "$chart" \
--namespace "$namespace" \
--values "$values_file" \
--wait \
--timeout 15m \
"$@"
}
if [[ "$(yq -r '.addons.longhorn.enabled' "$CONFIG")" == "true" ]]; then
LONGHORN_CHART="$BUNDLE_ROOT/$(yq -r '.addons.longhorn.chart' "$CONFIG")"
LONGHORN_NAMESPACE="$(yq -r '.addons.longhorn.namespace' "$CONFIG")"
install_chart \
longhorn \
"$LONGHORN_NAMESPACE" \
"$LONGHORN_CHART" \
"$DEPLOY_ROOT/addons/longhorn/values.yaml"
fi
if [[ "$(yq -r '.addons.gpuOperator.enabled' "$CONFIG")" == "true" ]]; then
GPU_CHART="$BUNDLE_ROOT/$(yq -r '.addons.gpuOperator.chart' "$CONFIG")"
GPU_NAMESPACE="$(yq -r '.addons.gpuOperator.namespace' "$CONFIG")"
DRIVER_MANAGED="$(yq -r '.gpu.driverManagedByOperator' "$CONFIG")"
install_chart \
gpu-operator \
"$GPU_NAMESPACE" \
"$GPU_CHART" \
"$DEPLOY_ROOT/addons/gpu/values.yaml" \
--set "driver.enabled=$DRIVER_MANAGED"
fi
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Render Helm overrides derived from a validated site configuration."""
from __future__ import annotations
import argparse
import copy
import os
import re
from pathlib import Path
import yaml
DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--site", type=Path, required=True)
parser.add_argument("--versions", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
site = yaml.safe_load(args.site.read_text(encoding="utf-8"))
versions = yaml.safe_load(args.versions.read_text(encoding="utf-8"))
raw_values = site.get("helmValues")
if not isinstance(raw_values, dict):
raise ValueError("site configuration is missing helmValues")
values = copy.deepcopy(raw_values)
deployment = site["deployment"]
storage = site["storage"]
gpu = site["gpu"]
values.setdefault("global", {})
values["global"]["runtimeProfile"] = deployment["runtimeProfile"]
values["global"]["storageClass"] = storage["storageClass"]
values["modelStorage"] = {
"capacity": storage["modelCapacity"],
"accessMode": storage["modelAccessMode"],
}
values.setdefault("gpu", {})
values["gpu"]["enabled"] = gpu["enabled"]
values.setdefault("images", {})
for name, locked_image in versions.get("images", {}).items():
digest = locked_image.get("digest", "") if isinstance(locked_image, dict) else ""
if DIGEST_PATTERN.fullmatch(str(digest)):
values["images"].setdefault(name, {})
values["images"][name]["digest"] = digest
descriptor = os.open(args.output, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as output:
yaml.safe_dump(values, output, allow_unicode=True, sort_keys=False)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Render Kubernetes Secret keys to a mode-0600 env file."""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import yaml
KEYS = {
"POSTGRES_PASSWORD": "postgresPassword",
"REDIS_PASSWORD": "redisPassword",
"MINIO_ROOT_USER": "minioRootUser",
"MINIO_ROOT_PASSWORD": "minioRootPassword",
"ARTIFACT_INSTALLER_TOKEN": "artifactInstallerToken",
"UAV_ACCESS_INTERNAL_TOKEN": "uavAccessInternalToken",
"FLIGHTHUB_EVENT_CALLBACK_TOKEN": "flighthubEventCallbackToken",
"EMQX_DASHBOARD_USERNAME": "emqxDashboardUsername",
"EMQX_DASHBOARD_PASSWORD": "emqxDashboardPassword",
"FLIGHTHUB_USER_TOKEN": "flighthubUserToken",
"FLIGHTHUB_MQTT_USERNAME": "flighthubMqttUsername",
"FLIGHTHUB_MQTT_PASSWORD": "flighthubMqttPassword",
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--secrets", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
document = yaml.safe_load(args.secrets.read_text(encoding="utf-8"))
application = document.get("application", {})
lines: list[str] = []
for output_key, input_key in KEYS.items():
value = application.get(input_key, "")
if not isinstance(value, str) or "\n" in value or "\r" in value:
raise ValueError(f"secret {input_key} must be a single-line string")
lines.append(f"{output_key}={value}")
descriptor = os.open(args.output, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as output:
output.write("\n".join(lines))
output.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""Validate AItrackwalker offline deployment inputs without contacting a cluster."""
from __future__ import annotations
import argparse
import ipaddress
import re
import sys
from pathlib import Path
from typing import Any
import yaml
UNSAFE_MARKERS = ("CHANGE_ME", "UNRESOLVED", "replace-with", "minioadmin")
DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
DNS_LABEL_PATTERN = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
class ValidationResult:
def __init__(self) -> None:
self.errors: list[str] = []
self.warnings: list[str] = []
def require(self, condition: bool, message: str) -> None:
if not condition:
self.errors.append(message)
def warn(self, condition: bool, message: str) -> None:
if not condition:
self.warnings.append(message)
def load_yaml(path: Path) -> dict[str, Any]:
try:
content = yaml.safe_load(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise ValueError(f"文件不存在: {path}") from exc
except yaml.YAMLError as exc:
raise ValueError(f"YAML 无法解析: {path}: {exc}") from exc
if not isinstance(content, dict):
raise ValueError(f"YAML 顶层必须是对象: {path}")
return content
def nested(data: dict[str, Any], dotted_path: str, default: Any = None) -> Any:
value: Any = data
for part in dotted_path.split("."):
if not isinstance(value, dict) or part not in value:
return default
value = value[part]
return value
def hosts_in_group(inventory: dict[str, Any], group: str) -> dict[str, Any]:
hosts = nested(inventory, f"all.children.{group}.hosts", {})
return hosts if isinstance(hosts, dict) else {}
def has_unsafe_marker(value: Any) -> bool:
if not isinstance(value, str):
return False
lowered = value.lower()
return any(marker.lower() in lowered for marker in UNSAFE_MARKERS)
def validate_site(site: dict[str, Any], result: ValidationResult) -> str:
result.require(site.get("schemaVersion") == 1, "site.schemaVersion 必须为 1")
profile = nested(site, "deployment.profile")
result.require(profile in {"single", "ha"}, "deployment.profile 必须是 single 或 ha")
namespace = nested(site, "deployment.namespace", "")
result.require(
isinstance(namespace, str) and bool(DNS_LABEL_PATTERN.fullmatch(namespace)),
"deployment.namespace 不是合法的 Kubernetes namespace",
)
result.require(
nested(site, "deployment.runtimeProfile") in {"cpu-local", "server-gpu"},
"deployment.runtimeProfile 必须是 cpu-local 或 server-gpu",
)
result.require(
nested(site, "deployment.runtimeNetworkMode") in {"isolated", "restricted-egress"},
"deployment.runtimeNetworkMode 必须是 isolated 或 restricted-egress",
)
networks: list[ipaddress._BaseNetwork] = []
for key in ("cluster.podCidr", "cluster.serviceCidr"):
value = nested(site, key)
try:
networks.append(ipaddress.ip_network(value, strict=False))
except (TypeError, ValueError):
result.errors.append(f"{key} 不是合法 CIDR: {value!r}")
if len(networks) == 2:
result.require(not networks[0].overlaps(networks[1]), "Pod CIDR 与 Service CIDR 不能重叠")
data_dir = nested(site, "cluster.dataDir", "")
result.require(isinstance(data_dir, str) and data_dir.startswith("/"), "cluster.dataDir 必须是绝对路径")
result.require(
nested(site, "storage.provider") in {"local-path", "longhorn", "external"},
"storage.provider 必须是 local-path、longhorn 或 external",
)
result.require(
nested(site, "storage.modelAccessMode") in {"ReadWriteOnce", "ReadWriteMany"},
"storage.modelAccessMode 必须是 ReadWriteOnce 或 ReadWriteMany",
)
runtime_profile = nested(site, "deployment.runtimeProfile")
gpu_enabled = nested(site, "gpu.enabled")
if runtime_profile == "server-gpu":
result.require(gpu_enabled is True, "server-gpu 档案必须启用 gpu.enabled")
if nested(site, "addons.gpuOperator.enabled"):
result.require(gpu_enabled is True, "启用 GPU Operator 时必须设置 gpu.enabled=true")
if nested(site, "storage.provider") == "longhorn":
result.require(
nested(site, "addons.longhorn.enabled") is True,
"storage.provider=longhorn 时必须启用 Longhorn addon",
)
return str(profile)
def validate_inventory(
inventory: dict[str, Any], profile: str, site: dict[str, Any], result: ValidationResult
) -> None:
servers = hosts_in_group(inventory, "rke2_servers")
agents = hosts_in_group(inventory, "rke2_agents")
gpu_workers = hosts_in_group(inventory, "gpu_workers")
result.require(bool(servers), "inventory 至少需要一个 rke2_servers 节点")
overlap = set(servers).intersection(agents)
result.require(not overlap, f"节点不能同时属于 rke2_servers 和 rke2_agents: {sorted(overlap)}")
all_cluster_hosts = {**servers, **agents}
result.require(
set(gpu_workers).issubset(all_cluster_hosts),
"gpu_workers 中的节点必须同时属于 rke2_servers 或 rke2_agents",
)
node_ips: list[str] = []
for hostname, variables in all_cluster_hosts.items():
result.require(
isinstance(variables, dict) and bool(variables.get("rke2_node_ip")),
f"节点 {hostname} 缺少 rke2_node_ip",
)
node_ip = variables.get("rke2_node_ip") if isinstance(variables, dict) else None
if node_ip:
try:
ipaddress.ip_address(node_ip)
node_ips.append(str(node_ip))
except ValueError:
result.errors.append(f"节点 {hostname} 的 rke2_node_ip 非法: {node_ip}")
result.require(len(node_ips) == len(set(node_ips)), "rke2_node_ip 必须唯一")
if profile == "single":
result.require(len(servers) == 1, "single 档案必须且只能有一个 rke2 server")
result.require(not agents, "single 档案不应配置独立 rke2 agent")
elif profile == "ha":
result.require(len(servers) >= 3, "ha 档案至少需要三个 rke2 server")
result.require(len(servers) % 2 == 1, "ha 档案的 rke2 server 数量必须为奇数")
result.require(
nested(site, "storage.provider") != "local-path",
"ha 档案不能使用 local-path 作为生产存储",
)
result.require(
nested(site, "storage.modelAccessMode") == "ReadWriteMany",
"ha 档案的模型卷必须为 ReadWriteMany",
)
result.require(
nested(site, "helmValues.bundledDataServices.enabled") is False,
"ha 档案必须关闭 Chart 内置单实例数据服务",
)
if nested(site, "gpu.enabled") and nested(site, "gpu.requiredOnGpuWorkers", True):
result.require(bool(gpu_workers), "启用 GPU 后 inventory 至少需要一个 gpu_workers 节点")
def validate_secrets(secrets: dict[str, Any], result: ValidationResult) -> None:
result.require(secrets.get("schemaVersion") == 1, "secrets.schemaVersion 必须为 1")
required_lengths = {
"rke2.token": 32,
"application.postgresPassword": 16,
"application.redisPassword": 16,
"application.minioRootUser": 3,
"application.minioRootPassword": 16,
"application.artifactInstallerToken": 32,
"application.uavAccessInternalToken": 32,
"application.flighthubEventCallbackToken": 32,
"application.emqxDashboardUsername": 3,
"application.emqxDashboardPassword": 16,
}
for key, minimum_length in required_lengths.items():
value = nested(secrets, key)
result.require(isinstance(value, str) and len(value) >= minimum_length, f"{key} 长度必须至少为 {minimum_length}")
result.require(not has_unsafe_marker(value), f"{key} 仍包含默认值或占位符")
if isinstance(value, str):
result.require("\n" not in value and "\r" not in value, f"{key} 不能包含换行")
def validate_versions(versions: dict[str, Any], release_mode: bool, result: ValidationResult) -> None:
result.require(versions.get("schemaVersion") == 1, "versions.schemaVersion 必须为 1")
validated = versions.get("validated") is True
if release_mode:
result.require(validated, "正式发布要求 versions.lock.yaml 的 validated=true")
else:
result.warn(validated, "版本锁尚未通过 M0 验证,仅允许开发部署")
for key, value in versions.items():
if key == "images" or not isinstance(value, (dict, list)):
continue
serialized = yaml.safe_dump(versions, allow_unicode=True)
if release_mode:
result.require(
nested(versions, "compliance.approved") is True,
"formal release requires compliance.approved=true",
)
result.require("UNRESOLVED" not in serialized, "正式发布的版本锁不能包含 UNRESOLVED")
images = versions.get("images", {})
result.require(isinstance(images, dict) and bool(images), "versions.images 不能为空")
if not isinstance(images, dict):
return
for name, image in images.items():
if not isinstance(image, dict):
result.errors.append(f"versions.images.{name} 必须是对象")
continue
reference = image.get("reference", "")
digest = image.get("digest", "")
result.require(bool(reference), f"镜像 {name} 缺少 reference")
result.require(":latest" not in str(reference), f"镜像 {name} 禁止使用 latest")
if release_mode:
result.require(bool(DIGEST_PATTERN.fullmatch(str(digest))), f"镜像 {name} 缺少合法 sha256 digest")
def validate_cross_file(site: dict[str, Any], secrets: dict[str, Any], result: ValidationResult) -> None:
flighthub_enabled = nested(site, "helmValues.flighthub.enabled") is True
mqtt_enabled = nested(site, "helmValues.flighthub.mqttEnabled") is True
if mqtt_enabled:
result.require(flighthub_enabled, "启用 FlightHub MQTT 前必须启用 FlightHub")
broker = nested(site, "helmValues.endpoints.mqtt.brokerUrl", "")
result.require(str(broker).startswith(("tcp://", "ssl://")), "MQTT brokerUrl 必须使用 tcp:// 或 ssl://")
if flighthub_enabled:
for key in ("baseUrl", "organizationUuid", "projectUuid"):
result.require(
bool(nested(site, f"helmValues.flighthub.{key}")),
f"helmValues.flighthub.{key} is required when FlightHub is enabled",
)
result.require(
bool(nested(secrets, "application.flighthubUserToken")),
"启用 FlightHub 时必须提供 application.flighthubUserToken",
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--site", type=Path, required=True)
parser.add_argument("--inventory", type=Path, required=True)
parser.add_argument("--secrets", type=Path, required=True)
parser.add_argument("--versions", type=Path, required=True)
parser.add_argument("--release", action="store_true", help="启用正式发布严格校验")
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
site = load_yaml(args.site)
inventory = load_yaml(args.inventory)
secrets = load_yaml(args.secrets)
versions = load_yaml(args.versions)
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
if "sops" in secrets:
print("ERROR: validate_config.py 需要已解密的秘密文件", file=sys.stderr)
return 2
result = ValidationResult()
profile = validate_site(site, result)
validate_inventory(inventory, profile, site, result)
validate_secrets(secrets, result)
validate_versions(versions, args.release, result)
validate_cross_file(site, secrets, result)
for warning in result.warnings:
print(f"WARNING: {warning}")
for error in result.errors:
print(f"ERROR: {error}", file=sys.stderr)
if result.errors:
print(f"配置预检失败:{len(result.errors)} 个错误,{len(result.warnings)} 个警告", file=sys.stderr)
return 1
print(f"配置预检通过:{len(result.warnings)} 个警告")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -Eeuo pipefail
BUNDLE_ROOT=""
ALLOW_DEVELOPMENT="false"
TRUSTED_PUBLIC_KEY=""
usage() {
echo "Usage: $0 --bundle-root <path> [--trusted-public-key <path>] [--allow-development-bundle]" >&2
}
while [[ $# -gt 0 ]]; do
case "$1" in
--bundle-root)
BUNDLE_ROOT="${2:?missing bundle root}"
shift 2
;;
--allow-development-bundle)
ALLOW_DEVELOPMENT="true"
shift
;;
--trusted-public-key)
TRUSTED_PUBLIC_KEY="${2:?missing trusted public key}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage
exit 2
;;
esac
done
[[ -n "$BUNDLE_ROOT" ]] || { usage; exit 2; }
BUNDLE_ROOT="$(cd "$BUNDLE_ROOT" && pwd)"
CHECKSUM_FILE="$BUNDLE_ROOT/SHA256SUMS"
if [[ ! -f "$CHECKSUM_FILE" ]]; then
if [[ "$ALLOW_DEVELOPMENT" == "true" ]]; then
echo "WARNING: development bundle has no SHA256SUMS"
exit 0
fi
echo "Missing bundle checksum file: $CHECKSUM_FILE" >&2
exit 1
fi
command -v sha256sum >/dev/null 2>&1 || {
echo "sha256sum is required" >&2
exit 1
}
(
cd "$BUNDLE_ROOT"
sha256sum --check SHA256SUMS
)
SIGNATURE_FILE="$BUNDLE_ROOT/SHA256SUMS.sig"
if [[ -f "$SIGNATURE_FILE" ]]; then
if [[ -n "$TRUSTED_PUBLIC_KEY" ]]; then
[[ -f "$TRUSTED_PUBLIC_KEY" ]] || {
echo "Trusted release public key does not exist: $TRUSTED_PUBLIC_KEY" >&2
exit 1
}
BUNDLE_REAL="$(realpath "$BUNDLE_ROOT")"
KEY_REAL="$(realpath "$TRUSTED_PUBLIC_KEY")"
case "$KEY_REAL" in
"$BUNDLE_REAL"/*)
echo "Trusted public key must be provisioned outside the bundle trust boundary" >&2
exit 1
;;
esac
command -v cosign >/dev/null 2>&1 || {
echo "cosign is required to verify the signed bundle" >&2
exit 1
}
cosign verify-blob \
--key "$KEY_REAL" \
--signature "$SIGNATURE_FILE" \
"$CHECKSUM_FILE"
elif [[ "$ALLOW_DEVELOPMENT" != "true" ]]; then
echo "Formal deployment requires --trusted-public-key outside the bundle" >&2
exit 1
else
echo "WARNING: signature was not verified because no trusted public key was supplied"
fi
elif [[ "$ALLOW_DEVELOPMENT" != "true" ]]; then
echo "Unsigned bundle is not allowed" >&2
exit 1
else
echo "WARNING: development bundle is not signed"
fi
echo "Bundle verification passed: $BUNDLE_ROOT"
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -Eeuo pipefail
CONFIG="${1:?site config is required}"
command -v kubectl >/dev/null 2>&1 || { echo "kubectl is required" >&2; exit 1; }
command -v yq >/dev/null 2>&1 || { echo "yq v4 is required" >&2; exit 1; }
NAMESPACE="$(yq -r '.deployment.namespace' "$CONFIG")"
RELEASE="$(yq -r '.deployment.releaseName' "$CONFIG")"
SELECTOR="app.kubernetes.io/instance=$RELEASE"
kubectl get nodes -o wide
kubectl --namespace "$NAMESPACE" get pods -o wide
kubectl --namespace "$NAMESPACE" wait \
--for=condition=Available \
deployment \
--selector "$SELECTOR" \
--timeout=15m
for deployment in frontend backend uav-access-service vision-inference pointcloud-analysis artifact-installer; do
kubectl --namespace "$NAMESPACE" rollout status "deployment/$deployment" --timeout=10m
done
if [[ "$(yq -r '.helmValues.bundledDataServices.enabled' "$CONFIG")" == "true" ]]; then
for statefulset in postgres redis kafka minio; do
kubectl --namespace "$NAMESPACE" rollout status "statefulset/$statefulset" --timeout=15m
done
fi
kubectl --namespace "$NAMESPACE" get ingress,service,pvc
echo "Cluster verification passed"
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
import os
import shutil
import subprocess
import unittest
from pathlib import Path
import yaml
DEPLOY_ROOT = Path(__file__).resolve().parents[1]
CHART = DEPLOY_ROOT / "charts" / "trackwalker"
HELM_BINARY = os.environ.get("HELM_BINARY") or shutil.which("helm")
@unittest.skipUnless(HELM_BINARY, "helm is not installed")
class HelmRenderTests(unittest.TestCase):
def render(self, profile: str) -> list[dict]:
command = [
HELM_BINARY or "helm",
"template",
"trackwalker",
str(CHART),
"--namespace",
"trackwalker",
"--values",
str(CHART / f"values-{profile}.yaml"),
]
completed = subprocess.run(
command,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
return [item for item in yaml.safe_load_all(completed.stdout) if item]
def test_single_profile_renders_bundled_stateful_services(self):
documents = self.render("single")
deployments = {
item["metadata"]["name"]: item["spec"]["replicas"]
for item in documents
if item["kind"] == "Deployment"
}
stateful_sets = {
item["metadata"]["name"]
for item in documents
if item["kind"] == "StatefulSet"
}
self.assertEqual(1, deployments["backend"])
self.assertEqual(1, deployments["artifact-installer"])
self.assertTrue(
{"postgres", "redis", "kafka", "minio", "prometheus"}.issubset(
stateful_sets
)
)
def test_ha_profile_excludes_bundled_stateful_services(self):
documents = self.render("ha")
self.assertFalse(any(item["kind"] == "StatefulSet" for item in documents))
deployments = {
item["metadata"]["name"]: item["spec"]["replicas"]
for item in documents
if item["kind"] == "Deployment"
}
self.assertEqual(2, deployments["frontend"])
self.assertEqual(1, deployments["backend"])
if __name__ == "__main__":
unittest.main()
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
import yaml
DEPLOY_ROOT = Path(__file__).resolve().parents[1]
class RenderValuesTests(unittest.TestCase):
def test_derives_site_values_and_injects_locked_image_digest(self):
versions = yaml.safe_load(
(DEPLOY_ROOT / "versions.lock.yaml").read_text(encoding="utf-8")
)
digest = "sha256:" + ("a" * 64)
versions["images"]["frontend"]["digest"] = digest
with tempfile.TemporaryDirectory() as directory:
temp_root = Path(directory)
versions_path = temp_root / "versions.yaml"
output_path = temp_root / "values.yaml"
versions_path.write_text(
yaml.safe_dump(versions, sort_keys=False),
encoding="utf-8",
)
subprocess.run(
[
sys.executable,
str(DEPLOY_ROOT / "scripts/render_helm_values.py"),
"--site",
str(DEPLOY_ROOT / "config/site.example.yaml"),
"--versions",
str(versions_path),
"--output",
str(output_path),
],
check=True,
)
values = yaml.safe_load(output_path.read_text(encoding="utf-8"))
self.assertEqual("cpu-local", values["global"]["runtimeProfile"])
self.assertEqual("local-path", values["global"]["storageClass"])
self.assertEqual("100Gi", values["modelStorage"]["capacity"])
self.assertEqual(digest, values["images"]["frontend"]["digest"])
self.assertNotIn("digest", values["images"].get("backend", {}))
if __name__ == "__main__":
unittest.main()
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
import copy
import importlib.util
import unittest
from pathlib import Path
import yaml
DEPLOY_ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location(
"validate_config", DEPLOY_ROOT / "scripts" / "validate_config.py"
)
assert SPEC and SPEC.loader
validate_config = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(validate_config)
def load_yaml(relative_path: str) -> dict:
return yaml.safe_load((DEPLOY_ROOT / relative_path).read_text(encoding="utf-8"))
def safe_secrets() -> dict:
secrets = load_yaml("config/secrets.example.yaml")
secrets["rke2"]["token"] = "r" * 48
app = secrets["application"]
app["postgresPassword"] = "p" * 24
app["redisPassword"] = "r" * 24
app["minioRootPassword"] = "m" * 24
app["artifactInstallerToken"] = "a" * 48
app["uavAccessInternalToken"] = "u" * 48
app["flighthubEventCallbackToken"] = "c" * 48
app["emqxDashboardPassword"] = "e" * 24
return secrets
def validate(site: dict, inventory: dict, secrets: dict | None = None):
result = validate_config.ValidationResult()
profile = validate_config.validate_site(site, result)
validate_config.validate_inventory(inventory, profile, site, result)
validate_config.validate_secrets(secrets or safe_secrets(), result)
validate_config.validate_cross_file(site, secrets or safe_secrets(), result)
return result
class ConfigurationValidationTests(unittest.TestCase):
def test_single_example_contract_is_valid_with_real_secrets(self):
result = validate(
load_yaml("config/site.example.yaml"),
load_yaml("inventories/single/hosts.example.yml"),
)
self.assertEqual([], result.errors)
def test_ha_example_contract_is_valid_with_real_secrets(self):
result = validate(
load_yaml("config/site-ha.example.yaml"),
load_yaml("inventories/ha/hosts.example.yml"),
)
self.assertEqual([], result.errors)
def test_rejects_overlapping_cluster_networks(self):
site = load_yaml("config/site.example.yaml")
site["cluster"]["serviceCidr"] = site["cluster"]["podCidr"]
result = validate(site, load_yaml("inventories/single/hosts.example.yml"))
self.assertTrue(any("CIDR" in error for error in result.errors))
def test_rejects_single_profile_with_multiple_servers(self):
inventory = load_yaml("inventories/single/hosts.example.yml")
inventory = copy.deepcopy(inventory)
inventory["all"]["children"]["rke2_servers"]["hosts"]["extra"] = {
"ansible_host": "192.0.2.11",
"rke2_node_ip": "192.0.2.11",
}
result = validate(load_yaml("config/site.example.yaml"), inventory)
self.assertTrue(any("single" in error for error in result.errors))
def test_rejects_ha_with_bundled_single_instance_data_services(self):
site = load_yaml("config/site-ha.example.yaml")
site["helmValues"]["bundledDataServices"]["enabled"] = True
result = validate(site, load_yaml("inventories/ha/hosts.example.yml"))
self.assertTrue(any("Chart" in error for error in result.errors))
def test_rejects_placeholder_secrets(self):
result = validate(
load_yaml("config/site.example.yaml"),
load_yaml("inventories/single/hosts.example.yml"),
load_yaml("config/secrets.example.yaml"),
)
self.assertGreater(len(result.errors), 0)
def test_flighthub_requires_connection_identifiers(self):
site = load_yaml("config/site.example.yaml")
site["helmValues"]["flighthub"]["enabled"] = True
secrets = safe_secrets()
secrets["application"]["flighthubUserToken"] = "flight-token"
result = validate(
site,
load_yaml("inventories/single/hosts.example.yml"),
secrets,
)
self.assertTrue(any("organizationUuid" in error for error in result.errors))
self.assertTrue(any("projectUuid" in error for error in result.errors))
def test_release_mode_requires_validated_lock_and_digests(self):
result = validate_config.ValidationResult()
validate_config.validate_versions(
load_yaml("versions.lock.yaml"), release_mode=True, result=result
)
self.assertTrue(any("validated" in error for error in result.errors))
self.assertTrue(any("digest" in error for error in result.errors))
self.assertTrue(any("compliance.approved" in error for error in result.errors))
if __name__ == "__main__":
unittest.main()
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Static checks that do not require Linux, Helm, Ansible or a Kubernetes cluster."""
from __future__ import annotations
import json
import py_compile
import re
import sys
from pathlib import Path
import yaml
DEPLOY_ROOT = Path(__file__).resolve().parents[1]
REQUIRED_FILES = [
"README.md",
"versions.lock.yaml",
"config/site.example.yaml",
"config/site-ha.example.yaml",
"config/secrets.example.yaml",
"inventories/single/hosts.example.yml",
"inventories/ha/hosts.example.yml",
"scripts/deploy.sh",
"scripts/verify-bundle.sh",
"ansible/site.yml",
"charts/trackwalker/Chart.yaml",
"charts/trackwalker/values.yaml",
"charts/trackwalker/values-single.yaml",
"charts/trackwalker/values-ha.yaml",
"charts/trackwalker/templates/applications.yaml",
"charts/trackwalker/templates/stateful-services.yaml",
"charts/trackwalker/files/cpu-models.json",
"charts/trackwalker/files/server-models.json",
]
APP_COMPONENTS = [
"frontend",
"backend",
"uav-access-service",
"vision-inference",
"pointcloud-analysis",
"artifact-installer",
]
def main() -> int:
errors: list[str] = []
files = [
path
for path in DEPLOY_ROOT.rglob("*")
if path.is_file()
and "__pycache__" not in path.parts
and ".state" not in path.parts
and "artifacts" not in path.parts
and "dist" not in path.parts
]
text_suffixes = {
"",
".cfg",
".gitignore",
".j2",
".json",
".md",
".py",
".sh",
".tpl",
".txt",
".yaml",
".yml",
}
for relative_path in REQUIRED_FILES:
if not (DEPLOY_ROOT / relative_path).is_file():
errors.append(f"missing required file: {relative_path}")
for path in files:
relative = path.relative_to(DEPLOY_ROOT)
data = path.read_bytes()
if b"\r\n" in data:
errors.append(f"CRLF is not allowed in offline Linux artifact: {relative}")
if b"\x00" in data:
errors.append(f"NUL byte found: {relative}")
if path.suffix in text_suffixes or path.name == ".gitignore":
try:
text = data.decode("utf-8")
except UnicodeDecodeError as exc:
errors.append(f"text file is not UTF-8: {relative}: {exc}")
continue
if any(line.endswith((" ", "\t")) for line in text.splitlines()):
errors.append(f"trailing whitespace found: {relative}")
if path.suffix in {".yaml", ".yml", ".tpl"}:
latest_pattern = r"(?i):" + r"latest(?:[\"'\s}]|$)"
if re.search(latest_pattern, text):
errors.append(f"unlocked latest image tag: {relative}")
if ("ansible" + ".utils.") in text:
errors.append(f"external Ansible collection dependency: {relative}")
for path in DEPLOY_ROOT.rglob("*.py"):
try:
py_compile.compile(str(path), doraise=True)
except py_compile.PyCompileError as exc:
errors.append(f"Python compile failed for {path.relative_to(DEPLOY_ROOT)}: {exc}")
yaml_paths = [
path
for path in files
if path.suffix in {".yaml", ".yml"}
and "templates" not in path.parts
]
for path in yaml_paths:
try:
list(yaml.safe_load_all(path.read_text(encoding="utf-8")))
except yaml.YAMLError as exc:
errors.append(f"YAML parse failed for {path.relative_to(DEPLOY_ROOT)}: {exc}")
for path in DEPLOY_ROOT.rglob("*.json"):
try:
json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
errors.append(f"JSON parse failed for {path.relative_to(DEPLOY_ROOT)}: {exc}")
applications = (
DEPLOY_ROOT / "charts/trackwalker/templates/applications.yaml"
).read_text(encoding="utf-8")
for component in APP_COMPONENTS:
if component not in applications:
errors.append(f"Helm application component is missing: {component}")
for environment_name in (
"FLIGHTHUB_ORGANIZATION_UUID",
"FLIGHTHUB_PROJECT_UUID",
"FLIGHTHUB_MQTT_TOPIC_FILTER",
"MINIO_ACCESS_KEY",
"MINIO_SECRET_KEY",
):
if environment_name not in applications:
errors.append(f"required application environment mapping is missing: {environment_name}")
values_ha = yaml.safe_load(
(DEPLOY_ROOT / "charts/trackwalker/values-ha.yaml").read_text(encoding="utf-8")
)
base_values = yaml.safe_load(
(DEPLOY_ROOT / "charts/trackwalker/values.yaml").read_text(encoding="utf-8")
)
version_lock = yaml.safe_load(
(DEPLOY_ROOT / "versions.lock.yaml").read_text(encoding="utf-8")
)
if set(base_values["images"]) != set(version_lock["images"]):
errors.append("Helm image keys and versions.lock.yaml image keys must match")
if values_ha["bundledDataServices"]["enabled"] is not False:
errors.append("HA values must disable bundled stateful services")
if values_ha["replicaCount"]["backend"] != 1:
errors.append("backend must remain single replica until it is made HA-safe")
bundle_verifier = (
DEPLOY_ROOT / "scripts/verify-bundle.sh"
).read_text(encoding="utf-8")
if "--key" not in bundle_verifier or "--trusted-public-key" not in bundle_verifier:
errors.append("formal bundle verification must use an explicitly trusted public key")
if "--certificate" in bundle_verifier:
errors.append("a certificate shipped inside the bundle must not be its own trust root")
deployment_entrypoint = (
DEPLOY_ROOT / "scripts/deploy.sh"
).read_text(encoding="utf-8")
if 'render_helm_values.py" \\' not in deployment_entrypoint or "--versions" not in deployment_entrypoint:
errors.append("deployment entrypoint must inject locked image digests into Helm values")
for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
if errors:
print(f"Static validation failed with {len(errors)} error(s)", file=sys.stderr)
return 1
print(f"Static validation passed for {len(files)} deployment files")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+64
View File
@@ -0,0 +1,64 @@
schemaVersion: 1
release: 0.1.0-dev
validated: false
architecture: linux-amd64
platform:
os: ubuntu-22.04
kernel: UNRESOLVED
rke2: v1.36.1+rke2r1
kubernetes: v1.36.1
helm: v3.20.2
ansibleCore: UNRESOLVED
longhorn: v1.11.2
gpuOperator: UNRESOLVED
images:
frontend:
reference: registry.example.com/trackwalker/frontend:0.1.0-dev
digest: UNRESOLVED
backend:
reference: registry.example.com/trackwalker/backend:0.1.0-dev
digest: UNRESOLVED
uavAccess:
reference: registry.example.com/trackwalker/uav-access-service:0.1.0-dev
digest: UNRESOLVED
visionCpu:
reference: registry.example.com/trackwalker/vision-inference:0.1.0-dev
digest: UNRESOLVED
visionGpu:
reference: registry.example.com/trackwalker/vision-inference-gpu:0.1.0-dev
digest: UNRESOLVED
pointcloudCpu:
reference: registry.example.com/trackwalker/pointcloud-analysis:0.1.0-dev
digest: UNRESOLVED
pointcloudGpu:
reference: registry.example.com/trackwalker/pointcloud-analysis-gpu:0.1.0-dev
digest: UNRESOLVED
artifactInstaller:
reference: registry.example.com/trackwalker/artifact-installer:0.1.0-dev
digest: UNRESOLVED
postgis:
reference: postgis/postgis:16-3.4
digest: UNRESOLVED
redis:
reference: redis:7.2-alpine
digest: UNRESOLVED
kafka:
reference: bitnami/kafka:3.8.1
digest: UNRESOLVED
minio:
reference: minio/minio:RELEASE.2024-10-13T13-34-11Z
digest: UNRESOLVED
emqx:
reference: emqx/emqx:5.8.4
digest: UNRESOLVED
prometheus:
reference: prom/prometheus:v2.55.0
digest: UNRESOLVED
compliance:
approved: false
thirdPartyNotices: UNRESOLVED
sbom: UNRESOLVED
correspondingSource: UNRESOLVED
File diff suppressed because it is too large Load Diff
+441
View File
@@ -0,0 +1,441 @@
# 司空 2 私有化 OpenAPI 接入部署与联调手册
> 适用项目:AItrackwalker 铁路无人机智能巡检平台
> 代码基线日期:2026-07-25
> 设计依据:[司空 2 私有化 OpenAPI 集成设计](./dji-flighthub2-private-openapi-integration-design.md)
## 1. 当前交付范围
本次实现新增独立的 `uav-access-service`,并将原有巡检平台从模拟器适配扩展为 `FLIGHTHUB2` 真实适配器。代码已经具备以下链路:
| 领域 | 已实现能力 | 当前边界 |
|---|---|---|
| 鉴权 | 从只读密钥文件读取 `x-user-token`;平台到接入服务使用独立内部令牌 | 令牌由司空 2 部署后签发 |
| 健康检查 | 校验本地配置并调用项目拓扑接口 | 必须使用真实项目验证权限范围 |
| 设备同步 | 拉取机场、飞行器和负载拓扑,写入平台设备及机场表 | 设备型号字段需结合实际返回样本复核 |
| 航线 | 平台航点编译为 WPML/KMZ,申请 STS、上传对象存储、提交导入回调、轮询导入结果 | WPML 机型和负载枚举必须按真实设备确认 |
| 任务 | 飞前检查、创建高级任务、暂停/恢复/取消、任务状态对账 | 默认关闭飞行控制,真机验收后才允许开启 |
| 实时事件 | EventAPI HTTP 接入、MQTT Bridge 订阅、原始事件去重、状态归一化、Outbox 投递 Kafka | EventAPI 签名和 MQTT 主题结构需部署后冻结 |
| 平台投影 | Kafka Inbox 去重,更新任务、命令、设备状态、遥测及实际航迹 | 依赖真实事件样本补充字段映射 |
| 可观测性 | Actuator 健康检查、Prometheus 指标入口、请求 ID、原始事件审计和对账任务 | 生产告警阈值需按现场频率配置 |
| 媒体 | 已预留厂商媒体映射表和事件入口 | 媒体清单/下载接口和对象存储策略确认后实现镜像 Worker |
| 直播/DRC | 架构和安全开关已预留 | 不在本次默认启用范围 |
数据库迁移由 Flyway 自动执行:
- 平台库:`platform/backend/.../V17__flighthub2_integration.sql`
- 接入域:`uav-access-service/.../V1__uav_access_foundation.sql`
接入域使用 PostgreSQL 的独立 `uav_access` schema,不与平台业务表混写。
## 2. 部署拓扑
```mermaid
flowchart LR
UI["巡检平台前端"] --> API["platform/backend"]
API --> ACCESS["uav-access-service"]
ACCESS --> FH["司空 2 私有化 OpenAPI"]
FH --> CALLBACK["EventAPI 回调入口"]
CALLBACK --> ACCESS
FH --> BRIDGE["司空 2 MQTT Bridge"]
BRIDGE --> EMQX["EMQX"]
EMQX --> ACCESS
ACCESS --> KAFKA["Kafka: uav.access.events.v1"]
KAFKA --> API
ACCESS --> DB[("PostgreSQL / uav_access")]
API --> DB2[("PostgreSQL / public")]
```
生产网络建议:
1. `platform/backend`、Kafka、PostgreSQL 和 `uav-access-service` 放在应用内网。
2. 仅将 `POST /api/v1/events/flighthub` 通过 HTTPS 反向代理开放给司空 2 EventAPI。
3. `8091` 端口不能直接暴露到不可信网络;内部接口通过 `X-Access-Token` 保护,但仍应配合防火墙或安全组。
4. EMQX 的 `1883` 仅用于隔离网络联调;跨主机或跨安全域应使用 `8883/TLS`
5. 司空 2 OpenAPI、STS 对象存储地址、DNS 和证书链必须能从 `uav-access-service` 容器访问。
## 3. 部署文件
| 文件 | 用途 |
|---|---|
| `infra/docker-compose.yml` | 基础平台和默认关闭的接入服务 |
| `infra/docker-compose.flighthub2.yml` | 启用司空 2 的生产覆盖配置 |
| `infra/docker-compose.server.yml` | GPU/服务器资源与重启策略覆盖 |
| `infra/.env.flighthub2.example` | 参数模板,不包含真实密钥 |
| `uav-access-service/Dockerfile` | 接入服务镜像构建 |
| `infra/prometheus.yml` | 平台与接入服务指标采集 |
真实 `.env.flighthub2` 和用户令牌文件均已加入忽略规则或位于被忽略目录,禁止提交到 Git。
## 4. 部署后必须确认的参数
### 4.1 阻断上线的必填参数
| 环境变量 | 获取位置 | 验证方法 |
|---|---|---|
| `FLIGHTHUB_BASE_URL` | 司空 2 私有化部署输出或域名规划 | 容器内解析 DNS,证书有效,健康检查不报网络错误 |
| `FLIGHTHUB_USER_TOKEN_HOST_FILE` | 将司空 2 用户 Token 写入宿主机只读文件 | 调用项目拓扑接口返回业务码 `0` |
| `FLIGHTHUB_ORGANIZATION_UUID` | 司空 2 组织管理/API 返回 | 与 Token 所属组织一致 |
| `FLIGHTHUB_PROJECT_UUID` | 司空 2 项目详情/API 返回 | 拓扑、STS、航线和任务接口均使用同一项目 |
| `FLIGHTHUB_WPML_*_ENUM_VALUE` | 实际飞行器、机场、负载型号及 WPML 兼容表 | 导入一条最小安全航线并确认解析成功 |
| `FLIGHTHUB_EVENT_CALLBACK_TOKEN` | 双方协商生成 | 回调缺失或错误 Token 返回 401,正确 Token 入 Inbox |
| `UAV_ACCESS_INTERNAL_TOKEN` | 本方生成,至少 32 个随机字符 | 平台健康检查成功,错误 Token 返回 401 |
| `FLIGHTHUB_MQTT_USERNAME/PASSWORD` | EMQX 或现场 Broker 账号 | 连接成功且事件可进入接入服务 |
用户令牌采用 `x-user-token` 请求头发送。不要把 Token 直接写进 `.env`;当前实现优先读取 `FLIGHTHUB_USER_TOKEN_FILE`
### 4.2 需要用真实实例冻结的参数
这些值已有配置入口,但不能仅依靠离线文档安全定版:
| 项目 | 默认值/当前实现 | 部署后动作 |
|---|---|---|
| OpenAPI 根地址和前缀 | `FLIGHTHUB_BASE_URL` + `/openapi/v2.0/...` | 确认私有化网关是否增加统一前缀 |
| API Token 生命周期 | 文件挂载 | 确认有效期、刷新方式、撤销方式和最小权限 |
| WPML 版本 | `1.0.6` | 以部署版本和设备固件兼容矩阵为准 |
| 飞行器/负载枚举 | 无安全默认值,强制必填 | 用项目真实拓扑和官方枚举表确认四个值 |
| STS 存储类型 | 支持 `minio``aws` S3 兼容上传 | 若实际返回 `ali`,需增加 OSS 上传实现后才能启用航线发布 |
| S3 寻址 | Path-style 默认开启 | 根据 STS endpoint、bucket 和网关证书验证 |
| 任务 `device_type` | 空值时不发送 | 根据机场/设备和高级任务接口实际要求填写 |
| 失控动作/续飞策略 | `0` / `1` | 由飞行安全负责人审核,不允许仅沿用示例 |
| 返航高度 | `100m` | 按线路净空、空域和现场 SOP 审核 |
| EventAPI 鉴权 | 当前支持共享 Header Token | 若司空 2 使用签名、时间戳或固定 Header,按真实协议替换校验器 |
| EventAPI 事件字段 | 接受原始 JSON 并做通用归一化 | 保存脱敏样本,冻结任务、设备、媒体字段映射 |
| MQTT Broker/TLS | 默认本地 EMQX、`tcp://emqx:1883` | 生产改 TLS,确认 CA、账号、ACL 和双向认证要求 |
| MQTT Topic/QoS | `flighthub2/#`、QoS 1 | 以 Bridge 配置和真实主题样本为准,收紧通配范围 |
| 媒体回传 | 仅建表和事件入口 | 确认媒体清单、下载授权、URL 有效期和 MinIO 镜像规则 |
| 遥测频率 | 不在接入端硬编码 | 用真机样本确定抽稀、乱序窗口和存储周期 |
| 直播/DRC | 默认未启用 | 单独进行网络、时延、审计和飞行安全评审 |
所有部署后确认结果都应回填到本文件第 10 节的验收记录,不应只保存在聊天或临时工单中。
## 5. 首次部署
以下命令在 `infra` 目录执行。
### 5.1 准备环境文件
```bash
cp .env.flighthub2.example .env.flighthub2
chmod 600 .env.flighthub2
```
编辑 `.env.flighthub2`,替换所有 `replace-...` 占位值。
生成服务间令牌和回调令牌时可使用:
```bash
openssl rand -hex 32
```
### 5.2 创建司空 2 用户 Token 密钥文件
```bash
install -d -m 700 secrets
umask 077
read -r -s FLIGHTHUB_TOKEN
printf '%s' "$FLIGHTHUB_TOKEN" > secrets/flighthub-user-token
unset FLIGHTHUB_TOKEN
chmod 600 secrets/flighthub-user-token
```
文件内容只能包含 Token 本身。不要附加 JSON、变量名或引号。
### 5.3 配置 MQTT Bridge
若使用本项目 EMQX
1. 使用 `https://<部署主机>:18083` 或仅内网的 Dashboard 管理入口。
2. 开启密码认证,创建 `FLIGHTHUB_MQTT_USERNAME` 对应用户。
3. 配置最小 ACL:司空 2 Bridge 只允许发布约定主题;接入服务只允许订阅该主题。
4. 在司空 2 MQTT Bridge 中填写 EMQX 地址、端口、账号、密码、Topic 和 QoS。
5. 生产环境启用 TLS 后,将 `FLIGHTHUB_MQTT_BROKER_URL` 改为 `ssl://<broker>:8883`,并把内部 CA 导入接入服务 JVM truststore。
如果现场已有受管 Broker,可不使用本项目 EMQX,但需要从覆盖文件移除 `uav-access-service -> emqx` 的启动依赖,且保留等价的 TLS、认证和 ACL。
### 5.4 校验编排
```bash
docker compose \
--env-file .env.flighthub2 \
-f docker-compose.yml \
-f docker-compose.flighthub2.yml \
--profile flighthub2 \
config --quiet
```
服务器 GPU 环境再追加:
```text
-f docker-compose.server.yml
```
若命令提示 `must be set``must be confirmed`,说明必填参数仍未完成,不应绕过。
### 5.5 构建和启动
通用环境:
```bash
docker compose \
--env-file .env.flighthub2 \
-f docker-compose.yml \
-f docker-compose.flighthub2.yml \
--profile flighthub2 \
up -d --build
```
服务器 GPU 环境:
```bash
docker compose \
--env-file .env.flighthub2 \
-f docker-compose.yml \
-f docker-compose.flighthub2.yml \
-f docker-compose.server.yml \
--profile flighthub2 \
up -d --build
```
启动时两套 Flyway 迁移会自动执行。先确认迁移成功,再执行平台初始化。
### 5.6 健康检查
```bash
curl -fsS http://127.0.0.1:8091/actuator/health
curl -fsS http://127.0.0.1:8080/actuator/health
docker compose --env-file .env.flighthub2 \
-f docker-compose.yml -f docker-compose.flighthub2.yml \
--profile flighthub2 ps
```
查看日志时不得复制或输出 Token:
```bash
docker compose --env-file .env.flighthub2 \
-f docker-compose.yml -f docker-compose.flighthub2.yml \
--profile flighthub2 logs --tail=200 uav-access-service backend
```
## 6. 平台初始化
### 6.1 注册司空 2 连接
`workspaceId` 当前保存司空 2 项目 UUID;真实凭据不写入平台表,`secretRef` 只记录密钥引用名称。
```bash
curl -fsS -X POST http://127.0.0.1:8080/api/v1/uav/connections \
-H 'Content-Type: application/json' \
-d '{
"name": "司空2生产环境",
"vendorCode": "FLIGHTHUB2",
"connectionMode": "PRIVATE_OPENAPI_V2",
"environment": "PRODUCTION",
"endpointUrl": "https://flighthub2.example.internal",
"workspaceId": "<FLIGHTHUB_PROJECT_UUID>",
"secretRef": "file:/run/secrets/flighthub-user-token",
"enabled": true
}'
```
记录响应中的 `connection_id`
### 6.2 检查连接和同步设备
```bash
curl -fsS -X POST \
http://127.0.0.1:8080/api/v1/uav/connections/<connection_id>/check
curl -fsS -X POST \
http://127.0.0.1:8080/api/v1/uav/connections/<connection_id>/sync-devices
curl -fsS http://127.0.0.1:8080/api/v1/uav/devices
```
连接只有在拓扑接口调用成功后才会从 `MATERIALS_REQUIRED` 变为 `AVAILABLE`。不要手工改数据库绕过健康检查。
### 6.3 配置 EventAPI
司空 2 回调目标:
```text
POST https://<公开接入域名>/api/v1/events/flighthub
```
当前接入端识别以下 Header
```text
X-FlightHub-Event-Token: <FLIGHTHUB_EVENT_CALLBACK_TOKEN>
X-FlightHub-Event-Id: <唯一事件 ID,可选>
X-FlightHub-Event-Type: <事件类型,可选>
X-Connection-Id: <平台 connection_id>
```
其中 `X-Connection-Id` 应配置为第 6.1 节创建的连接 ID,确保事件投影到正确连接。若司空 2 EventAPI 控制台不能配置这些 Header,需根据实际回调契约调整 `FlightHubEventController`,不能关闭鉴权。
反向代理只放行上述 POST 路径,并配置:
- HTTPS 和可信证书;
- 请求体大小限制;
- 来源 IP 白名单(若司空 2 提供固定出口);
- 限流;
- 保留请求 ID,日志中隐藏鉴权 Header。
## 7. 分阶段联调与飞行控制开关
### 阶段 A:只读连接
保持:
```text
FLIGHTHUB_FLIGHT_CONTROL_ENABLED=false
```
完成以下检查:
1. OpenAPI 健康检查成功。
2. 机场、飞行器、负载层级与司空 2 控制台一致。
3. EventAPI 和 MQTT 各至少收到一类可识别事件。
4. 重复投递同一事件不会重复更新业务状态。
### 阶段 B:航线导入
1. 选定空旷测试场的最小闭合或短航线。
2. 确认四个 WPML 设备枚举。
3. 在平台完成航线校验和发布后,调用独立同步接口:
```bash
curl -fsS -X POST \
http://127.0.0.1:8080/api/v1/route-versions/<version_id>/uav-connections/<connection_id>/synchronize
```
4. 若返回 `IMPORTING`,等待数秒后重复调用;接口会刷新司空 2 导入结果。
5. 观察 STS 上传、导入回调和导入状态轮询。
6. 在司空 2 控制台人工核对高度、速度、航点、返航参数和负载动作。
航线导入未通过时,不得进入任务联调。
### 阶段 C:任务影子模式
仍保持飞行控制关闭,通过只读接口和测试项目验证:
- 航线导入幂等和状态对账;
- 幂等键重复提交;
- 任务状态归一化;
- EventAPI 丢失时批量对账;
- 任务创建及暂停、恢复、取消命令均被安全开关拒绝。
### 阶段 D:受控真机验收
经飞行安全负责人批准后,将:
```text
FLIGHTHUB_FLIGHT_CONTROL_ENABLED=true
```
只重建或重启 `uav-access-service`
```bash
docker compose \
--env-file .env.flighthub2 \
-f docker-compose.yml \
-f docker-compose.flighthub2.yml \
--profile flighthub2 \
up -d --no-deps uav-access-service
```
先做单机场、单飞行器、单航线任务;验证创建、起飞、执行、暂停、恢复、取消、完成和异常终止。任务创建成功只是“司空 2 已接受”,平台不会再把接受结果伪装为执行成功,最终状态来自事件或对账。
## 8. 升级、回滚和密钥轮换
### 8.1 升级
1. 备份 PostgreSQL 和 `.env.flighthub2` 的版本记录,但不把明文密钥放入制品库。
2. 先在测试项目执行 `docker compose ... config --quiet`。
3. 构建并滚动启动 `uav-access-service`、`backend`。
4. 复核 Flyway、健康检查、Outbox 堆积和 Kafka 消费延迟。
### 8.2 紧急停飞
优先在司空 2 控制台执行现场安全 SOP,同时将:
```text
FLIGHTHUB_FLIGHT_CONTROL_ENABLED=false
```
并重启接入服务。该开关禁止新的任务创建和控制命令,不替代飞行中的现场处置。
### 8.3 Token 轮换
1. 在司空 2 创建新 Token 并赋予最小权限。
2. 原子替换宿主机密钥文件。
3. 重启接入服务并执行连接检查。
4. 验证成功后撤销旧 Token。
Token 轮换失败时回退到上一份受控密钥文件;不要在日志、命令行参数或工单正文中传递 Token。
### 8.4 应用回滚
应用镜像可回滚到上一版本,但 Flyway 数据库迁移默认只向前。`V17` 和接入域 `V1` 均为增量建表/加列,旧应用可以忽略这些字段。禁止用 `git reset --hard` 或直接删表作为部署回滚手段。
## 9. 监控和排障
Prometheus 已增加:
- `backend:8080/actuator/prometheus`
- `uav-access-service:8091/actuator/prometheus`
上线至少监控:
- 接入服务健康状态和 JVM 资源;
- OpenAPI 失败率、401/403/429/5xx
- 航线导入长期停留在处理中;
- 任务长时间未对账;
- Kafka 消费延迟;
- Event Inbox 重复率;
- Outbox 未投递数量;
- MQTT 断连和重连次数;
- 设备最后在线时间。
常见故障判断:
| 症状 | 优先检查 |
|---|---|
| `CONFIGURATION_REQUIRED` | `FLIGHTHUB_ENABLED`、必填环境变量、HTTPS 安全门 |
| 401/鉴权失败 | Token 文件内容、有效期、用户权限、系统时间 |
| 拓扑为空 | 项目 UUID、用户是否加入项目、设备是否绑定到该项目 |
| 航线上传失败 | STS provider/endpoint/bucket、容器 DNS、Path-style、时间同步 |
| 航线导入失败 | WPML 版本、机型/负载枚举、动作参数和航点坐标 |
| 任务被拒绝 | 飞前检查结果、设备在线状态、电量、航线 UUID、控制安全开关 |
| 有事件但平台不更新 | `X-Connection-Id`、Kafka Topic、消费者组、Inbox/Outbox 状态 |
| MQTT 无消息 | Bridge 地址、账号 ACL、Topic filter、TLS CA 和 QoS |
## 10. 部署验收记录模板
每个环境复制一份填写,建议文件名:
```text
dji-flighthub2-acceptance-<environment>-<yyyyMMdd>.md
```
| 检查项 | 确认值/证据 | 负责人 | 日期 | 结果 |
|---|---|---|---|---|
| 司空 2 部署版本 | | | | |
| OpenAPI Base URL/前缀 | | | | |
| 组织 UUID / 项目 UUID | | | | |
| Token 权限与有效期 | | | | |
| 飞行器/机场/负载和固件 | | | | |
| WPML 版本及四个枚举 | | | | |
| STS provider/endpoint/寻址方式 | | | | |
| EventAPI 鉴权和事件样本 | | | | |
| MQTT Broker/TLS/Topic/QoS | | | | |
| 任务 `device_type` | | | | |
| 失控动作/续飞/返航策略 | | | | |
| 遥测字段和频率 | | | | |
| 媒体清单和下载机制 | | | | |
| 航线导入验收 | | | | |
| 单机任务全状态验收 | | | | |
| 断网、重复事件、对账验收 | | | | |
| 飞行控制开关审批 | | | | |
只有当阻断项全部通过且飞行安全审批完成后,生产环境才允许设置 `FLIGHTHUB_FLIGHT_CONTROL_ENABLED=true`。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,777 @@
# 铁路无人机智能巡检平台前端界面效果优化方案
## 1. 文档定位
| 项目 | 内容 |
| --- | --- |
| 适用项目 | 铁路无人机智能巡检平台 Web 前端 |
| 参考材料 | 《铁路无人机智能巡查方案 ---202606(1).pptx》第 22 页指挥调度大屏构想 |
| 当前技术栈 | Vue 3、TypeScript、Element Plus、ECharts、MapLibre GL、Three.js |
| 优化目标 | 在不破坏现有业务功能的前提下,建设具有铁路行业辨识度、实时态势感和指挥调度感的展示界面 |
| 主要交付 | 新增“指挥中心大屏”视觉模式,并统一现有页面的颜色、图表、地图、动效和状态表达 |
## 2. 核心结论
当前前端不是“技术栈太基础”,而是“已有高级技术没有被组织成一个明确的视觉中心”。
项目已经具备 ECharts、MapLibre GL 和 Three.js,不需要推翻 Vue 工程,也不建议改成单文件 HTML + CDN。真正需要做的是:
1. 新增独立的深色“指挥中心大屏”路由,不把现有管理后台全部改成大屏。
2. 以铁路 GIS 态势图作为画面中心,而不是使用与业务关系较弱的 3D 地球。
3. 把无人机、机巢、航线、任务、AI 告警、工单闭环组织为同一条实时业务链。
4. 建立克制的深色视觉令牌、面板体系、图表语法和状态动效。
5. 引入 deck.gl、Motion for Vue、CountUp.js 等增强库,但避免同时堆叠多个功能重叠的动画和边框库。
6. 所有装饰和动画都必须表达真实状态,不能只为“炫”而存在。
最终应形成两种互补的产品模式:
- **工作台模式**:保留现有浅色后台,用于表格、配置、研判、处置和模型管理。
- **指挥中心模式**:深色 16:9 大屏,用于综合态势、值守监控、领导汇报和应急指挥。
## 3. 现状判断
### 3.1 当前实现基础
| 当前能力 | 已有实现 | 可复用价值 |
| --- | --- | --- |
| 页面框架 | `AppShell.vue`、左侧导航、顶部栏 | 保留为日常工作台框架 |
| 工作总览 | `OverviewView.vue` | 可复用指标、待办、告警和闭环数据逻辑 |
| GIS 态势 | `GisView.vue``OperationalGisMap.vue` | 作为指挥中心核心场景的基础 |
| 图表 | ECharts 5 | 继续作为趋势、占比、仪表盘的主图表引擎 |
| 地图 | MapLibre GL | 继续作为铁路空间态势底座 |
| 三维能力 | Three.js | 用于点云、无人机/机巢模型和局部数字孪生 |
| 实时能力 | SSE、运行事件、任务和告警接口 | 可支撑实时状态更新和事件流 |
| UI 组件 | Element Plus | 继续服务表单、弹窗、表格和复杂业务操作 |
### 3.2 当前视觉问题
1. **页面身份更像通用管理后台。**
当前全局背景为浅灰,卡片以白底、浅边框、普通圆角为主,能够满足业务操作,但缺少指挥中心的空间层次和实时氛围。
2. **工作总览没有明确的视觉主体。**
指标、待办、表格、快捷入口和健康状态权重接近,用户第一眼看不到“当前线路态势”和“最需要处理的事件”。
3. **GIS 与业务总览被分成两个页面。**
PPT 中的核心构想是“地图态势 + 设备 + 告警 + 调度”同时出现;当前总览页与 GIS 页分离,无法形成统一指挥场景。
4. **地图目前是功能底图,还不是态势底图。**
当前地图使用空白浅色背景和基础 GeoJSON 图层,缺少暗色铁路底图、地形层次、里程语义、飞行动态、告警影响范围和聚合表达。
5. **状态表达以静态卡片和表格为主。**
任务执行、无人机在线、告警产生、工单闭环等状态变化缺少连续、可感知的视觉反馈。
6. **现有样式集中度较高。**
大量页面共用 `workspace.css`,如果直接全局改成深色科技风,会影响模型管理、表格和业务表单的可读性。
## 4. 目标视觉方向
### 4.1 视觉关键词
- 深空蓝黑,而不是纯黑。
- 低饱和科技蓝,而不是刺眼荧光蓝。
- 半透明分层,而不是所有卡片都使用高模糊毛玻璃。
- 细线、微光、低对比网格,而不是粗边框和重阴影。
- 铁路线性地理特征,而不是通用智慧城市地球。
- 实时状态驱动动效,而不是页面永久高频闪烁。
- 少量重点红橙告警,而不是红蓝元素平均铺满页面。
### 4.2 业务视觉中心
不建议把“旋转 3D 地球”作为本项目主场景。铁路无人机巡检的核心空间对象是:
- 线路、桥梁、隧道、边坡和防洪点;
- 无人机、机巢、航线和实时轨迹;
- 告警位置、影响区间和责任专业;
- 工单状态和复查路径。
因此,中心视觉应采用:
> 暗色 2.5D 铁路 GIS + 地形/线路高程 + 无人机飞行轨迹 + 告警脉冲 + 关键设施三维模型
Three.js 仍然保留,但用于有业务价值的局部三维内容:
- 无人机和机巢的轻量 GLTF 模型;
- 点云、DEM、边坡形变和局部数字孪生;
- 重点告警区域的三维定位;
- MapLibre 自定义三维图层。
## 5. 指挥中心大屏布局
建议新增路由:
```text
/command-center
```
默认设计画布为 `1920 × 1080`,采用 CSS Grid 构建,不依赖所有页面统一缩放。
```text
┌────────────────────────────────────────────────────────────────────────────┐
│ 系统标题 / 当前线路 / 班次 全局态势 KPI 时间 / 天气 / 系统状态 │
├───────────────┬──────────────────────────────────┬─────────────────────────┤
│ 无人机与机巢 │ │ AI 告警结构 │
│ 设备在线态势 │ │ 风险等级 / 专业分布 │
├───────────────┤ 铁路 GIS 核心态势图 ├─────────────────────────┤
│ 今日任务趋势 │ 线路 / 航线 / 轨迹 / 告警 /工单 │ 实时告警事件流 │
│ 飞行与覆盖率 │ │ 研判 / 派单快捷动作 │
├───────────────┴──────────────────────────────────┴─────────────────────────┤
│ 实时影像 / 重点区段 / 处置闭环进度 / 系统事件滚动条 │
└────────────────────────────────────────────────────────────────────────────┘
```
### 5.1 顶部态势栏
建议高度 `6472px`,包含:
- 居中标题:`AI + 无人机智慧铁路低空巡检指挥中心`
- 左侧:当前线路、区段、值守班次和数据更新时间;
- 中部:飞行架次、巡检里程、有效告警、闭环率等 4–6 个核心 KPI;
- 右侧:实时时钟、天气、网络状态、数据延迟和服务健康;
- 底部使用一条低亮度流动光线表达“实时连接”,不使用大面积霓虹。
### 5.2 中央 GIS 核心区
中央区域应占主内容宽度的 `50%60%`,承担第一视觉焦点。
显示层级建议:
1. 深色矢量底图和铁路沿线地形。
2. 铁路线、桥梁、隧道、防洪点和限制区。
3. 计划航线与实际轨迹。
4. 无人机、机巢和现场设备。
5. 告警点、影响里程区间和处置状态。
6. 当前选中对象的轻量详情浮层。
交互原则:
- 单击对象后,左右面板同步切换到该对象上下文。
- 告警点不能只靠颜色区分,同时显示等级、状态和影响范围。
- 只有当前飞行任务使用持续轨迹动画;历史轨迹保持静态。
- 高等级告警使用 2–3 次脉冲后转为稳定高亮,禁止永久闪烁。
- 支持“全线态势”“当前任务”“应急事件”三个相机视角预设。
### 5.3 左侧运行面板
建议包含两类信息:
1. **无人机与机巢状态**
- 在线、飞行、充电、离线、故障数量;
- 当前任务、电量、定位、图传延迟;
- 使用紧凑设备列表,不放大幅静态产品图。
2. **今日巡检趋势**
- 计划任务、完成任务、累计里程、覆盖率;
- 使用平滑面积图或柱线组合图;
- 当前时刻用细竖线标记,避免默认 ECharts 配色。
### 5.4 右侧研判面板
建议包含:
1. **AI 告警结构**
- 风险等级环图;
- 工务、供电、电务等专业分布;
- 有效告警率和模型置信度区间。
2. **实时告警列表**
- 时间、里程、类型、等级、状态;
- 新告警从上方平滑进入;
- 严重告警提供“定位”“研判”“转工单”快捷动作;
- 列表滚动时支持悬停暂停和键盘访问。
3. **闭环状态**
- 待研判、待派发、处理中、待复核、已闭环;
- 采用横向阶段条,不建议用多个重复仪表盘。
### 5.5 底部信息带
底部建议高度 `170220px`,可在以下内容中选择一个主模式:
- 当前任务的实时视频与红外缩略图;
- 重点区段和最新告警证据;
- “任务—采集—AI—告警—工单—复查”闭环进度;
- 平台事件和设备异常信息流。
不要同时放置过多内容。底部承担“证据”和“进展”,而不是继续堆 KPI。
## 6. PPT 构想与现有功能映射
| PPT 大屏模块 | 当前项目能力 | 建议的大屏表达 |
| --- | --- | --- |
| 无人机管理 | `UavOperationsView.vue`、无人机设备和任务接口 | 左侧设备在线态势 + 地图实时位置 |
| 自动机巢管理 | 设备连接和状态数据 | 机巢在线、充电、占用、故障的紧凑状态面板 |
| 航线管理 | `RoutesView.vue`、航线接口 | 地图航线图层 + 计划/实际轨迹对比 |
| 任务调度 | `TasksView.vue`、全链路运行 | 顶部任务 KPI + 当前任务状态 |
| 视频融合 | 资源预览、证据查看 | 底部可见光/红外/现场证据条 |
| GIS 地图 | `GisView.vue``OperationalGisMap.vue` | 中央 2.5D 铁路态势图 |
| 数据中台 | 统计、资源、模型与运行数据 | 数据新鲜度、处理量和服务状态,不单独画“数据库图标” |
| AI 预警 | 告警、AI 结果、规则判定 | 右侧告警结构 + 实时事件流 |
| 警情联动 | 告警与工单接口 | 告警定位、研判、派单、复查状态 |
| 智慧铁路 | 全链路和闭环能力 | 用闭环完成率和关键结论体现,避免做成空泛模块 |
## 7. 视觉设计系统
### 7.1 颜色令牌
建议在独立的 `command-center.css` 中建立作用域令牌,避免污染现有浅色工作台。
```css
.command-center {
--screen-bg-0: #030711;
--screen-bg-1: #07111f;
--screen-bg-2: #0a1a2d;
--screen-surface: rgba(8, 22, 38, 0.76);
--screen-surface-strong: rgba(9, 28, 48, 0.92);
--screen-line: rgba(102, 176, 214, 0.24);
--screen-line-strong: rgba(111, 201, 239, 0.48);
--screen-primary: #62b9df;
--screen-secondary: #58d2c8;
--screen-text-1: #eef8ff;
--screen-text-2: #9fb5c6;
--screen-text-3: #6f8798;
--screen-success: #53c7a2;
--screen-warning: #e6ad55;
--screen-danger: #e66f6f;
}
```
规则:
- 蓝色只承担选中、连接和实时状态。
- 红色只用于严重告警和不可逆风险。
- 橙色用于待确认和超时。
- 绿色用于在线、正常和已闭环。
- 面板背景至少区分两级,不让所有区域像同一块黑板。
### 7.2 面板与边框
- 面板圆角控制在 `26px`,避免大圆角 SaaS 卡片感。
- 使用 `1px` 低透明描边和轻微内阴影。
- 毛玻璃仅用于浮层和叠加面板,模糊值建议 `812px`
- 角部装饰只用于一级面板,不要给每个小组件都加 DataV 边框。
- 同一屏幕最多保留 2 种边框语言。
- Hover 只增强边框和背景亮度,不做大幅缩放。
### 7.3 字体和数字
- 中文正文优先使用系统可用的黑体类字体,并在部署时统一字体资源和授权。
- 英文与数字可使用 `Inter``DIN` 类窄体或等宽数字字体。
- KPI 数字使用 `font-variant-numeric: tabular-nums`,避免滚动时宽度抖动。
- 标题不使用大面积渐变字;只允许主标题或关键数字使用非常轻的冷色渐变。
- 最小正文建议不低于 `14px`,核心信息不低于 `16px`
### 7.4 图标
- 继续使用 Element Plus Icons,必要时补充同风格线性图标库。
- 禁止使用 Emoji 作为正式业务图标。
- 禁止不同线宽、不同透视风格的图标混用。
- 设备、线路、隧道、桥梁等行业图标应形成统一资产包。
## 8. 高级展示库选型
### 8.1 推荐组合
| 层级 | 库 | 作用 | 建议 |
| --- | --- | --- | --- |
| 基础 UI | Element Plus | 表单、弹窗、抽屉、表格、分页 | 保留;大屏内使用主题覆盖,不直接套默认白色卡片 |
| 图表 | ECharts | 趋势、环图、散点、仪表盘、关系图 | 保留;统一主题、tooltip 和动画节奏 |
| 地图 | MapLibre GL | 矢量地图、相机、线路和空间图层 | 保留并升级暗色底图 |
| 地理 WebGL | deck.gl | 飞线、轨迹、热力、聚合、海量点线和 3D 图层 | 推荐新增,使用 `MapboxOverlay` 与 MapLibre 叠加 |
| 局部三维 | Three.js | 无人机/机巢模型、点云、DEM、数字孪生 | 保留;只在业务需要时加载 |
| DOM 动效 | Motion for Vue (`motion-v`) | 面板进入、状态切换、列表增删、布局动画 | 推荐新增,适合 Vue 组件化管理 |
| 数字动画 | CountUp.js | KPI 数字平滑更新 | 可选新增,体积小、职责单一 |
| 科技装饰 | DataV 类组件 | 边框、装饰线、轮播表 | 谨慎使用,仅用于少量一级面板 |
建议新增的最小依赖:
```bash
npm add @deck.gl/core @deck.gl/layers @deck.gl/aggregation-layers @deck.gl/mapbox
npm add motion-v countup.js
```
### 8.2 为什么不建议强制引入 Tailwind CSS
Tailwind 可以提高样式编写效率,但不会自动产生高级感。当前项目已有 Element Plus 和较多全局 CSS,直接加入 Tailwind 可能带来:
- 样式优先级冲突;
- 大量模板类名降低业务组件可读性;
- 两套设计令牌并存;
- 对已有页面产生不必要的改造成本。
本项目优先采用:
- CSS 自定义属性;
- 大屏独立作用域;
- 可复用 `PanelFrame` 等组件;
- Element Plus 暗色变量覆盖。
如果后续确认全团队采用 Tailwind,应作为一次独立工程决策,而不是为了“科技感”临时加入。
### 8.3 DataV 使用边界
原版 `@jiaminghi/data-view` 的主要使用方式基于 Vue 2。Vue 3 项目不应直接把它作为基础 UI 依赖。
建议:
1. 先检查 Vue 3 分支或第三方分支的维护频率、许可证和类型支持。
2. 只选用边框、装饰线、数字翻牌等低耦合组件。
3. 不使用 DataV 自带图表替换 ECharts。
4. 不让 DataV 边框成为页面信息架构。
5. 如果维护风险较高,内部实现一个受控的 `ScreenPanelFrame` 即可。
### 8.4 动画库只选一套主方案
优先选择 Motion for Vue,原因是:
- Vue 模板内声明式使用;
- 适合列表、面板和布局状态切换;
- 支持 `useReducedMotion`
- 生命周期更容易与 Vue 组件保持一致。
GSAP 适合复杂时间轴、路径动画和高度定制的开场演示。如果没有明确的复杂时间轴需求,不要同时引入 Motion for Vue 和 GSAP。
## 9. 推荐组件结构
```text
frontend/src/
views/
command-center/
CommandCenterView.vue
components/
command-center/
CommandCenterHeader.vue
CommandCenterKpiRail.vue
RailwaySituationMap.vue
UavFleetPanel.vue
MissionTrendPanel.vue
AiWarningPanel.vue
LiveAlarmStream.vue
ClosureStageBar.vue
InspectionMediaStrip.vue
CommandTicker.vue
ScreenPanelFrame.vue
ScreenStatusPill.vue
composables/
useCommandCenterData.ts
useCommandCenterStream.ts
useScreenScale.ts
useReducedScreenMotion.ts
styles/
command-center.css
command-center-echarts-theme.ts
```
组件职责:
- `CommandCenterView` 只负责布局和上下文编排。
- `RailwaySituationMap` 负责 MapLibre、deck.gl 和三维图层生命周期。
- 各面板只接收整理后的展示模型,不直接拼装多个后端接口。
- `useCommandCenterData` 负责首屏聚合数据。
- `useCommandCenterStream` 负责 SSE/WebSocket 增量更新。
- `ScreenPanelFrame` 统一边框、标题、更新时间、加载和错误状态。
## 10. 图表规范
### 10.1 ECharts 统一主题
需要建立独立主题,统一:
- 背景透明;
- 字体颜色和字号;
- 坐标轴细线;
- 网格线透明度;
- tooltip 深色表面;
- series 配色;
- emphasis 和 blur 状态;
- 动画时长与缓动。
建议图表动画:
- 首次进入:`500700ms`
- 数据更新:`300500ms`
- 面积图采用轻透明渐变,不使用大面积高饱和填充;
- 环图只保留 35 个有意义分类;
- 仪表盘最多使用一个;
- 实时曲线只更新最新窗口,不反复重建实例。
### 10.2 图表选择
| 数据 | 推荐图表 | 不推荐 |
| --- | --- | --- |
| 今日任务与里程趋势 | 面积折线图、柱线组合 | 3D 柱状图 |
| 告警等级占比 | 环图 + 直接标签 | 多层嵌套饼图 |
| 专业分布 | 横向条形图 | 颜色过多的玫瑰图 |
| 闭环进度 | 阶段条、漏斗或桑基关系 | 多个相似仪表盘 |
| 设备在线状态 | 状态矩阵、紧凑列表 | 大面积装饰性设备卡片 |
| 空间热点 | 地图热力、聚合点 | 脱离地理上下文的散点图 |
## 11. 地图和三维升级方案
### 11.1 第一阶段:MapLibre 视觉升级
- 接入可私有化部署的暗色矢量底图。
- 统一铁路、航线、轨迹、告警和设备的图层语法。
- 增加 pitch、bearing 和相机预设,形成 2.5D 视角。
- 为桥梁、隧道、边坡、防洪点建立行业图层图例。
- 告警支持影响里程区间和责任范围高亮。
### 11.2 第二阶段:deck.gl 增强
建议使用:
- `PathLayer`:铁路、计划航线和实际轨迹;
- `TripsLayer`:当前无人机飞行轨迹;
- `ScatterplotLayer`:设备和告警点;
- `HeatmapLayer`:历史风险热点;
- `HexagonLayer`:区域风险聚合;
- `ArcLayer`:机巢、无人机、任务或处置中心之间的关系;
- `GeoJsonLayer`:沿线设施和风险区。
优先使用与 MapLibre 共享 WebGL2 上下文的 interleaved 模式,避免两个大画布长期重复渲染。
### 11.3 第三阶段:Three.js 业务三维
- 选中机巢时加载机巢和无人机轻量模型;
- 选中点云或 DEM 资源时打开局部三维工作台;
- 选中边坡告警时显示形变对比;
- 三维模型使用按需加载、LOD、纹理压缩和实例化;
- 不在后台持续运行无业务意义的 3D 地球和粒子星云。
## 12. 动效规范
### 12.1 动效必须表达状态
| 业务状态 | 建议动效 |
| --- | --- |
| 无人机正在飞行 | 轨迹端点缓慢移动,设备点轻微呼吸 |
| 新告警产生 | 告警点脉冲 2–3 次,事件列表平滑插入 |
| 告警待研判 | 稳定橙色描边,不持续闪烁 |
| 严重告警 | 短促红色脉冲 + 明确文字标签 |
| 工单状态更新 | 阶段条平滑推进,旧状态降低亮度 |
| 服务离线 | 状态灯变灰/红,并显示最后心跳时间 |
| KPI 更新 | 数字平滑过渡,变化值短暂显示上升/下降 |
### 12.2 性能规则
- DOM 动画只使用 `transform``opacity`
- 避免对 `width``height``top``left` 做高频动画。
- 单屏同时持续运动的视觉对象不超过 3 类。
- 背景粒子仅作为极弱氛围层,数量和帧率必须可配置。
- 页面不可见时暂停动画和定时器。
- 支持 `prefers-reduced-motion`
- 不使用扫描线覆盖所有正文,避免影响阅读和截图清晰度。
## 13. 大屏适配策略
### 13.1 不建议全站直接使用 autofit.js
全局缩放会带来以下风险:
- Element Plus 的弹窗、下拉框和 tooltip 定位偏移;
- 鼠标坐标与地图拾取坐标不一致;
- 浏览器缩放和设备像素比叠加;
- 非 16:9 设备出现空白或裁切;
- 业务工作台的响应式能力被破坏。
### 13.2 推荐方案
1. 指挥中心内部使用 `1920 × 1080` 设计基准。
2. 使用 `useScreenScale` 计算等比缩放,只包裹指挥中心根节点。
3. 地图、ECharts 和 Three.js 使用 `ResizeObserver` 监听真实容器尺寸。
4. 弹窗和浮层尽量挂载在缩放容器内。
5. 工作台模式继续采用正常响应式布局。
6. 对 4K 屏采用等比放大,对 1366×768 使用整体缩放和最小字号保护。
7. 浏览器设备像素比建议封顶在 `1.52`,避免 WebGL 在 4K 屏过度消耗显存。
## 14. 数据与实时架构
建议新增一个指挥中心聚合接口,避免前端同时请求大量列表后自行统计:
```text
GET /api/v1/command-center/overview
```
返回内容建议:
```json
{
"timestamp": "2026-07-25T15:00:00+08:00",
"freshness_seconds": 2,
"kpis": {},
"fleet": {},
"missions": {},
"alarms": {},
"workorders": {},
"gis_summary": {},
"service_health": {}
}
```
实时更新使用现有 SSE 能力扩展:
```text
GET /api/v1/command-center/events
```
事件类型建议:
- `uav.telemetry.updated`
- `mission.status.changed`
- `alarm.created`
- `alarm.decision.changed`
- `workorder.status.changed`
- `service.health.changed`
前端只更新受影响的局部状态,避免每个事件都重新请求整个页面。
## 15. 性能与工程质量
### 15.1 当前需要关注的问题
当前生产构建已经出现多个大体积 chunk,部分页面包超过 `800KB1MB`。新增大屏能力时必须避免把 deck.gl、Three.js、ECharts 和全部业务页面打入同一个首屏包。
### 15.2 优化要求
- 指挥中心路由必须懒加载。
- ECharts 使用按需导入。
- deck.gl 只导入实际使用的 layer 包。
- Three.js 模型、加载器和点云模块按需加载。
- 将地图、图表、三维和 Element Plus 拆为合理的 vendor chunk。
- 页面销毁时调用 `chart.dispose()``map.remove()` 并清理事件。
- 使用一个统一刷新节拍,避免多个组件各自创建高频定时器。
- 海量点位启用聚合、视口裁剪、LOD 或服务端瓦片。
- 保持主要交互在普通指挥中心终端上稳定达到 `5060 FPS`
## 16. 可用性与无障碍
“科技感”不能以牺牲可读性为代价。
- 正文和背景保持足够对比度。
- 告警不能只靠红、橙、黄、蓝颜色区分,必须带文字或图形标识。
- 所有可点击项具有可见 hover、active 和 focus 状态。
- 告警列表、快捷动作和视角切换支持键盘操作。
- 自动滚动列表支持暂停。
- 图表提供文字摘要或可访问标签。
- 提供降低动效模式。
- 断网、空数据、加载失败和数据过期必须有明确状态。
- 在右上角显示“最后更新时间/数据延迟”,提升指挥场景可信度。
## 17. 分阶段实施计划
### 阶段 0:视觉和数据定版
目标:
- 确定指挥中心使用场景和主要岗位;
- 确定 6 个以内核心 KPI
- 确定中央地图的图层清单;
- 确定左右面板的数据来源;
- 输出 1920×1080 高保真视觉稿。
验收:
- 所有模块都能对应真实接口或明确的后端补充项;
- 画面第一视觉焦点是铁路态势,不是装饰卡片;
- 不使用无法解释的数据和图表。
### 阶段 1:大屏骨架与主题
目标:
- 新增 `/command-center` 路由;
- 新增独立布局和大屏主题令牌;
- 实现顶部、左右、中央和底部布局;
- 实现 1080P、4K 和 1366×768 等比适配;
- 暂不加入复杂动画。
验收:
- 无白底面板;
- 无全局样式污染;
- 内容不重叠、不裁切;
- 浏览器缩放和弹层定位正确。
### 阶段 2GIS 核心态势
目标:
- 升级暗色 MapLibre 地图;
- 接入铁路、航线、轨迹、设备和告警图层;
- 接入 deck.gl 动态轨迹和聚合层;
- 实现对象选中和左右面板联动。
验收:
- 地图占据视觉中心;
- 线路、航线、轨迹和告警一眼可区分;
- 选中对象后上下文一致;
- 大数据量下交互稳定。
### 阶段 3:图表与实时事件
目标:
- 建立统一 ECharts 主题;
- 接入 KPI、任务趋势、告警结构和闭环阶段;
- 接入 SSE 增量事件;
- 增加数字更新和事件列表动画。
验收:
- 图表不是静态占位;
- 实时更新不重建整个页面;
- 新告警、任务状态和工单状态可被清晰感知。
### 阶段 4:三维、动效和细节
目标:
- 按需加入无人机/机巢三维模型;
- 增加面板进入、状态切换和告警脉冲;
- 增加少量角部装饰、网格和光带;
- 完成降低动效模式。
验收:
- 所有动效均能说明业务状态;
- 不出现持续高亮、频闪和动画争抢;
- 低性能设备可以关闭三维和粒子。
### 阶段 5:性能和展示验收
目标:
- 完成 chunk 拆分;
- 验证 1080P、4K、浏览器缩放和多终端;
- 验证长时间运行、内存、WebGL 上下文和 SSE 重连;
- 完成数据异常、空状态和离线状态。
验收:
- 连续运行 8 小时无明显内存增长;
- 主画面稳定、无 WebGL 上下文丢失;
- 关键交互帧率不低于 50 FPS
- 数据延迟和断连状态可见;
- 页面截图、投屏和录屏清晰。
## 18. 改造后的 Codex 实施 Prompt
以下 Prompt 适合当前项目,不要求生成单文件 HTML:
```text
你是资深可视化前端工程师。请在当前 Vue 3 + TypeScript 项目中新增“铁路无人机智能巡检指挥中心”页面,而不是生成单文件 HTML,也不要重写现有业务后台。
【必须复用】
- 现有 Vue Router、Element Plus、ECharts、MapLibre GL、Three.js。
- 现有任务、无人机、航线、GIS、告警、工单、系统健康和 SSE 数据能力。
- 保留现有浅色工作台,仅新增独立深色指挥中心路由 `/command-center`。
【建议新增】
- deck.glMapLibre 地图上的动态轨迹、飞线、热力、聚合和海量点线。
- motion-v:Vue 面板、列表和状态切换动画。
- countup.jsKPI 数字更新动画。
【核心视觉】
- 页面比例以 1920×1080 为设计基准,支持 4K 和 1366×768 等比适配。
- 中央使用暗色 2.5D 铁路 GIS 作为视觉主体,不使用与铁路业务关系弱的旋转 3D 地球。
- 地图显示铁路、桥梁、隧道、防洪点、机巢、无人机、计划航线、实际轨迹、告警点和工单状态。
- Three.js 仅用于无人机/机巢模型、点云、DEM 和局部数字孪生,按需加载。
【布局】
1. 顶部:系统标题、当前线路/班次、4–6 个核心 KPI、实时时钟、天气、数据延迟和系统状态。
2. 左侧:无人机与机巢状态、今日巡检任务和里程趋势。
3. 中央:铁路 GIS 综合态势图。
4. 右侧:AI 告警结构、实时告警流、工单闭环阶段和快捷研判动作。
5. 底部:实时影像/红外证据、重点区段或全链路闭环进度。
【视觉规范】
- 背景使用 #030711 → #07111f → #0a1a2d 的深蓝黑分层。
- 面板使用低透明深色表面、1px 细描边、轻内发光,圆角 2–6px。
- 主强调色为低饱和科技蓝 #62b9df,辅助色为青绿 #58d2c8。
- 红色只用于严重告警,橙色用于待确认和超时,绿色用于正常和闭环。
- 保持高级、克制、清晰,不使用粗大霓虹、过度毛玻璃和大圆角 SaaS 卡片。
【动效规范】
- 只使用 transform/opacity 驱动 DOM 动画。
- 无人机飞行时显示动态轨迹;新告警脉冲 2–3 次后稳定高亮。
- KPI 更新使用数字缓动;事件列表平滑插入。
- 支持 prefers-reduced-motion,页面不可见时暂停动画。
- 同屏持续运动对象不超过 3 类。
【工程要求】
- 指挥中心路由懒加载。
- ECharts、deck.gl、Three.js 按需导入。
- 地图和图表使用 ResizeObserver。
- 组件销毁时清理图表、地图、动画和订阅。
- 不修改现有业务页面的全局视觉。
- 不使用 CDN,不省略 npm 依赖,不生成静态图片代替 WebGL。
- 不使用 Emoji 作为正式图标。
- 先实现布局骨架和主题,再实现 GIS,再实现图表和实时数据,最后实现动效和性能优化。
【强制禁止】
- 禁止把指挥中心做成普通后台卡片网格。
- 禁止让 3D 地球取代铁路 GIS。
- 禁止所有面板都使用高亮 DataV 边框。
- 禁止持续频闪、廉价霓虹和无业务意义的粒子特效。
- 禁止静态假数据伪装成实时数据。
- 禁止一次性把所有可视化依赖打入首页主包。
```
## 19. 最终验收清单
### 视觉
- [ ] 第一视觉焦点为铁路 GIS。
- [ ] 深色背景有层次,不是纯黑平面。
- [ ] 面板边界清晰但不抢内容。
- [ ] 发光亮度克制,告警色使用有节制。
- [ ] 不存在大面积白底、超大圆角和默认 Element Plus 卡片观感。
- [ ] 1080P、4K 和 1366×768 均无裁切。
### 业务
- [ ] 无人机、机巢、航线、任务、告警、工单可在同屏建立关系。
- [ ] 点击地图对象后,左右面板同步切换上下文。
- [ ] 高等级告警能够定位、研判和转工单。
- [ ] 显示数据更新时间和延迟。
- [ ] 空数据、断连、加载失败和服务异常有明确状态。
### 动效
- [ ] 动效均对应业务状态。
- [ ] 新告警不会永久闪烁。
- [ ] 自动滚动内容可暂停。
- [ ] 支持降低动效。
- [ ] 页面不可见时暂停持续渲染。
### 性能
- [ ] 指挥中心路由和大型依赖按需加载。
- [ ] ECharts、MapLibre、deck.gl 和 Three.js 生命周期正确释放。
- [ ] 主要交互保持 5060 FPS。
- [ ] 4K 屏显存和设备像素比受控。
- [ ] 连续运行 8 小时无明显内存泄漏。
## 20. 参考资料
- [deck.gl 与 MapLibre 集成](https://deck.gl/docs/developer-guide/base-maps/using-with-maplibre)
- [deck.gl Mapbox/MapLibre Overlay](https://deck.gl/docs/api-reference/mapbox/overview)
- [MapLibre 自定义 WebGL 图层](https://maplibre.org/maplibre-gl-js/docs/API/interfaces/CustomLayerInterface/)
- [MapLibre 使用 Three.js 加载三维模型](https://maplibre.org/maplibre-gl-js/docs/examples/add-a-3d-model-using-threejs/)
- [Apache ECharts Canvas 与 SVG 选择](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/)
- [Motion for Vue](https://motion.dev/docs/vue)
- [CountUp.js](https://github.com/inorganik/countUp.js)
- [DataV 项目说明](https://github.com/DataV-Team/DataV)
## 21. 最终建议
本项目不应把“高级感”等同于“三维地球 + 大量粒子 + 发光边框”。最适合铁路无人机巡检的高级感,来自:
- 清晰的铁路空间态势;
- 真实的实时任务和设备状态;
- 告警到工单的闭环关系;
- 克制、统一的视觉语言;
- 流畅而有意义的状态动效;
- 长时间稳定运行的工程质量。
优先完成“中央铁路 GIS + 左右业务态势 + 实时事件联动”,再逐步加入三维模型和高级动效,能够以最低风险获得最明显的展示提升。
@@ -128,7 +128,7 @@ GIS 采用统一地图组件,通过不同模式加载任务、航线、资源
4. 告警趋势:按时间、线路、专业和等级统计。
5. 工单闭环趋势:派发量、按时完成率、超时量和退回量。
6. 系统健康摘要:后端、视觉服务、点云服务、数据库、Kafka、Redis 和对象存储状态。
7. 快捷入口:新建巡检任务、启动全链路、进入 GIS、处理告警、处理工单。
7. 快捷入口:新建巡检任务、进入全链路运行、进入 GIS、处理告警、处理工单;总览不直接提供“启动全链路”操作
#### 页面交互
+256
View File
@@ -0,0 +1,256 @@
# 团队 Git 远端仓库搭建方案
本文档用于在香港地区 Debian 服务器上部署团队共享 Git 服务,并为后续绑定域名和 HTTPS 做准备。
推荐技术栈:
- Gitea:Git 仓库、用户、组织、权限、Issue 和合并请求
- PostgreSQLGitea 数据库
- Caddy:反向代理和自动 HTTPS
- Docker Compose:部署和升级
## 一、推荐架构
```text
开发者浏览器 / Git 客户端
├── https://git.example.com
│ │
│ CaddyHTTPS、反向代理
│ │
│ Gitea:Web 管理、仓库服务
│ │
│ PostgreSQL:用户、权限、仓库元数据
└── SSH git@git.example.com:team/project.git
```
建议服务器至少具备:
- 2 核 CPU、4 GB 内存、40 GB SSD
- 独立公网 IPv4
- 服务器安全组开放 TCP 22、80、443、2222
## 二、检查系统并安装 Docker
先确认系统版本和架构:
```bash
cat /etc/os-release
uname -m
```
服务器应使用 Debian 11/12/13 的 amd64 架构。以下以 Debian 12Bookworm)为例;如果系统版本不同,请替换仓库中的代号。
```bash
apt update
apt install -y ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg \
-o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
cat >/etc/apt/sources.list.d/docker.sources <<'EOF'
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: bookworm
Components: stable
Architectures: amd64
Signed-By: /etc/apt/keyrings/docker.asc
EOF
apt update
apt install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
docker run hello-world
```
## 三、创建部署目录
```bash
mkdir -p /opt/gitea/{gitea,postgres,caddy}
cd /opt/gitea
```
创建 `/opt/gitea/docker-compose.yml`
```yaml
services:
db:
image: postgres:16
container_name: gitea-db
restart: unless-stopped
environment:
POSTGRES_USER: gitea
POSTGRES_PASSWORD: 请替换为高强度数据库密码
POSTGRES_DB: gitea
volumes:
- ./postgres:/var/lib/postgresql/data
networks:
- gitea
gitea:
image: docker.gitea.com/gitea:latest
container_name: gitea
restart: unless-stopped
depends_on:
- db
environment:
USER_UID: 1000
USER_GID: 1000
GITEA__database__DB_TYPE: postgres
GITEA__database__HOST: db:5432
GITEA__database__NAME: gitea
GITEA__database__USER: gitea
GITEA__database__PASSWD: 请替换为高强度数据库密码
GITEA__server__DOMAIN: git.example.com
GITEA__server__ROOT_URL: https://git.example.com/
GITEA__server__SSH_DOMAIN: git.example.com
GITEA__server__SSH_PORT: 2222
ports:
- "3000:3000"
- "2222:22"
volumes:
- ./gitea:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
networks:
- gitea
caddy:
image: caddy:2
container_name: gitea-caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy/data:/data
- ./caddy/config:/config
networks:
- gitea
networks:
gitea:
```
正式部署时应将数据库密码替换为随机高强度密码,并建议固定经过验证的 Gitea 镜像版本,而不是长期使用 `latest`
## 四、配置域名和 HTTPS
创建 `/opt/gitea/caddy/Caddyfile`
```caddyfile
git.example.com {
reverse_proxy gitea:3000
}
```
`git.example.com` 替换为实际域名,并在 DNS 服务商增加:
```text
类型:A
主机记录:git
记录值:服务器公网 IPv4
```
确保域名已经解析到服务器,并开放 TCP 80 和 443。Caddy 会自动申请、续期 HTTPS 证书,并将 HTTP 跳转到 HTTPS。
启动服务:
```bash
cd /opt/gitea
docker compose up -d
docker compose ps
```
## 五、初始化 Gitea
访问:
```text
https://git.example.com
```
安装页面填写:
```text
数据库类型:PostgreSQL
数据库主机:db:5432
数据库名称:gitea
数据库用户:gitea
数据库密码:docker-compose.yml 中设置的密码
站点 URLhttps://git.example.com/
SSH 服务器域名:git.example.com
SSH 端口:2222
```
创建第一个管理员账号后,建议建立团队组织,例如 `AItrackwalker`,再按成员职责授予权限:
- Owner:项目负责人
- Admin:维护者
- Write:开发者
- Read:只读成员
成员可以使用以下地址克隆仓库:
```bash
git clone ssh://git@git.example.com:2222/AItrackwalker/project.git
```
成员使用自己的 SSH 密钥,不要共享 Git 账号:
```bash
ssh-keygen -t ed25519 -C "成员邮箱"
cat ~/.ssh/id_ed25519.pub
```
将公钥添加到 Gitea 的“个人设置 → SSH/GPG 密钥”。
## 六、备份
Git 仓库、用户、权限、Issue 等数据分布在 Gitea 数据目录和 PostgreSQL 数据库中,两者都必须备份。
```bash
mkdir -p /opt/backup/gitea
docker exec gitea-db pg_dump -U gitea gitea \
> /opt/backup/gitea/gitea-$(date +%F).sql
tar -czf /opt/backup/gitea/data-$(date +%F).tar.gz \
/opt/gitea/gitea
```
建议每天备份,保留 7~30 天,并将至少一份备份复制到另一台服务器或对象存储。定期进行恢复测试,不要只保留服务器本地备份。
## 七、安全建议
- 管理 SSH 使用密钥登录,禁止 root 密码登录
- Gitea 关闭公开注册,采用邀请制
- 管理员账号启用 2FA
- 限制普通成员创建组织和管理员账号
- 不要暴露 PostgreSQL 的 5432 端口
- Docker 镜像升级前先做完整备份
- 使用 `.gitignore` 防止提交 `.env`、密码和密钥
- 对敏感代码考虑使用 VPN 或 IP 白名单
- 开启 Debian 自动安全更新:
```bash
apt install -y unattended-upgrades
dpkg-reconfigure unattended-upgrades
```
## 八、后续扩展
初期使用 Gitea 即可。后续如需 CI/CD,可添加 Gitea Actions Runner;如需更复杂的代码扫描、容器镜像仓库和企业级审计,再评估 GitLab。
参考文档:
- [Docker Engine on Debian](https://docs.docker.com/engine/install/debian/)
- [Gitea Installation](https://docs.gitea.com/category/installation)
- [Gitea HTTPS Setup](https://docs.gitea.com/usage/https-setup)
- [Caddy Automatic HTTPS](https://caddyserver.com/docs/automatic-https)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -87,8 +87,8 @@ flowchart LR
| P0-01 | 巡检类型、周期计划和对象台账 | 已实现 | 对象台账、计划类型、周期规则、启用、按对象生成任务 | 无 |
| P0-02 | 智能航线规划与航点编辑 | 部分实现 | 航点编辑、三维坐标、载荷动作、飞行参数、校验、版本和发布 | 桥梁环绕、隧道内飞、防洪走廊等自动生成算法需要真实构筑物尺寸、净空、障碍物、地形及设备能力参数 |
| P0-03 | 载荷与飞行参数配置 | 已实现 | 航线级速度、高度、返航策略、云台角度和载荷动作 | 厂商设备的最终参数上限需在真实适配器中二次校验 |
| P0-04 | 真实无人机厂商任务下发 | 外部受阻 | 统一适配器、连接模型、任务状态机、命令幂等、模拟器均已实现 | 缺少大疆和纵横正式授权、接口包、测试租户、设备、固件版本和联调网络 |
| P0-05 | 实时遥测与实际航迹 | 部分实现 | 遥测点、设备位置、飞行进度、实际航迹和 GIS 图层已实现 | 真实遥测主题、字段、频率、时钟和质量规则需厂商协议及设备联调 |
| P0-04 | 真实无人机厂商任务下发 | 部分实现 | 已新增司空 2 私有化 OpenAPI 接入服务、拓扑同步、WPML/KMZ 航线导入、飞前检查、高级任务、控制命令、异步状态和对账;飞行控制默认关闭 | 需在私有化实例确认 Token、项目、设备枚举、任务参数并完成真机安全验收;纵横适配仍待正式材料 |
| P0-05 | 实时遥测与实际航迹 | 部分实现 | 已新增 EventAPI、MQTT Bridge、事件去重、Kafka Outbox/Inbox、设备/任务/遥测/实际航迹投影 | 真实 MQTT 主题、EventAPI 鉴权、字段、频率、时钟和质量规则需部署及设备联调后冻结 |
| P0-06 | 巡检文件上传与弱网续传 | 部分实现 | 服务端分片会话、断点数据结构、分片校验、合并、哈希和对象存储已实现 | 机巢或现场边缘代理、弱网重试策略和真实大视频压力参数依赖目标硬件与网络测试 |
| P0-07 | 生产模型制品就绪 | 外部受阻 | 模型配置、安装卸载、运行时路由、健康检查和本地 CPU 调试链路已实现 | 缺少四类已训练验收的 ONNX 制品、类别映射、预后处理清单、校验和及使用许可;Open3D 几何分析已可运行 |
| P0-08 | 核心缺陷类别模型与验收集 | 外部受阻 | 样本管理、预览、上传测试、测试记录和模型选择已实现 | 缺少铁路专业标注样本、独立验收集、缺陷等级口径和误漏报验收阈值 |
@@ -0,0 +1,305 @@
<template>
<article class="alarm-evidence">
<header class="evidence-hero">
<div>
<div class="evidence-tags">
<el-tag :type="severityTag(alarm.severity)" effect="dark">{{ severityLabel(alarm.severity) }}</el-tag>
<el-tag :type="statusTag(alarm.status)" effect="plain">{{ statusLabel(alarm.status) }}</el-tag>
<el-tag v-if="alarm.suppressed" type="info" effect="plain">已抑制</el-tag>
</div>
<h2>{{ alarm.scene || "未命名告警" }}</h2>
<p>{{ alarm.category || "未分类" }} · {{ alarm.alarm_id || "-" }}</p>
</div>
<div class="confidence-score">
<span>模型置信度</span>
<strong>{{ confidencePercent }}%</strong>
<el-progress :percentage="confidencePercent" :show-text="false" :stroke-width="7" :color="confidenceColor" />
</div>
</header>
<div class="evidence-summary-grid">
<div>
<span>识别模型</span>
<strong>{{ aiResult.model_name || "-" }}</strong>
<small>{{ aiResult.model_version || "版本未知" }}</small>
</div>
<div>
<span>原始资源</span>
<strong class="compact-id">{{ aiResult.resource_id || evidenceInfo.resource_id || "-" }}</strong>
<small>{{ resourceTypeLabel(aiResult.resource_type) }}</small>
</div>
<div>
<span>线路位置</span>
<strong>{{ location.mileage || "里程待补充" }}</strong>
<small>{{ location.line_id || "线路待补充" }}</small>
</div>
<div>
<span>距线路</span>
<strong>{{ distanceText }}</strong>
<small>{{ geometryTypeLabel(geometry.type) }}</small>
</div>
</div>
<section class="evidence-section">
<div class="section-heading">
<div><strong>证据形成过程</strong><span>从原始资源到告警生成的可追溯链路</span></div>
</div>
<el-steps direction="vertical" :active="4" finish-status="success" class="evidence-steps">
<el-step title="原始资源" :description="String(aiResult.resource_id || evidenceInfo.resource_id || '-')" />
<el-step title="模型识别" :description="`${aiResult.model_name || '-'} / ${aiResult.model_version || '-'}`" />
<el-step title="规则判定" :description="ruleSummary" />
<el-step title="告警生成" :description="`${statusLabel(alarm.status)} / ${locationSummary}`" />
</el-steps>
</section>
<section class="evidence-section">
<div class="section-heading">
<div><strong>规则判定依据</strong><span>触发告警等级与处置流程的业务条件</span></div>
</div>
<div v-if="ruleHits.length" class="rule-hit-list">
<span v-for="(rule, index) in ruleHits" :key="`${rule}-${index}`"><i>{{ index + 1 }}</i>{{ rule }}</span>
</div>
<el-empty v-else description="暂无规则命中信息" :image-size="46" />
<div v-if="alarm.suppression_reason" class="suppression-note">
<strong>抑制原因</strong><span>{{ alarm.suppression_reason }}</span>
</div>
</section>
<section class="evidence-section">
<div class="section-heading">
<div><strong>空间定位</strong><span>业务坐标与检测区域摘要</span></div>
</div>
<div class="location-panel">
<div class="location-mark" aria-hidden="true"><i></i><span></span></div>
<dl>
<div><dt>线路</dt><dd>{{ location.line_id || "-" }}</dd></div>
<div><dt>里程</dt><dd>{{ location.mileage || "-" }}</dd></div>
<div><dt>距线路</dt><dd>{{ distanceText }}</dd></div>
<div><dt>几何类型</dt><dd>{{ geometryTypeLabel(geometry.type) }}</dd></div>
<div class="coordinate-row"><dt>坐标/区域</dt><dd>{{ coordinateSummary }}</dd></div>
</dl>
</div>
</section>
<section v-if="measurementItems.length" class="evidence-section">
<div class="section-heading">
<div><strong>关键量测</strong><span>模型输出与规则计算使用的结构化指标</span></div>
</div>
<div class="measurement-grid">
<div v-for="item in measurementItems" :key="item.key">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
<small v-if="item.note">{{ item.note }}</small>
</div>
</div>
</section>
<section v-if="attributeItems.length" class="evidence-section">
<div class="section-heading">
<div><strong>模型运行信息</strong><span>用于结果复现和运行环境追溯</span></div>
</div>
<dl class="runtime-list">
<div v-for="item in attributeItems" :key="item.key"><dt>{{ item.label }}</dt><dd>{{ item.value }}</dd></div>
</dl>
</section>
<slot name="actions" />
<el-collapse class="technical-data">
<el-collapse-item title="原始技术数据(仅用于排障与审计)" name="raw">
<pre class="detail-json">{{ rawTechnicalData }}</pre>
</el-collapse-item>
</el-collapse>
</article>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { safeObject, severityLabel, statusLabel, statusTag, type Row } from "../../types/demo-run";
const props = defineProps<{ evidence: Row }>();
const alarm = computed(() => safeObject(props.evidence.alarm));
const aiResult = computed(() => safeObject(props.evidence.ai_result));
const evidenceInfo = computed(() => safeObject(alarm.value.evidence));
const location = computed(() => safeObject(alarm.value.location));
const geometry = computed(() => {
const locationGeometry = safeObject(location.value.geometry);
return Object.keys(locationGeometry).length ? locationGeometry : safeObject(aiResult.value.geometry);
});
const measurements = computed(() => safeObject(aiResult.value.measurements));
const attributes = computed(() => safeObject(aiResult.value.attributes));
const ruleHits = computed(() => Array.isArray(alarm.value.rule_hits) ? alarm.value.rule_hits.map(String) : []);
const confidencePercent = computed(() => Math.max(0, Math.min(100, Math.round(Number(alarm.value.confidence || 0) * 100))));
const confidenceColor = computed(() => confidencePercent.value >= 85 ? "#15803d" : confidencePercent.value >= 70 ? "#d97706" : "#dc2626");
const distanceText = computed(() => {
const value = location.value.distance_to_track_m ?? measurements.value.distance_to_track_m;
return value === "" || value === null || value === undefined ? "待测量" : `${formatNumber(value)} m`;
});
const locationSummary = computed(() => [location.value.line_id, location.value.mileage, distanceText.value].filter(Boolean).join(" / ") || "位置待补充");
const ruleSummary = computed(() => ruleHits.value.join(" / ") || "未记录规则命中");
const coordinateSummary = computed(() => formatGeometry(geometry.value));
const measurementItems = computed(() => Object.entries(measurements.value).map(([key, value]) => ({
key,
label: fieldMeta[key]?.label || humanizeKey(key),
value: formatMeasurement(key, value),
note: fieldMeta[key]?.note || ""
})));
const attributeItems = computed(() => Object.entries(attributes.value).map(([key, value]) => ({
key,
label: attributeLabels[key] || humanizeKey(key),
value: formatValue(value)
})));
const rawTechnicalData = computed(() => JSON.stringify({
location: location.value,
geometry: geometry.value,
measurements: measurements.value,
attributes: attributes.value,
evidence: evidenceInfo.value
}, null, 2));
const fieldMeta: Record<string, { label: string; unit?: string; note?: string; percent?: boolean }> = {
distance_to_track_m: { label: "距线路", unit: "m", note: "空间规则输入" },
distance_to_contact_network_m: { label: "距接触网", unit: "m" },
temperature_c: { label: "目标温度", unit: "℃", note: "热成像测温" },
relative_rise_c: { label: "相对温升", unit: "℃" },
volume_change_m3: { label: "体积变化", unit: "m³" },
change_area_m2: { label: "变化面积", unit: "m²" },
width_cm: { label: "目标宽度", unit: "cm" },
length_cm: { label: "目标长度", unit: "cm" },
tree_height_m: { label: "树木高度", unit: "m" },
max_height_diff_m: { label: "最大高差", unit: "m" },
registration_error_m: { label: "配准误差", unit: "m" },
registration_precision_m: { label: "配准精度", unit: "m" },
max_deformation_m: { label: "最大形变", unit: "m" },
area_ratio: { label: "区域占比", percent: true },
change_score: { label: "变化得分", percent: true },
in_protected_area: { label: "位于防护区", note: "空间规则输入" },
maintenance_window: { label: "处于天窗期", note: "作业时间规则" },
quality_passed: { label: "质量校验" },
point_count: { label: "有效点数" },
mileage: { label: "检测里程" }
};
const attributeLabels: Record<string, string> = {
execution_mode: "推理模式",
adapter: "推理适配器",
deployment_id: "运行部署",
analysis: "分析类型",
parser: "解析器",
fallback_reason: "降级原因",
configured_model: "配置模型",
test_mode: "测试模式",
coordinate_space: "坐标空间",
model_group: "模型组",
pixel_area: "像素面积"
};
function severityTag(value: unknown): "danger" | "warning" | "info" | "success" {
return ({ critical: "danger", high: "warning", medium: "info", low: "success" } as Record<string, "danger" | "warning" | "info" | "success">)[String(value)] || "info";
}
function resourceTypeLabel(value: unknown) {
return ({ image: "可见光图像", video: "巡检视频", thermal: "红外热成像", tif: "栅格影像", pointcloud: "点云数据" } as Record<string, string>)[String(value)] || String(value || "资源类型未知");
}
function geometryTypeLabel(value: unknown) {
return ({ Point: "地理坐标点", BBox: "目标检测框", Polygon: "目标区域", LineString: "线路范围" } as Record<string, string>)[String(value)] || String(value || "无几何信息");
}
function formatGeometry(value: Row) {
const type = String(value.type || "");
const coordinates = value.coordinates;
if (!Array.isArray(coordinates) || coordinates.length === 0) return "暂无坐标";
if (type === "Point") return coordinates.slice(0, 2).map((item) => formatNumber(item, 6)).join(", ");
if (type === "BBox" && coordinates.length >= 4) {
return `${coordinates.slice(0, 2).map((item) => formatNumber(item, 3)).join(", ")}${coordinates.slice(2, 4).map((item) => formatNumber(item, 3)).join(", ")}`;
}
if (type === "Polygon") {
const points = Array.isArray(coordinates[0]) ? coordinates[0] : coordinates;
return `${points.length} 个边界点${value.coordinate_space ? ` · ${value.coordinate_space}` : ""}`;
}
return `${coordinates.length} 组坐标`;
}
function formatMeasurement(key: string, value: unknown) {
const meta = fieldMeta[key];
if (meta?.percent) return `${formatNumber(Number(value) * 100)}%`;
const formatted = formatValue(value);
return meta?.unit && typeof value === "number" ? `${formatted} ${meta.unit}` : formatted;
}
function formatValue(value: unknown): string {
if (typeof value === "boolean") return value ? "是" : "否";
if (typeof value === "number") return formatNumber(value);
if (Array.isArray(value)) return value.map(formatValue).join("、") || "-";
if (value && typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>);
return entries.length ? entries.map(([key, item]) => `${humanizeKey(key)}${formatValue(item)}`).join("") : "-";
}
return String(value ?? "-");
}
function formatNumber(value: unknown, digits = 2) {
const number = Number(value);
if (!Number.isFinite(number)) return String(value ?? "-");
return number.toLocaleString("zh-CN", { maximumFractionDigits: digits });
}
function humanizeKey(key: string) {
return key.replaceAll("_", " ");
}
</script>
<style scoped>
.alarm-evidence { color: #1e293b; }
.evidence-hero { padding: 2px 0 18px; display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; border-bottom: 1px solid #e2e8f0; }
.evidence-tags { display: flex; gap: 7px; flex-wrap: wrap; }
.evidence-hero h2 { margin: 11px 0 5px; color: #17365d; font-size: 22px; }
.evidence-hero p { margin: 0; color: #64748b; font-size: 12px; }
.confidence-score { width: 150px; flex: 0 0 auto; }
.confidence-score span { display: block; color: #64748b; font-size: 11px; }
.confidence-score strong { display: block; margin: 4px 0 8px; color: #17365d; font-size: 26px; line-height: 1; }
.evidence-summary-grid { margin: 16px 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
.evidence-summary-grid > div { min-width: 0; padding: 12px; border: 1px solid #dce4ed; border-radius: 6px; background: #f8fafc; }
.evidence-summary-grid span, .evidence-summary-grid small { display: block; color: #64748b; font-size: 11px; }
.evidence-summary-grid strong { display: block; margin: 6px 0 3px; color: #17365d; font-size: 15px; }
.compact-id { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: Consolas, monospace; font-size: 12px !important; }
.evidence-section { margin-top: 16px; padding: 15px; border: 1px solid #dce4ed; border-radius: 7px; background: #fff; }
.section-heading { margin-bottom: 13px; display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; }
.section-heading strong, .section-heading span { display: block; }
.section-heading strong { color: #17365d; font-size: 15px; }
.section-heading span { margin-top: 4px; color: #94a3b8; font-size: 11px; }
.evidence-steps { padding: 2px 4px 0; }
.rule-hit-list { display: grid; gap: 8px; }
.rule-hit-list span { min-height: 38px; padding: 8px 10px; display: flex; align-items: center; gap: 9px; border-radius: 5px; background: #fff7ed; color: #9a3412; font-size: 13px; font-weight: 700; }
.rule-hit-list i { width: 21px; height: 21px; display: inline-grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: #fed7aa; color: #9a3412; font-size: 11px; font-style: normal; }
.suppression-note { margin-top: 10px; padding: 10px; display: grid; gap: 4px; border-radius: 5px; background: #f1f5f9; }
.suppression-note strong { color: #475569; font-size: 12px; }
.suppression-note span { color: #64748b; font-size: 12px; }
.location-panel { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 14px; align-items: stretch; }
.location-mark { min-height: 122px; position: relative; display: grid; place-items: center; overflow: hidden; border-radius: 6px; background: linear-gradient(135deg, #e0f2fe, #eff6ff); }
.location-mark::before, .location-mark::after { content: ""; position: absolute; width: 150%; height: 1px; background: rgba(30, 64, 175, .13); transform: rotate(-24deg); }
.location-mark::after { transform: rotate(32deg); }
.location-mark i { width: 28px; height: 28px; position: relative; z-index: 1; border: 7px solid #dc2626; border-radius: 50% 50% 50% 0; background: #fff; transform: rotate(-45deg); box-shadow: 0 4px 12px rgba(220, 38, 38, .22); }
.location-mark span { width: 38px; height: 8px; position: absolute; bottom: 28px; border-radius: 50%; background: rgba(15, 23, 42, .14); filter: blur(1px); }
.location-panel dl, .runtime-list { margin: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 12px; }
.location-panel dl > div, .runtime-list > div { min-width: 0; }
.location-panel dt, .runtime-list dt { color: #94a3b8; font-size: 10px; }
.location-panel dd, .runtime-list dd { margin: 4px 0 0; color: #334155; font-size: 12px; font-weight: 700; word-break: break-word; }
.coordinate-row { grid-column: 1 / -1; }
.measurement-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.measurement-grid > div { min-width: 0; padding: 10px; border-radius: 5px; background: #f8fafc; }
.measurement-grid span, .measurement-grid small { display: block; color: #64748b; font-size: 10px; }
.measurement-grid strong { display: block; margin: 5px 0 3px; color: #0f766e; font-size: 17px; word-break: break-word; }
.technical-data { margin-top: 16px; }
.technical-data :deep(.el-collapse-item__header) { color: #64748b; font-size: 12px; }
.technical-data .detail-json { margin: 0; max-height: 300px; }
@media (max-width: 640px) {
.evidence-hero { display: grid; }
.confidence-score { width: 100%; }
.evidence-summary-grid, .measurement-grid { grid-template-columns: 1fr; }
.location-panel { grid-template-columns: 1fr; }
.location-mark { min-height: 90px; }
}
</style>
@@ -0,0 +1,350 @@
<template>
<el-form class="inspection-plan-form" label-position="top" @submit.prevent>
<section class="form-section">
<header><strong>选择巡检方式</strong><span>周期巡检按固定规则重复执行手动巡检用于一次性或临时任务</span></header>
<div class="trigger-selector">
<button type="button" :class="{ active: form.trigger_type === 'PERIODIC' }" @click="selectTrigger('PERIODIC')">
<i>周期</i><span><strong>周期巡检</strong><small>每天每周或每月定时执行</small></span><em>{{ form.trigger_type === "PERIODIC" ? "已选择" : "选择" }}</em>
</button>
<button type="button" :class="{ active: form.trigger_type === 'MANUAL' }" @click="selectTrigger('MANUAL')">
<i>手动</i><span><strong>手动巡检</strong><small>立即创建或预约一次巡检任务</small></span><em>{{ form.trigger_type === "MANUAL" ? "已选择" : "选择" }}</em>
</button>
</div>
</section>
<el-alert
:title="form.trigger_type === 'PERIODIC' ? '创建后先保存为草稿,确认无误后在计划列表启用。' : '创建手动巡检后会同步生成一次性巡检任务。'"
type="info"
:closable="false"
show-icon
/>
<section class="form-section">
<header><strong>基本信息</strong><span>用于识别计划用途并确定处置优先级</span></header>
<div class="form-grid">
<el-form-item label="计划名称" required>
<el-input v-model="form.name" maxlength="80" show-word-limit :placeholder="namePlaceholder" />
</el-form-item>
<el-form-item label="业务类型" required>
<el-select v-model="form.plan_type">
<el-option label="日常巡检" value="DAILY"><span>日常巡检</span><small class="option-note">常规线路与设施检查</small></el-option>
<el-option label="专项巡检" value="SPECIAL"><span>专项巡检</span><small class="option-note">特定隐患或专业检查</small></el-option>
<el-option label="应急巡检" value="EMERGENCY"><span>应急巡检</span><small class="option-note">突发事件快速响应</small></el-option>
</el-select>
</el-form-item>
</div>
<el-form-item label="任务优先级" required>
<el-radio-group v-model="form.priority">
<el-radio-button value="normal">普通</el-radio-button>
<el-radio-button value="high">优先</el-radio-button>
<el-radio-button value="urgent">紧急</el-radio-button>
</el-radio-group>
</el-form-item>
</section>
<section v-if="form.trigger_type === 'PERIODIC'" class="form-section schedule-section">
<header><strong>周期安排</strong><span>无需填写 Cron 表达式系统会根据选项自动生成规则</span></header>
<el-form-item label="重复频率" required>
<el-radio-group v-model="form.frequency" @change="normalizeFrequency">
<el-radio-button value="DAILY">每天</el-radio-button>
<el-radio-button value="WEEKLY">每周</el-radio-button>
<el-radio-button value="MONTHLY">每月</el-radio-button>
</el-radio-group>
</el-form-item>
<div v-if="form.frequency === 'DAILY'" class="schedule-choice">
<span></span><el-input-number v-model="form.interval_days" :min="1" :max="30" controls-position="right" /><span>天执行一次</span>
</div>
<el-form-item v-else-if="form.frequency === 'WEEKLY'" label="执行星期" required>
<el-checkbox-group v-model="form.weekdays" class="weekday-selector">
<el-checkbox-button v-for="item in weekdayOptions" :key="item.value" :value="item.value">{{ item.label }}</el-checkbox-button>
</el-checkbox-group>
</el-form-item>
<el-form-item v-else label="每月执行日期" required>
<el-select v-model="form.month_days" multiple collapse-tags :max-collapse-tags="5" placeholder="选择每月日期">
<el-option v-for="day in monthDayOptions" :key="day" :label="`${day} 日`" :value="day" />
</el-select>
<small class="field-help">仅提供 128 避免短月份没有对应日期</small>
</el-form-item>
<div class="form-grid">
<el-form-item label="开始日期" required>
<el-date-picker v-model="form.start_date" type="date" :disabled-date="disablePastDate" placeholder="选择生效日期" />
</el-form-item>
<el-form-item label="执行时间" required>
<el-time-select v-model="form.execution_time" start="00:00" step="00:30" end="23:30" placeholder="选择执行时间" />
</el-form-item>
</div>
</section>
<section v-else class="form-section schedule-section">
<header><strong>执行安排</strong><span>手动巡检只生成一次任务不会按周期重复</span></header>
<el-form-item label="任务时间" required>
<div class="manual-timing-selector">
<button type="button" :class="{ active: form.manual_timing === 'NOW' }" @click="form.manual_timing = 'NOW'">
<strong>立即创建</strong><span>任务创建后进入待执行队列</span>
</button>
<button type="button" :class="{ active: form.manual_timing === 'SCHEDULED' }" @click="form.manual_timing = 'SCHEDULED'">
<strong>预约执行</strong><span>指定未来的计划开始时间</span>
</button>
</div>
</el-form-item>
<el-form-item v-if="form.manual_timing === 'SCHEDULED'" label="计划执行时间" required>
<el-date-picker v-model="form.manual_start_at" type="datetime" :disabled-date="disablePastDate" placeholder="选择未来时间" />
</el-form-item>
</section>
<section class="form-section">
<header><strong>巡检范围</strong><span>选择本次需要覆盖的业务对象和识别场景</span></header>
<el-form-item label="巡检对象" required>
<el-select v-model="form.object_ids" multiple filterable collapse-tags :max-collapse-tags="3" placeholder="搜索并选择线路、桥隧或设施">
<el-option v-for="item in objects" :key="item.object_id" :label="objectOptionLabel(item)" :value="item.object_id">
<div class="object-option"><span><strong>{{ item.name }}</strong><small>{{ objectTypeLabel(item.object_type) }} · {{ item.line_id }}</small></span><em>{{ mileageText(item) }}</em></div>
</el-option>
</el-select>
<small class="field-help">每个巡检对象会生成一条独立任务便于调度和追踪</small>
</el-form-item>
<el-form-item label="识别场景" required>
<el-checkbox-group v-model="form.scene_set" class="scene-selector">
<el-checkbox v-for="scene in scenes" :key="scene" :value="scene" border>{{ scene }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
</section>
<section class="plan-summary">
<div><span>巡检方式</span><strong>{{ triggerLabel }}</strong></div>
<div><span>执行安排</span><strong>{{ scheduleSummary }}</strong></div>
<div><span>任务规模</span><strong>{{ form.object_ids.length }} 个对象 · {{ form.scene_set.length }} 个场景</strong></div>
</section>
<footer class="form-actions">
<el-button @click="emit('cancel')">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">{{ submitLabel }}</el-button>
</footer>
</el-form>
</template>
<script setup lang="ts">
import { computed, reactive, watch } from "vue";
import { ElMessage } from "element-plus";
import type { Row } from "../../types/demo-run";
const props = defineProps<{
objects: Row[];
scenes: string[];
saving: boolean;
initialTrigger?: "PERIODIC" | "MANUAL";
}>();
const emit = defineEmits<{
cancel: [];
submit: [request: { payload: Record<string, unknown>; generateTasks: boolean }];
}>();
const form = reactive({
name: "",
trigger_type: (props.initialTrigger || "PERIODIC") as "PERIODIC" | "MANUAL",
plan_type: props.initialTrigger === "MANUAL" ? "SPECIAL" : "DAILY",
priority: "normal",
frequency: "WEEKLY" as "DAILY" | "WEEKLY" | "MONTHLY",
interval_days: 1,
weekdays: ["MON"] as string[],
month_days: [1] as number[],
start_date: tomorrow(),
execution_time: "09:00",
manual_timing: "NOW" as "NOW" | "SCHEDULED",
manual_start_at: tomorrowAt(9),
object_ids: [] as string[],
scene_set: [] as string[]
});
const weekdayOptions = [
{ value: "MON", label: "周一" }, { value: "TUE", label: "周二" }, { value: "WED", label: "周三" },
{ value: "THU", label: "周四" }, { value: "FRI", label: "周五" }, { value: "SAT", label: "周六" },
{ value: "SUN", label: "周日" }
];
const monthDayOptions = Array.from({ length: 28 }, (_, index) => index + 1);
const triggerLabel = computed(() => form.trigger_type === "PERIODIC" ? "周期巡检" : "手动巡检");
const submitLabel = computed(() => form.trigger_type === "PERIODIC" ? "创建周期计划" : "创建手动任务");
const namePlaceholder = computed(() => form.trigger_type === "PERIODIC" ? "例如:京广线接触网每周巡检" : "例如:K123 区段雨后专项巡检");
const scheduleSummary = computed(() => {
if (form.trigger_type === "MANUAL") {
return form.manual_timing === "NOW" ? "立即创建,人工下发" : formatDateTime(form.manual_start_at);
}
if (form.frequency === "DAILY") return `${form.interval_days}${form.execution_time}`;
if (form.frequency === "WEEKLY") {
const labels = form.weekdays.map((day) => weekdayOptions.find((item) => item.value === day)?.label || day);
return `每周 ${labels.join("、")} ${form.execution_time}`;
}
return `每月 ${form.month_days.slice().sort((a, b) => a - b).join("、")}${form.execution_time}`;
});
watch(() => form.trigger_type, (value) => {
form.plan_type = value === "PERIODIC" ? "DAILY" : "SPECIAL";
});
function selectTrigger(value: "PERIODIC" | "MANUAL") {
form.trigger_type = value;
}
function normalizeFrequency() {
if (form.frequency === "WEEKLY" && !form.weekdays.length) form.weekdays = ["MON"];
if (form.frequency === "MONTHLY" && !form.month_days.length) form.month_days = [1];
}
function submit() {
if (!form.name.trim()) return ElMessage.warning("请填写便于识别的计划名称");
if (!form.object_ids.length) return ElMessage.warning("请至少选择一个巡检对象");
if (!form.scene_set.length) return ElMessage.warning("请至少选择一个识别场景");
let nextRunAt: string | null = null;
let scheduleRule: string | null = null;
let scheduleConfig: Record<string, unknown>;
if (form.trigger_type === "PERIODIC") {
if (!form.start_date || !form.execution_time) return ElMessage.warning("请选择周期计划的开始日期和执行时间");
if (form.frequency === "WEEKLY" && !form.weekdays.length) return ElMessage.warning("请至少选择一个执行星期");
if (form.frequency === "MONTHLY" && !form.month_days.length) return ElMessage.warning("请至少选择一个每月执行日期");
const firstRun = combineDateAndTime(form.start_date, form.execution_time);
if (firstRun.getTime() <= Date.now()) return ElMessage.warning("首次执行时间必须晚于当前时间");
nextRunAt = firstRun.toISOString();
scheduleRule = buildCronRule();
scheduleConfig = {
frequency: form.frequency,
interval_days: form.frequency === "DAILY" ? form.interval_days : undefined,
weekdays: form.frequency === "WEEKLY" ? form.weekdays : undefined,
month_days: form.frequency === "MONTHLY" ? form.month_days : undefined,
start_date: toDateKey(form.start_date),
execution_time: form.execution_time,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "Asia/Shanghai"
};
} else {
if (form.manual_timing === "SCHEDULED") {
if (!form.manual_start_at || form.manual_start_at.getTime() <= Date.now()) return ElMessage.warning("请选择晚于当前时间的预约执行时间");
nextRunAt = form.manual_start_at.toISOString();
}
scheduleConfig = { timing: form.manual_timing };
}
emit("submit", {
payload: {
name: form.name.trim(),
trigger_type: form.trigger_type,
plan_type: form.plan_type,
schedule_rule: scheduleRule,
schedule_config: scheduleConfig,
object_ids: form.object_ids,
scene_set: form.scene_set,
priority: form.priority,
next_run_at: nextRunAt,
owner_org_id: "org-railway",
created_by: "user-dispatcher"
},
generateTasks: form.trigger_type === "MANUAL"
});
}
function buildCronRule() {
const [hour, minute] = form.execution_time.split(":").map(Number);
if (form.frequency === "DAILY") return `0 ${minute} ${hour} */${form.interval_days} * *`;
if (form.frequency === "WEEKLY") return `0 ${minute} ${hour} * * ${form.weekdays.join(",")}`;
return `0 ${minute} ${hour} ${form.month_days.slice().sort((a, b) => a - b).join(",")} * *`;
}
function objectOptionLabel(item: Row) {
return `${item.name} / ${item.line_id} / ${mileageText(item)}`;
}
function objectTypeLabel(value: unknown) {
return ({ RAILWAY: "铁路线路", BRIDGE: "桥梁", TUNNEL: "隧道", FLOOD: "防洪设施", POWER: "电力设施" } as Record<string, string>)[String(value)] || String(value || "巡检对象");
}
function mileageText(item: Row) {
return [item.mileage_start, item.mileage_end].filter(Boolean).join(" - ") || "里程待补充";
}
function disablePastDate(date: Date) {
const today = new Date();
today.setHours(0, 0, 0, 0);
return date.getTime() < today.getTime();
}
function combineDateAndTime(date: Date, time: string) {
const result = new Date(date);
const [hour, minute] = time.split(":").map(Number);
result.setHours(hour, minute, 0, 0);
return result;
}
function tomorrow() {
const date = new Date();
date.setDate(date.getDate() + 1);
date.setHours(0, 0, 0, 0);
return date;
}
function tomorrowAt(hour: number) {
const date = tomorrow();
date.setHours(hour, 0, 0, 0);
return date;
}
function toDateKey(value: Date) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function formatDateTime(value: Date) {
return value.toLocaleString("zh-CN", { hour12: false, month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" });
}
</script>
<style scoped>
.inspection-plan-form { display: grid; gap: 15px; }
.form-section { padding: 15px; border: 1px solid #dce4ed; border-radius: 7px; background: #fff; }
.form-section > header { margin-bottom: 14px; }
.form-section > header strong, .form-section > header span { display: block; }
.form-section > header strong { color: #17365d; font-size: 15px; }
.form-section > header span { margin-top: 4px; color: #64748b; font-size: 11px; }
.trigger-selector { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.trigger-selector button { min-width: 0; min-height: 78px; padding: 12px; display: grid; grid-template-columns: 46px minmax(0, 1fr) auto; align-items: center; gap: 11px; border: 1px solid #dce4ed; border-radius: 7px; background: #fff; color: #334155; cursor: pointer; text-align: left; transition: .18s ease; }
.trigger-selector button:hover { border-color: #93abc5; background: #f8fafc; }
.trigger-selector button.active { border-color: #2563eb; background: #eff6ff; box-shadow: 0 0 0 1px rgba(37, 99, 235, .08); }
.trigger-selector i { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 6px; background: #e2e8f0; color: #475569; font-size: 11px; font-style: normal; font-weight: 800; }
.trigger-selector button.active i { background: #2563eb; color: #fff; }
.trigger-selector strong, .trigger-selector small { display: block; }
.trigger-selector strong { color: #17365d; font-size: 14px; }
.trigger-selector small { margin-top: 4px; color: #64748b; font-size: 11px; line-height: 1.35; }
.trigger-selector em { color: #2563eb; font-size: 11px; font-style: normal; font-weight: 700; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 14px; }
.inspection-plan-form :deep(.el-form-item) { margin-bottom: 14px; }
.inspection-plan-form :deep(.el-form-item:last-child) { margin-bottom: 0; }
.inspection-plan-form :deep(.el-select), .inspection-plan-form :deep(.el-date-editor), .inspection-plan-form :deep(.el-time-select) { width: 100%; }
.option-note { margin-left: 10px; color: #94a3b8; font-size: 11px; }
.field-help { display: block; margin-top: 6px; color: #94a3b8; font-size: 10px; line-height: 1.4; }
.schedule-section { background: #f8fafc; }
.schedule-choice { min-height: 48px; margin-bottom: 14px; display: flex; align-items: center; gap: 9px; color: #475569; font-size: 13px; }
.schedule-choice :deep(.el-input-number) { width: 128px; }
.weekday-selector { display: flex; flex-wrap: wrap; }
.manual-timing-selector { width: 100%; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
.manual-timing-selector button { min-height: 64px; padding: 11px 12px; border: 1px solid #dce4ed; border-radius: 6px; background: #fff; color: #334155; cursor: pointer; text-align: left; }
.manual-timing-selector button.active { border-color: #2563eb; background: #eff6ff; }
.manual-timing-selector strong, .manual-timing-selector span { display: block; }
.manual-timing-selector strong { color: #17365d; font-size: 13px; }
.manual-timing-selector span { margin-top: 4px; color: #64748b; font-size: 11px; }
.object-option { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.object-option strong, .object-option small { display: block; }
.object-option small { color: #94a3b8; font-size: 10px; }
.object-option em { color: #64748b; font-size: 11px; font-style: normal; }
.scene-selector { display: flex; flex-wrap: wrap; gap: 7px; }
.scene-selector :deep(.el-checkbox) { margin: 0; }
.plan-summary { padding: 13px 15px; display: grid; grid-template-columns: .8fr 1.5fr 1fr; gap: 14px; border-radius: 7px; background: #17365d; color: #fff; }
.plan-summary span, .plan-summary strong { display: block; }
.plan-summary span { color: #bfdbfe; font-size: 10px; }
.plan-summary strong { margin-top: 5px; font-size: 12px; line-height: 1.4; }
.form-actions { display: flex; justify-content: flex-end; gap: 8px; }
@media (max-width: 680px) {
.trigger-selector, .form-grid, .manual-timing-selector, .plan-summary { grid-template-columns: 1fr; }
}
</style>
+6 -3
View File
@@ -21,9 +21,12 @@
<el-table-column label="操作" width="230" fixed="right"><template #default="scope"><div class="list-actions" @click.stop><el-button link type="primary" @click="openDetail(scope.row)">证据链</el-button><el-button v-if="!scope.row.suppressed && !['confirmed','closed'].includes(scope.row.status)" link type="success" @click="decision(scope.row, 'confirm')">确认</el-button><el-button v-if="!scope.row.suppressed && scope.row.status !== 'closed'" link type="warning" @click="decision(scope.row, 'suppress')">抑制</el-button><el-button v-if="scope.row.suppressed" link type="primary" @click="decision(scope.row, 'reopen')">恢复</el-button></div></template></el-table-column>
</el-table><el-pagination v-model:current-page="page" v-model:page-size="pageSize" class="table-pagination" background layout="total, sizes, prev, pager, next" :page-sizes="[20, 50, 100]" :total="filteredRows.length" /></el-card>
<el-drawer v-model="detailVisible" title="告警证据链" size="620px">
<el-drawer v-model="detailVisible" title="告警研判" size="min(760px, 94vw)">
<el-skeleton v-if="detailLoading" :rows="10" animated />
<template v-else-if="evidence.alarm"><div class="alarm-evidence-head"><div><el-tag :type="severityTag(evidence.alarm.severity)" effect="dark">{{ severityLabel(evidence.alarm.severity) }}</el-tag><h2>{{ evidence.alarm.scene }}</h2><p>{{ evidence.alarm.category }} · 置信度 {{ Number(evidence.alarm.confidence || 0).toFixed(2) }}</p></div></div><el-steps direction="vertical" :active="4" finish-status="success"><el-step title="原始资源" :description="String(evidence.ai_result?.resource_id || '-')" /><el-step title="模型识别" :description="`${evidence.ai_result?.model_name || '-'} / ${evidence.ai_result?.model_version || '-'}`" /><el-step title="规则判定" :description="ruleText(evidence.alarm.rule_hits)" /><el-step title="告警生成" :description="`${statusLabel(evidence.alarm.status)} / ${locationText(evidence.alarm.location)}`" /></el-steps><div class="drawer-actions"><el-button type="primary" @click="decision(evidence.alarm, 'confirm')">确认有效</el-button><el-button type="warning" @click="decision(evidence.alarm, 'suppress')">抑制告警</el-button><el-button @click="router.push({ path: '/gis', query: { alarmId: evidence.alarm.alarm_id } })">地图定位</el-button><el-button v-if="evidence.workorders?.length" @click="router.push(`/workorders/${evidence.workorders[0].workorder_id}`)">查看工单</el-button></div><pre class="detail-json">{{ JSON.stringify({ location: evidence.alarm.location, result: evidence.ai_result }, null, 2) }}</pre></template>
<AlarmEvidencePanel v-else-if="evidence.alarm" :evidence="evidence">
<template #actions><div class="drawer-actions"><el-button type="primary" @click="decision(evidence.alarm, 'confirm')">确认有效</el-button><el-button type="warning" @click="decision(evidence.alarm, 'suppress')">抑制告警</el-button><el-button @click="router.push({ path: '/gis', query: { alarmId: evidence.alarm.alarm_id } })">地图定位</el-button><el-button v-if="evidence.workorders?.length" @click="router.push(`/workorders/${evidence.workorders[0].workorder_id}`)">查看工单</el-button></div></template>
</AlarmEvidencePanel>
<el-empty v-else description="未获取到告警证据" />
</el-drawer>
<el-drawer v-model="notificationVisible" title="站内通知" size="520px">
<div v-if="notifications.length" class="notification-list">
@@ -43,6 +46,7 @@ import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Bell, MapLocation, Refresh, Search } from "@element-plus/icons-vue";
import AlarmEvidencePanel from "../../components/alarm/AlarmEvidencePanel.vue";
import PageHeader from "../../components/common/PageHeader.vue";
import { alarmEvidence, alarms, decideAlarm, notificationDeliveries, readNotificationDelivery, workorders } from "../../services/api";
import { safeObject, severityLabel, statusLabel, statusTag, type Row } from "../../types/demo-run";
@@ -60,7 +64,6 @@ function matchTab(row: Row) { return matchesTab(row, activeTab.value); }
function tabCount(tab: string) { return rows.value.filter((row) => matchesTab(row, tab)).length; }
function severityTag(value: unknown): "danger" | "warning" | "info" | "success" { return ({ critical: "danger", high: "warning", medium: "info", low: "success" } as any)[String(value)] || "info"; }
function locationText(value: unknown) { const item = safeObject(value); return [item.mileage, item.distance_to_track_m ? `${item.distance_to_track_m}m` : ""].filter(Boolean).join(" / ") || "线路邻近"; }
function ruleText(value: unknown) { return Array.isArray(value) ? value.join(" / ") : String(value || "业务与空间规则命中"); }
function formatDateTime(value: unknown) { return value ? new Date(String(value)).toLocaleString("zh-CN", { hour12: false }) : "-"; }
async function load() { loading.value = true; try { [rows.value, workorderRows.value, notifications.value] = await Promise.all([alarms(true), workorders(), notificationDeliveries()]); const id = String(route.params.alarmId || ""); if (id) { const row = rows.value.find((item) => String(item.alarm_id) === id); if (row) openDetail(row); } } finally { loading.value = false; } }
async function openDetail(row: Row) { detailVisible.value = true; detailLoading.value = true; router.replace({ path: `/alarms/${row.alarm_id}`, query: route.query }); try { evidence.value = await alarmEvidence(String(row.alarm_id)); } finally { detailLoading.value = false; } }
+3 -4
View File
@@ -2,7 +2,6 @@
<div v-loading="loading">
<PageHeader title="工作总览" description="汇总巡检运行、告警处置和系统健康状态">
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
<el-button type="primary" :icon="VideoPlay" @click="router.push('/runs')">启动全链路</el-button>
</PageHeader>
<section class="metric-strip">
@@ -100,14 +99,14 @@ const pendingWorkorders = computed(() => workorderRows.value.filter((row) => row
const closedWorkorders = computed(() => workorderRows.value.filter((row) => row.status === "closed").length);
const closureRate = computed(() => workorderRows.value.length ? Math.round(closedWorkorders.value / workorderRows.value.length * 100) : 0);
const todos = computed(() => [
{ label: "待执行巡检任务", value: taskRows.value.filter((row) => ["created", "pending"].includes(String(row.status))).length, path: "/tasks?tab=pending", icon: Files },
{ label: "待执行巡检任务", value: taskRows.value.filter((row) => ["created", "pending", "dispatching", "dispatched"].includes(String(row.status))).length, path: "/tasks?tab=pending", icon: Files },
{ label: "待研判高等级告警", value: priorityAlarms.value.filter((row) => row.status !== "closed").length, path: "/alarms?tab=pending", icon: Bell },
{ label: "待处置与复核工单", value: pendingWorkorders.value, path: "/workorders?tab=pending", icon: Checked }
]);
const quickActions = [
{ label: "全链路运行", description: "启动完整巡检处理流程", path: "/runs", icon: VideoPlay },
{ label: "全链路运行", description: "查看运行记录与处理进度", path: "/runs", icon: VideoPlay },
{ label: "GIS 态势", description: "查看线路与隐患分布", path: "/gis", icon: MapLocation },
{ label: "巡检任务", description: "创建和下发巡检任务", path: "/tasks", icon: Files },
{ label: "巡检任务", description: "任务调度与执行跟踪", path: "/tasks", icon: Files },
{ label: "工单中心", description: "处理现场闭环事项", path: "/workorders", icon: Checked }
];
const healthServices = [
+51 -23
View File
@@ -2,11 +2,11 @@
<div v-loading="loading">
<PageHeader title="巡检计划" description="管理周期计划、巡检对象、任务生成和执行准备">
<el-button :icon="Refresh" @click="load">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="planDialog = true">新建计划</el-button>
<el-button type="primary" :icon="Plus" @click="openPlanDialog()">新建计划</el-button>
</PageHeader>
<section class="metric-strip">
<div class="metric-card"><strong>{{ plans.length }}</strong><span>巡检计划</span><small>{{ activePlans }} 已启用</small></div>
<div class="metric-card"><strong>{{ plans.length }}</strong><span>巡检计划</span><small>{{ periodicPlans }} 个周期 · {{ manualPlans }} 手动</small></div>
<div class="metric-card"><strong>{{ objects.length }}</strong><span>巡检对象</span><small>线路桥隧防洪与电力</small></div>
<div class="metric-card"><strong>{{ highRiskObjects }}</strong><span>重点对象</span><small>高及以上风险</small></div>
<div class="metric-card"><strong>{{ generatedTasks }}</strong><span>已生成任务</span><small>由计划自动生成</small></div>
@@ -17,14 +17,15 @@
<el-card class="workspace-card" shadow="never">
<el-table :data="plans" empty-text="暂无巡检计划" @row-click="inspectPlan">
<el-table-column prop="name" label="计划" min-width="180"><template #default="scope"><strong>{{ scope.row.name }}</strong><small class="cell-subtext entity-id">{{ scope.row.plan_id }}</small></template></el-table-column>
<el-table-column label="类型" width="100"><template #default="scope">{{ planTypeLabel(scope.row.plan_type) }}</template></el-table-column>
<el-table-column prop="schedule_rule" label="周期规则" min-width="135" />
<el-table-column label="巡检方式" width="105"><template #default="scope"><el-tag :type="scope.row.trigger_type === 'MANUAL' ? 'warning' : 'primary'" effect="plain">{{ triggerTypeLabel(scope.row.trigger_type) }}</el-tag></template></el-table-column>
<el-table-column label="业务类型" width="100"><template #default="scope">{{ planTypeLabel(scope.row.plan_type) }}</template></el-table-column>
<el-table-column label="执行安排" min-width="190"><template #default="scope">{{ scheduleText(scope.row) }}</template></el-table-column>
<el-table-column label="巡检对象" width="100"><template #default="scope">{{ parseArray(scope.row.object_ids).length }} </template></el-table-column>
<el-table-column label="识别场景" width="100"><template #default="scope">{{ parseArray(scope.row.scene_set).length }} </template></el-table-column>
<el-table-column prop="owner_org_name" label="责任单位" min-width="130" />
<el-table-column label="下次执行" min-width="165"><template #default="scope">{{ formatDate(scope.row.next_run_at) }}</template></el-table-column>
<el-table-column label="状态" width="100"><template #default="scope"><el-tag :type="scope.row.status === 'ACTIVE' ? 'success' : 'info'" effect="plain">{{ scope.row.status === 'ACTIVE' ? '已启用' : '草稿' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="220" fixed="right"><template #default="scope"><div class="list-actions" @click.stop><el-button v-if="scope.row.status !== 'ACTIVE'" link type="primary" @click="activate(scope.row)">启用</el-button><el-button link type="primary" @click="generate(scope.row)">生成任务</el-button><el-button link @click="inspectPlan(scope.row)">详情</el-button></div></template></el-table-column>
<el-table-column label="下次/计划执行" min-width="165"><template #default="scope">{{ executionDateText(scope.row) }}</template></el-table-column>
<el-table-column label="状态" width="105"><template #default="scope"><el-tag :type="planStatusTag(scope.row)" effect="plain">{{ planStatusLabel(scope.row) }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="230" fixed="right"><template #default="scope"><div class="list-actions" @click.stop><el-button v-if="scope.row.trigger_type !== 'MANUAL' && scope.row.status !== 'ACTIVE'" link type="primary" @click="activate(scope.row)">启用计划</el-button><el-button v-if="scope.row.trigger_type !== 'MANUAL' || !Number(scope.row.generated_task_count)" link type="primary" @click="generate(scope.row)">{{ scope.row.trigger_type === 'MANUAL' ? '创建任务' : '立即生成' }}</el-button><el-button link @click="inspectPlan(scope.row)">详情</el-button></div></template></el-table-column>
</el-table>
</el-card>
</el-tab-pane>
@@ -44,14 +45,8 @@
</el-tab-pane>
</el-tabs>
<el-dialog v-model="planDialog" title="新建巡检计划" width="min(680px, 94vw)">
<el-form label-position="top"><div class="form-grid">
<el-form-item label="计划名称"><el-input v-model="planForm.name" /></el-form-item>
<el-form-item label="计划类型"><el-select v-model="planForm.plan_type"><el-option label="日常巡检" value="DAILY" /><el-option label="专项巡检" value="SPECIAL" /><el-option label="应急巡检" value="EMERGENCY" /></el-select></el-form-item>
<el-form-item label="周期规则"><el-input v-model="planForm.schedule_rule" placeholder="例如 0 0 9 * * MON" /></el-form-item>
<el-form-item label="下次执行"><el-date-picker v-model="planForm.next_run_at" type="datetime" /></el-form-item>
</div><el-form-item label="巡检对象"><el-select v-model="planForm.object_ids" multiple filterable><el-option v-for="item in objects" :key="item.object_id" :label="`${item.name} / ${item.line_id}`" :value="item.object_id" /></el-select></el-form-item><el-form-item label="识别场景"><el-select v-model="planForm.scene_set" multiple allow-create filterable><el-option v-for="scene in scenes" :key="scene" :label="scene" :value="scene" /></el-select></el-form-item></el-form>
<template #footer><el-button @click="planDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="savePlan">创建</el-button></template>
<el-dialog v-model="planDialog" class="plan-create-dialog" :title="planTrigger === 'MANUAL' ? '发起手动巡检' : '新建周期巡检计划'" width="min(780px, 96vw)" destroy-on-close :close-on-click-modal="false">
<InspectionPlanForm v-if="planDialog" :key="planFormKey" :objects="objects" :scenes="scenes" :saving="saving" :initial-trigger="planTrigger" @cancel="planDialog = false" @submit="savePlan" />
</el-dialog>
<el-dialog v-model="objectDialog" title="新增巡检对象" width="min(640px, 94vw)">
@@ -64,36 +59,69 @@
<template #footer><el-button @click="objectDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveObject">创建</el-button></template>
</el-dialog>
<el-drawer v-model="detailVisible" title="计划详情" size="520px"><template v-if="selectedPlan"><el-descriptions :column="1" border><el-descriptions-item label="计划编号"><span class="entity-id">{{ selectedPlan.plan_id }}</span></el-descriptions-item><el-descriptions-item label="类型">{{ planTypeLabel(selectedPlan.plan_type) }}</el-descriptions-item><el-descriptions-item label="周期">{{ selectedPlan.schedule_rule || '人工触发' }}</el-descriptions-item><el-descriptions-item label="对象">{{ objectNames(selectedPlan.object_ids) }}</el-descriptions-item><el-descriptions-item label="场景">{{ parseArray(selectedPlan.scene_set).join('、') }}</el-descriptions-item><el-descriptions-item label="已生成任务">{{ selectedPlan.generated_task_count }}</el-descriptions-item></el-descriptions></template></el-drawer>
<el-drawer v-model="detailVisible" title="计划详情" size="560px"><template v-if="selectedPlan"><el-descriptions :column="1" border><el-descriptions-item label="计划编号"><span class="entity-id">{{ selectedPlan.plan_id }}</span></el-descriptions-item><el-descriptions-item label="巡检方式">{{ triggerTypeLabel(selectedPlan.trigger_type) }}</el-descriptions-item><el-descriptions-item label="业务类型">{{ planTypeLabel(selectedPlan.plan_type) }}</el-descriptions-item><el-descriptions-item label="执行安排">{{ scheduleText(selectedPlan) }}</el-descriptions-item><el-descriptions-item label="下次/计划执行">{{ executionDateText(selectedPlan) }}</el-descriptions-item><el-descriptions-item label="优先级">{{ priorityLabel(selectedPlan.priority) }}</el-descriptions-item><el-descriptions-item label="对象">{{ objectNames(selectedPlan.object_ids) }}</el-descriptions-item><el-descriptions-item label="场景">{{ parseArray(selectedPlan.scene_set).join('、') }}</el-descriptions-item><el-descriptions-item label="已生成任务">{{ selectedPlan.generated_task_count }}</el-descriptions-item></el-descriptions></template></el-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Plus, Refresh, Search } from "@element-plus/icons-vue";
import PageHeader from "../../components/common/PageHeader.vue";
import InspectionPlanForm from "../../components/planning/InspectionPlanForm.vue";
import { activateInspectionPlan, createInspectionObject, createInspectionPlan, generatePlanTasks, inspectionObjects, inspectionPlans } from "../../services/api";
import type { Row } from "../../types/demo-run";
import { safeObject, type Row } from "../../types/demo-run";
const route = useRoute(); const router = useRouter();
const plans = ref<Row[]>([]); const objects = ref<Row[]>([]); const loading = ref(false); const saving = ref(false); const activeTab = ref("plans"); const keyword = ref(""); const objectType = ref("");
const planDialog = ref(false); const objectDialog = ref(false); const detailVisible = ref(false); const selectedPlan = ref<Row | null>(null);
const planDialog = ref(false); const planFormKey = ref(0); const planTrigger = ref<"PERIODIC" | "MANUAL">("PERIODIC"); const objectDialog = ref(false); const detailVisible = ref(false); const selectedPlan = ref<Row | null>(null);
const objectTypes = [{ value: "RAILWAY", label: "铁路线路" }, { value: "BRIDGE", label: "桥梁" }, { value: "TUNNEL", label: "隧道" }, { value: "FLOOD", label: "防洪设施" }, { value: "POWER", label: "电力设施" }];
const scenes = ["异物侵限", "护网破损", "桥梁裂缝", "隧道渗漏", "防洪体积变化", "接触网异物", "设备发热"];
const planForm = reactive({ name: "试点线路周期巡检", plan_type: "DAILY", schedule_rule: "0 0 9 * * MON", object_ids: [] as string[], scene_set: ["异物侵限"], priority: "high", next_run_at: null as Date | null, owner_org_id: "org-railway", created_by: "user-dispatcher" });
const objectForm = reactive({ name: "", object_type: "RAILWAY", line_id: "line-demo", mileage_start: "", mileage_end: "", risk_level: "NORMAL", longitude: 116.1, latitude: 39.1 });
const activePlans = computed(() => plans.value.filter((item) => item.status === "ACTIVE").length); const highRiskObjects = computed(() => objects.value.filter((item) => ["HIGH", "CRITICAL"].includes(item.risk_level)).length); const generatedTasks = computed(() => plans.value.reduce((sum, item) => sum + Number(item.generated_task_count || 0), 0));
const periodicPlans = computed(() => plans.value.filter((item) => item.trigger_type !== "MANUAL").length); const manualPlans = computed(() => plans.value.filter((item) => item.trigger_type === "MANUAL").length); const highRiskObjects = computed(() => objects.value.filter((item) => ["HIGH", "CRITICAL"].includes(item.risk_level)).length); const generatedTasks = computed(() => plans.value.reduce((sum, item) => sum + Number(item.generated_task_count || 0), 0));
const filteredObjects = computed(() => objects.value.filter((item) => (!objectType.value || item.object_type === objectType.value) && (!keyword.value || `${item.name} ${item.line_id} ${item.mileage_start}`.toLowerCase().includes(keyword.value.toLowerCase()))));
function parseArray(value: unknown): string[] { try { return Array.isArray(value) ? value.map(String) : JSON.parse(String(value || "[]")); } catch { return []; } }
function planTypeLabel(value: unknown) { return ({ DAILY: "日常", SPECIAL: "专项", EMERGENCY: "应急" } as Record<string, string>)[String(value)] || String(value); }
function triggerTypeLabel(value: unknown) { return String(value || "PERIODIC") === "MANUAL" ? "手动巡检" : "周期巡检"; }
function priorityLabel(value: unknown) { return ({ normal: "普通", high: "优先", urgent: "紧急" } as Record<string, string>)[String(value)] || String(value || "普通"); }
function objectTypeLabel(value: unknown) { return objectTypes.find((item) => item.value === value)?.label || String(value); }
function formatDate(value: unknown) { return value ? new Date(String(value)).toLocaleString("zh-CN", { hour12: false }) : "人工触发"; }
function formatDate(value: unknown) { return value ? new Date(String(value)).toLocaleString("zh-CN", { hour12: false }) : "-"; }
function executionDateText(row: Row) { return row.next_run_at ? formatDate(row.next_run_at) : row.trigger_type === "MANUAL" ? "立即创建" : "启用后计算"; }
function scheduleText(row: Row) {
const triggerType = String(row.trigger_type || (row.schedule_rule ? "PERIODIC" : "MANUAL"));
const config = safeObject(row.schedule_config);
if (triggerType === "MANUAL") return String(config.timing) === "SCHEDULED" ? "预约一次执行" : "人工触发,执行一次";
const time = String(config.execution_time || "").trim();
if (config.frequency === "DAILY") return `${Number(config.interval_days || 1)}${time ? ` ${time}` : ""}`;
if (config.frequency === "WEEKLY") return `每周 ${parseArray(config.weekdays).map(weekdayLabel).join("、")}${time ? ` ${time}` : ""}`;
if (config.frequency === "MONTHLY") return `每月 ${parseNumberArray(config.month_days).join("、")}${time ? ` ${time}` : ""}`;
return row.schedule_rule || "周期规则待配置";
}
function weekdayLabel(value: string) { return ({ MON: "周一", TUE: "周二", WED: "周三", THU: "周四", FRI: "周五", SAT: "周六", SUN: "周日" } as Record<string, string>)[value] || value; }
function parseNumberArray(value: unknown): number[] { return Array.isArray(value) ? value.map(Number) : []; }
function planStatusLabel(row: Row) { if (row.trigger_type === "MANUAL") return Number(row.generated_task_count) ? "任务已生成" : "待创建任务"; return row.status === "ACTIVE" ? "已启用" : "草稿"; }
function planStatusTag(row: Row): "success" | "warning" | "info" { if (row.trigger_type === "MANUAL") return Number(row.generated_task_count) ? "success" : "warning"; return row.status === "ACTIVE" ? "success" : "info"; }
function objectNames(value: unknown) { const ids = parseArray(value); return ids.map((id) => objects.value.find((item) => item.object_id === id)?.name || id).join("、"); }
async function load() { loading.value = true; try { [plans.value, objects.value] = await Promise.all([inspectionPlans(), inspectionObjects()]); } finally { loading.value = false; } }
async function savePlan() { if (!planForm.name || !planForm.object_ids.length || !planForm.scene_set.length) return ElMessage.warning("请填写计划名称、巡检对象和识别场景"); saving.value = true; try { await createInspectionPlan({ ...planForm, next_run_at: planForm.next_run_at?.toISOString() }); planDialog.value = false; ElMessage.success("巡检计划已创建"); await load(); } finally { saving.value = false; } }
function openPlanDialog(trigger: "PERIODIC" | "MANUAL" = "PERIODIC") { planTrigger.value = trigger; planFormKey.value += 1; planDialog.value = true; }
async function savePlan(request: { payload: Record<string, unknown>; generateTasks: boolean }) { saving.value = true; try { const created = await createInspectionPlan(request.payload); if (request.generateTasks) { const result = await generatePlanTasks(String(created.plan_id)); ElMessage.success(`手动巡检已创建 ${result.generated} 条任务`); } else { ElMessage.success("周期巡检计划已创建,请确认后启用"); } planDialog.value = false; await load(); } finally { saving.value = false; } }
async function saveObject() { if (!objectForm.name || !objectForm.line_id) return ElMessage.warning("请填写对象名称和线路"); saving.value = true; try { await createInspectionObject({ ...objectForm, geometry: { type: "Point", coordinates: [objectForm.longitude, objectForm.latitude] }, owner_org_id: objectForm.object_type === "FLOOD" ? "org-flood" : "org-works", inspection_cycle_days: 7, source_crs: "EPSG:4326" }); objectDialog.value = false; ElMessage.success("巡检对象已创建"); await load(); } finally { saving.value = false; } }
async function activate(row: Row) { await activateInspectionPlan(String(row.plan_id)); ElMessage.success("计划已启用"); await load(); }
async function generate(row: Row) { const result = await generatePlanTasks(String(row.plan_id)); ElMessage.success(`已生成 ${result.generated} 个巡检任务`); await load(); }
function inspectPlan(row: Row) { selectedPlan.value = row; detailVisible.value = true; }
onMounted(load);
onMounted(async () => {
await load();
if (route.query.create === "manual") {
openPlanDialog("MANUAL");
const query = { ...route.query };
delete query.create;
router.replace({ path: "/planning", query });
}
});
</script>
<style scoped>
:global(.plan-create-dialog) { margin-top: 4vh; }
:global(.plan-create-dialog .el-dialog__body) { max-height: calc(92vh - 118px); overflow-y: auto; }
</style>
+329 -43
View File
@@ -1,77 +1,363 @@
<template>
<div v-loading="loading">
<PageHeader title="巡检任务" description="管理任务参数、线路范围、航线下发和飞行执行状态">
<PageHeader title="巡检任务" description="统一查看计划生成的任务,完成执行准备、飞行调度和结果跟踪">
<el-button :icon="Refresh" @click="load">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="createVisible = true">新建任务</el-button>
<el-button type="primary" :icon="Plus" @click="startManualInspection">发起手动巡检</el-button>
</PageHeader>
<el-alert
class="task-entry-tip"
type="info"
:closable="false"
show-icon
title="任务统一由周期计划自动生成,或由“巡检计划 > 手动巡检”创建;本页不再提供第二套新建入口。"
/>
<el-tabs v-model="activeTab" class="secondary-tabs">
<el-tab-pane label="全部任务" name="all" />
<el-tab-pane :label="`全部任务 ${rows.length}`" name="all" />
<el-tab-pane :label="`待执行 ${statusCount('pending')}`" name="pending" />
<el-tab-pane :label="`执行中 ${statusCount('running')}`" name="running" />
<el-tab-pane :label="`已完成 ${statusCount('completed')}`" name="completed" />
<el-tab-pane :label="`异常 ${statusCount('abnormal')}`" name="abnormal" />
<el-tab-pane :label="`异常/取消 ${statusCount('abnormal')}`" name="abnormal" />
</el-tabs>
<div class="filter-bar"><el-input v-model="keyword" clearable placeholder="任务编号、线路或航线" :prefix-icon="Search" /><el-select v-model="priorityFilter"><el-option label="全部优先级" value="" /><el-option label="高" value="high" /><el-option label="普通" value="normal" /></el-select><span class="filter-spacer"></span><span>{{ filteredRows.length }} 项任务</span></div>
<div class="filter-bar">
<el-input v-model="keyword" clearable placeholder="任务编号、计划、线路或航线" :prefix-icon="Search" />
<el-select v-model="priorityFilter">
<el-option label="全部优先级" value="" />
<el-option label="紧急" value="urgent" />
<el-option label="优先" value="high" />
<el-option label="普通" value="normal" />
</el-select>
<span class="filter-spacer"></span>
<span>{{ filteredRows.length }} 项任务</span>
</div>
<el-card class="workspace-card" shadow="never">
<el-table :data="filteredRows" empty-text="暂无巡检任务" @row-click="openDetail">
<el-table-column prop="task_id" label="任务编号" min-width="175"><template #default="scope"><span class="entity-id">{{ scope.row.task_id }}</span><small class="cell-subtext">{{ scope.row.external_task_id || '平台创建' }}</small></template></el-table-column>
<el-table-column prop="line_id" label="线路" width="115" />
<el-table-column label="里程范围" min-width="190"><template #default="scope">{{ scope.row.mileage_start }} - {{ scope.row.mileage_end }}</template></el-table-column>
<el-table-column prop="route_id" label="航线" min-width="130" />
<el-table-column label="场景" min-width="160"><template #default="scope">{{ sceneCount(scope.row.scene_set) }} 个场景</template></el-table-column>
<el-table-column prop="priority" label="优先级" width="90" />
<el-table-column label="状态" width="105"><template #default="scope"><el-tag :type="statusTag(scope.row.status)" effect="plain">{{ statusLabel(scope.row.status) }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="230" fixed="right"><template #default="scope"><div class="list-actions" @click.stop><el-button link type="primary" @click="dispatch(scope.row)">航线下发</el-button><el-button link type="primary" @click="router.push({ path: '/gis', query: { taskId: scope.row.task_id } })">地图</el-button><el-button link type="danger" :disabled="['completed','cancelled'].includes(scope.row.status)" @click="cancel(scope.row)">取消</el-button></div></template></el-table-column>
<el-table-column prop="task_id" label="任务编号" min-width="190">
<template #default="scope">
<span class="entity-id">{{ scope.row.task_id }}</span>
<small class="cell-subtext">{{ taskSource(scope.row) }}</small>
</template>
</el-table-column>
<el-table-column prop="line_id" label="线路" width="110" />
<el-table-column label="里程范围" min-width="175">
<template #default="scope">{{ mileageText(scope.row) }}</template>
</el-table-column>
<el-table-column label="航线" min-width="150">
<template #default="scope">
<span>{{ scope.row.route_name || scope.row.route_id || "待配置" }}</span>
<small v-if="scope.row.route_id && (scope.row.route_status !== 'PUBLISHED' || scope.row.route_version_status !== 'PUBLISHED')" class="cell-subtext warning-text">尚未发布</small>
</template>
</el-table-column>
<el-table-column label="计划执行" min-width="165">
<template #default="scope">{{ formatDate(scope.row.planned_start_at) }}</template>
</el-table-column>
<el-table-column label="优先级" width="90">
<template #default="scope">{{ priorityLabel(scope.row.priority) }}</template>
</el-table-column>
<el-table-column label="状态" min-width="145">
<template #default="scope">
<div class="status-stack">
<el-tag :type="phaseTag(taskPhase(scope.row))" effect="plain">{{ phaseLabel(taskPhase(scope.row)) }}</el-tag>
<small v-if="scope.row.mission_id">{{ scope.row.vendor_code || "无人机" }} · {{ Number(scope.row.mission_progress || 0) }}%</small>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right">
<template #default="scope">
<div class="list-actions" @click.stop>
<el-button v-if="canDispatch(scope.row)" link type="primary" @click="openDispatchWorkspace(scope.row)">航线下发</el-button>
<el-button v-else-if="needsRoute(scope.row)" link type="warning" @click="configureRoute(scope.row)">配置航线</el-button>
<el-tooltip v-else-if="awaitingApproval(scope.row)" :content="String(scope.row.dispatch_block_reason || '任务尚未审批通过')" placement="top">
<span><el-button link disabled>待审批</el-button></span>
</el-tooltip>
<el-button v-if="scope.row.mission_id" link type="primary" @click="openMission(scope.row)">查看飞行</el-button>
<el-button v-if="hasResults(scope.row)" link type="primary" @click="openResources(scope.row)">查看结果</el-button>
<el-button v-else link @click="openMap(scope.row)">地图</el-button>
<el-button v-if="canCancel(scope.row)" link type="danger" @click="cancel(scope.row)">取消</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<el-drawer v-model="detailVisible" title="巡检任务详情" size="540px">
<el-drawer v-model="detailVisible" title="巡检任务详情" size="600px">
<template v-if="selectedTask">
<el-alert
class="detail-guidance"
:type="guidanceType(selectedTask)"
:closable="false"
show-icon
:title="nextActionText(selectedTask)"
/>
<el-steps
class="task-workflow"
:active="workflowStep(selectedTask)"
:process-status="taskPhase(selectedTask) === 'failed' ? 'error' : 'process'"
finish-status="success"
align-center
>
<el-step title="任务生成" />
<el-step title="执行准备" />
<el-step title="飞行执行" />
<el-step title="数据处理" />
</el-steps>
<el-descriptions :column="1" border>
<el-descriptions-item label="任务编号"><span class="entity-id">{{ selectedTask.task_id }}</span></el-descriptions-item><el-descriptions-item label="线路区段">{{ selectedTask.line_id }} / {{ selectedTask.mileage_start }} - {{ selectedTask.mileage_end }}</el-descriptions-item><el-descriptions-item label="航线">{{ selectedTask.route_id }}</el-descriptions-item><el-descriptions-item label="优先级">{{ selectedTask.priority }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(selectedTask.status) }}</el-descriptions-item><el-descriptions-item label="识别场景">{{ sceneText(selectedTask.scene_set) }}</el-descriptions-item>
<el-descriptions-item label="任务编号"><span class="entity-id">{{ selectedTask.task_id }}</span></el-descriptions-item>
<el-descriptions-item label="任务来源">{{ taskSource(selectedTask) }}</el-descriptions-item>
<el-descriptions-item label="巡检计划">{{ selectedTask.plan_name || selectedTask.plan_id || "历史任务" }}</el-descriptions-item>
<el-descriptions-item label="线路区段">{{ selectedTask.line_id }} / {{ mileageText(selectedTask) }}</el-descriptions-item>
<el-descriptions-item label="计划执行">{{ formatDate(selectedTask.planned_start_at) }}</el-descriptions-item>
<el-descriptions-item label="航线">{{ selectedTask.route_name || selectedTask.route_id || "待配置" }}</el-descriptions-item>
<el-descriptions-item label="审批状态">{{ approvalLabel(selectedTask.approval_status) }}</el-descriptions-item>
<el-descriptions-item label="业务状态">
<el-tag :type="phaseTag(taskPhase(selectedTask))" effect="plain">{{ phaseLabel(taskPhase(selectedTask)) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item v-if="selectedTask.mission_id" label="飞行任务">
<span class="entity-id">{{ selectedTask.mission_id }}</span> · {{ missionLabel(selectedTask.mission_status) }} · {{ Number(selectedTask.mission_progress || 0) }}%
</el-descriptions-item>
<el-descriptions-item v-if="selectedTask.mission_error_message" label="异常原因">{{ selectedTask.mission_error_message }}</el-descriptions-item>
<el-descriptions-item label="识别场景">{{ sceneText(selectedTask.scene_set) }}</el-descriptions-item>
</el-descriptions>
<div class="drawer-actions"><el-button type="primary" @click="router.push('/runs')">进入全链路运行</el-button><el-button @click="router.push({ path: '/resources', query: { taskId: selectedTask.task_id } })">查看数据资源</el-button></div>
<div class="drawer-actions">
<el-button v-if="canDispatch(selectedTask)" type="primary" @click="openDispatchWorkspace(selectedTask)">进入调度并下发</el-button>
<el-button v-if="needsRoute(selectedTask)" type="warning" plain @click="configureRoute(selectedTask)">配置航线</el-button>
<el-button v-if="selectedTask.mission_id" @click="openMission(selectedTask)">查看飞行任务</el-button>
<el-button v-if="hasResults(selectedTask)" @click="openResources(selectedTask)">查看数据资源</el-button>
<el-button v-if="canCancel(selectedTask)" type="danger" plain @click="cancel(selectedTask)">取消任务</el-button>
</div>
</template>
</el-drawer>
<el-dialog v-model="createVisible" title="新建巡检任务" width="min(640px, 92vw)" destroy-on-close>
<el-form label-position="top">
<div class="form-grid"><el-form-item label="外部任务编号"><el-input v-model="form.external_task_id" placeholder="可选" /></el-form-item><el-form-item label="线路"><el-input v-model="form.line_id" /></el-form-item><el-form-item label="起始里程"><el-input v-model="form.mileage_start" /></el-form-item><el-form-item label="结束里程"><el-input v-model="form.mileage_end" /></el-form-item><el-form-item label="航线"><el-input v-model="form.route_id" /></el-form-item><el-form-item label="优先级"><el-select v-model="form.priority"><el-option label="高" value="high" /><el-option label="普通" value="normal" /></el-select></el-form-item></div>
<el-form-item label="巡检场景"><el-checkbox-group v-model="form.scene_set"><el-checkbox v-for="scene in availableScenes" :key="scene" :label="scene">{{ scene }}</el-checkbox></el-checkbox-group></el-form-item>
</el-form>
<template #footer><el-button @click="createVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="createTask">创建任务</el-button></template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Refresh, Search } from "@element-plus/icons-vue";
import PageHeader from "../../components/common/PageHeader.vue";
import { cancelInspectionTask, createInspectionTask, dispatchUavMission, tasks } from "../../services/api";
import { statusLabel, statusTag, type Row } from "../../types/demo-run";
import { cancelInspectionTask, tasks } from "../../services/api";
import type { Row } from "../../types/demo-run";
const route = useRoute(); const router = useRouter();
const rows = ref<Row[]>([]); const loading = ref(false); const saving = ref(false); const createVisible = ref(false); const detailVisible = ref(false); const selectedTask = ref<Row | null>(null);
const keyword = ref(""); const priorityFilter = ref(""); const activeTab = ref(String(route.query.tab || "all"));
const availableScenes = ["违法开挖", "塔吊", "人员入侵", "防洪点精密监测", "接触网异物(高危)", "杆上设备发热"];
const form = reactive({ external_task_id: "", line_id: "line-demo", mileage_start: "K123+000", mileage_end: "K130+000", route_id: "route-manual", priority: "high", scene_set: [...availableScenes] });
const filteredRows = computed(() => rows.value.filter((row) => matchTab(row) && (!priorityFilter.value || row.priority === priorityFilter.value) && (!keyword.value || `${row.task_id} ${row.line_id} ${row.route_id}`.toLowerCase().includes(keyword.value.toLowerCase()))));
function matchTab(row: Row) { const status = String(row.status); if (activeTab.value === "all") return true; if (activeTab.value === "pending") return ["created", "pending"].includes(status); if (activeTab.value === "running") return ["flying", "data_uploading", "running"].includes(status); if (activeTab.value === "completed") return status === "completed"; return ["failed", "cancelled"].includes(status); }
function statusCount(kind: string) { return rows.value.filter((row) => { const status = String(row.status); if (kind === "pending") return ["created", "pending"].includes(status); if (kind === "running") return ["flying", "data_uploading", "running"].includes(status); if (kind === "completed") return status === "completed"; return ["failed", "cancelled"].includes(status); }).length; }
type TaskPhase =
| "awaiting_approval" | "route_pending" | "ready" | "dispatching" | "dispatched" | "preparing"
| "flying" | "paused" | "returning" | "uploading" | "processing" | "completed" | "failed" | "cancelled";
const route = useRoute();
const router = useRouter();
const rows = ref<Row[]>([]);
const loading = ref(false);
const detailVisible = ref(false);
const selectedTask = ref<Row | null>(null);
const keyword = ref("");
const priorityFilter = ref("");
const activeTab = ref(String(route.query.tab || "all"));
const filteredRows = computed(() => rows.value.filter((row) => {
const searchText = `${row.task_id} ${row.plan_name || ""} ${row.line_id || ""} ${row.route_name || row.route_id || ""}`.toLowerCase();
return matchTab(row)
&& (!priorityFilter.value || row.priority === priorityFilter.value)
&& (!keyword.value || searchText.includes(keyword.value.toLowerCase()));
}));
function taskPhase(row: Row): TaskPhase {
const taskStatus = String(row.status || "").toLowerCase();
if (taskStatus === "completed") return "completed";
if (taskStatus === "cancelled") return "cancelled";
if (taskStatus === "failed") return "failed";
const missionStatus = String(row.mission_status || "").toUpperCase();
const missionPhases: Record<string, TaskPhase> = {
DISPATCHING: "dispatching",
DISPATCHED: "dispatched",
PREPARING: "preparing",
FLYING: "flying",
PAUSED: "paused",
RETURNING: "returning",
UPLOADING: "uploading",
COMPLETED: "processing",
DISPATCH_FAILED: "failed",
FAILED: "failed",
ABORTED: "failed",
CANCELLED: "cancelled"
};
if (missionPhases[missionStatus]) return missionPhases[missionStatus];
if (taskStatus === "data_uploading") return "processing";
if (taskStatus === "flying" || taskStatus === "running") return "flying";
if (taskStatus === "dispatched" || taskStatus === "dispatching") return "dispatched";
if (String(row.approval_status || "") !== "APPROVED") return "awaiting_approval";
if (!row.route_id || row.route_status !== "PUBLISHED") return "route_pending";
return "ready";
}
function phaseLabel(phase: TaskPhase) {
const labels: Record<TaskPhase, string> = {
awaiting_approval: "待审批",
route_pending: "待配置航线",
ready: "待下发",
dispatching: "下发中",
dispatched: "已下发",
preparing: "飞行准备",
flying: "飞行中",
paused: "已暂停",
returning: "返航中",
uploading: "数据回传",
processing: "待数据处理",
completed: "已完成",
failed: "执行异常",
cancelled: "已取消"
};
return labels[phase];
}
function phaseTag(phase: TaskPhase): "success" | "warning" | "info" | "danger" | "primary" {
if (phase === "completed") return "success";
if (phase === "failed") return "danger";
if (["awaiting_approval", "route_pending", "paused"].includes(phase)) return "warning";
if (["dispatching", "dispatched", "preparing", "flying", "returning", "uploading", "processing"].includes(phase)) return "primary";
return "info";
}
function matchTab(row: Row) {
if (activeTab.value === "all") return true;
const phase = taskPhase(row);
if (activeTab.value === "pending") return ["awaiting_approval", "route_pending", "ready", "dispatching", "dispatched", "preparing"].includes(phase);
if (activeTab.value === "running") return ["flying", "paused", "returning", "uploading", "processing"].includes(phase);
if (activeTab.value === "completed") return phase === "completed";
return ["failed", "cancelled"].includes(phase);
}
function statusCount(kind: string) {
return rows.value.filter((row) => {
const phase = taskPhase(row);
if (kind === "pending") return ["awaiting_approval", "route_pending", "ready", "dispatching", "dispatched", "preparing"].includes(phase);
if (kind === "running") return ["flying", "paused", "returning", "uploading", "processing"].includes(phase);
if (kind === "completed") return phase === "completed";
return ["failed", "cancelled"].includes(phase);
}).length;
}
function booleanFlag(value: unknown) {
return value === true || value === 1 || String(value).toLowerCase() === "true";
}
function canDispatch(row: Row) { return booleanFlag(row.can_dispatch); }
function canCancel(row: Row) { return booleanFlag(row.can_cancel); }
function needsRoute(row: Row) { return ["created", "pending"].includes(String(row.status)) && String(row.approval_status) === "APPROVED" && (!row.route_id || row.route_status !== "PUBLISHED" || row.route_version_status !== "PUBLISHED"); }
function awaitingApproval(row: Row) { return ["created", "pending"].includes(String(row.status)) && String(row.approval_status) !== "APPROVED"; }
function hasResults(row: Row) { return ["data_uploading", "completed"].includes(String(row.status)); }
function parseScenes(value: unknown): string[] { try { return Array.isArray(value) ? value.map(String) : JSON.parse(String(value || "[]")); } catch { return []; } }
function sceneCount(value: unknown) { return parseScenes(value).length; }
function parseIds(value: unknown): string[] { try { return Array.isArray(value) ? value.map(String) : JSON.parse(String(value || "[]")); } catch { return []; } }
function sceneText(value: unknown) { return parseScenes(value).join("、") || "-"; }
async function load() { loading.value = true; try { rows.value = await tasks(); openRouteTask(); } finally { loading.value = false; } }
async function createTask() { if (!form.line_id || !form.scene_set.length) return ElMessage.warning("请填写线路并至少选择一个巡检场景"); saving.value = true; try { await createInspectionTask({ ...form, planned_start_time: new Date(Date.now() + 300000).toISOString() }); createVisible.value = false; ElMessage.success("巡检任务已创建"); await load(); } finally { saving.value = false; } }
async function dispatch(row: Row) { await dispatchUavMission({ task_id: String(row.task_id), route_id: String(row.route_id || "route-manual"), connection_id: "connection-simulator", device_id: "uav-sim-01", dock_id: "dock-sim-01", requested_by: "user-dispatcher", idempotency_key: `dispatch-${row.task_id}` }); ElMessage.success("航线已通过统一厂商适配器下发"); await load(); }
async function cancel(row: Row) { try { await ElMessageBox.confirm("确认取消该巡检任务?", "取消任务", { type: "warning" }); await cancelInspectionTask(String(row.task_id)); ElMessage.success("任务已取消"); await load(); } catch { /* cancelled */ } }
function openDetail(row: Row) { selectedTask.value = row; detailVisible.value = true; router.replace({ path: `/tasks/${row.task_id}`, query: route.query }); }
function openRouteTask() { const id = String(route.params.taskId || ""); if (!id) return; const row = rows.value.find((item) => String(item.task_id) === id); if (row) { selectedTask.value = row; detailVisible.value = true; } }
watch(detailVisible, (visible) => { if (!visible && route.params.taskId) router.replace({ path: "/tasks", query: route.query }); });
function mileageText(row: Row) { return [row.mileage_start, row.mileage_end].filter(Boolean).join(" - ") || "-"; }
function formatDate(value: unknown) { return value ? new Date(String(value)).toLocaleString("zh-CN", { hour12: false }) : "未指定"; }
function priorityLabel(value: unknown) { return ({ urgent: "紧急", high: "优先", normal: "普通" } as Record<string, string>)[String(value)] || String(value || "普通"); }
function approvalLabel(value: unknown) { return ({ APPROVED: "已审批", DRAFT: "待审批", PENDING: "审批中", REJECTED: "已驳回" } as Record<string, string>)[String(value)] || String(value || "待审批"); }
function missionLabel(value: unknown) { return ({ DISPATCHING: "下发中", DISPATCHED: "已下发", PREPARING: "准备中", FLYING: "飞行中", PAUSED: "已暂停", RETURNING: "返航中", UPLOADING: "数据回传", COMPLETED: "飞行完成", CANCELLED: "已取消", ABORTED: "已中止", FAILED: "失败", DISPATCH_FAILED: "下发失败" } as Record<string, string>)[String(value)] || String(value || "-"); }
function taskSource(row: Row) { if (row.plan_id) return row.trigger_type === "MANUAL" ? "手动巡检" : `周期计划 · ${row.plan_name || row.plan_id}`; return row.external_task_id || "历史任务"; }
function workflowStep(row: Row) {
const phase = taskPhase(row);
if (phase === "completed") return 4;
if (["uploading", "processing"].includes(phase)) return 3;
if (["flying", "paused", "returning", "failed"].includes(phase)) return 2;
if (["dispatching", "dispatched", "preparing"].includes(phase)) return 2;
return 1;
}
function guidanceType(row: Row): "success" | "warning" | "info" | "error" {
const phase = taskPhase(row);
if (phase === "completed") return "success";
if (phase === "failed") return "error";
if (["awaiting_approval", "route_pending", "paused"].includes(phase)) return "warning";
return "info";
}
function nextActionText(row: Row) {
const phase = taskPhase(row);
const messages: Record<TaskPhase, string> = {
awaiting_approval: "任务尚未审批通过,审批完成前不能下发航线。",
route_pending: row.route_id ? "任务航线尚未发布,请完成校验和发布后再下发。" : "任务尚未关联航线,请先完成航线配置。",
ready: "执行准备已完成,可进入无人机运行选择厂商连接和在线设备后下发。",
dispatching: "航线正在下发,请等待厂商回执,勿重复操作。",
dispatched: "航线已下发,等待在无人机运行工作台启动飞行。",
preparing: "无人机正在执行起飞前准备,请在运行工作台关注设备状态。",
flying: "任务飞行执行中,可进入无人机运行查看遥测和控制命令。",
paused: "飞行任务已暂停,请在无人机运行工作台选择恢复或返航。",
returning: "无人机正在返航,任务不可取消或重复下发。",
uploading: "飞行结束,数据正在回传。",
processing: "飞行已完成,等待资源接入和智能分析完成。",
completed: "任务已完成,可查看数据资源和分析结果。",
failed: row.mission_error_message ? `任务执行异常:${row.mission_error_message}` : "任务执行异常,请进入无人机运行查看飞行记录和错误信息。",
cancelled: "任务已取消,不再允许下发航线。"
};
return messages[phase];
}
async function load() {
loading.value = true;
try {
rows.value = await tasks();
if (selectedTask.value) {
selectedTask.value = rows.value.find((item) => item.task_id === selectedTask.value?.task_id) || null;
}
openRouteTask();
} finally {
loading.value = false;
}
}
function startManualInspection() { router.push({ path: "/planning", query: { create: "manual" } }); }
function openDispatchWorkspace(row: Row) { router.push({ path: "/uav-operations", query: { tab: "missions", dispatchTaskId: String(row.task_id) } }); }
function openMission(row: Row) { router.push({ path: "/uav-operations", query: { tab: "missions", missionId: String(row.mission_id) } }); }
function openResources(row: Row) { router.push({ path: "/resources", query: { taskId: String(row.task_id) } }); }
function openMap(row: Row) { router.push({ path: "/gis", query: { taskId: String(row.task_id) } }); }
function configureRoute(row: Row) {
const objectId = parseIds(row.object_scope)[0];
router.push({ path: "/routes", query: objectId ? { objectId, taskId: String(row.task_id) } : { taskId: String(row.task_id) } });
}
async function cancel(row: Row) {
try {
await ElMessageBox.confirm("仅尚未下发的任务可以取消。确认取消该巡检任务?", "取消任务", { type: "warning" });
await cancelInspectionTask(String(row.task_id));
ElMessage.success("任务已取消");
await load();
} catch {
// 用户取消确认或接口拒绝后保持当前列表。
}
}
function openDetail(row: Row) {
selectedTask.value = row;
detailVisible.value = true;
router.replace({ path: `/tasks/${row.task_id}`, query: route.query });
}
function openRouteTask() {
const id = String(route.params.taskId || "");
if (!id) return;
const row = rows.value.find((item) => String(item.task_id) === id);
if (row) {
selectedTask.value = row;
detailVisible.value = true;
}
}
watch(detailVisible, (visible) => {
if (!visible && route.params.taskId) router.replace({ path: "/tasks", query: route.query });
});
onMounted(load);
</script>
<style scoped>
.task-entry-tip { margin-bottom: 14px; }
.status-stack { display: flex; align-items: flex-start; flex-direction: column; gap: 4px; }
.status-stack small { color: var(--el-text-color-secondary); }
.warning-text { color: var(--el-color-warning); }
.detail-guidance { margin-bottom: 22px; }
.task-workflow { margin: 10px 0 28px; }
</style>
@@ -2,7 +2,7 @@
<div v-loading="loading">
<PageHeader title="无人机运行" description="管理厂商连接、设备能力、飞行任务、命令回执和遥测航迹">
<el-button :icon="Refresh" @click="load">刷新</el-button>
<el-button type="primary" :icon="Promotion" @click="dispatchDialog = true">下发任务</el-button>
<el-button type="primary" :icon="Promotion" @click="openDispatchDialog()">下发任务</el-button>
</PageHeader>
<section class="metric-strip">
@@ -21,7 +21,7 @@
<el-table-column label="设备" width="80"><template #default="scope">{{ scope.row.device_count }}</template></el-table-column>
<el-table-column label="状态" width="135"><template #default="scope"><el-tag :type="connectionTag(scope.row.status)" effect="plain">{{ connectionLabel(scope.row.status) }}</el-tag></template></el-table-column>
<el-table-column label="最近检查" min-width="165"><template #default="scope">{{ formatDate(scope.row.last_checked_at) }}</template></el-table-column>
<el-table-column label="操作" width="205" fixed="right"><template #default="scope"><div class="list-actions"><el-button link type="primary" @click="checkConnection(scope.row)">检查连接</el-button><el-button link type="primary" :disabled="scope.row.vendor_code !== 'SIMULATOR'" @click="syncDevices(scope.row)">同步设备</el-button></div></template></el-table-column>
<el-table-column label="操作" width="205" fixed="right"><template #default="scope"><div class="list-actions"><el-button link type="primary" @click="checkConnection(scope.row)">检查连接</el-button><el-button link type="primary" :disabled="scope.row.status !== 'AVAILABLE'" @click="syncDevices(scope.row)">同步设备</el-button></div></template></el-table-column>
</el-table></el-card>
</el-tab-pane>
@@ -38,14 +38,14 @@
<el-table-column prop="vendor_code" label="厂商" width="95" />
<el-table-column label="进度" min-width="140"><template #default="scope"><el-progress :percentage="scope.row.progress" :stroke-width="8" /></template></el-table-column>
<el-table-column label="状态" width="105"><template #default="scope"><el-tag :type="missionTag(scope.row.status)" effect="plain">{{ missionLabel(scope.row.status) }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="300" fixed="right"><template #default="scope"><div class="list-actions" @click.stop><el-button v-if="scope.row.status === 'DISPATCHED'" link type="primary" @click="command(scope.row, 'MISSION_START')">开始</el-button><el-button v-if="scope.row.status === 'FLYING'" link type="warning" @click="command(scope.row, 'MISSION_PAUSE')">暂停</el-button><el-button v-if="scope.row.status === 'PAUSED'" link type="primary" @click="command(scope.row, 'MISSION_RESUME')">恢复</el-button><el-button v-if="scope.row.status === 'FLYING' && scope.row.vendor_code === 'SIMULATOR'" link type="primary" @click="command(scope.row, 'SIMULATE_PROGRESS')">推进</el-button><el-button v-if="['FLYING','PAUSED'].includes(scope.row.status)" link type="danger" @click="command(scope.row, 'RETURN_HOME')">返航</el-button><el-button link @click="inspectMission(scope.row)">遥测</el-button></div></template></el-table-column>
<el-table-column label="操作" width="330" fixed="right"><template #default="scope"><div class="list-actions" @click.stop><el-button v-if="scope.row.status === 'DISPATCHED' && scope.row.vendor_code === 'SIMULATOR'" link type="primary" @click="command(scope.row, 'MISSION_START')">开始</el-button><el-button v-if="scope.row.status === 'FLYING'" link type="warning" @click="command(scope.row, 'MISSION_PAUSE')">暂停</el-button><el-button v-if="scope.row.status === 'PAUSED'" link type="primary" @click="command(scope.row, 'MISSION_RESUME')">恢复</el-button><el-button v-if="scope.row.status === 'FLYING' && scope.row.vendor_code === 'SIMULATOR'" link type="primary" @click="command(scope.row, 'SIMULATE_PROGRESS')">推进</el-button><el-button v-if="['FLYING','PAUSED'].includes(scope.row.status)" link type="danger" @click="command(scope.row, 'RETURN_HOME')">返航</el-button><el-button v-if="['DISPATCHED','PREPARING'].includes(scope.row.status)" link type="danger" @click="command(scope.row, 'MISSION_CANCEL')">取消</el-button><el-button link @click="inspectMission(scope.row)">遥测</el-button></div></template></el-table-column>
</el-table></el-card>
</el-tab-pane>
</el-tabs>
<el-dialog v-model="dispatchDialog" title="下发飞行任务" width="min(660px, 94vw)">
<el-alert type="info" :closable="false" show-icon title="本地环境通过厂商适配器模拟器执行,生产厂商连接缺少授权时禁止下发。" />
<el-form label-position="top" class="dialog-form"><div class="form-grid"><el-form-item label="巡检任务"><el-select v-model="dispatchForm.task_id" filterable><el-option v-for="item in taskRows" :key="item.task_id" :label="`${item.task_id} / ${item.line_id}`" :value="item.task_id" /></el-select></el-form-item><el-form-item label="发布航线"><el-select v-model="dispatchForm.route_id"><el-option v-for="item in publishedRoutes" :key="item.route_id" :label="item.name" :value="item.route_id" /></el-select></el-form-item><el-form-item label="厂商连接"><el-select v-model="dispatchForm.connection_id" @change="selectConnection"><el-option v-for="item in usableConnections" :key="item.connection_id" :label="item.name" :value="item.connection_id" /></el-select></el-form-item><el-form-item label="无人机"><el-select v-model="dispatchForm.device_id"><el-option v-for="item in availableDevices" :key="item.device_id" :label="`${item.model} / ${item.vendor_device_id}`" :value="item.device_id" /></el-select></el-form-item></div></el-form>
<el-form label-position="top" class="dialog-form"><div class="form-grid"><el-form-item label="待下发巡检任务"><el-select v-model="dispatchForm.task_id" filterable @change="selectTask"><el-option v-for="item in dispatchableTasks" :key="item.task_id" :label="`${item.task_id} / ${item.line_id}`" :value="item.task_id" /></el-select></el-form-item><el-form-item label="已审批航线"><el-input :model-value="selectedTask?.route_name || selectedTask?.route_id || '未配置'" disabled /></el-form-item><el-form-item label="厂商连接"><el-select v-model="dispatchForm.connection_id" @change="selectConnection"><el-option v-for="item in usableConnections" :key="item.connection_id" :label="item.name" :value="item.connection_id" /></el-select></el-form-item><el-form-item label="无人机"><el-select v-model="dispatchForm.device_id"><el-option v-for="item in availableDevices" :key="item.device_id" :label="`${item.model} / ${item.vendor_device_id}`" :value="item.device_id" /></el-select></el-form-item></div></el-form>
<template #footer><el-button @click="dispatchDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="dispatch">下发</el-button></template>
</el-dialog>
@@ -57,30 +57,44 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import { CircleCheck, Position, Promotion, Refresh, Warning } from "@element-plus/icons-vue";
import PageHeader from "../../components/common/PageHeader.vue";
import { checkUavConnection, dispatchUavMission, executeUavMissionCommand, inspectionRoutes, synchronizeUavDevices, tasks, uavConnections, uavDevices, uavMissions, uavMissionTelemetry } from "../../services/api";
import { checkUavConnection, dispatchUavMission, executeUavMissionCommand, synchronizeUavDevices, tasks, uavConnections, uavDevices, uavMissions, uavMissionTelemetry } from "../../services/api";
import { safeObject, type Row } from "../../types/demo-run";
const connections = ref<Row[]>([]); const devices = ref<Row[]>([]); const missions = ref<Row[]>([]); const taskRows = ref<Row[]>([]); const routeRows = ref<Row[]>([]); const loading = ref(false); const saving = ref(false); const activeTab = ref("connections"); const dispatchDialog = ref(false); const healthVisible = ref(false); const healthResult = ref<Row | null>(null); const missionVisible = ref(false); const selectedMission = ref<Row | null>(null); const telemetry = ref<Row[]>([]);
const dispatchForm = reactive({ task_id: "", route_id: "route-manual", connection_id: "connection-simulator", device_id: "uav-sim-01", dock_id: "dock-sim-01" });
const availableConnections = computed(() => connections.value.filter((item) => item.status === "AVAILABLE").length); const onlineDevices = computed(() => devices.value.filter((item) => item.status === "ONLINE").length); const activeMissions = computed(() => missions.value.filter((item) => ["DISPATCHING","DISPATCHED","PREPARING","FLYING","PAUSED","RETURNING"].includes(item.status)).length); const completedMissions = computed(() => missions.value.filter((item) => item.status === "COMPLETED").length); const publishedRoutes = computed(() => routeRows.value.filter((item) => item.status === "PUBLISHED")); const usableConnections = computed(() => connections.value.filter((item) => item.status === "AVAILABLE")); const availableDevices = computed(() => devices.value.filter((item) => item.connection_id === dispatchForm.connection_id && item.status === "ONLINE"));
const route = useRoute();
const connections = ref<Row[]>([]); const devices = ref<Row[]>([]); const missions = ref<Row[]>([]); const taskRows = ref<Row[]>([]); const loading = ref(false); const saving = ref(false); const activeTab = ref(String(route.query.tab || "connections")); const dispatchDialog = ref(false); const healthVisible = ref(false); const healthResult = ref<Row | null>(null); const missionVisible = ref(false); const selectedMission = ref<Row | null>(null); const telemetry = ref<Row[]>([]); const deepLinkHandled = ref(false);
const dispatchForm = reactive({ task_id: "", route_id: "", connection_id: "connection-simulator", device_id: "uav-sim-01", dock_id: "dock-sim-01" });
const availableConnections = computed(() => connections.value.filter((item) => item.status === "AVAILABLE").length); const onlineDevices = computed(() => devices.value.filter((item) => item.status === "ONLINE").length); const activeMissions = computed(() => missions.value.filter((item) => ["DISPATCHING","DISPATCHED","PREPARING","FLYING","PAUSED","RETURNING"].includes(item.status)).length); const completedMissions = computed(() => missions.value.filter((item) => item.status === "COMPLETED").length); const dispatchableTasks = computed(() => taskRows.value.filter((item) => item.can_dispatch === true || String(item.can_dispatch).toLowerCase() === "true")); const selectedTask = computed(() => taskRows.value.find((item) => String(item.task_id) === dispatchForm.task_id)); const usableConnections = computed(() => connections.value.filter((item) => item.status === "AVAILABLE")); const availableDevices = computed(() => devices.value.filter((item) => item.connection_id === dispatchForm.connection_id && item.status === "ONLINE"));
function parseArray(value: unknown): string[] { try { return Array.isArray(value) ? value.map(String) : JSON.parse(String(value || "[]")); } catch { return []; } }
function health(row: Row) { return safeObject(row.health_detail); }
function connectionLabel(value: unknown) { return ({ AVAILABLE: "服务可用", MATERIALS_REQUIRED: "等待接入材料", DEGRADED: "连接降级", OFFLINE: "不可用" } as Record<string, string>)[String(value)] || String(value); }
function connectionTag(value: unknown): "success" | "warning" | "danger" | "info" { return value === "AVAILABLE" ? "success" : value === "MATERIALS_REQUIRED" ? "warning" : "danger"; }
function connectionLabel(value: unknown) { return ({ AVAILABLE: "服务可用", CONFIGURATION_REQUIRED: "等待部署参数", MATERIALS_REQUIRED: "等待接入材料", DEGRADED: "连接降级", OFFLINE: "不可用" } as Record<string, string>)[String(value)] || String(value); }
function connectionTag(value: unknown): "success" | "warning" | "danger" | "info" { return value === "AVAILABLE" ? "success" : ["CONFIGURATION_REQUIRED","MATERIALS_REQUIRED"].includes(String(value)) ? "warning" : "danger"; }
function missionLabel(value: unknown) { return ({ DISPATCHING: "下发中", DISPATCHED: "已下发", PREPARING: "准备中", FLYING: "飞行中", PAUSED: "已暂停", RETURNING: "返航中", UPLOADING: "数据回传", COMPLETED: "已完成", CANCELLED: "已取消", FAILED: "失败" } as Record<string, string>)[String(value)] || String(value); }
function missionTag(value: unknown): "success" | "warning" | "danger" | "info" | "primary" { if (value === "COMPLETED") return "success"; if (["FAILED","CANCELLED"].includes(String(value))) return "danger"; if (["FLYING","RETURNING"].includes(String(value))) return "primary"; if (value === "PAUSED") return "warning"; return "info"; }
function formatDate(value: unknown) { return value ? new Date(String(value)).toLocaleString("zh-CN", { hour12: false }) : "-"; } function timeOnly(value: unknown) { return value ? new Date(String(value)).toLocaleTimeString("zh-CN", { hour12: false }) : "-"; }
function layerLabel(value: unknown) { return ({ NETWORK: "网络可达", AUTHENTICATION: "身份认证", CAPABILITIES: "能力读取", BUSINESS_HANDSHAKE: "业务握手" } as Record<string, string>)[String(value)] || String(value); }
function telemetrySummary(value: unknown) { const data = safeObject(value); return `电量 ${data.battery_percent ?? '-'}% · 链路 ${data.link_quality ?? '-'}% · RTK ${data.rtk_status || '-'}`; }
async function load() { loading.value = true; try { [connections.value, devices.value, missions.value, taskRows.value, routeRows.value] = await Promise.all([uavConnections(), uavDevices(), uavMissions(), tasks(), inspectionRoutes()]); if (!dispatchForm.task_id && taskRows.value.length) dispatchForm.task_id = String(taskRows.value[0].task_id); selectConnection(); } finally { loading.value = false; } }
function selectConnection() { const first = devices.value.find((item) => item.connection_id === dispatchForm.connection_id && item.status === "ONLINE"); dispatchForm.device_id = first ? String(first.device_id) : ""; }
async function load() { loading.value = true; try { [connections.value, devices.value, missions.value, taskRows.value] = await Promise.all([uavConnections(), uavDevices(), uavMissions(), tasks()]); if (!dispatchForm.task_id && dispatchableTasks.value.length) dispatchForm.task_id = String(dispatchableTasks.value[0].task_id); selectTask(); if (!usableConnections.value.some((item) => item.connection_id === dispatchForm.connection_id)) dispatchForm.connection_id = String(usableConnections.value[0]?.connection_id || ""); selectConnection(); } finally { loading.value = false; } }
function selectTask() { dispatchForm.route_id = String(selectedTask.value?.route_id || ""); }
function selectConnection() { const first = devices.value.find((item) => item.connection_id === dispatchForm.connection_id && item.status === "ONLINE"); dispatchForm.device_id = first ? String(first.device_id) : ""; dispatchForm.dock_id = ""; }
function openDispatchDialog(taskId?: string) { const target = taskId ? dispatchableTasks.value.find((item) => String(item.task_id) === taskId) : dispatchableTasks.value[0]; if (!target) return ElMessage.warning(taskId ? "该任务当前不可下发,请返回巡检任务查看准备状态" : "当前没有满足审批、航线和状态条件的待下发任务"); dispatchForm.task_id = String(target.task_id); selectTask(); activeTab.value = "missions"; dispatchDialog.value = true; }
async function checkConnection(row: Row) { healthResult.value = await checkUavConnection(String(row.connection_id)); healthVisible.value = true; await load(); }
async function syncDevices(row: Row) { const result = await synchronizeUavDevices(String(row.connection_id)); ElMessage.success(`已同步 ${result.synchronized} 台设备`); await load(); activeTab.value = "devices"; }
async function dispatch() { if (!dispatchForm.task_id || !dispatchForm.route_id || !dispatchForm.device_id) return ElMessage.warning("请选择任务、航线和无人机"); saving.value = true; try { const result = await dispatchUavMission({ ...dispatchForm, requested_by: "user-dispatcher", idempotency_key: `dispatch-${dispatchForm.task_id}-${Date.now()}` }); ElMessage.success(`任务已下发:${result.vendor_mission_id}`); dispatchDialog.value = false; activeTab.value = "missions"; await load(); } finally { saving.value = false; } }
async function command(row: Row, action: string) { const result = await executeUavMissionCommand(String(row.mission_id), action, Number(row.version_no)); ElMessage.success(`命令完成,任务状态:${missionLabel(result.status)}`); await load(); }
async function command(row: Row, action: string) { const result = await executeUavMissionCommand(String(row.mission_id), action, Number(row.version_no)); ElMessage.success(result.accepted ? `命令已受理,等待司空 2 状态回传:${missionLabel(result.status)}` : `命令完成,任务状态:${missionLabel(result.status)}`); await load(); }
async function inspectMission(row: Row) { selectedMission.value = row; telemetry.value = await uavMissionTelemetry(String(row.mission_id)); missionVisible.value = true; }
onMounted(load);
onMounted(async () => {
await load();
if (deepLinkHandled.value) return;
deepLinkHandled.value = true;
if (route.query.dispatchTaskId) openDispatchDialog(String(route.query.dispatchTaskId));
else if (route.query.missionId) {
activeTab.value = "missions";
const mission = missions.value.find((item) => String(item.mission_id) === String(route.query.missionId));
if (mission) await inspectMission(mission);
}
});
</script>
+59
View File
@@ -0,0 +1,59 @@
# Copy to .env.flighthub2. Do not commit the real file.
COMPOSE_PROJECT_NAME=rail-inspection
# Existing platform infrastructure
POSTGRES_PASSWORD=replace-with-a-strong-password
MINIO_ROOT_USER=replace-with-minio-access-key
MINIO_ROOT_PASSWORD=replace-with-minio-secret-key
ARTIFACT_INSTALLER_TOKEN=replace-with-artifact-installer-token
# Internal service authentication; use the same value for backend and uav-access-service
UAV_ACCESS_INTERNAL_TOKEN=replace-with-at-least-32-random-characters
UAV_ACCESS_EVENT_TOPIC=uav.access.events.v1
UAV_ACCESS_EVENT_CONSUMER_GROUP=rail-platform-uav-access-v1
UAV_ACCESS_OUTBOX_RELAY_DELAY_MS=5000
# FlightHub 2 private deployment OpenAPI
FLIGHTHUB_BASE_URL=https://flighthub2.example.internal
FLIGHTHUB_USER_TOKEN_HOST_FILE=./secrets/flighthub-user-token
FLIGHTHUB_ORGANIZATION_UUID=replace-after-private-deployment
FLIGHTHUB_PROJECT_UUID=replace-after-project-creation
FLIGHTHUB_CONNECT_TIMEOUT_SECONDS=5
FLIGHTHUB_READ_TIMEOUT_SECONDS=30
FLIGHTHUB_ALLOW_INSECURE_HTTP=false
# Safety gate: keep false until topology, route import, precheck, and event callbacks pass
FLIGHTHUB_FLIGHT_CONTROL_ENABLED=false
# Object storage returned by the FlightHub STS endpoint
FLIGHTHUB_STORAGE_PATH_STYLE_ACCESS=true
# Confirm these values using the deployed aircraft/payload and FlightHub WPML compatibility table
FLIGHTHUB_WPML_VERSION=1.0.6
FLIGHTHUB_WPML_AUTHOR=AItrackwalker
FLIGHTHUB_WPML_DRONE_ENUM_VALUE=replace-after-device-confirmation
FLIGHTHUB_WPML_DRONE_SUB_ENUM_VALUE=replace-after-device-confirmation
FLIGHTHUB_WPML_PAYLOAD_ENUM_VALUE=replace-after-payload-confirmation
FLIGHTHUB_WPML_PAYLOAD_SUB_ENUM_VALUE=replace-after-payload-confirmation
# Mission policy
FLIGHTHUB_MISSION_TIME_ZONE=Asia/Shanghai
FLIGHTHUB_MISSION_MIN_BATTERY_PERCENT=30
FLIGHTHUB_MISSION_RESUMABLE_STATUS=1
FLIGHTHUB_MISSION_OUT_OF_CONTROL_ACTION=0
FLIGHTHUB_MISSION_RTH_ALTITUDE_M=100
FLIGHTHUB_MISSION_DEVICE_TYPE=
# EventAPI callback shared token used by the current adapter
FLIGHTHUB_EVENT_CALLBACK_TOKEN=replace-with-at-least-32-random-characters
# MQTT Bridge / EMQX
FLIGHTHUB_MQTT_ENABLED=true
FLIGHTHUB_MQTT_BROKER_URL=tcp://emqx:1883
FLIGHTHUB_MQTT_CLIENT_ID=aitrackwalker-uav-access
FLIGHTHUB_MQTT_USERNAME=flighthub2_bridge
FLIGHTHUB_MQTT_PASSWORD=replace-with-a-strong-password
FLIGHTHUB_MQTT_TOPIC_FILTER=flighthub2/#
FLIGHTHUB_MQTT_QOS=1
EMQX_DASHBOARD_USERNAME=admin
EMQX_DASHBOARD_PASSWORD=replace-with-a-strong-password
+53
View File
@@ -0,0 +1,53 @@
services:
uav-access-service:
depends_on:
emqx:
condition: service_healthy
environment:
SPRING_DATASOURCE_PASSWORD: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}"
UAV_ACCESS_INTERNAL_TOKEN: "${UAV_ACCESS_INTERNAL_TOKEN:?UAV_ACCESS_INTERNAL_TOKEN must be set}"
UAV_ACCESS_EVENT_TOPIC: "${UAV_ACCESS_EVENT_TOPIC:-uav.access.events.v1}"
UAV_ACCESS_OUTBOX_RELAY_DELAY_MS: "${UAV_ACCESS_OUTBOX_RELAY_DELAY_MS:-5000}"
FLIGHTHUB_ENABLED: "true"
FLIGHTHUB_BASE_URL: "${FLIGHTHUB_BASE_URL:?FLIGHTHUB_BASE_URL must be set}"
FLIGHTHUB_USER_TOKEN_FILE: /run/secrets/flighthub-user-token
FLIGHTHUB_ORGANIZATION_UUID: "${FLIGHTHUB_ORGANIZATION_UUID:?FLIGHTHUB_ORGANIZATION_UUID must be set}"
FLIGHTHUB_PROJECT_UUID: "${FLIGHTHUB_PROJECT_UUID:?FLIGHTHUB_PROJECT_UUID must be set}"
FLIGHTHUB_CONNECT_TIMEOUT_SECONDS: "${FLIGHTHUB_CONNECT_TIMEOUT_SECONDS:-5}"
FLIGHTHUB_READ_TIMEOUT_SECONDS: "${FLIGHTHUB_READ_TIMEOUT_SECONDS:-30}"
FLIGHTHUB_ALLOW_INSECURE_HTTP: "${FLIGHTHUB_ALLOW_INSECURE_HTTP:-false}"
FLIGHTHUB_FLIGHT_CONTROL_ENABLED: "${FLIGHTHUB_FLIGHT_CONTROL_ENABLED:-false}"
FLIGHTHUB_STORAGE_PATH_STYLE_ACCESS: "${FLIGHTHUB_STORAGE_PATH_STYLE_ACCESS:-true}"
FLIGHTHUB_WPML_VERSION: "${FLIGHTHUB_WPML_VERSION:-1.0.6}"
FLIGHTHUB_WPML_AUTHOR: "${FLIGHTHUB_WPML_AUTHOR:-AItrackwalker}"
FLIGHTHUB_WPML_DRONE_ENUM_VALUE: "${FLIGHTHUB_WPML_DRONE_ENUM_VALUE:?FLIGHTHUB_WPML_DRONE_ENUM_VALUE must be confirmed}"
FLIGHTHUB_WPML_DRONE_SUB_ENUM_VALUE: "${FLIGHTHUB_WPML_DRONE_SUB_ENUM_VALUE:?FLIGHTHUB_WPML_DRONE_SUB_ENUM_VALUE must be confirmed}"
FLIGHTHUB_WPML_PAYLOAD_ENUM_VALUE: "${FLIGHTHUB_WPML_PAYLOAD_ENUM_VALUE:?FLIGHTHUB_WPML_PAYLOAD_ENUM_VALUE must be confirmed}"
FLIGHTHUB_WPML_PAYLOAD_SUB_ENUM_VALUE: "${FLIGHTHUB_WPML_PAYLOAD_SUB_ENUM_VALUE:?FLIGHTHUB_WPML_PAYLOAD_SUB_ENUM_VALUE must be confirmed}"
FLIGHTHUB_MISSION_TIME_ZONE: "${FLIGHTHUB_MISSION_TIME_ZONE:-Asia/Shanghai}"
FLIGHTHUB_MISSION_MIN_BATTERY_PERCENT: "${FLIGHTHUB_MISSION_MIN_BATTERY_PERCENT:-30}"
FLIGHTHUB_MISSION_RESUMABLE_STATUS: "${FLIGHTHUB_MISSION_RESUMABLE_STATUS:-1}"
FLIGHTHUB_MISSION_OUT_OF_CONTROL_ACTION: "${FLIGHTHUB_MISSION_OUT_OF_CONTROL_ACTION:-0}"
FLIGHTHUB_MISSION_RTH_ALTITUDE_M: "${FLIGHTHUB_MISSION_RTH_ALTITUDE_M:-100}"
FLIGHTHUB_MISSION_DEVICE_TYPE: "${FLIGHTHUB_MISSION_DEVICE_TYPE:-}"
FLIGHTHUB_EVENT_CALLBACK_TOKEN: "${FLIGHTHUB_EVENT_CALLBACK_TOKEN:?FLIGHTHUB_EVENT_CALLBACK_TOKEN must be set}"
FLIGHTHUB_MQTT_ENABLED: "${FLIGHTHUB_MQTT_ENABLED:-true}"
FLIGHTHUB_MQTT_BROKER_URL: "${FLIGHTHUB_MQTT_BROKER_URL:-tcp://emqx:1883}"
FLIGHTHUB_MQTT_CLIENT_ID: "${FLIGHTHUB_MQTT_CLIENT_ID:-aitrackwalker-uav-access}"
FLIGHTHUB_MQTT_USERNAME: "${FLIGHTHUB_MQTT_USERNAME:?FLIGHTHUB_MQTT_USERNAME must be set}"
FLIGHTHUB_MQTT_PASSWORD: "${FLIGHTHUB_MQTT_PASSWORD:?FLIGHTHUB_MQTT_PASSWORD must be set}"
FLIGHTHUB_MQTT_TOPIC_FILTER: "${FLIGHTHUB_MQTT_TOPIC_FILTER:-flighthub2/#}"
FLIGHTHUB_MQTT_QOS: "${FLIGHTHUB_MQTT_QOS:-1}"
volumes:
- "${FLIGHTHUB_USER_TOKEN_HOST_FILE:-./secrets/flighthub-user-token}:/run/secrets/flighthub-user-token:ro"
backend:
environment:
UAV_ACCESS_INTERNAL_TOKEN: "${UAV_ACCESS_INTERNAL_TOKEN:?UAV_ACCESS_INTERNAL_TOKEN must be set}"
UAV_ACCESS_EVENT_TOPIC: "${UAV_ACCESS_EVENT_TOPIC:-uav.access.events.v1}"
UAV_ACCESS_EVENT_CONSUMER_GROUP: "${UAV_ACCESS_EVENT_CONSUMER_GROUP:-rail-platform-uav-access-v1}"
emqx:
environment:
EMQX_DASHBOARD__DEFAULT_USERNAME: "${EMQX_DASHBOARD_USERNAME:?EMQX_DASHBOARD_USERNAME must be set}"
EMQX_DASHBOARD__DEFAULT_PASSWORD: "${EMQX_DASHBOARD_PASSWORD:?EMQX_DASHBOARD_PASSWORD must be set}"
+24 -2
View File
@@ -4,7 +4,13 @@ services:
context: ../ai-services/vision-inference
dockerfile: Dockerfile.server
restart: unless-stopped
gpus: all
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
environment:
RAIL_RUNTIME_PROFILE: server-gpu
RAIL_MODEL_REGISTRY: /app/config/model-registry.json
@@ -31,7 +37,13 @@ services:
context: ../ai-services/pointcloud-analysis
dockerfile: Dockerfile.server
restart: unless-stopped
gpus: all
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
environment:
RAIL_RUNTIME_PROFILE: server-gpu
RAIL_MODEL_REGISTRY: /app/config/model-registry.json
@@ -66,6 +78,12 @@ services:
- ./model-registry/server-models.json:/app/config/model-registry.json:ro
mem_limit: "${ARTIFACT_INSTALLER_MEMORY_LIMIT:-4g}"
uav-access-service:
restart: unless-stopped
environment:
SPRING_DATASOURCE_PASSWORD: "${POSTGRES_PASSWORD:-rail}"
mem_limit: "${UAV_ACCESS_MEMORY_LIMIT:-2g}"
backend:
restart: unless-stopped
environment:
@@ -94,6 +112,10 @@ services:
restart: unless-stopped
mem_limit: "${KAFKA_MEMORY_LIMIT:-4g}"
emqx:
restart: unless-stopped
mem_limit: "${EMQX_MEMORY_LIMIT:-2g}"
minio:
restart: unless-stopped
environment:
+51
View File
@@ -48,6 +48,26 @@ services:
timeout: 10s
retries: 12
emqx:
image: swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/emqx/emqx:5.8.4
container_name: rail-emqx
profiles: ["flighthub2"]
environment:
EMQX_DASHBOARD__DEFAULT_USERNAME: "${EMQX_DASHBOARD_USERNAME:-admin}"
EMQX_DASHBOARD__DEFAULT_PASSWORD: "${EMQX_DASHBOARD_PASSWORD:-change-me-before-production}"
ports:
- "1883:1883"
- "8883:8883"
- "18083:18083"
volumes:
- emqx-data:/opt/emqx/data
- emqx-log:/opt/emqx/log
healthcheck:
test: ["CMD", "/opt/emqx/bin/emqx", "ctl", "status"]
interval: 10s
timeout: 10s
retries: 20
minio:
image: swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/minio/minio:RELEASE.2024-10-13T13-34-11Z
container_name: rail-minio
@@ -115,6 +135,31 @@ services:
timeout: 5s
retries: 10
uav-access-service:
build:
context: ../uav-access-service
container_name: rail-uav-access
depends_on:
postgres:
condition: service_healthy
kafka:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/rail_inspection
SPRING_DATASOURCE_USERNAME: rail
SPRING_DATASOURCE_PASSWORD: rail
SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092
UAV_ACCESS_INTERNAL_TOKEN: "${UAV_ACCESS_INTERNAL_TOKEN:-local-uav-access-token}"
FLIGHTHUB_ENABLED: "false"
FLIGHTHUB_MQTT_ENABLED: "false"
ports:
- "8091:8091"
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8091/actuator/health || exit 1"]
interval: 15s
timeout: 5s
retries: 20
backend:
build:
context: ../platform/backend
@@ -132,6 +177,8 @@ services:
condition: service_healthy
artifact-installer:
condition: service_healthy
uav-access-service:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/rail_inspection
SPRING_DATASOURCE_USERNAME: rail
@@ -144,6 +191,8 @@ services:
MINIO_ARTIFACT_BUCKET: rail-model-artifacts
RAIL_ARTIFACT_INSTALLER_URL: http://artifact-installer:8103
RAIL_ARTIFACT_INSTALLER_TOKEN: local-artifact-token
UAV_ACCESS_SERVICE_URL: http://uav-access-service:8091
UAV_ACCESS_INTERNAL_TOKEN: "${UAV_ACCESS_INTERNAL_TOKEN:-local-uav-access-token}"
ports:
- "8080:8080"
healthcheck:
@@ -173,3 +222,5 @@ services:
volumes:
postgres-data:
minio-data:
emqx-data:
emqx-log:
+5
View File
@@ -6,3 +6,8 @@ scrape_configs:
metrics_path: /actuator/prometheus
static_configs:
- targets: ["backend:8080"]
- job_name: "rail-uav-access"
metrics_path: /actuator/prometheus
static_configs:
- targets: ["uav-access-service:8091"]
+12
View File
@@ -0,0 +1,12 @@
__pycache__/
*.py[cod]
.pytest_cache/
.coverage
.venv/
dist/
build/
*.egg-info/
runs/
artifacts/
cache/
.env
+190
View File
@@ -0,0 +1,190 @@
# AItrackwalker 本地模型训练项目
这是从主工程中独立出来的本地训练工作台,面向单张 RTX 3090(24 GB)组织
AItrackwalker 已规划的检测、分割、变化检测、点云分割和红外异常检测训练。
项目本身只使用 Python 标准库,可以直接运行。具体模型仍由
PaddleDetection、PaddleSeg、Open-CD、Pointcept 或 Anomalib 执行;本项目负责:
- 用 TOML 统一保存训练命令和任务元数据;
- 以参数数组启动外部训练进程,不经过 shell 拼接;
- 汇集 stdout/stderr、Loss、mAP、mIoU、Accuracy 和学习率;
- 周期采集 RTX 3090 利用率、显存、温度和功耗;
- 将每次任务保存为可追踪的本地运行记录;
- 提供无需 Node.js、无需数据库的浏览器训练看板。
完整的模型选型、3090 容量估算、WSL2/CUDA 环境和数据规范见
[本地 RTX 3090 模型微调训练环境方案](../docs/local-rtx3090-model-finetuning-environment-plan.md)。
## 目录结构
```text
model-training/
├─ configs/ # 各模型的启动模板
├─ railtrain/ # 任务运行、采集、存储和看板服务
│ └─ static/ # 原生 HTML/CSS/JS 可视化页面
├─ scripts/ # Windows / WSL 快捷启动脚本
├─ tests/ # 标准库单元测试
├─ runs/ # 运行后生成,已加入 .gitignore
├─ pyproject.toml
└─ README.md
```
`runs/<任务 ID>/` 中会生成:
```text
run.json # 配置、状态、起止时间和退出码
system.json # Python、系统及 GPU 快照
metrics.jsonl # 训练指标与 GPU 时序数据
train.log # 原始训练日志
```
## 先看一眼:演示任务和看板
在 Windows PowerShell 中:
```powershell
cd D:\workspace\AItrackwalker\model-training
python -m railtrain doctor
python -m railtrain demo --steps 160
python -m railtrain serve --host 127.0.0.1 --port 8090
```
在 WSL2 中:
```bash
cd /mnt/d/workspace/AItrackwalker/model-training
python -m railtrain doctor
python -m railtrain demo --steps 160
python -m railtrain serve --host 127.0.0.1 --port 8090
```
然后打开 `http://127.0.0.1:8090`。演示任务只生成具有合理趋势的模拟指标,
用于确认存储和可视化链路,不代表真实模型精度,也不会占用 GPU 训练。
也可以运行快捷脚本:
```powershell
.\scripts\start-dashboard.ps1
```
```bash
./scripts/start-dashboard.sh
```
看板每 4 秒自动刷新,展示任务状态、当前 Epoch/Step、Loss、主质量指标、
GPU 利用率、显存、温度、元数据和最近日志。页面和数据都只在本机提供。
## 开始真实训练
推荐把框架源码、数据、权重和输出统一放在 WSL2 的 Linux 文件系统中:
```text
~/rail-ai/
├─ src/ # PaddleDetection、PaddleSeg、Open-CD、Pointcept、Anomalib
├─ data/ # 版本化后的训练数据
├─ weights/ # 预训练权重
├─ configs/ # 项目专用的框架配置
└─ runs/ # 框架原始训练产物
```
设置根目录并先预览命令:
```bash
export RAIL_AI_HOME="$HOME/rail-ai"
cd /mnt/d/workspace/AItrackwalker/model-training
python -m railtrain run configs/ppyoloe-sod.toml --dry-run
```
确认以下三项后,去掉 `--dry-run` 即开始训练并由看板采集:
1. `RAIL_AI_HOME` 指向真实训练根目录;
2. TOML 的 `working_dir` 已存在;
3. 模型配置和数据集路径已按主方案创建。
```bash
python -m railtrain run configs/ppyoloe-sod.toml
```
另开一个终端启动看板:
```bash
cd /mnt/d/workspace/AItrackwalker/model-training
python -m railtrain serve
```
如果运行记录放在其他磁盘,所有命令都使用同一个全局参数:
```bash
python -m railtrain --runs-dir /data/rail-ai/run-records run configs/ppyoloe-sod.toml
python -m railtrain --runs-dir /data/rail-ai/run-records serve
```
## 已提供的模型启动模板
| 模板 | 场景 | 框架 | 主指标 |
|---|---|---|---|
| `ppyoloe-sod.toml` | 无人机铁路小目标检测 | PaddleDetection | mAP |
| `pp-liteseg.toml` | 钢轨、道床、边坡等区域分割 | PaddleSeg | mIoU |
| `bit-r18.toml` | 双时相变化检测基线 | Open-CD | mIoU |
| `changer-r18.toml` | 双时相变化检测候选 | Open-CD | mIoU |
| `point-transformer-v3.toml` | 铁路点云语义分割 | Pointcept | mIoU |
| `patchcore.toml` | 红外设备异常定位 | Anomalib | Accuracy |
这些 TOML 是“启动适配层”,不会替代各框架自己的 YAML/Python 配置。比如
`configs/rail/ppyoloe_plus_sod_crn_s_80e_rail.yml` 应位于 PaddleDetection
源码目录中,并按实际 COCO 数据路径、类别数、预训练权重和 3090 批量大小调整。
建议的落地顺序是:
1. PP-YOLOE-SOD-s 小目标检测;
2. PP-LiteSeg-STDC1 图像分割;
3. BIT-R18,再训练 Changer-R18 做变化检测对照;
4. Point Transformer V3 点云分割;
5. PatchCore 红外异常检测。
## 新增或适配训练任务
复制一个 TOML 并修改 `[run]`
```toml
[run]
name = "自定义训练"
task = "custom"
framework = "PyTorch"
working_dir = "${RAIL_AI_HOME}/src/my-model"
command = ["python", "train.py", "--config", "configs/rail.yaml"]
tags = ["custom", "rtx3090"]
[run.environment]
CUDA_VISIBLE_DEVICES = "0"
PYTHONUNBUFFERED = "1"
[display]
primary_metric = "miou"
higher_is_better = true
```
训练程序输出常见的 `loss=...``lr=...``mAP=...``mIoU=...`
`accuracy=...``epoch=...``step=...` 即可自动画曲线。若框架日志格式特殊,
`railtrain/metrics.py` 中增加正则适配即可。
## 命令速查
```text
python -m railtrain doctor
python -m railtrain demo [--steps 160] [--interval 0.02]
python -m railtrain list
python -m railtrain run <config.toml> [--dry-run] [--gpu-interval 2]
python -m railtrain serve [--host 127.0.0.1] [--port 8090]
```
全局 `--runs-dir` 要放在子命令之前。项目不包含数据集、第三方框架源码和模型权重,
以避免训练数据与主仓库代码耦合。
## 验证
```bash
python -m unittest discover -s tests -v
python -m compileall -q railtrain
```
+22
View File
@@ -0,0 +1,22 @@
[run]
name = "BIT-R18 铁路双时相变化检测"
task = "change-detector"
framework = "Open-CD"
description = "以 512×512 配准影像建立铁路双时相变化检测基线。"
working_dir = "${RAIL_AI_HOME}/src/open-cd"
tags = ["change-detection", "pytorch", "opencd", "rtx3090"]
command = [
"python",
"tools/train.py",
"configs/rail/bit_r18_512x512_rail.py",
"--work-dir",
"${RAIL_AI_HOME}/runs/change/bit_r18_v1"
]
[run.environment]
CUDA_VISIBLE_DEVICES = "0"
PYTHONUNBUFFERED = "1"
[display]
primary_metric = "miou"
higher_is_better = true
+22
View File
@@ -0,0 +1,22 @@
[run]
name = "Changer-R18 铁路变化检测"
task = "change-detector"
framework = "Open-CD"
description = "在 BIT-R18 基线之后训练 Changer-R18 生产候选。"
working_dir = "${RAIL_AI_HOME}/src/open-cd"
tags = ["change-detection", "pytorch", "opencd", "rtx3090"]
command = [
"python",
"tools/train.py",
"configs/rail/changer_ex_r18_512x512_rail.py",
"--work-dir",
"${RAIL_AI_HOME}/runs/change/changer_r18_v1"
]
[run.environment]
CUDA_VISIBLE_DEVICES = "0"
PYTHONUNBUFFERED = "1"
[display]
primary_metric = "miou"
higher_is_better = true
+21
View File
@@ -0,0 +1,21 @@
[run]
name = "PatchCore 红外异常检测"
task = "thermal-analyzer"
framework = "Anomalib"
description = "按设备类型建立正常样本 memory bank,并评估红外异常定位。"
working_dir = "${RAIL_AI_HOME}/src/anomalib"
tags = ["thermal", "anomaly", "patchcore", "rtx3090"]
command = [
"anomalib",
"train",
"--config",
"${RAIL_AI_HOME}/configs/rail/patchcore_thermal_v1.yaml"
]
[run.environment]
CUDA_VISIBLE_DEVICES = "0"
PYTHONUNBUFFERED = "1"
[display]
primary_metric = "accuracy"
higher_is_better = true
@@ -0,0 +1,27 @@
[run]
name = "Point Transformer V3 铁路点云分割"
task = "pointcloud-analyzer"
framework = "Pointcept"
description = "使用单卡、小批次和铁路点云切块微调 Point Transformer V3。"
working_dir = "${RAIL_AI_HOME}/src/PointTransformerV3/Pointcept"
tags = ["pointcloud", "pointcept", "pytorch", "rtx3090"]
command = [
"python",
"tools/train.py",
"--config-file",
"configs/rail/semseg-pt-v3m1-rail.py",
"--num-gpus",
"1",
"--options",
"save_path=${RAIL_AI_HOME}/runs/pointcloud/ptv3_v1",
"enable_wandb=False"
]
[run.environment]
CUDA_VISIBLE_DEVICES = "0"
PYTHONPATH = "./"
PYTHONUNBUFFERED = "1"
[display]
primary_metric = "miou"
higher_is_better = true
+29
View File
@@ -0,0 +1,29 @@
[run]
name = "PP-LiteSeg-STDC1 铁路语义分割"
task = "vision-segmenter"
framework = "PaddleSeg"
description = "训练钢轨、道床、边坡、积水和裂缝区域分割模型。"
working_dir = "${RAIL_AI_HOME}/src/PaddleSeg"
tags = ["segmentation", "paddle", "rtx3090"]
command = [
"python",
"tools/train.py",
"--config",
"configs/rail/pp_liteseg_stdc1_rail.yml",
"--save_dir",
"${RAIL_AI_HOME}/runs/segmentation/pp_liteseg_stdc1_v1",
"--save_interval",
"1000",
"--num_workers",
"8",
"--do_eval",
"--use_vdl"
]
[run.environment]
CUDA_VISIBLE_DEVICES = "0"
PYTHONUNBUFFERED = "1"
[display]
primary_metric = "miou"
higher_is_better = true
+24
View File
@@ -0,0 +1,24 @@
[run]
name = "PP-YOLOE-SOD-s 铁路小目标检测"
task = "vision-detector"
framework = "PaddleDetection"
description = "使用铁路无人机 COCO 数据微调 PP-YOLOE-SOD-s,默认启用单卡 AMP。"
working_dir = "${RAIL_AI_HOME}/src/PaddleDetection"
tags = ["detection", "paddle", "small-object", "rtx3090"]
command = [
"python",
"tools/train.py",
"-c",
"configs/rail/ppyoloe_plus_sod_crn_s_80e_rail.yml",
"--amp",
"--eval"
]
[run.environment]
CUDA_VISIBLE_DEVICES = "0"
FLAGS_cudnn_deterministic = "True"
PYTHONUNBUFFERED = "1"
[display]
primary_metric = "map"
higher_is_better = true
+25
View File
@@ -0,0 +1,25 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "ai-trackwalker-model-training"
version = "0.1.0"
description = "Local training workbench and lightweight visualization dashboard for AItrackwalker."
readme = "README.md"
requires-python = ">=3.10"
license = { text = "Proprietary" }
authors = [
{ name = "AItrackwalker" }
]
dependencies = []
[project.scripts]
railtrain = "railtrain.cli:main"
[tool.setuptools.packages.find]
where = ["."]
include = ["railtrain*"]
[tool.setuptools.package-data]
railtrain = ["static/*.html", "static/*.css", "static/*.js"]
+3
View File
@@ -0,0 +1,3 @@
"""AItrackwalker local model training workbench."""
__version__ = "0.1.0"
+5
View File
@@ -0,0 +1,5 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
import argparse
import json
import os
import shlex
from pathlib import Path
from .config import ConfigError, load_config
from .dashboard import serve
from .gpu import collect_system
from .runner import TrainingRunner, create_demo_run
from .store import RunStore
PROJECT_ROOT = Path(__file__).resolve().parent.parent
def default_runs_dir() -> Path:
configured = os.environ.get("RAIL_TRAIN_RUNS")
return Path(configured).expanduser() if configured else PROJECT_ROOT / "runs"
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="railtrain",
description="AItrackwalker 本地模型训练工作台",
)
parser.add_argument(
"--runs-dir",
type=Path,
default=default_runs_dir(),
help="训练任务记录目录,默认 model-training/runs",
)
subparsers = parser.add_subparsers(dest="command", required=True)
demo = subparsers.add_parser("demo", help="生成一条带曲线的演示训练任务")
demo.add_argument("--steps", type=int, default=160)
demo.add_argument("--interval", type=float, default=0.02)
demo.add_argument("--seed", type=int, default=42)
dashboard = subparsers.add_parser("serve", help="启动本地训练可视化看板")
dashboard.add_argument("--host", default="127.0.0.1")
dashboard.add_argument("--port", type=int, default=8090)
run = subparsers.add_parser("run", help="按 TOML 配置启动真实训练命令")
run.add_argument("config", type=Path)
run.add_argument("--dry-run", action="store_true")
run.add_argument("--gpu-interval", type=float, default=2.0)
subparsers.add_parser("list", help="列出本地训练任务")
subparsers.add_parser("doctor", help="检查 Python、GPU 和目录状态")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
store = RunStore(args.runs_dir)
try:
if args.command == "demo":
run_id = create_demo_run(
store,
steps=args.steps,
interval=args.interval,
seed=args.seed,
)
print(f"演示任务已生成: {run_id}")
print(f"启动看板: python -m railtrain --runs-dir {store.root} serve")
return 0
if args.command == "serve":
serve(store, host=args.host, port=args.port)
return 0
if args.command == "run":
config = load_config(args.config, strict=not args.dry_run)
if args.dry_run:
resolved = config.resolved(strict=False)
print(json.dumps(resolved.as_dict(), ensure_ascii=False, indent=2))
print("命令:", shlex.join(resolved.command))
return 0
run_id = TrainingRunner(
store,
gpu_interval=args.gpu_interval,
).execute(config)
manifest = store.read_manifest(run_id)
print(f"训练任务结束: {run_id} ({manifest['status']})")
return 0 if manifest["status"] == "completed" else 1
if args.command == "list":
runs = store.list_runs()
if not runs:
print("还没有训练任务。运行 `python -m railtrain demo` 创建演示。")
return 0
for item in runs:
print(
f"{item.get('id', '-'):<64} "
f"{item.get('status', '-'):<10} "
f"{item.get('task', '-'):<20} "
f"{item.get('name', '-')}"
)
return 0
if args.command == "doctor":
report = collect_system()
report["project_root"] = str(PROJECT_ROOT)
report["runs_dir"] = str(store.root)
report["configs"] = [
str(path)
for path in sorted((PROJECT_ROOT / "configs").glob("*.toml"))
]
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0 if report.get("gpu") else 2
except (ConfigError, FileNotFoundError, OSError) as exc:
parser.error(str(exc))
return 1
if __name__ == "__main__":
raise SystemExit(main())
+154
View File
@@ -0,0 +1,154 @@
from __future__ import annotations
import os
import re
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
_ENV_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
class ConfigError(ValueError):
"""Raised when a run configuration is invalid."""
def _expand(value: str, *, strict: bool) -> str:
missing: set[str] = set()
def replace(match: re.Match[str]) -> str:
name = match.group(1)
resolved = os.environ.get(name)
if resolved is None:
missing.add(name)
return match.group(0)
return resolved
expanded = _ENV_PATTERN.sub(replace, value)
if strict and missing:
names = ", ".join(sorted(missing))
raise ConfigError(f"未设置配置所需环境变量: {names}")
return str(Path(expanded).expanduser()) if expanded.startswith("~") else expanded
def _expand_path(value: str, *, strict: bool) -> str:
return os.path.normpath(_expand(value, strict=strict))
def _expand_argument(value: str, *, strict: bool) -> str:
expanded = _expand(value, strict=strict)
if not (_ENV_PATTERN.search(value) or value.startswith("~")):
return expanded
if "=" in expanded:
prefix, path_value = expanded.split("=", 1)
return f"{prefix}={os.path.normpath(path_value)}"
return os.path.normpath(expanded)
@dataclass(slots=True)
class RunConfig:
name: str
task: str
framework: str
working_dir: str
command: list[str]
description: str = ""
tags: list[str] = field(default_factory=list)
environment: dict[str, str] = field(default_factory=dict)
primary_metric: str = "loss"
higher_is_better: bool = False
source_path: Path | None = None
def resolved(self, *, strict: bool = True) -> "RunConfig":
return RunConfig(
name=self.name,
task=self.task,
framework=self.framework,
working_dir=_expand_path(self.working_dir, strict=strict),
command=[
_expand_argument(item, strict=strict)
for item in self.command
],
description=self.description,
tags=list(self.tags),
environment={
key: _expand(value, strict=strict)
for key, value in self.environment.items()
},
primary_metric=self.primary_metric,
higher_is_better=self.higher_is_better,
source_path=self.source_path,
)
def as_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"task": self.task,
"framework": self.framework,
"working_dir": self.working_dir,
"command": list(self.command),
"description": self.description,
"tags": list(self.tags),
"environment": dict(self.environment),
"primary_metric": self.primary_metric,
"higher_is_better": self.higher_is_better,
"source_path": str(self.source_path) if self.source_path else None,
}
def load_config(path: str | Path, *, strict: bool = False) -> RunConfig:
source = Path(path).expanduser().resolve()
if not source.is_file():
raise ConfigError(f"训练配置不存在: {source}")
with source.open("rb") as handle:
data = tomllib.load(handle)
run = data.get("run")
if not isinstance(run, dict):
raise ConfigError("配置必须包含 [run] 表")
required = ("name", "task", "framework", "working_dir", "command")
missing = [key for key in required if key not in run]
if missing:
raise ConfigError(f"[run] 缺少字段: {', '.join(missing)}")
command = run["command"]
if not isinstance(command, list) or not command or not all(
isinstance(item, str) and item for item in command
):
raise ConfigError("[run].command 必须是非空字符串数组")
environment = run.get("environment", {})
if not isinstance(environment, dict) or not all(
isinstance(key, str) and isinstance(value, str)
for key, value in environment.items()
):
raise ConfigError("[run].environment 必须是字符串键值表")
tags = run.get("tags", [])
if not isinstance(tags, list) or not all(isinstance(item, str) for item in tags):
raise ConfigError("[run].tags 必须是字符串数组")
display = data.get("display", {})
if not isinstance(display, dict):
raise ConfigError("[display] 必须是表")
config = RunConfig(
name=str(run["name"]).strip(),
task=str(run["task"]).strip(),
framework=str(run["framework"]).strip(),
working_dir=str(run["working_dir"]),
command=list(command),
description=str(run.get("description", "")).strip(),
tags=list(tags),
environment=dict(environment),
primary_metric=str(display.get("primary_metric", "loss")),
higher_is_better=bool(display.get("higher_is_better", False)),
source_path=source,
)
if not config.name or not config.task or not config.framework:
raise ConfigError("name、task 和 framework 不能为空")
return config.resolved(strict=strict)
+126
View File
@@ -0,0 +1,126 @@
from __future__ import annotations
import json
import mimetypes
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, unquote, urlparse
from .store import RunStore
STATIC_ROOT = Path(__file__).with_name("static")
def _json_bytes(value: Any) -> bytes:
return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
def build_handler(store: RunStore) -> type[BaseHTTPRequestHandler]:
class DashboardHandler(BaseHTTPRequestHandler):
server_version = "RailTrainDashboard/0.1"
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
path = unquote(parsed.path)
try:
if path == "/api/health":
self._send_json({"status": "ok"})
return
if path == "/api/runs":
self._send_json({"runs": store.list_runs()})
return
if path.startswith("/api/runs/"):
self._serve_run_api(path, parse_qs(parsed.query))
return
self._serve_static(path)
except KeyError as exc:
self._send_json(
{"error": str(exc)},
status=HTTPStatus.NOT_FOUND,
)
except Exception as exc: # keep local dashboard responsive
self._send_json(
{"error": f"{type(exc).__name__}: {exc}"},
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
def _serve_run_api(
self, path: str, query: dict[str, list[str]]
) -> None:
parts = [part for part in path.split("/") if part]
if len(parts) < 3:
raise KeyError("缺少训练任务 ID")
run_id = parts[2]
if len(parts) == 3:
self._send_json(store.read_manifest(run_id))
return
resource = parts[3]
if resource == "metrics":
raw_limit = query.get("limit", ["5000"])[0]
limit = max(1, min(int(raw_limit), 10000))
self._send_json(
{"run_id": run_id, "metrics": store.read_metrics(run_id, limit=limit)}
)
return
if resource == "log":
raw_tail = query.get("tail", ["120"])[0]
tail = max(1, min(int(raw_tail), 1000))
self._send_json(
{"run_id": run_id, "log": store.read_log(run_id, tail=tail)}
)
return
raise KeyError(f"未知资源: {resource}")
def _serve_static(self, path: str) -> None:
filename = "index.html" if path in {"/", "/index.html"} else path.lstrip("/")
if filename not in {"index.html", "app.js", "styles.css"}:
self.send_error(HTTPStatus.NOT_FOUND)
return
target = STATIC_ROOT / filename
data = target.read_bytes()
content_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", f"{content_type}; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(data)
def _send_json(
self,
value: Any,
*,
status: HTTPStatus = HTTPStatus.OK,
) -> None:
data = _json_bytes(value)
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(data)
def log_message(self, format: str, *args: Any) -> None:
return
return DashboardHandler
def serve(
store: RunStore,
*,
host: str = "127.0.0.1",
port: int = 8090,
) -> None:
server = ThreadingHTTPServer((host, port), build_handler(store))
print(f"训练看板: http://{host}:{port}")
print(f"任务目录: {store.root}")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n正在停止训练看板…")
finally:
server.server_close()
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
import platform
import subprocess
import sys
from datetime import datetime
from typing import Any
GPU_QUERY = (
"index,name,utilization.gpu,memory.used,memory.total,"
"temperature.gpu,power.draw"
)
def _number(value: str) -> float | None:
cleaned = value.strip()
if not cleaned or cleaned in {"N/A", "[N/A]"}:
return None
try:
return float(cleaned)
except ValueError:
return None
def sample_gpu() -> dict[str, Any] | None:
try:
completed = subprocess.run(
[
"nvidia-smi",
f"--query-gpu={GPU_QUERY}",
"--format=csv,noheader,nounits",
],
check=True,
capture_output=True,
text=True,
timeout=5,
)
except (FileNotFoundError, subprocess.SubprocessError):
return None
first = next((line for line in completed.stdout.splitlines() if line.strip()), None)
if first is None:
return None
parts = [part.strip() for part in first.split(",")]
if len(parts) != 7:
return None
memory_used = _number(parts[3])
memory_total = _number(parts[4])
return {
"kind": "gpu",
"recorded_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"gpu_index": int(float(parts[0])),
"gpu_name": parts[1],
"gpu_utilization": _number(parts[2]),
"gpu_memory_used_mb": memory_used,
"gpu_memory_total_mb": memory_total,
"gpu_memory_percent": (
round(memory_used / memory_total * 100, 2)
if memory_used is not None and memory_total
else None
),
"gpu_temperature_c": _number(parts[5]),
"gpu_power_w": _number(parts[6]),
}
def collect_system() -> dict[str, Any]:
gpu = sample_gpu()
return {
"recorded_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"platform": platform.platform(),
"python": sys.version.split()[0],
"executable": sys.executable,
"processor": platform.processor(),
"gpu": gpu,
}
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Pattern
_NUMBER = r"([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?)"
@dataclass(slots=True)
class LogMetricParser:
"""Best-effort parser for Paddle, OpenMMLab and common training logs."""
step: int = 0
patterns: dict[str, Pattern[str]] = field(
default_factory=lambda: {
"loss": re.compile(
rf"(?:^|[\s,{{])(?:loss|total_loss|loss_total)\s*[:=]\s*{_NUMBER}",
re.IGNORECASE,
),
"learning_rate": re.compile(
rf"(?:^|[\s,{{])(?:lr|learning_rate)\s*[:=]\s*{_NUMBER}",
re.IGNORECASE,
),
"map": re.compile(
rf"(?:mAP(?:50(?:-95)?)?|bbox_mmap|coco/bbox_mAP)\s*[:=]\s*{_NUMBER}",
re.IGNORECASE,
),
"miou": re.compile(
rf"(?:mIoU|mean_iou)\s*[:=]\s*{_NUMBER}",
re.IGNORECASE,
),
"accuracy": re.compile(
rf"(?:accuracy|acc(?:uracy)?)\s*[:=]\s*{_NUMBER}",
re.IGNORECASE,
),
"epoch": re.compile(
rf"(?:epoch)\s*[:=/]\s*{_NUMBER}",
re.IGNORECASE,
),
"iteration": re.compile(
rf"(?:iter(?:ation)?|step)\s*[:=/]\s*{_NUMBER}",
re.IGNORECASE,
),
}
)
def parse(self, line: str) -> dict[str, float | int | str] | None:
values: dict[str, float | int | str] = {}
for name, pattern in self.patterns.items():
match = pattern.search(line)
if not match:
continue
number = float(match.group(1))
values[name] = int(number) if name in {"epoch", "iteration"} else number
if not values:
return None
self.step += 1
values["kind"] = "train"
values["step"] = int(values.get("iteration", self.step))
return values
+233
View File
@@ -0,0 +1,233 @@
from __future__ import annotations
import math
import os
import random
import subprocess
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from .config import RunConfig
from .gpu import collect_system, sample_gpu
from .metrics import LogMetricParser
from .store import RunStore, now_iso
class TrainingRunner:
def __init__(self, store: RunStore, *, gpu_interval: float = 2.0):
self.store = store
self.gpu_interval = max(0.5, gpu_interval)
def execute(self, config: RunConfig) -> str:
resolved = config.resolved(strict=True)
workdir = Path(resolved.working_dir).expanduser().resolve()
if not workdir.is_dir():
raise FileNotFoundError(f"训练工作目录不存在: {workdir}")
run_id, _ = self.store.create(
{
**resolved.as_dict(),
"working_dir": str(workdir),
"command": list(resolved.command),
}
)
self.store.write_system(run_id, collect_system())
self.store.update_manifest(run_id, status="running", started_at=now_iso())
environment = os.environ.copy()
environment.update(resolved.environment)
parser = LogMetricParser()
stop_monitor = threading.Event()
status = "failed"
exit_code: int | None = None
process: subprocess.Popen[str] | None = None
monitor: threading.Thread | None = None
try:
process = subprocess.Popen(
resolved.command,
cwd=workdir,
env=environment,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
)
self.store.update_manifest(run_id, pid=process.pid)
monitor = threading.Thread(
target=self._monitor_gpu,
args=(run_id, stop_monitor),
daemon=True,
)
monitor.start()
assert process.stdout is not None
for line in process.stdout:
print(line, end="")
self.store.append_log(run_id, [line])
metric = parser.parse(line)
if metric:
metric["recorded_at"] = now_iso()
self.store.append_metric(run_id, metric)
exit_code = process.wait()
status = "completed" if exit_code == 0 else "failed"
except KeyboardInterrupt:
status = "cancelled"
if process is not None:
process.terminate()
try:
exit_code = process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
exit_code = process.wait()
finally:
stop_monitor.set()
if monitor is not None:
monitor.join(timeout=self.gpu_interval + 1)
if process is not None and process.stdout is not None:
process.stdout.close()
self.store.update_manifest(
run_id,
status=status,
ended_at=now_iso(),
exit_code=exit_code,
)
return run_id
def _monitor_gpu(self, run_id: str, stop: threading.Event) -> None:
while not stop.is_set():
metric = sample_gpu()
if metric:
self.store.append_metric(run_id, metric)
stop.wait(self.gpu_interval)
def create_demo_run(
store: RunStore,
*,
steps: int = 160,
interval: float = 0.02,
seed: int = 42,
) -> str:
steps = max(10, min(steps, 5000))
random_source = random.Random(seed)
run_id, _ = store.create(
{
"name": "PP-YOLOE-SOD-s 演示训练",
"task": "vision-detector",
"framework": "PaddleDetection",
"description": "用于验证本地训练看板的模拟指标,不代表真实模型精度。",
"tags": ["demo", "detection", "rtx3090"],
"working_dir": str(Path.cwd()),
"command": ["railtrain", "demo", "--steps", str(steps)],
"environment": {"CUDA_VISIBLE_DEVICES": "0"},
"primary_metric": "map",
"higher_is_better": True,
"source_path": None,
}
)
started = datetime.now().astimezone()
self_gpu = sample_gpu()
system = collect_system()
if system.get("gpu") is None:
system["gpu"] = {
"gpu_name": "NVIDIA GeForce RTX 3090",
"gpu_memory_total_mb": 24576,
"simulated": True,
}
store.write_system(run_id, system)
store.update_manifest(
run_id,
status="running",
started_at=started.isoformat(timespec="seconds"),
)
for step in range(1, steps + 1):
progress = step / steps
noise = random_source.uniform(-0.025, 0.025)
loss = max(0.16, 4.1 * math.exp(-4.2 * progress) + 0.14 + noise)
quality = min(
0.91,
max(0.04, 0.08 + 0.83 * (1 - math.exp(-3.4 * progress)) + noise * 0.4),
)
learning_rate = 0.00125 * (
min(1.0, step / max(1, int(steps * 0.08)))
if progress < 0.08
else 0.5 * (1 + math.cos(math.pi * (progress - 0.08) / 0.92))
)
epoch = max(1, math.ceil(progress * 80))
train_metric: dict[str, Any] = {
"kind": "train",
"recorded_at": now_iso(),
"step": step,
"epoch": epoch,
"loss": round(loss, 5),
"map": round(quality, 5),
"learning_rate": round(learning_rate, 8),
}
store.append_metric(run_id, train_metric)
utilization = min(
99.0,
max(52.0, 91 + 6 * math.sin(step / 8) + random_source.uniform(-3, 3)),
)
memory_percent = min(
93.0,
max(64.0, 78 + 5 * math.sin(step / 19) + random_source.uniform(-2, 2)),
)
gpu_metric = {
"kind": "gpu",
"recorded_at": now_iso(),
"gpu_index": 0,
"gpu_name": (
self_gpu.get("gpu_name")
if self_gpu
else "NVIDIA GeForce RTX 3090"
),
"gpu_utilization": round(utilization, 2),
"gpu_memory_used_mb": round(24576 * memory_percent / 100, 1),
"gpu_memory_total_mb": 24576,
"gpu_memory_percent": round(memory_percent, 2),
"gpu_temperature_c": round(
63 + 7 * progress + random_source.uniform(-1.2, 1.2), 1
),
"gpu_power_w": round(
270 + 35 * math.sin(progress * math.pi) + random_source.uniform(-5, 5),
1,
),
"simulated": True,
}
store.append_metric(run_id, gpu_metric)
if step % max(1, steps // 12) == 0 or step == 1:
store.append_log(
run_id,
[
(
f"epoch={epoch:02d} step={step:04d} "
f"loss={loss:.5f} mAP={quality:.5f} "
f"lr={learning_rate:.8f}\n"
)
],
)
if interval > 0:
time.sleep(interval)
ended = datetime.now().astimezone()
store.append_log(
run_id,
[
"演示训练完成。指标为可视化样例,不得作为模型验收结果。\n",
],
)
store.update_manifest(
run_id,
status="completed",
ended_at=ended.isoformat(timespec="seconds"),
exit_code=0,
)
return run_id
+389
View File
@@ -0,0 +1,389 @@
const state = {
runs: [],
selectedId: null,
metrics: [],
refreshTimer: null,
};
const elements = {
connectionStatus: document.getElementById("connectionStatus"),
refreshButton: document.getElementById("refreshButton"),
runCount: document.getElementById("runCount"),
runList: document.getElementById("runList"),
emptyRuns: document.getElementById("emptyRuns"),
emptySelection: document.getElementById("emptySelection"),
runDetail: document.getElementById("runDetail"),
runStatus: document.getElementById("runStatus"),
runTask: document.getElementById("runTask"),
runName: document.getElementById("runName"),
runDescription: document.getElementById("runDescription"),
updatedAt: document.getElementById("updatedAt"),
epochValue: document.getElementById("epochValue"),
stepValue: document.getElementById("stepValue"),
lossValue: document.getElementById("lossValue"),
lossTrend: document.getElementById("lossTrend"),
qualityLabel: document.getElementById("qualityLabel"),
qualityValue: document.getElementById("qualityValue"),
qualityContext: document.getElementById("qualityContext"),
gpuValue: document.getElementById("gpuValue"),
gpuContext: document.getElementById("gpuContext"),
qualityLegend: document.getElementById("qualityLegend"),
trainingChart: document.getElementById("trainingChart"),
gpuChart: document.getElementById("gpuChart"),
metadata: document.getElementById("metadata"),
logOutput: document.getElementById("logOutput"),
};
const statusLabels = {
queued: "排队中",
running: "训练中",
completed: "已完成",
failed: "失败",
cancelled: "已取消",
};
const metricLabels = {
map: "mAP",
miou: "mIoU",
accuracy: "Accuracy",
loss: "Loss",
};
async function requestJson(path) {
const response = await fetch(path, { cache: "no-store" });
if (!response.ok) {
const data = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(data.error || `HTTP ${response.status}`);
}
return response.json();
}
function formatNumber(value, digits = 4) {
return Number.isFinite(Number(value)) ? Number(value).toFixed(digits) : "—";
}
function formatDate(value) {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime())
? value
: new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(date);
}
function durationText(run) {
const start = run.started_at ? new Date(run.started_at).getTime() : null;
const end = run.ended_at ? new Date(run.ended_at).getTime() : Date.now();
if (!start || Number.isNaN(start) || Number.isNaN(end)) return "—";
const seconds = Math.max(0, Math.round((end - start) / 1000));
if (seconds < 60) return `${seconds}`;
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
if (minutes < 60) return `${minutes}${remainder}`;
return `${Math.floor(minutes / 60)} 小时 ${minutes % 60}`;
}
function renderRunList() {
elements.runCount.textContent = String(state.runs.length);
elements.emptyRuns.hidden = state.runs.length > 0;
elements.runList.replaceChildren();
state.runs.forEach((run) => {
const button = document.createElement("button");
button.type = "button";
button.className = `run-button${run.id === state.selectedId ? " active" : ""}`;
button.dataset.runId = run.id;
button.setAttribute("aria-pressed", run.id === state.selectedId ? "true" : "false");
const title = document.createElement("strong");
title.textContent = run.name || run.id;
const meta = document.createElement("span");
meta.className = "run-meta";
const task = document.createElement("span");
task.textContent = run.task || "custom";
const status = document.createElement("span");
status.className = "run-state";
status.textContent = statusLabels[run.status] || run.status || "未知";
meta.append(task, status);
button.append(title, meta);
button.addEventListener("click", () => selectRun(run.id));
elements.runList.append(button);
});
}
function latestByKind(kind) {
for (let index = state.metrics.length - 1; index >= 0; index -= 1) {
if (state.metrics[index].kind === kind) return state.metrics[index];
}
return null;
}
function trainMetrics() {
return state.metrics.filter((metric) => metric.kind === "train");
}
function qualityKey(run, train) {
const preferred = run.primary_metric;
if (preferred && train.some((item) => Number.isFinite(Number(item[preferred])))) {
return preferred;
}
return ["map", "miou", "accuracy"].find((key) =>
train.some((item) => Number.isFinite(Number(item[key])))
) || "loss";
}
function renderSummary(run) {
const train = trainMetrics();
const latestTrain = train.at(-1);
const latestGpu = latestByKind("gpu");
const key = qualityKey(run, train);
const qualityValues = train
.map((item) => Number(item[key]))
.filter(Number.isFinite);
const bestQuality = qualityValues.length
? (run.higher_is_better ? Math.max(...qualityValues) : Math.min(...qualityValues))
: null;
elements.runStatus.textContent = statusLabels[run.status] || run.status || "未知";
elements.runStatus.className = `status-badge ${run.status || ""}`;
elements.runTask.textContent = run.task || "custom";
elements.runName.textContent = run.name || run.id;
elements.runDescription.textContent = run.description || "未填写任务说明。";
elements.updatedAt.textContent = new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(new Date());
elements.epochValue.textContent = latestTrain?.epoch ?? "—";
elements.stepValue.textContent = latestTrain?.step
? `Step ${latestTrain.step}`
: "尚无 step";
elements.lossValue.textContent = formatNumber(latestTrain?.loss);
if (train.length > 1 && Number.isFinite(Number(latestTrain?.loss))) {
const firstLoss = Number(train.find((item) => Number.isFinite(Number(item.loss)))?.loss);
const latestLoss = Number(latestTrain.loss);
const delta = firstLoss ? ((latestLoss - firstLoss) / firstLoss) * 100 : null;
elements.lossTrend.textContent = Number.isFinite(delta)
? `较起点 ${delta <= 0 ? "下降" : "上升"} ${Math.abs(delta).toFixed(1)}%`
: "已记录训练指标";
} else {
elements.lossTrend.textContent = "等待指标";
}
const label = metricLabels[key] || key;
elements.qualityLabel.textContent = `最佳 ${label}`;
elements.qualityLegend.textContent = label;
elements.qualityValue.textContent = formatNumber(bestQuality);
elements.qualityContext.textContent =
key === "loss" ? "数值越低越好" : "数值越高越好";
elements.gpuValue.textContent = latestGpu
? `${formatNumber(latestGpu.gpu_utilization, 0)}%`
: "—";
elements.gpuContext.textContent = latestGpu
? `显存 ${formatNumber(latestGpu.gpu_memory_percent, 0)}% · ${formatNumber(latestGpu.gpu_temperature_c, 0)}°C`
: "等待采样";
}
function pathFor(values, width, height, padding, minValue, maxValue) {
if (!values.length) return "";
const range = maxValue - minValue || 1;
const span = Math.max(1, values.length - 1);
return values
.map((value, index) => {
const x = padding.left + (index / span) * (width - padding.left - padding.right);
const y =
padding.top +
(1 - (value - minValue) / range) *
(height - padding.top - padding.bottom);
return `${index === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(" ");
}
function chartSvg({ series, emptyText, height = 250, ariaLabel }) {
const validSeries = series
.map((item) => ({
...item,
values: item.values.filter((value) => Number.isFinite(Number(value))).map(Number),
}))
.filter((item) => item.values.length);
if (!validSeries.length) {
return `<div class="chart-empty">${emptyText}</div>`;
}
const width = 920;
const padding = { left: 52, right: 18, top: 15, bottom: 32 };
const lines = [];
const labels = [];
[0, 0.5, 1].forEach((ratio) => {
const y = padding.top + ratio * (height - padding.top - padding.bottom);
lines.push(
`<line class="grid-line" x1="${padding.left}" y1="${y}" x2="${width - padding.right}" y2="${y}"></line>`
);
});
labels.push(
`<text class="axis-label" x="${padding.left}" y="${height - 9}">起点</text>`,
`<text class="axis-label" x="${width - padding.right}" y="${height - 9}" text-anchor="end">最新</text>`
);
validSeries.forEach((item) => {
const minValue = item.fixedMin ?? Math.min(...item.values);
const maxValue = item.fixedMax ?? Math.max(...item.values);
const d = pathFor(item.values, width, height, padding, minValue, maxValue);
lines.push(`<path class="${item.className}" d="${d}"></path>`);
const latest = item.values.at(-1);
labels.push(
`<text class="axis-label" x="${width - padding.right}" y="${item.labelY}" text-anchor="end">${item.label}: ${formatNumber(latest, item.digits ?? 2)}</text>`
);
});
return `
<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${ariaLabel}">
${lines.join("")}
${labels.join("")}
</svg>
`;
}
function renderCharts(run) {
const train = trainMetrics();
const quality = qualityKey(run, train);
const lossValues = train.map((item) => item.loss).filter((value) => value !== undefined);
const qualityValues = train
.map((item) => item[quality])
.filter((value) => value !== undefined);
const qualityLabel = metricLabels[quality] || quality;
elements.trainingChart.innerHTML = chartSvg({
emptyText: "等待训练指标",
ariaLabel: `Loss 和 ${qualityLabel} 训练曲线`,
series: [
{
values: lossValues,
className: "loss-line",
label: "Loss",
labelY: 28,
digits: 4,
},
{
values: qualityValues,
className: "quality-line",
label: qualityLabel,
labelY: 44,
digits: 4,
fixedMin: quality === "loss" ? undefined : 0,
fixedMax: quality === "loss" ? undefined : 1,
},
],
});
const gpu = state.metrics.filter((metric) => metric.kind === "gpu");
elements.gpuChart.innerHTML = chartSvg({
emptyText: "等待 GPU 采样",
height: 185,
ariaLabel: "GPU 利用率和显存占用百分比曲线",
series: [
{
values: gpu.map((item) => item.gpu_utilization),
className: "gpu-line",
label: "GPU",
labelY: 28,
digits: 0,
fixedMin: 0,
fixedMax: 100,
},
{
values: gpu.map((item) => item.gpu_memory_percent),
className: "memory-line",
label: "显存",
labelY: 44,
digits: 0,
fixedMin: 0,
fixedMax: 100,
},
],
});
}
function renderMetadata(run) {
const rows = [
["框架", run.framework || "—"],
["状态", statusLabels[run.status] || run.status || "—"],
["创建时间", formatDate(run.created_at)],
["开始时间", formatDate(run.started_at)],
["运行时长", durationText(run)],
["主指标", metricLabels[run.primary_metric] || run.primary_metric || "—"],
["工作目录", run.working_dir || "—"],
["配置文件", run.source_path || "演示任务"],
];
elements.metadata.replaceChildren();
rows.forEach(([label, value]) => {
const term = document.createElement("dt");
term.textContent = label;
const detail = document.createElement("dd");
detail.textContent = String(value);
elements.metadata.append(term, detail);
});
}
async function selectRun(runId, { silent = false } = {}) {
state.selectedId = runId;
renderRunList();
try {
const [run, metricsData, logData] = await Promise.all([
requestJson(`/api/runs/${encodeURIComponent(runId)}`),
requestJson(`/api/runs/${encodeURIComponent(runId)}/metrics?limit=5000`),
requestJson(`/api/runs/${encodeURIComponent(runId)}/log?tail=120`),
]);
if (state.selectedId !== runId) return;
state.metrics = metricsData.metrics || [];
elements.emptySelection.hidden = true;
elements.runDetail.hidden = false;
renderSummary(run);
renderCharts(run);
renderMetadata(run);
elements.logOutput.textContent = logData.log || "尚无日志输出。";
if (!silent) elements.runName.focus?.();
} catch (error) {
elements.logOutput.textContent = `加载失败:${error.message}`;
}
}
async function refresh() {
try {
const data = await requestJson("/api/runs");
state.runs = data.runs || [];
elements.connectionStatus.textContent = "本地连接正常";
elements.connectionStatus.classList.add("online");
renderRunList();
if (!state.selectedId && state.runs.length) {
await selectRun(state.runs[0].id, { silent: true });
} else if (state.selectedId) {
const stillExists = state.runs.some((run) => run.id === state.selectedId);
if (stillExists) {
await selectRun(state.selectedId, { silent: true });
} else {
state.selectedId = null;
state.metrics = [];
elements.runDetail.hidden = true;
elements.emptySelection.hidden = false;
}
}
} catch (error) {
elements.connectionStatus.textContent = "连接失败";
elements.connectionStatus.classList.remove("online");
console.error(error);
}
}
elements.refreshButton.addEventListener("click", refresh);
refresh();
state.refreshTimer = window.setInterval(refresh, 4000);
+138
View File
@@ -0,0 +1,138 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AItrackwalker 模型训练中心</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header class="topbar">
<div>
<p class="eyebrow">AItrackwalker · Local GPU Workbench</p>
<h1>模型训练中心</h1>
</div>
<div class="topbar-actions">
<span id="connectionStatus" class="connection-status">正在连接</span>
<button id="refreshButton" type="button">刷新数据</button>
</div>
</header>
<main class="layout">
<aside class="sidebar" aria-label="训练任务">
<div class="section-heading">
<div>
<p class="eyebrow">Runs</p>
<h2>训练任务</h2>
</div>
<span id="runCount" class="count">0</span>
</div>
<div id="runList" class="run-list"></div>
<div id="emptyRuns" class="empty-state" hidden>
<p>还没有训练记录。</p>
<code>python -m railtrain demo</code>
</div>
</aside>
<section class="content" aria-live="polite">
<div id="emptySelection" class="welcome">
<p class="eyebrow">RTX 3090 Ready</p>
<h2>选择一条训练任务查看曲线</h2>
<p>看板会读取本地 JSONL 指标,不上传模型、日志或数据集。</p>
</div>
<div id="runDetail" hidden>
<div class="detail-header">
<div>
<div class="title-row">
<span id="runStatus" class="status-badge">未知</span>
<span id="runTask" class="task-label"></span>
</div>
<h2 id="runName">训练任务</h2>
<p id="runDescription" class="description"></p>
</div>
<div class="updated-at">
<span>最近刷新</span>
<strong id="updatedAt"></strong>
</div>
</div>
<div class="stats" aria-label="训练摘要">
<article>
<span>当前 Epoch</span>
<strong id="epochValue"></strong>
<small id="stepValue">尚无 step</small>
</article>
<article>
<span>最新 Loss</span>
<strong id="lossValue"></strong>
<small id="lossTrend">等待指标</small>
</article>
<article>
<span id="qualityLabel">最佳指标</span>
<strong id="qualityValue"></strong>
<small id="qualityContext">等待评估</small>
</article>
<article>
<span>GPU / 显存</span>
<strong id="gpuValue"></strong>
<small id="gpuContext">等待采样</small>
</article>
</div>
<section class="panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Optimization</p>
<h3>训练曲线</h3>
</div>
<div class="legend" aria-label="训练曲线图例">
<span><i class="loss-dot"></i>Loss</span>
<span><i class="quality-dot"></i><b id="qualityLegend">mAP</b></span>
</div>
</div>
<div id="trainingChart" class="chart" role="img" aria-label="Loss 和质量指标训练曲线"></div>
</section>
<section class="panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Hardware</p>
<h3>RTX 3090 负载</h3>
</div>
<div class="legend" aria-label="GPU 曲线图例">
<span><i class="gpu-dot"></i>利用率</span>
<span><i class="memory-dot"></i>显存</span>
</div>
</div>
<div id="gpuChart" class="chart compact" role="img" aria-label="GPU 利用率和显存占用曲线"></div>
</section>
<div class="lower-grid">
<section class="panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Run Metadata</p>
<h3>任务信息</h3>
</div>
</div>
<dl id="metadata" class="metadata"></dl>
</section>
<section class="panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Latest Output</p>
<h3>最近日志</h3>
</div>
</div>
<pre id="logOutput">等待日志…</pre>
</section>
</div>
</div>
</section>
</main>
<script src="/app.js" defer></script>
</body>
</html>
+590
View File
@@ -0,0 +1,590 @@
:root {
color-scheme: dark;
--background: #081015;
--surface: #0e191f;
--surface-strong: #13232b;
--surface-soft: #0b151b;
--border: #21343d;
--border-strong: #31505d;
--text: #edf7f4;
--muted: #8fa5ab;
--muted-strong: #b9c9ca;
--primary: #6ee7c1;
--primary-soft: rgba(110, 231, 193, 0.12);
--orange: #ffb86b;
--orange-soft: rgba(255, 184, 107, 0.12);
--blue: #78b7ff;
--violet: #a895ff;
--danger: #ff7c86;
--grid: rgba(143, 165, 171, 0.17);
font-family: Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
margin: 0;
background:
radial-gradient(circle at 82% -10%, rgba(110, 231, 193, 0.09), transparent 34rem),
var(--background);
color: var(--text);
}
button {
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--surface-strong);
color: var(--text);
padding: 9px 14px;
font: inherit;
cursor: pointer;
}
button:hover {
border-color: var(--primary);
}
button:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.topbar {
min-height: 94px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 22px 32px;
border-bottom: 1px solid var(--border);
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
margin-bottom: 0;
font-size: clamp(24px, 3vw, 34px);
letter-spacing: -0.03em;
}
h2 {
margin-bottom: 0;
font-size: 22px;
letter-spacing: -0.02em;
}
h3 {
margin-bottom: 0;
font-size: 16px;
font-weight: 600;
}
.eyebrow {
margin-bottom: 6px;
color: var(--primary);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.topbar-actions,
.title-row,
.legend {
display: flex;
align-items: center;
gap: 12px;
}
.connection-status {
color: var(--muted);
font-size: 13px;
}
.connection-status::before {
content: "";
display: inline-block;
width: 7px;
height: 7px;
margin-right: 7px;
border-radius: 50%;
background: var(--orange);
}
.connection-status.online::before {
background: var(--primary);
}
.layout {
display: grid;
grid-template-columns: 290px minmax(0, 1fr);
min-height: calc(100vh - 94px);
}
.sidebar {
padding: 26px 18px;
border-right: 1px solid var(--border);
background: rgba(8, 16, 21, 0.58);
}
.section-heading,
.detail-header,
.panel-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.section-heading {
padding: 0 8px 18px;
}
.count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
border-radius: 50%;
background: var(--primary-soft);
color: var(--primary);
font-size: 12px;
}
.run-list {
display: grid;
gap: 8px;
}
.run-button {
width: 100%;
padding: 13px 14px;
border-color: transparent;
background: transparent;
text-align: left;
}
.run-button:hover {
background: var(--surface-soft);
}
.run-button.active {
border-color: var(--border-strong);
background: var(--surface-strong);
}
.run-button strong,
.run-button span {
display: block;
}
.run-button strong {
overflow: hidden;
margin-bottom: 7px;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.run-button span {
color: var(--muted);
font-size: 12px;
}
.run-button .run-meta {
display: flex;
justify-content: space-between;
gap: 12px;
}
.run-state {
color: var(--muted-strong) !important;
}
.empty-state,
.welcome {
color: var(--muted);
}
.empty-state {
padding: 20px 8px;
line-height: 1.7;
}
code {
color: var(--primary);
}
.content {
min-width: 0;
padding: 30px;
}
.welcome {
max-width: 580px;
margin: 12vh auto;
text-align: center;
}
.welcome h2 {
margin-bottom: 12px;
color: var(--text);
font-size: clamp(24px, 4vw, 38px);
}
.detail-header {
margin-bottom: 24px;
}
.detail-header h2 {
margin: 10px 0 7px;
font-size: clamp(24px, 3vw, 32px);
}
.status-badge,
.task-label {
display: inline-flex;
align-items: center;
min-height: 26px;
border-radius: 999px;
padding: 4px 9px;
font-size: 12px;
}
.status-badge {
background: var(--primary-soft);
color: var(--primary);
}
.status-badge.failed,
.status-badge.cancelled {
background: rgba(255, 124, 134, 0.12);
color: var(--danger);
}
.status-badge.running {
background: var(--orange-soft);
color: var(--orange);
}
.task-label {
border: 1px solid var(--border);
color: var(--muted-strong);
}
.description {
max-width: 720px;
margin-bottom: 0;
color: var(--muted);
line-height: 1.6;
}
.updated-at {
flex: 0 0 auto;
text-align: right;
}
.updated-at span,
.updated-at strong {
display: block;
}
.updated-at span {
margin-bottom: 5px;
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
}
.updated-at strong {
font-size: 13px;
}
.stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.stats article,
.panel {
border: 1px solid var(--border);
border-radius: 12px;
background: linear-gradient(145deg, rgba(19, 35, 43, 0.84), rgba(11, 21, 27, 0.84));
}
.stats article {
min-width: 0;
padding: 17px 18px;
}
.stats span,
.stats strong,
.stats small {
display: block;
}
.stats span {
margin-bottom: 10px;
color: var(--muted);
font-size: 12px;
}
.stats strong {
overflow: hidden;
margin-bottom: 5px;
font-size: clamp(22px, 3vw, 30px);
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
}
.stats small {
overflow: hidden;
color: var(--muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.panel {
margin-bottom: 14px;
padding: 18px;
}
.panel-heading {
align-items: center;
margin-bottom: 16px;
}
.panel-heading .eyebrow {
margin-bottom: 4px;
}
.legend {
flex-wrap: wrap;
color: var(--muted);
font-size: 11px;
}
.legend span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.legend b {
font-weight: 500;
}
.legend i {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
.loss-dot {
background: var(--orange);
}
.quality-dot {
background: var(--primary);
}
.gpu-dot {
background: var(--blue);
}
.memory-dot {
background: var(--violet);
}
.chart {
min-height: 250px;
}
.chart.compact {
min-height: 185px;
}
.chart svg {
display: block;
width: 100%;
height: auto;
overflow: visible;
}
.chart .grid-line {
stroke: var(--grid);
stroke-width: 1;
}
.chart .axis-label {
fill: var(--muted);
font-size: 11px;
}
.chart .loss-line,
.chart .quality-line,
.chart .gpu-line,
.chart .memory-line {
fill: none;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
}
.chart .loss-line {
stroke: var(--orange);
}
.chart .quality-line {
stroke: var(--primary);
}
.chart .gpu-line {
stroke: var(--blue);
}
.chart .memory-line {
stroke: var(--violet);
}
.chart .area {
fill: var(--primary-soft);
}
.chart-empty {
display: grid;
min-height: inherit;
place-items: center;
color: var(--muted);
font-size: 13px;
}
.lower-grid {
display: grid;
grid-template-columns: minmax(280px, 0.8fr) minmax(0, 1.2fr);
gap: 14px;
}
.lower-grid .panel {
min-width: 0;
margin-bottom: 0;
}
.metadata {
display: grid;
grid-template-columns: minmax(90px, 0.6fr) minmax(0, 1.4fr);
gap: 11px 18px;
margin: 0;
font-size: 13px;
}
.metadata dt {
color: var(--muted);
}
.metadata dd {
overflow-wrap: anywhere;
margin: 0;
color: var(--muted-strong);
}
pre {
max-height: 280px;
overflow: auto;
margin: 0;
color: var(--muted-strong);
font: 12px/1.7 "Cascadia Code", "SFMono-Regular", Consolas, monospace;
white-space: pre-wrap;
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 230px minmax(0, 1fr);
}
.stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.lower-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.topbar {
align-items: flex-start;
padding: 18px;
}
.connection-status {
display: none;
}
.layout {
display: block;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid var(--border);
padding: 18px 12px;
}
.run-list {
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
}
.content {
padding: 20px 14px;
}
.detail-header {
display: block;
}
.updated-at {
margin-top: 16px;
text-align: left;
}
.stats {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 430px) {
.topbar-actions {
align-self: center;
}
.stats {
grid-template-columns: 1fr;
}
.panel-heading {
align-items: flex-start;
flex-direction: column;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
}
}
+127
View File
@@ -0,0 +1,127 @@
from __future__ import annotations
import json
import re
import threading
from datetime import datetime
from pathlib import Path
from typing import Any, Iterable
_SAFE_RUN_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
def now_iso() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
def _slug(value: str) -> str:
normalized = re.sub(r"[^A-Za-z0-9]+", "-", value.lower()).strip("-")
return normalized[:48] or "run"
class RunStore:
def __init__(self, root: str | Path):
self.root = Path(root).expanduser().resolve()
self.root.mkdir(parents=True, exist_ok=True)
self._write_lock = threading.Lock()
def create(self, manifest: dict[str, Any]) -> tuple[str, Path]:
timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S")
base = f"{timestamp}-{_slug(str(manifest.get('name', 'run')))}"
run_id = base
suffix = 2
while (self.root / run_id).exists():
run_id = f"{base}-{suffix}"
suffix += 1
run_dir = self.root / run_id
run_dir.mkdir(parents=True)
document = {
"id": run_id,
"status": "queued",
"created_at": now_iso(),
**manifest,
}
self._write_json(run_dir / "run.json", document)
return run_id, run_dir
def run_dir(self, run_id: str) -> Path:
if not _SAFE_RUN_ID.fullmatch(run_id):
raise KeyError("无效任务 ID")
path = (self.root / run_id).resolve()
if path.parent != self.root or not path.is_dir():
raise KeyError(f"训练任务不存在: {run_id}")
return path
def read_manifest(self, run_id: str) -> dict[str, Any]:
with (self.run_dir(run_id) / "run.json").open("r", encoding="utf-8") as handle:
return json.load(handle)
def update_manifest(self, run_id: str, **changes: Any) -> dict[str, Any]:
with self._write_lock:
path = self.run_dir(run_id) / "run.json"
with path.open("r", encoding="utf-8") as handle:
manifest = json.load(handle)
manifest.update(changes)
self._write_json(path, manifest)
return manifest
def list_runs(self) -> list[dict[str, Any]]:
runs: list[dict[str, Any]] = []
for path in self.root.iterdir():
manifest_path = path / "run.json"
if not path.is_dir() or not manifest_path.is_file():
continue
try:
with manifest_path.open("r", encoding="utf-8") as handle:
runs.append(json.load(handle))
except (OSError, json.JSONDecodeError):
continue
return sorted(
runs,
key=lambda item: item.get("created_at", ""),
reverse=True,
)
def append_metric(self, run_id: str, metric: dict[str, Any]) -> None:
line = json.dumps(metric, ensure_ascii=False, separators=(",", ":"))
path = self.run_dir(run_id) / "metrics.jsonl"
with self._write_lock, path.open("a", encoding="utf-8") as handle:
handle.write(line + "\n")
def read_metrics(self, run_id: str, *, limit: int = 5000) -> list[dict[str, Any]]:
path = self.run_dir(run_id) / "metrics.jsonl"
if not path.is_file():
return []
values: list[dict[str, Any]] = []
with path.open("r", encoding="utf-8") as handle:
for line in handle:
try:
values.append(json.loads(line))
except json.JSONDecodeError:
continue
return values[-limit:]
def append_log(self, run_id: str, lines: Iterable[str]) -> None:
path = self.run_dir(run_id) / "train.log"
with self._write_lock, path.open("a", encoding="utf-8") as handle:
handle.writelines(lines)
def read_log(self, run_id: str, *, tail: int = 120) -> str:
path = self.run_dir(run_id) / "train.log"
if not path.is_file():
return ""
with path.open("r", encoding="utf-8", errors="replace") as handle:
lines = handle.readlines()
return "".join(lines[-max(1, min(tail, 1000)):])
def write_system(self, run_id: str, system: dict[str, Any]) -> None:
self._write_json(self.run_dir(run_id) / "system.json", system)
@staticmethod
def _write_json(path: Path, document: dict[str, Any]) -> None:
temporary = path.with_suffix(path.suffix + ".tmp")
with temporary.open("w", encoding="utf-8") as handle:
json.dump(document, handle, ensure_ascii=False, indent=2)
handle.write("\n")
temporary.replace(path)
@@ -0,0 +1,4 @@
$ErrorActionPreference = "Stop"
$ProjectRoot = Split-Path -Parent $PSScriptRoot
Set-Location $ProjectRoot
python -m railtrain serve --host 127.0.0.1 --port 8090
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
python -m railtrain serve --host 127.0.0.1 --port 8090
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from railtrain.config import ConfigError, load_config
class ConfigTests(unittest.TestCase):
def test_load_and_expand_config(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
config_path = root / "run.toml"
config_path.write_text(
"""
[run]
name = "Demo"
task = "vision-detector"
framework = "PaddleDetection"
working_dir = "${TRAIN_HOME}/src"
command = ["python", "train.py", "--out", "${TRAIN_HOME}/runs"]
tags = ["demo"]
[run.environment]
CUDA_VISIBLE_DEVICES = "0"
[display]
primary_metric = "map"
higher_is_better = true
""".strip(),
encoding="utf-8",
)
previous = os.environ.get("TRAIN_HOME")
os.environ["TRAIN_HOME"] = str(root)
try:
config = load_config(config_path, strict=True)
finally:
if previous is None:
os.environ.pop("TRAIN_HOME", None)
else:
os.environ["TRAIN_HOME"] = previous
self.assertEqual(config.name, "Demo")
self.assertEqual(config.primary_metric, "map")
self.assertEqual(config.working_dir, str(root / "src"))
self.assertEqual(config.command[-1], str(root / "runs"))
def test_missing_environment_variable_is_rejected_in_strict_mode(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
config_path = Path(temporary) / "run.toml"
config_path.write_text(
"""
[run]
name = "Demo"
task = "custom"
framework = "custom"
working_dir = "${MISSING_RAILTRAIN_VALUE}"
command = ["python", "train.py"]
""".strip(),
encoding="utf-8",
)
os.environ.pop("MISSING_RAILTRAIN_VALUE", None)
with self.assertRaises(ConfigError):
load_config(config_path, strict=True)
if __name__ == "__main__":
unittest.main()
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from railtrain.runner import create_demo_run
from railtrain.store import RunStore
class DemoRunTests(unittest.TestCase):
def test_demo_run_produces_train_and_gpu_metrics(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
store = RunStore(Path(temporary) / "runs")
run_id = create_demo_run(store, steps=12, interval=0)
manifest = store.read_manifest(run_id)
metrics = store.read_metrics(run_id)
self.assertEqual(manifest["status"], "completed")
self.assertEqual(
len([item for item in metrics if item["kind"] == "train"]),
12,
)
self.assertEqual(
len([item for item in metrics if item["kind"] == "gpu"]),
12,
)
self.assertIn("演示训练完成", store.read_log(run_id))
if __name__ == "__main__":
unittest.main()

Some files were not shown because too many files have changed in this diff Show More