feat: 补全多云资产与账单同步
- 接入 AWS、华为云和 Azure 资产及账单 API - 按原始币种隔离统计并修正账号服务器数 - 修复侧栏菜单展开后的滚动行为
This commit is contained in:
1 parent
65e2f09b97
commit
132fe9ba97
25 files changed
+1094
-141
No files matched your search
@@ -0,0 +1,162 @@
|
|||||||
|
package cloud
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AWSProvider 通过 EC2 Query API 同步 AWS 区域与实例。 // Logan.Li
|
||||||
|
type AWSProvider struct {
|
||||||
|
accessKey string
|
||||||
|
secretKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAWSProvider 创建 AWS Provider。 // Logan.Li
|
||||||
|
func NewAWSProvider(accessKey, secretKey string) *AWSProvider {
|
||||||
|
return &AWSProvider{accessKey: accessKey, secretKey: secretKey}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name 返回 AWS 厂商标识。 // Logan.Li
|
||||||
|
func (p *AWSProvider) Name() string { return "aws" }
|
||||||
|
|
||||||
|
// call 使用 AWS Signature V4 调用 EC2 Query API。 // Logan.Li
|
||||||
|
func (p *AWSProvider) call(region, action string, values url.Values) ([]byte, error) {
|
||||||
|
if region == "" {
|
||||||
|
region = "us-east-1"
|
||||||
|
}
|
||||||
|
values.Set("Action", action)
|
||||||
|
values.Set("Version", "2016-11-15")
|
||||||
|
now := time.Now().UTC()
|
||||||
|
date := now.Format("20060102")
|
||||||
|
host := "ec2." + region + ".amazonaws.com"
|
||||||
|
payload := values.Encode()
|
||||||
|
canonicalHeaders := "content-type:application/x-www-form-urlencoded; charset=utf-8\nhost:" + host + "\nx-amz-date:" + now.Format("20060102T150405Z") + "\n"
|
||||||
|
signedHeaders := "content-type;host;x-amz-date"
|
||||||
|
canonicalRequest := "POST\n/\n\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + sha256Hex(payload)
|
||||||
|
credentialScope := date + "/" + region + "/ec2/aws4_request"
|
||||||
|
stringToSign := "AWS4-HMAC-SHA256\n" + now.Format("20060102T150405Z") + "\n" + credentialScope + "\n" + sha256Hex(canonicalRequest)
|
||||||
|
kDate := hmacSHA256([]byte("AWS4"+p.secretKey), date)
|
||||||
|
kRegion := hmacSHA256(kDate, region)
|
||||||
|
kService := hmacSHA256(kRegion, "ec2")
|
||||||
|
signature := hex.EncodeToString(hmacSHA256(hmacSHA256(kService, "aws4_request"), stringToSign))
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://"+host+"/", strings.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建 AWS 请求失败: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
|
||||||
|
req.Header.Set("X-Amz-Date", now.Format("20060102T150405Z"))
|
||||||
|
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+p.accessKey+"/"+credentialScope+", SignedHeaders="+signedHeaders+", Signature="+signature)
|
||||||
|
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("请求 AWS API 失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取 AWS 响应失败: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("AWS API 返回 HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRegions 获取当前凭据可见的 AWS 区域。 // Logan.Li
|
||||||
|
func (p *AWSProvider) ListRegions() ([]CloudRegion, error) {
|
||||||
|
body, err := p.call("us-east-1", "DescribeRegions", url.Values{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
Regions []struct {
|
||||||
|
Name string `xml:"regionName"`
|
||||||
|
Endpoint string `xml:"regionEndpoint"`
|
||||||
|
} `xml:"regionInfo>item"`
|
||||||
|
}
|
||||||
|
if err := xml.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析 AWS 区域响应失败: %w", err)
|
||||||
|
}
|
||||||
|
regions := make([]CloudRegion, 0, len(result.Regions))
|
||||||
|
for _, region := range result.Regions {
|
||||||
|
regions = append(regions, CloudRegion{RegionID: region.Name, LocalName: region.Name, Endpoint: "https://" + region.Endpoint, Status: "active"})
|
||||||
|
}
|
||||||
|
sort.Slice(regions, func(i, j int) bool { return regions[i].RegionID < regions[j].RegionID })
|
||||||
|
return regions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInstances 获取一个 AWS 区域中的 EC2 实例。 // Logan.Li
|
||||||
|
func (p *AWSProvider) ListInstances(regionID string) ([]CloudInstance, error) {
|
||||||
|
body, err := p.call(regionID, "DescribeInstances", url.Values{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
Reservations []struct {
|
||||||
|
Instances []struct {
|
||||||
|
ID string `xml:"instanceId"`
|
||||||
|
Type string `xml:"instanceType"`
|
||||||
|
State string `xml:"instanceState>name"`
|
||||||
|
PrivateIP string `xml:"privateIpAddress"`
|
||||||
|
PublicIP string `xml:"ipAddress"`
|
||||||
|
Launch string `xml:"launchTime"`
|
||||||
|
Placement string `xml:"placement>availabilityZone"`
|
||||||
|
Platform string `xml:"platform"`
|
||||||
|
Tags []struct {
|
||||||
|
Key string `xml:"key"`
|
||||||
|
Value string `xml:"value"`
|
||||||
|
} `xml:"tagSet>item"`
|
||||||
|
} `xml:"instancesSet>item"`
|
||||||
|
} `xml:"reservationSet>item"`
|
||||||
|
}
|
||||||
|
if err := xml.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析 AWS 实例响应失败: %w", err)
|
||||||
|
}
|
||||||
|
instances := []CloudInstance{}
|
||||||
|
for _, reservation := range result.Reservations {
|
||||||
|
for _, item := range reservation.Instances {
|
||||||
|
name := item.ID
|
||||||
|
for _, tag := range item.Tags {
|
||||||
|
if tag.Key == "Name" && tag.Value != "" {
|
||||||
|
name = tag.Value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
osType := "linux"
|
||||||
|
if strings.EqualFold(item.Platform, "windows") {
|
||||||
|
osType = "windows"
|
||||||
|
}
|
||||||
|
instances = append(instances, CloudInstance{InstanceID: item.ID, InstanceName: name, Hostname: name, OSType: osType, Status: awsState(item.State), RegionID: regionID, ZoneID: item.Placement, PrivateIP: item.PrivateIP, PublicIP: item.PublicIP, InstanceType: item.Type, CreationTime: item.Launch})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instances, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// awsState 统一 AWS 实例状态,便于复用现有资产导入逻辑。 // Logan.Li
|
||||||
|
func awsState(state string) string {
|
||||||
|
if state == "running" {
|
||||||
|
return "Running"
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
// sha256Hex 计算签名所需的 SHA256 十六进制摘要。 // Logan.Li
|
||||||
|
func sha256Hex(value string) string {
|
||||||
|
sum := sha256.Sum256([]byte(value))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// hmacSHA256 计算签名链中的 HMAC-SHA256。 // Logan.Li
|
||||||
|
func hmacSHA256(key []byte, value string) []byte {
|
||||||
|
mac := hmac.New(sha256.New, key)
|
||||||
|
mac.Write([]byte(value))
|
||||||
|
return mac.Sum(nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package cloud
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FetchDailyBill 通过 Cost Explorer 拉取 AWS 单日服务和区域费用。 // Logan.Li
|
||||||
|
func (p *AWSProvider) FetchDailyBill(billingDate string) ([]CloudBillingRecord, error) {
|
||||||
|
start, err := time.Parse("2006-01-02", billingDate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("账单日期格式应为 YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
type aggregationKey struct {
|
||||||
|
service string
|
||||||
|
region string
|
||||||
|
}
|
||||||
|
aggregation := map[aggregationKey]float64{}
|
||||||
|
nextToken := ""
|
||||||
|
currency := "USD"
|
||||||
|
for {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"TimePeriod": map[string]string{"Start": billingDate, "End": start.AddDate(0, 0, 1).Format("2006-01-02")},
|
||||||
|
"Granularity": "DAILY",
|
||||||
|
"Metrics": []string{"UnblendedCost"},
|
||||||
|
"GroupBy": []map[string]string{
|
||||||
|
{"Type": "DIMENSION", "Key": "SERVICE"},
|
||||||
|
{"Type": "DIMENSION", "Key": "REGION"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if nextToken != "" {
|
||||||
|
payload["NextPageToken"] = nextToken
|
||||||
|
}
|
||||||
|
body, callErr := p.callCostExplorer(payload)
|
||||||
|
if callErr != nil {
|
||||||
|
return nil, callErr
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
NextPageToken string `json:"NextPageToken"`
|
||||||
|
ResultsByTime []struct {
|
||||||
|
Groups []struct {
|
||||||
|
Keys []string `json:"Keys"`
|
||||||
|
Metrics map[string]struct {
|
||||||
|
Amount string `json:"Amount"`
|
||||||
|
Unit string `json:"Unit"`
|
||||||
|
} `json:"Metrics"`
|
||||||
|
} `json:"Groups"`
|
||||||
|
} `json:"ResultsByTime"`
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(body, &response); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析 AWS Cost Explorer 响应失败: %w", err)
|
||||||
|
}
|
||||||
|
for _, period := range response.ResultsByTime {
|
||||||
|
for _, group := range period.Groups {
|
||||||
|
metric := group.Metrics["UnblendedCost"]
|
||||||
|
cost, _ := strconv.ParseFloat(metric.Amount, 64)
|
||||||
|
if cost == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if metric.Unit != "" {
|
||||||
|
currency = metric.Unit
|
||||||
|
}
|
||||||
|
service, region := "其他", "全局"
|
||||||
|
if len(group.Keys) > 0 && group.Keys[0] != "" {
|
||||||
|
service = group.Keys[0]
|
||||||
|
}
|
||||||
|
if len(group.Keys) > 1 && group.Keys[1] != "" && group.Keys[1] != "NoRegion" {
|
||||||
|
region = group.Keys[1]
|
||||||
|
}
|
||||||
|
aggregation[aggregationKey{service: service, region: region}] += cost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextToken = response.NextPageToken
|
||||||
|
if nextToken == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys := make([]aggregationKey, 0, len(aggregation))
|
||||||
|
for key := range aggregation {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Slice(keys, func(i, j int) bool {
|
||||||
|
if keys[i].service == keys[j].service {
|
||||||
|
return keys[i].region < keys[j].region
|
||||||
|
}
|
||||||
|
return keys[i].service < keys[j].service
|
||||||
|
})
|
||||||
|
records := make([]CloudBillingRecord, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
records = append(records, CloudBillingRecord{
|
||||||
|
BillingDate: billingDate, Provider: "aws", Cost: math.Round(aggregation[key]*1000000) / 1000000,
|
||||||
|
Currency: currency, Service: key.service, Region: key.region,
|
||||||
|
Description: "云 API 自动同步(AWS Cost Explorer)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// callCostExplorer 使用 Signature V4 调用 AWS Cost Explorer JSON API。 // Logan.Li
|
||||||
|
func (p *AWSProvider) callCostExplorer(payload interface{}) ([]byte, error) {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("编码 AWS 账单请求失败: %w", err)
|
||||||
|
}
|
||||||
|
const host = "ce.us-east-1.amazonaws.com"
|
||||||
|
const target = "AWSInsightsIndexService.GetCostAndUsage"
|
||||||
|
now := time.Now().UTC()
|
||||||
|
amzDate, date := now.Format("20060102T150405Z"), now.Format("20060102")
|
||||||
|
canonicalHeaders := "content-type:application/x-amz-json-1.1\nhost:" + host + "\nx-amz-date:" + amzDate + "\nx-amz-target:" + target + "\n"
|
||||||
|
signedHeaders := "content-type;host;x-amz-date;x-amz-target"
|
||||||
|
canonicalRequest := "POST\n/\n\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + sha256Hex(string(body))
|
||||||
|
credentialScope := date + "/us-east-1/ce/aws4_request"
|
||||||
|
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + credentialScope + "\n" + sha256Hex(canonicalRequest)
|
||||||
|
kDate := hmacSHA256([]byte("AWS4"+p.secretKey), date)
|
||||||
|
kRegion := hmacSHA256(kDate, "us-east-1")
|
||||||
|
kService := hmacSHA256(kRegion, "ce")
|
||||||
|
signature := hex.EncodeToString(hmacSHA256(hmacSHA256(kService, "aws4_request"), stringToSign))
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://"+host+"/", strings.NewReader(string(body)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建 AWS 账单请求失败: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-amz-json-1.1")
|
||||||
|
req.Header.Set("X-Amz-Date", amzDate)
|
||||||
|
req.Header.Set("X-Amz-Target", target)
|
||||||
|
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+p.accessKey+"/"+credentialScope+", SignedHeaders="+signedHeaders+", Signature="+signature)
|
||||||
|
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("请求 AWS Cost Explorer 失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取 AWS Cost Explorer 响应失败: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("AWS Cost Explorer 返回 HTTP %d: %s", resp.StatusCode, string(data))
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package cloud
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AzureProvider 使用服务主体凭据同步 Azure Resource Manager 虚拟机。 // Logan.Li
|
||||||
|
type AzureProvider struct{ tenantID, subscriptionID, clientID, clientSecret string }
|
||||||
|
|
||||||
|
// NewAzureProvider 创建 Azure Provider,AccessKey 格式为 tenantId:subscriptionId:clientId。 // Logan.Li
|
||||||
|
func NewAzureProvider(accessKey, secretKey string) *AzureProvider {
|
||||||
|
parts := strings.Split(accessKey, ":")
|
||||||
|
p := &AzureProvider{clientSecret: secretKey}
|
||||||
|
if len(parts) == 3 {
|
||||||
|
p.tenantID, p.subscriptionID, p.clientID = parts[0], parts[1], parts[2]
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name 返回 Azure 厂商标识。 // Logan.Li
|
||||||
|
func (p *AzureProvider) Name() string { return "azure" }
|
||||||
|
|
||||||
|
// token 获取 ARM 所需的服务主体访问令牌。 // Logan.Li
|
||||||
|
func (p *AzureProvider) token() (string, error) {
|
||||||
|
if p.tenantID == "" || p.subscriptionID == "" || p.clientID == "" || p.clientSecret == "" {
|
||||||
|
return "", fmt.Errorf("Azure AccessKey 应为 租户ID:订阅ID:客户端ID,SecretKey 应为客户端密钥")
|
||||||
|
}
|
||||||
|
form := url.Values{"client_id": {p.clientID}, "client_secret": {p.clientSecret}, "grant_type": {"client_credentials"}, "scope": {"https://management.azure.com/.default"}}
|
||||||
|
resp, err := (&http.Client{Timeout: 30 * time.Second}).PostForm("https://login.microsoftonline.com/"+p.tenantID+"/oauth2/v2.0/token", form)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("获取 Azure 令牌失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return "", fmt.Errorf("Azure 令牌接口返回 HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
var data struct {
|
||||||
|
Token string `json:"access_token"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &data); err != nil || data.Token == "" {
|
||||||
|
return "", fmt.Errorf("解析 Azure 令牌失败")
|
||||||
|
}
|
||||||
|
return data.Token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// get 调用 Azure Resource Manager GET API。 // Logan.Li
|
||||||
|
func (p *AzureProvider) get(path string) ([]byte, error) {
|
||||||
|
token, err := p.token()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, "https://management.azure.com"+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("请求 Azure API 失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("Azure API 返回 HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// post 调用 Azure Resource Manager JSON POST API。 // Logan.Li
|
||||||
|
func (p *AzureProvider) post(path string, payload interface{}) ([]byte, error) {
|
||||||
|
token, err := p.token()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("编码 Azure 请求失败: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://management.azure.com"+path, strings.NewReader(string(body)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("请求 Azure API 失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("Azure API 返回 HTTP %d: %s", resp.StatusCode, string(data))
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRegions 获取 Azure 订阅可用区域。 // Logan.Li
|
||||||
|
func (p *AzureProvider) ListRegions() ([]CloudRegion, error) {
|
||||||
|
body, err := p.get("/subscriptions/" + p.subscriptionID + "/locations?api-version=2022-12-01")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
Value []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
} `json:"value"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析 Azure 区域响应失败: %w", err)
|
||||||
|
}
|
||||||
|
regions := []CloudRegion{}
|
||||||
|
for _, item := range result.Value {
|
||||||
|
regions = append(regions, CloudRegion{RegionID: item.Name, LocalName: item.DisplayName, Endpoint: "https://management.azure.com", Status: "active"})
|
||||||
|
}
|
||||||
|
return regions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInstances 获取 Azure 指定区域的虚拟机并保留 ARM 提供的基础元数据。 // Logan.Li
|
||||||
|
func (p *AzureProvider) ListInstances(regionID string) ([]CloudInstance, error) {
|
||||||
|
body, err := p.get("/subscriptions/" + p.subscriptionID + "/providers/Microsoft.Compute/virtualMachines?api-version=2024-03-01")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
Value []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Properties struct {
|
||||||
|
Hardware struct {
|
||||||
|
VMSize string `json:"vmSize"`
|
||||||
|
} `json:"hardwareProfile"`
|
||||||
|
OS struct {
|
||||||
|
Type string `json:"osType"`
|
||||||
|
Name string `json:"computerName"`
|
||||||
|
} `json:"osProfile"`
|
||||||
|
Provisioning string `json:"provisioningState"`
|
||||||
|
} `json:"properties"`
|
||||||
|
} `json:"value"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析 Azure 实例响应失败: %w", err)
|
||||||
|
}
|
||||||
|
instances := []CloudInstance{}
|
||||||
|
for _, item := range result.Value {
|
||||||
|
if !strings.EqualFold(item.Location, regionID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
instances = append(instances, CloudInstance{InstanceID: item.ID, InstanceName: item.Name, Hostname: item.Properties.OS.Name, OSType: item.Properties.OS.Type, Status: azureState(item.Properties.Provisioning), RegionID: item.Location, InstanceType: item.Properties.Hardware.VMSize})
|
||||||
|
}
|
||||||
|
return instances, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// azureState 统一 Azure 实例状态,便于复用导入逻辑。 // Logan.Li
|
||||||
|
func azureState(state string) string {
|
||||||
|
if strings.EqualFold(state, "Succeeded") {
|
||||||
|
return "Running"
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package cloud
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FetchDailyBill 通过 Azure Cost Management Query 拉取订阅单日成本。 // Logan.Li
|
||||||
|
func (p *AzureProvider) FetchDailyBill(billingDate string) ([]CloudBillingRecord, error) {
|
||||||
|
start, err := time.Parse("2006-01-02", billingDate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("账单日期格式应为 YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
if p.subscriptionID == "" {
|
||||||
|
return nil, fmt.Errorf("Azure AccessKey 应为 租户ID:订阅ID:客户端ID")
|
||||||
|
}
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"type": "ActualCost",
|
||||||
|
"timeframe": "Custom",
|
||||||
|
"timePeriod": map[string]string{
|
||||||
|
"from": start.Format("2006-01-02T00:00:00Z"),
|
||||||
|
"to": start.AddDate(0, 0, 1).Format("2006-01-02T00:00:00Z"),
|
||||||
|
},
|
||||||
|
"dataset": map[string]interface{}{
|
||||||
|
"granularity": "Daily",
|
||||||
|
"aggregation": map[string]interface{}{
|
||||||
|
"totalCost": map[string]string{"name": "PreTaxCost", "function": "Sum"},
|
||||||
|
},
|
||||||
|
"grouping": []map[string]string{
|
||||||
|
{"type": "Dimension", "name": "ServiceName"},
|
||||||
|
{"type": "Dimension", "name": "ResourceLocation"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
path := "/subscriptions/" + p.subscriptionID + "/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
|
||||||
|
body, err := p.post(path, payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
Properties struct {
|
||||||
|
Columns []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"columns"`
|
||||||
|
Rows [][]interface{} `json:"rows"`
|
||||||
|
} `json:"properties"`
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(body, &response); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析 Azure Cost Management 响应失败: %w", err)
|
||||||
|
}
|
||||||
|
columnIndex := map[string]int{}
|
||||||
|
for index, column := range response.Properties.Columns {
|
||||||
|
columnIndex[strings.ToLower(column.Name)] = index
|
||||||
|
}
|
||||||
|
valueAt := func(row []interface{}, names ...string) interface{} {
|
||||||
|
for _, name := range names {
|
||||||
|
if index, ok := columnIndex[strings.ToLower(name)]; ok && index < len(row) {
|
||||||
|
return row[index]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
type aggregationKey struct {
|
||||||
|
service string
|
||||||
|
region string
|
||||||
|
}
|
||||||
|
aggregation := map[aggregationKey]float64{}
|
||||||
|
currency := "USD"
|
||||||
|
for _, row := range response.Properties.Rows {
|
||||||
|
cost, _ := strconv.ParseFloat(fmt.Sprint(valueAt(row, "PreTaxCost", "Cost", "totalCost")), 64)
|
||||||
|
if cost == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
service := firstNonEmpty(fmt.Sprint(valueAt(row, "ServiceName")), "其他")
|
||||||
|
region := firstNonEmpty(fmt.Sprint(valueAt(row, "ResourceLocation")), "全局")
|
||||||
|
if value := fmt.Sprint(valueAt(row, "Currency")); value != "" && value != "<nil>" {
|
||||||
|
currency = value
|
||||||
|
}
|
||||||
|
aggregation[aggregationKey{service: service, region: region}] += cost
|
||||||
|
}
|
||||||
|
keys := make([]aggregationKey, 0, len(aggregation))
|
||||||
|
for key := range aggregation {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Slice(keys, func(i, j int) bool {
|
||||||
|
if keys[i].service == keys[j].service {
|
||||||
|
return keys[i].region < keys[j].region
|
||||||
|
}
|
||||||
|
return keys[i].service < keys[j].service
|
||||||
|
})
|
||||||
|
records := make([]CloudBillingRecord, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
records = append(records, CloudBillingRecord{
|
||||||
|
BillingDate: billingDate, Provider: "azure", Cost: math.Round(aggregation[key]*1000000) / 1000000,
|
||||||
|
Currency: currency, Service: key.service, Region: key.region,
|
||||||
|
Description: "云 API 自动同步(Azure Cost Management)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ type BillingFetcher interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SupportedBillingProviders 已接入账单 API 的云厂商列表。 // Logan.Li
|
// SupportedBillingProviders 已接入账单 API 的云厂商列表。 // Logan.Li
|
||||||
var SupportedBillingProviders = []string{"aliyun", "tencent"}
|
var SupportedBillingProviders = []string{"aliyun", "tencent", "aws", "huawei", "azure"}
|
||||||
|
|
||||||
// NewBillingFetcher 根据云厂商创建账单拉取器
|
// NewBillingFetcher 根据云厂商创建账单拉取器
|
||||||
func NewBillingFetcher(provider, accessKey, secretKey string) BillingFetcher {
|
func NewBillingFetcher(provider, accessKey, secretKey string) BillingFetcher {
|
||||||
@@ -26,6 +26,12 @@ func NewBillingFetcher(provider, accessKey, secretKey string) BillingFetcher {
|
|||||||
return NewAliyunProvider(accessKey, secretKey)
|
return NewAliyunProvider(accessKey, secretKey)
|
||||||
case "tencent":
|
case "tencent":
|
||||||
return NewTencentProvider(accessKey, secretKey)
|
return NewTencentProvider(accessKey, secretKey)
|
||||||
|
case "aws":
|
||||||
|
return NewAWSProvider(accessKey, secretKey)
|
||||||
|
case "huawei":
|
||||||
|
return NewHuaweiProvider(accessKey, secretKey)
|
||||||
|
case "azure":
|
||||||
|
return NewAzureProvider(accessKey, secretKey)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package cloud
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HuaweiProvider 通过华为云 AK/SK 签名 API 同步项目区域与 ECS 实例。 // Logan.Li
|
||||||
|
type HuaweiProvider struct{ accessKey, secretKey string }
|
||||||
|
|
||||||
|
// NewHuaweiProvider 创建华为云 Provider。 // Logan.Li
|
||||||
|
func NewHuaweiProvider(accessKey, secretKey string) *HuaweiProvider {
|
||||||
|
return &HuaweiProvider{accessKey, secretKey}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name 返回华为云厂商标识。 // Logan.Li
|
||||||
|
func (p *HuaweiProvider) Name() string { return "huawei" }
|
||||||
|
|
||||||
|
// signedGet 使用 SDK-HMAC-SHA256 调用华为云 GET API。 // Logan.Li
|
||||||
|
func (p *HuaweiProvider) signedGet(rawURL, _ string) ([]byte, error) {
|
||||||
|
u, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建华为云请求失败: %w", err)
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format("20060102T150405Z")
|
||||||
|
host := u.URL.Host
|
||||||
|
canonicalHeaders := "host:" + host + "\n" + "x-sdk-date:" + now + "\n"
|
||||||
|
signedHeaders := "host;x-sdk-date"
|
||||||
|
canonicalRequest := "GET\n" + u.URL.EscapedPath() + "\n" + u.URL.Query().Encode() + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + sha256Hex("")
|
||||||
|
stringToSign := "SDK-HMAC-SHA256\n" + now + "\n" + sha256Hex(canonicalRequest)
|
||||||
|
signature := hex.EncodeToString(hmacSHA256([]byte(p.secretKey), stringToSign))
|
||||||
|
u.Header.Set("X-Sdk-Date", now)
|
||||||
|
u.Header.Set("Authorization", "SDK-HMAC-SHA256 Access="+p.accessKey+", SignedHeaders="+signedHeaders+", Signature="+signature)
|
||||||
|
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(u)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("请求华为云 API 失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取华为云响应失败: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("华为云 API 返回 HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRegions 获取当前账号的项目列表,项目 ID 作为可用于 ECS 请求的区域代码。 // Logan.Li
|
||||||
|
func (p *HuaweiProvider) ListRegions() ([]CloudRegion, error) {
|
||||||
|
body, err := p.signedGet("https://iam.myhuaweicloud.com/v3/projects", "iam")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
Projects []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"projects"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析华为云区域响应失败: %w", err)
|
||||||
|
}
|
||||||
|
regions := []CloudRegion{}
|
||||||
|
for _, project := range result.Projects {
|
||||||
|
if project.ID == "" || project.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
regions = append(regions, CloudRegion{RegionID: project.ID, LocalName: project.Name, Endpoint: "https://ecs." + project.Name + ".myhuaweicloud.com", Status: "active"})
|
||||||
|
}
|
||||||
|
sort.Slice(regions, func(i, j int) bool { return regions[i].LocalName < regions[j].LocalName })
|
||||||
|
return regions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInstances 获取指定华为云项目中的 ECS 实例。 // Logan.Li
|
||||||
|
func (p *HuaweiProvider) ListInstances(projectID string) ([]CloudInstance, error) {
|
||||||
|
regions, err := p.ListRegions()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
regionName := ""
|
||||||
|
for _, region := range regions {
|
||||||
|
if region.RegionID == projectID {
|
||||||
|
regionName = region.LocalName
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if regionName == "" {
|
||||||
|
return nil, fmt.Errorf("未找到华为云项目 %s", projectID)
|
||||||
|
}
|
||||||
|
body, err := p.signedGet("https://ecs."+regionName+".myhuaweicloud.com/v2.1/"+projectID+"/servers/detail", "ecs")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
Servers []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Flavor struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"flavor"`
|
||||||
|
Created string `json:"created"`
|
||||||
|
Metadata map[string]string `json:"metadata"`
|
||||||
|
Addresses map[string][]struct {
|
||||||
|
Addr string `json:"addr"`
|
||||||
|
Type string `json:"OS-EXT-IPS:type"`
|
||||||
|
} `json:"addresses"`
|
||||||
|
Zone string `json:"OS-EXT-AZ:availability_zone"`
|
||||||
|
} `json:"servers"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析华为云实例响应失败: %w", err)
|
||||||
|
}
|
||||||
|
instances := []CloudInstance{}
|
||||||
|
for _, server := range result.Servers {
|
||||||
|
privateIP, publicIP := "", ""
|
||||||
|
for _, addresses := range server.Addresses {
|
||||||
|
for _, address := range addresses {
|
||||||
|
if address.Type == "floating" {
|
||||||
|
publicIP = address.Addr
|
||||||
|
} else if privateIP == "" {
|
||||||
|
privateIP = address.Addr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
osType := server.Metadata["os_type"]
|
||||||
|
if osType == "" {
|
||||||
|
osType = server.Metadata["__os_type"]
|
||||||
|
}
|
||||||
|
instances = append(instances, CloudInstance{InstanceID: server.ID, InstanceName: server.Name, Hostname: server.Name, OSType: osType, Status: huaweiState(server.Status), RegionID: projectID, ZoneID: server.Zone, PrivateIP: privateIP, PublicIP: publicIP, InstanceType: server.Flavor.ID, CreationTime: server.Created})
|
||||||
|
}
|
||||||
|
return instances, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// huaweiState 统一华为云实例状态,便于复用导入逻辑。 // Logan.Li
|
||||||
|
func huaweiState(state string) string {
|
||||||
|
if strings.EqualFold(state, "ACTIVE") {
|
||||||
|
return "Running"
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package cloud
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FetchDailyBill 通过华为云 BSS 费用流水接口拉取单日账单。 // Logan.Li
|
||||||
|
func (p *HuaweiProvider) FetchDailyBill(billingDate string) ([]CloudBillingRecord, error) {
|
||||||
|
if _, err := time.Parse("2006-01-02", billingDate); err != nil {
|
||||||
|
return nil, fmt.Errorf("账单日期格式应为 YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
type aggregationKey struct {
|
||||||
|
service string
|
||||||
|
region string
|
||||||
|
}
|
||||||
|
aggregation := map[aggregationKey]float64{}
|
||||||
|
const pageSize = 1000
|
||||||
|
for offset := 0; ; offset += pageSize {
|
||||||
|
query := url.Values{
|
||||||
|
"cycle": {billingDate[:7]},
|
||||||
|
"bill_date_begin": {billingDate},
|
||||||
|
"bill_date_end": {billingDate},
|
||||||
|
"offset": {strconv.Itoa(offset)},
|
||||||
|
"limit": {strconv.Itoa(pageSize)},
|
||||||
|
}
|
||||||
|
body, err := p.signedGet("https://bss.myhuaweicloud.com/v2/bills/customer-bills/fee-records?"+query.Encode(), "bss")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
TotalCount int `json:"total_count"`
|
||||||
|
FeeRecords []struct {
|
||||||
|
CloudServiceType string `json:"cloud_service_type"`
|
||||||
|
CloudServiceTypeName string `json:"cloud_service_type_name"`
|
||||||
|
ResourceTypeName string `json:"resource_type_name"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
RegionName string `json:"region_name"`
|
||||||
|
Amount json.RawMessage `json:"amount"`
|
||||||
|
OfficialAmount json.RawMessage `json:"official_amount"`
|
||||||
|
DiscountAmount json.RawMessage `json:"discount_amount"`
|
||||||
|
} `json:"fee_records"`
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(body, &response); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析华为云 BSS 响应失败: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range response.FeeRecords {
|
||||||
|
cost := huaweiBillAmount(item.Amount)
|
||||||
|
if cost == 0 {
|
||||||
|
cost = huaweiBillAmount(item.OfficialAmount) - huaweiBillAmount(item.DiscountAmount)
|
||||||
|
}
|
||||||
|
if cost == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
service := firstNonEmpty(item.CloudServiceTypeName, item.ResourceTypeName, item.CloudServiceType, "其他")
|
||||||
|
region := firstNonEmpty(item.RegionName, item.Region, "全局")
|
||||||
|
aggregation[aggregationKey{service: service, region: region}] += cost
|
||||||
|
}
|
||||||
|
if len(response.FeeRecords) < pageSize || (response.TotalCount > 0 && offset+pageSize >= response.TotalCount) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys := make([]aggregationKey, 0, len(aggregation))
|
||||||
|
for key := range aggregation {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Slice(keys, func(i, j int) bool {
|
||||||
|
if keys[i].service == keys[j].service {
|
||||||
|
return keys[i].region < keys[j].region
|
||||||
|
}
|
||||||
|
return keys[i].service < keys[j].service
|
||||||
|
})
|
||||||
|
records := make([]CloudBillingRecord, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
records = append(records, CloudBillingRecord{
|
||||||
|
BillingDate: billingDate, Provider: "huawei", Cost: math.Round(aggregation[key]*100) / 100,
|
||||||
|
Currency: "CNY", Service: key.service, Region: key.region,
|
||||||
|
Description: "云 API 自动同步(华为云 BSS)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// huaweiBillAmount 兼容华为云金额字段的字符串和数字格式。 // Logan.Li
|
||||||
|
func huaweiBillAmount(raw json.RawMessage) float64 {
|
||||||
|
value := strings.Trim(strings.TrimSpace(string(raw)), "\"")
|
||||||
|
amount, err := strconv.ParseFloat(value, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return amount
|
||||||
|
}
|
||||||
@@ -42,12 +42,14 @@ func NewProvider(provider, accessKey, secretKey string) Provider {
|
|||||||
switch provider {
|
switch provider {
|
||||||
case "aliyun":
|
case "aliyun":
|
||||||
return NewAliyunProvider(accessKey, secretKey)
|
return NewAliyunProvider(accessKey, secretKey)
|
||||||
|
case "aws":
|
||||||
|
return NewAWSProvider(accessKey, secretKey)
|
||||||
case "tencent":
|
case "tencent":
|
||||||
return NewTencentProvider(accessKey, secretKey) // 启用腾讯云 CVM 区域与实例同步。 // Logan.Li
|
return NewTencentProvider(accessKey, secretKey) // 启用腾讯云 CVM 区域与实例同步。 // Logan.Li
|
||||||
// case "huawei":
|
case "huawei":
|
||||||
// return NewHuaweiProvider(accessKey, secretKey)
|
return NewHuaweiProvider(accessKey, secretKey)
|
||||||
// case "aws":
|
case "azure":
|
||||||
// return NewAWSProvider(accessKey, secretKey)
|
return NewAzureProvider(accessKey, secretKey)
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,3 +26,12 @@ func TestTencentRetryDelay(t *testing.T) {
|
|||||||
t.Fatal("非限频错误不应重试")
|
t.Fatal("非限频错误不应重试")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBillingFetcherProviders 验证五家云厂商均已注册账单拉取器。 // Logan.Li
|
||||||
|
func TestBillingFetcherProviders(t *testing.T) {
|
||||||
|
for _, provider := range SupportedBillingProviders {
|
||||||
|
if fetcher := NewBillingFetcher(provider, "ak", "sk"); fetcher == nil {
|
||||||
|
t.Fatalf("云厂商 %s 未注册账单拉取器", provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,9 @@ func (h *BillingHandler) ListBillings(c *gin.Context) {
|
|||||||
if accountID := c.Query("cloud_account_id"); accountID != "" {
|
if accountID := c.Query("cloud_account_id"); accountID != "" {
|
||||||
query = query.Where("cloud_account_id = ?", accountID)
|
query = query.Where("cloud_account_id = ?", accountID)
|
||||||
}
|
}
|
||||||
|
if currency := c.Query("currency"); currency != "" {
|
||||||
|
query = query.Where("currency = ?", currency)
|
||||||
|
}
|
||||||
if startDate := c.Query("start_date"); startDate != "" {
|
if startDate := c.Query("start_date"); startDate != "" {
|
||||||
query = query.Where("billing_date >= ?", startDate)
|
query = query.Where("billing_date >= ?", startDate)
|
||||||
}
|
}
|
||||||
@@ -167,27 +170,29 @@ func (h *BillingHandler) GetBillingStats(c *gin.Context) {
|
|||||||
yesterday := now.AddDate(0, 0, -1).Format("2006-01-02")
|
yesterday := now.AddDate(0, 0, -1).Format("2006-01-02")
|
||||||
monthStart := now.Format("2006-01") + "-01"
|
monthStart := now.Format("2006-01") + "-01"
|
||||||
yearStart := fmt.Sprintf("%d-01-01", now.Year())
|
yearStart := fmt.Sprintf("%d-01-01", now.Year())
|
||||||
|
currency := c.DefaultQuery("currency", "CNY")
|
||||||
|
|
||||||
stats := model.BillingStats{
|
stats := model.BillingStats{
|
||||||
|
Currency: currency,
|
||||||
MonthTrend: []model.DailyCost{},
|
MonthTrend: []model.DailyCost{},
|
||||||
ProviderDist: []model.ProviderCost{},
|
ProviderDist: []model.ProviderCost{},
|
||||||
Alerts: []model.BillingAlert{},
|
Alerts: []model.BillingAlert{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// 今日成本
|
// 今日成本
|
||||||
h.db.Model(&model.CloudBilling{}).Where("billing_date = ?", today).
|
h.db.Model(&model.CloudBilling{}).Where("billing_date = ? AND currency = ?", today, currency).
|
||||||
Select("COALESCE(SUM(cost), 0)").Scan(&stats.TodayCost)
|
Select("COALESCE(SUM(cost), 0)").Scan(&stats.TodayCost)
|
||||||
|
|
||||||
// 昨日成本
|
// 昨日成本
|
||||||
h.db.Model(&model.CloudBilling{}).Where("billing_date = ?", yesterday).
|
h.db.Model(&model.CloudBilling{}).Where("billing_date = ? AND currency = ?", yesterday, currency).
|
||||||
Select("COALESCE(SUM(cost), 0)").Scan(&stats.YesterdayCost)
|
Select("COALESCE(SUM(cost), 0)").Scan(&stats.YesterdayCost)
|
||||||
|
|
||||||
// 本月成本
|
// 本月成本
|
||||||
h.db.Model(&model.CloudBilling{}).Where("billing_date >= ?", monthStart).
|
h.db.Model(&model.CloudBilling{}).Where("billing_date >= ? AND currency = ?", monthStart, currency).
|
||||||
Select("COALESCE(SUM(cost), 0)").Scan(&stats.MonthCost)
|
Select("COALESCE(SUM(cost), 0)").Scan(&stats.MonthCost)
|
||||||
|
|
||||||
// 本年成本
|
// 本年成本
|
||||||
h.db.Model(&model.CloudBilling{}).Where("billing_date >= ?", yearStart).
|
h.db.Model(&model.CloudBilling{}).Where("billing_date >= ? AND currency = ?", yearStart, currency).
|
||||||
Select("COALESCE(SUM(cost), 0)").Scan(&stats.YearCost)
|
Select("COALESCE(SUM(cost), 0)").Scan(&stats.YearCost)
|
||||||
|
|
||||||
// 近30天趋势
|
// 近30天趋势
|
||||||
@@ -198,7 +203,7 @@ func (h *BillingHandler) GetBillingStats(c *gin.Context) {
|
|||||||
var trends []dayCost
|
var trends []dayCost
|
||||||
h.db.Model(&model.CloudBilling{}).
|
h.db.Model(&model.CloudBilling{}).
|
||||||
Select("billing_date, SUM(cost) as total").
|
Select("billing_date, SUM(cost) as total").
|
||||||
Where("billing_date >= ?", now.AddDate(0, 0, -29).Format("2006-01-02")).
|
Where("billing_date >= ? AND currency = ?", now.AddDate(0, 0, -29).Format("2006-01-02"), currency).
|
||||||
Group("billing_date").
|
Group("billing_date").
|
||||||
Order("billing_date ASC").
|
Order("billing_date ASC").
|
||||||
Scan(&trends)
|
Scan(&trends)
|
||||||
@@ -215,7 +220,7 @@ func (h *BillingHandler) GetBillingStats(c *gin.Context) {
|
|||||||
var provCosts []provCost
|
var provCosts []provCost
|
||||||
h.db.Model(&model.CloudBilling{}).
|
h.db.Model(&model.CloudBilling{}).
|
||||||
Select("provider, SUM(cost) as total").
|
Select("provider, SUM(cost) as total").
|
||||||
Where("billing_date >= ?", monthStart).
|
Where("billing_date >= ? AND currency = ?", monthStart, currency).
|
||||||
Group("provider").
|
Group("provider").
|
||||||
Scan(&provCosts)
|
Scan(&provCosts)
|
||||||
|
|
||||||
@@ -258,7 +263,7 @@ func (h *BillingHandler) checkBillingAlerts(stats *model.BillingStats, now time.
|
|||||||
|
|
||||||
if rule.Metric == "daily" {
|
if rule.Metric == "daily" {
|
||||||
// 查今日该厂商/账号的成本
|
// 查今日该厂商/账号的成本
|
||||||
q := h.db.Model(&model.CloudBilling{}).Where("billing_date = ?", now.Format("2006-01-02"))
|
q := h.db.Model(&model.CloudBilling{}).Where("billing_date = ? AND currency = ?", now.Format("2006-01-02"), stats.Currency)
|
||||||
if rule.Provider != "" {
|
if rule.Provider != "" {
|
||||||
q = q.Where("provider = ?", rule.Provider)
|
q = q.Where("provider = ?", rule.Provider)
|
||||||
}
|
}
|
||||||
@@ -268,7 +273,7 @@ func (h *BillingHandler) checkBillingAlerts(stats *model.BillingStats, now time.
|
|||||||
q.Select("COALESCE(SUM(cost), 0)").Scan(¤t)
|
q.Select("COALESCE(SUM(cost), 0)").Scan(¤t)
|
||||||
} else {
|
} else {
|
||||||
// monthly - 本月累计
|
// monthly - 本月累计
|
||||||
q := h.db.Model(&model.CloudBilling{}).Where("billing_date >= ?", now.Format("2006-01")+"-01")
|
q := h.db.Model(&model.CloudBilling{}).Where("billing_date >= ? AND currency = ?", now.Format("2006-01")+"-01", stats.Currency)
|
||||||
if rule.Provider != "" {
|
if rule.Provider != "" {
|
||||||
q = q.Where("provider = ?", rule.Provider)
|
q = q.Where("provider = ?", rule.Provider)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -32,6 +33,24 @@ func NewCloudAccountHandler(db *gorm.DB) *CloudAccountHandler {
|
|||||||
func (h *CloudAccountHandler) ListCloudAccounts(c *gin.Context) {
|
func (h *CloudAccountHandler) ListCloudAccounts(c *gin.Context) {
|
||||||
var accounts []model.CloudAccount
|
var accounts []model.CloudAccount
|
||||||
h.db.Find(&accounts)
|
h.db.Find(&accounts)
|
||||||
|
// 服务器数来自真实云账号关联,避免重复导入后被“本次新增数”覆盖。 // Logan.Li
|
||||||
|
type accountServerCount struct {
|
||||||
|
CloudAccountID uint
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
var serverCounts []accountServerCount
|
||||||
|
h.db.Model(&model.Server{}).
|
||||||
|
Select("cloud_account_id, COUNT(*) AS total").
|
||||||
|
Where("cloud_account_id IS NOT NULL").Group("cloud_account_id").Scan(&serverCounts)
|
||||||
|
countByAccount := make(map[uint]int, len(serverCounts))
|
||||||
|
for _, item := range serverCounts {
|
||||||
|
countByAccount[item.CloudAccountID] = item.Total
|
||||||
|
}
|
||||||
|
for index := range accounts {
|
||||||
|
if total, ok := countByAccount[accounts[index].ID]; ok {
|
||||||
|
accounts[index].ServerCount = total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, model.Response{
|
c.JSON(http.StatusOK, model.Response{
|
||||||
Code: 200,
|
Code: 200,
|
||||||
@@ -80,6 +99,10 @@ func (h *CloudAccountHandler) CreateCloudAccount(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := validateCloudAccountCredentials(req.Provider, req.AccessKey, req.SecretKey); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
status := strings.TrimSpace(req.Status)
|
status := strings.TrimSpace(req.Status)
|
||||||
if status == "" {
|
if status == "" {
|
||||||
@@ -158,6 +181,10 @@ func (h *CloudAccountHandler) UpdateCloudAccount(c *gin.Context) {
|
|||||||
account.Status = strings.TrimSpace(req.Status)
|
account.Status = strings.TrimSpace(req.Status)
|
||||||
}
|
}
|
||||||
account.MonthlyCost = req.MonthlyCost
|
account.MonthlyCost = req.MonthlyCost
|
||||||
|
if err := validateCloudAccountCredentials(account.Provider, account.AccessKey, account.SecretKey); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.db.Save(&account).Error; err != nil {
|
if err := h.db.Save(&account).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, model.Response{
|
c.JSON(http.StatusInternalServerError, model.Response{
|
||||||
@@ -174,6 +201,20 @@ func (h *CloudAccountHandler) UpdateCloudAccount(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateCloudAccountCredentials 校验各云厂商调用 API 所需的凭据格式。 // Logan.Li
|
||||||
|
func validateCloudAccountCredentials(provider, accessKey, secretKey string) error {
|
||||||
|
if strings.TrimSpace(accessKey) == "" || strings.TrimSpace(secretKey) == "" {
|
||||||
|
return fmt.Errorf("AccessKey 和 SecretKey 不能为空")
|
||||||
|
}
|
||||||
|
if provider == "azure" {
|
||||||
|
parts := strings.Split(accessKey, ":")
|
||||||
|
if len(parts) != 3 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" || strings.TrimSpace(parts[2]) == "" {
|
||||||
|
return fmt.Errorf("Azure AccessKey 格式应为 租户ID:订阅ID:客户端ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteCloudAccount 删除云账号
|
// DeleteCloudAccount 删除云账号
|
||||||
// @Summary 删除云账号
|
// @Summary 删除云账号
|
||||||
// @Description 删除指定云账号
|
// @Description 删除指定云账号
|
||||||
@@ -199,4 +240,3 @@ func (h *CloudAccountHandler) DeleteCloudAccount(c *gin.Context) {
|
|||||||
Message: "删除成功",
|
Message: "删除成功",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,10 +227,12 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) {
|
|||||||
ip = inst.InstanceID
|
ip = inst.InstanceID
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否已存在(按 IP 或 hostname)
|
// 重复资产补齐来源账号关联,保证服务器数统计准确。 // Logan.Li
|
||||||
var count int64
|
var existing model.Server
|
||||||
h.db.Model(&model.Server{}).Where("ip = ? OR hostname = ?", ip, inst.Hostname).Count(&count)
|
if err := h.db.Where("ip = ? OR hostname = ?", ip, inst.Hostname).Take(&existing).Error; err == nil {
|
||||||
if count > 0 {
|
if existing.CloudAccountID == nil {
|
||||||
|
h.db.Model(&existing).Update("cloud_account_id", account.ID)
|
||||||
|
}
|
||||||
skipped++
|
skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -275,22 +277,23 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) {
|
|||||||
// 注意:servers 表的 cloud_id 外键引用的是 clouds 表(非 cloud_accounts),
|
// 注意:servers 表的 cloud_id 外键引用的是 clouds 表(非 cloud_accounts),
|
||||||
// region_id 外键引用 regions 表。未找到关联时设为 NULL 避免外键约束失败。
|
// region_id 外键引用 regions 表。未找到关联时设为 NULL 避免外键约束失败。
|
||||||
server := model.Server{
|
server := model.Server{
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
IP: ipAddr,
|
IP: ipAddr,
|
||||||
PublicIP: inst.PublicIP,
|
PublicIP: inst.PublicIP,
|
||||||
Port: defaultPort,
|
Port: defaultPort,
|
||||||
Username: username,
|
Username: username,
|
||||||
AuthType: "password",
|
AuthType: "password",
|
||||||
OSType: osType,
|
OSType: osType,
|
||||||
Protocol: protocol,
|
Protocol: protocol,
|
||||||
RDPPort: 3389,
|
RDPPort: 3389,
|
||||||
OS: osName,
|
OS: osName,
|
||||||
CPU: inst.CPU,
|
CPU: inst.CPU,
|
||||||
Memory: int64(inst.Memory),
|
Memory: int64(inst.Memory),
|
||||||
Status: status,
|
Status: status,
|
||||||
RegionID: regionID, // nil = NULL
|
RegionID: regionID, // nil = NULL
|
||||||
Description: "从 " + account.Provider + " 导入 (" + inst.InstanceID + ")",
|
CloudAccountID: &account.ID,
|
||||||
AgentStatus: "offline",
|
Description: "从 " + account.Provider + " 导入 (" + inst.InstanceID + ")",
|
||||||
|
AgentStatus: "offline",
|
||||||
}
|
}
|
||||||
if protocol == "rdp" && hasRDPCred {
|
if protocol == "rdp" && hasRDPCred {
|
||||||
applyCredential(rdpCred, rdpPlain, &server)
|
applyCredential(rdpCred, rdpPlain, &server)
|
||||||
@@ -305,8 +308,10 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新账号的 server_count
|
// 保存该账号当前已关联的资产总数,而不是本次新增数。 // Logan.Li
|
||||||
h.db.Model(&account).UpdateColumn("server_count", imported)
|
var serverCount int64
|
||||||
|
h.db.Model(&model.Server{}).Where("cloud_account_id = ?", account.ID).Count(&serverCount)
|
||||||
|
h.db.Model(&account).UpdateColumn("server_count", serverCount)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, model.Response{
|
c.JSON(http.StatusOK, model.Response{
|
||||||
Code: 200,
|
Code: 200,
|
||||||
|
|||||||
+42
-42
@@ -2,24 +2,23 @@ package model
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CloudBilling 云账单模型 - 每日成本记录
|
// CloudBilling 云账单模型 - 每日成本记录
|
||||||
type CloudBilling struct {
|
type CloudBilling struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
CloudAccountID *uint `gorm:"index" json:"cloud_account_id"`
|
CloudAccountID *uint `gorm:"index" json:"cloud_account_id"`
|
||||||
AccountName string `gorm:"size:128" json:"account_name"`
|
AccountName string `gorm:"size:128" json:"account_name"`
|
||||||
Provider string `gorm:"size:64;index" json:"provider"` // aliyun, tencent, aws, huawei, azure
|
Provider string `gorm:"size:64;index" json:"provider"` // aliyun, tencent, aws, huawei, azure
|
||||||
BillingDate string `gorm:"size:10;index;not null" json:"billing_date"` // YYYY-MM-DD
|
BillingDate string `gorm:"size:10;index;not null" json:"billing_date"` // YYYY-MM-DD
|
||||||
Cost float64 `gorm:"type:decimal(12,2);default:0" json:"cost"` // 费用(元)
|
Cost float64 `gorm:"type:decimal(12,2);default:0" json:"cost"` // 费用(元)
|
||||||
Currency string `gorm:"size:8;default:CNY" json:"currency"` // CNY, USD
|
Currency string `gorm:"size:8;default:CNY" json:"currency"` // CNY, USD
|
||||||
Service string `gorm:"size:128" json:"service"` // ECS, RDS, OSS, CDN 等
|
Service string `gorm:"size:128" json:"service"` // ECS, RDS, OSS, CDN 等
|
||||||
Region string `gorm:"size:64" json:"region"`
|
Region string `gorm:"size:64" json:"region"`
|
||||||
Tags string `gorm:"type:text" json:"tags"` // JSON
|
Tags string `gorm:"type:text" json:"tags"` // JSON
|
||||||
Description string `gorm:"size:500" json:"description"`
|
Description string `gorm:"size:500" json:"description"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (CloudBilling) TableName() string {
|
func (CloudBilling) TableName() string {
|
||||||
@@ -28,17 +27,17 @@ func (CloudBilling) TableName() string {
|
|||||||
|
|
||||||
// BillingAlertRule 账单预警规则
|
// BillingAlertRule 账单预警规则
|
||||||
type BillingAlertRule struct {
|
type BillingAlertRule struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
Name string `gorm:"size:128;not null" json:"name"`
|
Name string `gorm:"size:128;not null" json:"name"`
|
||||||
Provider string `gorm:"size:64" json:"provider"` // 空=所有云厂商
|
Provider string `gorm:"size:64" json:"provider"` // 空=所有云厂商
|
||||||
CloudAccountID *uint `gorm:"index" json:"cloud_account_id"` // 空=所有账号
|
CloudAccountID *uint `gorm:"index" json:"cloud_account_id"` // 空=所有账号
|
||||||
Metric string `gorm:"size:32;default:daily" json:"metric"` // daily, monthly
|
Metric string `gorm:"size:32;default:daily" json:"metric"` // daily, monthly
|
||||||
Threshold float64 `gorm:"type:decimal(12,2)" json:"threshold"` // 阈值(元)
|
Threshold float64 `gorm:"type:decimal(12,2)" json:"threshold"` // 阈值(元)
|
||||||
Operator string `gorm:"size:8;default:>" json:"operator"` // >, >=, <, <=
|
Operator string `gorm:"size:8;default:>" json:"operator"` // >, >=, <, <=
|
||||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||||
LastTriggered *time.Time `json:"last_triggered"`
|
LastTriggered *time.Time `json:"last_triggered"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (BillingAlertRule) TableName() string {
|
func (BillingAlertRule) TableName() string {
|
||||||
@@ -47,19 +46,20 @@ func (BillingAlertRule) TableName() string {
|
|||||||
|
|
||||||
// BillingStats 账单统计
|
// BillingStats 账单统计
|
||||||
type BillingStats struct {
|
type BillingStats struct {
|
||||||
TodayCost float64 `json:"today_cost"`
|
Currency string `json:"currency"`
|
||||||
MonthCost float64 `json:"month_cost"`
|
TodayCost float64 `json:"today_cost"`
|
||||||
YearCost float64 `json:"year_cost"`
|
MonthCost float64 `json:"month_cost"`
|
||||||
YesterdayCost float64 `json:"yesterday_cost"`
|
YearCost float64 `json:"year_cost"`
|
||||||
MonthTrend []DailyCost `json:"month_trend"`
|
YesterdayCost float64 `json:"yesterday_cost"`
|
||||||
ProviderDist []ProviderCost `json:"provider_dist"`
|
MonthTrend []DailyCost `json:"month_trend"`
|
||||||
Alerts []BillingAlert `json:"alerts"`
|
ProviderDist []ProviderCost `json:"provider_dist"`
|
||||||
|
Alerts []BillingAlert `json:"alerts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DailyCost 每日成本
|
// DailyCost 每日成本
|
||||||
type DailyCost struct {
|
type DailyCost struct {
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
Cost float64 `json:"cost"`
|
Cost float64 `json:"cost"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProviderCost 厂商成本分布
|
// ProviderCost 厂商成本分布
|
||||||
@@ -82,15 +82,15 @@ type BillingAlert struct {
|
|||||||
|
|
||||||
// CreateBillingRequest 创建账单记录请求
|
// CreateBillingRequest 创建账单记录请求
|
||||||
type CreateBillingRequest struct {
|
type CreateBillingRequest struct {
|
||||||
CloudAccountID *uint `json:"cloud_account_id"`
|
CloudAccountID *uint `json:"cloud_account_id"`
|
||||||
AccountName string `json:"account_name"`
|
AccountName string `json:"account_name"`
|
||||||
Provider string `json:"provider" binding:"required"`
|
Provider string `json:"provider" binding:"required"`
|
||||||
BillingDate string `json:"billing_date" binding:"required"`
|
BillingDate string `json:"billing_date" binding:"required"`
|
||||||
Cost float64 `json:"cost" binding:"required"`
|
Cost float64 `json:"cost" binding:"required"`
|
||||||
Currency string `json:"currency"`
|
Currency string `json:"currency"`
|
||||||
Service string `json:"service"`
|
Service string `json:"service"`
|
||||||
Region string `json:"region"`
|
Region string `json:"region"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateBillingAlertRequest 创建预警规则请求
|
// CreateBillingAlertRequest 创建预警规则请求
|
||||||
|
|||||||
+48
-46
@@ -6,40 +6,41 @@ import (
|
|||||||
|
|
||||||
// Server 服务器模型
|
// Server 服务器模型
|
||||||
type Server struct {
|
type Server struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
Hostname string `gorm:"size:128;not null" json:"hostname"`
|
Hostname string `gorm:"size:128;not null" json:"hostname"`
|
||||||
IP string `gorm:"size:64;not null" json:"ip"`
|
IP string `gorm:"size:64;not null" json:"ip"`
|
||||||
PublicIP string `gorm:"size:64" json:"public_ip"`
|
PublicIP string `gorm:"size:64" json:"public_ip"`
|
||||||
Port int `gorm:"default:22" json:"port"`
|
Port int `gorm:"default:22" json:"port"`
|
||||||
Username string `gorm:"size:64;default:root" json:"username"`
|
Username string `gorm:"size:64;default:root" json:"username"`
|
||||||
Password string `gorm:"size:255" json:"-"`
|
Password string `gorm:"size:255" json:"-"`
|
||||||
SSHKey string `gorm:"type:text" json:"-"`
|
SSHKey string `gorm:"type:text" json:"-"`
|
||||||
AuthType string `gorm:"size:32;default:password" json:"auth_type"` // password, key
|
AuthType string `gorm:"size:32;default:password" json:"auth_type"` // password, key
|
||||||
OSType string `gorm:"size:32;default:linux" json:"os_type"` // 操作系统类型:linux/windows // Logan.Li
|
OSType string `gorm:"size:32;default:linux" json:"os_type"` // 操作系统类型:linux/windows // Logan.Li
|
||||||
Protocol string `gorm:"size:32;default:ssh" json:"protocol"` // 远程协议:ssh/rdp // Logan.Li
|
Protocol string `gorm:"size:32;default:ssh" json:"protocol"` // 远程协议:ssh/rdp // Logan.Li
|
||||||
RDPPort int `gorm:"default:3389" json:"rdp_port"` // Windows RDP 端口 // Logan.Li
|
RDPPort int `gorm:"default:3389" json:"rdp_port"` // Windows RDP 端口 // Logan.Li
|
||||||
OS string `gorm:"size:64" json:"os"`
|
OS string `gorm:"size:64" json:"os"`
|
||||||
Kernel string `gorm:"size:64" json:"kernel"`
|
Kernel string `gorm:"size:64" json:"kernel"`
|
||||||
OSVersion string `gorm:"size:64" json:"os_version"`
|
OSVersion string `gorm:"size:64" json:"os_version"`
|
||||||
Arch string `gorm:"size:32" json:"arch"`
|
Arch string `gorm:"size:32" json:"arch"`
|
||||||
CPU int `json:"cpu"`
|
CPU int `json:"cpu"`
|
||||||
Memory int64 `json:"memory"` // MB
|
Memory int64 `json:"memory"` // MB
|
||||||
Disk int64 `json:"disk"` // GB
|
Disk int64 `json:"disk"` // GB
|
||||||
Status string `gorm:"size:32;default:stopped" json:"status"` // running, stopped, maintenance, error
|
Status string `gorm:"size:32;default:stopped" json:"status"` // running, stopped, maintenance, error
|
||||||
CPUUsage float64 `json:"cpu_usage"`
|
CPUUsage float64 `json:"cpu_usage"`
|
||||||
MemoryUsage float64 `json:"memory_usage"`
|
MemoryUsage float64 `json:"memory_usage"`
|
||||||
DiskUsage float64 `json:"disk_usage"`
|
DiskUsage float64 `json:"disk_usage"`
|
||||||
Uptime int64 `json:"uptime"` // seconds
|
Uptime int64 `json:"uptime"` // seconds
|
||||||
AgentVersion string `gorm:"size:32" json:"agent_version"`
|
AgentVersion string `gorm:"size:32" json:"agent_version"`
|
||||||
AgentStatus string `gorm:"size:32;default:offline" json:"agent_status"` // online, offline
|
AgentStatus string `gorm:"size:32;default:offline" json:"agent_status"` // online, offline
|
||||||
HasAuth bool `gorm:"-" json:"has_auth"` // 是否有认证凭据(密码或SSH密钥) — 不存DB,handler 计算后返回 // Logan.Li
|
HasAuth bool `gorm:"-" json:"has_auth"` // 是否有认证凭据(密码或SSH密钥) — 不存DB,handler 计算后返回 // Logan.Li
|
||||||
RegionID *uint `json:"region_id"`
|
RegionID *uint `json:"region_id"`
|
||||||
CloudID *uint `json:"cloud_id"`
|
CloudID *uint `json:"cloud_id"`
|
||||||
Tags string `gorm:"type:text" json:"tags"` // JSON
|
CloudAccountID *uint `gorm:"index" json:"cloud_account_id"` // 关联导入来源云账号,用于准确统计。 // Logan.Li
|
||||||
Description string `gorm:"size:500" json:"description"`
|
Tags string `gorm:"type:text" json:"tags"` // JSON
|
||||||
LastCheckAt *time.Time `json:"last_check_at"`
|
Description string `gorm:"size:500" json:"description"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
LastCheckAt *time.Time `json:"last_check_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
// 关联
|
// 关联
|
||||||
Region *Region `json:"region,omitempty"`
|
Region *Region `json:"region,omitempty"`
|
||||||
@@ -71,18 +72,19 @@ func (Cloud) TableName() string {
|
|||||||
|
|
||||||
// CloudAccount 云账号模型
|
// CloudAccount 云账号模型
|
||||||
type CloudAccount struct {
|
type CloudAccount struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
Name string `gorm:"size:128;not null" json:"name"`
|
Name string `gorm:"size:128;not null" json:"name"`
|
||||||
Provider string `gorm:"size:64;not null" json:"provider"` // aws, aliyun, tencent, huawei, azure
|
Provider string `gorm:"size:64;not null" json:"provider"` // aws, aliyun, tencent, huawei, azure
|
||||||
AccessKey string `gorm:"size:255;not null" json:"-"`
|
AccessKey string `gorm:"size:255;not null" json:"-"`
|
||||||
SecretKey string `gorm:"size:255;not null" json:"-"`
|
SecretKey string `gorm:"size:255;not null" json:"-"`
|
||||||
Status string `gorm:"size:32;default:active" json:"status"` // active, inactive, error
|
Status string `gorm:"size:32;default:active" json:"status"` // active, inactive, error
|
||||||
RegionCount int `gorm:"default:0" json:"region_count"`
|
RegionCount int `gorm:"default:0" json:"region_count"`
|
||||||
ServerCount int `gorm:"default:0" json:"server_count"`
|
ServerCount int `gorm:"default:0" json:"server_count"`
|
||||||
MonthlyCost float64 `gorm:"default:0" json:"monthly_cost"` // 月费用(元),驱动仪表盘成本统计
|
MonthlyCost float64 `gorm:"default:0" json:"monthly_cost"` // 月费用(元),驱动仪表盘成本统计
|
||||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
BillingCurrency string `gorm:"size:8;default:CNY" json:"billing_currency"` // 账单原始币种。 // Logan.Li
|
||||||
CreatedAt time.Time `json:"created_at"`
|
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名
|
// TableName 表名
|
||||||
|
|||||||
@@ -95,8 +95,15 @@ func syncAccount(db *gorm.DB, account model.CloudAccount, billingDate string) (c
|
|||||||
|
|
||||||
accountID := account.ID
|
accountID := account.ID
|
||||||
accountName := account.Name
|
accountName := account.Name
|
||||||
|
billingCurrency := records[0].Currency
|
||||||
|
if billingCurrency == "" {
|
||||||
|
billingCurrency = "CNY"
|
||||||
|
}
|
||||||
|
|
||||||
for _, rec := range records {
|
for _, rec := range records {
|
||||||
|
if rec.Currency == "" {
|
||||||
|
rec.Currency = billingCurrency
|
||||||
|
}
|
||||||
var existing model.CloudBilling
|
var existing model.CloudBilling
|
||||||
findErr := db.Where(
|
findErr := db.Where(
|
||||||
"cloud_account_id = ? AND billing_date = ? AND provider = ? AND service = ? AND region = ?",
|
"cloud_account_id = ? AND billing_date = ? AND provider = ? AND service = ? AND region = ?",
|
||||||
@@ -145,9 +152,12 @@ func syncAccount(db *gorm.DB, account model.CloudAccount, billingDate string) (c
|
|||||||
monthStart := billingDate[:7] + "-01"
|
monthStart := billingDate[:7] + "-01"
|
||||||
var monthCost float64
|
var monthCost float64
|
||||||
db.Model(&model.CloudBilling{}).
|
db.Model(&model.CloudBilling{}).
|
||||||
Where("cloud_account_id = ? AND billing_date >= ?", accountID, monthStart).
|
Where("cloud_account_id = ? AND billing_date >= ? AND currency = ?", accountID, monthStart, billingCurrency).
|
||||||
Select("COALESCE(SUM(cost), 0)").Scan(&monthCost)
|
Select("COALESCE(SUM(cost), 0)").Scan(&monthCost)
|
||||||
db.Model(&account).Update("monthly_cost", monthCost)
|
db.Model(&account).Updates(map[string]interface{}{
|
||||||
|
"monthly_cost": monthCost,
|
||||||
|
"billing_currency": billingCurrency,
|
||||||
|
})
|
||||||
|
|
||||||
return created, updated, skipped, nil
|
return created, updated, skipped, nil
|
||||||
}
|
}
|
||||||
@@ -3,13 +3,13 @@
|
|||||||
import { http, type ListResponse } from '@/utils/request';
|
import { http, type ListResponse } from '@/utils/request';
|
||||||
|
|
||||||
export const billingApi = {
|
export const billingApi = {
|
||||||
getStats: async () => {
|
getStats: async (currency = 'CNY') => {
|
||||||
return http.get('/cloud-billings/stats');
|
return http.get('/cloud-billings/stats', { currency });
|
||||||
},
|
},
|
||||||
getCostAnalysis: async () => {
|
getCostAnalysis: async () => {
|
||||||
return http.get('/cloud-billings/cost-analysis');
|
return http.get('/cloud-billings/cost-analysis');
|
||||||
},
|
},
|
||||||
list: async (params?: { provider?: string; cloud_account_id?: number; start_date?: string; end_date?: string; page?: number; page_size?: number }) => {
|
list: async (params?: { provider?: string; cloud_account_id?: number; currency?: string; start_date?: string; end_date?: string; page?: number; page_size?: number }) => {
|
||||||
return http.get<ListResponse>('/cloud-billings', params);
|
return http.get<ListResponse>('/cloud-billings', params);
|
||||||
},
|
},
|
||||||
create: async (data: Record<string, unknown>) => {
|
create: async (data: Record<string, unknown>) => {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function AIPredictiveAnalytics() {
|
|||||||
dashboardStatsApi.getMetrics(72),
|
dashboardStatsApi.getMetrics(72),
|
||||||
serverApi.getServerStats(),
|
serverApi.getServerStats(),
|
||||||
billingApi.getStats().catch(() => null),
|
billingApi.getStats().catch(() => null),
|
||||||
billingApi.list({ start_date: startDate, page_size: 500 }).catch(() => ({ list: [] })),
|
billingApi.list({ start_date: startDate, currency: 'CNY', page_size: 500 }).catch(() => ({ list: [] })),
|
||||||
billingApi.listAlertRules().catch(() => ({ list: [] })),
|
billingApi.listAlertRules().catch(() => ({ list: [] })),
|
||||||
billingApi.getCostAnalysis().catch(() => null),
|
billingApi.getCostAnalysis().catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { cloudApi, billingApi } from '@/api';
|
|||||||
import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon';
|
import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon';
|
||||||
|
|
||||||
import type { JsonData } from '@/types/common';
|
import type { JsonData } from '@/types/common';
|
||||||
|
import { formatCurrencyByCode } from './CloudBillingHelpers';
|
||||||
|
|
||||||
interface CloudAccountDetailSheetProps {
|
interface CloudAccountDetailSheetProps {
|
||||||
account: JsonData | null;
|
account: JsonData | null;
|
||||||
@@ -141,6 +142,7 @@ export function CloudAccountDetailSheet({
|
|||||||
const serverCount = account.server_count ?? account.serverCount ?? 0;
|
const serverCount = account.server_count ?? account.serverCount ?? 0;
|
||||||
const regionCount = account.region_count ?? account.regionCount ?? regions.length;
|
const regionCount = account.region_count ?? account.regionCount ?? regions.length;
|
||||||
const monthlyCost = account.monthly_cost ?? account.monthlyCost ?? 0;
|
const monthlyCost = account.monthly_cost ?? account.monthlyCost ?? 0;
|
||||||
|
const billingCurrency = account.billing_currency || account.billingCurrency || 'CNY';
|
||||||
const lastSync = account.last_sync_at || account.lastSyncAt || account.last_sync;
|
const lastSync = account.last_sync_at || account.lastSyncAt || account.last_sync;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -185,7 +187,7 @@ export function CloudAccountDetailSheet({
|
|||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="rounded-lg p-3 bg-[var(--dg-bg-tertiary)]">
|
<div className="rounded-lg p-3 bg-[var(--dg-bg-tertiary)]">
|
||||||
<p className="text-xs dg-text-secondary">{t('cloud.monthlyFee')}</p>
|
<p className="text-xs dg-text-secondary">{t('cloud.monthlyFee')}</p>
|
||||||
<p className="text-lg font-medium text-[var(--dg-green)]">¥{Number(monthlyCost).toLocaleString()}</p>
|
<p className="text-lg font-medium text-[var(--dg-green)]">{formatCurrencyByCode(Number(monthlyCost), billingCurrency)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg p-3 bg-[var(--dg-bg-tertiary)]">
|
<div className="rounded-lg p-3 bg-[var(--dg-bg-tertiary)]">
|
||||||
<p className="text-xs dg-text-secondary">{t('cloud.serverCount')}</p>
|
<p className="text-xs dg-text-secondary">{t('cloud.serverCount')}</p>
|
||||||
@@ -262,7 +264,7 @@ export function CloudAccountDetailSheet({
|
|||||||
<p className="text-xs dg-text-muted">{b.billing_date} · {b.region || '-'}</p>
|
<p className="text-xs dg-text-muted">{b.billing_date} · {b.region || '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[var(--dg-green)] font-medium">
|
<span className="text-[var(--dg-green)] font-medium">
|
||||||
¥{Number(b.cost || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
{formatCurrencyByCode(Number(b.cost || 0), b.currency)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -273,4 +275,4 @@ export function CloudAccountDetailSheet({
|
|||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,22 @@ export function ProviderDot({ provider, className = 'w-2 h-2' }: { provider: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatCurrency(n: number) {
|
export function formatCurrency(n: number) {
|
||||||
return `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
return formatCurrencyByCode(n, 'CNY');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按账单原始币种格式化,避免 USD 等费用被错误显示成人民币。 // Logan.Li
|
||||||
|
export function formatCurrencyByCode(n: number, currency = 'CNY') {
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat('zh-CN', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency || 'CNY',
|
||||||
|
currencyDisplay: 'narrowSymbol',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(Number(n) || 0);
|
||||||
|
} catch {
|
||||||
|
return `${currency || 'CNY'} ${(Number(n) || 0).toFixed(2)}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatCard({ icon, label, value, trend, trendLabel, alert }: {
|
export function StatCard({ icon, label, value, trend, trendLabel, alert }: {
|
||||||
@@ -106,4 +121,4 @@ export function TrendChart({ data }: { data: { date: string; cost: number }[] })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon';
|
import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon';
|
||||||
import type { JsonData } from '@/types/common';
|
import type { JsonData } from '@/types/common';
|
||||||
import type { ActionLoading } from './types';
|
import type { ActionLoading } from './types';
|
||||||
|
import { formatCurrencyByCode } from '../CloudBillingHelpers';
|
||||||
|
|
||||||
export interface AccountCardProps {
|
export interface AccountCardProps {
|
||||||
account: JsonData;
|
account: JsonData;
|
||||||
@@ -46,6 +47,7 @@ export function AccountCard({
|
|||||||
const serverCount = account.server_count ?? account.serverCount ?? 0;
|
const serverCount = account.server_count ?? account.serverCount ?? 0;
|
||||||
const lastSync = account.last_sync || account.lastSync || account.created_at || '-';
|
const lastSync = account.last_sync || account.lastSync || account.created_at || '-';
|
||||||
const monthlyCost = account.monthly_cost ?? account.monthlyCost ?? 0;
|
const monthlyCost = account.monthly_cost ?? account.monthlyCost ?? 0;
|
||||||
|
const billingCurrency = account.billing_currency || account.billingCurrency || 'CNY';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={account.id} className="dg-card dg-card-hover">
|
<Card key={account.id} className="dg-card dg-card-hover">
|
||||||
@@ -120,7 +122,7 @@ export function AccountCard({
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs dg-text-secondary">{t('cloud.monthlyFee')}</p>
|
<p className="text-xs dg-text-secondary">{t('cloud.monthlyFee')}</p>
|
||||||
<p className="text-lg font-medium text-[var(--dg-green)]">
|
<p className="text-lg font-medium text-[var(--dg-green)]">
|
||||||
¥{Number(monthlyCost).toLocaleString()}
|
{formatCurrencyByCode(Number(monthlyCost), billingCurrency)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ export function AccountFormDialog({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
}: AccountFormDialogProps) {
|
}: AccountFormDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const accessKeyPlaceholder = formData.provider === 'azure'
|
||||||
|
? t('cloud.azureAccessKeyPlaceholder')
|
||||||
|
: formData.provider === 'aws'
|
||||||
|
? t('cloud.awsAccessKeyPlaceholder')
|
||||||
|
: t('cloud.defaultAccessKeyPlaceholder');
|
||||||
|
const secretKeyPlaceholder = formData.provider === 'azure'
|
||||||
|
? t('cloud.azureSecretKeyPlaceholder')
|
||||||
|
: t('cloud.defaultSecretKeyPlaceholder');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -87,7 +95,7 @@ export function AccountFormDialog({
|
|||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="LTAI..."
|
placeholder={accessKeyPlaceholder}
|
||||||
value={formData.access_key}
|
value={formData.access_key}
|
||||||
onChange={(e) => onFormDataChange({ ...formData, access_key: e.target.value })}
|
onChange={(e) => onFormDataChange({ ...formData, access_key: e.target.value })}
|
||||||
className="dg-input font-mono text-sm"
|
className="dg-input font-mono text-sm"
|
||||||
@@ -103,7 +111,7 @@ export function AccountFormDialog({
|
|||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="••••••••"
|
placeholder={secretKeyPlaceholder}
|
||||||
value={formData.secret_key}
|
value={formData.secret_key}
|
||||||
onChange={(e) => onFormDataChange({ ...formData, secret_key: e.target.value })}
|
onChange={(e) => onFormDataChange({ ...formData, secret_key: e.target.value })}
|
||||||
className="dg-input font-mono text-sm"
|
className="dg-input font-mono text-sm"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Plus, Trash2, Pencil, RefreshCw, Wallet } from 'lucide-react';
|
|||||||
import { ContentPanel } from '@/components/layout/ContentPanel';
|
import { ContentPanel } from '@/components/layout/ContentPanel';
|
||||||
import { PageLoading } from '@/components/layout/PageLoading';
|
import { PageLoading } from '@/components/layout/PageLoading';
|
||||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||||
import { formatCurrency, providerLabels, ProviderDot } from '../CloudBillingHelpers';
|
import { formatCurrencyByCode, providerLabels, ProviderDot } from '../CloudBillingHelpers';
|
||||||
import type { JsonData } from '@/types/common';
|
import type { JsonData } from '@/types/common';
|
||||||
|
|
||||||
/** 成本记录筛选工具栏 — 云厂商下拉 + 日期范围 + 查询按钮 // Logan.Li */
|
/** 成本记录筛选工具栏 — 云厂商下拉 + 日期范围 + 查询按钮 // Logan.Li */
|
||||||
@@ -123,7 +123,7 @@ export function RecordsTab({
|
|||||||
<td className="px-4 py-2.5 dg-text-secondary">{r.service || '-'}</td>
|
<td className="px-4 py-2.5 dg-text-secondary">{r.service || '-'}</td>
|
||||||
<td className="px-4 py-2.5 dg-text-secondary">{r.region || '-'}</td>
|
<td className="px-4 py-2.5 dg-text-secondary">{r.region || '-'}</td>
|
||||||
<td className="px-4 py-2.5 dg-text-secondary">{r.account_name || '-'}</td>
|
<td className="px-4 py-2.5 dg-text-secondary">{r.account_name || '-'}</td>
|
||||||
<td className="px-4 py-2.5 text-right text-[var(--text-primary)] font-medium">{formatCurrency(r.cost)}</td>
|
<td className="px-4 py-2.5 text-right text-[var(--text-primary)] font-medium">{formatCurrencyByCode(r.cost, r.currency)}</td>
|
||||||
<td className="px-4 py-2.5 dg-text-muted max-w-xs truncate">{r.description || '-'}</td>
|
<td className="px-4 py-2.5 dg-text-muted max-w-xs truncate">{r.description || '-'}</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu Items */}
|
{/* Menu Items */}
|
||||||
<ScrollArea className="flex-1 py-2 px-1.5">
|
<ScrollArea className="flex-1 min-h-0 py-2 px-1.5 overscroll-contain">
|
||||||
<nav className="space-y-0.5">
|
<nav className="space-y-0.5">
|
||||||
{visibleMenuItems.map((item) => (
|
{visibleMenuItems.map((item) => (
|
||||||
<MenuItemComponent key={item.id} item={item} />
|
<MenuItemComponent key={item.id} item={item} />
|
||||||
|
|||||||
@@ -6945,6 +6945,11 @@
|
|||||||
"accountId": "Account ID",
|
"accountId": "Account ID",
|
||||||
"accountName": "Account Name",
|
"accountName": "Account Name",
|
||||||
"accountNamePlaceholder": "e.g. Aliyun Production",
|
"accountNamePlaceholder": "e.g. Aliyun Production",
|
||||||
|
"azureAccessKeyPlaceholder": "tenantId:subscriptionId:clientId",
|
||||||
|
"awsAccessKeyPlaceholder": "AWS Access Key ID",
|
||||||
|
"defaultAccessKeyPlaceholder": "AccessKey ID",
|
||||||
|
"azureSecretKeyPlaceholder": "Azure client secret",
|
||||||
|
"defaultSecretKeyPlaceholder": "SecretKey",
|
||||||
"accountSecretKey": "SecretKey",
|
"accountSecretKey": "SecretKey",
|
||||||
"accountStatus": "Status",
|
"accountStatus": "Status",
|
||||||
"accountType": "Vendor",
|
"accountType": "Vendor",
|
||||||
|
|||||||
@@ -6945,6 +6945,11 @@
|
|||||||
"accountId": "账号 ID",
|
"accountId": "账号 ID",
|
||||||
"accountName": "账号名称",
|
"accountName": "账号名称",
|
||||||
"accountNamePlaceholder": "阿里云生产账号",
|
"accountNamePlaceholder": "阿里云生产账号",
|
||||||
|
"azureAccessKeyPlaceholder": "租户ID:订阅ID:客户端ID",
|
||||||
|
"awsAccessKeyPlaceholder": "AWS Access Key ID",
|
||||||
|
"defaultAccessKeyPlaceholder": "AccessKey ID",
|
||||||
|
"azureSecretKeyPlaceholder": "Azure 客户端密钥",
|
||||||
|
"defaultSecretKeyPlaceholder": "SecretKey",
|
||||||
"accountSecretKey": "SecretKey",
|
"accountSecretKey": "SecretKey",
|
||||||
"accountStatus": "状态",
|
"accountStatus": "状态",
|
||||||
"accountType": "云厂商",
|
"accountType": "云厂商",
|
||||||
|
|||||||
Reference in new issue
Block a user