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
- GHard Monitor is protected by gnexus-auth
- Sign in
+ {{ t('login.title') }}
+ {{ t('login.subtitle') }}
+ {{ 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"
>
@@ -133,7 +153,7 @@
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')),
+)
@@ -66,18 +63,18 @@
- no data yet — install the agent
+ {{ t('server.nodata') }}
{{ fmtMbs(metrics.net_in_mbs) }}
{{ fmtMbs(metrics.net_out_mbs) }}
- uptime {{ fmtUptime(metrics.uptime) }}
+ {{ t('server.uptime', { value: fmtUptime(metrics.uptime) }) }}
{{ server.note }}
@@ -86,7 +83,7 @@
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 @@
-
+
- Add server
+ {{ t('dash.addServer') }}
@@ -88,12 +92,12 @@
- Add server
+ {{ t('dash.addServer') }}
@@ -106,12 +110,12 @@
/>
-
+
- The name is for the panel only — the agent reports the hostname itself.
+ {{ t('dash.nameHint') }}
-
- The key is shown once — save it and configure the agent with it.
-
+ {{ t('dash.keyHint', { once: t('common.once') }) }}
{{ createdKey }}
-
+
- Install on the server:
+ {{ t('dash.installOn') }}
curl -fsSL https://git.gnexus.space/root/hard-panel/raw/branch/master/monitor/install.sh |
sudo bash -s -- https://your-panel {{ createdKey }} 30
- Close
+ {{ t('common.close') }}
- Create
+ {{ t('common.create') }}
diff --git a/panel/frontend/src/pages/ServerPage.vue b/panel/frontend/src/pages/ServerPage.vue
index aa1f4af..bbec7e6 100644
--- a/panel/frontend/src/pages/ServerPage.vue
+++ b/panel/frontend/src/pages/ServerPage.vue
@@ -12,6 +12,7 @@
import ChartLine from '../components/ChartLine.vue'
import { CHART_COLORS, SERIES_IN, SERIES_OUT } from '../chartTheme.js'
import { fmtAgo, fmtBytes, fmtGiB, fmtMbs, fmtPercent, fmtUptime, ramPercent } from '../format.js'
+import { t } from '../i18n/index.js'
const props = defineProps({ id: { type: String, required: true } })
const router = useRouter()
@@ -53,7 +54,7 @@
const since = new Date(Date.now() - periodHours.value * 3600 * 1000)
history.value = await api.metrics(props.id, since, null, 1500)
} catch (e) {
- if (e.status !== 401) toast.warning({ title: 'History failed to load', text: e.message })
+ if (e.status !== 401) toast.warning({ title: t('server.historyFailed'), text: e.message })
}
}
@@ -121,23 +122,24 @@
// --- Таблицы (последний снимок) ----------------------------------------------
-const diskColumns = [
- { key: 'mount', label: 'Mount point' },
- { key: 'total', label: 'Size' },
- { key: 'used', label: 'Used' },
- { key: 'percent', label: 'Usage' },
-]
-const processColumns = [
- { key: 'name', label: 'Process' },
- { key: 'pid', label: 'PID' },
+// Columns — computed: label из t() должен перерисовываться при смене языка
+const diskColumns = computed(() => [
+ { key: 'mount', label: t('server.colMount') },
+ { key: 'total', label: t('server.colSize') },
+ { key: 'used', label: t('server.colUsed') },
+ { key: 'percent', label: t('server.colUsage') },
+])
+const processColumns = computed(() => [
+ { key: 'name', label: t('server.colProcess') },
+ { key: 'pid', label: t('server.colPid') },
{ key: 'cpu', label: 'CPU %' },
{ key: 'mem', label: 'RAM %' },
-]
-const dockerColumns = [
- { key: 'name', label: 'Container' },
- { key: 'image', label: 'Image' },
- { key: 'status', label: 'Status' },
-]
+])
+const dockerColumns = computed(() => [
+ { key: 'name', label: t('server.colContainer') },
+ { key: 'image', label: t('server.colImage') },
+ { key: 'status', label: t('server.colStatus') },
+])
const dockerVariant = (status) => (status?.startsWith('Up') ? 'success' : 'danger')
@@ -147,13 +149,13 @@
const s = server.value
if (!s) return []
return [
- { term: 'Hostname', value: s.hostname || '—' },
- { term: 'OS', value: s.os || '—' },
- { term: 'Kernel', value: s.kernel || '—' },
- { term: 'IP addresses', value: s.ips?.length ? s.ips.join(', ') : '—' },
- { term: 'Connection IP', value: s.source_ip || '—' },
- { term: 'Agent interval', value: `${s.interval} s` },
- { term: 'Last packet', value: fmtAgo(s.last_seen) },
+ { term: t('server.hostname'), value: s.hostname || '—' },
+ { term: t('server.os'), value: s.os || '—' },
+ { term: t('server.kernel'), value: s.kernel || '—' },
+ { term: t('server.ips'), value: s.ips?.length ? s.ips.join(', ') : '—' },
+ { term: t('server.connIp'), value: s.source_ip || '—' },
+ { term: t('server.interval'), value: `${s.interval} s` },
+ { term: t('server.lastPacket'), value: fmtAgo(s.last_seen) },
]
})
@@ -175,9 +177,9 @@
await api.updateServer(props.id, { note: noteDraft.value })
server.value.note = noteDraft.value
noteDraft.value = null
- toast.success({ title: 'Note saved' })
+ toast.success({ title: t('server.noteSaved') })
} catch (e) {
- toast.danger({ title: 'Failed to save', text: e.message })
+ toast.danger({ title: t('server.failedToSave'), text: e.message })
} finally {
noteSaving.value = false
}
@@ -201,9 +203,9 @@
await api.updateServer(props.id, { name: nameDraft.value.trim() })
server.value.name = nameDraft.value.trim()
renameOpen.value = false
- toast.success({ title: 'Renamed' })
+ toast.success({ title: t('server.renamed') })
} catch (e) {
- toast.danger({ title: 'Failed to rename', text: e.message })
+ toast.danger({ title: t('server.failedToRename'), text: e.message })
} finally {
renameSaving.value = false
}
@@ -214,10 +216,10 @@
async function removeServer() {
try {
await api.deleteServer(props.id)
- toast.success({ title: 'Server deleted' })
+ toast.success({ title: t('server.serverDeleted') })
router.push('/')
} catch (e) {
- toast.danger({ title: 'Failed to delete', text: e.message })
+ toast.danger({ title: t('server.failedToDelete'), text: e.message })
}
}
@@ -228,12 +230,22 @@
default: return 'warning'
}
})
+
+const statusText = computed(() => t('status.' + (server.value?.status || 'waiting')))
+
+const tabItems = computed(() => [
+ { id: 'overview', label: t('server.tabOverview'), icon: 'ph-squares-four' },
+ { id: 'disks', label: t('server.tabDisks'), icon: 'ph-hard-drives' },
+ { id: 'net', label: t('server.tabNetwork'), icon: 'ph-arrows-down-up' },
+ { id: 'processes', label: t('server.tabProcesses'), icon: 'ph-list-dashes' },
+ { id: 'docker', label: t('server.tabDocker'), icon: 'ph-cube' },
+])
-
+
@@ -242,25 +254,23 @@
- Back to dashboard
+ {{ t('server.back') }}
-
- {{ server.status === 'online' ? 'online' : server.status === 'offline' ? 'offline' : 'waiting' }}
-
+ {{ statusText }}
- Rename
- Delete
+ {{ t('server.rename') }}
+ {{ t('server.delete') }}
@@ -269,11 +279,11 @@
-
-
+
+
-
+
-
+
- Save
+ {{ t('common.save') }}
- Cancel
+ {{ t('common.cancel') }}
@@ -317,7 +327,7 @@
- History:
+ {{ t('server.history') }}
-
+
-
+
-
+
@@ -354,7 +355,7 @@
{{ fmtGiB(value) }}
{{ fmtGiB(value) }}
@@ -369,7 +370,7 @@
-
+
@@ -379,7 +380,7 @@
@@ -387,7 +388,7 @@
{{ value }}
@@ -398,26 +399,26 @@
-
+
- Cancel
- Save
+ {{ t('common.cancel') }}
+ {{ t('common.save') }}
diff --git a/panel/frontend/src/pages/SettingsPage.vue b/panel/frontend/src/pages/SettingsPage.vue
new file mode 100644
index 0000000..1977abc
--- /dev/null
+++ b/panel/frontend/src/pages/SettingsPage.vue
@@ -0,0 +1,114 @@
+
+
+
+
+
+ {{ loadError }}
+
+
+
+
+
+ {{ description }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/panel/frontend/src/pages/StoragePage.vue b/panel/frontend/src/pages/StoragePage.vue
index 9a6be21..00c009e 100644
--- a/panel/frontend/src/pages/StoragePage.vue
+++ b/panel/frontend/src/pages/StoragePage.vue
@@ -10,6 +10,9 @@
import ChartLine from '../components/ChartLine.vue'
import { CHART_COLORS } from '../chartTheme.js'
import { fmtGiB } from '../format.js'
+import { t } from '../i18n/index.js'
+
+const statusText = (status) => t('status.' + (status || 'waiting'))
const toast = useToast()
@@ -28,7 +31,7 @@
await Promise.all(shares.value.map((sh) => loadSamples(sh.id)))
loading.value = false
} catch (e) {
- toast.danger({ title: 'Failed to load', text: e.message })
+ toast.danger({ title: t('common.failedToLoad'), text: e.message })
loading.value = false
}
}
@@ -45,7 +48,7 @@
})),
}
} catch (e) {
- toast.warning({ title: 'Storage history failed to load', text: e.message })
+ toast.warning({ title: t('storage.historyFailed'), text: e.message })
}
}
@@ -113,9 +116,9 @@
await api.createShare(newName.value.trim(), newPath.value.trim())
addOpen.value = false
await load()
- toast.success({ title: 'Share added' })
+ toast.success({ title: t('storage.shareAdded') })
} catch (e) {
- toast.danger({ title: 'Failed', text: e.message })
+ toast.danger({ title: t('dash.failed'), text: e.message })
} finally {
creating.value = false
}
@@ -132,10 +135,10 @@
async function removeShare() {
try {
await api.deleteShare(deleteTarget.value.id)
- toast.success({ title: 'Share deleted' })
+ toast.success({ title: t('storage.shareDeleted') })
await load()
} catch (e) {
- toast.danger({ title: 'Failed to delete', text: e.message })
+ toast.danger({ title: t('server.failedToDelete'), text: e.message })
} finally {
deleteOpen.value = false
}
@@ -143,9 +146,9 @@
-
+
- Add share
+ {{ t('storage.addShare') }}
@@ -155,12 +158,12 @@
- Add share
+ {{ t('storage.addShare') }}
@@ -171,7 +174,7 @@
{{ share.name }}
- {{ share.status === 'online' ? 'online' : share.status === 'offline' ? 'offline' : 'waiting' }}
+ {{ statusText(share.status) }}
@@ -180,47 +183,49 @@
- no data — check the mount
+ {{ t('storage.nodata') }}
- {{ fmtGiB(share.used) }} used
- {{ fmtGiB(share.total - share.used) }} free
- {{ fmtGiB(share.total) }} total
+ {{ fmtGiB(share.used) }} {{ t('storage.used') }}
+ {{ fmtGiB(share.total - share.used) }} {{ t('storage.free') }}
+ {{ fmtGiB(share.total) }} {{ t('storage.total') }}
-
+
-
-
+
+
- Cancel
+ {{ t('common.cancel') }}
- Add
+ {{ t('common.add') }}
@@ -228,10 +233,10 @@
diff --git a/panel/frontend/src/router.js b/panel/frontend/src/router.js
index ecb08f7..7296a78 100644
--- a/panel/frontend/src/router.js
+++ b/panel/frontend/src/router.js
@@ -14,6 +14,11 @@
component: () => import('./pages/StoragePage.vue'),
},
{
+ path: '/settings',
+ name: 'settings',
+ component: () => import('./pages/SettingsPage.vue'),
+ },
+ {
path: '/servers/:id',
name: 'server',
component: () => import('./pages/ServerPage.vue'),