Compare commits
10
Commits
690c5ffd61
...
8cdd3f3df9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cdd3f3df9 | ||
|
|
d34ab6351d | ||
|
|
9fc7459e1e | ||
|
|
e9b7331e5a | ||
|
|
1245ec9b20 | ||
|
|
24afcc2cae | ||
|
|
1319091f2e | ||
|
|
c9eae8c915 | ||
|
|
2bf60bf0d9 | ||
|
|
51340602c3 |
@@ -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/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
* text eol=lf
|
||||
@@ -0,0 +1,10 @@
|
||||
artifacts/
|
||||
dist/
|
||||
.state/
|
||||
*.log
|
||||
config/site.yml
|
||||
config/secrets.yaml
|
||||
config/secrets.sops.yaml
|
||||
inventories/*/hosts.yml
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
driver:
|
||||
enabled: false
|
||||
|
||||
toolkit:
|
||||
enabled: true
|
||||
|
||||
dcgmExporter:
|
||||
enabled: true
|
||||
|
||||
gfd:
|
||||
enabled: true
|
||||
|
||||
operator:
|
||||
defaultRuntime: containerd
|
||||
@@ -0,0 +1,12 @@
|
||||
defaultSettings:
|
||||
defaultReplicaCount: 3
|
||||
replicaAutoBalance: best-effort
|
||||
storageMinimalAvailablePercentage: 20
|
||||
upgradeChecker: false
|
||||
|
||||
persistence:
|
||||
defaultClass: false
|
||||
defaultClassReplicaCount: 3
|
||||
|
||||
longhornUI:
|
||||
replicas: 1
|
||||
@@ -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 %}
|
||||
@@ -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]
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: ""
|
||||
@@ -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/#
|
||||
@@ -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/#
|
||||
@@ -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: {}
|
||||
@@ -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."
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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
@@ -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 顶部态势栏
|
||||
|
||||
建议高度 `64–72px`,包含:
|
||||
|
||||
- 居中标题:`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 底部信息带
|
||||
|
||||
底部建议高度 `170–220px`,可在以下内容中选择一个主模式:
|
||||
|
||||
- 当前任务的实时视频与红外缩略图;
|
||||
- 重点区段和最新告警证据;
|
||||
- “任务—采集—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 面板与边框
|
||||
|
||||
- 面板圆角控制在 `2–6px`,避免大圆角 SaaS 卡片感。
|
||||
- 使用 `1px` 低透明描边和轻微内阴影。
|
||||
- 毛玻璃仅用于浮层和叠加面板,模糊值建议 `8–12px`。
|
||||
- 角部装饰只用于一级面板,不要给每个小组件都加 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 状态;
|
||||
- 动画时长与缓动。
|
||||
|
||||
建议图表动画:
|
||||
|
||||
- 首次进入:`500–700ms`;
|
||||
- 数据更新:`300–500ms`;
|
||||
- 面积图采用轻透明渐变,不使用大面积高饱和填充;
|
||||
- 环图只保留 3–5 个有意义分类;
|
||||
- 仪表盘最多使用一个;
|
||||
- 实时曲线只更新最新窗口,不反复重建实例。
|
||||
|
||||
### 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.5–2`,避免 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,部分页面包超过 `800KB–1MB`。新增大屏能力时必须避免把 deck.gl、Three.js、ECharts 和全部业务页面打入同一个首屏包。
|
||||
|
||||
### 15.2 优化要求
|
||||
|
||||
- 指挥中心路由必须懒加载。
|
||||
- ECharts 使用按需导入。
|
||||
- deck.gl 只导入实际使用的 layer 包。
|
||||
- Three.js 模型、加载器和点云模块按需加载。
|
||||
- 将地图、图表、三维和 Element Plus 拆为合理的 vendor chunk。
|
||||
- 页面销毁时调用 `chart.dispose()`、`map.remove()` 并清理事件。
|
||||
- 使用一个统一刷新节拍,避免多个组件各自创建高频定时器。
|
||||
- 海量点位启用聚合、视口裁剪、LOD 或服务端瓦片。
|
||||
- 保持主要交互在普通指挥中心终端上稳定达到 `50–60 FPS`。
|
||||
|
||||
## 16. 可用性与无障碍
|
||||
|
||||
“科技感”不能以牺牲可读性为代价。
|
||||
|
||||
- 正文和背景保持足够对比度。
|
||||
- 告警不能只靠红、橙、黄、蓝颜色区分,必须带文字或图形标识。
|
||||
- 所有可点击项具有可见 hover、active 和 focus 状态。
|
||||
- 告警列表、快捷动作和视角切换支持键盘操作。
|
||||
- 自动滚动列表支持暂停。
|
||||
- 图表提供文字摘要或可访问标签。
|
||||
- 提供降低动效模式。
|
||||
- 断网、空数据、加载失败和数据过期必须有明确状态。
|
||||
- 在右上角显示“最后更新时间/数据延迟”,提升指挥场景可信度。
|
||||
|
||||
## 17. 分阶段实施计划
|
||||
|
||||
### 阶段 0:视觉和数据定版
|
||||
|
||||
目标:
|
||||
|
||||
- 确定指挥中心使用场景和主要岗位;
|
||||
- 确定 6 个以内核心 KPI;
|
||||
- 确定中央地图的图层清单;
|
||||
- 确定左右面板的数据来源;
|
||||
- 输出 1920×1080 高保真视觉稿。
|
||||
|
||||
验收:
|
||||
|
||||
- 所有模块都能对应真实接口或明确的后端补充项;
|
||||
- 画面第一视觉焦点是铁路态势,不是装饰卡片;
|
||||
- 不使用无法解释的数据和图表。
|
||||
|
||||
### 阶段 1:大屏骨架与主题
|
||||
|
||||
目标:
|
||||
|
||||
- 新增 `/command-center` 路由;
|
||||
- 新增独立布局和大屏主题令牌;
|
||||
- 实现顶部、左右、中央和底部布局;
|
||||
- 实现 1080P、4K 和 1366×768 等比适配;
|
||||
- 暂不加入复杂动画。
|
||||
|
||||
验收:
|
||||
|
||||
- 无白底面板;
|
||||
- 无全局样式污染;
|
||||
- 内容不重叠、不裁切;
|
||||
- 浏览器缩放和弹层定位正确。
|
||||
|
||||
### 阶段 2:GIS 核心态势
|
||||
|
||||
目标:
|
||||
|
||||
- 升级暗色 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.gl:MapLibre 地图上的动态轨迹、飞线、热力、聚合和海量点线。
|
||||
- motion-v:Vue 面板、列表和状态切换动画。
|
||||
- countup.js:KPI 数字更新动画。
|
||||
|
||||
【核心视觉】
|
||||
- 页面比例以 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 生命周期正确释放。
|
||||
- [ ] 主要交互保持 50–60 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、处理告警、处理工单;总览不直接提供“启动全链路”操作。
|
||||
|
||||
#### 页面交互
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
# 团队 Git 远端仓库搭建方案
|
||||
|
||||
本文档用于在香港地区 Debian 服务器上部署团队共享 Git 服务,并为后续绑定域名和 HTTPS 做准备。
|
||||
|
||||
推荐技术栈:
|
||||
|
||||
- Gitea:Git 仓库、用户、组织、权限、Issue 和合并请求
|
||||
- PostgreSQL:Gitea 数据库
|
||||
- Caddy:反向代理和自动 HTTPS
|
||||
- Docker Compose:部署和升级
|
||||
|
||||
## 一、推荐架构
|
||||
|
||||
```text
|
||||
开发者浏览器 / Git 客户端
|
||||
│
|
||||
├── https://git.example.com
|
||||
│ │
|
||||
│ Caddy:HTTPS、反向代理
|
||||
│ │
|
||||
│ 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 12(Bookworm)为例;如果系统版本不同,请替换仓库中的代号。
|
||||
|
||||
```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 中设置的密码
|
||||
站点 URL:https://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">仅提供 1—28 日,避免短月份没有对应日期。</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>
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
.venv/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
runs/
|
||||
artifacts/
|
||||
cache/
|
||||
.env
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""AItrackwalker local model training workbench."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
from .cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
Reference in New Issue
Block a user