diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0264318..5a2a05a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,8 @@ "Bash(xargs ls:*)", "Bash(npm install:*)", "Bash(node -e \"require\\(''''c:/CPANEL/PANEL_BASES_ANEXO24/node_modules/exceljs''''\\)\")", - "Bash(docker-compose up:*)" + "Bash(docker-compose up:*)", + "Bash(npx svelte-check *)" ] } } diff --git a/.env.example b/.env.example index 74c1405..c4933c4 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,11 @@ DB_POSTGRES_DB=CONTROLDESK # JWT Secret (cambiar en producción) JWT_SECRET=change-this-secret-in-production-please-use-a-long-random-string -# Ruta de backups +# Ruta de respaldos (LEGACY / fallback): carpeta única leída por la vista "Respaldos +# Almacenados" y por /backup?file=... cuando no se especifica restaurador. Las vistas nuevas +# "Respaldos Restaurados" y "Restores Fallidos" resuelven la carpeta de CADA restaurador de +# forma relativa (derivada de la Entrada que reporta cada uno), sin usar esta variable. +# Apúntala a una carpeta de procesados accesible por filesystem (local o share) si usas el modo legacy. BACKUP_PATH=D:/BackupSFTP/ # SMTP para envío de avisos de alertas críticas diff --git a/database/schema.sql b/database/schema.sql index bc5c6c3..241fd90 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -85,6 +85,13 @@ CREATE TABLE IF NOT EXISTS a24c.restore_targets ( ssh_password_encrypted TEXT, -- credencial SSH cifrada (AES-256-GCM) remote_inbox_path VARCHAR(500), -- ruta en el server donde se sube el .bak y se restaura (C:\RestoreInbox) notes TEXT, + -- Características de hardware (opcionales, capturadas a mano). Se usan para distribuir bases + -- por capacidad; el disco es la capacidad que manda. NULL = sin capturar. + os VARCHAR(50), -- Sistema operativo (Linux/Windows/…) + ram_gb INTEGER, -- RAM en GB + disk_gb INTEGER, -- Disco en GB + location VARCHAR(255), -- Ubicación (ej: Kansas City, United States) + size_category VARCHAR(20), -- Etiqueta: Chico | Mediano | Grande created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); @@ -117,6 +124,9 @@ CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_restored_at ON a24c.restore_job_logs (restored_at); CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_status ON a24c.restore_job_logs (status); +-- Bitácora por servidor en el panel: últimas N restauraciones de un target. +CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_target_date + ON a24c.restore_job_logs (restore_target_id, restored_at DESC); COMMENT ON TABLE a24c.restore_targets IS 'Servidores SQL Server destino de restauración (CloudRestoreAS). Contraseña cifrada AES-256-GCM.'; COMMENT ON TABLE a24c.restore_job_logs IS 'Bitácora de restauraciones reportadas por CloudRestoreAS.'; diff --git a/package-lock.json b/package-lock.json index e77ac31..0c4f975 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "jsonwebtoken": "^9.0.3", "mssql": "^10.0.2", "nodemailer": "^8.0.9", - "pg": "^8.18.0" + "pg": "^8.18.0", + "ssh2-sftp-client": "^12.1.1" }, "devDependencies": { "@sveltejs/adapter-auto": "^3.3.1", @@ -29,6 +30,7 @@ "@types/node": "^22.0.0", "@types/nodemailer": "^8.0.0", "@types/pg": "^8.16.0", + "@types/ssh2-sftp-client": "^9.0.6", "autoprefixer": "^10.4.24", "postcss": "^8.5.6", "svelte": "^5.0.0-next.1", @@ -1604,6 +1606,43 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2-sftp-client": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/ssh2-sftp-client/-/ssh2-sftp-client-9.0.6.tgz", + "integrity": "sha512-4+KvXO/V77y9VjI2op2T8+RCGI/GXQAwR0q5Qkj/EJ5YSeyKszqZP6F8i3H3txYoBqjc7sgorqyvBP3+w1EHyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ssh2": "^1.0.0" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typespec/ts-http-runtime": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.3.tgz", @@ -1967,6 +2006,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2104,6 +2152,15 @@ "node": ">= 18" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -2324,6 +2381,12 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, "node_modules/buffer-indexof-polyfill": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", @@ -2341,6 +2404,15 @@ "node": ">=0.2.0" } }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -2588,6 +2660,35 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -2631,6 +2732,20 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -5114,6 +5229,13 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -6434,6 +6556,40 @@ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "license": "BSD-3-Clause" }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/ssh2-sftp-client": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/ssh2-sftp-client/-/ssh2-sftp-client-12.1.1.tgz", + "integrity": "sha512-wYVDgwkpcKG2iPGQQ+QR33xkWqLFIaVrYvA+uON4pmxTPaPuB81f1aooUEPN75e/9DCK6rrKYXb6zR6zP3+EtA==", + "license": "Apache-2.0", + "dependencies": { + "concat-stream": "^2.0.0", + "ssh2": "^1.16.0" + }, + "engines": { + "node": ">=18.20.4" + }, + "funding": { + "type": "individual", + "url": "https://square.link/u/4g7sPflL" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -7040,6 +7196,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -7127,6 +7289,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 8f44491..d1eb68e 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@types/node": "^22.0.0", "@types/nodemailer": "^8.0.0", "@types/pg": "^8.16.0", + "@types/ssh2-sftp-client": "^9.0.6", "autoprefixer": "^10.4.24", "postcss": "^8.5.6", "svelte": "^5.0.0-next.1", @@ -41,7 +42,8 @@ "jsonwebtoken": "^9.0.3", "mssql": "^10.0.2", "nodemailer": "^8.0.9", - "pg": "^8.18.0" + "pg": "^8.18.0", + "ssh2-sftp-client": "^12.1.1" }, "type": "module" } diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte new file mode 100644 index 0000000..10f21ef --- /dev/null +++ b/src/lib/components/AppShell.svelte @@ -0,0 +1,243 @@ + + +{#snippet navLink(item: NavItem, isCollapsed: boolean)} + + + {#if !isCollapsed}{item.label}{/if} + +{/snippet} + +{#snippet sidebarInner(isCollapsed: boolean, isMobile: boolean)} +
Aduanasoft
+Bases de datos
+{displayName}
+ {#if currentUser?.email} +{currentUser.email}
+ {/if} +| - - |
Estimado ${nombre},
+Detectamos que la base de datos ${bd} no se ha sincronizado correctamente en las últimas 24 horas.
+La última restauración registrada fue: ${fecha}.
+ ${SCAIIWEB_H4} +Por favor, recuerde nunca cerrar la aplicación ni apagar su equipo. Revise la conexión e intente realizar una sincronización manual desde el botón Backup manual, o contacte al soporte técnico si es necesario.
`; + return renderSyncEmail(inner); +} + +/** Correo de "sincronización restablecida" (kind=resolved): misma plantilla legacy, mensaje positivo. */ +export function buildBackupResolvedHtml(alerts: BackupAlertRow[]): string { + const a = alerts[0]; + const nombre = escHtml(a?.clientName ?? a?.visible_name ?? ''); + const bd = escHtml(a?.visible_name ?? ''); + const fecha = escHtml(formatFechaEs(a?.last_restore_date)); + const inner = ` +Estimado ${nombre},
+La base de datos ${bd} volvió a sincronizarse correctamente.
+La última restauración registrada fue: ${fecha}.
+ ${SCAIIWEB_H4} +No se requiere ninguna acción de su parte. Gracias por mantener su equipo y la aplicación en funcionamiento.
`; + return renderSyncEmail(inner); +} + export interface BackupAlertRow { visible_name: string; clientName: string | null; diff --git a/src/lib/server/restore-fetch.ts b/src/lib/server/restore-fetch.ts new file mode 100644 index 0000000..a95a3d3 --- /dev/null +++ b/src/lib/server/restore-fetch.ts @@ -0,0 +1,103 @@ +/** + * Descarga de respaldos por restaurador, con ruta RELATIVA y SIN rutas fijas. + * + * La base de cada restaurador se DERIVA al vuelo de su Entrada reportada + * (`cloudrestore_status.input_folder`): hermana `Procesados`/`Fallados` (layout por defecto del + * install). El cliente solo envía `target` + `kind` + `relPath`; el servidor arma `base + relPath` + * y lo sirve leyendo la carpeta por filesystem (local o montada como share). CloudRestoreAS no + * reporta ni configura nada nuevo. + */ +import fs from 'node:fs/promises'; +import { createReadStream } from 'node:fs'; +import { Readable } from 'node:stream'; +import { deriveSiblingFolder } from './backup-files'; +import { getRestoreTargetForDownload, type RestoreTargetDownload } from './controldesk-pg'; + +export type BackupKind = 'procesados' | 'fallados'; + +export class BackupDownloadError extends Error { + constructor( + public status: number, + message: string + ) { + super(message); + this.name = 'BackupDownloadError'; + } +} + +function baseSeparator(p: string): '\\' | '/' { + return p.includes('\\') ? '\\' : '/'; +} + +/** Carpeta base (procesados/fallados) del restaurador, SIEMPRE derivada de su Entrada. */ +function resolveBaseFolder(t: RestoreTargetDownload, kind: BackupKind): string | null { + const input = t.input_folder; + if (!input) return null; + return deriveSiblingFolder(input, kind === 'procesados' ? 'Procesados' : 'Fallados'); +} + +/** Valida la ruta relativa: sin '..', sin absoluto, sin componentes vacíos. */ +export function sanitizeRelPath(relPath: string): string | null { + const rel = String(relPath ?? '') + .replace(/\\/g, '/') + .trim(); + if (!rel) return null; + if (rel.startsWith('/') || /^[a-zA-Z]:/.test(rel)) return null; // absoluto + const parts = rel.split('/'); + if (parts.some((p) => p === '' || p === '.' || p === '..')) return null; + return parts.join('/'); +} + +function localJoin(base: string, rel: string): string { + const sep = baseSeparator(base); + const b = base.replace(/[\\/]+$/, ''); + const relNative = rel.split('/').join(sep); + return `${b}${sep}${relNative}`; +} + +export interface BackupDownload { + body: ReadableStream+ Último respaldo restaurado por nodo, por servidor de restauración. Se + conserva aunque el nodo se reasigne a otro restaurador. +
+| Servidor | +Nodo / Cliente | +Archivo | +Fecha | +Tamaño | +Acciones | +
|---|---|---|---|---|---|
| {r.server_name ?? '—'} | ++ {r.node_key ?? r.db_name ?? '—'} + {#if r.client_name}{r.client_name}{/if} + | +{r.filename} | +{new Date(r.restored_at).toLocaleString()} | +{formatMb(r.size_bytes)} | ++ {#if r.restore_target_id && r.rel_path} + Descargar + {:else} + — + {/if} + | +
| + No hay respaldos restaurados registrados. + | +|||||
+ Restauraciones que fallaron, por servidor. Puedes descargar el respaldo + para diagnóstico. +
+| Servidor | +Base / Archivo | +Fecha | +Error | +Acciones | +
|---|---|---|---|---|
| {f.server_name ?? '—'} | ++ {f.db_name ?? '—'} + {f.filename} + | +{new Date(f.restored_at).toLocaleString()} | +{f.error_message ?? '—'} | ++ {#if f.restore_target_id && f.rel_path} + Descargar + {:else} + — + {/if} + | +
| + No hay restores fallidos registrados. + | +||||
- Configura IP, credenciales y rutas de cada servidor. La asignación de cada base a - un servidor se hace en Gestión de Bases de Datos (un radio por base). + Configura IP, credenciales, rutas y capacidad de cada servidor, y asigna qué bases + restaura cada uno (checklist o distribución automática por capacidad).
- Cada servidor de restauración puede tener su propia instalación de CloudRestoreAS. - La carpeta de entrada se configura en cada equipo; el panel solo la muestra. -
+Crea un servidor con + Nuevo servidor.
{:else} -+ Sin capacidad — captura Disco (GB) para distribuir +
{/if} ++ Reparte las bases entre los servidores de forma balanceada y proporcional a su disco. + Revisa la propuesta antes de aplicar. +
+| Incluir | +Base / Nodo | +Servidor | +Tamaño | +Categoría | +Cambio | +
|---|---|---|---|---|---|
| + toggleExcluded(r.node.ID)} + class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" /> + | ++ {r.node.BDName || r.node.NodoSubNodo} + · {r.node.NodoSubNodo} + | +{r.serverName} | +{formatSize(r.sizeMb)} | +{r.categoria} | ++ {#if kind === 'nueva'} + Nueva + {:else if kind === 'reasignar'} + + Reasignar + + {:else} + Sin cambio + {/if} + | +
| Nombre | Servidor SQL | -Usuario SQL | -Host SSH | +Disco | +Bases asignadas | Acciones |
|---|---|---|---|---|---|---|
| {t.name} @@ -199,28 +1064,35 @@ sin configurar {/if} + {#if !hasCapacity(t)} + + sin capacidad + + {/if} | {t.server_ip ?? '—'} | -{t.sql_username ?? '—'} | -- {t.ssh_host ? `${t.ssh_host}:${t.ssh_port ?? 22}` : '—'} - | +{t.disk_gb != null ? `${t.disk_gb} GB` : '—'} | +{assignedCount} |
|