Skip to content

Commit b0935f1

Browse files
committed
Merge branch 'dev' into debug
2 parents b9eaae3 + 1a0bf7e commit b0935f1

15 files changed

Lines changed: 381 additions & 7 deletions

File tree

.github/workflows/_build-shared.yml

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ env:
2525
jobs:
2626
build-and-push:
2727
name: 构建多平台镜像并推送到 Harbor (${{ inputs.profile }})
28-
runs-on: [ self-hosted, macOS, ARM64 ]
28+
runs-on: [self-hosted, macOS, ARM64]
2929

3030
steps:
3131
- name: 检出代码
32-
uses: actions/checkout@v4
32+
uses: actions/checkout@v5
3333

3434
# 构建 builder 工具用于后续步骤
3535
- name: 构建 builder 工具
@@ -46,14 +46,14 @@ jobs:
4646
working-directory: dashboard
4747

4848
- name: 登录到 Harbor
49-
uses: docker/login-action@v3
49+
uses: docker/login-action@v4
5050
with:
5151
registry: ${{ env.REGISTRY }}
5252
username: ${{ env.REGISTRY_USERNAME }}
5353
password: ${{ env.REGISTRY_SECRET }}
5454

5555
- name: Set up Docker Buildx
56-
uses: docker/setup-buildx-action@v3
56+
uses: docker/setup-buildx-action@v4
5757
with:
5858
driver-opts: network=host
5959
config-inline: |
@@ -94,4 +94,3 @@ jobs:
9494
--dashboard \
9595
--registry ${{ env.REGISTRY }} \
9696
--user ${{ github.repository_owner }}
97-

crates/convd/src/server/model.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
mod backend_status;
12
mod url_result;
23

4+
pub use backend_status::*;
35
pub use url_result::*;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
use serde::Serialize;
2+
3+
#[derive(Debug, Clone, Serialize)]
4+
pub struct BackendStatus {
5+
/// 后端版本号,取自 env!("CARGO_PKG_VERSION")
6+
pub version: String,
7+
/// 各子服务健康状态
8+
pub services: Vec<ServiceStatus>,
9+
}
10+
11+
#[derive(Debug, Clone, Serialize)]
12+
pub struct ServiceStatus {
13+
/// 服务名称,如 "redis", "loki", "tempo"
14+
pub name: String,
15+
/// 是否健康
16+
pub healthy: bool,
17+
/// 附加信息(错误原因等),健康时可为 None
18+
#[serde(skip_serializing_if = "Option::is_none")]
19+
pub message: Option<String>,
20+
}
21+
22+
impl ServiceStatus {
23+
pub fn healthy(name: impl Into<String>) -> Self {
24+
Self {
25+
name: name.into(),
26+
healthy: true,
27+
message: None,
28+
}
29+
}
30+
31+
pub fn unhealthy(name: impl Into<String>, message: impl Into<String>) -> Self {
32+
Self {
33+
name: name.into(),
34+
healthy: false,
35+
message: Some(message.into()),
36+
}
37+
}
38+
}

crates/convd/src/server/router/actuator.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::server::app_state::AppState;
22
use crate::server::error::{AppError, AppStatus};
33
use crate::server::extractor::RequestExtractor;
4+
use crate::server::model::{BackendStatus, ServiceStatus};
45
use crate::server::response::{ApiError, ApiResponse};
56
use axum::Router;
67
use axum::extract::State;
@@ -16,6 +17,7 @@ pub fn router(metrics_handle: PrometheusHandle) -> Router<Arc<AppState>> {
1617
.route("/healthy", get(healthy))
1718
.route("/ready", get(redis))
1819
.route("/redis", get(redis))
20+
.route("/status", get(status))
1921
.route(
2022
"/metrics",
2123
get(move || {
@@ -43,3 +45,34 @@ async fn redis(RequestExtractor(request): RequestExtractor, State(state): State<
4345
.map_err(|r| AppError::new(AppStatus::NO_REDIS, r))
4446
.map_err(|e| ApiError::internal_server(e, request))
4547
}
48+
49+
#[instrument(skip_all)]
50+
async fn status(State(state): State<Arc<AppState>>) -> ApiResponse<BackendStatus> {
51+
let mut services = Vec::new();
52+
53+
// Redis
54+
match state.redis_connection.clone() {
55+
Some(mut con) => match con.ping().await {
56+
Ok(_) => services.push(ServiceStatus::healthy("redis")),
57+
Err(e) => services.push(ServiceStatus::unhealthy("redis", e.to_string())),
58+
},
59+
None => services.push(ServiceStatus::unhealthy("redis", "未配置")),
60+
}
61+
62+
// Loki
63+
match std::env::var("LOKI_URL") {
64+
Ok(url) if !url.is_empty() => services.push(ServiceStatus::healthy("loki")),
65+
_ => services.push(ServiceStatus::unhealthy("loki", "未配置 LOKI_URL")),
66+
}
67+
68+
// Tempo (OTLP)
69+
match std::env::var("OTLP_GRPC") {
70+
Ok(url) if !url.is_empty() => services.push(ServiceStatus::healthy("tempo")),
71+
_ => services.push(ServiceStatus::unhealthy("tempo", "未配置 OTLP_GRPC")),
72+
}
73+
74+
ApiResponse::ok(BackendStatus {
75+
version: env!("CARGO_PKG_VERSION").to_string(),
76+
services,
77+
})
78+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import * as z from "zod";
2+
3+
export const ServiceStatusSchema = z.object({
4+
name: z.string(),
5+
healthy: z.boolean(),
6+
message: z.string().optional(),
7+
});
8+
9+
export const BackendStatusSchema = z.object({
10+
version: z.string(),
11+
services: z.array(ServiceStatusSchema),
12+
});
13+
14+
export class ServiceStatus {
15+
constructor(
16+
public readonly name: string,
17+
public readonly healthy: boolean,
18+
public readonly message?: string,
19+
) {
20+
}
21+
22+
static parse(json: unknown): z.infer<typeof ServiceStatusSchema> {
23+
return ServiceStatusSchema.parse(json);
24+
}
25+
26+
static deserialize(json: z.infer<typeof ServiceStatusSchema>): ServiceStatus {
27+
return new ServiceStatus(json.name, json.healthy, json.message);
28+
}
29+
}
30+
31+
export class BackendStatus {
32+
constructor(
33+
public readonly version: string,
34+
public readonly services: ServiceStatus[],
35+
) {
36+
}
37+
38+
get healthy(): boolean {
39+
return this.services.every(s => s.healthy);
40+
}
41+
42+
static parse(json: unknown): z.infer<typeof BackendStatusSchema> {
43+
return BackendStatusSchema.parse(json);
44+
}
45+
46+
static deserialize(json: z.infer<typeof BackendStatusSchema>): BackendStatus {
47+
return new BackendStatus(
48+
json.version,
49+
json.services.map(ServiceStatus.deserialize),
50+
);
51+
}
52+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<app-dashboard-panel>
2+
<mat-card-header>
3+
<mat-card-title>Status</mat-card-title>
4+
<app-icon-button (click)="refresh()">refresh</app-icon-button>
5+
</mat-card-header>
6+
<mat-card-content>
7+
@let status = backendStatus$ | async;
8+
@let error = error$ | async;
9+
<div class="status-panel">
10+
<div class="status-row">
11+
<span class="key">Dashboard</span>
12+
<span class="value version">v{{ dashboardVersion }}</span>
13+
</div>
14+
15+
<div class="status-row">
16+
<span class="key">API</span>
17+
@if (status) {
18+
<span class="value version">v{{ status.version }}</span>
19+
} @else if (error) {
20+
<span class="value health unhealthy">
21+
<span class="dot"></span>
22+
<span class="desc">{{ error }}</span>
23+
</span>
24+
} @else {
25+
<span class="value health offline">
26+
<span class="dot"></span>
27+
<span class="desc">loading...</span>
28+
</span>
29+
}
30+
</div>
31+
32+
@if (status) {
33+
@for (svc of status.services; track svc.name) {
34+
<div class="status-row">
35+
<span class="key">{{ svc.name }}</span>
36+
<span class="value health" [class.healthy]="svc.healthy" [class.unhealthy]="!svc.healthy">
37+
<span class="dot"></span>
38+
<span class="desc">{{ svc.healthy ? 'healthy' : (svc.message ?? 'unhealthy') }}</span>
39+
</span>
40+
</div>
41+
}
42+
}
43+
</div>
44+
</mat-card-content>
45+
</app-dashboard-panel>
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
:host {
2+
display: block;
3+
width: 100%;
4+
5+
mat-card-header {
6+
display: flex;
7+
align-items: center;
8+
justify-content: space-between;
9+
}
10+
11+
.status-panel {
12+
display: grid;
13+
grid-template-columns: auto 1fr;
14+
gap: 4px 8px;
15+
align-items: center;
16+
}
17+
18+
.status-row {
19+
display: grid;
20+
grid-template-columns: subgrid;
21+
grid-column: 1 / -1;
22+
align-items: center;
23+
font-size: 12px;
24+
line-height: 18px;
25+
26+
.key {
27+
min-width: 48px;
28+
color: var(--mat-sys-on-surface-variant);
29+
opacity: 0.7;
30+
text-transform: capitalize;
31+
}
32+
33+
.value {
34+
color: var(--mat-sys-on-surface);
35+
36+
&.version {
37+
font-family: "JetBrains Mono", "Fira Code", monospace;
38+
opacity: 0.9;
39+
}
40+
41+
&.health {
42+
display: inline-flex;
43+
align-items: center;
44+
gap: 4px;
45+
46+
.dot {
47+
width: 6px;
48+
height: 6px;
49+
border-radius: 50%;
50+
display: inline-block;
51+
flex-shrink: 0;
52+
}
53+
54+
.desc {
55+
opacity: 0.9;
56+
}
57+
}
58+
59+
&.healthy .dot {
60+
background: #4caf50;
61+
}
62+
63+
&.unhealthy .dot {
64+
background: #f44336;
65+
}
66+
67+
&.offline {
68+
.dot {
69+
background: #757575;
70+
}
71+
72+
.desc {
73+
opacity: 0.5;
74+
}
75+
}
76+
}
77+
}
78+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { AsyncPipe } from "@angular/common";
2+
import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core";
3+
import { MatCardContent, MatCardHeader, MatCardTitle } from "@angular/material/card";
4+
import { BehaviorSubject } from "rxjs";
5+
import { BackendStatus } from "../../../common/model/api/backend-status";
6+
import { MetadataService } from "../../../service/metadata.service";
7+
import { StatusService } from "../../../service/status.service";
8+
import { DashboardPanel } from "../dashboard-panel/dashboard-panel";
9+
import { IconButton } from "../../shared/icon-button/icon-button";
10+
11+
@Component({
12+
selector: "app-dashboard-status",
13+
imports: [AsyncPipe, DashboardPanel, MatCardHeader, MatCardTitle, MatCardContent, IconButton],
14+
templateUrl: "./dashboard-status.html",
15+
styleUrl: "./dashboard-status.scss",
16+
changeDetection: ChangeDetectionStrategy.OnPush,
17+
})
18+
export class DashboardStatus implements OnInit {
19+
private metadataService = inject(MetadataService);
20+
private statusService = inject(StatusService);
21+
22+
dashboardVersion = this.metadataService.version;
23+
backendStatus$ = new BehaviorSubject<BackendStatus | null>(null);
24+
error$ = new BehaviorSubject<string | null>(null);
25+
26+
ngOnInit(): void {
27+
this.refresh();
28+
}
29+
30+
refresh(): void {
31+
this.error$.next(null);
32+
this.backendStatus$.next(null);
33+
this.statusService.getStatus().subscribe({
34+
next: status => this.backendStatus$.next(status),
35+
error: () => this.error$.next("无法连接后端"),
36+
});
37+
}
38+
}

dashboard/src/app/page/dashboard/dashboard.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
<h1>Convertor · Dashboard</h1>
1+
<h1>Convertor</h1>
2+
<app-dashboard-status></app-dashboard-status>
23
<app-dashboard-param></app-dashboard-param>
34
@let data = data$ | async;
45
@let error = error$ | async;

dashboard/src/app/page/dashboard/dashboard.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
padding: 24px 120px;
1010
gap: 16px;
1111

12+
h1 .version {
13+
font-size: 0.45em;
14+
font-weight: 400;
15+
opacity: 0.5;
16+
vertical-align: middle;
17+
}
18+
1219
.dashboard-content {
1320
display: flex;
1421
flex: 1 1 auto;

0 commit comments

Comments
 (0)