feat: implement CDN source preview functionality and enhance data handling. Add a new endpoint for previewing CDN sources, allowing users to fetch and parse CIDR prefixes from specified URLs. Update OpenAPI documentation to include new request and response schemas, and modify internal logic to support JSON parsing with prefix path traversal. Enhance UI components to accommodate new preview features, improving user experience and data management.
CI / changes (push) Successful in 6s
CI / openapi (push) Successful in 25s
CI / go (push) Successful in 29s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m1s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m1s
CI / docker-bird (push) Successful in 42s
CI / bird2 (push) Successful in 14s
CI / docker-go-prime (push) Successful in 1m22s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 58s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 2m0s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m16s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m14s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m17s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m2s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m15s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m15s
CI / changes (push) Successful in 6s
CI / openapi (push) Successful in 25s
CI / go (push) Successful in 29s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m1s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m1s
CI / docker-bird (push) Successful in 42s
CI / bird2 (push) Successful in 14s
CI / docker-go-prime (push) Successful in 1m22s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 58s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 2m0s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m16s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m14s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m17s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m2s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m15s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m15s
This commit is contained in:
@@ -60,17 +60,26 @@ export type CdnSource = {
|
||||
id: string;
|
||||
url: string;
|
||||
source_kind: string;
|
||||
prefix_path: string;
|
||||
community_id: string | null;
|
||||
refresh_interval_sec: number | null;
|
||||
};
|
||||
export type CdnSourceCreate = {
|
||||
url: string;
|
||||
source_kind: string;
|
||||
prefix_path?: string;
|
||||
community_id?: string | null;
|
||||
};
|
||||
export type CdnSourcePatch = Partial<CdnSourceCreate> & { refresh_interval_sec?: number | null };
|
||||
export type CdnSourcesResponse = Page<CdnSource>;
|
||||
|
||||
export type CdnPreviewResponse = {
|
||||
items: string[];
|
||||
total: number;
|
||||
truncated: boolean;
|
||||
source_url: string;
|
||||
};
|
||||
|
||||
// ---- Domain Entries ----
|
||||
export type DomainEntry = {
|
||||
id: string;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
CdnSource,
|
||||
CdnSourceCreate,
|
||||
CdnSourcesResponse,
|
||||
CdnPreviewResponse,
|
||||
DomainEntry,
|
||||
DomainEntryCreate,
|
||||
DomainEntriesResponse,
|
||||
@@ -91,9 +92,20 @@
|
||||
let cdnSources = $state<CdnSource[]>([]);
|
||||
let cdnDialog = $state(false);
|
||||
let cdnEdit = $state<CdnSource | null>(null);
|
||||
let cdnForm = $state<CdnSourceCreate & { refresh_interval_sec?: number | null }>({ url: '', source_kind: '', community_id: null });
|
||||
let cdnForm = $state<CdnSourceCreate & { refresh_interval_sec?: number | null }>({
|
||||
url: '',
|
||||
source_kind: 'plaintext',
|
||||
prefix_path: '',
|
||||
community_id: null
|
||||
});
|
||||
let cdnSaving = $state(false);
|
||||
let cdnDeleteTarget = $state<CdnSource | null>(null);
|
||||
let cdnPreviewLoading = $state(false);
|
||||
let cdnPreviewItems = $state<string[]>([]);
|
||||
let cdnPreviewTotal = $state(0);
|
||||
let cdnPreviewTruncated = $state(false);
|
||||
let cdnPreviewError = $state<string | null>(null);
|
||||
let cdnPreviewOk = $state(false);
|
||||
|
||||
// Domain entries
|
||||
let domainEntries = $state<DomainEntry[]>([]);
|
||||
@@ -164,6 +176,19 @@
|
||||
|
||||
onMount(loadMod);
|
||||
|
||||
function normalizeCdnSourceKind(k: string): 'plaintext' | 'json' {
|
||||
return k.trim().toLowerCase() === 'json' ? 'json' : 'plaintext';
|
||||
}
|
||||
|
||||
function clearCdnPreview() {
|
||||
cdnPreviewLoading = false;
|
||||
cdnPreviewItems = [];
|
||||
cdnPreviewTotal = 0;
|
||||
cdnPreviewTruncated = false;
|
||||
cdnPreviewError = null;
|
||||
cdnPreviewOk = false;
|
||||
}
|
||||
|
||||
// --- Module Actions ---
|
||||
async function openEditMod() {
|
||||
if (!mod) return;
|
||||
@@ -270,24 +295,77 @@
|
||||
// --- CDN Sources ---
|
||||
function openCdnCreate() {
|
||||
cdnEdit = null;
|
||||
cdnForm = { url: '', source_kind: '', community_id: null };
|
||||
clearCdnPreview();
|
||||
cdnForm = { url: '', source_kind: 'plaintext', prefix_path: '', community_id: null };
|
||||
cdnDialog = true;
|
||||
}
|
||||
function openCdnEdit(src: CdnSource) {
|
||||
cdnEdit = src;
|
||||
cdnForm = { url: src.url, source_kind: src.source_kind, community_id: src.community_id, refresh_interval_sec: src.refresh_interval_sec };
|
||||
clearCdnPreview();
|
||||
cdnForm = {
|
||||
url: src.url,
|
||||
source_kind: normalizeCdnSourceKind(src.source_kind),
|
||||
prefix_path: src.prefix_path ?? '',
|
||||
community_id: src.community_id,
|
||||
refresh_interval_sec: src.refresh_interval_sec
|
||||
};
|
||||
cdnDialog = true;
|
||||
}
|
||||
async function previewCdn() {
|
||||
const urlTrim = cdnForm.url.trim();
|
||||
if (!urlTrim) {
|
||||
toast.error('Укажите URL');
|
||||
return;
|
||||
}
|
||||
cdnPreviewLoading = true;
|
||||
cdnPreviewError = null;
|
||||
cdnPreviewOk = false;
|
||||
try {
|
||||
const res = await apiMutate<CdnPreviewResponse>(
|
||||
`/v1/modules/${moduleId}/cdn-sources/preview`,
|
||||
'POST',
|
||||
{
|
||||
url: urlTrim,
|
||||
source_kind: cdnForm.source_kind,
|
||||
prefix_path: cdnForm.prefix_path?.trim() ?? ''
|
||||
}
|
||||
);
|
||||
cdnPreviewItems = res.items;
|
||||
cdnPreviewTotal = res.total;
|
||||
cdnPreviewTruncated = res.truncated;
|
||||
cdnPreviewOk = true;
|
||||
} catch (e) {
|
||||
cdnPreviewError = e instanceof Error ? e.message : String(e);
|
||||
cdnPreviewItems = [];
|
||||
cdnPreviewTotal = 0;
|
||||
cdnPreviewTruncated = false;
|
||||
cdnPreviewOk = false;
|
||||
} finally {
|
||||
cdnPreviewLoading = false;
|
||||
}
|
||||
}
|
||||
async function saveCdn() {
|
||||
const urlTrim = cdnForm.url.trim();
|
||||
if (!urlTrim) {
|
||||
toast.error('Укажите URL');
|
||||
return;
|
||||
}
|
||||
cdnSaving = true;
|
||||
try {
|
||||
const body = {
|
||||
...cdnForm,
|
||||
url: urlTrim,
|
||||
source_kind: cdnForm.source_kind,
|
||||
prefix_path: cdnForm.prefix_path?.trim() ?? ''
|
||||
};
|
||||
if (cdnEdit) {
|
||||
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${cdnEdit.id}`, 'PATCH', cdnForm);
|
||||
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${cdnEdit.id}`, 'PATCH', body);
|
||||
toast.success('Источник обновлён');
|
||||
} else {
|
||||
await apiMutate(`/v1/modules/${moduleId}/cdn-sources`, 'POST', cdnForm);
|
||||
await apiMutate(`/v1/modules/${moduleId}/cdn-sources`, 'POST', body);
|
||||
toast.success('Источник добавлен');
|
||||
}
|
||||
clearCdnPreview();
|
||||
cdnDialog = false;
|
||||
await loadEntries();
|
||||
} catch (e) {
|
||||
@@ -559,7 +637,16 @@
|
||||
{#each cdnSources as src (src.id)}
|
||||
<TableRow>
|
||||
<TableCell class="font-mono text-xs max-w-xs truncate">{src.url}</TableCell>
|
||||
<TableCell><Badge variant="outline">{src.source_kind}</Badge></TableCell>
|
||||
<TableCell>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<Badge variant="outline">{normalizeCdnSourceKind(src.source_kind)}</Badge>
|
||||
{#if src.prefix_path?.trim()}
|
||||
<span class="text-muted-foreground font-mono text-xs break-all" title={src.prefix_path}
|
||||
>{src.prefix_path}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground text-sm">{communityLabel(src.community_id)}</TableCell>
|
||||
<TableCell class="text-muted-foreground text-sm">{src.refresh_interval_sec ? `${src.refresh_interval_sec}с` : '—'}</TableCell>
|
||||
<TableCell>
|
||||
@@ -805,8 +892,13 @@
|
||||
</AlertDialog>
|
||||
|
||||
<!-- ================== CDN Source Dialog ================== -->
|
||||
<Dialog bind:open={cdnDialog}>
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<Dialog
|
||||
bind:open={cdnDialog}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) clearCdnPreview();
|
||||
}}
|
||||
>
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{cdnEdit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -817,7 +909,34 @@
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="cdn-kind">Тип источника</Label>
|
||||
<Input id="cdn-kind" placeholder="plaintext_cidr" bind:value={cdnForm.source_kind} />
|
||||
<Select
|
||||
type="single"
|
||||
value={cdnForm.source_kind}
|
||||
onValueChange={(v) => {
|
||||
cdnForm.source_kind = v || 'plaintext';
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="cdn-kind" class="w-full">
|
||||
{cdnForm.source_kind === 'json' ? 'json' : 'plaintext'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="plaintext">plaintext</SelectItem>
|
||||
<SelectItem value="json">json</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="cdn-prefix-path">JSON path (prefix_path)</Label>
|
||||
<Input
|
||||
id="cdn-prefix-path"
|
||||
placeholder="напр. prefixes[] или data.items[].cidr"
|
||||
bind:value={cdnForm.prefix_path}
|
||||
/>
|
||||
{#if cdnForm.source_kind === 'json' && !cdnForm.prefix_path?.trim()}
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="cdn-comm">Community</Label>
|
||||
@@ -837,6 +956,28 @@
|
||||
<Label for="cdn-interval">Интервал обновления (сек)</Label>
|
||||
<Input id="cdn-interval" type="number" placeholder="3600" bind:value={cdnForm.refresh_interval_sec} />
|
||||
</div>
|
||||
<div class="border-border flex flex-col gap-2 rounded-lg border p-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="secondary" size="sm" onclick={previewCdn} disabled={cdnPreviewLoading}>
|
||||
{cdnPreviewLoading ? 'Загрузка…' : 'Предпросмотр'}
|
||||
</Button>
|
||||
{#if cdnPreviewError}
|
||||
<span class="text-destructive text-sm">{cdnPreviewError}</span>
|
||||
{:else if cdnPreviewOk}
|
||||
<span class="text-muted-foreground text-sm">
|
||||
Всего: {cdnPreviewTotal}{#if cdnPreviewTruncated}
|
||||
<span class="text-amber-600 dark:text-amber-500"> (обрезано)</span>{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if cdnPreviewItems.length}
|
||||
<ul class="bg-muted/40 max-h-48 overflow-y-auto rounded-md border p-2 font-mono text-xs">
|
||||
{#each cdnPreviewItems as item, i (`${i}-${item}`)}
|
||||
<li class="py-0.5">{item}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={() => (cdnDialog = false)}>Отмена</Button>
|
||||
|
||||
Reference in New Issue
Block a user