chore(i18n,ui): 全站冗长文案精简 + shadcn 样板汉化 + 一致性修复

- 文案:约 25 组长文案中英同步精简(数据源引导/RBAC 注解/采集提示/删除确认等),三处重复 Ingress 教学收敛
- 汉化 shadcn 样板:分页 Previous/Next/aria、弹窗与抽屉 Close 接入 i18n(common.ui.*)
- 一致性:查询授权面板 p-4、仪表盘卡片 p-6、分析卡头 pb-3、部署总数图标配色、
  每日统计错误可重试、策略卡/最近部署行 truncate、状态徽章字重、三处原生 checkbox 换 shadcn、Label 浏览器圆角
- 词典:删除无引用的 raw.*/views.* 死键;zh 复数键风格统一(importCount/hits)
- 快捷查询提示、Ingress 统计口径与表内说明按反馈删减
This commit is contained in:
opsx committed 2026-10-28 22:44:06 +08:00
1 parent 2da0a2bece
commit 3a8eb521e2
34 files changed
+162 -240

No files matched your search

+2 -1
View File
@@ -1,3 +1,4 @@
import i18n from '@/i18n'
import * as React from "react" import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react"
@@ -70,7 +71,7 @@ function DialogContent({
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
> >
<XIcon /> <XIcon />
<span className="sr-only">Close</span> <span className="sr-only">{i18n.t('ui.close', { ns: 'common' })}</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>
+6 -5
View File
@@ -1,3 +1,4 @@
import i18n from '@/i18n'
import * as React from "react" import * as React from "react"
import { import {
ChevronLeftIcon, ChevronLeftIcon,
@@ -71,13 +72,13 @@ function PaginationPrevious({
}: React.ComponentProps<typeof PaginationLink>) { }: React.ComponentProps<typeof PaginationLink>) {
return ( return (
<PaginationLink <PaginationLink
aria-label="Go to previous page" aria-label={i18n.t('ui.goPrevPage', { ns: 'common' })}
size="default" size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)} className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props} {...props}
> >
<ChevronLeftIcon /> <ChevronLeftIcon />
<span className="hidden sm:block">Previous</span> <span className="hidden sm:block">{i18n.t('ui.prevPage', { ns: 'common' })}</span>
</PaginationLink> </PaginationLink>
) )
} }
@@ -88,12 +89,12 @@ function PaginationNext({
}: React.ComponentProps<typeof PaginationLink>) { }: React.ComponentProps<typeof PaginationLink>) {
return ( return (
<PaginationLink <PaginationLink
aria-label="Go to next page" aria-label={i18n.t('ui.goNextPage', { ns: 'common' })}
size="default" size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)} className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props} {...props}
> >
<span className="hidden sm:block">Next</span> <span className="hidden sm:block">{i18n.t('ui.nextPage', { ns: 'common' })}</span>
<ChevronRightIcon /> <ChevronRightIcon />
</PaginationLink> </PaginationLink>
) )
@@ -111,7 +112,7 @@ function PaginationEllipsis({
{...props} {...props}
> >
<MoreHorizontalIcon className="size-4" /> <MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span> <span className="sr-only">{i18n.t('ui.morePages', { ns: 'common' })}</span>
</span> </span>
) )
} }
+2 -1
View File
@@ -1,3 +1,4 @@
import i18n from '@/i18n'
import * as React from "react" import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog" import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react"
@@ -72,7 +73,7 @@ function SheetContent({
{children} {children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"> <SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" /> <XIcon className="size-4" />
<span className="sr-only">Close</span> <span className="sr-only">{i18n.t('ui.close', { ns: 'common' })}</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
+1 -1
View File
@@ -69,7 +69,7 @@ export default {
sourcePlaceholder: 'Select a data source', sourcePlaceholder: 'Select a data source',
query: 'LogQL query *', query: 'LogQL query *',
queryHint: queryHint:
'Common: {app="xxx"} |= "FATAL" / {namespace=~".+"} |~ "(?i)error" / {app="x"} |= "OutOfMemoryError"', 'Common: {app="xxx"} |= "FATAL"',
channels: 'Channels (multi-select to fan out to multiple alert groups)', channels: 'Channels (multi-select to fan out to multiple alert groups)',
noChannels: 'Create channels first under "Settings → Alert Notifications"', noChannels: 'Create channels first under "Settings → Alert Notifications"',
channelDisabled: '(disabled)', channelDisabled: '(disabled)',
+10 -1
View File
@@ -64,4 +64,13 @@ export default {
untagged: '(untagged)', untagged: '(untagged)',
empty: 'No app-dimension data', empty: 'No app-dimension data',
}, },
} ingress: {
title: 'Ingress access analysis',
noIngress: 'No ingress-nginx / ingress namespace access logs found on this source',
empty: 'No parseable ingress access logs in this time range',
loading: 'Loading ingress access logs…',
notDetected: 'No ingress-nginx detected',
toggleAria: 'Expand / collapse Ingress access analysis',
loadFailed: 'Failed to load ingress access logs',
},
}
+4 -4
View File
@@ -23,7 +23,7 @@ export default {
}, },
delete: { delete: {
warning: warning:
'Delete "{{name}}"? Warning: {{dep}} deployments and {{col}} collector policies still reference this cluster; they will stop working once it is deleted.', 'Delete "{{name}}"? {{dep}} deployment(s) and {{col}} collector policy(ies) still reference it.',
}, },
toast: { toast: {
deleteFailed: 'Failed to delete cluster', deleteFailed: 'Failed to delete cluster',
@@ -52,13 +52,13 @@ export default {
rbacScope: rbacScope:
'Grant permissions only in the <code>log</code> namespace so a leaked Token cannot affect other namespaces; <strong>do not use cluster-admin</strong>.', 'Grant permissions only in the <code>log</code> namespace so a leaked Token cannot affect other namespaces; <strong>do not use cluster-admin</strong>.',
rbacScriptIntro: rbacScriptIntro:
'Creates the <code>log</code> namespace, ServiceAccount, namespace-scoped Role and a long-lived Token in one shot (run it once in the cluster, then paste the Token into the field below):', 'Run once in the cluster to create the namespace / SA / Role / long-lived Token:',
rbacScriptComment1: '# 1) Create in one shot: namespace + SA + namespace-scoped Role + long-lived Token', rbacScriptComment1: '# 1) Create in one shot: namespace + SA + namespace-scoped Role + long-lived Token',
rbacScriptComment2: '# 2) Extract the long-lived Token (it does not expire) and paste it into the Token field', rbacScriptComment2: '# 2) Extract the long-lived Token (it does not expire) and paste it into the Token field',
rbacNamespaceNote: rbacNamespaceNote:
'The Namespace above is created by an administrator; the platform Token itself has no cluster-wide permissions. Only grant the <code>namespaces: create/get</code> ClusterRole if the platform really must create its own namespaces — note that namespace creation cannot be restricted by name, so pre-creating <code>log</code> is safer.', 'The token has no cluster-level rights; grant namespaces: create/get only if the platform must create its own namespace.',
rbacTokenNote: rbacTokenNote:
'The Token is long-lived but bound to a Role in the <code>log</code> namespace only (it can only touch Pods and workloads there), so a leak is limited to <code>log</code>. The server converts it to a kubeconfig and stores it encrypted with <code>ENCRYPTION_KEY</code>; the database keeps only <code>ENC:</code> ciphertext and the API never returns the plaintext. To rotate it, delete and recreate the Secret — the old Token stops working immediately.', 'Token is scoped to the log namespace with small blast radius; stored encrypted server-side. Rotate by deleting the Secret and recreating.',
contextLabel: 'Context (optional)', contextLabel: 'Context (optional)',
contextPlaceholder: 'Leave blank to use the default context', contextPlaceholder: 'Leave blank to use the default context',
submitAdd: 'Add Cluster', submitAdd: 'Add Cluster',
+5 -5
View File
@@ -77,7 +77,7 @@ const collectors = {
logPath: 'Log Path', logPath: 'Log Path',
logPathHostHint: 'Supports glob wildcards (e.g. /var/log/*.log /var/log/syslog); separate multiple paths with commas', logPathHostHint: 'Supports glob wildcards (e.g. /var/log/*.log /var/log/syslog); separate multiple paths with commas',
logPathSidecarHint: logPathSidecarHint:
'Enter the file path your app writes to, e.g. /var/log/app/*.log. Existing log volumes are shared automatically; without one, a shared volume is created only in the dedicated logs directory. Regular paths such as /tmp/test.log are not mounted automatically.', 'Paths the app writes to, e.g. /var/log/app/*.log; only existing log volumes or the dedicated logs directory are shared.',
labels: 'Custom Labels (JSON, optional)', labels: 'Custom Labels (JSON, optional)',
labelsHint: 'When valid JSON, attached as labels to every log line for filtering in Loki/Grafana; leave empty to skip', labelsHint: 'When valid JSON, attached as labels to every log line for filtering in Loki/Grafana; leave empty to skip',
submit: 'Create & Deploy', submit: 'Create & Deploy',
@@ -103,11 +103,11 @@ const collectors = {
nsNoMatch: 'No matches', nsNoMatch: 'No matches',
nsLoadFailed: 'Failed to load namespaces', nsLoadFailed: 'Failed to load namespaces',
nsHint: nsHint:
'Check "All namespaces" to collect cluster-wide; otherwise only the selected namespaces are collected. Generates the <code>kubernetes_sd_configs.namespaces</code> allowlist for Promtail.', '"All namespaces" collects cluster-wide; otherwise only the checked ones.',
appLabelSource: 'app label source', appLabelSource: 'app label source',
appSourcePlaceholder: 'Empty = default (controller-name fallback chain)', appSourcePlaceholder: 'Empty = default (controller-name fallback chain)',
appSourceHint: appSourceHint:
'By default falls back through <code>app.kubernetes.io/name → app → controller name → pod name</code>. If your cluster has its own convention label (e.g. <code>k8s.kuboard.cn/name</code>), enter it here and it takes the highest priority; the rest still serve as fallback.', 'Falls back through <code>app.kubernetes.io/name → app → controller name → pod name</code>; a cluster-specific label (e.g. <code>k8s.kuboard.cn/name</code>) set here takes priority.',
appSourcePreset: { appSourcePreset: {
default: 'Default: app.kubernetes.io/name → app → controller name → pod name', default: 'Default: app.kubernetes.io/name → app → controller name → pod name',
controllerName: 'controller_name (ReplicaSet name of the Pod, hash stripped = Deployment name)', controllerName: 'controller_name (ReplicaSet name of the Pod, hash stripped = Deployment name)',
@@ -118,8 +118,8 @@ const collectors = {
appInclude: 'Only collect these apps (optional)', appInclude: 'Only collect these apps (optional)',
appIncludePlaceholder: 'Empty = no limit; comma-separated, e.g. cex-spot-api, cex-asset-api', appIncludePlaceholder: 'Empty = no limit; comma-separated, e.g. cex-spot-api, cex-asset-api',
appIncludeHint: appIncludeHint:
'When set, Promtail keeps only logs whose app label matches these values (<code>action: keep</code>) — the most direct way to control log volume. Values must match the app resolved by the "app label source" above.', 'Keeps only logs whose app matches these values (<code>action: keep</code>) — the most direct way to cap log volume.',
logPathHint: 'The Sidecar requires the app container\'s shared log volume or the dedicated logs directory. Click "Deploy" after saving to apply the new configuration.', logPathHint: 'Sidecar needs a shared log volume or the dedicated logs directory; click Deploy after saving.',
save: 'Save', save: 'Save',
saving: 'Saving…', saving: 'Saving…',
validation: { validation: {
+8
View File
@@ -106,6 +106,14 @@ export default {
apply: 'Apply', apply: 'Apply',
recent: 'Recently used', recent: 'Recently used',
}, },
ui: {
close: 'Close',
prevPage: 'Previous',
nextPage: 'Next',
goPrevPage: 'Go to previous page',
goNextPage: 'Go to next page',
morePages: 'More pages',
},
password: { password: {
hide: 'Hide password', hide: 'Hide password',
show: 'Show password', show: 'Show password',
+3 -3
View File
@@ -10,7 +10,7 @@ export default {
deleteDockerDesc: deleteDockerDesc:
'Delete "{{name}}"? The Loki container on the host will be removed as well. This cannot be undone.', 'Delete "{{name}}"? The Loki container on the host will be removed as well. This cannot be undone.',
deleteRecordDesc: deleteRecordDesc:
'Delete "{{name}}"? Deleting the record does not uninstall the Loki resources already deployed on the cluster/host. This cannot be undone.', 'Deleting "{{name}}" does not uninstall deployed Loki resources; this cannot be undone.',
errLoadRefs: 'Failed to load cluster/host/storage data', errLoadRefs: 'Failed to load cluster/host/storage data',
errDeploy: 'Deployment failed', errDeploy: 'Deployment failed',
errStop: 'Failed to stop', errStop: 'Failed to stop',
@@ -36,7 +36,7 @@ export default {
ready: 'Loki ready', ready: 'Loki ready',
notReady: 'Loki not ready', notReady: 'Loki not ready',
runningHint: runningHint:
'Tip: link Loki under "Log Query → Data Sources" to start querying; for cross-cluster/public access, configure an Ingress domain for Loki first.', 'Link the Loki source under "Log Query → Data Sources" to query logs.',
configure: 'Configure', configure: 'Configure',
deploy: 'Deploy', deploy: 'Deploy',
deploying: 'Deploying...', deploying: 'Deploying...',
@@ -103,7 +103,7 @@ export default {
loading: 'Loading…', loading: 'Loading…',
templateNote: 'Generated by the deployment template', templateNote: 'Generated by the deployment template',
yamlLabel: 'Retention Config YAML', yamlLabel: 'Retention Config YAML',
yamlHint: 'Edit limits_config.retention_period, e.g. 90d or 2160h. Only this setting is accepted here.', yamlHint: 'Edit retention_period, e.g. 90d / 2160h; only this setting is accepted.',
applyHintRunning: applyHintRunning:
'Save only updates the deployment record; Save & Apply redeploys Loki and queries may be briefly interrupted.', 'Save only updates the deployment record; Save & Apply redeploys Loki and queries may be briefly interrupted.',
applyHintIdle: 'Takes effect on the next deployment.', applyHintIdle: 'Takes effect on the next deployment.',
+1 -1
View File
@@ -24,7 +24,7 @@ export default {
}, },
delete: { delete: {
warning: warning:
'Delete "{{name}}"? Warning: {{dep}} deployments and {{col}} collector policies still reference this host; they will stop working once it is deleted.', 'Delete "{{name}}"? {{dep}} deployment(s) and {{col}} collector policy(ies) still reference it.',
}, },
toast: { toast: {
deleteFailed: 'Failed to delete host', deleteFailed: 'Failed to delete host',
+28 -84
View File
@@ -8,7 +8,7 @@ export default {
query: 'Loki Query', query: 'Loki Query',
access: 'Query Access', access: 'Query Access',
sources: 'Data Sources', sources: 'Data Sources',
descQuery: 'Search by log stream and time range; drag the timeline to narrow the range, and click a log line to view its context.', descQuery: 'Search by stream selector and time range; drag the timeline or open line context.',
descAccess: 'Reuse log scopes with policy groups and attach multiple users at once.', descAccess: 'Reuse log scopes with policy groups and attach multiple users at once.',
descSources: 'Configure Loki connections and authentication.', descSources: 'Configure Loki connections and authentication.',
}, },
@@ -20,7 +20,7 @@ export default {
sourceAria: 'Data source', sourceAria: 'Data source',
sourceEmptyOption: 'No data sources available', sourceEmptyOption: 'No data sources available',
limitLabel: 'Per page', limitLabel: 'Per page',
limitHint: 'Up to 1000 entries per page; paging keeps only the current page. For wide ranges, check the timeline first, then drag to select a peak window.', limitHint: 'Up to 1000 entries per page; paging keeps only the current page.',
rangeInvalid: 'Select a valid start and end time', rangeInvalid: 'Select a valid start and end time',
queryFailed: 'Query failed', queryFailed: 'Query failed',
}, },
@@ -28,14 +28,28 @@ export default {
loadFailed: 'Failed to load data sources', loadFailed: 'Failed to load data sources',
missingTitle: 'No Loki data source available', missingTitle: 'No Loki data source available',
missingBody: missingBody:
'Link a Loki data source first under "Data Sources": for in-cluster Loki, pick "Cluster Service" (Namespace / Service / port, no Ingress needed); for cross-cluster or public queries, expose Loki via an Ingress domain first, then add it with "Direct URL". Queries are unavailable until a data source is linked.', 'Link a Loki data source first under "Data Sources": use "Cluster Service" in-cluster, or "Direct URL" via an Ingress domain.',
goConfigure: 'Go to data sources', goConfigure: 'Go to data sources',
sectionAria: 'Data source management', sectionAria: 'Data source management',
heading: 'Loki Data Sources', heading: 'Loki Data Sources',
hint: 'Tip: when Loki runs in the cluster, add a "Cluster Service" data source directly (no Ingress needed); for cross-cluster / public queries, expose Loki via an Ingress domain first, then link it with "Direct URL". Without a linked data source, the query page cannot be used.', hint: 'In-cluster Loki works with "Cluster Service"; cross-cluster / public access needs an Ingress domain plus "Direct URL".',
managedCluster: 'Cluster {{name}}', managedCluster: 'Cluster {{name}}',
managedBadge: 'Platform deployed · managed with the deployment', managedBadge: 'Platform deployed · managed with the deployment',
add: '+ Add manually', countLabel: '{{count}} total',
create: 'New source',
createTitle: 'New data source',
editTitle: 'Edit data source',
edit: 'Edit',
cancel: 'Cancel',
testing: 'Testing…',
deleteTitle: 'Delete data source',
deleteDesc: 'Deleting "{{name}}" breaks queries and access policies based on it. Delete it?',
deleteAria: 'Delete {{name}}',
defaultBadge: 'Default',
emptyTitle: 'No data sources yet',
emptyDesc: 'Add a "Cluster Service" or "Direct URL" source, then query logs on the Loki Query tab.',
typeLabel: 'Type',
namePlaceholder: 'prod-loki',
nameLabel: 'Name', nameLabel: 'Name',
targetUrl: 'Direct URL', targetUrl: 'Direct URL',
targetClusterSvc: 'Cluster Service', targetClusterSvc: 'Cluster Service',
@@ -62,17 +76,6 @@ export default {
saveFailed: 'Failed to save', saveFailed: 'Failed to save',
remove: 'Delete', remove: 'Delete',
}, },
range: {
lastMinutes: 'Last {{count}} minutes',
lastHours: 'Last {{count}} hours',
lastHours_one: 'Last {{count}} hour',
custom: 'Custom time',
title: 'Select time range',
start: 'Start time',
end: 'End time',
apply: 'Apply',
recent: 'Recently used',
},
editor: { editor: {
aria: 'LogQL query', aria: 'LogQL query',
valuesOf: 'Values of {{label}}', valuesOf: 'Values of {{label}}',
@@ -85,7 +88,7 @@ export default {
templates: { templates: {
aria: 'Quick query templates', aria: 'Quick query templates',
title: 'Quick LogQL builder', title: 'Quick LogQL builder',
hint: 'Pick a namespace, then an app, then a pod; app candidates come only from the selected namespace.', hint: 'Pick a namespace to narrow down; selecting an app / pod runs the query.',
namespace: 'Namespace', namespace: 'Namespace',
app: 'App', app: 'App',
appWithKey: 'App ({{key}})', appWithKey: 'App ({{key}})',
@@ -95,12 +98,10 @@ export default {
pickNsFirst: 'Pick a namespace first to narrow down', pickNsFirst: 'Pick a namespace first to narrow down',
noPod: 'No pods under this app', noPod: 'No pods under this app',
optional: 'Optional', optional: 'Optional',
scopeLogs: 'Query this scope',
errorLogs: 'Errors (error)', errorLogs: 'Errors (error)',
warnLogs: 'Warnings (warn)', warnLogs: 'Warnings (warn)',
podLogs: 'Specific pod logs',
ingressLogs: 'Ingress access logs', ingressLogs: 'Ingress access logs',
scopeHint: 'Pick a namespace, then an app or pod — or choose values in the Label browser', scopeHint: 'Selecting an app / pod runs the query — or pick values in the Label browser',
}, },
labels: { labels: {
browser: 'Label Browser', browser: 'Label Browser',
@@ -115,11 +116,11 @@ export default {
loading: 'Loading…', loading: 'Loading…',
noValues: 'No matching values', noValues: 'No matching values',
useAsValue: 'Use "{{value}}" as value', useAsValue: 'Use "{{value}}" as value',
hint: 'Select namespace first to query all replicas in that Namespace; then app / service_name to narrow to a service (workloads without an app label can rely on namespace alone). Candidates come from logs written to Loki within the current time range; the authorized scope is still enforced server-side.', hint: 'Pick namespace, then app / service_name to narrow down; candidates come from logs in the current range.',
}, },
volume: { volume: {
statsLoading: 'Counting logs across the whole time range; you can keep browsing the log page.', statsLoading: 'Counting logs across the whole time range; you can keep browsing the log page.',
statsError: 'Log volume statistics timed out or failed; the log page still works. Narrow the time range and query again.', statsError: 'Volume stats failed; narrow the range and retry.',
histogramLoading: 'Counting…', histogramLoading: 'Counting…',
histogramEmpty: 'No log volume data', histogramEmpty: 'No log volume data',
timelineAria: 'Log volume timeline', timelineAria: 'Log volume timeline',
@@ -136,16 +137,10 @@ export default {
summary_one: 'Page {{page}} · {{count}} entry', summary_one: 'Page {{page}} · {{count}} entry',
summaryMore: 'Page {{page}} · {{count}} entries · more available', summaryMore: 'Page {{page}} · {{count}} entries · more available',
summaryMore_one: 'Page {{page}} · {{count}} entry · more available', summaryMore_one: 'Page {{page}} · {{count}} entry · more available',
viewAria: 'Log display mode',
viewRaw: 'Raw',
viewTable: 'Table',
viewLine: 'Line',
viewStream: 'Stream',
viewPie: 'Pie',
loadingPage: 'Loading log page', loadingPage: 'Loading log page',
loadingQuery: 'Querying Loki', loadingQuery: 'Querying Loki',
loadingWait: 'Waiting for {{seconds}}s. Wide-range queries may take a while.', loadingWait: 'Waiting for {{seconds}}s. Wide-range queries may take a while.',
pagingHint: 'Logs are paged newest to oldest; charts show the current page, and the top timeline covers the whole query range.', pagingHint: 'Logs page newest-first; the top timeline covers the whole query range.',
domainAll: 'All domains', domainAll: 'All domains',
domainFilter: 'Domain filter', domainFilter: 'Domain filter',
domainPlaceholder: 'Type or pick a domain', domainPlaceholder: 'Type or pick a domain',
@@ -165,42 +160,6 @@ export default {
idleHint: 'You can also build queries quickly in the Label browser', idleHint: 'You can also build queries quickly in the Label browser',
retry: 'Query again', retry: 'Query again',
}, },
views: {
noDomain: 'No domain recorded',
otherDomain: 'Other domains',
unknown: 'Unknown',
untagged: 'Untagged',
other: 'Other',
tableLoaded: '{{count}} log entries loaded on this page.',
tableEmpty: 'No logs to display',
thTime: 'Time',
thNsPod: 'namespace / pod',
thLevel: 'Level',
thLog: 'Log',
lineByLoaded: 'Based on currently loaded logs; load more for additional results.',
lineByRange: 'Log volume aggregated over the query time range, from Loki.',
lineGroupAria: 'Line chart grouping',
lineByDomain: 'By domain',
lineByTotal: 'Total',
lineEmpty: 'No time-series data for this query',
lineSeriesTotal: 'Log count',
streamParsed: 'Showing {{count}} parsed entries',
streamByLevel: 'Showing {{count}} loaded entries by level',
streamSuffix: ', based on currently loaded results.',
streamGroupAria: 'Stream chart grouping',
streamByStatus: 'By HTTP status class',
streamEmpty: 'No log streams to display',
pieByAccess: 'Grouping {{count}} parsed entries by access fields',
pieByLabel: 'Grouping {{count}} loaded entries by label',
pieGroupAria: 'Pie chart grouping',
pieDomain: 'Domain (request Host)',
pieStatus: 'HTTP status',
pieMethod: 'Method',
piePath: 'Path',
pieUpstream: 'Upstream service',
pieLevel: 'Level',
pieEmpty: 'No label distribution to display',
},
ingress: { ingress: {
modesAria: 'Ingress statistics mode', modesAria: 'Ingress statistics mode',
modeTraffic: 'Endpoints PV / UV', modeTraffic: 'Endpoints PV / UV',
@@ -208,7 +167,7 @@ export default {
modeSlow: 'Top 10 slow requests', modeSlow: 'Top 10 slow requests',
modeDetail: 'Request details', modeDetail: 'Request details',
summary: summary:
'Based on the {{loaded}} entries loaded on this page, of which {{parsed}} are ingress-nginx access logs. Statistics are recomputed after paging; UV is deduplicated by non-empty client IP.', 'Based on {{loaded}} loaded entries',
statPv: 'PV', statPv: 'PV',
statUv: 'UV (IP)', statUv: 'UV (IP)',
statErrors: '4xx / 5xx', statErrors: '4xx / 5xx',
@@ -219,9 +178,9 @@ export default {
error4xx: '4xx only', error4xx: '4xx only',
slowThreshold: 'Slow request threshold', slowThreshold: 'Slow request threshold',
seconds: '{{count}}s', seconds: '{{count}}s',
slowHint: 'Sorted by request_time from the access log; 408 / 499 / 504 are only timeout hints and do not by themselves prove a backend timeout.', slowHint: 'Sorted by request_time; 408 / 499 / 504 are timeout hints.',
slowMissing: '{{count}} entries lack a valid duration.', slowMissing: '{{count}} entries lack a valid duration.',
trafficHint: 'Success rate = 2xx / 3xx requests ÷ PV; query strings are stripped from paths.', trafficHint: 'Success rate = 2xx / 3xx ÷ PV',
thHost: 'Request Host', thHost: 'Request Host',
thPath: 'Path', thPath: 'Path',
thSuccess: 'Success rate', thSuccess: 'Success rate',
@@ -257,7 +216,7 @@ export default {
loadFailed: 'Failed to load context', loadFailed: 'Failed to load context',
}, },
access: { access: {
labelsUnlimited: 'Unlimited · all namespaces', labelsUnlimited: 'Unrestricted · all namespaces',
loadFailed: 'Failed to load query policies', loadFailed: 'Failed to load query policies',
validate: 'Fill in the policy group name, choose a data source, and select "Anyone" or at least one user', validate: 'Fill in the policy group name, choose a data source, and select "Anyone" or at least one user',
saveFailed: 'Failed to save policy group', saveFailed: 'Failed to save policy group',
@@ -309,19 +268,4 @@ export default {
loading: 'Loading…', loading: 'Loading…',
noMatch: 'No matches', noMatch: 'No matches',
}, },
raw: {
searching: 'Querying…',
count: '{{count}} logs',
count_one: '{{count}} log',
dedupSuffix: ' (deduplicated from {{count}})',
dedup: 'Dedup',
empty: 'No matching logs',
original: 'Raw log',
labelsTitle: 'Labels (click to filter)',
filterValue: 'Filter this value',
excludeValue: 'Exclude this value',
fields: 'Extracted fields',
loadMore: 'Load more',
loadingMore: 'Loading…',
},
} }
+4 -4
View File
@@ -45,9 +45,9 @@ export default {
cardTitle: 'Alert Channels — Feishu (Lark)', cardTitle: 'Alert Channels — Feishu (Lark)',
create: 'New channel', create: 'New channel',
// Empty state is assembled as "prefix + link + suffix" (link points to the Alerts page) // Empty state is assembled as "prefix + link + suffix" (link points to the Alerts page)
emptyBefore: 'No channels yet — create one per alert group (e.g. "App alerts", "Nginx alerts"), then link rules to it on the ', emptyBefore: 'No channels yet — create one per alert group, ',
emptyLink: 'Alerts', emptyLink: 'Alerts',
emptyAfter: ' page', emptyAfter: 'then link rules to it on the alerts page.',
webhookConfigured: 'Webhook configured', webhookConfigured: 'Webhook configured',
webhookMissing: 'Not configured', webhookMissing: 'Not configured',
secretConfigured: ' · Signing secret configured', secretConfigured: ' · Signing secret configured',
@@ -82,7 +82,7 @@ export default {
headerHint: 'Enables LDAP sign-in and bulk user import', headerHint: 'Enables LDAP sign-in and bulk user import',
errLoad: 'Failed to load LDAP configuration', errLoad: 'Failed to load LDAP configuration',
disabledHint: disabledHint:
'LDAP is disabled — turn it on and fill in the directory details (server / Bind DN / Base DN). After saving, "Import from LDAP" appears on the Users page.', 'LDAP disabled — enable it and fill in the directory info to show "Import from LDAP" on the Users page.',
hostLabel: 'Server address *', hostLabel: 'Server address *',
portLabel: 'Port', portLabel: 'Port',
ldapsLabel: 'LDAPS', ldapsLabel: 'LDAPS',
@@ -133,7 +133,7 @@ export default {
timezone: 'Timezone', timezone: 'Timezone',
timezoneAria: 'Theme schedule timezone', timezoneAria: 'Theme schedule timezone',
browser: 'Follow browser (local)', browser: 'Follow browser (local)',
autoHint: 'Light from {{start}}:00 to {{end}}:00 in the selected timezone, dark otherwise; the sun/moon toggle overrides anytime.', autoHint: 'Light from {{start}}:00–{{end}}:00 in the chosen timezone, dark otherwise; the top bar toggle always works.',
}, },
prefsCard: { prefsCard: {
title: 'Appearance & Language', title: 'Appearance & Language',
+1 -1
View File
@@ -10,7 +10,7 @@ export default {
deleteWarning_one: deleteWarning_one:
'Delete "{{name}}"? Warning: {{count}} deployment still uses this storage; its object storage config will stop working.', 'Delete "{{name}}"? Warning: {{count}} deployment still uses this storage; its object storage config will stop working.',
deleteWarning_other: deleteWarning_other:
'Delete "{{name}}"? Warning: {{count}} deployments still use this storage; their object storage configs will stop working.', 'Delete "{{name}}"? {{count}} deployment(s) still use it; their object storage will break.',
errDelete: 'Failed to delete the storage config', errDelete: 'Failed to delete the storage config',
errSetDefault: 'Failed to set the default storage', errSetDefault: 'Failed to set the default storage',
}, },
+1 -1
View File
@@ -80,7 +80,7 @@ export default {
clear: 'Clear selection', clear: 'Clear selection',
selectAll: 'Select all', selectAll: 'Select all',
confirmTitle: 'Confirm Bulk Deletion', confirmTitle: 'Confirm Bulk Deletion',
confirmDesc: 'Delete {{count}} selected users? Protected accounts (your own / the last admin) will be skipped. This cannot be undone.', confirmDesc: 'Delete {{count}} selected users? Protected accounts are skipped; this cannot be undone.',
skippedWarn: '{{count}} skipped: {{reasons}}', skippedWarn: '{{count}} skipped: {{reasons}}',
failed: 'Bulk delete failed', failed: 'Bulk delete failed',
}, },
+2 -2
View File
@@ -32,7 +32,7 @@ const wizard = {
ready: 'Ready', ready: 'Ready',
connected: 'Connected: {{names}}', connected: 'Connected: {{names}}',
nameSep: ', ', nameSep: ', ',
k8sHint: 'Connect a cluster via kubeconfig or ServiceAccount token; connectivity is verified automatically on creation', k8sHint: 'Connect via kubeconfig or Token; connectivity is verified on create.',
addCluster: 'Add Cluster', addCluster: 'Add Cluster',
hostHint: 'Connect a host via SSH key or password (used for deployment and later collector installation)', hostHint: 'Connect a host via SSH key or password (used for deployment and later collector installation)',
addHost: 'Add Host', addHost: 'Add Host',
@@ -44,7 +44,7 @@ const wizard = {
manage: 'Manage Storage', manage: 'Manage Storage',
add: 'Add Storage', add: 'Add Storage',
configure: 'Configure Storage', configure: 'Configure Storage',
hint: 'Object storage (MinIO / RustFS / S3 / OSS) is recommended for more reliable replication and retention. Use "Test Connection" when adding to verify credentials and auto-create missing buckets; skip to use local disk.', hint: 'Object storage (MinIO / RustFS / S3 / OSS) recommended; skip to use local disk.',
}, },
deploy: { deploy: {
meta: '{{mode}} · Namespace {{namespace}} · {{endpoint}}', meta: '{{mode}} · Namespace {{namespace}} · {{endpoint}}',
+2 -2
View File
@@ -34,7 +34,7 @@ export default {
sent: '已发送', sent: '已发送',
cooldown: '冷却跳过', cooldown: '冷却跳过',
failed: '失败', failed: '失败',
hits_other: '{{count}} 条命中', hits: '{{count}} 条命中',
}, },
tpl: { tpl: {
nsError: { nsError: {
@@ -65,7 +65,7 @@ export default {
sourcePlaceholder: '选择数据源', sourcePlaceholder: '选择数据源',
query: 'LogQL 查询 *', query: 'LogQL 查询 *',
queryHint: queryHint:
'常用:{app="xxx"} |= "FATAL" / {namespace=~".+"} |~ "(?i)error" / {app="x"} |= "OutOfMemoryError"', '常用:{app="xxx"} |= "FATAL"',
channels: '通知通道(可多选,扇出到多个告警群)', channels: '通知通道(可多选,扇出到多个告警群)',
noChannels: '请先在「系统设置 → 告警通知」创建通道', noChannels: '请先在「系统设置 → 告警通知」创建通道',
channelDisabled: '(禁用)', channelDisabled: '(禁用)',
+10 -7
View File
@@ -1,7 +1,6 @@
// 日志分析页(admin)— 经 i18n/domains.ts 注册,页面入口 import '@/i18n/domains' // 日志分析页(admin)— 经 i18n/domains.ts 注册,页面入口 import '@/i18n/domains'
export default { export default {
title: '日志分析', title: '日志分析',
subtitle: '按应用与命名空间统计日志量、错误量与趋势,定位日志大户',
adminOnly: { adminOnly: {
title: '仅管理员可访问', title: '仅管理员可访问',
desc: '日志分析面向管理员提供全量日志统计,请联系管理员开通。', desc: '日志分析面向管理员提供全量日志统计,请联系管理员开通。',
@@ -24,7 +23,6 @@ export default {
}, },
trend: { trend: {
title: '日志量趋势', title: '日志量趋势',
subtitle: '总量 / 错误 / 告警 按时间分桶',
seriesTotal: '总量', seriesTotal: '总量',
seriesError: '错误', seriesError: '错误',
seriesWarn: '告警', seriesWarn: '告警',
@@ -32,25 +30,21 @@ export default {
}, },
stream: { stream: {
title: '应用日志量流图', title: '应用日志量流图',
subtitle: '日志量 Top {{count}} 应用随时间的堆叠分布',
empty: '没有应用维度的数据', empty: '没有应用维度的数据',
}, },
pie: { pie: {
title: '命名空间分布', title: '命名空间分布',
subtitle: '按命名空间统计的日志量占比',
empty: '没有命名空间维度的数据', empty: '没有命名空间维度的数据',
other: '其他', other: '其他',
}, },
daily: { daily: {
fixedBadge: '固定近 7 天', fixedBadge: '固定近 7 天',
title: '应用每日日志量', title: '应用每日日志量',
subtitle: '固定统计近 7 个自然日的 Top 应用分布,不受上方时间范围影响',
empty: '近 7 天没有应用维度的数据', empty: '近 7 天没有应用维度的数据',
}, },
table: { table: {
title: '应用日志量排行', title: '应用日志量排行',
subtitle: '按日志量排序的 Top 10 应用,点击「查看日志」跳转查询页',
rank: '#', rank: '#',
app: '应用', app: '应用',
appWithKey: '应用({{key}})', appWithKey: '应用({{key}})',
@@ -64,4 +58,13 @@ export default {
untagged: '(未打标签)', untagged: '(未打标签)',
empty: '没有应用维度的数据', empty: '没有应用维度的数据',
}, },
} ingress: {
title: 'Ingress 访问分析',
noIngress: '当前数据源未发现 ingress-nginx / ingress 命名空间的访问日志',
empty: '该时间范围内没有可解析的 ingress 访问日志',
loading: '加载 ingress 访问日志中…',
notDetected: '未检测到 ingress-nginx',
toggleAria: '展开 / 折叠 Ingress 访问分析',
loadFailed: '加载 ingress 访问日志失败',
},
}
+3 -3
View File
@@ -51,13 +51,13 @@ export default {
rbacScope: rbacScope:
'只授予 <code>log</code> 命名空间权限,Token 泄露也不会影响其他命名空间;<strong>不要使用 cluster-admin</strong>。', '只授予 <code>log</code> 命名空间权限,Token 泄露也不会影响其他命名空间;<strong>不要使用 cluster-admin</strong>。',
rbacScriptIntro: rbacScriptIntro:
'一次性创建 <code>log</code> 命名空间、ServiceAccount、命名空间级 Role 和长期 Token(在集群里执行一次,Token 粘贴到下方输入框):', '在集群执行一次,创建命名空间 / SA / Role / 长期 Token:',
rbacScriptComment1: '# 1) 一次创建:命名空间 + SA + 命名空间级 Role + 长期 Token', rbacScriptComment1: '# 1) 一次创建:命名空间 + SA + 命名空间级 Role + 长期 Token',
rbacScriptComment2: '# 2) 取出长期 Token(不自动过期),粘贴到 Token 输入框', rbacScriptComment2: '# 2) 取出长期 Token(不自动过期),粘贴到 Token 输入框',
rbacNamespaceNote: rbacNamespaceNote:
'上面的 Namespace 由管理员创建,平台 Token 本身没有任何集群级权限。只有确实需要平台自建命名空间时,才额外授予 <code>namespaces: create/get</code> 的 ClusterRole;注意 namespace 的 create 无法按名称限制,所以预建 <code>log</code> 更安全。', 'Token 无集群级权限;仅当需要平台自建命名空间时才额外授予 namespaces: create/get。',
rbacTokenNote: rbacTokenNote:
'Token 长期有效,但只绑定 <code>log</code> 命名空间的 Role(仅能操作该命名空间的 Pod / 工作负载),泄露影响面仅限于 <code>log</code>;服务端会转成 kubeconfig 并用 <code>ENCRYPTION_KEY</code> 加密存储,数据库里只有 <code>ENC:</code> 密文,接口不返回明文。轮换时删除该 Secret 重建即可,旧 Token 立即失效。', 'Token 仅限 log 命名空间,泄露影响面小;服务端加密存储。轮换:删除该 Secret 重建。',
contextLabel: '上下文(可选)', contextLabel: '上下文(可选)',
contextPlaceholder: '留空则使用默认上下文', contextPlaceholder: '留空则使用默认上下文',
submitAdd: '添加集群', submitAdd: '添加集群',
+5 -5
View File
@@ -80,7 +80,7 @@ const collectors = {
logPath: '采集日志路径', logPath: '采集日志路径',
logPathHostHint: '支持 glob 通配(如 /var/log/*.log /var/log/syslog);多个路径用逗号分隔', logPathHostHint: '支持 glob 通配(如 /var/log/*.log /var/log/syslog);多个路径用逗号分隔',
logPathSidecarHint: logPathSidecarHint:
'填写业务写入的文件路径,如 /var/log/app/*.log。已有日志卷会自动共享;无日志卷时仅在专用 logs 目录创建共享卷。/tmp/test.log 等普通目录不会自动挂载。', '填业务写入的路径,如 /var/log/app/*.log;仅共享已有日志卷或专用 logs 目录。',
labels: '自定义标签(JSON,可选)', labels: '自定义标签(JSON,可选)',
labelsHint: '合法 JSON 时将附加到每条日志的标签,便于在 Loki/Grafana 中筛选;留空则不加', labelsHint: '合法 JSON 时将附加到每条日志的标签,便于在 Loki/Grafana 中筛选;留空则不加',
submit: '创建并部署', submit: '创建并部署',
@@ -107,11 +107,11 @@ const collectors = {
nsLoadFailed: '加载命名空间失败', nsLoadFailed: '加载命名空间失败',
// nsHint/appSourceHint/appIncludeHint 含行内 <code>,渲染处用 Trans 组件 // nsHint/appSourceHint/appIncludeHint 含行内 <code>,渲染处用 Trans 组件
nsHint: nsHint:
'勾「所有命名空间」= 采集全集群;否则只采集勾选的命名空间。生成 Promtail 的 <code>kubernetes_sd_configs.namespaces</code> 白名单。', '勾「所有命名空间」= 采集全集群;否则只采勾选项。',
appLabelSource: 'app 标签取值来源', appLabelSource: 'app 标签取值来源',
appSourcePlaceholder: '留空 = 默认(controller 名回退链)', appSourcePlaceholder: '留空 = 默认(controller 名回退链)',
appSourceHint: appSourceHint:
'默认按 <code>app.kubernetes.io/name → app → controller 名 → pod 名</code> 四级回退。若集群有自己的私有规范标签(如 <code>k8s.kuboard.cn/name</code>),填在这里会作为最高优先级,其余仍兜底。', '默认按 <code>app.kubernetes.io/name → app → controller 名 → pod 名</code> 回退;集群自定义标签(如 <code>k8s.kuboard.cn/name</code>)填此处优先。',
appSourcePreset: { appSourcePreset: {
default: '默认:app.kubernetes.io/name → app → controller 名 → pod 名', default: '默认:app.kubernetes.io/name → app → controller 名 → pod 名',
controllerName: 'controller_name(Pod 的 ReplicaSet 名,去 hash = Deployment 名)', controllerName: 'controller_name(Pod 的 ReplicaSet 名,去 hash = Deployment 名)',
@@ -122,8 +122,8 @@ const collectors = {
appInclude: '只采集这些 app(可选)', appInclude: '只采集这些 app(可选)',
appIncludePlaceholder: '留空 = 不限;多个用逗号分隔,如 cex-spot-api, cex-asset-api', appIncludePlaceholder: '留空 = 不限;多个用逗号分隔,如 cex-spot-api, cex-asset-api',
appIncludeHint: appIncludeHint:
'填了之后 Promtail 只保留 app 命中这些值的日志(<code>action: keep</code>),是控制日志量最直接的手段。值需与上面「app 标签取值来源」解析出的 app 一致。', '只保留 app 命中这些值的日志(<code>action: keep</code>),是控制日志量最直接的手段。',
logPathHint: 'Sidecar 需使用业务容器的共享日志卷,或专用 logs 目录。保存后点击「部署」应用新配置。', logPathHint: 'Sidecar 需共享日志卷或专用 logs 目录;保存后点「部署」生效。',
save: '保存', save: '保存',
saving: '保存中…', saving: '保存中…',
validation: { validation: {
+8
View File
@@ -105,6 +105,14 @@ export default {
apply: '应用时间', apply: '应用时间',
recent: '最近使用', recent: '最近使用',
}, },
ui: {
close: '关闭',
prevPage: '上一页',
nextPage: '下一页',
goPrevPage: '前往上一页',
goNextPage: '前往下一页',
morePages: '更多页码',
},
password: { password: {
hide: '隐藏密码', hide: '隐藏密码',
show: '显示密码', show: '显示密码',
+3 -3
View File
@@ -9,7 +9,7 @@ export default {
// 删除确认:host-docker 连容器一起删,其余模式仅删记录不卸载资源 // 删除确认:host-docker 连容器一起删,其余模式仅删记录不卸载资源
deleteDockerDesc: '确定要删除「{{name}}」吗?将同时移除主机上的 Loki 容器,此操作不可恢复。', deleteDockerDesc: '确定要删除「{{name}}」吗?将同时移除主机上的 Loki 容器,此操作不可恢复。',
deleteRecordDesc: deleteRecordDesc:
'确定要删除「{{name}}」吗?删除记录不会自动卸载集群/主机上已部署的 Loki 资源,此操作不可恢复。', '删除「{{name}}」不会卸载已部署的 Loki 资源,此操作不可恢复。',
errLoadRefs: '加载集群/主机/存储数据失败', errLoadRefs: '加载集群/主机/存储数据失败',
errDeploy: '部署失败', errDeploy: '部署失败',
errStop: '停止失败', errStop: '停止失败',
@@ -35,7 +35,7 @@ export default {
ready: 'Loki 就绪', ready: 'Loki 就绪',
notReady: 'Loki 未就绪', notReady: 'Loki 未就绪',
runningHint: runningHint:
'提示:到「日志查询 → 数据源」关联 Loki 后即可查询;跨集群/公网访问需先为 Loki 配置 Ingress 域名。', '到「日志查询 → 数据源」关联 Loki 后即可查询。',
configure: '配置', configure: '配置',
deploy: '部署', deploy: '部署',
deploying: '部署中...', deploying: '部署中...',
@@ -101,7 +101,7 @@ export default {
loading: '读取中…', loading: '读取中…',
templateNote: '由部署模板生成', templateNote: '由部署模板生成',
yamlLabel: '保留配置 YAML', yamlLabel: '保留配置 YAML',
yamlHint: '编辑 limits_config.retention_period,例如 90d 或 2160h。此处只接受这一项配置。', yamlHint: '编辑 retention_period,如 90d / 2160h;仅支持这一项。',
applyHintRunning: '仅保存会更新部署记录;保存并应用会重新部署 Loki,查询可能短暂中断。', applyHintRunning: '仅保存会更新部署记录;保存并应用会重新部署 Loki,查询可能短暂中断。',
applyHintIdle: '保存后在下次部署时生效。', applyHintIdle: '保存后在下次部署时生效。',
cancel: '取消', cancel: '取消',
+1 -1
View File
@@ -24,7 +24,7 @@ export default {
}, },
delete: { delete: {
warning: warning:
'确定要删除「{{name}}」吗?警告:仍有 {{dep}} 个部署、{{col}} 个采集策略引用该主机,删除后它们将无法工作。', '确定删除「{{name}}」吗?仍有 {{dep}} 个部署、{{col}} 个采集策略在引用。',
}, },
toast: { toast: {
deleteFailed: '删除主机失败', deleteFailed: '删除主机失败',
+27 -81
View File
@@ -7,7 +7,7 @@ export default {
query: 'Loki 查询', query: 'Loki 查询',
access: '查询授权', access: '查询授权',
sources: '数据源管理', sources: '数据源管理',
descQuery: '按日志流和时间范围检索;拖动时间线缩小范围,点日志查看前后文。', descQuery: '按日志流与时间范围检索,可拖时间线、看前后文。',
descAccess: '用策略组复用日志范围,并一次关联多个用户。', descAccess: '用策略组复用日志范围,并一次关联多个用户。',
descSources: '配置 Loki 连接与认证方式。', descSources: '配置 Loki 连接与认证方式。',
}, },
@@ -19,7 +19,7 @@ export default {
sourceAria: '数据源', sourceAria: '数据源',
sourceEmptyOption: '暂无可用数据源', sourceEmptyOption: '暂无可用数据源',
limitLabel: '每页条数', limitLabel: '每页条数',
limitHint: '每页最多 1000 条;翻页只保留当前页。大范围先看时间线,再拖选高峰时段。', limitHint: '每页最多 1000 条,翻页仅保留当前页。',
rangeInvalid: '请选择有效的起止时间', rangeInvalid: '请选择有效的起止时间',
queryFailed: '查询失败', queryFailed: '查询失败',
}, },
@@ -27,14 +27,28 @@ export default {
loadFailed: '加载数据源失败', loadFailed: '加载数据源失败',
missingTitle: '还没有可用的 Loki 数据源', missingTitle: '还没有可用的 Loki 数据源',
missingBody: missingBody:
'请先到「数据源管理」关联 Loki:集群内可直接选「集群服务」(命名空间 / Service / 端口,无需 Ingress);跨集群或公网查询,请先给 Loki 配置 Ingress 域名,再用「URL 直连」填写该域名。未关联数据源无法查询。', '请先到「数据源管理」关联 Loki:集群内选「集群服务」,跨集群 / 公网用「URL 直连」。',
goConfigure: '去配置数据源', goConfigure: '去配置数据源',
sectionAria: '数据源管理', sectionAria: '数据源管理',
heading: 'Loki 数据源', heading: 'Loki 数据源',
hint: '提示:Loki 部署在集群内时,可直接添加「集群服务」数据源(无需 Ingress);跨集群 / 公网查询需先为 Loki 配置 Ingress 域名,再用「URL 直连」关联。未关联数据源时,查询页无法使用。', hint: '集群内 Loki 选「集群服务」即可;跨集群 / 公网需先配 Ingress 域名,再用「URL 直连」。',
managedCluster: '集群 {{name}}', managedCluster: '集群 {{name}}',
managedBadge: '平台部署 · 随部署管理', managedBadge: '平台部署 · 随部署管理',
add: '+ 手动添加', countLabel: '共 {{count}} 个',
create: '新建数据源',
createTitle: '新建数据源',
editTitle: '编辑数据源',
edit: '编辑',
cancel: '取消',
testing: '测试中…',
deleteTitle: '删除数据源',
deleteDesc: '删除「{{name}}」后,基于它的查询与授权策略将失效,确定删除吗?',
deleteAria: '删除 {{name}}',
defaultBadge: '默认',
emptyTitle: '还没有数据源',
emptyDesc: '新建「集群服务」或「URL 直连」数据源后,即可在查询页检索日志。',
typeLabel: '类型',
namePlaceholder: 'prod-loki',
nameLabel: '名称', nameLabel: '名称',
targetUrl: 'URL 直连', targetUrl: 'URL 直连',
targetClusterSvc: '集群服务', targetClusterSvc: '集群服务',
@@ -61,16 +75,6 @@ export default {
saveFailed: '保存失败', saveFailed: '保存失败',
remove: '删除', remove: '删除',
}, },
range: {
lastMinutes: '最近 {{count}} 分钟',
lastHours: '最近 {{count}} 小时',
custom: '自定义时间',
title: '选择时间范围',
start: '开始时间',
end: '结束时间',
apply: '应用时间',
recent: '最近使用',
},
editor: { editor: {
aria: 'LogQL 查询语句', aria: 'LogQL 查询语句',
valuesOf: '{{label}} 的值', valuesOf: '{{label}} 的值',
@@ -83,7 +87,7 @@ export default {
templates: { templates: {
aria: '快捷查询模板', aria: '快捷查询模板',
title: '快捷生成 LogQL', title: '快捷生成 LogQL',
hint: '先选 namespace,再选 app,最后选副本 pod;app 候选只来自所选命名空间。', hint: '先选 namespace 缩小范围;选中 app / pod 自动查询。',
namespace: '命名空间', namespace: '命名空间',
app: 'App', app: 'App',
appWithKey: 'App({{key}})', appWithKey: 'App({{key}})',
@@ -93,12 +97,10 @@ export default {
pickNsFirst: '先选命名空间可缩小范围', pickNsFirst: '先选命名空间可缩小范围',
noPod: '该 app 下无副本', noPod: '该 app 下无副本',
optional: '可选', optional: '可选',
scopeLogs: '查询该范围日志',
errorLogs: '错误 error', errorLogs: '错误 error',
warnLogs: '告警 warn', warnLogs: '告警 warn',
podLogs: '指定副本日志',
ingressLogs: 'Ingress 访问日志', ingressLogs: 'Ingress 访问日志',
scopeHint: '先选 namespace,再选 app 或 pod,也可在 Label 浏览器选值', scopeHint: '选 app / pod 即自动查询;也可在 Label 浏览器选值',
}, },
labels: { labels: {
browser: 'Label 浏览器', browser: 'Label 浏览器',
@@ -113,11 +115,11 @@ export default {
loading: '加载中…', loading: '加载中…',
noValues: '没有匹配值', noValues: '没有匹配值',
useAsValue: '使用“{{value}}”作为值', useAsValue: '使用“{{value}}”作为值',
hint: '先选 namespace 可查该命名空间全部副本;再选 app / service_name 可缩小到某个服务(无 app 标签的工作负载用 namespace 即可)。候选来自当前时间范围内已写入 Loki 的日志;授权范围仍由服务端限制。', hint: '先选 namespace,再选 app / service_name 逐步缩小;候选为当前时间范围内的日志。',
}, },
volume: { volume: {
statsLoading: '正在统计整个时间范围的日志量,日志页可继续浏览。', statsLoading: '正在统计整个时间范围的日志量,日志页可继续浏览。',
statsError: '日志量统计超时或失败;日志页仍可使用。可缩小时间范围后重新查询。', statsError: '日志量统计失败;可缩小时间范围后重试。',
histogramLoading: '统计中…', histogramLoading: '统计中…',
histogramEmpty: '暂无日志量数据', histogramEmpty: '暂无日志量数据',
timelineAria: '日志量时间线', timelineAria: '日志量时间线',
@@ -131,16 +133,10 @@ export default {
title: '日志结果', title: '日志结果',
summary: '第 {{page}} 页 · 本页 {{count}} 条', summary: '第 {{page}} 页 · 本页 {{count}} 条',
summaryMore: '第 {{page}} 页 · 本页 {{count}} 条 · 还有更多', summaryMore: '第 {{page}} 页 · 本页 {{count}} 条 · 还有更多',
viewAria: '日志展示方式',
viewRaw: '原始日志',
viewTable: '表格',
viewLine: '折线图',
viewStream: '流图',
viewPie: '饼图',
loadingPage: '正在加载日志页', loadingPage: '正在加载日志页',
loadingQuery: '正在查询 Loki', loadingQuery: '正在查询 Loki',
loadingWait: '已等待 {{seconds}} 秒。大范围查询可能需要较长时间。', loadingWait: '已等待 {{seconds}} 秒。大范围查询可能需要较长时间。',
pagingHint: '日志按时间从新到旧翻页;图表展示当前页,顶部时间线展示整个查询范围。', pagingHint: '日志按时间从新到旧翻页;顶部时间线为整个查询范围。',
domainAll: '全部域名', domainAll: '全部域名',
domainFilter: '域名筛选', domainFilter: '域名筛选',
domainPlaceholder: '输入或选择域名', domainPlaceholder: '输入或选择域名',
@@ -160,42 +156,6 @@ export default {
idleHint: '也可以使用 Label 浏览器快速构建查询', idleHint: '也可以使用 Label 浏览器快速构建查询',
retry: '重新查询', retry: '重新查询',
}, },
views: {
noDomain: '未记录域名',
otherDomain: '其他域名',
unknown: '未知',
untagged: '未标记',
other: '其他',
tableLoaded: '当前页已加载 {{count}} 条日志。',
tableEmpty: '暂无可展示的日志',
thTime: '时间',
thNsPod: 'namespace / pod',
thLevel: '级别',
thLog: '日志',
lineByLoaded: '按当前已加载日志统计;更多结果请继续加载。',
lineByRange: '按查询时间范围统计的日志量,来自 Loki 时间聚合。',
lineGroupAria: '折线图分组字段',
lineByDomain: '按域名',
lineByTotal: '总量',
lineEmpty: '当前查询没有可用的时间统计',
lineSeriesTotal: '日志条数',
streamParsed: '展示 {{count}} 条已解析日志',
streamByLevel: '按级别展示当前已加载的 {{count}} 条日志',
streamSuffix: ',基于当前已加载结果。',
streamGroupAria: '流图分组字段',
streamByStatus: '按 HTTP 状态段',
streamEmpty: '暂无可展示的日志流',
pieByAccess: '按访问字段统计已解析的 {{count}} 条日志',
pieByLabel: '按标签统计当前已加载的 {{count}} 条日志',
pieGroupAria: '饼图分组字段',
pieDomain: '域名(请求 Host)',
pieStatus: 'HTTP 状态码',
pieMethod: '请求方法',
piePath: '请求路径',
pieUpstream: '上游服务',
pieLevel: '级别',
pieEmpty: '暂无可展示的标签分布',
},
ingress: { ingress: {
modesAria: 'Ingress 统计方式', modesAria: 'Ingress 统计方式',
modeTraffic: '接口 PV / UV', modeTraffic: '接口 PV / UV',
@@ -203,7 +163,7 @@ export default {
modeSlow: '慢请求 Top 10', modeSlow: '慢请求 Top 10',
modeDetail: '请求明细', modeDetail: '请求明细',
summary: summary:
'仅统计当前页已加载的 {{loaded}} 条日志,其中 {{parsed}} 条为 ingress-nginx 访问日志。翻页后重新统计;UV 按非空客户端 IP 去重。', '基于已加载的 {{loaded}} 条日志统计',
statPv: 'PV', statPv: 'PV',
statUv: 'UV(IP)', statUv: 'UV(IP)',
statErrors: '4xx / 5xx', statErrors: '4xx / 5xx',
@@ -214,9 +174,9 @@ export default {
error4xx: '只看 4xx', error4xx: '只看 4xx',
slowThreshold: '慢请求阈值', slowThreshold: '慢请求阈值',
seconds: '{{count}} 秒', seconds: '{{count}} 秒',
slowHint: '依据 access log 的 request_time 排序;408 / 499 / 504 仅作超时线索,不能单凭状态码断定后端超时。', slowHint: '按 request_time 排序;408 / 499 / 504 为超时线索。',
slowMissing: '{{count}} 条缺少有效耗时。', slowMissing: '{{count}} 条缺少有效耗时。',
trafficHint: '访问成功率按 2xx / 3xx 请求数 ÷ PV 计算;路径已去掉查询参数。', trafficHint: '成功率 = 2xx / 3xx ÷ PV',
thHost: '请求 Host', thHost: '请求 Host',
thPath: '接口路径', thPath: '接口路径',
thSuccess: '成功率', thSuccess: '成功率',
@@ -301,18 +261,4 @@ export default {
loading: '加载中…', loading: '加载中…',
noMatch: '无匹配值', noMatch: '无匹配值',
}, },
raw: {
searching: '查询中…',
count: '{{count}} 条',
dedupSuffix: '(去重后,原 {{count}} 条)',
dedup: '去重',
empty: '没有匹配的日志',
original: '日志原文',
labelsTitle: '标签(点击过滤)',
filterValue: '过滤此值',
excludeValue: '排除此值',
fields: '提取字段',
loadMore: '加载更多',
loadingMore: '加载中…',
},
} }
+4 -4
View File
@@ -45,9 +45,9 @@ export default {
cardTitle: '告警通知通道(飞书/Lark)', cardTitle: '告警通知通道(飞书/Lark)',
create: '新建通道', create: '新建通道',
// 空态文案按「前段 + 链接 + 后段」拼接(链接指向日志告警页) // 空态文案按「前段 + 链接 + 后段」拼接(链接指向日志告警页)
emptyBefore: '尚无通知通道 —— 每个告警群创建一个通道(如「应用告警群」「Nginx 告警群」),在', emptyBefore: '尚无通知通道 —— 每个告警群建一个通道,',
emptyLink: '日志告警', emptyLink: '日志告警',
emptyAfter: '页把规则关联到对应通道', emptyAfter: '在告警规则页把规则关联到对应通道。',
webhookConfigured: 'Webhook 已配置', webhookConfigured: 'Webhook 已配置',
webhookMissing: '未配置', webhookMissing: '未配置',
secretConfigured: ' · 签名已配置', secretConfigured: ' · 签名已配置',
@@ -82,7 +82,7 @@ export default {
headerHint: '启用后支持 LDAP 登录与批量导入', headerHint: '启用后支持 LDAP 登录与批量导入',
errLoad: '加载 LDAP 配置失败', errLoad: '加载 LDAP 配置失败',
disabledHint: disabledHint:
'LDAP 未启用 —— 打开开关填写目录服务信息(服务器 / Bind DN / Base DN),保存后用户管理页将出现「从 LDAP 导入」', 'LDAP 未启用 —— 开启并填写目录服务信息后,用户管理页将出现「从 LDAP 导入」。',
hostLabel: '服务器地址 *', hostLabel: '服务器地址 *',
portLabel: '端口', portLabel: '端口',
ldapsLabel: 'LDAPS', ldapsLabel: 'LDAPS',
@@ -133,7 +133,7 @@ export default {
timezone: '判定时区', timezone: '判定时区',
timezoneAria: '主题定时切换时区', timezoneAria: '主题定时切换时区',
browser: '跟随浏览器(本地)', browser: '跟随浏览器(本地)',
autoHint: '按所选时区,{{start}}:00–{{end}}:00 使用浅色,其余时段深色;顶栏太阳/月亮可随时手动覆盖。', autoHint: '按所选时区 {{start}}:00–{{end}}:00 浅色,其余深色;顶栏可随时手动切换。',
}, },
prefsCard: { prefsCard: {
title: '外观与语言', title: '外观与语言',
+1 -1
View File
@@ -8,7 +8,7 @@ export default {
emptyDescription: '添加第一个 Loki 存储后端', emptyDescription: '添加第一个 Loki 存储后端',
// 删除确认文案(有部署引用时的警告;count 走中文无复数) // 删除确认文案(有部署引用时的警告;count 走中文无复数)
deleteWarning: deleteWarning:
'确定要删除「{{name}}」吗?警告:仍有 {{count}} 个部署使用该存储,删除后这些部署的对象存储配置将失效。', '确定删除「{{name}}」吗?仍有 {{count}} 个部署在使用,删除后其对象存储将失效。',
errDelete: '删除存储配置失败', errDelete: '删除存储配置失败',
errSetDefault: '设置默认存储失败', errSetDefault: '设置默认存储失败',
}, },
+2 -2
View File
@@ -67,7 +67,7 @@ export default {
noEmail: '无邮箱', noEmail: '无邮箱',
empty: '目录下没有用户', empty: '目录下没有用户',
importing: '导入中...', importing: '导入中...',
importCount_other: '导入 {{count}} 个用户', importCount: '导入 {{count}} 个用户',
importFailed: '导入失败', importFailed: '导入失败',
loadFailed: '加载 LDAP 用户失败', loadFailed: '加载 LDAP 用户失败',
}, },
@@ -78,7 +78,7 @@ export default {
clear: '清除选择', clear: '清除选择',
selectAll: '全选', selectAll: '全选',
confirmTitle: '确认批量删除', confirmTitle: '确认批量删除',
confirmDesc: '确定要删除选中的 {{count}} 个用户吗?受保护的账号(自己/最后一个管理员)将被跳过,此操作不可恢复。', confirmDesc: '删除选中的 {{count}} 个用户?受保护账号会跳过,操作不可恢复。',
skippedWarn: '{{count}} 项被跳过:{{reasons}}', skippedWarn: '{{count}} 项被跳过:{{reasons}}',
failed: '批量删除失败', failed: '批量删除失败',
}, },
+2 -2
View File
@@ -33,7 +33,7 @@ const wizard = {
ready: '已就绪', ready: '已就绪',
connected: '已接入:{{names}}', connected: '已接入:{{names}}',
nameSep: '、', nameSep: '、',
k8sHint: '通过 kubeconfig 或 ServiceAccount Token 接入集群,创建后自动验证连通性', k8sHint: 'kubeconfig 或 Token 接入,创建后自动验证连通性。',
addCluster: '添加集群', addCluster: '添加集群',
hostHint: 'SSH 密钥或密码接入主机(用于部署与后续采集器安装)', hostHint: 'SSH 密钥或密码接入主机(用于部署与后续采集器安装)',
addHost: '添加主机', addHost: '添加主机',
@@ -45,7 +45,7 @@ const wizard = {
manage: '管理存储', manage: '管理存储',
add: '添加存储', add: '添加存储',
configure: '配置存储', configure: '配置存储',
hint: '推荐对象存储(MinIO / RustFS / S3 / OSS):多副本与数据保留更可靠。添加时可用「测试连接」验证凭证,并自动创建不存在的桶;跳过则使用本地磁盘。', hint: '推荐对象存储(MinIO / RustFS / S3 / OSS),更可靠;跳过则使用本地磁盘。',
}, },
deploy: { deploy: {
meta: '{{mode}} · 命名空间 {{namespace}} · {{endpoint}}', meta: '{{mode}} · 命名空间 {{namespace}} · {{endpoint}}',
+4 -5
View File
@@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router' import { Link } from 'react-router'
import { BellRing, Plus, Play, Pencil, Trash2 } from 'lucide-react' import { BellRing, Plus, Play, Pencil, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
@@ -380,14 +381,12 @@ const AlertsPage = memo(function AlertsPage() {
const checked = field.value.includes(String(ch.id)) const checked = field.value.includes(String(ch.id))
return ( return (
<label key={ch.id} className={`flex items-center gap-2 text-sm cursor-pointer rounded-md border px-2.5 py-1.5 ${checked ? 'border-primary bg-primary/5 text-primary' : 'border-border'}`}> <label key={ch.id} className={`flex items-center gap-2 text-sm cursor-pointer rounded-md border px-2.5 py-1.5 ${checked ? 'border-primary bg-primary/5 text-primary' : 'border-border'}`}>
<input <Checkbox
type="checkbox"
checked={checked} checked={checked}
onChange={(e) => { onCheckedChange={(v) => {
if (e.target.checked) field.onChange([...field.value, String(ch.id)]) if (v) field.onChange([...field.value, String(ch.id)])
else field.onChange(field.value.filter((x) => x !== String(ch.id))) else field.onChange(field.value.filter((x) => x !== String(ch.id)))
}} }}
className="accent-primary"
/> />
{ch.name} {ch.name}
{!ch.enabled && <span className="text-xs text-muted-foreground">{t('form.channelDisabled')}</span>} {!ch.enabled && <span className="text-xs text-muted-foreground">{t('form.channelDisabled')}</span>}
@@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import type { Collector } from '@/types' import type { Collector } from '@/types'
// 表单 schema:错误消息经 t 注入(随语言重建,RHF 每次渲染刷新 resolver) // 表单 schema:错误消息经 t 注入(随语言重建,RHF 每次渲染刷新 resolver)
@@ -153,7 +154,7 @@ export const CollectorEditDialog = memo(function CollectorEditDialog({ collector
{nsError && <p className="text-xs text-destructive">{nsError}</p>} {nsError && <p className="text-xs text-destructive">{nsError}</p>}
<div className="rounded-md border"> <div className="rounded-md border">
<label className="flex items-center gap-2 border-b px-3 py-2 text-sm font-medium cursor-pointer hover:bg-muted/50"> <label className="flex items-center gap-2 border-b px-3 py-2 text-sm font-medium cursor-pointer hover:bg-muted/50">
<input type="checkbox" checked={allSelected} onChange={setAll} /> <Checkbox checked={allSelected} onCheckedChange={() => setAll()} />
{t('edit.allCheckbox')} {t('edit.allCheckbox')}
</label> </label>
<div className="p-2"> <div className="p-2">
@@ -60,7 +60,7 @@ export const RecentDeploymentsCard = memo(function RecentDeploymentsCard({
<p className="text-sm font-medium text-foreground truncate"> <p className="text-sm font-medium text-foreground truncate">
{d.name} {d.name}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="truncate text-xs text-muted-foreground">
{d.mode} &middot; {d.namespace || 'loki'} {d.mode} &middot; {d.namespace || 'loki'}
</p> </p>
</div> </div>
@@ -79,7 +79,7 @@ export const ResourceListCard = memo(function ResourceListCard({
)} )}
</div> </div>
<span <span
className={`px-2 py-0.5 rounded-full text-xs flex-shrink-0 ${getStatusColorClass( className={`px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0 ${getStatusColorClass(
getItemStatus(item) getItemStatus(item)
)}`} )}`}
> >
+2 -2
View File
@@ -46,7 +46,7 @@ const DashboardPage = memo(function DashboardPage() {
label={t('stat.totalDeployments')} label={t('stat.totalDeployments')}
value={stats.totalDeployments} value={stats.totalDeployments}
icon={Rocket} icon={Rocket}
iconColor="text-primary" iconColor="text-success"
iconBg="bg-success/10" iconBg="bg-success/10"
sub={{ sub={{
icon: TrendingUp, icon: TrendingUp,
@@ -100,7 +100,7 @@ const DashboardPage = memo(function DashboardPage() {
{/* 快速开始引导:尚无任何部署时置顶(状态推导式引导) */} {/* 快速开始引导:尚无任何部署时置顶(状态推导式引导) */}
{(data?.stats.totalDeployments ?? 0) === 0 && ( {(data?.stats.totalDeployments ?? 0) === 0 && (
<Card className="border-primary/30 bg-primary/5"> <Card className="border-primary/30 bg-primary/5">
<CardContent className="p-5 flex items-center justify-between flex-wrap gap-3"> <CardContent className="p-6 flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center"> <div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<Wand2 className="w-5 h-5 text-primary" /> <Wand2 className="w-5 h-5 text-primary" />
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { ChevronDown, ChevronRight, Tags } from 'lucide-react' import { ChevronDown, ChevronRight, Tags } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { logsApi } from '@/api/logs' import { logsApi } from '@/api/logs'
import { applyLabelFilter, extractSelector } from '../query-utils' import { applyLabelFilter, extractSelector } from '../query-utils'
@@ -76,7 +77,7 @@ export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }:
} }
} }
return <section className="overflow-hidden rounded-lg border bg-card" aria-label={t('labels.browser')}> return <section className="overflow-hidden rounded-xl border bg-card" aria-label={t('labels.browser')}>
<button type="button" className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm font-medium hover:bg-muted/50" aria-expanded={open} onClick={() => { setOpen(!open); if (!open) setLoadingLabels(true) }}> <button type="button" className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm font-medium hover:bg-muted/50" aria-expanded={open} onClick={() => { setOpen(!open); if (!open) setLoadingLabels(true) }}>
{open ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />} {open ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
<Tags className="size-4 text-info" />{t('labels.browser')} <Tags className="size-4 text-info" />{t('labels.browser')}
@@ -88,7 +89,7 @@ export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }:
</div> </div>
<div className="min-w-0 p-3"> <div className="min-w-0 p-3">
{!activeLabel ? <p className="py-12 text-center text-sm text-muted-foreground">{t('labels.pickHint')}</p> : <><div className="mb-3 flex flex-wrap items-center gap-2"><strong className="text-sm">{t('labels.valuesOf', { label: activeLabel })}</strong><Input aria-label={t('labels.searchAria', { label: activeLabel })} className="h-8 max-w-sm" placeholder={t('labels.searchPlaceholder')} value={filter} onChange={event => setFilter(event.target.value)} /></div> {!activeLabel ? <p className="py-12 text-center text-sm text-muted-foreground">{t('labels.pickHint')}</p> : <><div className="mb-3 flex flex-wrap items-center gap-2"><strong className="text-sm">{t('labels.valuesOf', { label: activeLabel })}</strong><Input aria-label={t('labels.searchAria', { label: activeLabel })} className="h-8 max-w-sm" placeholder={t('labels.searchPlaceholder')} value={filter} onChange={event => setFilter(event.target.value)} /></div>
<label className="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground"><input type="checkbox" checked={scoped} onChange={event => setScoped(event.target.checked)} />{t('labels.scoped')}</label> <label className="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground"><Checkbox checked={scoped} onCheckedChange={v => setScoped(Boolean(v))} />{t('labels.scoped')}</label>
<div className="grid max-h-48 gap-1 overflow-auto sm:grid-cols-2 xl:grid-cols-3">{loadingValues ? <p className="p-2 text-sm text-muted-foreground">{t('labels.loading')}</p> : shown.length === 0 ? <p className="p-2 text-sm text-muted-foreground">{t('labels.noValues')}</p> : shown.map(value => <button key={value} type="button" title={value} onClick={() => choose(activeLabel, value)} className={`truncate rounded px-2 py-1.5 text-left font-mono text-sm ${selected[activeLabel] === value ? 'bg-success/10 text-success' : 'hover:bg-muted'}`}>{value}</button>)}</div> <div className="grid max-h-48 gap-1 overflow-auto sm:grid-cols-2 xl:grid-cols-3">{loadingValues ? <p className="p-2 text-sm text-muted-foreground">{t('labels.loading')}</p> : shown.length === 0 ? <p className="p-2 text-sm text-muted-foreground">{t('labels.noValues')}</p> : shown.map(value => <button key={value} type="button" title={value} onClick={() => choose(activeLabel, value)} className={`truncate rounded px-2 py-1.5 text-left font-mono text-sm ${selected[activeLabel] === value ? 'bg-success/10 text-success' : 'hover:bg-muted'}`}>{value}</button>)}</div>
{filter.trim() && !values.includes(filter.trim()) && <button type="button" className="mt-2 rounded border px-2 py-1 text-xs text-primary hover:bg-muted" onClick={() => choose(activeLabel, filter.trim())}>{t('labels.useAsValue', { value: filter.trim() })}</button>} {filter.trim() && !values.includes(filter.trim()) && <button type="button" className="mt-2 rounded border px-2 py-1 text-xs text-primary hover:bg-muted" onClick={() => choose(activeLabel, filter.trim())}>{t('labels.useAsValue', { value: filter.trim() })}</button>}
<p className="mt-2 text-xs text-muted-foreground">{t('labels.hint')}</p> <p className="mt-2 text-xs text-muted-foreground">{t('labels.hint')}</p>
@@ -86,7 +86,7 @@ export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
const sourceName = (id: number) => sources.find(source => source.id === id)?.name ?? t('access.sourceRef', { id }) const sourceName = (id: number) => sources.find(source => source.id === id)?.name ?? t('access.sourceRef', { id })
return <div className="grid gap-6 xl:grid-cols-[minmax(240px,0.85fr)_minmax(0,2fr)]"> return <div className="grid gap-6 xl:grid-cols-[minmax(240px,0.85fr)_minmax(0,2fr)]">
<aside className="rounded-xl border bg-card p-5 space-y-4"> <aside className="rounded-xl border bg-card p-4 space-y-4">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h2 className="font-semibold">{t('access.listTitle')} <span className="text-sm font-normal text-muted-foreground">({groups.length})</span></h2> <h2 className="font-semibold">{t('access.listTitle')} <span className="text-sm font-normal text-muted-foreground">({groups.length})</span></h2>
<Button size="sm" onClick={() => setForm(emptyForm(sources[0]?.id ?? 0))}><Plus className="mr-1 size-4" />{t('access.create')}</Button> <Button size="sm" onClick={() => setForm(emptyForm(sources[0]?.id ?? 0))}><Plus className="mr-1 size-4" />{t('access.create')}</Button>
@@ -96,7 +96,7 @@ export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
{error && <ErrorState message={error} onRetry={() => void reload()} />} {error && <ErrorState message={error} onRetry={() => void reload()} />}
{!loading && !error && groups.length === 0 && <p className="text-sm text-muted-foreground">{t('access.empty')}</p>} {!loading && !error && groups.length === 0 && <p className="text-sm text-muted-foreground">{t('access.empty')}</p>}
<div className="space-y-2">{groups.map(group => <button key={group.id} type="button" onClick={() => setForm({ ...group, labels: { ...group.labels }, userIds: [...group.userIds] })} className={`w-full rounded-lg border p-3 text-left transition-colors ${form.id === group.id ? 'border-primary bg-primary/5' : 'hover:bg-muted'}`}> <div className="space-y-2">{groups.map(group => <button key={group.id} type="button" onClick={() => setForm({ ...group, labels: { ...group.labels }, userIds: [...group.userIds] })} className={`w-full rounded-lg border p-3 text-left transition-colors ${form.id === group.id ? 'border-primary bg-primary/5' : 'hover:bg-muted'}`}>
<strong className="block text-sm">{group.name}</strong><span className="block mt-1 text-xs text-muted-foreground">{sourceName(group.sourceId)} · {group.userIds.includes(ANYONE) ? t('access.anyone') : t('access.userCount', { count: group.userIds.length })}</span>{Object.keys(group.labels).length > 0 ? ( <strong className="block text-sm">{group.name}</strong><span className="block mt-1 truncate text-xs text-muted-foreground">{sourceName(group.sourceId)} · {group.userIds.includes(ANYONE) ? t('access.anyone') : t('access.userCount', { count: group.userIds.length })}</span>{Object.keys(group.labels).length > 0 ? (
<span className="mt-1 flex flex-wrap gap-1"> <span className="mt-1 flex flex-wrap gap-1">
{Object.entries(group.labels).map(([key, value]) => <span key={key} className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground">{key}={value}</span>)} {Object.entries(group.labels).map(([key, value]) => <span key={key} className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground">{key}={value}</span>)}
</span> </span>
@@ -105,7 +105,7 @@ export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
)} )}
</button>)}</div> </button>)}</div>
</aside> </aside>
<section className="rounded-xl border bg-card p-5 space-y-6"> <section className="rounded-xl border bg-card p-4 space-y-6">
<div className="flex items-start justify-between gap-3"><div><h2 className="font-semibold">{form.id ? t('access.editTitle') : t('access.createTitle')}</h2><p className="mt-1 text-sm text-muted-foreground">{t('access.formHint')}</p></div>{form.id && <Button variant="outline" size="sm" className="text-destructive" onClick={() => setDeleteTarget(groups.find(group => group.id === form.id) ?? null)}>{t('access.remove')}</Button>}</div> <div className="flex items-start justify-between gap-3"><div><h2 className="font-semibold">{form.id ? t('access.editTitle') : t('access.createTitle')}</h2><p className="mt-1 text-sm text-muted-foreground">{t('access.formHint')}</p></div>{form.id && <Button variant="outline" size="sm" className="text-destructive" onClick={() => setDeleteTarget(groups.find(group => group.id === form.id) ?? null)}>{t('access.remove')}</Button>}</div>
<div className="grid gap-4 sm:grid-cols-2"><label className="text-sm font-medium">{t('access.nameLabel')}<Input className="mt-1" value={form.name} onChange={event => setForm(current => ({ ...current, name: event.target.value }))} placeholder={t('access.namePlaceholder')} /></label><label className="text-sm font-medium">{t('access.sourceLabel')} <div className="grid gap-4 sm:grid-cols-2"><label className="text-sm font-medium">{t('access.nameLabel')}<Input className="mt-1" value={form.name} onChange={event => setForm(current => ({ ...current, name: event.target.value }))} placeholder={t('access.namePlaceholder')} /></label><label className="text-sm font-medium">{t('access.sourceLabel')}
<Select <Select