diff --git a/archivebox/api/v1_crawls.py b/archivebox/api/v1_crawls.py index 38b0597e..d8845999 100644 --- a/archivebox/api/v1_crawls.py +++ b/archivebox/api/v1_crawls.py @@ -47,7 +47,7 @@ class CrawlSchema(Schema): @staticmethod def resolve_crawl_max_concurrent_snapshots(obj): - return int((obj.config or {}).get("CRAWL_MAX_CONCURRENT_SNAPSHOTS") or get_config().CRAWL_MAX_CONCURRENT_SNAPSHOTS) + return int(get_config(crawl=obj).CRAWL_MAX_CONCURRENT_SNAPSHOTS) @staticmethod def resolve_created_by_id(obj): @@ -119,15 +119,13 @@ def create_crawl(request: HttpRequest, data: CrawlCreateSchema): raise HttpError(400, "crawl_max_size must be >= 0") if data.snapshot_max_size < 0: raise HttpError(400, "snapshot_max_size must be >= 0") - crawl_max_concurrent_snapshots = data.crawl_max_concurrent_snapshots - if crawl_max_concurrent_snapshots is None: - crawl_max_concurrent_snapshots = get_config().CRAWL_MAX_CONCURRENT_SNAPSHOTS - if crawl_max_concurrent_snapshots < 1: + if data.crawl_max_concurrent_snapshots is not None and data.crawl_max_concurrent_snapshots < 1: raise HttpError(400, "crawl_max_concurrent_snapshots must be >= 1") tags = normalize_tag_list(data.tags, data.tags_str) config = dict(data.config or {}) - config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"] = crawl_max_concurrent_snapshots + if data.crawl_max_concurrent_snapshots is not None: + config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"] = data.crawl_max_concurrent_snapshots crawl = Crawl.objects.create( urls="\n".join(urls), max_depth=data.max_depth, diff --git a/archivebox/cli/archivebox_add.py b/archivebox/cli/archivebox_add.py index dd8225d7..ac2deff6 100644 --- a/archivebox/cli/archivebox_add.py +++ b/archivebox/cli/archivebox_add.py @@ -85,6 +85,7 @@ def add( crawl_max_size = parse_filesize_to_bytes(crawl_max_size) snapshot_max_size = parse_filesize_to_bytes(snapshot_max_size) config = get_config() + crawl_max_concurrent_snapshots_override = crawl_max_concurrent_snapshots is not None if crawl_max_concurrent_snapshots is None: crawl_max_concurrent_snapshots = config.CRAWL_MAX_CONCURRENT_SNAPSHOTS crawl_max_concurrent_snapshots = int(crawl_max_concurrent_snapshots) @@ -141,9 +142,26 @@ def add( # Read URLs directly into crawl urls_content = sources_file.read_text() persona_name = (persona or "Default").strip() or "Default" - plugins = plugins or str(config.get("PLUGINS") or "") + plugins = plugins or "" persona_obj, _ = Persona.objects.get_or_create(name=persona_name) persona_obj.ensure_dirs() + effective_persona_config = get_config(persona=persona_obj) + + crawl_config = { + **({"ONLY_NEW": not update} if not update else {}), + **({"INDEX_ONLY": True} if index_only else {}), + **({"OVERWRITE": True} if overwrite else {}), + **({"PLUGINS": plugins} if plugins else {}), + **( + {"CRAWL_MAX_CONCURRENT_SNAPSHOTS": crawl_max_concurrent_snapshots} + if crawl_max_concurrent_snapshots_override + and crawl_max_concurrent_snapshots != int(effective_persona_config.CRAWL_MAX_CONCURRENT_SNAPSHOTS) + else {} + ), + **({"PARSER": parser} if parser != "auto" else {}), + **({"URL_ALLOWLIST": url_allowlist} if url_allowlist else {}), + **({"URL_DENYLIST": url_denylist} if url_denylist else {}), + } crawl = Crawl.objects.create( urls=urls_content, @@ -157,17 +175,7 @@ def add( created_by_id=created_by_id, status=Crawl.StatusChoices.QUEUED if bg or index_only else Crawl.StatusChoices.STARTED, retry_at=timezone.now() if bg else None, - config={ - "ONLY_NEW": not update, - "INDEX_ONLY": index_only, - "OVERWRITE": overwrite, - "PLUGINS": plugins, - "DEFAULT_PERSONA": persona_name, - "CRAWL_MAX_CONCURRENT_SNAPSHOTS": crawl_max_concurrent_snapshots, - "PARSER": parser, - **({"URL_ALLOWLIST": url_allowlist} if url_allowlist else {}), - **({"URL_DENYLIST": url_denylist} if url_denylist else {}), - }, + config=crawl_config, ) print(f"[green]\\[+] Created Crawl {crawl.id} with max_depth={depth}[/green]") diff --git a/archivebox/core/views.py b/archivebox/core/views.py index 15e8e2aa..4a81e46c 100644 --- a/archivebox/core/views.py +++ b/archivebox/core/views.py @@ -1097,7 +1097,7 @@ class AddView(UserPassesTestMixin, FormView): if not isinstance(plugin_config, dict): plugin_config = {} custom_config = self._get_custom_config_overrides(form) - persona_name = persona.name if persona else "Default" + custom_config.pop("DEFAULT_PERSONA", None) if persona: persona.ensure_dirs() @@ -1121,19 +1121,20 @@ class AddView(UserPassesTestMixin, FormView): # 2. create a new Crawl with the URLs from the file timestamp = timezone.now().strftime("%Y-%m-%d__%H-%M-%S") urls_content = sources_file.read_text() - # Build complete config - config = { - "INDEX_ONLY": index_only, - "DEPTH": depth, - "PLUGINS": plugins or "", - "DEFAULT_PERSONA": persona_name, - "CRAWL_MAX_CONCURRENT_SNAPSHOTS": crawl_max_concurrent_snapshots, - } + # Store only explicit crawl-scoped overrides. Persona/machine/plugin + # defaults are resolved at hook runtime via get_config(...). + config = {} + if index_only: + config["INDEX_ONLY"] = True + if plugins: + config["PLUGINS"] = plugins + effective_config = get_config(persona=persona) if persona else get_config() + if crawl_max_concurrent_snapshots != int(effective_config.CRAWL_MAX_CONCURRENT_SNAPSHOTS): + config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"] = crawl_max_concurrent_snapshots # Merge custom config overrides config.update(plugin_config) config.update(custom_config) - config["DEFAULT_PERSONA"] = persona_name if url_filters.get("allowlist"): config["URL_ALLOWLIST"] = url_filters["allowlist"] if url_filters.get("denylist"): @@ -1527,14 +1528,22 @@ def live_progress_view(request): key=lambda crawl: crawl.modified_at, reverse=True, )[:10] - persona_names_by_id: dict[str, str] = {} + persona_details_by_id: dict[str, dict[str, str]] = {} + persona_details_by_name: dict[str, dict[str, str]] = {} persona_ids = {crawl.persona_id for crawl in active_crawls_list if crawl.persona_id} - if persona_ids: + persona_names = { + str((crawl.config or {}).get("DEFAULT_PERSONA") or "Default") for crawl in active_crawls_list if not crawl.persona_id + } + if persona_ids or persona_names: from archivebox.personas.models import Persona - persona_names_by_id = { - str(persona_id): name for persona_id, name in Persona.objects.filter(id__in=persona_ids).values_list("id", "name") - } + for persona in Persona.objects.filter(Q(id__in=persona_ids) | Q(name__in=persona_names)).only("id", "name"): + persona_details = { + "name": persona.name, + "admin_url": f"/admin/personas/persona/{persona.pk}/change/", + } + persona_details_by_id[str(persona.id)] = persona_details + persona_details_by_name[persona.name] = persona_details active_crawl_ids = [crawl.id for crawl in active_crawls_list] snapshot_counts_by_crawl: dict[str, dict[str, int]] = {str(crawl_id): {} for crawl_id in active_crawl_ids} cancelled_snapshot_counts_by_crawl: dict[str, int] = {str(crawl_id): 0 for crawl_id in active_crawl_ids} @@ -1929,8 +1938,9 @@ def live_progress_view(request): can_start = bool(crawl.urls) urls_preview = crawl.urls[:60] if crawl.urls else None crawl_tags = [tag.strip() for tag in (crawl.tags_str or "").replace("\n", ",").split(",") if tag.strip()] - persona_name = persona_names_by_id.get(str(crawl.persona_id)) if crawl.persona_id else None - persona_name = persona_name or str((crawl.config or {}).get("DEFAULT_PERSONA") or "Default") + persona_details = persona_details_by_id.get(str(crawl.persona_id)) if crawl.persona_id else None + persona_name = persona_details["name"] if persona_details else str((crawl.config or {}).get("DEFAULT_PERSONA") or "Default") + persona_details = persona_details or persona_details_by_name.get(persona_name) crawl_output_size = crawl_output_sizes_by_crawl.get(str(crawl.id), 0) avg_snapshot_size = int(crawl_output_size / total_snapshots) if total_snapshots else 0 @@ -1958,6 +1968,7 @@ def live_progress_view(request): "progress": crawl_progress, "created_by": crawl.created_by.username, "persona": persona_name, + "persona_admin_url": persona_details["admin_url"] if persona_details else None, "max_depth": crawl.max_depth, "max_urls": crawl.max_urls, "max_crawl_size": crawl.crawl_max_size, diff --git a/archivebox/crawls/models.py b/archivebox/crawls/models.py index 5aee094c..8e3703e1 100755 --- a/archivebox/crawls/models.py +++ b/archivebox/crawls/models.py @@ -6,6 +6,7 @@ from io import StringIO import uuid import json import re +from itertools import islice from datetime import timedelta from archivebox.uuid_compat import uuid7 from pathlib import Path @@ -180,6 +181,13 @@ class Crawl(ModelWithOutputDir, ModelWithConfig, ModelWithHealthStats, ModelWith return f"[...{short_id}] {first_url[:120]}" def save(self, *args, **kwargs): + update_fields = kwargs.get("update_fields") + sync_tags = update_fields is None or "tags_str" in update_fields + previous_tag_names = set() + if sync_tags and self.pk: + previous_tags_str = type(self).objects.filter(pk=self.pk).values_list("tags_str", flat=True).first() + previous_tag_names = set(self.parse_tag_names(previous_tags_str or "")) + config = dict(self.config or {}) if self.max_urls > 0: config["CRAWL_MAX_URLS"] = self.max_urls @@ -210,6 +218,20 @@ class Crawl(ModelWithOutputDir, ModelWithConfig, ModelWithHealthStats, ModelWith kwargs["update_fields"] = tuple(dict.fromkeys([*update_fields, "config"])) super().save(*args, **kwargs) + if sync_tags: + next_tag_names = set(self.parse_tag_names(self.tags_str or "")) + added_tag_names = next_tag_names - previous_tag_names + removed_tag_names = previous_tag_names - next_tag_names + if added_tag_names or removed_tag_names: + # Keep the SQLite write phase short: the Crawl row is already + # saved, and the potentially large snapshot tag fanout runs in + # chunked ORM writes after any caller atomic() exits. + transaction.on_commit( + lambda: self.apply_snapshot_tag_diff( + added_tag_names=added_tag_names, + removed_tag_names=removed_tag_names, + ), + ) # if is_new: # from archivebox.misc.logging_util import log_worker_event # first_url = self.get_urls_list()[0] if self.get_urls_list() else '' @@ -229,6 +251,68 @@ class Crawl(ModelWithOutputDir, ModelWithConfig, ModelWithHealthStats, ModelWith def api_url(self) -> str: return str(reverse_lazy("api-1:get_crawl", args=[self.id])) + @staticmethod + def parse_tag_names(tags: Iterable[str] | str, *, pattern: str = r",") -> list[str]: + raw_tags = re.split(pattern, tags) if isinstance(tags, str) else tags + tag_names: list[str] = [] + seen: set[str] = set() + for raw_tag in raw_tags: + tag_name = str(raw_tag or "").strip() + if not tag_name: + continue + lowered = tag_name.lower() + if lowered in seen: + continue + seen.add(lowered) + tag_names.append(tag_name) + return tag_names + + def current_tag_names(self) -> list[str]: + current_tags_str = type(self).objects.filter(pk=self.pk).values_list("tags_str", flat=True).first() if self.pk else self.tags_str + if current_tags_str is not None: + self.tags_str = current_tags_str + return self.parse_tag_names(self.tags_str or "") + + def apply_snapshot_tag_diff(self, *, added_tag_names: Iterable[str], removed_tag_names: Iterable[str]) -> None: + from archivebox.core.models import Snapshot, SnapshotTag, Tag + + added_names = self.parse_tag_names(added_tag_names) + removed_names = self.parse_tag_names(removed_tag_names) + if not added_names and not removed_names: + return + + if added_names: + tags_by_name = {tag.name: tag for tag in Tag.objects.filter(name__in=added_names)} + missing_tags = [Tag(name=name) for name in added_names if name not in tags_by_name] + if missing_tags: + # One small write for missing tag rows, followed by chunked + # M2M fanout below; avoid per-snapshot get_or_create loops. + Tag.objects.bulk_create(missing_tags, ignore_conflicts=True) + tags_by_name = {tag.name: tag for tag in Tag.objects.filter(name__in=added_names)} + + tag_ids = [tag.pk for tag_name in added_names if (tag := tags_by_name.get(tag_name))] + snapshot_ids = Snapshot.objects.filter(crawl=self).values_list("id", flat=True).iterator(chunk_size=5000) + while True: + batch_snapshot_ids = list(islice(snapshot_ids, 5000)) + if not batch_snapshot_ids: + break + for tag_id in tag_ids: + # Chunked bulk_create keeps memory bounded and uses the + # SnapshotTag uniqueness constraint instead of row-by-row + # existence checks. + SnapshotTag.objects.bulk_create( + [SnapshotTag(snapshot_id=snapshot_id, tag_id=tag_id) for snapshot_id in batch_snapshot_ids], + ignore_conflicts=True, + batch_size=5000, + ) + + if removed_names: + removed_tag_ids = list(Tag.objects.filter(name__in=removed_names).values_list("pk", flat=True)) + if removed_tag_ids: + # One DELETE with a subquery keeps the tag removal transaction + # bounded to the M2M rows touched by this crawl only. + SnapshotTag.objects.filter(snapshot__crawl=self, tag_id__in=removed_tag_ids).delete() + def to_json(self) -> dict: """ Convert Crawl model instance to a JSON-serializable dict. @@ -653,13 +737,15 @@ class Crawl(ModelWithOutputDir, ModelWithConfig, ModelWithHealthStats, ModelWith Returns: List of newly created Snapshot objects """ - from archivebox.core.models import Snapshot + from archivebox.core.models import Snapshot, Tag from archivebox.misc.util import fix_url_from_markdown, sanitize_extracted_url if self.status == self.StatusChoices.SEALED: return [] created_snapshots = [] + crawl_tag_names = self.current_tag_names() + tags_by_name: dict[str, Tag] = {} for line in self.urls.splitlines(): if not line.strip(): @@ -673,14 +759,14 @@ class Crawl(ModelWithOutputDir, ModelWithConfig, ModelWithHealthStats, ModelWith depth = entry.get("depth", 0) title = entry.get("title") timestamp = entry.get("timestamp") - tags = entry.get("tags", "") + tag_names = [*crawl_tag_names, *self.parse_tag_names(entry.get("tags", ""))] except json.JSONDecodeError: snapshot_id = None url = sanitize_extracted_url(fix_url_from_markdown(line.strip())) depth = 0 title = None timestamp = None - tags = self.tags_str + tag_names = crawl_tag_names if not url: continue @@ -747,8 +833,17 @@ class Crawl(ModelWithOutputDir, ModelWithConfig, ModelWithHealthStats, ModelWith if created: created_snapshots.append(snapshot) - if tags: - snapshot.save_tags(tags.split(",")) + if tag_names: + missing_names = [tag_name for tag_name in tag_names if tag_name not in tags_by_name] + if missing_names: + tags_by_name.update({tag.name: tag for tag in Tag.objects.filter(name__in=missing_names)}) + missing_tags = [Tag(name=tag_name) for tag_name in missing_names if tag_name not in tags_by_name] + if missing_tags: + # Create tag rows in bulk, then attach through the M2M + # relation without clearing any non-crawl snapshot tags. + Tag.objects.bulk_create(missing_tags, ignore_conflicts=True) + tags_by_name.update({tag.name: tag for tag in Tag.objects.filter(name__in=missing_names)}) + snapshot.tags.add(*[tag.pk for tag_name in tag_names if (tag := tags_by_name.get(tag_name))]) # Symlink creation touches the filesystem and can be slow on remote disks. # Defer it until after any active DB transaction commits so SQLite does @@ -797,6 +892,7 @@ class Crawl(ModelWithOutputDir, ModelWithConfig, ModelWithHealthStats, ModelWith return [] config = get_config(crawl=self, snapshot=parent_snapshot) + crawl_tag_names = self.current_tag_names() allowlist = self.split_filter_patterns(config.get("URL_ALLOWLIST", "")) denylist = self.split_filter_patterns(config.get("URL_DENYLIST", "")) protected_subdomains = {"admin", "web", "api", "public"} @@ -894,9 +990,12 @@ class Crawl(ModelWithOutputDir, ModelWithConfig, ModelWithHealthStats, ModelWith tag_names_by_url: dict[str, set[str]] = {} for snapshot in created_snapshots: - tags = str(deduped_records[snapshot.url].get("tags") or "").strip() - if tags: - tag_names_by_url[snapshot.url] = {tag.strip() for tag in re.split(config.TAG_SEPARATOR_PATTERN, tags) if tag.strip()} + tag_names = { + *crawl_tag_names, + *self.parse_tag_names(str(deduped_records[snapshot.url].get("tags") or ""), pattern=config.TAG_SEPARATOR_PATTERN), + } + if tag_names: + tag_names_by_url[snapshot.url] = tag_names # Same transaction rule as create_snapshots_from_urls(): bulk_create() # only writes DB rows; symlink creation waits until after commit. transaction.on_commit(lambda snapshot=snapshot: snapshot.ensure_crawl_symlink()) diff --git a/archivebox/services/runner.py b/archivebox/services/runner.py index 68c46baf..2a0df789 100644 --- a/archivebox/services/runner.py +++ b/archivebox/services/runner.py @@ -561,8 +561,7 @@ class CrawlRunner: from archivebox.config.common import get_config snapshot = Snapshot.objects.select_related("crawl").get(id=snapshot_id) - config = get_config(crawl=self.crawl, snapshot=snapshot, include_machine=False) - config.update(self.base_config) + config = get_config(crawl=snapshot.crawl, snapshot=snapshot, include_machine=False) config["CRAWL_DIR"] = self.crawl_output_dir config["SNAP_DIR"] = str(snapshot.output_dir) extra_context: dict[str, Any] = {} diff --git a/archivebox/templates/admin/progress_monitor.html b/archivebox/templates/admin/progress_monitor.html index a045990a..a8fde54b 100644 --- a/archivebox/templates/admin/progress_monitor.html +++ b/archivebox/templates/admin/progress_monitor.html @@ -244,10 +244,30 @@ overflow: hidden; text-overflow: ellipsis; } + #progress-monitor a.crawl-badge { + cursor: pointer; + text-decoration: none !important; + } + #progress-monitor a.crawl-badge:hover { + border-color: currentColor; + filter: brightness(1.14); + } #progress-monitor .crawl-badge strong { color: #8b949e; font-weight: 600; } + #progress-monitor .crawl-title-link { + display: inline-block; + max-width: 100%; + color: inherit; + text-decoration: none !important; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + #progress-monitor .crawl-title-link:hover { + color: #79c0ff; + } #progress-monitor .crawl-badge.persona { color: #d2a8ff; background: rgba(163, 113, 247, 0.16); @@ -560,6 +580,21 @@ background: #21262d; color: #6e7681; } + #progress-monitor .duration-badge { + flex-shrink: 0; + min-width: 28px; + padding: 2px 6px; + border-radius: 10px; + background: rgba(88, 166, 255, 0.12); + color: #a5d6ff; + border: 1px solid rgba(88, 166, 255, 0.2); + font-size: 10px; + font-weight: 600; + line-height: 1.2; + text-align: center; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } /* Thumbnail Strip */ #progress-monitor .thumbnail-strip { @@ -749,7 +784,7 @@ return headers; } function escapeHtml(value) { - return String(value || '').replace(/[&<>"']/g, char => ({ + return String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', @@ -802,6 +837,35 @@ function renderThumbnail(thumb, isNew) { return null; } function updateThumbnails(thumbnails) {} + function formatDuration(seconds) { + seconds = Math.max(0, Math.floor(seconds || 0)); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + if (hours > 0) return `${hours}hr ${minutes}m ${secs}s`; + if (minutes > 0) return `${minutes}m ${secs}s`; + return `${secs}s`; + } + + function durationText(startedAt) { + const startedMs = Date.parse(startedAt || ''); + if (!Number.isFinite(startedMs)) return ''; + return formatDuration((Date.now() - startedMs) / 1000); + } + + function renderDurationBadge(startedAt) { + const text = durationText(startedAt); + if (!text) return ''; + return `${escapeHtml(text)}`; + } + + function updateDurationBadges() { + document.querySelectorAll('#progress-monitor .duration-badge[data-started-at]').forEach((badge) => { + const text = durationText(badge.dataset.startedAt); + if (text) badge.textContent = text; + }); + } + function renderExtractor(extractor) { const icon = extractor.status === 'started' ? '▶' : extractor.status === 'succeeded' ? '✓' : @@ -841,6 +905,7 @@ ? `` : ''; const snapshotPidHtml = snapshot.worker_pid ? `pid ${snapshot.worker_pid}` : ''; + const snapshotDurationHtml = renderDurationBadge(snapshot.started); const titleText = snapshot.title || formatUrl(snapshot.full_url || snapshot.url); const urlText = snapshot.full_url || snapshot.url || ''; const faviconHtml = snapshot.favicon_url @@ -902,6 +967,7 @@ ${snapshotPidHtml} + ${snapshotDurationHtml} ${snapshot.status || 'unknown'} ${cancelBtn} @@ -919,6 +985,13 @@ function renderCrawl(crawl) { const adminUrl = `/admin/crawls/crawl/${crawl.id || 'unknown'}/change/`; + const adminFieldUrl = (fieldName) => `${adminUrl}#id_${fieldName}`; + const crawlBadge = (className, label, value, href, title) => { + const tag = href ? 'a' : 'span'; + const hrefAttr = href ? ` href="${escapeAttr(href)}"` : ''; + const titleAttr = title ? ` title="${escapeAttr(title)}"` : ''; + return `<${tag} class="crawl-badge ${className}"${hrefAttr}${titleAttr}>${escapeHtml(label)}${escapeHtml(value)}${tag}>`; + }; const crawlId = (crawl.id || 'unknown').toString(); const crawlShortId = crawlId === 'unknown' ? 'unknown' : crawlId.slice(-8); const startedDate = crawl.started ? crawl.started.slice(0, 10) : 'unknown date'; @@ -927,6 +1000,7 @@ ? `` : ''; const crawlPidHtml = crawl.worker_pid ? `pid ${crawl.worker_pid}` : ''; + const crawlDurationHtml = renderDurationBadge(crawl.started); let snapshotsHtml = ''; if (crawl.active_snapshots && crawl.active_snapshots.length > 0) { @@ -1005,12 +1079,12 @@ const crawlSizeLimitText = `${crawl.crawl_output_size_display || '0 B'} / ${crawl.max_crawl_size_display || 'unlimited'}`; const snapshotSizeLimitText = `${crawl.avg_snapshot_size_display || '0 B'} / ${crawl.max_snapshot_size_display || 'unlimited'}`; const crawlBadges = [ - `persona${escapeHtml(crawl.persona || 'Default')}`, - `depth${crawl.max_depth || 0}`, - `urls${escapeHtml(urlLimitText)}`, - `crawl size${escapeHtml(crawlSizeLimitText)}`, - `avg snap${escapeHtml(snapshotSizeLimitText)}`, - ...(crawl.tags || []).map(tag => `#${escapeHtml(tag)}`), + crawlBadge('persona', 'Persona Config', crawl.persona || 'Default', crawl.persona_admin_url, 'Edit persona config'), + crawlBadge('limit', 'depth', crawl.max_depth || 0, null, ''), + crawlBadge('limit', 'urls', urlLimitText, adminFieldUrl('max_urls'), 'Edit max URLs'), + crawlBadge('size', 'crawl size', crawlSizeLimitText, adminFieldUrl('crawl_max_size'), 'Edit max crawl size'), + crawlBadge('size', 'avg snap', snapshotSizeLimitText, adminFieldUrl('snapshot_max_size'), 'Edit max snapshot size'), + ...(crawl.tags || []).map(tag => `#${escapeHtml(tag)}`), ].join(''); const statsHtml = [ `done${crawl.completed_snapshots || 0}`, @@ -1022,17 +1096,18 @@ return `