forked from wrenn/wrenn
Add admin template deletion with broadcast to all hosts
- DELETE /v1/admin/templates/{name} endpoint (admin-only)
- Broadcasts DeleteSnapshot RPC to all online hosts before removing DB record
- Frontend admin templates page uses deleteAdminTemplate() instead of
team-scoped deleteSnapshot()
- Delete button shown for all template types, not just snapshots
This commit is contained in:
@ -64,3 +64,7 @@ export type AdminTemplate = {
|
|||||||
export async function listAdminTemplates(): Promise<ApiResult<AdminTemplate[]>> {
|
export async function listAdminTemplates(): Promise<ApiResult<AdminTemplate[]>> {
|
||||||
return apiFetch('GET', '/api/v1/admin/templates');
|
return apiFetch('GET', '/api/v1/admin/templates');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminTemplate(name: string): Promise<ApiResult<void>> {
|
||||||
|
return apiFetch('DELETE', `/api/v1/admin/templates/${name}`);
|
||||||
|
}
|
||||||
|
|||||||
@ -3,11 +3,11 @@
|
|||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { toast } from '$lib/toast.svelte';
|
import { toast } from '$lib/toast.svelte';
|
||||||
import { formatDate, timeAgo } from '$lib/utils/format';
|
import { formatDate, timeAgo } from '$lib/utils/format';
|
||||||
import { deleteSnapshot } from '$lib/api/capsules';
|
|
||||||
import {
|
import {
|
||||||
listBuilds,
|
listBuilds,
|
||||||
createBuild,
|
createBuild,
|
||||||
listAdminTemplates,
|
listAdminTemplates,
|
||||||
|
deleteAdminTemplate,
|
||||||
type Build,
|
type Build,
|
||||||
type BuildLogEntry,
|
type BuildLogEntry,
|
||||||
type AdminTemplate
|
type AdminTemplate
|
||||||
@ -145,7 +145,7 @@
|
|||||||
deleting = true;
|
deleting = true;
|
||||||
deleteError = null;
|
deleteError = null;
|
||||||
const name = deleteTarget.name;
|
const name = deleteTarget.name;
|
||||||
const result = await deleteSnapshot(name);
|
const result = await deleteAdminTemplate(name);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
templates = templates.filter((t) => t.name !== name);
|
templates = templates.filter((t) => t.name !== name);
|
||||||
deleteTarget = null;
|
deleteTarget = null;
|
||||||
@ -413,14 +413,12 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3.5 text-right">
|
<td class="px-4 py-3.5 text-right">
|
||||||
{#if tmpl.type === 'snapshot'}
|
|
||||||
<button
|
<button
|
||||||
onclick={() => { deleteTarget = tmpl; deleteError = null; }}
|
onclick={() => { deleteTarget = tmpl; deleteError = null; }}
|
||||||
class="rounded-[var(--radius-button)] px-3 py-1.5 text-meta text-[var(--color-text-tertiary)] transition-colors duration-150 hover:bg-[var(--color-red)]/10 hover:text-[var(--color-red)]"
|
class="rounded-[var(--radius-button)] px-3 py-1.5 text-meta text-[var(--color-text-tertiary)] transition-colors duration-150 hover:bg-[var(--color-red)]/10 hover:text-[var(--color-red)]"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@ -7,21 +7,25 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"connectrpc.com/connect"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"git.omukk.dev/wrenn/sandbox/internal/db"
|
"git.omukk.dev/wrenn/sandbox/internal/db"
|
||||||
"git.omukk.dev/wrenn/sandbox/internal/id"
|
"git.omukk.dev/wrenn/sandbox/internal/id"
|
||||||
|
"git.omukk.dev/wrenn/sandbox/internal/lifecycle"
|
||||||
"git.omukk.dev/wrenn/sandbox/internal/service"
|
"git.omukk.dev/wrenn/sandbox/internal/service"
|
||||||
"git.omukk.dev/wrenn/sandbox/internal/validate"
|
"git.omukk.dev/wrenn/sandbox/internal/validate"
|
||||||
|
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
|
||||||
)
|
)
|
||||||
|
|
||||||
type buildHandler struct {
|
type buildHandler struct {
|
||||||
svc *service.BuildService
|
svc *service.BuildService
|
||||||
db *db.Queries
|
db *db.Queries
|
||||||
|
pool *lifecycle.HostClientPool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newBuildHandler(svc *service.BuildService, db *db.Queries) *buildHandler {
|
func newBuildHandler(svc *service.BuildService, db *db.Queries, pool *lifecycle.HostClientPool) *buildHandler {
|
||||||
return &buildHandler{svc: svc, db: db}
|
return &buildHandler{svc: svc, db: db, pool: pool}
|
||||||
}
|
}
|
||||||
|
|
||||||
type createBuildRequest struct {
|
type createBuildRequest struct {
|
||||||
@ -202,3 +206,42 @@ func (h *buildHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteTemplate handles DELETE /v1/admin/templates/{name}.
|
||||||
|
func (h *buildHandler) DeleteTemplate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := chi.URLParam(r, "name")
|
||||||
|
if err := validate.SafeName(name); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", fmt.Sprintf("invalid template name: %s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
if _, err := h.db.GetTemplate(ctx, name); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "template not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast delete to all online hosts.
|
||||||
|
hosts, _ := h.db.ListActiveHosts(ctx)
|
||||||
|
for _, host := range hosts {
|
||||||
|
if host.Status != "online" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
agent, err := h.pool.GetForHost(host)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := agent.DeleteSnapshot(ctx, connect.NewRequest(&pb.DeleteSnapshotRequest{Name: name})); err != nil {
|
||||||
|
if connect.CodeOf(err) != connect.CodeNotFound {
|
||||||
|
slog.Warn("admin: failed to delete template on host", "host_id", id.FormatHostID(host.ID), "name", name, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.DeleteTemplate(ctx, name); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete template record")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|||||||
@ -67,7 +67,7 @@ func New(
|
|||||||
auditH := newAuditHandler(auditSvc)
|
auditH := newAuditHandler(auditSvc)
|
||||||
statsH := newStatsHandler(statsSvc)
|
statsH := newStatsHandler(statsSvc)
|
||||||
metricsH := newSandboxMetricsHandler(queries, pool)
|
metricsH := newSandboxMetricsHandler(queries, pool)
|
||||||
buildH := newBuildHandler(buildSvc, queries)
|
buildH := newBuildHandler(buildSvc, queries, pool)
|
||||||
|
|
||||||
// OpenAPI spec and docs.
|
// OpenAPI spec and docs.
|
||||||
r.Get("/openapi.yaml", serveOpenAPI)
|
r.Get("/openapi.yaml", serveOpenAPI)
|
||||||
@ -178,6 +178,7 @@ func New(
|
|||||||
r.Use(requireAdmin(queries))
|
r.Use(requireAdmin(queries))
|
||||||
r.Put("/teams/{id}/byoc", teamH.SetBYOC)
|
r.Put("/teams/{id}/byoc", teamH.SetBYOC)
|
||||||
r.Get("/templates", buildH.ListTemplates)
|
r.Get("/templates", buildH.ListTemplates)
|
||||||
|
r.Delete("/templates/{name}", buildH.DeleteTemplate)
|
||||||
r.Post("/builds", buildH.Create)
|
r.Post("/builds", buildH.Create)
|
||||||
r.Get("/builds", buildH.List)
|
r.Get("/builds", buildH.List)
|
||||||
r.Get("/builds/{id}", buildH.Get)
|
r.Get("/builds/{id}", buildH.Get)
|
||||||
|
|||||||
Reference in New Issue
Block a user