diff --git a/.github/workflows/frontend-linting.yml b/.github/workflows/frontend-linting.yml index 042d11d0..c24b80f5 100644 --- a/.github/workflows/frontend-linting.yml +++ b/.github/workflows/frontend-linting.yml @@ -24,3 +24,6 @@ jobs: - name: Run ESLint run: npm run lint -- --max-warnings=0 + + - name: Check i18n key parity and placeholders + run: npm run i18n:check diff --git a/.gitignore b/.gitignore index 6d6c1325..5d2b7147 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,9 @@ yarn-error.log* .env /public/env-config.js + +# TypeScript build info +tsconfig.tsbuildinfo + +# Kilo local config +.kilo/ diff --git a/.vscode/settings.json b/.vscode/settings.json index 04d54381..3308282c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,12 +9,12 @@ "eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"], "typescript.tsdk": "node_modules/typescript/lib", "files.watcherExclude": { - "**/.git/objects/**": true, - "**/.git/subtree-cache/**": true, - "**/node_modules/": true, - "/node_modules/**": true, - "**/env/": true, - "/env/**": true + "**/.git/objects/**": true, + "**/.git/subtree-cache/**": true, + "**/node_modules/": true, + "/node_modules/**": true, + "**/env/": true, + "/env/**": true }, "prettier.prettierPath": "./node_modules/prettier" } diff --git a/docs/i18n/CONTRIBUTING.i18n.md b/docs/i18n/CONTRIBUTING.i18n.md new file mode 100644 index 00000000..b4cd23da --- /dev/null +++ b/docs/i18n/CONTRIBUTING.i18n.md @@ -0,0 +1,278 @@ +# Contributing Translations (i18n) + +## Golden Rule + +**Every new UI string must be added through i18n.** No hardcoded strings in templates, components, or composables. + +--- + +## Adding a New Translation String + +### 1. Replace the hardcoded string with an i18n key + +In a Vue template: + +```vue + + + + + +``` + +In a script / composable: + +```ts +// bad +Notify.create({ message: "Agent deleted" }); + +// good — via useI18n +import { useI18n } from "vue-i18n"; +const { t } = useI18n(); +Notify.create({ message: t("agents.actions.deleted") }); +``` + +In boot files (no `setup` context): + +```ts +import { i18n } from "@/i18n"; +Notify.create({ message: i18n.global.t("common.saved") }); +``` + +### 2. Add the key to the source locale (`en`) + +`en` is the source of truth. Add the key there first: + +```json +// src/i18n/en/agents.json +{ + "actions": { + "delete": "Delete Agent", + "deleted": "Agent deleted successfully" + } +} +``` + +Then add translations in any locale that you are working on (for example `ru`). +If a key is missing in a locale, fallback to `en` is used. + +### 3. Run the integrity check + +```bash +npm run i18n:check +``` + +This reports: + +- **Key parity** — missing/extra keys in each locale vs `en`. +- **Placeholder consistency** — `{var}`, `%s`, `%d` etc. match for shared keys. + +### 4. Commit + +Follow the conventional commit format: + +``` +i18n: localize for and align key parity +``` + +--- + +## Key Naming Conventions + +| Pattern | Example | +| ------------------------------- | --------------------- | +| `module.section.action` | `agents.tabs.summary` | +| Verbs for actions | `agents.actions.run` | +| Nouns for titles | `agents.metaTitle` | +| `shared.*` for reusable strings | `shared.areYouSure` | + +## Locale Namespaces (Domains) + +| File | Area | +| ----------------- | ------------------------------------------------- | +| `agents.json` | Agents, remote background, file browser, services | +| `alerts.json` | Alerts, alert templates | +| `auth.json` | Authentication, SSO | +| `checks.json` | Monitoring checks | +| `common.json` | Shared strings, buttons, statuses | +| `dashboard.json` | Dashboard | +| `navigation.json` | Navigation menu, routes | +| `reporting.json` | Reports (EE) | +| `scripts.json` | Scripts, snippets | +| `settings.json` | Server and core settings | +| `software.json` | Software install/uninstall | +| `tasks.json` | Automated tasks | + +--- + +## Placeholders + +Preserve placeholders **exactly as they appear** in the English source: + +| Type | Examples | +| -------------- | -------------------------- | +| ICU / vue-i18n | `{name}`, `{count}`, `{n}` | +| printf | `%s`, `%d`, `%f` | + +```json +// en +"greeting": "Hello, {name}! You have %d messages." + +// target locale (example: ru) — same placeholders, translated text +"greeting": "Привет, {name}! У вас %d сообщений." +``` + +--- + +## Pluralization + +Use `vue-i18n` pipe syntax for plural forms: + +```json +{ + "agents": { + "selected": "No agents selected | {count} agent selected | {count} agents selected" + } +} +``` + +For locales with complex plural rules (example: Russian), use full form sets (0, 1, 2-4, 5+): + +```json +{ + "agents": { + "selected": "Нет выбранных агентов | Выбран {count} агент | Выбрано {count} агента | Выбрано {count} агентов" + } +} +``` + +--- + +## Translation Rules + +### Must + +- Translate the **meaning**, not the literal word form. +- Keep functional accuracy — translation must not change the action or status meaning. +- Keep strings short and clear for an admin interface. +- Preserve all placeholders, HTML/Markdown, and special tokens. +- Use neutral business tone, avoid colloquialisms. + +### Never + +- Translate technical identifiers: `agent_id`, `site_id`, `SMTP`, `SSH`, `RDP`, `URL`, `DNS`. +- Translate product names, brands, URLs, API endpoints, variable names, JSON keys, terminal commands. +- Change logic through translation (e.g. `Disable` → `Включить`). +- Lose negation (`not`, `failed`, `denied`). +- Split or merge strings if it breaks interpolation. +- Add new information not present in the source string. + +### Do Not Translate + +- `Tactical RMM`, product names, brands. +- Protocol names: `SSH`, `RDP`, `SMTP`, `DNS`, `HTTP`, `WebSocket`. +- Technical IDs: `agent_id`, `site_id`, `client_id`. +- File paths, URLs, API endpoints. +- Code blocks, commands, JSON keys. + +--- + +## Glossary (Example: RU) + +Use consistent terminology across all modules. + +| English Term | Russian Translation | Note | +| ------------ | ------------------- | ----------------------------------- | +| Agent | Агент | Not "клиент" | +| Client | Клиент | Organization/customer | +| Site | Сайт | Client location | +| Check | Проверка | Monitoring check | +| Alert | Оповещение | Not "тревога" | +| Policy | Политика | Set of rules | +| Task | Задача | Scheduled or manual action | +| Script | Скрипт | Not "сценарий" in UI | +| Dashboard | Панель мониторинга | Short: "Панель" if space is limited | +| Service | Служба | Windows service | +| Event Log | Журнал событий | | +| Run | Запустить | For action buttons | +| Retry | Повторить | | +| Success | Успешно | Execution status | +| Failed | Ошибка | For status/notification | +| Pending | В ожидании | | + +--- + +## Quality Checklist Before Merge + +1. [ ] New key exists in `en`. +2. [ ] If locale key exists, placeholders/tokens match `en`. +3. [ ] No accidental edits/removals in `en`. +4. [ ] Terms follow the glossary above. +5. [ ] UI is visually readable for the target locale (no text overflow in buttons, dialogs, tooltips). +6. [ ] `npm run i18n:check` passes. +7. [ ] `npm run lint` and `npm run build` pass. + +--- + +## CI Checks + +Every push / PR to `develop` automatically runs: + +1. **Lint + Build** — `frontend-linting.yml` +2. **i18n parity + placeholders (informational)** — `npm run i18n:check` + +`i18n:check` is non-blocking by design (coverage/progress signal). + +--- + +## Available npm Scripts + +| Script | Purpose | +| --------------------------- | -------------------------------------------------- | +| `npm run i18n:check` | Run all i18n integrity checks | +| `npm run i18n:parity` | Check key parity between `en` and non-`en` locales | +| `npm run i18n:placeholders` | Check placeholder consistency | + +--- + +## What If a String Seems "Technical"? + +When in doubt — leave it in English and add a comment in the PR explaining why. + +--- + +## Adding a New Language + +Locales are discovered automatically from `src/i18n//index.ts`. +Each locale controls what it imports — **partial translations are fully supported**. +Missing domains fall back to `en` automatically via `fallbackLocale`. + +### Step 1: Create the locale directory and JSON domains + +``` +src/i18n/de/common.json +``` + +You can add only the domains you have translated. + +### Step 2: Register locale in app runtime + +Edit `src/i18n/index.ts` and add locale to: + +- `supportedLocales` +- `localeAliases` +- `messages` +- `quasarLanguagePacks` + +### Step 3: Add translation files + +``` +src/i18n/de/auth.json +src/i18n/de/common.json +``` + +### That's it + +- Add language label to the locale picker options (if shown in UI). +- `npm run i18n:check` will report coverage for the new locale. +- Missing keys automatically fall back to `en`. diff --git a/index.html b/index.html index 65075f75..dc0b9229 100644 --- a/index.html +++ b/index.html @@ -1,4 +1,4 @@ - + <%= productName %> diff --git a/package-lock.json b/package-lock.json index 89397361..ea66368d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "qrcode": "1.5.4", "quasar": "2.18.5", "vue": "3.5.22", + "vue-i18n": "11.3.2", "vue-router": "4.5.1", "vue3-apexcharts": "1.7.0", "vuedraggable": "4.1.0", @@ -40,6 +41,7 @@ "eslint": "8.57.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-vue": "8.7.1", + "postcss-nested": "^6.2.0", "prettier": "3.3.3", "typescript": "5.9.2" } @@ -307,6 +309,79 @@ } } }, + "node_modules/@intlify/core-base": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.3.2.tgz", + "integrity": "sha512-cgsUaV/dyD6aS49UPgerIblrWeXAZHNaDWqm4LujOGC7IafSyhghGXEiSVvuDYaDPiQTP+tSFSTM1HIu7Yp1nA==", + "license": "MIT", + "dependencies": { + "@intlify/devtools-types": "11.3.2", + "@intlify/message-compiler": "11.3.2", + "@intlify/shared": "11.3.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/core-base/node_modules/@intlify/message-compiler": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.3.2.tgz", + "integrity": "sha512-d/awyHUkNSaGPxBxT/qlUpfRizxHX9dt55CnW03xx5p1KmMyfYHKupCnvzINX+Na8JR8LAR7y32lPKjoeQGmzA==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "11.3.2", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/core-base/node_modules/@intlify/shared": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.3.2.tgz", + "integrity": "sha512-x66fjdH6i+lNYPae5URSQGTjBL68Av6hi09jvC5Ci96iTkwfqrPhCj46aylQZmgMaG89rOZCIKqS7ApC8ZDVjg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/devtools-types": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.3.2.tgz", + "integrity": "sha512-q96G2ZZw0FNoXzejbjIf9dbfgz1xyYBZu6ZT4b5TE/55j8d1O9X5jv0k+U+L3fVe7uebPcqRQFD0ffm30i5mJA==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "11.3.2", + "@intlify/shared": "11.3.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/devtools-types/node_modules/@intlify/shared": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.3.2.tgz", + "integrity": "sha512-x66fjdH6i+lNYPae5URSQGTjBL68Av6hi09jvC5Ci96iTkwfqrPhCj46aylQZmgMaG89rOZCIKqS7ApC8ZDVjg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, "node_modules/@intlify/message-compiler": { "version": "9.14.5", "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.5.tgz", @@ -1294,7 +1369,6 @@ "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.19.2" } @@ -1409,7 +1483,6 @@ "integrity": "sha512-ar9E+k7CU8rWi2e5ErzQiC93KKEFAXA2Kky0scAlPcxYblLt8+XZuHUZwlyfXILyQa95P6lQg+eZgh/dDs3+Vw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.16.0", "@typescript-eslint/types": "7.16.0", @@ -1576,7 +1649,6 @@ "integrity": "sha512-IfFNbtkbIm36O9KB8QodlwwYvTEsJb4Lll4c2IwB3VHc2gie2mSPtSzL0eYay7X2jd/2WX02FjSGTWR6OPr/zg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12.0.0" }, @@ -1996,7 +2068,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2126,7 +2197,6 @@ "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.54.1.tgz", "integrity": "sha512-E4et0h/J1U3r3EwS/WlqJCQIbepKbp6wGUmaAwJOMjHUP4Ci0gxanLa7FR3okx6p9coi4st6J853/Cb1NP0vpA==", "license": "MIT", - "peer": true, "dependencies": { "@yr/monotone-cubic-spline": "^1.0.3", "svg.draggable.js": "^2.2.2", @@ -2301,7 +2371,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", @@ -2623,7 +2692,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4148,7 +4216,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6863,7 +6930,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6873,6 +6939,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, "node_modules/postcss-selector-parser": { "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", @@ -6992,7 +7084,6 @@ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", "license": "MIT", - "peer": true, "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", @@ -7026,7 +7117,6 @@ "resolved": "https://registry.npmjs.org/quasar/-/quasar-2.18.5.tgz", "integrity": "sha512-5ItDSsNjqBVRrC7SqcdvT1F5mghVyJ/KmaWNwnaT5mM91a7gWpT/d7wTCIFxxDbWLZdkHKI+cpdudEqnfcSw9A==", "license": "MIT", - "peer": true, "engines": { "node": ">= 10.18.1", "npm": ">= 6.13.4", @@ -7384,7 +7474,6 @@ "integrity": "sha512-/qxNTG7FbmefJWoeeYJFbHehJ2HNWnjkAFRKzWN/45eNBBF/r8lo992CwcJXEzyVxs5FmfId+vTSTQDb+bxA+g==", "dev": true, "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -8027,8 +8116,7 @@ "version": "1.14.0", "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz", "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/source-map": { "version": "0.6.1", @@ -8469,7 +8557,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8668,7 +8755,6 @@ "integrity": "sha512-sAOqI5wNM9QvSEE70W3UGMdT8cyEn0+PmJMTFvTB8wB0YbYUWw3gUbY62AOyrXosGieF2htmeLATvNxpv/zNyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.14.27", "postcss": "^8.4.13", @@ -8706,7 +8792,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz", "integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.22", "@vue/compiler-sfc": "3.5.22", @@ -8748,12 +8833,44 @@ "eslint": ">=6.0.0" } }, + "node_modules/vue-i18n": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.2.tgz", + "integrity": "sha512-gmFrvM+iuf2AH4ygligw/pC7PRJ63AdRNE68E0GPlQ83Mzfyck6g6cRQC3KzkYXr+ZidR91wq+5YBmAMpkgE1A==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "11.3.2", + "@intlify/devtools-types": "11.3.2", + "@intlify/shared": "11.3.2", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vue-i18n/node_modules/@intlify/shared": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.3.2.tgz", + "integrity": "sha512-x66fjdH6i+lNYPae5URSQGTjBL68Av6hi09jvC5Ci96iTkwfqrPhCj46aylQZmgMaG89rOZCIKqS7ApC8ZDVjg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, "node_modules/vue-router": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz", "integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/devtools-api": "^6.6.4" }, @@ -8859,7 +8976,6 @@ "resolved": "https://registry.npmjs.org/vuex/-/vuex-4.1.0.tgz", "integrity": "sha512-hmV6UerDrPcgbSy9ORAtNXDr9M4wlNP4pEFKye4ujJF8oqgFFuxDCdOLS3eNoRTtq5O3hoBDh9Doj1bQMYHRbQ==", "license": "MIT", - "peer": true, "dependencies": { "@vue/devtools-api": "^6.0.0-beta.11" }, diff --git a/package.json b/package.json index 53788ebe..ade09964 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,10 @@ "serve": "quasar dev", "build": "quasar build", "lint": "eslint --ext .js,.ts,.vue ./", - "format": "prettier --write \"**/*.{js,ts,vue,,html,md,json}\" --ignore-path .gitignore" + "format": "prettier --write \"**/*.{js,ts,vue,,html,md,json}\" --ignore-path .gitignore", + "i18n:check": "node scripts/i18n-check.mjs", + "i18n:parity": "node scripts/i18n-check.mjs --parity", + "i18n:placeholders": "node scripts/i18n-check.mjs --placeholders" }, "dependencies": { "@quasar/extras": "1.16.17", @@ -25,6 +28,7 @@ "qrcode": "1.5.4", "quasar": "2.18.5", "vue": "3.5.22", + "vue-i18n": "11.3.2", "vue-router": "4.5.1", "vue3-apexcharts": "1.7.0", "vuedraggable": "4.1.0", @@ -42,7 +46,8 @@ "eslint": "8.57.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-vue": "8.7.1", + "postcss-nested": "^6.2.0", "prettier": "3.3.3", "typescript": "5.9.2" } -} \ No newline at end of file +} diff --git a/quasar.config.js b/quasar.config.js index af5aa71b..c918b497 100644 --- a/quasar.config.js +++ b/quasar.config.js @@ -11,6 +11,7 @@ const { mergeConfig } = require("vite"); const { configure } = require("quasar/wrappers"); const path = require("path"); +const postcssNested = require("postcss-nested"); require("dotenv").config(); module.exports = configure(function (/* ctx */) { @@ -30,7 +31,7 @@ module.exports = configure(function (/* ctx */) { // app boot file (/src/boot) // --> boot files are part of "main.js" // https://v2.quasar.dev/quasar-cli-vite/boot-files - boot: ["pinia", "axios", "monaco", "integrations"], + boot: ["pinia", "axios", "i18n", "monaco", "integrations"], // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css css: ["app.sass"], @@ -82,8 +83,14 @@ module.exports = configure(function (/* ctx */) { /* eslint-disable quotes */ // eslint-disable-next-line @typescript-eslint/no-unused-vars extendViteConf(viteConf, { isServer, isClient }) { + viteConf.css = mergeConfig(viteConf.css || {}, { + postcss: { + plugins: [postcssNested()], + }, + }); + viteConf.build = mergeConfig(viteConf.build, { - chunkSizeWarningLimit: 1600, + chunkSizeWarningLimit: 4000, rollupOptions: { output: { entryFileNames: `[hash].js`, diff --git a/scripts/i18n-check.mjs b/scripts/i18n-check.mjs new file mode 100644 index 00000000..75ed51fe --- /dev/null +++ b/scripts/i18n-check.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node +/** + * i18n integrity checker. + * + * Uses `en` as the source of truth. Checks every other locale against it. + * + * Checks: + * 1. Key parity – missing keys compared to `en`. + * 2. Placeholder consistency – {var}, %s, %d etc. must match for shared keys. + * + * This script NEVER blocks a merge. It reports coverage and issues + * so the team can track translation progress incrementally. + * + * Usage: + * node scripts/i18n-check.mjs [--parity] [--placeholders] + * + * Defaults to running both checks if no flags given. + */ + +import { readFileSync, readdirSync, statSync } from "fs"; +import { join, resolve } from "path"; + +// ── helpers ────────────────────────────────────────────────────────── + +const I18N_DIR = resolve("src/i18n"); +const EN = "en"; + +function detectLocales() { + return readdirSync(I18N_DIR) + .filter((d) => { + const full = join(I18N_DIR, d); + return statSync(full).isDirectory() && + readdirSync(full).some((f) => f.endsWith(".json")); + }); +} + +function loadJSONs(locale) { + const dir = join(I18N_DIR, locale); + const result = {}; + for (const file of readdirSync(dir)) { + if (!file.endsWith(".json")) continue; + const raw = readFileSync(join(dir, file), "utf-8"); + result[file] = JSON.parse(raw); + } + return result; +} + +/** Flatten nested object to dot-path keys */ +function flatten(obj, prefix = "") { + const out = {}; + for (const [key, val] of Object.entries(obj)) { + const full = prefix ? `${prefix}.${key}` : key; + if (typeof val === "object" && val !== null && !Array.isArray(val)) { + Object.assign(out, flatten(val, full)); + } else { + out[full] = String(val ?? ""); + } + } + return out; +} + +/** Extract placeholders: {name}, %s, %d, {0}, etc. */ +function extractPlaceholders(str) { + const tokens = new Set(); + for (const m of str.matchAll(/\{[^}]+\}/g)) tokens.add(m[0]); + for (const m of str.matchAll(/%[sdflxXcbo]/g)) tokens.add(m[0]); + return tokens; +} + +// ── checks ─────────────────────────────────────────────────────────── + +let totalIssues = 0; + +function checkLocale(localeName, localeData, en, runParity, runPH) { + let localeIssues = 0; + const domains = new Set([...Object.keys(en), ...Object.keys(localeData)]); + + // --- parity --- + if (runParity) { + for (const domain of [...domains].sort()) { + const enFlat = en[domain] ? flatten(en[domain]) : {}; + const locFlat = localeData[domain] ? flatten(localeData[domain]) : {}; + + const enKeys = new Set(Object.keys(enFlat)); + const locKeys = new Set(Object.keys(locFlat)); + + const missing = [...enKeys].filter((k) => !locKeys.has(k)); + const extra = [...locKeys].filter((k) => !enKeys.has(k)); + + if (missing.length || extra.length) { + console.log(` ⚠️ ${localeName}/${domain}:`); + if (missing.length) + console.log(` missing keys: ${missing.length}`); + if (extra.length) + console.log(` extra keys: ${extra.length}`); + localeIssues += missing.length + extra.length; + } + } + } + + // --- placeholders --- + if (runPH) { + for (const domain of Object.keys(en)) { + const enFlat = en[domain] ? flatten(en[domain]) : {}; + const locFlat = localeData[domain] ? flatten(localeData[domain]) : {}; + + for (const [key, enVal] of Object.entries(enFlat)) { + const enPH = extractPlaceholders(enVal); + if (enPH.size === 0) continue; + + const locVal = locFlat[key]; + if (locVal === undefined) continue; + + const locPH = extractPlaceholders(locVal); + const missing = [...enPH].filter((p) => !locPH.has(p)); + const extra = [...locPH].filter((p) => !enPH.has(p)); + + if (missing.length || extra.length) { + console.log(` ⚠️ ${localeName}/${domain}.${key}:`); + if (missing.length) + console.log(` missing placeholders: ${missing.join(", ")}`); + if (extra.length) + console.log(` extra placeholders: ${extra.join(", ")}`); + localeIssues++; + } + } + } + } + + return localeIssues; +} + +function coverageReport(en, localeName, localeData) { + const enKeys = new Set(); + const locKeys = new Set(); + + for (const domain of Object.keys(en)) { + const enFlat = flatten(en[domain]); + const locFlat = localeData[domain] ? flatten(localeData[domain]) : {}; + for (const k of Object.keys(enFlat)) enKeys.add(k); + for (const k of Object.keys(locFlat)) { + if (enKeys.has(k)) locKeys.add(k); + } + } + + const total = enKeys.size; + const translated = locKeys.size; + const pct = total > 0 ? ((translated / total) * 100).toFixed(1) : 0; + console.log(` 📊 ${localeName}: ${translated}/${total} keys (${pct}%)`); +} + +// ── main ───────────────────────────────────────────────────────────── + +function main() { + const args = process.argv.slice(2); + const runParity = args.length === 0 || args.includes("--parity"); + const runPH = args.length === 0 || args.includes("--placeholders"); + + console.log("🔍 i18n integrity check\n"); + + const en = loadJSONs(EN); + const locales = detectLocales().filter((l) => l !== EN); + + if (locales.length === 0) { + console.log(" No non-English locales found. Nothing to check."); + process.exit(0); + return; + } + + for (const locale of locales.sort()) { + const locData = loadJSONs(locale); + + // Coverage summary + coverageReport(en, locale, locData); + + const issues = checkLocale(locale, locData, en, runParity, runPH); + totalIssues += issues; + + if (issues === 0) { + console.log(` ✅ ${locale}: fully in sync with en`); + } + + console.log(""); + } + + // Always exit 0 – this is informational, never blocks merge. + if (totalIssues > 0) { + console.log(`⚠️ ${totalIssues} i18n issue(s) found (non-blocking).`); + console.log(" Community needs more time to complete translations."); + } else { + console.log("✅ All locales fully in sync with en."); + } + process.exit(0); +} + +main(); diff --git a/src/api/agents.js b/src/api/agents.js index d8c2b662..928da2a9 100644 --- a/src/api/agents.js +++ b/src/api/agents.js @@ -1,6 +1,7 @@ import axios from "axios"; import { openURL } from "quasar"; import { router } from "@/router"; +import { i18n } from "@/i18n"; const baseUrl = "/agents"; @@ -333,7 +334,10 @@ export async function deleteRegistryValue(agent_id, path, name) { ); return data; } catch (e) { - console.error("Failed to delete value:", e); + console.error( + i18n.global.t("agents.registryManager.errors.deleteValue"), + e, + ); throw e; } } @@ -350,7 +354,10 @@ export async function renameRegistryValue(agentId, path, oldName, newName) { ); return data; } catch (e) { - console.error("Failed to rename value:", e); + console.error( + i18n.global.t("agents.registryManager.errors.renameValue"), + e, + ); throw e; } } @@ -374,7 +381,10 @@ export async function modifyRegistryValue( ); return data; } catch (e) { - console.error("Failed to modify registry value:", e); + console.error( + i18n.global.t("agents.registryManager.errors.modifyValue"), + e, + ); throw e; } } @@ -392,7 +402,10 @@ export async function createRegistryValue(agentId, path, name, type, data) { ); return data; } catch (e) { - console.error("Failed to create registry value:", e); + console.error( + i18n.global.t("agents.registryManager.errors.createValue"), + e, + ); throw e; } } diff --git a/src/api/services.js b/src/api/services.js index dcaf38fc..06fb95a7 100644 --- a/src/api/services.js +++ b/src/api/services.js @@ -27,7 +27,7 @@ export async function getAgentServiceDetails(agent_id, svcname, params = {}) { export async function editAgentServiceStartType(agent_id, svcname, payload) { const { data } = await axios.put( `${baseUrl}/${agent_id}/${svcname}/`, - payload + payload, ); return data; } @@ -35,7 +35,7 @@ export async function editAgentServiceStartType(agent_id, svcname, payload) { export async function sendAgentServiceAction(agent_id, svcname, payload) { const { data } = await axios.post( `${baseUrl}/${agent_id}/${svcname}/`, - payload + payload, ); return data; } diff --git a/src/boot/axios.js b/src/boot/axios.js index 05f55d52..292512e6 100644 --- a/src/boot/axios.js +++ b/src/boot/axios.js @@ -1,6 +1,7 @@ import axios from "axios"; import { useAuthStore } from "@/stores/auth"; import { Notify } from "quasar"; +import { i18n } from "@/i18n"; export const getBaseUrl = () => { if (process.env.NODE_ENV === "production") { @@ -46,9 +47,8 @@ export default function ({ app, router }) { if (error.code && error.code === "ERR_NETWORK") { Notify.create({ color: "negative", - message: "Backend is offline (network error)", - caption: - "Open your browser's dev tools and check the console tab for more detailed error messages", + message: i18n.global.t("notifications.network.backendOffline"), + caption: i18n.global.t("notifications.network.backendOfflineHelp"), timeout: 5000, }); return Promise.reject({ ...error }); diff --git a/src/boot/i18n.ts b/src/boot/i18n.ts new file mode 100644 index 00000000..1c033094 --- /dev/null +++ b/src/boot/i18n.ts @@ -0,0 +1,8 @@ +import { boot } from "quasar/wrappers"; + +import { i18n, resolveInitialLocale, setLocale } from "@/i18n"; + +export default boot(async ({ app }) => { + app.use(i18n); + await setLocale(resolveInitialLocale(), { persist: false }); +}); diff --git a/src/components/AdminManager.vue b/src/components/AdminManager.vue index e5b82a83..514d904f 100644 --- a/src/components/AdminManager.vue +++ b/src/components/AdminManager.vue @@ -9,17 +9,19 @@ flat push icon="refresh" - />User Administration + />{{ $t("settings.adminManager.title") }} - Close + {{ + $t("common.buttons.close") + }}
- Enable User + {{ + $t("settings.adminManager.enableUser") + }} @@ -55,7 +59,9 @@ @@ -77,7 +83,9 @@ - Edit + {{ + $t("common.buttons.edit") + }} - Delete + {{ + $t("common.buttons.delete") + }} @@ -103,7 +113,9 @@ - Reset Password + {{ + $t("settings.adminManager.resetPassword") + }} - Reset Two-Factor Auth + {{ + $t("settings.adminManager.resetTwoFactorAuth") + }} @@ -131,7 +145,9 @@ - Show Connected SSO Accounts + {{ + $t("settings.adminManager.showConnectedSsoAccounts") + }} - Show Active Sessions + {{ + $t("settings.adminManager.showActiveSessions") + }} - Close + {{ + $t("common.buttons.close") + }} @@ -167,7 +187,7 @@ v-if="props.row.social_accounts.length > 0" color="primary" dense - >SSO{{ $t("settings.adminManager.sso") }} {{ props.row.username }} @@ -176,7 +196,7 @@ {{ formatDate(props.row.last_login) }} - Never + {{ $t("agents.shared.never") }} {{ props.row.last_login_ip }} @@ -238,7 +258,7 @@ export default { columns: [ { name: "is_active", - label: "Active", + label: this.$t("settings.adminManager.columns.active"), field: "is_active", align: "left", }, @@ -251,35 +271,35 @@ export default { }, { name: "username", - label: "Username", + label: this.$t("settings.adminManager.columns.username"), field: "username", align: "left", sortable: true, }, { name: "name", - label: "Name", + label: this.$t("settings.common.name"), field: "name", align: "left", sortable: true, }, { name: "email", - label: "Email", + label: this.$t("settings.adminManager.columns.email"), field: "email", align: "left", sortable: true, }, { name: "last_login", - label: "Last Login", + label: this.$t("settings.adminManager.columns.lastLogin"), field: "last_login", align: "left", sortable: true, }, { name: "last_login_ip", - label: "Last Logon From", + label: this.$t("settings.adminManager.columns.lastLogonFrom"), field: "last_login_ip", align: "left", sortable: true, @@ -308,14 +328,20 @@ export default { deleteUser(user) { this.$q .dialog({ - title: `Delete user ${user.username}?`, + title: this.$t("settings.adminManager.deleteUserTitle", { + username: user.username, + }), cancel: true, - ok: { label: "Delete", color: "negative" }, + ok: { label: this.$t("common.buttons.delete"), color: "negative" }, }) .onOk(() => { this.$axios.delete(`/accounts/${user.id}/users/`).then(() => { this.getUsers(); - this.notifySuccess(`User ${user.username} was deleted!`); + this.notifySuccess( + this.$t("settings.adminManager.notify.userDeleted", { + username: user.username, + }), + ); }); }); }, @@ -345,8 +371,8 @@ export default { return; } let text = !user.is_active - ? "User enabled successfully" - : "User disabled successfully"; + ? this.$t("settings.adminManager.notify.userEnabled") + : this.$t("settings.adminManager.notify.userDisabled"); const data = { id: user.id, @@ -376,9 +402,11 @@ export default { this.$q .dialog({ - title: `Reset 2FA for ${user.username}?`, + title: this.$t("settings.adminManager.reset2faTitle", { + username: user.username, + }), cancel: true, - ok: { label: "Reset", color: "positive" }, + ok: { label: this.$t("common.actions.reset"), color: "positive" }, }) .onOk(() => { this.$axios diff --git a/src/components/AgentTable.vue b/src/components/AgentTable.vue index 8cc9d08a..0891db74 100644 --- a/src/components/AgentTable.vue +++ b/src/components/AgentTable.vue @@ -18,7 +18,7 @@ virtual-scroll v-model:pagination="pagination" :rows-per-page-options="[0]" - no-data-label="No Agents" + :no-data-label="$t('dashboard.agentTable.noAgents')" :loading="agentTableLoading" > @@ -52,35 +52,39 @@ @@ -107,8 +111,11 @@ dense > - Setting is overridden by alert template: - {{ props.row.alert_template.name }} + {{ + $t("dashboard.agentTable.settingOverriddenByTemplate", { + name: props.row.alert_template.name, + }) + }} @@ -134,8 +141,11 @@ dense > - Setting is overridden by alert template: - {{ props.row.alert_template.name }} + {{ + $t("dashboard.agentTable.settingOverriddenByTemplate", { + name: props.row.alert_template.name, + }) + }} @@ -161,8 +171,11 @@ dense > - Setting is overridden by alert template: - {{ props.row.alert_template.name }} + {{ + $t("dashboard.agentTable.settingOverriddenByTemplate", { + name: props.row.alert_template.name, + }) + }} @@ -189,7 +202,9 @@ size="sm" color="primary" > - Microsoft Windows + {{ + $t("dashboard.agentTable.platform.windows") + }} - Linux + {{ + $t("dashboard.agentTable.platform.linux") + }} - macOS + {{ + $t("dashboard.agentTable.platform.macos") + }} @@ -216,10 +235,14 @@ size="sm" color="primary" > - Server + {{ + $t("dashboard.agentTable.monitoringType.server") + }} - Workstation + {{ + $t("dashboard.agentTable.monitoringType.workstation") + }} @@ -230,7 +253,9 @@ size="1.2em" :color="dash_positive_color" > - Maintenance Mode Enabled + {{ + $t("dashboard.agentTable.status.maintenanceModeEnabled") + }} - Checks failing + {{ + $t("dashboard.agentTable.status.checksFailing") + }} - Checks warning + {{ + $t("dashboard.agentTable.status.checksWarning") + }} - Checks info + {{ + $t("dashboard.agentTable.status.checksInfo") + }} - Checks passing + {{ + $t("dashboard.agentTable.status.checksPassing") + }} @@ -287,7 +320,9 @@ size="1.5em" color="primary" > - Patches Pending + {{ + $t("dashboard.agentTable.patchesPending") + }} @@ -299,10 +334,13 @@ :color="dash_warning_color" class="cursor-pointer" > - Pending Action Count: - {{ props.row.pending_actions_count }} + + {{ + $t("dashboard.agentTable.pendingActionCount", { + count: props.row.pending_actions_count, + }) + }} + @@ -312,7 +350,9 @@ name="fas fa-power-off" color="primary" > - Reboot required + {{ + $t("dashboard.agentTable.status.rebootRequired") + }} @@ -322,7 +362,9 @@ size="1.2em" :color="dash_negative_color" > - Agent overdue + {{ + $t("dashboard.agentTable.status.agentOverdue") + }} - Agent offline + {{ + $t("dashboard.agentTable.status.agentOffline") + }} - Agent online + {{ + $t("dashboard.agentTable.status.agentOnline") + }} {{ @@ -362,7 +408,6 @@ import PendingActions from "@/components/logs/PendingActions.vue"; import AgentActionMenu from "@/components/agents/AgentActionMenu.vue"; import { runURLAction } from "@/api/core"; import { runTakeControl, runRemoteBackground } from "@/api/agents"; -import { capitalize } from "@vue/shared"; export default { name: "AgentTable", @@ -379,12 +424,28 @@ export default { sortBy: "hostname", descending: false, }, - dashboard_overdue_text: "Show a dashboard alert when agent is overdue", - email_overdue_text: "Send an email alert when agent is overdue", - sms_overdue_text: "Send a SMS alert when agent is overdue", + dashboard_overdue_text: this.$t( + "dashboard.agentTable.alerts.dashboardOverdue", + ), + email_overdue_text: this.$t("dashboard.agentTable.alerts.emailOverdue"), + sms_overdue_text: this.$t("dashboard.agentTable.alerts.smsOverdue"), }; }, + watch: { + "$i18n.locale"() { + this.syncLocalizedTexts(); + }, + }, methods: { + syncLocalizedTexts() { + this.dashboard_overdue_text = this.$t( + "dashboard.agentTable.alerts.dashboardOverdue", + ); + this.email_overdue_text = this.$t( + "dashboard.agentTable.alerts.emailOverdue", + ); + this.sms_overdue_text = this.$t("dashboard.agentTable.alerts.smsOverdue"); + }, filterTable(rows, terms, cols, cellValue) { const hiddenFields = [ "version", @@ -507,7 +568,12 @@ export default { else if (category === "text") db_field = "overdue_text_alert"; else if (category === "dashboard") db_field = "overdue_dashboard_alert"; - const action = !alert_action ? "enabled" : "disabled"; + const action = !alert_action + ? this.$t("dashboard.agentTable.alerts.action.enabled") + : this.$t("dashboard.agentTable.alerts.action.disabled"); + const categoryLabel = this.$t( + `dashboard.agentTable.alerts.category.${category}`, + ); const data = { [db_field]: !alert_action, }; @@ -519,9 +585,11 @@ export default { color: alertColor, textColor: "black", icon: "fas fa-check-circle", - message: `${capitalize(category)} alerts will now be ${action} when ${ - agent.hostname - } is overdue.`, + message: this.$t("dashboard.agentTable.alerts.updated", { + category: categoryLabel, + action, + hostname: agent.hostname, + }), timeout: 5000, }); }); diff --git a/src/components/AlertsIcon.vue b/src/components/AlertsIcon.vue index 25800eb3..d77039f2 100644 --- a/src/components/AlertsIcon.vue +++ b/src/components/AlertsIcon.vue @@ -5,7 +5,9 @@ }} - No New Alerts + {{ + $t("alerts.icon.noNewAlerts") + }} - Snooze alert + {{ $t("alerts.common.snoozeAlert") }} - Resolve alert + {{ $t("alerts.common.resolveAlert") }} - View All Alerts ({{ alertsCount }}) + {{ + $t("alerts.icon.viewAllAlerts", { count: alertsCount }) + }} @@ -112,8 +114,8 @@ export default { snoozeAlert(alert) { this.$q .dialog({ - title: "Snooze Alert", - message: "How many days to snooze alert?", + title: this.$t("alerts.dialog.snoozeTitle"), + message: this.$t("alerts.dialog.snoozeMessage"), prompt: { model: "", type: "number", @@ -135,7 +137,9 @@ export default { .then(() => { this.getAlerts(); this.$q.loading.hide(); - this.notifySuccess(`The alert has been snoozed for ${days} days`); + this.notifySuccess( + this.$t("alerts.notify.snoozedForDays", { days: days }), + ); }) .catch(() => { this.$q.loading.hide(); @@ -155,7 +159,7 @@ export default { .then(() => { this.getAlerts(); this.$q.loading.hide(); - this.notifySuccess("The alert has been resolved"); + this.notifySuccess(this.$t("alerts.notify.resolved")); }) .catch(() => { this.$q.loading.hide(); @@ -171,9 +175,12 @@ export default { else return this.alertsCount; }, pollAlerts() { - this.poll = setInterval(() => { - this.getAlerts(); - }, 60 * 1 * 1000); + this.poll = setInterval( + () => { + this.getAlerts(); + }, + 60 * 1 * 1000, + ); }, }, mounted() { diff --git a/src/components/AlertsManager.vue b/src/components/AlertsManager.vue index 379d2d7c..a9ff0dd4 100644 --- a/src/components/AlertsManager.vue +++ b/src/components/AlertsManager.vue @@ -11,17 +11,19 @@ flat push icon="refresh" - />Alerts Manager + />{{ $t("alerts.manager.title") }} - Close + {{ + $t("alerts.common.close") + }}
@@ -93,7 +103,9 @@ - Edit + {{ + $t("alerts.manager.menu.edit") + }} - Delete + {{ + $t("alerts.manager.menu.delete") + }} @@ -116,13 +130,17 @@ - Alert Exclusions + {{ + $t("alerts.manager.menu.alertExclusions") + }} - Close + {{ + $t("alerts.common.close") + }} @@ -142,9 +160,9 @@ name="done" size="sm" > - Alert template has agent alert settings + {{ + $t("alerts.manager.tooltips.templateHasAgentSettings") + }} @@ -155,9 +173,9 @@ name="done" size="sm" > - Alert template has check alert settings + {{ + $t("alerts.manager.tooltips.templateHasCheckSettings") + }} @@ -168,9 +186,9 @@ name="done" size="sm" > - Alert template has task alert settings + {{ + $t("alerts.manager.tooltips.templateHasTaskSettings") + }} @@ -181,7 +199,7 @@ color="primary" text-color="white" size="sm" - >Default{{ $t("alerts.manager.default") }} @@ -190,10 +208,12 @@ style="cursor: pointer; text-decoration: underline" class="text-primary" @click="showTemplateApplied(props.row)" - >Show where template is applied ({{ - props.row.applied_count - }}){{ + $t("alerts.manager.showAppliedCount", { + count: props.row.applied_count, + }) + }} + @@ -201,11 +221,14 @@ style="cursor: pointer; text-decoration: underline" class="text-primary" @click="showAlertExclusions(props.row)" - >Alert Exclusions ({{ - props.row.excluded_agents.length + - props.row.excluded_clients.length + - props.row.excluded_sites.length - }}){{ + $t("alerts.manager.showExclusionsCount", { + count: + props.row.excluded_agents.length + + props.row.excluded_clients.length + + props.row.excluded_sites.length, + }) + }} @@ -238,47 +261,52 @@ export default { columns: [ { name: "is_active", - label: "Active", + label: this.$t("alerts.manager.columns.active"), field: "is_active", align: "left", }, { name: "agent_settings", - label: "Agent Settings", + label: this.$t("alerts.manager.columns.agentSettings"), field: "agent_settings", }, { name: "check_settings", - label: "Check Settings", + label: this.$t("alerts.manager.columns.checkSettings"), field: "check_settings", }, { name: "task_settings", - label: "Task Settings", + label: this.$t("alerts.manager.columns.taskSettings"), field: "task_settings", }, - { name: "name", label: "Name", field: "name", align: "left" }, + { + name: "name", + label: this.$t("alerts.manager.columns.name"), + field: "name", + align: "left", + }, { name: "applied_to", - label: "Applied To", + label: this.$t("alerts.manager.columns.appliedTo"), field: "applied_to", align: "left", }, { name: "alert_exclusions", - label: "Alert Exclusions", + label: this.$t("alerts.manager.columns.alertExclusions"), field: "alert_exclusions", align: "left", }, { name: "action_name", - label: "Failure Action", + label: this.$t("alerts.manager.columns.failureAction"), field: "action_name", align: "left", }, { name: "resolved_action_name", - label: "Resolved Action", + label: this.$t("alerts.manager.columns.resolvedAction"), field: "resolved_action_name", align: "left", }, @@ -314,9 +342,14 @@ export default { deleteTemplate(template) { this.$q .dialog({ - title: `Delete alert template ${template.name}?`, + title: this.$t("alerts.manager.dialog.deleteTemplateTitle", { + name: template.name, + }), cancel: true, - ok: { label: "Delete", color: "negative" }, + ok: { + label: this.$t("alerts.manager.menu.delete"), + color: "negative", + }, }) .onOk(() => { this.$q.loading.show(); @@ -326,7 +359,9 @@ export default { this.refresh(); this.$q.loading.hide(); this.notifySuccess( - `Alert template ${template.name} was deleted!`, + this.$t("alerts.manager.notify.deleted", { + name: template.name, + }), ); }) .catch(() => { @@ -378,8 +413,8 @@ export default { }, toggleEnabled(template) { let text = !template.is_active - ? "Template enabled successfully" - : "Template disabled successfully"; + ? this.$t("alerts.manager.notify.templateEnabled") + : this.$t("alerts.manager.notify.templateDisabled"); const data = { id: template.id, diff --git a/src/components/FileBar.vue b/src/components/FileBar.vue index 9f442f83..b5ac55ff 100644 --- a/src/components/FileBar.vue +++ b/src/components/FileBar.vue @@ -2,93 +2,147 @@
- + - Add + {{ + $t("dashboard.fileBar.actions.add") + }} - Client + {{ + $t("dashboard.table.client") + }} - Site + {{ + $t("dashboard.table.site") + }} - Audit Log + {{ + $t("dashboard.fileBar.actions.auditLog") + }} - Debug Log + {{ + $t("dashboard.fileBar.actions.debugLog") + }} - + - Pending Actions + {{ + $t("dashboard.agentTable.pendingActions") + }} - + - Install Agent + {{ + $t("dashboard.contextMenu.installAgent") + }} - Manage Deployments + {{ + $t("dashboard.fileBar.actions.manageDeployments") + }} - Update Agents + {{ + $t("dashboard.fileBar.actions.updateAgents") + }} - + - Clients Manager + {{ + $t("dashboard.fileBar.actions.clientsManager") + }} - Script Manager + {{ + $t("dashboard.fileBar.actions.scriptManager") + }} - Automation Manager + {{ + $t("dashboard.fileBar.actions.automationManager") + }} - Alerts Manager + {{ + $t("dashboard.fileBar.actions.alertsManager") + }} - Permissions Manager + {{ + $t("dashboard.fileBar.actions.permissionsManager") + }} - User Administration + {{ + $t("dashboard.fileBar.actions.userAdministration") + }} - Global Settings + {{ + $t("dashboard.fileBar.actions.globalSettings") + }} - Code Signing + {{ + $t("dashboard.fileBar.actions.codeSigning") + }} - + @@ -120,15 +184,21 @@ v-close-popup @click="showBulkAction('command')" > - Bulk Command + {{ + $t("dashboard.fileBar.actions.bulkCommand") + }} - Bulk Script + {{ + $t("dashboard.fileBar.actions.bulkScript") + }} - Bulk Patch Management + {{ + $t("dashboard.fileBar.actions.bulkPatchManagement") + }} - Server Maintenance + {{ + $t("dashboard.fileBar.actions.serverMaintenance") + }} - Clear Cache + {{ + $t("dashboard.fileBar.actions.clearCache") + }} - Recover All Agents + {{ + $t("dashboard.fileBar.actions.recoverAllAgents") + }} - + - Reporting Manager + {{ + $t("dashboard.fileBar.actions.reportingManager") + }} - + - Documentation + {{ + $t("dashboard.fileBar.actions.documentation") + }} - GitHub Repo + {{ + $t("dashboard.fileBar.actions.githubRepo") + }} - Bug Report + {{ + $t("dashboard.fileBar.actions.bugReport") + }} - Feature Request + {{ + $t("dashboard.fileBar.actions.featureRequest") + }} - Join Discord + {{ + $t("dashboard.fileBar.actions.joinDiscord") + }} @@ -308,22 +409,23 @@ export default { }, methods: { clearCache() { - this.$axios - .get("/core/clearcache/") - .then((r) => this.notifySuccess(r.data)); + this.$axios.get("/core/clearcache/").then(() => { + this.notifySuccess(this.$t("dashboard.fileBar.notify.cacheCleared")); + }); }, bulkRecoverAgents() { this.$q .dialog({ - title: "Bulk Recover All Agents?", - message: - "This will restart the Tactical and Mesh Agent services on all agents", + title: this.$t("dashboard.fileBar.dialog.bulkRecoverTitle"), + message: this.$t("dashboard.fileBar.dialog.bulkRecoverMessage"), cancel: true, }) .onOk(() => { - this.$axios - .get("/agents/bulkrecovery/") - .then((r) => this.notifySuccess(r.data)); + this.$axios.get("/agents/bulkrecovery/").then(() => { + this.notifySuccess( + this.$t("dashboard.fileBar.notify.agentsRecoveryStarted"), + ); + }); }); }, openHelp(mode) { diff --git a/src/components/FileBrowser.vue b/src/components/FileBrowser.vue index 844aec44..6538596a 100644 --- a/src/components/FileBrowser.vue +++ b/src/components/FileBrowser.vue @@ -10,7 +10,9 @@ filter="filter" no-selection-unset selected-color="primary" - :filter-method="(node: QTreeFileNode/*, filter */) => node.type === 'folder'" + :filter-method=" + (node: QTreeFileNode /*, filter */) => node.type === 'folder' + " :nodes="nodes" @update:selected="onFolderSelection" @lazy-load="loadNodeChildren" @@ -26,7 +28,7 @@ :columns="tableColumns" :loading="loading" dense - no-data-label="Folder is Empty" + :no-data-label="t('dashboard.fileBrowser.folderEmpty')" binary-state-sort virtual-scroll selection="multiple" @@ -43,7 +45,12 @@ @@ -55,7 +62,12 @@ @@ -89,6 +101,7 @@ // composition imports import { ref, toRef, onMounted } from "vue"; import { isDefined } from "@vueuse/core"; +import { useI18n } from "vue-i18n"; // type imports import type { QTableColumn, QTreeLazyLoadParams, QTree, QTable } from "quasar"; @@ -103,6 +116,7 @@ import type { const emit = defineEmits<{ (event: "lazy-load", callback: LazyLoadCallbackParams): void; }>(); +const { t } = useI18n(); // props const props = withDefaults( @@ -116,7 +130,7 @@ const props = withDefaults( separator: "unix", loading: false, height: "200px", - } + }, ); // expose public methods @@ -138,21 +152,21 @@ const tableRows = ref([] as FileSystemNodeTable[]); const tableColumns: QTableColumn[] = [ { name: "name", - label: "Name", + label: t("dashboard.fileBrowser.columns.name"), field: "name", align: "left", sortable: true, }, { name: "type", - label: "Type", + label: t("dashboard.fileBrowser.columns.type"), field: "type", align: "left", sortable: true, }, { name: "size", - label: "Size", + label: t("dashboard.fileBrowser.columns.size"), field: "size", align: "left", sortable: true, @@ -202,7 +216,7 @@ function loadNodeChildren({ node, key, done, fail }: QTreeLazyLoadParams) { // parses children of node into table rows function parseNodeChildrenIntoTable( - node: QTreeFileNode + node: QTreeFileNode, ): FileSystemNodeTable[] { if (isDefined(node.children)) { return node.children.map((childNode) => ({ diff --git a/src/components/SubTableTabs.vue b/src/components/SubTableTabs.vue index 0e242f00..f7ad836d 100644 --- a/src/components/SubTableTabs.vue +++ b/src/components/SubTableTabs.vue @@ -20,70 +20,70 @@ name="summary" icon="fas fa-info-circle" size="xs" - label="Summary" + :label="$t('dashboard.subTableTabs.summary')" /> diff --git a/src/components/accounts/PermissionsManager.vue b/src/components/accounts/PermissionsManager.vue index 351d8c72..a3a975bd 100644 --- a/src/components/accounts/PermissionsManager.vue +++ b/src/components/accounts/PermissionsManager.vue @@ -3,7 +3,7 @@ - Manage Roles + {{ $t("settings.permissionsManager.title") }} @@ -21,7 +21,7 @@ :columns="columns" row-key="id" :pagination="{ rowsPerPage: 0, sortBy: 'name', descending: false }" - no-data-label="No Roles" + :no-data-label="$t('settings.permissionsManager.noRoles')" :rows-per-page-options="[0]" > @@ -45,7 +45,9 @@ - Edit + {{ + $t("common.buttons.edit") + }} - Delete + {{ + $t("common.buttons.delete") + }} - Close + {{ + $t("common.buttons.close") + }} @@ -88,35 +94,18 @@ // composition imports import { ref, onMounted } from "vue"; import { useQuasar, useDialogPluginComponent } from "quasar"; +import { useI18n } from "vue-i18n"; import { fetchRoles, removeRole } from "@/api/accounts"; import { notifySuccess } from "@/utils/notify"; // ui imports import RolesForm from "@/components/accounts/RolesForm.vue"; -// static data -const columns = [ - { name: "name", label: "Name", field: "name", align: "left", sortable: true }, - { - name: "is_superuser", - label: "Superuser", - field: "is_superuser", - align: "left", - sortable: true, - }, - { - name: "user_count", - label: "Assigned Users", - field: "user_count", - align: "left", - sortable: true, - }, -]; - export default { name: "PermissionsManager", emits: [...useDialogPluginComponent.emits], setup() { + const { t } = useI18n(); // setup quasar const $q = useQuasar(); const { dialogRef, onDialogHide } = useDialogPluginComponent(); @@ -124,6 +113,29 @@ export default { // permission manager logic const roles = ref([]); const loading = ref(false); + const columns = [ + { + name: "name", + label: t("settings.common.name"), + field: "name", + align: "left", + sortable: true, + }, + { + name: "is_superuser", + label: t("settings.permissionsManager.columns.superuser"), + field: "is_superuser", + align: "left", + sortable: true, + }, + { + name: "user_count", + label: t("settings.permissionsManager.columns.assignedUsers"), + field: "user_count", + align: "left", + sortable: true, + }, + ]; function showEditRoleModal(role) { $q.dialog({ @@ -148,9 +160,11 @@ export default { async function deleteRole(role) { $q.dialog({ - title: `Delete role ${role.name}?`, + title: t("settings.permissionsManager.deleteRoleTitle", { + name: role.name, + }), cancel: true, - ok: { label: "Delete", color: "negative" }, + ok: { label: t("common.buttons.delete"), color: "negative" }, }).onOk(async () => { try { $q.loading.show(); diff --git a/src/components/accounts/ResetPass.vue b/src/components/accounts/ResetPass.vue index 810247a3..7345ea50 100644 --- a/src/components/accounts/ResetPass.vue +++ b/src/components/accounts/ResetPass.vue @@ -2,14 +2,14 @@ -
New password:
+
{{ t("auth.resetPassword.newPassword") }}