Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bae2d68803 | ||
|
|
233f29b437 |
No files matched your search
@@ -1515,6 +1515,74 @@ paths:
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/modules/{module_id}/entries.csv:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TenantId"
|
||||
- $ref: "#/components/parameters/ModuleId"
|
||||
get:
|
||||
tags: [Modules]
|
||||
summary: Экспорт записей модуля в CSV
|
||||
description: |
|
||||
Доступно для типов модулей `AS_PREFIXES`, `DOMAINS`, `IP_RANGES`.
|
||||
Возвращает CSV с колонками:
|
||||
- AS: `asn,community`
|
||||
- Домены: `domain,community`
|
||||
- IP ranges: `ipRange,community`
|
||||
operationId: exportModuleEntriesCsv
|
||||
responses:
|
||||
"200":
|
||||
description: CSV-файл записей модуля.
|
||||
content:
|
||||
text/csv:
|
||||
schema:
|
||||
type: string
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"422":
|
||||
$ref: "#/components/responses/UnprocessableEntity"
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
post:
|
||||
tags: [Modules]
|
||||
summary: Импорт записей модуля из CSV
|
||||
description: |
|
||||
Импортирует CSV в модуль типов `AS_PREFIXES`, `DOMAINS`, `IP_RANGES`.
|
||||
Поддерживаемые заголовки:
|
||||
- `asn,community`
|
||||
- `domain,community`
|
||||
- `ipRange,community`
|
||||
|
||||
В поле `community` можно передавать либо ID community, либо её значение.
|
||||
operationId: importModuleEntriesCsv
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/IdempotencyKey"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
text/csv:
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Импорт завершён.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [imported, module_type]
|
||||
properties:
|
||||
imported:
|
||||
type: integer
|
||||
minimum: 0
|
||||
module_type:
|
||||
type: string
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"422":
|
||||
$ref: "#/components/responses/UnprocessableEntity"
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/doh-profiles:
|
||||
get:
|
||||
tags: [DoH profiles]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -38,6 +39,8 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
|
||||
m.HandleFunc("POST /modules/{module_id}/ip-range-entries", s.handlePostIPRange)
|
||||
m.HandleFunc("PATCH /modules/{module_id}/ip-range-entries/{entry_id}", s.handlePatchIPRange)
|
||||
m.HandleFunc("DELETE /modules/{module_id}/ip-range-entries/{entry_id}", s.handleDeleteIPRange)
|
||||
m.HandleFunc("GET /modules/{module_id}/entries.csv", s.handleExportModuleEntriesCSV)
|
||||
m.HandleFunc("POST /modules/{module_id}/entries.csv", s.handleImportModuleEntriesCSV)
|
||||
|
||||
m.HandleFunc("GET /doh-profiles", s.handleListDoh)
|
||||
m.HandleFunc("POST /doh-profiles", s.handlePostDoh)
|
||||
@@ -535,6 +538,249 @@ func (s *Server) handleDeleteIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleExportModuleEntriesCSV(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
moduleID := r.PathValue("module_id")
|
||||
mod, err := s.store.GetModule(a.TenantID, moduleID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
communities, err := s.store.ListCommunities(a.TenantID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
communityByID := make(map[string]string, len(communities))
|
||||
for _, c := range communities {
|
||||
communityByID[c.ID] = strings.TrimSpace(c.Community)
|
||||
}
|
||||
|
||||
records := make([][]string, 0, 64)
|
||||
|
||||
switch mod.Type {
|
||||
case "AS_PREFIXES":
|
||||
records = append(records, []string{"asn", "community"})
|
||||
list, err := s.store.ListASEntries(a.TenantID, moduleID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
for _, x := range list {
|
||||
community := ""
|
||||
if x.CommunityID != nil {
|
||||
community = communityByID[*x.CommunityID]
|
||||
}
|
||||
records = append(records, []string{strconv.FormatInt(x.ASN, 10), community})
|
||||
}
|
||||
case "DOMAINS":
|
||||
records = append(records, []string{"domain", "community"})
|
||||
list, err := s.store.ListDomainEntries(a.TenantID, moduleID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
for _, x := range list {
|
||||
community := ""
|
||||
if x.CommunityID != nil {
|
||||
community = communityByID[*x.CommunityID]
|
||||
}
|
||||
records = append(records, []string{x.FQDN, community})
|
||||
}
|
||||
case "IP_RANGES":
|
||||
records = append(records, []string{"ipRange", "community"})
|
||||
list, err := s.store.ListIPRangeEntries(a.TenantID, moduleID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
for _, x := range list {
|
||||
community := ""
|
||||
if x.CommunityID != nil {
|
||||
community = communityByID[*x.CommunityID]
|
||||
}
|
||||
records = append(records, []string{x.Prefix, community})
|
||||
}
|
||||
default:
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv import/export is supported only for AS_PREFIXES, DOMAINS, IP_RANGES")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="module-entries.csv"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
cw := csv.NewWriter(w)
|
||||
for _, rec := range records {
|
||||
if err := cw.Write(rec); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
cw.Flush()
|
||||
}
|
||||
|
||||
func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
moduleID := r.PathValue("module_id")
|
||||
mod, err := s.store.GetModule(a.TenantID, moduleID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
cr := csv.NewReader(io.LimitReader(r.Body, 8<<20))
|
||||
cr.TrimLeadingSpace = true
|
||||
cr.FieldsPerRecord = -1
|
||||
rows, err := cr.ReadAll()
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid csv")
|
||||
return
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv is empty")
|
||||
return
|
||||
}
|
||||
|
||||
communities, err := s.store.ListCommunities(a.TenantID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
communityIDByID := make(map[string]string, len(communities))
|
||||
communityIDByValue := make(map[string]string, len(communities))
|
||||
for _, c := range communities {
|
||||
communityIDByID[c.ID] = c.ID
|
||||
communityIDByValue[strings.TrimSpace(c.Community)] = c.ID
|
||||
}
|
||||
|
||||
resolveCommunity := func(raw string, required bool) (*string, error) {
|
||||
v := strings.TrimSpace(raw)
|
||||
if v == "" {
|
||||
if required {
|
||||
return nil, fmt.Errorf("community is required")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if id, ok := communityIDByID[v]; ok {
|
||||
return &id, nil
|
||||
}
|
||||
if id, ok := communityIDByValue[v]; ok {
|
||||
return &id, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unknown community %q", v)
|
||||
}
|
||||
|
||||
start := 0
|
||||
if len(rows[0]) >= 2 {
|
||||
key := strings.ToLower(strings.TrimSpace(rows[0][0]))
|
||||
switch key {
|
||||
case "asn", "domain", "iprange":
|
||||
start = 1
|
||||
}
|
||||
}
|
||||
|
||||
imported := 0
|
||||
switch mod.Type {
|
||||
case "AS_PREFIXES":
|
||||
for i := start; i < len(rows); i++ {
|
||||
rec := rows[i]
|
||||
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
|
||||
continue
|
||||
}
|
||||
if len(rec) < 2 {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: expected 2 columns", i+1))
|
||||
return
|
||||
}
|
||||
asn, err := strconv.ParseInt(strings.TrimSpace(rec[0]), 10, 64)
|
||||
if err != nil || asn <= 0 {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: invalid asn", i+1))
|
||||
return
|
||||
}
|
||||
cid, err := resolveCommunity(rec[1], false)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: %v", i+1, err))
|
||||
return
|
||||
}
|
||||
_, err = s.store.CreateASEntry(a.TenantID, moduleID, &store.ASEntry{ASN: asn, CommunityID: cid})
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
imported++
|
||||
}
|
||||
case "DOMAINS":
|
||||
for i := start; i < len(rows); i++ {
|
||||
rec := rows[i]
|
||||
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
|
||||
continue
|
||||
}
|
||||
if len(rec) < 2 {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: expected 2 columns", i+1))
|
||||
return
|
||||
}
|
||||
fqdn := strings.TrimSpace(rec[0])
|
||||
if fqdn == "" {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: domain is required", i+1))
|
||||
return
|
||||
}
|
||||
cid, err := resolveCommunity(rec[1], false)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: %v", i+1, err))
|
||||
return
|
||||
}
|
||||
_, err = s.store.CreateDomainEntry(a.TenantID, moduleID, &store.DomainEntry{FQDN: fqdn, CommunityID: cid})
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
imported++
|
||||
}
|
||||
case "IP_RANGES":
|
||||
for i := start; i < len(rows); i++ {
|
||||
rec := rows[i]
|
||||
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
|
||||
continue
|
||||
}
|
||||
if len(rec) < 2 {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: expected 2 columns", i+1))
|
||||
return
|
||||
}
|
||||
prefix := strings.TrimSpace(rec[0])
|
||||
if prefix == "" {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: ipRange is required", i+1))
|
||||
return
|
||||
}
|
||||
cid, err := resolveCommunity(rec[1], true)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: %v", i+1, err))
|
||||
return
|
||||
}
|
||||
_, err = s.store.CreateIPRangeEntry(a.TenantID, moduleID, &store.IPRangeEntry{Prefix: prefix, CommunityID: cid})
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
imported++
|
||||
}
|
||||
if imported > 0 {
|
||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "ip_range_import_csv")
|
||||
}
|
||||
default:
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv import/export is supported only for AS_PREFIXES, DOMAINS, IP_RANGES")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"imported": imported,
|
||||
"module_type": mod.Type,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleListDoh(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
|
||||
srv, err := New(Options{
|
||||
InsecureDev: true,
|
||||
SeedDemo: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
|
||||
tenant, _, modIP, _, _ := srv.Store().DemoIDs()
|
||||
srv.apiKeys = parseAPIKeysSpec("opkey|" + tenant + "|operator")
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
client := ts.Client()
|
||||
base := ts.URL
|
||||
|
||||
reqList, _ := http.NewRequest(http.MethodGet, base+"/v1/communities?limit=10", nil)
|
||||
reqList.Header.Set("Authorization", "Bearer opkey")
|
||||
respList, err := client.Do(reqList)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer respList.Body.Close()
|
||||
if respList.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(respList.Body)
|
||||
t.Fatalf("communities status %d: %s", respList.StatusCode, b)
|
||||
}
|
||||
var listBody struct {
|
||||
Items []struct {
|
||||
Community string `json:"community"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(respList.Body).Decode(&listBody); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listBody.Items) == 0 {
|
||||
t.Fatal("expected seeded community")
|
||||
}
|
||||
|
||||
csvBody := "\"ipRange\",\"community\"\n\"10.254.1.254/32\",\"" + listBody.Items[0].Community + "\"\n\"160.79.104.0/23\",\"" + listBody.Items[0].Community + "\"\n"
|
||||
reqImport, _ := http.NewRequest(http.MethodPost, base+"/v1/modules/"+modIP+"/entries.csv", strings.NewReader(csvBody))
|
||||
reqImport.Header.Set("Authorization", "Bearer opkey")
|
||||
reqImport.Header.Set("Content-Type", "text/csv")
|
||||
respImport, err := client.Do(reqImport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer respImport.Body.Close()
|
||||
if respImport.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(respImport.Body)
|
||||
t.Fatalf("import status %d: %s", respImport.StatusCode, b)
|
||||
}
|
||||
var importBody struct {
|
||||
Imported int `json:"imported"`
|
||||
}
|
||||
if err := json.NewDecoder(respImport.Body).Decode(&importBody); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if importBody.Imported != 2 {
|
||||
t.Fatalf("expected imported=2, got %d", importBody.Imported)
|
||||
}
|
||||
|
||||
reqExport, _ := http.NewRequest(http.MethodGet, base+"/v1/modules/"+modIP+"/entries.csv", nil)
|
||||
reqExport.Header.Set("Authorization", "Bearer opkey")
|
||||
respExport, err := client.Do(reqExport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer respExport.Body.Close()
|
||||
if respExport.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(respExport.Body)
|
||||
t.Fatalf("export status %d: %s", respExport.StatusCode, b)
|
||||
}
|
||||
if got := respExport.Header.Get("Content-Type"); !strings.Contains(got, "text/csv") {
|
||||
t.Fatalf("unexpected content-type: %q", got)
|
||||
}
|
||||
raw, err := io.ReadAll(respExport.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
records, err := csv.NewReader(strings.NewReader(string(raw))).ReadAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(records) < 3 {
|
||||
t.Fatalf("expected at least 3 csv rows, got %d", len(records))
|
||||
}
|
||||
if records[0][0] != "ipRange" || records[0][1] != "community" {
|
||||
t.Fatalf("unexpected header: %#v", records[0])
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
||||
import { apiFetch, apiJSON, apiMutate } from '$lib/api/client.js';
|
||||
import type {
|
||||
ModuleRow,
|
||||
ModulePatch,
|
||||
@@ -72,6 +72,8 @@
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import Save from '@lucide/svelte/icons/save';
|
||||
import Upload from '@lucide/svelte/icons/upload';
|
||||
import Download from '@lucide/svelte/icons/download';
|
||||
|
||||
const moduleId = $derived(page.params.moduleId);
|
||||
|
||||
@@ -132,6 +134,28 @@
|
||||
|
||||
// Refresh
|
||||
let refreshing = $state(false);
|
||||
let csvImporting = $state(false);
|
||||
let csvExporting = $state(false);
|
||||
let csvFileInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
function supportsCsvIO(type: ModuleRow['type'] | null | undefined): boolean {
|
||||
return type === 'AS_PREFIXES' || type === 'DOMAINS' || type === 'IP_RANGES';
|
||||
}
|
||||
|
||||
function sanitizeFilenamePart(v: string): string {
|
||||
const cleaned = v
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^[-_.]+|[-_.]+$/g, '');
|
||||
return cleaned || 'module';
|
||||
}
|
||||
|
||||
async function readErrorText(res: Response): Promise<string> {
|
||||
const body = (await res.text()).trim();
|
||||
return body || `HTTP ${res.status}`;
|
||||
}
|
||||
|
||||
async function loadMod() {
|
||||
loadingMod = true;
|
||||
@@ -245,6 +269,67 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function exportEntriesCsv() {
|
||||
if (!mod || !supportsCsvIO(mod.type) || csvExporting) return;
|
||||
csvExporting = true;
|
||||
try {
|
||||
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/csv' }
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast.error(await readErrorText(res));
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const safeModuleName = sanitizeFilenamePart(mod.name);
|
||||
a.href = url;
|
||||
a.download = `${safeModuleName}-${mod.type.toLowerCase()}-entries.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
csvExporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openImportCsvPicker() {
|
||||
if (!mod || !supportsCsvIO(mod.type) || csvImporting) return;
|
||||
csvFileInput?.click();
|
||||
}
|
||||
|
||||
async function handleImportCsvChange(event: Event) {
|
||||
const input = event.currentTarget as HTMLInputElement | null;
|
||||
const file = input?.files?.[0];
|
||||
if (!mod || !supportsCsvIO(mod.type) || !file || csvImporting) return;
|
||||
csvImporting = true;
|
||||
try {
|
||||
const fileText = await file.text();
|
||||
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/csv' },
|
||||
body: fileText
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast.error(await readErrorText(res));
|
||||
return;
|
||||
}
|
||||
const payload = (await res.json()) as { imported?: number };
|
||||
toast.success(`Импортировано записей: ${payload.imported ?? 0}`);
|
||||
await loadEntries();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
csvImporting = false;
|
||||
if (input) input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// --- AS Entries ---
|
||||
function openAsCreate() {
|
||||
asEdit = null;
|
||||
@@ -497,15 +582,22 @@
|
||||
<div class="text-muted-foreground flex h-32 items-center justify-center">Загрузка…</div>
|
||||
{:else if mod}
|
||||
<div class="space-y-6">
|
||||
<input
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
bind:this={csvFileInput}
|
||||
onchange={handleImportCsvChange}
|
||||
/>
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<Button variant="ghost" size="icon-sm" href={resolve('/modules')}>
|
||||
<ArrowLeft class="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{mod.name}</h1>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h1 class="min-w-0 break-words text-2xl font-semibold tracking-tight">{mod.name}</h1>
|
||||
<Badge variant="outline">{mod.type}</Badge>
|
||||
{#if mod.enabled}
|
||||
<Badge variant="default" class="text-xs">вкл</Badge>
|
||||
@@ -513,10 +605,10 @@
|
||||
<Badge variant="secondary" class="text-xs">выкл</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-muted-foreground mt-0.5 text-sm font-mono text-xs">{mod.id}</p>
|
||||
<p class="text-muted-foreground mt-0.5 break-all font-mono text-xs">{mod.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onclick={refreshMod} disabled={refreshing}>
|
||||
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
|
||||
Refresh
|
||||
@@ -533,7 +625,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Module metadata -->
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Card class="p-3">
|
||||
<p class="text-muted-foreground text-xs">Приоритет</p>
|
||||
<p class="font-semibold">{mod.priority}</p>
|
||||
@@ -550,21 +642,41 @@
|
||||
</Card>
|
||||
<Card class="p-3">
|
||||
<p class="text-muted-foreground text-xs">Community по умолч.</p>
|
||||
<p class="font-semibold text-sm">{communityLabel(mod.default_community_id)}</p>
|
||||
<p class="font-semibold text-sm break-all">{communityLabel(mod.default_community_id)}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Entries by type -->
|
||||
{#if mod.type === 'AS_PREFIXES'}
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
||||
<div>
|
||||
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base">AS-записи</CardTitle>
|
||||
<CardDescription>
|
||||
Номер AS и community; имя, число префиксов и дата обновляются при успешном обновлении модуля (RIPEstat)
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" onclick={openAsCreate}><Plus />Добавить</Button>
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={openImportCsvPicker}
|
||||
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
|
||||
>
|
||||
<Upload />
|
||||
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={exportEntriesCsv}
|
||||
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
|
||||
>
|
||||
<Download />
|
||||
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
|
||||
</Button>
|
||||
<Button size="sm" onclick={openAsCreate}><Plus />Добавить</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<Table>
|
||||
@@ -615,12 +727,12 @@
|
||||
</Card>
|
||||
{:else if mod.type === 'CDN_CIDRS'}
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
||||
<div>
|
||||
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base">CDN-источники</CardTitle>
|
||||
<CardDescription>URL источников для скачивания списков CIDR</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" onclick={openCdnCreate}><Plus />Добавить</Button>
|
||||
<Button size="sm" class="shrink-0 self-start sm:self-auto" onclick={openCdnCreate}><Plus />Добавить</Button>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<Table>
|
||||
@@ -667,12 +779,32 @@
|
||||
</Card>
|
||||
{:else if mod.type === 'DOMAINS'}
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
||||
<div>
|
||||
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base">Домены</CardTitle>
|
||||
<CardDescription>FQDN для резолвинга через DoH</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" onclick={openDomainCreate}><Plus />Добавить</Button>
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={openImportCsvPicker}
|
||||
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
|
||||
>
|
||||
<Upload />
|
||||
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={exportEntriesCsv}
|
||||
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
|
||||
>
|
||||
<Download />
|
||||
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
|
||||
</Button>
|
||||
<Button size="sm" onclick={openDomainCreate}><Plus />Добавить</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<Table>
|
||||
@@ -706,12 +838,32 @@
|
||||
</Card>
|
||||
{:else if mod.type === 'IP_RANGES'}
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
||||
<div>
|
||||
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base">IP-диапазоны</CardTitle>
|
||||
<CardDescription>Статические CIDR для анонса</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" onclick={openIpCreate}><Plus />Добавить</Button>
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={openImportCsvPicker}
|
||||
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
|
||||
>
|
||||
<Upload />
|
||||
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={exportEntriesCsv}
|
||||
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
|
||||
>
|
||||
<Download />
|
||||
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
|
||||
</Button>
|
||||
<Button size="sm" onclick={openIpCreate}><Plus />Добавить</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<Table>
|
||||
|
||||
@@ -446,24 +446,29 @@
|
||||
<!-- Quick actions + локальный статус BIRD (хост API, если задан EVOBGP_BIRDC_SOCKET) -->
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<Card class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-semibold">Apply all speakers</p>
|
||||
<p class="text-muted-foreground text-sm">Применить текущую конфигурацию на всех спикерах</p>
|
||||
</div>
|
||||
<Button onclick={() => (applyConfirm = true)} disabled={applying}>
|
||||
<Button class="shrink-0 self-start sm:self-auto" onclick={() => (applyConfirm = true)} disabled={applying}>
|
||||
<Play />
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-semibold">BIRD Reload</p>
|
||||
<p class="text-muted-foreground text-sm">Перезагрузить конфигурацию BIRD на всех спикерах</p>
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => (reloadConfirm = true)} disabled={reloading}>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="shrink-0 self-start sm:self-auto"
|
||||
onclick={() => (reloadConfirm = true)}
|
||||
disabled={reloading}
|
||||
>
|
||||
<RotateCcw />
|
||||
Reload
|
||||
</Button>
|
||||
@@ -526,12 +531,12 @@
|
||||
<!-- Revisions -->
|
||||
<TabsContent value="revisions" class="mt-4">
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
||||
<div>
|
||||
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base">История ревизий</CardTitle>
|
||||
<CardDescription>Создаются задачей module_refresh (CDN / IP / AS и т.д.); в превью есть полный текст BIRD с инклюдами</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onclick={loadRevisions} disabled={revLoading}>
|
||||
<Button variant="outline" size="sm" class="shrink-0 self-start sm:self-auto" onclick={loadRevisions} disabled={revLoading}>
|
||||
<RefreshCw class={revLoading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
@@ -585,20 +590,20 @@
|
||||
<CardDescription>Выберите ID двух ревизий для сравнения</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex gap-2">
|
||||
<select bind:value={diffRevA} class="border-border bg-background h-8 flex-1 rounded-md border px-2 text-sm">
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<select bind:value={diffRevA} class="border-border bg-background h-8 min-w-0 flex-1 rounded-md border px-2 text-sm">
|
||||
<option value="">Ревизия A</option>
|
||||
{#each revisions as rev}
|
||||
<option value={rev.id}>{rev.id.slice(0, 8)}… ({formatDate(rev.created_at)})</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select bind:value={diffRevB} class="border-border bg-background h-8 flex-1 rounded-md border px-2 text-sm">
|
||||
<select bind:value={diffRevB} class="border-border bg-background h-8 min-w-0 flex-1 rounded-md border px-2 text-sm">
|
||||
<option value="">Ревизия B</option>
|
||||
{#each revisions as rev}
|
||||
<option value={rev.id}>{rev.id.slice(0, 8)}… ({formatDate(rev.created_at)})</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Button size="sm" onclick={loadDiff} disabled={diffLoading}>
|
||||
<Button size="sm" class="shrink-0 self-start sm:self-auto" onclick={loadDiff} disabled={diffLoading}>
|
||||
{diffLoading ? 'Загрузка…' : 'Сравнить'}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -633,12 +638,12 @@
|
||||
<!-- Jobs -->
|
||||
<TabsContent value="jobs" class="mt-4">
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
||||
<div>
|
||||
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base">Задачи</CardTitle>
|
||||
<CardDescription>Фоновые задачи (ingest, apply, refresh)</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onclick={loadJobs} disabled={jobsLoading}>
|
||||
<Button variant="outline" size="sm" class="shrink-0 self-start sm:self-auto" onclick={loadJobs} disabled={jobsLoading}>
|
||||
<RefreshCw class={jobsLoading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
Reference in new issue
Block a user