diff --git a/docs/openapi.yaml b/docs/openapi.yaml index b09515f..fc8da57 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2379,6 +2379,21 @@ paths: $ref: "#/components/responses/NotFound" default: $ref: "#/components/responses/DefaultProblem" + delete: + tags: [Speakers] + summary: Удалить спикер + description: > + Удаляет BGP-спикер. Пиры с `bgp_speaker_id` этого спикера остаются, привязка сбрасывается (ON DELETE SET NULL). + operationId: deleteSpeaker + parameters: + - $ref: "#/components/parameters/IdempotencyKey" + responses: + "204": + description: Удалено. + "404": + $ref: "#/components/responses/NotFound" + default: + $ref: "#/components/responses/DefaultProblem" /v1/revisions: get: diff --git a/internal/httpapi/routes_crud.go b/internal/httpapi/routes_crud.go index d2499b9..0a791cb 100644 --- a/internal/httpapi/routes_crud.go +++ b/internal/httpapi/routes_crud.go @@ -65,6 +65,7 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) { m.HandleFunc("POST /speakers", s.handlePostSpeaker) m.HandleFunc("GET /speakers/{speaker_id}", s.handleGetSpeakerByID) m.HandleFunc("PATCH /speakers/{speaker_id}", s.handlePatchSpeaker) + m.HandleFunc("DELETE /speakers/{speaker_id}", s.handleDeleteSpeaker) m.HandleFunc("GET /revisions/{revision_id}/prefixes", s.handleRevisionPrefixes) @@ -1021,6 +1022,18 @@ func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, speakerJSONFromStore(s.store, x)) } +func (s *Server) handleDeleteSpeaker(w http.ResponseWriter, r *http.Request) { + a, ok := authFromContext(r.Context()) + if !ok || !s.requireAtLeast(w, a, "editor") { + return + } + if err := s.store.DeleteSpeaker(a.TenantID, r.PathValue("speaker_id")); err != nil { + writeStoreErr(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request) { a, ok := authFromContext(r.Context()) if !ok || !s.requireAtLeast(w, a, "viewer") { diff --git a/internal/httpapi/speakers_test.go b/internal/httpapi/speakers_test.go index 1f1488d..af174e0 100644 --- a/internal/httpapi/speakers_test.go +++ b/internal/httpapi/speakers_test.go @@ -41,6 +41,27 @@ func TestPostSpeaker_defaultsFromEndpointIP(t *testing.T) { } } +func TestDeleteSpeaker(t *testing.T) { + srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed}) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + tenant, _, _, _, demoSpk := srv.Store().DemoIDs() + mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|editor") + + req := httptest.NewRequest(http.MethodDelete, "/v1/speakers/"+demoSpk, nil) + req.Header.Set("Authorization", "Bearer edkey") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + if _, err := srv.Store().GetSpeaker(tenant, demoSpk); err == nil { + t.Fatal("speaker should be deleted") + } +} + func TestGetBundleSigningPublicKey(t *testing.T) { srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed}) if err != nil { diff --git a/internal/repository/postgres.go b/internal/repository/postgres.go index 80dce29..965c435 100644 --- a/internal/repository/postgres.go +++ b/internal/repository/postgres.go @@ -646,6 +646,18 @@ func (p *Postgres) UpdateSpeaker(tenantID, id string, patch *store.SpeakerPatch) return p.GetSpeaker(tenantID, id) } +func (p *Postgres) DeleteSpeaker(tenantID, id string) error { + ctx := context.Background() + tag, err := p.pool.Exec(ctx, `DELETE FROM bgp_speaker WHERE id=$1 AND tenant_id=$2`, id, tenantID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return store.ErrNotFound + } + return nil +} + func (p *Postgres) GetRevision(tenantID, revisionID string) (*store.Revision, error) { ctx := context.Background() var r store.Revision diff --git a/internal/store/backend.go b/internal/store/backend.go index 2eabf87..d157e5c 100644 --- a/internal/store/backend.go +++ b/internal/store/backend.go @@ -73,6 +73,7 @@ type Backend interface { GetSpeakerAnyTenant(speakerID string) (*Speaker, error) CreateSpeaker(tenantID string, in *Speaker) (*Speaker, error) UpdateSpeaker(tenantID, id string, patch *SpeakerPatch) (*Speaker, error) + DeleteSpeaker(tenantID, id string) error GetRevision(tenantID, revisionID string) (*Revision, error) ListRevisions(tenantID, moduleID string, cursor string, limit int) (items []*Revision, nextCursor string, hasMore bool) diff --git a/internal/store/memory_crud.go b/internal/store/memory_crud.go index aa2c0ed..b61eec9 100644 --- a/internal/store/memory_crud.go +++ b/internal/store/memory_crud.go @@ -779,6 +779,18 @@ func (m *Memory) UpdateSpeaker(tenantID, id string, patch *SpeakerPatch) (*Speak return sp, nil } +func (m *Memory) DeleteSpeaker(tenantID, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + sp, ok := m.speakers[id] + if !ok || sp.TenantID != tenantID { + return ErrNotFound + } + delete(m.speakers, id) + delete(m.publishedRevision, id) + return nil +} + func (m *Memory) ListRevisionPrefixes(tenantID, revisionID string, cursor string, limit int) ([]PrefixRow, string, bool) { if limit <= 0 { limit = 50 diff --git a/web/src/lib/components/network/NetworkSpeakersCard.svelte b/web/src/lib/components/network/NetworkSpeakersCard.svelte index 41ee610..29920ce 100644 --- a/web/src/lib/components/network/NetworkSpeakersCard.svelte +++ b/web/src/lib/components/network/NetworkSpeakersCard.svelte @@ -22,11 +22,13 @@ import FormField from '$lib/ui/patterns/form/form-field.svelte'; import AppInput from '$lib/ui/patterns/form/app-input.svelte'; import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte'; + import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js'; import { notify, notifyApiError } from '$lib/ui/app/toast.js'; import Plus from '@lucide/svelte/icons/plus'; import Pencil from '@lucide/svelte/icons/pencil'; import Play from '@lucide/svelte/icons/play'; import Copy from '@lucide/svelte/icons/copy'; + import Trash2 from '@lucide/svelte/icons/trash-2'; type Props = { items: SpeakerRow[]; @@ -180,6 +182,21 @@ dialogOpen = true; } + function requestDelete(s: SpeakerRow) { + const label = s.agent_domain ?? s.endpoint ?? s.id; + void confirm({ + title: 'Удалить спикера?', + description: label, + confirmLabel: 'Удалить', + destructive: true, + onConfirm: async () => { + await apiMutate(`/v1/speakers/${s.id}`, 'DELETE', undefined, { idempotent: false }); + notify.success('Спикер удалён'); + await onRefresh(); + } + }); + } + function openApply(s: SpeakerRow) { applyTarget = s; applyRevisionId = s.published_revision_id ?? ''; @@ -341,6 +358,15 @@ CF_DNS_API_TOKEN= + {/if} {/snippet}