diff --git a/README.md b/README.md index 884154e..9220b43 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,17 @@ - `DELETE /api/v1/servers/{id}` — удалить сервер (с каскадом метрик) - `GET/POST/PATCH/DELETE /api/v1/shares[/{id}]` — сетевые хранилища (CRUD) - `GET /api/v1/shares/{id}/samples?since=…` — история заполнения хранилища +- `GET/PATCH /api/v1/me` — профиль + язык (GET), override языка (PATCH; только + cookie-сессия — у Bearer-админа нет личности) + +## Язык интерфейса + +Три языка: en, uk, ru. По умолчанию панель следует языку аккаунта gnexus-auth +(`profile.locale`, меняется в самом gnexus-auth). В разделе «Настройки» первого +блока — «Язык интерфейса»: выбор перекрывает язык аккаунта только для панели; +`Авто` возвращает язык аккаунта. Выбор переживает logout и обновление профиля +(webhook не трогает override). Если gnexus-auth не настроен — панель открыта, +язык хранится в localStorage браузера и не синхронизируется между устройствами. ## Сетевые хранилища (раздел Storage) diff --git a/panel/backend/app/api/auth_routes.py b/panel/backend/app/api/auth_routes.py index 094631e..4e84266 100644 --- a/panel/backend/app/api/auth_routes.py +++ b/panel/backend/app/api/auth_routes.py @@ -8,6 +8,7 @@ """ from datetime import datetime, timedelta, timezone +import json from fastapi import APIRouter, Request, Response from fastapi.responses import JSONResponse, RedirectResponse @@ -17,6 +18,7 @@ TransportException, ) from gnexus_gauth.oauth import AuthorizationUrlBuilder, PkceGenerator +from pydantic import BaseModel from app.auth import ( SCOPES, @@ -135,21 +137,29 @@ me_router = APIRouter(prefix="/api/v1") +class LocaleUpdate(BaseModel): + locale: str = "" + + @me_router.get("/me", dependencies=[]) async def me(request: Request) -> dict: """Профиль текущего пользователя + флаг режима авторизации. Auth выключен → {auth_enabled: false} — SPA молча открывает панель. Включён и сессии нет → 401 (SPA показывает экран входа). - Включён и сессия есть → {auth_enabled: true, user: …}. + Включён и сессия есть → {auth_enabled: true, user: …, locale, locale_effective}. """ if not auth_enabled(): - return {"auth_enabled": False, "user": None} - from app.auth import get_session + return {"auth_enabled": False, "user": None, "locale_effective": None} + from app.auth import get_session, get_user_row + from app.locales import effective_locale session = await get_session(request.cookies.get(SESSION_COOKIE)) if session is None: raise auth_mode_error() + row = await get_user_row(session["user_id"]) + profile = json.loads(row["profile"]) if row and row["profile"] else {} + override = row["locale"] if row else None return { "auth_enabled": True, "user": { @@ -158,6 +168,47 @@ "display_name": session["display_name"], "avatar_url": session["avatar_url"], }, + "locale": override, + "locale_effective": effective_locale(override, profile), + } + + +@me_router.patch("/me", dependencies=[]) +async def update_me(request: Request, payload: LocaleUpdate) -> dict: + """Пер-сервисный override языка панели (настройки → «Язык интерфейса»). + + Только cookie-сессия: у Bearer-токена админа нет личности, которой можно + сохранить настройку, поэтому он здесь не проходит (401). 'auto' или пусто + сбрасывают override (= следовать языку аккаунта gnexus-auth). + """ + if not auth_enabled(): + raise auth_mode_error() + from app.auth import get_session, get_user_row, set_user_locale + from app.locales import effective_locale, normalize_locale + + session = await get_session(request.cookies.get(SESSION_COOKIE)) + if session is None: + raise auth_mode_error() + + value = (payload.locale or "").strip().lower() + override = None if value in ("", "auto") else normalize_locale(value) + if value not in ("", "auto") and override is None: + return JSONResponse({"detail": "unsupported locale"}, status_code=422) + + await set_user_locale(session["user_id"], override, email=session["email"], + display_name=session["display_name"]) + row = await get_user_row(session["user_id"]) + profile = json.loads(row["profile"]) if row and row["profile"] else {} + return { + "auth_enabled": True, + "user": { + "user_id": session["user_id"], + "email": session["email"], + "display_name": session["display_name"], + "avatar_url": session["avatar_url"], + }, + "locale": row["locale"] if row else None, + "locale_effective": effective_locale(row["locale"] if row else None, profile), } diff --git a/panel/backend/app/auth.py b/panel/backend/app/auth.py index a981234..008ced4 100644 --- a/panel/backend/app/auth.py +++ b/panel/backend/app/auth.py @@ -9,8 +9,13 @@ Режим: GHARD_AUTH_CLIENT_ID пуст → панель открыта (проверка в security); задан → браузер шлюзится через gnexus-auth, агент — по X-Server-Key, скрипты/MCP — по Bearer GHARD_ADMIN_TOKEN. + +Рядом с sessions живёт таблица users (настройки панели, переживают сессии): +upsert профиля из auth никогда не трогает колонку locale — override хранится +только в настройках панели. """ +import json import uuid from datetime import datetime, timedelta, timezone @@ -90,7 +95,39 @@ return token_set, user -# --- Сессии в SQLite ----------------------------------------------------------- +# --- Сессии в SQLite + users (настройки, переживающие сессии) ------------------- + +def _display_name_from_profile(profile: dict) -> str: + return profile.get("display_name") or profile.get("name") or "" + + +async def upsert_user_row(user, user_id: str | None = None, email: str | None = None) -> None: + """users(upsert): профиль gnexus-auth verbatim. Колонку locale НЕ трогаем — + она меняется только из настроек панели, override переживает sync профиля.""" + profile = (user.profile or {}) if hasattr(user, "profile") else (user if isinstance(user, dict) else {}) + if not isinstance(profile, dict): + profile = {} + row_id = user_id or getattr(user, "user_id", None) + row_email = email or getattr(user, "email", None) or "" + now = _now().isoformat() + db = get_db() + await db.execute( + "INSERT INTO users (user_id, email, display_name, profile, created_at, updated_at)" + " VALUES (?, ?, ?, ?, ?, ?)" + " ON CONFLICT(user_id) DO UPDATE SET email=excluded.email," + " display_name=excluded.display_name, profile=excluded.profile," + " updated_at=excluded.updated_at", + ( + row_id, + row_email, + _display_name_from_profile(profile) or row_email, + json.dumps(profile, ensure_ascii=False), + now, + now, + ), + ) + await db.commit() + async def create_session(user) -> str: """user — gnexus_gauth.dto.AuthenticatedUser. Возвращает id сессии.""" @@ -112,6 +149,7 @@ ), ) await db.commit() + await upsert_user_row(user) return session_id @@ -173,4 +211,40 @@ "UPDATE sessions SET avatar_url = ? WHERE user_id = ? AND expires_at > ?", (avatar, user_id, cutoff), ) + if profile: + # профиль обновился — пишем его в users verbatim, как в creds + # (override в колонке locale переживает sync: upsert locale не трогает) + await upsert_user_row( + profile, user_id=user_id, + email=metadata.get("email") or profile.get("email"), + ) + await db.commit() + + +async def get_user_row(user_id: str) -> dict | None: + """Строка users для /me (настройки пользователя); None, если не входил.""" + db = get_db() + cursor = await db.execute( + "SELECT user_id, email, display_name, profile, locale FROM users WHERE user_id = ?", + (user_id,), + ) + row = await cursor.fetchone() + return dict(row) if row else None + + +async def set_user_locale(user_id: str, locale: str | None, email: str, display_name: str) -> None: + """Записать override языка (None = авто). Строки users может не быть — + пользователь мог не залогиниться повторно после добавления таблицы.""" + db = get_db() + now = _now().isoformat() + await db.execute( + "INSERT INTO users (user_id, email, display_name, profile, locale, created_at, updated_at)" + " VALUES (?, ?, ?, '{}', ?, ?, ?)" + " ON CONFLICT(user_id) DO UPDATE SET" + " email = CASE WHEN excluded.email != '' THEN excluded.email ELSE users.email END," + " display_name = CASE WHEN excluded.display_name != '' THEN excluded.display_name" + " ELSE users.display_name END," + " locale = excluded.locale, updated_at = excluded.updated_at", + (user_id, email, display_name, locale, now, now), + ) await db.commit() \ No newline at end of file diff --git a/panel/backend/app/locales.py b/panel/backend/app/locales.py new file mode 100644 index 0000000..94ad343 --- /dev/null +++ b/panel/backend/app/locales.py @@ -0,0 +1,24 @@ +"""Языковые теги панели: нормализация и выбор действующего языка. + +Зеркало фронтовых i18n-слагов: override из настроек панели → язык аккаунта +gnexus-auth (users.profile.locale) → 'en'. Тего-нормализация одна и та же: +'en-US'/'en_US' → 'en', непонятное → None. +""" + +SUPPORTED_LOCALES = ("en", "uk", "ru") + + +def normalize_locale(tag) -> str | None: + """'en-US' -> 'en'; всё вне SUPPORTED_LOCALES -> None.""" + text = str(tag or "").strip().lower() + prefix = text.split("-", 1)[0].split("_", 1)[0] + return prefix if prefix in SUPPORTED_LOCALES else None + + +def effective_locale(override, profile) -> str: + """override -> язык аккаунта -> 'en' (совпадает с фолбэком фронта).""" + return ( + normalize_locale(override) + or normalize_locale((profile or {}).get("locale")) + or "en" + ) \ No newline at end of file diff --git a/panel/backend/app/schema.sql b/panel/backend/app/schema.sql index a2e1e6b..5245d10 100644 --- a/panel/backend/app/schema.sql +++ b/panel/backend/app/schema.sql @@ -92,3 +92,17 @@ ); CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); + +-- Пользователи gnexus-auth: пер-сервисные настройки панели. +-- profile = словарь профиля verbatim (язык аккаунта — profile.locale); +-- locale = override из настроек панели (NULL = следовать аккаунту). +-- Профиль живёт отдельно от сессий: сессии истекают/удаляются, настройка — нет. +CREATE TABLE IF NOT EXISTS users ( + user_id TEXT PRIMARY KEY, -- sub из gnexus-auth (= sessions.user_id) + email TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + profile TEXT NOT NULL DEFAULT '{}', -- JSON профиля gnexus-auth verbatim + locale TEXT, -- override 'en'|'uk'|'ru'; NULL = авто + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); diff --git a/panel/frontend/index.html b/panel/frontend/index.html index ac7d5dd..665a169 100644 --- a/panel/frontend/index.html +++ b/panel/frontend/index.html @@ -1,5 +1,6 @@ - + + diff --git a/panel/frontend/src/App.vue b/panel/frontend/src/App.vue index ccda59b..feb98c1 100644 --- a/panel/frontend/src/App.vue +++ b/panel/frontend/src/App.vue @@ -7,6 +7,7 @@ } from 'gnexus-ui-kit/vue' import { api } from './api.js' +import { setLocale, t } from './i18n/index.js' import { router } from './router.js' // GnNavList захватывает $route одним снапшотом в setup, и с vue-router 4 @@ -18,17 +19,33 @@ const navItems = computed(() => [ { - label: 'Dashboard', icon: 'ph-squares-four', href: '/', + label: t('nav.dashboard'), icon: 'ph-squares-four', href: '/', active: route.path === '/' || route.path.startsWith('/servers/'), onSelect: (item, event) => { event.preventDefault(); router.push(item.href) }, }, { - label: 'Storage', icon: 'ph-hard-drives', href: '/storage', + label: t('nav.storage'), icon: 'ph-hard-drives', href: '/storage', active: route.path.startsWith('/storage'), onSelect: (item, event) => { event.preventDefault(); router.push(item.href) }, }, + { + label: t('nav.settings'), icon: 'ph-gear', href: '/settings', + active: route.path.startsWith('/settings'), + onSelect: (item, event) => { event.preventDefault(); router.push(item.href) }, + }, ]) +// Язык, пока SPA открыт (SettingsPage меняет его через /me или localStorage). +function bootLocale(data) { + if (data.auth_enabled && data.user) { + // язык аккаунта с перекрытием из настроек — бэкенд считает сам + setLocale(data.user.locale_effective ?? data.locale_effective) + return + } + // открыта вовсе / не вошли — выбор хранится в этом браузере + setLocale(localStorage.getItem('ghhard.locale')) +} + // --- Шлюз входа (как в gnexus-creds): splash → /me → панель или экран входа. // OAuth стартует по клику пользователя — без тихого авто-редиректа на page load // (иначе gnexus-auth молча подхватит ещё живую сессию вместо явного входа). @@ -45,6 +62,7 @@ authState.value = 'checking' try { const data = await api.me() + bootLocale(data) me.value = data.user if (!data.auth_enabled || !data.user) { // панель открыта вовсе (gnexus-auth не настроен) @@ -55,9 +73,11 @@ } } catch (err) { if (err?.status === 401) { + setLocale(localStorage.getItem('ghhard.locale')) authState.value = 'denied' } else { // сеть/бэкенд упал — продолжаем с экраном входа, /auth/login вернёт обратно + setLocale(localStorage.getItem('ghhard.locale')) authState.value = 'denied' } } @@ -104,9 +124,9 @@
-

Sign in

- - Sign in +

{{ t('login.title') }}

+ + {{ t('login.action') }}
@@ -115,7 +135,7 @@ brand="GHard Monitor" logo-src="/logo.svg" current="Gnexus Hardware Monitor" - title="Monitoring" + :title="t('shell.title')" subtitle="GHard" > diff --git a/panel/frontend/src/api.js b/panel/frontend/src/api.js index c8c1041..4f5cd77 100644 --- a/panel/frontend/src/api.js +++ b/panel/frontend/src/api.js @@ -39,6 +39,9 @@ // { auth_enabled: false } (панель открыта) или // { auth_enabled: true, user: {...} } | 401 (нужно войти) me: () => request('/me'), + // PATCH /me — только cookie-сессия (у Bearer-админа нет личности для настроек); + // auth-off → 401 (настраивать нечего). locale: 'en'|'uk'|'ru'|'auto' + updateMe: (fields) => request('/me', { method: 'PATCH', body: fields }), servers: () => request('/servers'), server: (id) => request(`/servers/${id}`), createServer: (name, hostname = '', interval = 30) => diff --git a/panel/frontend/src/components/ServerCard.vue b/panel/frontend/src/components/ServerCard.vue index 1e7d4aa..1b49e9f 100644 --- a/panel/frontend/src/components/ServerCard.vue +++ b/panel/frontend/src/components/ServerCard.vue @@ -4,6 +4,7 @@ import { SERIES_IN, SERIES_OUT } from '../chartTheme.js' import { fmtAgo, fmtGiB, fmtMbs, fmtPercent, fmtUptime, ramPercent } from '../format.js' +import { t } from '../i18n/index.js' const props = defineProps({ server: { type: Object, required: true }, @@ -37,13 +38,9 @@ } }) -const statusText = computed(() => { - switch (props.server.status) { - case 'online': return 'online' - case 'offline': return 'offline' - default: return 'waiting' - } -}) +const statusText = computed(() => + t('status.' + (props.server.status || 'waiting')), +) diff --git a/panel/frontend/src/format.js b/panel/frontend/src/format.js index ab9818e..9b009f2 100644 --- a/panel/frontend/src/format.js +++ b/panel/frontend/src/format.js @@ -1,4 +1,8 @@ -// Panel formatters +// Panel formatters. Единицы измерения (B..TB, GiB, MB/s) универсальны и не +// переводятся; локаль-чувствительны только строки времени (t() читает locale +// ref из i18n — все вызовы в computed/шаблонах перерисовываются сами). +import { t } from './i18n/index.js' + export function fmtBytes(value) { if (!value && value !== 0) return '—' const units = ['B', 'KB', 'MB', 'GB', 'TB'] @@ -33,20 +37,20 @@ if (!seconds && seconds !== 0) return '—' const days = Math.floor(seconds / 86400) const hours = Math.floor((seconds % 86400) / 3600) - if (days > 0) return `${days}d ${hours}h` + if (days > 0) return t('time.upDaysHours', { d: days, h: hours }) const minutes = Math.floor((seconds % 3600) / 60) - if (hours > 0) return `${hours}h ${minutes}m` - return `${minutes}m ${Math.floor(seconds % 60)}s` + if (hours > 0) return t('time.upHoursMinutes', { h: hours, m: minutes }) + return t('time.upMinutesSeconds', { m: minutes, s: Math.floor(seconds % 60) }) } export function fmtAgo(iso) { - if (!iso) return 'never' + if (!iso) return t('time.never') const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000) - if (seconds < 5) return 'just now' - if (seconds < 60) return `${seconds}s ago` - if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago` - if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago` - return `${Math.floor(seconds / 86400)}d ago` + if (seconds < 5) return t('time.now') + if (seconds < 60) return t('time.secondsAgo', { n: seconds }) + if (seconds < 3600) return t('time.minutesAgo', { n: Math.floor(seconds / 60) }) + if (seconds < 86400) return t('time.hoursAgo', { n: Math.floor(seconds / 3600) }) + return t('time.daysAgo', { n: Math.floor(seconds / 86400) }) } export function ramPercent(metrics) { diff --git a/panel/frontend/src/i18n/index.js b/panel/frontend/src/i18n/index.js new file mode 100644 index 0000000..6e61b0c --- /dev/null +++ b/panel/frontend/src/i18n/index.js @@ -0,0 +1,65 @@ +// Tiny reactive i18n for the SPA (vue-i18n is deliberately not pulled in): +// a locale ref, a t() with {param} interpolation and Slavic plural forms, +// and flat per-area message dictionaries. The default locale is set from +// the backend's locale_effective (settings override -> auth account -> en). +import { ref, watch } from "vue"; + +import en from "./messages/en.js"; +import uk from "./messages/uk.js"; +import ru from "./messages/ru.js"; + +const messages = { en, uk, ru }; + +// Language tags normalize to their 2-letter prefix ("en-US" -> "en"); +// anything the dictionaries don't ship falls back to English. +export const SUPPORTED_LOCALES = Object.keys(messages); + +export const locale = ref("en"); + +function normalizeTag(tag) { + const prefix = String(tag || "").trim().toLowerCase().split(/[-_]/)[0]; + return messages[prefix] ? prefix : null; +} + +export function setLocale(tag) { + locale.value = normalizeTag(tag) || "en"; +} + +watch(locale, (lang) => { + if (typeof document !== "undefined") { + document.documentElement.lang = lang; + document.title = t("app.docTitle"); + } +}); + +function pluralCategory(lang, n) { + // ru/uk: n%10==1 (except 11) -> one; 2-4 (except 12-14) -> few; else many + const mod10 = n % 10; + const mod100 = n % 100; + if (lang === "ru" || lang === "uk") { + if (mod10 === 1 && mod100 !== 11) return "one"; + if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) return "few"; + return "many"; + } + return n === 1 ? "one" : "other"; +} + +function interpolate(text, params) { + return text.replace(/\{(\w+)\}/g, (match, key) => + params && key in params ? String(params[key]) : match + ); +} + +function lookup(slug) { + return messages[locale.value]?.[slug] ?? messages.en[slug]; +} + +export function t(slug, params) { + let text = lookup(slug); + if (text != null && typeof text === "object") { + // plural dictionary {one, few, many, other}; a missing form falls to other + const n = Number(params?.n ?? 0); + text = text[pluralCategory(locale.value, n)] ?? text.other; + } + return text != null ? interpolate(String(text), params) : slug; +} \ No newline at end of file diff --git a/panel/frontend/src/i18n/messages/en.js b/panel/frontend/src/i18n/messages/en.js new file mode 100644 index 0000000..6edf246 --- /dev/null +++ b/panel/frontend/src/i18n/messages/en.js @@ -0,0 +1,152 @@ +// English dictionary — canonical key set; the other locales mirror it +// (a missing slug falls back to English either way). +export default { + // app / shell / nav + "app.docTitle": "GHard Monitor", + "shell.title": "Monitoring", + "nav.dashboard": "Dashboard", + "nav.storage": "Storage", + "nav.settings": "Settings", + "nav.signOut": "Sign out", + + // login screen + "login.title": "Sign in", + "login.subtitle": "GHard Monitor is protected by gnexus-auth", + "login.action": "Sign in", + + // dashboard + "dash.title": "Dashboard", + "dash.addServer": "Add server", + "dash.summary": "{servers} · {online}", // counts are composed via nested t() + "dash.servers": { one: "{n} server", other: "{n} servers" }, + "dash.online": "{m} online", + "dash.emptyTitle": "No servers yet", + "dash.emptyText": "Add your first server — the panel will issue a key for the hard-monitor agent", + "dash.serverCreated": "Server created", + "dash.newServer": "New server", + "dash.nameHint": "The name is for the panel only — the agent reports the hostname itself.", + "dash.serverName": "Server name", + "dash.keyHint": "The key is shown {once} — save it and configure the agent with it.", + "dash.installOn": "Install on the server:", + "dash.failed": "Failed", + + // server card / server page + "server.open": "Open", + "server.uptime": "uptime {value}", + "server.disk": "Disk {mount}", + "server.nodata": "no data yet — install the agent", + "server.back": "Back to dashboard", + "server.notFound": "Server not found", + "server.notFoundText": "It may have been deleted", + "server.rename": "Rename", + "server.renameTitle": "Rename server", + "server.serverName": "Server name", + "server.delete": "Delete", + "server.deleteTitle": "Delete server?", + "server.deleteMessage": "\"{name}\" and its entire metric history will be deleted permanently.", + "server.infoTitle": "Info", + "server.noteTitle": "Note", + "server.serverNote": "Server note", + "server.notePlaceholder": "What the server is for, what runs on it. No secrets here :)", + "server.hostname": "Hostname", + "server.os": "OS", + "server.kernel": "Kernel", + "server.ips": "IP addresses", + "server.connIp": "Connection IP", + "server.interval": "Agent interval", + "server.lastPacket": "Last packet", + "server.network": "Network", + "server.history": "History:", + "server.tabOverview": "Overview", + "server.tabDisks": "Disks", + "server.tabNetwork": "Network", + "server.tabProcesses": "Processes", + "server.tabDocker": "Docker", + "server.cpuRamChart": "CPU & RAM, %", + "server.diskChart": "Disk usage, %", + "server.netChart": "Network speed, MB/s", + "server.colMount": "Mount point", + "server.colSize": "Size", + "server.colUsed": "Used", + "server.colUsage": "Usage", + "server.colProcess": "Process", + "server.colPid": "PID", + "server.colStatus": "Status", + "server.colContainer": "Container", + "server.colImage": "Image", + "server.noDisks": "No disks in the last packet", + "server.noProcesses": "No process data", + "server.noDocker": "No Docker containers found", + "server.of": "of {total}", + "server.noteSaved": "Note saved", + "server.renamed": "Renamed", + "server.serverDeleted": "Server deleted", + "server.failedToSave": "Failed to save", + "server.failedToRename": "Failed to rename", + "server.failedToDelete": "Failed to delete", + "server.historyFailed": "History failed to load", + + // storage + "storage.title": "Storage", + "storage.subtitle": "Network shares mounted to the panel host", + "storage.addShare": "Add share", + "storage.emptyTitle": "No shares yet", + "storage.emptyText": "Mount a network drive to the panel host and add its path here — the panel will track usage itself", + "storage.usedLabel": "Used", + "storage.used": "used", + "storage.free": "free", + "storage.total": "total", + "storage.nodata": "no data — check the mount", + "storage.sampledAt": "sampled: {time}", + "storage.notSampled": "not sampled yet", + "storage.historyTitle": "Usage over 24 h, %", + "storage.addTitle": "Add network share", + "storage.name": "Name", + "storage.mountPath": "Mount path on the panel host", + "storage.shareAdded": "Share added", + "storage.deleteTitle": "Delete share?", + "storage.deleteMessage": "\"{name}\" will be removed from monitoring. The mount itself and its data are not touched.", + "storage.historyFailed": "Storage history failed to load", + "storage.shareDeleted": "Share deleted", + + // settings + "settings.title": "Settings", + "settings.languageToolbar": "Interface language", + "settings.languageDescription": "By default the panel follows your gnexus-auth account language; a choice here overrides it for GHard Monitor only. Auto switches back to the account language.", + "settings.languageDescriptionOpen": "The panel is open without an account — the choice is stored in this browser.", + "settings.languageSaved": "Language updated.", + + // language picker (native names — invariant across UI languages) + "profile.languageAuto": "Auto (from account)", + "profile.langEn": "English", + "profile.langUk": "Українська", + "profile.langRu": "Русский", + + // status (single source for card/page/storage badges) + "status.online": "online", + "status.offline": "offline", + "status.waiting": "waiting", + + // relative time (fmtAgo / fmtUptime) + "time.never": "never", + "time.now": "just now", + "time.secondsAgo": "{n}s ago", + "time.minutesAgo": "{n}m ago", + "time.hoursAgo": "{n}h ago", + "time.daysAgo": "{n}d ago", + "time.upDaysHours": "{d}d {h}h", + "time.upHoursMinutes": "{h}h {m}m", + "time.upMinutesSeconds": "{m}m {s}s", + + // common + "common.cancel": "Cancel", + "common.close": "Close", + "common.copy": "Copy", + "common.create": "Create", + "common.save": "Save", + "common.add": "Add", + "common.delete": "Delete", + "common.once": "once", + "common.error": "Error", + "common.failedToLoad": "Failed to load", +}; \ No newline at end of file diff --git a/panel/frontend/src/i18n/messages/ru.js b/panel/frontend/src/i18n/messages/ru.js new file mode 100644 index 0000000..3aa9cfb --- /dev/null +++ b/panel/frontend/src/i18n/messages/ru.js @@ -0,0 +1,151 @@ +// Russian dictionary — same slugs as en.js; a missing slug falls back to English. +export default { + // app / shell / nav + "app.docTitle": "GHard Monitor", + "shell.title": "Мониторинг", + "nav.dashboard": "Дашборд", + "nav.storage": "Хранилища", + "nav.settings": "Настройки", + "nav.signOut": "Выйти", + + // login screen + "login.title": "Вход", + "login.subtitle": "GHard Monitor защищён gnexus-auth", + "login.action": "Войти", + + // dashboard + "dash.title": "Дашборд", + "dash.addServer": "Добавить сервер", + "dash.summary": "{servers} · {online}", // счётчики собираются вложенным t() + "dash.servers": { one: "{n} сервер", few: "{n} сервера", many: "{n} серверов" }, + "dash.online": "{m} в сети", + "dash.emptyTitle": "Серверов пока нет", + "dash.emptyText": "Добавьте первый сервер — панель выдаст ключ для агента hard-monitor", + "dash.serverCreated": "Сервер создан", + "dash.newServer": "Новый сервер", + "dash.nameHint": "Имя нужно только панели — имя хоста агент сообщает сам.", + "dash.serverName": "Имя сервера", + "dash.keyHint": "Ключ показывается {once} — сохраните его и настройте агента с ним.", + "dash.installOn": "Установка на сервере:", + "dash.failed": "Ошибка", + + // server card / server page + "server.open": "Открыть", + "server.uptime": "аптайм {value}", + "server.disk": "Диск {mount}", + "server.nodata": "данных пока нет — установите агента", + "server.back": "К дашборду", + "server.notFound": "Сервер не найден", + "server.notFoundText": "Возможно, он был удалён", + "server.rename": "Переименовать", + "server.renameTitle": "Переименовать сервер", + "server.serverName": "Имя сервера", + "server.delete": "Удалить", + "server.deleteTitle": "Удалить сервер?", + "server.deleteMessage": "«{name}» и вся его история метрик будут удалены навсегда.", + "server.infoTitle": "Инфо", + "server.noteTitle": "Заметка", + "server.serverNote": "Заметка о сервере", + "server.notePlaceholder": "Для чего сервер, что на нём работает. Без секретов :)", + "server.hostname": "Имя хоста", + "server.os": "ОС", + "server.kernel": "Ядро", + "server.ips": "IP-адреса", + "server.connIp": "IP подключения", + "server.interval": "Интервал агента", + "server.lastPacket": "Последний пакет", + "server.network": "Сеть", + "server.history": "История:", + "server.tabOverview": "Обзор", + "server.tabDisks": "Диски", + "server.tabNetwork": "Сеть", + "server.tabProcesses": "Процессы", + "server.tabDocker": "Docker", + "server.cpuRamChart": "CPU и RAM, %", + "server.diskChart": "Заполненность дисков, %", + "server.netChart": "Скорость сети, MB/s", + "server.colMount": "Точка монтирования", + "server.colSize": "Размер", + "server.colUsed": "Занято", + "server.colUsage": "Заполненность", + "server.colPid": "PID", + "server.colProcess": "Процесс", + "server.colStatus": "Статус", + "server.colContainer": "Контейнер", + "server.colImage": "Образ", + "server.noDisks": "В последнем пакете нет дисков", + "server.noProcesses": "Нет данных о процессах", + "server.noDocker": "Docker-контейнеры не найдены", + "server.of": "из {total}", + "server.noteSaved": "Заметка сохранена", + "server.renamed": "Переименовано", + "server.serverDeleted": "Сервер удалён", + "server.failedToSave": "Не удалось сохранить", + "server.failedToRename": "Не удалось переименовать", + "server.failedToDelete": "Не удалось удалить", + "server.historyFailed": "Не удалось загрузить историю", + + // storage + "storage.title": "Хранилища", + "storage.subtitle": "Сетевые шары, примонтированные к хосту панели", + "storage.addShare": "Добавить шару", + "storage.emptyTitle": "Шар пока нет", + "storage.emptyText": "Примонтируйте сетевой диск к хосту панели и добавьте его путь сюда — панель сама будет следить за заполненностью", + "storage.usedLabel": "Занято", + "storage.used": "занято", + "storage.free": "свободно", + "storage.total": "всего", + "storage.nodata": "нет данных — проверьте монтирование", + "storage.sampledAt": "замер: {time}", + "storage.notSampled": "замеров ещё не было", + "storage.historyTitle": "Заполненность за 24 ч, %", + "storage.addTitle": "Добавить сетевую шару", + "storage.name": "Название", + "storage.mountPath": "Путь монтирования на хосте панели", + "storage.shareAdded": "Шара добавлена", + "storage.deleteTitle": "Удалить шару?", + "storage.deleteMessage": "«{name}» будет убрана из мониторинга. Само монтирование и данные не затрагиваются.", + "storage.historyFailed": "Не удалось загрузить историю хранилищ", + "storage.shareDeleted": "Шара удалена", + + // settings + "settings.title": "Настройки", + "settings.languageToolbar": "Язык интерфейса", + "settings.languageDescription": "По умолчанию панель следует языку аккаунта gnexus-auth; выбор здесь перекрывает его только для GHard Monitor. «Авто» возвращает язык аккаунта.", + "settings.languageDescriptionOpen": "Панель открыта без аккаунта — выбор хранится в этом браузере.", + "settings.languageSaved": "Язык обновлён.", + + // language picker (native names — invariant across UI languages) + "profile.languageAuto": "Авто (из аккаунта)", + "profile.langEn": "English", + "profile.langUk": "Українська", + "profile.langRu": "Русский", + + // status (single source for card/page/storage badges) + "status.online": "в сети", + "status.offline": "оффлайн", + "status.waiting": "ожидание", + + // relative time (fmtAgo / fmtUptime) + "time.never": "никогда", + "time.now": "только что", + "time.secondsAgo": "{n} с назад", + "time.minutesAgo": "{n} мин назад", + "time.hoursAgo": "{n} ч назад", + "time.daysAgo": "{n} д назад", + "time.upDaysHours": "{d} д {h} ч", + "time.upHoursMinutes": "{h} ч {m} мин", + "time.upMinutesSeconds": "{m} мин {s} с", + + // common + "common.cancel": "Отмена", + "common.close": "Закрыть", + "common.copy": "Копировать", + "common.create": "Создать", + "common.save": "Сохранить", + "common.add": "Добавить", + "common.delete": "Удалить", + "common.once": "один раз", + "common.error": "Ошибка", + "common.failedToLoad": "Не удалось загрузить", +}; \ No newline at end of file diff --git a/panel/frontend/src/i18n/messages/uk.js b/panel/frontend/src/i18n/messages/uk.js new file mode 100644 index 0000000..c5131bf --- /dev/null +++ b/panel/frontend/src/i18n/messages/uk.js @@ -0,0 +1,151 @@ +// Ukrainian dictionary — same slugs as en.js; a missing slug falls back to English. +export default { + // app / shell / nav + "app.docTitle": "GHard Monitor", + "shell.title": "Моніторинг", + "nav.dashboard": "Дашборд", + "nav.storage": "Сховища", + "nav.settings": "Налаштування", + "nav.signOut": "Вийти", + + // login screen + "login.title": "Вхід", + "login.subtitle": "GHard Monitor захищений gnexus-auth", + "login.action": "Увійти", + + // dashboard + "dash.title": "Дашборд", + "dash.addServer": "Додати сервер", + "dash.summary": "{servers} · {online}", // лічильники збираються вкладеним t() + "dash.servers": { one: "{n} сервер", few: "{n} сервери", many: "{n} серверів" }, + "dash.online": "{m} у мережі", + "dash.emptyTitle": "Серверів ще немає", + "dash.emptyText": "Додайте перший сервер — панель видасть ключ для агента hard-monitor", + "dash.serverCreated": "Сервер створено", + "dash.newServer": "Новий сервер", + "dash.nameHint": "Ім'я потрібно лише панелі — ім'я хоста агент повідомляє сам.", + "dash.serverName": "Ім'я сервера", + "dash.keyHint": "Ключ показується {once} — збережіть його та налаштуйте агента з ним.", + "dash.installOn": "Встановлення на сервері:", + "dash.failed": "Помилка", + + // server card / server page + "server.open": "Відкрити", + "server.uptime": "аптайм {value}", + "server.disk": "Диск {mount}", + "server.nodata": "даних ще немає — встановіть агента", + "server.back": "До дашборду", + "server.notFound": "Сервер не знайдено", + "server.notFoundText": "Можливо, його було видалено", + "server.rename": "Перейменувати", + "server.renameTitle": "Перейменувати сервер", + "server.serverName": "Ім'я сервера", + "server.delete": "Видалити", + "server.deleteTitle": "Видалити сервер?", + "server.deleteMessage": "«{name}» і вся історія його метрик буде видалена назавжди.", + "server.infoTitle": "Інфо", + "server.noteTitle": "Нотатка", + "server.serverNote": "Нотатка про сервер", + "server.notePlaceholder": "Для чого сервер, що на ньому працює. Без секретів :)", + "server.hostname": "Ім'я хоста", + "server.os": "ОС", + "server.kernel": "Ядро", + "server.ips": "IP-адреси", + "server.connIp": "IP підключення", + "server.interval": "Інтервал агента", + "server.lastPacket": "Останній пакет", + "server.network": "Мережа", + "server.history": "Історія:", + "server.tabOverview": "Огляд", + "server.tabDisks": "Диски", + "server.tabNetwork": "Мережа", + "server.tabProcesses": "Процеси", + "server.tabDocker": "Docker", + "server.cpuRamChart": "CPU та RAM, %", + "server.diskChart": "Заповненість дисків, %", + "server.netChart": "Швидкість мережі, MB/s", + "server.colMount": "Точка монтування", + "server.colSize": "Розмір", + "server.colUsed": "Зайнято", + "server.colUsage": "Заповненість", + "server.colPid": "PID", + "server.colProcess": "Процес", + "server.colStatus": "Статус", + "server.colContainer": "Контейнер", + "server.colImage": "Образ", + "server.noDisks": "В останньому пакеті немає дисків", + "server.noProcesses": "Немає даних про процеси", + "server.noDocker": "Docker-контейнери не знайдено", + "server.of": "з {total}", + "server.noteSaved": "Нотатку збережено", + "server.renamed": "Перейменовано", + "server.serverDeleted": "Сервер видалено", + "server.failedToSave": "Не вдалося зберегти", + "server.failedToRename": "Не вдалося перейменувати", + "server.failedToDelete": "Не вдалося видалити", + "server.historyFailed": "Не вдалося завантажити історію", + + // storage + "storage.title": "Сховища", + "storage.subtitle": "Мережеві шари, примонтовані до хоста панелі", + "storage.addShare": "Додати шару", + "storage.emptyTitle": "Шар ще немає", + "storage.emptyText": "Примонтуйте мережевий диск до хоста панелі та додайте його шлях сюди — панель сама стежитиме за заповненістю", + "storage.usedLabel": "Зайнято", + "storage.used": "зайнято", + "storage.free": "вільно", + "storage.total": "всього", + "storage.nodata": "немає даних — перевірте монтування", + "storage.sampledAt": "замір: {time}", + "storage.notSampled": "замірів ще не було", + "storage.historyTitle": "Заповненість за 24 год, %", + "storage.addTitle": "Додати мережеву шару", + "storage.name": "Назва", + "storage.mountPath": "Шлях монтування на хості панелі", + "storage.shareAdded": "Шару додано", + "storage.deleteTitle": "Видалити шару?", + "storage.deleteMessage": "«{name}» буде прибрано з моніторингу. Саме монтування та дані не зачіпаються.", + "storage.historyFailed": "Не вдалося завантажити історію сховищ", + "storage.shareDeleted": "Шару видалено", + + // settings + "settings.title": "Налаштування", + "settings.languageToolbar": "Мова інтерфейсу", + "settings.languageDescription": "Усталено панель слідує мові акаунта gnexus-auth; вибір тут перекриває її лише для GHard Monitor. «Авто» повертає мову акаунта.", + "settings.languageDescriptionOpen": "Панель відкрита без акаунта — вибір зберігається у цьому браузері.", + "settings.languageSaved": "Мову оновлено.", + + // language picker (native names — invariant across UI languages) + "profile.languageAuto": "Авто (з акаунта)", + "profile.langEn": "English", + "profile.langUk": "Українська", + "profile.langRu": "Русский", + + // status (single source for card/page/storage badges) + "status.online": "у мережі", + "status.offline": "офлайн", + "status.waiting": "очікування", + + // relative time (fmtAgo / fmtUptime) + "time.never": "ніколи", + "time.now": "щойно", + "time.secondsAgo": "{n} с тому", + "time.minutesAgo": "{n} хв тому", + "time.hoursAgo": "{n} год тому", + "time.daysAgo": "{n} д тому", + "time.upDaysHours": "{d} д {h} год", + "time.upHoursMinutes": "{h} год {m} хв", + "time.upMinutesSeconds": "{m} хв {s} c", + + // common + "common.cancel": "Скасувати", + "common.close": "Закрити", + "common.copy": "Копіювати", + "common.create": "Створити", + "common.save": "Зберегти", + "common.add": "Додати", + "common.delete": "Видалити", + "common.once": "один раз", + "common.error": "Помилка", + "common.failedToLoad": "Не вдалося завантажити", +}; \ No newline at end of file diff --git a/panel/frontend/src/pages/DashboardPage.vue b/panel/frontend/src/pages/DashboardPage.vue index 54a6165..00192b7 100644 --- a/panel/frontend/src/pages/DashboardPage.vue +++ b/panel/frontend/src/pages/DashboardPage.vue @@ -8,6 +8,7 @@ import { useToast } from 'gnexus-ui-kit/vue' import { api } from '../api.js' +import { t } from '../i18n/index.js' import ServerCard from '../components/ServerCard.vue' const router = useRouter() @@ -38,7 +39,10 @@ const onlineCount = computed(() => servers.value.filter((s) => s.status === 'online').length) const subtitle = computed(() => - loading.value ? '' : `${servers.value.length} servers · ${onlineCount.value} online`, + loading.value ? '' : t('dash.summary', { + servers: t('dash.servers', { n: servers.value.length }), + online: t('dash.online', { m: onlineCount.value }), + }), ) function openServer(server) { @@ -66,7 +70,7 @@ createdKey.value = server.key await load() } catch (e) { - toast.danger({ title: 'Failed', text: e.message }) + toast.danger({ title: t('dash.failed'), text: e.message }) } finally { creating.value = false } @@ -74,9 +78,9 @@