Add pluggable live collectors and simplify API connect form
This commit is contained in:
11
README.md
11
README.md
@@ -92,7 +92,7 @@ open http://localhost:8082
|
|||||||
|
|
||||||
```
|
```
|
||||||
POST /api/upload # Загрузить архив
|
POST /api/upload # Загрузить архив
|
||||||
POST /api/collect # Создать задачу live-сбора (in-memory mock lifecycle)
|
POST /api/collect # Создать задачу live-сбора
|
||||||
GET /api/collect/{id} # Получить статус задачи live-сбора
|
GET /api/collect/{id} # Получить статус задачи live-сбора
|
||||||
POST /api/collect/{id}/cancel # Отменить задачу live-сбора
|
POST /api/collect/{id}/cancel # Отменить задачу live-сбора
|
||||||
GET /api/status # Получить статус парсинга
|
GET /api/status # Получить статус парсинга
|
||||||
@@ -162,7 +162,14 @@ POST /api/shutdown # Завершить работу приложени
|
|||||||
```
|
```
|
||||||
|
|
||||||
`POST /api/collect/{id}/cancel` возвращает `200 OK` и переводит задачу в `canceled`.
|
`POST /api/collect/{id}/cancel` возвращает `200 OK` и переводит задачу в `canceled`.
|
||||||
Жизненный цикл mock-задачи: `queued -> running -> success|failed` (если `host` содержит `fail`, задача переходит в `failed`).
|
Жизненный цикл задачи: `queued -> running -> success|failed|canceled`.
|
||||||
|
|
||||||
|
### Подключаемые коннекторы live-сбора
|
||||||
|
|
||||||
|
- `redfish`: реальный сбор конфигурации с BMC по REST API (`/redfish/v1/...`)
|
||||||
|
- `ipmi`: временный mock-коннектор (каркас для последующей замены на реальный IPMI)
|
||||||
|
|
||||||
|
`host` можно передавать как обычный hostname (например, `bmc01.example.local`) или как полный URL (`https://10.0.0.10:8443`).
|
||||||
`AnalysisResult` для API-сценария обновляется на `success`; при `failed/canceled` предыдущие загруженные данные сохраняются.
|
`AnalysisResult` для API-сценария обновляется на `success`; при `failed/canceled` предыдущие загруженные данные сохраняются.
|
||||||
|
|
||||||
## Структура проекта
|
## Структура проекта
|
||||||
|
|||||||
18
internal/collector/helpers.go
Normal file
18
internal/collector/helpers.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sleepWithContext(ctx context.Context, d time.Duration) bool {
|
||||||
|
timer := time.NewTimer(d)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
case <-timer.C:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
42
internal/collector/ipmi_mock.go
Normal file
42
internal/collector/ipmi_mock.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IPMIMockConnector struct{}
|
||||||
|
|
||||||
|
func NewIPMIMockConnector() *IPMIMockConnector {
|
||||||
|
return &IPMIMockConnector{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *IPMIMockConnector) Protocol() string {
|
||||||
|
return "ipmi"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *IPMIMockConnector) Collect(ctx context.Context, req Request, emit ProgressFn) (*models.AnalysisResult, error) {
|
||||||
|
steps := []Progress{
|
||||||
|
{Status: "running", Progress: 20, Message: "IPMI: подключение к BMC..."},
|
||||||
|
{Status: "running", Progress: 55, Message: "IPMI: чтение инвентаря..."},
|
||||||
|
{Status: "running", Progress: 85, Message: "IPMI: нормализация данных..."},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, step := range steps {
|
||||||
|
if !sleepWithContext(ctx, 150*time.Millisecond) {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
if emit != nil {
|
||||||
|
emit(step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.AnalysisResult{
|
||||||
|
Events: make([]models.Event, 0),
|
||||||
|
FRU: make([]models.FRUInfo, 0),
|
||||||
|
Sensors: make([]models.SensorReading, 0),
|
||||||
|
Hardware: &models.HardwareConfig{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
414
internal/collector/redfish.go
Normal file
414
internal/collector/redfish.go
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RedfishConnector struct {
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRedfishConnector() *RedfishConnector {
|
||||||
|
return &RedfishConnector{
|
||||||
|
timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RedfishConnector) Protocol() string {
|
||||||
|
return "redfish"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit ProgressFn) (*models.AnalysisResult, error) {
|
||||||
|
baseURL, err := c.baseURL(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := c.httpClient(req)
|
||||||
|
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{Status: "running", Progress: 10, Message: "Redfish: подключение к BMC..."})
|
||||||
|
}
|
||||||
|
if _, err := c.getJSON(ctx, client, req, baseURL, "/redfish/v1"); err != nil {
|
||||||
|
return nil, fmt.Errorf("redfish service root: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{Status: "running", Progress: 30, Message: "Redfish: чтение данных системы..."})
|
||||||
|
}
|
||||||
|
systemDoc, err := c.getJSON(ctx, client, req, baseURL, "/redfish/v1/Systems/1")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("system info: %w", err)
|
||||||
|
}
|
||||||
|
biosDoc, _ := c.getJSON(ctx, client, req, baseURL, "/redfish/v1/Systems/1/Bios")
|
||||||
|
secureBootDoc, _ := c.getJSON(ctx, client, req, baseURL, "/redfish/v1/Systems/1/SecureBoot")
|
||||||
|
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{Status: "running", Progress: 55, Message: "Redfish: чтение CPU/RAM/Storage..."})
|
||||||
|
}
|
||||||
|
processors, _ := c.getCollectionMembers(ctx, client, req, baseURL, "/redfish/v1/Systems/1/Processors")
|
||||||
|
memory, _ := c.getCollectionMembers(ctx, client, req, baseURL, "/redfish/v1/Systems/1/Memory")
|
||||||
|
storageMembers, _ := c.getCollectionMembers(ctx, client, req, baseURL, "/redfish/v1/Systems/1/Storage")
|
||||||
|
storageDevices := c.collectStorage(ctx, client, req, baseURL, storageMembers)
|
||||||
|
|
||||||
|
if emit != nil {
|
||||||
|
emit(Progress{Status: "running", Progress: 80, Message: "Redfish: чтение сетевых и BMC настроек..."})
|
||||||
|
}
|
||||||
|
nics := c.collectNICs(ctx, client, req, baseURL)
|
||||||
|
managerDoc, _ := c.getJSON(ctx, client, req, baseURL, "/redfish/v1/Managers/1")
|
||||||
|
networkProtocolDoc, _ := c.getJSON(ctx, client, req, baseURL, "/redfish/v1/Managers/1/NetworkProtocol")
|
||||||
|
|
||||||
|
result := &models.AnalysisResult{
|
||||||
|
Events: make([]models.Event, 0),
|
||||||
|
FRU: make([]models.FRUInfo, 0),
|
||||||
|
Sensors: make([]models.SensorReading, 0),
|
||||||
|
Hardware: &models.HardwareConfig{
|
||||||
|
BoardInfo: parseBoardInfo(systemDoc),
|
||||||
|
CPUs: parseCPUs(processors),
|
||||||
|
Memory: parseMemory(memory),
|
||||||
|
Storage: storageDevices,
|
||||||
|
NetworkAdapters: nics,
|
||||||
|
Firmware: parseFirmware(systemDoc, biosDoc, managerDoc, secureBootDoc, networkProtocolDoc),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RedfishConnector) httpClient(req Request) *http.Client {
|
||||||
|
transport := &http.Transport{}
|
||||||
|
if req.TLSMode == "insecure" {
|
||||||
|
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec
|
||||||
|
}
|
||||||
|
return &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
Timeout: c.timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RedfishConnector) baseURL(req Request) (string, error) {
|
||||||
|
host := strings.TrimSpace(req.Host)
|
||||||
|
if host == "" {
|
||||||
|
return "", fmt.Errorf("empty host")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(host, "http://") || strings.HasPrefix(host, "https://") {
|
||||||
|
u, err := url.Parse(host)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid host URL: %w", err)
|
||||||
|
}
|
||||||
|
u.Path = ""
|
||||||
|
u.RawQuery = ""
|
||||||
|
u.Fragment = ""
|
||||||
|
return strings.TrimRight(u.String(), "/"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
scheme := "https"
|
||||||
|
if req.TLSMode == "insecure" && req.Port == 80 {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s:%d", scheme, host, req.Port), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Client, req Request, baseURL string, storageMembers []map[string]interface{}) []models.Storage {
|
||||||
|
var out []models.Storage
|
||||||
|
for _, member := range storageMembers {
|
||||||
|
drives, ok := member["Drives"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, driveAny := range drives {
|
||||||
|
driveRef, ok := driveAny.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
odata := asString(driveRef["@odata.id"])
|
||||||
|
if odata == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
driveDoc, err := c.getJSON(ctx, client, req, baseURL, odata)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, parseDrive(driveDoc))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RedfishConnector) collectNICs(ctx context.Context, client *http.Client, req Request, baseURL string) []models.NetworkAdapter {
|
||||||
|
adapterDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, "/redfish/v1/Chassis/1/NetworkAdapters")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nics := make([]models.NetworkAdapter, 0, len(adapterDocs))
|
||||||
|
for _, doc := range adapterDocs {
|
||||||
|
nics = append(nics, parseNIC(doc))
|
||||||
|
}
|
||||||
|
return nics
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RedfishConnector) getCollectionMembers(ctx context.Context, client *http.Client, req Request, baseURL, collectionPath string) ([]map[string]interface{}, error) {
|
||||||
|
collection, err := c.getJSON(ctx, client, req, baseURL, collectionPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
refs, ok := collection["Members"].([]interface{})
|
||||||
|
if !ok || len(refs) == 0 {
|
||||||
|
return []map[string]interface{}{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]map[string]interface{}, 0, len(refs))
|
||||||
|
for _, refAny := range refs {
|
||||||
|
ref, ok := refAny.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
memberPath := asString(ref["@odata.id"])
|
||||||
|
if memberPath == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
memberDoc, err := c.getJSON(ctx, client, req, baseURL, memberPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, memberDoc)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RedfishConnector) getJSON(ctx context.Context, client *http.Client, req Request, baseURL, requestPath string) (map[string]interface{}, error) {
|
||||||
|
rel := requestPath
|
||||||
|
if rel == "" {
|
||||||
|
rel = "/"
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(rel, "/") {
|
||||||
|
rel = "/" + rel
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
u.Path = path.Join(strings.TrimSuffix(u.Path, "/"), rel)
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
switch req.AuthType {
|
||||||
|
case "password":
|
||||||
|
httpReq.SetBasicAuth(req.Username, req.Password)
|
||||||
|
case "token":
|
||||||
|
httpReq.Header.Set("X-Auth-Token", req.Token)
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+req.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return nil, fmt.Errorf("status %d from %s: %s", resp.StatusCode, requestPath, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc map[string]interface{}
|
||||||
|
dec := json.NewDecoder(resp.Body)
|
||||||
|
dec.UseNumber()
|
||||||
|
if err := dec.Decode(&doc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBoardInfo(system map[string]interface{}) models.BoardInfo {
|
||||||
|
return models.BoardInfo{
|
||||||
|
Manufacturer: asString(system["Manufacturer"]),
|
||||||
|
ProductName: firstNonEmpty(asString(system["Model"]), asString(system["Name"])),
|
||||||
|
SerialNumber: asString(system["SerialNumber"]),
|
||||||
|
PartNumber: asString(system["PartNumber"]),
|
||||||
|
UUID: asString(system["UUID"]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCPUs(docs []map[string]interface{}) []models.CPU {
|
||||||
|
cpus := make([]models.CPU, 0, len(docs))
|
||||||
|
for idx, doc := range docs {
|
||||||
|
cpus = append(cpus, models.CPU{
|
||||||
|
Socket: idx,
|
||||||
|
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
|
||||||
|
Cores: asInt(doc["TotalCores"]),
|
||||||
|
Threads: asInt(doc["TotalThreads"]),
|
||||||
|
FrequencyMHz: asInt(doc["OperatingSpeedMHz"]),
|
||||||
|
MaxFreqMHz: asInt(doc["MaxSpeedMHz"]),
|
||||||
|
SerialNumber: asString(doc["SerialNumber"]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return cpus
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMemory(docs []map[string]interface{}) []models.MemoryDIMM {
|
||||||
|
out := make([]models.MemoryDIMM, 0, len(docs))
|
||||||
|
for _, doc := range docs {
|
||||||
|
slot := firstNonEmpty(asString(doc["DeviceLocator"]), asString(doc["Name"]), asString(doc["Id"]))
|
||||||
|
present := true
|
||||||
|
if strings.EqualFold(asString(doc["Status"]), "Absent") {
|
||||||
|
present = false
|
||||||
|
}
|
||||||
|
if status, ok := doc["Status"].(map[string]interface{}); ok {
|
||||||
|
state := asString(status["State"])
|
||||||
|
if strings.EqualFold(state, "Absent") || strings.EqualFold(state, "Disabled") {
|
||||||
|
present = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, models.MemoryDIMM{
|
||||||
|
Slot: slot,
|
||||||
|
Location: slot,
|
||||||
|
Present: present,
|
||||||
|
SizeMB: asInt(doc["CapacityMiB"]),
|
||||||
|
Type: firstNonEmpty(asString(doc["MemoryDeviceType"]), asString(doc["MemoryType"])),
|
||||||
|
MaxSpeedMHz: asInt(doc["MaxSpeedMHz"]),
|
||||||
|
CurrentSpeedMHz: asInt(doc["OperatingSpeedMhz"]),
|
||||||
|
Manufacturer: asString(doc["Manufacturer"]),
|
||||||
|
SerialNumber: asString(doc["SerialNumber"]),
|
||||||
|
PartNumber: asString(doc["PartNumber"]),
|
||||||
|
Status: mapStatus(doc["Status"]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDrive(doc map[string]interface{}) models.Storage {
|
||||||
|
sizeGB := asInt(doc["CapacityBytes"]) / (1024 * 1024 * 1024)
|
||||||
|
if sizeGB == 0 {
|
||||||
|
sizeGB = asInt(doc["CapacityGB"])
|
||||||
|
}
|
||||||
|
|
||||||
|
return models.Storage{
|
||||||
|
Slot: firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])),
|
||||||
|
Type: firstNonEmpty(asString(doc["MediaType"]), asString(doc["Protocol"])),
|
||||||
|
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
|
||||||
|
SizeGB: sizeGB,
|
||||||
|
SerialNumber: asString(doc["SerialNumber"]),
|
||||||
|
Manufacturer: asString(doc["Manufacturer"]),
|
||||||
|
Firmware: asString(doc["Revision"]),
|
||||||
|
Interface: asString(doc["Protocol"]),
|
||||||
|
Present: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
|
||||||
|
return models.NetworkAdapter{
|
||||||
|
Slot: firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])),
|
||||||
|
Location: asString(doc["Location"]),
|
||||||
|
Present: !strings.EqualFold(mapStatus(doc["Status"]), "Absent"),
|
||||||
|
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
|
||||||
|
Vendor: asString(doc["Manufacturer"]),
|
||||||
|
SerialNumber: asString(doc["SerialNumber"]),
|
||||||
|
PartNumber: asString(doc["PartNumber"]),
|
||||||
|
Status: mapStatus(doc["Status"]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFirmware(system, bios, manager, secureBoot, networkProtocol map[string]interface{}) []models.FirmwareInfo {
|
||||||
|
var out []models.FirmwareInfo
|
||||||
|
|
||||||
|
appendFW := func(name, version string) {
|
||||||
|
version = strings.TrimSpace(version)
|
||||||
|
if version == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, models.FirmwareInfo{DeviceName: name, Version: version})
|
||||||
|
}
|
||||||
|
|
||||||
|
appendFW("BIOS", asString(system["BiosVersion"]))
|
||||||
|
appendFW("BIOS", asString(bios["Version"]))
|
||||||
|
appendFW("BMC", asString(manager["FirmwareVersion"]))
|
||||||
|
appendFW("SecureBoot", asString(secureBoot["SecureBootCurrentBoot"]))
|
||||||
|
appendFW("NetworkProtocol", asString(networkProtocol["Id"]))
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapStatus(statusAny interface{}) string {
|
||||||
|
if statusAny == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if statusMap, ok := statusAny.(map[string]interface{}); ok {
|
||||||
|
health := asString(statusMap["Health"])
|
||||||
|
state := asString(statusMap["State"])
|
||||||
|
return firstNonEmpty(health, state)
|
||||||
|
}
|
||||||
|
return asString(statusAny)
|
||||||
|
}
|
||||||
|
|
||||||
|
func asString(v interface{}) string {
|
||||||
|
switch value := v.(type) {
|
||||||
|
case nil:
|
||||||
|
return ""
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
case json.Number:
|
||||||
|
return value.String()
|
||||||
|
default:
|
||||||
|
return strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func asInt(v interface{}) int {
|
||||||
|
switch value := v.(type) {
|
||||||
|
case nil:
|
||||||
|
return 0
|
||||||
|
case int:
|
||||||
|
return value
|
||||||
|
case int64:
|
||||||
|
return int(value)
|
||||||
|
case float64:
|
||||||
|
return int(value)
|
||||||
|
case json.Number:
|
||||||
|
if i, err := value.Int64(); err == nil {
|
||||||
|
return int(i)
|
||||||
|
}
|
||||||
|
if f, err := value.Float64(); err == nil {
|
||||||
|
return int(f)
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if i, err := strconv.Atoi(value); err == nil {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, v := range values {
|
||||||
|
if strings.TrimSpace(v) != "" {
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
128
internal/collector/redfish_test.go
Normal file
128
internal/collector/redfish_test.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRedfishConnectorCollect(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
register := func(path string, payload interface{}) {
|
||||||
|
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
register("/redfish/v1", map[string]interface{}{"Name": "ServiceRoot"})
|
||||||
|
register("/redfish/v1/Systems/1", map[string]interface{}{
|
||||||
|
"Manufacturer": "Supermicro",
|
||||||
|
"Model": "SYS-TEST",
|
||||||
|
"SerialNumber": "SYS123",
|
||||||
|
"BiosVersion": "2.1a",
|
||||||
|
})
|
||||||
|
register("/redfish/v1/Systems/1/Bios", map[string]interface{}{"Version": "2.1a"})
|
||||||
|
register("/redfish/v1/Systems/1/SecureBoot", map[string]interface{}{"SecureBootCurrentBoot": "Enabled"})
|
||||||
|
register("/redfish/v1/Systems/1/Processors", map[string]interface{}{
|
||||||
|
"Members": []map[string]string{
|
||||||
|
{"@odata.id": "/redfish/v1/Systems/1/Processors/CPU1"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
register("/redfish/v1/Systems/1/Processors/CPU1", map[string]interface{}{
|
||||||
|
"Name": "CPU1",
|
||||||
|
"Model": "Xeon Gold",
|
||||||
|
"TotalCores": 32,
|
||||||
|
"TotalThreads": 64,
|
||||||
|
"MaxSpeedMHz": 3600,
|
||||||
|
})
|
||||||
|
register("/redfish/v1/Systems/1/Memory", map[string]interface{}{
|
||||||
|
"Members": []map[string]string{
|
||||||
|
{"@odata.id": "/redfish/v1/Systems/1/Memory/DIMM1"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
register("/redfish/v1/Systems/1/Memory/DIMM1", map[string]interface{}{
|
||||||
|
"Name": "DIMM A1",
|
||||||
|
"CapacityMiB": 32768,
|
||||||
|
"MemoryDeviceType": "DDR5",
|
||||||
|
"OperatingSpeedMhz": 4800,
|
||||||
|
"Status": map[string]interface{}{
|
||||||
|
"Health": "OK",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
register("/redfish/v1/Systems/1/Storage", map[string]interface{}{
|
||||||
|
"Members": []map[string]string{
|
||||||
|
{"@odata.id": "/redfish/v1/Systems/1/Storage/1"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
register("/redfish/v1/Systems/1/Storage/1", map[string]interface{}{
|
||||||
|
"Drives": []map[string]string{
|
||||||
|
{"@odata.id": "/redfish/v1/Systems/1/Storage/1/Drives/1"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
register("/redfish/v1/Systems/1/Storage/1/Drives/1", map[string]interface{}{
|
||||||
|
"Name": "Drive1",
|
||||||
|
"Model": "NVMe Test",
|
||||||
|
"MediaType": "SSD",
|
||||||
|
"Protocol": "NVMe",
|
||||||
|
"CapacityGB": 960,
|
||||||
|
"SerialNumber": "SN123",
|
||||||
|
})
|
||||||
|
register("/redfish/v1/Chassis/1/NetworkAdapters", map[string]interface{}{
|
||||||
|
"Members": []map[string]string{
|
||||||
|
{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/1"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
register("/redfish/v1/Chassis/1/NetworkAdapters/1", map[string]interface{}{
|
||||||
|
"Name": "Mellanox",
|
||||||
|
"Model": "ConnectX-6",
|
||||||
|
"SerialNumber": "NIC123",
|
||||||
|
})
|
||||||
|
register("/redfish/v1/Managers/1", map[string]interface{}{
|
||||||
|
"FirmwareVersion": "1.25",
|
||||||
|
})
|
||||||
|
register("/redfish/v1/Managers/1/NetworkProtocol", map[string]interface{}{
|
||||||
|
"Id": "NetworkProtocol",
|
||||||
|
})
|
||||||
|
|
||||||
|
ts := httptest.NewServer(mux)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
c := NewRedfishConnector()
|
||||||
|
result, err := c.Collect(context.Background(), Request{
|
||||||
|
Host: ts.URL,
|
||||||
|
Port: 443,
|
||||||
|
Protocol: "redfish",
|
||||||
|
Username: "admin",
|
||||||
|
AuthType: "password",
|
||||||
|
Password: "secret",
|
||||||
|
TLSMode: "strict",
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("collect failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Hardware == nil {
|
||||||
|
t.Fatalf("expected hardware config")
|
||||||
|
}
|
||||||
|
if result.Hardware.BoardInfo.ProductName != "SYS-TEST" {
|
||||||
|
t.Fatalf("unexpected board model: %q", result.Hardware.BoardInfo.ProductName)
|
||||||
|
}
|
||||||
|
if len(result.Hardware.CPUs) != 1 {
|
||||||
|
t.Fatalf("expected one CPU, got %d", len(result.Hardware.CPUs))
|
||||||
|
}
|
||||||
|
if len(result.Hardware.Memory) != 1 {
|
||||||
|
t.Fatalf("expected one DIMM, got %d", len(result.Hardware.Memory))
|
||||||
|
}
|
||||||
|
if len(result.Hardware.Storage) != 1 {
|
||||||
|
t.Fatalf("expected one drive, got %d", len(result.Hardware.Storage))
|
||||||
|
}
|
||||||
|
if len(result.Hardware.NetworkAdapters) != 1 {
|
||||||
|
t.Fatalf("expected one nic, got %d", len(result.Hardware.NetworkAdapters))
|
||||||
|
}
|
||||||
|
if len(result.Hardware.Firmware) == 0 {
|
||||||
|
t.Fatalf("expected firmware entries")
|
||||||
|
}
|
||||||
|
}
|
||||||
37
internal/collector/registry.go
Normal file
37
internal/collector/registry.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
type Registry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
connectors map[string]Connector
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRegistry() *Registry {
|
||||||
|
return &Registry{
|
||||||
|
connectors: make(map[string]Connector),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDefaultRegistry() *Registry {
|
||||||
|
r := NewRegistry()
|
||||||
|
r.Register(NewRedfishConnector())
|
||||||
|
r.Register(NewIPMIMockConnector())
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) Register(connector Connector) {
|
||||||
|
if connector == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
r.connectors[connector.Protocol()] = connector
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) Get(protocol string) (Connector, bool) {
|
||||||
|
r.mu.RLock()
|
||||||
|
connector, ok := r.connectors[protocol]
|
||||||
|
r.mu.RUnlock()
|
||||||
|
return connector, ok
|
||||||
|
}
|
||||||
31
internal/collector/types.go
Normal file
31
internal/collector/types.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
Host string
|
||||||
|
Protocol string
|
||||||
|
Port int
|
||||||
|
Username string
|
||||||
|
AuthType string
|
||||||
|
Password string
|
||||||
|
Token string
|
||||||
|
TLSMode string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Progress struct {
|
||||||
|
Status string
|
||||||
|
Progress int
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProgressFn func(Progress)
|
||||||
|
|
||||||
|
type Connector interface {
|
||||||
|
Protocol() string
|
||||||
|
Collect(ctx context.Context, req Request, emit ProgressFn) (*models.AnalysisResult, error)
|
||||||
|
}
|
||||||
@@ -14,7 +14,8 @@ import (
|
|||||||
|
|
||||||
func newCollectTestServer() (*Server, *httptest.Server) {
|
func newCollectTestServer() (*Server, *httptest.Server) {
|
||||||
s := &Server{
|
s := &Server{
|
||||||
jobManager: NewJobManager(),
|
jobManager: NewJobManager(),
|
||||||
|
collectors: testCollectorRegistry(),
|
||||||
}
|
}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("POST /api/collect", s.handleCollectStart)
|
mux.HandleFunc("POST /api/collect", s.handleCollectStart)
|
||||||
|
|||||||
63
internal/server/collect_test_helpers_test.go
Normal file
63
internal/server/collect_test_helpers_test.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/collector"
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockConnector struct {
|
||||||
|
protocol string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mockConnector) Protocol() string {
|
||||||
|
return c.protocol
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mockConnector) Collect(ctx context.Context, req collector.Request, emit collector.ProgressFn) (*models.AnalysisResult, error) {
|
||||||
|
steps := []collector.Progress{
|
||||||
|
{Status: CollectStatusRunning, Progress: 20, Message: "Подключение..."},
|
||||||
|
{Status: CollectStatusRunning, Progress: 50, Message: "Сбор инвентаря..."},
|
||||||
|
{Status: CollectStatusRunning, Progress: 80, Message: "Нормализация..."},
|
||||||
|
}
|
||||||
|
for _, step := range steps {
|
||||||
|
if !collectorSleep(ctx, 100*time.Millisecond) {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
if emit != nil {
|
||||||
|
emit(step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(strings.ToLower(req.Host), "fail") {
|
||||||
|
return nil, context.DeadlineExceeded
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.AnalysisResult{
|
||||||
|
Events: make([]models.Event, 0),
|
||||||
|
FRU: make([]models.FRUInfo, 0),
|
||||||
|
Sensors: make([]models.SensorReading, 0),
|
||||||
|
Hardware: &models.HardwareConfig{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCollectorRegistry() *collector.Registry {
|
||||||
|
r := collector.NewRegistry()
|
||||||
|
r.Register(&mockConnector{protocol: "redfish"})
|
||||||
|
r.Register(&mockConnector{protocol: "ipmi"})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectorSleep(ctx context.Context, d time.Duration) bool {
|
||||||
|
timer := time.NewTimer(d)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
case <-timer.C:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/collector"
|
||||||
"git.mchus.pro/mchus/logpile/internal/exporter"
|
"git.mchus.pro/mchus/logpile/internal/exporter"
|
||||||
"git.mchus.pro/mchus/logpile/internal/models"
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
@@ -592,7 +593,7 @@ func (s *Server) handleCollectStart(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
job := s.jobManager.CreateJob(req)
|
job := s.jobManager.CreateJob(req)
|
||||||
s.startMockCollectionJob(job.ID, req)
|
s.startCollectionJob(job.ID, req)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusAccepted)
|
w.WriteHeader(http.StatusAccepted)
|
||||||
@@ -631,7 +632,7 @@ func (s *Server) handleCollectCancel(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, job.toStatusResponse())
|
jsonResponse(w, job.toStatusResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) startMockCollectionJob(jobID string, req CollectRequest) {
|
func (s *Server) startCollectionJob(jobID string, req CollectRequest) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
if attached := s.jobManager.AttachJobCancel(jobID, cancel); !attached {
|
if attached := s.jobManager.AttachJobCancel(jobID, cancel); !attached {
|
||||||
cancel()
|
cancel()
|
||||||
@@ -639,31 +640,37 @@ func (s *Server) startMockCollectionJob(jobID string, req CollectRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
steps := []struct {
|
connector, ok := s.getCollector(req.Protocol)
|
||||||
delay time.Duration
|
if !ok {
|
||||||
status string
|
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Коннектор для протокола не зарегистрирован")
|
||||||
progress int
|
s.jobManager.AppendJobLog(jobID, "Сбор завершен с ошибкой")
|
||||||
log string
|
return
|
||||||
}{
|
|
||||||
{delay: 250 * time.Millisecond, status: CollectStatusRunning, progress: 20, log: "Подключение..."},
|
|
||||||
{delay: 250 * time.Millisecond, status: CollectStatusRunning, progress: 50, log: "Сбор инвентаря..."},
|
|
||||||
{delay: 250 * time.Millisecond, status: CollectStatusRunning, progress: 80, log: "Нормализация..."},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, step := range steps {
|
emitProgress := func(update collector.Progress) {
|
||||||
if !waitWithCancel(ctx, step.delay) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if job, ok := s.jobManager.GetJob(jobID); !ok || isTerminalCollectStatus(job.Status) {
|
if job, ok := s.jobManager.GetJob(jobID); !ok || isTerminalCollectStatus(job.Status) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
status := update.Status
|
||||||
s.jobManager.UpdateJobStatus(jobID, step.status, step.progress, "")
|
if status == "" {
|
||||||
s.jobManager.AppendJobLog(jobID, step.log)
|
status = CollectStatusRunning
|
||||||
|
}
|
||||||
|
s.jobManager.UpdateJobStatus(jobID, status, update.Progress, "")
|
||||||
|
if update.Message != "" {
|
||||||
|
s.jobManager.AppendJobLog(jobID, update.Message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !waitWithCancel(ctx, 250*time.Millisecond) {
|
result, err := connector.Collect(ctx, toCollectorRequest(req), emitProgress)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if job, ok := s.jobManager.GetJob(jobID); !ok || isTerminalCollectStatus(job.Status) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, err.Error())
|
||||||
|
s.jobManager.AppendJobLog(jobID, "Сбор завершен с ошибкой")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -671,31 +678,14 @@ func (s *Server) startMockCollectionJob(jobID string, req CollectRequest) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(strings.ToLower(req.Host), "fail") {
|
applyCollectSourceMetadata(result, req)
|
||||||
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Mock: не удалось завершить сбор")
|
|
||||||
s.jobManager.AppendJobLog(jobID, "Сбор завершен с ошибкой")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.jobManager.UpdateJobStatus(jobID, CollectStatusSuccess, 100, "")
|
s.jobManager.UpdateJobStatus(jobID, CollectStatusSuccess, 100, "")
|
||||||
s.jobManager.AppendJobLog(jobID, "Сбор завершен")
|
s.jobManager.AppendJobLog(jobID, "Сбор завершен")
|
||||||
s.SetResult(newAPIResult(req))
|
s.SetResult(result)
|
||||||
s.SetDetectedVendor("")
|
s.SetDetectedVendor("")
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitWithCancel(ctx context.Context, d time.Duration) bool {
|
|
||||||
timer := time.NewTimer(d)
|
|
||||||
defer timer.Stop()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return false
|
|
||||||
case <-timer.C:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateCollectRequest(req CollectRequest) error {
|
func validateCollectRequest(req CollectRequest) error {
|
||||||
if strings.TrimSpace(req.Host) == "" {
|
if strings.TrimSpace(req.Host) == "" {
|
||||||
return fmt.Errorf("field 'host' is required")
|
return fmt.Errorf("field 'host' is required")
|
||||||
@@ -756,16 +746,34 @@ func applyArchiveSourceMetadata(result *models.AnalysisResult) {
|
|||||||
result.CollectedAt = time.Now().UTC()
|
result.CollectedAt = time.Now().UTC()
|
||||||
}
|
}
|
||||||
|
|
||||||
func newAPIResult(req CollectRequest) *models.AnalysisResult {
|
func applyCollectSourceMetadata(result *models.AnalysisResult, req CollectRequest) {
|
||||||
return &models.AnalysisResult{
|
if result == nil {
|
||||||
SourceType: models.SourceTypeAPI,
|
return
|
||||||
Protocol: req.Protocol,
|
|
||||||
TargetHost: req.Host,
|
|
||||||
CollectedAt: time.Now().UTC(),
|
|
||||||
Events: make([]models.Event, 0),
|
|
||||||
FRU: make([]models.FRUInfo, 0),
|
|
||||||
Sensors: make([]models.SensorReading, 0),
|
|
||||||
}
|
}
|
||||||
|
result.SourceType = models.SourceTypeAPI
|
||||||
|
result.Protocol = req.Protocol
|
||||||
|
result.TargetHost = req.Host
|
||||||
|
result.CollectedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toCollectorRequest(req CollectRequest) collector.Request {
|
||||||
|
return collector.Request{
|
||||||
|
Host: req.Host,
|
||||||
|
Protocol: req.Protocol,
|
||||||
|
Port: req.Port,
|
||||||
|
Username: req.Username,
|
||||||
|
AuthType: req.AuthType,
|
||||||
|
Password: req.Password,
|
||||||
|
Token: req.Token,
|
||||||
|
TLSMode: req.TLSMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getCollector(protocol string) (collector.Connector, bool) {
|
||||||
|
if s.collectors == nil {
|
||||||
|
s.collectors = collector.NewDefaultRegistry()
|
||||||
|
}
|
||||||
|
return s.collectors.Get(protocol)
|
||||||
}
|
}
|
||||||
|
|
||||||
func jsonResponse(w http.ResponseWriter, data interface{}) {
|
func jsonResponse(w http.ResponseWriter, data interface{}) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/collector"
|
||||||
"git.mchus.pro/mchus/logpile/internal/models"
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,7 +30,8 @@ type Server struct {
|
|||||||
result *models.AnalysisResult
|
result *models.AnalysisResult
|
||||||
detectedVendor string
|
detectedVendor string
|
||||||
|
|
||||||
jobManager *JobManager
|
jobManager *JobManager
|
||||||
|
collectors *collector.Registry
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg Config) *Server {
|
func New(cfg Config) *Server {
|
||||||
@@ -37,6 +39,7 @@ func New(cfg Config) *Server {
|
|||||||
config: cfg,
|
config: cfg,
|
||||||
mux: http.NewServeMux(),
|
mux: http.NewServeMux(),
|
||||||
jobManager: NewJobManager(),
|
jobManager: NewJobManager(),
|
||||||
|
collectors: collector.NewDefaultRegistry(),
|
||||||
}
|
}
|
||||||
s.setupRoutes()
|
s.setupRoutes()
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func TestApplyArchiveSourceMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewAPIResultMetadata(t *testing.T) {
|
func TestApplyCollectSourceMetadata(t *testing.T) {
|
||||||
req := CollectRequest{
|
req := CollectRequest{
|
||||||
Host: "bmc-api.local",
|
Host: "bmc-api.local",
|
||||||
Protocol: "redfish",
|
Protocol: "redfish",
|
||||||
@@ -41,7 +41,12 @@ func TestNewAPIResultMetadata(t *testing.T) {
|
|||||||
TLSMode: "strict",
|
TLSMode: "strict",
|
||||||
}
|
}
|
||||||
|
|
||||||
result := newAPIResult(req)
|
result := &models.AnalysisResult{
|
||||||
|
Events: make([]models.Event, 0),
|
||||||
|
FRU: make([]models.FRUInfo, 0),
|
||||||
|
Sensors: make([]models.SensorReading, 0),
|
||||||
|
}
|
||||||
|
applyCollectSourceMetadata(result, req)
|
||||||
|
|
||||||
if result.SourceType != models.SourceTypeAPI {
|
if result.SourceType != models.SourceTypeAPI {
|
||||||
t.Fatalf("expected source type %q, got %q", models.SourceTypeAPI, result.SourceType)
|
t.Fatalf("expected source type %q, got %q", models.SourceTypeAPI, result.SourceType)
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import (
|
|||||||
|
|
||||||
func newFlowTestServer() (*Server, *httptest.Server) {
|
func newFlowTestServer() (*Server, *httptest.Server) {
|
||||||
s := &Server{
|
s := &Server{
|
||||||
jobManager: NewJobManager(),
|
jobManager: NewJobManager(),
|
||||||
|
collectors: testCollectorRegistry(),
|
||||||
}
|
}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("POST /api/upload", s.handleUpload)
|
mux.HandleFunc("POST /api/upload", s.handleUpload)
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ let sourceType = 'archive';
|
|||||||
let apiConnectPayload = null;
|
let apiConnectPayload = null;
|
||||||
let collectionJob = null;
|
let collectionJob = null;
|
||||||
let collectionJobPollTimer = null;
|
let collectionJobPollTimer = null;
|
||||||
let collectionJobScenario = [];
|
|
||||||
let collectionJobScenarioIndex = 0;
|
|
||||||
let collectionJobLogCounter = 0;
|
let collectionJobLogCounter = 0;
|
||||||
let apiPortTouchedByUser = false;
|
let apiPortTouchedByUser = false;
|
||||||
let isAutoUpdatingApiPort = false;
|
let isAutoUpdatingApiPort = false;
|
||||||
@@ -49,9 +47,8 @@ function initApiSource() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authTypeField = document.getElementById('api-auth-type');
|
|
||||||
const cancelJobButton = document.getElementById('cancel-job-btn');
|
const cancelJobButton = document.getElementById('cancel-job-btn');
|
||||||
const fieldNames = ['host', 'protocol', 'port', 'username', 'auth_type', 'tls_mode', 'password', 'token'];
|
const fieldNames = ['host', 'port', 'username', 'password'];
|
||||||
|
|
||||||
apiForm.addEventListener('submit', (event) => {
|
apiForm.addEventListener('submit', (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -83,12 +80,6 @@ function initApiSource() {
|
|||||||
|
|
||||||
const eventName = field.tagName.toLowerCase() === 'select' ? 'change' : 'input';
|
const eventName = field.tagName.toLowerCase() === 'select' ? 'change' : 'input';
|
||||||
field.addEventListener(eventName, () => {
|
field.addEventListener(eventName, () => {
|
||||||
if (fieldName === 'auth_type') {
|
|
||||||
toggleApiAuthFields(authTypeField.value);
|
|
||||||
}
|
|
||||||
if (fieldName === 'protocol') {
|
|
||||||
applyProtocolDefaultPort(field.value);
|
|
||||||
}
|
|
||||||
if (fieldName === 'port') {
|
if (fieldName === 'port') {
|
||||||
handleApiPortInput(field.value);
|
handleApiPortInput(field.value);
|
||||||
}
|
}
|
||||||
@@ -103,20 +94,15 @@ function initApiSource() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
applyProtocolDefaultPort(getApiValue('protocol'));
|
applyRedfishDefaultPort();
|
||||||
toggleApiAuthFields(authTypeField.value);
|
|
||||||
renderCollectionJob();
|
renderCollectionJob();
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateCollectForm() {
|
function validateCollectForm() {
|
||||||
const host = getApiValue('host');
|
const host = getApiValue('host');
|
||||||
const protocol = getApiValue('protocol');
|
|
||||||
const portRaw = getApiValue('port');
|
const portRaw = getApiValue('port');
|
||||||
const username = getApiValue('username');
|
const username = getApiValue('username');
|
||||||
const authType = getApiValue('auth_type');
|
|
||||||
const tlsMode = getApiValue('tls_mode');
|
|
||||||
const password = getApiValue('password');
|
const password = getApiValue('password');
|
||||||
const token = getApiValue('token');
|
|
||||||
|
|
||||||
const errors = {};
|
const errors = {};
|
||||||
|
|
||||||
@@ -124,10 +110,6 @@ function validateCollectForm() {
|
|||||||
errors.host = 'Укажите host.';
|
errors.host = 'Укажите host.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['redfish', 'ipmi'].includes(protocol)) {
|
|
||||||
errors.protocol = 'Выберите протокол.';
|
|
||||||
}
|
|
||||||
|
|
||||||
const port = Number(portRaw);
|
const port = Number(portRaw);
|
||||||
const isPortInteger = Number.isInteger(port);
|
const isPortInteger = Number.isInteger(port);
|
||||||
if (!portRaw) {
|
if (!portRaw) {
|
||||||
@@ -140,40 +122,25 @@ function validateCollectForm() {
|
|||||||
errors.username = 'Укажите username.';
|
errors.username = 'Укажите username.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['password', 'token'].includes(authType)) {
|
if (!password) {
|
||||||
errors.auth_type = 'Выберите тип авторизации.';
|
|
||||||
}
|
|
||||||
if (!['strict', 'insecure'].includes(tlsMode)) {
|
|
||||||
errors.tls_mode = 'Выберите TLS режим.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authType === 'password' && !password) {
|
|
||||||
errors.password = 'Введите пароль.';
|
errors.password = 'Введите пароль.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authType === 'token' && !token) {
|
|
||||||
errors.token = 'Введите токен.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(errors).length > 0) {
|
if (Object.keys(errors).length > 0) {
|
||||||
return { isValid: false, errors, payload: null };
|
return { isValid: false, errors, payload: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: UI для выбора протокола вернем, когда откроем IPMI коннектор.
|
||||||
const payload = {
|
const payload = {
|
||||||
host,
|
host,
|
||||||
protocol,
|
protocol: 'redfish',
|
||||||
port,
|
port,
|
||||||
username,
|
username,
|
||||||
auth_type: authType,
|
auth_type: 'password',
|
||||||
tls_mode: tlsMode
|
tls_mode: 'insecure',
|
||||||
|
password
|
||||||
};
|
};
|
||||||
|
|
||||||
if (authType === 'password') {
|
|
||||||
payload.password = password;
|
|
||||||
} else {
|
|
||||||
payload.token = token;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { isValid: true, errors: {}, payload };
|
return { isValid: true, errors: {}, payload };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +151,7 @@ function renderFormErrors(errors) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorFields = ['host', 'protocol', 'port', 'username', 'auth_type', 'tls_mode', 'password', 'token'];
|
const errorFields = ['host', 'port', 'username', 'password'];
|
||||||
errorFields.forEach((fieldName) => {
|
errorFields.forEach((fieldName) => {
|
||||||
const errorNode = apiForm.querySelector(`[data-error-for="${fieldName}"]`);
|
const errorNode = apiForm.querySelector(`[data-error-for="${fieldName}"]`);
|
||||||
if (!errorNode) {
|
if (!errorNode) {
|
||||||
@@ -246,42 +213,43 @@ function clearApiConnectStatus() {
|
|||||||
|
|
||||||
function startCollectionJob(payload) {
|
function startCollectionJob(payload) {
|
||||||
resetCollectionJobState();
|
resetCollectionJobState();
|
||||||
|
|
||||||
const totalSteps = 4;
|
|
||||||
const finalStatus = Math.random() < 0.2 ? 'Failed' : 'Success';
|
|
||||||
const jobId = `job-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
|
||||||
|
|
||||||
collectionJob = {
|
|
||||||
id: jobId,
|
|
||||||
status: 'Queued',
|
|
||||||
progress: 0,
|
|
||||||
currentStep: 0,
|
|
||||||
totalSteps,
|
|
||||||
logs: [],
|
|
||||||
payload
|
|
||||||
};
|
|
||||||
|
|
||||||
collectionJobScenario = [
|
|
||||||
{ status: 'Running', step: 1, message: 'Соединение с BMC установлено.' },
|
|
||||||
{ status: 'Running', step: 2, message: 'Собираем базовую конфигурацию сервера.' },
|
|
||||||
{ status: 'Running', step: 3, message: 'Собираем системные журналы и события.' },
|
|
||||||
{
|
|
||||||
status: finalStatus,
|
|
||||||
step: 4,
|
|
||||||
message: finalStatus === 'Success'
|
|
||||||
? 'Сбор завершен. Данные готовы к следующему этапу.'
|
|
||||||
: 'Сбор завершился с ошибкой: часть данных недоступна.'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
collectionJobScenarioIndex = 0;
|
|
||||||
|
|
||||||
appendJobLog('Задача создана и добавлена в очередь.');
|
|
||||||
setApiFormBlocked(true);
|
setApiFormBlocked(true);
|
||||||
renderCollectionJob();
|
|
||||||
|
|
||||||
collectionJobPollTimer = window.setInterval(() => {
|
fetch('/api/collect', {
|
||||||
pollCollectionJobStatus();
|
method: 'POST',
|
||||||
}, 1200);
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(body.error || 'Не удалось запустить задачу');
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionJob = {
|
||||||
|
id: body.job_id,
|
||||||
|
status: normalizeJobStatus(body.status || 'queued'),
|
||||||
|
progress: 0,
|
||||||
|
logs: [],
|
||||||
|
payload
|
||||||
|
};
|
||||||
|
appendJobLog(body.message || 'Задача поставлена в очередь');
|
||||||
|
renderCollectionJob();
|
||||||
|
|
||||||
|
collectionJobPollTimer = window.setInterval(() => {
|
||||||
|
pollCollectionJobStatus();
|
||||||
|
}, 1200);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setApiFormBlocked(false);
|
||||||
|
clearApiConnectStatus();
|
||||||
|
renderApiConnectStatus(false, null);
|
||||||
|
const status = document.getElementById('api-connect-status');
|
||||||
|
if (status) {
|
||||||
|
status.textContent = err.message || 'Ошибка запуска задачи';
|
||||||
|
status.className = 'api-connect-status error';
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function pollCollectionJobStatus() {
|
function pollCollectionJobStatus() {
|
||||||
@@ -290,33 +258,63 @@ function pollCollectionJobStatus() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextState = collectionJobScenario[collectionJobScenarioIndex];
|
fetch(`/api/collect/${encodeURIComponent(collectionJob.id)}`)
|
||||||
if (!nextState) {
|
.then(async (response) => {
|
||||||
clearCollectionJobPolling();
|
const body = await response.json().catch(() => ({}));
|
||||||
return;
|
if (!response.ok) {
|
||||||
}
|
throw new Error(body.error || 'Не удалось получить статус задачи');
|
||||||
|
}
|
||||||
|
|
||||||
collectionJobScenarioIndex += 1;
|
const prevStatus = collectionJob.status;
|
||||||
collectionJob.status = nextState.status;
|
collectionJob.status = normalizeJobStatus(body.status || collectionJob.status);
|
||||||
collectionJob.currentStep = nextState.step;
|
collectionJob.progress = Number.isFinite(body.progress) ? body.progress : collectionJob.progress;
|
||||||
collectionJob.progress = Math.round((nextState.step / collectionJob.totalSteps) * 100);
|
collectionJob.error = body.error || '';
|
||||||
appendJobLog(nextState.message);
|
syncServerLogs(body.logs);
|
||||||
renderCollectionJob();
|
renderCollectionJob();
|
||||||
|
|
||||||
if (isCollectionJobTerminal(collectionJob.status)) {
|
if (isCollectionJobTerminal(collectionJob.status)) {
|
||||||
clearCollectionJobPolling();
|
clearCollectionJobPolling();
|
||||||
}
|
if (collectionJob.status === 'success') {
|
||||||
|
loadDataFromStatus();
|
||||||
|
} else if (collectionJob.status === 'failed' && collectionJob.error) {
|
||||||
|
appendJobLog(`Ошибка: ${collectionJob.error}`);
|
||||||
|
renderCollectionJob();
|
||||||
|
}
|
||||||
|
} else if (prevStatus !== collectionJob.status && collectionJob.status === 'running') {
|
||||||
|
appendJobLog('Сбор выполняется...');
|
||||||
|
renderCollectionJob();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
appendJobLog(`Ошибка статуса: ${err.message}`);
|
||||||
|
renderCollectionJob();
|
||||||
|
clearCollectionJobPolling();
|
||||||
|
setApiFormBlocked(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelCollectionJob() {
|
function cancelCollectionJob() {
|
||||||
if (!collectionJob || isCollectionJobTerminal(collectionJob.status)) {
|
if (!collectionJob || isCollectionJobTerminal(collectionJob.status)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
fetch(`/api/collect/${encodeURIComponent(collectionJob.id)}/cancel`, {
|
||||||
clearCollectionJobPolling();
|
method: 'POST'
|
||||||
collectionJob.status = 'Canceled';
|
})
|
||||||
appendJobLog('Задача отменена пользователем.');
|
.then(async (response) => {
|
||||||
renderCollectionJob();
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(body.error || 'Не удалось отменить задачу');
|
||||||
|
}
|
||||||
|
collectionJob.status = normalizeJobStatus(body.status || 'canceled');
|
||||||
|
collectionJob.progress = Number.isFinite(body.progress) ? body.progress : collectionJob.progress;
|
||||||
|
syncServerLogs(body.logs);
|
||||||
|
clearCollectionJobPolling();
|
||||||
|
renderCollectionJob();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
appendJobLog(`Ошибка отмены: ${err.message}`);
|
||||||
|
renderCollectionJob();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendJobLog(message) {
|
function appendJobLog(message) {
|
||||||
@@ -355,13 +353,13 @@ function renderCollectionJob() {
|
|||||||
statusValue.className = `job-status-badge status-${collectionJob.status.toLowerCase()}`;
|
statusValue.className = `job-status-badge status-${collectionJob.status.toLowerCase()}`;
|
||||||
const isTerminal = isCollectionJobTerminal(collectionJob.status);
|
const isTerminal = isCollectionJobTerminal(collectionJob.status);
|
||||||
const terminalMessage = {
|
const terminalMessage = {
|
||||||
Success: 'Сбор завершен',
|
success: 'Сбор завершен',
|
||||||
Failed: 'Сбор завершился ошибкой',
|
failed: 'Сбор завершился ошибкой',
|
||||||
Canceled: 'Сбор отменен'
|
canceled: 'Сбор отменен'
|
||||||
}[collectionJob.status];
|
}[collectionJob.status];
|
||||||
const progressLabel = isTerminal
|
const progressLabel = isTerminal
|
||||||
? terminalMessage
|
? terminalMessage
|
||||||
: `Шаг ${collectionJob.currentStep} из ${collectionJob.totalSteps}`;
|
: 'Сбор данных...';
|
||||||
progressValue.textContent = `${collectionJob.progress}% · ${progressLabel}`;
|
progressValue.textContent = `${collectionJob.progress}% · ${progressLabel}`;
|
||||||
|
|
||||||
logsList.innerHTML = collectionJob.logs.map((log) => (
|
logsList.innerHTML = collectionJob.logs.map((log) => (
|
||||||
@@ -373,7 +371,7 @@ function renderCollectionJob() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isCollectionJobTerminal(status) {
|
function isCollectionJobTerminal(status) {
|
||||||
return ['Success', 'Failed', 'Canceled'].includes(status);
|
return ['success', 'failed', 'canceled'].includes(normalizeJobStatus(status));
|
||||||
}
|
}
|
||||||
|
|
||||||
function setApiFormBlocked(shouldBlock) {
|
function setApiFormBlocked(shouldBlock) {
|
||||||
@@ -400,34 +398,41 @@ function clearCollectionJobPolling() {
|
|||||||
function resetCollectionJobState() {
|
function resetCollectionJobState() {
|
||||||
clearCollectionJobPolling();
|
clearCollectionJobPolling();
|
||||||
collectionJob = null;
|
collectionJob = null;
|
||||||
collectionJobScenario = [];
|
|
||||||
collectionJobScenarioIndex = 0;
|
|
||||||
renderCollectionJob();
|
renderCollectionJob();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleApiAuthFields(authType) {
|
function syncServerLogs(logs) {
|
||||||
const passwordField = document.getElementById('api-password-field');
|
if (!collectionJob || !Array.isArray(logs)) {
|
||||||
const tokenField = document.getElementById('api-token-field');
|
return;
|
||||||
|
}
|
||||||
if (!passwordField || !tokenField) {
|
if (logs.length <= collectionJob.logs.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useToken = authType === 'token';
|
const from = collectionJob.logs.length;
|
||||||
passwordField.classList.toggle('hidden', useToken);
|
for (let i = from; i < logs.length; i += 1) {
|
||||||
tokenField.classList.toggle('hidden', !useToken);
|
appendJobLog(logs[i]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyProtocolDefaultPort(protocol) {
|
function normalizeJobStatus(status) {
|
||||||
const defaults = {
|
return String(status || '').trim().toLowerCase();
|
||||||
redfish: '443',
|
}
|
||||||
ipmi: '623'
|
|
||||||
};
|
|
||||||
const defaultPort = defaults[protocol];
|
|
||||||
if (!defaultPort) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
async function loadDataFromStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/status');
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!payload.loaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadData(payload.vendor || '', payload.filename || '');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load data after collect:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRedfishDefaultPort() {
|
||||||
const apiForm = document.getElementById('api-connect-form');
|
const apiForm = document.getElementById('api-connect-form');
|
||||||
if (!apiForm) {
|
if (!apiForm) {
|
||||||
return;
|
return;
|
||||||
@@ -444,7 +449,7 @@ function applyProtocolDefaultPort(protocol) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isAutoUpdatingApiPort = true;
|
isAutoUpdatingApiPort = true;
|
||||||
portField.value = defaultPort;
|
portField.value = '443';
|
||||||
isAutoUpdatingApiPort = false;
|
isAutoUpdatingApiPort = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,19 +42,9 @@
|
|||||||
<span class="field-error" data-error-for="host"></span>
|
<span class="field-error" data-error-for="host"></span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="api-form-field" for="api-protocol">
|
|
||||||
<span>Протокол</span>
|
|
||||||
<select id="api-protocol" name="protocol">
|
|
||||||
<option value="">Выберите протокол</option>
|
|
||||||
<option value="redfish">Redfish</option>
|
|
||||||
<option value="ipmi">IPMI</option>
|
|
||||||
</select>
|
|
||||||
<span class="field-error" data-error-for="protocol"></span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="api-form-field" for="api-port">
|
<label class="api-form-field" for="api-port">
|
||||||
<span>Порт</span>
|
<span>Порт</span>
|
||||||
<input id="api-port" name="port" type="number" min="1" max="65535" placeholder="443">
|
<input id="api-port" name="port" type="number" min="1" max="65535" value="443" placeholder="443">
|
||||||
<span class="field-error" data-error-for="port"></span>
|
<span class="field-error" data-error-for="port"></span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -64,37 +54,11 @@
|
|||||||
<span class="field-error" data-error-for="username"></span>
|
<span class="field-error" data-error-for="username"></span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="api-form-field" for="api-auth-type">
|
|
||||||
<span>Тип авторизации</span>
|
|
||||||
<select id="api-auth-type" name="auth_type">
|
|
||||||
<option value="">Выберите тип</option>
|
|
||||||
<option value="password">Пароль</option>
|
|
||||||
<option value="token">Токен</option>
|
|
||||||
</select>
|
|
||||||
<span class="field-error" data-error-for="auth_type"></span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="api-form-field" for="api-tls-mode">
|
|
||||||
<span>TLS режим</span>
|
|
||||||
<select id="api-tls-mode" name="tls_mode">
|
|
||||||
<option value="">Выберите режим</option>
|
|
||||||
<option value="strict">Strict (проверка сертификата)</option>
|
|
||||||
<option value="insecure">Insecure (без проверки сертификата)</option>
|
|
||||||
</select>
|
|
||||||
<span class="field-error" data-error-for="tls_mode"></span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="api-form-field" id="api-password-field" for="api-password">
|
<label class="api-form-field" id="api-password-field" for="api-password">
|
||||||
<span>Пароль</span>
|
<span>Пароль</span>
|
||||||
<input id="api-password" name="password" type="password" autocomplete="current-password">
|
<input id="api-password" name="password" type="password" autocomplete="current-password">
|
||||||
<span class="field-error" data-error-for="password"></span>
|
<span class="field-error" data-error-for="password"></span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="api-form-field hidden" id="api-token-field" for="api-token">
|
|
||||||
<span>Токен</span>
|
|
||||||
<input id="api-token" name="token" type="text" autocomplete="off">
|
|
||||||
<span class="field-error" data-error-for="token"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="api-form-actions">
|
<div class="api-form-actions">
|
||||||
|
|||||||
Reference in New Issue
Block a user